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

@@ -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 { SESSION_COOKIE, SESSION_TTL, randomToken, signValue, unsignValue, type Sessions } from '../auth/sessions';
import { toPublicUser, type Users } from '../auth/users'; import { toPublicUser, type Users } from '../auth/users';
import type { RoomManager } from '../rooms/manager'; import type { RoomManager } from '../rooms/manager';
import type { Replays } from '../rooms/replay';
import { WindowLimiter } from '../ws/rate-limit'; import { WindowLimiter } from '../ws/rate-limit';
import { createAdminApp, type AdminDeps } from './admin'; import { createAdminApp, type AdminDeps } from './admin';
import type { Roles } from '../admin/roles'; import type { Roles } from '../admin/roles';
@@ -18,6 +19,7 @@ export interface HttpDeps {
users: Users; users: Users;
sessions: Sessions; sessions: Sessions;
rooms: RoomManager; rooms: RoomManager;
replays: Replays;
/** Injected for tests. */ /** Injected for tests. */
discordExchange?: (code: string, redirectUri: string) => Promise<DiscordUser>; discordExchange?: (code: string, redirectUri: string) => Promise<DiscordUser>;
ipOf: (req: Request) => string; ipOf: (req: Request) => string;
@@ -37,6 +39,7 @@ export function createHttpApp(d: HttpDeps) {
const createLimiter = new WindowLimiter(10 * lift, 60_000); const createLimiter = new WindowLimiter(10 * lift, 60_000);
const lookupLimiter = new WindowLimiter(30 * lift, 60_000); const lookupLimiter = new WindowLimiter(30 * lift, 60_000);
const guestLimiter = new WindowLimiter(20 * 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 redirectUri = `${d.config.publicOrigin}/api/auth/discord/callback`;
const cookieOpts = (maxAgeMs: number) => ({ const cookieOpts = (maxAgeMs: number) => ({
@@ -238,6 +241,28 @@ export function createHttpApp(d: HttpDeps) {
return c.json({ stats: d.users.stats(u) }); 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) => { app.post('/api/rooms', async (c) => {
const u = requireUser(c); const u = requireUser(c);
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401); if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);

View File

@@ -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<typeof boot>[] = [];
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);
});
});

View File

@@ -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<string, Conn & { msgs: ServerMessage[] }> = {};
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<string>();
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<ServerMessage, { t: 'state' }>;
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<ServerMessage, { t: 'state' }> => 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<number>((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);
});
});

View File

