fix(battle-viewer): unblock synthetic-game battle load #16

Merged
developer merged 1 commits from feature/synthetic-battle-loading-fix into development 2026-05-19 05:58:02 +00:00
5 changed files with 100 additions and 17 deletions
Showing only changes of commit bde01b1ce2 - Show all commits
+8 -4
View File
@@ -706,10 +706,14 @@ renders one battle at a time. Entry points:
The viewer is a logically isolated component that consumes a The viewer is a logically isolated component that consumes a
`BattleReport` (shape per `pkg/model/report/battle.go`). The page `BattleReport` (shape per `pkg/model/report/battle.go`). The page
loader (`ui/frontend/src/lib/active-view/battle.svelte`) fetches loader (`ui/frontend/src/lib/active-view/battle.svelte`) fetches
the report through the backend gateway route the report through the signed `user.games.battle` ConnectRPC
`GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`, command on the authenticated edge: the gateway translates the
which forwards verbatim to the engine's verified envelope into `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`
`GET /api/v1/battle/:turn/:uuid`. 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 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 placed at equal angular spacing on an outer ring, and each race is
+7 -4
View File
@@ -724,10 +724,13 @@ Battle Viewer — отдельное представление, заменяю
Сам Viewer — логически изолированный компонент, потребляющий Сам Viewer — логически изолированный компонент, потребляющий
`BattleReport` в форме `pkg/model/report/battle.go`. Страница-обёртка `BattleReport` в форме `pkg/model/report/battle.go`. Страница-обёртка
(`ui/frontend/src/lib/active-view/battle.svelte`) забирает отчёт (`ui/frontend/src/lib/active-view/battle.svelte`) забирает отчёт
через backend-маршрут подписанной ConnectRPC-командой `user.games.battle` на
`GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`, аутентифицированном edge: gateway переводит верифицированный
который проксирует ответ engine-эндпоинта envelope в `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`
`GET /api/v1/battle/:turn/:uuid`. к backend-у, тот в свою очередь проксирует engine-эндпоинт
`GET /api/v1/battle/:turn/:uuid`. Для synthetic-игр загрузчик
короткозамыкает запрос на in-memory карту фикстур, наполненную из
synthetic-report envelope (см. ниже), и не обращается к gateway.
Визуальная модель — радиальная: планета в центре, расы по внешней Визуальная модель — радиальная: планета в центре, расы по внешней
окружности на равных угловых интервалах, внутри расы — облако окружности на равных угловых интервалах, внутри расы — облако
+12 -5
View File
@@ -73,13 +73,16 @@ const RESULT_CODE_OK = "ok";
/** /**
* fetchBattle returns the `BattleReport` for the supplied game, turn, * fetchBattle returns the `BattleReport` for the supplied game, turn,
* and battle id. In synthetic-report mode (DEV / e2e) the lookup is * and battle id. In synthetic-report mode (DEV / e2e) the lookup is
* served from `synthetic-battle.ts`; otherwise the function calls the * served from `synthetic-battle.ts` and the `client` argument is
* `user.games.battle` ConnectRPC command through the supplied * ignored; the in-game shell's synthetic branch deliberately never
* `GalaxyClient`. Throws `BattleFetchError` with the upstream HTTP * publishes a `GalaxyClient`, so callers pass `null` on that path.
* status (or `0` for transport-level failures) on error. * 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( export async function fetchBattle(
client: GalaxyClient, client: GalaxyClient | null,
gameId: string, gameId: string,
turn: number, turn: number,
battleId: string, battleId: string,
@@ -92,6 +95,10 @@ export async function fetchBattle(
return fixture; return fixture;
} }
if (client === null) {
throw new BattleFetchError(0, "GalaxyClient is unavailable");
}
const payload = encodeRequest(gameId, turn, battleId); const payload = encodeRequest(gameId, turn, battleId);
const result = await client.executeCommand(MESSAGE_TYPE, payload); const result = await client.executeCommand(MESSAGE_TYPE, payload);
if (result.resultCode !== RESULT_CODE_OK) { if (result.resultCode !== RESULT_CODE_OK) {
@@ -20,6 +20,7 @@ viewer keeps its prop-driven contract.
fetchBattle, fetchBattle,
type BattleReport, type BattleReport,
} from "../../api/battle-fetch"; } from "../../api/battle-fetch";
import { isSyntheticGameId } from "../../api/synthetic-report";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { import {
RENDERED_REPORT_CONTEXT_KEY, RENDERED_REPORT_CONTEXT_KEY,
@@ -93,7 +94,14 @@ viewer keeps its prop-driven contract.
return; return;
} }
const client = galaxyClient?.client ?? null; 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 // Layout populates the client after the boot Promise.all
// resolves; stay in `loading` so the effect re-runs once // resolves; stay in `loading` so the effect re-runs once
// the handle becomes non-null. // the handle becomes non-null.
+64 -3
View File
@@ -15,6 +15,11 @@ import { render } from "@testing-library/svelte";
import { beforeEach, describe, expect, test } from "vitest"; import { beforeEach, describe, expect, test } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte"; 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 MapView from "../src/lib/active-view/map.svelte";
import TableView from "../src/lib/active-view/table.svelte"; import TableView from "../src/lib/active-view/table.svelte";
@@ -24,6 +29,7 @@ import MailView from "../src/lib/active-view/mail.svelte";
beforeEach(() => { beforeEach(() => {
i18n.resetForTests("en"); i18n.resetForTests("en");
resetSyntheticBattles();
}); });
describe("active-view stubs", () => { 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 // Phase 27 replaces the Phase 10 stub with the Battle Viewer
// wrapper. The latest layout iteration moved the back- // wrapper. The latest layout iteration moved the back-
// navigation buttons inside `BattleViewer` so they only mount // navigation buttons inside `BattleViewer` so they only mount
// once the BattleReport finishes loading. The wrapper itself // once the BattleReport finishes loading. The wrapper itself
// always renders the `active-view-battle` host with the // always renders the `active-view-battle` host with the
// `data-battle-id` stamp and a localized loading copy until // `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, { 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"); const node = ui.getByTestId("active-view-battle");
expect(node).toHaveAttribute("data-battle-id", "b-42"); expect(node).toHaveAttribute("data-battle-id", "b-42");
@@ -102,4 +115,52 @@ describe("active-view stubs", () => {
); );
expect(ui.getByTestId("battle-not-found")).toBeInTheDocument(); 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();
});
}); });