등급 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;
|
||||
|
||||
+32
-32
@@ -1,32 +1,32 @@
|
||||
id,type,keyword,question,text,flavor,left_text,left_axis,left_grade,left_tag,right_text,right_axis,right_grade,right_tag
|
||||
1,일상,요원들의 시위 발생,시위를 어떻게 처리하시겠습니까?,B구역 하급 요원들이 예산 삭감에 반발해 장비 반납 시위를 하고 있습니다.,우리가 세상을 구하는데 월급이 깎인다고요? 촉수에 맞아 죽으나 굶어 죽으나 똑같습니다!,무력 진압,axis_hr,1,tag_ruthless,요구 수용,,,
|
||||
4,극비,변이 DNA 이식 제안,실험을 승인하시겠습니까?,진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠.,인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠.,실험 승인,axis_threat,2,tag_madness,제안 거절,axis_hr,1,
|
||||
5,작전,전술 폭격 건의,폭격을 승인하시겠습니까?,철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다.,희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠.,궤도 폭격 승인,axis_external,3,tag_sacrifice,작전 기각,axis_hr,1,
|
||||
6,일상,부산물 암시장 매각,매각을 허가하시겠습니까?,차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다.,이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠.,매각 허가,axis_external,1,tag_corruption,원칙대로 소각,,,
|
||||
7,작전,존재 양지화 프로젝트,프로젝트를 승인하시겠습니까?,대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다.,우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다.,감시망 구축,,,tag_coverup,프로젝트 보류,axis_hr,1,
|
||||
9,위기,이단 색출 마녀사냥,어떻게 개입하시겠습니까?,기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다.,더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로!,적당히 묵인한다,axis_hr,1,,군사력으로 제압,axis_external,1,
|
||||
10,일상,변칙 현상 영상 유출,어떻게 처리하시겠습니까?,한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다.,대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오.,제거 절차 승인,,,tag_coverup,영상만 삭제,axis_threat,1,
|
||||
12,위기,의회의 긴급 감사,어떻게 대처하시겠습니까?,의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다!,세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오.,은폐 공작 실행,axis_external,1,tag_corruption,횡령 사실 인정,axis_external,3,
|
||||
13,일상,연구 부서 파업 조짐,요구를 수용하시겠습니까?,연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다.,저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요.,특별 보너스 지급,,,,요구 전면 묵살,axis_hr,1,
|
||||
14,작전,이계 무역로 개방,무역로를 개방하시겠습니까?,이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다.,저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠.,게이트 강제 개방,axis_threat,1,tag_alien_contact,폐쇄 상태 유지,axis_hr,1,
|
||||
15,극비,전임 국장 비밀 금고,금고를 개방하시겠습니까?,전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다.,전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다.,강제 개방 지시,,,tag_corruption,영구 봉인,,,
|
||||
16,작전,메가코프 협박 작전,작전을 실행하시겠습니까?,글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다.,그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까.,작전 실행,,,,작전 취소,axis_hr,1,
|
||||
17,위기,전술 폭격 건의,폭격을 승인하시겠습니까?,제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요?,시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다.,시민 포기 및 폭격,axis_external,3,tag_ruthless,결사 방어 명령,,,
|
||||
18,일상,행정부 파업 예고,어떻게 대응하시겠습니까?,새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다.,기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다.,예산 전용으로 무마,axis_external,1,,파업 강경 대응,axis_hr,1,
|
||||
19,극비,불침번 요원 개발,배치하시겠습니까?,비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다.,잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠.,실전 즉각 배치,axis_threat,1,,즉각 폐기 처분,axis_hr,1,
|
||||
20,일상,내부 스파이 첩보,어떻게 조사하시겠습니까?,기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다.,누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다.,대규모 사찰 시작,axis_hr,3,tag_political_pressure,증거 부족으로 덮음,axis_external,1,
|
||||
25,극비,내부 고발자 발생,어떻게 막으시겠습니까?,의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다.,국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다.,사고사 위장 지시,,,tag_coverup,폭로전 정면 돌파,axis_external,2,
|
||||
26,위기,차원 붕괴 폭탄 오작동,어떻게 대응하시겠습니까?,차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다.,최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다.,특수 해체팀 투입,,,tag_sacrifice,폭발 방관 및 대피,axis_threat,3,
|
||||
27,작전,PMC 흡수 합병,결탁하시겠습니까?,군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다.,돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠.,결탁 및 흡수 합병,,,,독자적 노선 유지,axis_hr,1,
|
||||
28,작전,대규모 거주 구역 실험장화,제안을 승인하시겠습니까?,변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다.,시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠.,돔 건설 강행,axis_threat,1,,윤리적 이유로 기각,axis_external,1,
|
||||
30,일상,믹스 커피 재고 고갈,어떻게 해결하시겠습니까?,국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다.,종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까?,최고급 원두 지원,,,,헛소리 금지령,axis_hr,1,
|
||||
31,위기,신화적 존재 강림,어떻게 대적하시겠습니까?,신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다!,최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까?,현실 조작기 가동,axis_threat,4,tag_madness,모든 예비병력 투입,axis_external,1,tag_sacrifice
|
||||
2,위기,격리실 오염 발생,오염을 어떻게 정화하시겠습니까?,지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다.,보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다.,네이팜 소각 강행,,,,조사팀 파견,axis_threat,1,
|
||||
3,위기,촉수 괴물 출현,출동 승인하시겠습니까?,7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다.,규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다.,내역 위조 후 출동,axis_external,1,,규정대로 반려,axis_threat,1,
|
||||
8,극비,대규모 도서관 건립,예산을 배정하시겠습니까?,고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다.,이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다.,예산 배정,,,,예산 전액 삭감,axis_external,1,
|
||||
11,위기,사이비 종교 인신 공양,어떻게 대응하시겠습니까?,특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다.,위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다.,군사 개입 지시,axis_hr,1,,의식 방관,axis_threat,2,
|
||||
21,작전,비정상적 위기 발생,국장님 결단이 필요합니다,순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다.,괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다.,수용소 건립 승인,,,,제안 단호히 거절,axis_external,1,
|
||||
22,극비,이계의 유물 인양,유물을 어떻게 처리하시겠습니까?,고대 공존당의 사제들이 심해의 유물을 인양했습니다. 만지는 자의 정신을 갉아먹지만 막대한 지식을 줍니다.,약간의 두통만 참으면 이계의 비밀을 모두 알 수 있습니다. 요원 하나쯤 미쳐도 괜찮습니다.,유물 집중 연구,axis_threat,1,,유물 파괴 명령,axis_external,1,
|
||||
23,위기,이계의 주시자 출현,대중을 어떻게 통제하시겠습니까?,얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다.,하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오.,인터넷 전면 차단,axis_pr,1,,진정제 무상 배포,,,
|
||||
24,일상,요원 집단 트라우마,어떻게 지원하시겠습니까?,요원들이 괴수 진압 후 심각한 트라우마에 시달리고 있습니다. 심리 치료 지원이 시급합니다.,밤마다 촉수가 목을 조르는 악몽을 꾼답니다. 차라리 그 부분 기억을 지워버릴까요?,정신과 치료 지원,,,,트라우마 기억 소거,axis_external,1,
|
||||
29,극비,이계 군주의 외교,제안을 수락하시겠습니까?,이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다.,요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다.,거래 즉시 성사,,,,사절단 전원 사살,axis_threat,1,
|
||||
id,type,keyword,question,text,flavor,left_text,left_axis,left_grade,left_tag,right_text,right_axis,right_grade,right_tag
|
||||
1,일상,요원들의 시위 발생,시위를 어떻게 처리하시겠습니까?,B구역 하급 요원들이 예산 삭감에 반발해 장비 반납 시위를 하고 있습니다.,우리가 세상을 구하는데 월급이 깎인다고요? 촉수에 맞아 죽으나 굶어 죽으나 똑같습니다!,무력 진압,axis_hr,1,tag_ruthless,요구 수용,,,
|
||||
4,극비,변이 DNA 이식 제안,실험을 승인하시겠습니까?,진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠.,인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠.,실험 승인,axis_threat,9,tag_madness,제안 거절,axis_hr,7,
|
||||
5,작전,전술 폭격 건의,폭격을 승인하시겠습니까?,철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다.,희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠.,궤도 폭격 승인,axis_external,5,tag_sacrifice,작전 기각,axis_hr,2,
|
||||
6,일상,부산물 암시장 매각,매각을 허가하시겠습니까?,차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다.,이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠.,매각 허가,axis_external,1,tag_corruption,원칙대로 소각,,,
|
||||
7,작전,존재 양지화 프로젝트,프로젝트를 승인하시겠습니까?,대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다.,우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다.,감시망 구축,,,tag_coverup,프로젝트 보류,axis_hr,2,
|
||||
9,위기,이단 색출 마녀사냥,어떻게 개입하시겠습니까?,기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다.,더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로!,적당히 묵인한다,axis_hr,4,,군사력으로 제압,axis_external,4,
|
||||
10,일상,변칙 현상 영상 유출,어떻게 처리하시겠습니까?,한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다.,대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오.,제거 절차 승인,,,tag_coverup,영상만 삭제,axis_threat,1,
|
||||
12,위기,의회의 긴급 감사,어떻게 대처하시겠습니까?,의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다!,세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오.,은폐 공작 실행,axis_external,4,tag_corruption,횡령 사실 인정,axis_external,8,
|
||||
13,일상,연구 부서 파업 조짐,요구를 수용하시겠습니까?,연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다.,저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요.,특별 보너스 지급,,,,요구 전면 묵살,axis_hr,1,
|
||||
14,작전,이계 무역로 개방,무역로를 개방하시겠습니까?,이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다.,저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠.,게이트 강제 개방,axis_threat,2,tag_alien_contact,폐쇄 상태 유지,axis_hr,2,
|
||||
15,극비,전임 국장 비밀 금고,금고를 개방하시겠습니까?,전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다.,전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다.,강제 개방 지시,,,tag_corruption,영구 봉인,,,
|
||||
16,작전,메가코프 협박 작전,작전을 실행하시겠습니까?,글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다.,그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까.,작전 실행,,,,작전 취소,axis_hr,2,
|
||||
17,위기,전술 폭격 건의,폭격을 승인하시겠습니까?,제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요?,시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다.,시민 포기 및 폭격,axis_external,8,tag_ruthless,결사 방어 명령,,,
|
||||
18,일상,행정부 파업 예고,어떻게 대응하시겠습니까?,새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다.,기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다.,예산 전용으로 무마,axis_external,1,,파업 강경 대응,axis_hr,1,
|
||||
19,극비,불침번 요원 개발,배치하시겠습니까?,비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다.,잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠.,실전 즉각 배치,axis_threat,7,,즉각 폐기 처분,axis_hr,7,
|
||||
20,일상,내부 스파이 첩보,어떻게 조사하시겠습니까?,기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다.,누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다.,대규모 사찰 시작,axis_hr,3,tag_political_pressure,증거 부족으로 덮음,axis_external,1,
|
||||
25,극비,내부 고발자 발생,어떻게 막으시겠습니까?,의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다.,국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다.,사고사 위장 지시,,,tag_coverup,폭로전 정면 돌파,axis_external,9,
|
||||
26,위기,차원 붕괴 폭탄 오작동,어떻게 대응하시겠습니까?,차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다.,최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다.,특수 해체팀 투입,,,tag_sacrifice,폭발 방관 및 대피,axis_threat,8,
|
||||
27,작전,PMC 흡수 합병,결탁하시겠습니까?,군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다.,돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠.,결탁 및 흡수 합병,,,,독자적 노선 유지,axis_hr,2,
|
||||
28,작전,대규모 거주 구역 실험장화,제안을 승인하시겠습니까?,변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다.,시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠.,돔 건설 강행,axis_threat,2,,윤리적 이유로 기각,axis_external,2,
|
||||
30,일상,믹스 커피 재고 고갈,어떻게 해결하시겠습니까?,국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다.,종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까?,최고급 원두 지원,,,,헛소리 금지령,axis_hr,1,
|
||||
31,위기,신화적 존재 강림,어떻게 대적하시겠습니까?,신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다!,최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까?,현실 조작기 가동,axis_threat,10,tag_madness,모든 예비병력 투입,axis_external,4,tag_sacrifice
|
||||
2,위기,격리실 오염 발생,오염을 어떻게 정화하시겠습니까?,지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다.,보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다.,네이팜 소각 강행,,,,조사팀 파견,axis_threat,4,
|
||||
3,위기,촉수 괴물 출현,출동 승인하시겠습니까?,7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다.,규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다.,내역 위조 후 출동,axis_external,4,,규정대로 반려,axis_threat,4,
|
||||
8,극비,대규모 도서관 건립,예산을 배정하시겠습니까?,고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다.,이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다.,예산 배정,,,,예산 전액 삭감,axis_external,7,
|
||||
11,위기,사이비 종교 인신 공양,어떻게 대응하시겠습니까?,특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다.,위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다.,군사 개입 지시,axis_hr,4,,의식 방관,axis_threat,6,
|
||||
21,작전,비정상적 위기 발생,국장님 결단이 필요합니다,순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다.,괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다.,수용소 건립 승인,,,,제안 단호히 거절,axis_external,2,
|
||||
22,극비,이계의 유물 인양,유물을 어떻게 처리하시겠습니까?,고대 공존당의 사제들이 심해의 유물을 인양했습니다. 만지는 자의 정신을 갉아먹지만 막대한 지식을 줍니다.,약간의 두통만 참으면 이계의 비밀을 모두 알 수 있습니다. 요원 하나쯤 미쳐도 괜찮습니다.,유물 집중 연구,axis_threat,7,,유물 파괴 명령,axis_external,7,
|
||||
23,위기,이계의 주시자 출현,대중을 어떻게 통제하시겠습니까?,얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다.,하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오.,인터넷 전면 차단,axis_pr,4,,진정제 무상 배포,,,
|
||||
24,일상,요원 집단 트라우마,어떻게 지원하시겠습니까?,요원들이 괴수 진압 후 심각한 트라우마에 시달리고 있습니다. 심리 치료 지원이 시급합니다.,밤마다 촉수가 목을 조르는 악몽을 꾼답니다. 차라리 그 부분 기억을 지워버릴까요?,정신과 치료 지원,,,,트라우마 기억 소거,axis_external,1,
|
||||
29,극비,이계 군주의 외교,제안을 수락하시겠습니까?,이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다.,요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다.,거래 즉시 성사,,,,사절단 전원 사살,axis_threat,7,
|
||||
|
||||
|
@@ -1,20 +1,28 @@
|
||||
id,axis,grade,name,flavor,tag
|
||||
32,axis_threat,1,실험체의 노래,직원들 사이에서 그 노래가 귓가에 맴돈다는 소문이 돕니다.,
|
||||
33,axis_threat,2,움직이는 조각상,CCTV 확인 결과 아무도 없었는데 조각상은 분명 돌아서 있었습니다.,
|
||||
34,axis_threat,3,심해의 알,생물학팀은 저것은 알이 아니라 심장이라고 보고했습니다.,tag_alien_contact
|
||||
35a,axis_threat,4,장막 너머의 이름,언어병리학자는 이름의 세 음절만 듣고도 실어증에 걸렸습니다.,tag_madness
|
||||
35b,axis_threat,4,장막 너머의 이름,언어병리학자는 이름의 세 음절만 듣고도 실어증에 걸렸습니다.,tag_alien_contact
|
||||
36,axis_hr,1,초과근무 항의,정시 퇴근이 인권이라는 팻말을 봤습니다.,
|
||||
37,axis_hr,2,항명 성명,서명자 명단에 낯익은 이름이 보입니다. 승진 대상자였는데요.,
|
||||
38,axis_hr,3,경비 태업,다들 같은 날 같은 감기에 걸렸다고 합니다. 참으로 공교롭죠.,tag_coup
|
||||
39a,axis_hr,4,대규모 사표,이것은 항명이 아니라 쿠데타입니다.,tag_ruthless
|
||||
39b,axis_hr,4,대규모 사표,이것은 항명이 아니라 쿠데타입니다.,tag_coup
|
||||
40,axis_pr,1,괴이한 목격담,댓글 반응은 반반입니다. 반은 조작설 반은 목격담 추가.,
|
||||
41,axis_pr,2,특집 뉴스 보도,미스터리 특종이라는 자막이 하루 종일 반복 재생됩니다.,
|
||||
42,axis_pr,3,생필품 사재기,화장지 한 팩을 두고 몸싸움이 벌어졌다는 신고가 열 건째입니다.,tag_panic_mob
|
||||
43a,axis_pr,4,종말론 행진,그들의 팻말에는 이상하게도 정확한 좌표가 적혀 있었습니다.,tag_ruthless
|
||||
43b,axis_pr,4,종말론 행진,그들의 팻말에는 이상하게도 정확한 좌표가 적혀 있었습니다.,tag_panic_mob
|
||||
44,axis_external,1,접대비 감사,법인카드로 산 초밥이 감사 보고서 제목이 됐습니다.,
|
||||
45,axis_external,2,예산 전수조사,회계팀 막내가 벌써부터 사직서 초안을 쓰고 있습니다.,
|
||||
46,axis_external,3,결재권 정지 논의,의제 제목이 국장의 독단 경영에 대하여입니다.,tag_corruption
|
||||
47,axis_external,4,청문회 소환,출석하지 않으면 그 자체로 유죄로 간주하겠다는 문구가 적혀 있습니다.,tag_political_pressure
|
||||
id,axis,grade,name,flavor,tag
|
||||
48,axis_threat,1,온도계 이상 반응,지하 격리동 온도계가 37도를 반복 표시하고 있습니다.,
|
||||
32,axis_threat,2,실험체의 노래,직원들 사이에서 그 노래가 귓가에 맴돈다는 소문이 돕니다.,
|
||||
49,axis_threat,3,복도 그림자 목격,야간 근무자 2명이 같은 복도에서 동일한 그림자를 목격했다고 보고했습니다.,
|
||||
33,axis_threat,5,움직이는 조각상,CCTV 확인 결과 아무도 없었는데 조각상은 분명 돌아서 있었습니다.,
|
||||
34,axis_threat,7,심해의 알,생물학팀은 저것은 알이 아니라 심장이라고 보고했습니다.,tag_alien_contact
|
||||
35a,axis_threat,10,장막 너머의 이름,언어병리학자는 이름의 세 음절만 듣고도 실어증에 걸렸습니다.,tag_madness
|
||||
35b,axis_threat,10,장막 너머의 이름,언어병리학자는 이름의 세 음절만 듣고도 실어증에 걸렸습니다.,tag_alien_contact
|
||||
50,axis_hr,1,익명 건의함 투고 증가,이번 달 건의함 투고 건수가 지난달보다 14건 늘었습니다.,
|
||||
36,axis_hr,2,초과근무 항의,정시 퇴근이 인권이라는 팻말을 봤습니다.,
|
||||
51,axis_hr,3,정시 퇴근 서명 운동,현장 요원 22명이 정시 퇴근 보장을 요구하는 서명에 참여했습니다.,
|
||||
37,axis_hr,5,항명 성명,서명자 명단에 낯익은 이름이 보입니다. 승진 대상자였는데요.,
|
||||
38,axis_hr,7,경비 태업,다들 같은 날 같은 감기에 걸렸다고 합니다. 참으로 공교롭죠.,tag_coup
|
||||
39a,axis_hr,10,대규모 사표,이것은 항명이 아니라 쿠데타입니다.,tag_ruthless
|
||||
39b,axis_hr,10,대규모 사표,이것은 항명이 아니라 쿠데타입니다.,tag_coup
|
||||
52,axis_pr,1,온라인 목격담 게시글,관련 게시글 조회수가 하루 만에 800회를 넘었습니다.,
|
||||
40,axis_pr,2,괴이한 목격담,댓글 반응은 반반입니다. 반은 조작설 반은 목격담 추가.,
|
||||
53,axis_pr,3,지역 방송 문의 전화,지역 라디오 방송국에 제보 전화가 9통 걸려왔습니다.,
|
||||
41,axis_pr,5,특집 뉴스 보도,미스터리 특종이라는 자막이 하루 종일 반복 재생됩니다.,
|
||||
42,axis_pr,7,생필품 사재기,화장지 한 팩을 두고 몸싸움이 벌어졌다는 신고가 열 건째입니다.,tag_panic_mob
|
||||
43a,axis_pr,10,종말론 행진,그들의 팻말에는 이상하게도 정확한 좌표가 적혀 있었습니다.,tag_ruthless
|
||||
43b,axis_pr,10,종말론 행진,그들의 팻말에는 이상하게도 정확한 좌표가 적혀 있었습니다.,tag_panic_mob
|
||||
54,axis_external,1,분기 보고서 지연 지적,분기 보고서 제출이 이틀 늦어 서면 경고를 받았습니다.,
|
||||
44,axis_external,2,접대비 감사,법인카드로 산 초밥이 감사 보고서 제목이 됐습니다.,
|
||||
55,axis_external,3,예산 항목 소명 요청,이사회가 3개 지출 항목에 대한 소명서 제출을 요구했습니다.,
|
||||
45,axis_external,5,예산 전수조사,회계팀 막내가 벌써부터 사직서 초안을 쓰고 있습니다.,
|
||||
46,axis_external,7,결재권 정지 논의,의제 제목이 국장의 독단 경영에 대하여입니다.,tag_corruption
|
||||
47,axis_external,10,청문회 소환,출석하지 않으면 그 자체로 유죄로 간주하겠다는 문구가 적혀 있습니다.,tag_political_pressure
|
||||
|
||||
|
Reference in New Issue
Block a user