feat(game): race exit warnings in the turn report (#12)
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user