feat(timeline): integrate timeline functionality into game client

- Added TimelineController to manage timeline events and state.
- Introduced TimelinePanel for user interaction with the timeline.
- Implemented LruCache for efficient storage of timeline records.
- Enhanced Transport and GameRenderer to support timeline features.
- Updated various layers to respond to timeline events, ensuring synchronization with game state.
- Added support for seeking and jumping within the timeline, improving user experience during gameplay.

This commit lays the groundwork for a more interactive and responsive timeline feature in the game client.
This commit is contained in:
scamiv
2026-02-20 20:11:51 +01:00
parent f6a08e16db
commit a8ec56b5a4
30 changed files with 1719 additions and 200 deletions
+11 -2
View File
@@ -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(
+9
View File
@@ -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",
+11
View File
@@ -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,
+11
View File
@@ -27,6 +27,7 @@ export class AlertFrame extends LitElement implements Layer {
private animationTimeout: number | null = null;
private seenAttackIds: Set<string> = new Set();
private lastAlertTick: number = -1;
private lastTickSeen: number = -1;
// Map of player ID -> tick when we last attacked them
private outgoingAttackTicks: Map<number, number> = 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
+22 -1
View File
@@ -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;
+25 -1
View File
@@ -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;
+19
View File
@@ -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();
@@ -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];
+28
View File
@@ -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) {
@@ -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();
+39 -181
View File
@@ -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<TileRef, RailRef>();
private railroads = new Map<number, RailroadView>();
// Railroads under construction
private pendingRailroads = new Set<number>();
private nextRailIndexToCheck = 0;
private railTileList: TileRef[] = [];
private railTileIndex = new Map<TileRef, number>();
@@ -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;
}
}
+139
View File
@@ -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`
<div
class="pointer-events-auto p-2 bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg rounded-l-lg w-[320px]"
@contextmenu=${(e: Event) => e.preventDefault()}
>
<div class="flex items-center justify-between mb-2">
<div class="text-white text-sm" translate="no">
${status}${this.isSeeking ? " (seeking...)" : ""}
</div>
<button
class="py-0.5 px-2 text-sm text-white rounded-sm border transition border-gray-500 hover:border-gray-200 ${this
.isLive
? "opacity-50 cursor-not-allowed"
: ""}"
?disabled=${this.isLive}
@click=${this.onGoLive}
>
Live
</button>
</div>
<div class="text-xs text-gray-200 mb-1" translate="no">
Tick ${shownTick} / ${this.liveTick}
</div>
<input
class="w-full"
type="range"
min="0"
max=${this.liveTick}
.value=${String(shownTick)}
@input=${this.onSeekInput}
@pointerdown=${this.onSeekPointerDown}
@pointerup=${this.onSeekPointerUp}
/>
${this.storageError
? html`<div class="mt-2 text-xs text-amber-200" translate="no">
${this.storageError}
</div>`
: html``}
</div>
`;
}
}
+34
View File
@@ -0,0 +1,34 @@
export class LruCache<K, V> {
private readonly map = new Map<K, V>();
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<V> {
return this.map.values();
}
}
+116
View File
@@ -0,0 +1,116 @@
import { LruCache } from "./LruCache";
import { TimelineIdb } from "./TimelineIdb";
import { TimelineCheckpointRecord, TimelineTickRecord } from "./types";
export class TimelineArchive {
private readonly tickCache: LruCache<number, TimelineTickRecord>;
private readonly checkpointCache: LruCache<number, TimelineCheckpointRecord>;
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<void> {
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<TimelineTickRecord | null> {
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<TimelineTickRecord[]> {
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<TimelineCheckpointRecord | null> {
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;
}
}
}
+417
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();
}
}
+30
View File
@@ -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 {}
+127
View File
@@ -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<void> {
if (!this.isAvailable) return;
if (this.db) return;
this.db = await new Promise<IDBDatabase>((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<void> {
if (!this.isAvailable) return;
const db = this.requireDb();
await new Promise<void>((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<TimelineTickRecord | null> {
if (!this.isAvailable) return null;
const db = this.requireDb();
return await new Promise<TimelineTickRecord | null>((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<TimelineTickRecord[]> {
if (!this.isAvailable) return [];
const db = this.requireDb();
const range = IDBKeyRange.bound(fromTick, toTick);
return await new Promise<TimelineTickRecord[]>((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<void> {
if (!this.isAvailable) return;
const db = this.requireDb();
await new Promise<void>((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<TimelineCheckpointRecord | null> {
if (!this.isAvailable) return null;
const db = this.requireDb();
const range = IDBKeyRange.upperBound(tick);
return await new Promise<TimelineCheckpointRecord | null>(
(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"));
},
);
}
}
+23
View File
@@ -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<string, NameViewData>;
};
export type TimelineCheckpointRecord = {
tick: number;
mapStateBuffer: ArrayBuffer;
numTilesWithFallout: number;
players: PlayerUpdate[];
units: UnitUpdate[];
playerNameViewData: Record<string, NameViewData>;
toDeleteUnitIds: number[];
railroads: TimelineRailroad[];
};
+4
View File
@@ -94,6 +94,10 @@ export class GameRunner {
private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
) {}
public playerNameViewData(): Record<PlayerID, NameViewData> {
return this.playerViewData;
}
init() {
if (this.game.config().isRandomSpawn()) {
this.game.addExecution(...this.execManager.spawnPlayers());
+22
View File
@@ -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;
}
+154 -1
View File
@@ -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<string, NameViewData>;
toDeleteUnitIds: number[];
railroads: { id: number; tiles: TileRef[] }[];
};
export class GameView implements GameMap {
private lastUpdate: GameUpdateViewData | null;
private smallIDToID = new Map<number, PlayerID>();
private _players = new Map<PlayerID, PlayerView>();
private _units = new Map<number, UnitView>();
private updatedTiles: TileRef[] = [];
private railroadsById = new Map<number, TileRef[]>();
private _myPlayer: PlayerView | null = null;
@@ -629,6 +649,10 @@ export class GameView implements GameMap {
}
}
railroads(): ReadonlyMap<number, readonly TileRef[]> {
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[] {
+3
View File
@@ -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[];
}
+8 -1
View File
@@ -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;
+4
View File
@@ -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}`;
}
+95 -11
View File
@@ -9,14 +9,17 @@ import {
PlayerActionsResultMessage,
PlayerBorderTilesResultMessage,
PlayerProfileResultMessage,
SnapshotResponseMessage,
TransportShipSpawnResultMessage,
WorkerMessage,
WorkerSnapshot,
} from "./WorkerMessages";
const ctx: Worker = self as any;
let gameRunner: Promise<GameRunner> | 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<WorkerSnapshot> {
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<MainThreadMessage>) => {
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<MainThreadMessage>) => {
type: "initialized",
id: message.id,
} as InitializedMessage);
scheduleProcessing(0);
return gr;
});
} catch (error) {
@@ -85,11 +155,25 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
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) {
+22 -1
View File
@@ -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<WorkerSnapshot> {
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<PlayerProfile> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
+31 -1
View File
@@ -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<string, NameViewData>;
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
+45
View File
@@ -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);
});
});
+116
View File
@@ -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);
});
});
+137
View File
@@ -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<number, Uint16Array>();
const checkpoints: {
tick: number;
checkpoint: ReturnType<GameView["exportCheckpoint"]>;
}[] = [{ 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!),
);
}
});
});