From 35e6ee0d3934517a5392c4e33cf4cd3a2913f59f Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 20 Feb 2026 21:14:11 +0100 Subject: [PATCH] feat(timeline): implement history rewriting functionality - Added support for rewriting game history through new TimelineRewriteHistoryEvent. - Enhanced ClientGameRunner to handle timeline jump and history rewrite events. - Introduced methods in LocalServer and Transport to manage rewrite state and truncate turns. - Updated TimelineController to facilitate history truncation and checkpoint management. - Improved TimelineArchive for efficient deletion of tick records and checkpoints after a specified tick. This commit enhances the timeline feature, allowing players to discard future events and rewrite history, improving gameplay flexibility. --- src/client/ClientGameRunner.ts | 137 ++++++++++++++++------ src/client/LocalServer.ts | 18 ++- src/client/Transport.ts | 34 +++++- src/client/timeline/LruCache.ts | 12 ++ src/client/timeline/TimelineArchive.ts | 30 ++++- src/client/timeline/TimelineController.ts | 64 +++++++++- src/client/timeline/TimelineEvents.ts | 2 + src/client/timeline/TimelineIdb.ts | 44 +++++++ 8 files changed, 301 insertions(+), 40 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index aa001ae1b..7692f8224 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -52,6 +52,10 @@ import { createRenderer, GameRenderer } from "./graphics/GameRenderer"; import { GoToPlayerEvent } from "./graphics/layers/Leaderboard"; import SoundManager from "./sound/SoundManager"; import { TimelineController } from "./timeline/TimelineController"; +import { + TimelineJumpEvent, + TimelineRewriteHistoryEvent, +} from "./timeline/TimelineEvents"; export interface LobbyConfig { serverConfig: ServerConfig; @@ -263,6 +267,8 @@ async function createClientGame( export class ClientGameRunner { private myPlayer: PlayerView | null = null; private isActive = false; + private rewriteInProgress = false; + private suppressWorkerUpdates = false; private turnsSeen = 0; private lastMousePosition: { x: number; y: number } | null = null; @@ -286,6 +292,15 @@ export class ClientGameRunner { private timeline: TimelineController, ) { this.lastMessageTime = Date.now(); + + this.eventBus.on(TimelineRewriteHistoryEvent, () => { + void this.rewriteHistoryFromDisplayTick(); + }); + + this.eventBus.on(TimelineJumpEvent, () => { + // Time travel recreates PlayerView instances; drop cached references. + this.myPlayer = null; + }); } /** @@ -360,44 +375,17 @@ export class ClientGameRunner { this.renderer.initialize(); this.input.initialize(); - this.worker.start((gu: GameUpdateViewData | ErrorUpdate) => { - if (this.lobby.gameStartInfo === undefined) { - throw new Error("missing gameStartInfo"); - } - if ("errMsg" in gu) { - showErrorModal( - gu.errMsg, - gu.stack ?? "missing", - this.lobby.gameStartInfo.gameID, - this.clientID, - ); - console.error(gu.stack); - this.stop(); - return; - } - this.transport.turnComplete(); - gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => { - this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash)); - }); - this.timeline.onWorkerUpdate(gu); + this.worker.start((gu: GameUpdateViewData | ErrorUpdate) => + this.handleWorkerUpdate(gu), + ); - // Emit tick metrics event for performance overlay - this.eventBus.emit( - new TickMetricsEvent(gu.tickExecutionDuration, this.currentTickDelay), - ); - - // Reset tick delay for next measurement - this.currentTickDelay = undefined; - - if (gu.updates[GameUpdateType.Win].length > 0) { - this.saveGame(gu.updates[GameUpdateType.Win][0]); - } - }); - - const worker = this.worker; const keepWorkerAlive = () => { if (this.isActive) { - worker.sendHeartbeat(); + try { + this.worker.sendHeartbeat(); + } catch { + // ignore (worker may be restarting) + } requestAnimationFrame(keepWorkerAlive); } }; @@ -519,6 +507,85 @@ export class ClientGameRunner { this.transport.rejoinGame(0); } + private handleWorkerUpdate(gu: GameUpdateViewData | ErrorUpdate): void { + if (this.suppressWorkerUpdates) { + return; + } + if (this.lobby.gameStartInfo === undefined) { + throw new Error("missing gameStartInfo"); + } + if ("errMsg" in gu) { + showErrorModal( + gu.errMsg, + gu.stack ?? "missing", + this.lobby.gameStartInfo.gameID, + this.clientID, + ); + console.error(gu.stack); + this.stop(); + return; + } + this.transport.turnComplete(); + gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => { + this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash)); + }); + this.timeline.onWorkerUpdate(gu); + + // Emit tick metrics event for performance overlay + this.eventBus.emit( + new TickMetricsEvent(gu.tickExecutionDuration, this.currentTickDelay), + ); + + // Reset tick delay for next measurement + this.currentTickDelay = undefined; + + if (gu.updates[GameUpdateType.Win].length > 0) { + this.saveGame(gu.updates[GameUpdateType.Win][0]); + } + } + + private async rewriteHistoryFromDisplayTick(): Promise { + if (!this.isActive) return; + if (this.rewriteInProgress) return; + if (!this.transport.isLocal) return; + if (this.lobby.gameRecord !== undefined) return; + if (this.lobby.gameStartInfo === undefined) return; + + this.rewriteInProgress = true; + this.suppressWorkerUpdates = true; + + const targetTick = this.timeline.getDisplayTick(); + + const oldWorker = this.worker; + try { + this.transport.setRewriteFrozen(true); + await this.timeline.beginRewriteAtTick(targetTick); + this.transport.truncateLocalTurns(targetTick); + + const worker = new WorkerClient(this.lobby.gameStartInfo, this.clientID); + await worker.initialize(); + worker.start((gu: GameUpdateViewData | ErrorUpdate) => + this.handleWorkerUpdate(gu), + ); + + this.worker = worker; + this.gameView.worker = worker; + this.timeline.replaceWorker(worker); + oldWorker.cleanup(); + + this.turnsSeen = 0; + this.suppressWorkerUpdates = false; + // Trigger a rejoin so the local server re-sends the truncated turn history. + this.transport.rejoinGame(0); + } catch (e) { + console.error("Failed to rewrite history:", e); + } finally { + this.transport.setRewriteFrozen(false); + this.suppressWorkerUpdates = false; + this.rewriteInProgress = false; + } + } + public stop() { SoundManager.stopBackgroundMusic(); if (!this.isActive) return; diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 90712b868..a10a9b218 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -39,6 +39,7 @@ export class LocalServer { private startedAt: number; private paused = false; + private rewriteFrozen = false; private replaySpeedMultiplier = defaultReplaySpeedMultiplier; private clientID: ClientID | undefined; @@ -208,7 +209,7 @@ export class LocalServer { // endTurn in this context means the server has collected all the intents // and will send the turn to the client. private endTurn() { - if (this.paused) { + if (this.paused || this.rewriteFrozen) { return; } if (this.replayTurns.length > 0) { @@ -230,6 +231,21 @@ export class LocalServer { }); } + setRewriteFrozen(frozen: boolean): void { + this.rewriteFrozen = frozen; + } + + truncateToTurnCount(turnCount: number): void { + const clamped = Math.max(0, Math.min(turnCount, this.turns.length)); + this.turns = this.turns.slice(0, clamped); + this.intents = []; + + // After rewriting history, the client will re-send turns to a fresh worker, + // so turnsExecuted must start from 0 to keep the backlog logic correct. + this.turnsExecuted = 0; + this.turnStartTime = Date.now(); + } + public endGame() { console.log("local server ending game"); clearInterval(this.turnCheckInterval); diff --git a/src/client/Transport.ts b/src/client/Transport.ts index bc1dddfda..025a1e8f9 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -29,7 +29,10 @@ import { replacer } from "../core/Util"; import { getPlayToken } from "./Auth"; import { LobbyConfig } from "./ClientGameRunner"; import { LocalServer } from "./LocalServer"; -import { TimelineModeChangedEvent } from "./timeline/TimelineEvents"; +import { + TimelineModeChangedEvent, + TimelineRewriteHistoryEvent, +} from "./timeline/TimelineEvents"; export class PauseGameIntentEvent implements GameEvent { constructor(public readonly paused: boolean) {} @@ -187,6 +190,7 @@ export class Transport { private pingInterval: number | null = null; public readonly isLocal: boolean; private timelineIsLive = true; + private pendingRewriteIntent: Intent | null = null; constructor( private lobbyConfig: LobbyConfig, @@ -267,6 +271,11 @@ export class Transport { this.eventBus.on(TimelineModeChangedEvent, (e) => { this.timelineIsLive = e.isLive; + if (this.timelineIsLive && this.pendingRewriteIntent) { + const intent = this.pendingRewriteIntent; + this.pendingRewriteIntent = null; + this.sendIntent(intent); + } }); } @@ -651,6 +660,19 @@ export class Transport { private sendIntent(intent: Intent) { if (!this.timelineIsLive && intent.type !== "toggle_pause") { + const canRewriteHistory = + this.isLocal && + this.lobbyConfig.gameRecord === undefined && + this.lobbyConfig.gameStartInfo?.config.gameType === + GameType.Singleplayer; + + if (canRewriteHistory) { + const ok = window.confirm("Discard the future and rewrite history?"); + if (ok) { + this.pendingRewriteIntent = intent; + this.eventBus.emit(new TimelineRewriteHistoryEvent()); + } + } return; } if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) { @@ -668,6 +690,16 @@ export class Transport { } } + setRewriteFrozen(frozen: boolean): void { + if (!this.isLocal) return; + this.localServer?.setRewriteFrozen(frozen); + } + + truncateLocalTurns(turnCount: number): void { + if (!this.isLocal) return; + this.localServer?.truncateToTurnCount(turnCount); + } + private sendMsg(msg: ClientMessage) { if (this.isLocal) { // Forward message to local server diff --git a/src/client/timeline/LruCache.ts b/src/client/timeline/LruCache.ts index 0e5fe29dc..bbf9cc35e 100644 --- a/src/client/timeline/LruCache.ts +++ b/src/client/timeline/LruCache.ts @@ -31,4 +31,16 @@ export class LruCache { values(): IterableIterator { return this.map.values(); } + + keys(): IterableIterator { + return this.map.keys(); + } + + delete(key: K): void { + this.map.delete(key); + } + + clear(): void { + this.map.clear(); + } } diff --git a/src/client/timeline/TimelineArchive.ts b/src/client/timeline/TimelineArchive.ts index 416755554..a0415dda4 100644 --- a/src/client/timeline/TimelineArchive.ts +++ b/src/client/timeline/TimelineArchive.ts @@ -7,6 +7,7 @@ export class TimelineArchive { private readonly checkpointCache: LruCache; private readonly idb: TimelineIdb; private _storageError: string | null = null; + private readonly pendingWrites = new Set>(); constructor( opts: { @@ -35,9 +36,11 @@ export class TimelineArchive { putTickRecord(record: TimelineTickRecord): void { this.tickCache.set(record.tick, record); if (!this.idb.isAvailable) return; - void this.idb.putTickRecord(record).catch((e) => { + const p = this.idb.putTickRecord(record).catch((e) => { this._storageError = `IndexedDB write failed: ${String(e)}`; }); + this.pendingWrites.add(p); + void p.finally(() => this.pendingWrites.delete(p)); } async getTickRecord(tick: number): Promise { @@ -87,9 +90,11 @@ export class TimelineArchive { putCheckpoint(record: TimelineCheckpointRecord): void { this.checkpointCache.set(record.tick, record); if (!this.idb.isAvailable) return; - void this.idb.putCheckpoint(record).catch((e) => { + const p = this.idb.putCheckpoint(record).catch((e) => { this._storageError = `IndexedDB checkpoint write failed: ${String(e)}`; }); + this.pendingWrites.add(p); + void p.finally(() => this.pendingWrites.delete(p)); } async getCheckpointAtOrBefore( @@ -113,4 +118,25 @@ export class TimelineArchive { return null; } } + + async truncateAfterTick(tick: number): Promise { + // Ensure all outstanding IDB puts have completed so they don't re-introduce + // deleted records after truncation. + await Promise.allSettled(Array.from(this.pendingWrites.values())); + + for (const k of Array.from(this.tickCache.keys())) { + if (k > tick) this.tickCache.delete(k); + } + for (const k of Array.from(this.checkpointCache.keys())) { + if (k > tick) this.checkpointCache.delete(k); + } + + if (!this.idb.isAvailable) return; + try { + await this.idb.deleteTickRecordsAfter(tick); + await this.idb.deleteCheckpointsAfter(tick); + } catch (e) { + this._storageError = `IndexedDB truncate failed: ${String(e)}`; + } + } } diff --git a/src/client/timeline/TimelineController.ts b/src/client/timeline/TimelineController.ts index 43db99972..a5473497f 100644 --- a/src/client/timeline/TimelineController.ts +++ b/src/client/timeline/TimelineController.ts @@ -30,6 +30,7 @@ export class TimelineController { private isPaused = false; private replaySpeedMultiplier = defaultReplaySpeedMultiplier; private playbackTimer: number | null = null; + private syncingUntilTick: number | null = null; private pendingSeekTick: number | null = null; private seekScheduled = false; @@ -39,7 +40,7 @@ export class TimelineController { private rewindCheckpointSnapshotQueued = false; constructor( - private readonly worker: WorkerClient, + private worker: WorkerClient, private readonly gameView: GameView, private readonly renderer: GameRenderer, private readonly eventBus: EventBus, @@ -53,6 +54,56 @@ export class TimelineController { }); } + getDisplayTick(): number { + return this.displayTick; + } + + replaceWorker(worker: WorkerClient): void { + this.worker = worker; + } + + async beginRewriteAtTick(targetTick: number): Promise { + const clamped = Math.max(0, Math.min(targetTick, this.liveTick)); + + // Cancel any in-flight seeks / replays. + this.seekToken++; + this.pendingSeekTick = null; + this.syncingUntilTick = clamped; + + this.clearPlaybackTimer(); + this.setLive(false); + this.isSeeking = true; + + // This becomes the new "present" for this timeline branch. + this.liveTick = clamped; + this.displayTick = clamped; + this.emitRange(); + + // Persist a checkpoint at the rewrite point, then drop everything after it. + try { + const cp = this.gameView.exportCheckpoint(); + this.archive.putCheckpoint({ + tick: cp.tick, + mapStateBuffer: cp.mapState.buffer, + numTilesWithFallout: cp.numTilesWithFallout, + players: cp.players, + units: cp.units, + playerNameViewData: cp.playerNameViewData, + toDeleteUnitIds: cp.toDeleteUnitIds, + railroads: cp.railroads, + }); + } catch { + // ignore + } + + await this.archive.truncateAfterTick(clamped); + + // Mop up any stale writes that were in-flight right before truncation. + window.setTimeout(() => void this.archive.truncateAfterTick(clamped), 1000); + + this.emitRange(); + } + async initialize(): Promise { await this.archive.open(); @@ -107,6 +158,16 @@ export class TimelineController { }; this.archive.putTickRecord(tickRecord); + if (this.syncingUntilTick !== null) { + // During history rewrite we keep recording ticks, but avoid mutating the + // displayed GameView until the new worker has reached the rewrite point. + if (gu.tick >= this.syncingUntilTick) { + this.syncingUntilTick = null; + void this.goLive(); + } + return; + } + if (this.isLive) { const before = this.displayTick; this.displayTick = gu.tick; @@ -329,6 +390,7 @@ export class TimelineController { return; } if (this.isSeeking) return; + if (this.syncingUntilTick !== null) return; if (this.pendingSeekTick !== null) return; if (this.isPaused) return; if (this.playbackTimer !== null) return; diff --git a/src/client/timeline/TimelineEvents.ts b/src/client/timeline/TimelineEvents.ts index 186985ab1..4243f2ef8 100644 --- a/src/client/timeline/TimelineEvents.ts +++ b/src/client/timeline/TimelineEvents.ts @@ -28,3 +28,5 @@ export class TimelineRangeEvent implements GameEvent { } export class TimelineRangeRequestEvent implements GameEvent {} + +export class TimelineRewriteHistoryEvent implements GameEvent {} diff --git a/src/client/timeline/TimelineIdb.ts b/src/client/timeline/TimelineIdb.ts index 975aedce2..9b609bb7f 100644 --- a/src/client/timeline/TimelineIdb.ts +++ b/src/client/timeline/TimelineIdb.ts @@ -124,4 +124,48 @@ export class TimelineIdb { }, ); } + + async deleteTickRecordsAfter(tick: number): Promise { + if (!this.isAvailable) return; + const db = this.requireDb(); + const range = IDBKeyRange.lowerBound(tick + 1); + await new Promise((resolve, reject) => { + const tx = db.transaction(TICK_STORE, "readwrite"); + tx.oncomplete = () => resolve(); + tx.onerror = () => + reject(tx.error ?? new Error("tickRecords delete tx failed")); + const store = tx.objectStore(TICK_STORE); + const req = store.openCursor(range, "next"); + req.onsuccess = () => { + const cursor = req.result; + if (!cursor) return; + cursor.delete(); + cursor.continue(); + }; + req.onerror = () => + reject(req.error ?? new Error("tickRecords delete cursor failed")); + }); + } + + async deleteCheckpointsAfter(tick: number): Promise { + if (!this.isAvailable) return; + const db = this.requireDb(); + const range = IDBKeyRange.lowerBound(tick + 1); + await new Promise((resolve, reject) => { + const tx = db.transaction(CHECKPOINT_STORE, "readwrite"); + tx.oncomplete = () => resolve(); + tx.onerror = () => + reject(tx.error ?? new Error("checkpoints delete tx failed")); + const store = tx.objectStore(CHECKPOINT_STORE); + const req = store.openCursor(range, "next"); + req.onsuccess = () => { + const cursor = req.result; + if (!cursor) return; + cursor.delete(); + cursor.continue(); + }; + req.onerror = () => + reject(req.error ?? new Error("checkpoints delete cursor failed")); + }); + } }