From bde01b1ce2f6148067dd66b0a4418c10585d4d1a Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 19 May 2026 07:52:26 +0200 Subject: [PATCH] fix(battle-viewer): unblock synthetic-game battle load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase 28 ConnectRPC migration of the battle viewer added a guard in `lib/active-view/battle.svelte` that waits for the surrounding layout to publish a `GalaxyClient` before issuing the fetch. The in-game shell layout deliberately skips `galaxyClient.set(...)` on the synthetic branch (gateway is not reachable in synthetic mode), so for any battle opened from a synthetic-report game the viewer sat on "loading battle…" forever — `fetchBattle` was never called, so the synthetic-fixture short-circuit it carries was unreachable. Let the guard skip synthetic ids: `fetchBattle` already resolves those through `lookupSyntheticBattle` and never touches the client, so its signature widens to `GalaxyClient | null` and the synthetic path passes `null`. The live path still waits for the handle as before; a `null` client on the live path now fails fast with a transport-level `BattleFetchError` instead of silently sitting on `loading`. Tests: - Existing "loading placeholder" smoke now uses a non-synthetic game id so it keeps asserting the live-path wait. - Two new cases pin the synthetic behaviour: missing fixture → `battle-not-found`; registered fixture → `BattleViewer` mounts. Docs: - `docs/FUNCTIONAL.md` §6.5 still described the pre-Phase-28 raw REST path. Updated to the signed ConnectRPC command and noted the synthetic short-circuit. Russian mirror updated. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/FUNCTIONAL.md | 12 ++-- docs/FUNCTIONAL_ru.md | 11 +-- ui/frontend/src/api/battle-fetch.ts | 17 +++-- ui/frontend/src/lib/active-view/battle.svelte | 10 ++- ui/frontend/tests/game-shell-stubs.test.ts | 67 ++++++++++++++++++- 5 files changed, 100 insertions(+), 17 deletions(-) diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 15c4271..3947aee 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -706,10 +706,14 @@ renders one battle at a time. Entry points: The viewer is a logically isolated component that consumes a `BattleReport` (shape per `pkg/model/report/battle.go`). The page loader (`ui/frontend/src/lib/active-view/battle.svelte`) fetches -the report through the backend gateway route -`GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`, -which forwards verbatim to the engine's -`GET /api/v1/battle/:turn/:uuid`. +the report through the signed `user.games.battle` ConnectRPC +command on the authenticated edge: the gateway translates the +verified envelope into `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}` +against the backend, which in turn proxies the engine's +`GET /api/v1/battle/:turn/:uuid`. For synthetic games the loader +short-circuits to the in-memory fixture map populated by the +synthetic-report envelope (see below) and never touches the +gateway. Visual model is radial: the planet sits at the centre, races are placed at equal angular spacing on an outer ring, and each race is diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index a3b2d60..58a64e9 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -724,10 +724,13 @@ Battle Viewer — отдельное представление, заменяю Сам Viewer — логически изолированный компонент, потребляющий `BattleReport` в форме `pkg/model/report/battle.go`. Страница-обёртка (`ui/frontend/src/lib/active-view/battle.svelte`) забирает отчёт -через backend-маршрут -`GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`, -который проксирует ответ engine-эндпоинта -`GET /api/v1/battle/:turn/:uuid`. +подписанной ConnectRPC-командой `user.games.battle` на +аутентифицированном edge: gateway переводит верифицированный +envelope в `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}` +к backend-у, тот в свою очередь проксирует engine-эндпоинт +`GET /api/v1/battle/:turn/:uuid`. Для synthetic-игр загрузчик +короткозамыкает запрос на in-memory карту фикстур, наполненную из +synthetic-report envelope (см. ниже), и не обращается к gateway. Визуальная модель — радиальная: планета в центре, расы по внешней окружности на равных угловых интервалах, внутри расы — облако diff --git a/ui/frontend/src/api/battle-fetch.ts b/ui/frontend/src/api/battle-fetch.ts index fa18a56..03e31be 100644 --- a/ui/frontend/src/api/battle-fetch.ts +++ b/ui/frontend/src/api/battle-fetch.ts @@ -73,13 +73,16 @@ const RESULT_CODE_OK = "ok"; /** * fetchBattle returns the `BattleReport` for the supplied game, turn, * and battle id. In synthetic-report mode (DEV / e2e) the lookup is - * served from `synthetic-battle.ts`; otherwise the function calls the - * `user.games.battle` ConnectRPC command through the supplied - * `GalaxyClient`. Throws `BattleFetchError` with the upstream HTTP - * status (or `0` for transport-level failures) on error. + * served from `synthetic-battle.ts` and the `client` argument is + * ignored; the in-game shell's synthetic branch deliberately never + * publishes a `GalaxyClient`, so callers pass `null` on that path. + * Otherwise the function calls the `user.games.battle` ConnectRPC + * command through the supplied `GalaxyClient` and throws + * `BattleFetchError` with the upstream HTTP status (or `0` for + * transport-level failures) on error. */ export async function fetchBattle( - client: GalaxyClient, + client: GalaxyClient | null, gameId: string, turn: number, battleId: string, @@ -92,6 +95,10 @@ export async function fetchBattle( return fixture; } + if (client === null) { + throw new BattleFetchError(0, "GalaxyClient is unavailable"); + } + const payload = encodeRequest(gameId, turn, battleId); const result = await client.executeCommand(MESSAGE_TYPE, payload); if (result.resultCode !== RESULT_CODE_OK) { diff --git a/ui/frontend/src/lib/active-view/battle.svelte b/ui/frontend/src/lib/active-view/battle.svelte index 8b9448c..96ce2f6 100644 --- a/ui/frontend/src/lib/active-view/battle.svelte +++ b/ui/frontend/src/lib/active-view/battle.svelte @@ -20,6 +20,7 @@ viewer keeps its prop-driven contract. fetchBattle, type BattleReport, } from "../../api/battle-fetch"; + import { isSyntheticGameId } from "../../api/synthetic-report"; import { i18n } from "$lib/i18n/index.svelte"; import { RENDERED_REPORT_CONTEXT_KEY, @@ -93,7 +94,14 @@ viewer keeps its prop-driven contract. return; } const client = galaxyClient?.client ?? null; - if (!client) { + // Synthetic games never publish a GalaxyClient because the + // surrounding layout deliberately skips `galaxyClient.set(...)` + // on that branch (gateway is not reachable in synthetic mode). + // `fetchBattle` short-circuits synthetic ids through the + // in-memory fixture map and ignores the client argument, so we + // must not wait for the client handle on this path — otherwise + // the viewer would sit on `loading` forever. + if (!client && !isSyntheticGameId(gameId)) { // Layout populates the client after the boot Promise.all // resolves; stay in `loading` so the effect re-runs once // the handle becomes non-null. diff --git a/ui/frontend/tests/game-shell-stubs.test.ts b/ui/frontend/tests/game-shell-stubs.test.ts index 28ad925..de9004d 100644 --- a/ui/frontend/tests/game-shell-stubs.test.ts +++ b/ui/frontend/tests/game-shell-stubs.test.ts @@ -15,6 +15,11 @@ import { render } from "@testing-library/svelte"; import { beforeEach, describe, expect, test } from "vitest"; import { i18n } from "../src/lib/i18n/index.svelte"; +import { + registerSyntheticBattle, + resetSyntheticBattles, +} from "../src/api/synthetic-battle"; +import type { BattleReport } from "../src/api/battle-fetch"; import MapView from "../src/lib/active-view/map.svelte"; import TableView from "../src/lib/active-view/table.svelte"; @@ -24,6 +29,7 @@ import MailView from "../src/lib/active-view/mail.svelte"; beforeEach(() => { i18n.resetForTests("en"); + resetSyntheticBattles(); }); describe("active-view stubs", () => { @@ -76,16 +82,23 @@ describe("active-view stubs", () => { ); }); - test("battle view stamps the battleId and shows the loading placeholder", () => { + test("battle view stamps the battleId and shows the loading placeholder for a live game", () => { // Phase 27 replaces the Phase 10 stub with the Battle Viewer // wrapper. The latest layout iteration moved the back- // navigation buttons inside `BattleViewer` so they only mount // once the BattleReport finishes loading. The wrapper itself // always renders the `active-view-battle` host with the // `data-battle-id` stamp and a localized loading copy until - // the fetcher resolves. + // the fetcher resolves. For a live game id the wrapper also + // waits for the surrounding layout to publish a `GalaxyClient` + // before issuing the fetch — without the context here the + // effect stays in `loading` as designed. const ui = render(BattleView, { - props: { gameId: "synthetic-test", turn: 0, battleId: "b-42" }, + props: { + gameId: "00000000-0000-0000-0000-000000000010", + turn: 0, + battleId: "b-42", + }, }); const node = ui.getByTestId("active-view-battle"); expect(node).toHaveAttribute("data-battle-id", "b-42"); @@ -102,4 +115,52 @@ describe("active-view stubs", () => { ); expect(ui.getByTestId("battle-not-found")).toBeInTheDocument(); }); + + test("battle view surfaces not-found for a synthetic game when no fixture is registered", async () => { + // Synthetic games never publish a GalaxyClient — the in-game + // shell layout deliberately skips `galaxyClient.set(...)` on + // that branch. The viewer must resolve the fixture (or its + // absence) without waiting on the client handle; if it did + // wait, the view would sit on `loading` indefinitely because + // the handle never lands. `fetchBattle` itself is `async`, so + // even the synchronous fixture-miss path resolves on a + // microtask — `findByTestId` lets the BattleViewer wrapper + // flush its rejection handler before the assertion. + const ui = render(BattleView, { + props: { + gameId: "synthetic-test", + turn: 0, + battleId: "missing-fixture", + }, + }); + await ui.findByTestId("battle-not-found"); + }); + + test("battle view renders a synthetic fixture without a GalaxyClient context", async () => { + // Regression for the dev-deploy bug where the viewer + // short-circuited to `loading` for every cross click on a + // synthetic-report game. The fixture below mirrors the shape + // `fetchBattle` returns for the live path; once + // `lookupSyntheticBattle` resolves it, the wrapper transitions + // to `ready` and mounts the BattleViewer scene. + const fixture: BattleReport = { + id: "fixture-battle", + planet: 1, + planetName: "Earth", + races: { "0": "00000000-0000-0000-0000-0000000000aa" }, + ships: {}, + protocol: [], + }; + registerSyntheticBattle(fixture); + + const ui = render(BattleView, { + props: { + gameId: "synthetic-test", + turn: 0, + battleId: "fixture-battle", + }, + }); + await ui.findByTestId("battle-viewer"); + expect(ui.queryByTestId("battle-loading")).not.toBeInTheDocument(); + }); });