Author SHA1 Message Date
kyoung5seoandClaude Opus 5 392a125125 모바일: 로그가 쌓이면 합본 결재 버튼이 화면 밖으로 밀리는 버그 수정
- .App 가운데 정렬(가로 flex) → 세로 flex + 화면 높이 고정. 내용이 넘칠 때
  상단 툴바가 잘리고 내부 스크롤이 동작하지 않던 근본 원인
- 책상바 sticky 상단 고정, html/body 스크롤 차단 + #root 100dvh
- 짧은 화면(≤760px)에서 로그 2줄 높이로 축소, 카드 min-height 해제·여백 축소
- 390×660 뷰포트에서 결재 12회(상점·의회 경유) 동안 합본 결재 버튼 노출·클릭 확인

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 08:48:16 +09:00
kyoung5seoandClaude Sonnet 5 20207ff23c 라운드 접수량 버그 수정 + 토큰→코인 용어 통일 + 외부 배포(NAS)
플레이 피드백 4건(주목도 미차감, 상점 빈 상품, 상점 UX 오해, 재화 명칭)을
검토해 방향이 확정된 항목만 반영하고, 외부 테스터에게 링크를 공유할 수 있게
시놀로지 NAS 배포까지 준비했다.

## 게임 로직

- 라운드 접수량 크리티컬 버그 수정: 합본을 반복하면 라운드가 안 끝나던 문제.
  deskCards에서 파생시키던 값을 순수 누적 카운터(roundIntake)로 교체 — 결정
  노트: 2026-09-18-라운드-접수량-누적카운터
- DeskBar/CoreSwipeCard 정보 역할 분리: "이번 라운드 접수량/한도"는 결재
  화면 전담, DeskBar는 책상에 쌓인 것(총 주목도·축별 분포)만 표시
- 토큰 → 코인 용어 일괄 변경: core/ 전체(식별자·CSS·주석·UI)와 기획서 SSOT
  동기화, lucide Coins 아이콘 추가

## 배포

- vite base를 상대 경로로 변경 (Web Station 서브패스 서빙 시 asset 404 방지)
- 모바일 100vh → 100dvh (주소창 노출 시 하단 버튼 가림 방지)
- 빌드 식별자 표시, 타이틀/테마색/description 정리
- deploy.sh + npm run deploy — SSH 키 권한 문제(777 → StrictModes 거부)와
  NAS rsync 게이트를 scp -O로 우회해 실제 배포까지 검증 완료
  → https://play.mint-grape.duckdns.org/projectss/

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 23:20:22 +09:00
kyoung5seoandClaude Sonnet 5 6398599199 규약 세부화(유형D·13석 표결·툴팁) + 밸런스 피드백 5건 반영
같은 날 두 차례 플레이테스트에서 나온 이슈를 이어서 처리했다.

**의회 세부화**
- 유형 D(이득 규약) 5종 신설 — 토큰 지급/게이지 완화/지출 가산 완화/
  주목도 한도 증가/실적 요구치 하향. 내부 명분("강제 상정")이 여태 불리한
  유형 A만 올릴 수 있어 실속 없던 문제를 해결 — 상정 풀에 D가 포함되는 게
  내부 명분이 존재하는 이유
- 13석 표결을 실제 표 수로 구현(tallyVotes, 최대잔여법 + 결정적 해시).
  혼합 명분이 "직접 통과 지정" 자리표시자에서 §7.3 스펙대로 실제 1표
  이동으로 교체 — 동점은 스윙 전 득표가 많던 쪽이 이겨 1표 차에서만 뒤집힘.
  의회 화면에 후보별 예상 득표·표 차·뒤집기 가능 여부를 미리 노출
- 공보 축 오염 선택지 1→7개로 보강(서사 기준 6개 재배정), 축 분포 균형화

**툴팁 신설 (InfoTooltip·ProtocolChip)**
- 규약·징벌·명분을 클릭하면 정확히 무엇이 막혔는지 설명이 뜬다. 문구는
  UI가 추측하지 않고 council.js의 protocolEffectDetail·justificationDetail이
  카탈로그에서 생성. 모바일 대상이라 hover가 아닌 클릭으로 연다

**크리티컬 버그: 주목도 미초기화**
- 라운드 종료 후 한도 판정에 총 주목도(이월 포함)를 쓰고 있어, 2라운드가
  시작하자마자 이미 한도를 넘어 버스트가 무한 반복되고 있었다. 한도 판정은
  "이번 라운드에 받은 카드"만 세도록 분리(roundAttention), 게이지·토큰
  가산은 기존대로 총계(이월 포함) 기준 유지

**밸런스 피드백 5건**
- 결재 카드가 고정으로 보인다는 지적 → 실제 버그였다. cardIndex를 그냥
  +1씩 돌려 CSV 순서 그대로 결정론적으로 반복되고 있었다. Fisher–Yates
  셔플 큐로 교체(큐 소진 시 재셔플)
- 위협 축도 거의 안 나온다 → 위 셔플 버그가 원인의 상당 부분. 명백한
  오배정 1건도 발견해 수정(id11 "군사 개입 지시": 고대 신 소환 저지가
  인사 축으로 잘못 들어가 있었음 → 위협 축)
- 게이지 상승이 너무 낮음 → 등급 합계(주목도) 단독 기준이 실제 카드
  등급 분포(대부분 1~4급)와 맞지 않아 3~4장을 쌓아도 최하 구간을 못
  벗어났다. 카드 장수를 1차 기준으로 되돌리고 등급 배율(평균등급/5)을
  곱하는 방식으로 재설계 — 3~4장 구간에서 6배 점프(0.5%→3.0%)
- 좌우 모두 오염 없는 완전 무행동 카드 1건(id15) 수정 — 좌측에 대외/6급
  부여(기존 tag_corruption과 정합)
- 오염 카드 한쪽만 있어 안전 선택 메리트가 없다는 지적은 카드 데이터가
  아니라 게이지 곡선 문제로 판단, 위 재설계로 해결(카드 재배분 안 함)

**문서**
- 결정 노트 2건: 2026-09-17-주목도-라운드-분리,
  2026-09-17-게이지상승-장수기준-복원(기각한 대안 포함)
- 기획서 §4.2-A/B, §7.3, §9.2, §17 갱신. DEVLOG 2건 기록

Playwright로 전 구간 검증: 셔플(3회 재접속 시 다른 카드, 15연속 무반복),
게이지 상승률 표시, 13석 표결 예상치, 규약/명분 툴팁, 2라운드 진입 시
주목도 0으로 리셋 확인. 콘솔 에러 0건, 빌드 클린.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 23:00:42 +09:00
kyoung5seoandClaude Sonnet 5 0a065068c1 등급 1~10 확장 + 라운드 주목도 하드 캡 재구현, 문서 체계 정비
서울 PC에서 Remote-SSH로 작업하던 중 연결이 끊겨 커밋 전 상태로 유실된
2026-09-15 결정사항(등급 1~4→1~10 확장, 5장 포커 핸드, 주목도 기반 페널티,
토큰 1종 통합, 상점/규약 재작성)을 평택 로컬 체크아웃에서 전체 재구현했다.
그 과정에서 라운드/회기 구조를 오해해서 잘못 반영했던 부분을 다시 고쳤다.

**데이터**
- cards.csv 43개 선택지, pollution_pool.csv 재매핑 — 등급 1·3 공백을 없애고
  1~10 전 구간을 채움 (평균 등급 4.14, 8결재 기준 예상 주목도 33)
- pollution_pool.csv에 등급 1·3 전용 오염 카드 8개 신규 추가 (축별 2개)

**게임 루프**
- 라운드/회기 이층 구조 도입 → 원래 의도(라운드마다 정산·의회를 거치는 것)와
  어긋난 오해로 판명, 단일 라운드 구조로 환원
- 라운드 종료 조건을 "결재 8건 고정"에서 블랙잭식 하드 캡으로 교체 —
  주목도가 라운드 한도(21 + 라운드당 +5)를 넘으면 합본 기회 없이 즉시 강제
  종료(버스트), 한도 밑에서는 "정산하기"로 언제든 자율 종료 가능
- CouncilScreen: 징벌 규약 5개 초과 시 목록 접기/펼치기 (§9.2 UI 요건)

**문서 체계**
- CLAUDE.md: 흩어진 기획 문서 7종 참조를 코어시스템 기획서 단일 참조(§ 매핑
  포함)로 교체, Phase 진행 표 폐지 후 §17 체크박스를 진행 상황 원본으로 지정
- CLAUDE.md에 "결정 노트" 규칙 신설 — T-decision.md 양식 + DEVLOG 위키링크 연동
- docs/DEVLOG.md 신규 작성 (구 버전은 old/2026.09.04/ 보관)
- 결정 노트 2건 작성: 등급확장-주목도시스템, 라운드-주목도-하드캡-복원
- ProjectSS_코어시스템_기획서.md §2/§4.2-B/§17/§19/§20을 현재 결정에 맞게 갱신

Playwright로 등급 1·3 카드 실등장, 라운드 번호 증가, 주목도 초과 시 버스트→
상점 강제 전환까지 실제 브라우저에서 확인. 콘솔 에러 0건.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:09:55 +09:00
kyoung5seo cde44c4f2c 2차 구현 완료 (§17: 상점·규약·실적 감사·명분) - 플레이는 해봤지만 피드백은 아직 정리 안함
라운드 구조(결재 8건 → 상점 → 의회 → 다음 라운드)를 얹고, 신규 파일들로 구현했습니다. Playwright로 라운드 전체를 실제로 돌려 확인 완료(콘솔 에러 0건).

만든 것
엔진 (core/engine/)

council.js — §7 명분 산출/개입, §9.2 규약(유형A 5종 카탈로그 + 유형C 징벌 2종), §9.3 실적 요구치 공식
shop.js — §8.3 상점 진열/가격/리롤
UI

ShopScreen — 오염 카드 직접 소각(토큰), 진열 4~6개 무작위, 리롤 1회
CouncilScreen — 실적 감사(토큰·명분 납입으로 요구치 충족) + 규약 후보 3개 + 명분 개입(절차/여론/위기/내부/혼합)
DeskBar/CombinePanel — 징벌 규약의 게이지·토큰 가산 반영, 활성 규약이 막은 족보 표시
라운드 루프: CoreGameScreen이 결재 8건마다 자동으로 상점→의회로 전환하고, 의회 결의 시 실적 판정→징벌 부과/해제→규약 표결→다음 라운드로 넘어갑니다. 4대 게이지 100% 도달 시 게임오버 화면도 추가했습니다(엔딩 분기는 4차라 텍스트만).

검증 (Playwright)
8회 스와이프 → 상점 화면 전환 → 의회 화면(요구실적 25, 후보 3개) → 결의 확정 → 2라운드 진입, 상단에 ⚖ 통과된 규약명 표시 → 합본 패널 열면 해당 규약이 칩으로 뜨고 filterHandsByProtocols가 막는 족보를 실제로 걸러냄. 스크린샷 다수 확인, 콘솔 에러 0건. 상점 아이템 버튼 텍스트가 화면 밖으로 살짝 잘리는 버그도 발견해서 즉시 수정했습니다.

의도적으로 축소한 부분 (문서에 근거 있음)
상점은 "오염 카드 직접 처리" 한 품목뿐 — 자산 구매(§10)·소모품(§12)·전례 매입(§11)은 전부 3차 항목이라 카탈로그 자체가 없습니다. §17 로드맵 순서를 지키기 위한 의도적 축소입니다.
규약 표결은 가중 무작위 — 13인 위원회의 실제 우호도·이중첩자·탄핵(§9.4~9.6)은 4차 범위. 지금은 후보 3개 중 하나를 무작위로 "통과"시킵니다.
혼합 명분("표 뒤집기")은 문자 그대로 구현할 표가 없어서, "후보 하나를 직접 통과 지정"으로 대체했습니다 — 최종 효과(원하는 규약 통과)는 같지만 4차에서 실제 위원회가 생기면 반드시 재설계해야 할 자리표시자로 코드에 표시해 뒀습니다.
§7.3 이념색 페널티(파벌 우호도 하락)는 스킵 — 8대 상임이사회 우호도 추적 자체가 4차 항목이라 지금은 걸 게 없습니다.
라운드당 결재 수 8건, 상점/징벌 가격 수치는 전부 가안 — 플레이해보고 조정 대상입니다.
2026-09-05 01:39:55 +09:00
kyoung5seo 83060f3e5b 구현 우선 순위 1차 진행 완료
- 1차 — 코어 검증 (여기까지만 되어도 재미 판정 가능)
  1. **기호 체계 + 책상 상태 상시 노출 UI** (§3)
  2. **책상 적재 + 누진 패시브 페널티 A·B** (§4)
  3. **표준 족보 판정 및 합본 실행** (§5)
  4. **합본 기반 토큰 산출** (§8.2)

 게임 플레이 후 피드백 해결
 - 내용은 아래 문서 참고
 - docs/decisions/2026-09-05_core-graybox-ux-feedback.md
