import React, { useMemo, useState } from 'react'; import Papa from 'papaparse'; import cardsCsvRaw from '../../data/cards.csv?raw'; 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, roundAttention, roundAttentionCap, } from '../engine/desk'; import { rollShopDisplay, purgePrice, REROLL_COST } from '../engine/shop'; import { requiredScore, settleScore, rollProtocolCandidates, resolveProtocolVote, rollPenalty, applyInterventions, gaugeSurchargeOf, tokenSurchargeOf, attentionCapBonusOf, requiredScoreDeltaOf, grantTokenOf, tallyVotes, applyVoteSwing, COUNCIL_SEATS, } from '../engine/council'; import './CoreGameScreen.css'; // 코어 루프 그레이박스 — §17 1차(기호·책상·족보·토큰) + 2차(상점·규약·실적감사·명분). // // 2026-09-16 결정 반영 (라운드/회기 이층 구조 폐지, 결정 노트: // 2026-09-16-라운드-주목도-하드캡-복원): 라운드 하나 = 결재하다 정산(상점)→의회로 가는 단일 // 사이클이다. 결재 8건 같은 고정 건수가 아니라 **주목도가 이번 라운드의 한도(§4.2-B)를 // 넘으면 그 즉시 강제 종료**되고, 한도 밑에서는 플레이어가 아무 때나 "정산하기"로 자율 // 종료할 수 있다 — 블랙잭의 "히트/스탠드"와 같다. 강제 종료 시 합본 기회 없이 카드는 그대로 // 이월된다(§4.1). 한도 자체가 라운드 번호에 따라 커진다(roundAttentionCap). // // 자산·전례·소모품(§10~12, 3차)과 13인 위원회의 실제 우호도·이중첩자·탄핵(§9.4~9.6, 4차)은 // 아직 없다 — 상점은 "오염 카드 직접 처리"만, 규약 표결은 무작위로 흉내만 낸다. const INITIAL_TOKENS = 5; // §8.4 초기 지급 — 토큰 0으로 아무것도 못 하는 데드락 방지 const JUSTIFICATION_CAP = 5; // §7.1 보관 최대 5개 const buildCard = (row) => ({ id: row.id, type: row.type, keyword: row.keyword, question: row.question, text: row.text, flavor: row.flavor, left: { text: row.left_text, axis: row.left_axis || null, grade: row.left_grade ? Number(row.left_grade) : null, tag: row.left_tag || null, }, right: { text: row.right_text, axis: row.right_axis || null, grade: row.right_grade ? Number(row.right_grade) : null, tag: row.right_tag || null, }, }); 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 [drawQueue, setDrawQueue] = useState(() => shuffle(deck)); const [deskCards, setDeskCards] = useState([]); const [gauges, setGauges] = useState(INITIAL_GAUGES); const [tokens, setTokens] = useState(INITIAL_TOKENS); const [score, setScore] = useState(0); const [justifications, setJustifications] = useState({}); // { 명분종류: 수량 } const [showCombine, setShowCombine] = useState(false); const [showGuide, setShowGuide] = useState(false); const [log, setLog] = useState([]); // 라운드 구조 (§2) const [phase, setPhase] = useState('approval'); // 'approval' | 'shop' | 'council' const [round, setRound] = useState(1); const [roundScore, setRoundScore] = useState(0); // §9.3: 이번 라운드 중 합본한 족보 점수 합산 const [penalties, setPenalties] = useState([]); // §9.2 유형C, 영구 누적 const [activeProtocol, setActiveProtocol] = useState(null); // §9.2 유형A, 이번 라운드 1개 const [deferredCandidates, setDeferredCandidates] = useState([]); // 절차 명분 이월분 (§7.3) 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] : []; // 징벌(유형 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, attentionCapBonusOf(activeProtocols)); 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); if (hit) { const [gaugeKey] = hit; const label = { entropy: '엔트로피 100% — 세계 멸망', resistance: '조직 저항 100% — 사임', panic: '대중 공황 100% — 문명 붕괴', risk: '위험 수용 계수 100%', }[gaugeKey]; setGameOver(label); return true; } return false; }; const goToShop = () => { setShopDisplay(rollShopDisplay(deskCards)); setShopRerolled(false); setPhase('shop'); }; const handleSwipe = (direction) => { if (!currentCard || phase !== 'approval') return; const side = direction === 'right' ? currentCard.right : currentCard.left; const nextGauges = applyPassiveGaugeRise(gauges, deskCards, gaugeExtraFlat); setGauges(nextGauges); // setState는 비동기라, 이번 카드 반영 후의 책상 상태를 한도 판정에 바로 써야 하므로 // 로컬 변수로 미리 계산한다. let nextDesk = deskCards; if (side.axis) { const grade = side.grade || 1; const entry = pickPollutionEntry(side.axis, grade); const newCard = makeDeskCard(side.axis, grade, entry || {}, round); nextDesk = addToDesk(deskCards, [newCard]); setDeskCards(nextDesk); pushLog(`『${side.text}』 결재됨 → 오염 서류 『${entry?.name || '미상'}』(등급 ${grade}) 접수`); } else { pushLog(`『${side.text}』 결재됨 — 오염 없음`); } advanceCard(); if (checkGameOver(nextGauges)) return; // §4.2-B: 이번 라운드 한도를 넘기면 합본 기회 없이 그 즉시 강제 종료된다. 카드는 이월(§4.1). // 이월 카드는 한도 판정에서 빠진다(roundAttention) — 안 그러면 다음 라운드가 시작하자마자 // 버스트가 반복된다. if (roundAttention(nextDesk, round) > attentionCap) { pushLog(`주목도 초과(한도 ${attentionCap}) — 라운드 강제 종료, 합본 기회 없이 정산으로 이동`); goToShop(); return; } }; /** 플레이어가 한도 밑에서 스스로 라운드를 끝내는 "정산하기" — 블랙잭의 스탠드. */ const handleEndRound = () => { if (phase !== 'approval') return; pushLog(`제${round}라운드 자율 종료(정산하기) — 주목도 ${roundAttention(deskCards, round)}/${attentionCap}`); goToShop(); }; const handleCombineConfirm = (result) => { // 족보에 실제로 쓰인 카드만 소멸한다 — 선택했지만 안 쓰인 카드는 책상에 남는다 (§4.1 이월). setDeskCards((prev) => removeFromDesk(prev, result.removeIds)); setTokens((prev) => prev + result.tokens); // §8.2 토큰 1종, 등급 합계만큼 // §7.1 명분: 족보표의 명분 열이 곧 "수량"이다(0.5~5). 종류(4종)별로 누적하고 총합 5로 제한한다. // 0.5짜리 페어는 두 번 모아야 1개가 되므로, 하위 족보만 반복하면 정치 자원이 잘 안 쌓인다. setJustifications((prev) => { const next = { ...prev }; const t = result.justification.type; next[t] = (next[t] || 0) + result.justification.amount; const total = Object.values(next).reduce((a, b) => a + b, 0); if (total > JUSTIFICATION_CAP) next[t] = Math.max(0, next[t] - (total - JUSTIFICATION_CAP)); return next; }); setScore((prev) => prev + result.performanceScore); setRoundScore((prev) => prev + result.performanceScore); pushLog(`합본 『${result.pattern.name}』 — 실적 +${result.performanceScore}, 토큰 +${result.tokens}, 명분 ${result.justification.type} +${result.justification.amount} (${result.removeIds.length}장 소멸)`); setShowCombine(false); }; /* ── 상점 (§8.3) ── */ const handleShopPurge = (cardId) => { const card = deskCards.find((c) => c.id === cardId); if (!card) return; const price = purgePrice(card, surcharge); setTokens((prev) => prev - price); setDeskCards((prev) => removeFromDesk(prev, [cardId])); setShopDisplay((prev) => prev.filter((c) => c.id !== cardId)); pushLog(`상점: 『${card.name || '오염 카드'}』(등급 ${card.grade}) 직접 소각 — 토큰 -${price}`); }; const handleShopReroll = () => { setTokens((prev) => prev - REROLL_COST); setShopDisplay(rollShopDisplay(deskCards)); setShopRerolled(true); }; const handleShopContinue = () => { setCouncilCandidates(rollProtocolCandidates(deferredCandidates)); setDeferredCandidates([]); setPhase('council'); }; /* ── 의회 (§9) ── */ const handleCouncilResolve = ({ tokensPaid, justificationsPaidCount, interventions }) => { const { candidates: remainingCandidates, voteSwingTargetId, deferred, penaltyIdToClear } = applyInterventions(councilCandidates, interventions); // 이번 라운드 내내 적용 중이던 규약이 실적 요구치를 낮췄을 수 있다 (유형 D). const finalRequired = Math.max(0, requiredScore(round) + requiredScoreDeltaOf(activeProtocols)); // §9.3: 실적 점수 = Σ(이번 라운드 족보 점수) + 토큰 납입×5 + 명분 납입×20. const finalScore = settleScore({ combineScore: roundScore, tokensPaid, justificationsPaid: justificationsPaidCount, }); const passed = finalScore >= finalRequired; setTokens((prev) => prev - tokensPaid); setJustifications({}); // §7.1: 다음 의회까지만 유효 — 쓰지 않은 것도 여기서 소멸 // 징벌 규약 갱신. setState updater 안에서 로그를 찍으면 StrictMode의 이중 호출 때문에 // 같은 줄이 두 번 남는다(updater는 순수해야 한다) — 그래서 결과를 먼저 계산해서 커밋한다. let nextPenalties = penalties; if (penaltyIdToClear) { const cleared = nextPenalties.find((p) => p.id === penaltyIdToClear); nextPenalties = nextPenalties.filter((p) => p.id !== penaltyIdToClear); if (cleared) pushLog(`위기 명분으로 징벌 규약 『${cleared.name}』 조기 해제`); } if (!passed) { const penalty = rollPenalty(); nextPenalties = [...nextPenalties, { ...penalty, id: `${penalty.id}-${Date.now().toString(36)}` }]; pushLog(`실적 감사 미달 (${finalScore}/${finalRequired}) — 징벌 규약 『${penalty.name}』 부과`); } else { pushLog(`실적 감사 통과 (${finalScore}/${finalRequired})`); if (finalScore >= finalRequired * 1.5 && nextPenalties.length > 0) { pushLog(`실적 초과 달성 — 징벌 규약 『${nextPenalties[0].name}』 해제`); nextPenalties = nextPenalties.slice(1); } } setPenalties(nextPenalties); // 절차 명분으로 이월된 후보는 다음 라운드 후보 풀에 다시 올라온다 (§7.3). setDeferredCandidates(deferred); if (deferred.length > 0) { pushLog(`절차 명분으로 『${deferred.map((d) => d.name).join(', ')}』 다음 라운드 이월`); } // §9.2 13석 표결. 혼합 명분은 1표만 움직이므로 1표 차 승부에서만 결과가 뒤집힌다 (§7.3). const tallied = applyVoteSwing(tallyVotes(remainingCandidates), voteSwingTargetId); const winner = resolveProtocolVote(tallied); setActiveProtocol(winner); 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); setPhase('approval'); }; if (gameOver) { return (

결재 불가

{gameOver}

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

); } if (!currentCard) { return
카드를 불러오는 중...
; } return (
setShowCombine(true)} onOpenGuide={() => setShowGuide(true)} gaugeExtraFlat={gaugeExtraFlat} tokenExtraFlat={tokenExtraFlat} />
제{round}라운드 실적 {score} (이번 라운드 {roundScore}) 명분 {justificationTotal}개 {activeProtocol && } {penalties.map((p) => )}
{phase === 'approval' && ( )} {phase === 'shop' && ( )} {phase === 'council' && ( )}
{log.map((line, i) =>
{line}
)}
{showCombine && ( setShowCombine(false)} activeProtocols={activeProtocols} /> )} {showGuide && setShowGuide(false)} />}
); }