mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
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:
+102
-35
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -28,3 +28,5 @@ export class TimelineRangeEvent implements GameEvent {
|
||||
}
|
||||
|
||||
export class TimelineRangeRequestEvent implements GameEvent {}
|
||||
|
||||
export class TimelineRewriteHistoryEvent implements GameEvent {}
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user