diff --git a/client/src/App.jsx b/client/src/App.jsx index da6dc60..90a3583 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -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 (
- +
); } diff --git a/client/src/core/components/CombinePanel.css b/client/src/core/components/CombinePanel.css new file mode 100644 index 0000000..a13e618 --- /dev/null +++ b/client/src/core/components/CombinePanel.css @@ -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; } diff --git a/client/src/core/components/CombinePanel.jsx b/client/src/core/components/CombinePanel.jsx new file mode 100644 index 0000000..676d7f4 --- /dev/null +++ b/client/src/core/components/CombinePanel.jsx @@ -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 ( +
+
e.stopPropagation()}> +
+

합본 결재

+ +
+ +

책상에서 카드를 선택하면 성립하는 족보를 판정합니다.

+ +
+ {deskCards.length === 0 &&

책상이 비어 있습니다.

} + {deskCards.map((c) => ( + + ))} +
+ +
+ {selection.length > 0 && matches.length === 0 && ( +

선택한 {selection.length}장은 성립하는 족보가 없습니다.

+ )} + {matches.map((patternId) => { + const def = HAND_DEFS[patternId]; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/client/src/core/components/CoreGameScreen.css b/client/src/core/components/CoreGameScreen.css new file mode 100644 index 0000000..ea8d3e6 --- /dev/null +++ b/client/src/core/components/CoreGameScreen.css @@ -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; +} diff --git a/client/src/core/components/CoreGameScreen.jsx b/client/src/core/components/CoreGameScreen.jsx new file mode 100644 index 0000000..6f31ae9 --- /dev/null +++ b/client/src/core/components/CoreGameScreen.jsx @@ -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
카드를 불러오는 중...
; + } + + return ( +
+ setShowCombine(true)} + onOpenGuide={() => setShowGuide(true)} + /> + +
+ 실적 {score} + 예산 {tokens.budget} · 인력 {tokens.personnel} · 정보 {tokens.info} + 명분 {justifications.length}개 보유 +
+ + + +
+ {log.map((line, i) =>
{line}
)} +
+ + {showCombine && ( + setShowCombine(false)} + /> + )} + + {showGuide && setShowGuide(false)} />} +
+ ); +} diff --git a/client/src/core/components/CoreSwipeCard.css b/client/src/core/components/CoreSwipeCard.css new file mode 100644 index 0000000..3fd5f25 --- /dev/null +++ b/client/src/core/components/CoreSwipeCard.css @@ -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; +} diff --git a/client/src/core/components/CoreSwipeCard.jsx b/client/src/core/components/CoreSwipeCard.jsx new file mode 100644 index 0000000..a6d984f --- /dev/null +++ b/client/src/core/components/CoreSwipeCard.jsx @@ -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 ( +
+ + {/* 좌/우 선택 슬롯 — 항상 보이고, 카드 본문과 겹치지 않는 고정 영역 */} +
+ + ← +
+ {card.left?.text} + {card.left?.axis + ? + : 오염 없음} +
+
+ +
+ {card.right?.text} + {card.right?.axis + ? + : 오염 없음} +
+ → +
+
+ +
+ [{card.type}] + {card.keyword &&

{card.keyword}

} + {card.question &&

{card.question}

} +
+
+

{card.text}

+ {card.flavor &&

"{card.flavor}"

} +
+
+
+ ); +} diff --git a/client/src/core/components/DeskBar.css b/client/src/core/components/DeskBar.css new file mode 100644 index 0000000..7011692 --- /dev/null +++ b/client/src/core/components/DeskBar.css @@ -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; +} diff --git a/client/src/core/components/DeskBar.jsx b/client/src/core/components/DeskBar.jsx new file mode 100644 index 0000000..ecd2b3a --- /dev/null +++ b/client/src/core/components/DeskBar.jsx @@ -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 ( +
+
+
+ 책상 총 {total}장 + {surcharge > 0 && · 지출 가산 +{surcharge}} +
+
+ + +
+
+ +
+ {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 ( + + ); + })} +
+ + {detailAxis && ( + c.axis === detailAxis)} + onClose={() => setDetailAxis(null)} + /> + )} +
+ ); +} diff --git a/client/src/core/components/DeskDetailPopup.css b/client/src/core/components/DeskDetailPopup.css new file mode 100644 index 0000000..6b94cf4 --- /dev/null +++ b/client/src/core/components/DeskDetailPopup.css @@ -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; +} diff --git a/client/src/core/components/DeskDetailPopup.jsx b/client/src/core/components/DeskDetailPopup.jsx new file mode 100644 index 0000000..a3872af --- /dev/null +++ b/client/src/core/components/DeskDetailPopup.jsx @@ -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 ( +
+
e.stopPropagation()}> +
+ +

