Files
joke-app/docs/09-game-engine.md
EJClaw f4d63e1544 docs: 온라인 보드게임 사이트 계획서 (공통 14편 + 게임 26종)
- 개요, 조사(BGA·한국 인기 보드게임·UI/UX), 아키텍처, 실시간 프로토콜,
  안정성·보안, 계정, 로비·방, UI/UX, 데이터 모델, 게임 엔진, 테스트,
  배포·운영, 로드맵(M0~M9), 사용자 결정 항목
- 게임별 규칙·엔진 설계·UI·테스트 체크리스트 26종

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:18:29 +09:00

118 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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): { 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/<id>/`)
```ts
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`이 합법 행동을 돌려주는지.