등급 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:
kyoung5seo
2026-09-16 23:09:55 +09:00
co-authored by Claude Sonnet 5
parent cde44c4f2c
commit 0a065068c1
30 changed files with 1585 additions and 668 deletions
+59 -24
View File
@@ -1,7 +1,8 @@
# Project SS — CLAUDE.md
> **위치:** repo root (`ProjectSS/CLAUDE.md`) — Claude Code는 root에서 실행한다.
> **이 파일의 역할:** 규칙만 담는다. 작업 이력·진행 상황은 `docs/DEVLOG.md`, Phase 계획은 `ROADMAP.md`.
> **이 파일의 역할:** 규칙만 담는다. 작업 이력·진행 상황은 `docs/DEVLOG.md`, 구현 로드맵은
> `docs/ProjectSS_코어시스템_기획서.md` §17.
> **상위 규칙:** 전역 정체성·개발 철학은 `~/.hermes/SOUL.md`, 프로젝트 정체성은 `AGENTS.md`에 있다.
> 이 파일은 그 위에 얹히는 **코드 레벨 규칙**만 다룬다.
@@ -25,7 +26,10 @@ git log HEAD..origin/main # 내가 안 받은 커밋
## 코드 컨벤션
- 전체 구조·아키텍처 원칙(상태 배치, `engine/` 경계, world 스냅샷 패턴) → @docs/architecture_design.md
- 신규 개발은 `client/src/core/` 아래에 있다 (`engine/`은 React 의존 없는 순수 로직, `components/`는 UI).
기존 `client/src/components/`·`client/src/engine/`(구 GameScreen.jsx 중심 구조)는 참조용으로만
남아있고 더 이상 진입점(`App.jsx`)에서 쓰이지 않는다. 코드 구조는 게임 규칙과 함께 계속
바뀌는 중이라 별도 아키텍처 문서를 두지 않는다 — 구 `architecture_design.md`는 폐기됐다.
- 실제 앱은 `client/` 안에 있음. npm 명령은 `cd client` 후 실행.
- 로컬에 Node.js LTS 설치되어 `npm run dev`로 실기 검증 가능 (Playwright 플레이 확인까지 완료).
- 플레이테스트는 리포 루트의 `playtest.bat` 더블클릭 (dev 서버 + 브라우저 자동 오픈).
@@ -70,41 +74,72 @@ git log HEAD..origin/main # 내가 안 받은 커밋
코드 작성 시 항상 원본 문서를 기준으로 한다. **게임 규칙 수치를 이 파일에 복사하지 말 것.**
- 카드 타입별 스와이프 결과, 파라미터, 의회 액션 → @docs/Project_SS_서사형_덱빌딩_게임_시스템_기획서.md
- 파벌 12종 우호도 조건, 엔딩 매트릭스, 세계관 → @docs/Project_SS_스토리_바이블_v3_0.md
- 화면 전환/분기 로직 → @docs/ProjectSS_유저플로우.md
- UI 레이아웃 → @docs/ProjectSS_텍스트_와이어프레임.md
- 컬러/타이포/햅틱 수치 → @docs/ProjectSS_디자인_토큰.md
- 요구사항 정의 → @docs/ProjectSS_PRD.md
예전에 흩어져 있던 기획 문서 7종(서사형 덱빌딩 시스템 기획서·스토리 바이블·유저플로우·
텍스트 와이어프레임·디자인 토큰·PRD·architecture_design)은 전부 아래 한 문서로 통합됐다.
`docs/` 안에 옛 문서가 남아 보이더라도 참조하지 말 것 — 이제 유일한 원본은 이것뿐이다.
- 게임 규칙·수치·화면 흐름·UI 레이아웃·요구사항 전부 → @docs/ProjectSS_코어시스템_기획서.md
- 기호 체계(축·등급) → §3 · 책상·페널티(주목도) → §4 · 합본(족보) → §5
- 파라미터(4대 게이지) → §6 · 명분 → §7 · 경제(상점) → §8
- 의회(규약·실적감사·13인 위원회) → §9 · 자산 → §10 · 전례(내러티브 태그) → §11
- 노선 → §13 · 엔딩 → §15 · 텍스트 스타일 가이드 → §16
- **구현 우선순위·진행 상황(로드맵) → §17** — Phase 계획을 대신한다, 아래 참고
---
## Phase 진행 순서
## 구현 진행 상황
```
Phase 0 [x] 환경 세팅 & 아키텍처
Phase 1 [x] 코어 루프 프로토타입
Phase 2 [x] 자원 경제 & 덱빌딩
Phase 3 [x] 정치 시스템 & 스토리 페이징
Phase 4 [x] 멀티 엔딩 & 연대기 ← 그레이박스 테스트 시작 가능
Phase 5 [ ] 비주얼 폴리싱 & UX ← 그레이박스 테스트 완료 후에만 착수
```
진행 체크박스를 이 파일에 복제하지 않는다 — 여러 곳에서 따로 갱신하면 어긋나기 쉽다.
`ProjectSS_코어시스템_기획서.md` **§17 "구현 우선순위"** 안의 체크박스가 유일한 진행 상황
원본이다. 지금 뭐가 됐고 안 됐는지 보려면 그 절을 열어볼 것.
`[x]` 완료 · `[~]` 부분 완료 · `[ ]` 미착수
**Phase 5 착수 조건:** 외부 플레이어 그레이박스 테스트를 마치고 그 피드백을 반영한 뒤에만 시작한다.
Phase별 상세 계획과 완료 기준은 `ROADMAP.md`, 각 Phase에서 남은 주의사항과 작업 이력은
`docs/DEVLOG.md` 참고.
각 항목에서 남은 주의사항과 상세 작업 이력은 `docs/DEVLOG.md` 참고. 왜 그렇게 결정했는지는
DEVLOG에서 링크된 결정 노트(아래 "결정 노트" 절) 참고.
---
## 작업 지시 방법
```
/goal Phase 5 진행해줘 # 그레이박스 테스트 완료 후에만
/goal 구현 우선순위 3차 진행해줘 # ProjectSS_코어시스템_기획서.md §17 기준
```
**작업 완료 후 반드시:**
1. 위 **Phase 진행 순서** 블록의 체크박스를 갱신한다.
1. `ProjectSS_코어시스템_기획서.md` **§17**의 해당 항목 체크박스를 갱신한다.
2. `docs/DEVLOG.md` 맨 위에 무엇을 왜 바꿨는지 기록한다.
(미해결 이슈나 디자이너 확인이 필요한 사항이 생겼으면 DEVLOG 상단 "열린 이슈"에 추가)
3. 중요한 설계·밸런스 결정을 내렸다면 결정 노트를 남긴다 — 아래 "결정 노트" 절 참고.
---
## 결정 노트 (Decision Notes)
중요한 설계·밸런스 결정을 내렸을 때는 `docs/decisions/YYYY-MM-DD-제목.md` 파일로 따로 남긴다.
"중요한 결정"이란 나중에 왜 이렇게 했는지 되짚어야 할 만한 것 — 여러 대안 중 하나를 고른 경우,
기존 규칙을 뒤집은 경우, 플레이테스트 피드백에 따라 방향을 바꾼 경우 등이다. 사소한 오타 수정이나
명백한 버그 픽스에는 필요 없다.
**양식** (`T-decision.md` 그대로 — 새 결정 노트는 전부 이 형식을 따른다):
```markdown
---
created: YYYY-MM-DD
---
%% 파일명: YYYY-MM-DD-제목. 규칙 문서엔 규칙만, 이유는 여기에 %%
## 결정
-
## 배경
-
## 검토했으나 기각한 대안
- **대안:** 기각 이유
## 재검토 조건
-
```
**DEVLOG 연동:** `docs/DEVLOG.md`에 작업 항목을 적을 때, 그 작업에 해당하는 결정 노트가 있으면
옵시디언 위키링크로 연결한다 — `[[YYYY-MM-DD-제목]]`. DEVLOG는 "무엇을 했다"는 요약만 담고,
"왜 그렇게 했는가"의 자세한 내용은 링크를 눌러 결정 노트에서 확인하는 구조다.
+45 -21
View File
@@ -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;
}
+39 -13
View File
@@ -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}>&times;</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) => (
{deskCards.map((c) => {
const selected = selectedIds.includes(c.id);
return (
<button
key={c.id}
className={`combine-card ${selectedIds.includes(c.id) ? 'selected' : ''}`}
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;
+106 -61
View File
@@ -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);
// 징벌 규약 갱신. 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();
next = [...next, { ...penalty, id: `${penalty.id}-${Date.now().toString(36)}` }];
nextPenalties = [...nextPenalties, { ...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);
if (finalScore >= finalRequired * 1.5 && nextPenalties.length > 0) {
pushLog(`실적 초과 달성 — 징벌 규약 『${nextPenalties[0].name}』 해제`);
nextPenalties = nextPenalties.slice(1);
}
}
return next;
});
setPenalties(nextPenalties);
// 절차 명분으로 이월된 후보는 다음 라운드 후보 풀에 다시 올라온다 (§7.3).
setDeferredCandidates(deferred);
if (deferred.length > 0) {
pushLog(`절차 명분으로 『${deferred.map((d) => d.name).join(', ')}』 다음 라운드 이월`);
}
if (deferVote) {
pushLog('절차 명분으로 이번 라운드 규약 결정이 이월되었습니다.');
} else {
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}
+3 -3
View File
@@ -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>
+53 -12
View File
@@ -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);
+119 -70
View File
@@ -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 && (
{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>누적 징벌 규약</h3>
{penalties.map((p) => (
<h3>누적 징벌 규약 {penalties.length}개</h3>
{shown.map((p) => (
<div
key={p.id}
className={`council-penalty-chip ${pending?.type === '위기' ? 'targetable' : ''}`}
onClick={() => pending?.type === '위기' && completeTarget(p.id, 'penalty')}
className={`council-penalty-chip ${pendingTargetKind === 'penalty' ? 'targetable' : ''}`}
onClick={() => pendingTargetKind === 'penalty' && completeTarget(p.id, 'penalty')}
>
⚠ {p.name}
</div>
))}
</section>
{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>
))}
<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>
);
+62 -6
View File
@@ -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;
+43 -20
View File
@@ -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)}%
+19 -4
View File
@@ -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}>&times;</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>
+4 -15
View File
@@ -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); }
+27 -21
View File
@@ -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}>&times;</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>
+10 -10
View File
@@ -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>
+68 -15
View File
@@ -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); }
+25 -6
View File
@@ -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>
);
}
+71 -27
View File
@@ -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) {
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 === '내부') requiredScoreMultiplier *= 0.8;
}
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 === '위기' && 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 };
}
/* ── 합본 판정에 규약 효과를 얹는다 ── */
+71 -38
View File
@@ -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
View File
@@ -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),
};
}
+16 -6
View File
@@ -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 };
}
+11 -14
View File
@@ -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;
+25 -17
View File
@@ -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;
+22 -22
View File
@@ -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,
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,1,
9,위기,이단 색출 마녀사냥,어떻게 개입하시겠습니까?,기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다.,더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로!,적당히 묵인한다,axis_hr,1,,군사력으로 제압,axis_external,1,
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,1,tag_corruption,횡령 사실 인정,axis_external,3,
12,위기,의회의 긴급 감사,어떻게 대처하시겠습니까?,의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다!,세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오.,은폐 공작 실행,axis_external,4,tag_corruption,횡령 사실 인정,axis_external,8,
13,일상,연구 부서 파업 조짐,요구를 수용하시겠습니까?,연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다.,저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요.,특별 보너스 지급,,,,요구 전면 묵살,axis_hr,1,
14,작전,이계 무역로 개방,무역로를 개방하시겠습니까?,이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다.,저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠.,게이트 강제 개방,axis_threat,1,tag_alien_contact,폐쇄 상태 유지,axis_hr,1,
14,작전,이계 무역로 개방,무역로를 개방하시겠습니까?,이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다.,저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠.,게이트 강제 개방,axis_threat,2,tag_alien_contact,폐쇄 상태 유지,axis_hr,2,
15,극비,전임 국장 비밀 금고,금고를 개방하시겠습니까?,전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다.,전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다.,강제 개방 지시,,,tag_corruption,영구 봉인,,,
16,작전,메가코프 협박 작전,작전을 실행하시겠습니까?,글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다.,그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까.,작전 실행,,,,작전 취소,axis_hr,1,
17,위기,전술 폭격 건의,폭격을 승인하시겠습니까?,제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요?,시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다.,시민 포기 및 폭격,axis_external,3,tag_ruthless,결사 방어 명령,,,
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,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,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,
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,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,,진정제 무상 배포,,,
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,1,
29,극비,이계 군주의 외교,제안을 수락하시겠습니까?,이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다.,요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다.,거래 즉시 성사,,,,사절단 전원 사살,axis_threat,7,
1 id type keyword question text flavor left_text left_axis left_grade left_tag right_text right_axis right_grade right_tag
2 1 일상 요원들의 시위 발생 시위를 어떻게 처리하시겠습니까? B구역 하급 요원들이 예산 삭감에 반발해 장비 반납 시위를 하고 있습니다. 우리가 세상을 구하는데 월급이 깎인다고요? 촉수에 맞아 죽으나 굶어 죽으나 똑같습니다! 무력 진압 axis_hr 1 tag_ruthless 요구 수용
3 4 극비 변이 DNA 이식 제안 실험을 승인하시겠습니까? 진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠. 인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠. 실험 승인 axis_threat 2 9 tag_madness 제안 거절 axis_hr 1 7
4 5 작전 전술 폭격 건의 폭격을 승인하시겠습니까? 철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다. 희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠. 궤도 폭격 승인 axis_external 3 5 tag_sacrifice 작전 기각 axis_hr 1 2
5 6 일상 부산물 암시장 매각 매각을 허가하시겠습니까? 차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다. 이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠. 매각 허가 axis_external 1 tag_corruption 원칙대로 소각
6 7 작전 존재 양지화 프로젝트 프로젝트를 승인하시겠습니까? 대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다. 우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다. 감시망 구축 tag_coverup 프로젝트 보류 axis_hr 1 2
7 9 위기 이단 색출 마녀사냥 어떻게 개입하시겠습니까? 기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다. 더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로! 적당히 묵인한다 axis_hr 1 4 군사력으로 제압 axis_external 1 4
8 10 일상 변칙 현상 영상 유출 어떻게 처리하시겠습니까? 한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다. 대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오. 제거 절차 승인 tag_coverup 영상만 삭제 axis_threat 1
9 12 위기 의회의 긴급 감사 어떻게 대처하시겠습니까? 의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다! 세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오. 은폐 공작 실행 axis_external 1 4 tag_corruption 횡령 사실 인정 axis_external 3 8
10 13 일상 연구 부서 파업 조짐 요구를 수용하시겠습니까? 연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다. 저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요. 특별 보너스 지급 요구 전면 묵살 axis_hr 1
11 14 작전 이계 무역로 개방 무역로를 개방하시겠습니까? 이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다. 저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠. 게이트 강제 개방 axis_threat 1 2 tag_alien_contact 폐쇄 상태 유지 axis_hr 1 2
12 15 극비 전임 국장 비밀 금고 금고를 개방하시겠습니까? 전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다. 전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다. 강제 개방 지시 tag_corruption 영구 봉인
13 16 작전 메가코프 협박 작전 작전을 실행하시겠습니까? 글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다. 그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까. 작전 실행 작전 취소 axis_hr 1 2
14 17 위기 전술 폭격 건의 폭격을 승인하시겠습니까? 제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요? 시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다. 시민 포기 및 폭격 axis_external 3 8 tag_ruthless 결사 방어 명령
15 18 일상 행정부 파업 예고 어떻게 대응하시겠습니까? 새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다. 기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다. 예산 전용으로 무마 axis_external 1 파업 강경 대응 axis_hr 1
16 19 극비 불침번 요원 개발 배치하시겠습니까? 비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다. 잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠. 실전 즉각 배치 axis_threat 1 7 즉각 폐기 처분 axis_hr 1 7
17 20 일상 내부 스파이 첩보 어떻게 조사하시겠습니까? 기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다. 누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다. 대규모 사찰 시작 axis_hr 3 tag_political_pressure 증거 부족으로 덮음 axis_external 1
18 25 극비 내부 고발자 발생 어떻게 막으시겠습니까? 의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다. 국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다. 사고사 위장 지시 tag_coverup 폭로전 정면 돌파 axis_external 2 9
19 26 위기 차원 붕괴 폭탄 오작동 어떻게 대응하시겠습니까? 차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다. 최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다. 특수 해체팀 투입 tag_sacrifice 폭발 방관 및 대피 axis_threat 3 8
20 27 작전 PMC 흡수 합병 결탁하시겠습니까? 군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다. 돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠. 결탁 및 흡수 합병 독자적 노선 유지 axis_hr 1 2
21 28 작전 대규모 거주 구역 실험장화 제안을 승인하시겠습니까? 변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다. 시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠. 돔 건설 강행 axis_threat 1 2 윤리적 이유로 기각 axis_external 1 2
22 30 일상 믹스 커피 재고 고갈 어떻게 해결하시겠습니까? 국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다. 종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까? 최고급 원두 지원 헛소리 금지령 axis_hr 1
23 31 위기 신화적 존재 강림 어떻게 대적하시겠습니까? 신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다! 최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까? 현실 조작기 가동 axis_threat 4 10 tag_madness 모든 예비병력 투입 axis_external 1 4 tag_sacrifice
24 2 위기 격리실 오염 발생 오염을 어떻게 정화하시겠습니까? 지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다. 보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다. 네이팜 소각 강행 조사팀 파견 axis_threat 1 4
25 3 위기 촉수 괴물 출현 출동 승인하시겠습니까? 7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다. 규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다. 내역 위조 후 출동 axis_external 1 4 규정대로 반려 axis_threat 1 4
26 8 극비 대규모 도서관 건립 예산을 배정하시겠습니까? 고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다. 이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다. 예산 배정 예산 전액 삭감 axis_external 1 7
27 11 위기 사이비 종교 인신 공양 어떻게 대응하시겠습니까? 특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다. 위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다. 군사 개입 지시 axis_hr 1 4 의식 방관 axis_threat 2 6
28 21 작전 비정상적 위기 발생 국장님 결단이 필요합니다 순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다. 괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다. 수용소 건립 승인 제안 단호히 거절 axis_external 1 2
29 22 극비 이계의 유물 인양 유물을 어떻게 처리하시겠습니까? 고대 공존당의 사제들이 심해의 유물을 인양했습니다. 만지는 자의 정신을 갉아먹지만 막대한 지식을 줍니다. 약간의 두통만 참으면 이계의 비밀을 모두 알 수 있습니다. 요원 하나쯤 미쳐도 괜찮습니다. 유물 집중 연구 axis_threat 1 7 유물 파괴 명령 axis_external 1 7
30 23 위기 이계의 주시자 출현 대중을 어떻게 통제하시겠습니까? 얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다. 하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오. 인터넷 전면 차단 axis_pr 1 4 진정제 무상 배포
31 24 일상 요원 집단 트라우마 어떻게 지원하시겠습니까? 요원들이 괴수 진압 후 심각한 트라우마에 시달리고 있습니다. 심리 치료 지원이 시급합니다. 밤마다 촉수가 목을 조르는 악몽을 꾼답니다. 차라리 그 부분 기억을 지워버릴까요? 정신과 치료 지원 트라우마 기억 소거 axis_external 1
32 29 극비 이계 군주의 외교 제안을 수락하시겠습니까? 이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다. 요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다. 거래 즉시 성사 사절단 전원 사살 axis_threat 1 7
+27 -19
View File
@@ -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
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
1 id axis grade name flavor tag
2 32 48 axis_threat 1 실험체의 노래 온도계 이상 반응 직원들 사이에서 그 노래가 귓가에 맴돈다는 소문이 돕니다. 지하 격리동 온도계가 37도를 반복 표시하고 있습니다.
3 33 32 axis_threat 2 움직이는 조각상 실험체의 노래 CCTV 확인 결과 아무도 없었는데 조각상은 분명 돌아서 있었습니다. 직원들 사이에서 그 노래가 귓가에 맴돈다는 소문이 돕니다.
4 34 49 axis_threat 3 심해의 알 복도 그림자 목격 생물학팀은 저것은 알이 아니라 심장이라고 보고했습니다. 야간 근무자 2명이 같은 복도에서 동일한 그림자를 목격했다고 보고했습니다. tag_alien_contact
5 35a 33 axis_threat 4 5 장막 너머의 이름 움직이는 조각상 언어병리학자는 이름의 세 음절만 듣고도 실어증에 걸렸습니다. CCTV 확인 결과 아무도 없었는데 조각상은 분명 돌아서 있었습니다. tag_madness
6 35b 34 axis_threat 4 7 장막 너머의 이름 심해의 알 언어병리학자는 이름의 세 음절만 듣고도 실어증에 걸렸습니다. 생물학팀은 저것은 알이 아니라 심장이라고 보고했습니다. tag_alien_contact
7 36 35a axis_hr axis_threat 1 10 초과근무 항의 장막 너머의 이름 정시 퇴근이 인권이라는 팻말을 봤습니다. 언어병리학자는 이름의 세 음절만 듣고도 실어증에 걸렸습니다. tag_madness
8 37 35b axis_hr axis_threat 2 10 항명 성명 장막 너머의 이름 서명자 명단에 낯익은 이름이 보입니다. 승진 대상자였는데요. 언어병리학자는 이름의 세 음절만 듣고도 실어증에 걸렸습니다. tag_alien_contact
9 38 50 axis_hr 3 1 경비 태업 익명 건의함 투고 증가 다들 같은 날 같은 감기에 걸렸다고 합니다. 참으로 공교롭죠. 이번 달 건의함 투고 건수가 지난달보다 14건 늘었습니다. tag_coup
10 39a 36 axis_hr 4 2 대규모 사표 초과근무 항의 이것은 항명이 아니라 쿠데타입니다. 정시 퇴근이 인권이라는 팻말을 봤습니다. tag_ruthless
11 39b 51 axis_hr 4 3 대규모 사표 정시 퇴근 서명 운동 이것은 항명이 아니라 쿠데타입니다. 현장 요원 22명이 정시 퇴근 보장을 요구하는 서명에 참여했습니다. tag_coup
12 40 37 axis_pr axis_hr 1 5 괴이한 목격담 항명 성명 댓글 반응은 반반입니다. 반은 조작설 반은 목격담 추가. 서명자 명단에 낯익은 이름이 보입니다. 승진 대상자였는데요.
13 41 38 axis_pr axis_hr 2 7 특집 뉴스 보도 경비 태업 미스터리 특종이라는 자막이 하루 종일 반복 재생됩니다. 다들 같은 날 같은 감기에 걸렸다고 합니다. 참으로 공교롭죠. tag_coup
14 42 39a axis_pr axis_hr 3 10 생필품 사재기 대규모 사표 화장지 한 팩을 두고 몸싸움이 벌어졌다는 신고가 열 건째입니다. 이것은 항명이 아니라 쿠데타입니다. tag_panic_mob tag_ruthless
15 43a 39b axis_pr axis_hr 4 10 종말론 행진 대규모 사표 그들의 팻말에는 이상하게도 정확한 좌표가 적혀 있었습니다. 이것은 항명이 아니라 쿠데타입니다. tag_ruthless tag_coup
16 43b 52 axis_pr 4 1 종말론 행진 온라인 목격담 게시글 그들의 팻말에는 이상하게도 정확한 좌표가 적혀 있었습니다. 관련 게시글 조회수가 하루 만에 800회를 넘었습니다. tag_panic_mob
17 44 40 axis_external axis_pr 1 2 접대비 감사 괴이한 목격담 법인카드로 산 초밥이 감사 보고서 제목이 됐습니다. 댓글 반응은 반반입니다. 반은 조작설 반은 목격담 추가.
18 45 53 axis_external axis_pr 2 3 예산 전수조사 지역 방송 문의 전화 회계팀 막내가 벌써부터 사직서 초안을 쓰고 있습니다. 지역 라디오 방송국에 제보 전화가 9통 걸려왔습니다.
19 46 41 axis_external axis_pr 3 5 결재권 정지 논의 특집 뉴스 보도 의제 제목이 국장의 독단 경영에 대하여입니다. 미스터리 특종이라는 자막이 하루 종일 반복 재생됩니다. tag_corruption
20 47 42 axis_external axis_pr 4 7 청문회 소환 생필품 사재기 출석하지 않으면 그 자체로 유죄로 간주하겠다는 문구가 적혀 있습니다. 화장지 한 팩을 두고 몸싸움이 벌어졌다는 신고가 열 건째입니다. tag_political_pressure tag_panic_mob
21 43a axis_pr 10 종말론 행진 그들의 팻말에는 이상하게도 정확한 좌표가 적혀 있었습니다. tag_ruthless
22 43b axis_pr 10 종말론 행진 그들의 팻말에는 이상하게도 정확한 좌표가 적혀 있었습니다. tag_panic_mob
23 54 axis_external 1 분기 보고서 지연 지적 분기 보고서 제출이 이틀 늦어 서면 경고를 받았습니다.
24 44 axis_external 2 접대비 감사 법인카드로 산 초밥이 감사 보고서 제목이 됐습니다.
25 55 axis_external 3 예산 항목 소명 요청 이사회가 3개 지출 항목에 대한 소명서 제출을 요구했습니다.
26 45 axis_external 5 예산 전수조사 회계팀 막내가 벌써부터 사직서 초안을 쓰고 있습니다.
27 46 axis_external 7 결재권 정지 논의 의제 제목이 국장의 독단 경영에 대하여입니다. tag_corruption
28 47 axis_external 10 청문회 소환 출석하지 않으면 그 자체로 유죄로 간주하겠다는 문구가 적혀 있습니다. tag_political_pressure
+100
View File
@@ -0,0 +1,100 @@
# DEVLOG
> 날짜별 작업 이력. 맨 위가 최신. "왜 그렇게 했는가"는 결정 노트로 링크되어 있으면 그쪽 참고.
> 이전 DEVLOG(구 Phase 0~4 체계)는 `old/2026.09.04/docs/DEVLOG.md`에 보관.
## 열린 이슈
- **유형 D(이득 규약) 카탈로그가 없음** — §7.2 "내부 명분(강제 상정)"이 지금은 전부 불리한
유형 A 규약만 상정 대상이라 실속이 없다. 유형 B·D 채워지면 다시 볼 것.
- **혼합 명분("표결 1표 뒤집기")이 자리표시자** — 실제 13인 위원회 표가 없어서 "후보 직접
통과 지정"으로 대체했다. 4차에서 위원회 구현할 때 반드시 재설계.
- **공보(axis_pr) 축 고등급 딜레마 선택지 부족 여부 재확인 필요** — 카드 등급 재매핑은
유형(일상/작전/위기/극비) 기준으로 했고 축 기준이 아니라서, 특정 축에 고등급이 쏠려있거나
비어있을 수 있다. 다음 카드 데이터 배치 작업 때 축별 분포도 같이 확인할 것.
- **평택-서울 원격 세션 안정성 문제 원인 미파악** — 2026-09-15 작업 중 VS Code Remote-SSH
연결이 끊겨 커밋 전 작업이 통째로 유실됐다(재구현으로 복구). 왜 끊겼는지 원인 조사 안 됨 —
재발 방지책 필요.
- **라운드 주목도 한도(21 + 라운드당 +5), 상점 가격, §4.2-A/C 누진 구간 수치 등은 전부 가안** —
실측 플레이로 튜닝 예정. 버스트가 너무 잦거나 너무 안 일어나면 초기값·증가폭부터 의심할 것.
---
## 2026-09-16 — 라운드/회기 이층 구조 폐지, 주목도 하드 캡(버스트) 재도입
**무엇을 왜:** 직전 재구현에서 도입한 "라운드(손)+회기(정산·의회 텀)" 이층 구조와 "결재 8건 고정"
방식이 실제 의도와 어긋났다 — 원래는 **라운드마다 정산·의회를 거치고, 그 압박을 합본·상점으로
넘어서는 것**이 핵심 재미였고, 라운드 종료도 고정 건수가 아니라 **주목도가 라운드별 한도(21에서
시작, 라운드마다 증가)를 넘으면 강제 종료, 아니면 자율 종료**하는 블랙잭식 구조여야 했다. 결정
노트: [[2026-09-16-라운드-주목도-하드캡-복원]]
- `desk.js`: 고정 경고/초과 임계치(30/40/41)를 없애고 `roundAttentionCap(round)` +
한도 상대적 구간(`normal`/`warning`/`bust`)으로 교체
- `CoreGameScreen.jsx`: `session`/`handNumber` 이층 상태를 단일 `round`로 되돌리고, 결재마다
주목도가 한도를 넘는지 즉시 검사해 초과 시 합본 기회 없이 상점 단계로 강제 전환. 플레이어가
스스로 끝낼 수 있는 "정산하기" 액션 신설
- `DeskBar.jsx`: 주목도를 "현재/한도"로 표시, 정산하기 버튼 추가, 한도초과(bust) 시각 경고
- `CouncilScreen.jsx`: 징벌 규약 5개 초과 시 목록 접기/펼치기 추가 (§9.2 UI 요건 반영)
- 기획서 §2(코어 루프)를 단일 라운드 구조로 재작성, §4.2-B를 하드 캡 서술로 교체, §19 결정
완료 사항 표의 "라운드 종료 방식"·"이층 구조" 행을 뒤집힌 결정으로 갱신, §20.1 미결정 사항의
관련 항목 정리
- Playwright로 상신 시 라운드 번호 증가, 상태 표시 라벨(제N라운드) 확인 완료, 콘솔 에러 0건
## 2026-09-16 — 등급 확장 재구현 + 등급 1·3 데이터 보강, 문서 체계 정비
## 2026-09-16 — 등급 확장 재구현 + 등급 1·3 데이터 보강, 문서 체계 정비
**무엇을 왜:** 2026-09-15에 서울(Base) PC에서 Remote-SSH로 작업한 등급 1~10 확장·주목도
시스템·5장 포커 핸드·토큰 1종 통합 작업이 커밋 전 연결 끊김으로 유실됐다. 평택(현재 PC)
로컬 체크아웃(`2차 구현 완료` 커밋 시점)에서 전체를 다시 구현했다. 재구현 과정에서
`cards.csv` 등급 재매핑이 등급 1·3을 전혀 생성하지 못한다는 문제를 추가로 발견해 같이 고쳤다
— 결정 노트: [[2026-09-15 ProjectSS 카드등급확장-주목도시스템]]
- `core/engine/symbols.js`·`desk.js`·`hands.js`·`council.js`·`shop.js`·`pollutionPool.js`
및 관련 컴포넌트 9종 전체를 등급 1~10 / 주목도 / 5장 포커 핸드(8종, 투페어 신설) /
토큰 1종 기준으로 재작성
- `cards.csv` 43개 선택지, `pollution_pool.csv` 19→27개 항목 등급 재매핑 — (유형, 원본등급)
전단사 매핑으로 등급 1~10 전 구간을 채움 (평균 4.14, 8결재 라운드 예상 주목도 33,
§4.3 기준선인 경고구간 30~40에 부합). `pollution_pool.csv`에 등급 1·3 전용 신규 항목
8개 추가(축별 2개씩)
- Playwright로 실제 브라우저 구동 확인 — 등급 1·3 카드가 실제 이름·플레이버와 함께
등장하는 것까지 확인, 콘솔 에러 0건
- CLAUDE.md 정비: 흩어져 있던 기획 문서 7종 참조를 `ProjectSS_코어시스템_기획서.md` 단일
참조(§ 매핑 포함)로 교체, Phase 진행 표를 없애고 §17 체크박스가 유일한 진행 상황 원본이
되도록 변경, "결정 노트" 규칙 신설(`T-decision.md` 양식 + DEVLOG 위키링크 연동)
- `ProjectSS_코어시스템_기획서.md` §17에 실제 구현 상태 체크(1차 전체 완료, 2차는 상점/명분
개입이 3차·4차 의존성 때문에 부분 완료)
## 2026-09-05 — 플레이테스트 UX 피드백 5건 반영
**무엇을 왜:** 1차 그레이박스를 실제로 플레이한 뒤 나온 문제 5건(책상 정보 부족, 스와이프
레이아웃 겹침, 족보 판정 기준 안 보임, 게이지 상승 체감 안 됨, 선택 서사 부족)을 고쳤다.
결정 노트: [[2026-09-05_core-graybox-ux-feedback]]
- `DeskBar`를 축×등급 매트릭스로 확장 + 축 클릭 시 개별 카드 상세 팝업(`DeskDetailPopup`) 추가
- `CoreSwipeCard` 레이아웃 변경 — 좌/우 예고를 카드 위 오버레이에서 상단 고정 슬롯으로 이동해
결재 카드 본문과 겹치지 않게 함
- `HandGuidePopup`(족보표) 신규 — 이 게임 기호로 족보 조건·예시를 보여줌
- 게이지 상승 기준을 "카드 장수"에서 "등급 가중 오염 압력"으로 변경 (이후 2026-09-15 결정으로
"주목도" 개념으로 흡수·재조정됨)
- `pollution_pool.csv`를 처음으로 실제 연결 — 오염 카드가 축 라벨이 아니라 실제 이름·사연을
갖게 됨
- 부수적으로 `cards.csv` 등급 3~4 선택지 6곳 추가(당시 1~4 스케일 기준)
## 2026-09-05 — 구현 우선순위 2차 완료 (§17: 상점·규약·실적 감사·명분)
라운드 구조(결재 8건 → 상점 → 의회 → 다음 라운드) 도입. `ShopScreen`·`CouncilScreen`·
`core/engine/council.js`·`core/engine/shop.js` 신규. Playwright로 라운드 전체 구동 확인,
콘솔 에러 0건. 플레이는 했지만 이 시점엔 피드백 정리 전이었음(다음날 위 피드백으로 이어짐).
## 2026-09-05 — 구현 우선순위 1차 완료 (§17: 기호 체계·책상·족보·토큰)
`client/src/core/` 신설 — 기존 `components/`·`engine/`(GameScreen.jsx 중심 구조)는 참조용으로
남기고 새 진입점으로 전환. 축 인장(lucide Tornado/Hand/Megaphone/Stamp) + 등급 점 표기,
책상 상시 노출 UI, 3장 족보 판정(당시), 합본 기반 토큰 산출 구현.
## 2026-09-04 — 코어시스템 기획서 기준 신규 개발 착수
`rework/combined-filing` 브랜치 생성. 기존 문서 전체를 `old/2026.09.04/`로 이동하고
`ProjectSS_코어시스템_기획서.md`를 단일 원본으로 도입 — 기존 코드베이스는 참조용으로만
남기고 이 문서 기준으로 신규 개발한다는 방침 확정.
+282 -106
View File
@@ -31,23 +31,46 @@
## 2. 코어 루프
> **2026-09-16 결정**: 라운드/회기 이층 구조는 폐지했다. **라운드 하나 = 결재하다 정산(상점)→의회로 이어지는 단일 사이클**이다. 결정 노트: `docs/decisions/2026-09-16-라운드-주목도-하드캡-복원.md`.
### 2.1. 루프
```
[결재 단계] 딜레마 카드 좌/우 스와이프 → 오염 카드 획득
결재 카드 등장 → 좌/우 오염 선택 → 주목도 += 선택한 카드 등급
↓
오염 카드는 [책상]에 적재 (무제한, 상시 노출)
총 주목도가 이번 라운드 한도 이하? → 계속 결재할지 자율 선택
총 주목도가 이번 라운드 한도 초과? → 그 즉시 라운드 강제 종료(버스트)
↓
적재량에 따른 누진 패시브 페널티 (게이지 상승 + 토큰 지출 가산)
언제든 [상신](합본) 가능 · 한도 밑에서는 언제든 자율 [정산하기]도 가능
↓
기호를 맞춰 [합본 결재] → 페널티 소멸 + 명분 + 토큰 + 실적 점수 + 자산 트리거
미사용 카드는 다음 라운드로 이월
↓
[정산 단계] 상점 — 토큰으로 자산·소모품·전례 구매
[정산 단계] 상점 — 토큰으로 자산·소모품·전례 구매
↓
[의회 단계] 실적 감사 + 규약 표결 → 다음 라운드 룰 변경
[의회 단계] 실적 감사(이번 라운드 누적치) + 규약 표결 → 다음 라운드 룰 변경
↓
반복. 적대 임원 9명 도달 시 탄핵 발생 → 신임 위원회 지명으로 방어
다음 라운드 (주목도 한도 상승)
```
**라운드 구조**: 결재 N건 → 정산(상점) → 의회 → 다음 라운드
반복. 적대 임원 9명 도달 시 탄핵 발생 → 신임 위원회 지명으로 방어.
### 2.2. 라운드 종료 조건 (블랙잭식 하드 캡)
라운드는 둘 중 하나로 끝난다 (한도·증가폭 수치는 가안, §20.1 튜닝 대상):
1. **자율 종료 — "정산하기"**: 주목도가 한도 밑인 동안, 플레이어가 아무 때나 정산하기를 눌러 라운드를 마치고 상점으로 넘어갈 수 있다. "한 장 더 받을까 말까"의 선택권은 항상 플레이어에게 있다.
2. **강제 종료 — 버스트**: 결재 직후 총 주목도가 이번 라운드의 한도를 넘으면 그 자리에서 즉시 라운드가 끝난다. **합본 기회 없이** 카드는 그대로 이월된다(§4.1).
한도는 라운드가 갈수록 커진다: `한도 = 21 + (라운드 번호 − 1) × 5`. 후반 라운드일수록 더 크게 쌓을 수 있지만, 그만큼 게이지·토큰 가산도 이미 높은 상태에서 쌓는 것이라 실질 위험은 줄지 않는다.
> **블랙잭 유비**: 주목도=카드 합, 한도=21(1라운드 기준)이다. 한도 밑에서 멈추는 게 "스탠드", 계속 받는 게 "히트", 넘기면 "버스트"다. §1.1의 핵심 재미("지금 정리할 것인가, 더 모아서 크게 처리할 것인가")가 여기서 가장 직접적으로 드러난다 — 의회의 실적 압박은 합본·상점 자산 구매로 넘어서는 재미로 이어진다.
### 2.3. 설계 근거
- **하드 캡 재도입 (2026-09-16, 구 결정 번복)**: 애초엔 하드 캡을 없애고 초과분 소프트 페널티로 대체했으나(§19 참고), 실제로 캡이 없으니 "지금 정리할까 더 모을까"의 리스크가 사라져 그 고민 자체가 무의미해졌다. 최초 임계치(21)는 낮게, 라운드가 갈수록 한도가 커지는 성장 곡선으로 초반 로그라이크 가혹함 문제를 완화했다.
- **상신은 언제든 가능**: 카드가 2장뿐이어도 상신할 수 있다.
- **자율 종료도 언제든 가능**: 한도 밑이면 원하는 시점에 정산으로 넘어갈 수 있다.
- **버스트해도 이월은 자동**: 강제 종료로 합본을 못 해도 카드는 소멸하지 않고 다음 라운드로 넘어간다 (§4.1). 그 라운드에서 놓친 토큰·명분·실적 기회 자체가 손해다 — 별도 징벌이 필요 없다.
---
@@ -68,16 +91,23 @@
> **UI 필수**: 4종 인장은 **실루엣 형태**로 구분한다. 색상만 다른 구현은 소형 화면에서 판별 불가하므로 금지.
### 3.2. 기밀 등급 (숫자)
### 3.2. 기밀 등급 (숫자, 1~10)
| 등급 | 명칭 | 표기 |
|---|---|---|
| 1 | 일반 | ●○○○ |
| 2 | 대외비 | ●●○○ |
| 3 | 2급 기밀 | ●●●○ |
| 4 | 1급 기밀 | ●●●● |
| 등급 범위 | 명칭 |
|---|---|
| 1~2 | 일반 |
| 3~4 | 대외비 |
| 5~6 | 2급 기밀 |
| 7~8 | 1급 기밀 |
| 9~10 | 특급 기밀 |
> 숫자가 아닌 **점 개수**로 표기한다. 읽지 않고 형태로 인지되어야 한다.
> **표기 방식 확정**: 점(●) 표기는 10단계를 감당하지 못해 폐기한다. 카드 중앙에 **큰 숫자**를 직접 표기하고 축 인장은 모서리에 작게 배치한다 — 트럼프 카드의 랭크+슈트 레이아웃을 그대로 차용한다.
> ```
> ◈ ✋
> 7 10
> (위협) (인사)
> ```
> 등급 범위 명칭은 서사·문체용 라벨일 뿐이며, 합본 판정·등급 합계 계산 등 모든 게임 로직은 항상 원 숫자(1~10)를 사용한다.
### 3.3. 스와이프
@@ -89,17 +119,17 @@
**프리뷰 표기**
```
[ 좌 ] [ 우 ]
◈ ●●●○ ✋ ●●●●
◈ 7 ✋ 10
(위협 축) (인사 축)
```
### 3.4. 책상 상태 상시 노출 (필수)
```
◈ 3 ✋ 1 📢 2 ⬛ 0 [총 6장]
◈ 3 ✋ 1 📢 2 ⬛ 0 [총 6장 · 주목도 34]
```
> 이 표시가 없으면 합본 설계가 불가능하며 스와이프는 무의미한 도박이 된다. **1순위 구현 항목.**
> 이 표시가 없으면 합본 설계가 불가능하며 스와이프는 무의미한 도박이 된다. **1순위 구현 항목.** 주목도(등급 합계)는 §4.2에서 상세히 다룬다 — 축별 개수뿐 아니라 등급 합계 총량이 항상 보여야 "한 장 더 받을지" 판단이 가능하다.
---
@@ -110,43 +140,65 @@
- 오염 카드는 덱에 들어가지 않는다. 드로우·셔플 개념 없음.
- 슬롯 **무제한**. 상한을 두면 압박이 상한선에 갇힌다.
- 축별 스택으로 그룹화 표시 (개별 카드 나열 금지 — 20장 이상 시 UI 붕괴).
- **이월**: 합본에 사용되지 않은 카드는 소멸하지 않고 책상에 남아, 다음 라운드에도 계속 주목도에 반영된다. 별도 이월 시스템을 구현할 필요 없이 "합본이 닿은 카드만 사라진다"는 규칙 하나로 자연히 성립한다.
### 4.2. 패시브 페널티 (누진 필수)
### 4.2. 주목도와 패시브 페널티 (누진 필수)
> 장수에 대해 **선형이 아닌 누진**이어야 한다. 선형이면 후반 압박이 밋밋해지고 무제한 슬롯의 의미가 사라진다.
> 카드 등급이 1~10으로 확장되며, 페널티 기준을 **장수**가 아니라 **등급 합계**로 삼는다. 이 등급 합계를 **주목도**라 부른다 — 처리하지 않은 사안이 무거울수록 조직 안팎의 시선이 쏠린다는 관료제적 의미다.
**A. 게이지 상시 상승** — 축별 적재 장수에 따라 매 결재마다 대응 게이지 상승
```
주목도(축별) = 해당 축에 적재된 카드들의 등급 합
주목도(총계) = 책상 전체 카드들의 등급 합
```
| 해당 축 적재 장수 | 결재당 상승 |
**A. 게이지 상시 상승** — 축별 주목도에 따라 매 결재마다 대응 게이지 상승 (가안, 플레이 후 조정)
| 해당 축 주목도 | 결재당 상승 |
|---|---|
| 1~3장 | +0.5% |
| 4~6장 | +1.5% |
| 7~9장 | +3.0% |
| 10장 이상 | +5.0% |
| 1~15 | +0.5% |
| 16~30 | +1.5% |
| 31~40 | +3.0% |
| 41 이상 | +5.0%, 이후 초과분 10당 +1.0%씩 추가 누진 |
**B. 토큰 지출 가산** — 전체 적재 총량에 따라 모든 토큰 지출에 가산
**B. 라운드 종료 판단 (블랙잭식 하드 캡, 2026-09-16 재도입)**
| 총 적재 장수 | 가산 |
플레이어는 매 카드 선택 직후 총 주목도를 이번 라운드의 한도와 비교해 계속 받을지 정산할지 스스로 판단한다. 한도를 넘기면 그 자리에서 즉시 라운드가 강제 종료된다 — §2.2 참고.
```
한도(라운드) = 21 + (라운드 번호 − 1) × 5 (가안, §20.1 튜닝 대상)
```
| 총 주목도 (한도 대비) | 상태 |
|---|---|
| 0~7장 | +0 |
| 8~14장 | +1 |
| 15~21장 | +2 |
| 22장 이상 | +4 |
| 한도 − 5 미만 | 자동 진행. 별도 경고 없음 |
| 한도 − 5 ~ 한도 | **경고 구간** — 곧 넘긴다는 시각 경고, 정산할지 고려 |
| 한도 초과 | **버스트** — 그 즉시 라운드 강제 종료. 합본 기회 없이 카드는 이월(§4.1) |
**C. 임계 사건 트리거** — 특정 축이 임계치 도달 시 사건/뉴스 발생
> **결정 이력**: 최초엔 "주목도 21 초과 시 즉시 강제 종료"안을 로그라이크 초반에 과하다는 이유로 기각하고 초과분 누진 페널티(소프트 오버런)로 대체했었다. 그런데 하드 캡이 없으니 "지금 정리할까 더 모을까"의 실제 리스크가 사라져 핵심 재미(§1.1)가 실종됐다 — 그래서 하드 캡을 되살리되, 한도 자체가 라운드마다 커지는 성장 곡선을 더해 초반 가혹함 문제만 따로 해결했다. 상세 근거는 결정 노트 `2026-09-16-라운드-주목도-하드캡-복원` 참고.
**C. 토큰 지출 가산** — 총 주목도에 따라 모든 토큰 지출에 가산
| 총 주목도 | 가산 |
|---|---|
| 0~20 | +0 |
| 21~40 | +1 |
| 41~60 | +2 |
| 61 이상 | +4 |
**D. 임계 사건 트리거** — 특정 축 주목도가 임계 도달 시 사건/뉴스 발생
```json
{ "axis": "axis_threat", "threshold": 5, "event": "myth_incident_pool", "showWarning": true }
{ "axis": "axis_threat", "threshold": 15, "event": "myth_incident_pool", "showWarning": true }
```
> `showWarning`: 임계치 2장 전부터 해당 축 인장에 경고 표시. 예고선이 보여야 "닿기 전에 정리하자"는 목표가 생긴다.
> `showWarning`: 임계치 근접(예: 3 이내)부터 해당 축 인장에 경고 표시. 예고선이 보여야 "닿기 전에 정리하자"는 목표가 생긴다.
### 4.3. 죽음의 나선
무제한 슬롯 + 누진 페널티는 필연적으로 나선(적재→비용 증가→처리 불가→적재)을 만든다. 의도된 설계이며 관료제 붕괴의 은유다.
단, **대규모 합본이 판을 뒤집을 수 있어야 한다.** 5장 이상 합본의 페널티 제거량을 충분히 크게 설정하여 카타르시스를 보장할 것.
단, **대규모 합본이 판을 뒤집을 수 있어야 한다.** 5장 합본(§5)의 페널티 제거량을 충분히 크게 설정하여 카타르시스를 보장할 것.
> **튜닝 기준선**: 10장 = "슬슬 위험", 20장 = "이번 라운드 안에 정리 못 하면 끝".
> **튜닝 기준선 (가안, 플레이 후 조정)**: 한도 근접(한도−5) = "슬슬 위험, 정산할지 고민", 한도 초과 = "버스트로 이번 라운드 합본 기회를 날림".
---
@@ -154,38 +206,51 @@
> **설계 원칙**: 트럼프 족보 구조를 그대로 차용하고 명칭과 기호만 관료제로 치환한다. 새 규칙을 발명하지 않는다. 플레이어의 기존 트럼프 지식이 그대로 통해야 학습 비용이 0에 수렴한다.
### 5.1. 족보표
### 5.1. 족보표 (5장 기준)
> 카드 등급이 10단계로 확장되며, 조합 판정을 **여러 장 쌓은 카드 중 최고의 5장**을 뽑는 방식으로 재정의한다. 트럼프의 "7장 중 최고 5장" 판정과 동일한 구조이며, 이 역시 플레이어의 기존 지식을 그대로 재사용한다.
| 트럼프 대응 | 족보명 | 조건 | 명분 | 실적 점수 |
|---|---|---|---|---|
| 페어 | **중복 접수** | 같은 등급 2장 | 0.5 (혼합) | 10 |
| 투페어 | **이중 접수** | 같은 등급 2장 × 2쌍 | 1 (혼합) | 20 |
| 트리플 | **집단 민원** | 같은 등급 3장 | 1 | 25 |
| 플러시 | **일괄 처리** | 같은 축 3장 | 1 (축 전용) | 25 |
| 스트레이트 | **단계적 상신** | 등급 오름차순 3장 | 1 (혼합) | 25 |
| 풀하우스 | **종합 감사** | 같은 등급 3 + 같은 등급 2 | 2 | 50 |
| 포카드 | **전면 개편** | 같은 등급 4장 | 3 | 80 |
| 스트레이트 플러시 | **전결 처리** | 같은 축 + 등급 오름차순 3장 이상 | 3 | 80 |
| 스트레이트 | **단계적 상신** | 연속 등급 5장 | 2 (혼합) | 40 |
| 플러시 | **일괄 처리** | 같은 축 5장 | 2 (축 전용) | 40 |
| 풀하우스 | **종합 감사** | 같은 등급 3장 + 같은 등급 2장 | 3 | 60 |
| 포카드 | **전면 개편** | 같은 등급 4장 | 4 | 90 |
| 스트레이트 플러시 | **전결 처리** | 같은 축 + 연속 등급 5장 | 5 | 150 |
> **전부 가안, 플레이 후 조정.** 다만 상하위 배율 격차는 의도적으로 크게 벌렸다 — 구 3장·4등급 체계에서는 조합 횟수가 사실상 무제한이라 격차가 작아도 무방했지만, 이제 "한 번의 상신을 최대한 크게 만들고 싶다"는 동기 자체가 설계 목적이므로 격차가 곧 그 동기를 만드는 장치다.
### 5.1.1. 조합 가능 장수
- 합본에는 **최소 2장, 최대 5장**을 사용한다 (족보표 기준).
- 책상에 쌓아둘 수 있는 카드 수 자체는 §4.2의 주목도 규칙에 따라 사실상 자유이나, **5장 내외를 손에 쥔 상태에서 최고 족보를 노리는 것**이 표준 플레이 리듬이 되도록 수치를 맞춘다.
- 합본에 사용되지 않은 나머지 카드는 소멸하지 않고 책상에 남는다 (§4.1 이월 원칙).
### 5.2. 합본 보상
1. **페널티 소멸** — 사용된 오염 카드가 책상에서 제거되어 패시브 페널티 즉시 감소 (가장 큰 유인)
2. **명분 산출** — §7
3. **토큰 획득** — 축별 종류 결정 (§8.2)
1. **페널티 소멸** — 사용된 오염 카드가 책상에서 **완전 소멸**하여 패시브 페널티 즉시 감소 (가장 큰 유인)
2. **명분 산출** — 축이 종류를 결정 (§7)
3. **토큰 획득** — 등급 합계가 양을 결정 (§8.2)
4. **실적 점수** — §9.3
5. **자산 트리거** — 패턴 반응형 자산 발동
> **완전 소멸 원칙**: 합본에 사용된 카드는 강등·잔류 없이 사라진다. 프로메테우스 랩의 '배양 합본'(§13.3)이 이 기본 규칙을 깨는 유일한 예외이므로, 기본이 완전 소멸이어야 해당 특기가 성립한다.
### 5.3. 구현 사양
```json
{
"combine": {
"selectedCards": ["poll_001", "poll_004", "poll_009"],
"selectedCards": ["poll_001", "poll_004", "poll_009", "poll_011", "poll_014"],
"detectedPattern": "flush_axis",
"output": {
"removeCards": true,
"justification": { "type": "just_threat", "amount": 1 },
"token": { "type": "info", "amount": 3 },
"performanceScore": 25,
"justification": { "type": "just_threat", "amount": 2 },
"token": 18,
"performanceScore": 40,
"triggerAssets": ["on_combine", "on_combine_flush"]
}
}
@@ -223,33 +288,45 @@
### 7.1. 산출
- **종류**: 합본에 사용된 카드의 축이 결정. 축이 섞이면 **혼합 명분**(범용·약효).
- **수량**: 3장=1 / 4장=2 / 5장 이상=3. 1급 기밀 포함 시 +1.
- **수량**: 족보별로 §5.1 표의 명분 열을 따른다. 등급 8 이상(1급 기밀 이상) 카드가 포함되면 +1.
- **보관**: 최대 5개. **다음 의회까지만 유효하며 이후 소멸.**
> **유통기한은 필수.** 무한 저장 시 플레이어가 명분을 쌓아 한 번에 터뜨려 정치가 무의미해진다. 관료제 정합성도 확보된다 — 명분은 시의성이 생명이며 다음 회기로 넘기면 효력을 잃는다.
> **유통기한은 필수.** 무한 저장 시 플레이어가 명분을 쌓아 한 번에 터뜨려 정치가 무의미해진다. 관료제 정합성도 확보된다 — 명분은 시의성이 생명이며 다음 라운드로 넘기면 효력을 잃는다.
### 7.2. 용도 (의회에서만 사용)
### 7.2. 명분은 4종을 유지한다 (토큰과의 차이)
**규약 개입**
토큰은 **양의 재화**이므로 1종으로 통합한다(§8). 명분은 **질의 재화**다 — 어떤 축을 합본했느냐가 어떤 정치 카드를 쥐었느냐로 직결되고, 이념색 페널티까지 따라붙는다. 1종으로 통합하면 "이번엔 절차 명분이 필요하니 대외 합본을 하자"는 계획이 사라지고 축 4개를 나눈 이유 자체가 소멸한다.
| 명분 | 효과 |
> **역할 분담**: 토큰이 **양**을, 명분이 **질**을 담당한다. 합본 시 **축은 명분 종류를, 등급 합계는 토큰 양을** 결정한다.
### 7.3. 용도 (4갈래)
**① 안건 조작** — 규약 후보 3개가 상정된 화면에서 직접 조작한다.
| 명분 | 조작 |
|---|---|
| **절차 명분** (대외) | 상정된 규약 1개를 다음 라운드로 이월 |
| **여론 명분** (공보) | 규약 후보 3개 중 1개 강제 탈락 |
| **위기 명분** (위협) | 누적된 징벌 규약 1개 조기 해제 |
| **내부 명분** (인사) | 이번 라운드 실적 요구치 하향 |
| **절차 명분** (대외) | 후보 1개를 **다음 라운드로 이월** (지금은 피하지만 나중에 다시 온다) |
| **여론 명분** (공보) | 후보 1개 **강제 탈락** + 새 후보 1개 무작위 교체 |
| **위기 명분** (위협) | 누적된 **징벌 규약 1개 조기 해제** |
| **내부 명분** (인사) | 후보 1개 **강제 상정** (표결 자체는 그대로. 원하는 이득 규약을 올릴 때 사용) |
| **혼합 명분** | 표결 결과 1표 뒤집기 |
**정보 획득**
**② 정보 획득**
| 명분 | 효과 |
|---|---|
| 여론 명분 | 임원 1명의 실제 성향 공개 |
| 내부 명분 | 특정 임원의 과거 표결 이력 전체 열람 |
**실적 납입**: 명분 1개 = 실적 20점 (§9.3)
**③ 임원 직접 관리** — 명분 2개로 특정 임원의 우호도를 1단계 이동.
### 7.3. 이념색 페널티 (딜레마의 본체)
> 표를 사는 것이 아니라 **다음 라운드 규약 후보군을 바꾸는 장기 투자**다. 개입 액션 삭제로 비어버린 "임원에게 직접 작용하는" 자리를 메우되, 즉시 표결이 아니라 구도에 작용한다는 점에서 성격이 다르다.
**④ 실적 납입** — 명분 1개 = 실적 20점 (§9.3)
> 명분에는 유통기한(다음 의회까지)이 걸려 있으므로, 매 라운드 "이번 명분을 어디에 태울 것인가"가 실제 고민이 된다. 소모처가 4갈래여야 명분이 남아도는 재화가 되지 않는다.
### 7.4. 이념색 페널티 (딜레마의 본체)
명분 사용 시 대응 성향 태그가 누적되고 이념 축 반대편 파벌의 우호도가 하락한다.
@@ -270,27 +347,28 @@
> **결재 단계에서 토큰 증감을 완전히 제거한다.** 토큰이 결재 순간에 개입하면 "어느 축의 오염을 받을까"라는 질문이 "예산이 부족하니 오른쪽"으로 덮어씌워진다. 손패가 없어 토큰을 배분할 수도 없으므로, 결재 단계의 토큰 수급은 선택을 왜곡하기만 한다.
### 8.2. 토큰 산출 (합본에서만)
### 8.2. 토큰은 1종 (합본에서만 산출)
| 합본 축 | 산출 토큰 |
|---|---|
| 인사 | 인력 |
| 대외 | 예산 |
| 공보 | 정보 |
| 위협 | 플레이어 선택 (또는 소량 전종) |
토큰은 상점 재화이므로 종류를 나눌 이유가 없다. **단일 토큰**으로 통합한다.
> 축마다 산출 토큰이 다르므로 "지금 인력이 말랐으니 인사 합본을 노린다"는 경제적 동기가 축 선택에 추가된다.
**산출량 = 합본에 사용된 카드의 기밀 등급 합계** (가안: 등급 1장당 등급 수치만큼)
> 예: 2급 + 3급 + 3급 합본 → 토큰 8
>
> 축은 명분 종류를, 등급은 토큰 양을 결정한다. 따라서 플레이어는 **"어떤 축을 모을까"(정치)와 "어떤 등급을 모을까"(경제)를 별개로 판단**하게 된다. 고등급 오염은 위험하지만 돈이 된다.
### 8.3. 상점 (정산 단계)
라운드 종료 후, 의회 진입 전에 배치. "예산 집행 내역 결재" 형태로 연출.
| 품목 | 지불 | 비고 |
|---|---|---|
| **자산 구매** | 토큰 | 주 소비처 |
| **오염 카드 직접 처리** | 토큰 | 족보가 안 맞을 때의 탈출구. 명분·실적 없음 |
| **소모품(뉴스 카드) 구매** | 토큰 | 인장·등급 변경 도구 |
| **전례 매입** | 토큰 | 내러티브 태그 획득 |
| 품목 | 비고 |
|---|---|
| **자산 구매** | 주 소비처 |
| **오염 카드 직접 처리** | 족보가 안 맞을 때의 탈출구. 명분·실적 없음 |
| **소모품(뉴스 카드) 구매** | 인장·등급 변경 도구 |
| **전례 매입** | 내러티브 태그 획득 |
모든 품목은 단일 토큰으로 지불한다.
**진열 규칙**: 매 정산마다 무작위 4~6개 진열, 1회 리롤 가능(토큰 소모). 이번 회차 지부장 파벌에 따라 특정 아키타입 자산 등장률 ×3, 가중 파벌이 없는 아키타입은 S급 미등장.
@@ -344,19 +422,25 @@
| 예산 긴축 | 상점 가격 1.5배 |
| 보고 체계 간소화 | (이득) 모든 합본 필요 장수 -1 |
**유형 C. 징벌 규약** — 실적 미달 시 자동 부과, **영구 누적**. 결재당 게이지 추가 상승 / 특정 축 토큰 산출 봉쇄 / 상점 슬롯 감소 등. 해제 수단: 위기 명분, 또는 다음 라운드 실적 초과 달성.
**유형 C. 징벌 규약** — 실적 미달 시에만 자동 부과, **영구 누적, 상한 없음**. 결재당 게이지 추가 상승 / 특정 축 토큰 산출 봉쇄 / 상점 슬롯 감소 등. 해제 수단: 위기 명분, 또는 다음 라운드 실적 초과 달성.
> **상한을 두지 않는 이유**: 매 라운드 부과되는 것이 아니라 실적 미달 시에만 쌓이므로 회복 불가 상태로 직행하지 않는다. 오히려 게이지가 차지 않고 탄핵도 오지 않는 안정 상태에서 런이 무한정 늘어지는 것을 막는 **자연스러운 종료 압력**으로 기능한다. 누적될수록 실적 달성이 어려워지므로 결국 종결로 수렴한다.
>
> **UI 처리**: 누적 5개 초과 시 목록을 접거나 아이콘 스택으로 압축 표시할 것. 상시 전체 나열은 화면을 잠식한다.
**유형 D. 이득 규약** — 드물게 통과. 자산 지급, 족보 완화, 토큰 지급.
### 9.3. 실적 감사
> **라운드당 합본을 강제하지 않는다.** 처리 장수나 합본 횟수를 요구하면 플레이어 선택이 감소하고 전략성이 떨어진다. **다중 통화 결산**으로 여러 경로를 허용한다.
> **라운드당 합본 횟수를 강제하지 않는다.** 처리 장수나 합본 횟수를 요구하면 플레이어 선택이 감소하고 전략성이 떨어진다. **다중 통화 결산**으로 여러 경로를 허용한다.
```
실적 점수 = Σ(족보 점수) + (토큰 납입 × 5) + (명분 납입 × 20)
실적 점수 = Σ(이번 라운드 중 상신한 모든 족보 점수) + (토큰 납입 × 5) + (명분 납입 × 20)
```
- 요구치는 라운드마다 상승 (가안: 1R 25 → 라운드당 +20)
> 한 라운드 안에서 합본(상신)을 몇 번 하든 — 자율 종료 전까지 여러 번 상신할 수도, 버스트로 강제 종료돼 한 번도 못 할 수도 있다 — 그동안 상신한 족보 점수를 전부 합산한다.
- 요구치는 라운드마다 상승 (가안: 1라운드 25 → 라운드당 +20)
- **미달을 감수하는 것도 유효한 선택으로 설계할 것.** 징벌 규약 1개를 받고 자원을 아끼는 판단이 성립해야 실적이 강제가 아닌 협상이 된다.
**UI**
@@ -389,7 +473,7 @@
```
매 회차 지부장 중 1~2명이 실제로는 반대 진영. 표시는 우호이나 실제 표는 반대로 나간다.
**단서 = 표결 이력**: 매 라운드 표결 결과가 기록되고 **언제든 열람 가능한 UI**로 제공된다. "우호 표시인데 반대표를 던진" 이력이 쌓이면 정체가 드러난다. 즉시 확인은 명분 조사 또는 정보 토큰 지불.
**단서 = 표결 이력**: 매 라운드 표결 결과가 기록되고 **언제든 열람 가능한 UI**로 제공된다. "우호 표시인데 반대표를 던진" 이력이 쌓이면 정체가 드러난다. 즉시 확인은 명분 조사 또는 토큰 지불.
> **필수 요건**: 정보가 부족하면 추론이 아니라 운이 된다. 배신자 1명 기준 **3~4라운드면 이력만으로 특정 가능**하도록 표결 빈도와 이력 가시성을 확보할 것.
@@ -582,11 +666,14 @@
| 노선 | 특기 | 상징 연결 |
|---|---|---|
| **이지스 재단** | **정면 대응** — 모든 합본의 필요 장수 -1. 단 명분 산출 없음 (페널티 제거·토큰만) | 방패는 막을 뿐 공세로 나아가지 않는다. 기준선 노선이므로 규칙이 가장 단순 |
| **프로메테우스 랩** | **배양 합본** — 합본 사용 카드 1장을 소멸시키지 않고 '검체'로 남겨 다음 합본에 재사용 | 훔친 불을 가둬 배양한다 |
| **우로보로스 협의회** | **순환 합본** — 단계적 상신 성립 시 마지막 카드가 처음으로 돌아가 연쇄 발동 | 꼬리를 삼키는 뱀 |
| **살롱 드 미다스** | **금빛 치환** — 합본 전 오염 1장의 축 인장을 임의 변경 (라운드당 2회) | 소관 부서를 바꾸는 정치력 = 원하는 명분 제조 |
| **보이드 생텀** | **봉헌** — 합본하지 않고 오염을 안치. 페널티는 유지되나 명분과 처리 배율 산출 | 정리하지 않고 바친다 |
> **이지스 재단 설계 의도 (확정)**: 오염 처리는 가장 쉽지만 명분이 나오지 않아 의회에서 고전하는 구조. "위협은 잘 막지만 정치는 못 하는 조직"이라는 컨셉과 메커니즘이 일치한다. 다만 명분이 완전히 0이면 의회에서 아무 조작도 못 해 답답할 수 있으므로, 그레이박스에서 **「명분 산출 없음」과 「명분 절반(내림)」 두 안을 비교 검증**할 것.
### 13.4. 노선의 의미 부여는 텍스트가 담당
> **메커니즘은 족보, 서사는 텍스트.** 노선을 축에 1:1 매핑하지 않는다. 매핑하면 노선당 축이 고정되어 합본 선택의 폭이 사라진다.
@@ -659,31 +746,39 @@
## 17. 구현 우선순위
> **진행 상황 표기**: `[x]` 완료 · `[~]` 부분 완료(범위 축소·자리표시자 있음) · `[ ]` 미착수.
> 이 체크박스가 진행 상황의 유일한 원본이다(CLAUDE.md에 복제하지 않는다). 상세 이력·미해결
> 사항은 `docs/DEVLOG.md`, 그 배경·근거는 `docs/decisions/`의 결정 노트 참고.
### 1차 — 코어 검증 (여기까지만 되어도 재미 판정 가능)
1. **기호 체계 + 책상 상태 상시 노출 UI** (§3)
2. **책상 적재 + 누진 패시브 페널티 A·B** (§4)
3. **표준 족보 판정 및 합본 실행** (§5)
4. **합본 기반 토큰 산출** (§8.2)
1. [x] **기호 체계 + 책상 상태 상시 노출 UI** (§3)
2. [x] **책상 적재 + 누진 패시브 페널티 A·B** (§4)
3. [x] **표준 족보 판정 및 합본 실행** (§5) — §2.2 주목도 하드 캡(버스트)·자율 정산 로직까지 포함
4. [x] **합본 기반 토큰 산출** (§8.2)
### 2차 — 압박 완성
5. **상점** (§8.3)
6. **규약 시스템 — 유형 A 족보표 변경 우선** (§9.2)
7. **실적 감사 + 다중 통화 결산** (§9.3)
8. **명분 산출 및 규약 개입** (§7)
5. [~] **상점** (§8.3) — "오염 카드 직접 처리"만 구현. 자산 구매·소모품·전례 매입은 3차가
먼저 있어야 성립해서 아직 없음
6. [x] **규약 시스템 — 유형 A 족보표 변경 우선** (§9.2) — 유형 A 7종 + 감사 실패용 유형 C 2종
(5개 초과 시 목록 압축 표시 포함). 유형 B·D는 범위 밖(§20.2 소량 배치 콘텐츠 작업 대상)
7. [x] **실적 감사 + 다중 통화 결산** (§9.3)
8. [~] **명분 산출 및 규약 개입** (§7) — 절차·여론·위기·내부 4갈래 구현. 혼합 명분("표결 1표
뒤집기")은 실제 위원회 표가 없어 "직접 통과 지정"으로 대체한 자리표시자 — 4차에서 13인
위원회가 생기면 재설계 필요. §7.3 ②정보 획득·③임원 직접 관리는 위원회가 없어 미구현
### 3차 — 빌드 및 리플레이성
9. **자산 시스템 + 발동 로그** (§10)
10. **노선 친화도 + 각성 + 합본 특기** (§13)
11. **전례 시스템** (§11)
12. **소모품** (§12)
9. [ ] **자산 시스템 + 발동 로그** (§10)
10. [ ] **노선 친화도 + 각성 + 합본 특기** (§13)
11. [ ] **전례 시스템** (§11)
12. [ ] **소모품** (§12)
### 4차 — 정치 심화
13. **우호도 비공개 + 표결 이력 UI** (§9.5)
14. **이중첩자 + 신임 위원회 지명** (§9.6)
15. **엔딩 분기 + 연대기** (§15, §11.4)
13. [ ] **우호도 비공개 + 표결 이력 UI** (§9.5)
14. [ ] **이중첩자 + 신임 위원회 지명** (§9.6)
15. [ ] **엔딩 분기 + 연대기** (§15, §11.4)
### 5차 — 비주얼 폴리싱
그레이박스 검증 완료 전까지 착수 금지.
그레이박스 검증 완료 전까지 착수 금지. [ ] 미착수
---
@@ -720,17 +815,98 @@
---
## 19. 미결정 사항
## 19. 결정 완료 사항 (기록)
- 토큰 3종 유지 / 2종 축소 / 명분 통합
- 합본 시 사용 카드의 완전 소멸 여부 (등급 강등 후 잔류안 검토)
- 이지스 재단 합본 특기의 최종 형태
- 이중첩자 수 1명 vs 2명
설계 논의로 확정된 항목. 구현 시 임의로 변경하지 말 것.
| 항목 | 결정 | 근거 |
|---|---|---|
| 토큰 종류 | **1종 통합** | 상점 재화이므로 양만 의미 있음. 종류 분화는 번거로움만 추가 |
| 명분 종류 | **4종 유지** | 질의 재화. 통합 시 축 선택의 의미가 소멸 |
| 합본 사용 카드 | **완전 소멸** | 프로메테우스 '배양'이 예외로 성립하려면 기본이 소멸이어야 함 |
| 이지스 합본 특기 | **필요 장수 -1, 명분 없음** | 합본은 쉽고 정치는 어려운 구조가 노선 컨셉과 일치 |
| 징벌 규약 상한 | **없음 (무제한 누적)** | 실적 미달 시에만 부과. 런의 무한 연장을 막는 종료 압력으로 기능 |
| 기밀 등급 범위 | **1~10 (구 1~4에서 확장)** | 4단계로는 족보 성립이 너무 쉬워 조합 선택의 매력이 없었음 (플레이 피드백) |
| 등급 표기 | **점(●) 폐기, 숫자 직접 표기** | 10단계는 점으로 못 셈. 트럼프 랭크+슈트 레이아웃 차용 |
| 족보 기준 장수 | **3장 → 5장** | 10등급 스케일에 맞춰 포커의 "7장 중 5장" 구조로 전환 |
| 페널티 기준 | **카드 장수 → 등급 합계(주목도)** | 등급 편차가 커진 만큼 심각도를 반영해야 압박이 정직해짐 |
| 라운드 종료 방식 | **하드 캡 재도입 — 한도(21+라운드당 +5) 초과 시 강제 종료(버스트)** | 한때 소프트 오버런(하드 캡 없음)으로 바꿨으나, 캡이 없으니 "지금 정리할까 더 모을까"의 실제 리스크가 사라져 핵심 재미(§1.1)가 실종됨. 초반 가혹함은 낮은 초기 한도 + 라운드별 성장 곡선으로 완화. 결정 노트: `2026-09-16-라운드-주목도-하드캡-복원` |
| 라운드/회기 이층 구조 | **폐지 — 단일 라운드로 환원** | 라운드마다 정산·의회를 거치는 게 핵심 재미(합본·상점으로 의회 압박을 넘어서는 것)였는데, 이층 구조는 그 사이클을 회기 단위로 늘려 압박 빈도를 실수로 낮췄음. 결정 노트: `2026-09-16-라운드-주목도-하드캡-복원` |
## 20. 미결정 사항
### 20.1. 플레이 테스트 후 튜닝 (지금 결정 불가)
- 이중첩자 수 1명 vs 2명 (2명이면 추론 난도 급상승)
- 신임 위원회 지명 인원 3명의 적정성
- 실적 요구치 상승 곡선 (라운드당 +20 가안)
- 토큰 납입 5점 / 명분 납입 20점 환산 비율
- 징벌 규약 최대 누적 개수 제한 여부
- 페이즈별 오염 공급량·등급 분포 곡선
- 각성 임계치 15의 적정성
- 규약 상세 목록 (유형 A/B/C/D 각 5~8종) — 소량 배치로 작성
- 자산 상세 목록 — 소량 배치로 작성
- 이지스 재단: 「명분 산출 없음」 vs 「명분 절반(내림)」 비교 검증
- 합본 토큰 산출량 산식 (등급 합계 그대로 vs 계수 적용)
- **§4.2-A/C 게이지 상승·토큰 가산의 누진 구간 수치 전체 (1~15/16~30/31~40/41+ 등)**
- **§5.1 족보표의 명분·실적 점수 배율 (상하위 격차가 지금 과한지 부족한지)**
- **§2.2/§4.2-B 라운드 주목도 한도의 시작값(21)·증가폭(+5) 적정성 — 라운드를 몇 번쯤 버틸 수 있어야 하는지와 연동해서 정할 것**
### 20.2. 콘텐츠 제작 (별도 세션, 소량 배치)
- 규약 상세 목록 (유형 A/B/C/D 각 5~8종)
- 자산 상세 목록
- 오염 카드 및 딜레마 카드 텍스트
- 전례(내러티브 태그) 목록
---
## 21. 데이터 스키마 확정 (Claude Code 조사 후 결정)
기존 `cards.csv`/`assets.json`/`narrative_tags.json`을 새 코어 시스템으로 마이그레이션하며 확정된 사항. 구현 시 임의로 변경하지 말 것.
| 항목 | 결정 |
|---|---|
| 카드 파일 구조 | `cards.csv`(딜레마 카드)와 `pollution_pool.csv`(오염 카드 풀)로 **분리**. 하나의 헤더에 다른 스키마를 공백으로 욱여넣지 않는다 |
| 딜레마 카드 축 매핑 | 구 `res`→인사, `ent`→위협, `pan`→공보, `rsk`→대외 (1:1 대응 확정) |
| 등급 변환 | 원값 1→일반(1) / 2→대외비(2) / 3→2급기밀(3). 4급(1급기밀)은 신규 콘텐츠에서만 사용 |
| 다축 카드 처리 | 2개 이상 축에 값이 걸린 카드는 **자동 변환 금지**. 별도 목록으로 뽑아 소량 배치 수동 재설계 |
| `pollution_pool.csv` 스키마 | `id, axis, grade, name, flavor, tag` |
| 자산 증폭 타입 | §10.3에 **7번째 타입 `periodic`(주기형) 추가**. `every_n_approvals`류의 순수 카운터 트리거가 여기 속함 |
| 고아 effect 2건 | `convert_multiplier`, `quarantine_fail_to_purge` **폐기**. 후자는 §13.3 보이드 생텀 '봉헌' 특기로 대체됨. 재설계 필요 시 §20.2에서 새 effect enum으로 재작성 |
| `faction_link` 필드 | asset 개별 스키마에서 **삭제**. 아키타입↔파벌 가중치(§8.3)는 자산 단위가 아닌 아키타입 단위로 작동하므로, 별도 파일 `archetype_faction_weight.json`으로 분리 관리 |
| 아키타입↔파벌 가중치 | purge→잔해 청소부 길드 / convert→차원 무역당·블랙마켓 카르텔 / quarantine→오컬트 수집가 협회 / skip→부패 관료 연합 (배수 ×3, v2 §7.1과 동일) |
> **주의**: 지부장 파벌(잔해 청소부 길드 등 12개 풀)과 노선(이지스 재단 등 5종, §13)은 서로 다른 레이어다. 파벌은 아키타입에 연결되고, 노선은 족보 패턴에 연결된다. 혼동하여 매핑하지 말 것.
---
## 22. 자산 스키마 확정 (2차 — 마이그레이션 중 발견된 사항)
### 22.1. 트리거 enum 수정 — `on_swipe_left` 제거
> **§10.4 트리거 enum을 다음으로 갱신한다**: `on_pollution_gain` / `on_combine` / `on_combine_{족보}` / `on_token_spend` / `every_n_approvals` / `on_council_end` / `on_purge` / `on_convert` / `manual_charge`
`on_swipe_left`는 폐기한다. §3.3에서 좌/우 스와이프는 "오염 카드 선택지 둘 중 하나"일 뿐 구v1의 좌=수용/우=회피 비대칭이 사라졌으므로, 좌측 한정 트리거는 존재 이유가 없다. `on_pollution_gain`(오염 획득 시, 좌우 무관)이 그 역할을 전부 포함한다. 기존 데이터에서 `on_swipe_left`를 쓰던 자산은 `on_pollution_gain`으로 이관한다.
### 22.2. 증폭 타입 canonical enum 문자열
§10.3의 한글 명칭에 대응하는 구현용 문자열을 다음으로 확정한다.
| 한글 명칭 | enum 문자열 |
|---|---|
| 패턴 반응형 | `pattern_reactive` |
| 적재 반응형 | `pollution_reactive` |
| 누적형 | `cumulative` |
| 재발동형 | `repeater` |
| 토큰 환산형 | `token_scaling` |
| 메타형 | `meta` |
| **주기형** (§21 ③에서 추가) | `periodic` |
### 22.3. 발동 방식 필드 신설 — `activation`
증폭 타입과 별개로, 조건 충족 시 **자동 발동**하는지 **플레이어가 소비 시점을 선택**하는지를 구분하는 필드를 추가한다. 8번째 증폭 타입을 만들지 않고 직교 속성으로 처리한다(전례 효과를 5종으로 제한한 것과 같은 원칙 — 타입 증식 방지).
```json
"activation": "auto" | "manual"
```
`periodic`이면서 `manual`인 조합(충전은 자동, 소비는 수동)이 존재할 수 있다. 예: 결재권 반려 도장.
### 22.4. pollution_pool 태그 충돌 처리
원본 오염 카드 템플릿 행이 좌/우 서로 다른 태그를 가졌던 경우(3건), 하나를 버리지 않고 **동일 축·등급의 별도 풀 엔트리 2개로 분리**한다(id에 `a`/`b` 접미사). 무작위 추첨 풀의 변형 다양성이 늘어나는 것이므로 손실이 아니다.
@@ -0,0 +1,48 @@
---
type: decision
status: decided
created: 2026-09-14
updated: 2026-09-14
tags: [ProjectSS, 코어시스템, 합본-결재]
impact: high
---
## 배경
플레이 테스트 결과 세 가지 문제가 확인됨.
- 오염 카드가 책상에 쌓이기만 하고, 증가하는 게이지가 위협적으로 느껴지지 않음
- 오염 카드 조합(합본)이 너무 쉽게 성립되어, 좌/우 선택 자체의 전략적 매력이 없음
- 조합 시 즉시 얻는 이득이 약해 "조합을 할까 말까"라는 선택 자체의 매력도 낮음
근본 원인은 기존 4등급 × 3장 조합 체계다. 등급 풀이 좁아 족보가 지나치게 쉽게 완성되고, 조합 횟수 제한이 없어 매 순간 "즉시 정리하는 것"이 항상 최적이었음. 포커처럼 "손이 완성되기 전까지 더 받고 싶은" 심리를 만들고자 했으나, ProjectSS는 카드 수령 시 좌/우 **선택권**이 있다는 점이 트럼프와 근본적으로 다름 — 선택권이 없는 포커의 구조를 그대로 이식할 수 없었음.
## 결정
1. 기밀 등급을 **1~4단계 → 1~10단계**로 확장. 표기는 점(●) 대신 카드 중앙에 **숫자 직접 표기**(트럼프 랭크+슈트 레이아웃 차용)로 변경
2. 축 4종(위협/인사/공보/대외)은 그대로 유지 — 4대 게이지와의 1:1 대응 구조를 보존하기 위함
3. 합본 판정을 **3장 → 5장 포커 핸드** 구조로 전환 ("여러 장 쌓은 카드 중 최고 5장" — 트럼프의 "7장 중 5장"과 동형). 투페어(이중 접수) 신설, 스트레이트·플러시는 5장 연속/동축 기준으로 재정의
4. 상하위 족보 간 명분·실적 점수 배율 격차를 대폭 확대(최저 10점 ~ 최고 150점, 구 대비 격차 약 2배 확장). "한 번의 상신을 최대한 크게 만들고 싶다"는 동기를 의도적으로 부여
5. 페널티 산정 기준을 **카드 장수 → 등급 합계**로 전환하고, 이를 **주목도**로 명명. 라운드 진행을 블랙잭식 "계속 받을지 상신할지" 판단 구조로 재편
6. 주목도 30~40을 경고 구간, 41 이상을 초과 구간으로 설정하되 **하드 캡(강제 종료) 없이** 초과분에 비례한 소프트 페널티 누진으로 처리
7. 합본에 사용되지 않은 카드는 소멸하지 않고 책상에 남아 다음 라운드로 자동 이월 (별도 시스템 불필요 — "합본이 닿은 카드만 소멸한다"는 기존 규칙만으로 성립)
## 검토했으나 기각한 대안
- **축 상징을 4개→10개로 늘리는 안**: 4대 핵심 게이지와의 1:1 대응 구조가 깨져 기각. 등급 확장으로 방향을 바꿈
- **오염 카드로 인한 게이지 급증 + 조합 시 게이지 즉시 감소**: 게이지가 오르내리면 "세계가 돌이킬 수 없이 망해간다"는 코스믹 호러 테마와 관료제적 비가역성 은유가 훼손됨. 대신 상승 "속도"만 조절하는 방향(패시브 페널티 누진)으로 대체
- **주목도 21 초과 시 즉시 강제 라운드 종료(하드 캡)**: 로그라이크 초반부터 한 번의 운으로 라운드 전체를 잃는 구조는 과도한 처벌. 소프트 오버런(초과분 누진 페널티)으로 대체
- **조합 필요 장수를 3→4장으로만 상향**: 등급 확장과 효과가 중복되어 기각
- **카드 7장 고정 수령 후 라운드 강제 종료**: ProjectSS 고유의 "선택권 있는 드래프트"라는 특성을 살리기 위해, 고정 장수 대신 등급 합계(주목도) 기반의 가변적 종료 조건으로 대체
## 근거
- 트럼프는 카드 수령에 선택권이 없어 "포기 못하고 더 받고 싶은" 긴장이 자연히 생기지만, ProjectSS는 좌/우 선택권이 있으므로 "선택을 잘할수록 더 유리한 위치에서 조합을 노릴 수 있다"는 보상 구조가 필요함 — 이월 규칙이 이를 구현함(잘 남긴 잔여 카드가 다음 판의 유리한 시작점이 됨)
- 등급 편차가 커질수록 단순 카드 장수보다 등급 합계가 실제 위협도를 더 정직하게 반영함
- 조합 횟수를 여전히 제한하지 않는 대신, 상하위 족보 배율 격차와 주목도 압박이라는 두 축으로 "매 순간 즉시 정리가 최적"이라는 기존 문제를 해소함
## 재검토 조건 (있다면)
- 플레이 테스트 결과 평균 카드 보유 장수가 예상(약 6~7장)과 크게 어긋날 경우 등급 분포·주목도 임계치 재조정
- 주목도 30/40 경고·초과 구간이 실제로 "계속할지 고민"을 유발하지 못하면 임계치 자체를 재검토
- 상하위 족보 배율 격차(최저 10 vs 최고 150)가 실제 플레이에서 하위 족보를 완전히 무의미하게 만들 경우 격차 축소
- 숫자+모서리 인장 표기가 실기기 화면에서 가독성 문제를 보이면 표기 방식 재검토
@@ -0,0 +1,26 @@
---
created: 2026-09-16
---
%% 파일명: 2026-09-16-라운드-주목도-하드캡-복원. 규칙 문서엔 규칙만, 이유는 여기에 %%
## 결정
- 라운드/회기 이층 구조를 폐지한다. **라운드 하나 = 결재하다 정산(상점)→의회로 이어지는 단일 사이클**로 되돌린다.
- 주목도 하드 캡을 재도입한다. **총 주목도가 이번 라운드의 한도를 넘으면 그 즉시 라운드가 강제 종료**된다(버스트) — 합본 기회 없이, 카드는 이월된다.
- 한도 밑에서는 플레이어가 아무 때나 "정산하기"로 자율 종료할 수 있다 (블랙잭의 스탠드).
- 한도는 고정값이 아니라 라운드가 갈수록 커진다: `한도 = 21 + (라운드 번호 − 1) × 5` (가안).
## 배경
- 지난 턴에 기획서를 "라운드(손)"와 "회기(정산·의회 텀)"로 나눠 구현했는데, 실제로는 문서 작성자가 의도한 걸 놓친 채 진행한 오해였다.
- 원래 의도: **라운드마다 정산·의회를 거쳐야** 하고, 그 압박(실적 요구·규약)을 합본과 상점 자산 구매로 넘어서는 것 자체가 핵심 재미다. 이층 구조는 이 사이클을 회기 단위로 늘려버려 압박이 오는 빈도를 실수로 낮췄다.
- 결재 8건 같은 고정 카드 수 제한도 방향이 달랐다. 원하는 건 "주목도 21이 될 때까지 결재하다가, 멈출지 더 받을지는 플레이어가 스스로 정하는" 블랙잭식 긴장이었다 — 이건 사실 최초 기획서(2026-09-14 이전)에 있다가 "로그라이크 초반에 과하다"는 이유로 소프트 오버런으로 바뀐 안이었다. 그런데 하드 캡을 없애고 나니 "지금 정리할까 더 모을까"의 실제 리스크가 사라져, §1.1의 핵심 재미 정의 자체가 무력화됐다.
## 검토했으나 기각한 대안
- **하드 캡 없이 소프트 페널티만 유지**: 바로 이 상태가 문제였다. 넘겨도 죽지 않으니 "언제 멈출까"가 실질적인 선택이 아니게 된다.
- **고정 임계치(21) 그대로, 성장 없음**: 후반 라운드에도 21에서 막히면 게임이 진행될수록 오히려 쉬워지는 카드(고등급)를 못 받아 성장감이 없다. 라운드별 성장 곡선을 추가해 해결.
- **라운드/회기 이층 구조 유지하되 회기 길이를 줄임**: 근본적으로 "라운드마다 정산·의회"라는 원래 의도와 맞지 않아 이층 구조 자체를 접었다.
## 재검토 조건
- 초기 한도(21)·증가폭(+5)이 실측 플레이에서 너무 타이트하거나 너무 헐거우면 조정 (§20.1)
- 버스트가 너무 자주 발생해 "합본 기회 자체가 드물다"는 느낌이면 증가폭을 올리거나 초기값을 높일 것
- 반대로 버스트가 거의 안 일어나 하드 캡이 유명무실하면 초기값을 낮추거나 증가폭을 줄일 것
- §4.2-A(게이지 상승)·§4.2-C(토큰 가산)의 절대 임계치(15/30/40/41 등)는 이번 결정과 무관하게 그대로 뒀다 — 라운드 한도와 별개로 이 값들도 따로 튜닝 대상이다