import React, { useState, useEffect, useRef } from 'react'; import Papa from 'papaparse'; import cardsCsvRaw from '../data/cards.csv?raw'; import narrativeTagsData from '../data/narrative_tags.json'; import { BookOpen, HandCoins, Droplet, Eye, Shield, Building2, EyeOff } from 'lucide-react'; import SwipeCard from './SwipeCard'; import CouncilScreen from './CouncilScreen'; import EndingScreen from './EndingScreen'; import ChroniclePopup from './ChroniclePopup'; import AssetInfoPopup from './AssetInfoPopup'; import * as AssetEngine from '../engine/assetEngine'; import { CONTAM_TYPES, CONTAM_TYPE_ICON, previewPollution, buildPollutionCards, buildCouncilPollutionCards, insertIntoDeck, describePollution, } from '../engine/pollution'; import { CHAIRMAN_AXES, isNarrativeChainComplete, resolveEnding, getEndingById } from '../data/endings'; import './GameScreen.css'; // 오염 카드 주입 규칙(타입 매핑 · 등급 추첨 · 물량)은 engine/pollution.js에 모여 있다. // 스와이프 예고(SwipeCard)와 실제 주입이 같은 계산을 쓰게 하기 위해 여기서 다시 정의하지 말 것. // 내러티브 태그는 narrative_tags.json 풀(10종)에서 "처음 획득하는 태그"만 진행도에 반영된다. // 문턱은 풀 크기 대비 낮게 잡는다 (기존 4/8은 실제 플레이에서 길게 느껴진다는 피드백 반영). const PHASE2_TAG_THRESHOLD = 3; const PHASE3_TAG_THRESHOLD = 6; // 글로벌 지부장 12개 파벌 풀 (게임 시작 시 5명 무작위 선발, 실리에 따라 우호도 개별 계산) const GLOBAL_FACTIONS = [ { id: 'military', name: '군산복합 연합', computeAffinity: (c) => c.tags.force * 15 + c.tokens.personnel * 0.4 - 40 }, { id: 'blackmarket', name: '블랙마켓 카르텔', computeAffinity: (c) => c.tokens.budget * 0.4 + c.assetsCount * 20 - 40 }, { id: 'databroker', name: '정보 브로커 연맹', computeAffinity: (c) => c.tokens.info * 0.4 + c.tags.surveillance * 15 - 40 }, { id: 'infra', name: '인프라 재건 위원회', computeAffinity: (c) => c.tags.wealth * 18 - 30 }, { id: 'spindoctor', name: '여론 통제국', computeAffinity: (c) => c.tags.surveillance * 18 - 30 }, { id: 'union', name: '현장 요원 노조', computeAffinity: (c) => (2 - c.params.resistance) * 30 }, { id: 'risk', name: '리스크 관리반', computeAffinity: (c) => (2 - c.params.risk) * 30 }, { id: 'occult', name: '오컬트 수집가 협회', computeAffinity: (c) => c.mythicCount * 35 + c.tagCount * 3 - 20 }, { id: 'blackops', name: '극비 프로젝트 연구소', computeAffinity: (c) => c.assetsAcquiredCount * 25 - 30 }, { id: 'scavenger', name: '잔해 청소부 길드', computeAffinity: (c) => c.purgeCount * 35 - 20 }, { id: 'entropy', name: '엔트로피 관측소', computeAffinity: (c) => (2 - c.params.entropy) * 30 }, { id: 'corrupt', name: '부패 관료 연합', computeAffinity: (c) => c.bribeCount * 40 - 10 }, ]; const getFactionTier = (score) => { if (score >= 60) return '동맹'; if (score >= 20) return '우호'; if (score > -20) return '중립'; if (score > -60) return '적대'; return '파국'; }; const TIER_VOTE = { 동맹: '찬성', 우호: '찬성', 중립: '미정', 적대: '반대', 파국: '반대' }; const TIER_IMMUNE = new Set(['동맹', '파국']); const buildCardFromRow = (row) => ({ id: row.id, type: row.type, keyword: row.keyword, question: row.question, text: row.text, flavor: row.flavor, grade: row.grade || null, contaminated: CONTAM_TYPES.has(row.type), left: { text: row.left_text, tag: row.left_tag ? narrativeTagsData.find(t => t.id === row.left_tag) : null, stats: { res: row.left_res || 0, ent: row.left_ent || 0, pan: row.left_pan || 0, rsk: row.left_rsk || 0, bud: row.left_bud || 0, per: row.left_per || 0, inf: row.left_inf || 0, wea: row.left_wea || 0, for: row.left_for || 0, sur: row.left_sur || 0, }, }, right: { text: row.right_text, tag: row.right_tag ? narrativeTagsData.find(t => t.id === row.right_tag) : null, stats: { res: row.right_res || 0, ent: row.right_ent || 0, pan: row.right_pan || 0, rsk: row.right_rsk || 0, bud: row.right_bud || 0, per: row.right_per || 0, inf: row.right_inf || 0, wea: row.right_wea || 0, for: row.right_for || 0, sur: row.right_sur || 0, }, }, }); export function GameScreen() { const [params, setParams] = useState({ resistance: 0, entropy: 0, panic: 0, risk: 0 }); const [tokens, setTokens] = useState({ budget: 50, personnel: 50, info: 50 }); const [tags, setTags] = useState({ wealth: 0, force: 0, surveillance: 0 }); const [acquiredTags, setAcquiredTags] = useState([]); const [newTagNotification, setNewTagNotification] = useState(null); // 결재 직후 "오염 카드 N건이 덱에 유입됨"을 알리는 토스트. 연속 스와이프 시 타이머를 갱신한다. const [pollutionNotification, setPollutionNotification] = useState(null); const pollutionTimerRef = useRef(null); const [phase, setPhase] = useState(1); const [gameOver, setGameOver] = useState(false); const [cards, setCards] = useState([]); const [contaminationTemplates, setContaminationTemplates] = useState([]); const [phase2Pool, setPhase2Pool] = useState([]); const [phase2Unlocked, setPhase2Unlocked] = useState(false); const [currentCardIndex, setCurrentCardIndex] = useState(0); const [turns, setTurns] = useState(0); const [showCouncil, setShowCouncil] = useState(false); const [chronicle, setChronicle] = useState([]); const [showChronicle, setShowChronicle] = useState(false); // 자산(엔진) 시스템 — 작업지시서 기준 트리거/효과 엔진 const [ownedAssets, setOwnedAssets] = useState([]); const [assetLog, setAssetLog] = useState([]); const [assetPopup, setAssetPopup] = useState(null); // 라운드 구조: 라운드가 증가할수록 정례회의까지 남은 결재 건수가 줄어든다 (시간 압박 시각화) const [roundCount, setRoundCount] = useState(1); const [approvalsRemaining, setApprovalsRemaining] = useState(10); // 파산 문책 시스템 const [bankruptcyCount, setBankruptcyCount] = useState(0); const [bankruptcyWarning, setBankruptcyWarning] = useState(false); // 자산 효과 상태 const [mulliganShields, setMulliganShields] = useState(0); const [quarantineZone, setQuarantineZone] = useState([]); const [assetsAcquiredCount, setAssetsAcquiredCount] = useState(0); const [purgeCount, setPurgeCount] = useState(0); // 글로벌 지부장 파벌 (게임 시작 시 12개 중 5개 무작위 선발) const [factionRoster, setFactionRoster] = useState([]); const [bribeCount, setBribeCount] = useState(0); // 멀티 엔딩 판별기 상태 const [finalEnding, setFinalEnding] = useState(null); const [chairmanAxis, setChairmanAxis] = useState(null); const [lastParamDelta, setLastParamDelta] = useState(null); const [contaminationSequence, setContaminationSequence] = useState([]); const [narrativeChainUnlocked, setNarrativeChainUnlocked] = useState(false); // CSV Load Effect — 페이즈1(일상/위기) / 페이즈2(작전/극비) 카드 풀 분리 useEffect(() => { const parsed = Papa.parse(cardsCsvRaw, { header: true, dynamicTyping: true, skipEmptyLines: true }); const rows = parsed.data.filter(row => row.id); const phase1Rows = rows.filter(row => row.type === '일상' || row.type === '위기'); const phase2Rows = rows.filter(row => row.type === '작전' || row.type === '극비'); const templates = rows.filter(row => CONTAM_TYPES.has(row.type)).map(buildCardFromRow); setCards(phase1Rows.map(buildCardFromRow).sort(() => Math.random() - 0.5)); setPhase2Pool(phase2Rows.map(buildCardFromRow)); setContaminationTemplates(templates); }, []); // 글로벌 지부장 5명 선발 (게임 시작 시 1회) useEffect(() => { const shuffled = [...GLOBAL_FACTIONS].sort(() => Math.random() - 0.5); setFactionRoster(shuffled.slice(0, 5)); }, []); // 의회 의장 성향 축 선정 (게임 시작 시 1회) — 의회 정치 엔딩 판별에 사용 useEffect(() => { setChairmanAxis(CHAIRMAN_AXES[Math.floor(Math.random() * CHAIRMAN_AXES.length)]); }, []); // 내러티브 태그가 PHASE2_TAG_THRESHOLD종 도달 시 페이즈2 카드 풀(작전/극비) 개방 useEffect(() => { if (phase2Unlocked || phase2Pool.length === 0 || acquiredTags.length < PHASE2_TAG_THRESHOLD) return; setPhase2Unlocked(true); setCards(prev => { const shuffledPool = [...phase2Pool].sort(() => Math.random() - 0.5); const insertAt = Math.min(prev.length, currentCardIndex + 1); const next = [...prev]; next.splice(insertAt, 0, ...shuffledPool); return next; }); setChronicle(prev => [...prev, { turn: turns, cardType: '페이징', keyword: null, text: '페이즈 2 진입 - 작전 / 극비 프로젝트 카드 풀이 개방되었습니다.', decision: '', tag: null }]); }, [acquiredTags, phase2Pool, phase2Unlocked, currentCardIndex, turns]); useEffect(() => { if (!gameOver && (params.resistance >= 5 || params.entropy >= 5 || params.panic >= 5)) { // 위험 수용 계수(risk)는 PRD상 탄핵/법안 상정의 기준치일 뿐, 직접적인 게임오버 트리거가 아니다. const ending = resolveEnding({ trigger: 'collapse', params, tags, tokens, lastDelta: lastParamDelta, narrativeChainUnlocked, }); setFinalEnding(ending); setGameOver(true); } if (acquiredTags.length >= PHASE2_TAG_THRESHOLD && acquiredTags.length < PHASE3_TAG_THRESHOLD) setPhase(2); else if (acquiredTags.length >= PHASE3_TAG_THRESHOLD) setPhase(3); }, [params, acquiredTags]); // 시한부 격리된 오염 카드 복귀 useEffect(() => { if (quarantineZone.length === 0) return; const ready = quarantineZone.filter(c => c.returnTurn <= turns); if (ready.length > 0) { setCards(prev => [...prev, ...ready.map(({ returnTurn, ...rest }) => rest)]); setQuarantineZone(prev => prev.filter(c => c.returnTurn > turns)); setChronicle(prev => [...prev, { turn: turns, cardType: '자산', keyword: null, text: `격리했던 오염 카드 ${ready.length}건이 덱으로 복귀했습니다.`, decision: '', tag: null }]); } }, [turns]); // 자산 엔진 world 스냅샷 생성/반영 — 현재 state를 복사해 엔진에 넘기고, 반환된 결과를 다시 state로 반영한다. // 호출 지점 하나당 반드시 buildAssetWorld/commitAssetWorld를 한 번씩만 쓴다: 같은 이벤트 처리 안에서 // 여러 번 짝지어 부르면 나중 커밋이 아직 리렌더되지 않은(오래된) 클로저 state로 앞의 변경을 덮어써 버린다. const buildAssetWorld = () => ({ cards: [...cards], tokens: { ...tokens }, tags: { ...tags }, params: { ...params }, owned: ownedAssets.map(o => ({ ...o })), quarantine: quarantineZone.map(q => ({ ...q })), logs: [], purgeGain: 0, }); const commitAssetWorld = (world) => { setCards(world.cards); setTokens(world.tokens); setTags(world.tags); setParams(world.params); setOwnedAssets(world.owned); setQuarantineZone(world.quarantine); if (world.purgeGain) setPurgeCount(c => c + world.purgeGain); if (world.logs.length) setAssetLog(prev => [...prev, ...world.logs].slice(-8)); }; // 오염 유입 토스트 — 빠르게 연속 스와이프해도 마지막 알림 기준으로 표시 시간이 다시 채워진다. const notifyPollution = (count, entries) => { if (pollutionTimerRef.current) clearTimeout(pollutionTimerRef.current); setPollutionNotification({ id: Date.now(), count, summary: describePollution(entries) }); pollutionTimerRef.current = setTimeout(() => setPollutionNotification(null), 2600); }; useEffect(() => () => clearTimeout(pollutionTimerRef.current), []); const handleSwipe = (direction) => { if (cards.length === 0) return; const cardIdx = currentCardIndex % cards.length; const card = cards[cardIdx]; const side = direction === 'left' ? card.left : direction === 'right' ? card.right : null; const world = buildAssetWorld(); let outcomeText = ''; let isNewTag = false; let gainedTag = null; // 자산 엔진: 좌 스와이프 격리 도박(임시 격리 컨테이너) — 성공하면 이 카드는 정상 결과 대신 격리된다. let quarantinedByAsset = false; if (direction === 'left' && card.contaminated) { AssetEngine.onSwipeLeft(world, cardIdx, turns); quarantinedByAsset = world.quarantinedCard; } // 자산 엔진: 오염 카드 드로우 트리거(신화 환전/조직저항 자동 무효화 등) let negateFree = false; if (!quarantinedByAsset && card.contaminated) { AssetEngine.onPollutionDraw(world, card.type); negateFree = world.negateFree; } if (quarantinedByAsset) { setChronicle(prev => [...prev, { turn: turns + 1, cardType: card.type, keyword: card.keyword, text: card.text, decision: '임시 격리 컨테이너로 격리 (정례회의 전까지 미회수 시 실패)', tag: null, }]); } else if (side) { const shouldNullify = card.contaminated && (negateFree || mulliganShields > 0); const s = side.stats; world.params.resistance = Math.max(0, world.params.resistance + (shouldNullify ? 0 : s.res)); world.params.entropy = Math.max(0, world.params.entropy + (shouldNullify ? 0 : s.ent)); world.params.panic = Math.max(0, world.params.panic + (shouldNullify ? 0 : s.pan)); world.params.risk = Math.max(0, world.params.risk + (shouldNullify ? 0 : s.rsk)); world.tokens.budget += s.bud; world.tokens.personnel += s.per; world.tokens.info += s.inf; world.tags.wealth += s.wea; world.tags.force += s.for; world.tags.surveillance += s.sur; outcomeText = side.text; // 파산 문책: 결재 결과 토큰이 마이너스가 되면 파산 카운터 증가 if (world.tokens.budget < 0 || world.tokens.personnel < 0 || world.tokens.info < 0) { setBankruptcyCount(c => c + 1); } // 극단적 파라미터 조기 엔딩 판별용: 이번 결재에서 가장 크게 증가한 위험 파라미터 추적 if (!shouldNullify) { const dangerDeltas = { resistance: s.res, entropy: s.ent, panic: s.pan }; let maxParam = null, maxVal = 0; Object.entries(dangerDeltas).forEach(([p, v]) => { if (v > maxVal) { maxVal = v; maxParam = p; } }); if (maxParam) setLastParamDelta({ param: maxParam, amount: maxVal }); } // 내러티브 태그: 재사용 가능한 태그 풀에서 처음 획득하는 태그일 때만 진행도에 반영 gainedTag = side.tag; if (gainedTag && !acquiredTags.find(t => t.id === gainedTag.id)) { isNewTag = true; setAcquiredTags(prev => [...prev, gainedTag]); setNewTagNotification(gainedTag); setTimeout(() => setNewTagNotification(null), 3000); } // 내러티브 연쇄 엔딩: 오염 카드 4종이 정해진 순서로 등장했는지 추적 if (card.contaminated) { const nextSequence = [...contaminationSequence, card.type]; setContaminationSequence(nextSequence); if (!narrativeChainUnlocked && isNarrativeChainComplete(nextSequence)) { setNarrativeChainUnlocked(true); } } if (shouldNullify) { if (negateFree) { outcomeText += ' (책임 소재 불명 처리로 자동 무효화)'; } else { setMulliganShields(s2 => s2 - 1); outcomeText += ' (방어막으로 페널티 무효화)'; } } // 위험 파라미터를 올린 결재는 그 자리에서 덱에 오염 카드를 주입한다 (정례회의까지 기다리지 않는다). // 페널티가 무효화된 경우 파라미터도 오르지 않았으므로 오염도 유입되지 않는다 — 방어막의 값어치. if (!shouldNullify) { const { total, entries } = previewPollution(s); if (total > 0) { const injected = buildPollutionCards(entries, world.params, contaminationTemplates); if (injected.length > 0) { world.cards = insertIntoDeck(world.cards, cardIdx, injected); outcomeText += ` → 오염 카드 ${injected.length}건 유입`; notifyPollution(injected.length, entries); } } } } else if (direction === 'up') { outcomeText = '보류 / 버리기'; } if (!quarantinedByAsset && outcomeText) { setChronicle(prev => [...prev, { turn: turns + 1, cardType: card.type, keyword: card.keyword, text: card.text, decision: outcomeText, tag: isNewTag ? gainedTag : null, }]); } // 극비 프로젝트 성공 시 자산 획득 (아직 보유하지 않은 자산 중 무작위 1종) if (!quarantinedByAsset && card.type === '극비' && direction !== 'up') { const ownedIds = new Set(world.owned.map(a => a.defId)); const pool = AssetEngine.ASSET_CATALOG.filter(a => !ownedIds.has(a.id)); if (pool.length > 0) { const def = pool[Math.floor(Math.random() * pool.length)]; world.owned.push(AssetEngine.createAssetInstance(def.id, turns + 1)); setAssetsAcquiredCount(c => c + 1); world.logs.push(`『${def.name}』(${def.grade}급) 획득`); } } // 자산 엔진: 결재 N건마다 발동(문서 파쇄실) + 충전식 자산 충전 확인 const nextTurns = turns + 1; AssetEngine.onApprovalTick(world, nextTurns); commitAssetWorld(world); // 덱은 인덱스를 순환하는 풀이다. 주입/소각으로 길이가 바뀌므로 다음 인덱스는 항상 최종 덱 길이로 // 정규화해 범위 안에 둔다 — 그래야 "현재 위치 기준 2~4장 뒤 삽입"이 의도대로 동작한다. setCurrentCardIndex(world.cards.length > 0 ? (cardIdx + 1) % world.cards.length : 0); setTurns(nextTurns); const newRemaining = approvalsRemaining - 1; setApprovalsRemaining(newRemaining); if (newRemaining <= 0) { setShowCouncil(true); } }; // 그림자 의회 종료 후 파라미터 단계에 비례한 등급/물량으로 오염 카드 주입 const injectContaminationCards = () => { const newCards = buildCouncilPollutionCards(params, contaminationTemplates); if (newCards.length === 0) return; setCards(prev => insertIntoDeck(prev, currentCardIndex, newCards)); setChronicle(prev => [...prev, { turn: turns, cardType: '오염', keyword: null, text: `정례회의 종료 후 신규 오염 카드 ${newCards.length}건이 덱에 스며들었습니다.`, decision: '', tag: null }]); const byType = newCards.reduce((acc, c) => ({ ...acc, [c.type]: (acc[c.type] || 0) + 1 }), {}); notifyPollution( newCards.length, Object.entries(byType).map(([type, count]) => ({ icon: CONTAM_TYPE_ICON[type], type, count })), ); }; const handleCouncilResolve = (result) => { setShowCouncil(false); // 자산 엔진: 의회 종료 시 상시 자산 리스크 정산 + 격리 실패 판정(심연 보관 조례 개입 포함) const councilWorld = buildAssetWorld(); AssetEngine.onCouncilEnd(councilWorld); commitAssetWorld(councilWorld); if (result.bankrupted) { setFinalEnding(getEndingById('bankruptcy')); setGameOver(true); return; } if (result.impeachment) { const ending = resolveEnding({ trigger: 'impeachment', tags, chairmanAxis, narrativeChainUnlocked }); setFinalEnding(ending); setGameOver(true); return; } if (typeof result.bankruptcyWarning === 'boolean') { setBankruptcyWarning(result.bankruptcyWarning); } injectContaminationCards(); // 라운드 진행: 라운드가 증가할수록 다음 정례회의까지의 결재 건수가 줄어든다 const nextRound = roundCount + 1; let nextApprovals; if (roundCount === 1) nextApprovals = 8; else if (roundCount === 2) nextApprovals = 6; else if (roundCount === 3) nextApprovals = 4; else if (roundCount === 4) nextApprovals = 2; else nextApprovals = 0; setRoundCount(nextRound); setApprovalsRemaining(nextApprovals); if (nextApprovals <= 0) { setFinalEnding(getEndingById('term_expired')); setGameOver(true); } }; // 자산 슬롯 클릭 — 항상 정보 팝업을 연다 (Part C UX 블로커 해결). defId만 저장해 최신 스택/충전 값을 반영한다. const handleAssetSlotClick = (inst) => setAssetPopup(inst.defId); // 충전식 자산(manual_charge) 수동 발동 — 팝업 내부의 '발동' 버튼에서 호출 const handleAssetActivate = (defId) => { const world = buildAssetWorld(); AssetEngine.activateManualAsset(world, defId, turns); commitAssetWorld(world); if (world.negateChargeGranted) setMulliganShields(s => s + 1); setAssetPopup(null); }; const renderDots = (value, max = 5) => { return Array.from({ length: max }).map((_, i) => (
)); }; if (gameOver) { return ([{newTagNotification.name}]
{pollutionNotification.summary}