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:
@@ -60,6 +60,11 @@ already decodes from server responses
|
||||
| `UninhabitedPlanet[]` | `Uninhabited Planets` |
|
||||
| `UnidentifiedPlanet[]`| `Unidentified Planets` |
|
||||
| `LocalShipClass[]` | `Your Ship Types` |
|
||||
| `OtherShipClass[]` | `<Race> Ship Types` (Phase 23) |
|
||||
| `LocalScience[]` | `Your Sciences` (Phase 23) |
|
||||
| `OtherScience[]` | `<Race> 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 (`<Race> Ship Types`)
|
||||
- Sciences, both local (`Your Sciences`) and foreign (`<Race> 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 `<Race> 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`.
|
||||
|
||||
@@ -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 "<Race> 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 "<Race> 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
|
||||
|
||||
@@ -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