구현 우선 순위 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
+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;