Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
392a125125 | ||
|
|
20207ff23c | ||
|
|
6398599199 | ||
|
|
0a065068c1 | ||
|
|
cde44c4f2c | ||
|
|
83060f3e5b | ||
|
|
54e2fa8132 | ||
|
|
8833e52fab | ||
|
|
f64c4f3375 | ||
|
|
db1051c08c | ||
|
|
31558edcb2 | ||
|
|
cdee98ffc2 | ||
|
|
ad7db470f6 | ||
|
|
c67914c248 | ||
|
|
4c44f942c5 | ||
|
|
56f494f331 | ||
|
|
873ed0dcd3 | ||
|
|
c0921548ec | ||
|
|
e2557ebee8 | ||
|
|
7f06cb6519 | ||
|
|
e1f5cafdf1 | ||
|
|
e1310bc872 | ||
|
|
4d3e55a89b | ||
|
|
26f8c6593a | ||
|
|
9ec2fc260a | ||
|
|
4aa36a5273 |
@@ -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
@@ -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.*
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 프로젝트: Project SS
|
||||||
|
|
||||||
|
서사형 덱빌딩 로그라이크. 모바일 & 스팀 출시 예정.
|
||||||
|
|
||||||
|
플레이어는 정체불명 조직의 국장이 되어 매 라운드 딜레마를 스와이프로 결재하며,
|
||||||
|
덱을 오염시키거나 정화하고, 그림자 의회(상임 이사회 8명 + 글로벌 지부장 5명)의
|
||||||
|
정치 공작(매수·장부조작·내부감사)을 헤쳐나가 탄핵·문명붕괴·세계멸망 등
|
||||||
|
수십 가지 엔딩 중 하나에 도달한다.
|
||||||
|
|
||||||
|
톤앤매너: 어둡고 정치적인 블랙코미디. 밝고 가벼운 표현은 지양한다.
|
||||||
|
|
||||||
|
## 코어 시스템
|
||||||
|
- 코어 루프: 결재(덱빌딩) → 위기(드로우) → 엔딩
|
||||||
|
- 핵심 파라미터 4개: 내부 저항 · 엔트로피 · 대중 공황 · 위험 수용 계수 (각 0~100%, 5단계)
|
||||||
|
- 자원: 소모성 토큰(예산·인력·정보) / 누적 자원 태그(재력·무력·감시)
|
||||||
|
|
||||||
|
## 작업 원칙
|
||||||
|
- 코드를 짤 때 코어 루프와 4개 핵심 파라미터의 정합성을 항상 먼저 확인한다.
|
||||||
|
- 밸런스 수치를 임의로 창작하지 않는다. 기획 문서에 없는 수치는 반드시 확인을 요청한다.
|
||||||
|
- 기능 제안 시 "이걸 어떻게 플레이로 검증할 것인가"를 함께 제시한다.
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Project SS — CLAUDE.md
|
||||||
|
|
||||||
|
> **위치:** repo root (`ProjectSS/CLAUDE.md`) — Claude Code는 root에서 실행한다.
|
||||||
|
> **이 파일의 역할:** 규칙만 담는다. 작업 이력·진행 상황은 `docs/DEVLOG.md`, 구현 로드맵은
|
||||||
|
> `docs/ProjectSS_코어시스템_기획서.md` §17.
|
||||||
|
> **상위 규칙:** 전역 정체성·개발 철학은 `~/.hermes/SOUL.md`, 프로젝트 정체성은 `AGENTS.md`에 있다.
|
||||||
|
> 이 파일은 그 위에 얹히는 **코드 레벨 규칙**만 다룬다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 작업 시작 전 체크
|
||||||
|
|
||||||
|
**⚠️ 병렬 브랜치 확인 (필수)**
|
||||||
|
이 폴더가 유일한 작업 사본이 아니라면, 작업 시작 전 반드시:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch
|
||||||
|
git log origin/main..HEAD # 내가 안 올린 커밋
|
||||||
|
git log HEAD..origin/main # 내가 안 받은 커밋
|
||||||
|
```
|
||||||
|
|
||||||
|
과거에 다른 세션이 origin에 푸시한 커밋을 로컬이 받지 못한 채 Phase 2~4를 독립적으로
|
||||||
|
재구현해 대형 충돌이 난 적이 있다. 경위는 `ROADMAP.md` 상단 박스 참고.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 코드 컨벤션
|
||||||
|
|
||||||
|
- 신규 개발은 `client/src/core/` 아래에 있다 (`engine/`은 React 의존 없는 순수 로직, `components/`는 UI).
|
||||||
|
기존 `client/src/components/`·`client/src/engine/`(구 GameScreen.jsx 중심 구조)는 참조용으로만
|
||||||
|
남아있고 더 이상 진입점(`App.jsx`)에서 쓰이지 않는다. 코드 구조는 게임 규칙과 함께 계속
|
||||||
|
바뀌는 중이라 별도 아키텍처 문서를 두지 않는다 — 구 `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의 "완성도보다 검증 속도 우선"은 **코드**에 적용된다.
|
||||||
|
> 데이터(카드 텍스트·밸런스)는 예외로, 디자이너가 직접 검토할 수 있는 속도로 제안한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기획 문서 참조 (게임 규칙의 원본)
|
||||||
|
|
||||||
|
코드 작성 시 항상 원본 문서를 기준으로 한다. **게임 규칙 수치를 이 파일에 복사하지 말 것.**
|
||||||
|
|
||||||
|
예전에 흩어져 있던 기획 문서 7종(서사형 덱빌딩 시스템 기획서·스토리 바이블·유저플로우·
|
||||||
|
텍스트 와이어프레임·디자인 토큰·PRD·architecture_design)은 전부 아래 한 문서로 통합됐다.
|
||||||
|
`docs/` 안에 옛 문서가 남아 보이더라도 참조하지 말 것 — 이제 유일한 원본은 이것뿐이다.
|
||||||
|
|
||||||
|
- 게임 규칙·수치·화면 흐름·UI 레이아웃·요구사항 전부 → @docs/ProjectSS_코어시스템_기획서.md
|
||||||
|
- 기호 체계(축·등급) → §3 · 책상·페널티(주목도) → §4 · 합본(족보) → §5
|
||||||
|
- 파라미터(4대 게이지) → §6 · 명분 → §7 · 경제(상점) → §8
|
||||||
|
- 의회(규약·실적감사·13인 위원회) → §9 · 자산 → §10 · 전례(내러티브 태그) → §11
|
||||||
|
- 노선 → §13 · 엔딩 → §15 · 텍스트 스타일 가이드 → §16
|
||||||
|
- **구현 우선순위·진행 상황(로드맵) → §17** — Phase 계획을 대신한다, 아래 참고
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 진행 상황
|
||||||
|
|
||||||
|
진행 체크박스를 이 파일에 복제하지 않는다 — 여러 곳에서 따로 갱신하면 어긋나기 쉽다.
|
||||||
|
`ProjectSS_코어시스템_기획서.md` **§17 "구현 우선순위"** 안의 체크박스가 유일한 진행 상황
|
||||||
|
원본이다. 지금 뭐가 됐고 안 됐는지 보려면 그 절을 열어볼 것.
|
||||||
|
|
||||||
|
각 항목에서 남은 주의사항과 상세 작업 이력은 `docs/DEVLOG.md` 참고. 왜 그렇게 결정했는지는
|
||||||
|
DEVLOG에서 링크된 결정 노트(아래 "결정 노트" 절) 참고.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 작업 지시 방법
|
||||||
|
|
||||||
|
```
|
||||||
|
/goal 구현 우선순위 3차 진행해줘 # ProjectSS_코어시스템_기획서.md §17 기준
|
||||||
|
```
|
||||||
|
|
||||||
|
**작업 완료 후 반드시:**
|
||||||
|
1. `ProjectSS_코어시스템_기획서.md` **§17**의 해당 항목 체크박스를 갱신한다.
|
||||||
|
2. `docs/DEVLOG.md` 맨 위에 무엇을 왜 바꿨는지 기록한다.
|
||||||
|
(미해결 이슈나 디자이너 확인이 필요한 사항이 생겼으면 DEVLOG 상단 "열린 이슈"에 추가)
|
||||||
|
3. 중요한 설계·밸런스 결정을 내렸다면 결정 노트를 남긴다 — 아래 "결정 노트" 절 참고.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 결정 노트 (Decision Notes)
|
||||||
|
|
||||||
|
중요한 설계·밸런스 결정을 내렸을 때는 `docs/decisions/YYYY-MM-DD-제목.md` 파일로 따로 남긴다.
|
||||||
|
"중요한 결정"이란 나중에 왜 이렇게 했는지 되짚어야 할 만한 것 — 여러 대안 중 하나를 고른 경우,
|
||||||
|
기존 규칙을 뒤집은 경우, 플레이테스트 피드백에 따라 방향을 바꾼 경우 등이다. 사소한 오타 수정이나
|
||||||
|
명백한 버그 픽스에는 필요 없다.
|
||||||
|
|
||||||
|
**양식** (`T-decision.md` 그대로 — 새 결정 노트는 전부 이 형식을 따른다):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
created: YYYY-MM-DD
|
||||||
|
---
|
||||||
|
%% 파일명: YYYY-MM-DD-제목. 규칙 문서엔 규칙만, 이유는 여기에 %%
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
-
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
-
|
||||||
|
|
||||||
|
## 검토했으나 기각한 대안
|
||||||
|
- **대안:** 기각 이유
|
||||||
|
|
||||||
|
## 재검토 조건
|
||||||
|
-
|
||||||
|
```
|
||||||
|
|
||||||
|
**DEVLOG 연동:** `docs/DEVLOG.md`에 작업 항목을 적을 때, 그 작업에 해당하는 결정 노트가 있으면
|
||||||
|
옵시디언 위키링크로 연결한다 — `[[YYYY-MM-DD-제목]]`. DEVLOG는 "무엇을 했다"는 요약만 담고,
|
||||||
|
"왜 그렇게 했는가"의 자세한 내용은 링크를 눌러 결정 노트에서 확인하는 구조다.
|
||||||
@@ -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
@@ -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]`로 업데이트하고 **현재 상태** 블록을 갱신한다.
|
||||||
@@ -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 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||||
|
<!-- maximum-scale/user-scalable은 넣지 않는다 — 확대를 막으면 작은 글씨를 읽어야 하는
|
||||||
|
테스터의 접근성을 해친다. 대신 레이아웃이 390px 폭에서 깨지지 않도록 맞췄다. -->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<!-- 모바일 브라우저 상단/하단 바를 다크 UI와 맞춘다 -->
|
||||||
|
<meta name="theme-color" content="#121212" />
|
||||||
|
<meta name="description" content="관료주의 코스믹 호러 덱빌딩 — 종말을 결재하시겠습니까?" />
|
||||||
|
<title>Project SS — 종말을 결재하시겠습니까?</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2485
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "projectss",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"deploy": "bash ../deploy.sh",
|
||||||
|
"deploy:dry": "bash ../deploy.sh --dry-run"
|
||||||
|
},
|
||||||
|
"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 |
@@ -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 |
@@ -0,0 +1,13 @@
|
|||||||
|
/* 세로 flex + stretch로 둔다. 예전엔 가로 flex + align-items:center(중앙 정렬)였는데,
|
||||||
|
콘텐츠가 화면보다 커지면 위아래로 균등하게 넘쳐서 상단 툴바(합본 결재)가 화면 위로
|
||||||
|
잘려 나갔다(모바일에서 로그가 쌓이면 실제로 발생). 또 그 구조에선 자식의 높이가 고정되지
|
||||||
|
않아 CoreGameScreen의 내부 스크롤·sticky 툴바가 동작하지 않았다. */
|
||||||
|
.App {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: radial-gradient(circle at center, #1a1a1a 0%, #0a0a0a 100%);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './App.css';
|
||||||
|
// ProjectSS_코어시스템_기획서.md 기준 신규 개발 — 구현 우선순위 1차(§17)부터 core/ 아래 새로
|
||||||
|
// 짓는다. 기존 GameScreen(components/)은 참조용으로만 남겨두고 더 이상 진입점에서 쓰지 않는다.
|
||||||
|
import CoreGameScreen from './core/components/CoreGameScreen';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="App">
|
||||||
|
<CoreGameScreen />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -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 |
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ARCHETYPE_META } from '../engine/assetEngine';
|
||||||
|
import './AssetInfoPopup.css';
|
||||||
|
|
||||||
|
export default function AssetInfoPopup({ instance, def, onActivate, onClose }) {
|
||||||
|
if (!instance || !def) return null;
|
||||||
|
const meta = ARCHETYPE_META[def.archetype];
|
||||||
|
const isManualCharge = def.trigger.type === 'manual_charge';
|
||||||
|
const ready = isManualCharge && instance.charge >= 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="asset-popup-overlay" onClick={onClose}>
|
||||||
|
<div className="asset-popup" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="asset-popup-header">
|
||||||
|
<span className={`asset-popup-grade grade-${def.grade}`}>{def.grade}</span>
|
||||||
|
<span className="asset-popup-archetype">{meta.icon} {meta.label}</span>
|
||||||
|
<button className="close-btn" onClick={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="asset-popup-name">{def.name}</h3>
|
||||||
|
<p className="asset-popup-desc">{def.description}</p>
|
||||||
|
|
||||||
|
{def.growth && (
|
||||||
|
<div className="asset-popup-stat">
|
||||||
|
현재 숙련 스택: <strong>{instance.stack || 0}</strong> (매 {def.growth.per}회마다 추가 발동)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isManualCharge && (
|
||||||
|
<div className="asset-popup-stat">
|
||||||
|
{ready ? '충전 완료 — 발동 가능' : `충전 대기 중 (${def.trigger.params.chargeEvery}건마다 충전)`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ready && (
|
||||||
|
<button className="asset-popup-activate" onClick={onActivate}>발동</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="asset-popup-flavor">{def.flavor}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}>×</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ENDING_CATALOG } from '../data/endings';
|
||||||
|
import './EndingCodex.css';
|
||||||
|
|
||||||
|
export const ENDINGS_STORAGE_KEY = 'ss_endings_v2';
|
||||||
|
|
||||||
|
export default function EndingCodex({ onClose }) {
|
||||||
|
const unlocked = JSON.parse(localStorage.getItem(ENDINGS_STORAGE_KEY) || '[]');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="codex-overlay" onClick={onClose}>
|
||||||
|
<div className="codex-panel" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="codex-header">
|
||||||
|
<h2>엔딩 도감 ({unlocked.length}/{ENDING_CATALOG.length})</h2>
|
||||||
|
<button className="close-btn" onClick={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="codex-content">
|
||||||
|
<div className="codex-grid">
|
||||||
|
{ENDING_CATALOG.map((e) => {
|
||||||
|
const isUnlocked = unlocked.includes(e.id);
|
||||||
|
return (
|
||||||
|
<div key={e.id} className={`codex-entry ${isUnlocked ? 'unlocked' : 'locked'}`}>
|
||||||
|
<div className="codex-title">{isUnlocked ? e.title : '??? (미확인)'}</div>
|
||||||
|
<div className="codex-desc">{isUnlocked ? e.reason : '아직 발견하지 못한 결말입니다.'}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
.combine-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-panel {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
width: min(560px, 100%);
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-doc-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.65;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-limit {
|
||||||
|
color: var(--color-risk-critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-protocols {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-protocol-chip {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 215, 0, 0.1);
|
||||||
|
color: var(--color-risk-critical);
|
||||||
|
border: 1px solid rgba(255, 215, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-desk-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-card:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-card-name {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.6;
|
||||||
|
max-width: 90px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-card.selected {
|
||||||
|
opacity: 1;
|
||||||
|
outline: 2px solid var(--color-coin);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-empty {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding-top: 12px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-no-match {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-blocked {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-risk-critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-match {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combine-match:hover {
|
||||||
|
border-color: var(--color-coin);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-name {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-score { color: var(--color-coin); }
|
||||||
|
|
||||||
|
.match-detail {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-used {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import SymbolBadge from './SymbolBadge';
|
||||||
|
import { detectHands, resolveCombine, MAX_COMBINE_CARDS, MIN_COMBINE_CARDS } from '../engine/hands';
|
||||||
|
import { filterHandsByProtocols, protocolEffectDetail } from '../engine/council';
|
||||||
|
import ProtocolChip from './ProtocolChip';
|
||||||
|
import './CombinePanel.css';
|
||||||
|
|
||||||
|
// 합본 결재 (§5) — 책상에서 카드를 다중 선택하면 성립 가능한 족보를 자동 판정해 보여준다.
|
||||||
|
// 여러 족보가 동시 성립하면 상위 족보를 먼저 보여주되, 하위 족보도 선택할 수 있게 한다 (§5.3) —
|
||||||
|
// 명분 종류를 고르기 위해 일부러 하위 족보를 쓰는 선택이 존재해야 하기 때문이다.
|
||||||
|
//
|
||||||
|
// 2026-09-15 결정 반영: 선택은 최대 5장(§5.1.1). 족보에 실제로 쓰이는 카드만 소멸하므로,
|
||||||
|
// 각 족보 후보가 어떤 카드를 가져가고 코인이 얼마 나오는지(등급 합계, §8.2)를 미리 보여준다.
|
||||||
|
export default function CombinePanel({ deskCards, onConfirm, onClose, activeProtocols = [] }) {
|
||||||
|
const [selectedIds, setSelectedIds] = useState([]);
|
||||||
|
|
||||||
|
const selection = useMemo(
|
||||||
|
() => deskCards.filter((c) => selectedIds.includes(c.id)),
|
||||||
|
[deskCards, selectedIds]
|
||||||
|
);
|
||||||
|
const rawMatches = useMemo(() => detectHands(selection), [selection]);
|
||||||
|
const matches = useMemo(
|
||||||
|
() => filterHandsByProtocols(rawMatches, selection, activeProtocols),
|
||||||
|
[rawMatches, selection, activeProtocols]
|
||||||
|
);
|
||||||
|
const blockedByProtocol = rawMatches.length > 0 && matches.length === 0;
|
||||||
|
|
||||||
|
const atLimit = selectedIds.length >= MAX_COMBINE_CARDS;
|
||||||
|
|
||||||
|
const toggle = (id) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
if (prev.includes(id)) return prev.filter((x) => x !== id);
|
||||||
|
if (prev.length >= MAX_COMBINE_CARDS) return prev; // §5.1.1 최대 5장
|
||||||
|
return [...prev, id];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirm = (patternId) => {
|
||||||
|
const result = resolveCombine(selection, patternId);
|
||||||
|
if (result) onConfirm(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="combine-overlay" onClick={onClose}>
|
||||||
|
<div className="combine-panel" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="combine-header">
|
||||||
|
<h2>합본 결재</h2>
|
||||||
|
<button className="combine-close" onClick={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="combine-hint">
|
||||||
|
{MIN_COMBINE_CARDS}~{MAX_COMBINE_CARDS}장을 선택하면 성립하는 족보를 판정합니다.
|
||||||
|
선택 {selectedIds.length}/{MAX_COMBINE_CARDS}
|
||||||
|
{atLimit && <span className="combine-limit"> · 최대치</span>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{activeProtocols.length > 0 && (
|
||||||
|
<div className="combine-protocols">
|
||||||
|
{activeProtocols.map((p) => (
|
||||||
|
<ProtocolChip key={p.id} protocol={p} prefix="⚖ " />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="combine-desk-grid">
|
||||||
|
{deskCards.length === 0 && <p className="combine-empty">책상이 비어 있습니다.</p>}
|
||||||
|
{deskCards.map((c) => {
|
||||||
|
const selected = selectedIds.includes(c.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
className={`combine-card ${selected ? 'selected' : ''}`}
|
||||||
|
onClick={() => toggle(c.id)}
|
||||||
|
disabled={!selected && atLimit}
|
||||||
|
>
|
||||||
|
<SymbolBadge axis={c.axis} grade={c.grade} />
|
||||||
|
{c.name && <span className="combine-card-name">{c.name}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="combine-results">
|
||||||
|
{selection.length > 0 && matches.length === 0 && !blockedByProtocol && (
|
||||||
|
<p className="combine-no-match">선택한 {selection.length}장은 성립하는 족보가 없습니다.</p>
|
||||||
|
)}
|
||||||
|
{blockedByProtocol && (
|
||||||
|
<p className="combine-blocked">
|
||||||
|
규약 때문에 이 선택은 합본할 수 없습니다 —{' '}
|
||||||
|
{activeProtocols.map((p) => protocolEffectDetail(p).summary).join(' · ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{matches.map((patternId) => {
|
||||||
|
const preview = resolveCombine(selection, patternId);
|
||||||
|
if (!preview) return null;
|
||||||
|
const { pattern, usedCards, coins, justification, performanceScore } = preview;
|
||||||
|
return (
|
||||||
|
<button key={patternId} className="combine-match" onClick={() => confirm(patternId)}>
|
||||||
|
<div className="match-top">
|
||||||
|
<span className="match-name">{pattern.name}</span>
|
||||||
|
<span className="match-score">실적 +{performanceScore}</span>
|
||||||
|
</div>
|
||||||
|
<div className="match-detail">
|
||||||
|
코인 +{coins} · 명분 {justification.type} +{justification.amount} · {usedCards.length}장 소멸
|
||||||
|
</div>
|
||||||
|
<div className="match-used">
|
||||||
|
{usedCards.map((c) => (
|
||||||
|
<SymbolBadge key={c.id} axis={c.axis} grade={c.grade} size="sm" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
.core-game-screen {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
/* 안전 밸브: 규약·징벌 칩이 늘거나 등급 칩이 줄바꿈되며 콘텐츠가 뷰포트를 넘길 때,
|
||||||
|
문서 전체가 밀리는 대신 이 영역 안에서만 스크롤되게 한다. 툴바는 아래 sticky로
|
||||||
|
고정되므로 스크롤되더라도 합본 결재 버튼은 계속 보인다. */
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-loading {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-status-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.75;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-log {
|
||||||
|
padding: 6px 16px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.6;
|
||||||
|
max-height: 110px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 세로가 짧은 화면(대부분의 폰) — 화면을 넘기지 않도록 우선순위가 낮은 것부터 줄인다.
|
||||||
|
가장 덜 중요한 게 로그(플레이버·디버그 성격)라 제일 먼저 깎고, 그다음이 카드 여백이다.
|
||||||
|
책상바(합본 결재)와 결재 카드·정산하기는 절대 희생하지 않는다. */
|
||||||
|
@media (max-height: 760px) {
|
||||||
|
.core-log {
|
||||||
|
/* 최근 2줄만 — 더 보려면 이 영역 안에서 스크롤된다 */
|
||||||
|
max-height: 30px;
|
||||||
|
padding: 4px 16px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-status-row {
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-log-line {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-status-protocol {
|
||||||
|
color: var(--color-risk-critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 빌드 식별자 — 항상 보이되 시선을 끌지는 않게. 테스터가 스크린샷만 찍어도 버전이 남는다. */
|
||||||
|
.core-build-stamp {
|
||||||
|
margin-left: auto;
|
||||||
|
opacity: 0.4;
|
||||||
|
font-size: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-status-penalty {
|
||||||
|
color: var(--color-entropy-critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-gameover {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-gameover h1 {
|
||||||
|
font-family: var(--font-doc-title);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-gameover-note {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
/* global __BUILD_INFO__ */
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import Papa from 'papaparse';
|
||||||
|
import cardsCsvRaw from '../../data/cards.csv?raw';
|
||||||
|
import DeskBar from './DeskBar';
|
||||||
|
import CoreSwipeCard from './CoreSwipeCard';
|
||||||
|
import CombinePanel from './CombinePanel';
|
||||||
|
import HandGuidePopup from './HandGuidePopup';
|
||||||
|
import ProtocolChip from './ProtocolChip';
|
||||||
|
import ShopScreen from './ShopScreen';
|
||||||
|
import CouncilScreen from './CouncilScreen';
|
||||||
|
import { pickPollutionEntry } from '../engine/pollutionPool';
|
||||||
|
import {
|
||||||
|
makeDeskCard, applyPassiveGaugeRise, addToDesk, removeFromDesk, coinSurcharge,
|
||||||
|
roundAttentionCap,
|
||||||
|
} from '../engine/desk';
|
||||||
|
import { rollShopDisplay, purgePrice, REROLL_COST } from '../engine/shop';
|
||||||
|
import {
|
||||||
|
requiredScore, settleScore, rollProtocolCandidates, resolveProtocolVote, rollPenalty,
|
||||||
|
applyInterventions, gaugeSurchargeOf, coinSurchargeOf, attentionCapBonusOf,
|
||||||
|
requiredScoreDeltaOf, grantCoinOf, tallyVotes, applyVoteSwing, COUNCIL_SEATS,
|
||||||
|
} from '../engine/council';
|
||||||
|
import './CoreGameScreen.css';
|
||||||
|
|
||||||
|
// 코어 루프 그레이박스 — §17 1차(기호·책상·족보·코인) + 2차(상점·규약·실적감사·명분).
|
||||||
|
//
|
||||||
|
// 2026-09-16 결정 반영 (라운드/회기 이층 구조 폐지, 결정 노트:
|
||||||
|
// 2026-09-16-라운드-주목도-하드캡-복원): 라운드 하나 = 결재하다 정산(상점)→의회로 가는 단일
|
||||||
|
// 사이클이다. 결재 8건 같은 고정 건수가 아니라 **주목도가 이번 라운드의 한도(§4.2-B)를
|
||||||
|
// 넘으면 그 즉시 강제 종료**되고, 한도 밑에서는 플레이어가 아무 때나 "정산하기"로 자율
|
||||||
|
// 종료할 수 있다 — 블랙잭의 "히트/스탠드"와 같다. 강제 종료 시 합본 기회 없이 카드는 그대로
|
||||||
|
// 이월된다(§4.1). 한도 자체가 라운드 번호에 따라 커진다(roundAttentionCap).
|
||||||
|
//
|
||||||
|
// 자산·전례·소모품(§10~12, 3차)과 13인 위원회의 실제 우호도·이중첩자·탄핵(§9.4~9.6, 4차)은
|
||||||
|
// 아직 없다 — 상점은 "오염 카드 직접 처리"만, 규약 표결은 무작위로 흉내만 낸다.
|
||||||
|
|
||||||
|
const INITIAL_COINS = 5; // §8.4 초기 지급 — 코인 0으로 아무것도 못 하는 데드락 방지
|
||||||
|
const JUSTIFICATION_CAP = 5; // §7.1 보관 최대 5개
|
||||||
|
|
||||||
|
const buildCard = (row) => ({
|
||||||
|
id: row.id,
|
||||||
|
type: row.type,
|
||||||
|
keyword: row.keyword,
|
||||||
|
question: row.question,
|
||||||
|
text: row.text,
|
||||||
|
flavor: row.flavor,
|
||||||
|
left: {
|
||||||
|
text: row.left_text,
|
||||||
|
axis: row.left_axis || null,
|
||||||
|
grade: row.left_grade ? Number(row.left_grade) : null,
|
||||||
|
tag: row.left_tag || null,
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
text: row.right_text,
|
||||||
|
axis: row.right_axis || null,
|
||||||
|
grade: row.right_grade ? Number(row.right_grade) : null,
|
||||||
|
tag: row.right_tag || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const INITIAL_GAUGES = { entropy: 0, resistance: 0, panic: 0, risk: 0 };
|
||||||
|
|
||||||
|
// 2026-09-17 피드백: 결재 카드가 "고정으로 등장하는 것 같다"는 지적 — 실제로 버그였다.
|
||||||
|
// 예전엔 cardIndex를 그냥 +1씩 돌려서 CSV 파일 순서 그대로, 매 실행마다 똑같은 순서로
|
||||||
|
// 반복됐다(진짜 무작위가 아니라 결정론적 순환). Fisher–Yates로 섞은 뒤 앞에서부터 뽑고,
|
||||||
|
// 큐가 비면 다시 섞어 채운다 — 한 바퀴 안에서는 같은 카드가 두 번 나오지 않으면서도
|
||||||
|
// 실행할 때마다, 그리고 한 바퀴가 끝날 때마다 순서가 달라진다.
|
||||||
|
const shuffle = (arr) => {
|
||||||
|
const a = [...arr];
|
||||||
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[a[i], a[j]] = [a[j], a[i]];
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CoreGameScreen() {
|
||||||
|
const deck = useMemo(() => {
|
||||||
|
const parsed = Papa.parse(cardsCsvRaw, { header: true, skipEmptyLines: true });
|
||||||
|
return parsed.data.map(buildCard);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [drawQueue, setDrawQueue] = useState(() => shuffle(deck));
|
||||||
|
const [deskCards, setDeskCards] = useState([]);
|
||||||
|
const [gauges, setGauges] = useState(INITIAL_GAUGES);
|
||||||
|
const [coins, setCoins] = useState(INITIAL_COINS);
|
||||||
|
const [score, setScore] = useState(0);
|
||||||
|
const [justifications, setJustifications] = useState({}); // { 명분종류: 수량 }
|
||||||
|
const [showCombine, setShowCombine] = useState(false);
|
||||||
|
const [showGuide, setShowGuide] = useState(false);
|
||||||
|
const [log, setLog] = useState([]);
|
||||||
|
|
||||||
|
// 라운드 구조 (§2)
|
||||||
|
const [phase, setPhase] = useState('approval'); // 'approval' | 'shop' | 'council'
|
||||||
|
const [round, setRound] = useState(1);
|
||||||
|
const [roundScore, setRoundScore] = useState(0); // §9.3: 이번 라운드 중 합본한 족보 점수 합산
|
||||||
|
// 2026-09-18 결정(순수 누적 카운터, 결정 노트: 2026-09-18-라운드-접수량-누적카운터):
|
||||||
|
// 이번 라운드 접수량 = 카드를 받을 때마다 등급만큼 더하는 카운터. 라운드가 바뀔 때만 0으로
|
||||||
|
// 리셋되고, 합본으로 책상 카드가 사라져도 줄지 않는다 — desk.js에서 deskCards를 필터링해
|
||||||
|
// 파생시켰더니 합본할 때마다 줄어서 "합본을 반복하면 라운드가 안 끝나는" 문제가 있었다.
|
||||||
|
const [roundIntake, setRoundIntake] = useState(0);
|
||||||
|
const [penalties, setPenalties] = useState([]); // §9.2 유형C, 영구 누적
|
||||||
|
const [activeProtocol, setActiveProtocol] = useState(null); // §9.2 유형A, 이번 라운드 1개
|
||||||
|
const [deferredCandidates, setDeferredCandidates] = useState([]); // 절차 명분 이월분 (§7.3)
|
||||||
|
const [shopDisplay, setShopDisplay] = useState([]);
|
||||||
|
const [shopRerolled, setShopRerolled] = useState(false);
|
||||||
|
const [councilCandidates, setCouncilCandidates] = useState([]);
|
||||||
|
const [gameOver, setGameOver] = useState(null);
|
||||||
|
|
||||||
|
const pushLog = (msg) => setLog((prev) => [msg, ...prev].slice(0, 6));
|
||||||
|
|
||||||
|
const activeProtocols = activeProtocol ? [activeProtocol] : [];
|
||||||
|
// 징벌(유형 C)과 활성 규약(유형 A/D)이 같은 수치를 건드리므로 합쳐서 합산한다.
|
||||||
|
// 이득 규약이면 음수라 페널티가 상쇄된다.
|
||||||
|
const effectiveProtocols = [...penalties, ...activeProtocols];
|
||||||
|
const gaugeExtraFlat = gaugeSurchargeOf(effectiveProtocols);
|
||||||
|
const coinExtraFlat = coinSurchargeOf(effectiveProtocols);
|
||||||
|
const surcharge = coinSurcharge(deskCards, coinExtraFlat);
|
||||||
|
const justificationTotal = Object.values(justifications).reduce((a, b) => a + b, 0);
|
||||||
|
const attentionCap = roundAttentionCap(round, attentionCapBonusOf(activeProtocols));
|
||||||
|
|
||||||
|
const currentCard = drawQueue[0] || null;
|
||||||
|
const advanceCard = () => {
|
||||||
|
setDrawQueue((prev) => {
|
||||||
|
const rest = prev.slice(1);
|
||||||
|
return rest.length > 0 ? rest : shuffle(deck);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkGameOver = (nextGauges) => {
|
||||||
|
const hit = Object.entries(nextGauges).find(([, v]) => v >= 100);
|
||||||
|
if (hit) {
|
||||||
|
const [gaugeKey] = hit;
|
||||||
|
const label = {
|
||||||
|
entropy: '엔트로피 100% — 세계 멸망',
|
||||||
|
resistance: '조직 저항 100% — 사임',
|
||||||
|
panic: '대중 공황 100% — 문명 붕괴',
|
||||||
|
risk: '위험 수용 계수 100%',
|
||||||
|
}[gaugeKey];
|
||||||
|
setGameOver(label);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToShop = () => {
|
||||||
|
setShopDisplay(rollShopDisplay(deskCards));
|
||||||
|
setShopRerolled(false);
|
||||||
|
setPhase('shop');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSwipe = (direction) => {
|
||||||
|
if (!currentCard || phase !== 'approval') return;
|
||||||
|
const side = direction === 'right' ? currentCard.right : currentCard.left;
|
||||||
|
|
||||||
|
const nextGauges = applyPassiveGaugeRise(gauges, deskCards, gaugeExtraFlat);
|
||||||
|
setGauges(nextGauges);
|
||||||
|
|
||||||
|
// setState는 비동기라, 이번 카드 반영 후의 접수량을 한도 판정에 바로 써야 하므로
|
||||||
|
// 로컬 변수로 미리 계산한다.
|
||||||
|
let nextIntake = roundIntake;
|
||||||
|
if (side.axis) {
|
||||||
|
const grade = side.grade || 1;
|
||||||
|
const entry = pickPollutionEntry(side.axis, grade);
|
||||||
|
const newCard = makeDeskCard(side.axis, grade, entry || {}, round);
|
||||||
|
setDeskCards((prev) => addToDesk(prev, [newCard]));
|
||||||
|
nextIntake = roundIntake + grade;
|
||||||
|
setRoundIntake(nextIntake);
|
||||||
|
pushLog(`『${side.text}』 결재됨 → 오염 서류 『${entry?.name || '미상'}』(등급 ${grade}) 접수`);
|
||||||
|
} else {
|
||||||
|
pushLog(`『${side.text}』 결재됨 — 오염 없음`);
|
||||||
|
}
|
||||||
|
|
||||||
|
advanceCard();
|
||||||
|
|
||||||
|
if (checkGameOver(nextGauges)) return;
|
||||||
|
|
||||||
|
// §4.2-B: 이번 라운드 한도를 넘기면 합본 기회 없이 그 즉시 강제 종료된다. 카드는 이월(§4.1).
|
||||||
|
// roundIntake는 순수 누적 카운터라 합본해도 줄지 않는다 — 그래야 "합본을 반복하면 라운드가
|
||||||
|
// 안 끝나는" 문제 없이 한도가 실제로 라운드를 마감시킨다.
|
||||||
|
if (nextIntake > attentionCap) {
|
||||||
|
pushLog(`주목도 초과(한도 ${attentionCap}) — 라운드 강제 종료, 합본 기회 없이 정산으로 이동`);
|
||||||
|
goToShop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 플레이어가 한도 밑에서 스스로 라운드를 끝내는 "정산하기" — 블랙잭의 스탠드. */
|
||||||
|
const handleEndRound = () => {
|
||||||
|
if (phase !== 'approval') return;
|
||||||
|
pushLog(`제${round}라운드 자율 종료(정산하기) — 접수량 ${roundIntake}/${attentionCap}`);
|
||||||
|
goToShop();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCombineConfirm = (result) => {
|
||||||
|
// 족보에 실제로 쓰인 카드만 소멸한다 — 선택했지만 안 쓰인 카드는 책상에 남는다 (§4.1 이월).
|
||||||
|
setDeskCards((prev) => removeFromDesk(prev, result.removeIds));
|
||||||
|
setCoins((prev) => prev + result.coins); // §8.2 코인 1종, 등급 합계만큼
|
||||||
|
// §7.1 명분: 족보표의 명분 열이 곧 "수량"이다(0.5~5). 종류(4종)별로 누적하고 총합 5로 제한한다.
|
||||||
|
// 0.5짜리 페어는 두 번 모아야 1개가 되므로, 하위 족보만 반복하면 정치 자원이 잘 안 쌓인다.
|
||||||
|
setJustifications((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
const t = result.justification.type;
|
||||||
|
next[t] = (next[t] || 0) + result.justification.amount;
|
||||||
|
const total = Object.values(next).reduce((a, b) => a + b, 0);
|
||||||
|
if (total > JUSTIFICATION_CAP) next[t] = Math.max(0, next[t] - (total - JUSTIFICATION_CAP));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setScore((prev) => prev + result.performanceScore);
|
||||||
|
setRoundScore((prev) => prev + result.performanceScore);
|
||||||
|
pushLog(`합본 『${result.pattern.name}』 — 실적 +${result.performanceScore}, 코인 +${result.coins}, 명분 ${result.justification.type} +${result.justification.amount} (${result.removeIds.length}장 소멸)`);
|
||||||
|
setShowCombine(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 상점 (§8.3) ── */
|
||||||
|
const handleShopPurge = (cardId) => {
|
||||||
|
const card = deskCards.find((c) => c.id === cardId);
|
||||||
|
if (!card) return;
|
||||||
|
const price = purgePrice(card, surcharge);
|
||||||
|
setCoins((prev) => prev - price);
|
||||||
|
setDeskCards((prev) => removeFromDesk(prev, [cardId]));
|
||||||
|
setShopDisplay((prev) => prev.filter((c) => c.id !== cardId));
|
||||||
|
pushLog(`상점: 『${card.name || '오염 카드'}』(등급 ${card.grade}) 직접 소각 — 코인 -${price}`);
|
||||||
|
};
|
||||||
|
const handleShopReroll = () => {
|
||||||
|
setCoins((prev) => prev - REROLL_COST);
|
||||||
|
setShopDisplay(rollShopDisplay(deskCards));
|
||||||
|
setShopRerolled(true);
|
||||||
|
};
|
||||||
|
const handleShopContinue = () => {
|
||||||
|
setCouncilCandidates(rollProtocolCandidates(deferredCandidates));
|
||||||
|
setDeferredCandidates([]);
|
||||||
|
setPhase('council');
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 의회 (§9) ── */
|
||||||
|
const handleCouncilResolve = ({ coinsPaid, justificationsPaidCount, interventions }) => {
|
||||||
|
const { candidates: remainingCandidates, voteSwingTargetId, deferred, penaltyIdToClear } =
|
||||||
|
applyInterventions(councilCandidates, interventions);
|
||||||
|
|
||||||
|
// 이번 라운드 내내 적용 중이던 규약이 실적 요구치를 낮췄을 수 있다 (유형 D).
|
||||||
|
const finalRequired = Math.max(0, requiredScore(round) + requiredScoreDeltaOf(activeProtocols));
|
||||||
|
// §9.3: 실적 점수 = Σ(이번 라운드 족보 점수) + 코인 납입×5 + 명분 납입×20.
|
||||||
|
const finalScore = settleScore({
|
||||||
|
combineScore: roundScore, coinsPaid, justificationsPaid: justificationsPaidCount,
|
||||||
|
});
|
||||||
|
const passed = finalScore >= finalRequired;
|
||||||
|
|
||||||
|
setCoins((prev) => prev - coinsPaid);
|
||||||
|
setJustifications({}); // §7.1: 다음 의회까지만 유효 — 쓰지 않은 것도 여기서 소멸
|
||||||
|
|
||||||
|
// 징벌 규약 갱신. setState updater 안에서 로그를 찍으면 StrictMode의 이중 호출 때문에
|
||||||
|
// 같은 줄이 두 번 남는다(updater는 순수해야 한다) — 그래서 결과를 먼저 계산해서 커밋한다.
|
||||||
|
let nextPenalties = penalties;
|
||||||
|
if (penaltyIdToClear) {
|
||||||
|
const cleared = nextPenalties.find((p) => p.id === penaltyIdToClear);
|
||||||
|
nextPenalties = nextPenalties.filter((p) => p.id !== penaltyIdToClear);
|
||||||
|
if (cleared) pushLog(`위기 명분으로 징벌 규약 『${cleared.name}』 조기 해제`);
|
||||||
|
}
|
||||||
|
if (!passed) {
|
||||||
|
const penalty = rollPenalty();
|
||||||
|
nextPenalties = [...nextPenalties, { ...penalty, id: `${penalty.id}-${Date.now().toString(36)}` }];
|
||||||
|
pushLog(`실적 감사 미달 (${finalScore}/${finalRequired}) — 징벌 규약 『${penalty.name}』 부과`);
|
||||||
|
} else {
|
||||||
|
pushLog(`실적 감사 통과 (${finalScore}/${finalRequired})`);
|
||||||
|
if (finalScore >= finalRequired * 1.5 && nextPenalties.length > 0) {
|
||||||
|
pushLog(`실적 초과 달성 — 징벌 규약 『${nextPenalties[0].name}』 해제`);
|
||||||
|
nextPenalties = nextPenalties.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPenalties(nextPenalties);
|
||||||
|
|
||||||
|
// 절차 명분으로 이월된 후보는 다음 라운드 후보 풀에 다시 올라온다 (§7.3).
|
||||||
|
setDeferredCandidates(deferred);
|
||||||
|
if (deferred.length > 0) {
|
||||||
|
pushLog(`절차 명분으로 『${deferred.map((d) => d.name).join(', ')}』 다음 라운드 이월`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// §9.2 13석 표결. 혼합 명분은 1표만 움직이므로 1표 차 승부에서만 결과가 뒤집힌다 (§7.3).
|
||||||
|
const tallied = applyVoteSwing(tallyVotes(remainingCandidates), voteSwingTargetId);
|
||||||
|
const winner = resolveProtocolVote(tallied);
|
||||||
|
setActiveProtocol(winner);
|
||||||
|
if (winner) {
|
||||||
|
pushLog(`규약 통과: 『${winner.name}』 ${winner.votes}/${COUNCIL_SEATS}표 — 다음 라운드 적용`);
|
||||||
|
const grant = grantCoinOf(winner);
|
||||||
|
if (grant > 0) {
|
||||||
|
setCoins((prev) => prev + grant);
|
||||||
|
pushLog(`이득 규약 『${winner.name}』 — 코인 +${grant} 즉시 지급`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pushLog('통과된 규약 없음 — 다음 라운드는 제약 없이 진행됩니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setRound((r) => r + 1);
|
||||||
|
setRoundScore(0);
|
||||||
|
setRoundIntake(0); // 새 라운드는 접수량 0에서 다시 시작한다 (블랙잭의 새 핸드)
|
||||||
|
setPhase('approval');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (gameOver) {
|
||||||
|
return (
|
||||||
|
<div className="core-game-screen">
|
||||||
|
<div className="core-gameover">
|
||||||
|
<h1>결재 불가</h1>
|
||||||
|
<p>{gameOver}</p>
|
||||||
|
<p className="core-gameover-note">
|
||||||
|
엔딩 분기(§15)는 4차 범위라 아직 없다 — 여기서 그레이박스 루프가 멈춘다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentCard) {
|
||||||
|
return <div className="core-loading">카드를 불러오는 중...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="core-game-screen">
|
||||||
|
<DeskBar
|
||||||
|
deskCards={deskCards}
|
||||||
|
gauges={gauges}
|
||||||
|
coins={coins}
|
||||||
|
onOpenCombine={() => setShowCombine(true)}
|
||||||
|
onOpenGuide={() => setShowGuide(true)}
|
||||||
|
gaugeExtraFlat={gaugeExtraFlat}
|
||||||
|
coinExtraFlat={coinExtraFlat}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="core-status-row">
|
||||||
|
<span>제{round}라운드</span>
|
||||||
|
<span>실적 {score} (이번 라운드 {roundScore})</span>
|
||||||
|
<span>명분 {justificationTotal}개</span>
|
||||||
|
{activeProtocol && <ProtocolChip protocol={activeProtocol} prefix="⚖ " />}
|
||||||
|
{penalties.map((p) => <ProtocolChip key={p.id} protocol={p} prefix="⚠ " />)}
|
||||||
|
{/* 빌드 식별자 — 외부 테스터 피드백을 "어느 빌드였나"에 붙여 정리하기 위한 표시.
|
||||||
|
vite.config.js가 빌드 시각+커밋 해시를 주입한다. */}
|
||||||
|
<span className="core-build-stamp" title="빌드 식별자 (피드백 시 함께 적어 주세요)">
|
||||||
|
{__BUILD_INFO__}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{phase === 'approval' && (
|
||||||
|
<CoreSwipeCard
|
||||||
|
card={currentCard}
|
||||||
|
onSwipe={handleSwipe}
|
||||||
|
onEndRound={handleEndRound}
|
||||||
|
roundIntake={roundIntake}
|
||||||
|
attentionCap={attentionCap}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'shop' && (
|
||||||
|
<ShopScreen
|
||||||
|
display={shopDisplay}
|
||||||
|
coins={coins}
|
||||||
|
deskSurcharge={surcharge}
|
||||||
|
onPurge={handleShopPurge}
|
||||||
|
onReroll={handleShopReroll}
|
||||||
|
rerolled={shopRerolled}
|
||||||
|
onContinue={handleShopContinue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'council' && (
|
||||||
|
<CouncilScreen
|
||||||
|
round={round}
|
||||||
|
combineScore={roundScore}
|
||||||
|
coins={coins}
|
||||||
|
justifications={justifications}
|
||||||
|
penalties={penalties}
|
||||||
|
candidates={councilCandidates}
|
||||||
|
requiredDelta={requiredScoreDeltaOf(activeProtocols)}
|
||||||
|
onResolve={handleCouncilResolve}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="core-log">
|
||||||
|
{log.map((line, i) => <div key={i} className="core-log-line">{line}</div>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCombine && (
|
||||||
|
<CombinePanel
|
||||||
|
deskCards={deskCards}
|
||||||
|
onConfirm={handleCombineConfirm}
|
||||||
|
onClose={() => setShowCombine(false)}
|
||||||
|
activeProtocols={activeProtocols}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showGuide && <HandGuidePopup onClose={() => setShowGuide(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
.core-card-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-swipe-card {
|
||||||
|
position: relative;
|
||||||
|
width: min(420px, 92vw);
|
||||||
|
min-height: 320px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 좌/우 선택 슬롯 — 카드 본문과 절대 겹치지 않는 고정 영역. 항상 노출된다 (§3.3). */
|
||||||
|
.core-choice-row {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-choice {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-choice.left {
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-choice.right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-arrow {
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-choice.right .choice-body {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-text {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-safe {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.4;
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-card-header {
|
||||||
|
padding: 16px 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-card-category {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.6;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-card-keyword {
|
||||||
|
font-family: var(--font-doc-title);
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-card-question {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 15px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-card-body {
|
||||||
|
padding: 14px 20px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-card-text {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-card-flavor {
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.55;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 라운드 종료 액션 — 결재 카드 바로 아래. 주목도/한도를 같이 보여줘야 "한 장 더"가 판단이 된다. */
|
||||||
|
.core-round-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: min(420px, 92vw);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-round-action.zone-warning { border-color: var(--color-risk-critical); }
|
||||||
|
.core-round-action.zone-bust { border-color: var(--symbol-threat, #ff7b6b); }
|
||||||
|
|
||||||
|
.core-round-attention {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-round-attention strong {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-warning .core-round-attention strong { color: var(--color-risk-critical); }
|
||||||
|
.zone-bust .core-round-attention strong { color: var(--symbol-threat, #ff7b6b); }
|
||||||
|
|
||||||
|
.core-round-remaining { opacity: 0.6; }
|
||||||
|
|
||||||
|
.core-end-round-btn {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--color-token-info);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 9px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-end-round-btn:hover { filter: brightness(1.15); }
|
||||||
|
|
||||||
|
/* 세로가 짧은 화면(대부분의 폰) — 카드가 차지하는 고정 높이를 줄여 전체가 뷰포트에
|
||||||
|
들어오게 한다. min-height 320px는 폰에서 상단 툴바를 밀어내는 주 원인 중 하나였다.
|
||||||
|
카드 본문은 내용만큼만 차지하고, 넘치면 카드 안에서 스크롤된다. */
|
||||||
|
@media (max-height: 760px) {
|
||||||
|
.core-card-container {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-swipe-card {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-card-header {
|
||||||
|
padding: 10px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-card-body {
|
||||||
|
padding: 8px 16px 14px;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-choice {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { motion, useMotionValue, useTransform, useAnimation } from 'framer-motion';
|
||||||
|
import { SymbolFace } from './SymbolBadge';
|
||||||
|
import { attentionZone } from '../engine/desk';
|
||||||
|
import './CoreSwipeCard.css';
|
||||||
|
|
||||||
|
// §3 핵심 요건: "스와이프 판단은 기호만 보고 0.5초 내에 이루어져야 한다." 텍스트는 서사만 담당한다.
|
||||||
|
// 플레이테스트 피드백 반영: 처음엔 이 좌/우 예고를 카드 위에 겹쳐서(absolute) 그렸더니 드래그
|
||||||
|
// 중에 결재 카드 본문 글자를 가렸다. 그리고 기호 라벨("인사"/"대외" 등)만 보여서 이 선택이
|
||||||
|
// 실제로 뭘 하는 선택인지(대사)가 안 읽혔다. 그래서 좌/우 슬롯을 카드 위 고정 영역으로 빼고,
|
||||||
|
// 기호 + 선택 대사를 처음부터 항상 같이 보여준다. 드래그 제스처·물리는 기존
|
||||||
|
// components/SwipeCard.jsx를 재사용했다 (기획서 "재사용 자산").
|
||||||
|
//
|
||||||
|
// 2026-09-17 피드백: "정산하기"는 카드를 더 받을지 라운드를 끝낼지 정하는 핵심 결정인데
|
||||||
|
// 화면 오른쪽 위 툴바에 있으니 눈에 안 들어온다는 지적. 결재 카드 바로 아래, 선택을 내리는
|
||||||
|
// 자리로 옮기고 지금 접수량/한도를 버튼 옆에 같이 붙였다 — 블랙잭에서 "스탠드"를 누르기 전에
|
||||||
|
// 내 패 합계를 보는 것과 같다.
|
||||||
|
//
|
||||||
|
// 2026-09-18: 이번 라운드 접수량/한도는 "카드를 더 받을지" 판단에 쓰는 정보라 결재 화면(여기)
|
||||||
|
// 소관이다. 책상 전체의 주목도(이월 포함)는 DeskBar가 따로 보여준다 — 둘은 용도가 다르다.
|
||||||
|
export default function CoreSwipeCard({ card, onSwipe, onEndRound, roundIntake = 0, attentionCap = 0 }) {
|
||||||
|
const x = useMotionValue(0);
|
||||||
|
const y = useMotionValue(0);
|
||||||
|
const controls = useAnimation();
|
||||||
|
|
||||||
|
const rotate = useTransform(x, [-200, 200], [-15, 15]);
|
||||||
|
// 드래그 중인 방향의 선택 슬롯을 강조한다 — 겹쳐 가리는 대신 이미 보이는 슬롯을 밝힌다.
|
||||||
|
const leftHighlight = useTransform(x, [-100, 0], [1, 0.4]);
|
||||||
|
const rightHighlight = useTransform(x, [0, 100], [0.4, 1]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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.25 } });
|
||||||
|
onSwipe('right');
|
||||||
|
} else if (info.offset.x < -threshold) {
|
||||||
|
await controls.start({ x: -300, opacity: 0, transition: { duration: 0.25 } });
|
||||||
|
onSwipe('left');
|
||||||
|
} else {
|
||||||
|
controls.start({ x: 0, y: 0, transition: { type: 'spring', stiffness: 300, damping: 20 } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!card) return null;
|
||||||
|
|
||||||
|
const remaining = Math.max(0, attentionCap - roundIntake);
|
||||||
|
const zone = attentionZone(roundIntake, attentionCap); // desk.js와 같은 기준 (§4.2-B)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="core-card-container">
|
||||||
|
<motion.div
|
||||||
|
className="core-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="core-choice-row">
|
||||||
|
<motion.div className="core-choice left" style={{ opacity: leftHighlight }}>
|
||||||
|
<span className="choice-arrow">←</span>
|
||||||
|
<div className="choice-body">
|
||||||
|
<span className="choice-text">{card.left?.text}</span>
|
||||||
|
{card.left?.axis
|
||||||
|
? <SymbolFace axis={card.left.axis} grade={card.left.grade} />
|
||||||
|
: <span className="choice-safe">오염 없음</span>}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div className="core-choice right" style={{ opacity: rightHighlight }}>
|
||||||
|
<div className="choice-body">
|
||||||
|
<span className="choice-text">{card.right?.text}</span>
|
||||||
|
{card.right?.axis
|
||||||
|
? <SymbolFace axis={card.right.axis} grade={card.right.grade} />
|
||||||
|
: <span className="choice-safe">오염 없음</span>}
|
||||||
|
</div>
|
||||||
|
<span className="choice-arrow">→</span>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="core-card-header">
|
||||||
|
<span className="core-card-category">[{card.type}]</span>
|
||||||
|
{card.keyword && <h3 className="core-card-keyword">{card.keyword}</h3>}
|
||||||
|
{card.question && <h2 className="core-card-question">{card.question}</h2>}
|
||||||
|
</div>
|
||||||
|
<div className="core-card-body">
|
||||||
|
<p className="core-card-text">{card.text}</p>
|
||||||
|
{card.flavor && <p className="core-card-flavor">"{card.flavor}"</p>}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 결재 카드 바로 아래 — "한 장 더 받을까, 여기서 멈출까"를 같은 시선 안에서 판단한다. */}
|
||||||
|
{onEndRound && (
|
||||||
|
<div className={`core-round-action zone-${zone}`}>
|
||||||
|
<span className="core-round-attention">
|
||||||
|
이번 라운드 접수량 <strong>{roundIntake}</strong> / {attentionCap}
|
||||||
|
{remaining > 0
|
||||||
|
? <span className="core-round-remaining"> · 한도까지 {remaining}</span>
|
||||||
|
: <span className="core-round-remaining"> · 한도 도달</span>}
|
||||||
|
</span>
|
||||||
|
<button type="button" className="core-end-round-btn" onClick={onEndRound}>
|
||||||
|
정산하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
.council-screen {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-screen h2 {
|
||||||
|
font-family: var(--font-doc-title);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-screen h3 {
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
opacity: 0.75;
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-audit {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-audit-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
/* 규약 델타까지 붙으면 좁은 화면에서 오른쪽이 잘린다 — 줄바꿈을 허용한다. */
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 10px;
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-met { color: var(--color-coin); }
|
||||||
|
.audit-short { color: var(--color-risk-critical); }
|
||||||
|
|
||||||
|
.council-audit-breakdown {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin: 6px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-coin-pay {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-coin-pay button {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-coin-pay button:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-coin-committed {
|
||||||
|
min-width: 14px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-coin);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-coin-note {
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-penalties {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-penalty-chip {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(255, 68, 68, 0.08);
|
||||||
|
border: 1px solid rgba(255, 68, 68, 0.25);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--color-risk-critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-penalty-chip.targetable {
|
||||||
|
cursor: pointer;
|
||||||
|
outline: 1px dashed var(--color-risk-critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-penalty-more {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-justifications {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-empty {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-justification-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justification-badge {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.justification-partial {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-justification-item button {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 11px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-justification-item button:hover {
|
||||||
|
border-color: var(--color-coin);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-justification-item button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-pending-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-risk-critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-cancel-btn {
|
||||||
|
margin-left: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-candidates {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-pool {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-candidate {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-candidate.targetable {
|
||||||
|
cursor: pointer;
|
||||||
|
outline: 1px dashed var(--color-token-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-candidate.removed {
|
||||||
|
opacity: 0.4;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-candidate.forced {
|
||||||
|
border-color: var(--color-coin);
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-flavor {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-deferred-note {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-confirm-btn {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
background: var(--color-token-info);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 2026-09-17: 13석 표결 표시 + 툴팁 배치 ── */
|
||||||
|
|
||||||
|
.council-required-delta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-coin);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-margin {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-margin-hint {
|
||||||
|
color: var(--color-risk-critical);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-penalty-list,
|
||||||
|
.council-pool-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-penalty-slot.targetable,
|
||||||
|
.council-pool-slot.targetable {
|
||||||
|
outline: 1px dashed var(--color-risk-critical);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-votes {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-votes.lead {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--color-coin);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-state {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.65;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-candidate.winning {
|
||||||
|
border-color: var(--color-coin);
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-candidate.added {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-confirm-sub {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.council-margin-hint-weak {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Coins } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
requiredScore, settleScore, JUSTIFICATION_INTERVENTIONS, justificationDetail,
|
||||||
|
availableForTabling, applyInterventions, tallyVotes, applyVoteSwing, resolveProtocolVote,
|
||||||
|
COUNCIL_SEATS,
|
||||||
|
} from '../engine/council';
|
||||||
|
import InfoTooltip from './InfoTooltip';
|
||||||
|
import ProtocolChip from './ProtocolChip';
|
||||||
|
import './CouncilScreen.css';
|
||||||
|
|
||||||
|
// 의회 (§9.1) — 실적 감사와 규약 결정은 독립적이다: 실적을 잘 채웠다고 우호적 규약이
|
||||||
|
// 상정되지 않는다. 플레이어는 표결에 직접 개입 못 하고 명분으로 안건 자체를 조작할 뿐이다(§9.2).
|
||||||
|
//
|
||||||
|
// 2026-09-15 결정: 코인 1종, 명분 개입 4갈래(§7.3). 명분은 종류별 "수량"이라 1개 이상이어야 쓴다.
|
||||||
|
// 2026-09-17: §9.2의 13석 표결을 실제 표 수로 구현하고, 개입을 반영한 **예상 득표를 미리** 보여준다.
|
||||||
|
// 혼합 명분이 1표만 움직이는 이상(§7.3), 표 차를 모르면 쓸지 말지 판단할 수 없기 때문이다.
|
||||||
|
// 임원 개개인의 우호도·이중첩자(§9.5)는 4차 범위 — 지금은 안건 조합에 따른 결정적 배분이다.
|
||||||
|
export default function CouncilScreen({
|
||||||
|
round, combineScore, coins, justifications, penalties, candidates, requiredDelta = 0, onResolve,
|
||||||
|
}) {
|
||||||
|
const required = Math.max(0, requiredScore(round) + requiredDelta);
|
||||||
|
|
||||||
|
const [coinsPaid, setCoinsPaid] = useState(0);
|
||||||
|
const [spent, setSpent] = useState({}); // { 명분종류: 소모한 수량 }
|
||||||
|
const [scorePaidCount, setScorePaidCount] = useState(0);
|
||||||
|
const [interventions, setInterventions] = useState([]);
|
||||||
|
const [pending, setPending] = useState(null); // { type }
|
||||||
|
const [penaltiesExpanded, setPenaltiesExpanded] = useState(false);
|
||||||
|
|
||||||
|
const remainingOf = (type) => (justifications[type] || 0) - (spent[type] || 0);
|
||||||
|
|
||||||
|
const currentTotal = settleScore({
|
||||||
|
combineScore, coinsPaid, justificationsPaid: scorePaidCount,
|
||||||
|
});
|
||||||
|
const met = currentTotal >= required;
|
||||||
|
|
||||||
|
const spend = (type) => setSpent((prev) => ({ ...prev, [type]: (prev[type] || 0) + 1 }));
|
||||||
|
|
||||||
|
const payForScore = (type) => {
|
||||||
|
if (remainingOf(type) < 1) return;
|
||||||
|
spend(type);
|
||||||
|
setScorePaidCount((n) => n + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startIntervention = (type) => {
|
||||||
|
if (remainingOf(type) < 1) return;
|
||||||
|
setPending({ type });
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeTarget = (targetId, targetKind) => {
|
||||||
|
if (!pending) return;
|
||||||
|
const key = {
|
||||||
|
candidate: 'targetCandidateId',
|
||||||
|
penalty: 'targetPenaltyId',
|
||||||
|
pool: 'targetPoolProtocolId',
|
||||||
|
}[targetKind];
|
||||||
|
const iv = { type: pending.type, [key]: targetId };
|
||||||
|
// 여론 명분의 교체 후보는 지금 확정해 둔다 — 확정 시점에 무작위로 뽑으면 아래 예상 득표와
|
||||||
|
// 실제 결과가 달라진다.
|
||||||
|
if (pending.type === '여론') {
|
||||||
|
const pool = availableForTabling(candidates.filter((c) => c.id !== targetId));
|
||||||
|
const replacement = pool[Math.floor(Math.random() * pool.length)];
|
||||||
|
if (replacement) iv.replacementId = replacement.id;
|
||||||
|
}
|
||||||
|
spend(pending.type);
|
||||||
|
setInterventions((prev) => [...prev, iv]);
|
||||||
|
setPending(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingTargetKind = pending ? JUSTIFICATION_INTERVENTIONS[pending.type]?.target : null;
|
||||||
|
const tablingPool = availableForTabling(candidates);
|
||||||
|
const heldTypes = Object.keys(justifications).filter((t) => remainingOf(t) > 0);
|
||||||
|
|
||||||
|
// 지금까지의 개입을 반영한 최종 안건과 예상 득표. 확정 로직(CoreGameScreen)과 같은 함수를
|
||||||
|
// 같은 순서로 쓰고 tallyVotes가 결정적이라 미리보기와 결과가 일치한다.
|
||||||
|
const preview = applyInterventions(candidates, interventions);
|
||||||
|
const tallied = applyVoteSwing(tallyVotes(preview.candidates), preview.voteSwingTargetId);
|
||||||
|
const projectedWinner = resolveProtocolVote(tallied);
|
||||||
|
const sortedTally = [...tallied].sort((a, b) => b.votes - a.votes);
|
||||||
|
const margin = sortedTally.length >= 2 ? sortedTally[0].votes - sortedTally[1].votes : null;
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
onResolve({ coinsPaid, justificationsPaidCount: scorePaidCount, interventions });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="council-screen">
|
||||||
|
<h2>제{round}차 정례회의</h2>
|
||||||
|
|
||||||
|
<section className="council-audit">
|
||||||
|
<div className="council-audit-row">
|
||||||
|
<span>
|
||||||
|
요구 실적 {required}
|
||||||
|
{requiredDelta !== 0 && (
|
||||||
|
<span className="council-required-delta">
|
||||||
|
{' '}({requiredDelta > 0 ? '+' : ''}{requiredDelta} 규약 적용)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className={met ? 'audit-met' : 'audit-short'}>
|
||||||
|
현재 {currentTotal} {met ? '(충족)' : `(부족 ${required - currentTotal})`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="council-audit-breakdown">
|
||||||
|
족보 {combineScore} + 코인 납입 {coinsPaid}×5 + 명분 납입 {scorePaidCount}×20
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="council-coin-pay">
|
||||||
|
<span><Coins size={13} className="coin-icon" /> 코인 {coins - coinsPaid} 보유</span>
|
||||||
|
<button onClick={() => setCoinsPaid((n) => Math.max(0, n - 1))} disabled={coinsPaid === 0}>-</button>
|
||||||
|
<span className="council-coin-committed">{coinsPaid}</span>
|
||||||
|
<button onClick={() => setCoinsPaid((n) => Math.min(coins, n + 1))} disabled={coinsPaid >= coins}>+</button>
|
||||||
|
<span className="council-coin-note">납입 1개 = 실적 5</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{penalties.length > 0 && (() => {
|
||||||
|
// §9.2 유형C: "누적 5개 초과 시 목록을 접거나 압축 표시할 것 — 상시 전체 나열은
|
||||||
|
// 화면을 잠식한다." 위기 명분으로 특정 항목을 골라야 할 때는 강제로 펼친다.
|
||||||
|
const forceExpanded = pendingTargetKind === 'penalty';
|
||||||
|
const shown = forceExpanded || penaltiesExpanded ? penalties : penalties.slice(0, 5);
|
||||||
|
const hidden = penalties.length - shown.length;
|
||||||
|
return (
|
||||||
|
<section className="council-penalties">
|
||||||
|
<h3>누적 징벌 규약 {penalties.length}개</h3>
|
||||||
|
<div className="council-penalty-list">
|
||||||
|
{shown.map((p) => (
|
||||||
|
<span
|
||||||
|
key={p.id}
|
||||||
|
className={`council-penalty-slot ${pendingTargetKind === 'penalty' ? 'targetable' : ''}`}
|
||||||
|
onClick={() => pendingTargetKind === 'penalty' && completeTarget(p.id, 'penalty')}
|
||||||
|
>
|
||||||
|
<ProtocolChip protocol={p} prefix="⚠ " />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hidden > 0 && (
|
||||||
|
<button className="council-penalty-more" onClick={() => setPenaltiesExpanded(true)}>
|
||||||
|
+{hidden}개 더 보기
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!forceExpanded && penaltiesExpanded && penalties.length > 5 && (
|
||||||
|
<button className="council-penalty-more" onClick={() => setPenaltiesExpanded(false)}>
|
||||||
|
접기
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<section className="council-justifications">
|
||||||
|
<h3>보유 명분</h3>
|
||||||
|
{heldTypes.length === 0 && <p className="council-empty">사용 가능한 명분이 없습니다.</p>}
|
||||||
|
{heldTypes.map((type) => {
|
||||||
|
const spec = JUSTIFICATION_INTERVENTIONS[type];
|
||||||
|
const info = justificationDetail(type);
|
||||||
|
const remaining = remainingOf(type);
|
||||||
|
const usable = remaining >= 1;
|
||||||
|
return (
|
||||||
|
<div key={type} className="council-justification-item">
|
||||||
|
<InfoTooltip
|
||||||
|
title={`${type} 명분`}
|
||||||
|
badge={`보유 ${remaining}개`}
|
||||||
|
summary={info.summary}
|
||||||
|
detail={info.detail}
|
||||||
|
tone={usable ? 'neutral' : 'bad'}
|
||||||
|
>
|
||||||
|
<span className="justification-badge">{type} {remaining}개</span>
|
||||||
|
</InfoTooltip>
|
||||||
|
{!usable && <span className="justification-partial">1개 미만 — 사용 불가</span>}
|
||||||
|
<button onClick={() => payForScore(type)} disabled={!usable}>실적 납입 (+20)</button>
|
||||||
|
{spec && (
|
||||||
|
<button onClick={() => startIntervention(type)} disabled={!usable}>{spec.label}</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{pending && (
|
||||||
|
<p className="council-pending-hint">
|
||||||
|
"{pending.type}" 개입 대상을 {{
|
||||||
|
candidate: '아래 규약 후보',
|
||||||
|
penalty: '위 징벌 규약',
|
||||||
|
pool: '아래 상정 가능 규약',
|
||||||
|
}[pendingTargetKind]}에서 클릭하세요.
|
||||||
|
<button className="council-cancel-btn" onClick={() => setPending(null)}>취소</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="council-candidates">
|
||||||
|
<h3>
|
||||||
|
규약 표결 — {COUNCIL_SEATS}석
|
||||||
|
{margin !== null && (
|
||||||
|
<span className="council-margin">
|
||||||
|
{margin === 0 ? ' · 동점' : ` · 1위와 2위 ${margin}표 차`}
|
||||||
|
{margin <= 1 && !preview.voteSwingTargetId && (
|
||||||
|
<span className="council-margin-hint"> — 혼합 명분 1표로 뒤집을 수 있음</span>
|
||||||
|
)}
|
||||||
|
{margin >= 2 && !preview.voteSwingTargetId && (
|
||||||
|
<span className="council-margin-hint-weak"> — 혼합 명분으로는 못 뒤집음</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{candidates.map((c) => {
|
||||||
|
const deferredHere = interventions.some((iv) => iv.type === '절차' && iv.targetCandidateId === c.id);
|
||||||
|
const removed = interventions.some((iv) => iv.type === '여론' && iv.targetCandidateId === c.id);
|
||||||
|
const swung = interventions.some((iv) => iv.type === '혼합' && iv.targetCandidateId === c.id);
|
||||||
|
const gone = deferredHere || removed;
|
||||||
|
const tallyRow = tallied.find((t) => t.id === c.id);
|
||||||
|
const isWinner = projectedWinner && projectedWinner.id === c.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className={`council-candidate ${gone ? 'removed' : ''} ${isWinner ? 'winning' : ''} ${pendingTargetKind === 'candidate' ? 'targetable' : ''}`}
|
||||||
|
onClick={() => pendingTargetKind === 'candidate' && completeTarget(c.id, 'candidate')}
|
||||||
|
>
|
||||||
|
<div className="candidate-head">
|
||||||
|
<ProtocolChip protocol={c} />
|
||||||
|
{!gone && tallyRow && (
|
||||||
|
<span className={`candidate-votes ${isWinner ? 'lead' : ''}`}>
|
||||||
|
{tallyRow.votes}표{isWinner ? ' · 통과 예상' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(gone || swung) && (
|
||||||
|
<div className="candidate-state">
|
||||||
|
{deferredHere && '다음 라운드로 이월됨'}
|
||||||
|
{removed && '강제 탈락됨'}
|
||||||
|
{swung && '혼합 명분으로 +1표'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 개입으로 새로 들어온 안건(여론 교체분·내부 강제 상정분) */}
|
||||||
|
{preview.candidates
|
||||||
|
.filter((c) => !candidates.some((orig) => orig.id === c.id))
|
||||||
|
.map((c) => {
|
||||||
|
const tallyRow = tallied.find((t) => t.id === c.id);
|
||||||
|
const isWinner = projectedWinner && projectedWinner.id === c.id;
|
||||||
|
return (
|
||||||
|
<div key={c.id} className={`council-candidate added ${isWinner ? 'winning' : ''}`}>
|
||||||
|
<div className="candidate-head">
|
||||||
|
<ProtocolChip protocol={c} />
|
||||||
|
{tallyRow && (
|
||||||
|
<span className={`candidate-votes ${isWinner ? 'lead' : ''}`}>
|
||||||
|
{tallyRow.votes}표{isWinner ? ' · 통과 예상' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="candidate-state">명분으로 새로 올라온 안건</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{preview.candidates.length === 0 && (
|
||||||
|
<p className="council-empty">안건이 모두 사라졌습니다 — 이번 라운드는 통과 규약 없이 넘어갑니다.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{pendingTargetKind === 'pool' && (
|
||||||
|
<section className="council-pool">
|
||||||
|
<h3>상정 가능 규약 — 올릴 안건을 고르십시오</h3>
|
||||||
|
{tablingPool.length === 0 && <p className="council-empty">상정할 규약이 남지 않았습니다.</p>}
|
||||||
|
<div className="council-pool-list">
|
||||||
|
{tablingPool.map((p) => (
|
||||||
|
<span key={p.id} className="council-pool-slot targetable" onClick={() => completeTarget(p.id, 'pool')}>
|
||||||
|
<ProtocolChip protocol={p} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="council-confirm-btn" onClick={confirm}>
|
||||||
|
의회 결의 확정
|
||||||
|
{projectedWinner && <span className="council-confirm-sub"> — 『{projectedWinner.name}』 통과 예정</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
.desk-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
/* 상단 고정 — 로그·규약 칩이 쌓여 콘텐츠가 넘쳐도 "합본 결재"·"족보표" 버튼이
|
||||||
|
화면 밖으로 밀려나지 않게 한다. 모바일에서 실제로 버튼이 가려지는 문제가 있었다. */
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-bar-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-total {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-attention {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-surcharge { color: var(--color-risk-critical); }
|
||||||
|
.desk-coins { opacity: 0.8; }
|
||||||
|
|
||||||
|
.desk-bar-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-guide-btn,
|
||||||
|
.desk-combine-btn {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-guide-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-combine-btn {
|
||||||
|
background: var(--color-coin);
|
||||||
|
color: #0a0a0a;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-combine-btn:disabled {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-bar-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 3px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-row:not(:disabled):hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-row:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-row-name {
|
||||||
|
font-size: 11px;
|
||||||
|
width: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 축별 주목도 — 이 숫자가 게이지 상승률을 결정하므로 장수보다 크게 보여야 한다.
|
||||||
|
등급 칩과 값이 같을 때 붙어 보이지 않도록 배경을 줘서 분리한다. */
|
||||||
|
.desk-row-attention {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 30px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-row-chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-chip {
|
||||||
|
font-size: 11px;
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-chip.more { opacity: 0.55; }
|
||||||
|
|
||||||
|
.desk-row-empty {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-row-gauge {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.65;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-row-rate {
|
||||||
|
color: var(--color-risk-critical);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Coins } from 'lucide-react';
|
||||||
|
import { AXES, AXIS_META } from '../engine/symbols';
|
||||||
|
import {
|
||||||
|
totalCount, totalAttention, attentionByAxis, coinSurcharge, gaugeRiseRateByAxis,
|
||||||
|
} from '../engine/desk';
|
||||||
|
import { AxisIcon } from './SymbolBadge';
|
||||||
|
import DeskDetailPopup from './DeskDetailPopup';
|
||||||
|
import './DeskBar.css';
|
||||||
|
|
||||||
|
// 책상 상태 상시 노출 (§3.4) — "이 표시가 없으면 합본 설계가 불가능하며 스와이프는 무의미한 도박이 된다."
|
||||||
|
// 2026-09-16 결정 반영: 페널티 기준이 등급 합계(주목도)다.
|
||||||
|
//
|
||||||
|
// 2026-09-18 결정(역할 분리): 이번 라운드 접수량/한도("카드를 더 받을지" 판단)는 결재 화면
|
||||||
|
// (CoreSwipeCard)이 전담한다. 여기 DeskBar는 **책상에 지금 쌓여 있는 것**(이월 포함 총
|
||||||
|
// 주목도, 축별 분포, 게이지·지출 가산)만 보여준다 — "무엇을 합본할 수 있는가"에 필요한 정보다.
|
||||||
|
const MAX_GRADE_CHIPS = 6;
|
||||||
|
|
||||||
|
export default function DeskBar({
|
||||||
|
deskCards, gauges, coins, onOpenCombine, onOpenGuide,
|
||||||
|
gaugeExtraFlat = 0, coinExtraFlat = 0,
|
||||||
|
}) {
|
||||||
|
const [detailAxis, setDetailAxis] = useState(null);
|
||||||
|
|
||||||
|
const total = totalCount(deskCards);
|
||||||
|
const deskAttention = totalAttention(deskCards);
|
||||||
|
const perAxis = attentionByAxis(deskCards);
|
||||||
|
const rates = gaugeRiseRateByAxis(deskCards, gaugeExtraFlat);
|
||||||
|
const surcharge = coinSurcharge(deskCards, coinExtraFlat);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="desk-bar">
|
||||||
|
<div className="desk-bar-top">
|
||||||
|
<div className="desk-total">
|
||||||
|
책상 {total}장 · <span className="desk-attention">주목도 {deskAttention}</span>
|
||||||
|
{surcharge > 0 && <span className="desk-surcharge"> · 지출 가산 +{surcharge}</span>}
|
||||||
|
<span className="desk-coins"> · <Coins size={13} className="coin-icon" /> {coins}</span>
|
||||||
|
</div>
|
||||||
|
<div className="desk-bar-actions">
|
||||||
|
<button className="desk-guide-btn" onClick={onOpenGuide}>족보표</button>
|
||||||
|
<button className="desk-combine-btn" onClick={onOpenCombine} disabled={total < 2}>
|
||||||
|
합본 결재
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="desk-bar-rows">
|
||||||
|
{AXES.map((axis) => {
|
||||||
|
const cards = deskCards.filter((c) => c.axis === axis);
|
||||||
|
const grades = cards.map((c) => c.grade).sort((a, b) => b - a);
|
||||||
|
const shown = grades.slice(0, MAX_GRADE_CHIPS);
|
||||||
|
const hidden = grades.length - shown.length;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={axis}
|
||||||
|
className="desk-row"
|
||||||
|
onClick={() => cards.length > 0 && setDetailAxis(axis)}
|
||||||
|
disabled={cards.length === 0}
|
||||||
|
>
|
||||||
|
<AxisIcon axis={axis} size={17} />
|
||||||
|
<span className="desk-row-name">{AXIS_META[axis].name}</span>
|
||||||
|
<span className="desk-row-attention">{perAxis[axis]}</span>
|
||||||
|
<span className="desk-row-chips">
|
||||||
|
{shown.length === 0
|
||||||
|
? <span className="desk-row-empty">없음</span>
|
||||||
|
: shown.map((g, i) => <span key={i} className="desk-chip">{g}</span>)}
|
||||||
|
{hidden > 0 && <span className="desk-chip more">+{hidden}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="desk-row-gauge">
|
||||||
|
→ {AXIS_META[axis].gaugeName} {Math.round(gauges[AXIS_META[axis].gauge] || 0)}%
|
||||||
|
{rates[axis] > 0 && <span className="desk-row-rate"> ▲+{rates[axis].toFixed(1)}%/결재</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailAxis && (
|
||||||
|
<DeskDetailPopup
|
||||||
|
axis={detailAxis}
|
||||||
|
cards={deskCards.filter((c) => c.axis === detailAxis)}
|
||||||
|
onClose={() => setDetailAxis(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
.desk-detail-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 55;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-detail-panel {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
width: min(480px, 100%);
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-detail-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
flex: 1;
|
||||||
|
font-family: var(--font-doc-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-detail-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-detail-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-detail-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 등급 숫자 표기 (§3.2) — 점 표기 폐기 */
|
||||||
|
.desk-detail-grade {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 26px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-detail-grade.axis-axis_threat { color: var(--symbol-threat); }
|
||||||
|
.desk-detail-grade.axis-axis_hr { color: var(--symbol-hr); }
|
||||||
|
.desk-detail-grade.axis-axis_pr { color: var(--symbol-pr); }
|
||||||
|
.desk-detail-grade.axis-axis_external { color: var(--symbol-external); }
|
||||||
|
|
||||||
|
.desk-detail-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-detail-band {
|
||||||
|
font-weight: 400;
|
||||||
|
opacity: 0.55;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-detail-flavor {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-detail-empty {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { AXIS_META, gradeBandName } from '../engine/symbols';
|
||||||
|
import { AxisIcon } from './SymbolBadge';
|
||||||
|
import './DeskDetailPopup.css';
|
||||||
|
|
||||||
|
// 축 하나를 클릭했을 때 그 축에 쌓인 개별 오염 카드(이름·등급·사연)를 보여준다.
|
||||||
|
// DeskBar의 기본 뷰는 §4.1 "개별 카드 나열 금지" 때문에 집계만 보여주므로, 상세는 여기서.
|
||||||
|
export default function DeskDetailPopup({ axis, cards, onClose }) {
|
||||||
|
const meta = AXIS_META[axis];
|
||||||
|
const sorted = [...cards].sort((a, b) => b.grade - a.grade);
|
||||||
|
const attention = cards.reduce((s, c) => s + c.grade, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="desk-detail-overlay" onClick={onClose}>
|
||||||
|
<div className="desk-detail-panel" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="desk-detail-header">
|
||||||
|
<AxisIcon axis={axis} size={20} />
|
||||||
|
<h2>{meta.name} 축 — {cards.length}장 · 주목도 {attention}</h2>
|
||||||
|
<button className="desk-detail-close" onClick={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="desk-detail-list">
|
||||||
|
{sorted.map((c) => (
|
||||||
|
<div key={c.id} className="desk-detail-item">
|
||||||
|
<span className={`desk-detail-grade axis-${axis}`}>{c.grade}</span>
|
||||||
|
<div className="desk-detail-text">
|
||||||
|
<div className="desk-detail-name">
|
||||||
|
{c.name || '미상 서류'}
|
||||||
|
<span className="desk-detail-band"> · {gradeBandName(c.grade)}</span>
|
||||||
|
</div>
|
||||||
|
{c.flavor && <div className="desk-detail-flavor">{c.flavor}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{cards.length === 0 && <p className="desk-detail-empty">이 축에 쌓인 오염 카드가 없습니다.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
.guide-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 60;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-panel {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
width: min(560px, 100%);
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-doc-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.65;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-hint strong {
|
||||||
|
color: var(--color-coin);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-row {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-row-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-name {
|
||||||
|
font-weight: 700;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-score { font-size: 12px; color: var(--color-coin); }
|
||||||
|
.guide-justification { font-size: 12px; opacity: 0.65; }
|
||||||
|
|
||||||
|
.guide-condition {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-example {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { HAND_DEFS, MIN_COMBINE_CARDS, MAX_COMBINE_CARDS } from '../engine/hands';
|
||||||
|
import { TOP_SECRET_THRESHOLD } from '../engine/symbols';
|
||||||
|
import SymbolBadge from './SymbolBadge';
|
||||||
|
import './HandGuidePopup.css';
|
||||||
|
|
||||||
|
// 족보표 (§5.1) — 트럼프 족보가 아무리 유명해도 기호 자체가 다르면 안 와닿는다는
|
||||||
|
// 플레이테스트 피드백. 버튼으로 언제든 열어 이 게임의 기호(축 인장 + 등급 숫자)로 다시 설명한다.
|
||||||
|
// 2026-09-15 결정 반영: 5장 포커 핸드 8종, 등급 1~10.
|
||||||
|
const GUIDE_ORDER = [
|
||||||
|
'pair', 'two_pair', 'triple', 'straight', 'flush_axis', 'full_house', 'four_kind', 'straight_flush',
|
||||||
|
];
|
||||||
|
|
||||||
|
const EXAMPLES = {
|
||||||
|
pair: [['axis_hr', 7], ['axis_pr', 7]],
|
||||||
|
two_pair: [['axis_hr', 7], ['axis_pr', 7], ['axis_external', 3], ['axis_threat', 3]],
|
||||||
|
triple: [['axis_hr', 5], ['axis_pr', 5], ['axis_external', 5]],
|
||||||
|
straight: [['axis_threat', 3], ['axis_pr', 4], ['axis_external', 5], ['axis_hr', 6], ['axis_threat', 7]],
|
||||||
|
flush_axis: [['axis_hr', 2], ['axis_hr', 5], ['axis_hr', 6], ['axis_hr', 9], ['axis_hr', 10]],
|
||||||
|
full_house: [['axis_hr', 8], ['axis_pr', 8], ['axis_external', 8], ['axis_threat', 4], ['axis_hr', 4]],
|
||||||
|
four_kind: [['axis_hr', 9], ['axis_pr', 9], ['axis_external', 9], ['axis_threat', 9]],
|
||||||
|
straight_flush: [['axis_hr', 6], ['axis_hr', 7], ['axis_hr', 8], ['axis_hr', 9], ['axis_hr', 10]],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HandGuidePopup({ onClose }) {
|
||||||
|
return (
|
||||||
|
<div className="guide-overlay" onClick={onClose}>
|
||||||
|
<div className="guide-panel" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="guide-header">
|
||||||
|
<h2>족보표</h2>
|
||||||
|
<button className="guide-close" onClick={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
<p className="guide-hint">
|
||||||
|
"같은 등급"은 트럼프의 같은 숫자, "같은 축"은 같은 무늬와 같다. 한 번에
|
||||||
|
{' '}{MIN_COMBINE_CARDS}~{MAX_COMBINE_CARDS}장을 선택하면 성립하는 족보가 자동 판정되며,
|
||||||
|
족보에 실제로 쓰인 카드만 소멸한다 (나머지는 책상에 남는다).
|
||||||
|
코인은 <strong>사용된 카드의 등급 합계</strong>만큼 나오고, 등급 {TOP_SECRET_THRESHOLD} 이상
|
||||||
|
카드가 끼면 명분이 +1 된다.
|
||||||
|
</p>
|
||||||
|
<div className="guide-list">
|
||||||
|
{GUIDE_ORDER.map((id) => {
|
||||||
|
const def = HAND_DEFS[id];
|
||||||
|
return (
|
||||||
|
<div key={id} className="guide-row">
|
||||||
|
<div className="guide-row-head">
|
||||||
|
<span className="guide-name">{def.name}</span>
|
||||||
|
<span className="guide-score">실적 +{def.score}</span>
|
||||||
|
<span className="guide-justification">명분 +{def.baseJustification}</span>
|
||||||
|
</div>
|
||||||
|
<div className="guide-condition">{def.condition}</div>
|
||||||
|
<div className="guide-example">
|
||||||
|
{EXAMPLES[id].map(([axis, grade], i) => (
|
||||||
|
<SymbolBadge key={i} axis={axis} grade={grade} size="sm" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
.info-tip-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tip-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tip-trigger:hover,
|
||||||
|
.info-tip-trigger.open {
|
||||||
|
border-color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tip-trigger.tone-bad {
|
||||||
|
background: rgba(255, 68, 68, 0.1);
|
||||||
|
border-color: rgba(255, 120, 107, 0.35);
|
||||||
|
color: var(--symbol-threat, #ff7b6b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tip-trigger.tone-good {
|
||||||
|
background: rgba(76, 175, 80, 0.12);
|
||||||
|
border-color: rgba(76, 175, 80, 0.4);
|
||||||
|
color: var(--color-coin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 물음표 배지 — 누르면 설명이 나온다는 신호. 이게 없으면 툴팁의 존재 자체를 모른다. */
|
||||||
|
.info-tip-mark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tip-pop {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 80;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
width: max(260px, 100%);
|
||||||
|
max-width: min(340px, 84vw);
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #101014;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.55);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tip-pop.tone-bad { border-color: rgba(255, 120, 107, 0.5); }
|
||||||
|
.info-tip-pop.tone-good { border-color: rgba(76, 175, 80, 0.5); }
|
||||||
|
|
||||||
|
.info-tip-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tip-badge {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tip-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tip-summary {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone-bad .info-tip-summary { color: var(--symbol-threat, #ff7b6b); }
|
||||||
|
.tone-good .info-tip-summary { color: var(--color-coin); }
|
||||||
|
|
||||||
|
.info-tip-detail {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 화면 오른쪽 끝에 붙은 트리거는 팝업을 왼쪽으로 정렬해 잘리지 않게 한다. */
|
||||||
|
.info-tip-wrap.align-right .info-tip-pop {
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import './InfoTooltip.css';
|
||||||
|
|
||||||
|
// 규약·징벌·명분이 "정확히 무엇을 하는지" 눌러서 확인하는 툴팁 (플레이테스트 피드백).
|
||||||
|
// 규약 이름만 보고는 어떤 족보가 막혔는지 알 수 없다는 지적이 있었다 — 그래서 설명 문구는
|
||||||
|
// UI가 추측하지 않고 엔진 카탈로그(council.js protocolEffectDetail 등)에서 받아 온다.
|
||||||
|
//
|
||||||
|
// 모바일 대상(§1)이라 hover가 아니라 **클릭/탭**으로 연다. 바깥을 누르거나 Esc로 닫는다.
|
||||||
|
export default function InfoTooltip({ title, badge, summary, detail, tone = 'neutral', children, className = '' }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const wrapRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
const onDocDown = (e) => {
|
||||||
|
if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
|
||||||
|
document.addEventListener('mousedown', onDocDown);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDocDown);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`info-tip-wrap ${className}`} ref={wrapRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`info-tip-trigger tone-${tone} ${open ? 'open' : ''}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<span className="info-tip-mark" aria-hidden="true">?</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<span className={`info-tip-pop tone-${tone}`} role="tooltip">
|
||||||
|
<span className="info-tip-head">
|
||||||
|
{badge && <span className="info-tip-badge">{badge}</span>}
|
||||||
|
<span className="info-tip-title">{title}</span>
|
||||||
|
</span>
|
||||||
|
{summary && <span className="info-tip-summary">{summary}</span>}
|
||||||
|
{detail && <span className="info-tip-detail">{detail}</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import InfoTooltip from './InfoTooltip';
|
||||||
|
import { protocolEffectDetail } from '../engine/council';
|
||||||
|
|
||||||
|
// 규약·징벌을 이름 + 물음표 배지로 보여주고, 누르면 "정확히 무엇이 막혔는지"를 띄운다.
|
||||||
|
// 설명 문구는 council.js의 카탈로그에서 생성하므로 화면마다 달라지지 않는다.
|
||||||
|
// 상태 표시줄 · 의회 화면 · 합본 패널에서 공용으로 쓴다.
|
||||||
|
export default function ProtocolChip({ protocol, prefix, className = '' }) {
|
||||||
|
if (!protocol) return null;
|
||||||
|
const { typeLabel, summary, detail, tone } = protocolEffectDetail(protocol);
|
||||||
|
return (
|
||||||
|
<InfoTooltip
|
||||||
|
title={protocol.name}
|
||||||
|
badge={`유형 ${protocol.type} · ${typeLabel}`}
|
||||||
|
summary={summary}
|
||||||
|
detail={`${detail}${protocol.flavor ? `\n\n"${protocol.flavor}"` : ''}`}
|
||||||
|
tone={tone}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<span className="protocol-chip-name">
|
||||||
|
{prefix}{protocol.name}
|
||||||
|
</span>
|
||||||
|
</InfoTooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
.shop-screen {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px 20px;
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-header h2 {
|
||||||
|
font-family: var(--font-doc-title);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.65;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-coins {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 60px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-buy-btn {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--color-coin);
|
||||||
|
color: #0a0a0a;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-buy-btn:disabled {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-empty {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-reroll-btn,
|
||||||
|
.shop-continue-btn {
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-reroll-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-reroll-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-continue-btn {
|
||||||
|
background: var(--color-token-info);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Coins } from 'lucide-react';
|
||||||
|
import SymbolBadge from './SymbolBadge';
|
||||||
|
import { purgePrice, REROLL_COST, canAfford } from '../engine/shop';
|
||||||
|
import './ShopScreen.css';
|
||||||
|
|
||||||
|
// 상점 (§8.3) — 라운드 종료 후 의회 진입 전 정산 단계. 자산 구매·소모품·전례 매입은
|
||||||
|
// 각각 §10·§12·§11(3차 범위)이 있어야 성립해서 아직 없다. 지금 성립하는 품목은
|
||||||
|
// "오염 카드 직접 처리" 하나뿐이다 — 족보가 안 맞을 때의 탈출구, 명분·실적은 안 준다.
|
||||||
|
export default function ShopScreen({ display, coins, deskSurcharge, onPurge, onReroll, rerolled, onContinue }) {
|
||||||
|
return (
|
||||||
|
<div className="shop-screen">
|
||||||
|
<div className="shop-header">
|
||||||
|
<h2>정산 — 상점</h2>
|
||||||
|
<p className="shop-hint">
|
||||||
|
족보가 안 맞는 오염 카드를 코인으로 직접 처리할 수 있습니다 (명분·실적은 없음).
|
||||||
|
가격은 등급만큼이며 지출 가산 +{deskSurcharge}이 얹힙니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shop-coins"><Coins size={14} className="coin-icon" /> 보유 코인 {coins}</div>
|
||||||
|
|
||||||
|
<div className="shop-grid">
|
||||||
|
{display.length === 0 && <p className="shop-empty">진열할 오염 카드가 없습니다.</p>}
|
||||||
|
{display.map((card) => {
|
||||||
|
const price = purgePrice(card, deskSurcharge);
|
||||||
|
return (
|
||||||
|
<div key={card.id} className="shop-item">
|
||||||
|
<SymbolBadge axis={card.axis} grade={card.grade} />
|
||||||
|
<span className="shop-item-name">{card.name || '미상 서류'}</span>
|
||||||
|
<button
|
||||||
|
className="shop-buy-btn"
|
||||||
|
disabled={!canAfford(coins, price)}
|
||||||
|
onClick={() => onPurge(card.id)}
|
||||||
|
>
|
||||||
|
소각 · {price}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shop-actions">
|
||||||
|
<button
|
||||||
|
className="shop-reroll-btn"
|
||||||
|
disabled={rerolled || !canAfford(coins, REROLL_COST)}
|
||||||
|
onClick={onReroll}
|
||||||
|
>
|
||||||
|
{rerolled ? '리롤 완료' : `진열 다시 뽑기 (${REROLL_COST})`}
|
||||||
|
</button>
|
||||||
|
<button className="shop-continue-btn" onClick={onContinue}>의회로 이동 →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
.symbol-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 등급 숫자가 주역이다 (§3.2 트럼프 랭크+슈트 레이아웃) — 인장은 곁들이는 보조 기호. */
|
||||||
|
.symbol-grade {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
min-width: 15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.symbol-badge.size-sm .symbol-grade { font-size: 13px; }
|
||||||
|
.symbol-badge.size-lg .symbol-grade { font-size: 20px; }
|
||||||
|
|
||||||
|
.symbol-name {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 카드 면 표기 — 인장을 모서리에, 등급 숫자를 중앙에 크게 */
|
||||||
|
.symbol-face {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 56px;
|
||||||
|
padding: 6px 10px 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
font-family: var(--font-typewriter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.symbol-face-seal {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.symbol-face-grade {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.symbol-face-axis {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 축별 색상 — 인장 아이콘 형태가 1차 구분 수단이고, 색은 보조 신호일 뿐이다 (§3.1).
|
||||||
|
index.css의 게이지 토큰(--color-entropy-critical 등)은 게이지 바 배경용으로 만들어져서
|
||||||
|
어두운 값(#8B0000)이 섞여 있다. 등급 숫자는 다크 배경 위의 본문 텍스트라 그대로 쓰면
|
||||||
|
읽히지 않으므로, 기호 표기 전용으로 밝은 변형을 따로 둔다 (토큰 자체는 건드리지 않는다). */
|
||||||
|
:root {
|
||||||
|
--symbol-threat: #ff7b6b;
|
||||||
|
--symbol-hr: var(--color-resistance-critical);
|
||||||
|
--symbol-pr: #d07bff;
|
||||||
|
--symbol-external: var(--color-risk-critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis-axis_threat .axis-icon,
|
||||||
|
.symbol-badge.axis-axis_threat .symbol-grade,
|
||||||
|
.symbol-face.axis-axis_threat .symbol-face-grade { color: var(--symbol-threat); }
|
||||||
|
.axis-axis_hr .axis-icon,
|
||||||
|
.symbol-badge.axis-axis_hr .symbol-grade,
|
||||||
|
.symbol-face.axis-axis_hr .symbol-face-grade { color: var(--symbol-hr); }
|
||||||
|
.axis-axis_pr .axis-icon,
|
||||||
|
.symbol-badge.axis-axis_pr .symbol-grade,
|
||||||
|
.symbol-face.axis-axis_pr .symbol-face-grade { color: var(--symbol-pr); }
|
||||||
|
.axis-axis_external .axis-icon,
|
||||||
|
.symbol-badge.axis-axis_external .symbol-grade,
|
||||||
|
.symbol-face.axis-axis_external .symbol-face-grade { color: var(--symbol-external); }
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Tornado, Hand, Megaphone, Stamp } from 'lucide-react';
|
||||||
|
import { AXIS_META, clampGrade } from '../engine/symbols';
|
||||||
|
import './SymbolBadge.css';
|
||||||
|
|
||||||
|
// §3.1 요건: 4종 인장은 실루엣(아이콘 형태)으로 구분한다. 색상만으로 구분하지 않는다 —
|
||||||
|
// 그래서 axis마다 아이콘 자체가 다르고, CSS 색상은 보조 신호로만 얹는다.
|
||||||
|
const AXIS_ICON = {
|
||||||
|
axis_threat: Tornado,
|
||||||
|
axis_hr: Hand,
|
||||||
|
axis_pr: Megaphone,
|
||||||
|
axis_external: Stamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 축 인장 하나. size는 lucide 아이콘 px 크기. */
|
||||||
|
export function AxisIcon({ axis, size = 20, className = '' }) {
|
||||||
|
const Icon = AXIS_ICON[axis];
|
||||||
|
if (!Icon) return null;
|
||||||
|
return <Icon size={size} className={`axis-icon axis-${axis} ${className}`} strokeWidth={2.25} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 축 인장 + 기밀 등급을 한 세트로 보여주는 배지.
|
||||||
|
* 2026-09-15 결정: 점(●) 표기 폐기 — 10단계는 점으로 셀 수 없다. 트럼프 랭크+슈트 레이아웃대로
|
||||||
|
* 등급 숫자를 크게 놓고 축 인장을 작게 곁들인다 (§3.2).
|
||||||
|
*/
|
||||||
|
export default function SymbolBadge({ axis, grade, size = 'md', showName = false }) {
|
||||||
|
if (!axis) return null;
|
||||||
|
const g = clampGrade(grade);
|
||||||
|
const iconSize = size === 'lg' ? 20 : size === 'sm' ? 12 : 15;
|
||||||
|
return (
|
||||||
|
<div className={`symbol-badge size-${size} axis-${axis}`}>
|
||||||
|
<AxisIcon axis={axis} size={iconSize} />
|
||||||
|
<span className="symbol-grade">{g}</span>
|
||||||
|
{showName && <span className="symbol-name">{AXIS_META[axis].name}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 카드 면에 크게 찍는 형태 — 스와이프 예고 슬롯처럼 판단이 걸린 자리에 쓴다. */
|
||||||
|
export function SymbolFace({ axis, grade }) {
|
||||||
|
if (!axis) return null;
|
||||||
|
const g = clampGrade(grade);
|
||||||
|
return (
|
||||||
|
<div className={`symbol-face axis-${axis}`}>
|
||||||
|
<AxisIcon axis={axis} size={18} className="symbol-face-seal" />
|
||||||
|
<span className="symbol-face-grade">{g}</span>
|
||||||
|
<span className="symbol-face-axis">{AXIS_META[axis].name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
// 의회 엔진 — ProjectSS_코어시스템_기획서.md §7(명분) · §9.2(규약) · §9.3(실적 감사).
|
||||||
|
// React 의존 없는 순수 함수 + 카탈로그.
|
||||||
|
//
|
||||||
|
// 2026-09-15 결정 반영: 코인 1종 통합(§8.2), 명분 개입 4갈래 재정의(§7.3).
|
||||||
|
// 2026-09-17 반영:
|
||||||
|
// - 유형 D(이득 규약) 카탈로그 신설. 내부 명분("강제 상정")이 불리한 유형 A만 올릴 수 있어
|
||||||
|
// 실속이 없던 문제를 해결한다 — 이제 이득 규약을 직접 안건에 올릴 수 있다.
|
||||||
|
// - 13석 표결을 실제 표 수로 구현. 혼합 명분이 "직접 통과 지정"(무조건 통과)이라는
|
||||||
|
// 자리표시자였는데, 이제 §7.3 스펙대로 **1표만 움직인다** — 1표 차 승부일 때만 뒤집힌다.
|
||||||
|
// 임원 개개인의 우호도·이중첩자(§9.5~9.6)는 여전히 4차 범위이며, 그때 이 무작위 배분이
|
||||||
|
// "임원별 투표"로 교체된다. 표 집계 자체는 §9.5가 공개를 요구하므로 화면에 노출한다.
|
||||||
|
// - 툴팁용 효과 설명(protocolEffectDetail)을 엔진에서 생성한다 — 규약이 정확히 무엇을
|
||||||
|
// 막는지 UI가 자체 문구로 추측하지 않도록, 카탈로그와 설명을 한곳에 둔다.
|
||||||
|
|
||||||
|
import { HAND_DEFS } from './hands.js';
|
||||||
|
|
||||||
|
/* ── §9.3 실적 감사 ── */
|
||||||
|
|
||||||
|
// 요구치 곡선은 가안: 1라운드 25, 라운드당 +20 (§9.3).
|
||||||
|
export const requiredScore = (round) => 25 + (round - 1) * 20;
|
||||||
|
|
||||||
|
// 실적 점수 = Σ(족보 점수, 라운드 중 합본 누적) + 코인 납입×5 + 명분 납입×20.
|
||||||
|
export const settleScore = ({ combineScore, coinsPaid, justificationsPaid }) =>
|
||||||
|
combineScore + coinsPaid * 5 + justificationsPaid * 20;
|
||||||
|
|
||||||
|
/* ── §9.2 규약 카탈로그 ──
|
||||||
|
* 유형 A(족보표 변경)·C(징벌)·D(이득)까지 구현. 유형 B(금리형)는 §20.2 콘텐츠 작업 대상. */
|
||||||
|
|
||||||
|
export const PROTOCOL_TYPE_A = [
|
||||||
|
{
|
||||||
|
id: 'protocol_flush_ban',
|
||||||
|
name: '동일 부서 일괄 처리 금지 규약',
|
||||||
|
type: 'A',
|
||||||
|
effect: { kind: 'disable_hand', patternId: 'flush_axis' },
|
||||||
|
flavor: '동일 부서 건을 묶어 올리는 관행이 유착으로 지적되었습니다. 개별 상신할 것.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'protocol_straight_ban',
|
||||||
|
name: '연번 상신 금지 규약',
|
||||||
|
type: 'A',
|
||||||
|
effect: { kind: 'disable_hand', patternId: 'straight' },
|
||||||
|
flavor: '등급을 순서대로 올리는 관행이 결재 회피 수단으로 악용되고 있습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'protocol_straightflush_ban',
|
||||||
|
name: '전결 처리 금지 규약',
|
||||||
|
type: 'A',
|
||||||
|
effect: { kind: 'disable_hand', patternId: 'straight_flush' },
|
||||||
|
flavor: '한 부서가 연번까지 독점 처리하는 것은 감사 사각지대를 만듭니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'protocol_fourkind_ban',
|
||||||
|
name: '전면 개편 금지 규약',
|
||||||
|
type: 'A',
|
||||||
|
effect: { kind: 'disable_hand', patternId: 'four_kind' },
|
||||||
|
flavor: '동일 등급 4건 일괄 개편은 절차 생략 소지가 있어 당분간 금지합니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'protocol_fullhouse_ban',
|
||||||
|
name: '종합 감사 제한 규약',
|
||||||
|
type: 'A',
|
||||||
|
effect: { kind: 'disable_hand', patternId: 'full_house' },
|
||||||
|
flavor: '3+2 형태의 묶음 상신은 감사 항목을 흐린다는 지적이 있었습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'protocol_min_grade',
|
||||||
|
name: '기밀 등급 심사 강화 규약',
|
||||||
|
type: 'A',
|
||||||
|
effect: { kind: 'min_grade', minGrade: 4 },
|
||||||
|
flavor: '등급 4 미만 문서만으로는 더 이상 합본 상신이 승인되지 않습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'protocol_pair_ban',
|
||||||
|
name: '중복 접수 금지 규약',
|
||||||
|
type: 'A',
|
||||||
|
effect: { kind: 'disable_hand', patternId: 'pair' },
|
||||||
|
flavor: '두 건만 묶어 올리는 편의적 관행을 제한합니다. 최소 규모를 갖출 것.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PROTOCOL_TYPE_C = [
|
||||||
|
{
|
||||||
|
id: 'penalty_gauge_accel',
|
||||||
|
name: '미결 서류 관리 규정',
|
||||||
|
type: 'C',
|
||||||
|
effect: { kind: 'gauge_surcharge', amount: 1.0 },
|
||||||
|
flavor: '미결 누적에 대한 문책성 규정 — 모든 축의 결재당 게이지 상승이 가산됩니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'penalty_coin_surcharge',
|
||||||
|
name: '긴급 재정 감사 대응',
|
||||||
|
type: 'C',
|
||||||
|
effect: { kind: 'coin_surcharge', amount: 2 },
|
||||||
|
flavor: '감사 대응 인력 편성 비용이 모든 코인 지출에 얹힙니다.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 유형 D — 이득 규약. §9.2 "드물게 통과"이므로 후보 등장 확률을 낮게 잡는다(D_CANDIDATE_CHANCE).
|
||||||
|
// 자산 지급형은 자산 시스템(§10, 3차)이 없어 제외했고, 코인·족보 완화·한도 완화로 구성했다.
|
||||||
|
export const PROTOCOL_TYPE_D = [
|
||||||
|
{
|
||||||
|
id: 'boon_budget_grant',
|
||||||
|
name: '예비비 긴급 편성',
|
||||||
|
type: 'D',
|
||||||
|
effect: { kind: 'grant_coin', amount: 12 },
|
||||||
|
flavor: '이월 예산 일부를 국장 재량으로 집행할 수 있도록 의결되었습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'boon_workload_relief',
|
||||||
|
name: '업무 경감 조치',
|
||||||
|
type: 'D',
|
||||||
|
effect: { kind: 'gauge_surcharge', amount: -1.0 },
|
||||||
|
flavor: '과중 업무에 대한 한시적 완화 조치가 승인되었습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'boon_admin_simplify',
|
||||||
|
name: '행정 간소화 지침',
|
||||||
|
type: 'D',
|
||||||
|
effect: { kind: 'coin_surcharge', amount: -2 },
|
||||||
|
flavor: '중복 결재 단계를 축소해 집행 비용을 낮춥니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'boon_cap_extension',
|
||||||
|
name: '임시 결재 권한 확대',
|
||||||
|
type: 'D',
|
||||||
|
effect: { kind: 'attention_cap_bonus', amount: 8 },
|
||||||
|
flavor: '한시적으로 국장 전결 범위가 확대되었습니다. 더 오래 끌고 갈 수 있습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'boon_audit_relief',
|
||||||
|
name: '실적 기준 완화',
|
||||||
|
type: 'D',
|
||||||
|
effect: { kind: 'required_score_delta', amount: -20 },
|
||||||
|
flavor: '이번 회기의 실적 요구 기준이 하향 조정되었습니다.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 후보로 올라올 수 있는 전체 규약 (유형 A + D). 징벌(C)은 감사 실패로만 부과된다. */
|
||||||
|
export const TABLEABLE_PROTOCOLS = [...PROTOCOL_TYPE_A, ...PROTOCOL_TYPE_D];
|
||||||
|
|
||||||
|
const D_CANDIDATE_CHANCE = 0.3; // §9.2 "드물게 통과" — 후보 3개 중 1개가 D일 확률
|
||||||
|
|
||||||
|
const pick = (arr, n) => {
|
||||||
|
const pool = [...arr];
|
||||||
|
const out = [];
|
||||||
|
while (out.length < n && pool.length > 0) {
|
||||||
|
out.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매 라운드 규약 후보 3개를 뽑는다.
|
||||||
|
* carriedOver: 절차 명분으로 지난 라운드에서 이월된 후보 — "지금은 피하지만 나중에 다시 온다"(§7.3).
|
||||||
|
* 이득 규약(D)은 낮은 확률로 한 자리만 차지한다.
|
||||||
|
*/
|
||||||
|
export const rollProtocolCandidates = (carriedOver = []) => {
|
||||||
|
const carriedIds = new Set(carriedOver.map((c) => c.id));
|
||||||
|
const slots = Math.max(0, 3 - carriedOver.length);
|
||||||
|
const out = [...carriedOver];
|
||||||
|
|
||||||
|
if (slots > 0 && Math.random() < D_CANDIDATE_CHANCE) {
|
||||||
|
const boon = pick(PROTOCOL_TYPE_D.filter((p) => !carriedIds.has(p.id)), 1);
|
||||||
|
out.push(...boon);
|
||||||
|
}
|
||||||
|
const need = Math.max(0, 3 - out.length);
|
||||||
|
const taken = new Set(out.map((c) => c.id));
|
||||||
|
out.push(...pick(PROTOCOL_TYPE_A.filter((p) => !taken.has(p.id)), need));
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rollPenalty = () => pick(PROTOCOL_TYPE_C, 1)[0];
|
||||||
|
|
||||||
|
/** 아직 후보에 오르지 않은 규약들 — 내부 명분의 "강제 상정" 대상. 이득 규약(D)이 여기 포함되는 것이
|
||||||
|
* 내부 명분의 존재 이유다(§7.3: "원하는 이득 규약을 올릴 때 사용"). */
|
||||||
|
export const availableForTabling = (candidates) => {
|
||||||
|
const onBoard = new Set(candidates.map((c) => c.id));
|
||||||
|
return TABLEABLE_PROTOCOLS.filter((p) => !onBoard.has(p.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── §9.2 13석 표결 ── */
|
||||||
|
|
||||||
|
export const COUNCIL_SEATS = 13;
|
||||||
|
|
||||||
|
// 같은 후보 조합이면 항상 같은 표가 나오도록 하는 결정적 해시. 명분으로 안건을 만질 때마다
|
||||||
|
// 표가 무작위로 흔들리면 플레이어가 개입 결과를 판단할 수 없다.
|
||||||
|
const hashStr = (s) => {
|
||||||
|
let h = 2166136261;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
h ^= s.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 16777619);
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 후보 목록에 13석을 배분한다 (최대잔여법). 결정적이므로 같은 조합 = 같은 표.
|
||||||
|
* @returns {Array<{...candidate, votes:number}>}
|
||||||
|
*/
|
||||||
|
export function tallyVotes(candidates) {
|
||||||
|
if (candidates.length === 0) return [];
|
||||||
|
const key = candidates.map((c) => c.id).join('|');
|
||||||
|
const weights = candidates.map((c) => 1 + (hashStr(`${key}#${c.id}`) % 100) / 100);
|
||||||
|
const total = weights.reduce((a, b) => a + b, 0);
|
||||||
|
const raw = weights.map((w) => (w / total) * COUNCIL_SEATS);
|
||||||
|
const seats = raw.map(Math.floor);
|
||||||
|
const remainder = COUNCIL_SEATS - seats.reduce((a, b) => a + b, 0);
|
||||||
|
const byFrac = raw
|
||||||
|
.map((r, i) => ({ i, frac: r - Math.floor(r) }))
|
||||||
|
.sort((a, b) => b.frac - a.frac);
|
||||||
|
for (let k = 0; k < remainder; k++) seats[byFrac[k % byFrac.length].i]++;
|
||||||
|
return candidates.map((c, i) => ({ ...c, votes: seats[i] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 혼합 명분 — §7.3 "표결 결과 1표 뒤집기". 지정 후보에 1표를 더하고 선두에서 1표를 뺀다.
|
||||||
|
* 1표 차 승부가 아니면 결과가 바뀌지 않는다 — 그게 이 명분의 한계이자 판단 지점이다.
|
||||||
|
*
|
||||||
|
* baseVotes(스윙 전 득표)를 남기는 이유: +1/−1은 표 차를 2 좁히므로 2표 차가 동점이 된다.
|
||||||
|
* 동점을 상정 순서로 가르면 배열 위치에 따라 2표 차가 뒤집히기도 해서 일관성이 없다.
|
||||||
|
* 그래서 동점은 "원래 표가 많던 쪽"이 이기고, 결과적으로 1표 차에서만 뒤집힌다.
|
||||||
|
*/
|
||||||
|
export function applyVoteSwing(tallied, targetId) {
|
||||||
|
const withBase = tallied.map((c) => ({ ...c, baseVotes: c.votes }));
|
||||||
|
if (!targetId || withBase.length === 0) return withBase;
|
||||||
|
const leader = [...withBase]
|
||||||
|
.sort((a, b) => b.votes - a.votes)
|
||||||
|
.find((c) => c.id !== targetId);
|
||||||
|
if (!leader) return withBase;
|
||||||
|
return withBase.map((c) => {
|
||||||
|
if (c.id === targetId) return { ...c, votes: c.votes + 1 };
|
||||||
|
if (c.id === leader.id) return { ...c, votes: Math.max(0, c.votes - 1) };
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 최다 득표 후보가 통과한다. 동점이면 스윙 전 득표가 많던 쪽, 그것도 같으면 먼저 상정된 쪽. */
|
||||||
|
export function resolveProtocolVote(tallied) {
|
||||||
|
if (!tallied || tallied.length === 0) return null;
|
||||||
|
return tallied.reduce((best, c) => {
|
||||||
|
if (c.votes !== best.votes) return c.votes > best.votes ? c : best;
|
||||||
|
const cBase = c.baseVotes ?? c.votes;
|
||||||
|
const bestBase = best.baseVotes ?? best.votes;
|
||||||
|
return cBase > bestBase ? c : best;
|
||||||
|
}, tallied[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── §7.3 ① 안건 조작 — 명분 4종 + 혼합 ──
|
||||||
|
* ② 정보 획득 · ③ 임원 직접 관리는 13인 위원회의 개별 임원(§9.4~9.6, 4차)이 있어야 대상이 존재한다.
|
||||||
|
* ④ 실적 납입(명분 1개 = 20점)은 CouncilScreen에서 직접 처리한다. */
|
||||||
|
export const JUSTIFICATION_INTERVENTIONS = {
|
||||||
|
절차: {
|
||||||
|
label: '후보 1개 다음 라운드로 이월',
|
||||||
|
target: 'candidate',
|
||||||
|
detail: '지금 표결에서 빼지만 다음 라운드 후보로 다시 올라온다. 당장의 위험만 미루는 것.',
|
||||||
|
},
|
||||||
|
여론: {
|
||||||
|
label: '후보 1개 강제 탈락 + 새 후보로 교체',
|
||||||
|
target: 'candidate',
|
||||||
|
detail: '지정한 후보를 완전히 없애고 빈자리를 새 후보로 채운다. 새 후보가 더 나쁠 수도 있다.',
|
||||||
|
},
|
||||||
|
위기: {
|
||||||
|
label: '누적 징벌 규약 1개 조기 해제',
|
||||||
|
target: 'penalty',
|
||||||
|
detail: '영구 누적되는 징벌 규약(유형 C) 하나를 즉시 없앤다.',
|
||||||
|
},
|
||||||
|
내부: {
|
||||||
|
label: '규약 1개 강제 상정',
|
||||||
|
target: 'pool',
|
||||||
|
detail: '후보에 없던 규약을 안건에 올린다. 이득 규약을 올려 통과를 노리는 용도.',
|
||||||
|
},
|
||||||
|
혼합: {
|
||||||
|
label: '표결 1표 이동 (지정 후보 +1, 선두 −1)',
|
||||||
|
target: 'candidate',
|
||||||
|
detail: '1표 차 승부일 때만 결과가 뒤집힌다. 표 차가 2 이상이면 소용없다.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 명분 종류별 출처(어느 축을 합본해야 나오는지)와 용도 — 툴팁용 (§7.1~7.3).
|
||||||
|
const JUSTIFICATION_SOURCE = {
|
||||||
|
절차: '대외 축(도장)만으로 합본할 때 산출',
|
||||||
|
여론: '공보 축(확성기)만으로 합본할 때 산출',
|
||||||
|
위기: '위협 축(소용돌이)만으로 합본할 때 산출',
|
||||||
|
내부: '인사 축(손)만으로 합본할 때 산출',
|
||||||
|
혼합: '축이 섞인 합본에서 산출',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @returns {{ summary, detail }} 명분 1종의 출처·용도 설명. */
|
||||||
|
export function justificationDetail(type) {
|
||||||
|
const iv = JUSTIFICATION_INTERVENTIONS[type];
|
||||||
|
return {
|
||||||
|
summary: iv ? iv.label : '용도 없음',
|
||||||
|
detail: [
|
||||||
|
JUSTIFICATION_SOURCE[type] || '',
|
||||||
|
iv ? iv.detail : '',
|
||||||
|
'실적 납입으로 돌리면 1개당 실적 20점. 쓰지 않은 명분은 이번 의회가 끝나면 소멸한다.',
|
||||||
|
'1개 미만(예: 0.5)은 사용할 수 없다 — 같은 종류를 더 모아야 한다.',
|
||||||
|
].filter(Boolean).join(' · '),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 명분 개입을 규약 후보에 반영한다 (§7.3 ①).
|
||||||
|
* interventions: [{ type, targetCandidateId?, targetPenaltyId?, targetPoolProtocolId? }]
|
||||||
|
* @returns {{ candidates, voteSwingTargetId, deferred, penaltyIdToClear }}
|
||||||
|
*/
|
||||||
|
export function applyInterventions(candidates, interventions) {
|
||||||
|
let pool = [...candidates];
|
||||||
|
let voteSwingTargetId = null;
|
||||||
|
const deferred = [];
|
||||||
|
let penaltyIdToClear = null;
|
||||||
|
|
||||||
|
interventions.forEach((iv) => {
|
||||||
|
if (iv.type === '절차' && iv.targetCandidateId) {
|
||||||
|
const target = pool.find((c) => c.id === iv.targetCandidateId);
|
||||||
|
if (target) {
|
||||||
|
deferred.push(target);
|
||||||
|
pool = pool.filter((c) => c.id !== iv.targetCandidateId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (iv.type === '여론' && iv.targetCandidateId) {
|
||||||
|
pool = pool.filter((c) => c.id !== iv.targetCandidateId);
|
||||||
|
// 교체 후보는 개입을 지정하는 시점(UI)에서 미리 뽑아 replacementId로 실어 보낸다.
|
||||||
|
// 여기서 무작위로 뽑으면 의회 화면의 표 미리보기와 확정 결과가 어긋난다.
|
||||||
|
const replacement = iv.replacementId
|
||||||
|
? TABLEABLE_PROTOCOLS.find((p) => p.id === iv.replacementId)
|
||||||
|
: pick(availableForTabling(pool), 1)[0];
|
||||||
|
if (replacement && !pool.some((c) => c.id === replacement.id)) pool = [...pool, replacement];
|
||||||
|
}
|
||||||
|
if (iv.type === '내부' && iv.targetPoolProtocolId) {
|
||||||
|
const tabled = TABLEABLE_PROTOCOLS.find((p) => p.id === iv.targetPoolProtocolId);
|
||||||
|
if (tabled && !pool.some((c) => c.id === tabled.id)) pool = [...pool, tabled];
|
||||||
|
}
|
||||||
|
if (iv.type === '위기' && iv.targetPenaltyId) penaltyIdToClear = iv.targetPenaltyId;
|
||||||
|
if (iv.type === '혼합' && iv.targetCandidateId) voteSwingTargetId = iv.targetCandidateId;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { candidates: pool, voteSwingTargetId, deferred, penaltyIdToClear };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 규약 효과 설명 (툴팁용) ──
|
||||||
|
* "무엇이 막혔는지 정확히" 알려주려면 족보 이름·조건까지 짚어야 한다. UI가 문구를 자체
|
||||||
|
* 추측하지 않도록 카탈로그 옆에서 같이 생성한다. */
|
||||||
|
|
||||||
|
const TYPE_LABEL = { A: '족보 제한', C: '징벌', D: '이득' };
|
||||||
|
|
||||||
|
/** @returns {{ typeLabel, summary, detail, tone }} tone: 'bad' | 'good' | 'neutral' */
|
||||||
|
export function protocolEffectDetail(protocol) {
|
||||||
|
const e = protocol.effect;
|
||||||
|
const typeLabel = TYPE_LABEL[protocol.type] || '규약';
|
||||||
|
|
||||||
|
switch (e.kind) {
|
||||||
|
case 'disable_hand': {
|
||||||
|
const def = HAND_DEFS[e.patternId];
|
||||||
|
return {
|
||||||
|
typeLabel,
|
||||||
|
summary: `「${def.name}」 족보 사용 불가`,
|
||||||
|
detail: `조건 「${def.condition}」. 이 족보는 합본 판정에서 제외되어, 해당 조합을 골라도 성립하지 않습니다 (실적 ${def.score} · 명분 ${def.baseJustification} 손실).`,
|
||||||
|
tone: 'bad',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'min_grade':
|
||||||
|
return {
|
||||||
|
typeLabel,
|
||||||
|
summary: `등급 ${e.minGrade} 미만 카드 합본 불가`,
|
||||||
|
detail: `선택한 카드 중 등급 ${e.minGrade} 미만이 하나라도 있으면 어떤 족보도 성립하지 않습니다. 저등급 카드는 상점에서 직접 처리해야 합니다.`,
|
||||||
|
tone: 'bad',
|
||||||
|
};
|
||||||
|
case 'gauge_surcharge':
|
||||||
|
return {
|
||||||
|
typeLabel,
|
||||||
|
summary: `결재당 게이지 ${e.amount > 0 ? '+' : ''}${e.amount.toFixed(1)}%p`,
|
||||||
|
detail: e.amount > 0
|
||||||
|
? `결재 1건마다 4대 게이지 전부가 추가로 ${e.amount.toFixed(1)}%p씩 더 오릅니다. 주목도와 무관하게 항상 붙습니다.`
|
||||||
|
: `결재 1건마다 4대 게이지 상승분이 ${Math.abs(e.amount).toFixed(1)}%p씩 줄어듭니다.`,
|
||||||
|
tone: e.amount > 0 ? 'bad' : 'good',
|
||||||
|
};
|
||||||
|
case 'coin_surcharge':
|
||||||
|
return {
|
||||||
|
typeLabel,
|
||||||
|
summary: `코인 지출 가산 ${e.amount > 0 ? '+' : ''}${e.amount}`,
|
||||||
|
detail: e.amount > 0
|
||||||
|
? `상점 소각·리롤 등 모든 코인 지출에 ${e.amount}이(가) 추가로 붙습니다.`
|
||||||
|
: `모든 코인 지출 가산이 ${Math.abs(e.amount)} 줄어듭니다 (최소 0).`,
|
||||||
|
tone: e.amount > 0 ? 'bad' : 'good',
|
||||||
|
};
|
||||||
|
case 'grant_coin':
|
||||||
|
return {
|
||||||
|
typeLabel,
|
||||||
|
summary: `통과 즉시 코인 +${e.amount}`,
|
||||||
|
detail: `표결 통과 시점에 코인 ${e.amount}을(를) 한 번 지급합니다. 지속 효과는 없습니다.`,
|
||||||
|
tone: 'good',
|
||||||
|
};
|
||||||
|
case 'attention_cap_bonus':
|
||||||
|
return {
|
||||||
|
typeLabel,
|
||||||
|
summary: `주목도 한도 +${e.amount}`,
|
||||||
|
detail: `다음 라운드의 주목도 한도가 ${e.amount} 늘어납니다. 버스트 전까지 카드를 더 받을 수 있습니다.`,
|
||||||
|
tone: 'good',
|
||||||
|
};
|
||||||
|
case 'required_score_delta':
|
||||||
|
return {
|
||||||
|
typeLabel,
|
||||||
|
summary: `실적 요구치 ${e.amount > 0 ? '+' : ''}${e.amount}`,
|
||||||
|
detail: `다음 의회의 실적 요구치가 ${Math.abs(e.amount)} ${e.amount < 0 ? '낮아집니다' : '높아집니다'}.`,
|
||||||
|
tone: e.amount < 0 ? 'good' : 'bad',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return { typeLabel, summary: '효과 미정', detail: '', tone: 'neutral' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 활성 규약 효과 합산 ──
|
||||||
|
* 징벌(C)과 활성 규약(A/D)이 같은 종류의 수치를 건드리므로, 호출부에서 둘을 합친 배열을 넘긴다. */
|
||||||
|
|
||||||
|
/** 결재당 게이지 상승에 더해지는 가산분 합계 (이득 규약이면 음수). */
|
||||||
|
export function gaugeSurchargeOf(protocols) {
|
||||||
|
return protocols.reduce((sum, p) => sum + (p.effect.kind === 'gauge_surcharge' ? p.effect.amount : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 모든 코인 지출에 더해지는 가산분 합계 (이득 규약이면 음수). */
|
||||||
|
export function coinSurchargeOf(protocols) {
|
||||||
|
return protocols.reduce((sum, p) => sum + (p.effect.kind === 'coin_surcharge' ? p.effect.amount : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이번 라운드 주목도 한도에 더해지는 보너스. */
|
||||||
|
export function attentionCapBonusOf(protocols) {
|
||||||
|
return protocols.reduce((sum, p) => sum + (p.effect.kind === 'attention_cap_bonus' ? p.effect.amount : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 실적 요구치에 더해지는 증감분. */
|
||||||
|
export function requiredScoreDeltaOf(protocols) {
|
||||||
|
return protocols.reduce((sum, p) => sum + (p.effect.kind === 'required_score_delta' ? p.effect.amount : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 통과 즉시 1회 지급되는 코인. */
|
||||||
|
export function grantCoinOf(protocol) {
|
||||||
|
return protocol && protocol.effect.kind === 'grant_coin' ? protocol.effect.amount : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 합본 판정에 규약 효과를 얹는다 ── */
|
||||||
|
|
||||||
|
/** 활성 규약을 반영해 detectHands 결과를 거른다. */
|
||||||
|
export function filterHandsByProtocols(matches, selection, activeProtocols) {
|
||||||
|
let allowed = matches;
|
||||||
|
activeProtocols.forEach((p) => {
|
||||||
|
if (p.effect.kind === 'disable_hand') {
|
||||||
|
allowed = allowed.filter((id) => id !== p.effect.patternId);
|
||||||
|
}
|
||||||
|
if (p.effect.kind === 'min_grade') {
|
||||||
|
const violatesMinGrade = selection.some((c) => c.grade < p.effect.minGrade);
|
||||||
|
if (violatesMinGrade) allowed = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return allowed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// 책상(Desk) 엔진 — ProjectSS_코어시스템_기획서.md §4.
|
||||||
|
// 오염 카드는 덱에 들어가지 않는다. 드로우·셔플 개념 없음, 슬롯 무제한. 여기서 다루는 desk는
|
||||||
|
// 순수 배열 [{ id, axis, grade, name, flavor, tag }]이며, React state는 이 배열을 그대로 들고
|
||||||
|
// 있다가 이 모듈의 함수로 파생값(주목도, 페널티)을 계산한다. React/훅 의존 없음 — 순수 함수만.
|
||||||
|
//
|
||||||
|
// 2026-09-15 결정 반영: 페널티 기준을 "카드 장수" → "등급 합계"로 전환하고 이를 주목도라 부른다.
|
||||||
|
// 등급 편차가 1~10으로 커진 만큼, 장수만 세면 특급 기밀과 일반 문서가 똑같이 취급돼 압박이
|
||||||
|
// 정직하지 않다. 등급 자체가 곧 가중치이므로 별도 가중치 테이블은 없다.
|
||||||
|
//
|
||||||
|
// 이월(§4.1): 합본에 사용되지 않은 카드는 소멸하지 않고 책상에 남는다. 별도 이월 시스템이
|
||||||
|
// 없는 이유가 바로 이것 — "합본이 닿은 카드만 사라진다"는 규칙 하나로 자연히 성립한다.
|
||||||
|
|
||||||
|
import { AXES, AXIS_META, clampGrade } from './symbols.js';
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
// meta: pollutionPool.js에서 뽑은 { name, flavor, tag } — 책상 카드가 실제 이름/사연을 갖게 한다.
|
||||||
|
// round: 이 카드를 받은 라운드 번호(기록용 — 한도 판정에는 더 이상 쓰이지 않는다. 아래 참고).
|
||||||
|
export const makeDeskCard = (axis, grade, meta = {}, round = 1) => ({
|
||||||
|
id: `desk-${Date.now().toString(36)}-${(seq++).toString(36)}`,
|
||||||
|
axis,
|
||||||
|
grade: clampGrade(grade),
|
||||||
|
round,
|
||||||
|
name: meta.name || null,
|
||||||
|
flavor: meta.flavor || null,
|
||||||
|
tag: meta.tag || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 축별 적재 장수. { axis_threat: 3, axis_hr: 1, ... } 형태로 항상 4축 전부를 채워 반환한다. */
|
||||||
|
export function countByAxis(deskCards) {
|
||||||
|
const out = Object.fromEntries(AXES.map((a) => [a, 0]));
|
||||||
|
deskCards.forEach((c) => { out[c.axis] = (out[c.axis] || 0) + 1; });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 축별 등급 분포. { axis_threat: {7: 2, 3: 1}, ... } — DeskBar의 축×등급 표시용. */
|
||||||
|
export function gradeBreakdownByAxis(deskCards) {
|
||||||
|
const out = Object.fromEntries(AXES.map((a) => [a, {}]));
|
||||||
|
deskCards.forEach((c) => {
|
||||||
|
out[c.axis][c.grade] = (out[c.axis][c.grade] || 0) + 1;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const totalCount = (deskCards) => deskCards.length;
|
||||||
|
|
||||||
|
/* ── 주목도 (§4.2) — 페널티 산정의 단일 기준 ── */
|
||||||
|
|
||||||
|
/** 해당 축의 주목도 = 그 축 카드들의 등급 합. */
|
||||||
|
export function axisAttention(deskCards, axis) {
|
||||||
|
return deskCards
|
||||||
|
.filter((c) => c.axis === axis)
|
||||||
|
.reduce((sum, c) => sum + c.grade, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 책상 전체 주목도 = 모든 카드의 등급 합. 이월 카드까지 포함하며, 패시브 페널티
|
||||||
|
* (게이지 상승 A · 코인 가산 C)의 기준이다 — 쌓아둔 미결은 계속 대가를 치른다. */
|
||||||
|
export function totalAttention(deskCards) {
|
||||||
|
return deskCards.reduce((sum, c) => sum + c.grade, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attentionByAxis(deskCards) {
|
||||||
|
const out = {};
|
||||||
|
AXES.forEach((axis) => { out[axis] = axisAttention(deskCards, axis); });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── A. 게이지 상시 상승 (§4.2-A) — 축별 카드 "장수" 기준 급누진 + 등급 배율.
|
||||||
|
*
|
||||||
|
* 2026-09-17 재설계: 등급 합계(attention)만으로는 상승이 너무 느렸다 — 이 게임 카드의 등급이
|
||||||
|
* 대부분 1~4에 몰려 있어서(§17 카드 데이터 현황), 같은 축 카드 서너 장을 쌓아도 attention이
|
||||||
|
* 낮은 구간에 계속 머물러 위협이 전혀 체감되지 않았다(플레이 피드백). 그래서 1차 기준을
|
||||||
|
* "쌓인 장수"로 되돌리되, 등급이 여전히 의미를 갖도록 평균 등급 배율을 곱한다 —
|
||||||
|
* 저등급 카드만 쌓이면 완만하고, 고등급이 섞이면 같은 장수에서도 훨씬 아프다.
|
||||||
|
* 피드백 원문: "누적 카드 3장 이상일 때부터 증가값이 엄청 커졌으면 함". */
|
||||||
|
|
||||||
|
const GAUGE_COUNT_TIERS = [
|
||||||
|
{ max: 2, base: 0.5 }, // 1~2장: 아직 완만
|
||||||
|
{ max: 4, base: 3.0 }, // 3~4장: 여기서 확 뛴다 (피드백 핵심 요구)
|
||||||
|
{ max: 6, base: 6.0 },
|
||||||
|
{ max: 9, base: 10.0 },
|
||||||
|
];
|
||||||
|
const GAUGE_COUNT_OVERRUN_FROM = 10;
|
||||||
|
const GAUGE_COUNT_OVERRUN_BASE = 15.0;
|
||||||
|
const GAUGE_COUNT_OVERRUN_STEP = 2.0; // 10장부터 1장마다 가산
|
||||||
|
|
||||||
|
const GAUGE_GRADE_BASELINE = 5; // 이 등급이 "평균" — 배율 1.0 기준점 (등급 범위 1~10의 중앙)
|
||||||
|
|
||||||
|
/** 축에 쌓인 장수 기준 결재 1건당 게이지 상승 기본값(%). */
|
||||||
|
function gaugeCountBase(count) {
|
||||||
|
const tier = GAUGE_COUNT_TIERS.find((t) => count <= t.max);
|
||||||
|
if (tier) return tier.base;
|
||||||
|
return GAUGE_COUNT_OVERRUN_BASE + (count - GAUGE_COUNT_OVERRUN_FROM) * GAUGE_COUNT_OVERRUN_STEP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 축 카드 수·등급 합계로 결재 1건당 게이지 상승률(%)을 낸다.
|
||||||
|
* base(장수 구간) × (평균 등급 / 5) — 고등급이 섞이면 같은 장수에서도 더 아프다. */
|
||||||
|
export function gaugeRiseRate(count, attention) {
|
||||||
|
if (count <= 0) return 0;
|
||||||
|
const base = gaugeCountBase(count);
|
||||||
|
const avgGrade = attention / count;
|
||||||
|
return base * (avgGrade / GAUGE_GRADE_BASELINE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 축별 현재 상승률(%) — DeskBar의 "▲+X%/결재" 표시에 그대로 쓴다.
|
||||||
|
* extraFlat: 활성 징벌 규약(§9.2 유형C)이 얹는 가산분(council.js의 penaltyGaugeSurcharge). */
|
||||||
|
export function gaugeRiseRateByAxis(deskCards, extraFlat = 0) {
|
||||||
|
const counts = countByAxis(deskCards);
|
||||||
|
const out = {};
|
||||||
|
// extraFlat(징벌 규약 §9.2 유형C)은 "모든 축의 게이지 상승"이라 카드 유무와 무관하게 항상 더한다.
|
||||||
|
AXES.forEach((axis) => {
|
||||||
|
out[axis] = gaugeRiseRate(counts[axis], axisAttention(deskCards, axis)) + extraFlat;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 매 결재마다 4개 게이지 전부에 축별 상승분을 더한다. gauges = { entropy, resistance, panic, risk } (0~100). */
|
||||||
|
export function applyPassiveGaugeRise(gauges, deskCards, extraFlat = 0) {
|
||||||
|
const counts = countByAxis(deskCards);
|
||||||
|
const next = { ...gauges };
|
||||||
|
AXES.forEach((axis) => {
|
||||||
|
const gaugeKey = AXIS_META[axis].gauge;
|
||||||
|
const rate = gaugeRiseRate(counts[axis], axisAttention(deskCards, axis)) + extraFlat;
|
||||||
|
next[gaugeKey] = Math.min(100, (next[gaugeKey] || 0) + rate);
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── B. 라운드 종료 판단 (§4.2-B, 2026-09-16 결정 재수정) — 블랙잭식 하드 캡.
|
||||||
|
* 라운드당 주목도 한도를 넘기면 그 즉시 라운드가 강제 종료된다(합본 기회 없이, 카드는 이월).
|
||||||
|
* 한도 자체가 라운드가 갈수록 올라가 후반에는 더 크게 쌓아도 되는 대신 압박도 커진다.
|
||||||
|
* 한도 밑에서는 플레이어가 아무 때나 자율적으로 라운드를 끝내고 정산으로 넘어갈 수 있다 —
|
||||||
|
* "한 장 더 받을까 말까"의 긴장은 여기서 나온다. 결정 노트: 2026-09-16-라운드-주목도-하드캡-복원.
|
||||||
|
*
|
||||||
|
* 2026-09-18 결정 (누적 카운터로 전환, 결정 노트: 2026-09-18-라운드-접수량-누적카운터):
|
||||||
|
* 이번 라운드 접수량을 "책상에서 이번 라운드 카드를 필터링해 합산"하는 파생값으로 뒀더니,
|
||||||
|
* 합본으로 그 카드들이 책상에서 사라지는 순간 접수량도 같이 줄었다. 블랙잭은 낸 카드를
|
||||||
|
* 버려서 핸드 합계를 낮출 수 없는데, 이 파생 방식은 그걸 가능하게 해서 "합본을 반복하면
|
||||||
|
* 라운드가 끝나지 않는" 문제를 낳았다(피드백: "합본을 많이 하면 라운드가 너무 길어진다").
|
||||||
|
* 그래서 접수량은 더 이상 desk.js가 deskCards에서 계산하지 않는다 — CoreGameScreen이
|
||||||
|
* 카드를 받을 때마다 등급만큼 더하는 **순수 누적 카운터**(라운드가 바뀔 때만 0으로 리셋)로
|
||||||
|
* 관리하고, 이 모듈의 함수들은 그 값을 인자로 받기만 한다. */
|
||||||
|
|
||||||
|
const ROUND_CAP_BASE = 21; // 가안 — 최초 블랙잭 기준으로 복귀
|
||||||
|
const ROUND_CAP_GROWTH = 5; // 라운드당 가산분, 가안
|
||||||
|
|
||||||
|
/** 라운드 번호에 따른 이번 라운드의 주목도 한도. bonus: 이득 규약의 한도 보너스(§9.2 유형 D). */
|
||||||
|
export function roundAttentionCap(round, bonus = 0) {
|
||||||
|
return ROUND_CAP_BASE + (round - 1) * ROUND_CAP_GROWTH + bonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 한도 대비 이번 라운드 접수량 구간: 'normal' | 'warning'(한도 5 이내로 근접) | 'bust'(한도 초과).
|
||||||
|
* intake: CoreGameScreen이 들고 있는 누적 카운터 값 — 합본해도 줄지 않는다. */
|
||||||
|
export function attentionZone(intake, cap) {
|
||||||
|
if (intake > cap) return 'bust';
|
||||||
|
if (intake >= cap - 5) return 'warning';
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── C. 코인 지출 가산 (§4.2-C) — 총 주목도 기준 ── */
|
||||||
|
|
||||||
|
const SURCHARGE_TIERS = [
|
||||||
|
{ max: 20, surcharge: 0 },
|
||||||
|
{ max: 40, surcharge: 1 },
|
||||||
|
{ max: 60, surcharge: 2 },
|
||||||
|
{ max: Infinity, surcharge: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 총 주목도 기준 모든 코인 지출에 붙는 가산액.
|
||||||
|
* extraFlat: 활성 규약이 얹는 가산분(council.js의 coinSurchargeOf) — 이득 규약이면 음수라
|
||||||
|
* 0 밑으로 내려갈 수 있으므로, 지출이 되레 할인되지 않도록 0에서 끊는다. */
|
||||||
|
export function coinSurcharge(deskCards, extraFlat = 0) {
|
||||||
|
const total = totalAttention(deskCards);
|
||||||
|
const tier = SURCHARGE_TIERS.find((t) => total <= t.max);
|
||||||
|
return Math.max(0, (tier ? tier.surcharge : 4) + extraFlat);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 책상에서 특정 카드들을 제거한 새 배열을 반환한다 (합본 실행 시 사용). */
|
||||||
|
export function removeFromDesk(deskCards, idsToRemove) {
|
||||||
|
const idSet = new Set(idsToRemove);
|
||||||
|
return deskCards.filter((c) => !idSet.has(c.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addToDesk(deskCards, newCards) {
|
||||||
|
return [...deskCards, ...newCards];
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// 합본(족보) 판정 · 실행 엔진 — ProjectSS_코어시스템_기획서.md §5, §7.1, §8.2.
|
||||||
|
// 트럼프 족보 구조를 그대로 차용한다 (§5.1 설계 원칙). React/훅 의존 없는 순수 함수.
|
||||||
|
//
|
||||||
|
// 2026-09-15 결정 반영 (3장 4등급 → 5장 10등급):
|
||||||
|
// - 족보를 5장 포커 핸드 8종으로 재정의. 투페어(이중 접수) 신설.
|
||||||
|
// - §5.1 "여러 장 쌓은 카드 중 최고의 5장을 뽑는 방식"에 맞춰, 판정 방식을 바꿨다.
|
||||||
|
// 구 구현은 "선택한 카드가 정확히 족보 크기와 일치해야 성립"하는 엄격 매칭이었지만,
|
||||||
|
// 이제는 선택 집합 안에서 족보를 이루는 카드를 찾아낸다. 그래서 5장을 골라도 페어만
|
||||||
|
// 성립하면 페어로 상신할 수 있고, 그 경우 족보에 실제로 쓰인 2장만 소멸한다
|
||||||
|
// (나머지는 §4.1 이월 원칙에 따라 책상에 남는다).
|
||||||
|
// - 코인은 1종이고 산출량은 등급 합계다 (§8.2). 축은 명분 종류만 결정한다 (§7.2).
|
||||||
|
// - 명분 +1 보너스 기준이 "등급 4" → "등급 8 이상"으로 바뀌었다 (§7.1).
|
||||||
|
|
||||||
|
import { justificationOf, TOP_SECRET_THRESHOLD } from './symbols.js';
|
||||||
|
|
||||||
|
export const MIN_COMBINE_CARDS = 2;
|
||||||
|
export const MAX_COMBINE_CARDS = 5;
|
||||||
|
|
||||||
|
export const HAND_DEFS = {
|
||||||
|
pair: { id: 'pair', name: '중복 접수', cards: 2, baseJustification: 0.5, score: 10, condition: '같은 등급 2장' },
|
||||||
|
two_pair: { id: 'two_pair', name: '이중 접수', cards: 4, baseJustification: 1, score: 20, condition: '같은 등급 2장 × 2쌍' },
|
||||||
|
triple: { id: 'triple', name: '집단 민원', cards: 3, baseJustification: 1, score: 25, condition: '같은 등급 3장' },
|
||||||
|
straight: { id: 'straight', name: '단계적 상신', cards: 5, baseJustification: 2, score: 40, condition: '연속 등급 5장 (축 무관)' },
|
||||||
|
flush_axis: { id: 'flush_axis', name: '일괄 처리', cards: 5, baseJustification: 2, score: 40, condition: '같은 축 5장' },
|
||||||
|
full_house: { id: 'full_house', name: '종합 감사', cards: 5, baseJustification: 3, score: 60, condition: '같은 등급 3장 + 같은 등급 2장' },
|
||||||
|
four_kind: { id: 'four_kind', name: '전면 개편', cards: 4, baseJustification: 4, score: 90, condition: '같은 등급 4장' },
|
||||||
|
straight_flush: { id: 'straight_flush', name: '전결 처리', cards: 5, baseJustification: 5, score: 150, condition: '같은 축 + 연속 등급 5장' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 족보 우선순위(뒤로 갈수록 상위). 실적 점수 순이며, straight와 flush_axis가 동점(40)일 때는
|
||||||
|
// 트럼프 관례대로 플러시를 상위로 둔다.
|
||||||
|
const HAND_RANK = [
|
||||||
|
'pair', 'two_pair', 'triple', 'straight', 'flush_axis', 'full_house', 'four_kind', 'straight_flush',
|
||||||
|
];
|
||||||
|
|
||||||
|
const sumGrades = (cards) => cards.reduce((s, c) => s + c.grade, 0);
|
||||||
|
|
||||||
|
/** 등급 → 그 등급의 카드 배열. */
|
||||||
|
function groupByGrade(cards) {
|
||||||
|
const m = new Map();
|
||||||
|
cards.forEach((c) => {
|
||||||
|
if (!m.has(c.grade)) m.set(c.grade, []);
|
||||||
|
m.get(c.grade).push(c);
|
||||||
|
});
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 축 → 그 축의 카드 배열. */
|
||||||
|
function groupByAxis(cards) {
|
||||||
|
const m = new Map();
|
||||||
|
cards.forEach((c) => {
|
||||||
|
if (!m.has(c.axis)) m.set(c.axis, []);
|
||||||
|
m.get(c.axis).push(c);
|
||||||
|
});
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 등급이 n장 이상 모인 등급들을 내림차순으로 (등급 합을 최대화하려면 높은 등급이 유리). */
|
||||||
|
function gradesWithAtLeast(byGrade, n) {
|
||||||
|
return [...byGrade.entries()]
|
||||||
|
.filter(([, cards]) => cards.length >= n)
|
||||||
|
.map(([grade]) => grade)
|
||||||
|
.sort((a, b) => b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 연속 등급 5장을 찾는다. 중복 등급은 한 장만 쓴다. 등급 합이 가장 큰 구간을 고른다. */
|
||||||
|
function findRun(cards, length = 5) {
|
||||||
|
const byGrade = groupByGrade(cards);
|
||||||
|
const grades = [...byGrade.keys()].sort((a, b) => a - b);
|
||||||
|
let best = null;
|
||||||
|
for (let i = 0; i + length - 1 < grades.length; i++) {
|
||||||
|
const window = grades.slice(i, i + length);
|
||||||
|
const isConsecutive = window.every((g, k) => k === 0 || g === window[k - 1] + 1);
|
||||||
|
if (!isConsecutive) continue;
|
||||||
|
// 각 등급에서 한 장씩 (같은 등급이 여러 장이면 아무거나 — 등급이 같으므로 합도 같다)
|
||||||
|
const picked = window.map((g) => byGrade.get(g)[0]);
|
||||||
|
if (!best || sumGrades(picked) > sumGrades(best)) best = picked;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선택 집합에서 지정 족보를 이루는 카드들을 찾는다. 성립하지 않으면 null.
|
||||||
|
* 여러 조합이 가능하면 등급 합이 큰 쪽을 고른다 — 코인 산출이 등급 합계이므로 그게 이득이다.
|
||||||
|
*/
|
||||||
|
export function findHandCards(selection, patternId) {
|
||||||
|
const byGrade = groupByGrade(selection);
|
||||||
|
|
||||||
|
switch (patternId) {
|
||||||
|
case 'pair': {
|
||||||
|
const g = gradesWithAtLeast(byGrade, 2)[0];
|
||||||
|
return g === undefined ? null : byGrade.get(g).slice(0, 2);
|
||||||
|
}
|
||||||
|
case 'triple': {
|
||||||
|
const g = gradesWithAtLeast(byGrade, 3)[0];
|
||||||
|
return g === undefined ? null : byGrade.get(g).slice(0, 3);
|
||||||
|
}
|
||||||
|
case 'four_kind': {
|
||||||
|
const g = gradesWithAtLeast(byGrade, 4)[0];
|
||||||
|
return g === undefined ? null : byGrade.get(g).slice(0, 4);
|
||||||
|
}
|
||||||
|
case 'two_pair': {
|
||||||
|
const pairs = gradesWithAtLeast(byGrade, 2);
|
||||||
|
if (pairs.length < 2) return null;
|
||||||
|
return [...byGrade.get(pairs[0]).slice(0, 2), ...byGrade.get(pairs[1]).slice(0, 2)];
|
||||||
|
}
|
||||||
|
case 'full_house': {
|
||||||
|
const triples = gradesWithAtLeast(byGrade, 3);
|
||||||
|
const pairs = gradesWithAtLeast(byGrade, 2);
|
||||||
|
let best = null;
|
||||||
|
triples.forEach((tg) => {
|
||||||
|
pairs.filter((pg) => pg !== tg).forEach((pg) => {
|
||||||
|
const picked = [...byGrade.get(tg).slice(0, 3), ...byGrade.get(pg).slice(0, 2)];
|
||||||
|
if (!best || sumGrades(picked) > sumGrades(best)) best = picked;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
case 'flush_axis': {
|
||||||
|
const byAxis = groupByAxis(selection);
|
||||||
|
let best = null;
|
||||||
|
byAxis.forEach((cards) => {
|
||||||
|
if (cards.length < 5) return;
|
||||||
|
const picked = [...cards].sort((a, b) => b.grade - a.grade).slice(0, 5);
|
||||||
|
if (!best || sumGrades(picked) > sumGrades(best)) best = picked;
|
||||||
|
});
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
case 'straight':
|
||||||
|
return findRun(selection, 5);
|
||||||
|
case 'straight_flush': {
|
||||||
|
const byAxis = groupByAxis(selection);
|
||||||
|
let best = null;
|
||||||
|
byAxis.forEach((cards) => {
|
||||||
|
const run = findRun(cards, 5);
|
||||||
|
if (run && (!best || sumGrades(run) > sumGrades(best))) best = run;
|
||||||
|
});
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선택 집합으로 성립하는 족보(들)를 판정한다.
|
||||||
|
* 여러 족보가 동시 성립할 수 있다 (§5.3) — 예: 같은 축 연속 5장은 straight_flush / flush_axis /
|
||||||
|
* straight 셋 다 성립하며, 플레이어가 하나를 고른다(명분 종류를 고르려고 일부러 하위를 쓰는 선택).
|
||||||
|
* @returns {string[]} 성립하는 HAND_DEFS id 배열, 상위 족보 우선 정렬
|
||||||
|
*/
|
||||||
|
export function detectHands(selection) {
|
||||||
|
if (selection.length < MIN_COMBINE_CARDS || selection.length > MAX_COMBINE_CARDS) return [];
|
||||||
|
return HAND_RANK
|
||||||
|
.filter((id) => findHandCards(selection, id) !== null)
|
||||||
|
.sort((a, b) => HAND_RANK.indexOf(b) - HAND_RANK.indexOf(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선택한 족보로 합본을 실행한 결과를 계산한다 (책상에서 실제로 지우는 건 호출부 책임).
|
||||||
|
* removeIds에는 족보를 이룬 카드만 담긴다 — 선택했지만 족보에 쓰이지 않은 카드는 책상에 남는다.
|
||||||
|
*/
|
||||||
|
export function resolveCombine(selection, patternId) {
|
||||||
|
const def = HAND_DEFS[patternId];
|
||||||
|
if (!def) return null;
|
||||||
|
const used = findHandCards(selection, patternId);
|
||||||
|
if (!used) return null;
|
||||||
|
|
||||||
|
const uniformAxis = used.every((c) => c.axis === used[0].axis) ? used[0].axis : null;
|
||||||
|
const justificationType = uniformAxis ? justificationOf(uniformAxis) : '혼합';
|
||||||
|
const hasTopSecret = used.some((c) => c.grade >= TOP_SECRET_THRESHOLD);
|
||||||
|
const justificationAmount = def.baseJustification + (hasTopSecret ? 1 : 0);
|
||||||
|
|
||||||
|
// §8.2: 코인은 1종, 산출량은 합본에 사용된 카드의 기밀 등급 합계.
|
||||||
|
const coins = sumGrades(used);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pattern: def,
|
||||||
|
usedCards: used,
|
||||||
|
justification: { type: justificationType, amount: justificationAmount },
|
||||||
|
coins,
|
||||||
|
performanceScore: def.score,
|
||||||
|
removeIds: used.map((c) => c.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// 오염 카드 풀 — data/pollution_pool.csv를 그대로 읽어 "이 축·등급이면 어떤 이름과
|
||||||
|
// 사연을 가진 오염 카드인가"를 뽑아 준다. 지금까지는 이 CSV가 어디서도 쓰이지 않아서
|
||||||
|
// 책상 카드가 축 라벨만 가진 익명 토큰이었다 — 플레이테스트 피드백(선택의 서사가 안 보임)의
|
||||||
|
// 원인 중 하나였다. React 의존 없는 순수 조회 함수만 둔다.
|
||||||
|
|
||||||
|
import Papa from 'papaparse';
|
||||||
|
import poolCsvRaw from '../../data/pollution_pool.csv?raw';
|
||||||
|
|
||||||
|
const POOL = Papa.parse(poolCsvRaw, { header: true, skipEmptyLines: true }).data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주어진 축·등급에 맞는 오염 카드 항목을 하나 뽑는다.
|
||||||
|
* 등급이 1~10으로 확장된 뒤 pollution_pool.csv는 축당 몇 개 등급대만 갖고 있으므로,
|
||||||
|
* 정확히 일치하는 등급이 없으면 **가장 가까운 등급**의 플레이버를 쓴다. 아무거나 폴백하면
|
||||||
|
* 등급 10 사안에 "초과근무 항의" 같은 저강도 서술이 붙어 심각도와 텍스트가 어긋난다.
|
||||||
|
* (등급대별 신규 오염 카드 텍스트는 CLAUDE.md "데이터 파일 다루는 법"에 따라 소량 배치로 채운다.)
|
||||||
|
*/
|
||||||
|
export function pickPollutionEntry(axis, grade) {
|
||||||
|
const sameAxis = POOL.filter((r) => r.axis === axis);
|
||||||
|
if (sameAxis.length === 0) return null;
|
||||||
|
|
||||||
|
const target = Number(grade);
|
||||||
|
let bestDistance = Infinity;
|
||||||
|
sameAxis.forEach((r) => {
|
||||||
|
const d = Math.abs(Number(r.grade) - target);
|
||||||
|
if (d < bestDistance) bestDistance = d;
|
||||||
|
});
|
||||||
|
const nearest = sameAxis.filter((r) => Math.abs(Number(r.grade) - target) === bestDistance);
|
||||||
|
|
||||||
|
const row = nearest[Math.floor(Math.random() * nearest.length)];
|
||||||
|
return { name: row.name, flavor: row.flavor, tag: row.tag || null };
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// 상점 엔진 — ProjectSS_코어시스템_기획서.md §8.3.
|
||||||
|
// 자산 구매·소모품·전례 매입은 각각 §10·§12·§11(3차 범위)이 있어야 성립하는데 아직 없다.
|
||||||
|
// 그래서 2차에서 성립하는 품목은 "오염 카드 직접 처리"(§8.3 표: 족보가 안 맞을 때의 탈출구,
|
||||||
|
// 명분·실적 없음) 하나뿐이다 — 이 자체가 §17 로드맵 순서를 지키기 위한 의도적 축소다.
|
||||||
|
// 진열 규칙(무작위 4~6개, 1회 리롤 가능·코인 소모)만 그대로 §8.3을 따른다.
|
||||||
|
//
|
||||||
|
// 2026-09-15 결정 반영: 코인 1종 통합(§8.2, §19). 가격은 단일 숫자다.
|
||||||
|
// 등급 1~10 확장에 맞춰 처리 비용도 등급에 비례한다 — 합본하면 등급 합계만큼 코인을 벌지만
|
||||||
|
// 직접 처리하면 그만큼 잃으므로, 탈출구를 쓰는 대가가 등급에 정직하게 비례한다.
|
||||||
|
|
||||||
|
export const SHOP_DISPLAY_MIN = 4;
|
||||||
|
export const SHOP_DISPLAY_MAX = 6;
|
||||||
|
export const REROLL_COST = 3;
|
||||||
|
|
||||||
|
/** 오염 카드 1장을 직접 처리(소각)하는 가격 — 등급만큼, 책상이 무거우면(지출 가산) 더 비싸다. */
|
||||||
|
export function purgePrice(card, currentSurcharge = 0) {
|
||||||
|
return card.grade + currentSurcharge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 책상 카드 중 무작위로 4~6장(또는 그 미만이면 전부)을 진열한다. */
|
||||||
|
export function rollShopDisplay(deskCards) {
|
||||||
|
const span = SHOP_DISPLAY_MAX - SHOP_DISPLAY_MIN + 1;
|
||||||
|
const n = Math.min(deskCards.length, SHOP_DISPLAY_MIN + Math.floor(Math.random() * span));
|
||||||
|
const pool = [...deskCards];
|
||||||
|
const out = [];
|
||||||
|
while (out.length < n && pool.length > 0) {
|
||||||
|
out.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const canAfford = (coins, price) => coins >= price;
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// 기호 체계 (ProjectSS_코어시스템_기획서.md §3) — 모든 카드가 공유하는 단일 기호 언어.
|
||||||
|
// 스와이프 판단은 텍스트가 아니라 이 기호(축 인장 + 기밀 등급)만으로 0.5초 내에 이루어져야 한다.
|
||||||
|
// 색상만으로 구분하지 말 것 — 아이콘 형태(실루엣) 자체가 축을 구분해야 한다 (§3.1).
|
||||||
|
//
|
||||||
|
// 2026-09-15 결정 반영: 기밀 등급 1~4 → 1~10 확장, 점(●) 표기 폐기.
|
||||||
|
// 카드 중앙에 큰 숫자, 축 인장은 모서리에 작게 — 트럼프 랭크+슈트 레이아웃 (§3.2).
|
||||||
|
// 코인은 1종으로 통합됐으므로(§8.2, §19) 축은 더 이상 코인 종류를 결정하지 않는다.
|
||||||
|
// 축이 결정하는 것은 "명분 종류"(질)뿐이고, 코인 양(양)은 등급 합계가 결정한다 (§7.2).
|
||||||
|
|
||||||
|
export const AXES = ['axis_threat', 'axis_hr', 'axis_pr', 'axis_external'];
|
||||||
|
|
||||||
|
// 인장(아이콘)은 lucide-react의 Tornado/Hand/Megaphone/Stamp를 그대로 쓴다.
|
||||||
|
// 실제 아이콘 컴포넌트 매핑은 SymbolBadge.jsx에서 담당 — 여기는 축의 이름·게이지·명분 매핑만.
|
||||||
|
export const AXIS_META = {
|
||||||
|
axis_threat: {
|
||||||
|
name: '위협',
|
||||||
|
sealName: '소용돌이',
|
||||||
|
gauge: 'entropy',
|
||||||
|
gaugeName: '엔트로피',
|
||||||
|
justification: '위기',
|
||||||
|
},
|
||||||
|
axis_hr: {
|
||||||
|
name: '인사',
|
||||||
|
sealName: '손',
|
||||||
|
gauge: 'resistance',
|
||||||
|
gaugeName: '조직 저항',
|
||||||
|
justification: '내부',
|
||||||
|
},
|
||||||
|
axis_pr: {
|
||||||
|
name: '공보',
|
||||||
|
sealName: '확성기',
|
||||||
|
gauge: 'panic',
|
||||||
|
gaugeName: '대중 공황',
|
||||||
|
justification: '여론',
|
||||||
|
},
|
||||||
|
axis_external: {
|
||||||
|
name: '대외',
|
||||||
|
sealName: '도장',
|
||||||
|
gauge: 'risk',
|
||||||
|
gaugeName: '위험 수용 계수',
|
||||||
|
justification: '절차',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GRADE_MIN = 1;
|
||||||
|
export const GRADE_MAX = 10;
|
||||||
|
|
||||||
|
// 등급 범위 명칭 (§3.2). 서사·문체용 라벨일 뿐이며, 합본 판정·주목도 계산 등
|
||||||
|
// 모든 게임 로직은 항상 원 숫자(1~10)를 쓴다.
|
||||||
|
const GRADE_BANDS = [
|
||||||
|
{ max: 2, name: '일반' },
|
||||||
|
{ max: 4, name: '대외비' },
|
||||||
|
{ max: 6, name: '2급 기밀' },
|
||||||
|
{ max: 8, name: '1급 기밀' },
|
||||||
|
{ max: 10, name: '특급 기밀' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const gradeBandName = (grade) => {
|
||||||
|
const g = clampGrade(grade);
|
||||||
|
return GRADE_BANDS.find((b) => g <= b.max)?.name ?? '일반';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clampGrade = (grade) =>
|
||||||
|
Math.max(GRADE_MIN, Math.min(GRADE_MAX, Math.round(grade || GRADE_MIN)));
|
||||||
|
|
||||||
|
// 명분 +1 보너스 기준 등급 (§7.1: "등급 8 이상(1급 기밀 이상) 카드가 포함되면 +1")
|
||||||
|
export const TOP_SECRET_THRESHOLD = 8;
|
||||||
|
|
||||||
|
export const gaugeOf = (axis) => AXIS_META[axis]?.gauge ?? null;
|
||||||
|
export const justificationOf = (axis) => AXIS_META[axis]?.justification ?? '혼합';
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"multiplier": 3,
|
||||||
|
"weights": {
|
||||||
|
"purge": [
|
||||||
|
"scavengers"
|
||||||
|
],
|
||||||
|
"convert": [
|
||||||
|
"nexus_traders",
|
||||||
|
"black_market_cartel"
|
||||||
|
],
|
||||||
|
"quarantine": [
|
||||||
|
"occult_collectors"
|
||||||
|
],
|
||||||
|
"skip": [
|
||||||
|
"corrupt_bureaucrats"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "asset_purge_001",
|
||||||
|
"name": "문서 파쇄실",
|
||||||
|
"grade": "C",
|
||||||
|
"archetype": "purge",
|
||||||
|
"amplifier": "periodic",
|
||||||
|
"trigger": { "type": "every_n_approvals", "params": { "n": 5 } },
|
||||||
|
"effect": { "type": "purge_lowest", "params": { "count": 1 } },
|
||||||
|
"growth": null,
|
||||||
|
"risk": null,
|
||||||
|
"description": "결재 5건마다 덱에서 최하 등급 오염 카드 1장을 영구 소각한다.",
|
||||||
|
"flavor": "파쇄 용량: 일일 1건. 증설 요청은 3회 반려됨."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "asset_purge_002",
|
||||||
|
"name": "소각 처리 대장",
|
||||||
|
"grade": "A",
|
||||||
|
"archetype": "purge",
|
||||||
|
"amplifier": "cumulative",
|
||||||
|
"trigger": { "type": "on_purge", "params": {} },
|
||||||
|
"effect": null,
|
||||||
|
"growth": { "stackOn": "on_purge", "per": 3, "bonus": { "effect": "purge_lowest", "count": 1 } },
|
||||||
|
"risk": null,
|
||||||
|
"description": "소각이 발생할 때마다 숙련도가 쌓인다. 숙련 3회마다 최하 등급 오염 카드 1장을 추가로 소각한다.",
|
||||||
|
"flavor": "소각로 온도는 서류로 증명되지 않는다."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "asset_convert_001",
|
||||||
|
"name": "폐기물 재활용 규정",
|
||||||
|
"grade": "C",
|
||||||
|
"archetype": "convert",
|
||||||
|
"amplifier": "pollution_reactive",
|
||||||
|
"trigger": { "type": "on_pollution_gain", "params": { "category": "신화", "minGrade": null } },
|
||||||
|
"effect": { "type": "convert_to_token", "params": { "tokenType": "budget", "amount": 2 } },
|
||||||
|
"growth": null,
|
||||||
|
"risk": null,
|
||||||
|
"description": "신화 오염 카드가 드로우되면 예산 토큰 2를 회수한다. (페널티는 정상 적용 후)",
|
||||||
|
"flavor": "괴수 사체도 감가상각 대상이다."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "asset_quarantine_001",
|
||||||
|
"name": "임시 격리 컨테이너",
|
||||||
|
"grade": "C",
|
||||||
|
"archetype": "quarantine",
|
||||||
|
"amplifier": "pollution_reactive",
|
||||||
|
"trigger": { "type": "on_pollution_gain", "params": {} },
|
||||||
|
"effect": { "type": "quarantine", "params": { "failCondition": "다음 의회까지 대중 공황 1단계 상승" } },
|
||||||
|
"growth": null,
|
||||||
|
"risk": null,
|
||||||
|
"description": "좌로 스와이프한 오염 카드를 격리한다. 다음 의회 전까지 처리하지 못하면 대중 공황이 상승한다.",
|
||||||
|
"flavor": "컨테이너 라벨: '절대 열지 말 것 (2차 통보)'."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "asset_skip_001",
|
||||||
|
"name": "결재 반려 도장",
|
||||||
|
"grade": "C",
|
||||||
|
"archetype": "skip",
|
||||||
|
"amplifier": "periodic",
|
||||||
|
"activation": "manual",
|
||||||
|
"trigger": { "type": "manual_charge", "params": { "chargeEvery": 10 } },
|
||||||
|
"effect": { "type": "negate_penalty", "params": { "category": null } },
|
||||||
|
"growth": null,
|
||||||
|
"risk": null,
|
||||||
|
"description": "결재 10건마다 충전된다. 충전 완료 시 수동으로 발동해 다음 오염 카드의 페널티를 1회 무효화한다.",
|
||||||
|
"flavor": "도장이 닳는 속도가 곧 국장의 임기다."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "asset_skip_002",
|
||||||
|
"name": "책임 소재 불명 처리",
|
||||||
|
"grade": "B",
|
||||||
|
"archetype": "skip",
|
||||||
|
"amplifier": "pollution_reactive",
|
||||||
|
"trigger": { "type": "on_pollution_gain", "params": { "category": "조직저항", "minGrade": null } },
|
||||||
|
"effect": { "type": "negate_penalty", "params": { "category": "조직저항" } },
|
||||||
|
"growth": null,
|
||||||
|
"risk": { "param": "resistance", "amount": 3, "when": "on_trigger" },
|
||||||
|
"description": "조직 저항 계열 오염 카드의 페널티를 자동으로 무효화한다. 다만 책임 소재가 사라질 때마다 내부 저항이 소폭 상승한다.",
|
||||||
|
"flavor": "'담당자 부재'라는 담당자가 새로 생겼다."
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
id,type,keyword,question,text,flavor,left_text,left_axis,left_grade,left_tag,right_text,right_axis,right_grade,right_tag
|
||||||
|
1,일상,요원들의 시위 발생,시위를 어떻게 처리하시겠습니까?,B구역 하급 요원들이 예산 삭감에 반발해 장비 반납 시위를 하고 있습니다.,우리가 세상을 구하는데 월급이 깎인다고요? 촉수에 맞아 죽으나 굶어 죽으나 똑같습니다!,무력 진압,axis_hr,1,tag_ruthless,요구 수용,,,
|
||||||
|
4,극비,변이 DNA 이식 제안,실험을 승인하시겠습니까?,진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠.,인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠.,실험 승인,axis_threat,9,tag_madness,제안 거절,axis_hr,7,
|
||||||
|
5,작전,전술 폭격 건의,폭격을 승인하시겠습니까?,철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다.,희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠.,궤도 폭격 승인,axis_external,5,tag_sacrifice,작전 기각,axis_hr,2,
|
||||||
|
6,일상,부산물 암시장 매각,매각을 허가하시겠습니까?,차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다.,이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠.,매각 허가,axis_external,1,tag_corruption,원칙대로 소각,,,
|
||||||
|
7,작전,존재 양지화 프로젝트,프로젝트를 승인하시겠습니까?,대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다.,우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다.,감시망 구축,axis_pr,2,tag_coverup,프로젝트 보류,axis_hr,2,
|
||||||
|
9,위기,이단 색출 마녀사냥,어떻게 개입하시겠습니까?,기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다.,더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로!,적당히 묵인한다,axis_hr,4,,군사력으로 제압,axis_external,4,
|
||||||
|
10,일상,변칙 현상 영상 유출,어떻게 처리하시겠습니까?,한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다.,대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오.,제거 절차 승인,,,tag_coverup,영상만 삭제,axis_pr,1,
|
||||||
|
12,위기,의회의 긴급 감사,어떻게 대처하시겠습니까?,의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다!,세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오.,은폐 공작 실행,axis_external,4,tag_corruption,횡령 사실 인정,axis_external,8,
|
||||||
|
13,일상,연구 부서 파업 조짐,요구를 수용하시겠습니까?,연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다.,저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요.,특별 보너스 지급,,,,요구 전면 묵살,axis_hr,1,
|
||||||
|
14,작전,이계 무역로 개방,무역로를 개방하시겠습니까?,이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다.,저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠.,게이트 강제 개방,axis_threat,2,tag_alien_contact,폐쇄 상태 유지,axis_hr,2,
|
||||||
|
15,극비,전임 국장 비밀 금고,금고를 개방하시겠습니까?,전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다.,전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다.,강제 개방 지시,axis_external,6,tag_corruption,영구 봉인,,,
|
||||||
|
16,작전,메가코프 협박 작전,작전을 실행하시겠습니까?,글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다.,그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까.,작전 실행,,,,작전 취소,axis_hr,2,
|
||||||
|
17,위기,전술 폭격 건의,폭격을 승인하시겠습니까?,제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요?,시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다.,시민 포기 및 폭격,axis_pr,8,tag_ruthless,결사 방어 명령,,,
|
||||||
|
18,일상,행정부 파업 예고,어떻게 대응하시겠습니까?,새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다.,기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다.,예산 전용으로 무마,axis_external,1,,파업 강경 대응,axis_hr,1,
|
||||||
|
19,극비,불침번 요원 개발,배치하시겠습니까?,비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다.,잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠.,실전 즉각 배치,axis_threat,7,,즉각 폐기 처분,axis_hr,7,
|
||||||
|
20,일상,내부 스파이 첩보,어떻게 조사하시겠습니까?,기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다.,누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다.,대규모 사찰 시작,axis_hr,3,tag_political_pressure,증거 부족으로 덮음,axis_external,1,
|
||||||
|
25,극비,내부 고발자 발생,어떻게 막으시겠습니까?,의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다.,국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다.,사고사 위장 지시,,,tag_coverup,폭로전 정면 돌파,axis_pr,9,
|
||||||
|
26,위기,차원 붕괴 폭탄 오작동,어떻게 대응하시겠습니까?,차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다.,최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다.,특수 해체팀 투입,,,tag_sacrifice,폭발 방관 및 대피,axis_threat,8,
|
||||||
|
27,작전,PMC 흡수 합병,결탁하시겠습니까?,군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다.,돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠.,결탁 및 흡수 합병,,,,독자적 노선 유지,axis_hr,2,
|
||||||
|
28,작전,대규모 거주 구역 실험장화,제안을 승인하시겠습니까?,변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다.,시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠.,돔 건설 강행,axis_threat,2,,윤리적 이유로 기각,axis_external,2,
|
||||||
|
30,일상,믹스 커피 재고 고갈,어떻게 해결하시겠습니까?,국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다.,종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까?,최고급 원두 지원,,,,헛소리 금지령,axis_hr,1,
|
||||||
|
31,위기,신화적 존재 강림,어떻게 대적하시겠습니까?,신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다!,최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까?,현실 조작기 가동,axis_threat,10,tag_madness,모든 예비병력 투입,axis_pr,4,tag_sacrifice
|
||||||
|
2,위기,격리실 오염 발생,오염을 어떻게 정화하시겠습니까?,지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다.,보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다.,네이팜 소각 강행,,,,조사팀 파견,axis_threat,4,
|
||||||
|
3,위기,촉수 괴물 출현,출동 승인하시겠습니까?,7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다.,규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다.,내역 위조 후 출동,axis_external,4,,규정대로 반려,axis_threat,4,
|
||||||
|
8,극비,대규모 도서관 건립,예산을 배정하시겠습니까?,고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다.,이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다.,예산 배정,,,,예산 전액 삭감,axis_external,7,
|
||||||
|
11,위기,사이비 종교 인신 공양,어떻게 대응하시겠습니까?,특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다.,위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다.,군사 개입 지시,axis_threat,5,,의식 방관,axis_threat,6,
|
||||||
|
21,작전,비정상적 위기 발생,국장님 결단이 필요합니다,순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다.,괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다.,수용소 건립 승인,,,,제안 단호히 거절,axis_external,2,
|
||||||
|
22,극비,이계의 유물 인양,유물을 어떻게 처리하시겠습니까?,고대 공존당의 사제들이 심해의 유물을 인양했습니다. 만지는 자의 정신을 갉아먹지만 막대한 지식을 줍니다.,약간의 두통만 참으면 이계의 비밀을 모두 알 수 있습니다. 요원 하나쯤 미쳐도 괜찮습니다.,유물 집중 연구,axis_threat,7,,유물 파괴 명령,axis_external,7,
|
||||||
|
23,위기,이계의 주시자 출현,대중을 어떻게 통제하시겠습니까?,얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다.,하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오.,인터넷 전면 차단,axis_pr,4,,진정제 무상 배포,axis_pr,2,
|
||||||
|
24,일상,요원 집단 트라우마,어떻게 지원하시겠습니까?,요원들이 괴수 진압 후 심각한 트라우마에 시달리고 있습니다. 심리 치료 지원이 시급합니다.,밤마다 촉수가 목을 조르는 악몽을 꾼답니다. 차라리 그 부분 기억을 지워버릴까요?,정신과 치료 지원,,,,트라우마 기억 소거,axis_external,1,
|
||||||
|
29,극비,이계 군주의 외교,제안을 수락하시겠습니까?,이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다.,요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다.,거래 즉시 성사,,,,사절단 전원 사살,axis_threat,7,"
|
||||||
|
""
|
||||||
|
"
|
||||||
|
@@ -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]];
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "tag_ruthless",
|
||||||
|
"name": "피도 눈물도 없는 진압",
|
||||||
|
"icon": "🩸",
|
||||||
|
"description": "반발을 무력으로 짓밟았습니다. 공포는 훌륭한 통제 수단입니다.",
|
||||||
|
"snippet": "진압 대응 인력 214명 투입. 민간인 부상 신고는 '작전 지연 방지' 사유로 접수 목록에서 삭제 처리됨.",
|
||||||
|
"snippet_type": "system",
|
||||||
|
"precedent": { "type": "modify_penalty", "axis": "axis_hr", "amount": -1 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tag_coverup",
|
||||||
|
"name": "진실의 장막",
|
||||||
|
"icon": "🤫",
|
||||||
|
"description": "대중이 알지 못하도록 변칙 사건을 완벽하게 은폐했습니다.",
|
||||||
|
"snippet": "변칙 목격 신고 63건, 언론 자문위 검토 후 '기상 이상 현상'으로 통합 발표 승인됨.",
|
||||||
|
"snippet_type": "system",
|
||||||
|
"precedent": { "type": "change_justification_type", "axis": "axis_pr", "newType": "절차" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tag_alien_contact",
|
||||||
|
"name": "금지된 지식",
|
||||||
|
"icon": "👁️",
|
||||||
|
"description": "이계의 존재와 거래하거나 그들의 유물을 받아들였습니다. 장막 너머의 눈이 당신을 주시합니다.",
|
||||||
|
"snippet": "압수된 이계 유물 1점, 등급 미분류 상태로 특수연구소 이관됨.",
|
||||||
|
"snippet_type": "system",
|
||||||
|
"precedent": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tag_corruption",
|
||||||
|
"name": "검은 뒷돈",
|
||||||
|
"icon": "💼",
|
||||||
|
"description": "공적인 자산을 사적으로 유용하거나 부당한 거래를 승인했습니다.",
|
||||||
|
"snippet": "결산 불일치 340만 원, 담당 경리 개인 계좌 이체로 충당된 정황 확인. 사유 불문 처리됨.",
|
||||||
|
"snippet_type": "human",
|
||||||
|
"precedent": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tag_madness",
|
||||||
|
"name": "이성의 붕괴",
|
||||||
|
"icon": "🌀",
|
||||||
|
"description": "요원들을 비인도적인 실험과 광기에 노출시켰습니다. 인간성은 점점 희미해집니다.",
|
||||||
|
"snippet": "실험 참여 요원, 심리 평가 미제출 24개월째로 현장 배치 유지 확인. 재평가 요청 반려됨.",
|
||||||
|
"snippet_type": "human",
|
||||||
|
"precedent": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tag_cult",
|
||||||
|
"name": "사이비의 그림자",
|
||||||
|
"icon": "🌑",
|
||||||
|
"description": "고대 신을 숭배하는 종교와 엮이기 시작했습니다. 그들의 교리가 스며듭니다.",
|
||||||
|
"snippet": "신흥 종교단체 신도 수 3,400명 규모로 집계. 포교 활동 규제 안건은 종교자유 조항 검토 사유로 반려됨.",
|
||||||
|
"snippet_type": "system",
|
||||||
|
"precedent": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tag_coup",
|
||||||
|
"name": "쿠데타의 그림자",
|
||||||
|
"icon": "🗡️",
|
||||||
|
"description": "권력자들이 등을 돌리기 시작했습니다. 칼끝이 서서히 당신을 향하고 있습니다.",
|
||||||
|
"snippet": "국장 신임 재투표 요구 서명 9명분 접수. 인사위원회 소집 안건은 '정족수 미달'로 미반영됨.",
|
||||||
|
"snippet_type": "system",
|
||||||
|
"precedent": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tag_panic_mob",
|
||||||
|
"name": "군중의 광기",
|
||||||
|
"icon": "😱",
|
||||||
|
"description": "이성을 잃은 대중이 거리를 뒤덮었습니다. 더 이상 통제할 수 없습니다.",
|
||||||
|
"snippet": "자경단 관련 신고 118건, 담당 부서 인력 부족을 사유로 전건 내부 종결됨.",
|
||||||
|
"snippet_type": "system",
|
||||||
|
"precedent": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tag_political_pressure",
|
||||||
|
"name": "정치적 압박",
|
||||||
|
"icon": "⚖️",
|
||||||
|
"description": "이사회가 당신의 자리를 노리고 있습니다. 숫자 하나하나가 무기가 됩니다.",
|
||||||
|
"snippet": "이사회 특별 감사 요청 안건 5건 상정. 국장 관련 항목은 '자료 미비'로 반려됨.",
|
||||||
|
"snippet_type": "system",
|
||||||
|
"precedent": { "type": "bonus_justification", "axis": "axis_external", "amount": 1 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tag_sacrifice",
|
||||||
|
"name": "희생의 대가",
|
||||||
|
"icon": "⚰️",
|
||||||
|
"description": "누군가의 목숨과 맞바꾼 결정이었습니다. 그 무게는 사라지지 않습니다.",
|
||||||
|
"snippet": "현장 요원 1명, 격리 프로토콜을 위반해 통로를 개방한 뒤 실종 처리됨.",
|
||||||
|
"snippet_type": "human",
|
||||||
|
"precedent": null
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
id,axis,grade,name,flavor,tag
|
||||||
|
48,axis_threat,1,온도계 이상 반응,지하 격리동 온도계가 37도를 반복 표시하고 있습니다.,
|
||||||
|
32,axis_threat,2,실험체의 노래,직원들 사이에서 그 노래가 귓가에 맴돈다는 소문이 돕니다.,
|
||||||
|
49,axis_threat,3,복도 그림자 목격,야간 근무자 2명이 같은 복도에서 동일한 그림자를 목격했다고 보고했습니다.,
|
||||||
|
33,axis_threat,5,움직이는 조각상,CCTV 확인 결과 아무도 없었는데 조각상은 분명 돌아서 있었습니다.,
|
||||||
|
34,axis_threat,7,심해의 알,생물학팀은 저것은 알이 아니라 심장이라고 보고했습니다.,tag_alien_contact
|
||||||
|
35a,axis_threat,10,장막 너머의 이름,언어병리학자는 이름의 세 음절만 듣고도 실어증에 걸렸습니다.,tag_madness
|
||||||
|
35b,axis_threat,10,장막 너머의 이름,언어병리학자는 이름의 세 음절만 듣고도 실어증에 걸렸습니다.,tag_alien_contact
|
||||||
|
50,axis_hr,1,익명 건의함 투고 증가,이번 달 건의함 투고 건수가 지난달보다 14건 늘었습니다.,
|
||||||
|
36,axis_hr,2,초과근무 항의,정시 퇴근이 인권이라는 팻말을 봤습니다.,
|
||||||
|
51,axis_hr,3,정시 퇴근 서명 운동,현장 요원 22명이 정시 퇴근 보장을 요구하는 서명에 참여했습니다.,
|
||||||
|
37,axis_hr,5,항명 성명,서명자 명단에 낯익은 이름이 보입니다. 승진 대상자였는데요.,
|
||||||
|
38,axis_hr,7,경비 태업,다들 같은 날 같은 감기에 걸렸다고 합니다. 참으로 공교롭죠.,tag_coup
|
||||||
|
39a,axis_hr,10,대규모 사표,이것은 항명이 아니라 쿠데타입니다.,tag_ruthless
|
||||||
|
39b,axis_hr,10,대규모 사표,이것은 항명이 아니라 쿠데타입니다.,tag_coup
|
||||||
|
52,axis_pr,1,온라인 목격담 게시글,관련 게시글 조회수가 하루 만에 800회를 넘었습니다.,
|
||||||
|
40,axis_pr,2,괴이한 목격담,댓글 반응은 반반입니다. 반은 조작설 반은 목격담 추가.,
|
||||||
|
53,axis_pr,3,지역 방송 문의 전화,지역 라디오 방송국에 제보 전화가 9통 걸려왔습니다.,
|
||||||
|
41,axis_pr,5,특집 뉴스 보도,미스터리 특종이라는 자막이 하루 종일 반복 재생됩니다.,
|
||||||
|
42,axis_pr,7,생필품 사재기,화장지 한 팩을 두고 몸싸움이 벌어졌다는 신고가 열 건째입니다.,tag_panic_mob
|
||||||
|
43a,axis_pr,10,종말론 행진,그들의 팻말에는 이상하게도 정확한 좌표가 적혀 있었습니다.,tag_ruthless
|
||||||
|
43b,axis_pr,10,종말론 행진,그들의 팻말에는 이상하게도 정확한 좌표가 적혀 있었습니다.,tag_panic_mob
|
||||||
|
54,axis_external,1,분기 보고서 지연 지적,분기 보고서 제출이 이틀 늦어 서면 경고를 받았습니다.,
|
||||||
|
44,axis_external,2,접대비 감사,법인카드로 산 초밥이 감사 보고서 제목이 됐습니다.,
|
||||||
|
55,axis_external,3,예산 항목 소명 요청,이사회가 3개 지출 항목에 대한 소명서 제출을 요구했습니다.,
|
||||||
|
45,axis_external,5,예산 전수조사,회계팀 막내가 벌써부터 사직서 초안을 쓰고 있습니다.,
|
||||||
|
46,axis_external,7,결재권 정지 논의,의제 제목이 국장의 독단 경영에 대하여입니다.,tag_corruption
|
||||||
|
47,axis_external,10,청문회 소환,출석하지 않으면 그 자체로 유죄로 간주하겠다는 문구가 적혀 있습니다.,tag_political_pressure
|
||||||
|
@@ -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 };
|
||||||
@@ -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(' · ');
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
@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;
|
||||||
|
/* 신규 코어 시스템(client/src/core/)은 재화를 "코인" 1종으로 통합했다(§8.2) — 구 3종
|
||||||
|
토큰 색상 중 예산색(--color-token-budget)을 코인 강조색으로 재사용한다. 기존 변수는
|
||||||
|
참조용 레거시 컴포넌트(client/src/components/)가 계속 쓰므로 남겨 두고 별칭만 추가한다. */
|
||||||
|
--color-coin: var(--color-token-budget);
|
||||||
|
|
||||||
|
--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;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
/* 스와이프 제스처와 페이지 스크롤이 충돌하지 않게 문서 자체는 스크롤하지 않는다.
|
||||||
|
html에도 걸어야 한다 — body만 걸면 모바일 브라우저가 html을 스크롤해서, 콘텐츠가
|
||||||
|
넘칠 때 화면 전체가 밀려 올라가 상단 툴바(합본 결재)가 사라진다(실제로 겪음). */
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
/* 모바일 필수: 100vh는 "주소창이 숨은 상태" 기준 높이라, 주소창이 보이는 동안 화면 아래
|
||||||
|
60~100px이 잘린다. body가 overflow:hidden이어서 스크롤로 닿을 수도 없다 — 화면 맨 아래에
|
||||||
|
있는 "정산하기" 버튼이 가려지는 실제 문제가 된다. dvh(동적 뷰포트 높이)는 주소창 노출
|
||||||
|
상태를 반영한다. vh는 dvh 미지원 구형 브라우저용 폴백으로 남긴다. */
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 코인 수치 옆에 붙는 lucide Coins 아이콘 공용 정렬 — DeskBar/ShopScreen/CouncilScreen 공유. */
|
||||||
|
.coin-icon {
|
||||||
|
vertical-align: -2px;
|
||||||
|
color: var(--color-coin);
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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');
|
||||||
@@ -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');
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { execSync } from 'node:child_process'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// 빌드 식별자 — 여러 명에게 며칠에 걸쳐 피드백을 받으면 "어느 빌드였나"가 반드시 꼬인다.
|
||||||
|
// 화면 구석에 이 값을 박아 두면 피드백을 버전에 붙여 정리할 수 있다.
|
||||||
|
const buildInfo = (() => {
|
||||||
|
let sha = 'nogit'
|
||||||
|
try {
|
||||||
|
sha = execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] })
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
} catch {
|
||||||
|
// 깃 없는 환경(배포 서버 등)에서 빌드해도 실패하지 않게 둔다
|
||||||
|
}
|
||||||
|
const d = new Date()
|
||||||
|
const p = (n) => String(n).padStart(2, '0')
|
||||||
|
return `${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}·${sha}`
|
||||||
|
})()
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
// 상대 경로로 빌드한다. NAS Web Station이 문서 루트 하위 폴더(/ProjectSS/ 등)로 서빙하므로
|
||||||
|
// 기본값('/')이면 asset을 도메인 루트에서 찾아 404 → 빈 화면이 된다. './'로 두면 어느
|
||||||
|
// 서브패스에 떨어뜨려도 그대로 동작해서, 폴더를 옮길 때 재빌드할 필요가 없다.
|
||||||
|
base: './',
|
||||||
|
plugins: [react()],
|
||||||
|
define: {
|
||||||
|
__BUILD_INFO__: JSON.stringify(buildInfo),
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ProjectSS 배포 — 시놀로지 NAS(Web Station, 정적 웹사이트)로 빌드 결과물을 올린다.
|
||||||
|
#
|
||||||
|
# 구조:
|
||||||
|
# client/dist --(scp over ssh)--> NAS:$REMOTE_DIR --(Web Station nginx)--> 공개 URL
|
||||||
|
#
|
||||||
|
# rsync가 아니라 scp인 이유: 이 NAS의 /usr/bin/rsync는 SSH 전송 모드에서도
|
||||||
|
# "rsync error: rsync service is no running (code 43)"로 막힌다. DSM의
|
||||||
|
# 제어판 → 파일 서비스 → rsync 활성화 + 사용자 애플리케이션 권한(rsync) 부여를
|
||||||
|
# 둘 다 했는데도 동일했다 — 시놀로지 쪽에 rsync-over-ssh용 별도 게이트(아마
|
||||||
|
# rsync daemon 설정 안의 사용자 화이트리스트)가 더 있는 것으로 보이는데, 이
|
||||||
|
# 프로젝트 빌드 결과물이 400KB 안팎이라 rsync의 증분 전송 이점이 없어서 원인을
|
||||||
|
# 더 파고들지 않고 scp(-O, 레거시 exec 프로토콜)로 우회했다. 이 NAS는 SFTP
|
||||||
|
# 서브시스템도 안 열려 있어(-O 없는 최신 scp는 "subsystem request failed")
|
||||||
|
# 반드시 -O가 필요하다.
|
||||||
|
#
|
||||||
|
# 사전 준비(각 1회):
|
||||||
|
# 1) NAS에서 SSH 활성화 (제어판 → 터미널 및 SNMP)
|
||||||
|
# 2) 비밀번호 없이 배포하려면 SSH 키 등록:
|
||||||
|
# ssh-keygen -t ed25519 -C projectss-deploy # 키가 없을 때만
|
||||||
|
# ssh-copy-id -p 225 kyoung5@192.168.0.6
|
||||||
|
# 시놀로지는 키 인증이 홈 디렉터리 권한에 민감하다 — ssh-copy-id 직후에도
|
||||||
|
# 비밀번호를 계속 물으면 NAS에 비밀번호로 한 번 접속해 다음을 확인:
|
||||||
|
# chmod 755 ~ && chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys
|
||||||
|
#
|
||||||
|
# 사용법:
|
||||||
|
# ./deploy.sh # 빌드 후 배포
|
||||||
|
# ./deploy.sh --no-build # 이미 빌드된 dist를 그대로 배포
|
||||||
|
# ./deploy.sh --dry-run # 실제로 올리지 않고 무엇을 보낼지만 확인
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── 설정 ──────────────────────────────────────────────────────────────────
|
||||||
|
SSH_USER="kyoung5"
|
||||||
|
SSH_HOST="192.168.0.6"
|
||||||
|
SSH_PORT="225"
|
||||||
|
|
||||||
|
# Web Station 문서 루트가 web/web_games 이므로 그 하위 폴더에 올린다.
|
||||||
|
# 여기를 바꾸면 공개 URL도 같이 바뀐다 (PUBLIC_URL도 함께 수정).
|
||||||
|
REMOTE_DIR="/volume1/web/web_games/projectss"
|
||||||
|
PUBLIC_URL="https://play.mint-grape.duckdns.org/projectss/"
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
DO_BUILD=1
|
||||||
|
DRY_RUN=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--no-build) DO_BUILD=0 ;;
|
||||||
|
--dry-run) DRY_RUN=1 ;;
|
||||||
|
*) echo "알 수 없는 옵션: $arg" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $DO_BUILD -eq 1 ]]; then
|
||||||
|
echo "▶ 빌드"
|
||||||
|
(cd client && npm run build)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f client/dist/index.html ]]; then
|
||||||
|
echo "✗ client/dist/index.html 이 없다. 먼저 빌드할 것." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
echo "▶ (dry-run) 아래 파일들을 ${SSH_USER}@${SSH_HOST}:${REMOTE_DIR} 로 보낼 예정 — 그 전에 원격 폴더를 비운다:"
|
||||||
|
find client/dist -type f | sed 's/^client\/dist/ /'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "▶ 배포 → ${SSH_USER}@${SSH_HOST}:${REMOTE_DIR}"
|
||||||
|
|
||||||
|
# 해시 붙은 옛 asset 파일이 남아 쌓이지 않게, 매번 원격 폴더를 비우고 새로 올린다
|
||||||
|
# (rsync --delete와 같은 효과). REMOTE_DIR 전용 폴더만 지우므로 다른 경로는 안 건드린다.
|
||||||
|
ssh -p "${SSH_PORT}" "${SSH_USER}@${SSH_HOST}" \
|
||||||
|
"rm -rf '${REMOTE_DIR}' && mkdir -p '${REMOTE_DIR}'"
|
||||||
|
|
||||||
|
scp -O -P "${SSH_PORT}" -r client/dist/* "${SSH_USER}@${SSH_HOST}:${REMOTE_DIR}/"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "✓ 배포 완료"
|
||||||
|
echo " $PUBLIC_URL"
|
||||||
+256
@@ -0,0 +1,256 @@
|
|||||||
|
# DEVLOG
|
||||||
|
|
||||||
|
> 날짜별 작업 이력. 맨 위가 최신. "왜 그렇게 했는가"는 결정 노트로 링크되어 있으면 그쪽 참고.
|
||||||
|
> 이전 DEVLOG(구 Phase 0~4 체계)는 `old/2026.09.04/docs/DEVLOG.md`에 보관.
|
||||||
|
|
||||||
|
## 열린 이슈
|
||||||
|
|
||||||
|
- **유형 B(금리형 규약) 카탈로그가 없음** — 유형 A·C·D는 채웠다. B는 게임오버 게이지 최대치
|
||||||
|
변경·상점 가격 배율 등 판 전체 변수라 §20.2 소량 배치 콘텐츠 작업 때 채울 것.
|
||||||
|
- **공보(axis_pr) 축 선택지가 여전히 7개로 최소 수준** — 축별 재배정으로 1개→7개까지 늘렸지만
|
||||||
|
다른 축(12~14개)에 비하면 적다. 공보로 "일괄 처리"(같은 축 5장)를 노리기는 아직 빠듯하다.
|
||||||
|
§20.2 신규 카드 작성 때 공보 축을 우선 보강할 것.
|
||||||
|
- **평택-서울 원격 세션 안정성 문제 원인 미파악** — 2026-09-15 작업 중 VS Code Remote-SSH
|
||||||
|
연결이 끊겨 커밋 전 작업이 통째로 유실됐다(재구현으로 복구). 왜 끊겼는지 원인 조사 안 됨 —
|
||||||
|
재발 방지책 필요.
|
||||||
|
- **라운드 주목도 한도(21 + 라운드당 +5), 상점 가격, 게이지 상승 장수 구간 수치 등은 전부
|
||||||
|
가안** — 실측 플레이로 튜닝 예정. 2026-09-18에 접수량을 누적 카운터로 바꾸면서 한도가
|
||||||
|
실질적으로 더 타이트해졌으므로(§20.1과 연동해) 다시 볼 것. 결정 노트:
|
||||||
|
[[2026-09-18-라운드-접수량-누적카운터]]
|
||||||
|
- **이월 누적이 실제로 압박이 되는지 미검증** — 주목도 라운드 분리(2026-09-17) 이후 이월분은
|
||||||
|
게이지·코인 가산에만 작용한다. 이월을 쌓아두고 매 라운드 한도까지 새로 받는 플레이가 지배
|
||||||
|
전략이 되지 않는지 확인 필요. 결정 노트: [[2026-09-17-주목도-라운드-분리]]
|
||||||
|
- **상점이 사실상 비어 있음** — 지금 유일한 품목(오염 카드 직접 처리)이 의회 실적 납입(코인
|
||||||
|
1개=실적 5점, §9.3)과 경쟁이 안 돼 "살 이유가 없다"는 피드백. 자산·소모품 등 3차 품목이
|
||||||
|
들어와야 근본 해결(§20.2 이후 우선 작업 후보). 코인 납입 환산율 하향은 상점이 채워진 뒤
|
||||||
|
다시 볼 것 — 지금 건드리면 상점 완성 후 또 바뀌어야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-19 — 모바일: 로그가 쌓이면 합본 결재 버튼이 화면 밖으로 밀리는 버그 수정
|
||||||
|
|
||||||
|
- **증상**: 폰에서 결재를 계속하면 하단 로그가 늘어나면서 화면 전체가 위로 밀려 올라가고,
|
||||||
|
상단 책상바의 "합본 결재" 버튼이 가려져 누를 수 없었다.
|
||||||
|
- **원인**: `.App`이 가로 flex + `align-items:center`(중앙 정렬)였다. 콘텐츠가 뷰포트보다
|
||||||
|
커지면 위아래로 균등하게 넘쳐 상단이 잘렸고, 자식 높이도 고정되지 않아 게임 화면 내부
|
||||||
|
스크롤이 동작하지 않았다.
|
||||||
|
- **수정**:
|
||||||
|
- `App.css` — 세로 flex + `flex:1; min-height:0`로 바꿔 높이를 뷰포트에 고정.
|
||||||
|
- `index.css` — html/body `overflow:hidden; overscroll-behavior:none`, `#root`에 `100dvh`.
|
||||||
|
- `CoreGameScreen.css` — 게임 화면 자체를 내부 스크롤 영역으로(안전 밸브). 세로가 짧은
|
||||||
|
화면(≤760px)에선 로그를 최근 2줄 높이로 줄인다.
|
||||||
|
- `DeskBar.css` — 책상바를 `position:sticky; top:0`으로 고정.
|
||||||
|
- `CoreSwipeCard.css` — 짧은 화면에서 카드 `min-height:320px` 해제, 여백 축소, 본문은 카드
|
||||||
|
안에서 스크롤.
|
||||||
|
- **검증**: 390×660 모바일 뷰포트 Playwright로 결재 12회(상점·의회를 거쳐 2라운드까지)
|
||||||
|
진행하면서 매번 합본 결재 버튼이 화면 안에 있고 실제로 눌리는지(elementFromPoint) 확인 —
|
||||||
|
전부 통과, 콘솔 에러 0건. 정산하기 버튼도 계속 화면 안에 있었다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-18 — 외부 플레이테스트 배포 준비 (시놀로지 NAS) + 모바일 실기 대응
|
||||||
|
|
||||||
|
**무엇을 왜:** 이제부터 외부 테스터에게 링크를 공유해 피드백을 받기로 해서, "모바일에서 링크만
|
||||||
|
누르면 플레이되고, 배포는 한 줄로 끝나는" 상태를 만들었다. 기존 NAS 인프라(Web Station nginx +
|
||||||
|
DuckDNS + 인증서)를 그대로 쓴다 — 조사해 보니 서브도메인 DNS·443 포워딩·인증서가 모두 이미
|
||||||
|
동작 중이었고(구 HTML 프로토타입이 같은 경로로 서빙되고 있었다), 실제 블로커는 두 개뿐이었다.
|
||||||
|
|
||||||
|
- **서브패스 asset 경로 (배포 시 빈 화면이 되는 블로커)**: Web Station 문서 루트 하위 폴더로
|
||||||
|
서빙하는데 Vite 기본 `base: '/'`는 asset을 도메인 루트에서 찾아 전부 404가 된다.
|
||||||
|
`base: './'`(상대 경로)로 바꿨다 — 특정 서브패스에 고정되지 않아 폴더를 옮겨도 재빌드가
|
||||||
|
필요 없다. 로컬에서 `/projectss/` 하위로 서빙해 프로덕션 빌드가 실제로 뜨는 것까지 확인
|
||||||
|
- **모바일 `100vh` → `100dvh`**: `100vh`는 주소창이 숨은 상태 기준 높이라, 주소창이 보이는
|
||||||
|
동안 화면 아래 60~100px이 잘린다. `body`가 `overflow:hidden`이어서 스크롤로 닿을 수도 없어
|
||||||
|
**화면 맨 아래의 "정산하기" 버튼이 가려지는** 실제 문제였다. `vh`는 구형 폴백으로 남겼다.
|
||||||
|
`#root`의 `width: 100vw`도 `100%`로 바꿨다(100vw는 데스크톱 스크롤바 폭만큼 가로 넘침)
|
||||||
|
- **빌드 식별자 표시**: 여러 명에게 며칠에 걸쳐 피드백을 받으면 "어느 빌드였나"가 반드시
|
||||||
|
꼬인다. `vite.config.js`가 빌드 시각+커밋 해시(`0918-2239·6398599`)를 주입하고 상태 줄
|
||||||
|
오른쪽에 흐리게 표시한다 — 테스터가 스크린샷만 찍어도 버전이 남는다
|
||||||
|
- **`<title>`·`theme-color`·`description`**: 링크를 공유·북마크할 때 보이는 이름을 게임 제목으로
|
||||||
|
바꾸고, 모바일 브라우저 바 색을 다크 UI에 맞췄다. 확대 차단(`user-scalable=no`)은 작은 글씨를
|
||||||
|
읽어야 하는 테스터의 접근성을 해쳐서 넣지 않았다
|
||||||
|
- **`deploy.sh` + `npm run deploy`**: `vite build` 후 NAS에 올린다. `--dry-run`으로 보낼 파일
|
||||||
|
목록만 먼저 확인 가능. 배포용 SSH 키(ed25519) 생성, `ssh-copy-id`로 등록 완료 — 이후 비밀번호
|
||||||
|
없이 배포된다. 실제 배포까지 검증 완료: **https://play.mint-grape.duckdns.org/projectss/**
|
||||||
|
- **배포 과정에서 겪은 삽질 2건, 재발 방지용으로 기록**:
|
||||||
|
1. `ssh-copy-id` 직후에도 계속 비밀번호를 요구함 → 홈 디렉터리가 `drwxrwxrwx`(777),
|
||||||
|
`~/.ssh`도 777, `authorized_keys`도 777이었다. OpenSSH는 키 소유자 외 그룹/기타 쓰기
|
||||||
|
권한이 있으면 StrictModes로 그 키를 무시하고 조용히 비밀번호 인증으로 넘어간다 —
|
||||||
|
클라이언트 쪽엔 에러가 안 보여서 원인 파악이 까다로웠다. `chmod 755 ~ && chmod 700
|
||||||
|
~/.ssh && chmod 600 ~/.ssh/authorized_keys`로 해결
|
||||||
|
2. 키 인증은 성공했는데(`Authenticated ... using "publickey"`) **rsync만** 항상
|
||||||
|
`rsync error: rsync service is no running (code 43)`로 실패. DSM 제어판의
|
||||||
|
"파일 서비스 → rsync 활성화"와 사용자 애플리케이션 권한(rsync 체크)을 둘 다 켜고
|
||||||
|
`rsyncd.service`가 `active (running)`인 것까지 확인했는데도 동일 에러 — SSH 전송
|
||||||
|
모드(`-e ssh`)인데도 시놀로지의 `/usr/bin/rsync`가 별도 게이트(아마 daemon 설정의
|
||||||
|
사용자 화이트리스트)에 걸리는 것으로 보이나, 이 프로젝트 빌드 결과물이 400KB 안팎이라
|
||||||
|
rsync의 증분 전송 이점이 없어서 원인을 더 파지 않고 **`scp -O`(레거시 exec 프로토콜)로
|
||||||
|
교체**했다. 이 NAS는 SFTP 서브시스템도 꺼져 있어(`-O` 없는 최신 scp는 "subsystem
|
||||||
|
request failed") `-O`가 필수. `rm -rf "$REMOTE_DIR" && mkdir -p` 후 scp로 옮겨
|
||||||
|
rsync `--delete`와 같은 "매번 깨끗하게" 효과를 낸다
|
||||||
|
- iPhone 13 크기(390×664)로 실기 검증: 터치 스와이프 정상(CDP로 실제 터치 입력 전송), 결재
|
||||||
|
카드·정산하기·합본 버튼 모두 화면 안, 상점·의회 화면도 스크롤 없이 들어감, 실패 요청 0건,
|
||||||
|
콘솔 에러 0건. 배포 후 공개 URL에서 최신 빌드 asset 해시가 그대로 서빙되는 것까지 확인
|
||||||
|
|
||||||
|
## 2026-09-18 — 라운드 접수량 버그 수정 + 책상/결재 화면 정보 재배치 + 토큰→코인 용어 통일
|
||||||
|
|
||||||
|
**무엇을 왜:** 플레이 피드백 4건(주목도 미차감 버그, 상점 빈 상품, 상점 UX 오해, 재화 명칭)을
|
||||||
|
먼저 원인 분석·해결안으로 정리해 사용자에게 제시하고, 방향이 확정된 항목만 반영했다. 상점
|
||||||
|
품목 확충은 3차(자산·소모품) 이후로, 코인 납입 환산율 조정은 상점이 채워진 뒤로 보류.
|
||||||
|
|
||||||
|
- **라운드 접수량 크리티컬 버그 수정**: 합본을 여러 번 하면 라운드가 끝나지 않던 문제
|
||||||
|
(피드백: "합본을 많이 하면 라운드가 너무 길어진다"). `roundAttention(deskCards, round)`이
|
||||||
|
책상 카드를 필터링해 매번 다시 합산하는 파생값이라, 합본으로 카드가 사라지면 접수량도 같이
|
||||||
|
줄어 한도(21)에 다시 도달할 때까지 계속 새로 받을 수 있었다. CoreGameScreen이 직접 들고
|
||||||
|
관리하는 순수 누적 카운터(`roundIntake`, 라운드가 바뀔 때만 0으로 리셋)로 교체 — 결정 노트:
|
||||||
|
[[2026-09-18-라운드-접수량-누적카운터]]
|
||||||
|
- **DeskBar/CoreSwipeCard 정보 역할 분리**: "이번 라운드 접수량/한도"(카드를 더 받을지 판단)는
|
||||||
|
결재 화면(CoreSwipeCard)이 전담하고, DeskBar는 "책상에 지금 쌓인 것"(총 주목도, 축별 분포,
|
||||||
|
게이지·지출 가산)만 보여주도록 재배치. 중복 표시·죽은 CSS(zone 배너 등) 정리. `attentionZone`
|
||||||
|
판정을 desk.js 단일 함수로 통일해 CoreSwipeCard가 갖고 있던 로컬 중복 로직 제거
|
||||||
|
- **토큰 → 코인 용어 일괄 변경**: 상점이 본격 가동되면서 "토큰"보다 "코인"이 재화를 직관적으로
|
||||||
|
떠올리게 한다는 피드백. `core/` 전체(식별자·CSS 클래스·주석·UI 텍스트, 17개 파일)와 기획서
|
||||||
|
SSOT를 함께 갱신 — §10.4 자산 스키마의 구 3종 토큰 JSON 예시는 3차 미구현 상태라 제외했다.
|
||||||
|
lucide `Coins` 아이콘을 책상 바·상점·의회의 코인 표시에 추가. `--color-token-budget` CSS
|
||||||
|
변수는 레거시 참조용 컴포넌트가 계속 쓰므로 그대로 두고 `--color-coin` 별칭만 신설(같은 값을
|
||||||
|
가리킴) — `--color-token-info`는 재화와 무관한 범용 강조색이라 이름을 바꾸지 않았다
|
||||||
|
- Playwright로 검증: 4장 소멸하는 합본 전후 접수량이 17→17로 불변임을 직접 확인(이전엔 이
|
||||||
|
케이스가 버그였다), 버스트 임계 정상 작동(주목도 25에서 한도 21 초과 종료) 확인, 전체 페이지
|
||||||
|
텍스트에 "토큰" 잔존 0건·코인 아이콘 렌더 확인, 콘솔 에러 0건
|
||||||
|
|
||||||
|
## 2026-09-17 — 플레이 피드백 5건 반영 (합본 결재 리뉴얼 후속)
|
||||||
|
|
||||||
|
**무엇을 왜:** 합본 결재 리뉴얼(같은 날 앞선 작업) 이후 나온 밸런스 피드백 5건. 그중 "결재
|
||||||
|
카드가 고정으로 등장하는 것 같다"는 실제 버그였다 — 카드 인덱스를 그냥 +1씩 돌려서 CSV 파일
|
||||||
|
순서 그대로, 매 실행마다 완전히 동일한 순서로 반복되고 있었다(진짜 무작위가 아니라 결정론적
|
||||||
|
순환). 이게 "위협 축도 거의 등장 안 함" 피드백의 상당 부분을 설명한다 — 테스트할 때마다 같은
|
||||||
|
초반 카드만 봤을 것이다. 결정 노트: [[2026-09-17-게이지상승-장수기준-복원]]
|
||||||
|
|
||||||
|
- **결재 카드 셔플 큐 도입**: Fisher–Yates로 섞은 덱에서 앞부터 뽑고, 큐가 비면 다시 섞어
|
||||||
|
채운다. 한 바퀴 안에서는 같은 카드가 반복되지 않으면서 실행마다·바퀴마다 순서가 달라진다.
|
||||||
|
Playwright로 3번 재로딩해 첫 카드가 매번 다른 것, 15회 연속 뽑기에서 직전 반복 0회 확인
|
||||||
|
- **게이지 상승 기준을 등급 합계(주목도) → 카드 장수로 재설계** (등급 배율은 유지): 실제 카드
|
||||||
|
등급이 1~4급에 몰려 있어 등급 합계만으로는 3~4장을 쌓아도 최하 구간(+0.5%)을 못 벗어났다.
|
||||||
|
장수 구간(1~2장 0.5% → 3~4장 3.0% → 5~6장 6.0% → 7~9장 10.0% → 10장+ 15.0%+)을 1차
|
||||||
|
기준으로 되돌리고 `축 평균 등급/5` 배율을 곱해 등급도 계속 의미를 갖게 했다. 시뮬레이션으로
|
||||||
|
검증: 등급6 카드가 쌓이는 축은 12장째에 게임오버, 3장째에 전 구간 대비 6배 점프(0.6%→3.6%)
|
||||||
|
- **위협 축 카드 1개 재배정**: id11 "사이비 종교 인신 공양" 좌측("군사 개입 지시")이 인사 축
|
||||||
|
이었는데, 고대 신 소환 저지 개입이 인사(HR) 사안일 리 없다 — 위협 축으로 이동(4급→5급).
|
||||||
|
위협 11→12개
|
||||||
|
- **완전 무행동 카드 제거**: id15 "전임 국장 비밀 금고"가 좌/우 모두 오염 없는 유일한 카드였다
|
||||||
|
(결재해도 아무 일도 안 일어남). 좌측("강제 개방 지시")에 대외/6급 부여 — 비자금 발견 시
|
||||||
|
정치적 위험이라는 기존 `tag_corruption` 태그와도 맞는다. 우측("영구 봉인")은 안전 선택으로 유지
|
||||||
|
- **"오염 카드 한쪽만 있는 선택지가 많아 초반엔 안전 선택 메리트가 없다"는 피드백은 카드 데이터가
|
||||||
|
아니라 게이지 곡선 문제로 판단해 별도 수정 안 함** — 위 장수 기준 재설계로 3장 이상 쌓일 때의
|
||||||
|
손해가 뚜렷해졌으므로, 안전 선택의 가치는 이걸로 회복된다고 보고 카드 재배분은 보류
|
||||||
|
- Playwright로 셔플·다양성·게이지 표시 전부 재확인, 콘솔 에러 0건
|
||||||
|
|
||||||
|
## 2026-09-17 — 열린 이슈 3건 해소 + 플레이 피드백 4건 반영
|
||||||
|
|
||||||
|
**무엇을 왜:** 축별 등급 분포 확인에서 공보 축이 43개 선택지 중 1개뿐인 게 드러났고(대중 공황
|
||||||
|
게이지가 사실상 죽은 축), 유형 D 부재로 내부 명분이 무용지물이었으며, 혼합 명분은 자리표시자
|
||||||
|
였다. 여기에 플레이 피드백 4건을 같이 처리했다. 그중 **주목도 미초기화는 게임 진행이 불가능한
|
||||||
|
크리티컬 버그**여서 설계 결정이 필요했다 — 결정 노트: [[2026-09-17-주목도-라운드-분리]]
|
||||||
|
|
||||||
|
- **공보 축 보강**: 서사 기준으로 축이 어긋난 선택지 6개를 공보로 재배정(영상 유출 후속, 진정제
|
||||||
|
배포, 감시망 구축, 전 병력 투입, 시민 포기 폭격, 폭로전). 공보 1→7개, 축 분포
|
||||||
|
위협11/인사14/공보7/대외13으로 균형 개선. 신규 카드 작성이 아니라 재배정이라 §20.2 범위 밖
|
||||||
|
- **유형 D(이득 규약) 5종 신설**: 토큰 즉시 지급 / 게이지 완화 / 지출 가산 완화 / 주목도 한도
|
||||||
|
+8 / 실적 요구치 −20. 후보 등장률 30%, 내부 명분의 강제 상정 대상에 포함 — 이게 내부 명분의
|
||||||
|
존재 이유다. `gaugeSurchargeOf`·`tokenSurchargeOf` 등을 징벌 전용에서 규약 공용으로 일반화
|
||||||
|
- **13석 표결 실제 구현**: `tallyVotes`(최대잔여법, 결정적 해시라 개입해도 표가 흔들리지 않음)
|
||||||
|
+ `applyVoteSwing`. 혼합 명분이 "직접 통과 지정"에서 §7.3 스펙대로 **1표 이동**으로 바뀌었고,
|
||||||
|
동점은 스윙 전 득표가 많던 쪽이 이겨 **1표 차에서만 뒤집힌다**. 의회 화면에 예상 득표·표 차·
|
||||||
|
"혼합 명분으로 뒤집을 수 있음/없음" 힌트를 노출
|
||||||
|
- **주목도 라운드 분리**(크리티컬 버그 수정): 한도 판정은 이번 라운드분, 게이지·토큰 가산은
|
||||||
|
총계(이월 포함). 카드에 받은 라운드 번호를 기록해 구분. 기존엔 이월 때문에 2라운드가
|
||||||
|
시작하자마자 버스트가 무한 반복됐다
|
||||||
|
- **"정산하기" 버튼을 결재 카드 아래로 이동** — 툴바 구석에 있어 핵심 결정 버튼인 걸 모른다는
|
||||||
|
피드백. 이번 라운드 주목도/한도/남은 여유를 버튼 옆에 같이 표시
|
||||||
|
- **툴팁 신설**(`InfoTooltip`·`ProtocolChip`): 규약·징벌·명분을 누르면 "정확히 무엇이 막혔는지"를
|
||||||
|
띄운다. 설명 문구는 UI가 추측하지 않고 `council.js`의 `protocolEffectDetail`·
|
||||||
|
`justificationDetail`이 카탈로그에서 생성 — 예: "「일괄 처리」 족보 사용 불가 / 조건 「같은 축
|
||||||
|
5장」. 합본 판정에서 제외되어 실적 40·명분 2 손실". 모바일 대상이라 hover가 아닌 클릭으로 연다
|
||||||
|
- 부수 수정: 이득 규약의 음수 가산이 지출을 할인하지 않도록 `tokenSurcharge`를 0에서 끊음,
|
||||||
|
좁은 화면에서 의회 감사 행이 잘리던 문제(`flex-wrap`), 툴팁 조사 오류("일괄 처리이(가)")
|
||||||
|
- Playwright로 버스트→상점→의회→2라운드 전체 검증: 2라운드가 0/26으로 리셋되고 이월 23은 별도
|
||||||
|
표시, 합본→명분 획득→명분 툴팁, 규약 툴팁, 자율 정산 모두 확인. 콘솔 에러 0건
|
||||||
|
|
||||||
|
## 2026-09-16 — 라운드/회기 이층 구조 폐지, 주목도 하드 캡(버스트) 재도입
|
||||||
|
|
||||||
|
**무엇을 왜:** 직전 재구현에서 도입한 "라운드(손)+회기(정산·의회 텀)" 이층 구조와 "결재 8건 고정"
|
||||||
|
방식이 실제 의도와 어긋났다 — 원래는 **라운드마다 정산·의회를 거치고, 그 압박을 합본·상점으로
|
||||||
|
넘어서는 것**이 핵심 재미였고, 라운드 종료도 고정 건수가 아니라 **주목도가 라운드별 한도(21에서
|
||||||
|
시작, 라운드마다 증가)를 넘으면 강제 종료, 아니면 자율 종료**하는 블랙잭식 구조여야 했다. 결정
|
||||||
|
노트: [[2026-09-16-라운드-주목도-하드캡-복원]]
|
||||||
|
|
||||||
|
- `desk.js`: 고정 경고/초과 임계치(30/40/41)를 없애고 `roundAttentionCap(round)` +
|
||||||
|
한도 상대적 구간(`normal`/`warning`/`bust`)으로 교체
|
||||||
|
- `CoreGameScreen.jsx`: `session`/`handNumber` 이층 상태를 단일 `round`로 되돌리고, 결재마다
|
||||||
|
주목도가 한도를 넘는지 즉시 검사해 초과 시 합본 기회 없이 상점 단계로 강제 전환. 플레이어가
|
||||||
|
스스로 끝낼 수 있는 "정산하기" 액션 신설
|
||||||
|
- `DeskBar.jsx`: 주목도를 "현재/한도"로 표시, 정산하기 버튼 추가, 한도초과(bust) 시각 경고
|
||||||
|
- `CouncilScreen.jsx`: 징벌 규약 5개 초과 시 목록 접기/펼치기 추가 (§9.2 UI 요건 반영)
|
||||||
|
- 기획서 §2(코어 루프)를 단일 라운드 구조로 재작성, §4.2-B를 하드 캡 서술로 교체, §19 결정
|
||||||
|
완료 사항 표의 "라운드 종료 방식"·"이층 구조" 행을 뒤집힌 결정으로 갱신, §20.1 미결정 사항의
|
||||||
|
관련 항목 정리
|
||||||
|
- Playwright로 상신 시 라운드 번호 증가, 상태 표시 라벨(제N라운드) 확인 완료, 콘솔 에러 0건
|
||||||
|
|
||||||
|
## 2026-09-16 — 등급 확장 재구현 + 등급 1·3 데이터 보강, 문서 체계 정비
|
||||||
|
|
||||||
|
## 2026-09-16 — 등급 확장 재구현 + 등급 1·3 데이터 보강, 문서 체계 정비
|
||||||
|
|
||||||
|
**무엇을 왜:** 2026-09-15에 서울(Base) PC에서 Remote-SSH로 작업한 등급 1~10 확장·주목도
|
||||||
|
시스템·5장 포커 핸드·토큰 1종 통합 작업이 커밋 전 연결 끊김으로 유실됐다. 평택(현재 PC)
|
||||||
|
로컬 체크아웃(`2차 구현 완료` 커밋 시점)에서 전체를 다시 구현했다. 재구현 과정에서
|
||||||
|
`cards.csv` 등급 재매핑이 등급 1·3을 전혀 생성하지 못한다는 문제를 추가로 발견해 같이 고쳤다
|
||||||
|
— 결정 노트: [[2026-09-15 ProjectSS 카드등급확장-주목도시스템]]
|
||||||
|
|
||||||
|
- `core/engine/symbols.js`·`desk.js`·`hands.js`·`council.js`·`shop.js`·`pollutionPool.js`
|
||||||
|
및 관련 컴포넌트 9종 전체를 등급 1~10 / 주목도 / 5장 포커 핸드(8종, 투페어 신설) /
|
||||||
|
토큰 1종 기준으로 재작성
|
||||||
|
- `cards.csv` 43개 선택지, `pollution_pool.csv` 19→27개 항목 등급 재매핑 — (유형, 원본등급)
|
||||||
|
전단사 매핑으로 등급 1~10 전 구간을 채움 (평균 4.14, 8결재 라운드 예상 주목도 33,
|
||||||
|
§4.3 기준선인 경고구간 30~40에 부합). `pollution_pool.csv`에 등급 1·3 전용 신규 항목
|
||||||
|
8개 추가(축별 2개씩)
|
||||||
|
- Playwright로 실제 브라우저 구동 확인 — 등급 1·3 카드가 실제 이름·플레이버와 함께
|
||||||
|
등장하는 것까지 확인, 콘솔 에러 0건
|
||||||
|
- CLAUDE.md 정비: 흩어져 있던 기획 문서 7종 참조를 `ProjectSS_코어시스템_기획서.md` 단일
|
||||||
|
참조(§ 매핑 포함)로 교체, Phase 진행 표를 없애고 §17 체크박스가 유일한 진행 상황 원본이
|
||||||
|
되도록 변경, "결정 노트" 규칙 신설(`T-decision.md` 양식 + DEVLOG 위키링크 연동)
|
||||||
|
- `ProjectSS_코어시스템_기획서.md` §17에 실제 구현 상태 체크(1차 전체 완료, 2차는 상점/명분
|
||||||
|
개입이 3차·4차 의존성 때문에 부분 완료)
|
||||||
|
|
||||||
|
## 2026-09-05 — 플레이테스트 UX 피드백 5건 반영
|
||||||
|
|
||||||
|
**무엇을 왜:** 1차 그레이박스를 실제로 플레이한 뒤 나온 문제 5건(책상 정보 부족, 스와이프
|
||||||
|
레이아웃 겹침, 족보 판정 기준 안 보임, 게이지 상승 체감 안 됨, 선택 서사 부족)을 고쳤다.
|
||||||
|
결정 노트: [[2026-09-05_core-graybox-ux-feedback]]
|
||||||
|
|
||||||
|
- `DeskBar`를 축×등급 매트릭스로 확장 + 축 클릭 시 개별 카드 상세 팝업(`DeskDetailPopup`) 추가
|
||||||
|
- `CoreSwipeCard` 레이아웃 변경 — 좌/우 예고를 카드 위 오버레이에서 상단 고정 슬롯으로 이동해
|
||||||
|
결재 카드 본문과 겹치지 않게 함
|
||||||
|
- `HandGuidePopup`(족보표) 신규 — 이 게임 기호로 족보 조건·예시를 보여줌
|
||||||
|
- 게이지 상승 기준을 "카드 장수"에서 "등급 가중 오염 압력"으로 변경 (이후 2026-09-15 결정으로
|
||||||
|
"주목도" 개념으로 흡수·재조정됨)
|
||||||
|
- `pollution_pool.csv`를 처음으로 실제 연결 — 오염 카드가 축 라벨이 아니라 실제 이름·사연을
|
||||||
|
갖게 됨
|
||||||
|
- 부수적으로 `cards.csv` 등급 3~4 선택지 6곳 추가(당시 1~4 스케일 기준)
|
||||||
|
|
||||||
|
## 2026-09-05 — 구현 우선순위 2차 완료 (§17: 상점·규약·실적 감사·명분)
|
||||||
|
|
||||||
|
라운드 구조(결재 8건 → 상점 → 의회 → 다음 라운드) 도입. `ShopScreen`·`CouncilScreen`·
|
||||||
|
`core/engine/council.js`·`core/engine/shop.js` 신규. Playwright로 라운드 전체 구동 확인,
|
||||||
|
콘솔 에러 0건. 플레이는 했지만 이 시점엔 피드백 정리 전이었음(다음날 위 피드백으로 이어짐).
|
||||||
|
|
||||||
|
## 2026-09-05 — 구현 우선순위 1차 완료 (§17: 기호 체계·책상·족보·토큰)
|
||||||
|
|
||||||
|
`client/src/core/` 신설 — 기존 `components/`·`engine/`(GameScreen.jsx 중심 구조)는 참조용으로
|
||||||
|
남기고 새 진입점으로 전환. 축 인장(lucide Tornado/Hand/Megaphone/Stamp) + 등급 점 표기,
|
||||||
|
책상 상시 노출 UI, 3장 족보 판정(당시), 합본 기반 토큰 산출 구현.
|
||||||
|
|
||||||
|
## 2026-09-04 — 코어시스템 기획서 기준 신규 개발 착수
|
||||||
|
|
||||||
|
`rework/combined-filing` 브랜치 생성. 기존 문서 전체를 `old/2026.09.04/`로 이동하고
|
||||||
|
`ProjectSS_코어시스템_기획서.md`를 단일 원본으로 도입 — 기존 코드베이스는 참조용으로만
|
||||||
|
남기고 이 문서 기준으로 신규 개발한다는 방침 확정.
|
||||||
@@ -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 진행 조건 추가.
|
|
||||||
|
|
||||||
@@ -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,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명의 부피를 초과했습니다."
|
||||||
@@ -0,0 +1,932 @@
|
|||||||
|
# Project SS — 코어 시스템 기획서 (신규 개발 기준)
|
||||||
|
|
||||||
|
> **문서 위상**: 본 문서는 Project SS의 **단일 진실 원본(Single Source of Truth)** 이다. 이전 채팅 세션에서 생성된 작업지시서(자산시스템 v1, 전략구조 v2, 코어시스템 v3, 의회경제 v4)는 모두 본 문서로 흡수되었으며 **폐기**한다.
|
||||||
|
> **개발 방식**: 기존 코드베이스를 개선하지 않고 **신규 개발**한다. 기존 리포의 새 브랜치에서 시작하며, 기존 코드는 참조용으로만 남긴다.
|
||||||
|
> **재사용 자산**: 스와이프 제스처·물리 구현, CSV 파이프라인 및 데이터 로더, 디자인 토큰, 프로젝트 셋업.
|
||||||
|
> **보호 파일**: `update_csv.js`, `update_csv_tags.js`는 수정·실행 금지. 스키마 변경이 필요하면 근거 설명 → diff 제시 → 승인 후 진행.
|
||||||
|
> **데이터 원칙**: 카드·태그·규약 데이터의 일괄 자동 생성 금지. 소량 배치로 제안하고 기획자 확인 후 반영한다.
|
||||||
|
> **개발 원칙**: Greybox First. 본 문서의 모든 수치는 가안이며 플레이 테스트로 튜닝한다. 시각 연출·애니메이션은 코어 검증 완료 전까지 구현하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 게임 개요
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 장르 | 서사형 로그라이크 덱빌딩 (오염 관리 + 족보 조합) |
|
||||||
|
| 플랫폼 | 모바일 / Steam |
|
||||||
|
| 주제 | 관료주의 코스믹 호러 — "종말을 결재하시겠습니까?" |
|
||||||
|
| 핵심 재미 | **빌드와 선택의 딜레마** |
|
||||||
|
| 최종 목표 | 수십 종의 파생 엔딩 수집 |
|
||||||
|
|
||||||
|
### 1.1. 핵심 재미 정의 (모든 설계 판단의 기준)
|
||||||
|
|
||||||
|
플레이어는 매 순간 **"지금 정리할 것인가, 더 모아서 크게 처리할 것인가"**를 고민해야 한다. 이 고민이 게이지·경제·정치·빌드 네 곳에 동시에 걸릴 때 게임이 성립한다.
|
||||||
|
|
||||||
|
### 1.2. 주제와 시스템의 일치
|
||||||
|
|
||||||
|
초자연 위협 자체는 맥거핀이다. 진짜 게임은 **위협을 처리하는 과정에서 발생하는 조직적 뒷감당**이다. 정치적으로 살아남으려면(명분) 세계를 썩혀야 한다(오염 적재).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 코어 루프
|
||||||
|
|
||||||
|
> **2026-09-16 결정**: 라운드/회기 이층 구조는 폐지했다. **라운드 하나 = 결재하다 정산(상점)→의회로 이어지는 단일 사이클**이다. 결정 노트: `docs/decisions/2026-09-16-라운드-주목도-하드캡-복원.md`.
|
||||||
|
|
||||||
|
### 2.1. 루프
|
||||||
|
|
||||||
|
```
|
||||||
|
결재 카드 등장 → 좌/우 오염 선택 → 주목도 += 선택한 카드 등급
|
||||||
|
↓
|
||||||
|
총 주목도가 이번 라운드 한도 이하? → 계속 결재할지 자율 선택
|
||||||
|
총 주목도가 이번 라운드 한도 초과? → 그 즉시 라운드 강제 종료(버스트)
|
||||||
|
↓
|
||||||
|
언제든 [상신](합본) 가능 · 한도 밑에서는 언제든 자율 [정산하기]도 가능
|
||||||
|
↓
|
||||||
|
미사용 카드는 다음 라운드로 이월
|
||||||
|
↓
|
||||||
|
[정산 단계] 상점 — 코인으로 자산·소모품·전례 구매
|
||||||
|
↓
|
||||||
|
[의회 단계] 실적 감사(이번 라운드 누적치) + 규약 표결 → 다음 라운드 룰 변경
|
||||||
|
↓
|
||||||
|
다음 라운드 (주목도 한도 상승)
|
||||||
|
```
|
||||||
|
|
||||||
|
반복. 적대 임원 9명 도달 시 탄핵 발생 → 신임 위원회 지명으로 방어.
|
||||||
|
|
||||||
|
### 2.2. 라운드 종료 조건 (블랙잭식 하드 캡)
|
||||||
|
|
||||||
|
라운드는 둘 중 하나로 끝난다 (한도·증가폭 수치는 가안, §20.1 튜닝 대상):
|
||||||
|
|
||||||
|
1. **자율 종료 — "정산하기"**: 주목도가 한도 밑인 동안, 플레이어가 아무 때나 정산하기를 눌러 라운드를 마치고 상점으로 넘어갈 수 있다. "한 장 더 받을까 말까"의 선택권은 항상 플레이어에게 있다.
|
||||||
|
2. **강제 종료 — 버스트**: 결재 직후 총 주목도가 이번 라운드의 한도를 넘으면 그 자리에서 즉시 라운드가 끝난다. **합본 기회 없이** 카드는 그대로 이월된다(§4.1).
|
||||||
|
|
||||||
|
한도는 라운드가 갈수록 커진다: `한도 = 21 + (라운드 번호 − 1) × 5`. 후반 라운드일수록 더 크게 쌓을 수 있지만, 그만큼 게이지·코인 가산도 이미 높은 상태에서 쌓는 것이라 실질 위험은 줄지 않는다.
|
||||||
|
|
||||||
|
> **블랙잭 유비**: 주목도=카드 합, 한도=21(1라운드 기준)이다. 한도 밑에서 멈추는 게 "스탠드", 계속 받는 게 "히트", 넘기면 "버스트"다. §1.1의 핵심 재미("지금 정리할 것인가, 더 모아서 크게 처리할 것인가")가 여기서 가장 직접적으로 드러난다 — 의회의 실적 압박은 합본·상점 자산 구매로 넘어서는 재미로 이어진다.
|
||||||
|
|
||||||
|
### 2.3. 설계 근거
|
||||||
|
|
||||||
|
- **하드 캡 재도입 (2026-09-16, 구 결정 번복)**: 애초엔 하드 캡을 없애고 초과분 소프트 페널티로 대체했으나(§19 참고), 실제로 캡이 없으니 "지금 정리할까 더 모을까"의 리스크가 사라져 그 고민 자체가 무의미해졌다. 최초 임계치(21)는 낮게, 라운드가 갈수록 한도가 커지는 성장 곡선으로 초반 로그라이크 가혹함 문제를 완화했다.
|
||||||
|
- **상신은 언제든 가능**: 카드가 2장뿐이어도 상신할 수 있다.
|
||||||
|
- **자율 종료도 언제든 가능**: 한도 밑이면 원하는 시점에 정산으로 넘어갈 수 있다.
|
||||||
|
- **버스트해도 이월은 자동**: 강제 종료로 합본을 못 해도 카드는 소멸하지 않고 다음 라운드로 넘어간다 (§4.1). 그 라운드에서 놓친 코인·명분·실적 기회 자체가 손해다 — 별도 징벌이 필요 없다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 기호 체계 (최우선 구현)
|
||||||
|
|
||||||
|
모든 카드가 동일한 기호 언어를 사용한다.
|
||||||
|
|
||||||
|
> **핵심 요건: 스와이프 판단은 기호만 보고 0.5초 내에 이루어져야 한다.** 텍스트는 서사·플레이버를 담당하고, 게임플레이 판단은 전적으로 기호가 담당한다.
|
||||||
|
|
||||||
|
### 3.1. 축 인장 (무늬)
|
||||||
|
|
||||||
|
| 인장 | 축 | 대응 게이지 | 태그값 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 소용돌이 | **위협** | 엔트로피 | `axis_threat` |
|
||||||
|
| 손 | **인사** | 조직 저항 | `axis_hr` |
|
||||||
|
| 확성기 | **공보** | 대중 공황 | `axis_pr` |
|
||||||
|
| 도장 | **대외** | 위험 수용 계수 | `axis_external` |
|
||||||
|
|
||||||
|
> **UI 필수**: 4종 인장은 **실루엣 형태**로 구분한다. 색상만 다른 구현은 소형 화면에서 판별 불가하므로 금지.
|
||||||
|
|
||||||
|
### 3.2. 기밀 등급 (숫자, 1~10)
|
||||||
|
|
||||||
|
| 등급 범위 | 명칭 |
|
||||||
|
|---|---|
|
||||||
|
| 1~2 | 일반 |
|
||||||
|
| 3~4 | 대외비 |
|
||||||
|
| 5~6 | 2급 기밀 |
|
||||||
|
| 7~8 | 1급 기밀 |
|
||||||
|
| 9~10 | 특급 기밀 |
|
||||||
|
|
||||||
|
> **표기 방식 확정**: 점(●) 표기는 10단계를 감당하지 못해 폐기한다. 카드 중앙에 **큰 숫자**를 직접 표기하고 축 인장은 모서리에 작게 배치한다 — 트럼프 카드의 랭크+슈트 레이아웃을 그대로 차용한다.
|
||||||
|
> ```
|
||||||
|
> ◈ ✋
|
||||||
|
> 7 10
|
||||||
|
> (위협) (인사)
|
||||||
|
> ```
|
||||||
|
> 등급 범위 명칭은 서사·문체용 라벨일 뿐이며, 합본 판정·등급 합계 계산 등 모든 게임 로직은 항상 원 숫자(1~10)를 사용한다.
|
||||||
|
|
||||||
|
### 3.3. 스와이프
|
||||||
|
|
||||||
|
| 방향 | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| 좌 / 우 | 두 개의 오염 카드 선택지 중 하나를 책상에 적재. **코인 증감 없음** |
|
||||||
|
| 위 | 보류 (1~2칸 소형 슬롯, 보조 장치) |
|
||||||
|
|
||||||
|
**프리뷰 표기**
|
||||||
|
```
|
||||||
|
[ 좌 ] [ 우 ]
|
||||||
|
◈ 7 ✋ 10
|
||||||
|
(위협 축) (인사 축)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4. 책상 상태 상시 노출 (필수)
|
||||||
|
|
||||||
|
```
|
||||||
|
◈ 3 ✋ 1 📢 2 ⬛ 0 [총 6장 · 주목도 34]
|
||||||
|
```
|
||||||
|
|
||||||
|
> 이 표시가 없으면 합본 설계가 불가능하며 스와이프는 무의미한 도박이 된다. **1순위 구현 항목.** 주목도(등급 합계)는 §4.2에서 상세히 다룬다 — 축별 개수뿐 아니라 등급 합계 총량이 항상 보여야 "한 장 더 받을지" 판단이 가능하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 책상 (Desk)
|
||||||
|
|
||||||
|
### 4.1. 원칙
|
||||||
|
|
||||||
|
- 오염 카드는 덱에 들어가지 않는다. 드로우·셔플 개념 없음.
|
||||||
|
- 슬롯 **무제한**. 상한을 두면 압박이 상한선에 갇힌다.
|
||||||
|
- 축별 스택으로 그룹화 표시 (개별 카드 나열 금지 — 20장 이상 시 UI 붕괴).
|
||||||
|
- **이월**: 합본에 사용되지 않은 카드는 소멸하지 않고 책상에 남아, 다음 라운드에도 계속 주목도에 반영된다. 별도 이월 시스템을 구현할 필요 없이 "합본이 닿은 카드만 사라진다"는 규칙 하나로 자연히 성립한다.
|
||||||
|
|
||||||
|
### 4.2. 주목도와 패시브 페널티 (누진 필수)
|
||||||
|
|
||||||
|
> 카드 등급이 1~10으로 확장되며, 페널티 기준을 **장수**가 아니라 **등급 합계**로 삼는다. 이 등급 합계를 **주목도**라 부른다 — 처리하지 않은 사안이 무거울수록 조직 안팎의 시선이 쏠린다는 관료제적 의미다.
|
||||||
|
|
||||||
|
```
|
||||||
|
주목도(축별) = 해당 축에 적재된 카드들의 등급 합 → A. 게이지 상승
|
||||||
|
주목도(총계) = 책상 전체 카드들의 등급 합 (이월 포함) → C. 코인 지출 가산
|
||||||
|
주목도(이번 라운드) = 이번 라운드에 받은 카드들의 등급 합 → B. 라운드 한도 판정
|
||||||
|
```
|
||||||
|
|
||||||
|
> **2026-09-17 결정**: 한도 판정만 "이번 라운드분"으로 분리한다. 한도 판정에 총계를 쓰면 이월 카드(§4.1) 때문에 라운드가 시작하자마자 이미 한도를 넘어 있어 **버스트가 무한 반복**된다(실제 플레이에서 라운드가 계속 자동으로 넘어가는 버그로 드러남). 블랙잭에서 각 핸드가 0부터 시작하듯, 한도는 "이번 판에 새로 떠안은 무게"만 센다. 이월된 카드의 압박은 사라지지 않고 총계 기준인 게이지 상승·코인 가산으로 계속 작용한다. 결정 노트: `2026-09-17-주목도-라운드-분리`.
|
||||||
|
|
||||||
|
**A. 게이지 상시 상승** — 축별 **카드 장수**를 1차 기준으로, 등급 배율을 곱해 매 결재마다 대응 게이지 상승 (가안, 플레이 후 조정)
|
||||||
|
|
||||||
|
```
|
||||||
|
결재당 상승(%) = 장수 구간 기본값 × (해당 축 평균 등급 / 5)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 해당 축 카드 장수 | 기본값 |
|
||||||
|
|---|---|
|
||||||
|
| 1~2장 | 0.5% |
|
||||||
|
| 3~4장 | **3.0%** |
|
||||||
|
| 5~6장 | 6.0% |
|
||||||
|
| 7~9장 | 10.0% |
|
||||||
|
| 10장 이상 | 15.0%, 이후 1장마다 +2.0%씩 추가 누진 |
|
||||||
|
|
||||||
|
> **2026-09-17 결정 (재설계, 주목도 단독 기준 폐기)**: 등급 합계(주목도)만으로 상승률을 정하면 실제 카드 등급 분포(대부분 1~4급에 몰림)에서 같은 축 카드를 여러 장 쌓아도 낮은 구간에 계속 머물러 위협이 전혀 느껴지지 않았다(플레이 피드백: "누적 카드 3장 이상부터 증가값이 커졌으면 함"). 그래서 1차 기준을 장수로 되돌리되, 등급이 여전히 의미를 갖도록 평균 등급 배율(÷5, 등급 5가 기준점)을 곱한다 — 저등급만 쌓이면 완만하고 고등급이 섞이면 같은 장수에서도 훨씬 아프다. 결정 노트: `2026-09-17-게이지상승-장수기준-복원`.
|
||||||
|
|
||||||
|
**B. 라운드 종료 판단 (블랙잭식 하드 캡, 2026-09-16 재도입)**
|
||||||
|
|
||||||
|
플레이어는 매 카드 선택 직후 **이번 라운드 주목도**를 한도와 비교해 계속 받을지 정산할지 스스로 판단한다. 한도를 넘기면 그 자리에서 즉시 라운드가 강제 종료된다 — §2.2 참고.
|
||||||
|
|
||||||
|
```
|
||||||
|
한도(라운드) = 21 + (라운드 번호 − 1) × 5 + 이득 규약 보너스 (가안, §20.1 튜닝 대상)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 이번 라운드 주목도 (한도 대비) | 상태 |
|
||||||
|
|---|---|
|
||||||
|
| 한도 − 5 미만 | 자동 진행. 별도 경고 없음 |
|
||||||
|
| 한도 − 5 ~ 한도 | **경고 구간** — 곧 넘긴다는 시각 경고, 정산할지 고려 |
|
||||||
|
| 한도 초과 | **버스트** — 그 즉시 라운드 강제 종료. 합본 기회 없이 카드는 이월(§4.1) |
|
||||||
|
|
||||||
|
> **UI 필수**: 이번 라운드 주목도와 이월분을 구분해 보여줄 것. 이월분이 안 보이면 게이지가 왜 계속 오르는지 알 수 없고, 합쳐 보이면 한도까지 얼마 남았는지 오독한다. "정산하기" 버튼은 결재 카드 바로 아래에 둔다 — 툴바 구석에 있으면 핵심 결정 버튼인 줄 모른다(플레이테스트 확인).
|
||||||
|
|
||||||
|
> **결정 이력**: 최초엔 "주목도 21 초과 시 즉시 강제 종료"안을 로그라이크 초반에 과하다는 이유로 기각하고 초과분 누진 페널티(소프트 오버런)로 대체했었다. 그런데 하드 캡이 없으니 "지금 정리할까 더 모을까"의 실제 리스크가 사라져 핵심 재미(§1.1)가 실종됐다 — 그래서 하드 캡을 되살리되, 한도 자체가 라운드마다 커지는 성장 곡선을 더해 초반 가혹함 문제만 따로 해결했다. 상세 근거는 결정 노트 `2026-09-16-라운드-주목도-하드캡-복원` 참고.
|
||||||
|
|
||||||
|
**C. 코인 지출 가산** — 총 주목도에 따라 모든 코인 지출에 가산
|
||||||
|
|
||||||
|
| 총 주목도 | 가산 |
|
||||||
|
|---|---|
|
||||||
|
| 0~20 | +0 |
|
||||||
|
| 21~40 | +1 |
|
||||||
|
| 41~60 | +2 |
|
||||||
|
| 61 이상 | +4 |
|
||||||
|
|
||||||
|
**D. 임계 사건 트리거** — 특정 축 주목도가 임계 도달 시 사건/뉴스 발생
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "axis": "axis_threat", "threshold": 15, "event": "myth_incident_pool", "showWarning": true }
|
||||||
|
```
|
||||||
|
> `showWarning`: 임계치 근접(예: 3 이내)부터 해당 축 인장에 경고 표시. 예고선이 보여야 "닿기 전에 정리하자"는 목표가 생긴다.
|
||||||
|
|
||||||
|
### 4.3. 죽음의 나선
|
||||||
|
|
||||||
|
무제한 슬롯 + 누진 페널티는 필연적으로 나선(적재→비용 증가→처리 불가→적재)을 만든다. 의도된 설계이며 관료제 붕괴의 은유다.
|
||||||
|
|
||||||
|
단, **대규모 합본이 판을 뒤집을 수 있어야 한다.** 5장 합본(§5)의 페널티 제거량을 충분히 크게 설정하여 카타르시스를 보장할 것.
|
||||||
|
|
||||||
|
> **튜닝 기준선 (가안, 플레이 후 조정)**: 한도 근접(한도−5) = "슬슬 위험, 정산할지 고민", 한도 초과 = "버스트로 이번 라운드 합본 기회를 날림".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 합본 결재 (족보)
|
||||||
|
|
||||||
|
> **설계 원칙**: 트럼프 족보 구조를 그대로 차용하고 명칭과 기호만 관료제로 치환한다. 새 규칙을 발명하지 않는다. 플레이어의 기존 트럼프 지식이 그대로 통해야 학습 비용이 0에 수렴한다.
|
||||||
|
|
||||||
|
### 5.1. 족보표 (5장 기준)
|
||||||
|
|
||||||
|
> 카드 등급이 10단계로 확장되며, 조합 판정을 **여러 장 쌓은 카드 중 최고의 5장**을 뽑는 방식으로 재정의한다. 트럼프의 "7장 중 최고 5장" 판정과 동일한 구조이며, 이 역시 플레이어의 기존 지식을 그대로 재사용한다.
|
||||||
|
|
||||||
|
| 트럼프 대응 | 족보명 | 조건 | 명분 | 실적 점수 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 페어 | **중복 접수** | 같은 등급 2장 | 0.5 (혼합) | 10 |
|
||||||
|
| 투페어 | **이중 접수** | 같은 등급 2장 × 2쌍 | 1 (혼합) | 20 |
|
||||||
|
| 트리플 | **집단 민원** | 같은 등급 3장 | 1 | 25 |
|
||||||
|
| 스트레이트 | **단계적 상신** | 연속 등급 5장 | 2 (혼합) | 40 |
|
||||||
|
| 플러시 | **일괄 처리** | 같은 축 5장 | 2 (축 전용) | 40 |
|
||||||
|
| 풀하우스 | **종합 감사** | 같은 등급 3장 + 같은 등급 2장 | 3 | 60 |
|
||||||
|
| 포카드 | **전면 개편** | 같은 등급 4장 | 4 | 90 |
|
||||||
|
| 스트레이트 플러시 | **전결 처리** | 같은 축 + 연속 등급 5장 | 5 | 150 |
|
||||||
|
|
||||||
|
> **전부 가안, 플레이 후 조정.** 다만 상하위 배율 격차는 의도적으로 크게 벌렸다 — 구 3장·4등급 체계에서는 조합 횟수가 사실상 무제한이라 격차가 작아도 무방했지만, 이제 "한 번의 상신을 최대한 크게 만들고 싶다"는 동기 자체가 설계 목적이므로 격차가 곧 그 동기를 만드는 장치다.
|
||||||
|
|
||||||
|
### 5.1.1. 조합 가능 장수
|
||||||
|
|
||||||
|
- 합본에는 **최소 2장, 최대 5장**을 사용한다 (족보표 기준).
|
||||||
|
- 책상에 쌓아둘 수 있는 카드 수 자체는 §4.2의 주목도 규칙에 따라 사실상 자유이나, **5장 내외를 손에 쥔 상태에서 최고 족보를 노리는 것**이 표준 플레이 리듬이 되도록 수치를 맞춘다.
|
||||||
|
- 합본에 사용되지 않은 나머지 카드는 소멸하지 않고 책상에 남는다 (§4.1 이월 원칙).
|
||||||
|
|
||||||
|
### 5.2. 합본 보상
|
||||||
|
|
||||||
|
1. **페널티 소멸** — 사용된 오염 카드가 책상에서 **완전 소멸**하여 패시브 페널티 즉시 감소 (가장 큰 유인)
|
||||||
|
2. **명분 산출** — 축이 종류를 결정 (§7)
|
||||||
|
3. **코인 획득** — 등급 합계가 양을 결정 (§8.2)
|
||||||
|
4. **실적 점수** — §9.3
|
||||||
|
5. **자산 트리거** — 패턴 반응형 자산 발동
|
||||||
|
|
||||||
|
> **완전 소멸 원칙**: 합본에 사용된 카드는 강등·잔류 없이 사라진다. 프로메테우스 랩의 '배양 합본'(§13.3)이 이 기본 규칙을 깨는 유일한 예외이므로, 기본이 완전 소멸이어야 해당 특기가 성립한다.
|
||||||
|
|
||||||
|
### 5.3. 구현 사양
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"combine": {
|
||||||
|
"selectedCards": ["poll_001", "poll_004", "poll_009", "poll_011", "poll_014"],
|
||||||
|
"detectedPattern": "flush_axis",
|
||||||
|
"output": {
|
||||||
|
"removeCards": true,
|
||||||
|
"justification": { "type": "just_threat", "amount": 2 },
|
||||||
|
"token": 18,
|
||||||
|
"performanceScore": 40,
|
||||||
|
"triggerAssets": ["on_combine", "on_combine_flush"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 책상에서 카드 다중 선택 → 성립 가능한 족보를 **자동 판정하여 표시** → 확정.
|
||||||
|
- 여러 족보가 동시 성립 시 **최고 족보를 우선 표시**하되, 플레이어가 하위 족보를 선택할 수 있어야 한다 (명분 종류를 고르기 위해 일부러 하위 족보를 쓰는 선택이 존재해야 함).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 파라미터
|
||||||
|
|
||||||
|
### 6.1. 4대 게이지 (0~100%, 1~5단계)
|
||||||
|
|
||||||
|
| 게이지 | 대응 축 | 100% 도달 시 |
|
||||||
|
|---|---|---|
|
||||||
|
| **엔트로피** | 위협 | 세계 멸망 엔딩 |
|
||||||
|
| **조직 저항** | 인사 | 사임 엔딩 |
|
||||||
|
| **대중 공황** | 공보 | 문명 붕괴 엔딩 |
|
||||||
|
| **위험 수용 계수** | 대외 | (즉사 아님) 임원 적대 전환 가속 — §9.5 |
|
||||||
|
|
||||||
|
### 6.2. 위험 수용 계수의 역할
|
||||||
|
|
||||||
|
즉사 게이지가 아니라 **정치 압력 게이지**다. 수치가 높을수록:
|
||||||
|
- 매 의회마다 임원 우호도가 적대 방향으로 이동하는 폭이 커진다
|
||||||
|
- 상정되는 규약 후보가 강경해진다
|
||||||
|
|
||||||
|
즉 대외 축 오염 방치 → 위험 수용 계수 상승 → 적대 임원 증가 → 탄핵. 다른 축과 달리 **정치 경로로 죽는다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 명분 (Justification)
|
||||||
|
|
||||||
|
### 7.1. 산출
|
||||||
|
|
||||||
|
- **종류**: 합본에 사용된 카드의 축이 결정. 축이 섞이면 **혼합 명분**(범용·약효).
|
||||||
|
- **수량**: 족보별로 §5.1 표의 명분 열을 따른다. 등급 8 이상(1급 기밀 이상) 카드가 포함되면 +1.
|
||||||
|
- **보관**: 최대 5개. **다음 의회까지만 유효하며 이후 소멸.**
|
||||||
|
|
||||||
|
> **유통기한은 필수.** 무한 저장 시 플레이어가 명분을 쌓아 한 번에 터뜨려 정치가 무의미해진다. 관료제 정합성도 확보된다 — 명분은 시의성이 생명이며 다음 라운드로 넘기면 효력을 잃는다.
|
||||||
|
|
||||||
|
### 7.2. 명분은 4종을 유지한다 (코인과의 차이)
|
||||||
|
|
||||||
|
코인은 **양의 재화**이므로 1종으로 통합한다(§8). 명분은 **질의 재화**다 — 어떤 축을 합본했느냐가 어떤 정치 카드를 쥐었느냐로 직결되고, 이념색 페널티까지 따라붙는다. 1종으로 통합하면 "이번엔 절차 명분이 필요하니 대외 합본을 하자"는 계획이 사라지고 축 4개를 나눈 이유 자체가 소멸한다.
|
||||||
|
|
||||||
|
> **역할 분담**: 코인이 **양**을, 명분이 **질**을 담당한다. 합본 시 **축은 명분 종류를, 등급 합계는 코인 양을** 결정한다.
|
||||||
|
|
||||||
|
### 7.3. 용도 (4갈래)
|
||||||
|
|
||||||
|
**① 안건 조작** — 규약 후보 3개가 상정된 화면에서 직접 조작한다.
|
||||||
|
|
||||||
|
| 명분 | 조작 |
|
||||||
|
|---|---|
|
||||||
|
| **절차 명분** (대외) | 후보 1개를 **다음 라운드로 이월** (지금은 피하지만 나중에 다시 온다) |
|
||||||
|
| **여론 명분** (공보) | 후보 1개 **강제 탈락** + 새 후보 1개 무작위 교체 |
|
||||||
|
| **위기 명분** (위협) | 누적된 **징벌 규약 1개 조기 해제** |
|
||||||
|
| **내부 명분** (인사) | 후보 1개 **강제 상정** (표결 자체는 그대로. 원하는 이득 규약을 올릴 때 사용) |
|
||||||
|
| **혼합 명분** | 표결 결과 1표 뒤집기 (지정 후보 +1, 선두 −1) |
|
||||||
|
|
||||||
|
> **혼합 명분은 1표만 움직인다.** +1/−1이라 표 차를 2 좁히지만, 동점은 원래 표가 많던 쪽이 이기므로 **실제로 뒤집히는 건 1표 차 승부일 때뿐**이다. 그래서 의회 화면은 후보별 예상 득표와 1·2위 표 차를 미리 보여줘야 한다 — 표 차를 모르면 쓸지 말지 판단할 수 없다. (§9.2 13석 표결)
|
||||||
|
|
||||||
|
**② 정보 획득**
|
||||||
|
|
||||||
|
| 명분 | 효과 |
|
||||||
|
|---|---|
|
||||||
|
| 여론 명분 | 임원 1명의 실제 성향 공개 |
|
||||||
|
| 내부 명분 | 특정 임원의 과거 표결 이력 전체 열람 |
|
||||||
|
|
||||||
|
**③ 임원 직접 관리** — 명분 2개로 특정 임원의 우호도를 1단계 이동.
|
||||||
|
|
||||||
|
> 표를 사는 것이 아니라 **다음 라운드 규약 후보군을 바꾸는 장기 투자**다. 개입 액션 삭제로 비어버린 "임원에게 직접 작용하는" 자리를 메우되, 즉시 표결이 아니라 구도에 작용한다는 점에서 성격이 다르다.
|
||||||
|
|
||||||
|
**④ 실적 납입** — 명분 1개 = 실적 20점 (§9.3)
|
||||||
|
|
||||||
|
> 명분에는 유통기한(다음 의회까지)이 걸려 있으므로, 매 라운드 "이번 명분을 어디에 태울 것인가"가 실제 고민이 된다. 소모처가 4갈래여야 명분이 남아도는 재화가 되지 않는다.
|
||||||
|
|
||||||
|
### 7.4. 이념색 페널티 (딜레마의 본체)
|
||||||
|
|
||||||
|
명분 사용 시 대응 성향 태그가 누적되고 이념 축 반대편 파벌의 우호도가 하락한다.
|
||||||
|
|
||||||
|
| 명분 | 우호도 상승 | 우호도 하락 |
|
||||||
|
|---|---|---|
|
||||||
|
| 위기 | 철의 수호당, 순수 인간당 | 지식 보존당, 고대 공존당 |
|
||||||
|
| 여론 | 세계 재건당 | 침묵의 장막당 |
|
||||||
|
| 절차 | 침묵의 장막당 | 세계 재건당 |
|
||||||
|
| 내부 | — | 현장 요원 노조 급락 |
|
||||||
|
|
||||||
|
> **설계 의도**: 이번 라운드 규약을 막으려고 쓴 명분이 다음 라운드 규약 후보군을 바꾼다. **눈앞의 방어 vs 장기 정치 지형**의 딜레마.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 경제
|
||||||
|
|
||||||
|
### 8.1. 원칙
|
||||||
|
|
||||||
|
> **결재 단계에서 코인 증감을 완전히 제거한다.** 코인이 결재 순간에 개입하면 "어느 축의 오염을 받을까"라는 질문이 "예산이 부족하니 오른쪽"으로 덮어씌워진다. 손패가 없어 코인을 배분할 수도 없으므로, 결재 단계의 코인 수급은 선택을 왜곡하기만 한다.
|
||||||
|
|
||||||
|
### 8.2. 코인은 1종 (합본에서만 산출)
|
||||||
|
|
||||||
|
코인은 상점 재화이므로 종류를 나눌 이유가 없다. **단일 코인**으로 통합한다.
|
||||||
|
|
||||||
|
**산출량 = 합본에 사용된 카드의 기밀 등급 합계** (가안: 등급 1장당 등급 수치만큼)
|
||||||
|
|
||||||
|
> 예: 2급 + 3급 + 3급 합본 → 코인 8
|
||||||
|
>
|
||||||
|
> 축은 명분 종류를, 등급은 코인 양을 결정한다. 따라서 플레이어는 **"어떤 축을 모을까"(정치)와 "어떤 등급을 모을까"(경제)를 별개로 판단**하게 된다. 고등급 오염은 위험하지만 돈이 된다.
|
||||||
|
|
||||||
|
### 8.3. 상점 (정산 단계)
|
||||||
|
|
||||||
|
라운드 종료 후, 의회 진입 전에 배치. "예산 집행 내역 결재" 형태로 연출.
|
||||||
|
|
||||||
|
| 품목 | 비고 |
|
||||||
|
|---|---|
|
||||||
|
| **자산 구매** | 주 소비처 |
|
||||||
|
| **오염 카드 직접 처리** | 족보가 안 맞을 때의 탈출구. 명분·실적 없음 |
|
||||||
|
| **소모품(뉴스 카드) 구매** | 인장·등급 변경 도구 |
|
||||||
|
| **전례 매입** | 내러티브 태그 획득 |
|
||||||
|
|
||||||
|
모든 품목은 단일 코인으로 지불한다.
|
||||||
|
|
||||||
|
**진열 규칙**: 매 정산마다 무작위 4~6개 진열, 1회 리롤 가능(코인 소모). 이번 회차 지부장 파벌에 따라 특정 아키타입 자산 등장률 ×3, 가중 파벌이 없는 아키타입은 S급 미등장.
|
||||||
|
|
||||||
|
### 8.4. 초기 지급
|
||||||
|
|
||||||
|
취임 시 기본 코인 지급 + 첫 합본까지의 유예 필요. 코인 0으로 아무것도 못 하는 데드락 구간이 발생하지 않도록 할 것.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 의회 (보스 기믹)
|
||||||
|
|
||||||
|
### 9.1. 구조
|
||||||
|
|
||||||
|
매 라운드 의회는 **두 가지**를 수행한다. 이 둘은 **독립적**이다.
|
||||||
|
|
||||||
|
1. **규약 결정** — 표결로 다음 라운드 룰이 바뀐다 (정치 축)
|
||||||
|
2. **실적 감사** — 실적 점수 미달 시 징벌 규약 부과 (감사 축)
|
||||||
|
|
||||||
|
> **분리 원칙 (필수)**: 실적을 잘 채웠다고 우호적 규약이 상정되어서는 안 된다. 실적은 **징벌 규약의 유무만** 결정하고, 어떤 규약이 상정되는지는 **오직 파벌 구도**가 결정한다.
|
||||||
|
>
|
||||||
|
> 목표 체험: **"일은 잘했는데 정치적으로 죽는다."**
|
||||||
|
|
||||||
|
### 9.2. 규약 (Protocol)
|
||||||
|
|
||||||
|
통과 시 **다음 라운드 내내** 적용되는 룰 변경. 매 라운드 후보 3개가 상정되고 13인 표결로 1개가 통과한다.
|
||||||
|
|
||||||
|
> **플레이어는 표결에 직접 개입할 수 없다.** 명분으로 안건 자체를 조작할 뿐이다. 표를 사는 게임이 아니라 **어떤 규약이 올라오는 판인지를 장기적으로 관리**하는 게임이다.
|
||||||
|
|
||||||
|
**유형 A. 족보표 변경 (핵심 압박)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "protocol_flush_ban",
|
||||||
|
"name": "동일 부서 일괄 처리 금지 규약",
|
||||||
|
"type": "hand_modifier",
|
||||||
|
"effect": { "disableHand": "flush_axis" },
|
||||||
|
"duration": 1,
|
||||||
|
"flavor": "동일 부서 건을 묶어 올리는 관행이 유착으로 지적되었습니다. 개별 상신할 것. 의결됨."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
특정 족보 무효화 / 필요 장수 +1 / 특정 축 합본 금지 / 최소 등급 제한.
|
||||||
|
**노선별로 아픈 규약이 다르다** — 우로보로스는 연번 봉쇄, 살롱은 축 합본 봉쇄가 치명적.
|
||||||
|
|
||||||
|
**유형 B. 금리형 결정 (판 전체 변수)**
|
||||||
|
|
||||||
|
| 규약 | 효과 |
|
||||||
|
|---|---|
|
||||||
|
| 위기 대응 기준 강화 | 게임오버 게이지 최대치 100% → 90% |
|
||||||
|
| 결재 효율 재산정 | 합본 명분 산출 -1 |
|
||||||
|
| 미결 서류 관리 규정 | 패시브 페널티 누진 1단계 가속 |
|
||||||
|
| 예산 긴축 | 상점 가격 1.5배 |
|
||||||
|
| 보고 체계 간소화 | (이득) 모든 합본 필요 장수 -1 |
|
||||||
|
|
||||||
|
**유형 C. 징벌 규약** — 실적 미달 시에만 자동 부과, **영구 누적, 상한 없음**. 결재당 게이지 추가 상승 / 특정 축 코인 산출 봉쇄 / 상점 슬롯 감소 등. 해제 수단: 위기 명분, 또는 다음 라운드 실적 초과 달성.
|
||||||
|
|
||||||
|
> **상한을 두지 않는 이유**: 매 라운드 부과되는 것이 아니라 실적 미달 시에만 쌓이므로 회복 불가 상태로 직행하지 않는다. 오히려 게이지가 차지 않고 탄핵도 오지 않는 안정 상태에서 런이 무한정 늘어지는 것을 막는 **자연스러운 종료 압력**으로 기능한다. 누적될수록 실적 달성이 어려워지므로 결국 종결로 수렴한다.
|
||||||
|
>
|
||||||
|
> **UI 처리**: 누적 5개 초과 시 목록을 접거나 아이콘 스택으로 압축 표시할 것. 상시 전체 나열은 화면을 잠식한다.
|
||||||
|
|
||||||
|
**유형 D. 이득 규약** — 드물게 통과. 자산 지급, 족보 완화, 코인 지급.
|
||||||
|
|
||||||
|
구현된 5종(가안): 코인 즉시 지급 / 결재당 게이지 상승 완화 / 코인 지출 가산 완화 / **주목도 한도 증가** / 실적 요구치 하향. 후보 3개 중 하나가 D일 확률은 30% — §9.2의 "드물게 통과"를 확률로 옮긴 값이다. 자산 지급형은 자산 시스템(§10, 3차)이 생긴 뒤 추가한다.
|
||||||
|
|
||||||
|
> **내부 명분과의 연결**: 유형 D가 없으면 §7.3의 내부 명분("후보 강제 상정")이 불리한 규약만 올릴 수 있어 쓸 이유가 없다. 상정 가능 목록에 이득 규약이 포함되는 것이 내부 명분의 존재 이유다.
|
||||||
|
|
||||||
|
### 9.3. 실적 감사
|
||||||
|
|
||||||
|
> **라운드당 합본 횟수를 강제하지 않는다.** 처리 장수나 합본 횟수를 요구하면 플레이어 선택이 감소하고 전략성이 떨어진다. **다중 통화 결산**으로 여러 경로를 허용한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
실적 점수 = Σ(이번 라운드 중 상신한 모든 족보 점수) + (코인 납입 × 5) + (명분 납입 × 20)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 한 라운드 안에서 합본(상신)을 몇 번 하든 — 자율 종료 전까지 여러 번 상신할 수도, 버스트로 강제 종료돼 한 번도 못 할 수도 있다 — 그동안 상신한 족보 점수를 전부 합산한다.
|
||||||
|
|
||||||
|
- 요구치는 라운드마다 상승 (가안: 1라운드 25 → 라운드당 +20)
|
||||||
|
- **미달을 감수하는 것도 유효한 선택으로 설계할 것.** 징벌 규약 1개를 받고 자원을 아끼는 판단이 성립해야 실적이 강제가 아닌 협상이 된다.
|
||||||
|
|
||||||
|
**UI**
|
||||||
|
```
|
||||||
|
[제3차 정례회의 요구 실적: 65]
|
||||||
|
현재 확보: 50 (족보 25 + 코인 납입 25)
|
||||||
|
부족: 15 → 명분 1개 납입 시 충족
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.4. 13인 위원회
|
||||||
|
|
||||||
|
- **상임 이사회 8명**: 고정 이념. 국장의 성향 태그에 직접 반응.
|
||||||
|
- **글로벌 지부장 5명**: 12개 파벌 풀에서 매 회차 무작위 선발. 실리(코인·자산·축 처리 실적)에 반응.
|
||||||
|
|
||||||
|
### 9.5. 정체 추론 (아발론 구조)
|
||||||
|
|
||||||
|
**우호도 비공개**: 수치가 아닌 추정 등급으로만 표시 — `동맹 / 우호 / 중립 / 적대 / 파국`
|
||||||
|
|
||||||
|
**이중첩자**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"doubleAgent": {
|
||||||
|
"count": 1,
|
||||||
|
"pool": "global_directors",
|
||||||
|
"displayedFavor": "friendly",
|
||||||
|
"actualFavor": "hostile",
|
||||||
|
"revealOn": ["vote_history_inference", "justification_investigation", "impeachment_failure"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
매 회차 지부장 중 1~2명이 실제로는 반대 진영. 표시는 우호이나 실제 표는 반대로 나간다.
|
||||||
|
|
||||||
|
**단서 = 표결 이력**: 매 라운드 표결 결과가 기록되고 **언제든 열람 가능한 UI**로 제공된다. "우호 표시인데 반대표를 던진" 이력이 쌓이면 정체가 드러난다. 즉시 확인은 명분 조사 또는 코인 지불.
|
||||||
|
|
||||||
|
> **필수 요건**: 정보가 부족하면 추론이 아니라 운이 된다. 배신자 1명 기준 **3~4라운드면 이력만으로 특정 가능**하도록 표결 빈도와 이력 가시성을 확보할 것.
|
||||||
|
|
||||||
|
### 9.6. 탄핵 = 신임 위원회 지명
|
||||||
|
|
||||||
|
- **발생 조건**: 적대 파벌 임원이 9명(2/3)에 도달하는 즉시 발생.
|
||||||
|
- **방어 방식**: 국장이 **임원 3명을 지명**하여 신임 위원회를 구성. 지명된 3인의 표가 가중치를 갖고 방어 성패가 결정된다.
|
||||||
|
- 진짜 우군만 골라내면 방어 성공
|
||||||
|
- 이중첩자를 포함시키면 실패
|
||||||
|
- **자원 소모 없음.** 지금까지 관찰한 표결 이력을 걸고 하는 판단이다.
|
||||||
|
|
||||||
|
> 자원이 고갈된 플레이어도 관찰만 잘했다면 살아남을 수 있어야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 자산 (엔진)
|
||||||
|
|
||||||
|
### 10.1. 문법
|
||||||
|
|
||||||
|
자산 1장 = **트리거 → 효과 → (선택) 성장**
|
||||||
|
|
||||||
|
### 10.2. 아키타입 (개발 층 — 플레이어에게 노출하지 않음)
|
||||||
|
|
||||||
|
| 아키타입 | 정의 |
|
||||||
|
|---|---|
|
||||||
|
| `purge` | 책상의 오염 직접 제거 |
|
||||||
|
| `convert` | 오염을 코인·명분으로 환전 |
|
||||||
|
| `quarantine` | 오염을 페널티 없는 상태로 묶어두되 조건부 복귀 |
|
||||||
|
| `skip` | 패시브 페널티 무시·경감 |
|
||||||
|
|
||||||
|
### 10.3. 증폭 타입
|
||||||
|
|
||||||
|
| 타입 | 정의 | 우선순위 |
|
||||||
|
|---|---|---|
|
||||||
|
| **패턴 반응형** | 특정 족보 성립 시 발동 (`on_combine_flush` 등) | **최우선 — 빌드의 척추** |
|
||||||
|
| **적재 반응형** | 오염 적재 시 발동 (`on_pollution_gain`) | 높음 |
|
||||||
|
| **누적형** | 스택 영구 누적 후 임계마다 발동 | 높음 |
|
||||||
|
| **재발동형** | 다른 자산 효과 복제·재트리거 | 중간 |
|
||||||
|
| **코인 환산형** | 보유 코인·명분 비례 효과 | 중간 |
|
||||||
|
| **메타형** | 게이지·자산 수·책상 상태 조건 | 중간 |
|
||||||
|
|
||||||
|
> **누적형·재발동형은 노선 무관으로 등장시켜 범용 증폭기 역할을 부여할 것.** 발라트로에서 성장형·재배치형 조커가 어떤 빌드에도 꽂히는 것과 같다.
|
||||||
|
|
||||||
|
### 10.4. 데이터 스키마
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "asset_purge_001",
|
||||||
|
"name": "문서 파쇄실",
|
||||||
|
"grade": "C",
|
||||||
|
"archetype": "purge",
|
||||||
|
"amplifier": "periodic",
|
||||||
|
"trigger": { "type": "every_n_approvals", "params": { "n": 5 } },
|
||||||
|
"effect": { "type": "purge_lowest", "params": { "count": 1 } },
|
||||||
|
"growth": null,
|
||||||
|
"risk": null,
|
||||||
|
"cost": { "token": "budget", "amount": 8 },
|
||||||
|
"faction_link": "scavengers",
|
||||||
|
"description": "결재 5건마다 책상에서 최하 등급 오염 카드 1장을 영구 소각한다.",
|
||||||
|
"flavor": "파쇄 용량: 일일 1건. 증설 요청은 3회 반려됨."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**트리거 enum**: `on_pollution_gain` / `on_combine` / `on_combine_{족보}` / `on_swipe_left` / `on_token_spend` / `every_n_approvals` / `on_council_end` / `on_purge` / `on_convert` / `manual_charge`
|
||||||
|
|
||||||
|
**효과 enum**: `purge_lowest` / `purge_axis` / `convert_to_token` / `convert_to_justification` / `quarantine` / `negate_penalty` / `gain_token` / `change_seal` / `change_grade` / `reduce_required_cards`
|
||||||
|
|
||||||
|
> **구현 노트**: 효과 실행부가 반드시 이벤트를 발행해야 `on_purge`/`on_convert` 연쇄가 성립한다. 무한 루프 방지를 위해 결재 1건당 연쇄 깊이 상한 10회.
|
||||||
|
|
||||||
|
### 10.5. 획득 경로
|
||||||
|
|
||||||
|
| 경로 | 방식 |
|
||||||
|
|---|---|
|
||||||
|
| 상점 구매 | 주 경로. 코인 소비 |
|
||||||
|
| 극비 프로젝트 결재 | 스와이프 중 등장, 즉시 획득 |
|
||||||
|
| 이득 규약 | 의회 표결 결과 보상 |
|
||||||
|
|
||||||
|
### 10.6. 등급 정의
|
||||||
|
|
||||||
|
효과 크기가 아니라 **문법 복잡도**로 구분한다.
|
||||||
|
- **C**: 트리거+효과 단문 (엔진 부품)
|
||||||
|
- **B**: 조건부 강화
|
||||||
|
- **A**: 성장형 (스택 누적)
|
||||||
|
- **S**: 규칙 왜곡 (예: "격리 실패가 성공으로 취급된다")
|
||||||
|
|
||||||
|
### 10.7. UI 필수 요건
|
||||||
|
|
||||||
|
- 자산 탭 시 정보 팝업: 이름, 등급, **트리거 → 효과 한 문장**, 성장형이면 현재 스택
|
||||||
|
- **발동 로그**: 화면 한쪽에 `『문서 파쇄실』 발동 — [일반/위협] 소각됨` 1줄 출력. 연쇄 시 순서대로 출력.
|
||||||
|
|
||||||
|
> 발동 로그는 발라트로의 조커 점멸에 해당하는 최소 구현이다. **이것이 없으면 엔진이 돌아도 체감되지 않는다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 내러티브 태그 = 전례(前例)
|
||||||
|
|
||||||
|
### 11.1. 역할
|
||||||
|
|
||||||
|
태그 획득 시 그 사건이 조직의 전례로 남아 **합본 규칙을 회차 내 영구 변형**한다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "tag_cover_up_z7",
|
||||||
|
"name": "7구역 은폐 성공",
|
||||||
|
"precedent": { "type": "reduce_required_cards", "axis": "axis_pr", "amount": 1 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2. 전례 효과 유형 (5종으로 제한)
|
||||||
|
|
||||||
|
> 태그마다 고유 효과를 전부 다르게 만들지 말 것. 학습 부담이 폭발한다.
|
||||||
|
|
||||||
|
1. `reduce_required_cards` — 특정 축 합본 필요 장수 감소
|
||||||
|
2. `bonus_justification` — 특정 축 합본 시 명분 +1
|
||||||
|
3. `modify_penalty` — 특정 축 패시브 페널티 증감
|
||||||
|
4. `change_justification_type` — 특정 축 합본의 산출 명분 종류 변경
|
||||||
|
5. `modify_slot` — 보류 슬롯 증감
|
||||||
|
|
||||||
|
### 11.3. 금지 사항
|
||||||
|
|
||||||
|
- **태그를 소모 재화로 쓰지 말 것** — 페이즈 전환 카운터가 깨진다. 누적만 되며 소모되지 않는다.
|
||||||
|
- **태그가 직접 명분·코인을 산출하지 말 것** — 명분 인플레이션으로 합본할 이유가 사라진다. 태그는 **규칙만 바꾸고 재화는 만들지 않는다.**
|
||||||
|
|
||||||
|
### 11.4. 연대기 시스템
|
||||||
|
|
||||||
|
연대기 팝업은 감상용 읽을거리가 아니라 **"현재 내 합본 규칙표"**다. 태그 목록 = 전례 목록 = 유효 규칙 목록으로 표시한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 소모품 (뉴스 이벤트)
|
||||||
|
|
||||||
|
일회성 조작 도구. 상점 구매 또는 임계 사건(§4.2-C)으로 획득.
|
||||||
|
|
||||||
|
- 오염 카드 1장의 **축 인장 변경**
|
||||||
|
- 오염 카드 1장의 **등급 변경**
|
||||||
|
- 합본 1회 **조건 무시 성립**
|
||||||
|
- (양날) 특정 축 오염 즉시 추가 + 강력한 조작 효과
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 노선 (Route)
|
||||||
|
|
||||||
|
### 13.1. 5대 노선
|
||||||
|
|
||||||
|
| 노선 | 위협 태도 | 조직 유형 | 상징 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **이지스 재단** (Aegis Foundation) | 맞선다 | 재단 | 방패 중앙에 새겨진 감긴 눈 |
|
||||||
|
| **프로메테우스 랩** (Prometheus Lab) | 길들인다 | 연구소 | 사슬에 묶인 이중나선, 중심에 작은 불꽃 |
|
||||||
|
| **우로보로스 협의회** (Ouroboros Council) | 이해한다 | 길드 | 자기 꼬리를 삼키는 뱀 |
|
||||||
|
| **살롱 드 미다스** (Salon de Midas) | 우회한다 | 클럽 | 황금 가면 위에 남은 손자국 |
|
||||||
|
| **보이드 생텀** (Void Sanctum) | 숭배한다 | 컬트 | 촉수 문양이 새겨진 성유물함 |
|
||||||
|
|
||||||
|
> **상징 해석 (설정·텍스트 확장용)**
|
||||||
|
> - 이지스: 아이기스는 고르곤의 머리가 새겨진 방패. 정면으로 마주하되 함부로 시선을 맞추지 않는다.
|
||||||
|
> - 프로메테우스: 훔친 불(생명·창조의 원리)을 실험실 안에 가두고 배양한다.
|
||||||
|
> - 우로보로스: 순환하는 지식. 끝이 시작을 삼킨다.
|
||||||
|
> - 미다스: 손이 닿은 자리마다 금으로 변한다. 가면조차 그들의 손을 거치면 금빛으로 물든다.
|
||||||
|
> - 보이드 생텀: 빛이 닿지 않는 성소에서 형체 없는 것을 향해 봉헌한다.
|
||||||
|
|
||||||
|
### 13.2. 각성 (발견형)
|
||||||
|
|
||||||
|
> 노선은 취임 시 선언하지 않는다. **반복한 족보 패턴**이 각성을 유발한다.
|
||||||
|
|
||||||
|
| 노선 | 친화 족보 |
|
||||||
|
|---|---|
|
||||||
|
| 이지스 재단 | 중복 접수 · 집단 민원 (같은 등급, 안정형) |
|
||||||
|
| 프로메테우스 랩 | 전면 개편 (같은 등급 4장, 고등급 집중) |
|
||||||
|
| 우로보로스 협의회 | 단계적 상신 (연번) |
|
||||||
|
| 살롱 드 미다스 | 일괄 처리 (같은 축) |
|
||||||
|
| 보이드 생텀 | 합본하지 않고 적재 유지 |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"awakening": {
|
||||||
|
"threshold": 15,
|
||||||
|
"exclusive": true,
|
||||||
|
"onAwaken": ["unlock_combine_specialty", "unlock_route_ending_goal", "apply_faction_favor_shift", "apply_route_text_variant"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 각성 이전에는 **표준 족보만** 사용 가능. 각성 순간 합본 특기가 해금된다.
|
||||||
|
- 회차당 1회, 배타적. **각성 없이 런을 마칠 수 있으며 무소속 플레이도 유효한 선택지로 둔다.**
|
||||||
|
- **UI 필수**: 각성 전 친화도 상위 2개 노선을 힌트로 상시 노출 (`▲ 우로보로스 11 / 이지스 6`). 힌트가 없으면 각성은 무작위 이벤트로 체감된다.
|
||||||
|
|
||||||
|
> **설계 의도**: "계속 연번을 맞추고 있었는데, 각성하니 그 연번이 순환하기 시작한다" — 빌드가 완성되는 순간의 쾌감.
|
||||||
|
|
||||||
|
### 13.3. 합본 특기 (각성 시 해금)
|
||||||
|
|
||||||
|
| 노선 | 특기 | 상징 연결 |
|
||||||
|
|---|---|---|
|
||||||
|
| **이지스 재단** | **정면 대응** — 모든 합본의 필요 장수 -1. 단 명분 산출 없음 (페널티 제거·코인만) | 방패는 막을 뿐 공세로 나아가지 않는다. 기준선 노선이므로 규칙이 가장 단순 |
|
||||||
|
|
||||||
|
| **프로메테우스 랩** | **배양 합본** — 합본 사용 카드 1장을 소멸시키지 않고 '검체'로 남겨 다음 합본에 재사용 | 훔친 불을 가둬 배양한다 |
|
||||||
|
| **우로보로스 협의회** | **순환 합본** — 단계적 상신 성립 시 마지막 카드가 처음으로 돌아가 연쇄 발동 | 꼬리를 삼키는 뱀 |
|
||||||
|
| **살롱 드 미다스** | **금빛 치환** — 합본 전 오염 1장의 축 인장을 임의 변경 (라운드당 2회) | 소관 부서를 바꾸는 정치력 = 원하는 명분 제조 |
|
||||||
|
| **보이드 생텀** | **봉헌** — 합본하지 않고 오염을 안치. 페널티는 유지되나 명분과 처리 배율 산출 | 정리하지 않고 바친다 |
|
||||||
|
|
||||||
|
> **이지스 재단 설계 의도 (확정)**: 오염 처리는 가장 쉽지만 명분이 나오지 않아 의회에서 고전하는 구조. "위협은 잘 막지만 정치는 못 하는 조직"이라는 컨셉과 메커니즘이 일치한다. 다만 명분이 완전히 0이면 의회에서 아무 조작도 못 해 답답할 수 있으므로, 그레이박스에서 **「명분 산출 없음」과 「명분 절반(내림)」 두 안을 비교 검증**할 것.
|
||||||
|
|
||||||
|
### 13.4. 노선의 의미 부여는 텍스트가 담당
|
||||||
|
|
||||||
|
> **메커니즘은 족보, 서사는 텍스트.** 노선을 축에 1:1 매핑하지 않는다. 매핑하면 노선당 축이 고정되어 합본 선택의 폭이 사라진다.
|
||||||
|
|
||||||
|
각성 후 오염 카드와 축의 **서술 방식**이 노선에 맞게 바뀐다.
|
||||||
|
- 우로보로스 각성 → 위협 오염이 "관측 대상"으로 서술
|
||||||
|
- 보이드 생텀 각성 → "봉헌 대상"
|
||||||
|
- 프로메테우스 각성 → "검체"
|
||||||
|
|
||||||
|
**노선별 문체 및 처리 스탬프**
|
||||||
|
|
||||||
|
| 노선 | 문체 | 스탬프 |
|
||||||
|
|---|---|---|
|
||||||
|
| 이지스 재단 | 표준 행정 보고서체 | `처리됨` |
|
||||||
|
| 프로메테우스 랩 | 실험 기록·검체 번호 중심 | `검체 등록됨` |
|
||||||
|
| 우로보로스 협의회 | 각주·인용·편찬 주석체 | `색인됨` |
|
||||||
|
| 살롱 드 미다스 | 회원 명부·초대장·회계 장부체 | `의결됨` |
|
||||||
|
| 보이드 생텀 | 전례문·봉헌 기록체 | `봉헌됨` |
|
||||||
|
|
||||||
|
> 메커니즘 용어와 문체 용어를 일치시킨다. 프로메테우스의 '검체'는 실제 재사용 오염 카드의 이름이고, 보이드의 '봉헌'은 실제 액션명이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 진행 및 페이징
|
||||||
|
|
||||||
|
내러티브 태그 누적 개수에 따라 페이즈가 전환되며, 등장 카드 풀과 오염 공급량이 변한다.
|
||||||
|
|
||||||
|
| 페이즈 | 태그 수 | 성격 |
|
||||||
|
|---|---|---|
|
||||||
|
| **1** | 0~3 | 사태 파악. 오염 공급 완만, 저등급 위주 |
|
||||||
|
| **2** | 4~7 | 본격화. 자산·전례 빌드업 구간, 중등급 증가 |
|
||||||
|
| **3** | 8+ | 종말 카운트다운. 고등급 오염 집중, 규약 누적으로 족보 봉쇄 다발 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 엔딩
|
||||||
|
|
||||||
|
| 분류 | 조건 |
|
||||||
|
|---|---|
|
||||||
|
| **기본 붕괴** | 엔트로피 100% (세계 멸망) / 조직 저항 100% (사임) / 대중 공황 100% (문명 붕괴) / 탄핵 방어 실패 (정치적 탄핵) |
|
||||||
|
| **변주 붕괴** | 기본 붕괴 + 특정 코인·자산·전례 조합 보유 |
|
||||||
|
| **노선 엔딩** | 각성한 노선의 전용 조건 달성 (각성 시 목표로 제시) |
|
||||||
|
| **전례 연쇄 엔딩** | 특정 전례를 정해진 순서로 수립 |
|
||||||
|
|
||||||
|
**노선 전용 엔딩 (가안)**
|
||||||
|
|
||||||
|
| 노선 | 엔딩 | 조건 |
|
||||||
|
|---|---|---|
|
||||||
|
| 이지스 재단 | 완전한 인계 | 4대 게이지 2단계 이하 유지 + 최대 라운드 도달 |
|
||||||
|
| 프로메테우스 랩 | 사슬을 놓다 | 검체 5개 이상 보유 + 엔트로피 붕괴 |
|
||||||
|
| 우로보로스 협의회 | 마지막 각주 | 전례 연쇄 3회 + 순환 합본 10회 |
|
||||||
|
| 살롱 드 미다스 | 가면무도회의 폐회 | 이중첩자 전원 특정 + 탄핵 방어 성공 |
|
||||||
|
| 보이드 생텀 | 인양된 왕관 | 안치 8장 이상 + 엔트로피 100% |
|
||||||
|
|
||||||
|
**설계 규칙**
|
||||||
|
1. 목표 달성은 보너스이지 승리 조건이 아니다. 실패해도 다른 엔딩이 나온다.
|
||||||
|
2. 조건 일부는 의도적으로 자해적이어야 한다. 평소 피하던 행동을 하게 만들어야 선택이 재해석된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 텍스트 스타일 가이드
|
||||||
|
|
||||||
|
- **관료적 거리**: 감정 형용사·괴수 외형 묘사 금지. 결과 보고서 형식 유지.
|
||||||
|
- **숫자 1개**: 스니펫당 구체적 숫자는 정확히 하나.
|
||||||
|
- **인정 유예**: 감정적 인식은 엔딩·연대기로 유예한다.
|
||||||
|
- **스탬프 필수**: 모든 스니펫은 관료적 도장으로 끝난다 (`처리됨` / `종결됨` / `이관됨` / `미반영됨` 등, 노선 각성 시 §13.4 표로 대체).
|
||||||
|
- **비율**: 시스템형 70% / 인간형 30%. 인간형은 개인의 희생을 데이터 이상치로 매립하며 `snippet_type: "human"` 태그를 부여해 엔딩 콜백에 사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 구현 우선순위
|
||||||
|
|
||||||
|
> **진행 상황 표기**: `[x]` 완료 · `[~]` 부분 완료(범위 축소·자리표시자 있음) · `[ ]` 미착수.
|
||||||
|
> 이 체크박스가 진행 상황의 유일한 원본이다(CLAUDE.md에 복제하지 않는다). 상세 이력·미해결
|
||||||
|
> 사항은 `docs/DEVLOG.md`, 그 배경·근거는 `docs/decisions/`의 결정 노트 참고.
|
||||||
|
|
||||||
|
### 1차 — 코어 검증 (여기까지만 되어도 재미 판정 가능)
|
||||||
|
1. [x] **기호 체계 + 책상 상태 상시 노출 UI** (§3)
|
||||||
|
2. [x] **책상 적재 + 누진 패시브 페널티 A·B** (§4)
|
||||||
|
3. [x] **표준 족보 판정 및 합본 실행** (§5) — §2.2 주목도 하드 캡(버스트)·자율 정산 로직까지 포함
|
||||||
|
4. [x] **합본 기반 코인 산출** (§8.2)
|
||||||
|
|
||||||
|
### 2차 — 압박 완성
|
||||||
|
5. [~] **상점** (§8.3) — "오염 카드 직접 처리"만 구현. 자산 구매·소모품·전례 매입은 3차가
|
||||||
|
먼저 있어야 성립해서 아직 없음
|
||||||
|
6. [x] **규약 시스템 — 유형 A 족보표 변경 우선** (§9.2) — 유형 A 7종 + 감사 실패용 유형 C 2종
|
||||||
|
(5개 초과 시 목록 압축 표시 포함) + 이득 유형 D 5종. 13석 표결을 실제 표 수로 구현하고
|
||||||
|
예상 득표를 의회 화면에 공개한다. 유형 B(금리형)만 범위 밖(§20.2)
|
||||||
|
7. [x] **실적 감사 + 다중 통화 결산** (§9.3)
|
||||||
|
8. [~] **명분 산출 및 규약 개입** (§7) — 절차·여론·위기·내부·혼합 5갈래 전부 구현. 혼합 명분은
|
||||||
|
더 이상 자리표시자가 아니라 §7.3 스펙대로 **실제 1표 이동**이다(1표 차에서만 뒤집힘).
|
||||||
|
다만 §7.3 ②정보 획득·③임원 직접 관리는 임원 개개인이 없어 미구현 — 4차에서 13인 위원회의
|
||||||
|
우호도·이중첩자(§9.5)가 생기면 지금의 무작위 표 배분도 "임원별 투표"로 교체한다
|
||||||
|
|
||||||
|
### 3차 — 빌드 및 리플레이성
|
||||||
|
9. [ ] **자산 시스템 + 발동 로그** (§10)
|
||||||
|
10. [ ] **노선 친화도 + 각성 + 합본 특기** (§13)
|
||||||
|
11. [ ] **전례 시스템** (§11)
|
||||||
|
12. [ ] **소모품** (§12)
|
||||||
|
|
||||||
|
### 4차 — 정치 심화
|
||||||
|
13. [ ] **우호도 비공개 + 표결 이력 UI** (§9.5)
|
||||||
|
14. [ ] **이중첩자 + 신임 위원회 지명** (§9.6)
|
||||||
|
15. [ ] **엔딩 분기 + 연대기** (§15, §11.4)
|
||||||
|
|
||||||
|
### 5차 — 비주얼 폴리싱
|
||||||
|
그레이박스 검증 완료 전까지 착수 금지. [ ] 미착수
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. 플레이 테스트 체크리스트
|
||||||
|
|
||||||
|
**코어 (1차 완료 시점)**
|
||||||
|
- [ ] 스와이프 시 기호만 보고 0.5초 내에 판단 가능한가? 텍스트를 읽어야 판단되면 실패다.
|
||||||
|
- [ ] 책상 상태를 보며 "위협 하나 더 받으면 일괄 처리 성립"을 즉시 계산하게 되는가?
|
||||||
|
- [ ] 합본 성립 순간에 카타르시스가 있는가?
|
||||||
|
- [ ] 오염 적재가 실제로 목을 조이는 압박으로 느껴지는가?
|
||||||
|
- [ ] "지금 정리할까, 더 모아서 크게 먹을까"의 고민이 매 결재마다 발생하는가?
|
||||||
|
|
||||||
|
**압박 (2차)**
|
||||||
|
- [ ] 규약 통과 시 "다음 라운드가 힘들어지겠다"는 긴장이 발생하는가?
|
||||||
|
- [ ] 족보 봉쇄 규약이 빌드에 따라 다르게 아픈가?
|
||||||
|
- [ ] 실적 미달을 감수하는 선택이 실제로 성립하는가?
|
||||||
|
- [ ] "일은 잘했는데 정치적으로 죽는" 상황이 발생하는가?
|
||||||
|
- [ ] 상점이 코인 획득에 의미를 부여하는가?
|
||||||
|
|
||||||
|
**빌드 (3차)**
|
||||||
|
- [ ] 자산 발동 로그로 엔진이 도는 것이 체감되는가?
|
||||||
|
- [ ] 각성 전 힌트를 보고 의도적으로 족보 패턴을 조정하게 되는가?
|
||||||
|
- [ ] 각성 순간에 "빌드가 완성됐다"는 쾌감이 있는가?
|
||||||
|
- [ ] 5개 노선의 플레이 패턴이 실제로 다르게 느껴지는가?
|
||||||
|
- [ ] 보이드 생텀(합본 안 하는 노선)이 유효한 전략으로 성립하는가?
|
||||||
|
|
||||||
|
**정치 (4차)**
|
||||||
|
- [ ] 표결 이력만으로 이중첩자를 3~4라운드 내에 추론 가능한가?
|
||||||
|
- [ ] 신임 위원회 지명 순간에 긴장이 있는가, 아니면 찍는 느낌인가?
|
||||||
|
- [ ] 자원이 없어도 관찰만으로 탄핵 방어가 가능한가?
|
||||||
|
|
||||||
|
**최우선**
|
||||||
|
- [ ] **핵심 재미인 '빌드와 선택의 딜레마'가 구현되었는가?**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. 결정 완료 사항 (기록)
|
||||||
|
|
||||||
|
설계 논의로 확정된 항목. 구현 시 임의로 변경하지 말 것.
|
||||||
|
|
||||||
|
| 항목 | 결정 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| 코인 종류 | **1종 통합** | 상점 재화이므로 양만 의미 있음. 종류 분화는 번거로움만 추가 |
|
||||||
|
| 명분 종류 | **4종 유지** | 질의 재화. 통합 시 축 선택의 의미가 소멸 |
|
||||||
|
| 합본 사용 카드 | **완전 소멸** | 프로메테우스 '배양'이 예외로 성립하려면 기본이 소멸이어야 함 |
|
||||||
|
| 이지스 합본 특기 | **필요 장수 -1, 명분 없음** | 합본은 쉽고 정치는 어려운 구조가 노선 컨셉과 일치 |
|
||||||
|
| 징벌 규약 상한 | **없음 (무제한 누적)** | 실적 미달 시에만 부과. 런의 무한 연장을 막는 종료 압력으로 기능 |
|
||||||
|
| 기밀 등급 범위 | **1~10 (구 1~4에서 확장)** | 4단계로는 족보 성립이 너무 쉬워 조합 선택의 매력이 없었음 (플레이 피드백) |
|
||||||
|
| 등급 표기 | **점(●) 폐기, 숫자 직접 표기** | 10단계는 점으로 못 셈. 트럼프 랭크+슈트 레이아웃 차용 |
|
||||||
|
| 족보 기준 장수 | **3장 → 5장** | 10등급 스케일에 맞춰 포커의 "7장 중 5장" 구조로 전환 |
|
||||||
|
| 페널티 기준 | **카드 장수 → 등급 합계(주목도)** | 등급 편차가 커진 만큼 심각도를 반영해야 압박이 정직해짐 |
|
||||||
|
| 라운드 종료 방식 | **하드 캡 재도입 — 한도(21+라운드당 +5) 초과 시 강제 종료(버스트)** | 한때 소프트 오버런(하드 캡 없음)으로 바꿨으나, 캡이 없으니 "지금 정리할까 더 모을까"의 실제 리스크가 사라져 핵심 재미(§1.1)가 실종됨. 초반 가혹함은 낮은 초기 한도 + 라운드별 성장 곡선으로 완화. 결정 노트: `2026-09-16-라운드-주목도-하드캡-복원` |
|
||||||
|
| 라운드/회기 이층 구조 | **폐지 — 단일 라운드로 환원** | 라운드마다 정산·의회를 거치는 게 핵심 재미(합본·상점으로 의회 압박을 넘어서는 것)였는데, 이층 구조는 그 사이클을 회기 단위로 늘려 압박 빈도를 실수로 낮췄음. 결정 노트: `2026-09-16-라운드-주목도-하드캡-복원` |
|
||||||
|
|
||||||
|
## 20. 미결정 사항
|
||||||
|
|
||||||
|
### 20.1. 플레이 테스트 후 튜닝 (지금 결정 불가)
|
||||||
|
- 이중첩자 수 1명 vs 2명 (2명이면 추론 난도 급상승)
|
||||||
|
- 신임 위원회 지명 인원 3명의 적정성
|
||||||
|
- 실적 요구치 상승 곡선 (라운드당 +20 가안)
|
||||||
|
- 코인 납입 5점 / 명분 납입 20점 환산 비율
|
||||||
|
- 페이즈별 오염 공급량·등급 분포 곡선
|
||||||
|
- 각성 임계치 15의 적정성
|
||||||
|
- 이지스 재단: 「명분 산출 없음」 vs 「명분 절반(내림)」 비교 검증
|
||||||
|
- 합본 코인 산출량 산식 (등급 합계 그대로 vs 계수 적용)
|
||||||
|
- **§4.2-A/C 게이지 상승·코인 가산의 누진 구간 수치 전체 (1~15/16~30/31~40/41+ 등)**
|
||||||
|
- **§5.1 족보표의 명분·실적 점수 배율 (상하위 격차가 지금 과한지 부족한지)**
|
||||||
|
- **§2.2/§4.2-B 라운드 주목도 한도의 시작값(21)·증가폭(+5) 적정성 — 라운드를 몇 번쯤 버틸 수 있어야 하는지와 연동해서 정할 것**
|
||||||
|
|
||||||
|
### 20.2. 콘텐츠 제작 (별도 세션, 소량 배치)
|
||||||
|
- 규약 상세 목록 (유형 A/B/C/D 각 5~8종)
|
||||||
|
- 자산 상세 목록
|
||||||
|
- 오염 카드 및 딜레마 카드 텍스트
|
||||||
|
- 전례(내러티브 태그) 목록
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 21. 데이터 스키마 확정 (Claude Code 조사 후 결정)
|
||||||
|
|
||||||
|
기존 `cards.csv`/`assets.json`/`narrative_tags.json`을 새 코어 시스템으로 마이그레이션하며 확정된 사항. 구현 시 임의로 변경하지 말 것.
|
||||||
|
|
||||||
|
| 항목 | 결정 |
|
||||||
|
|---|---|
|
||||||
|
| 카드 파일 구조 | `cards.csv`(딜레마 카드)와 `pollution_pool.csv`(오염 카드 풀)로 **분리**. 하나의 헤더에 다른 스키마를 공백으로 욱여넣지 않는다 |
|
||||||
|
| 딜레마 카드 축 매핑 | 구 `res`→인사, `ent`→위협, `pan`→공보, `rsk`→대외 (1:1 대응 확정) |
|
||||||
|
| 등급 변환 | 원값 1→일반(1) / 2→대외비(2) / 3→2급기밀(3). 4급(1급기밀)은 신규 콘텐츠에서만 사용 |
|
||||||
|
| 다축 카드 처리 | 2개 이상 축에 값이 걸린 카드는 **자동 변환 금지**. 별도 목록으로 뽑아 소량 배치 수동 재설계 |
|
||||||
|
| `pollution_pool.csv` 스키마 | `id, axis, grade, name, flavor, tag` |
|
||||||
|
| 자산 증폭 타입 | §10.3에 **7번째 타입 `periodic`(주기형) 추가**. `every_n_approvals`류의 순수 카운터 트리거가 여기 속함 |
|
||||||
|
| 고아 effect 2건 | `convert_multiplier`, `quarantine_fail_to_purge` **폐기**. 후자는 §13.3 보이드 생텀 '봉헌' 특기로 대체됨. 재설계 필요 시 §20.2에서 새 effect enum으로 재작성 |
|
||||||
|
| `faction_link` 필드 | asset 개별 스키마에서 **삭제**. 아키타입↔파벌 가중치(§8.3)는 자산 단위가 아닌 아키타입 단위로 작동하므로, 별도 파일 `archetype_faction_weight.json`으로 분리 관리 |
|
||||||
|
| 아키타입↔파벌 가중치 | purge→잔해 청소부 길드 / convert→차원 무역당·블랙마켓 카르텔 / quarantine→오컬트 수집가 협회 / skip→부패 관료 연합 (배수 ×3, v2 §7.1과 동일) |
|
||||||
|
|
||||||
|
> **주의**: 지부장 파벌(잔해 청소부 길드 등 12개 풀)과 노선(이지스 재단 등 5종, §13)은 서로 다른 레이어다. 파벌은 아키타입에 연결되고, 노선은 족보 패턴에 연결된다. 혼동하여 매핑하지 말 것.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 22. 자산 스키마 확정 (2차 — 마이그레이션 중 발견된 사항)
|
||||||
|
|
||||||
|
### 22.1. 트리거 enum 수정 — `on_swipe_left` 제거
|
||||||
|
|
||||||
|
> **§10.4 트리거 enum을 다음으로 갱신한다**: `on_pollution_gain` / `on_combine` / `on_combine_{족보}` / `on_token_spend` / `every_n_approvals` / `on_council_end` / `on_purge` / `on_convert` / `manual_charge`
|
||||||
|
|
||||||
|
`on_swipe_left`는 폐기한다. §3.3에서 좌/우 스와이프는 "오염 카드 선택지 둘 중 하나"일 뿐 구v1의 좌=수용/우=회피 비대칭이 사라졌으므로, 좌측 한정 트리거는 존재 이유가 없다. `on_pollution_gain`(오염 획득 시, 좌우 무관)이 그 역할을 전부 포함한다. 기존 데이터에서 `on_swipe_left`를 쓰던 자산은 `on_pollution_gain`으로 이관한다.
|
||||||
|
|
||||||
|
### 22.2. 증폭 타입 canonical enum 문자열
|
||||||
|
|
||||||
|
§10.3의 한글 명칭에 대응하는 구현용 문자열을 다음으로 확정한다.
|
||||||
|
|
||||||
|
| 한글 명칭 | enum 문자열 |
|
||||||
|
|---|---|
|
||||||
|
| 패턴 반응형 | `pattern_reactive` |
|
||||||
|
| 적재 반응형 | `pollution_reactive` |
|
||||||
|
| 누적형 | `cumulative` |
|
||||||
|
| 재발동형 | `repeater` |
|
||||||
|
| 코인 환산형 | `coin_scaling` |
|
||||||
|
| 메타형 | `meta` |
|
||||||
|
| **주기형** (§21 ③에서 추가) | `periodic` |
|
||||||
|
|
||||||
|
### 22.3. 발동 방식 필드 신설 — `activation`
|
||||||
|
|
||||||
|
증폭 타입과 별개로, 조건 충족 시 **자동 발동**하는지 **플레이어가 소비 시점을 선택**하는지를 구분하는 필드를 추가한다. 8번째 증폭 타입을 만들지 않고 직교 속성으로 처리한다(전례 효과를 5종으로 제한한 것과 같은 원칙 — 타입 증식 방지).
|
||||||
|
|
||||||
|
```json
|
||||||
|
"activation": "auto" | "manual"
|
||||||
|
```
|
||||||
|
`periodic`이면서 `manual`인 조합(충전은 자동, 소비는 수동)이 존재할 수 있다. 예: 결재권 반려 도장.
|
||||||
|
|
||||||
|
### 22.4. pollution_pool 태그 충돌 처리
|
||||||
|
|
||||||
|
원본 오염 카드 템플릿 행이 좌/우 서로 다른 태그를 가졌던 경우(3건), 하나를 버리지 않고 **동일 축·등급의 별도 풀 엔트리 2개로 분리**한다(id에 `a`/`b` 접미사). 무작위 추첨 풀의 변형 다양성이 늘어나는 것이므로 손실이 아니다.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
type: decision
|
||||||
|
status: decided
|
||||||
|
created: 2026-09-05
|
||||||
|
updated: 2026-09-05
|
||||||
|
tags: [core-system, graybox, ux, balance, desk, hands]
|
||||||
|
impact:
|
||||||
|
- client/src/core/engine/desk.js
|
||||||
|
- client/src/core/engine/pollutionPool.js (신규)
|
||||||
|
- client/src/core/components/DeskBar.jsx
|
||||||
|
- client/src/core/components/DeskDetailPopup.jsx (신규)
|
||||||
|
- client/src/core/components/HandGuidePopup.jsx (신규)
|
||||||
|
- client/src/core/components/CoreSwipeCard.jsx
|
||||||
|
- client/src/core/components/CombinePanel.jsx
|
||||||
|
- client/src/data/cards.csv
|
||||||
|
---
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
구현 우선순위 1차(§17: 기호 체계·책상 적재/누진 페널티 A·B·표준 족보 판정/합본·토큰 산출) 그레이박스를
|
||||||
|
실제로 플레이해 본 뒤 5가지 문제가 보고됐다.
|
||||||
|
|
||||||
|
1. 합본 결재 버튼을 누르기 전에는 현재 책상에 어떤 오염 카드를 보유했는지 알 수 없었다.
|
||||||
|
2. 좌/우 스와이프 시 카드 위에 겹쳐 그리던 오염 예고 정보가 결재 카드 본문 글자를 가렸다.
|
||||||
|
3. 트럼프 족보 구조를 그대로 썼지만(§5.1 설계 원칙), 기호 자체가 다르면 "같은 등급 3장" 같은
|
||||||
|
조건이 바로 안 와닿아 판정 기준을 확인할 방법이 없었다.
|
||||||
|
4. 4대 게임오버 게이지가 §4.2-A 표(장수 기준, 1~3장 +0.5%)대로 상승해서 초반 위기감이 거의 없었다.
|
||||||
|
5. 좌/우 선택 시 "인사/대외/위협/공보" 같은 분류 라벨만 보여서, 결재 카드 본문을 읽을 필요도
|
||||||
|
없어지고 그 선택이 왜 해당 게이지를 올리는지 인과가 안 읽혔다.
|
||||||
|
|
||||||
|
추가로, 4번을 고치는 과정에서 `cards.csv`의 딜레마 카드가 전부 등급 1~2만 생성한다는 사실이
|
||||||
|
드러났다 — 등급 3~4 오염은 `pollution_pool.csv`에 데이터가 있어도 게임에 전혀 등장하지 않는 구조였다.
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
1. **책상 상시 노출 강화** — `DeskBar`를 축별 등급 그룹 칩(`●●○○×2`)까지 보여주도록 확장하고,
|
||||||
|
축 행 클릭 시 `DeskDetailPopup`으로 개별 카드 이름·사연을 볼 수 있게 한다.
|
||||||
|
2. **좌/우 예고를 카드 위 오버레이에서 상단 고정 슬롯(`core-choice-row`)으로 이동** — 드래그 여부와
|
||||||
|
무관하게 항상 노출하고, 카드 본문과 겹치지 않는 별도 영역으로 분리한다.
|
||||||
|
3. **족보표 팝업 추가** — `DeskBar`에 `[족보표]` 버튼을 두고, 트럼프 용어가 아니라 이 게임의
|
||||||
|
축 인장·등급 점 표기로 7종 족보 조건과 예시를 보여주는 `HandGuidePopup`을 연다.
|
||||||
|
4. **게이지 상승 기준을 "장수"에서 "등급 가중 오염 압력(P)"으로 변경** — 등급별 가중치
|
||||||
|
(1=1, 2=2, 3=3, 4=5)를 합산한 P로 상승률을 재산정한다. 구간도 초반 체감이 되도록 올렸다
|
||||||
|
(P 1~2: +1.0%, 3~6: +2.0%, 7~12: +3.5%, 13~20: +6.0%, 21~30: +9.0%, 31+: +13.0% / 결재).
|
||||||
|
현재 축별 상승률은 `DeskBar`에 `▲+X.X%/결재`로 상시 표시한다.
|
||||||
|
5. **오염 카드에 실제 이름·사연 부여** — 그동안 어디서도 쓰이지 않던 `pollution_pool.csv`를
|
||||||
|
`pollutionPool.js`로 연결해, 책상에 쌓이는 카드가 축 라벨이 아니라 구체적인 서류 이름
|
||||||
|
(예: 『항명 성명』)을 갖게 한다. 결재 로그도 `『선택 문구』 결재됨 → 오염 서류 『이름』 접수`
|
||||||
|
형태로 바꿔 선택-결과 인과를 드러낸다. (2번 레이아웃 수정과 합쳐 선택 문구 자체도 상시 노출됨.)
|
||||||
|
6. **등급 3~4 미출현 문제는 카드 데이터에 등급 3~4 선택지를 직접 추가하는 방식(안 ①)으로 해결** —
|
||||||
|
`cards.csv`의 서사적으로 심각한 선택지 6곳의 등급을 올렸다: id=31 좌측("신화적 존재 강림" →
|
||||||
|
현실 조작기 가동)을 1급 기밀(4)로, id=5·12·17·20·26을 2급 기밀(3)로.
|
||||||
|
|
||||||
|
## 검토했으나 기각한 대안
|
||||||
|
|
||||||
|
- **책상 표시를 지금처럼 축별 장수 집계만 유지** — 족보는 등급으로 판정되므로 등급이 안 보이면
|
||||||
|
합본 설계 자체가 불가능하다는 원 기획서 §3.4 취지(예고 없이는 스와이프가 도박이 된다)에
|
||||||
|
정면으로 어긋나 기각.
|
||||||
|
- **등급 3~4 미출현 문제: 안 ② 페이즈 상승 시 같은 선택의 등급을 보정** — 카드 데이터 변경 없이
|
||||||
|
런타임에서 등급을 올리는 방식. 데이터가 명시적이지 않아 플레이어가 "왜 이 선택이 이번엔 더
|
||||||
|
위험한지" 추론하기 어렵다고 보고, 데이터에 등급을 직접 박아 넣는 안 ①을 택함. 안 ③(2차로
|
||||||
|
미루기)도 검토했으나, 1차 그레이박스 검증 자체가 고등급 오염의 압박감을 포함해야 의미가
|
||||||
|
있다고 보고 기각.
|
||||||
|
- **게이지 상승 기준을 유지한 채 구간 상수만 올리는 안** — 장수만 세면 1급 기밀과 일반 문서가
|
||||||
|
똑같이 취급되는 근본 문제가 남는다고 판단해, 등급 가중 압력(P) 도입으로 대체.
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- "밸런스는 플레이해봐야 아는 것"이라는 방향에 따라, 정확한 수치 확정보다 **압박이 실제로
|
||||||
|
체감되는 구조**를 우선했다. 모든 수치는 가안이며 플레이테스트로 계속 조정한다
|
||||||
|
(ProjectSS_코어시스템_기획서.md의 "Greybox First" 원칙과 동일).
|
||||||
|
- §3 기호 체계의 핵심 요건("스와이프 판단은 기호만 보고 0.5초 내에 이루어져야 한다")과, 이번
|
||||||
|
피드백이 지적한 "서사가 안 읽힌다"는 문제는 상충하지 않는다 — 판단은 기호로, 인과·서사는
|
||||||
|
텍스트로 담당하도록 역할을 분리해 둘 다 만족시켰다.
|
||||||
|
- 등급 3~4를 데이터에 직접 추가하는 쪽이, 카드 밸런스와 스토리 개연성을 디자이너가 직접
|
||||||
|
검토할 수 있게 하라는 CLAUDE.md의 데이터 원칙에도 더 부합한다.
|
||||||
|
|
||||||
|
## 재검토 조건 (있다면)
|
||||||
|
|
||||||
|
- 공보(axis_pr) 축은 이번 작업 후에도 등급 2 이상 딜레마 선택지가 하나도 없다 — 다음 카드
|
||||||
|
데이터 배치 작업 때 반드시 재검토.
|
||||||
|
- 게이지 상승률 구간(P 1~2: +1.0% ~ 31+: +13.0%)은 가안 — 몇 라운드 플레이 후 "10장=슬슬 위험,
|
||||||
|
20장=이번 라운드 안에 못 정리하면 끝"(§4.3 기준선)에 맞는지 재확인.
|
||||||
|
- 이번에 등급을 올린 6곳(id=5,12,17,20,26,31)이 스토리 개연성상 적절한지 디자이너 검토 필요.
|
||||||
|
- 혼합축 합본의 토큰 산출 규칙("소량 전종")은 여전히 미확정 가안 — 상점(2차) 구현 시 재검토.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
type: decision
|
||||||
|
status: decided
|
||||||
|
created: 2026-09-14
|
||||||
|
updated: 2026-09-14
|
||||||
|
tags: [ProjectSS, 코어시스템, 합본-결재]
|
||||||
|
impact: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
플레이 테스트 결과 세 가지 문제가 확인됨.
|
||||||
|
- 오염 카드가 책상에 쌓이기만 하고, 증가하는 게이지가 위협적으로 느껴지지 않음
|
||||||
|
- 오염 카드 조합(합본)이 너무 쉽게 성립되어, 좌/우 선택 자체의 전략적 매력이 없음
|
||||||
|
- 조합 시 즉시 얻는 이득이 약해 "조합을 할까 말까"라는 선택 자체의 매력도 낮음
|
||||||
|
|
||||||
|
근본 원인은 기존 4등급 × 3장 조합 체계다. 등급 풀이 좁아 족보가 지나치게 쉽게 완성되고, 조합 횟수 제한이 없어 매 순간 "즉시 정리하는 것"이 항상 최적이었음. 포커처럼 "손이 완성되기 전까지 더 받고 싶은" 심리를 만들고자 했으나, ProjectSS는 카드 수령 시 좌/우 **선택권**이 있다는 점이 트럼프와 근본적으로 다름 — 선택권이 없는 포커의 구조를 그대로 이식할 수 없었음.
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
1. 기밀 등급을 **1~4단계 → 1~10단계**로 확장. 표기는 점(●) 대신 카드 중앙에 **숫자 직접 표기**(트럼프 랭크+슈트 레이아웃 차용)로 변경
|
||||||
|
2. 축 4종(위협/인사/공보/대외)은 그대로 유지 — 4대 게이지와의 1:1 대응 구조를 보존하기 위함
|
||||||
|
3. 합본 판정을 **3장 → 5장 포커 핸드** 구조로 전환 ("여러 장 쌓은 카드 중 최고 5장" — 트럼프의 "7장 중 5장"과 동형). 투페어(이중 접수) 신설, 스트레이트·플러시는 5장 연속/동축 기준으로 재정의
|
||||||
|
4. 상하위 족보 간 명분·실적 점수 배율 격차를 대폭 확대(최저 10점 ~ 최고 150점, 구 대비 격차 약 2배 확장). "한 번의 상신을 최대한 크게 만들고 싶다"는 동기를 의도적으로 부여
|
||||||
|
5. 페널티 산정 기준을 **카드 장수 → 등급 합계**로 전환하고, 이를 **주목도**로 명명. 라운드 진행을 블랙잭식 "계속 받을지 상신할지" 판단 구조로 재편
|
||||||
|
6. 주목도 30~40을 경고 구간, 41 이상을 초과 구간으로 설정하되 **하드 캡(강제 종료) 없이** 초과분에 비례한 소프트 페널티 누진으로 처리
|
||||||
|
7. 합본에 사용되지 않은 카드는 소멸하지 않고 책상에 남아 다음 라운드로 자동 이월 (별도 시스템 불필요 — "합본이 닿은 카드만 소멸한다"는 기존 규칙만으로 성립)
|
||||||
|
|
||||||
|
## 검토했으나 기각한 대안
|
||||||
|
|
||||||
|
- **축 상징을 4개→10개로 늘리는 안**: 4대 핵심 게이지와의 1:1 대응 구조가 깨져 기각. 등급 확장으로 방향을 바꿈
|
||||||
|
- **오염 카드로 인한 게이지 급증 + 조합 시 게이지 즉시 감소**: 게이지가 오르내리면 "세계가 돌이킬 수 없이 망해간다"는 코스믹 호러 테마와 관료제적 비가역성 은유가 훼손됨. 대신 상승 "속도"만 조절하는 방향(패시브 페널티 누진)으로 대체
|
||||||
|
- **주목도 21 초과 시 즉시 강제 라운드 종료(하드 캡)**: 로그라이크 초반부터 한 번의 운으로 라운드 전체를 잃는 구조는 과도한 처벌. 소프트 오버런(초과분 누진 페널티)으로 대체
|
||||||
|
- **조합 필요 장수를 3→4장으로만 상향**: 등급 확장과 효과가 중복되어 기각
|
||||||
|
- **카드 7장 고정 수령 후 라운드 강제 종료**: ProjectSS 고유의 "선택권 있는 드래프트"라는 특성을 살리기 위해, 고정 장수 대신 등급 합계(주목도) 기반의 가변적 종료 조건으로 대체
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
- 트럼프는 카드 수령에 선택권이 없어 "포기 못하고 더 받고 싶은" 긴장이 자연히 생기지만, ProjectSS는 좌/우 선택권이 있으므로 "선택을 잘할수록 더 유리한 위치에서 조합을 노릴 수 있다"는 보상 구조가 필요함 — 이월 규칙이 이를 구현함(잘 남긴 잔여 카드가 다음 판의 유리한 시작점이 됨)
|
||||||
|
- 등급 편차가 커질수록 단순 카드 장수보다 등급 합계가 실제 위협도를 더 정직하게 반영함
|
||||||
|
- 조합 횟수를 여전히 제한하지 않는 대신, 상하위 족보 배율 격차와 주목도 압박이라는 두 축으로 "매 순간 즉시 정리가 최적"이라는 기존 문제를 해소함
|
||||||
|
|
||||||
|
## 재검토 조건 (있다면)
|
||||||
|
|
||||||
|
- 플레이 테스트 결과 평균 카드 보유 장수가 예상(약 6~7장)과 크게 어긋날 경우 등급 분포·주목도 임계치 재조정
|
||||||
|
- 주목도 30/40 경고·초과 구간이 실제로 "계속할지 고민"을 유발하지 못하면 임계치 자체를 재검토
|
||||||
|
- 상하위 족보 배율 격차(최저 10 vs 최고 150)가 실제 플레이에서 하위 족보를 완전히 무의미하게 만들 경우 격차 축소
|
||||||
|
- 숫자+모서리 인장 표기가 실기기 화면에서 가독성 문제를 보이면 표기 방식 재검토
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
created: 2026-09-16
|
||||||
|
---
|
||||||
|
%% 파일명: 2026-09-16-라운드-주목도-하드캡-복원. 규칙 문서엔 규칙만, 이유는 여기에 %%
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
- 라운드/회기 이층 구조를 폐지한다. **라운드 하나 = 결재하다 정산(상점)→의회로 이어지는 단일 사이클**로 되돌린다.
|
||||||
|
- 주목도 하드 캡을 재도입한다. **총 주목도가 이번 라운드의 한도를 넘으면 그 즉시 라운드가 강제 종료**된다(버스트) — 합본 기회 없이, 카드는 이월된다.
|
||||||
|
- 한도 밑에서는 플레이어가 아무 때나 "정산하기"로 자율 종료할 수 있다 (블랙잭의 스탠드).
|
||||||
|
- 한도는 고정값이 아니라 라운드가 갈수록 커진다: `한도 = 21 + (라운드 번호 − 1) × 5` (가안).
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
- 지난 턴에 기획서를 "라운드(손)"와 "회기(정산·의회 텀)"로 나눠 구현했는데, 실제로는 문서 작성자가 의도한 걸 놓친 채 진행한 오해였다.
|
||||||
|
- 원래 의도: **라운드마다 정산·의회를 거쳐야** 하고, 그 압박(실적 요구·규약)을 합본과 상점 자산 구매로 넘어서는 것 자체가 핵심 재미다. 이층 구조는 이 사이클을 회기 단위로 늘려버려 압박이 오는 빈도를 실수로 낮췄다.
|
||||||
|
- 결재 8건 같은 고정 카드 수 제한도 방향이 달랐다. 원하는 건 "주목도 21이 될 때까지 결재하다가, 멈출지 더 받을지는 플레이어가 스스로 정하는" 블랙잭식 긴장이었다 — 이건 사실 최초 기획서(2026-09-14 이전)에 있다가 "로그라이크 초반에 과하다"는 이유로 소프트 오버런으로 바뀐 안이었다. 그런데 하드 캡을 없애고 나니 "지금 정리할까 더 모을까"의 실제 리스크가 사라져, §1.1의 핵심 재미 정의 자체가 무력화됐다.
|
||||||
|
|
||||||
|
## 검토했으나 기각한 대안
|
||||||
|
- **하드 캡 없이 소프트 페널티만 유지**: 바로 이 상태가 문제였다. 넘겨도 죽지 않으니 "언제 멈출까"가 실질적인 선택이 아니게 된다.
|
||||||
|
- **고정 임계치(21) 그대로, 성장 없음**: 후반 라운드에도 21에서 막히면 게임이 진행될수록 오히려 쉬워지는 카드(고등급)를 못 받아 성장감이 없다. 라운드별 성장 곡선을 추가해 해결.
|
||||||
|
- **라운드/회기 이층 구조 유지하되 회기 길이를 줄임**: 근본적으로 "라운드마다 정산·의회"라는 원래 의도와 맞지 않아 이층 구조 자체를 접었다.
|
||||||
|
|
||||||
|
## 재검토 조건
|
||||||
|
- 초기 한도(21)·증가폭(+5)이 실측 플레이에서 너무 타이트하거나 너무 헐거우면 조정 (§20.1)
|
||||||
|
- 버스트가 너무 자주 발생해 "합본 기회 자체가 드물다"는 느낌이면 증가폭을 올리거나 초기값을 높일 것
|
||||||
|
- 반대로 버스트가 거의 안 일어나 하드 캡이 유명무실하면 초기값을 낮추거나 증가폭을 줄일 것
|
||||||
|
- §4.2-A(게이지 상승)·§4.2-C(토큰 가산)의 절대 임계치(15/30/40/41 등)는 이번 결정과 무관하게 그대로 뒀다 — 라운드 한도와 별개로 이 값들도 따로 튜닝 대상이다
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
created: 2026-09-17
|
||||||
|
---
|
||||||
|
%% 파일명: 2026-09-17-게이지상승-장수기준-복원. 규칙 문서엔 규칙만, 이유는 여기에 %%
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
- §4.2-A 게이지 상승률의 1차 기준을 **등급 합계(주목도)** → **카드 장수**로 되돌린다.
|
||||||
|
- 등급을 완전히 버리지는 않는다. 장수 구간이 정한 기본값에 `해당 축 평균 등급 / 5`를 곱한다 — 등급 5가 배율 1.0 기준점, 낮은 등급만 쌓이면 완만해지고 높은 등급이 섞이면 같은 장수에서도 더 아프다.
|
||||||
|
- 구간을 3~4장에서 크게 뛰도록 설계한다: 1~2장 0.5% → 3~4장 3.0%(6배 점프) → 5~6장 6.0% → 7~9장 10.0% → 10장+ 15.0%+장당 2.0%.
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
- 2026-09-15 결정으로 페널티 기준을 "카드 장수" → "등급 합계(주목도)"로 바꿨다. 등급 편차가 1~10으로 커진 만큼 등급 자체를 가중치로 쓰자는 취지였다.
|
||||||
|
- 그런데 실제 카드 데이터(`cards.csv`)는 등급이 대부분 1~4급에 몰려 있다. 그 결과 같은 축 카드를 3장, 4장 쌓아도 등급 합계가 15를 넘지 못해 계속 "1~15 → +0.5%" 최하 구간에 머물렀다 — 플레이 피드백: "4대 게이지 증가가 너무 낮아서 위협이 아예 안 됨".
|
||||||
|
- 플레이어가 실제로 느끼는 위협은 "등급 합계가 얼마인가"가 아니라 "몇 장이 쌓였는가"에 훨씬 직접적으로 반응한다는 게 이번 피드백으로 드러났다. 장수는 화면에서 세기도 쉽고, 매 결재 즉시 갱신되는 값이라 인지 부하가 적다.
|
||||||
|
|
||||||
|
## 검토했으나 기각한 대안
|
||||||
|
- **주목도 구간 값만 낮춘다(예: 1~5/6~15/16~25 식으로 세분화)**: 등급이 낮은 축에서는 여전히 굼뜨고, 등급이 높은 축에서는 카드 1~2장만으로 과하게 뛸 수 있어 두 문제를 동시에 해결하지 못한다.
|
||||||
|
- **등급 가중치를 완전히 버리고 순수 장수만 쓴다(2026-09-05 이전 방식으로 완전 회귀)**: 2026-09-15 결정의 문제의식(고등급과 저등급을 똑같이 취급하면 안 됨) 자체는 여전히 유효하다. 순수 장수만 쓰면 1급 카드 3장과 10급 카드 3장이 똑같이 위협적으로 취급돼 등급 확장(1~10, 2026-09-15)의 의미가 사라진다.
|
||||||
|
- **장수와 등급 합계 중 더 큰 쪽을 쓴다(max 방식)**: 두 지표의 스케일이 달라(장수는 1~10대, 등급 합계는 1~100대) 어느 한쪽이 항상 이겨서 실질적으로 단일 기준과 다르지 않다.
|
||||||
|
|
||||||
|
## 재검토 조건
|
||||||
|
- 카드 데이터에 고등급(7급 이상) 비중이 앞으로 늘어나면(§20.2 신규 카드 작성 시), 등급 배율이 과하게 커져 3장만으로도 즉시 버스트급 상승이 나올 수 있다 — 그때 배율 상한이나 기준점(현재 5)을 재조정할 것.
|
||||||
|
- 3~4장 구간의 6배 점프(0.5%→3.0%)가 실측 플레이에서 "적당히 위협적"인지 "너무 가혹한지" 확인 필요. §20.1 튜닝 대상.
|
||||||
|
- 등급 배율(÷5) 때문에 등급 1~2급만 쌓는 축은 상승이 여전히 매우 완만하다 — 이게 "저등급 축은 방치해도 괜찮다"는 의도된 설계인지, 아니면 그 축도 최소한의 압박이 있어야 하는지 결정 필요.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
created: 2026-09-17
|
||||||
|
---
|
||||||
|
%% 파일명: 2026-09-17-주목도-라운드-분리. 규칙 문서엔 규칙만, 이유는 여기에 %%
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
- 주목도를 용도에 따라 둘로 나눈다.
|
||||||
|
- **이번 라운드 주목도** = 이번 라운드에 받은 카드들의 등급 합 → **라운드 한도(버스트) 판정 전용**
|
||||||
|
- **총 주목도** = 책상 전체(이월 포함) 등급 합 → 게이지 상승(§4.2-A)·토큰 지출 가산(§4.2-C)
|
||||||
|
- 오염 카드에 "받은 라운드 번호"를 기록해 둘을 구분한다. 이월 규칙(§4.1) 자체는 그대로 둔다 — 카드는 여전히 소멸하지 않고 책상에 남는다.
|
||||||
|
- UI는 둘을 함께 보여준다: `이번 라운드 10/21 (이월 23)`.
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
- 플레이 테스트에서 "주목도가 라운드 종료 후 초기화되지 않아, 한도를 초과한 다음 라운드가 계속 바로바로 넘어간다"는 보고가 나왔다.
|
||||||
|
- 원인: 한도 판정을 **총 주목도**로 하고 있었는데, §4.1 이월 원칙에 따라 카드가 책상에 남으므로 2라운드는 시작하자마자 이미 한도를 넘은 상태였다. 결재 한 장만 해도 즉시 버스트 → 상점 → 의회 → 다음 라운드도 같은 상태. 게임이 진행 불가능했다.
|
||||||
|
- 즉 §2.2(블랙잭식 하드 캡)와 §4.1(이월)이 같은 숫자를 공유하면서 서로를 무력화하고 있었다. 블랙잭에서 각 핸드가 0에서 시작하듯, 한도 판정은 "이번 판에 새로 떠안은 무게"만 세야 성립한다.
|
||||||
|
|
||||||
|
## 검토했으나 기각한 대안
|
||||||
|
- **라운드 종료 시 책상을 비운다(카드 소멸)**: 이월은 "잘 남긴 카드가 다음 판의 유리한 시작점이 된다"는 2026-09-15 결정의 핵심 보상 구조다. 이걸 없애면 선택권 있는 드래프트라는 이 게임 고유의 성격이 사라진다.
|
||||||
|
- **한도를 총 주목도에 비례해 올린다**: 쌓을수록 한도도 올라가니 압박이 상쇄돼 하드 캡의 의미가 없어진다. 2026-09-16에 하드 캡을 되살린 이유와 정면으로 어긋난다.
|
||||||
|
- **이월 카드의 주목도를 할인해서 합산(예: 50%)**: 숫자는 굴러가지만 플레이어가 화면의 수치를 보고 한도까지 몇 점 남았는지 암산할 수 없다. 기호만 보고 0.5초 안에 판단해야 한다는 §3 요건에 어긋난다.
|
||||||
|
|
||||||
|
## 재검토 조건
|
||||||
|
- 이월분이 게이지·토큰 가산에만 작용하는 것이 실제로 충분한 압박인지 — 이월을 잔뜩 쌓아두고도 매 라운드 한도까지 새로 받는 플레이가 지배 전략이 되면, 이월분 일부를 한도 판정에 넣는 방식을 다시 검토한다.
|
||||||
|
- 반대로 이월 누적만으로 게이지가 너무 빨리 차서 2~3라운드 만에 끝나면 §4.2-A 구간 수치부터 조정한다(한도와는 별개 축).
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
created: 2026-09-18
|
||||||
|
---
|
||||||
|
%% 파일명: 2026-09-18-라운드-접수량-누적카운터. 규칙 문서엔 규칙만, 이유는 여기에 %%
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
- 이번 라운드 접수량(§4.2-B 한도 판정 기준)을 **책상 카드에서 파생시키는 값**이 아니라, **CoreGameScreen이 직접 들고 있는 순수 누적 카운터**로 바꾼다.
|
||||||
|
- 카드를 받을 때마다 그 등급만큼 카운터를 더한다. 합본으로 책상 카드가 사라져도 이 카운터는 줄지 않는다.
|
||||||
|
- 카운터는 라운드가 바뀔 때(의회 결의 후)만 0으로 리셋된다.
|
||||||
|
- 책상 전체 주목도(이월 포함, 게이지·코인 가산의 기준)는 지금처럼 `deskCards`에서 그대로 파생시킨다 — 이번 결정은 "라운드 한도 판정용" 수치에만 적용된다.
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
- 플레이 피드백: "합본을 많이 하게 되면 라운드가 너무 길어진다. 지루함을 유발함."
|
||||||
|
- 원인: 기존 `roundAttention(deskCards, round)`이 "책상에 남아 있는, 이번 라운드에 받은 카드"를 매번 다시 필터링해 합산하는 파생값이었다. 합본하면 그 카드들이 책상에서 사라지므로 접수량도 같이 줄었다 — 5장 받아 한도(21)에 걸린 뒤 합본하면 0으로 리셋되고, 다시 5장을 받을 수 있는 구조였다. 이론상 무한 반복이 가능했다.
|
||||||
|
- 블랙잭 유비(§2.2)로 보면 명백한 모순이었다: 블랙잭은 받은 카드를 버려서 핸드 합계를 낮출 수 없다. §1.1의 핵심 재미("지금 정리할 것인가, 더 모아서 크게 처리할 것인가")는 "정리해도 이번 판에 받은 기록 자체는 남는다"는 전제 위에서만 성립하는 긴장이다.
|
||||||
|
- 관료제 프레이밍과도 맞아떨어진다 — "접수 대장은 지워지지 않는다"(처리를 해도 이번 회기에 접수한 기록은 남는다).
|
||||||
|
|
||||||
|
## 검토했으나 기각한 대안
|
||||||
|
- **오염 카드가 더 어렵게 등장하도록 조정**: 운이 나쁘면 여전히 무한에 가깝게 라운드가 늘어질 수 있어 근본 해결이 아니었다. 또한 합본 카타르시스(§18 체크리스트)를 깎는 역방향 수정이라 기각.
|
||||||
|
- **합본해도 주목도가 전혀 안 깎이게 함**: §5.2 "페널티 소멸"이 합본의 가장 큰 보상인데, 이걸 없애면 대규모 합본이 판을 뒤집는다는 §4.3 원칙이 죽는다. 책상 총 주목도(게이지·코인 가산 기준)는 지금처럼 계속 깎이게 두고, **한도 판정용 수치만** 분리해 안 깎이게 하는 절충안을 택했다.
|
||||||
|
- **이월 카드에 할인율을 적용해 합산**: 화면 숫자로 한도까지 몇 점 남았는지 암산이 안 돼 §3 "0.5초 판단" 요건에 어긋난다 — 2026-09-17 결정(주목도-라운드-분리)에서 같은 이유로 기각한 전례와 일관성을 유지했다.
|
||||||
|
|
||||||
|
## 재검토 조건
|
||||||
|
- 한도(§4.2-B, 시작 21 + 라운드당 +5)가 이 변경으로 실질적으로 더 타이트해졌다 — 신규 카드만으로는 라운드당 합본 1회 안팎이 표준이 된다(평균 등급 4.14 기준 한도 21 ÷ 4.14 ≈ 5장). 실측 플레이 후 한도 시작값·증가폭을 재조정할 것(§20.1 기존 튜닝 항목과 연동).
|
||||||
|
- 라운드 내 여러 번 상신(§9.3 "합본을 몇 번 하든 그동안 점수를 합산")이 실제로 자주 일어나는지, 아니면 사실상 라운드당 1회로 굳어지는지 확인 필요.
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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,통제 사회,대중을 통제합니다. (정보 통제 관련 안건 비용 절반 감소)
|
||||||
|
@@ -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,,
|
||||||
|
@@ -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,진화를 거부하는 것은 곧 도태입니다.
|
||||||
|
@@ -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,비밀주의를 버리고 기구가 전면에 나서 세계 정부로 군림해야 한다고 믿음.
|
||||||
|
@@ -0,0 +1 @@
|
|||||||
|
id,title,req_tags,cost_anomaly,reward_asset_id,penalty_entropy
|
||||||
|
@@ -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
|
||||||
|
@@ -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,지는 싸움은 피하는 게 상책입니,-
|
||||||
|
Binary file not shown.
@@ -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) ] -> 터치 시 중립 파벌 목록 드롭다운
|
||||||
|
- [ 🎯 블랙리스트 지정 (무료) ] -> 터치 시 적대 파벌 목록 드롭다운
|
||||||
|
- [ ⏩ 개입 없이 법안 통과 (방관) ]
|
||||||
@@ -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>
|
||||||
@@ -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` 상단 박스 참고.
|
||||||
@@ -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;
|
||||||
|
```
|
||||||
@@ -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명을 사지로 보낸다"는 식의 비용 편익 분석.
|
||||||
|
- 보이지 않는 공포: 괴수의 모습보다는, 괴수가 지나간 자리에 남은 파괴된 도시의 피해액 보고서가 더 무섭게 다가오도록.
|
||||||
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user