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.
This commit is contained in:
scamiv
2026-02-20 21:14:11 +01:00
parent abf9048d80
commit 35e6ee0d39
8 changed files with 301 additions and 40 deletions
+102 -35
View File
@@ -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<void> {
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;
+17 -1
View File
@@ -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);
+33 -1
View File
@@ -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
+12
View File
@@ -31,4 +31,16 @@ export class LruCache<K, V> {
values(): IterableIterator<V> {
return this.map.values();
}
keys(): IterableIterator<K> {
return this.map.keys();
}
delete(key: K): void {
this.map.delete(key);
}
clear(): void {
this.map.clear();
}
}
+28 -2
View File
@@ -7,6 +7,7 @@ export class TimelineArchive {
private readonly checkpointCache: LruCache<number, TimelineCheckpointRecord>;
private readonly idb: TimelineIdb;
private _storageError: string | null = null;
private readonly pendingWrites = new Set<Promise<void>>();
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<TimelineTickRecord | null> {
@@ -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<void> {
// 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)}`;
}
}
}
+63 -1
View File
@@ -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<void> {
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<void> {
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;
+2
View File
@@ -28,3 +28,5 @@ export class TimelineRangeEvent implements GameEvent {
}
export class TimelineRangeRequestEvent implements GameEvent {}
export class TimelineRewriteHistoryEvent implements GameEvent {}
+44
View File
@@ -124,4 +124,48 @@ export class TimelineIdb {
},
);
}
async deleteTickRecordsAfter(tick: number): Promise<void> {
if (!this.isAvailable) return;
const db = this.requireDb();
const range = IDBKeyRange.lowerBound(tick + 1);
await new Promise<void>((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<void> {
if (!this.isAvailable) return;
const db = this.requireDb();
const range = IDBKeyRange.lowerBound(tick + 1);
await new Promise<void>((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"));
});
}
}