{meta.name} 축 — {cards.length}장

+ +
+
+ {sorted.map((c) => ( +
+ {gradeDots(c.grade)} +
+
{c.name || '미상 서류'}
+ {c.flavor &&
{c.flavor}
} +
+
+ ))} + {cards.length === 0 &&

이 축에 쌓인 오염 카드가 없습니다.

} +
+
+
+ ); +} diff --git a/client/src/core/components/HandGuidePopup.css b/client/src/core/components/HandGuidePopup.css new file mode 100644 index 0000000..116df75 --- /dev/null +++ b/client/src/core/components/HandGuidePopup.css @@ -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); } diff --git a/client/src/core/components/HandGuidePopup.jsx b/client/src/core/components/HandGuidePopup.jsx new file mode 100644 index 0000000..7b58d05 --- /dev/null +++ b/client/src/core/components/HandGuidePopup.jsx @@ -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 ( +
+
e.stopPropagation()}> +
+

족보표

+ +
+

+ "같은 등급"은 트럼프의 같은 숫자, "같은 축"은 같은 무늬와 같다. 책상에서 카드를 + 선택하면 아래 조건에 맞는 족보가 자동으로 판정된다. +

+
+ {GUIDE.map(({ id, condition, example }) => { + const def = HAND_DEFS[id]; + return ( +
+
+ {def.name} + 실적 +{def.score} + 명분 +{def.baseJustification} +
+
{condition}
+
+ {example.map(([axis, grade], i) => ( + + + {gradeDots(grade)} + + ))} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/client/src/core/components/SymbolBadge.css b/client/src/core/components/SymbolBadge.css new file mode 100644 index 0000000..8df2053 --- /dev/null +++ b/client/src/core/components/SymbolBadge.css @@ -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); } diff --git a/client/src/core/components/SymbolBadge.jsx b/client/src/core/components/SymbolBadge.jsx new file mode 100644 index 0000000..db84b1e --- /dev/null +++ b/client/src/core/components/SymbolBadge.jsx @@ -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 ; +} + +/** 축 인장 + 기밀 등급(점 표기)을 한 세트로 보여주는 배지. 스와이프 예고·책상 카드에 공용으로 쓴다. */ +export default function SymbolBadge({ axis, grade, size = 22, showName = false }) { + if (!axis) return null; + return ( +
+ + {gradeDots(grade)} + {showName && {AXIS_META[axis].name}} +
+ ); +} diff --git a/client/src/core/engine/desk.js b/client/src/core/engine/desk.js new file mode 100644 index 0000000..7b0e9f1 --- /dev/null +++ b/client/src/core/engine/desk.js @@ -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]; +} diff --git a/client/src/core/engine/hands.js b/client/src/core/engine/hands.js new file mode 100644 index 0000000..c8f4f9d --- /dev/null +++ b/client/src/core/engine/hands.js @@ -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), + }; +} diff --git a/client/src/core/engine/pollutionPool.js b/client/src/core/engine/pollutionPool.js new file mode 100644 index 0000000..0e1a266 --- /dev/null +++ b/client/src/core/engine/pollutionPool.js @@ -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 }; +} diff --git a/client/src/core/engine/symbols.js b/client/src/core/engine/symbols.js new file mode 100644 index 0000000..be10234 --- /dev/null +++ b/client/src/core/engine/symbols.js @@ -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; diff --git a/client/src/data/cards.csv b/client/src/data/cards.csv index 78371ce..537570a 100644 --- a/client/src/data/cards.csv +++ b/client/src/data/cards.csv @@ -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, diff --git a/docs/decisions/2026-09-05_core-graybox-ux-feedback.md b/docs/decisions/2026-09-05_core-graybox-ux-feedback.md new file mode 100644 index 0000000..c1bfea6 --- /dev/null +++ b/docs/decisions/2026-09-05_core-graybox-ux-feedback.md @@ -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차) 구현 시 재검토.