자산 시스템 (docs/작업지시서/자산시스템_작업지시서.md 반영, 이전 세션 작업분 커밋): - 발라트로형 조커 레이어 엔진 빌딩 도입. assets.json 8종(트리거→효과→성장 스키마) + engine/assetEngine.js(이벤트 버스, 연쇄 깊이 상한 10)로 기존 수동 클릭형 자산 4종 전면 대체 - AssetInfoPopup 추가 — 자산 슬롯 클릭 시 트리거/효과/스택/충전 상태 표시, 발동 로그 피드 노출 오염 카드 압박 강화 (그레이박스 위기감 피드백 반영): - engine/pollution.js 신설 — 오염 주입 규칙(타입 매핑·등급 추첨·물량)을 단일 모듈로 분리해 사전 예고(SwipeCard) · 실제 주입(GameScreen) · 사후 알림이 같은 계산을 공유하도록 함 - 위험 파라미터를 올리는 결재는 정례회의를 기다리지 않고 그 자리에서 덱에 오염 카드를 주입 - UI 3종: 스와이프 시 오염 유입 사전 경고, 결재 후 유입 토스트, 상단 덱 오염 상시 게이지 - currentCardIndex를 매 결재마다 덱 길이로 정규화(주입/소각으로 덱 길이가 바뀌므로 필요) 기타: - docs/architecture_design.md를 실제 구현(plain JS/CSS, useState, engine/ 순수 함수 경계) 기준으로 전면 재작성 — 기존 문서는 채택되지 않은 초기 제안(Tailwind/Zustand/TS)이었음 - playtest.bat 추가 — 더블클릭으로 dev 서버 실행 (ASCII 전용, chcp로 인한 배치 파싱 오류 회피) - CLAUDE.md 상태 갱신 및 실기 플레이 결과 기록 실기 플레이로 정상 동작 확인. 자산 획득 경로(극비 카드 스와이프)는 플레이 중 한 번도 발생하지 않아 원인 미조사 상태로 남김 — CLAUDE.md에 후속 확인 항목으로 기록. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
629 lines
28 KiB
React
629 lines
28 KiB
React
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) => (
|
|
<div key={i} className={`dot ${i < value ? 'filled' : 'empty'}`}></div>
|
|
));
|
|
};
|
|
|
|
if (gameOver) {
|
|
return (
|
|
<EndingScreen
|
|
ending={finalEnding}
|
|
narrativeTags={acquiredTags.length}
|
|
keywords={acquiredTags.map(t => ({ word: t.name }))}
|
|
onRestart={() => window.location.reload()}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (showCouncil) {
|
|
// 글로벌 지부장 12개 파벌 중 이번 회차 선발된 5명의 우호도를 각자 다른 조건으로 개별 계산
|
|
const affinityCtx = {
|
|
tags, tokens, params,
|
|
assetsCount: ownedAssets.length,
|
|
mythicCount: cards.filter(c => c.type === '신화').length,
|
|
tagCount: acquiredTags.length, assetsAcquiredCount, purgeCount, bribeCount,
|
|
};
|
|
const globalFactions = factionRoster.map(f => {
|
|
const score = f.computeAffinity(affinityCtx);
|
|
const tier = getFactionTier(score);
|
|
return { id: f.id, name: f.name, tier, vote: TIER_VOTE[tier], immune: TIER_IMMUNE.has(tier) };
|
|
});
|
|
|
|
return (
|
|
<CouncilScreen
|
|
params={params} setParams={setParams}
|
|
tokens={tokens} setTokens={setTokens}
|
|
tags={tags} setTags={setTags}
|
|
bankruptcyCount={bankruptcyCount}
|
|
bankruptcyWarning={bankruptcyWarning}
|
|
globalFactions={globalFactions}
|
|
onBribe={() => setBribeCount(c => c + 1)}
|
|
onResolve={handleCouncilResolve}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (cards.length === 0) {
|
|
return <div className="game-screen">데이터를 불러오는 중...</div>;
|
|
}
|
|
|
|
const phaseClass = phase === 1 ? 'phase-1' : phase === 2 ? 'phase-2' : 'phase-3';
|
|
const nextTagThreshold = phase === 1 ? PHASE2_TAG_THRESHOLD : phase === 2 ? PHASE3_TAG_THRESHOLD : null;
|
|
|
|
// 덱 오염도 — 결재를 이어갈수록 덱이 얼마나 썩었는지 상시 노출해 압박을 준다.
|
|
const pollutedCount = cards.filter(c => c.contaminated).length;
|
|
const pollutionRatio = cards.length > 0 ? pollutedCount / cards.length : 0;
|
|
|
|
return (
|
|
<div className={`game-screen ${phaseClass}`}>
|
|
{/* Top Bar */}
|
|
<div className="top-bar">
|
|
<div className="narrative-badge" onClick={() => setShowChronicle(true)} style={{cursor: 'pointer'}}>
|
|
<BookOpen size={16} /> <span>{acquiredTags.length}{nextTagThreshold !== null ? `/${nextTagThreshold}` : ''}</span>
|
|
</div>
|
|
<div className="round-info">
|
|
<div>제 {roundCount}차 정례회의 상정까지: 잔여 {approvalsRemaining}건</div>
|
|
<div className={`deck-pollution ${pollutionRatio >= 0.4 ? 'critical' : ''}`}>
|
|
☣ 덱 오염 {pollutedCount}/{cards.length}
|
|
</div>
|
|
</div>
|
|
<div className="parameters">
|
|
<div className={`param ${params.resistance >= 4 ? 'critical' : ''}`} style={{'--param-color': 'var(--color-resistance-critical)'}}>
|
|
<span className="icon">✊</span>
|
|
<div className="dots">{renderDots(params.resistance)}</div>
|
|
</div>
|
|
<div className={`param ${params.entropy >= 4 ? 'critical' : ''}`} style={{'--param-color': 'var(--color-entropy-critical)'}}>
|
|
<span className="icon">🌀</span>
|
|
<div className="dots">{renderDots(params.entropy)}</div>
|
|
</div>
|
|
<div className={`param ${params.panic >= 4 ? 'critical' : ''}`} style={{'--param-color': 'var(--color-panic-critical)'}}>
|
|
<span className="icon">😱</span>
|
|
<div className="dots">{renderDots(params.panic)}</div>
|
|
</div>
|
|
<div className={`param ${params.risk >= 4 ? 'critical' : ''}`} style={{'--param-color': 'var(--color-risk-critical)'}}>
|
|
<span className="icon">⚠️</span>
|
|
<div className="dots">{renderDots(params.risk)}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card Area */}
|
|
<div className="card-area">
|
|
<SwipeCard
|
|
card={cards[currentCardIndex % cards.length]}
|
|
onSwipe={handleSwipe}
|
|
phase={phase}
|
|
/>
|
|
</div>
|
|
|
|
{/* Bottom Inventory */}
|
|
<div className="bottom-bar">
|
|
<div className="tokens-row">
|
|
<div className={`token ${tokens.budget < 0 ? 'negative' : ''}`}><HandCoins size={16} color="var(--color-token-budget)" /> <span>{tokens.budget}</span></div>
|
|
<div className={`token ${tokens.personnel < 0 ? 'negative' : ''}`}><Droplet size={16} color="var(--color-token-personnel)" /> <span>{tokens.personnel}</span></div>
|
|
<div className={`token ${tokens.info < 0 ? 'negative' : ''}`}><Eye size={16} color="var(--color-token-info)" /> <span>{tokens.info}</span></div>
|
|
{mulliganShields > 0 && <div className="token shield-badge">🛡️ <span>x{mulliganShields}</span></div>}
|
|
</div>
|
|
<div className="tags-row">
|
|
<div className="tag"><Building2 size={14} /> <span>{tags.wealth}</span></div>
|
|
<div className="tag"><Shield size={14} /> <span>{tags.force}</span></div>
|
|
<div className="tag"><EyeOff size={14} /> <span>{tags.surveillance}</span></div>
|
|
</div>
|
|
<div className="asset-slots">
|
|
{ownedAssets.length === 0 && <div className="asset-slot-empty-hint">보유 자산 없음</div>}
|
|
{ownedAssets.map((inst) => {
|
|
const def = AssetEngine.getAssetDef(inst.defId);
|
|
const meta = AssetEngine.ARCHETYPE_META[def.archetype];
|
|
const ready = def.trigger.type === 'manual_charge' && inst.charge >= 1;
|
|
return (
|
|
<button
|
|
key={inst.defId}
|
|
className={`asset-slot filled grade-${def.grade} ${ready ? 'ready' : ''}`}
|
|
onClick={() => handleAssetSlotClick(inst)}
|
|
title={def.name}
|
|
>
|
|
<span className="asset-icon">{meta.icon}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{assetLog.length > 0 && (
|
|
<div className="asset-log-feed">
|
|
{assetLog.slice(-4).map((line, i) => (
|
|
<div key={i} className="asset-log-line">{line}</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{assetPopup && (
|
|
<AssetInfoPopup
|
|
instance={ownedAssets.find(a => a.defId === assetPopup)}
|
|
def={AssetEngine.getAssetDef(assetPopup)}
|
|
onActivate={() => handleAssetActivate(assetPopup)}
|
|
onClose={() => setAssetPopup(null)}
|
|
/>
|
|
)}
|
|
|
|
{(newTagNotification || pollutionNotification) && (
|
|
<div className="notification-stack">
|
|
{newTagNotification && (
|
|
<div className="tag-notification">
|
|
<div className="tag-noti-icon">{newTagNotification.icon}</div>
|
|
<div className="tag-noti-info">
|
|
<h4>새로운 내러티브 획득!</h4>
|
|
<p>[{newTagNotification.name}]</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{pollutionNotification && (
|
|
<div className="pollution-notification" key={pollutionNotification.id}>
|
|
<div className="pollution-noti-icon">☣</div>
|
|
<div className="pollution-noti-info">
|
|
<h4>오염 카드 {pollutionNotification.count}건이 덱에 유입되었습니다</h4>
|
|
<p>{pollutionNotification.summary}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{showChronicle && (
|
|
<ChroniclePopup
|
|
chronicle={chronicle}
|
|
acquiredTags={acquiredTags}
|
|
onClose={() => setShowChronicle(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|