ui: plan 01-27 done #1
@@ -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;
|
||||||
|
}
|
||||||
@@ -56,6 +56,16 @@ export class GameStateStore {
|
|||||||
* later phases (history mode, calc) will read it directly.
|
* later phases (history mode, calc) will read it directly.
|
||||||
*/
|
*/
|
||||||
currentTurn = $state(0);
|
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 client: GalaxyClient | null = null;
|
||||||
private cache: Cache | null = null;
|
private cache: Cache | null = null;
|
||||||
@@ -161,6 +171,31 @@ export class GameStateStore {
|
|||||||
this.error = message;
|
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 {
|
dispose(): void {
|
||||||
this.destroyed = true;
|
this.destroyed = true;
|
||||||
if (this.visibilityListener !== null && typeof document !== "undefined") {
|
if (this.visibilityListener !== null && typeof document !== "undefined") {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ fresh.
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount, setContext } from "svelte";
|
import { onDestroy, onMount, setContext } from "svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import Header from "$lib/header/header.svelte";
|
import Header from "$lib/header/header.svelte";
|
||||||
import Sidebar from "$lib/sidebar/sidebar.svelte";
|
import Sidebar from "$lib/sidebar/sidebar.svelte";
|
||||||
@@ -83,6 +84,10 @@ fresh.
|
|||||||
import { createEdgeGatewayClient } from "../../../api/connect";
|
import { createEdgeGatewayClient } from "../../../api/connect";
|
||||||
import { GalaxyClient } from "../../../api/galaxy-client";
|
import { GalaxyClient } from "../../../api/galaxy-client";
|
||||||
import { GATEWAY_BASE_URL, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
import { GATEWAY_BASE_URL, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
||||||
|
import {
|
||||||
|
getSyntheticReport,
|
||||||
|
isSyntheticGameId,
|
||||||
|
} from "../../../api/synthetic-report";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -167,6 +172,36 @@ fresh.
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
(async (): Promise<void> => {
|
(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 (
|
if (
|
||||||
session.keypair === null ||
|
session.keypair === null ||
|
||||||
session.deviceSessionId === null ||
|
session.deviceSessionId === null ||
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
import { ByteBuffer } from "flatbuffers";
|
import { ByteBuffer } from "flatbuffers";
|
||||||
import { AccountResponse } from "../../proto/galaxy/fbs/user";
|
import { AccountResponse } from "../../proto/galaxy/fbs/user";
|
||||||
import { GATEWAY_BASE_URL, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
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 { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
import { loadCore } from "../../platform/core/index";
|
import { loadCore } from "../../platform/core/index";
|
||||||
import { session } from "$lib/session-store.svelte";
|
import { session } from "$lib/session-store.svelte";
|
||||||
@@ -43,6 +47,8 @@
|
|||||||
|
|
||||||
let inviteActionInFlight: string | null = $state(null);
|
let inviteActionInFlight: string | null = $state(null);
|
||||||
|
|
||||||
|
let syntheticError: string | null = $state(null);
|
||||||
|
|
||||||
let client: GalaxyClient | null = null;
|
let client: GalaxyClient | null = null;
|
||||||
|
|
||||||
async function logout(): Promise<void> {
|
async function logout(): Promise<void> {
|
||||||
@@ -185,6 +191,32 @@
|
|||||||
goto(`/games/${gameId}/map`);
|
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.
|
// Statuses for which the game has a navigable in-game view.
|
||||||
// Lobby-internal statuses (draft, enrollment_open, ready_to_start,
|
// Lobby-internal statuses (draft, enrollment_open, ready_to_start,
|
||||||
// starting, start_failed) and terminal ones (cancelled) stay
|
// starting, start_failed) and terminal ones (cancelled) stay
|
||||||
@@ -337,6 +369,39 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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">
|
<section data-testid="lobby-public-games-section">
|
||||||
<h2>{i18n.t("lobby.section.public_games")}</h2>
|
<h2>{i18n.t("lobby.section.public_games")}</h2>
|
||||||
{#if listsLoading}
|
{#if listsLoading}
|
||||||
@@ -505,4 +570,20 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
padding: 0.4rem 0.5rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
// Vitest unit coverage for `api/synthetic-report.ts`. The decoder
|
||||||
|
// mirrors `pkg/model/report.Report` JSON (as emitted by the Go CLI
|
||||||
|
// `tools/local-dev/legacy-report/cmd/legacy-report-to-json`) into the
|
||||||
|
// in-game-shell `GameReport` shape. The tests assert the decoder
|
||||||
|
// flattens all four planet kinds, looks the local player's tech
|
||||||
|
// levels up by race name, defaults missing routes to empty, and
|
||||||
|
// rejects malformed input.
|
||||||
|
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SYNTHETIC_GAME_ID_PREFIX,
|
||||||
|
SyntheticReportError,
|
||||||
|
getSyntheticReport,
|
||||||
|
isSyntheticGameId,
|
||||||
|
loadSyntheticReportFromJSON,
|
||||||
|
} from "../src/api/synthetic-report";
|
||||||
|
|
||||||
|
function syntheticJSON(extra: Record<string, unknown> = {}): unknown {
|
||||||
|
return {
|
||||||
|
turn: 39,
|
||||||
|
mapWidth: 800,
|
||||||
|
mapHeight: 800,
|
||||||
|
mapPlanets: 700,
|
||||||
|
race: "KnightErrants",
|
||||||
|
votes: 16.02,
|
||||||
|
voteFor: "KnightErrants",
|
||||||
|
player: [
|
||||||
|
{
|
||||||
|
name: "KnightErrants",
|
||||||
|
drive: 13.25,
|
||||||
|
weapons: 6.11,
|
||||||
|
shields: 7.09,
|
||||||
|
cargo: 1,
|
||||||
|
population: 16015.04,
|
||||||
|
industry: 13668.76,
|
||||||
|
planets: 22,
|
||||||
|
relation: "-",
|
||||||
|
votes: 16.02,
|
||||||
|
extinct: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Other",
|
||||||
|
drive: 9.5,
|
||||||
|
weapons: 4.01,
|
||||||
|
shields: 4.69,
|
||||||
|
cargo: 1,
|
||||||
|
population: 0,
|
||||||
|
industry: 0,
|
||||||
|
planets: 0,
|
||||||
|
relation: "War",
|
||||||
|
votes: 0,
|
||||||
|
extinct: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
localPlanet: [
|
||||||
|
{
|
||||||
|
number: 17,
|
||||||
|
name: "Castle",
|
||||||
|
x: 171.05,
|
||||||
|
y: 700.24,
|
||||||
|
size: 1000,
|
||||||
|
population: 1000,
|
||||||
|
industry: 1000,
|
||||||
|
resources: 10,
|
||||||
|
production: "Drive_Research",
|
||||||
|
capital: 0,
|
||||||
|
material: 0.68,
|
||||||
|
colonists: 88.78,
|
||||||
|
freeIndustry: 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
otherPlanet: [
|
||||||
|
{
|
||||||
|
owner: "Monstrai",
|
||||||
|
number: 12,
|
||||||
|
name: "Skarabei",
|
||||||
|
x: 303.84,
|
||||||
|
y: 579.23,
|
||||||
|
size: 500,
|
||||||
|
population: 500,
|
||||||
|
industry: 500,
|
||||||
|
resources: 10,
|
||||||
|
production: "Capital",
|
||||||
|
capital: 0,
|
||||||
|
material: 70.99,
|
||||||
|
colonists: 20.03,
|
||||||
|
freeIndustry: 341.78,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
uninhabitedPlanet: [
|
||||||
|
{
|
||||||
|
number: 9,
|
||||||
|
name: "Dw2",
|
||||||
|
x: 117.87,
|
||||||
|
y: 795.21,
|
||||||
|
size: 500,
|
||||||
|
resources: 10,
|
||||||
|
capital: 0,
|
||||||
|
material: 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
unidentifiedPlanet: [
|
||||||
|
{ number: 0, x: 738.08, y: 600.26 },
|
||||||
|
{ number: 1, x: 579.12, y: 489.37 },
|
||||||
|
],
|
||||||
|
localShipClass: [
|
||||||
|
{
|
||||||
|
name: "Frontier",
|
||||||
|
drive: 11.37,
|
||||||
|
armament: 0,
|
||||||
|
weapons: 0,
|
||||||
|
shields: 0,
|
||||||
|
cargo: 1,
|
||||||
|
mass: 12.37,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("loadSyntheticReportFromJSON", () => {
|
||||||
|
test("flattens all four planet kinds with kind-specific nullables", () => {
|
||||||
|
const { gameId, report } = loadSyntheticReportFromJSON(syntheticJSON());
|
||||||
|
|
||||||
|
expect(isSyntheticGameId(gameId)).toBe(true);
|
||||||
|
expect(gameId.startsWith(SYNTHETIC_GAME_ID_PREFIX)).toBe(true);
|
||||||
|
|
||||||
|
expect(report.turn).toBe(39);
|
||||||
|
expect(report.mapWidth).toBe(800);
|
||||||
|
expect(report.mapHeight).toBe(800);
|
||||||
|
expect(report.planetCount).toBe(700);
|
||||||
|
expect(report.race).toBe("KnightErrants");
|
||||||
|
|
||||||
|
expect(report.planets).toHaveLength(5);
|
||||||
|
|
||||||
|
const local = report.planets.find((p) => p.kind === "local")!;
|
||||||
|
expect(local.name).toBe("Castle");
|
||||||
|
expect(local.industryStockpile).toBe(0);
|
||||||
|
expect(local.materialsStockpile).toBe(0.68);
|
||||||
|
expect(local.industry).toBe(1000);
|
||||||
|
expect(local.production).toBe("Drive_Research");
|
||||||
|
|
||||||
|
const other = report.planets.find((p) => p.kind === "other")!;
|
||||||
|
expect(other.owner).toBe("Monstrai");
|
||||||
|
expect(other.name).toBe("Skarabei");
|
||||||
|
|
||||||
|
const uninhab = report.planets.find((p) => p.kind === "uninhabited")!;
|
||||||
|
expect(uninhab.name).toBe("Dw2");
|
||||||
|
// Uninhabited planets carry size/resources/stockpiles but no
|
||||||
|
// industry / population / production.
|
||||||
|
expect(uninhab.size).toBe(500);
|
||||||
|
expect(uninhab.industry).toBeNull();
|
||||||
|
expect(uninhab.population).toBeNull();
|
||||||
|
expect(uninhab.production).toBeNull();
|
||||||
|
|
||||||
|
const unident = report.planets.filter((p) => p.kind === "unidentified");
|
||||||
|
expect(unident).toHaveLength(2);
|
||||||
|
expect(unident[0]!.name).toBe("");
|
||||||
|
expect(unident[0]!.size).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("derives local player tech from the matching player row", () => {
|
||||||
|
const { report } = loadSyntheticReportFromJSON(syntheticJSON());
|
||||||
|
expect(report.localPlayerDrive).toBe(13.25);
|
||||||
|
expect(report.localPlayerWeapons).toBe(6.11);
|
||||||
|
expect(report.localPlayerShields).toBe(7.09);
|
||||||
|
expect(report.localPlayerCargo).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns zeros when the local race row is missing", () => {
|
||||||
|
const { report } = loadSyntheticReportFromJSON(
|
||||||
|
syntheticJSON({ race: "GhostRace" }),
|
||||||
|
);
|
||||||
|
expect(report.localPlayerDrive).toBe(0);
|
||||||
|
expect(report.localPlayerWeapons).toBe(0);
|
||||||
|
expect(report.localPlayerShields).toBe(0);
|
||||||
|
expect(report.localPlayerCargo).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits empty routes (legacy format has no routes section)", () => {
|
||||||
|
const { report } = loadSyntheticReportFromJSON(syntheticJSON());
|
||||||
|
expect(report.routes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registers the report under the returned game id", () => {
|
||||||
|
const { gameId, report } = loadSyntheticReportFromJSON(syntheticJSON());
|
||||||
|
expect(getSyntheticReport(gameId)).toBe(report);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two loads produce distinct ids", () => {
|
||||||
|
const a = loadSyntheticReportFromJSON(syntheticJSON());
|
||||||
|
const b = loadSyntheticReportFromJSON(syntheticJSON());
|
||||||
|
expect(a.gameId).not.toBe(b.gameId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects non-object input", () => {
|
||||||
|
expect(() => loadSyntheticReportFromJSON(null)).toThrow(
|
||||||
|
SyntheticReportError,
|
||||||
|
);
|
||||||
|
expect(() => loadSyntheticReportFromJSON(42)).toThrow(
|
||||||
|
SyntheticReportError,
|
||||||
|
);
|
||||||
|
expect(() => loadSyntheticReportFromJSON("a string")).toThrow(
|
||||||
|
SyntheticReportError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ship classes survive with truncated armament", () => {
|
||||||
|
const { report } = loadSyntheticReportFromJSON(
|
||||||
|
syntheticJSON({
|
||||||
|
localShipClass: [
|
||||||
|
{
|
||||||
|
name: "Bow105",
|
||||||
|
drive: 74.77,
|
||||||
|
armament: 105,
|
||||||
|
weapons: 1,
|
||||||
|
shields: 19.72,
|
||||||
|
cargo: 1,
|
||||||
|
mass: 148.49,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(report.localShipClass).toHaveLength(1);
|
||||||
|
expect(report.localShipClass[0]!.name).toBe("Bow105");
|
||||||
|
expect(report.localShipClass[0]!.armament).toBe(105);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isSyntheticGameId", () => {
|
||||||
|
test("recognises the synthetic prefix", () => {
|
||||||
|
expect(isSyntheticGameId("synthetic-abc")).toBe(true);
|
||||||
|
expect(isSyntheticGameId("00000000-0000-0000-0000-000000000000")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(isSyntheticGameId("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSyntheticReport", () => {
|
||||||
|
test("returns undefined for unknown ids", () => {
|
||||||
|
expect(getSyntheticReport("synthetic-missing")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user