등급 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
@@ -47,6 +47,25 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.combine-limit {
|
||||
color: var(--color-risk-critical);
|
||||
}
|
||||
|
||||
.combine-protocols {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.combine-protocol-chip {
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 215, 0, 0.1);
|
||||
color: var(--color-risk-critical);
|
||||
border: 1px solid rgba(255, 215, 0, 0.25);
|
||||
}
|
||||
|
||||
.combine-desk-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -67,6 +86,11 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.combine-card:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.combine-card-name {
|
||||
font-size: 10px;
|
||||
color: var(--color-text);
|
||||
@@ -103,21 +127,6 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.combine-protocols {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.combine-protocol-chip {
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 215, 0, 0.1);
|
||||
color: var(--color-risk-critical);
|
||||
border: 1px solid rgba(255, 215, 0, 0.25);
|
||||
}
|
||||
|
||||
.combine-blocked {
|
||||
font-size: 13px;
|
||||
color: var(--color-risk-critical);
|
||||
@@ -125,9 +134,8 @@
|
||||
|
||||
.combine-match {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 6px;
|
||||
@@ -136,17 +144,33 @@
|
||||
cursor: pointer;
|
||||
font-family: var(--font-typewriter);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.combine-match:hover {
|
||||
border-color: var(--color-token-budget);
|
||||
}
|
||||
|
||||
.match-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.match-name {
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.match-score { color: var(--color-token-budget); }
|
||||
.match-justification { opacity: 0.7; }
|
||||
|
||||
.match-detail {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.match-used {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import SymbolBadge from './SymbolBadge';
|
||||
import { detectHands, resolveCombine, HAND_DEFS } from '../engine/hands';
|
||||
import { detectHands, resolveCombine, MAX_COMBINE_CARDS, MIN_COMBINE_CARDS } from '../engine/hands';
|
||||
import { filterHandsByProtocols } from '../engine/council';
|
||||
import './CombinePanel.css';
|
||||
|
||||
// 합본 결재 (§5) — 책상에서 카드를 다중 선택하면 성립 가능한 족보를 자동 판정해 보여준다.
|
||||
// 여러 족보가 동시 성립하면 최고 족보를 기본값으로 보여주되, 하위 족보도 선택할 수 있게 한다 (§5.3) —
|
||||
// 여러 족보가 동시 성립하면 상위 족보를 먼저 보여주되, 하위 족보도 선택할 수 있게 한다 (§5.3) —
|
||||
// 명분 종류를 고르기 위해 일부러 하위 족보를 쓰는 선택이 존재해야 하기 때문이다.
|
||||
// activeProtocols: 현재 라운드에 적용 중인 §9.2 유형A 규약 — 특정 족보를 무효화하거나
|
||||
// 최소 등급을 강제한다.
|
||||
//
|
||||
// 2026-09-15 결정 반영: 선택은 최대 5장(§5.1.1). 족보에 실제로 쓰이는 카드만 소멸하므로,
|
||||
// 각 족보 후보가 어떤 카드를 가져가고 토큰이 얼마 나오는지(등급 합계, §8.2)를 미리 보여준다.
|
||||
export default function CombinePanel({ deskCards, onConfirm, onClose, activeProtocols = [] }) {
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
|
||||
@@ -23,8 +24,14 @@ export default function CombinePanel({ deskCards, onConfirm, onClose, activeProt
|
||||
);
|
||||
const blockedByProtocol = rawMatches.length > 0 && matches.length === 0;
|
||||
|
||||
const atLimit = selectedIds.length >= MAX_COMBINE_CARDS;
|
||||
|
||||
const toggle = (id) => {
|
||||
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
setSelectedIds((prev) => {
|
||||
if (prev.includes(id)) return prev.filter((x) => x !== id);
|
||||
if (prev.length >= MAX_COMBINE_CARDS) return prev; // §5.1.1 최대 5장
|
||||
return [...prev, id];
|
||||
});
|
||||
};
|
||||
|
||||
const confirm = (patternId) => {
|
||||
@@ -40,7 +47,11 @@ export default function CombinePanel({ deskCards, onConfirm, onClose, activeProt
|
||||
<button className="combine-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<p className="combine-hint">책상에서 카드를 선택하면 성립하는 족보를 판정합니다.</p>
|
||||
<p className="combine-hint">
|
||||
{MIN_COMBINE_CARDS}~{MAX_COMBINE_CARDS}장을 선택하면 성립하는 족보를 판정합니다.
|
||||
선택 {selectedIds.length}/{MAX_COMBINE_CARDS}
|
||||
{atLimit && <span className="combine-limit"> · 최대치</span>}
|
||||
</p>
|
||||
|
||||
{activeProtocols.length > 0 && (
|
||||
<div className="combine-protocols">
|
||||
@@ -52,16 +63,20 @@ export default function CombinePanel({ deskCards, onConfirm, onClose, activeProt
|
||||
|
||||
<div className="combine-desk-grid">
|
||||
{deskCards.length === 0 && <p className="combine-empty">책상이 비어 있습니다.</p>}
|
||||
{deskCards.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
className={`combine-card ${selectedIds.includes(c.id) ? 'selected' : ''}`}
|
||||
onClick={() => toggle(c.id)}
|
||||
>
|
||||
<SymbolBadge axis={c.axis} grade={c.grade} />
|
||||
{c.name && <span className="combine-card-name">{c.name}</span>}
|
||||
</button>
|
||||
))}
|
||||
{deskCards.map((c) => {
|
||||
const selected = selectedIds.includes(c.id);
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
className={`combine-card ${selected ? 'selected' : ''}`}
|
||||
onClick={() => toggle(c.id)}
|
||||
disabled={!selected && atLimit}
|
||||
>
|
||||
<SymbolBadge axis={c.axis} grade={c.grade} />
|
||||
{c.name && <span className="combine-card-name">{c.name}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="combine-results">
|
||||
@@ -72,12 +87,23 @@ export default function CombinePanel({ deskCards, onConfirm, onClose, activeProt
|
||||
<p className="combine-blocked">규약 때문에 이 선택은 합본할 수 없습니다.</p>
|
||||
)}
|
||||
{matches.map((patternId) => {
|
||||
const def = HAND_DEFS[patternId];
|
||||
const preview = resolveCombine(selection, patternId);
|
||||
if (!preview) return null;
|
||||
const { pattern, usedCards, tokens, justification, performanceScore } = preview;
|
||||
return (
|
||||
<button key={patternId} className="combine-match" onClick={() => confirm(patternId)}>
|
||||
<span className="match-name">{def.name}</span>
|
||||
<span className="match-score">실적 +{def.score}</span>
|
||||
<span className="match-justification">명분 +{def.baseJustification}</span>
|
||||
<div className="match-top">
|
||||
<span className="match-name">{pattern.name}</span>
|
||||
<span className="match-score">실적 +{performanceScore}</span>
|
||||
</div>
|
||||
<div className="match-detail">
|
||||
토큰 +{tokens} · 명분 {justification.type} +{justification.amount} · {usedCards.length}장 소멸
|
||||
</div>
|
||||
<div className="match-used">
|
||||
{usedCards.map((c) => (
|
||||
<SymbolBadge key={c.id} axis={c.axis} grade={c.grade} size="sm" />
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -45,6 +45,10 @@
|
||||
color: var(--color-risk-critical);
|
||||
}
|
||||
|
||||
.core-status-penalty {
|
||||
color: var(--color-entropy-critical);
|
||||
}
|
||||
|
||||
.core-gameover {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { motion, useMotionValue, useTransform, useAnimation } from 'framer-motion';
|
||||
import SymbolBadge from './SymbolBadge';
|
||||
import { SymbolFace } from './SymbolBadge';
|
||||
import './CoreSwipeCard.css';
|
||||
|
||||
// §3 핵심 요건: "스와이프 판단은 기호만 보고 0.5초 내에 이루어져야 한다." 텍스트는 서사만 담당한다.
|
||||
@@ -58,7 +58,7 @@ export default function CoreSwipeCard({ card, onSwipe }) {
|
||||
<div className="choice-body">
|
||||
<span className="choice-text">{card.left?.text}</span>
|
||||
{card.left?.axis
|
||||
? <SymbolBadge axis={card.left.axis} grade={card.left.grade} size={16} />
|
||||
? <SymbolFace axis={card.left.axis} grade={card.left.grade} />
|
||||
: <span className="choice-safe">오염 없음</span>}
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -66,7 +66,7 @@ export default function CoreSwipeCard({ card, onSwipe }) {
|
||||
<div className="choice-body">
|
||||
<span className="choice-text">{card.right?.text}</span>
|
||||
{card.right?.axis
|
||||
? <SymbolBadge axis={card.right.axis} grade={card.right.grade} size={16} />
|
||||
? <SymbolFace axis={card.right.axis} grade={card.right.grade} />
|
||||
: <span className="choice-safe">오염 없음</span>}
|
||||
</div>
|
||||
<span className="choice-arrow">→</span>
|
||||
|
||||
@@ -48,33 +48,28 @@
|
||||
}
|
||||
|
||||
.council-token-pay {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.council-token-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-family: var(--font-typewriter);
|
||||
font-size: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
padding: 4px 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.council-token-row button {
|
||||
.council-token-pay button {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.council-token-row button:disabled {
|
||||
.council-token-pay button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -85,6 +80,11 @@
|
||||
color: var(--color-token-budget);
|
||||
}
|
||||
|
||||
.council-token-note {
|
||||
opacity: 0.5;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.council-penalties {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -105,6 +105,19 @@
|
||||
outline: 1px dashed var(--color-risk-critical);
|
||||
}
|
||||
|
||||
.council-penalty-more {
|
||||
align-self: flex-start;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
opacity: 0.6;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-typewriter);
|
||||
cursor: pointer;
|
||||
padding: 2px 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.council-justifications {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -135,6 +148,11 @@
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.justification-partial {
|
||||
font-size: 10px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.council-justification-item button {
|
||||
font-family: var(--font-typewriter);
|
||||
font-size: 11px;
|
||||
@@ -150,17 +168,40 @@
|
||||
border-color: var(--color-token-budget);
|
||||
}
|
||||
|
||||
.council-justification-item button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.council-pending-hint {
|
||||
font-size: 11px;
|
||||
color: var(--color-risk-critical);
|
||||
}
|
||||
|
||||
.council-cancel-btn {
|
||||
margin-left: 8px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
color: var(--color-text);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-typewriter);
|
||||
}
|
||||
|
||||
.council-candidates {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.council-pool {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.council-candidate {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
@@ -1,65 +1,67 @@
|
||||
import React, { useState } from 'react';
|
||||
import { requiredScore, settleScore, JUSTIFICATION_INTERVENTIONS } from '../engine/council';
|
||||
import {
|
||||
requiredScore, settleScore, JUSTIFICATION_INTERVENTIONS, availableForTabling,
|
||||
} from '../engine/council';
|
||||
import './CouncilScreen.css';
|
||||
|
||||
// 의회 (§9.1) — 실적 감사와 규약 결정은 독립적이다: 실적을 잘 채웠다고 우호적 규약이
|
||||
// 상정되지 않는다. 13인 위원회의 실제 우호도·표결(§9.4~9.6)은 4차 범위라 없다 — 후보 1개는
|
||||
// 가중 무작위로 뽑힌 "통과"를 그대로 쓴다. 플레이어는 표결에 직접 개입 못 하고 명분으로
|
||||
// 무작위로 뽑힌 "통과"를 그대로 쓴다. 플레이어는 표결에 직접 개입 못 하고 명분으로
|
||||
// 안건 자체를 조작할 뿐이라는 원칙(§9.2)만 지켰다.
|
||||
//
|
||||
// 2026-09-15 결정 반영: 토큰 1종, 명분 개입 4갈래 재정의(§7.3).
|
||||
// 명분은 종류별 "수량"(§5.1 표의 명분 열)이다 — 1개 이상 있어야 실적 납입이나 개입에 쓸 수 있고,
|
||||
// 페어(0.5)만 반복하면 반쪽만 쌓여 아무것도 못 한다.
|
||||
export default function CouncilScreen({
|
||||
round, combineScore, tokens, justifications, penalties, candidates, onResolve,
|
||||
}) {
|
||||
const required = requiredScore(round);
|
||||
|
||||
const [tokenPayments, setTokenPayments] = useState({ budget: 0, personnel: 0, info: 0 });
|
||||
const [usedIds, setUsedIds] = useState([]);
|
||||
const [scorePaidIds, setScorePaidIds] = useState([]);
|
||||
const [tokensPaid, setTokensPaid] = useState(0);
|
||||
const [spent, setSpent] = useState({}); // { 명분종류: 소모한 수량 }
|
||||
const [scorePaidCount, setScorePaidCount] = useState(0);
|
||||
const [interventions, setInterventions] = useState([]);
|
||||
const [pending, setPending] = useState(null); // { type, justificationId }
|
||||
const [pending, setPending] = useState(null); // { type }
|
||||
const [penaltiesExpanded, setPenaltiesExpanded] = useState(false);
|
||||
|
||||
const tokensPaidCount = Object.values(tokenPayments).reduce((a, b) => a + b, 0);
|
||||
const currentTotal = settleScore({ combineScore, tokensPaid: tokensPaidCount, justificationsPaid: scorePaidIds.length });
|
||||
const remainingOf = (type) => (justifications[type] || 0) - (spent[type] || 0);
|
||||
|
||||
const currentTotal = settleScore({
|
||||
combineScore, tokensPaid, justificationsPaid: scorePaidCount,
|
||||
});
|
||||
const met = currentTotal >= required;
|
||||
|
||||
const payToken = (type) => {
|
||||
if (tokenPayments[type] >= (tokens[type] || 0)) return;
|
||||
setTokenPayments((p) => ({ ...p, [type]: p[type] + 1 }));
|
||||
};
|
||||
const unpayToken = (type) => {
|
||||
if (tokenPayments[type] <= 0) return;
|
||||
setTokenPayments((p) => ({ ...p, [type]: p[type] - 1 }));
|
||||
const spend = (type) => setSpent((prev) => ({ ...prev, [type]: (prev[type] || 0) + 1 }));
|
||||
|
||||
const payForScore = (type) => {
|
||||
if (remainingOf(type) < 1) return;
|
||||
spend(type);
|
||||
setScorePaidCount((n) => n + 1);
|
||||
};
|
||||
|
||||
const payJustificationForScore = (j) => {
|
||||
setUsedIds((prev) => [...prev, j.id]);
|
||||
setScorePaidIds((prev) => [...prev, j.id]);
|
||||
};
|
||||
|
||||
const startIntervention = (type, j) => {
|
||||
const spec = JUSTIFICATION_INTERVENTIONS[type];
|
||||
if (!spec.needsTarget) {
|
||||
setUsedIds((prev) => [...prev, j.id]);
|
||||
setInterventions((prev) => [...prev, { type, justificationId: j.id }]);
|
||||
} else {
|
||||
setPending({ type, justificationId: j.id });
|
||||
}
|
||||
const startIntervention = (type) => {
|
||||
if (remainingOf(type) < 1) return;
|
||||
setPending({ type });
|
||||
};
|
||||
|
||||
const completeTarget = (targetId, targetKind) => {
|
||||
if (!pending) return;
|
||||
setUsedIds((prev) => [...prev, pending.justificationId]);
|
||||
setInterventions((prev) => [...prev, {
|
||||
type: pending.type,
|
||||
justificationId: pending.justificationId,
|
||||
...(targetKind === 'candidate' ? { targetCandidateId: targetId } : { targetPenaltyId: targetId }),
|
||||
}]);
|
||||
const key = {
|
||||
candidate: 'targetCandidateId',
|
||||
penalty: 'targetPenaltyId',
|
||||
pool: 'targetPoolProtocolId',
|
||||
}[targetKind];
|
||||
spend(pending.type);
|
||||
setInterventions((prev) => [...prev, { type: pending.type, [key]: targetId }]);
|
||||
setPending(null);
|
||||
};
|
||||
|
||||
const availableJustifications = justifications.filter((j) => !usedIds.includes(j.id));
|
||||
const pendingTargetKind = pending ? JUSTIFICATION_INTERVENTIONS[pending.type]?.target : null;
|
||||
const tablingPool = availableForTabling(candidates);
|
||||
const heldTypes = Object.keys(justifications).filter((t) => remainingOf(t) > 0);
|
||||
|
||||
const confirm = () => {
|
||||
onResolve({ tokensPaid: tokenPayments, justificationsPaidCount: scorePaidIds.length, interventions });
|
||||
onResolve({ tokensPaid, justificationsPaidCount: scorePaidCount, interventions });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -74,53 +76,76 @@ export default function CouncilScreen({
|
||||
</span>
|
||||
</div>
|
||||
<p className="council-audit-breakdown">
|
||||
족보 {combineScore} + 토큰 납입 {tokensPaidCount}×5 + 명분 납입 {scorePaidIds.length}×20
|
||||
족보 {combineScore} + 토큰 납입 {tokensPaid}×5 + 명분 납입 {scorePaidCount}×20
|
||||
</p>
|
||||
|
||||
<div className="council-token-pay">
|
||||
{['budget', 'personnel', 'info'].map((t) => (
|
||||
<div key={t} className="council-token-row">
|
||||
<span>{{ budget: '예산', personnel: '인력', info: '정보' }[t]} {tokens[t] - tokenPayments[t]}</span>
|
||||
<button onClick={() => unpayToken(t)} disabled={tokenPayments[t] === 0}>-</button>
|
||||
<span className="council-token-committed">{tokenPayments[t]}</span>
|
||||
<button onClick={() => payToken(t)} disabled={tokenPayments[t] >= tokens[t]}>+</button>
|
||||
</div>
|
||||
))}
|
||||
<span>토큰 {tokens - tokensPaid} 보유</span>
|
||||
<button onClick={() => setTokensPaid((n) => Math.max(0, n - 1))} disabled={tokensPaid === 0}>-</button>
|
||||
<span className="council-token-committed">{tokensPaid}</span>
|
||||
<button onClick={() => setTokensPaid((n) => Math.min(tokens, n + 1))} disabled={tokensPaid >= tokens}>+</button>
|
||||
<span className="council-token-note">납입 1개 = 실적 5</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{penalties.length > 0 && (
|
||||
<section className="council-penalties">
|
||||
<h3>누적 징벌 규약</h3>
|
||||
{penalties.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`council-penalty-chip ${pending?.type === '위기' ? 'targetable' : ''}`}
|
||||
onClick={() => pending?.type === '위기' && completeTarget(p.id, 'penalty')}
|
||||
>
|
||||
⚠ {p.name}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{penalties.length > 0 && (() => {
|
||||
// §9.2 유형C: "누적 5개 초과 시 목록을 접거나 압축 표시할 것 — 상시 전체 나열은
|
||||
// 화면을 잠식한다." 위기 명분으로 특정 항목을 골라야 할 때는 강제로 펼친다.
|
||||
const forceExpanded = pendingTargetKind === 'penalty';
|
||||
const shown = forceExpanded || penaltiesExpanded ? penalties : penalties.slice(0, 5);
|
||||
const hidden = penalties.length - shown.length;
|
||||
return (
|
||||
<section className="council-penalties">
|
||||
<h3>누적 징벌 규약 {penalties.length}개</h3>
|
||||
{shown.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`council-penalty-chip ${pendingTargetKind === 'penalty' ? 'targetable' : ''}`}
|
||||
onClick={() => pendingTargetKind === 'penalty' && completeTarget(p.id, 'penalty')}
|
||||
>
|
||||
⚠ {p.name}
|
||||
</div>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button className="council-penalty-more" onClick={() => setPenaltiesExpanded(true)}>
|
||||
+{hidden}개 더 보기
|
||||
</button>
|
||||
)}
|
||||
{!forceExpanded && penaltiesExpanded && penalties.length > 5 && (
|
||||
<button className="council-penalty-more" onClick={() => setPenaltiesExpanded(false)}>
|
||||
접기
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
|
||||
<section className="council-justifications">
|
||||
<h3>보유 명분 {availableJustifications.length}개</h3>
|
||||
{availableJustifications.length === 0 && <p className="council-empty">사용 가능한 명분이 없습니다.</p>}
|
||||
{availableJustifications.map((j) => (
|
||||
<div key={j.id} className="council-justification-item">
|
||||
<span className="justification-badge">{j.type} +{j.amount}</span>
|
||||
<button onClick={() => payJustificationForScore(j)}>실적 납입 (+20)</button>
|
||||
{Object.entries(JUSTIFICATION_INTERVENTIONS)
|
||||
.filter(([type]) => type === j.type)
|
||||
.map(([type, spec]) => (
|
||||
<button key={type} onClick={() => startIntervention(type, j)}>{spec.label}</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<h3>보유 명분</h3>
|
||||
{heldTypes.length === 0 && <p className="council-empty">사용 가능한 명분이 없습니다.</p>}
|
||||
{heldTypes.map((type) => {
|
||||
const spec = JUSTIFICATION_INTERVENTIONS[type];
|
||||
const remaining = remainingOf(type);
|
||||
const usable = remaining >= 1;
|
||||
return (
|
||||
<div key={type} className="council-justification-item">
|
||||
<span className="justification-badge">{type} {remaining}개</span>
|
||||
{!usable && <span className="justification-partial">1개 미만 — 사용 불가</span>}
|
||||
<button onClick={() => payForScore(type)} disabled={!usable}>실적 납입 (+20)</button>
|
||||
{spec && (
|
||||
<button onClick={() => startIntervention(type)} disabled={!usable}>{spec.label}</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{pending && (
|
||||
<p className="council-pending-hint">
|
||||
"{pending.type}" 개입 대상을 아래 규약 후보{pending.type === '위기' ? '(또는 위 징벌)' : ''}에서 클릭하세요.
|
||||
"{pending.type}" 개입 대상을 {{
|
||||
candidate: '아래 규약 후보',
|
||||
penalty: '위 징벌 규약',
|
||||
pool: '아래 상정 가능 규약',
|
||||
}[pendingTargetKind]}에서 클릭하세요.
|
||||
<button className="council-cancel-btn" onClick={() => setPending(null)}>취소</button>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
@@ -128,24 +153,48 @@ export default function CouncilScreen({
|
||||
<section className="council-candidates">
|
||||
<h3>규약 후보</h3>
|
||||
{candidates.map((c) => {
|
||||
const deferredHere = interventions.some((iv) => iv.type === '절차' && iv.targetCandidateId === c.id);
|
||||
const removed = interventions.some((iv) => iv.type === '여론' && iv.targetCandidateId === c.id);
|
||||
const forced = interventions.some((iv) => iv.type === '혼합' && iv.targetCandidateId === c.id);
|
||||
const gone = deferredHere || removed;
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`council-candidate ${removed ? 'removed' : ''} ${forced ? 'forced' : ''} ${pending?.type === '여론' || pending?.type === '혼합' ? 'targetable' : ''}`}
|
||||
onClick={() => (pending?.type === '여론' || pending?.type === '혼합') && completeTarget(c.id, 'candidate')}
|
||||
className={`council-candidate ${gone ? 'removed' : ''} ${forced ? 'forced' : ''} ${pendingTargetKind === 'candidate' ? 'targetable' : ''}`}
|
||||
onClick={() => pendingTargetKind === 'candidate' && completeTarget(c.id, 'candidate')}
|
||||
>
|
||||
<div className="candidate-name">{c.name}{removed && ' (탈락됨)'}{forced && ' (강제 통과 지정)'}</div>
|
||||
<div className="candidate-name">
|
||||
{c.name}
|
||||
{deferredHere && ' (다음 라운드로 이월)'}
|
||||
{removed && ' (강제 탈락)'}
|
||||
{forced && ' (통과 지정)'}
|
||||
</div>
|
||||
<div className="candidate-flavor">{c.flavor}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{interventions.some((iv) => iv.type === '절차') && (
|
||||
<p className="council-deferred-note">절차 명분으로 이번 라운드 규약 결정이 보류됩니다.</p>
|
||||
)}
|
||||
{interventions.filter((iv) => iv.type === '내부').map((iv, i) => (
|
||||
<div key={`tabled-${i}`} className="council-candidate forced">
|
||||
<div className="candidate-name">
|
||||
{tablingPool.find((p) => p.id === iv.targetPoolProtocolId)?.name} (강제 상정)
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{pendingTargetKind === 'pool' && (
|
||||
<section className="council-pool">
|
||||
<h3>상정 가능 규약</h3>
|
||||
{tablingPool.length === 0 && <p className="council-empty">상정할 규약이 남지 않았습니다.</p>}
|
||||
{tablingPool.map((p) => (
|
||||
<div key={p.id} className="council-candidate targetable" onClick={() => completeTarget(p.id, 'pool')}>
|
||||
<div className="candidate-name">{p.name}</div>
|
||||
<div className="candidate-flavor">{p.flavor}</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<button className="council-confirm-btn" onClick={confirm}>의회 결의 확정</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
font-family: var(--font-typewriter);
|
||||
}
|
||||
|
||||
/* 경고/한도초과(bust) 구간을 테두리로 알린다 (§4.2-B) — bust는 실제로 라운드를 강제 종료시킨다. */
|
||||
.desk-bar.zone-warning { border-bottom-color: var(--color-risk-critical); }
|
||||
.desk-bar.zone-bust { border-bottom-color: var(--color-entropy-critical); }
|
||||
|
||||
.desk-bar-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -18,12 +22,30 @@
|
||||
|
||||
.desk-total {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.desk-surcharge {
|
||||
.desk-attention {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.zone-warning .desk-attention { color: var(--color-risk-critical); }
|
||||
.zone-bust .desk-attention { color: var(--color-entropy-critical); }
|
||||
|
||||
.desk-surcharge { color: var(--color-risk-critical); }
|
||||
.desk-tokens { opacity: 0.8; }
|
||||
|
||||
.desk-zone-banner {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 215, 0, 0.1);
|
||||
color: var(--color-risk-critical);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.zone-bust .desk-zone-banner {
|
||||
background: rgba(139, 0, 0, 0.18);
|
||||
color: #ff8a80;
|
||||
}
|
||||
|
||||
.desk-bar-actions {
|
||||
@@ -60,6 +82,25 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.desk-end-round-btn {
|
||||
font-family: var(--font-typewriter);
|
||||
border: 1px solid var(--color-token-info);
|
||||
color: var(--color-token-info);
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
padding: 7px 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.desk-end-round-btn:disabled {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.desk-bar-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -97,22 +138,37 @@
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* 축별 주목도 — 이 숫자가 게이지 상승률을 결정하므로 장수보다 크게 보여야 한다.
|
||||
등급 칩과 값이 같을 때 붙어 보이지 않도록 배경을 줘서 분리한다. */
|
||||
.desk-row-attention {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
min-width: 30px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.desk-row-chips {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.desk-chip {
|
||||
font-size: 12px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
font-size: 11px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.desk-chip.more { opacity: 0.55; }
|
||||
|
||||
.desk-row-empty {
|
||||
font-size: 11px;
|
||||
opacity: 0.35;
|
||||
|
||||
@@ -1,57 +1,80 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AXES, AXIS_META, gradeDots } from '../engine/symbols';
|
||||
import { gradeBreakdownByAxis, totalCount, tokenSurcharge, gaugeRiseRateByAxis } from '../engine/desk';
|
||||
import { AXES, AXIS_META } from '../engine/symbols';
|
||||
import {
|
||||
totalCount, totalAttention, attentionByAxis, attentionZone, tokenSurcharge, gaugeRiseRateByAxis,
|
||||
} from '../engine/desk';
|
||||
import { AxisIcon } from './SymbolBadge';
|
||||
import DeskDetailPopup from './DeskDetailPopup';
|
||||
import './DeskBar.css';
|
||||
|
||||
// 책상 상태 상시 노출 (§3.4) — "이 표시가 없으면 합본 설계가 불가능하며 스와이프는 무의미한 도박이 된다."
|
||||
// 플레이테스트 피드백 반영: 장수만으로는 부족해서 축×등급 분포와, 그게 어느 게이지를 얼마나
|
||||
// 올리고 있는지(▲+X%/결재)까지 항상 보이게 했다. 개별 카드 이름은 축을 클릭하면 팝업으로.
|
||||
export default function DeskBar({ deskCards, gauges, onOpenCombine, onOpenGuide, gaugeExtraFlat = 0, tokenExtraFlat = 0 }) {
|
||||
// 2026-09-16 결정 반영: 페널티 기준이 등급 합계(주목도)이고, 이제 주목도가 **이번 라운드의
|
||||
// 한도(attentionCap)를 넘으면 그 자리에서 라운드가 강제 종료**된다(§4.2-B, 결정 노트
|
||||
// 2026-09-16-라운드-주목도-하드캡-복원). 그래서 총 주목도뿐 아니라 한도까지 항상 보여야
|
||||
// "한 장 더 받을지, 지금 정산할지" 판단이 선다. "정산하기" 버튼이 그 자율 종료 액션이다.
|
||||
const MAX_GRADE_CHIPS = 6;
|
||||
|
||||
export default function DeskBar({
|
||||
deskCards, gauges, tokens, attentionCap, onOpenCombine, onOpenGuide, onEndRound, canEndRound,
|
||||
gaugeExtraFlat = 0, tokenExtraFlat = 0,
|
||||
}) {
|
||||
const [detailAxis, setDetailAxis] = useState(null);
|
||||
const breakdown = gradeBreakdownByAxis(deskCards);
|
||||
const rates = gaugeRiseRateByAxis(deskCards, gaugeExtraFlat);
|
||||
|
||||
const total = totalCount(deskCards);
|
||||
const attention = totalAttention(deskCards);
|
||||
const perAxis = attentionByAxis(deskCards);
|
||||
const rates = gaugeRiseRateByAxis(deskCards, gaugeExtraFlat);
|
||||
const surcharge = tokenSurcharge(deskCards, tokenExtraFlat);
|
||||
const zone = attentionZone(deskCards, attentionCap);
|
||||
|
||||
const zoneLabel = {
|
||||
normal: null,
|
||||
warning: `한도 근접 (${attentionCap - 5}~${attentionCap}) — 정산할지 고려하십시오`,
|
||||
bust: `한도 초과! (${attentionCap}+) — 이번 라운드는 강제 종료됩니다`,
|
||||
}[zone];
|
||||
|
||||
return (
|
||||
<div className="desk-bar">
|
||||
<div className={`desk-bar zone-${zone}`}>
|
||||
<div className="desk-bar-top">
|
||||
<div className="desk-total">
|
||||
책상 총 {total}장
|
||||
책상 {total}장 · <span className="desk-attention">주목도 {attention}/{attentionCap}</span>
|
||||
{surcharge > 0 && <span className="desk-surcharge"> · 지출 가산 +{surcharge}</span>}
|
||||
<span className="desk-tokens"> · 토큰 {tokens}</span>
|
||||
</div>
|
||||
<div className="desk-bar-actions">
|
||||
<button className="desk-guide-btn" onClick={onOpenGuide}>족보표</button>
|
||||
<button className="desk-combine-btn" onClick={onOpenCombine} disabled={total < 2}>
|
||||
합본 결재
|
||||
</button>
|
||||
<button className="desk-end-round-btn" onClick={onEndRound} disabled={!canEndRound}>
|
||||
정산하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{zoneLabel && <div className="desk-zone-banner">{zoneLabel}</div>}
|
||||
|
||||
<div className="desk-bar-rows">
|
||||
{AXES.map((axis) => {
|
||||
const grades = breakdown[axis];
|
||||
const gradeEntries = Object.entries(grades).sort((a, b) => a[0] - b[0]);
|
||||
const count = gradeEntries.reduce((s, [, n]) => s + n, 0);
|
||||
const cards = deskCards.filter((c) => c.axis === axis);
|
||||
const grades = cards.map((c) => c.grade).sort((a, b) => b - a);
|
||||
const shown = grades.slice(0, MAX_GRADE_CHIPS);
|
||||
const hidden = grades.length - shown.length;
|
||||
return (
|
||||
<button
|
||||
key={axis}
|
||||
className="desk-row"
|
||||
onClick={() => count > 0 && setDetailAxis(axis)}
|
||||
disabled={count === 0}
|
||||
onClick={() => cards.length > 0 && setDetailAxis(axis)}
|
||||
disabled={cards.length === 0}
|
||||
>
|
||||
<AxisIcon axis={axis} size={17} />
|
||||
<span className="desk-row-name">{AXIS_META[axis].name}</span>
|
||||
<span className="desk-row-attention">{perAxis[axis]}</span>
|
||||
<span className="desk-row-chips">
|
||||
{gradeEntries.length === 0
|
||||
{shown.length === 0
|
||||
? <span className="desk-row-empty">없음</span>
|
||||
: gradeEntries.map(([grade, n]) => (
|
||||
<span key={grade} className="desk-chip">
|
||||
{gradeDots(Number(grade))}{n > 1 ? `×${n}` : ''}
|
||||
</span>
|
||||
))}
|
||||
: shown.map((g, i) => <span key={i} className="desk-chip">{g}</span>)}
|
||||
{hidden > 0 && <span className="desk-chip more">+{hidden}</span>}
|
||||
</span>
|
||||
<span className="desk-row-gauge">
|
||||
→ {AXIS_META[axis].gaugeName} {Math.round(gauges[AXIS_META[axis].gauge] || 0)}%
|
||||
|
||||
@@ -55,17 +55,32 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.desk-detail-dots {
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
padding-top: 1px;
|
||||
/* 등급 숫자 표기 (§3.2) — 점 표기 폐기 */
|
||||
.desk-detail-grade {
|
||||
font-family: var(--font-typewriter);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
min-width: 26px;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.desk-detail-grade.axis-axis_threat { color: var(--symbol-threat); }
|
||||
.desk-detail-grade.axis-axis_hr { color: var(--symbol-hr); }
|
||||
.desk-detail-grade.axis-axis_pr { color: var(--symbol-pr); }
|
||||
.desk-detail-grade.axis-axis_external { color: var(--symbol-external); }
|
||||
|
||||
.desk-detail-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.desk-detail-band {
|
||||
font-weight: 400;
|
||||
opacity: 0.55;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.desk-detail-flavor {
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { AXIS_META, gradeDots } from '../engine/symbols';
|
||||
import { AXIS_META, gradeBandName } from '../engine/symbols';
|
||||
import { AxisIcon } from './SymbolBadge';
|
||||
import './DeskDetailPopup.css';
|
||||
|
||||
@@ -8,21 +8,25 @@ import './DeskDetailPopup.css';
|
||||
export default function DeskDetailPopup({ axis, cards, onClose }) {
|
||||
const meta = AXIS_META[axis];
|
||||
const sorted = [...cards].sort((a, b) => b.grade - a.grade);
|
||||
const attention = cards.reduce((s, c) => s + c.grade, 0);
|
||||
|
||||
return (
|
||||
<div className="desk-detail-overlay" onClick={onClose}>
|
||||
<div className="desk-detail-panel" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="desk-detail-header">
|
||||
<AxisIcon axis={axis} size={20} />
|
||||
<h2>{meta.name} 축 — {cards.length}장</h2>
|
||||
<h2>{meta.name} 축 — {cards.length}장 · 주목도 {attention}</h2>
|
||||
<button className="desk-detail-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="desk-detail-list">
|
||||
{sorted.map((c) => (
|
||||
<div key={c.id} className="desk-detail-item">
|
||||
<span className="desk-detail-dots">{gradeDots(c.grade)}</span>
|
||||
<span className={`desk-detail-grade axis-${axis}`}>{c.grade}</span>
|
||||
<div className="desk-detail-text">
|
||||
<div className="desk-detail-name">{c.name || '미상 서류'}</div>
|
||||
<div className="desk-detail-name">
|
||||
{c.name || '미상 서류'}
|
||||
<span className="desk-detail-band"> · {gradeBandName(c.grade)}</span>
|
||||
</div>
|
||||
{c.flavor && <div className="desk-detail-flavor">{c.flavor}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,10 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.guide-hint strong {
|
||||
color: var(--color-token-budget);
|
||||
}
|
||||
|
||||
.guide-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -85,18 +89,3 @@
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.guide-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 5px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.guide-chip.axis-axis_threat { color: var(--color-entropy-critical); }
|
||||
.guide-chip.axis-axis_hr { color: var(--color-resistance-critical); }
|
||||
.guide-chip.axis-axis_pr { color: var(--color-panic-critical); }
|
||||
.guide-chip.axis-axis_external { color: var(--color-risk-critical); }
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import React from 'react';
|
||||
import { HAND_DEFS } from '../engine/hands';
|
||||
import { gradeDots } from '../engine/symbols';
|
||||
import { AxisIcon } from './SymbolBadge';
|
||||
import { HAND_DEFS, MIN_COMBINE_CARDS, MAX_COMBINE_CARDS } from '../engine/hands';
|
||||
import { TOP_SECRET_THRESHOLD } from '../engine/symbols';
|
||||
import SymbolBadge from './SymbolBadge';
|
||||
import './HandGuidePopup.css';
|
||||
|
||||
// 족보표 (§5.1) — 트럼프 족보가 아무리 유명해도 기호 자체가 다르면 안 와닿는다는
|
||||
// 플레이테스트 피드백. 버튼으로 언제든 열어 이 게임의 기호로 다시 설명해 준다.
|
||||
const GUIDE = [
|
||||
{ id: 'pair', condition: '같은 등급 2장', example: [['axis_hr', 2], ['axis_pr', 2]] },
|
||||
{ id: 'triple', condition: '같은 등급 3장', example: [['axis_hr', 1], ['axis_pr', 1], ['axis_external', 1]] },
|
||||
{ id: 'flush_axis', condition: '같은 축 3장', example: [['axis_hr', 1], ['axis_hr', 3], ['axis_hr', 2]] },
|
||||
{ id: 'straight', condition: '등급 오름차순 3장 (축 무관)', example: [['axis_threat', 1], ['axis_pr', 2], ['axis_external', 3]] },
|
||||
{ id: 'full_house', condition: '같은 등급 3장 + 같은 등급 2장', example: [['axis_hr', 2], ['axis_pr', 2], ['axis_external', 2], ['axis_threat', 3], ['axis_hr', 3]] },
|
||||
{ id: 'four_kind', condition: '같은 등급 4장', example: [['axis_hr', 2], ['axis_pr', 2], ['axis_external', 2], ['axis_threat', 2]] },
|
||||
{ id: 'straight_flush', condition: '같은 축 + 등급 오름차순 3장 이상', example: [['axis_hr', 1], ['axis_hr', 2], ['axis_hr', 3]] },
|
||||
// 플레이테스트 피드백. 버튼으로 언제든 열어 이 게임의 기호(축 인장 + 등급 숫자)로 다시 설명한다.
|
||||
// 2026-09-15 결정 반영: 5장 포커 핸드 8종, 등급 1~10.
|
||||
const GUIDE_ORDER = [
|
||||
'pair', 'two_pair', 'triple', 'straight', 'flush_axis', 'full_house', 'four_kind', 'straight_flush',
|
||||
];
|
||||
|
||||
const EXAMPLES = {
|
||||
pair: [['axis_hr', 7], ['axis_pr', 7]],
|
||||
two_pair: [['axis_hr', 7], ['axis_pr', 7], ['axis_external', 3], ['axis_threat', 3]],
|
||||
triple: [['axis_hr', 5], ['axis_pr', 5], ['axis_external', 5]],
|
||||
straight: [['axis_threat', 3], ['axis_pr', 4], ['axis_external', 5], ['axis_hr', 6], ['axis_threat', 7]],
|
||||
flush_axis: [['axis_hr', 2], ['axis_hr', 5], ['axis_hr', 6], ['axis_hr', 9], ['axis_hr', 10]],
|
||||
full_house: [['axis_hr', 8], ['axis_pr', 8], ['axis_external', 8], ['axis_threat', 4], ['axis_hr', 4]],
|
||||
four_kind: [['axis_hr', 9], ['axis_pr', 9], ['axis_external', 9], ['axis_threat', 9]],
|
||||
straight_flush: [['axis_hr', 6], ['axis_hr', 7], ['axis_hr', 8], ['axis_hr', 9], ['axis_hr', 10]],
|
||||
};
|
||||
|
||||
export default function HandGuidePopup({ onClose }) {
|
||||
return (
|
||||
<div className="guide-overlay" onClick={onClose}>
|
||||
@@ -25,11 +31,14 @@ export default function HandGuidePopup({ onClose }) {
|
||||
<button className="guide-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<p className="guide-hint">
|
||||
"같은 등급"은 트럼프의 같은 숫자, "같은 축"은 같은 무늬와 같다. 책상에서 카드를
|
||||
선택하면 아래 조건에 맞는 족보가 자동으로 판정된다.
|
||||
"같은 등급"은 트럼프의 같은 숫자, "같은 축"은 같은 무늬와 같다. 한 번에
|
||||
{' '}{MIN_COMBINE_CARDS}~{MAX_COMBINE_CARDS}장을 선택하면 성립하는 족보가 자동 판정되며,
|
||||
족보에 실제로 쓰인 카드만 소멸한다 (나머지는 책상에 남는다).
|
||||
토큰은 <strong>사용된 카드의 등급 합계</strong>만큼 나오고, 등급 {TOP_SECRET_THRESHOLD} 이상
|
||||
카드가 끼면 명분이 +1 된다.
|
||||
</p>
|
||||
<div className="guide-list">
|
||||
{GUIDE.map(({ id, condition, example }) => {
|
||||
{GUIDE_ORDER.map((id) => {
|
||||
const def = HAND_DEFS[id];
|
||||
return (
|
||||
<div key={id} className="guide-row">
|
||||
@@ -38,13 +47,10 @@ export default function HandGuidePopup({ onClose }) {
|
||||
<span className="guide-score">실적 +{def.score}</span>
|
||||
<span className="guide-justification">명분 +{def.baseJustification}</span>
|
||||
</div>
|
||||
<div className="guide-condition">{condition}</div>
|
||||
<div className="guide-condition">{def.condition}</div>
|
||||
<div className="guide-example">
|
||||
{example.map(([axis, grade], i) => (
|
||||
<span key={i} className={`guide-chip axis-${axis}`}>
|
||||
<AxisIcon axis={axis} size={14} />
|
||||
{gradeDots(grade)}
|
||||
</span>
|
||||
{EXAMPLES[id].map(([axis, grade], i) => (
|
||||
<SymbolBadge key={i} axis={axis} grade={grade} size="sm" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,36 +7,32 @@ import './ShopScreen.css';
|
||||
// 각각 §10·§12·§11(3차 범위)이 있어야 성립해서 아직 없다. 지금 성립하는 품목은
|
||||
// "오염 카드 직접 처리" 하나뿐이다 — 족보가 안 맞을 때의 탈출구, 명분·실적은 안 준다.
|
||||
export default function ShopScreen({ display, tokens, deskSurcharge, onPurge, onReroll, rerolled, onContinue }) {
|
||||
const rerollAffordable = canAfford(tokens, REROLL_COST);
|
||||
|
||||
return (
|
||||
<div className="shop-screen">
|
||||
<div className="shop-header">
|
||||
<h2>정산 — 상점</h2>
|
||||
<p className="shop-hint">
|
||||
족보가 안 맞는 오염 카드를 토큰으로 직접 처리할 수 있습니다 (명분·실적은 없음).
|
||||
가격은 등급만큼이며 지출 가산 +{deskSurcharge}이 얹힙니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="shop-tokens">
|
||||
예산 {tokens.budget} · 인력 {tokens.personnel} · 정보 {tokens.info}
|
||||
</div>
|
||||
<div className="shop-tokens">보유 토큰 {tokens}</div>
|
||||
|
||||
<div className="shop-grid">
|
||||
{display.length === 0 && <p className="shop-empty">진열할 오염 카드가 없습니다.</p>}
|
||||
{display.map((card) => {
|
||||
const price = purgePrice(card, deskSurcharge);
|
||||
const affordable = canAfford(tokens, price);
|
||||
return (
|
||||
<div key={card.id} className="shop-item">
|
||||
<SymbolBadge axis={card.axis} grade={card.grade} />
|
||||
<span className="shop-item-name">{card.name || '미상 서류'}</span>
|
||||
<button
|
||||
className="shop-buy-btn"
|
||||
disabled={!affordable}
|
||||
disabled={!canAfford(tokens, price)}
|
||||
onClick={() => onPurge(card.id)}
|
||||
>
|
||||
소각 · 예산{price.budget}
|
||||
소각 · {price}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -44,8 +40,12 @@ export default function ShopScreen({ display, tokens, deskSurcharge, onPurge, on
|
||||
</div>
|
||||
|
||||
<div className="shop-actions">
|
||||
<button className="shop-reroll-btn" disabled={rerolled || !rerollAffordable} onClick={onReroll}>
|
||||
{rerolled ? '리롤 완료' : `진열 다시 뽑기 (예산 ${REROLL_COST.budget})`}
|
||||
<button
|
||||
className="shop-reroll-btn"
|
||||
disabled={rerolled || !canAfford(tokens, REROLL_COST)}
|
||||
onClick={onReroll}
|
||||
>
|
||||
{rerolled ? '리롤 완료' : `진열 다시 뽑기 (${REROLL_COST})`}
|
||||
</button>
|
||||
<button className="shop-continue-btn" onClick={onContinue}>의회로 이동 →</button>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,83 @@
|
||||
.symbol-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
gap: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
font-family: var(--font-typewriter);
|
||||
}
|
||||
|
||||
.symbol-dots {
|
||||
font-size: 14px;
|
||||
letter-spacing: 1px;
|
||||
/* 등급 숫자가 주역이다 (§3.2 트럼프 랭크+슈트 레이아웃) — 인장은 곁들이는 보조 기호. */
|
||||
.symbol-grade {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
min-width: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.symbol-badge.size-sm .symbol-grade { font-size: 13px; }
|
||||
.symbol-badge.size-lg .symbol-grade { font-size: 20px; }
|
||||
|
||||
.symbol-name {
|
||||
font-size: 12px;
|
||||
color: var(--color-text);
|
||||
opacity: 0.75;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 카드 면 표기 — 인장을 모서리에, 등급 숫자를 중앙에 크게 */
|
||||
.symbol-face {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 56px;
|
||||
padding: 6px 10px 4px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
font-family: var(--font-typewriter);
|
||||
}
|
||||
|
||||
.symbol-face-seal {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 5px;
|
||||
}
|
||||
|
||||
.symbol-face-grade {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.symbol-face-axis {
|
||||
font-size: 10px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
/* 축별 색상 — 인장 아이콘 형태가 1차 구분 수단이고, 색은 보조 신호일 뿐이다 (§3.1).
|
||||
index.css의 게이지 토큰(--color-entropy-critical 등)은 게이지 바 배경용으로 만들어져서
|
||||
어두운 값(#8B0000)이 섞여 있다. 등급 숫자는 다크 배경 위의 본문 텍스트라 그대로 쓰면
|
||||
읽히지 않으므로, 기호 표기 전용으로 밝은 변형을 따로 둔다 (토큰 자체는 건드리지 않는다). */
|
||||
:root {
|
||||
--symbol-threat: #ff7b6b;
|
||||
--symbol-hr: var(--color-resistance-critical);
|
||||
--symbol-pr: #d07bff;
|
||||
--symbol-external: var(--color-risk-critical);
|
||||
}
|
||||
|
||||
/* 축별 색상 — 인장 아이콘 형태가 1차 구분 수단이고, 색은 보조 신호일 뿐이다 (§3.1). */
|
||||
.axis-axis_threat .axis-icon,
|
||||
.symbol-badge.axis-axis_threat .symbol-dots { color: var(--color-entropy-critical); }
|
||||
.symbol-badge.axis-axis_threat .symbol-grade,
|
||||
.symbol-face.axis-axis_threat .symbol-face-grade { color: var(--symbol-threat); }
|
||||
.axis-axis_hr .axis-icon,
|
||||
.symbol-badge.axis-axis_hr .symbol-dots { color: var(--color-resistance-critical); }
|
||||
.symbol-badge.axis-axis_hr .symbol-grade,
|
||||
.symbol-face.axis-axis_hr .symbol-face-grade { color: var(--symbol-hr); }
|
||||
.axis-axis_pr .axis-icon,
|
||||
.symbol-badge.axis-axis_pr .symbol-dots { color: var(--color-panic-critical); }
|
||||
.symbol-badge.axis-axis_pr .symbol-grade,
|
||||
.symbol-face.axis-axis_pr .symbol-face-grade { color: var(--symbol-pr); }
|
||||
.axis-axis_external .axis-icon,
|
||||
.symbol-badge.axis-axis_external .symbol-dots { color: var(--color-risk-critical); }
|
||||
.symbol-badge.axis-axis_external .symbol-grade,
|
||||
.symbol-face.axis-axis_external .symbol-face-grade { color: var(--symbol-external); }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Tornado, Hand, Megaphone, Stamp } from 'lucide-react';
|
||||
import { AXIS_META, gradeDots } from '../engine/symbols';
|
||||
import { AXIS_META, clampGrade } from '../engine/symbols';
|
||||
import './SymbolBadge.css';
|
||||
|
||||
// §3.1 요건: 4종 인장은 실루엣(아이콘 형태)으로 구분한다. 색상만으로 구분하지 않는다 —
|
||||
@@ -19,14 +19,33 @@ export function AxisIcon({ axis, size = 20, className = '' }) {
|
||||
return <Icon size={size} className={`axis-icon axis-${axis} ${className}`} strokeWidth={2.25} />;
|
||||
}
|
||||
|
||||
/** 축 인장 + 기밀 등급(점 표기)을 한 세트로 보여주는 배지. 스와이프 예고·책상 카드에 공용으로 쓴다. */
|
||||
export default function SymbolBadge({ axis, grade, size = 22, showName = false }) {
|
||||
/**
|
||||
* 축 인장 + 기밀 등급을 한 세트로 보여주는 배지.
|
||||
* 2026-09-15 결정: 점(●) 표기 폐기 — 10단계는 점으로 셀 수 없다. 트럼프 랭크+슈트 레이아웃대로
|
||||
* 등급 숫자를 크게 놓고 축 인장을 작게 곁들인다 (§3.2).
|
||||
*/
|
||||
export default function SymbolBadge({ axis, grade, size = 'md', showName = false }) {
|
||||
if (!axis) return null;
|
||||
const g = clampGrade(grade);
|
||||
const iconSize = size === 'lg' ? 20 : size === 'sm' ? 12 : 15;
|
||||
return (
|
||||
<div className={`symbol-badge axis-${axis}`}>
|
||||
<AxisIcon axis={axis} size={size} />
|
||||
<span className="symbol-dots">{gradeDots(grade)}</span>
|
||||
<div className={`symbol-badge size-${size} axis-${axis}`}>
|
||||
<AxisIcon axis={axis} size={iconSize} />
|
||||
<span className="symbol-grade">{g}</span>
|
||||
{showName && <span className="symbol-name">{AXIS_META[axis].name}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 카드 면에 크게 찍는 형태 — 스와이프 예고 슬롯처럼 판단이 걸린 자리에 쓴다. */
|
||||
export function SymbolFace({ axis, grade }) {
|
||||
if (!axis) return null;
|
||||
const g = clampGrade(grade);
|
||||
return (
|
||||
<div className={`symbol-face axis-${axis}`}>
|
||||
<AxisIcon axis={axis} size={18} className="symbol-face-seal" />
|
||||
<span className="symbol-face-grade">{g}</span>
|
||||
<span className="symbol-face-axis">{AXIS_META[axis].name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
// 의회 엔진 — ProjectSS_코어시스템_기획서.md §7(명분) · §9.2(규약) · §9.3(실적 감사).
|
||||
// React 의존 없는 순수 함수 + 카탈로그. 13인 위원회의 실제 우호도·표결(§9.4~9.6)은
|
||||
// 이중첩자·탄핵 방어까지 포함된 4차 범위라 아직 없다 — 그 대신 규약 후보 3개 중 1개를
|
||||
// 뽑는 절차는 "가중 무작위"로 흉내만 낸다. §9.1 "규약 결정과 실적 감사는 독립적"이라는
|
||||
// 뽑는 절차는 "무작위"로 흉내만 낸다. §9.1 "규약 결정과 실적 감사는 독립적"이라는
|
||||
// 원칙만은 지킨다: 감사 결과가 어떤 규약이 뽑히는지에 영향을 주지 않는다.
|
||||
//
|
||||
// 2026-09-15 결정 반영: 토큰 1종 통합(§8.2), 명분 개입 4갈래 재정의(§7.3).
|
||||
// 특히 내부 명분이 "실적 요구치 하향"에서 "후보 1개 강제 상정"으로 바뀌었다.
|
||||
|
||||
/* ── §9.3 실적 감사 ── */
|
||||
|
||||
// 요구치 곡선은 가안: 1R 25, 라운드당 +20.
|
||||
// 요구치 곡선은 가안: 1라운드 25, 라운드당 +20 (§9.3).
|
||||
export const requiredScore = (round) => 25 + (round - 1) * 20;
|
||||
|
||||
// 실적 점수 = Σ(족보 점수, 라운드 중 합본 누적) + 토큰 납입×5 + 명분 납입×20.
|
||||
export const settleScore = ({ combineScore, tokensPaid, justificationsPaid }) =>
|
||||
combineScore + tokensPaid * 5 + justificationsPaid * 20;
|
||||
|
||||
/* ── §9.2 규약 — 유형 A(족보표 변경) 우선, 유형 C(징벌)는 감사 실패 시 필요해서 최소 카탈로그만 ── */
|
||||
/* ── §9.2 규약 — 유형 A(족보표 변경) 우선, 유형 C(징벌)는 감사 실패 시 필요해서 최소 카탈로그만.
|
||||
* 유형 B(금리형)·D(이득)는 §20.2 "규약 상세 목록 — 소량 배치로 작성" 대상이라 아직 카탈로그가 없다.
|
||||
* 유형 D가 없으면 내부 명분의 "강제 상정"이 전략적으로 빈약하다 — 이득 규약이 추가되면 살아난다. */
|
||||
|
||||
export const PROTOCOL_TYPE_A = [
|
||||
{
|
||||
@@ -40,11 +45,23 @@ export const PROTOCOL_TYPE_A = [
|
||||
effect: { kind: 'disable_hand', patternId: 'four_kind' },
|
||||
flavor: '동일 등급 4건 일괄 개편은 절차 생략 소지가 있어 당분간 금지합니다.',
|
||||
},
|
||||
{
|
||||
id: 'protocol_fullhouse_ban',
|
||||
name: '종합 감사 제한 규약',
|
||||
effect: { kind: 'disable_hand', patternId: 'full_house' },
|
||||
flavor: '3+2 형태의 묶음 상신은 감사 항목을 흐린다는 지적이 있었습니다.',
|
||||
},
|
||||
{
|
||||
id: 'protocol_min_grade',
|
||||
name: '기밀 등급 심사 강화 규약',
|
||||
effect: { kind: 'min_grade', minGrade: 2 },
|
||||
flavor: '일반 문서 단독으로는 더 이상 합본 상신이 승인되지 않습니다.',
|
||||
effect: { kind: 'min_grade', minGrade: 4 },
|
||||
flavor: '등급 4 미만 문서만으로는 더 이상 합본 상신이 승인되지 않습니다.',
|
||||
},
|
||||
{
|
||||
id: 'protocol_pair_ban',
|
||||
name: '중복 접수 금지 규약',
|
||||
effect: { kind: 'disable_hand', patternId: 'pair' },
|
||||
flavor: '두 건만 묶어 올리는 편의적 관행을 제한합니다. 최소 규모를 갖출 것.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -72,10 +89,17 @@ const pick = (arr, n) => {
|
||||
return out;
|
||||
};
|
||||
|
||||
/** 매 라운드 규약 후보 3개를 뽑는다 (유형 A만, §17 2차 범위). */
|
||||
export const rollProtocolCandidates = () => pick(PROTOCOL_TYPE_A, 3);
|
||||
/**
|
||||
* 매 라운드 규약 후보 3개를 뽑는다 (유형 A만, §17 2차 범위).
|
||||
* carriedOver: 절차 명분으로 지난 라운드에서 이월된 후보 — "지금은 피하지만 나중에 다시 온다"(§7.3).
|
||||
*/
|
||||
export const rollProtocolCandidates = (carriedOver = []) => {
|
||||
const carriedIds = new Set(carriedOver.map((c) => c.id));
|
||||
const fresh = pick(PROTOCOL_TYPE_A.filter((p) => !carriedIds.has(p.id)), Math.max(0, 3 - carriedOver.length));
|
||||
return [...carriedOver, ...fresh];
|
||||
};
|
||||
|
||||
/** 후보 중 1개를 "표결 통과"로 확정한다 — 실제 위원회가 없어 가중 무작위로 대신한다. */
|
||||
/** 후보 중 1개를 "표결 통과"로 확정한다 — 실제 위원회가 없어 무작위로 대신한다. */
|
||||
export const resolveProtocolVote = (candidates) => {
|
||||
if (candidates.length === 0) return null;
|
||||
return candidates[Math.floor(Math.random() * candidates.length)];
|
||||
@@ -83,42 +107,62 @@ export const resolveProtocolVote = (candidates) => {
|
||||
|
||||
export const rollPenalty = () => pick(PROTOCOL_TYPE_C, 1)[0];
|
||||
|
||||
/* ── §7.2 명분 개입 — 규약 후보에 개입한다. 실제 13인 표결이 없어 "1표 뒤집기"(혼합 명분)는
|
||||
* 문자 그대로 구현할 표가 없다. 대신 혼합 명분은 "후보 중 하나를 직접 지정 통과"로 대체했다 —
|
||||
* 표 조작의 최종 효과(원하는 규약을 통과시킨다)는 같지만, 이건 §9.4~9.6의 실제 위원회가
|
||||
* 생기면 반드시 다시 설계해야 할 자리표시자다. */
|
||||
/** 아직 후보에 오르지 않은 규약들 — 내부 명분의 "강제 상정" 대상 목록. */
|
||||
export const availableForTabling = (candidates) => {
|
||||
const onBoard = new Set(candidates.map((c) => c.id));
|
||||
return PROTOCOL_TYPE_A.filter((p) => !onBoard.has(p.id));
|
||||
};
|
||||
|
||||
/* ── §7.3 ① 안건 조작 — 명분 4종 + 혼합 ──
|
||||
* ② 정보 획득 · ③ 임원 직접 관리는 13인 위원회(§9.4~9.6, 4차)가 있어야 대상이 존재한다.
|
||||
* ④ 실적 납입(명분 1개 = 20점)은 CouncilScreen에서 직접 처리한다.
|
||||
*
|
||||
* 혼합 명분의 "표결 결과 1표 뒤집기"는 실제 표가 없어 문자 그대로 구현할 수 없다.
|
||||
* 최종 효과(원하는 규약이 통과된다)만 같게 "직접 통과 지정"으로 대체했고,
|
||||
* 실제 위원회가 생기면 반드시 다시 설계해야 할 자리표시자다. */
|
||||
export const JUSTIFICATION_INTERVENTIONS = {
|
||||
절차: { label: '규약 이월 (이번 라운드 규약 보류)', needsTarget: false },
|
||||
여론: { label: '후보 1개 강제 탈락', needsTarget: true },
|
||||
위기: { label: '누적 징벌 규약 1개 조기 해제', needsTarget: 'penalty' },
|
||||
내부: { label: '이번 라운드 실적 요구치 하향 (-20%)', needsTarget: false },
|
||||
혼합: { label: '후보 1개 직접 통과 지정', needsTarget: true },
|
||||
절차: { label: '후보 1개 다음 라운드로 이월', target: 'candidate' },
|
||||
여론: { label: '후보 1개 강제 탈락 + 새 후보로 교체', target: 'candidate' },
|
||||
위기: { label: '누적 징벌 규약 1개 조기 해제', target: 'penalty' },
|
||||
내부: { label: '규약 1개 강제 상정', target: 'pool' },
|
||||
혼합: { label: '후보 1개 직접 통과 지정 (표 뒤집기)', target: 'candidate' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 명분 개입을 규약 후보에 반영한다 (§7.2 규약 개입).
|
||||
* interventions: [{ type: '절차'|'여론'|'위기'|'내부'|'혼합', targetCandidateId?, targetPenaltyId? }]
|
||||
* @returns {{ candidates, forcedWinnerId, deferVote, requiredScoreMultiplier, penaltyIdToClear }}
|
||||
* 명분 개입을 규약 후보에 반영한다 (§7.3 ①).
|
||||
* interventions: [{ type, targetCandidateId?, targetPenaltyId?, targetPoolProtocolId? }]
|
||||
* @returns {{ candidates, forcedWinnerId, deferred, penaltyIdToClear }}
|
||||
* deferred: 다음 라운드 후보로 이월할 규약 목록 (절차 명분)
|
||||
*/
|
||||
export function applyInterventions(candidates, interventions) {
|
||||
let pool = [...candidates];
|
||||
let forcedWinnerId = null;
|
||||
let deferVote = false;
|
||||
let requiredScoreMultiplier = 1;
|
||||
const deferred = [];
|
||||
let penaltyIdToClear = null;
|
||||
|
||||
interventions.forEach((iv) => {
|
||||
if (iv.type === '절차') deferVote = true;
|
||||
if (iv.type === '절차' && iv.targetCandidateId) {
|
||||
const target = pool.find((c) => c.id === iv.targetCandidateId);
|
||||
if (target) {
|
||||
deferred.push(target);
|
||||
pool = pool.filter((c) => c.id !== iv.targetCandidateId);
|
||||
}
|
||||
}
|
||||
if (iv.type === '여론' && iv.targetCandidateId) {
|
||||
pool = pool.filter((c) => c.id !== iv.targetCandidateId);
|
||||
// 강제 탈락시킨 자리에 새 후보 1개를 무작위로 채운다 (§7.3).
|
||||
const replacement = pick(availableForTabling(pool), 1)[0];
|
||||
if (replacement) pool = [...pool, replacement];
|
||||
}
|
||||
if (iv.type === '내부' && iv.targetPoolProtocolId) {
|
||||
const tabled = PROTOCOL_TYPE_A.find((p) => p.id === iv.targetPoolProtocolId);
|
||||
if (tabled && !pool.some((c) => c.id === tabled.id)) pool = [...pool, tabled];
|
||||
}
|
||||
if (iv.type === '내부') requiredScoreMultiplier *= 0.8;
|
||||
if (iv.type === '위기' && iv.targetPenaltyId) penaltyIdToClear = iv.targetPenaltyId;
|
||||
// 혼합 명분: 실제 표 뒤집기가 아니라 "직접 통과 지정"으로 대체한 자리표시자 — 위 §7.2 주석 참고.
|
||||
if (iv.type === '혼합' && iv.targetCandidateId) forcedWinnerId = iv.targetCandidateId;
|
||||
});
|
||||
|
||||
return { candidates: pool, forcedWinnerId, deferVote, requiredScoreMultiplier, penaltyIdToClear };
|
||||
return { candidates: pool, forcedWinnerId, deferred, penaltyIdToClear };
|
||||
}
|
||||
|
||||
/* ── 합본 판정에 규약 효과를 얹는다 ── */
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
// 책상(Desk) 엔진 — ProjectSS_코어시스템_기획서.md §4.
|
||||
// 오염 카드는 덱에 들어가지 않는다. 드로우·셔플 없음, 슬롯 무제한. 여기서 다루는 desk는
|
||||
// 순수 배열 [{ id, axis, grade }] 이며, React state는 이 배열을 그대로 들고 있다가
|
||||
// 이 모듈의 함수로 파생값(축별 스택, 패널티)을 계산한다. React/훅 의존 없음 — 순수 함수만.
|
||||
// 오염 카드는 덱에 들어가지 않는다. 드로우·셔플 개념 없음, 슬롯 무제한. 여기서 다루는 desk는
|
||||
// 순수 배열 [{ id, axis, grade, name, flavor, tag }]이며, React state는 이 배열을 그대로 들고
|
||||
// 있다가 이 모듈의 함수로 파생값(주목도, 페널티)을 계산한다. React/훅 의존 없음 — 순수 함수만.
|
||||
//
|
||||
// 2026-09-15 결정 반영: 페널티 기준을 "카드 장수" → "등급 합계"로 전환하고 이를 주목도라 부른다.
|
||||
// 등급 편차가 1~10으로 커진 만큼, 장수만 세면 특급 기밀과 일반 문서가 똑같이 취급돼 압박이
|
||||
// 정직하지 않다. 등급 자체가 곧 가중치이므로 별도 가중치 테이블은 없다.
|
||||
//
|
||||
// 이월(§4.1): 합본에 사용되지 않은 카드는 소멸하지 않고 책상에 남는다. 별도 이월 시스템이
|
||||
// 없는 이유가 바로 이것 — "합본이 닿은 카드만 사라진다"는 규칙 하나로 자연히 성립한다.
|
||||
|
||||
import { AXES } from './symbols.js';
|
||||
import { AXES, AXIS_META, clampGrade } from './symbols.js';
|
||||
|
||||
let seq = 0;
|
||||
// meta: pollutionPool.js에서 뽑은 { name, flavor, tag } — 책상 카드가 실제 이름/사연을 갖게 한다.
|
||||
export const makeDeskCard = (axis, grade, meta = {}) => ({
|
||||
id: `desk-${Date.now().toString(36)}-${(seq++).toString(36)}`,
|
||||
axis,
|
||||
grade,
|
||||
grade: clampGrade(grade),
|
||||
name: meta.name || null,
|
||||
flavor: meta.flavor || null,
|
||||
tag: meta.tag || null,
|
||||
@@ -23,7 +30,7 @@ export function countByAxis(deskCards) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 축별 등급 분포. { axis_threat: {1: 2, 3: 1}, ... } — DeskBar의 축×등급 매트릭스 표시용. */
|
||||
/** 축별 등급 분포. { axis_threat: {7: 2, 3: 1}, ... } — DeskBar의 축×등급 표시용. */
|
||||
export function gradeBreakdownByAxis(deskCards) {
|
||||
const out = Object.fromEntries(AXES.map((a) => [a, {}]));
|
||||
deskCards.forEach((c) => {
|
||||
@@ -34,41 +41,50 @@ export function gradeBreakdownByAxis(deskCards) {
|
||||
|
||||
export const totalCount = (deskCards) => deskCards.length;
|
||||
|
||||
/* ── A. 게이지 상시 상승 (§4.2-A, 플레이테스트 피드백 반영 재조정) ──
|
||||
* 최초 구현은 "장수"만 셌는데, 1급 기밀이나 일반 문서나 똑같이 취급돼 위기감이 없었다.
|
||||
* 그래서 장수 대신 등급 가중 "오염 압력"(P)을 쓴다: 등급1=1, 등급2=2, 등급3=3, 등급4=5.
|
||||
* 구간도 초반 체감이 되도록 올렸다 — 정확한 수치는 가안, 플레이테스트로 계속 조정한다.
|
||||
*/
|
||||
const GRADE_WEIGHT = { 1: 1, 2: 2, 3: 3, 4: 5 };
|
||||
/* ── 주목도 (§4.2) — 페널티 산정의 단일 기준 ── */
|
||||
|
||||
/** 해당 축의 오염 압력(P) = Σ 카드별 등급 가중치. */
|
||||
export function axisPressure(deskCards, axis) {
|
||||
/** 해당 축의 주목도 = 그 축 카드들의 등급 합. */
|
||||
export function axisAttention(deskCards, axis) {
|
||||
return deskCards
|
||||
.filter((c) => c.axis === axis)
|
||||
.reduce((sum, c) => sum + (GRADE_WEIGHT[c.grade] || 0), 0);
|
||||
.reduce((sum, c) => sum + c.grade, 0);
|
||||
}
|
||||
|
||||
/** 책상 전체 주목도 = 모든 카드의 등급 합. */
|
||||
export function totalAttention(deskCards) {
|
||||
return deskCards.reduce((sum, c) => sum + c.grade, 0);
|
||||
}
|
||||
|
||||
export function attentionByAxis(deskCards) {
|
||||
const out = {};
|
||||
AXES.forEach((axis) => { out[axis] = axisAttention(deskCards, axis); });
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── A. 게이지 상시 상승 (§4.2-A) — 축별 주목도 기준 누진 ── */
|
||||
|
||||
const GAUGE_TIERS = [
|
||||
{ max: 0, rate: 0 },
|
||||
{ max: 2, rate: 1.0 },
|
||||
{ max: 6, rate: 2.0 },
|
||||
{ max: 12, rate: 3.5 },
|
||||
{ max: 20, rate: 6.0 },
|
||||
{ max: 30, rate: 9.0 },
|
||||
{ max: Infinity, rate: 13.0 },
|
||||
{ max: 15, rate: 0.5 },
|
||||
{ max: 30, rate: 1.5 },
|
||||
{ max: 40, rate: 3.0 },
|
||||
];
|
||||
const GAUGE_OVERRUN_FROM = 41;
|
||||
const GAUGE_OVERRUN_BASE = 5.0;
|
||||
|
||||
/** 오염 압력(P)에 따른 결재 1건당 게이지 상승률(%). */
|
||||
export function gaugeRiseRate(pressure) {
|
||||
const tier = GAUGE_TIERS.find((t) => pressure <= t.max);
|
||||
return tier ? tier.rate : 13.0;
|
||||
/** 축 주목도에 따른 결재 1건당 게이지 상승률(%). 41 이상은 초과분 10당 +1.0% 추가 누진. */
|
||||
export function gaugeRiseRate(attention) {
|
||||
const tier = GAUGE_TIERS.find((t) => attention <= t.max);
|
||||
if (tier) return tier.rate;
|
||||
const overrun = attention - GAUGE_OVERRUN_FROM;
|
||||
return GAUGE_OVERRUN_BASE + Math.floor(overrun / 10) * 1.0;
|
||||
}
|
||||
|
||||
/** 축별 현재 상승률(%) — DeskBar의 "▲+X%/결재" 표시에 그대로 쓴다.
|
||||
* extraFlat: 활성 징벌 규약(§9.2 유형C)이 얹는 가산분(council.js의 penaltyGaugeSurcharge). */
|
||||
export function gaugeRiseRateByAxis(deskCards, extraFlat = 0) {
|
||||
const out = {};
|
||||
AXES.forEach((axis) => { out[axis] = gaugeRiseRate(axisPressure(deskCards, axis)) + extraFlat; });
|
||||
AXES.forEach((axis) => { out[axis] = gaugeRiseRate(axisAttention(deskCards, axis)) + extraFlat; });
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -76,31 +92,48 @@ export function gaugeRiseRateByAxis(deskCards, extraFlat = 0) {
|
||||
export function applyPassiveGaugeRise(gauges, deskCards, extraFlat = 0) {
|
||||
const next = { ...gauges };
|
||||
AXES.forEach((axis) => {
|
||||
const gaugeKey = axisGaugeKey(axis);
|
||||
const rate = gaugeRiseRate(axisPressure(deskCards, axis)) + extraFlat;
|
||||
const gaugeKey = AXIS_META[axis].gauge;
|
||||
const rate = gaugeRiseRate(axisAttention(deskCards, axis)) + extraFlat;
|
||||
next[gaugeKey] = Math.min(100, (next[gaugeKey] || 0) + rate);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
// symbols.js의 AXIS_META를 다시 import하면 순환 우려가 없으므로 바로 가져와 쓴다.
|
||||
import { AXIS_META } from './symbols.js';
|
||||
function axisGaugeKey(axis) {
|
||||
return AXIS_META[axis].gauge;
|
||||
/* ── B. 라운드 종료 판단 (§4.2-B, 2026-09-16 결정 재수정) — 블랙잭식 하드 캡.
|
||||
* 라운드당 주목도 한도를 넘기면 그 즉시 라운드가 강제 종료된다(합본 기회 없이, 카드는 이월).
|
||||
* 한도 자체가 라운드가 갈수록 올라가 후반에는 더 크게 쌓아도 되는 대신 압박도 커진다.
|
||||
* 한도 밑에서는 플레이어가 아무 때나 자율적으로 라운드를 끝내고 정산으로 넘어갈 수 있다 —
|
||||
* "한 장 더 받을까 말까"의 긴장은 여기서 나온다. 결정 노트: 2026-09-16-라운드-주목도-하드캡-복원. */
|
||||
|
||||
const ROUND_CAP_BASE = 21; // 가안 — 최초 블랙잭 기준으로 복귀
|
||||
const ROUND_CAP_GROWTH = 5; // 라운드당 가산분, 가안
|
||||
|
||||
/** 라운드 번호에 따른 이번 라운드의 주목도 한도. */
|
||||
export function roundAttentionCap(round) {
|
||||
return ROUND_CAP_BASE + (round - 1) * ROUND_CAP_GROWTH;
|
||||
}
|
||||
|
||||
/* ── B. 토큰 지출 가산 (§4.2-B) — 총 적재 장수에 따라 모든 토큰 지출에 가산 ── */
|
||||
/** 한도 대비 총 주목도 구간: 'normal' | 'warning'(한도 5 이내로 근접) | 'bust'(한도 초과). */
|
||||
export function attentionZone(deskCards, cap) {
|
||||
const total = totalAttention(deskCards);
|
||||
if (total > cap) return 'bust';
|
||||
if (total >= cap - 5) return 'warning';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
/* ── C. 토큰 지출 가산 (§4.2-C) — 총 주목도 기준 ── */
|
||||
|
||||
const SURCHARGE_TIERS = [
|
||||
{ max: 7, surcharge: 0 },
|
||||
{ max: 14, surcharge: 1 },
|
||||
{ max: 21, surcharge: 2 },
|
||||
{ max: 20, surcharge: 0 },
|
||||
{ max: 40, surcharge: 1 },
|
||||
{ max: 60, surcharge: 2 },
|
||||
{ max: Infinity, surcharge: 4 },
|
||||
];
|
||||
|
||||
/** 총 적재 장수 기준 모든 토큰 지출에 붙는 가산액.
|
||||
/** 총 주목도 기준 모든 토큰 지출에 붙는 가산액.
|
||||
* extraFlat: 활성 징벌 규약(§9.2 유형C)이 얹는 가산분(council.js의 penaltyTokenSurcharge). */
|
||||
export function tokenSurcharge(deskCards, extraFlat = 0) {
|
||||
const total = totalCount(deskCards);
|
||||
const total = totalAttention(deskCards);
|
||||
const tier = SURCHARGE_TIERS.find((t) => total <= t.max);
|
||||
return (tier ? tier.surcharge : 4) + extraFlat;
|
||||
}
|
||||
|
||||
+147
-69
@@ -1,106 +1,184 @@
|
||||
// 합본(족보) 판정 · 실행 엔진 — ProjectSS_코어시스템_기획서.md §5, §7.1, §8.2.
|
||||
// 트럼프 족보 구조를 그대로 차용한다 (§5.1 설계 원칙). React/훅 의존 없는 순수 함수.
|
||||
//
|
||||
// 문서 내부 불일치 메모: §7.1은 "수량 3장=1/4장=2/5장 이상=3"이라는 일반 공식을 제시하지만
|
||||
// §5.1 표의 실제 명분 값(예: 풀하우스 2, 포카드 3, 스트레이트 플러시 3)과 맞지 않는다.
|
||||
// 여기서는 §5.1 표의 고정값을 그대로 채택했다 — 구체적인 족보표가 일반 공식보다 우선한다고 봤다.
|
||||
// 1급 기밀(등급4) 포함 시 +1 규칙만 §7.1에서 그대로 가져와 표 값 위에 얹었다.
|
||||
// 2026-09-15 결정 반영 (3장 4등급 → 5장 10등급):
|
||||
// - 족보를 5장 포커 핸드 8종으로 재정의. 투페어(이중 접수) 신설.
|
||||
// - §5.1 "여러 장 쌓은 카드 중 최고의 5장을 뽑는 방식"에 맞춰, 판정 방식을 바꿨다.
|
||||
// 구 구현은 "선택한 카드가 정확히 족보 크기와 일치해야 성립"하는 엄격 매칭이었지만,
|
||||
// 이제는 선택 집합 안에서 족보를 이루는 카드를 찾아낸다. 그래서 5장을 골라도 페어만
|
||||
// 성립하면 페어로 상신할 수 있고, 그 경우 족보에 실제로 쓰인 2장만 소멸한다
|
||||
// (나머지는 §4.1 이월 원칙에 따라 책상에 남는다).
|
||||
// - 토큰은 1종이고 산출량은 등급 합계다 (§8.2). 축은 명분 종류만 결정한다 (§7.2).
|
||||
// - 명분 +1 보너스 기준이 "등급 4" → "등급 8 이상"으로 바뀌었다 (§7.1).
|
||||
|
||||
import { justificationOf, tokenOf } from './symbols.js';
|
||||
import { justificationOf, TOP_SECRET_THRESHOLD } from './symbols.js';
|
||||
|
||||
export const MIN_COMBINE_CARDS = 2;
|
||||
export const MAX_COMBINE_CARDS = 5;
|
||||
|
||||
export const HAND_DEFS = {
|
||||
pair: { id: 'pair', name: '중복 접수', size: [2, 2], baseJustification: 0.5, score: 10 },
|
||||
triple: { id: 'triple', name: '집단 민원', size: [3, 3], baseJustification: 1, score: 25 },
|
||||
flush_axis: { id: 'flush_axis', name: '일괄 처리', size: [3, 3], baseJustification: 1, score: 25 },
|
||||
straight: { id: 'straight', name: '단계적 상신', size: [3, 3], baseJustification: 1, score: 25 },
|
||||
full_house: { id: 'full_house', name: '종합 감사', size: [5, 5], baseJustification: 2, score: 50 },
|
||||
four_kind: { id: 'four_kind', name: '전면 개편', size: [4, 4], baseJustification: 3, score: 80 },
|
||||
straight_flush: { id: 'straight_flush', name: '전결 처리', size: [3, 4], baseJustification: 3, score: 80 },
|
||||
pair: { id: 'pair', name: '중복 접수', cards: 2, baseJustification: 0.5, score: 10, condition: '같은 등급 2장' },
|
||||
two_pair: { id: 'two_pair', name: '이중 접수', cards: 4, baseJustification: 1, score: 20, condition: '같은 등급 2장 × 2쌍' },
|
||||
triple: { id: 'triple', name: '집단 민원', cards: 3, baseJustification: 1, score: 25, condition: '같은 등급 3장' },
|
||||
straight: { id: 'straight', name: '단계적 상신', cards: 5, baseJustification: 2, score: 40, condition: '연속 등급 5장 (축 무관)' },
|
||||
flush_axis: { id: 'flush_axis', name: '일괄 처리', cards: 5, baseJustification: 2, score: 40, condition: '같은 축 5장' },
|
||||
full_house: { id: 'full_house', name: '종합 감사', cards: 5, baseJustification: 3, score: 60, condition: '같은 등급 3장 + 같은 등급 2장' },
|
||||
four_kind: { id: 'four_kind', name: '전면 개편', cards: 4, baseJustification: 4, score: 90, condition: '같은 등급 4장' },
|
||||
straight_flush: { id: 'straight_flush', name: '전결 처리', cards: 5, baseJustification: 5, score: 150, condition: '같은 축 + 연속 등급 5장' },
|
||||
};
|
||||
|
||||
// 족보 우선순위(높을수록 상위) — 동시 성립 시 기본 선택값과 정렬 순서에 쓴다.
|
||||
const HAND_RANK = ['pair', 'triple', 'flush_axis', 'straight', 'full_house', 'four_kind', 'straight_flush'];
|
||||
// 족보 우선순위(뒤로 갈수록 상위). 실적 점수 순이며, straight와 flush_axis가 동점(40)일 때는
|
||||
// 트럼프 관례대로 플러시를 상위로 둔다.
|
||||
const HAND_RANK = [
|
||||
'pair', 'two_pair', 'triple', 'straight', 'flush_axis', 'full_house', 'four_kind', 'straight_flush',
|
||||
];
|
||||
|
||||
const sameGrade = (cards) => cards.every((c) => c.grade === cards[0].grade);
|
||||
const sameAxis = (cards) => cards.every((c) => c.axis === cards[0].axis);
|
||||
const sumGrades = (cards) => cards.reduce((s, c) => s + c.grade, 0);
|
||||
|
||||
/** 등급이 중복 없이 연속 오름차순인가 (예: 1,2,3 / 2,3,4). §5.1 "등급 오름차순". */
|
||||
function isAscendingRun(cards) {
|
||||
const grades = [...cards.map((c) => c.grade)].sort((a, b) => a - b);
|
||||
if (new Set(grades).size !== grades.length) return false;
|
||||
for (let i = 1; i < grades.length; i++) {
|
||||
if (grades[i] !== grades[i - 1] + 1) return false;
|
||||
}
|
||||
return true;
|
||||
/** 등급 → 그 등급의 카드 배열. */
|
||||
function groupByGrade(cards) {
|
||||
const m = new Map();
|
||||
cards.forEach((c) => {
|
||||
if (!m.has(c.grade)) m.set(c.grade, []);
|
||||
m.get(c.grade).push(c);
|
||||
});
|
||||
return m;
|
||||
}
|
||||
|
||||
function gradeCounts(cards) {
|
||||
const m = {};
|
||||
cards.forEach((c) => { m[c.grade] = (m[c.grade] || 0) + 1; });
|
||||
return Object.values(m).sort((a, b) => b - a);
|
||||
/** 축 → 그 축의 카드 배열. */
|
||||
function groupByAxis(cards) {
|
||||
const m = new Map();
|
||||
cards.forEach((c) => {
|
||||
if (!m.has(c.axis)) m.set(c.axis, []);
|
||||
m.get(c.axis).push(c);
|
||||
});
|
||||
return m;
|
||||
}
|
||||
|
||||
/** 등급이 n장 이상 모인 등급들을 내림차순으로 (등급 합을 최대화하려면 높은 등급이 유리). */
|
||||
function gradesWithAtLeast(byGrade, n) {
|
||||
return [...byGrade.entries()]
|
||||
.filter(([, cards]) => cards.length >= n)
|
||||
.map(([grade]) => grade)
|
||||
.sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
/** 연속 등급 5장을 찾는다. 중복 등급은 한 장만 쓴다. 등급 합이 가장 큰 구간을 고른다. */
|
||||
function findRun(cards, length = 5) {
|
||||
const byGrade = groupByGrade(cards);
|
||||
const grades = [...byGrade.keys()].sort((a, b) => a - b);
|
||||
let best = null;
|
||||
for (let i = 0; i + length - 1 < grades.length; i++) {
|
||||
const window = grades.slice(i, i + length);
|
||||
const isConsecutive = window.every((g, k) => k === 0 || g === window[k - 1] + 1);
|
||||
if (!isConsecutive) continue;
|
||||
// 각 등급에서 한 장씩 (같은 등급이 여러 장이면 아무거나 — 등급이 같으므로 합도 같다)
|
||||
const picked = window.map((g) => byGrade.get(g)[0]);
|
||||
if (!best || sumGrades(picked) > sumGrades(best)) best = picked;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택된 책상 카드 묶음이 어떤 족보(들)로 성립하는지 판정한다.
|
||||
* 여러 족보가 동시 성립할 수 있다 (§5.3) — 예: 같은 축 + 연속 등급 3장은
|
||||
* flush_axis / straight / straight_flush 세 가지 모두로 성립하며, 플레이어가 하나를 고른다.
|
||||
* @returns {string[]} 성립하는 HAND_DEFS id 배열, 실적 점수 내림차순 정렬
|
||||
* 선택 집합에서 지정 족보를 이루는 카드들을 찾는다. 성립하지 않으면 null.
|
||||
* 여러 조합이 가능하면 등급 합이 큰 쪽을 고른다 — 토큰 산출이 등급 합계이므로 그게 이득이다.
|
||||
*/
|
||||
export function findHandCards(selection, patternId) {
|
||||
const byGrade = groupByGrade(selection);
|
||||
|
||||
switch (patternId) {
|
||||
case 'pair': {
|
||||
const g = gradesWithAtLeast(byGrade, 2)[0];
|
||||
return g === undefined ? null : byGrade.get(g).slice(0, 2);
|
||||
}
|
||||
case 'triple': {
|
||||
const g = gradesWithAtLeast(byGrade, 3)[0];
|
||||
return g === undefined ? null : byGrade.get(g).slice(0, 3);
|
||||
}
|
||||
case 'four_kind': {
|
||||
const g = gradesWithAtLeast(byGrade, 4)[0];
|
||||
return g === undefined ? null : byGrade.get(g).slice(0, 4);
|
||||
}
|
||||
case 'two_pair': {
|
||||
const pairs = gradesWithAtLeast(byGrade, 2);
|
||||
if (pairs.length < 2) return null;
|
||||
return [...byGrade.get(pairs[0]).slice(0, 2), ...byGrade.get(pairs[1]).slice(0, 2)];
|
||||
}
|
||||
case 'full_house': {
|
||||
const triples = gradesWithAtLeast(byGrade, 3);
|
||||
const pairs = gradesWithAtLeast(byGrade, 2);
|
||||
let best = null;
|
||||
triples.forEach((tg) => {
|
||||
pairs.filter((pg) => pg !== tg).forEach((pg) => {
|
||||
const picked = [...byGrade.get(tg).slice(0, 3), ...byGrade.get(pg).slice(0, 2)];
|
||||
if (!best || sumGrades(picked) > sumGrades(best)) best = picked;
|
||||
});
|
||||
});
|
||||
return best;
|
||||
}
|
||||
case 'flush_axis': {
|
||||
const byAxis = groupByAxis(selection);
|
||||
let best = null;
|
||||
byAxis.forEach((cards) => {
|
||||
if (cards.length < 5) return;
|
||||
const picked = [...cards].sort((a, b) => b.grade - a.grade).slice(0, 5);
|
||||
if (!best || sumGrades(picked) > sumGrades(best)) best = picked;
|
||||
});
|
||||
return best;
|
||||
}
|
||||
case 'straight':
|
||||
return findRun(selection, 5);
|
||||
case 'straight_flush': {
|
||||
const byAxis = groupByAxis(selection);
|
||||
let best = null;
|
||||
byAxis.forEach((cards) => {
|
||||
const run = findRun(cards, 5);
|
||||
if (run && (!best || sumGrades(run) > sumGrades(best))) best = run;
|
||||
});
|
||||
return best;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 집합으로 성립하는 족보(들)를 판정한다.
|
||||
* 여러 족보가 동시 성립할 수 있다 (§5.3) — 예: 같은 축 연속 5장은 straight_flush / flush_axis /
|
||||
* straight 셋 다 성립하며, 플레이어가 하나를 고른다(명분 종류를 고르려고 일부러 하위를 쓰는 선택).
|
||||
* @returns {string[]} 성립하는 HAND_DEFS id 배열, 상위 족보 우선 정렬
|
||||
*/
|
||||
export function detectHands(selection) {
|
||||
const n = selection.length;
|
||||
const matches = new Set();
|
||||
|
||||
if (n === 2 && sameGrade(selection)) matches.add('pair');
|
||||
|
||||
if (n === 3) {
|
||||
if (sameGrade(selection)) matches.add('triple');
|
||||
if (sameAxis(selection)) matches.add('flush_axis');
|
||||
if (isAscendingRun(selection)) matches.add('straight');
|
||||
if (sameAxis(selection) && isAscendingRun(selection)) matches.add('straight_flush');
|
||||
}
|
||||
|
||||
if (n === 4) {
|
||||
if (sameGrade(selection)) matches.add('four_kind');
|
||||
if (sameAxis(selection) && isAscendingRun(selection)) matches.add('straight_flush');
|
||||
}
|
||||
|
||||
if (n === 5) {
|
||||
const counts = gradeCounts(selection);
|
||||
if (counts[0] === 3 && counts[1] === 2) matches.add('full_house');
|
||||
}
|
||||
|
||||
return [...matches].sort((a, b) => HAND_RANK.indexOf(b) - HAND_RANK.indexOf(a));
|
||||
if (selection.length < MIN_COMBINE_CARDS || selection.length > MAX_COMBINE_CARDS) return [];
|
||||
return HAND_RANK
|
||||
.filter((id) => findHandCards(selection, id) !== null)
|
||||
.sort((a, b) => HAND_RANK.indexOf(b) - HAND_RANK.indexOf(a));
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 족보로 합본을 실행한 결과를 계산한다 (책상에서 실제로 지우는 건 호출부 책임).
|
||||
* @param {Array<{id,axis,grade}>} selection
|
||||
* @param {string} patternId - HAND_DEFS의 key
|
||||
* removeIds에는 족보를 이룬 카드만 담긴다 — 선택했지만 족보에 쓰이지 않은 카드는 책상에 남는다.
|
||||
*/
|
||||
export function resolveCombine(selection, patternId) {
|
||||
const def = HAND_DEFS[patternId];
|
||||
if (!def) return null;
|
||||
const [minSize, maxSize] = def.size;
|
||||
if (selection.length < minSize || selection.length > maxSize) return null;
|
||||
const used = findHandCards(selection, patternId);
|
||||
if (!used) return null;
|
||||
|
||||
const uniformAxis = sameAxis(selection) ? selection[0].axis : null;
|
||||
const uniformAxis = used.every((c) => c.axis === used[0].axis) ? used[0].axis : null;
|
||||
const justificationType = uniformAxis ? justificationOf(uniformAxis) : '혼합';
|
||||
const hasTopSecret = selection.some((c) => c.grade === 4);
|
||||
const hasTopSecret = used.some((c) => c.grade >= TOP_SECRET_THRESHOLD);
|
||||
const justificationAmount = def.baseJustification + (hasTopSecret ? 1 : 0);
|
||||
|
||||
// 토큰 산출 (§8.2): 축이 하나로 통일된 합본만 그 축의 전용 토큰을 낸다.
|
||||
// 위협 축(토큰 미지정) 또는 축이 섞인 합본은 "소량 전종" 규칙(§8.2 위협 축 대안)을 공통 적용한다.
|
||||
// 정확한 배분량은 미확정 수치이며 플레이 테스트로 튜닝할 가안이다.
|
||||
const axisToken = uniformAxis ? tokenOf(uniformAxis) : null;
|
||||
const tokens = axisToken
|
||||
? { [axisToken]: selection.length }
|
||||
: { budget: 1, personnel: 1, info: 1 };
|
||||
// §8.2: 토큰은 1종, 산출량은 합본에 사용된 카드의 기밀 등급 합계.
|
||||
const tokens = sumGrades(used);
|
||||
|
||||
return {
|
||||
pattern: def,
|
||||
usedCards: used,
|
||||
justification: { type: justificationType, amount: justificationAmount },
|
||||
tokens,
|
||||
performanceScore: def.score,
|
||||
removeIds: selection.map((c) => c.id),
|
||||
removeIds: used.map((c) => c.id),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,13 +10,23 @@ const POOL = Papa.parse(poolCsvRaw, { header: true, skipEmptyLines: true }).data
|
||||
|
||||
/**
|
||||
* 주어진 축·등급에 맞는 오염 카드 항목을 하나 뽑는다.
|
||||
* 정확히 맞는 등급이 없으면 같은 축 안에서 아무거나 폴백한다 (템플릿 물량이 등급별로
|
||||
* 고르지 않을 수 있어서다 — pollution_pool.csv는 등급당 딱 1~2개뿐이다).
|
||||
* 등급이 1~10으로 확장된 뒤 pollution_pool.csv는 축당 몇 개 등급대만 갖고 있으므로,
|
||||
* 정확히 일치하는 등급이 없으면 **가장 가까운 등급**의 플레이버를 쓴다. 아무거나 폴백하면
|
||||
* 등급 10 사안에 "초과근무 항의" 같은 저강도 서술이 붙어 심각도와 텍스트가 어긋난다.
|
||||
* (등급대별 신규 오염 카드 텍스트는 CLAUDE.md "데이터 파일 다루는 법"에 따라 소량 배치로 채운다.)
|
||||
*/
|
||||
export function pickPollutionEntry(axis, grade) {
|
||||
const exact = POOL.filter((r) => r.axis === axis && Number(r.grade) === Number(grade));
|
||||
const pool = exact.length > 0 ? exact : POOL.filter((r) => r.axis === axis);
|
||||
if (pool.length === 0) return null;
|
||||
const row = pool[Math.floor(Math.random() * pool.length)];
|
||||
const sameAxis = POOL.filter((r) => r.axis === axis);
|
||||
if (sameAxis.length === 0) return null;
|
||||
|
||||
const target = Number(grade);
|
||||
let bestDistance = Infinity;
|
||||
sameAxis.forEach((r) => {
|
||||
const d = Math.abs(Number(r.grade) - target);
|
||||
if (d < bestDistance) bestDistance = d;
|
||||
});
|
||||
const nearest = sameAxis.filter((r) => Math.abs(Number(r.grade) - target) === bestDistance);
|
||||
|
||||
const row = nearest[Math.floor(Math.random() * nearest.length)];
|
||||
return { name: row.name, flavor: row.flavor, tag: row.tag || null };
|
||||
}
|
||||
|
||||
@@ -3,19 +3,24 @@
|
||||
// 그래서 2차에서 성립하는 품목은 "오염 카드 직접 처리"(§8.3 표: 족보가 안 맞을 때의 탈출구,
|
||||
// 명분·실적 없음) 하나뿐이다 — 이 자체가 §17 로드맵 순서를 지키기 위한 의도적 축소다.
|
||||
// 진열 규칙(무작위 4~6개, 1회 리롤 가능·토큰 소모)만 그대로 §8.3을 따른다.
|
||||
//
|
||||
// 2026-09-15 결정 반영: 토큰 1종 통합(§8.2, §19). 가격은 단일 숫자다.
|
||||
// 등급 1~10 확장에 맞춰 처리 비용도 등급에 비례한다 — 합본하면 등급 합계만큼 토큰을 벌지만
|
||||
// 직접 처리하면 그만큼 잃으므로, 탈출구를 쓰는 대가가 등급에 정직하게 비례한다.
|
||||
|
||||
export const SHOP_DISPLAY_MIN = 4;
|
||||
export const SHOP_DISPLAY_MAX = 6;
|
||||
export const REROLL_COST = { budget: 3 };
|
||||
export const REROLL_COST = 3;
|
||||
|
||||
/** 오염 카드 1장을 직접 처리(소각)하는 가격 — 등급이 높을수록, 책상이 꽉 찰수록 비싸진다. */
|
||||
export function purgePrice(card, currentSurcharge) {
|
||||
return { budget: card.grade * 3 + currentSurcharge };
|
||||
/** 오염 카드 1장을 직접 처리(소각)하는 가격 — 등급만큼, 책상이 무거우면(지출 가산) 더 비싸다. */
|
||||
export function purgePrice(card, currentSurcharge = 0) {
|
||||
return card.grade + currentSurcharge;
|
||||
}
|
||||
|
||||
/** 책상 카드 중 무작위로 4~6장(또는 그 미만이면 전부)을 진열한다. */
|
||||
export function rollShopDisplay(deskCards) {
|
||||
const n = Math.min(deskCards.length, SHOP_DISPLAY_MIN + Math.floor(Math.random() * (SHOP_DISPLAY_MAX - SHOP_DISPLAY_MIN + 1)));
|
||||
const span = SHOP_DISPLAY_MAX - SHOP_DISPLAY_MIN + 1;
|
||||
const n = Math.min(deskCards.length, SHOP_DISPLAY_MIN + Math.floor(Math.random() * span));
|
||||
const pool = [...deskCards];
|
||||
const out = [];
|
||||
while (out.length < n && pool.length > 0) {
|
||||
@@ -24,12 +29,4 @@ export function rollShopDisplay(deskCards) {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function canAfford(tokens, price) {
|
||||
return Object.entries(price).every(([k, v]) => (tokens[k] || 0) >= v);
|
||||
}
|
||||
|
||||
export function payTokens(tokens, price) {
|
||||
const next = { ...tokens };
|
||||
Object.entries(price).forEach(([k, v]) => { next[k] = (next[k] || 0) - v; });
|
||||
return next;
|
||||
}
|
||||
export const canAfford = (tokens, price) => tokens >= price;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// 기호 체계 (ProjectSS_코어시스템_기획서.md §3) — 모든 카드가 공유하는 단일 기호 언어.
|
||||
// 스와이프 판단은 텍스트가 아니라 이 기호(축 인장 + 기밀 등급)만으로 0.5초 내에 이루어져야 한다.
|
||||
// 색상만으로 구분하지 말 것 — 아이콘 형태(실루엣) 자체가 축을 구분해야 한다 (§3.1).
|
||||
//
|
||||
// 2026-09-15 결정 반영: 기밀 등급 1~4 → 1~10 확장, 점(●) 표기 폐기.
|
||||
// 카드 중앙에 큰 숫자, 축 인장은 모서리에 작게 — 트럼프 랭크+슈트 레이아웃 (§3.2).
|
||||
// 토큰은 1종으로 통합됐으므로(§8.2, §19) 축은 더 이상 토큰 종류를 결정하지 않는다.
|
||||
// 축이 결정하는 것은 "명분 종류"(질)뿐이고, 토큰 양(양)은 등급 합계가 결정한다 (§7.2).
|
||||
|
||||
export const AXES = ['axis_threat', 'axis_hr', 'axis_pr', 'axis_external'];
|
||||
|
||||
// 인장(아이콘)은 lucide-react의 Tornado/Hand/Megaphone/Stamp를 그대로 쓴다.
|
||||
// 실제 아이콘 컴포넌트 매핑은 AxisIcon.jsx에서 담당 — 여기는 축의 이름·게이지·명분·토큰 매핑만.
|
||||
// 실제 아이콘 컴포넌트 매핑은 SymbolBadge.jsx에서 담당 — 여기는 축의 이름·게이지·명분 매핑만.
|
||||
export const AXIS_META = {
|
||||
axis_threat: {
|
||||
name: '위협',
|
||||
@@ -13,7 +18,6 @@ export const AXIS_META = {
|
||||
gauge: 'entropy',
|
||||
gaugeName: '엔트로피',
|
||||
justification: '위기',
|
||||
token: null, // §8.2: 위협 축은 플레이어 선택(또는 소량 전종) — economy.js에서 처리
|
||||
},
|
||||
axis_hr: {
|
||||
name: '인사',
|
||||
@@ -21,7 +25,6 @@ export const AXIS_META = {
|
||||
gauge: 'resistance',
|
||||
gaugeName: '조직 저항',
|
||||
justification: '내부',
|
||||
token: 'personnel',
|
||||
},
|
||||
axis_pr: {
|
||||
name: '공보',
|
||||
@@ -29,7 +32,6 @@ export const AXIS_META = {
|
||||
gauge: 'panic',
|
||||
gaugeName: '대중 공황',
|
||||
justification: '여론',
|
||||
token: 'info',
|
||||
},
|
||||
axis_external: {
|
||||
name: '대외',
|
||||
@@ -37,26 +39,32 @@ export const AXIS_META = {
|
||||
gauge: 'risk',
|
||||
gaugeName: '위험 수용 계수',
|
||||
justification: '절차',
|
||||
token: 'budget',
|
||||
},
|
||||
};
|
||||
|
||||
export const GRADE_MIN = 1;
|
||||
export const GRADE_MAX = 4;
|
||||
export const GRADE_MAX = 10;
|
||||
|
||||
export const GRADE_NAME = {
|
||||
1: '일반',
|
||||
2: '대외비',
|
||||
3: '2급 기밀',
|
||||
4: '1급 기밀',
|
||||
// 등급 범위 명칭 (§3.2). 서사·문체용 라벨일 뿐이며, 합본 판정·주목도 계산 등
|
||||
// 모든 게임 로직은 항상 원 숫자(1~10)를 쓴다.
|
||||
const GRADE_BANDS = [
|
||||
{ max: 2, name: '일반' },
|
||||
{ max: 4, name: '대외비' },
|
||||
{ max: 6, name: '2급 기밀' },
|
||||
{ max: 8, name: '1급 기밀' },
|
||||
{ max: 10, name: '특급 기밀' },
|
||||
];
|
||||
|
||||
export const gradeBandName = (grade) => {
|
||||
const g = clampGrade(grade);
|
||||
return GRADE_BANDS.find((b) => g <= b.max)?.name ?? '일반';
|
||||
};
|
||||
|
||||
// 숫자가 아닌 점 개수로 표기한다 (§3.2) — "●●●○" 형태 문자열을 만들어 준다.
|
||||
export const gradeDots = (grade) => {
|
||||
const g = Math.max(GRADE_MIN, Math.min(GRADE_MAX, grade || 0));
|
||||
return '●'.repeat(g) + '○'.repeat(GRADE_MAX - g);
|
||||
};
|
||||
export const clampGrade = (grade) =>
|
||||
Math.max(GRADE_MIN, Math.min(GRADE_MAX, Math.round(grade || GRADE_MIN)));
|
||||
|
||||
// 명분 +1 보너스 기준 등급 (§7.1: "등급 8 이상(1급 기밀 이상) 카드가 포함되면 +1")
|
||||
export const TOP_SECRET_THRESHOLD = 8;
|
||||
|
||||
export const gaugeOf = (axis) => AXIS_META[axis]?.gauge ?? null;
|
||||
export const justificationOf = (axis) => AXIS_META[axis]?.justification ?? '혼합';
|
||||
export const tokenOf = (axis) => AXIS_META[axis]?.token ?? null;
|
||||
|
||||
Reference in New Issue
Block a user