Files
joke-app/docs/09-game-engine.md
EJClaw 16d3c86486 M8: 그림 맞히기 규칙·화면·테스트
- packages/games/src/catchmind: 규칙 모듈(출제 순서·단어 풀 RNG, 고르기/그리기/공개 단계,
  정답 정규화(NFKC·제로폭·끝말·초성 차단·정답 포함 차단·자모 거리 "아까워요"), multi/firstOnly 점수,
  힌트 50%/75%, 신고 과반, 출제자 연결 끊김 10초, 자리 비움 건너뛰기와 "돌아왔어요", 결과 순위),
  단어 740개(words.ko.json, 10개 분야·쉬움/보통, 어린이 안전), 테스트 34개(§10 전부 + 무작위 1,000판)
- 그림 스트리밍: draw 액션은 canvasOp 이벤트만 'stream' 메시지로 중계하고 view에는 개수만,
  (재)접속·sync 때만 syncView로 전체 그림 기록 전송. 스트림 액션 로그는 묶어서(최대 1초) 저장
- 엔진/서버 최소 확장(하위 호환): validate(..., ctx?: {now}), isStreamAction, syncView,
  disconnectGraceMs 선택 훅, ServerMessage 'stream', RoomStore.save 로그 배열 허용
- apps/web/src/games/catchmind: 캔버스 그리기(포인터·coalesced 이벤트, 색 12·굵기 4·지우개·되돌리기·
  전체 지우기), 게이트웨이 한도 아래로 맞춘 배치 전송, 중간점 곡선 렌더링, 힌트 줄·시간 막대·점수판,
  정답/채팅 입력과 "아까워요"·"정답!" 표시, 단어 고르기, 그림 PNG 저장, 옵션·규칙
- 등록(GAMES, 카탈로그, 화면 레지스트리, 아이콘), 방 단위 테스트 4개, E2E 계획(PC+폰 실제 그리기·맞히기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:38:30 +09:00

123 lines
8.4 KiB
Markdown
Raw Permalink 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, 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>/`)
```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`이 합법 행동을 돌려주는지.