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
+38
View File
@@ -571,6 +571,21 @@ export interface GameReport {
* currently producing a ship.
*/
shipProductions: ReportShipProduction[];
/**
* personalExitWarning is the local race's own countdown to being
* auto-removed for inactivity, read from `Report.personal_exit_warning`.
* A value of `1..5` is a personal alarm (the player will be removed in
* that many turns unless orders are submitted); `0` means no warning.
*/
personalExitWarning: number;
/**
* racesLeavingSoon is the public list of other races within three
* turns of removal for inactivity, read from `Report.races_leaving_soon`.
* Shown to everyone. Each entry carries the race name and the number
* of turns left before that race is removed. Empty when no race is
* about to leave (or when the report predates the field).
*/
racesLeavingSoon: { race: string; turnsLeft: number }[];
}
export async function fetchGameReport(
@@ -731,6 +746,7 @@ function decodeReport(report: Report): GameReport {
const battleIds = battles.map((b) => b.id);
const bombings = decodeBombings(report);
const shipProductions = decodeShipProductions(report);
const racesLeavingSoon = decodeRacesLeavingSoon(report);
return {
turn: Number(report.turn()),
@@ -762,6 +778,8 @@ function decodeReport(report: Report): GameReport {
battleIds,
bombings,
shipProductions,
personalExitWarning: report.personalExitWarning(),
racesLeavingSoon,
};
}
@@ -939,6 +957,25 @@ function decodeUnidentifiedShipGroups(
return out;
}
/**
* decodeRacesLeavingSoon flattens `report.races_leaving_soon()[]` into
* the typed `racesLeavingSoon` array. Each `RaceExitNotice` carries the
* race name and the number of turns left before that race is removed for
* inactivity. Rows with a missing name are skipped. Empty when the
* report carries no notices.
*/
function decodeRacesLeavingSoon(
report: Report,
): { race: string; turnsLeft: number }[] {
const out: { race: string; turnsLeft: number }[] = [];
for (let i = 0; i < report.racesLeavingSoonLength(); i++) {
const n = report.racesLeavingSoon(i);
if (n === null) continue;
out.push({ race: n.race() ?? "", turnsLeft: n.turnsLeft() });
}
return out;
}
function decodeLocalFleets(report: Report): ReportLocalFleet[] {
const out: ReportLocalFleet[] = [];
for (let i = 0; i < report.localFleetLength(); i++) {
@@ -1479,6 +1516,7 @@ export function applyOrderOverlay(
battleIds: report.battleIds ?? [],
bombings: report.bombings ?? [],
shipProductions: report.shipProductions ?? [],
racesLeavingSoon: report.racesLeavingSoon ?? [],
};
}
+24
View File
@@ -9,6 +9,14 @@
// AND the Go CLI must learn to populate that field — see the
// synthetic-report parity rule in `ui/PLAN.md`.
//
// `personalExitWarning` / `racesLeavingSoon` are the exception the
// rule allows: they are runtime inactivity-countdown state the engine
// derives per turn, not anything present in a static legacy text
// report, so the legacy CLI leaves them empty (the parity rule's
// "cannot be derived from the legacy text format" escape hatch). This
// decoder still reads them defensively so a hand-authored synthetic
// JSON fixture can exercise the report's exit-warning UI.
//
// The in-memory map deliberately does not survive a page reload:
// synthetic mode is a debug affordance, not a session, and the
// layout redirects to /lobby when a synthetic id is opened with no
@@ -259,6 +267,11 @@ interface SyntheticShipProductionRow {
free?: number;
}
interface SyntheticRaceExitNotice {
race?: string;
turnsLeft?: number;
}
interface SyntheticReportRoot {
turn?: number;
mapWidth?: number;
@@ -284,6 +297,8 @@ interface SyntheticReportRoot {
battle?: SyntheticBattle[];
bombing?: SyntheticBombing[];
shipProduction?: SyntheticShipProductionRow[];
personalExitWarning?: number;
racesLeavingSoon?: SyntheticRaceExitNotice[];
}
function decodeSyntheticReport(json: unknown): GameReport {
@@ -465,6 +480,13 @@ function decodeSyntheticReport(json: unknown): GameReport {
return a.class.localeCompare(b.class);
});
const racesLeavingSoon: { race: string; turnsLeft: number }[] = (
root.racesLeavingSoon ?? []
).map((n) => ({
race: typeof n.race === "string" ? n.race : "",
turnsLeft: numOr0(n.turnsLeft),
}));
return {
turn: numOr0(root.turn),
mapWidth: numOr0(root.mapWidth),
@@ -495,6 +517,8 @@ function decodeSyntheticReport(json: unknown): GameReport {
battleIds,
bombings,
shipProductions,
personalExitWarning: numOr0(root.personalExitWarning),
racesLeavingSoon,
};
}
+10 -3
View File
@@ -2,7 +2,7 @@
Phase 23 turn-report active view.
Composes the table of contents (`report/report-toc.svelte`) and the
twenty section components that render each `GameReport` array. Each
section components that render each `GameReport` array. Each
section is its own component under `lib/active-view/report/` — the
data shapes are too varied for one generic table, and the
component-per-section seam matches Phase 23's targeted-test contract.
@@ -11,8 +11,10 @@ Active-section highlighting lands here: an `IntersectionObserver`
rooted on the viewport watches every `<section id="report-<slug>">`
and updates a local `activeSlug` rune that drives the TOC highlight.
The 20-section list lives here as a single source of truth so the
TOC and the body iterate the same data.
The section list lives here as a single source of truth so the
TOC and the body iterate the same data. One entry —
`race-exit-warnings` — renders nothing when its list is empty, so its
TOC item resolves to a no-op scroll on the rare turns it is hidden.
-->
<script lang="ts">
import { onMount } from "svelte";
@@ -20,7 +22,9 @@ TOC and the body iterate the same data.
import ReportToc, {
type TocEntry,
} from "./report/report-toc.svelte";
import PersonalExitBanner from "./report/personal-exit-banner.svelte";
import SectionGalaxySummary from "./report/section-galaxy-summary.svelte";
import SectionRaceExitWarnings from "./report/section-race-exit-warnings.svelte";
import SectionVotes from "./report/section-votes.svelte";
import SectionPlayerStatus from "./report/section-player-status.svelte";
import SectionMySciences from "./report/section-my-sciences.svelte";
@@ -43,6 +47,7 @@ TOC and the body iterate the same data.
const ENTRIES: readonly TocEntry[] = [
{ slug: "galaxy-summary", titleKey: "game.report.section.galaxy_summary.title" },
{ slug: "race-exit-warnings", titleKey: "game.report.section.race_exit_warnings.title" },
{ slug: "votes", titleKey: "game.report.section.votes.title" },
{ slug: "player-status", titleKey: "game.report.section.player_status.title" },
{ slug: "my-sciences", titleKey: "game.report.section.my_sciences.title" },
@@ -107,10 +112,12 @@ TOC and the body iterate the same data.
</script>
<div class="report-view" data-testid="active-view-report">
<PersonalExitBanner />
<ReportToc entries={ENTRIES} {activeSlug} />
<div class="report-body" bind:this={bodyEl}>
<SectionGalaxySummary />
<SectionRaceExitWarnings />
<SectionVotes />
<SectionPlayerStatus />
<SectionMySciences />
@@ -0,0 +1,64 @@
<!--
Personal exit-warning banner for the turn-report view. Mirrors the
`lib/header/history-banner.svelte` alert pattern (sticky `aside`,
design-token styling) but carries the local race's own inactivity
countdown rather than a history notice.
Renders only when `report.personalExitWarning > 0`: the engine reports
`1..5` turns remaining before the local race is auto-removed for
inactivity, `0` when there is no warning. The copy is a personal alarm
("you will be removed in N turns unless you submit orders"), distinct
from the public `section-race-exit-warnings.svelte` list of other
races leaving soon. Uses the danger token rather than the warning
token because it is the recipient's own removal that is at stake.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const turnsLeft = $derived(report?.personalExitWarning ?? 0);
</script>
{#if turnsLeft > 0}
<aside
class="exit-banner"
data-testid="report-personal-exit-banner"
role="alert"
>
<span class="message">
{i18n.t("game.report.personal_exit_warning", {
turns: String(turnsLeft),
})}
</span>
</aside>
{/if}
<style>
.exit-banner {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0 0 1.25rem;
padding: 0.6rem 0.9rem;
background: var(--color-surface);
color: var(--color-danger);
border: 1px solid var(--color-danger);
border-radius: 6px;
font-family: system-ui, sans-serif;
font-size: 0.9rem;
font-weight: 600;
}
.message {
flex: 1;
min-width: 0;
}
</style>
@@ -0,0 +1,70 @@
<!--
Report View — races leaving soon section. Surfaces the public
`racesLeavingSoon` projection: every other race within three turns of
being auto-removed for inactivity, with the number of turns each has
left. This is the public counterpart to the personal exit-warning
banner the report view renders at the top for the local race.
Unlike the other report sections, this one hides entirely when the
list is empty rather than showing an empty-state line: an absent
notice is the normal, healthy case for an active game, so a permanent
"no races leaving" row would be noise. The section's TOC entry stays
registered; clicking it while the section is hidden is a silent no-op
through the table-of-contents' existing `getElementById` guard.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.racesLeavingSoon ?? []);
</script>
{#if rows.length > 0}
<section
id="report-race-exit-warnings"
class="grid-section"
data-testid="report-section-race-exit-warnings"
>
<h2>{i18n.t("game.report.section.race_exit_warnings.title")}</h2>
<ul class="notices" data-testid="race-exit-warnings-list">
{#each rows as r (r.race)}
<li data-testid="race-exit-warnings-row" data-race={r.race}>
{i18n.t("game.report.section.race_exit_warnings.notice", {
race: r.race,
turns: String(r.turnsLeft),
})}
</li>
{/each}
</ul>
</section>
{/if}
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: var(--color-text);
}
.notices {
margin: 0;
padding-left: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.9rem;
color: var(--color-warning);
}
.notices li {
margin: 0;
}
</style>
+5
View File
@@ -657,11 +657,16 @@ const en = {
"game.report.toc.title": "sections",
"game.report.toc.open": "show section list",
"game.report.toc.close": "hide section list",
"game.report.personal_exit_warning":
"Inactivity warning: your race will be removed in {turns} turn(s) unless you submit orders.",
"game.report.section.galaxy_summary.title": "galaxy summary",
"game.report.section.galaxy_summary.field.turn": "turn",
"game.report.section.galaxy_summary.field.size": "map size",
"game.report.section.galaxy_summary.field.planets": "planet count",
"game.report.section.galaxy_summary.field.race": "your race",
"game.report.section.race_exit_warnings.title": "races leaving soon",
"game.report.section.race_exit_warnings.notice":
"{race} will be removed for inactivity in {turns} turn(s).",
"game.report.section.votes.title": "votes",
"game.report.section.votes.mine": "my votes",
"game.report.section.votes.target": "I vote for",
+5
View File
@@ -658,11 +658,16 @@ const ru: Record<keyof typeof en, string> = {
"game.report.toc.title": "разделы",
"game.report.toc.open": "показать список разделов",
"game.report.toc.close": "скрыть список разделов",
"game.report.personal_exit_warning":
"Предупреждение о неактивности: ваша раса будет удалена через {turns} ход(ов), если вы не отправите приказы.",
"game.report.section.galaxy_summary.title": "общие сведения о галактике",
"game.report.section.galaxy_summary.field.turn": "ход",
"game.report.section.galaxy_summary.field.size": "размер карты",
"game.report.section.galaxy_summary.field.planets": "всего планет",
"game.report.section.galaxy_summary.field.race": "ваша раса",
"game.report.section.race_exit_warnings.title": "расы скоро покинут игру",
"game.report.section.race_exit_warnings.notice":
"{race} будет удалена за неактивность через {turns} ход(ов).",
"game.report.section.votes.title": "голоса",
"game.report.section.votes.mine": "мои голоса",
"game.report.section.votes.target": "голосую за",
@@ -14,6 +14,7 @@ export { OtherPlanet, OtherPlanetT } from './report/other-planet.js';
export { OtherScience, OtherScienceT } from './report/other-science.js';
export { OthersShipClass, OthersShipClassT } from './report/others-ship-class.js';
export { Player, PlayerT } from './report/player.js';
export { RaceExitNotice, RaceExitNoticeT } from './report/race-exit-notice.js';
export { Report, ReportT } from './report/report.js';
export { Route, RouteT } from './report/route.js';
export { RouteEntry, RouteEntryT } from './report/route-entry.js';
@@ -0,0 +1,92 @@
// automatically generated by the FlatBuffers compiler, do not modify
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
import * as flatbuffers from 'flatbuffers';
export class RaceExitNotice implements flatbuffers.IUnpackableObject<RaceExitNoticeT> {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):RaceExitNotice {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsRaceExitNotice(bb:flatbuffers.ByteBuffer, obj?:RaceExitNotice):RaceExitNotice {
return (obj || new RaceExitNotice()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsRaceExitNotice(bb:flatbuffers.ByteBuffer, obj?:RaceExitNotice):RaceExitNotice {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new RaceExitNotice()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
race():string|null
race(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
race(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
turnsLeft():number {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readUint32(this.bb_pos + offset) : 0;
}
static startRaceExitNotice(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addRace(builder:flatbuffers.Builder, raceOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, raceOffset, 0);
}
static addTurnsLeft(builder:flatbuffers.Builder, turnsLeft:number) {
builder.addFieldInt32(1, turnsLeft, 0);
}
static endRaceExitNotice(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createRaceExitNotice(builder:flatbuffers.Builder, raceOffset:flatbuffers.Offset, turnsLeft:number):flatbuffers.Offset {
RaceExitNotice.startRaceExitNotice(builder);
RaceExitNotice.addRace(builder, raceOffset);
RaceExitNotice.addTurnsLeft(builder, turnsLeft);
return RaceExitNotice.endRaceExitNotice(builder);
}
unpack(): RaceExitNoticeT {
return new RaceExitNoticeT(
this.race(),
this.turnsLeft()
);
}
unpackTo(_o: RaceExitNoticeT): void {
_o.race = this.race();
_o.turnsLeft = this.turnsLeft();
}
}
export class RaceExitNoticeT implements flatbuffers.IGeneratedObject {
constructor(
public race: string|Uint8Array|null = null,
public turnsLeft: number = 0
){}
pack(builder:flatbuffers.Builder): flatbuffers.Offset {
const race = (this.race !== null ? builder.createString(this.race!) : 0);
return RaceExitNotice.createRaceExitNotice(builder,
race,
this.turnsLeft
);
}
}
@@ -15,6 +15,7 @@ import { OtherPlanet, OtherPlanetT } from '../report/other-planet.js';
import { OtherScience, OtherScienceT } from '../report/other-science.js';
import { OthersShipClass, OthersShipClassT } from '../report/others-ship-class.js';
import { Player, PlayerT } from '../report/player.js';
import { RaceExitNotice, RaceExitNoticeT } from '../report/race-exit-notice.js';
import { Route, RouteT } from '../report/route.js';
import { Science, ScienceT } from '../report/science.js';
import { ShipClass, ShipClassT } from '../report/ship-class.js';
@@ -266,8 +267,23 @@ unidentifiedGroupLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
personalExitWarning():number {
const offset = this.bb!.__offset(this.bb_pos, 56);
return offset ? this.bb!.readUint32(this.bb_pos + offset) : 0;
}
racesLeavingSoon(index: number, obj?:RaceExitNotice):RaceExitNotice|null {
const offset = this.bb!.__offset(this.bb_pos, 58);
return offset ? (obj || new RaceExitNotice()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
racesLeavingSoonLength():number {
const offset = this.bb!.__offset(this.bb_pos, 58);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startReport(builder:flatbuffers.Builder) {
builder.startObject(26);
builder.startObject(28);
}
static addVersion(builder:flatbuffers.Builder, version:bigint) {
@@ -590,6 +606,26 @@ static startUnidentifiedGroupVector(builder:flatbuffers.Builder, numElems:number
builder.startVector(4, numElems, 4);
}
static addPersonalExitWarning(builder:flatbuffers.Builder, personalExitWarning:number) {
builder.addFieldInt32(26, personalExitWarning, 0);
}
static addRacesLeavingSoon(builder:flatbuffers.Builder, racesLeavingSoonOffset:flatbuffers.Offset) {
builder.addFieldOffset(27, racesLeavingSoonOffset, 0);
}
static createRacesLeavingSoonVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startRacesLeavingSoonVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endReport(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
@@ -603,7 +639,7 @@ static finishSizePrefixedReportBuffer(builder:flatbuffers.Builder, offset:flatbu
builder.finish(offset, undefined, true);
}
static createReport(builder:flatbuffers.Builder, version:bigint, turn:bigint, width:number, height:number, planetCount:number, raceOffset:flatbuffers.Offset, votes:number, voteForOffset:flatbuffers.Offset, playerOffset:flatbuffers.Offset, localScienceOffset:flatbuffers.Offset, otherScienceOffset:flatbuffers.Offset, localShipClassOffset:flatbuffers.Offset, otherShipClassOffset:flatbuffers.Offset, battleOffset:flatbuffers.Offset, bombingOffset:flatbuffers.Offset, incomingGroupOffset:flatbuffers.Offset, localPlanetOffset:flatbuffers.Offset, shipProductionOffset:flatbuffers.Offset, routeOffset:flatbuffers.Offset, otherPlanetOffset:flatbuffers.Offset, uninhabitedPlanetOffset:flatbuffers.Offset, unidentifiedPlanetOffset:flatbuffers.Offset, localFleetOffset:flatbuffers.Offset, localGroupOffset:flatbuffers.Offset, otherGroupOffset:flatbuffers.Offset, unidentifiedGroupOffset:flatbuffers.Offset):flatbuffers.Offset {
static createReport(builder:flatbuffers.Builder, version:bigint, turn:bigint, width:number, height:number, planetCount:number, raceOffset:flatbuffers.Offset, votes:number, voteForOffset:flatbuffers.Offset, playerOffset:flatbuffers.Offset, localScienceOffset:flatbuffers.Offset, otherScienceOffset:flatbuffers.Offset, localShipClassOffset:flatbuffers.Offset, otherShipClassOffset:flatbuffers.Offset, battleOffset:flatbuffers.Offset, bombingOffset:flatbuffers.Offset, incomingGroupOffset:flatbuffers.Offset, localPlanetOffset:flatbuffers.Offset, shipProductionOffset:flatbuffers.Offset, routeOffset:flatbuffers.Offset, otherPlanetOffset:flatbuffers.Offset, uninhabitedPlanetOffset:flatbuffers.Offset, unidentifiedPlanetOffset:flatbuffers.Offset, localFleetOffset:flatbuffers.Offset, localGroupOffset:flatbuffers.Offset, otherGroupOffset:flatbuffers.Offset, unidentifiedGroupOffset:flatbuffers.Offset, personalExitWarning:number, racesLeavingSoonOffset:flatbuffers.Offset):flatbuffers.Offset {
Report.startReport(builder);
Report.addVersion(builder, version);
Report.addTurn(builder, turn);
@@ -631,6 +667,8 @@ static createReport(builder:flatbuffers.Builder, version:bigint, turn:bigint, wi
Report.addLocalGroup(builder, localGroupOffset);
Report.addOtherGroup(builder, otherGroupOffset);
Report.addUnidentifiedGroup(builder, unidentifiedGroupOffset);
Report.addPersonalExitWarning(builder, personalExitWarning);
Report.addRacesLeavingSoon(builder, racesLeavingSoonOffset);
return Report.endReport(builder);
}
@@ -661,7 +699,9 @@ unpack(): ReportT {
this.bb!.createObjList<LocalFleet, LocalFleetT>(this.localFleet.bind(this), this.localFleetLength()),
this.bb!.createObjList<LocalGroup, LocalGroupT>(this.localGroup.bind(this), this.localGroupLength()),
this.bb!.createObjList<OtherGroup, OtherGroupT>(this.otherGroup.bind(this), this.otherGroupLength()),
this.bb!.createObjList<UnidentifiedGroup, UnidentifiedGroupT>(this.unidentifiedGroup.bind(this), this.unidentifiedGroupLength())
this.bb!.createObjList<UnidentifiedGroup, UnidentifiedGroupT>(this.unidentifiedGroup.bind(this), this.unidentifiedGroupLength()),
this.personalExitWarning(),
this.bb!.createObjList<RaceExitNotice, RaceExitNoticeT>(this.racesLeavingSoon.bind(this), this.racesLeavingSoonLength())
);
}
@@ -693,6 +733,8 @@ unpackTo(_o: ReportT): void {
_o.localGroup = this.bb!.createObjList<LocalGroup, LocalGroupT>(this.localGroup.bind(this), this.localGroupLength());
_o.otherGroup = this.bb!.createObjList<OtherGroup, OtherGroupT>(this.otherGroup.bind(this), this.otherGroupLength());
_o.unidentifiedGroup = this.bb!.createObjList<UnidentifiedGroup, UnidentifiedGroupT>(this.unidentifiedGroup.bind(this), this.unidentifiedGroupLength());
_o.personalExitWarning = this.personalExitWarning();
_o.racesLeavingSoon = this.bb!.createObjList<RaceExitNotice, RaceExitNoticeT>(this.racesLeavingSoon.bind(this), this.racesLeavingSoonLength());
}
}
@@ -723,7 +765,9 @@ constructor(
public localFleet: (LocalFleetT)[] = [],
public localGroup: (LocalGroupT)[] = [],
public otherGroup: (OtherGroupT)[] = [],
public unidentifiedGroup: (UnidentifiedGroupT)[] = []
public unidentifiedGroup: (UnidentifiedGroupT)[] = [],
public personalExitWarning: number = 0,
public racesLeavingSoon: (RaceExitNoticeT)[] = []
){}
@@ -748,6 +792,7 @@ pack(builder:flatbuffers.Builder): flatbuffers.Offset {
const localGroup = Report.createLocalGroupVector(builder, builder.createObjectOffsetList(this.localGroup));
const otherGroup = Report.createOtherGroupVector(builder, builder.createObjectOffsetList(this.otherGroup));
const unidentifiedGroup = Report.createUnidentifiedGroupVector(builder, builder.createObjectOffsetList(this.unidentifiedGroup));
const racesLeavingSoon = Report.createRacesLeavingSoonVector(builder, builder.createObjectOffsetList(this.racesLeavingSoon));
return Report.createReport(builder,
this.version,
@@ -775,7 +820,9 @@ pack(builder:flatbuffers.Builder): flatbuffers.Offset {
localFleet,
localGroup,
otherGroup,
unidentifiedGroup
unidentifiedGroup,
this.personalExitWarning,
racesLeavingSoon
);
}
}
@@ -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);