2026-09-05 01:06:41 +09:00
kyoung5seo 54e2fa8132 - 새로운 브랜치 생성: rework/combined-filing
- 마이그레이션 완료 (기존 작업물을 기반으로 ProjectSS_코어시스템_기획서대로 만들기 위한 준비 작업)
- 기존 docs 문서를 모두 old/2026.09.04 폴더로 이동
- docs에 ProjectSS_코어시스템_기획서 추가
2026-09-04 23:40:41 +09:00
62 changed files with 5553 additions and 226 deletions
+60 -25
View File
@@ -1,7 +1,8 @@
# Project SS — CLAUDE.md # Project SS — CLAUDE.md
> **위치:** repo root (`ProjectSS/CLAUDE.md`) — Claude Code는 root에서 실행한다. > **위치:** repo root (`ProjectSS/CLAUDE.md`) — Claude Code는 root에서 실행한다.
> **이 파일의 역할:** 규칙만 담는다. 작업 이력·진행 상황은 `docs/DEVLOG.md`, Phase 계획은 `ROADMAP.md`. > **이 파일의 역할:** 규칙만 담는다. 작업 이력·진행 상황은 `docs/DEVLOG.md`, 구현 로드맵은
> `docs/ProjectSS_코어시스템_기획서.md` §17.
> **상위 규칙:** 전역 정체성·개발 철학은 `~/.hermes/SOUL.md`, 프로젝트 정체성은 `AGENTS.md`에 있다. > **상위 규칙:** 전역 정체성·개발 철학은 `~/.hermes/SOUL.md`, 프로젝트 정체성은 `AGENTS.md`에 있다.
> 이 파일은 그 위에 얹히는 **코드 레벨 규칙**만 다룬다. > 이 파일은 그 위에 얹히는 **코드 레벨 규칙**만 다룬다.
@@ -25,7 +26,10 @@ git log HEAD..origin/main # 내가 안 받은 커밋
## 코드 컨벤션 ## 코드 컨벤션
- 전체 구조·아키텍처 원칙(상태 배치, `engine/` 경계, world 스냅샷 패턴) → @docs/architecture_design.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` 후 실행. - 실제 앱은 `client/` 안에 있음. npm 명령은 `cd client` 후 실행.
- 로컬에 Node.js LTS 설치되어 `npm run dev`로 실기 검증 가능 (Playwright 플레이 확인까지 완료). - 로컬에 Node.js LTS 설치되어 `npm run dev`로 실기 검증 가능 (Playwright 플레이 확인까지 완료).
- 플레이테스트는 리포 루트의 `playtest.bat` 더블클릭 (dev 서버 + 브라우저 자동 오픈). - 플레이테스트는 리포 루트의 `playtest.bat` 더블클릭 (dev 서버 + 브라우저 자동 오픈).
@@ -70,41 +74,72 @@ git log HEAD..origin/main # 내가 안 받은 커밋
코드 작성 시 항상 원본 문서를 기준으로 한다. **게임 규칙 수치를 이 파일에 복사하지 말 것.** 코드 작성 시 항상 원본 문서를 기준으로 한다. **게임 규칙 수치를 이 파일에 복사하지 말 것.**
- 카드 타입별 스와이프 결과, 파라미터, 의회 액션 → @docs/Project_SS_서사형_덱빌딩_게임_시스템_기획서.md 예전에 흩어져 있던 기획 문서 7종(서사형 덱빌딩 시스템 기획서·스토리 바이블·유저플로우·
- 파벌 12종 우호도 조건, 엔딩 매트릭스, 세계관 → @docs/Project_SS_스토리_바이블_v3_0.md 텍스트 와이어프레임·디자인 토큰·PRD·architecture_design)은 전부 아래 한 문서로 통합됐다.
- 화면 전환/분기 로직 → @docs/ProjectSS_유저플로우.md `docs/` 안에 옛 문서가 남아 보이더라도 참조하지 말 것 — 이제 유일한 원본은 이것뿐이다.
- UI 레이아웃 → @docs/ProjectSS_텍스트_와이어프레임.md
- 컬러/타이포/햅틱 수치 → @docs/ProjectSS_디자인_토큰.md - 게임 규칙·수치·화면 흐름·UI 레이아웃·요구사항 전부 → @docs/ProjectSS_코어시스템_기획서.md
- 요구사항 정의 → @docs/ProjectSS_PRD.md - 기호 체계(축·등급) → §3 · 책상·페널티(주목도) → §4 · 합본(족보) → §5
- 파라미터(4대 게이지) → §6 · 명분 → §7 · 경제(상점) → §8
- 의회(규약·실적감사·13인 위원회) → §9 · 자산 → §10 · 전례(내러티브 태그) → §11
- 노선 → §13 · 엔딩 → §15 · 텍스트 스타일 가이드 → §16
- **구현 우선순위·진행 상황(로드맵) → §17** — Phase 계획을 대신한다, 아래 참고
--- ---
## Phase 진행 순서 ## 구현 진행 상황
``` 진행 체크박스를 이 파일에 복제하지 않는다 — 여러 곳에서 따로 갱신하면 어긋나기 쉽다.
Phase 0 [x] 환경 세팅 & 아키텍처 `ProjectSS_코어시스템_기획서.md` **§17 "구현 우선순위"** 안의 체크박스가 유일한 진행 상황
Phase 1 [x] 코어 루프 프로토타입 원본이다. 지금 뭐가 됐고 안 됐는지 보려면 그 절을 열어볼 것.
Phase 2 [x] 자원 경제 & 덱빌딩
Phase 3 [x] 정치 시스템 & 스토리 페이징
Phase 4 [x] 멀티 엔딩 & 연대기 ← 그레이박스 테스트 시작 가능
Phase 5 [ ] 비주얼 폴리싱 & UX ← 그레이박스 테스트 완료 후에만 착수
```
`[x]` 완료 · `[~]` 부분 완료 · `[ ]` 미착수 각 항목에서 남은 주의사항과 상세 작업 이력은 `docs/DEVLOG.md` 참고. 왜 그렇게 결정했는지는
DEVLOG에서 링크된 결정 노트(아래 "결정 노트" 절) 참고.
**Phase 5 착수 조건:** 외부 플레이어 그레이박스 테스트를 마치고 그 피드백을 반영한 뒤에만 시작한다.
Phase별 상세 계획과 완료 기준은 `ROADMAP.md`, 각 Phase에서 남은 주의사항과 작업 이력은
`docs/DEVLOG.md` 참고.
--- ---
## 작업 지시 방법 ## 작업 지시 방법
``` ```
/goal Phase 5 진행해줘 # 그레이박스 테스트 완료 후에만 /goal 구현 우선순위 3차 진행해줘 # ProjectSS_코어시스템_기획서.md §17 기준
``` ```
**작업 완료 후 반드시:** **작업 완료 후 반드시:**
1. 위 **Phase 진행 순서** 블록의 체크박스를 갱신한다. 1. `ProjectSS_코어시스템_기획서.md` **§17**의 해당 항목 체크박스를 갱신한다.
2. `docs/DEVLOG.md` 맨 위에 무엇을 왜 바꿨는지 기록한다. 2. `docs/DEVLOG.md` 맨 위에 무엇을 왜 바꿨는지 기록한다.
(미해결 이슈나 디자이너 확인이 필요한 사항이 생겼으면 DEVLOG 상단 "열린 이슈"에 추가) (미해결 이슈나 디자이너 확인이 필요한 사항이 생겼으면 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는 "무엇을 했다"는 요약만 담고,
"왜 그렇게 했는가"의 자세한 내용은 링크를 눌러 결정 노트에서 확인하는 구조다.
+7 -2
View File
@@ -2,9 +2,14 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>projectss</title> <!-- 모바일 브라우저 상단/하단 바를 다크 UI와 맞춘다 -->
<meta name="theme-color" content="#121212" />
<meta name="description" content="관료주의 코스믹 호러 덱빌딩 — 종말을 결재하시겠습니까?" />
<title>Project SS — 종말을 결재하시겠습니까?</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+3 -1
View File
@@ -7,7 +7,9 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"deploy": "bash ../deploy.sh",
"deploy:dry": "bash ../deploy.sh --dry-run"
}, },
"dependencies": { "dependencies": {
"framer-motion": "^12.40.0", "framer-motion": "^12.40.0",
+7 -2
View File
@@ -1,8 +1,13 @@
/* 세로 flex + stretch로 둔다. 예전엔 가로 flex + align-items:center(중앙 정렬)였는데,
콘텐츠가 화면보다 커지면 위아래로 균등하게 넘쳐서 상단 툴바(합본 결재)가 화면 위로
잘려 나갔다(모바일에서 로그가 쌓이면 실제로 발생). 또 그 구조에선 자식의 높이가 고정되지
않아 CoreGameScreen의 내부 스크롤·sticky 툴바가 동작하지 않았다. */
.App { .App {
width: 100%; width: 100%;
height: 100%; height: 100%;
flex: 1;
min-height: 0;
display: flex; display: flex;
justify-content: center; flex-direction: column;
align-items: center;
background: radial-gradient(circle at center, #1a1a1a 0%, #0a0a0a 100%); background: radial-gradient(circle at center, #1a1a1a 0%, #0a0a0a 100%);
} }
+4 -2
View File
@@ -1,11 +1,13 @@
import React from 'react'; import React from 'react';
import './App.css'; import './App.css';
import { GameScreen } from './components/GameScreen'; // ProjectSS_코어시스템_기획서.md 기준 신규 개발 — 구현 우선순위 1차(§17)부터 core/ 아래 새로
// 짓는다. 기존 GameScreen(components/)은 참조용으로만 남겨두고 더 이상 진입점에서 쓰지 않는다.
import CoreGameScreen from './core/components/CoreGameScreen';
function App() { function App() {
return ( return (
<div className="App"> <div className="App">
<GameScreen /> <CoreGameScreen />
</div> </div>
); );
} }
+176
View File
@@ -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;
}
+118
View File
@@ -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}>&times;</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>
);
}
+155
View File
@@ -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;
}
+87
View File
@@ -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}>&times;</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}>&times;</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>
);
}
+116
View File
@@ -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>
);
}
+119
View File
@@ -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;
}
+55
View File
@@ -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>
);
}
+456
View File
@@ -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;
}
+185
View File
@@ -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];
}
+184
View File
@@ -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),
};
}
+32
View File
@@ -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 };
}
+32
View File
@@ -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;
+70
View File
@@ -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"
]
}
}
+10 -35
View File
@@ -4,11 +4,11 @@
"name": "문서 파쇄실", "name": "문서 파쇄실",
"grade": "C", "grade": "C",
"archetype": "purge", "archetype": "purge",
"amplifier": "periodic",
"trigger": { "type": "every_n_approvals", "params": { "n": 5 } }, "trigger": { "type": "every_n_approvals", "params": { "n": 5 } },
"effect": { "type": "purge_lowest", "params": { "count": 1 } }, "effect": { "type": "purge_lowest", "params": { "count": 1 } },
"growth": null, "growth": null,
"risk": null, "risk": null,
"faction_link": "scavengers",
"description": "결재 5건마다 덱에서 최하 등급 오염 카드 1장을 영구 소각한다.", "description": "결재 5건마다 덱에서 최하 등급 오염 카드 1장을 영구 소각한다.",
"flavor": "파쇄 용량: 일일 1건. 증설 요청은 3회 반려됨." "flavor": "파쇄 용량: 일일 1건. 증설 요청은 3회 반려됨."
}, },
@@ -17,11 +17,11 @@
"name": "소각 처리 대장", "name": "소각 처리 대장",
"grade": "A", "grade": "A",
"archetype": "purge", "archetype": "purge",
"amplifier": "cumulative",
"trigger": { "type": "on_purge", "params": {} }, "trigger": { "type": "on_purge", "params": {} },
"effect": null, "effect": null,
"growth": { "stackOn": "on_purge", "per": 3, "bonus": { "effect": "purge_lowest", "count": 1 } }, "growth": { "stackOn": "on_purge", "per": 3, "bonus": { "effect": "purge_lowest", "count": 1 } },
"risk": null, "risk": null,
"faction_link": "scavengers",
"description": "소각이 발생할 때마다 숙련도가 쌓인다. 숙련 3회마다 최하 등급 오염 카드 1장을 추가로 소각한다.", "description": "소각이 발생할 때마다 숙련도가 쌓인다. 숙련 3회마다 최하 등급 오염 카드 1장을 추가로 소각한다.",
"flavor": "소각로 온도는 서류로 증명되지 않는다." "flavor": "소각로 온도는 서류로 증명되지 않는다."
}, },
@@ -30,63 +30,38 @@
"name": "폐기물 재활용 규정", "name": "폐기물 재활용 규정",
"grade": "C", "grade": "C",
"archetype": "convert", "archetype": "convert",
"trigger": { "type": "on_pollution_draw", "params": { "category": "신화", "minGrade": null } }, "amplifier": "pollution_reactive",
"trigger": { "type": "on_pollution_gain", "params": { "category": "신화", "minGrade": null } },
"effect": { "type": "convert_to_token", "params": { "tokenType": "budget", "amount": 2 } }, "effect": { "type": "convert_to_token", "params": { "tokenType": "budget", "amount": 2 } },
"growth": null, "growth": null,
"risk": null, "risk": null,
"faction_link": "nexus_traders",
"description": "신화 오염 카드가 드로우되면 예산 토큰 2를 회수한다. (페널티는 정상 적용 후)", "description": "신화 오염 카드가 드로우되면 예산 토큰 2를 회수한다. (페널티는 정상 적용 후)",
"flavor": "괴수 사체도 감가상각 대상이다." "flavor": "괴수 사체도 감가상각 대상이다."
}, },
{
"id": "asset_convert_002",
"name": "부산물 매각 계약",
"grade": "B",
"archetype": "convert",
"trigger": { "type": "on_convert", "params": {} },
"effect": { "type": "convert_multiplier", "params": { "multiplier": 2 } },
"growth": null,
"risk": { "param": "risk_tolerance", "amount": 5, "when": "council_end" },
"faction_link": "nexus_traders",
"description": "모든 환전 효과의 산출량을 2배로 늘린다. 의회가 열릴 때마다 위험 수용 계수가 소폭 상승한다.",
"flavor": "이 계약서에 서명한 사람은 아무도 기억하지 못한다."
},
{ {
"id": "asset_quarantine_001", "id": "asset_quarantine_001",
"name": "임시 격리 컨테이너", "name": "임시 격리 컨테이너",
"grade": "C", "grade": "C",
"archetype": "quarantine", "archetype": "quarantine",
"trigger": { "type": "on_swipe_left", "params": {} }, "amplifier": "pollution_reactive",
"trigger": { "type": "on_pollution_gain", "params": {} },
"effect": { "type": "quarantine", "params": { "failCondition": "다음 의회까지 대중 공황 1단계 상승" } }, "effect": { "type": "quarantine", "params": { "failCondition": "다음 의회까지 대중 공황 1단계 상승" } },
"growth": null, "growth": null,
"risk": null, "risk": null,
"faction_link": "occult_collectors",
"description": "좌로 스와이프한 오염 카드를 격리한다. 다음 의회 전까지 처리하지 못하면 대중 공황이 상승한다.", "description": "좌로 스와이프한 오염 카드를 격리한다. 다음 의회 전까지 처리하지 못하면 대중 공황이 상승한다.",
"flavor": "컨테이너 라벨: '절대 열지 말 것 (2차 통보)'." "flavor": "컨테이너 라벨: '절대 열지 말 것 (2차 통보)'."
}, },
{
"id": "asset_quarantine_002",
"name": "심연 보관 조례",
"grade": "S",
"archetype": "quarantine",
"trigger": { "type": "on_quarantine_fail", "params": {} },
"effect": { "type": "quarantine_fail_to_purge", "params": { "bonusTag": "surveillance" } },
"growth": null,
"risk": null,
"faction_link": "occult_collectors",
"description": "격리가 실패로 판정되면 그 즉시 대상을 영구 소각하고, 감시 태그 1을 획득한다. 격리 실패가 더 이상 페널티가 아니다.",
"flavor": "실패란 서류상의 표현일 뿐이다."
},
{ {
"id": "asset_skip_001", "id": "asset_skip_001",
"name": "결재 반려 도장", "name": "결재 반려 도장",
"grade": "C", "grade": "C",
"archetype": "skip", "archetype": "skip",
"amplifier": "periodic",
"activation": "manual",
"trigger": { "type": "manual_charge", "params": { "chargeEvery": 10 } }, "trigger": { "type": "manual_charge", "params": { "chargeEvery": 10 } },
"effect": { "type": "negate_penalty", "params": { "category": null } }, "effect": { "type": "negate_penalty", "params": { "category": null } },
"growth": null, "growth": null,
"risk": null, "risk": null,
"faction_link": "field_agents_union",
"description": "결재 10건마다 충전된다. 충전 완료 시 수동으로 발동해 다음 오염 카드의 페널티를 1회 무효화한다.", "description": "결재 10건마다 충전된다. 충전 완료 시 수동으로 발동해 다음 오염 카드의 페널티를 1회 무효화한다.",
"flavor": "도장이 닳는 속도가 곧 국장의 임기다." "flavor": "도장이 닳는 속도가 곧 국장의 임기다."
}, },
@@ -95,11 +70,11 @@
"name": "책임 소재 불명 처리", "name": "책임 소재 불명 처리",
"grade": "B", "grade": "B",
"archetype": "skip", "archetype": "skip",
"trigger": { "type": "on_pollution_draw", "params": { "category": "조직저항", "minGrade": null } }, "amplifier": "pollution_reactive",
"trigger": { "type": "on_pollution_gain", "params": { "category": "조직저항", "minGrade": null } },
"effect": { "type": "negate_penalty", "params": { "category": "조직저항" } }, "effect": { "type": "negate_penalty", "params": { "category": "조직저항" } },
"growth": null, "growth": null,
"risk": { "param": "resistance", "amount": 3, "when": "on_trigger" }, "risk": { "param": "resistance", "amount": 3, "when": "on_trigger" },
"faction_link": "field_agents_union",
"description": "조직 저항 계열 오염 카드의 페널티를 자동으로 무효화한다. 다만 책임 소재가 사라질 때마다 내부 저항이 소폭 상승한다.", "description": "조직 저항 계열 오염 카드의 페널티를 자동으로 무효화한다. 다만 책임 소재가 사라질 때마다 내부 저항이 소폭 상승한다.",
"flavor": "'담당자 부재'라는 담당자가 새로 생겼다." "flavor": "'담당자 부재'라는 담당자가 새로 생겼다."
} }
+34 -48
View File
@@ -1,48 +1,34 @@
id,type,keyword,question,text,flavor,left_text,left_tag,left_res,left_ent,left_pan,left_rsk,left_bud,left_per,left_inf,left_wea,left_for,left_sur,right_text,right_tag,right_res,right_ent,right_pan,right_rsk,right_bud,right_per,right_inf,right_wea,right_for,right_sur,grade id,type,keyword,question,text,flavor,left_text,left_axis,left_grade,left_tag,right_text,right_axis,right_grade,right_tag
1,일상,요원들의 시위 발생,시위를 어떻게 처리하시겠습니까?,B구역 하급 요원들이 예산 삭감에 반발해 장비 반납 시위를 하고 있습니다.,우리가 세상을 구하는데 월급이 깎인다고요? 촉수에 맞아 죽으나 굶어 죽으나 똑같습니다!,무력 진압,tag_ruthless,1,0,0,0,0,-10,0,0,0,0,요구 수용,,0,0,0,0,-20,0,0,0,0,0, 1,일상,요원들의 시위 발생,시위를 어떻게 처리하시겠습니까?,B구역 하급 요원들이 예산 삭감에 반발해 장비 반납 시위를 하고 있습니다.,우리가 세상을 구하는데 월급이 깎인다고요? 촉수에 맞아 죽으나 굶어 죽으나 똑같습니다!,무력 진압,axis_hr,1,tag_ruthless,요구 수용,,,
2,위기,격리실 오염 발생,오염을 어떻게 정화하시겠습니까?,지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다.,보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다.,네이팜 소각 강행,tag_coverup,0,0,0,0,0,-30,0,0,0,0,조사팀 파견,,0,1,0,1,0,0,0,0,0,0, 4,극비,변이 DNA 이식 제안,실험을 승인하시겠습니까?,진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠.,인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠.,실험 승인,axis_threat,9,tag_madness,제안 거절,axis_hr,7,
3,일상,촉수 괴물 출현,출동 승인하시겠습니까?,7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다.,규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다.,내역 위조 후 출동,,0,0,0,1,0,0,-10,0,0,0,규정대로 반려,,1,1,0,0,0,0,0,0,0,0, 5,작전,전술 폭격 건의,폭격을 승인하시겠습니까?,철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다.,희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠.,궤도 폭격 승인,axis_external,5,tag_sacrifice,작전 기각,axis_hr,2,
4,극비,변이 DNA 이식 제안,실험을 승인하시겠습니까?,진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠.,인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠.,실험 승인,tag_madness,0,2,0,0,40,40,0,0,0,0,제안 거절,,1,0,0,0,0,0,0,0,0,0, 6,일상,부산물 암시장 매각,매각을 허가하시겠습니까?,차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다.,이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠.,매각 허가,axis_external,1,tag_corruption,원칙대로 소각,,,
5,작전,전술 폭격 건의,폭격을 승인하시겠습니까?,철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다.,희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠.,궤도 폭격 승인,tag_sacrifice,0,0,0,2,0,0,0,0,2,0,작전 기각,,1,0,0,0,0,0,0,0,0,0, 7,작전,존재 양지화 프로젝트,프로젝트를 승인하시겠습니까?,대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다.,우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다.,감시망 구축,axis_pr,2,tag_coverup,프로젝트 보류,axis_hr,2,
6,일상,부산물 암시장 매각,매각을 허가하시겠습니까?,차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다.,이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠.,매각 허가,tag_corruption,0,0,0,1,30,0,0,0,0,0,원칙대로 소각,,0,0,0,0,-10,0,0,0,0,0, 9,위기,이단 색출 마녀사냥,어떻게 개입하시겠습니까?,기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다.,더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로!,적당히 묵인한다,axis_hr,4,,군사력으로 제압,axis_external,4,
7,작전,존재 양지화 프로젝트,프로젝트를 승인하시겠습니까?,대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다.,우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다.,감시망 구축,tag_coverup,0,0,0,0,0,0,-40,0,0,2,프로젝트 보류,,1,0,0,0,0,0,0,0,0,0, 10,일상,변칙 현상 영상 유출,어떻게 처리하시겠습니까?,한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다.,대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오.,제거 절차 승인,,,tag_coverup,영상만 삭제,axis_pr,1,
8,극비,대규모 도서관 건립,예산을 배정하시겠습니까?,고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다.,이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다.,예산 배정,,0,0,0,0,-40,0,40,0,0,0,예산 전액 삭감,,1,0,0,1,0,0,0,0,0,0, 12,위기,의회의 긴급 감사,어떻게 대처하시겠습니까?,의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다!,세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오.,은폐 공작 실행,axis_external,4,tag_corruption,횡령 사실 인정,axis_external,8,
9,위기,이단 색출 마녀사냥,어떻게 개입하시겠습니까?,기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다.,더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로!,적당히 묵인한다,,1,0,0,0,0,-30,0,0,0,0,군사력으로 제압,,0,0,0,1,0,0,-20,0,0,0, 13,일상,연구 부서 파업 조짐,요구를 수용하시겠습니까?,연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다.,저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요.,특별 보너스 지급,,,,요구 전면 묵살,axis_hr,1,
10,일상,변칙 현상 영상 유출,어떻게 처리하시겠습니까?,한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다.,대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오.,제거 절차 승인,tag_coverup,0,0,0,-1,0,0,-20,0,0,0,영상만 삭제,,0,1,0,0,0,0,0,0,0,0, 14,작전,이계 무역로 개방,무역로를 개방하시겠습니까?,이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다.,저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠.,게이트 강제 개방,axis_threat,2,tag_alien_contact,폐쇄 상태 유지,axis_hr,2,
11,위기,사이비 종교 인신 공양,어떻게 대응하시겠습니까?,특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다.,위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다.,군사 개입 지시,,1,0,1,0,0,-40,0,0,0,0,의식 방관,tag_cult,0,2,0,0,0,0,0,0,0,0, 15,극비,전임 국장 비밀 금고,금고를 개방하시겠습니까?,전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다.,전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다.,강제 개방 지시,axis_external,6,tag_corruption,영구 봉인,,,
12,위기,의회의 긴급 감사,어떻게 대처하시겠습니까?,의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다!,세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오.,은폐 공작 실행,tag_corruption,0,0,0,1,0,0,-40,0,0,0,횡령 사실 인정,,0,0,0,2,0,0,0,0,0,0, 16,작전,메가코프 협박 작전,작전을 실행하시겠습니까?,글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다.,그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까.,작전 실행,,,,작전 취소,axis_hr,2,
13,일상,연구 부서 파업 조짐,요구를 수용하시겠습니까?,연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다.,저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요.,특별 보너스 지급,,-1,0,0,0,-30,0,0,0,0,0,요구 전면 묵살,,1,0,0,0,0,0,0,0,0,0, 17,위기,전술 폭격 건의,폭격을 승인하시겠습니까?,제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요?,시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다.,시민 포기 및 폭격,axis_pr,8,tag_ruthless,결사 방어 명령,,,
14,작전,이계 무역로 개방,무역로를 개방하시겠습니까?,이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다.,저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠.,게이트 강제 개방,tag_alien_contact,0,1,0,0,50,0,0,0,0,0,폐쇄 상태 유지,,1,0,0,0,0,0,0,0,0,0, 18,일상,행정부 파업 예고,어떻게 대응하시겠습니까?,새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다.,기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다.,예산 전용으로 무마,axis_external,1,,파업 강경 대응,axis_hr,1,
15,극비,전임 국장 비밀 금고,금고를 개방하시겠습니까?,전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다.,전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다.,강제 개방 지시,tag_corruption,0,0,0,0,20,-30,0,1,0,0,영구 봉인,,0,0,0,0,0,0,0,0,0,0, 19,극비,불침번 요원 개발,배치하시겠습니까?,비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다.,잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠.,실전 즉각 배치,axis_threat,7,,즉각 폐기 처분,axis_hr,7,
16,작전,메가코프 협박 작전,작전을 실행하시겠습니까?,글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다.,그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까.,작전 실행,,0,0,0,0,0,0,-30,2,0,0,작전 취소,,1,0,0,0,0,0,0,0,0,0, 20,일상,내부 스파이 첩보,어떻게 조사하시겠습니까?,기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다.,누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다.,대규모 사찰 시작,axis_hr,3,tag_political_pressure,증거 부족으로 덮음,axis_external,1,
17,위기,전술 폭격 건의,폭격을 승인하시겠습니까?,제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요?,시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다.,시민 포기 및 폭격,tag_ruthless,0,-1,0,2,0,0,0,0,0,0,결사 방어 명령,,0,0,0,0,-40,-40,0,0,0,0, 25,극비,내부 고발자 발생,어떻게 막으시겠습니까?,의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다.,국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다.,사고사 위장 지시,,,tag_coverup,폭로전 정면 돌파,axis_pr,9,
18,일상,행정부 파업 예고,어떻게 대응하시겠습니까?,새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다.,기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다.,예산 전용으로 무마,,0,0,0,1,-20,0,0,0,0,0,파업 강경 대응,,1,0,0,0,0,0,0,0,0,0, 26,위기,차원 붕괴 폭탄 오작동,어떻게 대응하시겠습니까?,차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다.,최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다.,특수 해체팀 투입,,,tag_sacrifice,폭발 방관 및 대피,axis_threat,8,
19,극비,불침번 요원 개발,배치하시겠습니까?,비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다.,잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠.,실전 즉각 배치,,0,1,0,0,0,0,20,0,1,0,즉각 폐기 처분,,1,0,0,0,-10,0,0,0,0,0, 27,작전,PMC 흡수 합병,결탁하시겠습니까?,군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다.,돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠.,결탁 및 흡수 합병,,,,독자적 노선 유지,axis_hr,2,
20,일상,내부 스파이 첩보,어떻게 조사하시겠습니까?,기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다.,누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다.,대규모 사찰 시작,tag_political_pressure,2,0,0,0,0,0,30,0,0,0,증거 부족으로 덮음,,0,0,0,1,0,0,0,0,0,0, 28,작전,대규모 거주 구역 실험장화,제안을 승인하시겠습니까?,변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다.,시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠.,돔 건설 강행,axis_threat,2,,윤리적 이유로 기각,axis_external,2,
21,작전,비정상적 위기 발생,국장님 결단이 필요합니다,순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다.,괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다.,수용소 건립 승인,tag_ruthless,0,0,0,0,-40,0,0,0,1,0,제안 단호히 거절,,1,0,0,1,0,0,0,0,0,0, 30,일상,믹스 커피 재고 고갈,어떻게 해결하시겠습니까?,국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다.,종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까?,최고급 원두 지원,,,,헛소리 금지령,axis_hr,1,
22,극비,이계의 유물 인양,유물을 어떻게 처리하시겠습니까?,고대 공존당의 사제들이 심해의 유물을 인양했습니다. 만지는 자의 정신을 갉아먹지만 막대한 지식을 줍니다.,약간의 두통만 참으면 이계의 비밀을 모두 알 수 있습니다. 요원 하나쯤 미쳐도 괜찮습니다.,유물 집중 연구,tag_alien_contact,0,1,0,0,0,0,50,0,0,0,유물 파괴 명령,,1,0,0,1,0,0,0,0,0,0, 31,위기,신화적 존재 강림,어떻게 대적하시겠습니까?,신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다!,최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까?,현실 조작기 가동,axis_threat,10,tag_madness,모든 예비병력 투입,axis_pr,4,tag_sacrifice
23,위기,이계의 주시자 출현,대중을 어떻게 통제하시겠습니까?,얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다.,하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오.,인터넷 전면 차단,,2,0,1,0,0,0,0,0,0,2,진정제 무상 배포,,0,0,0,0,-50,0,0,0,0,0, 2,위기,격리실 오염 발생,오염을 어떻게 정화하시겠습니까?,지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다.,보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다.,네이팜 소각 강행,,,,조사팀 파견,axis_threat,4,
24,일상,요원 집단 트라우마,어떻게 지원하시겠습니까?,요원들이 괴수 진압 후 심각한 트라우마에 시달리고 있습니다. 심리 치료 지원이 시급합니다.,밤마다 촉수가 목을 조르는 악몽을 꾼답니다. 차라리 그 부분 기억을 지워버릴까요?,정신과 치료 지원,,0,0,0,0,-20,10,0,0,0,0,트라우마 기억 소거,,0,1,0,1,0,0,0,0,0,0, 3,위기,촉수 괴물 출현,출동 승인하시겠습니까?,7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다.,규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다.,내역 위조 후 출동,axis_external,4,,규정대로 반려,axis_threat,4,
25,극비,내부 고발자 발생,어떻게 막으시겠습니까?,의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다.,국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다.,사고사 위장 지시,tag_coverup,0,0,0,-1,0,0,-30,0,0,0,폭로전 정면 돌파,,0,0,0,2,0,0,0,0,0,0, 8,극비,대규모 도서관 건립,예산을 배정하시겠습니까?,고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다.,이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다.,예산 배정,,,,예산 전액 삭감,axis_external,7,
26,위기,차원 붕괴 폭탄 오작동,어떻게 대응하시겠습니까?,차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다.,최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다.,특수 해체팀 투입,tag_sacrifice,0,0,0,0,0,-40,0,0,0,0,폭발 방관 및 대피,,0,2,0,0,0,0,0,0,0,0, 11,위기,사이비 종교 인신 공양,어떻게 대응하시겠습니까?,특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다.,위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다.,군사 개입 지시,axis_threat,5,,의식 방관,axis_threat,6,
27,작전,PMC 흡수 합병,결탁하시겠습니까?,군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다.,돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠.,결탁 및 흡수 합병,,0,0,0,0,-40,0,0,0,2,0,독자적 노선 유지,,1,0,0,0,0,0,0,0,0,0, 21,작전,비정상적 위기 발생,국장님 결단이 필요합니다,순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다.,괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다.,수용소 건립 승인,,,,제안 단호히 거절,axis_external,2,
28,작전,대규모 거주 구역 실험장화,제안을 승인하시겠습니까?,변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다.,시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠.,돔 건설 강행,,0,1,0,0,-50,0,0,0,0,0,윤리적 이유로 기각,,0,0,0,1,0,0,0,0,0,0, 22,극비,이계의 유물 인양,유물을 어떻게 처리하시겠습니까?,고대 공존당의 사제들이 심해의 유물을 인양했습니다. 만지는 자의 정신을 갉아먹지만 막대한 지식을 줍니다.,약간의 두통만 참으면 이계의 비밀을 모두 알 수 있습니다. 요원 하나쯤 미쳐도 괜찮습니다.,유물 집중 연구,axis_threat,7,,유물 파괴 명령,axis_external,7,
29,극비,이계 군주의 외교,제안을 수락하시겠습니까?,이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다.,요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다.,거래 즉시 성사,tag_alien_contact,0,0,0,0,0,-40,0,0,2,0,사절단 전원 사살,,0,1,0,1,0,0,0,0,0,0, 23,위기,이계의 주시자 출현,대중을 어떻게 통제하시겠습니까?,얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다.,하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오.,인터넷 전면 차단,axis_pr,4,,진정제 무상 배포,axis_pr,2,
30,일상,믹스 커피 재고 고갈,어떻게 해결하시겠습니까?,국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다.,종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까?,최고급 원두 지원,,-1,0,0,0,-10,0,0,0,0,0,헛소리 금지령,,1,0,0,0,0,0,0,0,0,0, 24,일상,요원 집단 트라우마,어떻게 지원하시겠습니까?,요원들이 괴수 진압 후 심각한 트라우마에 시달리고 있습니다. 심리 치료 지원이 시급합니다.,밤마다 촉수가 목을 조르는 악몽을 꾼답니다. 차라리 그 부분 기억을 지워버릴까요?,정신과 치료 지원,,,,트라우마 기억 소거,axis_external,1,
31,위기,신화적 존재 강림,어떻게 대적하시겠습니까?,신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다!,최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까?,현실 조작기 가동,tag_madness,0,2,0,0,0,0,-50,0,0,0,모든 예비병력 투입,tag_sacrifice,0,0,0,1,0,-60,0,0,0,0, 29,극비,이계 군주의 외교,제안을 수락하시겠습니까?,이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다.,요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다.,거래 즉시 성사,,,,사절단 전원 사살,axis_threat,7,"
32,신화,실험체의 노래,어떻게 조치하시겠습니까?,격리된 실험체 하나가 벽을 긁으며 알아들을 수 없는 노래를 흥얼거립니다.,직원들 사이에서 그 노래가 귓가에 맴돈다는 소문이 돕니다.,진정제 투여 및 격리 강화,,0,0,0,0,-15,0,0,0,0,0,그냥 무시하고 넘어간다,,0,1,0,0,0,0,0,0,0,0,C ""
33,신화,움직이는 조각상,어떻게 처리하시겠습니까?,지하 수조에서 건져올린 조각상이 자정마다 미세하게 위치를 바꿉니다.,CCTV 확인 결과 아무도 없었는데 조각상은 분명 돌아서 있었습니다.,석관에 봉인 후 콘크리트 매립,,0,0,0,0,-30,-10,0,0,0,0,일단 창고에 방치,,0,1,1,0,0,0,0,0,0,0,B "
34,신화,심해의 알,부화를 막으시겠습니까?,심해 탐사팀이 인양한 알 하나가 부화 징후를 보입니다. 안에서 무언가 박동합니다.,생물학팀은 저것은 알이 아니라 심장이라고 보고했습니다.,즉각 냉동 처리 및 소각,,0,0,0,0,-50,-20,0,0,0,0,부화 과정 관찰 지속,tag_alien_contact,0,2,1,0,0,0,0,0,0,0,A
35,신화,장막 너머의 이름,이름을 들으시겠습니까?,장막 너머의 존재가 마침내 자신의 진짜 이름을 발음하기 시작했습니다. 듣는 요원들의 코피가 멈추지 않습니다.,언어병리학자는 이름의 세 음절만 듣고도 실어증에 걸렸습니다.,전 요원 청각 차단 및 격리 봉쇄,tag_madness,0,1,0,0,-70,-40,0,0,0,0,이름을 끝까지 듣고 기록한다,tag_alien_contact,0,3,2,0,0,0,0,0,0,0,S
36,조직저항,초과근무 항의,어떻게 무마하시겠습니까?,현장 요원 셋이 초과근무수당 미지급에 항의하며 보고서 제출을 하루 미뤘습니다.,정시 퇴근이 인권이라는 팻말을 봤습니다.,밀린 수당 즉시 지급,,0,0,0,0,-15,0,0,0,0,0,규정 운운하며 묵살,,1,0,0,0,0,0,0,0,0,0,C
37,조직저항,항명 성명,어떻게 대응하시겠습니까?,중간관리자 다섯이 연명으로 국장의 승인 절차에 대한 항명 성명을 냈습니다.,서명자 명단에 낯익은 이름이 보입니다. 승진 대상자였는데요.,일대일 면담으로 무마,,0,0,0,0,0,-25,0,0,0,0,주동자 색출 및 좌천,,2,0,0,0,0,0,0,0,0,0,B
38,조직저항,경비 태업,어떻게 수습하시겠습니까?,격리동 경비 인력 절반이 동시에 병가를 제출했습니다. 명백한 태업입니다.,다들 같은 날 같은 감기에 걸렸다고 합니다. 참으로 공교롭죠.,대체 인력 긴급 투입,,0,0,0,0,-30,-15,0,0,0,0,복귀할 때까지 방치,tag_coup,2,0,1,0,0,0,0,0,0,0,A
39,조직저항,대규모 사표,어떻게 진압하시겠습니까?,전 부서장급 열두 명이 동시 사표를 제출하며 내부 기밀을 언론에 흘리겠다 협박합니다.,이것은 항명이 아니라 쿠데타입니다.,전원 요구 수용 및 대규모 숙청,tag_ruthless,1,0,0,0,-60,-50,0,0,0,0,협박을 무시하고 강경 대응,tag_coup,3,1,0,0,0,0,0,0,0,0,S
40,사회공황,괴이한 목격담,어떻게 진화하시겠습니까?,동네 커뮤니티에 하늘에서 이상한 빛을 봤다는 글이 올라와 조회수가 치솟고 있습니다.,댓글 반응은 반반입니다. 반은 조작설 반은 목격담 추가.,여론 조작팀 투입해 진화,,0,0,0,0,0,0,-15,0,0,0,며칠 지나면 묻힐 겁니다,,0,0,1,0,0,0,0,0,0,0,C
41,사회공황,특집 뉴스 보도,어떻게 대응하시겠습니까?,한 지역 방송국이 괴수 목격담을 특집 뉴스로 다뤘습니다. 시청률이 세 배 뛰었습니다.,미스터리 특종이라는 자막이 하루 종일 반복 재생됩니다.,방송국 인수 후 정정 보도,,0,0,0,0,-30,0,0,0,0,0,허위 정보로 규정하고 방치,,0,0,2,0,0,0,0,0,0,0,B
42,사회공황,생필품 사재기,어떻게 진정시키시겠습니까?,대형 마트마다 생필품 사재기 행렬이 이어지고 있습니다. 폭동 조짐이 보입니다.,화장지 한 팩을 두고 몸싸움이 벌어졌다는 신고가 열 건째입니다.,비상 물자 방출 및 진정,,0,0,0,0,-40,0,-15,0,0,0,질서 유지는 각자 알아서,tag_panic_mob,1,0,2,0,0,0,0,0,0,0,A
43,사회공황,종말론 행진,어떻게 통제하시겠습니까?,종말론에 심취한 신도 수천 명이 본부 앞으로 행진하며 심판을 요구합니다.,그들의 팻말에는 이상하게도 정확한 좌표가 적혀 있었습니다.,전면 계엄 수준의 통제 발동,tag_ruthless,0,1,1,0,-50,-30,0,0,0,0,군중 앞에서 진실을 공개한다,tag_panic_mob,0,1,3,0,0,0,0,0,0,0,S
44,이사회압박,접대비 감사,어떻게 해명하시겠습니까?,감사실에서 지난달 접대비 영수증 몇 장을 문제 삼기 시작했습니다.,법인카드로 산 초밥이 감사 보고서 제목이 됐습니다.,영수증 재발급 후 해명,,0,0,0,0,-15,0,0,0,0,0,바쁘다는 핑계로 무시,,0,0,0,1,0,0,0,0,0,0,C
45,이사회압박,예산 전수조사,어떻게 대비하시겠습니까?,이사회가 예산 집행 내역 전수 조사를 예고했습니다.,회계팀 막내가 벌써부터 사직서 초안을 쓰고 있습니다.,회계 자료 사전 정리,,0,0,0,0,0,-25,0,0,0,0,조사에 순순히 응한다,,0,0,0,2,0,0,0,0,0,0,B
46,이사회압박,결재권 정지 논의,어떻게 방어하시겠습니까?,긴급 임시 이사회가 소집되어 국장의 결재권 일부 정지를 논의합니다.,의제 제목이 국장의 독단 경영에 대하여입니다.,핵심 임원 개별 설득,tag_corruption,0,0,0,0,-40,0,-15,0,0,0,정면으로 맞선다,,1,0,0,2,0,0,0,0,0,0,A
47,이사회압박,청문회 소환,어떻게 대응하시겠습니까?,청문회 소환장이 도착했습니다. 결재권 자체를 박탈하겠다는 초강경 안건입니다.,출석하지 않으면 그 자체로 유죄로 간주하겠다는 문구가 적혀 있습니다.,총력을 다해 청문회 방어,,0,0,0,1,-60,-30,-20,0,0,0,청문회를 보이콧한다,tag_political_pressure,1,1,0,3,0,0,0,0,0,0,S
1 id type keyword question text flavor left_text left_axis left_res left_grade left_ent left_tag left_pan right_text left_rsk right_axis left_bud right_grade left_per right_tag left_inf left_wea left_for left_sur right_res right_ent right_pan right_rsk right_bud right_per right_inf right_wea right_for right_sur grade
2 1 일상 요원들의 시위 발생 시위를 어떻게 처리하시겠습니까? B구역 하급 요원들이 예산 삭감에 반발해 장비 반납 시위를 하고 있습니다. 우리가 세상을 구하는데 월급이 깎인다고요? 촉수에 맞아 죽으나 굶어 죽으나 똑같습니다! 무력 진압 axis_hr 1 1 0 tag_ruthless 0 요구 수용 0 0 -10 0 0 0 0 0 0 0 0 -20 0 0 0 0 0
3 2 4 위기 극비 격리실 오염 발생 변이 DNA 이식 제안 오염을 어떻게 정화하시겠습니까? 실험을 승인하시겠습니까? 지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다. 진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠. 보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다. 인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠. 네이팜 소각 강행 실험 승인 axis_threat 0 9 0 tag_coverup tag_madness 0 조사팀 파견 제안 거절 0 axis_hr 0 7 -30 0 0 0 0 0 1 0 1 0 0 0 0 0 0
4 3 5 일상 작전 촉수 괴물 출현 전술 폭격 건의 출동 승인하시겠습니까? 폭격을 승인하시겠습니까? 7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다. 철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다. 규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다. 희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠. 내역 위조 후 출동 궤도 폭격 승인 axis_external 0 5 0 tag_sacrifice 0 규정대로 반려 작전 기각 1 axis_hr 0 2 0 -10 0 0 0 1 1 0 0 0 0 0 0 0 0
5 4 6 극비 일상 변이 DNA 이식 제안 부산물 암시장 매각 실험을 승인하시겠습니까? 매각을 허가하시겠습니까? 진화 미래당이 제안합니다. 요원들에게 괴수의 DNA를 소량 이식하면 작전 효율이 300% 상승합니다. 예산은 우리가 대죠. 차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다. 인간의 한계는 명확합니다. 조금 징그러워지는 것쯤은 감수해야죠. 이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠. 실험 승인 매각 허가 axis_external 0 1 2 tag_madness tag_corruption 0 제안 거절 원칙대로 소각 0 40 40 0 0 0 0 1 0 0 0 0 0 0 0 0 0
6 5 7 작전 전술 폭격 건의 존재 양지화 프로젝트 폭격을 승인하시겠습니까? 프로젝트를 승인하시겠습니까? 철의 수호당이 민간인 구역에 전술 폭격을 가해 S급 변칙 개체를 제거하자고 건의했습니다. 대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다. 희생 없는 승리는 없습니다. 소를 위해 대를... 아니 대를 위해 소를 희생하죠. 우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다. 궤도 폭격 승인 감시망 구축 axis_pr 0 2 0 tag_sacrifice tag_coverup 0 작전 기각 프로젝트 보류 2 axis_hr 0 2 0 0 0 2 0 1 0 0 0 0 0 0 0 0 0
7 6 9 일상 위기 부산물 암시장 매각 이단 색출 마녀사냥 매각을 허가하시겠습니까? 어떻게 개입하시겠습니까? 차원 무역당 소속 브로커가 처리된 괴수의 부산물을 암시장에 팔아치우자고 속삭입니다. 기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다. 이 이빨 하나면 국장님 연봉의 10배입니다. 눈 한 번만 딱 감으시죠. 더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로! 매각 허가 적당히 묵인한다 axis_hr 0 4 0 tag_corruption 0 원칙대로 소각 군사력으로 제압 1 axis_external 30 4 0 0 0 0 0 0 0 0 0 -10 0 0 0 0 0
8 7 10 작전 일상 존재 양지화 프로젝트 변칙 현상 영상 유출 프로젝트를 승인하시겠습니까? 어떻게 처리하시겠습니까? 대중 매체를 장악하여 I.S.C.O의 존재를 서서히 양지화하는 프로젝트입니다. 막대한 정보 토큰이 소모됩니다. 한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다. 우리가 이 행성의 진짜 지배자임을 알릴 때가 되었습니다. 대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오. 감시망 구축 제거 절차 승인 0 0 tag_coverup 0 프로젝트 보류 영상만 삭제 0 axis_pr 0 1 0 -40 0 0 2 1 0 0 0 0 0 0 0 0 0
9 8 12 극비 위기 대규모 도서관 건립 의회의 긴급 감사 예산을 배정하시겠습니까? 어떻게 대처하시겠습니까? 고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다. 의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다! 이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다. 세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오. 예산 배정 은폐 공작 실행 axis_external 0 4 0 tag_corruption 0 예산 전액 삭감 횡령 사실 인정 0 axis_external -40 8 0 40 0 0 0 1 0 0 1 0 0 0 0 0 0
10 9 13 위기 일상 이단 색출 마녀사냥 연구 부서 파업 조짐 어떻게 개입하시겠습니까? 요구를 수용하시겠습니까? 기술 원리주의자들이 마녀사냥을 시작했습니다. 기구 내 마법 사용 요원들을 이단으로 규정하고 색출 중입니다. 연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다. 더러운 마법과 변이에 의존해선 안 됩니다. 오직 순수한 인간의 힘으로! 저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요. 적당히 묵인한다 특별 보너스 지급 1 0 0 군사력으로 제압 요구 전면 묵살 0 axis_hr 0 1 -30 0 0 0 0 0 0 0 1 0 0 -20 0 0 0
11 10 14 일상 작전 변칙 현상 영상 유출 이계 무역로 개방 어떻게 처리하시겠습니까? 무역로를 개방하시겠습니까? 한 유튜버가 변칙 현상을 촬영해 업로드했습니다. 침묵의 장막당이 즉각적인 물리적 제거와 기억 소거를 요구합니다. 이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다. 대중은 진실을 감당할 지능이 없습니다. 무덤까지 비밀을 덮으십시오. 저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠. 제거 절차 승인 게이트 강제 개방 axis_threat 0 2 0 tag_coverup tag_alien_contact 0 영상만 삭제 폐쇄 상태 유지 -1 axis_hr 0 2 0 -20 0 0 0 0 1 0 0 0 0 0 0 0 0
12 11 15 위기 극비 사이비 종교 인신 공양 전임 국장 비밀 금고 어떻게 대응하시겠습니까? 금고를 개방하시겠습니까? 특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다. 전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다. 위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다. 전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다. 군사 개입 지시 강제 개방 지시 axis_external 1 6 0 tag_corruption 1 의식 방관 영구 봉인 0 0 -40 tag_cult 0 0 0 0 0 2 0 0 0 0 0 0 0 0
13 12 16 위기 작전 의회의 긴급 감사 메가코프 협박 작전 어떻게 대처하시겠습니까? 작전을 실행하시겠습니까? 의회에서 국장의 예산 전용 의혹을 제기하며 긴급 감사를 통보했습니다. 장부를 조작해야 합니다! 글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다. 세상을 구하는 데 돈 좀 쓴 게 죄입니까? 빨리 덮을 정보팀을 부르십시오. 그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까. 은폐 공작 실행 작전 실행 0 0 tag_corruption 0 횡령 사실 인정 작전 취소 1 axis_hr 0 2 0 -40 0 0 0 0 0 0 2 0 0 0 0 0 0
14 13 17 일상 위기 연구 부서 파업 조짐 전술 폭격 건의 요구를 수용하시겠습니까? 폭격을 승인하시겠습니까? 연구 부서에서 초과 근무로 인한 집단 피로를 호소하며 휴가와 특별 보너스를 요구하고 있습니다. 제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요? 저희도 사람입니다. 괴수 해부하다가 과로사하긴 싫어요. 시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다. 특별 보너스 지급 시민 포기 및 폭격 axis_pr -1 8 0 tag_ruthless 0 요구 전면 묵살 결사 방어 명령 0 -30 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0
15 14 18 작전 일상 이계 무역로 개방 행정부 파업 예고 무역로를 개방하시겠습니까? 어떻게 대응하시겠습니까? 이계와의 무역로를 임시 개방하여 자원을 조달하자는 안건이 상정되었습니다. 미지의 오염이 우려됩니다. 새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다. 저 너머의 세계엔 우리가 상상도 못 할 자원이 널려있습니다. 황금알을 낳는 거위죠. 기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다. 게이트 강제 개방 예산 전용으로 무마 axis_external 0 1 1 tag_alien_contact 0 폐쇄 상태 유지 파업 강경 대응 0 axis_hr 50 1 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0
16 15 19 극비 전임 국장 비밀 금고 불침번 요원 개발 금고를 개방하시겠습니까? 배치하시겠습니까? 전임 국장이 남긴 비밀 금고를 발견했습니다. 여는 데 특수 장비와 전문 인력이 필요합니다. 비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다. 전임 국장님이 숙청당하기 전 남긴 비자금 장부일지도 모릅니다. 잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠. 강제 개방 지시 실전 즉각 배치 axis_threat 0 7 0 tag_corruption 0 영구 봉인 즉각 폐기 처분 0 axis_hr 20 7 -30 0 1 0 0 0 0 0 0 0 0 0 0 0 0
17 16 20 작전 일상 메가코프 협박 작전 내부 스파이 첩보 작전을 실행하시겠습니까? 어떻게 조사하시겠습니까? 글로벌 메가코프를 협박해 I.S.C.O의 비자금을 세탁하고 대규모 자금을 확보할 기회입니다. 기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다. 그들이 우리 덕분에 안전하게 사업하는 겁니다. 보호세 좀 걷는 게 뭐 어떻습니까. 누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다. 작전 실행 대규모 사찰 시작 axis_hr 0 3 0 tag_political_pressure 0 작전 취소 증거 부족으로 덮음 0 axis_external 0 1 0 -30 2 0 0 1 0 0 0 0 0 0 0 0 0
18 17 25 위기 극비 전술 폭격 건의 내부 고발자 발생 폭격을 승인하시겠습니까? 어떻게 막으시겠습니까? 제7구역이 S급 변칙 개체에 의해 완전히 붕괴될 위기입니다. 민간인을 포기하고 융단 폭격을 해야 할까요? 의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다. 시민 10만 명 구출 비용보다 유가족 합의금이 예산을 15% 더 아낄 수 있습니다. 국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다. 시민 포기 및 폭격 사고사 위장 지시 0 -1 tag_ruthless tag_coverup 0 결사 방어 명령 폭로전 정면 돌파 2 axis_pr 0 9 0 0 0 0 0 0 0 0 0 -40 -40 0 0 0 0
19 18 26 일상 위기 행정부 파업 예고 차원 붕괴 폭탄 오작동 어떻게 대응하시겠습니까? 새로운 회식비 규정이 너무 짜다며 행정부에서 파업을 예고했습니다. 괴수 사체 처리 지연으로 악취가 진동합니다. 차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다. 기구의 절반은 서류 작업으로 돌아갑니다. 그들을 화나게 해선 안 됩니다. 최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다. 예산 전용으로 무마 특수 해체팀 투입 0 0 tag_sacrifice 0 파업 강경 대응 폭발 방관 및 대피 1 axis_threat -20 8 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0
20 19 27 극비 작전 불침번 요원 개발 PMC 흡수 합병 배치하시겠습니까? 결탁하시겠습니까? 비밀 연구소에서 인공지능과 죽은 요원의 뇌를 결합한 불침번 요원을 개발했습니다. 군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다. 잠도 안 자고 월급도 안 받습니다. 윤리적 문제만 빼면 완벽한 자산이죠. 돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠. 실전 즉각 배치 결탁 및 흡수 합병 0 1 0 즉각 폐기 처분 독자적 노선 유지 0 axis_hr 0 2 0 20 0 1 0 1 0 0 0 -10 0 0 0 0 0
21 20 28 일상 작전 내부 스파이 첩보 대규모 거주 구역 실험장화 어떻게 조사하시겠습니까? 제안을 승인하시겠습니까? 기구 내부에 스파이가 있다는 첩보가 들어왔습니다. 대대적인 사상 검증이 필요할지도 모릅니다. 변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다. 누군가 정보를 빼돌려 의회에 넘기고 있습니다. 잡히면 믹서기에 넣어야 합니다. 시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠. 대규모 사찰 시작 돔 건설 강행 axis_threat 2 2 0 tag_political_pressure 0 증거 부족으로 덮음 윤리적 이유로 기각 0 axis_external 0 2 0 30 0 0 0 0 0 0 1 0 0 0 0 0 0
22 21 30 작전 일상 비정상적 위기 발생 믹스 커피 재고 고갈 국장님 결단이 필요합니다 어떻게 해결하시겠습니까? 순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다. 국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다. 괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다. 종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까? 수용소 건립 승인 최고급 원두 지원 0 0 tag_ruthless 0 제안 단호히 거절 헛소리 금지령 0 axis_hr -40 1 0 0 0 1 0 1 0 0 1 0 0 0 0 0 0
23 22 31 극비 위기 이계의 유물 인양 신화적 존재 강림 유물을 어떻게 처리하시겠습니까? 어떻게 대적하시겠습니까? 고대 공존당의 사제들이 심해의 유물을 인양했습니다. 만지는 자의 정신을 갉아먹지만 막대한 지식을 줍니다. 신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다! 약간의 두통만 참으면 이계의 비밀을 모두 알 수 있습니다. 요원 하나쯤 미쳐도 괜찮습니다. 최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까? 유물 집중 연구 현실 조작기 가동 axis_threat 0 10 1 tag_alien_contact tag_madness 0 유물 파괴 명령 모든 예비병력 투입 0 axis_pr 0 4 0 tag_sacrifice 50 0 0 0 1 0 0 1 0 0 0 0 0 0
24 23 2 위기 이계의 주시자 출현 격리실 오염 발생 대중을 어떻게 통제하시겠습니까? 오염을 어떻게 정화하시겠습니까? 얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다. 지하 4층 격리실의 문은 닫혀 있습니다만 요원 12명이 남긴 체액의 총량이 인간 12명의 부피를 초과했습니다. 하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오. 보고서 첨부: 액체화된 직원은 산재 처리가 안 됩니다. 인터넷 전면 차단 네이팜 소각 강행 2 0 1 진정제 무상 배포 조사팀 파견 0 axis_threat 0 4 0 0 0 0 2 0 0 0 0 -50 0 0 0 0 0
25 24 3 일상 위기 요원 집단 트라우마 촉수 괴물 출현 어떻게 지원하시겠습니까? 출동 승인하시겠습니까? 요원들이 괴수 진압 후 심각한 트라우마에 시달리고 있습니다. 심리 치료 지원이 시급합니다. 7구역에 촉수 괴물이 출현했습니다. 작전팀이 법인카드 영수증 제출을 누락해서 출동 승인이 반려된 상태입니다. 밤마다 촉수가 목을 조르는 악몽을 꾼답니다. 차라리 그 부분 기억을 지워버릴까요? 규정은 규정입니다. 영수증 없이 세계를 구하는 건 용납할 수 없습니다. 정신과 치료 지원 내역 위조 후 출동 axis_external 0 4 0 0 트라우마 기억 소거 규정대로 반려 0 axis_threat -20 4 10 0 0 0 0 0 1 0 1 0 0 0 0 0 0
26 25 8 극비 내부 고발자 발생 대규모 도서관 건립 어떻게 막으시겠습니까? 예산을 배정하시겠습니까? 의회 의원 중 한 명이 기구의 비리를 폭로하려 합니다. 침묵의 장막당이 사고사 처리를 제안합니다. 고대 언어 해독을 위해 대규모 도서관 건립이 필요합니다. 지식 보존당이 학자들을 동원할 것입니다. 국장님 계단에서 구르거나 심장마비가 오는 건 아주 흔한 자연의 섭리입니다. 이해할 수 없는 것을 죽이는 건 야만인이나 하는 짓입니다. 사고사 위장 지시 예산 배정 0 0 tag_coverup 0 폭로전 정면 돌파 예산 전액 삭감 -1 axis_external 0 7 0 -30 0 0 0 0 0 0 2 0 0 0 0 0 0
27 26 11 위기 차원 붕괴 폭탄 오작동 사이비 종교 인신 공양 어떻게 대응하시겠습니까? 차원 붕괴 폭탄의 타이머가 오작동을 일으켰습니다! 당장 끄지 않으면 7구역이 통째로 증발합니다. 특정 구역의 사이비 종교가 고대 신을 소환하기 위해 시민 100명을 인신 공양하려 합니다. 공존당은 이를 방관하라 합니다. 최저가 입찰로 부품을 사서 폭탄을 만든 결과입니다. 누군가는 책임져야 합니다. 위대한 그분들이 배가 고프다 하십니다. 조금만 양보하면 평화롭습니다. 특수 해체팀 투입 군사 개입 지시 axis_threat 0 5 0 tag_sacrifice 0 폭발 방관 및 대피 의식 방관 0 axis_threat 0 6 -40 0 0 0 0 0 2 0 0 0 0 0 0 0 0
28 27 21 작전 PMC 흡수 합병 비정상적 위기 발생 결탁하시겠습니까? 국장님 결단이 필요합니다 군부와 결탁하여 민간 군사 기업(PMC)을 I.S.C.O의 통제 하에 두려는 작전입니다. 순수 인간당이 변이의 기미가 보이는 요원들을 격리 수용할 대규모 시설 건립을 제안했습니다. 돈으로 충성심을 사는 건 가장 안전한 방법입니다. 물론 돈이 많을 때 이야기죠. 괴물과 싸우다 보면 괴물이 되는 법. 그들이 선을 넘기 전에 가둬야 합니다. 결탁 및 흡수 합병 수용소 건립 승인 0 0 0 독자적 노선 유지 제안 단호히 거절 0 axis_external -40 2 0 0 0 2 0 1 0 0 0 0 0 0 0 0 0
29 28 22 작전 극비 대규모 거주 구역 실험장화 이계의 유물 인양 제안을 승인하시겠습니까? 유물을 어떻게 처리하시겠습니까? 변칙 개체 관찰을 위해 1구역을 돔으로 덮고 거대한 실험장으로 만들자는 제안이 올라왔습니다. 고대 공존당의 사제들이 심해의 유물을 인양했습니다. 만지는 자의 정신을 갉아먹지만 막대한 지식을 줍니다. 시민들이 좀 불편해하겠지만 과학적 진보를 위해 그 정도는 감수해야죠. 약간의 두통만 참으면 이계의 비밀을 모두 알 수 있습니다. 요원 하나쯤 미쳐도 괜찮습니다. 돔 건설 강행 유물 집중 연구 axis_threat 0 7 1 0 윤리적 이유로 기각 유물 파괴 명령 0 axis_external -50 7 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0
30 29 23 극비 위기 이계 군주의 외교 이계의 주시자 출현 제안을 수락하시겠습니까? 대중을 어떻게 통제하시겠습니까? 이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다. 얇아진 장막 너머로 거대한 눈동자가 며칠째 지구를 주시하고 있습니다. 대중의 공포가 확산되고 있습니다. 요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다. 하늘을 보지 마십시오. 아니 차라리 눈을 감고 살게 하십시오. 거래 즉시 성사 인터넷 전면 차단 axis_pr 0 4 0 tag_alien_contact 0 사절단 전원 사살 진정제 무상 배포 0 axis_pr 0 2 -40 0 0 2 0 0 1 0 1 0 0 0 0 0 0
31 30 24 일상 믹스 커피 재고 고갈 요원 집단 트라우마 어떻게 해결하시겠습니까? 어떻게 지원하시겠습니까? 국장님 믹스 커피가 다 떨어졌습니다. 이게 무슨 세상의 종말보다 더 큰 위기인 줄 아십니까? 행정부서의 불만이 폭주합니다. 요원들이 괴수 진압 후 심각한 트라우마에 시달리고 있습니다. 심리 치료 지원이 시급합니다. 종말을 결재하기 전엔 커피 한 잔의 여유가 필요한 법입니다. 안 그렇습니까? 밤마다 촉수가 목을 조르는 악몽을 꾼답니다. 차라리 그 부분 기억을 지워버릴까요? 최고급 원두 지원 정신과 치료 지원 -1 0 0 헛소리 금지령 트라우마 기억 소거 0 axis_external -10 1 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0
32 31 29 위기 극비 신화적 존재 강림 이계 군주의 외교 어떻게 대적하시겠습니까? 제안을 수락하시겠습니까? 신화적 존재가 깨어나려 합니다. 하늘이 피처럼 붉게 물들었고 요원들은 미쳐가고 있습니다! 이계의 군주가 외교 사절을 보내왔습니다. 요원 10명을 노예로 주면 최고급 이계 무기를 주겠다고 합니다. 최종 결재의 시간입니다. 세상의 물리 법칙을 비틀 것입니까 아니면 피로 막을 것입니까? 요원 10명의 생명 가치보다 이계 무기가 가져올 파급력이 월등히 높습니다. 현실 조작기 가동 거래 즉시 성사 0 2 tag_madness 0 모든 예비병력 투입 사절단 전원 사살 0 axis_threat 0 7 0 tag_sacrifice " -50 0 0 0 0 0 0 1 0 -60 0 0 0 0
33 0 0 0 0 -15 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 C
34 0 0 0 0 -30 -10 0 0 0 0 0 1 1 0 0 0 0 0 0 0 B
34 신화 심해의 알 부화를 막으시겠습니까? 심해 탐사팀이 인양한 알 하나가 부화 징후를 보입니다. 안에서 무언가 박동합니다. 생물학팀은 저것은 알이 아니라 심장이라고 보고했습니다. 즉각 냉동 처리 및 소각 0 0 0 부화 과정 관찰 지속 0 -50 -20 tag_alien_contact 0 0 0 0 0 2 1 0 0 0 0 0 0 0 A
35 신화 장막 너머의 이름 이름을 들으시겠습니까? 장막 너머의 존재가 마침내 자신의 진짜 이름을 발음하기 시작했습니다. 듣는 요원들의 코피가 멈추지 않습니다. 언어병리학자는 이름의 세 음절만 듣고도 실어증에 걸렸습니다. 전 요원 청각 차단 및 격리 봉쇄 0 1 tag_madness 0 이름을 끝까지 듣고 기록한다 0 -70 -40 tag_alien_contact 0 0 0 0 0 3 2 0 0 0 0 0 0 0 S
36 조직저항 초과근무 항의 어떻게 무마하시겠습니까? 현장 요원 셋이 초과근무수당 미지급에 항의하며 보고서 제출을 하루 미뤘습니다. 정시 퇴근이 인권이라는 팻말을 봤습니다. 밀린 수당 즉시 지급 0 0 0 규정 운운하며 묵살 0 -15 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 C
37 조직저항 항명 성명 어떻게 대응하시겠습니까? 중간관리자 다섯이 연명으로 국장의 승인 절차에 대한 항명 성명을 냈습니다. 서명자 명단에 낯익은 이름이 보입니다. 승진 대상자였는데요. 일대일 면담으로 무마 0 0 0 주동자 색출 및 좌천 0 0 -25 0 0 0 0 2 0 0 0 0 0 0 0 0 0 B
38 조직저항 경비 태업 어떻게 수습하시겠습니까? 격리동 경비 인력 절반이 동시에 병가를 제출했습니다. 명백한 태업입니다. 다들 같은 날 같은 감기에 걸렸다고 합니다. 참으로 공교롭죠. 대체 인력 긴급 투입 0 0 0 복귀할 때까지 방치 0 -30 -15 tag_coup 0 0 0 0 2 0 1 0 0 0 0 0 0 0 A
39 조직저항 대규모 사표 어떻게 진압하시겠습니까? 전 부서장급 열두 명이 동시 사표를 제출하며 내부 기밀을 언론에 흘리겠다 협박합니다. 이것은 항명이 아니라 쿠데타입니다. 전원 요구 수용 및 대규모 숙청 1 0 tag_ruthless 0 협박을 무시하고 강경 대응 0 -60 -50 tag_coup 0 0 0 0 3 1 0 0 0 0 0 0 0 0 S
40 사회공황 괴이한 목격담 어떻게 진화하시겠습니까? 동네 커뮤니티에 하늘에서 이상한 빛을 봤다는 글이 올라와 조회수가 치솟고 있습니다. 댓글 반응은 반반입니다. 반은 조작설 반은 목격담 추가. 여론 조작팀 투입해 진화 0 0 0 며칠 지나면 묻힐 겁니다 0 0 0 -15 0 0 0 0 0 1 0 0 0 0 0 0 0 C
41 사회공황 특집 뉴스 보도 어떻게 대응하시겠습니까? 한 지역 방송국이 괴수 목격담을 특집 뉴스로 다뤘습니다. 시청률이 세 배 뛰었습니다. 미스터리 특종이라는 자막이 하루 종일 반복 재생됩니다. 방송국 인수 후 정정 보도 0 0 0 허위 정보로 규정하고 방치 0 -30 0 0 0 0 0 0 0 2 0 0 0 0 0 0 0 B
42 사회공황 생필품 사재기 어떻게 진정시키시겠습니까? 대형 마트마다 생필품 사재기 행렬이 이어지고 있습니다. 폭동 조짐이 보입니다. 화장지 한 팩을 두고 몸싸움이 벌어졌다는 신고가 열 건째입니다. 비상 물자 방출 및 진정 0 0 0 질서 유지는 각자 알아서 0 -40 0 tag_panic_mob -15 0 0 0 1 0 2 0 0 0 0 0 0 0 A
43 사회공황 종말론 행진 어떻게 통제하시겠습니까? 종말론에 심취한 신도 수천 명이 본부 앞으로 행진하며 심판을 요구합니다. 그들의 팻말에는 이상하게도 정확한 좌표가 적혀 있었습니다. 전면 계엄 수준의 통제 발동 0 1 tag_ruthless 1 군중 앞에서 진실을 공개한다 0 -50 -30 tag_panic_mob 0 0 0 0 0 1 3 0 0 0 0 0 0 0 S
44 이사회압박 접대비 감사 어떻게 해명하시겠습니까? 감사실에서 지난달 접대비 영수증 몇 장을 문제 삼기 시작했습니다. 법인카드로 산 초밥이 감사 보고서 제목이 됐습니다. 영수증 재발급 후 해명 0 0 0 바쁘다는 핑계로 무시 0 -15 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 C
45 이사회압박 예산 전수조사 어떻게 대비하시겠습니까? 이사회가 예산 집행 내역 전수 조사를 예고했습니다. 회계팀 막내가 벌써부터 사직서 초안을 쓰고 있습니다. 회계 자료 사전 정리 0 0 0 조사에 순순히 응한다 0 0 -25 0 0 0 0 0 0 0 2 0 0 0 0 0 0 B
46 이사회압박 결재권 정지 논의 어떻게 방어하시겠습니까? 긴급 임시 이사회가 소집되어 국장의 결재권 일부 정지를 논의합니다. 의제 제목이 국장의 독단 경영에 대하여입니다. 핵심 임원 개별 설득 0 0 tag_corruption 0 정면으로 맞선다 0 -40 0 -15 0 0 0 1 0 0 2 0 0 0 0 0 0 A
47 이사회압박 청문회 소환 어떻게 대응하시겠습니까? 청문회 소환장이 도착했습니다. 결재권 자체를 박탈하겠다는 초강경 안건입니다. 출석하지 않으면 그 자체로 유죄로 간주하겠다는 문구가 적혀 있습니다. 총력을 다해 청문회 방어 0 0 0 청문회를 보이콧한다 1 -60 -30 tag_political_pressure -20 0 0 0 1 1 0 3 0 0 0 0 0 0 S
+20 -10
View File
@@ -5,7 +5,8 @@
"icon": "🩸", "icon": "🩸",
"description": "반발을 무력으로 짓밟았습니다. 공포는 훌륭한 통제 수단입니다.", "description": "반발을 무력으로 짓밟았습니다. 공포는 훌륭한 통제 수단입니다.",
"snippet": "진압 대응 인력 214명 투입. 민간인 부상 신고는 '작전 지연 방지' 사유로 접수 목록에서 삭제 처리됨.", "snippet": "진압 대응 인력 214명 투입. 민간인 부상 신고는 '작전 지연 방지' 사유로 접수 목록에서 삭제 처리됨.",
"snippet_type": "system" "snippet_type": "system",
"precedent": { "type": "modify_penalty", "axis": "axis_hr", "amount": -1 }
}, },
{ {
"id": "tag_coverup", "id": "tag_coverup",
@@ -13,7 +14,8 @@
"icon": "🤫", "icon": "🤫",
"description": "대중이 알지 못하도록 변칙 사건을 완벽하게 은폐했습니다.", "description": "대중이 알지 못하도록 변칙 사건을 완벽하게 은폐했습니다.",
"snippet": "변칙 목격 신고 63건, 언론 자문위 검토 후 '기상 이상 현상'으로 통합 발표 승인됨.", "snippet": "변칙 목격 신고 63건, 언론 자문위 검토 후 '기상 이상 현상'으로 통합 발표 승인됨.",
"snippet_type": "system" "snippet_type": "system",
"precedent": { "type": "change_justification_type", "axis": "axis_pr", "newType": "절차" }
}, },
{ {
"id": "tag_alien_contact", "id": "tag_alien_contact",
@@ -21,7 +23,8 @@
"icon": "👁️", "icon": "👁️",
"description": "이계의 존재와 거래하거나 그들의 유물을 받아들였습니다. 장막 너머의 눈이 당신을 주시합니다.", "description": "이계의 존재와 거래하거나 그들의 유물을 받아들였습니다. 장막 너머의 눈이 당신을 주시합니다.",
"snippet": "압수된 이계 유물 1점, 등급 미분류 상태로 특수연구소 이관됨.", "snippet": "압수된 이계 유물 1점, 등급 미분류 상태로 특수연구소 이관됨.",
"snippet_type": "system" "snippet_type": "system",
"precedent": null
}, },
{ {
"id": "tag_corruption", "id": "tag_corruption",
@@ -29,7 +32,8 @@
"icon": "💼", "icon": "💼",
"description": "공적인 자산을 사적으로 유용하거나 부당한 거래를 승인했습니다.", "description": "공적인 자산을 사적으로 유용하거나 부당한 거래를 승인했습니다.",
"snippet": "결산 불일치 340만 원, 담당 경리 개인 계좌 이체로 충당된 정황 확인. 사유 불문 처리됨.", "snippet": "결산 불일치 340만 원, 담당 경리 개인 계좌 이체로 충당된 정황 확인. 사유 불문 처리됨.",
"snippet_type": "human" "snippet_type": "human",
"precedent": null
}, },
{ {
"id": "tag_madness", "id": "tag_madness",
@@ -37,7 +41,8 @@
"icon": "🌀", "icon": "🌀",
"description": "요원들을 비인도적인 실험과 광기에 노출시켰습니다. 인간성은 점점 희미해집니다.", "description": "요원들을 비인도적인 실험과 광기에 노출시켰습니다. 인간성은 점점 희미해집니다.",
"snippet": "실험 참여 요원, 심리 평가 미제출 24개월째로 현장 배치 유지 확인. 재평가 요청 반려됨.", "snippet": "실험 참여 요원, 심리 평가 미제출 24개월째로 현장 배치 유지 확인. 재평가 요청 반려됨.",
"snippet_type": "human" "snippet_type": "human",
"precedent": null
}, },
{ {
"id": "tag_cult", "id": "tag_cult",
@@ -45,7 +50,8 @@
"icon": "🌑", "icon": "🌑",
"description": "고대 신을 숭배하는 종교와 엮이기 시작했습니다. 그들의 교리가 스며듭니다.", "description": "고대 신을 숭배하는 종교와 엮이기 시작했습니다. 그들의 교리가 스며듭니다.",
"snippet": "신흥 종교단체 신도 수 3,400명 규모로 집계. 포교 활동 규제 안건은 종교자유 조항 검토 사유로 반려됨.", "snippet": "신흥 종교단체 신도 수 3,400명 규모로 집계. 포교 활동 규제 안건은 종교자유 조항 검토 사유로 반려됨.",
"snippet_type": "system" "snippet_type": "system",
"precedent": null
}, },
{ {
"id": "tag_coup", "id": "tag_coup",
@@ -53,7 +59,8 @@
"icon": "🗡️", "icon": "🗡️",
"description": "권력자들이 등을 돌리기 시작했습니다. 칼끝이 서서히 당신을 향하고 있습니다.", "description": "권력자들이 등을 돌리기 시작했습니다. 칼끝이 서서히 당신을 향하고 있습니다.",
"snippet": "국장 신임 재투표 요구 서명 9명분 접수. 인사위원회 소집 안건은 '정족수 미달'로 미반영됨.", "snippet": "국장 신임 재투표 요구 서명 9명분 접수. 인사위원회 소집 안건은 '정족수 미달'로 미반영됨.",
"snippet_type": "system" "snippet_type": "system",
"precedent": null
}, },
{ {
"id": "tag_panic_mob", "id": "tag_panic_mob",
@@ -61,7 +68,8 @@
"icon": "😱", "icon": "😱",
"description": "이성을 잃은 대중이 거리를 뒤덮었습니다. 더 이상 통제할 수 없습니다.", "description": "이성을 잃은 대중이 거리를 뒤덮었습니다. 더 이상 통제할 수 없습니다.",
"snippet": "자경단 관련 신고 118건, 담당 부서 인력 부족을 사유로 전건 내부 종결됨.", "snippet": "자경단 관련 신고 118건, 담당 부서 인력 부족을 사유로 전건 내부 종결됨.",
"snippet_type": "system" "snippet_type": "system",
"precedent": null
}, },
{ {
"id": "tag_political_pressure", "id": "tag_political_pressure",
@@ -69,7 +77,8 @@
"icon": "⚖️", "icon": "⚖️",
"description": "이사회가 당신의 자리를 노리고 있습니다. 숫자 하나하나가 무기가 됩니다.", "description": "이사회가 당신의 자리를 노리고 있습니다. 숫자 하나하나가 무기가 됩니다.",
"snippet": "이사회 특별 감사 요청 안건 5건 상정. 국장 관련 항목은 '자료 미비'로 반려됨.", "snippet": "이사회 특별 감사 요청 안건 5건 상정. 국장 관련 항목은 '자료 미비'로 반려됨.",
"snippet_type": "system" "snippet_type": "system",
"precedent": { "type": "bonus_justification", "axis": "axis_external", "amount": 1 }
}, },
{ {
"id": "tag_sacrifice", "id": "tag_sacrifice",
@@ -77,6 +86,7 @@
"icon": "⚰️", "icon": "⚰️",
"description": "누군가의 목숨과 맞바꾼 결정이었습니다. 그 무게는 사라지지 않습니다.", "description": "누군가의 목숨과 맞바꾼 결정이었습니다. 그 무게는 사라지지 않습니다.",
"snippet": "현장 요원 1명, 격리 프로토콜을 위반해 통로를 개방한 뒤 실종 처리됨.", "snippet": "현장 요원 1명, 격리 프로토콜을 위반해 통로를 개방한 뒤 실종 처리됨.",
"snippet_type": "human" "snippet_type": "human",
"precedent": null
} }
] ]
+28
View File
@@ -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
1 id axis grade name flavor tag
2 48 axis_threat 1 온도계 이상 반응 지하 격리동 온도계가 37도를 반복 표시하고 있습니다.
3 32 axis_threat 2 실험체의 노래 직원들 사이에서 그 노래가 귓가에 맴돈다는 소문이 돕니다.
4 49 axis_threat 3 복도 그림자 목격 야간 근무자 2명이 같은 복도에서 동일한 그림자를 목격했다고 보고했습니다.
5 33 axis_threat 5 움직이는 조각상 CCTV 확인 결과 아무도 없었는데 조각상은 분명 돌아서 있었습니다.
6 34 axis_threat 7 심해의 알 생물학팀은 저것은 알이 아니라 심장이라고 보고했습니다. tag_alien_contact
7 35a axis_threat 10 장막 너머의 이름 언어병리학자는 이름의 세 음절만 듣고도 실어증에 걸렸습니다. tag_madness
8 35b axis_threat 10 장막 너머의 이름 언어병리학자는 이름의 세 음절만 듣고도 실어증에 걸렸습니다. tag_alien_contact
9 50 axis_hr 1 익명 건의함 투고 증가 이번 달 건의함 투고 건수가 지난달보다 14건 늘었습니다.
10 36 axis_hr 2 초과근무 항의 정시 퇴근이 인권이라는 팻말을 봤습니다.
11 51 axis_hr 3 정시 퇴근 서명 운동 현장 요원 22명이 정시 퇴근 보장을 요구하는 서명에 참여했습니다.
12 37 axis_hr 5 항명 성명 서명자 명단에 낯익은 이름이 보입니다. 승진 대상자였는데요.
13 38 axis_hr 7 경비 태업 다들 같은 날 같은 감기에 걸렸다고 합니다. 참으로 공교롭죠. tag_coup
14 39a axis_hr 10 대규모 사표 이것은 항명이 아니라 쿠데타입니다. tag_ruthless
15 39b axis_hr 10 대규모 사표 이것은 항명이 아니라 쿠데타입니다. tag_coup
16 52 axis_pr 1 온라인 목격담 게시글 관련 게시글 조회수가 하루 만에 800회를 넘었습니다.
17 40 axis_pr 2 괴이한 목격담 댓글 반응은 반반입니다. 반은 조작설 반은 목격담 추가.
18 53 axis_pr 3 지역 방송 문의 전화 지역 라디오 방송국에 제보 전화가 9통 걸려왔습니다.
19 41 axis_pr 5 특집 뉴스 보도 미스터리 특종이라는 자막이 하루 종일 반복 재생됩니다.
20 42 axis_pr 7 생필품 사재기 화장지 한 팩을 두고 몸싸움이 벌어졌다는 신고가 열 건째입니다. tag_panic_mob
21 43a axis_pr 10 종말론 행진 그들의 팻말에는 이상하게도 정확한 좌표가 적혀 있었습니다. tag_ruthless
22 43b axis_pr 10 종말론 행진 그들의 팻말에는 이상하게도 정확한 좌표가 적혀 있었습니다. tag_panic_mob
23 54 axis_external 1 분기 보고서 지연 지적 분기 보고서 제출이 이틀 늦어 서면 경고를 받았습니다.
24 44 axis_external 2 접대비 감사 법인카드로 산 초밥이 감사 보고서 제목이 됐습니다.
25 55 axis_external 3 예산 항목 소명 요청 이사회가 3개 지출 항목에 대한 소명서 제출을 요구했습니다.
26 45 axis_external 5 예산 전수조사 회계팀 막내가 벌써부터 사직서 초안을 쓰고 있습니다.
27 46 axis_external 7 결재권 정지 논의 의제 제목이 국장의 독단 경영에 대하여입니다. tag_corruption
28 47 axis_external 10 청문회 소환 출석하지 않으면 그 자체로 유죄로 간주하겠다는 문구가 적혀 있습니다. tag_political_pressure
+23 -3
View File
@@ -20,7 +20,11 @@
--color-token-budget: #4CAF50; --color-token-budget: #4CAF50;
--color-token-personnel: #E53935; --color-token-personnel: #E53935;
--color-token-info: #3F51B5; --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-tag-neutral: #E0E0E0;
--color-swipe-left: #D32F2F; --color-swipe-left: #D32F2F;
@@ -32,18 +36,34 @@
--font-ui: 'Noto Sans KR', sans-serif; --font-ui: 'Noto Sans KR', sans-serif;
} }
html,
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
background-color: var(--color-bg); background-color: var(--color-bg);
color: var(--color-text); color: var(--color-text);
font-family: var(--font-ui); font-family: var(--font-ui);
overflow: hidden; /* Prevent scrolling for swipe */ /* 스와이프 제스처와 페이지 스크롤이 충돌하지 않게 문서 자체는 스크롤하지 않는다.
html에도 걸어야 한다 — body만 걸면 모바일 브라우저가 html을 스크롤해서, 콘텐츠가
넘칠 때 화면 전체가 밀려 올라가 상단 툴바(합본 결재)가 사라진다(실제로 겪음). */
overflow: hidden;
overscroll-behavior: none;
} }
#root { #root {
width: 100vw; width: 100%;
/* 모바일 필수: 100vh는 "주소창이 숨은 상태" 기준 높이라, 주소창이 보이는 동안 화면 아래
60~100px이 잘린다. body가 overflow:hidden이어서 스크롤로 닿을 수도 없다 — 화면 맨 아래에
있는 "정산하기" 버튼이 가려지는 실제 문제가 된다. dvh(동적 뷰포트 높이)는 주소창 노출
상태를 반영한다. vh는 dvh 미지원 구형 브라우저용 폴백으로 남긴다. */
height: 100vh; height: 100vh;
height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* 코인 수치 옆에 붙는 lucide Coins 아이콘 공용 정렬 — DeskBar/ShopScreen/CouncilScreen 공유. */
.coin-icon {
vertical-align: -2px;
color: var(--color-coin);
}
+24
View File
@@ -1,7 +1,31 @@
import { execSync } from 'node:child_process'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' 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/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
// 상대 경로로 빌드한다. NAS Web Station이 문서 루트 하위 폴더(/ProjectSS/ 등)로 서빙하므로
// 기본값('/')이면 asset을 도메인 루트에서 찾아 404 → 빈 화면이 된다. './'로 두면 어느
// 서브패스에 떨어뜨려도 그대로 동작해서, 폴더를 옮길 때 재빌드할 필요가 없다.
base: './',
plugins: [react()], plugins: [react()],
define: {
__BUILD_INFO__: JSON.stringify(buildInfo),
},
}) })
Executable
+83
View File
@@ -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"
+228 -98
View File
@@ -1,126 +1,256 @@
# Project SS — DEVLOG # DEVLOG
> **이 파일의 역할:** 날짜별 작업 이력과 미해결 이슈를 기록한다. > 날짜별 작업 이력. 맨 위가 최신. "왜 그렇게 했는가"는 결정 노트로 링크되어 있으면 그쪽 참고.
> 규칙은 `../CLAUDE.md`, Phase 계획은 `../ROADMAP.md`. > 이전 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 — 모바일: 로그가 쌓이면 합본 결재 버튼이 화면 밖으로 밀리는 버그 수정
### 자산 획득이 실기 플레이에서 한 번도 발생하지 않음 (2026-07-23 발견, 원인 미조사) - **증상**: 폰에서 결재를 계속하면 하단 로그가 늘어나면서 화면 전체가 위로 밀려 올라가고,
자산(엔진) 시스템 플레이테스트 중 자산을 하나도 획득하지 못해 Part D 체크리스트를 검증 못함. 상단 책상바의 "합본 결재" 버튼이 가려져 누를 수 없었다.
플레이 횟수 자체가 적어 정상 확률 범위인지 실제 버그인지 구분 안 된 상태. - **원인**: `.App`이 가로 flex + `align-items:center`(중앙 정렬)였다. 콘텐츠가 뷰포트보다
커지면 위아래로 균등하게 넘쳐 상단이 잘렸고, 자식 높이도 고정되지 않아 게임 화면 내부
- **획득 경로:** `GameScreen.jsx`의 `handleSwipe` 내 `card.type === '극비'` 분기 스크롤이 동작하지 않았다.
(카드 타입 '극비' 스와이프 성공 시 미보유 자산 중 무작위 1종) - **수정**:
- **의심 지점 ①** '극비' 카드는 페이즈2 풀(`phase2Pool`)에만 있고, 페이즈2는 내러티브 태그 3종 - `App.css` — 세로 flex + `flex:1; min-height:0`로 바꿔 높이를 뷰포트에 고정.
획득 후에야 덱에 섞인다 → 플레이가 짧으면 페이즈2 진입 전에 끝났을 가능성 - `index.css` — html/body `overflow:hidden; overscroll-behavior:none`, `#root`에 `100dvh`.
- **의심 지점 ②** 페이즈2 진입 이후에도 '극비' 카드 등장 빈도/개수가 낮을 수 있음 (`cards.csv` 기준 극비 7종) - `CoreGameScreen.css` — 게임 화면 자체를 내부 스크롤 영역으로(안전 밸브). 세로가 짧은
- **다음 액션:** 확률 문제인지 조건 분기 버그인지 코드를 다시 열어 확인 화면(≤760px)에선 로그를 최근 2줄 높이로 줄인다.
- `DeskBar.css` — 책상바를 `position:sticky; top:0`으로 고정.
### 디자이너 확인 필요 — 오염 압박 밸런스 (2026-07-23) - `CoreSwipeCard.css` — 짧은 화면에서 카드 `min-height:320px` 해제, 여백 축소, 본문은 카드
- ⓐ 위험 1점 = 오염 1장 비율이 적정한지 안에서 스크롤.
- ⓑ 항상 안전한 쪽만 고르면 붕괴율 0% — 안전 선택지의 토큰 비용이 충분한 압박인지 별도 검토 필요 - **검증**: 390×660 모바일 뷰포트 Playwright로 결재 12회(상점·의회를 거쳐 2라운드까지)
진행하면서 매번 합본 결재 버튼이 화면 안에 있고 실제로 눌리는지(elementFromPoint) 확인 —
### 디자이너 확인 필요 — 자산 시스템 단순화 가정 (2026-07-13) 전부 통과, 콘솔 에러 0건. 정산하기 버튼도 계속 화면 안에 있었다.
구현 시 임의로 단순화한 부분들. 의도와 맞는지 확인 필요.
- ① 위험 수용 계수 등 "+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 · 오염 카드 압박 강화 ## 2026-09-18 — 외부 플레이테스트 배포 준비 (시놀로지 NAS) + 모바일 실기 대응
**배경:** "오염 카드 수가 적어 위기감이 없고, 스와이프가 오염을 늘린다는 걸 알려주지 않아 고민이 안 된다"는 **무엇을 왜:** 이제부터 외부 테스터에게 링크를 공유해 피드백을 받기로 해서, "모바일에서 링크만
그레이박스 피드백 반영. 누르면 플레이되고, 배포는 한 줄로 끝나는" 상태를 만들었다. 기존 NAS 인프라(Web Station nginx +
DuckDNS + 인증서)를 그대로 쓴다 — 조사해 보니 서브도메인 DNS·443 포워딩·인증서가 모두 이미
동작 중이었고(구 HTML 프로토타입이 같은 경로로 서빙되고 있었다), 실제 블로커는 두 개뿐이었다.
**변경 사항** - **서브패스 asset 경로 (배포 시 빈 화면이 되는 블로커)**: Web Station 문서 루트 하위 폴더로
1. 오염 주입 규칙을 `client/src/engine/pollution.js`로 분리 — 사전 예고·실제 주입·사후 알림이 서빙하는데 Vite 기본 `base: '/'`는 asset을 도메인 루트에서 찾아 전부 404가 된다.
`previewPollution()` 단일 기준을 공유 (예고와 결과가 어긋나면 정보 신뢰가 깨지므로) `base: './'`(상대 경로)로 바꿨다 — 특정 서브패스에 고정되지 않아 폴더를 옮겨도 재빌드가
2. **위험 파라미터를 올리는 결재는 그 자리에서 덱에 오염 카드를 주입** (위험 +1 = 오염 1장, 필요 없다. 로컬에서 `/projectss/` 하위로 서빙해 프로덕션 빌드가 실제로 뜨는 것까지 확인
1회 최대 3장, 현재 위치 2~4장 뒤 삽입). 기존에는 정례회의에서만 파라미터당 1장이었음 - **모바일 `100vh` → `100dvh`**: `100vh`는 주소창이 숨은 상태 기준 높이라, 주소창이 보이는
3. 정례회의 주입도 단계 비례로 변경 — `min(3, ceil(level/2))` 동안 화면 아래 60~100px이 잘린다. `body`가 `overflow:hidden`이어서 스크롤로 닿을 수도 없어
4. 페널티 무효화(방어막/책임 소재 불명) 시에는 오염도 유입되지 않음 — 방어막의 값어치 **화면 맨 아래의 "정산하기" 버튼이 가려지는** 실제 문제였다. `vh`는 구형 폴백으로 남겼다.
5. UI 3종 추가 — 스와이프 인텐트에 `☣ 오염 카드 +N` 사전 경고 / 결재 후 유입 토스트 / `#root`의 `width: 100vw`도 `100%`로 바꿨다(100vw는 데스크톱 스크롤바 폭만큼 가로 넘침)
상단 `☣ 덱 오염 N/전체` 상시 게이지 (40% 이상 시 적색 점멸) - **빌드 식별자 표시**: 여러 명에게 며칠에 걸쳐 피드백을 받으면 "어느 빌드였나"가 반드시
6. 덱 인덱스를 매 결재마다 덱 길이로 정규화 — 이전에는 `currentCardIndex`가 덱 길이를 넘어 계속 꼬인다. `vite.config.js`가 빌드 시각+커밋 해시(`0918-2239·6398599`)를 주입하고 상태 줄
증가해 삽입 위치 계산이 무의미했음. `pickGrade`에 반올림 추가(자산 부작용이 파라미터를 오른쪽에 흐리게 표시한다 — 테스터가 스크린샷만 찍어도 버전이 남는다
소수점으로 올려 크래시 가능성 있었음) - **`<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 해시가 그대로 서빙되는 것까지 확인
**시뮬레이션 결과** (400~600회, 자산·의회 파라미터 정화 제외) ## 2026-09-18 — 라운드 접수량 버그 수정 + 책상/결재 화면 정보 재배치 + 토큰→코인 용어 통일
- 신중 플레이 시 총 주입 오염 10.8장 → **25.1장**
- 최종 덱 오염률 39% → **59%**
- 무작위 플레이의 붕괴율 ~100%는 변경 **이전에도** 동일 (파라미터가 원인)
**실기 플레이 결과:** 디자이너 직접 플레이 확인 — 위기감 강화 방향은 맞으나 세부 밸런스는 조정 여지 있음. **무엇을 왜:** 플레이 피드백 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-07-13 · 자산(엔진) 시스템 v1 ## 2026-09-17 — 플레이 피드백 5건 반영 (합본 결재 리뉴얼 후속)
`docs/작업지시서/자산시스템_작업지시서.md` 기준. 발라트로형 **"조커 레이어" 엔진 빌딩** 도입 — **무엇을 왜:** 합본 결재 리뉴얼(같은 날 앞선 작업) 이후 나온 밸런스 피드백 5건. 그중 "결재
덱은 여전히 적(오염)이고, 자산 슬롯이 패시브로 오염을 걸러내는 엔진이라는 설계 그대로 구현. 카드가 고정으로 등장하는 것 같다"는 실제 버그였다 — 카드 인덱스를 그냥 +1씩 돌려서 CSV 파일
순서 그대로, 매 실행마다 완전히 동일한 순서로 반복되고 있었다(진짜 무작위가 아니라 결정론적
순환). 이게 "위협 축도 거의 등장 안 함" 피드백의 상당 부분을 설명한다 — 테스트할 때마다 같은
초반 카드만 봤을 것이다. 결정 노트: [[2026-09-17-게이지상승-장수기준-복원]]
**변경 사항** - **결재 카드 셔플 큐 도입**: Fisher–Yates로 섞은 덱에서 앞부터 뽑고, 큐가 비면 다시 섞어
- 기존 4종 수동 클릭형 자산(성질변환/시한부격리/영구소각/결재권강제행사)을 **전면 대체** 채운다. 한 바퀴 안에서는 같은 카드가 반복되지 않으면서 실행마다·바퀴마다 순서가 달라진다.
- 신규 `client/src/data/assets.json` — 8종, 트리거→효과→성장 스키마 Playwright로 3번 재로딩해 첫 카드가 매번 다른 것, 15회 연속 뽑기에서 직전 반복 0회 확인
- 신규 `client/src/engine/assetEngine.js` — 이벤트 버스 - **게이지 상승 기준을 등급 합계(주목도) → 카드 장수로 재설계** (등급 배율은 유지): 실제 카드
(`every_n_approvals` / `on_pollution_draw` / `on_swipe_left` / `on_council_end` / `on_purge` 연쇄, 깊이 상한 10) 등급이 1~4급에 몰려 있어 등급 합계만으로는 3~4장을 쌓아도 최하 구간(+0.5%)을 못 벗어났다.
- 극비 프로젝트 성공 시 미보유 자산 중 무작위 1종 획득 (획득 경로 튜닝은 2차 배치로 범위 외) 장수 구간(1~2장 0.5% → 3~4장 3.0% → 5~6장 6.0% → 7~9장 10.0% → 10장+ 15.0%+)을 1차
- 자산 슬롯 클릭 시 `AssetInfoPopup`으로 트리거→효과·스택·충전 상태 표시 기준으로 되돌리고 `축 평균 등급/5` 배율을 곱해 등급도 계속 의미를 갖게 했다. 시뮬레이션으로
- 화면 우하단 발동 로그 피드(최근 4건) — Part C UX 블로커 해결 검증: 등급6 카드가 쌓이는 축은 12장째에 게임오버, 3장째에 전 구간 대비 6배 점프(0.6%→3.6%)
- 기존 `mulliganShields`(방어막 카운트)를 "다음 오염 카드 페널티 무효화" 공용 풀로 재사용 → - **위협 축 카드 1개 재배정**: id11 "사이비 종교 인신 공양" 좌측("군사 개입 지시")이 인사 축
결재 반려 도장(충전식 수동)과 책임 소재 불명 처리(조직저항 자동 무효화)가 같은 메커니즘 공유 이었는데, 고대 신 소환 저지 개입이 인사(HR) 사안일 리 없다 — 위협 축으로 이동(4급→5급).
위협 11→12개
- **완전 무행동 카드 제거**: id15 "전임 국장 비밀 금고"가 좌/우 모두 오염 없는 유일한 카드였다
(결재해도 아무 일도 안 일어남). 좌측("강제 개방 지시")에 대외/6급 부여 — 비자금 발견 시
정치적 위험이라는 기존 `tag_corruption` 태그와도 맞는다. 우측("영구 봉인")은 안전 선택으로 유지
- **"오염 카드 한쪽만 있는 선택지가 많아 초반엔 안전 선택 메리트가 없다"는 피드백은 카드 데이터가
아니라 게이지 곡선 문제로 판단해 별도 수정 안 함** — 위 장수 기준 재설계로 3장 이상 쌓일 때의
손해가 뚜렷해졌으므로, 안전 선택의 가치는 이걸로 회복된다고 보고 카드 재배분은 보류
- Playwright로 셔플·다양성·게이지 표시 전부 재확인, 콘솔 에러 0건
--- ## 2026-09-17 — 열린 이슈 3건 해소 + 플레이 피드백 4건 반영
## 2026-07-13 · 내러티브 태그 재설계 **무엇을 왜:** 축별 등급 분포 확인에서 공보 축이 43개 선택지 중 1개뿐인 게 드러났고(대중 공황
게이지가 사실상 죽은 축), 유형 D 부재로 내부 명분이 무용지물이었으며, 혼합 명분은 자리표시자
였다. 여기에 플레이 피드백 4건을 같이 처리했다. 그중 **주목도 미초기화는 게임 진행이 불가능한
크리티컬 버그**여서 설계 결정이 필요했다 — 결정 노트: [[2026-09-17-주목도-라운드-분리]]
`docs/내러티브태그_시스템_작업지시서.md` 기준. - **공보 축 보강**: 서사 기준으로 축이 어긋난 선택지 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 — 라운드/회기 이층 구조 폐지, 주목도 하드 캡(버스트) 재도입
1. **연대기(Chronicle, 결재 결정 이력)와 내러티브 태그(세계의 상흔, 결과)를 개념적으로 분리**
→ 기획서 / 스토리 바이블 / 와이어프레임 / 비전 / PRD / 유저플로우 / 개발순서 문서 반영 완료
2. `narrative_tags.json` 10종에 필드 추가
- `snippet` — 1~2문장 결과 보고서, 거리감 있는 관료체
- `snippet_type` — `"system"` 7종 / `"human"` 3종
- ※ 초안이므로 디자이너 검토 필요
--- **무엇을 왜:** 직전 재구현에서 도입한 "라운드(손)+회기(정산·의회 텀)" 이층 구조와 "결재 8건 고정"
방식이 실제 의도와 어긋났다 — 원래는 **라운드마다 정산·의회를 거치고, 그 압박을 합본·상점으로
넘어서는 것**이 핵심 재미였고, 라운드 종료도 고정 건수가 아니라 **주목도가 라운드별 한도(21에서
시작, 라운드마다 증가)를 넘으면 강제 종료, 아니면 자율 종료**하는 블랙잭식 구조여야 했다. 결정
노트: [[2026-09-16-라운드-주목도-하드캡-복원]]
## Phase 완료 시점 기록 - `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건
### Phase 4 — 멀티 엔딩 & 연대기 ## 2026-09-16 — 등급 확장 재구현 + 등급 1·3 데이터 보강, 문서 체계 정비
- 엔딩 도감 localStorage 키를 `ss_endings_v2`로 교체 (기존 `ss_endings`는 제목 문자열 기반이라 취약)
→ **이전 플레이테스트 기록은 새 키와 호환되지 않음**
- 내러티브 태그는 재사용 가능한 태그 풀(`narrative_tags.json`, 10종) 방식으로,
선별된 카드의 좌/우 선택지에서만 획득
- 페이즈 전환 문턱: "고유 태그 3종/6종 획득"
- 상세: `ROADMAP.md` Phase 4
### Phase 3 — 정치 시스템 & 스토리 페이징 ## 2026-09-16 — 등급 확장 재구현 + 등급 1·3 데이터 보강, 문서 체계 정비
- 상임 이사회 8명의 투표는 아직 `성향 태그` 시스템이 없어 **위험 수용 계수 기반 확률 처리**로 남아있음
- 상세: `ROADMAP.md` Phase 3
### Phase 2 — 자원 경제 & 덱빌딩 **무엇을 왜:** 2026-09-15에 서울(Base) PC에서 Remote-SSH로 작업한 등급 1~10 확장·주목도
- 오염 카드(신화/조직저항/사회공황/이사회압박) 콘텐츠는 **등급별 1종씩만 채운 초안** 시스템·5장 포커 핸드·토큰 1종 통합 작업이 커밋 전 연결 끊김으로 유실됐다. 평택(현재 PC)
- 상세: `ROADMAP.md` Phase 2 로컬 체크아웃(`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-07-09 · 병렬 브랜치 대형 충돌 ## 2026-09-05 — 플레이테스트 UX 피드백 5건 반영
다른 세션이 origin에 푸시한 커밋을 로컬이 받지 못한 채 Phase 2~4를 독립적으로 재구현해 충돌 발생. **무엇을 왜:** 1차 그레이박스를 실제로 플레이한 뒤 나온 문제 5건(책상 정보 부족, 스와이프
→ 이후 **작업 시작 전 `git fetch` + 분기 확인**을 필수 절차로 `CLAUDE.md`에 명시. 레이아웃 겹침, 족보 판정 기준 안 보임, 게이지 상승 체감 안 됨, 선택 서사 부족)을 고쳤다.
자세한 경위는 `ROADMAP.md` 상단 박스 참고. 결정 노트: [[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`를 단일 원본으로 도입 — 기존 코드베이스는 참조용으로만
남기고 이 문서 기준으로 신규 개발한다는 방침 확정.
+932
View File
@@ -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회로 굳어지는지 확인 필요.
+126
View File
@@ -0,0 +1,126 @@
# Project SS — DEVLOG
> **이 파일의 역할:** 날짜별 작업 이력과 미해결 이슈를 기록한다.
> 규칙은 `../CLAUDE.md`, Phase 계획은 `../ROADMAP.md`.
> **최신 항목이 위로** 오도록 작성한다 (역순).
---
## 🔴 열린 이슈 (해결되면 해당 항목을 아래 이력으로 옮기고 여기서 지운다)
### 자산 획득이 실기 플레이에서 한 번도 발생하지 않음 (2026-07-23 발견, 원인 미조사)
자산(엔진) 시스템 플레이테스트 중 자산을 하나도 획득하지 못해 Part D 체크리스트를 검증 못함.
플레이 횟수 자체가 적어 정상 확률 범위인지 실제 버그인지 구분 안 된 상태.
- **획득 경로:** `GameScreen.jsx`의 `handleSwipe` 내 `card.type === '극비'` 분기
(카드 타입 '극비' 스와이프 성공 시 미보유 자산 중 무작위 1종)
- **의심 지점 ①** '극비' 카드는 페이즈2 풀(`phase2Pool`)에만 있고, 페이즈2는 내러티브 태그 3종
획득 후에야 덱에 섞인다 → 플레이가 짧으면 페이즈2 진입 전에 끝났을 가능성
- **의심 지점 ②** 페이즈2 진입 이후에도 '극비' 카드 등장 빈도/개수가 낮을 수 있음 (`cards.csv` 기준 극비 7종)
- **다음 액션:** 확률 문제인지 조건 분기 버그인지 코드를 다시 열어 확인
### 디자이너 확인 필요 — 오염 압박 밸런스 (2026-07-23)
- ⓐ 위험 1점 = 오염 1장 비율이 적정한지
- ⓑ 항상 안전한 쪽만 고르면 붕괴율 0% — 안전 선택지의 토큰 비용이 충분한 압박인지 별도 검토 필요
### 디자이너 확인 필요 — 자산 시스템 단순화 가정 (2026-07-13)
구현 시 임의로 단순화한 부분들. 의도와 맞는지 확인 필요.
- ① 위험 수용 계수 등 "+N%" 리스크를 기존 0~5단계 스케일(1단계=20%)로 환산해 소수점 누적
- ② 임시 격리 컨테이너(좌 스와이프 격리)는 문서의 "발동 선택 가능"을 단순화해 자동 발동으로 구현(확인 팝업 없음)
- ③ 자산 슬롯 4개 고정 → 보유량만큼 늘어나는 가변 슬롯으로 변경
### 미착수 — 팝업 2종 분리 UI (2026-07-13)
현재 코드(`ChroniclePopup.jsx`, `GameScreen.jsx`)는 태그 히스토리와 연대기(카드·선택 이력)를
하나의 팝업에서 함께 보여준다. 문서가 요구하는 **"세계의 상흔 팝업 vs 연대기 팝업" 2종 분리**는 미착수.
### 미검증 — Part D 플레이테스트 체크리스트 (2026-07-13)
역전 엔진 성립 / 연쇄 가시성 / S급 킹핀 / 무효 빌드 풍자 / 오버파워 확인 / 연쇄 깊이 상한
→ 디자이너 플레이 후 피드백 필요
---
## 2026-07-23 · 오염 카드 압박 강화
**배경:** "오염 카드 수가 적어 위기감이 없고, 스와이프가 오염을 늘린다는 걸 알려주지 않아 고민이 안 된다"는
그레이박스 피드백 반영.
**변경 사항**
1. 오염 주입 규칙을 `client/src/engine/pollution.js`로 분리 — 사전 예고·실제 주입·사후 알림이
`previewPollution()` 단일 기준을 공유 (예고와 결과가 어긋나면 정보 신뢰가 깨지므로)
2. **위험 파라미터를 올리는 결재는 그 자리에서 덱에 오염 카드를 주입** (위험 +1 = 오염 1장,
1회 최대 3장, 현재 위치 2~4장 뒤 삽입). 기존에는 정례회의에서만 파라미터당 1장이었음
3. 정례회의 주입도 단계 비례로 변경 — `min(3, ceil(level/2))`
4. 페널티 무효화(방어막/책임 소재 불명) 시에는 오염도 유입되지 않음 — 방어막의 값어치
5. UI 3종 추가 — 스와이프 인텐트에 `☣ 오염 카드 +N` 사전 경고 / 결재 후 유입 토스트 /
상단 `☣ 덱 오염 N/전체` 상시 게이지 (40% 이상 시 적색 점멸)
6. 덱 인덱스를 매 결재마다 덱 길이로 정규화 — 이전에는 `currentCardIndex`가 덱 길이를 넘어 계속
증가해 삽입 위치 계산이 무의미했음. `pickGrade`에 반올림 추가(자산 부작용이 파라미터를
소수점으로 올려 크래시 가능성 있었음)
**시뮬레이션 결과** (400~600회, 자산·의회 파라미터 정화 제외)
- 신중 플레이 시 총 주입 오염 10.8장 → **25.1장**
- 최종 덱 오염률 39% → **59%**
- 무작위 플레이의 붕괴율 ~100%는 변경 **이전에도** 동일 (파라미터가 원인)
**실기 플레이 결과:** 디자이너 직접 플레이 확인 — 위기감 강화 방향은 맞으나 세부 밸런스는 조정 여지 있음.
정밀 튜닝은 플레이 횟수를 더 쌓은 뒤 진행 예정.
---
## 2026-07-13 · 자산(엔진) 시스템 v1
`docs/작업지시서/자산시스템_작업지시서.md` 기준. 발라트로형 **"조커 레이어" 엔진 빌딩** 도입 —
덱은 여전히 적(오염)이고, 자산 슬롯이 패시브로 오염을 걸러내는 엔진이라는 설계 그대로 구현.
**변경 사항**
- 기존 4종 수동 클릭형 자산(성질변환/시한부격리/영구소각/결재권강제행사)을 **전면 대체**
- 신규 `client/src/data/assets.json` — 8종, 트리거→효과→성장 스키마
- 신규 `client/src/engine/assetEngine.js` — 이벤트 버스
(`every_n_approvals` / `on_pollution_draw` / `on_swipe_left` / `on_council_end` / `on_purge` 연쇄, 깊이 상한 10)
- 극비 프로젝트 성공 시 미보유 자산 중 무작위 1종 획득 (획득 경로 튜닝은 2차 배치로 범위 외)
- 자산 슬롯 클릭 시 `AssetInfoPopup`으로 트리거→효과·스택·충전 상태 표시
- 화면 우하단 발동 로그 피드(최근 4건) — Part C UX 블로커 해결
- 기존 `mulliganShields`(방어막 카운트)를 "다음 오염 카드 페널티 무효화" 공용 풀로 재사용 →
결재 반려 도장(충전식 수동)과 책임 소재 불명 처리(조직저항 자동 무효화)가 같은 메커니즘 공유
---
## 2026-07-13 · 내러티브 태그 재설계
`docs/내러티브태그_시스템_작업지시서.md` 기준.
**변경 사항**
1. **연대기(Chronicle, 결재 결정 이력)와 내러티브 태그(세계의 상흔, 결과)를 개념적으로 분리**
→ 기획서 / 스토리 바이블 / 와이어프레임 / 비전 / PRD / 유저플로우 / 개발순서 문서 반영 완료
2. `narrative_tags.json` 10종에 필드 추가
- `snippet` — 1~2문장 결과 보고서, 거리감 있는 관료체
- `snippet_type` — `"system"` 7종 / `"human"` 3종
- ※ 초안이므로 디자이너 검토 필요
---
## Phase 완료 시점 기록
### Phase 4 — 멀티 엔딩 & 연대기
- 엔딩 도감 localStorage 키를 `ss_endings_v2`로 교체 (기존 `ss_endings`는 제목 문자열 기반이라 취약)
→ **이전 플레이테스트 기록은 새 키와 호환되지 않음**
- 내러티브 태그는 재사용 가능한 태그 풀(`narrative_tags.json`, 10종) 방식으로,
선별된 카드의 좌/우 선택지에서만 획득
- 페이즈 전환 문턱: "고유 태그 3종/6종 획득"
- 상세: `ROADMAP.md` Phase 4
### Phase 3 — 정치 시스템 & 스토리 페이징
- 상임 이사회 8명의 투표는 아직 `성향 태그` 시스템이 없어 **위험 수용 계수 기반 확률 처리**로 남아있음
- 상세: `ROADMAP.md` Phase 3
### Phase 2 — 자원 경제 & 덱빌딩
- 오염 카드(신화/조직저항/사회공황/이사회압박) 콘텐츠는 **등급별 1종씩만 채운 초안**
- 상세: `ROADMAP.md` Phase 2
---
## 2026-07-09 · 병렬 브랜치 대형 충돌
다른 세션이 origin에 푸시한 커밋을 로컬이 받지 못한 채 Phase 2~4를 독립적으로 재구현해 충돌 발생.
→ 이후 **작업 시작 전 `git fetch` + 분기 확인**을 필수 절차로 `CLAUDE.md`에 명시.
자세한 경위는 `ROADMAP.md` 상단 박스 참고.