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 ShopScreen from './ShopScreen'; import CouncilScreen from './CouncilScreen'; import { pickPollutionEntry } from '../engine/pollutionPool'; 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'; // 코어 루프 그레이박스 — §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, 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 }; 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(() => { const parsed = Papa.parse(cardsCsvRaw, { header: true, skipEmptyLines: true }); return parsed.data.map(buildCard); }, []); const [cardIndex, setCardIndex] = useState(0); 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 [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 || phase !== 'approval') return; const side = direction === 'right' ? currentCard.right : currentCard.left; const nextGauges = applyPassiveGaugeRise(gauges, deskCards, gaugeExtraFlat); setGauges(nextGauges); if (side.axis) { const grade = side.grade || 1; const entry = pickPollutionEntry(side.axis, grade); const newCard = makeDeskCard(side.axis, grade, entry || {}); setDeskCards((prev) => addToDesk(prev, [newCard])); pushLog(`『${side.text}』 결재됨 → 오염 서류 『${entry?.name || '미상'}』 접수`); } else { pushLog(`『${side.text}』 결재됨 — 오염 없음`); } 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) => { setDeskCards((prev) => removeFromDesk(prev, result.removeIds)); setTokens((prev) => { const next = { ...prev }; Object.entries(result.tokens).forEach(([k, v]) => { next[k] = (next[k] || 0) + v; }); return next; }); 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차 범위라 아직 없다 — 여기서 그레이박스 루프가 멈춘다.