Merge branch 'm9/replay' into codex/owner/grp_discord_joke-app
This commit is contained in:
@@ -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);
|
||||
|
||||
78
apps/server/src/replay-api.test.ts
Normal file
78
apps/server/src/replay-api.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
293
apps/server/src/rooms/replay.test.ts
Normal file
293
apps/server/src/rooms/replay.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
288
apps/server/src/rooms/replay.ts
Normal file
288
apps/server/src/rooms/replay.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
261
apps/web/src/pages/ReplayPage.tsx
Normal file
261
apps/web/src/pages/ReplayPage.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user