Compare commits

...

4 Commits

Author SHA1 Message Date
kyoung5seo
db1051c08c 자산(엔진) 시스템 v1 + 오염 카드 압박 강화
자산 시스템 (docs/작업지시서/자산시스템_작업지시서.md 반영, 이전 세션 작업분 커밋):
- 발라트로형 조커 레이어 엔진 빌딩 도입. assets.json 8종(트리거→효과→성장 스키마) +
  engine/assetEngine.js(이벤트 버스, 연쇄 깊이 상한 10)로 기존 수동 클릭형 자산 4종 전면 대체
- AssetInfoPopup 추가 — 자산 슬롯 클릭 시 트리거/효과/스택/충전 상태 표시, 발동 로그 피드 노출

오염 카드 압박 강화 (그레이박스 위기감 피드백 반영):
- engine/pollution.js 신설 — 오염 주입 규칙(타입 매핑·등급 추첨·물량)을 단일 모듈로 분리해
  사전 예고(SwipeCard) · 실제 주입(GameScreen) · 사후 알림이 같은 계산을 공유하도록 함
- 위험 파라미터를 올리는 결재는 정례회의를 기다리지 않고 그 자리에서 덱에 오염 카드를 주입
- UI 3종: 스와이프 시 오염 유입 사전 경고, 결재 후 유입 토스트, 상단 덱 오염 상시 게이지
- currentCardIndex를 매 결재마다 덱 길이로 정규화(주입/소각으로 덱 길이가 바뀌므로 필요)

기타:
- docs/architecture_design.md를 실제 구현(plain JS/CSS, useState, engine/ 순수 함수 경계)
  기준으로 전면 재작성 — 기존 문서는 채택되지 않은 초기 제안(Tailwind/Zustand/TS)이었음
- playtest.bat 추가 — 더블클릭으로 dev 서버 실행 (ASCII 전용, chcp로 인한 배치 파싱 오류 회피)
- CLAUDE.md 상태 갱신 및 실기 플레이 결과 기록

실기 플레이로 정상 동작 확인. 자산 획득 경로(극비 카드 스와이프)는 플레이 중 한 번도 발생하지
않아 원인 미조사 상태로 남김 — CLAUDE.md에 후속 확인 항목으로 기록.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:59:30 +09:00
kyoung5seo
31558edcb2 Part A (연대기 ↔ 내러티브 태그 개념 분리) — 문서 6개 반영
시스템 기획서: 내러티브 태그 항목에서 연대기 서술 제거, 연대기를 독립 항목으로 신설
스토리 바이블: "연대기의 편찬" 테마를 '결정 장부' 관점으로 재서술
텍스트 와이어프레임: 팝업을 '세계의 상흔'(태그) / '연대기'(결재 이력) 2종으로 분리, 엔딩 화면에 연대기 요약 블록 추가
비전 문서 / PRD / 유저플로우 / 개발순서: 각각 정의 분리 및 분리 저장 명시
Part B (스니펫 시스템) — B-4 결정사항(은근한 헌신 농도, 고정 마침 도장 리스트) 반영

narrative_tags.json 10종 전체에 snippet(1문장, 숫자 1개, 거리감 있는 관료체) + snippet_type 필드 추가 — 시스템형 7 : 사람형 3 비율. update_csv_tags.js는 이 파일을 생성/가공하지 않아 스크립트 변경 불필요했습니다.
빌드(vite build) 정상 확인.
2026-07-13 21:22:42 +09:00
kyoung5seo
cdee98ffc2 Merge branch 'main' of https://git.mint-grape.duckdns.org/kyoung5/ProjectSS 2026-07-09 23:20:31 +09:00
kyoung5seo
ad7db470f6 Phase 2~4 그레이박스 시스템 완성 및 내러티브 태그 재설계
1. Phase 2 완료 — 자원 경제 & 덱빌딩
   - 파산 문책 시스템: 토큰 마이너스 상태로 결재 시 파산 카운터 증가,
     의회에서 단계별 경고 → 미개선 시 다음 의회에서 해임 게임오버
   - 오염 카드 등급 확률: 엔트로피/내부저항/대중공황/위험계수 단계에 비례한
     S/A/B/C 가중치로 의회 종료 시 오염 카드 자동 주입
     (신화/조직저항/사회공황/이사회압박 16종 카드 신규 추가)
   - 자산 효과 4종 구현: 성질 변환·시한부 격리·물리적 영구 소각·결재권 강제 행사
     (자산 슬롯 클릭으로 발동)

2. Phase 3 완료 — 정치 시스템 & 스토리 페이징
   - 글로벌 지부장 12개 파벌이 각자 다른 공식으로 우호도를 계산하는 엔진 추가.
     게임 시작 시 5명 무작위 선발, 동맹/파국 성향 임원은 국장의 개입(매수/감사)에 면역
   - 내러티브 태그 누적치에 따라 페이즈1(일상/위기) → 페이즈2(작전/극비) 카드 풀이
     실시간으로 전환되도록 구현
   - 버그 수정: 위험 수용 계수(risk)가 직접 게임오버를 발동시키지만 EndingScreen에
     대응 엔딩이 없어 "미확인 종말" 빈 폴백으로 빠지던 문제 (risk는 탄핵 기준치로만 사용하도록 수정)
   - 버그 수정: CouncilScreen의 임원 초기화 effect가 매수 액션의 risk 상승으로
     재실행되며 방금 강제 전환한 투표 결과를 덮어쓰던 문제 (마운트 시 1회만 초기화하도록 수정)

3. Phase 4 완료 — 멀티 엔딩 & 연대기
   - 우선순위 기반 엔딩 판별기(endings.js) 신규: 의회 정치 엔딩 > 내러티브 연쇄 엔딩 >
     변주된 붕괴 엔딩(자원 태그 3종 + 토큰 3종) > 기본 붕괴 엔딩, 총 15종 엔딩 카탈로그
   - 극단적 파라미터 조기 엔딩: 단일 결재로 파라미터가 급격히 5단계에 도달하면
     "돌이킬 수 없는 순간" 즉시 발동
   - 엔딩 도감(EndingCodex) 신규 컴포넌트: 해금/미해금(???) 표시,
     localStorage(ss_endings_v2)에 엔딩 고유 id 기준으로 영구 저장
   - 연대기 팝업에 타임라인(세로선 + 턴별 점) 시각화 추가

4. 내러티브 태그 시스템 재설계
   - 기존에는 결재 카드 한 장당 무조건 +1 되던 카운터였으나, "항상 얻는 보상이 아니다"라는
     기획 의도에 맞춰 선별된 카드(22/63장)의 좌/우 선택지에만 짧은 키워드를 부여하도록 변경
     (cards.csv에 left_narrative/right_narrative 컬럼 추가)
   - 연대기 팝업 상단과 엔딩 화면에 수집한 키워드를 칩(chip) 형태로 표시
   - 위 재설계로 태그 획득이 희소해진 만큼, 페이즈 전환 문턱을 4/8 → 2/4로 하향 조정

5. 기타
   - 로컬 Node.js LTS 설치 및 Playwright 기반 실기 플레이 검증 체계 확립
   - ROADMAP.md/CLAUDE.md Phase 상태 갱신 (Phase 0~4 그레이박스 구간 완료,
     다음은 외부 플레이어 그레이박스 테스트)
2026-07-09 22:46:30 +09:00
34 changed files with 2781 additions and 444 deletions

20
.claude/commands/audit.md Normal file
View File

@@ -0,0 +1,20 @@
# 구현 실태 감사 (Audit)
커밋 히스토리에 "적용 안된 것 많음" 코멘트가 있으므로, 자기 보고가 아닌 **코드를 직접 열어 확인**하고 항목별로 [구현됨 / 부분 구현 / 미구현] + 근거 파일·라인을 보고하라.
## Phase 2 확인 항목
- [ ] **파산 문책 시스템** — 토큰 마이너스 시 파산 카운터 증가 → 의회 문책 → 미개선 시 게임 오버 흐름
- [ ] **오염 카드 등급 확률** — 파라미터 단계(1~5)에 비례한 S/A/B/C 가중치가 실제 작동하는지
- [ ] **자산 효과 4종** — 성질 변환 / 시한부 격리 / 물리적 영구 소각 / 결재권 강제 행사 각각의 완성도
## Phase 3 확인 항목
- [ ] **파벌 우호도 세부 조건** — 글로벌 지부장 12개 파벌이 각각 고유 조건으로 우호도가 계산되는지, 아니면 단순 확률 처리인지
(파벌별 조건 정의는 @docs/Project_SS_스토리_바이블_v3_0.md 4.2절 참조)
- [ ] **파산 카운터 → 의회 문책 연동** — 파산 카운터가 의회 단계에서 실제로 참조되는지
- [ ] **스토리 페이징 카드 풀 전환** — 내러티브 태그 0~3 / 4~7 / 8+ 구간에 따라 카드 풀이 실제로 바뀌는지
## 보고 후
감사 결과를 바탕으로 CLAUDE.md의 "현재 Phase 상태" 블록을 실제 구현 상태에 맞게 갱신하고, ROADMAP.md의 `[x]`/`[~]`/`[ ]` 표기를 수정할 것을 제안하라.

156
CLAUDE.md Normal file
View File

