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