diff --git a/docs/Replays.md b/docs/Replays.md index 54a944a2c85..413fb5930d9 100644 --- a/docs/Replays.md +++ b/docs/Replays.md @@ -107,11 +107,18 @@ You need a game record. Either: hash mismatch if the core has changed since. ```bash -GAME=dqKzit4cWu # the game's ID -mkdir -p /tmp/records && cp "$GAME.json" /tmp/records/ -# The dev client expects its API on 8787. Other API calls will 404. -npx tsx scripts/replay/stub-game-api.mts /tmp/records 8787 -npm run dev +# Generate a demo record: +npm run replay:demo + +# Run client, server, and replay stub API concurrently: +npm run dev:all +``` + +Then open `http://localhost:9000/#replay-viewer=demoGame1`. +You can also test with real games from production using: + +```bash +npm run dev:prod ``` Then open `http://localhost:9000/#replay-viewer=`. diff --git a/package.json b/package.json index d97b98ad93e..e361a77641e 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,12 @@ "start:server": "tsx src/server/Server.ts", "start:server-dev": "cross-env GAME_ENV=dev TURNSTILE_SITE_KEY=1x00000000000000000000AA API_KEY=WARNING_DEV_API_KEY_DO_NOT_USE_IN_PRODUCTION ADMIN_BOT_API_KEY=WARNING_DEV_ADMIN_BOT_KEY_DO_NOT_USE_IN_PRODUCTION DOMAIN=localhost GIT_COMMIT=DEV tsx src/server/Server.ts", "dev": "cross-env GAME_ENV=dev concurrently \"npm run start:client\" \"npm run start:server-dev\"", + "dev:all": "cross-env GAME_ENV=dev concurrently \"npm run start:client\" \"npm run start:server-dev\" \"npm run replay:api\"", "dev:host": "cross-env GAME_ENV=dev VITE_HOST=lan concurrently \"npm run start:client\" \"npm run start:server-dev\"", "dev:staging": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.dev concurrently \"npm run start:client\" \"npm run start:server-dev\"", "dev:prod": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.io concurrently \"npm run start:client\" \"npm run start:server-dev\"", + "replay:api": "tsx scripts/replay/stub-game-api.mts records 8787", + "replay:demo": "tsx scripts/replay/record-demo.mts records/demoGame1.json 1500 demoGame1", "docs:map-generator": "cd map-generator && go doc -cmd -u -all", "tunnel": "npm run build-prod && npm run start:server", "test": "vitest run", diff --git a/packages/engine-api/src/game/GameUpdates.ts b/packages/engine-api/src/game/GameUpdates.ts index c187b82ee12..87821cce407 100644 --- a/packages/engine-api/src/game/GameUpdates.ts +++ b/packages/engine-api/src/game/GameUpdates.ts @@ -142,6 +142,16 @@ export type GameUpdate = | GamePausedUpdate | DonateEventUpdate; +export function createGameUpdatesMap(): GameUpdates { + const map = {} as GameUpdates; + Object.values(GameUpdateType) + .filter((key) => !isNaN(Number(key))) + .forEach((key) => { + map[key as GameUpdateType] = []; + }); + return map; +} + export interface BonusEventUpdate { type: GameUpdateType.BonusEvent; player: PlayerID; diff --git a/packages/engine-api/src/worker/WorkerMessages.ts b/packages/engine-api/src/worker/WorkerMessages.ts index 941d1f474d1..7945becf8a3 100644 --- a/packages/engine-api/src/worker/WorkerMessages.ts +++ b/packages/engine-api/src/worker/WorkerMessages.ts @@ -1,6 +1,7 @@ import { TileRef } from "../game/GameMap"; import { BuildableUnit, + Difficulty, HumanStatsSnapshot, PlayerActions, PlayerBorderTiles, @@ -38,6 +39,9 @@ export type WorkerMessageType = | "transport_ship_spawn_result" | "snapshot" | "snapshot_result" + | "extract_snapshot" + | "extract_snapshot_result" + | "extract_snapshot_error" | "human_stats" | "human_stats_result"; @@ -86,6 +90,7 @@ export interface RunTurnsMessage extends BaseWorkerMessage { // Messages from worker to main thread export interface InitializedMessage extends BaseWorkerMessage { type: "initialized"; + initialUpdate?: GameUpdateViewData; } /** The game couldn't be started; `error` says why. */ @@ -201,6 +206,31 @@ export interface SnapshotResultMessage extends BaseWorkerMessage { type: "snapshot_result"; /** Uncompressed; null if the snapshot failed (see the worker log). */ snapshot: Uint8Array | null; + tick: number; +} + +export interface ExtractSnapshotMessage extends BaseWorkerMessage { + type: "extract_snapshot"; + gameStartInfo: GameStartInfo; + turns: Turn[]; + map: MapFiles; + targetTick: number; + chosenPlayerID: PlayerID; + localClientID: ClientID; + difficulty?: Difficulty; + newGameID?: string; +} + +export interface ExtractSnapshotResultMessage extends BaseWorkerMessage { + type: "extract_snapshot_result"; + snapshot: Uint8Array; + gameStartInfo: GameStartInfo; + tick: number; +} + +export interface ExtractSnapshotErrorMessage extends BaseWorkerMessage { + type: "extract_snapshot_error"; + error: string; } export interface HumanStatsMessage extends BaseWorkerMessage { @@ -225,6 +255,7 @@ export type MainThreadMessage = | AttackClusteredPositionsMessage | TransportShipSpawnMessage | SnapshotMessage + | ExtractSnapshotMessage | HumanStatsMessage; // Message send from worker @@ -243,4 +274,6 @@ export type WorkerMessage = | AttackClusteredPositionsResultMessage | TransportShipSpawnResultMessage | SnapshotResultMessage + | ExtractSnapshotResultMessage + | ExtractSnapshotErrorMessage | HumanStatsResultMessage; diff --git a/packages/engine-lib/package.json b/packages/engine-lib/package.json index 112fa0cdab3..e73940cc230 100644 --- a/packages/engine-lib/package.json +++ b/packages/engine-lib/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@openfront/engine-api": "0.0.0", + "@openfront/zbin": "0.0.0", "zod": "^4.4.2" } } diff --git a/packages/engine-lib/src/snapshot/MapSnapshot.ts b/packages/engine-lib/src/snapshot/MapSnapshot.ts new file mode 100644 index 00000000000..55aedf59fd7 --- /dev/null +++ b/packages/engine-lib/src/snapshot/MapSnapshot.ts @@ -0,0 +1,169 @@ +import { GameMap } from "@openfront/engine-api/game/GameMap"; +import { GameConfig, GameConfigSchema } from "@openfront/engine-api/Schemas"; +import { z } from "zod"; +import { GameMapImpl, GameMapSnapshot } from "../game/GameMapImpl"; +import { decodeSnapshotValue } from "./SnapshotCodec"; +import { + readVersioned, + SnapshotError, + VersionedSchema, + zInt, + zTiles, +} from "./SnapshotType"; + +export const SNAPSHOT_MAGIC = "OpenFrontGameSnapshot"; + +/** + * Version of the root layout below. Everything inside it carries its own + * record version, so this only moves when the root itself changes. + */ +export const SNAPSHOT_FORMAT_VERSION = 1; + +export const ExecRecordSchema = z.object({ + t: z.string(), + v: z.number().int(), + d: z.unknown(), +}); + +export const RootSchema = z.object({ + magic: z.literal(SNAPSHOT_MAGIC), + format: z.number().int(), + /** Build that wrote the snapshot. Informational: any build can read it. */ + gitCommit: z.string(), + gameID: z.string().nullable(), + tick: z.number().int(), + gameConfig: z.unknown(), + game: VersionedSchema, + map: VersionedSchema, + miniMap: VersionedSchema, + players: z.array(VersionedSchema), + units: z.array(VersionedSchema), + attacks: z.array(VersionedSchema), + alliances: z.array(VersionedSchema), + allianceRequests: z.array(VersionedSchema), + stations: z.array(VersionedSchema), + railroads: z.array(VersionedSchema), + clusters: z.array(VersionedSchema), + execs: z.array(ExecRecordSchema), +}); +export type Root = z.infer; + +export function decodeRoot(bytes: Uint8Array): Root { + let raw: unknown; + try { + raw = decodeSnapshotValue(bytes); + } catch (e) { + throw new SnapshotError(`not a game snapshot: ${String(e)}`); + } + if ( + typeof raw !== "object" || + raw === null || + (raw as { magic?: unknown }).magic !== SNAPSHOT_MAGIC + ) { + throw new SnapshotError("not a game snapshot"); + } + const format = (raw as { format?: unknown }).format; + if (typeof format !== "number" || !Number.isInteger(format) || format < 1) { + throw new SnapshotError(`invalid snapshot format: ${String(format)}`); + } + if (format > SNAPSHOT_FORMAT_VERSION) { + throw new SnapshotError( + `snapshot format ${String(format)} is newer than this build supports (${SNAPSHOT_FORMAT_VERSION})`, + ); + } + const root = RootSchema.safeParse(raw); + if (!root.success) { + throw new SnapshotError(`malformed snapshot: ${root.error.message}`); + } + return root.data; +} + +export function readStartTick(d: unknown): number | null { + if (typeof d !== "object" || d === null) return null; + const v = (d as { startTick?: unknown }).startTick; + return typeof v === "number" && Number.isInteger(v) ? v : null; +} + +export interface SnapshotHeader { + format: number; + gitCommit: string; + gameID: string | null; + tick: number; + gameConfig: GameConfig; + startTick?: number | null; +} + +/** Reads what a restore needs to load first: the config and the map. */ +export function readSnapshotHeader(bytes: Uint8Array): SnapshotHeader { + const root = decodeRoot(bytes); + return { + format: root.format, + gitCommit: root.gitCommit, + gameID: root.gameID, + tick: root.tick, + gameConfig: GameConfigSchema.parse(root.gameConfig), + startTick: readStartTick(root.game?.d), + }; +} + +const PlayerTilesSchema = z + .object({ + smallID: zInt(), + info: z.object({ + id: z.string(), + }), + tiles: zTiles(), + }) + .passthrough(); + +export interface RestoredMapsResult { + tick: number; + startTick: number | null; +} + +/** + * Restores map edits (water nukes, fallout, defense) and tile ownership from a + * snapshot onto freshly loaded map instances. Used on the client so GameView's + * local maps match the simulation state without waiting for per-tile deltas. + */ +export function restoreMapsFromSnapshot( + bytes: Uint8Array, + gameMap: GameMap, + miniGameMap?: GameMap, +): RestoredMapsResult { + const root = decodeRoot(bytes); + (gameMap as GameMapImpl).restoreSnapshot( + readVersioned(GameMapSnapshot, root.map), + ); + if (miniGameMap) { + (miniGameMap as GameMapImpl).restoreSnapshot( + readVersioned(GameMapSnapshot, root.miniMap), + ); + } + const players = root.players.map((p) => { + const parsed = PlayerTilesSchema.safeParse(p.d); + if (!parsed.success) { + throw new SnapshotError( + `Player v${p.v}: invalid data: ${parsed.error.message}`, + ); + } + return parsed.data; + }); + for (const p of players) { + const id = p.smallID; + const tiles = p.tiles; + for (let i = 0; i < tiles.length; i++) { + const tile = tiles[i]; + if (!gameMap.isValidRef(tile)) { + throw new SnapshotError( + `invalid tile ref ${tile} for player ${p.info.id}`, + ); + } + gameMap.setOwnerID(tile, id); + } + } + return { + tick: root.tick, + startTick: readStartTick(root.game?.d), + }; +} diff --git a/packages/engine-lib/src/snapshot/SnapshotCodec.ts b/packages/engine-lib/src/snapshot/SnapshotCodec.ts new file mode 100644 index 00000000000..40e731659b3 --- /dev/null +++ b/packages/engine-lib/src/snapshot/SnapshotCodec.ts @@ -0,0 +1,370 @@ +import { ByteReader } from "@openfront/zbin"; + +/** + * Self-describing binary encoding for snapshot data. + * + * zbin is positional: a payload can only be read by the exact schema that + * wrote it. Snapshots must stay readable across builds, so they need a format + * that carries its own field names and types, letting an older record be + * decoded as plain data and then migrated. This codec encodes plain values + * (the shape JSON has, plus bigint, undefined, non-finite floats and typed + * arrays) with a tag byte per value. Uint32Arrays, which are tile lists, are + * delta-encoded. Strings, including object keys, are + * interned: the first occurrence is written inline and later ones are a + * varint back-reference, so repeated field names cost one or two bytes. + * + * Encoding is deterministic: equal values built in the same key order encode + * to identical bytes, which the snapshot tests rely on. + */ + +const enum Tag { + Undefined = 0, + Null = 1, + False = 2, + True = 3, + Uint = 4, + NegInt = 5, + Float = 6, + BigInt = 7, + NewString = 8, + StringRef = 9, + Array = 10, + Object = 11, + TypedArray = 12, + // Uint32Array as zigzag varint deltas. These are mostly tile lists in + // conquest order, which is spatially coherent, so most deltas take one or + // two bytes instead of four. + DeltaU32 = 13, +} + +type TypedArray = + | Int8Array + | Uint8Array + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array; + +const TYPED_ARRAYS = [ + Int8Array, + Uint8Array, + Int16Array, + Uint16Array, + Int32Array, + Uint32Array, + Float32Array, + Float64Array, +] as const; + +// Typed arrays are written as their raw bytes. Every platform the game runs +// on is little-endian; fail loudly rather than write unreadable snapshots. +if (new Uint8Array(new Uint16Array([1]).buffer)[0] !== 1) { + throw new Error("snapshot codec requires a little-endian platform"); +} + +// Explicit mapping rather than indexing TYPED_ARRAYS with a stored value. +function typedArrayKind(kind: number): (typeof TYPED_ARRAYS)[number] { + switch (kind) { + case 0: + return Int8Array; + case 1: + return Uint8Array; + case 2: + return Int16Array; + case 3: + return Uint16Array; + case 4: + return Int32Array; + case 5: + return Uint32Array; + case 6: + return Float32Array; + case 7: + return Float64Array; + default: + throw new SnapshotCodecError(`unknown typed array kind ${kind}`); + } +} + +export class SnapshotCodecError extends Error { + override readonly name = "SnapshotCodecError"; +} + +const textEncoder = new TextEncoder(); + +// Growable writer. The varint, bigint and string layouts match zbin's +// ByteWriter so zbin's ByteReader decodes them; this one adds a bulk byte +// copy, which typed-array payloads (tile lists) need. +class SnapshotByteWriter { + private buf = new Uint8Array(1 << 16); + private view = new DataView(this.buf.buffer); + private pos = 0; + + private ensure(extra: number): void { + if (this.pos + extra <= this.buf.length) return; + let cap = this.buf.length * 2; + while (cap < this.pos + extra) cap *= 2; + const next = new Uint8Array(cap); + next.set(this.buf.subarray(0, this.pos)); + this.buf = next; + this.view = new DataView(next.buffer); + } + + u8(b: number): void { + this.ensure(1); + this.buf[this.pos++] = b; + } + + uint(n: number): void { + while (n >= 0x80) { + this.u8((n % 0x80) + 0x80); + n = Math.floor(n / 0x80); + } + this.u8(n); + } + + f64(n: number): void { + this.ensure(8); + this.view.setFloat64(this.pos, n, true); + this.pos += 8; + } + + // Zigzag LEB128, as zbin writes bigints. + bigint(n: bigint): void { + let u = n < 0n ? -2n * n - 1n : 2n * n; + while (u >= 0x80n) { + this.u8(Number(u & 0x7fn) | 0x80); + u >>= 7n; + } + this.u8(Number(u)); + } + + str(s: string): void { + const bytes = textEncoder.encode(s); + this.uint(bytes.length); + this.bytes(bytes); + } + + bytes(b: Uint8Array): void { + this.ensure(b.length); + this.buf.set(b, this.pos); + this.pos += b.length; + } + + finish(): Uint8Array { + return this.buf.slice(0, this.pos); + } +} + +export function encodeSnapshotValue(value: unknown): Uint8Array { + const w = new SnapshotByteWriter(); + const strings = new Map(); + + const writeString = (s: string) => { + const idx = strings.get(s); + if (idx !== undefined) { + w.u8(Tag.StringRef); + w.uint(idx); + return; + } + strings.set(s, strings.size); + w.u8(Tag.NewString); + w.str(s); + }; + + const write = (v: unknown, path: string): void => { + switch (typeof v) { + case "undefined": + w.u8(Tag.Undefined); + return; + case "boolean": + w.u8(v ? Tag.True : Tag.False); + return; + case "number": + if (Number.isSafeInteger(v) && !Object.is(v, -0)) { + if (v >= 0) { + w.u8(Tag.Uint); + w.uint(v); + } else { + w.u8(Tag.NegInt); + w.uint(-v - 1); + } + } else { + w.u8(Tag.Float); + w.f64(v); + } + return; + case "bigint": + w.u8(Tag.BigInt); + w.bigint(v); + return; + case "string": + writeString(v); + return; + case "object": { + if (v === null) { + w.u8(Tag.Null); + return; + } + if (Array.isArray(v)) { + w.u8(Tag.Array); + w.uint(v.length); + for (let i = 0; i < v.length; i++) write(v[i], path); + return; + } + if (v instanceof Uint32Array) { + w.u8(Tag.DeltaU32); + w.uint(v.length); + let prev = 0; + for (let i = 0; i < v.length; i++) { + const d = v[i] - prev; + w.uint(d < 0 ? -2 * d - 1 : 2 * d); + prev = v[i]; + } + return; + } + if (ArrayBuffer.isView(v)) { + const kind = TYPED_ARRAYS.findIndex( + (C) => (v as TypedArray).constructor === C, + ); + if (kind === -1) { + throw new SnapshotCodecError( + `${path}: unsupported binary view ${v.constructor.name}`, + ); + } + w.u8(Tag.TypedArray); + w.u8(kind); + w.uint(v.byteLength); + w.bytes(new Uint8Array(v.buffer, v.byteOffset, v.byteLength)); + return; + } + const proto = Object.getPrototypeOf(v); + if (proto !== Object.prototype && proto !== null) { + throw new SnapshotCodecError( + `${path}: snapshot data must be plain; got ${proto?.constructor?.name ?? "unknown"}`, + ); + } + const keys = Object.keys(v); + w.u8(Tag.Object); + w.uint(keys.length); + for (const k of keys) { + writeString(k); + write((v as Record)[k], `${path}.${k}`); + } + return; + } + default: + throw new SnapshotCodecError(`${path}: cannot encode a ${typeof v}`); + } + }; + + write(value, "$"); + return w.finish(); +} + +export function decodeSnapshotValue(bytes: Uint8Array): unknown { + const r = new ByteReader(bytes); + const strings: string[] = []; + + const readString = (tag: number): string => { + if (tag === Tag.NewString) { + const s = r.str(); + strings.push(s); + return s; + } + if (tag === Tag.StringRef) { + const idx = r.uint(); + if (idx >= strings.length) { + throw new SnapshotCodecError(`unknown string reference ${idx}`); + } + return strings[idx]; + } + throw new SnapshotCodecError(`expected a string, got tag ${tag}`); + }; + + const read = (): unknown => { + const tag = r.u8(); + switch (tag) { + case Tag.Undefined: + return undefined; + case Tag.Null: + return null; + case Tag.False: + return false; + case Tag.True: + return true; + case Tag.Uint: + return r.uint(); + case Tag.NegInt: + return -r.uint() - 1; + case Tag.Float: + return r.f64(); + case Tag.BigInt: + return r.bigint(); + case Tag.NewString: + case Tag.StringRef: + return readString(tag); + case Tag.Array: { + const n = r.uint(); + if (n > r.remaining) { + throw new SnapshotCodecError( + `array length ${n} exceeds remaining input (${r.remaining})`, + ); + } + const out = new Array(n); + for (let i = 0; i < n; i++) out[i] = read(); + return out; + } + case Tag.Object: { + const n = r.uint(); + const out: Record = {}; + for (let i = 0; i < n; i++) { + const k = readString(r.u8()); + // Snapshots can be hand-edited or corrupt: a "__proto__" key would + // replace the object's prototype instead of adding a field. + if (k === "__proto__") { + throw new SnapshotCodecError("invalid object key __proto__"); + } + out[k] = read(); + } + return out; + } + case Tag.DeltaU32: { + const n = r.uint(); + if (n > r.remaining) { + throw new SnapshotCodecError("truncated tile list"); + } + const out = new Uint32Array(n); + let prev = 0; + for (let i = 0; i < n; i++) { + const z = r.uint(); + const v = prev + (z % 2 === 0 ? z / 2 : -(z + 1) / 2); + if (v < 0 || v > 0xffffffff) { + throw new SnapshotCodecError("tile list value out of range"); + } + out[i] = v; + prev = v; + } + return out; + } + case Tag.TypedArray: { + const C = typedArrayKind(r.u8()); + const byteLength = r.uint(); + if (byteLength % C.BYTES_PER_ELEMENT !== 0) { + throw new SnapshotCodecError(`misaligned ${C.name} payload`); + } + // Copy so the result is aligned and does not pin the input buffer. + const copy = r.readBytes(byteLength).slice(); + return new C(copy.buffer, 0, byteLength / C.BYTES_PER_ELEMENT); + } + default: + throw new SnapshotCodecError(`unknown tag ${tag}`); + } + }; + + const value = read(); + r.expectEnd(); + return value; +} diff --git a/packages/engine/src/GameRunner.ts b/packages/engine/src/GameRunner.ts index 2880e69ced6..a8cd4dc2bb2 100644 --- a/packages/engine/src/GameRunner.ts +++ b/packages/engine/src/GameRunner.ts @@ -16,7 +16,9 @@ import { UnitType, } from "@openfront/engine-api/game/GameTypes"; import { + createGameUpdatesMap, ErrorUpdate, + GameUpdateType, GameUpdateViewData, } from "@openfront/engine-api/game/GameUpdates"; import { MapFiles } from "@openfront/engine-api/game/MapFiles"; @@ -131,7 +133,7 @@ export async function createGameRunnerFromSnapshot( teamGameSpawnAreas: gameMap.teamGameSpawnAreas, }); // No init(): the snapshot already holds every execution init() adds. - return new GameRunner( + const gr = new GameRunner( game, new Executor( game, @@ -141,12 +143,17 @@ export async function createGameRunnerFromSnapshot( ), callBack, ); + if (!game.inSpawnPhase()) { + gr.setPendingSpawnPhaseEnd(); + } + return gr; } export class GameRunner { private turns: Turn[] = []; private currTurn = 0; private isExecuting = false; + private pendingSpawnPhaseEnd = false; private playerViewData: Record = {}; // Name placements are recomputed periodically; a runner that starts @@ -159,6 +166,10 @@ export class GameRunner { private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void, ) {} + public setPendingSpawnPhaseEnd(): void { + this.pendingSpawnPhaseEnd = true; + } + /** * Serializes the simulation at the current tick boundary. Turns that were * added but not executed yet are not part of it. @@ -173,6 +184,78 @@ export class GameRunner { }); } + /** + * Generates a full GameUpdateViewData representing the current simulation state + * (e.g. for snapshot resume before processing new turns). Calling this also + * initializes PlayerImpl.lastSentUpdate so subsequent tick emissions retain partial diffs. + */ + public snapshotViewData(): GameUpdateViewData { + const updates = createGameUpdatesMap(); + + for (const player of this.game.allPlayers()) { + const update = player.toUpdate(undefined, undefined, true); + if (update !== null) { + updates[GameUpdateType.Player].push(update); + } + } + + for (const unit of this.game.units()) { + if (unit.isActive()) { + updates[GameUpdateType.Unit].push(unit.toUpdate()); + } + } + + for (const railroad of this.game.railNetwork().railroads()) { + updates[GameUpdateType.RailroadConstructionEvent].push( + railroad.toConstructionUpdate(), + ); + } + + for (const req of this.game.allianceRequests()) { + updates[GameUpdateType.AllianceRequest].push(req.toUpdate()); + } + + if (!this.game.inSpawnPhase()) { + this.pendingSpawnPhaseEnd = false; + updates[GameUpdateType.SpawnPhaseEnd].push({ + type: GameUpdateType.SpawnPhaseEnd, + startTick: this.game.startTick() ?? 0, + }); + } + + this.playerViewData = {}; + if (this.game.inSpawnPhase()) { + for (const p of this.game.players()) { + if (p.type() !== PlayerType.Human && p.type() !== PlayerType.Nation) { + continue; + } + if (p.spawnTile() === undefined) continue; + this.playerViewData[p.id()] = placeSpawnName(this.game, p); + } + } else { + for (const p of this.game.players()) { + this.playerViewData[p.id()] = placeName(this.game, p); + } + } + + const packedTileUpdates = new Uint32Array(0); + const packedPlayerUpdates = + this.game.drainPackedPlayerUpdates() ?? undefined; + const packedAttackUpdates = + this.game.drainPackedAttackUpdates() ?? undefined; + + return { + tick: this.game.ticks(), + updates, + packedTileUpdates, + ...(packedPlayerUpdates ? { packedPlayerUpdates } : {}), + ...(packedAttackUpdates ? { packedAttackUpdates } : {}), + playerNameViewData: this.playerViewData, + tickExecutionDuration: 0, + pendingTurns: 0, + }; + } + init() { if (this.game.config().gameConfig().gameType !== GameType.Singleplayer) { this.game.addExecution(new SpawnTimerExecution()); @@ -240,6 +323,16 @@ export class GameRunner { return false; } + if (this.pendingSpawnPhaseEnd) { + this.pendingSpawnPhaseEnd = false; + if (updates[GameUpdateType.SpawnPhaseEnd].length === 0) { + updates[GameUpdateType.SpawnPhaseEnd].push({ + type: GameUpdateType.SpawnPhaseEnd, + startTick: this.game.startTick() ?? 0, + }); + } + } + // Track whether placements were recomputed this tick — the record is // only attached to the update when it could have changed, so the main // thread doesn't structured-clone an identical ~all-players record on diff --git a/packages/engine/src/configuration/EngineConfig.ts b/packages/engine/src/configuration/EngineConfig.ts index a171955887a..f7c29d25f1a 100644 --- a/packages/engine/src/configuration/EngineConfig.ts +++ b/packages/engine/src/configuration/EngineConfig.ts @@ -1,5 +1,6 @@ import { Difficulty, + GameType, Gold, PlayerInfo, PlayerType, @@ -132,6 +133,14 @@ export class EngineConfig extends Config { return 7; } + setDifficulty(difficulty: Difficulty): void { + this._gameConfig = { ...this._gameConfig, difficulty }; + } + + setGameType(gameType: GameType): void { + this._gameConfig = { ...this._gameConfig, gameType }; + } + falloutDefenseModifier(falloutRatio: number): number { // falloutRatio is between 0 and 1 // So defense modifier is between [5, 2.5] diff --git a/packages/engine/src/execution/NationExecution.ts b/packages/engine/src/execution/NationExecution.ts index 9a6cae59579..ebd1c19db89 100644 --- a/packages/engine/src/execution/NationExecution.ts +++ b/packages/engine/src/execution/NationExecution.ts @@ -76,6 +76,10 @@ export class NationExecution implements Execution { this.expandRatio = this.random.nextInt(10, 20) / 100; } + playerID(): string { + return this.nation.playerInfo.id; + } + init(mg: Game) { this.mg = mg; this.attackRate = this.getAttackRate(); @@ -104,6 +108,24 @@ export class NationExecution implements Execution { } } + isInitialized(): boolean { + return this.mg !== undefined; + } + + refreshDifficulty(): void { + if (!this.mg) return; + this.attackRate = this.getAttackRate(); + this.attackTick = this.random.nextInt(0, this.attackRate); + } + + currentAttackRate(): number { + return this.attackRate; + } + + currentAttackTick(): number { + return this.attackTick; + } + tick(ticks: number) { // Ship tracking if ( diff --git a/packages/engine/src/execution/TribeExecution.ts b/packages/engine/src/execution/TribeExecution.ts index c74b61bbfaf..d9a1b9550e2 100644 --- a/packages/engine/src/execution/TribeExecution.ts +++ b/packages/engine/src/execution/TribeExecution.ts @@ -1,4 +1,4 @@ -import { Structures } from "@openfront/engine-api/game/GameTypes"; +import { Structures } from "@openfront/engine-api/game/GameTypes"; import { PseudoRandom } from "@openfront/engine-lib/PseudoRandom"; import { VersionedSchema, @@ -41,6 +41,10 @@ export class TribeExecution implements Execution { this.expandRatio = this.random.nextInt(10, 20) / 100; } + playerID(): string { + return this.tribe.id(); + } + activeDuringSpawnPhase(): boolean { return false; } diff --git a/packages/engine/src/game/Game.ts b/packages/engine/src/game/Game.ts index 3572aef12cd..18bd777fd57 100644 --- a/packages/engine/src/game/Game.ts +++ b/packages/engine/src/game/Game.ts @@ -4,6 +4,7 @@ import { AllPlayers, BuildableUnit, Cell, + Difficulty, EmojiMessage, GameUpdates, Gold, @@ -29,6 +30,7 @@ import { WarshipState, } from "@openfront/engine-api/game/GameTypes"; import { + AllianceRequestUpdate, GameUpdate, PlayerUpdate, UnitUpdate, @@ -40,7 +42,11 @@ import { UnitLike, UnitPredicate, } from "@openfront/engine-api/game/ReadViews"; -import { AllPlayersStats, ClientID } from "@openfront/engine-api/Schemas"; +import { + AllPlayersStats, + ClientID, + GameID, +} from "@openfront/engine-api/Schemas"; import { MotionPlanRecord } from "@openfront/engine-lib/game/MotionPlans"; import type { EngineConfig } from "../configuration/EngineConfig"; import { AbstractGraph } from "../pathfinding/algorithms/AbstractGraph"; @@ -161,6 +167,7 @@ export interface AllianceRequest { recipient(): Player; createdAt(): Tick; status(): "pending" | "accepted" | "rejected"; + toUpdate(): AllianceRequestUpdate; } export interface Alliance { @@ -311,6 +318,7 @@ export interface Player extends PlayerLike { // Basic Info smallID(): number; info(): PlayerInfo; + setPlayerInfo(info: PlayerInfo): void; name(): string; displayName(): string; clanTag(): string | null; @@ -497,6 +505,7 @@ export interface Player extends PlayerLike { toUpdate( statsOut?: number[], attackTroopsOut?: number[], + forceFull?: boolean, ): PlayerUpdate | null; playerProfile(): PlayerProfile; // WARNING: this operation is expensive. @@ -545,6 +554,7 @@ export interface Game extends GameLike { // Alliances expireAlliance(alliance: Alliance): void; + allianceRequests(): AllianceRequest[]; // Immunity timer isSpawnImmunityActive(): boolean; @@ -553,6 +563,7 @@ export interface Game extends GameLike { // Game State ticks(): Tick; + startTick(): Tick | null; inSpawnPhase(): boolean; endSpawnPhase(): void; executeNextTick(): GameUpdates; @@ -604,6 +615,11 @@ export interface Game extends GameLike { ): Array<{ unit: Unit; distSquared: number }>; addExecution(...exec: Execution[]): void; + executions(): Execution[]; + removeExecution(exec: Execution): void; + takeoverPlayer(player: Player | PlayerID, localClientID: ClientID): void; + convertHumanToNation(player: Player | PlayerID, gameID: GameID): Execution; + applySingleplayerConfig(difficulty?: Difficulty): void; displayMessage( message: string, type: MessageType, diff --git a/packages/engine/src/game/GameImpl.ts b/packages/engine/src/game/GameImpl.ts index ae57b0cafe6..1449eeaf7f6 100644 --- a/packages/engine/src/game/GameImpl.ts +++ b/packages/engine/src/game/GameImpl.ts @@ -2,9 +2,11 @@ import { GameMap, TileRef } from "@openfront/engine-api/game/GameMap"; import { Cell, ColoredTeams, + Difficulty, Duos, EmojiMessage, GameMode, + GameType, GameUpdates, HumansVsNations, MessageType, @@ -23,6 +25,7 @@ import { UnitType, } from "@openfront/engine-api/game/GameTypes"; import { + createGameUpdatesMap, GameUpdate, GameUpdateType, } from "@openfront/engine-api/game/GameUpdates"; @@ -30,6 +33,7 @@ import { UnitPredicate } from "@openfront/engine-api/game/ReadViews"; import { AllPlayersStats, ClientID, + GameID, Winner, } from "@openfront/engine-api/Schemas"; import { ATTACK_INDEX_SENT } from "@openfront/engine-api/StatsSchemas"; @@ -59,6 +63,8 @@ import { SharedWaterCache, SharedWaterCacheSnapshot, } from "../execution/nation/SharedWaterCache"; +import { NationExecution } from "../execution/NationExecution"; +import { TribeExecution } from "../execution/TribeExecution"; import { AbstractGraph } from "../pathfinding/algorithms/AbstractGraph"; import { WaterPathFinder } from "../pathfinding/PathFinder"; import { PathFinder } from "../pathfinding/types"; @@ -120,7 +126,7 @@ export type CellString = string; export class GameImpl implements Game { private _ticks = 0; - private startTick: number | null = null; + private _startTick: number | null = null; private unInitExecs: Execution[] = []; @@ -132,7 +138,7 @@ export class GameImpl implements Game { private _height: number; _terraNullius: TerraNulliusImpl; - allianceRequests: AllianceRequestImpl[] = []; + private _allianceRequests: AllianceRequestImpl[] = []; private nextPlayerID = 1; private _nextUnitID = 1; @@ -446,13 +452,13 @@ export class GameImpl implements Game { return null; } const ar = new AllianceRequestImpl(requestor, recipient, this._ticks, this); - this.allianceRequests.push(ar); + this._allianceRequests.push(ar); this.addUpdate(ar.toUpdate()); return ar; } acceptAllianceRequest(request: AllianceRequestImpl) { - this.allianceRequests = this.allianceRequests.filter( + this._allianceRequests = this._allianceRequests.filter( (ar) => ar !== request, ); @@ -490,7 +496,7 @@ export class GameImpl implements Game { } rejectAllianceRequest(request: AllianceRequestImpl) { - this.allianceRequests = this.allianceRequests.filter( + this._allianceRequests = this._allianceRequests.filter( (ar) => ar !== request, ); (request.requestor() as PlayerImpl).pastOutgoingAllianceRequests.push( @@ -519,18 +525,22 @@ export class GameImpl implements Game { this.addUpdate({ type: GameUpdateType.GamePaused, paused }); } + startTick(): Tick | null { + return this._startTick; + } + inSpawnPhase(): boolean { - return this.startTick === null; + return this._startTick === null; } endSpawnPhase(): void { - if (this.startTick !== null) { + if (this._startTick !== null) { return; } - this.startTick = this._ticks; + this._startTick = this._ticks; this.addUpdate({ type: GameUpdateType.SpawnPhaseEnd, - startTick: this.startTick, + startTick: this._startTick, }); } @@ -726,6 +736,95 @@ export class GameImpl implements Game { ); } + takeoverPlayer(player: Player | PlayerID, localClientID: ClientID): void { + const target = typeof player === "string" ? this.player(player) : player; + + const oldClientID = target.clientID(); + if (oldClientID !== null) { + this._stats.migrateClientID(oldClientID, localClientID); + } + + // Remove existing AI executions from chosenPlayer if it was previously an AI nation or tribe + for (const exec of this.executions()) { + if ( + (exec instanceof NationExecution || exec instanceof TribeExecution) && + exec.playerID() === target.id() + ) { + this.removeExecution(exec); + } + } + + // Update chosen player to be the local human + const chosenInfo = target.info(); + const updatedChosenInfo = new PlayerInfo( + chosenInfo.name, + PlayerType.Human, + localClientID, + chosenInfo.id, + chosenInfo.isLobbyCreator, + chosenInfo.clanTag, + chosenInfo.friends, + chosenInfo.teamIndex, + chosenInfo.nationFlag, + ); + target.setPlayerInfo(updatedChosenInfo); + target.markDisconnected(false); + + this._humans = this._humans.filter((h) => h.id !== target.id()); + this._humans.push(updatedChosenInfo); + this._nations = this._nations.filter( + (n) => n.playerInfo.id !== target.id(), + ); + } + + convertHumanToNation(player: Player | PlayerID, gameID: GameID): Execution { + const target = typeof player === "string" ? this.player(player) : player; + const pInfo = target.info(); + const updatedBotInfo = new PlayerInfo( + pInfo.name, + PlayerType.Nation, + null, + pInfo.id, + false, + pInfo.clanTag, + pInfo.friends, + pInfo.teamIndex, + pInfo.nationFlag, + ); + target.setPlayerInfo(updatedBotInfo); + target.markDisconnected(false); + + this._humans = this._humans.filter((h) => h.id !== target.id()); + this._nations = this._nations.filter( + (n) => n.playerInfo.id !== target.id(), + ); + const spawnTile = target.spawnTile(); + const spawnCell = + spawnTile !== undefined ? this.cell(spawnTile) : undefined; + const nation = new Nation(spawnCell, updatedBotInfo); + this._nations.push(nation); + + const nationExec = new NationExecution(gameID, nation); + this.addExecution(nationExec); + return nationExec; + } + + applySingleplayerConfig(difficulty?: Difficulty): void { + this.config().setGameType(GameType.Singleplayer); + if (difficulty !== undefined) { + this.config().setDifficulty(difficulty); + for (const exec of this.executions()) { + if ( + exec instanceof NationExecution && + exec.isActive() && + exec.isInitialized() + ) { + exec.refreshDifficulty(); + } + } + } + } + playerView(id: PlayerID): Player { return this.player(id); } @@ -945,6 +1044,10 @@ export class GameImpl implements Game { }); } + public allianceRequests(): AllianceRequest[] { + return this._allianceRequests; + } + public removeAlliancesByPlayerSilently(player: Player): void { // Snapshot — detachAlliance reassigns the player's _alliances as it goes. const removed = [...(player as PlayerImpl)._alliances]; @@ -994,7 +1097,7 @@ export class GameImpl implements Game { return 0; } - return Math.max(0, this.ticks() - this.startTick!); + return Math.max(0, this.ticks() - this._startTick!); } sendEmojiUpdate(msg: EmojiMessage): void { @@ -1453,18 +1556,18 @@ export class GameImpl implements Game { const unInitExecs = this.unInitExecs.map((e) => w.exec(e)); return { ticks: this._ticks, - startTick: this.startTick, + startTick: this._startTick, humans: this._humans.map(playerInfoData), nations: this._nations.map(nationData), players: [...this._players.values()].map((p) => w.player(p)), execs, unInitExecs, - allianceRequests: this.allianceRequests.map((r) => w.allianceRequest(r)), + allianceRequests: this._allianceRequests.map((r) => w.allianceRequest(r)), nextPlayerID: this.nextPlayerID, nextUnitID: this._nextUnitID, nextAllianceID: this.nextAllianceID, units: [...this._unitMap.values()].map((u) => w.unit(u)), - planDrivenUnitIds: [...this.planDrivenUnitIds], + planDrivenUnitIds: [], unitGrid: this.unitGrid.snapshot((u) => w.unit(u)), playerTeams: [...this.playerTeams], botTeam: this.botTeam, @@ -1505,10 +1608,10 @@ export class GameImpl implements Game { */ restoreState(s: GameState, r: SnapshotReader): void { this._ticks = s.ticks; - this.startTick = s.startTick; + this._startTick = s.startTick; this.execs = s.execs.map((i) => r.exec(i)); this.unInitExecs = s.unInitExecs.map((i) => r.exec(i)); - this.allianceRequests = s.allianceRequests.map((i) => + this._allianceRequests = s.allianceRequests.map((i) => r.allianceRequest(i), ); this.nextPlayerID = s.nextPlayerID; @@ -1520,7 +1623,7 @@ export class GameImpl implements Game { return [u.id(), u]; }), ); - this.planDrivenUnitIds = new Set(s.planDrivenUnitIds); + this.planDrivenUnitIds = new Set(); this.unitGrid.restoreSnapshot(s.unitGrid, (i) => r.unit(i)); this.playerTeams = [...s.playerTeams]; this.botTeam = s.botTeam; @@ -1675,14 +1778,3 @@ export const GameSnapshot = snapshotType({ }), }); export type GameState = z.infer; - -// Or a more dynamic approach that will catch new enum values: -const createGameUpdatesMap = (): GameUpdates => { - const map = {} as GameUpdates; - Object.values(GameUpdateType) - .filter((key) => !isNaN(Number(key))) // Filter out reverse mappings - .forEach((key) => { - map[key as GameUpdateType] = []; - }); - return map; -}; diff --git a/packages/engine/src/game/PlayerImpl.ts b/packages/engine/src/game/PlayerImpl.ts index 76ce6afa775..476f6b42c0a 100644 --- a/packages/engine/src/game/PlayerImpl.ts +++ b/packages/engine/src/game/PlayerImpl.ts @@ -241,11 +241,12 @@ export class PlayerImpl implements Player { toUpdate( statsOut?: number[], attackTroopsOut?: number[], + forceFull = false, ): PlayerUpdate | null { const full = this.toFullUpdate(); const prev = this.lastSentUpdate; this.lastSentUpdate = full; - if (prev === undefined) return full; + if (prev === undefined || forceFull) return full; if ( statsOut !== undefined && (prev.tilesOwned !== full.tilesOwned || @@ -290,7 +291,7 @@ export class PlayerImpl implements Player { // most players. The singletons are never mutated — updates are // structured-cloned before leaving the worker. let outgoingAllianceRequests = EMPTY_STRING_ARRAY; - for (const ar of this.mg.allianceRequests) { + for (const ar of this.mg.allianceRequests()) { if (ar.requestor() === this) { if (outgoingAllianceRequests === EMPTY_STRING_ARRAY) { outgoingAllianceRequests = []; @@ -729,6 +730,9 @@ export class PlayerImpl implements Player { info(): PlayerInfo { return this.playerInfo; } + setPlayerInfo(info: PlayerInfo): void { + this.playerInfo = info; + } isLobbyCreator(): boolean { return this.playerInfo.isLobbyCreator; @@ -751,11 +755,11 @@ export class PlayerImpl implements Player { } incomingAllianceRequests(): AllianceRequest[] { - return this.mg.allianceRequests.filter((ar) => ar.recipient() === this); + return this.mg.allianceRequests().filter((ar) => ar.recipient() === this); } outgoingAllianceRequests(): AllianceRequest[] { - return this.mg.allianceRequests.filter((ar) => ar.requestor() === this); + return this.mg.allianceRequests().filter((ar) => ar.requestor() === this); } alliances(): MutableAlliance[] { diff --git a/packages/engine/src/game/RailNetwork.ts b/packages/engine/src/game/RailNetwork.ts index e577214027b..1088f95d994 100644 --- a/packages/engine/src/game/RailNetwork.ts +++ b/packages/engine/src/game/RailNetwork.ts @@ -2,6 +2,7 @@ import { TileRef } from "@openfront/engine-api/game/GameMap"; import { UnitType } from "@openfront/engine-api/game/GameTypes"; import { Unit } from "./Game"; import { StationManager } from "./RailNetworkImpl"; +import { Railroad } from "./Railroad"; import { TrainStation } from "./TrainStation"; export interface RailNetwork { @@ -12,4 +13,5 @@ export interface RailNetwork { overlappingRailroads(unitType: UnitType, tile: TileRef): TileRef[]; computeGhostRailPaths(unitType: UnitType, tile: TileRef): TileRef[][]; recomputeClusters(): void; + railroads(): Railroad[]; } diff --git a/packages/engine/src/game/RailNetworkImpl.ts b/packages/engine/src/game/RailNetworkImpl.ts index 0e9fefd3a5e..ebc623c6d94 100644 --- a/packages/engine/src/game/RailNetworkImpl.ts +++ b/packages/engine/src/game/RailNetworkImpl.ts @@ -137,6 +137,16 @@ export class RailNetworkImpl implements RailNetwork { return this._stationManager; } + railroads(): Railroad[] { + const railroads = new Set(); + for (const station of this._stationManager.getAll()) { + for (const railroad of station.getRailroads()) { + railroads.add(railroad); + } + } + return Array.from(railroads); + } + connectStation(station: TrainStation) { this._stationManager.addStation(station); if (!this.connectToExistingRails(station)) { @@ -423,11 +433,7 @@ export class RailNetworkImpl implements RailNetwork { const path = this.pathService.findTilePath(from.tile(), to.tile()); if (path.length > 0 && path.length < this.game.config().railroadMaxSize()) { const railroad = new Railroad(from, to, path, this.nextId++); - this.game.addUpdate({ - type: GameUpdateType.RailroadConstructionEvent, - id: railroad.id, - tiles: railroad.tiles, - }); + this.game.addUpdate(railroad.toConstructionUpdate()); from.addRailroad(railroad); to.addRailroad(railroad); this.railGrid.register(railroad); diff --git a/packages/engine/src/game/Railroad.ts b/packages/engine/src/game/Railroad.ts index 9be4cd69c7f..b2d5d952885 100644 --- a/packages/engine/src/game/Railroad.ts +++ b/packages/engine/src/game/Railroad.ts @@ -1,5 +1,8 @@ import { TileRef } from "@openfront/engine-api/game/GameMap"; -import { GameUpdateType } from "@openfront/engine-api/game/GameUpdates"; +import { + GameUpdateType, + RailroadConstructionUpdate, +} from "@openfront/engine-api/game/GameUpdates"; import { snapshotType, zInt, @@ -22,6 +25,14 @@ export class Railroad { public id: number, ) {} + toConstructionUpdate(): RailroadConstructionUpdate { + return { + type: GameUpdateType.RailroadConstructionEvent, + id: this.id, + tiles: this.tiles, + }; + } + delete(game: Game) { game.addUpdate({ type: GameUpdateType.RailroadDestructionEvent, diff --git a/packages/engine/src/game/Stats.ts b/packages/engine/src/game/Stats.ts index 08dbc7f9272..812292ea1c7 100644 --- a/packages/engine/src/game/Stats.ts +++ b/packages/engine/src/game/Stats.ts @@ -12,6 +12,7 @@ import { Player } from "./Game"; export interface Stats { getPlayerStats(player: Player): PlayerStats | null; stats(): AllPlayersStats; + migrateClientID(oldClientID: ClientID, newClientID: ClientID): void; // Player attacks target attack( diff --git a/packages/engine/src/game/StatsImpl.ts b/packages/engine/src/game/StatsImpl.ts index 25cbf9f74e2..6d83ba286b7 100644 --- a/packages/engine/src/game/StatsImpl.ts +++ b/packages/engine/src/game/StatsImpl.ts @@ -93,6 +93,14 @@ export class StatsImpl implements Stats { return this.data; } + migrateClientID(oldClientID: ClientID, newClientID: ClientID): void { + if (oldClientID === newClientID) return; + if (oldClientID in this.data) { + this.data[newClientID] = this.data[oldClientID]; + delete this.data[oldClientID]; + } + } + private _makePlayerStats(player: Player): PlayerStats { const clientID = player.clientID(); if (clientID === null) return undefined; diff --git a/packages/engine/src/snapshot/GameSnapshot.ts b/packages/engine/src/snapshot/GameSnapshot.ts index 0fd0a27c528..2f9d61dde7f 100644 --- a/packages/engine/src/snapshot/GameSnapshot.ts +++ b/packages/engine/src/snapshot/GameSnapshot.ts @@ -13,9 +13,7 @@ import { SnapshotError, SnapshotType, Versioned, - VersionedSchema, } from "@openfront/engine-lib/snapshot/SnapshotType"; -import { z } from "zod"; import { EngineConfig } from "../configuration/EngineConfig"; import { AllianceImpl, AllianceSnapshot } from "../game/AllianceImpl"; import { @@ -37,7 +35,7 @@ import { import { UnitImpl, UnitSnapshot } from "../game/UnitImpl"; import { newCell, newPlayerInfo } from "./CommonSchemas"; import { EXECUTION_SNAPSHOT_TYPES } from "./ExecutionRegistry"; -import { decodeSnapshotValue, encodeSnapshotValue } from "./SnapshotCodec"; +import { encodeSnapshotValue } from "./SnapshotCodec"; import { ExecRecord, RefTable, @@ -45,42 +43,21 @@ import { SnapshotWriter, } from "./SnapshotContext"; -export const SNAPSHOT_MAGIC = "OpenFrontGameSnapshot"; - -/** - * Version of the root layout below. Everything inside it carries its own - * record version, so this only moves when the root itself changes. - */ -export const SNAPSHOT_FORMAT_VERSION = 1; - -const ExecRecordSchema = z.object({ - t: z.string(), - v: z.number().int(), - d: z.unknown(), -}); - -const RootSchema = z.object({ - magic: z.literal(SNAPSHOT_MAGIC), - format: z.number().int(), - /** Build that wrote the snapshot. Informational: any build can read it. */ - gitCommit: z.string(), - gameID: z.string().nullable(), - tick: z.number().int(), - gameConfig: z.unknown(), - game: VersionedSchema, - map: VersionedSchema, - miniMap: VersionedSchema, - players: z.array(VersionedSchema), - units: z.array(VersionedSchema), - attacks: z.array(VersionedSchema), - alliances: z.array(VersionedSchema), - allianceRequests: z.array(VersionedSchema), - stations: z.array(VersionedSchema), - railroads: z.array(VersionedSchema), - clusters: z.array(VersionedSchema), - execs: z.array(ExecRecordSchema), -}); -type Root = z.infer; +import { + decodeRoot, + type Root, + SNAPSHOT_FORMAT_VERSION, + SNAPSHOT_MAGIC, +} from "@openfront/engine-lib/snapshot/MapSnapshot"; +export { + decodeRoot, + readSnapshotHeader, + restoreMapsFromSnapshot, + SNAPSHOT_FORMAT_VERSION, + SNAPSHOT_MAGIC, + type RestoredMapsResult, + type SnapshotHeader, +} from "@openfront/engine-lib/snapshot/MapSnapshot"; const EXEC_TYPES = EXECUTION_SNAPSHOT_TYPES; const execTypes = new Map(EXEC_TYPES.map((t) => [t.name, t])); @@ -208,53 +185,6 @@ export function snapshotGameData(game: Game, opts: SnapshotOptions = {}): Root { }; } -export interface SnapshotHeader { - format: number; - gitCommit: string; - gameID: string | null; - tick: number; - gameConfig: GameConfig; -} - -function decodeRoot(bytes: Uint8Array): Root { - let raw: unknown; - try { - raw = decodeSnapshotValue(bytes); - } catch (e) { - throw new SnapshotError(`not a game snapshot: ${String(e)}`); - } - if ( - typeof raw !== "object" || - raw === null || - (raw as { magic?: unknown }).magic !== SNAPSHOT_MAGIC - ) { - throw new SnapshotError("not a game snapshot"); - } - const format = (raw as { format?: unknown }).format; - if (typeof format !== "number" || format > SNAPSHOT_FORMAT_VERSION) { - throw new SnapshotError( - `snapshot format ${String(format)} is newer than this build supports (${SNAPSHOT_FORMAT_VERSION})`, - ); - } - const root = RootSchema.safeParse(raw); - if (!root.success) { - throw new SnapshotError(`malformed snapshot: ${root.error.message}`); - } - return root.data; -} - -/** Reads what a restore needs to load first: the config and the map. */ -export function readSnapshotHeader(bytes: Uint8Array): SnapshotHeader { - const root = decodeRoot(bytes); - return { - format: root.format, - gitCommit: root.gitCommit, - gameID: root.gameID, - tick: root.tick, - gameConfig: GameConfigSchema.parse(root.gameConfig), - }; -} - export interface RestoreDeps { config: (gameConfig: GameConfig) => EngineConfig; /** Freshly loaded maps for header.gameConfig; they are mutated. */ diff --git a/packages/engine/src/snapshot/SnapshotCodec.ts b/packages/engine/src/snapshot/SnapshotCodec.ts index a0306297263..7f50b5e6f09 100644 --- a/packages/engine/src/snapshot/SnapshotCodec.ts +++ b/packages/engine/src/snapshot/SnapshotCodec.ts @@ -1,365 +1,5 @@ -import { ByteReader } from "@openfront/zbin"; - -/** - * Self-describing binary encoding for snapshot data. - * - * zbin is positional: a payload can only be read by the exact schema that - * wrote it. Snapshots must stay readable across builds, so they need a format - * that carries its own field names and types, letting an older record be - * decoded as plain data and then migrated. This codec encodes plain values - * (the shape JSON has, plus bigint, undefined, non-finite floats and typed - * arrays) with a tag byte per value. Uint32Arrays, which are tile lists, are - * delta-encoded. Strings, including object keys, are - * interned: the first occurrence is written inline and later ones are a - * varint back-reference, so repeated field names cost one or two bytes. - * - * Encoding is deterministic: equal values built in the same key order encode - * to identical bytes, which the snapshot tests rely on. - */ - -const enum Tag { - Undefined = 0, - Null = 1, - False = 2, - True = 3, - Uint = 4, - NegInt = 5, - Float = 6, - BigInt = 7, - NewString = 8, - StringRef = 9, - Array = 10, - Object = 11, - TypedArray = 12, - // Uint32Array as zigzag varint deltas. These are mostly tile lists in - // conquest order, which is spatially coherent, so most deltas take one or - // two bytes instead of four. - DeltaU32 = 13, -} - -type TypedArray = - | Int8Array - | Uint8Array - | Int16Array - | Uint16Array - | Int32Array - | Uint32Array - | Float32Array - | Float64Array; - -const TYPED_ARRAYS = [ - Int8Array, - Uint8Array, - Int16Array, - Uint16Array, - Int32Array, - Uint32Array, - Float32Array, - Float64Array, -] as const; - -// Typed arrays are written as their raw bytes. Every platform the game runs -// on is little-endian; fail loudly rather than write unreadable snapshots. -if (new Uint8Array(new Uint16Array([1]).buffer)[0] !== 1) { - throw new Error("snapshot codec requires a little-endian platform"); -} - -// Explicit mapping rather than indexing TYPED_ARRAYS with a stored value. -function typedArrayKind(kind: number): (typeof TYPED_ARRAYS)[number] { - switch (kind) { - case 0: - return Int8Array; - case 1: - return Uint8Array; - case 2: - return Int16Array; - case 3: - return Uint16Array; - case 4: - return Int32Array; - case 5: - return Uint32Array; - case 6: - return Float32Array; - case 7: - return Float64Array; - default: - throw new SnapshotCodecError(`unknown typed array kind ${kind}`); - } -} - -export class SnapshotCodecError extends Error { - override readonly name = "SnapshotCodecError"; -} - -const textEncoder = new TextEncoder(); - -// Growable writer. The varint, bigint and string layouts match zbin's -// ByteWriter so zbin's ByteReader decodes them; this one adds a bulk byte -// copy, which typed-array payloads (tile lists) need. -class SnapshotByteWriter { - private buf = new Uint8Array(1 << 16); - private view = new DataView(this.buf.buffer); - private pos = 0; - - private ensure(extra: number): void { - if (this.pos + extra <= this.buf.length) return; - let cap = this.buf.length * 2; - while (cap < this.pos + extra) cap *= 2; - const next = new Uint8Array(cap); - next.set(this.buf.subarray(0, this.pos)); - this.buf = next; - this.view = new DataView(next.buffer); - } - - u8(b: number): void { - this.ensure(1); - this.buf[this.pos++] = b; - } - - uint(n: number): void { - while (n >= 0x80) { - this.u8((n % 0x80) + 0x80); - n = Math.floor(n / 0x80); - } - this.u8(n); - } - - f64(n: number): void { - this.ensure(8); - this.view.setFloat64(this.pos, n, true); - this.pos += 8; - } - - // Zigzag LEB128, as zbin writes bigints. - bigint(n: bigint): void { - let u = n < 0n ? -2n * n - 1n : 2n * n; - while (u >= 0x80n) { - this.u8(Number(u & 0x7fn) | 0x80); - u >>= 7n; - } - this.u8(Number(u)); - } - - str(s: string): void { - const bytes = textEncoder.encode(s); - this.uint(bytes.length); - this.bytes(bytes); - } - - bytes(b: Uint8Array): void { - this.ensure(b.length); - this.buf.set(b, this.pos); - this.pos += b.length; - } - - finish(): Uint8Array { - return this.buf.slice(0, this.pos); - } -} - -export function encodeSnapshotValue(value: unknown): Uint8Array { - const w = new SnapshotByteWriter(); - const strings = new Map(); - - const writeString = (s: string) => { - const idx = strings.get(s); - if (idx !== undefined) { - w.u8(Tag.StringRef); - w.uint(idx); - return; - } - strings.set(s, strings.size); - w.u8(Tag.NewString); - w.str(s); - }; - - const write = (v: unknown, path: string): void => { - switch (typeof v) { - case "undefined": - w.u8(Tag.Undefined); - return; - case "boolean": - w.u8(v ? Tag.True : Tag.False); - return; - case "number": - if (Number.isSafeInteger(v) && !Object.is(v, -0)) { - if (v >= 0) { - w.u8(Tag.Uint); - w.uint(v); - } else { - w.u8(Tag.NegInt); - w.uint(-v - 1); - } - } else { - w.u8(Tag.Float); - w.f64(v); - } - return; - case "bigint": - w.u8(Tag.BigInt); - w.bigint(v); - return; - case "string": - writeString(v); - return; - case "object": { - if (v === null) { - w.u8(Tag.Null); - return; - } - if (Array.isArray(v)) { - w.u8(Tag.Array); - w.uint(v.length); - for (let i = 0; i < v.length; i++) write(v[i], path); - return; - } - if (v instanceof Uint32Array) { - w.u8(Tag.DeltaU32); - w.uint(v.length); - let prev = 0; - for (let i = 0; i < v.length; i++) { - const d = v[i] - prev; - w.uint(d < 0 ? -2 * d - 1 : 2 * d); - prev = v[i]; - } - return; - } - if (ArrayBuffer.isView(v)) { - const kind = TYPED_ARRAYS.findIndex( - (C) => (v as TypedArray).constructor === C, - ); - if (kind === -1) { - throw new SnapshotCodecError( - `${path}: unsupported binary view ${v.constructor.name}`, - ); - } - w.u8(Tag.TypedArray); - w.u8(kind); - w.uint(v.byteLength); - w.bytes(new Uint8Array(v.buffer, v.byteOffset, v.byteLength)); - return; - } - const proto = Object.getPrototypeOf(v); - if (proto !== Object.prototype && proto !== null) { - throw new SnapshotCodecError( - `${path}: snapshot data must be plain; got ${proto?.constructor?.name ?? "unknown"}`, - ); - } - const keys = Object.keys(v); - w.u8(Tag.Object); - w.uint(keys.length); - for (const k of keys) { - writeString(k); - write((v as Record)[k], `${path}.${k}`); - } - return; - } - default: - throw new SnapshotCodecError(`${path}: cannot encode a ${typeof v}`); - } - }; - - write(value, "$"); - return w.finish(); -} - -export function decodeSnapshotValue(bytes: Uint8Array): unknown { - const r = new ByteReader(bytes); - const strings: string[] = []; - - const readString = (tag: number): string => { - if (tag === Tag.NewString) { - const s = r.str(); - strings.push(s); - return s; - } - if (tag === Tag.StringRef) { - const idx = r.uint(); - if (idx >= strings.length) { - throw new SnapshotCodecError(`unknown string reference ${idx}`); - } - return strings[idx]; - } - throw new SnapshotCodecError(`expected a string, got tag ${tag}`); - }; - - const read = (): unknown => { - const tag = r.u8(); - switch (tag) { - case Tag.Undefined: - return undefined; - case Tag.Null: - return null; - case Tag.False: - return false; - case Tag.True: - return true; - case Tag.Uint: - return r.uint(); - case Tag.NegInt: - return -r.uint() - 1; - case Tag.Float: - return r.f64(); - case Tag.BigInt: - return r.bigint(); - case Tag.NewString: - case Tag.StringRef: - return readString(tag); - case Tag.Array: { - const n = r.uint(); - const out = new Array(n); - for (let i = 0; i < n; i++) out[i] = read(); - return out; - } - case Tag.Object: { - const n = r.uint(); - const out: Record = {}; - for (let i = 0; i < n; i++) { - const k = readString(r.u8()); - // Snapshots can be hand-edited or corrupt: a "__proto__" key would - // replace the object's prototype instead of adding a field. - if (k === "__proto__") { - throw new SnapshotCodecError("invalid object key __proto__"); - } - out[k] = read(); - } - return out; - } - case Tag.DeltaU32: { - const n = r.uint(); - if (n > r.remaining) { - throw new SnapshotCodecError("truncated tile list"); - } - const out = new Uint32Array(n); - let prev = 0; - for (let i = 0; i < n; i++) { - const z = r.uint(); - const v = prev + (z % 2 === 0 ? z / 2 : -(z + 1) / 2); - if (v < 0 || v > 0xffffffff) { - throw new SnapshotCodecError("tile list value out of range"); - } - out[i] = v; - prev = v; - } - return out; - } - case Tag.TypedArray: { - const C = typedArrayKind(r.u8()); - const byteLength = r.uint(); - if (byteLength % C.BYTES_PER_ELEMENT !== 0) { - throw new SnapshotCodecError(`misaligned ${C.name} payload`); - } - // Copy so the result is aligned and does not pin the input buffer. - const copy = r.readBytes(byteLength).slice(); - return new C(copy.buffer, 0, byteLength / C.BYTES_PER_ELEMENT); - } - default: - throw new SnapshotCodecError(`unknown tag ${tag}`); - } - }; - - const value = read(); - r.expectEnd(); - return value; -} +export { + SnapshotCodecError, + decodeSnapshotValue, + encodeSnapshotValue, +} from "@openfront/engine-lib/snapshot/SnapshotCodec"; diff --git a/packages/engine/src/snapshot/SnapshotExtractor.ts b/packages/engine/src/snapshot/SnapshotExtractor.ts new file mode 100644 index 00000000000..55ed6851c66 --- /dev/null +++ b/packages/engine/src/snapshot/SnapshotExtractor.ts @@ -0,0 +1,134 @@ +/** + * Extracts a playable singleplayer game snapshot from game start info and turns at a given tick. + * Converts other human players into AI bots (NationExecution) and binds the chosen player + * to the local clientID. + */ + +import { + Difficulty, + GameType, + PlayerID, + PlayerType, +} from "@openfront/engine-api/game/GameTypes"; +import { + ErrorUpdate, + GameUpdateViewData, +} from "@openfront/engine-api/game/GameUpdates"; +import { MapFiles } from "@openfront/engine-api/game/MapFiles"; +import { ClientID, GameStartInfo, Turn } from "@openfront/engine-api/Schemas"; +import { Player } from "../game/Game"; +import { createGameRunner } from "../GameRunner"; + +export interface ExtractSnapshotOptions { + gameStartInfo: GameStartInfo; + turns: Turn[]; + mapFiles: MapFiles; + targetTick: number; + chosenPlayerID: PlayerID; + localClientID: ClientID; + difficulty?: Difficulty; + newGameID?: string; +} + +export interface ExtractedSnapshotResult { + snapshot: Uint8Array; + gameStartInfo: GameStartInfo; + tick: number; +} + +/** + * Re-runs turns headless up to targetTick, configures the chosen player + * as the local human player and converts all other players to AI bots, then captures + * a snapshot ready for singleplayer game resumption. + */ +export async function extractSnapshot( + opts: ExtractSnapshotOptions, +): Promise { + const gameStart = opts.gameStartInfo; + let tickError: string | undefined; + + const runner = await createGameRunner( + gameStart, + undefined, + opts.mapFiles, + (gu: GameUpdateViewData | ErrorUpdate) => { + if ("errMsg" in gu) { + tickError = `${gu.errMsg}\n${gu.stack ?? ""}`; + } + }, + ); + + const game = runner.game; + const target = Math.max(0, opts.targetTick); + + for (let i = 0; i < opts.turns.length; i++) { + const turn = opts.turns[i]; + if (turn.turnNumber >= target) break; + runner.addTurn(turn); + if (!runner.executeNextTick() || tickError !== undefined) { + throw new Error( + `simulation failed at turn ${turn.turnNumber}: ${tickError ?? "tick did not execute"}`, + ); + } + if (game.ticks() >= target) break; + } + + const chosenPlayer = + (game.hasPlayer(opts.chosenPlayerID) + ? game.player(opts.chosenPlayerID) + : null) ?? + game.playerByClientID(opts.chosenPlayerID) ?? + game.players().find((p: Player) => p.name() === opts.chosenPlayerID) ?? + null; + if (!chosenPlayer) { + throw new Error(`Player with ID ${opts.chosenPlayerID} not found`); + } + + const originalClientID = chosenPlayer.clientID(); + + // Take over the chosen player as the local human + game.takeoverPlayer(chosenPlayer, opts.localClientID); + + // Convert other players to Nation bots + for (const p of game.allPlayers()) { + if (p.id() === chosenPlayer.id()) continue; + if (!game.inSpawnPhase() && !p.isAlive()) continue; + + if (p.type() === PlayerType.Human) { + game.convertHumanToNation(p, gameStart.gameID); + } + } + + // Update game configuration + game.applySingleplayerConfig(opts.difficulty); + + const snapshot = runner.snapshot(); + + const originalPlayer = + originalClientID !== null + ? gameStart.players.find((p) => p.clientID === originalClientID) + : undefined; + + const singlePlayerGameStart: GameStartInfo = { + ...gameStart, + gameID: opts.newGameID ?? gameStart.gameID, + players: [ + { + ...(originalPlayer ?? {}), + clientID: opts.localClientID, + username: chosenPlayer.name(), + clanTag: chosenPlayer.clanTag(), + }, + ], + config: { + ...game.config().gameConfig(), + gameType: GameType.Singleplayer, + }, + }; + + return { + snapshot, + gameStartInfo: singlePlayerGameStart, + tick: game.ticks(), + }; +} diff --git a/packages/engine/src/worker/Worker.worker.ts b/packages/engine/src/worker/Worker.worker.ts index b2d6e46f85a..42cfc859cfd 100644 --- a/packages/engine/src/worker/Worker.worker.ts +++ b/packages/engine/src/worker/Worker.worker.ts @@ -4,6 +4,8 @@ import { } from "@openfront/engine-api/game/GameUpdates"; import { AttackClusteredPositionsResultMessage, + ExtractSnapshotErrorMessage, + ExtractSnapshotResultMessage, HumanStatsResultMessage, InitErrorMessage, InitializedMessage, @@ -23,6 +25,7 @@ import { createGameRunnerFromSnapshot, GameRunner, } from "../GameRunner"; +import { extractSnapshot } from "../snapshot/SnapshotExtractor"; const ctx: Worker = self as any; // Where answers go: the page that started this worker, or the port it @@ -162,7 +165,7 @@ async function onMessage(e: MessageEvent) { break; case "init": try { - gameRunner = ( + const initPromise = message.snapshot !== undefined ? createGameRunnerFromSnapshot( message.gameStartInfo, @@ -170,30 +173,36 @@ async function onMessage(e: MessageEvent) { message.clientID, message.map, gameUpdate, - ) + ).then((gr) => { + const initialUpdate = gr.snapshotViewData(); + sendMessage({ + type: "initialized", + id: message.id, + initialUpdate, + } as InitializedMessage); + return gr; + }) : createGameRunner( message.gameStartInfo, message.clientID, message.map, gameUpdate, - ) - ).then( - (gr) => { - sendMessage({ - type: "initialized", - id: message.id, - } as InitializedMessage); - return gr; - }, - (error: unknown) => { - sendMessage({ - type: "init_error", - id: message.id, - error: error instanceof Error ? error.message : String(error), - } as InitErrorMessage); - throw error; - }, - ); + ).then((gr) => { + sendMessage({ + type: "initialized", + id: message.id, + } as InitializedMessage); + return gr; + }); + + gameRunner = initPromise.catch((error: unknown) => { + sendMessage({ + type: "init_error", + id: message.id, + error: error instanceof Error ? error.message : String(error), + } as InitErrorMessage); + throw error; + }); // The failure is reported above; later messages still see it when // they await gameRunner. gameRunner.catch(() => {}); @@ -403,10 +412,13 @@ async function onMessage(e: MessageEvent) { throw new Error("Game runner not initialized"); } let snapshot: Uint8Array | null = null; + let tick = 0; try { // Messages are handled between drain batches, so this is always a // tick boundary. - snapshot = (await gameRunner).snapshot(message.gitCommit); + const runner = await gameRunner; + snapshot = runner.snapshot(message.gitCommit); + tick = runner.game?.ticks() ?? 0; } catch (error) { console.error("Failed to snapshot game:", error); } @@ -415,11 +427,44 @@ async function onMessage(e: MessageEvent) { type: "snapshot_result", id: message.id, snapshot, + tick, } as SnapshotResultMessage, snapshot ? [snapshot.buffer] : [], ); break; } + case "extract_snapshot": { + try { + const result = await extractSnapshot({ + gameStartInfo: message.gameStartInfo, + turns: message.turns, + mapFiles: message.map, + targetTick: message.targetTick, + chosenPlayerID: message.chosenPlayerID, + localClientID: message.localClientID, + difficulty: message.difficulty, + newGameID: message.newGameID, + }); + out.postMessage( + { + type: "extract_snapshot_result", + id: message.id, + snapshot: result.snapshot, + gameStartInfo: result.gameStartInfo, + tick: result.tick, + } as ExtractSnapshotResultMessage, + [result.snapshot.buffer], + ); + } catch (error) { + console.error("Failed to extract snapshot:", error); + out.postMessage({ + type: "extract_snapshot_error", + id: message.id, + error: error instanceof Error ? error.message : String(error), + } as ExtractSnapshotErrorMessage); + } + break; + } default: console.warn("Unknown message :", message); } diff --git a/records/demoGame1.json b/records/demoGame1.json new file mode 100644 index 00000000000..1538fa479cf --- /dev/null +++ b/records/demoGame1.json @@ -0,0 +1,194 @@ +{ + "info": { + "gameID": "demoGame1", + "lobbyCreatedAt": 1700000000000, + "config": { + "gameMap": "Pangaea", + "difficulty": "Medium", + "donateGold": false, + "donateTroops": false, + "gameType": "Public", + "gameMode": "Free For All", + "gameMapSize": "Normal", + "nations": "default", + "bots": 30, + "infiniteGold": false, + "infiniteTroops": false, + "instantBuild": false, + "randomSpawn": false, + "disabledUnits": [] + }, + "players": [ + { + "clientID": "client001", + "username": "Human 1", + "clanTag": null, + "persistentID": null, + "stats": {} + } + ], + "start": 1700000000000, + "end": 1700000150000, + "duration": 150, + "num_turns": 1500, + "lobbyFillTime": 0 + }, + "version": "v0.0.2", + "gitCommit": "4444c9c536db3963a49c7834f692544eb5bbd144", + "turns": [ + { "turnNumber": 0, "intents": [], "hash": 661738854843751 }, + { + "turnNumber": 3, + "intents": [{ "type": "spawn", "tile": 250110, "clientID": "client001" }] + }, + { "turnNumber": 10, "intents": [], "hash": 979744053186101 }, + { "turnNumber": 20, "intents": [], "hash": 979755661439141 }, + { "turnNumber": 30, "intents": [], "hash": 979782976096423 }, + { "turnNumber": 40, "intents": [], "hash": 979846569464713 }, + { "turnNumber": 50, "intents": [], "hash": 979802623436904 }, + { "turnNumber": 60, "intents": [], "hash": 979808775909890 }, + { "turnNumber": 70, "intents": [], "hash": 979801709075832 }, + { "turnNumber": 80, "intents": [], "hash": 979801709075832 }, + { "turnNumber": 90, "intents": [], "hash": 979795763006004 }, + { "turnNumber": 100, "intents": [], "hash": 979786991132901 }, + { "turnNumber": 110, "intents": [], "hash": 979786991132901 }, + { "turnNumber": 120, "intents": [], "hash": 979848051180142 }, + { "turnNumber": 130, "intents": [], "hash": 979846742492503 }, + { "turnNumber": 140, "intents": [], "hash": 979846742492503 }, + { "turnNumber": 150, "intents": [], "hash": 979861786212628 }, + { "turnNumber": 160, "intents": [], "hash": 979865185287498 }, + { "turnNumber": 170, "intents": [], "hash": 979853441393558 }, + { "turnNumber": 180, "intents": [], "hash": 979849715002858 }, + { "turnNumber": 190, "intents": [], "hash": 979861483098858 }, + { "turnNumber": 200, "intents": [], "hash": 979854961706330 }, + { "turnNumber": 210, "intents": [], "hash": 708565018766447 }, + { "turnNumber": 220, "intents": [], "hash": 788703663292922 }, + { "turnNumber": 230, "intents": [], "hash": 885032343023981 }, + { "turnNumber": 240, "intents": [], "hash": 989567314304254 }, + { "turnNumber": 250, "intents": [], "hash": 1049678532268691 }, + { "turnNumber": 260, "intents": [], "hash": 1141446910643349 }, + { "turnNumber": 270, "intents": [], "hash": 1196637286882848 }, + { "turnNumber": 280, "intents": [], "hash": 1270947904185647 }, + { "turnNumber": 290, "intents": [], "hash": 1411479989130105 }, + { "turnNumber": 300, "intents": [], "hash": 1466766935646787 }, + { "turnNumber": 310, "intents": [], "hash": 1522715357256500 }, + { "turnNumber": 320, "intents": [], "hash": 1520102904615288 }, + { "turnNumber": 330, "intents": [], "hash": 1569269404191656 }, + { "turnNumber": 340, "intents": [], "hash": 1644382851485377 }, + { "turnNumber": 350, "intents": [], "hash": 1779435232148780 }, + { "turnNumber": 360, "intents": [], "hash": 1828442042478049 }, + { "turnNumber": 370, "intents": [], "hash": 1930923370985862 }, + { "turnNumber": 380, "intents": [], "hash": 1978726806048151 }, + { "turnNumber": 390, "intents": [], "hash": 2014223992005415 }, + { "turnNumber": 400, "intents": [], "hash": 2030450315333679 }, + { "turnNumber": 410, "intents": [], "hash": 2178495614335767 }, + { "turnNumber": 420, "intents": [], "hash": 2223093601975559 }, + { "turnNumber": 430, "intents": [], "hash": 2311593751429310 }, + { "turnNumber": 440, "intents": [], "hash": 2348358669427099 }, + { "turnNumber": 450, "intents": [], "hash": 2391799277183050 }, + { "turnNumber": 460, "intents": [], "hash": 2520062296989461 }, + { "turnNumber": 470, "intents": [], "hash": 2603193386723026 }, + { "turnNumber": 480, "intents": [], "hash": 2743195187736331 }, + { "turnNumber": 490, "intents": [], "hash": 2884309872447608 }, + { "turnNumber": 500, "intents": [], "hash": 2808455520755385 }, + { "turnNumber": 510, "intents": [], "hash": 2923187090410560 }, + { "turnNumber": 520, "intents": [], "hash": 2950641323104353 }, + { "turnNumber": 530, "intents": [], "hash": 3023821322682898 }, + { "turnNumber": 540, "intents": [], "hash": 3088669572276838 }, + { "turnNumber": 550, "intents": [], "hash": 3210418587986655 }, + { "turnNumber": 560, "intents": [], "hash": 3187868551765715 }, + { "turnNumber": 570, "intents": [], "hash": 3105849810590125 }, + { "turnNumber": 580, "intents": [], "hash": 3321732370727043 }, + { "turnNumber": 590, "intents": [], "hash": 3395087567051625 }, + { "turnNumber": 600, "intents": [], "hash": 3458181953221399 }, + { "turnNumber": 610, "intents": [], "hash": 3562801360505781 }, + { "turnNumber": 620, "intents": [], "hash": 3626181201223017 }, + { "turnNumber": 630, "intents": [], "hash": 3762994625753859 }, + { "turnNumber": 640, "intents": [], "hash": 3740235320741279 }, + { "turnNumber": 650, "intents": [], "hash": 3677023048254163 }, + { "turnNumber": 660, "intents": [], "hash": 3782842544449231 }, + { "turnNumber": 670, "intents": [], "hash": 3923729600332584 }, + { "turnNumber": 680, "intents": [], "hash": 3971924310375834 }, + { "turnNumber": 690, "intents": [], "hash": 4195253078369844 }, + { "turnNumber": 700, "intents": [], "hash": 4343949142343602 }, + { "turnNumber": 710, "intents": [], "hash": 4272707732428068 }, + { "turnNumber": 720, "intents": [], "hash": 4337658585446846 }, + { "turnNumber": 730, "intents": [], "hash": 4456687409295867 }, + { "turnNumber": 740, "intents": [], "hash": 4613597451696815 }, + { "turnNumber": 750, "intents": [], "hash": 4821733134623300 }, + { "turnNumber": 760, "intents": [], "hash": 5070888082593095 }, + { "turnNumber": 770, "intents": [], "hash": 5306436366755036 }, + { "turnNumber": 780, "intents": [], "hash": 5822548676217325 }, + { "turnNumber": 790, "intents": [], "hash": 5848043956272614 }, + { "turnNumber": 800, "intents": [], "hash": 6016047268805467 }, + { "turnNumber": 810, "intents": [], "hash": 6132870795633865 }, + { "turnNumber": 820, "intents": [], "hash": 6432944879336370 }, + { "turnNumber": 830, "intents": [], "hash": 6893782375485643 }, + { "turnNumber": 840, "intents": [], "hash": 6947145585693693 }, + { "turnNumber": 850, "intents": [], "hash": 7267469917671498 }, + { "turnNumber": 860, "intents": [], "hash": 7640753677982693 }, + { "turnNumber": 870, "intents": [], "hash": 7986557078494746 }, + { "turnNumber": 880, "intents": [], "hash": 8149985082581424 }, + { "turnNumber": 890, "intents": [], "hash": 8571026911054290 }, + { "turnNumber": 900, "intents": [], "hash": 8765706853722768 }, + { "turnNumber": 910, "intents": [], "hash": 8983520574778700 }, + { "turnNumber": 920, "intents": [], "hash": 9079742244892768 }, + { "turnNumber": 930, "intents": [], "hash": 8776427373602511 }, + { "turnNumber": 940, "intents": [], "hash": 8457038936519480 }, + { "turnNumber": 950, "intents": [], "hash": 8825545204119154 }, + { "turnNumber": 960, "intents": [], "hash": 8921240723804071 }, + { "turnNumber": 970, "intents": [], "hash": 9178178781473836 }, + { "turnNumber": 980, "intents": [], "hash": 9341508367210152 }, + { "turnNumber": 990, "intents": [], "hash": 9495474989667756 }, + { "turnNumber": 1000, "intents": [], "hash": 9774002822051740 }, + { "turnNumber": 1010, "intents": [], "hash": 10049848756192880 }, + { "turnNumber": 1020, "intents": [], "hash": 10228744891524240 }, + { "turnNumber": 1030, "intents": [], "hash": 10481851117963992 }, + { "turnNumber": 1040, "intents": [], "hash": 10607582515654344 }, + { "turnNumber": 1050, "intents": [], "hash": 10323850907390844 }, + { "turnNumber": 1060, "intents": [], "hash": 9935564069387088 }, + { "turnNumber": 1070, "intents": [], "hash": 10154145171161328 }, + { "turnNumber": 1080, "intents": [], "hash": 10379433323111380 }, + { "turnNumber": 1090, "intents": [], "hash": 10696999243875516 }, + { "turnNumber": 1100, "intents": [], "hash": 11019536153854080 }, + { "turnNumber": 1110, "intents": [], "hash": 11017842887923432 }, + { "turnNumber": 1120, "intents": [], "hash": 10422997445968082 }, + { "turnNumber": 1130, "intents": [], "hash": 10020450444129168 }, + { "turnNumber": 1140, "intents": [], "hash": 10613476332940580 }, + { "turnNumber": 1150, "intents": [], "hash": 11121107890342744 }, + { "turnNumber": 1160, "intents": [], "hash": 11117515687806624 }, + { "turnNumber": 1170, "intents": [], "hash": 11139972835704036 }, + { "turnNumber": 1180, "intents": [], "hash": 11196278068440312 }, + { "turnNumber": 1190, "intents": [], "hash": 10815536998892672 }, + { "turnNumber": 1200, "intents": [], "hash": 10911020460485856 }, + { "turnNumber": 1210, "intents": [], "hash": 10946552779522228 }, + { "turnNumber": 1220, "intents": [], "hash": 11483345630378932 }, + { "turnNumber": 1230, "intents": [], "hash": 11262120563309924 }, + { "turnNumber": 1240, "intents": [], "hash": 11381259662580660 }, + { "turnNumber": 1250, "intents": [], "hash": 11420214509920356 }, + { "turnNumber": 1260, "intents": [], "hash": 11379231458165772 }, + { "turnNumber": 1270, "intents": [], "hash": 11137180441404356 }, + { "turnNumber": 1280, "intents": [], "hash": 11386188902402320 }, + { "turnNumber": 1290, "intents": [], "hash": 11575479635617664 }, + { "turnNumber": 1300, "intents": [], "hash": 10831495032221372 }, + { "turnNumber": 1310, "intents": [], "hash": 11534616480844620 }, + { "turnNumber": 1320, "intents": [], "hash": 11181970285670440 }, + { "turnNumber": 1330, "intents": [], "hash": 11511097259583796 }, + { "turnNumber": 1340, "intents": [], "hash": 11894265851564144 }, + { "turnNumber": 1350, "intents": [], "hash": 11843381552083876 }, + { "turnNumber": 1360, "intents": [], "hash": 11954603400173436 }, + { "turnNumber": 1370, "intents": [], "hash": 12462764612283796 }, + { "turnNumber": 1380, "intents": [], "hash": 11940934044064884 }, + { "turnNumber": 1390, "intents": [], "hash": 11110634599549308 }, + { "turnNumber": 1400, "intents": [], "hash": 11278379983358192 }, + { "turnNumber": 1410, "intents": [], "hash": 11424741498846028 }, + { "turnNumber": 1420, "intents": [], "hash": 11392335381818372 }, + { "turnNumber": 1430, "intents": [], "hash": 11686971399865128 }, + { "turnNumber": 1440, "intents": [], "hash": 11905641925490964 }, + { "turnNumber": 1450, "intents": [], "hash": 11963061136832512 }, + { "turnNumber": 1460, "intents": [], "hash": 11047421317781652 }, + { "turnNumber": 1470, "intents": [], "hash": 11263121034697136 }, + { "turnNumber": 1480, "intents": [], "hash": 11836858141367428 }, + { "turnNumber": 1490, "intents": [], "hash": 11943269482533360 } + ] +} diff --git a/resources/lang/en.json b/resources/lang/en.json index 881b2734e3a..2f2e48f6f01 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -2114,15 +2114,28 @@ "replay_speed": "Replay speed" }, "replay_viewer": { + "bot_difficulty": "Bot Difficulty", + "cancel": "Cancel", + "continue_from_here": "Continue Game from Here", + "continue_modal_desc": "Take over and continue playing as a singleplayer match from this moment.", + "continue_modal_title": "Play from this Time", "following": "Following {name}", + "game_time": "Game Time", "load_failed": "This replay could not be loaded.", "loading": "Loading replay…", + "no_players_found": "No players found", "partial": "Only the first {time} of this game can be watched.", "pause": "Pause", "play": "Play", + "play_now": "Play Now", + "player_stats_control": "{percent} map control", + "player_stats_troops": "{troops} troops", + "preparing_game": "Preparing game state…", "processing": "Preparing this replay…", "processing_fetching": "Fetching the game record", "processing_simulating": "Replaying the game: {percent}%", + "search_players": "Search players…", + "select_player": "Choose Player to Control", "timeline": "Replay timeline", "unavailable_desync": "This game doesn't replay correctly on this version of OpenFront.", "unavailable_failed": "Processing this game failed.", diff --git a/scripts/replay/record-demo.mts b/scripts/replay/record-demo.mts index 9a0376ef892..70b5384b782 100644 --- a/scripts/replay/record-demo.mts +++ b/scripts/replay/record-demo.mts @@ -18,8 +18,9 @@ import { spawnOnLand, } from "../../tests/client/replay/util/ArchiveGame"; -const [out, ticks = "1500", gameID = "demoGame1"] = process.argv.slice(2); -if (out === undefined) { +const [out, ticksArg = "1500", gameID = "demoGame1"] = process.argv.slice(2); +const ticks = Number(ticksArg); +if (out === undefined || !Number.isInteger(ticks) || ticks <= 0) { console.error("usage: record-demo.mts [ticks] [gameID]"); process.exit(1); } @@ -32,7 +33,7 @@ const { record } = await playAndArchive({ gameID, config: config({ bots: 30 }), players: [human(1)], - ticks: Number(ticks), + ticks, intents: (game, t) => t === 3 ? [spawnOnLand(game, "client001", 250_000)] : [], }); diff --git a/scripts/replay/stub-game-api.mts b/scripts/replay/stub-game-api.mts index 5e09cb4cf4b..76293e7af8f 100644 --- a/scripts/replay/stub-game-api.mts +++ b/scripts/replay/stub-game-api.mts @@ -14,18 +14,17 @@ import fs from "fs"; import http from "http"; import path from "path"; -const dir: string | undefined = process.argv[2]; -const port = Number(process.argv[3] ?? 8788); +const dir: string = process.argv[2] ?? "records"; +const port = Number(process.argv[3] ?? 8787); const host = process.argv[4] ?? "127.0.0.1"; -if (dir === undefined) { - console.error("usage: stub-game-api.mts [port] [host]"); - process.exit(1); +if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); } http .createServer((req, res) => { // The dev client fetches records from another origin (localhost:9000). - res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Origin", "http://localhost:9000"); // The client-side replay's fetch (JoinLobbyModal) sends Content-Type, // so the browser asks first. Without this answer it blocks the fetch. if (req.method === "OPTIONS") { diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index a25226ffd24..ed08df0926f 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -14,9 +14,13 @@ import { } from "@openfront/engine-api/game/GameUpdates"; import { findClosestBy } from "@openfront/engine-lib/Util"; import { Config } from "@openfront/engine-lib/configuration/Config"; -import { TerrainMapData } from "@openfront/engine-lib/game/TerrainMapLoader"; +import { + loadTerrainMap, + TerrainMapData, +} from "@openfront/engine-lib/game/TerrainMapLoader"; +import { restoreMapsFromSnapshot } from "@openfront/engine-lib/snapshot/MapSnapshot"; import { EventBus } from "@openfront/shared/EventBus"; -import { GameMapLoader } from "@openfront/shared/GameMapLoader"; +import { GameMapLoader, loadMapFiles } from "@openfront/shared/GameMapLoader"; import { replacer } from "@openfront/shared/SharedUtil"; import { GameRecord, @@ -115,6 +119,9 @@ export interface LobbyConfig { gameRecord?: GameRecord; // Watch without playing. spectator?: boolean; + // Saved snapshot and tick when resuming a solo match. + resumeSnapshot?: Uint8Array; + resumeTick?: number; // Host only: the play token the lobby was created under, used for the // first join so the host joins as the creator (see createLobby). creatorToken?: string; @@ -675,7 +682,7 @@ function mountWebGLFrameLoop( return { builder, stopFrameLoop }; } -async function createClientGame( +export async function createClientGame( lobbyConfig: LobbyConfig, clientID: ClientID | undefined, eventBus: EventBus, @@ -695,7 +702,15 @@ async function createClientGame( ); let gameMap: TerrainMapData; - if (terrainLoad) { + if (lobbyConfig.resumeSnapshot) { + gameMap = await loadTerrainMap( + await loadMapFiles( + mapLoader, + lobbyConfig.gameStartInfo.config.gameMap, + lobbyConfig.gameStartInfo.config.gameMapSize, + ), + ); + } else if (terrainLoad) { gameMap = await terrainLoad; } else { gameMap = await loadCachedTerrainMap( @@ -706,9 +721,30 @@ async function createClientGame( // Kick off the font-atlas fetch so it overlaps with worker init; the // render passes need it parsed before createWebGLView runs. const atlasDataLoad = preloadAtlasData(); - const worker = new WorkerClient(lobbyConfig.gameStartInfo, clientID); + const worker = new WorkerClient( + lobbyConfig.gameStartInfo, + clientID, + lobbyConfig.resumeSnapshot, + ); await worker.initialize(); await atlasDataLoad; + let initialStartTick: number | null = null; + if (lobbyConfig.resumeSnapshot) { + try { + const restored = restoreMapsFromSnapshot( + lobbyConfig.resumeSnapshot, + gameMap.gameMap, + gameMap.miniGameMap, + ); + initialStartTick = restored.startTick; + lobbyConfig.resumeTick = restored.tick; + } catch (e) { + worker.cleanup(); + console.warn("Failed to restore maps from snapshot", e); + throw e; + } + } + const gameView = new GameView( worker, config, @@ -718,6 +754,7 @@ async function createClientGame( lobbyConfig.playerClanTag, lobbyConfig.gameStartInfo.gameID, lobbyConfig.gameStartInfo.players, + initialStartTick, ); // Transparent fullscreen overlay used purely as the pointer-event / @@ -951,6 +988,9 @@ export class ClientGameRunner { private metrics: GameMetrics | null = null, ) { this.lastMessageTime = Date.now(); + if (this.lobby.resumeTick !== undefined) { + this.turnsSeen = this.lobby.resumeTick; + } } /** @@ -1012,6 +1052,7 @@ export class ClientGameRunner { this.renderer.initialize(); this.input.initialize(); + let isInitialUpdate = Boolean(this.worker.initialUpdate); this.worker.start((gu: GameUpdateViewData | ErrorUpdate) => { if (this.lobby.gameStartInfo === undefined) { throw new Error("missing gameStartInfo"); @@ -1027,7 +1068,11 @@ export class ClientGameRunner { this.stop(); return; } - this.transport.turnComplete(); + if (isInitialUpdate) { + isInitialUpdate = false; + } else { + this.transport.turnComplete(); + } gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => { this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash)); }); diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index b392123ae44..f48f3fe6b79 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -19,10 +19,10 @@ import { ServerMessage, ServerStartGameMessage, } from "@openfront/shared/WireSchemas"; -import { ClientEnv } from "src/client/ClientEnv"; import { z } from "zod"; import { getApiBase } from "./Api"; import { getAuthHeader, getPersistentID } from "./Auth"; +import { ClientEnv } from "./ClientEnv"; import { LobbyConfig } from "./ClientGameRunner"; import { GameSpeedDownIntentEvent, @@ -51,6 +51,7 @@ export class LocalServer { private replayTurns: Turn[] = []; private turns: Turn[] = []; + private turnOffset = 0; private intents: StampedIntent[] = []; private startedAt: number; @@ -149,10 +150,13 @@ export class LocalServer { if (!this.clientID) { throw new Error("missing clientID"); } + if (this.lobbyConfig.resumeTick !== undefined) { + this.turnOffset = this.lobbyConfig.resumeTick; + } this.clientMessage({ type: "start", gameStartInfo: this.lobbyConfig.gameStartInfo, - turns: [], + turns: this.turns, lobbyCreatedAt: this.lobbyConfig.gameStartInfo.lobbyCreatedAt, // Don't send myClientID for replays — viewer has no player identity. myClientID: this.lobbyConfig.gameRecord ? undefined : this.clientID, @@ -209,7 +213,7 @@ export class LocalServer { if (!this.lobbyConfig.gameRecord) { if (clientMsg.turnNumber % 100 === 0) { // In singleplayer, only store hash every 100 turns to reduce size of game record. - const turn = this.turns[clientMsg.turnNumber]; + const turn = this.turns[clientMsg.turnNumber - this.turnOffset]; if (turn) { turn.hash = clientMsg.hash; } @@ -273,7 +277,7 @@ export class LocalServer { this.intents = this.replayTurns[this.turns.length].intents; } const pastTurn: Turn = { - turnNumber: this.turns.length, + turnNumber: this.turnOffset + this.turns.length, intents: this.intents, }; this.turns.push(pastTurn); @@ -298,7 +302,11 @@ export class LocalServer { } private archiveGameRecord(unloading: boolean) { - if (this.archived || this.archiveInFlight) { + if ( + this.archived || + this.archiveInFlight || + this.lobbyConfig.resumeSnapshot !== undefined + ) { return; } const players: PlayerRecord[] = [ diff --git a/src/client/Main.ts b/src/client/Main.ts index 8952b261bc5..dddd990b588 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -254,6 +254,8 @@ export interface JoinLobbyEvent { publicLobbyInfo?: GameInfo | PublicGameInfo; // Watch without playing. spectator?: boolean; + resumeSnapshot?: Uint8Array; + resumeTick?: number; // Host only: the play token the lobby was created under (see createLobby). creatorToken?: string; } @@ -291,6 +293,7 @@ class Client { private lobbyHandle: JoinLobbyResult | null = null; /** The game the replay viewer is showing, once it has replaced the menu. */ private replayViewerID: string | null = null; + private replayViewerOpeningToken = 0; private eventBus: EventBus = new EventBus(); private currentUrl: string | null = null; @@ -1075,9 +1078,8 @@ class Client { this.leaveReplayViewer(); return; } - // Checked before the join modal is closed below: closing it resets - // the URL, which would drop the hash before handleUrl reads it. - const replayViewerID = parseReplayViewerHash(window.location.hash); + const replayHash = window.location.hash; + const replayViewerID = parseReplayViewerHash(replayHash); if (replayViewerID !== null) { // Not over a game that's running, a lobby being waited in, or a join // still on its way to one: the viewer would open on top of it. Drop @@ -1269,18 +1271,22 @@ class Client { // get here (CrazyGames awaits its SDK first). Only the first opens it. if (this.replayViewerID !== null) return; this.replayViewerID = gameID; + const token = ++this.replayViewerOpeningToken; let ReplayViewer: typeof import("./replay/ReplayViewer").ReplayViewer; try { ({ ReplayViewer } = await import("./replay/ReplayViewer")); } catch (err) { + if (this.replayViewerOpeningToken !== token) return; // The viewer's chunk didn't load (a network error, or a deploy that // replaced it). The menu is still up, so fall back to the client-side // replay. It's a full page load, which also picks up a new deploy. console.error("replay viewer failed to load:", err); this.replayViewerID = null; + this.replayViewerOpeningToken++; window.location.assign(classicReplayHref(gameID)); return; } + if (this.replayViewerOpeningToken !== token) return; // A join may have started while the chunk loaded. if (this.gameUnderway()) { console.warn("replay viewer: ignored, a game started"); @@ -1331,6 +1337,7 @@ class Client { // Opening the viewer fires both popstate and hashchange, and the first // one opens it. The second still points at the open replay. if (gameID === this.replayViewerID) return; + this.replayViewerOpeningToken++; if (gameID !== null) { window.location.reload(); } else { @@ -1384,6 +1391,17 @@ class Client { // The replay viewer takes over the page (loaded on demand). const replayViewerID = parseReplayViewerHash(hash); if (replayViewerID !== null) { + if (this.lobbyHandle !== null || this.joinInFlight) { + await this.handleLeaveLobby(); + } + this.joinModal?.close(); + if (window.location.hash !== hash) { + history.replaceState( + null, + "", + window.location.pathname + window.location.search + hash, + ); + } await this.openReplayViewer(replayViewerID); return; } @@ -1707,9 +1725,11 @@ class Client { if (startingModal instanceof GameStartingModal) { startingModal.hide(); } + event.preventDefault(); return; } if (this.blockedJoin(lobby)) { + event.preventDefault(); return; } // Only once the join is actually going ahead: a refused dispatch that @@ -1721,6 +1741,12 @@ class Client { // above its own lobbyHandle guard precisely because this window exists. this.joinInFlight = true; + if (this.replayViewerID !== null) { + document.querySelector("replay-viewer")?.remove(); + this.replayViewerID = null; + this.replayViewerOpeningToken++; + } + console.log(`joining lobby ${lobby.gameID}`); // Entering a lobby. Singleplayer, public lobbies and replays know their // config up front; everything else is filled in by the lobby_info @@ -1832,6 +1858,8 @@ class Client { : undefined), gameRecord: lobby.gameRecord, spectator: lobby.spectator, + resumeSnapshot: lobby.resumeSnapshot, + resumeTick: lobby.resumeTick, creatorToken: lobby.creatorToken, }); diff --git a/src/client/VersionedReplay.ts b/src/client/VersionedReplay.ts index e08e90fb88d..848ae837e4b 100644 --- a/src/client/VersionedReplay.ts +++ b/src/client/VersionedReplay.ts @@ -27,6 +27,8 @@ export function isReplayShellHost(hostname: string): boolean { return hostname.startsWith("replay."); } +const PROBE_TIMEOUT_MS = 3000; + // The versioned shell URL for a game from another build, if that shell is // actually served. Null on a shell host (redirecting again would loop), in // dev, or when the probe fails. The probe requires text/html so a misrouted @@ -42,7 +44,10 @@ export async function findVersionedShell( const url = versionedReplayUrl(audience, gameID); if (url === null) return null; try { - const probe = await fetchFn.call(globalThis, url, { method: "HEAD" }); + const probe = await fetchFn.call(globalThis, url, { + method: "HEAD", + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); if (!probe.ok) return null; const contentType = probe.headers.get("content-type") ?? ""; return contentType.includes("text/html") ? url : null; diff --git a/src/client/WorkerClient.ts b/src/client/WorkerClient.ts index 8637ef33b9e..a569b1bb0d8 100644 --- a/src/client/WorkerClient.ts +++ b/src/client/WorkerClient.ts @@ -37,6 +37,11 @@ export async function createGameWorker(): Promise { return new GameWorker(); } +export interface WorkerSnapshotResult { + bytes: Uint8Array; + tick: number; +} + export class WorkerClient { private worker: Worker | null = null; private isInitialized = false; @@ -44,6 +49,13 @@ export class WorkerClient { private gameUpdateCallback?: ( update: GameUpdateViewData | ErrorUpdate, ) => void; + public initialUpdate: GameUpdateViewData | null = null; + + public consumeInitialUpdate(): GameUpdateViewData | null { + const update = this.initialUpdate; + this.initialUpdate = null; + return update; + } constructor( private gameStartInfo: GameStartInfo, @@ -77,6 +89,16 @@ export class WorkerClient { break; case "initialized": + if (message.initialUpdate) { + this.initialUpdate = message.initialUpdate; + } + if (message.id && this.messageHandlers.has(message.id)) { + const handler = this.messageHandlers.get(message.id)!; + handler(message); + this.messageHandlers.delete(message.id); + } + break; + default: if (message.id && this.messageHandlers.has(message.id)) { const handler = this.messageHandlers.get(message.id)!; @@ -112,6 +134,9 @@ export class WorkerClient { this.messageHandlers.set(messageId, (message) => { if (message.type === "initialized") { this.isInitialized = true; + if (message.initialUpdate) { + this.initialUpdate = message.initialUpdate; + } resolve(); } else if (message.type === "init_error") { // No game will own this client, so nothing would stop the worker. @@ -144,6 +169,11 @@ export class WorkerClient { throw new Error("Failed to initialize pathfinder"); } this.gameUpdateCallback = gameUpdate; + if (this.initialUpdate) { + const update = this.initialUpdate; + this.initialUpdate = null; + this.gameUpdateCallback(update); + } } sendTurn(turn: Turn) { @@ -158,7 +188,7 @@ export class WorkerClient { } /** Serializes the worker's game at its current tick (uncompressed). */ - snapshot(gitCommit?: string): Promise { + snapshot(gitCommit?: string): Promise { return new Promise((resolve, reject) => { if (!this.isInitialized) { reject(new Error("Worker not initialized")); @@ -172,7 +202,10 @@ export class WorkerClient { if (message.snapshot === null) { reject(new Error("Snapshot failed")); } else { - resolve(message.snapshot); + resolve({ + bytes: message.snapshot, + tick: message.tick, + }); } } }); diff --git a/src/client/render/gl/utils/PlayerPalette.ts b/src/client/render/gl/utils/PlayerPalette.ts index 3c84a8f52d5..330aa8ca548 100644 --- a/src/client/render/gl/utils/PlayerPalette.ts +++ b/src/client/render/gl/utils/PlayerPalette.ts @@ -103,11 +103,13 @@ export function writePatternEntry( if (!pattern?.patternData) return; try { const decoded = decodePatternData(pattern.patternData, base64url.decode); + const numBytes = (decoded.width * decoded.height + 7) >> 3; + if (numBytes > 1024) return; + data.set(decoded.bytes.subarray(3, 3 + numBytes), smallID * 1024); meta[metaOff] = 1; // hasPattern meta[metaOff + 1] = decoded.width; meta[metaOff + 2] = decoded.height; meta[metaOff + 3] = decoded.scale; - data.set(decoded.bytes.slice(3), smallID * 1024); } catch (e) { console.warn("Failed to decode territory pattern", e); } diff --git a/src/client/replay/ContinueGameModal.ts b/src/client/replay/ContinueGameModal.ts new file mode 100644 index 00000000000..b59e7ded9c3 --- /dev/null +++ b/src/client/replay/ContinueGameModal.ts @@ -0,0 +1,327 @@ +import { Difficulty } from "@openfront/engine-api/game/GameTypes"; +import { renderTroops } from "@openfront/engine-lib/Format"; +import { assetUrl } from "@openfront/shared/AssetUrls"; +import { html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { translateText } from "../Utils"; +import { formatGameTime } from "./ReplayControls"; + +export interface ContinuePlayerOption { + id: string; + name: string; + smallID: number; + flag?: string; + troops: number; + tiles: number; + controlPercent?: number; +} + +@customElement("continue-game-modal") +export class ContinueGameModal extends LitElement { + @property({ type: Number }) tick = 0; + @property({ type: Array }) players: ContinuePlayerOption[] = []; + @property({ type: String }) initialPlayerID = ""; + @property({ type: Boolean }) open = false; + + @state() private selectedPlayerID = ""; + @state() private selectedDifficulty: Difficulty = Difficulty.Medium; + @state() private searchQuery = ""; + @state() private loading = false; + @state() private error = ""; + + createRenderRoot() { + return this; + } + + willUpdate(changedProperties: Map): void { + if (changedProperties.has("open")) { + if (this.open) { + if (this.initialPlayerID) { + this.selectedPlayerID = this.initialPlayerID; + } + } else { + this.searchQuery = ""; + } + } else if ( + changedProperties.has("initialPlayerID") && + this.initialPlayerID + ) { + this.selectedPlayerID = this.initialPlayerID; + } + + if (this.players.length === 0) { + this.selectedPlayerID = ""; + } else if ( + !this.selectedPlayerID || + !this.players.some((p) => p.id === this.selectedPlayerID) + ) { + const matchInitial = this.players.find( + (p) => p.id === this.initialPlayerID, + ); + this.selectedPlayerID = matchInitial + ? matchInitial.id + : this.players[0].id; + } + } + + private renderFlag(flag?: string) { + if (!flag) return nothing; + if (flag.includes("/") || flag.includes(".svg")) { + return html``; + } + return html`${flag}`; + } + + private close(): void { + this.open = false; + this.loading = false; + this.error = ""; + this.searchQuery = ""; + this.dispatchEvent( + new CustomEvent("close", { bubbles: true, composed: true }), + ); + } + + private async handleConfirm(): Promise { + if (!this.selectedPlayerID || this.loading) return; + this.loading = true; + this.error = ""; + this.dispatchEvent( + new CustomEvent("continue", { + detail: { + playerID: this.selectedPlayerID, + difficulty: this.selectedDifficulty, + }, + bubbles: true, + composed: true, + }), + ); + } + + public setLoading(isLoading: boolean, errorMsg = ""): void { + this.loading = isLoading; + this.error = errorMsg; + } + + render() { + if (!this.open) return nothing; + + const timeStr = formatGameTime(this.tick); + + return html` +
{ + if (e.target === e.currentTarget) this.close(); + }} + > +
+ +
+
+

+ ${translateText("replay_viewer.continue_modal_title")} +

+

+ ${translateText("replay_viewer.continue_modal_desc")} +

+
+ +
+ + +
+ + ${translateText("replay_viewer.game_time")} + + + ${timeStr} + +
+ + +
+
+ + ${this.players.length > 0 + ? html` + ${this.searchQuery.trim() + ? `${ + this.players.filter((p) => + p.name + .toLowerCase() + .includes(this.searchQuery.trim().toLowerCase()), + ).length + } / ${this.players.length}` + : `${this.players.length}`} + ` + : nothing} +
+ + +
+ { + this.searchQuery = (e.target as HTMLInputElement).value; + }} + class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white placeholder-zinc-400 focus:outline-none focus:border-sky-400 transition" + /> + ${this.searchQuery + ? html`` + : nothing} +
+ +
+ ${(() => { + const query = this.searchQuery.trim().toLowerCase(); + const filtered = query + ? this.players.filter((p) => + p.name.toLowerCase().includes(query), + ) + : this.players; + + if (filtered.length === 0) { + return html` +
+ ${translateText("replay_viewer.no_players_found")} +
+ `; + } + + return filtered.map((p) => { + const isSelected = p.id === this.selectedPlayerID; + return html` + + `; + }); + })()} +
+
+ + +
+ +
+ ${[ + Difficulty.Easy, + Difficulty.Medium, + Difficulty.Hard, + Difficulty.Impossible, + ].map( + (diff) => html` + + `, + )} +
+
+ + ${this.error + ? html`
+ ${this.error} +
` + : nothing} + + +
+ + +
+
+
+ `; + } +} diff --git a/src/client/replay/LocalProcessing.ts b/src/client/replay/LocalProcessing.ts index 07e5334d250..1b4b886eec5 100644 --- a/src/client/replay/LocalProcessing.ts +++ b/src/client/replay/LocalProcessing.ts @@ -7,11 +7,27 @@ * starts it and gives the two workers a channel to talk on directly. */ -import type { ConnectMessage } from "@openfront/engine-api/worker/WorkerMessages"; -import { getCdnBase } from "@openfront/shared/AssetUrls"; -import type { GameRecord } from "@openfront/shared/WireSchemas"; +import type { Difficulty } from "@openfront/engine-api/game/GameTypes"; +import type { + ConnectMessage, + ExtractSnapshotMessage, + WorkerMessage, +} from "@openfront/engine-api/worker/WorkerMessages"; +import { assetUrl, getCdnBase } from "@openfront/shared/AssetUrls"; +import { FetchGameMapLoader } from "@openfront/shared/FetchGameMapLoader"; +import { + GameMapLoader, + loadMapFiles, + mapFilesTransfer, +} from "@openfront/shared/GameMapLoader"; +import { decompressGameRecord, generateID } from "@openfront/shared/SharedUtil"; +import type { + GameRecord, + WireGameStartInfo, +} from "@openfront/shared/WireSchemas"; import { createGameWorker } from "../WorkerClient"; import type { ReplayAppend, ReplayBase } from "./codec/ReplayTypes"; +import { wireGameStartInfo } from "./processor/ReplayProcessor"; import type { ProcessorRequest, ProcessorResponse } from "./ProcessorMessages"; export interface ProcessingHandlers { @@ -116,3 +132,104 @@ export function processInBrowser( return { cancel: stop }; } + +export async function extractSnapshotInWorker( + record: GameRecord, + targetTick: number, + chosenPlayerID: string, + localClientID: string, + difficulty?: Difficulty, + createWorker: () => Promise = createGameWorker, + signal?: AbortSignal, + mapLoader?: GameMapLoader, +): Promise<{ + snapshot: Uint8Array; + gameStartInfo: WireGameStartInfo; + tick: number; +}> { + if (signal?.aborted) { + throw ( + signal.reason ?? + new DOMException("The operation was aborted.", "AbortError") + ); + } + + if (!record.info || !("config" in record.info)) { + throw new Error("Game record is missing start configuration"); + } + + const turns = decompressGameRecord({ ...record }).turns; + const gameStart = wireGameStartInfo(record); + const loader = + mapLoader ?? new FetchGameMapLoader((path) => assetUrl(`maps/${path}`)); + const mapFiles = await loadMapFiles( + loader, + gameStart.config.gameMap, + gameStart.config.gameMapSize, + ); + + if (signal?.aborted) { + throw ( + signal.reason ?? + new DOMException("The operation was aborted.", "AbortError") + ); + } + + const worker = await createWorker(); + if (signal?.aborted) { + worker.terminate(); + throw ( + signal.reason ?? + new DOMException("The operation was aborted.", "AbortError") + ); + } + + return new Promise((resolve, reject) => { + const onAbort = () => { + worker.terminate(); + reject( + signal?.reason ?? + new DOMException("The operation was aborted.", "AbortError"), + ); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + + const cleanup = () => { + signal?.removeEventListener("abort", onAbort); + }; + + worker.addEventListener("message", (e: MessageEvent) => { + const msg = e.data; + if (msg.type === "extract_snapshot_result") { + cleanup(); + worker.terminate(); + resolve({ + snapshot: msg.snapshot, + gameStartInfo: msg.gameStartInfo, + tick: msg.tick, + }); + } else if (msg.type === "extract_snapshot_error") { + cleanup(); + worker.terminate(); + reject(new Error(msg.error)); + } + }); + worker.addEventListener("error", (e) => { + cleanup(); + worker.terminate(); + reject(new Error(e.message || "the worker failed")); + }); + const request: ExtractSnapshotMessage = { + type: "extract_snapshot", + gameStartInfo: gameStart, + turns, + map: mapFiles, + targetTick, + chosenPlayerID, + localClientID, + difficulty, + newGameID: generateID(), + }; + worker.postMessage(request, mapFilesTransfer(mapFiles)); + }); +} diff --git a/src/client/replay/ProcessorMessages.ts b/src/client/replay/ProcessorMessages.ts index 8f32443313d..3a9aa8cdbd2 100644 --- a/src/client/replay/ProcessorMessages.ts +++ b/src/client/replay/ProcessorMessages.ts @@ -3,12 +3,13 @@ import type { GameRecord } from "@openfront/shared/WireSchemas"; import type { ReplayAppend, ReplayBase } from "./codec/ReplayTypes"; -export interface ProcessorRequest { +export type ProcessorRequest = { + type?: "process"; record: GameRecord; cdnBase: string; /** A port to the engine's worker, which the page started. */ engine: MessagePort; -} +}; export type ProcessorResponse = /** Simulation progress, 0 to 100. */ diff --git a/src/client/replay/ReplayAppearance.ts b/src/client/replay/ReplayAppearance.ts index aa86e29f346..bcdd32e875e 100644 --- a/src/client/replay/ReplayAppearance.ts +++ b/src/client/replay/ReplayAppearance.ts @@ -69,7 +69,7 @@ export class ReplayAppearance { view.initSkinAtlas([...this.skinUrls()]); const p = this._palette; view.addPlayers(p.players, p.palette, p.patternMeta, p.patternData); - this.drawn = this.players.length; + this.drawn = p.players.length; this.applySkins(); this.applyEffects(); void fetchCosmetics().then( @@ -86,7 +86,7 @@ export class ReplayAppearance { if (view === null || this.players.length === this.drawn) return; this.resolve(); const added = this._palette.players.slice(this.drawn); - this.drawn = this.players.length; + this.drawn = this._palette.players.length; const p = this._palette; view.addPlayers(added, p.palette, p.patternMeta, p.patternData); this.applySkins(); diff --git a/src/client/replay/ReplayControls.ts b/src/client/replay/ReplayControls.ts index 0f726d549ba..ef36a2d960b 100644 --- a/src/client/replay/ReplayControls.ts +++ b/src/client/replay/ReplayControls.ts @@ -18,6 +18,7 @@ const SPEEDS = [0.5, 1, 2, 4, 8, 16, 32]; // Same icons as the in-game controls (GameRightSidebar). const playIcon = assetUrl("images/PlayIconWhite.svg"); const pauseIcon = assetUrl("images/PauseIconWhite.svg"); +const continueIcon = assetUrl("images/SwordIconWhite.svg"); const speedIcon = assetUrl("images/FastForwardIconSolidWhite.svg"); const settingsIcon = assetUrl("images/SettingIconWhite.svg"); const fullscreenIcon = assetUrl("images/FullscreenIconWhite.svg"); @@ -79,6 +80,7 @@ export class ReplayControls extends LitElement { @property({ type: Number }) total = 0; @property({ type: Boolean }) playing = false; @property({ type: Number }) speed = 1; + @property({ type: Boolean }) canContinue = true; @state() private speedMenuOpen = false; @state() private isFullscreen = document.fullscreenElement !== null; @@ -117,7 +119,11 @@ export class ReplayControls extends LitElement { /** Seek, but not past what's been loaded (the thumb snaps back). */ private onTimelineInput(e: Event): void { const input = e.target as HTMLInputElement; - const frame = Math.min(Number(input.value), this.loaded - 1); + if (this.loaded <= 0) { + input.value = "0"; + return; + } + const frame = Math.max(0, Math.min(Number(input.value), this.loaded - 1)); if (frame !== Number(input.value)) input.value = String(frame); this.emit("replay-seek", frame); } @@ -174,6 +180,7 @@ export class ReplayControls extends LitElement { min="0" max=${Math.max(0, this.total - 1)} .value=${String(frame)} + .disabled=${this.loaded <= 0} aria-label=${translateText("replay_viewer.timeline")} @input=${this.onTimelineInput} /> @@ -194,6 +201,13 @@ export class ReplayControls extends LitElement { translateText("replay_panel.replay_speed"), () => (this.speedMenuOpen = !this.speedMenuOpen), )} + ${this.canContinue + ? iconButton( + continueIcon, + translateText("replay_viewer.continue_from_here"), + () => this.emit("replay-continue"), + ) + : nothing} ${iconButton( settingsIcon, translateText("user_setting.game_menu_title"), diff --git a/src/client/replay/ReplayEntry.ts b/src/client/replay/ReplayEntry.ts index f82829eb180..ec51f38816f 100644 --- a/src/client/replay/ReplayEntry.ts +++ b/src/client/replay/ReplayEntry.ts @@ -57,7 +57,7 @@ export function classicReplayHref(gameID: string): string { /** The page URL that opens the viewer for a game. */ export function replayViewerHref(gameID: string): string { - return `${window.location.pathname}#replay-viewer=${encodeURIComponent(gameID)}`; + return `${window.location.pathname}${window.location.search}#replay-viewer=${encodeURIComponent(gameID)}`; } /** diff --git a/src/client/replay/ReplayProcessor.worker.ts b/src/client/replay/ReplayProcessor.worker.ts index 882a1b30dd1..f62430e35db 100644 --- a/src/client/replay/ReplayProcessor.worker.ts +++ b/src/client/replay/ReplayProcessor.worker.ts @@ -24,12 +24,13 @@ function send(msg: ProcessorResponse, transfer: Transferable[] = []): void { } ctx.addEventListener("message", (e: MessageEvent) => { - const { record, cdnBase, engine } = e.data; + const req = e.data; // Workers have no `window`, so AssetUrls reads the CDN base from here // (same as Worker.worker.ts). - globalThis.__CDN_BASE__ = cdnBase; - processGameRecord(record, { - engine: (gameStart, map) => startWorkerEngine(engine, gameStart, map), + globalThis.__CDN_BASE__ = req.cdnBase; + + processGameRecord(req.record, { + engine: (gameStart, map) => startWorkerEngine(req.engine, gameStart, map), mapLoader, gzip: gzipInBrowser, onProgress: (p) => send({ type: "progress", percent: p.percent }), diff --git a/src/client/replay/ReplayRecord.ts b/src/client/replay/ReplayRecord.ts index 901ff14d044..3fe22d3c65d 100644 --- a/src/client/replay/ReplayRecord.ts +++ b/src/client/replay/ReplayRecord.ts @@ -29,6 +29,8 @@ export function handOverRecord(gameID: string, record: GameRecord): void { handedOver = { gameID, record }; } +const RECORD_FETCH_TIMEOUT_MS = 10_000; + export async function fetchReplayRecord( gameID: string, opts: { @@ -50,7 +52,10 @@ export async function fetchReplayRecord( res = await fetchFn.call( globalThis, `${apiBase}/game/${encodeURIComponent(gameID)}`, - { headers: { Accept: "application/json" } }, + { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(RECORD_FETCH_TIMEOUT_MS), + }, ); } catch { return { kind: "unreachable" }; diff --git a/src/client/replay/ReplayViewer.ts b/src/client/replay/ReplayViewer.ts index bb289306f53..c581d903d0d 100644 --- a/src/client/replay/ReplayViewer.ts +++ b/src/client/replay/ReplayViewer.ts @@ -8,10 +8,16 @@ * build is sent to that build's versioned shell. */ -import { Cell, PlayerType } from "@openfront/engine-api/game/GameTypes"; +import { + Cell, + Difficulty, + PlayerType, +} from "@openfront/engine-api/game/GameTypes"; import type { GameStartInfo } from "@openfront/engine-api/Schemas"; import { Config } from "@openfront/engine-lib/configuration/Config"; import { EventBus } from "@openfront/shared/EventBus"; +import { generateID } from "@openfront/shared/SharedUtil"; +import type { GameRecord } from "@openfront/shared/WireSchemas"; import { html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { MapLayerController } from "../controllers/MapLayerController"; @@ -28,6 +34,7 @@ import { type SettingsModal, } from "../hud/layers/SettingsModal"; import { MouseMoveEvent } from "../InputHandler"; +import type { JoinLobbyEvent } from "../Main"; import { buildTerrainRowSpans } from "../render/frame/derive/TerrainRowSpans"; import { uploadFrameData } from "../render/frame/Upload"; import { @@ -68,7 +75,16 @@ import type { ReplayHeader, } from "./codec/ReplayTypes"; import { terrainOf } from "./codec/Terrain"; -import { processInBrowser, type Processing } from "./LocalProcessing"; +import "./ContinueGameModal"; +import type { + ContinueGameModal, + ContinuePlayerOption, +} from "./ContinueGameModal"; +import { + extractSnapshotInWorker, + processInBrowser, + type Processing, +} from "./LocalProcessing"; import { ReplayAppearance } from "./ReplayAppearance"; import { CameraGestures, ReplayCamera } from "./ReplayCamera"; import { formatGameTime, timelineFrames } from "./ReplayControls"; @@ -139,6 +155,12 @@ export class ReplayViewer extends LitElement { * processed stays watchable, with this shown over it. */ @state() private stoppedEarly = ""; + @state() private continueModalOpen = false; + @state() private continuePlayers: ContinuePlayerOption[] = []; + @state() private continueInitialPlayerID = ""; + private continueRequestCount = 0; + private continueAbortController: AbortController | null = null; + /** * The record has no hashes, so the replay can't be checked against the * game that was played. @@ -147,6 +169,7 @@ export class ReplayViewer extends LitElement { /** Says so over the replay, until closed. */ @state() private unverifiedNotice = false; + private record: GameRecord | null = null; /** The processing worker, while it runs. */ private processing: Processing | null = null; /** The game being processed (the worker's first message). */ @@ -189,6 +212,8 @@ export class ReplayViewer extends LitElement { disconnectedCallback(): void { super.disconnectedCallback(); + this.continueAbortController?.abort(); + this.continueAbortController = null; this.abort.abort(); this.processing?.cancel(); if (this.rafId !== null) cancelAnimationFrame(this.rafId); @@ -239,6 +264,7 @@ export class ReplayViewer extends LitElement { } const record = result.record; + this.record = record; this.gameLength = record.info.num_turns; this.unverified = !hasRecordedHashes(record); this.unverifiedNotice = this.unverified; @@ -686,9 +712,11 @@ export class ReplayViewer extends LitElement { private onKey(e: KeyboardEvent): void { const p = this.playback; if (p === null || this.status !== "ready") return; - // The settings menu paused playback, so keys wait until it closes. So + // The settings or continue modal paused playback, so keys wait until it closes. So // do the game settings it opens: their sliders take the arrow keys. - if (this.settingsOpen()) return; + if (this.continueModalOpen || this.settingsOpen()) { + return; + } // The timeline keeps focus after a click, so it doesn't count as typing. // Its own arrow-key steps are prevented below so a key seeks once. Any // other input keeps its keys. @@ -787,6 +815,122 @@ export class ReplayViewer extends LitElement { ); } + private openContinueModal(): void { + const p = this.playback; + if (p !== null && p.playing) { + p.pause(); + } + const adapter = this.adapter; + let players: ContinuePlayerOption[] = []; + if (adapter) { + adapter.sync(); + const inSpawn = adapter.inSpawnPhase(); + const landTiles = adapter.numLandTiles(); + players = adapter + .playerViews() + .filter( + (pv) => (inSpawn || pv.isAlive()) && pv.type() !== PlayerType.Bot, + ) + .map((pv) => { + const tiles = pv.numTilesOwned(); + const controlPercent = landTiles > 0 ? (tiles / landTiles) * 100 : 0; + return { + id: pv.id(), + name: pv.displayName() || pv.name(), + smallID: pv.smallID(), + flag: pv.flag(), + troops: pv.troops(), + tiles, + controlPercent, + }; + }); + } + this.continuePlayers = players; + const focusID = adapter?.focus?.id(); + this.continueInitialPlayerID = + players.find((p) => p.id === focusID)?.id ?? players[0]?.id ?? ""; + this.continueAbortController?.abort(); + this.continueAbortController = null; + this.continueRequestCount++; + this.continueModalOpen = true; + } + + private closeContinueModal(): void { + this.continueAbortController?.abort(); + this.continueAbortController = null; + this.continueRequestCount++; + this.continueModalOpen = false; + } + + private async handleContinueGame( + e: CustomEvent<{ playerID: string; difficulty: Difficulty }>, + ): Promise { + const abortController = new AbortController(); + this.continueAbortController = abortController; + const requestId = this.continueRequestCount; + const modal = this.querySelector("continue-game-modal"); + modal?.setLoading(true); + try { + let record = this.record; + if (!record) { + const res = await fetchReplayRecord(this.gameID); + if (this.continueRequestCount !== requestId) return; + if (res.kind === "record") { + this.record = res.record; + record = res.record; + } else { + modal?.setLoading( + false, + translateText("replay_viewer.unavailable_failed"), + ); + return; + } + } + const localClientID = generateID(); + const { snapshot, gameStartInfo, tick } = await extractSnapshotInWorker( + record, + this.tick, + e.detail.playerID, + localClientID, + e.detail.difficulty, + undefined, + abortController.signal, + ); + if (this.continueRequestCount !== requestId) return; + + const joinEvent = new CustomEvent("join-lobby", { + detail: { + gameID: gameStartInfo.gameID, + gameStartInfo, + source: "singleplayer", + resumeSnapshot: snapshot, + resumeTick: tick, + } satisfies JoinLobbyEvent, + bubbles: true, + composed: true, + cancelable: true, + }); + document.dispatchEvent(joinEvent); + if (joinEvent.defaultPrevented) { + modal?.setLoading(false); + return; + } + this.closeContinueModal(); + modal?.setLoading(false); + } catch (err) { + if (this.continueRequestCount !== requestId) return; + console.error("replay viewer: continue game failed:", err); + modal?.setLoading( + false, + err instanceof Error ? err.message : String(err), + ); + } finally { + if (this.continueAbortController === abortController) { + this.continueAbortController = null; + } + } + } + // ---- Render ---- render() { @@ -832,12 +976,14 @@ export class ReplayViewer extends LitElement { .total=${timelineFrames(loaded, this.gameLength, this.growing)} .playing=${this.playing} .speed=${this.speed} + .canContinue=${true} @replay-toggle-play=${() => this.togglePlay()} @replay-seek=${(e: CustomEvent) => void this.playback?.seek(e.detail)} @replay-speed=${(e: CustomEvent) => this.playback?.setSpeed(e.detail)} @replay-menu=${() => this.openMenu()} + @replay-continue=${() => this.openContinueModal()} >` : html``} + this.closeContinueModal()} + @continue=${( + e: CustomEvent<{ playerID: string; difficulty: Difficulty }>, + ) => void this.handleContinueGame(e)} + > `; } diff --git a/src/client/replay/codec/EntitySchema.ts b/src/client/replay/codec/EntitySchema.ts index 4ec1d6933e5..af8fcd0c26e 100644 --- a/src/client/replay/codec/EntitySchema.ts +++ b/src/client/replay/codec/EntitySchema.ts @@ -51,25 +51,45 @@ const BOOL: ValueCodec = { }; const U8: ValueCodec = { - write: (w, v) => w.writeU8(v), + write: (w, v) => { + if (!Number.isInteger(v) || v < 0 || v > 0xff) { + throw new RangeError(`Expected u8, got ${v}`); + } + w.writeU8(v); + }, read: (r) => r.readU8(), eq: same, }; const U16: ValueCodec = { - write: (w, v) => w.writeU16(v), + write: (w, v) => { + if (!Number.isInteger(v) || v < 0 || v > 0xffff) { + throw new RangeError(`Expected u16, got ${v}`); + } + w.writeU16(v); + }, read: (r) => r.readU16(), eq: same, }; const U32: ValueCodec = { - write: (w, v) => w.writeU32(v), + write: (w, v) => { + if (!Number.isInteger(v) || v < 0 || v > 0xffffffff) { + throw new RangeError(`Expected u32, got ${v}`); + } + w.writeU32(v); + }, read: (r) => r.readU32(), eq: same, }; const I32: ValueCodec = { - write: (w, v) => w.writeI32(v), + write: (w, v) => { + if (!Number.isInteger(v) || v < -0x80000000 || v > 0x7fffffff) { + throw new RangeError(`Expected i32, got ${v}`); + } + w.writeI32(v); + }, read: (r) => r.readI32(), eq: same, }; diff --git a/src/client/replay/codec/FrameNormalizer.ts b/src/client/replay/codec/FrameNormalizer.ts index 77166e5df9c..9e70a489e71 100644 --- a/src/client/replay/codec/FrameNormalizer.ts +++ b/src/client/replay/codec/FrameNormalizer.ts @@ -115,9 +115,11 @@ export interface NormalizedFrame { /** `[tileRef, state]` pairs, as delivered by the worker. */ tiles: Uint32Array; /** - * Every known player's state after this tick. Snapshots are never - * mutated, and a player that didn't change keeps last tick's object, so - * `prev === curr` means unchanged. + * Every known player's state after this tick. FrameNormalizer reuses and + * updates the map on the next push, so callers should not retain it as + * prior state. Snapshot objects themselves are immutable, and a player + * that didn't change keeps last tick's object, so `prev === curr` means + * unchanged. */ players: ReadonlyMap; /** Players seen for the first time this tick. */ diff --git a/src/client/replay/codec/decode/ReplayReader.ts b/src/client/replay/codec/decode/ReplayReader.ts index af209bac698..85f275aaff8 100644 --- a/src/client/replay/codec/decode/ReplayReader.ts +++ b/src/client/replay/codec/decode/ReplayReader.ts @@ -315,6 +315,11 @@ export class ReplayReader { let ref = 0; for (let i = 0; i < count; i++) { ref += r.readVarUint(); + if (ref >= this.tileState.length) { + throw new RangeError( + `tile ref out of bounds (${ref} >= ${this.tileState.length})`, + ); + } if (this.tileState[ref] & FALLOUT_BIT) this.falloutTiles--; if (state & FALLOUT_BIT) this.falloutTiles++; this.tileState[ref] = state; @@ -443,6 +448,11 @@ function readTileRuns( changed: number[] | null, ): number { const runs = r.readVarUint(); + if (runs > tiles.length) { + throw new RangeError( + `tile run count exceeds map size (${runs} > ${tiles.length})`, + ); + } const values = new Uint16Array(runs); for (let i = 0; i < runs; i++) values[i] = r.readU16(); let fallout = 0; diff --git a/src/client/view/GameView.ts b/src/client/view/GameView.ts index 811585c8b1d..d02a0c8104b 100644 --- a/src/client/view/GameView.ts +++ b/src/client/view/GameView.ts @@ -151,9 +151,11 @@ export class GameView implements GameLike { private _myClanTag: string | null, private _gameID: GameID, humans: WirePlayer[], + initialStartTick?: Tick | null, ) { this._map = this._mapData.gameMap; this.lastUpdate = null; + this.startTick = initialStartTick ?? null; this.unitGrid = new UnitGrid(this._map); this._cosmetics = new Map( humans.map((h) => [h.clientID, h.cosmetics ?? {}]), @@ -178,7 +180,7 @@ export class GameView implements GameLike { // events: fresh arrays we own; cleared and repopulated each tick. this._frame = { tick: 0, - inSpawnPhase: true, + inSpawnPhase: this.startTick === null, tileState: this._map.tileStateBuffer(), trailState: this.trailManager.getTrailState(), spiralRibbons: this.spiralTrails.getRibbons(), diff --git a/tests/PackedPlayerUpdates.test.ts b/tests/PackedPlayerUpdates.test.ts index 710175605ae..fd7d3b9cc99 100644 --- a/tests/PackedPlayerUpdates.test.ts +++ b/tests/PackedPlayerUpdates.test.ts @@ -4,7 +4,11 @@ * and `playerNameViewData` is attached only on ticks where the worker * recomputed name placements. See GameUpdateViewData in GameUpdates.ts. */ -import { PlayerInfo, PlayerType } from "@openfront/engine-api/game/GameTypes"; +import { + PlayerInfo, + PlayerType, + UnitType, +} from "@openfront/engine-api/game/GameTypes"; import { GameUpdateType, GameUpdateViewData, @@ -12,6 +16,8 @@ import { import { Executor } from "@openfront/engine/execution/ExecutionManager"; import { SpawnExecution } from "@openfront/engine/execution/SpawnExecution"; import { Game, Player } from "@openfront/engine/game/Game"; +import { Railroad } from "@openfront/engine/game/Railroad"; +import { TrainStation } from "@openfront/engine/game/TrainStation"; import { GameRunner } from "@openfront/engine/GameRunner"; import { setup } from "./util/Setup"; @@ -155,4 +161,120 @@ describe("GameRunner payload cadence", () => { const playerUpdates = gu.updates[GameUpdateType.Player]; expect(playerUpdates.find((u) => u.id === "alice_id")).toBeUndefined(); }); + + test("snapshotViewData emits full state and subsequent tick retains partial updates", () => { + tick(); // 1 + tick(); // 2 + game.endSpawnPhase(); + tick(); // 3 + + const alice = game.player("alice_id"); + const city = alice.buildUnit(UnitType.City, game.ref(10, 10), {}); + + const bobInfo = new PlayerInfo( + "bob", + PlayerType.Human, + "bob_client", + "bob_id", + ); + game.addPlayer(bobInfo); + expect(game.player("bob_id").isAlive()).toBe(false); + + const runner = new GameRunner( + game, + new Executor(game, gameID, "alice_client"), + (gu) => { + if (!("errMsg" in gu)) byTick.set(gu.tick, gu); + }, + ); + + const snapshotView = runner.snapshotViewData(); + expect(snapshotView.updates[GameUpdateType.Player].length).toBeGreaterThan( + 0, + ); + const alicePu = snapshotView.updates[GameUpdateType.Player].find( + (p) => p.id === "alice_id", + ); + expect(alicePu).toBeDefined(); + expect(alicePu!.name).toBe("alice"); + expect(alicePu!.smallID).toBe(alice.smallID()); + + const bobPu = snapshotView.updates[GameUpdateType.Player].find( + (p) => p.id === "bob_id", + ); + expect(bobPu).toBeDefined(); + expect(bobPu!.name).toBe("bob"); + expect(bobPu!.isAlive).toBe(false); + + const cityUu = snapshotView.updates[GameUpdateType.Unit].find( + (u) => u.id === city.id(), + ); + expect(cityUu).toBeDefined(); + expect(cityUu!.unitType).toBe(UnitType.City); + + // On the subsequent tick, no non-churn fields changed, so partial updates are retained + alice.addGold(500n); + runner.addTurn({ turnNumber: game.ticks(), intents: [] }); + runner.executeNextTick(); + + const nextGu = byTick.get(game.ticks())!; + const nextPlayerUpdates = nextGu.updates[GameUpdateType.Player]; + // Alice's static fields are omitted in partial update diff + expect(nextPlayerUpdates.find((u) => u.id === "alice_id")).toBeUndefined(); + }); + + test("snapshotViewData includes railroad construction and pending alliance requests", () => { + tick(); // 1 + tick(); // 2 + game.endSpawnPhase(); + tick(); // 3 + + const alice = game.player("alice_id"); + const bobInfo = new PlayerInfo( + "bob", + PlayerType.Human, + "bob_client", + "bob_id", + ); + game.addPlayer(bobInfo); + const bob = game.player("bob_id"); + + const req = alice.createAllianceRequest(bob); + expect(req).not.toBeNull(); + expect(game.allianceRequests()).toHaveLength(1); + + // Build two cities and connect their stations with a railroad + alice.addGold(1_000_000n); + const c1 = alice.buildUnit(UnitType.City, game.ref(15, 15), {}); + const c2 = alice.buildUnit(UnitType.City, game.ref(15, 30), {}); + const s1 = new TrainStation(game, c1); + const s2 = new TrainStation(game, c2); + const railroad = new Railroad(s1, s2, [c1.tile(), c2.tile()], 42); + s1.addRailroad(railroad); + s2.addRailroad(railroad); + game.railNetwork().stationManager().addStation(s1); + game.railNetwork().stationManager().addStation(s2); + + const railroads = game.railNetwork().railroads(); + expect(railroads).toHaveLength(1); + expect(railroads[0]).toBe(railroad); + + const runner = new GameRunner( + game, + new Executor(game, gameID, "alice_client"), + () => {}, + ); + + const snapshotView = runner.snapshotViewData(); + const allianceReqs = snapshotView.updates[GameUpdateType.AllianceRequest]; + expect(allianceReqs).toHaveLength(1); + expect(allianceReqs[0]).toEqual(req!.toUpdate()); + + const railUpdates = + snapshotView.updates[GameUpdateType.RailroadConstructionEvent]; + expect(railUpdates.length).toBe(railroads.length); + for (const r of railroads) { + expect(railUpdates.some((u) => u.id === r.id)).toBe(true); + } + }); }); diff --git a/tests/Stats.test.ts b/tests/Stats.test.ts index dadaec8d28f..47df3ebb757 100644 --- a/tests/Stats.test.ts +++ b/tests/Stats.test.ts @@ -321,4 +321,14 @@ describe("Stats", () => { BigInt(tiles), ); }); + + test("migrateClientID moves stats from old to new client ID", () => { + stats.attack(player1, player2, 10); + const p1Stats = stats.stats().client1; + expect(p1Stats).toBeDefined(); + + stats.migrateClientID("client1", "new_client1"); + expect(stats.stats().client1).toBeUndefined(); + expect(stats.stats().new_client1).toBe(p1Stats); + }); }); diff --git a/tests/VersionedReplay.test.ts b/tests/VersionedReplay.test.ts index 80bf164cdfd..00b22d975ab 100644 --- a/tests/VersionedReplay.test.ts +++ b/tests/VersionedReplay.test.ts @@ -53,11 +53,11 @@ describe("findVersionedShell", () => { ).toBe("https://replay.openfront.io/abcd1234"); expect(fetchFn).toHaveBeenCalledWith( "https://replay.openfront.io/abcd1234", - { method: "HEAD" }, + { method: "HEAD", signal: expect.any(AbortSignal) }, ); }); - test("no shell: missing, not a page, or unreachable", async () => { + test("no shell: missing, not a page, unreachable, or timed out", async () => { for (const fetchFn of [ async () => new Response("", { status: 404 }), async () => @@ -65,6 +65,11 @@ describe("findVersionedShell", () => { async (): Promise => { throw new TypeError("offline"); }, + async (): Promise => { + const err = new Error("The operation was aborted"); + err.name = "TimeoutError"; + throw err; + }, ]) { expect( await findVersionedShell( diff --git a/tests/client/ClientGameRunnerMessages.test.ts b/tests/client/ClientGameRunnerMessages.test.ts index 18d28410314..488e4ddc2c3 100644 --- a/tests/client/ClientGameRunnerMessages.test.ts +++ b/tests/client/ClientGameRunnerMessages.test.ts @@ -86,6 +86,10 @@ vi.mock("../../src/client/view", () => ({ vi.mock("../../src/client/WorkerClient", () => ({ WorkerClient: class {}, })); +vi.mock("@openfront/engine-lib/snapshot/MapSnapshot", () => ({ + compressSnapshot: vi.fn(async (raw) => raw), + restoreMapsFromSnapshot: vi.fn(), +})); vi.mock("../../src/client/Transport", async (importOriginal) => { const actual = await importOriginal(); @@ -110,7 +114,11 @@ import { loadCachedTerrainMap } from "../../src/client/TerrainMapFileLoader"; import { SendHashEvent } from "../../src/client/Transport"; import { reloadForUpdate } from "../../src/client/Utils"; -function makeLobbyConfig(withStartInfo: boolean): LobbyConfig { +function makeLobbyConfig( + withStartInfo: boolean, + resumeSnapshot?: Uint8Array, + resumeTick?: number, +): LobbyConfig { return { gameID: "game1234", playerName: "tester", @@ -121,6 +129,8 @@ function makeLobbyConfig(withStartInfo: boolean): LobbyConfig { ...(withStartInfo ? { gameStartInfo: { gameID: "game1234", config: {} } } : {}), + ...(resumeSnapshot ? { resumeSnapshot } : {}), + ...(resumeTick !== undefined ? { resumeTick } : {}), } as unknown as LobbyConfig; } @@ -128,7 +138,12 @@ function makeLobbyConfig(withStartInfo: boolean): LobbyConfig { // the callbacks start() handed to the transport and the worker. function makeStartedRunner( withStartInfo: boolean, - opts: { isLocal?: boolean; metrics?: object } = {}, + opts: { + isLocal?: boolean; + metrics?: object; + resumeSnapshot?: Uint8Array; + resumeTick?: number; + } = {}, ) { const eventBus = new EventBus(); const emitSpy = vi.spyOn(eventBus, "emit"); @@ -156,7 +171,7 @@ function makeStartedRunner( const userSettings = { goToPlayer: () => false }; const runner = new ClientGameRunner( - makeLobbyConfig(withStartInfo), + makeLobbyConfig(withStartInfo, opts.resumeSnapshot, opts.resumeTick), "c0000001", eventBus, renderer as never, @@ -286,6 +301,24 @@ describe("ClientGameRunner in-game messages", () => { ); }); + it("initializes turnsSeen from snapshot tick when resuming from snapshot so the first turn reaches worker.sendTurn", () => { + const { worker, onmessage } = makeStartedRunner(true, { + resumeSnapshot: new Uint8Array([1, 2, 3]), + resumeTick: 200, + }); + const turn = { turnNumber: 200, intents: [] }; + + onmessage({ type: "start", turns: [] }); + onmessage({ type: "turn", turn }); + expect(worker.sendTurn).toHaveBeenCalledWith(turn); + + onmessage({ type: "turn", turn: { turnNumber: 205, intents: [] } }); + expect(worker.sendTurn).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith( + "got wrong turn have turns 201, received turn 205", + ); + }); + // The (re)join's start message replays every turn so far, so live turns // that beat it there are expected to be dropped: debug, not an error. it("only logs a wrong turn at debug while a start message is awaited", () => { diff --git a/tests/client/ClientGameRunnerRestoreErrors.test.ts b/tests/client/ClientGameRunnerRestoreErrors.test.ts new file mode 100644 index 00000000000..3226e886bdd --- /dev/null +++ b/tests/client/ClientGameRunnerRestoreErrors.test.ts @@ -0,0 +1,86 @@ +import { GameMapType, GameType } from "@openfront/engine-api/game/GameTypes"; +import { GameID } from "@openfront/engine-api/Schemas"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockWorkerInstance = { + initialize: vi.fn(async () => {}), + cleanup: vi.fn(), +}; + +vi.mock("../../src/client/WorkerClient", () => ({ + WorkerClient: vi.fn().mockImplementation(function () { + return mockWorkerInstance; + }), +})); + +vi.mock("@openfront/engine-lib/game/TerrainMapLoader", () => ({ + loadTerrainMap: vi.fn(async () => ({ + gameMap: {}, + miniGameMap: {}, + })), +})); + +vi.mock("@openfront/shared/GameMapLoader", () => ({ + loadMapFiles: vi.fn(async () => ({})), +})); + +vi.mock("../../src/client/render/gl", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + preloadAtlasData: vi.fn(async () => {}), + }; +}); + +const mockRestoreMaps = vi.fn(); + +vi.mock("@openfront/engine-lib/snapshot/MapSnapshot", () => ({ + restoreMapsFromSnapshot: (...args: any[]) => mockRestoreMaps(...args), +})); + +vi.mock("../../src/client/view/GameView", () => ({ + GameView: class {}, +})); + +import { createClientGame } from "../../src/client/ClientGameRunner"; + +describe("createClientGame worker cleanup on restore failure", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + const dummyLobby = { + gameStartInfo: { + gameID: "game123" as GameID, + players: [{ clientID: "c1", username: "Player 1" }], + config: { + gameMap: GameMapType.World, + gameType: GameType.Singleplayer, + }, + }, + playerName: "Player 1", + resumeSnapshot: new Uint8Array([1, 2, 3]), + } as any; + + it("calls worker.cleanup() when restoreMapsFromSnapshot throws", async () => { + mockRestoreMaps.mockImplementationOnce(() => { + throw new Error("corrupt map data"); + }); + + await expect( + createClientGame( + dummyLobby, + "c1", + {} as any, + {} as any, + {} as any, + null, + {} as any, + ), + ).rejects.toThrow("corrupt map data"); + + expect(mockWorkerInstance.cleanup).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/client/LocalServerEdge.test.ts b/tests/client/LocalServerEdge.test.ts index 7855d346c02..27bc83ee73d 100644 --- a/tests/client/LocalServerEdge.test.ts +++ b/tests/client/LocalServerEdge.test.ts @@ -110,4 +110,45 @@ describe("LocalServer edge cases", () => { server.onMessage({ type: "hash", turnNumber: 0, hash: 1111 }); expect(messages.filter((m) => m.type === "desync")).toHaveLength(1); }); + + it("offsets turns and hash lookup by resumeTick when resuming from snapshot", () => { + const messages: ServerMessage[] = []; + const server = new LocalServer( + { + gameStartInfo: makeGameStartInfo(), + resumeSnapshot: new Uint8Array([1, 2, 3]), + resumeTick: 200, + playerName: "TestUser", + playerClanTag: null, + } as any, + false, + new EventBus(), + ); + server.updateCallback( + () => {}, + (message) => messages.push(message), + ); + server.start(); + + // End a turn (generates turn with turnNumber: turnOffset + turns.length) + (server as any).endTurn(); + + const turnMessages = messages.filter((m) => m.type === "turn"); + expect(turnMessages).toHaveLength(1); + expect(turnMessages[0]).toEqual({ + type: "turn", + turn: { + turnNumber: 200, + intents: [], + }, + }); + + // Hash for turn 200 (which is 200 % 100 === 0) + server.onMessage({ type: "hash", turnNumber: 200, hash: 9999 }); + + // Verify turn.hash was updated on the turn at index (200 - 200 = 0) + expect((server as any).turns[0].hash).toBe(9999); + + server.endGame(); + }); }); diff --git a/tests/client/MainInitialize.test.ts b/tests/client/MainInitialize.test.ts index cd965cd39e3..4857c0fb8b4 100644 --- a/tests/client/MainInitialize.test.ts +++ b/tests/client/MainInitialize.test.ts @@ -656,6 +656,7 @@ describe("Client.initialize() booted from Main.ts module scope", () => { expect(messages).not.toContain( translateText("common.backend_unreachable"), ); + document.dispatchEvent(new CustomEvent("leave-lobby")); }); }); @@ -707,6 +708,39 @@ describe("Client.initialize() booted from Main.ts module scope", () => { } }); + it("replaces the URL and clears the replay hash when joining a singleplayer lobby", async () => { + history.replaceState(null, "", "/#replay-viewer=dqKzit4cWu"); + expect(window.location.hash).toBe("#replay-viewer=dqKzit4cWu"); + + const replaceSpy = vi.spyOn(history, "replaceState"); + + try { + document.dispatchEvent( + new CustomEvent("join-lobby", { + detail: { + gameID: "sp_game_clear_hash", + source: "singleplayer", + }, + bubbles: true, + }), + ); + + await vi.waitFor(() => { + const calls = replaceSpy.mock.calls; + const targetCall = calls.find( + (call) => + typeof call[2] === "string" && + call[2].includes("sp_game_clear_hash"), + ); + expect(targetCall).toBeDefined(); + expect(targetCall![2]).not.toContain("#"); + }); + } finally { + document.dispatchEvent(new CustomEvent("leave-lobby")); + replaceSpy.mockRestore(); + } + }); + // Last: the viewer replaces the menu, and any later hash change would // then leave the page. it("opens the replay viewer when the hash changes to one, tearing the menu down like a game start", async () => { diff --git a/tests/client/replay/LocalProcessing.test.ts b/tests/client/replay/LocalProcessing.test.ts index 7b9d695fd35..94fc079de1d 100644 --- a/tests/client/replay/LocalProcessing.test.ts +++ b/tests/client/replay/LocalProcessing.test.ts @@ -16,6 +16,7 @@ import type { ReplayBase, } from "../../../src/client/replay/codec/ReplayTypes"; import { + extractSnapshotInWorker, processInBrowser, type ProcessingHandlers, } from "../../../src/client/replay/LocalProcessing"; @@ -76,7 +77,10 @@ describe("processInBrowser", () => { ); await vi.waitFor(() => expect(worker.posted).toHaveLength(1)); // The two workers get the ends of one channel. - const request = worker.posted[0] as ProcessorRequest; + const request = worker.posted[0] as Extract< + ProcessorRequest, + { engine: MessagePort } + >; expect(request.record).toBe(RECORD); expect(engine.posted).toEqual([ { type: "connect", port: expect.any(MessagePort) }, @@ -175,6 +179,130 @@ describe("processInBrowser", () => { }); }); +const stubMapLoader = { + getMapData: () => ({ + manifest: () => Promise.resolve(new ArrayBuffer(0)), + mapBin: () => Promise.resolve(new ArrayBuffer(0)), + map4xBin: () => Promise.resolve(new ArrayBuffer(0)), + }), +} as any; + +const RECORD_WITH_CONFIG = { + info: { + gameID: "abcd1234", + lobbyCreatedAt: 12345, + config: config(), + players: [], + }, + turns: [], +} as unknown as GameRecord; + +describe("extractSnapshotInWorker", () => { + test("throws when record is missing config", async () => { + await expect( + extractSnapshotInWorker(RECORD, 10, "p1", "c1"), + ).rejects.toThrow("Game record is missing start configuration"); + }); + + test("terminates worker and rejects if signal is aborted while in flight", async () => { + const worker = new FakeWorker(); + const abortController = new AbortController(); + const promise = extractSnapshotInWorker( + RECORD_WITH_CONFIG, + 10, + "p1", + "c1", + undefined, + async () => worker as unknown as Worker, + abortController.signal, + stubMapLoader, + ); + await vi.waitFor(() => expect(worker.posted).toHaveLength(1)); + abortController.abort(); + await expect(promise).rejects.toThrow(); + expect(worker.terminated).toBe(true); + }); + + test("terminates worker immediately if signal is already aborted", async () => { + const createWorker = vi.fn(); + const abortController = new AbortController(); + abortController.abort(); + await expect( + extractSnapshotInWorker( + RECORD_WITH_CONFIG, + 10, + "p1", + "c1", + undefined, + createWorker, + abortController.signal, + stubMapLoader, + ), + ).rejects.toThrow(); + expect(createWorker).not.toHaveBeenCalled(); + }); + + test("does not create worker if map loading fails", async () => { + const createWorker = vi.fn(); + const failingLoader = { + getMapData: () => ({ + manifest: () => Promise.reject(new Error("map fetch failed")), + mapBin: () => Promise.reject(new Error("map fetch failed")), + map4xBin: () => Promise.reject(new Error("map fetch failed")), + }), + } as any; + await expect( + extractSnapshotInWorker( + RECORD_WITH_CONFIG, + 10, + "p1", + "c1", + undefined, + createWorker, + undefined, + failingLoader, + ), + ).rejects.toThrow("map fetch failed"); + expect(createWorker).not.toHaveBeenCalled(); + }); + + test("resolves snapshot, gameStartInfo, and tick from worker result", async () => { + const worker = new FakeWorker(); + const promise = extractSnapshotInWorker( + RECORD_WITH_CONFIG, + 10, + "p1", + "c1", + undefined, + async () => worker as unknown as Worker, + undefined, + stubMapLoader, + ); + await vi.waitFor(() => expect(worker.posted).toHaveLength(1)); + const snapshot = new Uint8Array([9, 8, 7]); + const gameStartInfo = { + ...RECORD_WITH_CONFIG.info, + gameID: "newGame1", + }; + worker.dispatchEvent( + new MessageEvent("message", { + data: { + type: "extract_snapshot_result", + snapshot, + gameStartInfo, + tick: 10, + }, + }), + ); + await expect(promise).resolves.toEqual({ + snapshot, + gameStartInfo, + tick: 10, + }); + expect(worker.terminated).toBe(true); + }); +}); + test("the browser's compression makes a replay the viewer plays as it grows", async () => { const { record } = await playAndArchive({ gameID: "procBRWS1", diff --git a/tests/client/replay/ReplayCosmetics.test.ts b/tests/client/replay/ReplayCosmetics.test.ts index 1e193e3f0ef..9d95d24dd1b 100644 --- a/tests/client/replay/ReplayCosmetics.test.ts +++ b/tests/client/replay/ReplayCosmetics.test.ts @@ -18,6 +18,7 @@ import { PlayerTypeEnum, type PlayerStatic, } from "../../../src/client/render/types"; +import { ReplayAppearance } from "../../../src/client/replay/ReplayAppearance"; import { applyReplayEffects } from "../../../src/client/replay/ReplayEffects"; import { buildReplayPalette, @@ -339,3 +340,48 @@ describe("trail effects", () => { expect(countAt(1)).toBe(0); }); }); + +describe("ReplayAppearance attach and addAppended", () => { + test("tracks drawn count from palette players sent to the renderer", () => { + const players: PlayerStatic[] = [ + player(1, "client1"), + player(2, "client2"), + ]; + const app = new ReplayAppearance( + players, + { + gameID: "g1", + lobbyCreatedAt: 0, + players: [], + config: {} as any, + } as any, + { graphicsOverrides: () => ({ cosmetics: {} }) } as any, + { + setNukeTrailSpiral: vi.fn(), + clearNukeTrailSpiral: vi.fn(), + }, + ); + + const added: any[] = []; + const mockView = { + initSkinAtlas: vi.fn(), + addPlayers: vi.fn((p: any) => added.push(...p)), + applySkins: vi.fn(), + setPlayerEffectPalette: vi.fn(), + } as any; + + app.attach(mockView); + expect((app as any).drawn).toBe(2); + expect(added).toHaveLength(2); + + // Append a new player + players.push(player(3, "client3")); + const adapterMock = { addPlayers: vi.fn() } as any; + app.addAppended(adapterMock); + expect((app as any).drawn).toBe(3); + expect(added).toHaveLength(3); + expect(adapterMock.addPlayers).toHaveBeenCalledWith([ + expect.objectContaining({ smallID: 3 }), + ]); + }); +}); diff --git a/tests/client/replay/ReplayEntry.test.ts b/tests/client/replay/ReplayEntry.test.ts index 272d30a83d4..3edb7788b05 100644 --- a/tests/client/replay/ReplayEntry.test.ts +++ b/tests/client/replay/ReplayEntry.test.ts @@ -126,3 +126,13 @@ test("a game from another build is watched on its versioned shell", async () => vi.unstubAllGlobals(); } }); + +test("replayViewerHref preserves search parameters", () => { + const origSearch = window.location.search; + try { + history.replaceState(null, "", "/?foo=bar"); + expect(replayViewerHref("game123")).toBe("/?foo=bar#replay-viewer=game123"); + } finally { + history.replaceState(null, "", "/" + origSearch); + } +}); diff --git a/tests/client/replay/ReplayRecord.test.ts b/tests/client/replay/ReplayRecord.test.ts index 990c0fdcebb..5e746b0163d 100644 --- a/tests/client/replay/ReplayRecord.test.ts +++ b/tests/client/replay/ReplayRecord.test.ts @@ -70,6 +70,11 @@ test.each([ ["a server error", () => new Response("", { status: 502 }), "unreachable"], ["not JSON", () => new Response(""), "unreachable"], ["no network", () => new TypeError("Failed to fetch"), "unreachable"], + [ + "timeout or abort", + () => new DOMException("The operation was aborted.", "TimeoutError"), + "unreachable", + ], ])("%s → %s", async (_name, res, kind) => { const got = await fetchReplayRecord("recFETCH1", { apiBase: API, @@ -79,6 +84,20 @@ test.each([ expect(got).toEqual({ kind }); }); +test("passes signal with finite timeout in options", async () => { + let capturedInit: RequestInit | undefined; + const fetchFn = vi.fn(async (_url: string, init?: RequestInit) => { + capturedInit = init; + return Response.json(archived); + }); + await fetchReplayRecord("recFETCH1", { + apiBase: API, + ownCommit: OWN, + fetchFn: fetchFn as typeof fetch, + }); + expect(capturedInit?.signal).toBeInstanceOf(AbortSignal); +}); + test("a record can be verified only if it has hashes", () => { const record = (hashes: (number | undefined)[]) => ({ diff --git a/tests/client/replay/ReplayViewerContinueCancel.test.ts b/tests/client/replay/ReplayViewerContinueCancel.test.ts new file mode 100644 index 00000000000..59bc06238ac --- /dev/null +++ b/tests/client/replay/ReplayViewerContinueCancel.test.ts @@ -0,0 +1,185 @@ +import { Difficulty } from "@openfront/engine-api/game/GameTypes"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { extractSnapshotInWorker } from "../../../src/client/replay/LocalProcessing"; +import { fetchReplayRecord } from "../../../src/client/replay/ReplayRecord"; +import { ReplayViewer } from "../../../src/client/replay/ReplayViewer"; + +vi.mock("../../../src/client/replay/ReplayRecord", () => ({ + fetchReplayRecord: vi.fn(), +})); + +vi.mock("../../../src/client/replay/LocalProcessing", () => ({ + processInBrowser: vi.fn(), + extractSnapshotInWorker: vi.fn(), +})); + +vi.mock("../../../src/client/replay/ReplayStore", () => ({ + replayStore: { get: vi.fn(), put: vi.fn(), remove: vi.fn() }, +})); + +vi.mock( + "@openfront/engine-lib/game/TerrainMapLoader", + async (importOriginal) => ({ + ...(await importOriginal()), + loadTerrainMap: vi.fn(), + }), +); + +vi.mock("../../../src/client/render/gl", async (importOriginal) => ({ + ...(await importOriginal()), + preloadAtlasData: vi.fn(async () => {}), + MapRenderer: vi.fn(), +})); + +vi.mock("../../../src/client/replay/ReplayEntry", () => ({ + versionedViewerUrl: vi.fn(async () => null), + classicReplayHref: () => "/game/test1234", +})); + +describe("ReplayViewer continue game cancellation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not dispatch join-lobby if modal is closed during fetchReplayRecord", async () => { + const viewer = new ReplayViewer() as any; + viewer.gameID = "test1234"; + + let resolveFetch!: (value: any) => void; + vi.mocked(fetchReplayRecord).mockReturnValue( + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const joinLobbyListener = vi.fn(); + document.addEventListener("join-lobby", joinLobbyListener); + + viewer.openContinueModal(); + const continuePromise = viewer.handleContinueGame( + new CustomEvent("continue", { + detail: { playerID: "p1", difficulty: Difficulty.Medium }, + }), + ); + + // User closes modal before fetch completes + viewer.closeContinueModal(); + + resolveFetch({ + kind: "record", + record: { info: { gameID: "test1234" } }, + }); + + await continuePromise; + + expect(extractSnapshotInWorker).not.toHaveBeenCalled(); + expect(joinLobbyListener).not.toHaveBeenCalled(); + document.removeEventListener("join-lobby", joinLobbyListener); + }); + + it("does not dispatch join-lobby if modal is closed during extractSnapshotInWorker", async () => { + const viewer = new ReplayViewer() as any; + viewer.gameID = "test1234"; + viewer.record = { info: { gameID: "test1234" } } as any; + + let resolveExtract!: (value: any) => void; + vi.mocked(extractSnapshotInWorker).mockReturnValue( + new Promise((resolve) => { + resolveExtract = resolve; + }), + ); + + const joinLobbyListener = vi.fn(); + document.addEventListener("join-lobby", joinLobbyListener); + + viewer.openContinueModal(); + const continuePromise = viewer.handleContinueGame( + new CustomEvent("continue", { + detail: { playerID: "p1", difficulty: Difficulty.Medium }, + }), + ); + expect(extractSnapshotInWorker).toHaveBeenCalledTimes(1); + const signal = vi.mocked(extractSnapshotInWorker).mock.calls[0][6]; + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal?.aborted).toBe(false); + + // User closes modal before worker finishes + viewer.closeContinueModal(); + expect(signal?.aborted).toBe(true); + + resolveExtract({ + snapshot: new Uint8Array([1, 2, 3]), + gameStartInfo: { gameID: "test1234_c" }, + }); + + await continuePromise; + + expect(joinLobbyListener).not.toHaveBeenCalled(); + document.removeEventListener("join-lobby", joinLobbyListener); + }); + + it("dispatches join-lobby when the request remains active", async () => { + const viewer = new ReplayViewer() as any; + viewer.gameID = "test1234"; + viewer.record = { info: { gameID: "test1234" } } as any; + + vi.mocked(extractSnapshotInWorker).mockResolvedValue({ + snapshot: new Uint8Array([1, 2, 3]), + gameStartInfo: { gameID: "test1234_c" } as any, + tick: 42, + }); + + const joinLobbyListener = vi.fn(); + document.addEventListener("join-lobby", joinLobbyListener); + + viewer.openContinueModal(); + await viewer.handleContinueGame( + new CustomEvent("continue", { + detail: { playerID: "p1", difficulty: Difficulty.Medium }, + }), + ); + + expect(joinLobbyListener).toHaveBeenCalledTimes(1); + expect(joinLobbyListener.mock.calls[0][0].detail.resumeTick).toBe(42); + expect(viewer.continueModalOpen).toBe(false); + document.removeEventListener("join-lobby", joinLobbyListener); + }); + + it("dispatches join-lobby as cancelable and keeps continue modal open when join is prevented", async () => { + const viewer = new ReplayViewer() as any; + viewer.gameID = "test1234"; + viewer.record = { info: { gameID: "test1234" } } as any; + + vi.mocked(extractSnapshotInWorker).mockResolvedValue({ + snapshot: new Uint8Array([1, 2, 3]), + gameStartInfo: { gameID: "test1234_c" } as any, + tick: 42, + }); + + let dispatchedEvent: CustomEvent | null = null; + const joinLobbyListener = vi.fn((e: Event) => { + dispatchedEvent = e as CustomEvent; + e.preventDefault(); + }); + document.addEventListener("join-lobby", joinLobbyListener); + + viewer.openContinueModal(); + await viewer.handleContinueGame( + new CustomEvent("continue", { + detail: { playerID: "p1", difficulty: Difficulty.Medium }, + }), + ); + + expect(joinLobbyListener).toHaveBeenCalledTimes(1); + expect(dispatchedEvent).not.toBeNull(); + expect(dispatchedEvent!.cancelable).toBe(true); + expect(dispatchedEvent!.defaultPrevented).toBe(true); + expect(viewer.continueModalOpen).toBe(true); + + document.removeEventListener("join-lobby", joinLobbyListener); + }); +}); diff --git a/tests/client/replay/ReplayViewerParts.test.ts b/tests/client/replay/ReplayViewerParts.test.ts index d29cf7306dc..e9af2050e7f 100644 --- a/tests/client/replay/ReplayViewerParts.test.ts +++ b/tests/client/replay/ReplayViewerParts.test.ts @@ -1,9 +1,12 @@ +import * as Utils from "../../../src/client/Utils"; +import { ContinueGameModal } from "../../../src/client/replay/ContinueGameModal"; import { CameraGestures, ReplayCamera, } from "../../../src/client/replay/ReplayCamera"; import { formatGameTime, + ReplayControls, timelineFrames, } from "../../../src/client/replay/ReplayControls"; import { ReplayNukedLayers } from "../../../src/client/replay/ReplayNukedLayers"; @@ -235,3 +238,117 @@ describe("the timeline of a game still being processed", () => { expect(timelineFrames(15691, 15000, true)).toBe(15691); }); }); + +describe("ContinueGameModal willUpdate", () => { + test("resets selectedPlayerID from initialPlayerID on open", () => { + const modal = new ContinueGameModal(); + modal.players = [ + { id: "p1", name: "P1", smallID: 1, troops: 10, tiles: 10 }, + { id: "p2", name: "P2", smallID: 2, troops: 20, tiles: 20 }, + ]; + modal.initialPlayerID = "p2"; + modal.open = true; + modal.willUpdate(new Map([["open", false]])); + expect((modal as any).selectedPlayerID).toBe("p2"); + + // Player switches selection, closes modal, and re-opens + (modal as any).selectedPlayerID = "p1"; + modal.open = false; + modal.willUpdate(new Map([["open", true]])); + expect((modal as any).searchQuery).toBe(""); + + modal.open = true; + modal.willUpdate(new Map([["open", false]])); + expect((modal as any).selectedPlayerID).toBe("p2"); + }); + + test("falls back to first available player if initialPlayerID is not found", () => { + const modal = new ContinueGameModal(); + modal.players = [ + { id: "p1", name: "P1", smallID: 1, troops: 10, tiles: 10 }, + { id: "p2", name: "P2", smallID: 2, troops: 20, tiles: 20 }, + ]; + modal.initialPlayerID = "nonexistent"; + modal.open = true; + modal.willUpdate(new Map([["open", false]])); + expect((modal as any).selectedPlayerID).toBe("p1"); + }); + + test("resets selectedPlayerID to empty if players list is empty", () => { + const modal = new ContinueGameModal(); + modal.players = []; + modal.initialPlayerID = "p1"; + modal.open = true; + modal.willUpdate(new Map([["open", false]])); + expect((modal as any).selectedPlayerID).toBe(""); + }); + + test("allows closing while loading and resets loading state", () => { + const modal = new ContinueGameModal(); + modal.open = true; + modal.setLoading(true, "some error"); + expect((modal as any).loading).toBe(true); + + let closed = false; + modal.addEventListener("close", () => { + closed = true; + }); + + (modal as any).close(); + expect(modal.open).toBe(false); + expect((modal as any).loading).toBe(false); + expect((modal as any).error).toBe(""); + expect(closed).toBe(true); + }); + + test("renders formatted troop counts using renderTroops", async () => { + const spy = vi.spyOn(Utils, "translateText"); + const modal = new ContinueGameModal(); + modal.players = [ + { + id: "p1", + name: "P1", + smallID: 1, + troops: 15000, + tiles: 10, + controlPercent: 25.5, + }, + ]; + modal.open = true; + document.body.appendChild(modal); + await modal.updateComplete; + expect(spy).toHaveBeenCalledWith("replay_viewer.player_stats_troops", { + troops: "1.50K", + }); + modal.remove(); + spy.mockRestore(); + }); +}); + +describe("ReplayControls timeline seeking", () => { + test("does not emit replay-seek when loaded is 0", () => { + const controls = new ReplayControls(); + controls.loaded = 0; + const seeks: number[] = []; + controls.addEventListener("replay-seek", (e: any) => seeks.push(e.detail)); + + (controls as any).onTimelineInput({ target: { value: "5" } } as any); + expect(seeks).toHaveLength(0); + }); + + test("clamps emitted seek frame between 0 and loaded - 1", () => { + const controls = new ReplayControls(); + controls.loaded = 50; + const seeks: number[] = []; + controls.addEventListener("replay-seek", (e: any) => seeks.push(e.detail)); + + (controls as any).onTimelineInput({ target: { value: "100" } } as any); + expect(seeks).toEqual([49]); + + (controls as any).onTimelineInput({ target: { value: "-10" } } as any); + expect(seeks).toEqual([49, 0]); + + (controls as any).onTimelineInput({ target: { value: "25" } } as any); + expect(seeks).toEqual([49, 0, 25]); + }); +}); diff --git a/tests/client/replay/SnapshotExtractor.test.ts b/tests/client/replay/SnapshotExtractor.test.ts new file mode 100644 index 00000000000..cfe2bf0855d --- /dev/null +++ b/tests/client/replay/SnapshotExtractor.test.ts @@ -0,0 +1,412 @@ +import { + Difficulty, + GameMapSize, + GameMapType, + GameMode, + GameType, + PlayerType, +} from "@openfront/engine-api/game/GameTypes"; +import { + ErrorUpdate, + GameUpdateViewData, +} from "@openfront/engine-api/game/GameUpdates"; +import { NationExecution } from "@openfront/engine/execution/NationExecution"; +import { TribeExecution } from "@openfront/engine/execution/TribeExecution"; +import { Player } from "@openfront/engine/game/Game"; +import { + createGameRunner, + createGameRunnerFromSnapshot, +} from "@openfront/engine/GameRunner"; +import { extractSnapshot } from "@openfront/engine/snapshot/SnapshotExtractor"; +import { GameMapLoader, loadMapFiles } from "@openfront/shared/GameMapLoader"; +import { decompressGameRecord, generateID } from "@openfront/shared/SharedUtil"; +import { GameRecord } from "@openfront/shared/WireSchemas"; +import { describe, expect, test } from "vitest"; +import { wireGameStartInfo } from "../../../src/client/replay/processor/ReplayProcessor"; +import { config, human, mapLoader, playAndArchive } from "./util/ArchiveGame"; + +const loadMap = (c: { gameMap: GameMapType; gameMapSize: GameMapSize }) => + loadMapFiles(mapLoader, c.gameMap, c.gameMapSize); + +async function extractSnapshotFromRecord(opts: { + record: GameRecord; + targetTick: number; + chosenPlayerID: string; + localClientID: string; + difficulty?: Difficulty; + mapLoader: GameMapLoader; +}) { + const { turns } = decompressGameRecord({ ...opts.record }); + const gameStart = wireGameStartInfo(opts.record); + const mapFiles = await loadMapFiles( + opts.mapLoader, + gameStart.config.gameMap, + gameStart.config.gameMapSize, + ); + return extractSnapshot({ + gameStartInfo: gameStart, + turns, + mapFiles, + targetTick: opts.targetTick, + chosenPlayerID: opts.chosenPlayerID, + localClientID: opts.localClientID, + difficulty: opts.difficulty, + newGameID: generateID(), + }); +} + +describe("SnapshotExtractor", () => { + test("extracts playable snapshot at target tick and converts other humans to bots", async () => { + const p1 = human(1); + const p2 = human(2); + + const gameConfig = config({ + gameMap: GameMapType.World, + gameMapSize: GameMapSize.Normal, + gameMode: GameMode.FFA, + gameType: GameType.Public, + bots: 2, + }); + + const { record } = await playAndArchive({ + gameID: "TEST0001", + config: gameConfig, + players: [p1, p2], + ticks: 40, + }); + + // Extract snapshot at tick 20, taking over as Human 2 + const result = await extractSnapshotFromRecord({ + record, + targetTick: 20, + chosenPlayerID: "client002", + localClientID: "MYCLIENT1", + difficulty: Difficulty.Hard, + mapLoader, + }); + + expect(result.snapshot).toBeInstanceOf(Uint8Array); + expect(result.tick).toBe(20); + expect(result.gameStartInfo.config.gameType).toBe(GameType.Singleplayer); + expect(result.gameStartInfo.config.difficulty).toBe(Difficulty.Hard); + expect(result.gameStartInfo.players.length).toBe(1); + expect(result.gameStartInfo.players[0].username).toBe("Human 2"); + expect(result.gameStartInfo.players[0].clientID).toBe("MYCLIENT1"); + + // Restore the runner from snapshot + let updateReceived = false; + const restoredRunner = await createGameRunnerFromSnapshot( + result.gameStartInfo, + result.snapshot, + "MYCLIENT1", + await loadMap(result.gameStartInfo.config), + (gu: GameUpdateViewData | ErrorUpdate) => { + if (!("errMsg" in gu)) { + updateReceived = true; + } + }, + ); + + const game = restoredRunner.game; + expect(game.ticks()).toBe(20); + + // Verify Human 2 is local human + const human2 = game.playerByClientID("MYCLIENT1"); + expect(human2).not.toBeNull(); + expect(human2?.name()).toBe("Human 2"); + expect(human2?.type()).toBe(PlayerType.Human); + + // Verify Human 1 was converted to Bot/Nation + const human1 = game + .allPlayers() + .find((p: Player) => p.name() === "Human 1"); + expect(human1).not.toBeUndefined(); + expect(human1?.type()).toBe(PlayerType.Nation); + expect(human1?.clientID()).toBeNull(); + + // Verify game can continue ticking + restoredRunner.addTurn({ + turnNumber: 20, + intents: [], + }); + const tickResult = restoredRunner.executeNextTick(); + expect(tickResult).toBe(true); + expect(game.ticks()).toBe(21); + expect(updateReceived).toBe(true); + }); + + test("can take over an AI nation and play as it", async () => { + const p1 = human(1); + + const gameConfig = config({ + gameMap: GameMapType.World, + gameMapSize: GameMapSize.Normal, + gameMode: GameMode.FFA, + gameType: GameType.Public, + bots: 3, + }); + + const { record } = await playAndArchive({ + gameID: "TEST0002", + config: gameConfig, + players: [p1], + ticks: 30, + }); + + // Run to target tick to find a real nation player + const preRunner = await createGameRunner( + record.info, + undefined, + await loadMap(record.info.config), + () => {}, + ); + const turns = decompressGameRecord(record).turns; + for (let t = 0; t < 15; t++) { + preRunner.addTurn(turns[t]); + preRunner.executeNextTick(); + } + const preGame = preRunner.game; + const nationPlayer = preGame + .players() + .find((p: Player) => p.type() === PlayerType.Nation); + expect(nationPlayer).toBeDefined(); + const nationID = nationPlayer!.id(); + const nationName = nationPlayer!.name(); + + // Extract snapshot at tick 15, taking over as the nation + const result = await extractSnapshotFromRecord({ + record, + targetTick: 15, + chosenPlayerID: nationID, + localClientID: "MYCLIENT2", + mapLoader, + }); + + const restoredRunner = await createGameRunnerFromSnapshot( + result.gameStartInfo, + result.snapshot, + "MYCLIENT2", + await loadMap(result.gameStartInfo.config), + () => {}, + ); + + const game = restoredRunner.game; + expect(game.ticks()).toBe(15); + + const player = game.playerByClientID("MYCLIENT2"); + expect(player).not.toBeNull(); + expect(player?.id()).toBe(nationID); + expect(player?.name()).toBe(nationName); + expect(player?.type()).toBe(PlayerType.Human); + + // Verify AI execution was removed for the chosen player + for (const exec of game.executions()) { + if (exec instanceof NationExecution) { + expect(exec.playerID()).not.toBe(player?.id()); + } + if (exec instanceof TribeExecution) { + expect(exec.playerID()).not.toBe(player?.id()); + } + } + + restoredRunner.addTurn({ turnNumber: 15, intents: [] }); + expect(restoredRunner.executeNextTick()).toBe(true); + expect(game.ticks()).toBe(16); + }); + + test("removes TribeExecution and NationExecution when taking over a bot", async () => { + const p1 = human(1); + const gameConfig = config({ + gameMap: GameMapType.World, + gameMapSize: GameMapSize.Normal, + gameMode: GameMode.FFA, + gameType: GameType.Public, + bots: 5, + }); + + const { record } = await playAndArchive({ + gameID: "TEST0003", + config: gameConfig, + players: [p1], + tribes: [{ name: "SaharaNomads" }], + ticks: 40, + }); + + // Before extraction, verify that the selected tribe exists and has a TribeExecution + const preRunner = await createGameRunner( + record.info, + undefined, + await loadMap(record.info.config), + () => {}, + ); + const turns = decompressGameRecord(record).turns; + for (let t = 0; t < 20; t++) { + preRunner.addTurn(turns[t]); + preRunner.executeNextTick(); + } + const preGame = preRunner.game; + const botPlayer = preGame + .players() + .find( + (p: Player) => + p.type() === PlayerType.Bot && p.name() === "SaharaNomads", + ); + expect(botPlayer).toBeDefined(); + + const hasTribeExecBefore = (preGame as any) + .executions() + .some( + (exec: any) => + exec instanceof TribeExecution && exec.playerID() === botPlayer!.id(), + ); + expect(hasTribeExecBefore).toBe(true); + + const result = await extractSnapshotFromRecord({ + record, + targetTick: 20, + chosenPlayerID: botPlayer!.id(), + localClientID: "MYCLIENT3", + mapLoader, + }); + + const restoredRunner = await createGameRunnerFromSnapshot( + result.gameStartInfo, + result.snapshot, + "MYCLIENT3", + await loadMap(result.gameStartInfo.config), + () => {}, + ); + + const game = restoredRunner.game; + const player = game.playerByClientID("MYCLIENT3"); + expect(player).not.toBeNull(); + expect(player?.type()).toBe(PlayerType.Human); + expect(player?.id()).toBe(botPlayer!.id()); + + let chosenHasTribeExec = false; + let otherTribesExecCount = 0; + for (const exec of game.executions()) { + if (exec instanceof NationExecution) { + expect(exec.playerID()).not.toBe(player?.id()); + } + if (exec instanceof TribeExecution) { + if (exec.playerID() === player?.id()) { + chosenHasTribeExec = true; + } else { + otherTribesExecCount++; + } + } + } + expect(chosenHasTribeExec).toBe(false); + expect(otherTribesExecCount).toBeGreaterThan(0); + }); + + test("refreshes attack rate of existing active NationExecutions when difficulty changes", async () => { + const p1 = human(1); + const p2 = human(2); + const gameConfig = config({ + gameMap: GameMapType.World, + gameMapSize: GameMapSize.Normal, + gameMode: GameMode.FFA, + gameType: GameType.Public, + bots: 2, + difficulty: Difficulty.Easy, + }); + + const { record } = await playAndArchive({ + gameID: "TESTDIFF1", + config: gameConfig, + players: [p1, p2], + ticks: 30, + }); + + const result = await extractSnapshotFromRecord({ + record, + targetTick: 20, + chosenPlayerID: "client001", + localClientID: "MYCLIENT_DIFF", + difficulty: Difficulty.Impossible, + mapLoader, + }); + + const restoredRunner = await createGameRunnerFromSnapshot( + result.gameStartInfo, + result.snapshot, + "MYCLIENT_DIFF", + await loadMap(result.gameStartInfo.config), + () => {}, + ); + + const game = restoredRunner.game; + const initializedNationExecs = (game as any) + .executions() + .filter( + (e: unknown): e is NationExecution => + e instanceof NationExecution && e.isActive() && e.isInitialized(), + ); + + expect(initializedNationExecs.length).toBeGreaterThan(0); + for (const exec of initializedNationExecs) { + // Impossible difficulty has attack rate 30..50 + expect(exec.currentAttackRate()).toBeGreaterThanOrEqual(30); + expect(exec.currentAttackRate()).toBeLessThanOrEqual(50); + expect(exec.currentAttackTick()).toBeGreaterThanOrEqual(0); + expect(exec.currentAttackTick()).toBeLessThan(exec.currentAttackRate()); + } + + // After adding a turn and ticking the restored runner, uninitialized executions initialize with the new difficulty + restoredRunner.addTurn({ turnNumber: 20, intents: [] }); + expect(restoredRunner.executeNextTick()).toBe(true); + + const allNationExecs = (game as any) + .executions() + .filter( + (e: unknown): e is NationExecution => + e instanceof NationExecution && e.isActive(), + ); + + for (const exec of allNationExecs) { + expect(exec.isInitialized()).toBe(true); + expect(exec.currentAttackRate()).toBeGreaterThanOrEqual(30); + expect(exec.currentAttackRate()).toBeLessThanOrEqual(50); + } + }); + + test("extracts playable snapshot at target tick 0 without executing turn 0", async () => { + const p1 = human(1); + const gameConfig = config({ + gameMap: GameMapType.World, + gameMapSize: GameMapSize.Normal, + gameMode: GameMode.FFA, + gameType: GameType.Public, + bots: 2, + }); + + const { record } = await playAndArchive({ + gameID: "TESTTICK0", + config: gameConfig, + players: [p1], + ticks: 10, + }); + + const result = await extractSnapshotFromRecord({ + record, + targetTick: 0, + chosenPlayerID: "client001", + localClientID: "MYCLIENT0", + mapLoader, + }); + + const restoredRunner = await createGameRunnerFromSnapshot( + result.gameStartInfo, + result.snapshot, + "MYCLIENT0", + await loadMap(result.gameStartInfo.config), + () => {}, + ); + + const game = restoredRunner.game; + expect(game.ticks()).toBe(0); + expect(game.inSpawnPhase()).toBe(true); + }); +}); diff --git a/tests/client/replay/codec/EntitySchema.test.ts b/tests/client/replay/codec/EntitySchema.test.ts index b0b4ddfb92b..aa0bfa4d5d7 100644 --- a/tests/client/replay/codec/EntitySchema.test.ts +++ b/tests/client/replay/codec/EntitySchema.test.ts @@ -357,3 +357,55 @@ describe("attack lists", () => { expect(target.outgoingAttacks).toEqual(outgoingAttacks); }); }); + +describe("primitive integer codec validation", () => { + const betrayals = PLAYER_FIELDS.find((f) => f.keys.includes("betrayals"))!; + const traitorTicks = PLAYER_FIELDS.find((f) => + f.keys.includes("traitorRemainingTicks"), + )!; + const markedTick = PLAYER_FIELDS.find((f) => + f.keys.includes("markedDoomsdayClockTick"), + )!; + + test("U16 rejects non-integers and out-of-range values", () => { + const w = new BinaryWriter(64); + for (const invalid of [-1, 65536, 1.5, NaN, Infinity]) { + expect(() => + betrayals.write( + w, + { ...basePlayer, betrayals: invalid }, + basePlayer, + encodeCtx, + ), + ).toThrow(RangeError); + } + }); + + test("U32 rejects non-integers and out-of-range values", () => { + const w = new BinaryWriter(64); + for (const invalid of [-1, 4294967296, 3.14, NaN, Infinity]) { + expect(() => + traitorTicks.write( + w, + { ...basePlayer, traitorRemainingTicks: invalid }, + basePlayer, + encodeCtx, + ), + ).toThrow(RangeError); + } + }); + + test("I32 rejects non-integers and out-of-range values", () => { + const w = new BinaryWriter(64); + for (const invalid of [2147483648, -2147483649, 10.2, NaN, Infinity]) { + expect(() => + markedTick.write( + w, + { ...basePlayer, markedDoomsdayClockTick: invalid }, + basePlayer, + encodeCtx, + ), + ).toThrow(RangeError); + } + }); +}); diff --git a/tests/client/replay/codec/ReplayFormat.test.ts b/tests/client/replay/codec/ReplayFormat.test.ts index 0fa0480617a..382b953e951 100644 --- a/tests/client/replay/codec/ReplayFormat.test.ts +++ b/tests/client/replay/codec/ReplayFormat.test.ts @@ -230,6 +230,27 @@ describe("replay format", () => { expect(() => reader.seek(-1)).toThrow(RangeError); }); + test("delta tile ref out of bounds throws RangeError", async () => { + const enc = new StreamingEncoder({ + mapWidth: 2, + mapHeight: 2, + terrain: new Uint8Array(4), + gzip, + keyframeInterval: 10, + gameStartInfo: {}, + numLandTiles: 4, + }); + enc.pushFrame(frame(1, {})); + enc.pushFrame( + frame(2, { + packedTileUpdates: Uint32Array.from([10, 1]), + }), + ); + const reader = openReader(await finish(enc)); + reader.next(); + expect(() => reader.next()).toThrow(RangeError); + }); + test("terrain changes: per frame, and in every later keyframe", async () => { // A 4x2 map, base terrain byte 0x80 (land). Tile 5 becomes water // (0x00) at tick 3 and stays; tile 6 does at tick 4 and goes back to diff --git a/tests/client/replay/util/Expect.ts b/tests/client/replay/util/Expect.ts index 8863915cf47..1aebfdd3956 100644 --- a/tests/client/replay/util/Expect.ts +++ b/tests/client/replay/util/Expect.ts @@ -27,7 +27,12 @@ export function expectFrameMatchesTruth( for (const [smallID, t] of truth.players) { const p = frame.players.get(smallID); - if (p === undefined) continue; // never emitted on the wire (e.g. not yet spawned) + if (p === undefined) { + expect(t.tilesOwned, `${at} unspawned player ${smallID} tilesOwned`).toBe( + 0, + ); + continue; // never emitted on the wire (e.g. not yet spawned) + } expectSame( { tilesOwned: p.tilesOwned, diff --git a/tests/client/view/GameView.test.ts b/tests/client/view/GameView.test.ts index 6403446e7cd..d5212d03626 100644 --- a/tests/client/view/GameView.test.ts +++ b/tests/client/view/GameView.test.ts @@ -11,6 +11,10 @@ import { UnitType } from "@openfront/engine-api/game/GameTypes"; import { GameUpdateType } from "@openfront/engine-api/game/GameUpdates"; +import { + MotionPlanRecord, + packMotionPlans, +} from "@openfront/engine-lib/game/MotionPlans"; import { describe, expect, it } from "vitest"; import { makeEmptyGu, @@ -896,3 +900,134 @@ describe("GameView.unitsOwnedBy — per-tick owner index", () => { expect(alice.units()).toHaveLength(3); }); }); + +describe("GameView.update — motion plans and snapshot restore", () => { + it("fresh client without retained motion plan updates unit position from snapshot and advances on next update", () => { + const game = makeGameView(); + const unitId = 42; + const initialTile = 10; + const advancedTile = 20; + + const snapshotGu = makeEmptyGu(50); + snapshotGu.updates[GameUpdateType.Unit] = [ + makeUnitUpdate({ + id: unitId, + pos: initialTile, + unitType: UnitType.TransportShip, + }), + ]; + game.update(snapshotGu); + expect(game.unit(unitId)?.tile()).toBe(initialTile); + + const nextGu = makeEmptyGu(51); + nextGu.updates[GameUpdateType.Unit] = [ + makeUnitUpdate({ + id: unitId, + pos: advancedTile, + unitType: UnitType.TransportShip, + }), + ]; + game.update(nextGu); + expect(game.unit(unitId)?.tile()).toBe(advancedTile); + expect(game.unit(unitId)?.tile()).not.toBe(initialTile); + }); + + it("client retaining a grid plan drives unit position across snapshot update", () => { + const game = makeGameView(); + const unitId = 42; + const plannedTile1 = 10; + const plannedTile2 = 11; + const plannedTile3 = 12; + const gridPlan: MotionPlanRecord = { + kind: "grid", + unitId, + planId: 100, + startTick: 50, + ticksPerStep: 1, + path: new Uint32Array([plannedTile1, plannedTile2, plannedTile3]), + }; + + // Preload client with unit and grid plan from before snapshot + const preSnapshotGu = makeEmptyGu(49); + preSnapshotGu.updates[GameUpdateType.Unit] = [ + makeUnitUpdate({ id: unitId, pos: plannedTile1 }), + ]; + preSnapshotGu.packedMotionPlans = packMotionPlans([gridPlan]); + game.update(preSnapshotGu); + expect(game.motionPlannedUnitIds()).toContain(unitId); + + // When snapshot update arrives, client's position is driven by its grid plan + const snapshotGu = makeEmptyGu(50); + snapshotGu.updates[GameUpdateType.Unit] = [ + makeUnitUpdate({ id: unitId, pos: plannedTile1 }), + ]; + game.update(snapshotGu); + expect(game.unit(unitId)?.tile()).toBe(plannedTile1); + + // Next tick unit update arrives: grid plan advances client to plannedTile2 + const nextGu = makeEmptyGu(51); + nextGu.updates[GameUpdateType.Unit] = [ + makeUnitUpdate({ id: unitId, pos: plannedTile2 }), + ]; + game.update(nextGu); + expect(game.unit(unitId)?.tile()).toBe(plannedTile2); + }); + + it("client retaining a train plan drives engine and car positions across snapshot update", () => { + const game = makeGameView(); + const engineId = 9001; + const carId = 9002; + const trainTrack = [1, 2, 3, 4, 5, 6, 7, 8]; + const trainPlan: MotionPlanRecord = { + kind: "train", + engineUnitId: engineId, + carUnitIds: [carId], + planId: 200, + startTick: 50, + speed: 1, + spacing: 1, + path: new Uint32Array(trainTrack), + }; + + const trainUnits = [ + makeUnitUpdate({ + id: engineId, + unitType: UnitType.Train, + pos: trainTrack[0], + lastPos: trainTrack[0], + }), + makeUnitUpdate({ + id: carId, + unitType: UnitType.Train, + pos: trainTrack[0], + lastPos: trainTrack[0], + }), + ]; + + const preSnapshotGu = makeEmptyGu(49); + preSnapshotGu.updates[GameUpdateType.Unit] = trainUnits; + preSnapshotGu.packedMotionPlans = packMotionPlans([trainPlan]); + game.update(preSnapshotGu); + expect(game.motionPlannedUnitIds()).toContain(engineId); + expect(game.motionPlannedUnitIds()).toContain(carId); + + // Snapshot update contains engine and car units + const snapshotGu = makeEmptyGu(50); + snapshotGu.updates[GameUpdateType.Unit] = trainUnits; + game.update(snapshotGu); + expect(game.unit(engineId)?.tile()).toBe(trainTrack[0]); + + // Next unit update arrives: train plan advances + const nextGu = makeEmptyGu(51); + nextGu.updates[GameUpdateType.Unit] = [ + makeUnitUpdate({ + id: engineId, + unitType: UnitType.Train, + pos: trainTrack[1], + lastPos: trainTrack[0], + }), + ]; + game.update(nextGu); + expect(game.unit(engineId)?.tile()).toBe(trainTrack[1]); + }); +}); diff --git a/tests/core/game/GameImpl.test.ts b/tests/core/game/GameImpl.test.ts index 6c954af4748..cce7a239154 100644 --- a/tests/core/game/GameImpl.test.ts +++ b/tests/core/game/GameImpl.test.ts @@ -1,16 +1,19 @@ -import { GameID } from "@openfront/engine-api/Schemas"; -import { AttackExecution } from "@openfront/engine/execution/AttackExecution"; -import { SpawnExecution } from "@openfront/engine/execution/SpawnExecution"; -//import { TransportShipExecution } from "@openfront/engine/execution/TransportShipExecution"; import { TileRef } from "@openfront/engine-api/game/GameMap"; import { + Difficulty, GameType, PlayerInfo, PlayerType, } from "@openfront/engine-api/game/GameTypes"; import { GameUpdateType } from "@openfront/engine-api/game/GameUpdates"; +import { GameID } from "@openfront/engine-api/Schemas"; import { AllianceRequestExecution } from "@openfront/engine/execution/alliance/AllianceRequestExecution"; +import { AttackExecution } from "@openfront/engine/execution/AttackExecution"; +import { NationExecution } from "@openfront/engine/execution/NationExecution"; +import { SpawnExecution } from "@openfront/engine/execution/SpawnExecution"; +import { TribeExecution } from "@openfront/engine/execution/TribeExecution"; import { Game, Player } from "@openfront/engine/game/Game"; +import { GameImpl } from "@openfront/engine/game/GameImpl"; import { setup } from "../../util/Setup"; const gameID: GameID = "game_id"; @@ -168,4 +171,132 @@ describe("GameImpl", () => { expect(spawnUpdates[GameUpdateType.SpawnPhaseEnd]).toHaveLength(1); expect(singleplayerGame.isSpawnImmunityActive()).toBe(true); }); + + test("convertHumanToNation converts human player to nation bot and adds NationExecution", () => { + expect(defender.type()).toBe(PlayerType.Human); + const nationExec = game.convertHumanToNation(defender, gameID); + + expect(nationExec).toBeInstanceOf(NationExecution); + expect(defender.type()).toBe(PlayerType.Nation); + expect(defender.clientID()).toBeNull(); + expect(defender.isLobbyCreator()).toBe(false); + + // Verify executions list includes the new NationExecution + expect(game.executions()).toContain(nationExec); + + // Verify registry updates + const nationEntry = game + .nations() + .find((n) => n.playerInfo.id === defender.id()); + expect(nationEntry).toBeDefined(); + expect(nationEntry?.playerInfo.playerType).toBe(PlayerType.Nation); + + // Verify player info lookup reflects the bot + const info = (game as GameImpl).findPlayerInfo(defender.id()); + expect(info?.playerType).toBe(PlayerType.Nation); + }); + + test("takeoverPlayer converts bot to human, assigns clientID, updates registries and removes bot executions", () => { + // Convert defender to nation first + const nationExec = game.convertHumanToNation(defender, gameID); + expect(game.executions()).toContain(nationExec); + expect(defender.type()).toBe(PlayerType.Nation); + + // Also add a TribeExecution to verify tribe executions are removed as well + const tribeExec = new TribeExecution(defender); + game.addExecution(tribeExec); + expect(game.executions()).toContain(tribeExec); + + // Now takeover defender with a new client ID + game.takeoverPlayer(defender, "new_client_123"); + + expect(defender.type()).toBe(PlayerType.Human); + expect(defender.clientID()).toBe("new_client_123"); + expect(defender.info().clientID).toBe("new_client_123"); + expect(defender.info().playerType).toBe(PlayerType.Human); + + // Verify AI executions were removed + expect(game.executions()).not.toContain(nationExec); + expect(game.executions()).not.toContain(tribeExec); + + // Verify nations registry no longer includes the taken-over player + expect( + game.nations().find((n) => n.playerInfo.id === defender.id()), + ).toBeUndefined(); + + // Verify player info lookup reflects human + const info = (game as GameImpl).findPlayerInfo(defender.id()); + expect(info?.playerType).toBe(PlayerType.Human); + expect(info?.clientID).toBe("new_client_123"); + }); + + test("takeoverPlayer rebinds existing human player to new client ID and migrates stats", () => { + attacker.setPlayerInfo( + new PlayerInfo( + attacker.name(), + PlayerType.Human, + "old_client_id", + attacker.id(), + ), + ); + expect(attacker.type()).toBe(PlayerType.Human); + const oldClientID = attacker.clientID()!; + game.stats().attack(attacker, defender, 100); + + const oldStats = game.stats().getPlayerStats(attacker); + expect(oldStats).toBeDefined(); + expect(game.stats().stats()[oldClientID]).toBeDefined(); + + game.takeoverPlayer(attacker.id(), "attacker_new_client"); + + expect(attacker.clientID()).toBe("attacker_new_client"); + expect(attacker.info().clientID).toBe("attacker_new_client"); + expect(attacker.type()).toBe(PlayerType.Human); + + // Verify stats migrated to the new client ID and remain available + expect(game.stats().stats()[oldClientID]).toBeUndefined(); + expect(game.stats().stats()["attacker_new_client"]).toBe(oldStats); + expect(game.stats().getPlayerStats(attacker)).toBe(oldStats); + }); + + test("convertHumanToNation clears disconnected status on a previously disconnected player", () => { + defender.markDisconnected(true); + expect(defender.isDisconnected()).toBe(true); + + game.convertHumanToNation(defender, gameID); + + expect(defender.type()).toBe(PlayerType.Nation); + expect(defender.isDisconnected()).toBe(false); + }); + + test("takeoverPlayer clears disconnected status on a previously disconnected player", () => { + attacker.markDisconnected(true); + expect(attacker.isDisconnected()).toBe(true); + + game.takeoverPlayer(attacker.id(), "attacker_new_client"); + + expect(attacker.clientID()).toBe("attacker_new_client"); + expect(attacker.isDisconnected()).toBe(false); + }); + + test("applySingleplayerConfig updates gameType, difficulty, and refreshes active initialized NationExecution instances", () => { + const nationExec = game.convertHumanToNation( + defender, + gameID, + ) as NationExecution; + nationExec.init(game); + expect(nationExec.isInitialized()).toBe(true); + + const initialRate = nationExec.currentAttackRate(); + + game.applySingleplayerConfig(Difficulty.Impossible); + + expect(game.config().gameConfig().gameType).toBe(GameType.Singleplayer); + expect(game.config().gameConfig().difficulty).toBe(Difficulty.Impossible); + expect(nationExec.currentAttackRate()).not.toBe(initialRate); + + game.applySingleplayerConfig(); + expect(game.config().gameConfig().gameType).toBe(GameType.Singleplayer); + expect(game.config().gameConfig().difficulty).toBe(Difficulty.Impossible); + }); }); diff --git a/tests/core/snapshot/CoreSnapshot.test.ts b/tests/core/snapshot/CoreSnapshot.test.ts index 6480c405ff8..3a604674043 100644 --- a/tests/core/snapshot/CoreSnapshot.test.ts +++ b/tests/core/snapshot/CoreSnapshot.test.ts @@ -3,12 +3,30 @@ import { PlayerType, UnitType, } from "@openfront/engine-api/game/GameTypes"; +import { GameUpdateType } from "@openfront/engine-api/game/GameUpdates"; +import { SnapshotError } from "@openfront/engine-lib/snapshot/SnapshotType"; +import { Executor } from "@openfront/engine/execution/ExecutionManager"; import { WinCheckExecution } from "@openfront/engine/execution/WinCheckExecution"; import { Game } from "@openfront/engine/game/Game"; import { GameImpl } from "@openfront/engine/game/GameImpl"; -import { snapshotGame } from "@openfront/engine/snapshot/GameSnapshot"; +import { GameRunner } from "@openfront/engine/GameRunner"; +import { + readSnapshotHeader, + restoreMapsFromSnapshot, + SNAPSHOT_FORMAT_VERSION, + snapshotGame, +} from "@openfront/engine/snapshot/GameSnapshot"; +import { + decodeSnapshotValue, + encodeSnapshotValue, +} from "@openfront/engine/snapshot/SnapshotCodec"; import { setup } from "../../util/Setup"; -import { diffGraphs, diffSnapshots, roundTrip } from "../../util/Snapshot"; +import { + diffGraphs, + diffSnapshots, + loadTestMaps, + roundTrip, +} from "../../util/Snapshot"; const MAP = "plains"; @@ -65,4 +83,112 @@ describe("core snapshot", () => { [], ); }); + + test("restoreMapsFromSnapshot restores tile ownership and map state", async () => { + const game = await builtGame(); + const bytes = snapshotGame(game); + const { gameMap, miniGameMap } = await loadTestMaps(MAP); + const restored = restoreMapsFromSnapshot(bytes, gameMap, miniGameMap); + expect(restored.startTick).toBe(game.startTick()); + expect(restored.tick).toBe(game.ticks()); + + const a = game.player("alice"); + expect(gameMap.ownerID(game.ref(10, 10))).toBe(a.smallID()); + expect(gameMap.ownerID(game.ref(8, 8))).toBe(a.smallID()); + const b = game.player("bob"); + expect(gameMap.ownerID(game.ref(25, 10))).toBe(b.smallID()); + expect(gameMap.hasFallout(game.ref(40, 40))).toBe(true); + }); + + test("readSnapshotHeader validates startTick and returns null for malformed values", async () => { + const game = await builtGame(); + const bytes = snapshotGame(game); + const header = readSnapshotHeader(bytes); + expect(header.tick).toBe(game.ticks()); + + // Corrupt startTick in raw snapshot object to various malformed types + const raw = decodeSnapshotValue(bytes) as any; + for (const malformed of ["not-a-number", 12.34, NaN, {}, [], true]) { + raw.game.d.startTick = malformed; + const corruptedBytes = encodeSnapshotValue(raw); + const corruptedHeader = readSnapshotHeader(corruptedBytes); + expect(corruptedHeader.startTick).toBeNull(); + } + + // When startTick is a valid integer, it should be preserved + raw.game.d.startTick = 42; + const validBytes = encodeSnapshotValue(raw); + const validHeader = readSnapshotHeader(validBytes); + expect(validHeader.startTick).toBe(42); + }); + + test("restoreMapsFromSnapshot throws SnapshotError on invalid tile reference", async () => { + const game = await builtGame(); + const bytes = snapshotGame(game); + const { gameMap, miniGameMap } = await loadTestMaps(MAP); + + const raw = decodeSnapshotValue(bytes) as any; + const invalidTile = 99999999; + raw.players[0].d.tiles = new Uint32Array([invalidTile]); + const corruptedBytes = encodeSnapshotValue(raw); + + const playerID = raw.players[0].d.info.id; + expect(() => + restoreMapsFromSnapshot(corruptedBytes, gameMap, miniGameMap), + ).toThrowError( + new SnapshotError( + `invalid tile ref ${invalidTile} for player ${playerID}`, + ), + ); + }); + + test("snapshotViewData clears pendingSpawnPhaseEnd so tick 1 does not duplicate SpawnPhaseEnd", async () => { + const game = await builtGame(); + expect(game.inSpawnPhase()).toBe(false); + + let tickSpawnPhaseEndCount = 0; + const runner = new GameRunner( + game, + new Executor(game, "game123456", undefined, []), + (gu) => { + if ("updates" in gu) { + tickSpawnPhaseEndCount += + gu.updates[GameUpdateType.SpawnPhaseEnd].length; + } + }, + ); + runner.setPendingSpawnPhaseEnd(); + + const viewData = runner.snapshotViewData(); + expect(viewData.updates[GameUpdateType.SpawnPhaseEnd].length).toBe(1); + + runner.addTurn({ turnNumber: 0, intents: [] }); + expect(runner.executeNextTick()).toBe(true); + expect(tickSpawnPhaseEndCount).toBe(0); + }); + + test("readSnapshotHeader rejects non-integer formats or formats below 1, and reports newer formats", async () => { + const game = await builtGame(); + const bytes = snapshotGame(game); + + // Formats that are not integers or < 1 + for (const invalid of ["not-a-number", 1.5, 0, -1, NaN, {}, [], true]) { + const raw = decodeSnapshotValue(bytes) as any; + raw.format = invalid; + const corruptedBytes = encodeSnapshotValue(raw); + expect(() => readSnapshotHeader(corruptedBytes)).toThrowError( + new SnapshotError(`invalid snapshot format: ${String(invalid)}`), + ); + } + + // Format newer than build supports + const raw = decodeSnapshotValue(bytes) as any; + raw.format = 999; + const newerBytes = encodeSnapshotValue(raw); + expect(() => readSnapshotHeader(newerBytes)).toThrowError( + new SnapshotError( + `snapshot format 999 is newer than this build supports (${SNAPSHOT_FORMAT_VERSION})`, + ), + ); + }); }); diff --git a/tests/core/snapshot/MovingExecutions.test.ts b/tests/core/snapshot/MovingExecutions.test.ts index c49a730d97e..99d47d4c822 100644 --- a/tests/core/snapshot/MovingExecutions.test.ts +++ b/tests/core/snapshot/MovingExecutions.test.ts @@ -3,8 +3,10 @@ import { PlayerType, UnitType, } from "@openfront/engine-api/game/GameTypes"; +import { GameUpdateType } from "@openfront/engine-api/game/GameUpdates"; import { AttackExecution } from "@openfront/engine/execution/AttackExecution"; import { BoatRetreatExecution } from "@openfront/engine/execution/BoatRetreatExecution"; +import { Executor } from "@openfront/engine/execution/ExecutionManager"; import { FactoryExecution } from "@openfront/engine/execution/FactoryExecution"; import { RetreatExecution } from "@openfront/engine/execution/RetreatExecution"; import { SpawnExecution } from "@openfront/engine/execution/SpawnExecution"; @@ -12,8 +14,9 @@ import { TradeShipExecution } from "@openfront/engine/execution/TradeShipExecuti import { TransportShipExecution } from "@openfront/engine/execution/TransportShipExecution"; import { WarshipExecution } from "@openfront/engine/execution/WarshipExecution"; import { Game, Player } from "@openfront/engine/game/Game"; +import { GameRunner } from "@openfront/engine/GameRunner"; import { setup } from "../../util/Setup"; -import { expectSnapshotRoundTrip } from "../../util/Snapshot"; +import { expectSnapshotRoundTrip, roundTrip } from "../../util/Snapshot"; import { executeTicks } from "../../util/utils"; function conquerRect( @@ -138,6 +141,80 @@ describe("snapshot: transport ships", () => { expect(boat.transportShipState().isRetreating).toBe(true); await expectSnapshotRoundTrip(game, MAP, 40); }); + + test("resuming snapshot with a moving transport ship emits advancing client position updates", async () => { + const { game, defender } = await boatGame(); + game.addExecution( + new TransportShipExecution(defender, game.ref(15, 8), 100), + ); + executeTicks(game, 5); + const boat = defender.units(UnitType.TransportShip)[0]; + expect(boat).toBeDefined(); + const initialTile = boat.tile(); + + const { restored } = await roundTrip(game, MAP); + const restoredBoat = restored.unit(boat.id()); + expect(restoredBoat).toBeDefined(); + expect(restoredBoat!.tile()).toBe(initialTile); + + let advanced = false; + for (let i = 0; i < 20; i++) { + const updates = restored.executeNextTick(); + const unitUpdates = updates[GameUpdateType.Unit] ?? []; + const boatUpdate = unitUpdates.find((u) => u.id === boat.id()); + if (boatUpdate && boatUpdate.pos !== initialTile) { + expect(boatUpdate.pos).toBe(restoredBoat!.tile()); + advanced = true; + break; + } + } + expect(advanced).toBe(true); + }); + + test("GameRunner.snapshotViewData and restore behavior for moving units", async () => { + const { game, defender } = await boatGame(); + game.addExecution( + new TransportShipExecution(defender, game.ref(15, 8), 100), + ); + executeTicks(game, 5); + const boat = defender.units(UnitType.TransportShip)[0]; + expect(boat).toBeDefined(); + const initialTile = boat.tile(); + + const { restored } = await roundTrip(game, MAP); + const restoredBoat = restored.unit(boat.id()); + expect(restoredBoat).toBeDefined(); + expect(restoredBoat!.tile()).toBe(initialTile); + + const runner = new GameRunner( + restored, + new Executor(restored, "game_id", "defender"), + () => {}, + ); + + // Initial snapshot update from GameRunner.snapshotViewData + const snapshotView = runner.snapshotViewData(); + const boatSnapshotUnit = snapshotView.updates[GameUpdateType.Unit]?.find( + (u) => u.id === boat.id(), + ); + expect(boatSnapshotUnit).toBeDefined(); + expect(boatSnapshotUnit?.pos).toBe(initialTile); + + // Advance restored game ticks until boat moves and emits a unit update + let advancedBoatPos: number | null = null; + for (let i = 0; i < 20; i++) { + const updates = restored.executeNextTick(); + const unitUpdates = updates[GameUpdateType.Unit] ?? []; + const boatUpdate = unitUpdates.find((u) => u.id === boat.id()); + if (boatUpdate && boatUpdate.pos !== initialTile) { + advancedBoatPos = boatUpdate.pos; + break; + } + } + expect(advancedBoatPos).not.toBeNull(); + expect(advancedBoatPos).toBe(restoredBoat!.tile()); + expect(advancedBoatPos).not.toBe(initialTile); + }); }); describe("snapshot: warships and trade ships", () => { diff --git a/tests/core/snapshot/SnapshotCodec.test.ts b/tests/core/snapshot/SnapshotCodec.test.ts index 3df0371c07a..3e6cfec53b9 100644 --- a/tests/core/snapshot/SnapshotCodec.test.ts +++ b/tests/core/snapshot/SnapshotCodec.test.ts @@ -1,6 +1,7 @@ import { decodeSnapshotValue, encodeSnapshotValue, + SnapshotCodecError, } from "@openfront/engine/snapshot/SnapshotCodec"; describe("snapshot codec", () => { @@ -82,4 +83,15 @@ describe("snapshot codec", () => { expect(() => decodeSnapshotValue(new Uint8Array([...bytes, 0]))).toThrow(); expect(() => decodeSnapshotValue(new Uint8Array([99]))).toThrow(); }); + + test("rejects arrays whose length exceeds remaining input", () => { + // Tag.Array (10) with length 5, but 0 remaining bytes + expect(() => decodeSnapshotValue(new Uint8Array([10, 5]))).toThrow( + SnapshotCodecError, + ); + // Tag.Array (10) with oversized length that would cause RangeError in new Array() + expect(() => + decodeSnapshotValue(new Uint8Array([10, 0xff, 0xff, 0xff, 0xff, 0x0f])), + ).toThrow(SnapshotCodecError); + }); }); diff --git a/tests/core/snapshot/SnapshotFixtures.test.ts b/tests/core/snapshot/SnapshotFixtures.test.ts index f81be55a2e8..020e02844bc 100644 --- a/tests/core/snapshot/SnapshotFixtures.test.ts +++ b/tests/core/snapshot/SnapshotFixtures.test.ts @@ -1,3 +1,7 @@ +import { + GameUpdateType, + type GameUpdateViewData, +} from "@openfront/engine-api/game/GameUpdates"; import { readSnapshotHeader, SNAPSHOT_FORMAT_VERSION, @@ -60,12 +64,54 @@ describe("snapshot fixtures from earlier builds", { timeout: 120_000 }, () => { gunzipSync(fs.readFileSync(path.join(DIR, name))), ); const header = readSnapshotHeader(bytes); - const runner = await restoreScriptedRunner(MAP, start, bytes); + expect(header.startTick).not.toBeNull(); + const recordedUpdates: GameUpdateViewData[] = []; + const runner = await restoreScriptedRunner(MAP, start, bytes, (gu) => { + if (!("errMsg" in gu)) recordedUpdates.push(gu); + }); expect(runner.game.ticks()).toBe(header.tick); + expect(runner.game.startTick()).toBe(header.startTick); + expect(runner.game.inSpawnPhase()).toBe(false); expect(runner.game.players().length).toBeGreaterThan(0); - for (let i = 0; i < 100; i++) stepScripted(runner); + + // The first tick after restoring past spawn phase emits exactly one SpawnPhaseEnd + stepScripted(runner); + expect( + recordedUpdates[0].updates[GameUpdateType.SpawnPhaseEnd], + ).toHaveLength(1); + expect( + recordedUpdates[0].updates[GameUpdateType.SpawnPhaseEnd][0].startTick, + ).toBe(header.startTick); + + // The second tick emits none + stepScripted(runner); + expect( + recordedUpdates[1].updates[GameUpdateType.SpawnPhaseEnd], + ).toHaveLength(0); + + for (let i = 2; i < 100; i++) stepScripted(runner); // A snapshot written by this build from the migrated game loads too. const again = await restoreScriptedRunner(MAP, start, runner.snapshot()); expect(again.game.ticks()).toBe(header.tick + 100); }); + + test("restoring a snapshot still in the spawn phase emits no synthetic SpawnPhaseEnd", async () => { + const spawnRunner = await createScriptedRunner(MAP, start); + expect(spawnRunner.game.inSpawnPhase()).toBe(true); + const spawnSnapshot = spawnRunner.snapshot(); + const spawnUpdates: GameUpdateViewData[] = []; + const restoredSpawnRunner = await restoreScriptedRunner( + MAP, + start, + spawnSnapshot, + (gu) => { + if (!("errMsg" in gu)) spawnUpdates.push(gu); + }, + ); + expect(restoredSpawnRunner.game.inSpawnPhase()).toBe(true); + stepScripted(restoredSpawnRunner); + expect(spawnUpdates[0].updates[GameUpdateType.SpawnPhaseEnd]).toHaveLength( + 0, + ); + }); }); diff --git a/tests/core/worker/WorkerClient.test.ts b/tests/core/worker/WorkerClient.test.ts index 9147179c66a..092f5503153 100644 --- a/tests/core/worker/WorkerClient.test.ts +++ b/tests/core/worker/WorkerClient.test.ts @@ -131,6 +131,90 @@ describe("WorkerClient playerInteraction", () => { }); }); +describe("WorkerClient snapshot", () => { + it("resolves with bytes and tick when worker responds", async () => { + const { client, worker, internalClient } = createClient(); + const promise = client.snapshot("commit-123"); + const request = vi.mocked(worker.postMessage).mock.calls[0][0] as { + type: string; + id: string; + gitCommit?: string; + }; + expect(request.type).toBe("snapshot"); + expect(request.gitCommit).toBe("commit-123"); + + const dummyBytes = new Uint8Array([1, 2, 3]); + internalClient.handleWorkerMessage({ + data: { + type: "snapshot_result", + id: request.id, + snapshot: dummyBytes, + tick: 42, + }, + } as MessageEvent); + + await expect(promise).resolves.toEqual({ + bytes: dummyBytes, + tick: 42, + }); + expect(internalClient.messageHandlers.size).toBe(0); + }); + + it("rejects when worker returns null snapshot", async () => { + const { client, worker, internalClient } = createClient(); + const promise = client.snapshot(); + const request = vi.mocked(worker.postMessage).mock.calls[0][0] as { + id: string; + }; + + internalClient.handleWorkerMessage({ + data: { + type: "snapshot_result", + id: request.id, + snapshot: null, + tick: 0, + }, + } as MessageEvent); + + await expect(promise).rejects.toThrow("Snapshot failed"); + expect(internalClient.messageHandlers.size).toBe(0); + }); +}); + +describe("WorkerClient initialUpdate", () => { + it("stores initialUpdate from initialized message and consumeInitialUpdate clears it", () => { + const { client, internalClient } = createClient(); + const dummyUpdate = { tick: 10 } as any; + + internalClient.handleWorkerMessage({ + data: { + type: "initialized", + id: "msg-1", + initialUpdate: dummyUpdate, + }, + } as MessageEvent); + + // If a handler was waiting for msg-1, it would be called; + // but here we can directly verify handleWorkerMessage behavior + expect(client.initialUpdate).toBe(dummyUpdate); + expect(client.consumeInitialUpdate()).toBe(dummyUpdate); + expect(client.initialUpdate).toBeNull(); + expect(client.consumeInitialUpdate()).toBeNull(); + }); + + it("passes initialUpdate to gameUpdate callback on start if not consumed", () => { + const { client } = createClient(); + const dummyUpdate = { tick: 20 } as any; + client.initialUpdate = dummyUpdate; + + const callback = vi.fn(); + client.start(callback); + + expect(callback).toHaveBeenCalledWith(dummyUpdate); + expect(client.initialUpdate).toBeNull(); + }); +}); + describe("WorkerClient humanStats", () => { it("asks the worker for the humans' stats and resolves with its answer", async () => { const { client, worker, internalClient } = createClient(); diff --git a/tests/util/ScriptedGame.ts b/tests/util/ScriptedGame.ts index c811a2f3db6..6487035e0cd 100644 --- a/tests/util/ScriptedGame.ts +++ b/tests/util/ScriptedGame.ts @@ -118,6 +118,7 @@ export async function restoreScriptedRunner( mapName: string, gameStart: GameStartInfo, snapshot: Uint8Array, + onUpdate?: (gu: GameUpdateViewData | ErrorUpdate) => void, ): Promise { const { gameMap, gameMapSize } = readSnapshotHeader(snapshot).gameConfig; return createGameRunnerFromSnapshot( @@ -125,7 +126,10 @@ export async function restoreScriptedRunner( snapshot, undefined, await loadMapFiles(new TestDataMapLoader(mapName), gameMap, gameMapSize), - recordErrors, + (gu) => { + recordErrors(gu); + onUpdate?.(gu); + }, ); } diff --git a/tests/util/Snapshot.ts b/tests/util/Snapshot.ts index 0e60ddd7e0b..96f3bb64306 100644 --- a/tests/util/Snapshot.ts +++ b/tests/util/Snapshot.ts @@ -137,6 +137,7 @@ export const DERIVED_FIELDS = new Set([ "playerStatsQuads", "attackTroopsQuads", "motionPlanRecords", + "planDrivenUnitIds", "_nukeImpactQueue", "unitCountMemo", "unitsByTypeMemo",