라운드 구조(결재 8건 → 상점 → 의회 → 다음 라운드)를 얹고, 신규 파일들로 구현했습니다. Playwright로 라운드 전체를 실제로 돌려 확인 완료(콘솔 에러 0건).
만든 것
엔진 (core/engine/)
council.js — §7 명분 산출/개입, §9.2 규약(유형A 5종 카탈로그 + 유형C 징벌 2종), §9.3 실적 요구치 공식
shop.js — §8.3 상점 진열/가격/리롤
UI
ShopScreen — 오염 카드 직접 소각(토큰), 진열 4~6개 무작위, 리롤 1회
CouncilScreen — 실적 감사(토큰·명분 납입으로 요구치 충족) + 규약 후보 3개 + 명분 개입(절차/여론/위기/내부/혼합)
DeskBar/CombinePanel — 징벌 규약의 게이지·토큰 가산 반영, 활성 규약이 막은 족보 표시
라운드 루프: CoreGameScreen이 결재 8건마다 자동으로 상점→의회로 전환하고, 의회 결의 시 실적 판정→징벌 부과/해제→규약 표결→다음 라운드로 넘어갑니다. 4대 게이지 100% 도달 시 게임오버 화면도 추가했습니다(엔딩 분기는 4차라 텍스트만).
검증 (Playwright)
8회 스와이프 → 상점 화면 전환 → 의회 화면(요구실적 25, 후보 3개) → 결의 확정 → 2라운드 진입, 상단에 ⚖ 통과된 규약명 표시 → 합본 패널 열면 해당 규약이 칩으로 뜨고 filterHandsByProtocols가 막는 족보를 실제로 걸러냄. 스크린샷 다수 확인, 콘솔 에러 0건. 상점 아이템 버튼 텍스트가 화면 밖으로 살짝 잘리는 버그도 발견해서 즉시 수정했습니다.
의도적으로 축소한 부분 (문서에 근거 있음)
상점은 "오염 카드 직접 처리" 한 품목뿐 — 자산 구매(§10)·소모품(§12)·전례 매입(§11)은 전부 3차 항목이라 카탈로그 자체가 없습니다. §17 로드맵 순서를 지키기 위한 의도적 축소입니다.
규약 표결은 가중 무작위 — 13인 위원회의 실제 우호도·이중첩자·탄핵(§9.4~9.6)은 4차 범위. 지금은 후보 3개 중 하나를 무작위로 "통과"시킵니다.
혼합 명분("표 뒤집기")은 문자 그대로 구현할 표가 없어서, "후보 하나를 직접 통과 지정"으로 대체했습니다 — 최종 효과(원하는 규약 통과)는 같지만 4차에서 실제 위원회가 생기면 반드시 재설계해야 할 자리표시자로 코드에 표시해 뒀습니다.
§7.3 이념색 페널티(파벌 우호도 하락)는 스킵 — 8대 상임이사회 우호도 추적 자체가 4차 항목이라 지금은 걸 게 없습니다.
라운드당 결재 수 8건, 상점/징벌 가격 수치는 전부 가안 — 플레이해보고 조정 대상입니다.
295 lines
12 KiB
React
295 lines
12 KiB
React
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 (
|
|
<div className="core-game-screen">
|
|
<div className="core-gameover">
|
|
<h1>결재 불가</h1>
|
|
<p>{gameOver}</p>
|
|
<p className="core-gameover-note">
|
|
엔딩 분기(§15)는 4차 범위라 아직 없다 — 여기서 그레이박스 루프가 멈춘다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!currentCard) {
|
|
return <div className="core-loading">카드를 불러오는 중...</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="core-game-screen">
|
|
<DeskBar
|
|
deskCards={deskCards}
|
|
gauges={gauges}
|
|
onOpenCombine={() => setShowCombine(true)}
|
|
onOpenGuide={() => setShowGuide(true)}
|
|
gaugeExtraFlat={gaugeExtraFlat}
|
|
tokenExtraFlat={tokenExtraFlat}
|
|
/>
|
|
|
|
<div className="core-status-row">
|
|
<span>{round}R · 결재 {approvalsThisRound}/{APPROVALS_PER_ROUND}</span>
|
|
<span>실적 {score} (이번 라운드 {roundScore})</span>
|
|
<span>예산 {tokens.budget} · 인력 {tokens.personnel} · 정보 {tokens.info}</span>
|
|
<span>명분 {justifications.length}개 보유</span>
|
|
{activeProtocol && <span className="core-status-protocol">⚖ {activeProtocol.name}</span>}
|
|
</div>
|
|
|
|
{phase === 'approval' && <CoreSwipeCard card={currentCard} onSwipe={handleSwipe} />}
|
|
|
|
{phase === 'shop' && (
|
|
<ShopScreen
|
|
display={shopDisplay}
|
|
tokens={tokens}
|
|
deskSurcharge={tokenSurcharge(deskCards, tokenExtraFlat)}
|
|
onPurge={handleShopPurge}
|
|
onReroll={handleShopReroll}
|
|
rerolled={shopRerolled}
|
|
onContinue={handleShopContinue}
|
|
/>
|
|
)}
|
|
|
|
{phase === 'council' && (
|
|
<CouncilScreen
|
|
round={round}
|
|
combineScore={roundScore}
|
|
tokens={tokens}
|
|
justifications={justifications}
|
|
penalties={penalties}
|
|
candidates={councilCandidates}
|
|
onResolve={handleCouncilResolve}
|
|
/>
|
|
)}
|
|
|
|
<div className="core-log">
|
|
{log.map((line, i) => <div key={i} className="core-log-line">{line}</div>)}
|
|
</div>
|
|
|
|
{showCombine && (
|
|
<CombinePanel
|
|
deskCards={deskCards}
|
|
onConfirm={handleCombineConfirm}
|
|
onClose={() => setShowCombine(false)}
|
|
activeProtocols={activeProtocols}
|
|
/>
|
|
)}
|
|
|
|
{showGuide && <HandGuidePopup onClose={() => setShowGuide(false)} />}
|
|
</div>
|
|
);
|
|
}
|