- {selection.length > 0 && matches.length === 0 && (
+ {selection.length > 0 && matches.length === 0 && !blockedByProtocol && (
선택한 {selection.length}장은 성립하는 족보가 없습니다.
규약 때문에 이 선택은 합본할 수 없습니다.
+ )}
{matches.map((patternId) => {
const def = HAND_DEFS[patternId];
return (
diff --git a/client/src/core/components/CoreGameScreen.css b/client/src/core/components/CoreGameScreen.css
index ea8d3e6..1a690c1 100644
--- a/client/src/core/components/CoreGameScreen.css
+++ b/client/src/core/components/CoreGameScreen.css
@@ -40,3 +40,29 @@
.core-log-line {
white-space: pre-wrap;
}
+
+.core-status-protocol {
+ color: var(--color-risk-critical);
+}
+
+.core-gameover {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ text-align: center;
+ padding: 24px;
+}
+
+.core-gameover h1 {
+ font-family: var(--font-doc-title);
+ margin: 0;
+}
+
+.core-gameover-note {
+ font-size: 12px;
+ opacity: 0.5;
+ max-width: 360px;
+}
diff --git a/client/src/core/components/CoreGameScreen.jsx b/client/src/core/components/CoreGameScreen.jsx
index 6f31ae9..bf816cf 100644
--- a/client/src/core/components/CoreGameScreen.jsx
+++ b/client/src/core/components/CoreGameScreen.jsx
@@ -5,14 +5,24 @@ import DeskBar from './DeskBar';
import CoreSwipeCard from './CoreSwipeCard';
import CombinePanel from './CombinePanel';
import HandGuidePopup from './HandGuidePopup';
+import ShopScreen from './ShopScreen';
+import CouncilScreen from './CouncilScreen';
import { pickPollutionEntry } from '../engine/pollutionPool';
-import { makeDeskCard, applyPassiveGaugeRise, addToDesk, removeFromDesk } from '../engine/desk';
+import { makeDeskCard, applyPassiveGaugeRise, addToDesk, removeFromDesk, tokenSurcharge } from '../engine/desk';
+import { rollShopDisplay, purgePrice, payTokens, REROLL_COST } from '../engine/shop';
+import {
+ requiredScore, settleScore, rollProtocolCandidates, resolveProtocolVote, rollPenalty,
+ applyInterventions, penaltyGaugeSurcharge, penaltyTokenSurcharge,
+} from '../engine/council';
import './CoreGameScreen.css';
-// 코어 루프 그레이박스 (§2·§17 1차) — 기호 체계 + 책상 적재/누진 페널티(A·B) + 표준 족보 판정/합본 +
-// 합본 기반 토큰 산출까지만 구현한다. 상점·규약·의회·자산·전례·엔딩은 2~4차 범위라 아직 없다.
-// 기존 GameScreen.jsx/engine/pollution.js는 참조용으로만 남기고 건드리지 않는다 — 코어시스템
-// 기획서 자체가 "기존 코드는 참조용, 신규 개발" 원칙을 명시하고 있다.
+// 코어 루프 그레이박스 — §17 1차(기호·책상·족보·토큰)에 이어 2차(상점·규약·실적감사·명분)까지.
+// 라운드 구조: 결재 N건 → 정산(상점) → 의회(실적감사+규약) → 다음 라운드 (§2).
+// 자산·전례·소모품(§10~12, 3차)과 13인 위원회의 실제 우호도·이중첩자·탄핵(§9.4~9.6, 4차)은
+// 아직 없다 — 상점은 "오염 카드 직접 처리"만, 규약 표결은 가중 무작위로 흉내만 낸다.
+// 기존 GameScreen.jsx/engine/pollution.js는 참조용으로만 남기고 건드리지 않는다.
+
+const APPROVALS_PER_ROUND = 8;
const buildCard = (row) => ({
id: row.id,
@@ -37,6 +47,8 @@ const buildCard = (row) => ({
const INITIAL_GAUGES = { entropy: 0, resistance: 0, panic: 0, risk: 0 };
const INITIAL_TOKENS = { budget: 0, personnel: 0, info: 0 };
+let justificationSeq = 0;
+const makeJustificationId = () => `just-${Date.now().toString(36)}-${(justificationSeq++).toString(36)}`;
export default function CoreGameScreen() {
const deck = useMemo(() => {
@@ -54,20 +66,44 @@ export default function CoreGameScreen() {
const [showGuide, setShowGuide] = useState(false);
const [log, setLog] = useState([]);
+ // 라운드 구조 (§2)
+ const [phase, setPhase] = useState('approval'); // 'approval' | 'shop' | 'council'
+ const [round, setRound] = useState(1);
+ const [approvalsThisRound, setApprovalsThisRound] = useState(0);
+ const [roundScore, setRoundScore] = useState(0);
+ const [penalties, setPenalties] = useState([]); // §9.2 유형C, 영구 누적
+ const [activeProtocol, setActiveProtocol] = useState(null); // §9.2 유형A, 이번 라운드 1개
+ const [shopDisplay, setShopDisplay] = useState([]);
+ const [shopRerolled, setShopRerolled] = useState(false);
+ const [councilCandidates, setCouncilCandidates] = useState([]);
+ const [gameOver, setGameOver] = useState(null);
+
const pushLog = (msg) => setLog((prev) => [msg, ...prev].slice(0, 6));
+ const activeProtocols = activeProtocol ? [activeProtocol] : [];
+ const gaugeExtraFlat = penaltyGaugeSurcharge(penalties);
+ const tokenExtraFlat = penaltyTokenSurcharge(penalties);
+
const currentCard = deck.length > 0 ? deck[cardIndex % deck.length] : null;
+ const checkGameOver = (nextGauges) => {
+ const hit = Object.entries(nextGauges).find(([, v]) => v >= 100);
+ if (hit) {
+ const [gaugeKey] = hit;
+ const label = { entropy: '엔트로피 100% — 세계 멸망', resistance: '조직 저항 100% — 사임', panic: '대중 공황 100% — 문명 붕괴', risk: '위험 수용 계수 100%' }[gaugeKey];
+ setGameOver(label);
+ return true;
+ }
+ return false;
+ };
+
const handleSwipe = (direction) => {
- if (!currentCard) return;
+ if (!currentCard || phase !== 'approval') return;
const side = direction === 'right' ? currentCard.right : currentCard.left;
- // A. 게이지 상시 상승 — 이번 결재 시점 책상 상태 기준으로 축별 누진율만큼 올린다 (§4.2-A).
- setGauges((prev) => applyPassiveGaugeRise(prev, deskCards));
+ const nextGauges = applyPassiveGaugeRise(gauges, deskCards, gaugeExtraFlat);
+ setGauges(nextGauges);
- // 선택한 쪽에 축이 지정돼 있으면 오염 카드 1장을 책상에 적재한다.
- // pollution_pool.csv에서 실제 이름·사연을 붙여서, "이 선택이 어떤 서류를 남겼는지"가
- // 축 라벨이 아니라 구체적인 사건으로 보이게 한다 (플레이테스트 피드백).
if (side.axis) {
const grade = side.grade || 1;
const entry = pickPollutionEntry(side.axis, grade);
@@ -79,6 +115,19 @@ export default function CoreGameScreen() {
}
setCardIndex((i) => (i + 1) % deck.length);
+
+ if (checkGameOver(nextGauges)) return;
+
+ const nextApprovals = approvalsThisRound + 1;
+ if (nextApprovals >= APPROVALS_PER_ROUND) {
+ setApprovalsThisRound(0);
+ setShopDisplay(rollShopDisplay(deskCards));
+ setShopRerolled(false);
+ setPhase('shop');
+ pushLog(`제${round}라운드 결재 마감 — 정산 단계로 이동`);
+ } else {
+ setApprovalsThisRound(nextApprovals);
+ }
};
const handleCombineConfirm = (result) => {
@@ -88,12 +137,95 @@ export default function CoreGameScreen() {
Object.entries(result.tokens).forEach(([k, v]) => { next[k] = (next[k] || 0) + v; });
return next;
});
- setJustifications((prev) => [...prev, result.justification]);
+ setJustifications((prev) => {
+ const next = [...prev, { id: makeJustificationId(), ...result.justification }];
+ return next.length > 5 ? next.slice(next.length - 5) : next; // §7.1 최대 5개 보관
+ });
setScore((prev) => prev + result.performanceScore);
+ setRoundScore((prev) => prev + result.performanceScore);
pushLog(`합본 성립: 『${result.pattern.name}』 — 실적 +${result.performanceScore}, 명분(${result.justification.type}) +${result.justification.amount}`);
setShowCombine(false);
};
+ /* ── 상점 (§8.3) ── */
+ const handleShopPurge = (cardId) => {
+ const card = deskCards.find((c) => c.id === cardId);
+ if (!card) return;
+ const price = purgePrice(card, tokenSurcharge(deskCards, tokenExtraFlat));
+ setTokens((prev) => payTokens(prev, price));
+ setDeskCards((prev) => removeFromDesk(prev, [cardId]));
+ setShopDisplay((prev) => prev.filter((c) => c.id !== cardId));
+ pushLog(`상점: 『${card.name || '오염 카드'}』 직접 소각 (예산 -${price.budget})`);
+ };
+ const handleShopReroll = () => {
+ setTokens((prev) => payTokens(prev, REROLL_COST));
+ setShopDisplay(rollShopDisplay(deskCards));
+ setShopRerolled(true);
+ };
+ const handleShopContinue = () => {
+ setCouncilCandidates(rollProtocolCandidates());
+ setPhase('council');
+ };
+
+ /* ── 의회 (§9) ── */
+ const handleCouncilResolve = ({ tokensPaid, justificationsPaidCount, interventions }) => {
+ const { candidates: remainingCandidates, forcedWinnerId, deferVote, requiredScoreMultiplier, penaltyIdToClear } =
+ applyInterventions(councilCandidates, interventions);
+
+ const finalRequired = Math.round(requiredScore(round) * requiredScoreMultiplier);
+ const tokensPaidCount = Object.values(tokensPaid).reduce((a, b) => a + b, 0);
+ const finalScore = settleScore({ combineScore: roundScore, tokensPaid: tokensPaidCount, justificationsPaid: justificationsPaidCount });
+ const passed = finalScore >= finalRequired;
+
+ setTokens((prev) => payTokens(prev, tokensPaid));
+ setJustifications([]); // §7.1: 다음 의회까지만 유효, 이후 소멸 — 쓰지 않은 것도 여기서 사라진다
+
+ setPenalties((prev) => {
+ let next = prev;
+ if (penaltyIdToClear) next = next.filter((p) => p.id !== penaltyIdToClear);
+ if (!passed) {
+ const penalty = rollPenalty();
+ next = [...next, { ...penalty, id: `${penalty.id}-${Date.now().toString(36)}` }];
+ pushLog(`실적 감사 미달 (${finalScore}/${finalRequired}) — 징벌 규약 『${penalty.name}』 부과`);
+ } else {
+ pushLog(`실적 감사 통과 (${finalScore}/${finalRequired})`);
+ if (finalScore >= finalRequired * 1.5 && next.length > 0) {
+ pushLog(`실적 초과 달성 — 징벌 규약 『${next[0].name}』 조기 해제`);
+ next = next.slice(1);
+ }
+ }
+ return next;
+ });
+
+ if (deferVote) {
+ pushLog('절차 명분으로 이번 라운드 규약 결정이 이월되었습니다.');
+ } else {
+ const winner = forcedWinnerId
+ ? remainingCandidates.find((c) => c.id === forcedWinnerId) || null
+ : resolveProtocolVote(remainingCandidates);
+ setActiveProtocol(winner);
+ if (winner) pushLog(`규약 통과: 『${winner.name}』 — 다음 라운드 적용`);
+ }
+
+ setRound((r) => r + 1);
+ setRoundScore(0);
+ setPhase('approval');
+ };
+
+ if (gameOver) {
+ return (
+
카드를 불러오는 중...
;
}
@@ -105,15 +237,43 @@ export default function CoreGameScreen() {
gauges={gauges}
onOpenCombine={() => setShowCombine(true)}
onOpenGuide={() => setShowGuide(true)}
+ gaugeExtraFlat={gaugeExtraFlat}
+ tokenExtraFlat={tokenExtraFlat}
/>
{log.map((line, i) =>
{line}
)}
@@ -124,6 +284,7 @@ export default function CoreGameScreen() {
deskCards={deskCards}
onConfirm={handleCombineConfirm}
onClose={() => setShowCombine(false)}
+ activeProtocols={activeProtocols}
/>
)}
diff --git a/client/src/core/components/CouncilScreen.css b/client/src/core/components/CouncilScreen.css
new file mode 100644
index 0000000..1681368
--- /dev/null
+++ b/client/src/core/components/CouncilScreen.css
@@ -0,0 +1,212 @@
+.council-screen {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 20px;
+ max-width: 560px;
+ margin: 0 auto;
+ width: 100%;
+ overflow-y: auto;
+ font-family: var(--font-ui);
+}
+
+.council-screen h2 {
+ font-family: var(--font-doc-title);
+ margin: 0;
+}
+
+.council-screen h3 {
+ font-size: 13px;
+ margin: 0 0 8px;
+ opacity: 0.75;
+ font-family: var(--font-typewriter);
+}
+
+.council-audit {
+ background: var(--color-surface);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 8px;
+ padding: 14px;
+}
+
+.council-audit-row {
+ display: flex;
+ justify-content: space-between;
+ font-family: var(--font-typewriter);
+ font-size: 14px;
+ font-weight: 700;
+}
+
+.audit-met { color: var(--color-token-budget); }
+.audit-short { color: var(--color-risk-critical); }
+
+.council-audit-breakdown {
+ font-size: 11px;
+ opacity: 0.6;
+ margin: 6px 0 12px;
+}
+
+.council-token-pay {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.council-token-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-family: var(--font-typewriter);
+ font-size: 12px;
+ background: rgba(255, 255, 255, 0.04);
+ padding: 4px 8px;
+ border-radius: 5px;
+}
+
+.council-token-row button {
+ background: rgba(255, 255, 255, 0.1);
+ border: none;
+ color: var(--color-text);
+ width: 20px;
+ height: 20px;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.council-token-row button:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+}
+
+.council-token-committed {
+ min-width: 14px;
+ text-align: center;
+ color: var(--color-token-budget);
+}
+
+.council-penalties {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.council-penalty-chip {
+ font-size: 12px;
+ padding: 6px 10px;
+ background: rgba(255, 68, 68, 0.08);
+ border: 1px solid rgba(255, 68, 68, 0.25);
+ border-radius: 5px;
+ color: var(--color-risk-critical);
+}
+
+.council-penalty-chip.targetable {
+ cursor: pointer;
+ outline: 1px dashed var(--color-risk-critical);
+}
+
+.council-justifications {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.council-empty {
+ font-size: 12px;
+ opacity: 0.5;
+}
+
+.council-justification-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+ background: rgba(255, 255, 255, 0.03);
+ padding: 8px;
+ border-radius: 6px;
+}
+
+.justification-badge {
+ font-family: var(--font-typewriter);
+ font-size: 12px;
+ font-weight: 700;
+ padding: 2px 8px;
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.council-justification-item button {
+ font-family: var(--font-typewriter);
+ font-size: 11px;
+ background: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ color: var(--color-text);
+ border-radius: 4px;
+ padding: 4px 8px;
+ cursor: pointer;
+}
+
+.council-justification-item button:hover {
+ border-color: var(--color-token-budget);
+}
+
+.council-pending-hint {
+ font-size: 11px;
+ color: var(--color-risk-critical);
+}
+
+.council-candidates {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.council-candidate {
+ background: var(--color-surface);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 6px;
+ padding: 10px 12px;
+}
+
+.council-candidate.targetable {
+ cursor: pointer;
+ outline: 1px dashed var(--color-token-info);
+}
+
+.council-candidate.removed {
+ opacity: 0.4;
+ text-decoration: line-through;
+}
+
+.council-candidate.forced {
+ border-color: var(--color-token-budget);
+}
+
+.candidate-name {
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.candidate-flavor {
+ font-size: 11px;
+ opacity: 0.6;
+ margin-top: 3px;
+}
+
+.council-deferred-note {
+ font-size: 12px;
+ opacity: 0.7;
+}
+
+.council-confirm-btn {
+ font-family: var(--font-typewriter);
+ background: var(--color-token-info);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 12px;
+ font-weight: 700;
+ cursor: pointer;
+ font-size: 14px;
+ margin-top: 4px;
+}
diff --git a/client/src/core/components/CouncilScreen.jsx b/client/src/core/components/CouncilScreen.jsx
new file mode 100644
index 0000000..d6d4e43
--- /dev/null
+++ b/client/src/core/components/CouncilScreen.jsx
@@ -0,0 +1,152 @@
+import React, { useState } from 'react';
+import { requiredScore, settleScore, JUSTIFICATION_INTERVENTIONS } from '../engine/council';
+import './CouncilScreen.css';
+
+// 의회 (§9.1) — 실적 감사와 규약 결정은 독립적이다: 실적을 잘 채웠다고 우호적 규약이
+// 상정되지 않는다. 13인 위원회의 실제 우호도·표결(§9.4~9.6)은 4차 범위라 없다 — 후보 1개는
+// 가중 무작위로 뽑힌 "통과"를 그대로 쓴다. 플레이어는 표결에 직접 개입 못 하고 명분으로
+// 안건 자체를 조작할 뿐이라는 원칙(§9.2)만 지켰다.
+export default function CouncilScreen({
+ round, combineScore, tokens, justifications, penalties, candidates, onResolve,
+}) {
+ const required = requiredScore(round);
+
+ const [tokenPayments, setTokenPayments] = useState({ budget: 0, personnel: 0, info: 0 });
+ const [usedIds, setUsedIds] = useState([]);
+ const [scorePaidIds, setScorePaidIds] = useState([]);
+ const [interventions, setInterventions] = useState([]);
+ const [pending, setPending] = useState(null); // { type, justificationId }
+
+ const tokensPaidCount = Object.values(tokenPayments).reduce((a, b) => a + b, 0);
+ const currentTotal = settleScore({ combineScore, tokensPaid: tokensPaidCount, justificationsPaid: scorePaidIds.length });
+ const met = currentTotal >= required;
+
+ const payToken = (type) => {
+ if (tokenPayments[type] >= (tokens[type] || 0)) return;
+ setTokenPayments((p) => ({ ...p, [type]: p[type] + 1 }));
+ };
+ const unpayToken = (type) => {
+ if (tokenPayments[type] <= 0) return;
+ setTokenPayments((p) => ({ ...p, [type]: p[type] - 1 }));
+ };
+
+ const payJustificationForScore = (j) => {
+ setUsedIds((prev) => [...prev, j.id]);
+ setScorePaidIds((prev) => [...prev, j.id]);
+ };
+
+ const startIntervention = (type, j) => {
+ const spec = JUSTIFICATION_INTERVENTIONS[type];
+ if (!spec.needsTarget) {
+ setUsedIds((prev) => [...prev, j.id]);
+ setInterventions((prev) => [...prev, { type, justificationId: j.id }]);
+ } else {
+ setPending({ type, justificationId: j.id });
+ }
+ };
+
+ const completeTarget = (targetId, targetKind) => {
+ if (!pending) return;
+ setUsedIds((prev) => [...prev, pending.justificationId]);
+ setInterventions((prev) => [...prev, {
+ type: pending.type,
+ justificationId: pending.justificationId,
+ ...(targetKind === 'candidate' ? { targetCandidateId: targetId } : { targetPenaltyId: targetId }),
+ }]);
+ setPending(null);
+ };
+
+ const availableJustifications = justifications.filter((j) => !usedIds.includes(j.id));
+
+ const confirm = () => {
+ onResolve({ tokensPaid: tokenPayments, justificationsPaidCount: scorePaidIds.length, interventions });
+ };
+
+ return (
+
+
제{round}차 정례회의
+
+
+
+ 요구 실적 {required}
+
+ 현재 {currentTotal} {met ? '(충족)' : `(부족 ${required - currentTotal})`}
+
+
+
+ 족보 {combineScore} + 토큰 납입 {tokensPaidCount}×5 + 명분 납입 {scorePaidIds.length}×20
+
+
+
+ {['budget', 'personnel', 'info'].map((t) => (
+
+ {{ budget: '예산', personnel: '인력', info: '정보' }[t]} {tokens[t] - tokenPayments[t]}
+
+ {tokenPayments[t]}
+
+
+ ))}
+
+
+
+ {penalties.length > 0 && (
+
+ 누적 징벌 규약
+ {penalties.map((p) => (
+ pending?.type === '위기' && completeTarget(p.id, 'penalty')}
+ >
+ ⚠ {p.name}
+
+ ))}
+
+ )}
+
+
+ 보유 명분 {availableJustifications.length}개
+ {availableJustifications.length === 0 && 사용 가능한 명분이 없습니다.
}
+ {availableJustifications.map((j) => (
+
+ {j.type} +{j.amount}
+
+ {Object.entries(JUSTIFICATION_INTERVENTIONS)
+ .filter(([type]) => type === j.type)
+ .map(([type, spec]) => (
+
+ ))}
+
+ ))}
+ {pending && (
+
+ "{pending.type}" 개입 대상을 아래 규약 후보{pending.type === '위기' ? '(또는 위 징벌)' : ''}에서 클릭하세요.
+
+ )}
+
+
+
+ 규약 후보
+ {candidates.map((c) => {
+ const removed = interventions.some((iv) => iv.type === '여론' && iv.targetCandidateId === c.id);
+ const forced = interventions.some((iv) => iv.type === '혼합' && iv.targetCandidateId === c.id);
+ return (
+ (pending?.type === '여론' || pending?.type === '혼합') && completeTarget(c.id, 'candidate')}
+ >
+
{c.name}{removed && ' (탈락됨)'}{forced && ' (강제 통과 지정)'}
+
{c.flavor}
+
+ );
+ })}
+ {interventions.some((iv) => iv.type === '절차') && (
+ 절차 명분으로 이번 라운드 규약 결정이 보류됩니다.
+ )}
+
+
+
+
+ );
+}
diff --git a/client/src/core/components/DeskBar.jsx b/client/src/core/components/DeskBar.jsx
index ecd2b3a..8676f16 100644
--- a/client/src/core/components/DeskBar.jsx
+++ b/client/src/core/components/DeskBar.jsx
@@ -8,12 +8,12 @@ import './DeskBar.css';
// 책상 상태 상시 노출 (§3.4) — "이 표시가 없으면 합본 설계가 불가능하며 스와이프는 무의미한 도박이 된다."
// 플레이테스트 피드백 반영: 장수만으로는 부족해서 축×등급 분포와, 그게 어느 게이지를 얼마나
// 올리고 있는지(▲+X%/결재)까지 항상 보이게 했다. 개별 카드 이름은 축을 클릭하면 팝업으로.
-export default function DeskBar({ deskCards, gauges, onOpenCombine, onOpenGuide }) {
+export default function DeskBar({ deskCards, gauges, onOpenCombine, onOpenGuide, gaugeExtraFlat = 0, tokenExtraFlat = 0 }) {
const [detailAxis, setDetailAxis] = useState(null);
const breakdown = gradeBreakdownByAxis(deskCards);
- const rates = gaugeRiseRateByAxis(deskCards);
+ const rates = gaugeRiseRateByAxis(deskCards, gaugeExtraFlat);
const total = totalCount(deskCards);
- const surcharge = tokenSurcharge(deskCards);
+ const surcharge = tokenSurcharge(deskCards, tokenExtraFlat);
return (
diff --git a/client/src/core/components/ShopScreen.css b/client/src/core/components/ShopScreen.css
new file mode 100644
index 0000000..54cf643
--- /dev/null
+++ b/client/src/core/components/ShopScreen.css
@@ -0,0 +1,119 @@
+.shop-screen {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 24px 20px;
+ max-width: 520px;
+ margin: 0 auto;
+ width: 100%;
+ overflow-y: auto;
+}
+
+.shop-header h2 {
+ font-family: var(--font-doc-title);
+ margin: 0 0 6px;
+}
+
+.shop-hint {
+ font-size: 12px;
+ opacity: 0.65;
+ margin: 0;
+ line-height: 1.5;
+}
+
+.shop-tokens {
+ font-family: var(--font-typewriter);
+ font-size: 13px;
+ padding: 8px 12px;
+ background: rgba(255, 255, 255, 0.04);
+ border-radius: 6px;
+}
+
+.shop-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.shop-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 12px;
+ background: var(--color-surface);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 6px;
+ flex-wrap: wrap;
+ min-width: 0;
+}
+
+.shop-item-name {
+ flex: 1;
+ min-width: 60px;
+ font-size: 13px;
+ font-weight: 700;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.shop-buy-btn {
+ font-family: var(--font-typewriter);
+ font-size: 11px;
+ background: var(--color-token-budget);
+ color: #0a0a0a;
+ border: none;
+ border-radius: 4px;
+ padding: 6px 9px;
+ cursor: pointer;
+ font-weight: 700;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.shop-buy-btn:disabled {
+ background: rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.35);
+ cursor: not-allowed;
+}
+
+.shop-empty {
+ font-size: 13px;
+ opacity: 0.5;
+}
+
+.shop-actions {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ margin-top: 8px;
+}
+
+.shop-reroll-btn,
+.shop-continue-btn {
+ font-family: var(--font-typewriter);
+ border-radius: 4px;
+ padding: 10px 14px;
+ cursor: pointer;
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.shop-reroll-btn {
+ background: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ color: var(--color-text);
+}
+
+.shop-reroll-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.shop-continue-btn {
+ background: var(--color-token-info);
+ border: none;
+ color: #fff;
+ margin-left: auto;
+}
diff --git a/client/src/core/components/ShopScreen.jsx b/client/src/core/components/ShopScreen.jsx
new file mode 100644
index 0000000..c79ebfa
--- /dev/null
+++ b/client/src/core/components/ShopScreen.jsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import SymbolBadge from './SymbolBadge';
+import { purgePrice, REROLL_COST, canAfford } from '../engine/shop';
+import './ShopScreen.css';
+
+// 상점 (§8.3) — 라운드 종료 후 의회 진입 전 정산 단계. 자산 구매·소모품·전례 매입은
+// 각각 §10·§12·§11(3차 범위)이 있어야 성립해서 아직 없다. 지금 성립하는 품목은
+// "오염 카드 직접 처리" 하나뿐이다 — 족보가 안 맞을 때의 탈출구, 명분·실적은 안 준다.
+export default function ShopScreen({ display, tokens, deskSurcharge, onPurge, onReroll, rerolled, onContinue }) {
+ const rerollAffordable = canAfford(tokens, REROLL_COST);
+
+ return (
+
+
+
정산 — 상점
+
+ 족보가 안 맞는 오염 카드를 토큰으로 직접 처리할 수 있습니다 (명분·실적은 없음).
+
+
+
+
+ 예산 {tokens.budget} · 인력 {tokens.personnel} · 정보 {tokens.info}
+
+
+
+ {display.length === 0 &&
진열할 오염 카드가 없습니다.
}
+ {display.map((card) => {
+ const price = purgePrice(card, deskSurcharge);
+ const affordable = canAfford(tokens, price);
+ return (
+
+
+ {card.name || '미상 서류'}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/core/engine/council.js b/client/src/core/engine/council.js
new file mode 100644
index 0000000..f5935e3
--- /dev/null
+++ b/client/src/core/engine/council.js
@@ -0,0 +1,149 @@
+// 의회 엔진 — ProjectSS_코어시스템_기획서.md §7(명분) · §9.2(규약) · §9.3(실적 감사).
+// React 의존 없는 순수 함수 + 카탈로그. 13인 위원회의 실제 우호도·표결(§9.4~9.6)은
+// 이중첩자·탄핵 방어까지 포함된 4차 범위라 아직 없다 — 그 대신 규약 후보 3개 중 1개를
+// 뽑는 절차는 "가중 무작위"로 흉내만 낸다. §9.1 "규약 결정과 실적 감사는 독립적"이라는
+// 원칙만은 지킨다: 감사 결과가 어떤 규약이 뽑히는지에 영향을 주지 않는다.
+
+/* ── §9.3 실적 감사 ── */
+
+// 요구치 곡선은 가안: 1R 25, 라운드당 +20.
+export const requiredScore = (round) => 25 + (round - 1) * 20;
+
+// 실적 점수 = Σ(족보 점수, 라운드 중 합본 누적) + 토큰 납입×5 + 명분 납입×20.
+export const settleScore = ({ combineScore, tokensPaid, justificationsPaid }) =>
+ combineScore + tokensPaid * 5 + justificationsPaid * 20;
+
+/* ── §9.2 규약 — 유형 A(족보표 변경) 우선, 유형 C(징벌)는 감사 실패 시 필요해서 최소 카탈로그만 ── */
+
+export const PROTOCOL_TYPE_A = [
+ {
+ id: 'protocol_flush_ban',
+ name: '동일 부서 일괄 처리 금지 규약',
+ effect: { kind: 'disable_hand', patternId: 'flush_axis' },
+ flavor: '동일 부서 건을 묶어 올리는 관행이 유착으로 지적되었습니다. 개별 상신할 것.',
+ },
+ {
+ id: 'protocol_straight_ban',
+ name: '연번 상신 금지 규약',
+ effect: { kind: 'disable_hand', patternId: 'straight' },
+ flavor: '등급을 순서대로 올리는 관행이 결재 회피 수단으로 악용되고 있습니다.',
+ },
+ {
+ id: 'protocol_straightflush_ban',
+ name: '전결 처리 금지 규약',
+ effect: { kind: 'disable_hand', patternId: 'straight_flush' },
+ flavor: '한 부서가 연번까지 독점 처리하는 것은 감사 사각지대를 만듭니다.',
+ },
+ {
+ id: 'protocol_fourkind_ban',
+ name: '전면 개편 금지 규약',
+ effect: { kind: 'disable_hand', patternId: 'four_kind' },
+ flavor: '동일 등급 4건 일괄 개편은 절차 생략 소지가 있어 당분간 금지합니다.',
+ },
+ {
+ id: 'protocol_min_grade',
+ name: '기밀 등급 심사 강화 규약',
+ effect: { kind: 'min_grade', minGrade: 2 },
+ flavor: '일반 문서 단독으로는 더 이상 합본 상신이 승인되지 않습니다.',
+ },
+];
+
+export const PROTOCOL_TYPE_C = [
+ {
+ id: 'penalty_gauge_accel',
+ name: '미결 서류 관리 규정',
+ effect: { kind: 'gauge_surcharge', amount: 1.0 },
+ flavor: '미결 누적에 대한 문책성 규정 — 모든 축의 결재당 게이지 상승이 가산됩니다.',
+ },
+ {
+ id: 'penalty_token_surcharge',
+ name: '긴급 재정 감사 대응',
+ effect: { kind: 'token_surcharge', amount: 2 },
+ flavor: '감사 대응 인력 편성 비용이 모든 토큰 지출에 얹힙니다.',
+ },
+];
+
+const pick = (arr, n) => {
+ const pool = [...arr];
+ const out = [];
+ while (out.length < n && pool.length > 0) {
+ out.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]);
+ }
+ return out;
+};
+
+/** 매 라운드 규약 후보 3개를 뽑는다 (유형 A만, §17 2차 범위). */
+export const rollProtocolCandidates = () => pick(PROTOCOL_TYPE_A, 3);
+
+/** 후보 중 1개를 "표결 통과"로 확정한다 — 실제 위원회가 없어 가중 무작위로 대신한다. */
+export const resolveProtocolVote = (candidates) => {
+ if (candidates.length === 0) return null;
+ return candidates[Math.floor(Math.random() * candidates.length)];
+};
+
+export const rollPenalty = () => pick(PROTOCOL_TYPE_C, 1)[0];
+
+/* ── §7.2 명분 개입 — 규약 후보에 개입한다. 실제 13인 표결이 없어 "1표 뒤집기"(혼합 명분)는
+ * 문자 그대로 구현할 표가 없다. 대신 혼합 명분은 "후보 중 하나를 직접 지정 통과"로 대체했다 —
+ * 표 조작의 최종 효과(원하는 규약을 통과시킨다)는 같지만, 이건 §9.4~9.6의 실제 위원회가
+ * 생기면 반드시 다시 설계해야 할 자리표시자다. */
+export const JUSTIFICATION_INTERVENTIONS = {
+ 절차: { label: '규약 이월 (이번 라운드 규약 보류)', needsTarget: false },
+ 여론: { label: '후보 1개 강제 탈락', needsTarget: true },
+ 위기: { label: '누적 징벌 규약 1개 조기 해제', needsTarget: 'penalty' },
+ 내부: { label: '이번 라운드 실적 요구치 하향 (-20%)', needsTarget: false },
+ 혼합: { label: '후보 1개 직접 통과 지정', needsTarget: true },
+};
+
+/**
+ * 명분 개입을 규약 후보에 반영한다 (§7.2 규약 개입).
+ * interventions: [{ type: '절차'|'여론'|'위기'|'내부'|'혼합', targetCandidateId?, targetPenaltyId? }]
+ * @returns {{ candidates, forcedWinnerId, deferVote, requiredScoreMultiplier, penaltyIdToClear }}
+ */
+export function applyInterventions(candidates, interventions) {
+ let pool = [...candidates];
+ let forcedWinnerId = null;
+ let deferVote = false;
+ let requiredScoreMultiplier = 1;
+ let penaltyIdToClear = null;
+
+ interventions.forEach((iv) => {
+ if (iv.type === '절차') deferVote = true;
+ if (iv.type === '여론' && iv.targetCandidateId) {
+ pool = pool.filter((c) => c.id !== iv.targetCandidateId);
+ }
+ if (iv.type === '내부') requiredScoreMultiplier *= 0.8;
+ if (iv.type === '위기' && iv.targetPenaltyId) penaltyIdToClear = iv.targetPenaltyId;
+ // 혼합 명분: 실제 표 뒤집기가 아니라 "직접 통과 지정"으로 대체한 자리표시자 — 위 §7.2 주석 참고.
+ if (iv.type === '혼합' && iv.targetCandidateId) forcedWinnerId = iv.targetCandidateId;
+ });
+
+ return { candidates: pool, forcedWinnerId, deferVote, requiredScoreMultiplier, penaltyIdToClear };
+}
+
+/* ── 합본 판정에 규약 효과를 얹는다 ── */
+
+/** 활성 유형 A 규약(들)을 반영해 detectHands 결과를 거른다. */
+export function filterHandsByProtocols(matches, selection, activeProtocols) {
+ let allowed = matches;
+ activeProtocols.forEach((p) => {
+ if (p.effect.kind === 'disable_hand') {
+ allowed = allowed.filter((id) => id !== p.effect.patternId);
+ }
+ if (p.effect.kind === 'min_grade') {
+ const violatesMinGrade = selection.some((c) => c.grade < p.effect.minGrade);
+ if (violatesMinGrade) 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);
+}
diff --git a/client/src/core/engine/desk.js b/client/src/core/engine/desk.js
index 7b0e9f1..1b238a2 100644
--- a/client/src/core/engine/desk.js
+++ b/client/src/core/engine/desk.js
@@ -64,19 +64,20 @@ export function gaugeRiseRate(pressure) {
return tier ? tier.rate : 13.0;
}
-/** 축별 현재 상승률(%) — DeskBar의 "▲+X%/결재" 표시에 그대로 쓴다. */
-export function gaugeRiseRateByAxis(deskCards) {
+/** 축별 현재 상승률(%) — DeskBar의 "▲+X%/결재" 표시에 그대로 쓴다.
+ * extraFlat: 활성 징벌 규약(§9.2 유형C)이 얹는 가산분(council.js의 penaltyGaugeSurcharge). */
+export function gaugeRiseRateByAxis(deskCards, extraFlat = 0) {
const out = {};
- AXES.forEach((axis) => { out[axis] = gaugeRiseRate(axisPressure(deskCards, axis)); });
+ AXES.forEach((axis) => { out[axis] = gaugeRiseRate(axisPressure(deskCards, axis)) + extraFlat; });
return out;
}
/** 매 결재마다 4개 게이지 전부에 축별 상승분을 더한다. gauges = { entropy, resistance, panic, risk } (0~100). */
-export function applyPassiveGaugeRise(gauges, deskCards) {
+export function applyPassiveGaugeRise(gauges, deskCards, extraFlat = 0) {
const next = { ...gauges };
AXES.forEach((axis) => {
const gaugeKey = axisGaugeKey(axis);
- const rate = gaugeRiseRate(axisPressure(deskCards, axis));
+ const rate = gaugeRiseRate(axisPressure(deskCards, axis)) + extraFlat;
next[gaugeKey] = Math.min(100, (next[gaugeKey] || 0) + rate);
});
return next;
@@ -96,11 +97,12 @@ const SURCHARGE_TIERS = [
{ max: Infinity, surcharge: 4 },
];
-/** 총 적재 장수 기준 모든 토큰 지출에 붙는 가산액. */
-export function tokenSurcharge(deskCards) {
+/** 총 적재 장수 기준 모든 토큰 지출에 붙는 가산액.
+ * extraFlat: 활성 징벌 규약(§9.2 유형C)이 얹는 가산분(council.js의 penaltyTokenSurcharge). */
+export function tokenSurcharge(deskCards, extraFlat = 0) {
const total = totalCount(deskCards);
const tier = SURCHARGE_TIERS.find((t) => total <= t.max);
- return tier ? tier.surcharge : 4;
+ return (tier ? tier.surcharge : 4) + extraFlat;
}
/** 책상에서 특정 카드들을 제거한 새 배열을 반환한다 (합본 실행 시 사용). */
diff --git a/client/src/core/engine/shop.js b/client/src/core/engine/shop.js
new file mode 100644
index 0000000..51bf302
--- /dev/null
+++ b/client/src/core/engine/shop.js
@@ -0,0 +1,35 @@
+// 상점 엔진 — ProjectSS_코어시스템_기획서.md §8.3.
+// 자산 구매·소모품·전례 매입은 각각 §10·§12·§11(3차 범위)이 있어야 성립하는데 아직 없다.
+// 그래서 2차에서 성립하는 품목은 "오염 카드 직접 처리"(§8.3 표: 족보가 안 맞을 때의 탈출구,
+// 명분·실적 없음) 하나뿐이다 — 이 자체가 §17 로드맵 순서를 지키기 위한 의도적 축소다.
+// 진열 규칙(무작위 4~6개, 1회 리롤 가능·토큰 소모)만 그대로 §8.3을 따른다.
+
+export const SHOP_DISPLAY_MIN = 4;
+export const SHOP_DISPLAY_MAX = 6;
+export const REROLL_COST = { budget: 3 };
+
+/** 오염 카드 1장을 직접 처리(소각)하는 가격 — 등급이 높을수록, 책상이 꽉 찰수록 비싸진다. */
+export function purgePrice(card, currentSurcharge) {
+ return { budget: card.grade * 3 + currentSurcharge };
+}
+
+/** 책상 카드 중 무작위로 4~6장(또는 그 미만이면 전부)을 진열한다. */
+export function rollShopDisplay(deskCards) {
+ const n = Math.min(deskCards.length, SHOP_DISPLAY_MIN + Math.floor(Math.random() * (SHOP_DISPLAY_MAX - SHOP_DISPLAY_MIN + 1)));
+ const pool = [...deskCards];
+ const out = [];
+ while (out.length < n && pool.length > 0) {
+ out.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]);
+ }
+ return out;
+}
+
+export function canAfford(tokens, price) {
+ return Object.entries(price).every(([k, v]) => (tokens[k] || 0) >= v);
+}
+
+export function payTokens(tokens, price) {
+ const next = { ...tokens };
+ Object.entries(price).forEach(([k, v]) => { next[k] = (next[k] || 0) - v; });
+ return next;
+}