Phase 2~4 그레이박스 시스템 완성 및 내러티브 태그 재설계
1. Phase 2 완료 — 자원 경제 & 덱빌딩
- 파산 문책 시스템: 토큰 마이너스 상태로 결재 시 파산 카운터 증가,
의회에서 단계별 경고 → 미개선 시 다음 의회에서 해임 게임오버
- 오염 카드 등급 확률: 엔트로피/내부저항/대중공황/위험계수 단계에 비례한
S/A/B/C 가중치로 의회 종료 시 오염 카드 자동 주입
(신화/조직저항/사회공황/이사회압박 16종 카드 신규 추가)
- 자산 효과 4종 구현: 성질 변환·시한부 격리·물리적 영구 소각·결재권 강제 행사
(자산 슬롯 클릭으로 발동)
2. Phase 3 완료 — 정치 시스템 & 스토리 페이징
- 글로벌 지부장 12개 파벌이 각자 다른 공식으로 우호도를 계산하는 엔진 추가.
게임 시작 시 5명 무작위 선발, 동맹/파국 성향 임원은 국장의 개입(매수/감사)에 면역
- 내러티브 태그 누적치에 따라 페이즈1(일상/위기) → 페이즈2(작전/극비) 카드 풀이
실시간으로 전환되도록 구현
- 버그 수정: 위험 수용 계수(risk)가 직접 게임오버를 발동시키지만 EndingScreen에
대응 엔딩이 없어 "미확인 종말" 빈 폴백으로 빠지던 문제 (risk는 탄핵 기준치로만 사용하도록 수정)
- 버그 수정: CouncilScreen의 임원 초기화 effect가 매수 액션의 risk 상승으로
재실행되며 방금 강제 전환한 투표 결과를 덮어쓰던 문제 (마운트 시 1회만 초기화하도록 수정)
3. Phase 4 완료 — 멀티 엔딩 & 연대기
- 우선순위 기반 엔딩 판별기(endings.js) 신규: 의회 정치 엔딩 > 내러티브 연쇄 엔딩 >
변주된 붕괴 엔딩(자원 태그 3종 + 토큰 3종) > 기본 붕괴 엔딩, 총 15종 엔딩 카탈로그
- 극단적 파라미터 조기 엔딩: 단일 결재로 파라미터가 급격히 5단계에 도달하면
"돌이킬 수 없는 순간" 즉시 발동
- 엔딩 도감(EndingCodex) 신규 컴포넌트: 해금/미해금(???) 표시,
localStorage(ss_endings_v2)에 엔딩 고유 id 기준으로 영구 저장
- 연대기 팝업에 타임라인(세로선 + 턴별 점) 시각화 추가
4. 내러티브 태그 시스템 재설계
- 기존에는 결재 카드 한 장당 무조건 +1 되던 카운터였으나, "항상 얻는 보상이 아니다"라는
기획 의도에 맞춰 선별된 카드(22/63장)의 좌/우 선택지에만 짧은 키워드를 부여하도록 변경
(cards.csv에 left_narrative/right_narrative 컬럼 추가)
- 연대기 팝업 상단과 엔딩 화면에 수집한 키워드를 칩(chip) 형태로 표시
- 위 재설계로 태그 획득이 희소해진 만큼, 페이즈 전환 문턱을 4/8 → 2/4로 하향 조정
5. 기타
- 로컬 Node.js LTS 설치 및 Playwright 기반 실기 플레이 검증 체계 확립
- ROADMAP.md/CLAUDE.md Phase 상태 갱신 (Phase 0~4 그레이박스 구간 완료,
다음은 외부 플레이어 그레이박스 테스트)
This commit is contained in:
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]`/`[~]`/`[ ]` 표기를 수정할 것을 제안하라.
|
||||
95
CLAUDE.md
Normal file
95
CLAUDE.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Project SS — CLAUDE.md
|
||||
> **위치:** repo root (`ProjectSS/CLAUDE.md`) — Claude Code는 root에서 실행한다.
|
||||
> **규칙:** 작업 완료 후 "현재 Phase 상태" 블록을 반드시 업데이트한다.
|
||||
|
||||
---
|
||||
|
||||
## 개발 전략
|
||||
|
||||
**그레이박스 우선 (Greybox First)**
|
||||
게임플레이 재미 검증 → 그레이박스 테스트 → 비주얼 폴리싱 순서로 진행한다.
|
||||
Phase 5(비주얼 폴리싱)는 그레이박스 테스트 피드백 반영 완료 후에만 착수한다.
|
||||
|
||||
---
|
||||
|
||||
## 코드 컨벤션 (package.json으로 알 수 없는 것)
|
||||
|
||||
- 실제 앱은 `client/` 안에 있음. npm 명령은 `cd client` 후 실행.
|
||||
- 스타일: 컴포넌트별 개별 `.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`는 제목 문자열 기반이라 취약했음). 이전 플레이테스트 기록은 새 키와
|
||||
호환되지 않는다. 내러티브 태그는 모든 카드가 아니라 선별된 카드(22/63장)만 키워드를 주는
|
||||
방식으로 재설계했고, 페이즈 전환 문턱도 4/8 → 2/4로 낮췄다. 자세한 내용은 `ROADMAP.md`
|
||||
Phase 4 참고.
|
||||
|
||||
Phase 0~4 그레이박스 구간이 모두 완료됐다. 다음은 외부 플레이어 그레이박스 테스트이며,
|
||||
Phase 5(비주얼 폴리싱)는 그 피드백 반영 후에만 착수한다.
|
||||
|
||||
로컬에 Node.js LTS가 설치되어 `npm run dev`로 실기 검증이 가능하다
|
||||
(Playwright로 실제 플레이까지 확인 완료).
|
||||
|
||||
---
|
||||
|
||||
## 작업 지시 방법
|
||||
|
||||
```
|
||||
/goal Phase 5 진행해줘 # 그레이박스 테스트 완료 후에만
|
||||
```
|
||||
|
||||
작업 완료 후 이 파일의 **현재 Phase 상태** 블록을 업데이트한다.
|
||||
253
ROADMAP.md
Normal file
253
ROADMAP.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# 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]` 완료 · `[~]` 부분 완료(완료 기준 미충족 항목 있음) · `[ ]` 미착수
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계
|
||||
|
||||
**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행)은 이번 범위에 포함하지 않았다.
|
||||
> node/npm이 설치되지 않은 환경이라 `npm run dev` 실기 검증은 못 했고, 코드 리뷰로만 확인했다 — 실행 확인 필요.
|
||||
|
||||
### 핵심 참조 문서
|
||||
- `시스템 기획서` — 결재 단계, 덱 오염, 덱 압축, 자산 슬롯
|
||||
- `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] **스토리 페이징 카드 풀 전환** — 초기 덱은 일상/위기만 포함되고, 내러티브 태그 4개 도달 시 작전/극비 프로젝트 카드 풀이 실시간으로 덱에 개방된다(`GameScreen.jsx` `phase2Pool`/`phase2Unlocked`).
|
||||
|
||||
### 완료 기준 (Done Criteria)
|
||||
- [x] 글로벌 지부장 12개 파벌의 우호도가 각자 다른 조건으로 개별 계산된다.
|
||||
- [x] 파산 카운터가 의회 문책 로직에 연동된다.
|
||||
- [x] 내러티브 태그 4개 도달 시 등장 카드 풀이 실시간으로 전환된다.
|
||||
|
||||
> **참고 및 남은 과제:**
|
||||
> - 상임 이사회 8명의 투표는 여전히 `params.risk` 기반 확률 처리다. 기획서상 이들은 국장의 '성향 태그'에 반응해야 하는데, 이 게임에는 아직 성향 태그 시스템 자체가 없어 범위 밖으로 남겨뒀다(§4.1 대응).
|
||||
> - 태그 8개(페이즈3) 시점의 카드 풀은 기획서상 페이즈2와 동일한 유형(작전/극비/오염)이라 별도 전환 이벤트를 추가하지 않았다. 대신 오염 카드 등급 가중치가 파라미터 상승에 따라 자연스럽게 심해지는 방식으로 "압박 루프" 강도를 구현했다.
|
||||
> - Playwright로 실제 브라우저에서 플레이 검증 중 **`params.risk >= 5`가 직접 게임오버를 발동시키지만 `EndingScreen`에 해당 분기가 없어 "미확인 종말" 폴백으로 빠지는 버그**를 발견해 수정했다(PRD상 risk는 탄핵 기준치일 뿐 직접 붕괴 조건이 아님).
|
||||
|
||||
### 핵심 참조 문서
|
||||
- `스토리 바이블` — §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] **도감 UI** — `EndingCodex.jsx` 신규 컴포넌트. 엔딩 화면에서 "📖 엔딩 도감 보기"로 열람, 해금 15종 중 몇 종 확인했는지 표시, 미해금은 "???"로 잠금 처리, `localStorage`(`ss_endings_v2`)에 엔딩 id 기준으로 영구 저장
|
||||
- [x] **연대기 팝업 완성** — 좌측 세로선 + 턴별 점(dot)으로 타임라인 형태 시각화(`ChroniclePopup.css`)
|
||||
|
||||
### 완료 기준 (Done Criteria)
|
||||
- [x] 파라미터 5단계 도달 시 우선순위 판별기가 정확한 엔딩을 출력한다. (Playwright로 "군사 쿠데타" 변주 엔딩과 "정치적 탄핵" 기본 엔딩을 각각 실제로 트리거해 확인)
|
||||
- [x] 엔딩 화면에 이름 / 서사 텍스트 / 원인 요약이 표시된다.
|
||||
- [x] 해금 엔딩이 도감에 기록되고 재실행 후에도 유지된다. (localStorage 기반, id로 dedup)
|
||||
- [x] 연대기 팝업에서 이번 회차 내러티브 히스토리를 확인할 수 있다.
|
||||
|
||||
> **참고:** 엔딩 도감 localStorage 키를 `ss_endings`(제목 문자열 기반, 취약)에서 `ss_endings_v2`(고유 id 기반)로 교체했다.
|
||||
> 이전에 수동 플레이테스트로 쌓인 `ss_endings` 데이터는 새 키와 호환되지 않아 브라우저에서 도감이 0/15로 보일 수 있다 —
|
||||
> 프로토타입 단계라 마이그레이션 로직은 만들지 않았다.
|
||||
|
||||
> **내러티브 태그 시스템 재설계 (디자이너 피드백 반영):** 최초 구현은 결재 카드 한 장당 무조건 +1 되는
|
||||
> 카운터였는데, 이는 "내러티브 태그는 항상 얻는 것이 아니다"라는 기획 의도와 맞지 않았다. `cards.csv`에
|
||||
> `left_narrative` / `right_narrative` 컬럼을 추가해 선별된 카드(위기/작전/극비 22종 + 오염 카드 A·S등급
|
||||
> 8종, 총 22장/63장)의 좌/우 선택지에만 짧은 키워드(예: "은폐 공작 실행", "쿠데타 조짐")를 부여하도록
|
||||
> 바꿨다. 수집한 키워드는 연대기 팝업 상단과 엔딩 화면에서 칩(chip) 형태로 확인할 수 있다(기존 턴별
|
||||
> 결재 로그는 그대로 유지). 동시에 페이즈 전환 문턱을 4/8 → 2/4로 낮췄다(키워드가 희소해진 만큼, 그리고
|
||||
> 기존 4/8도 실제 플레이에서 길게 느껴진다는 피드백 반영).
|
||||
|
||||
> **이 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]`로 업데이트하고 **현재 상태** 블록을 갱신한다.
|
||||
@@ -47,6 +47,43 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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 {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
@@ -61,16 +98,31 @@
|
||||
|
||||
.chronicle-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
padding: 0 0 0 14px;
|
||||
margin: 0;
|
||||
border-left: 2px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.chronicle-item {
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
padding-left: 16px;
|
||||
border-bottom: 1px dashed rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.chronicle-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -21px;
|
||||
top: 4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-token-info);
|
||||
box-shadow: 0 0 0 3px var(--color-surface);
|
||||
}
|
||||
|
||||
.chronicle-item:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import './ChroniclePopup.css';
|
||||
|
||||
export default function ChroniclePopup({ chronicle, onClose }) {
|
||||
export default function ChroniclePopup({ chronicle, keywords = [], onClose }) {
|
||||
return (
|
||||
<div className="chronicle-overlay" onClick={onClose}>
|
||||
<div className="chronicle-popup" onClick={e => e.stopPropagation()}>
|
||||
@@ -9,6 +9,20 @@ export default function ChroniclePopup({ chronicle, onClose }) {
|
||||
<h2>국장의 연대기</h2>
|
||||
<button className="close-btn" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="keyword-section">
|
||||
<span className="keyword-label">수집한 키워드</span>
|
||||
{keywords.length === 0 ? (
|
||||
<span className="keyword-empty">아직 수집한 키워드가 없습니다.</span>
|
||||
) : (
|
||||
<div className="keyword-chips">
|
||||
{keywords.map((k, idx) => (
|
||||
<span key={idx} className="keyword-chip" title={`Turn ${k.turn}`}>{k.word}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="chronicle-content">
|
||||
{chronicle.length === 0 ? (
|
||||
<p className="empty-text">아직 기록된 역사가 없습니다.</p>
|
||||
|
||||
@@ -148,3 +148,33 @@
|
||||
.council-result h3 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.bankruptcy-warning {
|
||||
margin-top: 12px;
|
||||
color: var(--color-risk-critical);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.council-screen.bankruptcy-termination {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.council-screen.bankruptcy-termination h2 {
|
||||
font-family: var(--font-doc-title);
|
||||
color: var(--color-risk-critical);
|
||||
}
|
||||
|
||||
.bankruptcy-quote {
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.council-screen.bankruptcy-termination .fat-btn {
|
||||
max-width: 280px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,38 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './CouncilScreen.css';
|
||||
|
||||
export default function CouncilScreen({ params, setParams, tokens, setTokens, tags, setTags, onResolve }) {
|
||||
const getBankruptcyTier = (counter) => {
|
||||
if (counter >= 8) return { label: '최종 경고', text: '이사회 최종 경고: 다음에도 장부가 마이너스라면, 당신의 자리는 없습니다.' };
|
||||
if (counter >= 4) return { label: '공식 문책', text: '인사위원회가 소환장을 보냈습니다. 법인카드는 잠시 압수하겠습니다.' };
|
||||
return { label: '가벼운 경고', text: '회계팀이 슬쩍 눈치를 주기 시작했습니다. 영수증 좀 잘 챙기시죠.' };
|
||||
};
|
||||
|
||||
export default function CouncilScreen({ params, setParams, tokens, setTokens, tags, setTags, bankruptcyCounter, bankruptcyWarned, globalFactions = [], onBribe, onResolve }) {
|
||||
const [members, setMembers] = useState([]);
|
||||
const [agenda, setAgenda] = useState('');
|
||||
const [impeachment, setImpeachment] = useState(false);
|
||||
const [actionTaken, setActionTaken] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
|
||||
const isNegative = tokens.budget < 0 || tokens.personnel < 0 || tokens.info < 0;
|
||||
const bankruptcyTermination = isNegative && bankruptcyWarned;
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize 13 members (8 board, 5 global)
|
||||
// Their vote depends on risk parameter and randomness
|
||||
const newMembers = Array.from({ length: 13 }).map((_, i) => {
|
||||
const type = i < 8 ? 'board' : 'global';
|
||||
// High risk = more likely to vote against
|
||||
// 상임 이사회 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, 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;
|
||||
@@ -33,7 +43,10 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
|
||||
const randAgenda = Math.random() > 0.5 ? '이득 규약 법안 상정' : '표적 규약 법안 상정';
|
||||
setAgenda(randAgenda);
|
||||
}
|
||||
}, [params.risk]);
|
||||
// 의회는 마운트 시 1회만 초기화한다 (params.risk를 deps에 넣으면 매수 액션의 risk 상승이
|
||||
// 이 effect를 재실행시켜 방금 강제 전환한 투표를 덮어써버리는 버그가 있었다)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleAction = (type) => {
|
||||
if (actionTaken) return;
|
||||
@@ -42,10 +55,11 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
|
||||
if (type === 'bribe' && tokens.budget >= 30) {
|
||||
setTokens(prev => ({ ...prev, budget: prev.budget - 30 }));
|
||||
setParams(prev => ({ ...prev, risk: Math.min(5, prev.risk + 1) }));
|
||||
// Force 2 against to support
|
||||
onBribe && onBribe();
|
||||
// 동맹/파국 성향 임원은 국장의 개입이 통하지 않는다
|
||||
let changed = 0;
|
||||
for (let m of newMembers) {
|
||||
if (m.vote === '반대' && changed < 2) {
|
||||
if (m.vote === '반대' && !m.immune && changed < 2) {
|
||||
m.vote = '찬성';
|
||||
changed++;
|
||||
}
|
||||
@@ -60,10 +74,10 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
|
||||
} else if (type === 'audit' && tokens.personnel >= 30) {
|
||||
setTokens(prev => ({ ...prev, personnel: prev.personnel - 30 }));
|
||||
setParams(prev => ({ ...prev, resistance: Math.min(5, prev.resistance + 1) }));
|
||||
// Force 3 against to abstain
|
||||
// 동맹/파국 성향 임원은 국장의 개입이 통하지 않는다
|
||||
let changed = 0;
|
||||
for (let m of newMembers) {
|
||||
if (m.vote === '반대' && changed < 3) {
|
||||
if (m.vote === '반대' && !m.immune && changed < 3) {
|
||||
m.vote = '미정';
|
||||
changed++;
|
||||
}
|
||||
@@ -78,16 +92,16 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
|
||||
|
||||
const finalize = () => {
|
||||
if (result) {
|
||||
onResolve({ impeachment: false });
|
||||
onResolve({ impeachment: false, bankruptcy: false, bankruptcyWarning: isNegative });
|
||||
return;
|
||||
}
|
||||
|
||||
const againstCount = members.filter(m => m.vote === '반대').length;
|
||||
if (impeachment) {
|
||||
if (againstCount >= 9) {
|
||||
onResolve({ impeachment: true });
|
||||
onResolve({ impeachment: true, bankruptcy: false, bankruptcyWarning: isNegative });
|
||||
} else {
|
||||
onResolve({ impeachment: false });
|
||||
onResolve({ impeachment: false, bankruptcy: false, bankruptcyWarning: isNegative });
|
||||
}
|
||||
} else {
|
||||
const supportCount = members.filter(m => m.vote === '찬성').length;
|
||||
@@ -98,10 +112,23 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
|
||||
setTags(prev => ({ ...prev, wealth: Math.max(0, prev.wealth - 1) }));
|
||||
}
|
||||
}
|
||||
onResolve({ impeachment: false });
|
||||
onResolve({ impeachment: false, bankruptcy: false, bankruptcyWarning: isNegative });
|
||||
}
|
||||
};
|
||||
|
||||
if (bankruptcyTermination) {
|
||||
return (
|
||||
<div className="council-screen bankruptcy-termination">
|
||||
<h2>🚨 재정 파탄 특별 청문회</h2>
|
||||
<p>지난 경고에도 불구하고 장부는 여전히 마이너스입니다. 이사회는 더 이상 변명을 듣지 않기로 했습니다.</p>
|
||||
<p className="bankruptcy-quote">"숫자는 거짓말을 하지 않습니다. 당신의 자리도 이제 안녕입니다."</p>
|
||||
<button className="fat-btn" onClick={() => onResolve({ impeachment: false, bankruptcy: true, bankruptcyWarning: true })}>해임 통보 수령</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tier = getBankruptcyTier(bankruptcyCounter);
|
||||
|
||||
return (
|
||||
<div className="council-screen">
|
||||
<div className="council-header">
|
||||
@@ -113,11 +140,14 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta
|
||||
</div>
|
||||
</div>
|
||||
{impeachment && <div className="warning-blink">[🚨 정치적 탄핵안 상정!]</div>}
|
||||
{isNegative && !bankruptcyWarned && (
|
||||
<div className="bankruptcy-warning">⚠️ [{tier.label}] {tier.text}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="members-grid">
|
||||
{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-badge">
|
||||
{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;
|
||||
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 EndingCodex, { ENDINGS_STORAGE_KEY } from './EndingCodex';
|
||||
import './EndingScreen.css';
|
||||
|
||||
export default function EndingScreen({ params, impeachment, narrativeTags, onRestart }) {
|
||||
const [endingInfo, setEndingInfo] = useState({ title: '', desc: '', reason: '' });
|
||||
export default function EndingScreen({ ending, narrativeTags, keywords = [], onRestart }) {
|
||||
const [showCodex, setShowCodex] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Determine ending
|
||||
let title = '미확인 종말';
|
||||
let desc = '기록되지 않은 파멸입니다.';
|
||||
let reason = '알 수 없는 원인';
|
||||
|
||||
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단계 도달';
|
||||
if (!ending) return;
|
||||
const collection = JSON.parse(localStorage.getItem(ENDINGS_STORAGE_KEY) || '[]');
|
||||
if (!collection.includes(ending.id)) {
|
||||
collection.push(ending.id);
|
||||
localStorage.setItem(ENDINGS_STORAGE_KEY, JSON.stringify(collection));
|
||||
}
|
||||
}, [ending]);
|
||||
|
||||
setEndingInfo({ title, desc, reason });
|
||||
|
||||
// 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]);
|
||||
if (!ending) return null;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<p className="ending-desc">{endingInfo.desc}</p>
|
||||
<p className="ending-desc">{ending.desc}</p>
|
||||
<div className="ending-reason">
|
||||
<strong>발동 원인:</strong> {endingInfo.reason}
|
||||
<strong>발동 원인:</strong> {ending.reason}
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,6 +133,15 @@
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.token.negative {
|
||||
color: var(--color-risk-critical);
|
||||
}
|
||||
|
||||
.token.shield-badge {
|
||||
font-size: 14px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 14px;
|
||||
color: var(--color-tag-neutral);
|
||||
@@ -155,6 +164,24 @@
|
||||
display: flex;
|
||||
align-items: 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;
|
||||
}
|
||||
|
||||
/* Game Over */
|
||||
|
||||
@@ -6,125 +6,288 @@ import SwipeCard from './SwipeCard';
|
||||
import CouncilScreen from './CouncilScreen';
|
||||
import EndingScreen from './EndingScreen';
|
||||
import ChroniclePopup from './ChroniclePopup';
|
||||
import { CHAIRMAN_AXES, isNarrativeChainComplete, resolveEnding, getEndingById } from '../data/endings';
|
||||
import './GameScreen.css';
|
||||
|
||||
// 오염 카드 타입 <-> 파라미터 매핑 (파산/오염 등급 시스템)
|
||||
const CONTAM_TYPE_BY_PARAM = {
|
||||
entropy: '신화',
|
||||
resistance: '조직저항',
|
||||
panic: '사회공황',
|
||||
risk: '이사회압박',
|
||||
};
|
||||
const CONTAM_TYPES = new Set(Object.values(CONTAM_TYPE_BY_PARAM));
|
||||
|
||||
// 파라미터 단계(1~5)가 높을수록 높은 등급(S/A) 확률이 커지는 가중치 테이블
|
||||
const GRADE_WEIGHTS = {
|
||||
1: { C: 70, B: 22, A: 6, S: 2 },
|
||||
2: { C: 50, B: 28, A: 15, S: 7 },
|
||||
3: { C: 32, B: 30, A: 23, S: 15 },
|
||||
4: { C: 18, B: 24, A: 32, S: 26 },
|
||||
5: { C: 8, B: 17, A: 33, S: 42 },
|
||||
};
|
||||
|
||||
const pickGrade = (level) => {
|
||||
const weights = GRADE_WEIGHTS[Math.min(5, Math.max(1, level))];
|
||||
const total = Object.values(weights).reduce((a, b) => a + b, 0);
|
||||
let r = Math.random() * total;
|
||||
for (const [grade, w] of Object.entries(weights)) {
|
||||
if (r < w) return grade;
|
||||
r -= w;
|
||||
}
|
||||
return 'C';
|
||||
};
|
||||
|
||||
// 내러티브 키워드 누적치에 따른 페이즈 전환 문턱 (기존 4/8은 실제 플레이에서 너무 길게 느껴져 하향 조정)
|
||||
const PHASE2_TAG_THRESHOLD = 2;
|
||||
const PHASE3_TAG_THRESHOLD = 4;
|
||||
|
||||
const ASSET_META = {
|
||||
transform: { icon: '🧪', label: '성질 변환' },
|
||||
quarantine: { icon: '⏳', label: '시한부 격리' },
|
||||
purge: { icon: '🔥', label: '물리적 영구 소각' },
|
||||
mulligan: { icon: '🛡️', label: '결재권 강제 행사' },
|
||||
};
|
||||
|
||||
// 글로벌 지부장 12개 파벌 풀 (게임 시작 시 5명 무작위 선발, 실리에 따라 우호도 개별 계산)
|
||||
const GLOBAL_FACTIONS = [
|
||||
{ id: 'military', name: '군산복합 연합', computeAffinity: (c) => c.tags.force * 15 + c.tokens.personnel * 0.4 - 40 },
|
||||
{ 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.narrativeTags * 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,
|
||||
text: row.text,
|
||||
flavor: row.flavor,
|
||||
grade: row.grade || null,
|
||||
contaminated: CONTAM_TYPES.has(row.type),
|
||||
left: {
|
||||
text: row.left_text,
|
||||
narrativeTag: row.left_narrative || 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,
|
||||
narrativeTag: row.right_narrative || 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const transformCard = (card) => {
|
||||
const flip = (stats) => ({ ...stats, res: -stats.res, ent: -stats.ent, pan: -stats.pan, rsk: -stats.rsk });
|
||||
return {
|
||||
...card,
|
||||
transformed: true,
|
||||
text: card.transformed ? card.text : `${card.text} (순화됨)`,
|
||||
left: { ...card.left, stats: flip(card.left.stats) },
|
||||
right: { ...card.right, stats: flip(card.right.stats) },
|
||||
};
|
||||
};
|
||||
|
||||
export function GameScreen() {
|
||||
const [params, setParams] = useState({ resistance: 0, entropy: 0, panic: 0, risk: 0 });
|
||||
const [tokens, setTokens] = useState({ budget: 50, personnel: 50, info: 50 });
|
||||
const [tags, setTags] = useState({ wealth: 0, force: 0, surveillance: 0 });
|
||||
const [narrativeTags, setNarrativeTags] = useState(0);
|
||||
const [keywords, setKeywords] = useState([]);
|
||||
const [phase, setPhase] = useState(1);
|
||||
const [gameOver, setGameOver] = useState(false);
|
||||
const [cards, setCards] = useState([]);
|
||||
const [contaminationTemplates, setContaminationTemplates] = useState([]);
|
||||
const [phase2Pool, setPhase2Pool] = useState([]);
|
||||
const [phase2Unlocked, setPhase2Unlocked] = useState(false);
|
||||
const [currentCardIndex, setCurrentCardIndex] = useState(0);
|
||||
|
||||
|
||||
const [turns, setTurns] = useState(0);
|
||||
const [showCouncil, setShowCouncil] = useState(false);
|
||||
const [impeachmentPassed, setImpeachmentPassed] = useState(false);
|
||||
const [chronicle, setChronicle] = useState([]);
|
||||
const [showChronicle, setShowChronicle] = useState(false);
|
||||
const [assets, setAssets] = useState([]);
|
||||
|
||||
// CSV Load Effect
|
||||
// 파산 문책 시스템
|
||||
const [bankruptcyCounter, setBankruptcyCounter] = useState(0);
|
||||
const [bankruptcyWarned, setBankruptcyWarned] = useState(false);
|
||||
|
||||
// 자산 효과 상태
|
||||
const [mulliganShields, setMulliganShields] = useState(0);
|
||||
const [quarantineZone, setQuarantineZone] = useState([]);
|
||||
const [assetsAcquiredCount, setAssetsAcquiredCount] = useState(0);
|
||||
const [purgeCount, setPurgeCount] = useState(0);
|
||||
|
||||
// 글로벌 지부장 파벌 (게임 시작 시 12개 중 5개 무작위 선발)
|
||||
const [factionRoster, setFactionRoster] = useState([]);
|
||||
const [bribeCount, setBribeCount] = useState(0);
|
||||
|
||||
// 멀티 엔딩 판별기 상태
|
||||
const [finalEnding, setFinalEnding] = useState(null);
|
||||
const [chairmanAxis, setChairmanAxis] = useState(null);
|
||||
const [lastParamDelta, setLastParamDelta] = useState(null);
|
||||
const [contaminationSequence, setContaminationSequence] = useState([]);
|
||||
const [narrativeChainUnlocked, setNarrativeChainUnlocked] = useState(false);
|
||||
|
||||
// CSV Load Effect — 페이즈1(일상/위기) / 페이즈2(작전/극비) 카드 풀 분리
|
||||
useEffect(() => {
|
||||
const parsed = Papa.parse(cardsCsvRaw, { header: true, dynamicTyping: true, skipEmptyLines: true });
|
||||
|
||||
const formattedCards = parsed.data.map(row => ({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
text: row.text,
|
||||
flavor: row.flavor,
|
||||
left: {
|
||||
text: row.left_text,
|
||||
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,
|
||||
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));
|
||||
const rows = parsed.data.filter(row => row.id);
|
||||
|
||||
const phase1Rows = rows.filter(row => row.type === '일상' || row.type === '위기');
|
||||
const phase2Rows = rows.filter(row => row.type === '작전' || row.type === '극비');
|
||||
const templates = rows.filter(row => CONTAM_TYPES.has(row.type)).map(buildCardFromRow);
|
||||
|
||||
setCards(phase1Rows.map(buildCardFromRow).sort(() => Math.random() - 0.5));
|
||||
setPhase2Pool(phase2Rows.map(buildCardFromRow));
|
||||
setContaminationTemplates(templates);
|
||||
}, []);
|
||||
|
||||
// 글로벌 지부장 5명 선발 (게임 시작 시 1회)
|
||||
useEffect(() => {
|
||||
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 || narrativeTags < 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, text: '[페이징] 페이즈 2 진입 - 작전 / 극비 프로젝트 카드 풀이 개방되었습니다.' }]);
|
||||
}, [narrativeTags, phase2Pool, phase2Unlocked, currentCardIndex, turns]);
|
||||
|
||||
useEffect(() => {
|
||||
// 위험 수용 계수(risk)는 PRD상 탄핵/법안 상정의 기준치일 뿐, 직접적인 게임오버 트리거가 아니다.
|
||||
if (!gameOver && (params.resistance >= 5 || params.entropy >= 5 || params.panic >= 5)) {
|
||||
const ending = resolveEnding({
|
||||
trigger: 'collapse', params, tags, tokens,
|
||||
lastDelta: lastParamDelta, narrativeChainUnlocked,
|
||||
});
|
||||
setFinalEnding(ending);
|
||||
setGameOver(true);
|
||||
}
|
||||
if (narrativeTags >= 4 && narrativeTags < 8) setPhase(2);
|
||||
else if (narrativeTags >= 8) setPhase(3);
|
||||
if (narrativeTags >= PHASE2_TAG_THRESHOLD && narrativeTags < PHASE3_TAG_THRESHOLD) setPhase(2);
|
||||
else if (narrativeTags >= PHASE3_TAG_THRESHOLD) setPhase(3);
|
||||
}, [params, narrativeTags]);
|
||||
|
||||
// 시한부 격리된 오염 카드 복귀
|
||||
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, text: `[자산] 격리했던 오염 카드 ${ready.length}건이 덱으로 복귀했습니다.` }]);
|
||||
}
|
||||
}, [turns]);
|
||||
|
||||
const applySide = (side, nullifyDanger) => {
|
||||
const s = side.stats;
|
||||
setParams(p => ({
|
||||
resistance: Math.max(0, p.resistance + (nullifyDanger ? 0 : s.res)),
|
||||
entropy: Math.max(0, p.entropy + (nullifyDanger ? 0 : s.ent)),
|
||||
panic: Math.max(0, p.panic + (nullifyDanger ? 0 : s.pan)),
|
||||
risk: Math.max(0, p.risk + (nullifyDanger ? 0 : s.rsk)),
|
||||
}));
|
||||
setTokens(t => ({
|
||||
budget: t.budget + s.bud,
|
||||
personnel: t.personnel + s.per,
|
||||
info: t.info + s.inf,
|
||||
}));
|
||||
setTags(tg => ({
|
||||
wealth: tg.wealth + s.wea,
|
||||
force: tg.force + s.for,
|
||||
surveillance: tg.surveillance + s.sur,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSwipe = (direction) => {
|
||||
if (cards.length === 0) return;
|
||||
const card = cards[currentCardIndex % cards.length];
|
||||
|
||||
let outcomeText = '';
|
||||
|
||||
if (direction === 'left' && card.left) {
|
||||
card.left.effect();
|
||||
outcomeText = card.left.text;
|
||||
} else if (direction === 'right' && card.right) {
|
||||
card.right.effect();
|
||||
outcomeText = card.right.text;
|
||||
let outcomeText = '';
|
||||
const side = direction === 'left' ? card.left : direction === 'right' ? card.right : null;
|
||||
|
||||
if (side) {
|
||||
const shouldNullify = card.contaminated && mulliganShields > 0;
|
||||
applySide(side, shouldNullify);
|
||||
outcomeText = side.text;
|
||||
|
||||
// 파산 문책: 결재 결과 토큰이 마이너스가 되면 파산 카운터 증가
|
||||
const nextBudget = tokens.budget + side.stats.bud;
|
||||
const nextPersonnel = tokens.personnel + side.stats.per;
|
||||
const nextInfo = tokens.info + side.stats.inf;
|
||||
if (nextBudget < 0 || nextPersonnel < 0 || nextInfo < 0) {
|
||||
setBankruptcyCounter(c => c + 1);
|
||||
}
|
||||
|
||||
// 극단적 파라미터 조기 엔딩 판별용: 이번 결재에서 가장 크게 증가한 위험 파라미터 추적
|
||||
if (!shouldNullify) {
|
||||
const dangerDeltas = { resistance: side.stats.res, entropy: side.stats.ent, panic: side.stats.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 });
|
||||
}
|
||||
|
||||
// 내러티브 키워드: 모든 카드가 아니라 선택된 일부 카드/선택지만 키워드를 부여한다
|
||||
if (side.narrativeTag) {
|
||||
setKeywords(prev => [...prev, { turn: turns + 1, word: side.narrativeTag }]);
|
||||
setNarrativeTags(n => n + 1);
|
||||
}
|
||||
|
||||
// 내러티브 연쇄 엔딩: 오염 카드 4종이 정해진 순서로 등장했는지 추적
|
||||
if (card.contaminated) {
|
||||
const nextSequence = [...contaminationSequence, card.type];
|
||||
setContaminationSequence(nextSequence);
|
||||
if (!narrativeChainUnlocked && isNarrativeChainComplete(nextSequence)) {
|
||||
setNarrativeChainUnlocked(true);
|
||||
setChronicle(prev => [...prev, { turn: turns + 1, text: '[내러티브] 사건들의 연쇄가 완성되었습니다 - 진실에 가까워졌습니다.' }]);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldNullify) {
|
||||
setMulliganShields(s => s - 1);
|
||||
outcomeText += ' (방어막으로 페널티 무효화)';
|
||||
}
|
||||
} else if (direction === 'up') {
|
||||
outcomeText = '보류 / 버리기';
|
||||
}
|
||||
@@ -132,31 +295,108 @@ export function GameScreen() {
|
||||
if (outcomeText) {
|
||||
setChronicle(prev => [...prev, { turn: turns + 1, text: `[${card.type}] ${card.text} -> 결정: ${outcomeText}` }]);
|
||||
}
|
||||
|
||||
// Add random asset for '극비' success (just for demonstration)
|
||||
|
||||
// 극비 프로젝트 성공 시 자산 획득 (4종 중 랜덤)
|
||||
if (card.type === '극비' && direction !== 'up') {
|
||||
if (assets.length < 4) {
|
||||
setAssets(prev => [...prev, { id: Date.now(), icon: ['📦','💎','🧪','💾'][Math.floor(Math.random()*4)] }]);
|
||||
const types = Object.keys(ASSET_META);
|
||||
const type = types[Math.floor(Math.random() * types.length)];
|
||||
setAssets(prev => [...prev, { id: Date.now(), type, ...ASSET_META[type] }]);
|
||||
setAssetsAcquiredCount(c => c + 1);
|
||||
}
|
||||
}
|
||||
|
||||
setNarrativeTags(n => n + 1);
|
||||
setCurrentCardIndex(idx => idx + 1);
|
||||
|
||||
|
||||
const newTurns = turns + 1;
|
||||
setTurns(newTurns);
|
||||
|
||||
|
||||
if (newTurns > 0 && newTurns % 10 === 0) {
|
||||
setShowCouncil(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 그림자 의회 종료 후 파라미터 단계에 비례한 등급 가중치로 오염 카드 주입
|
||||
const injectContaminationCards = () => {
|
||||
const newCards = [];
|
||||
Object.entries(CONTAM_TYPE_BY_PARAM).forEach(([paramKey, cardType]) => {
|
||||
const level = params[paramKey];
|
||||
if (level < 1) return;
|
||||
const grade = pickGrade(level);
|
||||
const pool = contaminationTemplates.filter(t => t.type === cardType && t.grade === grade);
|
||||
if (pool.length === 0) return;
|
||||
const template = pool[Math.floor(Math.random() * pool.length)];
|
||||
newCards.push({ ...template, id: `${template.id}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` });
|
||||
});
|
||||
|
||||
if (newCards.length > 0) {
|
||||
setCards(prev => {
|
||||
const insertAt = Math.min(prev.length, currentCardIndex + 1);
|
||||
const next = [...prev];
|
||||
next.splice(insertAt, 0, ...newCards);
|
||||
return next;
|
||||
});
|
||||
setChronicle(prev => [...prev, { turn: turns, text: `[오염] 의회 종료 후 신규 오염 카드 ${newCards.length}건이 덱에 스며들었습니다.` }]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCouncilResolve = (result) => {
|
||||
setShowCouncil(false);
|
||||
if (result.impeachment) {
|
||||
setImpeachmentPassed(true);
|
||||
|
||||
if (result.bankruptcy) {
|
||||
setFinalEnding(getEndingById('bankruptcy'));
|
||||
setGameOver(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setBankruptcyWarned(result.bankruptcyWarning);
|
||||
|
||||
if (result.impeachment) {
|
||||
const ending = resolveEnding({ trigger: 'impeachment', tags, chairmanAxis, narrativeChainUnlocked });
|
||||
setFinalEnding(ending);
|
||||
setGameOver(true);
|
||||
return;
|
||||
}
|
||||
|
||||
injectContaminationCards();
|
||||
};
|
||||
|
||||
// 자산 효과 4종 발동
|
||||
const activateAsset = (asset) => {
|
||||
if (asset.type === 'mulligan') {
|
||||
setMulliganShields(s => s + 1);
|
||||
setAssets(prev => prev.filter(a => a.id !== asset.id));
|
||||
setChronicle(prev => [...prev, { turn: turns, text: '[자산] 결재권 강제 행사 방어막을 전개했습니다. 다음 오염 카드의 페널티가 무효화됩니다.' }]);
|
||||
return;
|
||||
}
|
||||
|
||||
const contaminatedIdx = [];
|
||||
cards.forEach((c, i) => { if (c.contaminated) contaminatedIdx.push(i); });
|
||||
|
||||
if (contaminatedIdx.length === 0) {
|
||||
setChronicle(prev => [...prev, { turn: turns, text: `[자산] ${asset.label}을(를) 발동했지만 대상이 될 오염 카드가 없습니다.` }]);
|
||||
setAssets(prev => prev.filter(a => a.id !== asset.id));
|
||||
return;
|
||||
}
|
||||
|
||||
if (asset.type === 'purge') {
|
||||
const target = contaminatedIdx[Math.floor(Math.random() * contaminatedIdx.length)];
|
||||
setCards(prev => prev.filter((_, i) => i !== target));
|
||||
setPurgeCount(c => c + 1);
|
||||
setChronicle(prev => [...prev, { turn: turns, text: '[자산] 오염 카드를 물리적으로 영구 소각했습니다.' }]);
|
||||
} else if (asset.type === 'quarantine') {
|
||||
const targetSet = new Set(contaminatedIdx.slice(0, 2));
|
||||
const quarantined = cards.filter((c, i) => targetSet.has(i));
|
||||
setCards(prev => prev.filter((_, i) => !targetSet.has(i)));
|
||||
setQuarantineZone(prev => [...prev, ...quarantined.map(c => ({ ...c, returnTurn: turns + 5 }))]);
|
||||
setChronicle(prev => [...prev, { turn: turns, text: `[자산] 오염 카드 ${quarantined.length}건을 시한부 격리했습니다. (5턴 후 덱으로 복귀)` }]);
|
||||
} else if (asset.type === 'transform') {
|
||||
const target = contaminatedIdx[Math.floor(Math.random() * contaminatedIdx.length)];
|
||||
setCards(prev => prev.map((c, i) => (i === target ? transformCard(c) : c)));
|
||||
setChronicle(prev => [...prev, { turn: turns, text: '[자산] 오염 카드의 성질을 영구적으로 순화했습니다. (독을 약으로)' }]);
|
||||
}
|
||||
|
||||
setAssets(prev => prev.filter(a => a.id !== asset.id));
|
||||
};
|
||||
|
||||
const renderDots = (value, max = 5) => {
|
||||
@@ -167,21 +407,38 @@ export function GameScreen() {
|
||||
|
||||
if (gameOver) {
|
||||
return (
|
||||
<EndingScreen
|
||||
params={params}
|
||||
impeachment={impeachmentPassed}
|
||||
narrativeTags={narrativeTags}
|
||||
onRestart={() => window.location.reload()}
|
||||
<EndingScreen
|
||||
ending={finalEnding}
|
||||
narrativeTags={narrativeTags}
|
||||
keywords={keywords}
|
||||
onRestart={() => window.location.reload()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (showCouncil) {
|
||||
// 글로벌 지부장 12개 파벌 중 이번 회차 선발된 5명의 우호도를 각자 다른 조건으로 개별 계산
|
||||
const affinityCtx = {
|
||||
tags, tokens, params,
|
||||
assetsCount: assets.length,
|
||||
mythicCount: cards.filter(c => c.type === '신화').length,
|
||||
narrativeTags, 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 (
|
||||
<CouncilScreen
|
||||
<CouncilScreen
|
||||
params={params} setParams={setParams}
|
||||
tokens={tokens} setTokens={setTokens}
|
||||
tags={tags} setTags={setTags}
|
||||
bankruptcyCounter={bankruptcyCounter}
|
||||
bankruptcyWarned={bankruptcyWarned}
|
||||
globalFactions={globalFactions}
|
||||
onBribe={() => setBribeCount(c => c + 1)}
|
||||
onResolve={handleCouncilResolve}
|
||||
/>
|
||||
);
|
||||
@@ -192,13 +449,14 @@ export function GameScreen() {
|
||||
}
|
||||
|
||||
const phaseClass = phase === 1 ? 'phase-1' : phase === 2 ? 'phase-2' : 'phase-3';
|
||||
const nextTagThreshold = phase === 1 ? PHASE2_TAG_THRESHOLD : phase === 2 ? PHASE3_TAG_THRESHOLD : null;
|
||||
|
||||
return (
|
||||
<div className={`game-screen ${phaseClass}`}>
|
||||
{/* Top Bar */}
|
||||
<div className="top-bar">
|
||||
<div className="narrative-badge" onClick={() => setShowChronicle(true)} style={{cursor: 'pointer'}}>
|
||||
<BookOpen size={16} /> <span>{narrativeTags}/8</span>
|
||||
<BookOpen size={16} /> <span>{narrativeTags}{nextTagThreshold !== null ? `/${nextTagThreshold}` : ''}</span>
|
||||
</div>
|
||||
<div className="parameters">
|
||||
<div className={`param ${params.resistance >= 4 ? 'critical' : ''}`} style={{'--param-color': 'var(--color-resistance-critical)'}}>
|
||||
@@ -222,8 +480,8 @@ export function GameScreen() {
|
||||
|
||||
{/* Card Area */}
|
||||
<div className="card-area">
|
||||
<SwipeCard
|
||||
card={cards[currentCardIndex % cards.length]}
|
||||
<SwipeCard
|
||||
card={cards[currentCardIndex % cards.length]}
|
||||
onSwipe={handleSwipe}
|
||||
phase={phase}
|
||||
/>
|
||||
@@ -232,9 +490,10 @@ export function GameScreen() {
|
||||
{/* Bottom Inventory */}
|
||||
<div className="bottom-bar">
|
||||
<div className="tokens-row">
|
||||
<div className="token"><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"><Eye size={16} color="var(--color-token-info)" /> <span>{tokens.info}</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 ${tokens.personnel < 0 ? 'negative' : ''}`}><Droplet size={16} color="var(--color-token-personnel)" /> <span>{tokens.personnel}</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 className="tags-row">
|
||||
<div className="tag"><Building2 size={14} /> <span>{tags.wealth}</span></div>
|
||||
@@ -242,18 +501,28 @@ export function GameScreen() {
|
||||
<div className="tag"><EyeOff size={14} /> <span>{tags.surveillance}</span></div>
|
||||
</div>
|
||||
<div className="asset-slots">
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
<div key={i} className="asset-slot">
|
||||
{assets[i] ? <span className="asset-icon">{assets[i].icon}</span> : ''}
|
||||
</div>
|
||||
))}
|
||||
{[0, 1, 2, 3].map(i => {
|
||||
const asset = assets[i];
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
className={`asset-slot ${asset ? 'filled' : ''}`}
|
||||
onClick={() => asset && activateAsset(asset)}
|
||||
disabled={!asset}
|
||||
title={asset ? asset.label : '빈 슬롯'}
|
||||
>
|
||||
{asset ? <span className="asset-icon">{asset.icon}</span> : ''}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showChronicle && (
|
||||
<ChroniclePopup
|
||||
chronicle={chronicle}
|
||||
onClose={() => setShowChronicle(false)}
|
||||
<ChroniclePopup
|
||||
chronicle={chronicle}
|
||||
keywords={keywords}
|
||||
onClose={() => setShowChronicle(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,49 @@
|
||||
id,type,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,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,grade,left_narrative,right_narrative
|
||||
1,일상,B구역 하급 요원들이 예산 삭감에 반발해 장비 반납 시위를 하고 있습니다.,우리가 세상을 구하는데 월급이 깎인다고요? 촉수에 맞아 죽으나 굶어 죽으나 똑같습니다!,무력 진압,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명의 부피를 초과했습니다.,보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다.,네이팜 소각 강행,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
|
||||
4,극비,진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠.,인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠.,실험 승인,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
|
||||
4,극비,진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠.,인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠.,실험 승인,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,,민간인 희생 묵인,온건 노선 고수
|
||||
6,일상,차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다.,이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠.,매각 허가,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의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다.,우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다.,감시망 구축,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
|
||||
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
|
||||
11,위기,특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다.,위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다.,군사 개입 지시,1,0,1,0,0,-40,0,0,0,0,의식 방관,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,위기,의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다!,세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오.,은폐 공작 실행,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
|
||||
14,작전,이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다.,저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠.,게이트 강제 개방,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
|
||||
14,작전,이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다.,저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠.,게이트 강제 개방,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,,비자금 장부 발견,과거를 봉인
|
||||
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% 더 아낄 수 있습니다.,시민 포기 및 폭격,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
|
||||
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
|
||||
21,작전,순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다.,괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다.,수용소 건립 승인,0,0,0,0,-40,0,0,0,1,0,제안 단호히 거절,1,0,0,1,0,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,,공포 정치 시작,의혹 은폐
|
||||
21,작전,순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다.,괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다.,수용소 건립 승인,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
|
||||
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
|
||||
25,극비,의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다.,국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다.,사고사 위장 지시,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
|
||||
25,극비,의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다.,국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다.,사고사 위장 지시,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,,목숨 건 해체 작전,구역 포기 결정
|
||||
27,작전,군부와 결탁하여 민간 군사 기업(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
|
||||
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명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다.,거래 즉시 성사,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
|
||||
31,위기,신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다!,최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까?,현실 조작기 가동,0,2,0,0,0,0,-50,0,0,0,모든 예비병력 투입,0,0,0,1,0,-60,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,,현실 왜곡 감행,총력전 돌입
|
||||
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,부화 과정 관찰 지속,0,2,1,0,0,0,0,0,0,0,A,미지의 알 폐기,금단의 부화 관찰
|
||||
35,신화,장막 너머의 존재가 마침내 자신의 진짜 이름을 발음하기 시작했습니다. 듣는 요원들의 코피가 멈추지 않습니다.,언어병리학자는 이름의 세 음절만 듣고도 실어증에 걸렸습니다.,전 요원 청각 차단 및 격리 봉쇄,0,1,0,0,-70,-40,0,0,0,0,이름을 끝까지 듣고 기록한다,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,복귀할 때까지 방치,2,0,1,0,0,0,0,0,0,0,A,태업 진압,조직 와해 방관
|
||||
39,조직저항,전 부서장급 열두 명이 동시 사표를 제출하며 내부 기밀을 언론에 흘리겠다 협박합니다.,이것은 항명이 아니라 쿠데타입니다.,전원 요구 수용 및 대규모 숙청,1,0,0,0,-60,-50,0,0,0,0,협박을 무시하고 강경 대응,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,질서 유지는 각자 알아서,1,0,2,0,0,0,0,0,0,0,A,비상 물자 방출,치안 붕괴 방관
|
||||
43,사회공황,종말론에 심취한 신도 수천 명이 본부 앞으로 행진하며 심판을 요구합니다.,그들의 팻말에는 이상하게도 정확한 좌표가 적혀 있었습니다.,전면 계엄 수준의 통제 발동,0,1,1,0,-50,-30,0,0,0,0,군중 앞에서 진실을 공개한다,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,이사회압박,긴급 임시 이사회가 소집되어 국장의 결재권 일부 정지를 논의합니다.,의제 제목이 국장의 독단 경영에 대하여입니다.,핵심 임원 개별 설득,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,청문회를 보이콧한다,1,1,0,3,0,0,0,0,0,0,S,청문회 사수,청문회 보이콧
|
||||
,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
|
119
client/src/data/endings.js
Normal file
119
client/src/data/endings.js
Normal file
@@ -0,0 +1,119 @@
|
||||
// 멀티 엔딩 판별기 (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: '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]];
|
||||
}
|
||||
Reference in New Issue
Block a user