feat(game): race exit warnings in the turn report (#12)
Surface the inactivity-removal countdown the rules promise but the engine never reported. A race within five turns of being auto-removed for inactivity gets a personal warning in its own report; every race within three turns is listed publicly to all participants. - model: Report.PersonalExitWarning + RacesLeavingSoon ([]RaceExitNotice) - fbs: RaceExitNotice table + Report.personal_exit_warning / races_leaving_soon (regenerated Go + TS bindings) - transcoder: encode/decode both fields - engine: ReportExitWarnings fills the recipient's TTL (1..5) and lists other non-extinct races with TTL 1..3, excluding the recipient itself - ui: danger-styled personal banner + "races leaving soon" section (hidden when empty), wired into the report view, EN/RU i18n - docs: rules.txt report-section list, FUNCTIONAL.md 6.4 + RU mirror Voluntary quit and idle timeout share the TTL countdown and are not distinguished, per the agreed scope.
This commit is contained in:
+12
-5
@@ -704,11 +704,18 @@ demand. Backend authorises the caller and forwards the request;
|
|||||||
there is no caching or denormalisation in this path.
|
there is no caching or denormalisation in this path.
|
||||||
|
|
||||||
The web client renders the report as one section per FBS array
|
The web client renders the report as one section per FBS array
|
||||||
(galaxy summary, votes, player status, my / foreign sciences, my /
|
(galaxy summary, races leaving soon, votes, player status, my /
|
||||||
foreign ship classes, battles, bombings, approaching groups, my /
|
foreign sciences, my / foreign ship classes, battles, bombings,
|
||||||
foreign / uninhabited / unknown planets, ships in production,
|
approaching groups, my / foreign / uninhabited / unknown planets,
|
||||||
cargo routes, my fleets, my / foreign / unidentified ship groups).
|
ships in production, cargo routes, my fleets, my / foreign /
|
||||||
Empty sections render explicit empty-state copy. Section
|
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
|
navigation is exposed through a sticky icon-popup menu pinned to
|
||||||
the top-right of the report column (an anchored popover on desktop
|
the top-right of the report column (an anchored popover on desktop
|
||||||
and a fixed bottom-sheet on mobile); the trigger label tracks the
|
and a fixed bottom-sheet on mobile); the trigger label tracks the
|
||||||
|
|||||||
+14
-6
@@ -722,12 +722,20 @@ Backend авторизует вызывающего и форвардит зап
|
|||||||
нет ни кэширования, ни денормализации.
|
нет ни кэширования, ни денормализации.
|
||||||
|
|
||||||
Web-клиент рендерит отчёт как одну секцию на каждый FBS-массив
|
Web-клиент рендерит отчёт как одну секцию на каждый FBS-массив
|
||||||
(общие сведения, голоса, статус игроков, мои / чужие науки, мои /
|
(общие сведения, скоро покидающие игру расы, голоса, статус
|
||||||
чужие классы кораблей, сражения, бомбардировки, приближающиеся
|
игроков, мои / чужие науки, мои / чужие классы кораблей, сражения,
|
||||||
группы, мои / чужие / необитаемые / неопознанные планеты, корабли в
|
бомбардировки, приближающиеся группы, мои / чужие / необитаемые /
|
||||||
производстве, грузовые маршруты, мои флоты, мои / чужие /
|
неопознанные планеты, корабли в производстве, грузовые маршруты,
|
||||||
неопознанные группы кораблей). Пустые секции получают явную копию
|
мои флоты, мои / чужие / неопознанные группы кораблей). Пустые
|
||||||
empty-state. Навигация по секциям — sticky icon-popup в правом
|
секции получают явную копию empty-state; исключение — секция
|
||||||
|
«скоро покидающие игру расы»: она полностью скрывается, когда ни
|
||||||
|
одна раса не близка к исключению. Если же близка к исключению за
|
||||||
|
неактивность сама локальная раса (осталось не более пяти ходов),
|
||||||
|
над списком секций показывается персональный
|
||||||
|
баннер-предупреждение (стиль danger) с числом оставшихся ходов;
|
||||||
|
публичная секция «скоро покидающие игру расы» перечисляет все
|
||||||
|
прочие расы, до исключения которых осталось не более трёх ходов.
|
||||||
|
Навигация по секциям — sticky icon-popup в правом
|
||||||
верхнем углу колонки отчёта (анкорный popover на десктопе и фикс.
|
верхнем углу колонки отчёта (анкорный popover на десктопе и фикс.
|
||||||
bottom-sheet на мобильном); подпись на кнопке отслеживает раздел,
|
bottom-sheet на мобильном); подпись на кнопке отслеживает раздел,
|
||||||
который сейчас в зоне видимости, выбор пункта меню — скролл к
|
который сейчас в зоне видимости, выбор пункта меню — скролл к
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ func (c *Cache) ReportRace(ri int, rep *mr.Report, battles []*mr.BattleReport, b
|
|||||||
rep.Player[i].Relation = "-"
|
rep.Player[i].Relation = "-"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// race exit warnings
|
||||||
|
c.ReportExitWarnings(ri, rep)
|
||||||
|
|
||||||
// sciences
|
// sciences
|
||||||
c.ReportLocalScience(ri, rep)
|
c.ReportLocalScience(ri, rep)
|
||||||
c.ReportOtherScience(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)
|
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) {
|
func (c *Cache) ReportLocalScience(ri int, rep *mr.Report) {
|
||||||
c.validateRaceIndex(ri)
|
c.validateRaceIndex(ri)
|
||||||
r := &c.g.Race[ri]
|
r := &c.g.Race[ri]
|
||||||
|
|||||||
@@ -196,3 +196,34 @@ func TestReportIncomingGroupRemainingDistance(t *testing.T) {
|
|||||||
// route would be sqrt(2) ≈ 1.414.
|
// route would be sqrt(2) ≈ 1.414.
|
||||||
assert.InDelta(t, 5.657, rep.IncomingGroup[0].Distance.F(), 0.01)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1152,6 +1152,14 @@ Freighter загруженный 15 ед. груза при технологии
|
|||||||
|
|
||||||
- Размер галактики, количество планет галактики и количество оставшихся рас.
|
- Размер галактики, количество планет галактики и количество оставшихся рас.
|
||||||
|
|
||||||
|
- Предупреждение о скором исключении Вашей расы из игры за неактивность,
|
||||||
|
если до удаления осталось не более 5 ходов: указывается количество
|
||||||
|
оставшихся ходов (механизм описан в разделе "Выход из игры").
|
||||||
|
|
||||||
|
- Расы, покидающие игру в ближайшее время: расы, до принудительного
|
||||||
|
исключения которых за неактивность осталось не более 3 ходов; этот
|
||||||
|
список виден всем участникам.
|
||||||
|
|
||||||
- Ваше общее количество голосов.
|
- Ваше общее количество голосов.
|
||||||
- Имя расы, которой Вы отдаете свои голоса.
|
- Имя расы, которой Вы отдаете свои голоса.
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ type Report struct {
|
|||||||
OtherGroup []OtherGroup `json:"otherGroup,omitempty"`
|
OtherGroup []OtherGroup `json:"otherGroup,omitempty"`
|
||||||
UnidentifiedGroup []UnidentifiedGroup `json:"unidentifiedGroup,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:"-"`
|
OnPlanetGroupCache map[uint][]int `json:"-"`
|
||||||
InSpaceGroupRangeCache map[int]map[uint]float64 `json:"-"`
|
InSpaceGroupRangeCache map[int]map[uint]float64 `json:"-"`
|
||||||
}
|
}
|
||||||
@@ -71,6 +78,13 @@ type Player struct {
|
|||||||
Extinct bool `json:"extinct"`
|
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) {
|
func (r Report) MarshalBinary() (data []byte, err error) {
|
||||||
return json.Marshal(&r)
|
return json.Marshal(&r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,13 @@ table BattleSummary {
|
|||||||
shots:uint64;
|
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 {
|
table Report {
|
||||||
version:uint64;
|
version:uint64;
|
||||||
turn:uint64;
|
turn:uint64;
|
||||||
@@ -236,6 +243,8 @@ table Report {
|
|||||||
local_group:[LocalGroup];
|
local_group:[LocalGroup];
|
||||||
other_group:[OtherGroup];
|
other_group:[OtherGroup];
|
||||||
unidentified_group:[UnidentifiedGroup];
|
unidentified_group:[UnidentifiedGroup];
|
||||||
|
personal_exit_warning:uint32 = 0;
|
||||||
|
races_leaving_soon:[RaceExitNotice];
|
||||||
}
|
}
|
||||||
|
|
||||||
// GameReportRequest is the signed-gRPC request payload for
|
// GameReportRequest is the signed-gRPC request payload for
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -489,8 +489,40 @@ func (rcv *Report) UnidentifiedGroupLength() int {
|
|||||||
return 0
|
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) {
|
func ReportStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(26)
|
builder.StartObject(28)
|
||||||
}
|
}
|
||||||
func ReportAddVersion(builder *flatbuffers.Builder, version uint64) {
|
func ReportAddVersion(builder *flatbuffers.Builder, version uint64) {
|
||||||
builder.PrependUint64Slot(0, version, 0)
|
builder.PrependUint64Slot(0, version, 0)
|
||||||
@@ -624,6 +656,15 @@ func ReportAddUnidentifiedGroup(builder *flatbuffers.Builder, unidentifiedGroup
|
|||||||
func ReportStartUnidentifiedGroupVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
func ReportStartUnidentifiedGroupVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
return builder.StartVector(4, numElems, 4)
|
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 {
|
func ReportEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,11 @@ func ReportToPayload(report *model.Report) ([]byte, error) {
|
|||||||
unidentifiedGroupOffsets[i] = encodeReportUnidentifiedGroup(builder, &report.UnidentifiedGroup[i])
|
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)
|
playerVector := encodeReportOffsetVector(builder, len(playerOffsets), fbs.ReportStartPlayerVector, playerOffsets)
|
||||||
localScienceVector := encodeReportOffsetVector(builder, len(localScienceOffsets), fbs.ReportStartLocalScienceVector, localScienceOffsets)
|
localScienceVector := encodeReportOffsetVector(builder, len(localScienceOffsets), fbs.ReportStartLocalScienceVector, localScienceOffsets)
|
||||||
otherScienceVector := encodeReportOffsetVector(builder, len(otherScienceOffsets), fbs.ReportStartOtherScienceVector, otherScienceOffsets)
|
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)
|
localGroupVector := encodeReportOffsetVector(builder, len(localGroupOffsets), fbs.ReportStartLocalGroupVector, localGroupOffsets)
|
||||||
otherGroupVector := encodeReportOffsetVector(builder, len(otherGroupOffsets), fbs.ReportStartOtherGroupVector, otherGroupOffsets)
|
otherGroupVector := encodeReportOffsetVector(builder, len(otherGroupOffsets), fbs.ReportStartOtherGroupVector, otherGroupOffsets)
|
||||||
unidentifiedGroupVector := encodeReportOffsetVector(builder, len(unidentifiedGroupOffsets), fbs.ReportStartUnidentifiedGroupVector, unidentifiedGroupOffsets)
|
unidentifiedGroupVector := encodeReportOffsetVector(builder, len(unidentifiedGroupOffsets), fbs.ReportStartUnidentifiedGroupVector, unidentifiedGroupOffsets)
|
||||||
|
racesLeavingSoonVector := encodeReportOffsetVector(builder, len(racesLeavingSoonOffsets), fbs.ReportStartRacesLeavingSoonVector, racesLeavingSoonOffsets)
|
||||||
|
|
||||||
fbs.ReportStart(builder)
|
fbs.ReportStart(builder)
|
||||||
fbs.ReportAddVersion(builder, uint64(report.Version))
|
fbs.ReportAddVersion(builder, uint64(report.Version))
|
||||||
@@ -196,6 +202,10 @@ func ReportToPayload(report *model.Report) ([]byte, error) {
|
|||||||
if len(unidentifiedGroupOffsets) > 0 {
|
if len(unidentifiedGroupOffsets) > 0 {
|
||||||
fbs.ReportAddUnidentifiedGroup(builder, unidentifiedGroupVector)
|
fbs.ReportAddUnidentifiedGroup(builder, unidentifiedGroupVector)
|
||||||
}
|
}
|
||||||
|
fbs.ReportAddPersonalExitWarning(builder, uint32(report.PersonalExitWarning))
|
||||||
|
if len(racesLeavingSoonOffsets) > 0 {
|
||||||
|
fbs.ReportAddRacesLeavingSoon(builder, racesLeavingSoonVector)
|
||||||
|
}
|
||||||
|
|
||||||
reportOffset := fbs.ReportEnd(builder)
|
reportOffset := fbs.ReportEnd(builder)
|
||||||
fbs.FinishReportBuffer(builder, reportOffset)
|
fbs.FinishReportBuffer(builder, reportOffset)
|
||||||
@@ -238,6 +248,8 @@ func PayloadToReport(data []byte) (result *model.Report, err error) {
|
|||||||
Race: string(flatReport.Race()),
|
Race: string(flatReport.Race()),
|
||||||
Votes: reportFloatFromFBS(flatReport.Votes()),
|
Votes: reportFloatFromFBS(flatReport.Votes()),
|
||||||
VoteFor: string(flatReport.VoteFor()),
|
VoteFor: string(flatReport.VoteFor()),
|
||||||
|
|
||||||
|
PersonalExitWarning: uint(flatReport.PersonalExitWarning()),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := decodeReportPlayerVector(flatReport, result); err != nil {
|
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 {
|
if err := decodeReportUnidentifiedGroupVector(flatReport, result); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := decodeReportRacesLeavingSoonVector(flatReport, result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -583,6 +598,14 @@ func encodeReportUnidentifiedGroup(builder *flatbuffers.Builder, group *model.Un
|
|||||||
return fbs.UnidentifiedGroupEnd(builder)
|
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 {
|
func decodeReportPlayerVector(flatReport *fbs.Report, result *model.Report) error {
|
||||||
length := flatReport.PlayerLength()
|
length := flatReport.PlayerLength()
|
||||||
if length == 0 {
|
if length == 0 {
|
||||||
@@ -1244,6 +1267,33 @@ func decodeReportUnidentifiedGroupVector(flatReport *fbs.Report, result *model.R
|
|||||||
return nil
|
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) {
|
func decodeReportRouteMap(flatRoute *fbs.Route, routeIndex int) (map[uint]string, error) {
|
||||||
length := flatRoute.RouteLength()
|
length := flatRoute.RouteLength()
|
||||||
if length == 0 {
|
if length == 0 {
|
||||||
|
|||||||
@@ -365,6 +365,11 @@ func sampleReport() *model.Report {
|
|||||||
UnidentifiedGroup: []model.UnidentifiedGroup{
|
UnidentifiedGroup: []model.UnidentifiedGroup{
|
||||||
{X: model.Float(10.0), Y: model.Float(11.0)},
|
{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{
|
OnPlanetGroupCache: map[uint][]int{
|
||||||
1: {2, 3},
|
1: {2, 3},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -571,6 +571,21 @@ export interface GameReport {
|
|||||||
* currently producing a ship.
|
* currently producing a ship.
|
||||||
*/
|
*/
|
||||||
shipProductions: ReportShipProduction[];
|
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(
|
export async function fetchGameReport(
|
||||||
@@ -731,6 +746,7 @@ function decodeReport(report: Report): GameReport {
|
|||||||
const battleIds = battles.map((b) => b.id);
|
const battleIds = battles.map((b) => b.id);
|
||||||
const bombings = decodeBombings(report);
|
const bombings = decodeBombings(report);
|
||||||
const shipProductions = decodeShipProductions(report);
|
const shipProductions = decodeShipProductions(report);
|
||||||
|
const racesLeavingSoon = decodeRacesLeavingSoon(report);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
turn: Number(report.turn()),
|
turn: Number(report.turn()),
|
||||||
@@ -762,6 +778,8 @@ function decodeReport(report: Report): GameReport {
|
|||||||
battleIds,
|
battleIds,
|
||||||
bombings,
|
bombings,
|
||||||
shipProductions,
|
shipProductions,
|
||||||
|
personalExitWarning: report.personalExitWarning(),
|
||||||
|
racesLeavingSoon,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -939,6 +957,25 @@ function decodeUnidentifiedShipGroups(
|
|||||||
return out;
|
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[] {
|
function decodeLocalFleets(report: Report): ReportLocalFleet[] {
|
||||||
const out: ReportLocalFleet[] = [];
|
const out: ReportLocalFleet[] = [];
|
||||||
for (let i = 0; i < report.localFleetLength(); i++) {
|
for (let i = 0; i < report.localFleetLength(); i++) {
|
||||||
@@ -1479,6 +1516,7 @@ export function applyOrderOverlay(
|
|||||||
battleIds: report.battleIds ?? [],
|
battleIds: report.battleIds ?? [],
|
||||||
bombings: report.bombings ?? [],
|
bombings: report.bombings ?? [],
|
||||||
shipProductions: report.shipProductions ?? [],
|
shipProductions: report.shipProductions ?? [],
|
||||||
|
racesLeavingSoon: report.racesLeavingSoon ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,14 @@
|
|||||||
// AND the Go CLI must learn to populate that field — see the
|
// AND the Go CLI must learn to populate that field — see the
|
||||||
// synthetic-report parity rule in `ui/PLAN.md`.
|
// 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:
|
// The in-memory map deliberately does not survive a page reload:
|
||||||
// synthetic mode is a debug affordance, not a session, and the
|
// synthetic mode is a debug affordance, not a session, and the
|
||||||
// layout redirects to /lobby when a synthetic id is opened with no
|
// layout redirects to /lobby when a synthetic id is opened with no
|
||||||
@@ -259,6 +267,11 @@ interface SyntheticShipProductionRow {
|
|||||||
free?: number;
|
free?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SyntheticRaceExitNotice {
|
||||||
|
race?: string;
|
||||||
|
turnsLeft?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface SyntheticReportRoot {
|
interface SyntheticReportRoot {
|
||||||
turn?: number;
|
turn?: number;
|
||||||
mapWidth?: number;
|
mapWidth?: number;
|
||||||
@@ -284,6 +297,8 @@ interface SyntheticReportRoot {
|
|||||||
battle?: SyntheticBattle[];
|
battle?: SyntheticBattle[];
|
||||||
bombing?: SyntheticBombing[];
|
bombing?: SyntheticBombing[];
|
||||||
shipProduction?: SyntheticShipProductionRow[];
|
shipProduction?: SyntheticShipProductionRow[];
|
||||||
|
personalExitWarning?: number;
|
||||||
|
racesLeavingSoon?: SyntheticRaceExitNotice[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeSyntheticReport(json: unknown): GameReport {
|
function decodeSyntheticReport(json: unknown): GameReport {
|
||||||
@@ -465,6 +480,13 @@ function decodeSyntheticReport(json: unknown): GameReport {
|
|||||||
return a.class.localeCompare(b.class);
|
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 {
|
return {
|
||||||
turn: numOr0(root.turn),
|
turn: numOr0(root.turn),
|
||||||
mapWidth: numOr0(root.mapWidth),
|
mapWidth: numOr0(root.mapWidth),
|
||||||
@@ -495,6 +517,8 @@ function decodeSyntheticReport(json: unknown): GameReport {
|
|||||||
battleIds,
|
battleIds,
|
||||||
bombings,
|
bombings,
|
||||||
shipProductions,
|
shipProductions,
|
||||||
|
personalExitWarning: numOr0(root.personalExitWarning),
|
||||||
|
racesLeavingSoon,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Phase 23 turn-report active view.
|
Phase 23 turn-report active view.
|
||||||
|
|
||||||
Composes the table of contents (`report/report-toc.svelte`) and the
|
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
|
section is its own component under `lib/active-view/report/` — the
|
||||||
data shapes are too varied for one generic table, and the
|
data shapes are too varied for one generic table, and the
|
||||||
component-per-section seam matches Phase 23's targeted-test contract.
|
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>">`
|
rooted on the viewport watches every `<section id="report-<slug>">`
|
||||||
and updates a local `activeSlug` rune that drives the TOC highlight.
|
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
|
The section list lives here as a single source of truth so the
|
||||||
TOC and the body iterate the same data.
|
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">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
@@ -20,7 +22,9 @@ TOC and the body iterate the same data.
|
|||||||
import ReportToc, {
|
import ReportToc, {
|
||||||
type TocEntry,
|
type TocEntry,
|
||||||
} from "./report/report-toc.svelte";
|
} from "./report/report-toc.svelte";
|
||||||
|
import PersonalExitBanner from "./report/personal-exit-banner.svelte";
|
||||||
import SectionGalaxySummary from "./report/section-galaxy-summary.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 SectionVotes from "./report/section-votes.svelte";
|
||||||
import SectionPlayerStatus from "./report/section-player-status.svelte";
|
import SectionPlayerStatus from "./report/section-player-status.svelte";
|
||||||
import SectionMySciences from "./report/section-my-sciences.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[] = [
|
const ENTRIES: readonly TocEntry[] = [
|
||||||
{ slug: "galaxy-summary", titleKey: "game.report.section.galaxy_summary.title" },
|
{ 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: "votes", titleKey: "game.report.section.votes.title" },
|
||||||
{ slug: "player-status", titleKey: "game.report.section.player_status.title" },
|
{ slug: "player-status", titleKey: "game.report.section.player_status.title" },
|
||||||
{ slug: "my-sciences", titleKey: "game.report.section.my_sciences.title" },
|
{ slug: "my-sciences", titleKey: "game.report.section.my_sciences.title" },
|
||||||
@@ -107,10 +112,12 @@ TOC and the body iterate the same data.
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="report-view" data-testid="active-view-report">
|
<div class="report-view" data-testid="active-view-report">
|
||||||
|
<PersonalExitBanner />
|
||||||
<ReportToc entries={ENTRIES} {activeSlug} />
|
<ReportToc entries={ENTRIES} {activeSlug} />
|
||||||
|
|
||||||
<div class="report-body" bind:this={bodyEl}>
|
<div class="report-body" bind:this={bodyEl}>
|
||||||
<SectionGalaxySummary />
|
<SectionGalaxySummary />
|
||||||
|
<SectionRaceExitWarnings />
|
||||||
<SectionVotes />
|
<SectionVotes />
|
||||||
<SectionPlayerStatus />
|
<SectionPlayerStatus />
|
||||||
<SectionMySciences />
|
<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>
|
||||||
@@ -657,11 +657,16 @@ const en = {
|
|||||||
"game.report.toc.title": "sections",
|
"game.report.toc.title": "sections",
|
||||||
"game.report.toc.open": "show section list",
|
"game.report.toc.open": "show section list",
|
||||||
"game.report.toc.close": "hide 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.title": "galaxy summary",
|
||||||
"game.report.section.galaxy_summary.field.turn": "turn",
|
"game.report.section.galaxy_summary.field.turn": "turn",
|
||||||
"game.report.section.galaxy_summary.field.size": "map size",
|
"game.report.section.galaxy_summary.field.size": "map size",
|
||||||
"game.report.section.galaxy_summary.field.planets": "planet count",
|
"game.report.section.galaxy_summary.field.planets": "planet count",
|
||||||
"game.report.section.galaxy_summary.field.race": "your race",
|
"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.title": "votes",
|
||||||
"game.report.section.votes.mine": "my votes",
|
"game.report.section.votes.mine": "my votes",
|
||||||
"game.report.section.votes.target": "I vote for",
|
"game.report.section.votes.target": "I vote for",
|
||||||
|
|||||||
@@ -658,11 +658,16 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.report.toc.title": "разделы",
|
"game.report.toc.title": "разделы",
|
||||||
"game.report.toc.open": "показать список разделов",
|
"game.report.toc.open": "показать список разделов",
|
||||||
"game.report.toc.close": "скрыть список разделов",
|
"game.report.toc.close": "скрыть список разделов",
|
||||||
|
"game.report.personal_exit_warning":
|
||||||
|
"Предупреждение о неактивности: ваша раса будет удалена через {turns} ход(ов), если вы не отправите приказы.",
|
||||||
"game.report.section.galaxy_summary.title": "общие сведения о галактике",
|
"game.report.section.galaxy_summary.title": "общие сведения о галактике",
|
||||||
"game.report.section.galaxy_summary.field.turn": "ход",
|
"game.report.section.galaxy_summary.field.turn": "ход",
|
||||||
"game.report.section.galaxy_summary.field.size": "размер карты",
|
"game.report.section.galaxy_summary.field.size": "размер карты",
|
||||||
"game.report.section.galaxy_summary.field.planets": "всего планет",
|
"game.report.section.galaxy_summary.field.planets": "всего планет",
|
||||||
"game.report.section.galaxy_summary.field.race": "ваша раса",
|
"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.title": "голоса",
|
||||||
"game.report.section.votes.mine": "мои голоса",
|
"game.report.section.votes.mine": "мои голоса",
|
||||||
"game.report.section.votes.target": "голосую за",
|
"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 { OtherScience, OtherScienceT } from './report/other-science.js';
|
||||||
export { OthersShipClass, OthersShipClassT } from './report/others-ship-class.js';
|
export { OthersShipClass, OthersShipClassT } from './report/others-ship-class.js';
|
||||||
export { Player, PlayerT } from './report/player.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 { Report, ReportT } from './report/report.js';
|
||||||
export { Route, RouteT } from './report/route.js';
|
export { Route, RouteT } from './report/route.js';
|
||||||
export { RouteEntry, RouteEntryT } from './report/route-entry.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 { OtherScience, OtherScienceT } from '../report/other-science.js';
|
||||||
import { OthersShipClass, OthersShipClassT } from '../report/others-ship-class.js';
|
import { OthersShipClass, OthersShipClassT } from '../report/others-ship-class.js';
|
||||||
import { Player, PlayerT } from '../report/player.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 { Route, RouteT } from '../report/route.js';
|
||||||
import { Science, ScienceT } from '../report/science.js';
|
import { Science, ScienceT } from '../report/science.js';
|
||||||
import { ShipClass, ShipClassT } from '../report/ship-class.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;
|
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) {
|
static startReport(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(26);
|
builder.startObject(28);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addVersion(builder:flatbuffers.Builder, version:bigint) {
|
static addVersion(builder:flatbuffers.Builder, version:bigint) {
|
||||||
@@ -590,6 +606,26 @@ static startUnidentifiedGroupVector(builder:flatbuffers.Builder, numElems:number
|
|||||||
builder.startVector(4, numElems, 4);
|
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 {
|
static endReport(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
@@ -603,7 +639,7 @@ static finishSizePrefixedReportBuffer(builder:flatbuffers.Builder, offset:flatbu
|
|||||||
builder.finish(offset, undefined, true);
|
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.startReport(builder);
|
||||||
Report.addVersion(builder, version);
|
Report.addVersion(builder, version);
|
||||||
Report.addTurn(builder, turn);
|
Report.addTurn(builder, turn);
|
||||||
@@ -631,6 +667,8 @@ static createReport(builder:flatbuffers.Builder, version:bigint, turn:bigint, wi
|
|||||||
Report.addLocalGroup(builder, localGroupOffset);
|
Report.addLocalGroup(builder, localGroupOffset);
|
||||||
Report.addOtherGroup(builder, otherGroupOffset);
|
Report.addOtherGroup(builder, otherGroupOffset);
|
||||||
Report.addUnidentifiedGroup(builder, unidentifiedGroupOffset);
|
Report.addUnidentifiedGroup(builder, unidentifiedGroupOffset);
|
||||||
|
Report.addPersonalExitWarning(builder, personalExitWarning);
|
||||||
|
Report.addRacesLeavingSoon(builder, racesLeavingSoonOffset);
|
||||||
return Report.endReport(builder);
|
return Report.endReport(builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,7 +699,9 @@ unpack(): ReportT {
|
|||||||
this.bb!.createObjList<LocalFleet, LocalFleetT>(this.localFleet.bind(this), this.localFleetLength()),
|
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<LocalGroup, LocalGroupT>(this.localGroup.bind(this), this.localGroupLength()),
|
||||||
this.bb!.createObjList<OtherGroup, OtherGroupT>(this.otherGroup.bind(this), this.otherGroupLength()),
|
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.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.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.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 localFleet: (LocalFleetT)[] = [],
|
||||||
public localGroup: (LocalGroupT)[] = [],
|
public localGroup: (LocalGroupT)[] = [],
|
||||||
public otherGroup: (OtherGroupT)[] = [],
|
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 localGroup = Report.createLocalGroupVector(builder, builder.createObjectOffsetList(this.localGroup));
|
||||||
const otherGroup = Report.createOtherGroupVector(builder, builder.createObjectOffsetList(this.otherGroup));
|
const otherGroup = Report.createOtherGroupVector(builder, builder.createObjectOffsetList(this.otherGroup));
|
||||||
const unidentifiedGroup = Report.createUnidentifiedGroupVector(builder, builder.createObjectOffsetList(this.unidentifiedGroup));
|
const unidentifiedGroup = Report.createUnidentifiedGroupVector(builder, builder.createObjectOffsetList(this.unidentifiedGroup));
|
||||||
|
const racesLeavingSoon = Report.createRacesLeavingSoonVector(builder, builder.createObjectOffsetList(this.racesLeavingSoon));
|
||||||
|
|
||||||
return Report.createReport(builder,
|
return Report.createReport(builder,
|
||||||
this.version,
|
this.version,
|
||||||
@@ -775,7 +820,9 @@ pack(builder:flatbuffers.Builder): flatbuffers.Offset {
|
|||||||
localFleet,
|
localFleet,
|
||||||
localGroup,
|
localGroup,
|
||||||
otherGroup,
|
otherGroup,
|
||||||
unidentifiedGroup
|
unidentifiedGroup,
|
||||||
|
this.personalExitWarning,
|
||||||
|
racesLeavingSoon
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
OtherScience,
|
OtherScience,
|
||||||
OthersShipClass,
|
OthersShipClass,
|
||||||
Player,
|
Player,
|
||||||
|
RaceExitNotice,
|
||||||
Report,
|
Report,
|
||||||
Route,
|
Route,
|
||||||
RouteEntry,
|
RouteEntry,
|
||||||
@@ -139,6 +140,11 @@ export interface ShipProductionFixture {
|
|||||||
free?: number;
|
free?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RaceExitNoticeFixture {
|
||||||
|
race: string;
|
||||||
|
turnsLeft: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReportFixture {
|
export interface ReportFixture {
|
||||||
turn: number;
|
turn: number;
|
||||||
mapWidth?: number;
|
mapWidth?: number;
|
||||||
@@ -159,6 +165,8 @@ export interface ReportFixture {
|
|||||||
battles?: BattleSummaryFixture[];
|
battles?: BattleSummaryFixture[];
|
||||||
bombings?: BombingFixture[];
|
bombings?: BombingFixture[];
|
||||||
shipProductions?: ShipProductionFixture[];
|
shipProductions?: ShipProductionFixture[];
|
||||||
|
personalExitWarning?: number;
|
||||||
|
racesLeavingSoon?: RaceExitNoticeFixture[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||||
@@ -356,6 +364,14 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
|||||||
return ShipProduction.endShipProduction(builder);
|
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 =
|
const localVec =
|
||||||
localOffsets.length === 0
|
localOffsets.length === 0
|
||||||
? null
|
? null
|
||||||
@@ -404,6 +420,10 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
|||||||
shipProductionOffsets.length === 0
|
shipProductionOffsets.length === 0
|
||||||
? null
|
? null
|
||||||
: Report.createShipProductionVector(builder, shipProductionOffsets);
|
: Report.createShipProductionVector(builder, shipProductionOffsets);
|
||||||
|
const racesLeavingSoonVec =
|
||||||
|
racesLeavingSoonOffsets.length === 0
|
||||||
|
? null
|
||||||
|
: Report.createRacesLeavingSoonVector(builder, racesLeavingSoonOffsets);
|
||||||
// Phase 27 — `battle` carries `BattleSummary` tables, each with
|
// Phase 27 — `battle` carries `BattleSummary` tables, each with
|
||||||
// an inline `id:UUID` struct plus `planet` and `shots` slots.
|
// an inline `id:UUID` struct plus `planet` and `shots` slots.
|
||||||
const battleVec = (() => {
|
const battleVec = (() => {
|
||||||
@@ -462,6 +482,10 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
|||||||
if (bombingVec !== null) Report.addBombing(builder, bombingVec);
|
if (bombingVec !== null) Report.addBombing(builder, bombingVec);
|
||||||
if (shipProductionVec !== null)
|
if (shipProductionVec !== null)
|
||||||
Report.addShipProduction(builder, shipProductionVec);
|
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);
|
const reportOff = Report.endReport(builder);
|
||||||
builder.finish(reportOff);
|
builder.finish(reportOff);
|
||||||
return builder.asUint8Array();
|
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.
|
// the popover and a `report-section-<slug>` testid in the body.
|
||||||
const SECTIONS: ReadonlyArray<{ slug: string; expectRow: string | null }> = [
|
const SECTIONS: ReadonlyArray<{ slug: string; expectRow: string | null }> = [
|
||||||
{ slug: "galaxy-summary", expectRow: "galaxy-summary-field-turn" },
|
{ slug: "galaxy-summary", expectRow: "galaxy-summary-field-turn" },
|
||||||
|
{ slug: "race-exit-warnings", expectRow: "race-exit-warnings-row" },
|
||||||
{ slug: "votes", expectRow: "votes-mine" },
|
{ slug: "votes", expectRow: "votes-mine" },
|
||||||
{ slug: "player-status", expectRow: "player-status-row" },
|
{ slug: "player-status", expectRow: "player-status-row" },
|
||||||
{ slug: "my-sciences", expectRow: "my-sciences-row" },
|
{ slug: "my-sciences", expectRow: "my-sciences-row" },
|
||||||
@@ -161,6 +162,10 @@ async function mockGateway(page: Page): Promise<void> {
|
|||||||
shipProductions: [
|
shipProductions: [
|
||||||
{ planet: 1, class: "Cruiser", cost: 100, prodUsed: 25, percent: 0.25, free: 800 },
|
{ planet: 1, class: "Cruiser", cost: 100, prodUsed: 25, percent: 0.25, free: 800 },
|
||||||
],
|
],
|
||||||
|
racesLeavingSoon: [
|
||||||
|
{ race: "Bajori", turnsLeft: 2 },
|
||||||
|
{ race: "Cardassian", turnsLeft: 3 },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { ByteBuffer } from "flatbuffers";
|
|||||||
import {
|
import {
|
||||||
GameReportRequest,
|
GameReportRequest,
|
||||||
LocalPlanet,
|
LocalPlanet,
|
||||||
|
RaceExitNotice,
|
||||||
Report,
|
Report,
|
||||||
ShipClass,
|
ShipClass,
|
||||||
} from "../src/proto/galaxy/fbs/report";
|
} from "../src/proto/galaxy/fbs/report";
|
||||||
@@ -124,6 +125,8 @@ function buildReportPayload(opts: {
|
|||||||
height?: number;
|
height?: number;
|
||||||
planets?: PlanetFixture[];
|
planets?: PlanetFixture[];
|
||||||
shipClasses?: ShipClassFixture[];
|
shipClasses?: ShipClassFixture[];
|
||||||
|
personalExitWarning?: number;
|
||||||
|
racesLeavingSoon?: { race: string; turnsLeft: number }[];
|
||||||
}): Uint8Array {
|
}): Uint8Array {
|
||||||
const builder = new Builder(256);
|
const builder = new Builder(256);
|
||||||
const planetOffsets = (opts.planets ?? []).map((planet) => {
|
const planetOffsets = (opts.planets ?? []).map((planet) => {
|
||||||
@@ -156,6 +159,17 @@ function buildReportPayload(opts: {
|
|||||||
shipClassOffsets.length === 0
|
shipClassOffsets.length === 0
|
||||||
? null
|
? null
|
||||||
: Report.createLocalShipClassVector(builder, shipClassOffsets);
|
: 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.startReport(builder);
|
||||||
Report.addTurn(builder, BigInt(opts.turn));
|
Report.addTurn(builder, BigInt(opts.turn));
|
||||||
@@ -168,6 +182,12 @@ function buildReportPayload(opts: {
|
|||||||
if (localShipClassVec !== null) {
|
if (localShipClassVec !== null) {
|
||||||
Report.addLocalShipClass(builder, localShipClassVec);
|
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);
|
const reportOff = Report.endReport(builder);
|
||||||
builder.finish(reportOff);
|
builder.finish(reportOff);
|
||||||
return builder.asUint8Array();
|
return builder.asUint8Array();
|
||||||
@@ -214,6 +234,52 @@ describe("GameStateStore", () => {
|
|||||||
store.dispose();
|
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 () => {
|
test("init surfaces an error when the game is missing from lobby", async () => {
|
||||||
listMyGamesSpy.mockResolvedValue([makeGameSummary(0).gameId === "other" ? null : makeGameSummary(0)].filter(Boolean));
|
listMyGamesSpy.mockResolvedValue([makeGameSummary(0).gameId === "other" ? null : makeGameSummary(0)].filter(Boolean));
|
||||||
// Replace the helper above's awkward filter with an explicit
|
// Replace the helper above's awkward filter with an explicit
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// EMPTY_SHIP_GROUPS supplies empty arrays / zero defaults for the
|
// EMPTY_SHIP_GROUPS supplies empty arrays / zero defaults for the
|
||||||
// ancillary report fields added in Phase 19 (ship-groups + fleets),
|
// 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
|
// 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
|
// Test fixtures spread it into their report objects so the fixture
|
||||||
// body still focuses on the fields under test, without forcing
|
// body still focuses on the fields under test, without forcing
|
||||||
// every spec to enumerate the full GameReport surface.
|
// every spec to enumerate the full GameReport surface.
|
||||||
@@ -41,6 +43,8 @@ export const EMPTY_SHIP_GROUPS: {
|
|||||||
battleIds: string[];
|
battleIds: string[];
|
||||||
bombings: ReportBombing[];
|
bombings: ReportBombing[];
|
||||||
shipProductions: ReportShipProduction[];
|
shipProductions: ReportShipProduction[];
|
||||||
|
personalExitWarning: number;
|
||||||
|
racesLeavingSoon: { race: string; turnsLeft: number }[];
|
||||||
} = {
|
} = {
|
||||||
localShipGroups: [],
|
localShipGroups: [],
|
||||||
otherShipGroups: [],
|
otherShipGroups: [],
|
||||||
@@ -59,4 +63,6 @@ export const EMPTY_SHIP_GROUPS: {
|
|||||||
battleIds: [],
|
battleIds: [],
|
||||||
bombings: [],
|
bombings: [],
|
||||||
shipProductions: [],
|
shipProductions: [],
|
||||||
|
personalExitWarning: 0,
|
||||||
|
racesLeavingSoon: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ function makeReport(
|
|||||||
battleIds: [],
|
battleIds: [],
|
||||||
bombings: [],
|
bombings: [],
|
||||||
shipProductions: [],
|
shipProductions: [],
|
||||||
|
personalExitWarning: 0,
|
||||||
|
racesLeavingSoon: [],
|
||||||
...overrides,
|
...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([]);
|
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", () => {
|
test("registers the report under the returned game id", () => {
|
||||||
const { gameId, report } = loadSyntheticReportFromJSON(syntheticJSON());
|
const { gameId, report } = loadSyntheticReportFromJSON(syntheticJSON());
|
||||||
expect(getSyntheticReport(gameId)).toBe(report);
|
expect(getSyntheticReport(gameId)).toBe(report);
|
||||||
|
|||||||
Reference in New Issue
Block a user