규약 세부화(유형D·13석 표결·툴팁) + 밸런스 피드백 5건 반영
같은 날 두 차례 플레이테스트에서 나온 이슈를 이어서 처리했다.
**의회 세부화**
- 유형 D(이득 규약) 5종 신설 — 토큰 지급/게이지 완화/지출 가산 완화/
주목도 한도 증가/실적 요구치 하향. 내부 명분("강제 상정")이 여태 불리한
유형 A만 올릴 수 있어 실속 없던 문제를 해결 — 상정 풀에 D가 포함되는 게
내부 명분이 존재하는 이유
- 13석 표결을 실제 표 수로 구현(tallyVotes, 최대잔여법 + 결정적 해시).
혼합 명분이 "직접 통과 지정" 자리표시자에서 §7.3 스펙대로 실제 1표
이동으로 교체 — 동점은 스윙 전 득표가 많던 쪽이 이겨 1표 차에서만 뒤집힘.
의회 화면에 후보별 예상 득표·표 차·뒤집기 가능 여부를 미리 노출
- 공보 축 오염 선택지 1→7개로 보강(서사 기준 6개 재배정), 축 분포 균형화
**툴팁 신설 (InfoTooltip·ProtocolChip)**
- 규약·징벌·명분을 클릭하면 정확히 무엇이 막혔는지 설명이 뜬다. 문구는
UI가 추측하지 않고 council.js의 protocolEffectDetail·justificationDetail이
카탈로그에서 생성. 모바일 대상이라 hover가 아닌 클릭으로 연다
**크리티컬 버그: 주목도 미초기화**
- 라운드 종료 후 한도 판정에 총 주목도(이월 포함)를 쓰고 있어, 2라운드가
시작하자마자 이미 한도를 넘어 버스트가 무한 반복되고 있었다. 한도 판정은
"이번 라운드에 받은 카드"만 세도록 분리(roundAttention), 게이지·토큰
가산은 기존대로 총계(이월 포함) 기준 유지
**밸런스 피드백 5건**
- 결재 카드가 고정으로 보인다는 지적 → 실제 버그였다. cardIndex를 그냥
+1씩 돌려 CSV 순서 그대로 결정론적으로 반복되고 있었다. Fisher–Yates
셔플 큐로 교체(큐 소진 시 재셔플)
- 위협 축도 거의 안 나온다 → 위 셔플 버그가 원인의 상당 부분. 명백한
오배정 1건도 발견해 수정(id11 "군사 개입 지시": 고대 신 소환 저지가
인사 축으로 잘못 들어가 있었음 → 위협 축)
- 게이지 상승이 너무 낮음 → 등급 합계(주목도) 단독 기준이 실제 카드
등급 분포(대부분 1~4급)와 맞지 않아 3~4장을 쌓아도 최하 구간을 못
벗어났다. 카드 장수를 1차 기준으로 되돌리고 등급 배율(평균등급/5)을
곱하는 방식으로 재설계 — 3~4장 구간에서 6배 점프(0.5%→3.0%)
- 좌우 모두 오염 없는 완전 무행동 카드 1건(id15) 수정 — 좌측에 대외/6급
부여(기존 tag_corruption과 정합)
- 오염 카드 한쪽만 있어 안전 선택 메리트가 없다는 지적은 카드 데이터가
아니라 게이지 곡선 문제로 판단, 위 재설계로 해결(카드 재배분 안 함)
**문서**
- 결정 노트 2건: 2026-09-17-주목도-라운드-분리,
2026-09-17-게이지상승-장수기준-복원(기각한 대안 포함)
- 기획서 §4.2-A/B, §7.3, §9.2, §17 갱신. DEVLOG 2건 기록
Playwright로 전 구간 검증: 셔플(3회 재접속 시 다른 카드, 15연속 무반복),
게이지 상승률 표시, 13석 표결 예상치, 규약/명분 툴팁, 2라운드 진입 시
주목도 0으로 리셋 확인. 콘솔 에러 0건, 빌드 클린.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0a065068c1
commit
6398599199
@@ -1,7 +1,8 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import SymbolBadge from './SymbolBadge';
|
import SymbolBadge from './SymbolBadge';
|
||||||
import { detectHands, resolveCombine, MAX_COMBINE_CARDS, MIN_COMBINE_CARDS } from '../engine/hands';
|
import { detectHands, resolveCombine, MAX_COMBINE_CARDS, MIN_COMBINE_CARDS } from '../engine/hands';
|
||||||
import { filterHandsByProtocols } from '../engine/council';
|
import { filterHandsByProtocols, protocolEffectDetail } from '../engine/council';
|
||||||
|
import ProtocolChip from './ProtocolChip';
|
||||||
import './CombinePanel.css';
|
import './CombinePanel.css';
|
||||||
|
|
||||||
// 합본 결재 (§5) — 책상에서 카드를 다중 선택하면 성립 가능한 족보를 자동 판정해 보여준다.
|
// 합본 결재 (§5) — 책상에서 카드를 다중 선택하면 성립 가능한 족보를 자동 판정해 보여준다.
|
||||||
@@ -56,7 +57,7 @@ export default function CombinePanel({ deskCards, onConfirm, onClose, activeProt
|
|||||||
{activeProtocols.length > 0 && (
|
{activeProtocols.length > 0 && (
|
||||||
<div className="combine-protocols">
|
<div className="combine-protocols">
|
||||||
{activeProtocols.map((p) => (
|
{activeProtocols.map((p) => (
|
||||||
<span key={p.id} className="combine-protocol-chip">⚖ {p.name}</span>
|
<ProtocolChip key={p.id} protocol={p} prefix="⚖ " />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -84,7 +85,10 @@ export default function CombinePanel({ deskCards, onConfirm, onClose, activeProt
|
|||||||
<p className="combine-no-match">선택한 {selection.length}장은 성립하는 족보가 없습니다.</p>
|
<p className="combine-no-match">선택한 {selection.length}장은 성립하는 족보가 없습니다.</p>
|
||||||
)}
|
)}
|
||||||
{blockedByProtocol && (
|
{blockedByProtocol && (
|
||||||
<p className="combine-blocked">규약 때문에 이 선택은 합본할 수 없습니다.</p>
|
<p className="combine-blocked">
|
||||||
|
규약 때문에 이 선택은 합본할 수 없습니다 —{' '}
|
||||||
|
{activeProtocols.map((p) => protocolEffectDetail(p).summary).join(' · ')}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{matches.map((patternId) => {
|
{matches.map((patternId) => {
|
||||||
const preview = resolveCombine(selection, patternId);
|
const preview = resolveCombine(selection, patternId);
|
||||||
|
|||||||
@@ -5,17 +5,19 @@ import DeskBar from './DeskBar';
|
|||||||
import CoreSwipeCard from './CoreSwipeCard';
|
import CoreSwipeCard from './CoreSwipeCard';
|
||||||
import CombinePanel from './CombinePanel';
|
import CombinePanel from './CombinePanel';
|
||||||
import HandGuidePopup from './HandGuidePopup';
|
import HandGuidePopup from './HandGuidePopup';
|
||||||
|
import ProtocolChip from './ProtocolChip';
|
||||||
import ShopScreen from './ShopScreen';
|
import ShopScreen from './ShopScreen';
|
||||||
import CouncilScreen from './CouncilScreen';
|
import CouncilScreen from './CouncilScreen';
|
||||||
import { pickPollutionEntry } from '../engine/pollutionPool';
|
import { pickPollutionEntry } from '../engine/pollutionPool';
|
||||||
import {
|
import {
|
||||||
makeDeskCard, applyPassiveGaugeRise, addToDesk, removeFromDesk, tokenSurcharge,
|
makeDeskCard, applyPassiveGaugeRise, addToDesk, removeFromDesk, tokenSurcharge,
|
||||||
totalAttention, roundAttentionCap,
|
roundAttention, roundAttentionCap,
|
||||||
} from '../engine/desk';
|
} from '../engine/desk';
|
||||||
import { rollShopDisplay, purgePrice, REROLL_COST } from '../engine/shop';
|
import { rollShopDisplay, purgePrice, REROLL_COST } from '../engine/shop';
|
||||||
import {
|
import {
|
||||||
requiredScore, settleScore, rollProtocolCandidates, resolveProtocolVote, rollPenalty,
|
requiredScore, settleScore, rollProtocolCandidates, resolveProtocolVote, rollPenalty,
|
||||||
applyInterventions, penaltyGaugeSurcharge, penaltyTokenSurcharge,
|
applyInterventions, gaugeSurchargeOf, tokenSurchargeOf, attentionCapBonusOf,
|
||||||
|
requiredScoreDeltaOf, grantTokenOf, tallyVotes, applyVoteSwing, COUNCIL_SEATS,
|
||||||
} from '../engine/council';
|
} from '../engine/council';
|
||||||
import './CoreGameScreen.css';
|
import './CoreGameScreen.css';
|
||||||
|
|
||||||
@@ -57,13 +59,27 @@ const buildCard = (row) => ({
|
|||||||
|
|
||||||
const INITIAL_GAUGES = { entropy: 0, resistance: 0, panic: 0, risk: 0 };
|
const INITIAL_GAUGES = { entropy: 0, resistance: 0, panic: 0, risk: 0 };
|
||||||
|
|
||||||
|
// 2026-09-17 피드백: 결재 카드가 "고정으로 등장하는 것 같다"는 지적 — 실제로 버그였다.
|
||||||
|
// 예전엔 cardIndex를 그냥 +1씩 돌려서 CSV 파일 순서 그대로, 매 실행마다 똑같은 순서로
|
||||||
|
// 반복됐다(진짜 무작위가 아니라 결정론적 순환). Fisher–Yates로 섞은 뒤 앞에서부터 뽑고,
|
||||||
|
// 큐가 비면 다시 섞어 채운다 — 한 바퀴 안에서는 같은 카드가 두 번 나오지 않으면서도
|
||||||
|
// 실행할 때마다, 그리고 한 바퀴가 끝날 때마다 순서가 달라진다.
|
||||||
|
const shuffle = (arr) => {
|
||||||
|
const a = [...arr];
|
||||||
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[a[i], a[j]] = [a[j], a[i]];
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
};
|
||||||
|
|
||||||
export default function CoreGameScreen() {
|
export default function CoreGameScreen() {
|
||||||
const deck = useMemo(() => {
|
const deck = useMemo(() => {
|
||||||
const parsed = Papa.parse(cardsCsvRaw, { header: true, skipEmptyLines: true });
|
const parsed = Papa.parse(cardsCsvRaw, { header: true, skipEmptyLines: true });
|
||||||
return parsed.data.map(buildCard);
|
return parsed.data.map(buildCard);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [cardIndex, setCardIndex] = useState(0);
|
const [drawQueue, setDrawQueue] = useState(() => shuffle(deck));
|
||||||
const [deskCards, setDeskCards] = useState([]);
|
const [deskCards, setDeskCards] = useState([]);
|
||||||
const [gauges, setGauges] = useState(INITIAL_GAUGES);
|
const [gauges, setGauges] = useState(INITIAL_GAUGES);
|
||||||
const [tokens, setTokens] = useState(INITIAL_TOKENS);
|
const [tokens, setTokens] = useState(INITIAL_TOKENS);
|
||||||
@@ -88,13 +104,22 @@ export default function CoreGameScreen() {
|
|||||||
const pushLog = (msg) => setLog((prev) => [msg, ...prev].slice(0, 6));
|
const pushLog = (msg) => setLog((prev) => [msg, ...prev].slice(0, 6));
|
||||||
|
|
||||||
const activeProtocols = activeProtocol ? [activeProtocol] : [];
|
const activeProtocols = activeProtocol ? [activeProtocol] : [];
|
||||||
const gaugeExtraFlat = penaltyGaugeSurcharge(penalties);
|
// 징벌(유형 C)과 활성 규약(유형 A/D)이 같은 수치를 건드리므로 합쳐서 합산한다.
|
||||||
const tokenExtraFlat = penaltyTokenSurcharge(penalties);
|
// 이득 규약이면 음수라 페널티가 상쇄된다.
|
||||||
|
const effectiveProtocols = [...penalties, ...activeProtocols];
|
||||||
|
const gaugeExtraFlat = gaugeSurchargeOf(effectiveProtocols);
|
||||||
|
const tokenExtraFlat = tokenSurchargeOf(effectiveProtocols);
|
||||||
const surcharge = tokenSurcharge(deskCards, tokenExtraFlat);
|
const surcharge = tokenSurcharge(deskCards, tokenExtraFlat);
|
||||||
const justificationTotal = Object.values(justifications).reduce((a, b) => a + b, 0);
|
const justificationTotal = Object.values(justifications).reduce((a, b) => a + b, 0);
|
||||||
const attentionCap = roundAttentionCap(round);
|
const attentionCap = roundAttentionCap(round, attentionCapBonusOf(activeProtocols));
|
||||||
|
|
||||||
const currentCard = deck.length > 0 ? deck[cardIndex % deck.length] : null;
|
const currentCard = drawQueue[0] || null;
|
||||||
|
const advanceCard = () => {
|
||||||
|
setDrawQueue((prev) => {
|
||||||
|
const rest = prev.slice(1);
|
||||||
|
return rest.length > 0 ? rest : shuffle(deck);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const checkGameOver = (nextGauges) => {
|
const checkGameOver = (nextGauges) => {
|
||||||
const hit = Object.entries(nextGauges).find(([, v]) => v >= 100);
|
const hit = Object.entries(nextGauges).find(([, v]) => v >= 100);
|
||||||
@@ -131,7 +156,7 @@ export default function CoreGameScreen() {
|
|||||||
if (side.axis) {
|
if (side.axis) {
|
||||||
const grade = side.grade || 1;
|
const grade = side.grade || 1;
|
||||||
const entry = pickPollutionEntry(side.axis, grade);
|
const entry = pickPollutionEntry(side.axis, grade);
|
||||||
const newCard = makeDeskCard(side.axis, grade, entry || {});
|
const newCard = makeDeskCard(side.axis, grade, entry || {}, round);
|
||||||
nextDesk = addToDesk(deskCards, [newCard]);
|
nextDesk = addToDesk(deskCards, [newCard]);
|
||||||
setDeskCards(nextDesk);
|
setDeskCards(nextDesk);
|
||||||
pushLog(`『${side.text}』 결재됨 → 오염 서류 『${entry?.name || '미상'}』(등급 ${grade}) 접수`);
|
pushLog(`『${side.text}』 결재됨 → 오염 서류 『${entry?.name || '미상'}』(등급 ${grade}) 접수`);
|
||||||
@@ -139,12 +164,14 @@ export default function CoreGameScreen() {
|
|||||||
pushLog(`『${side.text}』 결재됨 — 오염 없음`);
|
pushLog(`『${side.text}』 결재됨 — 오염 없음`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCardIndex((i) => (i + 1) % deck.length);
|
advanceCard();
|
||||||
|
|
||||||
if (checkGameOver(nextGauges)) return;
|
if (checkGameOver(nextGauges)) return;
|
||||||
|
|
||||||
// §4.2-B: 이번 라운드 한도를 넘기면 합본 기회 없이 그 즉시 강제 종료된다. 카드는 이월(§4.1).
|
// §4.2-B: 이번 라운드 한도를 넘기면 합본 기회 없이 그 즉시 강제 종료된다. 카드는 이월(§4.1).
|
||||||
if (totalAttention(nextDesk) > attentionCap) {
|
// 이월 카드는 한도 판정에서 빠진다(roundAttention) — 안 그러면 다음 라운드가 시작하자마자
|
||||||
|
// 버스트가 반복된다.
|
||||||
|
if (roundAttention(nextDesk, round) > attentionCap) {
|
||||||
pushLog(`주목도 초과(한도 ${attentionCap}) — 라운드 강제 종료, 합본 기회 없이 정산으로 이동`);
|
pushLog(`주목도 초과(한도 ${attentionCap}) — 라운드 강제 종료, 합본 기회 없이 정산으로 이동`);
|
||||||
goToShop();
|
goToShop();
|
||||||
return;
|
return;
|
||||||
@@ -154,7 +181,7 @@ export default function CoreGameScreen() {
|
|||||||
/** 플레이어가 한도 밑에서 스스로 라운드를 끝내는 "정산하기" — 블랙잭의 스탠드. */
|
/** 플레이어가 한도 밑에서 스스로 라운드를 끝내는 "정산하기" — 블랙잭의 스탠드. */
|
||||||
const handleEndRound = () => {
|
const handleEndRound = () => {
|
||||||
if (phase !== 'approval') return;
|
if (phase !== 'approval') return;
|
||||||
pushLog(`제${round}라운드 자율 종료(정산하기) — 주목도 ${totalAttention(deskCards)}/${attentionCap}`);
|
pushLog(`제${round}라운드 자율 종료(정산하기) — 주목도 ${roundAttention(deskCards, round)}/${attentionCap}`);
|
||||||
goToShop();
|
goToShop();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -201,10 +228,11 @@ export default function CoreGameScreen() {
|
|||||||
|
|
||||||
/* ── 의회 (§9) ── */
|
/* ── 의회 (§9) ── */
|
||||||
const handleCouncilResolve = ({ tokensPaid, justificationsPaidCount, interventions }) => {
|
const handleCouncilResolve = ({ tokensPaid, justificationsPaidCount, interventions }) => {
|
||||||
const { candidates: remainingCandidates, forcedWinnerId, deferred, penaltyIdToClear } =
|
const { candidates: remainingCandidates, voteSwingTargetId, deferred, penaltyIdToClear } =
|
||||||
applyInterventions(councilCandidates, interventions);
|
applyInterventions(councilCandidates, interventions);
|
||||||
|
|
||||||
const finalRequired = requiredScore(round);
|
// 이번 라운드 내내 적용 중이던 규약이 실적 요구치를 낮췄을 수 있다 (유형 D).
|
||||||
|
const finalRequired = Math.max(0, requiredScore(round) + requiredScoreDeltaOf(activeProtocols));
|
||||||
// §9.3: 실적 점수 = Σ(이번 라운드 족보 점수) + 토큰 납입×5 + 명분 납입×20.
|
// §9.3: 실적 점수 = Σ(이번 라운드 족보 점수) + 토큰 납입×5 + 명분 납입×20.
|
||||||
const finalScore = settleScore({
|
const finalScore = settleScore({
|
||||||
combineScore: roundScore, tokensPaid, justificationsPaid: justificationsPaidCount,
|
combineScore: roundScore, tokensPaid, justificationsPaid: justificationsPaidCount,
|
||||||
@@ -241,12 +269,20 @@ export default function CoreGameScreen() {
|
|||||||
pushLog(`절차 명분으로 『${deferred.map((d) => d.name).join(', ')}』 다음 라운드 이월`);
|
pushLog(`절차 명분으로 『${deferred.map((d) => d.name).join(', ')}』 다음 라운드 이월`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const winner = forcedWinnerId
|
// §9.2 13석 표결. 혼합 명분은 1표만 움직이므로 1표 차 승부에서만 결과가 뒤집힌다 (§7.3).
|
||||||
? remainingCandidates.find((c) => c.id === forcedWinnerId) || null
|
const tallied = applyVoteSwing(tallyVotes(remainingCandidates), voteSwingTargetId);
|
||||||
: resolveProtocolVote(remainingCandidates);
|
const winner = resolveProtocolVote(tallied);
|
||||||
setActiveProtocol(winner);
|
setActiveProtocol(winner);
|
||||||
if (winner) pushLog(`규약 통과: 『${winner.name}』 — 다음 라운드 적용`);
|
if (winner) {
|
||||||
else pushLog('통과된 규약 없음 — 다음 라운드는 제약 없이 진행됩니다.');
|
pushLog(`규약 통과: 『${winner.name}』 ${winner.votes}/${COUNCIL_SEATS}표 — 다음 라운드 적용`);
|
||||||
|
const grant = grantTokenOf(winner);
|
||||||
|
if (grant > 0) {
|
||||||
|
setTokens((prev) => prev + grant);
|
||||||
|
pushLog(`이득 규약 『${winner.name}』 — 토큰 +${grant} 즉시 지급`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pushLog('통과된 규약 없음 — 다음 라운드는 제약 없이 진행됩니다.');
|
||||||
|
}
|
||||||
|
|
||||||
setRound((r) => r + 1);
|
setRound((r) => r + 1);
|
||||||
setRoundScore(0);
|
setRoundScore(0);
|
||||||
@@ -277,11 +313,10 @@ export default function CoreGameScreen() {
|
|||||||
deskCards={deskCards}
|
deskCards={deskCards}
|
||||||
gauges={gauges}
|
gauges={gauges}
|
||||||
tokens={tokens}
|
tokens={tokens}
|
||||||
|
round={round}
|
||||||
attentionCap={attentionCap}
|
attentionCap={attentionCap}
|
||||||
onOpenCombine={() => setShowCombine(true)}
|
onOpenCombine={() => setShowCombine(true)}
|
||||||
onOpenGuide={() => setShowGuide(true)}
|
onOpenGuide={() => setShowGuide(true)}
|
||||||
onEndRound={handleEndRound}
|
|
||||||
canEndRound={phase === 'approval'}
|
|
||||||
gaugeExtraFlat={gaugeExtraFlat}
|
gaugeExtraFlat={gaugeExtraFlat}
|
||||||
tokenExtraFlat={tokenExtraFlat}
|
tokenExtraFlat={tokenExtraFlat}
|
||||||
/>
|
/>
|
||||||
@@ -290,11 +325,19 @@ export default function CoreGameScreen() {
|
|||||||
<span>제{round}라운드</span>
|
<span>제{round}라운드</span>
|
||||||
<span>실적 {score} (이번 라운드 {roundScore})</span>
|
<span>실적 {score} (이번 라운드 {roundScore})</span>
|
||||||
<span>명분 {justificationTotal}개</span>
|
<span>명분 {justificationTotal}개</span>
|
||||||
{penalties.length > 0 && <span className="core-status-penalty">⚠ 징벌 {penalties.length}</span>}
|
{activeProtocol && <ProtocolChip protocol={activeProtocol} prefix="⚖ " />}
|
||||||
{activeProtocol && <span className="core-status-protocol">⚖ {activeProtocol.name}</span>}
|
{penalties.map((p) => <ProtocolChip key={p.id} protocol={p} prefix="⚠ " />)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{phase === 'approval' && <CoreSwipeCard card={currentCard} onSwipe={handleSwipe} />}
|
{phase === 'approval' && (
|
||||||
|
<CoreSwipeCard
|
||||||
|
card={currentCard}
|
||||||
|
onSwipe={handleSwipe}
|
||||||
|
onEndRound={handleEndRound}
|
||||||
|
roundAttention={roundAttention(deskCards, round)}
|
||||||
|
attentionCap={attentionCap}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{phase === 'shop' && (
|
{phase === 'shop' && (
|
||||||
<ShopScreen
|
<ShopScreen
|
||||||
@@ -316,6 +359,7 @@ export default function CoreGameScreen() {
|
|||||||
justifications={justifications}
|
justifications={justifications}
|
||||||
penalties={penalties}
|
penalties={penalties}
|
||||||
candidates={councilCandidates}
|
candidates={councilCandidates}
|
||||||
|
requiredDelta={requiredScoreDeltaOf(activeProtocols)}
|
||||||
onResolve={handleCouncilResolve}
|
onResolve={handleCouncilResolve}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -123,3 +123,50 @@
|
|||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 라운드 종료 액션 — 결재 카드 바로 아래. 주목도/한도를 같이 보여줘야 "한 장 더"가 판단이 된다. */
|
||||||
|
.core-round-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: min(420px, 92vw);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-round-action.zone-warning { border-color: var(--color-risk-critical); }
|
||||||
|
.core-round-action.zone-bust { border-color: var(--symbol-threat, #ff7b6b); }
|
||||||
|
|
||||||
|
.core-round-attention {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-round-attention strong {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-warning .core-round-attention strong { color: var(--color-risk-critical); }
|
||||||
|
.zone-bust .core-round-attention strong { color: var(--symbol-threat, #ff7b6b); }
|
||||||
|
|
||||||
|
.core-round-remaining { opacity: 0.6; }
|
||||||
|
|
||||||
|
.core-end-round-btn {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--color-token-info);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 9px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-end-round-btn:hover { filter: brightness(1.15); }
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import './CoreSwipeCard.css';
|
|||||||
// 실제로 뭘 하는 선택인지(대사)가 안 읽혔다. 그래서 좌/우 슬롯을 카드 위 고정 영역으로 빼고,
|
// 실제로 뭘 하는 선택인지(대사)가 안 읽혔다. 그래서 좌/우 슬롯을 카드 위 고정 영역으로 빼고,
|
||||||
// 기호 + 선택 대사를 처음부터 항상 같이 보여준다. 드래그 제스처·물리는 기존
|
// 기호 + 선택 대사를 처음부터 항상 같이 보여준다. 드래그 제스처·물리는 기존
|
||||||
// components/SwipeCard.jsx를 재사용했다 (기획서 "재사용 자산").
|
// components/SwipeCard.jsx를 재사용했다 (기획서 "재사용 자산").
|
||||||
export default function CoreSwipeCard({ card, onSwipe }) {
|
//
|
||||||
|
// 2026-09-17 피드백: "정산하기"는 카드를 더 받을지 라운드를 끝낼지 정하는 핵심 결정인데
|
||||||
|
// 화면 오른쪽 위 툴바에 있으니 눈에 안 들어온다는 지적. 결재 카드 바로 아래, 선택을 내리는
|
||||||
|
// 자리로 옮기고 지금 주목도/한도를 버튼 옆에 같이 붙였다 — 블랙잭에서 "스탠드"를 누르기 전에
|
||||||
|
// 내 패 합계를 보는 것과 같다.
|
||||||
|
export default function CoreSwipeCard({ card, onSwipe, onEndRound, roundAttention = 0, attentionCap = 0 }) {
|
||||||
const x = useMotionValue(0);
|
const x = useMotionValue(0);
|
||||||
const y = useMotionValue(0);
|
const y = useMotionValue(0);
|
||||||
const controls = useAnimation();
|
const controls = useAnimation();
|
||||||
@@ -40,6 +45,9 @@ export default function CoreSwipeCard({ card, onSwipe }) {
|
|||||||
|
|
||||||
if (!card) return null;
|
if (!card) return null;
|
||||||
|
|
||||||
|
const remaining = Math.max(0, attentionCap - roundAttention);
|
||||||
|
const zone = remaining === 0 ? 'bust' : remaining <= 5 ? 'warning' : 'normal';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="core-card-container">
|
<div className="core-card-container">
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -83,6 +91,21 @@ export default function CoreSwipeCard({ card, onSwipe }) {
|
|||||||
{card.flavor && <p className="core-card-flavor">"{card.flavor}"</p>}
|
{card.flavor && <p className="core-card-flavor">"{card.flavor}"</p>}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 결재 카드 바로 아래 — "한 장 더 받을까, 여기서 멈출까"를 같은 시선 안에서 판단한다. */}
|
||||||
|
{onEndRound && (
|
||||||
|
<div className={`core-round-action zone-${zone}`}>
|
||||||
|
<span className="core-round-attention">
|
||||||
|
이번 라운드 주목도 <strong>{roundAttention}</strong> / {attentionCap}
|
||||||
|
{remaining > 0
|
||||||
|
? <span className="core-round-remaining"> · 한도까지 {remaining}</span>
|
||||||
|
: <span className="core-round-remaining"> · 한도 도달</span>}
|
||||||
|
</span>
|
||||||
|
<button type="button" className="core-end-round-btn" onClick={onEndRound}>
|
||||||
|
정산하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@
|
|||||||
.council-audit-row {
|
.council-audit-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
/* 규약 델타까지 붙으면 좁은 화면에서 오른쪽이 잘린다 — 줄바꿈을 허용한다. */
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 10px;
|
||||||
font-family: var(--font-typewriter);
|
font-family: var(--font-typewriter);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -251,3 +254,82 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 2026-09-17: 13석 표결 표시 + 툴팁 배치 ── */
|
||||||
|
|
||||||
|
.council-required-delta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-token-budget);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-margin {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-margin-hint {
|
||||||
|
color: var(--color-risk-critical);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-penalty-list,
|
||||||
|
.council-pool-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-penalty-slot.targetable,
|
||||||
|
.council-pool-slot.targetable {
|
||||||
|
outline: 1px dashed var(--color-risk-critical);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-votes {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-votes.lead {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--color-token-budget);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-state {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.65;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-candidate.winning {
|
||||||
|
border-color: var(--color-token-budget);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-candidate.added {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-confirm-sub {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-margin-hint-weak {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
requiredScore, settleScore, JUSTIFICATION_INTERVENTIONS, availableForTabling,
|
requiredScore, settleScore, JUSTIFICATION_INTERVENTIONS, justificationDetail,
|
||||||
|
availableForTabling, applyInterventions, tallyVotes, applyVoteSwing, resolveProtocolVote,
|
||||||
|
COUNCIL_SEATS,
|
||||||
} from '../engine/council';
|
} from '../engine/council';
|
||||||
|
import InfoTooltip from './InfoTooltip';
|
||||||
|
import ProtocolChip from './ProtocolChip';
|
||||||
import './CouncilScreen.css';
|
import './CouncilScreen.css';
|
||||||
|
|
||||||
// 의회 (§9.1) — 실적 감사와 규약 결정은 독립적이다: 실적을 잘 채웠다고 우호적 규약이
|
// 의회 (§9.1) — 실적 감사와 규약 결정은 독립적이다: 실적을 잘 채웠다고 우호적 규약이
|
||||||
// 상정되지 않는다. 13인 위원회의 실제 우호도·표결(§9.4~9.6)은 4차 범위라 없다 — 후보 1개는
|
// 상정되지 않는다. 플레이어는 표결에 직접 개입 못 하고 명분으로 안건 자체를 조작할 뿐이다(§9.2).
|
||||||
// 무작위로 뽑힌 "통과"를 그대로 쓴다. 플레이어는 표결에 직접 개입 못 하고 명분으로
|
|
||||||
// 안건 자체를 조작할 뿐이라는 원칙(§9.2)만 지켰다.
|
|
||||||
//
|
//
|
||||||
// 2026-09-15 결정 반영: 토큰 1종, 명분 개입 4갈래 재정의(§7.3).
|
// 2026-09-15 결정: 토큰 1종, 명분 개입 4갈래(§7.3). 명분은 종류별 "수량"이라 1개 이상이어야 쓴다.
|
||||||
// 명분은 종류별 "수량"(§5.1 표의 명분 열)이다 — 1개 이상 있어야 실적 납입이나 개입에 쓸 수 있고,
|
// 2026-09-17: §9.2의 13석 표결을 실제 표 수로 구현하고, 개입을 반영한 **예상 득표를 미리** 보여준다.
|
||||||
// 페어(0.5)만 반복하면 반쪽만 쌓여 아무것도 못 한다.
|
// 혼합 명분이 1표만 움직이는 이상(§7.3), 표 차를 모르면 쓸지 말지 판단할 수 없기 때문이다.
|
||||||
|
// 임원 개개인의 우호도·이중첩자(§9.5)는 4차 범위 — 지금은 안건 조합에 따른 결정적 배분이다.
|
||||||
export default function CouncilScreen({
|
export default function CouncilScreen({
|
||||||
round, combineScore, tokens, justifications, penalties, candidates, onResolve,
|
round, combineScore, tokens, justifications, penalties, candidates, requiredDelta = 0, onResolve,
|
||||||
}) {
|
}) {
|
||||||
const required = requiredScore(round);
|
const required = Math.max(0, requiredScore(round) + requiredDelta);
|
||||||
|
|
||||||
const [tokensPaid, setTokensPaid] = useState(0);
|
const [tokensPaid, setTokensPaid] = useState(0);
|
||||||
const [spent, setSpent] = useState({}); // { 명분종류: 소모한 수량 }
|
const [spent, setSpent] = useState({}); // { 명분종류: 소모한 수량 }
|
||||||
@@ -51,8 +54,16 @@ export default function CouncilScreen({
|
|||||||
penalty: 'targetPenaltyId',
|
penalty: 'targetPenaltyId',
|
||||||
pool: 'targetPoolProtocolId',
|
pool: 'targetPoolProtocolId',
|
||||||
}[targetKind];
|
}[targetKind];
|
||||||
|
const iv = { type: pending.type, [key]: targetId };
|
||||||
|
// 여론 명분의 교체 후보는 지금 확정해 둔다 — 확정 시점에 무작위로 뽑으면 아래 예상 득표와
|
||||||
|
// 실제 결과가 달라진다.
|
||||||
|
if (pending.type === '여론') {
|
||||||
|
const pool = availableForTabling(candidates.filter((c) => c.id !== targetId));
|
||||||
|
const replacement = pool[Math.floor(Math.random() * pool.length)];
|
||||||
|
if (replacement) iv.replacementId = replacement.id;
|
||||||
|
}
|
||||||
spend(pending.type);
|
spend(pending.type);
|
||||||
setInterventions((prev) => [...prev, { type: pending.type, [key]: targetId }]);
|
setInterventions((prev) => [...prev, iv]);
|
||||||
setPending(null);
|
setPending(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,6 +71,14 @@ export default function CouncilScreen({
|
|||||||
const tablingPool = availableForTabling(candidates);
|
const tablingPool = availableForTabling(candidates);
|
||||||
const heldTypes = Object.keys(justifications).filter((t) => remainingOf(t) > 0);
|
const heldTypes = Object.keys(justifications).filter((t) => remainingOf(t) > 0);
|
||||||
|
|
||||||
|
// 지금까지의 개입을 반영한 최종 안건과 예상 득표. 확정 로직(CoreGameScreen)과 같은 함수를
|
||||||
|
// 같은 순서로 쓰고 tallyVotes가 결정적이라 미리보기와 결과가 일치한다.
|
||||||
|
const preview = applyInterventions(candidates, interventions);
|
||||||
|
const tallied = applyVoteSwing(tallyVotes(preview.candidates), preview.voteSwingTargetId);
|
||||||
|
const projectedWinner = resolveProtocolVote(tallied);
|
||||||
|
const sortedTally = [...tallied].sort((a, b) => b.votes - a.votes);
|
||||||
|
const margin = sortedTally.length >= 2 ? sortedTally[0].votes - sortedTally[1].votes : null;
|
||||||
|
|
||||||
const confirm = () => {
|
const confirm = () => {
|
||||||
onResolve({ tokensPaid, justificationsPaidCount: scorePaidCount, interventions });
|
onResolve({ tokensPaid, justificationsPaidCount: scorePaidCount, interventions });
|
||||||
};
|
};
|
||||||
@@ -70,7 +89,14 @@ export default function CouncilScreen({
|
|||||||
|
|
||||||
<section className="council-audit">
|
<section className="council-audit">
|
||||||
<div className="council-audit-row">
|
<div className="council-audit-row">
|
||||||
<span>요구 실적 {required}</span>
|
<span>
|
||||||
|
요구 실적 {required}
|
||||||
|
{requiredDelta !== 0 && (
|
||||||
|
<span className="council-required-delta">
|
||||||
|
{' '}({requiredDelta > 0 ? '+' : ''}{requiredDelta} 규약 적용)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className={met ? 'audit-met' : 'audit-short'}>
|
<span className={met ? 'audit-met' : 'audit-short'}>
|
||||||
현재 {currentTotal} {met ? '(충족)' : `(부족 ${required - currentTotal})`}
|
현재 {currentTotal} {met ? '(충족)' : `(부족 ${required - currentTotal})`}
|
||||||
</span>
|
</span>
|
||||||
@@ -97,15 +123,17 @@ export default function CouncilScreen({
|
|||||||
return (
|
return (
|
||||||
<section className="council-penalties">
|
<section className="council-penalties">
|
||||||
<h3>누적 징벌 규약 {penalties.length}개</h3>
|
<h3>누적 징벌 규약 {penalties.length}개</h3>
|
||||||
{shown.map((p) => (
|
<div className="council-penalty-list">
|
||||||
<div
|
{shown.map((p) => (
|
||||||
key={p.id}
|
<span
|
||||||
className={`council-penalty-chip ${pendingTargetKind === 'penalty' ? 'targetable' : ''}`}
|
key={p.id}
|
||||||
onClick={() => pendingTargetKind === 'penalty' && completeTarget(p.id, 'penalty')}
|
className={`council-penalty-slot ${pendingTargetKind === 'penalty' ? 'targetable' : ''}`}
|
||||||
>
|
onClick={() => pendingTargetKind === 'penalty' && completeTarget(p.id, 'penalty')}
|
||||||
⚠ {p.name}
|
>
|
||||||
</div>
|
<ProtocolChip protocol={p} prefix="⚠ " />
|
||||||
))}
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
{hidden > 0 && (
|
{hidden > 0 && (
|
||||||
<button className="council-penalty-more" onClick={() => setPenaltiesExpanded(true)}>
|
<button className="council-penalty-more" onClick={() => setPenaltiesExpanded(true)}>
|
||||||
+{hidden}개 더 보기
|
+{hidden}개 더 보기
|
||||||
@@ -125,11 +153,20 @@ export default function CouncilScreen({
|
|||||||
{heldTypes.length === 0 && <p className="council-empty">사용 가능한 명분이 없습니다.</p>}
|
{heldTypes.length === 0 && <p className="council-empty">사용 가능한 명분이 없습니다.</p>}
|
||||||
{heldTypes.map((type) => {
|
{heldTypes.map((type) => {
|
||||||
const spec = JUSTIFICATION_INTERVENTIONS[type];
|
const spec = JUSTIFICATION_INTERVENTIONS[type];
|
||||||
|
const info = justificationDetail(type);
|
||||||
const remaining = remainingOf(type);
|
const remaining = remainingOf(type);
|
||||||
const usable = remaining >= 1;
|
const usable = remaining >= 1;
|
||||||
return (
|
return (
|
||||||
<div key={type} className="council-justification-item">
|
<div key={type} className="council-justification-item">
|
||||||
<span className="justification-badge">{type} {remaining}개</span>
|
<InfoTooltip
|
||||||
|
title={`${type} 명분`}
|
||||||
|
badge={`보유 ${remaining}개`}
|
||||||
|
summary={info.summary}
|
||||||
|
detail={info.detail}
|
||||||
|
tone={usable ? 'neutral' : 'bad'}
|
||||||
|
>
|
||||||
|
<span className="justification-badge">{type} {remaining}개</span>
|
||||||
|
</InfoTooltip>
|
||||||
{!usable && <span className="justification-partial">1개 미만 — 사용 불가</span>}
|
{!usable && <span className="justification-partial">1개 미만 — 사용 불가</span>}
|
||||||
<button onClick={() => payForScore(type)} disabled={!usable}>실적 납입 (+20)</button>
|
<button onClick={() => payForScore(type)} disabled={!usable}>실적 납입 (+20)</button>
|
||||||
{spec && (
|
{spec && (
|
||||||
@@ -151,51 +188,97 @@ export default function CouncilScreen({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="council-candidates">
|
<section className="council-candidates">
|
||||||
<h3>규약 후보</h3>
|
<h3>
|
||||||
|
규약 표결 — {COUNCIL_SEATS}석
|
||||||
|
{margin !== null && (
|
||||||
|
<span className="council-margin">
|
||||||
|
{margin === 0 ? ' · 동점' : ` · 1위와 2위 ${margin}표 차`}
|
||||||
|
{margin <= 1 && !preview.voteSwingTargetId && (
|
||||||
|
<span className="council-margin-hint"> — 혼합 명분 1표로 뒤집을 수 있음</span>
|
||||||
|
)}
|
||||||
|
{margin >= 2 && !preview.voteSwingTargetId && (
|
||||||
|
<span className="council-margin-hint-weak"> — 혼합 명분으로는 못 뒤집음</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
{candidates.map((c) => {
|
{candidates.map((c) => {
|
||||||
const deferredHere = interventions.some((iv) => iv.type === '절차' && iv.targetCandidateId === c.id);
|
const deferredHere = interventions.some((iv) => iv.type === '절차' && iv.targetCandidateId === c.id);
|
||||||
const removed = interventions.some((iv) => iv.type === '여론' && iv.targetCandidateId === c.id);
|
const removed = interventions.some((iv) => iv.type === '여론' && iv.targetCandidateId === c.id);
|
||||||
const forced = interventions.some((iv) => iv.type === '혼합' && iv.targetCandidateId === c.id);
|
const swung = interventions.some((iv) => iv.type === '혼합' && iv.targetCandidateId === c.id);
|
||||||
const gone = deferredHere || removed;
|
const gone = deferredHere || removed;
|
||||||
|
const tallyRow = tallied.find((t) => t.id === c.id);
|
||||||
|
const isWinner = projectedWinner && projectedWinner.id === c.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={c.id}
|
key={c.id}
|
||||||
className={`council-candidate ${gone ? 'removed' : ''} ${forced ? 'forced' : ''} ${pendingTargetKind === 'candidate' ? 'targetable' : ''}`}
|
className={`council-candidate ${gone ? 'removed' : ''} ${isWinner ? 'winning' : ''} ${pendingTargetKind === 'candidate' ? 'targetable' : ''}`}
|
||||||
onClick={() => pendingTargetKind === 'candidate' && completeTarget(c.id, 'candidate')}
|
onClick={() => pendingTargetKind === 'candidate' && completeTarget(c.id, 'candidate')}
|
||||||
>
|
>
|
||||||
<div className="candidate-name">
|
<div className="candidate-head">
|
||||||
{c.name}
|
<ProtocolChip protocol={c} />
|
||||||
{deferredHere && ' (다음 라운드로 이월)'}
|
{!gone && tallyRow && (
|
||||||
{removed && ' (강제 탈락)'}
|
<span className={`candidate-votes ${isWinner ? 'lead' : ''}`}>
|
||||||
{forced && ' (통과 지정)'}
|
{tallyRow.votes}표{isWinner ? ' · 통과 예상' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="candidate-flavor">{c.flavor}</div>
|
{(gone || swung) && (
|
||||||
|
<div className="candidate-state">
|
||||||
|
{deferredHere && '다음 라운드로 이월됨'}
|
||||||
|
{removed && '강제 탈락됨'}
|
||||||
|
{swung && '혼합 명분으로 +1표'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{interventions.filter((iv) => iv.type === '내부').map((iv, i) => (
|
|
||||||
<div key={`tabled-${i}`} className="council-candidate forced">
|
{/* 개입으로 새로 들어온 안건(여론 교체분·내부 강제 상정분) */}
|
||||||
<div className="candidate-name">
|
{preview.candidates
|
||||||
{tablingPool.find((p) => p.id === iv.targetPoolProtocolId)?.name} (강제 상정)
|
.filter((c) => !candidates.some((orig) => orig.id === c.id))
|
||||||
</div>
|
.map((c) => {
|
||||||
</div>
|
const tallyRow = tallied.find((t) => t.id === c.id);
|
||||||
))}
|
const isWinner = projectedWinner && projectedWinner.id === c.id;
|
||||||
|
return (
|
||||||
|
<div key={c.id} className={`council-candidate added ${isWinner ? 'winning' : ''}`}>
|
||||||
|
<div className="candidate-head">
|
||||||
|
<ProtocolChip protocol={c} />
|
||||||
|
{tallyRow && (
|
||||||
|
<span className={`candidate-votes ${isWinner ? 'lead' : ''}`}>
|
||||||
|
{tallyRow.votes}표{isWinner ? ' · 통과 예상' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="candidate-state">명분으로 새로 올라온 안건</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{preview.candidates.length === 0 && (
|
||||||
|
<p className="council-empty">안건이 모두 사라졌습니다 — 이번 라운드는 통과 규약 없이 넘어갑니다.</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{pendingTargetKind === 'pool' && (
|
{pendingTargetKind === 'pool' && (
|
||||||
<section className="council-pool">
|
<section className="council-pool">
|
||||||
<h3>상정 가능 규약</h3>
|
<h3>상정 가능 규약 — 올릴 안건을 고르십시오</h3>
|
||||||
{tablingPool.length === 0 && <p className="council-empty">상정할 규약이 남지 않았습니다.</p>}
|
{tablingPool.length === 0 && <p className="council-empty">상정할 규약이 남지 않았습니다.</p>}
|
||||||
{tablingPool.map((p) => (
|
<div className="council-pool-list">
|
||||||
<div key={p.id} className="council-candidate targetable" onClick={() => completeTarget(p.id, 'pool')}>
|
{tablingPool.map((p) => (
|
||||||
<div className="candidate-name">{p.name}</div>
|
<span key={p.id} className="council-pool-slot targetable" onClick={() => completeTarget(p.id, 'pool')}>
|
||||||
<div className="candidate-flavor">{p.flavor}</div>
|
<ProtocolChip protocol={p} />
|
||||||
</div>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button className="council-confirm-btn" onClick={confirm}>의회 결의 확정</button>
|
<button className="council-confirm-btn" onClick={confirm}>
|
||||||
|
의회 결의 확정
|
||||||
|
{projectedWinner && <span className="council-confirm-sub"> — 『{projectedWinner.name}』 통과 예정</span>}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,25 +82,6 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desk-end-round-btn {
|
|
||||||
font-family: var(--font-typewriter);
|
|
||||||
border: 1px solid var(--color-token-info);
|
|
||||||
color: var(--color-token-info);
|
|
||||||
background: transparent;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 7px 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desk-end-round-btn:disabled {
|
|
||||||
border-color: rgba(255, 255, 255, 0.15);
|
|
||||||
color: rgba(255, 255, 255, 0.3);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desk-bar-rows {
|
.desk-bar-rows {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -185,3 +166,8 @@
|
|||||||
color: var(--color-risk-critical);
|
color: var(--color-risk-critical);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desk-carried {
|
||||||
|
opacity: 0.55;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { AXES, AXIS_META } from '../engine/symbols';
|
import { AXES, AXIS_META } from '../engine/symbols';
|
||||||
import {
|
import {
|
||||||
totalCount, totalAttention, attentionByAxis, attentionZone, tokenSurcharge, gaugeRiseRateByAxis,
|
totalCount, totalAttention, roundAttention, attentionByAxis, attentionZone, tokenSurcharge,
|
||||||
|
gaugeRiseRateByAxis,
|
||||||
} from '../engine/desk';
|
} from '../engine/desk';
|
||||||
import { AxisIcon } from './SymbolBadge';
|
import { AxisIcon } from './SymbolBadge';
|
||||||
import DeskDetailPopup from './DeskDetailPopup';
|
import DeskDetailPopup from './DeskDetailPopup';
|
||||||
@@ -11,21 +12,27 @@ import './DeskBar.css';
|
|||||||
// 2026-09-16 결정 반영: 페널티 기준이 등급 합계(주목도)이고, 이제 주목도가 **이번 라운드의
|
// 2026-09-16 결정 반영: 페널티 기준이 등급 합계(주목도)이고, 이제 주목도가 **이번 라운드의
|
||||||
// 한도(attentionCap)를 넘으면 그 자리에서 라운드가 강제 종료**된다(§4.2-B, 결정 노트
|
// 한도(attentionCap)를 넘으면 그 자리에서 라운드가 강제 종료**된다(§4.2-B, 결정 노트
|
||||||
// 2026-09-16-라운드-주목도-하드캡-복원). 그래서 총 주목도뿐 아니라 한도까지 항상 보여야
|
// 2026-09-16-라운드-주목도-하드캡-복원). 그래서 총 주목도뿐 아니라 한도까지 항상 보여야
|
||||||
// "한 장 더 받을지, 지금 정산할지" 판단이 선다. "정산하기" 버튼이 그 자율 종료 액션이다.
|
// "한 장 더 받을지, 지금 정산할지" 판단이 선다.
|
||||||
|
//
|
||||||
|
// 2026-09-17 수정: (1) "정산하기" 버튼은 결재 카드 아래(CoreSwipeCard)로 옮겼다 — 툴바 구석에
|
||||||
|
// 있으면 핵심 결정 버튼인 걸 모른다는 피드백. (2) 한도 판정에 쓰는 주목도는 **이번 라운드분**
|
||||||
|
// 이다. 책상 전체 주목도는 이월 카드까지 포함해 게이지·토큰 가산에 계속 작용하므로 둘 다 보여준다.
|
||||||
const MAX_GRADE_CHIPS = 6;
|
const MAX_GRADE_CHIPS = 6;
|
||||||
|
|
||||||
export default function DeskBar({
|
export default function DeskBar({
|
||||||
deskCards, gauges, tokens, attentionCap, onOpenCombine, onOpenGuide, onEndRound, canEndRound,
|
deskCards, gauges, tokens, round, attentionCap, onOpenCombine, onOpenGuide,
|
||||||
gaugeExtraFlat = 0, tokenExtraFlat = 0,
|
gaugeExtraFlat = 0, tokenExtraFlat = 0,
|
||||||
}) {
|
}) {
|
||||||
const [detailAxis, setDetailAxis] = useState(null);
|
const [detailAxis, setDetailAxis] = useState(null);
|
||||||
|
|
||||||
const total = totalCount(deskCards);
|
const total = totalCount(deskCards);
|
||||||
const attention = totalAttention(deskCards);
|
const deskAttention = totalAttention(deskCards);
|
||||||
|
const current = roundAttention(deskCards, round);
|
||||||
|
const carried = deskAttention - current;
|
||||||
const perAxis = attentionByAxis(deskCards);
|
const perAxis = attentionByAxis(deskCards);
|
||||||
const rates = gaugeRiseRateByAxis(deskCards, gaugeExtraFlat);
|
const rates = gaugeRiseRateByAxis(deskCards, gaugeExtraFlat);
|
||||||
const surcharge = tokenSurcharge(deskCards, tokenExtraFlat);
|
const surcharge = tokenSurcharge(deskCards, tokenExtraFlat);
|
||||||
const zone = attentionZone(deskCards, attentionCap);
|
const zone = attentionZone(deskCards, attentionCap, round);
|
||||||
|
|
||||||
const zoneLabel = {
|
const zoneLabel = {
|
||||||
normal: null,
|
normal: null,
|
||||||
@@ -37,7 +44,8 @@ export default function DeskBar({
|
|||||||
<div className={`desk-bar zone-${zone}`}>
|
<div className={`desk-bar zone-${zone}`}>
|
||||||
<div className="desk-bar-top">
|
<div className="desk-bar-top">
|
||||||
<div className="desk-total">
|
<div className="desk-total">
|
||||||
책상 {total}장 · <span className="desk-attention">주목도 {attention}/{attentionCap}</span>
|
책상 {total}장 · <span className="desk-attention">이번 라운드 {current}/{attentionCap}</span>
|
||||||
|
{carried > 0 && <span className="desk-carried"> (이월 {carried})</span>}
|
||||||
{surcharge > 0 && <span className="desk-surcharge"> · 지출 가산 +{surcharge}</span>}
|
{surcharge > 0 && <span className="desk-surcharge"> · 지출 가산 +{surcharge}</span>}
|
||||||
<span className="desk-tokens"> · 토큰 {tokens}</span>
|
<span className="desk-tokens"> · 토큰 {tokens}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,9 +54,6 @@ export default function DeskBar({
|
|||||||
<button className="desk-combine-btn" onClick={onOpenCombine} disabled={total < 2}>
|
<button className="desk-combine-btn" onClick={onOpenCombine} disabled={total < 2}>
|
||||||
합본 결재
|
합본 결재
|
||||||
</button>
|
</button>
|
||||||
<button className="desk-end-round-btn" onClick={onEndRound} disabled={!canEndRound}>
|
|
||||||
정산하기
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
.info-tip-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tip-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tip-trigger:hover,
|
||||||
|
.info-tip-trigger.open {
|
||||||
|
border-color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tip-trigger.tone-bad {
|
||||||
|
background: rgba(255, 68, 68, 0.1);
|
||||||
|
border-color: rgba(255, 120, 107, 0.35);
|
||||||
|
color: var(--symbol-threat, #ff7b6b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tip-trigger.tone-good {
|
||||||
|
background: rgba(76, 175, 80, 0.12);
|
||||||
|
border-color: rgba(76, 175, 80, 0.4);
|
||||||
|
color: var(--color-token-budget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 물음표 배지 — 누르면 설명이 나온다는 신호. 이게 없으면 툴팁의 존재 자체를 모른다. */
|
||||||
|
.info-tip-mark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tip-pop {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 80;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
width: max(260px, 100%);
|
||||||
|
max-width: min(340px, 84vw);
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #101014;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.55);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tip-pop.tone-bad { border-color: rgba(255, 120, 107, 0.5); }
|
||||||
|
.info-tip-pop.tone-good { border-color: rgba(76, 175, 80, 0.5); }
|
||||||
|
|
||||||
|
.info-tip-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tip-badge {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tip-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tip-summary {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone-bad .info-tip-summary { color: var(--symbol-threat, #ff7b6b); }
|
||||||
|
.tone-good .info-tip-summary { color: var(--color-token-budget); }
|
||||||
|
|
||||||
|
.info-tip-detail {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 화면 오른쪽 끝에 붙은 트리거는 팝업을 왼쪽으로 정렬해 잘리지 않게 한다. */
|
||||||
|
.info-tip-wrap.align-right .info-tip-pop {
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import './InfoTooltip.css';
|
||||||
|
|
||||||
|
// 규약·징벌·명분이 "정확히 무엇을 하는지" 눌러서 확인하는 툴팁 (플레이테스트 피드백).
|
||||||
|
// 규약 이름만 보고는 어떤 족보가 막혔는지 알 수 없다는 지적이 있었다 — 그래서 설명 문구는
|
||||||
|
// UI가 추측하지 않고 엔진 카탈로그(council.js protocolEffectDetail 등)에서 받아 온다.
|
||||||
|
//
|
||||||
|
// 모바일 대상(§1)이라 hover가 아니라 **클릭/탭**으로 연다. 바깥을 누르거나 Esc로 닫는다.
|
||||||
|
export default function InfoTooltip({ title, badge, summary, detail, tone = 'neutral', children, className = '' }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const wrapRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
const onDocDown = (e) => {
|
||||||
|
if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
|
||||||
|
document.addEventListener('mousedown', onDocDown);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDocDown);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`info-tip-wrap ${className}`} ref={wrapRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`info-tip-trigger tone-${tone} ${open ? 'open' : ''}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<span className="info-tip-mark" aria-hidden="true">?</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<span className={`info-tip-pop tone-${tone}`} role="tooltip">
|
||||||
|
<span className="info-tip-head">
|
||||||
|
{badge && <span className="info-tip-badge">{badge}</span>}
|
||||||
|
<span className="info-tip-title">{title}</span>
|
||||||
|
</span>
|
||||||
|
{summary && <span className="info-tip-summary">{summary}</span>}
|
||||||
|
{detail && <span className="info-tip-detail">{detail}</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import InfoTooltip from './InfoTooltip';
|
||||||
|
import { protocolEffectDetail } from '../engine/council';
|
||||||
|
|
||||||
|
// 규약·징벌을 이름 + 물음표 배지로 보여주고, 누르면 "정확히 무엇이 막혔는지"를 띄운다.
|
||||||
|
// 설명 문구는 council.js의 카탈로그에서 생성하므로 화면마다 달라지지 않는다.
|
||||||
|
// 상태 표시줄 · 의회 화면 · 합본 패널에서 공용으로 쓴다.
|
||||||
|
export default function ProtocolChip({ protocol, prefix, className = '' }) {
|
||||||
|
if (!protocol) return null;
|
||||||
|
const { typeLabel, summary, detail, tone } = protocolEffectDetail(protocol);
|
||||||
|
return (
|
||||||
|
<InfoTooltip
|
||||||
|
title={protocol.name}
|
||||||
|
badge={`유형 ${protocol.type} · ${typeLabel}`}
|
||||||
|
summary={summary}
|
||||||
|
detail={`${detail}${protocol.flavor ? `\n\n"${protocol.flavor}"` : ''}`}
|
||||||
|
tone={tone}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<span className="protocol-chip-name">
|
||||||
|
{prefix}{protocol.name}
|
||||||
|
</span>
|
||||||
|
</InfoTooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
// 의회 엔진 — ProjectSS_코어시스템_기획서.md §7(명분) · §9.2(규약) · §9.3(실적 감사).
|
// 의회 엔진 — ProjectSS_코어시스템_기획서.md §7(명분) · §9.2(규약) · §9.3(실적 감사).
|
||||||
// React 의존 없는 순수 함수 + 카탈로그. 13인 위원회의 실제 우호도·표결(§9.4~9.6)은
|
// React 의존 없는 순수 함수 + 카탈로그.
|
||||||
// 이중첩자·탄핵 방어까지 포함된 4차 범위라 아직 없다 — 그 대신 규약 후보 3개 중 1개를
|
|
||||||
// 뽑는 절차는 "무작위"로 흉내만 낸다. §9.1 "규약 결정과 실적 감사는 독립적"이라는
|
|
||||||
// 원칙만은 지킨다: 감사 결과가 어떤 규약이 뽑히는지에 영향을 주지 않는다.
|
|
||||||
//
|
//
|
||||||
// 2026-09-15 결정 반영: 토큰 1종 통합(§8.2), 명분 개입 4갈래 재정의(§7.3).
|
// 2026-09-15 결정 반영: 토큰 1종 통합(§8.2), 명분 개입 4갈래 재정의(§7.3).
|
||||||
// 특히 내부 명분이 "실적 요구치 하향"에서 "후보 1개 강제 상정"으로 바뀌었다.
|
// 2026-09-17 반영:
|
||||||
|
// - 유형 D(이득 규약) 카탈로그 신설. 내부 명분("강제 상정")이 불리한 유형 A만 올릴 수 있어
|
||||||
|
// 실속이 없던 문제를 해결한다 — 이제 이득 규약을 직접 안건에 올릴 수 있다.
|
||||||
|
// - 13석 표결을 실제 표 수로 구현. 혼합 명분이 "직접 통과 지정"(무조건 통과)이라는
|
||||||
|
// 자리표시자였는데, 이제 §7.3 스펙대로 **1표만 움직인다** — 1표 차 승부일 때만 뒤집힌다.
|
||||||
|
// 임원 개개인의 우호도·이중첩자(§9.5~9.6)는 여전히 4차 범위이며, 그때 이 무작위 배분이
|
||||||
|
// "임원별 투표"로 교체된다. 표 집계 자체는 §9.5가 공개를 요구하므로 화면에 노출한다.
|
||||||
|
// - 툴팁용 효과 설명(protocolEffectDetail)을 엔진에서 생성한다 — 규약이 정확히 무엇을
|
||||||
|
// 막는지 UI가 자체 문구로 추측하지 않도록, 카탈로그와 설명을 한곳에 둔다.
|
||||||
|
|
||||||
|
import { HAND_DEFS } from './hands.js';
|
||||||
|
|
||||||
/* ── §9.3 실적 감사 ── */
|
/* ── §9.3 실적 감사 ── */
|
||||||
|
|
||||||
@@ -16,50 +23,56 @@ export const requiredScore = (round) => 25 + (round - 1) * 20;
|
|||||||
export const settleScore = ({ combineScore, tokensPaid, justificationsPaid }) =>
|
export const settleScore = ({ combineScore, tokensPaid, justificationsPaid }) =>
|
||||||
combineScore + tokensPaid * 5 + justificationsPaid * 20;
|
combineScore + tokensPaid * 5 + justificationsPaid * 20;
|
||||||
|
|
||||||
/* ── §9.2 규약 — 유형 A(족보표 변경) 우선, 유형 C(징벌)는 감사 실패 시 필요해서 최소 카탈로그만.
|
/* ── §9.2 규약 카탈로그 ──
|
||||||
* 유형 B(금리형)·D(이득)는 §20.2 "규약 상세 목록 — 소량 배치로 작성" 대상이라 아직 카탈로그가 없다.
|
* 유형 A(족보표 변경)·C(징벌)·D(이득)까지 구현. 유형 B(금리형)는 §20.2 콘텐츠 작업 대상. */
|
||||||
* 유형 D가 없으면 내부 명분의 "강제 상정"이 전략적으로 빈약하다 — 이득 규약이 추가되면 살아난다. */
|
|
||||||
|
|
||||||
export const PROTOCOL_TYPE_A = [
|
export const PROTOCOL_TYPE_A = [
|
||||||
{
|
{
|
||||||
id: 'protocol_flush_ban',
|
id: 'protocol_flush_ban',
|
||||||
name: '동일 부서 일괄 처리 금지 규약',
|
name: '동일 부서 일괄 처리 금지 규약',
|
||||||
|
type: 'A',
|
||||||
effect: { kind: 'disable_hand', patternId: 'flush_axis' },
|
effect: { kind: 'disable_hand', patternId: 'flush_axis' },
|
||||||
flavor: '동일 부서 건을 묶어 올리는 관행이 유착으로 지적되었습니다. 개별 상신할 것.',
|
flavor: '동일 부서 건을 묶어 올리는 관행이 유착으로 지적되었습니다. 개별 상신할 것.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'protocol_straight_ban',
|
id: 'protocol_straight_ban',
|
||||||
name: '연번 상신 금지 규약',
|
name: '연번 상신 금지 규약',
|
||||||
|
type: 'A',
|
||||||
effect: { kind: 'disable_hand', patternId: 'straight' },
|
effect: { kind: 'disable_hand', patternId: 'straight' },
|
||||||
flavor: '등급을 순서대로 올리는 관행이 결재 회피 수단으로 악용되고 있습니다.',
|
flavor: '등급을 순서대로 올리는 관행이 결재 회피 수단으로 악용되고 있습니다.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'protocol_straightflush_ban',
|
id: 'protocol_straightflush_ban',
|
||||||
name: '전결 처리 금지 규약',
|
name: '전결 처리 금지 규약',
|
||||||
|
type: 'A',
|
||||||
effect: { kind: 'disable_hand', patternId: 'straight_flush' },
|
effect: { kind: 'disable_hand', patternId: 'straight_flush' },
|
||||||
flavor: '한 부서가 연번까지 독점 처리하는 것은 감사 사각지대를 만듭니다.',
|
flavor: '한 부서가 연번까지 독점 처리하는 것은 감사 사각지대를 만듭니다.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'protocol_fourkind_ban',
|
id: 'protocol_fourkind_ban',
|
||||||
name: '전면 개편 금지 규약',
|
name: '전면 개편 금지 규약',
|
||||||
|
type: 'A',
|
||||||
effect: { kind: 'disable_hand', patternId: 'four_kind' },
|
effect: { kind: 'disable_hand', patternId: 'four_kind' },
|
||||||
flavor: '동일 등급 4건 일괄 개편은 절차 생략 소지가 있어 당분간 금지합니다.',
|
flavor: '동일 등급 4건 일괄 개편은 절차 생략 소지가 있어 당분간 금지합니다.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'protocol_fullhouse_ban',
|
id: 'protocol_fullhouse_ban',
|
||||||
name: '종합 감사 제한 규약',
|
name: '종합 감사 제한 규약',
|
||||||
|
type: 'A',
|
||||||
effect: { kind: 'disable_hand', patternId: 'full_house' },
|
effect: { kind: 'disable_hand', patternId: 'full_house' },
|
||||||
flavor: '3+2 형태의 묶음 상신은 감사 항목을 흐린다는 지적이 있었습니다.',
|
flavor: '3+2 형태의 묶음 상신은 감사 항목을 흐린다는 지적이 있었습니다.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'protocol_min_grade',
|
id: 'protocol_min_grade',
|
||||||
name: '기밀 등급 심사 강화 규약',
|
name: '기밀 등급 심사 강화 규약',
|
||||||
|
type: 'A',
|
||||||
effect: { kind: 'min_grade', minGrade: 4 },
|
effect: { kind: 'min_grade', minGrade: 4 },
|
||||||
flavor: '등급 4 미만 문서만으로는 더 이상 합본 상신이 승인되지 않습니다.',
|
flavor: '등급 4 미만 문서만으로는 더 이상 합본 상신이 승인되지 않습니다.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'protocol_pair_ban',
|
id: 'protocol_pair_ban',
|
||||||
name: '중복 접수 금지 규약',
|
name: '중복 접수 금지 규약',
|
||||||
|
type: 'A',
|
||||||
effect: { kind: 'disable_hand', patternId: 'pair' },
|
effect: { kind: 'disable_hand', patternId: 'pair' },
|
||||||
flavor: '두 건만 묶어 올리는 편의적 관행을 제한합니다. 최소 규모를 갖출 것.',
|
flavor: '두 건만 묶어 올리는 편의적 관행을 제한합니다. 최소 규모를 갖출 것.',
|
||||||
},
|
},
|
||||||
@@ -69,17 +82,64 @@ export const PROTOCOL_TYPE_C = [
|
|||||||
{
|
{
|
||||||
id: 'penalty_gauge_accel',
|
id: 'penalty_gauge_accel',
|
||||||
name: '미결 서류 관리 규정',
|
name: '미결 서류 관리 규정',
|
||||||
|
type: 'C',
|
||||||
effect: { kind: 'gauge_surcharge', amount: 1.0 },
|
effect: { kind: 'gauge_surcharge', amount: 1.0 },
|
||||||
flavor: '미결 누적에 대한 문책성 규정 — 모든 축의 결재당 게이지 상승이 가산됩니다.',
|
flavor: '미결 누적에 대한 문책성 규정 — 모든 축의 결재당 게이지 상승이 가산됩니다.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'penalty_token_surcharge',
|
id: 'penalty_token_surcharge',
|
||||||
name: '긴급 재정 감사 대응',
|
name: '긴급 재정 감사 대응',
|
||||||
|
type: 'C',
|
||||||
effect: { kind: 'token_surcharge', amount: 2 },
|
effect: { kind: 'token_surcharge', amount: 2 },
|
||||||
flavor: '감사 대응 인력 편성 비용이 모든 토큰 지출에 얹힙니다.',
|
flavor: '감사 대응 인력 편성 비용이 모든 토큰 지출에 얹힙니다.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 유형 D — 이득 규약. §9.2 "드물게 통과"이므로 후보 등장 확률을 낮게 잡는다(D_CANDIDATE_CHANCE).
|
||||||
|
// 자산 지급형은 자산 시스템(§10, 3차)이 없어 제외했고, 토큰·족보 완화·한도 완화로 구성했다.
|
||||||
|
export const PROTOCOL_TYPE_D = [
|
||||||
|
{
|
||||||
|
id: 'boon_budget_grant',
|
||||||
|
name: '예비비 긴급 편성',
|
||||||
|
type: 'D',
|
||||||
|
effect: { kind: 'grant_token', amount: 12 },
|
||||||
|
flavor: '이월 예산 일부를 국장 재량으로 집행할 수 있도록 의결되었습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'boon_workload_relief',
|
||||||
|
name: '업무 경감 조치',
|
||||||
|
type: 'D',
|
||||||
|
effect: { kind: 'gauge_surcharge', amount: -1.0 },
|
||||||
|
flavor: '과중 업무에 대한 한시적 완화 조치가 승인되었습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'boon_admin_simplify',
|
||||||
|
name: '행정 간소화 지침',
|
||||||
|
type: 'D',
|
||||||
|
effect: { kind: 'token_surcharge', amount: -2 },
|
||||||
|
flavor: '중복 결재 단계를 축소해 집행 비용을 낮춥니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'boon_cap_extension',
|
||||||
|
name: '임시 결재 권한 확대',
|
||||||
|
type: 'D',
|
||||||
|
effect: { kind: 'attention_cap_bonus', amount: 8 },
|
||||||
|
flavor: '한시적으로 국장 전결 범위가 확대되었습니다. 더 오래 끌고 갈 수 있습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'boon_audit_relief',
|
||||||
|
name: '실적 기준 완화',
|
||||||
|
type: 'D',
|
||||||
|
effect: { kind: 'required_score_delta', amount: -20 },
|
||||||
|
flavor: '이번 회기의 실적 요구 기준이 하향 조정되었습니다.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 후보로 올라올 수 있는 전체 규약 (유형 A + D). 징벌(C)은 감사 실패로만 부과된다. */
|
||||||
|
export const TABLEABLE_PROTOCOLS = [...PROTOCOL_TYPE_A, ...PROTOCOL_TYPE_D];
|
||||||
|
|
||||||
|
const D_CANDIDATE_CHANCE = 0.3; // §9.2 "드물게 통과" — 후보 3개 중 1개가 D일 확률
|
||||||
|
|
||||||
const pick = (arr, n) => {
|
const pick = (arr, n) => {
|
||||||
const pool = [...arr];
|
const pool = [...arr];
|
||||||
const out = [];
|
const out = [];
|
||||||
@@ -90,53 +150,163 @@ const pick = (arr, n) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 매 라운드 규약 후보 3개를 뽑는다 (유형 A만, §17 2차 범위).
|
* 매 라운드 규약 후보 3개를 뽑는다.
|
||||||
* carriedOver: 절차 명분으로 지난 라운드에서 이월된 후보 — "지금은 피하지만 나중에 다시 온다"(§7.3).
|
* carriedOver: 절차 명분으로 지난 라운드에서 이월된 후보 — "지금은 피하지만 나중에 다시 온다"(§7.3).
|
||||||
|
* 이득 규약(D)은 낮은 확률로 한 자리만 차지한다.
|
||||||
*/
|
*/
|
||||||
export const rollProtocolCandidates = (carriedOver = []) => {
|
export const rollProtocolCandidates = (carriedOver = []) => {
|
||||||
const carriedIds = new Set(carriedOver.map((c) => c.id));
|
const carriedIds = new Set(carriedOver.map((c) => c.id));
|
||||||
const fresh = pick(PROTOCOL_TYPE_A.filter((p) => !carriedIds.has(p.id)), Math.max(0, 3 - carriedOver.length));
|
const slots = Math.max(0, 3 - carriedOver.length);
|
||||||
return [...carriedOver, ...fresh];
|
const out = [...carriedOver];
|
||||||
};
|
|
||||||
|
|
||||||
/** 후보 중 1개를 "표결 통과"로 확정한다 — 실제 위원회가 없어 무작위로 대신한다. */
|
if (slots > 0 && Math.random() < D_CANDIDATE_CHANCE) {
|
||||||
export const resolveProtocolVote = (candidates) => {
|
const boon = pick(PROTOCOL_TYPE_D.filter((p) => !carriedIds.has(p.id)), 1);
|
||||||
if (candidates.length === 0) return null;
|
out.push(...boon);
|
||||||
return candidates[Math.floor(Math.random() * candidates.length)];
|
}
|
||||||
|
const need = Math.max(0, 3 - out.length);
|
||||||
|
const taken = new Set(out.map((c) => c.id));
|
||||||
|
out.push(...pick(PROTOCOL_TYPE_A.filter((p) => !taken.has(p.id)), need));
|
||||||
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const rollPenalty = () => pick(PROTOCOL_TYPE_C, 1)[0];
|
export const rollPenalty = () => pick(PROTOCOL_TYPE_C, 1)[0];
|
||||||
|
|
||||||
/** 아직 후보에 오르지 않은 규약들 — 내부 명분의 "강제 상정" 대상 목록. */
|
/** 아직 후보에 오르지 않은 규약들 — 내부 명분의 "강제 상정" 대상. 이득 규약(D)이 여기 포함되는 것이
|
||||||
|
* 내부 명분의 존재 이유다(§7.3: "원하는 이득 규약을 올릴 때 사용"). */
|
||||||
export const availableForTabling = (candidates) => {
|
export const availableForTabling = (candidates) => {
|
||||||
const onBoard = new Set(candidates.map((c) => c.id));
|
const onBoard = new Set(candidates.map((c) => c.id));
|
||||||
return PROTOCOL_TYPE_A.filter((p) => !onBoard.has(p.id));
|
return TABLEABLE_PROTOCOLS.filter((p) => !onBoard.has(p.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── §7.3 ① 안건 조작 — 명분 4종 + 혼합 ──
|
/* ── §9.2 13석 표결 ── */
|
||||||
* ② 정보 획득 · ③ 임원 직접 관리는 13인 위원회(§9.4~9.6, 4차)가 있어야 대상이 존재한다.
|
|
||||||
* ④ 실적 납입(명분 1개 = 20점)은 CouncilScreen에서 직접 처리한다.
|
export const COUNCIL_SEATS = 13;
|
||||||
*
|
|
||||||
* 혼합 명분의 "표결 결과 1표 뒤집기"는 실제 표가 없어 문자 그대로 구현할 수 없다.
|
// 같은 후보 조합이면 항상 같은 표가 나오도록 하는 결정적 해시. 명분으로 안건을 만질 때마다
|
||||||
* 최종 효과(원하는 규약이 통과된다)만 같게 "직접 통과 지정"으로 대체했고,
|
// 표가 무작위로 흔들리면 플레이어가 개입 결과를 판단할 수 없다.
|
||||||
* 실제 위원회가 생기면 반드시 다시 설계해야 할 자리표시자다. */
|
const hashStr = (s) => {
|
||||||
export const JUSTIFICATION_INTERVENTIONS = {
|
let h = 2166136261;
|
||||||
절차: { label: '후보 1개 다음 라운드로 이월', target: 'candidate' },
|
for (let i = 0; i < s.length; i++) {
|
||||||
여론: { label: '후보 1개 강제 탈락 + 새 후보로 교체', target: 'candidate' },
|
h ^= s.charCodeAt(i);
|
||||||
위기: { label: '누적 징벌 규약 1개 조기 해제', target: 'penalty' },
|
h = Math.imul(h, 16777619);
|
||||||
내부: { label: '규약 1개 강제 상정', target: 'pool' },
|
}
|
||||||
혼합: { label: '후보 1개 직접 통과 지정 (표 뒤집기)', target: 'candidate' },
|
return h >>> 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 후보 목록에 13석을 배분한다 (최대잔여법). 결정적이므로 같은 조합 = 같은 표.
|
||||||
|
* @returns {Array<{...candidate, votes:number}>}
|
||||||
|
*/
|
||||||
|
export function tallyVotes(candidates) {
|
||||||
|
if (candidates.length === 0) return [];
|
||||||
|
const key = candidates.map((c) => c.id).join('|');
|
||||||
|
const weights = candidates.map((c) => 1 + (hashStr(`${key}#${c.id}`) % 100) / 100);
|
||||||
|
const total = weights.reduce((a, b) => a + b, 0);
|
||||||
|
const raw = weights.map((w) => (w / total) * COUNCIL_SEATS);
|
||||||
|
const seats = raw.map(Math.floor);
|
||||||
|
const remainder = COUNCIL_SEATS - seats.reduce((a, b) => a + b, 0);
|
||||||
|
const byFrac = raw
|
||||||
|
.map((r, i) => ({ i, frac: r - Math.floor(r) }))
|
||||||
|
.sort((a, b) => b.frac - a.frac);
|
||||||
|
for (let k = 0; k < remainder; k++) seats[byFrac[k % byFrac.length].i]++;
|
||||||
|
return candidates.map((c, i) => ({ ...c, votes: seats[i] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 혼합 명분 — §7.3 "표결 결과 1표 뒤집기". 지정 후보에 1표를 더하고 선두에서 1표를 뺀다.
|
||||||
|
* 1표 차 승부가 아니면 결과가 바뀌지 않는다 — 그게 이 명분의 한계이자 판단 지점이다.
|
||||||
|
*
|
||||||
|
* baseVotes(스윙 전 득표)를 남기는 이유: +1/−1은 표 차를 2 좁히므로 2표 차가 동점이 된다.
|
||||||
|
* 동점을 상정 순서로 가르면 배열 위치에 따라 2표 차가 뒤집히기도 해서 일관성이 없다.
|
||||||
|
* 그래서 동점은 "원래 표가 많던 쪽"이 이기고, 결과적으로 1표 차에서만 뒤집힌다.
|
||||||
|
*/
|
||||||
|
export function applyVoteSwing(tallied, targetId) {
|
||||||
|
const withBase = tallied.map((c) => ({ ...c, baseVotes: c.votes }));
|
||||||
|
if (!targetId || withBase.length === 0) return withBase;
|
||||||
|
const leader = [...withBase]
|
||||||
|
.sort((a, b) => b.votes - a.votes)
|
||||||
|
.find((c) => c.id !== targetId);
|
||||||
|
if (!leader) return withBase;
|
||||||
|
return withBase.map((c) => {
|
||||||
|
if (c.id === targetId) return { ...c, votes: c.votes + 1 };
|
||||||
|
if (c.id === leader.id) return { ...c, votes: Math.max(0, c.votes - 1) };
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 최다 득표 후보가 통과한다. 동점이면 스윙 전 득표가 많던 쪽, 그것도 같으면 먼저 상정된 쪽. */
|
||||||
|
export function resolveProtocolVote(tallied) {
|
||||||
|
if (!tallied || tallied.length === 0) return null;
|
||||||
|
return tallied.reduce((best, c) => {
|
||||||
|
if (c.votes !== best.votes) return c.votes > best.votes ? c : best;
|
||||||
|
const cBase = c.baseVotes ?? c.votes;
|
||||||
|
const bestBase = best.baseVotes ?? best.votes;
|
||||||
|
return cBase > bestBase ? c : best;
|
||||||
|
}, tallied[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── §7.3 ① 안건 조작 — 명분 4종 + 혼합 ──
|
||||||
|
* ② 정보 획득 · ③ 임원 직접 관리는 13인 위원회의 개별 임원(§9.4~9.6, 4차)이 있어야 대상이 존재한다.
|
||||||
|
* ④ 실적 납입(명분 1개 = 20점)은 CouncilScreen에서 직접 처리한다. */
|
||||||
|
export const JUSTIFICATION_INTERVENTIONS = {
|
||||||
|
절차: {
|
||||||
|
label: '후보 1개 다음 라운드로 이월',
|
||||||
|
target: 'candidate',
|
||||||
|
detail: '지금 표결에서 빼지만 다음 라운드 후보로 다시 올라온다. 당장의 위험만 미루는 것.',
|
||||||
|
},
|
||||||
|
여론: {
|
||||||
|
label: '후보 1개 강제 탈락 + 새 후보로 교체',
|
||||||
|
target: 'candidate',
|
||||||
|
detail: '지정한 후보를 완전히 없애고 빈자리를 새 후보로 채운다. 새 후보가 더 나쁠 수도 있다.',
|
||||||
|
},
|
||||||
|
위기: {
|
||||||
|
label: '누적 징벌 규약 1개 조기 해제',
|
||||||
|
target: 'penalty',
|
||||||
|
detail: '영구 누적되는 징벌 규약(유형 C) 하나를 즉시 없앤다.',
|
||||||
|
},
|
||||||
|
내부: {
|
||||||
|
label: '규약 1개 강제 상정',
|
||||||
|
target: 'pool',
|
||||||
|
detail: '후보에 없던 규약을 안건에 올린다. 이득 규약을 올려 통과를 노리는 용도.',
|
||||||
|
},
|
||||||
|
혼합: {
|
||||||
|
label: '표결 1표 이동 (지정 후보 +1, 선두 −1)',
|
||||||
|
target: 'candidate',
|
||||||
|
detail: '1표 차 승부일 때만 결과가 뒤집힌다. 표 차가 2 이상이면 소용없다.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 명분 종류별 출처(어느 축을 합본해야 나오는지)와 용도 — 툴팁용 (§7.1~7.3).
|
||||||
|
const JUSTIFICATION_SOURCE = {
|
||||||
|
절차: '대외 축(도장)만으로 합본할 때 산출',
|
||||||
|
여론: '공보 축(확성기)만으로 합본할 때 산출',
|
||||||
|
위기: '위협 축(소용돌이)만으로 합본할 때 산출',
|
||||||
|
내부: '인사 축(손)만으로 합본할 때 산출',
|
||||||
|
혼합: '축이 섞인 합본에서 산출',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @returns {{ summary, detail }} 명분 1종의 출처·용도 설명. */
|
||||||
|
export function justificationDetail(type) {
|
||||||
|
const iv = JUSTIFICATION_INTERVENTIONS[type];
|
||||||
|
return {
|
||||||
|
summary: iv ? iv.label : '용도 없음',
|
||||||
|
detail: [
|
||||||
|
JUSTIFICATION_SOURCE[type] || '',
|
||||||
|
iv ? iv.detail : '',
|
||||||
|
'실적 납입으로 돌리면 1개당 실적 20점. 쓰지 않은 명분은 이번 의회가 끝나면 소멸한다.',
|
||||||
|
'1개 미만(예: 0.5)은 사용할 수 없다 — 같은 종류를 더 모아야 한다.',
|
||||||
|
].filter(Boolean).join(' · '),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 명분 개입을 규약 후보에 반영한다 (§7.3 ①).
|
* 명분 개입을 규약 후보에 반영한다 (§7.3 ①).
|
||||||
* interventions: [{ type, targetCandidateId?, targetPenaltyId?, targetPoolProtocolId? }]
|
* interventions: [{ type, targetCandidateId?, targetPenaltyId?, targetPoolProtocolId? }]
|
||||||
* @returns {{ candidates, forcedWinnerId, deferred, penaltyIdToClear }}
|
* @returns {{ candidates, voteSwingTargetId, deferred, penaltyIdToClear }}
|
||||||
* deferred: 다음 라운드 후보로 이월할 규약 목록 (절차 명분)
|
|
||||||
*/
|
*/
|
||||||
export function applyInterventions(candidates, interventions) {
|
export function applyInterventions(candidates, interventions) {
|
||||||
let pool = [...candidates];
|
let pool = [...candidates];
|
||||||
let forcedWinnerId = null;
|
let voteSwingTargetId = null;
|
||||||
const deferred = [];
|
const deferred = [];
|
||||||
let penaltyIdToClear = null;
|
let penaltyIdToClear = null;
|
||||||
|
|
||||||
@@ -150,24 +320,127 @@ export function applyInterventions(candidates, interventions) {
|
|||||||
}
|
}
|
||||||
if (iv.type === '여론' && iv.targetCandidateId) {
|
if (iv.type === '여론' && iv.targetCandidateId) {
|
||||||
pool = pool.filter((c) => c.id !== iv.targetCandidateId);
|
pool = pool.filter((c) => c.id !== iv.targetCandidateId);
|
||||||
// 강제 탈락시킨 자리에 새 후보 1개를 무작위로 채운다 (§7.3).
|
// 교체 후보는 개입을 지정하는 시점(UI)에서 미리 뽑아 replacementId로 실어 보낸다.
|
||||||
const replacement = pick(availableForTabling(pool), 1)[0];
|
// 여기서 무작위로 뽑으면 의회 화면의 표 미리보기와 확정 결과가 어긋난다.
|
||||||
if (replacement) pool = [...pool, replacement];
|
const replacement = iv.replacementId
|
||||||
|
? TABLEABLE_PROTOCOLS.find((p) => p.id === iv.replacementId)
|
||||||
|
: pick(availableForTabling(pool), 1)[0];
|
||||||
|
if (replacement && !pool.some((c) => c.id === replacement.id)) pool = [...pool, replacement];
|
||||||
}
|
}
|
||||||
if (iv.type === '내부' && iv.targetPoolProtocolId) {
|
if (iv.type === '내부' && iv.targetPoolProtocolId) {
|
||||||
const tabled = PROTOCOL_TYPE_A.find((p) => p.id === iv.targetPoolProtocolId);
|
const tabled = TABLEABLE_PROTOCOLS.find((p) => p.id === iv.targetPoolProtocolId);
|
||||||
if (tabled && !pool.some((c) => c.id === tabled.id)) pool = [...pool, tabled];
|
if (tabled && !pool.some((c) => c.id === tabled.id)) pool = [...pool, tabled];
|
||||||
}
|
}
|
||||||
if (iv.type === '위기' && iv.targetPenaltyId) penaltyIdToClear = iv.targetPenaltyId;
|
if (iv.type === '위기' && iv.targetPenaltyId) penaltyIdToClear = iv.targetPenaltyId;
|
||||||
if (iv.type === '혼합' && iv.targetCandidateId) forcedWinnerId = iv.targetCandidateId;
|
if (iv.type === '혼합' && iv.targetCandidateId) voteSwingTargetId = iv.targetCandidateId;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { candidates: pool, forcedWinnerId, deferred, penaltyIdToClear };
|
return { candidates: pool, voteSwingTargetId, deferred, penaltyIdToClear };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 규약 효과 설명 (툴팁용) ──
|
||||||
|
* "무엇이 막혔는지 정확히" 알려주려면 족보 이름·조건까지 짚어야 한다. UI가 문구를 자체
|
||||||
|
* 추측하지 않도록 카탈로그 옆에서 같이 생성한다. */
|
||||||
|
|
||||||
|
const TYPE_LABEL = { A: '족보 제한', C: '징벌', D: '이득' };
|
||||||
|
|
||||||
|
/** @returns {{ typeLabel, summary, detail, tone }} tone: 'bad' | 'good' | 'neutral' */
|
||||||
|
export function protocolEffectDetail(protocol) {
|
||||||
|
const e = protocol.effect;
|
||||||
|
const typeLabel = TYPE_LABEL[protocol.type] || '규약';
|
||||||
|
|
||||||
|
switch (e.kind) {
|
||||||
|
case 'disable_hand': {
|
||||||
|
const def = HAND_DEFS[e.patternId];
|
||||||
|
return {
|
||||||
|
typeLabel,
|
||||||
|
summary: `「${def.name}」 족보 사용 불가`,
|
||||||
|
detail: `조건 「${def.condition}」. 이 족보는 합본 판정에서 제외되어, 해당 조합을 골라도 성립하지 않습니다 (실적 ${def.score} · 명분 ${def.baseJustification} 손실).`,
|
||||||
|
tone: 'bad',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'min_grade':
|
||||||
|
return {
|
||||||
|
typeLabel,
|
||||||
|
summary: `등급 ${e.minGrade} 미만 카드 합본 불가`,
|
||||||
|
detail: `선택한 카드 중 등급 ${e.minGrade} 미만이 하나라도 있으면 어떤 족보도 성립하지 않습니다. 저등급 카드는 상점에서 직접 처리해야 합니다.`,
|
||||||
|
tone: 'bad',
|
||||||
|
};
|
||||||
|
case 'gauge_surcharge':
|
||||||
|
return {
|
||||||
|
typeLabel,
|
||||||
|
summary: `결재당 게이지 ${e.amount > 0 ? '+' : ''}${e.amount.toFixed(1)}%p`,
|
||||||
|
detail: e.amount > 0
|
||||||
|
? `결재 1건마다 4대 게이지 전부가 추가로 ${e.amount.toFixed(1)}%p씩 더 오릅니다. 주목도와 무관하게 항상 붙습니다.`
|
||||||
|
: `결재 1건마다 4대 게이지 상승분이 ${Math.abs(e.amount).toFixed(1)}%p씩 줄어듭니다.`,
|
||||||
|
tone: e.amount > 0 ? 'bad' : 'good',
|
||||||
|
};
|
||||||
|
case 'token_surcharge':
|
||||||
|
return {
|
||||||
|
typeLabel,
|
||||||
|
summary: `토큰 지출 가산 ${e.amount > 0 ? '+' : ''}${e.amount}`,
|
||||||
|
detail: e.amount > 0
|
||||||
|
? `상점 소각·리롤 등 모든 토큰 지출에 ${e.amount}이(가) 추가로 붙습니다.`
|
||||||
|
: `모든 토큰 지출 가산이 ${Math.abs(e.amount)} 줄어듭니다 (최소 0).`,
|
||||||
|
tone: e.amount > 0 ? 'bad' : 'good',
|
||||||
|
};
|
||||||
|
case 'grant_token':
|
||||||
|
return {
|
||||||
|
typeLabel,
|
||||||
|
summary: `통과 즉시 토큰 +${e.amount}`,
|
||||||
|
detail: `표결 통과 시점에 토큰 ${e.amount}을(를) 한 번 지급합니다. 지속 효과는 없습니다.`,
|
||||||
|
tone: 'good',
|
||||||
|
};
|
||||||
|
case 'attention_cap_bonus':
|
||||||
|
return {
|
||||||
|
typeLabel,
|
||||||
|
summary: `주목도 한도 +${e.amount}`,
|
||||||
|
detail: `다음 라운드의 주목도 한도가 ${e.amount} 늘어납니다. 버스트 전까지 카드를 더 받을 수 있습니다.`,
|
||||||
|
tone: 'good',
|
||||||
|
};
|
||||||
|
case 'required_score_delta':
|
||||||
|
return {
|
||||||
|
typeLabel,
|
||||||
|
summary: `실적 요구치 ${e.amount > 0 ? '+' : ''}${e.amount}`,
|
||||||
|
detail: `다음 의회의 실적 요구치가 ${Math.abs(e.amount)} ${e.amount < 0 ? '낮아집니다' : '높아집니다'}.`,
|
||||||
|
tone: e.amount < 0 ? 'good' : 'bad',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return { typeLabel, summary: '효과 미정', detail: '', tone: 'neutral' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 활성 규약 효과 합산 ──
|
||||||
|
* 징벌(C)과 활성 규약(A/D)이 같은 종류의 수치를 건드리므로, 호출부에서 둘을 합친 배열을 넘긴다. */
|
||||||
|
|
||||||
|
/** 결재당 게이지 상승에 더해지는 가산분 합계 (이득 규약이면 음수). */
|
||||||
|
export function gaugeSurchargeOf(protocols) {
|
||||||
|
return protocols.reduce((sum, p) => sum + (p.effect.kind === 'gauge_surcharge' ? p.effect.amount : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 모든 토큰 지출에 더해지는 가산분 합계 (이득 규약이면 음수). */
|
||||||
|
export function tokenSurchargeOf(protocols) {
|
||||||
|
return protocols.reduce((sum, p) => sum + (p.effect.kind === 'token_surcharge' ? p.effect.amount : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이번 라운드 주목도 한도에 더해지는 보너스. */
|
||||||
|
export function attentionCapBonusOf(protocols) {
|
||||||
|
return protocols.reduce((sum, p) => sum + (p.effect.kind === 'attention_cap_bonus' ? p.effect.amount : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 실적 요구치에 더해지는 증감분. */
|
||||||
|
export function requiredScoreDeltaOf(protocols) {
|
||||||
|
return protocols.reduce((sum, p) => sum + (p.effect.kind === 'required_score_delta' ? p.effect.amount : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 통과 즉시 1회 지급되는 토큰. */
|
||||||
|
export function grantTokenOf(protocol) {
|
||||||
|
return protocol && protocol.effect.kind === 'grant_token' ? protocol.effect.amount : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 합본 판정에 규약 효과를 얹는다 ── */
|
/* ── 합본 판정에 규약 효과를 얹는다 ── */
|
||||||
|
|
||||||
/** 활성 유형 A 규약(들)을 반영해 detectHands 결과를 거른다. */
|
/** 활성 규약을 반영해 detectHands 결과를 거른다. */
|
||||||
export function filterHandsByProtocols(matches, selection, activeProtocols) {
|
export function filterHandsByProtocols(matches, selection, activeProtocols) {
|
||||||
let allowed = matches;
|
let allowed = matches;
|
||||||
activeProtocols.forEach((p) => {
|
activeProtocols.forEach((p) => {
|
||||||
@@ -181,13 +454,3 @@ export function filterHandsByProtocols(matches, selection, activeProtocols) {
|
|||||||
});
|
});
|
||||||
return allowed;
|
return allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 활성 징벌(유형 C)이 게이지 상승률에 더하는 가산분 합계. */
|
|
||||||
export function penaltyGaugeSurcharge(penalties) {
|
|
||||||
return penalties.reduce((sum, p) => sum + (p.effect.kind === 'gauge_surcharge' ? p.effect.amount : 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 활성 징벌(유형 C)이 토큰 지출 가산에 더하는 합계. */
|
|
||||||
export function penaltyTokenSurcharge(penalties) {
|
|
||||||
return penalties.reduce((sum, p) => sum + (p.effect.kind === 'token_surcharge' ? p.effect.amount : 0), 0);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import { AXES, AXIS_META, clampGrade } from './symbols.js';
|
|||||||
|
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
// meta: pollutionPool.js에서 뽑은 { name, flavor, tag } — 책상 카드가 실제 이름/사연을 갖게 한다.
|
// meta: pollutionPool.js에서 뽑은 { name, flavor, tag } — 책상 카드가 실제 이름/사연을 갖게 한다.
|
||||||
export const makeDeskCard = (axis, grade, meta = {}) => ({
|
// round: 이 카드를 받은 라운드 번호. 한도 판정(roundAttention)이 이월 카드를 빼고 세기 위해 필요하다.
|
||||||
|
export const makeDeskCard = (axis, grade, meta = {}, round = 1) => ({
|
||||||
id: `desk-${Date.now().toString(36)}-${(seq++).toString(36)}`,
|
id: `desk-${Date.now().toString(36)}-${(seq++).toString(36)}`,
|
||||||
axis,
|
axis,
|
||||||
grade: clampGrade(grade),
|
grade: clampGrade(grade),
|
||||||
|
round,
|
||||||
name: meta.name || null,
|
name: meta.name || null,
|
||||||
flavor: meta.flavor || null,
|
flavor: meta.flavor || null,
|
||||||
tag: meta.tag || null,
|
tag: meta.tag || null,
|
||||||
@@ -50,50 +52,85 @@ export function axisAttention(deskCards, axis) {
|
|||||||
.reduce((sum, c) => sum + c.grade, 0);
|
.reduce((sum, c) => sum + c.grade, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 책상 전체 주목도 = 모든 카드의 등급 합. */
|
/** 책상 전체 주목도 = 모든 카드의 등급 합. 이월 카드까지 포함하며, 패시브 페널티
|
||||||
|
* (게이지 상승 A · 토큰 가산 C)의 기준이다 — 쌓아둔 미결은 계속 대가를 치른다. */
|
||||||
export function totalAttention(deskCards) {
|
export function totalAttention(deskCards) {
|
||||||
return deskCards.reduce((sum, c) => sum + c.grade, 0);
|
return deskCards.reduce((sum, c) => sum + c.grade, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 이번 라운드 주목도 = 이번 라운드에 새로 받은 카드의 등급 합. 하드 캡(버스트) 판정 전용.
|
||||||
|
*
|
||||||
|
* 왜 총 주목도와 분리했나 (2026-09-17): 한도 판정에 책상 전체를 쓰면, 이월된 카드(§4.1) 때문에
|
||||||
|
* 라운드가 시작하자마자 이미 한도를 넘어 있어 즉시 버스트가 반복된다 — 실제 플레이에서
|
||||||
|
* 라운드가 계속 자동으로 넘어가는 버그로 드러났다. 블랙잭에서 각 핸드가 0부터 시작하듯
|
||||||
|
* 한도 판정은 "이번 판에 내가 새로 떠안은 무게"만 센다. 이월 카드의 압박은 사라지지 않고
|
||||||
|
* 총 주목도(게이지·토큰 가산)로 계속 작용한다. */
|
||||||
|
export function roundAttention(deskCards, round) {
|
||||||
|
return deskCards.filter((c) => c.round === round).reduce((sum, c) => sum + c.grade, 0);
|
||||||
|
}
|
||||||
|
|
||||||
export function attentionByAxis(deskCards) {
|
export function attentionByAxis(deskCards) {
|
||||||
const out = {};
|
const out = {};
|
||||||
AXES.forEach((axis) => { out[axis] = axisAttention(deskCards, axis); });
|
AXES.forEach((axis) => { out[axis] = axisAttention(deskCards, axis); });
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── A. 게이지 상시 상승 (§4.2-A) — 축별 주목도 기준 누진 ── */
|
/* ── A. 게이지 상시 상승 (§4.2-A) — 축별 카드 "장수" 기준 급누진 + 등급 배율.
|
||||||
|
*
|
||||||
|
* 2026-09-17 재설계: 등급 합계(attention)만으로는 상승이 너무 느렸다 — 이 게임 카드의 등급이
|
||||||
|
* 대부분 1~4에 몰려 있어서(§17 카드 데이터 현황), 같은 축 카드 서너 장을 쌓아도 attention이
|
||||||
|
* 낮은 구간에 계속 머물러 위협이 전혀 체감되지 않았다(플레이 피드백). 그래서 1차 기준을
|
||||||
|
* "쌓인 장수"로 되돌리되, 등급이 여전히 의미를 갖도록 평균 등급 배율을 곱한다 —
|
||||||
|
* 저등급 카드만 쌓이면 완만하고, 고등급이 섞이면 같은 장수에서도 훨씬 아프다.
|
||||||
|
* 피드백 원문: "누적 카드 3장 이상일 때부터 증가값이 엄청 커졌으면 함". */
|
||||||
|
|
||||||
const GAUGE_TIERS = [
|
const GAUGE_COUNT_TIERS = [
|
||||||
{ max: 0, rate: 0 },
|
{ max: 2, base: 0.5 }, // 1~2장: 아직 완만
|
||||||
{ max: 15, rate: 0.5 },
|
{ max: 4, base: 3.0 }, // 3~4장: 여기서 확 뛴다 (피드백 핵심 요구)
|
||||||
{ max: 30, rate: 1.5 },
|
{ max: 6, base: 6.0 },
|
||||||
{ max: 40, rate: 3.0 },
|
{ max: 9, base: 10.0 },
|
||||||
];
|
];
|
||||||
const GAUGE_OVERRUN_FROM = 41;
|
const GAUGE_COUNT_OVERRUN_FROM = 10;
|
||||||
const GAUGE_OVERRUN_BASE = 5.0;
|
const GAUGE_COUNT_OVERRUN_BASE = 15.0;
|
||||||
|
const GAUGE_COUNT_OVERRUN_STEP = 2.0; // 10장부터 1장마다 가산
|
||||||
|
|
||||||
/** 축 주목도에 따른 결재 1건당 게이지 상승률(%). 41 이상은 초과분 10당 +1.0% 추가 누진. */
|
const GAUGE_GRADE_BASELINE = 5; // 이 등급이 "평균" — 배율 1.0 기준점 (등급 범위 1~10의 중앙)
|
||||||
export function gaugeRiseRate(attention) {
|
|
||||||
const tier = GAUGE_TIERS.find((t) => attention <= t.max);
|
/** 축에 쌓인 장수 기준 결재 1건당 게이지 상승 기본값(%). */
|
||||||
if (tier) return tier.rate;
|
function gaugeCountBase(count) {
|
||||||
const overrun = attention - GAUGE_OVERRUN_FROM;
|
const tier = GAUGE_COUNT_TIERS.find((t) => count <= t.max);
|
||||||
return GAUGE_OVERRUN_BASE + Math.floor(overrun / 10) * 1.0;
|
if (tier) return tier.base;
|
||||||
|
return GAUGE_COUNT_OVERRUN_BASE + (count - GAUGE_COUNT_OVERRUN_FROM) * GAUGE_COUNT_OVERRUN_STEP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 축 카드 수·등급 합계로 결재 1건당 게이지 상승률(%)을 낸다.
|
||||||
|
* base(장수 구간) × (평균 등급 / 5) — 고등급이 섞이면 같은 장수에서도 더 아프다. */
|
||||||
|
export function gaugeRiseRate(count, attention) {
|
||||||
|
if (count <= 0) return 0;
|
||||||
|
const base = gaugeCountBase(count);
|
||||||
|
const avgGrade = attention / count;
|
||||||
|
return base * (avgGrade / GAUGE_GRADE_BASELINE);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 축별 현재 상승률(%) — DeskBar의 "▲+X%/결재" 표시에 그대로 쓴다.
|
/** 축별 현재 상승률(%) — DeskBar의 "▲+X%/결재" 표시에 그대로 쓴다.
|
||||||
* extraFlat: 활성 징벌 규약(§9.2 유형C)이 얹는 가산분(council.js의 penaltyGaugeSurcharge). */
|
* extraFlat: 활성 징벌 규약(§9.2 유형C)이 얹는 가산분(council.js의 penaltyGaugeSurcharge). */
|
||||||
export function gaugeRiseRateByAxis(deskCards, extraFlat = 0) {
|
export function gaugeRiseRateByAxis(deskCards, extraFlat = 0) {
|
||||||
|
const counts = countByAxis(deskCards);
|
||||||
const out = {};
|
const out = {};
|
||||||
AXES.forEach((axis) => { out[axis] = gaugeRiseRate(axisAttention(deskCards, axis)) + extraFlat; });
|
// extraFlat(징벌 규약 §9.2 유형C)은 "모든 축의 게이지 상승"이라 카드 유무와 무관하게 항상 더한다.
|
||||||
|
AXES.forEach((axis) => {
|
||||||
|
out[axis] = gaugeRiseRate(counts[axis], axisAttention(deskCards, axis)) + extraFlat;
|
||||||
|
});
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 매 결재마다 4개 게이지 전부에 축별 상승분을 더한다. gauges = { entropy, resistance, panic, risk } (0~100). */
|
/** 매 결재마다 4개 게이지 전부에 축별 상승분을 더한다. gauges = { entropy, resistance, panic, risk } (0~100). */
|
||||||
export function applyPassiveGaugeRise(gauges, deskCards, extraFlat = 0) {
|
export function applyPassiveGaugeRise(gauges, deskCards, extraFlat = 0) {
|
||||||
|
const counts = countByAxis(deskCards);
|
||||||
const next = { ...gauges };
|
const next = { ...gauges };
|
||||||
AXES.forEach((axis) => {
|
AXES.forEach((axis) => {
|
||||||
const gaugeKey = AXIS_META[axis].gauge;
|
const gaugeKey = AXIS_META[axis].gauge;
|
||||||
const rate = gaugeRiseRate(axisAttention(deskCards, axis)) + extraFlat;
|
const rate = gaugeRiseRate(counts[axis], axisAttention(deskCards, axis)) + extraFlat;
|
||||||
next[gaugeKey] = Math.min(100, (next[gaugeKey] || 0) + rate);
|
next[gaugeKey] = Math.min(100, (next[gaugeKey] || 0) + rate);
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
@@ -103,21 +140,23 @@ export function applyPassiveGaugeRise(gauges, deskCards, extraFlat = 0) {
|
|||||||
* 라운드당 주목도 한도를 넘기면 그 즉시 라운드가 강제 종료된다(합본 기회 없이, 카드는 이월).
|
* 라운드당 주목도 한도를 넘기면 그 즉시 라운드가 강제 종료된다(합본 기회 없이, 카드는 이월).
|
||||||
* 한도 자체가 라운드가 갈수록 올라가 후반에는 더 크게 쌓아도 되는 대신 압박도 커진다.
|
* 한도 자체가 라운드가 갈수록 올라가 후반에는 더 크게 쌓아도 되는 대신 압박도 커진다.
|
||||||
* 한도 밑에서는 플레이어가 아무 때나 자율적으로 라운드를 끝내고 정산으로 넘어갈 수 있다 —
|
* 한도 밑에서는 플레이어가 아무 때나 자율적으로 라운드를 끝내고 정산으로 넘어갈 수 있다 —
|
||||||
* "한 장 더 받을까 말까"의 긴장은 여기서 나온다. 결정 노트: 2026-09-16-라운드-주목도-하드캡-복원. */
|
* "한 장 더 받을까 말까"의 긴장은 여기서 나온다. 결정 노트: 2026-09-16-라운드-주목도-하드캡-복원.
|
||||||
|
*
|
||||||
|
* 2026-09-17: 판정 기준을 총 주목도 → 이번 라운드 주목도로 교체 (roundAttention 주석 참고). */
|
||||||
|
|
||||||
const ROUND_CAP_BASE = 21; // 가안 — 최초 블랙잭 기준으로 복귀
|
const ROUND_CAP_BASE = 21; // 가안 — 최초 블랙잭 기준으로 복귀
|
||||||
const ROUND_CAP_GROWTH = 5; // 라운드당 가산분, 가안
|
const ROUND_CAP_GROWTH = 5; // 라운드당 가산분, 가안
|
||||||
|
|
||||||
/** 라운드 번호에 따른 이번 라운드의 주목도 한도. */
|
/** 라운드 번호에 따른 이번 라운드의 주목도 한도. bonus: 이득 규약의 한도 보너스(§9.2 유형 D). */
|
||||||
export function roundAttentionCap(round) {
|
export function roundAttentionCap(round, bonus = 0) {
|
||||||
return ROUND_CAP_BASE + (round - 1) * ROUND_CAP_GROWTH;
|
return ROUND_CAP_BASE + (round - 1) * ROUND_CAP_GROWTH + bonus;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 한도 대비 총 주목도 구간: 'normal' | 'warning'(한도 5 이내로 근접) | 'bust'(한도 초과). */
|
/** 한도 대비 이번 라운드 주목도 구간: 'normal' | 'warning'(한도 5 이내로 근접) | 'bust'(한도 초과). */
|
||||||
export function attentionZone(deskCards, cap) {
|
export function attentionZone(deskCards, cap, round) {
|
||||||
const total = totalAttention(deskCards);
|
const current = roundAttention(deskCards, round);
|
||||||
if (total > cap) return 'bust';
|
if (current > cap) return 'bust';
|
||||||
if (total >= cap - 5) return 'warning';
|
if (current >= cap - 5) return 'warning';
|
||||||
return 'normal';
|
return 'normal';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,11 +170,12 @@ const SURCHARGE_TIERS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/** 총 주목도 기준 모든 토큰 지출에 붙는 가산액.
|
/** 총 주목도 기준 모든 토큰 지출에 붙는 가산액.
|
||||||
* extraFlat: 활성 징벌 규약(§9.2 유형C)이 얹는 가산분(council.js의 penaltyTokenSurcharge). */
|
* extraFlat: 활성 규약이 얹는 가산분(council.js의 tokenSurchargeOf) — 이득 규약이면 음수라
|
||||||
|
* 0 밑으로 내려갈 수 있으므로, 지출이 되레 할인되지 않도록 0에서 끊는다. */
|
||||||
export function tokenSurcharge(deskCards, extraFlat = 0) {
|
export function tokenSurcharge(deskCards, extraFlat = 0) {
|
||||||
const total = totalAttention(deskCards);
|
const total = totalAttention(deskCards);
|
||||||
const tier = SURCHARGE_TIERS.find((t) => total <= t.max);
|
const tier = SURCHARGE_TIERS.find((t) => total <= t.max);
|
||||||
return (tier ? tier.surcharge : 4) + extraFlat;
|
return Math.max(0, (tier ? tier.surcharge : 4) + extraFlat);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 책상에서 특정 카드들을 제거한 새 배열을 반환한다 (합본 실행 시 사용). */
|
/** 책상에서 특정 카드들을 제거한 새 배열을 반환한다 (합본 실행 시 사용). */
|
||||||
|
|||||||
@@ -3,30 +3,32 @@ id,type,keyword,question,text,flavor,left_text,left_axis,left_grade,left_tag,rig
|
|||||||
4,극비,변이 DNA 이식 제안,실험을 승인하시겠습니까?,진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠.,인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠.,실험 승인,axis_threat,9,tag_madness,제안 거절,axis_hr,7,
|
4,극비,변이 DNA 이식 제안,실험을 승인하시겠습니까?,진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠.,인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠.,실험 승인,axis_threat,9,tag_madness,제안 거절,axis_hr,7,
|
||||||
5,작전,전술 폭격 건의,폭격을 승인하시겠습니까?,철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다.,희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠.,궤도 폭격 승인,axis_external,5,tag_sacrifice,작전 기각,axis_hr,2,
|
5,작전,전술 폭격 건의,폭격을 승인하시겠습니까?,철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다.,희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠.,궤도 폭격 승인,axis_external,5,tag_sacrifice,작전 기각,axis_hr,2,
|
||||||
6,일상,부산물 암시장 매각,매각을 허가하시겠습니까?,차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다.,이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠.,매각 허가,axis_external,1,tag_corruption,원칙대로 소각,,,
|
6,일상,부산물 암시장 매각,매각을 허가하시겠습니까?,차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다.,이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠.,매각 허가,axis_external,1,tag_corruption,원칙대로 소각,,,
|
||||||
7,작전,존재 양지화 프로젝트,프로젝트를 승인하시겠습니까?,대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다.,우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다.,감시망 구축,,,tag_coverup,프로젝트 보류,axis_hr,2,
|
7,작전,존재 양지화 프로젝트,프로젝트를 승인하시겠습니까?,대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다.,우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다.,감시망 구축,axis_pr,2,tag_coverup,프로젝트 보류,axis_hr,2,
|
||||||
9,위기,이단 색출 마녀사냥,어떻게 개입하시겠습니까?,기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다.,더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로!,적당히 묵인한다,axis_hr,4,,군사력으로 제압,axis_external,4,
|
9,위기,이단 색출 마녀사냥,어떻게 개입하시겠습니까?,기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다.,더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로!,적당히 묵인한다,axis_hr,4,,군사력으로 제압,axis_external,4,
|
||||||
10,일상,변칙 현상 영상 유출,어떻게 처리하시겠습니까?,한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다.,대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오.,제거 절차 승인,,,tag_coverup,영상만 삭제,axis_threat,1,
|
10,일상,변칙 현상 영상 유출,어떻게 처리하시겠습니까?,한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다.,대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오.,제거 절차 승인,,,tag_coverup,영상만 삭제,axis_pr,1,
|
||||||
12,위기,의회의 긴급 감사,어떻게 대처하시겠습니까?,의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다!,세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오.,은폐 공작 실행,axis_external,4,tag_corruption,횡령 사실 인정,axis_external,8,
|
12,위기,의회의 긴급 감사,어떻게 대처하시겠습니까?,의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다!,세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오.,은폐 공작 실행,axis_external,4,tag_corruption,횡령 사실 인정,axis_external,8,
|
||||||
13,일상,연구 부서 파업 조짐,요구를 수용하시겠습니까?,연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다.,저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요.,특별 보너스 지급,,,,요구 전면 묵살,axis_hr,1,
|
13,일상,연구 부서 파업 조짐,요구를 수용하시겠습니까?,연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다.,저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요.,특별 보너스 지급,,,,요구 전면 묵살,axis_hr,1,
|
||||||
14,작전,이계 무역로 개방,무역로를 개방하시겠습니까?,이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다.,저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠.,게이트 강제 개방,axis_threat,2,tag_alien_contact,폐쇄 상태 유지,axis_hr,2,
|
14,작전,이계 무역로 개방,무역로를 개방하시겠습니까?,이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다.,저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠.,게이트 강제 개방,axis_threat,2,tag_alien_contact,폐쇄 상태 유지,axis_hr,2,
|
||||||
15,극비,전임 국장 비밀 금고,금고를 개방하시겠습니까?,전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다.,전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다.,강제 개방 지시,,,tag_corruption,영구 봉인,,,
|
15,극비,전임 국장 비밀 금고,금고를 개방하시겠습니까?,전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다.,전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다.,강제 개방 지시,axis_external,6,tag_corruption,영구 봉인,,,
|
||||||
16,작전,메가코프 협박 작전,작전을 실행하시겠습니까?,글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다.,그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까.,작전 실행,,,,작전 취소,axis_hr,2,
|
16,작전,메가코프 협박 작전,작전을 실행하시겠습니까?,글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다.,그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까.,작전 실행,,,,작전 취소,axis_hr,2,
|
||||||
17,위기,전술 폭격 건의,폭격을 승인하시겠습니까?,제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요?,시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다.,시민 포기 및 폭격,axis_external,8,tag_ruthless,결사 방어 명령,,,
|
17,위기,전술 폭격 건의,폭격을 승인하시겠습니까?,제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요?,시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다.,시민 포기 및 폭격,axis_pr,8,tag_ruthless,결사 방어 명령,,,
|
||||||
18,일상,행정부 파업 예고,어떻게 대응하시겠습니까?,새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다.,기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다.,예산 전용으로 무마,axis_external,1,,파업 강경 대응,axis_hr,1,
|
18,일상,행정부 파업 예고,어떻게 대응하시겠습니까?,새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다.,기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다.,예산 전용으로 무마,axis_external,1,,파업 강경 대응,axis_hr,1,
|
||||||
19,극비,불침번 요원 개발,배치하시겠습니까?,비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다.,잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠.,실전 즉각 배치,axis_threat,7,,즉각 폐기 처분,axis_hr,7,
|
19,극비,불침번 요원 개발,배치하시겠습니까?,비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다.,잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠.,실전 즉각 배치,axis_threat,7,,즉각 폐기 처분,axis_hr,7,
|
||||||
20,일상,내부 스파이 첩보,어떻게 조사하시겠습니까?,기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다.,누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다.,대규모 사찰 시작,axis_hr,3,tag_political_pressure,증거 부족으로 덮음,axis_external,1,
|
20,일상,내부 스파이 첩보,어떻게 조사하시겠습니까?,기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다.,누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다.,대규모 사찰 시작,axis_hr,3,tag_political_pressure,증거 부족으로 덮음,axis_external,1,
|
||||||
25,극비,내부 고발자 발생,어떻게 막으시겠습니까?,의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다.,국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다.,사고사 위장 지시,,,tag_coverup,폭로전 정면 돌파,axis_external,9,
|
25,극비,내부 고발자 발생,어떻게 막으시겠습니까?,의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다.,국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다.,사고사 위장 지시,,,tag_coverup,폭로전 정면 돌파,axis_pr,9,
|
||||||
26,위기,차원 붕괴 폭탄 오작동,어떻게 대응하시겠습니까?,차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다.,최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다.,특수 해체팀 투입,,,tag_sacrifice,폭발 방관 및 대피,axis_threat,8,
|
26,위기,차원 붕괴 폭탄 오작동,어떻게 대응하시겠습니까?,차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다.,최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다.,특수 해체팀 투입,,,tag_sacrifice,폭발 방관 및 대피,axis_threat,8,
|
||||||
27,작전,PMC 흡수 합병,결탁하시겠습니까?,군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다.,돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠.,결탁 및 흡수 합병,,,,독자적 노선 유지,axis_hr,2,
|
27,작전,PMC 흡수 합병,결탁하시겠습니까?,군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다.,돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠.,결탁 및 흡수 합병,,,,독자적 노선 유지,axis_hr,2,
|
||||||
28,작전,대규모 거주 구역 실험장화,제안을 승인하시겠습니까?,변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다.,시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠.,돔 건설 강행,axis_threat,2,,윤리적 이유로 기각,axis_external,2,
|
28,작전,대규모 거주 구역 실험장화,제안을 승인하시겠습니까?,변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다.,시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠.,돔 건설 강행,axis_threat,2,,윤리적 이유로 기각,axis_external,2,
|
||||||
30,일상,믹스 커피 재고 고갈,어떻게 해결하시겠습니까?,국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다.,종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까?,최고급 원두 지원,,,,헛소리 금지령,axis_hr,1,
|
30,일상,믹스 커피 재고 고갈,어떻게 해결하시겠습니까?,국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다.,종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까?,최고급 원두 지원,,,,헛소리 금지령,axis_hr,1,
|
||||||
31,위기,신화적 존재 강림,어떻게 대적하시겠습니까?,신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다!,최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까?,현실 조작기 가동,axis_threat,10,tag_madness,모든 예비병력 투입,axis_external,4,tag_sacrifice
|
31,위기,신화적 존재 강림,어떻게 대적하시겠습니까?,신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다!,최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까?,현실 조작기 가동,axis_threat,10,tag_madness,모든 예비병력 투입,axis_pr,4,tag_sacrifice
|
||||||
2,위기,격리실 오염 발생,오염을 어떻게 정화하시겠습니까?,지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다.,보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다.,네이팜 소각 강행,,,,조사팀 파견,axis_threat,4,
|
2,위기,격리실 오염 발생,오염을 어떻게 정화하시겠습니까?,지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다.,보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다.,네이팜 소각 강행,,,,조사팀 파견,axis_threat,4,
|
||||||
3,위기,촉수 괴물 출현,출동 승인하시겠습니까?,7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다.,규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다.,내역 위조 후 출동,axis_external,4,,규정대로 반려,axis_threat,4,
|
3,위기,촉수 괴물 출현,출동 승인하시겠습니까?,7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다.,규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다.,내역 위조 후 출동,axis_external,4,,규정대로 반려,axis_threat,4,
|
||||||
8,극비,대규모 도서관 건립,예산을 배정하시겠습니까?,고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다.,이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다.,예산 배정,,,,예산 전액 삭감,axis_external,7,
|
8,극비,대규모 도서관 건립,예산을 배정하시겠습니까?,고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다.,이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다.,예산 배정,,,,예산 전액 삭감,axis_external,7,
|
||||||
11,위기,사이비 종교 인신 공양,어떻게 대응하시겠습니까?,특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다.,위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다.,군사 개입 지시,axis_hr,4,,의식 방관,axis_threat,6,
|
11,위기,사이비 종교 인신 공양,어떻게 대응하시겠습니까?,특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다.,위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다.,군사 개입 지시,axis_threat,5,,의식 방관,axis_threat,6,
|
||||||
21,작전,비정상적 위기 발생,국장님 결단이 필요합니다,순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다.,괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다.,수용소 건립 승인,,,,제안 단호히 거절,axis_external,2,
|
21,작전,비정상적 위기 발생,국장님 결단이 필요합니다,순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다.,괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다.,수용소 건립 승인,,,,제안 단호히 거절,axis_external,2,
|
||||||
22,극비,이계의 유물 인양,유물을 어떻게 처리하시겠습니까?,고대 공존당의 사제들이 심해의 유물을 인양했습니다. 만지는 자의 정신을 갉아먹지만 막대한 지식을 줍니다.,약간의 두통만 참으면 이계의 비밀을 모두 알 수 있습니다. 요원 하나쯤 미쳐도 괜찮습니다.,유물 집중 연구,axis_threat,7,,유물 파괴 명령,axis_external,7,
|
22,극비,이계의 유물 인양,유물을 어떻게 처리하시겠습니까?,고대 공존당의 사제들이 심해의 유물을 인양했습니다. 만지는 자의 정신을 갉아먹지만 막대한 지식을 줍니다.,약간의 두통만 참으면 이계의 비밀을 모두 알 수 있습니다. 요원 하나쯤 미쳐도 괜찮습니다.,유물 집중 연구,axis_threat,7,,유물 파괴 명령,axis_external,7,
|
||||||
23,위기,이계의 주시자 출현,대중을 어떻게 통제하시겠습니까?,얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다.,하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오.,인터넷 전면 차단,axis_pr,4,,진정제 무상 배포,,,
|
23,위기,이계의 주시자 출현,대중을 어떻게 통제하시겠습니까?,얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다.,하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오.,인터넷 전면 차단,axis_pr,4,,진정제 무상 배포,axis_pr,2,
|
||||||
24,일상,요원 집단 트라우마,어떻게 지원하시겠습니까?,요원들이 괴수 진압 후 심각한 트라우마에 시달리고 있습니다. 심리 치료 지원이 시급합니다.,밤마다 촉수가 목을 조르는 악몽을 꾼답니다. 차라리 그 부분 기억을 지워버릴까요?,정신과 치료 지원,,,,트라우마 기억 소거,axis_external,1,
|
24,일상,요원 집단 트라우마,어떻게 지원하시겠습니까?,요원들이 괴수 진압 후 심각한 트라우마에 시달리고 있습니다. 심리 치료 지원이 시급합니다.,밤마다 촉수가 목을 조르는 악몽을 꾼답니다. 차라리 그 부분 기억을 지워버릴까요?,정신과 치료 지원,,,,트라우마 기억 소거,axis_external,1,
|
||||||
29,극비,이계 군주의 외교,제안을 수락하시겠습니까?,이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다.,요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다.,거래 즉시 성사,,,,사절단 전원 사살,axis_threat,7,
|
29,극비,이계 군주의 외교,제안을 수락하시겠습니까?,이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다.,요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다.,거래 즉시 성사,,,,사절단 전원 사살,axis_threat,7,"
|
||||||
|
""
|
||||||
|
"
|
||||||
|
|||||||
|
+68
-9
@@ -5,21 +5,80 @@
|
|||||||
|
|
||||||
## 열린 이슈
|
## 열린 이슈
|
||||||
|
|
||||||
- **유형 D(이득 규약) 카탈로그가 없음** — §7.2 "내부 명분(강제 상정)"이 지금은 전부 불리한
|
- **유형 B(금리형 규약) 카탈로그가 없음** — 유형 A·C·D는 채웠다. B는 게임오버 게이지 최대치
|
||||||
유형 A 규약만 상정 대상이라 실속이 없다. 유형 B·D 채워지면 다시 볼 것.
|
변경·상점 가격 배율 등 판 전체 변수라 §20.2 소량 배치 콘텐츠 작업 때 채울 것.
|
||||||
- **혼합 명분("표결 1표 뒤집기")이 자리표시자** — 실제 13인 위원회 표가 없어서 "후보 직접
|
- **공보(axis_pr) 축 선택지가 여전히 7개로 최소 수준** — 축별 재배정으로 1개→7개까지 늘렸지만
|
||||||
통과 지정"으로 대체했다. 4차에서 위원회 구현할 때 반드시 재설계.
|
다른 축(12~14개)에 비하면 적다. 공보로 "일괄 처리"(같은 축 5장)를 노리기는 아직 빠듯하다.
|
||||||
- **공보(axis_pr) 축 고등급 딜레마 선택지 부족 여부 재확인 필요** — 카드 등급 재매핑은
|
§20.2 신규 카드 작성 때 공보 축을 우선 보강할 것.
|
||||||
유형(일상/작전/위기/극비) 기준으로 했고 축 기준이 아니라서, 특정 축에 고등급이 쏠려있거나
|
|
||||||
비어있을 수 있다. 다음 카드 데이터 배치 작업 때 축별 분포도 같이 확인할 것.
|
|
||||||
- **평택-서울 원격 세션 안정성 문제 원인 미파악** — 2026-09-15 작업 중 VS Code Remote-SSH
|
- **평택-서울 원격 세션 안정성 문제 원인 미파악** — 2026-09-15 작업 중 VS Code Remote-SSH
|
||||||
연결이 끊겨 커밋 전 작업이 통째로 유실됐다(재구현으로 복구). 왜 끊겼는지 원인 조사 안 됨 —
|
연결이 끊겨 커밋 전 작업이 통째로 유실됐다(재구현으로 복구). 왜 끊겼는지 원인 조사 안 됨 —
|
||||||
재발 방지책 필요.
|
재발 방지책 필요.
|
||||||
- **라운드 주목도 한도(21 + 라운드당 +5), 상점 가격, §4.2-A/C 누진 구간 수치 등은 전부 가안** —
|
- **라운드 주목도 한도(21 + 라운드당 +5), 상점 가격, 게이지 상승 장수 구간 수치 등은 전부
|
||||||
실측 플레이로 튜닝 예정. 버스트가 너무 잦거나 너무 안 일어나면 초기값·증가폭부터 의심할 것.
|
가안** — 실측 플레이로 튜닝 예정. 3~4장 구간의 6배 점프(0.5%→3.0%)가 적당한지부터 확인할 것.
|
||||||
|
- **이월 누적이 실제로 압박이 되는지 미검증** — 주목도 라운드 분리(2026-09-17) 이후 이월분은
|
||||||
|
게이지·토큰 가산에만 작용한다. 이월을 쌓아두고 매 라운드 한도까지 새로 받는 플레이가 지배
|
||||||
|
전략이 되지 않는지 확인 필요. 결정 노트: [[2026-09-17-주목도-라운드-분리]]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-09-17 — 플레이 피드백 5건 반영 (합본 결재 리뉴얼 후속)
|
||||||
|
|
||||||
|
**무엇을 왜:** 합본 결재 리뉴얼(같은 날 앞선 작업) 이후 나온 밸런스 피드백 5건. 그중 "결재
|
||||||
|
카드가 고정으로 등장하는 것 같다"는 실제 버그였다 — 카드 인덱스를 그냥 +1씩 돌려서 CSV 파일
|
||||||
|
순서 그대로, 매 실행마다 완전히 동일한 순서로 반복되고 있었다(진짜 무작위가 아니라 결정론적
|
||||||
|
순환). 이게 "위협 축도 거의 등장 안 함" 피드백의 상당 부분을 설명한다 — 테스트할 때마다 같은
|
||||||
|
초반 카드만 봤을 것이다. 결정 노트: [[2026-09-17-게이지상승-장수기준-복원]]
|
||||||
|
|
||||||
|
- **결재 카드 셔플 큐 도입**: Fisher–Yates로 섞은 덱에서 앞부터 뽑고, 큐가 비면 다시 섞어
|
||||||
|
채운다. 한 바퀴 안에서는 같은 카드가 반복되지 않으면서 실행마다·바퀴마다 순서가 달라진다.
|
||||||
|
Playwright로 3번 재로딩해 첫 카드가 매번 다른 것, 15회 연속 뽑기에서 직전 반복 0회 확인
|
||||||
|
- **게이지 상승 기준을 등급 합계(주목도) → 카드 장수로 재설계** (등급 배율은 유지): 실제 카드
|
||||||
|
등급이 1~4급에 몰려 있어 등급 합계만으로는 3~4장을 쌓아도 최하 구간(+0.5%)을 못 벗어났다.
|
||||||
|
장수 구간(1~2장 0.5% → 3~4장 3.0% → 5~6장 6.0% → 7~9장 10.0% → 10장+ 15.0%+)을 1차
|
||||||
|
기준으로 되돌리고 `축 평균 등급/5` 배율을 곱해 등급도 계속 의미를 갖게 했다. 시뮬레이션으로
|
||||||
|
검증: 등급6 카드가 쌓이는 축은 12장째에 게임오버, 3장째에 전 구간 대비 6배 점프(0.6%→3.6%)
|
||||||
|
- **위협 축 카드 1개 재배정**: id11 "사이비 종교 인신 공양" 좌측("군사 개입 지시")이 인사 축
|
||||||
|
이었는데, 고대 신 소환 저지 개입이 인사(HR) 사안일 리 없다 — 위협 축으로 이동(4급→5급).
|
||||||
|
위협 11→12개
|
||||||
|
- **완전 무행동 카드 제거**: id15 "전임 국장 비밀 금고"가 좌/우 모두 오염 없는 유일한 카드였다
|
||||||
|
(결재해도 아무 일도 안 일어남). 좌측("강제 개방 지시")에 대외/6급 부여 — 비자금 발견 시
|
||||||
|
정치적 위험이라는 기존 `tag_corruption` 태그와도 맞는다. 우측("영구 봉인")은 안전 선택으로 유지
|
||||||
|
- **"오염 카드 한쪽만 있는 선택지가 많아 초반엔 안전 선택 메리트가 없다"는 피드백은 카드 데이터가
|
||||||
|
아니라 게이지 곡선 문제로 판단해 별도 수정 안 함** — 위 장수 기준 재설계로 3장 이상 쌓일 때의
|
||||||
|
손해가 뚜렷해졌으므로, 안전 선택의 가치는 이걸로 회복된다고 보고 카드 재배분은 보류
|
||||||
|
- Playwright로 셔플·다양성·게이지 표시 전부 재확인, 콘솔 에러 0건
|
||||||
|
|
||||||
|
## 2026-09-17 — 열린 이슈 3건 해소 + 플레이 피드백 4건 반영
|
||||||
|
|
||||||
|
**무엇을 왜:** 축별 등급 분포 확인에서 공보 축이 43개 선택지 중 1개뿐인 게 드러났고(대중 공황
|
||||||
|
게이지가 사실상 죽은 축), 유형 D 부재로 내부 명분이 무용지물이었으며, 혼합 명분은 자리표시자
|
||||||
|
였다. 여기에 플레이 피드백 4건을 같이 처리했다. 그중 **주목도 미초기화는 게임 진행이 불가능한
|
||||||
|
크리티컬 버그**여서 설계 결정이 필요했다 — 결정 노트: [[2026-09-17-주목도-라운드-분리]]
|
||||||
|
|
||||||
|
- **공보 축 보강**: 서사 기준으로 축이 어긋난 선택지 6개를 공보로 재배정(영상 유출 후속, 진정제
|
||||||
|
배포, 감시망 구축, 전 병력 투입, 시민 포기 폭격, 폭로전). 공보 1→7개, 축 분포
|
||||||
|
위협11/인사14/공보7/대외13으로 균형 개선. 신규 카드 작성이 아니라 재배정이라 §20.2 범위 밖
|
||||||
|
- **유형 D(이득 규약) 5종 신설**: 토큰 즉시 지급 / 게이지 완화 / 지출 가산 완화 / 주목도 한도
|
||||||
|
+8 / 실적 요구치 −20. 후보 등장률 30%, 내부 명분의 강제 상정 대상에 포함 — 이게 내부 명분의
|
||||||
|
존재 이유다. `gaugeSurchargeOf`·`tokenSurchargeOf` 등을 징벌 전용에서 규약 공용으로 일반화
|
||||||
|
- **13석 표결 실제 구현**: `tallyVotes`(최대잔여법, 결정적 해시라 개입해도 표가 흔들리지 않음)
|
||||||
|
+ `applyVoteSwing`. 혼합 명분이 "직접 통과 지정"에서 §7.3 스펙대로 **1표 이동**으로 바뀌었고,
|
||||||
|
동점은 스윙 전 득표가 많던 쪽이 이겨 **1표 차에서만 뒤집힌다**. 의회 화면에 예상 득표·표 차·
|
||||||
|
"혼합 명분으로 뒤집을 수 있음/없음" 힌트를 노출
|
||||||
|
- **주목도 라운드 분리**(크리티컬 버그 수정): 한도 판정은 이번 라운드분, 게이지·토큰 가산은
|
||||||
|
총계(이월 포함). 카드에 받은 라운드 번호를 기록해 구분. 기존엔 이월 때문에 2라운드가
|
||||||
|
시작하자마자 버스트가 무한 반복됐다
|
||||||
|
- **"정산하기" 버튼을 결재 카드 아래로 이동** — 툴바 구석에 있어 핵심 결정 버튼인 걸 모른다는
|
||||||
|
피드백. 이번 라운드 주목도/한도/남은 여유를 버튼 옆에 같이 표시
|
||||||
|
- **툴팁 신설**(`InfoTooltip`·`ProtocolChip`): 규약·징벌·명분을 누르면 "정확히 무엇이 막혔는지"를
|
||||||
|
띄운다. 설명 문구는 UI가 추측하지 않고 `council.js`의 `protocolEffectDetail`·
|
||||||
|
`justificationDetail`이 카탈로그에서 생성 — 예: "「일괄 처리」 족보 사용 불가 / 조건 「같은 축
|
||||||
|
5장」. 합본 판정에서 제외되어 실적 40·명분 2 손실". 모바일 대상이라 hover가 아닌 클릭으로 연다
|
||||||
|
- 부수 수정: 이득 규약의 음수 가산이 지출을 할인하지 않도록 `tokenSurcharge`를 0에서 끊음,
|
||||||
|
좁은 화면에서 의회 감사 행이 잘리던 문제(`flex-wrap`), 툴팁 조사 오류("일괄 처리이(가)")
|
||||||
|
- Playwright로 버스트→상점→의회→2라운드 전체 검증: 2라운드가 0/26으로 리셋되고 이월 23은 별도
|
||||||
|
표시, 합본→명분 획득→명분 툴팁, 규약 툴팁, 자율 정산 모두 확인. 콘솔 에러 0건
|
||||||
|
|
||||||
## 2026-09-16 — 라운드/회기 이층 구조 폐지, 주목도 하드 캡(버스트) 재도입
|
## 2026-09-16 — 라운드/회기 이층 구조 폐지, 주목도 하드 캡(버스트) 재도입
|
||||||
|
|
||||||
**무엇을 왜:** 직전 재구현에서 도입한 "라운드(손)+회기(정산·의회 텀)" 이층 구조와 "결재 8건 고정"
|
**무엇을 왜:** 직전 재구현에서 도입한 "라운드(손)+회기(정산·의회 텀)" 이층 구조와 "결재 8건 고정"
|
||||||
|
|||||||
+36
-16
@@ -147,33 +147,45 @@
|
|||||||
> 카드 등급이 1~10으로 확장되며, 페널티 기준을 **장수**가 아니라 **등급 합계**로 삼는다. 이 등급 합계를 **주목도**라 부른다 — 처리하지 않은 사안이 무거울수록 조직 안팎의 시선이 쏠린다는 관료제적 의미다.
|
> 카드 등급이 1~10으로 확장되며, 페널티 기준을 **장수**가 아니라 **등급 합계**로 삼는다. 이 등급 합계를 **주목도**라 부른다 — 처리하지 않은 사안이 무거울수록 조직 안팎의 시선이 쏠린다는 관료제적 의미다.
|
||||||
|
|
||||||
```
|
```
|
||||||
주목도(축별) = 해당 축에 적재된 카드들의 등급 합
|
주목도(축별) = 해당 축에 적재된 카드들의 등급 합 → A. 게이지 상승
|
||||||
주목도(총계) = 책상 전체 카드들의 등급 합
|
주목도(총계) = 책상 전체 카드들의 등급 합 (이월 포함) → C. 토큰 지출 가산
|
||||||
|
주목도(이번 라운드) = 이번 라운드에 받은 카드들의 등급 합 → B. 라운드 한도 판정
|
||||||
```
|
```
|
||||||
|
|
||||||
**A. 게이지 상시 상승** — 축별 주목도에 따라 매 결재마다 대응 게이지 상승 (가안, 플레이 후 조정)
|
> **2026-09-17 결정**: 한도 판정만 "이번 라운드분"으로 분리한다. 한도 판정에 총계를 쓰면 이월 카드(§4.1) 때문에 라운드가 시작하자마자 이미 한도를 넘어 있어 **버스트가 무한 반복**된다(실제 플레이에서 라운드가 계속 자동으로 넘어가는 버그로 드러남). 블랙잭에서 각 핸드가 0부터 시작하듯, 한도는 "이번 판에 새로 떠안은 무게"만 센다. 이월된 카드의 압박은 사라지지 않고 총계 기준인 게이지 상승·토큰 가산으로 계속 작용한다. 결정 노트: `2026-09-17-주목도-라운드-분리`.
|
||||||
|
|
||||||
| 해당 축 주목도 | 결재당 상승 |
|
**A. 게이지 상시 상승** — 축별 **카드 장수**를 1차 기준으로, 등급 배율을 곱해 매 결재마다 대응 게이지 상승 (가안, 플레이 후 조정)
|
||||||
|
|
||||||
|
```
|
||||||
|
결재당 상승(%) = 장수 구간 기본값 × (해당 축 평균 등급 / 5)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 해당 축 카드 장수 | 기본값 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 1~15 | +0.5% |
|
| 1~2장 | 0.5% |
|
||||||
| 16~30 | +1.5% |
|
| 3~4장 | **3.0%** |
|
||||||
| 31~40 | +3.0% |
|
| 5~6장 | 6.0% |
|
||||||
| 41 이상 | +5.0%, 이후 초과분 10당 +1.0%씩 추가 누진 |
|
| 7~9장 | 10.0% |
|
||||||
|
| 10장 이상 | 15.0%, 이후 1장마다 +2.0%씩 추가 누진 |
|
||||||
|
|
||||||
|
> **2026-09-17 결정 (재설계, 주목도 단독 기준 폐기)**: 등급 합계(주목도)만으로 상승률을 정하면 실제 카드 등급 분포(대부분 1~4급에 몰림)에서 같은 축 카드를 여러 장 쌓아도 낮은 구간에 계속 머물러 위협이 전혀 느껴지지 않았다(플레이 피드백: "누적 카드 3장 이상부터 증가값이 커졌으면 함"). 그래서 1차 기준을 장수로 되돌리되, 등급이 여전히 의미를 갖도록 평균 등급 배율(÷5, 등급 5가 기준점)을 곱한다 — 저등급만 쌓이면 완만하고 고등급이 섞이면 같은 장수에서도 훨씬 아프다. 결정 노트: `2026-09-17-게이지상승-장수기준-복원`.
|
||||||
|
|
||||||
**B. 라운드 종료 판단 (블랙잭식 하드 캡, 2026-09-16 재도입)**
|
**B. 라운드 종료 판단 (블랙잭식 하드 캡, 2026-09-16 재도입)**
|
||||||
|
|
||||||
플레이어는 매 카드 선택 직후 총 주목도를 이번 라운드의 한도와 비교해 계속 받을지 정산할지 스스로 판단한다. 한도를 넘기면 그 자리에서 즉시 라운드가 강제 종료된다 — §2.2 참고.
|
플레이어는 매 카드 선택 직후 **이번 라운드 주목도**를 한도와 비교해 계속 받을지 정산할지 스스로 판단한다. 한도를 넘기면 그 자리에서 즉시 라운드가 강제 종료된다 — §2.2 참고.
|
||||||
|
|
||||||
```
|
```
|
||||||
한도(라운드) = 21 + (라운드 번호 − 1) × 5 (가안, §20.1 튜닝 대상)
|
한도(라운드) = 21 + (라운드 번호 − 1) × 5 + 이득 규약 보너스 (가안, §20.1 튜닝 대상)
|
||||||
```
|
```
|
||||||
|
|
||||||
| 총 주목도 (한도 대비) | 상태 |
|
| 이번 라운드 주목도 (한도 대비) | 상태 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 한도 − 5 미만 | 자동 진행. 별도 경고 없음 |
|
| 한도 − 5 미만 | 자동 진행. 별도 경고 없음 |
|
||||||
| 한도 − 5 ~ 한도 | **경고 구간** — 곧 넘긴다는 시각 경고, 정산할지 고려 |
|
| 한도 − 5 ~ 한도 | **경고 구간** — 곧 넘긴다는 시각 경고, 정산할지 고려 |
|
||||||
| 한도 초과 | **버스트** — 그 즉시 라운드 강제 종료. 합본 기회 없이 카드는 이월(§4.1) |
|
| 한도 초과 | **버스트** — 그 즉시 라운드 강제 종료. 합본 기회 없이 카드는 이월(§4.1) |
|
||||||
|
|
||||||
|
> **UI 필수**: 이번 라운드 주목도와 이월분을 구분해 보여줄 것. 이월분이 안 보이면 게이지가 왜 계속 오르는지 알 수 없고, 합쳐 보이면 한도까지 얼마 남았는지 오독한다. "정산하기" 버튼은 결재 카드 바로 아래에 둔다 — 툴바 구석에 있으면 핵심 결정 버튼인 줄 모른다(플레이테스트 확인).
|
||||||
|
|
||||||
> **결정 이력**: 최초엔 "주목도 21 초과 시 즉시 강제 종료"안을 로그라이크 초반에 과하다는 이유로 기각하고 초과분 누진 페널티(소프트 오버런)로 대체했었다. 그런데 하드 캡이 없으니 "지금 정리할까 더 모을까"의 실제 리스크가 사라져 핵심 재미(§1.1)가 실종됐다 — 그래서 하드 캡을 되살리되, 한도 자체가 라운드마다 커지는 성장 곡선을 더해 초반 가혹함 문제만 따로 해결했다. 상세 근거는 결정 노트 `2026-09-16-라운드-주목도-하드캡-복원` 참고.
|
> **결정 이력**: 최초엔 "주목도 21 초과 시 즉시 강제 종료"안을 로그라이크 초반에 과하다는 이유로 기각하고 초과분 누진 페널티(소프트 오버런)로 대체했었다. 그런데 하드 캡이 없으니 "지금 정리할까 더 모을까"의 실제 리스크가 사라져 핵심 재미(§1.1)가 실종됐다 — 그래서 하드 캡을 되살리되, 한도 자체가 라운드마다 커지는 성장 곡선을 더해 초반 가혹함 문제만 따로 해결했다. 상세 근거는 결정 노트 `2026-09-16-라운드-주목도-하드캡-복원` 참고.
|
||||||
|
|
||||||
**C. 토큰 지출 가산** — 총 주목도에 따라 모든 토큰 지출에 가산
|
**C. 토큰 지출 가산** — 총 주목도에 따라 모든 토큰 지출에 가산
|
||||||
@@ -309,7 +321,9 @@
|
|||||||
| **여론 명분** (공보) | 후보 1개 **강제 탈락** + 새 후보 1개 무작위 교체 |
|
| **여론 명분** (공보) | 후보 1개 **강제 탈락** + 새 후보 1개 무작위 교체 |
|
||||||
| **위기 명분** (위협) | 누적된 **징벌 규약 1개 조기 해제** |
|
| **위기 명분** (위협) | 누적된 **징벌 규약 1개 조기 해제** |
|
||||||
| **내부 명분** (인사) | 후보 1개 **강제 상정** (표결 자체는 그대로. 원하는 이득 규약을 올릴 때 사용) |
|
| **내부 명분** (인사) | 후보 1개 **강제 상정** (표결 자체는 그대로. 원하는 이득 규약을 올릴 때 사용) |
|
||||||
| **혼합 명분** | 표결 결과 1표 뒤집기 |
|
| **혼합 명분** | 표결 결과 1표 뒤집기 (지정 후보 +1, 선두 −1) |
|
||||||
|
|
||||||
|
> **혼합 명분은 1표만 움직인다.** +1/−1이라 표 차를 2 좁히지만, 동점은 원래 표가 많던 쪽이 이기므로 **실제로 뒤집히는 건 1표 차 승부일 때뿐**이다. 그래서 의회 화면은 후보별 예상 득표와 1·2위 표 차를 미리 보여줘야 한다 — 표 차를 모르면 쓸지 말지 판단할 수 없다. (§9.2 13석 표결)
|
||||||
|
|
||||||
**② 정보 획득**
|
**② 정보 획득**
|
||||||
|
|
||||||
@@ -430,6 +444,10 @@
|
|||||||
|
|
||||||
**유형 D. 이득 규약** — 드물게 통과. 자산 지급, 족보 완화, 토큰 지급.
|
**유형 D. 이득 규약** — 드물게 통과. 자산 지급, 족보 완화, 토큰 지급.
|
||||||
|
|
||||||
|
구현된 5종(가안): 토큰 즉시 지급 / 결재당 게이지 상승 완화 / 토큰 지출 가산 완화 / **주목도 한도 증가** / 실적 요구치 하향. 후보 3개 중 하나가 D일 확률은 30% — §9.2의 "드물게 통과"를 확률로 옮긴 값이다. 자산 지급형은 자산 시스템(§10, 3차)이 생긴 뒤 추가한다.
|
||||||
|
|
||||||
|
> **내부 명분과의 연결**: 유형 D가 없으면 §7.3의 내부 명분("후보 강제 상정")이 불리한 규약만 올릴 수 있어 쓸 이유가 없다. 상정 가능 목록에 이득 규약이 포함되는 것이 내부 명분의 존재 이유다.
|
||||||
|
|
||||||
### 9.3. 실적 감사
|
### 9.3. 실적 감사
|
||||||
|
|
||||||
> **라운드당 합본 횟수를 강제하지 않는다.** 처리 장수나 합본 횟수를 요구하면 플레이어 선택이 감소하고 전략성이 떨어진다. **다중 통화 결산**으로 여러 경로를 허용한다.
|
> **라운드당 합본 횟수를 강제하지 않는다.** 처리 장수나 합본 횟수를 요구하면 플레이어 선택이 감소하고 전략성이 떨어진다. **다중 통화 결산**으로 여러 경로를 허용한다.
|
||||||
@@ -760,11 +778,13 @@
|
|||||||
5. [~] **상점** (§8.3) — "오염 카드 직접 처리"만 구현. 자산 구매·소모품·전례 매입은 3차가
|
5. [~] **상점** (§8.3) — "오염 카드 직접 처리"만 구현. 자산 구매·소모품·전례 매입은 3차가
|
||||||
먼저 있어야 성립해서 아직 없음
|
먼저 있어야 성립해서 아직 없음
|
||||||
6. [x] **규약 시스템 — 유형 A 족보표 변경 우선** (§9.2) — 유형 A 7종 + 감사 실패용 유형 C 2종
|
6. [x] **규약 시스템 — 유형 A 족보표 변경 우선** (§9.2) — 유형 A 7종 + 감사 실패용 유형 C 2종
|
||||||
(5개 초과 시 목록 압축 표시 포함). 유형 B·D는 범위 밖(§20.2 소량 배치 콘텐츠 작업 대상)
|
(5개 초과 시 목록 압축 표시 포함) + 이득 유형 D 5종. 13석 표결을 실제 표 수로 구현하고
|
||||||
|
예상 득표를 의회 화면에 공개한다. 유형 B(금리형)만 범위 밖(§20.2)
|
||||||
7. [x] **실적 감사 + 다중 통화 결산** (§9.3)
|
7. [x] **실적 감사 + 다중 통화 결산** (§9.3)
|
||||||
8. [~] **명분 산출 및 규약 개입** (§7) — 절차·여론·위기·내부 4갈래 구현. 혼합 명분("표결 1표
|
8. [~] **명분 산출 및 규약 개입** (§7) — 절차·여론·위기·내부·혼합 5갈래 전부 구현. 혼합 명분은
|
||||||
뒤집기")은 실제 위원회 표가 없어 "직접 통과 지정"으로 대체한 자리표시자 — 4차에서 13인
|
더 이상 자리표시자가 아니라 §7.3 스펙대로 **실제 1표 이동**이다(1표 차에서만 뒤집힘).
|
||||||
위원회가 생기면 재설계 필요. §7.3 ②정보 획득·③임원 직접 관리는 위원회가 없어 미구현
|
다만 §7.3 ②정보 획득·③임원 직접 관리는 임원 개개인이 없어 미구현 — 4차에서 13인 위원회의
|
||||||
|
우호도·이중첩자(§9.5)가 생기면 지금의 무작위 표 배분도 "임원별 투표"로 교체한다
|
||||||
|
|
||||||
### 3차 — 빌드 및 리플레이성
|
### 3차 — 빌드 및 리플레이성
|
||||||
9. [ ] **자산 시스템 + 발동 로그** (§10)
|
9. [ ] **자산 시스템 + 발동 로그** (§10)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
created: 2026-09-17
|
||||||
|
---
|
||||||
|
%% 파일명: 2026-09-17-게이지상승-장수기준-복원. 규칙 문서엔 규칙만, 이유는 여기에 %%
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
- §4.2-A 게이지 상승률의 1차 기준을 **등급 합계(주목도)** → **카드 장수**로 되돌린다.
|
||||||
|
- 등급을 완전히 버리지는 않는다. 장수 구간이 정한 기본값에 `해당 축 평균 등급 / 5`를 곱한다 — 등급 5가 배율 1.0 기준점, 낮은 등급만 쌓이면 완만해지고 높은 등급이 섞이면 같은 장수에서도 더 아프다.
|
||||||
|
- 구간을 3~4장에서 크게 뛰도록 설계한다: 1~2장 0.5% → 3~4장 3.0%(6배 점프) → 5~6장 6.0% → 7~9장 10.0% → 10장+ 15.0%+장당 2.0%.
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
- 2026-09-15 결정으로 페널티 기준을 "카드 장수" → "등급 합계(주목도)"로 바꿨다. 등급 편차가 1~10으로 커진 만큼 등급 자체를 가중치로 쓰자는 취지였다.
|
||||||
|
- 그런데 실제 카드 데이터(`cards.csv`)는 등급이 대부분 1~4급에 몰려 있다. 그 결과 같은 축 카드를 3장, 4장 쌓아도 등급 합계가 15를 넘지 못해 계속 "1~15 → +0.5%" 최하 구간에 머물렀다 — 플레이 피드백: "4대 게이지 증가가 너무 낮아서 위협이 아예 안 됨".
|
||||||
|
- 플레이어가 실제로 느끼는 위협은 "등급 합계가 얼마인가"가 아니라 "몇 장이 쌓였는가"에 훨씬 직접적으로 반응한다는 게 이번 피드백으로 드러났다. 장수는 화면에서 세기도 쉽고, 매 결재 즉시 갱신되는 값이라 인지 부하가 적다.
|
||||||
|
|
||||||
|
## 검토했으나 기각한 대안
|
||||||
|
- **주목도 구간 값만 낮춘다(예: 1~5/6~15/16~25 식으로 세분화)**: 등급이 낮은 축에서는 여전히 굼뜨고, 등급이 높은 축에서는 카드 1~2장만으로 과하게 뛸 수 있어 두 문제를 동시에 해결하지 못한다.
|
||||||
|
- **등급 가중치를 완전히 버리고 순수 장수만 쓴다(2026-09-05 이전 방식으로 완전 회귀)**: 2026-09-15 결정의 문제의식(고등급과 저등급을 똑같이 취급하면 안 됨) 자체는 여전히 유효하다. 순수 장수만 쓰면 1급 카드 3장과 10급 카드 3장이 똑같이 위협적으로 취급돼 등급 확장(1~10, 2026-09-15)의 의미가 사라진다.
|
||||||
|
- **장수와 등급 합계 중 더 큰 쪽을 쓴다(max 방식)**: 두 지표의 스케일이 달라(장수는 1~10대, 등급 합계는 1~100대) 어느 한쪽이 항상 이겨서 실질적으로 단일 기준과 다르지 않다.
|
||||||
|
|
||||||
|
## 재검토 조건
|
||||||
|
- 카드 데이터에 고등급(7급 이상) 비중이 앞으로 늘어나면(§20.2 신규 카드 작성 시), 등급 배율이 과하게 커져 3장만으로도 즉시 버스트급 상승이 나올 수 있다 — 그때 배율 상한이나 기준점(현재 5)을 재조정할 것.
|
||||||
|
- 3~4장 구간의 6배 점프(0.5%→3.0%)가 실측 플레이에서 "적당히 위협적"인지 "너무 가혹한지" 확인 필요. §20.1 튜닝 대상.
|
||||||
|
- 등급 배율(÷5) 때문에 등급 1~2급만 쌓는 축은 상승이 여전히 매우 완만하다 — 이게 "저등급 축은 방치해도 괜찮다"는 의도된 설계인지, 아니면 그 축도 최소한의 압박이 있어야 하는지 결정 필요.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
created: 2026-09-17
|
||||||
|
---
|
||||||
|
%% 파일명: 2026-09-17-주목도-라운드-분리. 규칙 문서엔 규칙만, 이유는 여기에 %%
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
- 주목도를 용도에 따라 둘로 나눈다.
|
||||||
|
- **이번 라운드 주목도** = 이번 라운드에 받은 카드들의 등급 합 → **라운드 한도(버스트) 판정 전용**
|
||||||
|
- **총 주목도** = 책상 전체(이월 포함) 등급 합 → 게이지 상승(§4.2-A)·토큰 지출 가산(§4.2-C)
|
||||||
|
- 오염 카드에 "받은 라운드 번호"를 기록해 둘을 구분한다. 이월 규칙(§4.1) 자체는 그대로 둔다 — 카드는 여전히 소멸하지 않고 책상에 남는다.
|
||||||
|
- UI는 둘을 함께 보여준다: `이번 라운드 10/21 (이월 23)`.
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
- 플레이 테스트에서 "주목도가 라운드 종료 후 초기화되지 않아, 한도를 초과한 다음 라운드가 계속 바로바로 넘어간다"는 보고가 나왔다.
|
||||||
|
- 원인: 한도 판정을 **총 주목도**로 하고 있었는데, §4.1 이월 원칙에 따라 카드가 책상에 남으므로 2라운드는 시작하자마자 이미 한도를 넘은 상태였다. 결재 한 장만 해도 즉시 버스트 → 상점 → 의회 → 다음 라운드도 같은 상태. 게임이 진행 불가능했다.
|
||||||
|
- 즉 §2.2(블랙잭식 하드 캡)와 §4.1(이월)이 같은 숫자를 공유하면서 서로를 무력화하고 있었다. 블랙잭에서 각 핸드가 0에서 시작하듯, 한도 판정은 "이번 판에 새로 떠안은 무게"만 세야 성립한다.
|
||||||
|
|
||||||
|
## 검토했으나 기각한 대안
|
||||||
|
- **라운드 종료 시 책상을 비운다(카드 소멸)**: 이월은 "잘 남긴 카드가 다음 판의 유리한 시작점이 된다"는 2026-09-15 결정의 핵심 보상 구조다. 이걸 없애면 선택권 있는 드래프트라는 이 게임 고유의 성격이 사라진다.
|
||||||
|
- **한도를 총 주목도에 비례해 올린다**: 쌓을수록 한도도 올라가니 압박이 상쇄돼 하드 캡의 의미가 없어진다. 2026-09-16에 하드 캡을 되살린 이유와 정면으로 어긋난다.
|
||||||
|
- **이월 카드의 주목도를 할인해서 합산(예: 50%)**: 숫자는 굴러가지만 플레이어가 화면의 수치를 보고 한도까지 몇 점 남았는지 암산할 수 없다. 기호만 보고 0.5초 안에 판단해야 한다는 §3 요건에 어긋난다.
|
||||||
|
|
||||||
|
## 재검토 조건
|
||||||
|
- 이월분이 게이지·토큰 가산에만 작용하는 것이 실제로 충분한 압박인지 — 이월을 잔뜩 쌓아두고도 매 라운드 한도까지 새로 받는 플레이가 지배 전략이 되면, 이월분 일부를 한도 판정에 넣는 방식을 다시 검토한다.
|
||||||
|
- 반대로 이월 누적만으로 게이지가 너무 빨리 차서 2~3라운드 만에 끝나면 §4.2-A 구간 수치부터 조정한다(한도와는 별개 축).
|
||||||
Reference in New Issue
Block a user