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:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user