구현 우선 순위 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:
+4
-2
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>×</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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}>×</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}>×</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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
|
Reference in New Issue
Block a user