diff --git a/apps/server/src/http/app.ts b/apps/server/src/http/app.ts index aa9cac4..99fd473 100644 --- a/apps/server/src/http/app.ts +++ b/apps/server/src/http/app.ts @@ -8,6 +8,7 @@ import { authorizeUrl, avatarUrl, exchangeCode, safeNext, type DiscordUser } fro import { SESSION_COOKIE, SESSION_TTL, randomToken, signValue, unsignValue, type Sessions } from '../auth/sessions'; import { toPublicUser, type Users } from '../auth/users'; import type { RoomManager } from '../rooms/manager'; +import type { Replays } from '../rooms/replay'; import { WindowLimiter } from '../ws/rate-limit'; import { createAdminApp, type AdminDeps } from './admin'; import type { Roles } from '../admin/roles'; @@ -18,6 +19,7 @@ export interface HttpDeps { users: Users; sessions: Sessions; rooms: RoomManager; + replays: Replays; /** Injected for tests. */ discordExchange?: (code: string, redirectUri: string) => Promise; ipOf: (req: Request) => string; @@ -37,6 +39,7 @@ export function createHttpApp(d: HttpDeps) { const createLimiter = new WindowLimiter(10 * lift, 60_000); const lookupLimiter = new WindowLimiter(30 * lift, 60_000); const guestLimiter = new WindowLimiter(20 * lift, 60_000); + const replayLimiter = new WindowLimiter(30 * lift, 60_000); const redirectUri = `${d.config.publicOrigin}/api/auth/discord/callback`; const cookieOpts = (maxAgeMs: number) => ({ @@ -238,6 +241,28 @@ export function createHttpApp(d: HttpDeps) { return c.json({ stats: d.users.stats(u) }); }); + app.get('/api/me/games', (c) => { + const u = requireUser(c); + if (!u) return c.json({ error: '로그인이 필요해요.' }, 401); + const before = c.req.query('before') ?? null; + const limit = Math.min(Math.max(Number(c.req.query('limit')) || 20, 1), 50); + if (before !== null && !/^[0-9A-Z]{26}$/.test(before)) return c.json({ error: '잘못된 요청이에요.' }, 400); + return c.json(d.replays.listForUser(u, before, limit)); + }); + + /** Replay of a finished game, for its players only (docs/09 §8). Rendered per viewer like the live game. */ + app.get('/api/replays/:gamePk', async (c) => { + const u = requireUser(c); + if (!u) return c.json({ error: '로그인이 필요해요.' }, 401); + if (!replayLimiter.allow(u)) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429); + const gamePk = c.req.param('gamePk'); + if (!/^[0-9A-Z]{26}$/.test(gamePk)) return c.json({ error: '게임 기록을 찾을 수 없어요.' }, 404); + const r = await d.replays.get(gamePk, u); + if (!r.ok) return c.json({ error: r.error }, r.status); + // no-store: the body holds this viewer's private view (e.g. their hand); a shared device must not cache it. + return c.body(r.body, 200, { 'content-type': 'application/json; charset=UTF-8', 'cache-control': 'private, no-store' }); + }); + app.post('/api/rooms', async (c) => { const u = requireUser(c); if (!u) return c.json({ error: '로그인이 필요해요.' }, 401); diff --git a/apps/server/src/replay-api.test.ts b/apps/server/src/replay-api.test.ts new file mode 100644 index 0000000..f06f127 --- /dev/null +++ b/apps/server/src/replay-api.test.ts @@ -0,0 +1,78 @@ +/** Replay HTTP API (docs/09 §8): my games list and replays, players only, finished games only. */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { applyViewPatch, type ReplayData } from '@bg/shared'; +import { Client, api, boot, guest } from './test-utils'; + +const running: ReturnType[] = []; +const clients: Client[] = []; +afterEach(async () => { + for (const c of clients.splice(0)) c.close(); + for (const s of running.splice(0)) await s.stop(); +}); + +describe('replay API', () => { + test('players of a finished game can replay it; others, logged-out users and running games are refused', async () => { + const s = boot(); + running.push(s); + const ga = await guest(s.base, '호랑이'); + const gb = await guest(s.base, '고양이'); + const gc = await guest(s.base, '구경꾼'); + const a = await new Client(s.base, ga.cookie, ga.id).connect(); + const b = await new Client(s.base, gb.cookie, gb.id).connect(); + clients.push(a, b); + const code = (await api(s.base, '/api/rooms', { method: 'POST', cookie: a.cookie, body: JSON.stringify({ gameId: 'omok' }) })).body.code; + a.send({ t: 'join', code, as: 'player' }); + await a.next('room'); + b.send({ t: 'join', code, as: 'player' }); + await b.next('room'); + a.send({ t: 'config', options: { colorAssignment: 'hostBlack', ruleSet: 'free' } }); + await a.next('room', (m) => (m.room.options as any).colorAssignment === 'hostBlack'); + const started = b.next('state'); + a.send({ t: 'start' }); + await started; + const gamePk = s.db.query<{ id: string }, []>('SELECT id FROM games').get()!.id; + + const running409 = await api(s.base, `/api/replays/${gamePk}`, { cookie: a.cookie }); + expect(running409.res.status).toBe(409); + + for (let i = 0; i < 4; i++) { + let p = b.next('state', (m) => (m.view as any).turn === 'white'); + a.act({ type: 'place', x: 3 + i, y: 7 }); + await p; + p = a.next('state', (m) => (m.view as any).turn === 'black'); + b.act({ type: 'place', x: 3 + i, y: 0 }); + await p; + } + const room = a.next('room', (m) => m.room.status === 'finished'); + a.act({ type: 'place', x: 7, y: 7 }); + expect((await room).room.lastGamePk).toBe(gamePk); + + const r = await api(s.base, `/api/replays/${gamePk}`, { cookie: a.cookie }); + expect(r.res.status).toBe(200); + expect(r.res.headers.get('cache-control')).toBe('private, no-store'); + const data = r.body as ReplayData; + expect(data).toMatchObject({ gamePk, gameId: 'omok', me: a.id }); + expect(data.players[b.id]!.nickname).toBe('고양이'); + expect(data.frames).toHaveLength(10); + expect(data.frames[1]!.actor).toBe(a.id); + let view: unknown; + for (const f of data.frames) view = f.view ?? applyViewPatch(view, f.patch); + expect(view).toEqual(a.lastOf('state')!.view); + const seed = s.db.query<{ seed: string }, []>('SELECT seed FROM games').get()!.seed; + expect(JSON.stringify(r.body)).not.toContain(seed); + + expect((await api(s.base, `/api/replays/${gamePk}`, { cookie: b.cookie })).body.me).toBe(b.id); + expect((await api(s.base, `/api/replays/${gamePk}`, { cookie: gc.cookie })).res.status).toBe(403); + expect((await api(s.base, `/api/replays/${gamePk}`)).res.status).toBe(401); + expect((await api(s.base, '/api/replays/01J0000000000000000000000X', { cookie: a.cookie })).res.status).toBe(404); + expect((await api(s.base, '/api/replays/..%2Fadmin', { cookie: a.cookie })).res.status).toBe(404); + + const mine = await api(s.base, '/api/me/games', { cookie: b.cookie }); + expect(mine.body.games).toHaveLength(1); + expect(mine.body.games[0]).toMatchObject({ gamePk, gameId: 'omok', myRank: 2, replayable: true }); + expect(mine.body.games[0].players.map((p: { nickname: string }) => p.nickname)).toEqual(['호랑이', '고양이']); + expect((await api(s.base, '/api/me/games', { cookie: gc.cookie })).body).toEqual({ games: [], next: null }); + expect((await api(s.base, '/api/me/games')).res.status).toBe(401); + expect((await api(s.base, '/api/me/games?before=bad', { cookie: a.cookie })).res.status).toBe(400); + }); +}); diff --git a/apps/server/src/rooms/replay.test.ts b/apps/server/src/rooms/replay.test.ts new file mode 100644 index 0000000..a4b1689 --- /dev/null +++ b/apps/server/src/rooms/replay.test.ts @@ -0,0 +1,293 @@ +/** + * Replays (docs/09 §8): every game played to the end through a real Room (random legal actions, timeouts, + * streamed strokes, leaves) is rebuilt from the seed + room_log, and each player's replay shows exactly the + * views that player was sent while playing. + */ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, type AnyGameDefinition } from '@bg/engine'; +import { applyViewPatch, type ReplayData, type ServerMessage } from '@bg/shared'; +import { GAMES } from '@bg/games'; +import { openDb } from '../db'; +import { Room, type Conn, type RoomConfig } from './room'; +import { RoomStore } from './store'; +import { Replays, rebuildGame, type ReplayInput } from './replay'; + +const json = (v: unknown) => JSON.parse(JSON.stringify(v)); + +function playerCount(def: AnyGameDefinition): number { + const r = def.playersFor ? def.playersFor(def.defaultOptions) : { min: def.minPlayers, max: def.maxPlayers }; + const lo = Math.max(r.min, 2); + for (const n of [lo + 1, lo, lo + 2, r.min]) { + if (n >= r.min && n <= r.max && !def.playerCountError?.(def.defaultOptions, n)) return n; + } + throw new Error(`${def.id}: no player count`); +} + +/** Plays one game of `gameId` through a Room with a fake clock. Returns everything the assertions need. */ +function playThrough(gameId: string, seed: number) { + const def = GAMES[gameId]!; + const db = openDb(':memory:'); + const store = new RoomStore(db); + let now = 1_700_000_000_000; + const clock = { now: () => now }; + const players = Array.from({ length: playerCount(def) }, (_, i) => `P${i + 1}`); + const users = { publicUser: (id: string) => ({ id, nickname: `닉${id}`, avatar: null, kind: 'guest' as const }) }; + const config: RoomConfig = { + gameId, + options: def.defaultOptions, + maxPlayers: players.length, + visibility: 'private', + allowSpectators: true, + chatEnabled: true, + chatFilter: true, + graceSec: 60, + }; + const room = new Room('room1', '482913', config, players[0]!, { store, games: GAMES, users, now: clock.now }); + store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId, status: 'lobby', visibility: 'private', config, stateJson: '{}', now }); + const conns: Record = {}; + for (const p of players) { + const msgs: ServerMessage[] = []; + conns[p] = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} }; + room.join(conns[p]!, 'player'); + } + const startIdx = Object.fromEntries(players.map((p) => [p, conns[p]!.msgs.length])); + expect(room.start(players[0]!)).toBeNull(); + room.clearTimer(); + + const rng = new SeededRng([seed, seed * 7 + 1, 0x9e3779b9, seed ^ 0x55]); + let cs = 0; + const left = new Set(); + const counts = { act: 0, timeout: 0, leave: 0 }; + const fireTimer = (at: number) => { + now = Math.max(now, at); + room.onTimer(); + room.clearTimer(); + counts.timeout++; + }; + const finished = () => !room.game || room.game.finished; + for (let step = 0; step < 4000 && !finished(); step++) { + const deadline = room.effectiveDeadline(); + const dt = 50 + rng.int(1500); + // A due deadline fires before anyone's next move, as the real timer would; sometimes jump to it on purpose. + if (deadline !== null && (now + dt >= deadline || rng.next() < 0.03)) { + fireTimer(deadline); + continue; + } + now += dt; + const state = room.game!.state; + const actives = def.activePlayers(state).filter((p: string) => !left.has(p)); + if (actives.length === 0) continue; + const actor = actives[rng.int(actives.length)]!; + const legal = (def.legalActions?.(state, actor) ?? []).filter( + (a: unknown) => def.actionSchema.safeParse(a).success && def.validate(state, actor, a, { now }).ok, + ); + if (legal.length === 0) continue; + room.act(actor, ++cs, legal[rng.int(legal.length)]); + room.clearTimer(); + counts.act++; + } + // Rare long games: players leave one by one (onLeave auto actions) until the game ends. + for (const p of [...players].reverse()) { + if (finished()) break; + room.leave(p); + room.clearTimer(); + left.add(p); + counts.leave++; + for (let i = 0; i < 50 && !finished() && room.effectiveDeadline() !== null; i++) fireTimer(room.effectiveDeadline()!); + } + room.flushStream(); + expect(finished()).toBe(true); + return { def, db, store, room, conns, players, startIdx, users, counts, left }; +} + +/** Decodes frames into full views, as the replay page does. */ +function decode(data: ReplayData): unknown[] { + const views: unknown[] = []; + let v: unknown; + for (const f of data.frames) { + v = f.view !== undefined ? f.view : applyViewPatch(v, f.patch); + views.push(v); + } + return views; +} + +describe('replay: every game, played through a Room, rebuilds exactly', () => { + for (const gameId of Object.keys(GAMES)) { + test(gameId, async () => { + const seed = 1 + Object.keys(GAMES).indexOf(gameId); + const { def, db, store, room, conns, players, startIdx, users, counts, left } = playThrough(gameId, seed); + const g = room.game!; + const row = db.query<{ seed: string; result_json: string; started_at: number; options_json: string }, [string]>('SELECT * FROM games WHERE id = ?').get(g.gamePk)!; + expect(row.result_json).toBe(JSON.stringify(room.lastResult)); + + // The rebuilt state equals the live one, field for field. + const log = store.log('room1').filter((e) => e.gameNo === room.gameNo); + const start = log.find((e) => e.kind === 'start')!; + expect(start.at).toBe(row.started_at); + const input: ReplayInput = { + seedHex: row.seed, + options: JSON.parse(row.options_json), + players: (start.data as { players: string[] }).players, + startedAt: row.started_at, + gameNo: room.gameNo, + actions: log.filter((e) => e.kind === 'act' || e.kind === 'auto').map((e) => ({ actorId: e.actorId!, kind: e.kind, data: e.data, at: e.at })), + }; + const final = rebuildGame(def, input); + expect(JSON.stringify(def.result(final))).toBe(row.result_json); + expect(JSON.stringify(final)).toBe(JSON.stringify(g.state)); + + const replays = new Replays(db, GAMES, users, () => 1_700_000_000_000); + for (const p of players) { + const r = await replays.get(g.gamePk, p); + if (!r.ok) throw new Error(`${gameId} ${p}: ${r.status} ${r.error}`); + const data = JSON.parse(r.body) as ReplayData; + expect(data.me).toBe(p); + expect(data.result).toEqual(json(room.lastResult)); + const views = decode(data); + const frameCount = 1 + input.actions.filter((a) => !(a.kind === 'act' && def.isStreamAction?.(a.data))).length; + expect(views).toHaveLength(frameCount); + // Final frame = what a (re)joining player is sent now. + const c = conns[p]!; + const before = c.msgs.length; + room.sendSync(c); + const sync = c.msgs.slice(before).find((m) => m.t === 'state') as Extract; + expect(views.at(-1)).toEqual(json(sync.view)); + // Every frame = the view this player was sent live, in order (games without a separate sync view). + if (!def.syncView) { + const live = c.msgs + .slice(startIdx[p], before) + .filter((m): m is Extract => m.t === 'state') + .map((m) => json(m.view)); + if (left.has(p)) expect(views.slice(0, live.length)).toEqual(live); + else expect([...live.slice(0, -1)]).toEqual(views); // the last message repeats the final view (gameOver) + } + } + if (process.env.REPLAY_STATS) { + const t0 = performance.now(); + const r = await new Replays(db, GAMES, users, () => 1_700_000_000_000).get(g.gamePk, players[0]!); + const ms = performance.now() - t0; + const body = r.ok ? r.body : ''; + const views = decode(JSON.parse(body)); + const fullViews = views.reduce((n, v) => n + JSON.stringify(v).length, 0); + console.log( + JSON.stringify({ + gameId, + players: players.length, + ...counts, + log: input.actions.length, + frames: views.length, + bodyKB: +(body.length / 1024).toFixed(1), + gzipKB: +(Bun.gzipSync(body).length / 1024).toFixed(1), + fullViewsKB: +(fullViews / 1024).toFixed(1), + rebuildMs: +ms.toFixed(1), + }), + ); + } + }); + } +}); + +describe('replay: access and edge cases', () => { + function omokRoom() { + const db = openDb(':memory:'); + const store = new RoomStore(db); + let now = 1_700_000_000_000; + const clock = { now: () => now, advance: (ms: number) => (now += ms) }; + const users = { publicUser: (id: string) => ({ id, nickname: `닉${id}`, avatar: null, kind: 'guest' as const }) }; + const config: RoomConfig = { + gameId: 'omok', + options: { ...GAMES.omok!.defaultOptions, colorAssignment: 'hostBlack' }, + maxPlayers: 2, + visibility: 'private', + allowSpectators: true, + chatEnabled: true, + chatFilter: true, + graceSec: 60, + }; + const room = new Room('room1', '482913', config, 'A', { store, games: GAMES, users, now: clock.now }); + store.insertRoom({ id: 'room1', code: '482913', hostId: 'A', gameId: 'omok', status: 'lobby', visibility: 'private', config, stateJson: '{}', now }); + for (const u of ['A', 'B']) room.join({ userId: u, send: () => {}, close: () => {} }, 'player'); + expect(room.start('A')).toBeNull(); + room.clearTimer(); + return { db, room, clock, users }; + } + + test('leaving mid-game: the onLeave action is the last (auto) frame; only finished games, only players', async () => { + const { db, room, clock, users } = omokRoom(); + const replays = new Replays(db, GAMES, users); + const pk = room.game!.gamePk; + expect(room.roomView().lastGamePk).toBeNull(); + expect(await replays.get(pk, 'A')).toMatchObject({ ok: false, status: 409 }); + clock.advance(1234); + room.act('A', 1, { type: 'place', x: 7, y: 7 }); + clock.advance(999); + room.leave('B'); + room.clearTimer(); + expect(room.game!.finished).toBe(true); + expect(room.roomView().lastGamePk).toBe(pk); + const r = await replays.get(pk, 'A'); + if (!r.ok) throw new Error(r.error); + const data = JSON.parse(r.body) as ReplayData; + expect(data.frames.map((f) => [f.actor, f.auto ?? false])).toEqual([ + [null, false], + ['A', false], + ['B', true], + ]); + expect(data.frames[1]!.at).toBe(1_700_000_001_234); + expect(data.players.B!.nickname).toBe('닉B'); + // The seed and the raw log stay on the server. + const seed = db.query<{ seed: string }, [string]>('SELECT seed FROM games WHERE id = ?').get(pk)!.seed; + expect(r.body).not.toContain(seed); + expect(await replays.get(pk, 'C')).toMatchObject({ ok: false, status: 403 }); + expect(await replays.get('01J0000000000000000000000X', 'A')).toMatchObject({ ok: false, status: 404 }); + }); + + test('guest merged into a member keeps their seat; changed rules or a purged log are not replayable', async () => { + const { db, room, users } = omokRoom(); + room.act('A', 1, { type: 'place', x: 7, y: 7 }); + room.leave('B'); + const pk = room.game!.gamePk; + db.query("UPDATE game_players SET user_id = 'M' WHERE user_id = 'B'").run(); + const merged = await new Replays(db, GAMES, users).get(pk, 'M'); + if (!merged.ok) throw new Error(merged.error); + expect((JSON.parse(merged.body) as ReplayData).me).toBe('B'); + + const bumped = { ...GAMES, omok: { ...GAMES.omok!, stateVersion: GAMES.omok!.stateVersion + 1 } }; + expect(await new Replays(db, bumped, users).get(pk, 'A')).toMatchObject({ ok: false, status: 410 }); + // A rules change that alters the outcome is caught by the rebuilt-result check. + const changed = { ...GAMES, omok: { ...GAMES.omok!, result: () => null } }; + expect(await new Replays(db, changed, users).get(pk, 'A')).toMatchObject({ ok: false, status: 410 }); + + db.query("DELETE FROM room_log WHERE kind = 'start'").run(); + expect(await new Replays(db, GAMES, users).get(pk, 'A')).toMatchObject({ ok: false, status: 410 }); + }); + + test('my games list: newest first, paged, with ranks and nicknames', async () => { + const { db, room, clock, users } = omokRoom(); + room.act('A', 1, { type: 'place', x: 7, y: 7 }); + room.leave('B'); + const first = room.game!.gamePk; + // Second game: B comes back and wins when A leaves. + room.join({ userId: 'B', send: () => {}, close: () => {} }, 'player'); + clock.advance(10); + expect(room.start('A')).toBeNull(); + room.clearTimer(); + room.leave('A'); + const second = room.game!.gamePk; + const replays = new Replays(db, GAMES, users, clock.now); + const p1 = replays.listForUser('B', null, 1); + expect(p1.games.map((g) => g.gamePk)).toEqual([second]); + expect(p1.games[0]).toMatchObject({ gameId: 'omok', myRank: 1, replayable: true }); + expect(p1.games[0]!.players).toEqual([ + { nickname: '닉B', rank: 1, me: true }, + { nickname: '닉A', rank: 2, me: false }, + ]); + const p2 = replays.listForUser('B', p1.next, 1); + expect(p2.games.map((g) => [g.gamePk, g.myRank])).toEqual([[first, 2]]); + expect(p2.next).toBeNull(); + expect(replays.listForUser('C', null, 20)).toEqual({ games: [], next: null }); + // Past the log retention the list no longer offers a replay. + const later = new Replays(db, GAMES, users, () => clock.now() + 91 * 24 * 3600_000); + expect(later.listForUser('B', null, 5).games.every((g) => !g.replayable)).toBe(true); + }); +}); diff --git a/apps/server/src/rooms/replay.ts b/apps/server/src/rooms/replay.ts new file mode 100644 index 0000000..53672b0 --- /dev/null +++ b/apps/server/src/rooms/replay.ts @@ -0,0 +1,288 @@ +/** + * Game replays (docs/09 §8): a finished game is rebuilt from its revealed seed and the action log + * (`room_log` act/auto entries) by running the pure game module again, then rendered with the same + * per-viewer `view` the player saw while playing. The seed and the raw log never leave the server. + */ +import type { Database } from 'bun:sqlite'; +import { SeededRng, seedFromHex, type AnyGameDefinition, type GameResult } from '@bg/engine'; +import { diffView, type MyGameSummary, type PublicUser, type ReplayData, type ReplayFrame } from '@bg/shared'; + +/** room_log rows are purged after this long (store.purgeOldLogs). */ +export const LOG_RETENTION_MS = 90 * 24 * 3600_000; +/** Rebuilt replays are kept briefly (people step through right after opening one); bounded in count and size. */ +const CACHE_ENTRIES = 32; +const CACHE_CHARS = 16_000_000; +const CACHE_TTL_MS = 10 * 60_000; + +export interface ReplayInput { + seedHex: string; + options: unknown; + players: string[]; + startedAt: number; + gameNo: number; + /** act/auto log entries of this game in seq order. */ + actions: { actorId: string; kind: string; data: unknown; at: number }[]; +} + +type Step = { state: unknown; action: ReplayInput['actions'][number] | null; events: unknown[] }; + +/** + * Re-runs a game exactly as the room did: setup with the seed and start time, then every logged action with + * the RNG continuing and the time it was applied at (the room logs that exact `now`). + */ +function* replaySteps(def: AnyGameDefinition, input: ReplayInput): Generator { + const rng = SeededRng.fromSeed(seedFromHex(input.seedHex)); + let state = def.setup({ players: input.players, options: input.options, rng, now: input.startedAt, gameNo: input.gameNo }); + yield { state, action: null, events: [{ type: 'gameStarted' }] }; + for (const a of input.actions) { + const next = def.apply(state, a.actorId, a.data, { rng, now: a.at }); + state = next.state; + yield { state, action: a, events: next.events }; + } +} + +/** Final state of a rebuilt game. */ +export function rebuildGame(def: AnyGameDefinition, input: ReplayInput): unknown { + let state: unknown; + for (const step of replaySteps(def, input)) state = step.state; + return state; +} + +/** + * Frames for one viewer. Streamed actions (drawing strokes) are folded into the next frame, as their effect + * reaches clients through `syncView` (the whole drawing). Views are sent as deltas against the previous frame. + * The work is cut into slices of about `sliceMs` so a long game (마작: ~300 ms in total) never blocks the + * rooms sharing this thread for long. + */ +export async function buildFrames( + def: AnyGameDefinition, + input: ReplayInput, + viewer: string, + sliceMs = 8, +): Promise<{ frames: ReplayFrame[]; final: unknown }> { + const frames: ReplayFrame[] = []; + const render = (s: unknown) => (def.syncView ? def.syncView(s, viewer) : def.view(s, viewer)); + let prevView: unknown; + let final: unknown; + let slice = performance.now(); + for (const { state, action: a, events } of replaySteps(def, input)) { + final = state; + if (performance.now() - slice > sliceMs) { + await new Promise((r) => setImmediate(r)); + slice = performance.now(); + } + if (a && a.kind === 'act' && def.isStreamAction?.(a.data)) continue; + const view = render(state); + const active = def.result(state) ? [] : def.activePlayers(state); + if (!a) frames.push({ at: input.startedAt, actor: null, events, active, view }); + else { + const patch = diffView(prevView, view); + frames.push({ at: a.at, actor: a.actorId, ...(a.kind === 'auto' ? { auto: true } : {}), events, active, ...(patch ? { patch } : {}) }); + } + prevView = view; + } + return { frames, final }; +} + +interface GameRow { + id: string; + room_id: string; + game_no: number; + game_id: string; + options_json: string; + seed: string | null; + started_at: number; + ended_at: number | null; + end_reason: string | null; + result_json: string | null; +} + +export type ReplayResult = { ok: true; body: string } | { ok: false; status: 400 | 403 | 404 | 409 | 410; error: string }; + +export class Replays { + private cache = new Map(); + private cacheChars = 0; + private queue: Promise = Promise.resolve(); + private inflight = new Map>>>(); + private readonly now: () => number; + + constructor( + private db: Database, + private games: Record, + private users: { publicUser(id: string): PublicUser | null }, + now?: () => number, + ) { + this.now = now ?? Date.now; + } + + /** My finished games, newest first. `before` is the last gamePk of the previous page. */ + listForUser(userId: string, before: string | null, limit: number): { games: MyGameSummary[]; next: string | null } { + const rows = this.db + .query( + `SELECT g.id, g.room_id, g.game_no, g.game_id, g.options_json, g.seed, g.started_at, g.ended_at, g.end_reason, g.result_json, gp.rank + FROM game_players gp JOIN games g ON g.id = gp.game_pk + WHERE gp.user_id = ? AND g.ended_at IS NOT NULL AND g.id < ? + ORDER BY g.id DESC LIMIT ?`, + ) + .all(userId, before ?? '~', limit + 1); + const page = rows.slice(0, limit); + const others = new Map(); + if (page.length) { + const marks = page.map(() => '?').join(','); + const list = this.db + .query<{ game_pk: string; user_id: string; rank: number | null }, string[]>( + `SELECT game_pk, user_id, rank FROM game_players WHERE game_pk IN (${marks}) ORDER BY rank IS NULL, rank, seat`, + ) + .all(...page.map((r) => r.id)); + for (const p of list) { + const arr = others.get(p.game_pk) ?? []; + arr.push(p); + others.set(p.game_pk, arr); + } + } + const cutoff = this.now() - LOG_RETENTION_MS; + const games = page.map((r): MyGameSummary => { + const result = r.result_json ? (JSON.parse(r.result_json) as GameResult) : null; + return { + gamePk: r.id, + gameId: r.game_id, + startedAt: r.started_at, + endedAt: r.ended_at!, + endReason: r.end_reason ?? 'normal', + myRank: r.rank, + players: (others.get(r.id) ?? []).map((p) => ({ + nickname: this.users.publicUser(p.user_id)?.nickname ?? '알 수 없음', + rank: p.rank, + me: p.user_id === userId, + })), + summary: result?.summary ?? '', + // Cheap check for the list; the replay request itself rebuilds and verifies the game. + replayable: !!r.seed && !!this.games[r.game_id] && r.started_at > cutoff, + }; + }); + return { games, next: rows.length > limit ? page[page.length - 1]!.id : null }; + } + + async get(gamePk: string, userId: string): Promise { + const g = this.db + .query( + 'SELECT id, room_id, game_no, game_id, options_json, seed, started_at, ended_at, end_reason, result_json FROM games WHERE id = ?', + ) + .get(gamePk); + if (!g) return { ok: false, status: 404, error: '게임 기록을 찾을 수 없어요.' }; + const seats = this.db.query<{ user_id: string }, [string]>('SELECT user_id FROM game_players WHERE game_pk = ?').all(gamePk); + // game_players is written when the game ends; before that nobody (not even players) can open a replay. + if (g.ended_at === null || !g.seed || !g.result_json) return { ok: false, status: 409, error: '게임이 끝난 뒤에 다시 볼 수 있어요.' }; + if (!seats.some((s) => s.user_id === userId)) return { ok: false, status: 403, error: '이 게임에 참여한 사람만 다시 볼 수 있어요.' }; + + const key = `${gamePk}:${userId}`; + const hit = this.cache.get(key); + if (hit) { + this.cache.delete(key); + if (this.now() - hit.at < CACHE_TTL_MS) { + this.cache.set(key, hit); + return { ok: true, body: hit.body }; + } + this.cacheChars -= hit.body.length; + } + + const gone = { ok: false as const, status: 410 as const, error: '기록이 남아 있지 않아 다시 볼 수 없어요. (90일이 지난 게임은 다시 볼 수 없어요.)' }; + const def = this.games[g.game_id]; + if (!def) return gone; + const log = this.db + .query<{ actor_id: string | null; kind: string; data_json: string; at: number }, [string, number]>( + 'SELECT actor_id, kind, data_json, at FROM room_log WHERE room_id = ? AND game_no = ? ORDER BY seq', + ) + .all(g.room_id, g.game_no); + const startIdx = log.findIndex((e) => e.kind === 'start'); + const endIdx = log.findIndex((e, i) => i > startIdx && e.kind === 'finish'); + if (startIdx < 0 || endIdx < 0) return gone; + const start = JSON.parse(log[startIdx]!.data_json) as { players: string[]; stateVersion?: number }; + // Games logged before stateVersion was recorded all ran at version 1. Actions logged under other + // rules cannot be re-applied safely (a state migration does not translate actions). + if ((start.stateVersion ?? 1) !== def.stateVersion) { + return { ok: false, status: 410, error: '그 뒤로 게임 규칙이 바뀌어서 이 게임은 다시 볼 수 없어요.' }; + } + const viewer = this.playerIdOf(userId, start.players, seats.map((s) => s.user_id)); + if (!viewer) return { ok: false, status: 410, error: '이 게임은 다시 볼 수 없어요.' }; + const input: ReplayInput = { + seedHex: g.seed, + options: JSON.parse(g.options_json), + players: start.players, + startedAt: g.started_at, + gameNo: g.game_no, + actions: log + .slice(startIdx + 1, endIdx) + .filter((e) => (e.kind === 'act' || e.kind === 'auto') && e.actor_id !== null) + .map((e) => ({ actorId: e.actor_id!, kind: e.kind, data: JSON.parse(e.data_json), at: e.at })), + }; + + // Rebuilds run one at a time (each already yields between slices); identical requests share one rebuild. + let job = this.inflight.get(key); + if (!job) { + job = this.queue.then(() => buildFrames(def, input, viewer)); + this.queue = job.catch(() => {}); + this.inflight.set(key, job); + const done = () => this.inflight.delete(key); + job.then(done, done); + } + let built: Awaited>; + try { + built = await job; + } catch { + return { ok: false, status: 410, error: '이 게임은 다시 볼 수 없어요.' }; + } + // The rebuilt game must end exactly as recorded; otherwise the replay would show a different game. + const result = JSON.parse(g.result_json) as GameResult; + const rebuilt = def.result(built.final); + const matches = g.end_reason === 'void' ? rebuilt === null : JSON.stringify(rebuilt) === g.result_json; + if (!matches) return { ok: false, status: 410, error: '이 게임은 다시 볼 수 없어요.' }; + + const ids = this.idMap(start.players, seats.map((s) => s.user_id)); + const players: Record = {}; + for (const p of start.players) { + const u = this.users.publicUser(ids.get(p) ?? p); + players[p] = u ? { ...u, id: p } : { id: p, nickname: '알 수 없음', avatar: null, kind: 'guest' }; + } + const data: ReplayData = { + gamePk, + gameId: g.game_id, + options: input.options, + startedAt: g.started_at, + endedAt: g.ended_at, + me: viewer, + players, + result, + frames: built.frames, + }; + const body = JSON.stringify(data); + this.cache.set(key, { body, at: this.now() }); + this.cacheChars += body.length; + while (this.cache.size > CACHE_ENTRIES || (this.cacheChars > CACHE_CHARS && this.cache.size > 1)) { + const [k, v] = this.cache.entries().next().value!; + this.cache.delete(k); + this.cacheChars -= v.body.length; + } + return { ok: true, body }; + } + + /** + * In-game player id → current user id. A guest who later linked Discord into an existing member has their + * game_players row moved to the member id while the game state keeps the old id; pair those up when the + * leftover is unambiguous. + */ + private idMap(players: string[], current: string[]): Map { + const map = new Map(); + const cur = new Set(current); + const missing = players.filter((p) => !cur.has(p)); + const extra = current.filter((u) => !players.includes(u)); + for (const p of players) if (cur.has(p)) map.set(p, p); + if (missing.length === 1 && extra.length === 1) map.set(missing[0]!, extra[0]!); + return map; + } + + private playerIdOf(userId: string, players: string[], current: string[]): string | null { + for (const [p, u] of this.idMap(players, current)) if (u === userId) return p; + return null; + } +} diff --git a/apps/server/src/rooms/room.ts b/apps/server/src/rooms/room.ts index c92b6d3..9229c54 100644 --- a/apps/server/src/rooms/room.ts +++ b/apps/server/src/rooms/room.ts @@ -220,7 +220,8 @@ export class Room { return room; } - private persist(log: { actorId: string | null; kind: string; data: unknown } | null): void { + /** `log.at` defaults to the current time; game actions pass the exact `now` they were applied with (replays, docs/09 §8). */ + private persist(log: { actorId: string | null; kind: string; data: unknown; at?: number } | null): void { const nextSeq = this.seq + 1; const entry = log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null; const logs = this.pendingLogs.length ? [...this.pendingLogs, ...(entry ? [entry] : [])] : entry; @@ -242,9 +243,9 @@ export class Room { * Streaming actions (e.g. 그림 맞히기 strokes, ~20/s) get a seq and a log entry right away, but the snapshot * is written at most every STREAM_FLUSH_MS (or with the next regular persist) instead of once per message. */ - private persistStream(log: { actorId: string | null; kind: string; data: unknown }): void { + private persistStream(log: { actorId: string | null; kind: string; data: unknown; at: number }): void { this.seq += 1; - this.pendingLogs.push({ seq: this.seq, gameNo: this.gameNo, at: this.now(), ...log }); + this.pendingLogs.push({ seq: this.seq, gameNo: this.gameNo, ...log }); this.lastActiveAt = this.now(); this.flushTimer ??= setTimeout(() => this.flushStream(), STREAM_FLUSH_MS); } @@ -288,6 +289,7 @@ export class Room { seedHash: this.game?.seedHash ?? null, lockedOptions: this.locked(this.config.gameId).paths, lastSeed: this.lastSeed, + lastGamePk: this.game?.finished ? this.game.gamePk : null, }; } @@ -673,7 +675,7 @@ export class Room { this.lastResult = null; this.consecutiveErrors = 0; for (const s of this.seats) if (s) s.ready = false; - this.persist({ actorId: this.hostId, kind: 'start', data: { gameId: def.id, players, seedHash } }); + this.persist({ actorId: this.hostId, kind: 'start', data: { gameId: def.id, players, seedHash, stateVersion: def.stateVersion }, at: now }); this.broadcastRoom(); this.broadcastState([{ type: 'gameStarted' }]); this.scheduleTimer(); @@ -727,11 +729,11 @@ export class Room { g.deadlineFloor = null; if (!system && def.isStreamAction?.(action)) { // Relay the (public) events only; views are not recomputed per stroke batch (docs/games/catchmind.md §5.5). - this.persistStream({ actorId: userId, kind: 'act', data: action }); + this.persistStream({ actorId: userId, kind: 'act', data: action, at: now }); const msg: ServerMessage = { t: 'stream', seq: this.seq, events: next.events }; for (const c of this.conns.values()) c.send(c.userId === userId && cs !== undefined ? { ...msg, cs } : msg); } else { - this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action }); + this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action, at: now }); this.broadcastState(next.events, userId, cs); } const result = def.result(g.state); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 337f250..f4c119f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -10,6 +10,7 @@ import { Users, toPublicUser } from './auth/users'; import { createHttpApp, type HttpDeps } from './http/app'; import { RoomManager } from './rooms/manager'; import { RoomStore } from './rooms/store'; +import { Replays } from './rooms/replay'; import { Gateway, type WsData } from './ws/gateway'; import { Audit, Roles } from './admin/roles'; import { SettingsStore } from './admin/settings'; @@ -60,6 +61,7 @@ export function startServer(opts: StartOptions) { users, sessions, rooms, + replays: new Replays(db, GAMES, { publicUser }), ipOf, discordExchange: opts.discordExchange, health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }), diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 2a9444b..a785d1c 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -13,6 +13,7 @@ const JoinPage = lazy(() => import('./pages/JoinPage')); const RoomPage = lazy(() => import('./pages/RoomPage')); const GamePage = lazy(() => import('./pages/GamePage')); const MePage = lazy(() => import('./pages/MePage')); +const ReplayPage = lazy(() => import('./pages/ReplayPage')); const RulesPage = lazy(() => import('./pages/RulesPage')); const NotFound = lazy(() => import('./pages/NotFound')); const AdminApp = lazy(() => import('./pages/admin/AdminApp')); @@ -67,6 +68,7 @@ function Shell() { } /> } /> } /> + } /> } /> } /> diff --git a/apps/web/src/net/api.ts b/apps/web/src/net/api.ts index cd1a690..5660dee 100644 --- a/apps/web/src/net/api.ts +++ b/apps/web/src/net/api.ts @@ -1,4 +1,4 @@ -import type { CatalogEntry, PublicUser } from '@bg/shared/lite'; +import type { CatalogEntry, MyGameSummary, PublicUser, ReplayData } from '@bg/shared/lite'; export class ApiError extends Error { constructor( @@ -66,6 +66,9 @@ export const api = { logoutAll: () => call<{ ok: true }>('POST', '/api/auth/logout-all', {}), deleteMe: () => call<{ ok: true }>('DELETE', '/api/me', { confirm: '탈퇴' }), stats: () => call<{ stats: { gameId: string; played: number; wins: number; draws: number }[] }>('GET', '/api/me/stats'), + myGames: (before?: string | null) => + call<{ games: MyGameSummary[]; next: string | null }>('GET', `/api/me/games${before ? `?before=${encodeURIComponent(before)}` : ''}`), + replay: (gamePk: string) => call('GET', `/api/replays/${encodeURIComponent(gamePk)}`), createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }), quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}), room: (code: string) => call('GET', `/api/rooms/${code}`), diff --git a/apps/web/src/pages/MePage.tsx b/apps/web/src/pages/MePage.tsx index b495e2f..8b985c2 100644 --- a/apps/web/src/pages/MePage.tsx +++ b/apps/web/src/pages/MePage.tsx @@ -1,9 +1,10 @@ /** 계정 페이지 (docs/05 §7). */ import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router'; -import { catalogById, nicknameError, normalizeNickname } from '@bg/shared/lite'; +import { Link, useNavigate } from 'react-router'; +import { catalogById, nicknameError, normalizeNickname, type MyGameSummary } from '@bg/shared/lite'; import { Avatar, ConfirmSheet, TopBar } from '../components/ui'; import { NicknameForm } from '../components/NicknameForm'; +import { gameName } from '../components/GameNotice'; import { api, discordLoginUrl } from '../net/api'; import { useSession } from '../store/session'; import { toast } from '../store/toast'; @@ -152,6 +153,8 @@ export default function MePage() { )} + +
+ )} +
+ ); +} diff --git a/apps/web/src/pages/ReplayPage.tsx b/apps/web/src/pages/ReplayPage.tsx new file mode 100644 index 0000000..a270f36 --- /dev/null +++ b/apps/web/src/pages/ReplayPage.tsx @@ -0,0 +1,261 @@ +/** + * 게임 기록 다시 보기 (docs/09 §8, docs/07 §10). Shows a finished game step by step with the game's own + * Board/Side, read-only: `act` does nothing and clicks/keys inside the board are swallowed. + */ +import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { applyViewPatch, type ReplayData } from '@bg/shared/lite'; +import { TopBar } from '../components/ui'; +import { gameName } from '../components/GameNotice'; +import { api } from '../net/api'; +import { useSession } from '../store/session'; +import { loadGameUI } from '../games/registry'; +import type { GameUI } from '../games/types'; +import { useWide } from '../lib/media'; + +const STEP_MS = 1000; + +export default function ReplayPage() { + const { gamePk = '' } = useParams(); + const me = useSession((s) => s.me); + const nav = useNavigate(); + const [data, setData] = useState<{ replay: ReplayData; ui: GameUI } | null>(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!me) return; + let alive = true; + setData(null); + setError(null); + api.replay(gamePk).then( + async (replay) => { + const ui = await loadGameUI(replay.gameId); + if (!alive) return; + if (ui) setData({ replay, ui }); + else setError('이 게임은 다시 볼 수 없어요.'); + }, + (e) => alive && setError((e as Error).message), + ); + return () => { + alive = false; + }; + }, [gamePk, me?.id]); // eslint-disable-line react-hooks/exhaustive-deps + + // Came from the room or the account page: go back there; opened directly: the account page. + const back = () => ((window.history.state as { idx?: number } | null)?.idx ? nav(-1) : nav('/me')); + + if (!me || error || !data) { + return ( + <> + +
+ {!me || error ? ( +
+

{error ?? '로그인한 뒤에 다시 볼 수 있어요.'}

+ + +
+ ) : ( +

기록을 불러오는 중…

+ )} +
+ + ); + } + return ; +} + +/** Swallows clicks and pointer/keyboard input inside the board (scrolling still works). */ +const swallow = (e: SyntheticEvent) => { + e.stopPropagation(); + if (e.type === 'click') e.preventDefault(); +}; +const swallowKeys = (e: React.KeyboardEvent) => { + if (e.key !== 'Tab') swallow(e); +}; +const readOnly = { + className: 'replay-readonly', + onClickCapture: swallow, + onPointerDownCapture: swallow, + onPointerUpCapture: swallow, + onPointerMoveCapture: swallow, + onMouseDownCapture: swallow, + onKeyDownCapture: swallowKeys, + onDragStartCapture: swallow, +}; + +function Player({ data, ui, onBack }: { data: ReplayData; ui: GameUI; onBack: () => void }) { + const wide = useWide(); + const tapConfirm = useSession((s) => s.settings.tapConfirm); + const views = useMemo(() => { + const out: unknown[] = []; + let v: unknown; + for (const f of data.frames) { + v = f.view !== undefined ? f.view : applyViewPatch(v, f.patch); + out.push(v); + } + return out; + }, [data]); + const last = views.length - 1; + const [i, setI] = useState(0); + const [playing, setPlaying] = useState(false); + // The board is remounted on jumps (처음/끝/slider) so its move animations and sounds only follow single steps. + const [mount, setMount] = useState(0); + const at = useRef(0); + + const go = (n: number, keepPlaying = false) => { + const t = Math.max(0, Math.min(last, n)); + if (Math.abs(t - at.current) > 1) setMount((m) => m + 1); + at.current = t; + setI(t); + if (!keepPlaying) setPlaying(false); + }; + const goRef = useRef(go); + goRef.current = go; + + useEffect(() => { + if (!playing) return; + if (i >= last) return setPlaying(false); + const t = setTimeout(() => goRef.current(i + 1, true), STEP_MS); + return () => clearTimeout(t); + }, [playing, i, last]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const el = e.target as HTMLElement | null; + if (el?.closest('input, textarea, select, [contenteditable="true"]')) return; + const d = { ArrowLeft: -1, ArrowRight: 1 }[e.key]; + if (d) goRef.current(at.current + d); + else if (e.key === 'Home') goRef.current(0); + else if (e.key === 'End') goRef.current(Number.MAX_SAFE_INTEGER); + else return; + e.preventDefault(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + const frame = data.frames[i]!; + const view = views[i]; + const nick = (id: string | null) => (id && data.players[id]?.nickname) || '알 수 없음'; + const who = frame.actor === data.me ? '내가' : `${nick(frame.actor)}님이`; + const desc = + i === 0 + ? '게임을 시작했어요.' + : frame.auto + ? `시간이 지나거나 자리를 떠서 ${frame.actor === data.me ? '내' : `${nick(frame.actor)}님`} 차례를 자동으로 넘겼어요.` + : `${who} 움직였어요.`; + // The game's own status line talks to a live player ("놓을 곳을 눌러 주세요"); say whose turn it was instead. + const turn = frame.active.length + ? `이때 ${frame.active.map((p) => (p === data.me ? '내' : `${nick(p)}님`)).join(', ')} 차례였어요.` + : ui.statusText(view, data.me, data.players); + const sec = Math.max(0, Math.round((frame.at - data.startedAt) / 1000)); + const clock = `${Math.floor(sec / 60)}분 ${String(sec % 60).padStart(2, '0')}초`; + const props = { + view, + me: data.me, + players: data.players, + act: () => {}, + pending: false, + deadline: null, + active: frame.active, + tapConfirm, + }; + const when = new Date(data.startedAt).toLocaleString('ko-KR', { month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + + const status = ( +
+ {i === last ? `끝: ${data.result.summary}` : turn} +
+ ); + const controls = ( +
+
+ + {i === 0 ? '시작' : `${i}번째`} / 전체 {last}번 + + {clock} +
+

+ {desc} +

+ go(Number(e.target.value))} + aria-label="몇 번째 장면인지 고르기" + aria-valuetext={i === 0 ? '시작' : `${i}번째 / 전체 ${last}번`} + /> +
+ + + + + +
+
+ ); + + return ( + <> + +
+
+ {!wide && status} +
+ +
+
+ +
+ {!wide && ( +
+
{controls}
+
+ )} + + ); +} diff --git a/apps/web/src/pages/RoomPage.tsx b/apps/web/src/pages/RoomPage.tsx index 5f4ac00..3485b0a 100644 --- a/apps/web/src/pages/RoomPage.tsx +++ b/apps/web/src/pages/RoomPage.tsx @@ -1,6 +1,6 @@ /** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */ import { useEffect, useMemo, useRef, useState } from 'react'; -import { useNavigate, useParams } from 'react-router'; +import { Link, useNavigate, useParams } from 'react-router'; import { formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite'; import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui'; import { NicknameForm } from '../components/NicknameForm'; @@ -614,6 +614,11 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi + {room.lastGamePk && result.ranking.some((g) => g.includes(me.id)) && ( + + ▶ 처음부터 다시 보기 + + )} diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index ed50e22..ae6efa1 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -957,3 +957,80 @@ label.field { .schema-group > legend { grid-column: 1 / -1; } + +/* Replay (docs/09 §8): read-only board, step controls in the side panel (PC) or a bottom bar (phone/tablet) */ +.replay-readonly { + display: contents; +} +.replay-readonly button, +.replay-readonly [role='button'], +.replay-readonly svg { + cursor: default; +} +.replay-controls { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + background: var(--surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + padding: 12px; +} +.bottom-bar .replay-controls { + box-shadow: none; + background: none; + padding: 0; +} +.replay-count { + font-size: 1.1rem; + font-variant-numeric: tabular-nums; +} +.replay-desc { + margin: 0; + min-height: 1.4em; +} +.replay-slider { + width: 100%; + height: 36px; + margin: 0; + accent-color: var(--primary); +} +.replay-buttons { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 6px; +} +.replay-buttons .btn { + min-width: 0; + min-height: 60px; + padding: 0 4px; + flex-direction: column; + gap: 2px; + font-size: 0.95rem; + line-height: 1.1; +} +.replay-buttons .btn span { + font-size: 1.2rem; +} +.game-layout.replay-narrow { + padding-bottom: calc(240px + env(safe-area-inset-bottom)); +} +.my-games { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} +.my-game { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 0; + border-top: 1px solid var(--border); +} +.my-game:first-child { + border-top: 0; + padding-top: 0; +} diff --git a/docs/07-ui-ux.md b/docs/07-ui-ux.md index 120d68a..4c30352 100644 --- a/docs/07-ui-ux.md +++ b/docs/07-ui-ux.md @@ -152,6 +152,7 @@ - 게임 상세 + 공개 방 목록 - 대기실(방장/참가자/관전자, 인원 부족/가득 참) - 게임 화면(내 차례/남의 차례/연결 끊김/자리 비움/일시 정지) -- 결과 화면(승/패/무승부, 한 판 더 투표 중) -- 계정(게스트/디스코드) +- 결과 화면(승/패/무승부, 한 판 더 투표 중, 처음부터 다시 보기) +- 계정(게스트/디스코드, 내 게임 기록 목록 + [다시 보기]) +- 다시 보기(`/replay/:gamePk`: 판은 읽기 전용, 처음/이전/재생·멈춤/다음/끝 + 슬라이더. PC는 오른쪽 칸, 폰은 아래 고정 막대. 못 보는 게임은 안내 문구, docs/09 §8) - 오류(방 없음, 방 가득 참, 서버 업데이트, 오프라인) diff --git a/docs/09-game-engine.md b/docs/09-game-engine.md index e86f5a8..076b125 100644 --- a/docs/09-game-engine.md +++ b/docs/09-game-engine.md @@ -120,3 +120,12 @@ export interface GameUI { - 게임별 불변식(카드 총수 보존, 칩 총합 보존 등) 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. 들어가는 곳: 결과 화면 [처음부터 다시 보기](`RoomView.lastGamePk`), 내 정보 › 내 게임 기록 [다시 보기]. +- **테스트**: 모든 게임을 실제 `Room`으로 끝까지(무작위 행동, 시간 초과, 나가기) 둔 뒤 다시 만든 상태·결과가 같은지, 모든 장면이 그 플레이어가 받은 view와 같은지(`replay.test.ts`), 권한(`replay-api.test.ts`), 브라우저(`e2e/replay.e2e.ts`). diff --git a/e2e/replay.e2e.ts b/e2e/replay.e2e.ts new file mode 100644 index 0000000..d9b4b83 --- /dev/null +++ b/e2e/replay.e2e.ts @@ -0,0 +1,249 @@ +/** + * E2E (docs/09 §8): game record replay. + * 1) 오목 to the end with two browsers (PC + phone), open 다시 보기 from the result sheet and from 내 정보, + * step through it (buttons, slider, keyboard, play) and check the board changes. + * 2) 원카드 (hidden hands): each player's replay shows only their own cards. + * Run: E2E_PORT=4532 bun e2e/replay.e2e.ts (needs apps/web/dist built) + */ +import { chromium, type Page } from 'playwright-core'; +import { mkdirSync, mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { loadConfig } from '../apps/server/src/config'; +import { startServer } from '../apps/server/src/server'; + +const PORT = Number(process.env.E2E_PORT ?? 4532); +const ORIGIN = `http://localhost:${PORT}`; +const SHOTS = process.env.E2E_SHOTS ?? join(import.meta.dir, '../test-results'); +mkdirSync(SHOTS, { recursive: true }); + +const dbDir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-')); +const app = startServer({ + config: loadConfig({ PORT: String(PORT), DB_PATH: join(dbDir, 'app.db'), PUBLIC_ORIGIN: ORIGIN }), + log: () => {}, + staticDir: join(import.meta.dir, '../apps/web/dist'), +}); + +const browser = await chromium.launch({ executablePath: process.env.CHROME ?? '/usr/bin/google-chrome', headless: true }); +let failed = false; +const step = async (name: string, fn: () => Promise) => { + const t0 = performance.now(); + try { + await fn(); + console.log(`✓ ${name} (${Math.round(performance.now() - t0)}ms)`); + } catch (e) { + failed = true; + console.log(`✗ ${name}: ${(e as Error).message.split('\n')[0]}`); + throw e; + } +}; +const expectEq = (got: unknown, want: unknown, what: string) => { + if (JSON.stringify(got) !== JSON.stringify(want)) throw new Error(`${what}: expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`); +}; + +const PAD = 0.9; +async function tapCell(page: Page, x: number, y: number, touch: boolean, n = 15) { + const box = (await page.locator('svg[role="grid"]').boundingBox())!; + const span = n - 1 + 2 * PAD; + const px = box.x + ((x + PAD) / span) * box.width; + const py = box.y + ((y + PAD) / span) * box.height; + if (touch) { + await page.touchscreen.tap(px, py); + await page.waitForTimeout(60); + await page.touchscreen.tap(px, py); + } else { + await page.mouse.move(px, py); + await page.mouse.click(px, py); + } +} +const stones = (p: Page) => p.locator('svg[role="grid"] circle[r="0.46"]').count(); +async function waitStones(p: Page, n: number) { + await p.waitForFunction((n) => document.querySelectorAll('svg[role="grid"] circle[r="0.46"]').length === n, n, { timeout: 5000 }).catch(async () => { + throw new Error(`expected ${n} stones, got ${await stones(p)}`); + }); +} +const count = (p: Page) => p.locator('.replay-count').innerText(); +/** Everything the board shows (text + accessible names); the side panel's 특수 카드 legend names cards too. */ +const tableText = () => { + const main = document.querySelector('.game-main')!; + return main.textContent + [...main.querySelectorAll('[aria-label]')].map((e) => e.getAttribute('aria-label')).join('|'); +}; +/** One-card hand: card names of the face-up cards in "내 카드". */ +const hand = (p: Page) => + p.locator('button.oc-card').evaluateAll((els) => els.map((e) => (e.getAttribute('aria-label') ?? '').split(',')[0]!.trim()).sort()); + +try { + const ctxA = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' }); + const ctxB = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, locale: 'ko-KR' }); + const a = await ctxA.newPage(); + const b = await ctxB.newPage(); + const errors: string[] = []; + for (const p of [a, b]) p.on('pageerror', (e) => errors.push(e.message)); + let replayUrl = ''; + + await step('오목: 방 만들기 → 입장 → 흑 가로 5목', async () => { + await a.goto(`${ORIGIN}/g/omok`); + await a.getByRole('button', { name: /친구랑 하기/ }).click(); + await a.getByLabel('내 이름(닉네임)').fill('호랑이'); + await a.getByRole('button', { name: '이 이름으로 시작하기' }).click(); + await a.waitForURL(/\/r\/\d{6}$/); + const code = a.url().slice(-6); + await b.goto(`${ORIGIN}/r/${code}`); + await b.getByLabel('내 이름(닉네임)').fill('고양이'); + await b.getByRole('button', { name: '이 이름으로 입장하기' }).click(); + await a.getByLabel('자리').getByText('고양이').waitFor(); + await a.getByRole('button', { name: '자유룰' }).click(); + await a.getByRole('button', { name: '방장이 흑' }).click(); + await a.getByRole('button', { name: '설정 저장' }).click(); + await a.getByText('자유룰 · 15줄').waitFor(); + await a.getByRole('button', { name: '▶ 게임 시작' }).click(); + await a.getByRole('button', { name: '시작하기' }).click(); + for (let i = 0; i < 4; i++) { + await a.getByText('내 차례예요').waitFor(); + await tapCell(a, 5 + i, 7, false); + await b.getByText('내 차례예요').waitFor(); + await tapCell(b, 5 + i, 3, true); + } + await a.getByText('내 차례예요').waitFor(); + await tapCell(a, 9, 7, false); + await a.getByText('🎉 이겼어요!').waitFor(); + await b.getByText('아쉽게 졌어요').waitFor(); + }); + + await step('A(PC): 결과 화면 → 처음부터 다시 보기 → 한 수씩 넘기기', async () => { + await a.waitForTimeout(400); // sheet animation + await a.getByRole('link', { name: /처음부터 다시 보기/ }).click(); + await a.waitForURL(/\/replay\/[0-9A-Z]{26}$/); + replayUrl = a.url(); + await a.getByText('게임을 시작했어요.').waitFor(); + expectEq((await count(a)).replace(/\s+/g, ' '), '시작 / 전체 9번', 'count at start'); + await waitStones(a, 0); + await a.getByRole('button', { name: '다음' }).click(); + await waitStones(a, 1); + await a.getByText('내가 움직였어요.').waitFor(); + await a.getByRole('button', { name: '다음' }).click(); + await waitStones(a, 2); + await a.getByText('고양이님이 움직였어요.').waitFor(); + await a.getByRole('button', { name: '끝으로' }).click(); + await waitStones(a, 9); + await a.getByText(/^끝: /).waitFor(); + if (!(await a.getByRole('button', { name: '다음' }).isDisabled())) throw new Error('다음 should be disabled at the end'); + await a.getByRole('button', { name: '이전' }).click(); + await waitStones(a, 8); + await a.screenshot({ path: join(SHOTS, 'replay-1-omok-pc.png') }); + }); + + await step('A(PC): 슬라이더 · 방향키 · 재생/멈춤 · 판은 눌러도 그대로', async () => { + await a.getByLabel('몇 번째 장면인지 고르기').fill('4'); + await waitStones(a, 4); + expectEq((await count(a)).replace(/\s+/g, ' '), '4번째 / 전체 9번', 'count after slider'); + await a.locator('body').click({ position: { x: 5, y: 300 } }); + await a.keyboard.press('ArrowRight'); + await waitStones(a, 5); + await a.keyboard.press('Home'); + await waitStones(a, 0); + await a.getByRole('button', { name: '재생' }).click(); + await waitStones(a, 2); + await a.getByRole('button', { name: '멈춤' }).click(); + const paused = await stones(a); + await a.waitForTimeout(1500); + expectEq(await stones(a), paused, 'stones after pause'); + // Clicking the board in a replay does nothing (read-only). + await tapCell(a, 0, 0, false); + await a.waitForTimeout(300); + expectEq(await stones(a), paused, 'stones after clicking the board'); + await a.getByRole('button', { name: '← 뒤로' }).click(); + await a.waitForURL(/\/r\/\d{6}$/); + }); + + await step('B(폰): 내 정보 → 내 게임 기록 → 다시 보기', async () => { + await b.goto(`${ORIGIN}/me`); + const list = b.getByRole('region', { name: '내 게임 기록' }); + await list.getByText('졌어요').waitFor(); + await list.getByText('호랑이').waitFor(); + await b.screenshot({ path: join(SHOTS, 'replay-2-mygames-phone.png'), fullPage: true }); + await list.getByRole('link', { name: /다시 보기/ }).first().click(); + await b.waitForURL(replayUrl); + await b.getByText('게임을 시작했어요.').waitFor(); + await waitStones(b, 0); + await b.getByRole('button', { name: '다음' }).tap(); + await waitStones(b, 1); + await b.getByText('호랑이님이 움직였어요.').waitFor(); + await b.getByRole('button', { name: '다음' }).tap(); + await waitStones(b, 2); + await b.getByText('내가 움직였어요.').waitFor(); + await b.screenshot({ path: join(SHOTS, 'replay-3-omok-phone.png') }); + await b.getByRole('button', { name: '끝으로' }).tap(); + await waitStones(b, 9); + }); + + await step('남의 게임 다시 보기는 막힘', async () => { + const c = await (await browser.newContext({ locale: 'ko-KR' })).newPage(); + await c.goto(`${ORIGIN}/`); + await c.goto(`${ORIGIN}/me`); + await c.getByLabel('내 이름(닉네임)').fill('구경꾼'); + await c.getByRole('button', { name: /시작하기/ }).click(); + await c.getByText('아직 다시 볼 게임이 없어요.').waitFor(); + await c.goto(replayUrl); + await c.getByText('이 게임에 참여한 사람만 다시 볼 수 있어요.').waitFor(); + await c.context().close(); + }); + + let liveA: string[] = []; + let liveB: string[] = []; + await step('원카드: 두 사람 시작 → 손패 기억 → 한 사람이 나가서 끝', async () => { + // Leave the omok room first so both are free. + await a.getByRole('button', { name: '방 나가기' }).click(); + await a.waitForURL(`${ORIGIN}/`); + await b.goto(`${ORIGIN}/g/one-card`); + await b.getByRole('button', { name: /친구랑 하기/ }).click(); + await b.waitForURL(/\/r\/\d{6}$/); + const code = b.url().slice(-6); + await a.goto(`${ORIGIN}/r/${code}`); + await b.getByLabel('자리').getByText('호랑이').waitFor(); + await b.getByRole('button', { name: '▶ 게임 시작' }).click(); + await b.getByRole('button', { name: '시작하기' }).click(); + await a.locator('button.oc-card').first().waitFor(); + await b.locator('button.oc-card').first().waitFor(); + liveA = await hand(a); + liveB = await hand(b); + if (liveA.length < 5 || liveB.length < 5) throw new Error(`hands too small: ${liveA.length}, ${liveB.length}`); + if (liveA.some((c) => liveB.includes(c))) throw new Error('hands overlap'); + // B leaves mid-game → A wins. + await b.getByRole('button', { name: '메뉴' }).tap(); + await b.getByRole('button', { name: '🚪 방 나가기' }).tap(); + await b.getByRole('button', { name: '나가기', exact: true }).tap(); + await b.waitForURL(`${ORIGIN}/`); + await a.getByText('🎉 이겼어요!').waitFor(); + }); + + await step('원카드 다시 보기: 각자 자기 카드만 보임', async () => { + await a.waitForTimeout(400); + await a.getByRole('link', { name: /처음부터 다시 보기/ }).click(); + await a.waitForURL(/\/replay\//); + await a.getByText('게임을 시작했어요.').waitFor(); + expectEq(await hand(a), liveA, "A's replay hand at the start"); + const pageA = await a.evaluate(tableText); + for (const c of liveB) if (pageA.includes(c)) throw new Error(`A's replay shows B's card ${c}: …${pageA.slice(Math.max(0, pageA.indexOf(c) - 80), pageA.indexOf(c) + 40)}…`); + await a.getByRole('button', { name: '끝으로' }).click(); + await a.getByText(/자동으로 넘겼어요/).waitFor(); + await a.screenshot({ path: join(SHOTS, 'replay-4-onecard-pc.png') }); + + await b.goto(`${ORIGIN}/me`); + await b.getByRole('region', { name: '내 게임 기록' }).getByRole('link', { name: /원카드.*다시 보기/ }).click(); + await b.getByText('게임을 시작했어요.').waitFor(); + expectEq(await hand(b), liveB, "B's replay hand at the start"); + const pageB = await b.evaluate(tableText); + for (const c of liveA) if (pageB.includes(c)) throw new Error(`B's replay shows A's card ${c}`); + await b.screenshot({ path: join(SHOTS, 'replay-5-onecard-phone.png') }); + }); + + if (errors.length) throw new Error(`page errors: ${errors.join(' | ')}`); +} catch (e) { + failed = true; + console.error(e); +} finally { + await browser.close(); + await app.stop(); +} +console.log(failed ? 'E2E FAILED' : 'E2E PASSED'); +process.exit(failed ? 1 : 0); diff --git a/packages/shared/src/lite.ts b/packages/shared/src/lite.ts index 34614a7..9285347 100644 --- a/packages/shared/src/lite.ts +++ b/packages/shared/src/lite.ts @@ -3,3 +3,4 @@ export * from './types'; export * from './nickname'; export * from './room-code'; export * from './catalog'; +export * from './view-patch'; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 57af97e..59bce07 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -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; d?: string[] } + | { a: number; p: Record }; + +/** 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; + 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; +} diff --git a/packages/shared/src/view-patch.test.ts b/packages/shared/src/view-patch.test.ts new file mode 100644 index 0000000..8c19158 --- /dev/null +++ b/packages/shared/src/view-patch.test.ts @@ -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 = {}; + 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 = { ...(v as Record) }; + 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; + expect(({} as Record).polluted).toBeUndefined(); + expect(Object.keys(got)).toEqual(['__proto__']); + }); +}); diff --git a/packages/shared/src/view-patch.ts b/packages/shared/src/view-patch.ts new file mode 100644 index 0000000..7ba2393 --- /dev/null +++ b/packages/shared/src/view-patch.ts @@ -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; + +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 = {}; + 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 = {}; + 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; +}