From 9e9977d5f126bb2dc2fd7da57d55fbc997b5105d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 31 May 2026 10:34:50 +0200 Subject: [PATCH] feat(game): race exit warnings in the turn report (#12) Surface the inactivity-removal countdown the rules promise but the engine never reported. A race within five turns of being auto-removed for inactivity gets a personal warning in its own report; every race within three turns is listed publicly to all participants. - model: Report.PersonalExitWarning + RacesLeavingSoon ([]RaceExitNotice) - fbs: RaceExitNotice table + Report.personal_exit_warning / races_leaving_soon (regenerated Go + TS bindings) - transcoder: encode/decode both fields - engine: ReportExitWarnings fills the recipient's TTL (1..5) and lists other non-extinct races with TTL 1..3, excluding the recipient itself - ui: danger-styled personal banner + "races leaving soon" section (hidden when empty), wired into the report view, EN/RU i18n - docs: rules.txt report-section list, FUNCTIONAL.md 6.4 + RU mirror Voluntary quit and idle timeout share the TTL countdown and are not distinguished, per the agreed scope. --- docs/FUNCTIONAL.md | 17 ++- docs/FUNCTIONAL_ru.md | 20 ++- game/internal/controller/report.go | 33 +++++ game/internal/controller/report_test.go | 31 +++++ game/rules.txt | 8 ++ pkg/model/report/report.go | 14 ++ pkg/schema/fbs/report.fbs | 9 ++ pkg/schema/fbs/report/RaceExitNotice.go | 75 ++++++++++ pkg/schema/fbs/report/Report.go | 43 +++++- pkg/transcoder/report.go | 50 +++++++ pkg/transcoder/report_test.go | 5 + ui/frontend/src/api/game-state.ts | 38 +++++ ui/frontend/src/api/synthetic-report.ts | 24 ++++ ui/frontend/src/lib/active-view/report.svelte | 13 +- .../report/personal-exit-banner.svelte | 64 +++++++++ .../report/section-race-exit-warnings.svelte | 70 ++++++++++ ui/frontend/src/lib/i18n/locales/en.ts | 5 + ui/frontend/src/lib/i18n/locales/ru.ts | 5 + ui/frontend/src/proto/galaxy/fbs/report.ts | 1 + .../galaxy/fbs/report/race-exit-notice.ts | 92 +++++++++++++ .../src/proto/galaxy/fbs/report/report.ts | 57 +++++++- ui/frontend/tests/e2e/fixtures/report-fbs.ts | 24 ++++ ui/frontend/tests/e2e/report-sections.spec.ts | 5 + ui/frontend/tests/game-state.test.ts | 66 +++++++++ .../tests/helpers/empty-ship-groups.ts | 10 +- ui/frontend/tests/pending-send-routes.test.ts | 2 + .../report-section-race-exit-warnings.test.ts | 130 ++++++++++++++++++ ui/frontend/tests/synthetic-report.test.ts | 19 +++ 28 files changed, 908 insertions(+), 22 deletions(-) create mode 100644 pkg/schema/fbs/report/RaceExitNotice.go create mode 100644 ui/frontend/src/lib/active-view/report/personal-exit-banner.svelte create mode 100644 ui/frontend/src/lib/active-view/report/section-race-exit-warnings.svelte create mode 100644 ui/frontend/src/proto/galaxy/fbs/report/race-exit-notice.ts create mode 100644 ui/frontend/tests/report-section-race-exit-warnings.test.ts diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 26a33b0..6eace12 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index a732dcd..756d1e4 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -722,12 +722,20 @@ Backend авторизует вызывающего и форвардит зап нет ни кэширования, ни денормализации. Web-клиент рендерит отчёт как одну секцию на каждый FBS-массив -(общие сведения, голоса, статус игроков, мои / чужие науки, мои / -чужие классы кораблей, сражения, бомбардировки, приближающиеся -группы, мои / чужие / необитаемые / неопознанные планеты, корабли в -производстве, грузовые маршруты, мои флоты, мои / чужие / -неопознанные группы кораблей). Пустые секции получают явную копию -empty-state. Навигация по секциям — sticky icon-popup в правом +(общие сведения, скоро покидающие игру расы, голоса, статус +игроков, мои / чужие науки, мои / чужие классы кораблей, сражения, +бомбардировки, приближающиеся группы, мои / чужие / необитаемые / +неопознанные планеты, корабли в производстве, грузовые маршруты, +мои флоты, мои / чужие / неопознанные группы кораблей). Пустые +секции получают явную копию empty-state; исключение — секция +«скоро покидающие игру расы»: она полностью скрывается, когда ни +одна раса не близка к исключению. Если же близка к исключению за +неактивность сама локальная раса (осталось не более пяти ходов), +над списком секций показывается персональный +баннер-предупреждение (стиль danger) с числом оставшихся ходов; +публичная секция «скоро покидающие игру расы» перечисляет все +прочие расы, до исключения которых осталось не более трёх ходов. +Навигация по секциям — sticky icon-popup в правом верхнем углу колонки отчёта (анкорный popover на десктопе и фикс. bottom-sheet на мобильном); подпись на кнопке отслеживает раздел, который сейчас в зоне видимости, выбор пункта меню — скролл к diff --git a/game/internal/controller/report.go b/game/internal/controller/report.go index 56b6a73..0572004 100644 --- a/game/internal/controller/report.go +++ b/game/internal/controller/report.go @@ -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] diff --git a/game/internal/controller/report_test.go b/game/internal/controller/report_test.go index a0c7de2..4adb6fa 100644 --- a/game/internal/controller/report_test.go +++ b/game/internal/controller/report_test.go @@ -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) +} diff --git a/game/rules.txt b/game/rules.txt index 34712e5..5782c47 100644 --- a/game/rules.txt +++ b/game/rules.txt @@ -1152,6 +1152,14 @@ Freighter загруженный 15 ед. груза при технологии - Размер галактики, количество планет галактики и количество оставшихся рас. +- Предупреждение о скором исключении Вашей расы из игры за неактивность, + если до удаления осталось не более 5 ходов: указывается количество + оставшихся ходов (механизм описан в разделе "Выход из игры"). + +- Расы, покидающие игру в ближайшее время: расы, до принудительного + исключения которых за неактивность осталось не более 3 ходов; этот + список виден всем участникам. + - Ваше общее количество голосов. - Имя расы, которой Вы отдаете свои голоса. diff --git a/pkg/model/report/report.go b/pkg/model/report/report.go index fd50e45..fa15579 100644 --- a/pkg/model/report/report.go +++ b/pkg/model/report/report.go @@ -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) } diff --git a/pkg/schema/fbs/report.fbs b/pkg/schema/fbs/report.fbs index 23cae10..97f7b9b 100644 --- a/pkg/schema/fbs/report.fbs +++ b/pkg/schema/fbs/report.fbs @@ -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 diff --git a/pkg/schema/fbs/report/RaceExitNotice.go b/pkg/schema/fbs/report/RaceExitNotice.go new file mode 100644 index 0000000..2c7efe8 --- /dev/null +++ b/pkg/schema/fbs/report/RaceExitNotice.go @@ -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() +} diff --git a/pkg/schema/fbs/report/Report.go b/pkg/schema/fbs/report/Report.go index 3914ba9..142e383 100644 --- a/pkg/schema/fbs/report/Report.go +++ b/pkg/schema/fbs/report/Report.go @@ -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() } diff --git a/pkg/transcoder/report.go b/pkg/transcoder/report.go index df8b622..f11d7ba 100644 --- a/pkg/transcoder/report.go +++ b/pkg/transcoder/report.go @@ -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 { diff --git a/pkg/transcoder/report_test.go b/pkg/transcoder/report_test.go index 2aeb7ec..37c0b5d 100644 --- a/pkg/transcoder/report_test.go +++ b/pkg/transcoder/report_test.go @@ -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}, }, diff --git a/ui/frontend/src/api/game-state.ts b/ui/frontend/src/api/game-state.ts index b81a34b..bfce826 100644 --- a/ui/frontend/src/api/game-state.ts +++ b/ui/frontend/src/api/game-state.ts @@ -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 ?? [], }; } diff --git a/ui/frontend/src/api/synthetic-report.ts b/ui/frontend/src/api/synthetic-report.ts index ed8c222..d4ea0e1 100644 --- a/ui/frontend/src/api/synthetic-report.ts +++ b/ui/frontend/src/api/synthetic-report.ts @@ -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, }; } diff --git a/ui/frontend/src/lib/active-view/report.svelte b/ui/frontend/src/lib/active-view/report.svelte index e0f0392..b8767a9 100644 --- a/ui/frontend/src/lib/active-view/report.svelte +++ b/ui/frontend/src/lib/active-view/report.svelte @@ -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 `
` 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. -->
+
+ diff --git a/ui/frontend/src/lib/active-view/report/personal-exit-banner.svelte b/ui/frontend/src/lib/active-view/report/personal-exit-banner.svelte new file mode 100644 index 0000000..c8c1ff1 --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/personal-exit-banner.svelte @@ -0,0 +1,64 @@ + + + +{#if turnsLeft > 0} + +{/if} + + diff --git a/ui/frontend/src/lib/active-view/report/section-race-exit-warnings.svelte b/ui/frontend/src/lib/active-view/report/section-race-exit-warnings.svelte new file mode 100644 index 0000000..5580af5 --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-race-exit-warnings.svelte @@ -0,0 +1,70 @@ + + + +{#if rows.length > 0} +
+

{i18n.t("game.report.section.race_exit_warnings.title")}

+ +
    + {#each rows as r (r.race)} +
  • + {i18n.t("game.report.section.race_exit_warnings.notice", { + race: r.race, + turns: String(r.turnsLeft), + })} +
  • + {/each} +
+
+{/if} + + diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index cf01635..9065f51 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -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", diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index 15994ff..b2d23ca 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -658,11 +658,16 @@ const ru: Record = { "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": "голосую за", diff --git a/ui/frontend/src/proto/galaxy/fbs/report.ts b/ui/frontend/src/proto/galaxy/fbs/report.ts index d35b818..c709665 100644 --- a/ui/frontend/src/proto/galaxy/fbs/report.ts +++ b/ui/frontend/src/proto/galaxy/fbs/report.ts @@ -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'; diff --git a/ui/frontend/src/proto/galaxy/fbs/report/race-exit-notice.ts b/ui/frontend/src/proto/galaxy/fbs/report/race-exit-notice.ts new file mode 100644 index 0000000..3159b1e --- /dev/null +++ b/ui/frontend/src/proto/galaxy/fbs/report/race-exit-notice.ts @@ -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 { + 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 + ); +} +} diff --git a/ui/frontend/src/proto/galaxy/fbs/report/report.ts b/ui/frontend/src/proto/galaxy/fbs/report/report.ts index df929af..329c398 100644 --- a/ui/frontend/src/proto/galaxy/fbs/report/report.ts +++ b/ui/frontend/src/proto/galaxy/fbs/report/report.ts @@ -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(this.localFleet.bind(this), this.localFleetLength()), this.bb!.createObjList(this.localGroup.bind(this), this.localGroupLength()), this.bb!.createObjList(this.otherGroup.bind(this), this.otherGroupLength()), - this.bb!.createObjList(this.unidentifiedGroup.bind(this), this.unidentifiedGroupLength()) + this.bb!.createObjList(this.unidentifiedGroup.bind(this), this.unidentifiedGroupLength()), + this.personalExitWarning(), + this.bb!.createObjList(this.racesLeavingSoon.bind(this), this.racesLeavingSoonLength()) ); } @@ -693,6 +733,8 @@ unpackTo(_o: ReportT): void { _o.localGroup = this.bb!.createObjList(this.localGroup.bind(this), this.localGroupLength()); _o.otherGroup = this.bb!.createObjList(this.otherGroup.bind(this), this.otherGroupLength()); _o.unidentifiedGroup = this.bb!.createObjList(this.unidentifiedGroup.bind(this), this.unidentifiedGroupLength()); + _o.personalExitWarning = this.personalExitWarning(); + _o.racesLeavingSoon = this.bb!.createObjList(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 ); } } diff --git a/ui/frontend/tests/e2e/fixtures/report-fbs.ts b/ui/frontend/tests/e2e/fixtures/report-fbs.ts index 6c87413..469cdf8 100644 --- a/ui/frontend/tests/e2e/fixtures/report-fbs.ts +++ b/ui/frontend/tests/e2e/fixtures/report-fbs.ts @@ -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(); diff --git a/ui/frontend/tests/e2e/report-sections.spec.ts b/ui/frontend/tests/e2e/report-sections.spec.ts index cfd2074..d8b70a6 100644 --- a/ui/frontend/tests/e2e/report-sections.spec.ts +++ b/ui/frontend/tests/e2e/report-sections.spec.ts @@ -45,6 +45,7 @@ const BATTLE_ID = "00000000-0000-0000-0000-000000000001"; // the popover and a `report-section-` 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 { shipProductions: [ { planet: 1, class: "Cruiser", cost: 100, prodUsed: 25, percent: 0.25, free: 800 }, ], + racesLeavingSoon: [ + { race: "Bajori", turnsLeft: 2 }, + { race: "Cardassian", turnsLeft: 3 }, + ], }); break; } diff --git a/ui/frontend/tests/game-state.test.ts b/ui/frontend/tests/game-state.test.ts index b62865f..1be0d0d 100644 --- a/ui/frontend/tests/game-state.test.ts +++ b/ui/frontend/tests/game-state.test.ts @@ -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 diff --git a/ui/frontend/tests/helpers/empty-ship-groups.ts b/ui/frontend/tests/helpers/empty-ship-groups.ts index 9303880..b3cc177 100644 --- a/ui/frontend/tests/helpers/empty-ship-groups.ts +++ b/ui/frontend/tests/helpers/empty-ship-groups.ts @@ -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: [], }; diff --git a/ui/frontend/tests/pending-send-routes.test.ts b/ui/frontend/tests/pending-send-routes.test.ts index 15d8932..63fdba6 100644 --- a/ui/frontend/tests/pending-send-routes.test.ts +++ b/ui/frontend/tests/pending-send-routes.test.ts @@ -81,6 +81,8 @@ function makeReport( battleIds: [], bombings: [], shipProductions: [], + personalExitWarning: 0, + racesLeavingSoon: [], ...overrides, }; } diff --git a/ui/frontend/tests/report-section-race-exit-warnings.test.ts b/ui/frontend/tests/report-section-race-exit-warnings.test.ts new file mode 100644 index 0000000..91a471a --- /dev/null +++ b/ui/frontend/tests/report-section-race-exit-warnings.test.ts @@ -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 { + 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([ + [ + 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"); + }); +}); diff --git a/ui/frontend/tests/synthetic-report.test.ts b/ui/frontend/tests/synthetic-report.test.ts index ebbc707..eb2c359 100644 --- a/ui/frontend/tests/synthetic-report.test.ts +++ b/ui/frontend/tests/synthetic-report.test.ts @@ -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);