M9: 게임 기록 다시 보기

- 서버: 끝난 게임을 공개된 시드 + room_log 행동으로 다시 돌려 플레이어별 view 장면을 만든다
  (rooms/replay.ts, 장면은 view 차이만 전송: shared/view-patch.ts). 행동 로그의 at = 적용한 정확한 시각,
  start 로그에 stateVersion. GET /api/me/games, GET /api/replays/:gamePk(그 판 플레이어만, no-store),
  RoomView.lastGamePk. 로그가 지워졌거나 규칙 버전이 바뀌었거나 결과가 다르면 한국어 안내(410).
- 웹: 다시 보기 화면(/replay/:gamePk, 게임 Board/Side 읽기 전용, 처음/이전/재생·멈춤/다음/끝, 슬라이더),
  내 정보의 내 게임 기록 목록, 결과 화면의 [처음부터 다시 보기].
- 테스트: 모든 게임을 Room으로 끝까지 둔 뒤 다시 만든 상태·결과·장면이 같은지, 권한, view-patch, e2e/replay.e2e.ts.
- 문서: docs/09 §8, docs/07 §10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 13:20:05 +09:00
parent c27cb0a11a
commit c84c1ba95e
19 changed files with 1612 additions and 12 deletions

View File

@@ -3,3 +3,4 @@ export * from './types';
export * from './nickname';
export * from './room-code';
export * from './catalog';
export * from './view-patch';

View File