@@ -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<Step> {
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<string, { body: string; at: number }>();
private cacheChars = 0;
private queue: Promise<unknown> = Promise.resolve();
private inflight = new Map<string, Promise<Awaited<ReturnType<typeof buildFrames>>>>();
private readonly now: () => number;
constructor(
private db: Database,
private games: Record<string, AnyGameDefinition>,
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<GameRow & { rank: number | null }, [string, string, number]>(
`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<string, { user_id: string; rank: number | null }[]>();
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<ReplayResult> {
const g = this.db
.query<GameRow, [string]>(
'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<ReturnType<typeof buildFrames>>;
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<string, PublicUser> = {};
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<string, string> {
const map = new Map<string, string>();
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;
}
}

View File

@@ -220,7 +220,8 @@ export class Room {
return 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 nextSeq = this.seq + 1;
const entry = log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null; const entry = log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null;
const logs = this.pendingLogs.length ? [...this.pendingLogs, ...(entry ? [entry] : [])] : entry; 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 * 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. * 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.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.lastActiveAt = this.now();
this.flushTimer ??= setTimeout(() => this.flushStream(), STREAM_FLUSH_MS); this.flushTimer ??= setTimeout(() => this.flushStream(), STREAM_FLUSH_MS);
} }
@@ -288,6 +289,7 @@ export class Room {
seedHash: this.game?.seedHash ?? null, seedHash: this.game?.seedHash ?? null,
lockedOptions: this.locked(this.config.gameId).paths, lockedOptions: this.locked(this.config.gameId).paths,
lastSeed: this.lastSeed, lastSeed: this.lastSeed,
lastGamePk: this.game?.finished ? this.game.gamePk : null,
}; };
} }
@@ -673,7 +675,7 @@ export class Room {
this.lastResult = null; this.lastResult = null;
this.consecutiveErrors = 0; this.consecutiveErrors = 0;
for (const s of this.seats) if (s) s.ready = false; 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.broadcastRoom();
this.broadcastState([{ type: 'gameStarted' }]); this.broadcastState([{ type: 'gameStarted' }]);
this.scheduleTimer(); this.scheduleTimer();
@@ -727,11 +729,11 @@ export class Room {
g.deadlineFloor = null; g.deadlineFloor = null;
if (!system && def.isStreamAction?.(action)) { if (!system && def.isStreamAction?.(action)) {
// Relay the (public) events only; views are not recomputed per stroke batch (docs/games/catchmind.md §5.5). // 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 }; 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); for (const c of this.conns.values()) c.send(c.userId === userId && cs !== undefined ? { ...msg, cs } : msg);
} else { } 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); this.broadcastState(next.events, userId, cs);
} }
const result = def.result(g.state); const result = def.result(g.state);

View File

@@ -10,6 +10,7 @@ import { Users, toPublicUser } from './auth/users';
import { createHttpApp, type HttpDeps } from './http/app'; import { createHttpApp, type HttpDeps } from './http/app';
import { RoomManager } from './rooms/manager'; import { RoomManager } from './rooms/manager';
import { RoomStore } from './rooms/store'; import { RoomStore } from './rooms/store';
import { Replays } from './rooms/replay';
import { Gateway, type WsData } from './ws/gateway'; import { Gateway, type WsData } from './ws/gateway';
import { Audit, Roles } from './admin/roles'; import { Audit, Roles } from './admin/roles';
import { SettingsStore } from './admin/settings'; import { SettingsStore } from './admin/settings';
@@ -60,6 +61,7 @@ export function startServer(opts: StartOptions) {
users, users,
sessions, sessions,
rooms, rooms,
replays: new Replays(db, GAMES, { publicUser }),
ipOf, ipOf,
discordExchange: opts.discordExchange, discordExchange: opts.discordExchange,
health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }), health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }),

View File

@@ -13,6 +13,7 @@ const JoinPage = lazy(() => import('./pages/JoinPage'));
const RoomPage = lazy(() => import('./pages/RoomPage')); const RoomPage = lazy(() => import('./pages/RoomPage'));
const GamePage = lazy(() => import('./pages/GamePage')); const GamePage = lazy(() => import('./pages/GamePage'));
const MePage = lazy(() => import('./pages/MePage')); const MePage = lazy(() => import('./pages/MePage'));
const ReplayPage = lazy(() => import('./pages/ReplayPage'));
const RulesPage = lazy(() => import('./pages/RulesPage')); const RulesPage = lazy(() => import('./pages/RulesPage'));
const NotFound = lazy(() => import('./pages/NotFound')); const NotFound = lazy(() => import('./pages/NotFound'));
const AdminApp = lazy(() => import('./pages/admin/AdminApp')); const AdminApp = lazy(() => import('./pages/admin/AdminApp'));
@@ -67,6 +68,7 @@ function Shell() {
<Route path="/g/:gameId" element={<GamePage />} /> <Route path="/g/:gameId" element={<GamePage />} />
<Route path="/rules/:gameId" element={<RulesPage />} /> <Route path="/rules/:gameId" element={<RulesPage />} />
<Route path="/me" element={<MePage />} /> <Route path="/me" element={<MePage />} />
<Route path="/replay/:gamePk" element={<ReplayPage />} />
<Route path="/admin/*" element={<AdminApp />} /> <Route path="/admin/*" element={<AdminApp />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>

View File

@@ -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 { export class ApiError extends Error {
constructor( constructor(
@@ -66,6 +66,9 @@ export const api = {
logoutAll: () => call<{ ok: true }>('POST', '/api/auth/logout-all', {}), logoutAll: () => call<{ ok: true }>('POST', '/api/auth/logout-all', {}),
deleteMe: () => call<{ ok: true }>('DELETE', '/api/me', { confirm: '탈퇴' }), deleteMe: () => call<{ ok: true }>('DELETE', '/api/me', { confirm: '탈퇴' }),
stats: () => call<{ stats: { gameId: string; played: number; wins: number; draws: number }[] }>('GET', '/api/me/stats'), 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<ReplayData>('GET', `/api/replays/${encodeURIComponent(gamePk)}`),
createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }), createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }),
quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}), quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}),
room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`), room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`),

View File

@@ -1,9 +1,10 @@
/** 계정 페이지 (docs/05 §7). */ /** 계정 페이지 (docs/05 §7). */
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router'; import { Link, useNavigate } from 'react-router';
import { catalogById, nicknameError, normalizeNickname } from '@bg/shared/lite'; import { catalogById, nicknameError, normalizeNickname, type MyGameSummary } from '@bg/shared/lite';
import { Avatar, ConfirmSheet, TopBar } from '../components/ui'; import { Avatar, ConfirmSheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm'; import { NicknameForm } from '../components/NicknameForm';
import { gameName } from '../components/GameNotice';
import { api, discordLoginUrl } from '../net/api'; import { api, discordLoginUrl } from '../net/api';
import { useSession } from '../store/session'; import { useSession } from '../store/session';
import { toast } from '../store/toast'; import { toast } from '../store/toast';
@@ -152,6 +153,8 @@ export default function MePage() {
)} )}
</section> </section>
<MyGames />
<section className="stack"> <section className="stack">
<button className="btn btn-secondary btn-block" onClick={() => setConfirm('logout')}> <button className="btn btn-secondary btn-block" onClick={() => setConfirm('logout')}>
로그아웃 로그아웃
@@ -210,3 +213,83 @@ export default function MePage() {
</> </>
); );
} }
function resultLabel(g: MyGameSummary): string {
if (g.endReason === 'void') return '무효';
if (g.myRank === null) return '끝남';
if (g.players.length === 1) return '혼자 했어요';
if (g.players.every((p) => p.rank === 1)) return '무승부';
if (g.players.length === 2) return g.myRank === 1 ? '이겼어요' : '졌어요';
return `${g.myRank}등`;
}
/** 내 게임 기록: finished games, newest first, with a 다시 보기 button (docs/09 §8). */
function MyGames() {
const [games, setGames] = useState<MyGameSummary[] | null>(null);
const [next, setNext] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const load = (before: string | null) => {
setLoading(true);
api
.myGames(before)
.then((r) => {
setGames((prev) => [...(before ? (prev ?? []) : []), ...r.games]);
setNext(r.next);
})
.catch((e) => toast((e as Error).message, 'error'))
.finally(() => setLoading(false));
};
useEffect(() => load(null), []);
return (
<section className="card stack" aria-labelledby="my-games-title">
<h2 id="my-games-title">내 게임 기록</h2>
{games === null ? (
<p className="muted">불러오는 중…</p>
) : games.length === 0 ? (
<p className="muted">아직 다시 볼 게임이 없어요.</p>
) : (
<ul className="my-games">
{games.map((g) => {
const label = resultLabel(g);
return (
<li key={g.gamePk} className="my-game">
<div className="stack" style={{ gap: 4, flex: 1, minWidth: 0 }}>
<div className="row" style={{ gap: 8 }}>
<strong>{gameName(g.gameId)}</strong>
<span className={`badge${label === '이겼어요' || label === '1등' ? ' success' : ''}`}>{label}</span>
</div>
<span className="muted small">
{new Date(g.startedAt).toLocaleString('ko-KR', { month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
</span>
<span className="small">
{g.players.map((p, i) => (
<span key={i}>
{i > 0 && ' · '}
{p.me ? <strong>나</strong> : p.nickname}
</span>
))}
</span>
</div>
{g.replayable ? (
<Link className="btn btn-soft" to={`/replay/${g.gamePk}`} aria-label={`${gameName(g.gameId)} ${label} 다시 보기`}>
▶ 다시 보기
</Link>
) : (
<span className="muted small" style={{ maxWidth: 110, textAlign: 'right' }}>
다시 볼 수 없어요
</span>
)}
</li>
);
})}
</ul>
)}
{next && (
<button className="btn btn-secondary btn-block" disabled={loading} onClick={() => load(next)}>
{loading ? '불러오는 중…' : '더 보기'}
</button>
)}
</section>
);
}

View File

@@ -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<string | null>(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 (
<>
<TopBar title="다시 보기" onBack={back} />
<main className="page">
{!me || error ? (
<section className="card stack">
<h1>{error ?? '로그인한 뒤에 다시 볼 수 있어요.'}</h1>
<button className="btn btn-primary btn-block" onClick={() => nav('/me')}>
내 게임 기록 보기
</button>
<button className="btn btn-secondary btn-block" onClick={() => nav('/')}>
홈으로
</button>
</section>
) : (
<p className="muted">기록을 불러오는 중…</p>
)}
</main>
</>
);
}
return <Player data={data.replay} ui={data.ui} onBack={back} />;
}
/** 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 = (
<div className="card turn-status" role="status" style={{ padding: '14px 16px', fontWeight: 700, fontSize: '1.15rem', width: '100%' }}>
{i === last ? `끝: ${data.result.summary}` : turn}
</div>
);
const controls = (
<section className="replay-controls" aria-label="다시 보기 조작">
<div className="row" style={{ justifyContent: 'space-between' }}>
<strong className="replay-count">
{i === 0 ? '시작' : `${i}번째`} <span className="muted">/ 전체 {last}번</span>
</strong>
<span className="muted small">{clock}</span>
</div>
<p className="small replay-desc" aria-live="polite">
{desc}
</p>
<input
type="range"
className="replay-slider"
min={0}
max={last}
step={1}
value={i}
onChange={(e) => go(Number(e.target.value))}
aria-label="몇 번째 장면인지 고르기"
aria-valuetext={i === 0 ? '시작' : `${i}번째 / 전체 ${last}번`}
/>
<div className="replay-buttons">
<button className="btn btn-secondary" onClick={() => go(0)} disabled={i === 0} aria-label="처음으로">
<span aria-hidden>⏮</span>처음
</button>
<button className="btn btn-secondary" onClick={() => go(i - 1)} disabled={i === 0} aria-label="이전">
<span aria-hidden>◁</span>이전
</button>
<button
className="btn btn-primary"
onClick={() => {
if (playing) return setPlaying(false);
if (i >= last) go(0);
setPlaying(true);
}}
disabled={last === 0}
aria-label={playing ? '멈춤' : '재생'}
>
<span aria-hidden>{playing ? '⏸' : '▶'}</span>
{playing ? '멈춤' : '재생'}
</button>
<button className="btn btn-secondary" onClick={() => go(i + 1)} disabled={i === last} aria-label="다음">
<span aria-hidden>▷</span>다음
</button>
<button className="btn btn-secondary" onClick={() => go(last)} disabled={i === last} aria-label="끝으로">
<span aria-hidden>⏭</span>끝
</button>
</div>
</section>
);
return (
<>
<nav className="room-bar" aria-label="다시 보기 메뉴">
<h1 className="sr-only">{gameName(data.gameId)} 다시 보기</h1>
<div className="room-bar-inner">
<button className="btn btn-ghost btn-sm" onClick={onBack}>
← 뒤로
</button>
<strong style={{ fontSize: '1.1rem' }}>{gameName(data.gameId)} 다시 보기</strong>
<span className="muted small wide-only">{when}</span>
</div>
</nav>
<div className={`game-layout${wide ? '' : ' replay-narrow'}`}>
<div className="game-main">
{!wide && status}
<div {...readOnly}>
<ui.Board key={mount} {...props} />
</div>
</div>
<aside className="game-side" aria-label="게임 정보">
{wide && controls}
{wide && status}
<div {...readOnly}>
<ui.Side key={mount} {...props} />
</div>
</aside>
</div>
{!wide && (
<div className="bottom-bar">
<div className="bottom-bar-inner">{controls}</div>
</div>
)}
</>
);
}

View File

@@ -1,6 +1,6 @@
/** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */ /** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */
import { useEffect, useMemo, useRef, useState } from 'react'; 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 { formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite';
import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui'; import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm'; import { NicknameForm } from '../components/NicknameForm';
@@ -614,6 +614,11 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
<button className="btn btn-secondary btn-block" onClick={() => setHidden(true)}> <button className="btn btn-secondary btn-block" onClick={() => setHidden(true)}>
판 다시 보기 판 다시 보기
</button> </button>
{room.lastGamePk && result.ranking.some((g) => g.includes(me.id)) && (
<Link className="btn btn-secondary btn-block" to={`/replay/${room.lastGamePk}`}>
▶ 처음부터 다시 보기
</Link>
)}
<button className="btn btn-ghost btn-block" onClick={onLeave}> <button className="btn btn-ghost btn-block" onClick={onLeave}>
방 나가기 방 나가기
</button> </button>

View File

@@ -957,3 +957,80 @@ label.field {
.schema-group > legend { .schema-group > legend {
grid-column: 1 / -1; 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;
}

View File

@@ -152,6 +152,7 @@
- 게임 상세 + 공개 방 목록 - 게임 상세 + 공개 방 목록
- 대기실(방장/참가자/관전자, 인원 부족/가득 참) - 대기실(방장/참가자/관전자, 인원 부족/가득 참)
- 게임 화면(내 차례/남의 차례/연결 끊김/자리 비움/일시 정지) - 게임 화면(내 차례/남의 차례/연결 끊김/자리 비움/일시 정지)
- 결과 화면(승/패/무승부, 한 판 더 투표 중) - 결과 화면(승/패/무승부, 한 판 더 투표 중, 처음부터 다시 보기)
- 계정(게스트/디스코드) - 계정(게스트/디스코드, 내 게임 기록 목록 + [다시 보기])
- 다시 보기(`/replay/:gamePk`: 판은 읽기 전용, 처음/이전/재생·멈춤/다음/끝 + 슬라이더. PC는 오른쪽 칸, 폰은 아래 고정 막대. 못 보는 게임은 안내 문구, docs/09 §8)
- 오류(방 없음, 방 가득 참, 서버 업데이트, 오프라인) - 오류(방 없음, 방 가득 참, 서버 업데이트, 오프라인)

View File

@@ -120,3 +120,12 @@ export interface GameUI<V> {
- 게임별 불변식(카드 총수 보존, 칩 총합 보존 등) - 게임별 불변식(카드 총수 보존, 칩 총합 보존 등)
3. **정보 유출 검사**: 매 단계 각 플레이어 view의 JSON에 남의 비공개 카드 ID가 없는지(게임이 `secretsOf(state, player)`를 테스트용으로 제공). 3. **정보 유출 검사**: 매 단계 각 플레이어 view의 JSON에 남의 비공개 카드 ID가 없는지(게임이 `secretsOf(state, player)`를 테스트용으로 제공).
4. **시간 초과 테스트**: 모든 상태에서 `onTimeout`이 합법 행동을 돌려주는지. 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`).

249
e2e/replay.e2e.ts Normal file
View File

@@ -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<void>) => {
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);

View File

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

View File

@@ -54,6 +54,8 @@ export interface RoomView {
lockedOptions: string[]; lockedOptions: string[];
/** Seed revealed after the game for fairness check. */ /** Seed revealed after the game for fairness check. */
lastSeed: string | null; lastSeed: string | null;
/** The game that just ended (for 다시 보기), null while playing or before the first game. */
lastGamePk: string | null;
} }
export interface GameResultView { export interface GameResultView {
@@ -85,3 +87,50 @@ export type ServerMessage =
| { t: 'error'; code: string; message: string } | { t: 'error'; code: string; message: string }
| { t: 'pong'; ts: number; serverTime: number } | { t: 'pong'; ts: number; serverTime: number }
| { t: 'bye'; reason: 'restart' | 'replaced' | 'kicked' | 'closed' }; | { 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;
}