feat(ui+legacy): F8-05 owner-feedback round 1 — inspector tweaks + parser
Owner-reported polish on top of #48, plus a legacy-parser gap that prevented verifying stationed ship groups against a real .REP fixture. UI: - Production: drop the empty `(production)` placeholder option. Owned planets always produce something, so the primary select now opens on `industry` by default when `planet.production` is null/unknown, keeping the row inside the four real production kinds at all times. - Production: lock the row to a single line (no flex-wrap) and strip border + padding from the ✓/✗ buttons so the apply/cancel icons read as glyphs and the row no longer breaks into two visual rows for Research / Ship contexts where both selects are present. - Cargo routes: the placeholder option is now an `<option disabled>` styled like a section header (greyed, italic) and reads "manage routes" instead of "cargo routes". The wording shifts the intent from a section label to an action prompt. Legacy parser: - F8-05 (#48 п.32) "Stationed ship groups" couldn't be verified against the dg fixture because the legacy `<Race> Groups` blocks (outside battles) and the `Unidentified Groups` block were dropped by the parser — both are now wired up. Foreign group rows parse the `# T D W S C T Q D P M` columns and resolve the destination against the parsed planet tables (rows with an invisible destination drop, matching the existing local-group convention). The legacy row carries no origin / range columns, so foreign groups surface as stationed at the destination. - Smoke tests on every fixture extended with `otherGroups` and `unidentifiedGroups` counts. New focused unit test `TestParseOtherAndUnidentifiedGroups` covers the column layout, the drop-on-unknown-destination rule, and the `X Y`-only unidentified rows. - `tools/local-dev/reports/dg/KNNTS039.json` and `tools/local-dev/reports/dg/KNNTS041.json` regenerated so the synthetic-loader fixtures carry the new arrays. - README updated: the two sections move out of "Skipped sections" into a "Foreign and unidentified groups" block; package doc-comment reflects the broader scope. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -129,17 +129,33 @@ 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
|
at a planet that did not appear in `Your Planets` (which would be a
|
||||||
malformed legacy file) is dropped.
|
malformed legacy file) is dropped.
|
||||||
|
|
||||||
|
## Foreign and unidentified groups
|
||||||
|
|
||||||
|
The legacy text format does carry top-level `<Race> Groups` blocks
|
||||||
|
and a single `Unidentified Groups` block, both outside the battle
|
||||||
|
rosters — earlier parser revisions silently dropped them. F8-05
|
||||||
|
wires them up:
|
||||||
|
|
||||||
|
- **`OtherGroup[]`** — every `<Race> Groups` section outside a
|
||||||
|
`Battle at` block contributes one entry per row. The legacy row
|
||||||
|
is `# T D W S C T Q D P M` (count, class, drive/weapons/shields/
|
||||||
|
cargo tech, cargo type, load, destination, power=drive·20 — not
|
||||||
|
retained, mass). The destination resolves against the parsed
|
||||||
|
planet tables (`Your Planets`, `<Race> Planets`, `Uninhabited
|
||||||
|
Planets`); rows whose destination is invisible to the local
|
||||||
|
player are dropped — preferable to fabricating a number. The
|
||||||
|
legacy row carries no origin / range columns, so foreign groups
|
||||||
|
surface as stationed at the destination (origin / range nil).
|
||||||
|
- **`UnidentifiedGroup[]`** — the `Unidentified Groups` section
|
||||||
|
carries `X Y` floats only. Each row maps directly onto
|
||||||
|
`UnidentifiedGroup{X, Y}`; no planet resolution needed.
|
||||||
|
|
||||||
## Skipped sections (today)
|
## Skipped sections (today)
|
||||||
|
|
||||||
These exist in legacy reports but cannot be derived from the legacy
|
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
|
text format at all. Each could become in-scope if a strong enough
|
||||||
reason arises (see "Adding a new field" below).
|
reason arises (see "Adding a new field" below).
|
||||||
|
|
||||||
- `OtherGroup[]` — no top-level legacy section. Foreign groups appear
|
|
||||||
only inside battle rosters; the synthetic JSON emits
|
|
||||||
`otherGroup: []`.
|
|
||||||
- `UnidentifiedGroup[]` — no legacy section at all; synthetic JSON
|
|
||||||
emits `unidentifiedGroup: []`.
|
|
||||||
- Cargo routes — no dedicated section in the legacy text format; the
|
- Cargo routes — no dedicated section in the legacy text format; the
|
||||||
synthetic JSON emits `route: []`. The UI's overlay path
|
synthetic JSON emits `route: []`. The UI's overlay path
|
||||||
(`applyOrderOverlay`) supports running on top of an empty `routes`.
|
(`applyOrderOverlay`) supports running on top of an empty `routes`.
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
// Scope is intentionally narrow: only the fields the UI client decodes
|
// Scope is intentionally narrow: only the fields the UI client decodes
|
||||||
// from server reports today (planets, players, own ship classes,
|
// from server reports today (planets, players, own ship classes,
|
||||||
// header data, plus — added in Phase 19 — own ship groups, own fleets
|
// header data, plus — added in Phase 19 — own ship groups, own fleets
|
||||||
// and incoming groups). Everything else in the legacy file is silently
|
// and incoming groups; and — added in F8-05 — foreign `<Race> Groups`
|
||||||
// skipped. The synthetic-report parity rule in ui/PLAN.md is the
|
// blocks outside battles together with `Unidentified Groups`).
|
||||||
// source of truth for when to extend this parser; the package's
|
// Everything else in the legacy file is silently skipped. The
|
||||||
// README.md tracks every legacy section that could be wired up later
|
// synthetic-report parity rule in ui/PLAN.md is the source of truth
|
||||||
// when the corresponding UI decoder lands.
|
// for when to extend this parser; the package's README.md tracks
|
||||||
|
// every legacy section that could be wired up later when the
|
||||||
|
// corresponding UI decoder lands.
|
||||||
package legacyreport
|
package legacyreport
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -65,6 +67,8 @@ const (
|
|||||||
sectionYourGroups
|
sectionYourGroups
|
||||||
sectionYourFleets
|
sectionYourFleets
|
||||||
sectionIncomingGroups
|
sectionIncomingGroups
|
||||||
|
sectionOtherGroups
|
||||||
|
sectionUnidentifiedGroups
|
||||||
sectionYourSciences
|
sectionYourSciences
|
||||||
sectionOtherSciences
|
sectionOtherSciences
|
||||||
sectionOtherShipTypes
|
sectionOtherShipTypes
|
||||||
@@ -94,6 +98,7 @@ type parser struct {
|
|||||||
pendingFleets []pendingFleet
|
pendingFleets []pendingFleet
|
||||||
pendingIncomings []pendingIncoming
|
pendingIncomings []pendingIncoming
|
||||||
pendingShipProducts []pendingShipProduction
|
pendingShipProducts []pendingShipProduction
|
||||||
|
pendingOtherGroups []pendingOtherGroup
|
||||||
|
|
||||||
// Battle accumulator. `battles` collects every parsed BattleReport;
|
// Battle accumulator. `battles` collects every parsed BattleReport;
|
||||||
// `pendingBattle` carries the in-flight battle until its block
|
// `pendingBattle` carries the in-flight battle until its block
|
||||||
@@ -148,6 +153,25 @@ type pendingGroup struct {
|
|||||||
state string
|
state string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pendingOtherGroup buffers a foreign "<Race> Groups" row outside any
|
||||||
|
// battle block — these are visible to the local player but live on
|
||||||
|
// foreign planets, so the destination resolves against the parsed
|
||||||
|
// planet tables in [parser.resolvePending]. The legacy row carries
|
||||||
|
// no origin / range / fleet columns, so foreign groups are always
|
||||||
|
// treated as stationed at the destination.
|
||||||
|
type pendingOtherGroup struct {
|
||||||
|
count uint
|
||||||
|
class string
|
||||||
|
drive float64
|
||||||
|
weapons float64
|
||||||
|
shields float64
|
||||||
|
cargoTech float64
|
||||||
|
cargoType string
|
||||||
|
load float64
|
||||||
|
destinationName string
|
||||||
|
mass float64
|
||||||
|
}
|
||||||
|
|
||||||
type pendingFleet struct {
|
type pendingFleet struct {
|
||||||
name string
|
name string
|
||||||
groups uint
|
groups uint
|
||||||
@@ -290,6 +314,10 @@ func (p *parser) handle(line string) error {
|
|||||||
p.parseYourFleet(fields)
|
p.parseYourFleet(fields)
|
||||||
case sectionIncomingGroups:
|
case sectionIncomingGroups:
|
||||||
p.parseIncomingGroup(fields)
|
p.parseIncomingGroup(fields)
|
||||||
|
case sectionOtherGroups:
|
||||||
|
p.parseOtherGroup(fields)
|
||||||
|
case sectionUnidentifiedGroups:
|
||||||
|
p.parseUnidentifiedGroup(fields)
|
||||||
case sectionYourSciences:
|
case sectionYourSciences:
|
||||||
p.parseYourScience(fields)
|
p.parseYourScience(fields)
|
||||||
case sectionOtherSciences:
|
case sectionOtherSciences:
|
||||||
@@ -381,6 +409,8 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
|
|||||||
return sectionYourFleets, "", true
|
return sectionYourFleets, "", true
|
||||||
case "Incoming Groups":
|
case "Incoming Groups":
|
||||||
return sectionIncomingGroups, "", true
|
return sectionIncomingGroups, "", true
|
||||||
|
case "Unidentified Groups":
|
||||||
|
return sectionUnidentifiedGroups, "", true
|
||||||
case "Uninhabited Planets":
|
case "Uninhabited Planets":
|
||||||
return sectionUninhabitedPlanets, "", true
|
return sectionUninhabitedPlanets, "", true
|
||||||
case "Unidentified Planets":
|
case "Unidentified Planets":
|
||||||
@@ -417,8 +447,8 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
|
|||||||
if owner, ok := singleTokenPrefix(line, " Sciences"); ok {
|
if owner, ok := singleTokenPrefix(line, " Sciences"); ok {
|
||||||
return sectionOtherSciences, owner, true
|
return sectionOtherSciences, owner, true
|
||||||
}
|
}
|
||||||
if _, ok := singleTokenPrefix(line, " Groups"); ok {
|
if owner, ok := singleTokenPrefix(line, " Groups"); ok {
|
||||||
return sectionNone, "", true
|
return sectionOtherGroups, owner, true
|
||||||
}
|
}
|
||||||
return sectionNone, "", false
|
return sectionNone, "", false
|
||||||
}
|
}
|
||||||
@@ -1067,6 +1097,68 @@ func (p *parser) parseYourFleet(fields []string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseOtherGroup buffers a foreign "<Race> Groups" row. The row's
|
||||||
|
// race is the current `otherOwner` set by classifySection. Columns
|
||||||
|
// (11 fields, last is mass):
|
||||||
|
//
|
||||||
|
// # T D W S C T Q D P M
|
||||||
|
//
|
||||||
|
// where the second T is the cargo type, the second D is the
|
||||||
|
// destination planet name, P is the flight-distance hint (drive*20,
|
||||||
|
// not retained) and M is the mass.
|
||||||
|
func (p *parser) parseOtherGroup(fields []string) {
|
||||||
|
if len(fields) < 11 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count, err := strconv.ParseUint(fields[0], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
drive, _ := parseFloat(fields[2])
|
||||||
|
weapons, _ := parseFloat(fields[3])
|
||||||
|
shields, _ := parseFloat(fields[4])
|
||||||
|
cargoTech, _ := parseFloat(fields[5])
|
||||||
|
load, _ := parseFloat(fields[7])
|
||||||
|
mass, _ := parseFloat(fields[10])
|
||||||
|
|
||||||
|
p.pendingOtherGroups = append(p.pendingOtherGroups, pendingOtherGroup{
|
||||||
|
count: uint(count),
|
||||||
|
class: fields[1],
|
||||||
|
drive: drive,
|
||||||
|
weapons: weapons,
|
||||||
|
shields: shields,
|
||||||
|
cargoTech: cargoTech,
|
||||||
|
cargoType: fields[6],
|
||||||
|
load: load,
|
||||||
|
destinationName: fields[8],
|
||||||
|
mass: mass,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUnidentifiedGroup appends an "Unidentified Groups" row
|
||||||
|
// directly to the report — the legacy format only carries the
|
||||||
|
// floating-point world coordinates of the in-flight blip, so
|
||||||
|
// there is nothing to defer to [parser.finish].
|
||||||
|
//
|
||||||
|
// X Y
|
||||||
|
func (p *parser) parseUnidentifiedGroup(fields []string) {
|
||||||
|
if len(fields) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
x, err := parseFloat(fields[0])
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
y, err := parseFloat(fields[1])
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.rep.UnidentifiedGroup = append(p.rep.UnidentifiedGroup, report.UnidentifiedGroup{
|
||||||
|
X: report.F(x),
|
||||||
|
Y: report.F(y),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// parseIncomingGroup buffers an "Incoming Groups" row. Columns:
|
// parseIncomingGroup buffers an "Incoming Groups" row. Columns:
|
||||||
//
|
//
|
||||||
// O D R S M
|
// O D R S M
|
||||||
@@ -1145,6 +1237,28 @@ func (p *parser) resolvePending() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, pg := range p.pendingOtherGroups {
|
||||||
|
dest, ok := p.lookupPlanetNumber(pg.destinationName)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tech := map[string]report.Float{
|
||||||
|
"drive": report.F(pg.drive),
|
||||||
|
"weapons": report.F(pg.weapons),
|
||||||
|
"shields": report.F(pg.shields),
|
||||||
|
"cargo": report.F(pg.cargoTech),
|
||||||
|
}
|
||||||
|
p.rep.OtherGroup = append(p.rep.OtherGroup, report.OtherGroup{
|
||||||
|
Number: pg.count,
|
||||||
|
Class: pg.class,
|
||||||
|
Tech: tech,
|
||||||
|
Cargo: pg.cargoType,
|
||||||
|
Load: report.F(pg.load),
|
||||||
|
Destination: dest,
|
||||||
|
Mass: report.F(pg.mass),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
for _, pf := range p.pendingFleets {
|
for _, pf := range p.pendingFleets {
|
||||||
dest, ok := p.lookupPlanetNumber(pf.destinationName)
|
dest, ok := p.lookupPlanetNumber(pf.destinationName)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -790,6 +790,87 @@ func TestParseIncomingGroups(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseOtherAndUnidentifiedGroups(t *testing.T) {
|
||||||
|
// Mixed fixture: two foreign "<Race> Groups" sections (one row
|
||||||
|
// drops because its destination is not in any parsed planet
|
||||||
|
// table), plus a single "Unidentified Groups" block. The
|
||||||
|
// per-race section header sets `otherOwner`, but the resulting
|
||||||
|
// model carries the destination planet number only — the race
|
||||||
|
// name surfaces through battle rosters and the planet's
|
||||||
|
// `owner` field, not on the group row.
|
||||||
|
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 Capital 0.00 0.68 88.78 1000.00",
|
||||||
|
"",
|
||||||
|
"Aliens Planets",
|
||||||
|
"",
|
||||||
|
" # X Y N S P I R P $ M C L",
|
||||||
|
" 42 200.00 300.00 Far 500.00 500.00 500.00 10.00 Capital 0.00 0.00 0.00 500.00",
|
||||||
|
"",
|
||||||
|
"Aliens Groups",
|
||||||
|
"",
|
||||||
|
" # T D W S C T Q D P M",
|
||||||
|
" 6 Skiff 3.40 0 0 1 COL 2 Castle 68.00 3.00",
|
||||||
|
" 1 Drone 5.10 0 0 0 - 0 Limbo 102.0 1.00",
|
||||||
|
"",
|
||||||
|
"Reds Groups",
|
||||||
|
"",
|
||||||
|
" # T D W S C T Q D P M",
|
||||||
|
" 1 Phantom 4.20 0 0 0 - 0 Far 84.0 9.00",
|
||||||
|
"",
|
||||||
|
"Unidentified Groups",
|
||||||
|
"",
|
||||||
|
" X Y",
|
||||||
|
"100.50 200.25",
|
||||||
|
"333.10 444.20",
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
rep, _, err := Parse(strings.NewReader(in))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
// Skiff → Castle resolves against Your Planets, Phantom → Far
|
||||||
|
// resolves against Other Planets, Drone → Limbo drops (unknown).
|
||||||
|
if got, want := len(rep.OtherGroup), 2; got != want {
|
||||||
|
t.Fatalf("len(OtherGroup) = %d, want %d (Drone → Limbo dropped due to unknown destination); rows=%+v",
|
||||||
|
got, want, rep.OtherGroup)
|
||||||
|
}
|
||||||
|
a := rep.OtherGroup[0]
|
||||||
|
if a.Class != "Skiff" || a.Number != 6 || a.Destination != 17 {
|
||||||
|
t.Errorf("a (Class, Number, Destination) = (%q, %d, %d), want (\"Skiff\", 6, 17)",
|
||||||
|
a.Class, a.Number, a.Destination)
|
||||||
|
}
|
||||||
|
if a.Cargo != "COL" || float64(a.Load) != 2 || float64(a.Mass) != 3 {
|
||||||
|
t.Errorf("a (Cargo, Load, Mass) = (%q, %v, %v), want (\"COL\", 2, 3)",
|
||||||
|
a.Cargo, float64(a.Load), float64(a.Mass))
|
||||||
|
}
|
||||||
|
if a.Origin != nil || a.Range != nil {
|
||||||
|
t.Errorf("a (Origin, Range) = (%v, %v), want (nil, nil) — legacy foreign rows have no origin/range columns",
|
||||||
|
a.Origin, a.Range)
|
||||||
|
}
|
||||||
|
if float64(a.Tech["drive"]) != 3.40 {
|
||||||
|
t.Errorf("a Tech.drive = %v, want 3.40", float64(a.Tech["drive"]))
|
||||||
|
}
|
||||||
|
b := rep.OtherGroup[1]
|
||||||
|
if b.Class != "Phantom" || b.Number != 1 || b.Destination != 42 {
|
||||||
|
t.Errorf("b (Class, Number, Destination) = (%q, %d, %d), want (\"Phantom\", 1, 42)",
|
||||||
|
b.Class, b.Number, b.Destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, want := len(rep.UnidentifiedGroup), 2; got != want {
|
||||||
|
t.Fatalf("len(UnidentifiedGroup) = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
u := rep.UnidentifiedGroup[0]
|
||||||
|
if float64(u.X) != 100.50 || float64(u.Y) != 200.25 {
|
||||||
|
t.Errorf("u[0] (X, Y) = (%v, %v), want (100.50, 200.25)",
|
||||||
|
float64(u.X), float64(u.Y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- smoke tests -----------------------------------------------------
|
// --- smoke tests -----------------------------------------------------
|
||||||
|
|
||||||
type smokeWant struct {
|
type smokeWant struct {
|
||||||
@@ -801,6 +882,7 @@ type smokeWant struct {
|
|||||||
players, extinct, local, other int
|
players, extinct, local, other int
|
||||||
uninhabited, unidentified, shipClasses int
|
uninhabited, unidentified, shipClasses int
|
||||||
localGroups, localFleets, incomingGroups int
|
localGroups, localFleets, incomingGroups int
|
||||||
|
otherGroups, unidentifiedGroups int
|
||||||
localScience, otherScience, otherShipClass int
|
localScience, otherScience, otherShipClass int
|
||||||
bombings, shipProductions int
|
bombings, shipProductions int
|
||||||
battles int
|
battles int
|
||||||
@@ -849,6 +931,8 @@ func runSmoke(t *testing.T, path string, want smokeWant) {
|
|||||||
{"LocalGroup", len(rep.LocalGroup), want.localGroups},
|
{"LocalGroup", len(rep.LocalGroup), want.localGroups},
|
||||||
{"LocalFleet", len(rep.LocalFleet), want.localFleets},
|
{"LocalFleet", len(rep.LocalFleet), want.localFleets},
|
||||||
{"IncomingGroup", len(rep.IncomingGroup), want.incomingGroups},
|
{"IncomingGroup", len(rep.IncomingGroup), want.incomingGroups},
|
||||||
|
{"OtherGroup", len(rep.OtherGroup), want.otherGroups},
|
||||||
|
{"UnidentifiedGroup", len(rep.UnidentifiedGroup), want.unidentifiedGroups},
|
||||||
{"LocalScience", len(rep.LocalScience), want.localScience},
|
{"LocalScience", len(rep.LocalScience), want.localScience},
|
||||||
{"OtherScience", len(rep.OtherScience), want.otherScience},
|
{"OtherScience", len(rep.OtherScience), want.otherScience},
|
||||||
{"OtherShipClass", len(rep.OtherShipClass), want.otherShipClass},
|
{"OtherShipClass", len(rep.OtherShipClass), want.otherShipClass},
|
||||||
@@ -888,6 +972,7 @@ func runSmoke(t *testing.T, path string, want smokeWant) {
|
|||||||
// silently drops half the data.
|
// silently drops half the data.
|
||||||
func TestParseDgKNNTS039(t *testing.T) {
|
func TestParseDgKNNTS039(t *testing.T) {
|
||||||
runSmoke(t, "../reports/dg/KNNTS039.REP", smokeWant{
|
runSmoke(t, "../reports/dg/KNNTS039.REP", smokeWant{
|
||||||
|
otherGroups: 723, unidentifiedGroups: 72,
|
||||||
race: "KnightErrants", turn: 39,
|
race: "KnightErrants", turn: 39,
|
||||||
mapW: 800, mapH: 800, planetCount: 700,
|
mapW: 800, mapH: 800, planetCount: 700,
|
||||||
voteFor: "KnightErrants", votes: 16.02,
|
voteFor: "KnightErrants", votes: 16.02,
|
||||||
@@ -908,6 +993,7 @@ func TestParseDgKNNTS039(t *testing.T) {
|
|||||||
|
|
||||||
func TestParseDgKNNTS040(t *testing.T) {
|
func TestParseDgKNNTS040(t *testing.T) {
|
||||||
runSmoke(t, "../reports/dg/KNNTS040.REP", smokeWant{
|
runSmoke(t, "../reports/dg/KNNTS040.REP", smokeWant{
|
||||||
|
otherGroups: 734, unidentifiedGroups: 109,
|
||||||
race: "KnightErrants", turn: 40,
|
race: "KnightErrants", turn: 40,
|
||||||
mapW: 800, mapH: 800, planetCount: 700,
|
mapW: 800, mapH: 800, planetCount: 700,
|
||||||
players: 91, extinct: 49,
|
players: 91, extinct: 49,
|
||||||
@@ -930,6 +1016,7 @@ func TestParseDgKNNTS040(t *testing.T) {
|
|||||||
// exercises the deferred name-resolution path in [parser.finish].
|
// exercises the deferred name-resolution path in [parser.finish].
|
||||||
func TestParseDgKNNTS041(t *testing.T) {
|
func TestParseDgKNNTS041(t *testing.T) {
|
||||||
runSmoke(t, "../reports/dg/KNNTS041.REP", smokeWant{
|
runSmoke(t, "../reports/dg/KNNTS041.REP", smokeWant{
|
||||||
|
otherGroups: 772, unidentifiedGroups: 349,
|
||||||
race: "KnightErrants", turn: 41,
|
race: "KnightErrants", turn: 41,
|
||||||
mapW: 800, mapH: 800, planetCount: 700,
|
mapW: 800, mapH: 800, planetCount: 700,
|
||||||
players: 91, extinct: 50,
|
players: 91, extinct: 50,
|
||||||
@@ -952,6 +1039,7 @@ func TestParseDgKNNTS041(t *testing.T) {
|
|||||||
// gplus also sneaks "Incoming Groups" between sections.
|
// gplus also sneaks "Incoming Groups" between sections.
|
||||||
func TestParseGplus40(t *testing.T) {
|
func TestParseGplus40(t *testing.T) {
|
||||||
runSmoke(t, "../reports/gplus/40.REP", smokeWant{
|
runSmoke(t, "../reports/gplus/40.REP", smokeWant{
|
||||||
|
otherGroups: 1042, unidentifiedGroups: 44,
|
||||||
race: "MbI", turn: 40,
|
race: "MbI", turn: 40,
|
||||||
mapW: 350, mapH: 350, planetCount: 300,
|
mapW: 350, mapH: 350, planetCount: 300,
|
||||||
players: 26, extinct: 0,
|
players: 26, extinct: 0,
|
||||||
@@ -974,6 +1062,7 @@ func TestParseGplus40(t *testing.T) {
|
|||||||
// membership shape (no "Incoming Groups" this turn).
|
// membership shape (no "Incoming Groups" this turn).
|
||||||
func TestParseDgKiller031(t *testing.T) {
|
func TestParseDgKiller031(t *testing.T) {
|
||||||
runSmoke(t, "../reports/dg/Killer031.rep", smokeWant{
|
runSmoke(t, "../reports/dg/Killer031.rep", smokeWant{
|
||||||
|
otherGroups: 925, unidentifiedGroups: 34,
|
||||||
race: "Killer", turn: 31,
|
race: "Killer", turn: 31,
|
||||||
mapW: 250, mapH: 250, planetCount: 175,
|
mapW: 250, mapH: 250, planetCount: 175,
|
||||||
players: 25, extinct: 12,
|
players: 25, extinct: 12,
|
||||||
@@ -997,6 +1086,7 @@ func TestParseDgKiller031(t *testing.T) {
|
|||||||
// deferred name resolution is exercised in production conditions).
|
// deferred name resolution is exercised in production conditions).
|
||||||
func TestParseDgTancordia037(t *testing.T) {
|
func TestParseDgTancordia037(t *testing.T) {
|
||||||
runSmoke(t, "../reports/dg/Tancordia037.rep", smokeWant{
|
runSmoke(t, "../reports/dg/Tancordia037.rep", smokeWant{
|
||||||
|
otherGroups: 580, unidentifiedGroups: 24,
|
||||||
race: "Tancordia", turn: 37,
|
race: "Tancordia", turn: 37,
|
||||||
mapW: 210, mapH: 210, planetCount: 140,
|
mapW: 210, mapH: 210, planetCount: 140,
|
||||||
players: 18, extinct: 7,
|
players: 18, extinct: 7,
|
||||||
|
|||||||
+33514
-9244
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -292,7 +292,6 @@ const en = {
|
|||||||
"game.inspector.planet.rename.invalid.disallowed_character": "name contains disallowed characters",
|
"game.inspector.planet.rename.invalid.disallowed_character": "name contains disallowed characters",
|
||||||
"game.inspector.planet.production.title": "production",
|
"game.inspector.planet.production.title": "production",
|
||||||
"game.inspector.planet.production.main.aria": "production type",
|
"game.inspector.planet.production.main.aria": "production type",
|
||||||
"game.inspector.planet.production.main.placeholder": "(production)",
|
|
||||||
"game.inspector.planet.production.option.industry": "industry",
|
"game.inspector.planet.production.option.industry": "industry",
|
||||||
"game.inspector.planet.production.option.materials": "materials",
|
"game.inspector.planet.production.option.materials": "materials",
|
||||||
"game.inspector.planet.production.option.research": "research",
|
"game.inspector.planet.production.option.research": "research",
|
||||||
@@ -308,7 +307,7 @@ const en = {
|
|||||||
"game.inspector.planet.production.ship.no_classes": "no ship classes designed yet",
|
"game.inspector.planet.production.ship.no_classes": "no ship classes designed yet",
|
||||||
"game.inspector.planet.production.apply": "apply production change",
|
"game.inspector.planet.production.apply": "apply production change",
|
||||||
"game.inspector.planet.production.cancel": "discard production change",
|
"game.inspector.planet.production.cancel": "discard production change",
|
||||||
"game.inspector.planet.cargo.placeholder": "cargo routes",
|
"game.inspector.planet.cargo.placeholder": "manage routes",
|
||||||
"game.inspector.planet.cargo.slot.col": "colonists",
|
"game.inspector.planet.cargo.slot.col": "colonists",
|
||||||
"game.inspector.planet.cargo.slot.cap": "industry",
|
"game.inspector.planet.cargo.slot.cap": "industry",
|
||||||
"game.inspector.planet.cargo.slot.mat": "materials",
|
"game.inspector.planet.cargo.slot.mat": "materials",
|
||||||
|
|||||||
@@ -293,7 +293,6 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.inspector.planet.rename.invalid.disallowed_character": "имя содержит недопустимые символы",
|
"game.inspector.planet.rename.invalid.disallowed_character": "имя содержит недопустимые символы",
|
||||||
"game.inspector.planet.production.title": "производство",
|
"game.inspector.planet.production.title": "производство",
|
||||||
"game.inspector.planet.production.main.aria": "тип производства",
|
"game.inspector.planet.production.main.aria": "тип производства",
|
||||||
"game.inspector.planet.production.main.placeholder": "(производство)",
|
|
||||||
"game.inspector.planet.production.option.industry": "промышленность",
|
"game.inspector.planet.production.option.industry": "промышленность",
|
||||||
"game.inspector.planet.production.option.materials": "сырьё",
|
"game.inspector.planet.production.option.materials": "сырьё",
|
||||||
"game.inspector.planet.production.option.research": "исследование",
|
"game.inspector.planet.production.option.research": "исследование",
|
||||||
@@ -309,7 +308,7 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.inspector.planet.production.ship.no_classes": "классы кораблей ещё не спроектированы",
|
"game.inspector.planet.production.ship.no_classes": "классы кораблей ещё не спроектированы",
|
||||||
"game.inspector.planet.production.apply": "применить изменение производства",
|
"game.inspector.planet.production.apply": "применить изменение производства",
|
||||||
"game.inspector.planet.production.cancel": "отменить изменение производства",
|
"game.inspector.planet.production.cancel": "отменить изменение производства",
|
||||||
"game.inspector.planet.cargo.placeholder": "грузовые маршруты",
|
"game.inspector.planet.cargo.placeholder": "управление маршрутами",
|
||||||
"game.inspector.planet.cargo.slot.col": "колонисты",
|
"game.inspector.planet.cargo.slot.col": "колонисты",
|
||||||
"game.inspector.planet.cargo.slot.cap": "промышленность",
|
"game.inspector.planet.cargo.slot.cap": "промышленность",
|
||||||
"game.inspector.planet.cargo.slot.mat": "сырьё",
|
"game.inspector.planet.cargo.slot.mat": "сырьё",
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ torus distance via the F8-07 (#50) fix.
|
|||||||
onchange={pickType}
|
onchange={pickType}
|
||||||
disabled={disabled || pendingSlot !== null}
|
disabled={disabled || pendingSlot !== null}
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="" disabled class="placeholder">
|
||||||
{i18n.t("game.inspector.planet.cargo.placeholder")}
|
{i18n.t("game.inspector.planet.cargo.placeholder")}
|
||||||
</option>
|
</option>
|
||||||
{#each CARGO_LOAD_TYPE_VALUES as loadType (loadType)}
|
{#each CARGO_LOAD_TYPE_VALUES as loadType (loadType)}
|
||||||
@@ -304,6 +304,10 @@ torus distance via the F8-07 (#50) fix.
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
.select option.placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
.destination {
|
.destination {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,7 +136,14 @@ sciences win because they carry more user intent.
|
|||||||
parseTarget(planet.production, localShipClass, localScience),
|
parseTarget(planet.production, localShipClass, localScience),
|
||||||
);
|
);
|
||||||
|
|
||||||
let mainSel = $state<MainSegment | "">("");
|
// An owned planet always produces something on the engine side, so
|
||||||
|
// the row defaults to `industry` when `planet.production` is
|
||||||
|
// somehow null/empty rather than carrying a separate "(none)"
|
||||||
|
// placeholder option. The user lands on a valid production type
|
||||||
|
// and can apply or change immediately.
|
||||||
|
const DEFAULT_MAIN: MainSegment = "industry";
|
||||||
|
|
||||||
|
let mainSel = $state<MainSegment>(DEFAULT_MAIN);
|
||||||
let targetSel = $state<string>("");
|
let targetSel = $state<string>("");
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -147,20 +154,19 @@ sciences win because they carry more user intent.
|
|||||||
void planet.number;
|
void planet.number;
|
||||||
void parsedMain;
|
void parsedMain;
|
||||||
void parsedTarget;
|
void parsedTarget;
|
||||||
mainSel = parsedMain ?? "";
|
mainSel = parsedMain ?? DEFAULT_MAIN;
|
||||||
targetSel = parsedTarget ?? "";
|
targetSel = parsedTarget ?? "";
|
||||||
});
|
});
|
||||||
|
|
||||||
const needsTarget = $derived(mainSel === "research" || mainSel === "ship");
|
const needsTarget = $derived(mainSel === "research" || mainSel === "ship");
|
||||||
|
|
||||||
const dirty = $derived(
|
const dirty = $derived(
|
||||||
(mainSel === "" ? null : mainSel) !== parsedMain
|
mainSel !== parsedMain
|
||||||
|| (targetSel === "" ? null : targetSel) !== parsedTarget,
|
|| (targetSel === "" ? null : targetSel) !== parsedTarget,
|
||||||
);
|
);
|
||||||
|
|
||||||
const applyDisabled = $derived(
|
const applyDisabled = $derived(
|
||||||
disabled
|
disabled
|
||||||
|| mainSel === ""
|
|
||||||
|| (needsTarget && targetSel === "")
|
|| (needsTarget && targetSel === "")
|
||||||
|| !dirty,
|
|| !dirty,
|
||||||
);
|
);
|
||||||
@@ -168,8 +174,7 @@ sciences win because they carry more user intent.
|
|||||||
const cancelDisabled = $derived(disabled || !dirty);
|
const cancelDisabled = $derived(disabled || !dirty);
|
||||||
|
|
||||||
function pickMain(event: Event): void {
|
function pickMain(event: Event): void {
|
||||||
const value = (event.target as HTMLSelectElement).value as MainSegment | "";
|
mainSel = (event.target as HTMLSelectElement).value as MainSegment;
|
||||||
mainSel = value;
|
|
||||||
// Switching the primary list clears any pending secondary
|
// Switching the primary list clears any pending secondary
|
||||||
// choice — the picker for the new main might not even include
|
// choice — the picker for the new main might not even include
|
||||||
// the previous target.
|
// the previous target.
|
||||||
@@ -181,7 +186,7 @@ sciences win because they carry more user intent.
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function applyRow(): Promise<void> {
|
async function applyRow(): Promise<void> {
|
||||||
if (applyDisabled || draft === undefined || mainSel === "") return;
|
if (applyDisabled || draft === undefined) return;
|
||||||
if (mainSel === "industry") {
|
if (mainSel === "industry") {
|
||||||
await emit("CAP", "");
|
await emit("CAP", "");
|
||||||
return;
|
return;
|
||||||
@@ -205,7 +210,7 @@ sciences win because they carry more user intent.
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cancelRow(): void {
|
function cancelRow(): void {
|
||||||
mainSel = parsedMain ?? "";
|
mainSel = parsedMain ?? DEFAULT_MAIN;
|
||||||
targetSel = parsedTarget ?? "";
|
targetSel = parsedTarget ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,9 +242,6 @@ sciences win because they carry more user intent.
|
|||||||
onchange={pickMain}
|
onchange={pickMain}
|
||||||
{disabled}
|
{disabled}
|
||||||
>
|
>
|
||||||
<option value="">
|
|
||||||
{i18n.t("game.inspector.planet.production.main.placeholder")}
|
|
||||||
</option>
|
|
||||||
<option value="industry">
|
<option value="industry">
|
||||||
{i18n.t("game.inspector.planet.production.option.industry")}
|
{i18n.t("game.inspector.planet.production.option.industry")}
|
||||||
</option>
|
</option>
|
||||||
@@ -353,7 +355,7 @@ sciences win because they carry more user intent.
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
.select {
|
.select {
|
||||||
flex: 1 1 6rem;
|
flex: 1 1 6rem;
|
||||||
@@ -374,22 +376,21 @@ sciences win because they carry more user intent.
|
|||||||
.icon-action {
|
.icon-action {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.9rem;
|
font-size: 1rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.15rem 0.2rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
border: 1px solid var(--color-border);
|
border: 0;
|
||||||
border-radius: 3px;
|
border-radius: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.icon-action:not(:disabled):hover {
|
.icon-action:not(:disabled):hover {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
}
|
||||||
.icon-action:disabled {
|
.icon-action:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.4;
|
opacity: 0.35;
|
||||||
}
|
}
|
||||||
.icon-action--apply:not(:disabled) {
|
.icon-action--apply:not(:disabled) {
|
||||||
color: var(--color-success);
|
color: var(--color-success);
|
||||||
|
|||||||
@@ -124,13 +124,15 @@ function getTarget(
|
|||||||
|
|
||||||
describe("planet inspector — production controls", () => {
|
describe("planet inspector — production controls", () => {
|
||||||
test("renders the main select with localised options and ✓/✗ icons", () => {
|
test("renders the main select with localised options and ✓/✗ icons", () => {
|
||||||
|
// No production is set on the seeded planet → the select falls
|
||||||
|
// back to the documented `industry` default (an owned planet
|
||||||
|
// always produces something on the engine side, so there is no
|
||||||
|
// "(none)" placeholder option).
|
||||||
const ui = mountProduction(localPlanet({ number: 1 }));
|
const ui = mountProduction(localPlanet({ number: 1 }));
|
||||||
const main = getMain(ui);
|
const main = getMain(ui);
|
||||||
expect(main.value).toBe("");
|
expect(main.value).toBe("industry");
|
||||||
const labels = Array.from(main.options).map((o) => o.textContent?.trim());
|
const labels = Array.from(main.options).map((o) => o.textContent?.trim());
|
||||||
// One placeholder + the four production kinds, in the documented order.
|
|
||||||
expect(labels).toEqual([
|
expect(labels).toEqual([
|
||||||
"(production)",
|
|
||||||
"industry",
|
"industry",
|
||||||
"materials",
|
"materials",
|
||||||
"research",
|
"research",
|
||||||
@@ -140,18 +142,20 @@ describe("planet inspector — production controls", () => {
|
|||||||
expect(
|
expect(
|
||||||
ui.queryByTestId("inspector-planet-production-target"),
|
ui.queryByTestId("inspector-planet-production-target"),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
// The row is dirty against the seeded `production: null`, so
|
||||||
|
// both icon buttons are enabled — the player can either ✓ to
|
||||||
|
// confirm the default or ✗ to revert (back to industry again).
|
||||||
expect(
|
expect(
|
||||||
ui.getByTestId("inspector-planet-production-apply"),
|
ui.getByTestId("inspector-planet-production-apply"),
|
||||||
).toBeDisabled();
|
).not.toBeDisabled();
|
||||||
expect(
|
expect(
|
||||||
ui.getByTestId("inspector-planet-production-cancel"),
|
ui.getByTestId("inspector-planet-production-cancel"),
|
||||||
).toBeDisabled();
|
).not.toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Industry pick + ✓ emits a CAP setProductionType command", async () => {
|
test("Industry default + ✓ emits a CAP setProductionType command", async () => {
|
||||||
const ui = mountProduction(localPlanet({ number: 7 }));
|
const ui = mountProduction(localPlanet({ number: 7 }));
|
||||||
const main = getMain(ui);
|
expect(getMain(ui).value).toBe("industry");
|
||||||
await fireEvent.change(main, { target: { value: "industry" } });
|
|
||||||
const apply = ui.getByTestId("inspector-planet-production-apply");
|
const apply = ui.getByTestId("inspector-planet-production-apply");
|
||||||
expect(apply).not.toBeDisabled();
|
expect(apply).not.toBeDisabled();
|
||||||
await fireEvent.click(apply);
|
await fireEvent.click(apply);
|
||||||
@@ -268,7 +272,7 @@ describe("planet inspector — production controls", () => {
|
|||||||
test("active main derivation seeds the select from planet.production", () => {
|
test("active main derivation seeds the select from planet.production", () => {
|
||||||
const cases: ReadonlyArray<{
|
const cases: ReadonlyArray<{
|
||||||
production: string | null;
|
production: string | null;
|
||||||
expected: "" | "industry" | "materials" | "research" | "ship";
|
expected: "industry" | "materials" | "research" | "ship";
|
||||||
}> = [
|
}> = [
|
||||||
{ production: "Capital", expected: "industry" },
|
{ production: "Capital", expected: "industry" },
|
||||||
{ production: "Material", expected: "materials" },
|
{ production: "Material", expected: "materials" },
|
||||||
@@ -277,9 +281,11 @@ describe("planet inspector — production controls", () => {
|
|||||||
{ production: "Shields", expected: "research" },
|
{ production: "Shields", expected: "research" },
|
||||||
{ production: "Cargo", expected: "research" },
|
{ production: "Cargo", expected: "research" },
|
||||||
{ production: "Scout", expected: "ship" },
|
{ production: "Scout", expected: "ship" },
|
||||||
{ production: "-", expected: "" },
|
// Falls back to the documented `industry` default when the
|
||||||
{ production: null, expected: "" },
|
// engine display string is missing or unrecognised.
|
||||||
{ production: "UnknownThing", expected: "" },
|
{ production: "-", expected: "industry" },
|
||||||
|
{ production: null, expected: "industry" },
|
||||||
|
{ production: "UnknownThing", expected: "industry" },
|
||||||
];
|
];
|
||||||
for (const tc of cases) {
|
for (const tc of cases) {
|
||||||
const ui = mountProduction(
|
const ui = mountProduction(
|
||||||
|
|||||||
Reference in New Issue
Block a user