등급 1~10 확장 + 라운드 주목도 하드 캡 재구현, 문서 체계 정비
서울 PC에서 Remote-SSH로 작업하던 중 연결이 끊겨 커밋 전 상태로 유실된 2026-09-15 결정사항(등급 1~4→1~10 확장, 5장 포커 핸드, 주목도 기반 페널티, 토큰 1종 통합, 상점/규약 재작성)을 평택 로컬 체크아웃에서 전체 재구현했다. 그 과정에서 라운드/회기 구조를 오해해서 잘못 반영했던 부분을 다시 고쳤다. **데이터** - cards.csv 43개 선택지, pollution_pool.csv 재매핑 — 등급 1·3 공백을 없애고 1~10 전 구간을 채움 (평균 등급 4.14, 8결재 기준 예상 주목도 33) - pollution_pool.csv에 등급 1·3 전용 오염 카드 8개 신규 추가 (축별 2개) **게임 루프** - 라운드/회기 이층 구조 도입 → 원래 의도(라운드마다 정산·의회를 거치는 것)와 어긋난 오해로 판명, 단일 라운드 구조로 환원 - 라운드 종료 조건을 "결재 8건 고정"에서 블랙잭식 하드 캡으로 교체 — 주목도가 라운드 한도(21 + 라운드당 +5)를 넘으면 합본 기회 없이 즉시 강제 종료(버스트), 한도 밑에서는 "정산하기"로 언제든 자율 종료 가능 - CouncilScreen: 징벌 규약 5개 초과 시 목록 접기/펼치기 (§9.2 UI 요건) **문서 체계** - CLAUDE.md: 흩어진 기획 문서 7종 참조를 코어시스템 기획서 단일 참조(§ 매핑 포함)로 교체, Phase 진행 표 폐지 후 §17 체크박스를 진행 상황 원본으로 지정 - CLAUDE.md에 "결정 노트" 규칙 신설 — T-decision.md 양식 + DEVLOG 위키링크 연동 - docs/DEVLOG.md 신규 작성 (구 버전은 old/2026.09.04/ 보관) - 결정 노트 2건 작성: 등급확장-주목도시스템, 라운드-주목도-하드캡-복원 - ProjectSS_코어시스템_기획서.md §2/§4.2-B/§17/§19/§20을 현재 결정에 맞게 갱신 Playwright로 등급 1·3 카드 실등장, 라운드 번호 증가, 주목도 초과 시 버스트→ 상점 강제 전환까지 실제 브라우저에서 확인. 콘솔 에러 0건. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
cde44c4f2c
commit
0a065068c1
@@ -8,21 +8,31 @@ 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 {
|
||||
makeDeskCard, applyPassiveGaugeRise, addToDesk, removeFromDesk, tokenSurcharge,
|
||||
totalAttention, roundAttentionCap,
|
||||
} from '../engine/desk';
|
||||
import { rollShopDisplay, purgePrice, 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).
|
||||
// 코어 루프 그레이박스 — §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차)은
|
||||
// 아직 없다 — 상점은 "오염 카드 직접 처리"만, 규약 표결은 가중 무작위로 흉내만 낸다.
|
||||
// 기존 GameScreen.jsx/engine/pollution.js는 참조용으로만 남기고 건드리지 않는다.
|
||||
// 아직 없다 — 상점은 "오염 카드 직접 처리"만, 규약 표결은 무작위로 흉내만 낸다.
|
||||
|
||||
const APPROVALS_PER_ROUND = 8;
|
||||
const INITIAL_TOKENS = 5; // §8.4 초기 지급 — 토큰 0으로 아무것도 못 하는 데드락 방지
|
||||
const JUSTIFICATION_CAP = 5; // §7.1 보관 최대 5개
|
||||
|
||||
const buildCard = (row) => ({
|
||||
id: row.id,
|
||||
@@ -46,9 +56,6 @@ const buildCard = (row) => ({
|
||||
});
|
||||
|
||||
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(() => {
|
||||
@@ -61,7 +68,7 @@ export default function CoreGameScreen() {
|
||||
const [gauges, setGauges] = useState(INITIAL_GAUGES);
|
||||
const [tokens, setTokens] = useState(INITIAL_TOKENS);
|
||||
const [score, setScore] = useState(0);
|
||||
const [justifications, setJustifications] = useState([]);
|
||||
const [justifications, setJustifications] = useState({}); // { 명분종류: 수량 }
|
||||
const [showCombine, setShowCombine] = useState(false);
|
||||
const [showGuide, setShowGuide] = useState(false);
|
||||
const [log, setLog] = useState([]);
|
||||
@@ -69,10 +76,10 @@ export default function CoreGameScreen() {
|
||||
// 라운드 구조 (§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 [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([]);
|
||||
@@ -83,6 +90,9 @@ export default function CoreGameScreen() {
|
||||
const activeProtocols = activeProtocol ? [activeProtocol] : [];
|
||||
const gaugeExtraFlat = penaltyGaugeSurcharge(penalties);
|
||||
const tokenExtraFlat = penaltyTokenSurcharge(penalties);
|
||||
const surcharge = tokenSurcharge(deskCards, tokenExtraFlat);
|
||||
const justificationTotal = Object.values(justifications).reduce((a, b) => a + b, 0);
|
||||
const attentionCap = roundAttentionCap(round);
|
||||
|
||||
const currentCard = deck.length > 0 ? deck[cardIndex % deck.length] : null;
|
||||
|
||||
@@ -90,13 +100,24 @@ export default function CoreGameScreen() {
|
||||
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];
|
||||
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;
|
||||
@@ -104,12 +125,16 @@ export default function CoreGameScreen() {
|
||||
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 || {});
|
||||
setDeskCards((prev) => addToDesk(prev, [newCard]));
|
||||
pushLog(`『${side.text}』 결재됨 → 오염 서류 『${entry?.name || '미상'}』 접수`);
|
||||
nextDesk = addToDesk(deskCards, [newCard]);
|
||||
setDeskCards(nextDesk);
|
||||
pushLog(`『${side.text}』 결재됨 → 오염 서류 『${entry?.name || '미상'}』(등급 ${grade}) 접수`);
|
||||
} else {
|
||||
pushLog(`『${side.text}』 결재됨 — 오염 없음`);
|
||||
}
|
||||
@@ -118,32 +143,38 @@ export default function CoreGameScreen() {
|
||||
|
||||
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);
|
||||
// §4.2-B: 이번 라운드 한도를 넘기면 합본 기회 없이 그 즉시 강제 종료된다. 카드는 이월(§4.1).
|
||||
if (totalAttention(nextDesk) > attentionCap) {
|
||||
pushLog(`주목도 초과(한도 ${attentionCap}) — 라운드 강제 종료, 합본 기회 없이 정산으로 이동`);
|
||||
goToShop();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
/** 플레이어가 한도 밑에서 스스로 라운드를 끝내는 "정산하기" — 블랙잭의 스탠드. */
|
||||
const handleEndRound = () => {
|
||||
if (phase !== 'approval') return;
|
||||
pushLog(`제${round}라운드 자율 종료(정산하기) — 주목도 ${totalAttention(deskCards)}/${attentionCap}`);
|
||||
goToShop();
|
||||
};
|
||||
|
||||
const handleCombineConfirm = (result) => {
|
||||
// 족보에 실제로 쓰인 카드만 소멸한다 — 선택했지만 안 쓰인 카드는 책상에 남는다 (§4.1 이월).
|
||||
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;
|
||||
});
|
||||
setTokens((prev) => prev + result.tokens); // §8.2 토큰 1종, 등급 합계만큼
|
||||
// §7.1 명분: 족보표의 명분 열이 곧 "수량"이다(0.5~5). 종류(4종)별로 누적하고 총합 5로 제한한다.
|
||||
// 0.5짜리 페어는 두 번 모아야 1개가 되므로, 하위 족보만 반복하면 정치 자원이 잘 안 쌓인다.
|
||||
setJustifications((prev) => {
|
||||
const next = [...prev, { id: makeJustificationId(), ...result.justification }];
|
||||
return next.length > 5 ? next.slice(next.length - 5) : next; // §7.1 최대 5개 보관
|
||||
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.justification.type}) +${result.justification.amount}`);
|
||||
pushLog(`합본 『${result.pattern.name}』 — 실적 +${result.performanceScore}, 토큰 +${result.tokens}, 명분 ${result.justification.type} +${result.justification.amount} (${result.removeIds.length}장 소멸)`);
|
||||
setShowCombine(false);
|
||||
};
|
||||
|
||||
@@ -151,61 +182,71 @@ export default function CoreGameScreen() {
|
||||
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));
|
||||
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 || '오염 카드'}』 직접 소각 (예산 -${price.budget})`);
|
||||
pushLog(`상점: 『${card.name || '오염 카드'}』(등급 ${card.grade}) 직접 소각 — 토큰 -${price}`);
|
||||
};
|
||||
const handleShopReroll = () => {
|
||||
setTokens((prev) => payTokens(prev, REROLL_COST));
|
||||
setTokens((prev) => prev - REROLL_COST);
|
||||
setShopDisplay(rollShopDisplay(deskCards));
|
||||
setShopRerolled(true);
|
||||
};
|
||||
const handleShopContinue = () => {
|
||||
setCouncilCandidates(rollProtocolCandidates());
|
||||
setCouncilCandidates(rollProtocolCandidates(deferredCandidates));
|
||||
setDeferredCandidates([]);
|
||||
setPhase('council');
|
||||
};
|
||||
|
||||
/* ── 의회 (§9) ── */
|
||||
const handleCouncilResolve = ({ tokensPaid, justificationsPaidCount, interventions }) => {
|
||||
const { candidates: remainingCandidates, forcedWinnerId, deferVote, requiredScoreMultiplier, penaltyIdToClear } =
|
||||
const { candidates: remainingCandidates, forcedWinnerId, deferred, 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 finalRequired = requiredScore(round);
|
||||
// §9.3: 실적 점수 = Σ(이번 라운드 족보 점수) + 토큰 납입×5 + 명분 납입×20.
|
||||
const finalScore = settleScore({
|
||||
combineScore: roundScore, tokensPaid, justificationsPaid: justificationsPaidCount,
|
||||
});
|
||||
const passed = finalScore >= finalRequired;
|
||||
|
||||
setTokens((prev) => payTokens(prev, tokensPaid));
|
||||
setJustifications([]); // §7.1: 다음 의회까지만 유효, 이후 소멸 — 쓰지 않은 것도 여기서 사라진다
|
||||
setTokens((prev) => 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}』 — 다음 라운드 적용`);
|
||||
// 징벌 규약 갱신. 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(', ')}』 다음 라운드 이월`);
|
||||
}
|
||||
|
||||
const winner = forcedWinnerId
|
||||
? remainingCandidates.find((c) => c.id === forcedWinnerId) || null
|
||||
: resolveProtocolVote(remainingCandidates);
|
||||
setActiveProtocol(winner);
|
||||
if (winner) pushLog(`규약 통과: 『${winner.name}』 — 다음 라운드 적용`);
|
||||
else pushLog('통과된 규약 없음 — 다음 라운드는 제약 없이 진행됩니다.');
|
||||
|
||||
setRound((r) => r + 1);
|
||||
setRoundScore(0);
|
||||
@@ -235,17 +276,21 @@ export default function CoreGameScreen() {
|
||||
<DeskBar
|
||||
deskCards={deskCards}
|
||||
gauges={gauges}
|
||||
tokens={tokens}
|
||||
attentionCap={attentionCap}
|
||||
onOpenCombine={() => setShowCombine(true)}
|
||||
onOpenGuide={() => setShowGuide(true)}
|
||||
onEndRound={handleEndRound}
|
||||
canEndRound={phase === 'approval'}
|
||||
gaugeExtraFlat={gaugeExtraFlat}
|
||||
tokenExtraFlat={tokenExtraFlat}
|
||||
/>
|
||||
|
||||
<div className="core-status-row">
|
||||
<span>{round}R · 결재 {approvalsThisRound}/{APPROVALS_PER_ROUND}</span>
|
||||
<span>제{round}라운드</span>
|
||||
<span>실적 {score} (이번 라운드 {roundScore})</span>
|
||||
<span>예산 {tokens.budget} · 인력 {tokens.personnel} · 정보 {tokens.info}</span>
|
||||
<span>명분 {justifications.length}개 보유</span>
|
||||
<span>명분 {justificationTotal}개</span>
|
||||
{penalties.length > 0 && <span className="core-status-penalty">⚠ 징벌 {penalties.length}</span>}
|
||||
{activeProtocol && <span className="core-status-protocol">⚖ {activeProtocol.name}</span>}
|
||||
</div>
|
||||
|
||||
@@ -255,7 +300,7 @@ export default function CoreGameScreen() {
|
||||
<ShopScreen
|
||||
display={shopDisplay}
|
||||
tokens={tokens}
|
||||
deskSurcharge={tokenSurcharge(deskCards, tokenExtraFlat)}
|
||||
deskSurcharge={surcharge}
|
||||
onPurge={handleShopPurge}
|
||||
onReroll={handleShopReroll}
|
||||
rerolled={shopRerolled}
|
||||
|
||||
Reference in New Issue
Block a user