feat(game): race exit warnings in the turn report (#12)
Tests · Go / test (push) Successful in 2m29s
Tests · UI / test (push) Waiting to run
Tests · Go / test (pull_request) Successful in 2m17s
Tests · Integration / integration (pull_request) Successful in 1m53s
Tests · UI / test (pull_request) Successful in 3m37s

Surface the inactivity-removal countdown the rules promise but the
engine never reported. A race within five turns of being auto-removed
for inactivity gets a personal warning in its own report; every race
within three turns is listed publicly to all participants.

- model: Report.PersonalExitWarning + RacesLeavingSoon ([]RaceExitNotice)
- fbs: RaceExitNotice table + Report.personal_exit_warning /
  races_leaving_soon (regenerated Go + TS bindings)
- transcoder: encode/decode both fields
- engine: ReportExitWarnings fills the recipient's TTL (1..5) and lists
  other non-extinct races with TTL 1..3, excluding the recipient itself
- ui: danger-styled personal banner + "races leaving soon" section
  (hidden when empty), wired into the report view, EN/RU i18n
- docs: rules.txt report-section list, FUNCTIONAL.md 6.4 + RU mirror

Voluntary quit and idle timeout share the TTL countdown and are not
distinguished, per the agreed scope.
This commit is contained in:
Ilia Denisov
2026-05-31 10:34:50 +02:00
parent 9dce15c7bb
commit 9e9977d5f1
28 changed files with 908 additions and 22 deletions
@@ -26,6 +26,7 @@ import {
OtherScience,
OthersShipClass,
Player,
RaceExitNotice,
Report,
Route,
RouteEntry,
@@ -139,6 +140,11 @@ export interface ShipProductionFixture {
free?: number;
}
export interface RaceExitNoticeFixture {
race: string;
turnsLeft: number;
}
export interface ReportFixture {
turn: number;
mapWidth?: number;
@@ -159,6 +165,8 @@ export interface ReportFixture {
battles?: BattleSummaryFixture[];
bombings?: BombingFixture[];
shipProductions?: ShipProductionFixture[];
personalExitWarning?: number;
racesLeavingSoon?: RaceExitNoticeFixture[];
}
export function buildReportPayload(fixture: ReportFixture): Uint8Array {
@@ -356,6 +364,14 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
return ShipProduction.endShipProduction(builder);
});
const racesLeavingSoonOffsets = (fixture.racesLeavingSoon ?? []).map((n) => {
const race = builder.createString(n.race);
RaceExitNotice.startRaceExitNotice(builder);
RaceExitNotice.addRace(builder, race);
RaceExitNotice.addTurnsLeft(builder, n.turnsLeft);
return RaceExitNotice.endRaceExitNotice(builder);
});
const localVec =
localOffsets.length === 0
? null
@@ -404,6 +420,10 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
shipProductionOffsets.length === 0
? null
: Report.createShipProductionVector(builder, shipProductionOffsets);
const racesLeavingSoonVec =
racesLeavingSoonOffsets.length === 0
? null
: Report.createRacesLeavingSoonVector(builder, racesLeavingSoonOffsets);
// Phase 27 — `battle` carries `BattleSummary` tables, each with
// an inline `id:UUID` struct plus `planet` and `shots` slots.
const battleVec = (() => {
@@ -462,6 +482,10 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
if (bombingVec !== null) Report.addBombing(builder, bombingVec);
if (shipProductionVec !== null)
Report.addShipProduction(builder, shipProductionVec);
if (fixture.personalExitWarning !== undefined)
Report.addPersonalExitWarning(builder, fixture.personalExitWarning);
if (racesLeavingSoonVec !== null)
Report.addRacesLeavingSoon(builder, racesLeavingSoonVec);
const reportOff = Report.endReport(builder);
builder.finish(reportOff);
return builder.asUint8Array();
@@ -45,6 +45,7 @@ const BATTLE_ID = "00000000-0000-0000-0000-000000000001";
// the popover and a `report-section-<slug>` testid in the body.
const SECTIONS: ReadonlyArray<{ slug: string; expectRow: string | null }> = [
{ slug: "galaxy-summary", expectRow: "galaxy-summary-field-turn" },
{ slug: "race-exit-warnings", expectRow: "race-exit-warnings-row" },
{ slug: "votes", expectRow: "votes-mine" },
{ slug: "player-status", expectRow: "player-status-row" },
{ slug: "my-sciences", expectRow: "my-sciences-row" },
@@ -161,6 +162,10 @@ async function mockGateway(page: Page): Promise<void> {
shipProductions: [
{ planet: 1, class: "Cruiser", cost: 100, prodUsed: 25, percent: 0.25, free: 800 },
],
racesLeavingSoon: [
{ race: "Bajori", turnsLeft: 2 },
{ race: "Cardassian", turnsLeft: 3 },
],
});
break;
}
+66
View File
@@ -29,6 +29,7 @@ import { ByteBuffer } from "flatbuffers";
import {
GameReportRequest,
LocalPlanet,
RaceExitNotice,
Report,
ShipClass,
} from "../src/proto/galaxy/fbs/report";
@@ -124,6 +125,8 @@ function buildReportPayload(opts: {
height?: number;
planets?: PlanetFixture[];
shipClasses?: ShipClassFixture[];
personalExitWarning?: number;
racesLeavingSoon?: { race: string; turnsLeft: number }[];
}): Uint8Array {
const builder = new Builder(256);
const planetOffsets = (opts.planets ?? []).map((planet) => {
@@ -156,6 +159,17 @@ function buildReportPayload(opts: {
shipClassOffsets.length === 0
? null
: Report.createLocalShipClassVector(builder, shipClassOffsets);
const racesLeavingSoonOffsets = (opts.racesLeavingSoon ?? []).map((n) => {
const race = builder.createString(n.race);
RaceExitNotice.startRaceExitNotice(builder);
RaceExitNotice.addRace(builder, race);
RaceExitNotice.addTurnsLeft(builder, n.turnsLeft);
return RaceExitNotice.endRaceExitNotice(builder);
});
const racesLeavingSoonVec =
racesLeavingSoonOffsets.length === 0
? null
: Report.createRacesLeavingSoonVector(builder, racesLeavingSoonOffsets);
Report.startReport(builder);
Report.addTurn(builder, BigInt(opts.turn));
@@ -168,6 +182,12 @@ function buildReportPayload(opts: {
if (localShipClassVec !== null) {
Report.addLocalShipClass(builder, localShipClassVec);
}
if (opts.personalExitWarning !== undefined) {
Report.addPersonalExitWarning(builder, opts.personalExitWarning);
}
if (racesLeavingSoonVec !== null) {
Report.addRacesLeavingSoon(builder, racesLeavingSoonVec);
}
const reportOff = Report.endReport(builder);
builder.finish(reportOff);
return builder.asUint8Array();
@@ -214,6 +234,52 @@ describe("GameStateStore", () => {
store.dispose();
});
test("decodes personalExitWarning and racesLeavingSoon from the report", async () => {
listMyGamesSpy.mockResolvedValue([makeGameSummary(7)]);
const client = makeFakeClient(async () => ({
resultCode: "ok",
payloadBytes: buildReportPayload({
turn: 7,
personalExitWarning: 3,
racesLeavingSoon: [
{ race: "Bajori", turnsLeft: 2 },
{ race: "Cardassian", turnsLeft: 1 },
],
}),
}));
const store = new GameStateStore();
await store.init({ client, cache, gameId: GAME_ID });
expect(store.status).toBe("ready");
expect(store.report?.personalExitWarning).toBe(3);
expect(store.report?.racesLeavingSoon).toEqual([
{ race: "Bajori", turnsLeft: 2 },
{ race: "Cardassian", turnsLeft: 1 },
]);
store.dispose();
});
test("defaults personalExitWarning to 0 and racesLeavingSoon to [] when absent", async () => {
listMyGamesSpy.mockResolvedValue([makeGameSummary(7)]);
const client = makeFakeClient(async () => ({
resultCode: "ok",
payloadBytes: buildReportPayload({ turn: 7 }),
}));
const store = new GameStateStore();
await store.init({ client, cache, gameId: GAME_ID });
expect(store.status).toBe("ready");
expect(store.report?.personalExitWarning).toBe(0);
expect(store.report?.racesLeavingSoon).toEqual([]);
store.dispose();
});
test("init surfaces an error when the game is missing from lobby", async () => {
listMyGamesSpy.mockResolvedValue([makeGameSummary(0).gameId === "other" ? null : makeGameSummary(0)].filter(Boolean));
// Replace the helper above's awkward filter with an explicit
@@ -1,8 +1,10 @@
// EMPTY_SHIP_GROUPS supplies empty arrays / zero defaults for the
// ancillary report fields added in Phase 19 (ship-groups + fleets),
// Phase 21 (sciences), Phase 22 (races / diplomacy / voting), and
// Phase 21 (sciences), Phase 22 (races / diplomacy / voting),
// Phase 23 (full player roster, foreign sciences, foreign ship
// classes, battle ids, bombings, ships in production).
// classes, battle ids, bombings, ships in production), and the
// per-turn inactivity exit warnings (personal countdown + public
// races-leaving-soon list).
// Test fixtures spread it into their report objects so the fixture
// body still focuses on the fields under test, without forcing
// every spec to enumerate the full GameReport surface.
@@ -41,6 +43,8 @@ export const EMPTY_SHIP_GROUPS: {
battleIds: string[];
bombings: ReportBombing[];
shipProductions: ReportShipProduction[];
personalExitWarning: number;
racesLeavingSoon: { race: string; turnsLeft: number }[];
} = {
localShipGroups: [],
otherShipGroups: [],
@@ -59,4 +63,6 @@ export const EMPTY_SHIP_GROUPS: {
battleIds: [],
bombings: [],
shipProductions: [],
personalExitWarning: 0,
racesLeavingSoon: [],
};
@@ -81,6 +81,8 @@ function makeReport(
battleIds: [],
bombings: [],
shipProductions: [],
personalExitWarning: 0,
racesLeavingSoon: [],
...overrides,
};
}
@@ -0,0 +1,130 @@
// Vitest coverage for the report view's race-exit-warnings section and
// the personal exit-warning banner. The section lists other races
// within a few turns of inactivity removal and hides entirely when the
// list is empty; the banner shows the local race's own countdown only
// when it is non-zero. Both read the report through the rendered-report
// context, mirroring the other report sections.
import "@testing-library/jest-dom/vitest";
import { render } from "@testing-library/svelte";
import { beforeEach, describe, expect, test } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import type { GameReport } from "../src/api/game-state";
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
import SectionRaceExitWarnings from "../src/lib/active-view/report/section-race-exit-warnings.svelte";
import PersonalExitBanner from "../src/lib/active-view/report/personal-exit-banner.svelte";
beforeEach(() => {
i18n.resetForTests("en");
});
function makeReport(overrides: Partial<GameReport> = {}): GameReport {
return {
turn: 1,
mapWidth: 1000,
mapHeight: 1000,
planetCount: 0,
planets: [],
race: "Self",
localShipClass: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
...overrides,
};
}
function mount(
component: typeof SectionRaceExitWarnings | typeof PersonalExitBanner,
report: GameReport | null,
) {
const context = new Map<unknown, unknown>([
[
RENDERED_REPORT_CONTEXT_KEY,
{
get report() {
return report;
},
},
],
]);
return render(component, { context });
}
describe("report race-exit-warnings section", () => {
test("renders nothing before the report lands", () => {
const ui = mount(SectionRaceExitWarnings, null);
expect(
ui.queryByTestId("report-section-race-exit-warnings"),
).not.toBeInTheDocument();
});
test("hides the section entirely when no races are leaving", () => {
const ui = mount(
SectionRaceExitWarnings,
makeReport({ racesLeavingSoon: [] }),
);
expect(
ui.queryByTestId("report-section-race-exit-warnings"),
).not.toBeInTheDocument();
});
test("lists each race with its remaining turns", () => {
const ui = mount(
SectionRaceExitWarnings,
makeReport({
racesLeavingSoon: [
{ race: "Bajori", turnsLeft: 2 },
{ race: "Cardassian", turnsLeft: 1 },
],
}),
);
expect(
ui.getByTestId("report-section-race-exit-warnings"),
).toBeInTheDocument();
const rows = ui.getAllByTestId("race-exit-warnings-row");
expect(rows).toHaveLength(2);
expect(rows[0]).toHaveAttribute("data-race", "Bajori");
expect(rows[0]).toHaveTextContent("Bajori");
expect(rows[0]).toHaveTextContent("2");
expect(rows[1]).toHaveAttribute("data-race", "Cardassian");
expect(rows[1]).toHaveTextContent("Cardassian");
expect(rows[1]).toHaveTextContent("1");
});
});
describe("personal exit-warning banner", () => {
test("renders nothing before the report lands", () => {
const ui = mount(PersonalExitBanner, null);
expect(
ui.queryByTestId("report-personal-exit-banner"),
).not.toBeInTheDocument();
});
test("stays hidden when there is no personal warning", () => {
const ui = mount(
PersonalExitBanner,
makeReport({ personalExitWarning: 0 }),
);
expect(
ui.queryByTestId("report-personal-exit-banner"),
).not.toBeInTheDocument();
});
test("shows the danger banner with the countdown when warned", () => {
const ui = mount(
PersonalExitBanner,
makeReport({ personalExitWarning: 3 }),
);
const banner = ui.getByTestId("report-personal-exit-banner");
expect(banner).toBeInTheDocument();
expect(banner).toHaveAttribute("role", "alert");
expect(banner).toHaveTextContent("3");
});
});
@@ -189,6 +189,25 @@ describe("loadSyntheticReportFromJSON", () => {
expect(report.routes).toEqual([]);
});
test("defaults exit warnings to empty (legacy format has no exit data)", () => {
const { report } = loadSyntheticReportFromJSON(syntheticJSON());
expect(report.personalExitWarning).toBe(0);
expect(report.racesLeavingSoon).toEqual([]);
});
test("reads hand-authored exit warnings when present", () => {
const { report } = loadSyntheticReportFromJSON(
syntheticJSON({
personalExitWarning: 4,
racesLeavingSoon: [{ race: "Monstrai", turnsLeft: 2 }],
}),
);
expect(report.personalExitWarning).toBe(4);
expect(report.racesLeavingSoon).toEqual([
{ race: "Monstrai", turnsLeft: 2 },
]);
});
test("registers the report under the returned game id", () => {
const { gameId, report } = loadSyntheticReportFromJSON(syntheticJSON());
expect(getSyntheticReport(gameId)).toBe(report);