From 8f320010c622fa45ea179a42ea5d8add1f31202c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 10 May 2026 11:08:05 +0200 Subject: [PATCH] ui/synthetic-report: dev-only legacy report loader on lobby MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds api/synthetic-report.ts, an in-memory registry + JSON->GameReport decoder for synthetic-mode game sessions. The lobby grows a import.meta.env.DEV-gated "Synthetic test reports" section with a JSON file picker; loading a file registers the decoded report under a synthetic- id and navigates to /games//map. The in-game shell layout detects the synthetic id range, takes the report straight from the registry via gameState.initSynthetic, and deliberately skips both galaxyClient.set and orderDraft.bindClient. Order auto-sync stays silent: scheduleSync already short-circuits on non-UUID game ids, and without a bound client the network path is unreachable. applyOrderOverlay continues to project locally-valid draft commands onto the rendered report so renames / production choices / route edits are visible immediately. A page reload loses the in-memory entry and redirects to /lobby — synthetic mode is a debug affordance, not a session. Co-Authored-By: Claude Opus 4.7 --- ui/frontend/src/api/synthetic-report.ts | 250 ++++++++++++++++++ ui/frontend/src/lib/game-state.svelte.ts | 35 +++ .../src/routes/games/[id]/+layout.svelte | 35 +++ ui/frontend/src/routes/lobby/+page.svelte | 81 ++++++ ui/frontend/tests/synthetic-report.test.ts | 246 +++++++++++++++++ 5 files changed, 647 insertions(+) create mode 100644 ui/frontend/src/api/synthetic-report.ts create mode 100644 ui/frontend/tests/synthetic-report.test.ts diff --git a/ui/frontend/src/api/synthetic-report.ts b/ui/frontend/src/api/synthetic-report.ts new file mode 100644 index 0000000..bb48203 --- /dev/null +++ b/ui/frontend/src/api/synthetic-report.ts @@ -0,0 +1,250 @@ +// DEV-only synthetic-report loader. Backs the "Load synthetic report" +// affordance on the lobby (visible behind `import.meta.env.DEV`) and +// the in-game shell layout's bypass for the synthetic game id range. +// +// The accepted JSON shape mirrors `pkg/model/report.Report` as +// emitted by `tools/local-dev/legacy-report/cmd/legacy-report-to-json`. +// Whenever the UI's `decodeReport` (`api/game-state.ts`) is extended +// to read a new field, this decoder must be extended in lock-step +// AND the Go CLI must learn to populate that field — see the +// synthetic-report parity rule in `ui/PLAN.md`. +// +// The in-memory map deliberately does not survive a page reload: +// synthetic mode is a debug affordance, not a session, and the +// layout redirects to /lobby when a synthetic id is opened with no +// matching entry. +// +// Routes are always emitted empty: the legacy text report has no +// dedicated cargo-routes section, and `applyOrderOverlay` already +// handles an empty `routes` array. + +import type { + GameReport, + ReportPlanet, + ReportRoute, + ShipClassSummary, +} from "./game-state"; + +export const SYNTHETIC_GAME_ID_PREFIX = "synthetic-"; + +const SYNTHETIC_REPORTS = new Map(); + +export function isSyntheticGameId(gameId: string): boolean { + return gameId.startsWith(SYNTHETIC_GAME_ID_PREFIX); +} + +export class SyntheticReportError extends Error { + constructor(message: string) { + super(message); + this.name = "SyntheticReportError"; + } +} + +/** + * loadSyntheticReportFromJSON validates the passed payload, decodes + * it into a `GameReport`, registers it in the in-memory map under a + * fresh `synthetic-` id, and returns both the id and the + * decoded report. Throws `SyntheticReportError` for malformed input. + */ +export function loadSyntheticReportFromJSON(json: unknown): { + gameId: string; + report: GameReport; +} { + const report = decodeSyntheticReport(json); + const gameId = SYNTHETIC_GAME_ID_PREFIX + crypto.randomUUID(); + SYNTHETIC_REPORTS.set(gameId, report); + return { gameId, report }; +} + +/** getSyntheticReport returns the report registered under `gameId`, + * or `undefined` if the entry was lost (e.g. page reload). */ +export function getSyntheticReport(gameId: string): GameReport | undefined { + return SYNTHETIC_REPORTS.get(gameId); +} + +interface SyntheticPlanet { + number: number; + name?: string; + x: number; + y: number; + size?: number; + resources?: number; + capital?: number; + material?: number; + industry?: number; + population?: number; + colonists?: number; + production?: string; + freeIndustry?: number; + owner?: string; +} + +interface SyntheticShipClass { + name: string; + drive: number; + armament: number; + weapons: number; + shields: number; + cargo: number; +} + +interface SyntheticPlayer { + name: string; + drive: number; + weapons: number; + shields: number; + cargo: number; +} + +interface SyntheticReportRoot { + turn?: number; + mapWidth?: number; + mapHeight?: number; + mapPlanets?: number; + race?: string; + player?: SyntheticPlayer[]; + localPlanet?: SyntheticPlanet[]; + otherPlanet?: SyntheticPlanet[]; + uninhabitedPlanet?: SyntheticPlanet[]; + unidentifiedPlanet?: SyntheticPlanet[]; + localShipClass?: SyntheticShipClass[]; +} + +function decodeSyntheticReport(json: unknown): GameReport { + if (typeof json !== "object" || json === null) { + throw new SyntheticReportError("synthetic report must be a JSON object"); + } + const root = json as SyntheticReportRoot; + + const planets: ReportPlanet[] = []; + for (const p of root.localPlanet ?? []) { + planets.push(toPlanet(p, "local", null)); + } + for (const p of root.otherPlanet ?? []) { + planets.push(toPlanet(p, "other", p.owner ?? null)); + } + for (const p of root.uninhabitedPlanet ?? []) { + planets.push(toPlanet(p, "uninhabited", null)); + } + for (const p of root.unidentifiedPlanet ?? []) { + planets.push(toPlanet(p, "unidentified", null)); + } + + const localShipClass: ShipClassSummary[] = (root.localShipClass ?? []).map( + (sc) => ({ + name: sc.name, + drive: numOr0(sc.drive), + armament: Math.trunc(numOr0(sc.armament)), + weapons: numOr0(sc.weapons), + shields: numOr0(sc.shields), + cargo: numOr0(sc.cargo), + }), + ); + + const race = typeof root.race === "string" ? root.race : ""; + const tech = findLocalPlayerTech(root.player ?? [], race); + + const routes: ReportRoute[] = []; + + return { + turn: numOr0(root.turn), + mapWidth: numOr0(root.mapWidth), + mapHeight: numOr0(root.mapHeight), + planetCount: numOr0(root.mapPlanets), + planets, + race, + localShipClass, + routes, + localPlayerDrive: tech.drive, + localPlayerWeapons: tech.weapons, + localPlayerShields: tech.shields, + localPlayerCargo: tech.cargo, + }; +} + +function toPlanet( + p: SyntheticPlanet, + kind: ReportPlanet["kind"], + owner: string | null, +): ReportPlanet { + const has = (v: number | undefined): number | null => + typeof v === "number" ? v : null; + if (kind === "unidentified") { + return { + number: numOr0(p.number), + name: "", + x: numOr0(p.x), + y: numOr0(p.y), + kind, + owner, + size: null, + resources: null, + industryStockpile: null, + materialsStockpile: null, + industry: null, + population: null, + colonists: null, + production: null, + freeIndustry: null, + }; + } + if (kind === "uninhabited") { + return { + number: numOr0(p.number), + name: typeof p.name === "string" ? p.name : "", + x: numOr0(p.x), + y: numOr0(p.y), + kind, + owner, + size: has(p.size), + resources: has(p.resources), + industryStockpile: has(p.capital), + materialsStockpile: has(p.material), + industry: null, + population: null, + colonists: null, + production: null, + freeIndustry: null, + }; + } + return { + number: numOr0(p.number), + name: typeof p.name === "string" ? p.name : "", + x: numOr0(p.x), + y: numOr0(p.y), + kind, + owner, + size: has(p.size), + resources: has(p.resources), + industryStockpile: has(p.capital), + materialsStockpile: has(p.material), + industry: has(p.industry), + population: has(p.population), + colonists: has(p.colonists), + production: typeof p.production === "string" ? p.production : null, + freeIndustry: has(p.freeIndustry), + }; +} + +function findLocalPlayerTech( + players: SyntheticPlayer[], + race: string, +): { drive: number; weapons: number; shields: number; cargo: number } { + if (race === "") { + return { drive: 0, weapons: 0, shields: 0, cargo: 0 }; + } + const local = players.find((p) => p.name === race); + if (local === undefined) { + return { drive: 0, weapons: 0, shields: 0, cargo: 0 }; + } + return { + drive: numOr0(local.drive), + weapons: numOr0(local.weapons), + shields: numOr0(local.shields), + cargo: numOr0(local.cargo), + }; +} + +function numOr0(v: unknown): number { + return typeof v === "number" && Number.isFinite(v) ? v : 0; +} diff --git a/ui/frontend/src/lib/game-state.svelte.ts b/ui/frontend/src/lib/game-state.svelte.ts index 053677f..2b2528e 100644 --- a/ui/frontend/src/lib/game-state.svelte.ts +++ b/ui/frontend/src/lib/game-state.svelte.ts @@ -56,6 +56,16 @@ export class GameStateStore { * later phases (history mode, calc) will read it directly. */ currentTurn = $state(0); + /** + * synthetic is set by `initSynthetic` for DEV-only sessions backed + * by a hand-loaded report (lobby's "Load synthetic report" + * affordance). The flag travels through the layout so the order + * tab and any future server-bound features can short-circuit and + * stay local. The auto-sync pipeline already protects itself via + * the UUID guard on `OrderDraftStore.scheduleSync`, so flipping + * this flag is enough to keep the network silent. + */ + synthetic = $state(false); private client: GalaxyClient | null = null; private cache: Cache | null = null; @@ -161,6 +171,31 @@ export class GameStateStore { this.error = message; } + /** + * initSynthetic seeds the store from a pre-loaded `GameReport` + * without touching the network. Used by the lobby's DEV-only + * "Load synthetic report" affordance: the layout invokes this + * instead of `init` when the route id is in the synthetic id + * range. The store ends up in `ready` immediately; no polling, + * no visibility-driven refresh, no client / cache-of-server + * binding. + */ + async initSynthetic(opts: { + cache: Cache; + gameId: string; + report: GameReport; + }): Promise { + this.cache = opts.cache; + this.gameId = opts.gameId; + this.synthetic = true; + this.gameName = "Synthetic"; + this.error = null; + this.wrapMode = await readWrapMode(opts.cache, opts.gameId); + this.report = opts.report; + this.currentTurn = opts.report.turn; + this.status = "ready"; + } + dispose(): void { this.destroyed = true; if (this.visibilityListener !== null && typeof document !== "undefined") { diff --git a/ui/frontend/src/routes/games/[id]/+layout.svelte b/ui/frontend/src/routes/games/[id]/+layout.svelte index 6d44067..88ab268 100644 --- a/ui/frontend/src/routes/games/[id]/+layout.svelte +++ b/ui/frontend/src/routes/games/[id]/+layout.svelte @@ -44,6 +44,7 @@ fresh. -->