From 0b4a856203089541e59a53bb013f96375cc6997b Mon Sep 17 00:00:00 2001 From: LoSt543215543 Date: Tue, 27 Oct 2026 08:10:42 -0700 Subject: [PATCH] feat(replay): continue game from replay (#5650) --- .gitignore | 4 + docs/Replays.md | 20 +- package.json | 3 + .../engine-api/src/worker/WorkerMessages.ts | 31 + .../engine/src/configuration/EngineConfig.ts | 9 + .../engine/src/execution/NationExecution.ts | 22 + .../engine/src/execution/TribeExecution.ts | 6 +- packages/engine/src/game/Game.ts | 15 +- packages/engine/src/game/GameImpl.ts | 110 +++ packages/engine/src/game/PlayerImpl.ts | 3 + packages/engine/src/game/Stats.ts | 1 + packages/engine/src/game/StatsImpl.ts | 8 + .../engine/src/snapshot/SnapshotExtractor.ts | 157 ++++ packages/engine/src/worker/Worker.worker.ts | 35 + records/demoGame1.json | 194 +++++ resources/lang/en.json | 14 + scripts/replay/record-demo.mts | 24 +- scripts/replay/stub-game-api.mts | 14 +- src/client/Main.ts | 15 + src/client/replay/ContinueGameModal.ts | 319 ++++++++ src/client/replay/LocalProcessing.ts | 123 +++- src/client/replay/ReplayControls.ts | 11 + src/client/replay/ReplayViewer.ts | 158 +++- .../replay/processor/ReplayProcessor.ts | 2 +- tests/Stats.test.ts | 10 + tests/client/MainInitialize.test.ts | 13 +- tests/client/replay/LocalProcessing.test.ts | 125 ++++ .../replay/ReplayViewerContinueCancel.test.ts | 232 ++++++ tests/client/replay/SnapshotExtractor.test.ts | 692 ++++++++++++++++++ tests/core/game/GameImpl.test.ts | 164 ++++- vite.config.ts | 1 + 31 files changed, 2499 insertions(+), 36 deletions(-) create mode 100644 packages/engine/src/snapshot/SnapshotExtractor.ts create mode 100644 records/demoGame1.json create mode 100644 src/client/replay/ContinueGameModal.ts create mode 100644 tests/client/replay/ReplayViewerContinueCancel.test.ts create mode 100644 tests/client/replay/SnapshotExtractor.test.ts diff --git a/.gitignore b/.gitignore index bd93776f81d..1e7d04fdf2b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ map-generator/map-generator.exe # src/core package split (#1701): tick-trace inputs and baselines .tick-records/ .tick-baseline/ + +# Saved replay records for local testing +records/* +!records/demoGame1.json diff --git a/docs/Replays.md b/docs/Replays.md index 54a944a2c85..c54ad3d30ce 100644 --- a/docs/Replays.md +++ b/docs/Replays.md @@ -107,11 +107,21 @@ 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 + +# Or restore a saved production record: +# Place the saved record at records/.json + +# Run client, server, and replay stub API concurrently: +npm run dev:all +``` + +Then open `http://localhost:9000/#replay-viewer=` (or `#replay-viewer=demoGame1` for the sample record). +You can also test directly with live records from production using: + +```bash +npm run dev:prod ``` Then open `http://localhost:9000/#replay-viewer=`. diff --git a/package.json b/package.json index 618b71eee4c..cf433f50f70 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", "docs:map-generator": "cd map-generator && go doc -cmd -u -all", "tunnel": "npm run build-prod && npm run start:server", "test": "vitest run --project client --project client-forks --project node", diff --git a/packages/engine-api/src/worker/WorkerMessages.ts b/packages/engine-api/src/worker/WorkerMessages.ts index 43d983215d9..d2bf06c3359 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, HumanDisconnects, HumanStatsSnapshot, PlayerActions, @@ -42,6 +43,9 @@ export type WorkerMessageType = | "snapshot_result" | "read_snapshot_header" | "read_snapshot_header_result" + | "extract_snapshot" + | "extract_snapshot_result" + | "extract_snapshot_error" | "human_stats" | "human_stats_result" | "human_disconnects" @@ -233,6 +237,30 @@ export interface HumanStatsResultMessage extends BaseWorkerMessage { result: HumanStatsSnapshot; } +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 HumanDisconnectsMessage extends BaseWorkerMessage { type: "human_disconnects"; } @@ -256,6 +284,7 @@ export type MainThreadMessage = | TransportShipSpawnMessage | SnapshotMessage | ReadSnapshotHeaderMessage + | ExtractSnapshotMessage | HumanStatsMessage | HumanDisconnectsMessage; @@ -276,5 +305,7 @@ export type WorkerMessage = | TransportShipSpawnResultMessage | SnapshotResultMessage | ReadSnapshotHeaderResultMessage + | ExtractSnapshotResultMessage + | ExtractSnapshotErrorMessage | HumanStatsResultMessage | HumanDisconnectsResultMessage; diff --git a/packages/engine/src/configuration/EngineConfig.ts b/packages/engine/src/configuration/EngineConfig.ts index 907b1b613ad..0a60787d27b 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 9fe91935825..dc094a69a9f 100644 --- a/packages/engine/src/execution/NationExecution.ts +++ b/packages/engine/src/execution/NationExecution.ts @@ -76,6 +76,28 @@ export class NationExecution implements Execution { this.expandRatio = this.random.nextInt(10, 20) / 100; } + playerID(): string { + return this.nation.playerInfo.id; + } + + 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; + } + init(mg: Game) { this.mg = mg; this.attackRate = this.getAttackRate(); diff --git a/packages/engine/src/execution/TribeExecution.ts b/packages/engine/src/execution/TribeExecution.ts index 293262f00a7..97efbeb820c 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 21356178e1f..139ec3460cc 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, @@ -39,7 +40,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"; @@ -310,6 +315,7 @@ export interface Player extends PlayerLike { // Basic Info smallID(): number; info(): PlayerInfo; + setPlayerInfo(info: PlayerInfo): void; name(): string; displayName(): string; clanTag(): string | null; @@ -605,6 +611,13 @@ 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; + clearHumanPlayer(player: Player | PlayerID): void; + clearOtherHumans(keptPlayer: Player | PlayerID): void; + 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 e9964be971a..7a73cee1bf1 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, @@ -33,6 +35,7 @@ import { import { AllPlayersStats, ClientID, + GameID, Winner, } from "@openfront/engine-api/Schemas"; import { ATTACK_INDEX_SENT } from "@openfront/engine-api/StatsSchemas"; @@ -63,6 +66,9 @@ import { SharedWaterCache, SharedWaterCacheSnapshot, } from "../execution/nation/SharedWaterCache"; +import { NationExecution } from "../execution/NationExecution"; +import { SpawnTimerExecution } from "../execution/SpawnTimerExecution"; +import { TribeExecution } from "../execution/TribeExecution"; import { AbstractGraph } from "../pathfinding/algorithms/AbstractGraph"; import { WaterPathFinder } from "../pathfinding/PathFinder"; import { PathFinder } from "../pathfinding/types"; @@ -782,6 +788,110 @@ 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 any NationExecution or TribeExecution controlling this player + 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(), + ); + } + + clearHumanPlayer(player: Player | PlayerID): void { + 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()); + } + + clearOtherHumans(keptPlayer: Player | PlayerID): void { + const target = + typeof keptPlayer === "string" ? this.player(keptPlayer) : keptPlayer; + this._humans = this._humans.filter((h) => h.id === target.id()); + } + + convertHumanToNation(player: Player | PlayerID, gameID: GameID): Execution { + const target = typeof player === "string" ? this.player(player) : player; + this.clearHumanPlayer(target); + + 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, target.info()); + 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 SpawnTimerExecution) { + this.removeExecution(exec); + } else if ( + difficulty !== undefined && + exec instanceof NationExecution && + exec.isActive() && + exec.isInitialized() + ) { + exec.refreshDifficulty(); + } + } + } + playerView(id: PlayerID): Player { return this.player(id); } diff --git a/packages/engine/src/game/PlayerImpl.ts b/packages/engine/src/game/PlayerImpl.ts index a0feba3bcdc..3704dff5729 100644 --- a/packages/engine/src/game/PlayerImpl.ts +++ b/packages/engine/src/game/PlayerImpl.ts @@ -729,6 +729,9 @@ export class PlayerImpl implements Player { info(): PlayerInfo { return this.playerInfo; } + setPlayerInfo(info: PlayerInfo): void { + this.playerInfo = info; + } isLobbyCreator(): boolean { return this.playerInfo.isLobbyCreator; 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 a18f6320aa2..448424aa9dd 100644 --- a/packages/engine/src/game/StatsImpl.ts +++ b/packages/engine/src/game/StatsImpl.ts @@ -103,6 +103,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/SnapshotExtractor.ts b/packages/engine/src/snapshot/SnapshotExtractor.ts new file mode 100644 index 00000000000..bdaa1fe6008 --- /dev/null +++ b/packages/engine/src/snapshot/SnapshotExtractor.ts @@ -0,0 +1,157 @@ +/** + * 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 { SpawnExecution } from "../execution/SpawnExecution"; +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: alive ones become Nation bots with executions; + // dead ones are removed from humans and their clientID cleared so the game is strictly singleplayer. + for (const p of game.allPlayers()) { + if (p.id() === chosenPlayer.id()) continue; + + if (p.type() === PlayerType.Human) { + if (!game.inSpawnPhase() && !p.isAlive()) { + game.clearHumanPlayer(p); + } else { + if (game.inSpawnPhase() && !p.hasSpawned()) { + const spawnExec = new SpawnExecution(gameStart.gameID, p.info()); + spawnExec.init(game, game.ticks()); + spawnExec.tick(game.ticks()); + } + if (!p.hasSpawned()) { + game.clearHumanPlayer(p); + } else { + game.convertHumanToNation(p, gameStart.gameID); + } + } + } + } + + // Clear any humans remaining in _humans (e.g. kicked players in team games who never became active players) + game.clearOtherHumans(chosenPlayer); + + // In singleplayer, spawn phase ends when the human spawns. If the chosen + // player has already spawned during the replay, end the spawn phase now. + if (chosenPlayer.hasSpawned() && game.inSpawnPhase()) { + game.endSpawnPhase(); + } + + // 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 65d966c541c..10a1bb5cca3 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, HumanDisconnectsResultMessage, HumanStatsResultMessage, InitErrorMessage, @@ -26,6 +28,7 @@ import { GameRunner, } from "../GameRunner"; import { readSnapshotHeader } from "../snapshot/GameSnapshot"; +import { extractSnapshot } from "../snapshot/SnapshotExtractor"; const ctx: Worker = self as any; // Where answers go: the page that started this worker, or the port it @@ -473,6 +476,38 @@ async function onMessage(e: MessageEvent) { sendMessage(result); 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 1c6d19a9e31..52c03c4a8d7 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -2143,15 +2143,29 @@ "replay_speed": "Replay speed" }, "replay_viewer": { + "bot_difficulty": "Bot Difficulty", + "cancel": "Cancel", + "continue_failed": "Failed to continue this game.", + "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..a060e0c2ea2 100644 --- a/scripts/replay/record-demo.mts +++ b/scripts/replay/record-demo.mts @@ -2,15 +2,17 @@ * Plays a game locally and writes its archived record, so the replay * pipeline can be tried end to end without the live API. * - * npx tsx scripts/replay/record-demo.mts [ticks] [gameID] + * npx tsx scripts/replay/record-demo.mts [out.json] [ticks] [gameID] * * The record is stamped with this checkout's commit ($GIT_COMMIT overrides * it). A dev client (commit DEV) processes any record; a built one only a * record from its own commit. */ +import { generateID } from "@openfront/shared/SharedUtil"; import { execSync } from "child_process"; import fs from "fs"; +import path from "path"; import { config, human, @@ -18,12 +20,24 @@ import { spawnOnLand, } from "../../tests/client/replay/util/ArchiveGame"; -const [out, ticks = "1500", gameID = "demoGame1"] = process.argv.slice(2); -if (out === undefined) { - console.error("usage: record-demo.mts [ticks] [gameID]"); +const [outArg, ticksArg = "1500", customGameID] = process.argv.slice(2); +const ticks = Number(ticksArg); +if (!Number.isInteger(ticks) || ticks <= 0) { + console.error("usage: record-demo.mts [out.json] [ticks] [gameID]"); process.exit(1); } +const gameID = + customGameID ?? + (outArg ? path.basename(outArg, path.extname(outArg)) : undefined) ?? + generateID(); + +const out = outArg ?? path.join("records", `${gameID}.json`); +const outDir = path.dirname(out); +if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }); +} + const commit = process.env.GIT_COMMIT ?? execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); @@ -32,7 +46,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..846a53f8095 100644 --- a/scripts/replay/stub-game-api.mts +++ b/scripts/replay/stub-game-api.mts @@ -14,18 +14,20 @@ 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); +const clientOrigin = + process.env.CLIENT_ORIGIN ?? + `http://localhost:${process.env.CLIENT_PORT ?? 9000}`; +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", clientOrigin); // 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/Main.ts b/src/client/Main.ts index 3569ae6f13f..60b55eb2f74 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -1747,6 +1747,7 @@ class Client { private async handleJoinLobby(event: CustomEvent) { const lobby = event.detail; if (this.usernameInput && !this.usernameInput.canPlay()) { + event.preventDefault(); // The singleplayer modal shows the starting overlay before dispatching // join-lobby; a refused join must release it or it stays over the menu. const startingModal = document.querySelector("game-starting-modal"); @@ -1756,6 +1757,7 @@ class Client { return; } if (this.blockedJoin(lobby)) { + event.preventDefault(); return; } // Only once the join is actually going ahead: a refused dispatch that @@ -1804,6 +1806,19 @@ class Client { }; this.emitPresence(); + if (this.replayViewerID !== null) { + console.log("joining lobby, tearing down replay viewer"); + this.replayViewerID = null; + document.querySelector("replay-viewer")?.remove(); + if (parseReplayViewerHash(window.location.hash) !== null) { + history.replaceState( + null, + "", + window.location.pathname + window.location.search, + ); + } + } + if (this.lobbyHandle !== null) { console.log("joining lobby, stopping existing game"); this.lobbyHandle.stop(true); diff --git a/src/client/replay/ContinueGameModal.ts b/src/client/replay/ContinueGameModal.ts new file mode 100644 index 00000000000..6c636900d76 --- /dev/null +++ b/src/client/replay/ContinueGameModal.ts @@ -0,0 +1,319 @@ +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 > 5 + ? html` + + (this.searchQuery = ( + e.target as HTMLInputElement + ).value)} + /> + ` + : 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/ReplayControls.ts b/src/client/replay/ReplayControls.ts index 1a633ca96ba..38516a3747a 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; @@ -194,6 +196,15 @@ 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/ReplayViewer.ts b/src/client/replay/ReplayViewer.ts index 917eafa61f4..a2620537c88 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"; @@ -27,6 +33,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 { @@ -67,7 +74,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"; @@ -138,6 +154,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; + private record: GameRecord | null = null; /** * The record has no hashes, so the replay can't be checked against the * game that was played. @@ -188,6 +210,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); @@ -238,6 +262,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; @@ -685,9 +710,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. @@ -786,6 +813,117 @@ 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 } = 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", + snapshot, + } satisfies JoinLobbyEvent, + bubbles: true, + composed: true, + cancelable: true, + }); + document.dispatchEvent(joinEvent); + if (joinEvent.defaultPrevented) { + modal?.setLoading( + false, + translateText("replay_viewer.continue_failed"), + ); + return; + } + this.closeContinueModal(); + this.remove(); + } catch (err) { + if (this.continueRequestCount !== requestId) return; + console.error("replay viewer: continue game failed:", err); + modal?.setLoading(false, translateText("replay_viewer.continue_failed")); + } + } + // ---- Render ---- render() { @@ -835,12 +973,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/processor/ReplayProcessor.ts b/src/client/replay/processor/ReplayProcessor.ts index 32baadbb255..aaee564b066 100644 --- a/src/client/replay/processor/ReplayProcessor.ts +++ b/src/client/replay/processor/ReplayProcessor.ts @@ -127,7 +127,7 @@ export class ReplayDesyncError extends Error { * this goes into the replay header), then the server's wire blanking is * applied. */ -function wireGameStartInfo(record: GameRecord): WireGameStartInfo { +export function wireGameStartInfo(record: GameRecord): WireGameStartInfo { return toWireGameStartInfo(WireGameStartInfoSchema.parse(record.info)); } 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/client/MainInitialize.test.ts b/tests/client/MainInitialize.test.ts index 754690438f3..d6dc46be732 100644 --- a/tests/client/MainInitialize.test.ts +++ b/tests/client/MainInitialize.test.ts @@ -364,13 +364,14 @@ describe("Client.initialize() booted from Main.ts module scope", () => { const canPlay = vi.fn(() => false); input.canPlay = canPlay; logSpy.mockClear(); - document.dispatchEvent( - new CustomEvent("join-lobby", { - detail: { gameID: "AbCd1234" }, - bubbles: true, - }), - ); + const event = new CustomEvent("join-lobby", { + detail: { gameID: "AbCd1234" }, + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(event); await vi.waitFor(() => expect(canPlay).toHaveBeenCalled()); + expect(event.defaultPrevented).toBe(true); // Early return before the "joining lobby" log — nothing joined. expect(logSpy).not.toHaveBeenCalledWith( expect.stringContaining("joining lobby"), diff --git a/tests/client/replay/LocalProcessing.test.ts b/tests/client/replay/LocalProcessing.test.ts index 7b9d695fd35..78519a118dc 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"; @@ -175,6 +176,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/ReplayViewerContinueCancel.test.ts b/tests/client/replay/ReplayViewerContinueCancel.test.ts new file mode 100644 index 00000000000..3d4283789b4 --- /dev/null +++ b/tests/client/replay/ReplayViewerContinueCancel.test.ts @@ -0,0 +1,232 @@ +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" }, + tick: 42, + }); + + 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; + viewer.remove = vi.fn(); + + 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.snapshot).toEqual( + new Uint8Array([1, 2, 3]), + ); + expect(joinLobbyListener.mock.calls[0][0].detail.source).toBe( + "singleplayer", + ); + expect(viewer.continueModalOpen).toBe(false); + expect(viewer.remove).toHaveBeenCalledTimes(1); + 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; + viewer.remove = vi.fn(); + + const modal = { + setLoading: vi.fn(), + }; + viewer.querySelector = vi.fn().mockReturnValue(modal); + + 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); + expect(viewer.remove).not.toHaveBeenCalled(); + expect(modal.setLoading).toHaveBeenLastCalledWith( + false, + "replay_viewer.continue_failed", + ); + + document.removeEventListener("join-lobby", joinLobbyListener); + }); + + it("shows translated error message when extraction fails", async () => { + const viewer = new ReplayViewer() as any; + viewer.gameID = "test1234"; + viewer.record = { info: { gameID: "test1234" } } as any; + + const modal = { + setLoading: vi.fn(), + }; + viewer.querySelector = vi.fn().mockReturnValue(modal); + + vi.mocked(extractSnapshotInWorker).mockRejectedValue( + new Error("Engine simulation failure"), + ); + + viewer.openContinueModal(); + + await viewer.handleContinueGame( + new CustomEvent("continue", { + detail: { playerID: "p1", difficulty: Difficulty.Medium }, + }), + ); + + expect(modal.setLoading).toHaveBeenLastCalledWith( + false, + "replay_viewer.continue_failed", + ); + }); +}); diff --git a/tests/client/replay/SnapshotExtractor.test.ts b/tests/client/replay/SnapshotExtractor.test.ts new file mode 100644 index 00000000000..8868d4a1eab --- /dev/null +++ b/tests/client/replay/SnapshotExtractor.test.ts @@ -0,0 +1,692 @@ +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 { SpawnTimerExecution } from "@openfront/engine/execution/SpawnTimerExecution"; +import { TribeExecution } from "@openfront/engine/execution/TribeExecution"; +import { Player } from "@openfront/engine/game/Game"; +import { + createGameRunner, + createGameRunnerFromSnapshot, +} from "@openfront/engine/GameRunner"; +import { readSnapshotHeader } from "@openfront/engine/snapshot/GameSnapshot"; +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, + spawnOnLand, +} 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); + }); + + test("clears eliminated human players from _humans so header has exactly 1 human", async () => { + const p1 = human(1); + const p2 = human(2); + const p3 = human(3); + + const gameConfig = config({ + gameMap: GameMapType.World, + gameMapSize: GameMapSize.Normal, + gameMode: GameMode.FFA, + gameType: GameType.Public, + bots: 2, + }); + + const { record } = await playAndArchive({ + gameID: "DEAD0001", + config: gameConfig, + players: [p1, p2, p3], + ticks: 210, + intents: (game, t) => { + // Spawn p1 and p2 during spawn phase, but do not spawn p3 so p3 is eliminated + if (t === 2) { + return [ + spawnOnLand(game, "client001", 100_000), + spawnOnLand(game, "client002", 100_000), + ]; + } + return []; + }, + }); + + // Extract snapshot at tick 205 (post spawn phase), taking over p1 + const result = await extractSnapshotFromRecord({ + record, + targetTick: 205, + chosenPlayerID: "client001", + localClientID: "LOCAL_CHOSEN", + difficulty: Difficulty.Medium, + mapLoader, + }); + + const header = readSnapshotHeader(result.snapshot); + expect(header.humans.length).toBe(1); + expect(header.humans[0].clientID).toBe("LOCAL_CHOSEN"); + + // Restore the runner to verify post-spawn dead human state + const restoredRunner = await createGameRunnerFromSnapshot( + result.gameStartInfo, + result.snapshot, + "LOCAL_CHOSEN", + await loadMap(result.gameStartInfo.config), + () => {}, + ); + + expect(restoredRunner.game.inSpawnPhase()).toBe(false); + + // p3 was eliminated, so it was removed from humans and its clientID cleared + const deadPlayer = restoredRunner.game.playerByClientID("client003"); + expect(deadPlayer).toBeNull(); + + expect( + restoredRunner.game.players().find((p) => p.name() === "Human 3"), + ).toBeUndefined(); + + const deadP3 = restoredRunner.game + .allPlayers() + .find((p) => p.name() === "Human 3"); + expect(deadP3).toBeDefined(); + expect(deadP3?.isAlive()).toBe(false); + expect(deadP3?.type()).toBe(PlayerType.Nation); + expect(deadP3?.clientID()).toBeNull(); + + // Dead player should not have an active NationExecution + const deadHasExec = restoredRunner.game + .executions() + .some( + (exec: any) => + exec instanceof NationExecution && exec.playerID() === deadP3!.id(), + ); + expect(deadHasExec).toBe(false); + }); + + test("clears unassigned/kicked humans from _humans other than chosen player", 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: "KICK0001", + config: gameConfig, + players: [p1, p2], + ticks: 20, + }); + + // Simulate extra human info in record (such as kicked human who never spawned as active player) + const recordWithKicked = { + ...record, + info: { + ...record.info, + players: [...record.info.players, human(99)], + }, + }; + + const result = await extractSnapshotFromRecord({ + record: recordWithKicked as any, + targetTick: 10, + chosenPlayerID: "client001", + localClientID: "LOCAL_CHOSEN", + difficulty: Difficulty.Easy, + mapLoader, + }); + + const header = readSnapshotHeader(result.snapshot); + expect(header.humans.length).toBe(1); + expect(header.humans[0].clientID).toBe("LOCAL_CHOSEN"); + }); + + test("preserves spawn phase without timer cutoff when extracting unspawned player between ticks 101 and 199", 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: "SPAWN001", + config: gameConfig, + players: [p1, p2], + ticks: 160, + intents: (game, t) => { + // Spawn p1 only; p2 remains unspawned throughout + if (t === 2) { + return [spawnOnLand(game, "client001", 100_000)]; + } + return []; + }, + }); + + // Extract at tick 150 as the unspawned p2 + const result = await extractSnapshotFromRecord({ + record, + targetTick: 150, + chosenPlayerID: "client002", + localClientID: "MYCLIENT2", + difficulty: Difficulty.Medium, + mapLoader, + }); + + const restoredRunner = await createGameRunnerFromSnapshot( + result.gameStartInfo, + result.snapshot, + "MYCLIENT2", + await loadMap(result.gameStartInfo.config), + () => {}, + ); + + const game = restoredRunner.game; + expect(game.ticks()).toBe(150); + // Should still be in spawn phase because human hasn't spawned yet + expect(game.inSpawnPhase()).toBe(true); + + // SpawnTimerExecution must be removed so it doesn't end the spawn phase prematurely + const hasSpawnTimer = game + .executions() + .some((e) => e instanceof SpawnTimerExecution); + expect(hasSpawnTimer).toBe(false); + + // Run a tick past tick 150 (would have exceeded singleplayer 100 limit if timer was still present) + restoredRunner.addTurn({ + turnNumber: 150, + intents: [], + }); + const tickResult = restoredRunner.executeNextTick(); + expect(tickResult).toBe(true); + expect(game.ticks()).toBe(151); + expect(game.inSpawnPhase()).toBe(true); + }); + + test("spawns unspawned human as nation bot when extracting spawned player mid-spawn-phase", 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: 0, + }); + + const { record } = await playAndArchive({ + gameID: "SPAWN002", + config: gameConfig, + players: [p1, p2], + ticks: 30, + intents: (game, t) => { + // Spawn p1 only; p2 remains unspawned + if (t === 2) { + return [spawnOnLand(game, "client001", 100_000)]; + } + return []; + }, + }); + + // Extract at tick 20 as the spawned p1 + const result = await extractSnapshotFromRecord({ + record, + targetTick: 20, + chosenPlayerID: "client001", + localClientID: "MYCLIENT1", + difficulty: Difficulty.Medium, + mapLoader, + }); + + const restoredRunner = await createGameRunnerFromSnapshot( + result.gameStartInfo, + result.snapshot, + "MYCLIENT1", + await loadMap(result.gameStartInfo.config), + () => {}, + ); + + const game = restoredRunner.game; + expect(game.ticks()).toBe(20); + // Chosen player had already spawned, so spawn phase is ended for singleplayer + expect(game.inSpawnPhase()).toBe(false); + + // Chosen player is alive with the local clientID + const chosen = game.playerByClientID("MYCLIENT1"); + expect(chosen).toBeDefined(); + expect(chosen!.isAlive()).toBe(true); + + // Unspawned p2 was converted to a nation bot and spawned rather than dropping out + const convertedP2 = game.allPlayers().find((p) => p.name() === "Human 2"); + expect(convertedP2).toBeDefined(); + expect(convertedP2!.type()).toBe(PlayerType.Nation); + expect(convertedP2!.hasSpawned()).toBe(true); + expect(convertedP2!.isAlive()).toBe(true); + + // Converted nation bot has an active NationExecution + const nationExec = game + .executions() + .find( + (e): e is NationExecution => + e instanceof NationExecution && e.playerID() === convertedP2!.id(), + ); + expect(nationExec).toBeDefined(); + expect(nationExec!.isActive()).toBe(true); + + // Execute next tick post-spawn-phase: nation does not deactivate/drop out + restoredRunner.addTurn({ + turnNumber: 20, + intents: [], + }); + const tickResult = restoredRunner.executeNextTick(); + expect(tickResult).toBe(true); + expect(game.ticks()).toBe(21); + expect(convertedP2!.isAlive()).toBe(true); + expect(nationExec!.isActive()).toBe(true); + }); +}); diff --git a/tests/core/game/GameImpl.test.ts b/tests/core/game/GameImpl.test.ts index 6c954af4748..f1fdf2701f5 100644 --- a/tests/core/game/GameImpl.test.ts +++ b/tests/core/game/GameImpl.test.ts @@ -1,16 +1,20 @@ -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 { SpawnTimerExecution } from "@openfront/engine/execution/SpawnTimerExecution"; +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 +172,156 @@ 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("clearOtherHumans removes all humans except the designated player", () => { + attacker.setPlayerInfo( + new PlayerInfo("Attacker", PlayerType.Human, "clientA", "attacker_id"), + ); + defender.setPlayerInfo( + new PlayerInfo("Defender", PlayerType.Human, "clientB", "defender_id"), + ); + (game as any)._humans = [attacker.info(), defender.info()]; + + game.clearOtherHumans(attacker); + + expect((game as any)._humans).toEqual([attacker.info()]); + }); + + 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); + }); + + test("applySingleplayerConfig removes SpawnTimerExecution", () => { + const timer = new SpawnTimerExecution(); + game.addExecution(timer); + expect(game.executions().includes(timer)).toBe(true); + + game.applySingleplayerConfig(); + + expect(game.executions().includes(timer)).toBe(false); + }); }); diff --git a/vite.config.ts b/vite.config.ts index 182a0030db5..df4e1161f80 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -507,6 +507,7 @@ export default defineConfig(({ mode }) => { server: { port: 9000, + strictPort: true, host: process.env.VITE_HOST === "lan", // Automatically open the browser when the server starts open: process.env.SKIP_BROWSER_OPEN !== "true",