Compare commits
4 Commits
c67914c248
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db1051c08c | ||
|
|
31558edcb2 | ||
|
|
cdee98ffc2 | ||
|
|
ad7db470f6 |
20
.claude/commands/audit.md
Normal file
20
.claude/commands/audit.md
Normal 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
156
CLAUDE.md
Normal 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
278
ROADMAP.md
Normal 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]`로 업데이트하고 **현재 상태** 블록을 갱신한다.
|
||||||
92
client/src/components/AssetInfoPopup.css
Normal file
92
client/src/components/AssetInfoPopup.css
Normal 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;
|
||||||
|
}
|
||||||
43
client/src/components/AssetInfoPopup.jsx
Normal file
43
client/src/components/AssetInfoPopup.jsx
Normal 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}>×</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}>×</button>
|
<button className="close-btn" onClick={onClose}>×</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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,46 +19,52 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
|
|||||||
setAgenda('재무 건전성 악화 및 횡령 배임 혐의');
|
setAgenda('재무 건전성 악화 및 횡령 배임 혐의');
|
||||||
setResult('당신은 파산했고 자금 횡령으로 체포되었습니다. 감옥의 식사는 다행히 무료입니다.');
|
setResult('당신은 파산했고 자금 횡령으로 체포되었습니다. 감옥의 식사는 다행히 무료입니다.');
|
||||||
return;
|
return;
|
||||||
} else {
|
|
||||||
setIsBankruptcyWarning(true);
|
|
||||||
setAgenda('파산 문책: 재무 경고');
|
|
||||||
if (bankruptcyCount >= 5) {
|
|
||||||
setBankruptPenalty(`파산 카운터 ${bankruptcyCount}회: 의회가 매우 분노했습니다. 모든 위험도 파라미터가 상승합니다.`);
|
|
||||||
setParams(prev => ({
|
|
||||||
resistance: Math.min(5, prev.resistance + 1),
|
|
||||||
entropy: Math.min(5, prev.entropy + 1),
|
|
||||||
panic: Math.min(5, prev.panic + 1),
|
|
||||||
risk: Math.min(5, prev.risk + 1)
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
setBankruptPenalty(`파산 카운터 ${bankruptcyCount}회: 의회에서 강력한 경고가 나왔습니다. 다음 의회까지 복구하지 못하면 해임됩니다.`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
setIsBankruptcyWarning(true);
|
||||||
// Normal Agenda
|
setAgenda('파산 문책: 재무 경고');
|
||||||
const newMembers = Array.from({ length: 13 }).map((_, i) => {
|
if (bankruptcyCount >= 5) {
|
||||||
const type = i < 8 ? 'board' : 'global';
|
setBankruptPenalty(`파산 카운터 ${bankruptcyCount}회: 의회가 매우 분노했습니다. 모든 위험도 파라미터가 상승합니다.`);
|
||||||
const rand = Math.random() * 100;
|
setParams(prev => ({
|
||||||
let vote = '미정';
|
resistance: Math.min(5, prev.resistance + 1),
|
||||||
if (rand < params.risk * 20) {
|
entropy: Math.min(5, prev.entropy + 1),
|
||||||
vote = '반대';
|
panic: Math.min(5, prev.panic + 1),
|
||||||
} else if (rand > 50) {
|
risk: Math.min(5, prev.risk + 1),
|
||||||
vote = '찬성';
|
}));
|
||||||
}
|
|
||||||
return { id: i, type, vote };
|
|
||||||
});
|
|
||||||
setMembers(newMembers);
|
|
||||||
|
|
||||||
const againstCount = newMembers.filter(m => m.vote === '반대').length;
|
|
||||||
if (againstCount >= 9) {
|
|
||||||
setAgenda('정치적 탄핵안 상정');
|
|
||||||
setImpeachment(true);
|
|
||||||
} else {
|
} else {
|
||||||
const randAgenda = Math.random() > 0.5 ? '이득 규약 법안 상정' : '표적 규약 법안 상정';
|
setBankruptPenalty(`파산 카운터 ${bankruptcyCount}회: 의회에서 강력한 경고가 나왔습니다. 다음 의회까지 복구하지 못하면 해임됩니다.`);
|
||||||
setAgenda(randAgenda);
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [params.risk, tokens, bankruptcyWarning, bankruptcyCount]);
|
|
||||||
|
// 상임 이사회 8명: 위험 수용 계수 기반 무작위 (성향 태그 세부 연동은 Phase 3 범위 밖)
|
||||||
|
const boardMembers = Array.from({ length: 8 }).map((_, i) => {
|
||||||
|
const rand = Math.random() * 100;
|
||||||
|
let vote = '미정';
|
||||||
|
if (rand < params.risk * 20) vote = '반대';
|
||||||
|
else if (rand > 50) vote = '찬성';
|
||||||
|
return { id: i, type: 'board', vote, immune: false, label: '상임 이사회' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 글로벌 지부장 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);
|
||||||
|
|
||||||
|
const againstCount = newMembers.filter(m => m.vote === '반대').length;
|
||||||
|
if (againstCount >= 9) {
|
||||||
|
setAgenda('정치적 탄핵안 상정');
|
||||||
|
setImpeachment(true);
|
||||||
|
} else {
|
||||||
|
const randAgenda = Math.random() > 0.5 ? '이득 규약 법안 상정' : '표적 규약 법안 상정';
|
||||||
|
setAgenda(randAgenda);
|
||||||
|
}
|
||||||
|
// 의회는 마운트 시 1회만 초기화한다 (deps에 tokens/params.risk를 넣으면 매수 액션 등으로
|
||||||
|
// 값이 바뀔 때마다 재실행되어 방금 강제 전환한 투표 결과를 덮어써버리는 버그가 있었다)
|
||||||
|
// 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 === '반대' ? '❌' : '❓'}
|
||||||
|
|||||||
83
client/src/components/EndingCodex.css
Normal file
83
client/src/components/EndingCodex.css
Normal 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;
|
||||||
|
}
|
||||||
33
client/src/components/EndingCodex.jsx
Normal file
33
client/src/components/EndingCodex.jsx
Normal 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}>×</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
<button className="restart-btn" onClick={onRestart}>도감에 기록하기 & 다음 회차 시작</button>
|
<div className="ending-actions">
|
||||||
|
<button className="restart-btn" onClick={onRestart}>도감에 기록하기 & 다음 회차 시작</button>
|
||||||
|
<button className="codex-btn" onClick={() => setShowCodex(true)}>📖 엔딩 도감 보기</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCodex && <EndingCodex onClose={() => setShowCodex(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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); }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
// 오염 카드 주입 규칙(타입 매핑 · 등급 추첨 · 물량)은 engine/pollution.js에 모여 있다.
|
||||||
|
// 스와이프 예고(SwipeCard)와 실제 주입이 같은 계산을 쓰게 하기 위해 여기서 다시 정의하지 말 것.
|
||||||
|
|
||||||
|
// 내러티브 태그는 narrative_tags.json 풀(10종)에서 "처음 획득하는 태그"만 진행도에 반영된다.
|
||||||
|
// 문턱은 풀 크기 대비 낮게 잡는다 (기존 4/8은 실제 플레이에서 길게 느껴진다는 피드백 반영).
|
||||||
|
const PHASE2_TAG_THRESHOLD = 3;
|
||||||
|
const PHASE3_TAG_THRESHOLD = 6;
|
||||||
|
|
||||||
|
// 글로벌 지부장 12개 파벌 풀 (게임 시작 시 5명 무작위 선발, 실리에 따라 우호도 개별 계산)
|
||||||
|
const GLOBAL_FACTIONS = [
|
||||||
|
{ id: 'military', name: '군산복합 연합', computeAffinity: (c) => c.tags.force * 15 + c.tokens.personnel * 0.4 - 40 },
|
||||||
|
{ id: 'blackmarket', name: '블랙마켓 카르텔', computeAffinity: (c) => c.tokens.budget * 0.4 + c.assetsCount * 20 - 40 },
|
||||||
|
{ id: 'databroker', name: '정보 브로커 연맹', computeAffinity: (c) => c.tokens.info * 0.4 + c.tags.surveillance * 15 - 40 },
|
||||||
|
{ 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 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getFactionTier = (score) => {
|
||||||
|
if (score >= 60) return '동맹';
|
||||||
|
if (score >= 20) return '우호';
|
||||||
|
if (score > -20) return '중립';
|
||||||
|
if (score > -60) return '적대';
|
||||||
|
return '파국';
|
||||||
|
};
|
||||||
|
const TIER_VOTE = { 동맹: '찬성', 우호: '찬성', 중립: '미정', 적대: '반대', 파국: '반대' };
|
||||||
|
const TIER_IMMUNE = new Set(['동맹', '파국']);
|
||||||
|
|
||||||
|
const buildCardFromRow = (row) => ({
|
||||||
|
id: row.id,
|
||||||
|
type: row.type,
|
||||||
|
keyword: row.keyword,
|
||||||
|
question: row.question,
|
||||||
|
text: row.text,
|
||||||
|
flavor: row.flavor,
|
||||||
|
grade: row.grade || null,
|
||||||
|
contaminated: CONTAM_TYPES.has(row.type),
|
||||||
|
left: {
|
||||||
|
text: row.left_text,
|
||||||
|
tag: row.left_tag ? narrativeTagsData.find(t => t.id === row.left_tag) : null,
|
||||||
|
stats: {
|
||||||
|
res: row.left_res || 0, ent: row.left_ent || 0, pan: row.left_pan || 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
text: row.right_text,
|
||||||
|
tag: row.right_tag ? narrativeTagsData.find(t => t.id === row.right_tag) : null,
|
||||||
|
stats: {
|
||||||
|
res: row.right_res || 0, ent: row.right_ent || 0, pan: row.right_pan || 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export function GameScreen() {
|
export function GameScreen() {
|
||||||
const [params, setParams] = useState({ resistance: 0, entropy: 0, panic: 0, risk: 0 });
|
const [params, setParams] = useState({ resistance: 0, entropy: 0, panic: 0, risk: 0 });
|
||||||
const [tokens, setTokens] = useState({ budget: 50, personnel: 50, info: 50 });
|
const [tokens, setTokens] = useState({ budget: 50, personnel: 50, info: 50 });
|
||||||
const [tags, setTags] = useState({ wealth: 0, force: 0, surveillance: 0 });
|
const [tags, setTags] = useState({ wealth: 0, force: 0, surveillance: 0 });
|
||||||
const [acquiredTags, setAcquiredTags] = useState([]);
|
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 [phase, setPhase] = useState(1);
|
||||||
const [gameOver, setGameOver] = useState(false);
|
const [gameOver, setGameOver] = useState(false);
|
||||||
const [cards, setCards] = useState([]);
|
const [cards, setCards] = useState([]);
|
||||||
|
const [contaminationTemplates, setContaminationTemplates] = useState([]);
|
||||||
|
const [phase2Pool, setPhase2Pool] = useState([]);
|
||||||
|
const [phase2Unlocked, setPhase2Unlocked] = useState(false);
|
||||||
const [currentCardIndex, setCurrentCardIndex] = useState(0);
|
const [currentCardIndex, setCurrentCardIndex] = useState(0);
|
||||||
|
|
||||||
const [turns, setTurns] = useState(0);
|
const [turns, setTurns] = useState(0);
|
||||||
const [showCouncil, setShowCouncil] = useState(false);
|
const [showCouncil, setShowCouncil] = useState(false);
|
||||||
const [impeachmentPassed, setImpeachmentPassed] = useState(false);
|
|
||||||
const [termExpired, setTermExpired] = useState(false);
|
|
||||||
const [chronicle, setChronicle] = useState([]);
|
const [chronicle, setChronicle] = useState([]);
|
||||||
const [showChronicle, setShowChronicle] = useState(false);
|
const [showChronicle, setShowChronicle] = useState(false);
|
||||||
const [assets, setAssets] = useState([]);
|
|
||||||
|
|
||||||
|
// 자산(엔진) 시스템 — 작업지시서 기준 트리거/효과 엔진
|
||||||
|
const [ownedAssets, setOwnedAssets] = useState([]);
|
||||||
|
const [assetLog, setAssetLog] = useState([]);
|
||||||
|
const [assetPopup, setAssetPopup] = useState(null);
|
||||||
|
|
||||||
|
// 라운드 구조: 라운드가 증가할수록 정례회의까지 남은 결재 건수가 줄어든다 (시간 압박 시각화)
|
||||||
const [roundCount, setRoundCount] = useState(1);
|
const [roundCount, setRoundCount] = useState(1);
|
||||||
const [approvalsRemaining, setApprovalsRemaining] = useState(10);
|
const [approvalsRemaining, setApprovalsRemaining] = useState(10);
|
||||||
|
|
||||||
|
// 파산 문책 시스템
|
||||||
const [bankruptcyCount, setBankruptcyCount] = useState(0);
|
const [bankruptcyCount, setBankruptcyCount] = useState(0);
|
||||||
const [bankruptcyWarning, setBankruptcyWarning] = useState(false);
|
const [bankruptcyWarning, setBankruptcyWarning] = useState(false);
|
||||||
const [bankrupted, setBankrupted] = useState(false);
|
|
||||||
const [newTagNotification, setNewTagNotification] = useState(null);
|
|
||||||
|
|
||||||
// CSV Load Effect
|
// 자산 효과 상태
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const parsed = Papa.parse(cardsCsvRaw, { header: true, dynamicTyping: true, skipEmptyLines: true });
|
const parsed = Papa.parse(cardsCsvRaw, { header: true, dynamicTyping: true, skipEmptyLines: true });
|
||||||
|
const rows = parsed.data.filter(row => row.id);
|
||||||
|
|
||||||
const formattedCards = parsed.data.map(row => ({
|
const phase1Rows = rows.filter(row => row.type === '일상' || row.type === '위기');
|
||||||
id: row.id,
|
const phase2Rows = rows.filter(row => row.type === '작전' || row.type === '극비');
|
||||||
type: row.type,
|
const templates = rows.filter(row => CONTAM_TYPES.has(row.type)).map(buildCardFromRow);
|
||||||
keyword: row.keyword,
|
|
||||||
question: row.question,
|
|
||||||
text: row.text,
|
|
||||||
flavor: row.flavor,
|
|
||||||
left: {
|
|
||||||
text: row.left_text,
|
|
||||||
tag: row.left_tag ? narrativeTagsData.find(t => t.id === row.left_tag) : null,
|
|
||||||
stats: {
|
|
||||||
res: row.left_res || 0,
|
|
||||||
ent: row.left_ent || 0,
|
|
||||||
pan: row.left_pan || 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: {
|
|
||||||
text: row.right_text,
|
|
||||||
tag: row.right_tag ? narrativeTagsData.find(t => t.id === row.right_tag) : null,
|
|
||||||
stats: {
|
|
||||||
res: row.right_res || 0,
|
|
||||||
ent: row.right_ent || 0,
|
|
||||||
pan: row.right_pan || 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));
|
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) {
|
// 자산 엔진: 오염 카드 드로우 트리거(신화 환전/조직저항 자동 무효화 등)
|
||||||
setBankruptcyCount(c => c + 1);
|
let negateFree = false;
|
||||||
|
if (!quarantinedByAsset && card.contaminated) {
|
||||||
|
AssetEngine.onPollutionDraw(world, card.type);
|
||||||
|
negateFree = world.negateFree;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (outcomeText) {
|
if (quarantinedByAsset) {
|
||||||
let isNewTag = false;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 극단적 파라미터 조기 엔딩 판별용: 이번 결재에서 가장 크게 증가한 위험 파라미터 추적
|
||||||
|
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,21 +557,62 @@ 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>
|
||||||
|
|
||||||
{newTagNotification && (
|
{assetLog.length > 0 && (
|
||||||
<div className="tag-notification">
|
<div className="asset-log-feed">
|
||||||
<div className="tag-noti-icon">{newTagNotification.icon}</div>
|
{assetLog.slice(-4).map((line, i) => (
|
||||||
<div className="tag-noti-info">
|
<div key={i} className="asset-log-line">{line}</div>
|
||||||
<h4>새로운 내러티브 획득!</h4>
|
))}
|
||||||
<p>[{newTagNotification.name}]</p>
|
</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 && (
|
||||||
|
<div className="tag-notification">
|
||||||
|
<div className="tag-noti-icon">{newTagNotification.icon}</div>
|
||||||
|
<div className="tag-noti-info">
|
||||||
|
<h4>새로운 내러티브 획득!</h4>
|
||||||
|
<p>[{newTagNotification.name}]</p>
|
||||||
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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 === '극비' && (
|
||||||
|
|||||||
@@ -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": "'담당자 부재'라는 담당자가 새로 생겼다."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
|
123
client/src/data/endings.js
Normal file
123
client/src/data/endings.js
Normal 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]];
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
226
client/src/engine/assetEngine.js
Normal file
226
client/src/engine/assetEngine.js
Normal 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 };
|
||||||
154
client/src/engine/pollution.js
Normal file
154
client/src/engine/pollution.js
Normal 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(' · ');
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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개)
|
||||||
|
|||||||
@@ -65,7 +65,9 @@
|
|||||||
- **페이즈 3 (태그 8개 이상)**: 종말 카운트다운. 극한의 오염 카드와 뉴스 속보가 쏟아지는 자원 고갈의 생존 구간.
|
- **페이즈 3 (태그 8개 이상)**: 종말 카운트다운. 극한의 오염 카드와 뉴스 속보가 쏟아지는 자원 고갈의 생존 구간.
|
||||||
|
|
||||||
## 5. 최종 목표: 멀티 엔딩 매트릭스 도감
|
## 5. 최종 목표: 멀티 엔딩 매트릭스 도감
|
||||||
다양한 엔딩을 수집하여 [연대기 시스템]을 통해 플레이어의 히스토리를 시각화합니다.
|
다양한 엔딩을 수집하여 도감에 기록합니다. 회차의 여정은 두 축으로 분리되어 기록됩니다.
|
||||||
|
- **연대기(Chronicle)**: 국장이 결재한 카드와 좌/우/위 선택을 시간순으로 기록한 결정 이력.
|
||||||
|
- **내러티브 태그**: 그 결재의 결과로 세계에 남은 상흔이자 엔딩 조건. 연대기와는 별개로 관리·저장됩니다.
|
||||||
|
|
||||||
- **수집해야 할 엔딩 카테고리**:
|
- **수집해야 할 엔딩 카테고리**:
|
||||||
- 기본 4대 붕괴 엔딩 (사임, 멸망, 문명 붕괴, 탄핵)
|
- 기본 4대 붕괴 엔딩 (사임, 멸망, 문명 붕괴, 탄핵)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
- **작업 항목**:
|
- **작업 항목**:
|
||||||
- **엔딩 분기 시스템**: 파라미터 5단계 도달 시, 현재 보유한 토큰/자원 태그를 검사하여 기본 붕괴 엔딩 또는 변주된 엔딩으로 분기시키는 판별기 구현.
|
- **엔딩 분기 시스템**: 파라미터 5단계 도달 시, 현재 보유한 토큰/자원 태그를 검사하여 기본 붕괴 엔딩 또는 변주된 엔딩으로 분기시키는 판별기 구현.
|
||||||
- **특수 엔딩 트리거**: 극단적 파라미터, 내러티브 연쇄, 의회 정치 조건 달성 시 발동하는 조기 엔딩 이벤트 구현.
|
- **특수 엔딩 트리거**: 극단적 파라미터, 내러티브 연쇄, 의회 정치 조건 달성 시 발동하는 조기 엔딩 이벤트 구현.
|
||||||
- **도감 및 연대기(Chronicle)**: 해금된 엔딩을 저장하는 도감 UI 구현 및 이번 회차에서 획득한 내러티브 태그를 히스토리 텍스트로 나열해 주는 연대기 기능 구현.
|
- **도감 및 연대기(Chronicle)**: 해금된 엔딩을 저장하는 도감 UI 구현. 연대기 기능(결재 카드·선택 이력 로그)과 내러티브 태그 뷰('세계의 상흔', 태그 키워드+스니펫 히스토리)를 별도의 UI/팝업으로 분리 구현.
|
||||||
|
|
||||||
## Phase 5: 비주얼 폴리싱 및 다이제틱 UX
|
## Phase 5: 비주얼 폴리싱 및 다이제틱 UX
|
||||||
핵심 로직이 모두 검증된 후, 기획 단계에서 논의된 추상적이고 몰입감 있는 피드백을 적용합니다.
|
핵심 로직이 모두 검증된 후, 기획 단계에서 논의된 추상적이고 몰입감 있는 피드백을 적용합니다.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
- **영구 자원 태그 (1코스트 할인 효과)**: 재력/자본 태그(건물), 무력/조직 태그(방패), 감시/언론 태그(눈동자).
|
- **영구 자원 태그 (1코스트 할인 효과)**: 재력/자본 태그(건물), 무력/조직 태그(방패), 감시/언론 태그(눈동자).
|
||||||
|
|
||||||
### C. 엔딩 도감 및 연대기 (Chronicle)
|
### C. 엔딩 도감 및 연대기 (Chronicle)
|
||||||
- **연대기 시스템**: 누적된 내러티브 태그를 통해 플레이어가 세상을 어떻게 망치고 구해왔는지 히스토리를 확인하여 몰입도를 강화합니다.
|
- **연대기 시스템**: 국장이 결재한 카드와 좌/우/위 선택을 시간순으로 기록한 결정 장부입니다. 세계가 어떻게 되었는가(세계의 상흔)는 내러티브 태그가 별도로 담당하며, 연대기는 '누가 그렇게 결정했는가'라는 원인 축을 기록해 몰입도를 강화합니다.
|
||||||
- **멀티 엔딩 매트릭스**: 기본 붕괴 엔딩 외에도 특정 자원/토큰과 결합된 변주 엔딩, 극단적 파라미터 조기 엔딩, 내러티브 연쇄 엔딩, 의회 정치 엔딩 등 수십 가지의 결과를 수집해야 합니다.
|
- **멀티 엔딩 매트릭스**: 기본 붕괴 엔딩 외에도 특정 자원/토큰과 결합된 변주 엔딩, 극단적 파라미터 조기 엔딩, 내러티브 연쇄 엔딩, 의회 정치 엔딩 등 수십 가지의 결과를 수집해야 합니다.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -82,5 +82,5 @@
|
|||||||
- 조건에 부합하는 가장 높은 우선순위의 텍스트와 연출 출력.
|
- 조건에 부합하는 가장 높은 우선순위의 텍스트와 연출 출력.
|
||||||
4. **도감 동기화**
|
4. **도감 동기화**
|
||||||
- 출력된 엔딩을 계정 도감 데이터에 '해금(Unlocked)' 처리.
|
- 출력된 엔딩을 계정 도감 데이터에 '해금(Unlocked)' 처리.
|
||||||
- 이번 회차의 연대기 히스토리 보관.
|
- 이번 회차의 [연대기](결재 결정 이력)와 [내러티브 태그](세계 상흔) 목록을 각각 분리 저장.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
붕괴 조건 도달 시 출력되는 화면.
|
붕괴 조건 도달 시 출력되는 화면.
|
||||||
- **타이틀**: `[수집한 엔딩 이름]` (예: 사임, 세계 멸망, 문명 붕괴, 정치적 탄핵 등).
|
- **타이틀**: `[수집한 엔딩 이름]` (예: 사임, 세계 멸망, 문명 붕괴, 정치적 탄핵 등).
|
||||||
- **결과 텍스트**: 해당 엔딩에 대한 상세 서사.
|
- **결과 텍스트**: 해당 엔딩에 대한 상세 서사.
|
||||||
- **원인 요약**: 현재 붕괴 게이지, 특정 자원 태그, 토큰 보유 상황 등 엔딩 발동 조건 표기.
|
- **원인 요약**: 현재 붕괴 게이지, 특정 자원 태그, 토큰 보유 상황 등 엔딩 발동 조건 표기.
|
||||||
|
- **이번 회차 연대기**: 회차 중 결재한 카드와 선택 이력을 요약한 '결정 요약' 블록.
|
||||||
- **액션**: `[도감에 기록하기 & 다음 회차 시작]` 버튼.
|
- **액션**: `[도감에 기록하기 & 다음 회차 시작]` 버튼.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()` 재시작** — 상태 초기화 로직이 따로 없다.
|
||||||
|
|||||||
138
docs/작업지시서/내러티브태그_시스템_작업지시서.md
Normal file
138
docs/작업지시서/내러티브태그_시스템_작업지시서.md
Normal 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원칙·포맷 규칙 문서화(선택)
|
||||||
128
docs/작업지시서/자산시스템_작업지시서.md
Normal file
128
docs/작업지시서/자산시스템_작업지시서.md
Normal 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
46
playtest.bat
Normal 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
|
||||||
Reference in New Issue
Block a user