Files
joke-app/docs/09-game-engine.md

11 KiB
Raw Blame History

09. 게임 엔진 공통 설계

1. 목표

  • 게임 규칙은 순수 함수 모듈로 만들고, 방·네트워크·저장은 공통 코드(GameRunner)가 맡는다. 새 게임 추가 = 규칙 모듈 + 화면 컴포넌트만 작성.
  • 같은 입력이면 항상 같은 결과(결정적). 그래서 기록 재생, 버그 재현, 무작위 대국 테스트가 가능하다.

2. 인터페이스 (packages/engine/src/types.ts)

export type PlayerId = string;

export interface Rng {
  next(): number;                 // [0,1)
  int(maxExclusive: number): number;
  shuffle<T>(arr: readonly T[]): T[];
}

export interface GameEvent { type: string; [k: string]: unknown } // 애니메이션·로그용, 공개 정보만

export interface GameResult {
  ranking: PlayerId[][];          // [[1등], [공동 2등, 공동 2등], ...]
  scores?: Record<PlayerId, number>;
  summary: string;                // "흑 5목 완성으로 OO님 승리"
  reason: 'normal' | 'resign' | 'timeout' | 'abandoned' | 'draw-agreed';
}

export interface GameDefinition<S, A, V, O> {
  id: string;
  nameKo: string;
  minPlayers: number;
  maxPlayers: number;
  stateVersion: number;
  defaultOptions: O;
  optionsSchema: ZodType<O>;
  actionSchema: ZodType<A>;       // 서버 입력 검증(모양만), 규칙 검증은 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, ctx?: { now: number }): { ok: true } | { ok: false; reason: string }; // now: 서버가 넘김(레이트 리밋용)
  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)
  timeoutNotBefore?(state: S): number | null;     // 자리 비움이어도 이 시각 전에는 시간 초과 처리 안 함(실시간 게임)
  disconnectGraceMs?(state: S): number | null;    // 연결 끊긴 마감 대상의 유예 시간(ms), null이면 방의 graceSec
  isStreamAction?(action: A): boolean;            // 그림 획처럼 잦은 행동: 이벤트만 'stream' 메시지로 중계, 저장은 묶어서
  syncView?(state: S, viewer: PlayerId | null): V; // (재)접속·sync 때 보내는 전체 view(기본: view)
  onTimeout(state: S, player: PlayerId): A;
  onLeave?(state: S, player: PlayerId): A | null;   // 기권·자리 비움 처리
  result(state: S): GameResult | null;
  migrate?(old: unknown, fromVersion: number): S;
}

규칙

  • 옵션 메타 필수: optionsSchema의 모든 항목에 .meta({ title: '한국어 제목', labels: { 값: '표시 이름' } })를 붙인다. 관리자 사이트가 이 정보로 입력 화면을 자동으로 만든다(14-admin.md 4절). 규칙 코드 안의 고정 숫자도 가능한 한 옵션으로 꺼낸다.
  • 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/<id>/)

export interface GameUI<V> {
  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이 합법 행동을 돌려주는지.

8. 게임 기록 다시 보기 (apps/server/src/rooms/replay.ts, apps/web/src/pages/ReplayPage.tsx)

  • 다시 만들기: 끝난 게임을 공개된 시드 + room_log의 act/auto 기록으로 처음부터 다시 돌린다. setup(시작 시각)과 각 apply(그 행동을 적용한 정확한 now, 로그의 at)를 방과 똑같이 부르고 RNG도 이어서 쓰므로 결과가 방에서 끝난 상태와 한 글자도 다르지 않다. start 로그에 stateVersion을 남긴다.
  • 보이는 것: 그 판에 앉았던 플레이어만 볼 수 있고(관전자·남은 403), 각자 게임 중에 받던 자기 view(있으면 syncView)만 받는다. 시드와 원본 로그는 서버 밖으로 나가지 않는다. 응답은 private, no-store.
  • 장면: 스트리밍 행동(그림 맞히기 선)이 아닌 행동 하나가 한 장면. 첫 장면만 view 전체, 나머지는 앞 장면과의 차이(packages/shared/src/view-patch.ts)만 보낸다. 다시 만든 결과가 저장된 결과와 다르면 보여 주지 않는다.
  • 못 보는 경우(410, 한국어 안내): 90일이 지나 로그가 지워짐, 그 뒤로 게임 규칙 버전(stateVersion)이 바뀜, 다시 만든 결과가 다름.
  • API: GET /api/me/games?before=&limit=(내가 끝까지 한 게임, 최신순), GET /api/replays/:gamePk. 다시 만든 결과는 잠깐(10분, 32개) 캐시하고, 한 번에 하나씩 조금씩 끊어 만들어 방 처리를 막지 않는다.
  • 화면: 게임의 Board/Side를 그대로 쓰되 읽기 전용(act는 아무것도 안 함, 판 안의 누르기·키 입력은 막음). 처음 / 이전 / 재생·멈춤 / 다음 / 끝 버튼, 슬라이더, "n번째 / 전체 n번", 장면 설명(누가 움직였는지, 자동 진행). 키보드 ←/→/Home/End. 들어가는 곳: 결과 화면 처음부터 다시 보기, 내 정보 › 내 게임 기록 [다시 보기].
  • 테스트: 모든 게임을 실제 Room으로 끝까지(무작위 행동, 시간 초과, 나가기) 둔 뒤 다시 만든 상태·결과가 같은지, 모든 장면이 그 플레이어가 받은 view와 같은지(replay.test.ts), 권한(replay-api.test.ts), 브라우저(e2e/replay.e2e.ts).

9. 컴퓨터 상대 (packages/games/src/bots/, 06 §12)

  • 게임마다 <게임>/bot.ts의 전략 하나: (입력: { me, view, legal, random }) => 행동. view는 view(state, me), legal은 legalActions(state, me). 게임 상태는 넘기지 않는다(비공개 정보 차단).
  • decideBotAction이 고른 수를 validate로 다시 검사하고, 틀리면 다른 합법 수로 바꾼다. 서버는 그래도 실패하면 게임의 onTimeout 행동을 쓴다.
  • 테스트(bots/bots.test.ts): 게임마다 "사람 1명은 시간 초과만, 나머지는 컴퓨터"와 "컴퓨터끼리" 판을 끝까지 돌려, 모든 수가 합법이고 컴퓨터 때문에 멈추지 않으며 한 수가 충분히 빠른지 본다.