diff --git a/.claude/commands/audit.md b/.claude/commands/audit.md new file mode 100644 index 0000000..790eb3d --- /dev/null +++ b/.claude/commands/audit.md @@ -0,0 +1,20 @@ +# 구현 실태 감사 (Audit) + +커밋 히스토리에 "적용 안된 것 많음" 코멘트가 있으므로, 자기 보고가 아닌 **코드를 직접 열어 확인**하고 항목별로 [구현됨 / 부분 구현 / 미구현] + 근거 파일·라인을 보고하라. + +## Phase 2 확인 항목 + +- [ ] **파산 문책 시스템** — 토큰 마이너스 시 파산 카운터 증가 → 의회 문책 → 미개선 시 게임 오버 흐름 +- [ ] **오염 카드 등급 확률** — 파라미터 단계(1~5)에 비례한 S/A/B/C 가중치가 실제 작동하는지 +- [ ] **자산 효과 4종** — 성질 변환 / 시한부 격리 / 물리적 영구 소각 / 결재권 강제 행사 각각의 완성도 + +## Phase 3 확인 항목 + +- [ ] **파벌 우호도 세부 조건** — 글로벌 지부장 12개 파벌이 각각 고유 조건으로 우호도가 계산되는지, 아니면 단순 확률 처리인지 + (파벌별 조건 정의는 @docs/Project_SS_스토리_바이블_v3_0.md 4.2절 참조) +- [ ] **파산 카운터 → 의회 문책 연동** — 파산 카운터가 의회 단계에서 실제로 참조되는지 +- [ ] **스토리 페이징 카드 풀 전환** — 내러티브 태그 0~3 / 4~7 / 8+ 구간에 따라 카드 풀이 실제로 바뀌는지 + +## 보고 후 + +감사 결과를 바탕으로 CLAUDE.md의 "현재 Phase 상태" 블록을 실제 구현 상태에 맞게 갱신하고, ROADMAP.md의 `[x]`/`[~]`/`[ ]` 표기를 수정할 것을 제안하라. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0037c2f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,95 @@ +# Project SS — CLAUDE.md +> **위치:** repo root (`ProjectSS/CLAUDE.md`) — Claude Code는 root에서 실행한다. +> **규칙:** 작업 완료 후 "현재 Phase 상태" 블록을 반드시 업데이트한다. + +--- + +## 개발 전략 + +**그레이박스 우선 (Greybox First)** +게임플레이 재미 검증 → 그레이박스 테스트 → 비주얼 폴리싱 순서로 진행한다. +Phase 5(비주얼 폴리싱)는 그레이박스 테스트 피드백 반영 완료 후에만 착수한다. + +--- + +## 코드 컨벤션 (package.json으로 알 수 없는 것) + +- 실제 앱은 `client/` 안에 있음. npm 명령은 `cd client` 후 실행. +- 스타일: 컴포넌트별 개별 `.css` (글로벌 클래스, CSS Modules 아님) +- 상태관리: `useState` / `useReducer`만 사용. 외부 상태관리 라이브러리 도입 금지. +- 영구 저장: `localStorage` (도감 엔딩 저장 용도) +- 카드 데이터는 `client/src/data/cards.csv`를 papaparse로 런타임 파싱함 +- `GameScreen.jsx`에 상태가 집중되어 있음 — 수정 시 주의, 리팩토링 필요 가능성 + +## ⛔ 보호 파일 (직접 수정/실행 금지 — 변경 제안은 가능) + +- `client/update_csv.js` — cards.csv 전처리 스크립트 (데이터 변환 규칙: 컬럼 구조, 인코딩, 파싱 로직 등) +- `client/update_csv_tags.js` — 내러티브 태그 매핑 스크립트 + +**이유:** 두 스크립트의 출력 포맷(변환 규칙)이 바뀌면, 이를 읽는 게임 코드(papaparse 파싱 부분)가 +깨지거나 데이터를 잘못 읽을 수 있다. + +**규칙:** 이 스크립트들을 직접 고쳐서 실행하지 말 것. 단, Phase 진행상 스키마 변경이 실제로 필요한 +경우(예: 새 컬럼/태그 카테고리 추가) 코드를 바로 바꾸지 말고, 어떤 변경이 왜 필요한지 설명하고 +변경안(diff)을 먼저 보여준 뒤 승인을 받고 나서 적용할 것. + +**단, `cards.csv` / `narrative_tags.json` 등 데이터 내용(카드 텍스트, 수치, 태그)은 자유롭게 다뤄도 된다.** +이 게임은 카드 밸런스와 스토리 개연성이 핵심이라, 데이터는 자동 생성으로 일괄 채우지 말고 +디자이너가 직접 열어보고 검토·조정할 수 있는 형태로 제안할 것 (예: 몇 개씩 초안을 보여주고 +피드백을 받아 반영하는 방식). 통짜로 대량 생성해서 덮어쓰지 말 것. + +--- + +## 기획 문서 참조 (게임 규칙의 원본 — 코드 작성 시 이쪽을 기준으로) + +- 카드 타입별 스와이프 결과, 파라미터, 의회 액션 → @docs/Project_SS_서사형_덱빌딩_게임_시스템_기획서.md +- 파벌 12종 우호도 조건, 엔딩 매트릭스, 세계관 → @docs/Project_SS_스토리_바이블_v3_0.md +- 화면 전환/분기 로직 → @docs/ProjectSS_유저플로우.md +- UI 레이아웃 → @docs/ProjectSS_텍스트_와이어프레임.md +- 컬러/타이포/햅틱 수치 → @docs/ProjectSS_디자인_토큰.md +- 요구사항 정의 → @docs/ProjectSS_PRD.md + +게임 규칙 수치를 이 파일에 복사하지 말 것. 항상 원본 문서를 참조한다. + +--- + +## 현재 Phase 상태 + +``` +Phase 0 [x] 환경 세팅 & 아키텍처 +Phase 1 [x] 코어 루프 프로토타입 +Phase 2 [x] 자원 경제 & 덱빌딩 +Phase 3 [x] 정치 시스템 & 스토리 페이징 +Phase 4 [x] 멀티 엔딩 & 연대기 ← 그레이박스 테스트 시작 가능 +Phase 5 [ ] 비주얼 폴리싱 & UX ← 그레이박스 테스트 완료 후에만 착수 +``` + +`[x]` 완료 · `[~]` 부분 완료 · `[ ]` 미착수 + +**Phase 2 완료 시 남은 주의사항:** 오염 카드(신화/조직저항/사회공황/이사회압박) 콘텐츠는 +등급별 1종씩만 채운 초안이다. 자세한 내용은 `ROADMAP.md` Phase 2 참고. + +**Phase 3 완료 시 남은 주의사항:** 상임 이사회 8명의 투표는 아직 `성향 태그` 시스템이 없어 +위험 수용 계수 기반 확률 처리로 남아있다. 자세한 내용은 `ROADMAP.md` Phase 3 참고. + +**Phase 4 완료 시 남은 주의사항:** 엔딩 도감 localStorage 키를 `ss_endings_v2`로 교체했다 +(기존 `ss_endings`는 제목 문자열 기반이라 취약했음). 이전 플레이테스트 기록은 새 키와 +호환되지 않는다. 내러티브 태그는 모든 카드가 아니라 선별된 카드(22/63장)만 키워드를 주는 +방식으로 재설계했고, 페이즈 전환 문턱도 4/8 → 2/4로 낮췄다. 자세한 내용은 `ROADMAP.md` +Phase 4 참고. + +Phase 0~4 그레이박스 구간이 모두 완료됐다. 다음은 외부 플레이어 그레이박스 테스트이며, +Phase 5(비주얼 폴리싱)는 그 피드백 반영 후에만 착수한다. + +로컬에 Node.js LTS가 설치되어 `npm run dev`로 실기 검증이 가능하다 +(Playwright로 실제 플레이까지 확인 완료). + +--- + +## 작업 지시 방법 + +``` +/goal Phase 5 진행해줘 # 그레이박스 테스트 완료 후에만 +``` + +작업 완료 후 이 파일의 **현재 Phase 상태** 블록을 업데이트한다. \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..1c10dca --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,253 @@ +# Project SS — ROADMAP.md +> **용도:** AI 에이전트에게 `/goal Phase N 진행해줘` 명령 시 컨텍스트로 주입하는 개발 로드맵 문서. +> **규칙:** 각 Phase는 독립적으로 실행 가능하며, `완료 기준(Done Criteria)`을 모두 충족해야 다음 Phase로 진행한다. + +--- + +## 개발 전략 + +**그레이박스 우선 전략 (Greybox First)** + +이 프로젝트는 그래픽보다 게임플레이 검증을 먼저 완성하는 전략을 채택한다. +게임은 기획 단계에서 재미있어 보여도 실제 플레이 시 재미없는 경우가 많기 때문에, +시스템이 실제로 재미있는지를 먼저 확인하고, 그래픽은 마지막에 올린다. + +``` +[Phase 0~4] 그레이박스 구간 — 로직과 재미 검증이 목표. 비주얼은 최소한으로. + ↓ +[그레이박스 테스트] 외부 플레이어 피드백 수집 및 반복 수정 + ↓ +[Phase 5] 검증된 게임 위에 비주얼 폴리싱 적용 + ↓ +[출시] +``` + +**Phase 5는 Phase 0~4의 재미 검증이 완료된 후에만 진행한다.** +비주얼 작업을 먼저 시작하지 않는다. + +--- + +## 현재 상태 (Current Status) + +``` +Phase 0 [x] 환경 세팅 & 아키텍처 +Phase 1 [x] 코어 루프 프로토타입 +Phase 2 [x] 자원 경제 & 덱빌딩 +Phase 3 [x] 정치 시스템 & 스토리 페이징 +Phase 4 [x] 멀티 엔딩 & 연대기 +Phase 5 [ ] 비주얼 폴리싱 & UX ← 그레이박스 테스트 완료 후에만 착수 +``` + +`[x]` 완료 · `[~]` 부분 완료(완료 기준 미충족 항목 있음) · `[ ]` 미착수 + +--- + +## 다음 단계 + +**Phase 0~4 그레이박스 작업이 모두 완료됐다.** 이제 외부 플레이어 그레이박스 테스트를 진행할 차례다. +Phase 5(비주얼 폴리싱)는 그 피드백을 반영한 뒤에만 착수한다. + +``` +/goal Phase 5 진행해줘 # 그레이박스 테스트 완료 후에만 +``` + +--- + +## Phase 0 — 프로젝트 환경 세팅 & 아키텍처 설계 + +**상태: `[x] 완료`** + +**목표:** 본격적인 개발 전 코드 환경, 버전 관리, 데이터 파이프라인을 확립한다. + +### 완료된 항목 +- [x] VS Code + Claude Code 개발 환경 +- [x] Gitea 리포지토리 + CI/CD 파이프라인 +- [x] React 19 + Vite 8 프로젝트 (그레이박스 전략에 따라 선택) +- [x] CSV/JSON 데이터 파서 (papaparse 기반) + +--- + +## Phase 1 — 코어 루프 프로토타입 + +**상태: `[x] 완료`** + +**목표:** 스와이프 조작과 4대 파라미터 증감이 동작하는지 검증한다. + +### 완료된 항목 +- [x] 카드 스와이프 시스템 (좌/우/위) +- [x] 4대 핵심 파라미터 (내부 저항 / 엔트로피 / 대중 공황 / 위험 수용 계수) +- [x] 라운드 카운터 (결재 N건 → 의회 진입) +- [x] 파라미터 5단계 도달 시 게임 오버 분기 + +--- + +## Phase 2 — 자원 경제 & 로그라이크 덱빌딩 + +**상태: `[x] 완료`** + +**목표:** 토큰 수급/소모, 덱 오염, 덱 압축(자산)의 전체 경제 루프를 완성한다. + +### 완료된 항목 +- [x] 토큰 시스템 (예산 / 인력 / 정보) +- [x] 영구 자원 태그 (재력 / 무력 / 감시) +- [x] 딜레마 카드 6종 분기 로직 +- [x] 무제한 인벤토리 UI +- [x] 자산 기초 로직 (랜덤 획득) +- [x] 파이프라인 연쇄 반응 기초 +- [x] **파산 문책 시스템** — 마이너스 결재 시 파산 카운터 증가(`GameScreen.jsx` `handleSwipe`), 의회에서 경고 배너 → 미개선 시 특별 청문회로 게임 오버(`CouncilScreen.jsx`) +- [x] **오염 카드 등급 확률** — 파라미터 단계(1~5)별 S/A/B/C 가중치 테이블로 의회 종료 시 오염 카드 주입(`GameScreen.jsx` `GRADE_WEIGHTS`, `injectContaminationCards`) +- [x] **자산 효과 4종** — 성질 변환 / 시한부 격리 / 물리적 영구 소각 / 결재권 강제 행사, 자산 슬롯 클릭으로 발동(`GameScreen.jsx` `activateAsset`) + +### 완료 기준 (Done Criteria) +- [x] 토큰이 마이너스인 상태로 결재하면 파산 카운터가 증가한다. +- [x] 의회 단계에서 파산 카운터가 문책 로직에 실제로 반영된다. +- [x] 경고를 받은 뒤 다음 의회까지 마이너스를 회복하지 못하면 게임 오버된다. +- [x] 오염 카드 등급이 파라미터 단계에 비례하여 가중치대로 뽑힌다. +- [x] 자산 효과 4종이 각각 독립적으로 동작한다. + +> **참고:** 오염 카드 콘텐츠(`cards.csv`의 신화/조직저항/사회공황/이사회압박 16종)는 등급별 1종씩만 채운 **초안**이다. +> 밸런스·문체 검토 후 디자이너가 등급별로 추가 카드를 늘리는 작업이 별도로 필요하다. +> 또한 A급 이상 신화 카드가 '뉴스 이벤트' 카드를 추가하는 연쇄 규칙(기획서 92-95행)은 이번 범위에 포함하지 않았다. +> node/npm이 설치되지 않은 환경이라 `npm run dev` 실기 검증은 못 했고, 코드 리뷰로만 확인했다 — 실행 확인 필요. + +### 핵심 참조 문서 +- `시스템 기획서` — 결재 단계, 덱 오염, 덱 압축, 자산 슬롯 +- `PRD` — §3.2 결재 시스템, §3.3 자산 및 덱 압축 시스템 + +--- + +## Phase 3 — 정치 시스템 & 스토리 페이징 통합 + +**상태: `[x] 완료`** + +**목표:** 그림자 위원회 투표와 내러티브 태그 기반 페이즈 전환을 완성한다. + +### 완료된 항목 +- [x] 그림자 위원회 13명 (상임 8 + 지부장 5 무작위) +- [x] 의회 투표 / 탄핵 / 법안 분기 기초 +- [x] 국장 개입 액션 3종 (매수 / 장부 조작 / 내부 감사) +- [x] 내러티브 태그 기반 페이징 기초 +- [x] 연대기(Chronicle) 팝업 +- [x] **파벌 우호도 세부 조건** — 12개 글로벌 지부장 파벌이 각자 다른 공식으로 우호도를 계산한다(`GameScreen.jsx` `GLOBAL_FACTIONS`). 게임 시작 시 12개 중 5개를 무작위 선발(`factionRoster`), 매 의회마다 실시간 상태(토큰/태그/파라미터/자산/카운터)로 점수를 산출해 동맹/우호/중립/적대/파국 5단계 티어로 환산, 투표와 국장 개입 면역 여부를 결정한다(`CouncilScreen.jsx`). +- [x] **파산 카운터 → 의회 문책 연동** — Phase 2 작업으로 완료됨 (`CouncilScreen.jsx`가 `bankruptcyCounter`/`bankruptcyWarned`를 직접 참조) +- [x] **스토리 페이징 카드 풀 전환** — 초기 덱은 일상/위기만 포함되고, 내러티브 태그 4개 도달 시 작전/극비 프로젝트 카드 풀이 실시간으로 덱에 개방된다(`GameScreen.jsx` `phase2Pool`/`phase2Unlocked`). + +### 완료 기준 (Done Criteria) +- [x] 글로벌 지부장 12개 파벌의 우호도가 각자 다른 조건으로 개별 계산된다. +- [x] 파산 카운터가 의회 문책 로직에 연동된다. +- [x] 내러티브 태그 4개 도달 시 등장 카드 풀이 실시간으로 전환된다. + +> **참고 및 남은 과제:** +> - 상임 이사회 8명의 투표는 여전히 `params.risk` 기반 확률 처리다. 기획서상 이들은 국장의 '성향 태그'에 반응해야 하는데, 이 게임에는 아직 성향 태그 시스템 자체가 없어 범위 밖으로 남겨뒀다(§4.1 대응). +> - 태그 8개(페이즈3) 시점의 카드 풀은 기획서상 페이즈2와 동일한 유형(작전/극비/오염)이라 별도 전환 이벤트를 추가하지 않았다. 대신 오염 카드 등급 가중치가 파라미터 상승에 따라 자연스럽게 심해지는 방식으로 "압박 루프" 강도를 구현했다. +> - Playwright로 실제 브라우저에서 플레이 검증 중 **`params.risk >= 5`가 직접 게임오버를 발동시키지만 `EndingScreen`에 해당 분기가 없어 "미확인 종말" 폴백으로 빠지는 버그**를 발견해 수정했다(PRD상 risk는 탄핵 기준치일 뿐 직접 붕괴 조건이 아님). + +### 핵심 참조 문서 +- `스토리 바이블` — §4.2 글로벌 지부장 12개 파벌 상세 +- `시스템 기획서` — 주간 의회 단계, 토큰 경제 +- `유저플로우` — §3 주간 의회 단계 플로우 + +--- + +## Phase 4 — 멀티 엔딩 & 연대기 (콘텐츠 완성) + +**상태: `[x] 완료`** + +**목표:** 도감 수집이라는 게임의 최종 목표를 완성한다. +이 Phase 완료 후 그레이박스 테스트를 시작한다. + +### 완료된 항목 +- [x] **엔딩 판별기 (우선순위 순)** — `client/src/data/endings.js`의 `resolveEnding()`이 트리거(탄핵/붕괴)별로 아래 순서를 그대로 구현한다. + + | 우선순위 | 엔딩 유형 | 트리거 조건 | + |---|---|---| + | 1 | 의회 정치 엔딩 | 탄핵 가결 + 의회 의장 성향 축(무력/재력/감시 중 게임 시작 시 무작위 선정) 태그 5 이상 | + | 2 | 내러티브 연쇄 엔딩 | 신화→조직저항→사회공황→이사회압박 오염 카드가 이 순서로 등장 | + | 3 | 변주된 붕괴 엔딩 | 기본 붕괴 조건 + 특정 자원 태그(3종)/토큰(3종) 보유 | + | 4 | 기본 붕괴 엔딩 | 파라미터 5단계 도달 or 탄핵 가결 (그 외 조건 미충족 시 폴백) | + +- [x] **변주된 붕괴 엔딩** — 자원 태그 변주 3종(쿠데타/은폐된 멸망/자본의 폭주) + 토큰 변주 3종(야반도주/정보 암흑/긴축의 폭동) +- [x] **극단적 파라미터 조기 엔딩** — 단일 결재에서 위험 파라미터가 3단계 이상 급증하며 5단계에 도달하면 "돌이킬 수 없는 순간" 즉각 붕괴 엔딩 발동 +- [x] **내러티브 연쇄 엔딩** — 오염 카드 4종을 지정 순서로 수집하면 해금, 이후 탄핵/붕괴 시 최우선 적용 +- [x] **도감 UI** — `EndingCodex.jsx` 신규 컴포넌트. 엔딩 화면에서 "📖 엔딩 도감 보기"로 열람, 해금 15종 중 몇 종 확인했는지 표시, 미해금은 "???"로 잠금 처리, `localStorage`(`ss_endings_v2`)에 엔딩 id 기준으로 영구 저장 +- [x] **연대기 팝업 완성** — 좌측 세로선 + 턴별 점(dot)으로 타임라인 형태 시각화(`ChroniclePopup.css`) + +### 완료 기준 (Done Criteria) +- [x] 파라미터 5단계 도달 시 우선순위 판별기가 정확한 엔딩을 출력한다. (Playwright로 "군사 쿠데타" 변주 엔딩과 "정치적 탄핵" 기본 엔딩을 각각 실제로 트리거해 확인) +- [x] 엔딩 화면에 이름 / 서사 텍스트 / 원인 요약이 표시된다. +- [x] 해금 엔딩이 도감에 기록되고 재실행 후에도 유지된다. (localStorage 기반, id로 dedup) +- [x] 연대기 팝업에서 이번 회차 내러티브 히스토리를 확인할 수 있다. + +> **참고:** 엔딩 도감 localStorage 키를 `ss_endings`(제목 문자열 기반, 취약)에서 `ss_endings_v2`(고유 id 기반)로 교체했다. +> 이전에 수동 플레이테스트로 쌓인 `ss_endings` 데이터는 새 키와 호환되지 않아 브라우저에서 도감이 0/15로 보일 수 있다 — +> 프로토타입 단계라 마이그레이션 로직은 만들지 않았다. + +> **내러티브 태그 시스템 재설계 (디자이너 피드백 반영):** 최초 구현은 결재 카드 한 장당 무조건 +1 되는 +> 카운터였는데, 이는 "내러티브 태그는 항상 얻는 것이 아니다"라는 기획 의도와 맞지 않았다. `cards.csv`에 +> `left_narrative` / `right_narrative` 컬럼을 추가해 선별된 카드(위기/작전/극비 22종 + 오염 카드 A·S등급 +> 8종, 총 22장/63장)의 좌/우 선택지에만 짧은 키워드(예: "은폐 공작 실행", "쿠데타 조짐")를 부여하도록 +> 바꿨다. 수집한 키워드는 연대기 팝업 상단과 엔딩 화면에서 칩(chip) 형태로 확인할 수 있다(기존 턴별 +> 결재 로그는 그대로 유지). 동시에 페이즈 전환 문턱을 4/8 → 2/4로 낮췄다(키워드가 희소해진 만큼, 그리고 +> 기존 4/8도 실제 플레이에서 길게 느껴진다는 피드백 반영). + +> **이 Phase 완료 = 그레이박스 테스트 준비 완료.** +> Phase 5는 테스트 피드백 반영이 끝난 후 착수한다. + +### 핵심 참조 문서 +- `스토리 바이블` — §5 멀티 엔딩 매트릭스 +- `유저플로우` — §4 엔딩 분기 및 도감 플로우 +- `텍스트 와이어프레임` — §3.2 엔딩 결산 화면 + +--- + +## Phase 5 — 비주얼 폴리싱 & 다이제틱 UX + +**상태: `[ ] 미착수`** +**착수 조건: 그레이박스 테스트 피드백 반영 완료 후에만 시작한다.** + +**목표:** 재미가 검증된 게임 위에 몰입감 있는 시각·청각·촉각 피드백을 입힌다. + +### 작업 항목 +- [ ] **시각적 오염도 연출** — 오염 카드 테두리에 검은 얼룩·불탄 자국 CSS 효과 +- [ ] **앰비언트 페이즈 비네팅** + - 페이즈 1: 없음 (`opacity 0.0`) + - 페이즈 2: 가장자리 어두운 테두리 (`opacity 0.4`) + - 페이즈 3: 붉게 점멸 (`opacity 0.8`) +- [ ] **스와이프 마찰력 피드백** — 오염도·위험 단계에 따라 카드 저항값 증가 (`0.8` → `1.5`) +- [ ] **햅틱 진동 4종** + + | 종류 | 강도 | 지속 | 발동 조건 | + |---|---|---|---| + | `swipe-tick` | Light | 10ms | 일상 딜레마 스와이프 | + | `stamp-success` | Medium | 30ms | 자원 태그/자산 획득 | + | `crisis-hit` | Heavy | 50ms | 위기 강타 파라미터 상승 | + | `chain-reaction` | Light×3 | 10ms×3 | 파이프라인 연쇄 발동 | + +- [ ] **오디오 앰비언트** — 페이즈 전환에 따른 배경 사운드 변화 +- [ ] **파이프라인 연쇄 시각 이펙트** — 자산 아이콘 사이 발광 선 애니메이션 + +### 완료 기준 (Done Criteria) +- 페이즈 3 진입 시 화면 테두리가 붉게 점멸한다. +- 위기 강타 스와이프 시 Heavy 햅틱이 발동한다 (모바일 실기 확인). +- 파이프라인 연쇄 발동 시 발광 선 이펙트와 연속 진동이 함께 재생된다. + +### 핵심 참조 문서 +- `디자인 토큰` — §4 인터랙션 & 모션 토큰, §5 햅틱 피드백 토큰 +- `컴포넌트 인벤토리` — `VFX_SimpleChain`, `Card_SwipeFull` + +--- + +## 에이전트 사용법 + +``` +# 감사 먼저 (현재 필수) +/goal 감사해줘 — Phase 2~3 미완 항목 실제 구현 여부 확인하고 보고해줘 + +# Phase 진행 +/goal Phase 2 미완 항목 마무리해줘 +/goal Phase 3 미완 항목 마무리해줘 +/goal Phase 4 진행해줘 +/goal Phase 5 진행해줘 ← 그레이박스 테스트 완료 후에만 사용 +``` + +작업 완료 후 `[ ]`를 `[x]`로 업데이트하고 **현재 상태** 블록을 갱신한다. diff --git a/client/src/components/ChroniclePopup.css b/client/src/components/ChroniclePopup.css index 98ae3c3..20d6e03 100644 --- a/client/src/components/ChroniclePopup.css +++ b/client/src/components/ChroniclePopup.css @@ -47,6 +47,43 @@ cursor: pointer; } +.keyword-section { + padding: 16px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.02); +} + +.keyword-label { + display: block; + font-size: 11px; + color: #888; + font-weight: bold; + margin-bottom: 8px; + letter-spacing: 0.5px; +} + +.keyword-empty { + font-size: 12px; + color: #666; + font-style: italic; +} + +.keyword-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.keyword-chip { + font-size: 12px; + padding: 4px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + color: var(--color-token-info); + white-space: nowrap; +} + .chronicle-content { padding: 20px; overflow-y: auto; @@ -61,16 +98,31 @@ .chronicle-list { list-style: none; - padding: 0; + padding: 0 0 0 14px; margin: 0; + border-left: 2px solid rgba(255, 255, 255, 0.1); } .chronicle-item { + position: relative; margin-bottom: 20px; padding-bottom: 20px; + padding-left: 16px; border-bottom: 1px dashed rgba(255, 255, 255, 0.1); } +.chronicle-item::before { + content: ''; + position: absolute; + left: -21px; + top: 4px; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-token-info); + box-shadow: 0 0 0 3px var(--color-surface); +} + .chronicle-item:last-child { border-bottom: none; margin-bottom: 0; diff --git a/client/src/components/ChroniclePopup.jsx b/client/src/components/ChroniclePopup.jsx index 1398aa6..0d6464e 100644 --- a/client/src/components/ChroniclePopup.jsx +++ b/client/src/components/ChroniclePopup.jsx @@ -1,7 +1,7 @@ import React from 'react'; import './ChroniclePopup.css'; -export default function ChroniclePopup({ chronicle, onClose }) { +export default function ChroniclePopup({ chronicle, keywords = [], onClose }) { return (
아직 기록된 역사가 없습니다.
diff --git a/client/src/components/CouncilScreen.css b/client/src/components/CouncilScreen.css index 5c8d43b..9a7b2dc 100644 --- a/client/src/components/CouncilScreen.css +++ b/client/src/components/CouncilScreen.css @@ -148,3 +148,33 @@ .council-result h3 { margin-bottom: 20px; } + +.bankruptcy-warning { + margin-top: 12px; + color: var(--color-risk-critical); + font-family: var(--font-ui); + font-size: 13px; + font-weight: bold; +} + +.council-screen.bankruptcy-termination { + justify-content: center; + align-items: center; + text-align: center; + gap: 16px; +} + +.council-screen.bankruptcy-termination h2 { + font-family: var(--font-doc-title); + color: var(--color-risk-critical); +} + +.bankruptcy-quote { + font-style: italic; + opacity: 0.8; +} + +.council-screen.bankruptcy-termination .fat-btn { + max-width: 280px; + margin-top: 20px; +} diff --git a/client/src/components/CouncilScreen.jsx b/client/src/components/CouncilScreen.jsx index 32e7582..6d7a006 100644 --- a/client/src/components/CouncilScreen.jsx +++ b/client/src/components/CouncilScreen.jsx @@ -1,28 +1,38 @@ import React, { useState, useEffect } from 'react'; import './CouncilScreen.css'; -export default function CouncilScreen({ params, setParams, tokens, setTokens, tags, setTags, onResolve }) { +const getBankruptcyTier = (counter) => { + if (counter >= 8) return { label: '최종 경고', text: '이사회 최종 경고: 다음에도 장부가 마이너스라면, 당신의 자리는 없습니다.' }; + if (counter >= 4) return { label: '공식 문책', text: '인사위원회가 소환장을 보냈습니다. 법인카드는 잠시 압수하겠습니다.' }; + return { label: '가벼운 경고', text: '회계팀이 슬쩍 눈치를 주기 시작했습니다. 영수증 좀 잘 챙기시죠.' }; +}; + +export default function CouncilScreen({ params, setParams, tokens, setTokens, tags, setTags, bankruptcyCounter, bankruptcyWarned, globalFactions = [], onBribe, onResolve }) { const [members, setMembers] = useState([]); const [agenda, setAgenda] = useState(''); const [impeachment, setImpeachment] = useState(false); const [actionTaken, setActionTaken] = useState(false); const [result, setResult] = useState(null); + const isNegative = tokens.budget < 0 || tokens.personnel < 0 || tokens.info < 0; + const bankruptcyTermination = isNegative && bankruptcyWarned; + useEffect(() => { - // Initialize 13 members (8 board, 5 global) - // Their vote depends on risk parameter and randomness - const newMembers = Array.from({ length: 13 }).map((_, i) => { - const type = i < 8 ? 'board' : 'global'; - // High risk = more likely to vote against + // 상임 이사회 8명: 위험 수용 계수 기반 무작위 (성향 태그 세부 연동은 Phase 3 범위 밖) + const boardMembers = Array.from({ length: 8 }).map((_, i) => { const rand = Math.random() * 100; let vote = '미정'; - if (rand < params.risk * 20) { - vote = '반대'; - } else if (rand > 50) { - vote = '찬성'; - } - return { id: i, type, vote }; + if (rand < params.risk * 20) vote = '반대'; + else if (rand > 50) vote = '찬성'; + return { id: i, type: 'board', vote, immune: false, label: '상임 이사회' }; }); + + // 글로벌 지부장 5명: 파벌별 개별 우호도 계산 결과를 그대로 투표에 반영 + const globalMembers = globalFactions.map((f, i) => ({ + id: 8 + i, type: 'global', vote: f.vote, immune: f.immune, label: `${f.name} (${f.tier})`, + })); + + const newMembers = [...boardMembers, ...globalMembers]; setMembers(newMembers); const againstCount = newMembers.filter(m => m.vote === '반대').length; @@ -33,7 +43,10 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta const randAgenda = Math.random() > 0.5 ? '이득 규약 법안 상정' : '표적 규약 법안 상정'; setAgenda(randAgenda); } - }, [params.risk]); + // 의회는 마운트 시 1회만 초기화한다 (params.risk를 deps에 넣으면 매수 액션의 risk 상승이 + // 이 effect를 재실행시켜 방금 강제 전환한 투표를 덮어써버리는 버그가 있었다) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const handleAction = (type) => { if (actionTaken) return; @@ -42,10 +55,11 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta if (type === 'bribe' && tokens.budget >= 30) { setTokens(prev => ({ ...prev, budget: prev.budget - 30 })); setParams(prev => ({ ...prev, risk: Math.min(5, prev.risk + 1) })); - // Force 2 against to support + onBribe && onBribe(); + // 동맹/파국 성향 임원은 국장의 개입이 통하지 않는다 let changed = 0; for (let m of newMembers) { - if (m.vote === '반대' && changed < 2) { + if (m.vote === '반대' && !m.immune && changed < 2) { m.vote = '찬성'; changed++; } @@ -60,10 +74,10 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta } else if (type === 'audit' && tokens.personnel >= 30) { setTokens(prev => ({ ...prev, personnel: prev.personnel - 30 })); setParams(prev => ({ ...prev, resistance: Math.min(5, prev.resistance + 1) })); - // Force 3 against to abstain + // 동맹/파국 성향 임원은 국장의 개입이 통하지 않는다 let changed = 0; for (let m of newMembers) { - if (m.vote === '반대' && changed < 3) { + if (m.vote === '반대' && !m.immune && changed < 3) { m.vote = '미정'; changed++; } @@ -78,16 +92,16 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta const finalize = () => { if (result) { - onResolve({ impeachment: false }); + onResolve({ impeachment: false, bankruptcy: false, bankruptcyWarning: isNegative }); return; } const againstCount = members.filter(m => m.vote === '반대').length; if (impeachment) { if (againstCount >= 9) { - onResolve({ impeachment: true }); + onResolve({ impeachment: true, bankruptcy: false, bankruptcyWarning: isNegative }); } else { - onResolve({ impeachment: false }); + onResolve({ impeachment: false, bankruptcy: false, bankruptcyWarning: isNegative }); } } else { const supportCount = members.filter(m => m.vote === '찬성').length; @@ -98,10 +112,23 @@ export default function CouncilScreen({ params, setParams, tokens, setTokens, ta setTags(prev => ({ ...prev, wealth: Math.max(0, prev.wealth - 1) })); } } - onResolve({ impeachment: false }); + onResolve({ impeachment: false, bankruptcy: false, bankruptcyWarning: isNegative }); } }; + if (bankruptcyTermination) { + return ( +지난 경고에도 불구하고 장부는 여전히 마이너스입니다. 이사회는 더 이상 변명을 듣지 않기로 했습니다.
+"숫자는 거짓말을 하지 않습니다. 당신의 자리도 이제 안녕입니다."
+ +{endingInfo.desc}
+{ending.desc}