Merge branch 'm9/replay' into codex/owner/grp_discord_joke-app

This commit is contained in:
EJClaw
2026-10-06 13:35:23 +09:00
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 { 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<DiscordUser>;
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);

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;
}
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);

View File

@@ -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 }),

View File

@@ -14,6 +14,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'));
@@ -68,6 +69,7 @@ function Shell() {
<Route path="/g/:gameId" element={<GamePage />} />
<Route path="/rules/:gameId" element={<RulesPage />} />
<Route path="/me" element={<MePage />} />
<Route path="/replay/:gamePk" element={<ReplayPage />} />
<Route path="/admin/*" element={<AdminApp />} />
<Route path="*" element={<NotFound />} />
</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 {
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<ReplayData>('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<RoomPreview>('GET', `/api/rooms/${code}`),

View File

@@ -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() {
)}
</section>
<MyGames />
<section className="stack">
<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). */
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';
@@ -622,6 +622,11 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
<button className="btn btn-secondary btn-block" onClick={() => setHidden(true)}>
판 다시 보기
</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>

View File

@@ -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;
}