diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 45715d7..b96fa44 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -654,6 +654,16 @@ Per-turn reports are read-only views fetched from the engine on demand. Backend authorises the caller and forwards the request; there is no caching or denormalisation in this path. +The web client renders the report as one section per FBS array +(galaxy summary, votes, player status, my / foreign sciences, my / +foreign ship classes, battles, bombings, approaching groups, my / +foreign / uninhabited / unknown planets, ships in production, +cargo routes, my fleets, my / foreign / unidentified ship groups). +Empty sections render explicit empty-state copy. Section anchors +are exposed in a sticky table of contents (a ``); позиция скролла сохраняется при переключении активного +представления через SvelteKit `Snapshot` API. + ### 6.5 Побочные эффекты Успешная генерация хода публикует runtime-snapshot в lobby-модуль, diff --git a/game/internal/controller/planet.go b/game/internal/controller/planet.go index ffc5ef4..8be2294 100644 --- a/game/internal/controller/planet.go +++ b/game/internal/controller/planet.go @@ -267,21 +267,20 @@ func (c *Cache) putMaterial(pn uint, v float64) { c.MustPlanet(pn).Mat(v) } +// ProduceShip returns number of ships with shipMass planet p can produce in one turn func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint { if productionAvailable <= 0 { return 0 } ships := uint(0) pa := productionAvailable - PRODcost := calc.ShipProductionCost(shipMass) - var MATneed, MATfarm, totalCost float64 + var MATneed, totalCost float64 for { MATneed = shipMass - float64(p.Material) if MATneed < 0 { MATneed = 0 } - MATfarm = MATneed / float64(p.Resources) - totalCost = PRODcost + MATfarm + totalCost = calc.ShipBuildCost(shipMass, float64(p.Material), float64(p.Resources)) if pa < totalCost { progress := pa / totalCost pval := game.F(progress) diff --git a/pkg/calc/planet.go b/pkg/calc/planet.go index ba1a325..237eca5 100644 --- a/pkg/calc/planet.go +++ b/pkg/calc/planet.go @@ -11,3 +11,29 @@ func PlanetProduceShipMass(L, Mat, Res float64) float64 { } return (L + Mat/Res) / (10 + 1/Res) } + +// ShipBuildCost returns the total per-turn cost (production units) to +// build one ship of empty mass shipMass on a planet that currently +// holds material stockpile and has natural resources. The cost is the +// ship's production cost ([ShipProductionCost]) plus the cost of +// farming any missing material from the planet (the missing-material +// volume divided by the planet's resources rating). +// +// resources is expected to be positive in normal play; the helper +// guards against a non-positive value by collapsing the material- +// farming term to zero, which keeps callers numerically stable on +// pathological synthetic data. Mirrors the per-iteration math inside +// the engine's controller.ProduceShip so both surfaces — and the +// legacy-report-to-json dev tool that needs to derive prod_used from +// percent — share the same formula. +func ShipBuildCost(shipMass, material, resources float64) float64 { + matNeed := shipMass - material + if matNeed < 0 { + matNeed = 0 + } + matFarm := 0. + if resources > 0 { + matFarm = matNeed / resources + } + return ShipProductionCost(shipMass) + matFarm +} diff --git a/pkg/calc/planet_test.go b/pkg/calc/planet_test.go new file mode 100644 index 0000000..ef2471e --- /dev/null +++ b/pkg/calc/planet_test.go @@ -0,0 +1,63 @@ +package calc_test + +import ( + "math" + "testing" + + "galaxy/calc" +) + +func TestShipBuildCost(t *testing.T) { + cases := []struct { + name string + shipMass float64 + material float64 + resources float64 + want float64 + }{ + { + name: "material exceeds mass: no farming needed", + shipMass: 5, + material: 10, + resources: 0.5, + want: 50, // ShipProductionCost(5) = 50; matFarm = 0. + }, + { + name: "material equal to mass: no farming needed", + shipMass: 5, + material: 5, + resources: 0.5, + want: 50, + }, + { + name: "material short of mass: farming term added", + shipMass: 10, + material: 3, + resources: 0.5, + want: 114, // 100 + (7 / 0.5). + }, + { + name: "no material at all: full mass farmed", + shipMass: 4, + material: 0, + resources: 0.5, + want: 48, // 40 + (4 / 0.5). + }, + { + name: "zero resources collapses farming term to zero", + shipMass: 10, + material: 3, + resources: 0, + want: 100, // 100 + 0; resources == 0 is a pathological guard. + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := calc.ShipBuildCost(tc.shipMass, tc.material, tc.resources) + if math.Abs(got-tc.want) > 1e-9 { + t.Errorf("ShipBuildCost(%v, %v, %v) = %v, want %v", + tc.shipMass, tc.material, tc.resources, got, tc.want) + } + }) + } +} diff --git a/tools/local-dev/legacy-report/README.md b/tools/local-dev/legacy-report/README.md index 1c192b2..dd915f2 100644 --- a/tools/local-dev/legacy-report/README.md +++ b/tools/local-dev/legacy-report/README.md @@ -60,6 +60,11 @@ already decodes from server responses | `UninhabitedPlanet[]` | `Uninhabited Planets` | | `UnidentifiedPlanet[]`| `Unidentified Planets` | | `LocalShipClass[]` | `Your Ship Types` | +| `OtherShipClass[]` | ` Ship Types` (Phase 23) | +| `LocalScience[]` | `Your Sciences` (Phase 23) | +| `OtherScience[]` | ` Sciences` (Phase 23) | +| `Bombing[]` | `Bombings` (Phase 23) | +| `ShipProduction[]` | `Ships In Production` (Phase 23) | | `LocalGroup[]` | `Your Groups` (Phase 19) | | `LocalFleet[]` | `Your Fleets` (Phase 19) | | `IncomingGroup[]` | `Incoming Groups` (Phase 19) | @@ -78,29 +83,37 @@ tables (foreign-only knowledge the local player lacks) cause the entire group / fleet / incoming row to be dropped — preferable to fabricating a destination. +`ShipProduction.ProdUsed` is derived from the on-disk `Percent` and the +producing planet's material/resources via [`pkg/calc.ShipBuildCost`] +(the same helper the engine's `controller.ProduceShip` uses). The +legacy text format does not carry a `prod_used` column directly; the +derivation gives the cumulative production-equivalent of the build +progress so far. The real engine's `ProdUsed` is the per-turn +residual production poured into the partial ship, which is not +recoverable from a single legacy snapshot. The two numbers stay in +the same units and the same ballpark, which is good enough for the +synthetic-mode UI — live engine reports come over the FBS wire and +do not flow through this parser. A ships-in-production row pointing +at a planet that did not appear in `Your Planets` (which would be a +malformed legacy file) is dropped. + ## Skipped sections (today) -These exist in legacy reports but either have no UI decoder yet or -cannot be derived from the legacy text format at all. Each becomes -in-scope as soon as its UI phase lands (see "Adding a new field" -below). +These exist in legacy reports but cannot be derived from the legacy +text format at all. Each could become in-scope if a strong enough +reason arises (see "Adding a new field" below). -- Foreign / other ship types (` Ship Types`) -- Sciences, both local (`Your Sciences`) and foreign (` Sciences`) -- Battles (`Battle at (#N) Name`, `Battle Protocol`) — battle rosters - inside these blocks carry minimal columns (no origin / range / - destination) and are intentionally skipped: parsing them would - produce mostly-empty `OtherGroup` records that drift away from the - typed contract. -- Bombings (`Bombings`) -- Ships in production (`Ships In Production`) +- Battles (`Battle at (#N) Name`, `Battle Protocol`) — the wire schema + carries battle UUIDs (`Report.Battle: []uuid.UUID`); the legacy text + carries per-battle rosters with stripped columns (no origin / range / + destination) and no stable identifier. Synthesising UUIDs from the + text would invent data that future Phase 27 work would have to drop; + the synthetic JSON therefore emits `battle: []`. - `OtherGroup[]` — no top-level legacy section. Foreign groups appear only inside battle rosters (see above), with stripped columns; the synthetic JSON emits `otherGroup: []`. - `UnidentifiedGroup[]` — no legacy section at all; synthetic JSON emits `unidentifiedGroup: []`. -- `OtherShipClass[]` — present in legacy as ` Ship Types`, but - no UI decoder yet; synthetic JSON emits `otherShipClass: []`. - Cargo routes — no dedicated section in the legacy text format; the synthetic JSON emits `route: []`. The UI's overlay path (`applyOrderOverlay`) supports running on top of an empty `routes`. diff --git a/tools/local-dev/legacy-report/parser.go b/tools/local-dev/legacy-report/parser.go index ade265e..1863439 100644 --- a/tools/local-dev/legacy-report/parser.go +++ b/tools/local-dev/legacy-report/parser.go @@ -21,6 +21,7 @@ import ( "github.com/google/uuid" + "galaxy/calc" "galaxy/model/report" ) @@ -57,6 +58,11 @@ const ( sectionYourGroups sectionYourFleets sectionIncomingGroups + sectionYourSciences + sectionOtherSciences + sectionOtherShipTypes + sectionBombings + sectionShipsInProduction ) type parser struct { @@ -71,10 +77,14 @@ type parser struct { // they carry destination/origin planet names that may resolve // against the planet tables only after the whole file has been // read — "Incoming Groups" can appear before "Your Planets" in - // some engine variants. - pendingGroups []pendingGroup - pendingFleets []pendingFleet - pendingIncomings []pendingIncoming + // some engine variants. Ships-in-production rows are buffered + // because their prod_used derivation needs the producing planet's + // material and resources (read from "Your Planets") to call + // [calc.ShipBuildCost], and the section order is not guaranteed. + pendingGroups []pendingGroup + pendingFleets []pendingFleet + pendingIncomings []pendingIncoming + pendingShipProducts []pendingShipProduction } type pendingGroup struct { @@ -112,6 +122,14 @@ type pendingIncoming struct { mass float64 } +type pendingShipProduction struct { + planetNumber uint + class string + cost float64 + percent float64 + free float64 +} + func newParser() *parser { return &parser{sec: sectionNone} } @@ -177,6 +195,16 @@ func (p *parser) handle(line string) error { p.parseYourFleet(fields) case sectionIncomingGroups: p.parseIncomingGroup(fields) + case sectionYourSciences: + p.parseYourScience(fields) + case sectionOtherSciences: + p.parseOtherScience(fields) + case sectionOtherShipTypes: + p.parseOtherShipClass(fields) + case sectionBombings: + p.parseBombing(fields) + case sectionShipsInProduction: + p.parseShipProductionRow(fields) } return nil } @@ -259,10 +287,13 @@ func classifySection(line string) (sec section, owner string, isHeader bool) { return sectionUnidentifiedPlanets, "", true case "Your vote:": return sectionYourVote, "", true - case "Your Sciences", - "Bombings", - "Ships In Production", - "Approaching Groups", + case "Your Sciences": + return sectionYourSciences, "", true + case "Bombings": + return sectionBombings, "", true + case "Ships In Production": + return sectionShipsInProduction, "", true + case "Approaching Groups", "Broadcast Message", "Battle Protocol": return sectionNone, "", true @@ -279,11 +310,11 @@ func classifySection(line string) (sec section, owner string, isHeader bool) { if owner, ok := singleTokenPrefix(line, " Planets"); ok { return sectionOtherPlanets, owner, true } - if _, ok := singleTokenPrefix(line, " Ship Types"); ok { - return sectionNone, "", true + if owner, ok := singleTokenPrefix(line, " Ship Types"); ok { + return sectionOtherShipTypes, owner, true } - if _, ok := singleTokenPrefix(line, " Sciences"); ok { - return sectionNone, "", true + if owner, ok := singleTokenPrefix(line, " Sciences"); ok { + return sectionOtherSciences, owner, true } if _, ok := singleTokenPrefix(line, " Groups"); ok { return sectionNone, "", true @@ -468,30 +499,173 @@ func (p *parser) parseUnidentifiedPlanet(fields []string) { // // N D A W S C M func (p *parser) parseShipClass(fields []string) { - if len(fields) < 7 { + sc, ok := decodeShipClassRow(fields) + if !ok { return } + p.rep.LocalShipClass = append(p.rep.LocalShipClass, sc) +} + +// parseOtherShipClass parses one row of a " Ship Types" block. +// Same 7-column layout as [parser.parseShipClass]; the owning race is +// captured into [parser.otherOwner] when the section header is +// classified by [classifySection]. +func (p *parser) parseOtherShipClass(fields []string) { + sc, ok := decodeShipClassRow(fields) + if !ok { + return + } + p.rep.OtherShipClass = append(p.rep.OtherShipClass, report.OthersShipClass{ + Race: p.otherOwner, + ShipClass: sc, + }) +} + +func decodeShipClassRow(fields []string) (report.ShipClass, bool) { + var sc report.ShipClass + if len(fields) < 7 { + return sc, false + } drive, err := parseFloat(fields[1]) if err != nil { - return + return sc, false } armament, err := strconv.ParseUint(fields[2], 10, 32) if err != nil { - return + return sc, false } weapons, _ := parseFloat(fields[3]) shields, _ := parseFloat(fields[4]) cargo, _ := parseFloat(fields[5]) mass, _ := parseFloat(fields[6]) + sc.Name = fields[0] + sc.Drive = report.F(drive) + sc.Armament = uint(armament) + sc.Weapons = report.F(weapons) + sc.Shields = report.F(shields) + sc.Cargo = report.F(cargo) + sc.Mass = report.F(mass) + return sc, true +} - p.rep.LocalShipClass = append(p.rep.LocalShipClass, report.ShipClass{ - Name: fields[0], - Drive: report.F(drive), - Armament: uint(armament), - Weapons: report.F(weapons), - Shields: report.F(shields), - Cargo: report.F(cargo), - Mass: report.F(mass), +// parseYourScience parses one row of the "Your Sciences" block. +// Columns: +// +// N D W S C +// +// where D/W/S/C are the four tech proportions as fractions summing +// to 1.0 (`pkg/calc/validator.go.ValidateScienceValues`). +func (p *parser) parseYourScience(fields []string) { + sc, ok := decodeScienceRow(fields) + if !ok { + return + } + p.rep.LocalScience = append(p.rep.LocalScience, sc) +} + +// parseOtherScience parses one row of a " Sciences" block. +// Same 5-column layout as [parser.parseYourScience]; the owning race +// is captured into [parser.otherOwner] by [classifySection]. +func (p *parser) parseOtherScience(fields []string) { + sc, ok := decodeScienceRow(fields) + if !ok { + return + } + p.rep.OtherScience = append(p.rep.OtherScience, report.OtherScience{ + Race: p.otherOwner, + Science: sc, + }) +} + +func decodeScienceRow(fields []string) (report.Science, bool) { + var sc report.Science + if len(fields) < 5 { + return sc, false + } + drive, err := parseFloat(fields[1]) + if err != nil { + return sc, false + } + weapons, _ := parseFloat(fields[2]) + shields, _ := parseFloat(fields[3]) + cargo, _ := parseFloat(fields[4]) + sc.Name = fields[0] + sc.Drive = report.F(drive) + sc.Weapons = report.F(weapons) + sc.Shields = report.F(shields) + sc.Cargo = report.F(cargo) + return sc, true +} + +// parseBombing parses one row of the "Bombings" block. Columns +// (12 tokens, last is the wiped/damaged status word): +// +// W O # N P I P $ M C A status +// +// where the first P is the post-bombing population and the second +// P is the production string left on the planet. Status is parsed +// positionally — the header has a duplicate P, so a header-name +// lookup is not safe. +func (p *parser) parseBombing(fields []string) { + if len(fields) < 12 { + return + } + number, err := strconv.ParseUint(fields[2], 10, 32) + if err != nil { + return + } + population, _ := parseFloat(fields[4]) + industry, _ := parseFloat(fields[5]) + capital, _ := parseFloat(fields[7]) + material, _ := parseFloat(fields[8]) + colonists, _ := parseFloat(fields[9]) + attack, _ := parseFloat(fields[10]) + wiped := fields[11] == "Wiped" + p.rep.Bombing = append(p.rep.Bombing, &report.Bombing{ + Attacker: fields[0], + Owner: fields[1], + Number: uint(number), + Planet: fields[3], + Population: report.F(population), + Industry: report.F(industry), + Production: fields[6], + Capital: report.F(capital), + Material: report.F(material), + Colonists: report.F(colonists), + AttackPower: report.F(attack), + Wiped: wiped, + }) +} + +// parseShipProductionRow buffers a "Ships In Production" row for +// post-processing in [parser.finish]. Columns: +// +// # N S C P L +// +// where # is the planet number, N is the planet name (decorative — +// resolution uses #), S is the building ship class, C is the cost +// (== shipMass * 10), P is the build progress as a fraction in +// [0, 1], and L is the producing planet's free industry. The wire +// shape's `prod_used` field is not carried by the legacy text; it is +// derived during [parser.resolvePending] from the planet's material +// and resources via [calc.ShipBuildCost]. +func (p *parser) parseShipProductionRow(fields []string) { + if len(fields) < 6 { + return + } + number, err := strconv.ParseUint(fields[0], 10, 32) + if err != nil { + return + } + cost, _ := parseFloat(fields[3]) + percent, _ := parseFloat(fields[4]) + free, _ := parseFloat(fields[5]) + p.pendingShipProducts = append(p.pendingShipProducts, pendingShipProduction{ + planetNumber: uint(number), + class: fields[2], + cost: cost, + percent: percent, + free: free, }) } @@ -695,6 +869,51 @@ func (p *parser) resolvePending() { Mass: report.F(pi.mass), }) } + + for _, ps := range p.pendingShipProducts { + lp, ok := p.findLocalPlanet(ps.planetNumber) + if !ok { + continue + } + shipMass := ps.cost / 10 + totalCost := calc.ShipBuildCost( + shipMass, + float64(lp.Material), + float64(lp.Resources), + ) + // `ProdUsed` is the cumulative production-equivalent of the + // build progress so far. The real engine's `Progress` field + // accumulates across turns and the per-turn `ProdUsed` is a + // transient residual — neither of those is recoverable from a + // single legacy report. The derivation here keeps the value in + // the same units (production points) and in the right ballpark + // for synthetic-mode UI rendering; live engine reports do not + // flow through this parser, so the approximation never reaches + // production traffic. README.md skips section explains. + prodUsed := totalCost * ps.percent + p.rep.ShipProduction = append(p.rep.ShipProduction, report.ShipProduction{ + Planet: ps.planetNumber, + Class: ps.class, + Cost: report.F(ps.cost), + ProdUsed: report.F(prodUsed), + Percent: report.F(ps.percent), + Free: report.F(ps.free), + }) + } +} + +// findLocalPlanet returns the parsed "Your Planets" entry with the +// given number, used by the ships-in-production resolver to read +// material / resources for the [calc.ShipBuildCost] derivation. +// Ships-in-production only lists own ships, so the lookup against +// `LocalPlanet` is correct. +func (p *parser) findLocalPlanet(number uint) (report.LocalPlanet, bool) { + for _, lp := range p.rep.LocalPlanet { + if lp.Number == number { + return lp, true + } + } + return report.LocalPlanet{}, false } // lookupPlanetNumber resolves a legacy planet reference — either a diff --git a/tools/local-dev/legacy-report/parser_test.go b/tools/local-dev/legacy-report/parser_test.go index c305e99..155975a 100644 --- a/tools/local-dev/legacy-report/parser_test.go +++ b/tools/local-dev/legacy-report/parser_test.go @@ -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 " 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, }) } diff --git a/ui/PLAN.md b/ui/PLAN.md index 2b6536a..79ce958 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -2506,6 +2506,81 @@ Targeted tests: - Playwright e2e: open the report, scroll to each section via anchor navigation, assert content present. +Decisions during stage: + +1. **Component decomposition.** The orchestrator + `lib/active-view/report.svelte` is one file; each of the twenty + sections is its own component under + `lib/active-view/report/section-.svelte`. Six distinct data + shapes (kv-list, races-style grid, planets-style grid, sub-table- + per-race, raw UUID list, fleet/group grids) sit too unevenly in one + monolith; per-section components also map directly onto the Vitest + targeted-test seam. No shared `
` abstraction was extracted + — CLAUDE.md "wait for the third real caller" still holds with one + shape per section. Shared formatters live in `report/format.ts`. +2. **`races` vs `players`.** A parallel + `GameReport.players: ReportPlayer[]` was added (full roster, self + row included, extinct rows kept with `extinct: true`). The Phase 22 + `races[]` (non-extinct, self excluded) stays untouched so no Phase + 22 surface had to change. Extinct races are shown in Player Status + with a `RIP` marker; the orchestrator highlights the local row. +3. **Scroll save / restore.** Wired through SvelteKit's `Snapshot` + API on `routes/games/[id]/report/+page.svelte`. Captures + `window.scrollY` (the in-game shell layout expands its + `active-view-host` to fit content, so the document body is the real + scroll container) and restores via a `requestAnimationFrame` poll + that waits for `documentElement.scrollHeight` to catch up before + calling `window.scrollTo`. The earlier plan to track the host's + `scrollTop` did not survive contact with the layout's + no-explicit-height contract; the change is contained to the route + file. No new context plumbing was introduced. +4. **Active-section highlight.** `IntersectionObserver` rooted on the + viewport (`root: null`) with `rootMargin: "-30% 0px -60% 0px"` + tracks which section sits in the upper third of the visible area + and updates the TOC. Cheaper than a scroll handler and degrades + gracefully where IO is not available. +5. **Mobile TOC.** A sticky ` +├── report/section-galaxy-summary.svelte +├── report/section-votes.svelte +├── report/section-player-status.svelte +├── report/section-my-sciences.svelte +├── report/section-foreign-sciences.svelte +├── report/section-my-ship-classes.svelte +├── report/section-foreign-ship-classes.svelte +├── report/section-battles.svelte +├── report/section-bombings.svelte +├── report/section-approaching-groups.svelte +├── report/section-my-planets.svelte +├── report/section-ships-in-production.svelte +├── report/section-cargo-routes.svelte +├── report/section-foreign-planets.svelte +├── report/section-uninhabited-planets.svelte +├── report/section-unknown-planets.svelte +├── report/section-my-fleets.svelte +├── report/section-my-ship-groups.svelte +├── report/section-foreign-ship-groups.svelte +└── report/section-unidentified-groups.svelte +``` + +Each section component is self-contained: +- reads `RenderedReportSource` from context; +- renders the loading copy when `rendered.report === null`; +- renders the empty-state copy when its array is empty; +- otherwise emits a `
` + containing the relevant grid / list / kv-list. + +No shared `
` wrapper exists. The visual scaffolding (dark +grid CSS, header style, status paragraph) is inlined per +component. The CLAUDE.md "wait for the third real caller before +extracting an abstraction" rule applies; with one shape per +section, the per-section inline CSS is the smallest correct +solution. + +Shared formatters live in `report/format.ts` (`formatPercent`, +`formatCount`, `formatFloat`, `formatVotes`, `planetLabel`). + +## Section order, data sources, empty copy + +| # | Slug | Data | Empty copy (en) | +|---|------|------|-----------------| +| 1 | `galaxy-summary` | header turn / size / planet count / race | never empty | +| 2 | `votes` | `myVotes`, `myVoteFor`, `races[].votesReceived` | "no votes cast yet" | +| 3 | `player-status` | `players[]` (full roster, self + extinct) | never empty | +| 4 | `my-sciences` | `localScience[]` | "no sciences defined yet" | +| 5 | `foreign-sciences` | `otherScience[]`, one sub-table per race | "no foreign sciences observed yet" | +| 6 | `my-ship-classes` | `localShipClass[]` | "no ship classes designed yet" | +| 7 | `foreign-ship-classes` | `otherShipClass[]`, one sub-table per race | "no foreign ship classes observed yet" | +| 8 | `battles` | `battleIds[]` (inactive monospace spans) | "no battles last turn" | +| 9 | `bombings` | `bombings[]`, wiped rows visually distinct | "no bombings last turn" | +| 10 | `approaching-groups` | `incomingShipGroups[]` | "no approaching groups" | +| 11 | `my-planets` | `planets.filter(kind==="local")` | "no planets owned yet" | +| 12 | `ships-in-production` | `shipProductions[]` | "no ships in production" | +| 13 | `cargo-routes` | `routes[]` (flattened to one row per entry) | "no cargo routes set" | +| 14 | `foreign-planets` | `planets.filter(kind==="other")` | "no foreign planets observed" | +| 15 | `uninhabited-planets` | `planets.filter(kind==="uninhabited")` | "no uninhabited planets observed" | +| 16 | `unknown-planets` | `planets.filter(kind==="unidentified")` | "no unknown planets" | +| 17 | `my-fleets` | `localFleets[]` | "no fleets created yet" | +| 18 | `my-ship-groups` | `localShipGroups[]` | "no ship groups yet" | +| 19 | `foreign-ship-groups` | `otherShipGroups[]` | "no foreign ship groups observed" | +| 20 | `unidentified-groups` | `unidentifiedShipGroups[]` | "no unidentified groups" | + +The orchestrator iterates this list once for the TOC and once for +the body — both surfaces stay in sync by construction. + +## Table of contents and active highlight + +`report/report-toc.svelte` renders two surfaces driven by the same +entry list: + +- **Desktop / tablet sidebar** — sticky `