# Project SS — 아키텍처 (구현 실태) > **이 문서는 "제안"이 아니라 "현재 코드가 실제로 어떻게 생겼는가"의 기록이다.** > 2026-07-23 기준으로 실제 구현을 확인해 전면 재작성했다. > 초기 제안서(React + Vite + **Tailwind + Zustand + TypeScript**)는 상당 부분 채택되지 않았고, > 그 내역은 문서 하단 "채택하지 않은 초기 제안"에 남겨 뒀다. > > 게임 **규칙·수치**의 원본은 이 문서가 아니라 `docs/` 하위 기획서들이다. 여기엔 코드 구조만 적는다. --- ## 1. 기술 스택 (실제) `client/package.json` 의존성이 전부다. 목록에 없는 것은 쓰지 않는다. | 영역 | 채택 | 비고 | |---|---|---| | 프레임워크 | **React 19** | 함수 컴포넌트만, 클래스 컴포넌트 없음 | | 빌드 | **Vite 8** | `client/`가 프로젝트 루트. npm 명령은 `cd client` 후 실행 | | 언어 | **plain JavaScript (`.jsx`)** | TypeScript 미도입 | | 스타일 | **컴포넌트별 개별 `.css`** | 글로벌 클래스명. CSS Modules 아님, Tailwind 아님 | | 상태관리 | **`useState` / `useEffect`** | 외부 상태관리 라이브러리 없음 | | 애니메이션 | **Framer Motion** | 카드 드래그/스와이프 | | 아이콘 | **lucide-react** | | | CSV 파싱 | **papaparse** | 런타임 파싱 (빌드 타임 변환 아님) | | 영구 저장 | **localStorage** | 엔딩 도감 전용 | 디자인 토큰(컬러/폰트)은 `src/index.css`의 CSS 변수로 정의한다 (`--color-entropy-critical`, `--font-typewriter` 등). 컴포넌트 CSS는 이 변수를 참조한다. --- ## 2. 디렉토리 구조 (실제) ```text ProjectSS/ ├── playtest.bat # 더블클릭 → npm install(최초 1회) + dev 서버 + 브라우저 자동 오픈 │ # ASCII 전용 — 한글/chcp를 넣으면 cmd.exe가 배치 파싱을 깨뜨린다 ├── CLAUDE.md # 작업 규칙 · Phase 상태 (실질적인 단일 진실 공급원) ├── ROADMAP.md ├── docs/ # 기획서 · 스토리 바이블 · 작업지시서 (게임 규칙의 원본) └── client/ # ★ 실제 앱. npm 명령은 여기서 실행 ├── update_csv.js # ⛔ 보호 파일 — cards.csv 전처리 ├── update_csv_tags.js # ⛔ 보호 파일 — 내러티브 태그 매핑 └── src/ ├── main.jsx (10줄) 진입점 ├── App.jsx (13줄) GameScreen 하나만 렌더 — 라우터 없음 ├── index.css 디자인 토큰(CSS 변수) + 리셋 ├── assets/ hero.png 등 정적 파일 ├── components/ ★ flat 구조. 하위 폴더 없음 │ ├── GameScreen.jsx (628줄) ★ 상태 허브 + 화면 전환 │ ├── SwipeCard.jsx (156줄) 카드 드래그 · 선택지 사전 예고 │ ├── CouncilScreen.jsx (220줄) 정례회의(투표 · 개입 액션) │ ├── EndingScreen.jsx (46줄) 엔딩 출력 + 도감 저장 │ ├── EndingCodex.jsx (33줄) 엔딩 도감 (localStorage 읽기) │ ├── ChroniclePopup.jsx (63줄) 연대기 + 태그 히스토리 │ ├── AssetInfoPopup.jsx (43줄) 자산 상세 · 수동 발동 │ └── *.css 컴포넌트당 1개씩 짝을 이룸 ├── engine/ ★ React를 모르는 순수 로직 │ ├── assetEngine.js (226줄) 자산 트리거→효과→연쇄 │ └── pollution.js (154줄) 오염 카드 주입 규칙 · 밸런스 상수 └── data/ ├── cards.csv 카드 원본 (런타임 papaparse 파싱) ├── assets.json 자산 8종 정의 ├── narrative_tags.json 재사용 태그 풀 10종 ├── endings.js (123줄) 엔딩 카탈로그 + 판별 함수 └── pollution_cards.json ⚠️ 어디서도 import하지 않음 (레거시) ``` --- ## 3. 핵심 아키텍처 원칙 ### 3.1 상태는 `GameScreen.jsx` 한 곳에 모은다 전역 스토어가 없다. 게임 상태 전부가 `GameScreen`의 `useState`로 존재하고, 하위 컴포넌트는 props로 값과 콜백을 받는 프레젠테이션 계층이다. ``` GameScreen (상태 소유) ├── SwipeCard ← card, onSwipe, phase ├── CouncilScreen ← params/tokens/tags + setter, onResolve ※ setter를 직접 넘기는 유일한 예외 ├── EndingScreen ← ending, narrativeTags ├── ChroniclePopup ← chronicle, acquiredTags └── AssetInfoPopup ← instance, def, onActivate ``` **트레이드오프:** 파일이 628줄까지 커졌고 관심사가 섞여 있다. 다만 게임 규칙이 아직 확정되지 않아 잦은 수정이 예상되는 그레이박스 단계에서는 "한 파일만 보면 전체 흐름을 안다"는 이점이 분할 비용보다 컸다. Phase 5 이후 재검토 대상이다. ### 3.2 규칙 계산은 `engine/`의 순수 함수로 뺀다 `engine/`은 React·훅·JSX를 전혀 import하지 않는다. 평범한 객체를 받아 평범한 객체를 돌려준다. 이 경계 덕분에 **게임을 띄우지 않고 밸런스를 검증할 수 있다.** 실제로 오염 카드 물량 조정 시 `node` 스크립트로 `pollution.js`를 직접 import해 600회 시뮬레이션을 돌려 변경 전후를 비교했다. 새 규칙 모듈을 만들 때도 이 성질을 깨지 말 것. ### 3.3 `world` 스냅샷 패턴 (자산 엔진) `assetEngine`은 상태를 직접 만지지 않는다. `GameScreen`이 현재 state를 복사한 `world` 객체를 만들어 넘기고, 엔진이 변형한 결과를 다시 state로 커밋한다. ```js const world = buildAssetWorld(); // state 복사 → { cards, tokens, tags, params, owned, quarantine, logs } AssetEngine.onPollutionDraw(world, card.type); AssetEngine.onApprovalTick(world, nextTurns); commitAssetWorld(world); // 결과를 setState로 일괄 반영 ``` > **⚠️ 반드시 지킬 것:** 한 이벤트 처리 안에서 `buildAssetWorld`/`commitAssetWorld` 쌍을 > **정확히 한 번만** 쓴다. 두 번 짝지어 부르면 두 번째 `build`가 아직 리렌더되지 않은 > 낡은 클로저 state를 복사하므로, 나중 커밋이 앞의 변경을 덮어써 버린다. 엔진 훅 지점은 4곳이다: `onApprovalTick`(결재마다) · `onPollutionDraw`(오염 카드 등장) · `onSwipeLeft`(좌 스와이프 격리) · `onCouncilEnd`(정례회의 종료). 연쇄 발동은 `fireEvent`가 깊이 상한 10으로 처리한다. ### 3.4 예고 · 실행 · 알림은 같은 함수를 공유한다 플레이어에게 미리 보여준 숫자와 실제 결과가 어긋나면 정보 자체를 신뢰하지 않게 된다. 그래서 오염 주입량은 `pollution.js`의 `previewPollution()` 하나가 단일 기준이다. ``` SwipeCard "☣ 오염 카드 +2" 사전 경고 ─┐ GameScreen 실제 덱 주입 ─┼─ 모두 previewPollution() 사용 GameScreen "2건 유입" 사후 토스트 ─┘ ``` 이 함수를 우회해 오염 카드를 만들지 말 것. 밸런스 수치는 `pollution.js` 상단 상수 블록에 모여 있다. --- ## 4. 데이터 흐름 ``` cards.csv ──(?raw import)──> papaparse ──> buildCardFromRow() ──> 덱 배열 │ narrative_tags.json ──> left_tag/right_tag id로 조인 ────────────────┘ ``` - **카드 데이터는 런타임에 파싱한다.** `import cardsCsvRaw from '../data/cards.csv?raw'` 후 브라우저에서 papaparse로 처리한다. 빌드 타임 변환 단계가 없으므로 CSV를 고치면 새로고침만으로 반영된다. - 덱은 **인덱스를 순환(modulo)하는 풀**이다. 소진되어 없어지지 않고 계속 돈다. `currentCardIndex`는 매 결재마다 최종 덱 길이로 정규화해 범위 안에 유지한다 — 주입/소각으로 길이가 변하기 때문이다. - 카드 풀은 페이즈별로 분리한다: 페이즈1(일상/위기)로 시작하고, 내러티브 태그 3종 획득 시 페이즈2(작전/극비) 풀이 덱에 삽입된다. - 오염 카드(신화/조직저항/사회공황/이사회압박)는 `contaminationTemplates`로 따로 보관하다가 주입 시점에 복제해 고유 id를 붙인다. --- ## 5. 화면 전환 라우터가 없다. `GameScreen`이 상태를 보고 조기 return으로 화면을 고른다. ```js if (gameOver) return ; if (showCouncil) return ; if (cards.length === 0) return
데이터를 불러오는 중...
; return ( /* 결재 화면 */ ); ``` 팝업(연대기·자산 정보·도감)은 화면을 갈아끼우지 않고 조건부 오버레이로 렌더한다. --- ## 6. 영구 저장 localStorage에 저장하는 것은 **엔딩 도감뿐이다.** 세이브/로드 기능은 없고, 게임오버 시 `window.location.reload()`로 재시작한다. - 키: `ss_endings_v2` (`EndingCodex.jsx`에서 `ENDINGS_STORAGE_KEY`로 export) - 값: 해금된 엔딩 **id** 배열. 구버전 `ss_endings`는 제목 문자열 기반이라 폐기했고 호환되지 않는다. --- ## 7. 채택하지 않은 초기 제안 초기 제안서에 있었으나 **의도적으로 도입하지 않은** 것들이다. 되살리려면 CLAUDE.md 규칙부터 바꿔야 한다. | 제안 | 현재 | 사유 | |---|---|---| | Tailwind CSS | 컴포넌트별 `.css` | 관료제 다크 UI는 유틸리티 클래스보다 커스텀 CSS가 손에 맞았다 | | Zustand | `useState` | 상태가 한 컴포넌트에 모여 있어 전역 스토어의 값어치가 없다. **도입 금지** | | TypeScript | plain JS | 그레이박스 단계의 잦은 스키마 변경 대비 타입 유지비가 컸다 | | `store/ hooks/ types/ utils/ constants/` | 없음 | 규칙 로직은 `engine/`으로 통합 | | `components/{common,dashboard,gameplay,council}/` | flat | 컴포넌트 7개뿐이라 계층이 불필요 | | 8대 파벌 도넛 차트 | 텍스트 목록 | 파벌은 12종 풀에서 5명 선발 방식으로 변경됨 | --- ## 8. 알려진 부채 - **`GameScreen.jsx` 628줄** — 상태 허브 + 화면 전환 + 스와이프 처리 + 파벌 계산이 한 파일에 있다. - **`data/pollution_cards.json` 미사용** — 어디서도 import하지 않는 레거시. 오염 카드는 `cards.csv`에서 온다. - **테스트 코드 없음** — 검증은 `engine/` 모듈을 직접 부르는 일회성 node 스크립트로 해 왔다. - **`window.location.reload()` 재시작** — 상태 초기화 로직이 따로 없다.