@@ -0,0 +1,156 @@
# Project SS — CLAUDE.md
> **위치:** repo root (`ProjectSS/CLAUDE.md`) — Claude Code는 root에서 실행한다.
> **규칙:** 작업 완료 후 "현재 Phase 상태" 블록을 반드시 업데이트한다.
---
## 개발 전략
**그레이박스 우선 (Greybox First)**
게임플레이 재미 검증 → 그레이박스 테스트 → 비주얼 폴리싱 순서로 진행한다.
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` (도감 엔딩 저장 용도)
- 카드 데이터는 `client/src/data/cards.csv`를 papaparse로 런타임 파싱함
- `GameScreen.jsx`에 상태가 집중되어 있음 — 수정 시 주의, 리팩토링 필요 가능성
## ⛔ 보호 파일 (직접 수정/실행 금지 — 변경 제안은 가능)
- `client/update_csv.js` — cards.csv 전처리 스크립트 (데이터 변환 규칙: 컬럼 구조, 인코딩, 파싱 로직 등)
- `client/update_csv_tags.js` — 내러티브 태그 매핑 스크립트
**이유:** 두 스크립트의 출력 포맷(변환 규칙)이 바뀌면, 이를 읽는 게임 코드(papaparse 파싱 부분)가
깨지거나 데이터를 잘못 읽을 수 있다.
**규칙:** 이 스크립트들을 직접 고쳐서 실행하지 말 것. 단, Phase 진행상 스키마 변경이 실제로 필요한
경우(예: 새 컬럼/태그 카테고리 추가) 코드를 바로 바꾸지 말고, 어떤 변경이 왜 필요한지 설명하고
변경안(diff)을 먼저 보여준 뒤 승인을 받고 나서 적용할 것.
**단, `cards.csv` / `narrative_tags.json` 등 데이터 내용(카드 텍스트, 수치, 태그)은 자유롭게 다뤄도 된다.**
이 게임은 카드 밸런스와 스토리 개연성이 핵심이라, 데이터는 자동 생성으로 일괄 채우지 말고
디자이너가 직접 열어보고 검토·조정할 수 있는 형태로 제안할 것 (예: 몇 개씩 초안을 보여주고
피드백을 받아 반영하는 방식). 통짜로 대량 생성해서 덮어쓰지 말 것.
---
## 기획 문서 참조 (게임 규칙의 원본 — 코드 작성 시 이쪽을 기준으로)
- 카드 타입별 스와이프 결과, 파라미터, 의회 액션 → @docs/Project_SS_서사형_덱빌딩_게임_시스템_기획서.md
- 파벌 12종 우호도 조건, 엔딩 매트릭스, 세계관 → @docs/Project_SS_스토리_바이블_v3_0.md
- 화면 전환/분기 로직 → @docs/ProjectSS_유저플로우.md
- UI 레이아웃 → @docs/ProjectSS_텍스트_와이어프레임.md
- 컬러/타이포/햅틱 수치 → @docs/ProjectSS_디자인_토큰.md
- 요구사항 정의 → @docs/ProjectSS_PRD.md
게임 규칙 수치를 이 파일에 복사하지 말 것. 항상 원본 문서를 참조한다.
---
## 현재 Phase 상태
```
Phase 0 [x] 환경 세팅 & 아키텍처
Phase 1 [x] 코어 루프 프로토타입
Phase 2 [x] 자원 경제 & 덱빌딩
Phase 3 [x] 정치 시스템 & 스토리 페이징
Phase 4 [x] 멀티 엔딩 & 연대기 ← 그레이박스 테스트 시작 가능
Phase 5 [ ] 비주얼 폴리싱 & UX ← 그레이박스 테스트 완료 후에만 착수
```
`[x]` 완료 · `[~]` 부분 완료 · `[ ]` 미착수
**Phase 2 완료 시 남은 주의사항:** 오염 카드(신화/조직저항/사회공황/이사회압박) 콘텐츠는
등급별 1종씩만 채운 초안이다. 자세한 내용은 `ROADMAP.md` Phase 2 참고.
**Phase 3 완료 시 남은 주의사항:** 상임 이사회 8명의 투표는 아직 `성향 태그` 시스템이 없어
위험 수용 계수 기반 확률 처리로 남아있다. 자세한 내용은 `ROADMAP.md` Phase 3 참고.
**Phase 4 완료 시 남은 주의사항:** 엔딩 도감 localStorage 키를 `ss_endings_v2`로 교체했다
(기존 `ss_endings`는 제목 문자열 기반이라 취약했음). 이전 플레이테스트 기록은 새 키와
호환되지 않는다. 내러티브 태그는 재사용 가능한 태그 풀(`narrative_tags.json`, 10종) 방식으로,
선별된 카드의 좌/우 선택지에서만 얻는다. 페이즈 전환 문턱은 "고유 태그 3종/6종 획득". 자세한
내용은 `ROADMAP.md` Phase 4 참고.
**2026-07-13 내러티브 태그 재설계 (작업지시서 반영):** `docs/내러티브태그_시스템_작업지시서.md` 기준으로
① 연대기(Chronicle, 결재 결정 이력)와 내러티브 태그(세계의 상흔, 결과)를 개념적으로 분리 —
기획서/스토리 바이블/와이어프레임/비전/PRD/유저플로우/개발순서 문서 반영 완료. ② `narrative_tags.json`
10종에 `snippet`(1~2문장 결과 보고서, 거리감 있는 관료체) · `snippet_type`("system" 7종 / "human" 3종)
필드 추가 — 초안이므로 디자이너 검토 필요.
**남은 작업:** 현재 코드(`ChroniclePopup.jsx`, `GameScreen.jsx`)는 아직 태그 히스토리와 연대기(카드·선택 이력)를
하나의 팝업에서 함께 보여준다. 문서가 요구하는 "세계의 상흔 팝업 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를
독립적으로 재구현해 대형 충돌이 났던 적이 있다. 자세한 경위는 `ROADMAP.md` 상단 박스 참고.
Phase 0~4 그레이박스 구간이 모두 완료됐다. 다음은 외부 플레이어 그레이박스 테스트이며,
Phase 5(비주얼 폴리싱)는 그 피드백 반영 후에만 착수한다.
로컬에 Node.js LTS가 설치되어 `npm run dev`로 실기 검증이 가능하다
(Playwright로 실제 플레이까지 확인 완료).
---
## 작업 지시 방법
```
/goal Phase 5 진행해줘 # 그레이박스 테스트 완료 후에만
```
작업 완료 후 이 파일의 **현재 Phase 상태** 블록을 업데이트한다.

278
ROADMAP.md Normal file
View File

@@ -0,0 +1,278 @@
# Project SS — ROADMAP.md
> **용도:** AI 에이전트에게 `/goal Phase N 진행해줘` 명령 시 컨텍스트로 주입하는 개발 로드맵 문서.
> **규칙:** 각 Phase는 독립적으로 실행 가능하며, `완료 기준(Done Criteria)`을 모두 충족해야 다음 Phase로 진행한다.
---
## 개발 전략
**그레이박스 우선 전략 (Greybox First)**
이 프로젝트는 그래픽보다 게임플레이 검증을 먼저 완성하는 전략을 채택한다.
게임은 기획 단계에서 재미있어 보여도 실제 플레이 시 재미없는 경우가 많기 때문에,
시스템이 실제로 재미있는지를 먼저 확인하고, 그래픽은 마지막에 올린다.
```
[Phase 0~4] 그레이박스 구간 — 로직과 재미 검증이 목표. 비주얼은 최소한으로.
[그레이박스 테스트] 외부 플레이어 피드백 수집 및 반복 수정
[Phase 5] 검증된 게임 위에 비주얼 폴리싱 적용
[출시]
```
**Phase 5는 Phase 0~4의 재미 검증이 완료된 후에만 진행한다.**
비주얼 작업을 먼저 시작하지 않는다.
---
## 현재 상태 (Current Status)
```
Phase 0 [x] 환경 세팅 & 아키텍처
Phase 1 [x] 코어 루프 프로토타입
Phase 2 [x] 자원 경제 & 덱빌딩
Phase 3 [x] 정치 시스템 & 스토리 페이징
Phase 4 [x] 멀티 엔딩 & 연대기
Phase 5 [ ] 비주얼 폴리싱 & UX ← 그레이박스 테스트 완료 후에만 착수
```
`[x]` 완료 · `[~]` 부분 완료(완료 기준 미충족 항목 있음) · `[ ]` 미착수
---
## ⚠️ 2026-07-09: 병렬 작업 브랜치 병합 (중요 — 반드시 읽을 것)
이 로컬 작업 폴더는 2026-06-24~25에 **다른 세션에서 origin/main에 푸시된 4개 커밋**을
받아오지 못한 채로 Phase 2~4 작업을 진행했다. 그 커밋들에는 이 로컬에서 독립적으로
다시 구현한 것과 겹치는 기능(파산 문책, 내러티브 태그, 오염 카드, 자산)이 서로 다른
방식으로 이미 존재했고, `git pull` 시점에 6개 파일에서 충돌이 나서 origin 쪽 구현을
기준으로 병합했다. 원인·과정·잔여 이슈는 아래 각 Phase 절의 "병합 참고" 박스에 정리했다.
**이 폴더가 유일한 작업 사본이 아니라면, 다음 세션을 시작하기 전에 반드시 `git fetch` +
`git log origin/main..HEAD` / `git log HEAD..origin/main`으로 분기 여부를 먼저 확인할 것.**
---
## 다음 단계
**Phase 0~4 그레이박스 작업이 모두 완료됐다.** 이제 외부 플레이어 그레이박스 테스트를 진행할 차례다.
Phase 5(비주얼 폴리싱)는 그 피드백을 반영한 뒤에만 착수한다.
```
/goal Phase 5 진행해줘 # 그레이박스 테스트 완료 후에만
```
---
## Phase 0 — 프로젝트 환경 세팅 & 아키텍처 설계
**상태: `[x] 완료`**
**목표:** 본격적인 개발 전 코드 환경, 버전 관리, 데이터 파이프라인을 확립한다.
### 완료된 항목
- [x] VS Code + Claude Code 개발 환경
- [x] Gitea 리포지토리 + CI/CD 파이프라인
- [x] React 19 + Vite 8 프로젝트 (그레이박스 전략에 따라 선택)
- [x] CSV/JSON 데이터 파서 (papaparse 기반)
---
## Phase 1 — 코어 루프 프로토타입
**상태: `[x] 완료`**
**목표:** 스와이프 조작과 4대 파라미터 증감이 동작하는지 검증한다.
### 완료된 항목
- [x] 카드 스와이프 시스템 (좌/우/위)
- [x] 4대 핵심 파라미터 (내부 저항 / 엔트로피 / 대중 공황 / 위험 수용 계수)
- [x] 라운드 카운터 (결재 N건 → 의회 진입)
- [x] 파라미터 5단계 도달 시 게임 오버 분기
---
## Phase 2 — 자원 경제 & 로그라이크 덱빌딩
**상태: `[x] 완료`**
**목표:** 토큰 수급/소모, 덱 오염, 덱 압축(자산)의 전체 경제 루프를 완성한다.
### 완료된 항목
- [x] 토큰 시스템 (예산 / 인력 / 정보)
- [x] 영구 자원 태그 (재력 / 무력 / 감시)
- [x] 딜레마 카드 6종 분기 로직
- [x] 무제한 인벤토리 UI
- [x] 자산 기초 로직 (랜덤 획득)
- [x] 파이프라인 연쇄 반응 기초
- [x] **파산 문책 시스템** — 마이너스 결재 시 파산 카운터 증가(`GameScreen.jsx` `handleSwipe`), 의회에서 경고 배너 → 미개선 시 특별 청문회로 게임 오버(`CouncilScreen.jsx`)
- [x] **오염 카드 등급 확률** — 파라미터 단계(1~5)별 S/A/B/C 가중치 테이블로 의회 종료 시 오염 카드 주입(`GameScreen.jsx` `GRADE_WEIGHTS`, `injectContaminationCards`)
- [x] **자산 효과 4종** — 성질 변환 / 시한부 격리 / 물리적 영구 소각 / 결재권 강제 행사, 자산 슬롯 클릭으로 발동(`GameScreen.jsx` `activateAsset`)
### 완료 기준 (Done Criteria)
- [x] 토큰이 마이너스인 상태로 결재하면 파산 카운터가 증가한다.
- [x] 의회 단계에서 파산 카운터가 문책 로직에 실제로 반영된다.
- [x] 경고를 받은 뒤 다음 의회까지 마이너스를 회복하지 못하면 게임 오버된다.
- [x] 오염 카드 등급이 파라미터 단계에 비례하여 가중치대로 뽑힌다.
- [x] 자산 효과 4종이 각각 독립적으로 동작한다.
> **참고:** 오염 카드 콘텐츠(`cards.csv`의 신화/조직저항/사회공황/이사회압박 16종)는 등급별 1종씩만 채운 **초안**이다.
> 밸런스·문체 검토 후 디자이너가 등급별로 추가 카드를 늘리는 작업이 별도로 필요하다.
> 또한 A급 이상 신화 카드가 '뉴스 이벤트' 카드를 추가하는 연쇄 규칙(기획서 92-95행)은 이번 범위에 포함하지 않았다.
> **병합 참고 (2026-07-09):** origin/main에 미리 푸시되어 있던 다른 세션의 파산 문책 구현(정례회의
> 라운드가 늘수록 결재 건수가 8→6→4→2→0으로 줄어들고, 파산 카운터 5회 이상이면 의회에서 전 파라미터를
> 강제로 올리는 에스컬레이션 포함)이 더 완성도 높고 실제 사용자 피드백을 직접 반영한 것이었다. 그 버전을
> 기준으로 병합하면서 상태명을 `bankruptcyCounter/bankruptcyWarned` → `bankruptcyCount/bankruptcyWarning`으로
> 통일했다. 자산은 origin에 `assets.json`(이름 있는 개별 아이템 4종: 전임 국장의 만년필 등)이 준비돼 있었지만
> 실제 효과 로직이 연결돼 있지 않아서, 이 로컬에서 만든 "성질 변환/시한부 격리/물리적 영구 소각/결재권 강제
> 행사" 4종 기능 로직을 그대로 유지했다 — `assets.json`의 이름 있는 아이템 플레이버는 아직 미사용 상태다.
### 핵심 참조 문서
- `시스템 기획서` — 결재 단계, 덱 오염, 덱 압축, 자산 슬롯
- `PRD` — §3.2 결재 시스템, §3.3 자산 및 덱 압축 시스템
---
## Phase 3 — 정치 시스템 & 스토리 페이징 통합
**상태: `[x] 완료`**
**목표:** 그림자 위원회 투표와 내러티브 태그 기반 페이즈 전환을 완성한다.
### 완료된 항목
- [x] 그림자 위원회 13명 (상임 8 + 지부장 5 무작위)
- [x] 의회 투표 / 탄핵 / 법안 분기 기초
- [x] 국장 개입 액션 3종 (매수 / 장부 조작 / 내부 감사)
- [x] 내러티브 태그 기반 페이징 기초
- [x] 연대기(Chronicle) 팝업
- [x] **파벌 우호도 세부 조건** — 12개 글로벌 지부장 파벌이 각자 다른 공식으로 우호도를 계산한다(`GameScreen.jsx` `GLOBAL_FACTIONS`). 게임 시작 시 12개 중 5개를 무작위 선발(`factionRoster`), 매 의회마다 실시간 상태(토큰/태그/파라미터/자산/카운터)로 점수를 산출해 동맹/우호/중립/적대/파국 5단계 티어로 환산, 투표와 국장 개입 면역 여부를 결정한다(`CouncilScreen.jsx`).
- [x] **파산 카운터 → 의회 문책 연동** — Phase 2 작업으로 완료됨 (`CouncilScreen.jsx``bankruptcyCounter`/`bankruptcyWarned`를 직접 참조)
- [x] **스토리 페이징 카드 풀 전환** — 초기 덱은 일상/위기만 포함되고, 고유 내러티브 태그 3종 도달 시 작전/극비 프로젝트 카드 풀이 실시간으로 덱에 개방된다(`GameScreen.jsx` `phase2Pool`/`phase2Unlocked`).
### 완료 기준 (Done Criteria)
- [x] 글로벌 지부장 12개 파벌의 우호도가 각자 다른 조건으로 개별 계산된다.
- [x] 파산 카운터가 의회 문책 로직에 연동된다.
- [x] 내러티브 태그 누적 시 등장 카드 풀이 실시간으로 전환된다.
> **참고 및 남은 과제:**
> - 상임 이사회 8명의 투표는 여전히 `params.risk` 기반 확률 처리다. 기획서상 이들은 국장의 '성향 태그'에 반응해야 하는데, 이 게임에는 아직 성향 태그 시스템 자체가 없어 범위 밖으로 남겨뒀다(§4.1 대응).
> - Playwright로 실제 브라우저에서 플레이 검증 중 **`params.risk >= 5`가 직접 게임오버를 발동시키지만 `EndingScreen`에 해당 분기가 없어 "미확인 종말" 폴백으로 빠지는 버그**를 발견해 수정했다(PRD상 risk는 탄핵 기준치일 뿐 직접 붕괴 조건이 아님).
> **병합 참고 (2026-07-09):** 내러티브 태그 시스템 자체를 origin 버전(재사용 가능한 태그 풀 방식, 아래 참고)으로
> 교체하면서 페이징 문턱도 "고유 태그 4개/8개" → "고유 태그 3개/6개"로 재조정했다(풀 크기 10종 기준).
### 핵심 참조 문서
- `스토리 바이블` — §4.2 글로벌 지부장 12개 파벌 상세
- `시스템 기획서` — 주간 의회 단계, 토큰 경제
- `유저플로우` — §3 주간 의회 단계 플로우
---
## Phase 4 — 멀티 엔딩 & 연대기 (콘텐츠 완성)
**상태: `[x] 완료`**
**목표:** 도감 수집이라는 게임의 최종 목표를 완성한다.
이 Phase 완료 후 그레이박스 테스트를 시작한다.
### 완료된 항목
- [x] **엔딩 판별기 (우선순위 순)**`client/src/data/endings.js``resolveEnding()`이 트리거(탄핵/붕괴)별로 아래 순서를 그대로 구현한다.
| 우선순위 | 엔딩 유형 | 트리거 조건 |
|---|---|---|
| 1 | 의회 정치 엔딩 | 탄핵 가결 + 의회 의장 성향 축(무력/재력/감시 중 게임 시작 시 무작위 선정) 태그 5 이상 |
| 2 | 내러티브 연쇄 엔딩 | 신화→조직저항→사회공황→이사회압박 오염 카드가 이 순서로 등장 |
| 3 | 변주된 붕괴 엔딩 | 기본 붕괴 조건 + 특정 자원 태그(3종)/토큰(3종) 보유 |
| 4 | 기본 붕괴 엔딩 | 파라미터 5단계 도달 or 탄핵 가결 (그 외 조건 미충족 시 폴백) |
- [x] **변주된 붕괴 엔딩** — 자원 태그 변주 3종(쿠데타/은폐된 멸망/자본의 폭주) + 토큰 변주 3종(야반도주/정보 암흑/긴축의 폭동)
- [x] **극단적 파라미터 조기 엔딩** — 단일 결재에서 위험 파라미터가 3단계 이상 급증하며 5단계에 도달하면 "돌이킬 수 없는 순간" 즉각 붕괴 엔딩 발동
- [x] **내러티브 연쇄 엔딩** — 오염 카드 4종을 지정 순서로 수집하면 해금, 이후 탄핵/붕괴 시 최우선 적용
- [x] **임기 만료 엔딩** (병합으로 추가) — 라운드가 거듭될수록 정례회의까지의 결재 건수가 8→6→4→2→0으로 줄어들다 0이 되면 발동
- [x] **도감 UI**`EndingCodex.jsx` 신규 컴포넌트. 엔딩 화면에서 "📖 엔딩 도감 보기"로 열람, 해금 16종 중 몇 종 확인했는지 표시, 미해금은 "???"로 잠금 처리, `localStorage`(`ss_endings_v2`)에 엔딩 id 기준으로 영구 저장
- [x] **연대기 팝업 완성** — 턴별 타임라인 + 새로 획득한 내러티브 태그를 아이콘/설명과 함께 인라인으로 강조 표시, 상단에 수집한 태그 전체를 칩으로 요약(`ChroniclePopup.jsx/css`)
### 완료 기준 (Done Criteria)
- [x] 파라미터 5단계 도달 시 우선순위 판별기가 정확한 엔딩을 출력한다. (Playwright로 "군사 쿠데타" 변주 엔딩과 "정치적 탄핵" 기본 엔딩을 각각 실제로 트리거해 확인)
- [x] 엔딩 화면에 이름 / 서사 텍스트 / 원인 요약이 표시된다.
- [x] 해금 엔딩이 도감에 기록되고 재실행 후에도 유지된다. (localStorage 기반, id로 dedup)
- [x] 연대기 팝업에서 이번 회차 내러티브 히스토리를 확인할 수 있다.
> **참고:** 엔딩 도감 localStorage 키를 `ss_endings`(제목 문자열 기반, 취약)에서 `ss_endings_v2`(고유 id 기반)로 교체했다.
> 이전에 수동 플레이테스트로 쌓인 `ss_endings` 데이터는 새 키와 호환되지 않아 브라우저에서 도감이 0/16으로 보일 수 있다 —
> 프로토타입 단계라 마이그레이션 로직은 만들지 않았다.
> **병합 참고 (2026-07-09) — 내러티브 태그 시스템 최종본:** 이 Phase에서 한 번(카드당 자유 텍스트 키워드
> `left_narrative`/`right_narrative`) 재설계했었는데, 이후 origin/main에 병합할 다른 세션의 구현을 발견했다.
> 그 버전은 `narrative_tags.json`(재사용 가능한 태그 10종 — 아이콘+이름+설명, 예: "쿠데타의 그림자" 🗡️)과
> `cards.csv`의 `left_tag`/`right_tag` 컬럼(태그 id 참조) 조합으로, 처음 획득하는 태그일 때만 토스트 알림이
> 뜨고 진행도에 반영되는 방식이었다 — 사용자 피드백을 더 직접 반영한 더 성숙한 설계라 최종적으로 이 방식을
> 채택하고 자유 텍스트 방식은 폐기했다. 다만 origin의 `cards.csv`에는 태그 컬럼 자체가 비어 있어 실제로는
> 작동하지 않는 상태였으므로, `update_csv_tags.js`의 기존 11개 매핑을 참고해 새로 채워 넣고 태그 풀도
> 4종(쿠데타의 그림자/군중의 광기/정치적 압박/희생의 대가) 추가했다. 페이징 문턱도 "태그 풀 10종 중 고유
> 3종/6종 획득"으로 재조정했다.
> **이 Phase 완료 = 그레이박스 테스트 준비 완료.**
> Phase 5는 테스트 피드백 반영이 끝난 후 착수한다.
### 핵심 참조 문서
- `스토리 바이블` — §5 멀티 엔딩 매트릭스
- `유저플로우` — §4 엔딩 분기 및 도감 플로우
- `텍스트 와이어프레임` — §3.2 엔딩 결산 화면
---
## Phase 5 — 비주얼 폴리싱 & 다이제틱 UX
**상태: `[ ] 미착수`**
**착수 조건: 그레이박스 테스트 피드백 반영 완료 후에만 시작한다.**
**목표:** 재미가 검증된 게임 위에 몰입감 있는 시각·청각·촉각 피드백을 입힌다.
### 작업 항목
- [ ] **시각적 오염도 연출** — 오염 카드 테두리에 검은 얼룩·불탄 자국 CSS 효과
- [ ] **앰비언트 페이즈 비네팅**
- 페이즈 1: 없음 (`opacity 0.0`)
- 페이즈 2: 가장자리 어두운 테두리 (`opacity 0.4`)
- 페이즈 3: 붉게 점멸 (`opacity 0.8`)
- [ ] **스와이프 마찰력 피드백** — 오염도·위험 단계에 따라 카드 저항값 증가 (`0.8``1.5`)
- [ ] **햅틱 진동 4종**
| 종류 | 강도 | 지속 | 발동 조건 |
|---|---|---|---|
| `swipe-tick` | Light | 10ms | 일상 딜레마 스와이프 |
| `stamp-success` | Medium | 30ms | 자원 태그/자산 획득 |
| `crisis-hit` | Heavy | 50ms | 위기 강타 파라미터 상승 |
| `chain-reaction` | Light×3 | 10ms×3 | 파이프라인 연쇄 발동 |
- [ ] **오디오 앰비언트** — 페이즈 전환에 따른 배경 사운드 변화
- [ ] **파이프라인 연쇄 시각 이펙트** — 자산 아이콘 사이 발광 선 애니메이션
### 완료 기준 (Done Criteria)
- 페이즈 3 진입 시 화면 테두리가 붉게 점멸한다.
- 위기 강타 스와이프 시 Heavy 햅틱이 발동한다 (모바일 실기 확인).
- 파이프라인 연쇄 발동 시 발광 선 이펙트와 연속 진동이 함께 재생된다.
### 핵심 참조 문서
- `디자인 토큰` — §4 인터랙션 & 모션 토큰, §5 햅틱 피드백 토큰
- `컴포넌트 인벤토리``VFX_SimpleChain`, `Card_SwipeFull`
---
## 에이전트 사용법
```
# 감사 먼저 (현재 필수)
/goal 감사해줘 — Phase 2~3 미완 항목 실제 구현 여부 확인하고 보고해줘
# Phase 진행
/goal Phase 2 미완 항목 마무리해줘
/goal Phase 3 미완 항목 마무리해줘
/goal Phase 4 진행해줘
/goal Phase 5 진행해줘 ← 그레이박스 테스트 완료 후에만 사용
```
작업 완료 후 `[ ]``[x]`로 업데이트하고 **현재 상태** 블록을 갱신한다.

View File

@@ -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;
}

View File

@@ -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 (
<div className="asset-popup-overlay" onClick={onClose}>
<div className="asset-popup" onClick={e => e.stopPropagation()}>
<div className="asset-popup-header">
<span className={`asset-popup-grade grade-${def.grade}`}>{def.grade}</span>
<span className="asset-popup-archetype">{meta.icon} {meta.label}</span>
<button className="close-btn" onClick={onClose}>&times;</button>
</div>
<h3 className="asset-popup-name">{def.name}</h3>
<p className="asset-popup-desc">{def.description}</p>
{def.growth && (
<div className="asset-popup-stat">
현재 숙련 스택: <strong>{instance.stack || 0}</strong> ( {def.growth.per}회마다 추가 발동)
</div>
)}
{isManualCharge && (
<div className="asset-popup-stat">
{ready ? '충전 완료 — 발동 가능' : `충전 대기 중 (${def.trigger.params.chargeEvery}건마다 충전)`}
</div>
)}
{ready && (
<button className="asset-popup-activate" onClick={onActivate}>발동</button>
)}
<p className="asset-popup-flavor">{def.flavor}</p>
</div>
</div>
);
}

View File

@@ -57,6 +57,43 @@
line-height: 1; line-height: 1;
} }
.keyword-section {
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.02);
}
.keyword-label {
display: block;
font-size: 11px;
color: #888;
font-weight: bold;
margin-bottom: 8px;
letter-spacing: 0.5px;
}
.keyword-empty {
font-size: 12px;
color: #666;
font-style: italic;
}
.keyword-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.keyword-chip {
font-size: 12px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
color: var(--color-token-info);
white-space: nowrap;
}
.chronicle-content { .chronicle-content {
padding: 24px; padding: 24px;
overflow-y: auto; overflow-y: auto;

View File

@@ -1,17 +1,31 @@
import React from 'react'; import React from 'react';
import './ChroniclePopup.css'; import './ChroniclePopup.css';
export default function ChroniclePopup({ chronicle, acquiredTags, onClose }) { export default function ChroniclePopup({ chronicle, acquiredTags = [], onClose }) {
return ( return (
<div className="chronicle-overlay" onClick={onClose}> <div className="chronicle-overlay" onClick={onClose}>
<div className="chronicle-popup" onClick={e => e.stopPropagation()}> <div className="chronicle-popup" onClick={e => e.stopPropagation()}>
<div className="chronicle-header"> <div className="chronicle-header">
<h2>국장의 연대기</h2> <h2>국장의 연대기</h2>
<div className="chronicle-tags-summary"> <div className="chronicle-tags-summary">
획득한 내러티브: {acquiredTags?.length || 0} 획득한 내러티브: {acquiredTags.length}
</div> </div>
<button className="close-btn" onClick={onClose}>&times;</button> <button className="close-btn" onClick={onClose}>&times;</button>
</div> </div>
<div className="keyword-section">
<span className="keyword-label">수집한 키워드</span>
{acquiredTags.length === 0 ? (
<span className="keyword-empty">아직 수집한 키워드가 없습니다.</span>
) : (
<div className="keyword-chips">
{acquiredTags.map((t) => (
<span key={t.id} className="keyword-chip" title={t.description}>{t.icon} {t.name}</span>
))}
</div>
)}
</div>
<div className="chronicle-content"> <div className="chronicle-content">
{chronicle.length === 0 ? ( {chronicle.length === 0 ? (
<p className="empty-text">아직 기록된 역사가 없습니다.</p> <p className="empty-text">아직 기록된 역사가 없습니다.</p>
@@ -23,10 +37,10 @@ export default function ChroniclePopup({ chronicle, acquiredTags, onClose }) {
<div className="entry-body"> <div className="entry-body">
<div className="entry-context"> <div className="entry-context">
<span className="entry-type">[{entry.cardType}]</span> <span className="entry-type">[{entry.cardType}]</span>
<span className="entry-keyword">{entry.keyword}</span> {entry.keyword && <span className="entry-keyword">{entry.keyword}</span>}
</div> </div>
<div className="entry-text">{entry.text}</div> <div className="entry-text">{entry.text}</div>
<div className="entry-decision">👉 결정: {entry.decision}</div> {entry.decision && <div className="entry-decision">👉 결정: {entry.decision}</div>}
{entry.tag && ( {entry.tag && (
<div className="entry-tag-highlight"> <div className="entry-tag-highlight">

View File

@@ -148,3 +148,20 @@
.council-result h3 { .council-result h3 {
margin-bottom: 20px; margin-bottom: 20px;
} }
.bankruptcy-warning-box {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 20px;
}
.penalty-text {
color: var(--color-risk-critical);
font-family: var(--font-ui);
font-size: 16px;
font-weight: bold;
line-height: 1.6;
}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import './CouncilScreen.css'; import './CouncilScreen.css';
export default function CouncilScreen({ params, setParams, tokens, setTokens, tags, setTags, bankruptcyCount, setBankruptcyCount, bankruptcyWarning, onResolve }) { export default function CouncilScreen({ params, setParams, tokens, setTokens, tags, setTags, bankruptcyCount, bankruptcyWarning, globalFactions = [], onBribe, onResolve }) {
const [members, setMembers] = useState([]); const [members, setMembers] = useState([]);
const [agenda, setAgenda] = useState(''); const [agenda, setAgenda] = useState('');
const [impeachment, setImpeachment] = useState(false); const [impeachment, setImpeachment] = useState(false);
@@ -19,7 +19,7 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
setAgenda('재무 건전성 악화 및 횡령 배임 혐의'); setAgenda('재무 건전성 악화 및 횡령 배임 혐의');
setResult('당신은 파산했고 자금 횡령으로 체포되었습니다. 감옥의 식사는 다행히 무료입니다.'); setResult('당신은 파산했고 자금 횡령으로 체포되었습니다. 감옥의 식사는 다행히 무료입니다.');
return; return;
} else { }
setIsBankruptcyWarning(true); setIsBankruptcyWarning(true);
setAgenda('파산 문책: 재무 경고'); setAgenda('파산 문책: 재무 경고');
if (bankruptcyCount >= 5) { if (bankruptcyCount >= 5) {
@@ -28,25 +28,29 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
resistance: Math.min(5, prev.resistance + 1), resistance: Math.min(5, prev.resistance + 1),
entropy: Math.min(5, prev.entropy + 1), entropy: Math.min(5, prev.entropy + 1),
panic: Math.min(5, prev.panic + 1), panic: Math.min(5, prev.panic + 1),
risk: Math.min(5, prev.risk + 1) risk: Math.min(5, prev.risk + 1),
})); }));
} else { } else {
setBankruptPenalty(`파산 카운터 ${bankruptcyCount}회: 의회에서 강력한 경고가 나왔습니다. 다음 의회까지 복구하지 못하면 해임됩니다.`); setBankruptPenalty(`파산 카운터 ${bankruptcyCount}회: 의회에서 강력한 경고가 나왔습니다. 다음 의회까지 복구하지 못하면 해임됩니다.`);
} }
return;
} }
} else {
// Normal Agenda // 상임 이사회 8명: 위험 수용 계수 기반 무작위 (성향 태그 세부 연동은 Phase 3 범위 밖)
const newMembers = Array.from({ length: 13 }).map((_, i) => { const boardMembers = Array.from({ length: 8 }).map((_, i) => {
const type = i < 8 ? 'board' : 'global';
const rand = Math.random() * 100; const rand = Math.random() * 100;
let vote = '미정'; let vote = '미정';
if (rand < params.risk * 20) { if (rand < params.risk * 20) vote = '반대';
vote = '반대'; else if (rand > 50) vote = '찬성';
} else if (rand > 50) { return { id: i, type: 'board', vote, immune: false, label: '상임 이사회' };
vote = '찬성';
}
return { id: i, type, vote };
}); });
// 글로벌 지부장 5명: 파벌별 개별 우호도 계산 결과를 그대로 투표에 반영
const globalMembers = globalFactions.map((f, i) => ({
id: 8 + i, type: 'global', vote: f.vote, immune: f.immune, label: `${f.name} (${f.tier})`,
}));
const newMembers = [...boardMembers, ...globalMembers];
setMembers(newMembers); setMembers(newMembers);
const againstCount = newMembers.filter(m => m.vote === '반대').length; const againstCount = newMembers.filter(m => m.vote === '반대').length;
@@ -57,8 +61,10 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
const randAgenda = Math.random() > 0.5 ? '이득 규약 법안 상정' : '표적 규약 법안 상정'; const randAgenda = Math.random() > 0.5 ? '이득 규약 법안 상정' : '표적 규약 법안 상정';
setAgenda(randAgenda); setAgenda(randAgenda);
} }
} // 의회는 마운트 시 1회만 초기화한다 (deps에 tokens/params.risk를 넣으면 매수 액션 등으로
}, [params.risk, tokens, bankruptcyWarning, bankruptcyCount]); // 값이 바뀔 때마다 재실행되어 방금 강제 전환한 투표 결과를 덮어써버리는 버그가 있었다)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleAction = (type) => { const handleAction = (type) => {
if (actionTaken) return; if (actionTaken) return;
@@ -67,10 +73,11 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
if (type === 'bribe' && tokens.budget >= 30) { if (type === 'bribe' && tokens.budget >= 30) {
setTokens(prev => ({ ...prev, budget: prev.budget - 30 })); setTokens(prev => ({ ...prev, budget: prev.budget - 30 }));
setParams(prev => ({ ...prev, risk: Math.min(5, prev.risk + 1) })); setParams(prev => ({ ...prev, risk: Math.min(5, prev.risk + 1) }));
// Force 2 against to support onBribe && onBribe();
// 동맹/파국 성향 임원은 국장의 개입이 통하지 않는다
let changed = 0; let changed = 0;
for (let m of newMembers) { for (let m of newMembers) {
if (m.vote === '반대' && changed < 2) { if (m.vote === '반대' && !m.immune && changed < 2) {
m.vote = '찬성'; m.vote = '찬성';
changed++; changed++;
} }
@@ -85,10 +92,10 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
} else if (type === 'audit' && tokens.personnel >= 30) { } else if (type === 'audit' && tokens.personnel >= 30) {
setTokens(prev => ({ ...prev, personnel: prev.personnel - 30 })); setTokens(prev => ({ ...prev, personnel: prev.personnel - 30 }));
setParams(prev => ({ ...prev, resistance: Math.min(5, prev.resistance + 1) })); setParams(prev => ({ ...prev, resistance: Math.min(5, prev.resistance + 1) }));
// Force 3 against to abstain // 동맹/파국 성향 임원은 국장의 개입이 통하지 않는다
let changed = 0; let changed = 0;
for (let m of newMembers) { for (let m of newMembers) {
if (m.vote === '반대' && changed < 3) { if (m.vote === '반대' && !m.immune && changed < 3) {
m.vote = '미정'; m.vote = '미정';
changed++; changed++;
} }
@@ -112,7 +119,6 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
} }
if (isBankruptcyWarning) { if (isBankruptcyWarning) {
// Acknowledged warning
onResolve({ impeachment: false, bankruptcyWarning: true }); onResolve({ impeachment: false, bankruptcyWarning: true });
return; return;
} }
@@ -122,7 +128,7 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
if (againstCount >= 9) { if (againstCount >= 9) {
onResolve({ impeachment: true }); onResolve({ impeachment: true });
} else { } else {
onResolve({ impeachment: false }); onResolve({ impeachment: false, bankruptcyWarning: false });
} }
} else { } else {
const supportCount = members.filter(m => m.vote === '찬성').length; const supportCount = members.filter(m => m.vote === '찬성').length;
@@ -133,7 +139,7 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
setTags(prev => ({ ...prev, wealth: Math.max(0, prev.wealth - 1) })); setTags(prev => ({ ...prev, wealth: Math.max(0, prev.wealth - 1) }));
} }
} }
// If tokens are positive, clear bankruptcy warning // 토큰이 정상 범위로 돌아왔다면 파산 경고를 해제한다
onResolve({ impeachment: false, bankruptcyWarning: false }); onResolve({ impeachment: false, bankruptcyWarning: false });
} }
}; };
@@ -158,7 +164,7 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
) : ( ) : (
<div className="members-grid"> <div className="members-grid">
{members.map(m => ( {members.map(m => (
<div key={m.id} className={`member-icon ${m.type}`}> <div key={m.id} className={`member-icon ${m.type}`} title={m.label}>
<span className="member-avatar">👤</span> <span className="member-avatar">👤</span>
<span className="member-badge"> <span className="member-badge">
{m.vote === '찬성' ? '✅' : m.vote === '반대' ? '❌' : '❓'} {m.vote === '찬성' ? '✅' : m.vote === '반대' ? '❌' : '❓'}

View File

@@ -0,0 +1,83 @@
.codex-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;
}
.codex-panel {
background: var(--color-surface);
width: 90%;
max-width: 400px;
max-height: 80%;
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8);
}
.codex-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
background: rgba(0, 0, 0, 0.5);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.codex-header h2 {
margin: 0;
font-family: var(--font-doc-title);
font-size: 18px;
}
.codex-content {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.codex-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.codex-entry {
padding: 12px 14px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
.codex-entry.unlocked {
border-color: rgba(255, 255, 255, 0.2);
}
.codex-entry.locked {
opacity: 0.5;
}
.codex-title {
font-family: var(--font-doc-title);
font-size: 14px;
margin-bottom: 4px;
}
.codex-entry.unlocked .codex-title {
color: var(--color-risk-critical);
}
.codex-desc {
font-size: 12px;
line-height: 1.5;
color: #bbb;
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { ENDING_CATALOG } from '../data/endings';
import './EndingCodex.css';
export const ENDINGS_STORAGE_KEY = 'ss_endings_v2';
export default function EndingCodex({ onClose }) {
const unlocked = JSON.parse(localStorage.getItem(ENDINGS_STORAGE_KEY) || '[]');
return (
<div className="codex-overlay" onClick={onClose}>
<div className="codex-panel" onClick={(e) => e.stopPropagation()}>
<div className="codex-header">
<h2>엔딩 도감 ({unlocked.length}/{ENDING_CATALOG.length})</h2>
<button className="close-btn" onClick={onClose}>&times;</button>
</div>
<div className="codex-content">
<div className="codex-grid">
{ENDING_CATALOG.map((e) => {
const isUnlocked = unlocked.includes(e.id);
return (
<div key={e.id} className={`codex-entry ${isUnlocked ? 'unlocked' : 'locked'}`}>
<div className="codex-title">{isUnlocked ? e.title : '??? (미확인)'}</div>
<div className="codex-desc">{isUnlocked ? e.reason : '아직 발견하지 못한 결말입니다.'}</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}

View File

@@ -42,3 +42,45 @@
font-size: 14px; font-size: 14px;
color: #aaa; color: #aaa;
} }
.ending-keywords {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
margin-top: 12px;
}
.keyword-chip {
font-size: 12px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
color: var(--color-token-info);
white-space: nowrap;
}
.ending-actions {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 280px;
}
.codex-btn {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: var(--color-text);
padding: 12px 24px;
font-family: var(--font-ui);
font-size: 14px;
cursor: pointer;
border-radius: 4px;
transition: all 0.3s ease;
}
.codex-btn:hover {
background: rgba(255, 255, 255, 0.08);
}

View File

@@ -1,56 +1,46 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import EndingCodex, { ENDINGS_STORAGE_KEY } from './EndingCodex';
import './EndingScreen.css'; import './EndingScreen.css';
export default function EndingScreen({ params, impeachment, narrativeTags, onRestart }) { export default function EndingScreen({ ending, narrativeTags, keywords = [], onRestart }) {
const [endingInfo, setEndingInfo] = useState({ title: '', desc: '', reason: '' }); const [showCodex, setShowCodex] = useState(false);
useEffect(() => { useEffect(() => {
// Determine ending if (!ending) return;
let title = '미확인 종말'; const collection = JSON.parse(localStorage.getItem(ENDINGS_STORAGE_KEY) || '[]');
let desc = '기록되지 않은 파멸입니다.'; if (!collection.includes(ending.id)) {
let reason = '알 수 없는 원인'; collection.push(ending.id);
localStorage.setItem(ENDINGS_STORAGE_KEY, JSON.stringify(collection));
if (impeachment) {
title = '정치적 탄핵 (윗선 붕괴)';
desc = '그림자 위원회가 당신의 권한을 박탈했습니다. 당신의 모든 업적은 지워질 것입니다.';
reason = '정기 의회에서 적대적 파벌 임원 9명 이상 찬성';
} else if (params.panic >= 5) {
title = '문명 붕괴 (사회 붕괴)';
desc = '대중은 광기에 휩싸였고, 문명은 스스로를 파괴했습니다. 통제 불능의 시대가 도래했습니다.';
reason = '대중 공황 파라미터 5단계 도달';
} else if (params.entropy >= 5) {
title = '세계 멸망 (외부 붕괴)';
desc = '장막이 찢어지고 이계의 존재들이 쏟아져 들어옵니다. 우리가 알던 세계는 끝났습니다.';
reason = '엔트로피 파라미터 5단계 도달';
} else if (params.resistance >= 5) {
title = '사임 (내부 붕괴)';
desc = '조직원들의 불만이 폭발하여 폭동이 일어났습니다. 당신은 자리에서 끌려 내려왔습니다.';
reason = '내부 저항 파라미터 5단계 도달';
} }
}, [ending]);
setEndingInfo({ title, desc, reason }); if (!ending) return null;
// Save to collection
const collection = JSON.parse(localStorage.getItem('ss_endings') || '[]');
if (!collection.includes(title)) {
collection.push(title);
localStorage.setItem('ss_endings', JSON.stringify(collection));
}
}, [params, impeachment]);
return ( return (
<div className="ending-screen"> <div className="ending-screen">
<h1 className="glitch" data-text={endingInfo.title}>{endingInfo.title}</h1> <h1 className="glitch" data-text={ending.title}>{ending.title}</h1>
<div className="ending-content"> <div className="ending-content">
<p className="ending-desc">{endingInfo.desc}</p> <p className="ending-desc">{ending.desc}</p>
<div className="ending-reason"> <div className="ending-reason">
<strong>발동 원인:</strong> {endingInfo.reason} <strong>발동 원인:</strong> {ending.reason}
</div> </div>
<div className="ending-stats"> <div className="ending-stats">
<span>획득한 내러티브 태그: {narrativeTags}</span> <span>획득한 내러티브 키워드: {narrativeTags}</span>
{keywords.length > 0 && (
<div className="ending-keywords">
{keywords.map((k, idx) => (
<span key={idx} className="keyword-chip">{k.word}</span>
))}
</div>
)}
</div> </div>
</div> </div>
<div className="ending-actions">
<button className="restart-btn" onClick={onRestart}>도감에 기록하기 & 다음 회차 시작</button> <button className="restart-btn" onClick={onRestart}>도감에 기록하기 & 다음 회차 시작</button>
<button className="codex-btn" onClick={() => setShowCodex(true)}>📖 엔딩 도감 보기</button>
</div>
{showCodex && <EndingCodex onClose={() => setShowCodex(false)} />}
</div> </div>
); );
} }

View File

@@ -52,6 +52,37 @@
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
} }
.round-info {
font-family: var(--font-typewriter);
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 { .parameters {
display: flex; display: flex;
gap: 15px; gap: 15px;
@@ -133,6 +164,15 @@
font-size: 16px; font-size: 16px;
} }
.token.negative {
color: var(--color-risk-critical);
}
.token.shield-badge {
font-size: 14px;
opacity: 0.85;
}
.tag { .tag {
font-size: 14px; font-size: 14px;
color: var(--color-tag-neutral); color: var(--color-tag-neutral);
@@ -155,6 +195,64 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0;
cursor: default;
}
.asset-slot.filled {
border-style: solid;
border-color: rgba(255, 255, 255, 0.35);
cursor: pointer;
transition: transform 0.15s ease, background 0.15s ease;
}
.asset-slot.filled:hover {
background: rgba(255, 255, 255, 0.08);
transform: scale(1.05);
}
.asset-slot:disabled {
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 */
@@ -200,11 +298,61 @@
background: var(--color-text); background: var(--color-text);
color: var(--color-bg); color: var(--color-bg);
} }
.tag-notification { /* 태그 획득 / 오염 유입 토스트는 같은 열에 쌓아 서로 겹치지 않게 한다 */
.notification-stack {
position: absolute; position: absolute;
top: 100px; top: 100px;
left: 50%; left: 50%;
transform: translateX(-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); background: rgba(0, 0, 0, 0.9);
border: 1px solid #fbbf24; border: 1px solid #fbbf24;
border-radius: 12px; border-radius: 12px;
@@ -213,8 +361,7 @@
align-items: center; align-items: center;
gap: 16px; gap: 16px;
box-shadow: 0 4px 20px rgba(251, 191, 36, 0.4); box-shadow: 0 4px 20px rgba(251, 191, 36, 0.4);
z-index: 100; animation: notiFadeOut 3s forwards;
animation: slideDownFadeOut 3s forwards;
} }
.tag-noti-icon { .tag-noti-icon {
@@ -234,9 +381,3 @@
font-size: 18px; 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); }
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import Papa from 'papaparse'; import Papa from 'papaparse';
import cardsCsvRaw from '../data/cards.csv?raw'; import cardsCsvRaw from '../data/cards.csv?raw';
import narrativeTagsData from '../data/narrative_tags.json'; import narrativeTagsData from '../data/narrative_tags.json';
@@ -7,160 +7,295 @@ import SwipeCard from './SwipeCard';
import CouncilScreen from './CouncilScreen'; import CouncilScreen from './CouncilScreen';
import EndingScreen from './EndingScreen'; import EndingScreen from './EndingScreen';
import ChroniclePopup from './ChroniclePopup'; 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'; import './GameScreen.css';
export function GameScreen() { // 오염 카드 주입 규칙(타입 매핑 · 등급 추첨 · 물량)은 engine/pollution.js에 모여 있다.
const [params, setParams] = useState({ resistance: 0, entropy: 0, panic: 0, risk: 0 }); // 스와이프 예고(SwipeCard)와 실제 주입이 같은 계산을 쓰게 하기 위해 여기서 다시 정의하지 말 것.
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 [phase, setPhase] = useState(1);
const [gameOver, setGameOver] = useState(false);
const [cards, setCards] = useState([]);
const [currentCardIndex, setCurrentCardIndex] = useState(0);
const [turns, setTurns] = useState(0); // 내러티브 태그는 narrative_tags.json 풀(10종)에서 "처음 획득하는 태그"만 진행도에 반영된다.
const [showCouncil, setShowCouncil] = useState(false); // 문턱은 풀 크기 대비 낮게 잡는다 (기존 4/8은 실제 플레이에서 길게 느껴진다는 피드백 반영).
const [impeachmentPassed, setImpeachmentPassed] = useState(false); const PHASE2_TAG_THRESHOLD = 3;
const [termExpired, setTermExpired] = useState(false); const PHASE3_TAG_THRESHOLD = 6;
const [chronicle, setChronicle] = useState([]);
const [showChronicle, setShowChronicle] = useState(false);
const [assets, setAssets] = useState([]);
const [roundCount, setRoundCount] = useState(1); // 글로벌 지부장 12개 파벌 풀 (게임 시작 시 5명 무작위 선발, 실리에 따라 우호도 개별 계산)
const [approvalsRemaining, setApprovalsRemaining] = useState(10); const GLOBAL_FACTIONS = [
const [bankruptcyCount, setBankruptcyCount] = useState(0); { id: 'military', name: '군산복합 연합', computeAffinity: (c) => c.tags.force * 15 + c.tokens.personnel * 0.4 - 40 },
const [bankruptcyWarning, setBankruptcyWarning] = useState(false); { id: 'blackmarket', name: '블랙마켓 카르텔', computeAffinity: (c) => c.tokens.budget * 0.4 + c.assetsCount * 20 - 40 },
const [bankrupted, setBankrupted] = useState(false); { id: 'databroker', name: '정보 브로커 연맹', computeAffinity: (c) => c.tokens.info * 0.4 + c.tags.surveillance * 15 - 40 },
const [newTagNotification, setNewTagNotification] = useState(null); { id: 'infra', name: '인프라 재건 위원회', computeAffinity: (c) => c.tags.wealth * 18 - 30 },
{ id: 'spindoctor', name: '여론 통제국', computeAffinity: (c) => c.tags.surveillance * 18 - 30 },
{ id: 'union', name: '현장 요원 노조', computeAffinity: (c) => (2 - c.params.resistance) * 30 },
{ id: 'risk', name: '리스크 관리반', computeAffinity: (c) => (2 - c.params.risk) * 30 },
{ id: 'occult', name: '오컬트 수집가 협회', computeAffinity: (c) => c.mythicCount * 35 + c.tagCount * 3 - 20 },
{ id: 'blackops', name: '극비 프로젝트 연구소', computeAffinity: (c) => c.assetsAcquiredCount * 25 - 30 },
{ id: 'scavenger', name: '잔해 청소부 길드', computeAffinity: (c) => c.purgeCount * 35 - 20 },
{ id: 'entropy', name: '엔트로피 관측소', computeAffinity: (c) => (2 - c.params.entropy) * 30 },
{ id: 'corrupt', name: '부패 관료 연합', computeAffinity: (c) => c.bribeCount * 40 - 10 },
];
// CSV Load Effect const getFactionTier = (score) => {
useEffect(() => { if (score >= 60) return '동맹';
const parsed = Papa.parse(cardsCsvRaw, { header: true, dynamicTyping: true, skipEmptyLines: true }); if (score >= 20) return '우호';
if (score > -20) return '중립';
if (score > -60) return '적대';
return '파국';
};
const TIER_VOTE = { 동맹: '찬성', 우호: '찬성', 중립: '미정', 적대: '반대', 파국: '반대' };
const TIER_IMMUNE = new Set(['동맹', '파국']);
const formattedCards = parsed.data.map(row => ({ const buildCardFromRow = (row) => ({
id: row.id, id: row.id,
type: row.type, type: row.type,
keyword: row.keyword, keyword: row.keyword,
question: row.question, question: row.question,
text: row.text, text: row.text,
flavor: row.flavor, flavor: row.flavor,
grade: row.grade || null,
contaminated: CONTAM_TYPES.has(row.type),
left: { left: {
text: row.left_text, text: row.left_text,
tag: row.left_tag ? narrativeTagsData.find(t => t.id === row.left_tag) : null, tag: row.left_tag ? narrativeTagsData.find(t => t.id === row.left_tag) : null,
stats: { stats: {
res: row.left_res || 0, res: row.left_res || 0, ent: row.left_ent || 0, pan: row.left_pan || 0, rsk: row.left_rsk || 0,
ent: row.left_ent || 0, bud: row.left_bud || 0, per: row.left_per || 0, inf: row.left_inf || 0,
pan: row.left_pan || 0, wea: row.left_wea || 0, for: row.left_for || 0, sur: row.left_sur || 0,
rsk: row.left_rsk || 0,
bud: row.left_bud || 0,
per: row.left_per || 0,
inf: row.left_inf || 0,
wea: row.left_wea || 0,
for: row.left_for || 0,
sur: row.left_sur || 0,
}, },
effect: () => {
setParams(p => ({
resistance: Math.max(0, p.resistance + (row.left_res || 0)),
entropy: Math.max(0, p.entropy + (row.left_ent || 0)),
panic: Math.max(0, p.panic + (row.left_pan || 0)),
risk: Math.max(0, p.risk + (row.left_rsk || 0)),
}));
setTokens(t => ({
budget: t.budget + (row.left_bud || 0),
personnel: t.personnel + (row.left_per || 0),
info: t.info + (row.left_inf || 0),
}));
setTags(tg => ({
wealth: tg.wealth + (row.left_wea || 0),
force: tg.force + (row.left_for || 0),
surveillance: tg.surveillance + (row.left_sur || 0),
}));
}
}, },
right: { right: {
text: row.right_text, text: row.right_text,
tag: row.right_tag ? narrativeTagsData.find(t => t.id === row.right_tag) : null, tag: row.right_tag ? narrativeTagsData.find(t => t.id === row.right_tag) : null,
stats: { stats: {
res: row.right_res || 0, res: row.right_res || 0, ent: row.right_ent || 0, pan: row.right_pan || 0, rsk: row.right_rsk || 0,
ent: row.right_ent || 0, bud: row.right_bud || 0, per: row.right_per || 0, inf: row.right_inf || 0,
pan: row.right_pan || 0, wea: row.right_wea || 0, for: row.right_for || 0, sur: row.right_sur || 0,
rsk: row.right_rsk || 0,
bud: row.right_bud || 0,
per: row.right_per || 0,
inf: row.right_inf || 0,
wea: row.right_wea || 0,
for: row.right_for || 0,
sur: row.right_sur || 0,
}, },
effect: () => { },
setParams(p => ({ });
resistance: Math.max(0, p.resistance + (row.right_res || 0)),
entropy: Math.max(0, p.entropy + (row.right_ent || 0)),
panic: Math.max(0, p.panic + (row.right_pan || 0)),
risk: Math.max(0, p.risk + (row.right_rsk || 0)),
}));
setTokens(t => ({
budget: t.budget + (row.right_bud || 0),
personnel: t.personnel + (row.right_per || 0),
info: t.info + (row.right_inf || 0),
}));
setTags(tg => ({
wealth: tg.wealth + (row.right_wea || 0),
force: tg.force + (row.right_for || 0),
surveillance: tg.surveillance + (row.right_sur || 0),
}));
}
}
}));
setCards(formattedCards.sort(() => Math.random() - 0.5)); 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([]);
const [contaminationTemplates, setContaminationTemplates] = useState([]);
const [phase2Pool, setPhase2Pool] = useState([]);
const [phase2Unlocked, setPhase2Unlocked] = useState(false);
const [currentCardIndex, setCurrentCardIndex] = useState(0);
const [turns, setTurns] = useState(0);
const [showCouncil, setShowCouncil] = useState(false);
const [chronicle, setChronicle] = useState([]);
const [showChronicle, setShowChronicle] = useState(false);
// 자산(엔진) 시스템 — 작업지시서 기준 트리거/효과 엔진
const [ownedAssets, setOwnedAssets] = useState([]);
const [assetLog, setAssetLog] = useState([]);
const [assetPopup, setAssetPopup] = useState(null);
// 라운드 구조: 라운드가 증가할수록 정례회의까지 남은 결재 건수가 줄어든다 (시간 압박 시각화)
const [roundCount, setRoundCount] = useState(1);
const [approvalsRemaining, setApprovalsRemaining] = useState(10);
// 파산 문책 시스템
const [bankruptcyCount, setBankruptcyCount] = useState(0);
const [bankruptcyWarning, setBankruptcyWarning] = useState(false);
// 자산 효과 상태
const [mulliganShields, setMulliganShields] = useState(0);
const [quarantineZone, setQuarantineZone] = useState([]);
const [assetsAcquiredCount, setAssetsAcquiredCount] = useState(0);
const [purgeCount, setPurgeCount] = useState(0);
// 글로벌 지부장 파벌 (게임 시작 시 12개 중 5개 무작위 선발)
const [factionRoster, setFactionRoster] = useState([]);
const [bribeCount, setBribeCount] = useState(0);
// 멀티 엔딩 판별기 상태
const [finalEnding, setFinalEnding] = useState(null);
const [chairmanAxis, setChairmanAxis] = useState(null);
const [lastParamDelta, setLastParamDelta] = useState(null);
const [contaminationSequence, setContaminationSequence] = useState([]);
const [narrativeChainUnlocked, setNarrativeChainUnlocked] = useState(false);
// CSV Load Effect — 페이즈1(일상/위기) / 페이즈2(작전/극비) 카드 풀 분리
useEffect(() => {
const parsed = Papa.parse(cardsCsvRaw, { header: true, dynamicTyping: true, skipEmptyLines: true });
const rows = parsed.data.filter(row => row.id);
const phase1Rows = rows.filter(row => row.type === '일상' || row.type === '위기');
const phase2Rows = rows.filter(row => row.type === '작전' || row.type === '극비');
const templates = rows.filter(row => CONTAM_TYPES.has(row.type)).map(buildCardFromRow);
setCards(phase1Rows.map(buildCardFromRow).sort(() => Math.random() - 0.5));
setPhase2Pool(phase2Rows.map(buildCardFromRow));
setContaminationTemplates(templates);
}, []); }, []);
// 글로벌 지부장 5명 선발 (게임 시작 시 1회)
useEffect(() => { useEffect(() => {
if (params.resistance >= 5 || params.entropy >= 5 || params.panic >= 5 || params.risk >= 5) { const shuffled = [...GLOBAL_FACTIONS].sort(() => Math.random() - 0.5);
setFactionRoster(shuffled.slice(0, 5));
}, []);
// 의회 의장 성향 축 선정 (게임 시작 시 1회) — 의회 정치 엔딩 판별에 사용
useEffect(() => {
setChairmanAxis(CHAIRMAN_AXES[Math.floor(Math.random() * CHAIRMAN_AXES.length)]);
}, []);
// 내러티브 태그가 PHASE2_TAG_THRESHOLD종 도달 시 페이즈2 카드 풀(작전/극비) 개방
useEffect(() => {
if (phase2Unlocked || phase2Pool.length === 0 || acquiredTags.length < PHASE2_TAG_THRESHOLD) return;
setPhase2Unlocked(true);
setCards(prev => {
const shuffledPool = [...phase2Pool].sort(() => Math.random() - 0.5);
const insertAt = Math.min(prev.length, currentCardIndex + 1);
const next = [...prev];
next.splice(insertAt, 0, ...shuffledPool);
return next;
});
setChronicle(prev => [...prev, { turn: turns, cardType: '페이징', keyword: null, text: '페이즈 2 진입 - 작전 / 극비 프로젝트 카드 풀이 개방되었습니다.', decision: '', tag: null }]);
}, [acquiredTags, phase2Pool, phase2Unlocked, currentCardIndex, turns]);
useEffect(() => {
if (!gameOver && (params.resistance >= 5 || params.entropy >= 5 || params.panic >= 5)) {
// 위험 수용 계수(risk)는 PRD상 탄핵/법안 상정의 기준치일 뿐, 직접적인 게임오버 트리거가 아니다.
const ending = resolveEnding({
trigger: 'collapse', params, tags, tokens,
lastDelta: lastParamDelta, narrativeChainUnlocked,
});
setFinalEnding(ending);
setGameOver(true); setGameOver(true);
} }
if (acquiredTags.length >= 4 && acquiredTags.length < 8) setPhase(2); if (acquiredTags.length >= PHASE2_TAG_THRESHOLD && acquiredTags.length < PHASE3_TAG_THRESHOLD) setPhase(2);
else if (acquiredTags.length >= 8) setPhase(3); else if (acquiredTags.length >= PHASE3_TAG_THRESHOLD) setPhase(3);
}, [params, acquiredTags]); }, [params, acquiredTags]);
// 시한부 격리된 오염 카드 복귀
useEffect(() => {
if (quarantineZone.length === 0) return;
const ready = quarantineZone.filter(c => c.returnTurn <= turns);
if (ready.length > 0) {
setCards(prev => [...prev, ...ready.map(({ returnTurn, ...rest }) => rest)]);
setQuarantineZone(prev => prev.filter(c => c.returnTurn > turns));
setChronicle(prev => [...prev, { turn: turns, cardType: '자산', keyword: null, text: `격리했던 오염 카드 ${ready.length}건이 덱으로 복귀했습니다.`, decision: '', tag: null }]);
}
}, [turns]);
// 자산 엔진 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) => { const handleSwipe = (direction) => {
if (cards.length === 0) return; if (cards.length === 0) return;
const card = cards[currentCardIndex % cards.length]; 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 outcomeText = '';
let isNewTag = false;
let gainedTag = null; let gainedTag = null;
let newBudget = tokens.budget; // 자산 엔진: 좌 스와이프 격리 도박(임시 격리 컨테이너) — 성공하면 이 카드는 정상 결과 대신 격리된다.
let newPersonnel = tokens.personnel; let quarantinedByAsset = false;
let newInfo = tokens.info; if (direction === 'left' && card.contaminated) {
AssetEngine.onSwipeLeft(world, cardIdx, turns);
if (direction === 'left' && card.left) { quarantinedByAsset = world.quarantinedCard;
newBudget += (card.left.stats?.bud || 0);
newPersonnel += (card.left.stats?.per || 0);
newInfo += (card.left.stats?.inf || 0);
card.left.effect();
outcomeText = card.left.text;
gainedTag = card.left.tag;
} else if (direction === 'right' && card.right) {
newBudget += (card.right.stats?.bud || 0);
newPersonnel += (card.right.stats?.per || 0);
newInfo += (card.right.stats?.inf || 0);
card.right.effect();
outcomeText = card.right.text;
gainedTag = card.right.tag;
} else if (direction === 'up') {
outcomeText = '보류 / 버리기';
} }
if (newBudget < 0 || newPersonnel < 0 || newInfo < 0) { // 자산 엔진: 오염 카드 드로우 트리거(신화 환전/조직저항 자동 무효화 등)
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;
// 파산 문책: 결재 결과 토큰이 마이너스가 되면 파산 카운터 증가
if (world.tokens.budget < 0 || world.tokens.personnel < 0 || world.tokens.info < 0) {
setBankruptcyCount(c => c + 1); setBankruptcyCount(c => c + 1);
} }
if (outcomeText) { // 극단적 파라미터 조기 엔딩 판별용: 이번 결재에서 가장 크게 증가한 위험 파라미터 추적
let isNewTag = false; if (!shouldNullify) {
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; }
});
if (maxParam) setLastParamDelta({ param: maxParam, amount: maxVal });
}
// 내러티브 태그: 재사용 가능한 태그 풀에서 처음 획득하는 태그일 때만 진행도에 반영
gainedTag = side.tag;
if (gainedTag && !acquiredTags.find(t => t.id === gainedTag.id)) { if (gainedTag && !acquiredTags.find(t => t.id === gainedTag.id)) {
isNewTag = true; isNewTag = true;
setAcquiredTags(prev => [...prev, gainedTag]); setAcquiredTags(prev => [...prev, gainedTag]);
@@ -168,56 +303,121 @@ export function GameScreen() {
setTimeout(() => setNewTagNotification(null), 3000); setTimeout(() => setNewTagNotification(null), 3000);
} }
// 내러티브 연쇄 엔딩: 오염 카드 4종이 정해진 순서로 등장했는지 추적
if (card.contaminated) {
const nextSequence = [...contaminationSequence, card.type];
setContaminationSequence(nextSequence);
if (!narrativeChainUnlocked && isNarrativeChainComplete(nextSequence)) {
setNarrativeChainUnlocked(true);
}
}
if (shouldNullify) {
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 (!quarantinedByAsset && outcomeText) {
setChronicle(prev => [...prev, { setChronicle(prev => [...prev, {
turn: turns + 1, turn: turns + 1, cardType: card.type, keyword: card.keyword, text: card.text,
cardType: card.type, decision: outcomeText, tag: isNewTag ? gainedTag : null,
keyword: card.keyword,
text: card.text,
decision: outcomeText,
tag: isNewTag ? gainedTag : null
}]); }]);
} }
// Add random asset for '극비' success (just for demonstration) // 극비 프로젝트 성공 시 자산 획득 (아직 보유하지 않은 자산 중 무작위 1종)
if (card.type === '극비' && direction !== 'up') { if (!quarantinedByAsset && card.type === '극비' && direction !== 'up') {
if (assets.length < 4) { const ownedIds = new Set(world.owned.map(a => a.defId));
setAssets(prev => [...prev, { id: Date.now(), icon: ['📦','💎','🧪','💾'][Math.floor(Math.random()*4)] }]); 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); // 자산 엔진: 결재 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);
setTurns(turns + 1);
const newRemaining = approvalsRemaining - 1; const newRemaining = approvalsRemaining - 1;
setApprovalsRemaining(newRemaining); setApprovalsRemaining(newRemaining);
if (newRemaining <= 0) { if (newRemaining <= 0) {
setShowCouncil(true); setShowCouncil(true);
} }
}; };
// 그림자 의회 종료 후 파라미터 단계에 비례한 등급/물량으로 오염 카드 주입
const injectContaminationCards = () => {
const newCards = buildCouncilPollutionCards(params, contaminationTemplates);
if (newCards.length === 0) return;
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) => { const handleCouncilResolve = (result) => {
setShowCouncil(false); setShowCouncil(false);
if (result.impeachment) {
setImpeachmentPassed(true); // 자산 엔진: 의회 종료 시 상시 자산 리스크 정산 + 격리 실패 판정(심연 보관 조례 개입 포함)
setGameOver(true); const councilWorld = buildAssetWorld();
return; AssetEngine.onCouncilEnd(councilWorld);
} commitAssetWorld(councilWorld);
if (result.bankrupted) { if (result.bankrupted) {
setBankrupted(true); setFinalEnding(getEndingById('bankruptcy'));
setGameOver(true); setGameOver(true);
return; return;
} }
// Update warning state if (result.impeachment) {
if (result.bankruptcyWarning) { const ending = resolveEnding({ trigger: 'impeachment', tags, chairmanAxis, narrativeChainUnlocked });
setBankruptcyWarning(true); setFinalEnding(ending);
setGameOver(true);
return;
} }
// Next round logic if (typeof result.bankruptcyWarning === 'boolean') {
setBankruptcyWarning(result.bankruptcyWarning);
}
injectContaminationCards();
// 라운드 진행: 라운드가 증가할수록 다음 정례회의까지의 결재 건수가 줄어든다
const nextRound = roundCount + 1; const nextRound = roundCount + 1;
// Reduce approvals per round, ending when it hits 0 let nextApprovals;
let nextApprovals = approvalsRemaining;
if (roundCount === 1) nextApprovals = 8; if (roundCount === 1) nextApprovals = 8;
else if (roundCount === 2) nextApprovals = 6; else if (roundCount === 2) nextApprovals = 6;
else if (roundCount === 3) nextApprovals = 4; else if (roundCount === 3) nextApprovals = 4;
@@ -228,11 +428,23 @@ export function GameScreen() {
setApprovalsRemaining(nextApprovals); setApprovalsRemaining(nextApprovals);
if (nextApprovals <= 0) { if (nextApprovals <= 0) {
setTermExpired(true); setFinalEnding(getEndingById('term_expired'));
setGameOver(true); setGameOver(true);
} }
}; };
// 자산 슬롯 클릭 — 항상 정보 팝업을 연다 (Part C UX 블로커 해결). defId만 저장해 최신 스택/충전 값을 반영한다.
const handleAssetSlotClick = (inst) => setAssetPopup(inst.defId);
// 충전식 자산(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) => { const renderDots = (value, max = 5) => {
return Array.from({ length: max }).map((_, i) => ( return Array.from({ length: max }).map((_, i) => (
<div key={i} className={`dot ${i < value ? 'filled' : 'empty'}`}></div> <div key={i} className={`dot ${i < value ? 'filled' : 'empty'}`}></div>
@@ -242,25 +454,37 @@ export function GameScreen() {
if (gameOver) { if (gameOver) {
return ( return (
<EndingScreen <EndingScreen
params={params} ending={finalEnding}
impeachment={impeachmentPassed}
termExpired={termExpired}
bankrupted={bankrupted}
narrativeTags={acquiredTags.length} narrativeTags={acquiredTags.length}
keywords={acquiredTags.map(t => ({ word: t.name }))}
onRestart={() => window.location.reload()} onRestart={() => window.location.reload()}
/> />
); );
} }
if (showCouncil) { if (showCouncil) {
// 글로벌 지부장 12개 파벌 중 이번 회차 선발된 5명의 우호도를 각자 다른 조건으로 개별 계산
const affinityCtx = {
tags, tokens, params,
assetsCount: ownedAssets.length,
mythicCount: cards.filter(c => c.type === '신화').length,
tagCount: acquiredTags.length, assetsAcquiredCount, purgeCount, bribeCount,
};
const globalFactions = factionRoster.map(f => {
const score = f.computeAffinity(affinityCtx);
const tier = getFactionTier(score);
return { id: f.id, name: f.name, tier, vote: TIER_VOTE[tier], immune: TIER_IMMUNE.has(tier) };
});
return ( return (
<CouncilScreen <CouncilScreen
params={params} setParams={setParams} params={params} setParams={setParams}
tokens={tokens} setTokens={setTokens} tokens={tokens} setTokens={setTokens}
tags={tags} setTags={setTags} tags={tags} setTags={setTags}
bankruptcyCount={bankruptcyCount} bankruptcyCount={bankruptcyCount}
setBankruptcyCount={setBankruptcyCount}
bankruptcyWarning={bankruptcyWarning} bankruptcyWarning={bankruptcyWarning}
globalFactions={globalFactions}
onBribe={() => setBribeCount(c => c + 1)}
onResolve={handleCouncilResolve} onResolve={handleCouncilResolve}
/> />
); );
@@ -271,16 +495,24 @@ export function GameScreen() {
} }
const phaseClass = phase === 1 ? 'phase-1' : phase === 2 ? 'phase-2' : 'phase-3'; 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 ( return (
<div className={`game-screen ${phaseClass}`}> <div className={`game-screen ${phaseClass}`}>
{/* Top Bar */} {/* Top Bar */}
<div className="top-bar"> <div className="top-bar">
<div className="narrative-badge" onClick={() => setShowChronicle(true)} style={{cursor: 'pointer'}}> <div className="narrative-badge" onClick={() => setShowChronicle(true)} style={{cursor: 'pointer'}}>
<BookOpen size={16} /> <span>{acquiredTags.length}/8</span> <BookOpen size={16} /> <span>{acquiredTags.length}{nextTagThreshold !== null ? `/${nextTagThreshold}` : ''}</span>
</div> </div>
<div className="round-info"> <div className="round-info">
{roundCount} 정례회의 상정까지: 잔여 {approvalsRemaining} <div> {roundCount} 정례회의 상정까지: 잔여 {approvalsRemaining}</div>
<div className={`deck-pollution ${pollutionRatio >= 0.4 ? 'critical' : ''}`}>
오염 {pollutedCount}/{cards.length}
</div>
</div> </div>
<div className="parameters"> <div className="parameters">
<div className={`param ${params.resistance >= 4 ? 'critical' : ''}`} style={{'--param-color': 'var(--color-resistance-critical)'}}> <div className={`param ${params.resistance >= 4 ? 'critical' : ''}`} style={{'--param-color': 'var(--color-resistance-critical)'}}>
@@ -314,9 +546,10 @@ export function GameScreen() {
{/* Bottom Inventory */} {/* Bottom Inventory */}
<div className="bottom-bar"> <div className="bottom-bar">
<div className="tokens-row"> <div className="tokens-row">
<div className="token"><HandCoins size={16} color="var(--color-token-budget)" /> <span>{tokens.budget}</span></div> <div className={`token ${tokens.budget < 0 ? 'negative' : ''}`}><HandCoins size={16} color="var(--color-token-budget)" /> <span>{tokens.budget}</span></div>
<div className="token"><Droplet size={16} color="var(--color-token-personnel)" /> <span>{tokens.personnel}</span></div> <div className={`token ${tokens.personnel < 0 ? 'negative' : ''}`}><Droplet size={16} color="var(--color-token-personnel)" /> <span>{tokens.personnel}</span></div>
<div className="token"><Eye size={16} color="var(--color-token-info)" /> <span>{tokens.info}</span></div> <div className={`token ${tokens.info < 0 ? 'negative' : ''}`}><Eye size={16} color="var(--color-token-info)" /> <span>{tokens.info}</span></div>
{mulliganShields > 0 && <div className="token shield-badge">🛡 <span>x{mulliganShields}</span></div>}
</div> </div>
<div className="tags-row"> <div className="tags-row">
<div className="tag"><Building2 size={14} /> <span>{tags.wealth}</span></div> <div className="tag"><Building2 size={14} /> <span>{tags.wealth}</span></div>
@@ -324,14 +557,44 @@ export function GameScreen() {
<div className="tag"><EyeOff size={14} /> <span>{tags.surveillance}</span></div> <div className="tag"><EyeOff size={14} /> <span>{tags.surveillance}</span></div>
</div> </div>
<div className="asset-slots"> <div className="asset-slots">
{[0, 1, 2, 3].map(i => ( {ownedAssets.length === 0 && <div className="asset-slot-empty-hint">보유 자산 없음</div>}
<div key={i} className="asset-slot"> {ownedAssets.map((inst) => {
{assets[i] ? <span className="asset-icon">{assets[i].icon}</span> : ''} const def = AssetEngine.getAssetDef(inst.defId);
</div> const meta = AssetEngine.ARCHETYPE_META[def.archetype];
))} const ready = def.trigger.type === 'manual_charge' && inst.charge >= 1;
return (
<button
key={inst.defId}
className={`asset-slot filled grade-${def.grade} ${ready ? 'ready' : ''}`}
onClick={() => handleAssetSlotClick(inst)}
title={def.name}
>
<span className="asset-icon">{meta.icon}</span>
</button>
);
})}
</div> </div>
</div> </div>
{assetLog.length > 0 && (
<div className="asset-log-feed">
{assetLog.slice(-4).map((line, i) => (
<div key={i} className="asset-log-line">{line}</div>
))}
</div>
)}
{assetPopup && (
<AssetInfoPopup
instance={ownedAssets.find(a => a.defId === assetPopup)}
def={AssetEngine.getAssetDef(assetPopup)}
onActivate={() => handleAssetActivate(assetPopup)}
onClose={() => setAssetPopup(null)}
/>
)}
{(newTagNotification || pollutionNotification) && (
<div className="notification-stack">
{newTagNotification && ( {newTagNotification && (
<div className="tag-notification"> <div className="tag-notification">
<div className="tag-noti-icon">{newTagNotification.icon}</div> <div className="tag-noti-icon">{newTagNotification.icon}</div>
@@ -341,6 +604,17 @@ export function GameScreen() {
</div> </div>
</div> </div>
)} )}
{pollutionNotification && (
<div className="pollution-notification" key={pollutionNotification.id}>
<div className="pollution-noti-icon"></div>
<div className="pollution-noti-info">
<h4>오염 카드 {pollutionNotification.count}건이 덱에 유입되었습니다</h4>
<p>{pollutionNotification.summary}</p>
</div>
</div>
)}
</div>
)}
{showChronicle && ( {showChronicle && (
<ChroniclePopup <ChroniclePopup

View File

@@ -222,6 +222,43 @@
font-size: 20px; 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 { @keyframes pulseTag {
0% { transform: scale(1); box-shadow: 0 0 15px rgba(251, 191, 36, 0.5); } 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); } 50% { transform: scale(1.05); box-shadow: 0 0 25px rgba(251, 191, 36, 0.8); }

View File

@@ -1,8 +1,22 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion, useMotionValue, useTransform, useAnimation } from 'framer-motion'; import { motion, useMotionValue, useTransform, useAnimation } from 'framer-motion';
import { HandCoins, Droplet, Eye, Shield, Building2, EyeOff } from 'lucide-react'; import { HandCoins, Droplet, Eye, Shield, Building2, EyeOff } from 'lucide-react';
import { previewPollution, describePollution } from '../engine/pollution';
import './SwipeCard.css'; import './SwipeCard.css';
// 이 선택이 덱에 오염 카드를 몇 장 밀어 넣는지 스와이프 전에 예고한다.
// 계산은 반드시 engine/pollution.js의 previewPollution을 쓴다 — 실제 주입과 같은 기준이어야 한다.
const renderPollutionWarning = (stats) => {
const { total, entries } = previewPollution(stats);
if (total === 0) return null;
return (
<div className="pollution-warning">
<span className="pollution-warning-head"> 오염 카드 +{total}</span>
<span className="pollution-warning-types">{describePollution(entries)}</span>
</div>
);
};
const renderStats = (stats) => { const renderStats = (stats) => {
if (!stats) return null; if (!stats) return null;
const items = []; const items = [];
@@ -117,6 +131,7 @@ export default function SwipeCard({ card, onSwipe, phase }) {
<span className="intent-tag-name">{card.left.tag.name}</span> <span className="intent-tag-name">{card.left.tag.name}</span>
</div> </div>
)} )}
{renderPollutionWarning(card.left?.stats)}
{renderStats(card.left?.stats)} {renderStats(card.left?.stats)}
</motion.div> </motion.div>
<motion.div className="swipe-intent right-intent" style={{ opacity: rightOpacity }}> <motion.div className="swipe-intent right-intent" style={{ opacity: rightOpacity }}>
@@ -127,6 +142,7 @@ export default function SwipeCard({ card, onSwipe, phase }) {
<span className="intent-tag-name">{card.right.tag.name}</span> <span className="intent-tag-name">{card.right.tag.name}</span>
</div> </div>
)} )}
{renderPollutionWarning(card.right?.stats)}
{renderStats(card.right?.stats)} {renderStats(card.right?.stats)}
</motion.div> </motion.div>
{card.type === '극비' && ( {card.type === '극비' && (

View File

@@ -1,30 +1,106 @@
[ [
{ {
"id": "asset_01", "id": "asset_purge_001",
"name": "전임 국장의 만년필", "name": "문서 파쇄실",
"icon": "🖋️", "grade": "C",
"description": "피로 쓰여진 듯한 잉크가 나옵니다. 예산 관리에 소소한 도움을 줍니다.", "archetype": "purge",
"flavor": "\"예산 전용 동의서에 서명하기 아주 좋은 펜이군.\"" "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", "id": "asset_purge_002",
"name": "비밀 스위스 은행 계좌", "name": "소각 처리 대장",
"icon": "🏦", "grade": "A",
"description": "마이너스 예산을 한 번 막아줄 수 있습니다.", "archetype": "purge",
"flavor": "\"우리 기구가 파산해도 내 노후는 안전해야지.\"" "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", "id": "asset_convert_001",
"name": "기억 소거 장치", "name": "폐기물 재활용 규정",
"icon": "🔦", "grade": "C",
"description": "불리한 결재 건을 한 번 취소할 수 있습니다.", "archetype": "convert",
"flavor": "\"자, 여기 불빛을 보세요. 당신은 방금 아무것도 보지 못했습니다.\"" "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", "id": "asset_convert_002",
"name": "방탄 커피 머신", "name": "부산물 매각 계약",
"icon": "", "grade": "B",
"description": "요원들의 불만을 약간 억제합니다.", "archetype": "convert",
"flavor": "\"아무리 세상이 멸망해도 모닝 커피는 포기 못하지.\"" "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": "'담당자 부재'라는 담당자가 새로 생겼다."
} }
] ]

View File

@@ -1,33 +1,48 @@
id,type,keyword,question,text,flavor,left_text,left_res,left_ent,left_pan,left_rsk,left_bud,left_per,left_inf,left_wea,left_for,left_sur,right_text,right_res,right_ent,right_pan,right_rsk,right_bud,right_per,right_inf,right_wea,right_for,right_sur id,type,keyword,question,text,flavor,left_text,left_tag,left_res,left_ent,left_pan,left_rsk,left_bud,left_per,left_inf,left_wea,left_for,left_sur,right_text,right_tag,right_res,right_ent,right_pan,right_rsk,right_bud,right_per,right_inf,right_wea,right_for,right_sur,grade
1,일상,요원들의 시위 발생,시위를 어떻게 처리하시겠습니까?,B구역 하급 요원들이 예산 삭감에 반발해 장비 반납 시위를 하고 있습니다.,우리가 세상을 구하는데 월급이 깎인다고요? 촉수에 맞아 죽으나 굶어 죽으나 똑같습니다!,무력 진압,1,0,0,0,0,-10,/a,0,0,0,요구 수용,0,0,0,0,-20,0,0,0,0,0 1,일상,요원들의 시위 발생,시위를 어떻게 처리하시겠습니까?,B구역 하급 요원들이 예산 삭감에 반발해 장비 반납 시위를 하고 있습니다.,우리가 세상을 구하는데 월급이 깎인다고요? 촉수에 맞아 죽으나 굶어 죽으나 똑같습니다!,무력 진압,tag_ruthless,1,0,0,0,0,-10,0,0,0,0,요구 수용,,0,0,0,0,-20,0,0,0,0,0,
2,위기,격리실 오염 발생,오염을 어떻게 정화하시겠습니까?,지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다.,보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다.,네이팜 소각 강행,0,0,0,0,0,-30,0,0,0,0,조사팀 파견,0,1,0,1,0,0,0,0,0,0 2,위기,격리실 오염 발생,오염을 어떻게 정화하시겠습니까?,지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다.,보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다.,네이팜 소각 강행,tag_coverup,0,0,0,0,0,-30,0,0,0,0,조사팀 파견,,0,1,0,1,0,0,0,0,0,0,
3,일상,촉수 괴물 출현,출동 승인하시겠습니까?,7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다.,규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다.,내역 위조 후 출동,0,0,0,1,0,0,-10,0,0,0,규정대로 반려,1,1,0,0,0,0,0,0,0,0 3,일상,촉수 괴물 출현,출동 승인하시겠습니까?,7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다.,규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다.,내역 위조 후 출동,,0,0,0,1,0,0,-10,0,0,0,규정대로 반려,,1,1,0,0,0,0,0,0,0,0,
4,극비,변이 DNA 이식 제안,실험을 승인하시겠습니까?,진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠.,인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠.,실험 승인,0,2,0,0,40,40,0,0,0,0,제안 거절,1,0,0,0,0,0,0,0,0,0 4,극비,변이 DNA 이식 제안,실험을 승인하시겠습니까?,진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠.,인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠.,실험 승인,tag_madness,0,2,0,0,40,40,0,0,0,0,제안 거절,,1,0,0,0,0,0,0,0,0,0,
5,작전,전술 폭격 건의,폭격을 승인하시겠습니까?,철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다.,희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠.,궤도 폭격 승인,0,0,0,2,0,0,0,0,2,0,작전 기각,1,0,0,0,0,0,0,0,0,0 5,작전,전술 폭격 건의,폭격을 승인하시겠습니까?,철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다.,희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠.,궤도 폭격 승인,tag_sacrifice,0,0,0,2,0,0,0,0,2,0,작전 기각,,1,0,0,0,0,0,0,0,0,0,
6,일상,부산물 암시장 매각,매각을 허가하시겠습니까?,차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다.,이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠.,매각 허가,0,0,0,1,30,0,0,0,0,0,원칙대로 소각,0,0,0,0,-10,0,0,0,0,0 6,일상,부산물 암시장 매각,매각을 허가하시겠습니까?,차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다.,이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠.,매각 허가,tag_corruption,0,0,0,1,30,0,0,0,0,0,원칙대로 소각,,0,0,0,0,-10,0,0,0,0,0,
7,작전,존재 양지화 프로젝트,프로젝트를 승인하시겠습니까?,대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다.,우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다.,감시망 구축,0,0,0,0,0,0,-40,0,0,2,프로젝트 보류,1,0,0,0,0,0,0,0,0,0 7,작전,존재 양지화 프로젝트,프로젝트를 승인하시겠습니까?,대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다.,우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다.,감시망 구축,tag_coverup,0,0,0,0,0,0,-40,0,0,2,프로젝트 보류,,1,0,0,0,0,0,0,0,0,0,
8,극비,대규모 도서관 건립,예산을 배정하시겠습니까?,고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다.,이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다.,예산 배정,0,0,0,0,-40,0,40,0,0,0,예산 전액 삭감,1,0,0,1,0,0,0,0,0,0 8,극비,대규모 도서관 건립,예산을 배정하시겠습니까?,고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다.,이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다.,예산 배정,,0,0,0,0,-40,0,40,0,0,0,예산 전액 삭감,,1,0,0,1,0,0,0,0,0,0,
9,위기,이단 색출 마녀사냥,어떻게 개입하시겠습니까?,기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다.,더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로!,적당히 묵인한다,1,0,0,0,0,-30,0,0,0,0,군사력으로 제압,0,0,0,1,0,0,-20,0,0,0 9,위기,이단 색출 마녀사냥,어떻게 개입하시겠습니까?,기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다.,더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로!,적당히 묵인한다,,1,0,0,0,0,-30,0,0,0,0,군사력으로 제압,,0,0,0,1,0,0,-20,0,0,0,
10,일상,변칙 현상 영상 유출,어떻게 처리하시겠습니까?,한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다.,대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오.,제거 절차 승인,0,0,0,-1,0,0,-20,0,0,0,영상만 삭제,0,1,0,0,0,0,0,0,0,0 10,일상,변칙 현상 영상 유출,어떻게 처리하시겠습니까?,한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다.,대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오.,제거 절차 승인,tag_coverup,0,0,0,-1,0,0,-20,0,0,0,영상만 삭제,,0,1,0,0,0,0,0,0,0,0,
11,위기,사이비 종교 인신 공양,어떻게 대응하시겠습니까?,특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다.,위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다.,군사 개입 지시,1,0,1,0,0,-40,0,0,0,0,의식 방관,0,2,0,0,0,0,0,0,0,0 11,위기,사이비 종교 인신 공양,어떻게 대응하시겠습니까?,특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다.,위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다.,군사 개입 지시,,1,0,1,0,0,-40,0,0,0,0,의식 방관,tag_cult,0,2,0,0,0,0,0,0,0,0,
12,위기,의회의 긴급 감사,어떻게 대처하시겠습니까?,의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다!,세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오.,은폐 공작 실행,0,0,0,1,0,0,-40,0,0,0,횡령 사실 인정,0,0,0,2,0,0,0,0,0,0 12,위기,의회의 긴급 감사,어떻게 대처하시겠습니까?,의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다!,세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오.,은폐 공작 실행,tag_corruption,0,0,0,1,0,0,-40,0,0,0,횡령 사실 인정,,0,0,0,2,0,0,0,0,0,0,
13,일상,연구 부서 파업 조짐,요구를 수용하시겠습니까?,연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다.,저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요.,특별 보너스 지급,-1,0,0,0,-30,0,0,0,0,0,요구 전면 묵살,1,0,0,0,0,0,0,0,0,0 13,일상,연구 부서 파업 조짐,요구를 수용하시겠습니까?,연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다.,저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요.,특별 보너스 지급,,-1,0,0,0,-30,0,0,0,0,0,요구 전면 묵살,,1,0,0,0,0,0,0,0,0,0,
14,작전,이계 무역로 개방,무역로를 개방하시겠습니까?,이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다.,저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠.,게이트 강제 개방,0,1,0,0,50,0,0,0,0,0,폐쇄 상태 유지,1,0,0,0,0,0,0,0,0,0 14,작전,이계 무역로 개방,무역로를 개방하시겠습니까?,이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다.,저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠.,게이트 강제 개방,tag_alien_contact,0,1,0,0,50,0,0,0,0,0,폐쇄 상태 유지,,1,0,0,0,0,0,0,0,0,0,
15,극비,전임 국장 비밀 금고,금고를 개방하시겠습니까?,전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다.,전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다.,강제 개방 지시,0,0,0,0,20,-30,0,1,0,0,영구 봉인,0,0,0,0,0,0,0,0,0,0 15,극비,전임 국장 비밀 금고,금고를 개방하시겠습니까?,전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다.,전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다.,강제 개방 지시,tag_corruption,0,0,0,0,20,-30,0,1,0,0,영구 봉인,,0,0,0,0,0,0,0,0,0,0,
16,작전,메가코프 협박 작전,작전을 실행하시겠습니까?,글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다.,그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까.,작전 실행,0,0,0,0,0,0,-30,2,0,0,작전 취소,1,0,0,0,0,0,0,0,0,0 16,작전,메가코프 협박 작전,작전을 실행하시겠습니까?,글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다.,그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까.,작전 실행,,0,0,0,0,0,0,-30,2,0,0,작전 취소,,1,0,0,0,0,0,0,0,0,0,
17,위기,전술 폭격 건의,폭격을 승인하시겠습니까?,제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요?,시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다.,시민 포기 및 폭격,0,-1,0,2,0,0,0,0,0,0,결사 방어 명령,0,0,0,0,-40,-40,0,0,0,0 17,위기,전술 폭격 건의,폭격을 승인하시겠습니까?,제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요?,시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다.,시민 포기 및 폭격,tag_ruthless,0,-1,0,2,0,0,0,0,0,0,결사 방어 명령,,0,0,0,0,-40,-40,0,0,0,0,
18,일상,행정부 파업 예고,어떻게 대응하시겠습니까?,새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다.,기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다.,예산 전용으로 무마,0,0,0,1,-20,0,0,0,0,0,파업 강경 대응,1,0,0,0,0,0,0,0,0,0 18,일상,행정부 파업 예고,어떻게 대응하시겠습니까?,새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다.,기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다.,예산 전용으로 무마,,0,0,0,1,-20,0,0,0,0,0,파업 강경 대응,,1,0,0,0,0,0,0,0,0,0,
19,극비,불침번 요원 개발,배치하시겠습니까?,비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다.,잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠.,실전 즉각 배치,0,1,0,0,0,0,20,0,1,0,즉각 폐기 처분,1,0,0,0,-10,0,0,0,0,0 19,극비,불침번 요원 개발,배치하시겠습니까?,비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다.,잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠.,실전 즉각 배치,,0,1,0,0,0,0,20,0,1,0,즉각 폐기 처분,,1,0,0,0,-10,0,0,0,0,0,
20,일상,내부 스파이 첩보,어떻게 조사하시겠습니까?,기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다.,누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다.,대규모 사찰 시작,2,0,0,0,0,0,30,0,0,0,증거 부족으로 덮음,0,0,0,1,0,0,0,0,0,0 20,일상,내부 스파이 첩보,어떻게 조사하시겠습니까?,기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다.,누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다.,대규모 사찰 시작,tag_political_pressure,2,0,0,0,0,0,30,0,0,0,증거 부족으로 덮음,,0,0,0,1,0,0,0,0,0,0,
21,작전,비정상적 위기 발생,"국장님, 결단이 필요합니다.",순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다.,괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다.,수용소 건립 승인,0,0,0,0,-40,0,0,0,1,0,제안 단호히 거절,1,0,0,1,0,0,0,0,0,0 21,작전,비정상적 위기 발생,국장님 결단이 필요합니다,순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다.,괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다.,수용소 건립 승인,tag_ruthless,0,0,0,0,-40,0,0,0,1,0,제안 단호히 거절,,1,0,0,1,0,0,0,0,0,0,
22,극비,이계의 유물 인양,유물을 어떻게 처리하시겠습니까?,고대 공존당의 사제들이 심해의 유물을 인양했습니다. 만지는 자의 정신을 갉아먹지만 막대한 지식을 줍니다.,약간의 두통만 참으면 이계의 비밀을 모두 알 수 있습니다. 요원 하나쯤 미쳐도 괜찮습니다.,유물 집중 연구,0,1,0,0,0,0,50,0,0,0,유물 파괴 명령,1,0,0,1,0,0,0,0,0,0 22,극비,이계의 유물 인양,유물을 어떻게 처리하시겠습니까?,고대 공존당의 사제들이 심해의 유물을 인양했습니다. 만지는 자의 정신을 갉아먹지만 막대한 지식을 줍니다.,약간의 두통만 참으면 이계의 비밀을 모두 알 수 있습니다. 요원 하나쯤 미쳐도 괜찮습니다.,유물 집중 연구,tag_alien_contact,0,1,0,0,0,0,50,0,0,0,유물 파괴 명령,,1,0,0,1,0,0,0,0,0,0,
23,위기,이계의 주시자 출현,대중을 어떻게 통제하시겠습니까?,얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다.,하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오.,인터넷 전면 차단,2,0,1,0,0,0,0,0,0,2,진정제 무상 배포,0,0,0,0,-50,0,0,0,0,0 23,위기,이계의 주시자 출현,대중을 어떻게 통제하시겠습니까?,얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다.,하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오.,인터넷 전면 차단,,2,0,1,0,0,0,0,0,0,2,진정제 무상 배포,,0,0,0,0,-50,0,0,0,0,0,
24,일상,요원 집단 트라우마,어떻게 지원하시겠습니까?,요원들이 괴수 진압 후 심각한 트라우마에 시달리고 있습니다. 심리 치료 지원이 시급합니다.,밤마다 촉수가 목을 조르는 악몽을 꾼답니다. 차라리 그 부분 기억을 지워버릴까요?,정신과 치료 지원,0,0,0,0,-20,10,0,0,0,0,트라우마 기억 소거,0,1,0,1,0,0,0,0,0,0 24,일상,요원 집단 트라우마,어떻게 지원하시겠습니까?,요원들이 괴수 진압 후 심각한 트라우마에 시달리고 있습니다. 심리 치료 지원이 시급합니다.,밤마다 촉수가 목을 조르는 악몽을 꾼답니다. 차라리 그 부분 기억을 지워버릴까요?,정신과 치료 지원,,0,0,0,0,-20,10,0,0,0,0,트라우마 기억 소거,,0,1,0,1,0,0,0,0,0,0,
25,극비,내부 고발자 발생,어떻게 막으시겠습니까?,의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다.,국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다.,사고사 위장 지시,0,0,0,-1,0,0,-30,0,0,0,폭로전 정면 돌파,0,0,0,2,0,0,0,0,0,0 25,극비,내부 고발자 발생,어떻게 막으시겠습니까?,의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다.,국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다.,사고사 위장 지시,tag_coverup,0,0,0,-1,0,0,-30,0,0,0,폭로전 정면 돌파,,0,0,0,2,0,0,0,0,0,0,
26,위기,차원 붕괴 폭탄 오작동,어떻게 대응하시겠습니까?,차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다.,최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다.,특수 해체팀 투입,0,0,0,0,0,-40,0,0,0,0,폭발 방관 및 대피,0,2,0,0,0,0,0,0,0,0 26,위기,차원 붕괴 폭탄 오작동,어떻게 대응하시겠습니까?,차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다.,최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다.,특수 해체팀 투입,tag_sacrifice,0,0,0,0,0,-40,0,0,0,0,폭발 방관 및 대피,,0,2,0,0,0,0,0,0,0,0,
27,작전,PMC 흡수 합병,결탁하시겠습니까?,군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다.,돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠.,결탁 및 흡수 합병,0,0,0,0,-40,0,0,0,2,0,독자적 노선 유지,1,0,0,0,0,0,0,0,0,0 27,작전,PMC 흡수 합병,결탁하시겠습니까?,군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다.,돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠.,결탁 및 흡수 합병,,0,0,0,0,-40,0,0,0,2,0,독자적 노선 유지,,1,0,0,0,0,0,0,0,0,0,
28,작전,대규모 거주 구역 실험장화,제안을 승인하시겠습니까?,변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다.,시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠.,돔 건설 강행,0,1,0,0,-50,0,0,0,0,0,윤리적 이유로 기각,0,0,0,1,0,0,0,0,0,0 28,작전,대규모 거주 구역 실험장화,제안을 승인하시겠습니까?,변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다.,시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠.,돔 건설 강행,,0,1,0,0,-50,0,0,0,0,0,윤리적 이유로 기각,,0,0,0,1,0,0,0,0,0,0,
29,극비,이계 군주의 외교,제안을 수락하시겠습니까?,이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다.,요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다.,거래 즉시 성사,0,0,0,0,0,-40,0,0,2,0,사절단 전원 사살,0,1,0,1,0,0,0,0,0,0 29,극비,이계 군주의 외교,제안을 수락하시겠습니까?,이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다.,요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다.,거래 즉시 성사,tag_alien_contact,0,0,0,0,0,-40,0,0,2,0,사절단 전원 사살,,0,1,0,1,0,0,0,0,0,0,
30,일상,믹스 커피 재고 고갈,어떻게 해결하시겠습니까?,국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다.,종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까?,최고급 원두 지원,-1,0,0,0,-10,0,0,0,0,0,헛소리 금지령,1,0,0,0,0,0,0,0,0,0 30,일상,믹스 커피 재고 고갈,어떻게 해결하시겠습니까?,국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다.,종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까?,최고급 원두 지원,,-1,0,0,0,-10,0,0,0,0,0,헛소리 금지령,,1,0,0,0,0,0,0,0,0,0,
31,위기,신화적 존재 강림,어떻게 대적하시겠습니까?,신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다!,최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까?,현실 조작기 가동,0,2,0,0,0,0,-50,0,0,0,모든 예비병력 투입,0,0,0,1,0,-60,0,0,0,0 31,위기,신화적 존재 강림,어떻게 대적하시겠습니까?,신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다!,최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까?,현실 조작기 가동,tag_madness,0,2,0,0,0,0,-50,0,0,0,모든 예비병력 투입,tag_sacrifice,0,0,0,1,0,-60,0,0,0,0,
,,,,,,,,,,,,,,,,,,,,,,,,,,, 32,신화,실험체의 노래,어떻게 조치하시겠습니까?,격리된 실험체 하나가 벽을 긁으며 알아들을 수 없는 노래를 흥얼거립니다.,직원들 사이에서 그 노래가 귓가에 맴돈다는 소문이 돕니다.,진정제 투여 및 격리 강화,,0,0,0,0,-15,0,0,0,0,0,그냥 무시하고 넘어간다,,0,1,0,0,0,0,0,0,0,0,C
33,신화,움직이는 조각상,어떻게 처리하시겠습니까?,지하 수조에서 건져올린 조각상이 자정마다 미세하게 위치를 바꿉니다.,CCTV 확인 결과 아무도 없었는데 조각상은 분명 돌아서 있었습니다.,석관에 봉인 후 콘크리트 매립,,0,0,0,0,-30,-10,0,0,0,0,일단 창고에 방치,,0,1,1,0,0,0,0,0,0,0,B
34,신화,심해의 알,부화를 막으시겠습니까?,심해 탐사팀이 인양한 알 하나가 부화 징후를 보입니다. 안에서 무언가 박동합니다.,생물학팀은 저것은 알이 아니라 심장이라고 보고했습니다.,즉각 냉동 처리 및 소각,,0,0,0,0,-50,-20,0,0,0,0,부화 과정 관찰 지속,tag_alien_contact,0,2,1,0,0,0,0,0,0,0,A
35,신화,장막 너머의 이름,이름을 들으시겠습니까?,장막 너머의 존재가 마침내 자신의 진짜 이름을 발음하기 시작했습니다. 듣는 요원들의 코피가 멈추지 않습니다.,언어병리학자는 이름의 세 음절만 듣고도 실어증에 걸렸습니다.,전 요원 청각 차단 및 격리 봉쇄,tag_madness,0,1,0,0,-70,-40,0,0,0,0,이름을 끝까지 듣고 기록한다,tag_alien_contact,0,3,2,0,0,0,0,0,0,0,S
36,조직저항,초과근무 항의,어떻게 무마하시겠습니까?,현장 요원 셋이 초과근무수당 미지급에 항의하며 보고서 제출을 하루 미뤘습니다.,정시 퇴근이 인권이라는 팻말을 봤습니다.,밀린 수당 즉시 지급,,0,0,0,0,-15,0,0,0,0,0,규정 운운하며 묵살,,1,0,0,0,0,0,0,0,0,0,C
37,조직저항,항명 성명,어떻게 대응하시겠습니까?,중간관리자 다섯이 연명으로 국장의 승인 절차에 대한 항명 성명을 냈습니다.,서명자 명단에 낯익은 이름이 보입니다. 승진 대상자였는데요.,일대일 면담으로 무마,,0,0,0,0,0,-25,0,0,0,0,주동자 색출 및 좌천,,2,0,0,0,0,0,0,0,0,0,B
38,조직저항,경비 태업,어떻게 수습하시겠습니까?,격리동 경비 인력 절반이 동시에 병가를 제출했습니다. 명백한 태업입니다.,다들 같은 날 같은 감기에 걸렸다고 합니다. 참으로 공교롭죠.,대체 인력 긴급 투입,,0,0,0,0,-30,-15,0,0,0,0,복귀할 때까지 방치,tag_coup,2,0,1,0,0,0,0,0,0,0,A
39,조직저항,대규모 사표,어떻게 진압하시겠습니까?,전 부서장급 열두 명이 동시 사표를 제출하며 내부 기밀을 언론에 흘리겠다 협박합니다.,이것은 항명이 아니라 쿠데타입니다.,전원 요구 수용 및 대규모 숙청,tag_ruthless,1,0,0,0,-60,-50,0,0,0,0,협박을 무시하고 강경 대응,tag_coup,3,1,0,0,0,0,0,0,0,0,S
40,사회공황,괴이한 목격담,어떻게 진화하시겠습니까?,동네 커뮤니티에 하늘에서 이상한 빛을 봤다는 글이 올라와 조회수가 치솟고 있습니다.,댓글 반응은 반반입니다. 반은 조작설 반은 목격담 추가.,여론 조작팀 투입해 진화,,0,0,0,0,0,0,-15,0,0,0,며칠 지나면 묻힐 겁니다,,0,0,1,0,0,0,0,0,0,0,C
41,사회공황,특집 뉴스 보도,어떻게 대응하시겠습니까?,한 지역 방송국이 괴수 목격담을 특집 뉴스로 다뤘습니다. 시청률이 세 배 뛰었습니다.,미스터리 특종이라는 자막이 하루 종일 반복 재생됩니다.,방송국 인수 후 정정 보도,,0,0,0,0,-30,0,0,0,0,0,허위 정보로 규정하고 방치,,0,0,2,0,0,0,0,0,0,0,B
42,사회공황,생필품 사재기,어떻게 진정시키시겠습니까?,대형 마트마다 생필품 사재기 행렬이 이어지고 있습니다. 폭동 조짐이 보입니다.,화장지 한 팩을 두고 몸싸움이 벌어졌다는 신고가 열 건째입니다.,비상 물자 방출 및 진정,,0,0,0,0,-40,0,-15,0,0,0,질서 유지는 각자 알아서,tag_panic_mob,1,0,2,0,0,0,0,0,0,0,A
43,사회공황,종말론 행진,어떻게 통제하시겠습니까?,종말론에 심취한 신도 수천 명이 본부 앞으로 행진하며 심판을 요구합니다.,그들의 팻말에는 이상하게도 정확한 좌표가 적혀 있었습니다.,전면 계엄 수준의 통제 발동,tag_ruthless,0,1,1,0,-50,-30,0,0,0,0,군중 앞에서 진실을 공개한다,tag_panic_mob,0,1,3,0,0,0,0,0,0,0,S
44,이사회압박,접대비 감사,어떻게 해명하시겠습니까?,감사실에서 지난달 접대비 영수증 몇 장을 문제 삼기 시작했습니다.,법인카드로 산 초밥이 감사 보고서 제목이 됐습니다.,영수증 재발급 후 해명,,0,0,0,0,-15,0,0,0,0,0,바쁘다는 핑계로 무시,,0,0,0,1,0,0,0,0,0,0,C
45,이사회압박,예산 전수조사,어떻게 대비하시겠습니까?,이사회가 예산 집행 내역 전수 조사를 예고했습니다.,회계팀 막내가 벌써부터 사직서 초안을 쓰고 있습니다.,회계 자료 사전 정리,,0,0,0,0,0,-25,0,0,0,0,조사에 순순히 응한다,,0,0,0,2,0,0,0,0,0,0,B
46,이사회압박,결재권 정지 논의,어떻게 방어하시겠습니까?,긴급 임시 이사회가 소집되어 국장의 결재권 일부 정지를 논의합니다.,의제 제목이 국장의 독단 경영에 대하여입니다.,핵심 임원 개별 설득,tag_corruption,0,0,0,0,-40,0,-15,0,0,0,정면으로 맞선다,,1,0,0,2,0,0,0,0,0,0,A
47,이사회압박,청문회 소환,어떻게 대응하시겠습니까?,청문회 소환장이 도착했습니다. 결재권 자체를 박탈하겠다는 초강경 안건입니다.,출석하지 않으면 그 자체로 유죄로 간주하겠다는 문구가 적혀 있습니다.,총력을 다해 청문회 방어,,0,0,0,1,-60,-30,-20,0,0,0,청문회를 보이콧한다,tag_political_pressure,1,1,0,3,0,0,0,0,0,0,S
1 id type keyword question text flavor left_text left_tag left_res left_ent left_pan left_rsk left_bud left_per left_inf left_wea left_for left_sur right_text right_tag right_res right_ent right_pan right_rsk right_bud right_per right_inf right_wea right_for right_sur grade
2 1 일상 요원들의 시위 발생 시위를 어떻게 처리하시겠습니까? B구역 하급 요원들이 예산 삭감에 반발해 장비 반납 시위를 하고 있습니다. 우리가 세상을 구하는데 월급이 깎인다고요? 촉수에 맞아 죽으나 굶어 죽으나 똑같습니다! 무력 진압 tag_ruthless 1 0 0 0 0 -10 /a 0 0 0 0 요구 수용 0 0 0 0 -20 0 0 0 0 0
3 2 위기 격리실 오염 발생 오염을 어떻게 정화하시겠습니까? 지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다. 보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다. 네이팜 소각 강행 tag_coverup 0 0 0 0 0 -30 0 0 0 0 조사팀 파견 0 1 0 1 0 0 0 0 0 0
4 3 일상 촉수 괴물 출현 출동 승인하시겠습니까? 7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다. 규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다. 내역 위조 후 출동 0 0 0 1 0 0 -10 0 0 0 규정대로 반려 1 1 0 0 0 0 0 0 0 0
5 4 극비 변이 DNA 이식 제안 실험을 승인하시겠습니까? 진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠. 인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠. 실험 승인 tag_madness 0 2 0 0 40 40 0 0 0 0 제안 거절 1 0 0 0 0 0 0 0 0 0
6 5 작전 전술 폭격 건의 폭격을 승인하시겠습니까? 철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다. 희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠. 궤도 폭격 승인 tag_sacrifice 0 0 0 2 0 0 0 0 2 0 작전 기각 1 0 0 0 0 0 0 0 0 0
7 6 일상 부산물 암시장 매각 매각을 허가하시겠습니까? 차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다. 이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠. 매각 허가 tag_corruption 0 0 0 1 30 0 0 0 0 0 원칙대로 소각 0 0 0 0 -10 0 0 0 0 0
8 7 작전 존재 양지화 프로젝트 프로젝트를 승인하시겠습니까? 대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다. 우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다. 감시망 구축 tag_coverup 0 0 0 0 0 0 -40 0 0 2 프로젝트 보류 1 0 0 0 0 0 0 0 0 0
9 8 극비 대규모 도서관 건립 예산을 배정하시겠습니까? 고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다. 이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다. 예산 배정 0 0 0 0 -40 0 40 0 0 0 예산 전액 삭감 1 0 0 1 0 0 0 0 0 0
10 9 위기 이단 색출 마녀사냥 어떻게 개입하시겠습니까? 기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다. 더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로! 적당히 묵인한다 1 0 0 0 0 -30 0 0 0 0 군사력으로 제압 0 0 0 1 0 0 -20 0 0 0
11 10 일상 변칙 현상 영상 유출 어떻게 처리하시겠습니까? 한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다. 대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오. 제거 절차 승인 tag_coverup 0 0 0 -1 0 0 -20 0 0 0 영상만 삭제 0 1 0 0 0 0 0 0 0 0
12 11 위기 사이비 종교 인신 공양 어떻게 대응하시겠습니까? 특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다. 위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다. 군사 개입 지시 1 0 1 0 0 -40 0 0 0 0 의식 방관 tag_cult 0 2 0 0 0 0 0 0 0 0
13 12 위기 의회의 긴급 감사 어떻게 대처하시겠습니까? 의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다! 세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오. 은폐 공작 실행 tag_corruption 0 0 0 1 0 0 -40 0 0 0 횡령 사실 인정 0 0 0 2 0 0 0 0 0 0
14 13 일상 연구 부서 파업 조짐 요구를 수용하시겠습니까? 연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다. 저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요. 특별 보너스 지급 -1 0 0 0 -30 0 0 0 0 0 요구 전면 묵살 1 0 0 0 0 0 0 0 0 0
15 14 작전 이계 무역로 개방 무역로를 개방하시겠습니까? 이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다. 저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠. 게이트 강제 개방 tag_alien_contact 0 1 0 0 50 0 0 0 0 0 폐쇄 상태 유지 1 0 0 0 0 0 0 0 0 0
16 15 극비 전임 국장 비밀 금고 금고를 개방하시겠습니까? 전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다. 전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다. 강제 개방 지시 tag_corruption 0 0 0 0 20 -30 0 1 0 0 영구 봉인 0 0 0 0 0 0 0 0 0 0
17 16 작전 메가코프 협박 작전 작전을 실행하시겠습니까? 글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다. 그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까. 작전 실행 0 0 0 0 0 0 -30 2 0 0 작전 취소 1 0 0 0 0 0 0 0 0 0
18 17 위기 전술 폭격 건의 폭격을 승인하시겠습니까? 제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요? 시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다. 시민 포기 및 폭격 tag_ruthless 0 -1 0 2 0 0 0 0 0 0 결사 방어 명령 0 0 0 0 -40 -40 0 0 0 0
19 18 일상 행정부 파업 예고 어떻게 대응하시겠습니까? 새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다. 기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다. 예산 전용으로 무마 0 0 0 1 -20 0 0 0 0 0 파업 강경 대응 1 0 0 0 0 0 0 0 0 0
20 19 극비 불침번 요원 개발 배치하시겠습니까? 비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다. 잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠. 실전 즉각 배치 0 1 0 0 0 0 20 0 1 0 즉각 폐기 처분 1 0 0 0 -10 0 0 0 0 0
21 20 일상 내부 스파이 첩보 어떻게 조사하시겠습니까? 기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다. 누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다. 대규모 사찰 시작 tag_political_pressure 2 0 0 0 0 0 30 0 0 0 증거 부족으로 덮음 0 0 0 1 0 0 0 0 0 0
22 21 작전 비정상적 위기 발생 국장님, 결단이 필요합니다. 국장님 결단이 필요합니다 순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다. 괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다. 수용소 건립 승인 tag_ruthless 0 0 0 0 -40 0 0 0 1 0 제안 단호히 거절 1 0 0 1 0 0 0 0 0 0
23 22 극비 이계의 유물 인양 유물을 어떻게 처리하시겠습니까? 고대 공존당의 사제들이 심해의 유물을 인양했습니다. 만지는 자의 정신을 갉아먹지만 막대한 지식을 줍니다. 약간의 두통만 참으면 이계의 비밀을 모두 알 수 있습니다. 요원 하나쯤 미쳐도 괜찮습니다. 유물 집중 연구 tag_alien_contact 0 1 0 0 0 0 50 0 0 0 유물 파괴 명령 1 0 0 1 0 0 0 0 0 0
24 23 위기 이계의 주시자 출현 대중을 어떻게 통제하시겠습니까? 얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다. 하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오. 인터넷 전면 차단 2 0 1 0 0 0 0 0 0 2 진정제 무상 배포 0 0 0 0 -50 0 0 0 0 0
25 24 일상 요원 집단 트라우마 어떻게 지원하시겠습니까? 요원들이 괴수 진압 후 심각한 트라우마에 시달리고 있습니다. 심리 치료 지원이 시급합니다. 밤마다 촉수가 목을 조르는 악몽을 꾼답니다. 차라리 그 부분 기억을 지워버릴까요? 정신과 치료 지원 0 0 0 0 -20 10 0 0 0 0 트라우마 기억 소거 0 1 0 1 0 0 0 0 0 0
26 25 극비 내부 고발자 발생 어떻게 막으시겠습니까? 의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다. 국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다. 사고사 위장 지시 tag_coverup 0 0 0 -1 0 0 -30 0 0 0 폭로전 정면 돌파 0 0 0 2 0 0 0 0 0 0
27 26 위기 차원 붕괴 폭탄 오작동 어떻게 대응하시겠습니까? 차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다. 최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다. 특수 해체팀 투입 tag_sacrifice 0 0 0 0 0 -40 0 0 0 0 폭발 방관 및 대피 0 2 0 0 0 0 0 0 0 0
28 27 작전 PMC 흡수 합병 결탁하시겠습니까? 군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다. 돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠. 결탁 및 흡수 합병 0 0 0 0 -40 0 0 0 2 0 독자적 노선 유지 1 0 0 0 0 0 0 0 0 0
29 28 작전 대규모 거주 구역 실험장화 제안을 승인하시겠습니까? 변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다. 시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠. 돔 건설 강행 0 1 0 0 -50 0 0 0 0 0 윤리적 이유로 기각 0 0 0 1 0 0 0 0 0 0
30 29 극비 이계 군주의 외교 제안을 수락하시겠습니까? 이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다. 요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다. 거래 즉시 성사 tag_alien_contact 0 0 0 0 0 -40 0 0 2 0 사절단 전원 사살 0 1 0 1 0 0 0 0 0 0
31 30 일상 믹스 커피 재고 고갈 어떻게 해결하시겠습니까? 국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다. 종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까? 최고급 원두 지원 -1 0 0 0 -10 0 0 0 0 0 헛소리 금지령 1 0 0 0 0 0 0 0 0 0
32 31 위기 신화적 존재 강림 어떻게 대적하시겠습니까? 신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다! 최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까? 현실 조작기 가동 tag_madness 0 2 0 0 0 0 -50 0 0 0 모든 예비병력 투입 tag_sacrifice 0 0 0 1 0 -60 0 0 0 0
33 32 신화 실험체의 노래 어떻게 조치하시겠습니까? 격리된 실험체 하나가 벽을 긁으며 알아들을 수 없는 노래를 흥얼거립니다. 직원들 사이에서 그 노래가 귓가에 맴돈다는 소문이 돕니다. 진정제 투여 및 격리 강화 0 0 0 0 -15 0 0 0 0 0 그냥 무시하고 넘어간다 0 1 0 0 0 0 0 0 0 0 C
34 33 신화 움직이는 조각상 어떻게 처리하시겠습니까? 지하 수조에서 건져올린 조각상이 자정마다 미세하게 위치를 바꿉니다. CCTV 확인 결과 아무도 없었는데 조각상은 분명 돌아서 있었습니다. 석관에 봉인 후 콘크리트 매립 0 0 0 0 -30 -10 0 0 0 0 일단 창고에 방치 0 1 1 0 0 0 0 0 0 0 B
35 34 신화 심해의 알 부화를 막으시겠습니까? 심해 탐사팀이 인양한 알 하나가 부화 징후를 보입니다. 안에서 무언가 박동합니다. 생물학팀은 저것은 알이 아니라 심장이라고 보고했습니다. 즉각 냉동 처리 및 소각 0 0 0 0 -50 -20 0 0 0 0 부화 과정 관찰 지속 tag_alien_contact 0 2 1 0 0 0 0 0 0 0 A
36 35 신화 장막 너머의 이름 이름을 들으시겠습니까? 장막 너머의 존재가 마침내 자신의 진짜 이름을 발음하기 시작했습니다. 듣는 요원들의 코피가 멈추지 않습니다. 언어병리학자는 이름의 세 음절만 듣고도 실어증에 걸렸습니다. 전 요원 청각 차단 및 격리 봉쇄 tag_madness 0 1 0 0 -70 -40 0 0 0 0 이름을 끝까지 듣고 기록한다 tag_alien_contact 0 3 2 0 0 0 0 0 0 0 S
37 36 조직저항 초과근무 항의 어떻게 무마하시겠습니까? 현장 요원 셋이 초과근무수당 미지급에 항의하며 보고서 제출을 하루 미뤘습니다. 정시 퇴근이 인권이라는 팻말을 봤습니다. 밀린 수당 즉시 지급 0 0 0 0 -15 0 0 0 0 0 규정 운운하며 묵살 1 0 0 0 0 0 0 0 0 0 C
38 37 조직저항 항명 성명 어떻게 대응하시겠습니까? 중간관리자 다섯이 연명으로 국장의 승인 절차에 대한 항명 성명을 냈습니다. 서명자 명단에 낯익은 이름이 보입니다. 승진 대상자였는데요. 일대일 면담으로 무마 0 0 0 0 0 -25 0 0 0 0 주동자 색출 및 좌천 2 0 0 0 0 0 0 0 0 0 B
39 38 조직저항 경비 태업 어떻게 수습하시겠습니까? 격리동 경비 인력 절반이 동시에 병가를 제출했습니다. 명백한 태업입니다. 다들 같은 날 같은 감기에 걸렸다고 합니다. 참으로 공교롭죠. 대체 인력 긴급 투입 0 0 0 0 -30 -15 0 0 0 0 복귀할 때까지 방치 tag_coup 2 0 1 0 0 0 0 0 0 0 A
40 39 조직저항 대규모 사표 어떻게 진압하시겠습니까? 전 부서장급 열두 명이 동시 사표를 제출하며 내부 기밀을 언론에 흘리겠다 협박합니다. 이것은 항명이 아니라 쿠데타입니다. 전원 요구 수용 및 대규모 숙청 tag_ruthless 1 0 0 0 -60 -50 0 0 0 0 협박을 무시하고 강경 대응 tag_coup 3 1 0 0 0 0 0 0 0 0 S
41 40 사회공황 괴이한 목격담 어떻게 진화하시겠습니까? 동네 커뮤니티에 하늘에서 이상한 빛을 봤다는 글이 올라와 조회수가 치솟고 있습니다. 댓글 반응은 반반입니다. 반은 조작설 반은 목격담 추가. 여론 조작팀 투입해 진화 0 0 0 0 0 0 -15 0 0 0 며칠 지나면 묻힐 겁니다 0 0 1 0 0 0 0 0 0 0 C
42 41 사회공황 특집 뉴스 보도 어떻게 대응하시겠습니까? 한 지역 방송국이 괴수 목격담을 특집 뉴스로 다뤘습니다. 시청률이 세 배 뛰었습니다. 미스터리 특종이라는 자막이 하루 종일 반복 재생됩니다. 방송국 인수 후 정정 보도 0 0 0 0 -30 0 0 0 0 0 허위 정보로 규정하고 방치 0 0 2 0 0 0 0 0 0 0 B
43 42 사회공황 생필품 사재기 어떻게 진정시키시겠습니까? 대형 마트마다 생필품 사재기 행렬이 이어지고 있습니다. 폭동 조짐이 보입니다. 화장지 한 팩을 두고 몸싸움이 벌어졌다는 신고가 열 건째입니다. 비상 물자 방출 및 진정 0 0 0 0 -40 0 -15 0 0 0 질서 유지는 각자 알아서 tag_panic_mob 1 0 2 0 0 0 0 0 0 0 A
44 43 사회공황 종말론 행진 어떻게 통제하시겠습니까? 종말론에 심취한 신도 수천 명이 본부 앞으로 행진하며 심판을 요구합니다. 그들의 팻말에는 이상하게도 정확한 좌표가 적혀 있었습니다. 전면 계엄 수준의 통제 발동 tag_ruthless 0 1 1 0 -50 -30 0 0 0 0 군중 앞에서 진실을 공개한다 tag_panic_mob 0 1 3 0 0 0 0 0 0 0 S
45 44 이사회압박 접대비 감사 어떻게 해명하시겠습니까? 감사실에서 지난달 접대비 영수증 몇 장을 문제 삼기 시작했습니다. 법인카드로 산 초밥이 감사 보고서 제목이 됐습니다. 영수증 재발급 후 해명 0 0 0 0 -15 0 0 0 0 0 바쁘다는 핑계로 무시 0 0 0 1 0 0 0 0 0 0 C
46 45 이사회압박 예산 전수조사 어떻게 대비하시겠습니까? 이사회가 예산 집행 내역 전수 조사를 예고했습니다. 회계팀 막내가 벌써부터 사직서 초안을 쓰고 있습니다. 회계 자료 사전 정리 0 0 0 0 0 -25 0 0 0 0 조사에 순순히 응한다 0 0 0 2 0 0 0 0 0 0 B
47 46 이사회압박 결재권 정지 논의 어떻게 방어하시겠습니까? 긴급 임시 이사회가 소집되어 국장의 결재권 일부 정지를 논의합니다. 의제 제목이 국장의 독단 경영에 대하여입니다. 핵심 임원 개별 설득 tag_corruption 0 0 0 0 -40 0 -15 0 0 0 정면으로 맞선다 1 0 0 2 0 0 0 0 0 0 A
48 47 이사회압박 청문회 소환 어떻게 대응하시겠습니까? 청문회 소환장이 도착했습니다. 결재권 자체를 박탈하겠다는 초강경 안건입니다. 출석하지 않으면 그 자체로 유죄로 간주하겠다는 문구가 적혀 있습니다. 총력을 다해 청문회 방어 0 0 0 1 -60 -30 -20 0 0 0 청문회를 보이콧한다 tag_political_pressure 1 1 0 3 0 0 0 0 0 0 S

123
client/src/data/endings.js Normal file
View File

@@ -0,0 +1,123 @@
// 멀티 엔딩 판별기 (Phase 4)
// 우선순위: 의회 정치 엔딩 > 내러티브 연쇄 엔딩 > 변주된 붕괴 엔딩 > 기본 붕괴 엔딩
// 의회 수장 성향 축 — 상임 이사회 8대 이념(§4.1) 중 기존 자원 태그로 매핑 가능한 3개 축
export const CHAIRMAN_AXES = ['force', 'wealth', 'surveillance'];
// 내러티브 연쇄 엔딩: 4종 오염 카드가 이 순서로(연속일 필요는 없음) 한 번씩 등장해야 완성된다
const NARRATIVE_CHAIN_SEQUENCE = ['신화', '조직저항', '사회공황', '이사회압박'];
export const isNarrativeChainComplete = (sequence) => {
let i = 0;
for (const type of sequence) {
if (type === NARRATIVE_CHAIN_SEQUENCE[i]) i++;
if (i === NARRATIVE_CHAIN_SEQUENCE.length) return true;
}
return false;
};
const PARAM_LABEL = { resistance: '내부 저항', entropy: '엔트로피', panic: '대중 공황' };
export const ENDING_CATALOG = [
{ id: 'bankruptcy', title: '파산 문책 (조용한 해임)',
desc: '텅 빈 금고와 산더미 같은 미결 청구서 앞에서 이사회는 더 이상 웃지 않았습니다. 세상이 끝나기도 전에, 당신의 자리가 먼저 끝났습니다.',
reason: '연속된 재정 파탄 경고를 개선하지 못함' },
{ id: 'council_victory', title: '의회 정치 엔딩 (그림자 뒤의 승리)',
desc: '탄핵안은 가결되었지만, 이사회 의장은 이미 당신이 쌓아온 힘 앞에 고개를 숙였습니다. 해임장에 서명하는 손이 떨리고 있었습니다. 자리는 잃었지만, 진짜 권력은 잃지 않았습니다.',
reason: '탄핵 가결 + 의회 의장 성향 조건 충족' },
{ id: 'narrative_chain', title: '내러티브 연쇄 엔딩 (진실의 연쇄)',
desc: '신화, 조직의 균열, 대중의 광기, 권력의 사슬 — 당신은 이 모든 사건이 하나의 손에서 비롯되었다는 진실을 끝내 마주했습니다. 아무도 믿지 않겠지만, 적어도 당신만은 알고 있습니다.',
reason: '신화 → 조직저항 → 사회공황 → 이사회압박 순서로 내러티브 사건 수집' },
{ id: 'impeachment', title: '정치적 탄핵 (윗선 붕괴)',
desc: '그림자 위원회가 당신의 권한을 박탈했습니다. 당신의 모든 업적은 지워질 것입니다.',
reason: '주간 의회에서 적대적 파벌 임원 9명 이상 찬성' },
{ id: 'resistance_base', title: '사임 (내부 붕괴)',
desc: '조직원들의 불만이 폭발하여 폭동이 일어났습니다. 당신은 자리에서 끌려 내려왔습니다.',
reason: '내부 저항 파라미터 5단계 도달' },
{ id: 'resistance_coup', title: '군사 쿠데타 (내부 붕괴)',
desc: '무력을 손에 쥔 자들은 더 이상 결재를 기다리지 않았습니다. 총구가 당신의 사무실을 겨눴습니다.',
reason: '내부 저항 5단계 + 무력 태그 다량 보유' },
{ id: 'resistance_flee', title: '야반도주 (내부 붕괴)',
desc: '텅 빈 급여 계좌를 확인한 요원들이 하나둘 사라졌습니다. 마지막까지 남은 건 당신과 꺼진 사무실 불빛뿐이었습니다.',
reason: '내부 저항 5단계 + 인력 토큰 심각한 마이너스' },
{ id: 'entropy_base', title: '세계 멸망 (외부 붕괴)',
desc: '장막이 찢어지고 이계의 존재들이 쏟아져 들어옵니다. 우리가 알던 세계는 끝났습니다.',
reason: '엔트로피 파라미터 5단계 도달' },
{ id: 'entropy_coverup', title: '은폐된 멸망 (외부 붕괴)',
desc: '언론과 감시망을 총동원해 진실을 덮었지만, 덮은 것은 진실이 아니라 탈출할 시간이었습니다. 세계는 아무도 모르게 끝났습니다.',
reason: '엔트로피 5단계 + 감시 태그 다량 보유' },
{ id: 'entropy_blackout', title: '정보 암흑 (외부 붕괴)',
desc: '정보망이 완전히 말라붙은 순간, 당신은 세계가 무너지는 소리조차 듣지 못했습니다.',
reason: '엔트로피 5단계 + 정보 토큰 심각한 마이너스' },
{ id: 'panic_base', title: '문명 붕괴 (사회 붕괴)',
desc: '대중은 광기에 휩싸였고, 문명은 스스로를 파괴했습니다. 통제 불능의 시대가 도래했습니다.',
reason: '대중 공황 파라미터 5단계 도달' },
{ id: 'panic_capitalism', title: '자본의 폭주 (사회 붕괴)',
desc: '재난 앞에서도 시장은 웃었습니다. 부가 쌓일수록 거리는 비어갔고, 마지막엔 돈을 셀 사람조차 남지 않았습니다.',
reason: '대중 공황 5단계 + 재력 태그 다량 보유' },
{ id: 'panic_riot', title: '긴축의 폭동 (사회 붕괴)',
desc: '텅 빈 금고를 본 대중은 더 이상 설명을 기다리지 않았습니다.',
reason: '대중 공황 5단계 + 예산 토큰 심각한 마이너스' },
{ id: 'sudden_collapse', title: '돌이킬 수 없는 순간 (즉각 붕괴)',
desc: '망설임 없는 결정 한 번이 모든 단계를 건너뛰었습니다. 경고도, 예고도 없이 — 그저 끝났습니다.',
reason: '단일 결정으로 파라미터가 급격히 5단계에 도달' },
{ id: 'term_expired', title: '임기 만료 (조용한 퇴장)',
desc: '붕괴도, 탄핵도 없었습니다. 그저 라운드가 거듭될수록 결재할 시간이 줄어들었고, 결국 정례회의 상정 건수가 바닥났습니다. 세상은 여전히 위태롭지만, 당신의 임기만은 조용히 끝났습니다.',
reason: '라운드가 거듭되며 정례회의까지의 결재 건수가 0이 됨' },
{ id: 'unidentified', title: '미확인 종말',
desc: '기록되지 않은 파멸입니다.',
reason: '알 수 없는 원인' },
];
const byId = Object.fromEntries(ENDING_CATALOG.map((e) => [e.id, e]));
export const getEndingById = (id) => byId[id] || byId.unidentified;
const VARIANT_MAP = {
resistance: [
{ id: 'resistance_coup', condition: (c) => c.tags.force >= 3 },
{ id: 'resistance_flee', condition: (c) => c.tokens.personnel <= -50 },
],
entropy: [
{ id: 'entropy_coverup', condition: (c) => c.tags.surveillance >= 3 },
{ id: 'entropy_blackout', condition: (c) => c.tokens.info <= -50 },
],
panic: [
{ id: 'panic_capitalism', condition: (c) => c.tags.wealth >= 3 },
{ id: 'panic_riot', condition: (c) => c.tokens.budget <= -50 },
],
};
const BASE_MAP = { resistance: 'resistance_base', entropy: 'entropy_base', panic: 'panic_base' };
// ctx: { trigger: 'impeachment' | 'collapse', params, tags, tokens, lastDelta, chairmanAxis, narrativeChainUnlocked }
export function resolveEnding(ctx) {
if (ctx.trigger === 'impeachment') {
if (ctx.chairmanAxis && ctx.tags[ctx.chairmanAxis] >= 5) return byId.council_victory;
if (ctx.narrativeChainUnlocked) return byId.narrative_chain;
return byId.impeachment;
}
if (ctx.narrativeChainUnlocked) return byId.narrative_chain;
const param = ['resistance', 'entropy', 'panic'].find((p) => ctx.params[p] >= 5);
if (!param) return byId.unidentified;
if (ctx.lastDelta && ctx.lastDelta.param === param && ctx.lastDelta.amount >= 3) {
return { ...byId.sudden_collapse, reason: `${PARAM_LABEL[param]} 단일 결정으로 급격히 5단계 도달` };
}
const variant = (VARIANT_MAP[param] || []).find((v) => v.condition(ctx));
if (variant) return byId[variant.id];
return byId[BASE_MAP[param]];
}

View File

@@ -3,36 +3,80 @@
"id": "tag_ruthless", "id": "tag_ruthless",
"name": "피도 눈물도 없는 진압", "name": "피도 눈물도 없는 진압",
"icon": "🩸", "icon": "🩸",
"description": "반발을 무력으로 짓밟았습니다. 공포는 훌륭한 통제 수단입니다." "description": "반발을 무력으로 짓밟았습니다. 공포는 훌륭한 통제 수단입니다.",
"snippet": "진압 대응 인력 214명 투입. 민간인 부상 신고는 '작전 지연 방지' 사유로 접수 목록에서 삭제 처리됨.",
"snippet_type": "system"
}, },
{ {
"id": "tag_coverup", "id": "tag_coverup",
"name": "진실의 장막", "name": "진실의 장막",
"icon": "🤫", "icon": "🤫",
"description": "대중이 알지 못하도록 변칙 사건을 완벽하게 은폐했습니다." "description": "대중이 알지 못하도록 변칙 사건을 완벽하게 은폐했습니다.",
"snippet": "변칙 목격 신고 63건, 언론 자문위 검토 후 '기상 이상 현상'으로 통합 발표 승인됨.",
"snippet_type": "system"
}, },
{ {
"id": "tag_alien_contact", "id": "tag_alien_contact",
"name": "금지된 지식", "name": "금지된 지식",
"icon": "👁️", "icon": "👁️",
"description": "이계의 존재와 거래하거나 그들의 유물을 받아들였습니다. 장막 너머의 눈이 당신을 주시합니다." "description": "이계의 존재와 거래하거나 그들의 유물을 받아들였습니다. 장막 너머의 눈이 당신을 주시합니다.",
"snippet": "압수된 이계 유물 1점, 등급 미분류 상태로 특수연구소 이관됨.",
"snippet_type": "system"
}, },
{ {
"id": "tag_corruption", "id": "tag_corruption",
"name": "검은 뒷돈", "name": "검은 뒷돈",
"icon": "💼", "icon": "💼",
"description": "공적인 자산을 사적으로 유용하거나 부당한 거래를 승인했습니다." "description": "공적인 자산을 사적으로 유용하거나 부당한 거래를 승인했습니다.",
"snippet": "결산 불일치 340만 원, 담당 경리 개인 계좌 이체로 충당된 정황 확인. 사유 불문 처리됨.",
"snippet_type": "human"
}, },
{ {
"id": "tag_madness", "id": "tag_madness",
"name": "이성의 붕괴", "name": "이성의 붕괴",
"icon": "🌀", "icon": "🌀",
"description": "요원들을 비인도적인 실험과 광기에 노출시켰습니다. 인간성은 점점 희미해집니다." "description": "요원들을 비인도적인 실험과 광기에 노출시켰습니다. 인간성은 점점 희미해집니다.",
"snippet": "실험 참여 요원, 심리 평가 미제출 24개월째로 현장 배치 유지 확인. 재평가 요청 반려됨.",
"snippet_type": "human"
}, },
{ {
"id": "tag_cult", "id": "tag_cult",
"name": "사이비의 그림자", "name": "사이비의 그림자",
"icon": "🌑", "icon": "🌑",
"description": "고대 신을 숭배하는 종교와 엮이기 시작했습니다. 그들의 교리가 스며듭니다." "description": "고대 신을 숭배하는 종교와 엮이기 시작했습니다. 그들의 교리가 스며듭니다.",
"snippet": "신흥 종교단체 신도 수 3,400명 규모로 집계. 포교 활동 규제 안건은 종교자유 조항 검토 사유로 반려됨.",
"snippet_type": "system"
},
{
"id": "tag_coup",
"name": "쿠데타의 그림자",
"icon": "🗡️",
"description": "권력자들이 등을 돌리기 시작했습니다. 칼끝이 서서히 당신을 향하고 있습니다.",
"snippet": "국장 신임 재투표 요구 서명 9명분 접수. 인사위원회 소집 안건은 '정족수 미달'로 미반영됨.",
"snippet_type": "system"
},
{
"id": "tag_panic_mob",
"name": "군중의 광기",
"icon": "😱",
"description": "이성을 잃은 대중이 거리를 뒤덮었습니다. 더 이상 통제할 수 없습니다.",
"snippet": "자경단 관련 신고 118건, 담당 부서 인력 부족을 사유로 전건 내부 종결됨.",
"snippet_type": "system"
},
{
"id": "tag_political_pressure",
"name": "정치적 압박",
"icon": "⚖️",
"description": "이사회가 당신의 자리를 노리고 있습니다. 숫자 하나하나가 무기가 됩니다.",
"snippet": "이사회 특별 감사 요청 안건 5건 상정. 국장 관련 항목은 '자료 미비'로 반려됨.",
"snippet_type": "system"
},
{
"id": "tag_sacrifice",
"name": "희생의 대가",
"icon": "⚰️",
"description": "누군가의 목숨과 맞바꾼 결정이었습니다. 그 무게는 사라지지 않습니다.",
"snippet": "현장 요원 1명, 격리 프로토콜을 위반해 통로를 개방한 뒤 실종 처리됨.",
"snippet_type": "human"
} }
] ]

View File

@@ -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 };

View File

@@ -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(' · ');

View File

@@ -84,8 +84,6 @@
- 우: 위험 단계 (엔트로피, 내부 저항, 위험 수용 계수) 상승 - 우: 위험 단계 (엔트로피, 내부 저항, 위험 수용 계수) 상승
- 공통 획득 - 공통 획득
- 내러티브 태그: 스토리 전달 + 엔딩 조건 중 하나 - 내러티브 태그: 스토리 전달 + 엔딩 조건 중 하나
- 연대기 시스템 (Chronicle)
- 누적된 내러티브 태그를 터치하면, 플레이어가 지금까지 세상을 어떻게 망치고 구해왔는지 연대기 형태의 히스토리/텍스트로 확인 가능. (플레이어의 목적의식과 내러티브 몰입도 강화)
- 성향 태그: 파벌과의 우호도 변화 - 성향 태그: 파벌과의 우호도 변화
- 덱 오염 (토큰을 마이너스) - 덱 오염 (토큰을 마이너스)
- 그림자 의회가 끝날때 마다 엔트로피 단계(1~5단계)에 따라 '신화 카드' 추가 - 그림자 의회가 끝날때 마다 엔트로피 단계(1~5단계)에 따라 '신화 카드' 추가
@@ -174,6 +172,9 @@
- 태그 0~3개: 페이즈1 (사태 파악 및 은폐의 시작) - 태그 0~3개: 페이즈1 (사태 파악 및 은폐의 시작)
- 태그 4~7개: 페이즈2 (프로젝트 가동 및 이사회의 개입 본격화) - 태그 4~7개: 페이즈2 (프로젝트 가동 및 이사회의 개입 본격화)
- 태그 8개 이상: 페이즈3 (통제 불능의 사태 및 종말의 카운트다운) - 태그 8개 이상: 페이즈3 (통제 불능의 사태 및 종말의 카운트다운)
- 연대기 (Chronicle)
- 국장이 결재한 카드와 그에 대한 선택(좌/우/위)을 시간순으로 기록하는 결정 장부. 내러티브 태그가 '세계의 결과'라면, 연대기는 '국장의 원인 이력'이다.
- 내러티브 태그(세계의 상흔)와는 별도의 데이터·별도의 진입점(로그 버튼)으로 관리한다.
## 라운드 플로우차트 (Round Flowchart) ## 라운드 플로우차트 (Round Flowchart)

View File

@@ -19,7 +19,7 @@ UN 상임이사국과 글로벌 메가코프들이 비밀리에 설립한 초법
**모래성 무너뜨리기 (The Sandcastle)** **모래성 무너뜨리기 (The Sandcastle)**
국장은 재력, 무력, 감시라는 영구 자원 태그를 모아 거대한 통제 엔진을 구축합니다. 하지만 세계의 엔트로피가 차오를수록, 이사회의 표적 규약과 쏟아지는 뉴스 속보가 당신이 공들여 쌓은 방어벽을 잔인하게 허물어뜨립니다. 국장은 재력, 무력, 감시라는 영구 자원 태그를 모아 거대한 통제 엔진을 구축합니다. 하지만 세계의 엔트로피가 차오를수록, 이사회의 표적 규약과 쏟아지는 뉴스 속보가 당신이 공들여 쌓은 방어벽을 잔인하게 허물어뜨립니다.
**연대기의 편찬 (The Chronicle)** **연대기의 편찬 (The Chronicle)**
국장은 끝없이 반복되는 루프 속에서 단편적인 무작위 내러티브 태그들을 마주합니다. 국장은 이 혼란스러운 단서들을 수집(Collect)하고, 얽혀있는 파벌 간의 인과를 조직화(Organize)하며, 멸망의 진정한 원인을 추출(Distill)하여 최종 결재라는 형태로 세계에 표현(Express)해 나갑니다. 이 지독한 과정은 플레이어의 목적의식을 강화하는 한 편의 텍스트 연대기(Chronicle)로 기록됩니다. 국장의 모든 결재는 되돌릴 수 없는 기록으로 남습니다. 세계의 상흔(내러티브 태그)이 '무엇이 벌어졌는가'를 말한다면, 연대기는 '누가 그렇게 결정했는가'를 말합니다. 플레이어는 회차가 끝난 뒤 이 장부를 되짚으며, 멸망이 사고가 아니라 자신의 서명들이 쌓인 결과였음을 마주합니다.
## 3. 메인 시나리오: 종말의 3단계 페이징 (Story Paging) ## 3. 메인 시나리오: 종말의 3단계 페이징 (Story Paging)
시나리오는 획득한 [내러티브 태그]의 누적 개수에 따라 기구의 토큰 경제가 파탄 나는 3가지 페이즈로 진행됩니다. 시나리오는 획득한 [내러티브 태그]의 누적 개수에 따라 기구의 토큰 경제가 파탄 나는 3가지 페이즈로 진행됩니다.
### 페이즈 1: 사태 파악 및 은폐의 시작 (태그 0~3개) ### 페이즈 1: 사태 파악 및 은폐의 시작 (태그 0~3개)

View File

@@ -65,7 +65,9 @@
- **페이즈 3 (태그 8개 이상)**: 종말 카운트다운. 극한의 오염 카드와 뉴스 속보가 쏟아지는 자원 고갈의 생존 구간. - **페이즈 3 (태그 8개 이상)**: 종말 카운트다운. 극한의 오염 카드와 뉴스 속보가 쏟아지는 자원 고갈의 생존 구간.
## 5. 최종 목표: 멀티 엔딩 매트릭스 도감 ## 5. 최종 목표: 멀티 엔딩 매트릭스 도감
다양한 엔딩을 수집하여 [연대기 시스템]을 통해 플레이어의 히스토리를 시각화합니다. 다양한 엔딩을 수집하여 도감에 기록합니다. 회차의 여정은 두 축으로 분리되어 기록됩니다.
- **연대기(Chronicle)**: 국장이 결재한 카드와 좌/우/위 선택을 시간순으로 기록한 결정 이력.
- **내러티브 태그**: 그 결재의 결과로 세계에 남은 상흔이자 엔딩 조건. 연대기와는 별개로 관리·저장됩니다.
- **수집해야 할 엔딩 카테고리**: - **수집해야 할 엔딩 카테고리**:
- 기본 4대 붕괴 엔딩 (사임, 멸망, 문명 붕괴, 탄핵) - 기본 4대 붕괴 엔딩 (사임, 멸망, 문명 붕괴, 탄핵)

View File

@@ -46,7 +46,7 @@
- **작업 항목**: - **작업 항목**:
- **엔딩 분기 시스템**: 파라미터 5단계 도달 시, 현재 보유한 토큰/자원 태그를 검사하여 기본 붕괴 엔딩 또는 변주된 엔딩으로 분기시키는 판별기 구현. - **엔딩 분기 시스템**: 파라미터 5단계 도달 시, 현재 보유한 토큰/자원 태그를 검사하여 기본 붕괴 엔딩 또는 변주된 엔딩으로 분기시키는 판별기 구현.
- **특수 엔딩 트리거**: 극단적 파라미터, 내러티브 연쇄, 의회 정치 조건 달성 시 발동하는 조기 엔딩 이벤트 구현. - **특수 엔딩 트리거**: 극단적 파라미터, 내러티브 연쇄, 의회 정치 조건 달성 시 발동하는 조기 엔딩 이벤트 구현.
- **도감 및 연대기(Chronicle)**: 해금된 엔딩을 저장하는 도감 UI 구현 및 이번 회차에서 획득한 내러티브 태그를 히스토리 텍스트로 나열해 주는 연대기 기능 구현. - **도감 및 연대기(Chronicle)**: 해금된 엔딩을 저장하는 도감 UI 구현. 연대기 기능(결재 카드·선택 이력 로그)과 내러티브 태그 뷰('세계의 상흔', 태그 키워드+스니펫 히스토리)를 별도의 UI/팝업으로 분리 구현.
## Phase 5: 비주얼 폴리싱 및 다이제틱 UX ## Phase 5: 비주얼 폴리싱 및 다이제틱 UX
핵심 로직이 모두 검증된 후, 기획 단계에서 논의된 추상적이고 몰입감 있는 피드백을 적용합니다. 핵심 로직이 모두 검증된 후, 기획 단계에서 논의된 추상적이고 몰입감 있는 피드백을 적용합니다.

View File

@@ -34,7 +34,7 @@
- **영구 자원 태그 (1코스트 할인 효과)**: 재력/자본 태그(건물), 무력/조직 태그(방패), 감시/언론 태그(눈동자). - **영구 자원 태그 (1코스트 할인 효과)**: 재력/자본 태그(건물), 무력/조직 태그(방패), 감시/언론 태그(눈동자).
### C. 엔딩 도감 및 연대기 (Chronicle) ### C. 엔딩 도감 및 연대기 (Chronicle)
- **연대기 시스템**: 누적된 내러티브 태그를 통해 플레이어가 세상을 어떻게 망치고 구해왔는지 히스토리를 확인하여 몰입도를 강화합니다. - **연대기 시스템**: 국장이 결재한 카드와 좌/우/위 선택을 시간순으로 기록한 결정 장부입니다. 세계가 어떻게 되었는가(세계의 상흔)는 내러티브 태그가 별도로 담당하며, 연대기는 '누가 그렇게 결정했는가'라는 원인 축을 기록해 몰입도를 강화합니다.
- **멀티 엔딩 매트릭스**: 기본 붕괴 엔딩 외에도 특정 자원/토큰과 결합된 변주 엔딩, 극단적 파라미터 조기 엔딩, 내러티브 연쇄 엔딩, 의회 정치 엔딩 등 수십 가지의 결과를 수집해야 합니다. - **멀티 엔딩 매트릭스**: 기본 붕괴 엔딩 외에도 특정 자원/토큰과 결합된 변주 엔딩, 극단적 파라미터 조기 엔딩, 내러티브 연쇄 엔딩, 의회 정치 엔딩 등 수십 가지의 결과를 수집해야 합니다.

View File

@@ -82,5 +82,5 @@
- 조건에 부합하는 가장 높은 우선순위의 텍스트와 연출 출력. - 조건에 부합하는 가장 높은 우선순위의 텍스트와 연출 출력.
4. **도감 동기화** 4. **도감 동기화**
- 출력된 엔딩을 계정 도감 데이터에 '해금(Unlocked)' 처리. - 출력된 엔딩을 계정 도감 데이터에 '해금(Unlocked)' 처리.
- 이번 회차의 연대기 히스토리 보관. - 이번 회차의 [연대기](결재 결정 이력)와 [내러티브 태그](세계 상흔) 목록을 각각 분리 저장.

View File

@@ -47,16 +47,26 @@
- `[🩸 내부 감사] (인력 소모 / 기권 유도)` - `[🩸 내부 감사] (인력 소모 / 기권 유도)`
## 3. 오버레이 및 팝업 (Overlays & Popups) ## 3. 오버레이 및 팝업 (Overlays & Popups)
### 3.1. 연대기 시스템 (Chronicle Popup) ### 3.1. 세계의 상흔 팝업 (World's Scars Popup)
메인 화면 좌상단의 내러티브 태그 터치 시 노출. 메인 화면 좌상단의 `[📖 4/8]` 카운터 터치 시 노출.
- **타이틀**: 국장의 연대기 - **타이틀**: 세계의 상흔
- **본문**: 획득한 [내러티브 태그]의 스토리가 시간순으로 나열된 텍스트 목록 (세상을 망치고 구한 히스토리). - **본문**: 획득한 [내러티브 태그]의 키워드와 스니펫이 시간순으로 나열된 텍스트 목록 (세상이 어떻게 망가졌는가).
### 3.2. 엔딩 결산 화면 (Ending Result Screen) ### 3.2. 연대기 팝업 (Chronicle Popup, 신규)
메인 화면의 별도 로그 버튼 터치 시 노출. '세계의 상흔' 팝업과는 별개의 진입점·별개의 데이터.
- **타이틀**: 국장의 연대기
- **본문**: 국장이 결재한 카드와 좌/우/위 선택 이력이 시간순으로 나열된 결정 장부.
```
[일상] 오염된 장부 승인
└ 좌(예산+): 유가족 위로금은 예비비 충당
```
### 3.3. 엔딩 결산 화면 (Ending Result Screen)
붕괴 조건 도달 시 출력되는 화면. 붕괴 조건 도달 시 출력되는 화면.
- **타이틀**: `[수집한 엔딩 이름]` (예: 사임, 세계 멸망, 문명 붕괴, 정치적 탄핵 등). - **타이틀**: `[수집한 엔딩 이름]` (예: 사임, 세계 멸망, 문명 붕괴, 정치적 탄핵 등).
- **결과 텍스트**: 해당 엔딩에 대한 상세 서사. - **결과 텍스트**: 해당 엔딩에 대한 상세 서사.
- **원인 요약**: 현재 붕괴 게이지, 특정 자원 태그, 토큰 보유 상황 등 엔딩 발동 조건 표기. - **원인 요약**: 현재 붕괴 게이지, 특정 자원 태그, 토큰 보유 상황 등 엔딩 발동 조건 표기.
- **이번 회차 연대기**: 회차 중 결재한 카드와 선택 이력을 요약한 '결정 요약' 블록.
- **액션**: `[도감에 기록하기 & 다음 회차 시작]` 버튼. - **액션**: `[도감에 기록하기 & 다음 회차 시작]` 버튼.

View File

@@ -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 ```text
/src ProjectSS/
├── assets/ # 이미지, 폰트, 아이콘 등 정적 파일 ├── playtest.bat # 더블클릭 → npm install(최초 1회) + dev 서버 + 브라우저 자동 오픈
├── components/ # UI 및 도메인 컴포넌트 │ # ASCII 전용 — 한글/chcp를 넣으면 cmd.exe가 배치 파싱을 깨뜨린다
│ ├── common/ # 공통 UI 요소 (버튼, 모달, 툴팁 등) ├── CLAUDE.md # 작업 규칙 · Phase 상태 (실질적인 단일 진실 공급원)
│ ├── dashboard/ # 상단 상태창 및 파라미터 표시 ├── ROADMAP.md
│ ├── gameplay/ # 핵심 게임 루프 (카드 스와이프, 자산 관리) ├── docs/ # 기획서 · 스토리 바이블 · 작업지시서 (게임 규칙의 원본)
│ └── council/ # 주간 의회 시스템 (투표, 법안, 파벌) └── client/ # ★ 실제 앱. npm 명령은 여기서 실행
├── constants/ # 게임 내 상수 (파벌 정보, 테마 컬러, 엔딩 조건 등) ├── update_csv.js # ⛔ 보호 파일 — cards.csv 전처리
├── data/ # 초기 데이터 및 시나리오 (CSV/JSON 형식) ├── update_csv_tags.js # ⛔ 보호 파일 — 내러티브 태그 매핑
├── hooks/ # 커스텀 훅 (ex. useSwipe, useVoting) └── src/
├── store/ # 전역 상태 관리 (Zustand) ├── main.jsx (10줄) 진입점
├── types/ # TypeScript 타입 정의 (Card, Faction, State 등) ├── App.jsx (13줄) GameScreen 하나만 렌더 — 라우터 없음
├── utils/ # 헬퍼 함수 (엔트로피 계산, 전투/결과 연산 등) ├── index.css 디자인 토큰(CSS 변수) + 리셋
├── App.tsx # 루트 컴포넌트 (페이지 라우팅 및 전역 상태 주입) ├── assets/ hero.png 등 정적 파일
└── main.tsx # 진입점 ├── 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`) ### 3.1 상태는 `GameScreen.jsx` 한 곳에 모은다
프리미엄한 관료주의 다크/레트로 UI를 구성하는 기본 요소들입니다.
- `Button.tsx`: 액션 버튼 (결재, 서명 등 텍스처 효과 포함)
- `Modal.tsx`: 미스터리 보드, 법안 확인 등을 위한 공통 팝업
- `ProgressBar.tsx`: 게이지 바 (엔트로피, 내부 저항 등)
- `AlertToast.tsx`: 패시브 효과 연쇄나 비상 사태를 알리는 시스템 메시지 (우측 하단/상단 노출)
### 2.2 대시보드 (`/components/dashboard`) 전역 스토어가 없다. 게임 상태 전부가 `GameScreen``useState`로 존재하고, 하위 컴포넌트는
플레이어의 현재 상태(국장실 요약)를 보여줍니다. props로 값과 콜백을 받는 프레젠테이션 계층이다.
- `StatusHeader.tsx`: 현재 주차(Week), 일차(Day), 주간 아젠다 노출
- `ParameterBoard.tsx`: 3대 핵심 파라미터 게이지 (내부 저항, 엔트로피, 위험 수용 계수)
- `ResourcePanel.tsx`: 3대 토큰(예산, 인력, 정보) 및 자원 태그(재력, 무력, 감시) 현황
- `FactionDonut.tsx`: 8대 파벌의 의회 지분율 및 우호도를 시각화하는 도넛 차트
### 2.3 게임플레이 (`/components/gameplay`) ```
일일 업무(결재 단계)를 처리하는 핵심 루프 컴포넌트입니다. GameScreen (상태 소유)
- `DeskView.tsx`: 국장실 책상 배경 및 전체 레이아웃 래퍼 ├── SwipeCard ← card, onSwipe, phase
- `CardSwiper.tsx`: 틴더 스타일의 스와이프 인터페이스 (Tinder-like Swipe) ├── CouncilScreen ← params/tokens/tags + setter, onResolve ※ setter를 직접 넘기는 유일한 예외
- 좌/우 스와이프에 따른 예측 결과(예상 토큰/파라미터 증감)를 오버레이로 표시 ├── EndingScreen ← ending, narrativeTags
- `DilemmaCard.tsx`: 개별 딜레마/위기 서류. (제목, 내용, 플레이버 텍스트, 결재/반려 스탬프 이펙트) ├── ChroniclePopup ← chronicle, acquiredTags
- `AssetManager.tsx`: 보유 중인 자산(파이프라인, 성질 변환 등 덱 압축 장치) 관리 모달 └── AssetInfoPopup ← instance, def, onActivate
### 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[];
}
``` ```
**트레이드오프:** 파일이 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. 추천 기술 스택 ## 4. 데이터 흐름
- **Framework**: `React` (컴포넌트 분리를 통한 복잡한 UI 관리)
- **Build Tool**: `Vite` (빠른 HMR 및 빌드 속도) ```
- **Styling**: `Tailwind CSS` (유틸리티 클래스로 빠른 UI 구현) + CSS Animations (스와이프 액션, 팝업 등) cards.csv ──(?raw import)──> papaparse ──> buildCardFromRow() ──> 덱 배열
- **State Management**: `Zustand` (가볍고 직관적인 전역 상태 관리)
- **Interactions**: `Framer Motion` 또는 `react-spring` (부드러운 카드 스와이프 액션 및 몰입감 있는 애니메이션) narrative_tags.json ──> left_tag/right_tag id로 조인 ────────────────┘
- **Type Checking**: `TypeScript` (복잡한 자원 계산과 이벤트 분기에 대한 안정성 보장) ```
- **카드 데이터는 런타임에 파싱한다.** `import cardsCsvRaw from '../data/cards.csv?raw'`
브라우저에서 papaparse로 처리한다. 빌드 타임 변환 단계가 없으므로 CSV를 고치면 새로고침만으로 반영된다.
- 덱은 **인덱스를 순환(modulo)하는 풀**이다. 소진되어 없어지지 않고 계속 돈다.
`currentCardIndex`는 매 결재마다 최종 덱 길이로 정규화해 범위 안에 유지한다 — 주입/소각으로
길이가 변하기 때문이다.
- 카드 풀은 페이즈별로 분리한다: 페이즈1(일상/위기)로 시작하고, 내러티브 태그 3종 획득 시
페이즈2(작전/극비) 풀이 덱에 삽입된다.
- 오염 카드(신화/조직저항/사회공황/이사회압박)는 `contaminationTemplates`로 따로 보관하다가
주입 시점에 복제해 고유 id를 붙인다.
---
## 5. 화면 전환
라우터가 없다. `GameScreen`이 상태를 보고 조기 return으로 화면을 고른다.
```js
if (gameOver) return <EndingScreen ... />;
if (showCouncil) return <CouncilScreen ... />;
if (cards.length === 0) return <div>데이터를 불러오는 ...</div>;
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()` 재시작** — 상태 초기화 로직이 따로 없다.

