Merge pull request 'fix(battle-viewer): unblock synthetic-game battle load' (#16) from feature/synthetic-battle-loading-fix into development
This commit was merged in pull request #16.
This commit is contained in:
+8
-4
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
Визуальная модель — радиальная: планета в центре, расы по внешней
|
Визуальная модель — радиальная: планета в центре, расы по внешней
|
||||||
окружности на равных угловых интервалах, внутри расы — облако
|
окружности на равных угловых интервалах, внутри расы — облако
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user