diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index fb01edb34..aa001ae1b 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -51,6 +51,7 @@ import { createCanvas } from "./Utils"; import { createRenderer, GameRenderer } from "./graphics/GameRenderer"; import { GoToPlayerEvent } from "./graphics/layers/Leaderboard"; import SoundManager from "./sound/SoundManager"; +import { TimelineController } from "./timeline/TimelineController"; export interface LobbyConfig { serverConfig: ServerConfig; @@ -234,6 +235,13 @@ async function createClientGame( const canvas = createCanvas(); const gameRenderer = createRenderer(canvas, gameView, eventBus); + const timelineController = new TimelineController( + worker, + gameView, + gameRenderer, + eventBus, + ); + await timelineController.initialize(); console.log( `creating private game got difficulty: ${lobbyConfig.gameStartInfo.config.difficulty}`, @@ -248,6 +256,7 @@ async function createClientGame( transport, worker, gameView, + timelineController, ); } @@ -274,6 +283,7 @@ export class ClientGameRunner { private transport: Transport, private worker: WorkerClient, private gameView: GameView, + private timeline: TimelineController, ) { this.lastMessageTime = Date.now(); } @@ -369,8 +379,7 @@ export class ClientGameRunner { gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => { this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash)); }); - this.gameView.update(gu); - this.renderer.tick(); + this.timeline.onWorkerUpdate(gu); // Emit tick metrics event for performance overlay this.eventBus.emit( diff --git a/src/client/Transport.ts b/src/client/Transport.ts index d86f0fe82..bc1dddfda 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -29,6 +29,7 @@ import { replacer } from "../core/Util"; import { getPlayToken } from "./Auth"; import { LobbyConfig } from "./ClientGameRunner"; import { LocalServer } from "./LocalServer"; +import { TimelineModeChangedEvent } from "./timeline/TimelineEvents"; export class PauseGameIntentEvent implements GameEvent { constructor(public readonly paused: boolean) {} @@ -185,6 +186,7 @@ export class Transport { private pingInterval: number | null = null; public readonly isLocal: boolean; + private timelineIsLive = true; constructor( private lobbyConfig: LobbyConfig, @@ -262,6 +264,10 @@ export class Transport { this.eventBus.on(SendUpdateGameConfigIntentEvent, (e) => this.onSendUpdateGameConfigIntent(e), ); + + this.eventBus.on(TimelineModeChangedEvent, (e) => { + this.timelineIsLive = e.isLive; + }); } private startPing() { @@ -644,6 +650,9 @@ export class Transport { } private sendIntent(intent: Intent) { + if (!this.timelineIsLive && intent.type !== "toggle_pause") { + return; + } if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) { const msg = { type: "intent", diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index d1e191162..e545518d7 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -41,6 +41,7 @@ import { StructureLayer } from "./layers/StructureLayer"; import { TeamStats } from "./layers/TeamStats"; import { TerrainLayer } from "./layers/TerrainLayer"; import { TerritoryLayer } from "./layers/TerritoryLayer"; +import { TimelinePanel } from "./layers/TimelinePanel"; import { UILayer } from "./layers/UILayer"; import { UnitDisplay } from "./layers/UnitDisplay"; import { UnitLayer } from "./layers/UnitLayer"; @@ -168,6 +169,15 @@ export function createRenderer( replayPanel.eventBus = eventBus; replayPanel.game = game; + const timelinePanel = document.querySelector( + "timeline-panel", + ) as TimelinePanel; + if (!(timelinePanel instanceof TimelinePanel)) { + console.error("timeline panel not found"); + } + timelinePanel.eventBus = eventBus; + timelinePanel.game = game; + const gameRightSidebar = document.querySelector( "game-right-sidebar", ) as GameRightSidebar; @@ -313,6 +323,7 @@ export function createRenderer( controlPanel, playerInfo, winModal, + timelinePanel, replayPanel, settingsModal, teamStats, diff --git a/src/client/graphics/layers/AlertFrame.ts b/src/client/graphics/layers/AlertFrame.ts index 407cd81ff..2fae19426 100644 --- a/src/client/graphics/layers/AlertFrame.ts +++ b/src/client/graphics/layers/AlertFrame.ts @@ -27,6 +27,7 @@ export class AlertFrame extends LitElement implements Layer { private animationTimeout: number | null = null; private seenAttackIds: Set = new Set(); private lastAlertTick: number = -1; + private lastTickSeen: number = -1; // Map of player ID -> tick when we last attacked them private outgoingAttackTicks: Map = new Map(); @@ -92,6 +93,16 @@ export class AlertFrame extends LitElement implements Layer { return; // Game not initialized yet } + const currentTick = this.game.ticks(); + if (this.lastTickSeen >= 0 && currentTick < this.lastTickSeen) { + // Timeline scrubbed backwards; clear state that assumes monotonic time. + this.seenAttackIds.clear(); + this.outgoingAttackTicks.clear(); + this.lastAlertTick = -1; + this.isActive = false; + } + this.lastTickSeen = currentTick; + const myPlayer = this.game.myPlayer(); // Clear tracked attacks if player dies or doesn't exist diff --git a/src/client/graphics/layers/AttacksDisplay.ts b/src/client/graphics/layers/AttacksDisplay.ts index f77411e55..2ffd74d4d 100644 --- a/src/client/graphics/layers/AttacksDisplay.ts +++ b/src/client/graphics/layers/AttacksDisplay.ts @@ -8,6 +8,10 @@ import { UnitIncomingUpdate, } from "../../../core/game/GameUpdates"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; +import { + TimelineJumpEvent, + TimelineModeChangedEvent, +} from "../../timeline/TimelineEvents"; import { CancelAttackIntentEvent, CancelBoatIntentEvent, @@ -44,7 +48,14 @@ export class AttacksDisplay extends LitElement implements Layer { return this; } - init() {} + init() { + this.eventBus.on(TimelineJumpEvent, () => this.resetForTimeline()); + this.eventBus.on(TimelineModeChangedEvent, (e) => { + if (!e.isLive) { + this.resetForTimeline(); + } + }); + } tick() { this.active = true; @@ -114,6 +125,16 @@ export class AttacksDisplay extends LitElement implements Layer { renderLayer(): void {} + private resetForTimeline() { + this.incomingBoatIDs.clear(); + this.incomingAttacks = []; + this.outgoingAttacks = []; + this.outgoingLandAttacks = []; + this.outgoingBoats = []; + this.incomingBoats = []; + this.requestUpdate(); + } + private renderButton(options: { content: any; onClick?: () => void; diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index a84e76205..074b420c3 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -34,6 +34,10 @@ import { onlyImages } from "../../../core/Util"; import { renderNumber } from "../../Utils"; import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard"; +import { + TimelineJumpEvent, + TimelineModeChangedEvent, +} from "../../timeline/TimelineEvents"; import { getMessageTypeClasses, translateText } from "../../Utils"; import { UIState } from "../UIState"; import allianceIcon from "/images/AllianceIconWhite.svg?url"; @@ -183,7 +187,27 @@ export class EventsDisplay extends LitElement implements Layer { this.events = []; } - init() {} + init() { + this.eventBus.on(TimelineJumpEvent, () => this.resetForTimeline()); + this.eventBus.on(TimelineModeChangedEvent, (e) => { + if (!e.isLive) { + this.resetForTimeline(); + } + }); + } + + private resetForTimeline() { + this.events = []; + this.alliancesCheckedAt.clear(); + this.newEvents = 0; + this.latestGoldAmount = null; + this.goldAmountAnimating = false; + if (this.goldAmountTimeoutId) { + clearTimeout(this.goldAmountTimeoutId); + this.goldAmountTimeoutId = null; + } + this.requestUpdate(); + } tick() { this.active = true; diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index d6281be8d..58ec2438a 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -5,6 +5,10 @@ import { TileRef } from "../../../core/game/GameMap"; import { ConquestUpdate, GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView, UnitView } from "../../../core/game/GameView"; import SoundManager, { SoundEffect } from "../../sound/SoundManager"; +import { + TimelineJumpEvent, + TimelineModeChangedEvent, +} from "../../timeline/TimelineEvents"; import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; import { conquestFxFactory } from "../fx/ConquestFx"; import { Fx, FxType } from "../fx/Fx"; @@ -235,6 +239,13 @@ export class FxLayer implements Layer { this.eventBus.on(RailTileChangedEvent, (e) => { this.onRailroadEvent(e.tile); }); + + this.eventBus.on(TimelineJumpEvent, () => this.resetForTimeline()); + this.eventBus.on(TimelineModeChangedEvent, (e) => { + if (!e.isLive) { + this.resetForTimeline(); + } + }); try { this.animatedSpriteLoader.loadAllAnimatedSpriteImages(); console.log("FX sprites loaded successfully"); @@ -253,6 +264,14 @@ export class FxLayer implements Layer { this.canvas.height = this.game.height(); } + private resetForTimeline() { + this.allFx = []; + this.hasBufferedFrame = false; + if (this.context) { + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); + } + } + renderLayer(context: CanvasRenderingContext2D) { const nowMs = performance.now(); diff --git a/src/client/graphics/layers/HeadsUpMessage.ts b/src/client/graphics/layers/HeadsUpMessage.ts index 832e5871a..aa1d1ca52 100644 --- a/src/client/graphics/layers/HeadsUpMessage.ts +++ b/src/client/graphics/layers/HeadsUpMessage.ts @@ -22,6 +22,7 @@ export class HeadsUpMessage extends LitElement implements Layer { @state() private isCatchingUp = false; private catchingUpTicks = 0; + private lastTickSeen = -1; private static readonly CATCHING_UP_SHOW_THRESHOLD = 10; @@ -82,6 +83,16 @@ export class HeadsUpMessage extends LitElement implements Layer { } tick() { + const currentTick = this.game.ticks(); + if (this.lastTickSeen >= 0 && currentTick < this.lastTickSeen) { + // Timeline scrubbed backwards; clear state that assumes monotonic time. + this.catchingUpTicks = 0; + this.isCatchingUp = false; + this.isPaused = false; + this.isImmunityActive = false; + } + this.lastTickSeen = currentTick; + const updates = this.game.updatesSinceLastTick(); if (updates && updates[GameUpdateType.GamePaused].length > 0) { const pauseUpdate = updates[GameUpdateType.GamePaused][0]; diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index e23d4d609..5da923398 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -7,6 +7,7 @@ import { GameView, PlayerView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; import { AlternateViewEvent } from "../../InputHandler"; import { createCanvas, renderNumber, renderTroops } from "../../Utils"; +import { TimelineJumpEvent } from "../../timeline/TimelineEvents"; import { computeAllianceClipPath, createAllianceProgressIcon, @@ -99,6 +100,33 @@ export class NameLayer implements Layer { this.container.appendChild(style); this.eventBus.on(AlternateViewEvent, (e) => this.onAlternateViewChange(e)); + this.eventBus.on(TimelineJumpEvent, () => this.resetTimelineState()); + } + + private resetTimelineState() { + for (const render of this.renders) { + render.element.remove(); + } + this.renders = []; + this.seenPlayers.clear(); + this.firstPlace = null; + + // Rebuild immediately so name labels match the new GameView objects even if + // renderer.tick() is throttled by per-layer tick intervals. + for (const player of this.game.playerViews()) { + if (!player.isAlive()) continue; + this.seenPlayers.add(player); + const info = new RenderInfo( + player, + 0, + null, + 0, + "", + this.createPlayerElement(player), + ); + this.updateElementVisibility(info); + this.renders.push(info); + } } private onAlternateViewChange(event: AlternateViewEvent) { diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 674b83e15..10dce41af 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -19,6 +19,10 @@ import { MouseUpEvent, SwapRocketDirectionEvent, } from "../../InputHandler"; +import { + TimelineJumpEvent, + TimelineModeChangedEvent, +} from "../../timeline/TimelineEvents"; import { SendAllianceRequestIntentEvent, SendBreakAllianceIntentEvent, @@ -85,6 +89,8 @@ export class PlayerPanel extends LitElement implements Layer { this.hide(); } }); + eventBus.on(TimelineJumpEvent, () => this.hide()); + eventBus.on(TimelineModeChangedEvent, () => this.hide()); eventBus.on(SwapRocketDirectionEvent, (event) => { this.uiState.rocketDirectionUp = event.rocketDirectionUp; this.requestUpdate(); diff --git a/src/client/graphics/layers/RailroadLayer.ts b/src/client/graphics/layers/RailroadLayer.ts index 1b3815ac6..2ccf6ed5a 100644 --- a/src/client/graphics/layers/RailroadLayer.ts +++ b/src/client/graphics/layers/RailroadLayer.ts @@ -2,24 +2,14 @@ import { colord } from "colord"; import { EventBus, GameEvent } from "../../../core/EventBus"; import { PlayerID, UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; -import { - GameUpdateType, - RailroadConstructionUpdate, - RailroadDestructionUpdate, - RailroadSnapUpdate, -} from "../../../core/game/GameUpdates"; +import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; import { AlternateViewEvent } from "../../InputHandler"; import { TransformHandler } from "../TransformHandler"; import { UIState } from "../UIState"; import { Layer } from "./Layer"; import { getBridgeRects, getRailroadRects } from "./RailroadSprites"; -import { - computeRailTiles, - RailroadView, - RailTile, - RailType, -} from "./RailroadView"; +import { computeRailTiles, RailTile, RailType } from "./RailroadView"; type RailRef = { tile: RailTile; @@ -41,9 +31,6 @@ export class RailroadLayer implements Layer { private alternativeView = false; // Save the number of railroads per tiles. Delete when it reaches 0 private existingRailroads = new Map(); - private railroads = new Map(); - // Railroads under construction - private pendingRailroads = new Set(); private nextRailIndexToCheck = 0; private railTileList: TileRef[] = []; private railTileIndex = new Map(); @@ -62,45 +49,15 @@ export class RailroadLayer implements Layer { } tick() { - this.updatePendingRailroads(); const updates = this.game.updatesSinceLastTick(); - if (!updates) return; - // The event has to be handled in this specific order: construction / snap / destruction - // Otherwise some ID may not be available yet/anymore - updates[GameUpdateType.RailroadConstructionEvent]?.forEach((update) => { - if (update === undefined) return; - this.onRailroadConstruction(update); - }); - updates[GameUpdateType.RailroadSnapEvent]?.forEach((update) => { - if (update === undefined) return; - this.onRailroadSnapEvent(update); - }); - updates[GameUpdateType.RailroadDestructionEvent]?.forEach((update) => { - if (update === undefined) return; - this.onRailroadDestruction(update); - }); - } - - updatePendingRailroads() { - for (const id of this.pendingRailroads) { - const pending = this.railroads.get(id); - if (pending === undefined) { - // Rail deleted or snapped before the end of the animation - this.pendingRailroads.delete(id); - continue; - } - const newTiles = pending.tick(); - if (newTiles.length === 0) { - // Animation complete - this.pendingRailroads.delete(id); - continue; - } - - for (const railTile of newTiles) { - this.paintRailTile(railTile); - this.eventBus.emit(new RailTileChangedEvent(railTile.tile)); - } + const hasRailUpdates = + (updates?.[GameUpdateType.RailroadConstructionEvent]?.length ?? 0) > 0 || + (updates?.[GameUpdateType.RailroadSnapEvent]?.length ?? 0) > 0 || + (updates?.[GameUpdateType.RailroadDestructionEvent]?.length ?? 0) > 0; + if (hasRailUpdates) { + this.rebuildFromGameView(); } + this.updateRailColors(); } updateRailColors() { @@ -142,9 +99,7 @@ export class RailroadLayer implements Layer { init() { this.eventBus.on(AlternateViewEvent, (e) => { this.alternativeView = e.alternateView; - for (const { tile } of this.existingRailroads.values()) { - this.paintRail(tile); - } + this.rebuildFromGameView(); }); this.redraw(); } @@ -162,9 +117,28 @@ export class RailroadLayer implements Layer { this.canvas.width = this.game.width() * 2; this.canvas.height = this.game.height() * 2; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for (const [_, rail] of this.existingRailroads) { - this.paintRail(rail.tile); + this.rebuildFromGameView(); + } + + private rebuildFromGameView() { + if (this.context === undefined) return; + + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.existingRailroads.clear(); + this.railTileList = []; + this.railTileIndex.clear(); + this.nextRailIndexToCheck = 0; + + for (const tiles of this.game.railroads().values()) { + const railTiles = computeRailTiles(this.game, Array.from(tiles)); + for (const railTile of railTiles) { + this.registerRailTile(railTile); + } + } + + for (const { tile } of this.existingRailroads.values()) { + this.paintRail(tile); + this.eventBus.emit(new RailTileChangedEvent(tile.tile)); } } @@ -183,13 +157,12 @@ export class RailroadLayer implements Layer { const offsetY = -this.game.height() / 2; context.fillStyle = "rgba(0, 255, 0, 0.4)"; for (const id of this.uiState.overlappingRailroads) { - const rail = this.railroads.get(id); - if (rail) { - for (const railTile of rail.drawnTiles()) { - const x = this.game.x(railTile.tile); - const y = this.game.y(railTile.tile); - context.fillRect(x + offsetX - 1, y + offsetY - 1, 2.5, 2.5); - } + const tiles = this.game.railroads().get(id); + if (!tiles) continue; + for (const tile of tiles) { + const x = this.game.x(tile); + const y = this.game.y(tile); + context.fillRect(x + offsetX - 1, y + offsetY - 1, 2.5, 2.5); } } } @@ -293,77 +266,7 @@ export class RailroadLayer implements Layer { } } - private onRailroadSnapEvent(update: RailroadSnapUpdate) { - const original = this.railroads.get(update.originalId); - if (!original) { - console.warn("Could not snap railroad: ", update.originalId); - return; - } - if (!original.isComplete()) { - // The animation is not complete but we don't want to compute where the animation should resume - // Just draw every remaining rails at once - this.drawRemainingTiles(original); - } - - // No need to compute the directions here, the rails are already painted - const directions1: RailTile[] = update.tiles1.map((tile) => ({ - tile, - type: RailType.HORIZONTAL, - })); - const directions2: RailTile[] = update.tiles2.map((tile) => ({ - tile, - type: RailType.HORIZONTAL, - })); - // The rails are already painted, consider them complete - this.railroads.set( - update.newId1, - new RailroadView(update.newId1, directions1, true), - ); - this.railroads.set( - update.newId2, - new RailroadView(update.newId2, directions2, true), - ); - - this.railroads.delete(update.originalId); - } - - private drawRemainingTiles(railroad: RailroadView) { - for (const tile of railroad.remainingTiles()) { - this.paintRail(tile); - } - this.pendingRailroads.delete(railroad.id); - } - - private onRailroadConstruction(railUpdate: RailroadConstructionUpdate) { - const railTiles = computeRailTiles(this.game, railUpdate.tiles); - const rail = new RailroadView(railUpdate.id, railTiles); - this.addRailroad(rail); - } - - private onRailroadDestruction(railUpdate: RailroadDestructionUpdate) { - const railroad = this.railroads.get(railUpdate.id); - if (!railroad) { - console.warn("Can't remove unexisting railroad: ", railUpdate.id); - return; - } - this.removeRailroad(railroad); - } - - private addRailroad(railroad: RailroadView) { - this.railroads.set(railroad.id, railroad); - this.pendingRailroads.add(railroad.id); - } - - private removeRailroad(railroad: RailroadView) { - this.pendingRailroads.delete(railroad.id); - for (const railTile of railroad.drawnTiles()) { - this.clearRailroad(railTile.tile); - this.eventBus.emit(new RailTileChangedEvent(railTile.tile)); - } - this.railroads.delete(railroad.id); - } - - private paintRailTile(railTile: RailTile) { + private registerRailTile(railTile: RailTile) { const currentOwner = this.game.owner(railTile.tile)?.id() ?? null; const railRef = this.existingRailroads.get(railTile.tile); @@ -379,51 +282,6 @@ export class RailroadLayer implements Layer { }); this.railTileIndex.set(railTile.tile, this.railTileList.length); this.railTileList.push(railTile.tile); - this.paintRail(railTile); - } - } - - private clearRailroad(railroad: TileRef) { - const ref = this.existingRailroads.get(railroad); - if (ref) ref.numOccurence--; - - if (!ref || ref.numOccurence <= 0) { - this.existingRailroads.delete(railroad); - this.removeRailTile(railroad); - if (this.context === undefined) throw new Error("Not initialized"); - if (this.game.isWater(railroad)) { - this.context.clearRect( - this.game.x(railroad) * 2 - 2, - this.game.y(railroad) * 2 - 2, - 5, - 6, - ); - } else { - this.context.clearRect( - this.game.x(railroad) * 2 - 1, - this.game.y(railroad) * 2 - 1, - 3, - 3, - ); - } - } - } - - private removeRailTile(tile: TileRef) { - const idx = this.railTileIndex.get(tile); - if (idx === undefined) return; - - const lastIndex = this.railTileList.length - 1; - const lastTile = this.railTileList[lastIndex]; - - this.railTileList[idx] = lastTile; - this.railTileIndex.set(lastTile, idx); - - this.railTileList.pop(); - this.railTileIndex.delete(tile); - - if (this.nextRailIndexToCheck >= this.railTileList.length) { - this.nextRailIndexToCheck = 0; } } diff --git a/src/client/graphics/layers/TimelinePanel.ts b/src/client/graphics/layers/TimelinePanel.ts new file mode 100644 index 000000000..0a13fe818 --- /dev/null +++ b/src/client/graphics/layers/TimelinePanel.ts @@ -0,0 +1,139 @@ +import { html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { EventBus } from "../../../core/EventBus"; +import { GameView } from "../../../core/game/GameView"; +import { + TimelineGoLiveEvent, + TimelineRangeEvent, + TimelineRangeRequestEvent, + TimelineSeekEvent, +} from "../../timeline/TimelineEvents"; +import { Layer } from "./Layer"; + +@customElement("timeline-panel") +export class TimelinePanel extends LitElement implements Layer { + public eventBus: EventBus | undefined; + public game: GameView | undefined; + + @property({ type: Boolean }) + visible: boolean = true; + + @state() private liveTick = 0; + @state() private displayTick = 0; + @state() private isLive = true; + @state() private isSeeking = false; + @state() private storageError: string | null = null; + @state() private isDragging = false; + @state() private dragTick: number | null = null; + + createRenderRoot() { + return this; // Enable Tailwind CSS + } + + init() { + this.eventBus?.on(TimelineRangeEvent, (e) => { + this.liveTick = e.liveTick; + this.displayTick = e.displayTick; + this.isLive = e.isLive; + this.isSeeking = e.isSeeking; + this.storageError = e.storageError; + if (!this.isDragging) { + this.dragTick = null; + } + this.requestUpdate(); + }); + + this.eventBus?.emit(new TimelineRangeRequestEvent()); + } + + getTickIntervalMs() { + return 0; + } + + tick() { + // Render is driven by events. + } + + shouldTransform() { + return false; + } + + renderLayer(_ctx: CanvasRenderingContext2D) {} + + private onSeekInput(e: Event) { + const input = e.target as HTMLInputElement; + const t = Number.parseInt(input.value, 10); + if (!Number.isFinite(t)) return; + this.dragTick = t; + this.eventBus?.emit(new TimelineSeekEvent(t)); + } + + private onSeekPointerDown() { + this.isDragging = true; + this.dragTick = this.displayTick; + } + + private onSeekPointerUp() { + this.isDragging = false; + this.dragTick = null; + } + + private onGoLive() { + this.eventBus?.emit(new TimelineGoLiveEvent()); + } + + render() { + if (!this.visible) return html``; + + const shownTick = this.isDragging + ? (this.dragTick ?? this.displayTick) + : this.displayTick; + + const delta = this.liveTick - shownTick; + const status = this.isLive ? "Live" : `Rewinding (-${delta})`; + + return html` +
e.preventDefault()} + > +
+
+ ${status}${this.isSeeking ? " (seeking...)" : ""} +
+ +
+ +
+ Tick ${shownTick} / ${this.liveTick} +
+ + + + ${this.storageError + ? html`
+ ${this.storageError} +
` + : html``} +
+ `; + } +} diff --git a/src/client/timeline/LruCache.ts b/src/client/timeline/LruCache.ts new file mode 100644 index 000000000..0e5fe29dc --- /dev/null +++ b/src/client/timeline/LruCache.ts @@ -0,0 +1,34 @@ +export class LruCache { + private readonly map = new Map(); + + constructor(private readonly capacity: number) { + if (!Number.isFinite(capacity) || capacity <= 0) { + throw new Error(`Invalid LruCache capacity: ${capacity}`); + } + } + + get(key: K): V | undefined { + const value = this.map.get(key); + if (value === undefined) return undefined; + // Refresh recency + this.map.delete(key); + this.map.set(key, value); + return value; + } + + set(key: K, value: V): void { + if (this.map.has(key)) { + this.map.delete(key); + } + this.map.set(key, value); + while (this.map.size > this.capacity) { + const oldestKey = this.map.keys().next().value as K | undefined; + if (oldestKey === undefined) break; + this.map.delete(oldestKey); + } + } + + values(): IterableIterator { + return this.map.values(); + } +} diff --git a/src/client/timeline/TimelineArchive.ts b/src/client/timeline/TimelineArchive.ts new file mode 100644 index 000000000..416755554 --- /dev/null +++ b/src/client/timeline/TimelineArchive.ts @@ -0,0 +1,116 @@ +import { LruCache } from "./LruCache"; +import { TimelineIdb } from "./TimelineIdb"; +import { TimelineCheckpointRecord, TimelineTickRecord } from "./types"; + +export class TimelineArchive { + private readonly tickCache: LruCache; + private readonly checkpointCache: LruCache; + private readonly idb: TimelineIdb; + private _storageError: string | null = null; + + constructor( + opts: { + tickCacheSize?: number; + checkpointCacheSize?: number; + idb?: TimelineIdb; + } = {}, + ) { + this.tickCache = new LruCache(opts.tickCacheSize ?? 5000); + this.checkpointCache = new LruCache(opts.checkpointCacheSize ?? 50); + this.idb = opts.idb ?? new TimelineIdb(); + } + + get storageError(): string | null { + return this._storageError; + } + + async open(): Promise { + try { + await this.idb.open(); + } catch (e) { + this._storageError = `IndexedDB unavailable: ${String(e)}`; + } + } + + putTickRecord(record: TimelineTickRecord): void { + this.tickCache.set(record.tick, record); + if (!this.idb.isAvailable) return; + void this.idb.putTickRecord(record).catch((e) => { + this._storageError = `IndexedDB write failed: ${String(e)}`; + }); + } + + async getTickRecord(tick: number): Promise { + const cached = this.tickCache.get(tick); + if (cached) return cached; + if (!this.idb.isAvailable) return null; + try { + const rec = await this.idb.getTickRecord(tick); + if (rec) this.tickCache.set(tick, rec); + return rec; + } catch (e) { + this._storageError = `IndexedDB read failed: ${String(e)}`; + return null; + } + } + + async getTickRecordsRange( + fromTick: number, + toTick: number, + ): Promise { + if (fromTick > toTick) return []; + + if (!this.idb.isAvailable) { + const out: TimelineTickRecord[] = []; + for (let t = fromTick; t <= toTick; t++) { + const rec = this.tickCache.get(t); + if (!rec) { + throw new Error(`Missing tick record ${t} (memory-only archive)`); + } + out.push(rec); + } + return out; + } + + try { + const recs = await this.idb.getTickRecordsRange(fromTick, toTick); + for (const rec of recs) { + this.tickCache.set(rec.tick, rec); + } + return recs; + } catch (e) { + this._storageError = `IndexedDB range read failed: ${String(e)}`; + return []; + } + } + + putCheckpoint(record: TimelineCheckpointRecord): void { + this.checkpointCache.set(record.tick, record); + if (!this.idb.isAvailable) return; + void this.idb.putCheckpoint(record).catch((e) => { + this._storageError = `IndexedDB checkpoint write failed: ${String(e)}`; + }); + } + + async getCheckpointAtOrBefore( + tick: number, + ): Promise { + let best: TimelineCheckpointRecord | null = null; + for (const rec of this.checkpointCache.values()) { + if (rec.tick <= tick && (best === null || rec.tick > best.tick)) { + best = rec; + } + } + if (best) return best; + + if (!this.idb.isAvailable) return null; + try { + const rec = await this.idb.getCheckpointAtOrBefore(tick); + if (rec) this.checkpointCache.set(rec.tick, rec); + return rec; + } catch (e) { + this._storageError = `IndexedDB checkpoint read failed: ${String(e)}`; + return null; + } + } +} diff --git a/src/client/timeline/TimelineController.ts b/src/client/timeline/TimelineController.ts new file mode 100644 index 000000000..43db99972 --- /dev/null +++ b/src/client/timeline/TimelineController.ts @@ -0,0 +1,417 @@ +import { EventBus } from "../../core/EventBus"; +import { + GameUpdateType, + GameUpdateViewData, +} from "../../core/game/GameUpdates"; +import { GameView } from "../../core/game/GameView"; +import { WorkerClient } from "../../core/worker/WorkerClient"; +import { GameRenderer } from "../graphics/GameRenderer"; +import { ReplaySpeedChangeEvent } from "../InputHandler"; +import { defaultReplaySpeedMultiplier } from "../utilities/ReplaySpeedMultiplier"; +import { TimelineArchive } from "./TimelineArchive"; +import { + TimelineGoLiveEvent, + TimelineJumpEvent, + TimelineModeChangedEvent, + TimelineRangeEvent, + TimelineRangeRequestEvent, + TimelineSeekEvent, +} from "./TimelineEvents"; +import { TimelineCheckpointRecord, TimelineTickRecord } from "./types"; + +const CHECKPOINT_EVERY_TICKS = 300; + +export class TimelineController { + private readonly archive = new TimelineArchive(); + private isLive = true; + private isSeeking = false; + private liveTick = 0; + private displayTick = 0; + private isPaused = false; + private replaySpeedMultiplier = defaultReplaySpeedMultiplier; + private playbackTimer: number | null = null; + + private pendingSeekTick: number | null = null; + private seekScheduled = false; + private seekToken = 0; + + private rewindCheckpointSnapshotInFlight = false; + private rewindCheckpointSnapshotQueued = false; + + constructor( + private readonly worker: WorkerClient, + private readonly gameView: GameView, + private readonly renderer: GameRenderer, + private readonly eventBus: EventBus, + ) { + this.eventBus.on(TimelineSeekEvent, (e) => this.requestSeek(e.targetTick)); + this.eventBus.on(TimelineGoLiveEvent, () => void this.goLive()); + this.eventBus.on(TimelineRangeRequestEvent, () => this.emitRange()); + this.eventBus.on(ReplaySpeedChangeEvent, (e) => { + this.replaySpeedMultiplier = e.replaySpeedMultiplier; + this.maybeSchedulePlayback(); + }); + } + + async initialize(): Promise { + await this.archive.open(); + + // Create a base checkpoint (usually tick 0) so rewind has a stable origin. + try { + const snapshot = await this.worker.snapshot(); + this.liveTick = snapshot.tick; + this.displayTick = snapshot.tick; + this.archive.putCheckpoint({ + tick: snapshot.tick, + mapStateBuffer: snapshot.mapState.buffer, + numTilesWithFallout: snapshot.numTilesWithFallout, + players: snapshot.players, + units: snapshot.units, + playerNameViewData: snapshot.playerNameViewData, + toDeleteUnitIds: snapshot.toDeleteUnitIds, + railroads: snapshot.railroads, + }); + + this.gameView.importWorkerSnapshot(snapshot); + this.renderer.redraw(); + } catch (e) { + // If snapshot fails we can still function once we start receiving ticks. + console.warn("Timeline init snapshot failed:", e); + } + + this.emitRange(); + } + + onWorkerUpdate(gu: GameUpdateViewData): void { + this.liveTick = Math.max(this.liveTick, gu.tick); + + const pauseUpdate = gu.updates?.[GameUpdateType.GamePaused]?.[0]; + if (pauseUpdate) { + this.isPaused = pauseUpdate.paused; + } + + const packedTileUpdatesBuffer = + gu.packedTileUpdates.byteOffset === 0 && + gu.packedTileUpdates.byteLength === gu.packedTileUpdates.buffer.byteLength + ? gu.packedTileUpdates.buffer + : gu.packedTileUpdates.buffer.slice( + gu.packedTileUpdates.byteOffset, + gu.packedTileUpdates.byteOffset + gu.packedTileUpdates.byteLength, + ); + + const tickRecord: TimelineTickRecord = { + tick: gu.tick, + packedTileUpdatesBuffer, + updates: gu.updates, + playerNameViewData: gu.playerNameViewData, + }; + this.archive.putTickRecord(tickRecord); + + if (this.isLive) { + const before = this.displayTick; + this.displayTick = gu.tick; + + this.gameView.update(gu); + + if (gu.tick % CHECKPOINT_EVERY_TICKS === 0) { + const cp = this.gameView.exportCheckpoint(); + const cpRecord: TimelineCheckpointRecord = { + 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, + }; + this.archive.putCheckpoint(cpRecord); + } + + // Normal live tick: let layers consume the delta for this tick. + this.renderer.tick(); + this.emitRange(); + + // Keep internal caches stable across big jumps (e.g., after snapshot init). + if (gu.tick - before > 5) { + this.renderer.redraw(); + } + } else { + // Rewinding: do not mutate view state, only extend timeline range. + this.emitRange(); + + // Still store checkpoints via worker snapshots so forward scrubs stay fast. + if (gu.tick % CHECKPOINT_EVERY_TICKS === 0) { + this.requestRewindCheckpointSnapshot(); + } + + this.maybeSchedulePlayback(); + } + } + + private requestRewindCheckpointSnapshot(): void { + this.rewindCheckpointSnapshotQueued = true; + if (this.rewindCheckpointSnapshotInFlight) return; + + this.rewindCheckpointSnapshotInFlight = true; + this.rewindCheckpointSnapshotQueued = false; + + void this.worker + .snapshot() + .then((snapshot) => { + this.archive.putCheckpoint({ + tick: snapshot.tick, + mapStateBuffer: snapshot.mapState.buffer, + numTilesWithFallout: snapshot.numTilesWithFallout, + players: snapshot.players, + units: snapshot.units, + playerNameViewData: snapshot.playerNameViewData, + toDeleteUnitIds: snapshot.toDeleteUnitIds, + railroads: snapshot.railroads, + }); + }) + .catch(() => { + // ignore; archive.storageError will be surfaced if persistent + }) + .finally(() => { + this.rewindCheckpointSnapshotInFlight = false; + if (this.rewindCheckpointSnapshotQueued) { + this.requestRewindCheckpointSnapshot(); + } + }); + } + + private requestSeek(targetTick: number): void { + this.pendingSeekTick = targetTick; + if (this.isSeeking) return; + if (this.seekScheduled) return; + this.seekScheduled = true; + requestAnimationFrame(() => { + this.seekScheduled = false; + const t = this.pendingSeekTick; + this.pendingSeekTick = null; + if (t === null) return; + void this.seekTo(t); + }); + } + + private setLive(isLive: boolean): void { + if (this.isLive === isLive) return; + this.isLive = isLive; + this.eventBus.emit(new TimelineModeChangedEvent(isLive)); + if (isLive) { + this.clearPlaybackTimer(); + } else { + this.maybeSchedulePlayback(); + } + } + + private emitRange(): void { + this.eventBus.emit( + new TimelineRangeEvent( + this.liveTick, + this.displayTick, + this.isLive, + this.isSeeking, + this.archive.storageError, + ), + ); + } + + private async seekTo(targetTick: number): Promise { + const clamped = Math.max(0, Math.min(targetTick, this.liveTick)); + + if (clamped === this.liveTick) { + await this.goLive(); + return; + } + + const token = ++this.seekToken; + const fromTick = this.displayTick; + this.setLive(false); + this.isSeeking = true; + this.emitRange(); + + const checkpoint = + (await this.archive.getCheckpointAtOrBefore(clamped)) ?? null; + if (token !== this.seekToken) return; + if (!checkpoint) { + this.isSeeking = false; + this.emitRange(); + return; + } + + this.gameView.importCheckpoint({ + tick: checkpoint.tick, + mapState: new Uint16Array(checkpoint.mapStateBuffer), + numTilesWithFallout: checkpoint.numTilesWithFallout, + players: checkpoint.players, + units: checkpoint.units, + playerNameViewData: checkpoint.playerNameViewData, + toDeleteUnitIds: checkpoint.toDeleteUnitIds, + railroads: checkpoint.railroads, + }); + + const tickRecords = await this.archive.getTickRecordsRange( + checkpoint.tick + 1, + clamped, + ); + if (token !== this.seekToken) return; + + for (const rec of tickRecords) { + if (token !== this.seekToken) return; + this.gameView.update({ + tick: rec.tick, + packedTileUpdates: new BigUint64Array(rec.packedTileUpdatesBuffer), + updates: rec.updates, + playerNameViewData: rec.playerNameViewData, + }); + } + + this.displayTick = clamped; + this.isSeeking = false; + this.eventBus.emit(new TimelineJumpEvent(fromTick, clamped)); + this.renderer.redraw(); + this.renderer.tick(); + this.emitRange(); + this.maybeSchedulePlayback(); + + if (this.pendingSeekTick !== null) { + const next = this.pendingSeekTick; + this.pendingSeekTick = null; + this.requestSeek(next); + } + } + + private async goLive(): Promise { + const token = ++this.seekToken; + const fromTick = this.displayTick; + this.isSeeking = true; + this.emitRange(); + + try { + const snapshot = await this.worker.snapshot(); + if (token !== this.seekToken) return; + + this.gameView.importWorkerSnapshot(snapshot); + this.liveTick = Math.max(this.liveTick, snapshot.tick); + this.displayTick = snapshot.tick; + this.setLive(true); + this.isSeeking = false; + this.eventBus.emit(new TimelineJumpEvent(fromTick, snapshot.tick)); + this.renderer.redraw(); + this.renderer.tick(); + this.emitRange(); + } catch (e) { + console.warn("Failed to go live via snapshot:", e); + this.isSeeking = false; + this.emitRange(); + } + + this.maybeSchedulePlayback(); + + if (this.pendingSeekTick !== null) { + const next = this.pendingSeekTick; + this.pendingSeekTick = null; + this.requestSeek(next); + } + } + + private clearPlaybackTimer(): void { + if (this.playbackTimer === null) return; + window.clearTimeout(this.playbackTimer); + this.playbackTimer = null; + } + + private maybeSchedulePlayback(): void { + if (this.isLive) { + this.clearPlaybackTimer(); + return; + } + if (this.isSeeking) return; + if (this.pendingSeekTick !== null) return; + if (this.isPaused) return; + if (this.playbackTimer !== null) return; + + const baseMs = this.gameView.config().serverConfig().turnIntervalMs(); + const intervalMs = baseMs * this.replaySpeedMultiplier; + + if (intervalMs <= 0) { + // "Fastest": step a few ticks per frame-like cadence without blocking. + this.playbackTimer = window.setTimeout(() => { + this.playbackTimer = null; + void this.playbackFastStep(); + }, 0); + return; + } + + this.playbackTimer = window.setTimeout(() => { + this.playbackTimer = null; + void this.playbackSingleStep(); + }, intervalMs); + } + + private async playbackSingleStep(): Promise { + if (this.isLive || this.isSeeking || this.isPaused) return; + + if (this.displayTick >= this.liveTick) { + await this.goLive(); + return; + } + + const nextTick = this.displayTick + 1; + const rec = await this.archive.getTickRecord(nextTick); + if (!rec) { + // Tick record not available yet (worker still processing / IDB lag). + this.maybeSchedulePlayback(); + return; + } + + this.gameView.update({ + tick: rec.tick, + packedTileUpdates: new BigUint64Array(rec.packedTileUpdatesBuffer), + updates: rec.updates, + playerNameViewData: rec.playerNameViewData, + }); + this.displayTick = rec.tick; + this.renderer.tick(); + this.emitRange(); + this.maybeSchedulePlayback(); + } + + private async playbackFastStep(): Promise { + if (this.isLive || this.isSeeking || this.isPaused) return; + + if (this.displayTick >= this.liveTick) { + await this.goLive(); + return; + } + + const start = performance.now(); + let steps = 0; + while ( + steps < 10 && + this.displayTick < this.liveTick && + performance.now() - start < 8 + ) { + const nextTick = this.displayTick + 1; + const rec = await this.archive.getTickRecord(nextTick); + if (!rec) break; + this.gameView.update({ + tick: rec.tick, + packedTileUpdates: new BigUint64Array(rec.packedTileUpdatesBuffer), + updates: rec.updates, + playerNameViewData: rec.playerNameViewData, + }); + this.displayTick = rec.tick; + steps++; + } + + if (steps > 0) { + this.renderer.tick(); + this.emitRange(); + } + + this.maybeSchedulePlayback(); + } +} diff --git a/src/client/timeline/TimelineEvents.ts b/src/client/timeline/TimelineEvents.ts new file mode 100644 index 000000000..186985ab1 --- /dev/null +++ b/src/client/timeline/TimelineEvents.ts @@ -0,0 +1,30 @@ +import { GameEvent } from "../../core/EventBus"; + +export class TimelineSeekEvent implements GameEvent { + constructor(public readonly targetTick: number) {} +} + +export class TimelineGoLiveEvent implements GameEvent {} + +export class TimelineModeChangedEvent implements GameEvent { + constructor(public readonly isLive: boolean) {} +} + +export class TimelineJumpEvent implements GameEvent { + constructor( + public readonly fromTick: number, + public readonly toTick: number, + ) {} +} + +export class TimelineRangeEvent implements GameEvent { + constructor( + public readonly liveTick: number, + public readonly displayTick: number, + public readonly isLive: boolean, + public readonly isSeeking: boolean, + public readonly storageError: string | null, + ) {} +} + +export class TimelineRangeRequestEvent implements GameEvent {} diff --git a/src/client/timeline/TimelineIdb.ts b/src/client/timeline/TimelineIdb.ts new file mode 100644 index 000000000..975aedce2 --- /dev/null +++ b/src/client/timeline/TimelineIdb.ts @@ -0,0 +1,127 @@ +import { TimelineCheckpointRecord, TimelineTickRecord } from "./types"; + +const DB_NAME = "openfront_timeline_v1"; +const DB_VERSION = 1; +const TICK_STORE = "tickRecords"; +const CHECKPOINT_STORE = "checkpoints"; + +function isIndexedDbAvailable(): boolean { + return typeof indexedDB !== "undefined"; +} + +export class TimelineIdb { + private db: IDBDatabase | null = null; + + get isAvailable(): boolean { + return isIndexedDbAvailable(); + } + + async open(): Promise { + if (!this.isAvailable) return; + if (this.db) return; + + this.db = await new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(TICK_STORE)) { + db.createObjectStore(TICK_STORE, { keyPath: "tick" }); + } + if (!db.objectStoreNames.contains(CHECKPOINT_STORE)) { + db.createObjectStore(CHECKPOINT_STORE, { keyPath: "tick" }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => + reject(req.error ?? new Error("indexedDB open failed")); + }); + } + + private requireDb(): IDBDatabase { + if (!this.db) { + throw new Error("TimelineIdb not opened"); + } + return this.db; + } + + async putTickRecord(record: TimelineTickRecord): Promise { + if (!this.isAvailable) return; + const db = this.requireDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(TICK_STORE, "readwrite"); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error("tickRecords tx failed")); + tx.objectStore(TICK_STORE).put(record); + }); + } + + async getTickRecord(tick: number): Promise { + if (!this.isAvailable) return null; + const db = this.requireDb(); + return await new Promise((resolve, reject) => { + const tx = db.transaction(TICK_STORE, "readonly"); + const req = tx.objectStore(TICK_STORE).get(tick); + req.onsuccess = () => resolve((req.result as TimelineTickRecord) ?? null); + req.onerror = () => + reject(req.error ?? new Error("tickRecords get failed")); + }); + } + + async getTickRecordsRange( + fromTick: number, + toTick: number, + ): Promise { + if (!this.isAvailable) return []; + const db = this.requireDb(); + const range = IDBKeyRange.bound(fromTick, toTick); + return await new Promise((resolve, reject) => { + const out: TimelineTickRecord[] = []; + const tx = db.transaction(TICK_STORE, "readonly"); + const store = tx.objectStore(TICK_STORE); + const req = store.openCursor(range, "next"); + req.onsuccess = () => { + const cursor = req.result; + if (!cursor) { + resolve(out); + return; + } + out.push(cursor.value as TimelineTickRecord); + cursor.continue(); + }; + req.onerror = () => + reject(req.error ?? new Error("tickRecords openCursor failed")); + }); + } + + async putCheckpoint(record: TimelineCheckpointRecord): Promise { + if (!this.isAvailable) return; + const db = this.requireDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(CHECKPOINT_STORE, "readwrite"); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error("checkpoints tx failed")); + tx.objectStore(CHECKPOINT_STORE).put(record); + }); + } + + async getCheckpointAtOrBefore( + tick: number, + ): Promise { + if (!this.isAvailable) return null; + const db = this.requireDb(); + const range = IDBKeyRange.upperBound(tick); + return await new Promise( + (resolve, reject) => { + const tx = db.transaction(CHECKPOINT_STORE, "readonly"); + const store = tx.objectStore(CHECKPOINT_STORE); + const req = store.openCursor(range, "prev"); + req.onsuccess = () => { + const cursor = req.result; + resolve(cursor ? (cursor.value as TimelineCheckpointRecord) : null); + }; + req.onerror = () => + reject(req.error ?? new Error("checkpoints openCursor failed")); + }, + ); + } +} diff --git a/src/client/timeline/types.ts b/src/client/timeline/types.ts new file mode 100644 index 000000000..da9a6ad24 --- /dev/null +++ b/src/client/timeline/types.ts @@ -0,0 +1,23 @@ +import { GameUpdates, NameViewData } from "../../core/game/Game"; +import { TileRef } from "../../core/game/GameMap"; +import { PlayerUpdate, UnitUpdate } from "../../core/game/GameUpdates"; + +export type TimelineRailroad = { id: number; tiles: TileRef[] }; + +export type TimelineTickRecord = { + tick: number; + packedTileUpdatesBuffer: ArrayBuffer; + updates: GameUpdates; + playerNameViewData: Record; +}; + +export type TimelineCheckpointRecord = { + tick: number; + mapStateBuffer: ArrayBuffer; + numTilesWithFallout: number; + players: PlayerUpdate[]; + units: UnitUpdate[]; + playerNameViewData: Record; + toDeleteUnitIds: number[]; + railroads: TimelineRailroad[]; +}; diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 3e3affd39..0c414c4f2 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -94,6 +94,10 @@ export class GameRunner { private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void, ) {} + public playerNameViewData(): Record { + return this.playerViewData; + } + init() { if (this.game.config().isRandomSpawn()) { this.game.addExecution(...this.execManager.spawnPlayers()); diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 136fdf1d9..8ba188200 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -109,6 +109,28 @@ export class GameMapImpl implements GameMap { } } } + + exportMutableState(): { state: Uint16Array; numTilesWithFallout: number } { + return { + state: new Uint16Array(this.state), + numTilesWithFallout: this._numTilesWithFallout, + }; + } + + importMutableState(state: Uint16Array, numTilesWithFallout: number): void { + if (state.length !== this.state.length) { + throw new Error( + `State length ${state.length} doesn't match map state length ${this.state.length}`, + ); + } + this.state.set(state); + this._numTilesWithFallout = numTilesWithFallout; + } + + resetMutableState(): void { + this.state.fill(0); + this._numTilesWithFallout = 0; + } numTilesWithFallout(): number { return this._numTilesWithFallout; } diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index f02e0e10b..4648d40eb 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -6,6 +6,7 @@ import { PatternDecoder } from "../PatternDecoder"; import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas"; import { createRandomName } from "../Util"; import { WorkerClient } from "../worker/WorkerClient"; +import { WorkerSnapshot } from "../worker/WorkerMessages"; import { Cell, EmojiMessage, @@ -25,13 +26,16 @@ import { UnitInfo, UnitType, } from "./Game"; -import { GameMap, TileRef, TileUpdate } from "./GameMap"; +import { GameMap, GameMapImpl, TileRef, TileUpdate } from "./GameMap"; import { AllianceView, AttackUpdate, GameUpdateType, GameUpdateViewData, PlayerUpdate, + RailroadConstructionUpdate, + RailroadDestructionUpdate, + RailroadSnapUpdate, UnitUpdate, } from "./GameUpdates"; import { TerrainMapData } from "./TerrainMapLoader"; @@ -83,6 +87,10 @@ export class UnitView { this.data = data; } + snapshotUpdate(): UnitUpdate { + return this.data; + } + id(): number { return this.data.id; } @@ -582,12 +590,24 @@ export class PlayerView { } } +export type GameViewCheckpoint = { + tick: number; + mapState: Uint16Array; + numTilesWithFallout: number; + players: PlayerUpdate[]; + units: UnitUpdate[]; + playerNameViewData: Record; + toDeleteUnitIds: number[]; + railroads: { id: number; tiles: TileRef[] }[]; +}; + export class GameView implements GameMap { private lastUpdate: GameUpdateViewData | null; private smallIDToID = new Map(); private _players = new Map(); private _units = new Map(); private updatedTiles: TileRef[] = []; + private railroadsById = new Map(); private _myPlayer: PlayerView | null = null; @@ -629,6 +649,10 @@ export class GameView implements GameMap { } } + railroads(): ReadonlyMap { + return this.railroadsById; + } + isOnEdgeOfMap(ref: TileRef): boolean { return this._map.isOnEdgeOfMap(ref); } @@ -702,6 +726,135 @@ export class GameView implements GameMap { this.toDelete.add(unit.id()); } }); + + gu.updates[GameUpdateType.RailroadConstructionEvent]?.forEach( + (u: RailroadConstructionUpdate) => { + this.railroadsById.set(u.id, u.tiles); + }, + ); + gu.updates[GameUpdateType.RailroadSnapEvent]?.forEach( + (u: RailroadSnapUpdate) => { + this.railroadsById.delete(u.originalId); + this.railroadsById.set(u.newId1, u.tiles1); + this.railroadsById.set(u.newId2, u.tiles2); + }, + ); + gu.updates[GameUpdateType.RailroadDestructionEvent]?.forEach( + (u: RailroadDestructionUpdate) => { + this.railroadsById.delete(u.id); + }, + ); + } + + exportCheckpoint(): GameViewCheckpoint { + const tick = this.ticks(); + const map = this._map as unknown as GameMapImpl; + if (typeof map.exportMutableState !== "function") { + throw new Error("GameView map does not support exportMutableState()"); + } + const { state, numTilesWithFallout } = map.exportMutableState(); + return { + tick, + mapState: state, + numTilesWithFallout, + players: Array.from(this._players.values()).map((p) => p.data), + units: Array.from(this._units.values()).map((u) => u.snapshotUpdate()), + playerNameViewData: this.lastUpdate?.playerNameViewData ?? {}, + toDeleteUnitIds: Array.from(this.toDelete.values()), + railroads: Array.from(this.railroadsById.entries()).map( + ([id, tiles]) => ({ + id, + tiles: tiles.slice(), + }), + ), + }; + } + + importCheckpoint(checkpoint: GameViewCheckpoint): void { + const map = this._map as unknown as GameMapImpl; + if (typeof map.importMutableState !== "function") { + throw new Error("GameView map does not support mutable state import"); + } + + map.importMutableState(checkpoint.mapState, checkpoint.numTilesWithFallout); + + this.smallIDToID.clear(); + this._players.clear(); + this._units.clear(); + this.updatedTiles = []; + this._myPlayer = null; + this.toDelete = new Set(checkpoint.toDeleteUnitIds); + this.unitGrid = new UnitGrid(this._map); + this.railroadsById = new Map( + checkpoint.railroads.map((r) => [r.id, r.tiles.slice()]), + ); + + this.lastUpdate = { + tick: checkpoint.tick, + packedTileUpdates: new BigUint64Array(0), + updates: GameView.createEmptyGameUpdates(), + playerNameViewData: checkpoint.playerNameViewData, + }; + + const getNameData = (playerId: PlayerID): NameViewData => { + return ( + (checkpoint.playerNameViewData[playerId] as + | NameViewData + | undefined) ?? { + x: 0, + y: 0, + size: 0, + } + ); + }; + + for (const pu of checkpoint.players) { + this.smallIDToID.set(pu.smallID, pu.id); + this._players.set( + pu.id, + new PlayerView( + this, + pu, + getNameData(pu.id), + this._cosmetics.get(pu.clientID ?? "") ?? + this._cosmetics.get(pu.name) ?? + {}, + ), + ); + } + + for (const uu of checkpoint.units) { + const unit = new UnitView(this, uu); + this._units.set(uu.id, unit); + if (uu.isActive) { + this.unitGrid.addUnit(unit); + } + } + + this._myPlayer = this.playerByClientID(this._myClientID); + } + + importWorkerSnapshot(snapshot: WorkerSnapshot): void { + this.importCheckpoint({ + tick: snapshot.tick, + mapState: snapshot.mapState, + numTilesWithFallout: snapshot.numTilesWithFallout, + players: snapshot.players, + units: snapshot.units, + playerNameViewData: snapshot.playerNameViewData, + toDeleteUnitIds: snapshot.toDeleteUnitIds, + railroads: snapshot.railroads, + }); + } + + private static createEmptyGameUpdates(): GameUpdates { + const map = {} as GameUpdates; + Object.values(GameUpdateType) + .filter((key) => !isNaN(Number(key))) + .forEach((key) => { + map[key as GameUpdateType] = []; + }); + return map; } recentlyUpdatedTiles(): TileRef[] { diff --git a/src/core/game/RailNetwork.ts b/src/core/game/RailNetwork.ts index c80880783..2dac7ad6a 100644 --- a/src/core/game/RailNetwork.ts +++ b/src/core/game/RailNetwork.ts @@ -3,6 +3,8 @@ import { TileRef } from "./GameMap"; import { StationManager } from "./RailNetworkImpl"; import { TrainStation } from "./TrainStation"; +export type RailroadSnapshot = { id: number; tiles: TileRef[] }; + export interface RailNetwork { connectStation(station: TrainStation): void; removeStation(unit: Unit): void; @@ -11,4 +13,5 @@ export interface RailNetwork { overlappingRailroads(tile: TileRef): number[]; computeGhostRailPaths(unitType: UnitType, tile: TileRef): TileRef[][]; recomputeClusters(): void; + exportRailroads(): RailroadSnapshot[]; } diff --git a/src/core/game/RailNetworkImpl.ts b/src/core/game/RailNetworkImpl.ts index 813098401..e1c888bb2 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -2,7 +2,7 @@ import { PathFinding } from "../pathfinding/PathFinder"; import { Game, Unit, UnitType } from "./Game"; import { TileRef } from "./GameMap"; import { GameUpdateType } from "./GameUpdates"; -import { RailNetwork } from "./RailNetwork"; +import { RailNetwork, RailroadSnapshot } from "./RailNetwork"; import { Railroad } from "./Railroad"; import { RailSpatialGrid } from "./RailroadSpatialGrid"; import { Cluster, TrainStation } from "./TrainStation"; @@ -130,6 +130,13 @@ export class RailNetworkImpl implements RailNetwork { this.dirtyClusters.clear(); } + exportRailroads(): RailroadSnapshot[] { + return this.railGrid.allRails().map((r) => ({ + id: r.id, + tiles: r.tiles.slice(), + })); + } + removeStation(unit: Unit): void { const station = this._stationManager.findStation(unit); if (!station) return; diff --git a/src/core/game/RailroadSpatialGrid.ts b/src/core/game/RailroadSpatialGrid.ts index 27e57fcae..547716d63 100644 --- a/src/core/game/RailroadSpatialGrid.ts +++ b/src/core/game/RailroadSpatialGrid.ts @@ -84,6 +84,10 @@ export class RailSpatialGrid { return result; } + allRails(): Railroad[] { + return Array.from(this.railToCells.keys()); + } + private key(cx: number, cy: number): string { return `${cx}:${cy}`; } diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 55c37dda1..288dc69f5 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -9,14 +9,17 @@ import { PlayerActionsResultMessage, PlayerBorderTilesResultMessage, PlayerProfileResultMessage, + SnapshotResponseMessage, TransportShipSpawnResultMessage, WorkerMessage, + WorkerSnapshot, } from "./WorkerMessages"; const ctx: Worker = self as any; let gameRunner: Promise | null = null; const mapLoader = new FetchGameMapLoader(`/maps`, version); const MAX_TICKS_PER_HEARTBEAT = 4; +let processTimer: number | null = null; function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) { // skip if ErrorUpdate @@ -36,25 +39,91 @@ function sendMessage(message: WorkerMessage) { ctx.postMessage(message, [message.gameUpdate.packedTileUpdates.buffer]); return; } + if (message.type === "snapshot_response") { + ctx.postMessage(message, [message.snapshot.mapState.buffer]); + return; + } ctx.postMessage(message); } +function scheduleProcessing(delayMs: number) { + if (processTimer !== null) return; + processTimer = setTimeout(async () => { + processTimer = null; + await runProcessingStep(); + }, delayMs) as unknown as number; +} + +async function runProcessingStep() { + const gr = await gameRunner; + if (!gr) return; + + const pendingTurns = gr.pendingTurns(); + if (pendingTurns <= 0) { + scheduleProcessing(10); + return; + } + + const ticksToRun = Math.min(pendingTurns, MAX_TICKS_PER_HEARTBEAT); + for (let i = 0; i < ticksToRun; i++) { + if (!gr.executeNextTick(gr.pendingTurns())) { + break; + } + } + scheduleProcessing(0); +} + +async function buildSnapshot(gr: GameRunner): Promise { + const tick = gr.game.ticks(); + const map = gr.game.map() as any; + if ( + typeof map.exportMutableState !== "function" || + typeof map.numTilesWithFallout !== "function" + ) { + throw new Error("Game map does not support snapshot export"); + } + const exported = map.exportMutableState() as { + state: Uint16Array; + numTilesWithFallout: number; + }; + + const mapState = exported.state; + const numTilesWithFallout = exported.numTilesWithFallout; + + const players = gr.game.allPlayers().map((p) => p.toUpdate()); + const units = gr.game + .allPlayers() + .flatMap((p) => p.units()) + .map((u) => u.toUpdate()); + + // Best-effort: emulate client-side deferred deletion behavior by scheduling + // inactive units for deletion on the next tick. + const toDeleteUnitIds = units.filter((u) => !u.isActive).map((u) => u.id); + + const railroads = gr.game.railNetwork().exportRailroads(); + + // Player name view data is only computed periodically in GameRunner. + const playerNameViewData = gr.playerNameViewData(); + + return { + tick, + mapState, + numTilesWithFallout, + players, + units, + playerNameViewData, + toDeleteUnitIds, + railroads, + }; +} + ctx.addEventListener("message", async (e: MessageEvent) => { const message = e.data; switch (message.type) { case "heartbeat": { - const gr = await gameRunner; - if (!gr) { - break; - } - const pendingTurns = gr.pendingTurns(); - const ticksToRun = Math.min(pendingTurns, MAX_TICKS_PER_HEARTBEAT); - for (let i = 0; i < ticksToRun; i++) { - if (!gr.executeNextTick(gr.pendingTurns())) { - break; - } - } + // Heartbeats are optional; the worker is self-clocked. + scheduleProcessing(0); break; } case "init": @@ -69,6 +138,7 @@ ctx.addEventListener("message", async (e: MessageEvent) => { type: "initialized", id: message.id, } as InitializedMessage); + scheduleProcessing(0); return gr; }); } catch (error) { @@ -85,11 +155,25 @@ ctx.addEventListener("message", async (e: MessageEvent) => { try { const gr = await gameRunner; await gr.addTurn(message.turn); + scheduleProcessing(0); } catch (error) { console.error("Failed to process turn:", error); throw error; } break; + case "snapshot_request": { + const gr = await gameRunner; + if (!gr) { + throw new Error("Game runner not initialized"); + } + const snapshot = await buildSnapshot(gr); + sendMessage({ + type: "snapshot_response", + id: message.id, + snapshot, + } as SnapshotResponseMessage); + break; + } case "player_actions": if (!gameRunner) { diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index a5039e9d7..4f1d0310d 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -10,7 +10,7 @@ import { TileRef } from "../game/GameMap"; import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates"; import { ClientID, GameStartInfo, Turn } from "../Schemas"; import { generateID } from "../Util"; -import { WorkerMessage } from "./WorkerMessages"; +import { WorkerMessage, WorkerSnapshot } from "./WorkerMessages"; export class WorkerClient { private worker: Worker; @@ -109,6 +109,27 @@ export class WorkerClient { }); } + snapshot(): Promise { + return new Promise((resolve, reject) => { + if (!this.isInitialized) { + reject(new Error("Worker not initialized")); + return; + } + + const messageId = generateID(); + this.messageHandlers.set(messageId, (message) => { + if (message.type === "snapshot_response") { + resolve(message.snapshot); + } + }); + + this.worker.postMessage({ + type: "snapshot_request", + id: messageId, + }); + }); + } + playerProfile(playerID: number): Promise { return new Promise((resolve, reject) => { if (!this.isInitialized) { diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index 795df5497..e0e1205eb 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -1,4 +1,5 @@ import { + NameViewData, PlayerActions, PlayerBorderTiles, PlayerID, @@ -6,7 +7,12 @@ import { UnitType, } from "../game/Game"; import { TileRef } from "../game/GameMap"; -import { GameUpdateViewData } from "../game/GameUpdates"; +import { + GameUpdateViewData, + PlayerUpdate, + UnitUpdate, +} from "../game/GameUpdates"; +import { RailroadSnapshot } from "../game/RailNetwork"; import { ClientID, GameStartInfo, Turn } from "../Schemas"; export type WorkerMessageType = @@ -15,6 +21,8 @@ export type WorkerMessageType = | "initialized" | "turn" | "game_update" + | "snapshot_request" + | "snapshot_response" | "player_actions" | "player_actions_result" | "player_profile" @@ -58,6 +66,26 @@ export interface GameUpdateMessage extends BaseWorkerMessage { gameUpdate: GameUpdateViewData; } +export type WorkerSnapshot = { + tick: number; + mapState: Uint16Array; + numTilesWithFallout: number; + players: PlayerUpdate[]; + units: UnitUpdate[]; + playerNameViewData: Record; + toDeleteUnitIds: number[]; + railroads: RailroadSnapshot[]; +}; + +export interface SnapshotRequestMessage extends BaseWorkerMessage { + type: "snapshot_request"; +} + +export interface SnapshotResponseMessage extends BaseWorkerMessage { + type: "snapshot_response"; + snapshot: WorkerSnapshot; +} + export interface PlayerActionsMessage extends BaseWorkerMessage { type: "player_actions"; playerID: PlayerID; @@ -119,6 +147,7 @@ export type MainThreadMessage = | HeartbeatMessage | InitMessage | TurnMessage + | SnapshotRequestMessage | PlayerActionsMessage | PlayerProfileMessage | PlayerBorderTilesMessage @@ -129,6 +158,7 @@ export type MainThreadMessage = export type WorkerMessage = | InitializedMessage | GameUpdateMessage + | SnapshotResponseMessage | PlayerActionsResultMessage | PlayerProfileResultMessage | PlayerBorderTilesResultMessage diff --git a/tests/GameMapMutableState.test.ts b/tests/GameMapMutableState.test.ts new file mode 100644 index 000000000..b8db9e0df --- /dev/null +++ b/tests/GameMapMutableState.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { GameMapImpl } from "../src/core/game/GameMap"; + +describe("GameMapImpl mutable state", () => { + it("exports and imports mutable state losslessly", () => { + const w = 4; + const h = 3; + const terrain = new Uint8Array(w * h).fill(1 << 7); // mark as land + + const map1 = new GameMapImpl(w, h, terrain, w * h); + const t0 = map1.ref(0, 0); + const t1 = map1.ref(1, 0); + const t2 = map1.ref(2, 0); + + map1.setOwnerID(t0, 123); + map1.setFallout(t1, true); + map1.setDefenseBonus(t2, true); + + const exported = map1.exportMutableState(); + + const map2 = new GameMapImpl(w, h, terrain, w * h); + map2.importMutableState(exported.state, exported.numTilesWithFallout); + + expect(map2.ownerID(t0)).toBe(123); + expect(map2.hasFallout(t1)).toBe(true); + expect(map2.hasDefenseBonus(t2)).toBe(true); + expect(map2.numTilesWithFallout()).toBe(1); + }); + + it("resets mutable state", () => { + const w = 2; + const h = 2; + const terrain = new Uint8Array(w * h).fill(1 << 7); + const map = new GameMapImpl(w, h, terrain, w * h); + + map.setOwnerID(map.ref(0, 0), 1); + map.setFallout(map.ref(1, 0), true); + expect(map.numTilesWithFallout()).toBe(1); + + map.resetMutableState(); + expect(map.ownerID(map.ref(0, 0))).toBe(0); + expect(map.hasFallout(map.ref(1, 0))).toBe(false); + expect(map.numTilesWithFallout()).toBe(0); + }); +}); diff --git a/tests/GameViewCheckpoint.test.ts b/tests/GameViewCheckpoint.test.ts new file mode 100644 index 000000000..2b1397f2a --- /dev/null +++ b/tests/GameViewCheckpoint.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import type { Config } from "../src/core/configuration/Config"; +import { UnitType } from "../src/core/game/Game"; +import { GameMapImpl } from "../src/core/game/GameMap"; +import { + GameUpdateType, + type GameUpdateViewData, +} from "../src/core/game/GameUpdates"; +import { GameView } from "../src/core/game/GameView"; +import type { TerrainMapData } from "../src/core/game/TerrainMapLoader"; +import type { WorkerClient } from "../src/core/worker/WorkerClient"; + +function createEmptyGameUpdates() { + const updates: any = {}; + for (const v of Object.values(GameUpdateType)) { + if (typeof v === "number") { + updates[v] = []; + } + } + return updates; +} + +function createMinimalGameView(): GameView { + const w = 4; + const h = 3; + const terrain = new Uint8Array(w * h).fill(1 << 7); // land + const gameMap = new GameMapImpl(w, h, terrain, w * h); + const mapData: TerrainMapData = { + nations: [], + gameMap, + miniGameMap: gameMap, + }; + + return new GameView( + {} as unknown as WorkerClient, + {} as unknown as Config, + mapData, + "client1" as any, + "me", + "game1" as any, + [], + ); +} + +describe("GameView checkpoints", () => { + it("roundtrips a checkpoint (map, units, railroads, toDelete)", () => { + const view1 = createMinimalGameView(); + + const tileA = view1.ref(0, 0); + const tileB = view1.ref(1, 0); + const ownerA = 7; + const hasFallout = true; + const defenseBonus = true; + + const stateA = ownerA; + const stateB = + 3 /* owner */ | (hasFallout ? 1 << 13 : 0) | (defenseBonus ? 1 << 14 : 0); + + const packedTileUpdates = new BigUint64Array([ + (BigInt(tileA) << 16n) | BigInt(stateA), + (BigInt(tileB) << 16n) | BigInt(stateB), + ]); + + const updates = createEmptyGameUpdates(); + updates[GameUpdateType.Unit].push({ + type: GameUpdateType.Unit, + unitType: UnitType.City, + troops: 10, + id: 42, + ownerID: 1, + pos: tileA, + lastPos: tileA, + isActive: false, + reachedTarget: false, + retreating: false, + targetable: false, + markedForDeletion: false, + missileTimerQueue: [], + level: 1, + hasTrainStation: false, + }); + updates[GameUpdateType.RailroadConstructionEvent].push({ + type: GameUpdateType.RailroadConstructionEvent, + id: 99, + tiles: [tileA, tileB], + }); + + const gu: GameUpdateViewData = { + tick: 1, + packedTileUpdates, + updates, + playerNameViewData: {}, + }; + + view1.update(gu); + const cp1 = view1.exportCheckpoint(); + + const view2 = createMinimalGameView(); + view2.importCheckpoint(cp1); + const cp2 = view2.exportCheckpoint(); + + expect(view2.ticks()).toBe(1); + expect(view2.ownerID(tileA)).toBe(ownerA); + expect(view2.hasFallout(tileB)).toBe(true); + expect(((cp2.mapState[tileB] >> 14) & 1) === 1).toBe(true); + + expect(cp2.toDeleteUnitIds).toEqual(cp1.toDeleteUnitIds); + expect(cp2.railroads).toEqual(cp1.railroads); + expect(Array.from(cp2.mapState)).toEqual(Array.from(cp1.mapState)); + expect(cp2.numTilesWithFallout).toBe(cp1.numTilesWithFallout); + + const unit = view2.unit(42); + expect(unit).toBeDefined(); + expect(unit?.isActive()).toBe(false); + }); +}); diff --git a/tests/TimelineSeekReplay.test.ts b/tests/TimelineSeekReplay.test.ts new file mode 100644 index 000000000..53759a10c --- /dev/null +++ b/tests/TimelineSeekReplay.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import type { Config } from "../src/core/configuration/Config"; +import { GameMapImpl } from "../src/core/game/GameMap"; +import { + GameUpdateType, + type GameUpdateViewData, +} from "../src/core/game/GameUpdates"; +import { GameView } from "../src/core/game/GameView"; +import type { TerrainMapData } from "../src/core/game/TerrainMapLoader"; +import type { WorkerClient } from "../src/core/worker/WorkerClient"; + +function createEmptyGameUpdates() { + const updates: any = {}; + for (const v of Object.values(GameUpdateType)) { + if (typeof v === "number") { + updates[v] = []; + } + } + return updates; +} + +function createView(w: number, h: number): GameView { + const terrain = new Uint8Array(w * h).fill(1 << 7); + const gameMap = new GameMapImpl(w, h, terrain, w * h); + const mapData: TerrainMapData = { + nations: [], + gameMap, + miniGameMap: gameMap, + }; + + return new GameView( + {} as unknown as WorkerClient, + {} as unknown as Config, + mapData, + "client1" as any, + "me", + "game1" as any, + [], + ); +} + +function packTileUpdate(tile: number, state16: number): bigint { + return (BigInt(tile) << 16n) | BigInt(state16 & 0xffff); +} + +describe("Timeline-style seek via checkpoints + tick replay", () => { + it("reconstructs map state for arbitrary ticks", () => { + const w = 5; + const h = 4; + const totalTicks = 30; + const checkpointEvery = 5; + + const baseline = createView(w, h); + const byTickState = new Map(); + const checkpoints: { + tick: number; + checkpoint: ReturnType; + }[] = [{ tick: 0, checkpoint: baseline.exportCheckpoint() }]; + const tickRecords: { + tick: number; + packedTileUpdatesBuffer: ArrayBuffer; + updates: any; + }[] = []; + + for (let tick = 1; tick <= totalTicks; tick++) { + const tile1 = tick % (w * h); + const tile2 = (tick * 7) % (w * h); + + const owner1 = (tick % 15) + 1; + const owner2 = ((tick + 3) % 15) + 1; + const fallout2 = tick % 2 === 0; + + const state1 = owner1; + const state2 = owner2 | (fallout2 ? 1 << 13 : 0); + + const packedTileUpdates = new BigUint64Array([ + packTileUpdate(tile1, state1), + packTileUpdate(tile2, state2), + ]); + + const updates = createEmptyGameUpdates(); + const gu: GameUpdateViewData = { + tick, + packedTileUpdates, + updates, + playerNameViewData: {}, + }; + + baseline.update(gu); + byTickState.set(tick, baseline.exportCheckpoint().mapState); + + tickRecords.push({ + tick, + packedTileUpdatesBuffer: packedTileUpdates.buffer.slice(0), + updates, + }); + + if (tick % checkpointEvery === 0) { + checkpoints.push({ tick, checkpoint: baseline.exportCheckpoint() }); + } + } + + const nearestCheckpoint = (targetTick: number) => { + let best = checkpoints[0]?.checkpoint ?? baseline.exportCheckpoint(); + for (const cp of checkpoints) { + if (cp.tick <= targetTick && cp.tick >= best.tick) { + best = cp.checkpoint; + } + } + return best; + }; + + const targets = [1, 2, 7, 13, 19, 24, 30]; + for (const target of targets) { + const view = createView(w, h); + const cp = nearestCheckpoint(target); + view.importCheckpoint(cp); + + for (const rec of tickRecords) { + if (rec.tick <= cp.tick) continue; + if (rec.tick > target) break; + view.update({ + tick: rec.tick, + packedTileUpdates: new BigUint64Array(rec.packedTileUpdatesBuffer), + updates: rec.updates, + playerNameViewData: {}, + }); + } + + const expected = byTickState.get(target); + expect(expected).toBeDefined(); + expect(Array.from(view.exportCheckpoint().mapState)).toEqual( + Array.from(expected!), + ); + } + }); +});