View File

@@ -0,0 +1,138 @@
# 내러티브 태그 시스템 작업 지시서
> **범위:** (A) 연대기 ↔ 내러티브 태그 개념 분리 + (B) 내러티브 태그 스니펫 시스템 도입.
>
> **작업 환경:** Claude Code 세션에서 각 파일을 열어 해당 섹션을 대조 후 반영. 아래 문구는 리포 실제 텍스트와 100% 일치하지 않을 수 있으므로 의미 기준으로 수정할 것.
>
> **주의:** `narrative_tags.json` 콘텐츠(스니펫 등)는 자유롭게 초안·반영 가능. 단 `update_csv_tags.js` 등 **보호된 스크립트** 수정이 필요해지면 임의 실행 금지 — 근거 설명 → diff 확인 → 승인 절차를 따를 것.
---
# PART A. 연대기 ↔ 내러티브 태그 분리
## A-0. 개념 정의 (기준선)
| 구분 | 내러티브 태그 (Narrative Tag) | 연대기 (Chronicle) |
|---|---|---|
| 본질 | **결과** — 세계가 어떻게 되었는가 | **원인** — 내가 무엇을 어떻게 결재했는가 |
| 성격 | 명사 · 세계의 상흔 | 동사 · 국장의 결정 로그 |
| 구성 | 키워드 + 스니펫 + 엔딩 조건 + 페이즈 축 | 등장한 결재 카드 + 플레이어 선택(좌/우/위) 이력 (시간순) |
| 진입점 | 좌상단 `[📖 4/8]` 카운터 → '세계의 상흔' 팝업 | 별도 로그 버튼 + 엔딩 화면 '이번 회차 결정 요약' |
**한 줄 기억표:** 태그는 스토리의 '무엇', 연대기는 '어떻게'.
**스니펫은 태그 쪽에 귀속된다** (태그의 살). 연대기와는 무관.
## A-1. 시스템 기획서 — `Project SS 서사형 덱빌딩 게임 시스템 기획서.md`
1. 내러티브 태그 항목에서 연대기 서술을 **제거**. 태그 항목엔 스토리 전달(스니펫) + 엔딩 조건 + 페이즈 축만 남김.
2. 시스템 목록에 **독립 항목** 신설:
> **연대기 (Chronicle):** 국장이 결재한 카드와 그에 대한 선택(좌/우/위)을 시간순으로 기록한 결정 장부. 내러티브 태그가 '세계의 결과'라면, 연대기는 '국장의 원인 이력'이다.
## A-2. 스토리 바이블 — `Project SS 스토리 바이블 v3.0.md`
2장 "연대기의 편찬(The Chronicle)" 테마를 '결정 장부' 관점으로 재서술:
> 국장의 모든 결재는 되돌릴 수 없는 기록으로 남는다. 세계의 상흔(내러티브 태그)이 '무엇이 벌어졌는가'를 말한다면, 연대기는 '누가 그렇게 결정했는가'를 말한다. 플레이어는 회차가 끝난 뒤 이 장부를 되짚으며, 멸망이 사고가 아니라 자신의 서명들이 쌓인 결과였음을 마주한다.
## A-3. 텍스트 와이어프레임 — `ProjectSS 텍스트 와이어프레임.md`
1. **3.1 팝업 2종 분리:**
- '세계의 상흔' 팝업: `[📖 4/8]` 터치. 본문 = 태그 키워드 + 스니펫 시간순.
- '연대기' 팝업(신규): 별도 로그 버튼. 본문 = 결재 카드 + 선택 이력 시간순.
```
[일상] 오염된 장부 승인
└ 좌(예산+): 유가족 위로금은 예비비 충당
```
2. **3.2 엔딩 화면:** '이번 회차 연대기(결정 요약)' 블록 추가.
## A-4~A-7. 나머지 문서 (한두 줄 수정)
- **비전 문서 4-C:** 연대기 = 결재 카드·선택의 시간순 결정 장부. 세계 상태는 태그가 담당(분리).
- **PRD 5장:** 연대기(결재 결정 이력) / 내러티브 태그(엔딩·세계 상태) 각주 분리.
- **유저플로우 4.4:** 연대기와 태그 목록을 **분리 저장**으로 명시.
- **개발순서 Phase 4:** 연대기 기능과 태그 뷰('세계의 상흔')를 별도 UI로 분리 구현 명시.
---
# PART B. 내러티브 태그 스니펫 시스템
> 태그 키워드만으로는 서사 체감이 약함. 각 태그에 1~2문장 결과 보고서 스니펫을 붙여 살을 부여한다. 그레이박스 원칙 안에서 순수 텍스트로 검증 가능.
## B-1. 스타일 가이드 (확정)
**톤 3원칙**
1. **거리 유지** — 감정 형용사·판단·괴수 외형 묘사 금지. 오직 기록·숫자·행정 용어.
2. **숫자 하나** — 스니펫마다 구체 숫자 정확히 1개(예: 14명, 47건, 15%). 냉혹한 비용편익의 앵커.
3. **인정 유예** — 스니펫은 위로·의미 부여를 하지 않는다. 인정(감동)은 엔딩/연대기 회고의 몫으로 미룬다.
**포맷 골격**
`사실 진술 → 행정적 뒤틀림 → [처리 상태]로 종결`
- **마침 도장(각인층):** 모든 스니펫은 결재 상태어로 끝난다(처리됨 / 승인됨 / 종결됨 / 이관됨 / 반려됨 / 미반영됨 등). 이 게임만의 문장 지문.
**유형 2종 — 비율 약 7:3**
- **시스템형(70%):** 시스템의 부조리가 주인공.
- **사람형(30%):** 익명의 개인이 데이터 이상치로만 존재('숨겨진 헌신'). 엔딩/변주 콜백용.
> **설계 근거(참고 문서에서 이식):** 감동 공식(거리+숨겨진 헌신+인정+의미의 역류) — 거리는 문체로 확보, 헌신은 사람형 스니펫에 매설, 인정은 엔딩에서 역류. 로그라이크 루프가 이 역류를 회차마다 반복시킨다.
## B-2. 스키마 변경 (승인 필요 항목)
각 태그 객체에 두 필드 추가:
```json
{
"id": "quarantine_breach",
"keyword": "격리 붕괴",
"snippet": "격리동 전력 계통에 원인 불명의 수동 복구 기록 1건. 해당 시간대 출입 승인자 없음. 요원 K-771, 실종자 명단으로 이관됨.",
"snippet_type": "human",
"phase": 2
}
```
- `snippet` (string): 태그 스니펫 본문.
- `snippet_type` ("system" | "human"): 콜백 로직용. 엔딩/연대기 회고에서 해당 회차의 human 태그를 골라 "그때 K-771이…" 식 콜백 훅으로 사용.
> `narrative_tags.json` 직접 수정은 가능. 단 이 파일이 `update_csv_tags.js`로 **생성/가공**되는 구조라면 스크립트에 신규 컬럼 반영이 필요하므로, 그 경우 diff 확인 → 승인 후 진행.
## B-3. 샘플 배치 8종 (검토용 기준선)
**시스템형 (5)**
| 태그 | 스니펫 |
|---|---|
| `[실종]` | 7구역 정수장 근무자 14명 미귀가. 근태 시스템상 전원 '정상 퇴근'으로 **처리됨**. |
| `[가스 폭발]` | 언론 브리핑상 '노후 배관 폭발'로 발표. 유가족 위로금 항목은 예비비로 충당, 회계 **승인됨**. |
| `[매수]` | 3분기 감사 중 국장 명의 차명 계좌 3건 인지. 담당 조사관 해외 연수 발령으로 조사 **중단됨**. |
| `[차원 균열]` | 12구역 상공 '접힘 현상', 위성 미검출. 목격 진술 47건 전원 상이. 관측 장비 오류로 **종결됨**. |
| `[마녀사냥]` | '이웃이 변이체' 신고 폭증. 실제 변이 확인 0건, 자경단 폭행 사망 3건. 통계 비공개 **결정됨**. |
**사람형 (3) — 숨겨진 헌신**
| 태그 | 스니펫 |
|---|---|
| `[격리 붕괴]` | 격리동 전력 계통에 원인 불명의 수동 복구 기록 1건. 해당 시간대 출입 승인자 없음. 요원 K-771, 실종자 명단으로 **이관됨**. |
| `[결손 보전]` | 은폐 자금 결손분 340만, 익명 입금으로 충당. 입금자 추적 '불필요' 판정. 계정 **종결됨**. |
| `[초과 근무]` | 작전부 잔업 신청 반려 건수 218건. 반려된 요원 전원 무급으로 현장 잔류 확인. 근태 기록 **미반영됨**. |
## B-4. 확정 대기 (스타일 가이드 완성 전 결정 필요)
- [ ] **사람형 헌신 농도:** ⑥~⑧을 더 은근하게 vs 더 드러나게.
- [ ] **마침 도장 어휘:** 자유 어휘 허용 vs 고정 리스트로 관리(처리됨/승인됨/종결됨/이관됨/반려됨/미반영됨…).
이 둘이 정해지면 스타일 가이드 확정 → 나머지 태그를 다음 배치들로 진행.
---
# 통합 체크리스트
**Part A (개념 분리)**
- [ ] 시스템 기획서: 연대기 독립 항목 승격, 태그 항목 정리
- [ ] 스토리 바이블: "연대기의 편찬" 결정 장부 관점 재서술
- [ ] 와이어프레임: 팝업 2종 분리 + 엔딩 결정 요약 블록
- [ ] 비전/PRD/유저플로우/개발순서: 정의 분리 및 분리 저장/분리 구현 반영
**Part B (스니펫)**
- [ ] 스타일 가이드 확정(B-4 두 항목 결정)
- [ ] `narrative_tags.json` 스키마에 `snippet`, `snippet_type` 추가
- [ ] (해당 시) `update_csv_tags.js` 신규 컬럼 반영 — diff 승인 후
- [ ] 샘플 8종 반영 → 검토 → 나머지 태그 배치 확장
- [ ] 스토리 바이블 톤 앤 매너에 스니펫 3원칙·포맷 규칙 문서화(선택)

View File

@@ -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차 배치)
- 자산 획득 경로(극비 프로젝트 보상 테이블) 튜닝

46
playtest.bat Normal file
View File

@@ -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