# 09. 게임 엔진 공통 설계 ## 1. 목표 - 게임 규칙은 **순수 함수 모듈**로 만들고, 방·네트워크·저장은 공통 코드(GameRunner)가 맡는다. 새 게임 추가 = 규칙 모듈 + 화면 컴포넌트만 작성. - 같은 입력이면 항상 같은 결과(결정적). 그래서 기록 재생, 버그 재현, 무작위 대국 테스트가 가능하다. ## 2. 인터페이스 (`packages/engine/src/types.ts`) ```ts export type PlayerId = string; export interface Rng { next(): number; // [0,1) int(maxExclusive: number): number; shuffle(arr: readonly T[]): T[]; } export interface GameEvent { type: string; [k: string]: unknown } // 애니메이션·로그용, 공개 정보만 export interface GameResult { ranking: PlayerId[][]; // [[1등], [공동 2등, 공동 2등], ...] scores?: Record; summary: string; // "흑 5목 완성으로 OO님 승리" reason: 'normal' | 'resign' | 'timeout' | 'abandoned' | 'draw-agreed'; } export interface GameDefinition { id: string; nameKo: string; minPlayers: number; maxPlayers: number; stateVersion: number; defaultOptions: O; optionsSchema: ZodType; actionSchema: ZodType; // 서버 입력 검증(모양만), 규칙 검증은 validate playersFor?(options: O): { min: number; max: number }; // 모드별 인원이 다를 때 setup(ctx: { players: PlayerId[]; options: O; rng: Rng; now: number; gameNo?: number }): S; // players는 방장 자리부터 시계 방향 validate(state: S, actor: PlayerId, action: A): { ok: true } | { ok: false; reason: string }; apply(state: S, actor: PlayerId, action: A, ctx: { rng: Rng; now: number }): { state: S; events: GameEvent[] }; view(state: S, viewer: PlayerId | null): V; activePlayers(state: S): PlayerId[]; deadline?(state: S): number | null; timeoutPlayers?(state: S): PlayerId[]; // 마감이 적용되는 사람(기본: activePlayers) onTimeout(state: S, player: PlayerId): A; onLeave?(state: S, player: PlayerId): A | null; // 기권·자리 비움 처리 result(state: S): GameResult | null; migrate?(old: unknown, fromVersion: number): S; } ``` ### 규칙 - `apply`는 `validate`가 통과한 행동만 받는다. `apply` 안에서도 불변식이 깨지면 예외를 던진다(GameRunner가 잡음). - 상태는 **불변 객체처럼** 다룬다(새 객체 반환). 구현은 `structuredClone` 후 수정해도 된다(상태가 작음). - 상태는 JSON으로 직렬화 가능해야 한다(Map/Set/클래스 금지). 저장·복구가 그대로 된다. - `view`는 숨김 정보를 빼고, 화면에 필요한 계산 결과(예: 둘 수 있는 칸 목록 `legal`, 현재 족보 이름)를 넣어 준다. 클라이언트가 규칙을 몰라도 그릴 수 있어야 한다. - 차례 확인을 포함한 **모든 합법성 판단은 `validate`가 한다.** `activePlayers`는 "지금 행동을 기다리는 사람"(화면 표시·타이머용)일 뿐, 러너가 이것으로 행동을 막지 않는다(기권·무르기 요청처럼 차례와 무관한 행동이 있기 때문). - `onTimeout`이 만드는 시스템 전용 행동(예: `{type:'timeout'}`)은 `actionSchema`에 넣지 않는다. 그래서 클라이언트는 보낼 수 없고 서버 타이머만 만든다. - 시간은 `ctx.now`로만 받는다. 타이머가 필요한 게임은 상태에 `turnStartedAt`, `timeLeft` 같은 값을 둔다. ## 3. 결정적 난수 - 알고리즘: sfc32(32비트 4개 상태). 시드는 서버가 `crypto.getRandomValues`로 128비트 생성. - GameRunner가 저장된 RNG 상태로 `Rng`를 만들어 `ctx.rng`로 넘기고, 호출이 끝나면 새 RNG 상태를 저장한다. 게임 모듈은 RNG 상태를 직접 들고 있지 않는다. - 셔플은 Fisher–Yates, `rng.int`는 거부 샘플링으로 편향 없이. ## 4. GameRunner (서버, `apps/server/src/rooms/game-runner.ts`) ``` handleAction(actor, cs, action): if dedupe(actor, cs) return parsed = def.actionSchema.safeParse(action) → 실패: reject v = def.validate(state, actor, parsed) → 실패: reject(v.reason) (차례 확인 포함) {state', events} = def.apply(state, actor, parsed, {rng, now}) (try/catch) persist(log + state') (동기 트랜잭션) state = state'; seq++ broadcast views; reschedule timer; if result → finish() onTimer(): 마감 = min(def.deadline(state), 연결 끊긴 사람이면 끊긴 시각 + 방의 유예 시간) for p in timeoutPlayers where 마감 지남: a = def.onTimeout(state, p) → handleAction(p, system, a) 와 같은 경로(검증 포함) ``` - "언제나 행동 가능" 행동(할리갈리 종 치기, 도블 정답, 마작 울기 선언, 무르기 요청)은 `validate`가 허용하면 된다(특수 경로 없음). - 시간 설정(한 수 제한, 개인 시계 등)은 **게임 옵션**으로 각 게임 문서에서 정한다. 방 설정에는 연결 끊김 유예 시간(`graceSec`, 기본 60초)만 있다. - 서버 재시작 후에는 마감을 "복구 시각 + 30초" 이전에는 집행하지 않는다(재접속 시간). ## 5. 공통 유틸 (`packages/engine`) | 모듈 | 내용 | |---|---| | `rng.ts` | sfc32, 시드 생성/직렬화, shuffle | | `cards/standard.ts` | 52장 + 조커 트럼프 카드 ID(`"S-A"`, `"H-10"`, `"JK1"`), 무늬·숫자 파싱 | | `cards/hwatu.ts` | 화투 48장(+보너스패) ID와 속성(월, 광/열끗/띠/피, 쌍피 등). 섯다·고스톱 공용 | | `turns.ts` | 시계 방향 다음 사람, 살아 있는 사람만 돌기, 선 정하기 | | `betting.ts` | 베팅 라운드 엔진: 한국식(삥/체크/콜/따당/쿼터/하프/풀/다이) + 노리밋/팟리밋. 사이드 팟 계산. 포커 모든 모드와 섯다가 공유 | | `grid.ts` | 격자 좌표, 방향, 연속 개수 세기(오목·오셀로·바둑 공용) | | `testing.ts` | 무작위 대국 러너, 정보 유출 검사기, 직렬화 왕복 검사 | ## 6. 클라이언트 쪽 게임 모듈 (`apps/web/src/games//`) ```ts export interface GameUI { id: string; Board: React.ComponentType<{ view: V; me: PlayerId | null; act: (a: unknown) => void; pending: boolean }>; statusText(view: V, me: PlayerId | null): string; // "내 차례예요" 등 OptionsForm?: React.ComponentType<...>; // 대기실 옵션(기본은 스키마로 자동 생성) Rules: React.ComponentType; // 규칙 설명 } ``` - `games/registry.ts`에서 `lazy(() => import('./omok'))` 형태로 등록 → 게임 화면 코드는 그 게임을 할 때만 내려받는다. - 게임 목록 메타데이터(이름, 인원, 썸네일, 난이도, 예상 시간)는 `packages/shared/src/catalog.ts` 하나에서 관리하고 서버·웹이 같이 쓴다. ## 7. 공통 테스트 도구 (모든 게임 필수) 1. **규칙 단위 테스트**: 게임 문서 10절의 시나리오를 그대로 테스트로. 2. **무작위 대국**: 시드 1,000개 × 게임 끝까지 무작위 합법 행동(각 게임은 테스트용 `legalActions(state, p)` 제공). 매 단계 확인: - 예외 없음, `apply` 5ms 이내 - JSON 직렬화 왕복 후 같은 상태 - 같은 시드·같은 행동이면 같은 결과(결정성) - 게임이 상한 턴 안에 끝남 - 게임별 불변식(카드 총수 보존, 칩 총합 보존 등) 3. **정보 유출 검사**: 매 단계 각 플레이어 view의 JSON에 남의 비공개 카드 ID가 없는지(게임이 `secretsOf(state, player)`를 테스트용으로 제공). 4. **시간 초과 테스트**: 모든 상태에서 `onTimeout`이 합법 행동을 돌려주는지.