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:
@@ -3,3 +3,4 @@ export * from './types';
|
||||
export * from './nickname';
|
||||
export * from './room-code';
|
||||
export * from './catalog';
|
||||
export * from './view-patch';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
92
packages/shared/src/view-patch.test.ts
Normal file
92
packages/shared/src/view-patch.test.ts
Normal 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__']);
|
||||
});
|
||||
});
|
||||
80
packages/shared/src/view-patch.ts
Normal file
80
packages/shared/src/view-patch.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user