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 { SESSION_COOKIE, SESSION_TTL, randomToken, signValue, unsignValue, type Sessions } from '../auth/sessions';
|
||||||
import { toPublicUser, type Users } from '../auth/users';
|
import { toPublicUser, type Users } from '../auth/users';
|
||||||
import type { RoomManager } from '../rooms/manager';
|
import type { RoomManager } from '../rooms/manager';
|
||||||
|
import type { Replays } from '../rooms/replay';
|
||||||
import { WindowLimiter } from '../ws/rate-limit';
|
import { WindowLimiter } from '../ws/rate-limit';
|
||||||
import { createAdminApp, type AdminDeps } from './admin';
|
import { createAdminApp, type AdminDeps } from './admin';
|
||||||
import type { Roles } from '../admin/roles';
|
import type { Roles } from '../admin/roles';
|
||||||
@@ -18,6 +19,7 @@ export interface HttpDeps {
|
|||||||
users: Users;
|
users: Users;
|
||||||
sessions: Sessions;
|
sessions: Sessions;
|
||||||
rooms: RoomManager;
|
rooms: RoomManager;
|
||||||
|
replays: Replays;
|
||||||
/** Injected for tests. */
|
/** Injected for tests. */
|
||||||
discordExchange?: (code: string, redirectUri: string) => Promise<DiscordUser>;
|
discordExchange?: (code: string, redirectUri: string) => Promise<DiscordUser>;
|
||||||
ipOf: (req: Request) => string;
|
ipOf: (req: Request) => string;
|
||||||
@@ -37,6 +39,7 @@ export function createHttpApp(d: HttpDeps) {
|
|||||||
const createLimiter = new WindowLimiter(10 * lift, 60_000);
|
const createLimiter = new WindowLimiter(10 * lift, 60_000);
|
||||||
const lookupLimiter = new WindowLimiter(30 * lift, 60_000);
|
const lookupLimiter = new WindowLimiter(30 * lift, 60_000);
|
||||||
const guestLimiter = new WindowLimiter(20 * lift, 60_000);
|
const guestLimiter = new WindowLimiter(20 * lift, 60_000);
|
||||||
|
const replayLimiter = new WindowLimiter(30 * lift, 60_000);
|
||||||
const redirectUri = `${d.config.publicOrigin}/api/auth/discord/callback`;
|
const redirectUri = `${d.config.publicOrigin}/api/auth/discord/callback`;
|
||||||
|
|
||||||
const cookieOpts = (maxAgeMs: number) => ({
|
const cookieOpts = (maxAgeMs: number) => ({
|
||||||
@@ -238,6 +241,28 @@ export function createHttpApp(d: HttpDeps) {
|
|||||||
return c.json({ stats: d.users.stats(u) });
|
return c.json({ stats: d.users.stats(u) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/me/games', (c) => {
|
||||||
|
const u = requireUser(c);
|
||||||
|
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||||
|
const before = c.req.query('before') ?? null;
|
||||||
|
const limit = Math.min(Math.max(Number(c.req.query('limit')) || 20, 1), 50);
|
||||||
|
if (before !== null && !/^[0-9A-Z]{26}$/.test(before)) return c.json({ error: '잘못된 요청이에요.' }, 400);
|
||||||
|
return c.json(d.replays.listForUser(u, before, limit));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Replay of a finished game, for its players only (docs/09 §8). Rendered per viewer like the live game. */
|
||||||
|
app.get('/api/replays/:gamePk', async (c) => {
|
||||||
|
const u = requireUser(c);
|
||||||
|
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||||
|
if (!replayLimiter.allow(u)) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
|
||||||
|
const gamePk = c.req.param('gamePk');
|
||||||
|
if (!/^[0-9A-Z]{26}$/.test(gamePk)) return c.json({ error: '게임 기록을 찾을 수 없어요.' }, 404);
|
||||||
|
const r = await d.replays.get(gamePk, u);
|
||||||
|
if (!r.ok) return c.json({ error: r.error }, r.status);
|
||||||
|
// no-store: the body holds this viewer's private view (e.g. their hand); a shared device must not cache it.
|
||||||
|
return c.body(r.body, 200, { 'content-type': 'application/json; charset=UTF-8', 'cache-control': 'private, no-store' });
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/rooms', async (c) => {
|
app.post('/api/rooms', async (c) => {
|
||||||
const u = requireUser(c);
|
const u = requireUser(c);
|
||||||
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||||
|
|||||||
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;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
private persist(log: { actorId: string | null; kind: string; data: unknown } | null): void {
|
/** `log.at` defaults to the current time; game actions pass the exact `now` they were applied with (replays, docs/09 §8). */
|
||||||
|
private persist(log: { actorId: string | null; kind: string; data: unknown; at?: number } | null): void {
|
||||||
const nextSeq = this.seq + 1;
|
const nextSeq = this.seq + 1;
|
||||||
const entry = log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null;
|
const entry = log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null;
|
||||||
const logs = this.pendingLogs.length ? [...this.pendingLogs, ...(entry ? [entry] : [])] : entry;
|
const logs = this.pendingLogs.length ? [...this.pendingLogs, ...(entry ? [entry] : [])] : entry;
|
||||||
@@ -242,9 +243,9 @@ export class Room {
|
|||||||
* Streaming actions (e.g. 그림 맞히기 strokes, ~20/s) get a seq and a log entry right away, but the snapshot
|
* Streaming actions (e.g. 그림 맞히기 strokes, ~20/s) get a seq and a log entry right away, but the snapshot
|
||||||
* is written at most every STREAM_FLUSH_MS (or with the next regular persist) instead of once per message.
|
* is written at most every STREAM_FLUSH_MS (or with the next regular persist) instead of once per message.
|
||||||
*/
|
*/
|
||||||
private persistStream(log: { actorId: string | null; kind: string; data: unknown }): void {
|
private persistStream(log: { actorId: string | null; kind: string; data: unknown; at: number }): void {
|
||||||
this.seq += 1;
|
this.seq += 1;
|
||||||
this.pendingLogs.push({ seq: this.seq, gameNo: this.gameNo, at: this.now(), ...log });
|
this.pendingLogs.push({ seq: this.seq, gameNo: this.gameNo, ...log });
|
||||||
this.lastActiveAt = this.now();
|
this.lastActiveAt = this.now();
|
||||||
this.flushTimer ??= setTimeout(() => this.flushStream(), STREAM_FLUSH_MS);
|
this.flushTimer ??= setTimeout(() => this.flushStream(), STREAM_FLUSH_MS);
|
||||||
}
|
}
|
||||||
@@ -288,6 +289,7 @@ export class Room {
|
|||||||
seedHash: this.game?.seedHash ?? null,
|
seedHash: this.game?.seedHash ?? null,
|
||||||
lockedOptions: this.locked(this.config.gameId).paths,
|
lockedOptions: this.locked(this.config.gameId).paths,
|
||||||
lastSeed: this.lastSeed,
|
lastSeed: this.lastSeed,
|
||||||
|
lastGamePk: this.game?.finished ? this.game.gamePk : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,7 +675,7 @@ export class Room {
|
|||||||
this.lastResult = null;
|
this.lastResult = null;
|
||||||
this.consecutiveErrors = 0;
|
this.consecutiveErrors = 0;
|
||||||
for (const s of this.seats) if (s) s.ready = false;
|
for (const s of this.seats) if (s) s.ready = false;
|
||||||
this.persist({ actorId: this.hostId, kind: 'start', data: { gameId: def.id, players, seedHash } });
|
this.persist({ actorId: this.hostId, kind: 'start', data: { gameId: def.id, players, seedHash, stateVersion: def.stateVersion }, at: now });
|
||||||
this.broadcastRoom();
|
this.broadcastRoom();
|
||||||
this.broadcastState([{ type: 'gameStarted' }]);
|
this.broadcastState([{ type: 'gameStarted' }]);
|
||||||
this.scheduleTimer();
|
this.scheduleTimer();
|
||||||
@@ -727,11 +729,11 @@ export class Room {
|
|||||||
g.deadlineFloor = null;
|
g.deadlineFloor = null;
|
||||||
if (!system && def.isStreamAction?.(action)) {
|
if (!system && def.isStreamAction?.(action)) {
|
||||||
// Relay the (public) events only; views are not recomputed per stroke batch (docs/games/catchmind.md §5.5).
|
// Relay the (public) events only; views are not recomputed per stroke batch (docs/games/catchmind.md §5.5).
|
||||||
this.persistStream({ actorId: userId, kind: 'act', data: action });
|
this.persistStream({ actorId: userId, kind: 'act', data: action, at: now });
|
||||||
const msg: ServerMessage = { t: 'stream', seq: this.seq, events: next.events };
|
const msg: ServerMessage = { t: 'stream', seq: this.seq, events: next.events };
|
||||||
for (const c of this.conns.values()) c.send(c.userId === userId && cs !== undefined ? { ...msg, cs } : msg);
|
for (const c of this.conns.values()) c.send(c.userId === userId && cs !== undefined ? { ...msg, cs } : msg);
|
||||||
} else {
|
} else {
|
||||||
this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action });
|
this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action, at: now });
|
||||||
this.broadcastState(next.events, userId, cs);
|
this.broadcastState(next.events, userId, cs);
|
||||||
}
|
}
|
||||||
const result = def.result(g.state);
|
const result = def.result(g.state);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Users, toPublicUser } from './auth/users';
|
|||||||
import { createHttpApp, type HttpDeps } from './http/app';
|
import { createHttpApp, type HttpDeps } from './http/app';
|
||||||
import { RoomManager } from './rooms/manager';
|
import { RoomManager } from './rooms/manager';
|
||||||
import { RoomStore } from './rooms/store';
|
import { RoomStore } from './rooms/store';
|
||||||
|
import { Replays } from './rooms/replay';
|
||||||
import { Gateway, type WsData } from './ws/gateway';
|
import { Gateway, type WsData } from './ws/gateway';
|
||||||
import { Audit, Roles } from './admin/roles';
|
import { Audit, Roles } from './admin/roles';
|
||||||
import { SettingsStore } from './admin/settings';
|
import { SettingsStore } from './admin/settings';
|
||||||
@@ -60,6 +61,7 @@ export function startServer(opts: StartOptions) {
|
|||||||
users,
|
users,
|
||||||
sessions,
|
sessions,
|
||||||
rooms,
|
rooms,
|
||||||
|
replays: new Replays(db, GAMES, { publicUser }),
|
||||||
ipOf,
|
ipOf,
|
||||||
discordExchange: opts.discordExchange,
|
discordExchange: opts.discordExchange,
|
||||||
health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }),
|
health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const JoinPage = lazy(() => import('./pages/JoinPage'));
|
|||||||
const RoomPage = lazy(() => import('./pages/RoomPage'));
|
const RoomPage = lazy(() => import('./pages/RoomPage'));
|
||||||
const GamePage = lazy(() => import('./pages/GamePage'));
|
const GamePage = lazy(() => import('./pages/GamePage'));
|
||||||
const MePage = lazy(() => import('./pages/MePage'));
|
const MePage = lazy(() => import('./pages/MePage'));
|
||||||
|
const ReplayPage = lazy(() => import('./pages/ReplayPage'));
|
||||||
const RulesPage = lazy(() => import('./pages/RulesPage'));
|
const RulesPage = lazy(() => import('./pages/RulesPage'));
|
||||||
const NotFound = lazy(() => import('./pages/NotFound'));
|
const NotFound = lazy(() => import('./pages/NotFound'));
|
||||||
const AdminApp = lazy(() => import('./pages/admin/AdminApp'));
|
const AdminApp = lazy(() => import('./pages/admin/AdminApp'));
|
||||||
@@ -68,6 +69,7 @@ function Shell() {
|
|||||||
<Route path="/g/:gameId" element={<GamePage />} />
|
<Route path="/g/:gameId" element={<GamePage />} />
|
||||||
<Route path="/rules/:gameId" element={<RulesPage />} />
|
<Route path="/rules/:gameId" element={<RulesPage />} />
|
||||||
<Route path="/me" element={<MePage />} />
|
<Route path="/me" element={<MePage />} />
|
||||||
|
<Route path="/replay/:gamePk" element={<ReplayPage />} />
|
||||||
<Route path="/admin/*" element={<AdminApp />} />
|
<Route path="/admin/*" element={<AdminApp />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CatalogEntry, PublicUser } from '@bg/shared/lite';
|
import type { CatalogEntry, MyGameSummary, PublicUser, ReplayData } from '@bg/shared/lite';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -66,6 +66,9 @@ export const api = {
|
|||||||
logoutAll: () => call<{ ok: true }>('POST', '/api/auth/logout-all', {}),
|
logoutAll: () => call<{ ok: true }>('POST', '/api/auth/logout-all', {}),
|
||||||
deleteMe: () => call<{ ok: true }>('DELETE', '/api/me', { confirm: '탈퇴' }),
|
deleteMe: () => call<{ ok: true }>('DELETE', '/api/me', { confirm: '탈퇴' }),
|
||||||
stats: () => call<{ stats: { gameId: string; played: number; wins: number; draws: number }[] }>('GET', '/api/me/stats'),
|
stats: () => call<{ stats: { gameId: string; played: number; wins: number; draws: number }[] }>('GET', '/api/me/stats'),
|
||||||
|
myGames: (before?: string | null) =>
|
||||||
|
call<{ games: MyGameSummary[]; next: string | null }>('GET', `/api/me/games${before ? `?before=${encodeURIComponent(before)}` : ''}`),
|
||||||
|
replay: (gamePk: string) => call<ReplayData>('GET', `/api/replays/${encodeURIComponent(gamePk)}`),
|
||||||
createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }),
|
createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }),
|
||||||
quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}),
|
quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}),
|
||||||
room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`),
|
room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`),
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/** 계정 페이지 (docs/05 §7). */
|
/** 계정 페이지 (docs/05 §7). */
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { Link, useNavigate } from 'react-router';
|
||||||
import { catalogById, nicknameError, normalizeNickname } from '@bg/shared/lite';
|
import { catalogById, nicknameError, normalizeNickname, type MyGameSummary } from '@bg/shared/lite';
|
||||||
import { Avatar, ConfirmSheet, TopBar } from '../components/ui';
|
import { Avatar, ConfirmSheet, TopBar } from '../components/ui';
|
||||||
import { NicknameForm } from '../components/NicknameForm';
|
import { NicknameForm } from '../components/NicknameForm';
|
||||||
|
import { gameName } from '../components/GameNotice';
|
||||||
import { api, discordLoginUrl } from '../net/api';
|
import { api, discordLoginUrl } from '../net/api';
|
||||||
import { useSession } from '../store/session';
|
import { useSession } from '../store/session';
|
||||||
import { toast } from '../store/toast';
|
import { toast } from '../store/toast';
|
||||||
@@ -152,6 +153,8 @@ export default function MePage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<MyGames />
|
||||||
|
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
<button className="btn btn-secondary btn-block" onClick={() => setConfirm('logout')}>
|
<button className="btn btn-secondary btn-block" onClick={() => setConfirm('logout')}>
|
||||||
로그아웃
|
로그아웃
|
||||||
@@ -210,3 +213,83 @@ export default function MePage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resultLabel(g: MyGameSummary): string {
|
||||||
|
if (g.endReason === 'void') return '무효';
|
||||||
|
if (g.myRank === null) return '끝남';
|
||||||
|
if (g.players.length === 1) return '혼자 했어요';
|
||||||
|
if (g.players.every((p) => p.rank === 1)) return '무승부';
|
||||||
|
if (g.players.length === 2) return g.myRank === 1 ? '이겼어요' : '졌어요';
|
||||||
|
return `${g.myRank}등`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 내 게임 기록: finished games, newest first, with a 다시 보기 button (docs/09 §8). */
|
||||||
|
function MyGames() {
|
||||||
|
const [games, setGames] = useState<MyGameSummary[] | null>(null);
|
||||||
|
const [next, setNext] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const load = (before: string | null) => {
|
||||||
|
setLoading(true);
|
||||||
|
api
|
||||||
|
.myGames(before)
|
||||||
|
.then((r) => {
|
||||||
|
setGames((prev) => [...(before ? (prev ?? []) : []), ...r.games]);
|
||||||
|
setNext(r.next);
|
||||||
|
})
|
||||||
|
.catch((e) => toast((e as Error).message, 'error'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
useEffect(() => load(null), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card stack" aria-labelledby="my-games-title">
|
||||||
|
<h2 id="my-games-title">내 게임 기록</h2>
|
||||||
|
{games === null ? (
|
||||||
|
<p className="muted">불러오는 중…</p>
|
||||||
|
) : games.length === 0 ? (
|
||||||
|
<p className="muted">아직 다시 볼 게임이 없어요.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="my-games">
|
||||||
|
{games.map((g) => {
|
||||||
|
const label = resultLabel(g);
|
||||||
|
return (
|
||||||
|
<li key={g.gamePk} className="my-game">
|
||||||
|
<div className="stack" style={{ gap: 4, flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="row" style={{ gap: 8 }}>
|
||||||
|
<strong>{gameName(g.gameId)}</strong>
|
||||||
|
<span className={`badge${label === '이겼어요' || label === '1등' ? ' success' : ''}`}>{label}</span>
|
||||||
|
</div>
|
||||||
|
<span className="muted small">
|
||||||
|
{new Date(g.startedAt).toLocaleString('ko-KR', { month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
<span className="small">
|
||||||
|
{g.players.map((p, i) => (
|
||||||
|
<span key={i}>
|
||||||
|
{i > 0 && ' · '}
|
||||||
|
{p.me ? <strong>나</strong> : p.nickname}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{g.replayable ? (
|
||||||
|
<Link className="btn btn-soft" to={`/replay/${g.gamePk}`} aria-label={`${gameName(g.gameId)} ${label} 다시 보기`}>
|
||||||
|
▶ 다시 보기
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="muted small" style={{ maxWidth: 110, textAlign: 'right' }}>
|
||||||
|
다시 볼 수 없어요
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{next && (
|
||||||
|
<button className="btn btn-secondary btn-block" disabled={loading} onClick={() => load(next)}>
|
||||||
|
{loading ? '불러오는 중…' : '더 보기'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
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). */
|
/** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router';
|
import { Link, useNavigate, useParams } from 'react-router';
|
||||||
import { formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite';
|
import { formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite';
|
||||||
import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui';
|
import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui';
|
||||||
import { NicknameForm } from '../components/NicknameForm';
|
import { NicknameForm } from '../components/NicknameForm';
|
||||||
@@ -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 className="btn btn-secondary btn-block" onClick={() => setHidden(true)}>
|
||||||
판 다시 보기
|
판 다시 보기
|
||||||
</button>
|
</button>
|
||||||
|
{room.lastGamePk && result.ranking.some((g) => g.includes(me.id)) && (
|
||||||
|
<Link className="btn btn-secondary btn-block" to={`/replay/${room.lastGamePk}`}>
|
||||||
|
▶ 처음부터 다시 보기
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<button className="btn btn-ghost btn-block" onClick={onLeave}>
|
<button className="btn btn-ghost btn-block" onClick={onLeave}>
|
||||||
방 나가기
|
방 나가기
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -957,3 +957,80 @@ label.field {
|
|||||||
.schema-group > legend {
|
.schema-group > legend {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Replay (docs/09 §8): read-only board, step controls in the side panel (PC) or a bottom bar (phone/tablet) */
|
||||||
|
.replay-readonly {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.replay-readonly button,
|
||||||
|
.replay-readonly [role='button'],
|
||||||
|
.replay-readonly svg {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.replay-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.bottom-bar .replay-controls {
|
||||||
|
box-shadow: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.replay-count {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.replay-desc {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 1.4em;
|
||||||
|
}
|
||||||
|
.replay-slider {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
}
|
||||||
|
.replay-buttons {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.replay-buttons .btn {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 60px;
|
||||||
|
padding: 0 4px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.replay-buttons .btn span {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
.game-layout.replay-narrow {
|
||||||
|
padding-bottom: calc(240px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.my-games {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.my-game {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.my-game:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -157,8 +157,9 @@
|
|||||||
- 게임 상세 + 공개 방 목록
|
- 게임 상세 + 공개 방 목록
|
||||||
- 대기실(방장/참가자/관전자, 인원 부족/가득 참)
|
- 대기실(방장/참가자/관전자, 인원 부족/가득 참)
|
||||||
- 게임 화면(내 차례/남의 차례/연결 끊김/자리 비움/일시 정지)
|
- 게임 화면(내 차례/남의 차례/연결 끊김/자리 비움/일시 정지)
|
||||||
- 결과 화면(승/패/무승부, 한 판 더 투표 중)
|
- 결과 화면(승/패/무승부, 한 판 더 투표 중, 처음부터 다시 보기)
|
||||||
- 계정(게스트/디스코드)
|
- 계정(게스트/디스코드, 내 게임 기록 목록 + [다시 보기])
|
||||||
|
- 다시 보기(`/replay/:gamePk`: 판은 읽기 전용, 처음/이전/재생·멈춤/다음/끝 + 슬라이더. PC는 오른쪽 칸, 폰은 아래 고정 막대. 못 보는 게임은 안내 문구, docs/09 §8)
|
||||||
- 오류(방 없음, 방 가득 참, 서버 업데이트, 오프라인)
|
- 오류(방 없음, 방 가득 참, 서버 업데이트, 오프라인)
|
||||||
|
|
||||||
## 11. PWA (홈 화면 추가·오프라인 안내)
|
## 11. PWA (홈 화면 추가·오프라인 안내)
|
||||||
|
|||||||
@@ -120,3 +120,12 @@ export interface GameUI<V> {
|
|||||||
- 게임별 불변식(카드 총수 보존, 칩 총합 보존 등)
|
- 게임별 불변식(카드 총수 보존, 칩 총합 보존 등)
|
||||||
3. **정보 유출 검사**: 매 단계 각 플레이어 view의 JSON에 남의 비공개 카드 ID가 없는지(게임이 `secretsOf(state, player)`를 테스트용으로 제공).
|
3. **정보 유출 검사**: 매 단계 각 플레이어 view의 JSON에 남의 비공개 카드 ID가 없는지(게임이 `secretsOf(state, player)`를 테스트용으로 제공).
|
||||||
4. **시간 초과 테스트**: 모든 상태에서 `onTimeout`이 합법 행동을 돌려주는지.
|
4. **시간 초과 테스트**: 모든 상태에서 `onTimeout`이 합법 행동을 돌려주는지.
|
||||||
|
|
||||||
|
## 8. 게임 기록 다시 보기 (`apps/server/src/rooms/replay.ts`, `apps/web/src/pages/ReplayPage.tsx`)
|
||||||
|
- **다시 만들기**: 끝난 게임을 공개된 시드 + `room_log`의 `act`/`auto` 기록으로 처음부터 다시 돌린다. `setup`(시작 시각)과 각 `apply`(그 행동을 적용한 정확한 `now`, 로그의 `at`)를 방과 똑같이 부르고 RNG도 이어서 쓰므로 결과가 방에서 끝난 상태와 한 글자도 다르지 않다. `start` 로그에 `stateVersion`을 남긴다.
|
||||||
|
- **보이는 것**: 그 판에 앉았던 플레이어만 볼 수 있고(관전자·남은 403), 각자 게임 중에 받던 자기 `view`(있으면 `syncView`)만 받는다. 시드와 원본 로그는 서버 밖으로 나가지 않는다. 응답은 `private, no-store`.
|
||||||
|
- **장면**: 스트리밍 행동(그림 맞히기 선)이 아닌 행동 하나가 한 장면. 첫 장면만 view 전체, 나머지는 앞 장면과의 차이(`packages/shared/src/view-patch.ts`)만 보낸다. 다시 만든 결과가 저장된 결과와 다르면 보여 주지 않는다.
|
||||||
|
- **못 보는 경우**(410, 한국어 안내): 90일이 지나 로그가 지워짐, 그 뒤로 게임 규칙 버전(`stateVersion`)이 바뀜, 다시 만든 결과가 다름.
|
||||||
|
- **API**: `GET /api/me/games?before=&limit=`(내가 끝까지 한 게임, 최신순), `GET /api/replays/:gamePk`. 다시 만든 결과는 잠깐(10분, 32개) 캐시하고, 한 번에 하나씩 조금씩 끊어 만들어 방 처리를 막지 않는다.
|
||||||
|
- **화면**: 게임의 `Board`/`Side`를 그대로 쓰되 읽기 전용(`act`는 아무것도 안 함, 판 안의 누르기·키 입력은 막음). 처음 / 이전 / 재생·멈춤 / 다음 / 끝 버튼, 슬라이더, "n번째 / 전체 n번", 장면 설명(누가 움직였는지, 자동 진행). 키보드 ←/→/Home/End. 들어가는 곳: 결과 화면 [처음부터 다시 보기](`RoomView.lastGamePk`), 내 정보 › 내 게임 기록 [다시 보기].
|
||||||
|
- **테스트**: 모든 게임을 실제 `Room`으로 끝까지(무작위 행동, 시간 초과, 나가기) 둔 뒤 다시 만든 상태·결과가 같은지, 모든 장면이 그 플레이어가 받은 view와 같은지(`replay.test.ts`), 권한(`replay-api.test.ts`), 브라우저(`e2e/replay.e2e.ts`).
|
||||||
|
|||||||
249
e2e/replay.e2e.ts
Normal file
249
e2e/replay.e2e.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* E2E (docs/09 §8): game record replay.
|
||||||
|
* 1) 오목 to the end with two browsers (PC + phone), open 다시 보기 from the result sheet and from 내 정보,
|
||||||
|
* step through it (buttons, slider, keyboard, play) and check the board changes.
|
||||||
|
* 2) 원카드 (hidden hands): each player's replay shows only their own cards.
|
||||||
|
* Run: E2E_PORT=4532 bun e2e/replay.e2e.ts (needs apps/web/dist built)
|
||||||
|
*/
|
||||||
|
import { chromium, type Page } from 'playwright-core';
|
||||||
|
import { mkdirSync, mkdtempSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { loadConfig } from '../apps/server/src/config';
|
||||||
|
import { startServer } from '../apps/server/src/server';
|
||||||
|
|
||||||
|
const PORT = Number(process.env.E2E_PORT ?? 4532);
|
||||||
|
const ORIGIN = `http://localhost:${PORT}`;
|
||||||
|
const SHOTS = process.env.E2E_SHOTS ?? join(import.meta.dir, '../test-results');
|
||||||
|
mkdirSync(SHOTS, { recursive: true });
|
||||||
|
|
||||||
|
const dbDir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-'));
|
||||||
|
const app = startServer({
|
||||||
|
config: loadConfig({ PORT: String(PORT), DB_PATH: join(dbDir, 'app.db'), PUBLIC_ORIGIN: ORIGIN }),
|
||||||
|
log: () => {},
|
||||||
|
staticDir: join(import.meta.dir, '../apps/web/dist'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ executablePath: process.env.CHROME ?? '/usr/bin/google-chrome', headless: true });
|
||||||
|
let failed = false;
|
||||||
|
const step = async (name: string, fn: () => Promise<void>) => {
|
||||||
|
const t0 = performance.now();
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
console.log(`✓ ${name} (${Math.round(performance.now() - t0)}ms)`);
|
||||||
|
} catch (e) {
|
||||||
|
failed = true;
|
||||||
|
console.log(`✗ ${name}: ${(e as Error).message.split('\n')[0]}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const expectEq = (got: unknown, want: unknown, what: string) => {
|
||||||
|
if (JSON.stringify(got) !== JSON.stringify(want)) throw new Error(`${what}: expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAD = 0.9;
|
||||||
|
async function tapCell(page: Page, x: number, y: number, touch: boolean, n = 15) {
|
||||||
|
const box = (await page.locator('svg[role="grid"]').boundingBox())!;
|
||||||
|
const span = n - 1 + 2 * PAD;
|
||||||
|
const px = box.x + ((x + PAD) / span) * box.width;
|
||||||
|
const py = box.y + ((y + PAD) / span) * box.height;
|
||||||
|
if (touch) {
|
||||||
|
await page.touchscreen.tap(px, py);
|
||||||
|
await page.waitForTimeout(60);
|
||||||
|
await page.touchscreen.tap(px, py);
|
||||||
|
} else {
|
||||||
|
await page.mouse.move(px, py);
|
||||||
|
await page.mouse.click(px, py);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stones = (p: Page) => p.locator('svg[role="grid"] circle[r="0.46"]').count();
|
||||||
|
async function waitStones(p: Page, n: number) {
|
||||||
|
await p.waitForFunction((n) => document.querySelectorAll('svg[role="grid"] circle[r="0.46"]').length === n, n, { timeout: 5000 }).catch(async () => {
|
||||||
|
throw new Error(`expected ${n} stones, got ${await stones(p)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const count = (p: Page) => p.locator('.replay-count').innerText();
|
||||||
|
/** Everything the board shows (text + accessible names); the side panel's 특수 카드 legend names cards too. */
|
||||||
|
const tableText = () => {
|
||||||
|
const main = document.querySelector('.game-main')!;
|
||||||
|
return main.textContent + [...main.querySelectorAll('[aria-label]')].map((e) => e.getAttribute('aria-label')).join('|');
|
||||||
|
};
|
||||||
|
/** One-card hand: card names of the face-up cards in "내 카드". */
|
||||||
|
const hand = (p: Page) =>
|
||||||
|
p.locator('button.oc-card').evaluateAll((els) => els.map((e) => (e.getAttribute('aria-label') ?? '').split(',')[0]!.trim()).sort());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ctxA = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' });
|
||||||
|
const ctxB = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, locale: 'ko-KR' });
|
||||||
|
const a = await ctxA.newPage();
|
||||||
|
const b = await ctxB.newPage();
|
||||||
|
const errors: string[] = [];
|
||||||
|
for (const p of [a, b]) p.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
let replayUrl = '';
|
||||||
|
|
||||||
|
await step('오목: 방 만들기 → 입장 → 흑 가로 5목', async () => {
|
||||||
|
await a.goto(`${ORIGIN}/g/omok`);
|
||||||
|
await a.getByRole('button', { name: /친구랑 하기/ }).click();
|
||||||
|
await a.getByLabel('내 이름(닉네임)').fill('호랑이');
|
||||||
|
await a.getByRole('button', { name: '이 이름으로 시작하기' }).click();
|
||||||
|
await a.waitForURL(/\/r\/\d{6}$/);
|
||||||
|
const code = a.url().slice(-6);
|
||||||
|
await b.goto(`${ORIGIN}/r/${code}`);
|
||||||
|
await b.getByLabel('내 이름(닉네임)').fill('고양이');
|
||||||
|
await b.getByRole('button', { name: '이 이름으로 입장하기' }).click();
|
||||||
|
await a.getByLabel('자리').getByText('고양이').waitFor();
|
||||||
|
await a.getByRole('button', { name: '자유룰' }).click();
|
||||||
|
await a.getByRole('button', { name: '방장이 흑' }).click();
|
||||||
|
await a.getByRole('button', { name: '설정 저장' }).click();
|
||||||
|
await a.getByText('자유룰 · 15줄').waitFor();
|
||||||
|
await a.getByRole('button', { name: '▶ 게임 시작' }).click();
|
||||||
|
await a.getByRole('button', { name: '시작하기' }).click();
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
await a.getByText('내 차례예요').waitFor();
|
||||||
|
await tapCell(a, 5 + i, 7, false);
|
||||||
|
await b.getByText('내 차례예요').waitFor();
|
||||||
|
await tapCell(b, 5 + i, 3, true);
|
||||||
|
}
|
||||||
|
await a.getByText('내 차례예요').waitFor();
|
||||||
|
await tapCell(a, 9, 7, false);
|
||||||
|
await a.getByText('🎉 이겼어요!').waitFor();
|
||||||
|
await b.getByText('아쉽게 졌어요').waitFor();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('A(PC): 결과 화면 → 처음부터 다시 보기 → 한 수씩 넘기기', async () => {
|
||||||
|
await a.waitForTimeout(400); // sheet animation
|
||||||
|
await a.getByRole('link', { name: /처음부터 다시 보기/ }).click();
|
||||||
|
await a.waitForURL(/\/replay\/[0-9A-Z]{26}$/);
|
||||||
|
replayUrl = a.url();
|
||||||
|
await a.getByText('게임을 시작했어요.').waitFor();
|
||||||
|
expectEq((await count(a)).replace(/\s+/g, ' '), '시작 / 전체 9번', 'count at start');
|
||||||
|
await waitStones(a, 0);
|
||||||
|
await a.getByRole('button', { name: '다음' }).click();
|
||||||
|
await waitStones(a, 1);
|
||||||
|
await a.getByText('내가 움직였어요.').waitFor();
|
||||||
|
await a.getByRole('button', { name: '다음' }).click();
|
||||||
|
await waitStones(a, 2);
|
||||||
|
await a.getByText('고양이님이 움직였어요.').waitFor();
|
||||||
|
await a.getByRole('button', { name: '끝으로' }).click();
|
||||||
|
await waitStones(a, 9);
|
||||||
|
await a.getByText(/^끝: /).waitFor();
|
||||||
|
if (!(await a.getByRole('button', { name: '다음' }).isDisabled())) throw new Error('다음 should be disabled at the end');
|
||||||
|
await a.getByRole('button', { name: '이전' }).click();
|
||||||
|
await waitStones(a, 8);
|
||||||
|
await a.screenshot({ path: join(SHOTS, 'replay-1-omok-pc.png') });
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('A(PC): 슬라이더 · 방향키 · 재생/멈춤 · 판은 눌러도 그대로', async () => {
|
||||||
|
await a.getByLabel('몇 번째 장면인지 고르기').fill('4');
|
||||||
|
await waitStones(a, 4);
|
||||||
|
expectEq((await count(a)).replace(/\s+/g, ' '), '4번째 / 전체 9번', 'count after slider');
|
||||||
|
await a.locator('body').click({ position: { x: 5, y: 300 } });
|
||||||
|
await a.keyboard.press('ArrowRight');
|
||||||
|
await waitStones(a, 5);
|
||||||
|
await a.keyboard.press('Home');
|
||||||
|
await waitStones(a, 0);
|
||||||
|
await a.getByRole('button', { name: '재생' }).click();
|
||||||
|
await waitStones(a, 2);
|
||||||
|
await a.getByRole('button', { name: '멈춤' }).click();
|
||||||
|
const paused = await stones(a);
|
||||||
|
await a.waitForTimeout(1500);
|
||||||
|
expectEq(await stones(a), paused, 'stones after pause');
|
||||||
|
// Clicking the board in a replay does nothing (read-only).
|
||||||
|
await tapCell(a, 0, 0, false);
|
||||||
|
await a.waitForTimeout(300);
|
||||||
|
expectEq(await stones(a), paused, 'stones after clicking the board');
|
||||||
|
await a.getByRole('button', { name: '← 뒤로' }).click();
|
||||||
|
await a.waitForURL(/\/r\/\d{6}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('B(폰): 내 정보 → 내 게임 기록 → 다시 보기', async () => {
|
||||||
|
await b.goto(`${ORIGIN}/me`);
|
||||||
|
const list = b.getByRole('region', { name: '내 게임 기록' });
|
||||||
|
await list.getByText('졌어요').waitFor();
|
||||||
|
await list.getByText('호랑이').waitFor();
|
||||||
|
await b.screenshot({ path: join(SHOTS, 'replay-2-mygames-phone.png'), fullPage: true });
|
||||||
|
await list.getByRole('link', { name: /다시 보기/ }).first().click();
|
||||||
|
await b.waitForURL(replayUrl);
|
||||||
|
await b.getByText('게임을 시작했어요.').waitFor();
|
||||||
|
await waitStones(b, 0);
|
||||||
|
await b.getByRole('button', { name: '다음' }).tap();
|
||||||
|
await waitStones(b, 1);
|
||||||
|
await b.getByText('호랑이님이 움직였어요.').waitFor();
|
||||||
|
await b.getByRole('button', { name: '다음' }).tap();
|
||||||
|
await waitStones(b, 2);
|
||||||
|
await b.getByText('내가 움직였어요.').waitFor();
|
||||||
|
await b.screenshot({ path: join(SHOTS, 'replay-3-omok-phone.png') });
|
||||||
|
await b.getByRole('button', { name: '끝으로' }).tap();
|
||||||
|
await waitStones(b, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('남의 게임 다시 보기는 막힘', async () => {
|
||||||
|
const c = await (await browser.newContext({ locale: 'ko-KR' })).newPage();
|
||||||
|
await c.goto(`${ORIGIN}/`);
|
||||||
|
await c.goto(`${ORIGIN}/me`);
|
||||||
|
await c.getByLabel('내 이름(닉네임)').fill('구경꾼');
|
||||||
|
await c.getByRole('button', { name: /시작하기/ }).click();
|
||||||
|
await c.getByText('아직 다시 볼 게임이 없어요.').waitFor();
|
||||||
|
await c.goto(replayUrl);
|
||||||
|
await c.getByText('이 게임에 참여한 사람만 다시 볼 수 있어요.').waitFor();
|
||||||
|
await c.context().close();
|
||||||
|
});
|
||||||
|
|
||||||
|
let liveA: string[] = [];
|
||||||
|
let liveB: string[] = [];
|
||||||
|
await step('원카드: 두 사람 시작 → 손패 기억 → 한 사람이 나가서 끝', async () => {
|
||||||
|
// Leave the omok room first so both are free.
|
||||||
|
await a.getByRole('button', { name: '방 나가기' }).click();
|
||||||
|
await a.waitForURL(`${ORIGIN}/`);
|
||||||
|
await b.goto(`${ORIGIN}/g/one-card`);
|
||||||
|
await b.getByRole('button', { name: /친구랑 하기/ }).click();
|
||||||
|
await b.waitForURL(/\/r\/\d{6}$/);
|
||||||
|
const code = b.url().slice(-6);
|
||||||
|
await a.goto(`${ORIGIN}/r/${code}`);
|
||||||
|
await b.getByLabel('자리').getByText('호랑이').waitFor();
|
||||||
|
await b.getByRole('button', { name: '▶ 게임 시작' }).click();
|
||||||
|
await b.getByRole('button', { name: '시작하기' }).click();
|
||||||
|
await a.locator('button.oc-card').first().waitFor();
|
||||||
|
await b.locator('button.oc-card').first().waitFor();
|
||||||
|
liveA = await hand(a);
|
||||||
|
liveB = await hand(b);
|
||||||
|
if (liveA.length < 5 || liveB.length < 5) throw new Error(`hands too small: ${liveA.length}, ${liveB.length}`);
|
||||||
|
if (liveA.some((c) => liveB.includes(c))) throw new Error('hands overlap');
|
||||||
|
// B leaves mid-game → A wins.
|
||||||
|
await b.getByRole('button', { name: '메뉴' }).tap();
|
||||||
|
await b.getByRole('button', { name: '🚪 방 나가기' }).tap();
|
||||||
|
await b.getByRole('button', { name: '나가기', exact: true }).tap();
|
||||||
|
await b.waitForURL(`${ORIGIN}/`);
|
||||||
|
await a.getByText('🎉 이겼어요!').waitFor();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('원카드 다시 보기: 각자 자기 카드만 보임', async () => {
|
||||||
|
await a.waitForTimeout(400);
|
||||||
|
await a.getByRole('link', { name: /처음부터 다시 보기/ }).click();
|
||||||
|
await a.waitForURL(/\/replay\//);
|
||||||
|
await a.getByText('게임을 시작했어요.').waitFor();
|
||||||
|
expectEq(await hand(a), liveA, "A's replay hand at the start");
|
||||||
|
const pageA = await a.evaluate(tableText);
|
||||||
|
for (const c of liveB) if (pageA.includes(c)) throw new Error(`A's replay shows B's card ${c}: …${pageA.slice(Math.max(0, pageA.indexOf(c) - 80), pageA.indexOf(c) + 40)}…`);
|
||||||
|
await a.getByRole('button', { name: '끝으로' }).click();
|
||||||
|
await a.getByText(/자동으로 넘겼어요/).waitFor();
|
||||||
|
await a.screenshot({ path: join(SHOTS, 'replay-4-onecard-pc.png') });
|
||||||
|
|
||||||
|
await b.goto(`${ORIGIN}/me`);
|
||||||
|
await b.getByRole('region', { name: '내 게임 기록' }).getByRole('link', { name: /원카드.*다시 보기/ }).click();
|
||||||
|
await b.getByText('게임을 시작했어요.').waitFor();
|
||||||
|
expectEq(await hand(b), liveB, "B's replay hand at the start");
|
||||||
|
const pageB = await b.evaluate(tableText);
|
||||||
|
for (const c of liveA) if (pageB.includes(c)) throw new Error(`B's replay shows A's card ${c}`);
|
||||||
|
await b.screenshot({ path: join(SHOTS, 'replay-5-onecard-phone.png') });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errors.length) throw new Error(`page errors: ${errors.join(' | ')}`);
|
||||||
|
} catch (e) {
|
||||||
|
failed = true;
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await app.stop();
|
||||||
|
}
|
||||||
|
console.log(failed ? 'E2E FAILED' : 'E2E PASSED');
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
@@ -3,3 +3,4 @@ export * from './types';
|
|||||||
export * from './nickname';
|
export * from './nickname';
|
||||||
export * from './room-code';
|
export * from './room-code';
|
||||||
export * from './catalog';
|
export * from './catalog';
|
||||||
|
export * from './view-patch';
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export interface RoomView {
|
|||||||
lockedOptions: string[];
|
lockedOptions: string[];
|
||||||
/** Seed revealed after the game for fairness check. */
|
/** Seed revealed after the game for fairness check. */
|
||||||
lastSeed: string | null;
|
lastSeed: string | null;
|
||||||
|
/** The game that just ended (for 다시 보기), null while playing or before the first game. */
|
||||||
|
lastGamePk: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameResultView {
|
export interface GameResultView {
|
||||||
@@ -85,3 +87,50 @@ export type ServerMessage =
|
|||||||
| { t: 'error'; code: string; message: string }
|
| { t: 'error'; code: string; message: string }
|
||||||
| { t: 'pong'; ts: number; serverTime: number }
|
| { t: 'pong'; ts: number; serverTime: number }
|
||||||
| { t: 'bye'; reason: 'restart' | 'replaced' | 'kicked' | 'closed' };
|
| { t: 'bye'; reason: 'restart' | 'replaced' | 'kicked' | 'closed' };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- replays (docs/09 §8)
|
||||||
|
|
||||||
|
/** Structural change between two consecutive replay views (see `diffView`). */
|
||||||
|
export type ViewPatch =
|
||||||
|
| { s: unknown }
|
||||||
|
| { o: Record<string, ViewPatch>; d?: string[] }
|
||||||
|
| { a: number; p: Record<string, ViewPatch> };
|
||||||
|
|
||||||
|
/** One step of a replay: the state after a non-streaming action (streamed strokes are folded into the next step). */
|
||||||
|
export interface ReplayFrame {
|
||||||
|
at: number;
|
||||||
|
actor: string | null;
|
||||||
|
/** Time-out / leave action applied by the server. */
|
||||||
|
auto?: boolean;
|
||||||
|
events: unknown[];
|
||||||
|
active: string[];
|
||||||
|
/** Full view in the first frame; later frames carry `patch` against the previous frame's view (absent = unchanged). */
|
||||||
|
view?: unknown;
|
||||||
|
patch?: ViewPatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayData {
|
||||||
|
gamePk: string;
|
||||||
|
gameId: string;
|
||||||
|
options: unknown;
|
||||||
|
startedAt: number;
|
||||||
|
endedAt: number;
|
||||||
|
/** The viewer's player id inside this game. */
|
||||||
|
me: string;
|
||||||
|
players: Record<string, PublicUser>;
|
||||||
|
result: GameResultView;
|
||||||
|
frames: ReplayFrame[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MyGameSummary {
|
||||||
|
gamePk: string;
|
||||||
|
gameId: string;
|
||||||
|
startedAt: number;
|
||||||
|
endedAt: number;
|
||||||
|
endReason: string;
|
||||||
|
myRank: number | null;
|
||||||
|
players: { nickname: string; rank: number | null; me: boolean }[];
|
||||||
|
summary: string;
|
||||||
|
/** False when the action log is gone (older than 90 days) or the game was not recorded. */
|
||||||
|
replayable: boolean;
|
||||||
|
}
|
||||||
|
|||||||
92
packages/shared/src/view-patch.test.ts
Normal file
92
packages/shared/src/view-patch.test.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { applyViewPatch, diffView } from './view-patch';
|
||||||
|
|
||||||
|
const json = (v: unknown) => JSON.parse(JSON.stringify(v));
|
||||||
|
|
||||||
|
/** Small deterministic PRNG so failures are reproducible. */
|
||||||
|
function rng(seed: number) {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
s = (s * 1664525 + 1013904223) >>> 0;
|
||||||
|
return s / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomValue(r: () => number, depth: number): unknown {
|
||||||
|
const k = r();
|
||||||
|
if (depth <= 0 || k < 0.35) {
|
||||||
|
const p = r();
|
||||||
|
if (p < 0.2) return null;
|
||||||
|
if (p < 0.4) return r() < 0.5;
|
||||||
|
if (p < 0.7) return Math.floor(r() * 10);
|
||||||
|
if (p < 0.8) return undefined;
|
||||||
|
return ['a', 'b', '가', ''][Math.floor(r() * 4)];
|
||||||
|
}
|
||||||
|
if (k < 0.65) return Array.from({ length: Math.floor(r() * 6) }, () => randomValue(r, depth - 1));
|
||||||
|
const o: Record<string, unknown> = {};
|
||||||
|
for (let i = 0; i < Math.floor(r() * 5); i++) o[['x', 'y', 'z', 's', 'o', 'a', 'p', 'd'][Math.floor(r() * 8)]!] = randomValue(r, depth - 1);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mutates a copy a little, like one game step does. */
|
||||||
|
function tweak(r: () => number, v: unknown, depth = 0): unknown {
|
||||||
|
if (r() < 0.15 || depth > 4) return randomValue(r, 2);
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
const out = v.map((x) => (r() < 0.3 ? tweak(r, x, depth + 1) : x));
|
||||||
|
if (r() < 0.3) out.push(randomValue(r, 2));
|
||||||
|
if (r() < 0.2) out.pop();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (v && typeof v === 'object') {
|
||||||
|
const out: Record<string, unknown> = { ...(v as Record<string, unknown>) };
|
||||||
|
for (const k of Object.keys(out)) if (r() < 0.3) out[k] = tweak(r, out[k], depth + 1);
|
||||||
|
if (r() < 0.2) delete out[Object.keys(out)[0]!];
|
||||||
|
if (r() < 0.2) out.n = randomValue(r, 1);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return r() < 0.5 ? v : randomValue(r, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('view patches', () => {
|
||||||
|
test('diff + apply reproduces the next view (as JSON) over random edits', () => {
|
||||||
|
const r = rng(7);
|
||||||
|
for (let i = 0; i < 3000; i++) {
|
||||||
|
const a = { root: randomValue(r, 4) };
|
||||||
|
const b = { root: tweak(r, a.root) };
|
||||||
|
const patch = diffView(a, b);
|
||||||
|
// The client applies the JSON-decoded patch to the JSON-decoded previous view.
|
||||||
|
const got = applyViewPatch(json(a), patch ? json(patch) : null);
|
||||||
|
expect(json(got)).toEqual(json(b));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('identical (JSON-wise) views give no patch; undefined keys count as absent', () => {
|
||||||
|
expect(diffView({ a: 1, b: [1, 2] }, { a: 1, b: [1, 2] })).toBeNull();
|
||||||
|
expect(diffView({ a: 1, b: undefined }, { a: 1 })).toBeNull();
|
||||||
|
expect(diffView({ a: 1 }, { a: 1, b: undefined })).toBeNull();
|
||||||
|
expect(diffView({ a: 1, b: 2 }, { a: 1 })).toEqual({ o: {}, d: ['b'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a growing array only carries the new items, unchanged parts keep their identity', () => {
|
||||||
|
const ops = Array.from({ length: 500 }, (_, i) => ({ k: 'p', d: [i, i] }));
|
||||||
|
const prev = { turn: 3, canvas: ops, chat: [{ t: 'hi' }] };
|
||||||
|
const next = { turn: 3, canvas: [...ops, { k: 'e' }], chat: prev.chat };
|
||||||
|
const patch = diffView(prev, next)!;
|
||||||
|
expect(JSON.stringify(patch).length).toBeLessThan(60);
|
||||||
|
const got = applyViewPatch(prev, patch) as typeof next;
|
||||||
|
expect(got).toEqual(next);
|
||||||
|
expect(got.chat).toBe(prev.chat);
|
||||||
|
expect(got.canvas[10]).toBe(prev.canvas[10]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a mostly different array is replaced, not patched element by element', () => {
|
||||||
|
expect(diffView([1, 2, 3, 4, 5, 6, 7, 8], [8, 7, 6, 5, 4, 3, 2, 1])).toEqual({ s: [8, 7, 6, 5, 4, 3, 2, 1] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a __proto__ key in a view is data, not a prototype', () => {
|
||||||
|
const next = JSON.parse('{"__proto__": {"polluted": 1}}');
|
||||||
|
const got = applyViewPatch({}, json(diffView({}, next))) as Record<string, unknown>;
|
||||||
|
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||||
|
expect(Object.keys(got)).toEqual(['__proto__']);
|
||||||
|
});
|
||||||
|
});
|
||||||
80
packages/shared/src/view-patch.ts
Normal file
80
packages/shared/src/view-patch.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Replay views as structural deltas (docs/09 §8): consecutive views mostly share their content (a growing
|
||||||
|
* drawing, a 19×19 board with one new stone), so each replay frame only carries what changed.
|
||||||
|
* JSON semantics: keys whose value is `undefined` count as absent, exactly as the client would see them.
|
||||||
|
*/
|
||||||
|
import type { ViewPatch } from './types';
|
||||||
|
|
||||||
|
type Obj = Record<string, unknown>;
|
||||||
|
|
||||||
|
const isObj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||||
|
const own = (o: Obj, k: string): unknown => (Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined);
|
||||||
|
|
||||||
|
/** Patch turning `prev` into `next`, or null when they serialize identically. */
|
||||||
|
export function diffView(prev: unknown, next: unknown): ViewPatch | null {
|
||||||
|
if (prev === next) return null;
|
||||||
|
if (Array.isArray(prev) && Array.isArray(next)) {
|
||||||
|
const p: Record<string, ViewPatch> = {};
|
||||||
|
let changed = 0;
|
||||||
|
const common = Math.min(prev.length, next.length);
|
||||||
|
for (let i = 0; i < common; i++) {
|
||||||
|
const c = diffView(prev[i], next[i]);
|
||||||
|
if (c) {
|
||||||
|
p[i] = c;
|
||||||
|
changed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mostly different (e.g. a reshuffled hand): sending the array is smaller than patching it.
|
||||||
|
if (changed > 4 && changed * 2 > common) return { s: next };
|
||||||
|
for (let i = common; i < next.length; i++) p[i] = { s: next[i] === undefined ? null : next[i] };
|
||||||
|
if (prev.length === next.length && changed === 0) return null;
|
||||||
|
return { a: next.length, p };
|
||||||
|
}
|
||||||
|
if (isObj(prev) && isObj(next)) {
|
||||||
|
const o: Record<string, ViewPatch> = {};
|
||||||
|
const d: string[] = [];
|
||||||
|
let changed = false;
|
||||||
|
for (const k of Object.keys(next)) {
|
||||||
|
const nv = next[k];
|
||||||
|
if (nv === undefined) continue;
|
||||||
|
const pv = own(prev, k);
|
||||||
|
const c = pv === undefined ? { s: nv } : diffView(pv, nv);
|
||||||
|
if (c) {
|
||||||
|
setKey(o, k, c);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const k of Object.keys(prev)) {
|
||||||
|
if (prev[k] !== undefined && own(next, k) === undefined) d.push(k);
|
||||||
|
}
|
||||||
|
if (!changed && d.length === 0) return null;
|
||||||
|
return d.length ? { o, d } : { o };
|
||||||
|
}
|
||||||
|
if (typeof prev === 'number' && typeof next === 'number' && Number.isNaN(prev) && Number.isNaN(next)) return null;
|
||||||
|
// Inside arrays an undefined item serializes as null.
|
||||||
|
return { s: next === undefined ? null : next };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setKey(out: Obj, k: string, v: unknown): void {
|
||||||
|
if (k === '__proto__') Object.defineProperty(out, k, { value: v, enumerable: true, writable: true, configurable: true });
|
||||||
|
else out[k] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies a patch from `diffView`; unchanged parts keep their identity (structural sharing). */
|
||||||
|
export function applyViewPatch(prev: unknown, patch: ViewPatch | null | undefined): unknown {
|
||||||
|
if (!patch) return prev;
|
||||||
|
if ('s' in patch) return patch.s;
|
||||||
|
if ('o' in patch) {
|
||||||
|
const base = isObj(prev) ? prev : {};
|
||||||
|
const out: Obj = {};
|
||||||
|
const drop = new Set(patch.d ?? []);
|
||||||
|
for (const k of Object.keys(base)) if (!drop.has(k)) setKey(out, k, base[k]);
|
||||||
|
for (const k of Object.keys(patch.o)) setKey(out, k, applyViewPatch(own(base, k), patch.o[k]));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const base = Array.isArray(prev) ? prev : [];
|
||||||
|
const out = base.slice(0, patch.a);
|
||||||
|
while (out.length < patch.a) out.push(null);
|
||||||
|
for (const k of Object.keys(patch.p)) out[Number(k)] = applyViewPatch(base[Number(k)], patch.p[k]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user