규약 세부화(유형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:
kyoung5seo
2026-09-17 23:00:42 +09:00
co-authored by Claude Sonnet 5
parent 0a065068c1
commit 6398599199
18 changed files with 1113 additions and 214 deletions
+7 -3
View File
@@ -1,7 +1,8 @@
import React, { useMemo, useState } from 'react';
import SymbolBadge from './SymbolBadge';
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';
// 합본 결재 (§5) — 책상에서 카드를 다중 선택하면 성립 가능한 족보를 자동 판정해 보여준다.
@@ -56,7 +57,7 @@ export default function CombinePanel({ deskCards, onConfirm, onClose, activeProt
{activeProtocols.length > 0 && (
<div className="combine-protocols">
{activeProtocols.map((p) => (
<span key={p.id} className="combine-protocol-chip">⚖ {p.name}</span>
<ProtocolChip key={p.id} protocol={p} prefix="⚖ " />
))}
</div>
)}
@@ -84,7 +85,10 @@ export default function CombinePanel({ deskCards, onConfirm, onClose, activeProt
<p className="combine-no-match">선택한 {selection.length}장은 성립하는 족보가 없습니다.</p>
)}
{blockedByProtocol && (
<p className="combine-blocked">규약 때문에 이 선택은 합본할 수 없습니다.</p>
<p className="combine-blocked">
규약 때문에 이 선택은 합본할 수 없습니다 —{' '}
{activeProtocols.map((p) => protocolEffectDetail(p).summary).join(' · ')}
</p>
)}
{matches.map((patternId) => {
const preview = resolveCombine(selection, patternId);
+67 -23
View File
@@ -5,17 +5,19 @@ import DeskBar from './DeskBar';
import CoreSwipeCard from './CoreSwipeCard';
import CombinePanel from './CombinePanel';
import HandGuidePopup from './HandGuidePopup';
import ProtocolChip from './ProtocolChip';
import ShopScreen from './ShopScreen';
import CouncilScreen from './CouncilScreen';
import { pickPollutionEntry } from '../engine/pollutionPool';
import {
makeDeskCard, applyPassiveGaugeRise, addToDesk, removeFromDesk, tokenSurcharge,
totalAttention, roundAttentionCap,
roundAttention, roundAttentionCap,
} from '../engine/desk';
import { rollShopDisplay, purgePrice, REROLL_COST } from '../engine/shop';
import {
requiredScore, settleScore, rollProtocolCandidates, resolveProtocolVote, rollPenalty,
applyInterventions, penaltyGaugeSurcharge, penaltyTokenSurcharge,
applyInterventions, gaugeSurchargeOf, tokenSurchargeOf, attentionCapBonusOf,
requiredScoreDeltaOf, grantTokenOf, tallyVotes, applyVoteSwing, COUNCIL_SEATS,
} from '../engine/council';
import './CoreGameScreen.css';
@@ -57,13 +59,27 @@ const buildCard = (row) => ({
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() {
const deck = useMemo(() => {
const parsed = Papa.parse(cardsCsvRaw, { header: true, skipEmptyLines: true });
return parsed.data.map(buildCard);
}, []);
const [cardIndex, setCardIndex] = useState(0);
const [drawQueue, setDrawQueue] = useState(() => shuffle(deck));
const [deskCards, setDeskCards] = useState([]);
const [gauges, setGauges] = useState(INITIAL_GAUGES);
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 activeProtocols = activeProtocol ? [activeProtocol] : [];
const gaugeExtraFlat = penaltyGaugeSurcharge(penalties);
const tokenExtraFlat = penaltyTokenSurcharge(penalties);
// 징벌(유형 C)과 활성 규약(유형 A/D)이 같은 수치를 건드리므로 합쳐서 합산한다.
// 이득 규약이면 음수라 페널티가 상쇄된다.
const effectiveProtocols = [...penalties, ...activeProtocols];
const gaugeExtraFlat = gaugeSurchargeOf(effectiveProtocols);
const tokenExtraFlat = tokenSurchargeOf(effectiveProtocols);
const surcharge = tokenSurcharge(deskCards, tokenExtraFlat);
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 hit = Object.entries(nextGauges).find(([, v]) => v >= 100);
@@ -131,7 +156,7 @@ export default function CoreGameScreen() {
if (side.axis) {
const grade = side.grade || 1;
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]);
setDeskCards(nextDesk);
pushLog(`『${side.text}』 결재됨 → 오염 서류 『${entry?.name || '미상'}』(등급 ${grade}) 접수`);
@@ -139,12 +164,14 @@ export default function CoreGameScreen() {
pushLog(`『${side.text}』 결재됨 — 오염 없음`);
}
setCardIndex((i) => (i + 1) % deck.length);
advanceCard();
if (checkGameOver(nextGauges)) return;
// §4.2-B: 이번 라운드 한도를 넘기면 합본 기회 없이 그 즉시 강제 종료된다. 카드는 이월(§4.1).
if (totalAttention(nextDesk) > attentionCap) {
// 이월 카드는 한도 판정에서 빠진다(roundAttention) — 안 그러면 다음 라운드가 시작하자마자
// 버스트가 반복된다.
if (roundAttention(nextDesk, round) > attentionCap) {
pushLog(`주목도 초과(한도 ${attentionCap}) — 라운드 강제 종료, 합본 기회 없이 정산으로 이동`);
goToShop();
return;
@@ -154,7 +181,7 @@ export default function CoreGameScreen() {
/** 플레이어가 한도 밑에서 스스로 라운드를 끝내는 "정산하기" — 블랙잭의 스탠드. */
const handleEndRound = () => {
if (phase !== 'approval') return;
pushLog(`제${round}라운드 자율 종료(정산하기) — 주목도 ${totalAttention(deskCards)}/${attentionCap}`);
pushLog(`제${round}라운드 자율 종료(정산하기) — 주목도 ${roundAttention(deskCards, round)}/${attentionCap}`);
goToShop();
};
@@ -201,10 +228,11 @@ export default function CoreGameScreen() {
/* ── 의회 (§9) ── */
const handleCouncilResolve = ({ tokensPaid, justificationsPaidCount, interventions }) => {
const { candidates: remainingCandidates, forcedWinnerId, deferred, penaltyIdToClear } =
const { candidates: remainingCandidates, voteSwingTargetId, deferred, penaltyIdToClear } =
applyInterventions(councilCandidates, interventions);
const finalRequired = requiredScore(round);
// 이번 라운드 내내 적용 중이던 규약이 실적 요구치를 낮췄을 수 있다 (유형 D).
const finalRequired = Math.max(0, requiredScore(round) + requiredScoreDeltaOf(activeProtocols));
// §9.3: 실적 점수 = Σ(이번 라운드 족보 점수) + 토큰 납입×5 + 명분 납입×20.
const finalScore = settleScore({
combineScore: roundScore, tokensPaid, justificationsPaid: justificationsPaidCount,
@@ -241,12 +269,20 @@ export default function CoreGameScreen() {
pushLog(`절차 명분으로 『${deferred.map((d) => d.name).join(', ')}』 다음 라운드 이월`);
}
const winner = forcedWinnerId
? remainingCandidates.find((c) => c.id === forcedWinnerId) || null
: resolveProtocolVote(remainingCandidates);
// §9.2 13석 표결. 혼합 명분은 1표만 움직이므로 1표 차 승부에서만 결과가 뒤집힌다 (§7.3).
const tallied = applyVoteSwing(tallyVotes(remainingCandidates), voteSwingTargetId);
const winner = resolveProtocolVote(tallied);
setActiveProtocol(winner);
if (winner) pushLog(`규약 통과: 『${winner.name}』 — 다음 라운드 적용`);
else pushLog('통과된 규약 없음 — 다음 라운드는 제약 없이 진행됩니다.');
if (winner) {
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);
setRoundScore(0);
@@ -277,11 +313,10 @@ export default function CoreGameScreen() {
deskCards={deskCards}
gauges={gauges}
tokens={tokens}
round={round}
attentionCap={attentionCap}
onOpenCombine={() => setShowCombine(true)}
onOpenGuide={() => setShowGuide(true)}
onEndRound={handleEndRound}
canEndRound={phase === 'approval'}
gaugeExtraFlat={gaugeExtraFlat}
tokenExtraFlat={tokenExtraFlat}
/>
@@ -290,11 +325,19 @@ export default function CoreGameScreen() {
<span>제{round}라운드</span>
<span>실적 {score} (이번 라운드 {roundScore})</span>
<span>명분 {justificationTotal}개</span>
{penalties.length > 0 && <span className="core-status-penalty">⚠ 징벌 {penalties.length}</span>}
{activeProtocol && <span className="core-status-protocol">⚖ {activeProtocol.name}</span>}
{activeProtocol && <ProtocolChip protocol={activeProtocol} prefix="⚖ " />}
{penalties.map((p) => <ProtocolChip key={p.id} protocol={p} prefix="⚠ " />)}
</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' && (
<ShopScreen
@@ -316,6 +359,7 @@ export default function CoreGameScreen() {
justifications={justifications}
penalties={penalties}
candidates={councilCandidates}
requiredDelta={requiredScoreDeltaOf(activeProtocols)}
onResolve={handleCouncilResolve}
/>
)}
@@ -123,3 +123,50 @@
opacity: 0.55;
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); }
+24 -1
View File
@@ -9,7 +9,12 @@ import './CoreSwipeCard.css';
// 실제로 뭘 하는 선택인지(대사)가 안 읽혔다. 그래서 좌/우 슬롯을 카드 위 고정 영역으로 빼고,
// 기호 + 선택 대사를 처음부터 항상 같이 보여준다. 드래그 제스처·물리는 기존
// 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 y = useMotionValue(0);
const controls = useAnimation();
@@ -40,6 +45,9 @@ export default function CoreSwipeCard({ card, onSwipe }) {
if (!card) return null;
const remaining = Math.max(0, attentionCap - roundAttention);
const zone = remaining === 0 ? 'bust' : remaining <= 5 ? 'warning' : 'normal';
return (
<div className="core-card-container">
<motion.div
@@ -83,6 +91,21 @@ export default function CoreSwipeCard({ card, onSwipe }) {
{card.flavor && <p className="core-card-flavor">"{card.flavor}"</p>}
</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>
);
}
@@ -33,6 +33,9 @@
.council-audit-row {
display: flex;
justify-content: space-between;
/* 규약 델타까지 붙으면 좁은 화면에서 오른쪽이 잘린다 — 줄바꿈을 허용한다. */
flex-wrap: wrap;
gap: 4px 10px;
font-family: var(--font-typewriter);
font-size: 14px;
font-weight: 700;
@@ -251,3 +254,82 @@
font-size: 14px;
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;
}
+128 -45
View File
@@ -1,21 +1,24 @@
import React, { useState } from 'react';
import {
requiredScore, settleScore, JUSTIFICATION_INTERVENTIONS, availableForTabling,
requiredScore, settleScore, JUSTIFICATION_INTERVENTIONS, justificationDetail,
availableForTabling, applyInterventions, tallyVotes, applyVoteSwing, resolveProtocolVote,
COUNCIL_SEATS,
} from '../engine/council';
import InfoTooltip from './InfoTooltip';
import ProtocolChip from './ProtocolChip';
import './CouncilScreen.css';
// 의회 (§9.1) — 실적 감사와 규약 결정은 독립적이다: 실적을 잘 채웠다고 우호적 규약이
// 상정되지 않는다. 13인 위원회의 실제 우호도·표결(§9.4~9.6)은 4차 범위라 없다 — 후보 1개는
// 무작위로 뽑힌 "통과"를 그대로 쓴다. 플레이어는 표결에 직접 개입 못 하고 명분으로
// 안건 자체를 조작할 뿐이라는 원칙(§9.2)만 지켰다.
// 상정되지 않는다. 플레이어는 표결에 직접 개입 못 하고 명분으로 안건 자체를 조작할 뿐이다(§9.2).
//
// 2026-09-15 결정 반영: 토큰 1종, 명분 개입 4갈래 재정의(§7.3).
// 명분은 종류별 "수량"(§5.1 표의 명분 열)이다 — 1개 이상 있어야 실적 납입이나 개입에 쓸 수 있고,
// 페어(0.5)만 반복하면 반쪽만 쌓여 아무것도 못 한다.
// 2026-09-15 결정: 토큰 1종, 명분 개입 4갈래(§7.3). 명분은 종류별 "수량"이라 1개 이상이어야 쓴다.
// 2026-09-17: §9.2의 13석 표결을 실제 표 수로 구현하고, 개입을 반영한 **예상 득표를 미리** 보여준다.
// 혼합 명분이 1표만 움직이는 이상(§7.3), 표 차를 모르면 쓸지 말지 판단할 수 없기 때문이다.
// 임원 개개인의 우호도·이중첩자(§9.5)는 4차 범위 — 지금은 안건 조합에 따른 결정적 배분이다.
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 [spent, setSpent] = useState({}); // { 명분종류: 소모한 수량 }
@@ -51,8 +54,16 @@ export default function CouncilScreen({
penalty: 'targetPenaltyId',
pool: 'targetPoolProtocolId',
}[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);
setInterventions((prev) => [...prev, { type: pending.type, [key]: targetId }]);
setInterventions((prev) => [...prev, iv]);
setPending(null);
};
@@ -60,6 +71,14 @@ export default function CouncilScreen({
const tablingPool = availableForTabling(candidates);
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 = () => {
onResolve({ tokensPaid, justificationsPaidCount: scorePaidCount, interventions });
};
@@ -70,7 +89,14 @@ export default function CouncilScreen({
<section className="council-audit">
<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'}>
현재 {currentTotal} {met ? '(충족)' : `(부족 ${required - currentTotal})`}
</span>
@@ -97,15 +123,17 @@ export default function CouncilScreen({
return (
<section className="council-penalties">
<h3>누적 징벌 규약 {penalties.length}개</h3>
{shown.map((p) => (
<div
key={p.id}
className={`council-penalty-chip ${pendingTargetKind === 'penalty' ? 'targetable' : ''}`}
onClick={() => pendingTargetKind === 'penalty' && completeTarget(p.id, 'penalty')}
>
⚠ {p.name}
</div>
))}
<div className="council-penalty-list">
{shown.map((p) => (
<span
key={p.id}
className={`council-penalty-slot ${pendingTargetKind === 'penalty' ? 'targetable' : ''}`}
onClick={() => pendingTargetKind === 'penalty' && completeTarget(p.id, 'penalty')}
>
<ProtocolChip protocol={p} prefix="⚠ " />
</span>
))}
</div>
{hidden > 0 && (
<button className="council-penalty-more" onClick={() => setPenaltiesExpanded(true)}>
+{hidden}개 더 보기
@@ -125,11 +153,20 @@ export default function CouncilScreen({
{heldTypes.length === 0 && <p className="council-empty">사용 가능한 명분이 없습니다.</p>}
{heldTypes.map((type) => {
const spec = JUSTIFICATION_INTERVENTIONS[type];
const info = justificationDetail(type);
const remaining = remainingOf(type);
const usable = remaining >= 1;
return (
<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>}
<button onClick={() => payForScore(type)} disabled={!usable}>실적 납입 (+20)</button>
{spec && (
@@ -151,51 +188,97 @@ export default function CouncilScreen({
</section>
<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) => {
const deferredHere = 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 tallyRow = tallied.find((t) => t.id === c.id);
const isWinner = projectedWinner && projectedWinner.id === c.id;
return (
<div
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')}
>
<div className="candidate-name">
{c.name}
{deferredHere && ' (다음 라운드로 이월)'}
{removed && ' (강제 탈락)'}
{forced && ' (통과 지정)'}
<div className="candidate-head">
<ProtocolChip protocol={c} />
{!gone && tallyRow && (
<span className={`candidate-votes ${isWinner ? 'lead' : ''}`}>
{tallyRow.votes}표{isWinner ? ' · 통과 예상' : ''}
</span>
)}
</div>
<div className="candidate-flavor">{c.flavor}</div>
{(gone || swung) && (
<div className="candidate-state">
{deferredHere && '다음 라운드로 이월됨'}
{removed && '강제 탈락됨'}
{swung && '혼합 명분으로 +1표'}
</div>
)}
</div>
);
})}
{interventions.filter((iv) => iv.type === '내부').map((iv, i) => (
<div key={`tabled-${i}`} className="council-candidate forced">
<div className="candidate-name">
{tablingPool.find((p) => p.id === iv.targetPoolProtocolId)?.name} (강제 상정)
</div>
</div>
))}
{/* 개입으로 새로 들어온 안건(여론 교체분·내부 강제 상정분) */}
{preview.candidates
.filter((c) => !candidates.some((orig) => orig.id === c.id))
.map((c) => {
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>
{pendingTargetKind === 'pool' && (
<section className="council-pool">
<h3>상정 가능 규약</h3>
<h3>상정 가능 규약 — 올릴 안건을 고르십시오</h3>
{tablingPool.length === 0 && <p className="council-empty">상정할 규약이 남지 않았습니다.</p>}
{tablingPool.map((p) => (
<div key={p.id} className="council-candidate targetable" onClick={() => completeTarget(p.id, 'pool')}>
<div className="candidate-name">{p.name}</div>
<div className="candidate-flavor">{p.flavor}</div>
</div>
))}
<div className="council-pool-list">
{tablingPool.map((p) => (
<span key={p.id} className="council-pool-slot targetable" onClick={() => completeTarget(p.id, 'pool')}>
<ProtocolChip protocol={p} />
</span>
))}
</div>
</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>
);
}
+5 -19
View File
@@ -82,25 +82,6 @@
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 {
display: flex;
flex-direction: column;
@@ -185,3 +166,8 @@
color: var(--color-risk-critical);
opacity: 1;
}
.desk-carried {
opacity: 0.55;
font-size: 11px;
}
+14 -9
View File
@@ -1,7 +1,8 @@
import React, { useState } from 'react';
import { AXES, AXIS_META } from '../engine/symbols';
import {
totalCount, totalAttention, attentionByAxis, attentionZone, tokenSurcharge, gaugeRiseRateByAxis,
totalCount, totalAttention, roundAttention, attentionByAxis, attentionZone, tokenSurcharge,
gaugeRiseRateByAxis,
} from '../engine/desk';
import { AxisIcon } from './SymbolBadge';
import DeskDetailPopup from './DeskDetailPopup';
@@ -11,21 +12,27 @@ import './DeskBar.css';
// 2026-09-16 결정 반영: 페널티 기준이 등급 합계(주목도)이고, 이제 주목도가 **이번 라운드의
// 한도(attentionCap)를 넘으면 그 자리에서 라운드가 강제 종료**된다(§4.2-B, 결정 노트
// 2026-09-16-라운드-주목도-하드캡-복원). 그래서 총 주목도뿐 아니라 한도까지 항상 보여야
// "한 장 더 받을지, 지금 정산할지" 판단이 선다. "정산하기" 버튼이 그 자율 종료 액션이다.
// "한 장 더 받을지, 지금 정산할지" 판단이 선다.
//
// 2026-09-17 수정: (1) "정산하기" 버튼은 결재 카드 아래(CoreSwipeCard)로 옮겼다 — 툴바 구석에
// 있으면 핵심 결정 버튼인 걸 모른다는 피드백. (2) 한도 판정에 쓰는 주목도는 **이번 라운드분**
// 이다. 책상 전체 주목도는 이월 카드까지 포함해 게이지·토큰 가산에 계속 작용하므로 둘 다 보여준다.
const MAX_GRADE_CHIPS = 6;
export default function DeskBar({
deskCards, gauges, tokens, attentionCap, onOpenCombine, onOpenGuide, onEndRound, canEndRound,
deskCards, gauges, tokens, round, attentionCap, onOpenCombine, onOpenGuide,
gaugeExtraFlat = 0, tokenExtraFlat = 0,
}) {
const [detailAxis, setDetailAxis] = useState(null);
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 rates = gaugeRiseRateByAxis(deskCards, gaugeExtraFlat);
const surcharge = tokenSurcharge(deskCards, tokenExtraFlat);
const zone = attentionZone(deskCards, attentionCap);
const zone = attentionZone(deskCards, attentionCap, round);
const zoneLabel = {
normal: null,
@@ -37,7 +44,8 @@ export default function DeskBar({
<div className={`desk-bar zone-${zone}`}>
<div className="desk-bar-top">
<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>}
<span className="desk-tokens"> · 토큰 {tokens}</span>
</div>
@@ -46,9 +54,6 @@ export default function DeskBar({
<button className="desk-combine-btn" onClick={onOpenCombine} disabled={total < 2}>
합본 결재
</button>
<button className="desk-end-round-btn" onClick={onEndRound} disabled={!canEndRound}>
정산하기
</button>
</div>
</div>
+116
View File
@@ -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>
);
}
+314 -51
View File
@@ -1,11 +1,18 @@
// 의회 엔진 — ProjectSS_코어시스템_기획서.md §7(명분) · §9.2(규약) · §9.3(실적 감사).
// React 의존 없는 순수 함수 + 카탈로그. 13인 위원회의 실제 우호도·표결(§9.4~9.6)은
// 이중첩자·탄핵 방어까지 포함된 4차 범위라 아직 없다 — 그 대신 규약 후보 3개 중 1개를
// 뽑는 절차는 "무작위"로 흉내만 낸다. §9.1 "규약 결정과 실적 감사는 독립적"이라는
// 원칙만은 지킨다: 감사 결과가 어떤 규약이 뽑히는지에 영향을 주지 않는다.
// React 의존 없는 순수 함수 + 카탈로그.
//
// 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 실적 감사 ── */
@@ -16,50 +23,56 @@ export const requiredScore = (round) => 25 + (round - 1) * 20;
export const settleScore = ({ combineScore, tokensPaid, justificationsPaid }) =>
combineScore + tokensPaid * 5 + justificationsPaid * 20;
/* ── §9.2 규약 — 유형 A(족보표 변경) 우선, 유형 C(징벌)는 감사 실패 시 필요해서 최소 카탈로그만.
* 유형 B(금리형)·D(이득)는 §20.2 "규약 상세 목록 — 소량 배치로 작성" 대상이라 아직 카탈로그가 없다.
* 유형 D가 없으면 내부 명분의 "강제 상정"이 전략적으로 빈약하다 — 이득 규약이 추가되면 살아난다. */
/* ── §9.2 규약 카탈로그 ──
* 유형 A(족보표 변경)·C(징벌)·D(이득)까지 구현. 유형 B(금리형)는 §20.2 콘텐츠 작업 대상. */
export const PROTOCOL_TYPE_A = [
{
id: 'protocol_flush_ban',
name: '동일 부서 일괄 처리 금지 규약',
type: 'A',
effect: { kind: 'disable_hand', patternId: 'flush_axis' },
flavor: '동일 부서 건을 묶어 올리는 관행이 유착으로 지적되었습니다. 개별 상신할 것.',
},
{
id: 'protocol_straight_ban',
name: '연번 상신 금지 규약',
type: 'A',
effect: { kind: 'disable_hand', patternId: 'straight' },
flavor: '등급을 순서대로 올리는 관행이 결재 회피 수단으로 악용되고 있습니다.',
},
{
id: 'protocol_straightflush_ban',
name: '전결 처리 금지 규약',
type: 'A',
effect: { kind: 'disable_hand', patternId: 'straight_flush' },
flavor: '한 부서가 연번까지 독점 처리하는 것은 감사 사각지대를 만듭니다.',
},
{
id: 'protocol_fourkind_ban',
name: '전면 개편 금지 규약',
type: 'A',
effect: { kind: 'disable_hand', patternId: 'four_kind' },
flavor: '동일 등급 4건 일괄 개편은 절차 생략 소지가 있어 당분간 금지합니다.',
},
{
id: 'protocol_fullhouse_ban',
name: '종합 감사 제한 규약',
type: 'A',
effect: { kind: 'disable_hand', patternId: 'full_house' },
flavor: '3+2 형태의 묶음 상신은 감사 항목을 흐린다는 지적이 있었습니다.',
},
{
id: 'protocol_min_grade',
name: '기밀 등급 심사 강화 규약',
type: 'A',
effect: { kind: 'min_grade', minGrade: 4 },
flavor: '등급 4 미만 문서만으로는 더 이상 합본 상신이 승인되지 않습니다.',
},
{
id: 'protocol_pair_ban',
name: '중복 접수 금지 규약',
type: 'A',
effect: { kind: 'disable_hand', patternId: 'pair' },
flavor: '두 건만 묶어 올리는 편의적 관행을 제한합니다. 최소 규모를 갖출 것.',
},
@@ -69,17 +82,64 @@ export const PROTOCOL_TYPE_C = [
{
id: 'penalty_gauge_accel',
name: '미결 서류 관리 규정',
type: 'C',
effect: { kind: 'gauge_surcharge', amount: 1.0 },
flavor: '미결 누적에 대한 문책성 규정 — 모든 축의 결재당 게이지 상승이 가산됩니다.',
},
{
id: 'penalty_token_surcharge',
name: '긴급 재정 감사 대응',
type: 'C',
effect: { kind: 'token_surcharge', amount: 2 },
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 pool = [...arr];
const out = [];
@@ -90,53 +150,163 @@ const pick = (arr, n) => {
};
/**
* 매 라운드 규약 후보 3개를 뽑는다 (유형 A만, §17 2차 범위).
* 매 라운드 규약 후보 3개를 뽑는다.
* carriedOver: 절차 명분으로 지난 라운드에서 이월된 후보 — "지금은 피하지만 나중에 다시 온다"(§7.3).
* 이득 규약(D)은 낮은 확률로 한 자리만 차지한다.
*/
export const rollProtocolCandidates = (carriedOver = []) => {
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));
return [...carriedOver, ...fresh];
};
const slots = Math.max(0, 3 - carriedOver.length);
const out = [...carriedOver];
/** 후보 중 1개를 "표결 통과"로 확정한다 — 실제 위원회가 없어 무작위로 대신한다. */
export const resolveProtocolVote = (candidates) => {
if (candidates.length === 0) return null;
return candidates[Math.floor(Math.random() * candidates.length)];
if (slots > 0 && Math.random() < D_CANDIDATE_CHANCE) {
const boon = pick(PROTOCOL_TYPE_D.filter((p) => !carriedIds.has(p.id)), 1);
out.push(...boon);
}
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];
/** 아직 후보에 오르지 않은 규약들 — 내부 명분의 "강제 상정" 대상 목록. */
/** 아직 후보에 오르지 않은 규약들 — 내부 명분의 "강제 상정" 대상. 이득 규약(D)이 여기 포함되는 것이
* 내부 명분의 존재 이유다(§7.3: "원하는 이득 규약을 올릴 때 사용"). */
export const availableForTabling = (candidates) => {
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종 + 혼합 ──
* ② 정보 획득 · ③ 임원 직접 관리는 13인 위원회(§9.4~9.6, 4차)가 있어야 대상이 존재한다.
* ④ 실적 납입(명분 1개 = 20점)은 CouncilScreen에서 직접 처리한다.
*
* 혼합 명분의 "표결 결과 1표 뒤집기"는 실제 표가 없어 문자 그대로 구현할 수 없다.
* 최종 효과(원하는 규약이 통과된다)만 같게 "직접 통과 지정"으로 대체했고,
* 실제 위원회가 생기면 반드시 다시 설계해야 할 자리표시자다. */
export const JUSTIFICATION_INTERVENTIONS = {
절차: { label: '후보 1개 다음 라운드로 이월', target: 'candidate' },
여론: { label: '후보 1개 강제 탈락 + 새 후보로 교체', target: 'candidate' },
위기: { label: '누적 징벌 규약 1개 조기 해제', target: 'penalty' },
내부: { label: '규약 1개 강제 상정', target: 'pool' },
혼합: { label: '후보 1개 직접 통과 지정 (표 뒤집기)', target: 'candidate' },
/* ── §9.2 13석 표결 ── */
export const COUNCIL_SEATS = 13;
// 같은 후보 조합이면 항상 같은 표가 나오도록 하는 결정적 해시. 명분으로 안건을 만질 때마다
// 표가 무작위로 흔들리면 플레이어가 개입 결과를 판단할 수 없다.
const hashStr = (s) => {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
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 ①).
* interventions: [{ type, targetCandidateId?, targetPenaltyId?, targetPoolProtocolId? }]
* @returns {{ candidates, forcedWinnerId, deferred, penaltyIdToClear }}
* deferred: 다음 라운드 후보로 이월할 규약 목록 (절차 명분)
* @returns {{ candidates, voteSwingTargetId, deferred, penaltyIdToClear }}
*/
export function applyInterventions(candidates, interventions) {
let pool = [...candidates];
let forcedWinnerId = null;
let voteSwingTargetId = null;
const deferred = [];
let penaltyIdToClear = null;
@@ -150,24 +320,127 @@ export function applyInterventions(candidates, interventions) {
}
if (iv.type === '여론' && iv.targetCandidateId) {
pool = pool.filter((c) => c.id !== iv.targetCandidateId);
// 강제 탈락시킨 자리에 새 후보 1개를 무작위로 채운다 (§7.3).
const replacement = pick(availableForTabling(pool), 1)[0];
if (replacement) pool = [...pool, replacement];
// 교체 후보는 개입을 지정하는 시점(UI)에서 미리 뽑아 replacementId로 실어 보낸다.
// 여기서 무작위로 뽑으면 의회 화면의 표 미리보기와 확정 결과가 어긋난다.
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) {
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 (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) {
let allowed = matches;
activeProtocols.forEach((p) => {
@@ -181,13 +454,3 @@ export function filterHandsByProtocols(matches, selection, activeProtocols) {
});
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);
}
+69 -29
View File
@@ -14,10 +14,12 @@ import { AXES, AXIS_META, clampGrade } from './symbols.js';
let seq = 0;
// 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)}`,
axis,
grade: clampGrade(grade),
round,
name: meta.name || null,
flavor: meta.flavor || null,
tag: meta.tag || null,
@@ -50,50 +52,85 @@ export function axisAttention(deskCards, axis) {
.reduce((sum, c) => sum + c.grade, 0);
}
/** 책상 전체 주목도 = 모든 카드의 등급 합. */
/** 책상 전체 주목도 = 모든 카드의 등급 합. 이월 카드까지 포함하며, 패시브 페널티
* (게이지 상승 A · 토큰 가산 C)의 기준이다 — 쌓아둔 미결은 계속 대가를 치른다. */
export function totalAttention(deskCards) {
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) {
const out = {};
AXES.forEach((axis) => { out[axis] = axisAttention(deskCards, axis); });
return out;
}
/* ── A. 게이지 상시 상승 (§4.2-A) — 축별 주목도 기준 누진 ── */
/* ── A. 게이지 상시 상승 (§4.2-A) — 축별 카드 "장수" 기준 급누진 + 등급 배율.
*
* 2026-09-17 재설계: 등급 합계(attention)만으로는 상승이 너무 느렸다 — 이 게임 카드의 등급이
* 대부분 1~4에 몰려 있어서(§17 카드 데이터 현황), 같은 축 카드 서너 장을 쌓아도 attention이
* 낮은 구간에 계속 머물러 위협이 전혀 체감되지 않았다(플레이 피드백). 그래서 1차 기준을
* "쌓인 장수"로 되돌리되, 등급이 여전히 의미를 갖도록 평균 등급 배율을 곱한다 —
* 저등급 카드만 쌓이면 완만하고, 고등급이 섞이면 같은 장수에서도 훨씬 아프다.
* 피드백 원문: "누적 카드 3장 이상일 때부터 증가값이 엄청 커졌으면 함". */
const GAUGE_TIERS = [
{ max: 0, rate: 0 },
{ max: 15, rate: 0.5 },
{ max: 30, rate: 1.5 },
{ max: 40, rate: 3.0 },
const GAUGE_COUNT_TIERS = [
{ max: 2, base: 0.5 }, // 1~2장: 아직 완만
{ max: 4, base: 3.0 }, // 3~4장: 여기서 확 뛴다 (피드백 핵심 요구)
{ max: 6, base: 6.0 },
{ max: 9, base: 10.0 },
];
const GAUGE_OVERRUN_FROM = 41;
const GAUGE_OVERRUN_BASE = 5.0;
const GAUGE_COUNT_OVERRUN_FROM = 10;
const GAUGE_COUNT_OVERRUN_BASE = 15.0;
const GAUGE_COUNT_OVERRUN_STEP = 2.0; // 10장부터 1장마다 가산
/** 축 주목도에 따른 결재 1건당 게이지 상승률(%). 41 이상은 초과분 10당 +1.0% 추가 누진. */
export function gaugeRiseRate(attention) {
const tier = GAUGE_TIERS.find((t) => attention <= t.max);
if (tier) return tier.rate;
const overrun = attention - GAUGE_OVERRUN_FROM;
return GAUGE_OVERRUN_BASE + Math.floor(overrun / 10) * 1.0;
const GAUGE_GRADE_BASELINE = 5; // 이 등급이 "평균" — 배율 1.0 기준점 (등급 범위 1~10의 중앙)
/** 축에 쌓인 장수 기준 결재 1건당 게이지 상승 기본값(%). */
function gaugeCountBase(count) {
const tier = GAUGE_COUNT_TIERS.find((t) => count <= t.max);
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%/결재" 표시에 그대로 쓴다.
* extraFlat: 활성 징벌 규약(§9.2 유형C)이 얹는 가산분(council.js의 penaltyGaugeSurcharge). */
export function gaugeRiseRateByAxis(deskCards, extraFlat = 0) {
const counts = countByAxis(deskCards);
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;
}
/** 매 결재마다 4개 게이지 전부에 축별 상승분을 더한다. gauges = { entropy, resistance, panic, risk } (0~100). */
export function applyPassiveGaugeRise(gauges, deskCards, extraFlat = 0) {
const counts = countByAxis(deskCards);
const next = { ...gauges };
AXES.forEach((axis) => {
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);
});
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_GROWTH = 5; // 라운드당 가산분, 가안
/** 라운드 번호에 따른 이번 라운드의 주목도 한도. */
export function roundAttentionCap(round) {
return ROUND_CAP_BASE + (round - 1) * ROUND_CAP_GROWTH;
/** 라운드 번호에 따른 이번 라운드의 주목도 한도. bonus: 이득 규약의 한도 보너스(§9.2 유형 D). */
export function roundAttentionCap(round, bonus = 0) {
return ROUND_CAP_BASE + (round - 1) * ROUND_CAP_GROWTH + bonus;
}
/** 한도 대비 총 주목도 구간: 'normal' | 'warning'(한도 5 이내로 근접) | 'bust'(한도 초과). */
export function attentionZone(deskCards, cap) {
const total = totalAttention(deskCards);
if (total > cap) return 'bust';
if (total >= cap - 5) return 'warning';
/** 한도 대비 이번 라운드 주목도 구간: 'normal' | 'warning'(한도 5 이내로 근접) | 'bust'(한도 초과). */
export function attentionZone(deskCards, cap, round) {
const current = roundAttention(deskCards, round);
if (current > cap) return 'bust';
if (current >= cap - 5) return 'warning';
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) {
const total = totalAttention(deskCards);
const tier = SURCHARGE_TIERS.find((t) => total <= t.max);
return (tier ? tier.surcharge : 4) + extraFlat;
return Math.max(0, (tier ? tier.surcharge : 4) + extraFlat);
}
/** 책상에서 특정 카드들을 제거한 새 배열을 반환한다 (합본 실행 시 사용). */