같은 날 두 차례 플레이테스트에서 나온 이슈를 이어서 처리했다.
**의회 세부화**
- 유형 D(이득 규약) 5종 신설 — 토큰 지급/게이지 완화/지출 가산 완화/
주목도 한도 증가/실적 요구치 하향. 내부 명분("강제 상정")이 여태 불리한
유형 A만 올릴 수 있어 실속 없던 문제를 해결 — 상정 풀에 D가 포함되는 게
내부 명분이 존재하는 이유
- 13석 표결을 실제 표 수로 구현(tallyVotes, 최대잔여법 + 결정적 해시).
혼합 명분이 "직접 통과 지정" 자리표시자에서 §7.3 스펙대로 실제 1표
이동으로 교체 — 동점은 스윙 전 득표가 많던 쪽이 이겨 1표 차에서만 뒤집힘.
의회 화면에 후보별 예상 득표·표 차·뒤집기 가능 여부를 미리 노출
- 공보 축 오염 선택지 1→7개로 보강(서사 기준 6개 재배정), 축 분포 균형화
**툴팁 신설 (InfoTooltip·ProtocolChip)**
- 규약·징벌·명분을 클릭하면 정확히 무엇이 막혔는지 설명이 뜬다. 문구는
UI가 추측하지 않고 council.js의 protocolEffectDetail·justificationDetail이
카탈로그에서 생성. 모바일 대상이라 hover가 아닌 클릭으로 연다
**크리티컬 버그: 주목도 미초기화**
- 라운드 종료 후 한도 판정에 총 주목도(이월 포함)를 쓰고 있어, 2라운드가
시작하자마자 이미 한도를 넘어 버스트가 무한 반복되고 있었다. 한도 판정은
"이번 라운드에 받은 카드"만 세도록 분리(roundAttention), 게이지·토큰
가산은 기존대로 총계(이월 포함) 기준 유지
**밸런스 피드백 5건**
- 결재 카드가 고정으로 보인다는 지적 → 실제 버그였다. cardIndex를 그냥
+1씩 돌려 CSV 순서 그대로 결정론적으로 반복되고 있었다. Fisher–Yates
셔플 큐로 교체(큐 소진 시 재셔플)
- 위협 축도 거의 안 나온다 → 위 셔플 버그가 원인의 상당 부분. 명백한
오배정 1건도 발견해 수정(id11 "군사 개입 지시": 고대 신 소환 저지가
인사 축으로 잘못 들어가 있었음 → 위협 축)
- 게이지 상승이 너무 낮음 → 등급 합계(주목도) 단독 기준이 실제 카드
등급 분포(대부분 1~4급)와 맞지 않아 3~4장을 쌓아도 최하 구간을 못
벗어났다. 카드 장수를 1차 기준으로 되돌리고 등급 배율(평균등급/5)을
곱하는 방식으로 재설계 — 3~4장 구간에서 6배 점프(0.5%→3.0%)
- 좌우 모두 오염 없는 완전 무행동 카드 1건(id15) 수정 — 좌측에 대외/6급
부여(기존 tag_corruption과 정합)
- 오염 카드 한쪽만 있어 안전 선택 메리트가 없다는 지적은 카드 데이터가
아니라 게이지 곡선 문제로 판단, 위 재설계로 해결(카드 재배분 안 함)
**문서**
- 결정 노트 2건: 2026-09-17-주목도-라운드-분리,
2026-09-17-게이지상승-장수기준-복원(기각한 대안 포함)
- 기획서 §4.2-A/B, §7.3, §9.2, §17 갱신. DEVLOG 2건 기록
Playwright로 전 구간 검증: 셔플(3회 재접속 시 다른 카드, 15연속 무반복),
게이지 상승률 표시, 13석 표결 예상치, 규약/명분 툴팁, 2라운드 진입 시
주목도 0으로 리셋 확인. 콘솔 에러 0건, 빌드 클린.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
384 lines
16 KiB
React
384 lines
16 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 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 (
|
|
<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}
|
|
tokens={tokens}
|
|
round={round}
|
|
attentionCap={attentionCap}
|
|
onOpenCombine={() => setShowCombine(true)}
|
|
onOpenGuide={() => setShowGuide(true)}
|
|
gaugeExtraFlat={gaugeExtraFlat}
|
|
tokenExtraFlat={tokenExtraFlat}
|
|
/>
|
|
|
|
<div className="core-status-row">
|
|
<span>제{round}라운드</span>
|
|
<span>실적 {score} (이번 라운드 {roundScore})</span>
|
|
<span>명분 {justificationTotal}개</span>
|
|
{activeProtocol && <ProtocolChip protocol={activeProtocol} prefix="⚖ " />}
|
|
{penalties.map((p) => <ProtocolChip key={p.id} protocol={p} prefix="⚠ " />)}
|
|
</div>
|
|
|
|
{phase === 'approval' && (
|
|
<CoreSwipeCard
|
|
card={currentCard}
|
|
onSwipe={handleSwipe}
|
|
onEndRound={handleEndRound}
|
|
roundAttention={roundAttention(deskCards, round)}
|
|
attentionCap={attentionCap}
|
|
/>
|
|
)}
|
|
|
|
{phase === 'shop' && (
|
|
<ShopScreen
|
|
display={shopDisplay}
|
|
tokens={tokens}
|
|
deskSurcharge={surcharge}
|
|
onPurge={handleShopPurge}
|
|
onReroll={handleShopReroll}
|
|
rerolled={shopRerolled}
|
|
onContinue={handleShopContinue}
|
|
/>
|
|
)}
|
|
|
|
{phase === 'council' && (
|
|
<CouncilScreen
|
|
round={round}
|
|
combineScore={roundScore}
|
|
tokens={tokens}
|
|
justifications={justifications}
|
|
penalties={penalties}
|
|
candidates={councilCandidates}
|
|
requiredDelta={requiredScoreDeltaOf(activeProtocols)}
|
|
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>
|
|
);
|
|
}
|