F8-05 — game-mode chrome cleanup + inspector compact rows #66

Merged
developer merged 4 commits from feature/issue-48-game-chrome-and-inspector-compact-rows into development 2026-05-27 14:59:22 +00:00
10 changed files with 46770 additions and 9291 deletions
Showing only changes of commit cc4bc3c2b7 - Show all commits
+21 -5
View File
@@ -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`.
+121 -7
View File
@@ -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,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -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",
+1 -2
View File
@@ -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(