Compare commits

19 Commits
Author SHA1 Message Date
kyoung5seo 8833e52fab CLAUDE.md 정리 (중복, 충돌, 개선)
파일 역할 분담 개선
ProjectSS/
├── CLAUDE.md      ← 규칙 (거의 안 변함) · Claude Code용
├── AGENTS.md      ← 프로젝트 정체성 (거의 안 변함) · Hermes용
├── ROADMAP.md     ← Phase별 계획·완료 기준 (기존 그대로)
└── docs/
    └── DEVLOG.md  ← 신규: 날짜별 작업 이력 + 미해결 이슈
2026-08-06 22:19:13 +09:00
kyoung5seo f64c4f3375 Hermes용 Project SS 에이전트 규칙 2026-08-04 23:12:12 +09:00
kyoung5seoandClaude Sonnet 5 db1051c08c 자산(엔진) 시스템 v1 + 오염 카드 압박 강화
자산 시스템 (docs/작업지시서/자산시스템_작업지시서.md 반영, 이전 세션 작업분 커밋):
- 발라트로형 조커 레이어 엔진 빌딩 도입. assets.json 8종(트리거→효과→성장 스키마) +
  engine/assetEngine.js(이벤트 버스, 연쇄 깊이 상한 10)로 기존 수동 클릭형 자산 4종 전면 대체
- AssetInfoPopup 추가 — 자산 슬롯 클릭 시 트리거/효과/스택/충전 상태 표시, 발동 로그 피드 노출

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

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

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

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

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

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

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

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

5. 기타
   - 로컬 Node.js LTS 설치 및 Playwright 기반 실기 플레이 검증 체계 확립
   - ROADMAP.md/CLAUDE.md Phase 상태 갱신 (Phase 0~4 그레이박스 구간 완료,
     다음은 외부 플레이어 그레이박스 테스트)
2026-07-09 22:46:30 +09:00
Kyoung5seo c67914c248 feat: add initial cards dataset to client data directory 2026-06-25 22:51:30 +09:00
Kyoung5seo 4c44f942c5 내러티브 태그 시스템 개선
요청하신 내러티브 태그 시스템과 시각화 작업을 모두 완료했습니다! 이전에 멈췄던 개발 서버( npm run dev )도 다시 실행해
  두었으니, 새로고침 후 바로 확인해 보실 수 있습니다.
  구현된 시스템의 주요 변경점은 다음과 같습니다.
  ### 1. 내러티브 데이터 생성 및 연동 ( narrative_tags.json ,  cards.csv )

  • 기획서의 세계관을 바탕으로 피도 눈물도 없는 진압, 진실의 장막, 금지된 지식(이계 조우), 사이비의 그림자 등 서사
  몰입을 돕는 내러티브 태그 데이터를 생성했습니다.
  • 매 결재마다 무조건 얻는 것이 아니라, 특정 카드(예: 민간인 구역 폭격, 유물 연구 등)에서 특정한 선택을 했을 때만
  불규칙적으로 획득하도록  cards.csv  데이터를 스크립트로 자동 매핑했습니다.

  ### 2. 결재 시 스와이프 보상 시각화 ( SwipeCard.jsx ,  SwipeCard.css )
  • 내러티브 태그가 걸려 있는 선택지 쪽으로 스와이프(미리보기)를 하면, 텍스트 상단에 노란색 바탕의 뱃지( 💡 진실의 장막
  )가 심장 박동처럼 깜빡이는 애니메이션과 함께 아주 눈에 띄게 표시됩니다. 플레이어가 "아, 이 선택을 하면 스토리에 큰
  영향을 주는구나"를 직관적으로 알 수 있습니다.
  ### 3. 태그 획득 알림 팝업 ( GameScreen.jsx )

  • 결재를 완료하여 내러티브 태그를 얻는 순간, 화면 중앙 상단에  [새로운 내러티브 획득! - 금지된 지식]  형태의 눈에
  띄는 팝업 알림이 내려왔다가 3초 뒤 자연스럽게 사라지도록 구현했습니다.

  ### 4. 연대기 창 서사 부각 개편 ( ChroniclePopup.jsx ,  ChroniclePopup.css )

  • 단순히 텍스트만 나열되던 방식을 타임라인(Timeline) 형식으로 전면 재설계했습니다.
  • 과거의 기록에는  [위기] 상황 키워드 와 함께 내가 어떤 서사를 마주했고 어떤 결정을 내렸는지 명확히 표시됩니다.
  • 태그 획득 시각화: 특히 내러티브 태그를 얻은 턴은 배경이 금빛으로 하이라이트되며, 획득한 태그의 **자세한 설명(스토리
  서사)**이 커다란 아이콘과 함께 표시됩니다. 이를 통해 유저가 왜 이런 태그를 얻었는지 서사를 깊게 이해할 수 있습니다.
2026-06-25 22:48:38 +09:00
Kyoung5seo 56f494f331 피드백 수정 사항 적용 (모두 반영된 것은 아님)
▶주간 의회 명칭 변경: 주간 의회 --> 정기 의회
▶라운드 당 결재 건수는 다름 : 라운드가 증가할때마다 결재 건수가 줄어들도록 하여 아쉬움 발생 - 결재 건수가 0이 되면 게임 오버 (검토를 해보고 줄어드는게 나을지 늘어나는게 나을지 검토 해줘)
▶라운드 구성 개념의 디테일 부족: 기획서에 플로우차트(다이어그램)를 추가 ('결재 단계'와 '주간 의회 단계'의 흐름이 플레이어와 개발자 모두에게 명확하도록)
▶확인 결과 아래 데이터 없음. 데이터 추가 필요 (추가한 데이터가 시스템에 맞게 작동하도록 구현 해줘)
- 자산 데이터
- 오염 카드 데이터
- 내러티브 데이터
▶토큰 마이너스 상태 처리: 방향 B (패널티 및 파산 - 블랙코미디에 어울림), 마이너스를 허용하되 가혹한 대가를 치르게 하기
파산 문책 시스템 기획 및 구현 필요 (아래는 간략 시스템 작성 - 블랙 코미디가 구현되면 좋겠음)
- 마이너스 상태에서 결재 1건 처리 할 때 마다, 파산 카운터 증가
- 정기 의회에서 파산 문책 : 파산 카운터에 따라 문책의 강도가 달라 짐
- 파산 문책으로 경고를 받았는데도 개선(마이너스를 0 이상으로 복구)하지 못하면 다음 정기 의회에서 게임 오버
▶글이 너무 길어 읽기 힘든 문제:
- 게임 플레이 필수 글 (직관성 최우선): 카드 상단에 상황 키워드를 배치하고, 그 아래에 질문을 배치해 무엇을 요구 하는지 확실하게 알려 줌 (예: 상황 키워드 = 요원들의 시위 발생, 질문 = 시위를 어떻게 하시겠습니까?) 그리고 좌우 스와이프 시 등장하는 보상 표시에서 left_text, right_text의 텍스트 크기를 키우고 그 아래의 보상 오브젝트(예:  [예산 -2] ,  [지지도 +10] )는 텍스트보다는 기호와 숫자를 활용하여 1초 만에 파악할 수 있도록 함.
- 서사에 필요한 글 (메인 텍스트): '게임 플레이 필수 글'에서 추가적인 스토리 정보를 원한다면 봐야 하는 글로 카드 데이터에서 text컬럼의 내용을 표시 합니다. 게임 플레이 필수 글보다는 폰트가 작아야 하고, 너무 길지 않게 핵심 서사 위주로 설명 합니다. (예를 들면, 상황 키워드가 요원들의 시위 발생인데, 왜 시위가 발생했는지에 대한 서사를 알려 주면 됩니다)
- 블랙 코미디/플레이버 텍스트 (선택적 정보): 공간을 분리합니다.(공간 구분이 확실해야 합니다.) 카드 하단에 글자를 작게 하고, 폰트를 다르게 하여 구분되도록 합니다.
2026-06-24 23:08:32 +09:00
Kyoung5seo 873ed0dcd3 시간의 흐름과 압박감을 시각화 하는 ' 제 X차 정례회의 상정까지: 잔여 00건' 내용 추가 2026-06-24 21:52:32 +09:00
Kyoung5seo c0921548ec 기획 문서에서 미구현한 시스템 구현
다음과 같은 주요 시스템들이 클라이언트 단에 추가되었습니다:
  1. 대중 공황 (Mass Panic) 시스템
      • 기존의 저항, 엔트로피, 위험 수용 계수 외에 '대중 공황' 파라미터를 추가했습니다.
      •  cards.csv  파일에  left_pan ,  right_pan  컬럼을 동적으로 업데이트하고, 일부 카드에 패널티/효과를 부여했습니다.
      • 게임 메인 화면 상단에 😱 아이콘과 1~5단계 점(Dot) 위젯을 추가했습니다.
  2. 주간 의회 (Weekly Council) 시스템
      • 10턴 주기로 발동하는  CouncilScreen  컴포넌트를 신규 제작했습니다.
      • 13명(상임 8명, 지부장 5명)의 위원회를 시각적으로 구현하고 위험 수용 계수에 따라 반대 확률이 증가하도록 로직을
      작성했습니다.
      • 3가지 개입 액션(매수, 장부 조작, 내부 감사)을 예산, 정보, 인력 토큰 소모를 통해 실행할 수 있도록 했습니다.
      • 탄핵안 발의 및 법안(이득/표적) 상정 기능이 정상 작동합니다.
  3. 무제한 인벤토리 및 자산 (Assets) 시스템
      • 극비 카드를 완료했을 때 랜덤한 자산을 얻어 하단 인벤토리 슬롯에 시각적으로 누적되는 기초 로직을 붙였습니다.
      • 스와이프 카드의 드래그를 양방향(XY)으로 허용하여, 극비 프로젝트의 경우 '위로 스와이프'하여 보류/버리기가
      가능하도록 했습니다.
  4. 연대기 (Chronicle) 시스템
      • 좌상단 내러티브 태그 배지를 클릭하면 그동안 플레이어가 결재한 스토리와 선택(좌/우/위)을 기록한  ChroniclePopup
      이 노출되도록 구현했습니다.
  5. 멀티 엔딩 및 도감 시스템
      • 단순한 게임 오버 화면을 대체하는  EndingScreen  컴포넌트를 제작했습니다.
      • 4대 붕괴 조건(사임, 세계 멸망, 문명 붕괴, 정치적 탄핵) 중 어느 것을 달성했는지 판별하여 원인과 결과를 표시해
      줍니다.
      • 브라우저의  localStorage 를 이용해 수집된 엔딩이 도감에 영구적으로 기록되도록 처리했습니다.
2026-06-17 23:14:15 +09:00
Kyoung5seo e2557ebee8 기획 문서 추가 (앞으로 이 문서를 토대로 개발하고, Life 옵시디언에는 백업할 계획) 2026-06-17 22:36:19 +09:00
Kyoung5seo 7f06cb6519 프로토타입 테스트가 가능할 정도의 결재카드 데이터 추가 2026-06-16 22:45:09 +09:00
Kyoung5seo e1f5cafdf1 서사형 덱빌딩 게임 프로토 타입 2026-06-15 23:10:54 +09:00
Kyoung5seo e1310bc872 서사형 덱빌딩 게임 시스템으로 리뉴얼 2026-06-09 22:54:07 +09:00
Kyoung5seo 4d3e55a89b 주간 의회 버그 픽스 및 코어 시스템 고도화 계획 진행 (적용 안된것 많음. 체크 필요) 2026-04-29 23:59:25 +09:00
Kyoung5seo 26f8c6593a 추가 시스템
- 스와이프 시 태그 획득
- 파벌 우호도 시스템
- 미스터리 시스템 (미스터리 보드, 퀘스트 방식의 미스터리 해결)
- 우호도에 따른 주간 의회 표결
2026-04-28 22:56:52 +09:00
Kyoung5seo 9ec2fc260a 핵심 컨셉 '엔진 빌딩 vs 정치적 견제' 적용 2026-04-16 23:19:50 +09:00
Kyoung5seo 4aa36a5273 핵심 컨셉 '엔진 빌딩 vs 정치적 견제' 적용 2026-04-16 23:16:18 +09:00
77 changed files with 9132 additions and 6252 deletions
+20
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]`/`[~]`/`[ ]` 표기를 수정할 것을 제안하라.
+14 -128
View File
@@ -1,138 +1,24 @@
# ---> Node
# Logs # Logs
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) node_modules
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist dist
dist-ssr
*.local
# Gatsby files # Editor directories and files
.cache/ .vscode/*
# Comment in the public line in if your project uses Gatsby and not Next.js !.vscode/extensions.json
# https://nextjs.org/blog/next-9-1#public-directory-support .idea
# public .DS_Store
*.suo
# vuepress build output *.ntvs*
.vuepress/dist *.njsproj
*.sln
# vuepress v2.x temp and cache directory *.sw?
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
+20
View File
@@ -0,0 +1,20 @@
# 프로젝트: Project SS
서사형 덱빌딩 로그라이크. 모바일 & 스팀 출시 예정.
플레이어는 정체불명 조직의 국장이 되어 매 라운드 딜레마를 스와이프로 결재하며,
덱을 오염시키거나 정화하고, 그림자 의회(상임 이사회 8명 + 글로벌 지부장 5명)의
정치 공작(매수·장부조작·내부감사)을 헤쳐나가 탄핵·문명붕괴·세계멸망 등
수십 가지 엔딩 중 하나에 도달한다.
톤앤매너: 어둡고 정치적인 블랙코미디. 밝고 가벼운 표현은 지양한다.
## 코어 시스템
- 코어 루프: 결재(덱빌딩) → 위기(드로우) → 엔딩
- 핵심 파라미터 4개: 내부 저항 · 엔트로피 · 대중 공황 · 위험 수용 계수 (각 0~100%, 5단계)
- 자원: 소모성 토큰(예산·인력·정보) / 누적 자원 태그(재력·무력·감시)
## 작업 원칙
- 코드를 짤 때 코어 루프와 4개 핵심 파라미터의 정합성을 항상 먼저 확인한다.
- 밸런스 수치를 임의로 창작하지 않는다. 기획 문서에 없는 수치는 반드시 확인을 요청한다.
- 기능 제안 시 "이걸 어떻게 플레이로 검증할 것인가"를 함께 제시한다.
+110
View File
@@ -0,0 +1,110 @@
# Project SS — CLAUDE.md
> **위치:** repo root (`ProjectSS/CLAUDE.md`) — Claude Code는 root에서 실행한다.
> **이 파일의 역할:** 규칙만 담는다. 작업 이력·진행 상황은 `docs/DEVLOG.md`, Phase 계획은 `ROADMAP.md`.
> **상위 규칙:** 전역 정체성·개발 철학은 `~/.hermes/SOUL.md`, 프로젝트 정체성은 `AGENTS.md`에 있다.
> 이 파일은 그 위에 얹히는 **코드 레벨 규칙**만 다룬다.
---
## 작업 시작 전 체크
**⚠️ 병렬 브랜치 확인 (필수)**
이 폴더가 유일한 작업 사본이 아니라면, 작업 시작 전 반드시:
```bash
git fetch
git log origin/main..HEAD # 내가 안 올린 커밋
git log HEAD..origin/main # 내가 안 받은 커밋
```
과거에 다른 세션이 origin에 푸시한 커밋을 로컬이 받지 못한 채 Phase 2~4를 독립적으로
재구현해 대형 충돌이 난 적이 있다. 경위는 `ROADMAP.md` 상단 박스 참고.
---
## 코드 컨벤션
- 전체 구조·아키텍처 원칙(상태 배치, `engine/` 경계, world 스냅샷 패턴) → @docs/architecture_design.md
- 실제 앱은 `client/` 안에 있음. npm 명령은 `cd client` 후 실행.
- 로컬에 Node.js LTS 설치되어 `npm run dev`로 실기 검증 가능 (Playwright 플레이 확인까지 완료).
- 플레이테스트는 리포 루트의 `playtest.bat` 더블클릭 (dev 서버 + 브라우저 자동 오픈).
파일명은 ASCII만 사용 — 한글/`chcp`를 넣으면 cmd.exe가 배치 파싱을 깨뜨린다(과거에 겪음).
- 스타일: 컴포넌트별 개별 `.css` (글로벌 클래스, CSS Modules 아님)
- 상태관리: `useState` / `useReducer`만 사용. 외부 상태관리 라이브러리 도입 금지.
- 영구 저장: `localStorage` (도감 엔딩 저장 용도)
- 카드 데이터는 `client/src/data/cards.csv`를 papaparse로 런타임 파싱함
- `GameScreen.jsx`에 상태가 집중되어 있음 — 수정 시 주의, 리팩토링 필요 가능성
- 오염 카드 주입은 반드시 `client/src/engine/pollution.js`의 `previewPollution()`을 경유한다.
예고·주입·사후 알림이 이 단일 기준을 공유하므로, 우회해서 오염 카드를 만들지 말 것.
튜닝 수치는 `pollution.js` 상단 상수 블록에 모여 있다.
---
## ⛔ 보호 파일 (직접 수정/실행 금지 — 변경 제안은 가능)
- `client/update_csv.js` — cards.csv 전처리 스크립트
- `client/update_csv_tags.js` — 내러티브 태그 매핑 스크립트
**이유:** 두 스크립트의 출력 포맷이 바뀌면 이를 읽는 게임 코드(papaparse 파싱)가 깨지거나
데이터를 잘못 읽을 수 있다.
**규칙:** 직접 고쳐서 실행하지 말 것. 스키마 변경이 실제로 필요하면(예: 새 컬럼/태그 카테고리 추가)
어떤 변경이 왜 필요한지 설명하고 변경안(diff)을 먼저 보여준 뒤 승인을 받고 적용할 것.
## 데이터 파일 다루는 법
`cards.csv` / `narrative_tags.json` 등 **데이터 내용**(카드 텍스트, 수치, 태그)은 자유롭게 다뤄도 된다.
단, 이 게임은 카드 밸런스와 스토리 개연성이 핵심이므로:
- 자동 생성으로 일괄 채우지 말 것
- 몇 개씩 초안을 보여주고 피드백을 받아 반영하는 방식으로 진행
- 통짜로 대량 생성해서 덮어쓰지 말 것
> ℹ️ SOUL.md의 "완성도보다 검증 속도 우선"은 **코드**에 적용된다.
> 데이터(카드 텍스트·밸런스)는 예외로, 디자이너가 직접 검토할 수 있는 속도로 제안한다.
---
## 기획 문서 참조 (게임 규칙의 원본)
코드 작성 시 항상 원본 문서를 기준으로 한다. **게임 규칙 수치를 이 파일에 복사하지 말 것.**
- 카드 타입별 스와이프 결과, 파라미터, 의회 액션 → @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 5 착수 조건:** 외부 플레이어 그레이박스 테스트를 마치고 그 피드백을 반영한 뒤에만 시작한다.
Phase별 상세 계획과 완료 기준은 `ROADMAP.md`, 각 Phase에서 남은 주의사항과 작업 이력은
`docs/DEVLOG.md` 참고.
---
## 작업 지시 방법
```
/goal Phase 5 진행해줘 # 그레이박스 테스트 완료 후에만
```
**작업 완료 후 반드시:**
1. 위 **Phase 진행 순서** 블록의 체크박스를 갱신한다.
2. `docs/DEVLOG.md` 맨 위에 무엇을 왜 바꿨는지 기록한다.
(미해결 이슈나 디자이너 확인이 필요한 사항이 생겼으면 DEVLOG 상단 "열린 이슈"에 추가)
+15 -1
View File
@@ -1,2 +1,16 @@
# ProjectSS # React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+278
View File
@@ -0,0 +1,278 @@
# Project SS — ROADMAP.md
> **용도:** AI 에이전트에게 `/goal Phase N 진행해줘` 명령 시 컨텍스트로 주입하는 개발 로드맵 문서.
> **규칙:** 각 Phase는 독립적으로 실행 가능하며, `완료 기준(Done Criteria)`을 모두 충족해야 다음 Phase로 진행한다.
---
## 개발 전략
**그레이박스 우선 전략 (Greybox First)**
이 프로젝트는 그래픽보다 게임플레이 검증을 먼저 완성하는 전략을 채택한다.
게임은 기획 단계에서 재미있어 보여도 실제 플레이 시 재미없는 경우가 많기 때문에,
시스템이 실제로 재미있는지를 먼저 확인하고, 그래픽은 마지막에 올린다.
```
[Phase 0~4] 그레이박스 구간 — 로직과 재미 검증이 목표. 비주얼은 최소한으로.
↓
[그레이박스 테스트] 외부 플레이어 피드백 수집 및 반복 수정
↓
[Phase 5] 검증된 게임 위에 비주얼 폴리싱 적용
↓
[출시]
```
**Phase 5는 Phase 0~4의 재미 검증이 완료된 후에만 진행한다.**
비주얼 작업을 먼저 시작하지 않는다.
---
## 현재 상태 (Current Status)
```
Phase 0 [x] 환경 세팅 & 아키텍처
Phase 1 [x] 코어 루프 프로토타입
Phase 2 [x] 자원 경제 & 덱빌딩
Phase 3 [x] 정치 시스템 & 스토리 페이징
Phase 4 [x] 멀티 엔딩 & 연대기
Phase 5 [ ] 비주얼 폴리싱 & UX ← 그레이박스 테스트 완료 후에만 착수
```
`[x]` 완료 · `[~]` 부분 완료(완료 기준 미충족 항목 있음) · `[ ]` 미착수
---
## ⚠️ 2026-07-09: 병렬 작업 브랜치 병합 (중요 — 반드시 읽을 것)
이 로컬 작업 폴더는 2026-06-24~25에 **다른 세션에서 origin/main에 푸시된 4개 커밋**을
받아오지 못한 채로 Phase 2~4 작업을 진행했다. 그 커밋들에는 이 로컬에서 독립적으로
다시 구현한 것과 겹치는 기능(파산 문책, 내러티브 태그, 오염 카드, 자산)이 서로 다른
방식으로 이미 존재했고, `git pull` 시점에 6개 파일에서 충돌이 나서 origin 쪽 구현을
기준으로 병합했다. 원인·과정·잔여 이슈는 아래 각 Phase 절의 "병합 참고" 박스에 정리했다.
**이 폴더가 유일한 작업 사본이 아니라면, 다음 세션을 시작하기 전에 반드시 `git fetch` +
`git log origin/main..HEAD` / `git log HEAD..origin/main`으로 분기 여부를 먼저 확인할 것.**
---
## 다음 단계
**Phase 0~4 그레이박스 작업이 모두 완료됐다.** 이제 외부 플레이어 그레이박스 테스트를 진행할 차례다.
Phase 5(비주얼 폴리싱)는 그 피드백을 반영한 뒤에만 착수한다.
```
/goal Phase 5 진행해줘 # 그레이박스 테스트 완료 후에만
```
---
## Phase 0 — 프로젝트 환경 세팅 & 아키텍처 설계
**상태: `[x] 완료`**
**목표:** 본격적인 개발 전 코드 환경, 버전 관리, 데이터 파이프라인을 확립한다.
### 완료된 항목
- [x] VS Code + Claude Code 개발 환경
- [x] Gitea 리포지토리 + CI/CD 파이프라인
- [x] React 19 + Vite 8 프로젝트 (그레이박스 전략에 따라 선택)
- [x] CSV/JSON 데이터 파서 (papaparse 기반)
---
## Phase 1 — 코어 루프 프로토타입
**상태: `[x] 완료`**
**목표:** 스와이프 조작과 4대 파라미터 증감이 동작하는지 검증한다.
### 완료된 항목
- [x] 카드 스와이프 시스템 (좌/우/위)
- [x] 4대 핵심 파라미터 (내부 저항 / 엔트로피 / 대중 공황 / 위험 수용 계수)
- [x] 라운드 카운터 (결재 N건 → 의회 진입)
- [x] 파라미터 5단계 도달 시 게임 오버 분기
---
## Phase 2 — 자원 경제 & 로그라이크 덱빌딩
**상태: `[x] 완료`**
**목표:** 토큰 수급/소모, 덱 오염, 덱 압축(자산)의 전체 경제 루프를 완성한다.
### 완료된 항목
- [x] 토큰 시스템 (예산 / 인력 / 정보)
- [x] 영구 자원 태그 (재력 / 무력 / 감시)
- [x] 딜레마 카드 6종 분기 로직
- [x] 무제한 인벤토리 UI
- [x] 자산 기초 로직 (랜덤 획득)
- [x] 파이프라인 연쇄 반응 기초
- [x] **파산 문책 시스템** — 마이너스 결재 시 파산 카운터 증가(`GameScreen.jsx` `handleSwipe`), 의회에서 경고 배너 → 미개선 시 특별 청문회로 게임 오버(`CouncilScreen.jsx`)
- [x] **오염 카드 등급 확률** — 파라미터 단계(1~5)별 S/A/B/C 가중치 테이블로 의회 종료 시 오염 카드 주입(`GameScreen.jsx` `GRADE_WEIGHTS`, `injectContaminationCards`)
- [x] **자산 효과 4종** — 성질 변환 / 시한부 격리 / 물리적 영구 소각 / 결재권 강제 행사, 자산 슬롯 클릭으로 발동(`GameScreen.jsx` `activateAsset`)
### 완료 기준 (Done Criteria)
- [x] 토큰이 마이너스인 상태로 결재하면 파산 카운터가 증가한다.
- [x] 의회 단계에서 파산 카운터가 문책 로직에 실제로 반영된다.
- [x] 경고를 받은 뒤 다음 의회까지 마이너스를 회복하지 못하면 게임 오버된다.
- [x] 오염 카드 등급이 파라미터 단계에 비례하여 가중치대로 뽑힌다.
- [x] 자산 효과 4종이 각각 독립적으로 동작한다.
> **참고:** 오염 카드 콘텐츠(`cards.csv`의 신화/조직저항/사회공황/이사회압박 16종)는 등급별 1종씩만 채운 **초안**이다.
> 밸런스·문체 검토 후 디자이너가 등급별로 추가 카드를 늘리는 작업이 별도로 필요하다.
> 또한 A급 이상 신화 카드가 '뉴스 이벤트' 카드를 추가하는 연쇄 규칙(기획서 92-95행)은 이번 범위에 포함하지 않았다.
> **병합 참고 (2026-07-09):** origin/main에 미리 푸시되어 있던 다른 세션의 파산 문책 구현(정례회의
> 라운드가 늘수록 결재 건수가 8→6→4→2→0으로 줄어들고, 파산 카운터 5회 이상이면 의회에서 전 파라미터를
> 강제로 올리는 에스컬레이션 포함)이 더 완성도 높고 실제 사용자 피드백을 직접 반영한 것이었다. 그 버전을
> 기준으로 병합하면서 상태명을 `bankruptcyCounter/bankruptcyWarned` → `bankruptcyCount/bankruptcyWarning`으로
> 통일했다. 자산은 origin에 `assets.json`(이름 있는 개별 아이템 4종: 전임 국장의 만년필 등)이 준비돼 있었지만
> 실제 효과 로직이 연결돼 있지 않아서, 이 로컬에서 만든 "성질 변환/시한부 격리/물리적 영구 소각/결재권 강제
> 행사" 4종 기능 로직을 그대로 유지했다 — `assets.json`의 이름 있는 아이템 플레이버는 아직 미사용 상태다.
### 핵심 참조 문서
- `시스템 기획서` — 결재 단계, 덱 오염, 덱 압축, 자산 슬롯
- `PRD` — §3.2 결재 시스템, §3.3 자산 및 덱 압축 시스템
---
## Phase 3 — 정치 시스템 & 스토리 페이징 통합
**상태: `[x] 완료`**
**목표:** 그림자 위원회 투표와 내러티브 태그 기반 페이즈 전환을 완성한다.
### 완료된 항목
- [x] 그림자 위원회 13명 (상임 8 + 지부장 5 무작위)
- [x] 의회 투표 / 탄핵 / 법안 분기 기초
- [x] 국장 개입 액션 3종 (매수 / 장부 조작 / 내부 감사)
- [x] 내러티브 태그 기반 페이징 기초
- [x] 연대기(Chronicle) 팝업
- [x] **파벌 우호도 세부 조건** — 12개 글로벌 지부장 파벌이 각자 다른 공식으로 우호도를 계산한다(`GameScreen.jsx` `GLOBAL_FACTIONS`). 게임 시작 시 12개 중 5개를 무작위 선발(`factionRoster`), 매 의회마다 실시간 상태(토큰/태그/파라미터/자산/카운터)로 점수를 산출해 동맹/우호/중립/적대/파국 5단계 티어로 환산, 투표와 국장 개입 면역 여부를 결정한다(`CouncilScreen.jsx`).
- [x] **파산 카운터 → 의회 문책 연동** — Phase 2 작업으로 완료됨 (`CouncilScreen.jsx`가 `bankruptcyCounter`/`bankruptcyWarned`를 직접 참조)
- [x] **스토리 페이징 카드 풀 전환** — 초기 덱은 일상/위기만 포함되고, 고유 내러티브 태그 3종 도달 시 작전/극비 프로젝트 카드 풀이 실시간으로 덱에 개방된다(`GameScreen.jsx` `phase2Pool`/`phase2Unlocked`).
### 완료 기준 (Done Criteria)
- [x] 글로벌 지부장 12개 파벌의 우호도가 각자 다른 조건으로 개별 계산된다.
- [x] 파산 카운터가 의회 문책 로직에 연동된다.
- [x] 내러티브 태그 누적 시 등장 카드 풀이 실시간으로 전환된다.
> **참고 및 남은 과제:**
> - 상임 이사회 8명의 투표는 여전히 `params.risk` 기반 확률 처리다. 기획서상 이들은 국장의 '성향 태그'에 반응해야 하는데, 이 게임에는 아직 성향 태그 시스템 자체가 없어 범위 밖으로 남겨뒀다(§4.1 대응).
> - Playwright로 실제 브라우저에서 플레이 검증 중 **`params.risk >= 5`가 직접 게임오버를 발동시키지만 `EndingScreen`에 해당 분기가 없어 "미확인 종말" 폴백으로 빠지는 버그**를 발견해 수정했다(PRD상 risk는 탄핵 기준치일 뿐 직접 붕괴 조건이 아님).
> **병합 참고 (2026-07-09):** 내러티브 태그 시스템 자체를 origin 버전(재사용 가능한 태그 풀 방식, 아래 참고)으로
> 교체하면서 페이징 문턱도 "고유 태그 4개/8개" → "고유 태그 3개/6개"로 재조정했다(풀 크기 10종 기준).
### 핵심 참조 문서
- `스토리 바이블` — §4.2 글로벌 지부장 12개 파벌 상세
- `시스템 기획서` — 주간 의회 단계, 토큰 경제
- `유저플로우` — §3 주간 의회 단계 플로우
---
## Phase 4 — 멀티 엔딩 & 연대기 (콘텐츠 완성)
**상태: `[x] 완료`**
**목표:** 도감 수집이라는 게임의 최종 목표를 완성한다.
이 Phase 완료 후 그레이박스 테스트를 시작한다.
### 완료된 항목
- [x] **엔딩 판별기 (우선순위 순)** — `client/src/data/endings.js`의 `resolveEnding()`이 트리거(탄핵/붕괴)별로 아래 순서를 그대로 구현한다.
| 우선순위 | 엔딩 유형 | 트리거 조건 |
|---|---|---|
| 1 | 의회 정치 엔딩 | 탄핵 가결 + 의회 의장 성향 축(무력/재력/감시 중 게임 시작 시 무작위 선정) 태그 5 이상 |
| 2 | 내러티브 연쇄 엔딩 | 신화→조직저항→사회공황→이사회압박 오염 카드가 이 순서로 등장 |
| 3 | 변주된 붕괴 엔딩 | 기본 붕괴 조건 + 특정 자원 태그(3종)/토큰(3종) 보유 |
| 4 | 기본 붕괴 엔딩 | 파라미터 5단계 도달 or 탄핵 가결 (그 외 조건 미충족 시 폴백) |
- [x] **변주된 붕괴 엔딩** — 자원 태그 변주 3종(쿠데타/은폐된 멸망/자본의 폭주) + 토큰 변주 3종(야반도주/정보 암흑/긴축의 폭동)
- [x] **극단적 파라미터 조기 엔딩** — 단일 결재에서 위험 파라미터가 3단계 이상 급증하며 5단계에 도달하면 "돌이킬 수 없는 순간" 즉각 붕괴 엔딩 발동
- [x] **내러티브 연쇄 엔딩** — 오염 카드 4종을 지정 순서로 수집하면 해금, 이후 탄핵/붕괴 시 최우선 적용
- [x] **임기 만료 엔딩** (병합으로 추가) — 라운드가 거듭될수록 정례회의까지의 결재 건수가 8→6→4→2→0으로 줄어들다 0이 되면 발동
- [x] **도감 UI** — `EndingCodex.jsx` 신규 컴포넌트. 엔딩 화면에서 "📖 엔딩 도감 보기"로 열람, 해금 16종 중 몇 종 확인했는지 표시, 미해금은 "???"로 잠금 처리, `localStorage`(`ss_endings_v2`)에 엔딩 id 기준으로 영구 저장
- [x] **연대기 팝업 완성** — 턴별 타임라인 + 새로 획득한 내러티브 태그를 아이콘/설명과 함께 인라인으로 강조 표시, 상단에 수집한 태그 전체를 칩으로 요약(`ChroniclePopup.jsx/css`)
### 완료 기준 (Done Criteria)
- [x] 파라미터 5단계 도달 시 우선순위 판별기가 정확한 엔딩을 출력한다. (Playwright로 "군사 쿠데타" 변주 엔딩과 "정치적 탄핵" 기본 엔딩을 각각 실제로 트리거해 확인)
- [x] 엔딩 화면에 이름 / 서사 텍스트 / 원인 요약이 표시된다.
- [x] 해금 엔딩이 도감에 기록되고 재실행 후에도 유지된다. (localStorage 기반, id로 dedup)
- [x] 연대기 팝업에서 이번 회차 내러티브 히스토리를 확인할 수 있다.
> **참고:** 엔딩 도감 localStorage 키를 `ss_endings`(제목 문자열 기반, 취약)에서 `ss_endings_v2`(고유 id 기반)로 교체했다.
> 이전에 수동 플레이테스트로 쌓인 `ss_endings` 데이터는 새 키와 호환되지 않아 브라우저에서 도감이 0/16으로 보일 수 있다 —
> 프로토타입 단계라 마이그레이션 로직은 만들지 않았다.
> **병합 참고 (2026-07-09) — 내러티브 태그 시스템 최종본:** 이 Phase에서 한 번(카드당 자유 텍스트 키워드
> `left_narrative`/`right_narrative`) 재설계했었는데, 이후 origin/main에 병합할 다른 세션의 구현을 발견했다.
> 그 버전은 `narrative_tags.json`(재사용 가능한 태그 10종 — 아이콘+이름+설명, 예: "쿠데타의 그림자" 🗡️)과
> `cards.csv`의 `left_tag`/`right_tag` 컬럼(태그 id 참조) 조합으로, 처음 획득하는 태그일 때만 토스트 알림이
> 뜨고 진행도에 반영되는 방식이었다 — 사용자 피드백을 더 직접 반영한 더 성숙한 설계라 최종적으로 이 방식을
> 채택하고 자유 텍스트 방식은 폐기했다. 다만 origin의 `cards.csv`에는 태그 컬럼 자체가 비어 있어 실제로는
> 작동하지 않는 상태였으므로, `update_csv_tags.js`의 기존 11개 매핑을 참고해 새로 채워 넣고 태그 풀도
> 4종(쿠데타의 그림자/군중의 광기/정치적 압박/희생의 대가) 추가했다. 페이징 문턱도 "태그 풀 10종 중 고유
> 3종/6종 획득"으로 재조정했다.
> **이 Phase 완료 = 그레이박스 테스트 준비 완료.**
> Phase 5는 테스트 피드백 반영이 끝난 후 착수한다.
### 핵심 참조 문서
- `스토리 바이블` — §5 멀티 엔딩 매트릭스
- `유저플로우` — §4 엔딩 분기 및 도감 플로우
- `텍스트 와이어프레임` — §3.2 엔딩 결산 화면
---
## Phase 5 — 비주얼 폴리싱 & 다이제틱 UX
**상태: `[ ] 미착수`**
**착수 조건: 그레이박스 테스트 피드백 반영 완료 후에만 시작한다.**
**목표:** 재미가 검증된 게임 위에 몰입감 있는 시각·청각·촉각 피드백을 입힌다.
### 작업 항목
- [ ] **시각적 오염도 연출** — 오염 카드 테두리에 검은 얼룩·불탄 자국 CSS 효과
- [ ] **앰비언트 페이즈 비네팅**
- 페이즈 1: 없음 (`opacity 0.0`)
- 페이즈 2: 가장자리 어두운 테두리 (`opacity 0.4`)
- 페이즈 3: 붉게 점멸 (`opacity 0.8`)
- [ ] **스와이프 마찰력 피드백** — 오염도·위험 단계에 따라 카드 저항값 증가 (`0.8` → `1.5`)
- [ ] **햅틱 진동 4종**
| 종류 | 강도 | 지속 | 발동 조건 |
|---|---|---|---|
| `swipe-tick` | Light | 10ms | 일상 딜레마 스와이프 |
| `stamp-success` | Medium | 30ms | 자원 태그/자산 획득 |
| `crisis-hit` | Heavy | 50ms | 위기 강타 파라미터 상승 |
| `chain-reaction` | Light×3 | 10ms×3 | 파이프라인 연쇄 발동 |
- [ ] **오디오 앰비언트** — 페이즈 전환에 따른 배경 사운드 변화
- [ ] **파이프라인 연쇄 시각 이펙트** — 자산 아이콘 사이 발광 선 애니메이션
### 완료 기준 (Done Criteria)
- 페이즈 3 진입 시 화면 테두리가 붉게 점멸한다.
- 위기 강타 스와이프 시 Heavy 햅틱이 발동한다 (모바일 실기 확인).
- 파이프라인 연쇄 발동 시 발광 선 이펙트와 연속 진동이 함께 재생된다.
### 핵심 참조 문서
- `디자인 토큰` — §4 인터랙션 & 모션 토큰, §5 햅틱 피드백 토큰
- `컴포넌트 인벤토리` — `VFX_SimpleChain`, `Card_SwipeFull`
---
## 에이전트 사용법
```
# 감사 먼저 (현재 필수)
/goal 감사해줘 — Phase 2~3 미완 항목 실제 구현 여부 확인하고 보고해줘
# Phase 진행
/goal Phase 2 미완 항목 마무리해줘
/goal Phase 3 미완 항목 마무리해줘
/goal Phase 4 진행해줘
/goal Phase 5 진행해줘 ← 그레이박스 테스트 완료 후에만 사용
```
작업 완료 후 `[ ]`를 `[x]`로 업데이트하고 **현재 상태** 블록을 갱신한다.
View File
+21
View File
@@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>projectss</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2485
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "projectss",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"framer-motion": "^12.40.0",
"lucide-react": "^1.18.0",
"papaparse": "^5.5.3",
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"vite": "^8.0.12"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+8
View File
@@ -0,0 +1,8 @@
.App {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: radial-gradient(circle at center, #1a1a1a 0%, #0a0a0a 100%);
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import './App.css';
import { GameScreen } from './components/GameScreen';
function App() {
return (
<div className="App">
<GameScreen />
</div>
);
}
export default App;
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+92
View File
@@ -0,0 +1,92 @@
.asset-popup-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
}
.asset-popup {
background: var(--color-surface);
width: 85%;
max-width: 420px;
border-radius: 16px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 10px 50px rgba(0, 0, 0, 0.9);
}
.asset-popup-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.asset-popup-grade {
font-family: var(--font-typewriter);
font-weight: bold;
font-size: 13px;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid currentColor;
}
.grade-C { color: #9ca3af; }
.grade-B { color: #4ade80; }
.grade-A { color: #60a5fa; }
.grade-S { color: #fbbf24; }
.asset-popup-archetype {
font-size: 13px;
color: #a1a1aa;
}
.asset-popup-header .close-btn {
margin-left: auto;
}
.asset-popup-name {
margin: 0 0 8px 0;
font-family: var(--font-doc-title);
font-size: 20px;
}
.asset-popup-desc {
font-size: 14px;
color: #d4d4d8;
line-height: 1.5;
margin: 0 0 12px 0;
}
.asset-popup-stat {
font-size: 13px;
color: var(--color-token-info);
margin-bottom: 10px;
}
.asset-popup-activate {
width: 100%;
background: var(--color-resistance-critical);
color: #05201c;
border: none;
border-radius: 8px;
padding: 10px;
font-family: var(--font-ui);
font-weight: 700;
font-size: 15px;
cursor: pointer;
margin-bottom: 10px;
}
.asset-popup-flavor {
font-size: 12px;
color: #888;
font-style: italic;
margin: 0;
}
+43
View File
@@ -0,0 +1,43 @@
import React from 'react';
import { ARCHETYPE_META } from '../engine/assetEngine';
import './AssetInfoPopup.css';
export default function AssetInfoPopup({ instance, def, onActivate, onClose }) {
if (!instance || !def) return null;
const meta = ARCHETYPE_META[def.archetype];
const isManualCharge = def.trigger.type === 'manual_charge';
const ready = isManualCharge && instance.charge >= 1;
return (
<div className="asset-popup-overlay" onClick={onClose}>
<div className="asset-popup" onClick={e => e.stopPropagation()}>
<div className="asset-popup-header">
<span className={`asset-popup-grade grade-${def.grade}`}>{def.grade}</span>
<span className="asset-popup-archetype">{meta.icon} {meta.label}</span>
<button className="close-btn" onClick={onClose}>&times;</button>
</div>
<h3 className="asset-popup-name">{def.name}</h3>
<p className="asset-popup-desc">{def.description}</p>
{def.growth && (
<div className="asset-popup-stat">
현재 숙련 스택: <strong>{instance.stack || 0}</strong> (매 {def.growth.per}회마다 추가 발동)
</div>
)}
{isManualCharge && (
<div className="asset-popup-stat">
{ready ? '충전 완료 — 발동 가능' : `충전 대기 중 (${def.trigger.params.chargeEvery}건마다 충전)`}
</div>
)}
{ready && (
<button className="asset-popup-activate" onClick={onActivate}>발동</button>
)}
<p className="asset-popup-flavor">{def.flavor}</p>
</div>
</div>
);
}
+211
View File
@@ -0,0 +1,211 @@
.chronicle-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;
}
.chronicle-popup {
background: var(--color-surface);
width: 90%;
max-width: 500px;
max-height: 85%;
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 10px 50px rgba(0, 0, 0, 0.9);
}
.chronicle-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
background: rgba(0, 0, 0, 0.6);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.chronicle-header h2 {
margin: 0;
font-family: var(--font-doc-title);
font-size: 22px;
}
.chronicle-tags-summary {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
padding: 4px 12px;
border-radius: 12px;
font-size: 14px;
font-weight: bold;
}
.close-btn {
background: none;
border: none;
color: #fff;
font-size: 28px;
cursor: pointer;
line-height: 1;
}
.keyword-section {
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.02);
}
.keyword-label {
display: block;
font-size: 11px;
color: #888;
font-weight: bold;
margin-bottom: 8px;
letter-spacing: 0.5px;
}
.keyword-empty {
font-size: 12px;
color: #666;
font-style: italic;
}
.keyword-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.keyword-chip {
font-size: 12px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
color: var(--color-token-info);
white-space: nowrap;
}
.chronicle-content {
padding: 24px;
overflow-y: auto;
flex: 1;
}
.empty-text {
text-align: center;
color: #888;
font-style: italic;
}
.chronicle-timeline {
display: flex;
flex-direction: column;
gap: 20px;
}
.chronicle-entry {
display: flex;
gap: 16px;
padding-bottom: 20px;
border-bottom: 1px dashed rgba(255, 255, 255, 0.1);
}
.chronicle-entry:last-child {
border-bottom: none;
padding-bottom: 0;
}
.entry-turn {
font-size: 14px;
font-weight: bold;
color: #a1a1aa;
min-width: 60px;
padding-top: 2px;
}
.entry-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.entry-context {
display: flex;
gap: 8px;
align-items: center;
}
.entry-type {
font-size: 12px;
color: var(--color-token-info);
font-family: var(--font-typewriter);
}
.entry-keyword {
font-size: 15px;
font-weight: bold;
color: #fbbf24;
}
.entry-text {
font-size: 14px;
color: #d4d4d8;
line-height: 1.5;
}
.entry-decision {
font-size: 14px;
color: #4ade80;
font-weight: bold;
}
.entry-tag-highlight {
margin-top: 12px;
background: rgba(251, 191, 36, 0.15);
border-left: 4px solid #fbbf24;
border-radius: 4px;
padding: 12px;
display: flex;
align-items: center;
gap: 12px;
}
.tag-icon {
font-size: 32px;
}
.tag-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.tag-name {
font-size: 16px;
font-weight: bold;
color: #fbbf24;
}
.tag-desc {
font-size: 13px;
color: #fff;
line-height: 1.4;
}
.has-tag {
background: linear-gradient(90deg, rgba(251,191,36,0.05) 0%, transparent 100%);
border-radius: 8px;
padding: 12px;
border-bottom: none;
margin-bottom: 4px;
}
+63
View File
@@ -0,0 +1,63 @@
import React from 'react';
import './ChroniclePopup.css';
export default function ChroniclePopup({ chronicle, acquiredTags = [], onClose }) {
return (
<div className="chronicle-overlay" onClick={onClose}>
<div className="chronicle-popup" onClick={e => e.stopPropagation()}>
<div className="chronicle-header">
<h2>국장의 연대기</h2>
<div className="chronicle-tags-summary">
획득한 내러티브: {acquiredTags.length}개
</div>
<button className="close-btn" onClick={onClose}>&times;</button>
</div>
<div className="keyword-section">
<span className="keyword-label">수집한 키워드</span>
{acquiredTags.length === 0 ? (
<span className="keyword-empty">아직 수집한 키워드가 없습니다.</span>
) : (
<div className="keyword-chips">
{acquiredTags.map((t) => (
<span key={t.id} className="keyword-chip" title={t.description}>{t.icon} {t.name}</span>
))}
</div>
)}
</div>
<div className="chronicle-content">
{chronicle.length === 0 ? (
<p className="empty-text">아직 기록된 역사가 없습니다.</p>
) : (
<div className="chronicle-timeline">
{chronicle.map((entry, idx) => (
<div key={idx} className={`chronicle-entry ${entry.tag ? 'has-tag' : ''}`}>
<div className="entry-turn">Turn {entry.turn}</div>
<div className="entry-body">
<div className="entry-context">
<span className="entry-type">[{entry.cardType}]</span>
{entry.keyword && <span className="entry-keyword">{entry.keyword}</span>}
</div>
<div className="entry-text">{entry.text}</div>
{entry.decision && <div className="entry-decision">👉 결정: {entry.decision}</div>}
{entry.tag && (
<div className="entry-tag-highlight">
<div className="tag-icon">{entry.tag.icon}</div>
<div className="tag-details">
<div className="tag-name">내러티브 획득: {entry.tag.name}</div>
<div className="tag-desc">{entry.tag.description}</div>
</div>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
.council-screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.95);
z-index: 100;
display: flex;
flex-direction: column;
padding: 40px 20px;
color: #fff;
box-sizing: border-box;
}
.council-header {
text-align: center;
margin-bottom: 30px;
}
.council-header h2 {
font-family: var(--font-doc-title);
color: var(--color-risk-critical);
margin-bottom: 10px;
}
.risk-bar-container {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-family: var(--font-ui);
}
.risk-bar {
width: 200px;
height: 10px;
background: #333;
border-radius: 5px;
overflow: hidden;
}
.risk-fill {
height: 100%;
background: var(--color-risk-critical);
transition: width 0.3s;
}
.warning-blink {
color: #f87171;
font-weight: bold;
margin-top: 10px;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.2; }
}
.members-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 15px;
justify-items: center;
margin: 0 auto 40px auto;
max-width: 320px;
}
.member-icon {
width: 50px;
height: 50px;
border-radius: 50%;
background: #2a2a2a;
display: flex;
align-items: center;
justify-content: center;
position: relative;
font-size: 24px;
}
.member-icon.board {
border: 2px solid var(--color-risk-base);
}
.member-icon.global {
border: 2px solid var(--color-token-info);
}
.member-badge {
position: absolute;
bottom: -5px;
right: -5px;
font-size: 16px;
background: #000;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.council-actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: auto;
}
.fat-btn {
width: 100%;
padding: 15px;
border: none;
border-radius: 12px;
font-family: var(--font-ui);
font-size: 16px;
font-weight: bold;
background: #333;
color: #fff;
cursor: pointer;
transition: background 0.2s;
}
.fat-btn:hover:not(.disabled) {
background: #444;
}
.fat-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-proceed {
background: var(--color-swipe-right);
margin-top: 20px;
}
.action-proceed:hover {
background: #2e7d32;
}
.council-result {
text-align: center;
margin-top: auto;
}
.council-result h3 {
margin-bottom: 20px;
}
.bankruptcy-warning-box {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 20px;
}
.penalty-text {
color: var(--color-risk-critical);
font-family: var(--font-ui);
font-size: 16px;
font-weight: bold;
line-height: 1.6;
}
+220
View File
@@ -0,0 +1,220 @@
import React, { useState, useEffect } from 'react';
import './CouncilScreen.css';
export default function CouncilScreen({ params, setParams, tokens, setTokens, tags, setTags, bankruptcyCount, bankruptcyWarning, 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 [isBankruptcyWarning, setIsBankruptcyWarning] = useState(false);
const [bankruptPenalty, setBankruptPenalty] = useState('');
useEffect(() => {
const isNegative = tokens.budget < 0 || tokens.personnel < 0 || tokens.info < 0;
if (isNegative) {
if (bankruptcyWarning) {
setAgenda('재무 건전성 악화 및 횡령 배임 혐의');
setResult('당신은 파산했고 자금 횡령으로 체포되었습니다. 감옥의 식사는 다행히 무료입니다.');
return;
}
setIsBankruptcyWarning(true);
setAgenda('파산 문책: 재무 경고');
if (bankruptcyCount >= 5) {
setBankruptPenalty(`파산 카운터 ${bankruptcyCount}회: 의회가 매우 분노했습니다. 모든 위험도 파라미터가 상승합니다.`);
setParams(prev => ({
resistance: Math.min(5, prev.resistance + 1),
entropy: Math.min(5, prev.entropy + 1),
panic: Math.min(5, prev.panic + 1),
risk: Math.min(5, prev.risk + 1),
}));
} else {
setBankruptPenalty(`파산 카운터 ${bankruptcyCount}회: 의회에서 강력한 경고가 나왔습니다. 다음 의회까지 복구하지 못하면 해임됩니다.`);
}
return;
}
// 상임 이사회 8명: 위험 수용 계수 기반 무작위 (성향 태그 세부 연동은 Phase 3 범위 밖)
const boardMembers = Array.from({ length: 8 }).map((_, i) => {
const rand = Math.random() * 100;
let vote = '미정';
if (rand < params.risk * 20) vote = '반대';
else if (rand > 50) vote = '찬성';
return { id: i, type: 'board', vote, immune: false, label: '상임 이사회' };
});
// 글로벌 지부장 5명: 파벌별 개별 우호도 계산 결과를 그대로 투표에 반영
const globalMembers = globalFactions.map((f, i) => ({
id: 8 + i, type: 'global', vote: f.vote, immune: f.immune, label: `${f.name} (${f.tier})`,
}));
const newMembers = [...boardMembers, ...globalMembers];
setMembers(newMembers);
const againstCount = newMembers.filter(m => m.vote === '반대').length;
if (againstCount >= 9) {
setAgenda('정치적 탄핵안 상정');
setImpeachment(true);
} else {
const randAgenda = Math.random() > 0.5 ? '이득 규약 법안 상정' : '표적 규약 법안 상정';
setAgenda(randAgenda);
}
// 의회는 마운트 시 1회만 초기화한다 (deps에 tokens/params.risk를 넣으면 매수 액션 등으로
// 값이 바뀔 때마다 재실행되어 방금 강제 전환한 투표 결과를 덮어써버리는 버그가 있었다)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleAction = (type) => {
if (actionTaken) return;
const newMembers = [...members];
if (type === 'bribe' && tokens.budget >= 30) {
setTokens(prev => ({ ...prev, budget: prev.budget - 30 }));
setParams(prev => ({ ...prev, risk: Math.min(5, prev.risk + 1) }));
onBribe && onBribe();
// 동맹/파국 성향 임원은 국장의 개입이 통하지 않는다
let changed = 0;
for (let m of newMembers) {
if (m.vote === '반대' && !m.immune && changed < 2) {
m.vote = '찬성';
changed++;
}
}
} else if (type === 'manipulate' && tokens.info >= 30) {
setTokens(prev => ({ ...prev, info: prev.info - 30 }));
setParams(prev => ({ ...prev, entropy: Math.min(5, prev.entropy + 1) }));
// Delay agenda
setResult('법안 상정 이월 (장부 조작 성공)');
setActionTaken(true);
return;
} else if (type === 'audit' && tokens.personnel >= 30) {
setTokens(prev => ({ ...prev, personnel: prev.personnel - 30 }));
setParams(prev => ({ ...prev, resistance: Math.min(5, prev.resistance + 1) }));
// 동맹/파국 성향 임원은 국장의 개입이 통하지 않는다
let changed = 0;
for (let m of newMembers) {
if (m.vote === '반대' && !m.immune && changed < 3) {
m.vote = '미정';
changed++;
}
}
} else {
return; // Not enough tokens
}
setMembers(newMembers);
setActionTaken(true);
};
const finalize = () => {
if (result && result.includes('파산')) {
onResolve({ bankrupted: true });
return;
}
if (result) {
onResolve({ impeachment: false });
return;
}
if (isBankruptcyWarning) {
onResolve({ impeachment: false, bankruptcyWarning: true });
return;
}
const againstCount = members.filter(m => m.vote === '반대').length;
if (impeachment) {
if (againstCount >= 9) {
onResolve({ impeachment: true });
} else {
onResolve({ impeachment: false, bankruptcyWarning: false });
}
} else {
const supportCount = members.filter(m => m.vote === '찬성').length;
if (supportCount > againstCount) {
if (agenda.includes('이득')) {
setTags(prev => ({ ...prev, wealth: prev.wealth + 1 }));
} else {
setTags(prev => ({ ...prev, wealth: Math.max(0, prev.wealth - 1) }));
}
}
// 토큰이 정상 범위로 돌아왔다면 파산 경고를 해제한다
onResolve({ impeachment: false, bankruptcyWarning: false });
}
};
return (
<div className="council-screen">
<div className="council-header">
<h2>[📜 이번 정기 상정: {agenda}]</h2>
<div className="risk-bar-container">
<span>위험 수용 계수:</span>
<div className="risk-bar">
<div className="risk-fill" style={{ width: `${(params.risk / 5) * 100}%` }}></div>
</div>
</div>
{impeachment && <div className="warning-blink">[🚨 정치적 탄핵안 상정!]</div>}
</div>
{isBankruptcyWarning ? (
<div className="bankruptcy-warning-box">
<p className="penalty-text">{bankruptPenalty}</p>
</div>
) : (
<div className="members-grid">
{members.map(m => (
<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 === '반대' ? '❌' : '❓'}
</span>
</div>
))}
</div>
)}
{result ? (
<div className="council-result">
<h3>결과: {result}</h3>
<button className="fat-btn" onClick={finalize}>의회 종료</button>
</div>
) : isBankruptcyWarning ? (
<div className="council-actions">
<button className="fat-btn action-proceed" onClick={finalize}>경고 수용 및 의회 종료</button>
</div>
) : (
<div className="council-actions">
<button
className={`fat-btn ${tokens.budget < 30 ? 'disabled' : ''}`}
onClick={() => handleAction('bribe')}
disabled={actionTaken || tokens.budget < 30}
>
💰 매수 (예산 30 소모 / 임원 찬성)
</button>
<button
className={`fat-btn ${tokens.info < 30 ? 'disabled' : ''}`}
onClick={() => handleAction('manipulate')}
disabled={actionTaken || tokens.info < 30}
>
👁️ 장부 조작 (정보 30 소모 / 안건 이월)
</button>
<button
className={`fat-btn ${tokens.personnel < 30 ? 'disabled' : ''}`}
onClick={() => handleAction('audit')}
disabled={actionTaken || tokens.personnel < 30}
>
🩸 내부 감사 (인력 30 소모 / 기권 유도)
</button>
{actionTaken && (
<button className="fat-btn action-proceed" onClick={finalize}>표결 진행</button>
)}
{!actionTaken && (
<button className="fat-btn action-proceed" onClick={finalize}>개입 없이 표결 진행</button>
)}
</div>
)}
</div>
);
}
+83
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;
}
+33
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>
);
}
+86
View File
@@ -0,0 +1,86 @@
.ending-screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--color-bg);
color: var(--color-text);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
box-sizing: border-box;
text-align: center;
z-index: 200;
}
.ending-content {
margin: 40px 0;
background: rgba(255, 255, 255, 0.05);
padding: 30px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
max-width: 400px;
}
.ending-desc {
font-size: 18px;
line-height: 1.6;
margin-bottom: 20px;
font-family: var(--font-doc-title);
}
.ending-reason {
font-size: 14px;
color: #f87171;
margin-bottom: 10px;
}
.ending-stats {
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);
}
+46
View File
@@ -0,0 +1,46 @@
import React, { useEffect, useState } from 'react';
import EndingCodex, { ENDINGS_STORAGE_KEY } from './EndingCodex';
import './EndingScreen.css';
export default function EndingScreen({ ending, narrativeTags, keywords = [], onRestart }) {
const [showCodex, setShowCodex] = useState(false);
useEffect(() => {
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]);
if (!ending) return null;
return (
<div className="ending-screen">
<h1 className="glitch" data-text={ending.title}>{ending.title}</h1>
<div className="ending-content">
<p className="ending-desc">{ending.desc}</p>
<div className="ending-reason">
<strong>발동 원인:</strong> {ending.reason}
</div>
<div className="ending-stats">
<span>획득한 내러티브 키워드: {narrativeTags}개</span>
{keywords.length > 0 && (
<div className="ending-keywords">
{keywords.map((k, idx) => (
<span key={idx} className="keyword-chip">{k.word}</span>
))}
</div>
)}
</div>
</div>
<div 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>
);
}
+383
View File
@@ -0,0 +1,383 @@
/* src/components/GameScreen.css */
.game-screen {
width: 100%;
max-width: 480px;
height: 100vh;
display: flex;
flex-direction: column;
background-color: var(--color-bg);
position: relative;
transition: box-shadow 0.5s ease;
overflow: hidden;
}
.game-screen.phase-2 {
justify-content: center;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.asset-icon {
font-size: 20px;
}
.game-screen.phase-3 {
box-shadow: inset 0 0 100px rgba(139, 0, 0, 0.4);
animation: pulse-red 2s infinite alternate;
}
@keyframes pulse-red {
0% { box-shadow: inset 0 0 80px rgba(139, 0, 0, 0.2); }
100% { box-shadow: inset 0 0 120px rgba(139, 0, 0, 0.6); }
}
/* Top Bar */
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 20px;
z-index: 10;
}
.narrative-badge {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-typewriter);
font-size: 14px;
color: #aaa;
background: rgba(255, 255, 255, 0.05);
padding: 6px 12px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.round-info {
font-family: var(--font-typewriter);
font-size: 12px;
color: #999;
white-space: nowrap;
display: flex;
flex-direction: column;
gap: 3px;
align-items: center;
}
/* 덱 오염도 — 결재를 이어갈수록 덱이 얼마나 썩었는지 상시 노출 */
.deck-pollution {
font-size: 11px;
color: #84cc16;
opacity: 0.75;
letter-spacing: 0.5px;
}
.deck-pollution.critical {
color: #f87171;
opacity: 1;
font-weight: 700;
animation: pulse-pollution 1.6s infinite alternate;
}
@keyframes pulse-pollution {
0% { text-shadow: none; }
100% { text-shadow: 0 0 8px currentColor; }
}
.parameters {
display: flex;
gap: 15px;
}
.param {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.param .icon {
font-size: 14px;
}
.param.critical .icon {
animation: shake 0.5s infinite;
}
@keyframes shake {
0% { transform: translateX(0); }
25% { transform: translateX(-2px); }
50% { transform: translateX(2px); }
75% { transform: translateX(-2px); }
100% { transform: translateX(0); }
}
.dots {
display: flex;
gap: 3px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #333;
transition: background-color 0.3s ease;
}
.dot.filled {
background-color: var(--param-color);
box-shadow: 0 0 8px var(--param-color);
}
/* Card Area */
.card-area {
flex: 1;
position: relative;
display: flex;
justify-content: center;
align-items: center;
perspective: 1000px;
}
/* Bottom Bar */
.bottom-bar {
padding: 20px;
display: flex;
flex-direction: column;
gap: 15px;
background: linear-gradient(to top, rgba(0,0,0,0.9) 0%, transparent 100%);
z-index: 10;
}
.tokens-row, .tags-row {
display: flex;
justify-content: center;
gap: 20px;
}
.token, .tag {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-ui);
font-weight: 700;
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);
opacity: 0.8;
}
.asset-slots {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 10px;
}
.asset-slot {
width: 40px;
height: 40px;
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 8px;
background: rgba(255, 255, 255, 0.02);
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;
}
.asset-slot.grade-S { border-color: #fbbf24; }
.asset-slot.grade-A { border-color: #60a5fa; }
.asset-slot.grade-B { border-color: #4ade80; }
.asset-slot.grade-C { border-color: #9ca3af; }
.asset-slot.ready {
box-shadow: 0 0 10px rgba(251, 191, 36, 0.7);
animation: shake 1.2s infinite;
}
.asset-slot-empty-hint {
font-family: var(--font-typewriter);
font-size: 12px;
color: #555;
}
/* 자산 발동 로그 피드 — 발라트로 조커 점멸에 해당하는 최소 구현(텍스트 로그) */
.asset-log-feed {
position: absolute;
right: 12px;
bottom: 190px;
display: flex;
flex-direction: column;
gap: 4px;
max-width: 60%;
z-index: 50;
pointer-events: none;
}
.asset-log-line {
font-family: var(--font-typewriter);
font-size: 11px;
color: #d4d4d8;
background: rgba(0, 0, 0, 0.55);
border-left: 2px solid var(--color-risk-critical);
padding: 4px 8px;
border-radius: 4px;
text-align: right;
}
/* Game Over */
.game-over {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
background-color: var(--color-bg);
color: var(--color-entropy-critical);
text-align: center;
padding: 20px;
}
.game-over h1 {
font-family: var(--font-doc-title);
font-size: 48px;
margin-bottom: 20px;
text-transform: uppercase;
letter-spacing: 4px;
}
.game-over p {
font-family: var(--font-typewriter);
color: var(--color-text);
margin-bottom: 40px;
}
.restart-btn {
background: transparent;
border: 1px solid var(--color-text);
color: var(--color-text);
padding: 12px 24px;
font-family: var(--font-ui);
font-size: 16px;
cursor: pointer;
transition: all 0.3s ease;
border-radius: 4px;
}
.restart-btn:hover {
background: var(--color-text);
color: var(--color-bg);
}
/* 태그 획득 / 오염 유입 토스트는 같은 열에 쌓아 서로 겹치지 않게 한다 */
.notification-stack {
position: absolute;
top: 100px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
z-index: 100;
pointer-events: none;
width: max-content;
max-width: 90%;
}
.pollution-notification {
background: rgba(0, 0, 0, 0.92);
border: 1px solid #dc2626;
border-radius: 12px;
padding: 10px 20px;
display: flex;
align-items: center;
gap: 14px;
box-shadow: 0 4px 20px rgba(139, 0, 0, 0.5);
animation: notiFadeOut 2.6s forwards;
}
.pollution-noti-icon {
font-size: 26px;
color: #f87171;
}
.pollution-noti-info h4 {
margin: 0;
color: #f87171;
font-size: 13px;
font-family: var(--font-ui);
}
.pollution-noti-info p {
margin: 4px 0 0 0;
color: #e4e4e7;
font-size: 13px;
font-family: var(--font-typewriter);
}
@keyframes notiFadeOut {
0% { opacity: 0; transform: translateY(-16px); }
10% { opacity: 1; transform: translateY(0); }
85% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-16px); }
}
.tag-notification {
background: rgba(0, 0, 0, 0.9);
border: 1px solid #fbbf24;
border-radius: 12px;
padding: 12px 24px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 4px 20px rgba(251, 191, 36, 0.4);
animation: notiFadeOut 3s forwards;
}
.tag-noti-icon {
font-size: 32px;
}
.tag-noti-info h4 {
margin: 0;
color: #fbbf24;
font-size: 14px;
}
.tag-noti-info p {
margin: 4px 0 0 0;
color: #fff;
font-weight: bold;
font-size: 18px;
}
+628
View File
@@ -0,0 +1,628 @@
import React, { useState, useEffect, useRef } from 'react';
import Papa from 'papaparse';
import cardsCsvRaw from '../data/cards.csv?raw';
import narrativeTagsData from '../data/narrative_tags.json';
import { BookOpen, HandCoins, Droplet, Eye, Shield, Building2, EyeOff } from 'lucide-react';
import SwipeCard from './SwipeCard';
import CouncilScreen from './CouncilScreen';
import EndingScreen from './EndingScreen';
import ChroniclePopup from './ChroniclePopup';
import AssetInfoPopup from './AssetInfoPopup';
import * as AssetEngine from '../engine/assetEngine';
import {
CONTAM_TYPES,
CONTAM_TYPE_ICON,
previewPollution,
buildPollutionCards,
buildCouncilPollutionCards,
insertIntoDeck,
describePollution,
} from '../engine/pollution';
import { CHAIRMAN_AXES, isNarrativeChainComplete, resolveEnding, getEndingById } from '../data/endings';
import './GameScreen.css';
// 오염 카드 주입 규칙(타입 매핑 · 등급 추첨 · 물량)은 engine/pollution.js에 모여 있다.
// 스와이프 예고(SwipeCard)와 실제 주입이 같은 계산을 쓰게 하기 위해 여기서 다시 정의하지 말 것.
// 내러티브 태그는 narrative_tags.json 풀(10종)에서 "처음 획득하는 태그"만 진행도에 반영된다.
// 문턱은 풀 크기 대비 낮게 잡는다 (기존 4/8은 실제 플레이에서 길게 느껴진다는 피드백 반영).
const PHASE2_TAG_THRESHOLD = 3;
const PHASE3_TAG_THRESHOLD = 6;
// 글로벌 지부장 12개 파벌 풀 (게임 시작 시 5명 무작위 선발, 실리에 따라 우호도 개별 계산)
const GLOBAL_FACTIONS = [
{ id: 'military', name: '군산복합 연합', computeAffinity: (c) => c.tags.force * 15 + c.tokens.personnel * 0.4 - 40 },
{ id: 'blackmarket', name: '블랙마켓 카르텔', computeAffinity: (c) => c.tokens.budget * 0.4 + c.assetsCount * 20 - 40 },
{ id: 'databroker', name: '정보 브로커 연맹', computeAffinity: (c) => c.tokens.info * 0.4 + c.tags.surveillance * 15 - 40 },
{ id: 'infra', name: '인프라 재건 위원회', computeAffinity: (c) => c.tags.wealth * 18 - 30 },
{ id: 'spindoctor', name: '여론 통제국', computeAffinity: (c) => c.tags.surveillance * 18 - 30 },
{ id: 'union', name: '현장 요원 노조', computeAffinity: (c) => (2 - c.params.resistance) * 30 },
{ id: 'risk', name: '리스크 관리반', computeAffinity: (c) => (2 - c.params.risk) * 30 },
{ id: 'occult', name: '오컬트 수집가 협회', computeAffinity: (c) => c.mythicCount * 35 + c.tagCount * 3 - 20 },
{ id: 'blackops', name: '극비 프로젝트 연구소', computeAffinity: (c) => c.assetsAcquiredCount * 25 - 30 },
{ id: 'scavenger', name: '잔해 청소부 길드', computeAffinity: (c) => c.purgeCount * 35 - 20 },
{ id: 'entropy', name: '엔트로피 관측소', computeAffinity: (c) => (2 - c.params.entropy) * 30 },
{ id: 'corrupt', name: '부패 관료 연합', computeAffinity: (c) => c.bribeCount * 40 - 10 },
];
const getFactionTier = (score) => {
if (score >= 60) return '동맹';
if (score >= 20) return '우호';
if (score > -20) return '중립';
if (score > -60) return '적대';
return '파국';
};
const TIER_VOTE = { 동맹: '찬성', 우호: '찬성', 중립: '미정', 적대: '반대', 파국: '반대' };
const TIER_IMMUNE = new Set(['동맹', '파국']);
const buildCardFromRow = (row) => ({
id: row.id,
type: row.type,
keyword: row.keyword,
question: row.question,
text: row.text,
flavor: row.flavor,
grade: row.grade || null,
contaminated: CONTAM_TYPES.has(row.type),
left: {
text: row.left_text,
tag: row.left_tag ? narrativeTagsData.find(t => t.id === row.left_tag) : null,
stats: {
res: row.left_res || 0, ent: row.left_ent || 0, pan: row.left_pan || 0, rsk: row.left_rsk || 0,
bud: row.left_bud || 0, per: row.left_per || 0, inf: row.left_inf || 0,
wea: row.left_wea || 0, for: row.left_for || 0, sur: row.left_sur || 0,
},
},
right: {
text: row.right_text,
tag: row.right_tag ? narrativeTagsData.find(t => t.id === row.right_tag) : null,
stats: {
res: row.right_res || 0, ent: row.right_ent || 0, pan: row.right_pan || 0, rsk: row.right_rsk || 0,
bud: row.right_bud || 0, per: row.right_per || 0, inf: row.right_inf || 0,
wea: row.right_wea || 0, for: row.right_for || 0, sur: row.right_sur || 0,
},
},
});
export function GameScreen() {
const [params, setParams] = useState({ resistance: 0, entropy: 0, panic: 0, risk: 0 });
const [tokens, setTokens] = useState({ budget: 50, personnel: 50, info: 50 });
const [tags, setTags] = useState({ wealth: 0, force: 0, surveillance: 0 });
const [acquiredTags, setAcquiredTags] = useState([]);
const [newTagNotification, setNewTagNotification] = useState(null);
// 결재 직후 "오염 카드 N건이 덱에 유입됨"을 알리는 토스트. 연속 스와이프 시 타이머를 갱신한다.
const [pollutionNotification, setPollutionNotification] = useState(null);
const pollutionTimerRef = useRef(null);
const [phase, setPhase] = useState(1);
const [gameOver, setGameOver] = useState(false);
const [cards, setCards] = useState([]);
const [contaminationTemplates, setContaminationTemplates] = useState([]);
const [phase2Pool, setPhase2Pool] = useState([]);
const [phase2Unlocked, setPhase2Unlocked] = useState(false);
const [currentCardIndex, setCurrentCardIndex] = useState(0);
const [turns, setTurns] = useState(0);
const [showCouncil, setShowCouncil] = useState(false);
const [chronicle, setChronicle] = useState([]);
const [showChronicle, setShowChronicle] = useState(false);
// 자산(엔진) 시스템 — 작업지시서 기준 트리거/효과 엔진
const [ownedAssets, setOwnedAssets] = useState([]);
const [assetLog, setAssetLog] = useState([]);
const [assetPopup, setAssetPopup] = useState(null);
// 라운드 구조: 라운드가 증가할수록 정례회의까지 남은 결재 건수가 줄어든다 (시간 압박 시각화)
const [roundCount, setRoundCount] = useState(1);
const [approvalsRemaining, setApprovalsRemaining] = useState(10);
// 파산 문책 시스템
const [bankruptcyCount, setBankruptcyCount] = useState(0);
const [bankruptcyWarning, setBankruptcyWarning] = useState(false);
// 자산 효과 상태
const [mulliganShields, setMulliganShields] = useState(0);
const [quarantineZone, setQuarantineZone] = useState([]);
const [assetsAcquiredCount, setAssetsAcquiredCount] = useState(0);
const [purgeCount, setPurgeCount] = useState(0);
// 글로벌 지부장 파벌 (게임 시작 시 12개 중 5개 무작위 선발)
const [factionRoster, setFactionRoster] = useState([]);
const [bribeCount, setBribeCount] = useState(0);
// 멀티 엔딩 판별기 상태
const [finalEnding, setFinalEnding] = useState(null);
const [chairmanAxis, setChairmanAxis] = useState(null);
const [lastParamDelta, setLastParamDelta] = useState(null);
const [contaminationSequence, setContaminationSequence] = useState([]);
const [narrativeChainUnlocked, setNarrativeChainUnlocked] = useState(false);
// CSV Load Effect — 페이즈1(일상/위기) / 페이즈2(작전/극비) 카드 풀 분리
useEffect(() => {
const parsed = Papa.parse(cardsCsvRaw, { header: true, dynamicTyping: true, skipEmptyLines: true });
const rows = parsed.data.filter(row => row.id);
const phase1Rows = rows.filter(row => row.type === '일상' || row.type === '위기');
const phase2Rows = rows.filter(row => row.type === '작전' || row.type === '극비');
const templates = rows.filter(row => CONTAM_TYPES.has(row.type)).map(buildCardFromRow);
setCards(phase1Rows.map(buildCardFromRow).sort(() => Math.random() - 0.5));
setPhase2Pool(phase2Rows.map(buildCardFromRow));
setContaminationTemplates(templates);
}, []);
// 글로벌 지부장 5명 선발 (게임 시작 시 1회)
useEffect(() => {
const shuffled = [...GLOBAL_FACTIONS].sort(() => Math.random() - 0.5);
setFactionRoster(shuffled.slice(0, 5));
}, []);
// 의회 의장 성향 축 선정 (게임 시작 시 1회) — 의회 정치 엔딩 판별에 사용
useEffect(() => {
setChairmanAxis(CHAIRMAN_AXES[Math.floor(Math.random() * CHAIRMAN_AXES.length)]);
}, []);
// 내러티브 태그가 PHASE2_TAG_THRESHOLD종 도달 시 페이즈2 카드 풀(작전/극비) 개방
useEffect(() => {
if (phase2Unlocked || phase2Pool.length === 0 || acquiredTags.length < PHASE2_TAG_THRESHOLD) return;
setPhase2Unlocked(true);
setCards(prev => {
const shuffledPool = [...phase2Pool].sort(() => Math.random() - 0.5);
const insertAt = Math.min(prev.length, currentCardIndex + 1);
const next = [...prev];
next.splice(insertAt, 0, ...shuffledPool);
return next;
});
setChronicle(prev => [...prev, { turn: turns, cardType: '페이징', keyword: null, text: '페이즈 2 진입 - 작전 / 극비 프로젝트 카드 풀이 개방되었습니다.', decision: '', tag: null }]);
}, [acquiredTags, phase2Pool, phase2Unlocked, currentCardIndex, turns]);
useEffect(() => {
if (!gameOver && (params.resistance >= 5 || params.entropy >= 5 || params.panic >= 5)) {
// 위험 수용 계수(risk)는 PRD상 탄핵/법안 상정의 기준치일 뿐, 직접적인 게임오버 트리거가 아니다.
const ending = resolveEnding({
trigger: 'collapse', params, tags, tokens,
lastDelta: lastParamDelta, narrativeChainUnlocked,
});
setFinalEnding(ending);
setGameOver(true);
}
if (acquiredTags.length >= PHASE2_TAG_THRESHOLD && acquiredTags.length < PHASE3_TAG_THRESHOLD) setPhase(2);
else if (acquiredTags.length >= PHASE3_TAG_THRESHOLD) setPhase(3);
}, [params, acquiredTags]);
// 시한부 격리된 오염 카드 복귀
useEffect(() => {
if (quarantineZone.length === 0) return;
const ready = quarantineZone.filter(c => c.returnTurn <= turns);
if (ready.length > 0) {
setCards(prev => [...prev, ...ready.map(({ returnTurn, ...rest }) => rest)]);
setQuarantineZone(prev => prev.filter(c => c.returnTurn > turns));
setChronicle(prev => [...prev, { turn: turns, cardType: '자산', keyword: null, text: `격리했던 오염 카드 ${ready.length}건이 덱으로 복귀했습니다.`, decision: '', tag: null }]);
}
}, [turns]);
// 자산 엔진 world 스냅샷 생성/반영 — 현재 state를 복사해 엔진에 넘기고, 반환된 결과를 다시 state로 반영한다.
// 호출 지점 하나당 반드시 buildAssetWorld/commitAssetWorld를 한 번씩만 쓴다: 같은 이벤트 처리 안에서
// 여러 번 짝지어 부르면 나중 커밋이 아직 리렌더되지 않은(오래된) 클로저 state로 앞의 변경을 덮어써 버린다.
const buildAssetWorld = () => ({
cards: [...cards],
tokens: { ...tokens },
tags: { ...tags },
params: { ...params },
owned: ownedAssets.map(o => ({ ...o })),
quarantine: quarantineZone.map(q => ({ ...q })),
logs: [],
purgeGain: 0,
});
const commitAssetWorld = (world) => {
setCards(world.cards);
setTokens(world.tokens);
setTags(world.tags);
setParams(world.params);
setOwnedAssets(world.owned);
setQuarantineZone(world.quarantine);
if (world.purgeGain) setPurgeCount(c => c + world.purgeGain);
if (world.logs.length) setAssetLog(prev => [...prev, ...world.logs].slice(-8));
};
// 오염 유입 토스트 — 빠르게 연속 스와이프해도 마지막 알림 기준으로 표시 시간이 다시 채워진다.
const notifyPollution = (count, entries) => {
if (pollutionTimerRef.current) clearTimeout(pollutionTimerRef.current);
setPollutionNotification({ id: Date.now(), count, summary: describePollution(entries) });
pollutionTimerRef.current = setTimeout(() => setPollutionNotification(null), 2600);
};
useEffect(() => () => clearTimeout(pollutionTimerRef.current), []);
const handleSwipe = (direction) => {
if (cards.length === 0) return;
const cardIdx = currentCardIndex % cards.length;
const card = cards[cardIdx];
const side = direction === 'left' ? card.left : direction === 'right' ? card.right : null;
const world = buildAssetWorld();
let outcomeText = '';
let isNewTag = false;
let gainedTag = null;
// 자산 엔진: 좌 스와이프 격리 도박(임시 격리 컨테이너) — 성공하면 이 카드는 정상 결과 대신 격리된다.
let quarantinedByAsset = false;
if (direction === 'left' && card.contaminated) {
AssetEngine.onSwipeLeft(world, cardIdx, turns);
quarantinedByAsset = world.quarantinedCard;
}
// 자산 엔진: 오염 카드 드로우 트리거(신화 환전/조직저항 자동 무효화 등)
let negateFree = false;
if (!quarantinedByAsset && card.contaminated) {
AssetEngine.onPollutionDraw(world, card.type);
negateFree = world.negateFree;
}
if (quarantinedByAsset) {
setChronicle(prev => [...prev, {
turn: turns + 1, cardType: card.type, keyword: card.keyword, text: card.text,
decision: '임시 격리 컨테이너로 격리 (정례회의 전까지 미회수 시 실패)', tag: null,
}]);
} else if (side) {
const shouldNullify = card.contaminated && (negateFree || mulliganShields > 0);
const s = side.stats;
world.params.resistance = Math.max(0, world.params.resistance + (shouldNullify ? 0 : s.res));
world.params.entropy = Math.max(0, world.params.entropy + (shouldNullify ? 0 : s.ent));
world.params.panic = Math.max(0, world.params.panic + (shouldNullify ? 0 : s.pan));
world.params.risk = Math.max(0, world.params.risk + (shouldNullify ? 0 : s.rsk));
world.tokens.budget += s.bud;
world.tokens.personnel += s.per;
world.tokens.info += s.inf;
world.tags.wealth += s.wea;
world.tags.force += s.for;
world.tags.surveillance += s.sur;
outcomeText = side.text;
// 파산 문책: 결재 결과 토큰이 마이너스가 되면 파산 카운터 증가
if (world.tokens.budget < 0 || world.tokens.personnel < 0 || world.tokens.info < 0) {
setBankruptcyCount(c => c + 1);
}
// 극단적 파라미터 조기 엔딩 판별용: 이번 결재에서 가장 크게 증가한 위험 파라미터 추적
if (!shouldNullify) {
const dangerDeltas = { resistance: s.res, entropy: s.ent, panic: s.pan };
let maxParam = null, maxVal = 0;
Object.entries(dangerDeltas).forEach(([p, v]) => {
if (v > maxVal) { maxVal = v; maxParam = p; }
});
if (maxParam) setLastParamDelta({ param: maxParam, amount: maxVal });
}
// 내러티브 태그: 재사용 가능한 태그 풀에서 처음 획득하는 태그일 때만 진행도에 반영
gainedTag = side.tag;
if (gainedTag && !acquiredTags.find(t => t.id === gainedTag.id)) {
isNewTag = true;
setAcquiredTags(prev => [...prev, gainedTag]);
setNewTagNotification(gainedTag);
setTimeout(() => setNewTagNotification(null), 3000);
}
// 내러티브 연쇄 엔딩: 오염 카드 4종이 정해진 순서로 등장했는지 추적
if (card.contaminated) {
const nextSequence = [...contaminationSequence, card.type];
setContaminationSequence(nextSequence);
if (!narrativeChainUnlocked && isNarrativeChainComplete(nextSequence)) {
setNarrativeChainUnlocked(true);
}
}
if (shouldNullify) {
if (negateFree) {
outcomeText += ' (책임 소재 불명 처리로 자동 무효화)';
} else {
setMulliganShields(s2 => s2 - 1);
outcomeText += ' (방어막으로 페널티 무효화)';
}
}
// 위험 파라미터를 올린 결재는 그 자리에서 덱에 오염 카드를 주입한다 (정례회의까지 기다리지 않는다).
// 페널티가 무효화된 경우 파라미터도 오르지 않았으므로 오염도 유입되지 않는다 — 방어막의 값어치.
if (!shouldNullify) {
const { total, entries } = previewPollution(s);
if (total > 0) {
const injected = buildPollutionCards(entries, world.params, contaminationTemplates);
if (injected.length > 0) {
world.cards = insertIntoDeck(world.cards, cardIdx, injected);
outcomeText += ` → 오염 카드 ${injected.length}건 유입`;
notifyPollution(injected.length, entries);
}
}
}
} else if (direction === 'up') {
outcomeText = '보류 / 버리기';
}
if (!quarantinedByAsset && outcomeText) {
setChronicle(prev => [...prev, {
turn: turns + 1, cardType: card.type, keyword: card.keyword, text: card.text,
decision: outcomeText, tag: isNewTag ? gainedTag : null,
}]);
}
// 극비 프로젝트 성공 시 자산 획득 (아직 보유하지 않은 자산 중 무작위 1종)
if (!quarantinedByAsset && card.type === '극비' && direction !== 'up') {
const ownedIds = new Set(world.owned.map(a => a.defId));
const pool = AssetEngine.ASSET_CATALOG.filter(a => !ownedIds.has(a.id));
if (pool.length > 0) {
const def = pool[Math.floor(Math.random() * pool.length)];
world.owned.push(AssetEngine.createAssetInstance(def.id, turns + 1));
setAssetsAcquiredCount(c => c + 1);
world.logs.push(`『${def.name}』(${def.grade}급) 획득`);
}
}
// 자산 엔진: 결재 N건마다 발동(문서 파쇄실) + 충전식 자산 충전 확인
const nextTurns = turns + 1;
AssetEngine.onApprovalTick(world, nextTurns);
commitAssetWorld(world);
// 덱은 인덱스를 순환하는 풀이다. 주입/소각으로 길이가 바뀌므로 다음 인덱스는 항상 최종 덱 길이로
// 정규화해 범위 안에 둔다 — 그래야 "현재 위치 기준 2~4장 뒤 삽입"이 의도대로 동작한다.
setCurrentCardIndex(world.cards.length > 0 ? (cardIdx + 1) % world.cards.length : 0);
setTurns(nextTurns);
const newRemaining = approvalsRemaining - 1;
setApprovalsRemaining(newRemaining);
if (newRemaining <= 0) {
setShowCouncil(true);
}
};
// 그림자 의회 종료 후 파라미터 단계에 비례한 등급/물량으로 오염 카드 주입
const injectContaminationCards = () => {
const newCards = buildCouncilPollutionCards(params, contaminationTemplates);
if (newCards.length === 0) return;
setCards(prev => insertIntoDeck(prev, currentCardIndex, newCards));
setChronicle(prev => [...prev, { turn: turns, cardType: '오염', keyword: null, text: `정례회의 종료 후 신규 오염 카드 ${newCards.length}건이 덱에 스며들었습니다.`, decision: '', tag: null }]);
const byType = newCards.reduce((acc, c) => ({ ...acc, [c.type]: (acc[c.type] || 0) + 1 }), {});
notifyPollution(
newCards.length,
Object.entries(byType).map(([type, count]) => ({ icon: CONTAM_TYPE_ICON[type], type, count })),
);
};
const handleCouncilResolve = (result) => {
setShowCouncil(false);
// 자산 엔진: 의회 종료 시 상시 자산 리스크 정산 + 격리 실패 판정(심연 보관 조례 개입 포함)
const councilWorld = buildAssetWorld();
AssetEngine.onCouncilEnd(councilWorld);
commitAssetWorld(councilWorld);
if (result.bankrupted) {
setFinalEnding(getEndingById('bankruptcy'));
setGameOver(true);
return;
}
if (result.impeachment) {
const ending = resolveEnding({ trigger: 'impeachment', tags, chairmanAxis, narrativeChainUnlocked });
setFinalEnding(ending);
setGameOver(true);
return;
}
if (typeof result.bankruptcyWarning === 'boolean') {
setBankruptcyWarning(result.bankruptcyWarning);
}
injectContaminationCards();
// 라운드 진행: 라운드가 증가할수록 다음 정례회의까지의 결재 건수가 줄어든다
const nextRound = roundCount + 1;
let nextApprovals;
if (roundCount === 1) nextApprovals = 8;
else if (roundCount === 2) nextApprovals = 6;
else if (roundCount === 3) nextApprovals = 4;
else if (roundCount === 4) nextApprovals = 2;
else nextApprovals = 0;
setRoundCount(nextRound);
setApprovalsRemaining(nextApprovals);
if (nextApprovals <= 0) {
setFinalEnding(getEndingById('term_expired'));
setGameOver(true);
}
};
// 자산 슬롯 클릭 — 항상 정보 팝업을 연다 (Part C UX 블로커 해결). defId만 저장해 최신 스택/충전 값을 반영한다.
const handleAssetSlotClick = (inst) => setAssetPopup(inst.defId);
// 충전식 자산(manual_charge) 수동 발동 — 팝업 내부의 '발동' 버튼에서 호출
const handleAssetActivate = (defId) => {
const world = buildAssetWorld();
AssetEngine.activateManualAsset(world, defId, turns);
commitAssetWorld(world);
if (world.negateChargeGranted) setMulliganShields(s => s + 1);
setAssetPopup(null);
};
const renderDots = (value, max = 5) => {
return Array.from({ length: max }).map((_, i) => (
<div key={i} className={`dot ${i < value ? 'filled' : 'empty'}`}></div>
));
};
if (gameOver) {
return (
<EndingScreen
ending={finalEnding}
narrativeTags={acquiredTags.length}
keywords={acquiredTags.map(t => ({ word: t.name }))}
onRestart={() => window.location.reload()}
/>
);
}
if (showCouncil) {
// 글로벌 지부장 12개 파벌 중 이번 회차 선발된 5명의 우호도를 각자 다른 조건으로 개별 계산
const affinityCtx = {
tags, tokens, params,
assetsCount: ownedAssets.length,
mythicCount: cards.filter(c => c.type === '신화').length,
tagCount: acquiredTags.length, assetsAcquiredCount, purgeCount, bribeCount,
};
const globalFactions = factionRoster.map(f => {
const score = f.computeAffinity(affinityCtx);
const tier = getFactionTier(score);
return { id: f.id, name: f.name, tier, vote: TIER_VOTE[tier], immune: TIER_IMMUNE.has(tier) };
});
return (
<CouncilScreen
params={params} setParams={setParams}
tokens={tokens} setTokens={setTokens}
tags={tags} setTags={setTags}
bankruptcyCount={bankruptcyCount}
bankruptcyWarning={bankruptcyWarning}
globalFactions={globalFactions}
onBribe={() => setBribeCount(c => c + 1)}
onResolve={handleCouncilResolve}
/>
);
}
if (cards.length === 0) {
return <div className="game-screen">데이터를 불러오는 중...</div>;
}
const phaseClass = phase === 1 ? 'phase-1' : phase === 2 ? 'phase-2' : 'phase-3';
const nextTagThreshold = phase === 1 ? PHASE2_TAG_THRESHOLD : phase === 2 ? PHASE3_TAG_THRESHOLD : null;
// 덱 오염도 — 결재를 이어갈수록 덱이 얼마나 썩었는지 상시 노출해 압박을 준다.
const pollutedCount = cards.filter(c => c.contaminated).length;
const pollutionRatio = cards.length > 0 ? pollutedCount / cards.length : 0;
return (
<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>{acquiredTags.length}{nextTagThreshold !== null ? `/${nextTagThreshold}` : ''}</span>
</div>
<div className="round-info">
<div>제 {roundCount}차 정례회의 상정까지: 잔여 {approvalsRemaining}건</div>
<div className={`deck-pollution ${pollutionRatio >= 0.4 ? 'critical' : ''}`}>
☣ 덱 오염 {pollutedCount}/{cards.length}
</div>
</div>
<div className="parameters">
<div className={`param ${params.resistance >= 4 ? 'critical' : ''}`} style={{'--param-color': 'var(--color-resistance-critical)'}}>
<span className="icon">✊</span>
<div className="dots">{renderDots(params.resistance)}</div>
</div>
<div className={`param ${params.entropy >= 4 ? 'critical' : ''}`} style={{'--param-color': 'var(--color-entropy-critical)'}}>
<span className="icon">🌀</span>
<div className="dots">{renderDots(params.entropy)}</div>
</div>
<div className={`param ${params.panic >= 4 ? 'critical' : ''}`} style={{'--param-color': 'var(--color-panic-critical)'}}>
<span className="icon">😱</span>
<div className="dots">{renderDots(params.panic)}</div>
</div>
<div className={`param ${params.risk >= 4 ? 'critical' : ''}`} style={{'--param-color': 'var(--color-risk-critical)'}}>
<span className="icon">⚠️</span>
<div className="dots">{renderDots(params.risk)}</div>
</div>
</div>
</div>
{/* Card Area */}
<div className="card-area">
<SwipeCard
card={cards[currentCardIndex % cards.length]}
onSwipe={handleSwipe}
phase={phase}
/>
</div>
{/* Bottom Inventory */}
<div className="bottom-bar">
<div className="tokens-row">
<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>
<div className="tag"><Shield size={14} /> <span>{tags.force}</span></div>
<div className="tag"><EyeOff size={14} /> <span>{tags.surveillance}</span></div>
</div>
<div className="asset-slots">
{ownedAssets.length === 0 && <div className="asset-slot-empty-hint">보유 자산 없음</div>}
{ownedAssets.map((inst) => {
const def = AssetEngine.getAssetDef(inst.defId);
const meta = AssetEngine.ARCHETYPE_META[def.archetype];
const ready = def.trigger.type === 'manual_charge' && inst.charge >= 1;
return (
<button
key={inst.defId}
className={`asset-slot filled grade-${def.grade} ${ready ? 'ready' : ''}`}
onClick={() => handleAssetSlotClick(inst)}
title={def.name}
>
<span className="asset-icon">{meta.icon}</span>
</button>
);
})}
</div>
</div>
{assetLog.length > 0 && (
<div className="asset-log-feed">
{assetLog.slice(-4).map((line, i) => (
<div key={i} className="asset-log-line">{line}</div>
))}
</div>
)}
{assetPopup && (
<AssetInfoPopup
instance={ownedAssets.find(a => a.defId === assetPopup)}
def={AssetEngine.getAssetDef(assetPopup)}
onActivate={() => handleAssetActivate(assetPopup)}
onClose={() => setAssetPopup(null)}
/>
)}
{(newTagNotification || pollutionNotification) && (
<div className="notification-stack">
{newTagNotification && (
<div className="tag-notification">
<div className="tag-noti-icon">{newTagNotification.icon}</div>
<div className="tag-noti-info">
<h4>새로운 내러티브 획득!</h4>
<p>[{newTagNotification.name}]</p>
</div>
</div>
)}
{pollutionNotification && (
<div className="pollution-notification" key={pollutionNotification.id}>
<div className="pollution-noti-icon">☣</div>
<div className="pollution-noti-info">
<h4>오염 카드 {pollutionNotification.count}건이 덱에 유입되었습니다</h4>
<p>{pollutionNotification.summary}</p>
</div>
</div>
)}
</div>
)}
{showChronicle && (
<ChroniclePopup
chronicle={chronicle}
acquiredTags={acquiredTags}
onClose={() => setShowChronicle(false)}
/>
)}
</div>
);
}
+266
View File
@@ -0,0 +1,266 @@
/* src/components/SwipeCard.css */
.card-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: 0;
left: 0;
}
.swipe-card {
width: 320px;
height: 480px;
background-color: var(--color-surface);
border-radius: 24px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5), inset 0 0 0 1px rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
position: relative;
cursor: grab;
user-select: none;
overflow: hidden;
z-index: 20;
}
.swipe-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, rgba(255,255,255,0.05) 0%, transparent 100%);
pointer-events: none;
}
.card-content {
padding: 30px;
height: 100%;
display: flex;
flex-direction: column;
}
.card-header {
text-align: center;
margin-bottom: 30px;
}
.card-category {
font-family: var(--font-typewriter);
font-size: 14px;
font-weight: 500;
color: var(--color-risk-base);
letter-spacing: 2px;
display: block;
margin-bottom: 8px;
}
.card-keyword {
font-family: var(--font-doc-title);
font-size: 20px;
color: #fff;
margin: 0 0 8px 0;
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
}
.card-question {
font-family: var(--font-ui);
font-size: 18px;
color: #fbbf24;
margin: 0;
font-weight: bold;
}
.card-body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.card-text {
font-family: var(--font-ui);
font-size: 15px;
line-height: 1.6;
text-align: center;
color: rgba(255, 255, 255, 0.85);
text-shadow: 0 1px 2px rgba(0,0,0,0.8);
}
.swipe-intent {
position: absolute;
top: 20px;
width: 100%;
text-align: center;
pointer-events: none;
font-family: var(--font-ui);
font-weight: 700;
font-size: 16px;
text-transform: uppercase;
letter-spacing: 1px;
}
.left-intent {
color: var(--color-swipe-left);
transform: rotate(-10deg);
left: -20px;
}
.right-intent {
color: var(--color-swipe-right);
transform: rotate(10deg);
right: -20px;
}
.up-intent {
color: var(--color-swipe-up);
top: -40px;
left: 50%;
transform: translateX(-50%);
}
.swipe-intent .intent-title {
background: rgba(0, 0, 0, 0.8);
padding: 10px 20px;
border-radius: 8px;
border: 2px solid currentColor;
display: inline-block;
margin-bottom: 8px;
font-size: 24px;
font-weight: 900;
}
.card-flavor-container {
margin-top: auto;
padding: 12px;
background: rgba(0, 0, 0, 0.4);
border-radius: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.card-flavor {
font-family: var(--font-typewriter);
font-size: 13px;
font-style: italic;
color: #a1a1aa;
text-align: center;
line-height: 1.4;
margin: 0;
}
.stats-preview {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
background: rgba(0, 0, 0, 0.9);
padding: 8px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1);
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
min-width: 120px;
pointer-events: none;
}
.stat-item {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
font-family: var(--font-ui);
font-size: 18px;
font-weight: bold;
}
.stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
font-size: 18px;
}
.stat-icon svg {
width: 18px;
height: 18px;
}
.stat-label {
display: none;
}
.stat-val {
margin-left: 8px;
}
.stat-good {
color: #4ade80;
}
.stat-bad {
color: #f87171;
}
.intent-tag-reward {
background: rgba(251, 191, 36, 0.9);
color: #000;
padding: 8px 16px;
border-radius: 8px;
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-weight: bold;
font-family: var(--font-ui);
box-shadow: 0 0 15px rgba(251, 191, 36, 0.5);
animation: pulseTag 1.5s infinite;
}
.intent-tag-icon {
font-size: 20px;
}
/* 오염 카드 사전 경고 — 이 선택이 덱을 얼마나 더럽히는지 스와이프 전에 보여준다 */
.pollution-warning {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 2px;
background: rgba(20, 0, 0, 0.92);
border: 2px solid #dc2626;
color: #f87171;
padding: 6px 14px;
border-radius: 8px;
margin-bottom: 8px;
font-family: var(--font-ui);
box-shadow: 0 0 15px rgba(139, 0, 0, 0.6);
animation: pulsePollutionWarning 1.5s infinite;
text-transform: none;
letter-spacing: 0;
}
.pollution-warning-head {
font-size: 16px;
font-weight: 900;
}
.pollution-warning-types {
font-family: var(--font-typewriter);
font-size: 11px;
color: #e4e4e7;
white-space: nowrap;
}
@keyframes pulsePollutionWarning {
0% { transform: scale(1); box-shadow: 0 0 12px rgba(139, 0, 0, 0.5); }
50% { transform: scale(1.04); box-shadow: 0 0 22px rgba(139, 0, 0, 0.9); }
100% { transform: scale(1); box-shadow: 0 0 12px rgba(139, 0, 0, 0.5); }
}
@keyframes pulseTag {
0% { transform: scale(1); box-shadow: 0 0 15px rgba(251, 191, 36, 0.5); }
50% { transform: scale(1.05); box-shadow: 0 0 25px rgba(251, 191, 36, 0.8); }
100% { transform: scale(1); box-shadow: 0 0 15px rgba(251, 191, 36, 0.5); }
}
+156
View File
@@ -0,0 +1,156 @@
import React, { useState, useEffect } from 'react';
import { motion, useMotionValue, useTransform, useAnimation } from 'framer-motion';
import { HandCoins, Droplet, Eye, Shield, Building2, EyeOff } from 'lucide-react';
import { previewPollution, describePollution } from '../engine/pollution';
import './SwipeCard.css';
// 이 선택이 덱에 오염 카드를 몇 장 밀어 넣는지 스와이프 전에 예고한다.
// 계산은 반드시 engine/pollution.js의 previewPollution을 쓴다 — 실제 주입과 같은 기준이어야 한다.
const renderPollutionWarning = (stats) => {
const { total, entries } = previewPollution(stats);
if (total === 0) return null;
return (
<div className="pollution-warning">
<span className="pollution-warning-head">☣ 오염 카드 +{total}</span>
<span className="pollution-warning-types">{describePollution(entries)}</span>
</div>
);
};
const renderStats = (stats) => {
if (!stats) return null;
const items = [];
if (stats.res !== 0) items.push({ icon: '✊', label: '저항', val: stats.res, isDanger: true });
if (stats.ent !== 0) items.push({ icon: '🌀', label: '엔트로피', val: stats.ent, isDanger: true });
if (stats.pan !== 0) items.push({ icon: '😱', label: '공황', val: stats.pan, isDanger: true });
if (stats.rsk !== 0) items.push({ icon: '⚠️', label: '위험', val: stats.rsk, isDanger: true });
if (stats.bud !== 0) items.push({ icon: <HandCoins size={14} />, label: '예산', val: stats.bud });
if (stats.per !== 0) items.push({ icon: <Droplet size={14} />, label: '인력', val: stats.per });
if (stats.inf !== 0) items.push({ icon: <Eye size={14} />, label: '정보', val: stats.inf });
if (stats.wea !== 0) items.push({ icon: <Building2 size={14} />, label: '재력', val: stats.wea });
if (stats.for !== 0) items.push({ icon: <Shield size={14} />, label: '무력', val: stats.for });
if (stats.sur !== 0) items.push({ icon: <EyeOff size={14} />, label: '감시', val: stats.sur });
if (items.length === 0) return null;
return (
<div className="stats-preview">
{items.map((item, idx) => {
const sign = item.val > 0 ? '+' : '';
const isBad = item.isDanger ? item.val > 0 : item.val < 0;
const colorClass = isBad ? 'stat-bad' : 'stat-good';
return (
<div key={idx} className={`stat-item ${colorClass}`}>
<span className="stat-icon">{item.icon}</span>
<span className="stat-label">{item.label}</span>
<span className="stat-val">{sign}{item.val}</span>
</div>
);
})}
</div>
);
};
export default function SwipeCard({ card, onSwipe, phase }) {
const x = useMotionValue(0);
const y = useMotionValue(0);
const controls = useAnimation();
// Dynamic friction based on phase
const friction = phase === 3 ? 1.5 : phase === 2 ? 1.2 : 0.8;
// Rotations and opacity
const rotate = useTransform(x, [-200, 200], [-15 * friction, 15 * friction]);
const leftOpacity = useTransform(x, [-100, -50, 0], [1, 0.5, 0]);
const rightOpacity = useTransform(x, [0, 50, 100], [0, 0.5, 1]);
const upOpacity = useTransform(y, [-100, -50, 0], [1, 0.5, 0]);
const [exitX, setExitX] = useState(0);
// Reset when card changes
useEffect(() => {
setExitX(0);
x.set(0);
y.set(0);
controls.start({ x: 0, y: 0, opacity: 1, rotate: 0, transition: { duration: 0 } });
}, [card, controls, x, y]);
const handleDragEnd = async (event, info) => {
const threshold = 100;
if (info.offset.x > threshold) {
await controls.start({ x: 300, opacity: 0, transition: { duration: 0.3 } });
onSwipe('right');
} else if (info.offset.x < -threshold) {
await controls.start({ x: -300, opacity: 0, transition: { duration: 0.3 } });
onSwipe('left');
} else if (info.offset.y < -threshold && card.type === '극비') {
await controls.start({ y: -300, opacity: 0, transition: { duration: 0.3 } });
onSwipe('up');
} else {
// Snap back
controls.start({ x: 0, y: 0, transition: { type: 'spring', stiffness: 300, damping: 20 } });
}
};
return (
<div className="card-container">
<motion.div
className="swipe-card"
drag
dragConstraints={{ left: 0, right: 0, top: 0, bottom: 0 }}
style={{ x, y, rotate }}
onDragEnd={handleDragEnd}
animate={controls}
whileTap={{ scale: 0.98, cursor: 'grabbing' }}
>
<div className="card-content">
<div className="card-header">
<span className="card-category">[{card.type}]</span>
{card.keyword && <h3 className="card-keyword">{card.keyword}</h3>}
{card.question && <h2 className="card-question">{card.question}</h2>}
</div>
<div className="card-body">
<p className="card-text">{card.text}</p>
</div>
{card.flavor && (
<div className="card-flavor-container">
<p className="card-flavor">"{card.flavor}"</p>
</div>
)}
</div>
{/* Swipe Intents */}
<motion.div className="swipe-intent left-intent" style={{ opacity: leftOpacity }}>
<span className="intent-title">{card.left?.text}</span>
{card.left?.tag && (
<div className="intent-tag-reward">
<span className="intent-tag-icon">{card.left.tag.icon}</span>
<span className="intent-tag-name">{card.left.tag.name}</span>
</div>
)}
{renderPollutionWarning(card.left?.stats)}
{renderStats(card.left?.stats)}
</motion.div>
<motion.div className="swipe-intent right-intent" style={{ opacity: rightOpacity }}>
<span className="intent-title">{card.right?.text}</span>
{card.right?.tag && (
<div className="intent-tag-reward">
<span className="intent-tag-icon">{card.right.tag.icon}</span>
<span className="intent-tag-name">{card.right.tag.name}</span>
</div>
)}
{renderPollutionWarning(card.right?.stats)}
{renderStats(card.right?.stats)}
</motion.div>
{card.type === '극비' && (
<motion.div className="swipe-intent up-intent" style={{ opacity: upOpacity }}>
<span className="intent-title">보류 / 버리기</span>
</motion.div>
)}
</motion.div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
[
{
"id": "asset_purge_001",
"name": "문서 파쇄실",
"grade": "C",
"archetype": "purge",
"trigger": { "type": "every_n_approvals", "params": { "n": 5 } },
"effect": { "type": "purge_lowest", "params": { "count": 1 } },
"growth": null,
"risk": null,
"faction_link": "scavengers",
"description": "결재 5건마다 덱에서 최하 등급 오염 카드 1장을 영구 소각한다.",
"flavor": "파쇄 용량: 일일 1건. 증설 요청은 3회 반려됨."
},
{
"id": "asset_purge_002",
"name": "소각 처리 대장",
"grade": "A",
"archetype": "purge",
"trigger": { "type": "on_purge", "params": {} },
"effect": null,
"growth": { "stackOn": "on_purge", "per": 3, "bonus": { "effect": "purge_lowest", "count": 1 } },
"risk": null,
"faction_link": "scavengers",
"description": "소각이 발생할 때마다 숙련도가 쌓인다. 숙련 3회마다 최하 등급 오염 카드 1장을 추가로 소각한다.",
"flavor": "소각로 온도는 서류로 증명되지 않는다."
},
{
"id": "asset_convert_001",
"name": "폐기물 재활용 규정",
"grade": "C",
"archetype": "convert",
"trigger": { "type": "on_pollution_draw", "params": { "category": "신화", "minGrade": null } },
"effect": { "type": "convert_to_token", "params": { "tokenType": "budget", "amount": 2 } },
"growth": null,
"risk": null,
"faction_link": "nexus_traders",
"description": "신화 오염 카드가 드로우되면 예산 토큰 2를 회수한다. (페널티는 정상 적용 후)",
"flavor": "괴수 사체도 감가상각 대상이다."
},
{
"id": "asset_convert_002",
"name": "부산물 매각 계약",
"grade": "B",
"archetype": "convert",
"trigger": { "type": "on_convert", "params": {} },
"effect": { "type": "convert_multiplier", "params": { "multiplier": 2 } },
"growth": null,
"risk": { "param": "risk_tolerance", "amount": 5, "when": "council_end" },
"faction_link": "nexus_traders",
"description": "모든 환전 효과의 산출량을 2배로 늘린다. 의회가 열릴 때마다 위험 수용 계수가 소폭 상승한다.",
"flavor": "이 계약서에 서명한 사람은 아무도 기억하지 못한다."
},
{
"id": "asset_quarantine_001",
"name": "임시 격리 컨테이너",
"grade": "C",
"archetype": "quarantine",
"trigger": { "type": "on_swipe_left", "params": {} },
"effect": { "type": "quarantine", "params": { "failCondition": "다음 의회까지 대중 공황 1단계 상승" } },
"growth": null,
"risk": null,
"faction_link": "occult_collectors",
"description": "좌로 스와이프한 오염 카드를 격리한다. 다음 의회 전까지 처리하지 못하면 대중 공황이 상승한다.",
"flavor": "컨테이너 라벨: '절대 열지 말 것 (2차 통보)'."
},
{
"id": "asset_quarantine_002",
"name": "심연 보관 조례",
"grade": "S",
"archetype": "quarantine",
"trigger": { "type": "on_quarantine_fail", "params": {} },
"effect": { "type": "quarantine_fail_to_purge", "params": { "bonusTag": "surveillance" } },
"growth": null,
"risk": null,
"faction_link": "occult_collectors",
"description": "격리가 실패로 판정되면 그 즉시 대상을 영구 소각하고, 감시 태그 1을 획득한다. 격리 실패가 더 이상 페널티가 아니다.",
"flavor": "실패란 서류상의 표현일 뿐이다."
},
{
"id": "asset_skip_001",
"name": "결재 반려 도장",
"grade": "C",
"archetype": "skip",
"trigger": { "type": "manual_charge", "params": { "chargeEvery": 10 } },
"effect": { "type": "negate_penalty", "params": { "category": null } },
"growth": null,
"risk": null,
"faction_link": "field_agents_union",
"description": "결재 10건마다 충전된다. 충전 완료 시 수동으로 발동해 다음 오염 카드의 페널티를 1회 무효화한다.",
"flavor": "도장이 닳는 속도가 곧 국장의 임기다."
},
{
"id": "asset_skip_002",
"name": "책임 소재 불명 처리",
"grade": "B",
"archetype": "skip",
"trigger": { "type": "on_pollution_draw", "params": { "category": "조직저항", "minGrade": null } },
"effect": { "type": "negate_penalty", "params": { "category": "조직저항" } },
"growth": null,
"risk": { "param": "resistance", "amount": 3, "when": "on_trigger" },
"faction_link": "field_agents_union",
"description": "조직 저항 계열 오염 카드의 페널티를 자동으로 무효화한다. 다만 책임 소재가 사라질 때마다 내부 저항이 소폭 상승한다.",
"flavor": "'담당자 부재'라는 담당자가 새로 생겼다."
}
]
+48
View File
@@ -0,0 +1,48 @@
id,type,keyword,question,text,flavor,left_text,left_tag,left_res,left_ent,left_pan,left_rsk,left_bud,left_per,left_inf,left_wea,left_for,left_sur,right_text,right_tag,right_res,right_ent,right_pan,right_rsk,right_bud,right_per,right_inf,right_wea,right_for,right_sur,grade
1,일상,요원들의 시위 발생,시위를 어떻게 처리하시겠습니까?,B구역 하급 요원들이 예산 삭감에 반발해 장비 반납 시위를 하고 있습니다.,우리가 세상을 구하는데 월급이 깎인다고요? 촉수에 맞아 죽으나 굶어 죽으나 똑같습니다!,무력 진압,tag_ruthless,1,0,0,0,0,-10,0,0,0,0,요구 수용,,0,0,0,0,-20,0,0,0,0,0,
2,위기,격리실 오염 발생,오염을 어떻게 정화하시겠습니까?,지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다.,보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다.,네이팜 소각 강행,tag_coverup,0,0,0,0,0,-30,0,0,0,0,조사팀 파견,,0,1,0,1,0,0,0,0,0,0,
3,일상,촉수 괴물 출현,출동 승인하시겠습니까?,7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다.,규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다.,내역 위조 후 출동,,0,0,0,1,0,0,-10,0,0,0,규정대로 반려,,1,1,0,0,0,0,0,0,0,0,
4,극비,변이 DNA 이식 제안,실험을 승인하시겠습니까?,진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠.,인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠.,실험 승인,tag_madness,0,2,0,0,40,40,0,0,0,0,제안 거절,,1,0,0,0,0,0,0,0,0,0,
5,작전,전술 폭격 건의,폭격을 승인하시겠습니까?,철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다.,희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠.,궤도 폭격 승인,tag_sacrifice,0,0,0,2,0,0,0,0,2,0,작전 기각,,1,0,0,0,0,0,0,0,0,0,
6,일상,부산물 암시장 매각,매각을 허가하시겠습니까?,차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다.,이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠.,매각 허가,tag_corruption,0,0,0,1,30,0,0,0,0,0,원칙대로 소각,,0,0,0,0,-10,0,0,0,0,0,
7,작전,존재 양지화 프로젝트,프로젝트를 승인하시겠습니까?,대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다.,우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다.,감시망 구축,tag_coverup,0,0,0,0,0,0,-40,0,0,2,프로젝트 보류,,1,0,0,0,0,0,0,0,0,0,
8,극비,대규모 도서관 건립,예산을 배정하시겠습니까?,고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다.,이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다.,예산 배정,,0,0,0,0,-40,0,40,0,0,0,예산 전액 삭감,,1,0,0,1,0,0,0,0,0,0,
9,위기,이단 색출 마녀사냥,어떻게 개입하시겠습니까?,기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다.,더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로!,적당히 묵인한다,,1,0,0,0,0,-30,0,0,0,0,군사력으로 제압,,0,0,0,1,0,0,-20,0,0,0,
10,일상,변칙 현상 영상 유출,어떻게 처리하시겠습니까?,한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다.,대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오.,제거 절차 승인,tag_coverup,0,0,0,-1,0,0,-20,0,0,0,영상만 삭제,,0,1,0,0,0,0,0,0,0,0,
11,위기,사이비 종교 인신 공양,어떻게 대응하시겠습니까?,특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다.,위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다.,군사 개입 지시,,1,0,1,0,0,-40,0,0,0,0,의식 방관,tag_cult,0,2,0,0,0,0,0,0,0,0,
12,위기,의회의 긴급 감사,어떻게 대처하시겠습니까?,의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다!,세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오.,은폐 공작 실행,tag_corruption,0,0,0,1,0,0,-40,0,0,0,횡령 사실 인정,,0,0,0,2,0,0,0,0,0,0,
13,일상,연구 부서 파업 조짐,요구를 수용하시겠습니까?,연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다.,저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요.,특별 보너스 지급,,-1,0,0,0,-30,0,0,0,0,0,요구 전면 묵살,,1,0,0,0,0,0,0,0,0,0,
14,작전,이계 무역로 개방,무역로를 개방하시겠습니까?,이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다.,저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠.,게이트 강제 개방,tag_alien_contact,0,1,0,0,50,0,0,0,0,0,폐쇄 상태 유지,,1,0,0,0,0,0,0,0,0,0,
15,극비,전임 국장 비밀 금고,금고를 개방하시겠습니까?,전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다.,전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다.,강제 개방 지시,tag_corruption,0,0,0,0,20,-30,0,1,0,0,영구 봉인,,0,0,0,0,0,0,0,0,0,0,
16,작전,메가코프 협박 작전,작전을 실행하시겠습니까?,글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다.,그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까.,작전 실행,,0,0,0,0,0,0,-30,2,0,0,작전 취소,,1,0,0,0,0,0,0,0,0,0,
17,위기,전술 폭격 건의,폭격을 승인하시겠습니까?,제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요?,시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다.,시민 포기 및 폭격,tag_ruthless,0,-1,0,2,0,0,0,0,0,0,결사 방어 명령,,0,0,0,0,-40,-40,0,0,0,0,
18,일상,행정부 파업 예고,어떻게 대응하시겠습니까?,새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다.,기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다.,예산 전용으로 무마,,0,0,0,1,-20,0,0,0,0,0,파업 강경 대응,,1,0,0,0,0,0,0,0,0,0,
19,극비,불침번 요원 개발,배치하시겠습니까?,비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다.,잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠.,실전 즉각 배치,,0,1,0,0,0,0,20,0,1,0,즉각 폐기 처분,,1,0,0,0,-10,0,0,0,0,0,
20,일상,내부 스파이 첩보,어떻게 조사하시겠습니까?,기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다.,누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다.,대규모 사찰 시작,tag_political_pressure,2,0,0,0,0,0,30,0,0,0,증거 부족으로 덮음,,0,0,0,1,0,0,0,0,0,0,
21,작전,비정상적 위기 발생,국장님 결단이 필요합니다,순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다.,괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다.,수용소 건립 승인,tag_ruthless,0,0,0,0,-40,0,0,0,1,0,제안 단호히 거절,,1,0,0,1,0,0,0,0,0,0,
22,극비,이계의 유물 인양,유물을 어떻게 처리하시겠습니까?,고대 공존당의 사제들이 심해의 유물을 인양했습니다. 만지는 자의 정신을 갉아먹지만 막대한 지식을 줍니다.,약간의 두통만 참으면 이계의 비밀을 모두 알 수 있습니다. 요원 하나쯤 미쳐도 괜찮습니다.,유물 집중 연구,tag_alien_contact,0,1,0,0,0,0,50,0,0,0,유물 파괴 명령,,1,0,0,1,0,0,0,0,0,0,
23,위기,이계의 주시자 출현,대중을 어떻게 통제하시겠습니까?,얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다.,하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오.,인터넷 전면 차단,,2,0,1,0,0,0,0,0,0,2,진정제 무상 배포,,0,0,0,0,-50,0,0,0,0,0,
24,일상,요원 집단 트라우마,어떻게 지원하시겠습니까?,요원들이 괴수 진압 후 심각한 트라우마에 시달리고 있습니다. 심리 치료 지원이 시급합니다.,밤마다 촉수가 목을 조르는 악몽을 꾼답니다. 차라리 그 부분 기억을 지워버릴까요?,정신과 치료 지원,,0,0,0,0,-20,10,0,0,0,0,트라우마 기억 소거,,0,1,0,1,0,0,0,0,0,0,
25,극비,내부 고발자 발생,어떻게 막으시겠습니까?,의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다.,국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다.,사고사 위장 지시,tag_coverup,0,0,0,-1,0,0,-30,0,0,0,폭로전 정면 돌파,,0,0,0,2,0,0,0,0,0,0,
26,위기,차원 붕괴 폭탄 오작동,어떻게 대응하시겠습니까?,차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다.,최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다.,특수 해체팀 투입,tag_sacrifice,0,0,0,0,0,-40,0,0,0,0,폭발 방관 및 대피,,0,2,0,0,0,0,0,0,0,0,
27,작전,PMC 흡수 합병,결탁하시겠습니까?,군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다.,돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠.,결탁 및 흡수 합병,,0,0,0,0,-40,0,0,0,2,0,독자적 노선 유지,,1,0,0,0,0,0,0,0,0,0,
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명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다.,거래 즉시 성사,tag_alien_contact,0,0,0,0,0,-40,0,0,2,0,사절단 전원 사살,,0,1,0,1,0,0,0,0,0,0,
30,일상,믹스 커피 재고 고갈,어떻게 해결하시겠습니까?,국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다.,종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까?,최고급 원두 지원,,-1,0,0,0,-10,0,0,0,0,0,헛소리 금지령,,1,0,0,0,0,0,0,0,0,0,
31,위기,신화적 존재 강림,어떻게 대적하시겠습니까?,신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다!,최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까?,현실 조작기 가동,tag_madness,0,2,0,0,0,0,-50,0,0,0,모든 예비병력 투입,tag_sacrifice,0,0,0,1,0,-60,0,0,0,0,
32,신화,실험체의 노래,어떻게 조치하시겠습니까?,격리된 실험체 하나가 벽을 긁으며 알아들을 수 없는 노래를 흥얼거립니다.,직원들 사이에서 그 노래가 귓가에 맴돈다는 소문이 돕니다.,진정제 투여 및 격리 강화,,0,0,0,0,-15,0,0,0,0,0,그냥 무시하고 넘어간다,,0,1,0,0,0,0,0,0,0,0,C
33,신화,움직이는 조각상,어떻게 처리하시겠습니까?,지하 수조에서 건져올린 조각상이 자정마다 미세하게 위치를 바꿉니다.,CCTV 확인 결과 아무도 없었는데 조각상은 분명 돌아서 있었습니다.,석관에 봉인 후 콘크리트 매립,,0,0,0,0,-30,-10,0,0,0,0,일단 창고에 방치,,0,1,1,0,0,0,0,0,0,0,B
34,신화,심해의 알,부화를 막으시겠습니까?,심해 탐사팀이 인양한 알 하나가 부화 징후를 보입니다. 안에서 무언가 박동합니다.,생물학팀은 저것은 알이 아니라 심장이라고 보고했습니다.,즉각 냉동 처리 및 소각,,0,0,0,0,-50,-20,0,0,0,0,부화 과정 관찰 지속,tag_alien_contact,0,2,1,0,0,0,0,0,0,0,A
35,신화,장막 너머의 이름,이름을 들으시겠습니까?,장막 너머의 존재가 마침내 자신의 진짜 이름을 발음하기 시작했습니다. 듣는 요원들의 코피가 멈추지 않습니다.,언어병리학자는 이름의 세 음절만 듣고도 실어증에 걸렸습니다.,전 요원 청각 차단 및 격리 봉쇄,tag_madness,0,1,0,0,-70,-40,0,0,0,0,이름을 끝까지 듣고 기록한다,tag_alien_contact,0,3,2,0,0,0,0,0,0,0,S
36,조직저항,초과근무 항의,어떻게 무마하시겠습니까?,현장 요원 셋이 초과근무수당 미지급에 항의하며 보고서 제출을 하루 미뤘습니다.,정시 퇴근이 인권이라는 팻말을 봤습니다.,밀린 수당 즉시 지급,,0,0,0,0,-15,0,0,0,0,0,규정 운운하며 묵살,,1,0,0,0,0,0,0,0,0,0,C
37,조직저항,항명 성명,어떻게 대응하시겠습니까?,중간관리자 다섯이 연명으로 국장의 승인 절차에 대한 항명 성명을 냈습니다.,서명자 명단에 낯익은 이름이 보입니다. 승진 대상자였는데요.,일대일 면담으로 무마,,0,0,0,0,0,-25,0,0,0,0,주동자 색출 및 좌천,,2,0,0,0,0,0,0,0,0,0,B
38,조직저항,경비 태업,어떻게 수습하시겠습니까?,격리동 경비 인력 절반이 동시에 병가를 제출했습니다. 명백한 태업입니다.,다들 같은 날 같은 감기에 걸렸다고 합니다. 참으로 공교롭죠.,대체 인력 긴급 투입,,0,0,0,0,-30,-15,0,0,0,0,복귀할 때까지 방치,tag_coup,2,0,1,0,0,0,0,0,0,0,A
39,조직저항,대규모 사표,어떻게 진압하시겠습니까?,전 부서장급 열두 명이 동시 사표를 제출하며 내부 기밀을 언론에 흘리겠다 협박합니다.,이것은 항명이 아니라 쿠데타입니다.,전원 요구 수용 및 대규모 숙청,tag_ruthless,1,0,0,0,-60,-50,0,0,0,0,협박을 무시하고 강경 대응,tag_coup,3,1,0,0,0,0,0,0,0,0,S
40,사회공황,괴이한 목격담,어떻게 진화하시겠습니까?,동네 커뮤니티에 하늘에서 이상한 빛을 봤다는 글이 올라와 조회수가 치솟고 있습니다.,댓글 반응은 반반입니다. 반은 조작설 반은 목격담 추가.,여론 조작팀 투입해 진화,,0,0,0,0,0,0,-15,0,0,0,며칠 지나면 묻힐 겁니다,,0,0,1,0,0,0,0,0,0,0,C
41,사회공황,특집 뉴스 보도,어떻게 대응하시겠습니까?,한 지역 방송국이 괴수 목격담을 특집 뉴스로 다뤘습니다. 시청률이 세 배 뛰었습니다.,미스터리 특종이라는 자막이 하루 종일 반복 재생됩니다.,방송국 인수 후 정정 보도,,0,0,0,0,-30,0,0,0,0,0,허위 정보로 규정하고 방치,,0,0,2,0,0,0,0,0,0,0,B
42,사회공황,생필품 사재기,어떻게 진정시키시겠습니까?,대형 마트마다 생필품 사재기 행렬이 이어지고 있습니다. 폭동 조짐이 보입니다.,화장지 한 팩을 두고 몸싸움이 벌어졌다는 신고가 열 건째입니다.,비상 물자 방출 및 진정,,0,0,0,0,-40,0,-15,0,0,0,질서 유지는 각자 알아서,tag_panic_mob,1,0,2,0,0,0,0,0,0,0,A
43,사회공황,종말론 행진,어떻게 통제하시겠습니까?,종말론에 심취한 신도 수천 명이 본부 앞으로 행진하며 심판을 요구합니다.,그들의 팻말에는 이상하게도 정확한 좌표가 적혀 있었습니다.,전면 계엄 수준의 통제 발동,tag_ruthless,0,1,1,0,-50,-30,0,0,0,0,군중 앞에서 진실을 공개한다,tag_panic_mob,0,1,3,0,0,0,0,0,0,0,S
44,이사회압박,접대비 감사,어떻게 해명하시겠습니까?,감사실에서 지난달 접대비 영수증 몇 장을 문제 삼기 시작했습니다.,법인카드로 산 초밥이 감사 보고서 제목이 됐습니다.,영수증 재발급 후 해명,,0,0,0,0,-15,0,0,0,0,0,바쁘다는 핑계로 무시,,0,0,0,1,0,0,0,0,0,0,C
45,이사회압박,예산 전수조사,어떻게 대비하시겠습니까?,이사회가 예산 집행 내역 전수 조사를 예고했습니다.,회계팀 막내가 벌써부터 사직서 초안을 쓰고 있습니다.,회계 자료 사전 정리,,0,0,0,0,0,-25,0,0,0,0,조사에 순순히 응한다,,0,0,0,2,0,0,0,0,0,0,B
46,이사회압박,결재권 정지 논의,어떻게 방어하시겠습니까?,긴급 임시 이사회가 소집되어 국장의 결재권 일부 정지를 논의합니다.,의제 제목이 국장의 독단 경영에 대하여입니다.,핵심 임원 개별 설득,tag_corruption,0,0,0,0,-40,0,-15,0,0,0,정면으로 맞선다,,1,0,0,2,0,0,0,0,0,0,A
47,이사회압박,청문회 소환,어떻게 대응하시겠습니까?,청문회 소환장이 도착했습니다. 결재권 자체를 박탈하겠다는 초강경 안건입니다.,출석하지 않으면 그 자체로 유죄로 간주하겠다는 문구가 적혀 있습니다.,총력을 다해 청문회 방어,,0,0,0,1,-60,-30,-20,0,0,0,청문회를 보이콧한다,tag_political_pressure,1,1,0,3,0,0,0,0,0,0,S
1 id type keyword question text flavor left_text left_tag left_res left_ent left_pan left_rsk left_bud left_per left_inf left_wea left_for left_sur right_text right_tag right_res right_ent right_pan right_rsk right_bud right_per right_inf right_wea right_for right_sur grade
2 1 일상 요원들의 시위 발생 시위를 어떻게 처리하시겠습니까? B구역 하급 요원들이 예산 삭감에 반발해 장비 반납 시위를 하고 있습니다. 우리가 세상을 구하는데 월급이 깎인다고요? 촉수에 맞아 죽으나 굶어 죽으나 똑같습니다! 무력 진압 tag_ruthless 1 0 0 0 0 -10 0 0 0 0 요구 수용 0 0 0 0 -20 0 0 0 0 0
3 2 위기 격리실 오염 발생 오염을 어떻게 정화하시겠습니까? 지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다. 보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다. 네이팜 소각 강행 tag_coverup 0 0 0 0 0 -30 0 0 0 0 조사팀 파견 0 1 0 1 0 0 0 0 0 0
4 3 일상 촉수 괴물 출현 출동 승인하시겠습니까? 7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다. 규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다. 내역 위조 후 출동 0 0 0 1 0 0 -10 0 0 0 규정대로 반려 1 1 0 0 0 0 0 0 0 0
5 4 극비 변이 DNA 이식 제안 실험을 승인하시겠습니까? 진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠. 인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠. 실험 승인 tag_madness 0 2 0 0 40 40 0 0 0 0 제안 거절 1 0 0 0 0 0 0 0 0 0
6 5 작전 전술 폭격 건의 폭격을 승인하시겠습니까? 철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다. 희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠. 궤도 폭격 승인 tag_sacrifice 0 0 0 2 0 0 0 0 2 0 작전 기각 1 0 0 0 0 0 0 0 0 0
7 6 일상 부산물 암시장 매각 매각을 허가하시겠습니까? 차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다. 이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠. 매각 허가 tag_corruption 0 0 0 1 30 0 0 0 0 0 원칙대로 소각 0 0 0 0 -10 0 0 0 0 0
8 7 작전 존재 양지화 프로젝트 프로젝트를 승인하시겠습니까? 대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다. 우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다. 감시망 구축 tag_coverup 0 0 0 0 0 0 -40 0 0 2 프로젝트 보류 1 0 0 0 0 0 0 0 0 0
9 8 극비 대규모 도서관 건립 예산을 배정하시겠습니까? 고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다. 이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다. 예산 배정 0 0 0 0 -40 0 40 0 0 0 예산 전액 삭감 1 0 0 1 0 0 0 0 0 0
10 9 위기 이단 색출 마녀사냥 어떻게 개입하시겠습니까? 기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다. 더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로! 적당히 묵인한다 1 0 0 0 0 -30 0 0 0 0 군사력으로 제압 0 0 0 1 0 0 -20 0 0 0
11 10 일상 변칙 현상 영상 유출 어떻게 처리하시겠습니까? 한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다. 대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오. 제거 절차 승인 tag_coverup 0 0 0 -1 0 0 -20 0 0 0 영상만 삭제 0 1 0 0 0 0 0 0 0 0
12 11 위기 사이비 종교 인신 공양 어떻게 대응하시겠습니까? 특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다. 위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다. 군사 개입 지시 1 0 1 0 0 -40 0 0 0 0 의식 방관 tag_cult 0 2 0 0 0 0 0 0 0 0
13 12 위기 의회의 긴급 감사 어떻게 대처하시겠습니까? 의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다! 세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오. 은폐 공작 실행 tag_corruption 0 0 0 1 0 0 -40 0 0 0 횡령 사실 인정 0 0 0 2 0 0 0 0 0 0
14 13 일상 연구 부서 파업 조짐 요구를 수용하시겠습니까? 연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다. 저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요. 특별 보너스 지급 -1 0 0 0 -30 0 0 0 0 0 요구 전면 묵살 1 0 0 0 0 0 0 0 0 0
15 14 작전 이계 무역로 개방 무역로를 개방하시겠습니까? 이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다. 저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠. 게이트 강제 개방 tag_alien_contact 0 1 0 0 50 0 0 0 0 0 폐쇄 상태 유지 1 0 0 0 0 0 0 0 0 0
16 15 극비 전임 국장 비밀 금고 금고를 개방하시겠습니까? 전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다. 전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다. 강제 개방 지시 tag_corruption 0 0 0 0 20 -30 0 1 0 0 영구 봉인 0 0 0 0 0 0 0 0 0 0
17 16 작전 메가코프 협박 작전 작전을 실행하시겠습니까? 글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다. 그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까. 작전 실행 0 0 0 0 0 0 -30 2 0 0 작전 취소 1 0 0 0 0 0 0 0 0 0
18 17 위기 전술 폭격 건의 폭격을 승인하시겠습니까? 제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요? 시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다. 시민 포기 및 폭격 tag_ruthless 0 -1 0 2 0 0 0 0 0 0 결사 방어 명령 0 0 0 0 -40 -40 0 0 0 0
19 18 일상 행정부 파업 예고 어떻게 대응하시겠습니까? 새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다. 기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다. 예산 전용으로 무마 0 0 0 1 -20 0 0 0 0 0 파업 강경 대응 1 0 0 0 0 0 0 0 0 0
20 19 극비 불침번 요원 개발 배치하시겠습니까? 비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다. 잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠. 실전 즉각 배치 0 1 0 0 0 0 20 0 1 0 즉각 폐기 처분 1 0 0 0 -10 0 0 0 0 0
21 20 일상 내부 스파이 첩보 어떻게 조사하시겠습니까? 기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다. 누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다. 대규모 사찰 시작 tag_political_pressure 2 0 0 0 0 0 30 0 0 0 증거 부족으로 덮음 0 0 0 1 0 0 0 0 0 0
22 21 작전 비정상적 위기 발생 국장님 결단이 필요합니다 순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다. 괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다. 수용소 건립 승인 tag_ruthless 0 0 0 0 -40 0 0 0 1 0 제안 단호히 거절 1 0 0 1 0 0 0 0 0 0
23 22 극비 이계의 유물 인양 유물을 어떻게 처리하시겠습니까? 고대 공존당의 사제들이 심해의 유물을 인양했습니다. 만지는 자의 정신을 갉아먹지만 막대한 지식을 줍니다. 약간의 두통만 참으면 이계의 비밀을 모두 알 수 있습니다. 요원 하나쯤 미쳐도 괜찮습니다. 유물 집중 연구 tag_alien_contact 0 1 0 0 0 0 50 0 0 0 유물 파괴 명령 1 0 0 1 0 0 0 0 0 0
24 23 위기 이계의 주시자 출현 대중을 어떻게 통제하시겠습니까? 얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다. 하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오. 인터넷 전면 차단 2 0 1 0 0 0 0 0 0 2 진정제 무상 배포 0 0 0 0 -50 0 0 0 0 0
25 24 일상 요원 집단 트라우마 어떻게 지원하시겠습니까? 요원들이 괴수 진압 후 심각한 트라우마에 시달리고 있습니다. 심리 치료 지원이 시급합니다. 밤마다 촉수가 목을 조르는 악몽을 꾼답니다. 차라리 그 부분 기억을 지워버릴까요? 정신과 치료 지원 0 0 0 0 -20 10 0 0 0 0 트라우마 기억 소거 0 1 0 1 0 0 0 0 0 0
26 25 극비 내부 고발자 발생 어떻게 막으시겠습니까? 의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다. 국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다. 사고사 위장 지시 tag_coverup 0 0 0 -1 0 0 -30 0 0 0 폭로전 정면 돌파 0 0 0 2 0 0 0 0 0 0
27 26 위기 차원 붕괴 폭탄 오작동 어떻게 대응하시겠습니까? 차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다. 최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다. 특수 해체팀 투입 tag_sacrifice 0 0 0 0 0 -40 0 0 0 0 폭발 방관 및 대피 0 2 0 0 0 0 0 0 0 0
28 27 작전 PMC 흡수 합병 결탁하시겠습니까? 군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다. 돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠. 결탁 및 흡수 합병 0 0 0 0 -40 0 0 0 2 0 독자적 노선 유지 1 0 0 0 0 0 0 0 0 0
29 28 작전 대규모 거주 구역 실험장화 제안을 승인하시겠습니까? 변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다. 시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠. 돔 건설 강행 0 1 0 0 -50 0 0 0 0 0 윤리적 이유로 기각 0 0 0 1 0 0 0 0 0 0
30 29 극비 이계 군주의 외교 제안을 수락하시겠습니까? 이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다. 요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다. 거래 즉시 성사 tag_alien_contact 0 0 0 0 0 -40 0 0 2 0 사절단 전원 사살 0 1 0 1 0 0 0 0 0 0
31 30 일상 믹스 커피 재고 고갈 어떻게 해결하시겠습니까? 국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다. 종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까? 최고급 원두 지원 -1 0 0 0 -10 0 0 0 0 0 헛소리 금지령 1 0 0 0 0 0 0 0 0 0
32 31 위기 신화적 존재 강림 어떻게 대적하시겠습니까? 신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다! 최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까? 현실 조작기 가동 tag_madness 0 2 0 0 0 0 -50 0 0 0 모든 예비병력 투입 tag_sacrifice 0 0 0 1 0 -60 0 0 0 0
33 32 신화 실험체의 노래 어떻게 조치하시겠습니까? 격리된 실험체 하나가 벽을 긁으며 알아들을 수 없는 노래를 흥얼거립니다. 직원들 사이에서 그 노래가 귓가에 맴돈다는 소문이 돕니다. 진정제 투여 및 격리 강화 0 0 0 0 -15 0 0 0 0 0 그냥 무시하고 넘어간다 0 1 0 0 0 0 0 0 0 0 C
34 33 신화 움직이는 조각상 어떻게 처리하시겠습니까? 지하 수조에서 건져올린 조각상이 자정마다 미세하게 위치를 바꿉니다. CCTV 확인 결과 아무도 없었는데 조각상은 분명 돌아서 있었습니다. 석관에 봉인 후 콘크리트 매립 0 0 0 0 -30 -10 0 0 0 0 일단 창고에 방치 0 1 1 0 0 0 0 0 0 0 B
35 34 신화 심해의 알 부화를 막으시겠습니까? 심해 탐사팀이 인양한 알 하나가 부화 징후를 보입니다. 안에서 무언가 박동합니다. 생물학팀은 저것은 알이 아니라 심장이라고 보고했습니다. 즉각 냉동 처리 및 소각 0 0 0 0 -50 -20 0 0 0 0 부화 과정 관찰 지속 tag_alien_contact 0 2 1 0 0 0 0 0 0 0 A
36 35 신화 장막 너머의 이름 이름을 들으시겠습니까? 장막 너머의 존재가 마침내 자신의 진짜 이름을 발음하기 시작했습니다. 듣는 요원들의 코피가 멈추지 않습니다. 언어병리학자는 이름의 세 음절만 듣고도 실어증에 걸렸습니다. 전 요원 청각 차단 및 격리 봉쇄 tag_madness 0 1 0 0 -70 -40 0 0 0 0 이름을 끝까지 듣고 기록한다 tag_alien_contact 0 3 2 0 0 0 0 0 0 0 S
37 36 조직저항 초과근무 항의 어떻게 무마하시겠습니까? 현장 요원 셋이 초과근무수당 미지급에 항의하며 보고서 제출을 하루 미뤘습니다. 정시 퇴근이 인권이라는 팻말을 봤습니다. 밀린 수당 즉시 지급 0 0 0 0 -15 0 0 0 0 0 규정 운운하며 묵살 1 0 0 0 0 0 0 0 0 0 C
38 37 조직저항 항명 성명 어떻게 대응하시겠습니까? 중간관리자 다섯이 연명으로 국장의 승인 절차에 대한 항명 성명을 냈습니다. 서명자 명단에 낯익은 이름이 보입니다. 승진 대상자였는데요. 일대일 면담으로 무마 0 0 0 0 0 -25 0 0 0 0 주동자 색출 및 좌천 2 0 0 0 0 0 0 0 0 0 B
39 38 조직저항 경비 태업 어떻게 수습하시겠습니까? 격리동 경비 인력 절반이 동시에 병가를 제출했습니다. 명백한 태업입니다. 다들 같은 날 같은 감기에 걸렸다고 합니다. 참으로 공교롭죠. 대체 인력 긴급 투입 0 0 0 0 -30 -15 0 0 0 0 복귀할 때까지 방치 tag_coup 2 0 1 0 0 0 0 0 0 0 A
40 39 조직저항 대규모 사표 어떻게 진압하시겠습니까? 전 부서장급 열두 명이 동시 사표를 제출하며 내부 기밀을 언론에 흘리겠다 협박합니다. 이것은 항명이 아니라 쿠데타입니다. 전원 요구 수용 및 대규모 숙청 tag_ruthless 1 0 0 0 -60 -50 0 0 0 0 협박을 무시하고 강경 대응 tag_coup 3 1 0 0 0 0 0 0 0 0 S
41 40 사회공황 괴이한 목격담 어떻게 진화하시겠습니까? 동네 커뮤니티에 하늘에서 이상한 빛을 봤다는 글이 올라와 조회수가 치솟고 있습니다. 댓글 반응은 반반입니다. 반은 조작설 반은 목격담 추가. 여론 조작팀 투입해 진화 0 0 0 0 0 0 -15 0 0 0 며칠 지나면 묻힐 겁니다 0 0 1 0 0 0 0 0 0 0 C
42 41 사회공황 특집 뉴스 보도 어떻게 대응하시겠습니까? 한 지역 방송국이 괴수 목격담을 특집 뉴스로 다뤘습니다. 시청률이 세 배 뛰었습니다. 미스터리 특종이라는 자막이 하루 종일 반복 재생됩니다. 방송국 인수 후 정정 보도 0 0 0 0 -30 0 0 0 0 0 허위 정보로 규정하고 방치 0 0 2 0 0 0 0 0 0 0 B
43 42 사회공황 생필품 사재기 어떻게 진정시키시겠습니까? 대형 마트마다 생필품 사재기 행렬이 이어지고 있습니다. 폭동 조짐이 보입니다. 화장지 한 팩을 두고 몸싸움이 벌어졌다는 신고가 열 건째입니다. 비상 물자 방출 및 진정 0 0 0 0 -40 0 -15 0 0 0 질서 유지는 각자 알아서 tag_panic_mob 1 0 2 0 0 0 0 0 0 0 A
44 43 사회공황 종말론 행진 어떻게 통제하시겠습니까? 종말론에 심취한 신도 수천 명이 본부 앞으로 행진하며 심판을 요구합니다. 그들의 팻말에는 이상하게도 정확한 좌표가 적혀 있었습니다. 전면 계엄 수준의 통제 발동 tag_ruthless 0 1 1 0 -50 -30 0 0 0 0 군중 앞에서 진실을 공개한다 tag_panic_mob 0 1 3 0 0 0 0 0 0 0 S
45 44 이사회압박 접대비 감사 어떻게 해명하시겠습니까? 감사실에서 지난달 접대비 영수증 몇 장을 문제 삼기 시작했습니다. 법인카드로 산 초밥이 감사 보고서 제목이 됐습니다. 영수증 재발급 후 해명 0 0 0 0 -15 0 0 0 0 0 바쁘다는 핑계로 무시 0 0 0 1 0 0 0 0 0 0 C
46 45 이사회압박 예산 전수조사 어떻게 대비하시겠습니까? 이사회가 예산 집행 내역 전수 조사를 예고했습니다. 회계팀 막내가 벌써부터 사직서 초안을 쓰고 있습니다. 회계 자료 사전 정리 0 0 0 0 0 -25 0 0 0 0 조사에 순순히 응한다 0 0 0 2 0 0 0 0 0 0 B
47 46 이사회압박 결재권 정지 논의 어떻게 방어하시겠습니까? 긴급 임시 이사회가 소집되어 국장의 결재권 일부 정지를 논의합니다. 의제 제목이 국장의 독단 경영에 대하여입니다. 핵심 임원 개별 설득 tag_corruption 0 0 0 0 -40 0 -15 0 0 0 정면으로 맞선다 1 0 0 2 0 0 0 0 0 0 A
48 47 이사회압박 청문회 소환 어떻게 대응하시겠습니까? 청문회 소환장이 도착했습니다. 결재권 자체를 박탈하겠다는 초강경 안건입니다. 출석하지 않으면 그 자체로 유죄로 간주하겠다는 문구가 적혀 있습니다. 총력을 다해 청문회 방어 0 0 0 1 -60 -30 -20 0 0 0 청문회를 보이콧한다 tag_political_pressure 1 1 0 3 0 0 0 0 0 0 S
+123
View File
@@ -0,0 +1,123 @@
// 멀티 엔딩 판별기 (Phase 4)
// 우선순위: 의회 정치 엔딩 > 내러티브 연쇄 엔딩 > 변주된 붕괴 엔딩 > 기본 붕괴 엔딩
// 의회 수장 성향 축 — 상임 이사회 8대 이념(§4.1) 중 기존 자원 태그로 매핑 가능한 3개 축
export const CHAIRMAN_AXES = ['force', 'wealth', 'surveillance'];
// 내러티브 연쇄 엔딩: 4종 오염 카드가 이 순서로(연속일 필요는 없음) 한 번씩 등장해야 완성된다
const NARRATIVE_CHAIN_SEQUENCE = ['신화', '조직저항', '사회공황', '이사회압박'];
export const isNarrativeChainComplete = (sequence) => {
let i = 0;
for (const type of sequence) {
if (type === NARRATIVE_CHAIN_SEQUENCE[i]) i++;
if (i === NARRATIVE_CHAIN_SEQUENCE.length) return true;
}
return false;
};
const PARAM_LABEL = { resistance: '내부 저항', entropy: '엔트로피', panic: '대중 공황' };
export const ENDING_CATALOG = [
{ id: 'bankruptcy', title: '파산 문책 (조용한 해임)',
desc: '텅 빈 금고와 산더미 같은 미결 청구서 앞에서 이사회는 더 이상 웃지 않았습니다. 세상이 끝나기도 전에, 당신의 자리가 먼저 끝났습니다.',
reason: '연속된 재정 파탄 경고를 개선하지 못함' },
{ id: 'council_victory', title: '의회 정치 엔딩 (그림자 뒤의 승리)',
desc: '탄핵안은 가결되었지만, 이사회 의장은 이미 당신이 쌓아온 힘 앞에 고개를 숙였습니다. 해임장에 서명하는 손이 떨리고 있었습니다. 자리는 잃었지만, 진짜 권력은 잃지 않았습니다.',
reason: '탄핵 가결 + 의회 의장 성향 조건 충족' },
{ id: 'narrative_chain', title: '내러티브 연쇄 엔딩 (진실의 연쇄)',
desc: '신화, 조직의 균열, 대중의 광기, 권력의 사슬 — 당신은 이 모든 사건이 하나의 손에서 비롯되었다는 진실을 끝내 마주했습니다. 아무도 믿지 않겠지만, 적어도 당신만은 알고 있습니다.',
reason: '신화 → 조직저항 → 사회공황 → 이사회압박 순서로 내러티브 사건 수집' },
{ id: 'impeachment', title: '정치적 탄핵 (윗선 붕괴)',
desc: '그림자 위원회가 당신의 권한을 박탈했습니다. 당신의 모든 업적은 지워질 것입니다.',
reason: '주간 의회에서 적대적 파벌 임원 9명 이상 찬성' },
{ id: 'resistance_base', title: '사임 (내부 붕괴)',
desc: '조직원들의 불만이 폭발하여 폭동이 일어났습니다. 당신은 자리에서 끌려 내려왔습니다.',
reason: '내부 저항 파라미터 5단계 도달' },
{ id: 'resistance_coup', title: '군사 쿠데타 (내부 붕괴)',
desc: '무력을 손에 쥔 자들은 더 이상 결재를 기다리지 않았습니다. 총구가 당신의 사무실을 겨눴습니다.',
reason: '내부 저항 5단계 + 무력 태그 다량 보유' },
{ id: 'resistance_flee', title: '야반도주 (내부 붕괴)',
desc: '텅 빈 급여 계좌를 확인한 요원들이 하나둘 사라졌습니다. 마지막까지 남은 건 당신과 꺼진 사무실 불빛뿐이었습니다.',
reason: '내부 저항 5단계 + 인력 토큰 심각한 마이너스' },
{ id: 'entropy_base', title: '세계 멸망 (외부 붕괴)',
desc: '장막이 찢어지고 이계의 존재들이 쏟아져 들어옵니다. 우리가 알던 세계는 끝났습니다.',
reason: '엔트로피 파라미터 5단계 도달' },
{ id: 'entropy_coverup', title: '은폐된 멸망 (외부 붕괴)',
desc: '언론과 감시망을 총동원해 진실을 덮었지만, 덮은 것은 진실이 아니라 탈출할 시간이었습니다. 세계는 아무도 모르게 끝났습니다.',
reason: '엔트로피 5단계 + 감시 태그 다량 보유' },
{ id: 'entropy_blackout', title: '정보 암흑 (외부 붕괴)',
desc: '정보망이 완전히 말라붙은 순간, 당신은 세계가 무너지는 소리조차 듣지 못했습니다.',
reason: '엔트로피 5단계 + 정보 토큰 심각한 마이너스' },
{ id: 'panic_base', title: '문명 붕괴 (사회 붕괴)',
desc: '대중은 광기에 휩싸였고, 문명은 스스로를 파괴했습니다. 통제 불능의 시대가 도래했습니다.',
reason: '대중 공황 파라미터 5단계 도달' },
{ id: 'panic_capitalism', title: '자본의 폭주 (사회 붕괴)',
desc: '재난 앞에서도 시장은 웃었습니다. 부가 쌓일수록 거리는 비어갔고, 마지막엔 돈을 셀 사람조차 남지 않았습니다.',
reason: '대중 공황 5단계 + 재력 태그 다량 보유' },
{ id: 'panic_riot', title: '긴축의 폭동 (사회 붕괴)',
desc: '텅 빈 금고를 본 대중은 더 이상 설명을 기다리지 않았습니다.',
reason: '대중 공황 5단계 + 예산 토큰 심각한 마이너스' },
{ id: 'sudden_collapse', title: '돌이킬 수 없는 순간 (즉각 붕괴)',
desc: '망설임 없는 결정 한 번이 모든 단계를 건너뛰었습니다. 경고도, 예고도 없이 — 그저 끝났습니다.',
reason: '단일 결정으로 파라미터가 급격히 5단계에 도달' },
{ id: 'term_expired', title: '임기 만료 (조용한 퇴장)',
desc: '붕괴도, 탄핵도 없었습니다. 그저 라운드가 거듭될수록 결재할 시간이 줄어들었고, 결국 정례회의 상정 건수가 바닥났습니다. 세상은 여전히 위태롭지만, 당신의 임기만은 조용히 끝났습니다.',
reason: '라운드가 거듭되며 정례회의까지의 결재 건수가 0이 됨' },
{ id: 'unidentified', title: '미확인 종말',
desc: '기록되지 않은 파멸입니다.',
reason: '알 수 없는 원인' },
];
const byId = Object.fromEntries(ENDING_CATALOG.map((e) => [e.id, e]));
export const getEndingById = (id) => byId[id] || byId.unidentified;
const VARIANT_MAP = {
resistance: [
{ id: 'resistance_coup', condition: (c) => c.tags.force >= 3 },
{ id: 'resistance_flee', condition: (c) => c.tokens.personnel <= -50 },
],
entropy: [
{ id: 'entropy_coverup', condition: (c) => c.tags.surveillance >= 3 },
{ id: 'entropy_blackout', condition: (c) => c.tokens.info <= -50 },
],
panic: [
{ id: 'panic_capitalism', condition: (c) => c.tags.wealth >= 3 },
{ id: 'panic_riot', condition: (c) => c.tokens.budget <= -50 },
],
};
const BASE_MAP = { resistance: 'resistance_base', entropy: 'entropy_base', panic: 'panic_base' };
// ctx: { trigger: 'impeachment' | 'collapse', params, tags, tokens, lastDelta, chairmanAxis, narrativeChainUnlocked }
export function resolveEnding(ctx) {
if (ctx.trigger === 'impeachment') {
if (ctx.chairmanAxis && ctx.tags[ctx.chairmanAxis] >= 5) return byId.council_victory;
if (ctx.narrativeChainUnlocked) return byId.narrative_chain;
return byId.impeachment;
}
if (ctx.narrativeChainUnlocked) return byId.narrative_chain;
const param = ['resistance', 'entropy', 'panic'].find((p) => ctx.params[p] >= 5);
if (!param) return byId.unidentified;
if (ctx.lastDelta && ctx.lastDelta.param === param && ctx.lastDelta.amount >= 3) {
return { ...byId.sudden_collapse, reason: `${PARAM_LABEL[param]} 단일 결정으로 급격히 5단계 도달` };
}
const variant = (VARIANT_MAP[param] || []).find((v) => v.condition(ctx));
if (variant) return byId[variant.id];
return byId[BASE_MAP[param]];
}
+82
View File
@@ -0,0 +1,82 @@
[
{
"id": "tag_ruthless",
"name": "피도 눈물도 없는 진압",
"icon": "🩸",
"description": "반발을 무력으로 짓밟았습니다. 공포는 훌륭한 통제 수단입니다.",
"snippet": "진압 대응 인력 214명 투입. 민간인 부상 신고는 '작전 지연 방지' 사유로 접수 목록에서 삭제 처리됨.",
"snippet_type": "system"
},
{
"id": "tag_coverup",
"name": "진실의 장막",
"icon": "🤫",
"description": "대중이 알지 못하도록 변칙 사건을 완벽하게 은폐했습니다.",
"snippet": "변칙 목격 신고 63건, 언론 자문위 검토 후 '기상 이상 현상'으로 통합 발표 승인됨.",
"snippet_type": "system"
},
{
"id": "tag_alien_contact",
"name": "금지된 지식",
"icon": "👁️",
"description": "이계의 존재와 거래하거나 그들의 유물을 받아들였습니다. 장막 너머의 눈이 당신을 주시합니다.",
"snippet": "압수된 이계 유물 1점, 등급 미분류 상태로 특수연구소 이관됨.",
"snippet_type": "system"
},
{
"id": "tag_corruption",
"name": "검은 뒷돈",
"icon": "💼",
"description": "공적인 자산을 사적으로 유용하거나 부당한 거래를 승인했습니다.",
"snippet": "결산 불일치 340만 원, 담당 경리 개인 계좌 이체로 충당된 정황 확인. 사유 불문 처리됨.",
"snippet_type": "human"
},
{
"id": "tag_madness",
"name": "이성의 붕괴",
"icon": "🌀",
"description": "요원들을 비인도적인 실험과 광기에 노출시켰습니다. 인간성은 점점 희미해집니다.",
"snippet": "실험 참여 요원, 심리 평가 미제출 24개월째로 현장 배치 유지 확인. 재평가 요청 반려됨.",
"snippet_type": "human"
},
{
"id": "tag_cult",
"name": "사이비의 그림자",
"icon": "🌑",
"description": "고대 신을 숭배하는 종교와 엮이기 시작했습니다. 그들의 교리가 스며듭니다.",
"snippet": "신흥 종교단체 신도 수 3,400명 규모로 집계. 포교 활동 규제 안건은 종교자유 조항 검토 사유로 반려됨.",
"snippet_type": "system"
},
{
"id": "tag_coup",
"name": "쿠데타의 그림자",
"icon": "🗡️",
"description": "권력자들이 등을 돌리기 시작했습니다. 칼끝이 서서히 당신을 향하고 있습니다.",
"snippet": "국장 신임 재투표 요구 서명 9명분 접수. 인사위원회 소집 안건은 '정족수 미달'로 미반영됨.",
"snippet_type": "system"
},
{
"id": "tag_panic_mob",
"name": "군중의 광기",
"icon": "😱",
"description": "이성을 잃은 대중이 거리를 뒤덮었습니다. 더 이상 통제할 수 없습니다.",
"snippet": "자경단 관련 신고 118건, 담당 부서 인력 부족을 사유로 전건 내부 종결됨.",
"snippet_type": "system"
},
{
"id": "tag_political_pressure",
"name": "정치적 압박",
"icon": "⚖️",
"description": "이사회가 당신의 자리를 노리고 있습니다. 숫자 하나하나가 무기가 됩니다.",
"snippet": "이사회 특별 감사 요청 안건 5건 상정. 국장 관련 항목은 '자료 미비'로 반려됨.",
"snippet_type": "system"
},
{
"id": "tag_sacrifice",
"name": "희생의 대가",
"icon": "⚰️",
"description": "누군가의 목숨과 맞바꾼 결정이었습니다. 그 무게는 사라지지 않습니다.",
"snippet": "현장 요원 1명, 격리 프로토콜을 위반해 통로를 개방한 뒤 실종 처리됨.",
"snippet_type": "human"
}
]
+34
View File
@@ -0,0 +1,34 @@
[
{
"id": "poll_1",
"type": "오염",
"keyword": "장막 틈새의 오염",
"question": "알 수 없는 속삭임이 들립니다...",
"text": "사무실 벽에 기괴한 기하학적 무늬가 자라나고 있습니다. 쳐다보기만 해도 구역질이 납니다.",
"flavor": "\"저건 분명히 내 업무 일지보다 복잡한 구조야.\"",
"left": {
"text": "방치한다",
"stats": { "ent": 1, "pan": 1, "rsk": 1 }
},
"right": {
"text": "벽을 태운다",
"stats": { "bud": -20, "res": 1 }
}
},
{
"id": "poll_2",
"type": "오염",
"keyword": "부패하는 자원",
"question": "금고에서 악취가 납니다...",
"text": "예산으로 배정된 금괴들이 살덩어리로 변했습니다. 이대로는 결재에 사용할 수 없습니다.",
"flavor": "\"음, 적어도 인력 갈아넣는 것보단 싸게 먹히겠군.\"",
"left": {
"text": "폐기 처분",
"stats": { "bud": -50 }
},
"right": {
"text": "억지로 사용",
"stats": { "ent": 2, "rsk": 2 }
}
}
]
+226
View File
@@ -0,0 +1,226 @@
// 자산(엔진) 시스템 — 트리거 → 효과 → (선택) 성장 이벤트 엔진 (그레이박스 v1)
// docs/작업지시서/자산시스템_작업지시서.md 기준.
//
// GameScreen은 매 훅 지점(결재, 오염 드로우, 좌 스와이프, 의회 종료)마다 현재 상태로
// world 스냅샷을 만들어 이 모듈의 함수에 넘기고, 반환된 world를 다시 React state로 반영한다.
// world = { cards, tokens, tags, params, owned, quarantine, logs, purgeGain }
import assetDefs from '../data/assets.json';
const byId = Object.fromEntries(assetDefs.map((a) => [a.id, a]));
export const ASSET_CATALOG = assetDefs;
export const getAssetDef = (id) => byId[id];
export const ARCHETYPE_META = {
purge: { icon: '🔥', label: '소각' },
convert: { icon: '💱', label: '환전' },
quarantine: { icon: '⏳', label: '격리' },
skip: { icon: '🛡️', label: '회피' },
};
const MAX_CHAIN_DEPTH = 10;
const GRADE_RANK = { C: 0, B: 1, A: 2, S: 3 };
const TOKEN_LABEL = { budget: '예산', personnel: '인력', info: '정보' };
const TAG_LABEL = { wealth: '재력', force: '무력', surveillance: '감시' };
export const createAssetInstance = (defId, turns) => ({
defId,
stack: 0,
charge: 0,
lastChargeTurn: turns,
acquiredTurn: turns,
});
const log = (world, text) => world.logs.push(text);
const hasAsset = (world, defId) => world.owned.some((o) => o.defId === defId);
const lowestGradeContaminatedIndex = (cards) => {
let best = -1;
let bestRank = Infinity;
cards.forEach((c, i) => {
if (!c.contaminated) return;
const r = GRADE_RANK[c.grade] ?? 0;
if (r < bestRank) { bestRank = r; best = i; }
});
return best;
};
// 퍼센트 리스크(예: 5%, 3%)는 기존 0~5단계 파라미터 스케일(1단계 = 20%)로 환산한다.
// '1단계' 같이 정수 단위로 명시된 페널티(격리 실패 등)는 그대로 정수로 더한다.
const applyRisk = (world, risk, sourceName) => {
if (!risk) return;
const key = risk.param === 'risk_tolerance' ? 'risk' : risk.param;
const amount = risk.amount / 20;
world.params[key] = (world.params[key] || 0) + amount;
log(world, `『${sourceName}』 부작용 — ${key} +${risk.amount}%`);
};
const convertAmount = (world, amount) => {
const doubled = world.owned.some((o) => getAssetDef(o.defId)?.effect?.type === 'convert_multiplier');
return doubled ? amount * 2 : amount;
};
const doPurge = (world, count, sourceName, depth) => {
for (let i = 0; i < count; i++) {
const idx = lowestGradeContaminatedIndex(world.cards);
if (idx === -1) return;
const [removed] = world.cards.splice(idx, 1);
world.purgeGain = (world.purgeGain || 0) + 1;
log(world, `『${sourceName}』 발동 — [${removed.grade}급 ${removed.type}] 소각됨`);
fireEvent(world, 'on_purge', {}, depth + 1);
}
};
// 결재 카드 스와이프마다 호출: every_n_approvals 자산 발동 + manual_charge 충전 확인
export function onApprovalTick(world, turns) {
world.owned.forEach((inst) => {
const def = getAssetDef(inst.defId);
if (!def) return;
if (def.trigger.type === 'every_n_approvals') {
const n = def.trigger.params.n;
if (turns > 0 && turns % n === 0) {
doPurge(world, def.effect.params.count, def.name, 0);
}
}
if (def.trigger.type === 'manual_charge' && inst.charge < 1) {
const base = inst.lastChargeTurn ?? inst.acquiredTurn ?? 0;
if (turns - base >= def.trigger.params.chargeEvery) {
inst.charge = 1;
log(world, `『${def.name}』 충전 완료 — 슬롯을 눌러 발동할 수 있습니다.`);
}
}
});
return world;
}
// 오염 카드가 드로우(결재 대상으로 등장)될 때 호출. world.negateFree === true면 이번 카드 페널티를 자동 무효화.
export function onPollutionDraw(world, category) {
world.negateFree = false;
world.owned.forEach((inst) => {
const def = getAssetDef(inst.defId);
if (!def || def.trigger.type !== 'on_pollution_draw') return;
if (def.trigger.params.category && def.trigger.params.category !== category) return;
if (def.effect.type === 'convert_to_token') {
const amt = convertAmount(world, def.effect.params.amount);
const tokenType = def.effect.params.tokenType;
world.tokens[tokenType] = (world.tokens[tokenType] || 0) + amt;
log(world, `『${def.name}』 발동 — ${TOKEN_LABEL[tokenType]} +${amt}`);
} else if (def.effect.type === 'negate_penalty') {
world.negateFree = true;
log(world, `『${def.name}』 발동 — 이번 오염 카드 페널티 자동 무효화`);
applyRisk(world, def.risk, def.name);
}
});
return world;
}
// 좌 스와이프 시 호출(cardIndex는 world.cards 내 위치). 반환된 world.quarantinedCard === true면
// 이번 카드는 정상 결과 대신 격리 처리되어 world.cards에서 이미 제거된 상태다.
export function onSwipeLeft(world, cardIndex, turns) {
world.quarantinedCard = false;
const card = world.cards[cardIndex];
if (!card || !card.contaminated) return world;
const inst = world.owned.find((o) => o.defId === 'asset_quarantine_001');
if (!inst) return world;
const def = getAssetDef('asset_quarantine_001');
world.cards.splice(cardIndex, 1);
world.quarantine.push({ ...card, returnTurn: turns + 5, viaAsset: def.id, failChecked: false });
log(world, `『${def.name}』 발동 — [${card.grade}급 ${card.type}] 격리됨 (다음 의회 전까지 미회수 시 실패)`);
world.quarantinedCard = true;
return world;
}
// 의회 종료 시 호출: 상시 자산 리스크 정산 + 격리 실패 판정(+심연 보관 조례 개입)
export function onCouncilEnd(world) {
world.owned.forEach((inst) => {
const def = getAssetDef(inst.defId);
if (def?.risk?.when === 'council_end') applyRisk(world, def.risk, def.name);
});
const pending = world.quarantine.filter((c) => c.viaAsset === 'asset_quarantine_001' && !c.failChecked);
pending.forEach((card) => {
card.failChecked = true;
if (hasAsset(world, 'asset_quarantine_002')) {
world.quarantine = world.quarantine.filter((c) => c !== card);
const def6 = getAssetDef('asset_quarantine_002');
world.tags.surveillance = (world.tags.surveillance || 0) + 1;
world.purgeGain = (world.purgeGain || 0) + 1;
log(world, `『${def6.name}』 발동 — 격리 실패한 [${card.grade}급 ${card.type}]를 영구 소각하고 감시 태그 획득`);
fireEvent(world, 'on_purge', {}, 1);
} else {
world.params.panic = (world.params.panic || 0) + 1;
log(world, `격리 실패 — [${card.grade}급 ${card.type}] 처리 지연으로 대중 공황 1단계 상승`);
}
});
return world;
}
// 충전된 manual_charge 자산을 수동 발동. 성공 시 world.negateChargeGranted === true.
export function activateManualAsset(world, defId, turns) {
world.negateChargeGranted = false;
const inst = world.owned.find((o) => o.defId === defId);
const def = getAssetDef(defId);
if (!inst || !def || inst.charge < 1) return world;
inst.charge = 0;
inst.lastChargeTurn = turns;
world.negateChargeGranted = true;
log(world, `『${def.name}』 발동 — 다음 오염 카드 페널티 무효화 충전을 사용했습니다.`);
return world;
}
// on_purge / on_convert 등 연쇄 이벤트 디스패치. depth는 무한 루프 방지용 상한(10)까지.
function fireEvent(world, eventType, payload, depth) {
if (depth > MAX_CHAIN_DEPTH) {
log(world, '⚠️ 연쇄 발동이 상한(10회)에 도달해 중단되었습니다.');
return world;
}
world.owned.forEach((inst) => {
const def = getAssetDef(inst.defId);
if (!def || def.trigger.type !== eventType) return;
if (def.growth && def.growth.stackOn === eventType) {
inst.stack = (inst.stack || 0) + 1;
if (inst.stack % def.growth.per === 0) {
log(world, `『${def.name}』 숙련 ${inst.stack}회 누적 — 추가 발동`);
if (def.growth.bonus.effect === 'purge_lowest') {
doPurge(world, def.growth.bonus.count, def.name, depth);
}
}
return;
}
if (!def.effect) return;
switch (def.effect.type) {
case 'gain_token': {
const tokenType = def.effect.params.tokenType;
const amt = def.effect.params.amount;
world.tokens[tokenType] = (world.tokens[tokenType] || 0) + amt;
log(world, `『${def.name}』 발동 — ${TOKEN_LABEL[tokenType]} +${amt}`);
break;
}
case 'gain_tag': {
const tagType = def.effect.params.tagType;
const amt = def.effect.params.amount;
world.tags[tagType] = (world.tags[tagType] || 0) + amt;
log(world, `『${def.name}』 발동 — ${TAG_LABEL[tagType]} 태그 +${amt}`);
break;
}
default:
// convert_multiplier 등 상시 패시브 효과는 소유 여부만으로 별도 계산부(convertAmount)에서 반영한다.
break;
}
});
return world;
}
export { fireEvent };
+154
View File
@@ -0,0 +1,154 @@
// 오염 카드 주입 엔진 — 결재 선택(스와이프)과 정례회의 종료 시 덱에 오염 카드를 밀어 넣는다.
//
// 설계 의도(그레이박스 튜닝):
// 좌우 스와이프 외에 능동적 행동이 거의 없는 게임이라, "내 결정이 덱을 더럽힌다"는 압박이
// 위기감의 핵심이다. 그래서 위험 파라미터를 올리는 선택은 정례회의를 기다리지 않고
// 그 자리에서 덱에 오염 카드를 주입한다.
//
// SwipeCard의 사전 예고 · GameScreen의 사후 알림 · 실제 주입은 모두 previewPollution()을
// 단일 기준으로 쓴다. 예고한 숫자와 실제 주입량이 어긋나면 플레이어가 정보를 신뢰하지 않게 되므로,
// 이 함수를 우회해서 오염 카드를 만들지 말 것.
// 오염 카드 타입 <-> 파라미터 매핑 (파산/오염 등급 시스템)
export const CONTAM_TYPE_BY_PARAM = {
entropy: '신화',
resistance: '조직저항',
panic: '사회공황',
risk: '이사회압박',
};
export const CONTAM_TYPES = new Set(Object.values(CONTAM_TYPE_BY_PARAM));
export const CONTAM_TYPE_ICON = {
신화: '🌀',
조직저항: '✊',
사회공황: '😱',
이사회압박: '⚠️',
};
// cards.csv의 위험 스탯 키 -> 파라미터 키 (상단 파라미터 표시 순서와 동일하게 유지)
const DANGER_STAT_TO_PARAM = {
res: 'resistance',
ent: 'entropy',
pan: 'panic',
rsk: 'risk',
};
/* ── 밸런스 튜닝 상수 (오염 물량 조정은 여기만 고치면 된다) ───────────────── */
// 위험 파라미터 +1 당 주입되는 오염 카드 장수.
// cards.csv의 위험 수치는 대부분 +1, 일부 +2, 드물게 +3이다(한 선택지 최대 합계 +5).
export const POLLUTION_PER_DANGER_POINT = 1;
// 한 번의 결재로 주입되는 최대 장수 — 극단적인 카드 한 장이 덱을 통째로 망가뜨리지 않게 하는 상한.
export const MAX_POLLUTION_PER_SWIPE = 3;
// 정례회의 종료 시 파라미터 단계별 주입량 (기존: 단계와 무관하게 무조건 1장).
export const councilPollutionCount = (level) => Math.min(3, Math.ceil(level / 2));
// 주입된 카드가 실제로 등장하기까지 남겨두는 결재 건수 — 즉시 등장하면 예측 가능해지고,
// 너무 멀면 인과가 안 느껴진다.
const INJECT_OFFSET_MIN = 2;
const INJECT_OFFSET_MAX = 4;
// 파라미터 단계(1~5)가 높을수록 높은 등급(S/A) 확률이 커지는 가중치 테이블
const GRADE_WEIGHTS = {
1: { C: 70, B: 22, A: 6, S: 2 },
2: { C: 50, B: 28, A: 15, S: 7 },
3: { C: 32, B: 30, A: 23, S: 15 },
4: { C: 18, B: 24, A: 32, S: 26 },
5: { C: 8, B: 17, A: 33, S: 42 },
};
// 자산 부작용(applyRisk)이 파라미터를 소수점으로 올리므로 반드시 정수 단계로 반올림해서 조회한다.
export const pickGrade = (level) => {
const step = Math.min(5, Math.max(1, Math.round(level || 0)));
const weights = GRADE_WEIGHTS[step];
const total = Object.values(weights).reduce((a, b) => a + b, 0);
let r = Math.random() * total;
for (const [grade, w] of Object.entries(weights)) {
if (r < w) return grade;
r -= w;
}
return 'C';
};
/**
* 이 선택지가 덱에 몇 장의 오염 카드를 주입하는지 미리 계산한다.
* 스와이프 전 예고(SwipeCard)와 실제 주입(GameScreen)이 같은 값을 쓰도록 하는 단일 기준.
* @returns {{ total: number, entries: Array<{param, type, icon, count}> }}
*/
export function previewPollution(stats) {
if (!stats) return { total: 0, entries: [] };
const entries = [];
Object.entries(DANGER_STAT_TO_PARAM).forEach(([statKey, param]) => {
const value = stats[statKey] || 0;
if (value <= 0) return;
const type = CONTAM_TYPE_BY_PARAM[param];
entries.push({ param, type, icon: CONTAM_TYPE_ICON[type], count: value * POLLUTION_PER_DANGER_POINT });
});
// 상한 초과분은 뒤쪽 항목부터 깎는다 (앞 = 저항/엔트로피 쪽을 우선 남긴다)
let total = entries.reduce((sum, e) => sum + e.count, 0);
for (let i = entries.length - 1; i >= 0 && total > MAX_POLLUTION_PER_SWIPE; i--) {
const cut = Math.min(entries[i].count, total - MAX_POLLUTION_PER_SWIPE);
entries[i].count -= cut;
total -= cut;
}
return { total, entries: entries.filter((e) => e.count > 0) };
}
const makePollutionCard = (type, level, templates) => {
const grade = pickGrade(level);
// 등급별 초안이 아직 비어 있는 타입이 있을 수 있으므로 같은 타입 전체로 폴백한다.
const pool = templates.filter((t) => t.type === type && t.grade === grade);
const fallback = pool.length > 0 ? pool : templates.filter((t) => t.type === type);
if (fallback.length === 0) return null;
const template = fallback[Math.floor(Math.random() * fallback.length)];
return { ...template, id: `${template.id}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}` };
};
/**
* previewPollution()의 entries를 실제 카드 인스턴스로 만든다.
* 등급은 "그 파라미터의 현재 단계"로 뽑으므로, 위험이 높을수록 더 흉악한 오염이 들어온다.
*/
export function buildPollutionCards(entries, params, templates) {
const out = [];
entries.forEach(({ param, type, count }) => {
for (let i = 0; i < count; i++) {
const card = makePollutionCard(type, params[param], templates);
if (card) out.push(card);
}
});
return out;
}
/** 정례회의 종료 시 주입분 — 파라미터 단계에 비례한 장수로 뽑는다. */
export function buildCouncilPollutionCards(params, templates) {
const entries = [];
Object.entries(CONTAM_TYPE_BY_PARAM).forEach(([param, type]) => {
const level = params[param] || 0;
if (level < 1) return;
entries.push({ param, type, icon: CONTAM_TYPE_ICON[type], count: councilPollutionCount(level) });
});
return buildPollutionCards(entries, params, templates);
}
/**
* 현재 결재 위치 기준 2~4장 뒤에 오염 카드를 끼워 넣는다.
* 덱은 인덱스를 순환(modulo)하며 도는 풀이므로, currentIdx는 항상 덱 범위 안의 값이어야 한다.
*/
export function insertIntoDeck(deck, currentIdx, newCards) {
const next = [...deck];
newCards.forEach((card) => {
const span = INJECT_OFFSET_MAX - INJECT_OFFSET_MIN + 1;
const offset = INJECT_OFFSET_MIN + Math.floor(Math.random() * span);
next.splice(Math.min(next.length, currentIdx + offset), 0, card);
});
return next;
}
/** 요약 문자열: "🌀 신화 x2 · 😱 사회공황" */
export const describePollution = (entries) =>
entries.map((e) => `${e.icon} ${e.type}${e.count > 1 ? ` x${e.count}` : ''}`).join(' · ');
+49
View File
@@ -0,0 +1,49 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Noto+Sans+KR:wght@400;500;700&family=Noto+Serif+KR:wght@400;700&display=swap');
:root {
--color-bg: #121212;
--color-surface: #1e1e1e;
--color-text: #e0e0e0;
--color-entropy-base: #5C3A21;
--color-entropy-critical: #8B0000;
--color-panic-base: #4A148C;
--color-panic-critical: #9C27B0;
--color-resistance-base: #2F4F4F;
--color-resistance-critical: #00FFCC;
--color-risk-base: #B8860B;
--color-risk-critical: #FFD700;
--color-token-budget: #4CAF50;
--color-token-personnel: #E53935;
--color-token-info: #3F51B5;
--color-tag-neutral: #E0E0E0;
--color-swipe-left: #D32F2F;
--color-swipe-right: #388E3C;
--color-swipe-up: #757575;
--font-doc-title: 'Noto Serif KR', serif;
--font-typewriter: 'IBM Plex Mono', monospace;
--font-ui: 'Noto Sans KR', sans-serif;
}
body {
margin: 0;
padding: 0;
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-ui);
overflow: hidden; /* Prevent scrolling for swipe */
}
#root {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
+54
View File
@@ -0,0 +1,54 @@
import fs from 'fs';
import Papa from 'papaparse';
const csv = fs.readFileSync('./src/data/cards.csv', 'utf-8');
const parsed = Papa.parse(csv, { header: true });
parsed.data.forEach(row => {
if (!row.id) return;
// Auto-generate some basic keyword and question based on text
if (row.text.includes('시위')) { row.keyword = '요원들의 시위 발생'; row.question = '시위를 어떻게 처리하시겠습니까?'; }
else if (row.text.includes('격리실')) { row.keyword = '격리실 오염 발생'; row.question = '오염을 어떻게 정화하시겠습니까?'; }
else if (row.text.includes('촉수')) { row.keyword = '촉수 괴물 출현'; row.question = '출동 승인하시겠습니까?'; }
else if (row.text.includes('DNA')) { row.keyword = '변이 DNA 이식 제안'; row.question = '실험을 승인하시겠습니까?'; }
else if (row.text.includes('폭격')) { row.keyword = '전술 폭격 건의'; row.question = '폭격을 승인하시겠습니까?'; }
else if (row.text.includes('암시장')) { row.keyword = '부산물 암시장 매각'; row.question = '매각을 허가하시겠습니까?'; }
else if (row.text.includes('대중 매체')) { row.keyword = '존재 양지화 프로젝트'; row.question = '프로젝트를 승인하시겠습니까?'; }
else if (row.text.includes('도서관')) { row.keyword = '대규모 도서관 건립'; row.question = '예산을 배정하시겠습니까?'; }
else if (row.text.includes('마녀사냥')) { row.keyword = '이단 색출 마녀사냥'; row.question = '어떻게 개입하시겠습니까?'; }
else if (row.text.includes('유튜버')) { row.keyword = '변칙 현상 영상 유출'; row.question = '어떻게 처리하시겠습니까?'; }
else if (row.text.includes('인신 공양')) { row.keyword = '사이비 종교 인신 공양'; row.question = '어떻게 대응하시겠습니까?'; }
else if (row.text.includes('감사')) { row.keyword = '의회의 긴급 감사'; row.question = '어떻게 대처하시겠습니까?'; }
else if (row.text.includes('초과 근무')) { row.keyword = '연구 부서 파업 조짐'; row.question = '요구를 수용하시겠습니까?'; }
else if (row.text.includes('무역로')) { row.keyword = '이계 무역로 개방'; row.question = '무역로를 개방하시겠습니까?'; }
else if (row.text.includes('비밀 금고')) { row.keyword = '전임 국장 비밀 금고'; row.question = '금고를 개방하시겠습니까?'; }
else if (row.text.includes('메가코프')) { row.keyword = '메가코프 협박 작전'; row.question = '작전을 실행하시겠습니까?'; }
else if (row.text.includes('시민')) { row.keyword = '민간인 구역 붕괴 위기'; row.question = '어떻게 명령하시겠습니까?'; }
else if (row.text.includes('파업')) { row.keyword = '행정부 파업 예고'; row.question = '어떻게 대응하시겠습니까?'; }
else if (row.text.includes('뇌를 결합')) { row.keyword = '불침번 요원 개발'; row.question = '배치하시겠습니까?'; }
else if (row.text.includes('스파이')) { row.keyword = '내부 스파이 첩보'; row.question = '어떻게 조사하시겠습니까?'; }
else if (row.text.includes('수용소')) { row.keyword = '변이 요원 수용소'; row.question = '건립을 승인하시겠습니까?'; }
else if (row.text.includes('유물')) { row.keyword = '이계의 유물 인양'; row.question = '유물을 어떻게 처리하시겠습니까?'; }
else if (row.text.includes('눈동자')) { row.keyword = '이계의 주시자 출현'; row.question = '대중을 어떻게 통제하시겠습니까?'; }
else if (row.text.includes('트라우마')) { row.keyword = '요원 집단 트라우마'; row.question = '어떻게 지원하시겠습니까?'; }
else if (row.text.includes('폭로')) { row.keyword = '내부 고발자 발생'; row.question = '어떻게 막으시겠습니까?'; }
else if (row.text.includes('타이머')) { row.keyword = '차원 붕괴 폭탄 오작동'; row.question = '어떻게 대응하시겠습니까?'; }
else if (row.text.includes('군사 기업')) { row.keyword = 'PMC 흡수 합병'; row.question = '결탁하시겠습니까?'; }
else if (row.text.includes('관찰')) { row.keyword = '대규모 거주 구역 실험장화'; row.question = '제안을 승인하시겠습니까?'; }
else if (row.text.includes('이계의 군주')) { row.keyword = '이계 군주의 외교'; row.question = '제안을 수락하시겠습니까?'; }
else if (row.text.includes('믹스 커피')) { row.keyword = '믹스 커피 재고 고갈'; row.question = '어떻게 해결하시겠습니까?'; }
else if (row.text.includes('신화적 존재')) { row.keyword = '신화적 존재 강림'; row.question = '어떻게 대적하시겠습니까?'; }
else { row.keyword = '비정상적 위기 발생'; row.question = '국장님, 결단이 필요합니다.'; }
});
const newCsv = Papa.unparse(parsed.data, {
columns: [
'id','type','keyword','question','text','flavor',
'left_text','left_res','left_ent','left_pan','left_rsk','left_bud','left_per','left_inf','left_wea','left_for','left_sur',
'right_text','right_res','right_ent','right_pan','right_rsk','right_bud','right_per','right_inf','right_wea','right_for','right_sur'
]
});
fs.writeFileSync('./src/data/cards.csv', newCsv);
console.log('CSV updated with keyword and question');
+37
View File
@@ -0,0 +1,37 @@
import fs from 'fs';
import Papa from 'papaparse';
const csv = fs.readFileSync('./src/data/cards.csv', 'utf-8');
const parsed = Papa.parse(csv, { header: true });
parsed.data.forEach(row => {
if (!row.id) return;
// Initialize empty tags
row.left_tag = '';
row.right_tag = '';
// Assign tags based on id or content
if (row.id == 1) { row.left_tag = 'tag_ruthless'; } // 무력 진압
if (row.id == 4) { row.left_tag = 'tag_madness'; } // DNA 이식 실험 승인
if (row.id == 6) { row.left_tag = 'tag_corruption'; } // 부산물 암시장 매각
if (row.id == 7) { row.left_tag = 'tag_coverup'; } // 대중 매체 장악 감시망
if (row.id == 10) { row.left_tag = 'tag_coverup'; } // 유튜버 제거
if (row.id == 11) { row.right_tag = 'tag_cult'; } // 종교 의식 방관
if (row.id == 12) { row.left_tag = 'tag_corruption'; } // 장부 은폐 공작
if (row.id == 14) { row.left_tag = 'tag_alien_contact'; } // 이계 무역로 개방
if (row.id == 17) { row.left_tag = 'tag_ruthless'; } // 시민 포기 및 폭격
if (row.id == 22) { row.left_tag = 'tag_alien_contact'; } // 유물 집중 연구
if (row.id == 29) { row.left_tag = 'tag_alien_contact'; } // 이계 외교 사절 거래
});
const newCsv = Papa.unparse(parsed.data, {
columns: [
'id','type','keyword','question','text','flavor',
'left_text','left_tag','left_res','left_ent','left_pan','left_rsk','left_bud','left_per','left_inf','left_wea','left_for','left_sur',
'right_text','right_tag','right_res','right_ent','right_pan','right_rsk','right_bud','right_per','right_inf','right_wea','right_for','right_sur'
]
});
fs.writeFileSync('./src/data/cards.csv', newCsv);
console.log('CSV updated with tags');
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
+126
View File
@@ -0,0 +1,126 @@
# Project SS — DEVLOG
> **이 파일의 역할:** 날짜별 작업 이력과 미해결 이슈를 기록한다.
> 규칙은 `../CLAUDE.md`, Phase 계획은 `../ROADMAP.md`.
> **최신 항목이 위로** 오도록 작성한다 (역순).
---
## 🔴 열린 이슈 (해결되면 해당 항목을 아래 이력으로 옮기고 여기서 지운다)
### 자산 획득이 실기 플레이에서 한 번도 발생하지 않음 (2026-07-23 발견, 원인 미조사)
자산(엔진) 시스템 플레이테스트 중 자산을 하나도 획득하지 못해 Part D 체크리스트를 검증 못함.
플레이 횟수 자체가 적어 정상 확률 범위인지 실제 버그인지 구분 안 된 상태.
- **획득 경로:** `GameScreen.jsx`의 `handleSwipe` 내 `card.type === '극비'` 분기
(카드 타입 '극비' 스와이프 성공 시 미보유 자산 중 무작위 1종)
- **의심 지점 ①** '극비' 카드는 페이즈2 풀(`phase2Pool`)에만 있고, 페이즈2는 내러티브 태그 3종
획득 후에야 덱에 섞인다 → 플레이가 짧으면 페이즈2 진입 전에 끝났을 가능성
- **의심 지점 ②** 페이즈2 진입 이후에도 '극비' 카드 등장 빈도/개수가 낮을 수 있음 (`cards.csv` 기준 극비 7종)
- **다음 액션:** 확률 문제인지 조건 분기 버그인지 코드를 다시 열어 확인
### 디자이너 확인 필요 — 오염 압박 밸런스 (2026-07-23)
- ⓐ 위험 1점 = 오염 1장 비율이 적정한지
- ⓑ 항상 안전한 쪽만 고르면 붕괴율 0% — 안전 선택지의 토큰 비용이 충분한 압박인지 별도 검토 필요
### 디자이너 확인 필요 — 자산 시스템 단순화 가정 (2026-07-13)
구현 시 임의로 단순화한 부분들. 의도와 맞는지 확인 필요.
- ① 위험 수용 계수 등 "+N%" 리스크를 기존 0~5단계 스케일(1단계=20%)로 환산해 소수점 누적
- ② 임시 격리 컨테이너(좌 스와이프 격리)는 문서의 "발동 선택 가능"을 단순화해 자동 발동으로 구현(확인 팝업 없음)
- ③ 자산 슬롯 4개 고정 → 보유량만큼 늘어나는 가변 슬롯으로 변경
### 미착수 — 팝업 2종 분리 UI (2026-07-13)
현재 코드(`ChroniclePopup.jsx`, `GameScreen.jsx`)는 태그 히스토리와 연대기(카드·선택 이력)를
하나의 팝업에서 함께 보여준다. 문서가 요구하는 **"세계의 상흔 팝업 vs 연대기 팝업" 2종 분리**는 미착수.
### 미검증 — Part D 플레이테스트 체크리스트 (2026-07-13)
역전 엔진 성립 / 연쇄 가시성 / S급 킹핀 / 무효 빌드 풍자 / 오버파워 확인 / 연쇄 깊이 상한
→ 디자이너 플레이 후 피드백 필요
---
## 2026-07-23 · 오염 카드 압박 강화
**배경:** "오염 카드 수가 적어 위기감이 없고, 스와이프가 오염을 늘린다는 걸 알려주지 않아 고민이 안 된다"는
그레이박스 피드백 반영.
**변경 사항**
1. 오염 주입 규칙을 `client/src/engine/pollution.js`로 분리 — 사전 예고·실제 주입·사후 알림이
`previewPollution()` 단일 기준을 공유 (예고와 결과가 어긋나면 정보 신뢰가 깨지므로)
2. **위험 파라미터를 올리는 결재는 그 자리에서 덱에 오염 카드를 주입** (위험 +1 = 오염 1장,
1회 최대 3장, 현재 위치 2~4장 뒤 삽입). 기존에는 정례회의에서만 파라미터당 1장이었음
3. 정례회의 주입도 단계 비례로 변경 — `min(3, ceil(level/2))`
4. 페널티 무효화(방어막/책임 소재 불명) 시에는 오염도 유입되지 않음 — 방어막의 값어치
5. UI 3종 추가 — 스와이프 인텐트에 `☣ 오염 카드 +N` 사전 경고 / 결재 후 유입 토스트 /
상단 `☣ 덱 오염 N/전체` 상시 게이지 (40% 이상 시 적색 점멸)
6. 덱 인덱스를 매 결재마다 덱 길이로 정규화 — 이전에는 `currentCardIndex`가 덱 길이를 넘어 계속
증가해 삽입 위치 계산이 무의미했음. `pickGrade`에 반올림 추가(자산 부작용이 파라미터를
소수점으로 올려 크래시 가능성 있었음)
**시뮬레이션 결과** (400~600회, 자산·의회 파라미터 정화 제외)
- 신중 플레이 시 총 주입 오염 10.8장 → **25.1장**
- 최종 덱 오염률 39% → **59%**
- 무작위 플레이의 붕괴율 ~100%는 변경 **이전에도** 동일 (파라미터가 원인)
**실기 플레이 결과:** 디자이너 직접 플레이 확인 — 위기감 강화 방향은 맞으나 세부 밸런스는 조정 여지 있음.
정밀 튜닝은 플레이 횟수를 더 쌓은 뒤 진행 예정.
---
## 2026-07-13 · 자산(엔진) 시스템 v1
`docs/작업지시서/자산시스템_작업지시서.md` 기준. 발라트로형 **"조커 레이어" 엔진 빌딩** 도입 —
덱은 여전히 적(오염)이고, 자산 슬롯이 패시브로 오염을 걸러내는 엔진이라는 설계 그대로 구현.
**변경 사항**
- 기존 4종 수동 클릭형 자산(성질변환/시한부격리/영구소각/결재권강제행사)을 **전면 대체**
- 신규 `client/src/data/assets.json` — 8종, 트리거→효과→성장 스키마
- 신규 `client/src/engine/assetEngine.js` — 이벤트 버스
(`every_n_approvals` / `on_pollution_draw` / `on_swipe_left` / `on_council_end` / `on_purge` 연쇄, 깊이 상한 10)
- 극비 프로젝트 성공 시 미보유 자산 중 무작위 1종 획득 (획득 경로 튜닝은 2차 배치로 범위 외)
- 자산 슬롯 클릭 시 `AssetInfoPopup`으로 트리거→효과·스택·충전 상태 표시
- 화면 우하단 발동 로그 피드(최근 4건) — Part C UX 블로커 해결
- 기존 `mulliganShields`(방어막 카운트)를 "다음 오염 카드 페널티 무효화" 공용 풀로 재사용 →
결재 반려 도장(충전식 수동)과 책임 소재 불명 처리(조직저항 자동 무효화)가 같은 메커니즘 공유
---
## 2026-07-13 · 내러티브 태그 재설계
`docs/내러티브태그_시스템_작업지시서.md` 기준.
**변경 사항**
1. **연대기(Chronicle, 결재 결정 이력)와 내러티브 태그(세계의 상흔, 결과)를 개념적으로 분리**
→ 기획서 / 스토리 바이블 / 와이어프레임 / 비전 / PRD / 유저플로우 / 개발순서 문서 반영 완료
2. `narrative_tags.json` 10종에 필드 추가
- `snippet` — 1~2문장 결과 보고서, 거리감 있는 관료체
- `snippet_type` — `"system"` 7종 / `"human"` 3종
- ※ 초안이므로 디자이너 검토 필요
---
## Phase 완료 시점 기록
### Phase 4 — 멀티 엔딩 & 연대기
- 엔딩 도감 localStorage 키를 `ss_endings_v2`로 교체 (기존 `ss_endings`는 제목 문자열 기반이라 취약)
→ **이전 플레이테스트 기록은 새 키와 호환되지 않음**
- 내러티브 태그는 재사용 가능한 태그 풀(`narrative_tags.json`, 10종) 방식으로,
선별된 카드의 좌/우 선택지에서만 획득
- 페이즈 전환 문턱: "고유 태그 3종/6종 획득"
- 상세: `ROADMAP.md` Phase 4
### Phase 3 — 정치 시스템 & 스토리 페이징
- 상임 이사회 8명의 투표는 아직 `성향 태그` 시스템이 없어 **위험 수용 계수 기반 확률 처리**로 남아있음
- 상세: `ROADMAP.md` Phase 3
### Phase 2 — 자원 경제 & 덱빌딩
- 오염 카드(신화/조직저항/사회공황/이사회압박) 콘텐츠는 **등급별 1종씩만 채운 초안**
- 상세: `ROADMAP.md` Phase 2
---
## 2026-07-09 · 병렬 브랜치 대형 충돌
다른 세션이 origin에 푸시한 커밋을 로컬이 받지 못한 채 Phase 2~4를 독립적으로 재구현해 충돌 발생.
→ 이후 **작업 시작 전 `git fetch` + 분기 확인**을 필수 절차로 `CLAUDE.md`에 명시.
자세한 경위는 `ROADMAP.md` 상단 박스 참고.
-49
View File
@@ -1,49 +0,0 @@
# Project SS 제품 요구 사항 정의서 (PRD)
## 1. 프로젝트 개요
- **프로젝트명:** Project SS (가제: 비밀 결사단)
- **장르:** 로그라이크 경영 시뮬레이션 / 엔진 빌딩 / 정치 스릴러
- **타겟 유저:** * 'Reigns', 'Papers, Please' 류의 딜레마/내러티브 선택 게임 선호 유저
- '윙스팬', '테라포밍 마스' 류의 시너지/엔진 빌딩 보드게임 선호 유저
- **플랫폼:** 모바일 웹 (웹앱) 및 하이브리드 앱 환경 (Portrait 중심)
- **핵심 가치:** * **"선택의 딜레마와 기회비용"** (정답이 없는 차악의 선택)
- **"자산 시너지 빌드업"** (나만의 이득 창출 엔진 구축)
- **"정치적 표적 견제"** (플레이어의 엔진을 부수려는 의회의 현실적 압박)
## 2. 주요 기능 요구사항 (Functional Requirements)
### 2.1. 동적 데이터베이스 (Live DB) 연동
- **Google Sheets 연동:** 하드코딩 없이 구글 시트(CSV)를 Fetch하여 게임 데이터로 사용.
- **데이터 정규화 (Smart Parsing):** 대소문자, 공백 등 기획자의 입력 오류를 방어하는 파싱 로직 구현.
- **오프라인 폴백 (Fallback):** 네트워크 통신 실패 시 내장된 기본 데이터로 자동 전환되어 게임 진행 보장.
### 2.2. 결재 상호작용 (Swipe Interface)
- **Tinder-like 스와이프:** 카드를 좌우로 드래그하여 거절(Left) / 승인(Right) 판정.
- **롱프레스 (Override):** 법안에 의해 잠긴(Lock) 카드를 0.8초 이상 길게 눌러 신임도(HP)를 대가로 '강제 돌파'.
- **예측 오버레이 (Prediction Feedback):** 카드를 드래그하는 동안 수치 증감(예산, 엔트로피, 신임도, 파벌 지분)을 화살표와 색상으로 미리 시각화.
### 2.3. 자산 시너지 엔진 (Asset Synergy Build)
- **자산 획득 및 인벤토리:** 사건 결재 보상으로 '태그(Tag)'가 부여된 자산 획득.
- **패시브 시너지 (Combo System):** 보유한 자산의 동일 태그(예: `군사`, `오컬트`)가 특정 개수 이상 모이면 콤보 스킬(Rule-bending, 예: 거절 패널티 무시) 발동.
- **자산 매각:** 자금을 즉시 확보하는 대신 의심도나 엔트로피가 급증하는 매각 리스크 로직 구현.
### 2.4. 의회 및 정치 시스템 (Parliament & Sabotage)
- **제로섬 라이벌리:** 특정 파벌 지분 상승 시, 적대 파벌의 지분이 즉시 하락하는 연결 로직.
- **표적 법안 (Targeted Sabotage):** 현재 플레이어가 가장 많이 보유한 '자산 태그'를 파악하여, 해당 빌드를 무력화하는 카운터 법안을 의회에서 최우선 상정.
- **로비 시스템:** 의회 투표(여당안 vs 야당안) 단계에서 비자금을 소모하여 소수 정당(Swing Voter)의 표를 매수하는 인터랙션.
### 2.5. 자원 관리 모델 (Resource Management)
- **예산 칸막이:** 총 예산과 발신 부서 예산의 이중 체크. 부서 예산 부족 시 '적자' 상태(추후 패널티) 부여.
- **엔트로피의 역설:** 단순 (승인=해결/거절=위험)을 탈피하여, 각 카드 데이터에 설정된 비선형적 엔트로피 증감값 적용.
- **감사 의심도 & 비자금:** 횡령과 로비를 위한 이면 자원 시스템 관리.
## 3. 비기능 요구사항 (Non-Functional Requirements)
- **UI 반응성:** 모바일 환경의 Safe Area를 고려하고, 기기 해상도(vh/vw)에 반응하여 카드가 화면을 벗어나지 않도록 제한 (`dvh` 사용 권장).
- **조작 피드백:** 롱프레스 성공, 카드 넘김, 법안 잠금 등 주요 액션 시 Haptic Feedback(진동) 적용.
- **접근성:** 시각적 효과뿐만 아니라 텍스트로도 현재 상황과 잠금 사유가 명확히 인지될 것.
## 4. 마일스톤 및 페이즈
- **Phase 1 (코어 루프):** DB 파싱, 스와이프 결재, 5대 자원 증감, 주간 결산 기본 루프 완성.
- **Phase 2 (엔진 빌딩 & 정치):** 자산 태그 시너지 로직 구현, 의회 2단계(선포 및 입법) + 로비 시스템 + 표적 법안 로직 구현.
- **Phase 3 (폴리싱):** UI 오버레이 연출 고도화, BGM/SFX 연동, 엔딩 및 Act 진행 조건 추가.
-70
View File
@@ -1,70 +0,0 @@
# Project SS 게임 기획서 (GDD)
## 1. 게임 개요
- **로그라인:** 얇아지는 현실의 장막 속, 초자연적 위협을 막아야 하는 비밀 결사단의 국장이 되어 관료제와 파벌 정치의 틈바구니에서 아슬아슬한 결재 서류에 도장을 찍는 게임.
- **핵심 재미 요소:**
1. 무조건적인 선(善)이 없는 상황에서 자원과 정치를 맞바꾸는 **[고뇌의 스와이프]**
2. 태그를 모아 강력한 콤보를 터뜨리는 **[자산 시너지 빌드업]**
3. 나의 빌드업을 부수려고 달려드는 파벌들의 **[표적 정치 공세(입법)]** 방어
## 2. 코어 게임 루프
### 2.1. Micro Loop (일일 업무: 평일 5턴)
1. **아침 브리핑:** 이번 주 아젠다/퀘스트 확인, 보유 자산의 패시브 효과(예산 수급 등) 적용.
2. **결재 (스와이프):**
- 결재 서류(카드) 등장. (부서/파벌/딜레마/잠금 여부 확인)
- 플레이어는 **승인(우측)**, **거절(좌측)**, 또는 신임도를 태워 쇠사슬을 끊는 **강행 돌파(롱프레스)** 중 선택.
3. **결과 반영:** 예산 소모, 엔트로피 증감, 파벌 지분 변동(라이벌 동반 하락).
### 2.2. Macro Loop (주간 의회: 주말)
1. **지분 갱신:** 평일 동안 모인 배당금(코인)에 따라 8개 파벌의 지분율 재산정.
2. **Phase 1 (아젠다 선포):** 제1주주가 다음 한 주의 환경 변수(패시브 룰)와 퀘스트(목표) 강제 하달.
3. **Phase 2 (입법 전쟁):** * 플레이어의 빌드업을 저격하는 **여당안(A)**과 경쟁하는 **야당안(B)** 상정.
- 플레이어는 **[비자금]**을 소모해 소수 정당을 매수(로비).
- 50% 이상 지분을 얻은 법안이 가결되어 다음 주 결재의 **잠금(Lock) 제약**으로 작동.
## 3. 핵심 시스템 기획
### 3.1. 결재와 딜레마 (Approval & Dual-Lock)
- **엔트로피의 역설:** 위험한 마법을 허가해 사건을 해결하면 파벌 지분은 얻지만 세계관은 불안정해진다(엔트로피 상승). 사건을 무시하면 엔트로피가 폭등하지만 예산을 아낄 수 있다. 정답이 없는 딜레마의 연속.
- **양방향 잠금 (Dual-Lock):** 발효된 법안(예: _사살 금지법_) 태그가 안건 태그(`kill`)와 겹치면 승인 방향으로 드래그가 쇠사슬에 걸려 막힌다.
- **오버라이드 (강행 돌파):** 국가적 위기 시 0.8초간 카드를 꾹 눌러, 플레이어의 HP인 **[신임도 -20]**을 대가로 쇠사슬을 박살 내고 강제 승인한다.
### 3.2. 엔진 빌딩: 자산 시너지 (Asset Synergy)
보드게임 '테라포밍 마스', '윙스팬'의 재미를 차용하여 단순 텍스트 게임의 한계를 돌파한다.
- **태그 수집:** 자산은 고유 태그(`군사`, `오컬트`, `자본` 등)를 가짐.
- **빌드업 (Combo):** 같은 태그를 모을수록 사기적인 능력이 발동됨.
- _군사 1개:_ 작전부 비용 -10% / _군사 3개:_ 강행 돌파 시 신임도 패널티 면제.
- **전략성:** 이번 판은 어떤 태그 조합(빌드)으로 갈지 유저 스스로 목표를 설정하게 됨.
### 3.3. 의회의 표적 견제 (Targeted Sabotage)
유저가 엔진 빌딩의 단맛을 볼 때쯤, 현실적인 정치 견제가 들어온다.
- **질투와 공포:** 유저가 `군사` 태그 자산을 모아 콤보를 터뜨리면, `군사`를 혐오하는 반대 파벌의 적대감이 극에 달한다.
- **저격 입법:** 주말 의회에서 의도적으로 **"모든 군사 자산 무효화 및 유지비 부과"** 같은 카운터 법안이 1순위로 상정된다.
- **로비의 필요성:** 유저는 공들여 쌓은 엔진이 박살 나는 것을 막기 위해, 비자금을 쏟아부어 로비하거나 싫어하는 정당의 비위(악성 안건 승인)를 맞춰 지분을 방어해야 하는 진흙탕 정치를 경험하게 된다.
## 4. 콘텐츠 스펙
### 4.1. 4대 자원 및 게이지
1. **총 예산 / 부서 예산:** 합법적 자금. 부서 예산 부족 시 '적자' 상태 돌입.
2. **비자금:** 그림자 경제로 획득. 로비 및 급전용. (사용 시 의심도 증가)
3. **신임도 (HP):** 플레이어의 정치 생명. 0% 달성 시 **탄핵 (Game Over)**.
4. **엔트로피:** 종말 타이머. 100% 달성 시 **세계 멸망 (Game Over)**.
### 4.2. 8대 파벌 매트릭스 (라이벌 구조)
- **[무력]** 철의 수호당 (사살) ⚔️ 지식 보존당 (생포)
- **[순수]** 진화 미래당 (개조) ⚔️ 순수 인간당 (순혈)
- **[패권]** 세계 재건당 (양지화) ⚔️ 침묵의 장막당 (은폐)
- **[자본]** 차원 무역당 (상업) ⚔️ 고대 공존당 (숭배)
- _로직:_ 한 쪽의 지분이 상승하면 라이벌의 지분은 반드시 깎이는 제로섬 구조.
### 4.3. 시나리오 (진행도)
- **1막 (1~4주차):** 균열. 일상적 괴담과 횡령, 자산 빌드업 시작기.
- **2막 (5~8주차):** 붕괴. S급 위협 등장, 완성되어 가는 엔진과 의회의 본격적인 표적 견제.
- **3막 (9~12주차):** 혼란. 극단적 딜레마 요구, 아젠다의 폭주, 자산의 붕괴.
- **4막 (13주차~):** 심판. 현재 의회를 장악한 파벌에 따라 결말(멀티 엔딩) 결정.
## 5. UI / UX 방향성
- **시각 연출 (Diegetic):** 1인칭 국장 시점. 어두운 마호가니 데스크, 붉은색과 푸른색의 잉크 도장. 불길하게 깜빡이는 종말 시계.
- **예측 오버레이:** 조작 스트레스 방지를 위해, 카드 드래그 시 중앙에 팝업이 떠서 수치 증감과 라이벌 지분 하락을 색상별로 직관적으로 보여줌.
- **의회 연출:** 거대 양당의 법안(A/B)을 상단에 대치시키고, 하단에는 매수 가능한 소수 정당들을 뱃지(칩) 형태로 나열하여 터치 시 즉각적으로 표가 뒤집히는 애니메이션 제공.
@@ -0,0 +1,206 @@
- 개요
- 서사형 덱빌딩 게임
- 출시: 모바일&스팀
- 코어 루프
- 시스템 요약: 로그라이크 루프
1. **시작:** 무작위 사건(내러티브)과 무작위 지부장이 배치된 채 국장 취임.
2. **결재 (덱 빌딩):** 스와이프를 통해 토큰을 관리하며, 덱에 [오염 카드]를 밀어 넣거나 [자산 카드]를 챙김.
3. **위기 (드로우):** 내가 넣은 오염 카드가 튀어나와 목을 조를 때, 어떻게든 [자원 태그] 엔진과 남은 토큰으로 방어.
4. **엔딩 (결과):** 덱의 상태, 파벌 우호도, 붕괴 게이지의 조합에 따라 수십 가지의 파생 엔딩 중 하나를 수집하고 다음 회차로 루프.
- 시스템 (플레이어 행동 중심 시스템 정리)
- 게임 목표: 모든 엔딩 보기
- 기본 붕괴 엔딩
- 사임 (내부 붕괴): [내부 저항] 5단계 도달 시. (수많은 딜레마에서 조직원들을 갈아 넣고 장악력을 잃은 결과)
- 세계 멸망 (외부 붕괴): [엔트로피] 5단계 도달 시. (내러티브 태그 방치 및 오염 카드에서 세상의 타락을 방관한 결과)
- 문명 붕괴 (사회 붕괴): [대중 공황] 5단계 도달 시. (사태 은폐와 여론 조작의 부작용으로 인류가 집단 광기에 빠져 스스로 문명을 파괴한 결과)
- 정치적 탄핵 (윗선 붕괴): 주간 의회 단계에서 적대적 파벌 임원이 2/3(9명 이상)를 넘어 탄핵안이 상정되었으나, 방어 자원(정치적 개입)이 없어 가결될 시.
- 변주된 붕괴 엔딩
- 기본 붕괴 엔딩 + 특정 자원 태그
- 기본 붕괴 엔딩 + 특정 토큰
- 극단적 파라미터 엔딩
- 특정 파라미터를 극단적으로 비정상적인 상태로 만들었을 때 즉시 발동하는 강제 종료 엔딩 (조기 엔딩)
- 내러티브 연쇄 엔딩
- 특정 내러티브 엔딩을 순서대로 수집
- 의회 정치 엔딩 (의회의 수장이 원하는 바를 이룸)
- 탄핵 + 의회의 수장이 보유한 성향에 맞는 조건 달성
- 자원
- 토큰: 소모성 자원
- 예산 토큰
- 인력 토큰
- 정보 토큰
- 자원 태그: 누적 자원, 영구적인 1코스트 할인 효과
- 재력/자본 태그: 건물 아이콘
- 무력/조직 태그: 방패 아이콘
- 감시/언론 태그: 눈동자 아이콘
- 핵심 속성 파라미터 (명확한 수치화)
- [내부 저항 (Internal Resistance)]: (0~100%) 국장에 대한 조직 내부의 반발력.
- 1단계: ~20%
- 2단계: ~40%
- 3단계: ~60%
- 4단계: ~80%
- 5단계: ~100%
- [엔트로피 (Entropy)]: (0~100%) 세계가 미쳐가는 종말 카운트다운.
- 1단계: ~20%
- 2단계: ~40%
- 3단계: ~60%
- 4단계: ~80%
- 5단계: ~100%
- [대중 공황 (Mass Panic)]: (0~100%) 인류 문명이 미쳐가는 척도
- 1단계: ~20%
- 2단계: ~40%
- 3단계: ~60%
- 4단계: ~80%
- 5단계: ~100%
- [위험 수용 계수 (Risk Tolerance)]: (0~100%) 위원회가 국장의 행보를 보고 느끼는 위기감의 척도. (높을수록 위원회가 보수적/강압적으로 변함)
- 1단계: ~20%
- 2단계: ~40%
- 3단계: ~60%
- 4단계: ~80%
- 5단계: ~100%
- [내러티브 태그]: 결재로 얻는 세계의 훼손 상태 (스택형) & 엔딩 조건.
- [성향 태그]: 결재로 얻는 국장의 통치 사상 (스택형).
- [임원 우호도]: 13명의 위원이 국장에게 가지는 개별 호감도 (-100 ~ +100).
- 결재 단계: 스와이프 결정
- 딜레마 선택
- 일상 딜레마: 어떤 토큰과 어떤 오염 카드를 선택할 것인가?
- 좌: 토큰 획득 + 덱오염
- 우: 토큰 획득 + 덱오염
- 위기 딜레마: 덱오염의 대가로 토큰을 얻을 것인가? 토큰을 쓰고 위기를 넘길것인가?
- 좌: 토큰 획득 + 덱오염
- 우: 토큰 소모
- 작전: 무료 획득이지만 덱오염을 감수할 것인가? 토큰을 쓰고 더 많은 자원 태그를 얻을 것인가?
- 좌: 자원 태그 획득 + 덱오염
- 우: 토큰 소모 + 더 많은 자원 태그 획득
- 극비 프로젝트: 토큰을 소모해 어떤 보상 (자원 태그 or 자산)을 받을 것인가?
- 좌: 토큰 소모 + '자원 태그 획득 or 자산 획득'
- 우: 토큰 소모 + '자원 태그 획득 or 자산 획득'
- 위로: 버리기
- 위기 강타(오염 카드 - 신화, 조직저항, 이사회의 압박)
- 좌: 토큰 차감 or 자원 태그 차감
- 우: 위험 단계 (엔트로피, 내부 저항, 위험 수용 계수) 상승
- 뉴스 이벤트
- 좌: 보유 '자산' 중 1개 제거
- 우: 위험 단계 (엔트로피, 내부 저항, 위험 수용 계수) 상승
- 공통 획득
- 내러티브 태그: 스토리 전달 + 엔딩 조건 중 하나
- 성향 태그: 파벌과의 우호도 변화
- 덱 오염 (토큰을 마이너스)
- 그림자 의회가 끝날때 마다 엔트로피 단계(1~5단계)에 따라 '신화 카드' 추가
- 신화 카드:
- S, A, B, C 등급으로 구성 (엔트로피 단계가 높을 수록 높은 등급 등장 확률 증가)
- 카드 예시: 괴수, 미스터리, 고대 신, 변칙 사물, 차원 균열, 인지 재해, 이계 전염병, 공간 왜곡, 오컬트 의식 등
- A급 이상 신화 카드는 '뉴스 이벤트' 카드를 덱에 추가 함
- 그림자 의회가 끝날때 마다 내부 저항 단계(1~5단계)에 따라 '조직 저항 카드' 추가
- 조직 저항 카드:
- S, A, B, C 등급으로 구성 (조직 저항 단계가 높을 수록 높은 등급 등장 확률 증가)
- 카드 예시: 항명, 기밀 유출, 현장 요원 집단 파업, 내부 고발, 고의적인 서류 누락 및 태업 등
- 대중 공황 1~5단계: 그림자 의회가 끝날때 마다 대중 공황 단계(1~5단계)에 따라 '사회 공황 카드' 추가
- 사회 공황 카드:
- S, A, B, C 등급으로 구성 (대중 공황 단계가 높을 수록 높은 등급 등장 확률 증가)
- 카드 예시: 대규모 폭동, 종말론 사이비 종교의 테러, 글로벌 주가 폭락, 생필품 사재기, 무고한 시민 대상 마녀사냥 등
- 위험 수용 계수 1~5단계: 그림자 의회가 끝날때 마다 이사회의 압박 카드 추가
- 이사회의 압박 카드:
- S, A, B, C 등급으로 구성 (위험 수용 단계가 높을 수록 높은 등급 등장 확률 증가)
- 카드 예시: 예산안 동결, 긴급 재무 감사, 국장의 비자금 조사, 청문회 소환, 결재권(스와이프) 강제 제한 등
- 덱압축: 오염카드 제거
- 자산
- 성질 변환 (Transformation): 독을 약으로 바꾸기
- 오염카드를 영구 변환
- 시한부 격리 (Quarantine): 하이 리스크 하이 리턴 압축
- 여러장의 카드를 격리하지만 조건에 따라 덱으로 복귀
- 물리적 영구 소각 (Purge): 정통 덱 압축
- 조건 없이 깔끔하게 덱의 불순물을 제거하는 가장 기본적이고 강력한 수단
- 결재권 강제 행사 (Mulligan / Skip): 회피 기동
- 덱에서 카드가 사라지지는 않지만, 뽑혔을 때 뼈아픈 페널티를 무효화하는 패시브 방어막
- 자산 슬롯 (무제한 인벤토리)
- 파이프라인 연쇄 반응
- 연쇄 노드를 구축하면 오염카드 자동처리
- 입력
- 변환
- 출력
- 주간 의회 단계: 의회의 압박
- '그림자 위원회' 임원 구성 (총 13명):
- 상임 이사회 (8명): 고정된 이념을 가짐. 국장의 '성향 태그'에 직접적으로 우호도가 변함.
- 글로벌 지부장 (5명): 수많은 현장 책임자 중 5명만 선발되어 합류(게임을 시작할때마다 변경). 이념보다 실리(자원 태그, 자산, 특정 내러티브 태그)에 따라 우호도가 변동함.
- 법안 상정 및 투표
- 법안 상정
- 동맹: 이득 규약 법안 상정 (자원 태그 플러스)
- 파국: 표적 규약 법안 상정 (자원 태그 마이너스)
- 예상 표결
- 동맹: 이득 규약에 투표 - 국장의 개입 불가
- 우호: 이득 규약에 투표
- 중립: 알 수 없음
- 적대: 표적 규약에 투표
- 파국: 표적 규약에 투표 - 국장의 개입 불가
- 국장의 개입
- 매수 액션:
- 소모 비용: [예산 토큰] 대량 소모 (또는 재력 자원 태그 스택 희생)
- ​개입 효과: '중립' 또는 '적대' 성향의 임원 1~2명을 이번 투표에 한하여 '동맹(찬성)'으로 강제 전환합니다.
- ​치명적 리스크: 돈으로 입을 막은 대가로 이사회 내부에 국장의 부패 스캔들이 돌기 시작하며 [위험 수용 계수]가 즉시 1단계(20%) 상승합니다.
- 장부 조작 액션:
- 소모 비용: [정보 토큰] 대량 소모 (또는 감시 자원 태그 스택 희생)
- ​개입 효과: 발의된 법안의 핵심 근거 서류를 위조하여 법안 상정 자체를 무효화하고 다음 주 의회로 이월(Delay)시킵니다. 시간을 벌 수 있는 유일한 수단입니다.
- ​치명적 리스크: 은폐해야 할 외부의 미스터리 관련 기밀 정보를 정치판에 흘린 대가로 [엔트로피]가 즉시 1단계(20%) 상승합니다.
- 내부 감사 액션:
- 소모 비용: [인력 토큰] 대량 소모 (또는 무력 자원 태그 스택 희생)
- ​개입 효과: 가장 강력한 '적대/파국' 임원의 비리를 무력으로 털어, 해당 임원과 그 측근들을 이번 의회에서 '기권(투표권 박탈)' 처리합니다. 탄핵 정족수를 무너뜨릴 때 유용합니다.
- ​치명적 리스크: 요원들을 사적인 정치 싸움에 갈아 넣은 결과로 현장의 불만이 폭발하여 [내부 저항]이 즉시 1단계(20%) 상승합니다.
- 투표 결과 및 법안 통과: 국장의 개입이 반영된 투표 결과 출력 및 법안 통과
- 탄핵 투표
- 예상 표결:
- 동맹: 탄핵 반대 - 국장의 개입 불가
- 우호: 탄핵 반대
- 중립: 알 수 없음
- 적대: 탄핵 찬성
- 파국: 탄핵 찬성 - 국장의 개입 불가
- 국장의 개입: 매수 액션, 장부 조작 액션, 내부 감사 액션
- 투표 결과 및 탄핵 심판: 국장의 개입이 반영된 투표 결과 출력 및 탄핵 결과 적용
- 토큰 경제 (내러티브 태그 누적치에 따라 페이즈 전환)
- 페이즈1: 기본 루프 (어떤 독을 마실 것인가)
- 토큰과 오염 카드 선택
- 주요 등장 딜레마: 일상 딜레마 + 위기 딜레마
- 페이즈2: 기회 루프 (드디어 턴이 왔다)
- 토큰 소비와 스택 보상 선택(오염 카드 해결 + 자산, 자원 태그 빌드업)
- 주요 등장 딜레마: 작전 + 극비 프로젝트 + 오염 카드
- 페이즈3: 압박 루프 (쌓아둔 모래성 무너뜨리기)
- 엔트로피, 내부 저항, 위험 수용 계수는 거의 게임오버 한계에 도달
- 오염 카드, 뉴스 속보, 표적 규약이 극에 달하는 시기로 토큰이 항상 부족
- 주요 등장 딜레마: 작전 + 극비 프로젝트 + 오염 카드
- 스토리 기반 페이징
- **작동 방식:** 플레이어가 획득한 [내러티브 태그]의 총개수에 따라 파트가 넘어갑니다.
- 태그 0~3개: 페이즈1 (사태 파악 및 은폐의 시작)
- 태그 4~7개: 페이즈2 (프로젝트 가동 및 이사회의 개입 본격화)
- 태그 8개 이상: 페이즈3 (통제 불능의 사태 및 종말의 카운트다운)
- 연대기 (Chronicle)
- 국장이 결재한 카드와 그에 대한 선택(좌/우/위)을 시간순으로 기록하는 결정 장부. 내러티브 태그가 '세계의 결과'라면, 연대기는 '국장의 원인 이력'이다.
- 내러티브 태그(세계의 상흔)와는 별도의 데이터·별도의 진입점(로그 버튼)으로 관리한다.
## 라운드 플로우차트 (Round Flowchart)
```mermaid
graph TD
A[라운드 시작] --> B(잔여 결재 건수 확인);
B --> C{결재 건수 남음?};
C -- Yes --> D[결재 단계];
D --> E(토큰/파라미터 증감);
E --> F{파산 상태인가?};
F -- Yes --> G[파산 카운터 증가];
F -- No --> H;
G --> H(잔여 결재 1감소);
H --> B;
C -- No --> I[정기 의회 단계 진입];
I --> J{파산 문책 경고 상태인가?};
J -- Yes (토큰 마이너스 상태) --> K[해임 / 게임 오버];
J -- No --> L{파산 카운터 확인};
L -- 1 이상 --> M[파산 문책 경고 및 패널티];
L -- 0 --> N[정상 상정 안건 처리];
M --> O;
N --> O(의원 표결 및 국장 개입);
O --> P{탄핵안 통과?};
P -- Yes --> Q[정치적 탄핵 / 게임 오버];
P -- No --> R[법안 결과 반영];
R --> S(다음 라운드 진입 및 필요 결재 건수 재설정);
S --> A;
```
+111
View File
@@ -0,0 +1,111 @@
문서 정보
- 프로젝트명: Project SS (가제: 비밀 결사단)
- 작성일: 2026-01-20
- 작성자: Gemini (Lead Narrative Designer)
- 주제: 관료주의 코스믹 호러 - "종말을 결재하시겠습니까?"
## 1. 세계관 개요 (The World)
### 1.1. 배경: "얇아진 장막 (The Thinning Veil)"
21세기 현대. 인류는 과학의 승리를 자축하지만, 사실 세계는 얇은 '현실의 장막' 하나로 간신히 유지되고 있다. 최근 원인 불명의 이유로 이 장막이 전 지구적으로 얇아지기 시작했다. 고대 신화 속 괴수, 이계의 침략자, 물리 법칙을 무시하는 변칙 현상(Anomaly)들이 일상적으로 튀어나오기 시작했다.
### 1.2. 조직: 국제 초자연 통제기구 (I.S.C.O)
- 정체: UN 상임이사국과 글로벌 메가코프들이 비밀리에 설립한 초법적 기관.
- 목표: 인류 문명의 '현상 유지(Status Quo)'.
- 현실: 예산 부족, 파벌 싸움, 비효율적인 관료제에 찌들어 있다. 요원들은 괴수보다 '감사팀'을 더 무서워하며, 국장(플레이어)은 세계 평화보다 '이번 분기 실적'을 걱정해야 한다.
### 1.3. 플레이어: "총괄 국장 (Director General)"
- 당신은 위기 상황을 수습하기 위해 긴급 임명된 관리자다.
- 전임 국장은 **'과로사'**했거나, **'실종'**되었거나, **'의회에 의해 숙청'**당했다. (로그라이크 회차마다 설정 변경 가능)
- 당신의 책상 위에는 인류의 운명이 걸린 서류와, 차갑게 식은 커피 한 잔뿐이다.
## 2. 메인 시나리오: 멸망을 향한 4막
게임은 **'엔트로피(멸망 수치)'**가 임계점에 도달하는 것을 막으며 진행된다.
### 제1막: 균열 (The Crack)
"단순한 가스 폭발 사고입니다. 그렇게 보고하세요."
- 상황: 도시 곳곳에서 소규모 미스터리 발생 (하수구 괴물, 폴터가이스트).
- 주요 갈등: [은폐 vs 폭로]. 작은 사건들을 덮어서 대중의 공포를 막을 것인가, 아니면 솔직하게 대응할 것인가?
- 분위기: 아직은 통제 가능한 수준의 업무. 튜토리얼 성격의 '일상적 업무'.
- 위기: 예산이 턱없이 부족하다. 플레이어는 초기 자금을 위해 '그림자 경제(괴수 밀매)'에 손을 대기 시작한다.
### 제2막: 붕괴 (The Collapse)
"통제 불능. 제7구역 포기 건의안을 올립니다."
- 상황: S급 괴수 출현. 도시 하나가 봉쇄되거나 지도에서 지워짐. 대중들이 이상함을 눈치챔.
- 주요 갈등: [보수(규제) vs 급진(개방)]. 본격적인 파벌 전쟁의 시작.
- 보수파: "모든 것을 봉쇄하고 원칙대로 처리해!" (플레이어를 답답하게 함)
- 급진파: "규제를 풀고 신무기를 써! 민간인 피해는 어쩔 수 없어!" (플레이어를 타락시킴)
- 위기: 의회가 플레이어의 결재권을 제한하는 **[법안]**을 쏟아내기 시작함.
### 제3막: 혼란 (The Chaos)
"신은 죽었다. 우리가 그 자리를 대신할 뿐."
- 상황: 전 세계적 재난. 국가 기능 마비. I.S.C.O가 사실상 세계 정부 역할을 하게 됨.
- 주요 갈등: [인간성 vs 생존].
- 이제 괴수를 잡는 것만으론 부족하다. 인체 실험, 영혼 추출, 차원 융합 등 '금지된 수단'을 써야만 막을 수 있는 위협들이 등장한다.
- 위기: 엔트로피가 급상승하거나, 신임도가 바닥을 친다. [강행 돌파] 없이는 턴을 넘기기 힘들다.
### 제4막: 심판 (The Judgment)
"최종 결재 바랍니다. 이 세계를 폐기하시겠습니까?"
- 상황: '최종 위협(The End)' 강림. (고대 신, 차원 충돌, 혹은 내부의 반란)
- 주요 갈등: [엔딩 분기]. 그동안 쌓아온 **'지배구조(파벌 지분)'**에 따라 최종 작전이 결정된다.
- 결말: 플레이어는 마지막 도장을 찍는다. 그것이 구원일지, 멸망일지는 아무도 모른다.
## 3. 파벌 별 내러티브 (The Ideologies)
각 파벌은 단순한 아군/적군이 아니라, **"멸망을 막기 위한 서로 다른 철학"**을 대변한다.
### A. 급진/개방 연합 (The Accelerators)
"불은 불로 다스린다. 멸망보다 빠른 속도로 진화하라."
- 1. 철의 수호당 (Iron Guard) - [무력]
- "괴수에게 인권은 없습니다. 방아쇠를 당기십시오."
- 제안: 궤도 폭격, 민간인 구역 전술핵 사용, 즉결 처형 권한.
- 스토리: 군인 출신 강경파. 그들이 득세하면 세계는 **'군사 독재 국가'**가 된다.
- 2. 진화 미래당 (Genesis Project) - [진화]
- "인간의 몸으론 괴물을 이길 수 없습니다. 우리가 괴물이 되어야 합니다."
- 제안: 전국민 DNA 개조 의무화, 초인 병사 양성, 괴수 신체 이식.
- 스토리: 매드 사이언티스트 집단. 그들이 득세하면 인류는 **'새로운 종(Species)'**으로 변해버린다.
- 3. 세계 재건당 (Reshapers) - [패권]
- "숨어서 세상을 구하는 건 지쳤다. 이제 우리가 통치한다."
- 제안: I.S.C.O의 양지화, 각국 정부 해체 및 통합, 미디어 완전 장악.
- 스토리: 엘리트 선민사상. 그들이 득세하면 '빅 브라더' 감시 사회가 된다.
- 4. 차원 무역당 (Nexus Traders) - [자본]
- "위기는 곧 기회입니다. 이 괴수 시체가 얼마짜린지 아십니까?"
- 제안: 괴수 부산물 독점 판매, 이계 문명과의 무역로 개방.
- 스토리: 냉혈한 자본가. 그들이 득세하면 지구는 우주의 **'거대 식민지 시장'**이 된다.
### B. 보수/원칙 연합 (The Stabilizers)
"원칙을 잃으면 우리도 괴물과 다를 바 없다. 질서를 수호하라."
- 1. 지식 보존당 (Librarians) - [지성]
- "죽이면 배울 수 없습니다. 격리하고, 관찰하고, 이해해야 합니다."
- 제안: 사살 금지, 도서관 건립, 고대 언어 해독 프로젝트.
- 스토리: 답답한 학자들. 그들이 득세하면 **'상아탑'**에 갇혀 위기에 늦게 대응하다 멸망할 수 있다.
- 2. 순수 인간당 (Purifiers) - [순수]
- "오직 인간의 힘으로만. 더러운 마법과 변이에 의존해선 안 됩니다."
- 제안: 마법 금지법, 변이체 강제 추방, 기술 감사 강화.
- 스토리: 기술 원리주의자. 그들이 득세하면 **'마녀사냥'**이 시작된다.
- 3. 침묵의 장막당 (Veilkeepers) - [은폐]
- "대중은 진실을 감당할 수 없습니다. 무덤까지 비밀을 가져가십시오."
- 제안: 대규모 기억 소거, 인터넷 차단, 목격자 제거.
- 스토리: 편집증적 비밀주의. 그들이 득세하면 세상은 아무 일도 없는 척하다가 **'조용히 멸망'**한다.
- 4. 고대 공존당 (The Covenant) - [친화]
- "그들은 적이 아닙니다. 달래고 숭배하여 분노를 잠재워야 합니다."
- 제안: 평화 협정, 인신 공양(?!), 성역 지정.
- 스토리: 온건파인 척하는 광신도. 그들이 득세하면 인류는 고대 신들의 **'애완동물'**이 된다.
## 4. 멀티 엔딩 매트릭스 (Ending Matrix)
엔딩은 **[누가 의회를 장악했는가(지분율)]**와 **[세계 상태(엔트로피)]**에 따라 결정된다.
|지배 주주|낮은 엔트로피 (성공적 방어)|높은 엔트로피 (간신히 생존)|
|---|---|---|
|급진파 (Radical)|[신인류의 탄생] 인류는 진화했거나 정복자가 되었다. 하지만 예전의 도덕은 사라졌다. (디스토피아적 승리)|[폭주의 끝] 힘을 제어하지 못해 자멸했다. 혹은 지구 자체가 무기가 되어 우주의 공포가 되었다. (배드 엔딩)|
|보수파 (Conservative)|[위대한 침묵] 세계는 지켜졌으나, 아무도 진실을 모른다. 조직은 영원히 그림자에 남는다. (정사/진엔딩)|[질식사] 너무 많은 것을 금지하다 대응 시기를 놓쳤다. 서류 더미 속에서 맞이하는 멸망. (허무 엔딩)|
|중도/혼란 (Mixed)|[영원한 전쟁] 어느 쪽도 이기지 못했다. 괴수와의 전쟁은 일상이 되었다. (네버엔딩)|[탄핵 및 숙청] 국장은 모든 책임을 지고 처형당한다. 새로운 국장이 부임한다. (게임 오버)|
## 5. 톤 앤 매너 (Tone & Manner)
- 건조한 유머: 세상이 망해가는 와중에도 "식대 청구서 영수증이 누락되었습니다"라고 따지는 부조리함.
- 냉혹한 현실: 영웅적 희생보다는, "예산 10% 절감을 위해 요원 5명을 사지로 보낸다"는 식의 비용 편익 분석.
- 보이지 않는 공포: 괴수의 모습보다는, 괴수가 지나간 자리에 남은 파괴된 도시의 피해액 보고서가 더 무섭게 다가오도록.
@@ -1,116 +1,5 @@
# Project SS 스토리 바이블 v1.0
문서 정보
- 프로젝트명: Project SS (가제: 비밀 결사단)
- 작성일: 2026-01-20
- 작성자: Gemini (Lead Narrative Designer)
- 주제: 관료주의 코스믹 호러 - "종말을 결재하시겠습니까?"
## 1. 세계관 개요 (The World)
### 1.1. 배경: "얇아진 장막 (The Thinning Veil)"
21세기 현대. 인류는 과학의 승리를 자축하지만, 사실 세계는 얇은 '현실의 장막' 하나로 간신히 유지되고 있다. 최근 원인 불명의 이유로 이 장막이 전 지구적으로 얇아지기 시작했다. 고대 신화 속 괴수, 이계의 침략자, 물리 법칙을 무시하는 변칙 현상(Anomaly)들이 일상적으로 튀어나오기 시작했다.
### 1.2. 조직: 국제 초자연 통제기구 (I.S.C.O)
- 정체: UN 상임이사국과 글로벌 메가코프들이 비밀리에 설립한 초법적 기관.
- 목표: 인류 문명의 '현상 유지(Status Quo)'.
- 현실: 예산 부족, 파벌 싸움, 비효율적인 관료제에 찌들어 있다. 요원들은 괴수보다 '감사팀'을 더 무서워하며, 국장(플레이어)은 세계 평화보다 '이번 분기 실적'을 걱정해야 한다.
### 1.3. 플레이어: "총괄 국장 (Director General)"
- 당신은 위기 상황을 수습하기 위해 긴급 임명된 관리자다.
- 전임 국장은 **'과로사'**했거나, **'실종'**되었거나, **'의회에 의해 숙청'**당했다. (로그라이크 회차마다 설정 변경 가능)
- 당신의 책상 위에는 인류의 운명이 걸린 서류와, 차갑게 식은 커피 한 잔뿐이다.
## 2. 메인 시나리오: 멸망을 향한 4막
게임은 **'엔트로피(멸망 수치)'**가 임계점에 도달하는 것을 막으며 진행된다.
### 제1막: 균열 (The Crack)
"단순한 가스 폭발 사고입니다. 그렇게 보고하세요."
- 상황: 도시 곳곳에서 소규모 미스터리 발생 (하수구 괴물, 폴터가이스트).
- 주요 갈등: [은폐 vs 폭로]. 작은 사건들을 덮어서 대중의 공포를 막을 것인가, 아니면 솔직하게 대응할 것인가?
- 분위기: 아직은 통제 가능한 수준의 업무. 튜토리얼 성격의 '일상적 업무'.
- 위기: 예산이 턱없이 부족하다. 플레이어는 초기 자금을 위해 '그림자 경제(괴수 밀매)'에 손을 대기 시작한다.
### 제2막: 붕괴 (The Collapse)
"통제 불능. 제7구역 포기 건의안을 올립니다."
- 상황: S급 괴수 출현. 도시 하나가 봉쇄되거나 지도에서 지워짐. 대중들이 이상함을 눈치챔.
- 주요 갈등: [보수(규제) vs 급진(개방)]. 본격적인 파벌 전쟁의 시작.
- 보수파: "모든 것을 봉쇄하고 원칙대로 처리해!" (플레이어를 답답하게 함)
- 급진파: "규제를 풀고 신무기를 써! 민간인 피해는 어쩔 수 없어!" (플레이어를 타락시킴)
- 위기: 의회가 플레이어의 결재권을 제한하는 **[법안]**을 쏟아내기 시작함.
### 제3막: 혼란 (The Chaos)
"신은 죽었다. 우리가 그 자리를 대신할 뿐."
- 상황: 전 세계적 재난. 국가 기능 마비. I.S.C.O가 사실상 세계 정부 역할을 하게 됨.
- 주요 갈등: [인간성 vs 생존].
- 이제 괴수를 잡는 것만으론 부족하다. 인체 실험, 영혼 추출, 차원 융합 등 '금지된 수단'을 써야만 막을 수 있는 위협들이 등장한다.
- 위기: 엔트로피가 급상승하거나, 신임도가 바닥을 친다. [강행 돌파] 없이는 턴을 넘기기 힘들다.
### 제4막: 심판 (The Judgment)
"최종 결재 바랍니다. 이 세계를 폐기하시겠습니까?"
- 상황: '최종 위협(The End)' 강림. (고대 신, 차원 충돌, 혹은 내부의 반란)
- 주요 갈등: [엔딩 분기]. 그동안 쌓아온 **'지배구조(파벌 지분)'**에 따라 최종 작전이 결정된다.
- 결말: 플레이어는 마지막 도장을 찍는다. 그것이 구원일지, 멸망일지는 아무도 모른다.
## 3. 파벌 별 내러티브 (The Ideologies)
각 파벌은 단순한 아군/적군이 아니라, **"멸망을 막기 위한 서로 다른 철학"**을 대변한다.
### A. 급진/개방 연합 (The Accelerators)
"불은 불로 다스린다. 멸망보다 빠른 속도로 진화하라."
- 1. 철의 수호당 (Iron Guard) - [무력]
- "괴수에게 인권은 없습니다. 방아쇠를 당기십시오."
- 제안: 궤도 폭격, 민간인 구역 전술핵 사용, 즉결 처형 권한.
- 스토리: 군인 출신 강경파. 그들이 득세하면 세계는 **'군사 독재 국가'**가 된다.
- 2. 진화 미래당 (Genesis Project) - [진화]
- "인간의 몸으론 괴물을 이길 수 없습니다. 우리가 괴물이 되어야 합니다."
- 제안: 전국민 DNA 개조 의무화, 초인 병사 양성, 괴수 신체 이식.
- 스토리: 매드 사이언티스트 집단. 그들이 득세하면 인류는 **'새로운 종(Species)'**으로 변해버린다.
- 3. 세계 재건당 (Reshapers) - [패권]
- "숨어서 세상을 구하는 건 지쳤다. 이제 우리가 통치한다."
- 제안: I.S.C.O의 양지화, 각국 정부 해체 및 통합, 미디어 완전 장악.
- 스토리: 엘리트 선민사상. 그들이 득세하면 '빅 브라더' 감시 사회가 된다.
- 4. 차원 무역당 (Nexus Traders) - [자본]
- "위기는 곧 기회입니다. 이 괴수 시체가 얼마짜린지 아십니까?"
- 제안: 괴수 부산물 독점 판매, 이계 문명과의 무역로 개방.
- 스토리: 냉혈한 자본가. 그들이 득세하면 지구는 우주의 **'거대 식민지 시장'**이 된다.
### B. 보수/원칙 연합 (The Stabilizers)
"원칙을 잃으면 우리도 괴물과 다를 바 없다. 질서를 수호하라."
- 1. 지식 보존당 (Librarians) - [지성]
- "죽이면 배울 수 없습니다. 격리하고, 관찰하고, 이해해야 합니다."
- 제안: 사살 금지, 도서관 건립, 고대 언어 해독 프로젝트.
- 스토리: 답답한 학자들. 그들이 득세하면 **'상아탑'**에 갇혀 위기에 늦게 대응하다 멸망할 수 있다.
- 2. 순수 인간당 (Purifiers) - [순수]
- "오직 인간의 힘으로만. 더러운 마법과 변이에 의존해선 안 됩니다."
- 제안: 마법 금지법, 변이체 강제 추방, 기술 감사 강화.
- 스토리: 기술 원리주의자. 그들이 득세하면 **'마녀사냥'**이 시작된다.
- 3. 침묵의 장막당 (Veilkeepers) - [은폐]
- "대중은 진실을 감당할 수 없습니다. 무덤까지 비밀을 가져가십시오."
- 제안: 대규모 기억 소거, 인터넷 차단, 목격자 제거.
- 스토리: 편집증적 비밀주의. 그들이 득세하면 세상은 아무 일도 없는 척하다가 **'조용히 멸망'**한다.
- 4. 고대 공존당 (The Covenant) - [친화]
- "그들은 적이 아닙니다. 달래고 숭배하여 분노를 잠재워야 합니다."
- 제안: 평화 협정, 인신 공양(?!), 성역 지정.
- 스토리: 온건파인 척하는 광신도. 그들이 득세하면 인류는 고대 신들의 **'애완동물'**이 된다.
## 4. 멀티 엔딩 매트릭스 (Ending Matrix)
엔딩은 **[누가 의회를 장악했는가(지분율)]**와 **[세계 상태(엔트로피)]**에 따라 결정된다.
|지배 주주|낮은 엔트로피 (성공적 방어)|높은 엔트로피 (간신히 생존)|
|---|---|---|
|급진파 (Radical)|[신인류의 탄생] 인류는 진화했거나 정복자가 되었다. 하지만 예전의 도덕은 사라졌다. (디스토피아적 승리)|[폭주의 끝] 힘을 제어하지 못해 자멸했다. 혹은 지구 자체가 무기가 되어 우주의 공포가 되었다. (배드 엔딩)|
|보수파 (Conservative)|[위대한 침묵] 세계는 지켜졌으나, 아무도 진실을 모른다. 조직은 영원히 그림자에 남는다. (정사/진엔딩)|[질식사] 너무 많은 것을 금지하다 대응 시기를 놓쳤다. 서류 더미 속에서 맞이하는 멸망. (허무 엔딩)|
|중도/혼란 (Mixed)|[영원한 전쟁] 어느 쪽도 이기지 못했다. 괴수와의 전쟁은 일상이 되었다. (네버엔딩)|[탄핵 및 숙청] 국장은 모든 책임을 지고 처형당한다. 새로운 국장이 부임한다. (게임 오버)|
## 5. 톤 앤 매너 (Tone & Manner)
- 건조한 유머: 세상이 망해가는 와중에도 "식대 청구서 영수증이 누락되었습니다"라고 따지는 부조리함.
- 냉혹한 현실: 영웅적 희생보다는, "예산 10% 절감을 위해 요원 5명을 사지로 보낸다"는 식의 비용 편익 분석.
- 보이지 않는 공포: 괴수의 모습보다는, 괴수가 지나간 자리에 남은 파괴된 도시의 피해액 보고서가 더 무섭게 다가오도록.
# Project SS 스토리 바이블 v2.0
**문서 정보** **문서 정보**
- **프로젝트명:** Project SS (가제: 비밀 결사단) - **프로젝트명:** Project SS (가제: 비밀 결사단)
@@ -229,3 +118,4 @@
3. **보이지 않는 공포 (Unseen Horror):** 3. **보이지 않는 공포 (Unseen Horror):**
- 괴수의 외형을 구체적으로 묘사하기보다, 그들이 지나간 후 남겨진 기괴한 '결과 보고서'를 통해 코스믹 호러를 강조. - 괴수의 외형을 구체적으로 묘사하기보다, 그들이 지나간 후 남겨진 기괴한 '결과 보고서'를 통해 코스믹 호러를 강조.
- _예시:_ "지하 4층 격리실의 문은 닫혀 있습니다만, 안에 있던 요원 12명이 남긴 '체액의 총량'이 인간 12명의 부피를 초과했습니다." - _예시:_ "지하 4층 격리실의 문은 닫혀 있습니다만, 안에 있던 요원 12명이 남긴 '체액의 총량'이 인간 12명의 부피를 초과했습니다."
@@ -0,0 +1,95 @@
**문서 정보**
* **프로젝트명:** Project SS (가제: 비밀 결사단)
* **문서 버전:** v3.1 (코어 루프 및 토큰 경제 내러티브 통합)
* **작성자:** Lead Narrative Designer
* **주제:** 관료주의 코스믹 호러 - "종말을 결재하시겠습니까?"
## 1. 세계관 개요 (The World)
### 1.1. 배경: "얇아진 장막 (The Thinning Veil)"
21세기 현대. 인류는 과학의 승리를 자축하지만, 사실 세계는 얇은 '현실의 장막' 하나로 간신히 유지되고 있습니다. 최근 이 장막이 전 지구적으로 얇아지기 시작하며, 고대 신화 속 괴수, 이계의 침략자, 물리 법칙을 무시하는 변칙 현상들이 일상적으로 튀어나오기 시작했습니다. 세계는 서서히 미쳐가고 있습니다.
### 1.2. 조직: 국제 초자연 통제기구 (I.S.C.O)
UN 상임이사국과 글로벌 메가코프들이 비밀리에 설립한 초법적 기관입니다. 이들의 목표는 인류 문명의 '현상 유지'지만, 현실은 예산 부족과 비효율적인 파벌 싸움에 찌들어 있습니다. 작전부는 인력이 없어 죽어가고, 행정부는 예산을 통제하며 기싸움을 벌입니다. 현장 요원들은 괴수보다 재무 감사팀을 무서워합니다.
### 1.3. 플레이어: "무한한 루프 속의 국장"
당신은 붕괴 직전의 기구를 수습하기 위해 취임한 총괄 국장입니다. 영웅이 아닌, 쏟아지는 비극적인 서류에 스와이프로 도장을 찍는 고독한 결재권자입니다. 당신이 실패하여 멸망을 맞이하더라도 죽음은 안식이 되지 못합니다. 당신은 다시 새로운 무작위 사건과 낯선 지부장들이 배치된 책상 앞에서 강제로 새로운 회차의 국장으로 취임하는 끔찍한 로그라이크 루프에 갇혀 있습니다.
## 2. 핵심 내러티브 테마 (Core Narrative Themes)
게임의 스와이프 시스템과 토큰 경제는 곧 세계관의 비정한 현실을 대변합니다.
**어떤 독을 마실 것인가 (The Poisoned Chalice)**
일상과 위기 딜레마 속에서 안전한 선택지란 없습니다. 예산, 인력, 정보 토큰을 획득하기 위해서는 필연적으로 기구 내부에 부패와 타락을 의미하는 '오염 카드'를 밀어 넣어야 합니다. 오늘 당장의 결재를 위해 내일의 목을 조르는 독을 삼키는 과정입니다.
**모래성 무너뜨리기 (The Sandcastle)**
국장은 재력, 무력, 감시라는 영구 자원 태그를 모아 거대한 통제 엔진을 구축합니다. 하지만 세계의 엔트로피가 차오를수록, 이사회의 표적 규약과 쏟아지는 뉴스 속보가 당신이 공들여 쌓은 방어벽을 잔인하게 허물어뜨립니다.
**연대기의 편찬 (The Chronicle)**
국장의 모든 결재는 되돌릴 수 없는 기록으로 남습니다. 세계의 상흔(내러티브 태그)이 '무엇이 벌어졌는가'를 말한다면, 연대기는 '누가 그렇게 결정했는가'를 말합니다. 플레이어는 회차가 끝난 뒤 이 장부를 되짚으며, 멸망이 사고가 아니라 자신의 서명들이 쌓인 결과였음을 마주합니다.
## 3. 메인 시나리오: 종말의 3단계 페이징 (Story Paging)
시나리오는 획득한 [내러티브 태그]의 누적 개수에 따라 기구의 토큰 경제가 파탄 나는 3가지 페이즈로 진행됩니다.
### 페이즈 1: 사태 파악 및 은폐의 시작 (태그 0~3개)
* **시스템:** 기본 루프 (어떤 독을 마실 것인가)
* **상황:** 도시 곳곳에서 소규모 미스터리가 발생합니다. 일상 딜레마와 위기 딜레마가 주로 등장하며, 국장은 사태를 은폐하기 위해 덱에 오염 카드를 섞어 넣으면서까지 토큰(예산, 인력, 정보)을 비축해야 합니다.
* **내러티브:** "단순한 가스 폭발 사고입니다. 그렇게 보고하시고, 은폐 자금 마련을 위해 오염된 장부를 승인하십시오."
### 페이즈 2: 프로젝트 가동 및 이사회의 개입 본격화 (태그 4~7개)
* **시스템:** 기회 루프 (드디어 턴이 왔다)
* **상황:** 본격적인 작전과 극비 프로젝트가 책상에 올라옵니다. 비축한 토큰을 대량으로 소비하여 덱의 오염 카드를 해결하거나, 자산 및 자원 태그를 빌드업하여 기구의 지배력을 굳히는 시기입니다.
* **내러티브:** "자금은 충분합니다. 이제 우리의 규칙대로 움직일 극비 부서를 신설하고 자산을 확보할 때입니다."
### 페이즈 3: 통제 불능의 사태 및 종말의 카운트다운 (태그 8개 이상)
* **시스템:** 압박 루프 (쌓아둔 모래성 무너뜨리기)
* **상황:** 엔트로피, 내부 저항, 위험 수용 계수가 게임 오버 한계치에 도달합니다. 극악한 오염 카드와 뉴스 속보, 이사회의 표적 규약이 극에 달하며, 국장은 항상 심각한 토큰 부족에 시달립니다.
* **내러티브:** "모든 것이 무너지고 있습니다. 윗선의 탄핵과 바닥의 폭동 사이에서, 남은 것은 텅 빈 금고뿐입니다."
## 4. 그림자 위원회: 13인의 통제자들 (Factions & Leaders)
### 4.1. 상임 이사회 (8대 고정 이념 파벌)
국장의 '통치 사상(성향 태그)'에 직접적으로 반응하는 거대 정치 축입니다. 각 축은 완벽한 적대 관계를 가집니다.
**⚔️ [무력 축] : 대응의 방식**
* **철의 수호당 (Iron Guard):** "괴수에게 인권은 없습니다. 방아쇠를 당기십시오." (철학: 압도적 화력, 즉결 처형, 민간인 희생 불사. 득세 시: 군사 독재 국가)
* **지식 보존당 (Librarians) [RIVAL]:** "죽이면 배울 수 없습니다. 격리하고, 관찰하고, 이해해야 합니다." (철학: 생포, 데이터 수집, 사살 반대. 득세 시: 상아탑에 갇혀 멸망을 기록만 함)
**🧬 [순수 축] : 인류의 형태**
* **진화 미래당 (Genesis Project):** "인간의 몸으론 괴물을 이길 수 없습니다. 우리가 진화해야 합니다." (철학: 신체 개조, 초인 양성, 괴수 DNA 이식. 득세 시: 인류가 새로운 괴물 종으로 변이)
* **순수 인간당 (Purifiers) [RIVAL]:** "오직 순수한 인간의 힘으로만. 더러운 마법과 변이에 의존해선 안 됩니다." (철학: 마법 금지, 변이체 추방, 극단적 순혈주의. 득세 시: 무능력한 마녀사냥의 시대)
**👁️ [패권 축] : 기구의 존재 방식**
* **세계 재건당 (Reshapers):** "숨어서 세상을 구하는 건 지쳤다. 이제 우리가 전면에 나서 통치한다." (철학: 기구 양지화, 정부 해체, 미디어 장악. 득세 시: '빅 브라더' 감시 통제 사회)
* **침묵의 장막당 (Veilkeepers) [RIVAL]:** "대중은 진실을 감당할 수 없습니다. 무덤까지 비밀을 가져가십시오." (철학: 대규모 기억 소거, 인터넷 차단, 은폐. 득세 시: 아무 일도 없는 척하다가 조용히 멸망)
**💰 [외부 축] : 위협과의 관계**
* **차원 무역당 (Nexus Traders):** "위기는 곧 기회입니다. 이 괴수 시체가 암시장에서 얼마짜린지 아십니까?" (철학: 괴수 부산물 판매, 이계 무역로 개방, 민영화. 득세 시: 지구를 우주의 거대 식민 시장으로 매각)
* **고대 공존당 (The Covenant) [RIVAL]:** "그들은 적이 아닙니다. 달래고 숭배하여 분노를 잠재워야 합니다." (철학: 평화 협정, 인신 공양, 고대 신 숭배. 득세 시: 인류는 고대 신들의 애완동물로 전락)
### 4.2. 글로벌 지부장 (12개 실리주의 파벌 풀)
게임 시작 시 이 12개 파벌 중 무작위로 5명이 선발되어 의회에 합류합니다. 이들은 거대 이념보다는 '자원, 태그, 생존' 등 철저한 실리에 따라 우호도가 변동합니다.
* **군산복합 연합 (Military-Industrial Syndicate):** "총알이 떨어지면 다음은 우리 목숨입니다." (무력 태그 스택과 인력 토큰의 축적에 따라 우호도 상승)
* **블랙마켓 카르텔 (Black Market Cartel):** "세상이 망해도 돈은 돌고 돌아야죠." (예산 토큰 축적 및 특정 불법 자산 보유 시 우호도 상승)
* **정보 브로커 연맹 (Data Brokers):** "가장 치명적인 무기는 비밀입니다." (정보 토큰 축적 및 감시 태그 스택에 따라 우호도 상승)
* **인프라 재건 위원회 (Infrastructure Builders):** "기지가 무너지면 우리도 무너집니다." (재력/자본 태그 축적에 반응하며, 파괴 이벤트 발생 시 우호도 급락)
* **여론 통제국 (The Spin Doctors):** "진실은 우리가 편집합니다." (감시/언론 태그에 민감하며, 대중에게 공포를 유발하는 뉴스 이벤트 발생 시 우호도 하락)
* **현장 요원 노조 (Field Agents Union):** "더 이상 요원들을 갈아 넣지 마십시오." (내부 저항 파라미터가 높아지거나 인력 토큰을 무의미하게 소모할 시 우호도 급락)
* **리스크 관리반 (Risk Actuaries):** "도박은 카지노에서나 하십시오." (위험 수용 계수가 낮을수록 우호도가 높으며, 무리한 강행 시 적대적으로 변함)
* **오컬트 수집가 협회 (Occult Collectors):** "저주받은 물건일수록 가치가 높습니다." (신화 오염 카드나 변칙적 내러티브 태그가 덱에 많을수록 기괴하게 우호도 상승)
* **극비 프로젝트 연구소 (Black Ops Labs):** "결과는 수단보다 중요합니다." (극비 프로젝트 카드를 실행하여 새로운 자산을 획득할 때마다 우호도 상승)
* **잔해 청소부 길드 (The Scavengers):** "쓰레기통 속에도 무기가 있습니다." (덱 압축 메커니즘을 통해 오염 카드를 영구 소각할 때마다 우호도 상승)
* **엔트로피 관측소 (Entropy Watchers):** "초침이 12시를 향해 가고 있습니다." (엔트로피 파라미터가 상승할 때마다 극도로 적대적으로 변함)
* **부패 관료 연합 (Corrupt Bureaucrats):** "정치는 뇌물로 완성됩니다." (국장이 예산을 소모해 '매수 액션'을 실행할 때 도덕성 추락과 반비례하여 가장 큰 우호도를 보임)
## 5. 멀티 엔딩 매트릭스 (Ending Matrix)
이 게임의 최종 목표는 멸망을 피하는 것이 아니라, 수십 가지의 파생 엔딩을 모두 수집하여 세계의 모든 이면을 확인하는 것입니다. 엔딩은 덱의 상태, 파벌 우호도, 붕괴 게이지의 조합에 따라 다음 카테고리 중 하나로 결정됩니다.
**기본 붕괴 엔딩**
가장 흔하게 마주하게 될 3가지의 비극적인 기본 결말입니다.
* **사임 (내부 붕괴):** 내부 저항이 5단계(100%)에 도달할 시 발동합니다. 수많은 딜레마 속에서 조직원들을 소모품처럼 갈아 넣다 결국 장악력을 잃고 쫓겨난 결과입니다.
* **세계 멸망 (외부 붕괴):** 엔트로피가 5단계(100%)에 도달할 시 발동합니다. 내러티브 태그를 방치하고 신화 오염 카드의 증식을 방관하여 세상이 타락해 버린 결과입니다.
* **정치적 탄핵 (윗선 붕괴):** 주간 의회 단계에서 적대적 파벌 임원이 9명(2/3)을 넘어 탄핵안이 상정되었을 때, 이를 방어할 자원(정치적 개입)이 부족하여 가결된 결과입니다.
**변주된 붕괴 엔딩**
국장이 어떤 유산을 남겼느냐에 따라 텍스트와 결과가 미묘하게 뒤틀립니다.
* **자원 태그 변주:** 위 기본 붕괴 엔딩 조건을 충족했을 때, 특정 영구 자원 태그(재력, 무력, 감시)를 압도적으로 많이 보유하고 있다면 발동합니다.
* **토큰 변주:** 위 기본 붕괴 엔딩 조건을 충족했을 때, 특정 소모성 토큰을 비정상적으로 많이 쥐고 있다면 발동합니다.
**특수 진엔딩 및 조기 엔딩**
단순한 파라미터 오버플로우가 아닌, 국장의 치밀한 설계로 달성할 수 있는 고난도 엔딩입니다.
* **극단적 파라미터 조기 엔딩:** 특정 파라미터(예: 위험 수용 계수 등)를 극단적으로 낮거나 비정상적인 상태로 만들었을 때, 강제로 조기 발동하는 특수 종료 컷신입니다.
* **내러티브 연쇄 엔딩:** 파벌의 간섭을 피해 특정한 내러티브 사건(태그)들을 정확한 순서대로 수집했을 때만 열리는 진실에 가까운 엔딩입니다.
* **의회 정치 엔딩:** 탄핵안이 가결되어 쫓겨나더라도, 의회의 수장이 보유한 '성향 태그'에 완벽히 부합하는 조건을 달성해 두었다면 오히려 의회가 국장의 뜻대로 움직여주는 정치적 승리 엔딩입니다.
## 6. 톤 앤 매너 (Tone & Manner)
작가는 다음 3가지 원칙을 준수하여 게임 내 텍스트(이벤트, 플레이버 텍스트 등)를 작성합니다.
**건조한 블랙 유머 (Bureaucratic Absurdity)**
세상이 망해가는 와중에도 행정적 절차를 따지는 부조리함.
* *예시:* "S급 괴수가 7구역을 짓밟고 있습니다. 그런데 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려되었습니다."
**냉혹한 비용 편익 분석 (Cold Reality)**
영웅적 희생이나 도덕적 호소가 아닌, 철저히 '숫자'로 환산되는 생명.
* *예시:* "시민 10만 명을 구출하는 비용보다, 그들을 포기하고 나중에 유가족에게 합의금을 주는 것이 예산을 15% 절감할 수 있습니다."
**보이지 않는 공포 (Unseen Horror)**
괴수의 외형을 구체적으로 묘사하기보다, 그들이 지나간 후 남겨진 기괴한 '결과 보고서'를 통해 코스믹 호러를 강조.
* *예시:* "지하 4층 격리실의 문은 닫혀 있습니다만, 안에 있던 요원 12명이 남긴 '체액의 총량'이 인간 12명의 부피를 초과했습니다."
+80
View File
@@ -0,0 +1,80 @@
# 1차 작성
## 1. 제품 개요 (Product Overview)
- **프로젝트명**: Project SS (가제)
- **장르**: 서사형 덱빌딩 게임
- **타겟 플랫폼**: 모바일 (터치/스와이프) 및 스팀 (PC)
- **로그라인**: "선택은 독이 되고, 독은 다시 엔진이 된다. 비밀 조직의 국장이 되어 끝없는 딜레마를 스와이프하고, 다가오는 멸망과 의회의 탄핵 사이에서 당신만의 결말을 수집하는 서사형 덱빌딩 게임."
- **핵심 목표**: 플레이어가 수십 가지의 파생 엔딩을 모두 수집하도록 다회차 플레이(로그라이크 루프)를 유도하는 것.
## 2. 핵심 게임플레이 루프 (Core Gameplay Loop)
게임은 다음과 같은 로그라이크 사이클로 반복 실행되어야 합니다.
1. **초기화 (시작)**: 무작위 사건(내러티브) 카드와 무작위 글로벌 지부장(5명)이 세팅된 상태로 게임이 시작됩니다.
2. **플레이 (결재/덱 빌딩)**: 플레이어는 스와이프를 통해 토큰을 관리하고, 덱에 [오염 카드]를 밀어 넣거나 [자산 카드]를 획득합니다.
3. **위기 대응 (드로우)**: 덱에 섞여 들어간 오염 카드가 등장할 때, 구축한 [자원 태그] 엔진과 비축한 토큰을 소모하여 방어합니다.
4. **종료 (엔딩)**: 붕괴 게이지, 파벌 우호도, 덱 상태의 조합에 따라 도감에 기록될 엔딩을 수집하고 다음 회차로 넘어갑니다.
## 3. 핵심 시스템 요구사항 (Core System Requirements)
### 3.1. 파라미터 및 자원 시스템
플레이어의 행동을 제한하고 평가하는 핵심 변수들입니다.
| **분류** | **항목** | **스펙 및 동작 방식** |
| --------------------------- | ------------------------ | ------------------------------------------ |
| **핵심 파라미터** (0~100%, 1~5단계) | 내부 저항 | 국장에 대한 조직 반발력. 5단계 도달 시 '사임(내부 붕괴)' 엔딩 발동. |
| | 엔트로피 | 종말 카운트다운. 5단계 도달 시 '세계 멸망(외부 붕괴)' 엔딩 발동. |
| | 대중 공황 | 인류 문명이 미쳐가는 척도. 5단계 도달 시 '문명 붕괴(사회 붕괴)' 엔딩 발동. |
| | 위험 수용 계수 | 위원회의 위기감 척도. 탄핵 및 법안 상정의 주요 기준치로 작동. |
| **소모성 자원 (토큰)** | 예산 / 인력 / 정보 | 스와이프 액션 비용으로 즉시 소모 및 획득되는 유동성 자원. |
| **영구 자원 (태그)** | 재력(자본) / 무력(조직) / 감시(언론) | 방어 및 액션 시 영구적인 1코스트 할인 효과를 제공하는 스택형 엔진. |
### 3.2. 결재 시스템 (스와이프 딜레마)
카드 타입에 따라 플레이어에게 강제되는 좌/우/위 스와이프의 결과값입니다.
- **일상 딜레마**: 좌/우 모두 토큰 획득 및 덱 오염 발생.
- **위기 딜레마**: (좌) 토큰 획득 + 덱 오염 vs (우) 토큰 소모로 위기 회피.
- **작전**: (좌) 자원 태그 획득 + 덱 오염 vs (우) 토큰 소모 + 대량 자원 태그 획득.
- **극비 프로젝트**: 좌/우 모두 토큰 소모 후 '자원 태그' 또는 '자산' 획득. 위로 스와이프 시 버리기 가능.
- **위기 강타 (오염 카드)**: 신화, 조직 저항, 사회 공황, 이사회 압박 카드. (좌) 토큰/자원 차감 vs (우) 위험 단계 상승.
- **뉴스 이벤트**: (좌) 자산 1개 제거 vs (우) 위험 단계 상승.
### 3.3. 자산 및 덱 압축 시스템 (인벤토리)
오염 카드를 제어하기 위해 무제한 인벤토리에 누적되는 패시브 장치입니다.
- **단일 효과 자산**: 성질 변환(독을 약으로), 시한부 격리(조건부 복귀), 물리적 영구 소각, 결재권 강제 행사(무효화).
- **파이프라인 연쇄 반응 (핵심 시너지)**: 입력(트리거) -> 변환(매개체) -> 출력(덱 압축) 노드를 연결하여 오염 카드를 자동으로 처리하는 엔진 빌딩 기능 지원.
### 3.4. 주간 의회 정치 시스템
- **그림자 위원회 구성**: 고정 이념을 가진 상임 이사회 8명 + 실리 위주의 글로벌 지부장 5명 (매 회차 무작위).
- **법안 시스템**: 파벌 동맹 시 '이득 규약(+자원)', 파국 시 '표적 규약(-자원)' 상정.
- **탄핵 및 국장의 개입**: 적대 임원이 9명 이상일 때 탄핵안 상정. 국장은 다음 액션으로 개입 가능:
- **매수**: [예산] 소모. 임원 강제 찬성 전환. 페널티: 위험 수용 계수 상승.
- **장부 조작**: [정보] 소모. 법안 상정 이월. 페널티: 엔트로피 상승.
- **내부 감사**: [인력] 소모. 임원 기권 처리. 페널티: 내부 저항 상승.
## 4. 콘텐츠 진행 및 페이징 (Progression)
게임의 진행은 '시간의 흐름(결재 건수)'과 '스토리 진행(내러티브 태그)'이라는 두 가지 축을 기준으로 전개됩니다.
#### 4.1. 시간의 흐름과 라운드 시스템 (Time & Rounds)
- **기초 단위**: 결재 1건 (플레이어의 카드 스와이프 1회).
- **라운드 (Round)**: '제 X차 그림자 의회 회의'로 구분되는 주기 단위. 정해진 결재 건수(XX건)가 모일 때마다 하나의 라운드가 종료되며 의회가 개최됩니다.
- **종료 조건**: 라운드가 계속 반복되며 의회의 압박을 방어하다가, 획득한 내러티브 태그가 8개 이상이 되어 종말 카운트다운 페이즈를 돌파하면 최종 엔딩이 트리거됩니다.
#### 4.2. 페이즈 전환과 덱 풀 변경 (Story Phases)
플레이어가 획득한 [내러티브 태그]의 스택에 따라 게임의 페이즈가 강제 전환됩니다. 페이즈가 전환될 때마다 덱에서 등장하는 딜레마 카드의 풀(Pool)이 극적으로 변경되며, 이는 반복되는 로그라이크 루프에서 게임이 지루해지는 것을 방지하는 핵심 장치입니다.
- **페이즈 1 (태그 0~3개)**: 사태 파악 단계. 일상/위기 딜레마 중심으로 토큰 수급과 덱 오염 빌드업.
- **페이즈 2 (태그 4~7개)**: 본격화 단계. 작전/극비 프로젝트가 등장하여 엔진 구축 및 자산 획득의 기회 제공.
- **페이즈 3 (태그 8개 이상)**: 종말 카운트다운. 극한의 오염 카드와 뉴스 속보가 쏟아지는 자원 고갈의 생존 구간.
## 5. 최종 목표: 멀티 엔딩 매트릭스 도감
다양한 엔딩을 수집하여 도감에 기록합니다. 회차의 여정은 두 축으로 분리되어 기록됩니다.
- **연대기(Chronicle)**: 국장이 결재한 카드와 좌/우/위 선택을 시간순으로 기록한 결정 이력.
- **내러티브 태그**: 그 결재의 결과로 세계에 남은 상흔이자 엔딩 조건. 연대기와는 별개로 관리·저장됩니다.
- **수집해야 할 엔딩 카테고리**:
- 기본 4대 붕괴 엔딩 (사임, 멸망, 문명 붕괴, 탄핵)
- 파라미터 극단화 강제 종료 엔딩
- 특정 토큰 및 자원 보유 상태에 따른 변주 붕괴 엔딩
- 내러티브 연쇄 조건 달성 엔딩
- 의회 정치 조건부 엔딩
# 2차 작성
+59
View File
@@ -0,0 +1,59 @@
제공해주신 기획서를 바탕으로 프로젝트의 안정적인 빌드업을 위한 **개발 순서(Development Roadmap)** 문서를 작성했습니다. 기획의 핵심인 '핵심 루프 검증'을 최우선으로 두고, 시스템을 점진적으로 확장해 나가는 애자일(Agile) 형태의 마일스톤으로 구성했습니다.
각 페이즈의 마일스톤들은 Todoist의 에픽(Epic)과 태스크(Task)로 분할하여 관리하기 용이하도록 구조화했습니다.
# 🚀 게임 개발 순서 (Development Roadmap)
## Phase 0: 프로젝트 환경 세팅 및 아키텍처 설계
본격적인 개발에 앞서 코드 작성 환경과 DevOps 파이프라인을 구축하는 단계입니다.
- **작업 항목**:
- Cursor IDE 등 AI 지원 개발 환경 세팅 및 기획 문서(CODE 구조) 동기화.
- Gitea를 활용한 자체 버전 관리 리포지토리 생성 및 Gitea Runner를 연동한 자동화 빌드 파이프라인(CI/CD) 기초 세팅.
- 게임 엔진(Unity/Godot 등) 프로젝트 생성 및 모바일/PC 크로스 플랫폼 해상도 대응 기초 뼈대 구축.
- 데이터 테이블(CSV/JSON) 파서 구현: 카드, 파라미터, 임원 정보 등을 외부 스프레드시트에서 관리하고 엔진으로 불러오는 구조 확립.
## Phase 1: 코어 루프 프로토타이핑 (텍스트 기반)
시각적 요소는 완전히 배제하고 네모난 더미 UI와 텍스트만으로 스와이프 조작과 붕괴 파라미터의 변동성을 테스트합니다.
- **작업 항목**:
- **카드 스와이프 시스템**: 화면 중앙의 카드를 좌, 우, 위로 스와이프할 수 있는 터치/마우스 이벤트 구현.
- **4대 핵심 속성 파라미터 연동**: 내부 저항, 엔트로피, 대중 공황, 위험 수용 계수(0~100%, 1~5단계) UI 구현 및 스와이프 결과에 따른 증감 로직 적용.
- **단일 딜레마 테스트**: 일상 딜레마(좌/우 스와이프 시 덱 오염) 카드를 무한 드로우하며 파라미터가 5단계에 도달해 강제 종료되는지 확인.
## Phase 2: 자원 경제 및 로그라이크 덱빌딩 로직
토큰의 수급과 소모, 그리고 덱의 오염과 압축이 일어나는 '토큰 경제'와 '엔진 빌딩'을 구현합니다.
- **작업 항목**:
- **자원/토큰 시스템**: 소모성 자원(예산, 인력, 정보) 및 영구 자원 태그(재력, 무력, 감시) 변수 및 UI 구현.
- **딜레마 카드 세분화**: 위기 딜레마, 작전, 극비 프로젝트, 위기 강타, 뉴스 이벤트 카드의 각 좌/우 스와이프별 자원 증감 및 덱 오염/조건부 처리 로직 구현.
- **무제한 인벤토리 및 자산 시스템**: 획득한 자산을 하단 인벤토리에 무제한으로 누적하는 UI 구현.
- **파이프라인 연쇄 반응 기초**: 자산의 입력-변환-출력 메커니즘을 구현하여 특정 오염 카드 등장 시 자동으로 자원이 변환되거나 덱이 압축되는 기능 테스트.
## Phase 3: 정치 시스템 및 스토리 페이징 통합
게임의 거시적인 텐션을 조율하는 그림자 위원회 투표 시스템과 스토리 진행에 따른 페이즈 전환을 구현합니다.
- **작업 항목**:
- **그림자 위원회 AI**: 상임 이사회(8명)와 무작위 글로벌 지부장(5명) 객체 생성 및 국장의 성향/자원 태그에 따른 우호도 산출 알고리즘 구현.
- **의회 투표 기믹**: 우호도에 따른 법안/탄핵 찬반 판별 및 표결 결과 연출.
- **국장의 개입 액션**: 매수(예산 소모), 장부 조작(정보 소모), 내부 감사(인력 소모) 액션 발동 및 리스크(위험 단계 상승) 연동.
- **스토리 기반 페이징**: 내러티브 태그 누적치(0~3, 4~7, 8 이상)에 따라 페이즈 1~3으로 전환되며 등장하는 카드 풀(Pool)이 실시간으로 변경되는 로직 구현.
## Phase 4: 멀티 엔딩 매트릭스 및 연대기 (콘텐츠 완성)
게임의 최종 목표인 도감 수집 시스템과 플레이어의 여정을 기록하는 기능을 추가합니다.
- **작업 항목**:
- **엔딩 분기 시스템**: 파라미터 5단계 도달 시, 현재 보유한 토큰/자원 태그를 검사하여 기본 붕괴 엔딩 또는 변주된 엔딩으로 분기시키는 판별기 구현.
- **특수 엔딩 트리거**: 극단적 파라미터, 내러티브 연쇄, 의회 정치 조건 달성 시 발동하는 조기 엔딩 이벤트 구현.
- **도감 및 연대기(Chronicle)**: 해금된 엔딩을 저장하는 도감 UI 구현. 연대기 기능(결재 카드·선택 이력 로그)과 내러티브 태그 뷰('세계의 상흔', 태그 키워드+스니펫 히스토리)를 별도의 UI/팝업으로 분리 구현.
## Phase 5: 비주얼 폴리싱 및 다이제틱 UX
핵심 로직이 모두 검증된 후, 기획 단계에서 논의된 추상적이고 몰입감 있는 피드백을 적용합니다.
- **작업 항목**:
- **시각적 오염도 연출**: 덱의 오염도에 따라 드로우되는 카드의 테두리가 타거나 얼룩지는 셰이더/머티리얼 효과 적용.
- **햅틱 및 마찰력 피드백**: 파라미터가 위험하거나 오염도가 높을 때 스와이프에 저항감을 주고 진동/화면 흔들림을 주는 UX 연출.
- **오디오 및 앰비언트 동기화**: 페이즈 전환(1->3)에 따라 배경 조명이 붉게 변하거나 사운드가 무거워지는 연출 적용.
+80
View File
@@ -0,0 +1,80 @@
제공해주신 시스템 기획서를 바탕으로, 게임의 어둡고 관료적인 디스토피아 테마를 시각적·감각적 수치로 규격화한 **디자인 토큰(Design Tokens)** 문서를 작성했습니다.
이 문서는 UI/UX 디자이너와 클라이언트 개발자가 일관된 시각 언어를 사용하기 위한 핵심 참조 데이터가 됩니다.
# 🎨 디자인 토큰 (Design Tokens)
## 1. 컬러 시스템 (Color System)
비밀 조직의 차가운 관료제와 붕괴해 가는 세계의 절망감을 표현하기 위해, 채도를 낮춘 무채색 기반에 특정 파라미터(위험, 파괴)를 강조하는 원색을 포인트 컬러로 사용합니다.
### 1.1. 파라미터 및 경고 (Core Parameters)
4대 핵심 속성 파라미터의 위험도를 직관적으로 전달하는 컬러 토큰입니다. (1~5단계 진행도에 따라 채도 및 명도 증가)
- **`color-entropy-base`**: `#5C3A21` (부패하는 세계의 탁한 갈색) - 엔트로피 지표 표기용.
- **`color-entropy-critical`**: `#8B0000` (검붉은 핏빛) - 엔트로피 4~5단계 경고용.
- **`color-panic-base`**: `#4A148C` (혼돈과 광기의 보라색) - 대중 공황 지표 표기용.
- **`color-panic-critical`**: `#9C27B0` (밝은 네온 퍼플) - 대중 공황 4~5단계 경고용.
- **`color-resistance-base`**: `#2F4F4F` (차가운 심연의 청록색) - 내부 저항 지표 표기용.
- **`color-resistance-critical`**: `#00FFCC` (날카로운 네온 시안) - 내부 저항(파업/반란) 임박 경고용.
- **`color-risk-base`**: `#B8860B` (어두운 황금색) - 위험 수용 계수(이사회 압박) 지표 표기용.
- **`color-risk-critical`**: `#FFD700` (경고등의 쨍한 노란색) - 위험 수용 계수 4~5단계 및 탄핵 경고용.
### 1.2. 자원 및 토큰 (Tokens & Tags)
국장이 결재 시 관리해야 할 소모성 자원 및 영구 자원을 구분하는 컬러입니다.
- **`color-token-budget`**: `#4CAF50` (탁한 지폐의 녹색) - 예산 토큰 표기용.
- **`color-token-personnel`**: `#E53935` (피복/혈액의 붉은색) - 인력 토큰 표기용.
- **`color-token-info`**: `#3F51B5` (청사진/보안의 파란색) - 정보 토큰 표기용.
- **`color-tag-neutral`**: `#E0E0E0` (빛바랜 서류의 회백색) - 재력, 무력, 감시 자원 태그 활성화 시 공통 베이스 컬러.
### 1.3. 스와이프 인터랙션 피드백 (Swipe Intent)
딜레마 카드를 넘기는 방향에 따른 암시적 컬러 피드백입니다.
- **`color-swipe-left`**: `#D32F2F` (오염/손실 암시 - 덱 오염 발생 시 화면 테두리 이펙트).
- **`color-swipe-right`**: `#388E3C` (투자/해결 암시 - 토큰 소모로 위기를 넘길 때).
- **`color-swipe-up`**: `#757575` (보류/버리기 암시 - 극비 프로젝트 취소 시).
## 2. 타이포그래피 (Typography)
서류 결재라는 테마를 살리기 위해, 타자기로 친 듯한 모노스페이스 폰트와 관공서 느낌의 명조/고딕 폰트를 혼용합니다.
- **`font-family-doc-title`**: `Serif (명조체 계열)` - 극비 프로젝트, 위기 강타 등 카드의 제목 표기용.
- **`font-family-typewriter`**: `Monospace (타자기 폰트)` - 딜레마 카드의 본문 텍스트 및 내러티브 태그 설명용.
- **`font-family-ui`**: `Sans-Serif (고딕체 계열)` - 파라미터 수치(0~100%), 자원 보유량 등 직관적인 UI 정보 표기용.
## 3. 아이코노그래피 (Iconography)
UI의 여백을 확보하고 정보를 직관적으로 전달하기 위한 기호 체계입니다.
|**자원/요소**|**아이콘 토큰 (Icon Token)**|**시각적 묘사**|
|---|---|---|
|**재력/자본 태그**|`icon-tag-wealth`|**건물 아이콘** (마천루나 은행 실루엣).|
|**무력/조직 태그**|`icon-tag-force`|**방패 아이콘** (경찰/군사 조직의 상징).|
|**감시/언론 태그**|`icon-tag-surveillance`|**눈동자 아이콘** (CCTV 또는 전시안의 형태).|
|**예산 토큰**|`icon-token-budget`|지폐 뭉치 또는 동전 스택.|
|**인력 토큰**|`icon-token-personnel`|넥타이를 맨 사람의 흉상 또는 핏방울.|
|**정보 토큰**|`icon-token-info`|서류철 또는 플로피 디스크.|
|**오염 카드**|`icon-deck-pollution`|카드의 모서리에 묻은 검은 얼룩이나 불탄 자국 (시각적 오염도 연출용).|
## 4. 인터랙션 및 모션 토큰 (Interaction & Motion)
스와이프와 결재 과정에서 느껴지는 관료제 특유의 묵직함을 수치화합니다.
### 4.1. 스와이프 물리 엔진 (Swipe Physics)
- **`motion-swipe-friction-normal`**: `0.8` - 덱이 깨끗한 상태(페이즈 1)일 때 카드가 부드럽게 넘어가는 기본 저항값.
- **`motion-swipe-friction-heavy`**: `1.5` - 오염 카드가 많거나, 위험 단계가 높을 때 카드가 뻑뻑하게 끌려가는 저항값 (압박감 연출).
- **`motion-snap-duration`**: `0.3s` - 결재(스와이프) 완료 후 카드가 화면 밖으로 날아가는 애니메이션 시간.
### 4.2. 페이즈 전환 앰비언트 (Phase Ambient)
내러티브 태그 누적치에 따른 환경 변화 연출 토큰입니다.
- **`ambient-phase-1`**: `opacity 0.0` - 태그 0~3개. 깔끔하고 차분한 사무실 조명.
- **`ambient-phase-2`**: `opacity 0.4` - 태그 4~7개. 미세하게 화면 가장자리에 비네팅(어두운 테두리) 생성.
- **`ambient-phase-3`**: `opacity 0.8` - 태그 8개 이상. 화면 테두리가 붉게 점멸하며 사이렌을 연상시키는 극한의 비네팅.
## 5. 햅틱 피드백 토큰 (Haptic/Vibration)
모바일 기기의 진동을 통해 플레이어에게 직접적인 물리적 압박감을 전달합니다.
- **`haptic-swipe-tick`**: `Light (10ms)` - 일상 딜레마를 스와이프할 때의 가벼운 종이 넘김 느낌.
- **`haptic-stamp-success`**: `Medium (30ms)` - 토큰을 얻거나, 자원 태그/자산을 획득하여 투자가 성공적으로 이루어졌을 때.
- **`haptic-crisis-hit`**: `Heavy (50ms)` - 위기 강타(신화, 조직 저항 등)가 발동하여 붕괴 파라미터가 상승할 때의 충격.
- **`haptic-chain-reaction`**: `Successive Light (10ms x 3)` - 무제한 인벤토리에서 파이프라인 연쇄 반응(입력-변환-출력)이 발동하여 오염 카드가 자동 처리될 때의 짜릿한 연속 진동.
+42
View File
@@ -0,0 +1,42 @@
# 1차 작성
## 1. 핵심 개요 (Core Overview)
- **장르**: 서사형 덱빌딩 게임
- **타겟 플랫폼**: 모바일 및 스팀 (크로스 플랫폼)
- **궁극적 게임 목표**: 모든 엔딩(수십 가지의 파생 엔딩) 보기
- **핵심 경험**: 플레이어는 무작위 사건(내러티브)과 무작위 지부장이 배치된 채 비밀 조직의 국장으로 취임하여, 스와이프 결재를 통해 세상을 통제하거나 파멸을 맞이하게 됩니다.
## 2. 고유 판매 포인트 (USP: Unique Selling Points)
- **딜레마 중심의 덱 빌딩**: 단순한 스와이프 조작(좌/우/위)으로 일상, 위기, 극비 프로젝트 등의 딜레마를 선택하며, 이 과정에서 필연적으로 덱에 오염 카드가 섞여 들어가는 지독한 자원 관리의 재미를 제공합니다.
- **그림자 의회 정치 시스템**: 13명의 그림자 위원회(상임 8명, 글로벌 5명)를 상대로 매수, 장부 조작, 내부 감사 등의 강력한 개입 액션을 통해 탄핵을 방어하고 법안을 통제하는 정치적 긴장감을 구현합니다.
- **무제한 파이프라인 엔진 빌딩**: 자산 카드를 무제한 인벤토리에 누적하여 입력-변환-출력으로 이어지는 '파이프라인 연쇄 반응'을 구축하고, 덱의 오염 카드를 자동으로 처리하는 압도적인 덱 압축의 쾌감을 선사합니다.
- **스토리 기반 난이도 페이징**: 플레이어가 획득한 내러티브 태그의 누적치에 따라 페이즈가 1에서 3까지 유기적으로 전환되며, 종말의 카운트다운을 직관적으로 체감할 수 있습니다.
## 3. 핵심 게임플레이 루프 (Core Gameplay Loop)
게임은 다음의 로그라이크 루프를 반복하며 다회차 플레이를 유도합니다.
1. **시작**: 무작위 사건(내러티브)과 무작위 지부장이 배치된 채 국장 취임.
2. **결재 (덱 빌딩)**: 스와이프를 통해 예산, 인력, 정보 토큰을 관리하며, 덱에 오염 카드를 밀어 넣거나 자산 카드를 확보함.
3. **위기 (드로우)**: 덱에 넣은 오염 카드(신화, 조직저항, 이사회의 압박)가 튀어나올 때, 자원 태그 엔진과 남은 토큰으로 방어함.
4. **엔딩 (결과)**: 덱의 상태, 파벌 우호도, 붕괴 게이지의 조합에 따라 파생 엔딩 중 하나를 수집하고 다음 회차로 루프함.
## 4. 핵심 시스템 아키텍처 (Core System Architecture)
### A. 거시적 위험 관리 (4대 핵심 속성 파라미터)
국장의 행보는 0~100%(1~5단계)로 구성된 4가지 핵심 수치에 직접적인 영향을 받으며, 5단계 도달 시(위험 수용 계수 제외) 즉시 붕괴 엔딩으로 이어집니다.
- **내부 저항 (Internal Resistance)**: 국장에 대한 조직 내부의 반발력 (사임 엔딩 트리거).
- **엔트로피 (Entropy)**: 세계가 미쳐가는 종말 카운트다운 (세계 멸망 엔딩 트리거).
- **대중 공황 (Mass Panic)**: 인류 문명이 미쳐가는 척도 (문명 붕괴 엔딩 트리거).
- **위험 수용 계수 (Risk Tolerance)**: 위원회가 국장의 행보를 보고 느끼는 위기감 (보수적/강압적 변화).
### B. 자원 및 토큰 경제
- **소모성 토큰**: 예산 토큰, 인력 토큰, 정보 토큰.
- **영구 자원 태그 (1코스트 할인 효과)**: 재력/자본 태그(건물), 무력/조직 태그(방패), 감시/언론 태그(눈동자).
### C. 엔딩 도감 및 연대기 (Chronicle)
- **연대기 시스템**: 국장이 결재한 카드와 좌/우/위 선택을 시간순으로 기록한 결정 장부입니다. 세계가 어떻게 되었는가(세계의 상흔)는 내러티브 태그가 별도로 담당하며, 연대기는 '누가 그렇게 결정했는가'라는 원인 축을 기록해 몰입도를 강화합니다.
- **멀티 엔딩 매트릭스**: 기본 붕괴 엔딩 외에도 특정 자원/토큰과 결합된 변주 엔딩, 극단적 파라미터 조기 엔딩, 내러티브 연쇄 엔딩, 의회 정치 엔딩 등 수십 가지의 결과를 수집해야 합니다.
# 2차 작성
+86
View File
@@ -0,0 +1,86 @@
제공해 주신 시스템 기획서를 바탕으로, 플레이어가 게임에 진입하여 종료(엔딩)에 이르기까지 겪게 되는 모든 논리적 흐름과 분기점을 정리한 **유저 플로우(User Flow)** 문서를 작성했습니다.
이 문서는 프로그래머가 게임의 상태 머신(State Machine)과 화면 전환 로직을 구현할 때 핵심 지도로 활용됩니다.
# 🔄 유저 플로우 (User Flow)
## 1. 거시적 게임 루프 플로우 (Macro Game Loop)
플레이어가 게임 앱을 실행하고 다회차 플레이를 반복하는 전체적인 흐름입니다.
1. **게임 실행 및 메인 타이틀**
- [새 게임 시작] / [도감 확인] / [설정] 선택.
2. **게임 초기화 세팅**
- 무작위 사건(내러티브) 덱 생성.
- 무작위 글로벌 지부장(5명) 배치 및 상임 이사회(8명) 포함 총 13명의 그림자 위원회 구성.
- 국장 취임 (파라미터 0%, 기본 자원 지급).
3. **핵심 플레이 페이즈 (반복)**
- **결재 루프**: 딜레마 카드 드로우 및 스와이프 결정 (토큰 관리, 덱 빌딩).
- **스토리 페이징 검사**: 획득한 [내러티브 태그] 개수(0~3, 4~7, 8 이상)에 따라 페이즈 1~3 실시간 전환 및 카드 풀(Pool) 변경.
- **주간 의회 루프**: 일정 턴(결재 횟수) 도달 시 주간 의회 단계 진입하여 법안/탄핵 방어.
4. **종료 조건 검사 (매 액션 직후)**
- [내부 저항], [엔트로피], [대중 공황] 중 하나라도 5단계(100%) 도달 시 즉시 게임 오버 (붕괴 엔딩).
- 특정 내러티브 연쇄 달성 또는 파라미터 극단화 시 조기 엔딩 발동.
5. **결산 및 루프**
- 수집한 파생 엔딩 도감 기록.
- 다음 회차(1번 단계)로 루프.
## 2. 결재 단계 미시 플로우 (Micro Flow: Swipe Decision)
화면 중앙의 딜레마 카드를 처리하는 순간의 상세한 논리적 흐름입니다.
1. **카드 드로우 및 표시**
- 현재 페이즈와 덱의 상태(오염도)에 따라 딜레마 카드 1장이 화면에 등장.
2. **자동화 엔진(자산) 개입 검사**
- _Condition_: 인벤토리에 파이프라인 연쇄 반응 자산이 있고, 등장한 카드가 해당 자산의 [트리거]에 부합하는가?
- `Yes` -> 자산 효과(입력-변환-출력) 자동 발동 -> 오염 카드 파쇄 또는 자원 획득 등 처리 후 다음 카드 드로우.
- `No` -> 플레이어의 조작 대기.
3. **플레이어 스와이프 조작**
- 플레이어가 카드를 누르고 방향을 지정하여 결과를 미리보기 함.
4. **선택에 따른 분기 및 결과 적용**
- `[일상 딜레마]` 선택: 좌/우 방향에 따른 특정 토큰 획득 및 덱에 오염 카드 셔플.
- `[위기 딜레마]` 선택: (좌) 토큰 획득+오염 카드 셔플 / (우) 토큰 소모하여 위기 회피.
- `[작전]` 선택: (좌) 자원 태그 획득+오염 카드 셔플 / (우) 토큰 소모하여 더 많은 자원 태그 획득.
- `[극비 프로젝트]` 선택: (좌/우) 토큰 소모하여 보상(자원/자산) 획득 / (위) 보류 및 버리기.
- `[위기 강타(오염 카드)]` 등장: (좌) 토큰/자원 차감 페널티 / (우) 4대 위험 단계(엔트로피, 내부 저항, 대중 공황, 위험 수용 계수) 중 하나 상승.
- `[뉴스 이벤트]` 등장: (좌) 보유 자산 제거 페널티 / (우) 4대 위험 단계 중 하나 상승.
5. **공통 스택 누적**
- 결과에 따라 내러티브 태그(연대기 누적) 및 성향 태그(파벌 우호도 연동) 획득.
6. **다음 턴 진행**
## 3. 주간 의회 단계 플로우 (Weekly Council Flow)
의회 이벤트 발생 시 국장이 개입하여 위기를 넘기는 흐름입니다.
1. **의회 이벤트 발생**
- 현재 파벌 우호도 및 국장의 상태 계산.
2. **안건 상정 분기 검사**
- _Condition 1_: 적대적 파벌 임원이 2/3(9명) 이상인가?
- `Yes` -> **[정치적 탄핵안 상정]**
- `No` -> _Condition 2_: 현재 의회의 주도권이 동맹인가 파국인가?
- `동맹` -> **[이득 규약 법안 상정]** (+자원 태그)
- `파국` -> **[표적 규약 법안 상정]** (-자원 태그)
3. **예상 표결 표시**
- 13명 임원의 우호도에 따른 예상 투표(찬성/반대/미정) 현황 출력.
4. **국장의 개입 액션 (선택적)**
- 플레이어는 자원을 소모하여 개입하거나 개입 없이 투표 진행 가능.
- `[매수 액션]`: 예산 대량 소모 -> 임원 1~2명 강제 찬성 / 위험 수용 계수 +20%.
- `[장부 조작 액션]`: 정보 대량 소모 -> 법안 상정 무효화 및 이월 / 엔트로피 +20%.
- `[내부 감사 액션]`: 인력 대량 소모 -> 적대 임원 투표권 박탈(기권) / 내부 저항 +20%.
5. **최종 투표 및 결과 적용**
- 개입이 반영된 최종 투표 결과 출력.
- 법안 통과 시 자원 태그 증감 적용, 탄핵 가결 시 즉시 [정치적 탄핵 붕괴 엔딩]으로 게임 오버 분기.
## 4. 엔딩 분기 및 도감 플로우 (Ending Branch Flow)
게임 오버 조건이 충족되었을 때, 어떤 엔딩을 출력할지 결정하는 흐름입니다.
1. **엔딩 트리거 발동**
- 파라미터 5단계 도달, 탄핵 가결, 특정 내러티브 달성 등 종료 조건 진입.
2. **엔딩 매트릭스 조건 검사 (우선순위 판별)**
- _Check 1_: [의회 정치 엔딩] 조건(탄핵+수장 성향 조건)에 부합하는가?
- _Check 2_: [내러티브 연쇄 엔딩] 조건(특정 스토리 순서 달성)에 부합하는가?
- _Check 3_: [변주된 붕괴 엔딩] 조건(기본 붕괴 + 특정 보유 토큰/자원 태그 조합)에 부합하는가?
- _Check 4_: [기본 붕괴 엔딩] (사임, 세계 멸망, 문명 붕괴, 정치적 탄핵) 중 어느 것인가?
3. **엔딩 결과 화면 출력**
- 조건에 부합하는 가장 높은 우선순위의 텍스트와 연출 출력.
4. **도감 동기화**
- 출력된 엔딩을 계정 도감 데이터에 '해금(Unlocked)' 처리.
- 이번 회차의 [연대기](결재 결정 이력)와 [내러티브 태그](세계 상흔) 목록을 각각 분리 저장.
@@ -0,0 +1,20 @@
제공해 주신 기획서를 바탕으로, 게임 내 모든 UI/UX 요소들을 모듈화하여 정리한 **컴포넌트 인벤토리(Component Inventory)** 문서를 작성했습니다. 이 문서는 UI 디자이너와 프론트엔드/클라이언트 개발자가 디자인 시스템을 구축하고 재사용 가능한 위젯을 설계하는 데 기준이 됩니다.
# 🧩 컴포넌트 인벤토리 (Component Inventory)
## 1. 단순화된 지표 컴포넌트 (Simplified Indicators)
- **`Indicator_DotStep` (수정):** 기존의 두꺼운 게이지 바(Bar)를 대체하는 미니멀한 5칸짜리 점(Dot) 인디케이터. 공간을 적게 차지하며 1~5단계를 직관적으로 보여줍니다.
- **`Text_ResourceCounter` (수정):** 아이콘과 숫자가 한 덩어리로 묶인 심플한 텍스트 뱃지 (예: 💰 100). [예산], [인력], [정보] 토큰 및 자원 태그 표기용.
## 2. 인터랙티브 카드 컴포넌트 (Interactive Cards)
- **`Card_SwipeFull` (수정):** 화면의 70%를 차지하는 반응형 메인 카드. 복잡한 장식 없이 단색 배경, 큰 아이콘, 20pt 이상의 굵고 큰 텍스트로만 구성됩니다.
- **`Float_DecisionFeedback` (신규):** 스와이프를 할 때, 카드 상단 중앙의 빈 공간에 페이드인(Fade-in) 되는 결과 예측 위젯. 아이콘(증감) 형태로 노출되어 손가락에 가려지지 않게 설계됩니다.
## 3. 미니멀 인벤토리 컴포넌트 (Minimalist Inventory)
- **`Icon_Asset` (수정):** 무제한 인벤토리에 들어갈 자산 아이콘. 텍스트 설명 없이 직관적인 기호(픽토그램)만으로 디자인된 1:1 비율의 작은 정사각형 위젯입니다.
- **`VFX_SimpleChain` (수정):** 파이프라인 연쇄 효과 발동 시, `Icon_Asset` 사이를 빠르게 지나가는 얇은 발광 선(Line) 이펙트. 과도한 화면 가림을 방지합니다.
## 4. 의회 정치 추상화 컴포넌트 (Abstracted Politics)
- **`Grid_Council` (신규):** 13명의 임원을 화면 중앙에 4-4-5 배열 등 조밀하게 나열하기 위한 그리드 컨테이너입니다.
- **`Icon_CouncilMember` (수정):** 임원을 나타내는 심플한 원형 아이콘. 성향 태그나 지부장을 나타내는 고유 색상 및 투표 상태(`✅`, `❓`, `❌`)를 오버레이 배지로 포함합니다.
- **`Button_ActionFat` (신규):** 엄지손가락으로 터치하기 편하도록 가로폭을 100% 꽉 채운 굵고 큰 터치 버튼. 매수, 장부 조작, 내부 감사 액션에 사용됩니다.
@@ -0,0 +1,72 @@
모바일과 스팀 환경을 모두 고려하여, 중앙 집중형 레이아웃을 기준으로 UI 요소들의 배치를 정의했습니다.
# 📱 텍스트 와이어프레임 (Text Wireframe)
## 1. 메인 결재 화면 (Main Gameplay Screen)
플레이어가 스와이프를 통해 딜레마를 해결하고 덱을 구축하는 핵심 플레이 화면입니다.
### [상단 고정 영역: 미니멀 상태창]
- **좌상단 (내러티브):** `[📖 4/8]` (현재 획득한 내러티브 태그 숫자만 작게 표기. 터치 시 연대기 팝업).우측 여백에 다가오는 의회의 압박감을 시각화하는 `[제 X차 정례회의 상정까지: 잔여 00건]` 텍스트를 나란히 배치합니다.
- **중앙~우상단 (핵심 붕괴 파라미터):**
- 바(Bar) 형태를 버리고, 파라미터별 아이콘과 5개의 점(Dot)으로 1~5단계를 심플하게 표기합니다.
- `[저항 ✊] ⚫⚫⚪⚪⚪` | `[엔트로피 🌀] ⚫⚫⚫⚫⚪` | `[대중공황 😱] ⚫⚫⚫⚪⚪`
### [중앙 영역: 풀스크린 딜레마 카드]
- **화면 중앙 (`거대한 카드`):**
- 화면 중앙을 꽉 채우는 둥근 모서리의 직사각형 카드.
- 상단: 큼직한 [카드 카테고리 아이콘] (예: 일상, 위기, 작전 등).
- 중앙: 시원하고 큰 폰트의 [딜레마 텍스트] (모바일 가독성 극대화).
- **스와이프 프리뷰 (카드 드래그 시 나타나는 플로팅 텍스트):**
- 카드를 좌/우/위로 밀면, 카드 상단 여백에 **결과 예측 아이콘**이 크게 떠오릅니다.
- _(좌로 밀 때)_: `+💰예산`, `-✊저항` 등 심플한 아이콘 피드백 노출.
### [하단 영역: 자원 및 인벤토리]
- **하단 1열 (유동성 토큰):** 단순한 아이콘과 숫자만 가로 정렬.
- `[💰 120]` `[🩸 45]` `[👁️ 78]`
- **하단 2열 (영구 자원 태그):** 작은 아이콘과 스택 숫자 표기.
- `[🏢 3]` `[🛡️ 5]` `[👁️‍🗨️ 2]`
- **최하단 (자산 슬롯):** 가로로 스크롤 되는 미니멀한 네모 슬롯(1줄). 파이프라인 연쇄 반응 발생 시 아이콘들이 반짝이며 선(Line)으로 연결되는 심플한 이펙트 연출.
## 2. 주간 의회 단계 화면 (Weekly Council Screen)
복잡한 원탁회의 이미지를 버리고, 13명의 임원을 모바일 화면에 맞는 단순한 그리드(Grid) 아이콘으로 추상화합니다.
### [상단 영역: 안건 정보]
- **타이틀:** `[📜 이번 주 상정: 표적 규약 법안]`
- **위험 수용 계수**: 바 형태로 0~100%를 심플하게 표기 합니다.
- **경고문:** 위험 수용 계수 충족 시 화면 전체가 붉게 점멸하며 `[🚨 정치적 탄핵안 상정!]` 문구 노출.
### [중앙 영역: 13인 위원회 그리드]
- 초상화 대신, 13개의 추상화된 미니멀 아이콘(동그라미 또는 체스말 형태)이 3줄로 배치됩니다 (상임 8명 + 지부장 5명).
- 각 아이콘 내부 또는 우측 하단에 예상 표결 상태를 심플하게 뱃지로 표시:
- `[✅ 찬성]` / `[❓ 미정]` / `[❌ 반대(탄핵)]`
### [하단 영역: 국장의 개입 액션 (3개의 큰 버튼)]
모바일에서 한 손가락으로 누르기 쉬운 3개의 꽉 찬 가로형 버튼.
- `[💰 매수] (예산 소모 / 임원 매수)`
- `[👁️ 장부 조작] (정보 소모 / 안건 이월)`
- `[🩸 내부 감사] (인력 소모 / 기권 유도)`
## 3. 오버레이 및 팝업 (Overlays & Popups)
### 3.1. 세계의 상흔 팝업 (World's Scars Popup)
메인 화면 좌상단의 `[📖 4/8]` 카운터 터치 시 노출.
- **타이틀**: 세계의 상흔
- **본문**: 획득한 [내러티브 태그]의 키워드와 스니펫이 시간순으로 나열된 텍스트 목록 (세상이 어떻게 망가졌는가).
### 3.2. 연대기 팝업 (Chronicle Popup, 신규)
메인 화면의 별도 로그 버튼 터치 시 노출. '세계의 상흔' 팝업과는 별개의 진입점·별개의 데이터.
- **타이틀**: 국장의 연대기
- **본문**: 국장이 결재한 카드와 좌/우/위 선택 이력이 시간순으로 나열된 결정 장부.
```
[일상] 오염된 장부 승인
└ 좌(예산+): 유가족 위로금은 예비비 충당
```
### 3.3. 엔딩 결산 화면 (Ending Result Screen)
붕괴 조건 도달 시 출력되는 화면.
- **타이틀**: `[수집한 엔딩 이름]` (예: 사임, 세계 멸망, 문명 붕괴, 정치적 탄핵 등).
- **결과 텍스트**: 해당 엔딩에 대한 상세 서사.
- **원인 요약**: 현재 붕괴 게이지, 특정 자원 태그, 토큰 보유 상황 등 엔딩 발동 조건 표기.
- **이번 회차 연대기**: 회차 중 결재한 카드와 선택 이력을 요약한 '결정 요약' 블록.
- **액션**: `[도감에 기록하기 & 다음 회차 시작]` 버튼.
+200
View File
@@ -0,0 +1,200 @@
# Project SS — 아키텍처 (구현 실태)
> **이 문서는 "제안"이 아니라 "현재 코드가 실제로 어떻게 생겼는가"의 기록이다.**
> 2026-07-23 기준으로 실제 구현을 확인해 전면 재작성했다.
> 초기 제안서(React + Vite + **Tailwind + Zustand + TypeScript**)는 상당 부분 채택되지 않았고,
> 그 내역은 문서 하단 "채택하지 않은 초기 제안"에 남겨 뒀다.
>
> 게임 **규칙·수치**의 원본은 이 문서가 아니라 `docs/` 하위 기획서들이다. 여기엔 코드 구조만 적는다.
---
## 1. 기술 스택 (실제)
`client/package.json` 의존성이 전부다. 목록에 없는 것은 쓰지 않는다.
| 영역 | 채택 | 비고 |
|---|---|---|
| 프레임워크 | **React 19** | 함수 컴포넌트만, 클래스 컴포넌트 없음 |
| 빌드 | **Vite 8** | `client/`가 프로젝트 루트. npm 명령은 `cd client` 후 실행 |
| 언어 | **plain JavaScript (`.jsx`)** | TypeScript 미도입 |
| 스타일 | **컴포넌트별 개별 `.css`** | 글로벌 클래스명. CSS Modules 아님, Tailwind 아님 |
| 상태관리 | **`useState` / `useEffect`** | 외부 상태관리 라이브러리 없음 |
| 애니메이션 | **Framer Motion** | 카드 드래그/스와이프 |
| 아이콘 | **lucide-react** | |
| CSV 파싱 | **papaparse** | 런타임 파싱 (빌드 타임 변환 아님) |
| 영구 저장 | **localStorage** | 엔딩 도감 전용 |
디자인 토큰(컬러/폰트)은 `src/index.css`의 CSS 변수로 정의한다 (`--color-entropy-critical`,
`--font-typewriter` 등). 컴포넌트 CSS는 이 변수를 참조한다.
---
## 2. 디렉토리 구조 (실제)
```text
ProjectSS/
├── playtest.bat # 더블클릭 → npm install(최초 1회) + dev 서버 + 브라우저 자동 오픈
│ # ASCII 전용 — 한글/chcp를 넣으면 cmd.exe가 배치 파싱을 깨뜨린다
├── CLAUDE.md # 작업 규칙 · Phase 상태 (실질적인 단일 진실 공급원)
├── ROADMAP.md
├── docs/ # 기획서 · 스토리 바이블 · 작업지시서 (게임 규칙의 원본)
└── client/ # ★ 실제 앱. npm 명령은 여기서 실행
├── update_csv.js # ⛔ 보호 파일 — cards.csv 전처리
├── update_csv_tags.js # ⛔ 보호 파일 — 내러티브 태그 매핑
└── src/
├── main.jsx (10줄) 진입점
├── App.jsx (13줄) GameScreen 하나만 렌더 — 라우터 없음
├── index.css 디자인 토큰(CSS 변수) + 리셋
├── assets/ hero.png 등 정적 파일
├── components/ ★ flat 구조. 하위 폴더 없음
│ ├── GameScreen.jsx (628줄) ★ 상태 허브 + 화면 전환
│ ├── SwipeCard.jsx (156줄) 카드 드래그 · 선택지 사전 예고
│ ├── CouncilScreen.jsx (220줄) 정례회의(투표 · 개입 액션)
│ ├── EndingScreen.jsx (46줄) 엔딩 출력 + 도감 저장
│ ├── EndingCodex.jsx (33줄) 엔딩 도감 (localStorage 읽기)
│ ├── ChroniclePopup.jsx (63줄) 연대기 + 태그 히스토리
│ ├── AssetInfoPopup.jsx (43줄) 자산 상세 · 수동 발동
│ └── *.css 컴포넌트당 1개씩 짝을 이룸
├── engine/ ★ React를 모르는 순수 로직
│ ├── assetEngine.js (226줄) 자산 트리거→효과→연쇄
│ └── pollution.js (154줄) 오염 카드 주입 규칙 · 밸런스 상수
└── data/
├── cards.csv 카드 원본 (런타임 papaparse 파싱)
├── assets.json 자산 8종 정의
├── narrative_tags.json 재사용 태그 풀 10종
├── endings.js (123줄) 엔딩 카탈로그 + 판별 함수
└── pollution_cards.json ⚠️ 어디서도 import하지 않음 (레거시)
```
---
## 3. 핵심 아키텍처 원칙
### 3.1 상태는 `GameScreen.jsx` 한 곳에 모은다
전역 스토어가 없다. 게임 상태 전부가 `GameScreen`의 `useState`로 존재하고, 하위 컴포넌트는
props로 값과 콜백을 받는 프레젠테이션 계층이다.
```
GameScreen (상태 소유)
├── SwipeCard ← card, onSwipe, phase
├── CouncilScreen ← params/tokens/tags + setter, onResolve ※ setter를 직접 넘기는 유일한 예외
├── EndingScreen ← ending, narrativeTags
├── ChroniclePopup ← chronicle, acquiredTags
└── AssetInfoPopup ← instance, def, onActivate
```
**트레이드오프:** 파일이 628줄까지 커졌고 관심사가 섞여 있다. 다만 게임 규칙이 아직 확정되지
않아 잦은 수정이 예상되는 그레이박스 단계에서는 "한 파일만 보면 전체 흐름을 안다"는 이점이
분할 비용보다 컸다. Phase 5 이후 재검토 대상이다.
### 3.2 규칙 계산은 `engine/`의 순수 함수로 뺀다
`engine/`은 React·훅·JSX를 전혀 import하지 않는다. 평범한 객체를 받아 평범한 객체를 돌려준다.
이 경계 덕분에 **게임을 띄우지 않고 밸런스를 검증할 수 있다.** 실제로 오염 카드 물량 조정 시
`node` 스크립트로 `pollution.js`를 직접 import해 600회 시뮬레이션을 돌려 변경 전후를 비교했다.
새 규칙 모듈을 만들 때도 이 성질을 깨지 말 것.
### 3.3 `world` 스냅샷 패턴 (자산 엔진)
`assetEngine`은 상태를 직접 만지지 않는다. `GameScreen`이 현재 state를 복사한 `world` 객체를
만들어 넘기고, 엔진이 변형한 결과를 다시 state로 커밋한다.
```js
const world = buildAssetWorld(); // state 복사 → { cards, tokens, tags, params, owned, quarantine, logs }
AssetEngine.onPollutionDraw(world, card.type);
AssetEngine.onApprovalTick(world, nextTurns);
commitAssetWorld(world); // 결과를 setState로 일괄 반영
```
> **⚠️ 반드시 지킬 것:** 한 이벤트 처리 안에서 `buildAssetWorld`/`commitAssetWorld` 쌍을
> **정확히 한 번만** 쓴다. 두 번 짝지어 부르면 두 번째 `build`가 아직 리렌더되지 않은
> 낡은 클로저 state를 복사하므로, 나중 커밋이 앞의 변경을 덮어써 버린다.
엔진 훅 지점은 4곳이다: `onApprovalTick`(결재마다) · `onPollutionDraw`(오염 카드 등장) ·
`onSwipeLeft`(좌 스와이프 격리) · `onCouncilEnd`(정례회의 종료). 연쇄 발동은 `fireEvent`가
깊이 상한 10으로 처리한다.
### 3.4 예고 · 실행 · 알림은 같은 함수를 공유한다
플레이어에게 미리 보여준 숫자와 실제 결과가 어긋나면 정보 자체를 신뢰하지 않게 된다.
그래서 오염 주입량은 `pollution.js`의 `previewPollution()` 하나가 단일 기준이다.
```
SwipeCard "☣ 오염 카드 +2" 사전 경고 ─┐
GameScreen 실제 덱 주입 ─┼─ 모두 previewPollution() 사용
GameScreen "2건 유입" 사후 토스트 ─┘
```
이 함수를 우회해 오염 카드를 만들지 말 것. 밸런스 수치는 `pollution.js` 상단 상수 블록에 모여 있다.
---
## 4. 데이터 흐름
```
cards.csv ──(?raw import)──> papaparse ──> buildCardFromRow() ──> 덱 배열
│
narrative_tags.json ──> left_tag/right_tag id로 조인 ────────────────┘
```
- **카드 데이터는 런타임에 파싱한다.** `import cardsCsvRaw from '../data/cards.csv?raw'` 후
브라우저에서 papaparse로 처리한다. 빌드 타임 변환 단계가 없으므로 CSV를 고치면 새로고침만으로 반영된다.
- 덱은 **인덱스를 순환(modulo)하는 풀**이다. 소진되어 없어지지 않고 계속 돈다.
`currentCardIndex`는 매 결재마다 최종 덱 길이로 정규화해 범위 안에 유지한다 — 주입/소각으로
길이가 변하기 때문이다.
- 카드 풀은 페이즈별로 분리한다: 페이즈1(일상/위기)로 시작하고, 내러티브 태그 3종 획득 시
페이즈2(작전/극비) 풀이 덱에 삽입된다.
- 오염 카드(신화/조직저항/사회공황/이사회압박)는 `contaminationTemplates`로 따로 보관하다가
주입 시점에 복제해 고유 id를 붙인다.
---
## 5. 화면 전환
라우터가 없다. `GameScreen`이 상태를 보고 조기 return으로 화면을 고른다.
```js
if (gameOver) return <EndingScreen ... />;
if (showCouncil) return <CouncilScreen ... />;
if (cards.length === 0) return <div>데이터를 불러오는 중...</div>;
return ( /* 결재 화면 */ );
```
팝업(연대기·자산 정보·도감)은 화면을 갈아끼우지 않고 조건부 오버레이로 렌더한다.
---
## 6. 영구 저장
localStorage에 저장하는 것은 **엔딩 도감뿐이다.** 세이브/로드 기능은 없고, 게임오버 시
`window.location.reload()`로 재시작한다.
- 키: `ss_endings_v2` (`EndingCodex.jsx`에서 `ENDINGS_STORAGE_KEY`로 export)
- 값: 해금된 엔딩 **id** 배열. 구버전 `ss_endings`는 제목 문자열 기반이라 폐기했고 호환되지 않는다.
---
## 7. 채택하지 않은 초기 제안
초기 제안서에 있었으나 **의도적으로 도입하지 않은** 것들이다. 되살리려면 CLAUDE.md 규칙부터 바꿔야 한다.
| 제안 | 현재 | 사유 |
|---|---|---|
| Tailwind CSS | 컴포넌트별 `.css` | 관료제 다크 UI는 유틸리티 클래스보다 커스텀 CSS가 손에 맞았다 |
| Zustand | `useState` | 상태가 한 컴포넌트에 모여 있어 전역 스토어의 값어치가 없다. **도입 금지** |
| TypeScript | plain JS | 그레이박스 단계의 잦은 스키마 변경 대비 타입 유지비가 컸다 |
| `store/ hooks/ types/ utils/ constants/` | 없음 | 규칙 로직은 `engine/`으로 통합 |
| `components/{common,dashboard,gameplay,council}/` | flat | 컴포넌트 7개뿐이라 계층이 불필요 |
| 8대 파벌 도넛 차트 | 텍스트 목록 | 파벌은 12종 풀에서 5명 선발 방식으로 변경됨 |
---
## 8. 알려진 부채
- **`GameScreen.jsx` 628줄** — 상태 허브 + 화면 전환 + 스와이프 처리 + 파벌 계산이 한 파일에 있다.
- **`data/pollution_cards.json` 미사용** — 어디서도 import하지 않는 레거시. 오염 카드는 `cards.csv`에서 온다.
- **테스트 코드 없음** — 검증은 `engine/` 모듈을 직접 부르는 일회성 node 스크립트로 해 왔다.
- **`window.location.reload()` 재시작** — 상태 초기화 로직이 따로 없다.
@@ -0,0 +1,138 @@
# 내러티브 태그 시스템 작업 지시서
> **범위:** (A) 연대기 ↔ 내러티브 태그 개념 분리 + (B) 내러티브 태그 스니펫 시스템 도입.
>
> **작업 환경:** Claude Code 세션에서 각 파일을 열어 해당 섹션을 대조 후 반영. 아래 문구는 리포 실제 텍스트와 100% 일치하지 않을 수 있으므로 의미 기준으로 수정할 것.
>
> **주의:** `narrative_tags.json` 콘텐츠(스니펫 등)는 자유롭게 초안·반영 가능. 단 `update_csv_tags.js` 등 **보호된 스크립트** 수정이 필요해지면 임의 실행 금지 — 근거 설명 → diff 확인 → 승인 절차를 따를 것.
---
# PART A. 연대기 ↔ 내러티브 태그 분리
## A-0. 개념 정의 (기준선)
| 구분 | 내러티브 태그 (Narrative Tag) | 연대기 (Chronicle) |
|---|---|---|
| 본질 | **결과** — 세계가 어떻게 되었는가 | **원인** — 내가 무엇을 어떻게 결재했는가 |
| 성격 | 명사 · 세계의 상흔 | 동사 · 국장의 결정 로그 |
| 구성 | 키워드 + 스니펫 + 엔딩 조건 + 페이즈 축 | 등장한 결재 카드 + 플레이어 선택(좌/우/위) 이력 (시간순) |
| 진입점 | 좌상단 `[📖 4/8]` 카운터 → '세계의 상흔' 팝업 | 별도 로그 버튼 + 엔딩 화면 '이번 회차 결정 요약' |
**한 줄 기억표:** 태그는 스토리의 '무엇', 연대기는 '어떻게'.
**스니펫은 태그 쪽에 귀속된다** (태그의 살). 연대기와는 무관.
## A-1. 시스템 기획서 — `Project SS 서사형 덱빌딩 게임 시스템 기획서.md`
1. 내러티브 태그 항목에서 연대기 서술을 **제거**. 태그 항목엔 스토리 전달(스니펫) + 엔딩 조건 + 페이즈 축만 남김.
2. 시스템 목록에 **독립 항목** 신설:
> **연대기 (Chronicle):** 국장이 결재한 카드와 그에 대한 선택(좌/우/위)을 시간순으로 기록한 결정 장부. 내러티브 태그가 '세계의 결과'라면, 연대기는 '국장의 원인 이력'이다.
## A-2. 스토리 바이블 — `Project SS 스토리 바이블 v3.0.md`
2장 "연대기의 편찬(The Chronicle)" 테마를 '결정 장부' 관점으로 재서술:
> 국장의 모든 결재는 되돌릴 수 없는 기록으로 남는다. 세계의 상흔(내러티브 태그)이 '무엇이 벌어졌는가'를 말한다면, 연대기는 '누가 그렇게 결정했는가'를 말한다. 플레이어는 회차가 끝난 뒤 이 장부를 되짚으며, 멸망이 사고가 아니라 자신의 서명들이 쌓인 결과였음을 마주한다.
## A-3. 텍스트 와이어프레임 — `ProjectSS 텍스트 와이어프레임.md`
1. **3.1 팝업 2종 분리:**
- '세계의 상흔' 팝업: `[📖 4/8]` 터치. 본문 = 태그 키워드 + 스니펫 시간순.
- '연대기' 팝업(신규): 별도 로그 버튼. 본문 = 결재 카드 + 선택 이력 시간순.
```
[일상] 오염된 장부 승인
└ 좌(예산+): 유가족 위로금은 예비비 충당
```
2. **3.2 엔딩 화면:** '이번 회차 연대기(결정 요약)' 블록 추가.
## A-4~A-7. 나머지 문서 (한두 줄 수정)
- **비전 문서 4-C:** 연대기 = 결재 카드·선택의 시간순 결정 장부. 세계 상태는 태그가 담당(분리).
- **PRD 5장:** 연대기(결재 결정 이력) / 내러티브 태그(엔딩·세계 상태) 각주 분리.
- **유저플로우 4.4:** 연대기와 태그 목록을 **분리 저장**으로 명시.
- **개발순서 Phase 4:** 연대기 기능과 태그 뷰('세계의 상흔')를 별도 UI로 분리 구현 명시.
---
# PART B. 내러티브 태그 스니펫 시스템
> 태그 키워드만으로는 서사 체감이 약함. 각 태그에 1~2문장 결과 보고서 스니펫을 붙여 살을 부여한다. 그레이박스 원칙 안에서 순수 텍스트로 검증 가능.
## B-1. 스타일 가이드 (확정)
**톤 3원칙**
1. **거리 유지** — 감정 형용사·판단·괴수 외형 묘사 금지. 오직 기록·숫자·행정 용어.
2. **숫자 하나** — 스니펫마다 구체 숫자 정확히 1개(예: 14명, 47건, 15%). 냉혹한 비용편익의 앵커.
3. **인정 유예** — 스니펫은 위로·의미 부여를 하지 않는다. 인정(감동)은 엔딩/연대기 회고의 몫으로 미룬다.
**포맷 골격**
`사실 진술 → 행정적 뒤틀림 → [처리 상태]로 종결`
- **마침 도장(각인층):** 모든 스니펫은 결재 상태어로 끝난다(처리됨 / 승인됨 / 종결됨 / 이관됨 / 반려됨 / 미반영됨 등). 이 게임만의 문장 지문.
**유형 2종 — 비율 약 7:3**
- **시스템형(70%):** 시스템의 부조리가 주인공.
- **사람형(30%):** 익명의 개인이 데이터 이상치로만 존재('숨겨진 헌신'). 엔딩/변주 콜백용.
> **설계 근거(참고 문서에서 이식):** 감동 공식(거리+숨겨진 헌신+인정+의미의 역류) — 거리는 문체로 확보, 헌신은 사람형 스니펫에 매설, 인정은 엔딩에서 역류. 로그라이크 루프가 이 역류를 회차마다 반복시킨다.
## B-2. 스키마 변경 (승인 필요 항목)
각 태그 객체에 두 필드 추가:
```json
{
"id": "quarantine_breach",
"keyword": "격리 붕괴",
"snippet": "격리동 전력 계통에 원인 불명의 수동 복구 기록 1건. 해당 시간대 출입 승인자 없음. 요원 K-771, 실종자 명단으로 이관됨.",
"snippet_type": "human",
"phase": 2
}
```
- `snippet` (string): 태그 스니펫 본문.
- `snippet_type` ("system" | "human"): 콜백 로직용. 엔딩/연대기 회고에서 해당 회차의 human 태그를 골라 "그때 K-771이…" 식 콜백 훅으로 사용.
> `narrative_tags.json` 직접 수정은 가능. 단 이 파일이 `update_csv_tags.js`로 **생성/가공**되는 구조라면 스크립트에 신규 컬럼 반영이 필요하므로, 그 경우 diff 확인 → 승인 후 진행.
## B-3. 샘플 배치 8종 (검토용 기준선)
**시스템형 (5)**
| 태그 | 스니펫 |
|---|---|
| `[실종]` | 7구역 정수장 근무자 14명 미귀가. 근태 시스템상 전원 '정상 퇴근'으로 **처리됨**. |
| `[가스 폭발]` | 언론 브리핑상 '노후 배관 폭발'로 발표. 유가족 위로금 항목은 예비비로 충당, 회계 **승인됨**. |
| `[매수]` | 3분기 감사 중 국장 명의 차명 계좌 3건 인지. 담당 조사관 해외 연수 발령으로 조사 **중단됨**. |
| `[차원 균열]` | 12구역 상공 '접힘 현상', 위성 미검출. 목격 진술 47건 전원 상이. 관측 장비 오류로 **종결됨**. |
| `[마녀사냥]` | '이웃이 변이체' 신고 폭증. 실제 변이 확인 0건, 자경단 폭행 사망 3건. 통계 비공개 **결정됨**. |
**사람형 (3) — 숨겨진 헌신**
| 태그 | 스니펫 |
|---|---|
| `[격리 붕괴]` | 격리동 전력 계통에 원인 불명의 수동 복구 기록 1건. 해당 시간대 출입 승인자 없음. 요원 K-771, 실종자 명단으로 **이관됨**. |
| `[결손 보전]` | 은폐 자금 결손분 340만, 익명 입금으로 충당. 입금자 추적 '불필요' 판정. 계정 **종결됨**. |
| `[초과 근무]` | 작전부 잔업 신청 반려 건수 218건. 반려된 요원 전원 무급으로 현장 잔류 확인. 근태 기록 **미반영됨**. |
## B-4. 확정 대기 (스타일 가이드 완성 전 결정 필요)
- [ ] **사람형 헌신 농도:** ⑥~⑧을 더 은근하게 vs 더 드러나게.
- [ ] **마침 도장 어휘:** 자유 어휘 허용 vs 고정 리스트로 관리(처리됨/승인됨/종결됨/이관됨/반려됨/미반영됨…).
이 둘이 정해지면 스타일 가이드 확정 → 나머지 태그를 다음 배치들로 진행.
---
# 통합 체크리스트
**Part A (개념 분리)**
- [ ] 시스템 기획서: 연대기 독립 항목 승격, 태그 항목 정리
- [ ] 스토리 바이블: "연대기의 편찬" 결정 장부 관점 재서술
- [ ] 와이어프레임: 팝업 2종 분리 + 엔딩 결정 요약 블록
- [ ] 비전/PRD/유저플로우/개발순서: 정의 분리 및 분리 저장/분리 구현 반영
**Part B (스니펫)**
- [ ] 스타일 가이드 확정(B-4 두 항목 결정)
- [ ] `narrative_tags.json` 스키마에 `snippet`, `snippet_type` 추가
- [ ] (해당 시) `update_csv_tags.js` 신규 컬럼 반영 — diff 승인 후
- [ ] 샘플 8종 반영 → 검토 → 나머지 태그 배치 확장
- [ ] 스토리 바이블 톤 앤 매너에 스니펫 3원칙·포맷 규칙 문서화(선택)
@@ -0,0 +1,128 @@
# 자산(엔진) 시스템 작업지시서 — 그레이박스 v1
> **목적**: 발라트로형 엔진 빌딩 검증. 자산 8종을 데이터로 구현하고, 자산 정보 UI 블로커를 해결하여 실제 플레이 테스트가 가능한 상태를 만든다.
> **원칙**: Greybox First. 수치는 전부 가안이며 튜닝 대상. 비주얼 연출 금지(텍스트/기본 도형만).
> **주의**: `update_csv_tags.js`, `update_csv.js` 등 파이프라인 스크립트는 수정 금지. 본 작업은 신규 데이터 파일 + 신규 로직 모듈로만 구성한다.
---
## Part A. 자산 데이터 스키마
신규 파일 `src/data/assets.json` 생성. 자산 1장의 문법은 **트리거 → 효과 → (선택) 성장**.
```json
{
"id": "asset_purge_001",
"name": "문서 파쇄실",
"grade": "C", // C | B | A | S
"archetype": "purge", // purge | convert | quarantine | skip
"trigger": {
"type": "every_n_approvals", // 트리거 enum (아래 표)
"params": { "n": 5 }
},
"effect": {
"type": "purge_lowest", // 효과 enum (아래 표)
"params": { "count": 1 }
},
"growth": null, // A급 이상만 사용. { "stackOn": "...", "per": 3, "bonus": {...} }
"risk": null, // 부작용. { "param": "risk_tolerance", "amount": 5, "when": "council_end" }
"faction_link": "scavengers", // 우호도 연동 파벌 (nullable)
"description": "결재 5건마다 덱에서 최하 등급 오염 카드 1장을 영구 소각한다.",
"flavor": "파쇄 용량: 일일 1건. 증설 요청은 3회 반려됨."
}
```
### 트리거 enum (v1 범위)
| type | 의미 | params |
|---|---|---|
| `on_pollution_draw` | 오염 카드 드로우 시 | `category`(신화/저항/공황/압박, nullable), `minGrade`(nullable) |
| `on_swipe_left` | 좌 스와이프 시 | — |
| `on_token_spend` | 토큰 소모 시 | `tokenType`(nullable) |
| `every_n_approvals` | 결재 N건마다 | `n` |
| `on_council_end` | 의회 종료 시 | — |
| `on_purge` | 오염 카드 소각 시 (연쇄용) | — |
| `on_convert` | 오염 카드 변환 시 (연쇄용) | — |
| `on_quarantine_fail` | 격리 실패 복귀 시 (연쇄용) | — |
| `manual_charge` | 충전식 수동/자동 방패 | `chargeEvery` |
### 효과 enum (v1 범위)
| type | 의미 | params |
|---|---|---|
| `purge_lowest` | 최하 등급 오염 소각 | `count` |
| `convert_to_token` | 오염 → 토큰 환전 | `tokenType`, `amount` |
| `quarantine` | 오염 격리 (조건 실패 시 강화 복귀) | `failCondition` |
| `negate_penalty` | 페널티 1회 무효 | `category`(nullable) |
| `gain_token` | 토큰 생성 | `tokenType`, `amount` |
| `gain_tag` | 자원 태그 생성 | `tagType`, `amount` |
| `convert_multiplier` | 변환 산출 배수 (상시) | `multiplier` |
| `quarantine_fail_to_purge` | 격리 실패 → 영구 소각으로 치환 (규칙 왜곡) | `bonusTag`(nullable) |
> 구현 노트: 트리거는 게임 이벤트 버스에 리스너로 등록. `on_purge`/`on_convert`/`on_quarantine_fail`은 다른 자산의 효과가 발생시키는 이벤트를 구독하므로, **효과 실행부가 반드시 이벤트를 발행**해야 연쇄가 성립한다. 무한 루프 방지를 위해 결재 1건당 연쇄 깊이 상한 10회.
---
## Part B. 자산 8종 데이터 (확정 배치 1차)
### ① 소각 특화 (faction: scavengers)
1. **문서 파쇄실** — C급
- 트리거: `every_n_approvals(5)` / 효과: `purge_lowest(1)`
2. **소각 처리 대장** — A급
- 트리거: `on_purge` / 성장: 스택 +1, 스택 3마다 `purge_lowest(1)` 추가 발동
### ② 변환 경제 (faction: nexus_traders)
3. **폐기물 재활용 규정** — C급
- 트리거: `on_pollution_draw(category: 신화)` / 효과: `gain_token(예산, 2)` (페널티는 정상 적용 후)
4. **부산물 매각 계약** — B급
- 효과: `convert_multiplier(2)` 상시 / 리스크: 의회 종료 시마다 위험 수용 계수 +5%
### ③ 격리 도박 (faction: occult_collectors)
5. **임시 격리 컨테이너** — C급
- 트리거: `on_swipe_left`(발동 선택 가능) / 효과: `quarantine(failCondition: 다음 의회까지 대중 공황 1단계 상승)`
6. **심연 보관 조례** — S급
- 트리거: `on_quarantine_fail` / 효과: `quarantine_fail_to_purge(bonusTag: 감시 +1)`
### ④ 무효 회피 (faction: field_agents_union — 우호도 **하락** 연동)
7. **결재 반려 도장** — C급
- 트리거: `manual_charge(chargeEvery: 10)` / 효과: `negate_penalty(전체)`
8. **책임 소재 불명 처리** — B급
- 트리거: 조직 저항 계열 `negate_penalty` 발동 시 / 효과: 인력 토큰 소모 없이 통과 / 리스크: 내부 저항 +3%
> 수치(5건, 토큰 2, 스택 3, 5%, 3% 등)는 전부 가안. 플레이 테스트 후 일괄 튜닝.
---
## Part C. UX 블로커 해결 (최우선 선행 작업)
**현재 문제**: 자산 클릭 시 아무 정보도 표시되지 않음 → 덱압축 시스템 평가 자체가 불가능.
1. 하단 자산 슬롯의 `Icon_Asset` 탭/클릭 시 정보 팝업 표시:
- 이름, 등급, 아키타입 아이콘
- **트리거 조건 → 효과** 를 한 문장으로 (description 필드 그대로)
- 성장형이면 현재 스택 수치 표시
2. **자산 발동 시 가시 피드백 (텍스트 로그로 충분)**: 화면 한쪽에 "『문서 파쇄실』 발동 — [하급 신화] 소각됨" 식의 1줄 로그. 발라트로의 조커 점멸에 해당하는 최소 구현. 연쇄 발동 시 순서대로 줄줄이 출력되어야 "엔진이 돌아간다"는 감각이 생긴다.
3. 그레이박스 기준이므로 애니메이션 불필요. 텍스트 로그 + 팝업만.
---
## Part D. 플레이 테스트 체크리스트
구현 완료 후 아래를 검증한다.
- [ ] **역전 엔진 성립**: 3번(재활용 규정) + 1번(파쇄실) 조합 시, 오염이 늘수록 자원이 돌고 덱이 정화되는 체감이 있는가?
- [ ] **연쇄 가시성**: 2번(소각 처리 대장) 보유 시 소각→스택→추가 소각 연쇄가 로그로 읽히는가?
- [ ] **S급 킹핀**: 6번(심연 보관 조례) 획득 전후로 격리 자산의 가치 평가가 뒤집히는가? (획득 전: 격리 기피 / 획득 후: 격리 남발)
- [ ] **무효 빌드의 풍자 체감**: 7+8번 조합으로 "덱은 더럽지만 아무 일도 안 일어나는" 상태가 만들어지는가?
- [ ] **오버파워 확인**: 페이즈 3에서 엔진이 오염 주입 속도를 이기는 순간이 오는가? (이겨야 정상 — 발라트로 모델)
- [ ] 연쇄 깊이 상한(10회)에 걸리는 케이스가 있는가?
## 범위 외 (다음 배치)
- 의회 압박 강화(안건 예고제, 표차 가시화) — 별도 작업지시서로 분리 예정
- 하이브리드 아키타입 자산 및 A/S급 보강 (2차 배치)
- 자산 획득 경로(극비 프로젝트 보상 테이블) 튜닝
-34
View File
@@ -1,34 +0,0 @@
const { app, BrowserWindow } = require('electron');
const path = require('path');
function createWindow() {
const mainWindow = new BrowserWindow({
width: 1280,
height: 720,
// 스팀 게임처럼 보이도록 기본 메뉴바 숨김
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
});
// src/index.html 경로를 메인 화면으로 설정
mainWindow.loadFile(path.join(__dirname, 'src', 'index.html'));
}
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
+11
View File
@@ -0,0 +1,11 @@
id,title,desc
AG_01,대토벌의 시대,괴수 토벌 집중 주간. (작전부 안건 빈도 증가 / 전투 예산 소모 심화)
AG_02,긴축 재정,허리띠를 졸라맵니다. (매 턴 기본 예산 $10 추가 차감 / 자산 매각 이익 증가)
AG_03,투명한 사회,감사팀이 활개칩니다. (비자금 사용 및 불법 자산 매각 시 의심도 상승폭 2배)
AG_04,그림자 작전,어둠의 뒷거래가 활성화됩니다. (안건 승인 시 비자금 획득 확률 및 양 증가)
AG_05,이단 심문기,마녀사냥의 주간입니다. (오컬트/마법 관련 안건 승인 시 엔트로피 폭등)
AG_06,연구 장려 주간,과학 발전에 예산을 쏟습니다. (R&D 본부 비용 30% 감소 및 자산 획득률 증가)
AG_07,피의 숙청,"배신자를 색출합니다. (안건 거절 시 신임도 대폭 상승, 하지만 의심도도 동반 상승)"
AG_08,대규모 차원 균열,우주적 재난 주간. (모든 안건의 엔트로피 기본 증가량이 1.5배로 증폭됨)
AG_09,글로벌 후원금,스폰서들의 기분이 좋습니다. (시작 시 총 예산 +$150 일시불 지원)
AG_10,통제 사회,대중을 통제합니다. (정보 통제 관련 안건 비용 절반 감소)
1 id title desc
2 AG_01 대토벌의 시대 괴수 토벌 집중 주간. (작전부 안건 빈도 증가 / 전투 예산 소모 심화)
3 AG_02 긴축 재정 허리띠를 졸라맵니다. (매 턴 기본 예산 $10 추가 차감 / 자산 매각 이익 증가)
4 AG_03 투명한 사회 감사팀이 활개칩니다. (비자금 사용 및 불법 자산 매각 시 의심도 상승폭 2배)
5 AG_04 그림자 작전 어둠의 뒷거래가 활성화됩니다. (안건 승인 시 비자금 획득 확률 및 양 증가)
6 AG_05 이단 심문기 마녀사냥의 주간입니다. (오컬트/마법 관련 안건 승인 시 엔트로피 폭등)
7 AG_06 연구 장려 주간 과학 발전에 예산을 쏟습니다. (R&D 본부 비용 30% 감소 및 자산 획득률 증가)
8 AG_07 피의 숙청 배신자를 색출합니다. (안건 거절 시 신임도 대폭 상승, 하지만 의심도도 동반 상승)
9 AG_08 대규모 차원 균열 우주적 재난 주간. (모든 안건의 엔트로피 기본 증가량이 1.5배로 증폭됨)
10 AG_09 글로벌 후원금 스폰서들의 기분이 좋습니다. (시작 시 총 예산 +$150 일시불 지원)
11 AG_10 통제 사회 대중을 통제합니다. (정보 통제 관련 안건 비용 절반 감소)
+21
View File
@@ -0,0 +1,21 @@
id,name,type,desc,passive_eff_key,passive_eff_val,risk_eff_key,risk_eff_val,sale_value,sale_risk_key,sale_risk_val,flavor
AST_001,유령 커피머신,Relic,"커피 맛은 천상, 밤마다 악몽.",budget,5,entropy,1,50,suspicion,5,카페인과 공포는 훌륭한 각성제.
AST_002,말하는 곰팡이,Bio,벽속의 비밀을 엿듣고 알려줍니다.,black_fund,5,suspicion,2,80,entropy,10,내부 고발자보다 낫습니다.
AST_003,트롤 고기,Bio,잘라내도 계속 자라나는 고기 덩어리.,budget,15,trust,-2,120,suspicion,20,메뉴가 함박스테이크로 고정됨.
AST_004,저주받은 금화,Relic,소유자의 수명을 대가로 금화 생성.,black_fund,10,trust,-5,200,entropy,15,시간으로 돈을 사는 겁니다.
AST_005,비유클리드 칩,Tech,현실에 존재하지 않는 연산을 수행.,budget,30,entropy,5,300,suspicion,30,1+1=3이 되는 기적.
AST_006,베헤모스의 알,Bio,S급 괴수의 알입니다. 따뜻합니다.,trust,2,entropy,10,500,entropy,50,좋은 징조는 아니죠.
AST_007,예언자의 눈알,Relic,10초 뒤의 미래를 보여줍니다.,black_fund,15,entropy,3,150,suspicion,10,내일의 하한가를 아는 고통.
AST_008,자동 서명 깃펜,Relic,서류에 자동으로 서명합니다.,trust,5,black_fund,-5,40,trust,-10,팔이 아플 일은 없겠군요.
AST_009,흡혈귀 혈액팩,Bio,강력한 재생 효과가 있는 흡혈귀 피.,-,-,suspicion,1,100,suspicion,15,빈티지 1980년산 O형.
AST_010,외계 통신기,Tech,우주 어딘가로 좌표를 송출합니다.,-,-,entropy,5,80,entropy,20,"응답하라, 오버."
AST_011,거짓말 탐지 넥타이,Relic,거짓말을 하면 목을 강하게 조릅니다.,trust,3,trust,-1,60,suspicion,5,숨 막히는 진실 공방.
AST_012,무한 동력 햄스터,Bio,지치지 않고 쳇바퀴를 도는 돌연변이.,budget,20,entropy,5,250,entropy,10,존재해선 안 될 생물입니다.
AST_013,부패한 장부 원본,Info,전임 국장의 횡령 내역 파일.,black_fund,20,suspicion,10,0,trust,-50,이걸 태우면 당신도 공범입니다.
AST_014,둠스데이 버튼,Tech,세계를 멸망시킬 버튼.,-,-,entropy,15,1000,entropy,100,절대 누르지 마시오.
AST_015,사랑의 묘약,Relic,상대를 맹목적으로 따르게 만듭니다.,trust,5,suspicion,5,150,suspicion,20,화학적 동의도 동의입니까?
AST_016,좀비 바이러스 백신,Bio,세계 유일의 치료제 샘플.,-,-,entropy,5,600,entropy,30,손 떨지 마세요.
AST_017,악마의 계약서,Relic,대가 란이 비어있는 백지 계약서.,black_fund,30,entropy,8,5,entropy,2,핏방울을 떨어뜨리세요.
AST_018,투명 망토,Tech,투명해지지만 소리는 남음.,suspicion,-2,-,-,90,suspicion,10,보이지 않는다고 없는 건 아니죠.
AST_019,기밀 하드디스크,Info,주요국 정상들의 치명적 스캔들.,budget,50,trust,-10,400,trust,-100,판도라의 폴더.
AST_020,식인 식물 화분,Bio,시체 처리용으로 훌륭합니다.,suspicion,-5,trust,-1,30,entropy,,
1 id name type desc passive_eff_key passive_eff_val risk_eff_key risk_eff_val sale_value sale_risk_key sale_risk_val flavor
2 AST_001 유령 커피머신 Relic 커피 맛은 천상, 밤마다 악몽. budget 5 entropy 1 50 suspicion 5 카페인과 공포는 훌륭한 각성제.
3 AST_002 말하는 곰팡이 Bio 벽속의 비밀을 엿듣고 알려줍니다. black_fund 5 suspicion 2 80 entropy 10 내부 고발자보다 낫습니다.
4 AST_003 트롤 고기 Bio 잘라내도 계속 자라나는 고기 덩어리. budget 15 trust -2 120 suspicion 20 메뉴가 함박스테이크로 고정됨.
5 AST_004 저주받은 금화 Relic 소유자의 수명을 대가로 금화 생성. black_fund 10 trust -5 200 entropy 15 시간으로 돈을 사는 겁니다.
6 AST_005 비유클리드 칩 Tech 현실에 존재하지 않는 연산을 수행. budget 30 entropy 5 300 suspicion 30 1+1=3이 되는 기적.
7 AST_006 베헤모스의 알 Bio S급 괴수의 알입니다. 따뜻합니다. trust 2 entropy 10 500 entropy 50 좋은 징조는 아니죠.
8 AST_007 예언자의 눈알 Relic 10초 뒤의 미래를 보여줍니다. black_fund 15 entropy 3 150 suspicion 10 내일의 하한가를 아는 고통.
9 AST_008 자동 서명 깃펜 Relic 서류에 자동으로 서명합니다. trust 5 black_fund -5 40 trust -10 팔이 아플 일은 없겠군요.
10 AST_009 흡혈귀 혈액팩 Bio 강력한 재생 효과가 있는 흡혈귀 피. - - suspicion 1 100 suspicion 15 빈티지 1980년산 O형.
11 AST_010 외계 통신기 Tech 우주 어딘가로 좌표를 송출합니다. - - entropy 5 80 entropy 20 응답하라, 오버.
12 AST_011 거짓말 탐지 넥타이 Relic 거짓말을 하면 목을 강하게 조릅니다. trust 3 trust -1 60 suspicion 5 숨 막히는 진실 공방.
13 AST_012 무한 동력 햄스터 Bio 지치지 않고 쳇바퀴를 도는 돌연변이. budget 20 entropy 5 250 entropy 10 존재해선 안 될 생물입니다.
14 AST_013 부패한 장부 원본 Info 전임 국장의 횡령 내역 파일. black_fund 20 suspicion 10 0 trust -50 이걸 태우면 당신도 공범입니다.
15 AST_014 둠스데이 버튼 Tech 세계를 멸망시킬 버튼. - - entropy 15 1000 entropy 100 절대 누르지 마시오.
16 AST_015 사랑의 묘약 Relic 상대를 맹목적으로 따르게 만듭니다. trust 5 suspicion 5 150 suspicion 20 화학적 동의도 동의입니까?
17 AST_016 좀비 바이러스 백신 Bio 세계 유일의 치료제 샘플. - - entropy 5 600 entropy 30 손 떨지 마세요.
18 AST_017 악마의 계약서 Relic 대가 란이 비어있는 백지 계약서. black_fund 30 entropy 8 5 entropy 2 핏방울을 떨어뜨리세요.
19 AST_018 투명 망토 Tech 투명해지지만 소리는 남음. suspicion -2 - - 90 suspicion 10 보이지 않는다고 없는 건 아니죠.
20 AST_019 기밀 하드디스크 Info 주요국 정상들의 치명적 스캔들. budget 50 trust -10 400 trust -100 판도라의 폴더.
21 AST_020 식인 식물 화분 Bio 시체 처리용으로 훌륭합니다. suspicion -5 trust -1 30 entropy
+13
View File
@@ -0,0 +1,13 @@
id,name,desc,tag,type,flavor
B_01,생명 존중법,무력 사용 및 사살 안건의 승인을 금지합니다.,kill,lock_approve,괴수의 생명도 생명입니까?
B_02,무제한 발포령,작전부의 전투/사살 안건 거절을 금지합니다.,kill,lock_reject,탄약은 충분합니다. 쏘십시오.
B_03,마법 통제법,통제 불능의 오컬트/마법 안건 승인을 금지합니다.,magic,lock_approve,미신의 시대는 끝났습니다.
B_04,생명 윤리법,비윤리적인 생체 실험 및 변이 안건 승인을 금지합니다.,bio,lock_approve,우리는 아직 인간으로 남아있어야 합니다.
B_05,예산 삭감안,예산 낭비가 우려되는 대규모 안건 승인을 금지합니다.,budget,lock_approve,영수증 처리가 안 되는 일은 하지 마십시오.
B_06,강제 징집령,작전 본부의 모든 방어/전투 안건 거절을 금지합니다.,ops,lock_reject,도망칠 곳은 없습니다.
B_07,정보 공개법,대중을 기만하는 은폐/기억소거 안건 승인을 금지합니다.,secret,lock_approve,대중은 진실을 알 권리가 있습니다.
B_08,비밀 유지법,기구를 외부로 노출시키는 양지화 안건 승인을 금지합니다.,public,lock_approve,우리는 영원히 그림자 속에 남습니다.
B_09,특별 감사법,자금 유용 및 횡령 관련 안건 거절을 금지(강제 수사)합니다.,audit,lock_reject,숨길 것이 없다면 두려울 것도 없습니다.
B_10,연구 지원법,연구 부서의 모든 기술/연구 안건 거절을 금지합니다.,research,lock_reject,과학 발전만이 인류의 살 길입니다.
B_11,순혈주의 선언,이종족 및 비인간 관련 안건의 승인을 엄격히 금지합니다.,pure,lock_approve,뾰족한 귀를 가진 자들을 믿지 마십시오.
B_12,진화 강제법,신체 개조를 미루거나 거절하는 행위를 금지합니다.,bio,lock_reject,진화를 거부하는 것은 곧 도태입니다.
1 id name desc tag type flavor
2 B_01 생명 존중법 무력 사용 및 사살 안건의 승인을 금지합니다. kill lock_approve 괴수의 생명도 생명입니까?
3 B_02 무제한 발포령 작전부의 전투/사살 안건 거절을 금지합니다. kill lock_reject 탄약은 충분합니다. 쏘십시오.
4 B_03 마법 통제법 통제 불능의 오컬트/마법 안건 승인을 금지합니다. magic lock_approve 미신의 시대는 끝났습니다.
5 B_04 생명 윤리법 비윤리적인 생체 실험 및 변이 안건 승인을 금지합니다. bio lock_approve 우리는 아직 인간으로 남아있어야 합니다.
6 B_05 예산 삭감안 예산 낭비가 우려되는 대규모 안건 승인을 금지합니다. budget lock_approve 영수증 처리가 안 되는 일은 하지 마십시오.
7 B_06 강제 징집령 작전 본부의 모든 방어/전투 안건 거절을 금지합니다. ops lock_reject 도망칠 곳은 없습니다.
8 B_07 정보 공개법 대중을 기만하는 은폐/기억소거 안건 승인을 금지합니다. secret lock_approve 대중은 진실을 알 권리가 있습니다.
9 B_08 비밀 유지법 기구를 외부로 노출시키는 양지화 안건 승인을 금지합니다. public lock_approve 우리는 영원히 그림자 속에 남습니다.
10 B_09 특별 감사법 자금 유용 및 횡령 관련 안건 거절을 금지(강제 수사)합니다. audit lock_reject 숨길 것이 없다면 두려울 것도 없습니다.
11 B_10 연구 지원법 연구 부서의 모든 기술/연구 안건 거절을 금지합니다. research lock_reject 과학 발전만이 인류의 살 길입니다.
12 B_11 순혈주의 선언 이종족 및 비인간 관련 안건의 승인을 엄격히 금지합니다. pure lock_approve 뾰족한 귀를 가진 자들을 믿지 마십시오.
13 B_12 진화 강제법 신체 개조를 미루거나 거절하는 행위를 금지합니다. bio lock_reject 진화를 거부하는 것은 곧 도태입니다.
+9
View File
@@ -0,0 +1,9 @@
id,name,color,share,rival,love_tag,hate_tag,desc
iron,철의 수호당,#ef4444,25,libra,kill,bio,괴수 즉결 처형 및 압도적 군사력 증강을 주장하는 강경파.
libra,지식 보존당,#3b82f6,20,iron,research,kill,괴수를 생포하고 연구하여 미지의 지식을 축적하려는 학자들.
pure,순수 인간당,#10b981,15,gene,pure,bio,"인간 중심주의를 표방하며, 이종족과 신체 개조를 극도로 혐오함."
gene,진화 미래당,#8b5cf6,10,pure,bio,pure,인류의 생물학적 한계를 초월하기 위해 괴수 DNA 이식 등을 시도함.
trade,차원 무역당,#f59e0b,10,covenant,budget,magic,이계와의 무역과 괴수 부산물 판매로 막대한 부를 축적하려는 자본가들.
covenant,고대 공존당,#06b6d4,5,trade,magic,budget,심연의 고대 신들을 숭배하며 그들과의 평화적(굴종적) 공존을 모색함.
veil,침묵의 장막당,#6b7280,10,reshape,secret,public,"대중이 진실을 알면 미쳐버릴 거라 믿으며, 철저한 은폐와 기억 소거를 집행함."
reshape,세계 재건당,#000000,5,veil,public,secret,비밀주의를 버리고 기구가 전면에 나서 세계 정부로 군림해야 한다고 믿음.
1 id name color share rival love_tag hate_tag desc
2 iron 철의 수호당 #ef4444 25 libra kill bio 괴수 즉결 처형 및 압도적 군사력 증강을 주장하는 강경파.
3 libra 지식 보존당 #3b82f6 20 iron research kill 괴수를 생포하고 연구하여 미지의 지식을 축적하려는 학자들.
4 pure 순수 인간당 #10b981 15 gene pure bio 인간 중심주의를 표방하며, 이종족과 신체 개조를 극도로 혐오함.
5 gene 진화 미래당 #8b5cf6 10 pure bio pure 인류의 생물학적 한계를 초월하기 위해 괴수 DNA 이식 등을 시도함.
6 trade 차원 무역당 #f59e0b 10 covenant budget magic 이계와의 무역과 괴수 부산물 판매로 막대한 부를 축적하려는 자본가들.
7 covenant 고대 공존당 #06b6d4 5 trade magic budget 심연의 고대 신들을 숭배하며 그들과의 평화적(굴종적) 공존을 모색함.
8 veil 침묵의 장막당 #6b7280 10 reshape secret public 대중이 진실을 알면 미쳐버릴 거라 믿으며, 철저한 은폐와 기억 소거를 집행함.
9 reshape 세계 재건당 #000000 5 veil public secret 비밀주의를 버리고 기구가 전면에 나서 세계 정부로 군림해야 한다고 믿음.
+1
View File
@@ -0,0 +1 @@
id,title,req_tags,cost_anomaly,reward_asset_id,penalty_entropy
1 id title req_tags cost_anomaly reward_asset_id penalty_entropy
+9
View File
@@ -0,0 +1,9 @@
id,title,target_type,target_dept,target_val,reward_txt,penalty_txt
Q_01,강경 진압,approve_count,작전 본부,3,예산 +$100,신임도 -10
Q_02,안정화 작업,maintain_entropy,-,25,신임도 +15,엔트로피 +15
Q_03,흑자 전환,black_fund,-,60,의심도 초기화,비자금 전액 몰수
Q_04,지식의 축적,approve_count,R&D 본부,2,새로운 자산 지급,예산 -$50
Q_05,정보 통제,approve_count,정보 본부,2,신임도 +10,의심도 +20
Q_06,철저한 관리,reject_count,-,3,예산 +$80,엔트로피 +10
Q_07,행정 쇄신,approve_count,행정 본부,2,의심도 -10,신임도 -5
Q_08,자본의 흐름,budget_up,-,600,비자금 +30,의심도 +15
1 id title target_type target_dept target_val reward_txt penalty_txt
2 Q_01 강경 진압 approve_count 작전 본부 3 예산 +$100 신임도 -10
3 Q_02 안정화 작업 maintain_entropy - 25 신임도 +15 엔트로피 +15
4 Q_03 흑자 전환 black_fund - 60 의심도 초기화 비자금 전액 몰수
5 Q_04 지식의 축적 approve_count R&D 본부 2 새로운 자산 지급 예산 -$50
6 Q_05 정보 통제 approve_count 정보 본부 2 신임도 +10 의심도 +20
7 Q_06 철저한 관리 reject_count - 3 예산 +$80 엔트로피 +10
8 Q_07 행정 쇄신 approve_count 행정 본부 2 의심도 -10 신임도 -5
9 Q_08 자본의 흐름 budget_up - 600 비자금 +30 의심도 +15
+41
View File
@@ -0,0 +1,41 @@
id,act,dept,color,title,body,faction,rival,tags,conflict,cost,yes_e,no_e,flavor,reward_asset_id
101,1,행정 본부,#d97706,탕비실 믹스커피 횡령,야간조 요원들이 비품실의 믹스커피 50박스를 사적으로 빼돌렸습니다. 횡령으로 징계할까요?,지식 보존당,iron,audit,원칙 vs 융통성,0,-1,1,맥심 골드는 중대 사항입니다.,AST_001
102,1,작전 본부,#c92a2a,하수구 슬라임,하수구에서 악취를 유발하는 하급 슬라임이 발견되었습니다. 화학반을 투입해 소각하시겠습니까?,철의 수호당,libra,"kill,ops",강경진압 vs 방치,20,-3,3,소금 뿌리면 안 됩니까?,-
103,1,정보 본부,#1c4f82,인터넷 괴담 검열,SNS에 퍼진 기현상 목격담을 통제망을 가동해 강제 삭제하고 작성자를 추적할까요?,침묵의 장막당,reshape,secret,은폐 vs 자유,15,-2,4,좋아요가 만 개를 넘으면 실체가 됩니다.,-
104,1,R&D 본부,#6b21a8,말하는 곰팡이 배양,창고에서 인간의 언어를 모방하는 곰팡이가 발견되었습니다. 소각 규정을 무시하고 배양할까요?,진화 미래당,pure,"bio,research",호기심 vs 규정,30,4,-2,목소리가 꽤 좋답니다.,AST_002
105,1,행정 본부,#d97706,국회 로비 자금,기구의 예산을 늘리기 위해 의원들에게 '도서 구입비' 명목의 비자금을 찔러 넣어줄까요?,세계 재건당,veil,"budget,audit",타협 vs 청렴,60,0,0,돈 냄새는 귀신도 좋아합니다.,-
106,1,작전 본부,#c92a2a,이단 종교 집회,괴수를 섬기는 '별빛 교단'이 민간인을 모아 집회를 엽니다. 무장 타격대로 무력 해산시킬까요?,철의 수호당,covenant,"kill,ops",무력 vs 평화,40,-4,5,이단에게 자비는 없습니다.,-
107,1,정보 본부,#1c4f82,상수도 기억소거,전투를 목격한 시민이 너무 많습니다. 상수도에 약한 기억소거제를 타서 광역 은폐를 실시할까요?,침묵의 장막당,reshape,"secret,bio",통제 vs 윤리,30,-5,6,수돗물 맛이 조금 달콤해집니다.,-
108,1,R&D 본부,#6b21a8,이단 유물 발굴,재개발 구역에서 고대 마법 유적이 발견되었습니다. 군의 폭파 주장을 묵살하고 발굴할까요?,지식 보존당,iron,"magic,research",지식 vs 파괴,40,3,-3,흙 묻은 역사가 미래를 엽니다.,-
109,1,행정 본부,#d97706,괴수 가죽 밀거래,창고팀이 죽은 괴수 가죽을 블랙마켓에 팔려다 걸렸습니다. 징계 대신 가죽을 팔아 예산에 보탤까요?,차원 무역당,covenant,"budget,audit",자본 vs 규정,-80,5,-2,돈 냄새가 피 냄새를 덮습니다.,-
110,1,작전 본부,#c92a2a,뱀파이어 요원 채용,전향한 흡혈귀를 요원으로 쓰자는 건의입니다. 피를 주기적으로 제공해야 하지만 효율은 좋습니다.,고대 공존당,pure,"bio,ops",공존 vs 순혈,20,2,-2,피만 제때 주면 우리 편입니다.,AST_009
111,1,정보 본부,#1c4f82,라디오 전파 납치,심야 라디오 DJ가 우리 조직을 폭로하려 합니다. 납치 세뇌 후 선전 방송 스피커로 재활용할까요?,세계 재건당,veil,"public,secret",세뇌 vs 침묵,50,4,-3,통제의 꽃은 대중의 열광입니다.,-
112,1,R&D 본부,#6b21a8,부상 요원 기계화,팔을 잃은 요원이 사이보그 개조 수술을 자원했습니다. 순수 인간주의 원칙을 깨고 수술할까요?,진화 미래당,pure,"bio,research",진화 vs 순수,40,3,-2,살점보다 강철이 믿음직합니다.,-
113,1,행정 본부,#d97706,이종족 구호소 건립,차원 균열 난민들을 위해 도심 외곽에 이종족 난민 구호소를 세울까요? 시민 반발이 거셀 겁니다.,고대 공존당,trade,"pure,budget",공존 vs 배척,60,4,-4,우리는 모두 우주의 미아입니다.,-
114,1,작전 본부,#c92a2a,예언자 스카웃,10초 뒤를 보는 사기꾼이 고액 연봉을 요구하며 합류를 제안했습니다. 채용할까요?,차원 무역당,covenant,"magic,ops",실용 vs 원칙,70,5,-2,하한가를 피하는 값싼 비용.,AST_007
115,1,정보 본부,#1c4f82,기밀문서 유출자,내부 고발자가 비윤리적 실험을 폭로하려다 체포되었습니다. 그를 '조용히 처리'할까요?,침묵의 장막당,reshape,"secret,kill",은폐 vs 진실,30,-5,8,진실은 무덤 속에 있어야 합니다.,AST_013
201,2,작전 본부,#c92a2a,베헤모스의 알 회수,불법 경매장에 도시 붕괴급 괴수의 알이 나왔습니다. 파괴하지 않고 연구용으로 회수할까요?,진화 미래당,iron,"ops,bio",연구 vs 안전,100,8,-5,품속에 넣으니 따뜻하네요.,AST_006
202,2,R&D 본부,#6b21a8,비유클리드 칩 도입,연산력은 무한하지만 주변 공간을 뒤틀어버리는 외계 칩을 메인 서버에 장착할까요?,세계 재건당,pure,"research,magic",기술 vs 현실붕괴,60,6,0,1+1=3이 되는 기적.,AST_005
203,2,행정 본부,#d97706,저주받은 금화,소지자의 수명을 갉아먹지만 무한히 금화를 쏟아내는 유적 항아리를 자금원으로 쓸까요?,차원 무역당,covenant,"magic,budget",자본 vs 생명,-150,10,-5,돈 냄새가 핏물보다 진하군요.,AST_004
204,2,작전 본부,#c92a2a,전술핵 타격,12구역에 S급 괴수가 출현했습니다. 수만 명의 희생을 감수하고 소형 전술핵 타격을 승인할까요?,철의 수호당,libra,"kill,ops",학살 vs 방어실패,120,-15,20,도시는 다시 지으면 됩니다.,-
205,2,정보 본부,#1c4f82,해킹 그룹 참수,적대 해커들이 1급 기밀을 훔치고 있습니다. 역추적 암살 프로그램을 가동해 뇌를 태워버릴까요?,침묵의 장막당,reshape,"secret,kill",암살 vs 유출,50,-8,15,모니터 너머에 죽음 배달.,-
206,2,R&D 본부,#6b21a8,초인 부대 스파르탄,고통을 느끼지 못하는 생체 병기 부대를 창설하기 위해 인간의 존엄성을 포기하시겠습니까?,진화 미래당,pure,"bio,ops",진화 vs 윤리,90,10,-8,괴물을 잡기 위해 괴물이 됩니다.,-
207,2,행정 본부,#d97706,외계 물질 매각,창고의 위험한 외계 광물을 군수 기업이 거액에 사겠다고 합니다. 나중 파장은 무시하고 팔까요?,차원 무역당,veil,"budget,public",자본 vs 은폐,-200,12,-6,위험은 저들에게 전가합시다.,-
208,2,정보 본부,#1c4f82,숭배자 국회 진출,괴수를 섬기는 유력 인사가 국회에 입성하려 합니다. 선거를 조작하여 무조건 당선을 저지할까요?,철의 수호당,covenant,"secret,public",개입 vs 중립,60,-5,8,정치는 총칼 없는 전쟁입니다.,-
209,2,작전 본부,#c92a2a,생체 병기 폭주,피아를 식별하지 못하는 개조 요원이 폭주 중입니다. 목의 자폭 목걸이를 기폭시켜 처형할까요?,순수 인간당,gene,"kill,bio",처분 vs 관용,30,-6,10,불량품은 폐기해야 합니다.,-
210,2,R&D 본부,#6b21a8,타임머신 가동,작전 실패를 돌리기 위해 현실 우주 붕괴 리스크(70%)를 안고 회귀 장치를 가동하시겠습니까?,지식 보존당,iron,"research,magic",시간조작 vs 순응,150,18,-5,시간을 속이면 시간도 우리를 속입니다.,-
211,2,행정 본부,#d97706,차원문 상업 개방,이종족 상인들에게 통행료를 받고 차원문을 열어주는 관세법을 승인하시겠습니까?,차원 무역당,pure,"budget,magic",개방 vs 통제,-120,10,-4,돈은 차원을 넘어서도 통용됩니다.,-
212,2,정보 본부,#1c4f82,언론사 대표 암살,방송사 대표가 내일 아침 기구의 실체를 폭로하려 합니다. 오늘 밤 특수 요원을 보내 암살할까요?,침묵의 장막당,reshape,"kill,secret",암살 vs 폭로,70,-10,18,기사는 발행되지 않을 때 완벽합니다.,-
213,2,작전 본부,#c92a2a,지성체 괴수 휴전,언어를 구사하는 괴수 여왕이 학살을 멈추는 조건으로 영토 인정을 요구합니다. 휴전할까요?,고대 공존당,iron,ops,평화 vs 자존심,0,5,-12,적과 악수하려면 칼을 놔야 합니다.,-
214,2,R&D 본부,#6b21a8,복제 요원 양산,감정이 삭제된 복제 인간 요원들을 공장에서 찍어내듯 양산하는 비윤리적 계획을 승인할까요?,진화 미래당,pure,"bio,ops",복제 vs 윤리,110,12,-6,슬퍼할 가족이 없는 완벽한 소모품.,-
215,2,행정 본부,#d97706,최후의 벙커,대중을 버리고 수뇌부만 피신할 수 있는 초거대 지하 벙커 건설에 막대한 예산을 쏟아부을까요?,세계 재건당,trade,budget,생존 vs 방치,180,8,-4,지하엔 버려진 자들의 비명이 안 들립니다.,-
301,3,정보 본부,#1c4f82,비밀 결사 공개,세계가 패닉에 빠졌습니다. 음지에서 벗어나 우리가 전 세계를 직접 통치하겠다고 선포할까요?,세계 재건당,veil,"public,secret",양지화 vs 은폐,100,15,-5,구원자는 그림자에 숨지 않습니다.,-
302,3,작전 본부,#c92a2a,차원 붕괴 폭탄,적의 차원을 영구 붕괴시키는 블랙홀 폭탄입니다. 여파로 지구도 절반 파괴됩니다. 쏘시겠습니까?,철의 수호당,libra,"kill,ops",파멸 vs 지속,200,25,-10,불타는 세계 위에서 승리를 외칩니다.,AST_014
303,3,R&D 본부,#6b21a8,인류 강제 진화,전 인류의 DNA를 변이시키는 가스를 살포해 강제로 진화시킵니다. 부작용 치사율은 50%입니다.,진화 미래당,pure,bio,진화 vs 순수,250,30,-15,낡은 허물을 벗어던질 때입니다.,-
304,3,행정 본부,#d97706,글로벌 금융 해킹,자금이 완전히 고갈되었습니다. 대공황을 감수하고 적대국의 중앙 은행을 해킹해 예산을 증발시킬까요?,차원 무역당,audit,"budget,secret",자본 vs 경제붕괴,-300,20,-8,세상이 망해도 돈은 씁니다.,-
305,3,정보 본부,#1c4f82,지구 뇌파 리셋,폭동을 잠재우기 위해 위성에서 펄스를 쏴 전 인류를 기억 상실의 꼭두각시로 만들까요?,침묵의 장막당,reshape,"secret,public",백지화 vs 혼돈,180,22,-12,무지가 곧 평화요 망각이 구원입니다.,-
306,3,R&D 본부,#6b21a8,아카식 레코드 접속,우주의 모든 진리가 담긴 서버에 접속합니다. 연구원 90%가 뇌사할 확률을 안고 강행할까요?,지식 보존당,covenant,"research,magic",진리 vs 파멸,120,18,-10,우주의 비밀 앞에 인간은 나약합니다.,-
307,3,작전 본부,#c92a2a,군단 맹약,외계 군단이 인류 절반을 노예로 바치는 대가로 침공을 멈추겠답니다. 항복 문서에 서명할까요?,고대 공존당,iron,ops,굴복 vs 멸망,80,15,-18,무릎 꿇어서라도 숨을 이어가야 합니다.,-
308,3,행정 본부,#d97706,유전자 방주 발사,지구를 포기하고 순수 인류 엘리트들만 태운 방주를 우주로 발사해 탈출하시겠습니까?,순수 인간당,gene,"pure,budget",도피 vs 투쟁,200,25,-8,불순물들은 죽어가는 지구에 둡니다.,-
309,3,R&D 본부,#6b21a8,차원 융합 폭주,물리법칙을 무시하고 적 차원과 우리 차원을 융합시켜 아예 새로운 우주를 창조할까요?,세계 재건당,trade,"magic,research",재창조 vs 유지,220,35,-20,헌 우주를 부수고 새 우주를 엽시다.,-
310,3,작전 본부,#c92a2a,대재앙 방관,다른 대륙에 초거대 괴수가 깨어났습니다. 우리 전력을 보존하기 위해 그들의 멸망을 방관할까요?,침묵의 장막당,iron,"ops,kill",방관 vs 저지,0,20,-15,지는 싸움은 피하는 게 상책입니,-
1 id act dept color title body faction rival tags conflict cost yes_e no_e flavor reward_asset_id
2 101 1 행정 본부 #d97706 탕비실 믹스커피 횡령 야간조 요원들이 비품실의 믹스커피 50박스를 사적으로 빼돌렸습니다. 횡령으로 징계할까요? 지식 보존당 iron audit 원칙 vs 융통성 0 -1 1 맥심 골드는 중대 사항입니다. AST_001
3 102 1 작전 본부 #c92a2a 하수구 슬라임 하수구에서 악취를 유발하는 하급 슬라임이 발견되었습니다. 화학반을 투입해 소각하시겠습니까? 철의 수호당 libra kill,ops 강경진압 vs 방치 20 -3 3 소금 뿌리면 안 됩니까? -
4 103 1 정보 본부 #1c4f82 인터넷 괴담 검열 SNS에 퍼진 기현상 목격담을 통제망을 가동해 강제 삭제하고 작성자를 추적할까요? 침묵의 장막당 reshape secret 은폐 vs 자유 15 -2 4 좋아요가 만 개를 넘으면 실체가 됩니다. -
5 104 1 R&D 본부 #6b21a8 말하는 곰팡이 배양 창고에서 인간의 언어를 모방하는 곰팡이가 발견되었습니다. 소각 규정을 무시하고 배양할까요? 진화 미래당 pure bio,research 호기심 vs 규정 30 4 -2 목소리가 꽤 좋답니다. AST_002
6 105 1 행정 본부 #d97706 국회 로비 자금 기구의 예산을 늘리기 위해 의원들에게 '도서 구입비' 명목의 비자금을 찔러 넣어줄까요? 세계 재건당 veil budget,audit 타협 vs 청렴 60 0 0 돈 냄새는 귀신도 좋아합니다. -
7 106 1 작전 본부 #c92a2a 이단 종교 집회 괴수를 섬기는 '별빛 교단'이 민간인을 모아 집회를 엽니다. 무장 타격대로 무력 해산시킬까요? 철의 수호당 covenant kill,ops 무력 vs 평화 40 -4 5 이단에게 자비는 없습니다. -
8 107 1 정보 본부 #1c4f82 상수도 기억소거 전투를 목격한 시민이 너무 많습니다. 상수도에 약한 기억소거제를 타서 광역 은폐를 실시할까요? 침묵의 장막당 reshape secret,bio 통제 vs 윤리 30 -5 6 수돗물 맛이 조금 달콤해집니다. -
9 108 1 R&D 본부 #6b21a8 이단 유물 발굴 재개발 구역에서 고대 마법 유적이 발견되었습니다. 군의 폭파 주장을 묵살하고 발굴할까요? 지식 보존당 iron magic,research 지식 vs 파괴 40 3 -3 흙 묻은 역사가 미래를 엽니다. -
10 109 1 행정 본부 #d97706 괴수 가죽 밀거래 창고팀이 죽은 괴수 가죽을 블랙마켓에 팔려다 걸렸습니다. 징계 대신 가죽을 팔아 예산에 보탤까요? 차원 무역당 covenant budget,audit 자본 vs 규정 -80 5 -2 돈 냄새가 피 냄새를 덮습니다. -
11 110 1 작전 본부 #c92a2a 뱀파이어 요원 채용 전향한 흡혈귀를 요원으로 쓰자는 건의입니다. 피를 주기적으로 제공해야 하지만 효율은 좋습니다. 고대 공존당 pure bio,ops 공존 vs 순혈 20 2 -2 피만 제때 주면 우리 편입니다. AST_009
12 111 1 정보 본부 #1c4f82 라디오 전파 납치 심야 라디오 DJ가 우리 조직을 폭로하려 합니다. 납치 세뇌 후 선전 방송 스피커로 재활용할까요? 세계 재건당 veil public,secret 세뇌 vs 침묵 50 4 -3 통제의 꽃은 대중의 열광입니다. -
13 112 1 R&D 본부 #6b21a8 부상 요원 기계화 팔을 잃은 요원이 사이보그 개조 수술을 자원했습니다. 순수 인간주의 원칙을 깨고 수술할까요? 진화 미래당 pure bio,research 진화 vs 순수 40 3 -2 살점보다 강철이 믿음직합니다. -
14 113 1 행정 본부 #d97706 이종족 구호소 건립 차원 균열 난민들을 위해 도심 외곽에 이종족 난민 구호소를 세울까요? 시민 반발이 거셀 겁니다. 고대 공존당 trade pure,budget 공존 vs 배척 60 4 -4 우리는 모두 우주의 미아입니다. -
15 114 1 작전 본부 #c92a2a 예언자 스카웃 10초 뒤를 보는 사기꾼이 고액 연봉을 요구하며 합류를 제안했습니다. 채용할까요? 차원 무역당 covenant magic,ops 실용 vs 원칙 70 5 -2 하한가를 피하는 값싼 비용. AST_007
16 115 1 정보 본부 #1c4f82 기밀문서 유출자 내부 고발자가 비윤리적 실험을 폭로하려다 체포되었습니다. 그를 '조용히 처리'할까요? 침묵의 장막당 reshape secret,kill 은폐 vs 진실 30 -5 8 진실은 무덤 속에 있어야 합니다. AST_013
17 201 2 작전 본부 #c92a2a 베헤모스의 알 회수 불법 경매장에 도시 붕괴급 괴수의 알이 나왔습니다. 파괴하지 않고 연구용으로 회수할까요? 진화 미래당 iron ops,bio 연구 vs 안전 100 8 -5 품속에 넣으니 따뜻하네요. AST_006
18 202 2 R&D 본부 #6b21a8 비유클리드 칩 도입 연산력은 무한하지만 주변 공간을 뒤틀어버리는 외계 칩을 메인 서버에 장착할까요? 세계 재건당 pure research,magic 기술 vs 현실붕괴 60 6 0 1+1=3이 되는 기적. AST_005
19 203 2 행정 본부 #d97706 저주받은 금화 소지자의 수명을 갉아먹지만 무한히 금화를 쏟아내는 유적 항아리를 자금원으로 쓸까요? 차원 무역당 covenant magic,budget 자본 vs 생명 -150 10 -5 돈 냄새가 핏물보다 진하군요. AST_004
20 204 2 작전 본부 #c92a2a 전술핵 타격 12구역에 S급 괴수가 출현했습니다. 수만 명의 희생을 감수하고 소형 전술핵 타격을 승인할까요? 철의 수호당 libra kill,ops 학살 vs 방어실패 120 -15 20 도시는 다시 지으면 됩니다. -
21 205 2 정보 본부 #1c4f82 해킹 그룹 참수 적대 해커들이 1급 기밀을 훔치고 있습니다. 역추적 암살 프로그램을 가동해 뇌를 태워버릴까요? 침묵의 장막당 reshape secret,kill 암살 vs 유출 50 -8 15 모니터 너머에 죽음 배달. -
22 206 2 R&D 본부 #6b21a8 초인 부대 스파르탄 고통을 느끼지 못하는 생체 병기 부대를 창설하기 위해 인간의 존엄성을 포기하시겠습니까? 진화 미래당 pure bio,ops 진화 vs 윤리 90 10 -8 괴물을 잡기 위해 괴물이 됩니다. -
23 207 2 행정 본부 #d97706 외계 물질 매각 창고의 위험한 외계 광물을 군수 기업이 거액에 사겠다고 합니다. 나중 파장은 무시하고 팔까요? 차원 무역당 veil budget,public 자본 vs 은폐 -200 12 -6 위험은 저들에게 전가합시다. -
24 208 2 정보 본부 #1c4f82 숭배자 국회 진출 괴수를 섬기는 유력 인사가 국회에 입성하려 합니다. 선거를 조작하여 무조건 당선을 저지할까요? 철의 수호당 covenant secret,public 개입 vs 중립 60 -5 8 정치는 총칼 없는 전쟁입니다. -
25 209 2 작전 본부 #c92a2a 생체 병기 폭주 피아를 식별하지 못하는 개조 요원이 폭주 중입니다. 목의 자폭 목걸이를 기폭시켜 처형할까요? 순수 인간당 gene kill,bio 처분 vs 관용 30 -6 10 불량품은 폐기해야 합니다. -
26 210 2 R&D 본부 #6b21a8 타임머신 가동 작전 실패를 돌리기 위해 현실 우주 붕괴 리스크(70%)를 안고 회귀 장치를 가동하시겠습니까? 지식 보존당 iron research,magic 시간조작 vs 순응 150 18 -5 시간을 속이면 시간도 우리를 속입니다. -
27 211 2 행정 본부 #d97706 차원문 상업 개방 이종족 상인들에게 통행료를 받고 차원문을 열어주는 관세법을 승인하시겠습니까? 차원 무역당 pure budget,magic 개방 vs 통제 -120 10 -4 돈은 차원을 넘어서도 통용됩니다. -
28 212 2 정보 본부 #1c4f82 언론사 대표 암살 방송사 대표가 내일 아침 기구의 실체를 폭로하려 합니다. 오늘 밤 특수 요원을 보내 암살할까요? 침묵의 장막당 reshape kill,secret 암살 vs 폭로 70 -10 18 기사는 발행되지 않을 때 완벽합니다. -
29 213 2 작전 본부 #c92a2a 지성체 괴수 휴전 언어를 구사하는 괴수 여왕이 학살을 멈추는 조건으로 영토 인정을 요구합니다. 휴전할까요? 고대 공존당 iron ops 평화 vs 자존심 0 5 -12 적과 악수하려면 칼을 놔야 합니다. -
30 214 2 R&D 본부 #6b21a8 복제 요원 양산 감정이 삭제된 복제 인간 요원들을 공장에서 찍어내듯 양산하는 비윤리적 계획을 승인할까요? 진화 미래당 pure bio,ops 복제 vs 윤리 110 12 -6 슬퍼할 가족이 없는 완벽한 소모품. -
31 215 2 행정 본부 #d97706 최후의 벙커 대중을 버리고 수뇌부만 피신할 수 있는 초거대 지하 벙커 건설에 막대한 예산을 쏟아부을까요? 세계 재건당 trade budget 생존 vs 방치 180 8 -4 지하엔 버려진 자들의 비명이 안 들립니다. -
32 301 3 정보 본부 #1c4f82 비밀 결사 공개 세계가 패닉에 빠졌습니다. 음지에서 벗어나 우리가 전 세계를 직접 통치하겠다고 선포할까요? 세계 재건당 veil public,secret 양지화 vs 은폐 100 15 -5 구원자는 그림자에 숨지 않습니다. -
33 302 3 작전 본부 #c92a2a 차원 붕괴 폭탄 적의 차원을 영구 붕괴시키는 블랙홀 폭탄입니다. 여파로 지구도 절반 파괴됩니다. 쏘시겠습니까? 철의 수호당 libra kill,ops 파멸 vs 지속 200 25 -10 불타는 세계 위에서 승리를 외칩니다. AST_014
34 303 3 R&D 본부 #6b21a8 인류 강제 진화 전 인류의 DNA를 변이시키는 가스를 살포해 강제로 진화시킵니다. 부작용 치사율은 50%입니다. 진화 미래당 pure bio 진화 vs 순수 250 30 -15 낡은 허물을 벗어던질 때입니다. -
35 304 3 행정 본부 #d97706 글로벌 금융 해킹 자금이 완전히 고갈되었습니다. 대공황을 감수하고 적대국의 중앙 은행을 해킹해 예산을 증발시킬까요? 차원 무역당 audit budget,secret 자본 vs 경제붕괴 -300 20 -8 세상이 망해도 돈은 씁니다. -
36 305 3 정보 본부 #1c4f82 지구 뇌파 리셋 폭동을 잠재우기 위해 위성에서 펄스를 쏴 전 인류를 기억 상실의 꼭두각시로 만들까요? 침묵의 장막당 reshape secret,public 백지화 vs 혼돈 180 22 -12 무지가 곧 평화요 망각이 구원입니다. -
37 306 3 R&D 본부 #6b21a8 아카식 레코드 접속 우주의 모든 진리가 담긴 서버에 접속합니다. 연구원 90%가 뇌사할 확률을 안고 강행할까요? 지식 보존당 covenant research,magic 진리 vs 파멸 120 18 -10 우주의 비밀 앞에 인간은 나약합니다. -
38 307 3 작전 본부 #c92a2a 군단 맹약 외계 군단이 인류 절반을 노예로 바치는 대가로 침공을 멈추겠답니다. 항복 문서에 서명할까요? 고대 공존당 iron ops 굴복 vs 멸망 80 15 -18 무릎 꿇어서라도 숨을 이어가야 합니다. -
39 308 3 행정 본부 #d97706 유전자 방주 발사 지구를 포기하고 순수 인류 엘리트들만 태운 방주를 우주로 발사해 탈출하시겠습니까? 순수 인간당 gene pure,budget 도피 vs 투쟁 200 25 -8 불순물들은 죽어가는 지구에 둡니다. -
40 309 3 R&D 본부 #6b21a8 차원 융합 폭주 물리법칙을 무시하고 적 차원과 우리 차원을 융합시켜 아예 새로운 우주를 창조할까요? 세계 재건당 trade magic,research 재창조 vs 유지 220 35 -20 헌 우주를 부수고 새 우주를 엽시다. -
41 310 3 작전 본부 #c92a2a 대재앙 방관 다른 대륙에 초거대 괴수가 깨어났습니다. 우리 전력을 보존하기 위해 그들의 멸망을 방관할까요? 침묵의 장막당 iron ops,kill 방관 vs 저지 0 20 -15 지는 싸움은 피하는 게 상책입니 -
Binary file not shown.
+32
View File
@@ -0,0 +1,32 @@
## 1. 프로젝트 개요
- 프로젝트명: Project SS (가제: 비밀 결사단)
- 장르: 로그라이트 권력 시뮬레이션 / 엔진 빌딩 / 정치 스릴러
- 플랫폼: 모바일 웹앱 및 하이브리드 앱 (세로 모드 전용)
- 핵심 가치 (Core Value):
1. 원하는 자원과 태그를 수집하기 위한 [능동적 딜레마 스와이프]
2. 모은 자원을 폭발시켜 미스터리를 해결하고 자산을 얻는 [엔진 빌딩]
3. 사상적 우호도에 기반하여, 필요할 때만 권력을 개입시키는 [선택적 정치 공작]
## 2. 주요 기능 요구사항 (Functional Requirements)
### 2.1. 데이터 관리 (Live DB)
- Google Sheets Sync: 사건(결재 카드), 파벌, 자산, 미스터리 등 게임 내 모든 수치와 텍스트를 구글 시트에서 Fetch하여 로드하는 구조 필수.
- 상태 관리 (State Management): 세션 내 플레이어의 4대 속성, 획득한 태그, 파벌별 우호도 수치를 실시간으로 추적 및 업데이트해야 함.
### 2.2. [메인 화면] 결재 시스템 (A vs B Swipe)
- 기능: 메인 화면 중앙의 카드를 좌/우로 드래그하여 투입할 부서(대응 방식)를 선택.
- 오버레이 피드백: 드래그 중 화면 좌우측 및 상단에 예상되는 획득 자원과 태그 아이콘을 시각적으로 미리 표시(Preview).
### 2.3. [미스터리 모달] 미스터리 보드 시스템 (Mystery Dossier)
- 조건부 팝업: 조건(엔트로피 달성, 특정 태그 N개 누적 등) 만족 시 보드(별도 UI 모달)에 새로운 미스터리 항목 동적 생성.
- 상태 이상(디버프) 처리: 미결재 상태의 미스터리가 보드에 존재할 경우, 매 턴(스와이프)마다 지정된 디버프 수치(예: 신임도 -2) 자동 차감 로직 구현.
- 해결 및 보상 처리: 플레이어가 요구되는 '빌드업 태그'와 재화를 지불하고 '해결' 버튼 클릭 시, 디버프 제거 및 지정된 '자산(Asset)'을 인벤토리에 추가.
### 2.4. [주간 의회 모달] 정치 및 우호도 시스템 (Affinity & Parliament)
- 스펙트럼 로직: 플레이어의 '성향 태그'가 변경될 때마다 8개 파벌의 우호도 상태(5단계)를 자동 재계산 및 업데이트.
- 액션 개입 (Intervention): 주간 의회 페이즈 진입 시, 특정 조건(우호도)을 만족하는 파벌을 대상으로 '정치 자본'을 지불하여 액션(이면 계약, 블랙리스트)을 트리거하는 버튼 활성화.
- 카운터 입법: 적대 파벌은 플레이어가 가장 많이 보유한 '빌드업 태그'를 파악하여, 해당 기능을 정지시키는 카운터 법안을 상정하는 타겟팅 로직 필요.
## 3. UI/UX 및 비기능 요구사항
- 뷰포트 최적화: 좁은 모바일 화면에서 카드 스와이프 영역(메인)과 스탯/로그 영역을 침범하지 않도록 dvh 기반의 레이아웃 설계.
- 조작 피드백: 미스터리 해결, 정치 액션 실행, 게임 오버 발생 시 강력한 햅틱 진동 및 사운드 효과 필수.
@@ -0,0 +1,88 @@
## 코어 루프 (Core Game Loop)
게임은 다음 4개의 톱니바퀴가 반복되며 굴러가는 구조를 가집니다.
1. 사건 대응 (스와이프): 매 턴 사건 카드가 등장하며, 유저는 [어느 부서를 투입할지(A vs B)] 선택합니다. 이를 통해 재화와 태그를 획득합니다.
2. 빌드업 (미스터리 보드): 획득한 태그와 재화가 쌓이면 **[미스터리]**가 해금됩니다. 방치 시 페널티를 받으며, 재화를 소모해 해결하면 시너지를 내는 **[자산(엔진)]**을 획득합니다.
3. 정치적 반작용 (우호도 스펙트럼): 유저의 선택(성향)에 따라 파벌들이 자동적으로 우호/적대 상태로 재편성됩니다.
4. 난관 돌파 (주간 의회): 주말이 되면 적대 파벌이 유저의 엔진을 부수는 **[카운터 법안]**을 냅니다. 유저는 권력을 개입(이면 계약 등)시켜 이를 방어하고 다시 일상 업무로 돌아갑니다.
## 2. 속성 정의 (Attributes Definition)
이 게임의 모든 행동과 목표, 패배 조건은 다음 속성들에 의해 결정됩니다.
### 2.1. 4대 핵심 재화 (자원)
- 🧬 변칙 데이터 (Anomaly Data): 연구/포획 시 획득. [미스터리 해결]에 소모되는 필수 성장 재화.
- 👑 정치 자본 (Political Clout): 은폐/통제 시 획득. [의회 액션]에 소모되는 필수 통제 재화.
- ⏱️ 엔트로피 (Entropy): 종말 척도 (0~100%).
- 효과: 일정 구간마다 시민 패닉 단계 상승 (게임 내 억까 이벤트 등장 확률 증가).
- 패배 조건: 100% 도달 시 즉시 [세계 멸망 엔딩 (Game Over)].
- 🛡️ 신임도 (Trust): 대중과 기구의 지지율. (플레이어의 HP)
- 패배 조건: 0에 도달 시 즉시 [국장 숙청 엔딩 (Game Over)].
### 3.2. 2대 태그 시스템 (결재 시 이중 획득)
- 🏷️ 빌드업 태그 (군사, 과학, 정보, 오컬트, 자본): 미스터리 해금 조건이자, 보유한 자산(엔진)의 시너지 발동 조건이 되는 실질적 재료.
- 🧭 성향 태그 (지배, 순응, 은폐, 혼돈): 파벌의 우호도를 결정짓고, 최종 엔딩의 분기를 나누는 사상적 나침반.
### 3.3. 파벌 우호도 5단계 (성향 태그에 의해 자동 변경)
1. 👑 핵심 동맹: 태그 공명. 유저의 주력 빌드업 속도 가속화.
2. 🤝 우호: 주간 의회 시, 유저에게 유리한 버프 법안 자동 상정.
3. ⚖️ 중립: 아무 효과 없음. (이면 계약 액션의 타겟)
4. ⚔️ 적대: 주간 의회 시, 유저의 주력 태그를 박살 내는 카운터 법안 자동 상정.
5. 🔥 파국: 성향이 극악이거나 블랙리스트 지정 시 도달. 법안 대신 극단적 '사보타주 테러(강제 페널티)' 이벤트 발생.
## 4. UI 화면 중심 시스템 정의
이 게임의 시스템은 크게 3개의 UI 화면을 중심으로 나뉘며, 각 화면에서 할 수 있는 행동이 곧 게임의 규칙입니다.
### 4.1. [메인 집무실 화면] : 결재 및 태그 수집 시스템
유저가 가장 많은 시간을 보내는 기본 화면입니다.
- UI 구조: 중앙에 틴더 형태의 스와이프 카드. 좌/우측에 대응 부서 아이콘 노출.
- 시스템 규칙:
- 카드는 '사건'을 의미하며, 좌우 스와이프는 '투입할 부서'를 의미합니다.
- 드래그 방향에 따라 명확히 어떤 자원(데이터, 자본, 신임도 등)과 태그(빌드업, 성향)를 획득/상실할지 미리 보여줍니다. (예산 제약 없음)
- 결재 완료 즉시 상단의 4대 재화 수치와 누적 태그 현황판이 업데이트됩니다.
### 3.2. [미스터리 결재판 화면] : 퀘스트 및 엔진 빌딩 시스템
유저가 직접 진척도를 관리하고 보상을 수령하는 서브 모달 창입니다.
- UI 구조: 현재 해금된 미스터리 목록 리스트. 각 항목에 [해결 비용], [방치 페널티], [보상 자산] 명시.
- 시스템 규칙:
- 해금: 특정 빌드업 태그가 N개 모이거나, 엔트로피가 N%를 넘으면 새로운 미스터리가 리스트에 추가됩니다.
- 디버프: 리스트에 미스터리가 남아있는 동안, 메인 화면에서 결재를 1회 할 때마다 지속 페널티(예: 엔트로피 자동 +1)가 부과됩니다.
- 해결: 유저가 요구되는 '빌드업 태그'와 '변칙 데이터'를 소모해 버튼을 누르면 미스터리가 지워지며, 인벤토리에 **[자산(패시브 엔진)]**이 추가됩니다.
- 승리 조건: 세계관급 재앙인 **[종결 미스터리 3종]**을 이 화면에서 모두 해결하면, 누적된 성향에 따른 **[진엔딩]**이 출력되며 승리합니다.
### 3.3. [주간 의회 화면] : 파벌 정치 및 난관 돌파 시스템
일정 턴(예: 7턴) 경과 시 강제로 진입하는 이벤트 화면입니다.
- UI 구조:
- 상단: 현재 상정된 법안의 내용과 상정 주체(적대 파벌).
- 중단: 8개 파벌의 현재 우호도 스펙트럼 현황.
- 하단: 유저가 개입할 수 있는 2개의 '액션 버튼(이면 계약, 블랙리스트)'.
- 시스템 규칙:
- 난관 발생: 적대 파벌은 유저 인벤토리에 가장 많은 '빌드업 태그'를 무력화하는 카운터 법안을 상정합니다.
- 능동적 방어 (액션): 유저는 상정된 법안을 막기 위해 아래의 선택을 할 수 있습니다.
1. 액션 포기: 법안이 통과되어 자산이 정지되는 페널티를 그대로 수용합니다.
2. [이면 계약] 실행: 정치 자본을 지불하고 우호도 [중립] 파벌을 선택합니다. 해당 파벌이 거부권을 행사해 법안이 즉시 무효화됩니다.
3. [블랙리스트] 실행: 정치 자본 지불 없이 [적대] 파벌을 탄압합니다. 해당 파벌은 [🔥파국] 상태가 되어 테러를 준비하지만, 유저는 다음 주 동안 이 파벌의 안건을 부결시킬 때마다 대량의 신임도와 정치 자본을 보상으로 수확할 수 있습니다.
## 4. UI 텍스트 레이아웃 구조 (가이드라인)
AI 및 개발 단계를 위한 최소화된 텍스트 기반 레이아웃 구조입니다.
### [Header: 대시보드 영역]
- [🛡️ 신임도: 80%] [⏱️ 엔트로피: 45%]
- [🧬 변칙 데이터: 150] [👑 정치 자본: 30]
- 우측 상단 햄버거 메뉴: [인벤토리(자산) 및 성향 로그 보기] 버튼
### [Body: 스와이프 결재 영역 (메인)]
- (좌측 표시기): ⚔️ 작전부 투입 (군사 태그 / 신임도+ / 변칙 데이터-)
- [카드 UI]: <사건 타이틀> 및 <사건 설명 텍스트>
- (우측 표시기): 🔬 R&D부 투입 (과학 태그 / 변칙 데이터+ / 신임도-)
### [Footer: 네비게이션 영역]
- [ 📁 미스터리 결재판 (🚨 1) ] 버튼 (알림 배지 표시)
- [ 🏛️ 이번 주 의회 동향 ] 버튼
### [Modal: 주간 의회 팝업]
- [상정된 법안]: <과학 기술 통제법> (효과: 과학 자산 모두 무효화)
- [파벌 현황표]: (철의 수호당: 우호) / (순수 인간당: 적대) ...
- [개입 액션 패널]:
- [ 🤝 이면 계약 (비용: 👑 50) ] -> 터치 시 중립 파벌 목록 드롭다운
- [ 🎯 블랙리스트 지정 (무료) ] -> 터치 시 적대 파벌 목록 드롭다운
- [ ⏩ 개입 없이 법안 통과 (방관) ]
+955
View File
@@ -0,0 +1,955 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#000000">
<title>Project SS - Live DB Edition</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&family=Nanum+Myeongjo:wght@700;800&family=Black+Han+Sans&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root {
--bg-dark: #1a1a1a; --paper: #e6dec5; --ink: #2b2b2b;
--red-seal: #c92a2a; --blue-seal: #1c4f82; --ui-border: #444;
--safe-top: env(safe-area-inset-top, 30px);
}
body {
background-color: #000; display: flex; justify-content: center; align-items: center;
height: 100dvh; margin: 0; padding: 0; font-family: 'Courier Prime', 'Nanum Myeongjo', serif;
overflow: hidden; color: #eee; user-select: none; touch-action: none;
}
.phone-wrapper {
width: 100%; height: 100%; max-width: 420px; background: #111;
position: relative; overflow: hidden; display: flex; flex-direction: column;
box-shadow: 0 0 50px rgba(0,0,0,0.8);
}
@media (min-width: 450px) and (min-height: 700px) {
.phone-wrapper { height: 90vh; max-height: 850px; border-radius: 35px; border: 8px solid #333; }
}
.safe-area-top { width: 100%; height: var(--safe-top); min-height: 30px; background: #000; z-index: 1000; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #666; font-weight: bold; letter-spacing: 2px; }
.screen { flex: 1; display: none; flex-direction: column; position: relative; padding-top: 5px; box-sizing: border-box; overflow: hidden; }
.screen.active { display: flex; }
.reset-btn { position: absolute; top: env(safe-area-inset-top, 10px); right: 10px; background: #333; color: #aaa; font-size: 10px; padding: 4px 8px; border-radius: 4px; z-index: 2000; cursor: pointer; }
.loading-screen { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; width: 100%; background: rgba(0,0,0,0.9); z-index: 5000; position: absolute; top: 0; left: 0; }
.loader-spinner { border: 4px solid rgba(255,255,255,0.1); width: 40px; height: 40px; border-radius: 50%; border-left-color: #c92a2a; animation: spin 1s linear infinite; margin-bottom: 20px; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.desk-bg { background: #2d1b14; background-image: repeating-linear-gradient(45deg, #281812 25%, transparent 25%, transparent 75%, #281812 75%, #281812); background-size: 20px 20px; }
.status-dashboard { background: rgba(20, 20, 20, 0.95); border-bottom: 2px solid var(--ui-border); padding: 8px 12px; box-shadow: 0 5px 15px rgba(0,0,0,0.5); display: flex; flex-direction: column; gap: 6px; z-index: 30; }
.week-display { display: flex; justify-content: space-between; font-size: 11px; color: #fff; border-bottom: 1px solid #333; padding-bottom: 4px; font-weight: bold; }
.week-highlight { color: #fcd34d; }
.mandate-sticky { background: #fef3c7; color: #4b5563; padding: 4px 8px; font-size: 10px; border-radius: 2px; box-shadow: 1px 1px 3px rgba(0,0,0,0.3); margin-bottom: 2px; display: flex; justify-content: space-between; align-items: center; transform: rotate(-1deg); }
.mandate-title { font-weight: 800; color: #000; margin-right: 5px; }
.quest-progress { font-family: monospace; font-weight: bold; color: #c92a2a; }
.res-row { display: flex; justify-content: space-between; gap: 4px; }
.res-box { flex: 1; background: #333; border: 1px solid #555; border-radius: 4px; padding: 4px; display: flex; flex-direction: column; font-size: 9px; position: relative; justify-content: center; }
.res-val { font-weight: bold; font-family: monospace; font-size: 11px; margin-top: 2px; }
.gauge-row { display: flex; justify-content: space-between; align-items: flex-end; padding-top: 2px; }
.stat-item { width: 32%; display: flex; flex-direction: column; align-items: center; position: relative; cursor: pointer; }
.stat-label { font-size: 9px; color: #888; margin-top: 4px; }
.trust-bar { width: 100%; height: 6px; background: #444; border-radius: 3px; overflow: hidden; }
.trust-fill { height: 100%; width: 80%; background: linear-gradient(90deg, #d4af37, #fcd34d); transition: width 0.3s; }
.entropy-clock { width: 34px; height: 34px; border-radius: 50%; border: 2px solid #555; background: #222; position: relative; }
.clock-face { width: 100%; height: 100%; border-radius: 50%; background: conic-gradient(#10b981 0deg 270deg, #ef4444 270deg 360deg); opacity: 0.4; }
.clock-hand { position: absolute; top: 50%; left: 50%; width: 45%; height: 2px; background: #fff; transform-origin: left center; transform: rotate(270deg); transition: transform 0.5s; }
.share-bars { width: 100%; display: flex; height: 6px; background: #000; border-radius: 3px; overflow: hidden; margin-top: 5px; }
.share-segment { height: 100%; transition: width 0.3s; }
.share-donut { width: 36px; height: 36px; border-radius: 50%; display: flex; justify-content: center; align-items: center; position: relative; }
.donut-hole { width: 22px; height: 22px; border-radius: 50%; background: #1a1a1a; position: absolute; }
.desk-area { flex: 1; position: relative; display: flex; justify-content: center; align-items: center; perspective: 1000px; background: #2d1b14; touch-action: none; overflow: hidden; }
.doc-card {
width: 80%; max-width: 300px; height: 60vh; max-height: 440px;
background: var(--paper); color: var(--ink); position: absolute;
box-shadow: 0 10px 25px rgba(0,0,0,0.6); display: flex; flex-direction: column;
padding: 15px; border: 1px solid #cbb; cursor: grab; transform-origin: 50% 100%;
will-change: transform; z-index: 10; transition: border-color 0.2s;
}
.doc-card:active { cursor: grabbing; }
.folder-tab { position: absolute; top: -20px; left: 0; width: 80px; height: 20px; background: var(--red-seal); border-radius: 5px 5px 0 0; color: #fff; font-size: 10px; display: flex; align-items: center; justify-content: center; font-weight: bold; }
.warning-postit { position: absolute; top: -10px; right: -10px; background: #ef4444; color: white; padding: 4px 8px; font-size: 10px; font-weight: bold; transform: rotate(5deg); box-shadow: 2px 2px 5px rgba(0,0,0,0.3); z-index: 20; display: none; }
.card-header { border-bottom: 2px solid #555; padding-bottom: 8px; margin-bottom: 8px; }
.card-title { font-family: 'Black Han Sans', sans-serif; font-size: 1.1rem; line-height: 1.2; margin-bottom: 4px; }
.tag-badge { background: #333; color: #fff; font-size: 9px; padding: 2px 4px; border-radius: 4px; display: inline-block; font-weight: bold; margin-right: 2px; }
.card-body { font-size: 12px; line-height: 1.4; flex: 1; margin-top: 5px; overflow-y: auto; padding-right: 5px; }
.card-body::-webkit-scrollbar { width: 4px; }
.card-body::-webkit-scrollbar-thumb { background: #bbb; border-radius: 4px; }
.flavor-text { margin-top: 10px; font-size: 10px; font-style: italic; color: #555; border-left: 3px solid #ccc; padding-left: 8px; margin-bottom: 30px; flex-shrink: 0; }
.faction-stamp { position: absolute; bottom: 10px; right: 10px; width: 60px; height: 60px; border: 3px solid var(--blue-seal); border-radius: 50%; color: var(--blue-seal); display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 10px; opacity: 0.8; transform: rotate(-15deg); mix-blend-mode: multiply; text-align: center; }
.overlay-stamp { position: absolute; top: 30px; font-size: 22px; font-weight: 900; border: 4px solid; padding: 5px 15px; border-radius: 8px; text-transform: uppercase; opacity: 0; pointer-events: none; z-index: 50; transform: rotate(-15deg); }
.stamp-reject { right: 20px; color: #1c4f82; border-color: #1c4f82; transform: rotate(15deg); }
.stamp-approve { left: 20px; color: #c92a2a; border-color: #c92a2a; transform: rotate(-15deg); }
.lock-chain-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: transparent; display: none; flex-direction: column; justify-content: center; align-items: center; pointer-events: none; z-index: 40; border: 3px solid #ef4444; box-shadow: inset 0 0 15px rgba(239, 68, 68, 0.3); }
.lock-chain-overlay.active { display: flex; }
.chains { font-size: 40px; color: #ef4444; opacity: 0.2; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: -1; }
.lock-msg { position: absolute; bottom: 60px; background: #ef4444; color: #fff; padding: 5px 12px; font-weight: bold; font-size: 11px; border-radius: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); animation: bounce 2s infinite; text-align: center; transition: all 0.3s; }
.lock-msg.right { right: 20px; left: auto; }
.lock-msg.left { left: 20px; right: auto; }
@keyframes bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
.law-warning-popup { position: absolute; top: 35%; left: 50%; transform: translate(-50%, -50%) rotate(-5deg); background: rgba(220, 38, 38, 0.95); color: #fff; padding: 10px 15px; border-radius: 8px; border: 2px solid #fff; box-shadow: 0 10px 20px rgba(0,0,0,0.5); text-align: center; z-index: 100; opacity: 0; transition: opacity 0.2s; pointer-events: none; width: 80%; }
.law-warning-popup.visible { opacity: 1; }
.law-title { font-weight: 900; font-size: 13px; margin-bottom: 2px; text-transform: uppercase; }
.law-desc { font-size: 10px; }
.predict-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 70; display: none; justify-content: center; align-items: center; }
.predict-box { background: rgba(0,0,0,0.95); color: #fff; padding: 12px; border-radius: 8px; font-size: 11px; display: flex; flex-direction: column; gap: 4px; box-shadow: 0 10px 30px rgba(0,0,0,0.8); border: 1px solid #555; width: 200px; text-align: center; }
.p-row { display: flex; justify-content: space-between; }
.p-up { color: #10b981; } .p-down { color: #ef4444; }
.desc-text { color: #aaa; font-size: 9px; margin-top: 2px; }
.menu-btn-group { position: absolute; bottom: 15px; left: 15px; right: 15px; display: flex; gap: 10px; z-index: 50; }
.menu-btn { flex: 1; background: #333; color: #ccc; border: 1px solid #555; padding: 10px; border-radius: 6px; font-size: 11px; display: flex; justify-content: center; gap: 5px; cursor: pointer; align-items: center; }
.modal { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.95); z-index: 2000; display: none; flex-direction: column; padding: 50px 20px 20px; }
.modal.active { display: flex; }
.close-btn { margin-top: auto; padding: 15px; background: #444; color: #fff; text-align: center; border-radius: 8px; cursor: pointer; font-weight: bold; }
.list-item { background: #222; padding: 10px; border-radius: 4px; display: flex; justify-content: space-between; align-items: center; color: #ccc; font-size: 11px; margin-bottom: 5px; flex-wrap: wrap; }
.dividend-tube { position: absolute; right: 5px; top: 50%; transform: translateY(-50%); width: 12px; height: 150px; background: rgba(0,0,0,0.5); border: 1px solid #555; border-radius: 6px; display: flex; flex-direction: column-reverse; padding: 2px; overflow: hidden; z-index: 5; }
.dividend-block { width: 100%; height: 8px; margin-top: 2px; border-radius: 2px; opacity: 0; animation: dropIn 0.3s forwards; }
@keyframes dropIn { from { transform: translateY(-50px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.passive-toast { position: absolute; top: 80px; left: 50%; transform: translateX(-50%); background: rgba(16, 185, 129, 0.9); color: white; padding: 8px 15px; border-radius: 20px; font-size: 10px; z-index: 200; box-shadow: 0 4px 10px rgba(0,0,0,0.3); pointer-events: none; opacity: 0; transition: opacity 0.5s; width: 80%; text-align: center; }
.passive-toast.active { opacity: 1; animation: floatUp 3s forwards; }
@keyframes floatUp { 0% { transform: translate(-50%, 0); opacity: 0; } 10% { transform: translate(-50%, -10px); opacity: 1; } 90% { transform: translate(-50%, -30px); opacity: 1; } 100% { transform: translate(-50%, -40px); opacity: 0; } }
/* Council Screen */
.council-bg { background: #111; background: radial-gradient(circle at 50% 30%, #1f1f1f 0%, #000 80%); justify-content: flex-start; padding: 15px; overflow-y: auto; }
.council-header { text-align: center; margin-bottom: 15px; border-bottom: 1px solid #444; padding-bottom: 10px; width: 100%; flex-shrink: 0; }
.council-title { font-size: 20px; font-weight: 900; color: #fff; font-family: 'Black Han Sans'; }
.council-phase { display: none; width: 100%; height: 100%; flex-direction: column; }
.council-phase.active { display: flex; }
.agenda-announce { background: #eee; color: #000; padding: 15px; transform: rotate(-1deg); box-shadow: 0 10px 30px rgba(0,0,0,0.8); margin-bottom: 15px; position: relative; }
.stamp-mark { position: absolute; top: 10px; right: 10px; border: 3px solid #c92a2a; color: #c92a2a; font-weight: bold; padding: 4px; transform: rotate(15deg); opacity: 0.8; font-size: 10px; }
.vote-section { width: 100%; background: #222; padding: 12px; border-radius: 8px; margin-bottom: 15px; border: 1px solid #444; }
.vote-title { font-size: 12px; font-weight: bold; color: #aaa; margin-bottom: 8px; text-align: center; }
.bill-card { display: flex; justify-content: space-between; align-items: center; background: #111; border: 1px solid #333; padding: 8px; margin-bottom: 5px; }
.bill-card.major { border-left: 4px solid #3b82f6; }
.bill-card.minor { border-left: 4px solid #ef4444; }
.bill-name { font-size: 11px; font-weight: bold; }
.vote-bar-container { height: 16px; background: #000; border-radius: 8px; overflow: hidden; display: flex; position: relative; margin-top: 8px; }
.vote-segment { height: 100%; transition: width 0.5s; }
.majority-line { position: absolute; left: 50%; top: 0; bottom: 0; width: 2px; background: #fff; z-index: 10; box-shadow: 0 0 5px #fff; }
.lobby-target-list { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; justify-content: center; }
.lobby-tag { background: #333; border: 1px solid #555; color: #ccc; padding: 4px 8px; border-radius: 15px; font-size: 9px; cursor: pointer; }
.lobby-tag:active { background: #555; }
.lobby-tag.active { border-color: #d4af37; color: #d4af37; }
.action-btn { margin-top: auto; width: 100%; background: #c92a2a; color: #fff; padding: 12px; font-weight: bold; border-radius: 8px; text-align: center; cursor: pointer; font-size: 13px; flex-shrink: 0; }
.action-btn:active { background: #991b1b; }
.action-btn.secondary { background: #10b981; margin-top: 10px; }
</style>
</head>
<body>
<div class="phone-wrapper">
<div id="loading-screen" class="loading-screen">
<div class="loader-spinner"></div>
<div class="text-sm font-bold text-white mb-1" id="loading-msg">데이터베이스 연동 중...</div>
<div class="text-[10px] text-gray-500">(Google Sheets 연결)</div>
</div>
<div class="safe-area-top">PROJECT SS</div>
<div class="reset-btn" onclick="initDB()">↻ RESTART</div>
<div id="desk" class="screen desk-bg">
<div id="passive-toast" class="passive-toast"></div>
<div class="status-dashboard">
<div class="week-display">
<span>제 <span id="week-num" class="week-highlight">1</span> 주</span>
<span>Day <span id="day-num" class="week-highlight">1</span>/5</span>
</div>
<div class="mandate-sticky">
<div><span class="text-xs text-gray-500">아젠다:</span> <span class="mandate-title" id="agenda-title">Loading...</span></div>
<div><span class="text-xs text-gray-500">퀘스트:</span> <span class="quest-progress" id="quest-status">0/0</span></div>
</div>
<!-- 4대 재화 (예산 제거 적용) -->
<div class="res-row">
<div class="res-box"><span class="text-[9px] text-gray-400 font-bold">🧬 데이터 (자원)</span><span class="res-val text-blue-300" id="val-anomaly">0</span></div>
<div class="res-box"><span class="text-[9px] text-gray-400 font-bold">👑 정치 자본</span><span class="res-val text-yellow-500" id="val-political">0</span></div>
</div>
<div class="gauge-row">
<div class="stat-item"><div class="trust-bar"><div class="trust-fill" id="bar-trust"></div></div><span class="stat-label">신임도 <span id="txt-trust">80%</span></span></div>
<div class="stat-item"><div class="entropy-clock"><div class="clock-face"></div><div class="clock-hand" id="hand-entropy"></div></div><span class="stat-label">종말시계</span></div>
<div class="stat-item" style="width: 35%;" onclick="openModal('faction-modal')"><div class="share-donut" id="donut-share"><div class="donut-hole"></div></div><span class="stat-label">성향(지분) <i class="fa-solid fa-circle-info text-[8px]"></i></span></div>
</div>
<div id="engine-hud" class="mt-2 text-[10px] bg-gray-800 p-2 w-full border border-gray-600 shadow-inner rounded" style="margin-top: 8px;">
<div class="flex justify-between items-center mb-1">
<div class="font-bold text-gray-300"><i class="fa-solid fa-microchip text-yellow-500"></i> 누적 빌드업 태그</div>
<div id="hud-synergy-badge" class="text-gray-500 transition-colors duration-500"><i class="fa-solid fa-bolt"></i> 연쇄 없음</div>
</div>
<div id="hud-tags-container" class="flex flex-wrap gap-1 mt-1"></div>
</div>
</div>
<div class="desk-area">
<div class="doc-card" id="current-card" style="display:none;">
<div class="folder-tab" id="c-dept">부서</div>
<div id="c-conflict" class="absolute top-2 right-2 font-bold text-yellow-500 bg-gray-800 px-3 py-1 rounded shadow-md border border-yellow-700 hidden text-[11px] z-10" style="transform: rotate(2deg);"></div>
<div class="warning-postit" id="c-warning">경고</div>
<div class="lock-chain-overlay" id="lock-overlay"><i class="fa-solid fa-link chains"></i><div class="lock-msg" id="lock-overlay-msg"><span id="lock-type-text"></span><br><span id="lock-reason" class="text-yellow-300 text-[10px] mt-1 block"></span></div></div>
<div class="law-warning-popup" id="law-popup"><div class="law-title" id="law-popup-title"></div><div class="law-desc" id="law-popup-desc"></div></div>
<div class="card-header">
<div class="card-title" id="c-title">Title</div>
<div class="flex justify-between items-center text-[9px] text-gray-600">
<span id="c-sender">Sender</span>
<div id="c-tags" class="flex gap-1"></div>
</div>
</div>
<div class="card-body" id="c-body">Body</div>
<div class="flavor-text" id="c-flavor">Flavor</div>
<div class="faction-stamp" id="c-faction">Faction</div>
<div class="predict-overlay" id="predict-overlay">
<div class="predict-box">
<div id="p-header" class="font-bold border-b border-gray-600 mb-2 pb-1 text-center"></div>
<div class="desc-text mb-2" id="p-desc"></div>
<div class="p-row">데이터 획득: <span id="p-anomaly" class="p-up"></span></div>
<div class="p-row">정치 자본: <span id="p-political" class="p-up"></span></div>
<div class="p-row">엔트로피: <span id="p-entropy"></span></div>
<div class="p-row mt-1">신임도: <span id="p-trust"></span></div>
<hr class="border-gray-600 my-1">
<div class="text-[9px] font-bold text-gray-300">획득 태그 (빌드업):</div>
<div id="p-tag-gain" class="text-[10px] font-bold text-yellow-400 mt-1 mb-2 bg-gray-800 rounded px-1 py-1"></div>
<div class="text-[9px] font-bold text-gray-300">성향 태그 (우호도) 변화:</div>
<div id="p-faction-change" class="text-[9px] mt-1 bg-gray-800 rounded px-1 py-1"></div>
</div>
</div>
<div class="overlay-stamp stamp-approve" id="stamp-approve">승인</div>
<div class="overlay-stamp stamp-reject" id="stamp-reject">거절</div>
</div>
<div id="empty-state" class="hidden text-center text-gray-500 text-xs absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"><i class="fa-solid fa-check-double text-4xl mb-2"></i><br>오늘의 업무 완료</div>
<div class="dividend-tube" id="dividend-tube"></div>
</div>
<div class="menu-btn-group">
<div class="menu-btn" onclick="openModal('mystery-modal')"><i class="fa-solid fa-magnifying-glass"></i> 미스터리</div>
<div class="menu-btn" onclick="openModal('asset-modal')" style="flex:0.5"><i class="fa-solid fa-box-open"></i> 자산(<span id="asset-count">0</span>)</div>
<div class="menu-btn" onclick="openModal('law-modal')" style="flex:0.5"><i class="fa-solid fa-gavel"></i> 법안</div>
</div>
</div>
<!-- Council Screen -->
<div id="council" class="screen council-bg">
<div class="council-header">
<div class="text-[10px] text-gray-500">WEEKLY COUNCIL</div>
<div class="council-title">제 <span id="c-week-num">1</span>주차 입법 회의</div>
</div>
<div id="council-phase-1" class="council-phase active">
<div class="flex-1 flex flex-col justify-center items-center py-2">
<div class="text-gray-400 text-[10px] mb-1">제1주주 통치 선포 (동맹 파벌)</div>
<div class="text-xl font-black text-white mb-4" id="ruling-faction">Loading</div>
<div class="agenda-announce w-full"><div class="stamp-mark">CONFIRMED</div><div class="text-[10px] text-gray-600 font-bold">금주의 아젠다</div><div class="text-base font-black mb-1" id="next-agenda-title"></div><div class="text-[10px] text-gray-500" id="next-agenda-desc"></div></div>
<div class="agenda-announce w-full bg-yellow-100"><div class="text-[10px] text-yellow-800 font-bold">금주의 퀘스트</div><div class="text-base font-black mb-1" id="next-quest-title"></div><div class="flex justify-between text-[9px] text-gray-600 mt-2"><span>보상: <span id="next-quest-reward"></span></span><span>실패: <span id="next-quest-penalty"></span></span></div></div>
</div>
<div class="action-btn secondary" onclick="startVotePhase()">입법 회의 시작</div>
</div>
<div id="council-phase-2" class="council-phase">
<div class="flex-1 w-full">
<div id="sabotage-alert" class="hidden mb-2 bg-red-900 border border-red-500 p-2 rounded text-center shadow-[0_0_10px_rgba(239,68,68,0.5)] animate-pulse">
<div class="text-red-200 text-[10px] font-bold">⚠️ 표적 견제 발의</div>
<div class="text-white text-xs font-black mt-1">적대 파벌이 국장의 [<span id="sabotage-target-tag" class="text-yellow-400"></span>] 엔진을 견제합니다!</div>
</div>
<div class="vote-section border-red-500 border"><div class="vote-title text-red-500"><i class="fa-solid fa-scale-balanced"></i> 입법 전쟁</div>
<div class="bill-card major"><div class="flex-1"><div class="flex justify-between"><span class="text-blue-400 text-[9px] font-bold">여당안 (동맹 파벌 제안)</span><span class="text-[9px]" id="bill-a-faction"></span></div><div class="bill-name" id="bill-a-name"></div><div class="bill-desc text-[9px] italic text-gray-400 mt-1" id="bill-a-flavor"></div></div></div>
<div class="text-center text-[10px] my-1 text-gray-500 font-bold">VS</div>
<div class="bill-card minor"><div class="flex-1"><div class="flex justify-between"><span class="text-red-400 text-[9px] font-bold">야당안 (적대 파벌 제안)</span><span class="text-[9px]" id="bill-b-faction"></span></div><div class="bill-name" id="bill-b-name"></div><div class="bill-desc text-[9px] italic text-gray-400 mt-1" id="bill-b-flavor"></div></div></div>
<div class="mt-3"><div class="flex justify-between text-[9px] font-bold mb-1"><span class="text-blue-400">A 지지 (<span id="vote-a-val">50</span>%)</span><span class="text-red-400">B 지지 (<span id="vote-b-val">50</span>%)</span></div><div class="vote-bar-container"><div class="majority-line"></div><div class="vote-segment bg-blue-600" id="vote-a" style="width:50%"></div><div class="vote-segment bg-red-600" id="vote-b" style="width:50%"></div></div></div>
<div class="grid grid-cols-2 gap-2 mt-3 text-[8px]"><div><div class="text-blue-400 border-b border-blue-900 mb-1">A 찬성 파벌</div><div id="list-support-a" class="text-gray-400 space-y-1"></div></div><div><div class="text-red-400 border-b border-red-900 mb-1">B 찬성 파벌</div><div id="list-support-b" class="text-gray-400 space-y-1"></div></div></div>
<div class="mt-3 pt-3 border-t border-gray-700"><div class="text-[9px] text-yellow-500 mb-1 font-bold">소수 정당 로비 (정치 자본 -10)</div><div class="lobby-target-list" id="lobby-targets"></div></div>
<div class="mt-2 pt-2 border-t border-gray-700"><div class="text-[9px] text-red-500 mb-1 font-bold">블랙리스트 탄압 (무료, 영구적 파국)</div><div class="lobby-target-list" id="blacklist-targets"></div></div>
</div>
</div>
<div class="action-btn" onclick="endCouncil()">결과 확정 및 다음 주 시작</div>
</div>
</div>
<!-- Modals -->
<div class="modal" id="mystery-modal">
<div class="flex justify-between items-center border-b-2 border-gray-600 pb-3 mb-3"><h2 class="text-white font-bold text-base">미스터리 보드</h2><div class="close-btn p-2 text-[10px]" onclick="closeModal('mystery-modal')">X</div></div>
<div class="text-[10px] text-gray-400 mb-3">누적된 빌드업 태그와 데이터를 소모하여 미스터리를 해결하고 강력한 영구 자산을 획득하세요. 방치된 미스터리는 매 턴 지속 페널티를 유발할 수 있습니다.</div>
<div class="flex-1 overflow-y-auto space-y-2" id="mystery-list"></div>
</div>
<div class="modal" id="asset-modal">
<div class="flex justify-between items-center border-b-2 border-gray-600 pb-3 mb-3"><h2 class="text-white font-bold text-base">보유 자산 (엔진)</h2><div class="close-btn p-2 text-[10px]" onclick="closeModal('asset-modal')">X</div></div>
<div class="flex-1 overflow-y-auto" id="asset-list"></div>
</div>
<div class="modal" id="law-modal">
<div class="flex justify-between items-center border-b-2 border-gray-600 pb-3 mb-3"><h2 class="text-white font-bold text-base">법안 및 규칙</h2><div class="close-btn p-2 text-[10px]" onclick="closeModal('law-modal')">X</div></div>
<div class="space-y-3 text-xs text-gray-300 overflow-y-auto">
<div><span class="text-yellow-500 font-bold block">현재 아젠다</span><span id="modal-agenda" class="font-bold text-white"></span><p id="modal-agenda-desc" class="text-[10px] text-gray-400 mt-1"></p></div>
<div><span class="text-red-500 font-bold block">현재 퀘스트</span><span id="modal-quest" class="font-bold text-white"></span><div class="text-[10px] mt-1 grid grid-cols-2 gap-2"><div class="bg-gray-800 p-2 rounded"><span class="text-green-400">보상:</span> <span id="quest-reward"></span></div><div class="bg-gray-800 p-2 rounded"><span class="text-red-400">패널티:</span> <span id="quest-penalty"></span></div></div></div>
<div><span class="text-blue-400 font-bold block">발효된 법안</span><ul id="modal-laws" class="list-disc pl-4 text-[10px] mt-1 space-y-1 text-gray-400"></ul></div>
</div>
</div>
<div class="modal" id="faction-modal">
<div class="flex justify-between items-center border-b-2 border-gray-600 pb-3 mb-3"><h2 class="text-white font-bold text-base">파벌 정보</h2><div class="close-btn p-2 text-[10px]" onclick="closeModal('faction-modal')">X</div></div>
<div class="flex-1 overflow-y-auto" id="faction-list-detail"></div>
</div>
</div>
<script>
const SHEET_URLS = {
factions: "./data/factions.csv", agendas: "./data/agendas.csv", quests: "./data/quests.csv",
bills: "./data/bills.csv", assets: "./data/assets.csv", scenarios: "./data/scenarios.csv",
mysteries: "./data/mysteries.csv"
};
const EFFECT_NAMES = { anomaly: "데이터", political: "정치 자본", trust: "신임도", entropy: "엔트로피" };
let DB_FACTIONS = []; let DB_AGENDAS = []; let DB_QUESTS = []; let DB_BILLS = []; let DB_ASSETS = []; let DB_SCENARIOS = []; let DB_MYSTERIES = [];
let state = {
week: 1, day: 1, anomaly: 20, political: 20, trust: 80, entropy: 10,
assets: [], builtTags: {}, activeLaws: [], activeMysteries: [],
currentAgenda: null, currentQuest: null, questProgress: 0, currentCard: null, lockState: "none",
council: { billA: null, billB: null, votesA: 50, votesB: 50, supportersA: [], supportersB: [] }
};
function normalizeKey(str) { return String(str).replace(/^\uFEFF/, '').trim().toLowerCase(); }
function normalizeValue(val) { return typeof val === 'string' ? val.trim() : val; }
function parseCSV(url) {
return new Promise((resolve) => {
if (!url || url.trim() === "") { resolve(null); return; }
Papa.parse(url, {
header: true, skipEmptyLines: true, download: true,
complete: function(results) {
resolve(results.data.map(row => {
let r={};
for(let k in row) r[normalizeKey(k)] = normalizeValue(row[k]);
return r;
}));
},
error: function() { resolve(null); }
});
});
}
async function initDB() {
document.getElementById('loading-screen').style.display = 'flex';
try {
const [factions, agendas, quests, bills, assets, scenarios, mysteries] = await Promise.all([
parseCSV(SHEET_URLS.factions), parseCSV(SHEET_URLS.agendas), parseCSV(SHEET_URLS.quests),
parseCSV(SHEET_URLS.bills), parseCSV(SHEET_URLS.assets), parseCSV(SHEET_URLS.scenarios),
parseCSV(SHEET_URLS.mysteries)
]);
if (factions && factions.length > 0) {
DB_FACTIONS = factions.map(f => ({
...f,
share: parseInt(f.share)||10,
like_tag: f.love_tag || f.like_tag || '',
hate_tag: f.hate_tag || ''
}));
}
if (agendas && agendas.length > 0) DB_AGENDAS = agendas;
if (quests && quests.length > 0) DB_QUESTS = quests;
if (bills && bills.length > 0) {
DB_BILLS = bills.map(b => {
let is_buff = b.type === 'lock_reject' || b.type === 'buff_tag';
return {
...b,
is_buff: is_buff,
target_tag: b.tag,
buff_tag: is_buff ? b.tag : null,
nullify_tag: !is_buff ? b.tag : null
};
});
}
if (assets && assets.length > 0) {
DB_ASSETS = assets.map(a => ({
...a,
tags: a.tags ? a.tags.split(',').map(t=>t.trim()) : [],
passive_effect: { anomaly: parseInt(a.passive_eff_val)||0 }, // 간단한 호환성 매핑
holding_risk: { entropy: parseInt(a.risk_eff_val)||0 }
}));
}
if (scenarios && scenarios.length > 0) {
DB_SCENARIOS = scenarios.map(s => ({
...s,
act: parseInt(s.act)||1,
tags: s.tags ? s.tags.split(',').map(t=>t.trim().replace(/^"|"$/g, '')) : [],
yes_a: parseInt(s.cost)||0, // cost를 데이터(anomaly) 획득/소모로 매핑
no_p: parseInt(s.conflict)||0, // conflict를 정치 자본 획득/소모로 매핑
yes_e: parseInt(s.yes_e)||0,
no_e: parseInt(s.no_e)||0,
}));
}
if (mysteries && mysteries.length > 0) {
DB_MYSTERIES = mysteries.map(m => {
let req_tags = {};
if(m.req_tags) m.req_tags.split(',').forEach(rt => { let parts = rt.split(':'); if(parts.length===2) req_tags[parts[0].trim()] = parseInt(parts[1]); });
return { ...m, req_tags: req_tags, cost_anomaly: parseInt(m.cost_anomaly)||0, penalty_entropy: parseInt(m.penalty_entropy)||0 };
});
}
} catch(e) {
console.error("CSV 파싱 에러:", e);
}
setTimeout(() => { document.getElementById('loading-screen').style.display = 'none'; startGame(); }, 500);
}
function startGame() {
state = { week: 1, day: 1, anomaly: 20, political: 20, trust: 80, entropy: 10, assets: [], builtTags: {}, activeLaws: [], activeMysteries: [...DB_MYSTERIES], questProgress: 0, lockState: "none" };
document.getElementById('desk').style.display = 'flex'; document.getElementById('council').style.display = 'none';
setupWeek();
}
function setupWeek() {
state.day = 1; state.questProgress = 0;
state.currentAgenda = DB_AGENDAS[Math.floor(Math.random()*DB_AGENDAS.length)] || DB_AGENDAS[0];
state.currentQuest = DB_QUESTS[Math.floor(Math.random()*DB_QUESTS.length)] || DB_QUESTS[0];
updateHUD(); nextDay();
}
function getAffinity(f) {
if (f.is_blacklisted) return { level: 5, name: "🔥 파국", color: "#ef4444" };
if (f.share >= 40) return { level: 1, name: "👑 핵심 동맹", color: "#3b82f6" };
if (f.share >= 20) return { level: 2, name: "🤝 우호", color: "#10b981" };
if (f.share >= 10) return { level: 3, name: "⚖️ 중립", color: "#9ca3af" };
return { level: 4, name: "⚔️ 적대", color: "#f59e0b" };
}
function nextDay() {
if (state.day > 5) { startCouncil(); return; }
if (state.entropy >= 100) { alert("종말 시계 100% 도달. 통제에 실패했습니다."); initDB(); return; }
applyAssetPassives();
state.activeMysteries.forEach(m => { if(m.penalty_entropy) state.entropy += m.penalty_entropy; });
let panicLevel = 1; let panicMsg = "";
if (state.entropy >= 81) panicLevel = 5;
else if (state.entropy >= 61) panicLevel = 4;
else if (state.entropy >= 41) panicLevel = 3;
else if (state.entropy >= 21) panicLevel = 2;
if (panicLevel === 2) { addStat('trust', -1); panicMsg = "[동요] 시민 불안 (신임도 -1)"; }
else if (panicLevel >= 3) { addStat('political', -1); addStat('trust', -2); panicMsg = "[혼란] 시스템 마비 (자본/신임도 하락)"; }
let terrorChance = panicLevel >= 4 ? 0.6 : 0.3;
let sabotageMsg = [];
if (panicMsg) sabotageMsg.push(panicMsg);
DB_FACTIONS.forEach(f => {
if(getAffinity(f).level === 5 && Math.random() < terrorChance) {
state.entropy += 5; state.trust -= 5;
sabotageMsg.push(`[파국] ${f.name} 테러!`);
}
});
if (sabotageMsg.length > 0) {
const toast = document.getElementById('passive-toast');
toast.innerText = sabotageMsg.join(' / ');
toast.className = 'passive-toast bg-red-600'; void toast.offsetWidth; toast.classList.add('active');
}
if (panicLevel === 5) {
state.currentCard = {
id: 999, dept: "재난 관리", color: "#991b1b",
title: "현실 붕괴 임박", body: "사회 시스템이 통제 불능 상태입니다. 막대한 자원을 희생해 시간을 벌겠습니까?",
faction: "전체 파벌", tags: [], yes_a: 40, no_p: 20, yes_e: -10, no_e: 15, flavor: "신은 우리를 버렸습니다."
};
} else {
let pool = DB_SCENARIOS.filter(s => s.act === state.week);
if(pool.length === 0) pool = DB_SCENARIOS;
state.currentCard = pool[Math.floor(Math.random() * pool.length)];
}
renderCard(state.currentCard);
const cardEl = document.getElementById('current-card');
cardEl.style.transition = 'none'; cardEl.style.transform = 'translate(0,0) scale(0.9)';
setTimeout(() => { cardEl.style.transition = 'transform 0.3s ease-out'; cardEl.style.transform = 'scale(1)'; }, 50);
updateHUD();
}
function getMainTag() {
let max = 0; let mainTag = null;
for(let t in state.builtTags) { if(state.builtTags[t] > max) { max = state.builtTags[t]; mainTag = t; } }
return { tag: mainTag, count: max, counts: state.builtTags };
}
function getNullifiedTags() {
return state.activeLaws.filter(l => l.type === "nullify_tag").map(l => l.nullify_tag);
}
function getBuffTags() {
return state.activeLaws.filter(l => l.type === "buff_tag").map(l => l.buff_tag);
}
let eventQueue = []; let isFlushing = false;
function enqueueEvent(triggerType, context) { eventQueue.push({ triggerType, context }); if(!isFlushing) flushEvents(); }
function flushEvents() {
isFlushing = true; let triggeredMsgs = [];
let counts = state.builtTags; let nTags = getNullifiedTags();
while(eventQueue.length > 0) {
let ev = eventQueue.shift();
state.assets.forEach(a => {
if (a.tags && a.tags.some(t => nTags.includes(t))) return;
if (a.combo_req > 0 && (!a.tags || !a.tags.some(t => counts[t] >= a.combo_req))) return;
if (a.trigger === ev.triggerType) {
if (a.effect_type === 'resource' && a.effect_target) {
state[a.effect_target] += a.effect_val;
triggeredMsgs.push(`[${a.name}] 연쇄 획득`);
}
}
});
}
if (triggeredMsgs.length > 0) {
const toast = document.getElementById('passive-toast');
toast.innerText = "연쇄: " + [...new Set(triggeredMsgs)].join(', ');
toast.className = 'passive-toast'; void toast.offsetWidth; toast.classList.add('active');
}
isFlushing = false; updateHUD();
}
function addStat(key, val) { if(val !== 0) { state[key] += val; if(val > 0) enqueueEvent('on_stat_increase', key); } }
function applyAssetPassives() {
let msg = []; let nTags = getNullifiedTags(); let bTags = getBuffTags();
state.assets.forEach(a => {
if (a.tags && a.tags.some(t => nTags.includes(t))) return;
let mult = (a.tags && a.tags.some(t => bTags.includes(t))) ? 2 : 1;
if(a.passive_effect) {
if(a.passive_effect.anomaly) { addStat('anomaly', a.passive_effect.anomaly * mult); msg.push(`데이터+${a.passive_effect.anomaly * mult}`); }
if(a.passive_effect.political) { addStat('political', a.passive_effect.political * mult); msg.push(`정치+${a.passive_effect.political * mult}`); }
}
if(a.holding_risk && a.holding_risk.entropy) { addStat('entropy', a.holding_risk.entropy); msg.push(`엔트로피+${a.holding_risk.entropy}`); }
if(!a.trigger && a.combo_req > 0 && a.tags && a.tags.some(t => state.builtTags[t] >= a.combo_req)) {
if(a.combo_effect_key) { addStat(a.combo_effect_key, a.combo_effect_val * mult); msg.push(`[시너지] +${a.combo_effect_val*mult}`); }
}
});
if(msg.length > 0) {
const toast = document.getElementById('passive-toast');
toast.innerText = "자산 효과: " + [...new Set(msg)].join(', ');
toast.className = 'passive-toast'; void toast.offsetWidth; toast.classList.add('active');
}
}
function renderCard(data) {
const card = document.getElementById('current-card');
card.style.display = 'flex'; card.style.borderColor = "#cbb"; card.style.transform = "translate(0,0)";
document.getElementById('c-dept').innerText = data.dept || '부서 불명';
document.getElementById('c-dept').style.backgroundColor = data.color || '#333';
document.getElementById('c-title').innerText = data.title || '제목 없음';
document.getElementById('c-body').innerText = data.body || '내용 없음';
document.getElementById('c-flavor').innerText = `"${data.flavor || ''}"`;
document.getElementById('c-faction').innerText = data.faction || '무소속';
let tHtml = "";
if(data.tags) data.tags.forEach(t => tHtml += `<span class="tag-badge">${t}</span>`);
document.getElementById('c-tags').innerHTML = tHtml;
let lockType = "none";
if(data.tags) {
state.activeLaws.forEach(bill => {
if(data.tags.includes(bill.tag)) { if(bill.type === "lock_approve") lockType = "approve"; if(bill.type === "lock_reject") lockType = "reject"; }
});
}
state.lockState = lockType;
const overlay = document.getElementById('lock-overlay');
if (lockType === 'approve') { overlay.classList.add('active'); document.getElementById('lock-type-text').innerText = "승인 금지"; }
else if (lockType === 'reject') { overlay.classList.add('active'); document.getElementById('lock-type-text').innerText = "거절 금지"; }
else overlay.classList.remove('active');
document.getElementById('stamp-approve').style.opacity = 0; document.getElementById('stamp-reject').style.opacity = 0;
document.getElementById('predict-overlay').style.display = 'none';
document.getElementById('law-popup').classList.remove('visible');
}
function updateHUD() {
document.getElementById('week-num').innerText = state.week; document.getElementById('day-num').innerText = state.day;
document.getElementById('val-anomaly').innerText = state.anomaly; document.getElementById('val-political').innerText = state.political;
document.getElementById('bar-trust').style.width = `${state.trust}%`; document.getElementById('txt-trust').innerText = `${state.trust}%`;
document.getElementById('hand-entropy').style.transform = `translateX(-50%) rotate(${(state.entropy / 100) * 360}deg)`;
let sortedFactions = [...DB_FACTIONS].sort((a,b)=>b.share - a.share);
// Donut Chart
const donut = document.getElementById('donut-share');
if(donut) {
let conicStr = []; let currentDeg = 0;
sortedFactions.forEach(f => {
if(f.share > 0) {
let deg = (f.share / 100) * 360;
conicStr.push(`${f.color} ${currentDeg}deg ${currentDeg + deg}deg`);
currentDeg += deg;
}
});
donut.style.background = `conic-gradient(${conicStr.join(', ')})`;
}
document.getElementById('asset-count').innerText = state.assets.length;
const tagsContainer = document.getElementById('hud-tags-container');
if(tagsContainer) {
tagsContainer.innerHTML = "";
let tagCount = 0;
for(let t in state.builtTags) {
if(state.builtTags[t] > 0) {
tagsContainer.innerHTML += `<div class="bg-gray-700 text-yellow-300 px-2 py-1 rounded text-[9px] font-bold shadow-sm">[${t}] ${state.builtTags[t]}</div>`;
tagCount++;
}
}
if(tagCount === 0) tagsContainer.innerHTML = "<span class='text-gray-500 italic'>없음</span>";
}
let hasActiveSynergy = state.assets.some(a => a.combo_req > 0 && a.tags && a.tags.some(t => state.builtTags[t] >= a.combo_req));
document.getElementById('hud-synergy-badge').className = hasActiveSynergy ? "text-yellow-400 font-bold animate-pulse" : "text-gray-500";
document.getElementById('hud-synergy-badge').innerHTML = hasActiveSynergy ? `<i class="fa-solid fa-bolt"></i> 시너지 가동` : `<i class="fa-solid fa-bolt"></i> 연쇄 없음`;
if (state.currentAgenda) {
const agTitle = document.getElementById('agenda-title'); if(agTitle) agTitle.innerText = state.currentAgenda.title;
}
}
function renderCard(data) {
const card = document.getElementById('current-card');
card.style.display = 'flex'; card.style.borderColor = "#cbb"; card.style.transform = "translate(0,0)";
document.getElementById('c-dept').innerText = data.dept || '부서 불명';
document.getElementById('c-dept').style.backgroundColor = data.color || '#333';
document.getElementById('c-title').innerText = data.title || '제목 없음';
document.getElementById('c-body').innerText = data.body || '내용 없음';
document.getElementById('c-flavor').innerText = `"${data.flavor || ''}"`;
document.getElementById('c-faction').innerText = data.faction || '무소속';
let tHtml = "";
if(data.tags) data.tags.forEach(t => tHtml += `<span class="tag-badge">[${t}]</span>`);
const cTags = document.getElementById('c-tags');
if(cTags) cTags.innerHTML = tHtml;
let conflictElem = document.getElementById('c-conflict');
if(conflictElem) {
if (data.no_p > 0) {
conflictElem.innerHTML = `거절 시 👑 +${data.no_p}`;
conflictElem.classList.remove('hidden');
} else {
conflictElem.classList.add('hidden');
}
}
let lockType = "none";
if(data.tags) {
state.activeLaws.forEach(bill => {
if(data.tags.includes(bill.tag)) { if(bill.type === "lock_approve") lockType = "approve"; if(bill.type === "lock_reject") lockType = "reject"; }
});
}
state.lockState = lockType;
const overlay = document.getElementById('lock-overlay');
if (lockType === 'approve') { overlay.classList.add('active'); document.getElementById('lock-type-text').innerText = "승인 금지"; }
else if (lockType === 'reject') { overlay.classList.add('active'); document.getElementById('lock-type-text').innerText = "거절 금지"; }
else overlay.classList.remove('active');
document.getElementById('stamp-approve').style.opacity = 0; document.getElementById('stamp-reject').style.opacity = 0;
document.getElementById('predict-overlay').style.display = 'none';
document.getElementById('law-popup').classList.remove('visible');
}
const card = document.getElementById('current-card');
let startX = 0, currentX = 0, isDragging = false;
card.addEventListener('mousedown', startDrag); card.addEventListener('touchstart', startDrag, {passive:false});
document.addEventListener('mousemove', moveDrag); document.addEventListener('touchmove', moveDrag, {passive:false});
document.addEventListener('mouseup', endDrag); document.addEventListener('touchend', endDrag);
function startDrag(e) { isDragging = true; startX = (e.type==='touchstart')?e.touches[0].clientX:e.clientX; }
function moveDrag(e) {
if(!isDragging) return;
currentX = ((e.type==='touchmove')?e.touches[0].clientX:e.clientX) - startX;
if (state.lockState === 'none' || (state.lockState==='approve' && currentX < 0) || (state.lockState==='reject' && currentX > 0)) {
card.style.transform = `translateX(${currentX}px) rotate(${currentX*0.05}deg)`;
if (Math.abs(currentX) > 40) showPrediction(currentX > 0); else document.getElementById('predict-overlay').style.display = 'none';
document.getElementById('stamp-approve').style.opacity = currentX > 0 ? Math.min(currentX/100, 1) : 0;
document.getElementById('stamp-reject').style.opacity = currentX < 0 ? Math.min(Math.abs(currentX)/100, 1) : 0;
} else {
document.getElementById('law-popup').classList.add('visible');
document.getElementById('law-popup-title').innerText = state.lockState === 'approve' ? "🚫 승인 당론 위배" : "🚫 거절 당론 위배";
}
}
function endDrag() {
isDragging = false; document.getElementById('predict-overlay').style.display = 'none'; document.getElementById('law-popup').classList.remove('visible');
if (currentX > 100 && state.lockState !== 'approve') processDecision('approve');
else if (currentX < -100 && state.lockState !== 'reject') processDecision('reject');
else { card.style.transition = "transform 0.3s"; card.style.transform = "translate(0,0)"; }
currentX = 0; document.getElementById('stamp-approve').style.opacity = 0; document.getElementById('stamp-reject').style.opacity = 0;
}
function showPrediction(isApprove) {
const p = document.getElementById('predict-overlay'); p.style.display = 'flex'; p.style.zIndex = "100";
const data = state.currentCard;
let tGainHtml = ""; let fGainHtml = "";
if(isApprove) {
document.getElementById('p-header').innerText = "승인 (APPROVE)"; document.getElementById('p-header').className = "font-bold border-b border-gray-600 mb-2 pb-1 text-center text-red-400";
document.getElementById('p-desc').innerText = "엔진 빌드업 (태그 확보)";
document.getElementById('p-anomaly').innerHTML = data.yes_a ? `<span class="${data.yes_a>0?'p-down':'p-up'}">${data.yes_a>0?'-':'+'}${Math.abs(data.yes_a)}</span>` : "0";
document.getElementById('p-political').innerHTML = "-";
document.getElementById('p-entropy').innerHTML = data.yes_e ? `<span class="p-down">${data.yes_e > 0?'+':''}${data.yes_e}</span>` : "0";
document.getElementById('p-trust').innerHTML = "-";
data.tags.forEach(t => tGainHtml += `[${t}] +1 `);
fGainHtml = "변동 없음 (침묵)";
} else {
document.getElementById('p-header').innerText = "거절 (REJECT)"; document.getElementById('p-header').className = "font-bold border-b border-gray-600 mb-2 pb-1 text-center text-blue-400";
document.getElementById('p-desc').innerText = "관료제 통제 (기각 및 정치공작)";
document.getElementById('p-anomaly').innerHTML = "-";
document.getElementById('p-political').innerHTML = data.no_p ? `+${data.no_p}` : "0";
document.getElementById('p-entropy').innerHTML = data.no_e ? `<span class="p-up">${data.no_e > 0?'+':''}${data.no_e}</span>` : "0";
document.getElementById('p-trust').innerHTML = `<span class="p-up">+2</span>`;
tGainHtml = "<span class='text-gray-500'>획득 불가</span>";
data.tags.forEach(t => {
DB_FACTIONS.forEach(f => {
if(f.like_tag === t) fGainHtml += ` <span style="color:${f.color}">${f.name} ▼▼</span>`;
if(f.hate_tag === t) fGainHtml += ` <span style="color:${f.color}">${f.name} ▲▲</span>`;
});
});
if(fGainHtml === "") fGainHtml = "영향력 변동 없음";
}
document.getElementById('p-tag-gain').innerHTML = tGainHtml;
document.getElementById('p-faction-change').innerHTML = fGainHtml;
}
function processDecision(type) {
const data = state.currentCard; let animateDir = type === 'approve' ? 500 : -500;
if (type === 'approve') {
addStat('anomaly', -(data.yes_a || 0)); addStat('entropy', data.yes_e || 0);
if(data.tags) {
data.tags.forEach(t => {
state.builtTags[t] = (state.builtTags[t]||0) + 1;
});
}
if (state.currentQuest) state.questProgress++;
enqueueEvent('on_approve', null);
} else {
addStat('political', data.no_p || 0); addStat('entropy', data.no_e || 0); addStat('trust', 2);
if(data.tags) {
data.tags.forEach(t => {
DB_FACTIONS.forEach(f=>{
if(f.like_tag===t) f.share=Math.max(0, f.share-3);
if(f.hate_tag===t) f.share+=2;
});
});
}
enqueueEvent('on_reject', null);
}
const tube = document.getElementById('dividend-tube');
const b = document.createElement('div'); b.className = 'dividend-block'; b.style.backgroundColor = type === 'approve' ? '#ef4444' : '#3b82f6'; tube.appendChild(b);
card.style.transition = "transform 0.3s ease-in"; card.style.transform = `translateX(${animateDir}px) rotate(${animateDir/20}deg)`;
updateHUD();
setTimeout(() => { card.style.display = 'none'; state.day++; nextDay(); }, 300);
}
// --- MYSTERY BOARD ---
function renderMysteries() {
const list = document.getElementById('mystery-list'); list.innerHTML = "";
let bTags = getBuffTags();
state.activeMysteries.forEach((m, idx) => {
let reqHtml = ""; let canSolve = true;
// 할인 로직
let discount = 0; if(m.req_tags) Object.keys(m.req_tags).forEach(t => { if(bTags.includes(t)) discount += 5; });
let finalCost = Math.max(0, m.cost_anomaly - discount);
if(m.req_tags) {
for(let t in m.req_tags) {
let has = state.builtTags[t] || 0; let need = m.req_tags[t];
reqHtml += `<span class="${has>=need?'text-green-400':'text-red-400'}">[${t}] ${has}/${need}</span> `;
if(has<need) canSolve = false;
}
}
if(state.anomaly < finalCost) canSolve = false;
let ast = DB_ASSETS.find(a=>a.id === m.reward_asset_id);
list.innerHTML += `<div class="bg-gray-800 p-3 rounded mb-2 border border-blue-900 shadow-md">
<div class="flex justify-between border-b border-gray-600 pb-1 mb-2"><span class="font-bold text-blue-300 text-sm">${m.title}</span><span class="text-xs text-red-400">매턴 엔트로피 +${m.penalty_entropy || 0}</span></div>
<div class="text-[10px] text-gray-300 flex justify-between items-center">
<div>
<div class="mb-1 text-yellow-300 font-bold">소모 태그: ${reqHtml}</div>
<div>데이터 비용: 🧬 <span class="${state.anomaly >= finalCost ? 'text-blue-300' : 'text-red-400'}">${finalCost}</span> ${discount>0 ? `<span class="text-green-400 ml-1">(법안 할인 -${discount})</span>`:''}</div>
<div class="mt-1 text-green-300 font-bold">보상: ${ast?ast.name:'엔진 자산'}</div>
</div>
<button class="${canSolve?'bg-blue-600 hover:bg-blue-500':'bg-gray-700 opacity-50 cursor-not-allowed'} px-3 py-2 rounded text-white font-bold flex flex-col items-center" onclick="${canSolve?`solveMystery(${idx})`:'return false;'}"><span>해독</span><span class="text-[8px] font-normal">(태그 소모)</span></button>
</div>
</div>`;
});
if(state.activeMysteries.length === 0) list.innerHTML = "<div class='text-center text-gray-500 text-xs mt-4'>진행 중인 미스터리가 없습니다.</div>";
}
function solveMystery(idx) {
let m = state.activeMysteries[idx];
let bTags = getBuffTags(); let discount = 0; if(m.req_tags) Object.keys(m.req_tags).forEach(t => { if(bTags.includes(t)) discount += 5; });
state.anomaly -= Math.max(0, m.cost_anomaly - discount);
if(m.req_tags) for(let t in m.req_tags) state.builtTags[t] -= m.req_tags[t];
let ast = DB_ASSETS.find(a=>a.id === m.reward_asset_id);
if(ast) state.assets.push(ast);
state.activeMysteries.splice(idx, 1);
alert(`[${m.title}] 미스터리 해결! ${ast?ast.name:''} 획득`);
renderMysteries(); updateHUD();
}
// --- COUNCIL ---
function startCouncil() {
document.getElementById('desk').style.display = 'none'; document.getElementById('council').style.display = 'flex';
updateHUD(); document.getElementById('council-phase-1').classList.add('active'); document.getElementById('council-phase-2').classList.remove('active');
let sortedFactions = [...DB_FACTIONS].sort((a,b)=>b.share - a.share);
document.getElementById('ruling-faction').innerText = sortedFactions[0].name; document.getElementById('ruling-faction').style.color = sortedFactions[0].color;
}
function startVotePhase() {
document.getElementById('council-phase-1').classList.remove('active'); document.getElementById('council-phase-2').classList.add('active');
let mainTagInfo = getMainTag(); let targetTag = mainTagInfo.tag;
// Affinity 적용하여 동맹(1,2)과 적대(4,5) 판별
let factionsWithAff = DB_FACTIONS.map(f => ({ ...f, aff: getAffinity(f) }));
let sortedFactions = factionsWithAff.sort((a,b) => a.aff.level - b.aff.level);
const top = sortedFactions[0]; // 가장 우호적인 파벌
const hostile = sortedFactions[sortedFactions.length-1]; // 가장 적대적인 파벌
let buffBills = DB_BILLS.filter(b => b.is_buff);
let debuffBills = DB_BILLS.filter(b => !b.is_buff);
let b1 = buffBills.find(b => b.target_tag === targetTag) || buffBills[0];
let b2 = debuffBills.find(b => b.target_tag === targetTag) || debuffBills[0];
if (targetTag && b2.target_tag === targetTag) {
document.getElementById('sabotage-alert').classList.remove('hidden'); document.getElementById('sabotage-target-tag').innerText = targetTag;
} else { document.getElementById('sabotage-alert').classList.add('hidden'); }
state.council.billA = b1; state.council.billB = b2;
document.getElementById('bill-a-faction').innerText = top.name; document.getElementById('bill-b-faction').innerText = hostile.name;
document.getElementById('bill-a-name').innerText = b1.name; document.getElementById('bill-a-flavor').innerText = `"${b1.flavor}"`;
document.getElementById('bill-b-name').innerText = b2.name; document.getElementById('bill-b-flavor').innerText = `"${b2.flavor}"`;
let supportersA = [], supportersB = [], swingers = [], enemies = []; let votesA = 0;
factionsWithAff.forEach(f => {
if(f.aff.level <= 2 || (f.like_tag === targetTag)) { supportersA.push(f.name); votesA += f.share; }
else if(f.aff.level >= 4 || (f.hate_tag === targetTag)) { supportersB.push(f.name); enemies.push(f); }
else { swingers.push(f); }
});
state.council.votesA = Math.max(0, votesA);
state.council.supportersA = supportersA;
state.council.supportersB = supportersB;
updateVoteUI();
const lobbyList = document.getElementById('lobby-targets'); lobbyList.innerHTML = "";
swingers.forEach(f => { lobbyList.innerHTML += `<div class="lobby-tag" onclick="lobbyFaction('${f.id}', event)">${f.name} (${f.share}%)</div>`; });
const blacklistList = document.getElementById('blacklist-targets'); blacklistList.innerHTML = "";
enemies.forEach(f => {
if(!f.is_blacklisted) blacklistList.innerHTML += `<div class="lobby-tag" style="border-color:#ef4444; color:#ef4444;" onclick="blacklistFaction('${f.id}', event)">${f.name}</div>`;
});
}
function lobbyFaction(fid, e) {
if(state.political < 10) { alert("정치 자본 부족"); return; }
state.political -= 10;
const fac = DB_FACTIONS.find(f => f.id === fid); state.council.votesA += fac.share;
const tgt = e.currentTarget; tgt.style.borderColor = "#10b981"; tgt.style.color = "#10b981"; tgt.innerText = "포섭됨"; tgt.onclick = null;
updateVoteUI(); updateHUD();
}
function blacklistFaction(fid, e) {
const fac = DB_FACTIONS.find(f => f.id === fid);
fac.is_blacklisted = true;
state.council.supportersB = state.council.supportersB.filter(name => name !== fac.name);
const tgt = e.currentTarget; tgt.style.background = "#ef4444"; tgt.style.color = "#fff"; tgt.innerText = "탄압됨"; tgt.onclick = null;
alert(`${fac.name}을(를) 영구적 파국 상태로 몰아넣었습니다. 다음 턴부터 사보타주 테러가 발생할 수 있습니다.`);
updateVoteUI(); updateHUD();
}
function updateVoteUI() {
const vA = Math.min(100, state.council.votesA);
document.getElementById('vote-a').style.width = `${vA}%`; document.getElementById('vote-b').style.width = `${100-vA}%`;
document.getElementById('vote-a-val').innerText = vA; document.getElementById('vote-b-val').innerText = 100-vA;
document.getElementById('list-support-a').innerText = state.council.supportersA.length > 0 ? state.council.supportersA.join(', ') : '없음';
document.getElementById('list-support-b').innerText = state.council.supportersB.length > 0 ? state.council.supportersB.join(', ') : '없음';
}
function endCouncil() {
const winner = state.council.votesA >= 50 ? state.council.billA : state.council.billB;
alert(`${winner.name} 가결! (다음 주 적용)`);
state.activeLaws.push(winner); state.week++; state.day = 1;
document.getElementById('council').style.display = 'none'; document.getElementById('desk').style.display = 'flex';
setupWeek();
}
window.openModal = (id) => {
document.getElementById(id).classList.add('active');
if(id==='mystery-modal') renderMysteries();
if(id==='asset-modal') {
document.getElementById('asset-list').innerHTML = state.assets.map(a => `<div class="list-item"><span class="font-bold flex items-center">${a.name} <span class="bg-gray-700 text-gray-300 px-1 rounded ml-1 text-[8px]">${a.tags?a.tags.join(','):''}</span></span><span class="text-[9px] text-green-400 ml-auto">[작동 중]</span></div>`).join('');
if(state.assets.length===0) document.getElementById('asset-list').innerHTML = "<div class='text-center text-xs text-gray-500'>없음</div>";
}
if(id==='faction-modal') {
let facHtml = "";
let sortedFactions = [...DB_FACTIONS].sort((a,b)=>b.share - a.share);
sortedFactions.forEach(f => {
let aff = getAffinity(f);
facHtml += `<div class="bg-gray-800 p-3 rounded mb-2 border-l-4" style="border-color:${f.color}">
<div class="flex justify-between items-center mb-1"><span class="font-bold" style="color:${f.color}">${f.name}</span><span class="text-xs font-bold" style="color:${aff.color}">${aff.name} (지분 ${f.share}%)</span></div>
<div class="text-[10px] text-gray-400 mb-1">${f.desc}</div>
<div class="text-[9px] text-gray-500">선호: <span class="text-green-400">[${f.like_tag||'없음'}]</span> / 혐오: <span class="text-red-400">[${f.hate_tag||'없음'}]</span></div>
</div>`;
});
document.getElementById('faction-list-detail').innerHTML = facHtml;
}
}
window.closeModal = (id) => document.getElementById(id).classList.remove('active');
initDB();
</script>
</body>
</html>
-4896
View File
File diff suppressed because it is too large Load Diff
-37
View File
@@ -1,37 +0,0 @@
{
"name": "project-ss",
"version": "1.0.0",
"description": "Project SS - Steam App",
"main": "main.js",
"scripts": {
"start": "electron .",
"build:win": "electron-builder --win --x64"
},
"build": {
"appId": "com.example.projectss",
"productName": "Project SS",
"directories": {
"output": "dist"
},
"files": [
"main.js",
"src/**/*",
"assets/**/*",
"package.json"
],
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
},
"win": {
"target": "nsis"
}
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"electron": "^41.1.1",
"electron-builder": "^26.8.1"
}
}
+46
View File
@@ -0,0 +1,46 @@
@echo off
REM ProjectSS playtest launcher.
REM ASCII only on purpose: Korean text inside a .bat is mangled by cmd.exe codepage
REM handling, and calling "chcp" mid-script corrupts the batch parser itself.
title ProjectSS - playtest
cd /d "%~dp0client"
where npm >nul 2>nul
if errorlevel 1 goto no_node
if not exist node_modules goto install
goto run
:install
echo.
echo First run - installing dependencies. This takes 1-2 minutes.
echo.
call npm install
if errorlevel 1 goto install_failed
:run
echo.
echo Starting dev server. Your browser will open automatically.
echo Close this window or press Ctrl+C to stop.
echo.
call npm run dev -- --open
echo.
echo Server stopped.
pause
exit /b 0
:no_node
echo.
echo [ERROR] Node.js is not installed.
echo Install the LTS build from https://nodejs.org and run this again.
echo.
pause
exit /b 1
:install_failed
echo.
echo [ERROR] npm install failed. See the messages above.
echo.
pause
exit /b 1
-925
View File
@@ -1,925 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#000000">
<title>Project SS - Live DB Edition</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&family=Nanum+Myeongjo:wght@700;800&family=Black+Han+Sans&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root {
--bg-dark: #1a1a1a; --paper: #e6dec5; --ink: #2b2b2b;
--red-seal: #c92a2a; --blue-seal: #1c4f82; --ui-border: #444;
--safe-top: env(safe-area-inset-top, 30px);
}
/* [수정] dvh를 사용하여 모바일 주소창 영역 제외하고 완벽히 꽉 차게 변경. 스크롤 원천 차단 */
body {
background-color: #000;
display: flex;
justify-content: center;
align-items: center;
height: 100dvh;
margin: 0;
padding: 0;
font-family: 'Courier Prime', 'Nanum Myeongjo', serif;
overflow: hidden;
color: #eee;
user-select: none;
touch-action: none;
}
/* [수정] 모바일에서는 화면을 꽉 채우도록 기본 설정 */
.phone-wrapper {
width: 100%;
height: 100%;
max-width: 420px;
background: #111;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 0 50px rgba(0,0,0,0.8);
}
/* [수정] PC/태블릿처럼 화면이 넓고 높은 환경에서만 폰 모양 테두리 적용 (비율로 자동 크기 조절) */
@media (min-width: 450px) and (min-height: 700px) {
.phone-wrapper {
height: 90vh; /* 화면 높이의 90%만 차지하게 하여 상하단 안 잘림 */
max-height: 850px;
border-radius: 35px;
border: 8px solid #333;
}
}
.safe-area-top { width: 100%; height: var(--safe-top); min-height: 30px; background: #000; z-index: 1000; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #666; font-weight: bold; letter-spacing: 2px; }
.screen { flex: 1; display: none; flex-direction: column; position: relative; padding-top: 5px; box-sizing: border-box; overflow: hidden; }
.screen.active { display: flex; }
.reset-btn { position: absolute; top: env(safe-area-inset-top, 10px); right: 10px; background: #333; color: #aaa; font-size: 10px; padding: 4px 8px; border-radius: 4px; z-index: 2000; cursor: pointer; }
/* Loading Screen Styles */
.loading-screen { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; width: 100%; background: rgba(0,0,0,0.9); z-index: 5000; position: absolute; top: 0; left: 0; }
.loader-spinner { border: 4px solid rgba(255,255,255,0.1); width: 40px; height: 40px; border-radius: 50%; border-left-color: #c92a2a; animation: spin 1s linear infinite; margin-bottom: 20px; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.desk-bg { background: #2d1b14; background-image: repeating-linear-gradient(45deg, #281812 25%, transparent 25%, transparent 75%, #281812 75%, #281812); background-size: 20px 20px; }
.status-dashboard { background: rgba(20, 20, 20, 0.95); border-bottom: 2px solid var(--ui-border); padding: 8px 12px; box-shadow: 0 5px 15px rgba(0,0,0,0.5); display: flex; flex-direction: column; gap: 6px; z-index: 30; }
.week-display { display: flex; justify-content: space-between; font-size: 11px; color: #fff; border-bottom: 1px solid #333; padding-bottom: 4px; font-weight: bold; }
.week-highlight { color: #fcd34d; }
.mandate-sticky { background: #fef3c7; color: #4b5563; padding: 4px 8px; font-size: 10px; border-radius: 2px; box-shadow: 1px 1px 3px rgba(0,0,0,0.3); margin-bottom: 2px; display: flex; justify-content: space-between; align-items: center; transform: rotate(-1deg); }
.mandate-title { font-weight: 800; color: #000; margin-right: 5px; }
.quest-progress { font-family: monospace; font-weight: bold; color: #c92a2a; }
.res-row { display: flex; justify-content: space-between; gap: 4px; }
.res-box { flex: 1; background: #333; border: 1px solid #555; border-radius: 4px; padding: 4px; display: flex; flex-direction: column; font-size: 9px; position: relative; justify-content: center; }
.res-val { font-weight: bold; font-family: monospace; font-size: 11px; margin-top: 2px; }
.res-val.deficit { color: #ef4444; animation: pulse-red 1s infinite; }
@keyframes pulse-red { 0% { opacity: 1; } 50% { opacity: 0.5; } 100% { opacity: 1; } }
.gauge-row { display: flex; justify-content: space-between; align-items: flex-end; padding-top: 2px; }
.stat-item { width: 32%; display: flex; flex-direction: column; align-items: center; position: relative; cursor: pointer; }
.stat-label { font-size: 9px; color: #888; margin-top: 4px; }
.trust-bar { width: 100%; height: 6px; background: #444; border-radius: 3px; overflow: hidden; }
.trust-fill { height: 100%; width: 80%; background: linear-gradient(90deg, #d4af37, #fcd34d); transition: width 0.3s; }
.entropy-clock { width: 34px; height: 34px; border-radius: 50%; border: 2px solid #555; background: #222; position: relative; }
.clock-face { width: 100%; height: 100%; border-radius: 50%; background: conic-gradient(#10b981 0deg 270deg, #ef4444 270deg 360deg); opacity: 0.4; }
.clock-hand { position: absolute; top: 50%; left: 50%; width: 45%; height: 2px; background: #fff; transform-origin: left center; transform: rotate(270deg); transition: transform 0.5s; }
.share-bars { width: 100%; display: flex; height: 6px; background: #000; border-radius: 3px; overflow: hidden; margin-top: 5px; }
.share-segment { height: 100%; transition: width 0.3s; }
.desk-area { flex: 1; position: relative; display: flex; justify-content: center; align-items: center; perspective: 1000px; background: #2d1b14; touch-action: none; overflow: hidden; }
/* [수정] 카드의 크기를 유동적(비율)으로 설정하여 좁은 화면에서도 삐져나가지 않음 */
.doc-card {
width: 80%;
max-width: 300px;
height: 60vh; /* 화면 높이의 60%만 차지하게 설정 */
max-height: 440px;
background: var(--paper);
color: var(--ink);
position: absolute;
box-shadow: 0 10px 25px rgba(0,0,0,0.6);
display: flex;
flex-direction: column;
padding: 15px;
border: 1px solid #cbb;
cursor: grab;
transform-origin: 50% 100%;
will-change: transform;
z-index: 10;
transition: border-color 0.2s;
}
.doc-card:active { cursor: grabbing; }
.folder-tab { position: absolute; top: -20px; left: 0; width: 80px; height: 20px; background: var(--red-seal); border-radius: 5px 5px 0 0; color: #fff; font-size: 10px; display: flex; align-items: center; justify-content: center; font-weight: bold; }
.warning-postit { position: absolute; top: -10px; right: -10px; background: #ef4444; color: white; padding: 4px 8px; font-size: 10px; font-weight: bold; transform: rotate(5deg); box-shadow: 2px 2px 5px rgba(0,0,0,0.3); z-index: 20; display: none; }
.card-header { border-bottom: 2px solid #555; padding-bottom: 8px; margin-bottom: 8px; }
.card-title { font-family: 'Black Han Sans', sans-serif; font-size: 1.1rem; line-height: 1.2; margin-bottom: 4px; }
.conflict-badge { background: #222; color: #fff; font-size: 9px; padding: 2px 4px; border-radius: 4px; display: inline-block; font-weight: bold; }
.asset-badge { background: #059669; color: #fff; font-size: 9px; padding: 2px 4px; border-radius: 4px; margin-left: 4px; display: inline-block; font-weight: bold; }
/* 내용이 길 경우 스크롤 가능하게 처리 */
.card-body { font-size: 12px; line-height: 1.4; flex: 1; margin-top: 5px; overflow-y: auto; padding-right: 5px; }
/* 스크롤바 숨기기 */
.card-body::-webkit-scrollbar { width: 4px; }
.card-body::-webkit-scrollbar-thumb { background: #bbb; border-radius: 4px; }
.flavor-text { margin-top: 10px; font-size: 10px; font-style: italic; color: #555; border-left: 3px solid #ccc; padding-left: 8px; margin-bottom: 30px; flex-shrink: 0; }
.faction-stamp { position: absolute; bottom: 10px; right: 10px; width: 60px; height: 60px; border: 3px solid var(--blue-seal); border-radius: 50%; color: var(--blue-seal); display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 10px; opacity: 0.8; transform: rotate(-15deg); mix-blend-mode: multiply; text-align: center; }
.overlay-stamp { position: absolute; top: 30px; font-size: 22px; font-weight: 900; border: 4px solid; padding: 5px 15px; border-radius: 8px; text-transform: uppercase; opacity: 0; pointer-events: none; z-index: 50; transform: rotate(-15deg); }
.stamp-reject { right: 20px; color: #1c4f82; border-color: #1c4f82; transform: rotate(15deg); }
.stamp-approve { left: 20px; color: #c92a2a; border-color: #c92a2a; transform: rotate(-15deg); }
.lock-chain-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: transparent; display: none; flex-direction: column; justify-content: center; align-items: center; pointer-events: none; z-index: 40; border: 3px solid #ef4444; box-shadow: inset 0 0 15px rgba(239, 68, 68, 0.3); }
.lock-chain-overlay.active { display: flex; }
.chains { font-size: 40px; color: #ef4444; opacity: 0.2; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: -1; }
.lock-msg { position: absolute; bottom: 60px; background: #ef4444; color: #fff; padding: 5px 12px; font-weight: bold; font-size: 11px; border-radius: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); animation: bounce 2s infinite; text-align: center; transition: all 0.3s; }
.lock-msg.right { right: 20px; left: auto; }
.lock-msg.left { left: 20px; right: auto; }
@keyframes bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
.law-warning-popup { position: absolute; top: 35%; left: 50%; transform: translate(-50%, -50%) rotate(-5deg); background: rgba(220, 38, 38, 0.95); color: #fff; padding: 10px 15px; border-radius: 8px; border: 2px solid #fff; box-shadow: 0 10px 20px rgba(0,0,0,0.5); text-align: center; z-index: 100; opacity: 0; transition: opacity 0.2s; pointer-events: none; width: 80%; }
.law-warning-popup.visible { opacity: 1; }
.law-title { font-weight: 900; font-size: 13px; margin-bottom: 2px; text-transform: uppercase; }
.law-desc { font-size: 10px; }
.predict-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 70; display: none; justify-content: center; align-items: center; }
.predict-box { background: rgba(0,0,0,0.95); color: #fff; padding: 12px; border-radius: 8px; font-size: 11px; display: flex; flex-direction: column; gap: 4px; box-shadow: 0 10px 30px rgba(0,0,0,0.8); border: 1px solid #555; width: 200px; }
.p-row { display: flex; justify-content: space-between; }
.p-up { color: #10b981; } .p-down { color: #ef4444; }
.desc-text { color: #aaa; font-size: 9px; margin-top: 2px; text-align: right; }
.menu-btn-group { position: absolute; bottom: 15px; left: 15px; right: 15px; display: flex; gap: 10px; z-index: 50; }
.menu-btn { flex: 1; background: #333; color: #ccc; border: 1px solid #555; padding: 10px; border-radius: 6px; font-size: 11px; display: flex; justify-content: center; gap: 5px; cursor: pointer; align-items: center; }
.modal { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.95); z-index: 2000; display: none; flex-direction: column; padding: 50px 20px 20px; }
.modal.active { display: flex; }
.close-btn { margin-top: auto; padding: 15px; background: #444; color: #fff; text-align: center; border-radius: 8px; cursor: pointer; font-weight: bold; }
.list-item { background: #222; padding: 10px; border-radius: 4px; display: flex; justify-content: space-between; align-items: center; color: #ccc; font-size: 11px; margin-bottom: 5px; flex-wrap: wrap; }
.dividend-tube { position: absolute; right: 5px; top: 50%; transform: translateY(-50%); width: 12px; height: 150px; background: rgba(0,0,0,0.5); border: 1px solid #555; border-radius: 6px; display: flex; flex-direction: column-reverse; padding: 2px; overflow: hidden; z-index: 5; }
.dividend-block { width: 100%; height: 8px; margin-top: 2px; border-radius: 2px; opacity: 0; animation: dropIn 0.3s forwards; }
@keyframes dropIn { from { transform: translateY(-50px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.passive-toast { position: absolute; top: 80px; left: 50%; transform: translateX(-50%); background: rgba(16, 185, 129, 0.9); color: white; padding: 8px 15px; border-radius: 20px; font-size: 10px; z-index: 200; box-shadow: 0 4px 10px rgba(0,0,0,0.3); pointer-events: none; opacity: 0; transition: opacity 0.5s; width: 80%; text-align: center; }
.passive-toast.active { opacity: 1; animation: floatUp 3s forwards; }
@keyframes floatUp { 0% { transform: translate(-50%, 0); opacity: 0; } 10% { transform: translate(-50%, -10px); opacity: 1; } 90% { transform: translate(-50%, -30px); opacity: 1; } 100% { transform: translate(-50%, -40px); opacity: 0; } }
/* Council Screen */
.council-bg { background: #111; background: radial-gradient(circle at 50% 30%, #1f1f1f 0%, #000 80%); justify-content: flex-start; padding: 15px; overflow-y: auto; }
.council-header { text-align: center; margin-bottom: 15px; border-bottom: 1px solid #444; padding-bottom: 10px; width: 100%; flex-shrink: 0; }
.council-title { font-size: 20px; font-weight: 900; color: #fff; font-family: 'Black Han Sans'; }
.council-phase { display: none; width: 100%; height: 100%; flex-direction: column; }
.council-phase.active { display: flex; }
.agenda-announce { background: #eee; color: #000; padding: 15px; transform: rotate(-1deg); box-shadow: 0 10px 30px rgba(0,0,0,0.8); margin-bottom: 15px; position: relative; }
.stamp-mark { position: absolute; top: 10px; right: 10px; border: 3px solid #c92a2a; color: #c92a2a; font-weight: bold; padding: 4px; transform: rotate(15deg); opacity: 0.8; font-size: 10px; }
.vote-section { width: 100%; background: #222; padding: 12px; border-radius: 8px; margin-bottom: 15px; border: 1px solid #444; }
.vote-title { font-size: 12px; font-weight: bold; color: #aaa; margin-bottom: 8px; text-align: center; }
.bill-card { display: flex; justify-content: space-between; align-items: center; background: #111; border: 1px solid #333; padding: 8px; margin-bottom: 5px; }
.bill-card.major { border-left: 4px solid #3b82f6; }
.bill-card.minor { border-left: 4px solid #ef4444; }
.bill-name { font-size: 11px; font-weight: bold; }
.vote-bar-container { height: 16px; background: #000; border-radius: 8px; overflow: hidden; display: flex; position: relative; margin-top: 8px; }
.vote-segment { height: 100%; transition: width 0.5s; }
.majority-line { position: absolute; left: 50%; top: 0; bottom: 0; width: 2px; background: #fff; z-index: 10; box-shadow: 0 0 5px #fff; }
.lobby-target-list { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; justify-content: center; }
.lobby-tag { background: #333; border: 1px solid #555; color: #ccc; padding: 4px 8px; border-radius: 15px; font-size: 9px; cursor: pointer; }
.lobby-tag:active { background: #555; }
.lobby-tag.active { border-color: #d4af37; color: #d4af37; }
.action-btn { margin-top: auto; width: 100%; background: #c92a2a; color: #fff; padding: 12px; font-weight: bold; border-radius: 8px; text-align: center; cursor: pointer; animation: pulse 2s infinite; font-size: 13px; flex-shrink: 0; }
.action-btn.secondary { background: #10b981; animation: none; margin-top: 10px; }
</style>
</head>
<body>
<div class="phone-wrapper">
<!-- 로딩 스크린 -->
<div id="loading-screen" class="loading-screen">
<div class="loader-spinner"></div>
<div class="text-sm font-bold text-white mb-1" id="loading-msg">데이터베이스 연동 중...</div>
<div class="text-[10px] text-gray-500">(Google Sheets 연결)</div>
</div>
<div class="safe-area-top">PROJECT SS</div>
<div class="reset-btn" onclick="initDB()">↻ RESTART</div>
<div id="desk" class="screen desk-bg">
<div id="passive-toast" class="passive-toast"></div>
<div class="status-dashboard">
<div class="week-display">
<span>제 <span id="week-num" class="week-highlight">1</span> 주</span>
<span>Day <span id="day-num" class="week-highlight">1</span>/5</span>
</div>
<div class="mandate-sticky">
<div><span class="text-xs text-gray-500">아젠다:</span> <span class="mandate-title" id="agenda-title">Loading...</span></div>
<div><span class="text-xs text-gray-500">퀘스트:</span> <span class="quest-progress" id="quest-status">0/0</span></div>
</div>
<div class="res-row">
<div class="res-box"><span class="text-[9px] text-gray-400">총 예산</span><span class="res-val" id="val-total">$500</span></div>
<div class="res-box" id="dept-budget-box" style="border-color: #c92a2a;"><span class="text-[9px] text-gray-400" id="dept-name-display">부서 예산</span><span class="res-val" id="val-dept">$100</span></div>
<div class="res-box" style="border-color: #666; background:#222; width:20%;"><span class="text-[9px] text-gray-400">비자금</span><span class="res-val text-yellow-500" id="val-black">$20</span></div>
<div class="res-box" style="border-color: #666; background:#222; width:20%;"><span class="text-[9px] text-gray-400"><i class="fa-solid fa-eye"></i> 감시</span><span class="res-val text-red-400" id="val-suspicion">0%</span></div>
</div>
<div class="gauge-row">
<div class="stat-item"><div class="trust-bar"><div class="trust-fill" id="bar-trust"></div></div><span class="stat-label">신임도 <span id="txt-trust">80%</span></span></div>
<div class="stat-item"><div class="entropy-clock"><div class="clock-face"></div><div class="clock-hand" id="hand-entropy"></div></div><span class="stat-label">종말시계</span></div>
<div class="stat-item" style="width: 35%;" onclick="openModal('faction-modal')"><div class="share-bars" id="bar-share"></div><span class="stat-label">지분율 <i class="fa-solid fa-circle-info text-[8px]"></i></span></div>
</div>
</div>
<div class="desk-area">
<div class="doc-card" id="current-card" style="display:none;">
<div class="folder-tab" id="c-dept">부서</div>
<div class="warning-postit" id="c-warning">경고</div>
<div class="lock-chain-overlay" id="lock-overlay"><i class="fa-solid fa-link chains"></i><div class="lock-msg" id="lock-overlay-msg"><span id="lock-type-text"></span><br><span id="lock-reason" class="text-yellow-300 text-[10px] mt-1 block"></span></div></div>
<div class="law-warning-popup" id="law-popup"><div class="law-title" id="law-popup-title"></div><div class="law-desc" id="law-popup-desc"></div></div>
<div class="card-header">
<div class="card-title" id="c-title">Title</div>
<div class="flex justify-between items-center text-[9px] text-gray-600"><span id="c-sender">Sender</span><div><span class="conflict-badge" id="c-conflict">VS</span><span class="asset-badge" id="c-asset-badge" style="display:none;">📦 자산 획득</span></div></div>
</div>
<div class="card-body" id="c-body">Body</div>
<div class="flavor-text" id="c-flavor">Flavor</div>
<div class="faction-stamp" id="c-faction">Faction</div>
<div class="predict-overlay" id="predict-overlay">
<div class="predict-box">
<div id="p-header" class="font-bold border-b border-gray-600 mb-2 pb-1 text-center"></div>
<div class="p-row">예산: <span id="p-budget"></span></div><div class="p-row">엔트로피: <span id="p-entropy"></span></div>
<div class="desc-text" id="p-desc"></div>
<div class="p-row mt-1">신임도: <span id="p-trust"></span></div><hr class="border-gray-600 my-1"><div class="p-row" id="p-faction-gain"></div><div class="p-row" id="p-faction-loss"></div><div class="p-row" id="p-asset-gain"></div>
</div>
</div>
<div class="overlay-stamp stamp-approve" id="stamp-approve">승인</div><div class="overlay-stamp stamp-reject" id="stamp-reject">거절</div>
</div>
<div id="empty-state" class="hidden text-center text-gray-500 text-xs absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"><i class="fa-solid fa-check-double text-4xl mb-2"></i><br>오늘의 업무 완료</div>
<div class="dividend-tube" id="dividend-tube"></div>
</div>
<div class="menu-btn-group">
<div class="menu-btn" onclick="openModal('asset-modal')"><i class="fa-solid fa-box-open"></i> 자산 (<span id="asset-count">0</span>)</div>
<div class="menu-btn" onclick="openModal('law-modal')"><i class="fa-solid fa-gavel"></i> 법안/규칙</div>
</div>
</div>
<!-- Council Screen -->
<div id="council" class="screen council-bg">
<div class="council-header">
<div class="text-[10px] text-gray-500">WEEKLY COUNCIL</div>
<div class="council-title">제 <span id="c-week-num">1</span>주차 입법 회의</div>
</div>
<div id="council-phase-1" class="council-phase active">
<div class="flex-1 flex flex-col justify-center items-center py-2">
<div class="text-gray-400 text-[10px] mb-1">제1주주 통치 선포</div>
<div class="text-xl font-black text-white mb-4" id="ruling-faction">Loading</div>
<div class="agenda-announce w-full"><div class="stamp-mark">CONFIRMED</div><div class="text-[10px] text-gray-600 font-bold">금주의 아젠다</div><div class="text-base font-black mb-1" id="next-agenda-title"></div><div class="text-[10px] text-gray-500" id="next-agenda-desc"></div></div>
<div class="agenda-announce w-full bg-yellow-100"><div class="text-[10px] text-yellow-800 font-bold">금주의 퀘스트</div><div class="text-base font-black mb-1" id="next-quest-title"></div><div class="flex justify-between text-[9px] text-gray-600 mt-2"><span>보상: <span id="next-quest-reward"></span></span><span>실패: <span id="next-quest-penalty"></span></span></div></div>
</div>
<div class="action-btn secondary" onclick="startVotePhase()">입법 회의 시작</div>
</div>
<div id="council-phase-2" class="council-phase">
<div class="flex-1 w-full">
<div class="vote-section border-red-500 border"><div class="vote-title text-red-500"><i class="fa-solid fa-scale-balanced"></i> 입법 전쟁</div>
<div class="bill-card major"><div class="flex-1"><div class="flex justify-between"><span class="text-blue-400 text-[9px] font-bold">여당안 (A)</span><span class="text-[9px]" id="bill-a-faction"></span></div><div class="bill-name" id="bill-a-name"></div><div class="bill-desc text-[9px] italic text-gray-400 mt-1" id="bill-a-flavor"></div></div></div>
<div class="text-center text-[10px] my-1 text-gray-500 font-bold">VS</div>
<div class="bill-card minor"><div class="flex-1"><div class="flex justify-between"><span class="text-red-400 text-[9px] font-bold">야당안 (B)</span><span class="text-[9px]" id="bill-b-faction"></span></div><div class="bill-name" id="bill-b-name"></div><div class="bill-desc text-[9px] italic text-gray-400 mt-1" id="bill-b-flavor"></div></div></div>
<div class="mt-3"><div class="flex justify-between text-[9px] font-bold mb-1"><span class="text-blue-400">A 지지 (<span id="vote-a-val">50</span>%)</span><span class="text-red-400">B 지지 (<span id="vote-b-val">50</span>%)</span></div><div class="vote-bar-container"><div class="majority-line"></div><div class="vote-segment bg-blue-600" id="vote-a" style="width:50%"></div><div class="vote-segment bg-red-600" id="vote-b" style="width:50%"></div></div></div>
<div class="grid grid-cols-2 gap-2 mt-3 text-[8px]"><div><div class="text-blue-400 border-b border-blue-900 mb-1">A 찬성 파벌</div><div id="list-support-a" class="text-gray-400 space-y-1"></div></div><div><div class="text-red-400 border-b border-red-900 mb-1">B 찬성 파벌</div><div id="list-support-b" class="text-gray-400 space-y-1"></div></div></div>
<div class="mt-3 pt-3 border-t border-gray-700"><div class="text-[9px] text-yellow-500 mb-1 font-bold">소수 정당 로비 (비자금 -$10)</div><div class="lobby-target-list" id="lobby-targets"></div></div>
</div>
</div>
<div class="action-btn" onclick="endCouncil()">결과 확정 및 다음 주 시작</div>
</div>
</div>
<!-- Modals -->
<div class="modal" id="asset-modal">
<div class="flex justify-between items-center border-b-2 border-gray-600 pb-3 mb-3"><h2 class="text-white font-bold text-base">보유 자산</h2><div class="close-btn p-2 text-[10px]" onclick="closeModal('asset-modal')">X</div></div>
<div class="flex-1 overflow-y-auto" id="asset-list"></div>
</div>
<div class="modal" id="law-modal">
<div class="flex justify-between items-center border-b-2 border-gray-600 pb-3 mb-3"><h2 class="text-white font-bold text-base">법안 및 규칙</h2><div class="close-btn p-2 text-[10px]" onclick="closeModal('law-modal')">X</div></div>
<div class="space-y-3 text-xs text-gray-300 overflow-y-auto">
<div><span class="text-yellow-500 font-bold block">현재 아젠다</span><span id="modal-agenda" class="font-bold text-white"></span><p id="modal-agenda-desc" class="text-[10px] text-gray-400 mt-1"></p></div>
<div><span class="text-red-500 font-bold block">현재 퀘스트</span><span id="modal-quest" class="font-bold text-white"></span><div class="text-[10px] mt-1 grid grid-cols-2 gap-2"><div class="bg-gray-800 p-2 rounded"><span class="text-green-400">보상:</span> <span id="quest-reward"></span></div><div class="bg-gray-800 p-2 rounded"><span class="text-red-400">패널티:</span> <span id="quest-penalty"></span></div></div></div>
<div><span class="text-blue-400 font-bold block">발효된 법안</span><ul id="modal-laws" class="list-disc pl-4 text-[10px] mt-1 space-y-1 text-gray-400"></ul></div>
</div>
</div>
<div class="modal" id="faction-modal">
<div class="flex justify-between items-center border-b-2 border-gray-600 pb-3 mb-3"><h2 class="text-white font-bold text-base">파벌 정보</h2><div class="close-btn p-2 text-[10px]" onclick="closeModal('faction-modal')">X</div></div>
<div class="flex-1 overflow-y-auto" id="faction-list-detail"></div>
</div>
</div>
<script>
// ============================================================================
// 구글 시트 탭 별 독립된 링크 (유저가 제공한 링크 적용)
// ============================================================================
const SHEET_URLS = {
factions: "https://docs.google.com/spreadsheets/d/e/2PACX-1vSSzLgVoqnhadH-1oUvITvjZI8UBmBCkoDma4zumjopFXC2hHcvglgyfVpBLt5lhXnk9z20ZDvZdm5k/pub?gid=0&single=true&output=csv",
agendas: "https://docs.google.com/spreadsheets/d/e/2PACX-1vSSzLgVoqnhadH-1oUvITvjZI8UBmBCkoDma4zumjopFXC2hHcvglgyfVpBLt5lhXnk9z20ZDvZdm5k/pub?gid=529320998&single=true&output=csv",
quests: "https://docs.google.com/spreadsheets/d/e/2PACX-1vSSzLgVoqnhadH-1oUvITvjZI8UBmBCkoDma4zumjopFXC2hHcvglgyfVpBLt5lhXnk9z20ZDvZdm5k/pub?gid=57714287&single=true&output=csv",
bills: "https://docs.google.com/spreadsheets/d/e/2PACX-1vSSzLgVoqnhadH-1oUvITvjZI8UBmBCkoDma4zumjopFXC2hHcvglgyfVpBLt5lhXnk9z20ZDvZdm5k/pub?gid=1174273004&single=true&output=csv",
assets: "https://docs.google.com/spreadsheets/d/e/2PACX-1vSSzLgVoqnhadH-1oUvITvjZI8UBmBCkoDma4zumjopFXC2hHcvglgyfVpBLt5lhXnk9z20ZDvZdm5k/pub?gid=640542678&single=true&output=csv",
scenarios: "https://docs.google.com/spreadsheets/d/e/2PACX-1vSSzLgVoqnhadH-1oUvITvjZI8UBmBCkoDma4zumjopFXC2hHcvglgyfVpBLt5lhXnk9z20ZDvZdm5k/pub?gid=1732769140&single=true&output=csv"
};
const EFFECT_NAMES = { budget: "예산", black_fund: "비자금", trust: "신임도", entropy: "엔트로피", suspicion: "의심도" };
let DB_FACTIONS = []; let DB_AGENDAS = []; let DB_QUESTS = []; let DB_BILLS = []; let DB_ASSETS = []; let DB_SCENARIOS = [];
let state = {
week: 1, day: 1, budget: 500, blackFund: 20, trust: 80, entropy: 10, suspicion: 0,
deptBudgets: { "작전 본부": 150, "정보 본부": 100, "R&D 본부": 100, "행정 본부": 150 },
assets: [], activeLaws: [],
currentAgenda: null, currentQuest: null, questProgress: 0, currentCard: null, lockState: "none",
council: { billA: null, billB: null, votesA: 50, votesB: 50, supportersA: [], supportersB: [] }
};
function normalizeKey(str) {
return String(str).replace(/^\uFEFF/, '').trim().toLowerCase();
}
function parseCSV(url) {
return new Promise((resolve) => {
if (!url || url.trim() === "") { resolve(null); return; }
Papa.parse(url, {
download: true,
header: true,
skipEmptyLines: true,
complete: function(results) {
const normalizedData = results.data.map(row => {
let newRow = {};
for(let key in row) { newRow[normalizeKey(key)] = row[key]; }
return newRow;
});
resolve(normalizedData);
},
error: function(err) {
console.error("CSV 파싱 에러:", err);
resolve(null);
}
});
});
}
function loadFallbackData() {
if (DB_FACTIONS.length === 0) DB_FACTIONS = [
{ id: 'iron', name: '철의 수호당', color: '#ef4444', share: 30, rival: 'libra', ideology: 0, desc: "괴수 즉결 처형 및 군사력 증강을 주장합니다." },
{ id: 'libra', name: '지식 보존당', color: '#3b82f6', share: 20, rival: 'iron', ideology: 1, desc: "괴수 생포 및 연구를 통한 이해를 중시합니다." },
{ id: 'pure', name: '순수 인간당', color: '#10b981', share: 15, rival: 'gene', ideology: 1, desc: "인간 중심주의, 이종족 및 변이를 배척합니다." },
{ id: 'gene', name: '진화 미래당', color: '#8b5cf6', share: 10, rival: 'pure', ideology: 0, desc: "신체 개조 및 초인 양성을 통한 진화를 꿈꿉니다." },
{ id: 'trade', name: '차원 무역당', color: '#f59e0b', share: 10, rival: 'covenant', ideology: 0, desc: "괴수 부산물 판매 및 이계 무역을 지지합니다." },
{ id: 'covenant', name: '고대 공존당', color: '#06b6d4', share: 5, rival: 'trade', ideology: 1, desc: "괴수를 숭배하며 평화적 공존을 모색합니다." },
{ id: 'veil', name: '침묵의 장막당', color: '#6b7280', share: 5, rival: 'reshape', ideology: 1, desc: "대중에게 진실을 숨기고 비밀을 유지합니다." },
{ id: 'reshape', name: '세계 재건당', color: '#000000', share: 5, rival: 'veil', ideology: 0, desc: "조직의 양지화 및 세계 정부 수립을 목표로 합니다." }
];
if (DB_AGENDAS.length === 0) DB_AGENDAS = [
{ id: "AG_01", title: "대토벌의 시대", desc: "작전부 안건 2배 증가 / 전투 예산 소모 +20%" },
];
if (DB_QUESTS.length === 0) DB_QUESTS = [
{ id: "Q_01", title: "작전 승인 2회", target: { type: "approve_count", dept: "작전 본부", val: 2 }, reward_txt: "예산 +$100", penalty_txt: "신임도 -10" }
];
if (DB_BILLS.length === 0) DB_BILLS = [
{ id: "B_01", name: "사살 금지법", desc: "사살 관련 안건 승인 금지", tag: "kill", type: "lock_approve", flavor: "생명은 소중합니다." },
{ id: "B_03", name: "강제 징집령", desc: "작전부 안건 거절 불가", tag: "ops", type: "lock_reject", flavor: "국가가 부릅니다." }
];
if (DB_ASSETS.length === 0) DB_ASSETS = [
{ id: "AST_001", name: "유령 커피머신", type: "Relic", desc: "커피 맛은 천상, 밤마다 악몽.", passive_effect: { budget: 5 }, holding_risk: { entropy: 1 }, sale_value: 50, sale_risk: { suspicion: 5 }, flavor: "카페인과 공포." }
];
if (DB_SCENARIOS.length === 0) DB_SCENARIOS = [
{ id: 101, act: 1, dept: "행정 본부", color: "#d97706", title: "탕비실 믹스커피 횡령", body: "야간조 요원들이 믹스커피를 횡령했습니다.", faction: "지식 보존당", rival: "iron", tags: ["audit"], conflict: "원칙 vs 융통성", cost: 0, yes_e: -1, no_e: +1, flavor: "맥심 골드는 중대 사항입니다.", reward_asset_id: "AST_001" },
{ id: 102, act: 1, dept: "작전 본부", color: "#c92a2a", title: "하급 슬라임 민원", body: "하수구 슬라임을 소각하시겠습니까?", faction: "철의 수호당", rival: "libra", tags: ["kill", "ops"], conflict: "과잉진압 vs 방치", cost: 20, yes_e: -5, no_e: +5, flavor: "소금 뿌리면 안 됩니까?" }
];
}
async function initDB() {
document.getElementById('loading-screen').style.display = 'flex';
document.getElementById('loading-msg').innerText = "구글 시트 6개 탭 동기화 중...";
DB_FACTIONS = []; DB_AGENDAS = []; DB_QUESTS = []; DB_BILLS = []; DB_ASSETS = []; DB_SCENARIOS = [];
try {
const [factions, agendas, quests, bills, assets, scenarios] = await Promise.all([
parseCSV(SHEET_URLS.factions),
parseCSV(SHEET_URLS.agendas),
parseCSV(SHEET_URLS.quests),
parseCSV(SHEET_URLS.bills),
parseCSV(SHEET_URLS.assets),
parseCSV(SHEET_URLS.scenarios)
]);
if (factions && factions.length > 0 && factions[0].id) {
DB_FACTIONS = factions.map(f => ({
...f, share: Number(f.share) || 0, ideology: Number(f.ideology) || 0
}));
}
if (agendas && agendas.length > 0) DB_AGENDAS = agendas;
if (quests && quests.length > 0) {
DB_QUESTS = quests.map(q => ({
...q, target: { type: q.target_type, dept: q.target_dept, val: Number(q.target_val) || 0 }
}));
}
if (bills && bills.length > 0) DB_BILLS = bills;
if (assets && assets.length > 0) {
DB_ASSETS = assets.map(a => ({
id: a.id, name: a.name, type: a.type, desc: a.desc,
passive_effect: a.passive_eff_key && a.passive_eff_key !== '-' ? { [a.passive_eff_key]: Number(a.passive_eff_val) } : null,
holding_risk: a.risk_eff_key && a.risk_eff_key !== '-' ? { [a.risk_eff_key]: Number(a.risk_eff_val) } : null,
sale_value: Number(a.sale_value) || 0,
sale_risk: a.sale_risk_key && a.sale_risk_key !== '-' ? { [a.sale_risk_key]: Number(a.sale_risk_val) } : null,
flavor: a.flavor
}));
}
if (scenarios && scenarios.length > 0 && scenarios[0].id) {
DB_SCENARIOS = scenarios.map(s => ({
...s,
act: Number(s.act) || 1, cost: Number(s.cost) || 0,
yes_e: Number(s.yes_e) || 0, no_e: Number(s.no_e) || 0,
tags: s.tags && s.tags !== '-' ? String(s.tags).split(',').map(tag => tag.trim()) : []
}));
}
if(DB_FACTIONS.length === 0 || DB_SCENARIOS.length === 0) {
throw new Error("구글 시트에서 필수 데이터(Factions 또는 Scenarios)를 찾지 못했습니다.");
}
document.getElementById('loading-screen').style.display = 'none';
startGame();
} catch(e) {
console.error("DB Load failed:", e);
document.getElementById('loading-msg').innerHTML = `<span class="text-yellow-400">데이터 로드 실패. 내장 데이터로 시작합니다.</span><br><span class="text-[10px] text-gray-500 font-normal mt-2 block">${e.message}</span>`;
document.querySelector('.loader-spinner').style.display = 'none';
setTimeout(() => {
loadFallbackData();
document.getElementById('loading-screen').style.display = 'none';
startGame();
}, 1500);
}
}
function startGame() {
state = { ...state, week: 1, day: 1, budget: 500, blackFund: 20, trust: 80, entropy: 10, suspicion: 0, assets: [], activeLaws: [] };
document.getElementById('desk').style.display = 'flex';
document.getElementById('council').style.display = 'none';
document.getElementById('empty-state').style.display = 'none';
document.getElementById('passive-toast').className = 'passive-toast';
setupWeek();
}
function setupWeek() {
state.day = 1; state.questProgress = 0;
state.currentAgenda = DB_AGENDAS[Math.floor(Math.random()*DB_AGENDAS.length)] || DB_AGENDAS[0];
state.currentQuest = DB_QUESTS[Math.floor(Math.random()*DB_QUESTS.length)] || DB_QUESTS[0];
updateHUD();
nextDay();
}
function nextDay() {
if (state.day > 5) { startCouncil(); return; }
state.budget -= 10;
applyAssetPassives();
let pool = DB_SCENARIOS.filter(s => s.act === state.week);
if(pool.length === 0) pool = DB_SCENARIOS; // Fallback for acts beyond configured data
state.currentCard = pool[Math.floor(Math.random() * pool.length)];
renderCard(state.currentCard);
const cardEl = document.getElementById('current-card');
cardEl.style.transition = 'none';
cardEl.style.transform = 'translate(0,0) scale(0.9)';
setTimeout(() => {
cardEl.style.transition = 'transform 0.3s ease-out';
cardEl.style.transform = 'scale(1)';
}, 50);
updateHUD();
}
function applyAssetPassives() {
let msg = [];
state.assets.forEach(a => {
if(a.passive_effect) {
if(a.passive_effect.budget) { state.budget += a.passive_effect.budget; msg.push(`예산+${a.passive_effect.budget}`); }
if(a.passive_effect.black_fund) { state.blackFund += a.passive_effect.black_fund; msg.push(`비자금+${a.passive_effect.black_fund}`); }
if(a.passive_effect.suspicion) { state.suspicion += a.passive_effect.suspicion; msg.push(`의심+${a.passive_effect.suspicion}`); }
}
if(a.holding_risk) {
if(a.holding_risk.entropy) { state.entropy += a.holding_risk.entropy; msg.push(`엔트로피+${a.holding_risk.entropy}`); }
if(a.holding_risk.trust) { state.trust += a.holding_risk.trust; msg.push(`신임도${a.holding_risk.trust}`); }
}
});
if(msg.length > 0) {
const toast = document.getElementById('passive-toast');
toast.innerText = "자산 효과: " + msg.join(', ');
toast.classList.remove('active'); void toast.offsetWidth; toast.classList.add('active');
}
}
function renderCard(data) {
const card = document.getElementById('current-card');
card.style.display = 'flex';
card.style.borderColor = "#cbb";
card.style.boxShadow = "0 10px 25px rgba(0,0,0,0.6)";
document.getElementById('c-dept').innerText = data.dept || '부서 불명';
document.getElementById('c-dept').style.backgroundColor = data.color || '#333';
document.getElementById('c-title').innerText = data.title || '제목 없음';
document.getElementById('c-body').innerText = data.body || '내용 없음';
document.getElementById('c-flavor').innerText = `"${data.flavor || ''}"`;
document.getElementById('c-faction').innerText = data.faction || '무소속';
document.getElementById('c-sender').innerText = data.dept || '발신자 불명';
document.getElementById('c-conflict').innerText = data.conflict || '선택';
const badge = document.getElementById('c-asset-badge');
if (data.reward_asset_id && data.reward_asset_id !== '-') {
const asset = DB_ASSETS.find(a => a.id === data.reward_asset_id);
badge.style.display = 'inline-block'; badge.innerText = `📦 ${asset ? asset.name : '자산'} 획득`;
} else { badge.style.display = 'none'; }
let lockType = data.lock_type || "none"; let lockReason = data.warning;
if(data.tags && Array.isArray(data.tags)) {
state.activeLaws.forEach(bill => {
if(data.tags.includes(bill.tag)) {
if(bill.type === "lock_approve") { lockType = "approve"; lockReason = bill.name; }
if(bill.type === "lock_reject") { lockType = "reject"; lockReason = bill.name; }
}
});
}
state.lockState = lockType;
const overlay = document.getElementById('lock-overlay');
const msg = document.getElementById('lock-type-text');
const reason = document.getElementById('lock-reason');
const warning = document.getElementById('c-warning');
if (lockType === 'approve') {
overlay.classList.add('active');
msg.innerText = "승인 금지"; reason.innerText = lockReason ? `사유: ${lockReason}` : "법안 위반";
warning.style.display = 'block'; warning.innerText = "승인 금지";
document.getElementById('lock-overlay-msg').className = "lock-msg right";
} else if (lockType === 'reject') {
overlay.classList.add('active');
msg.innerText = "거절 금지"; reason.innerText = lockReason ? `사유: ${lockReason}` : "강제 집행";
warning.style.display = 'block'; warning.innerText = "거절 불가";
document.getElementById('lock-overlay-msg').className = "lock-msg left";
} else {
overlay.classList.remove('active'); warning.style.display = 'none';
}
const dBudget = state.deptBudgets[data.dept] || 0;
const valEl = document.getElementById('val-dept');
valEl.innerText = `$${dBudget}`;
if (dBudget < (data.cost || 0)) valEl.classList.add('deficit'); else valEl.classList.remove('deficit');
document.getElementById('dept-budget-box').style.borderColor = data.color || '#444';
// Reset Stamp
document.getElementById('stamp-approve').style.opacity = 0;
document.getElementById('stamp-reject').style.opacity = 0;
document.getElementById('predict-overlay').style.display = 'none';
}
function updateHUD() {
document.getElementById('week-num').innerText = state.week;
document.getElementById('day-num').innerText = state.day;
if(state.currentAgenda) document.getElementById('agenda-title').innerText = state.currentAgenda.title;
if(state.currentQuest) {
const qTarget = state.currentQuest.target.val;
document.getElementById('quest-status').innerText = `${state.currentQuest.title} (${state.questProgress}/${qTarget})`;
}
document.getElementById('val-total').innerText = `$${state.budget}`;
document.getElementById('val-black').innerText = `$${state.blackFund}`;
document.getElementById('val-suspicion').innerText = `${state.suspicion}%`;
document.getElementById('bar-trust').style.width = `${state.trust}%`;
document.getElementById('txt-trust').innerText = `${state.trust}%`;
const deg = 270 + (state.entropy / 100) * 90;
document.getElementById('hand-entropy').style.transform = `rotate(${deg}deg)`;
const barContainer = document.getElementById('bar-share');
if(barContainer) {
barContainer.innerHTML = '';
const listContainer = document.getElementById('faction-list-detail');
if(listContainer) listContainer.innerHTML = '';
DB_FACTIONS.sort((a,b) => b.share - a.share).forEach(f => {
const div = document.createElement('div');
div.className = 'share-segment'; div.style.width = `${f.share}%`; div.style.backgroundColor = f.color;
barContainer.appendChild(div);
if(listContainer) {
listContainer.innerHTML += `
<div class="bg-gray-800 p-2 rounded mb-2 text-xs">
<div class="flex justify-between font-bold mb-1"><span style="color:${f.color}">${f.name}</span><span>${f.share}%</span></div>
<div class="text-gray-400 text-[10px]">이념: ${f.ideology === 0 ? '급진' : '보수'} | 라이벌: ${DB_FACTIONS.find(r=>r.id===f.rival)?.name || '없음'}</div>
<div class="text-gray-500 mt-1 italic">"${f.desc}"</div>
</div>`;
}
});
}
const top = DB_FACTIONS[0];
const topEl = document.getElementById('top-faction');
if(topEl && top) { topEl.innerText = top.name; topEl.style.color = top.color; }
document.getElementById('asset-count').innerText = state.assets.length;
}
const card = document.getElementById('current-card');
let startX = 0, currentX = 0, isDragging = false, pressTimer = null;
card.addEventListener('mousedown', startDrag); card.addEventListener('touchstart', startDrag, {passive:false});
document.addEventListener('mousemove', moveDrag); document.addEventListener('touchmove', moveDrag, {passive:false});
document.addEventListener('mouseup', endDrag); document.addEventListener('touchend', endDrag);
function startDrag(e) {
isDragging = true; startX = (e.type==='touchstart')?e.touches[0].clientX:e.clientX;
if(state.lockState !== 'none') {
card.style.transform = "scale(0.98)";
pressTimer = setTimeout(() => {
if(Math.abs(currentX) < 10) {
state.lockState = 'none';
document.getElementById('lock-overlay').classList.remove('active');
card.style.transform = "scale(1.05)";
if(navigator.vibrate) navigator.vibrate(200);
}
}, 800);
}
}
function moveDrag(e) {
if(!isDragging) return;
let x = (e.type==='touchmove')?e.touches[0].clientX:e.clientX;
currentX = x - startX;
const lawPopup = document.getElementById('law-popup');
const reason = document.getElementById('lock-reason').innerText;
if((state.lockState === 'approve' && currentX > 20) || (state.lockState === 'reject' && currentX < -20)) {
lawPopup.classList.add('visible');
document.getElementById('law-popup-title').innerText = state.lockState === 'approve' ? "🚫 승인 금지" : "🚫 거절 불가";
document.getElementById('law-popup-desc').innerText = reason;
} else lawPopup.classList.remove('visible');
if(state.lockState === 'approve' && currentX > 0) currentX *= 0.1;
if(state.lockState === 'reject' && currentX < 0) currentX *= 0.1;
if (state.lockState === 'none' || (state.lockState==='approve' && currentX < 0) || (state.lockState==='reject' && currentX > 0)) {
card.style.transform = `translateX(${currentX}px) rotate(${currentX*0.05}deg)`;
if (Math.abs(currentX) > 50) showPrediction(currentX > 0);
else document.getElementById('predict-overlay').style.display = 'none';
if (currentX > 0) {
document.getElementById('stamp-approve').style.opacity = Math.min(currentX / 100, 1);
document.getElementById('stamp-reject').style.opacity = 0;
} else {
document.getElementById('stamp-reject').style.opacity = Math.min(Math.abs(currentX) / 100, 1);
document.getElementById('stamp-approve').style.opacity = 0;
}
} else {
card.style.transform = `translateX(${currentX}px) rotate(0deg)`;
}
if (Math.abs(currentX) > 10) clearTimeout(pressTimer);
}
function endDrag() {
isDragging = false; clearTimeout(pressTimer);
document.getElementById('predict-overlay').style.display = 'none';
document.getElementById('law-popup').classList.remove('visible');
card.style.borderColor = "#cbb";
document.getElementById('stamp-approve').style.opacity = 0;
document.getElementById('stamp-reject').style.opacity = 0;
if (currentX > 100 && state.lockState !== 'approve') processDecision('approve');
else if (currentX < -100 && state.lockState !== 'reject') processDecision('reject');
else { card.style.transition = "transform 0.3s"; card.style.transform = "translate(0,0)"; }
currentX = 0;
}
function showPrediction(isApprove) {
const p = document.getElementById('predict-overlay');
p.style.display = 'flex';
p.style.zIndex = "100";
const data = state.currentCard;
if(!data) return;
const fac = DB_FACTIONS.find(f => f.name === data.faction) || { name: data.faction || '무소속', color: "#aaa" };
const rivalName = fac.rival ? DB_FACTIONS.find(r => r.id === fac.rival)?.name : "라이벌";
const rivalColor = fac.rival ? DB_FACTIONS.find(r => r.id === fac.rival)?.color : "#aaa";
const header = document.getElementById('p-header');
const desc = document.getElementById('p-desc');
const eVal = isApprove ? data.yes_e : data.no_e;
let eHtml = eVal > 0 ? `<span class="p-down">▲${eVal}</span>` : `<span class="p-up">▼${Math.abs(eVal)}</span>`;
if (isApprove) {
header.innerText = "승인 (APPROVE)"; header.className = "font-bold border-b border-gray-600 mb-2 pb-1 text-center text-red-400";
desc.innerText = "결재 승인";
document.getElementById('p-budget').innerHTML = `<span class="p-down">-$${data.cost || 0}</span>`;
document.getElementById('p-entropy').innerHTML = eHtml;
document.getElementById('p-trust').innerHTML = state.lockState === 'approve' ? `<span class="p-down">-20 (강행)</span>` : `-`;
document.getElementById('p-faction-gain').innerHTML = `<span style="color:${fac.color}">${fac.name} ▲</span>`;
document.getElementById('p-faction-loss').innerHTML = `<span style="color:${rivalColor}">${rivalName} ▼</span>`;
if (data.reward_asset_id && data.reward_asset_id !== '-') {
const asset = DB_ASSETS.find(a => a.id === data.reward_asset_id);
document.getElementById('p-asset-gain').innerHTML = `<span class="text-green-400 text-[9px]">+ ${asset ? asset.name : '자산'}</span>`;
} else { document.getElementById('p-asset-gain').innerHTML = ""; }
} else {
header.innerText = "거절 (REJECT)"; header.className = "font-bold border-b border-gray-600 mb-2 pb-1 text-center text-blue-400";
desc.innerText = "결재 반려";
document.getElementById('p-budget').innerHTML = `-`;
document.getElementById('p-entropy').innerHTML = eHtml;
document.getElementById('p-trust').innerHTML = state.lockState === 'reject' ? `<span class="p-down">-20 (거부권)</span>` : `<span class="p-up">+2</span>`;
document.getElementById('p-faction-gain').innerHTML = "";
document.getElementById('p-faction-loss').innerHTML = `<span style="color:${rivalColor}">${rivalName} ▲ (반사익)</span>`;
document.getElementById('p-asset-gain').innerHTML = "";
}
}
function processDecision(type) {
const data = state.currentCard;
const fac = DB_FACTIONS.find(f => f.name === data.faction);
const rival = fac ? DB_FACTIONS.find(f => f.id === fac.rival) : null;
let animateDir = type === 'approve' ? 500 : -500;
if ((type === 'approve' && data.lock_type === 'approve') || (type === 'reject' && data.lock_type === 'reject')) { state.trust -= 20; }
if (type === 'approve') {
state.budget -= (data.cost || 0);
if(state.deptBudgets[data.dept]) state.deptBudgets[data.dept] -= (data.cost || 0);
state.entropy += (data.yes_e || 0);
if(fac) fac.share += 2;
if(rival) rival.share -= 1;
if (state.currentQuest && state.currentQuest.target.dept === data.dept) state.questProgress++;
if (data.reward_asset_id && data.reward_asset_id !== '-') {
const assetData = DB_ASSETS.find(a => a.id === data.reward_asset_id);
if (assetData) addAsset(assetData);
}
const tube = document.getElementById('dividend-tube');
const b = document.createElement('div'); b.className = 'dividend-block'; b.style.backgroundColor = data.color || '#fff';
tube.appendChild(b);
} else {
state.entropy += (data.no_e || 0);
state.trust += 2;
if(rival) rival.share += 1;
}
card.style.transition = "transform 0.3s ease-in";
card.style.transform = `translateX(${animateDir}px) rotate(${animateDir/20}deg)`;
updateHUD();
setTimeout(() => { card.style.display = 'none'; state.day++; nextDay(); }, 300);
}
// --- COUNCIL ---
function startCouncil() {
document.getElementById('desk').style.display = 'none';
document.getElementById('council').style.display = 'flex';
updateHUD();
document.getElementById('council-phase-1').classList.add('active');
document.getElementById('council-phase-2').classList.remove('active');
const top = DB_FACTIONS[0];
document.getElementById('ruling-faction').innerText = top.name;
document.getElementById('ruling-faction').style.color = top.color;
document.getElementById('next-agenda-title').innerText = state.currentAgenda.title;
document.getElementById('next-agenda-desc').innerText = state.currentAgenda.desc;
document.getElementById('next-quest-title').innerText = state.currentQuest.title;
document.getElementById('next-quest-reward').innerText = state.currentQuest.reward_txt;
document.getElementById('next-quest-penalty').innerText = state.currentQuest.penalty_txt;
}
function startVotePhase() {
document.getElementById('council-phase-1').classList.remove('active');
document.getElementById('council-phase-2').classList.add('active');
let b1 = DB_BILLS[0]; let b2 = DB_BILLS[1] || DB_BILLS[0];
if (DB_BILLS.length > 1) {
let i = Math.floor(Math.random()*DB_BILLS.length);
let j = Math.floor(Math.random()*DB_BILLS.length);
while(i===j) j = Math.floor(Math.random()*DB_BILLS.length);
b1 = DB_BILLS[i]; b2 = DB_BILLS[j];
}
state.council.billA = b1; state.council.billB = b2;
const top = DB_FACTIONS[0]; const rival = DB_FACTIONS[1];
document.getElementById('bill-a-faction').innerText = top.name;
document.getElementById('bill-b-faction').innerText = rival.name;
document.getElementById('bill-a-name').innerText = state.council.billA.name;
document.getElementById('bill-a-flavor').innerText = `"${state.council.billA.flavor}"`;
document.getElementById('bill-b-name').innerText = state.council.billB.name;
document.getElementById('bill-b-flavor').innerText = `"${state.council.billB.flavor}"`;
let supportersA = [], supportersB = [], swingers = [];
let votesA = 0;
DB_FACTIONS.forEach(f => {
if(f.id === top.id) { supportersA.push(f.name); votesA += f.share; }
else if(f.id === rival.id) { supportersB.push(f.name); }
else { swingers.push(f); }
});
state.council.supportersA = supportersA;
state.council.supportersB = supportersB;
state.council.votesA = votesA;
updateVoteUI();
const lobbyList = document.getElementById('lobby-targets');
lobbyList.innerHTML = "";
swingers.forEach(f => {
lobbyList.innerHTML += `<div class="lobby-tag" onclick="lobbyFaction('${f.id}', event)">${f.name} (${f.share}%)</div>`;
});
}
function lobbyFaction(fid, e) {
if(state.blackFund < 10) { alert("비자금 부족"); return; }
state.blackFund -= 10;
const fac = DB_FACTIONS.find(f => f.id === fid);
state.council.votesA += fac.share;
const tgt = e.currentTarget;
tgt.style.borderColor = "#10b981"; tgt.style.color = "#10b981"; tgt.innerText = "포섭됨"; tgt.onclick = null;
updateVoteUI(); updateHUD();
}
function updateVoteUI() {
const vA = Math.min(100, state.council.votesA);
document.getElementById('vote-a').style.width = `${vA}%`;
document.getElementById('vote-b').style.width = `${100-vA}%`;
document.getElementById('vote-a-val').innerText = vA;
document.getElementById('vote-b-val').innerText = 100-vA;
document.getElementById('list-support-a').innerText = state.council.supportersA.join(', ');
document.getElementById('list-support-b').innerText = state.council.supportersB.join(', ');
}
function endCouncil() {
const winner = state.council.votesA >= 50 ? state.council.billA : state.council.billB;
alert(`${winner.name} 가결! (다음 주 적용)`);
state.activeLaws.push(winner);
state.week++; state.day = 1;
document.getElementById('council').style.display = 'none';
document.getElementById('desk').style.display = 'flex';
setupWeek();
}
function addAsset(a) { state.assets.push(a); }
function renderAssets() {
const list = document.getElementById('asset-list'); list.innerHTML = "";
state.assets.forEach((a, idx) => {
let pass = "";
if(a.passive_effect) { const k = Object.keys(a.passive_effect)[0]; pass = `[매턴 ${EFFECT_NAMES[k] || k} ${a.passive_effect[k]}]`; }
list.innerHTML += `<div class="list-item"><div class="flex-1"><span class="font-bold block">${a.name}</span><span class="text-[9px] text-gray-400">${a.desc} <span class="text-green-400">${pass}</span></span></div><button class="bg-green-700 px-2 py-1 rounded text-xs text-white ml-2 flex-shrink-0" onclick="sellAsset(${idx})">판매 (+$${a.sale_value})</button></div>`;
});
if(state.assets.length===0) list.innerHTML = "<div class='text-center text-xs text-gray-500'>없음</div>";
}
function sellAsset(idx) {
const a = state.assets[idx]; state.budget += a.sale_value;
if(a.sale_risk && a.sale_risk.suspicion) state.suspicion += a.sale_risk.suspicion;
state.assets.splice(idx, 1); renderAssets(); updateHUD();
}
window.openModal = (id) => {
document.getElementById(id).classList.add('active');
if(id==='asset-modal') renderAssets();
if(id==='law-modal') {
document.getElementById('modal-agenda').innerText = state.currentAgenda?.title || "";
document.getElementById('modal-agenda-desc').innerText = state.currentAgenda?.desc || "";
document.getElementById('modal-quest').innerText = state.currentQuest?.title || "";
document.getElementById('quest-reward').innerText = state.currentQuest?.reward_txt || "";
document.getElementById('quest-penalty').innerText = state.currentQuest?.penalty_txt || "";
document.getElementById('modal-laws').innerHTML = state.activeLaws.map(l => `<li>${l.name}: ${l.desc}</li>`).join('');
}
if(id==='faction-modal') updateHUD();
}
window.closeModal = (id) => document.getElementById(id).classList.remove('active');
window.sellAsset = sellAsset;
// 앱 시작 지점
initDB();
</script>
</body>
</html>