diff --git a/CLAUDE.md b/CLAUDE.md
index b722af1..5b55d13 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -14,7 +14,10 @@ Phase 5(비주얼 폴리싱)는 그레이박스 테스트 피드백 반영 완
## 코드 컨벤션 (package.json으로 알 수 없는 것)
+- 전체 구조·아키텍처 원칙(상태 배치, `engine/` 경계, world 스냅샷 패턴) → @docs/architecture_design.md
- 실제 앱은 `client/` 안에 있음. npm 명령은 `cd client` 후 실행.
+- 플레이테스트는 리포 루트의 `playtest.bat` 더블클릭 (dev 서버 + 브라우저 자동 오픈).
+ 파일명은 ASCII만 사용 — 한글/`chcp`를 넣으면 cmd.exe가 배치 파싱을 깨뜨린다(과거에 겪음).
- 스타일: 컴포넌트별 개별 `.css` (글로벌 클래스, CSS Modules 아님)
- 상태관리: `useState` / `useReducer`만 사용. 외부 상태관리 라이브러리 도입 금지.
- 영구 저장: `localStorage` (도감 엔딩 저장 용도)
@@ -87,6 +90,50 @@ Phase 5 [ ] 비주얼 폴리싱 & UX ← 그레이박스 테스트 완
하나의 팝업에서 함께 보여준다. 문서가 요구하는 "세계의 상흔 팝업 vs 연대기 팝업" 2종 분리 UI 구현은
아직 미착수 — 별도 작업으로 진행 필요.
+**2026-07-13 자산(엔진) 시스템 v1 (작업지시서 반영):** `docs/작업지시서/자산시스템_작업지시서.md` 기준으로
+발라트로형 "조커 레이어" 엔진 빌딩을 도입 — 덱은 여전히 적(오염)이고, 자산 슬롯이 패시브로 오염을
+걸러내는 엔진이라는 설계 그대로 구현. 기존 4종 수동 클릭형 자산(성질변환/시한부격리/영구소각/결재권강제행사)을
+전면 대체: 신규 `client/src/data/assets.json`(8종, 트리거→효과→성장 스키마)과 `client/src/engine/assetEngine.js`
+(이벤트 버스 — every_n_approvals/on_pollution_draw/on_swipe_left/on_council_end/on_purge 연쇄, 깊이 상한 10)로 구현.
+극비 프로젝트 성공 시 미보유 자산 중 무작위 1종 획득(획득 경로 튜닝은 2차 배치로 범위 외). 자산 슬롯 클릭 시
+`AssetInfoPopup`으로 트리거→효과·스택·충전 상태 표시, 화면 우하단에 발동 로그 피드(최근 4건) 노출 — Part C UX
+블로커 해결. 기존 `mulliganShields`(방어막 카운트)를 "다음 오염 카드 페널티 무효화" 공용 풀로 재사용해
+결재 반려 도장(충전식 수동)과 책임 소재 불명 처리(조직저항 자동 무효화)가 같은 메커니즘을 공유하도록 구현.
+**단순화/가정 사항 (디자이너 확인 필요):** ① 위험 수용 계수 등 "+N%" 리스크는 기존 0~5단계 스케일(1단계=20%)로
+환산해 소수점으로 누적시킴. ② 임시 격리 컨테이너(좌 스와이프 격리)는 문서의 "발동 선택 가능"을 단순화해
+자동 발동으로 구현(확인 팝업 없음). ③ 자산 슬롯은 4개 고정 → 보유량만큼 늘어나는 가변 슬롯으로 변경.
+Part D 플레이테스트 체크리스트(역전 엔진 성립/연쇄 가시성/S급 킹핀/무효 빌드 풍자/오버파워 확인/연쇄 깊이 상한)는
+아직 미검증 — 디자이너 플레이 후 피드백 필요.
+
+**2026-07-23 오염 카드 압박 강화 (위기감 피드백 반영):** "오염 카드 수가 적어 위기감이 없고,
+스와이프가 오염을 늘린다는 걸 알려주지 않아 고민이 안 된다"는 그레이박스 피드백을 반영.
+① 오염 주입 규칙을 `client/src/engine/pollution.js`로 분리 — 사전 예고 · 실제 주입 · 사후 알림이
+`previewPollution()` 단일 기준을 공유한다(예고와 결과가 어긋나면 정보 신뢰가 깨지므로 이 함수를
+우회해 오염 카드를 만들지 말 것). ② **위험 파라미터를 올리는 결재는 그 자리에서 덱에 오염 카드를 주입**
+(위험 +1 = 오염 1장, 1회 최대 3장, 현재 위치 2~4장 뒤에 삽입). 기존에는 정례회의에서만 파라미터당 1장이었다.
+③ 정례회의 주입도 단계 비례로(`min(3, ceil(level/2))`). ④ 페널티 무효화(방어막/책임 소재 불명) 시에는
+오염도 유입되지 않는다 — 방어막의 값어치. ⑤ UI 3종: 스와이프 인텐트에 `☣ 오염 카드 +N` 사전 경고,
+결재 후 유입 토스트, 상단에 `☣ 덱 오염 N/전체` 상시 게이지(40% 이상 시 적색 점멸). ⑥ 덱 인덱스를
+매 결재마다 덱 길이로 정규화 — 이전에는 `currentCardIndex`가 덱 길이를 넘어 계속 증가해 삽입 위치 계산이
+무의미했다. `pickGrade`도 반올림 추가(자산 부작용이 파라미터를 소수점으로 올려 크래시 가능성이 있었음).
+**튜닝 수치는 `pollution.js` 상단 상수 블록에 모아뒀다.**
+**시뮬레이션(400~600회, 자산·의회 파라미터 정화 제외):** 신중 플레이 시 총 주입 오염 10.8장 → 25.1장,
+최종 덱 오염률 39% → 59%. 무작위 플레이의 붕괴율 ~100%는 변경 **이전에도** 동일했다(파라미터가 원인).
+**디자이너 확인 필요:** ⓐ 위험 1점 = 오염 1장 비율이 적정한지 ⓑ 항상 안전한 쪽만 고르면 붕괴율 0% —
+안전 선택지의 토큰 비용이 충분한 압박인지 별도 검토 필요.
+**2026-07-23 실기 플레이 결과:** 디자이너 직접 플레이 확인 — 위기감 강화 방향은 맞으나(목표에 조금 더
+가까워짐), 세부 밸런스는 아직 조정 여지 있음. 정밀 튜닝은 플레이 횟수를 더 쌓은 뒤 진행 예정.
+
+**⚠️ 미확인 이슈 — 자산 획득이 실기 플레이에서 한 번도 발생하지 않음 (2026-07-23):** 자산(엔진) 시스템
+플레이테스트 중 자산을 하나도 획득하지 못해 Part D 체크리스트를 아직 검증 못함. 단, 플레이 횟수 자체가
+적어 정상 확률 범위인지 실제 버그인지 구분 안 된 상태 — **아직 원인 조사 안 함, 다음 세션에서 확인할 것.**
+획득 경로는 `GameScreen.jsx`의 `handleSwipe` 내 `card.type === '극비'` 분기(카드 타입 '극비' 스와이프
+성공 시 미보유 자산 중 무작위 1종). 의심 지점: ① '극비' 카드는 페이즈2 풀(`phase2Pool`)에만 있고
+페이즈2는 내러티브 태그 3종 획득 후에야 덱에 섞이므로, 그 전까지는 애초에 등장 자체가 불가능 —
+플레이가 짧으면 페이즈2 진입 전에 끝났을 가능성. ② 페이즈2 진입 이후에도 '극비' 카드 등장 빈도/개수가
+낮을 수 있음(`cards.csv` 기준 극비 7종). 위쪽 확률 문제인지, 아니면 조건 분기 자체의 버그인지는 코드를
+다시 열어 확인 필요.
+
**⚠️ 2026-07-09 병렬 브랜치 병합:** 이 폴더가 유일한 작업 사본이 아니라면, 작업 시작 전
`git fetch` 후 `git log origin/main..HEAD`와 `git log HEAD..origin/main`으로 분기 여부를
반드시 확인할 것. 과거에 다른 세션이 origin에 푸시한 커밋을 이 로컬이 받지 못한 채 Phase 2~4를
diff --git a/client/src/components/AssetInfoPopup.css b/client/src/components/AssetInfoPopup.css
new file mode 100644
index 0000000..58817c9
--- /dev/null
+++ b/client/src/components/AssetInfoPopup.css
@@ -0,0 +1,92 @@
+.asset-popup-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.85);
+ z-index: 300;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.asset-popup {
+ background: var(--color-surface);
+ width: 85%;
+ max-width: 420px;
+ border-radius: 16px;
+ padding: 20px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ box-shadow: 0 10px 50px rgba(0, 0, 0, 0.9);
+}
+
+.asset-popup-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 12px;
+}
+
+.asset-popup-grade {
+ font-family: var(--font-typewriter);
+ font-weight: bold;
+ font-size: 13px;
+ padding: 2px 8px;
+ border-radius: 4px;
+ border: 1px solid currentColor;
+}
+
+.grade-C { color: #9ca3af; }
+.grade-B { color: #4ade80; }
+.grade-A { color: #60a5fa; }
+.grade-S { color: #fbbf24; }
+
+.asset-popup-archetype {
+ font-size: 13px;
+ color: #a1a1aa;
+}
+
+.asset-popup-header .close-btn {
+ margin-left: auto;
+}
+
+.asset-popup-name {
+ margin: 0 0 8px 0;
+ font-family: var(--font-doc-title);
+ font-size: 20px;
+}
+
+.asset-popup-desc {
+ font-size: 14px;
+ color: #d4d4d8;
+ line-height: 1.5;
+ margin: 0 0 12px 0;
+}
+
+.asset-popup-stat {
+ font-size: 13px;
+ color: var(--color-token-info);
+ margin-bottom: 10px;
+}
+
+.asset-popup-activate {
+ width: 100%;
+ background: var(--color-resistance-critical);
+ color: #05201c;
+ border: none;
+ border-radius: 8px;
+ padding: 10px;
+ font-family: var(--font-ui);
+ font-weight: 700;
+ font-size: 15px;
+ cursor: pointer;
+ margin-bottom: 10px;
+}
+
+.asset-popup-flavor {
+ font-size: 12px;
+ color: #888;
+ font-style: italic;
+ margin: 0;
+}
diff --git a/client/src/components/AssetInfoPopup.jsx b/client/src/components/AssetInfoPopup.jsx
new file mode 100644
index 0000000..c515df4
--- /dev/null
+++ b/client/src/components/AssetInfoPopup.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { ARCHETYPE_META } from '../engine/assetEngine';
+import './AssetInfoPopup.css';
+
+export default function AssetInfoPopup({ instance, def, onActivate, onClose }) {
+ if (!instance || !def) return null;
+ const meta = ARCHETYPE_META[def.archetype];
+ const isManualCharge = def.trigger.type === 'manual_charge';
+ const ready = isManualCharge && instance.charge >= 1;
+
+ return (
+
+
e.stopPropagation()}>
+
+ {def.grade}
+ {meta.icon} {meta.label}
+ ×
+
+
+
{def.name}
+
{def.description}
+
+ {def.growth && (
+
+ 현재 숙련 스택: {instance.stack || 0} (매 {def.growth.per}회마다 추가 발동)
+
+ )}
+
+ {isManualCharge && (
+
+ {ready ? '충전 완료 — 발동 가능' : `충전 대기 중 (${def.trigger.params.chargeEvery}건마다 충전)`}
+
+ )}
+
+ {ready && (
+
발동
+ )}
+
+
{def.flavor}
+
+
+ );
+}
diff --git a/client/src/components/GameScreen.css b/client/src/components/GameScreen.css
index a7b2230..9051f16 100644
--- a/client/src/components/GameScreen.css
+++ b/client/src/components/GameScreen.css
@@ -57,6 +57,30 @@
font-size: 12px;
color: #999;
white-space: nowrap;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ align-items: center;
+}
+
+/* 덱 오염도 — 결재를 이어갈수록 덱이 얼마나 썩었는지 상시 노출 */
+.deck-pollution {
+ font-size: 11px;
+ color: #84cc16;
+ opacity: 0.75;
+ letter-spacing: 0.5px;
+}
+
+.deck-pollution.critical {
+ color: #f87171;
+ opacity: 1;
+ font-weight: 700;
+ animation: pulse-pollution 1.6s infinite alternate;
+}
+
+@keyframes pulse-pollution {
+ 0% { text-shadow: none; }
+ 100% { text-shadow: 0 0 8px currentColor; }
}
.parameters {
@@ -191,6 +215,46 @@
cursor: default;
}
+.asset-slot.grade-S { border-color: #fbbf24; }
+.asset-slot.grade-A { border-color: #60a5fa; }
+.asset-slot.grade-B { border-color: #4ade80; }
+.asset-slot.grade-C { border-color: #9ca3af; }
+
+.asset-slot.ready {
+ box-shadow: 0 0 10px rgba(251, 191, 36, 0.7);
+ animation: shake 1.2s infinite;
+}
+
+.asset-slot-empty-hint {
+ font-family: var(--font-typewriter);
+ font-size: 12px;
+ color: #555;
+}
+
+/* 자산 발동 로그 피드 — 발라트로 조커 점멸에 해당하는 최소 구현(텍스트 로그) */
+.asset-log-feed {
+ position: absolute;
+ right: 12px;
+ bottom: 190px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ max-width: 60%;
+ z-index: 50;
+ pointer-events: none;
+}
+
+.asset-log-line {
+ font-family: var(--font-typewriter);
+ font-size: 11px;
+ color: #d4d4d8;
+ background: rgba(0, 0, 0, 0.55);
+ border-left: 2px solid var(--color-risk-critical);
+ padding: 4px 8px;
+ border-radius: 4px;
+ text-align: right;
+}
+
/* Game Over */
.game-over {
display: flex;
@@ -234,11 +298,61 @@
background: var(--color-text);
color: var(--color-bg);
}
-.tag-notification {
+/* 태그 획득 / 오염 유입 토스트는 같은 열에 쌓아 서로 겹치지 않게 한다 */
+.notification-stack {
position: absolute;
top: 100px;
left: 50%;
transform: translateX(-50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ z-index: 100;
+ pointer-events: none;
+ width: max-content;
+ max-width: 90%;
+}
+
+.pollution-notification {
+ background: rgba(0, 0, 0, 0.92);
+ border: 1px solid #dc2626;
+ border-radius: 12px;
+ padding: 10px 20px;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ box-shadow: 0 4px 20px rgba(139, 0, 0, 0.5);
+ animation: notiFadeOut 2.6s forwards;
+}
+
+.pollution-noti-icon {
+ font-size: 26px;
+ color: #f87171;
+}
+
+.pollution-noti-info h4 {
+ margin: 0;
+ color: #f87171;
+ font-size: 13px;
+ font-family: var(--font-ui);
+}
+
+.pollution-noti-info p {
+ margin: 4px 0 0 0;
+ color: #e4e4e7;
+ font-size: 13px;
+ font-family: var(--font-typewriter);
+}
+
+@keyframes notiFadeOut {
+ 0% { opacity: 0; transform: translateY(-16px); }
+ 10% { opacity: 1; transform: translateY(0); }
+ 85% { opacity: 1; transform: translateY(0); }
+ 100% { opacity: 0; transform: translateY(-16px); }
+}
+
+.tag-notification {
background: rgba(0, 0, 0, 0.9);
border: 1px solid #fbbf24;
border-radius: 12px;
@@ -247,8 +361,7 @@
align-items: center;
gap: 16px;
box-shadow: 0 4px 20px rgba(251, 191, 36, 0.4);
- z-index: 100;
- animation: slideDownFadeOut 3s forwards;
+ animation: notiFadeOut 3s forwards;
}
.tag-noti-icon {
@@ -268,9 +381,3 @@
font-size: 18px;
}
-@keyframes slideDownFadeOut {
- 0% { opacity: 0; transform: translate(-50%, -20px); }
- 10% { opacity: 1; transform: translate(-50%, 0); }
- 80% { opacity: 1; transform: translate(-50%, 0); }
- 100% { opacity: 0; transform: translate(-50%, -20px); }
-}
diff --git a/client/src/components/GameScreen.jsx b/client/src/components/GameScreen.jsx
index 58345d2..b6e99df 100644
--- a/client/src/components/GameScreen.jsx
+++ b/client/src/components/GameScreen.jsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useRef } from 'react';
import Papa from 'papaparse';
import cardsCsvRaw from '../data/cards.csv?raw';
import narrativeTagsData from '../data/narrative_tags.json';
@@ -7,50 +7,28 @@ import SwipeCard from './SwipeCard';
import CouncilScreen from './CouncilScreen';
import EndingScreen from './EndingScreen';
import ChroniclePopup from './ChroniclePopup';
+import AssetInfoPopup from './AssetInfoPopup';
+import * as AssetEngine from '../engine/assetEngine';
+import {
+ CONTAM_TYPES,
+ CONTAM_TYPE_ICON,
+ previewPollution,
+ buildPollutionCards,
+ buildCouncilPollutionCards,
+ insertIntoDeck,
+ describePollution,
+} from '../engine/pollution';
import { CHAIRMAN_AXES, isNarrativeChainComplete, resolveEnding, getEndingById } from '../data/endings';
import './GameScreen.css';
-// 오염 카드 타입 <-> 파라미터 매핑 (파산/오염 등급 시스템)
-const CONTAM_TYPE_BY_PARAM = {
- entropy: '신화',
- resistance: '조직저항',
- panic: '사회공황',
- risk: '이사회압박',
-};
-const CONTAM_TYPES = new Set(Object.values(CONTAM_TYPE_BY_PARAM));
-
-// 파라미터 단계(1~5)가 높을수록 높은 등급(S/A) 확률이 커지는 가중치 테이블
-const GRADE_WEIGHTS = {
- 1: { C: 70, B: 22, A: 6, S: 2 },
- 2: { C: 50, B: 28, A: 15, S: 7 },
- 3: { C: 32, B: 30, A: 23, S: 15 },
- 4: { C: 18, B: 24, A: 32, S: 26 },
- 5: { C: 8, B: 17, A: 33, S: 42 },
-};
-
-const pickGrade = (level) => {
- const weights = GRADE_WEIGHTS[Math.min(5, Math.max(1, level))];
- const total = Object.values(weights).reduce((a, b) => a + b, 0);
- let r = Math.random() * total;
- for (const [grade, w] of Object.entries(weights)) {
- if (r < w) return grade;
- r -= w;
- }
- return 'C';
-};
+// 오염 카드 주입 규칙(타입 매핑 · 등급 추첨 · 물량)은 engine/pollution.js에 모여 있다.
+// 스와이프 예고(SwipeCard)와 실제 주입이 같은 계산을 쓰게 하기 위해 여기서 다시 정의하지 말 것.
// 내러티브 태그는 narrative_tags.json 풀(10종)에서 "처음 획득하는 태그"만 진행도에 반영된다.
// 문턱은 풀 크기 대비 낮게 잡는다 (기존 4/8은 실제 플레이에서 길게 느껴진다는 피드백 반영).
const PHASE2_TAG_THRESHOLD = 3;
const PHASE3_TAG_THRESHOLD = 6;
-const ASSET_META = {
- transform: { icon: '🧪', label: '성질 변환' },
- quarantine: { icon: '⏳', label: '시한부 격리' },
- purge: { icon: '🔥', label: '물리적 영구 소각' },
- mulligan: { icon: '🛡️', label: '결재권 강제 행사' },
-};
-
// 글로벌 지부장 12개 파벌 풀 (게임 시작 시 5명 무작위 선발, 실리에 따라 우호도 개별 계산)
const GLOBAL_FACTIONS = [
{ id: 'military', name: '군산복합 연합', computeAffinity: (c) => c.tags.force * 15 + c.tokens.personnel * 0.4 - 40 },
@@ -106,23 +84,15 @@ const buildCardFromRow = (row) => ({
},
});
-const transformCard = (card) => {
- const flip = (stats) => ({ ...stats, res: -stats.res, ent: -stats.ent, pan: -stats.pan, rsk: -stats.rsk });
- return {
- ...card,
- transformed: true,
- text: card.transformed ? card.text : `${card.text} (순화됨)`,
- left: { ...card.left, stats: flip(card.left.stats) },
- right: { ...card.right, stats: flip(card.right.stats) },
- };
-};
-
export function GameScreen() {
const [params, setParams] = useState({ resistance: 0, entropy: 0, panic: 0, risk: 0 });
const [tokens, setTokens] = useState({ budget: 50, personnel: 50, info: 50 });
const [tags, setTags] = useState({ wealth: 0, force: 0, surveillance: 0 });
const [acquiredTags, setAcquiredTags] = useState([]);
const [newTagNotification, setNewTagNotification] = useState(null);
+ // 결재 직후 "오염 카드 N건이 덱에 유입됨"을 알리는 토스트. 연속 스와이프 시 타이머를 갱신한다.
+ const [pollutionNotification, setPollutionNotification] = useState(null);
+ const pollutionTimerRef = useRef(null);
const [phase, setPhase] = useState(1);
const [gameOver, setGameOver] = useState(false);
const [cards, setCards] = useState([]);
@@ -135,7 +105,11 @@ export function GameScreen() {
const [showCouncil, setShowCouncil] = useState(false);
const [chronicle, setChronicle] = useState([]);
const [showChronicle, setShowChronicle] = useState(false);
- const [assets, setAssets] = useState([]);
+
+ // 자산(엔진) 시스템 — 작업지시서 기준 트리거/효과 엔진
+ const [ownedAssets, setOwnedAssets] = useState([]);
+ const [assetLog, setAssetLog] = useState([]);
+ const [assetPopup, setAssetPopup] = useState(null);
// 라운드 구조: 라운드가 증가할수록 정례회의까지 남은 결재 건수가 줄어든다 (시간 압박 시각화)
const [roundCount, setRoundCount] = useState(1);
@@ -226,51 +200,93 @@ export function GameScreen() {
}
}, [turns]);
- const applySide = (side, nullifyDanger) => {
- const s = side.stats;
- setParams(p => ({
- resistance: Math.max(0, p.resistance + (nullifyDanger ? 0 : s.res)),
- entropy: Math.max(0, p.entropy + (nullifyDanger ? 0 : s.ent)),
- panic: Math.max(0, p.panic + (nullifyDanger ? 0 : s.pan)),
- risk: Math.max(0, p.risk + (nullifyDanger ? 0 : s.rsk)),
- }));
- setTokens(t => ({
- budget: t.budget + s.bud,
- personnel: t.personnel + s.per,
- info: t.info + s.inf,
- }));
- setTags(tg => ({
- wealth: tg.wealth + s.wea,
- force: tg.force + s.for,
- surveillance: tg.surveillance + s.sur,
- }));
+ // 자산 엔진 world 스냅샷 생성/반영 — 현재 state를 복사해 엔진에 넘기고, 반환된 결과를 다시 state로 반영한다.
+ // 호출 지점 하나당 반드시 buildAssetWorld/commitAssetWorld를 한 번씩만 쓴다: 같은 이벤트 처리 안에서
+ // 여러 번 짝지어 부르면 나중 커밋이 아직 리렌더되지 않은(오래된) 클로저 state로 앞의 변경을 덮어써 버린다.
+ const buildAssetWorld = () => ({
+ cards: [...cards],
+ tokens: { ...tokens },
+ tags: { ...tags },
+ params: { ...params },
+ owned: ownedAssets.map(o => ({ ...o })),
+ quarantine: quarantineZone.map(q => ({ ...q })),
+ logs: [],
+ purgeGain: 0,
+ });
+
+ const commitAssetWorld = (world) => {
+ setCards(world.cards);
+ setTokens(world.tokens);
+ setTags(world.tags);
+ setParams(world.params);
+ setOwnedAssets(world.owned);
+ setQuarantineZone(world.quarantine);
+ if (world.purgeGain) setPurgeCount(c => c + world.purgeGain);
+ if (world.logs.length) setAssetLog(prev => [...prev, ...world.logs].slice(-8));
};
+ // 오염 유입 토스트 — 빠르게 연속 스와이프해도 마지막 알림 기준으로 표시 시간이 다시 채워진다.
+ const notifyPollution = (count, entries) => {
+ if (pollutionTimerRef.current) clearTimeout(pollutionTimerRef.current);
+ setPollutionNotification({ id: Date.now(), count, summary: describePollution(entries) });
+ pollutionTimerRef.current = setTimeout(() => setPollutionNotification(null), 2600);
+ };
+
+ useEffect(() => () => clearTimeout(pollutionTimerRef.current), []);
+
const handleSwipe = (direction) => {
if (cards.length === 0) return;
- const card = cards[currentCardIndex % cards.length];
-
- let outcomeText = '';
+ const cardIdx = currentCardIndex % cards.length;
+ const card = cards[cardIdx];
const side = direction === 'left' ? card.left : direction === 'right' ? card.right : null;
+
+ const world = buildAssetWorld();
+ let outcomeText = '';
let isNewTag = false;
let gainedTag = null;
- if (side) {
- const shouldNullify = card.contaminated && mulliganShields > 0;
- applySide(side, shouldNullify);
+ // 자산 엔진: 좌 스와이프 격리 도박(임시 격리 컨테이너) — 성공하면 이 카드는 정상 결과 대신 격리된다.
+ let quarantinedByAsset = false;
+ if (direction === 'left' && card.contaminated) {
+ AssetEngine.onSwipeLeft(world, cardIdx, turns);
+ quarantinedByAsset = world.quarantinedCard;
+ }
+
+ // 자산 엔진: 오염 카드 드로우 트리거(신화 환전/조직저항 자동 무효화 등)
+ let negateFree = false;
+ if (!quarantinedByAsset && card.contaminated) {
+ AssetEngine.onPollutionDraw(world, card.type);
+ negateFree = world.negateFree;
+ }
+
+ if (quarantinedByAsset) {
+ setChronicle(prev => [...prev, {
+ turn: turns + 1, cardType: card.type, keyword: card.keyword, text: card.text,
+ decision: '임시 격리 컨테이너로 격리 (정례회의 전까지 미회수 시 실패)', tag: null,
+ }]);
+ } else if (side) {
+ const shouldNullify = card.contaminated && (negateFree || mulliganShields > 0);
+ const s = side.stats;
+ world.params.resistance = Math.max(0, world.params.resistance + (shouldNullify ? 0 : s.res));
+ world.params.entropy = Math.max(0, world.params.entropy + (shouldNullify ? 0 : s.ent));
+ world.params.panic = Math.max(0, world.params.panic + (shouldNullify ? 0 : s.pan));
+ world.params.risk = Math.max(0, world.params.risk + (shouldNullify ? 0 : s.rsk));
+ world.tokens.budget += s.bud;
+ world.tokens.personnel += s.per;
+ world.tokens.info += s.inf;
+ world.tags.wealth += s.wea;
+ world.tags.force += s.for;
+ world.tags.surveillance += s.sur;
outcomeText = side.text;
// 파산 문책: 결재 결과 토큰이 마이너스가 되면 파산 카운터 증가
- const nextBudget = tokens.budget + side.stats.bud;
- const nextPersonnel = tokens.personnel + side.stats.per;
- const nextInfo = tokens.info + side.stats.inf;
- if (nextBudget < 0 || nextPersonnel < 0 || nextInfo < 0) {
+ if (world.tokens.budget < 0 || world.tokens.personnel < 0 || world.tokens.info < 0) {
setBankruptcyCount(c => c + 1);
}
// 극단적 파라미터 조기 엔딩 판별용: 이번 결재에서 가장 크게 증가한 위험 파라미터 추적
if (!shouldNullify) {
- const dangerDeltas = { resistance: side.stats.res, entropy: side.stats.ent, panic: side.stats.pan };
+ const dangerDeltas = { resistance: s.res, entropy: s.ent, panic: s.pan };
let maxParam = null, maxVal = 0;
Object.entries(dangerDeltas).forEach(([p, v]) => {
if (v > maxVal) { maxVal = v; maxParam = p; }
@@ -297,32 +313,59 @@ export function GameScreen() {
}
if (shouldNullify) {
- setMulliganShields(s => s - 1);
- outcomeText += ' (방어막으로 페널티 무효화)';
+ if (negateFree) {
+ outcomeText += ' (책임 소재 불명 처리로 자동 무효화)';
+ } else {
+ setMulliganShields(s2 => s2 - 1);
+ outcomeText += ' (방어막으로 페널티 무효화)';
+ }
+ }
+
+ // 위험 파라미터를 올린 결재는 그 자리에서 덱에 오염 카드를 주입한다 (정례회의까지 기다리지 않는다).
+ // 페널티가 무효화된 경우 파라미터도 오르지 않았으므로 오염도 유입되지 않는다 — 방어막의 값어치.
+ if (!shouldNullify) {
+ const { total, entries } = previewPollution(s);
+ if (total > 0) {
+ const injected = buildPollutionCards(entries, world.params, contaminationTemplates);
+ if (injected.length > 0) {
+ world.cards = insertIntoDeck(world.cards, cardIdx, injected);
+ outcomeText += ` → 오염 카드 ${injected.length}건 유입`;
+ notifyPollution(injected.length, entries);
+ }
+ }
}
} else if (direction === 'up') {
outcomeText = '보류 / 버리기';
}
- if (outcomeText) {
+ if (!quarantinedByAsset && outcomeText) {
setChronicle(prev => [...prev, {
turn: turns + 1, cardType: card.type, keyword: card.keyword, text: card.text,
decision: outcomeText, tag: isNewTag ? gainedTag : null,
}]);
}
- // 극비 프로젝트 성공 시 자산 획득 (4종 중 랜덤)
- if (card.type === '극비' && direction !== 'up') {
- if (assets.length < 4) {
- const types = Object.keys(ASSET_META);
- const type = types[Math.floor(Math.random() * types.length)];
- setAssets(prev => [...prev, { id: Date.now(), type, ...ASSET_META[type] }]);
+ // 극비 프로젝트 성공 시 자산 획득 (아직 보유하지 않은 자산 중 무작위 1종)
+ if (!quarantinedByAsset && card.type === '극비' && direction !== 'up') {
+ const ownedIds = new Set(world.owned.map(a => a.defId));
+ const pool = AssetEngine.ASSET_CATALOG.filter(a => !ownedIds.has(a.id));
+ if (pool.length > 0) {
+ const def = pool[Math.floor(Math.random() * pool.length)];
+ world.owned.push(AssetEngine.createAssetInstance(def.id, turns + 1));
setAssetsAcquiredCount(c => c + 1);
+ world.logs.push(`『${def.name}』(${def.grade}급) 획득`);
}
}
- setCurrentCardIndex(idx => idx + 1);
- setTurns(t => t + 1);
+ // 자산 엔진: 결재 N건마다 발동(문서 파쇄실) + 충전식 자산 충전 확인
+ const nextTurns = turns + 1;
+ AssetEngine.onApprovalTick(world, nextTurns);
+ commitAssetWorld(world);
+
+ // 덱은 인덱스를 순환하는 풀이다. 주입/소각으로 길이가 바뀌므로 다음 인덱스는 항상 최종 덱 길이로
+ // 정규화해 범위 안에 둔다 — 그래야 "현재 위치 기준 2~4장 뒤 삽입"이 의도대로 동작한다.
+ setCurrentCardIndex(world.cards.length > 0 ? (cardIdx + 1) % world.cards.length : 0);
+ setTurns(nextTurns);
const newRemaining = approvalsRemaining - 1;
setApprovalsRemaining(newRemaining);
@@ -331,33 +374,28 @@ export function GameScreen() {
}
};
- // 그림자 의회 종료 후 파라미터 단계에 비례한 등급 가중치로 오염 카드 주입
+ // 그림자 의회 종료 후 파라미터 단계에 비례한 등급/물량으로 오염 카드 주입
const injectContaminationCards = () => {
- const newCards = [];
- Object.entries(CONTAM_TYPE_BY_PARAM).forEach(([paramKey, cardType]) => {
- const level = params[paramKey];
- if (level < 1) return;
- const grade = pickGrade(level);
- const pool = contaminationTemplates.filter(t => t.type === cardType && t.grade === grade);
- if (pool.length === 0) return;
- const template = pool[Math.floor(Math.random() * pool.length)];
- newCards.push({ ...template, id: `${template.id}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` });
- });
+ const newCards = buildCouncilPollutionCards(params, contaminationTemplates);
+ if (newCards.length === 0) return;
- if (newCards.length > 0) {
- setCards(prev => {
- const insertAt = Math.min(prev.length, currentCardIndex + 1);
- const next = [...prev];
- next.splice(insertAt, 0, ...newCards);
- return next;
- });
- setChronicle(prev => [...prev, { turn: turns, cardType: '오염', keyword: null, text: `정례회의 종료 후 신규 오염 카드 ${newCards.length}건이 덱에 스며들었습니다.`, decision: '', tag: null }]);
- }
+ setCards(prev => insertIntoDeck(prev, currentCardIndex, newCards));
+ setChronicle(prev => [...prev, { turn: turns, cardType: '오염', keyword: null, text: `정례회의 종료 후 신규 오염 카드 ${newCards.length}건이 덱에 스며들었습니다.`, decision: '', tag: null }]);
+ const byType = newCards.reduce((acc, c) => ({ ...acc, [c.type]: (acc[c.type] || 0) + 1 }), {});
+ notifyPollution(
+ newCards.length,
+ Object.entries(byType).map(([type, count]) => ({ icon: CONTAM_TYPE_ICON[type], type, count })),
+ );
};
const handleCouncilResolve = (result) => {
setShowCouncil(false);
+ // 자산 엔진: 의회 종료 시 상시 자산 리스크 정산 + 격리 실패 판정(심연 보관 조례 개입 포함)
+ const councilWorld = buildAssetWorld();
+ AssetEngine.onCouncilEnd(councilWorld);
+ commitAssetWorld(councilWorld);
+
if (result.bankrupted) {
setFinalEnding(getEndingById('bankruptcy'));
setGameOver(true);
@@ -395,42 +433,16 @@ export function GameScreen() {
}
};
- // 자산 효과 4종 발동
- const activateAsset = (asset) => {
- if (asset.type === 'mulligan') {
- setMulliganShields(s => s + 1);
- setAssets(prev => prev.filter(a => a.id !== asset.id));
- setChronicle(prev => [...prev, { turn: turns, cardType: '자산', keyword: null, text: '결재권 강제 행사 방어막을 전개했습니다. 다음 오염 카드의 페널티가 무효화됩니다.', decision: '', tag: null }]);
- return;
- }
+ // 자산 슬롯 클릭 — 항상 정보 팝업을 연다 (Part C UX 블로커 해결). defId만 저장해 최신 스택/충전 값을 반영한다.
+ const handleAssetSlotClick = (inst) => setAssetPopup(inst.defId);
- const contaminatedIdx = [];
- cards.forEach((c, i) => { if (c.contaminated) contaminatedIdx.push(i); });
-
- if (contaminatedIdx.length === 0) {
- setChronicle(prev => [...prev, { turn: turns, cardType: '자산', keyword: null, text: `${asset.label}을(를) 발동했지만 대상이 될 오염 카드가 없습니다.`, decision: '', tag: null }]);
- setAssets(prev => prev.filter(a => a.id !== asset.id));
- return;
- }
-
- if (asset.type === 'purge') {
- const target = contaminatedIdx[Math.floor(Math.random() * contaminatedIdx.length)];
- setCards(prev => prev.filter((_, i) => i !== target));
- setPurgeCount(c => c + 1);
- setChronicle(prev => [...prev, { turn: turns, cardType: '자산', keyword: null, text: '오염 카드를 물리적으로 영구 소각했습니다.', decision: '', tag: null }]);
- } else if (asset.type === 'quarantine') {
- const targetSet = new Set(contaminatedIdx.slice(0, 2));
- const quarantined = cards.filter((c, i) => targetSet.has(i));
- setCards(prev => prev.filter((_, i) => !targetSet.has(i)));
- setQuarantineZone(prev => [...prev, ...quarantined.map(c => ({ ...c, returnTurn: turns + 5 }))]);
- setChronicle(prev => [...prev, { turn: turns, cardType: '자산', keyword: null, text: `오염 카드 ${quarantined.length}건을 시한부 격리했습니다. (5턴 후 덱으로 복귀)`, decision: '', tag: null }]);
- } else if (asset.type === 'transform') {
- const target = contaminatedIdx[Math.floor(Math.random() * contaminatedIdx.length)];
- setCards(prev => prev.map((c, i) => (i === target ? transformCard(c) : c)));
- setChronicle(prev => [...prev, { turn: turns, cardType: '자산', keyword: null, text: '오염 카드의 성질을 영구적으로 순화했습니다. (독을 약으로)', decision: '', tag: null }]);
- }
-
- setAssets(prev => prev.filter(a => a.id !== asset.id));
+ // 충전식 자산(manual_charge) 수동 발동 — 팝업 내부의 '발동' 버튼에서 호출
+ const handleAssetActivate = (defId) => {
+ const world = buildAssetWorld();
+ AssetEngine.activateManualAsset(world, defId, turns);
+ commitAssetWorld(world);
+ if (world.negateChargeGranted) setMulliganShields(s => s + 1);
+ setAssetPopup(null);
};
const renderDots = (value, max = 5) => {
@@ -454,7 +466,7 @@ export function GameScreen() {
// 글로벌 지부장 12개 파벌 중 이번 회차 선발된 5명의 우호도를 각자 다른 조건으로 개별 계산
const affinityCtx = {
tags, tokens, params,
- assetsCount: assets.length,
+ assetsCount: ownedAssets.length,
mythicCount: cards.filter(c => c.type === '신화').length,
tagCount: acquiredTags.length, assetsAcquiredCount, purgeCount, bribeCount,
};
@@ -485,6 +497,10 @@ export function GameScreen() {
const phaseClass = phase === 1 ? 'phase-1' : phase === 2 ? 'phase-2' : 'phase-3';
const nextTagThreshold = phase === 1 ? PHASE2_TAG_THRESHOLD : phase === 2 ? PHASE3_TAG_THRESHOLD : null;
+ // 덱 오염도 — 결재를 이어갈수록 덱이 얼마나 썩었는지 상시 노출해 압박을 준다.
+ const pollutedCount = cards.filter(c => c.contaminated).length;
+ const pollutionRatio = cards.length > 0 ? pollutedCount / cards.length : 0;
+
return (
{/* Top Bar */}
@@ -493,7 +509,10 @@ export function GameScreen() {
{acquiredTags.length}{nextTagThreshold !== null ? `/${nextTagThreshold}` : ''}
- 제 {roundCount}차 정례회의 상정까지: 잔여 {approvalsRemaining}건
+
제 {roundCount}차 정례회의 상정까지: 잔여 {approvalsRemaining}건
+
= 0.4 ? 'critical' : ''}`}>
+ ☣ 덱 오염 {pollutedCount}/{cards.length}
+
= 4 ? 'critical' : ''}`} style={{'--param-color': 'var(--color-resistance-critical)'}}>
@@ -538,30 +557,62 @@ export function GameScreen() {
{tags.surveillance}
- {[0, 1, 2, 3].map(i => {
- const asset = assets[i];
+ {ownedAssets.length === 0 &&
보유 자산 없음
}
+ {ownedAssets.map((inst) => {
+ const def = AssetEngine.getAssetDef(inst.defId);
+ const meta = AssetEngine.ARCHETYPE_META[def.archetype];
+ const ready = def.trigger.type === 'manual_charge' && inst.charge >= 1;
return (
asset && activateAsset(asset)}
- disabled={!asset}
- title={asset ? asset.label : '빈 슬롯'}
+ key={inst.defId}
+ className={`asset-slot filled grade-${def.grade} ${ready ? 'ready' : ''}`}
+ onClick={() => handleAssetSlotClick(inst)}
+ title={def.name}
>
- {asset ? {asset.icon} : ''}
+ {meta.icon}
);
})}
- {newTagNotification && (
-
-
{newTagNotification.icon}
-
-
새로운 내러티브 획득!
-
[{newTagNotification.name}]
-
+ {assetLog.length > 0 && (
+
+ {assetLog.slice(-4).map((line, i) => (
+
{line}
+ ))}
+
+ )}
+
+ {assetPopup && (
+
a.defId === assetPopup)}
+ def={AssetEngine.getAssetDef(assetPopup)}
+ onActivate={() => handleAssetActivate(assetPopup)}
+ onClose={() => setAssetPopup(null)}
+ />
+ )}
+
+ {(newTagNotification || pollutionNotification) && (
+
+ {newTagNotification && (
+
+
{newTagNotification.icon}
+
+
새로운 내러티브 획득!
+
[{newTagNotification.name}]
+
+
+ )}
+ {pollutionNotification && (
+
+
☣
+
+
오염 카드 {pollutionNotification.count}건이 덱에 유입되었습니다
+
{pollutionNotification.summary}
+
+
+ )}
)}
diff --git a/client/src/components/SwipeCard.css b/client/src/components/SwipeCard.css
index 6e18f83..db41e57 100644
--- a/client/src/components/SwipeCard.css
+++ b/client/src/components/SwipeCard.css
@@ -222,6 +222,43 @@
font-size: 20px;
}
+/* 오염 카드 사전 경고 — 이 선택이 덱을 얼마나 더럽히는지 스와이프 전에 보여준다 */
+.pollution-warning {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ background: rgba(20, 0, 0, 0.92);
+ border: 2px solid #dc2626;
+ color: #f87171;
+ padding: 6px 14px;
+ border-radius: 8px;
+ margin-bottom: 8px;
+ font-family: var(--font-ui);
+ box-shadow: 0 0 15px rgba(139, 0, 0, 0.6);
+ animation: pulsePollutionWarning 1.5s infinite;
+ text-transform: none;
+ letter-spacing: 0;
+}
+
+.pollution-warning-head {
+ font-size: 16px;
+ font-weight: 900;
+}
+
+.pollution-warning-types {
+ font-family: var(--font-typewriter);
+ font-size: 11px;
+ color: #e4e4e7;
+ white-space: nowrap;
+}
+
+@keyframes pulsePollutionWarning {
+ 0% { transform: scale(1); box-shadow: 0 0 12px rgba(139, 0, 0, 0.5); }
+ 50% { transform: scale(1.04); box-shadow: 0 0 22px rgba(139, 0, 0, 0.9); }
+ 100% { transform: scale(1); box-shadow: 0 0 12px rgba(139, 0, 0, 0.5); }
+}
+
@keyframes pulseTag {
0% { transform: scale(1); box-shadow: 0 0 15px rgba(251, 191, 36, 0.5); }
50% { transform: scale(1.05); box-shadow: 0 0 25px rgba(251, 191, 36, 0.8); }
diff --git a/client/src/components/SwipeCard.jsx b/client/src/components/SwipeCard.jsx
index cbf90a1..017fc3b 100644
--- a/client/src/components/SwipeCard.jsx
+++ b/client/src/components/SwipeCard.jsx
@@ -1,8 +1,22 @@
import React, { useState, useEffect } from 'react';
import { motion, useMotionValue, useTransform, useAnimation } from 'framer-motion';
import { HandCoins, Droplet, Eye, Shield, Building2, EyeOff } from 'lucide-react';
+import { previewPollution, describePollution } from '../engine/pollution';
import './SwipeCard.css';
+// 이 선택이 덱에 오염 카드를 몇 장 밀어 넣는지 스와이프 전에 예고한다.
+// 계산은 반드시 engine/pollution.js의 previewPollution을 쓴다 — 실제 주입과 같은 기준이어야 한다.
+const renderPollutionWarning = (stats) => {
+ const { total, entries } = previewPollution(stats);
+ if (total === 0) return null;
+ return (
+
+ ☣ 오염 카드 +{total}
+ {describePollution(entries)}
+
+ );
+};
+
const renderStats = (stats) => {
if (!stats) return null;
const items = [];
@@ -117,6 +131,7 @@ export default function SwipeCard({ card, onSwipe, phase }) {
{card.left.tag.name}
)}
+ {renderPollutionWarning(card.left?.stats)}
{renderStats(card.left?.stats)}
@@ -127,6 +142,7 @@ export default function SwipeCard({ card, onSwipe, phase }) {
{card.right.tag.name}
)}
+ {renderPollutionWarning(card.right?.stats)}
{renderStats(card.right?.stats)}
{card.type === '극비' && (
diff --git a/client/src/data/assets.json b/client/src/data/assets.json
index bcc2cfc..59ff191 100644
--- a/client/src/data/assets.json
+++ b/client/src/data/assets.json
@@ -1,30 +1,106 @@
[
{
- "id": "asset_01",
- "name": "전임 국장의 만년필",
- "icon": "🖋️",
- "description": "피로 쓰여진 듯한 잉크가 나옵니다. 예산 관리에 소소한 도움을 줍니다.",
- "flavor": "\"예산 전용 동의서에 서명하기 아주 좋은 펜이군.\""
+ "id": "asset_purge_001",
+ "name": "문서 파쇄실",
+ "grade": "C",
+ "archetype": "purge",
+ "trigger": { "type": "every_n_approvals", "params": { "n": 5 } },
+ "effect": { "type": "purge_lowest", "params": { "count": 1 } },
+ "growth": null,
+ "risk": null,
+ "faction_link": "scavengers",
+ "description": "결재 5건마다 덱에서 최하 등급 오염 카드 1장을 영구 소각한다.",
+ "flavor": "파쇄 용량: 일일 1건. 증설 요청은 3회 반려됨."
},
{
- "id": "asset_02",
- "name": "비밀 스위스 은행 계좌",
- "icon": "🏦",
- "description": "마이너스 예산을 한 번 막아줄 수 있습니다.",
- "flavor": "\"우리 기구가 파산해도 내 노후는 안전해야지.\""
+ "id": "asset_purge_002",
+ "name": "소각 처리 대장",
+ "grade": "A",
+ "archetype": "purge",
+ "trigger": { "type": "on_purge", "params": {} },
+ "effect": null,
+ "growth": { "stackOn": "on_purge", "per": 3, "bonus": { "effect": "purge_lowest", "count": 1 } },
+ "risk": null,
+ "faction_link": "scavengers",
+ "description": "소각이 발생할 때마다 숙련도가 쌓인다. 숙련 3회마다 최하 등급 오염 카드 1장을 추가로 소각한다.",
+ "flavor": "소각로 온도는 서류로 증명되지 않는다."
},
{
- "id": "asset_03",
- "name": "기억 소거 장치",
- "icon": "🔦",
- "description": "불리한 결재 건을 한 번 취소할 수 있습니다.",
- "flavor": "\"자, 여기 불빛을 보세요. 당신은 방금 아무것도 보지 못했습니다.\""
+ "id": "asset_convert_001",
+ "name": "폐기물 재활용 규정",
+ "grade": "C",
+ "archetype": "convert",
+ "trigger": { "type": "on_pollution_draw", "params": { "category": "신화", "minGrade": null } },
+ "effect": { "type": "convert_to_token", "params": { "tokenType": "budget", "amount": 2 } },
+ "growth": null,
+ "risk": null,
+ "faction_link": "nexus_traders",
+ "description": "신화 오염 카드가 드로우되면 예산 토큰 2를 회수한다. (페널티는 정상 적용 후)",
+ "flavor": "괴수 사체도 감가상각 대상이다."
},
{
- "id": "asset_04",
- "name": "방탄 커피 머신",
- "icon": "☕",
- "description": "요원들의 불만을 약간 억제합니다.",
- "flavor": "\"아무리 세상이 멸망해도 모닝 커피는 포기 못하지.\""
+ "id": "asset_convert_002",
+ "name": "부산물 매각 계약",
+ "grade": "B",
+ "archetype": "convert",
+ "trigger": { "type": "on_convert", "params": {} },
+ "effect": { "type": "convert_multiplier", "params": { "multiplier": 2 } },
+ "growth": null,
+ "risk": { "param": "risk_tolerance", "amount": 5, "when": "council_end" },
+ "faction_link": "nexus_traders",
+ "description": "모든 환전 효과의 산출량을 2배로 늘린다. 의회가 열릴 때마다 위험 수용 계수가 소폭 상승한다.",
+ "flavor": "이 계약서에 서명한 사람은 아무도 기억하지 못한다."
+ },
+ {
+ "id": "asset_quarantine_001",
+ "name": "임시 격리 컨테이너",
+ "grade": "C",
+ "archetype": "quarantine",
+ "trigger": { "type": "on_swipe_left", "params": {} },
+ "effect": { "type": "quarantine", "params": { "failCondition": "다음 의회까지 대중 공황 1단계 상승" } },
+ "growth": null,
+ "risk": null,
+ "faction_link": "occult_collectors",
+ "description": "좌로 스와이프한 오염 카드를 격리한다. 다음 의회 전까지 처리하지 못하면 대중 공황이 상승한다.",
+ "flavor": "컨테이너 라벨: '절대 열지 말 것 (2차 통보)'."
+ },
+ {
+ "id": "asset_quarantine_002",
+ "name": "심연 보관 조례",
+ "grade": "S",
+ "archetype": "quarantine",
+ "trigger": { "type": "on_quarantine_fail", "params": {} },
+ "effect": { "type": "quarantine_fail_to_purge", "params": { "bonusTag": "surveillance" } },
+ "growth": null,
+ "risk": null,
+ "faction_link": "occult_collectors",
+ "description": "격리가 실패로 판정되면 그 즉시 대상을 영구 소각하고, 감시 태그 1을 획득한다. 격리 실패가 더 이상 페널티가 아니다.",
+ "flavor": "실패란 서류상의 표현일 뿐이다."
+ },
+ {
+ "id": "asset_skip_001",
+ "name": "결재 반려 도장",
+ "grade": "C",
+ "archetype": "skip",
+ "trigger": { "type": "manual_charge", "params": { "chargeEvery": 10 } },
+ "effect": { "type": "negate_penalty", "params": { "category": null } },
+ "growth": null,
+ "risk": null,
+ "faction_link": "field_agents_union",
+ "description": "결재 10건마다 충전된다. 충전 완료 시 수동으로 발동해 다음 오염 카드의 페널티를 1회 무효화한다.",
+ "flavor": "도장이 닳는 속도가 곧 국장의 임기다."
+ },
+ {
+ "id": "asset_skip_002",
+ "name": "책임 소재 불명 처리",
+ "grade": "B",
+ "archetype": "skip",
+ "trigger": { "type": "on_pollution_draw", "params": { "category": "조직저항", "minGrade": null } },
+ "effect": { "type": "negate_penalty", "params": { "category": "조직저항" } },
+ "growth": null,
+ "risk": { "param": "resistance", "amount": 3, "when": "on_trigger" },
+ "faction_link": "field_agents_union",
+ "description": "조직 저항 계열 오염 카드의 페널티를 자동으로 무효화한다. 다만 책임 소재가 사라질 때마다 내부 저항이 소폭 상승한다.",
+ "flavor": "'담당자 부재'라는 담당자가 새로 생겼다."
}
]
diff --git a/client/src/engine/assetEngine.js b/client/src/engine/assetEngine.js
new file mode 100644
index 0000000..cb9ae6b
--- /dev/null
+++ b/client/src/engine/assetEngine.js
@@ -0,0 +1,226 @@
+// 자산(엔진) 시스템 — 트리거 → 효과 → (선택) 성장 이벤트 엔진 (그레이박스 v1)
+// docs/작업지시서/자산시스템_작업지시서.md 기준.
+//
+// GameScreen은 매 훅 지점(결재, 오염 드로우, 좌 스와이프, 의회 종료)마다 현재 상태로
+// world 스냅샷을 만들어 이 모듈의 함수에 넘기고, 반환된 world를 다시 React state로 반영한다.
+// world = { cards, tokens, tags, params, owned, quarantine, logs, purgeGain }
+
+import assetDefs from '../data/assets.json';
+
+const byId = Object.fromEntries(assetDefs.map((a) => [a.id, a]));
+
+export const ASSET_CATALOG = assetDefs;
+export const getAssetDef = (id) => byId[id];
+
+export const ARCHETYPE_META = {
+ purge: { icon: '🔥', label: '소각' },
+ convert: { icon: '💱', label: '환전' },
+ quarantine: { icon: '⏳', label: '격리' },
+ skip: { icon: '🛡️', label: '회피' },
+};
+
+const MAX_CHAIN_DEPTH = 10;
+const GRADE_RANK = { C: 0, B: 1, A: 2, S: 3 };
+const TOKEN_LABEL = { budget: '예산', personnel: '인력', info: '정보' };
+const TAG_LABEL = { wealth: '재력', force: '무력', surveillance: '감시' };
+
+export const createAssetInstance = (defId, turns) => ({
+ defId,
+ stack: 0,
+ charge: 0,
+ lastChargeTurn: turns,
+ acquiredTurn: turns,
+});
+
+const log = (world, text) => world.logs.push(text);
+const hasAsset = (world, defId) => world.owned.some((o) => o.defId === defId);
+
+const lowestGradeContaminatedIndex = (cards) => {
+ let best = -1;
+ let bestRank = Infinity;
+ cards.forEach((c, i) => {
+ if (!c.contaminated) return;
+ const r = GRADE_RANK[c.grade] ?? 0;
+ if (r < bestRank) { bestRank = r; best = i; }
+ });
+ return best;
+};
+
+// 퍼센트 리스크(예: 5%, 3%)는 기존 0~5단계 파라미터 스케일(1단계 = 20%)로 환산한다.
+// '1단계' 같이 정수 단위로 명시된 페널티(격리 실패 등)는 그대로 정수로 더한다.
+const applyRisk = (world, risk, sourceName) => {
+ if (!risk) return;
+ const key = risk.param === 'risk_tolerance' ? 'risk' : risk.param;
+ const amount = risk.amount / 20;
+ world.params[key] = (world.params[key] || 0) + amount;
+ log(world, `『${sourceName}』 부작용 — ${key} +${risk.amount}%`);
+};
+
+const convertAmount = (world, amount) => {
+ const doubled = world.owned.some((o) => getAssetDef(o.defId)?.effect?.type === 'convert_multiplier');
+ return doubled ? amount * 2 : amount;
+};
+
+const doPurge = (world, count, sourceName, depth) => {
+ for (let i = 0; i < count; i++) {
+ const idx = lowestGradeContaminatedIndex(world.cards);
+ if (idx === -1) return;
+ const [removed] = world.cards.splice(idx, 1);
+ world.purgeGain = (world.purgeGain || 0) + 1;
+ log(world, `『${sourceName}』 발동 — [${removed.grade}급 ${removed.type}] 소각됨`);
+ fireEvent(world, 'on_purge', {}, depth + 1);
+ }
+};
+
+// 결재 카드 스와이프마다 호출: every_n_approvals 자산 발동 + manual_charge 충전 확인
+export function onApprovalTick(world, turns) {
+ world.owned.forEach((inst) => {
+ const def = getAssetDef(inst.defId);
+ if (!def) return;
+
+ if (def.trigger.type === 'every_n_approvals') {
+ const n = def.trigger.params.n;
+ if (turns > 0 && turns % n === 0) {
+ doPurge(world, def.effect.params.count, def.name, 0);
+ }
+ }
+
+ if (def.trigger.type === 'manual_charge' && inst.charge < 1) {
+ const base = inst.lastChargeTurn ?? inst.acquiredTurn ?? 0;
+ if (turns - base >= def.trigger.params.chargeEvery) {
+ inst.charge = 1;
+ log(world, `『${def.name}』 충전 완료 — 슬롯을 눌러 발동할 수 있습니다.`);
+ }
+ }
+ });
+ return world;
+}
+
+// 오염 카드가 드로우(결재 대상으로 등장)될 때 호출. world.negateFree === true면 이번 카드 페널티를 자동 무효화.
+export function onPollutionDraw(world, category) {
+ world.negateFree = false;
+ world.owned.forEach((inst) => {
+ const def = getAssetDef(inst.defId);
+ if (!def || def.trigger.type !== 'on_pollution_draw') return;
+ if (def.trigger.params.category && def.trigger.params.category !== category) return;
+
+ if (def.effect.type === 'convert_to_token') {
+ const amt = convertAmount(world, def.effect.params.amount);
+ const tokenType = def.effect.params.tokenType;
+ world.tokens[tokenType] = (world.tokens[tokenType] || 0) + amt;
+ log(world, `『${def.name}』 발동 — ${TOKEN_LABEL[tokenType]} +${amt}`);
+ } else if (def.effect.type === 'negate_penalty') {
+ world.negateFree = true;
+ log(world, `『${def.name}』 발동 — 이번 오염 카드 페널티 자동 무효화`);
+ applyRisk(world, def.risk, def.name);
+ }
+ });
+ return world;
+}
+
+// 좌 스와이프 시 호출(cardIndex는 world.cards 내 위치). 반환된 world.quarantinedCard === true면
+// 이번 카드는 정상 결과 대신 격리 처리되어 world.cards에서 이미 제거된 상태다.
+export function onSwipeLeft(world, cardIndex, turns) {
+ world.quarantinedCard = false;
+ const card = world.cards[cardIndex];
+ if (!card || !card.contaminated) return world;
+
+ const inst = world.owned.find((o) => o.defId === 'asset_quarantine_001');
+ if (!inst) return world;
+ const def = getAssetDef('asset_quarantine_001');
+
+ world.cards.splice(cardIndex, 1);
+ world.quarantine.push({ ...card, returnTurn: turns + 5, viaAsset: def.id, failChecked: false });
+ log(world, `『${def.name}』 발동 — [${card.grade}급 ${card.type}] 격리됨 (다음 의회 전까지 미회수 시 실패)`);
+ world.quarantinedCard = true;
+ return world;
+}
+
+// 의회 종료 시 호출: 상시 자산 리스크 정산 + 격리 실패 판정(+심연 보관 조례 개입)
+export function onCouncilEnd(world) {
+ world.owned.forEach((inst) => {
+ const def = getAssetDef(inst.defId);
+ if (def?.risk?.when === 'council_end') applyRisk(world, def.risk, def.name);
+ });
+
+ const pending = world.quarantine.filter((c) => c.viaAsset === 'asset_quarantine_001' && !c.failChecked);
+ pending.forEach((card) => {
+ card.failChecked = true;
+ if (hasAsset(world, 'asset_quarantine_002')) {
+ world.quarantine = world.quarantine.filter((c) => c !== card);
+ const def6 = getAssetDef('asset_quarantine_002');
+ world.tags.surveillance = (world.tags.surveillance || 0) + 1;
+ world.purgeGain = (world.purgeGain || 0) + 1;
+ log(world, `『${def6.name}』 발동 — 격리 실패한 [${card.grade}급 ${card.type}]를 영구 소각하고 감시 태그 획득`);
+ fireEvent(world, 'on_purge', {}, 1);
+ } else {
+ world.params.panic = (world.params.panic || 0) + 1;
+ log(world, `격리 실패 — [${card.grade}급 ${card.type}] 처리 지연으로 대중 공황 1단계 상승`);
+ }
+ });
+
+ return world;
+}
+
+// 충전된 manual_charge 자산을 수동 발동. 성공 시 world.negateChargeGranted === true.
+export function activateManualAsset(world, defId, turns) {
+ world.negateChargeGranted = false;
+ const inst = world.owned.find((o) => o.defId === defId);
+ const def = getAssetDef(defId);
+ if (!inst || !def || inst.charge < 1) return world;
+
+ inst.charge = 0;
+ inst.lastChargeTurn = turns;
+ world.negateChargeGranted = true;
+ log(world, `『${def.name}』 발동 — 다음 오염 카드 페널티 무효화 충전을 사용했습니다.`);
+ return world;
+}
+
+// on_purge / on_convert 등 연쇄 이벤트 디스패치. depth는 무한 루프 방지용 상한(10)까지.
+function fireEvent(world, eventType, payload, depth) {
+ if (depth > MAX_CHAIN_DEPTH) {
+ log(world, '⚠️ 연쇄 발동이 상한(10회)에 도달해 중단되었습니다.');
+ return world;
+ }
+
+ world.owned.forEach((inst) => {
+ const def = getAssetDef(inst.defId);
+ if (!def || def.trigger.type !== eventType) return;
+
+ if (def.growth && def.growth.stackOn === eventType) {
+ inst.stack = (inst.stack || 0) + 1;
+ if (inst.stack % def.growth.per === 0) {
+ log(world, `『${def.name}』 숙련 ${inst.stack}회 누적 — 추가 발동`);
+ if (def.growth.bonus.effect === 'purge_lowest') {
+ doPurge(world, def.growth.bonus.count, def.name, depth);
+ }
+ }
+ return;
+ }
+
+ if (!def.effect) return;
+ switch (def.effect.type) {
+ case 'gain_token': {
+ const tokenType = def.effect.params.tokenType;
+ const amt = def.effect.params.amount;
+ world.tokens[tokenType] = (world.tokens[tokenType] || 0) + amt;
+ log(world, `『${def.name}』 발동 — ${TOKEN_LABEL[tokenType]} +${amt}`);
+ break;
+ }
+ case 'gain_tag': {
+ const tagType = def.effect.params.tagType;
+ const amt = def.effect.params.amount;
+ world.tags[tagType] = (world.tags[tagType] || 0) + amt;
+ log(world, `『${def.name}』 발동 — ${TAG_LABEL[tagType]} 태그 +${amt}`);
+ break;
+ }
+ default:
+ // convert_multiplier 등 상시 패시브 효과는 소유 여부만으로 별도 계산부(convertAmount)에서 반영한다.
+ break;
+ }
+ });
+
+ return world;
+}
+
+export { fireEvent };
diff --git a/client/src/engine/pollution.js b/client/src/engine/pollution.js
new file mode 100644
index 0000000..2256551
--- /dev/null
+++ b/client/src/engine/pollution.js
@@ -0,0 +1,154 @@
+// 오염 카드 주입 엔진 — 결재 선택(스와이프)과 정례회의 종료 시 덱에 오염 카드를 밀어 넣는다.
+//
+// 설계 의도(그레이박스 튜닝):
+// 좌우 스와이프 외에 능동적 행동이 거의 없는 게임이라, "내 결정이 덱을 더럽힌다"는 압박이
+// 위기감의 핵심이다. 그래서 위험 파라미터를 올리는 선택은 정례회의를 기다리지 않고
+// 그 자리에서 덱에 오염 카드를 주입한다.
+//
+// SwipeCard의 사전 예고 · GameScreen의 사후 알림 · 실제 주입은 모두 previewPollution()을
+// 단일 기준으로 쓴다. 예고한 숫자와 실제 주입량이 어긋나면 플레이어가 정보를 신뢰하지 않게 되므로,
+// 이 함수를 우회해서 오염 카드를 만들지 말 것.
+
+// 오염 카드 타입 <-> 파라미터 매핑 (파산/오염 등급 시스템)
+export const CONTAM_TYPE_BY_PARAM = {
+ entropy: '신화',
+ resistance: '조직저항',
+ panic: '사회공황',
+ risk: '이사회압박',
+};
+export const CONTAM_TYPES = new Set(Object.values(CONTAM_TYPE_BY_PARAM));
+
+export const CONTAM_TYPE_ICON = {
+ 신화: '🌀',
+ 조직저항: '✊',
+ 사회공황: '😱',
+ 이사회압박: '⚠️',
+};
+
+// cards.csv의 위험 스탯 키 -> 파라미터 키 (상단 파라미터 표시 순서와 동일하게 유지)
+const DANGER_STAT_TO_PARAM = {
+ res: 'resistance',
+ ent: 'entropy',
+ pan: 'panic',
+ rsk: 'risk',
+};
+
+/* ── 밸런스 튜닝 상수 (오염 물량 조정은 여기만 고치면 된다) ───────────────── */
+
+// 위험 파라미터 +1 당 주입되는 오염 카드 장수.
+// cards.csv의 위험 수치는 대부분 +1, 일부 +2, 드물게 +3이다(한 선택지 최대 합계 +5).
+export const POLLUTION_PER_DANGER_POINT = 1;
+
+// 한 번의 결재로 주입되는 최대 장수 — 극단적인 카드 한 장이 덱을 통째로 망가뜨리지 않게 하는 상한.
+export const MAX_POLLUTION_PER_SWIPE = 3;
+
+// 정례회의 종료 시 파라미터 단계별 주입량 (기존: 단계와 무관하게 무조건 1장).
+export const councilPollutionCount = (level) => Math.min(3, Math.ceil(level / 2));
+
+// 주입된 카드가 실제로 등장하기까지 남겨두는 결재 건수 — 즉시 등장하면 예측 가능해지고,
+// 너무 멀면 인과가 안 느껴진다.
+const INJECT_OFFSET_MIN = 2;
+const INJECT_OFFSET_MAX = 4;
+
+// 파라미터 단계(1~5)가 높을수록 높은 등급(S/A) 확률이 커지는 가중치 테이블
+const GRADE_WEIGHTS = {
+ 1: { C: 70, B: 22, A: 6, S: 2 },
+ 2: { C: 50, B: 28, A: 15, S: 7 },
+ 3: { C: 32, B: 30, A: 23, S: 15 },
+ 4: { C: 18, B: 24, A: 32, S: 26 },
+ 5: { C: 8, B: 17, A: 33, S: 42 },
+};
+
+// 자산 부작용(applyRisk)이 파라미터를 소수점으로 올리므로 반드시 정수 단계로 반올림해서 조회한다.
+export const pickGrade = (level) => {
+ const step = Math.min(5, Math.max(1, Math.round(level || 0)));
+ const weights = GRADE_WEIGHTS[step];
+ const total = Object.values(weights).reduce((a, b) => a + b, 0);
+ let r = Math.random() * total;
+ for (const [grade, w] of Object.entries(weights)) {
+ if (r < w) return grade;
+ r -= w;
+ }
+ return 'C';
+};
+
+/**
+ * 이 선택지가 덱에 몇 장의 오염 카드를 주입하는지 미리 계산한다.
+ * 스와이프 전 예고(SwipeCard)와 실제 주입(GameScreen)이 같은 값을 쓰도록 하는 단일 기준.
+ * @returns {{ total: number, entries: Array<{param, type, icon, count}> }}
+ */
+export function previewPollution(stats) {
+ if (!stats) return { total: 0, entries: [] };
+
+ const entries = [];
+ Object.entries(DANGER_STAT_TO_PARAM).forEach(([statKey, param]) => {
+ const value = stats[statKey] || 0;
+ if (value <= 0) return;
+ const type = CONTAM_TYPE_BY_PARAM[param];
+ entries.push({ param, type, icon: CONTAM_TYPE_ICON[type], count: value * POLLUTION_PER_DANGER_POINT });
+ });
+
+ // 상한 초과분은 뒤쪽 항목부터 깎는다 (앞 = 저항/엔트로피 쪽을 우선 남긴다)
+ let total = entries.reduce((sum, e) => sum + e.count, 0);
+ for (let i = entries.length - 1; i >= 0 && total > MAX_POLLUTION_PER_SWIPE; i--) {
+ const cut = Math.min(entries[i].count, total - MAX_POLLUTION_PER_SWIPE);
+ entries[i].count -= cut;
+ total -= cut;
+ }
+
+ return { total, entries: entries.filter((e) => e.count > 0) };
+}
+
+const makePollutionCard = (type, level, templates) => {
+ const grade = pickGrade(level);
+ // 등급별 초안이 아직 비어 있는 타입이 있을 수 있으므로 같은 타입 전체로 폴백한다.
+ const pool = templates.filter((t) => t.type === type && t.grade === grade);
+ const fallback = pool.length > 0 ? pool : templates.filter((t) => t.type === type);
+ if (fallback.length === 0) return null;
+ const template = fallback[Math.floor(Math.random() * fallback.length)];
+ return { ...template, id: `${template.id}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}` };
+};
+
+/**
+ * previewPollution()의 entries를 실제 카드 인스턴스로 만든다.
+ * 등급은 "그 파라미터의 현재 단계"로 뽑으므로, 위험이 높을수록 더 흉악한 오염이 들어온다.
+ */
+export function buildPollutionCards(entries, params, templates) {
+ const out = [];
+ entries.forEach(({ param, type, count }) => {
+ for (let i = 0; i < count; i++) {
+ const card = makePollutionCard(type, params[param], templates);
+ if (card) out.push(card);
+ }
+ });
+ return out;
+}
+
+/** 정례회의 종료 시 주입분 — 파라미터 단계에 비례한 장수로 뽑는다. */
+export function buildCouncilPollutionCards(params, templates) {
+ const entries = [];
+ Object.entries(CONTAM_TYPE_BY_PARAM).forEach(([param, type]) => {
+ const level = params[param] || 0;
+ if (level < 1) return;
+ entries.push({ param, type, icon: CONTAM_TYPE_ICON[type], count: councilPollutionCount(level) });
+ });
+ return buildPollutionCards(entries, params, templates);
+}
+
+/**
+ * 현재 결재 위치 기준 2~4장 뒤에 오염 카드를 끼워 넣는다.
+ * 덱은 인덱스를 순환(modulo)하며 도는 풀이므로, currentIdx는 항상 덱 범위 안의 값이어야 한다.
+ */
+export function insertIntoDeck(deck, currentIdx, newCards) {
+ const next = [...deck];
+ newCards.forEach((card) => {
+ const span = INJECT_OFFSET_MAX - INJECT_OFFSET_MIN + 1;
+ const offset = INJECT_OFFSET_MIN + Math.floor(Math.random() * span);
+ next.splice(Math.min(next.length, currentIdx + offset), 0, card);
+ });
+ return next;
+}
+
+/** 요약 문자열: "🌀 신화 x2 · 😱 사회공황" */
+export const describePollution = (entries) =>
+ entries.map((e) => `${e.icon} ${e.type}${e.count > 1 ? ` x${e.count}` : ''}`).join(' · ');
diff --git a/docs/architecture_design.md b/docs/architecture_design.md
index d31f2f9..68f8a11 100644
--- a/docs/architecture_design.md
+++ b/docs/architecture_design.md
@@ -1,105 +1,200 @@
-# Project SS - 파일 및 컴포넌트 구조 설계
+# Project SS — 아키텍처 (구현 실태)
-본 문서는 **"서사형 덱빌딩 게임 시스템 기획서"** 및 **"스토리 바이블"**을 바탕으로, 기존 단일 HTML 파일을 모던 웹 프레임워크(React + Vite + Tailwind CSS + Zustand) 기반으로 마이그레이션 및 고도화하기 위한 디렉토리 및 컴포넌트 구조를 정의합니다.
+> **이 문서는 "제안"이 아니라 "현재 코드가 실제로 어떻게 생겼는가"의 기록이다.**
+> 2026-07-23 기준으로 실제 구현을 확인해 전면 재작성했다.
+> 초기 제안서(React + Vite + **Tailwind + Zustand + TypeScript**)는 상당 부분 채택되지 않았고,
+> 그 내역은 문서 하단 "채택하지 않은 초기 제안"에 남겨 뒀다.
+>
+> 게임 **규칙·수치**의 원본은 이 문서가 아니라 `docs/` 하위 기획서들이다. 여기엔 코드 구조만 적는다.
-## 📂 1. 전체 디렉토리 구조 (Directory Structure)
+---
+
+## 1. 기술 스택 (실제)
+
+`client/package.json` 의존성이 전부다. 목록에 없는 것은 쓰지 않는다.
+
+| 영역 | 채택 | 비고 |
+|---|---|---|
+| 프레임워크 | **React 19** | 함수 컴포넌트만, 클래스 컴포넌트 없음 |
+| 빌드 | **Vite 8** | `client/`가 프로젝트 루트. npm 명령은 `cd client` 후 실행 |
+| 언어 | **plain JavaScript (`.jsx`)** | TypeScript 미도입 |
+| 스타일 | **컴포넌트별 개별 `.css`** | 글로벌 클래스명. CSS Modules 아님, Tailwind 아님 |
+| 상태관리 | **`useState` / `useEffect`** | 외부 상태관리 라이브러리 없음 |
+| 애니메이션 | **Framer Motion** | 카드 드래그/스와이프 |
+| 아이콘 | **lucide-react** | |
+| CSV 파싱 | **papaparse** | 런타임 파싱 (빌드 타임 변환 아님) |
+| 영구 저장 | **localStorage** | 엔딩 도감 전용 |
+
+디자인 토큰(컬러/폰트)은 `src/index.css`의 CSS 변수로 정의한다 (`--color-entropy-critical`,
+`--font-typewriter` 등). 컴포넌트 CSS는 이 변수를 참조한다.
+
+---
+
+## 2. 디렉토리 구조 (실제)
```text
-/src
- ├── assets/ # 이미지, 폰트, 아이콘 등 정적 파일
- ├── components/ # UI 및 도메인 컴포넌트
- │ ├── common/ # 공통 UI 요소 (버튼, 모달, 툴팁 등)
- │ ├── dashboard/ # 상단 상태창 및 파라미터 표시
- │ ├── gameplay/ # 핵심 게임 루프 (카드 스와이프, 자산 관리)
- │ └── council/ # 주간 의회 시스템 (투표, 법안, 파벌)
- ├── constants/ # 게임 내 상수 (파벌 정보, 테마 컬러, 엔딩 조건 등)
- ├── data/ # 초기 데이터 및 시나리오 (CSV/JSON 형식)
- ├── hooks/ # 커스텀 훅 (ex. useSwipe, useVoting)
- ├── store/ # 전역 상태 관리 (Zustand)
- ├── types/ # TypeScript 타입 정의 (Card, Faction, State 등)
- ├── utils/ # 헬퍼 함수 (엔트로피 계산, 전투/결과 연산 등)
- ├── App.tsx # 루트 컴포넌트 (페이지 라우팅 및 전역 상태 주입)
- └── main.tsx # 진입점
+ProjectSS/
+├── playtest.bat # 더블클릭 → npm install(최초 1회) + dev 서버 + 브라우저 자동 오픈
+│ # ASCII 전용 — 한글/chcp를 넣으면 cmd.exe가 배치 파싱을 깨뜨린다
+├── CLAUDE.md # 작업 규칙 · Phase 상태 (실질적인 단일 진실 공급원)
+├── ROADMAP.md
+├── docs/ # 기획서 · 스토리 바이블 · 작업지시서 (게임 규칙의 원본)
+└── client/ # ★ 실제 앱. npm 명령은 여기서 실행
+ ├── update_csv.js # ⛔ 보호 파일 — cards.csv 전처리
+ ├── update_csv_tags.js # ⛔ 보호 파일 — 내러티브 태그 매핑
+ └── src/
+ ├── main.jsx (10줄) 진입점
+ ├── App.jsx (13줄) GameScreen 하나만 렌더 — 라우터 없음
+ ├── index.css 디자인 토큰(CSS 변수) + 리셋
+ ├── assets/ hero.png 등 정적 파일
+ ├── components/ ★ flat 구조. 하위 폴더 없음
+ │ ├── GameScreen.jsx (628줄) ★ 상태 허브 + 화면 전환
+ │ ├── SwipeCard.jsx (156줄) 카드 드래그 · 선택지 사전 예고
+ │ ├── CouncilScreen.jsx (220줄) 정례회의(투표 · 개입 액션)
+ │ ├── EndingScreen.jsx (46줄) 엔딩 출력 + 도감 저장
+ │ ├── EndingCodex.jsx (33줄) 엔딩 도감 (localStorage 읽기)
+ │ ├── ChroniclePopup.jsx (63줄) 연대기 + 태그 히스토리
+ │ ├── AssetInfoPopup.jsx (43줄) 자산 상세 · 수동 발동
+ │ └── *.css 컴포넌트당 1개씩 짝을 이룸
+ ├── engine/ ★ React를 모르는 순수 로직
+ │ ├── assetEngine.js (226줄) 자산 트리거→효과→연쇄
+ │ └── pollution.js (154줄) 오염 카드 주입 규칙 · 밸런스 상수
+ └── data/
+ ├── cards.csv 카드 원본 (런타임 papaparse 파싱)
+ ├── assets.json 자산 8종 정의
+ ├── narrative_tags.json 재사용 태그 풀 10종
+ ├── endings.js (123줄) 엔딩 카탈로그 + 판별 함수
+ └── pollution_cards.json ⚠️ 어디서도 import하지 않음 (레거시)
```
---
-## 🧩 2. 상세 컴포넌트 구조 (Components Breakdown)
+## 3. 핵심 아키텍처 원칙
-### 2.1 공통 UI (`/components/common`)
-프리미엄한 관료주의 다크/레트로 UI를 구성하는 기본 요소들입니다.
-- `Button.tsx`: 액션 버튼 (결재, 서명 등 텍스처 효과 포함)
-- `Modal.tsx`: 미스터리 보드, 법안 확인 등을 위한 공통 팝업
-- `ProgressBar.tsx`: 게이지 바 (엔트로피, 내부 저항 등)
-- `AlertToast.tsx`: 패시브 효과 연쇄나 비상 사태를 알리는 시스템 메시지 (우측 하단/상단 노출)
+### 3.1 상태는 `GameScreen.jsx` 한 곳에 모은다
-### 2.2 대시보드 (`/components/dashboard`)
-플레이어의 현재 상태(국장실 요약)를 보여줍니다.
-- `StatusHeader.tsx`: 현재 주차(Week), 일차(Day), 주간 아젠다 노출
-- `ParameterBoard.tsx`: 3대 핵심 파라미터 게이지 (내부 저항, 엔트로피, 위험 수용 계수)
-- `ResourcePanel.tsx`: 3대 토큰(예산, 인력, 정보) 및 자원 태그(재력, 무력, 감시) 현황
-- `FactionDonut.tsx`: 8대 파벌의 의회 지분율 및 우호도를 시각화하는 도넛 차트
+전역 스토어가 없다. 게임 상태 전부가 `GameScreen`의 `useState`로 존재하고, 하위 컴포넌트는
+props로 값과 콜백을 받는 프레젠테이션 계층이다.
-### 2.3 게임플레이 (`/components/gameplay`)
-일일 업무(결재 단계)를 처리하는 핵심 루프 컴포넌트입니다.
-- `DeskView.tsx`: 국장실 책상 배경 및 전체 레이아웃 래퍼
-- `CardSwiper.tsx`: 틴더 스타일의 스와이프 인터페이스 (Tinder-like Swipe)
- - 좌/우 스와이프에 따른 예측 결과(예상 토큰/파라미터 증감)를 오버레이로 표시
-- `DilemmaCard.tsx`: 개별 딜레마/위기 서류. (제목, 내용, 플레이버 텍스트, 결재/반려 스탬프 이펙트)
-- `AssetManager.tsx`: 보유 중인 자산(파이프라인, 성질 변환 등 덱 압축 장치) 관리 모달
-
-### 2.4 의회 시스템 (`/components/council`)
-매주 5일차 종료 후 진행되는 정치 시스템 컴포넌트입니다.
-- `CouncilPhase.tsx`: 주간 의회 화면 메인 컨테이너
-- `BillDisplay.tsx`: 상정된 법안(이득 규약 vs 표적 규약) 비교 UI
-- `VotingBoard.tsx`: 13명의 임원(상임 이사 8명 + 글로벌 지부장 5명)의 예상 투표 현황 및 게이지 바
-- `InterventionPanel.tsx`: 국장의 개입 액션 (매수, 장부 조작, 내부 감사) 버튼 및 리스크 안내 패널
-
-### 2.5 뷰 / 씬 (Views/Scenes)
-- `LoadingScreen.tsx`: 초기 데이터 파싱 및 게임 세팅 화면
-- `GameScreen.tsx`: 인게임 전체 메인 화면 (대시보드 + 게임플레이 결합)
-- `EndingScreen.tsx`: 멀티 엔딩 매트릭스에 따른 최종 결과 출력 (위대한 침묵, 신인류의 탄생 등)
-
----
-
-## 💾 3. 전역 상태 구조 (Global State - Zustand 제안)
-
-관리가 복잡한 게임 데이터를 효율적으로 다루기 위해 상태를 나눕니다.
-
-```typescript
-interface GameState {
- // 시간/페이즈 관리
- week: number;
- day: number;
- phase: 'DAY_WORK' | 'COUNCIL' | 'ENDING';
-
- // 핵심 파라미터
- entropy: number; // 0~100%
- internalResistance: number; // 0~100%
- riskTolerance: number; // 0~100%
-
- // 자원
- tokens: { budget: number; personnel: number; info: number; };
- tags: { finance: number; force: number; surveillance: number; };
-
- // 덱 및 내러티브
- narrativeTags: string[];
- ideologyTags: string[];
- deck: Card[]; // 섞인 오염카드 및 이벤트 풀
- assets: Asset[]; // 덱 압축 파이프라인
-
- // 파벌 및 의회 (13명의 임원 정보 포함)
- factions: FactionState[];
- currentBills: Bill[];
-}
+```
+GameScreen (상태 소유)
+ ├── SwipeCard ← card, onSwipe, phase
+ ├── CouncilScreen ← params/tokens/tags + setter, onResolve ※ setter를 직접 넘기는 유일한 예외
+ ├── EndingScreen ← ending, narrativeTags
+ ├── ChroniclePopup ← chronicle, acquiredTags
+ └── AssetInfoPopup ← instance, def, onActivate
```
+**트레이드오프:** 파일이 628줄까지 커졌고 관심사가 섞여 있다. 다만 게임 규칙이 아직 확정되지
+않아 잦은 수정이 예상되는 그레이박스 단계에서는 "한 파일만 보면 전체 흐름을 안다"는 이점이
+분할 비용보다 컸다. Phase 5 이후 재검토 대상이다.
+
+### 3.2 규칙 계산은 `engine/`의 순수 함수로 뺀다
+
+`engine/`은 React·훅·JSX를 전혀 import하지 않는다. 평범한 객체를 받아 평범한 객체를 돌려준다.
+
+이 경계 덕분에 **게임을 띄우지 않고 밸런스를 검증할 수 있다.** 실제로 오염 카드 물량 조정 시
+`node` 스크립트로 `pollution.js`를 직접 import해 600회 시뮬레이션을 돌려 변경 전후를 비교했다.
+새 규칙 모듈을 만들 때도 이 성질을 깨지 말 것.
+
+### 3.3 `world` 스냅샷 패턴 (자산 엔진)
+
+`assetEngine`은 상태를 직접 만지지 않는다. `GameScreen`이 현재 state를 복사한 `world` 객체를
+만들어 넘기고, 엔진이 변형한 결과를 다시 state로 커밋한다.
+
+```js
+const world = buildAssetWorld(); // state 복사 → { cards, tokens, tags, params, owned, quarantine, logs }
+AssetEngine.onPollutionDraw(world, card.type);
+AssetEngine.onApprovalTick(world, nextTurns);
+commitAssetWorld(world); // 결과를 setState로 일괄 반영
+```
+
+> **⚠️ 반드시 지킬 것:** 한 이벤트 처리 안에서 `buildAssetWorld`/`commitAssetWorld` 쌍을
+> **정확히 한 번만** 쓴다. 두 번 짝지어 부르면 두 번째 `build`가 아직 리렌더되지 않은
+> 낡은 클로저 state를 복사하므로, 나중 커밋이 앞의 변경을 덮어써 버린다.
+
+엔진 훅 지점은 4곳이다: `onApprovalTick`(결재마다) · `onPollutionDraw`(오염 카드 등장) ·
+`onSwipeLeft`(좌 스와이프 격리) · `onCouncilEnd`(정례회의 종료). 연쇄 발동은 `fireEvent`가
+깊이 상한 10으로 처리한다.
+
+### 3.4 예고 · 실행 · 알림은 같은 함수를 공유한다
+
+플레이어에게 미리 보여준 숫자와 실제 결과가 어긋나면 정보 자체를 신뢰하지 않게 된다.
+그래서 오염 주입량은 `pollution.js`의 `previewPollution()` 하나가 단일 기준이다.
+
+```
+SwipeCard "☣ 오염 카드 +2" 사전 경고 ─┐
+GameScreen 실제 덱 주입 ─┼─ 모두 previewPollution() 사용
+GameScreen "2건 유입" 사후 토스트 ─┘
+```
+
+이 함수를 우회해 오염 카드를 만들지 말 것. 밸런스 수치는 `pollution.js` 상단 상수 블록에 모여 있다.
+
---
-## 🚀 4. 추천 기술 스택
-- **Framework**: `React` (컴포넌트 분리를 통한 복잡한 UI 관리)
-- **Build Tool**: `Vite` (빠른 HMR 및 빌드 속도)
-- **Styling**: `Tailwind CSS` (유틸리티 클래스로 빠른 UI 구현) + CSS Animations (스와이프 액션, 팝업 등)
-- **State Management**: `Zustand` (가볍고 직관적인 전역 상태 관리)
-- **Interactions**: `Framer Motion` 또는 `react-spring` (부드러운 카드 스와이프 액션 및 몰입감 있는 애니메이션)
-- **Type Checking**: `TypeScript` (복잡한 자원 계산과 이벤트 분기에 대한 안정성 보장)
+## 4. 데이터 흐름
+
+```
+cards.csv ──(?raw import)──> papaparse ──> buildCardFromRow() ──> 덱 배열
+ │
+narrative_tags.json ──> left_tag/right_tag id로 조인 ────────────────┘
+```
+
+- **카드 데이터는 런타임에 파싱한다.** `import cardsCsvRaw from '../data/cards.csv?raw'` 후
+ 브라우저에서 papaparse로 처리한다. 빌드 타임 변환 단계가 없으므로 CSV를 고치면 새로고침만으로 반영된다.
+- 덱은 **인덱스를 순환(modulo)하는 풀**이다. 소진되어 없어지지 않고 계속 돈다.
+ `currentCardIndex`는 매 결재마다 최종 덱 길이로 정규화해 범위 안에 유지한다 — 주입/소각으로
+ 길이가 변하기 때문이다.
+- 카드 풀은 페이즈별로 분리한다: 페이즈1(일상/위기)로 시작하고, 내러티브 태그 3종 획득 시
+ 페이즈2(작전/극비) 풀이 덱에 삽입된다.
+- 오염 카드(신화/조직저항/사회공황/이사회압박)는 `contaminationTemplates`로 따로 보관하다가
+ 주입 시점에 복제해 고유 id를 붙인다.
+
+---
+
+## 5. 화면 전환
+
+라우터가 없다. `GameScreen`이 상태를 보고 조기 return으로 화면을 고른다.
+
+```js
+if (gameOver) return ;
+if (showCouncil) return ;
+if (cards.length === 0) return 데이터를 불러오는 중...
;
+return ( /* 결재 화면 */ );
+```
+
+팝업(연대기·자산 정보·도감)은 화면을 갈아끼우지 않고 조건부 오버레이로 렌더한다.
+
+---
+
+## 6. 영구 저장
+
+localStorage에 저장하는 것은 **엔딩 도감뿐이다.** 세이브/로드 기능은 없고, 게임오버 시
+`window.location.reload()`로 재시작한다.
+
+- 키: `ss_endings_v2` (`EndingCodex.jsx`에서 `ENDINGS_STORAGE_KEY`로 export)
+- 값: 해금된 엔딩 **id** 배열. 구버전 `ss_endings`는 제목 문자열 기반이라 폐기했고 호환되지 않는다.
+
+---
+
+## 7. 채택하지 않은 초기 제안
+
+초기 제안서에 있었으나 **의도적으로 도입하지 않은** 것들이다. 되살리려면 CLAUDE.md 규칙부터 바꿔야 한다.
+
+| 제안 | 현재 | 사유 |
+|---|---|---|
+| Tailwind CSS | 컴포넌트별 `.css` | 관료제 다크 UI는 유틸리티 클래스보다 커스텀 CSS가 손에 맞았다 |
+| Zustand | `useState` | 상태가 한 컴포넌트에 모여 있어 전역 스토어의 값어치가 없다. **도입 금지** |
+| TypeScript | plain JS | 그레이박스 단계의 잦은 스키마 변경 대비 타입 유지비가 컸다 |
+| `store/ hooks/ types/ utils/ constants/` | 없음 | 규칙 로직은 `engine/`으로 통합 |
+| `components/{common,dashboard,gameplay,council}/` | flat | 컴포넌트 7개뿐이라 계층이 불필요 |
+| 8대 파벌 도넛 차트 | 텍스트 목록 | 파벌은 12종 풀에서 5명 선발 방식으로 변경됨 |
+
+---
+
+## 8. 알려진 부채
+
+- **`GameScreen.jsx` 628줄** — 상태 허브 + 화면 전환 + 스와이프 처리 + 파벌 계산이 한 파일에 있다.
+- **`data/pollution_cards.json` 미사용** — 어디서도 import하지 않는 레거시. 오염 카드는 `cards.csv`에서 온다.
+- **테스트 코드 없음** — 검증은 `engine/` 모듈을 직접 부르는 일회성 node 스크립트로 해 왔다.
+- **`window.location.reload()` 재시작** — 상태 초기화 로직이 따로 없다.
diff --git a/docs/내러티브태그_시스템_작업지시서.md b/docs/작업지시서/내러티브태그_시스템_작업지시서.md
similarity index 100%
rename from docs/내러티브태그_시스템_작업지시서.md
rename to docs/작업지시서/내러티브태그_시스템_작업지시서.md
diff --git a/docs/작업지시서/자산시스템_작업지시서.md b/docs/작업지시서/자산시스템_작업지시서.md
new file mode 100644
index 0000000..b74d8a5
--- /dev/null
+++ b/docs/작업지시서/자산시스템_작업지시서.md
@@ -0,0 +1,128 @@
+# 자산(엔진) 시스템 작업지시서 — 그레이박스 v1
+
+> **목적**: 발라트로형 엔진 빌딩 검증. 자산 8종을 데이터로 구현하고, 자산 정보 UI 블로커를 해결하여 실제 플레이 테스트가 가능한 상태를 만든다.
+> **원칙**: Greybox First. 수치는 전부 가안이며 튜닝 대상. 비주얼 연출 금지(텍스트/기본 도형만).
+> **주의**: `update_csv_tags.js`, `update_csv.js` 등 파이프라인 스크립트는 수정 금지. 본 작업은 신규 데이터 파일 + 신규 로직 모듈로만 구성한다.
+
+---
+
+## Part A. 자산 데이터 스키마
+
+신규 파일 `src/data/assets.json` 생성. 자산 1장의 문법은 **트리거 → 효과 → (선택) 성장**.
+
+```json
+{
+ "id": "asset_purge_001",
+ "name": "문서 파쇄실",
+ "grade": "C", // C | B | A | S
+ "archetype": "purge", // purge | convert | quarantine | skip
+ "trigger": {
+ "type": "every_n_approvals", // 트리거 enum (아래 표)
+ "params": { "n": 5 }
+ },
+ "effect": {
+ "type": "purge_lowest", // 효과 enum (아래 표)
+ "params": { "count": 1 }
+ },
+ "growth": null, // A급 이상만 사용. { "stackOn": "...", "per": 3, "bonus": {...} }
+ "risk": null, // 부작용. { "param": "risk_tolerance", "amount": 5, "when": "council_end" }
+ "faction_link": "scavengers", // 우호도 연동 파벌 (nullable)
+ "description": "결재 5건마다 덱에서 최하 등급 오염 카드 1장을 영구 소각한다.",
+ "flavor": "파쇄 용량: 일일 1건. 증설 요청은 3회 반려됨."
+}
+```
+
+### 트리거 enum (v1 범위)
+
+| type | 의미 | params |
+|---|---|---|
+| `on_pollution_draw` | 오염 카드 드로우 시 | `category`(신화/저항/공황/압박, nullable), `minGrade`(nullable) |
+| `on_swipe_left` | 좌 스와이프 시 | — |
+| `on_token_spend` | 토큰 소모 시 | `tokenType`(nullable) |
+| `every_n_approvals` | 결재 N건마다 | `n` |
+| `on_council_end` | 의회 종료 시 | — |
+| `on_purge` | 오염 카드 소각 시 (연쇄용) | — |
+| `on_convert` | 오염 카드 변환 시 (연쇄용) | — |
+| `on_quarantine_fail` | 격리 실패 복귀 시 (연쇄용) | — |
+| `manual_charge` | 충전식 수동/자동 방패 | `chargeEvery` |
+
+### 효과 enum (v1 범위)
+
+| type | 의미 | params |
+|---|---|---|
+| `purge_lowest` | 최하 등급 오염 소각 | `count` |
+| `convert_to_token` | 오염 → 토큰 환전 | `tokenType`, `amount` |
+| `quarantine` | 오염 격리 (조건 실패 시 강화 복귀) | `failCondition` |
+| `negate_penalty` | 페널티 1회 무효 | `category`(nullable) |
+| `gain_token` | 토큰 생성 | `tokenType`, `amount` |
+| `gain_tag` | 자원 태그 생성 | `tagType`, `amount` |
+| `convert_multiplier` | 변환 산출 배수 (상시) | `multiplier` |
+| `quarantine_fail_to_purge` | 격리 실패 → 영구 소각으로 치환 (규칙 왜곡) | `bonusTag`(nullable) |
+
+> 구현 노트: 트리거는 게임 이벤트 버스에 리스너로 등록. `on_purge`/`on_convert`/`on_quarantine_fail`은 다른 자산의 효과가 발생시키는 이벤트를 구독하므로, **효과 실행부가 반드시 이벤트를 발행**해야 연쇄가 성립한다. 무한 루프 방지를 위해 결재 1건당 연쇄 깊이 상한 10회.
+
+---
+
+## Part B. 자산 8종 데이터 (확정 배치 1차)
+
+### ① 소각 특화 (faction: scavengers)
+
+1. **문서 파쇄실** — C급
+ - 트리거: `every_n_approvals(5)` / 효과: `purge_lowest(1)`
+2. **소각 처리 대장** — A급
+ - 트리거: `on_purge` / 성장: 스택 +1, 스택 3마다 `purge_lowest(1)` 추가 발동
+
+### ② 변환 경제 (faction: nexus_traders)
+
+3. **폐기물 재활용 규정** — C급
+ - 트리거: `on_pollution_draw(category: 신화)` / 효과: `gain_token(예산, 2)` (페널티는 정상 적용 후)
+4. **부산물 매각 계약** — B급
+ - 효과: `convert_multiplier(2)` 상시 / 리스크: 의회 종료 시마다 위험 수용 계수 +5%
+
+### ③ 격리 도박 (faction: occult_collectors)
+
+5. **임시 격리 컨테이너** — C급
+ - 트리거: `on_swipe_left`(발동 선택 가능) / 효과: `quarantine(failCondition: 다음 의회까지 대중 공황 1단계 상승)`
+6. **심연 보관 조례** — S급
+ - 트리거: `on_quarantine_fail` / 효과: `quarantine_fail_to_purge(bonusTag: 감시 +1)`
+
+### ④ 무효 회피 (faction: field_agents_union — 우호도 **하락** 연동)
+
+7. **결재 반려 도장** — C급
+ - 트리거: `manual_charge(chargeEvery: 10)` / 효과: `negate_penalty(전체)`
+8. **책임 소재 불명 처리** — B급
+ - 트리거: 조직 저항 계열 `negate_penalty` 발동 시 / 효과: 인력 토큰 소모 없이 통과 / 리스크: 내부 저항 +3%
+
+> 수치(5건, 토큰 2, 스택 3, 5%, 3% 등)는 전부 가안. 플레이 테스트 후 일괄 튜닝.
+
+---
+
+## Part C. UX 블로커 해결 (최우선 선행 작업)
+
+**현재 문제**: 자산 클릭 시 아무 정보도 표시되지 않음 → 덱압축 시스템 평가 자체가 불가능.
+
+1. 하단 자산 슬롯의 `Icon_Asset` 탭/클릭 시 정보 팝업 표시:
+ - 이름, 등급, 아키타입 아이콘
+ - **트리거 조건 → 효과** 를 한 문장으로 (description 필드 그대로)
+ - 성장형이면 현재 스택 수치 표시
+2. **자산 발동 시 가시 피드백 (텍스트 로그로 충분)**: 화면 한쪽에 "『문서 파쇄실』 발동 — [하급 신화] 소각됨" 식의 1줄 로그. 발라트로의 조커 점멸에 해당하는 최소 구현. 연쇄 발동 시 순서대로 줄줄이 출력되어야 "엔진이 돌아간다"는 감각이 생긴다.
+3. 그레이박스 기준이므로 애니메이션 불필요. 텍스트 로그 + 팝업만.
+
+---
+
+## Part D. 플레이 테스트 체크리스트
+
+구현 완료 후 아래를 검증한다.
+
+- [ ] **역전 엔진 성립**: 3번(재활용 규정) + 1번(파쇄실) 조합 시, 오염이 늘수록 자원이 돌고 덱이 정화되는 체감이 있는가?
+- [ ] **연쇄 가시성**: 2번(소각 처리 대장) 보유 시 소각→스택→추가 소각 연쇄가 로그로 읽히는가?
+- [ ] **S급 킹핀**: 6번(심연 보관 조례) 획득 전후로 격리 자산의 가치 평가가 뒤집히는가? (획득 전: 격리 기피 / 획득 후: 격리 남발)
+- [ ] **무효 빌드의 풍자 체감**: 7+8번 조합으로 "덱은 더럽지만 아무 일도 안 일어나는" 상태가 만들어지는가?
+- [ ] **오버파워 확인**: 페이즈 3에서 엔진이 오염 주입 속도를 이기는 순간이 오는가? (이겨야 정상 — 발라트로 모델)
+- [ ] 연쇄 깊이 상한(10회)에 걸리는 케이스가 있는가?
+
+## 범위 외 (다음 배치)
+
+- 의회 압박 강화(안건 예고제, 표차 가시화) — 별도 작업지시서로 분리 예정
+- 하이브리드 아키타입 자산 및 A/S급 보강 (2차 배치)
+- 자산 획득 경로(극비 프로젝트 보상 테이블) 튜닝
diff --git a/playtest.bat b/playtest.bat
new file mode 100644
index 0000000..79bd4df
--- /dev/null
+++ b/playtest.bat
@@ -0,0 +1,46 @@
+@echo off
+REM ProjectSS playtest launcher.
+REM ASCII only on purpose: Korean text inside a .bat is mangled by cmd.exe codepage
+REM handling, and calling "chcp" mid-script corrupts the batch parser itself.
+
+title ProjectSS - playtest
+cd /d "%~dp0client"
+
+where npm >nul 2>nul
+if errorlevel 1 goto no_node
+
+if not exist node_modules goto install
+goto run
+
+:install
+echo.
+echo First run - installing dependencies. This takes 1-2 minutes.
+echo.
+call npm install
+if errorlevel 1 goto install_failed
+
+:run
+echo.
+echo Starting dev server. Your browser will open automatically.
+echo Close this window or press Ctrl+C to stop.
+echo.
+call npm run dev -- --open
+echo.
+echo Server stopped.
+pause
+exit /b 0
+
+:no_node
+echo.
+echo [ERROR] Node.js is not installed.
+echo Install the LTS build from https://nodejs.org and run this again.
+echo.
+pause
+exit /b 1
+
+:install_failed
+echo.
+echo [ERROR] npm install failed. See the messages above.
+echo.
+pause
+exit /b 1