M9: 게임 기록 다시 보기
- 서버: 끝난 게임을 공개된 시드 + room_log 행동으로 다시 돌려 플레이어별 view 장면을 만든다 (rooms/replay.ts, 장면은 view 차이만 전송: shared/view-patch.ts). 행동 로그의 at = 적용한 정확한 시각, start 로그에 stateVersion. GET /api/me/games, GET /api/replays/:gamePk(그 판 플레이어만, no-store), RoomView.lastGamePk. 로그가 지워졌거나 규칙 버전이 바뀌었거나 결과가 다르면 한국어 안내(410). - 웹: 다시 보기 화면(/replay/:gamePk, 게임 Board/Side 읽기 전용, 처음/이전/재생·멈춤/다음/끝, 슬라이더), 내 정보의 내 게임 기록 목록, 결과 화면의 [처음부터 다시 보기]. - 테스트: 모든 게임을 Room으로 끝까지 둔 뒤 다시 만든 상태·결과·장면이 같은지, 권한, view-patch, e2e/replay.e2e.ts. - 문서: docs/09 §8, docs/07 §10. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -220,7 +220,8 @@ export class Room {
|
||||
return room;
|
||||
}
|
||||
|
||||
private persist(log: { actorId: string | null; kind: string; data: unknown } | null): void {
|
||||
/** `log.at` defaults to the current time; game actions pass the exact `now` they were applied with (replays, docs/09 §8). */
|
||||
private persist(log: { actorId: string | null; kind: string; data: unknown; at?: number } | null): void {
|
||||
const nextSeq = this.seq + 1;
|
||||
const entry = log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null;
|
||||
const logs = this.pendingLogs.length ? [...this.pendingLogs, ...(entry ? [entry] : [])] : entry;
|
||||
@@ -242,9 +243,9 @@ export class Room {
|
||||
* Streaming actions (e.g. 그림 맞히기 strokes, ~20/s) get a seq and a log entry right away, but the snapshot
|
||||
* is written at most every STREAM_FLUSH_MS (or with the next regular persist) instead of once per message.
|
||||
*/
|
||||
private persistStream(log: { actorId: string | null; kind: string; data: unknown }): void {
|
||||
private persistStream(log: { actorId: string | null; kind: string; data: unknown; at: number }): void {
|
||||
this.seq += 1;
|
||||
this.pendingLogs.push({ seq: this.seq, gameNo: this.gameNo, at: this.now(), ...log });
|
||||
this.pendingLogs.push({ seq: this.seq, gameNo: this.gameNo, ...log });
|
||||
this.lastActiveAt = this.now();
|
||||
this.flushTimer ??= setTimeout(() => this.flushStream(), STREAM_FLUSH_MS);
|
||||
}
|
||||
@@ -288,6 +289,7 @@ export class Room {
|
||||
seedHash: this.game?.seedHash ?? null,
|
||||
lockedOptions: this.locked(this.config.gameId).paths,
|
||||
lastSeed: this.lastSeed,
|
||||
lastGamePk: this.game?.finished ? this.game.gamePk : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -673,7 +675,7 @@ export class Room {
|
||||
this.lastResult = null;
|
||||
this.consecutiveErrors = 0;
|
||||
for (const s of this.seats) if (s) s.ready = false;
|
||||
this.persist({ actorId: this.hostId, kind: 'start', data: { gameId: def.id, players, seedHash } });
|
||||
this.persist({ actorId: this.hostId, kind: 'start', data: { gameId: def.id, players, seedHash, stateVersion: def.stateVersion }, at: now });
|
||||
this.broadcastRoom();
|
||||
this.broadcastState([{ type: 'gameStarted' }]);
|
||||
this.scheduleTimer();
|
||||
@@ -727,11 +729,11 @@ export class Room {
|
||||
g.deadlineFloor = null;
|
||||
if (!system && def.isStreamAction?.(action)) {
|
||||
// Relay the (public) events only; views are not recomputed per stroke batch (docs/games/catchmind.md §5.5).
|
||||
this.persistStream({ actorId: userId, kind: 'act', data: action });
|
||||
this.persistStream({ actorId: userId, kind: 'act', data: action, at: now });
|
||||
const msg: ServerMessage = { t: 'stream', seq: this.seq, events: next.events };
|
||||
for (const c of this.conns.values()) c.send(c.userId === userId && cs !== undefined ? { ...msg, cs } : msg);
|
||||
} else {
|
||||
this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action });
|
||||
this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action, at: now });
|
||||
this.broadcastState(next.events, userId, cs);
|
||||
}
|
||||
const result = def.result(g.state);
|
||||
|
||||
Reference in New Issue
Block a user