diff --git a/client/src/core/components/CombinePanel.css b/client/src/core/components/CombinePanel.css index a13e618..90ab52e 100644 --- a/client/src/core/components/CombinePanel.css +++ b/client/src/core/components/CombinePanel.css @@ -103,6 +103,26 @@ opacity: 0.6; } +.combine-protocols { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.combine-protocol-chip { + font-size: 11px; + padding: 3px 8px; + border-radius: 10px; + background: rgba(255, 215, 0, 0.1); + color: var(--color-risk-critical); + border: 1px solid rgba(255, 215, 0, 0.25); +} + +.combine-blocked { + font-size: 13px; + color: var(--color-risk-critical); +} + .combine-match { display: flex; justify-content: space-between; diff --git a/client/src/core/components/CombinePanel.jsx b/client/src/core/components/CombinePanel.jsx index 676d7f4..1b5d018 100644 --- a/client/src/core/components/CombinePanel.jsx +++ b/client/src/core/components/CombinePanel.jsx @@ -1,19 +1,27 @@ import React, { useMemo, useState } from 'react'; import SymbolBadge from './SymbolBadge'; import { detectHands, resolveCombine, HAND_DEFS } from '../engine/hands'; +import { filterHandsByProtocols } from '../engine/council'; import './CombinePanel.css'; // 합본 결재 (§5) — 책상에서 카드를 다중 선택하면 성립 가능한 족보를 자동 판정해 보여준다. // 여러 족보가 동시 성립하면 최고 족보를 기본값으로 보여주되, 하위 족보도 선택할 수 있게 한다 (§5.3) — // 명분 종류를 고르기 위해 일부러 하위 족보를 쓰는 선택이 존재해야 하기 때문이다. -export default function CombinePanel({ deskCards, onConfirm, onClose }) { +// activeProtocols: 현재 라운드에 적용 중인 §9.2 유형A 규약 — 특정 족보를 무효화하거나 +// 최소 등급을 강제한다. +export default function CombinePanel({ deskCards, onConfirm, onClose, activeProtocols = [] }) { const [selectedIds, setSelectedIds] = useState([]); const selection = useMemo( () => deskCards.filter((c) => selectedIds.includes(c.id)), [deskCards, selectedIds] ); - const matches = useMemo(() => detectHands(selection), [selection]); + const rawMatches = useMemo(() => detectHands(selection), [selection]); + const matches = useMemo( + () => filterHandsByProtocols(rawMatches, selection, activeProtocols), + [rawMatches, selection, activeProtocols] + ); + const blockedByProtocol = rawMatches.length > 0 && matches.length === 0; const toggle = (id) => { setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); @@ -34,6 +42,14 @@ export default function CombinePanel({ deskCards, onConfirm, onClose }) {

책상에서 카드를 선택하면 성립하는 족보를 판정합니다.

+ {activeProtocols.length > 0 && ( +
+ {activeProtocols.map((p) => ( + ⚖ {p.name} + ))} +
+ )} +
{deskCards.length === 0 &&

책상이 비어 있습니다.

} {deskCards.map((c) => ( @@ -49,9 +65,12 @@ export default function CombinePanel({ deskCards, onConfirm, onClose }) {
- {selection.length > 0 && matches.length === 0 && ( + {selection.length > 0 && matches.length === 0 && !blockedByProtocol && (

선택한 {selection.length}장은 성립하는 족보가 없습니다.

)} + {blockedByProtocol && ( +

규약 때문에 이 선택은 합본할 수 없습니다.

+ )} {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 ( +
+
+

결재 불가

+

{gameOver}

+

+ 엔딩 분기(§15)는 4차 범위라 아직 없다 — 여기서 그레이박스 루프가 멈춘다. +

+
+
+ ); + } + if (!currentCard) { return
카드를 불러오는 중...
; } @@ -105,15 +237,43 @@ export default function CoreGameScreen() { gauges={gauges} onOpenCombine={() => setShowCombine(true)} onOpenGuide={() => setShowGuide(true)} + gaugeExtraFlat={gaugeExtraFlat} + tokenExtraFlat={tokenExtraFlat} />
- 실적 {score} + {round}R · 결재 {approvalsThisRound}/{APPROVALS_PER_ROUND} + 실적 {score} (이번 라운드 {roundScore}) 예산 {tokens.budget} · 인력 {tokens.personnel} · 정보 {tokens.info} 명분 {justifications.length}개 보유 + {activeProtocol && ⚖ {activeProtocol.name}}
- + {phase === 'approval' && } + + {phase === 'shop' && ( + + )} + + {phase === 'council' && ( + + )}
{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; +}