ui/synthetic-report: dev-only legacy report loader on lobby

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-<uuid> id and navigates to /games/<id>/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 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-10 11:08:05 +02:00
parent 99962b295f
commit 8f320010c6
5 changed files with 647 additions and 0 deletions
+250
View File
@@ -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<string, GameReport>();
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-<uuid>` 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;
}
+35
View File
@@ -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<void> {
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") {
@@ -44,6 +44,7 @@ fresh.
-->
<script lang="ts">
import { onDestroy, onMount, setContext } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import Header from "$lib/header/header.svelte";
import Sidebar from "$lib/sidebar/sidebar.svelte";
@@ -83,6 +84,10 @@ fresh.
import { createEdgeGatewayClient } from "../../../api/connect";
import { GalaxyClient } from "../../../api/galaxy-client";
import { GATEWAY_BASE_URL, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import {
getSyntheticReport,
isSyntheticGameId,
} from "../../../api/synthetic-report";
let { children } = $props();
@@ -167,6 +172,36 @@ fresh.
onMount(() => {
(async (): Promise<void> => {
// DEV-only synthetic-report path. The lobby's "Load
// synthetic report" affordance navigates here with a
// `synthetic-<uuid>` id and the matching report
// pre-registered in an in-memory map. A page reload
// loses the map entry; that case redirects to /lobby
// so the user reloads the JSON.
if (isSyntheticGameId(gameId)) {
const report = getSyntheticReport(gameId);
if (report === undefined) {
await goto("/lobby");
return;
}
try {
const { cache } = await loadStore();
await Promise.all([
gameState.initSynthetic({ cache, gameId, report }),
orderDraft.init({ cache, gameId }),
]);
// Deliberately no `galaxyClient.set` and no
// `orderDraft.bindClient`: synthetic mode never
// sends to the gateway. The auto-sync pipeline
// already short-circuits via the UUID guard in
// `scheduleSync`, but skipping the bind keeps
// the path simple to reason about.
} catch (err) {
gameState.failBootstrap(describeBootstrapError(err));
}
return;
}
if (
session.keypair === null ||
session.deviceSessionId === null ||
+81
View File
@@ -20,6 +20,10 @@
import { ByteBuffer } from "flatbuffers";
import { AccountResponse } from "../../proto/galaxy/fbs/user";
import { GATEWAY_BASE_URL, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import {
SyntheticReportError,
loadSyntheticReportFromJSON,
} from "../../api/synthetic-report";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { loadCore } from "../../platform/core/index";
import { session } from "$lib/session-store.svelte";
@@ -43,6 +47,8 @@
let inviteActionInFlight: string | null = $state(null);
let syntheticError: string | null = $state(null);
let client: GalaxyClient | null = null;
async function logout(): Promise<void> {
@@ -185,6 +191,32 @@
goto(`/games/${gameId}/map`);
}
async function onSyntheticFileChange(
event: Event & { currentTarget: HTMLInputElement },
): Promise<void> {
syntheticError = null;
const file = event.currentTarget.files?.[0];
if (file === undefined) return;
try {
const text = await file.text();
const json: unknown = JSON.parse(text);
const { gameId } = loadSyntheticReportFromJSON(json);
await goto(`/games/${gameId}/map`);
} catch (err) {
if (err instanceof SyntheticReportError) {
syntheticError = err.message;
} else if (err instanceof SyntaxError) {
syntheticError = `invalid JSON: ${err.message}`;
} else if (err instanceof Error) {
syntheticError = err.message;
} else {
syntheticError = "failed to load synthetic report";
}
} finally {
event.currentTarget.value = "";
}
}
// Statuses for which the game has a navigable in-game view.
// Lobby-internal statuses (draft, enrollment_open, ready_to_start,
// starting, start_failed) and terminal ones (cancelled) stay
@@ -337,6 +369,39 @@
{/if}
</section>
{#if import.meta.env.DEV}
<!--
Synthetic-report loader. DEV-only affordance for visual testing
against rich game states without playing many turns. The JSON
is produced offline by the Go CLI in
`tools/local-dev/legacy-report/`; see
`ui/docs/testing.md#synthetic-reports` for the workflow.
-->
<section data-testid="lobby-synthetic-section">
<h2>Synthetic test reports (DEV)</h2>
<p class="meta">
Load a JSON file produced by
<code>legacy-report-to-json</code> to open the map view
against a synthetic snapshot. Orders compose locally but
never reach the server.
</p>
<label class="synthetic-loader">
Load JSON…
<input
type="file"
accept=".json,application/json"
onchange={onSyntheticFileChange}
data-testid="lobby-synthetic-file"
/>
</label>
{#if syntheticError !== null}
<p role="alert" data-testid="lobby-synthetic-error">
{syntheticError}
</p>
{/if}
</section>
{/if}
<section data-testid="lobby-public-games-section">
<h2>{i18n.t("lobby.section.public_games")}</h2>
{#if listsLoading}
@@ -505,4 +570,20 @@
font-size: 1rem;
padding: 0.4rem 0.5rem;
}
.synthetic-loader {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
border: 1px dashed #888;
border-radius: 0.4rem;
background: #f7f7f7;
cursor: pointer;
font: inherit;
}
.synthetic-loader input[type="file"] {
font-size: 0.9rem;
}
</style>