구현 우선 순위 1차 진행 완료

- 1차 — 코어 검증 (여기까지만 되어도 재미 판정 가능)
  1. **기호 체계 + 책상 상태 상시 노출 UI** (§3)
  2. **책상 적재 + 누진 패시브 페널티 A·B** (§4)
  3. **표준 족보 판정 및 합본 실행** (§5)
  4. **합본 기반 토큰 산출** (§8.2)

 게임 플레이 후 피드백 해결
 - 내용은 아래 문서 참고
 - docs/decisions/2026-09-05_core-graybox-ux-feedback.md
This commit is contained in:
kyoung5seo
2026-09-05 01:06:41 +09:00
parent 54e2fa8132
commit 83060f3e5b
21 changed files with 1526 additions and 8 deletions
+4 -2
View File
@@ -1,11 +1,13 @@
import React from 'react';
import './App.css';
import { GameScreen } from './components/GameScreen';
// ProjectSS_코어시스템_기획서.md 기준 신규 개발 — 구현 우선순위 1차(§17)부터 core/ 아래 새로
// 짓는다. 기존 GameScreen(components/)은 참조용으로만 남겨두고 더 이상 진입점에서 쓰지 않는다.
import CoreGameScreen from './core/components/CoreGameScreen';
function App() {
return (
<div className="App">
<GameScreen />
<CoreGameScreen />
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
.combine-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: 16px;
}
.combine-panel {
background: var(--color-surface);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
width: min(560px, 100%);
max-height: 85vh;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.combine-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.combine-header h2 {
margin: 0;
font-family: var(--font-doc-title);
}
.combine-close {
background: none;
border: none;
color: var(--color-text);
font-size: 22px;
cursor: pointer;
}
.combine-hint {
font-size: 12px;
opacity: 0.65;
margin: 0;
}
.combine-desk-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 40px;
}
.combine-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
background: transparent;
border: none;
padding: 4px;
cursor: pointer;
border-radius: 6px;
opacity: 0.8;
}
.combine-card-name {
font-size: 10px;
color: var(--color-text);
opacity: 0.6;
max-width: 90px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.combine-card.selected {
opacity: 1;
outline: 2px solid var(--color-token-budget);
border-radius: 6px;
}
.combine-empty {
font-size: 13px;
opacity: 0.5;
}
.combine-results {
display: flex;
flex-direction: column;
gap: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 12px;
min-height: 40px;
}
.combine-no-match {
font-size: 13px;
opacity: 0.6;
}
.combine-match {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
padding: 10px 12px;
color: var(--color-text);
cursor: pointer;
font-family: var(--font-typewriter);
font-size: 13px;
}
.combine-match:hover {
border-color: var(--color-token-budget);
}
.match-name {
font-weight: 700;
flex: 1;
text-align: left;
}
.match-score { color: var(--color-token-budget); }
.match-justification { opacity: 0.7; }
@@ -0,0 +1,69 @@
import React, { useMemo, useState } from 'react';
import SymbolBadge from './SymbolBadge';
import { detectHands, resolveCombine, HAND_DEFS } from '../engine/hands';
import './CombinePanel.css';
// 합본 결재 (§5) — 책상에서 카드를 다중 선택하면 성립 가능한 족보를 자동 판정해 보여준다.
// 여러 족보가 동시 성립하면 최고 족보를 기본값으로 보여주되, 하위 족보도 선택할 수 있게 한다 (§5.3) —
// 명분 종류를 고르기 위해 일부러 하위 족보를 쓰는 선택이 존재해야 하기 때문이다.
export default function CombinePanel({ deskCards, onConfirm, onClose }) {
const [selectedIds, setSelectedIds] = useState([]);
const selection = useMemo(
() => deskCards.filter((c) => selectedIds.includes(c.id)),
[deskCards, selectedIds]
);
const matches = useMemo(() => detectHands(selection), [selection]);
const toggle = (id) => {
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
const confirm = (patternId) => {
const result = resolveCombine(selection, patternId);
if (result) onConfirm(result);
};
return (
<div className="combine-overlay" onClick={onClose}>
<div className="combine-panel" onClick={(e) => e.stopPropagation()}>
<div className="combine-header">
<h2>합본 결재</h2>
<button className="combine-close" onClick={onClose}>&times;</button>
</div>
<p className="combine-hint">책상에서 카드를 선택하면 성립하는 족보를 판정합니다.</p>
<div className="combine-desk-grid">
{deskCards.length === 0 && <p className="combine-empty">책상이 비어 있습니다.</p>}
{deskCards.map((c) => (
<button
key={c.id}
className={`combine-card ${selectedIds.includes(c.id) ? 'selected' : ''}`}
onClick={() => toggle(c.id)}
>
<SymbolBadge axis={c.axis} grade={c.grade} />
{c.name && <span className="combine-card-name">{c.name}</span>}
</button>
))}
</div>
<div className="combine-results">
{selection.length > 0 && matches.length === 0 && (
<p className="combine-no-match">선택한 {selection.length}장은 성립하는 족보가 없습니다.</p>
)}
{matches.map((patternId) => {
const def = HAND_DEFS[patternId];
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>
</button>
);
})}
</div>
</div>
</div>
);
}
@@ -0,0 +1,42 @@
.core-game-screen {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--color-bg);
color: var(--color-text);
}
.core-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-typewriter);
}
.core-status-row {
display: flex;
gap: 18px;
padding: 8px 16px;
font-family: var(--font-typewriter);
font-size: 12px;
opacity: 0.75;
flex-wrap: wrap;
}
.core-log {
padding: 6px 16px 14px;
display: flex;
flex-direction: column;
gap: 2px;
font-family: var(--font-typewriter);
font-size: 11px;
opacity: 0.6;
max-height: 110px;
overflow-y: auto;
}
.core-log-line {
white-space: pre-wrap;
}
@@ -0,0 +1,133 @@
import React, { useMemo, useState } from 'react';
import Papa from 'papaparse';
import cardsCsvRaw from '../../data/cards.csv?raw';
import DeskBar from './DeskBar';
import CoreSwipeCard from './CoreSwipeCard';
import CombinePanel from './CombinePanel';
import HandGuidePopup from './HandGuidePopup';
import { pickPollutionEntry } from '../engine/pollutionPool';
import { makeDeskCard, applyPassiveGaugeRise, addToDesk, removeFromDesk } from '../engine/desk';
import './CoreGameScreen.css';
// 코어 루프 그레이박스 (§2·§17 1차) — 기호 체계 + 책상 적재/누진 페널티(A·B) + 표준 족보 판정/합본 +
// 합본 기반 토큰 산출까지만 구현한다. 상점·규약·의회·자산·전례·엔딩은 2~4차 범위라 아직 없다.
// 기존 GameScreen.jsx/engine/pollution.js는 참조용으로만 남기고 건드리지 않는다 — 코어시스템
// 기획서 자체가 "기존 코드는 참조용, 신규 개발" 원칙을 명시하고 있다.
const buildCard = (row) => ({
id: row.id,
type: row.type,
keyword: row.keyword,
question: row.question,
text: row.text,
flavor: row.flavor,
left: {
text: row.left_text,
axis: row.left_axis || null,
grade: row.left_grade ? Number(row.left_grade) : null,
tag: row.left_tag || null,
},
right: {
text: row.right_text,
axis: row.right_axis || null,
grade: row.right_grade ? Number(row.right_grade) : null,
tag: row.right_tag || null,
},
});
const INITIAL_GAUGES = { entropy: 0, resistance: 0, panic: 0, risk: 0 };
const INITIAL_TOKENS = { budget: 0, personnel: 0, info: 0 };
export default function CoreGameScreen() {
const deck = useMemo(() => {
const parsed = Papa.parse(cardsCsvRaw, { header: true, skipEmptyLines: true });
return parsed.data.map(buildCard);
}, []);
const [cardIndex, setCardIndex] = useState(0);
const [deskCards, setDeskCards] = useState([]);
const [gauges, setGauges] = useState(INITIAL_GAUGES);
const [tokens, setTokens] = useState(INITIAL_TOKENS);
const [score, setScore] = useState(0);
const [justifications, setJustifications] = useState([]);
const [showCombine, setShowCombine] = useState(false);
const [showGuide, setShowGuide] = useState(false);
const [log, setLog] = useState([]);
const pushLog = (msg) => setLog((prev) => [msg, ...prev].slice(0, 6));
const currentCard = deck.length > 0 ? deck[cardIndex % deck.length] : null;
const handleSwipe = (direction) => {
if (!currentCard) return;
const side = direction === 'right' ? currentCard.right : currentCard.left;
// A. 게이지 상시 상승 — 이번 결재 시점 책상 상태 기준으로 축별 누진율만큼 올린다 (§4.2-A).
setGauges((prev) => applyPassiveGaugeRise(prev, deskCards));
// 선택한 쪽에 축이 지정돼 있으면 오염 카드 1장을 책상에 적재한다.
// pollution_pool.csv에서 실제 이름·사연을 붙여서, "이 선택이 어떤 서류를 남겼는지"가
// 축 라벨이 아니라 구체적인 사건으로 보이게 한다 (플레이테스트 피드백).
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 || '미상'}』 접수`);
} else {
pushLog(`『${side.text}』 결재됨 — 오염 없음`);
}
setCardIndex((i) => (i + 1) % deck.length);
};
const handleCombineConfirm = (result) => {
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;
});
setJustifications((prev) => [...prev, result.justification]);
setScore((prev) => prev + result.performanceScore);
pushLog(`합본 성립: 『${result.pattern.name}』 — 실적 +${result.performanceScore}, 명분(${result.justification.type}) +${result.justification.amount}`);
setShowCombine(false);
};
if (!currentCard) {
return <div className="core-loading">카드를 불러오는 중...</div>;
}
return (
<div className="core-game-screen">
<DeskBar
deskCards={deskCards}
gauges={gauges}
onOpenCombine={() => setShowCombine(true)}
onOpenGuide={() => setShowGuide(true)}
/>
<div className="core-status-row">
<span>실적 {score}</span>
<span>예산 {tokens.budget} · 인력 {tokens.personnel} · 정보 {tokens.info}</span>
<span>명분 {justifications.length}개 보유</span>
</div>
<CoreSwipeCard card={currentCard} onSwipe={handleSwipe} />
<div className="core-log">
{log.map((line, i) => <div key={i} className="core-log-line">{line}</div>)}
</div>
{showCombine && (
<CombinePanel
deskCards={deskCards}
onConfirm={handleCombineConfirm}
onClose={() => setShowCombine(false)}
/>
)}
{showGuide && <HandGuidePopup onClose={() => setShowGuide(false)} />}
</div>
);
}
@@ -0,0 +1,125 @@
.core-card-container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 16px;
min-height: 0;
}
.core-swipe-card {
position: relative;
width: min(420px, 92vw);
min-height: 320px;
background: var(--color-surface);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
padding: 0;
display: flex;
flex-direction: column;
cursor: grab;
touch-action: none;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
/* 좌/우 선택 슬롯 — 카드 본문과 절대 겹치지 않는 고정 영역. 항상 노출된다 (§3.3). */
.core-choice-row {
display: flex;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.core-choice {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
min-width: 0;
}
.core-choice.left {
border-right: 1px solid rgba(255, 255, 255, 0.08);
}
.core-choice.right {
justify-content: flex-end;
text-align: right;
}
.choice-arrow {
font-size: 16px;
opacity: 0.5;
flex-shrink: 0;
}
.choice-body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.core-choice.right .choice-body {
align-items: flex-end;
}
.choice-text {
font-size: 13px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.choice-safe {
font-size: 10px;
opacity: 0.4;
font-family: var(--font-typewriter);
}
.core-card-header {
padding: 16px 20px 0;
}
.core-card-category {
font-family: var(--font-typewriter);
font-size: 12px;
opacity: 0.6;
letter-spacing: 1px;
}
.core-card-keyword {
font-family: var(--font-doc-title);
margin: 4px 0 0;
font-size: 20px;
}
.core-card-question {
font-family: var(--font-ui);
margin: 6px 0 0;
font-size: 15px;
opacity: 0.85;
}
.core-card-body {
padding: 14px 20px 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.core-card-text {
font-size: 14px;
line-height: 1.6;
margin: 0;
}
.core-card-flavor {
font-size: 12px;
font-style: italic;
opacity: 0.55;
margin: 0;
}
@@ -0,0 +1,88 @@
import React, { useEffect } from 'react';
import { motion, useMotionValue, useTransform, useAnimation } from 'framer-motion';
import SymbolBadge from './SymbolBadge';
import './CoreSwipeCard.css';
// §3 핵심 요건: "스와이프 판단은 기호만 보고 0.5초 내에 이루어져야 한다." 텍스트는 서사만 담당한다.
// 플레이테스트 피드백 반영: 처음엔 이 좌/우 예고를 카드 위에 겹쳐서(absolute) 그렸더니 드래그
// 중에 결재 카드 본문 글자를 가렸다. 그리고 기호 라벨("인사"/"대외" 등)만 보여서 이 선택이
// 실제로 뭘 하는 선택인지(대사)가 안 읽혔다. 그래서 좌/우 슬롯을 카드 위 고정 영역으로 빼고,
// 기호 + 선택 대사를 처음부터 항상 같이 보여준다. 드래그 제스처·물리는 기존
// components/SwipeCard.jsx를 재사용했다 (기획서 "재사용 자산").
export default function CoreSwipeCard({ card, onSwipe }) {
const x = useMotionValue(0);
const y = useMotionValue(0);
const controls = useAnimation();
const rotate = useTransform(x, [-200, 200], [-15, 15]);
// 드래그 중인 방향의 선택 슬롯을 강조한다 — 겹쳐 가리는 대신 이미 보이는 슬롯을 밝힌다.
const leftHighlight = useTransform(x, [-100, 0], [1, 0.4]);
const rightHighlight = useTransform(x, [0, 100], [0.4, 1]);
useEffect(() => {
x.set(0);
y.set(0);
controls.start({ x: 0, y: 0, opacity: 1, rotate: 0, transition: { duration: 0 } });
}, [card, controls, x, y]);
const handleDragEnd = async (event, info) => {
const threshold = 100;
if (info.offset.x > threshold) {
await controls.start({ x: 300, opacity: 0, transition: { duration: 0.25 } });
onSwipe('right');
} else if (info.offset.x < -threshold) {
await controls.start({ x: -300, opacity: 0, transition: { duration: 0.25 } });
onSwipe('left');
} else {
controls.start({ x: 0, y: 0, transition: { type: 'spring', stiffness: 300, damping: 20 } });
}
};
if (!card) return null;
return (
<div className="core-card-container">
<motion.div
className="core-swipe-card"
drag
dragConstraints={{ left: 0, right: 0, top: 0, bottom: 0 }}
style={{ x, y, rotate }}
onDragEnd={handleDragEnd}
animate={controls}
whileTap={{ scale: 0.98, cursor: 'grabbing' }}
>
{/* 좌/우 선택 슬롯 — 항상 보이고, 카드 본문과 겹치지 않는 고정 영역 */}
<div className="core-choice-row">
<motion.div className="core-choice left" style={{ opacity: leftHighlight }}>
<span className="choice-arrow">←</span>
<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} />
: <span className="choice-safe">오염 없음</span>}
</div>
</motion.div>
<motion.div className="core-choice right" style={{ opacity: rightHighlight }}>
<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} />
: <span className="choice-safe">오염 없음</span>}
</div>
<span className="choice-arrow">→</span>
</motion.div>
</div>
<div className="core-card-header">
<span className="core-card-category">[{card.type}]</span>
{card.keyword && <h3 className="core-card-keyword">{card.keyword}</h3>}
{card.question && <h2 className="core-card-question">{card.question}</h2>}
</div>
<div className="core-card-body">
<p className="core-card-text">{card.text}</p>
{card.flavor && <p className="core-card-flavor">"{card.flavor}"</p>}
</div>
</motion.div>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
.desk-bar {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 16px;
background: var(--color-surface);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
font-family: var(--font-typewriter);
}
.desk-bar-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.desk-total {
font-size: 13px;
font-weight: 700;
}
.desk-surcharge {
color: var(--color-risk-critical);
font-weight: 400;
}
.desk-bar-actions {
display: flex;
gap: 8px;
}
.desk-guide-btn,
.desk-combine-btn {
font-family: var(--font-typewriter);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
padding: 7px 12px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
font-size: 12px;
}
.desk-guide-btn {
background: transparent;
color: var(--color-text);
}
.desk-combine-btn {
background: var(--color-token-budget);
color: #0a0a0a;
border-color: transparent;
}
.desk-combine-btn:disabled {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.35);
cursor: not-allowed;
}
.desk-bar-rows {
display: flex;
flex-direction: column;
gap: 3px;
}
.desk-row {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
color: var(--color-text);
padding: 3px 4px;
border-radius: 4px;
cursor: pointer;
text-align: left;
width: 100%;
font-family: var(--font-typewriter);
}
.desk-row:not(:disabled):hover {
background: rgba(255, 255, 255, 0.05);
}
.desk-row:disabled {
cursor: default;
opacity: 0.5;
}
.desk-row-name {
font-size: 11px;
width: 28px;
flex-shrink: 0;
opacity: 0.75;
}
.desk-row-chips {
display: flex;
gap: 5px;
flex: 1;
flex-wrap: wrap;
min-width: 0;
}
.desk-chip {
font-size: 12px;
background: rgba(255, 255, 255, 0.06);
padding: 1px 6px;
border-radius: 4px;
white-space: nowrap;
}
.desk-row-empty {
font-size: 11px;
opacity: 0.35;
}
.desk-row-gauge {
font-size: 11px;
opacity: 0.65;
white-space: nowrap;
flex-shrink: 0;
}
.desk-row-rate {
color: var(--color-risk-critical);
opacity: 1;
}
+74
View File
@@ -0,0 +1,74 @@
import React, { useState } from 'react';
import { AXES, AXIS_META, gradeDots } from '../engine/symbols';
import { gradeBreakdownByAxis, totalCount, 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 }) {
const [detailAxis, setDetailAxis] = useState(null);
const breakdown = gradeBreakdownByAxis(deskCards);
const rates = gaugeRiseRateByAxis(deskCards);
const total = totalCount(deskCards);
const surcharge = tokenSurcharge(deskCards);
return (
<div className="desk-bar">
<div className="desk-bar-top">
<div className="desk-total">
책상 총 {total}장
{surcharge > 0 && <span className="desk-surcharge"> · 지출 가산 +{surcharge}</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>
</div>
</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);
return (
<button
key={axis}
className="desk-row"
onClick={() => count > 0 && setDetailAxis(axis)}
disabled={count === 0}
>
<AxisIcon axis={axis} size={17} />
<span className="desk-row-name">{AXIS_META[axis].name}</span>
<span className="desk-row-chips">
{gradeEntries.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>
))}
</span>
<span className="desk-row-gauge">
→ {AXIS_META[axis].gaugeName} {Math.round(gauges[AXIS_META[axis].gauge] || 0)}%
{rates[axis] > 0 && <span className="desk-row-rate"> ▲+{rates[axis].toFixed(1)}%/결재</span>}
</span>
</button>
);
})}
</div>
{detailAxis && (
<DeskDetailPopup
axis={detailAxis}
cards={deskCards.filter((c) => c.axis === detailAxis)}
onClose={() => setDetailAxis(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,78 @@
.desk-detail-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 55;
padding: 16px;
}
.desk-detail-panel {
background: var(--color-surface);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
width: min(480px, 100%);
max-height: 80vh;
overflow-y: auto;
padding: 18px;
}
.desk-detail-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.desk-detail-header h2 {
margin: 0;
font-size: 16px;
flex: 1;
font-family: var(--font-doc-title);
}
.desk-detail-close {
background: none;
border: none;
color: var(--color-text);
font-size: 20px;
cursor: pointer;
}
.desk-detail-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.desk-detail-item {
display: flex;
gap: 10px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.03);
border-radius: 6px;
}
.desk-detail-dots {
font-size: 14px;
white-space: nowrap;
padding-top: 1px;
}
.desk-detail-name {
font-size: 13px;
font-weight: 700;
}
.desk-detail-flavor {
font-size: 11px;
opacity: 0.6;
margin-top: 2px;
}
.desk-detail-empty {
font-size: 12px;
opacity: 0.5;
}
@@ -0,0 +1,35 @@
import React from 'react';
import { AXIS_META, gradeDots } from '../engine/symbols';
import { AxisIcon } from './SymbolBadge';
import './DeskDetailPopup.css';
// 축 하나를 클릭했을 때 그 축에 쌓인 개별 오염 카드(이름·등급·사연)를 보여준다.
// DeskBar의 기본 뷰는 §4.1 "개별 카드 나열 금지" 때문에 집계만 보여주므로, 상세는 여기서.
export default function DeskDetailPopup({ axis, cards, onClose }) {
const meta = AXIS_META[axis];
const sorted = [...cards].sort((a, b) => b.grade - a.grade);
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>
<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>
<div className="desk-detail-text">
<div className="desk-detail-name">{c.name || '미상 서류'}</div>
{c.flavor && <div className="desk-detail-flavor">{c.flavor}</div>}
</div>
</div>
))}
{cards.length === 0 && <p className="desk-detail-empty">이 축에 쌓인 오염 카드가 없습니다.</p>}
</div>
</div>
</div>
);
}
@@ -0,0 +1,102 @@
.guide-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 60;
padding: 16px;
}
.guide-panel {
background: var(--color-surface);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
width: min(560px, 100%);
max-height: 85vh;
overflow-y: auto;
padding: 20px;
}
.guide-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.guide-header h2 {
margin: 0;
font-family: var(--font-doc-title);
}
.guide-close {
background: none;
border: none;
color: var(--color-text);
font-size: 22px;
cursor: pointer;
}
.guide-hint {
font-size: 12px;
opacity: 0.65;
margin: 0 0 14px;
line-height: 1.5;
}
.guide-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.guide-row {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
padding: 10px 12px;
}
.guide-row-head {
display: flex;
align-items: baseline;
gap: 10px;
font-family: var(--font-typewriter);
}
.guide-name {
font-weight: 700;
flex: 1;
}
.guide-score { font-size: 12px; color: var(--color-token-budget); }
.guide-justification { font-size: 12px; opacity: 0.65; }
.guide-condition {
font-size: 12px;
opacity: 0.7;
margin: 4px 0 8px;
}
.guide-example {
display: flex;
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); }
@@ -0,0 +1,57 @@
import React from 'react';
import { HAND_DEFS } from '../engine/hands';
import { gradeDots } from '../engine/symbols';
import { AxisIcon } 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]] },
];
export default function HandGuidePopup({ onClose }) {
return (
<div className="guide-overlay" onClick={onClose}>
<div className="guide-panel" onClick={(e) => e.stopPropagation()}>
<div className="guide-header">
<h2>족보표</h2>
<button className="guide-close" onClick={onClose}>&times;</button>
</div>
<p className="guide-hint">
"같은 등급"은 트럼프의 같은 숫자, "같은 축"은 같은 무늬와 같다. 책상에서 카드를
선택하면 아래 조건에 맞는 족보가 자동으로 판정된다.
</p>
<div className="guide-list">
{GUIDE.map(({ id, condition, example }) => {
const def = HAND_DEFS[id];
return (
<div key={id} className="guide-row">
<div className="guide-row-head">
<span className="guide-name">{def.name}</span>
<span className="guide-score">실적 +{def.score}</span>
<span className="guide-justification">명분 +{def.baseJustification}</span>
</div>
<div className="guide-condition">{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>
))}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
@@ -0,0 +1,30 @@
.symbol-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.symbol-dots {
font-size: 14px;
letter-spacing: 1px;
}
.symbol-name {
font-size: 12px;
color: var(--color-text);
opacity: 0.75;
}
/* 축별 색상 — 인장 아이콘 형태가 1차 구분 수단이고, 색은 보조 신호일 뿐이다 (§3.1). */
.axis-axis_threat .axis-icon,
.symbol-badge.axis-axis_threat .symbol-dots { color: var(--color-entropy-critical); }
.axis-axis_hr .axis-icon,
.symbol-badge.axis-axis_hr .symbol-dots { color: var(--color-resistance-critical); }
.axis-axis_pr .axis-icon,
.symbol-badge.axis-axis_pr .symbol-dots { color: var(--color-panic-critical); }
.axis-axis_external .axis-icon,
.symbol-badge.axis-axis_external .symbol-dots { color: var(--color-risk-critical); }
@@ -0,0 +1,32 @@
import React from 'react';
import { Tornado, Hand, Megaphone, Stamp } from 'lucide-react';
import { AXIS_META, gradeDots } from '../engine/symbols';
import './SymbolBadge.css';
// §3.1 요건: 4종 인장은 실루엣(아이콘 형태)으로 구분한다. 색상만으로 구분하지 않는다 —
// 그래서 axis마다 아이콘 자체가 다르고, CSS 색상은 보조 신호로만 얹는다.
const AXIS_ICON = {
axis_threat: Tornado,
axis_hr: Hand,
axis_pr: Megaphone,
axis_external: Stamp,
};
/** 축 인장 하나. size는 lucide 아이콘 px 크기. */
export function AxisIcon({ axis, size = 20, className = '' }) {
const Icon = AXIS_ICON[axis];
if (!Icon) return null;
return <Icon size={size} className={`axis-icon axis-${axis} ${className}`} strokeWidth={2.25} />;
}
/** 축 인장 + 기밀 등급(점 표기)을 한 세트로 보여주는 배지. 스와이프 예고·책상 카드에 공용으로 쓴다. */
export default function SymbolBadge({ axis, grade, size = 22, showName = false }) {
if (!axis) return null;
return (
<div className={`symbol-badge axis-${axis}`}>
<AxisIcon axis={axis} size={size} />
<span className="symbol-dots">{gradeDots(grade)}</span>
{showName && <span className="symbol-name">{AXIS_META[axis].name}</span>}
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
// 책상(Desk) 엔진 — ProjectSS_코어시스템_기획서.md §4.
// 오염 카드는 덱에 들어가지 않는다. 드로우·셔플 없음, 슬롯 무제한. 여기서 다루는 desk는
// 순수 배열 [{ id, axis, grade }] 이며, React state는 이 배열을 그대로 들고 있다가
// 이 모듈의 함수로 파생값(축별 스택, 패널티)을 계산한다. React/훅 의존 없음 — 순수 함수만.
import { AXES } 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,
name: meta.name || null,
flavor: meta.flavor || null,
tag: meta.tag || null,
});
/** 축별 적재 장수. { axis_threat: 3, axis_hr: 1, ... } 형태로 항상 4축 전부를 채워 반환한다. */
export function countByAxis(deskCards) {
const out = Object.fromEntries(AXES.map((a) => [a, 0]));
deskCards.forEach((c) => { out[c.axis] = (out[c.axis] || 0) + 1; });
return out;
}
/** 축별 등급 분포. { axis_threat: {1: 2, 3: 1}, ... } — DeskBar의 축×등급 매트릭스 표시용. */
export function gradeBreakdownByAxis(deskCards) {
const out = Object.fromEntries(AXES.map((a) => [a, {}]));
deskCards.forEach((c) => {
out[c.axis][c.grade] = (out[c.axis][c.grade] || 0) + 1;
});
return out;
}
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 };
/** 해당 축의 오염 압력(P) = Σ 카드별 등급 가중치. */
export function axisPressure(deskCards, axis) {
return deskCards
.filter((c) => c.axis === axis)
.reduce((sum, c) => sum + (GRADE_WEIGHT[c.grade] || 0), 0);
}
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 },
];
/** 오염 압력(P)에 따른 결재 1건당 게이지 상승률(%). */
export function gaugeRiseRate(pressure) {
const tier = GAUGE_TIERS.find((t) => pressure <= t.max);
return tier ? tier.rate : 13.0;
}
/** 축별 현재 상승률(%) — DeskBar의 "▲+X%/결재" 표시에 그대로 쓴다. */
export function gaugeRiseRateByAxis(deskCards) {
const out = {};
AXES.forEach((axis) => { out[axis] = gaugeRiseRate(axisPressure(deskCards, axis)); });
return out;
}
/** 매 결재마다 4개 게이지 전부에 축별 상승분을 더한다. gauges = { entropy, resistance, panic, risk } (0~100). */
export function applyPassiveGaugeRise(gauges, deskCards) {
const next = { ...gauges };
AXES.forEach((axis) => {
const gaugeKey = axisGaugeKey(axis);
const rate = gaugeRiseRate(axisPressure(deskCards, axis));
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) — 총 적재 장수에 따라 모든 토큰 지출에 가산 ── */
const SURCHARGE_TIERS = [
{ max: 7, surcharge: 0 },
{ max: 14, surcharge: 1 },
{ max: 21, surcharge: 2 },
{ max: Infinity, surcharge: 4 },
];
/** 총 적재 장수 기준 모든 토큰 지출에 붙는 가산액. */
export function tokenSurcharge(deskCards) {
const total = totalCount(deskCards);
const tier = SURCHARGE_TIERS.find((t) => total <= t.max);
return tier ? tier.surcharge : 4;
}
/** 책상에서 특정 카드들을 제거한 새 배열을 반환한다 (합본 실행 시 사용). */
export function removeFromDesk(deskCards, idsToRemove) {
const idSet = new Set(idsToRemove);
return deskCards.filter((c) => !idSet.has(c.id));
}
export function addToDesk(deskCards, newCards) {
return [...deskCards, ...newCards];
}
+106
View File
@@ -0,0 +1,106 @@
// 합본(족보) 판정 · 실행 엔진 — 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에서 그대로 가져와 표 값 위에 얹었다.
import { justificationOf, tokenOf } from './symbols.js';
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 },
};
// 족보 우선순위(높을수록 상위) — 동시 성립 시 기본 선택값과 정렬 순서에 쓴다.
const HAND_RANK = ['pair', 'triple', 'flush_axis', 'straight', '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);
/** 등급이 중복 없이 연속 오름차순인가 (예: 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 gradeCounts(cards) {
const m = {};
cards.forEach((c) => { m[c.grade] = (m[c.grade] || 0) + 1; });
return Object.values(m).sort((a, b) => b - a);
}
/**
* 선택된 책상 카드 묶음이 어떤 족보(들)로 성립하는지 판정한다.
* 여러 족보가 동시 성립할 수 있다 (§5.3) — 예: 같은 축 + 연속 등급 3장은
* flush_axis / straight / straight_flush 세 가지 모두로 성립하며, 플레이어가 하나를 고른다.
* @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));
}
/**
* 선택한 족보로 합본을 실행한 결과를 계산한다 (책상에서 실제로 지우는 건 호출부 책임).
* @param {Array<{id,axis,grade}>} selection
* @param {string} patternId - HAND_DEFS의 key
*/
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 uniformAxis = sameAxis(selection) ? selection[0].axis : null;
const justificationType = uniformAxis ? justificationOf(uniformAxis) : '혼합';
const hasTopSecret = selection.some((c) => c.grade === 4);
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 };
return {
pattern: def,
justification: { type: justificationType, amount: justificationAmount },
tokens,
performanceScore: def.score,
removeIds: selection.map((c) => c.id),
};
}
+22
View File
@@ -0,0 +1,22 @@
// 오염 카드 풀 — data/pollution_pool.csv를 그대로 읽어 "이 축·등급이면 어떤 이름과
// 사연을 가진 오염 카드인가"를 뽑아 준다. 지금까지는 이 CSV가 어디서도 쓰이지 않아서
// 책상 카드가 축 라벨만 가진 익명 토큰이었다 — 플레이테스트 피드백(선택의 서사가 안 보임)의
// 원인 중 하나였다. React 의존 없는 순수 조회 함수만 둔다.
import Papa from 'papaparse';
import poolCsvRaw from '../../data/pollution_pool.csv?raw';
const POOL = Papa.parse(poolCsvRaw, { header: true, skipEmptyLines: true }).data;
/**
* 주어진 축·등급에 맞는 오염 카드 항목을 하나 뽑는다.
* 정확히 맞는 등급이 없으면 같은 축 안에서 아무거나 폴백한다 (템플릿 물량이 등급별로
* 고르지 않을 수 있어서다 — pollution_pool.csv는 등급당 딱 1~2개뿐이다).
*/
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)];
return { name: row.name, flavor: row.flavor, tag: row.tag || null };
}
+62
View File
@@ -0,0 +1,62 @@
// 기호 체계 (ProjectSS_코어시스템_기획서.md §3) — 모든 카드가 공유하는 단일 기호 언어.
// 스와이프 판단은 텍스트가 아니라 이 기호(축 인장 + 기밀 등급)만으로 0.5초 내에 이루어져야 한다.
// 색상만으로 구분하지 말 것 — 아이콘 형태(실루엣) 자체가 축을 구분해야 한다 (§3.1).
export const AXES = ['axis_threat', 'axis_hr', 'axis_pr', 'axis_external'];
// 인장(아이콘)은 lucide-react의 Tornado/Hand/Megaphone/Stamp를 그대로 쓴다.
// 실제 아이콘 컴포넌트 매핑은 AxisIcon.jsx에서 담당 — 여기는 축의 이름·게이지·명분·토큰 매핑만.
export const AXIS_META = {
axis_threat: {
name: '위협',
sealName: '소용돌이',
gauge: 'entropy',
gaugeName: '엔트로피',
justification: '위기',
token: null, // §8.2: 위협 축은 플레이어 선택(또는 소량 전종) — economy.js에서 처리
},
axis_hr: {
name: '인사',
sealName: '손',
gauge: 'resistance',
gaugeName: '조직 저항',
justification: '내부',
token: 'personnel',
},
axis_pr: {
name: '공보',
sealName: '확성기',
gauge: 'panic',
gaugeName: '대중 공황',
justification: '여론',
token: 'info',
},
axis_external: {
name: '대외',
sealName: '도장',
gauge: 'risk',
gaugeName: '위험 수용 계수',
justification: '절차',
token: 'budget',
},
};
export const GRADE_MIN = 1;
export const GRADE_MAX = 4;
export const GRADE_NAME = {
1: '일반',
2: '대외비',
3: '2급 기밀',
4: '1급 기밀',
};
// 숫자가 아닌 점 개수로 표기한다 (§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 gaugeOf = (axis) => AXIS_META[axis]?.gauge ?? null;
export const justificationOf = (axis) => AXIS_META[axis]?.justification ?? '혼합';
export const tokenOf = (axis) => AXIS_META[axis]?.token ?? null;
+6 -6
View File
@@ -1,26 +1,26 @@
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,2,tag_sacrifice,작전 기각,axis_hr,1,
5,작전,전술 폭격 건의,폭격을 승인하시겠습니까?,철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다.,희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠.,궤도 폭격 승인,axis_external,3,tag_sacrifice,작전 기각,axis_hr,1,
6,일상,부산물 암시장 매각,매각을 허가하시겠습니까?,차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다.,이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠.,매각 허가,axis_external,1,tag_corruption,원칙대로 소각,,,
7,작전,존재 양지화 프로젝트,프로젝트를 승인하시겠습니까?,대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다.,우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다.,감시망 구축,,,tag_coverup,프로젝트 보류,axis_hr,1,
9,위기,이단 색출 마녀사냥,어떻게 개입하시겠습니까?,기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다.,더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로!,적당히 묵인한다,axis_hr,1,,군사력으로 제압,axis_external,1,
10,일상,변칙 현상 영상 유출,어떻게 처리하시겠습니까?,한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다.,대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오.,제거 절차 승인,,,tag_coverup,영상만 삭제,axis_threat,1,
12,위기,의회의 긴급 감사,어떻게 대처하시겠습니까?,의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다!,세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오.,은폐 공작 실행,axis_external,1,tag_corruption,횡령 사실 인정,axis_external,2,
12,위기,의회의 긴급 감사,어떻게 대처하시겠습니까?,의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다!,세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오.,은폐 공작 실행,axis_external,1,tag_corruption,횡령 사실 인정,axis_external,3,
13,일상,연구 부서 파업 조짐,요구를 수용하시겠습니까?,연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다.,저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요.,특별 보너스 지급,,,,요구 전면 묵살,axis_hr,1,
14,작전,이계 무역로 개방,무역로를 개방하시겠습니까?,이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다.,저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠.,게이트 강제 개방,axis_threat,1,tag_alien_contact,폐쇄 상태 유지,axis_hr,1,
15,극비,전임 국장 비밀 금고,금고를 개방하시겠습니까?,전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다.,전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다.,강제 개방 지시,,,tag_corruption,영구 봉인,,,
16,작전,메가코프 협박 작전,작전을 실행하시겠습니까?,글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다.,그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까.,작전 실행,,,,작전 취소,axis_hr,1,
17,위기,전술 폭격 건의,폭격을 승인하시겠습니까?,제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요?,시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다.,시민 포기 및 폭격,axis_external,2,tag_ruthless,결사 방어 명령,,,
17,위기,전술 폭격 건의,폭격을 승인하시겠습니까?,제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요?,시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다.,시민 포기 및 폭격,axis_external,3,tag_ruthless,결사 방어 명령,,,
18,일상,행정부 파업 예고,어떻게 대응하시겠습니까?,새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다.,기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다.,예산 전용으로 무마,axis_external,1,,파업 강경 대응,axis_hr,1,
19,극비,불침번 요원 개발,배치하시겠습니까?,비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다.,잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠.,실전 즉각 배치,axis_threat,1,,즉각 폐기 처분,axis_hr,1,
20,일상,내부 스파이 첩보,어떻게 조사하시겠습니까?,기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다.,누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다.,대규모 사찰 시작,axis_hr,2,tag_political_pressure,증거 부족으로 덮음,axis_external,1,
20,일상,내부 스파이 첩보,어떻게 조사하시겠습니까?,기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다.,누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다.,대규모 사찰 시작,axis_hr,3,tag_political_pressure,증거 부족으로 덮음,axis_external,1,
25,극비,내부 고발자 발생,어떻게 막으시겠습니까?,의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다.,국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다.,사고사 위장 지시,,,tag_coverup,폭로전 정면 돌파,axis_external,2,
26,위기,차원 붕괴 폭탄 오작동,어떻게 대응하시겠습니까?,차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다.,최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다.,특수 해체팀 투입,,,tag_sacrifice,폭발 방관 및 대피,axis_threat,2,
26,위기,차원 붕괴 폭탄 오작동,어떻게 대응하시겠습니까?,차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다.,최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다.,특수 해체팀 투입,,,tag_sacrifice,폭발 방관 및 대피,axis_threat,3,
27,작전,PMC 흡수 합병,결탁하시겠습니까?,군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다.,돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠.,결탁 및 흡수 합병,,,,독자적 노선 유지,axis_hr,1,
28,작전,대규모 거주 구역 실험장화,제안을 승인하시겠습니까?,변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다.,시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠.,돔 건설 강행,axis_threat,1,,윤리적 이유로 기각,axis_external,1,
30,일상,믹스 커피 재고 고갈,어떻게 해결하시겠습니까?,국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다.,종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까?,최고급 원두 지원,,,,헛소리 금지령,axis_hr,1,
31,위기,신화적 존재 강림,어떻게 대적하시겠습니까?,신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다!,최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까?,현실 조작기 가동,axis_threat,2,tag_madness,모든 예비병력 투입,axis_external,1,tag_sacrifice
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,
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 tag_madness 제안 거절 axis_hr 1
4 5 작전 전술 폭격 건의 폭격을 승인하시겠습니까? 철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다. 희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠. 궤도 폭격 승인 axis_external 2 3 tag_sacrifice 작전 기각 axis_hr 1
5 6 일상 부산물 암시장 매각 매각을 허가하시겠습니까? 차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다. 이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠. 매각 허가 axis_external 1 tag_corruption 원칙대로 소각
6 7 작전 존재 양지화 프로젝트 프로젝트를 승인하시겠습니까? 대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다. 우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다. 감시망 구축 tag_coverup 프로젝트 보류 axis_hr 1
7 9 위기 이단 색출 마녀사냥 어떻게 개입하시겠습니까? 기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다. 더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로! 적당히 묵인한다 axis_hr 1 군사력으로 제압 axis_external 1
8 10 일상 변칙 현상 영상 유출 어떻게 처리하시겠습니까? 한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다. 대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오. 제거 절차 승인 tag_coverup 영상만 삭제 axis_threat 1
9 12 위기 의회의 긴급 감사 어떻게 대처하시겠습니까? 의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다! 세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오. 은폐 공작 실행 axis_external 1 tag_corruption 횡령 사실 인정 axis_external 2 3
10 13 일상 연구 부서 파업 조짐 요구를 수용하시겠습니까? 연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다. 저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요. 특별 보너스 지급 요구 전면 묵살 axis_hr 1
11 14 작전 이계 무역로 개방 무역로를 개방하시겠습니까? 이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다. 저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠. 게이트 강제 개방 axis_threat 1 tag_alien_contact 폐쇄 상태 유지 axis_hr 1
12 15 극비 전임 국장 비밀 금고 금고를 개방하시겠습니까? 전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다. 전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다. 강제 개방 지시 tag_corruption 영구 봉인
13 16 작전 메가코프 협박 작전 작전을 실행하시겠습니까? 글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다. 그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까. 작전 실행 작전 취소 axis_hr 1
14 17 위기 전술 폭격 건의 폭격을 승인하시겠습니까? 제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요? 시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다. 시민 포기 및 폭격 axis_external 2 3 tag_ruthless 결사 방어 명령
15 18 일상 행정부 파업 예고 어떻게 대응하시겠습니까? 새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다. 기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다. 예산 전용으로 무마 axis_external 1 파업 강경 대응 axis_hr 1
16 19 극비 불침번 요원 개발 배치하시겠습니까? 비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다. 잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠. 실전 즉각 배치 axis_threat 1 즉각 폐기 처분 axis_hr 1
17 20 일상 내부 스파이 첩보 어떻게 조사하시겠습니까? 기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다. 누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다. 대규모 사찰 시작 axis_hr 2 3 tag_political_pressure 증거 부족으로 덮음 axis_external 1
18 25 극비 내부 고발자 발생 어떻게 막으시겠습니까? 의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다. 국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다. 사고사 위장 지시 tag_coverup 폭로전 정면 돌파 axis_external 2
19 26 위기 차원 붕괴 폭탄 오작동 어떻게 대응하시겠습니까? 차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다. 최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다. 특수 해체팀 투입 tag_sacrifice 폭발 방관 및 대피 axis_threat 2 3
20 27 작전 PMC 흡수 합병 결탁하시겠습니까? 군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다. 돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠. 결탁 및 흡수 합병 독자적 노선 유지 axis_hr 1
21 28 작전 대규모 거주 구역 실험장화 제안을 승인하시겠습니까? 변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다. 시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠. 돔 건설 강행 axis_threat 1 윤리적 이유로 기각 axis_external 1
22 30 일상 믹스 커피 재고 고갈 어떻게 해결하시겠습니까? 국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다. 종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까? 최고급 원두 지원 헛소리 금지령 axis_hr 1
23 31 위기 신화적 존재 강림 어떻게 대적하시겠습니까? 신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다! 최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까? 현실 조작기 가동 axis_threat 2 4 tag_madness 모든 예비병력 투입 axis_external 1 tag_sacrifice
24 2 위기 격리실 오염 발생 오염을 어떻게 정화하시겠습니까? 지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다. 보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다. 네이팜 소각 강행 조사팀 파견 axis_threat 1
25 3 위기 촉수 괴물 출현 출동 승인하시겠습니까? 7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다. 규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다. 내역 위조 후 출동 axis_external 1 규정대로 반려 axis_threat 1
26 8 극비 대규모 도서관 건립 예산을 배정하시겠습니까? 고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다. 이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다. 예산 배정 예산 전액 삭감 axis_external 1
@@ -0,0 +1,84 @@
---
type: decision
status: decided
created: 2026-09-05
updated: 2026-09-05
tags: [core-system, graybox, ux, balance, desk, hands]
impact:
- client/src/core/engine/desk.js
- client/src/core/engine/pollutionPool.js (신규)
- client/src/core/components/DeskBar.jsx
- client/src/core/components/DeskDetailPopup.jsx (신규)
- client/src/core/components/HandGuidePopup.jsx (신규)
- client/src/core/components/CoreSwipeCard.jsx
- client/src/core/components/CombinePanel.jsx
- client/src/data/cards.csv
---
## 배경
구현 우선순위 1차(§17: 기호 체계·책상 적재/누진 페널티 A·B·표준 족보 판정/합본·토큰 산출) 그레이박스를
실제로 플레이해 본 뒤 5가지 문제가 보고됐다.
1. 합본 결재 버튼을 누르기 전에는 현재 책상에 어떤 오염 카드를 보유했는지 알 수 없었다.
2. 좌/우 스와이프 시 카드 위에 겹쳐 그리던 오염 예고 정보가 결재 카드 본문 글자를 가렸다.
3. 트럼프 족보 구조를 그대로 썼지만(§5.1 설계 원칙), 기호 자체가 다르면 "같은 등급 3장" 같은
조건이 바로 안 와닿아 판정 기준을 확인할 방법이 없었다.
4. 4대 게임오버 게이지가 §4.2-A 표(장수 기준, 1~3장 +0.5%)대로 상승해서 초반 위기감이 거의 없었다.
5. 좌/우 선택 시 "인사/대외/위협/공보" 같은 분류 라벨만 보여서, 결재 카드 본문을 읽을 필요도
없어지고 그 선택이 왜 해당 게이지를 올리는지 인과가 안 읽혔다.
추가로, 4번을 고치는 과정에서 `cards.csv`의 딜레마 카드가 전부 등급 1~2만 생성한다는 사실이
드러났다 — 등급 3~4 오염은 `pollution_pool.csv`에 데이터가 있어도 게임에 전혀 등장하지 않는 구조였다.
## 결정
1. **책상 상시 노출 강화** — `DeskBar`를 축별 등급 그룹 칩(`●●○○×2`)까지 보여주도록 확장하고,
축 행 클릭 시 `DeskDetailPopup`으로 개별 카드 이름·사연을 볼 수 있게 한다.
2. **좌/우 예고를 카드 위 오버레이에서 상단 고정 슬롯(`core-choice-row`)으로 이동** — 드래그 여부와
무관하게 항상 노출하고, 카드 본문과 겹치지 않는 별도 영역으로 분리한다.
3. **족보표 팝업 추가** — `DeskBar`에 `[족보표]` 버튼을 두고, 트럼프 용어가 아니라 이 게임의
축 인장·등급 점 표기로 7종 족보 조건과 예시를 보여주는 `HandGuidePopup`을 연다.
4. **게이지 상승 기준을 "장수"에서 "등급 가중 오염 압력(P)"으로 변경** — 등급별 가중치
(1=1, 2=2, 3=3, 4=5)를 합산한 P로 상승률을 재산정한다. 구간도 초반 체감이 되도록 올렸다
(P 1~2: +1.0%, 3~6: +2.0%, 7~12: +3.5%, 13~20: +6.0%, 21~30: +9.0%, 31+: +13.0% / 결재).
현재 축별 상승률은 `DeskBar`에 `▲+X.X%/결재`로 상시 표시한다.
5. **오염 카드에 실제 이름·사연 부여** — 그동안 어디서도 쓰이지 않던 `pollution_pool.csv`를
`pollutionPool.js`로 연결해, 책상에 쌓이는 카드가 축 라벨이 아니라 구체적인 서류 이름
(예: 『항명 성명』)을 갖게 한다. 결재 로그도 `『선택 문구』 결재됨 → 오염 서류 『이름』 접수`
형태로 바꿔 선택-결과 인과를 드러낸다. (2번 레이아웃 수정과 합쳐 선택 문구 자체도 상시 노출됨.)
6. **등급 3~4 미출현 문제는 카드 데이터에 등급 3~4 선택지를 직접 추가하는 방식(안 ①)으로 해결** —
`cards.csv`의 서사적으로 심각한 선택지 6곳의 등급을 올렸다: id=31 좌측("신화적 존재 강림" →
현실 조작기 가동)을 1급 기밀(4)로, id=5·12·17·20·26을 2급 기밀(3)로.
## 검토했으나 기각한 대안
- **책상 표시를 지금처럼 축별 장수 집계만 유지** — 족보는 등급으로 판정되므로 등급이 안 보이면
합본 설계 자체가 불가능하다는 원 기획서 §3.4 취지(예고 없이는 스와이프가 도박이 된다)에
정면으로 어긋나 기각.
- **등급 3~4 미출현 문제: 안 ② 페이즈 상승 시 같은 선택의 등급을 보정** — 카드 데이터 변경 없이
런타임에서 등급을 올리는 방식. 데이터가 명시적이지 않아 플레이어가 "왜 이 선택이 이번엔 더
위험한지" 추론하기 어렵다고 보고, 데이터에 등급을 직접 박아 넣는 안 ①을 택함. 안 ③(2차로
미루기)도 검토했으나, 1차 그레이박스 검증 자체가 고등급 오염의 압박감을 포함해야 의미가
있다고 보고 기각.
- **게이지 상승 기준을 유지한 채 구간 상수만 올리는 안** — 장수만 세면 1급 기밀과 일반 문서가
똑같이 취급되는 근본 문제가 남는다고 판단해, 등급 가중 압력(P) 도입으로 대체.
## 근거
- "밸런스는 플레이해봐야 아는 것"이라는 방향에 따라, 정확한 수치 확정보다 **압박이 실제로
체감되는 구조**를 우선했다. 모든 수치는 가안이며 플레이테스트로 계속 조정한다
(ProjectSS_코어시스템_기획서.md의 "Greybox First" 원칙과 동일).
- §3 기호 체계의 핵심 요건("스와이프 판단은 기호만 보고 0.5초 내에 이루어져야 한다")과, 이번
피드백이 지적한 "서사가 안 읽힌다"는 문제는 상충하지 않는다 — 판단은 기호로, 인과·서사는
텍스트로 담당하도록 역할을 분리해 둘 다 만족시켰다.
- 등급 3~4를 데이터에 직접 추가하는 쪽이, 카드 밸런스와 스토리 개연성을 디자이너가 직접
검토할 수 있게 하라는 CLAUDE.md의 데이터 원칙에도 더 부합한다.
## 재검토 조건 (있다면)
- 공보(axis_pr) 축은 이번 작업 후에도 등급 2 이상 딜레마 선택지가 하나도 없다 — 다음 카드
데이터 배치 작업 때 반드시 재검토.
- 게이지 상승률 구간(P 1~2: +1.0% ~ 31+: +13.0%)은 가안 — 몇 라운드 플레이 후 "10장=슬슬 위험,
20장=이번 라운드 안에 못 정리하면 끝"(§4.3 기준선)에 맞는지 재확인.
- 이번에 등급을 올린 6곳(id=5,12,17,20,26,31)이 스토리 개연성상 적절한지 디자이너 검토 필요.
- 혼합축 합본의 토큰 산출 규칙("소량 전종")은 여전히 미확정 가안 — 상점(2차) 구현 시 재검토.