feat(game): race exit warnings in the turn report (#12) #82

Merged
developer merged 1 commits from feature/game-race-exit-warnings into development 2026-05-31 09:12:34 +00:00
28 changed files with 908 additions and 22 deletions
Showing only changes of commit 9e9977d5f1 - Show all commits
+12 -5
View File
@@ -704,11 +704,18 @@ demand. Backend authorises the caller and forwards the request;
there is no caching or denormalisation in this path.
The web client renders the report as one section per FBS array
(galaxy summary, votes, player status, my / foreign sciences, my /
foreign ship classes, battles, bombings, approaching groups, my /
foreign / uninhabited / unknown planets, ships in production,
cargo routes, my fleets, my / foreign / unidentified ship groups).
Empty sections render explicit empty-state copy. Section
(galaxy summary, races leaving soon, votes, player status, my /
foreign sciences, my / foreign ship classes, battles, bombings,
approaching groups, my / foreign / uninhabited / unknown planets,
ships in production, cargo routes, my fleets, my / foreign /
unidentified ship groups). Empty sections render explicit
empty-state copy; "races leaving soon" is the exception and hides
entirely when no race is near removal. When the local race is
itself within five turns of being auto-removed for inactivity, a
danger-styled personal warning banner above the section list
carries its own turns-remaining countdown; the public "races
leaving soon" section lists every other race within three turns
of removal. Section
navigation is exposed through a sticky icon-popup menu pinned to
the top-right of the report column (an anchored popover on desktop
and a fixed bottom-sheet on mobile); the trigger label tracks the
+14 -6
View File
@@ -722,12 +722,20 @@ Backend авторизует вызывающего и форвардит зап
нет ни кэширования, ни денормализации.
Web-клиент рендерит отчёт как одну секцию на каждый FBS-массив
(общие сведения, голоса, статус игроков, мои / чужие науки, мои /
чужие классы кораблей, сражения, бомбардировки, приближающиеся
группы, мои / чужие / необитаемые / неопознанные планеты, корабли в
производстве, грузовые маршруты, мои флоты, мои / чужие /
неопознанные группы кораблей). Пустые секции получают явную копию
empty-state. Навигация по секциям — sticky icon-popup в правом
(общие сведения, скоро покидающие игру расы, голоса, статус
игроков, мои / чужие науки, мои / чужие классы кораблей, сражения,
бомбардировки, приближающиеся группы, мои / чужие / необитаемые /
неопознанные планеты, корабли в производстве, грузовые маршруты,
мои флоты, мои / чужие / неопознанные группы кораблей). Пустые
секции получают явную копию empty-state; исключение — секция
«скоро покидающие игру расы»: она полностью скрывается, когда ни
одна раса не близка к исключению. Если же близка к исключению за
неактивность сама локальная раса (осталось не более пяти ходов),
над списком секций показывается персональный
баннер-предупреждение (стиль danger) с числом оставшихся ходов;
публичная секция «скоро покидающие игру расы» перечисляет все
прочие расы, до исключения которых осталось не более трёх ходов.
Навигация по секциям — sticky icon-popup в правом
верхнем углу колонки отчёта (анкорный popover на десктопе и фикс.
bottom-sheet на мобильном); подпись на кнопке отслеживает раздел,
который сейчас в зоне видимости, выбор пункта меню — скролл к
+33
View File
@@ -129,6 +129,9 @@ func (c *Cache) ReportRace(ri int, rep *mr.Report, battles []*mr.BattleReport, b
rep.Player[i].Relation = "-"
}
// race exit warnings
c.ReportExitWarnings(ri, rep)
// sciences
c.ReportLocalScience(ri, rep)
c.ReportOtherScience(ri, rep)
@@ -177,6 +180,36 @@ func (c *Cache) ReportRace(ri int, rep *mr.Report, battles []*mr.BattleReport, b
c.ReportUnidentifiedGroup(ri, rep)
}
// ReportExitWarnings fills the inactivity-removal warnings. A race's TTL at
// report time equals the number of turns remaining before it is auto-removed
// (it is wiped at the start of turn T+TTL). The recipient gets a personal
// countdown once it is 5 turns out (rep.PersonalExitWarning); every other
// non-extinct race within 3 turns of removal is listed publicly
// (rep.RacesLeavingSoon). Voluntary quit and idle timeout share the TTL
// countdown and are intentionally not distinguished here.
func (c *Cache) ReportExitWarnings(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
rep.PersonalExitWarning = 0
if ttl := c.g.Race[ri].TTL; ttl > 0 && ttl <= 5 {
rep.PersonalExitWarning = ttl
}
rep.RacesLeavingSoon = rep.RacesLeavingSoon[:0]
for i := range c.g.Race {
r := &c.g.Race[i]
if i == ri || r.Extinct {
continue
}
if r.TTL > 0 && r.TTL <= 3 {
rep.RacesLeavingSoon = append(rep.RacesLeavingSoon, mr.RaceExitNotice{
Race: r.Name,
TurnsLeft: r.TTL,
})
}
}
}
func (c *Cache) ReportLocalScience(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
+31
View File
@@ -196,3 +196,34 @@ func TestReportIncomingGroupRemainingDistance(t *testing.T) {
// route would be sqrt(2) ≈ 1.414.
assert.InDelta(t, 5.657, rep.IncomingGroup[0].Distance.F(), 0.01)
}
// TestReportExitWarnings checks the inactivity-removal warnings: the recipient
// gets a personal countdown only at TTL 1..5, other non-extinct races within 3
// turns are listed publicly, the recipient is excluded from its own public
// list, and extinct races never appear.
func TestReportExitWarnings(t *testing.T) {
c, _ := newCache()
c.Race(Race_0_idx).TTL = 5
c.Race(Race_1_idx).TTL = 2
c.Race(2).TTL = 2 // Race_Extinct: extinct, must never appear publicly
// Race_0's report: personal countdown 5; only Race_1 (TTL 2) is public.
r0 := &report.Report{}
c.ReportExitWarnings(Race_0_idx, r0)
assert.Equal(t, uint(5), r0.PersonalExitWarning)
assert.Len(t, r0.RacesLeavingSoon, 1)
assert.Equal(t, Race_1.Name, r0.RacesLeavingSoon[0].Race)
assert.Equal(t, uint(2), r0.RacesLeavingSoon[0].TurnsLeft)
// Race_1's report: personal countdown 2; Race_0 (TTL 5 > 3) is not public.
r1 := &report.Report{}
c.ReportExitWarnings(Race_1_idx, r1)
assert.Equal(t, uint(2), r1.PersonalExitWarning)
assert.Empty(t, r1.RacesLeavingSoon)
// TTL above the 5-turn window → no personal warning.
c.Race(Race_0_idx).TTL = 6
r0b := &report.Report{}
c.ReportExitWarnings(Race_0_idx, r0b)
assert.Zero(t, r0b.PersonalExitWarning)
}
+8
View File
@@ -1152,6 +1152,14 @@ Freighter загруженный 15 ед. груза при технологии
- Размер галактики, количество планет галактики и количество оставшихся рас.
- Предупреждение о скором исключении Вашей расы из игры за неактивность,
если до удаления осталось не более 5 ходов: указывается количество
оставшихся ходов (механизм описан в разделе "Выход из игры").
- Расы, покидающие игру в ближайшее время: расы, до принудительного
исключения которых за неактивность осталось не более 3 ходов; этот
список виден всем участникам.
- Ваше общее количество голосов.
- Имя расы, которой Вы отдаете свои голоса.
+14
View File
@@ -47,6 +47,13 @@ type Report struct {
OtherGroup []OtherGroup `json:"otherGroup,omitempty"`
UnidentifiedGroup []UnidentifiedGroup `json:"unidentifiedGroup,omitempty"`
// Race exit warnings. PersonalExitWarning is the recipient race's own
// number of turns remaining before auto-removal for inactivity (set when
// it is 1..5, otherwise 0). RacesLeavingSoon lists other races within 3
// turns of removal and is shown to every recipient.
PersonalExitWarning uint `json:"personalExitWarning,omitempty"`
RacesLeavingSoon []RaceExitNotice `json:"racesLeavingSoon,omitempty"`
OnPlanetGroupCache map[uint][]int `json:"-"`
InSpaceGroupRangeCache map[int]map[uint]float64 `json:"-"`
}
@@ -71,6 +78,13 @@ type Player struct {
Extinct bool `json:"extinct"`
}
// RaceExitNotice is a public notice that a race is within a few turns of being
// auto-removed for inactivity; TurnsLeft is the number of turns until removal.
type RaceExitNotice struct {
Race string `json:"race"`
TurnsLeft uint `json:"turnsLeft"`
}
func (r Report) MarshalBinary() (data []byte, err error) {
return json.Marshal(&r)
}
+9
View File
@@ -209,6 +209,13 @@ table BattleSummary {
shots:uint64;
}
// RaceExitNotice is a public notice that a race is within a few turns of being
// auto-removed for inactivity; turns_left is the number of turns until removal.
table RaceExitNotice {
race:string;
turns_left:uint32;
}
table Report {
version:uint64;
turn:uint64;
@@ -236,6 +243,8 @@ table Report {
local_group:[LocalGroup];
other_group:[OtherGroup];
unidentified_group:[UnidentifiedGroup];
personal_exit_warning:uint32 = 0;
races_leaving_soon:[RaceExitNotice];
}
// GameReportRequest is the signed-gRPC request payload for
+75
View File
@@ -0,0 +1,75 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package report
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type RaceExitNotice struct {
_tab flatbuffers.Table
}
func GetRootAsRaceExitNotice(buf []byte, offset flatbuffers.UOffsetT) *RaceExitNotice {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &RaceExitNotice{}
x.Init(buf, n+offset)
return x
}
func FinishRaceExitNoticeBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsRaceExitNotice(buf []byte, offset flatbuffers.UOffsetT) *RaceExitNotice {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &RaceExitNotice{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedRaceExitNoticeBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *RaceExitNotice) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *RaceExitNotice) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *RaceExitNotice) Race() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *RaceExitNotice) TurnsLeft() uint32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetUint32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *RaceExitNotice) MutateTurnsLeft(n uint32) bool {
return rcv._tab.MutateUint32Slot(6, n)
}
func RaceExitNoticeStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func RaceExitNoticeAddRace(builder *flatbuffers.Builder, race flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(race), 0)
}
func RaceExitNoticeAddTurnsLeft(builder *flatbuffers.Builder, turnsLeft uint32) {
builder.PrependUint32Slot(1, turnsLeft, 0)
}
func RaceExitNoticeEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+42 -1
View File
@@ -489,8 +489,40 @@ func (rcv *Report) UnidentifiedGroupLength() int {
return 0
}
func (rcv *Report) PersonalExitWarning() uint32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(56))
if o != 0 {
return rcv._tab.GetUint32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *Report) MutatePersonalExitWarning(n uint32) bool {
return rcv._tab.MutateUint32Slot(56, n)
}
func (rcv *Report) RacesLeavingSoon(obj *RaceExitNotice, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(58))
if o != 0 {
x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x)
return true
}
return false
}
func (rcv *Report) RacesLeavingSoonLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(58))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func ReportStart(builder *flatbuffers.Builder) {
builder.StartObject(26)
builder.StartObject(28)
}
func ReportAddVersion(builder *flatbuffers.Builder, version uint64) {
builder.PrependUint64Slot(0, version, 0)
@@ -624,6 +656,15 @@ func ReportAddUnidentifiedGroup(builder *flatbuffers.Builder, unidentifiedGroup
func ReportStartUnidentifiedGroupVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func ReportAddPersonalExitWarning(builder *flatbuffers.Builder, personalExitWarning uint32) {
builder.PrependUint32Slot(26, personalExitWarning, 0)
}
func ReportAddRacesLeavingSoon(builder *flatbuffers.Builder, racesLeavingSoon flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(27, flatbuffers.UOffsetT(racesLeavingSoon), 0)
}
func ReportStartRacesLeavingSoonVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func ReportEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+50
View File
@@ -114,6 +114,11 @@ func ReportToPayload(report *model.Report) ([]byte, error) {
unidentifiedGroupOffsets[i] = encodeReportUnidentifiedGroup(builder, &report.UnidentifiedGroup[i])
}
racesLeavingSoonOffsets := make([]flatbuffers.UOffsetT, len(report.RacesLeavingSoon))
for i := range report.RacesLeavingSoon {
racesLeavingSoonOffsets[i] = encodeReportRaceExitNotice(builder, &report.RacesLeavingSoon[i])
}
playerVector := encodeReportOffsetVector(builder, len(playerOffsets), fbs.ReportStartPlayerVector, playerOffsets)
localScienceVector := encodeReportOffsetVector(builder, len(localScienceOffsets), fbs.ReportStartLocalScienceVector, localScienceOffsets)
otherScienceVector := encodeReportOffsetVector(builder, len(otherScienceOffsets), fbs.ReportStartOtherScienceVector, otherScienceOffsets)
@@ -132,6 +137,7 @@ func ReportToPayload(report *model.Report) ([]byte, error) {
localGroupVector := encodeReportOffsetVector(builder, len(localGroupOffsets), fbs.ReportStartLocalGroupVector, localGroupOffsets)
otherGroupVector := encodeReportOffsetVector(builder, len(otherGroupOffsets), fbs.ReportStartOtherGroupVector, otherGroupOffsets)
unidentifiedGroupVector := encodeReportOffsetVector(builder, len(unidentifiedGroupOffsets), fbs.ReportStartUnidentifiedGroupVector, unidentifiedGroupOffsets)
racesLeavingSoonVector := encodeReportOffsetVector(builder, len(racesLeavingSoonOffsets), fbs.ReportStartRacesLeavingSoonVector, racesLeavingSoonOffsets)
fbs.ReportStart(builder)
fbs.ReportAddVersion(builder, uint64(report.Version))
@@ -196,6 +202,10 @@ func ReportToPayload(report *model.Report) ([]byte, error) {
if len(unidentifiedGroupOffsets) > 0 {
fbs.ReportAddUnidentifiedGroup(builder, unidentifiedGroupVector)
}
fbs.ReportAddPersonalExitWarning(builder, uint32(report.PersonalExitWarning))
if len(racesLeavingSoonOffsets) > 0 {
fbs.ReportAddRacesLeavingSoon(builder, racesLeavingSoonVector)
}
reportOffset := fbs.ReportEnd(builder)
fbs.FinishReportBuffer(builder, reportOffset)
@@ -238,6 +248,8 @@ func PayloadToReport(data []byte) (result *model.Report, err error) {
Race: string(flatReport.Race()),
Votes: reportFloatFromFBS(flatReport.Votes()),
VoteFor: string(flatReport.VoteFor()),
PersonalExitWarning: uint(flatReport.PersonalExitWarning()),
}
if err := decodeReportPlayerVector(flatReport, result); err != nil {
@@ -294,6 +306,9 @@ func PayloadToReport(data []byte) (result *model.Report, err error) {
if err := decodeReportUnidentifiedGroupVector(flatReport, result); err != nil {
return nil, err
}
if err := decodeReportRacesLeavingSoonVector(flatReport, result); err != nil {
return nil, err
}
return result, nil
}
@@ -583,6 +598,14 @@ func encodeReportUnidentifiedGroup(builder *flatbuffers.Builder, group *model.Un
return fbs.UnidentifiedGroupEnd(builder)
}
func encodeReportRaceExitNotice(builder *flatbuffers.Builder, notice *model.RaceExitNotice) flatbuffers.UOffsetT {
race := builder.CreateString(notice.Race)
fbs.RaceExitNoticeStart(builder)
fbs.RaceExitNoticeAddRace(builder, race)
fbs.RaceExitNoticeAddTurnsLeft(builder, uint32(notice.TurnsLeft))
return fbs.RaceExitNoticeEnd(builder)
}
func decodeReportPlayerVector(flatReport *fbs.Report, result *model.Report) error {
length := flatReport.PlayerLength()
if length == 0 {
@@ -1244,6 +1267,33 @@ func decodeReportUnidentifiedGroupVector(flatReport *fbs.Report, result *model.R
return nil
}
func decodeReportRacesLeavingSoonVector(flatReport *fbs.Report, result *model.Report) error {
length := flatReport.RacesLeavingSoonLength()
if length == 0 {
return nil
}
result.RacesLeavingSoon = make([]model.RaceExitNotice, length)
item := new(fbs.RaceExitNotice)
for i := 0; i < length; i++ {
if !flatReport.RacesLeavingSoon(item, i) {
return fmt.Errorf("decode report races leaving soon %d: notice is missing", i)
}
turnsLeft, err := uint64ToUint(uint64(item.TurnsLeft()), "turnsLeft")
if err != nil {
return fmt.Errorf("decode report races leaving soon %d: %w", i, err)
}
result.RacesLeavingSoon[i] = model.RaceExitNotice{
Race: string(item.Race()),
TurnsLeft: turnsLeft,
}
}
return nil
}
func decodeReportRouteMap(flatRoute *fbs.Route, routeIndex int) (map[uint]string, error) {
length := flatRoute.RouteLength()
if length == 0 {
+5
View File
@@ -365,6 +365,11 @@ func sampleReport() *model.Report {
UnidentifiedGroup: []model.UnidentifiedGroup{
{X: model.Float(10.0), Y: model.Float(11.0)},
},
PersonalExitWarning: 4,
RacesLeavingSoon: []model.RaceExitNotice{
{Race: "Martians", TurnsLeft: 2},
{Race: "Klingons", TurnsLeft: 1},
},
OnPlanetGroupCache: map[uint][]int{
1: {2, 3},
},
+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);