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:
kyoung5seo
2026-07-09 22:46:30 +09:00
parent c0921548ec
commit ad7db470f6
15 changed files with 1266 additions and 193 deletions

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

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

95
CLAUDE.md Normal file
View 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
View 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]`로 업데이트하고 **현재 상태** 블록을 갱신한다.

View File

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

View File

@@ -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}>&times;</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>

View File

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

View File

@@ -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 === '반대' ? '❌' : '❓'}

View File

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

View File

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

View File

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

View File

@@ -1,56 +1,46 @@
import React, { useEffect, useState } from 'react';
import 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>
);
}

View File

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

View File

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

View File

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

119
client/src/data/endings.js Normal file
View 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]];
}