@@ -54,6 +54,8 @@ export interface RoomView {
lockedOptions: string[];
/** Seed revealed after the game for fairness check. */
lastSeed: string | null;
/** The game that just ended (for 다시 보기), null while playing or before the first game. */
lastGamePk: string | null;
}
export interface GameResultView {
@@ -85,3 +87,50 @@ export type ServerMessage =
| { t: 'error'; code: string; message: string }
| { t: 'pong'; ts: number; serverTime: number }
| { t: 'bye'; reason: 'restart' | 'replaced' | 'kicked' | 'closed' };
// ---------------------------------------------------------------- replays (docs/09 §8)
/** Structural change between two consecutive replay views (see `diffView`). */
export type ViewPatch =
| { s: unknown }
| { o: Record<string, ViewPatch>; d?: string[] }
| { a: number; p: Record<string, ViewPatch> };
/** One step of a replay: the state after a non-streaming action (streamed strokes are folded into the next step). */
export interface ReplayFrame {
at: number;
actor: string | null;
/** Time-out / leave action applied by the server. */
auto?: boolean;
events: unknown[];
active: string[];
/** Full view in the first frame; later frames carry `patch` against the previous frame's view (absent = unchanged). */
view?: unknown;
patch?: ViewPatch;
}
export interface ReplayData {
gamePk: string;
gameId: string;
options: unknown;
startedAt: number;
endedAt: number;
/** The viewer's player id inside this game. */
me: string;
players: Record<string, PublicUser>;
result: GameResultView;
frames: ReplayFrame[];
}
export interface MyGameSummary {
gamePk: string;
gameId: string;
startedAt: number;
endedAt: number;
endReason: string;
myRank: number | null;
players: { nickname: string; rank: number | null; me: boolean }[];
summary: string;
/** False when the action log is gone (older than 90 days) or the game was not recorded. */
replayable: boolean;
}

View File

@@ -0,0 +1,92 @@
import { describe, expect, test } from 'bun:test';
import { applyViewPatch, diffView } from './view-patch';
const json = (v: unknown) => JSON.parse(JSON.stringify(v));
/** Small deterministic PRNG so failures are reproducible. */
function rng(seed: number) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function randomValue(r: () => number, depth: number): unknown {
const k = r();
if (depth <= 0 || k < 0.35) {
const p = r();
if (p < 0.2) return null;
if (p < 0.4) return r() < 0.5;
if (p < 0.7) return Math.floor(r() * 10);
if (p < 0.8) return undefined;
return ['a', 'b', '가', ''][Math.floor(r() * 4)];
}
if (k < 0.65) return Array.from({ length: Math.floor(r() * 6) }, () => randomValue(r, depth - 1));
const o: Record<string, unknown> = {};
for (let i = 0; i < Math.floor(r() * 5); i++) o[['x', 'y', 'z', 's', 'o', 'a', 'p', 'd'][Math.floor(r() * 8)]!] = randomValue(r, depth - 1);
return o;
}
/** Mutates a copy a little, like one game step does. */
function tweak(r: () => number, v: unknown, depth = 0): unknown {
if (r() < 0.15 || depth > 4) return randomValue(r, 2);
if (Array.isArray(v)) {
const out = v.map((x) => (r() < 0.3 ? tweak(r, x, depth + 1) : x));
if (r() < 0.3) out.push(randomValue(r, 2));
if (r() < 0.2) out.pop();
return out;
}
if (v && typeof v === 'object') {
const out: Record<string, unknown> = { ...(v as Record<string, unknown>) };
for (const k of Object.keys(out)) if (r() < 0.3) out[k] = tweak(r, out[k], depth + 1);
if (r() < 0.2) delete out[Object.keys(out)[0]!];
if (r() < 0.2) out.n = randomValue(r, 1);
return out;
}
return r() < 0.5 ? v : randomValue(r, 1);
}
describe('view patches', () => {
test('diff + apply reproduces the next view (as JSON) over random edits', () => {
const r = rng(7);
for (let i = 0; i < 3000; i++) {
const a = { root: randomValue(r, 4) };
const b = { root: tweak(r, a.root) };
const patch = diffView(a, b);
// The client applies the JSON-decoded patch to the JSON-decoded previous view.
const got = applyViewPatch(json(a), patch ? json(patch) : null);
expect(json(got)).toEqual(json(b));
}
});
test('identical (JSON-wise) views give no patch; undefined keys count as absent', () => {
expect(diffView({ a: 1, b: [1, 2] }, { a: 1, b: [1, 2] })).toBeNull();
expect(diffView({ a: 1, b: undefined }, { a: 1 })).toBeNull();
expect(diffView({ a: 1 }, { a: 1, b: undefined })).toBeNull();
expect(diffView({ a: 1, b: 2 }, { a: 1 })).toEqual({ o: {}, d: ['b'] });
});
test('a growing array only carries the new items, unchanged parts keep their identity', () => {
const ops = Array.from({ length: 500 }, (_, i) => ({ k: 'p', d: [i, i] }));
const prev = { turn: 3, canvas: ops, chat: [{ t: 'hi' }] };
const next = { turn: 3, canvas: [...ops, { k: 'e' }], chat: prev.chat };
const patch = diffView(prev, next)!;
expect(JSON.stringify(patch).length).toBeLessThan(60);
const got = applyViewPatch(prev, patch) as typeof next;
expect(got).toEqual(next);
expect(got.chat).toBe(prev.chat);
expect(got.canvas[10]).toBe(prev.canvas[10]);
});
test('a mostly different array is replaced, not patched element by element', () => {
expect(diffView([1, 2, 3, 4, 5, 6, 7, 8], [8, 7, 6, 5, 4, 3, 2, 1])).toEqual({ s: [8, 7, 6, 5, 4, 3, 2, 1] });
});
test('a __proto__ key in a view is data, not a prototype', () => {
const next = JSON.parse('{"__proto__": {"polluted": 1}}');
const got = applyViewPatch({}, json(diffView({}, next))) as Record<string, unknown>;
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
expect(Object.keys(got)).toEqual(['__proto__']);
});
});

View File

@@ -0,0 +1,80 @@
/**
* Replay views as structural deltas (docs/09 §8): consecutive views mostly share their content (a growing
* drawing, a 19×19 board with one new stone), so each replay frame only carries what changed.
* JSON semantics: keys whose value is `undefined` count as absent, exactly as the client would see them.
*/
import type { ViewPatch } from './types';
type Obj = Record<string, unknown>;
const isObj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v);
const own = (o: Obj, k: string): unknown => (Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined);
/** Patch turning `prev` into `next`, or null when they serialize identically. */
export function diffView(prev: unknown, next: unknown): ViewPatch | null {
if (prev === next) return null;
if (Array.isArray(prev) && Array.isArray(next)) {
const p: Record<string, ViewPatch> = {};
let changed = 0;
const common = Math.min(prev.length, next.length);
for (let i = 0; i < common; i++) {
const c = diffView(prev[i], next[i]);
if (c) {
p[i] = c;
changed++;
}
}
// Mostly different (e.g. a reshuffled hand): sending the array is smaller than patching it.
if (changed > 4 && changed * 2 > common) return { s: next };
for (let i = common; i < next.length; i++) p[i] = { s: next[i] === undefined ? null : next[i] };
if (prev.length === next.length && changed === 0) return null;
return { a: next.length, p };
}
if (isObj(prev) && isObj(next)) {
const o: Record<string, ViewPatch> = {};
const d: string[] = [];
let changed = false;
for (const k of Object.keys(next)) {
const nv = next[k];
if (nv === undefined) continue;
const pv = own(prev, k);
const c = pv === undefined ? { s: nv } : diffView(pv, nv);
if (c) {
setKey(o, k, c);
changed = true;
}
}
for (const k of Object.keys(prev)) {
if (prev[k] !== undefined && own(next, k) === undefined) d.push(k);
}
if (!changed && d.length === 0) return null;
return d.length ? { o, d } : { o };
}
if (typeof prev === 'number' && typeof next === 'number' && Number.isNaN(prev) && Number.isNaN(next)) return null;
// Inside arrays an undefined item serializes as null.
return { s: next === undefined ? null : next };
}
function setKey(out: Obj, k: string, v: unknown): void {
if (k === '__proto__') Object.defineProperty(out, k, { value: v, enumerable: true, writable: true, configurable: true });
else out[k] = v;
}
/** Applies a patch from `diffView`; unchanged parts keep their identity (structural sharing). */
export function applyViewPatch(prev: unknown, patch: ViewPatch | null | undefined): unknown {
if (!patch) return prev;
if ('s' in patch) return patch.s;
if ('o' in patch) {
const base = isObj(prev) ? prev : {};
const out: Obj = {};
const drop = new Set(patch.d ?? []);
for (const k of Object.keys(base)) if (!drop.has(k)) setKey(out, k, base[k]);
for (const k of Object.keys(patch.o)) setKey(out, k, applyViewPatch(own(base, k), patch.o[k]));
return out;
}
const base = Array.isArray(prev) ? prev : [];
const out = base.slice(0, patch.a);
while (out.length < patch.a) out.push(null);
for (const k of Object.keys(patch.p)) out[Number(k)] = applyViewPatch(base[Number(k)], patch.p[k]);
return out;
}