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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user