ui/phase-23: turn-report view with twenty sections and TOC

Replaces the Phase 10 report stub with a scrollable orchestrator that
renders every FBS array as a dedicated section (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). A sticky table of contents (a <select> on mobile),
"back to map" affordance, IntersectionObserver-driven active-section
highlight, and SvelteKit Snapshot-based scroll save/restore round out
the view.

GameReport gains six new fields (players, otherScience, otherShipClass,
battleIds, bombings, shipProductions); decodeReport, the synthetic-
report loader, the e2e fixture builder, and EMPTY_SHIP_GROUPS extend
in lockstep. ~90 new i18n keys land in en + ru together.

The legacy-report parser is extended to populate the new sections from
the dg/gplus text formats (Your Sciences, <Race> Sciences, <Race> Ship
Types, Bombings, Ships In Production). Ships-in-production prod_used
is derived through a new pkg/calc.ShipBuildCost helper; the engine's
controller.ProduceShip refactors to call the same helper without any
behaviour change (engine tests stay unchanged and green). Battles
remain in the parser's Skipped list — the legacy text carries no
stable per-battle UUID.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-11 14:33:56 +02:00
parent 81d8be08b2
commit c58027c034
48 changed files with 5368 additions and 103 deletions
+259 -39
View File
@@ -201,7 +201,7 @@ func TestParseShipClasses(t *testing.T) {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.LocalShipClass), 2; got != want {
t.Fatalf("len(LocalShipClass) = %d, want %d (foreign types must be ignored)", got, want)
t.Fatalf("len(LocalShipClass) = %d, want %d", got, want)
}
bow := rep.LocalShipClass[1]
if bow.Name != "Bow105" || bow.Armament != 105 {
@@ -210,9 +210,193 @@ func TestParseShipClasses(t *testing.T) {
if got, want := float64(bow.Drive), 74.77; got != want {
t.Errorf("Bow105.Drive = %v, want %v", got, want)
}
if got, want := len(rep.OtherShipClass), 1; got != want {
t.Fatalf("len(OtherShipClass) = %d, want %d", got, want)
}
dragon := rep.OtherShipClass[0]
if dragon.Race != "Monstrai" || dragon.Name != "Dragon" || dragon.Armament != 1 {
t.Errorf("Dragon = (%q, %q, %d), want (Monstrai, Dragon, 1)",
dragon.Race, dragon.Name, dragon.Armament)
}
if got, want := float64(dragon.Mass), 19.80; got != want {
t.Errorf("Dragon.Mass = %v, want %v", got, want)
}
}
func TestParseSkipsBattlesAndBombings(t *testing.T) {
// TestParseSciences covers both "Your Sciences" and "<Race> Sciences"
// in one fixture. The five-column layout (N D W S C) is shared.
func TestParseSciences(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Your Sciences",
"",
"N D W S C",
"_TerraForm 1 0 0 0",
"BalancedMix 0.5 0.2 0.2 0.1",
"",
"Pahanchiks Sciences",
"",
"N D W S C",
"_Drift 1 0 0 0",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.LocalScience), 2; got != want {
t.Fatalf("len(LocalScience) = %d, want %d", got, want)
}
mix := rep.LocalScience[1]
if mix.Name != "BalancedMix" {
t.Errorf("LocalScience[1].Name = %q, want BalancedMix", mix.Name)
}
if float64(mix.Drive) != 0.5 || float64(mix.Cargo) != 0.1 {
t.Errorf("BalancedMix (Drive, Cargo) = (%v, %v), want (0.5, 0.1)",
float64(mix.Drive), float64(mix.Cargo))
}
if got, want := len(rep.OtherScience), 1; got != want {
t.Fatalf("len(OtherScience) = %d, want %d", got, want)
}
drift := rep.OtherScience[0]
if drift.Race != "Pahanchiks" || drift.Name != "_Drift" {
t.Errorf("OtherScience[0] = (%q, %q), want (Pahanchiks, _Drift)",
drift.Race, drift.Name)
}
}
// TestParseBombings covers a wiped row + a damaged row + the duplicate
// `P` column header (population vs production string) — assertions
// hit every wire field so a positional-index slip is caught.
func TestParseBombings(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Bombings",
"",
"W O # N P I P $ M C A",
"Knights Ricksha 20 DW-1207 1.56 0.00 Dron 0.00 0.00 0.00 7.62 Wiped",
"Knights Ricksha 332 PEHKE 500.00 258.64 Dron 184.39 0.00 6.42 331.93 Damaged",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.Bombing), 2; got != want {
t.Fatalf("len(Bombing) = %d, want %d", got, want)
}
wiped := rep.Bombing[0]
if !wiped.Wiped {
t.Errorf("Bombing[0].Wiped = false, want true")
}
if wiped.Attacker != "Knights" || wiped.Owner != "Ricksha" || wiped.Number != 20 {
t.Errorf("Bombing[0] head = (%q, %q, %d), want (Knights, Ricksha, 20)",
wiped.Attacker, wiped.Owner, wiped.Number)
}
if wiped.Planet != "DW-1207" || wiped.Production != "Dron" {
t.Errorf("Bombing[0] (planet, production) = (%q, %q), want (DW-1207, Dron)",
wiped.Planet, wiped.Production)
}
if float64(wiped.AttackPower) != 7.62 {
t.Errorf("Bombing[0].AttackPower = %v, want 7.62", float64(wiped.AttackPower))
}
damaged := rep.Bombing[1]
if damaged.Wiped {
t.Errorf("Bombing[1].Wiped = true, want false (Damaged)")
}
if float64(damaged.Capital) != 184.39 || float64(damaged.Colonists) != 6.42 {
t.Errorf("Bombing[1] (capital, colonists) = (%v, %v), want (184.39, 6.42)",
float64(damaged.Capital), float64(damaged.Colonists))
}
}
// TestParseShipsInProduction covers the prod_used derivation through
// [calc.ShipBuildCost]. The producing planet is mounted first with a
// non-zero material stockpile so the farming term contributes a
// non-trivial slice of totalCost; the expected prod_used number is
// derived from totalCost * percent with the same formula the parser
// uses.
func TestParseShipsInProduction(t *testing.T) {
// Planet: Material=0.68, Resources=10.00.
// Ship: cost=990.10 -> shipMass=99.01.
// totalCost = ShipProductionCost(99.01) + max(0, 99.01-0.68)/10
// = 990.10 + 9.833
// = 999.933
// prod_used = 999.933 * 0.07 (percent) ≈ 69.99531
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Your Planets",
"",
" # X Y N S P I R P $ M C L",
" 17 171.05 700.24 Castle 1000.00 1000.00 1000.00 10.00 CombatFlame 0.00 0.68 88.78 1000.00",
"",
"Ships In Production",
"",
" # N S C P L",
" 17 Castle CombatFlame 990.10 0.07 1000.00",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.ShipProduction), 1; got != want {
t.Fatalf("len(ShipProduction) = %d, want %d", got, want)
}
sp := rep.ShipProduction[0]
if sp.Planet != 17 || sp.Class != "CombatFlame" {
t.Errorf("ShipProduction[0] = (planet=%d, class=%q), want (17, CombatFlame)",
sp.Planet, sp.Class)
}
if got := float64(sp.Cost); got != 990.10 {
t.Errorf("ShipProduction[0].Cost = %v, want 990.10", got)
}
if got := float64(sp.Percent); got != 0.07 {
t.Errorf("ShipProduction[0].Percent = %v, want 0.07", got)
}
if got := float64(sp.Free); got != 1000.0 {
t.Errorf("ShipProduction[0].Free = %v, want 1000", got)
}
wantProdUsed := 69.995
if got := float64(sp.ProdUsed); got < wantProdUsed-0.01 || got > wantProdUsed+0.01 {
t.Errorf("ShipProduction[0].ProdUsed = %v, want ~%v (totalCost * percent)",
got, wantProdUsed)
}
}
// TestParseShipsInProductionDropsUnknownPlanet exercises the safety
// net: a ships-in-production row referencing a planet not seen in
// "Your Planets" is dropped, because the prod_used derivation needs
// the planet's material and resources.
func TestParseShipsInProductionDropsUnknownPlanet(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Ships In Production",
"",
" # N S C P L",
" 99 Lost Frigate 100.00 0.05 500.00",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.ShipProduction), 0; got != want {
t.Errorf("len(ShipProduction) = %d, want %d (planet #99 missing → drop)",
got, want)
}
}
// TestParseSkipsBattles covers the only remaining legacy section the
// parser ignores: "Battle at ..." headers and the following "Battle
// Protocol" block. Bombings, Ships In Production, and the per-race
// Sciences / Ship Types blocks now flow through real parsers; the
// dedicated section tests below cover them.
func TestParseSkipsBattles(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
@@ -225,10 +409,6 @@ func TestParseSkipsBattlesAndBombings(t *testing.T) {
"",
"Foo fires on Bar : Destroyed",
"",
"Bombings",
"",
"# data line",
"",
"Your Planets",
"",
" # X Y N S P I R P $ M C L",
@@ -240,7 +420,10 @@ func TestParseSkipsBattlesAndBombings(t *testing.T) {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.LocalPlanet), 1; got != want {
t.Fatalf("len(LocalPlanet) = %d, want %d (battle/bombing rows must not leak in)", got, want)
t.Fatalf("len(LocalPlanet) = %d, want %d (battle rows must not leak in)", got, want)
}
if got, want := len(rep.Battle), 0; got != want {
t.Errorf("len(Battle) = %d, want %d (legacy parser does not synthesise battle UUIDs)", got, want)
}
}
@@ -404,14 +587,16 @@ func TestParseIncomingGroups(t *testing.T) {
// --- smoke tests -----------------------------------------------------
type smokeWant struct {
race string
turn uint
mapW, mapH, planetCount uint32
voteFor string
votes float64
players, extinct, local, other int
uninhabited, unidentified, shipClasses int
localGroups, localFleets, incomingGroups int
race string
turn uint
mapW, mapH, planetCount uint32
voteFor string
votes float64
players, extinct, local, other int
uninhabited, unidentified, shipClasses int
localGroups, localFleets, incomingGroups int
localScience, otherScience, otherShipClass int
bombings, shipProductions int
}
func runSmoke(t *testing.T, path string, want smokeWant) {
@@ -457,6 +642,11 @@ func runSmoke(t *testing.T, path string, want smokeWant) {
{"LocalGroup", len(rep.LocalGroup), want.localGroups},
{"LocalFleet", len(rep.LocalFleet), want.localFleets},
{"IncomingGroup", len(rep.IncomingGroup), want.incomingGroups},
{"LocalScience", len(rep.LocalScience), want.localScience},
{"OtherScience", len(rep.OtherScience), want.otherScience},
{"OtherShipClass", len(rep.OtherShipClass), want.otherShipClass},
{"Bombing", len(rep.Bombing), want.bombings},
{"ShipProduction", len(rep.ShipProduction), want.shipProductions},
}
for _, c := range checks {
if c.got != c.want {
@@ -477,10 +667,15 @@ func TestParseDgKNNTS039(t *testing.T) {
voteFor: "KnightErrants", votes: 16.02,
players: 91, extinct: 49,
local: 22, other: 89, uninhabited: 17, unidentified: 572,
shipClasses: 24,
localGroups: 171,
localFleets: 0,
incomingGroups: 0,
shipClasses: 24,
localGroups: 171,
localFleets: 0,
incomingGroups: 0,
localScience: 1,
otherScience: 1,
otherShipClass: 170,
bombings: 16,
shipProductions: 6,
})
}
@@ -490,10 +685,15 @@ func TestParseDgKNNTS040(t *testing.T) {
mapW: 800, mapH: 800, planetCount: 700,
players: 91, extinct: 49,
local: 22, other: 93, uninhabited: 27, unidentified: 558,
shipClasses: 34,
localGroups: 207,
localFleets: 0,
incomingGroups: 0,
shipClasses: 34,
localGroups: 207,
localFleets: 0,
incomingGroups: 0,
localScience: 1,
otherScience: 1,
otherShipClass: 160,
bombings: 24,
shipProductions: 16,
})
}
@@ -506,10 +706,15 @@ func TestParseDgKNNTS041(t *testing.T) {
mapW: 800, mapH: 800, planetCount: 700,
players: 91, extinct: 50,
local: 29, other: 103, uninhabited: 23, unidentified: 545,
shipClasses: 36,
localGroups: 285,
localFleets: 0,
incomingGroups: 12,
shipClasses: 36,
localGroups: 285,
localFleets: 0,
incomingGroups: 12,
localScience: 1,
otherScience: 1,
otherShipClass: 218,
bombings: 12,
shipProductions: 22,
})
}
@@ -522,10 +727,15 @@ func TestParseGplus40(t *testing.T) {
mapW: 350, mapH: 350, planetCount: 300,
players: 26, extinct: 0,
local: 26, other: 116, uninhabited: 7, unidentified: 151,
shipClasses: 56,
localGroups: 255,
localFleets: 1,
incomingGroups: 10,
shipClasses: 56,
localGroups: 255,
localFleets: 1,
incomingGroups: 10,
localScience: 0,
otherScience: 0,
otherShipClass: 183,
bombings: 4,
shipProductions: 8,
})
}
@@ -538,10 +748,15 @@ func TestParseDgKiller031(t *testing.T) {
mapW: 250, mapH: 250, planetCount: 175,
players: 25, extinct: 12,
local: 18, other: 127, uninhabited: 20, unidentified: 10,
shipClasses: 11,
localGroups: 175,
localFleets: 2,
incomingGroups: 0,
shipClasses: 11,
localGroups: 175,
localFleets: 2,
incomingGroups: 0,
localScience: 0,
otherScience: 0,
otherShipClass: 161,
bombings: 18,
shipProductions: 0,
})
}
@@ -555,10 +770,15 @@ func TestParseDgTancordia037(t *testing.T) {
mapW: 210, mapH: 210, planetCount: 140,
players: 18, extinct: 7,
local: 23, other: 62, uninhabited: 26, unidentified: 29,
shipClasses: 40,
localGroups: 311,
localFleets: 30,
incomingGroups: 2,
shipClasses: 40,
localGroups: 311,
localFleets: 30,
incomingGroups: 2,
localScience: 1,
otherScience: 1,
otherShipClass: 123,
bombings: 22,
shipProductions: 20,
})
}