diff --git a/client/src/core/components/CombinePanel.jsx b/client/src/core/components/CombinePanel.jsx
index 0388909..ffe00df 100644
--- a/client/src/core/components/CombinePanel.jsx
+++ b/client/src/core/components/CombinePanel.jsx
@@ -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 && (
{activeProtocols.map((p) => (
-
⚖ {p.name}
+
))}
)}
@@ -84,7 +85,10 @@ export default function CombinePanel({ deskCards, onConfirm, onClose, activeProt
선택한 {selection.length}장은 성립하는 족보가 없습니다.
)}
{blockedByProtocol && (
- 규약 때문에 이 선택은 합본할 수 없습니다.
+
+ 규약 때문에 이 선택은 합본할 수 없습니다 —{' '}
+ {activeProtocols.map((p) => protocolEffectDetail(p).summary).join(' · ')}
+
)}
{matches.map((patternId) => {
const preview = resolveCombine(selection, patternId);
diff --git a/client/src/core/components/CoreGameScreen.jsx b/client/src/core/components/CoreGameScreen.jsx
index aedcc82..4eef822 100644
--- a/client/src/core/components/CoreGameScreen.jsx
+++ b/client/src/core/components/CoreGameScreen.jsx
@@ -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() {
제{round}라운드
실적 {score} (이번 라운드 {roundScore})
명분 {justificationTotal}개
- {penalties.length > 0 && ⚠ 징벌 {penalties.length}}
- {activeProtocol && ⚖ {activeProtocol.name}}
+ {activeProtocol && }
+ {penalties.map((p) => )}
- {phase === 'approval' && }
+ {phase === 'approval' && (
+
+ )}
{phase === 'shop' && (
)}
diff --git a/client/src/core/components/CoreSwipeCard.css b/client/src/core/components/CoreSwipeCard.css
index 3fd5f25..d83d2a8 100644
--- a/client/src/core/components/CoreSwipeCard.css
+++ b/client/src/core/components/CoreSwipeCard.css
@@ -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); }
diff --git a/client/src/core/components/CoreSwipeCard.jsx b/client/src/core/components/CoreSwipeCard.jsx
index 916a27d..32819d7 100644
--- a/client/src/core/components/CoreSwipeCard.jsx
+++ b/client/src/core/components/CoreSwipeCard.jsx
@@ -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 (
"{card.flavor}"}
+
+ {/* 결재 카드 바로 아래 — "한 장 더 받을까, 여기서 멈출까"를 같은 시선 안에서 판단한다. */}
+ {onEndRound && (
+
+
+ 이번 라운드 주목도 {roundAttention} / {attentionCap}
+ {remaining > 0
+ ? · 한도까지 {remaining}
+ : · 한도 도달}
+
+
+
+ )}
);
}
diff --git a/client/src/core/components/CouncilScreen.css b/client/src/core/components/CouncilScreen.css
index 6b6b62a..1da02b6 100644
--- a/client/src/core/components/CouncilScreen.css
+++ b/client/src/core/components/CouncilScreen.css
@@ -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;
+}
diff --git a/client/src/core/components/CouncilScreen.jsx b/client/src/core/components/CouncilScreen.jsx
index b7f769b..3722fef 100644
--- a/client/src/core/components/CouncilScreen.jsx
+++ b/client/src/core/components/CouncilScreen.jsx
@@ -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({
-
요구 실적 {required}
+
+ 요구 실적 {required}
+ {requiredDelta !== 0 && (
+
+ {' '}({requiredDelta > 0 ? '+' : ''}{requiredDelta} 규약 적용)
+
+ )}
+
현재 {currentTotal} {met ? '(충족)' : `(부족 ${required - currentTotal})`}
@@ -97,15 +123,17 @@ export default function CouncilScreen({
return (
누적 징벌 규약 {penalties.length}개
- {shown.map((p) => (
- pendingTargetKind === 'penalty' && completeTarget(p.id, 'penalty')}
- >
- ⚠ {p.name}
-
- ))}
+
+ {shown.map((p) => (
+
pendingTargetKind === 'penalty' && completeTarget(p.id, 'penalty')}
+ >
+
+
+ ))}
+
{hidden > 0 && (