Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
131 commits
Select commit Hold shift + click to select a range
35a17ab
refactor(client): move entity state conversions out of PlayerView/Uni…
ArtZab0 Sep 23, 2026
7c26f9c
feat(client): integrate singleplayer save/resume UI with versioned en…
LoSt543215543 Sep 23, 2026
970e340
feat(client): wire periodic background auto-snapshots during singlepl…
LoSt543215543 Sep 23, 2026
ee3553f
fix(client): preserve existing snapshot tick count in saveSoloGame
LoSt543215543 Sep 24, 2026
b903bf1
fix(client): address review feedback on save clearing, record archivi…
LoSt543215543 Sep 24, 2026
efff8ac
fix(client): scope save clearing by gameID, validate resume payload, …
LoSt543215543 Sep 24, 2026
8c39158
fix(client): skip legacy turn persistence when resumeSnapshot is active
LoSt543215543 Sep 24, 2026
5726d2b
fix(client): propagate startTick to GameView and emit SpawnPhaseEnd o…
LoSt543215543 Sep 24, 2026
9fbaa97
fix(client): use snapshot header tick for auto-snapshot numTurns
LoSt543215543 Sep 24, 2026
d626a6c
refactor(render): move player palette writes into PlayerPalette.ts
ArtZab0 Sep 23, 2026
3b8d6d9
refactor(client): move motion plan resolution out of GameView
ArtZab0 Sep 23, 2026
a84f7e1
feat(replay): add a seekable replay format
ArtZab0 Sep 23, 2026
d70272f
feat(replay): add the replay processor
ArtZab0 Sep 23, 2026
fbec7e0
feat(replay): add replay playback
ArtZab0 Sep 23, 2026
c7ecbb7
feat(replay): add the replay viewer, processed in the browser
ArtZab0 Sep 23, 2026
dc03b42
Restore client map ownership and state when resuming from snapshot
LoSt543215543 Sep 24, 2026
f2bb771
Merge branch 'upstream-pr-5644' into feat/replay-continue-game
LoSt543215543 Sep 24, 2026
d216157
feat(replay): implement 'continue game from here' (#5647)
LoSt543215543 Sep 24, 2026
143d2db
feat(steam): add exclamation mark to buy on steam copy and sort keys
LoSt543215543 Sep 24, 2026
ec443e8
fix(i18n): use dedicated translation keys for win modal and continue …
LoSt543215543 Sep 24, 2026
9710d15
fix(client): guard auto-snapshot when stopped, protect resume flow, a…
LoSt543215543 Sep 24, 2026
ff1a21f
fix(client): rethrow snapshot map restore error, derive accurate turn…
LoSt543215543 Sep 24, 2026
92c2894
fix(client): clear singleplayer save and halt snapshots upon player d…
LoSt543215543 Sep 24, 2026
9cdc7db
feat(replay): add dev:all script and demoGame1 record for offline rep…
LoSt543215543 Sep 24, 2026
2dcc4f9
feat(replay): polish continue modal with time title, flag images, map…
LoSt543215543 Sep 24, 2026
b41afa8
feat(replay): remove tick mentions from continue modal and position c…
LoSt543215543 Sep 24, 2026
d3a4d2f
fix(replay): address review feedback on execution cleanup, i18n, and …
LoSt543215543 Sep 24, 2026
f4939a0
fix(client): address review on save clearing scope, join cancellation…
LoSt543215543 Sep 24, 2026
879de92
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Sep 24, 2026
fe9c9b0
fix(replay): address review on modal reset, drawn count, timeline see…
LoSt543215543 Sep 24, 2026
e6a0d07
fix(client): clean up worker on snapshot restore failure and exit lob…
LoSt543215543 Sep 24, 2026
ac77585
fix(client): make resume join cancelable and guard replay viewer race
LoSt543215543 Sep 24, 2026
4c1adac
fix(client): use opening token to guard replay viewer async imports
LoSt543215543 Sep 24, 2026
6bba177
Merge branch 'main' into feat/replay-continue-game
LoSt543215543 Sep 24, 2026
b62e9ae
fix(replay): invalidate in-flight continue requests on modal close
LoSt543215543 Sep 24, 2026
ed95aa3
fix(client): address review on modal cancellation, hash preservation,…
LoSt543215543 Sep 25, 2026
1aee9fb
fix(client): pass worker tick in snapshot_result and omit header deco…
LoSt543215543 Sep 25, 2026
8fe56a3
fix(replay): address review on stub api cors, query preservation, fet…
LoSt543215543 Sep 25, 2026
cd107b2
fix(replay): validate record-demo ticks and refresh difficulty on act…
LoSt543215543 Sep 25, 2026
03c0c5b
fix(client): address review on resume join cancellation, discard scop…
LoSt543215543 Sep 25, 2026
6216454
fix(client): address review on share URL replace, cancelable continue…
LoSt543215543 Sep 25, 2026
3040b3a
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Sep 25, 2026
27451f3
fix(client): offset resumed turns by snapshot tick and verify tribe t…
LoSt543215543 Sep 26, 2026
ee2aab1
Merge branch 'main' into feat/singleplayer-ui-snapshots
LoSt543215543 Sep 26, 2026
e6fc682
fix(client): add timeout to auto-snapshot, verify real nation in extr…
LoSt543215543 Sep 27, 2026
8ccde7d
fix(client): initialize turnsSeen from snapshot tick on resume
LoSt543215543 Sep 27, 2026
c7e3a00
refactor(core): encapsulate takeover and bot conversion methods in Game
LoSt543215543 Sep 27, 2026
5a79df6
fix(replay): address review comments on the replay viewer
ArtZab0 Sep 27, 2026
88bf0c7
docs(replay): say which frames the hashes don't check
ArtZab0 Sep 27, 2026
1eecd2c
Merge remote-tracking branch 'origin/main' into feature/replay-ldlewo…
ArtZab0 Sep 27, 2026
1cc44d4
fix(snapshot): validate tile refs on map restore and clean up worker …
LoSt543215543 Sep 29, 2026
a708108
fix(snapshot): validate tile refs on map restore and clean up worker …
LoSt543215543 Sep 29, 2026
41de347
fix(steam): add steamId to SteamBridge and remove as any cast in getS…
LoSt543215543 Sep 29, 2026
b6353b2
fix(client): send full game update on snapshot resume and retain part…
LoSt543215543 Sep 29, 2026
3401739
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Sep 29, 2026
86abe51
test(client): use distinct worker tick in auto-snapshot tests and rem…
LoSt543215543 Sep 29, 2026
a32a94a
fix(save): validate gameStartInfo on solo save load and include dead …
LoSt543215543 Sep 29, 2026
2c0a530
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Sep 29, 2026
243771b
fix(snapshot): clear planDrivenUnitIds on restore to emit unit update…
LoSt543215543 Sep 29, 2026
683e5cd
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Sep 29, 2026
82a94ef
Merge branch 'main' into feat/singleplayer-ui-snapshots
LoSt543215543 Sep 29, 2026
2c3725d
Merge remote-tracking branch 'origin/main' into feature/replay-ldlewo…
ArtZab0 Sep 30, 2026
4565553
fix(replay): fill the HUD adapter's gaps and make the viewer opt-in
ArtZab0 Sep 30, 2026
dd1529f
fix(replay): throw RangeError for out-of-bounds delta tile refs
LoSt543215543 Oct 1, 2026
ceca05b
Merge branch 'upstream-pr-5644' into feat/replay-continue-game
LoSt543215543 Oct 1, 2026
326f79f
Merge branch 'main' into feat/replay-continue-game
LoSt543215543 Oct 1, 2026
d4d7b8d
fix(client): translate resume banner map name and clean up unused tim…
LoSt543215543 Oct 1, 2026
2104eff
Merge remote-tracking branch 'upstream/main' into feat/singleplayer-u…
LoSt543215543 Oct 1, 2026
50274ac
Merge remote-tracking branch 'upstream/main' into feat/replay-continu…
LoSt543215543 Oct 2, 2026
43a856c
fix(continue): upload restored state to WebGL renderer and clear disc…
LoSt543215543 Oct 2, 2026
92c6114
fix(save): store compressed snapshots in IndexedDB and keep metadata …
LoSt543215543 Oct 2, 2026
14c775c
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Oct 2, 2026
8bd3986
test(client): type fakeIndexedDb request stub in save manager tests
LoSt543215543 Oct 2, 2026
3053d84
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Oct 2, 2026
2e119f7
fix(save): pool shared IndexedDB connections and clear saves if game …
LoSt543215543 Oct 2, 2026
bc980a0
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Oct 2, 2026
7c8f878
fix(save): close cached database in shared connection error cleanup
LoSt543215543 Oct 2, 2026
d95c58b
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Oct 2, 2026
dada850
fix(save): discard stale snapshot bytes if save metadata changed duri…
LoSt543215543 Oct 2, 2026
d39998a
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Oct 2, 2026
9ad2279
fix(save): check for mismatched game before writing snapshot bytes
LoSt543215543 Oct 2, 2026
0d0e043
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Oct 2, 2026
c096e64
refactor(save): remove unused compressedSnapshot from saveSoloGame
LoSt543215543 Oct 2, 2026
491499e
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Oct 2, 2026
bfdd4c0
feat(core): add Game.applySingleplayerConfig and Config difficulty se…
LoSt543215543 Oct 2, 2026
1a2a2cc
fix(save): report snapshot persistence status and preserve save on st…
LoSt543215543 Oct 2, 2026
44debb7
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Oct 2, 2026
ee4c9a5
Merge branch 'upstream/main' into feat/replay-continue-game
LoSt543215543 Oct 7, 2026
9d6818c
Merge branch 'upstream/main' into feat/replay-continue-game
LoSt543215543 Oct 7, 2026
3193fc2
style: format replay and save manager snapshot code with prettier
LoSt543215543 Oct 7, 2026
326fe89
style: format remaining replay and snapshot files with prettier
LoSt543215543 Oct 7, 2026
5048b9f
test(client): update snapshot and UserSettings mocks in client runner…
LoSt543215543 Oct 7, 2026
cb65cdb
test(client): fix UserSettings mock in client runner tests
LoSt543215543 Oct 7, 2026
0e61850
Merge remote-tracking branch 'upstream/main' into feat/singleplayer-u…
LoSt543215543 Oct 7, 2026
bcd0a75
fix(save): preserve save on runner stop, type resume snapshot contrac…
LoSt543215543 Oct 7, 2026
15d63e3
refactor(snapshot): move snapshot header and map restoration to engin…
LoSt543215543 Oct 7, 2026
3b7e432
fix(snapshot): use type-only exports in GameSnapshot and format snaps…
LoSt543215543 Oct 7, 2026
e9a0689
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Oct 8, 2026
897fb26
refactor(snapshot): move snapshot extraction to engine and delegate v…
LoSt543215543 Oct 8, 2026
1a4a36d
test(client): update snapshot header and map restore mock paths in cl…
LoSt543215543 Oct 8, 2026
01888aa
refactor(snapshot): add zbin dependency to engine-lib and decouple cl…
LoSt543215543 Oct 8, 2026
46d36c5
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Oct 8, 2026
487fdef
feat(engine): include restored railroads and pending alliance request…
LoSt543215543 Oct 8, 2026
b3be4b7
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Oct 8, 2026
75a7c14
fix(client): require matching current save when clearSoloSave is call…
LoSt543215543 Oct 8, 2026
b6e63ef
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Oct 8, 2026
5a1ba5b
Merge remote-tracking branch 'upstream/main' into feat/singleplayer-u…
LoSt543215543 Oct 9, 2026
427cb69
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Oct 9, 2026
48b25c5
fix(client): defer initial worker update and deduplicate snapshot decode
LoSt543215543 Oct 9, 2026
692e2ef
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Oct 9, 2026
3a31231
fix(client): restore readSnapshotHeader import and remove redundant u…
LoSt543215543 Oct 9, 2026
75629a6
Merge remote-tracking branch 'upstream/main' into feat/singleplayer-u…
LoSt543215543 Oct 9, 2026
b774e77
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Oct 9, 2026
5221e0b
test(client): update restore error tests to reflect single decode flow
LoSt543215543 Oct 9, 2026
c06371d
Address review comments on snapshotViewData, clearSoloSave race, and …
LoSt543215543 Oct 9, 2026
3e4f22d
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Oct 9, 2026
18b4177
Reject non-integer or below 1 snapshot formats in decodeRoot
LoSt543215543 Oct 9, 2026
23293d6
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Oct 9, 2026
c2e2571
Validate array length against remaining bytes in SnapshotCodec
LoSt543215543 Oct 9, 2026
4c5f1b5
Merge branch 'feat/singleplayer-ui-snapshots' into feat/replay-contin…
LoSt543215543 Oct 9, 2026
96cd83a
refactor(replay): decouple continue game from singleplayer save manager
LoSt543215543 Oct 10, 2026
b8b0e85
Merge remote-tracking branch 'upstream/main' into feat/replay-continu…
LoSt543215543 Oct 10, 2026
e690749
fix(replay): address review on config copying, replay hash replacemen…
LoSt543215543 Oct 10, 2026
1f7486c
Merge remote-tracking branch 'upstream/main' into feat/replay-continu…
LoSt543215543 Oct 15, 2026
ad0eb93
fix(replay): relocate client GameView motion plan tests and validate …
LoSt543215543 Oct 15, 2026
96d2015
fix(replay): format player troop counts with renderTroops in continue…
LoSt543215543 Oct 15, 2026
bb8d013
refactor(client): propagate resumeTick to avoid redundant snapshot he…
LoSt543215543 Oct 15, 2026
6fed096
Merge remote-tracking branch 'upstream/main' into feat/replay-continu…
LoSt543215543 Oct 15, 2026
5c7fe23
refactor(replay): use extracted snapshot tick for resumeTick in join-…
LoSt543215543 Oct 16, 2026
e84fd75
refactor(client): remove unused resumeTurns plumbing
LoSt543215543 Oct 16, 2026
fa829c5
fix(engine): migrate player stats to localClientID on player takeover
LoSt543215543 Oct 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions docs/Replays.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<gameID>`.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions packages/engine-api/src/game/GameUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
33 changes: 33 additions & 0 deletions packages/engine-api/src/worker/WorkerMessages.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { TileRef } from "../game/GameMap";
import {
BuildableUnit,
Difficulty,
HumanStatsSnapshot,
PlayerActions,
PlayerBorderTiles,
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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 {
Expand All @@ -225,6 +255,7 @@ export type MainThreadMessage =
| AttackClusteredPositionsMessage
| TransportShipSpawnMessage
| SnapshotMessage
| ExtractSnapshotMessage
| HumanStatsMessage;

// Message send from worker
Expand All @@ -243,4 +274,6 @@ export type WorkerMessage =
| AttackClusteredPositionsResultMessage
| TransportShipSpawnResultMessage
| SnapshotResultMessage
| ExtractSnapshotResultMessage
| ExtractSnapshotErrorMessage
| HumanStatsResultMessage;
1 change: 1 addition & 0 deletions packages/engine-lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
},
"dependencies": {
"@openfront/engine-api": "0.0.0",
"@openfront/zbin": "0.0.0",
"zod": "^4.4.2"
}
}
169 changes: 169 additions & 0 deletions packages/engine-lib/src/snapshot/MapSnapshot.ts
Original file line number Diff line number Diff line change
@@ -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<typeof RootSchema>;

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),
};
}
Loading
Loading