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
+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