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
56 changed files with 50075 additions and 10372 deletions
+6 -2
View File
@@ -820,8 +820,12 @@ every change applies within one frame (no Pixi remount):
`VisibilityDistance(localPlayerDrive)` circles around LOCAL `VisibilityDistance(localPlayerDrive)` circles around LOCAL
planets; LOCAL planets are always exempt — the toggle is planets; LOCAL planets are always exempt — the toggle is
named after the visible part of the map rather than the named after the visible part of the map rather than the
obscured one) plus the torus / no-wrap radio that switches obscured one). The renderer always runs in torus mode; the
the renderer mode while preserving the camera centre. earlier torus / no-wrap radio was removed in F8 polish
(issue #48 п.8) because the topology is a server-side concept
rather than a per-session UI affordance. The renderer-side
no-wrap path is retained for the day the engine surfaces a
bounded-plane mode.
LOCAL planets are always rendered — they have no toggle. Every LOCAL planets are always rendered — they have no toggle. Every
other toggle defaults to ON. Hiding a planet cascades onto every other toggle defaults to ON. Hiding a planet cascades onto every
+6 -3
View File
@@ -840,9 +840,12 @@ Directory-промоушен ([Раздел 5](#5-реестр-названий-
объединения окружностей объединения окружностей
`VisibilityDistance(localPlayerDrive)` вокруг LOCAL-планет; `VisibilityDistance(localPlayerDrive)` вокруг LOCAL-планет;
LOCAL-планеты всегда вне фильтра — тоггл назван по видимой LOCAL-планеты всегда вне фильтра — тоггл назван по видимой
области карты, а не по затемнённой) плюс радиогруппа области карты, а не по затемнённой). Рендерер всегда работает
«торус / без переноса», переключающая режим рендерера с в торическом режиме; прежняя радиогруппа «торус / без
сохранением центра камеры. переноса» была удалена в полишинге F8 (issue #48 п.8),
поскольку топология карты — серверная сущность, а не
per-session UI-настройка. Код-путь без переноса в рендерере
оставлен на день, когда движок выставит режим bounded plane.
LOCAL-планеты отрисовываются всегда — для них тоггла нет. LOCAL-планеты отрисовываются всегда — для них тоггла нет.
Остальные тогглы по умолчанию включены. Скрытие планеты Остальные тогглы по умолчанию включены. Скрытие планеты
+1
View File
@@ -733,6 +733,7 @@ func (c *Cache) otherGroup(v *mr.OtherGroup, sg *game.ShipGroup, st *game.ShipTy
} }
v.Speed = mr.F(sg.Speed(st)) v.Speed = mr.F(sg.Speed(st))
v.Mass = mr.F(st.EmptyMass()) v.Mass = mr.F(st.EmptyMass())
v.Race = c.g.Race[c.RaceIndex(sg.OwnerID)].Name
} }
func (c *Cache) localPlanet(v *mr.LocalPlanet, p *game.Planet) { func (c *Cache) localPlanet(v *mr.LocalPlanet, p *game.Planet) {
+7
View File
@@ -52,6 +52,13 @@ type OtherGroup struct {
Range *Float `json:"range,omitempty"` Range *Float `json:"range,omitempty"`
Speed Float `json:"speed"` Speed Float `json:"speed"`
Mass Float `json:"mass"` Mass Float `json:"mass"`
// Race is the owner's display name resolved from `sg.OwnerID`
// (or, for legacy reports, the section header that introduced
// the row). The local race fills this in for its own
// `LocalGroup` copies via the embedded `OtherGroup` field so
// every group carries an authoritative attribution rather than a
// "foreign" fallback heuristic at render time.
Race string `json:"race,omitempty"`
} }
type UnidentifiedGroup struct { type UnidentifiedGroup struct {
+2
View File
@@ -168,6 +168,7 @@ table OtherGroup {
range:float32 = null; range:float32 = null;
speed:float32; speed:float32;
mass:float32; mass:float32;
race:string;
} }
table LocalGroup { table LocalGroup {
@@ -184,6 +185,7 @@ table LocalGroup {
id:common.UUID (required); id:common.UUID (required);
state:string; state:string;
fleet:string; fleet:string;
race:string;
} }
table LocalFleet { table LocalFleet {
+12 -1
View File
@@ -194,8 +194,16 @@ func (rcv *LocalGroup) Fleet() []byte {
return nil return nil
} }
func (rcv *LocalGroup) Race() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(30))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func LocalGroupStart(builder *flatbuffers.Builder) { func LocalGroupStart(builder *flatbuffers.Builder) {
builder.StartObject(13) builder.StartObject(14)
} }
func LocalGroupAddNumber(builder *flatbuffers.Builder, number uint64) { func LocalGroupAddNumber(builder *flatbuffers.Builder, number uint64) {
builder.PrependUint64Slot(0, number, 0) builder.PrependUint64Slot(0, number, 0)
@@ -241,6 +249,9 @@ func LocalGroupAddState(builder *flatbuffers.Builder, state flatbuffers.UOffsetT
func LocalGroupAddFleet(builder *flatbuffers.Builder, fleet flatbuffers.UOffsetT) { func LocalGroupAddFleet(builder *flatbuffers.Builder, fleet flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(12, flatbuffers.UOffsetT(fleet), 0) builder.PrependUOffsetTSlot(12, flatbuffers.UOffsetT(fleet), 0)
} }
func LocalGroupAddRace(builder *flatbuffers.Builder, race flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(13, flatbuffers.UOffsetT(race), 0)
}
func LocalGroupEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func LocalGroupEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+12 -1
View File
@@ -163,8 +163,16 @@ func (rcv *OtherGroup) MutateMass(n float32) bool {
return rcv._tab.MutateFloat32Slot(22, n) return rcv._tab.MutateFloat32Slot(22, n)
} }
func (rcv *OtherGroup) Race() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(24))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func OtherGroupStart(builder *flatbuffers.Builder) { func OtherGroupStart(builder *flatbuffers.Builder) {
builder.StartObject(10) builder.StartObject(11)
} }
func OtherGroupAddNumber(builder *flatbuffers.Builder, number uint64) { func OtherGroupAddNumber(builder *flatbuffers.Builder, number uint64) {
builder.PrependUint64Slot(0, number, 0) builder.PrependUint64Slot(0, number, 0)
@@ -201,6 +209,9 @@ func OtherGroupAddSpeed(builder *flatbuffers.Builder, speed float32) {
func OtherGroupAddMass(builder *flatbuffers.Builder, mass float32) { func OtherGroupAddMass(builder *flatbuffers.Builder, mass float32) {
builder.PrependFloat32Slot(9, mass, 0.0) builder.PrependFloat32Slot(9, mass, 0.0)
} }
func OtherGroupAddRace(builder *flatbuffers.Builder, race flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(10, flatbuffers.UOffsetT(race), 0)
}
func OtherGroupEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func OtherGroupEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+6
View File
@@ -513,6 +513,7 @@ func encodeReportLocalGroup(builder *flatbuffers.Builder, group *model.LocalGrou
class := builder.CreateString(group.Class) class := builder.CreateString(group.Class)
cargo := builder.CreateString(group.Cargo) cargo := builder.CreateString(group.Cargo)
state := builder.CreateString(group.State) state := builder.CreateString(group.State)
race := builder.CreateString(group.Race)
tech := encodeReportTechEntryVector(builder, group.Tech) tech := encodeReportTechEntryVector(builder, group.Tech)
var fleet flatbuffers.UOffsetT var fleet flatbuffers.UOffsetT
@@ -544,6 +545,7 @@ func encodeReportLocalGroup(builder *flatbuffers.Builder, group *model.LocalGrou
if group.Fleet != nil { if group.Fleet != nil {
fbs.LocalGroupAddFleet(builder, fleet) fbs.LocalGroupAddFleet(builder, fleet)
} }
fbs.LocalGroupAddRace(builder, race)
return fbs.LocalGroupEnd(builder) return fbs.LocalGroupEnd(builder)
} }
@@ -551,6 +553,7 @@ func encodeReportOtherGroup(builder *flatbuffers.Builder, group *model.OtherGrou
class := builder.CreateString(group.Class) class := builder.CreateString(group.Class)
cargo := builder.CreateString(group.Cargo) cargo := builder.CreateString(group.Cargo)
tech := encodeReportTechEntryVector(builder, group.Tech) tech := encodeReportTechEntryVector(builder, group.Tech)
race := builder.CreateString(group.Race)
fbs.OtherGroupStart(builder) fbs.OtherGroupStart(builder)
fbs.OtherGroupAddNumber(builder, uint64(group.Number)) fbs.OtherGroupAddNumber(builder, uint64(group.Number))
@@ -569,6 +572,7 @@ func encodeReportOtherGroup(builder *flatbuffers.Builder, group *model.OtherGrou
} }
fbs.OtherGroupAddSpeed(builder, reportFloatToFBS(group.Speed)) fbs.OtherGroupAddSpeed(builder, reportFloatToFBS(group.Speed))
fbs.OtherGroupAddMass(builder, reportFloatToFBS(group.Mass)) fbs.OtherGroupAddMass(builder, reportFloatToFBS(group.Mass))
fbs.OtherGroupAddRace(builder, race)
return fbs.OtherGroupEnd(builder) return fbs.OtherGroupEnd(builder)
} }
@@ -1134,6 +1138,7 @@ func decodeReportLocalGroupVector(flatReport *fbs.Report, result *model.Report)
Destination: destination, Destination: destination,
Speed: reportFloatFromFBS(item.Speed()), Speed: reportFloatFromFBS(item.Speed()),
Mass: reportFloatFromFBS(item.Mass()), Mass: reportFloatFromFBS(item.Mass()),
Race: string(item.Race()),
}, },
ID: uuidFromHiLo(id.Hi(), id.Lo()), ID: uuidFromHiLo(id.Hi(), id.Lo()),
State: string(item.State()), State: string(item.State()),
@@ -1196,6 +1201,7 @@ func decodeReportOtherGroupVector(flatReport *fbs.Report, result *model.Report)
Destination: destination, Destination: destination,
Speed: reportFloatFromFBS(item.Speed()), Speed: reportFloatFromFBS(item.Speed()),
Mass: reportFloatFromFBS(item.Mass()), Mass: reportFloatFromFBS(item.Mass()),
Race: string(item.Race()),
} }
if origin := item.Origin(); origin != nil { if origin := item.Origin(); origin != nil {
+2 -1
View File
@@ -352,6 +352,7 @@ func sampleReport() *model.Report {
Range: &rangeB, Range: &rangeB,
Speed: model.Float(2.5), Speed: model.Float(2.5),
Mass: model.Float(12.0), Mass: model.Float(12.0),
Race: "Earthlings",
}, },
ID: uuid.MustParse("33333333-3333-3333-3333-333333333333"), ID: uuid.MustParse("33333333-3333-3333-3333-333333333333"),
State: "in_orbit", State: "in_orbit",
@@ -359,7 +360,7 @@ func sampleReport() *model.Report {
}, },
}, },
OtherGroup: []model.OtherGroup{ OtherGroup: []model.OtherGroup{
{Number: 2, Class: "scout", Tech: map[string]model.Float{"CARGO": model.Float(1.25), "DRIVE": model.Float(1.75)}, Cargo: "CAP", Load: model.Float(3.5), Destination: 5, Speed: model.Float(2.25), Mass: model.Float(8.5)}, {Number: 2, Class: "scout", Tech: map[string]model.Float{"CARGO": model.Float(1.25), "DRIVE": model.Float(1.75)}, Cargo: "CAP", Load: model.Float(3.5), Destination: 5, Speed: model.Float(2.25), Mass: model.Float(8.5), Race: "Klingons"},
}, },
UnidentifiedGroup: []model.UnidentifiedGroup{ UnidentifiedGroup: []model.UnidentifiedGroup{
{X: model.Float(10.0), Y: model.Float(11.0)}, {X: model.Float(10.0), Y: model.Float(11.0)},
+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`.
+128 -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,29 @@ 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. The `race` field is set
// from the section header that introduced the row, so the inspector
// can name the foreign owner directly without falling back to a
// generic "foreign" placeholder.
type pendingOtherGroup struct {
race string
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 +318,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 +413,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 +451,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 +1101,69 @@ 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{
race: p.otherOwner,
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
@@ -1138,6 +1235,7 @@ func (p *parser) resolvePending() {
Origin: origin, Origin: origin,
Range: rng, Range: rng,
Mass: report.F(pg.mass), Mass: report.F(pg.mass),
Race: p.rep.Race,
}, },
ID: syntheticGroupID(pg.g), ID: syntheticGroupID(pg.g),
State: pg.state, State: pg.state,
@@ -1145,6 +1243,29 @@ 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),
Race: pg.race,
})
}
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,93 @@ 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.Race != "Aliens" {
t.Errorf("a Race = %q, want %q (carried over from the section header)", a.Race, "Aliens")
}
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 b.Race != "Reds" {
t.Errorf("b Race = %q, want %q (separate section, separate owner)", b.Race, "Reds")
}
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 +888,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 +937,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 +978,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 +999,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 +1022,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 +1045,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 +1068,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 +1092,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
+26 -19
View File
@@ -1,9 +1,10 @@
# Cargo routes UX # Cargo routes UX
This document covers the cargo-route surface: the four-slot This document covers the cargo-route surface: the inspector
inspector subsection, the map-driven destination pick, and the subsection (a single-row dropdown + contextual actions after
optimistic overlay that keeps the inspector and the map in lock-step F8-05), the map-driven destination pick, and the optimistic
with the local order draft. The engine semantics are quoted from overlay that keeps the inspector and the map in lock-step with
the local order draft. The engine semantics are quoted from
[`game/rules.txt`](../../game/rules.txt) section "Грузовые маршруты" [`game/rules.txt`](../../game/rules.txt) section "Грузовые маршруты"
(lines 808843); this file is the source of truth for how the UI (lines 808843); this file is the source of truth for how the UI
surfaces those rules. surfaces those rules.
@@ -25,29 +26,35 @@ than `40 × driveTech` world units along the torus-shortest path
destination becomes unreachable at the next turn is auto-removed destination becomes unreachable at the next turn is auto-removed
(`RemoveUnreachableRoutes`). (`RemoveUnreachableRoutes`).
## Four-slot inspector subsection ## Single-row inspector subsection
The cargo-routes subsection renders below the production controls The cargo-routes subsection renders below the production controls
on every owned planet inspector. Slots appear in on every owned planet inspector. F8-05 collapsed the previous
`CARGO_LOAD_TYPE_VALUES` order (COL, CAP, MAT, EMP) so visual order four-slot grid into a single `<select>` that lists the load-types
matches the engine's load priority — players who scan top-down see in `CARGO_LOAD_TYPE_VALUES` order (COL, CAP, MAT, EMP) — same
the highest-priority cargo first. order as the engine's load priority — preceded by a placeholder
option that absorbs the old "cargo routes" section title. Nothing
else is rendered until the player picks a type; after a pick the
dropdown stays on the chosen type and the row reveals one of two
states:
Slot states: - **Empty** — a single `Add` button.
- **Empty** — `(no route)` text plus a single `Add` button.
- **Filled** — `→ {destination name}` plus `Edit` and `Remove`. - **Filled** — `→ {destination name}` plus `Edit` and `Remove`.
`Add` and `Edit` open a renderer-driven destination pick (see next `Add` and `Edit` open a renderer-driven destination pick (see next
section). `Remove` emits a `removeCargoRoute` command. The collapse section). `Remove` emits a `removeCargoRoute` command. The
rule on the order draft store ensures only one entry per collapse rule on the order draft store ensures only one entry per
`(source, loadType)` slot survives in the draft at any time, so a `(source, loadType)` slot survives in the draft at any time, so a
sequence of `Add → Edit → Remove` collapses to the latest verb only sequence of `Add → Edit → Remove` collapses to the latest verb
(matching the production-controls pattern). only (matching the production-controls pattern). After a
successful pick or remove the dropdown deliberately stays on the
just-acted type so the player sees the result of the gesture in
place.
Disabled state: every button is disabled when the Disabled state: the dropdown and every action button are disabled
`OrderDraftStore` or `MapPickService` context is missing (the when the `OrderDraftStore` or `MapPickService` context is missing
component is mounted outside the in-game shell, in tests, etc.). (the component is mounted outside the in-game shell, in tests,
etc.).
## Map-driven destination pick ## Map-driven destination pick
+17 -5
View File
@@ -71,13 +71,24 @@ the colour block in `tokens.css`.
`theme.resolved` (`light` | `dark`), and `theme.setChoice(…)`. It `theme.resolved` (`light` | `dark`), and `theme.setChoice(…)`. It
persists the choice, applies `data-theme`, and — while the choice is persists the choice, applies `data-theme`, and — while the choice is
`system` — follows OS theme changes via `matchMedia`. `system` — follows OS theme changes via `matchMedia`.
- The account menu (`account-menu.svelte`) exposes the picker. The - The persisted picker lives in the lobby profile screen
default is `system` (it follows the OS preference); `light` / `dark` ([`screens/profile-screen.svelte`](../frontend/src/lib/screens/profile-screen.svelte)) —
pin a theme. the in-game header is intentionally light on chrome and only carries
the volatile light/dark toggle described below. The default is
`system` (it follows the OS preference); `light` / `dark` pin a theme.
- On top of the persisted choice the store carries an ephemeral
`theme.override` (`null` | `light` | `dark`). `setOverride(…)`
short-circuits `resolved` so the in-game toggle
([`header/game-mode-theme-toggle.svelte`](../frontend/src/lib/header/game-mode-theme-toggle.svelte))
can flip the document theme without touching the lobby preference.
The override lives in memory only; the game shell calls
`theme.clearOverride()` on unmount, so leaving the game and re-entering
it re-projects the persisted choice from lobby.
The `app.html` guard and the store deliberately duplicate the The `app.html` guard and the store deliberately duplicate the
resolution logic (one runs before modules load, the other after) — keep resolution logic (one runs before modules load, the other after) — keep
them in sync. them in sync. The ephemeral override is intentionally absent from the
pre-paint guard: it cannot survive a reload, only an in-tab session.
## Conventions ## Conventions
@@ -126,4 +137,5 @@ battle-scene palette, both defined in code rather than as tokens), the
overlay scrims, and the directional / deliberate drop shadows. overlay scrims, and the directional / deliberate drop shadows.
The default theme is **`system`** — it follows the OS light/dark The default theme is **`system`** — it follows the OS light/dark
preference; users can pin light or dark via the account-menu picker. preference; users pin light or dark via the lobby profile screen, and
flip the in-game appearance volatilely through the header theme toggle.
+16 -1
View File
@@ -62,7 +62,11 @@ transition. The validator (`lib/util/entity-name.ts`) is a TS port
of `pkg/util/string.go.ValidateTypeName`, exercised on every render of `pkg/util/string.go.ValidateTypeName`, exercised on every render
in the inline editor and re-run by the store on every `add`. The in the inline editor and re-run by the store on every `add`. The
submit pipeline filters the draft to `valid` entries only — any submit pipeline filters the draft to `valid` entries only — any
`invalid` row blocks the Submit button. `invalid` row blocks the Submit button. The entry point in the
inspector is the planet name itself: clicking it opens an inline
`<input>` with a single ✓ confirm icon on the right; Escape (or
leaving the inspector) cancels the edit without touching the
draft.
## Command status state machine ## Command status state machine
@@ -178,6 +182,17 @@ optimistic overlay rewrites `planet.production` using
mirrors the engine's `Cache.PlanetProductionDisplayName` so the mirrors the engine's `Cache.PlanetProductionDisplayName` so the
overlay stays byte-equal with the next server report. overlay stays byte-equal with the next server report.
The inspector surface that composes a `setProductionType`
(`lib/inspectors/planet/production.svelte`) is two dropdowns on
one row — a primary `industry / materials / research / ship`
plus a secondary one (tech / science / ship class) that appears
for the `research` and `ship` contexts — together with a green ✓
apply and yellow ✗ cancel icon. The ✓ button stays disabled
until the row selection differs from the planet's current
effective production; ✗ resets the local row state back to that
effective value without touching the draft. The Order tab is
the only place that can revoke an already-applied command.
### Collapse-by-target rule ### Collapse-by-target rule
`setProductionType` carries a collapse-by-target rule. `setProductionType` carries a collapse-by-target rule.
+18 -14
View File
@@ -74,22 +74,26 @@ which surfaces as `rejected` in the order tab.
## Production-picker integration ## Production-picker integration
The planet inspector's Research sub-row The planet inspector's production row
(`lib/inspectors/planet/production.svelte`) renders the four tech (`lib/inspectors/planet/production.svelte`) is two `<select>`s
buttons and one extra button per defined science from the player's plus a green ✓ apply / yellow ✗ cancel pair after F8-05. With
`localScience` overlay. A click on a science button dispatches the primary picker on `research`, the secondary picker lists the
four tech display strings and one extra option per defined
science from the player's `localScience` overlay. Picking a
science target and pressing ✓ dispatches
`setProductionType("SCIENCE", "<scienceName>")`, mirroring the `setProductionType("SCIENCE", "<scienceName>")`, mirroring the
wire-level `CommandPlanetProduce` shape wire-level `CommandPlanetProduce` shape
(`pkg/schema/fbs/order.fbs.CommandPlanetProduce`). (`pkg/schema/fbs/order.fbs.CommandPlanetProduce`).
The active highlight is derived from `planet.production` — the The active value of both selects is derived from
display string the engine emits in the report. A science name `planet.production` — the display string the engine emits in the
shadows the matching tech display string when they collide (a report. A science name shadows the matching tech display string
science deliberately named `Drive` wins over the Drive tech when they collide (a science deliberately named `Drive` wins over
button), because the wire string is ambiguous and the user clearly the Drive tech option), because the wire string is ambiguous and
intended the named science. This is a pragmatic accept; a the user clearly intended the named science. This is a pragmatic
structured production tag on the wire would let us disambiguate accept; a structured production tag on the wire would let us
without the shadow rule, but that is a separate backend concern. disambiguate without the shadow rule, but that is a separate
backend concern.
## Tests ## Tests
@@ -102,5 +106,5 @@ without the shadow rule, but that is a separate backend concern.
fractions, view-mode Delete dispatches `removeScience`, fractions, view-mode Delete dispatches `removeScience`,
duplicate-name guard against the overlay. duplicate-name guard against the overlay.
- `tests/e2e/sciences.spec.ts` — full Playwright walkthrough: - `tests/e2e/sciences.spec.ts` — full Playwright walkthrough:
create → list → set planet production via the Research sub-row create → list → set planet production via the research/target
→ delete. dropdown pair + ✓ apply → delete.
+11
View File
@@ -193,6 +193,15 @@ export interface ReportShipGroupBase {
range: number | null; range: number | null;
speed: number; speed: number;
mass: number; mass: number;
/**
* Owning race for the group. The engine fills this from
* `sg.OwnerID` (and the legacy parser from the section header
* that introduced the row) so the inspector can name foreign
* owners directly instead of falling back to a "foreign"
* placeholder when the planet kind does not carry an `owner`
* heuristic. Empty when the source report predates the field.
*/
race: string;
} }
/** /**
@@ -869,6 +878,7 @@ function decodeLocalShipGroups(report: Report): ReportLocalShipGroup[] {
mass: g.mass(), mass: g.mass(),
state: g.state() ?? "", state: g.state() ?? "",
fleet: g.fleet(), fleet: g.fleet(),
race: g.race() ?? "",
}); });
} }
return out; return out;
@@ -895,6 +905,7 @@ function decodeOtherShipGroups(report: Report): ReportOtherShipGroup[] {
range, range,
speed: g.speed(), speed: g.speed(),
mass: g.mass(), mass: g.mass(),
race: g.race() ?? "",
}); });
} }
return out; return out;
+3
View File
@@ -186,6 +186,7 @@ interface SyntheticShipGroup {
mass?: number; mass?: number;
state?: string; state?: string;
fleet?: string; fleet?: string;
race?: string;
} }
interface SyntheticIncomingGroup { interface SyntheticIncomingGroup {
@@ -344,6 +345,7 @@ function decodeSyntheticReport(json: unknown): GameReport {
mass: numOr0(g.mass), mass: numOr0(g.mass),
state: typeof g.state === "string" ? g.state : "", state: typeof g.state === "string" ? g.state : "",
fleet: typeof g.fleet === "string" ? g.fleet : null, fleet: typeof g.fleet === "string" ? g.fleet : null,
race: typeof g.race === "string" ? g.race : race,
}), }),
); );
const otherShipGroups: ReportOtherShipGroup[] = (root.otherGroup ?? []).map( const otherShipGroups: ReportOtherShipGroup[] = (root.otherGroup ?? []).map(
@@ -358,6 +360,7 @@ function decodeSyntheticReport(json: unknown): GameReport {
range: typeof g.range === "number" ? g.range : null, range: typeof g.range === "number" ? g.range : null,
speed: numOr0(g.speed), speed: numOr0(g.speed),
mass: numOr0(g.mass), mass: numOr0(g.mass),
race: typeof g.race === "string" ? g.race : "",
}), }),
); );
const incomingShipGroups: ReportIncomingShipGroup[] = ( const incomingShipGroups: ReportIncomingShipGroup[] = (
@@ -1,10 +1,15 @@
<!-- <!--
Phase 29 gear popover. Sits in the top-right corner of the map Phase 29 gear popover. Sits in the top-right corner of the map
canvas and exposes the per-game visibility / wrap toggles that the canvas and exposes the per-game visibility toggles that the
`GameStateStore` already owns. The component is a thin view of the `GameStateStore` already owns. The component is a thin view of the
store — every checkbox / radio fires `store.setMapToggle(...)` or store — every checkbox fires `store.setMapToggle(...)` and reads
`store.setWrapMode(...)` and reads back the current state through back the current state through the rune.
the rune.
The wrap-scrolling toggle that used to live alongside the visibility
flags was dropped in F8-05 (issue #48 п.8): wrap is a game-server
feature, not a per-session UI affordance, so the renderer always
runs in torus mode for now. The renderer-side `wrapMode` plumbing
stays put for when the engine surfaces non-torus topologies.
Outside-click + Escape close the popover, matching the Outside-click + Escape close the popover, matching the
`header/view-menu.svelte` precedent. On mobile (<768 px) the `header/view-menu.svelte` precedent. On mobile (<768 px) the
@@ -16,7 +21,6 @@ bottom-tabs bar.
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { restoreFocus } from "$lib/a11y/restore-focus"; import { restoreFocus } from "$lib/a11y/restore-focus";
import type { MapToggles, GameStateStore } from "$lib/game-state.svelte"; import type { MapToggles, GameStateStore } from "$lib/game-state.svelte";
import type { WrapMode } from "../../map/world";
type Props = { store: GameStateStore }; type Props = { store: GameStateStore };
let { store }: Props = $props(); let { store }: Props = $props();
@@ -35,18 +39,6 @@ bottom-tabs bar.
void store.setMapToggle(key, event.currentTarget.checked as MapToggles[K]); void store.setMapToggle(key, event.currentTarget.checked as MapToggles[K]);
} }
/**
* setWrap is wired to the radios' `onclick`, not `onchange`, so the
* Playwright `.click()` action on the input fires the callback even
* when the input is already checked (the `change` event suppresses
* the second activation, which made the wrap-mode e2e flake).
* `onclick` also fires reliably on touch / pointer activation.
*/
function setWrap(mode: WrapMode): void {
if (store.wrapMode === mode) return;
void store.setWrapMode(mode);
}
function onKeyDown(event: KeyboardEvent): void { function onKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape" && open) { if (event.key === "Escape" && open) {
open = false; open = false;
@@ -197,31 +189,6 @@ bottom-tabs bar.
/> />
<span>{i18n.t("game.map.toggles.visible_hyperspace")}</span> <span>{i18n.t("game.map.toggles.visible_hyperspace")}</span>
</label> </label>
<div class="wrap-row">
<span class="wrap-label">{i18n.t("game.map.toggles.wrap.label")}</span>
<label class="radio">
<input
type="radio"
name="map-toggles-wrap"
data-testid="map-toggles-wrap-torus"
value="torus"
checked={store.wrapMode === "torus"}
onclick={() => setWrap("torus")}
/>
<span>{i18n.t("game.map.toggles.wrap.torus")}</span>
</label>
<label class="radio">
<input
type="radio"
name="map-toggles-wrap"
data-testid="map-toggles-wrap-no-wrap"
value="no-wrap"
checked={store.wrapMode === "no-wrap"}
onclick={() => setWrap("no-wrap")}
/>
<span>{i18n.t("game.map.toggles.wrap.no_wrap")}</span>
</label>
</div>
</fieldset> </fieldset>
</div> </div>
{/if} {/if}
@@ -295,27 +262,9 @@ bottom-tabs bar.
label:hover { label:hover {
background: var(--color-surface-hover); background: var(--color-surface-hover);
} }
input[type="checkbox"], input[type="checkbox"] {
input[type="radio"] {
accent-color: var(--color-accent); accent-color: var(--color-accent);
} }
.wrap-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
font-size: 0.9rem;
}
.wrap-label {
color: var(--color-text-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
width: 100%;
}
.radio {
padding: 0.15rem 0.4rem;
}
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.surface { .surface {
position: fixed; position: fixed;
@@ -45,6 +45,7 @@ return to the lobby still disposes the stores via `onDestroy`.
<script lang="ts"> <script lang="ts">
import { onDestroy, setContext, untrack } from "svelte"; import { onDestroy, setContext, untrack } from "svelte";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { theme } from "$lib/theme/theme.svelte";
import { appScreen, activeView } from "$lib/app-nav.svelte"; import { appScreen, activeView } from "$lib/app-nav.svelte";
import Header from "$lib/header/header.svelte"; import Header from "$lib/header/header.svelte";
import HistoryBanner from "$lib/header/history-banner.svelte"; import HistoryBanner from "$lib/header/history-banner.svelte";
@@ -562,6 +563,11 @@ return to the lobby still disposes the stores via `onDestroy`.
gameState.dispose(); gameState.dispose();
orderDraft.dispose(); orderDraft.dispose();
selection.dispose(); selection.dispose();
// The in-game header carries an ephemeral light/dark override
// on `theme`; drop it on shell teardown so the lobby (and any
// future re-entry into the game) re-projects the persisted
// preference.
theme.clearOverride();
}); });
function describeBootstrapError(err: unknown): string { function describeBootstrapError(err: unknown): string {
@@ -1,191 +0,0 @@
<!--
Account-menu popover with Account / Settings / Sessions / Theme /
Language / Logout. Phase 10 only wires Language (via the existing
i18n primitive) and Logout (`session.signOut("user")`); the rest are
stub buttons that later phases (35 polish, dedicated phases for
Sessions and Theme) take over.
-->
<script lang="ts">
import { onMount } from "svelte";
import {
i18n,
SUPPORTED_LOCALES,
type Locale,
type TranslationKey,
} from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte";
import { theme, type ThemeChoice } from "$lib/theme/theme.svelte";
import { restoreFocus } from "$lib/a11y/restore-focus";
const THEME_CHOICES: ReadonlyArray<{ id: ThemeChoice; key: TranslationKey }> = [
{ id: "system", key: "game.shell.menu.theme_system" },
{ id: "light", key: "game.shell.menu.theme_light" },
{ id: "dark", key: "game.shell.menu.theme_dark" },
];
let open = $state(false);
let rootEl: HTMLDivElement | null = $state(null);
function toggleOpen(): void {
open = !open;
}
async function logout(): Promise<void> {
open = false;
await session.signOut("user");
}
function selectLocale(event: Event): void {
const value = (event.target as HTMLSelectElement).value as Locale;
i18n.setLocale(value);
}
function selectTheme(event: Event): void {
const value = (event.target as HTMLSelectElement).value as ThemeChoice;
theme.setChoice(value);
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape" && open) {
open = false;
}
}
onMount(() => {
const handleClick = (event: MouseEvent): void => {
if (!open || rootEl === null) return;
const target = event.target;
if (target instanceof Node && rootEl.contains(target)) return;
open = false;
};
document.addEventListener("click", handleClick, true);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("click", handleClick, true);
document.removeEventListener("keydown", onKeyDown);
};
});
</script>
<div class="account-menu" bind:this={rootEl}>
<button
type="button"
class="trigger"
data-testid="account-menu-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label={i18n.t("game.shell.menu.account")}
onclick={toggleOpen}
>
</button>
{#if open}
<div
class="surface"
role="menu"
data-testid="account-menu-list"
use:restoreFocus
>
<button type="button" role="menuitem" data-testid="account-menu-settings" disabled>
{i18n.t("game.shell.menu.settings")}
</button>
<button type="button" role="menuitem" data-testid="account-menu-sessions" disabled>
{i18n.t("game.shell.menu.sessions")}
</button>
<label class="field" data-testid="account-menu-theme">
<span>{i18n.t("game.shell.menu.theme")}</span>
<select
data-testid="account-menu-theme-select"
value={theme.choice}
onchange={selectTheme}
>
{#each THEME_CHOICES as entry (entry.id)}
<option value={entry.id}>{i18n.t(entry.key)}</option>
{/each}
</select>
</label>
<label class="field" data-testid="account-menu-language">
<span>{i18n.t("game.shell.menu.language")}</span>
<select
data-testid="account-menu-language-select"
value={i18n.locale}
onchange={selectLocale}
>
{#each SUPPORTED_LOCALES as entry (entry.code)}
<option value={entry.code}>{entry.nativeName}</option>
{/each}
</select>
</label>
<button
type="button"
role="menuitem"
data-testid="account-menu-logout"
onclick={logout}
>
{i18n.t("game.shell.menu.logout")}
</button>
</div>
{/if}
</div>
<style>
.account-menu {
position: relative;
}
.trigger {
font: inherit;
font-size: 1.1rem;
padding: 0.25rem 0.6rem;
background: transparent;
color: inherit;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.trigger:hover {
background: var(--color-surface-hover);
}
.surface {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
min-width: 12rem;
display: flex;
flex-direction: column;
background: var(--color-surface-overlay);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 50;
}
.surface > button,
.surface > label {
text-align: left;
font: inherit;
padding: 0.45rem 0.75rem;
background: transparent;
color: inherit;
border: 0;
cursor: pointer;
}
.surface > button:hover:not(:disabled) {
background: var(--color-surface-hover);
}
.surface > button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.field {
display: flex;
align-items: center;
gap: 0.5rem;
}
.field select {
font: inherit;
background: var(--color-surface-raised);
color: inherit;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.15rem 0.35rem;
}
</style>
@@ -0,0 +1,60 @@
<!--
In-game ephemeral light/dark theme toggle. Replaces the previous account
menu in the top-right of the game shell. The persisted theme choice
(system/light/dark) lives in the lobby's profile screen; this toggle
only flips an in-memory `override` on the shared `theme` store, so the
game shell can be re-themed without touching the user's persisted
preference. The override is cleared on shell unmount (see
`game-shell.svelte`), so leaving and re-entering the game re-projects
the persisted choice.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
import { theme } from "$lib/theme/theme.svelte";
const next = $derived(theme.resolved === "light" ? "dark" : "light");
const label = $derived(
i18n.t(
next === "light"
? "game.shell.theme_toggle.to_light"
: "game.shell.theme_toggle.to_dark",
),
);
function toggle(): void {
theme.setOverride(next);
}
</script>
<button
type="button"
class="theme-toggle"
data-testid="game-mode-theme-toggle"
data-theme={theme.resolved}
aria-label={label}
aria-pressed={theme.resolved === "dark"}
onclick={toggle}
>
<span aria-hidden="true">{theme.resolved === "light" ? "☼" : "☾"}</span>
</button>
<style>
.theme-toggle {
min-width: 44px;
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
font: inherit;
font-size: 1.1rem;
padding: 0.25rem 0.6rem;
background: transparent;
color: inherit;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.theme-toggle:hover {
background: var(--color-surface-hover);
}
</style>
+9 -5
View File
@@ -2,9 +2,13 @@
Top header for the in-game shell. Composes the in-game ID strip Top header for the in-game shell. Composes the in-game ID strip
(race name @ game name) followed by the Phase 26 turn navigator (a (race name @ game name) followed by the Phase 26 turn navigator (a
`← Turn N →` triplet with a popover of every turn), the view `← Turn N →` triplet with a popover of every turn), the view
dropdown / hamburger, and the account menu. The sidebar-toggle slot dropdown / hamburger, and the in-game ephemeral light/dark theme
to its left appears only on tablet viewports (7681024 px) and is toggle. The sidebar-toggle slot to its left appears only on tablet
wired by `+layout.svelte`. viewports (7681024 px) and is wired by `+layout.svelte`.
The persisted theme choice (and the language picker, logout, etc.)
lives in the lobby — the in-game header carries only the ephemeral
toggle for quick visual flips during a session.
The race name is read from the engine's `Report.race`, the game The race name is read from the engine's `Report.race`, the game
name from the lobby's `GameSummary.gameName`. While either piece name from the lobby's `GameSummary.gameName`. While either piece
@@ -24,7 +28,7 @@ absent until Phase 24 wires push-event state.
type GameStateStore, type GameStateStore,
} from "$lib/game-state.svelte"; } from "$lib/game-state.svelte";
import ViewMenu from "./view-menu.svelte"; import ViewMenu from "./view-menu.svelte";
import AccountMenu from "./account-menu.svelte"; import GameModeThemeToggle from "./game-mode-theme-toggle.svelte";
import TurnNavigator from "./turn-navigator.svelte"; import TurnNavigator from "./turn-navigator.svelte";
type Props = { type Props = {
@@ -78,7 +82,7 @@ absent until Phase 24 wires push-event state.
</button> </button>
<ViewMenu /> <ViewMenu />
<AccountMenu /> <GameModeThemeToggle />
</div> </div>
</header> </header>
+11 -17
View File
@@ -144,16 +144,9 @@ const en = {
"game.shell.menu.close_sidebar": "close sidebar", "game.shell.menu.close_sidebar": "close sidebar",
"game.shell.menu.open_views": "open views menu", "game.shell.menu.open_views": "open views menu",
"game.shell.menu.close_views": "close views menu", "game.shell.menu.close_views": "close views menu",
"game.shell.menu.account": "account",
"game.shell.menu.settings": "settings",
"game.shell.menu.sessions": "sessions",
"game.shell.menu.theme": "theme",
"game.shell.menu.theme_system": "system",
"game.shell.menu.theme_light": "light",
"game.shell.menu.theme_dark": "dark",
"game.shell.menu.language": "language",
"game.shell.menu.return_to_lobby": "return to lobby", "game.shell.menu.return_to_lobby": "return to lobby",
"game.shell.menu.logout": "logout", "game.shell.theme_toggle.to_light": "switch to light theme",
"game.shell.theme_toggle.to_dark": "switch to dark theme",
"game.shell.coming_soon": "coming soon", "game.shell.coming_soon": "coming soon",
"game.shell.turn.label": "turn {turn}", "game.shell.turn.label": "turn {turn}",
"game.shell.turn.list_item": "turn #{turn}", "game.shell.turn.list_item": "turn #{turn}",
@@ -183,9 +176,6 @@ const en = {
"game.map.toggles.unidentified_planets": "unidentified planets", "game.map.toggles.unidentified_planets": "unidentified planets",
"game.map.toggles.unreachable_planets": "show unreachable planets", "game.map.toggles.unreachable_planets": "show unreachable planets",
"game.map.toggles.visible_hyperspace": "visible hyperspace", "game.map.toggles.visible_hyperspace": "visible hyperspace",
"game.map.toggles.wrap.label": "wrap scrolling",
"game.map.toggles.wrap.torus": "torus",
"game.map.toggles.wrap.no_wrap": "no-wrap",
"game.view.table": "table", "game.view.table": "table",
"game.view.table.planets": "planets", "game.view.table.planets": "planets",
"game.view.table.ship_classes": "ship classes", "game.view.table.ship_classes": "ship classes",
@@ -293,7 +283,6 @@ const en = {
"game.inspector.planet.action.rename": "rename", "game.inspector.planet.action.rename": "rename",
"game.inspector.planet.rename.title": "rename planet", "game.inspector.planet.rename.title": "rename planet",
"game.inspector.planet.rename.confirm": "save", "game.inspector.planet.rename.confirm": "save",
"game.inspector.planet.rename.cancel": "cancel",
"game.inspector.planet.rename.invalid.empty": "name cannot be empty", "game.inspector.planet.rename.invalid.empty": "name cannot be empty",
"game.inspector.planet.rename.invalid.too_long": "name is too long (30 characters max)", "game.inspector.planet.rename.invalid.too_long": "name is too long (30 characters max)",
"game.inspector.planet.rename.invalid.starts_with_special": "name cannot start with a special character", "game.inspector.planet.rename.invalid.starts_with_special": "name cannot start with a special character",
@@ -302,6 +291,7 @@ const en = {
"game.inspector.planet.rename.invalid.whitespace": "name cannot contain spaces", "game.inspector.planet.rename.invalid.whitespace": "name cannot contain spaces",
"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.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",
@@ -310,8 +300,14 @@ const en = {
"game.inspector.planet.production.research.weapons": "weapons", "game.inspector.planet.production.research.weapons": "weapons",
"game.inspector.planet.production.research.shields": "shields", "game.inspector.planet.production.research.shields": "shields",
"game.inspector.planet.production.research.cargo": "cargo", "game.inspector.planet.production.research.cargo": "cargo",
"game.inspector.planet.production.target.research.aria": "research target",
"game.inspector.planet.production.target.research.placeholder": "(tech or science)",
"game.inspector.planet.production.target.ship.aria": "ship class",
"game.inspector.planet.production.target.ship.placeholder": "(ship class)",
"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.cargo.title": "cargo routes", "game.inspector.planet.production.apply": "apply production change",
"game.inspector.planet.production.cancel": "discard production change",
"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",
@@ -602,11 +598,9 @@ const en = {
"game.inspector.ship_group.action.invalid.level": "level must be in ({current}, {max}]", "game.inspector.ship_group.action.invalid.level": "level must be in ({current}, {max}]",
"game.inspector.ship_group.action.invalid.fleet_name": "fleet name does not match the entity-name rules", "game.inspector.ship_group.action.invalid.fleet_name": "fleet name does not match the entity-name rules",
"game.inspector.planet.ship_groups.race_filter.aria": "stationed race",
"game.inspector.planet.ship_groups.title": "stationed ship groups", "game.inspector.planet.ship_groups.title": "stationed ship groups",
"game.inspector.planet.ship_groups.row.count": "{count} ships",
"game.inspector.planet.ship_groups.row.mass": "mass {mass}",
"game.inspector.planet.ship_groups.race.unknown": "unknown", "game.inspector.planet.ship_groups.race.unknown": "unknown",
"game.inspector.planet.ship_groups.race.foreign": "foreign",
"game.report.loading": "loading report…", "game.report.loading": "loading report…",
"game.report.back_to_map": "back to map", "game.report.back_to_map": "back to map",
+11 -17
View File
@@ -145,16 +145,9 @@ const ru: Record<keyof typeof en, string> = {
"game.shell.menu.close_sidebar": "закрыть боковую панель", "game.shell.menu.close_sidebar": "закрыть боковую панель",
"game.shell.menu.open_views": "открыть меню видов", "game.shell.menu.open_views": "открыть меню видов",
"game.shell.menu.close_views": "закрыть меню видов", "game.shell.menu.close_views": "закрыть меню видов",
"game.shell.menu.account": "аккаунт",
"game.shell.menu.settings": "настройки",
"game.shell.menu.sessions": "сессии",
"game.shell.menu.theme": "тема",
"game.shell.menu.theme_system": "системная",
"game.shell.menu.theme_light": "светлая",
"game.shell.menu.theme_dark": "тёмная",
"game.shell.menu.language": "язык",
"game.shell.menu.return_to_lobby": "вернуться в лобби", "game.shell.menu.return_to_lobby": "вернуться в лобби",
"game.shell.menu.logout": "выйти", "game.shell.theme_toggle.to_light": "переключить на светлую тему",
"game.shell.theme_toggle.to_dark": "переключить на тёмную тему",
"game.shell.coming_soon": "скоро будет", "game.shell.coming_soon": "скоро будет",
"game.shell.turn.label": "ход {turn}", "game.shell.turn.label": "ход {turn}",
"game.shell.turn.list_item": "ход #{turn}", "game.shell.turn.list_item": "ход #{turn}",
@@ -184,9 +177,6 @@ const ru: Record<keyof typeof en, string> = {
"game.map.toggles.unidentified_planets": "неопознанные планеты", "game.map.toggles.unidentified_planets": "неопознанные планеты",
"game.map.toggles.unreachable_planets": "показывать недостижимые планеты", "game.map.toggles.unreachable_planets": "показывать недостижимые планеты",
"game.map.toggles.visible_hyperspace": "видимое гиперпространство", "game.map.toggles.visible_hyperspace": "видимое гиперпространство",
"game.map.toggles.wrap.label": "перенос карты",
"game.map.toggles.wrap.torus": "тор",
"game.map.toggles.wrap.no_wrap": "без переноса",
"game.view.table": "таблица", "game.view.table": "таблица",
"game.view.table.planets": "планеты", "game.view.table.planets": "планеты",
"game.view.table.ship_classes": "классы кораблей", "game.view.table.ship_classes": "классы кораблей",
@@ -294,7 +284,6 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.planet.action.rename": "переименовать", "game.inspector.planet.action.rename": "переименовать",
"game.inspector.planet.rename.title": "переименование планеты", "game.inspector.planet.rename.title": "переименование планеты",
"game.inspector.planet.rename.confirm": "сохранить", "game.inspector.planet.rename.confirm": "сохранить",
"game.inspector.planet.rename.cancel": "отмена",
"game.inspector.planet.rename.invalid.empty": "имя не может быть пустым", "game.inspector.planet.rename.invalid.empty": "имя не может быть пустым",
"game.inspector.planet.rename.invalid.too_long": "имя слишком длинное (максимум 30 символов)", "game.inspector.planet.rename.invalid.too_long": "имя слишком длинное (максимум 30 символов)",
"game.inspector.planet.rename.invalid.starts_with_special": "имя не может начинаться со спецсимвола", "game.inspector.planet.rename.invalid.starts_with_special": "имя не может начинаться со спецсимвола",
@@ -303,6 +292,7 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.planet.rename.invalid.whitespace": "имя не может содержать пробелы", "game.inspector.planet.rename.invalid.whitespace": "имя не может содержать пробелы",
"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.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": "исследование",
@@ -311,8 +301,14 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.planet.production.research.weapons": "оружие", "game.inspector.planet.production.research.weapons": "оружие",
"game.inspector.planet.production.research.shields": "щиты", "game.inspector.planet.production.research.shields": "щиты",
"game.inspector.planet.production.research.cargo": "трюм", "game.inspector.planet.production.research.cargo": "трюм",
"game.inspector.planet.production.target.research.aria": "цель исследования",
"game.inspector.planet.production.target.research.placeholder": "(технология или наука)",
"game.inspector.planet.production.target.ship.aria": "класс корабля",
"game.inspector.planet.production.target.ship.placeholder": "(класс корабля)",
"game.inspector.planet.production.ship.no_classes": "классы кораблей ещё не спроектированы", "game.inspector.planet.production.ship.no_classes": "классы кораблей ещё не спроектированы",
"game.inspector.planet.cargo.title": "грузовые маршруты", "game.inspector.planet.production.apply": "применить изменение производства",
"game.inspector.planet.production.cancel": "отменить изменение производства",
"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": "сырьё",
@@ -603,11 +599,9 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.ship_group.action.invalid.level": "уровень должен быть в ({current}, {max}]", "game.inspector.ship_group.action.invalid.level": "уровень должен быть в ({current}, {max}]",
"game.inspector.ship_group.action.invalid.fleet_name": "имя флота не соответствует правилам имён сущностей", "game.inspector.ship_group.action.invalid.fleet_name": "имя флота не соответствует правилам имён сущностей",
"game.inspector.planet.ship_groups.race_filter.aria": "раса в орбите",
"game.inspector.planet.ship_groups.title": "корабли на орбите", "game.inspector.planet.ship_groups.title": "корабли на орбите",
"game.inspector.planet.ship_groups.row.count": "{count} кораблей",
"game.inspector.planet.ship_groups.row.mass": "масса {mass}",
"game.inspector.planet.ship_groups.race.unknown": "неизвестно", "game.inspector.planet.ship_groups.race.unknown": "неизвестно",
"game.inspector.planet.ship_groups.race.foreign": "чужие",
"game.report.loading": "загрузка отчёта…", "game.report.loading": "загрузка отчёта…",
"game.report.back_to_map": "назад к карте", "game.report.back_to_map": "назад к карте",
+89 -96
View File
@@ -1,16 +1,20 @@
<!-- <!--
Planet inspector. Renders the documented field set for each planet Planet inspector. Renders the documented field set for each planet
kind (local / other / uninhabited / unidentified) and exposes a kind (local / other / uninhabited / unidentified) and exposes a
Rename action on owned (`local`) planets that opens an inline click-to-edit affordance on the name itself for owned (`local`)
editor. The editor runs the same `validateEntityName` rules as the planets: a click on the name turns it into an inline input with a
server-side validator (parity with `pkg/util/string.go`) and, on single ✓ confirm icon (Escape cancels). The editor runs the same
confirm, appends a `planetRename` command to the local order draft `validateEntityName` rules as the server-side validator (parity with
through the `OrderDraftStore` provided via context. `pkg/util/string.go`) and, on confirm, appends a `planetRename`
command to the local order draft through the `OrderDraftStore`
provided via context.
The read-only path stays unchanged for non-`local` planets. The The read-only path stays unchanged for non-`local` planets. The
inline editor lives directly inside this component per PLAN.md inline editor lives directly inside this component — a separate
Phase 14 — a separate file would be over-abstraction for one input file would be over-abstraction for one input field and a confirm
field with five buttons. button. F8-05 (issue #48 п.13) dropped the separate `Rename`
action button and the explicit `Cancel` button: the name itself is
the entry point, Escape (or unmounting the inspector) reverts.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext, tick } from "svelte"; import { getContext, tick } from "svelte";
@@ -158,68 +162,57 @@ field with five buttons.
> >
<header> <header>
<p class="kind" data-testid="inspector-planet-kind">{kindLabel}</p> <p class="kind" data-testid="inspector-planet-kind">{kindLabel}</p>
{#if planet.kind !== "unidentified"} {#if planet.kind === "local"}
{#if renameOpen}
<div class="rename" data-testid="inspector-planet-rename">
<input
id="planet-rename-input"
type="text"
class="rename-input"
data-testid="inspector-planet-rename-input"
aria-label={i18n.t("game.inspector.planet.rename.title")}
bind:value={renameInput}
bind:this={inputEl}
onkeydown={onKeyDown}
aria-invalid={renameValidation.ok ? "false" : "true"}
aria-describedby={renameValidation.ok ? undefined : "planet-rename-error"}
/>
<button
type="button"
class="icon-action icon-action--apply"
data-testid="inspector-planet-rename-confirm"
disabled={!renameValidation.ok || draft === undefined}
aria-label={i18n.t("game.inspector.planet.rename.confirm")}
onclick={() => void confirmRename()}
>
<span aria-hidden="true"></span>
</button>
</div>
{#if !renameValidation.ok}
<p
id="planet-rename-error"
class="rename-error"
data-testid="inspector-planet-rename-error"
>
{renameInvalidMessage}
</p>
{/if}
{:else}
<button
type="button"
class="name name--editable"
data-testid="inspector-planet-name"
aria-label={i18n.t("game.inspector.planet.action.rename")}
onclick={openRename}
>
{planet.name}
</button>
{/if}
{:else if planet.kind !== "unidentified"}
<h3 class="name" data-testid="inspector-planet-name">{planet.name}</h3> <h3 class="name" data-testid="inspector-planet-name">{planet.name}</h3>
{/if} {/if}
{#if planet.kind === "local" && !renameOpen}
<button
type="button"
class="action"
data-testid="inspector-planet-rename-action"
onclick={openRename}
>
{i18n.t("game.inspector.planet.action.rename")}
</button>
{/if}
</header> </header>
{#if planet.kind === "local" && renameOpen}
<div class="rename" data-testid="inspector-planet-rename">
<label class="rename-label" for="planet-rename-input">
{i18n.t("game.inspector.planet.rename.title")}
</label>
<input
id="planet-rename-input"
type="text"
class="rename-input"
data-testid="inspector-planet-rename-input"
bind:value={renameInput}
bind:this={inputEl}
onkeydown={onKeyDown}
aria-invalid={renameValidation.ok ? "false" : "true"}
aria-describedby={renameValidation.ok ? undefined : "planet-rename-error"}
/>
{#if !renameValidation.ok}
<p
id="planet-rename-error"
class="rename-error"
data-testid="inspector-planet-rename-error"
>
{renameInvalidMessage}
</p>
{/if}
<div class="rename-actions">
<button
type="button"
class="rename-cancel"
data-testid="inspector-planet-rename-cancel"
onclick={cancelRename}
>
{i18n.t("game.inspector.planet.rename.cancel")}
</button>
<button
type="button"
class="rename-confirm"
data-testid="inspector-planet-rename-confirm"
disabled={!renameValidation.ok || draft === undefined}
onclick={() => void confirmRename()}
>
{i18n.t("game.inspector.planet.rename.confirm")}
</button>
</div>
</div>
{/if}
{#if planet.kind === "local"} {#if planet.kind === "local"}
<Production {planet} {localShipClass} {localScience} /> <Production {planet} {localShipClass} {localScience} />
<CargoRoutes <CargoRoutes
@@ -374,34 +367,35 @@ field with five buttons.
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.8rem; font-size: 0.8rem;
} }
.action { .name--editable {
align-self: flex-start;
margin-top: 0.25rem;
font: inherit; font: inherit;
font-size: 0.85rem; font-size: 1.05rem;
padding: 0.2rem 0.55rem; font-weight: 600;
text-align: left;
padding: 0.1rem 0.25rem;
margin: 0 -0.25rem;
background: transparent; background: transparent;
color: var(--color-text-muted); color: inherit;
border: 1px solid var(--color-border); border: 1px dashed transparent;
border-radius: 3px; border-radius: 3px;
cursor: pointer; cursor: text;
} }
.action:hover { .name--editable:hover,
color: var(--color-text); .name--editable:focus-visible {
border-color: var(--color-accent); border-color: var(--color-border);
background: var(--color-surface-hover);
} }
.rename { .rename {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 0.35rem; gap: 0.4rem;
}
.rename-label {
font-size: 0.85rem;
color: var(--color-text-muted);
} }
.rename-input { .rename-input {
flex: 1;
min-width: 0;
font: inherit; font: inherit;
padding: 0.3rem 0.5rem; font-size: 1rem;
padding: 0.25rem 0.45rem;
background: var(--color-bg); background: var(--color-bg);
color: var(--color-text); color: var(--color-text);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -411,32 +405,31 @@ field with five buttons.
border-color: var(--color-danger); border-color: var(--color-danger);
} }
.rename-error { .rename-error {
margin: 0; margin: 0.2rem 0 0 0;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--color-danger); color: var(--color-danger);
} }
.rename-actions { .icon-action {
display: flex; flex: 0 0 auto;
gap: 0.4rem;
}
.rename-cancel,
.rename-confirm {
font: inherit; font: inherit;
font-size: 0.85rem; font-size: 0.95rem;
padding: 0.25rem 0.65rem; line-height: 1;
padding: 0.25rem 0.5rem;
background: transparent; background: transparent;
color: var(--color-text-muted); color: var(--color-text-muted);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 3px; border-radius: 3px;
cursor: pointer; cursor: pointer;
} }
.rename-confirm:not(:disabled):hover, .icon-action:not(:disabled):hover {
.rename-cancel:hover {
color: var(--color-text); color: var(--color-text);
border-color: var(--color-accent); border-color: var(--color-accent);
} }
.rename-confirm:disabled { .icon-action:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.5;
} }
.icon-action--apply:not(:disabled) {
color: var(--color-success);
}
</style> </style>
@@ -1,20 +1,24 @@
<!-- <!--
Phase 16 cargo-routes subsection of the planet inspector. Shows a F8-05 cargo-routes subsection of the planet inspector. Renders one
fixed COL/CAP/MAT/EMP four-slot table for the active local planet, compact row: a single `<select>` with the COL/CAP/MAT/EMP load-types
each slot either empty (with a single Add button) or filled (with (plus a default placeholder option that absorbs the old section
the destination planet's name plus Edit and Remove buttons). Add title) and a context block to its right showing either an `add`
and Edit hand off to the renderer-driven `MapPickService`: the map button when the selected type has no route, or `→ destination` plus
dims out-of-reach planets, draws the cursor-line anchor, and `edit` and `remove` buttons when a route is in place.
resolves with either a chosen destination id or `null` (cancel).
The component is purposely deferential to the existing infrastructure: Picking a load-type out of the dropdown does not commit anything by
- `OrderDraftStore` enforces the `(source, loadType)` collapse rule, itself: the player still has to press add / edit (which hand off to
so the optimistic overlay always matches what the server sees. the renderer-driven `MapPickService`) or remove (which appends a
- `MapPickService.pick(...)` is a renderer-side abstraction; its `removeCargoRoute` command directly). After every action the
source/destination semantics live in `lib/active-view/map.svelte`. dropdown stays on the type that was just acted on, so the result is
- Reach (`40 * driveTech` per `game/internal/model/game/race.go`) visible in place. The `OrderDraftStore.add()` collapse rule keeps at
is computed inline using `torusShortestDelta` to mirror the most one entry per `(source, loadType)` pair, mirroring the engine's
engine's torus distance — see `pkg/util/map.go.deltas`. own constraint.
Reach (`40 * driveTech` per `game/internal/model/game/race.go`) is
still computed inline through `torusShortestDelta`; the picker
shipping in `MapPickService.pick(...)` already mirrors the engine's
torus distance via the F8-07 (#50) fix.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
@@ -59,13 +63,15 @@ The component is purposely deferential to the existing infrastructure:
const disabled = $derived(draft === undefined || pick === undefined); const disabled = $derived(draft === undefined || pick === undefined);
let pendingSlot: CargoLoadType | null = $state(null); let pendingSlot: CargoLoadType | null = $state(null);
let selected = $state<CargoLoadType | "">("");
$effect(() => { $effect(() => {
// Reset the in-flight slot whenever the inspector switches to a // Reset the local UI whenever the inspector switches to a
// different planet so a stale "pick in progress" prompt does // different planet so a stale dropdown selection (or in-flight
// not leak across the selection boundary. // pick prompt) does not leak across the selection boundary.
void planet.number; void planet.number;
pendingSlot = null; pendingSlot = null;
selected = "";
}); });
const SLOT_LABELS: Record<CargoLoadType, TranslationKey> = { const SLOT_LABELS: Record<CargoLoadType, TranslationKey> = {
@@ -78,9 +84,9 @@ The component is purposely deferential to the existing infrastructure:
const currentEntries = $derived( const currentEntries = $derived(
routes.find((r) => r.sourcePlanetNumber === planet.number)?.entries ?? [], routes.find((r) => r.sourcePlanetNumber === planet.number)?.entries ?? [],
); );
// Per-slot derived map keeps the template's {#each} block free of // Per-slot derived map keeps the template free of the
// the {@const}/`find` chain that Svelte 5 sometimes mis-tracks // `.find(...)` chain that Svelte 5 sometimes mis-tracks when the
// when the source array is freshly cloned by `applyOrderOverlay`. // source array is freshly cloned by `applyOrderOverlay`.
const slotEntries = $derived.by(() => { const slotEntries = $derived.by(() => {
const map: Record<CargoLoadType, ReportRoute["entries"][number] | null> = { const map: Record<CargoLoadType, ReportRoute["entries"][number] | null> = {
COL: null, COL: null,
@@ -162,67 +168,82 @@ The component is purposely deferential to the existing infrastructure:
function cancelPick(): void { function cancelPick(): void {
pick?.cancel(); pick?.cancel();
} }
function pickType(event: Event): void {
selected = (event.target as HTMLSelectElement).value as CargoLoadType | "";
}
const activeEntry = $derived(
selected === "" ? null : slotEntries[selected],
);
const selectedSlug = $derived(
selected === "" ? "" : selected.toLowerCase(),
);
</script> </script>
<section class="cargo" data-testid="inspector-planet-cargo"> <section class="cargo" data-testid="inspector-planet-cargo">
<h4 class="title"> <div class="row">
{i18n.t("game.inspector.planet.cargo.title")} <select
</h4> class="select"
<dl class="slots"> data-testid="inspector-planet-cargo-type"
{#each CARGO_LOAD_TYPE_VALUES as loadType (loadType)} aria-label={i18n.t("game.inspector.planet.cargo.placeholder")}
{@const entry = slotEntries[loadType]} value={selected}
{@const slug = loadType.toLowerCase()} onchange={pickType}
<div class="slot" data-testid={`inspector-planet-cargo-slot-${slug}`}> disabled={disabled || pendingSlot !== null}
<dt class="slot-label" data-testid={`inspector-planet-cargo-slot-${slug}-label`}> >
<option value="" disabled class="placeholder">
{i18n.t("game.inspector.planet.cargo.placeholder")}
</option>
{#each CARGO_LOAD_TYPE_VALUES as loadType (loadType)}
<option
value={loadType}
data-testid={`inspector-planet-cargo-type-option-${loadType.toLowerCase()}`}
>
{i18n.t(SLOT_LABELS[loadType])} {i18n.t(SLOT_LABELS[loadType])}
</dt> </option>
<dd class="slot-body"> {/each}
{#if entry === null} </select>
<span
class="empty" {#if selected !== ""}
data-testid={`inspector-planet-cargo-slot-${slug}-empty`} {#if activeEntry === null}
> <button
{i18n.t("game.inspector.planet.cargo.empty")} type="button"
</span> class="action add"
<button data-testid={`inspector-planet-cargo-slot-${selectedSlug}-add`}
type="button" disabled={disabled || pendingSlot !== null}
class="action add" onclick={() => void startPick(selected as CargoLoadType)}
data-testid={`inspector-planet-cargo-slot-${slug}-add`} >
disabled={disabled || pendingSlot !== null} {i18n.t("game.inspector.planet.cargo.add")}
onclick={() => void startPick(loadType)} </button>
> {:else}
{i18n.t("game.inspector.planet.cargo.add")} <span
</button> class="destination"
{:else} data-testid={`inspector-planet-cargo-slot-${selectedSlug}-destination`}
<span >
class="destination" {destinationName(activeEntry.destinationPlanetNumber)}
data-testid={`inspector-planet-cargo-slot-${slug}-destination`} </span>
> <button
{destinationName(entry.destinationPlanetNumber)} type="button"
</span> class="action edit"
<button data-testid={`inspector-planet-cargo-slot-${selectedSlug}-edit`}
type="button" disabled={disabled || pendingSlot !== null}
class="action edit" onclick={() => void startPick(selected as CargoLoadType)}
data-testid={`inspector-planet-cargo-slot-${slug}-edit`} >
disabled={disabled || pendingSlot !== null} {i18n.t("game.inspector.planet.cargo.edit")}
onclick={() => void startPick(loadType)} </button>
> <button
{i18n.t("game.inspector.planet.cargo.edit")} type="button"
</button> class="action remove"
<button data-testid={`inspector-planet-cargo-slot-${selectedSlug}-remove`}
type="button" disabled={disabled || pendingSlot !== null}
class="action remove" onclick={() => void removeRoute(selected as CargoLoadType)}
data-testid={`inspector-planet-cargo-slot-${slug}-remove`} >
disabled={disabled || pendingSlot !== null} {i18n.t("game.inspector.planet.cargo.remove")}
onclick={() => void removeRoute(loadType)} </button>
> {/if}
{i18n.t("game.inspector.planet.cargo.remove")} {/if}
</button> </div>
{/if}
</dd>
</div>
{/each}
</dl>
{#if pendingSlot !== null} {#if pendingSlot !== null}
<div <div
class="pick-prompt" class="pick-prompt"
@@ -241,7 +262,7 @@ The component is purposely deferential to the existing infrastructure:
{i18n.t("game.inspector.planet.cargo.pick.cancel")} {i18n.t("game.inspector.planet.cargo.pick.cancel")}
</button> </button>
</div> </div>
{:else if reach > 0 && reachableSet().size === 0} {:else if selected !== "" && reach > 0 && reachableSet().size === 0}
<p <p
class="no-destinations" class="no-destinations"
data-testid="inspector-planet-cargo-no-destinations" data-testid="inspector-planet-cargo-no-destinations"
@@ -259,29 +280,7 @@ The component is purposely deferential to the existing infrastructure:
flex-direction: column; flex-direction: column;
gap: 0.4rem; gap: 0.4rem;
} }
.title { .row {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
font-weight: 500;
}
.slots {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
row-gap: 0.25rem;
column-gap: 0.6rem;
}
.slot {
display: contents;
}
.slot-label {
color: var(--color-text-muted);
font-size: 0.85rem;
align-self: center;
}
.slot-body {
margin: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
@@ -289,7 +288,23 @@ The component is purposely deferential to the existing infrastructure:
font-size: 0.9rem; font-size: 0.9rem;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.empty { .select {
flex: 1 1 8rem;
min-width: 0;
font: inherit;
font-size: 0.85rem;
padding: 0.2rem 0.4rem;
background: var(--color-surface-raised);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer;
}
.select:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.select option.placeholder {
color: var(--color-text-muted); color: var(--color-text-muted);
font-style: italic; font-style: italic;
} }
@@ -1,35 +1,31 @@
<!-- <!--
Phase 15 production-controls subsection of the planet inspector. F8-05 production-controls subsection of the planet inspector. Renders
Renders four main segments — industry / materials / research / build two dropdowns on a single row (primary: industry / materials /
ship — and reveals a sub-row when the player picks a category that research / ship; secondary: tech / science / ship class — only for
needs a target (research → tech field or science, build ship → the research and ship contexts) together with green ✓ apply and
designed class). Every leaf click appends a `setProductionType` yellow ✗ cancel icon buttons. The apply button becomes enabled when
command to the local order draft via `OrderDraftStore`; the the row selection differs from the planet's current effective
collapse-by-`planetNumber` rule inside `add` keeps at most one production (post-overlay) and the choice is complete (industry /
production choice per planet. materials need no secondary, research / ship require one). Apply
appends a `setProductionType` command to the local order draft via
`OrderDraftStore`; the collapse-by-`planetNumber` rule inside `add`
keeps at most one production choice per planet. Cancel resets the
local row state to the current effective value — it does **not**
revoke an in-flight command (that is the Order tab's responsibility).
The currently-active segment is derived from `planet.production` Until F8-05 every leaf click auto-submitted; the apply gate is the
new shape. The active-on-row state is derived from `planet.production`
through a parser that mirrors the engine's through a parser that mirrors the engine's
`Cache.PlanetProductionDisplayName` mapping. While the player is `Cache.PlanetProductionDisplayName` mapping. Whenever the inspector
mid-navigation (e.g. clicked Research but has not picked a tech yet) switches to a different planet or `planet.production` itself changes
a transient `expandedMain` override widens the visible state so the (typically after a successful apply round-trip projected by
sub-row can appear without forcing the player to commit a choice `applyOrderOverlay`), the row re-seeds from the new parsed value.
first; the override resets whenever the inspector switches to a
different planet or after any leaf click.
Phase 15 deliberately defers the per-type forecast number — see The Research target list combines the four tech display strings with
`ui/docs/calc-bridge.md` for the gap analysis. The component does each defined science from `localScience`. A science name shadows the
not render forecast text; the existing `freeIndustry` ("free tech display string with the same text the engine sends a single
production") row in the parent inspector is unchanged. ambiguous display string in `planet.production`; user-defined
sciences win because they carry more user intent.
Phase 21 widens the Research sub-row: in addition to the four tech
buttons the player sees one extra button per defined science from
`localScience`. The active highlight prefers a science-name match
over the four tech display strings, so a science deliberately named
exactly "Drive" / "Weapons" / "Shields" / "Cargo" shadows the
matching tech button (the engine sends a single ambiguous display
string in `planet.production`; user-defined sciences win because
they carry more user intent).
--> -->
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
@@ -59,26 +55,10 @@ they carry more user intent).
); );
const disabled = draft === undefined; const disabled = draft === undefined;
let expandedMain: MainSegment | null = $state(null); type TechFbs = "DRIVE" | "WEAPONS" | "SHIELDS" | "CARGO";
const parsedMain = $derived(
parseMain(planet.production, localShipClass, localScience),
);
const selectedMain = $derived(expandedMain ?? parsedMain);
const activeResearch = $derived(parseResearch(planet.production, localScience));
const activeScience = $derived(parseScience(planet.production, localScience));
const activeShip = $derived(parseShip(planet.production, localShipClass));
$effect(() => {
// Reset the expand-override whenever the inspector switches to a
// different planet so a stale category does not leak across the
// selection boundary.
void planet.number;
expandedMain = null;
});
const RESEARCH_OPTIONS: ReadonlyArray<{ const RESEARCH_OPTIONS: ReadonlyArray<{
fbs: ProductionType; fbs: TechFbs;
slug: "drive" | "weapons" | "shields" | "cargo"; slug: "drive" | "weapons" | "shields" | "cargo";
labelKey: TranslationKey; labelKey: TranslationKey;
}> = [ }> = [
@@ -128,14 +108,14 @@ they carry more user intent).
return classes.some((c) => c.name === value) ? "ship" : null; return classes.some((c) => c.name === value) ? "ship" : null;
} }
function parseResearch( function parseTarget(
value: string | null, value: string | null,
classes: ShipClassSummary[],
sciences: ScienceSummary[], sciences: ScienceSummary[],
): ProductionType | null { ): string | null {
// A science name shadows the four tech display strings — when a if (value === null || value === "") return null;
// science matches we surface no tech-button highlight so the // Science wins over tech display string (same shadowing rule).
// science button gets the active styling instead. if (sciences.some((s) => s.name === value)) return value;
if (value !== null && sciences.some((s) => s.name === value)) return null;
switch (value) { switch (value) {
case "Drive": case "Drive":
return "DRIVE"; return "DRIVE";
@@ -145,54 +125,93 @@ they carry more user intent).
return "SHIELDS"; return "SHIELDS";
case "Cargo": case "Cargo":
return "CARGO"; return "CARGO";
default:
return null;
} }
}
function parseScience(
value: string | null,
sciences: ScienceSummary[],
): string | null {
if (value === null || value === "") return null;
return sciences.some((s) => s.name === value) ? value : null;
}
function parseShip(
value: string | null,
classes: ShipClassSummary[],
): string | null {
if (value === null || value === "") return null;
return classes.some((c) => c.name === value) ? value : null; return classes.some((c) => c.name === value) ? value : null;
} }
function clickMain(segment: MainSegment): void { const parsedMain = $derived(
if (segment === "industry") { parseMain(planet.production, localShipClass, localScience),
void emit("CAP", ""); );
expandedMain = null; const parsedTarget = $derived(
parseTarget(planet.production, localShipClass, localScience),
);
// 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>("");
$effect(() => {
// Reset row state whenever the inspector switches to a different
// planet or the effective production changes (e.g. after a
// successful apply projected by the overlay). Reads are tracked
// even though the assignments do not consume the values.
void planet.number;
void parsedMain;
void parsedTarget;
mainSel = parsedMain ?? DEFAULT_MAIN;
targetSel = parsedTarget ?? "";
});
const needsTarget = $derived(mainSel === "research" || mainSel === "ship");
const dirty = $derived(
mainSel !== parsedMain
|| (targetSel === "" ? null : targetSel) !== parsedTarget,
);
const applyDisabled = $derived(
disabled
|| (needsTarget && targetSel === "")
|| !dirty,
);
const cancelDisabled = $derived(disabled || !dirty);
function pickMain(event: Event): void {
mainSel = (event.target as HTMLSelectElement).value as MainSegment;
// Switching the primary list clears any pending secondary
// choice — the picker for the new main might not even include
// the previous target.
targetSel = "";
}
function pickTarget(event: Event): void {
targetSel = (event.target as HTMLSelectElement).value;
}
async function applyRow(): Promise<void> {
if (applyDisabled || draft === undefined) return;
if (mainSel === "industry") {
await emit("CAP", "");
return; return;
} }
if (segment === "materials") { if (mainSel === "materials") {
void emit("MAT", ""); await emit("MAT", "");
expandedMain = null;
return; return;
} }
expandedMain = segment; if (mainSel === "research") {
const tech = RESEARCH_OPTIONS.find((o) => o.fbs === targetSel);
if (tech !== undefined) {
await emit(tech.fbs, "");
} else {
await emit("SCIENCE", targetSel);
}
return;
}
if (mainSel === "ship") {
await emit("SHIP", targetSel);
}
} }
function clickResearch(value: ProductionType): void { function cancelRow(): void {
void emit(value, ""); mainSel = parsedMain ?? DEFAULT_MAIN;
expandedMain = null; targetSel = parsedTarget ?? "";
}
function clickScience(name: string): void {
void emit("SCIENCE", name);
expandedMain = null;
}
function clickShip(name: string): void {
void emit("SHIP", name);
expandedMain = null;
} }
async function emit( async function emit(
@@ -214,103 +233,110 @@ they carry more user intent).
<h4 class="title"> <h4 class="title">
{i18n.t("game.inspector.planet.production.title")} {i18n.t("game.inspector.planet.production.title")}
</h4> </h4>
<div class="row main"> <div class="row">
<select
class="select"
data-testid="inspector-planet-production-main"
aria-label={i18n.t("game.inspector.planet.production.main.aria")}
value={mainSel}
onchange={pickMain}
{disabled}
>
<option value="industry">
{i18n.t("game.inspector.planet.production.option.industry")}
</option>
<option value="materials">
{i18n.t("game.inspector.planet.production.option.materials")}
</option>
<option value="research">
{i18n.t("game.inspector.planet.production.option.research")}
</option>
<option value="ship">
{i18n.t("game.inspector.planet.production.option.ship")}
</option>
</select>
{#if needsTarget}
<select
class="select"
data-testid="inspector-planet-production-target"
aria-label={i18n.t(
mainSel === "research"
? "game.inspector.planet.production.target.research.aria"
: "game.inspector.planet.production.target.ship.aria",
)}
value={targetSel}
onchange={pickTarget}
{disabled}
>
<option value="">
{i18n.t(
mainSel === "research"
? "game.inspector.planet.production.target.research.placeholder"
: "game.inspector.planet.production.target.ship.placeholder",
)}
</option>
{#if mainSel === "research"}
{#each RESEARCH_OPTIONS as option (option.fbs)}
<option
value={option.fbs}
data-testid={`inspector-planet-production-target-option-${option.slug}`}
>
{i18n.t(option.labelKey)}
</option>
{/each}
{#each localScience as sci (sci.name)}
<option
value={sci.name}
data-testid={`inspector-planet-production-target-option-science-${sci.name}`}
>
{sci.name}
</option>
{/each}
{:else if mainSel === "ship"}
{#if localShipClass.length === 0}
<option
value=""
disabled
data-testid="inspector-planet-production-ship-empty"
>
{i18n.t("game.inspector.planet.production.ship.no_classes")}
</option>
{:else}
{#each localShipClass as cls (cls.name)}
<option
value={cls.name}
data-testid={`inspector-planet-production-target-option-ship-${cls.name}`}
>
{cls.name}
</option>
{/each}
{/if}
{/if}
</select>
{/if}
<button <button
type="button" type="button"
class="seg" class="icon-action icon-action--apply"
class:active={selectedMain === "industry"} data-testid="inspector-planet-production-apply"
data-testid="inspector-planet-production-segment-industry" disabled={applyDisabled}
disabled={disabled} aria-label={i18n.t("game.inspector.planet.production.apply")}
onclick={() => clickMain("industry")} onclick={() => void applyRow()}
> >
{i18n.t("game.inspector.planet.production.option.industry")} <span aria-hidden="true"></span>
</button> </button>
<button <button
type="button" type="button"
class="seg" class="icon-action icon-action--cancel"
class:active={selectedMain === "materials"} data-testid="inspector-planet-production-cancel"
data-testid="inspector-planet-production-segment-materials" disabled={cancelDisabled}
disabled={disabled} aria-label={i18n.t("game.inspector.planet.production.cancel")}
onclick={() => clickMain("materials")} onclick={cancelRow}
> >
{i18n.t("game.inspector.planet.production.option.materials")} <span aria-hidden="true"></span>
</button>
<button
type="button"
class="seg"
class:active={selectedMain === "research"}
data-testid="inspector-planet-production-segment-research"
disabled={disabled}
onclick={() => clickMain("research")}
>
{i18n.t("game.inspector.planet.production.option.research")}
</button>
<button
type="button"
class="seg"
class:active={selectedMain === "ship"}
data-testid="inspector-planet-production-segment-ship"
disabled={disabled}
onclick={() => clickMain("ship")}
>
{i18n.t("game.inspector.planet.production.option.ship")}
</button> </button>
</div> </div>
{#if selectedMain === "research"}
<div class="row sub" data-testid="inspector-planet-production-research-row">
{#each RESEARCH_OPTIONS as option (option.fbs)}
<button
type="button"
class="sub-seg"
class:active={activeResearch === option.fbs}
data-testid={`inspector-planet-production-research-${option.slug}`}
disabled={disabled}
onclick={() => clickResearch(option.fbs)}
>
{i18n.t(option.labelKey)}
</button>
{/each}
{#each localScience as sci (sci.name)}
<button
type="button"
class="sub-seg"
class:active={activeScience === sci.name}
data-testid={`inspector-planet-production-science-${sci.name}`}
disabled={disabled}
onclick={() => clickScience(sci.name)}
>
{sci.name}
</button>
{/each}
</div>
{/if}
{#if selectedMain === "ship"}
<div class="row sub" data-testid="inspector-planet-production-ship-row">
{#if localShipClass.length === 0}
<p
class="empty"
data-testid="inspector-planet-production-ship-empty"
>
{i18n.t("game.inspector.planet.production.ship.no_classes")}
</p>
{:else}
{#each localShipClass as cls (cls.name)}
<button
type="button"
class="sub-seg"
class:active={activeShip === cls.name}
data-testid={`inspector-planet-production-ship-${cls.name}`}
disabled={disabled}
onclick={() => clickShip(cls.name)}
>
{cls.name}
</button>
{/each}
{/if}
</div>
{/if}
</section> </section>
<style> <style>
@@ -327,43 +353,49 @@ they carry more user intent).
} }
.row { .row {
display: flex; display: flex;
gap: 0.3rem; align-items: center;
flex-wrap: wrap; gap: 0.35rem;
flex-wrap: nowrap;
} }
.row.sub { .select {
padding-left: 0.6rem; flex: 1 1 6rem;
} min-width: 0;
.seg,
.sub-seg {
font: inherit; font: inherit;
font-size: 0.85rem; font-size: 0.85rem;
padding: 0.25rem 0.55rem; padding: 0.2rem 0.4rem;
background: transparent; background: var(--color-surface-raised);
color: var(--color-text-muted); color: var(--color-text);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 3px; border-radius: 3px;
cursor: pointer; cursor: pointer;
} }
.seg:not(:disabled):hover, .select:disabled {
.sub-seg:not(:disabled):hover {
color: var(--color-text);
border-color: var(--color-accent);
}
.seg.active,
.sub-seg.active {
color: var(--color-text);
border-color: var(--color-accent);
background: var(--color-accent-subtle);
}
.seg:disabled,
.sub-seg:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.5;
} }
.empty { .icon-action {
margin: 0; flex: 0 0 auto;
font-size: 0.8rem; font: inherit;
font-size: 1rem;
line-height: 1;
padding: 0.15rem 0.2rem;
background: transparent;
color: var(--color-text-muted); color: var(--color-text-muted);
font-style: italic; border: 0;
border-radius: 0;
cursor: pointer;
}
.icon-action:not(:disabled):hover {
color: var(--color-text);
}
.icon-action:disabled {
cursor: not-allowed;
opacity: 0.35;
}
.icon-action--apply:not(:disabled) {
color: var(--color-success);
}
.icon-action--cancel:not(:disabled) {
color: var(--color-warning);
} }
</style> </style>
@@ -10,14 +10,24 @@ best-effort:
typed contract does not carry per-group ownership outside typed contract does not carry per-group ownership outside
battle rosters. battle rosters.
Phase 20 makes own-ship rows interactive: clicking a row pivots Phase 20 made own-ship rows interactive: clicking a row pivots
the inspector to the corresponding ship-group inspector through the inspector to the corresponding ship-group inspector through
the shared `SelectionStore`. The actions panel mounts on top of the shared `SelectionStore`. The actions panel mounts on top of
the existing ship-group inspector, so the row is the on-planet the existing ship-group inspector, so the row is the on-planet
entry point for Send / Load / Modernize / etc. Foreign rows stay entry point for Send / Load / Modernize / etc. Foreign rows stay
non-interactive — there are no actions to drive against another non-interactive — there are no actions to drive against another
race's fleet. Phase 21+ will reuse the same row shape inside the race's fleet.
ship-groups table view with an additional `(planet, race)` filter.
F8-05 (issue #48 п.32) moved the race column into a dropdown
above the table: the previous "race | class | count | mass"
layout overflowed horizontally on narrow viewports. The dropdown
seeds with the player's own race when local groups are stationed
here, otherwise with the first race alphabetically; both cases
are after sorting `availableRaces` alphabetically so the picker
is stable across re-mounts. When a single race is in orbit the
dropdown is hidden — there is nothing to choose — and the table
renders straight through. The race column is dropped in both
modes because the dropdown already names the active race.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
@@ -56,6 +66,17 @@ ship-groups table view with an additional `(planet, race)` filter.
groupId: string | null; groupId: string | null;
} }
// F8-05 owner-feedback: the row carries the authoritative `race`
// field projected by the engine (and by the legacy parser via the
// `<Race> Groups` header), so every stationed group surfaces its
// real owner. The planet-owner / "foreign" fallback is gone — when
// the wire carries no race the row falls back to the i18n
// `race.unknown` placeholder, matching how the local-race column
// degrades if `localRace` is missing.
const unknownRace = $derived(
i18n.t("game.inspector.planet.ship_groups.race.unknown"),
);
const stationedRows: StationedRow[] = $derived.by(() => { const stationedRows: StationedRow[] = $derived.by(() => {
const rows: StationedRow[] = []; const rows: StationedRow[] = [];
for (const g of localShipGroups) { for (const g of localShipGroups) {
@@ -63,7 +84,7 @@ ship-groups table view with an additional `(planet, race)` filter.
if (g.origin !== null || g.range !== null) continue; if (g.origin !== null || g.range !== null) continue;
rows.push({ rows.push({
key: `local:${g.id}`, key: `local:${g.id}`,
race: localRace || i18n.t("game.inspector.planet.ship_groups.race.unknown"), race: g.race || localRace || unknownRace,
class: g.class, class: g.class,
count: g.count, count: g.count,
mass: g.mass, mass: g.mass,
@@ -71,16 +92,13 @@ ship-groups table view with an additional `(planet, race)` filter.
groupId: g.id, groupId: g.id,
}); });
} }
const foreignRace =
planet.owner ??
i18n.t("game.inspector.planet.ship_groups.race.foreign");
for (let i = 0; i < otherShipGroups.length; i++) { for (let i = 0; i < otherShipGroups.length; i++) {
const g = otherShipGroups[i]!; const g = otherShipGroups[i]!;
if (g.destination !== planet.number) continue; if (g.destination !== planet.number) continue;
if (g.origin !== null || g.range !== null) continue; if (g.origin !== null || g.range !== null) continue;
rows.push({ rows.push({
key: `other:${i}`, key: `other:${i}`,
race: foreignRace, race: g.race || unknownRace,
class: g.class, class: g.class,
count: g.count, count: g.count,
mass: g.mass, mass: g.mass,
@@ -91,6 +109,55 @@ ship-groups table view with an additional `(planet, race)` filter.
return rows; return rows;
}); });
const ownStationedHere = $derived(
stationedRows.some((r) => r.selectable),
);
const availableRaces = $derived.by(() => {
const set = new Set<string>();
for (const row of stationedRows) set.add(row.race);
return Array.from(set).sort((a, b) => a.localeCompare(b));
});
const ownRaceLabel = $derived(
localRace || i18n.t("game.inspector.planet.ship_groups.race.unknown"),
);
function defaultRace(races: ReadonlyArray<string>): string {
if (races.length === 0) return "";
if (ownStationedHere && races.includes(ownRaceLabel)) return ownRaceLabel;
return races[0]!;
}
let selectedRace = $state<string>("");
$effect(() => {
// Re-seed whenever the inspector switches planets or the
// stationed roster changes (new arrivals after a turn).
// Preserve the player's pick if it is still represented;
// otherwise fall back to the documented default.
void planet.number;
const races = availableRaces;
if (races.length === 0) {
selectedRace = "";
return;
}
if (selectedRace === "" || !races.includes(selectedRace)) {
selectedRace = defaultRace(races);
}
});
const showFilter = $derived(availableRaces.length > 1);
const filteredRows = $derived(
showFilter
? stationedRows.filter((r) => r.race === selectedRace)
: stationedRows,
);
function pickRace(event: Event): void {
selectedRace = (event.target as HTMLSelectElement).value;
}
function selectLocalGroup(groupId: string): void { function selectLocalGroup(groupId: string): void {
if (selection === undefined) return; if (selection === undefined) return;
selection.selectShipGroup({ variant: "local", id: groupId }); selection.selectShipGroup({ variant: "local", id: groupId });
@@ -99,48 +166,45 @@ ship-groups table view with an additional `(planet, race)` filter.
{#if stationedRows.length > 0} {#if stationedRows.length > 0}
<section class="ship-groups" data-testid="inspector-planet-ship-groups"> <section class="ship-groups" data-testid="inspector-planet-ship-groups">
<h4>{i18n.t("game.inspector.planet.ship_groups.title")}</h4> <div class="head">
<h4>{i18n.t("game.inspector.planet.ship_groups.title")}</h4>
{#if showFilter}
<select
class="race-select"
data-testid="inspector-planet-ship-groups-race-filter"
aria-label={i18n.t(
"game.inspector.planet.ship_groups.race_filter.aria",
)}
value={selectedRace}
onchange={pickRace}
>
{#each availableRaces as race (race)}
<option value={race}>{race}</option>
{/each}
</select>
{/if}
</div>
<ul class="rows"> <ul class="rows">
{#each stationedRows as row (row.key)} {#each filteredRows as row (row.key)}
<li class="row" data-testid="inspector-planet-ship-groups-row"> <li class="row" data-testid="inspector-planet-ship-groups-row">
{#if row.selectable && row.groupId !== null} {#if row.selectable && row.groupId !== null}
{@const groupId = row.groupId} {@const groupId = row.groupId}
<button <button
type="button" type="button"
class="select" class="cells select"
data-testid="inspector-planet-ship-groups-select" data-testid="inspector-planet-ship-groups-select"
onclick={() => selectLocalGroup(groupId)} onclick={() => selectLocalGroup(groupId)}
> >
<span class="race" data-testid="inspector-planet-ship-groups-race">
{row.race}
</span>
<span class="class">{row.class}</span> <span class="class">{row.class}</span>
<span class="count"> <span class="count">{row.count} ×</span>
{i18n.t("game.inspector.planet.ship_groups.row.count", { <span class="mass">{formatFloat(row.mass)}</span>
count: String(row.count),
})}
</span>
<span class="mass">
{i18n.t("game.inspector.planet.ship_groups.row.mass", {
mass: formatFloat(row.mass),
})}
</span>
</button> </button>
{:else} {:else}
<span class="race" data-testid="inspector-planet-ship-groups-race"> <div class="cells">
{row.race} <span class="class">{row.class}</span>
</span> <span class="count">{row.count} ×</span>
<span class="class">{row.class}</span> <span class="mass">{formatFloat(row.mass)}</span>
<span class="count"> </div>
{i18n.t("game.inspector.planet.ship_groups.row.count", {
count: String(row.count),
})}
</span>
<span class="mass">
{i18n.t("game.inspector.planet.ship_groups.row.mass", {
mass: formatFloat(row.mass),
})}
</span>
{/if} {/if}
</li> </li>
{/each} {/each}
@@ -154,6 +218,12 @@ ship-groups table view with an additional `(planet, race)` filter.
flex-direction: column; flex-direction: column;
gap: 0.4rem; gap: 0.4rem;
} }
.head {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
h4 { h4 {
margin: 0; margin: 0;
font-size: 0.85rem; font-size: 0.85rem;
@@ -161,6 +231,18 @@ ship-groups table view with an additional `(planet, race)` filter.
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.race-select {
flex: 1 1 auto;
min-width: 0;
font: inherit;
font-size: 0.85rem;
padding: 0.15rem 0.4rem;
background: var(--color-surface-raised);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer;
}
.rows { .rows {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -172,13 +254,12 @@ ship-groups table view with an additional `(planet, race)` filter.
.row { .row {
display: block; display: block;
font-size: 0.85rem; font-size: 0.85rem;
font-variant-numeric: tabular-nums;
} }
.row > span, .cells {
.row > .select {
display: grid; display: grid;
grid-template-columns: 1fr 1fr auto auto; grid-template-columns: 1fr auto auto;
gap: 0.5rem; gap: 0.5rem;
align-items: baseline;
} }
.select { .select {
width: 100%; width: 100%;
@@ -195,14 +276,18 @@ ship-groups table view with an additional `(planet, race)` filter.
border-color: var(--color-border); border-color: var(--color-border);
background: var(--color-surface-hover); background: var(--color-surface-hover);
} }
.race {
font-weight: 600;
}
.class { .class {
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.count, .count {
color: var(--color-text-muted);
text-align: right;
font-variant-numeric: tabular-nums;
}
.mass { .mass {
color: var(--color-text-muted); color: var(--color-text-muted);
text-align: right;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
} }
</style> </style>
+40 -1
View File
@@ -10,6 +10,13 @@
* `data-theme` from the same `localStorage` key before the app boots; * `data-theme` from the same `localStorage` key before the app boots;
* this store mirrors that logic and takes over once mounted, including * this store mirrors that logic and takes over once mounted, including
* reacting to OS theme changes while the choice is `system`. * reacting to OS theme changes while the choice is `system`.
*
* On top of the persisted choice the store carries an ephemeral
* `override` channel: while non-null it short-circuits `resolved` so the
* in-game light/dark toggle can flip the document theme without touching
* the lobby-side preference. The override lives in memory only leaving
* the game shell (or any other consumer calling `clearOverride()`)
* re-projects the persisted choice.
*/ */
/** A user's theme preference; `system` follows the OS setting. */ /** A user's theme preference; `system` follows the OS setting. */
@@ -45,6 +52,7 @@ function systemTheme(): ResolvedTheme {
class ThemeStore { class ThemeStore {
#choice = $state<ThemeChoice>(readStoredChoice()); #choice = $state<ThemeChoice>(readStoredChoice());
#system = $state<ResolvedTheme>(systemTheme()); #system = $state<ResolvedTheme>(systemTheme());
#override = $state<ResolvedTheme | null>(null);
constructor() { constructor() {
if ( if (
@@ -55,7 +63,9 @@ class ThemeStore {
.matchMedia(SYSTEM_LIGHT_QUERY) .matchMedia(SYSTEM_LIGHT_QUERY)
.addEventListener("change", (event: MediaQueryListEvent) => { .addEventListener("change", (event: MediaQueryListEvent) => {
this.#system = event.matches ? "light" : "dark"; this.#system = event.matches ? "light" : "dark";
if (this.#choice === "system") this.#apply(); if (this.#choice === "system" && this.#override === null) {
this.#apply();
}
}); });
} }
this.#apply(); this.#apply();
@@ -66,8 +76,17 @@ class ThemeStore {
return this.#choice; return this.#choice;
} }
/**
* The current ephemeral override (set by the in-game toggle) or
* `null` when no override is active.
*/
get override(): ResolvedTheme | null {
return this.#override;
}
/** The concrete theme currently applied to the document. */ /** The concrete theme currently applied to the document. */
get resolved(): ResolvedTheme { get resolved(): ResolvedTheme {
if (this.#override !== null) return this.#override;
return this.#choice === "system" ? this.#system : this.#choice; return this.#choice === "system" ? this.#system : this.#choice;
} }
@@ -80,6 +99,26 @@ class ThemeStore {
this.#apply(); this.#apply();
} }
/**
* Set the ephemeral override. The override is not persisted; it lives
* until `clearOverride()` (or another `setOverride` call) replaces it.
*/
setOverride(value: ResolvedTheme): void {
this.#override = value;
this.#apply();
}
/**
* Drop the ephemeral override so the document re-projects the
* persisted preference. Cheap to call on every game-shell unmount
* a no-op when no override was set.
*/
clearOverride(): void {
if (this.#override === null) return;
this.#override = null;
this.#apply();
}
#apply(): void { #apply(): void {
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
document.documentElement.dataset.theme = this.resolved; document.documentElement.dataset.theme = this.resolved;
@@ -104,8 +104,15 @@ fleet(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
} }
race():string|null
race(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
race(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 30);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startLocalGroup(builder:flatbuffers.Builder) { static startLocalGroup(builder:flatbuffers.Builder) {
builder.startObject(13); builder.startObject(14);
} }
static addNumber(builder:flatbuffers.Builder, number:bigint) { static addNumber(builder:flatbuffers.Builder, number:bigint) {
@@ -172,6 +179,10 @@ static addFleet(builder:flatbuffers.Builder, fleetOffset:flatbuffers.Offset) {
builder.addFieldOffset(12, fleetOffset, 0); builder.addFieldOffset(12, fleetOffset, 0);
} }
static addRace(builder:flatbuffers.Builder, raceOffset:flatbuffers.Offset) {
builder.addFieldOffset(13, raceOffset, 0);
}
static endLocalGroup(builder:flatbuffers.Builder):flatbuffers.Offset { static endLocalGroup(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
builder.requiredField(offset, 24) // id builder.requiredField(offset, 24) // id
@@ -193,7 +204,8 @@ unpack(): LocalGroupT {
this.mass(), this.mass(),
(this.id() !== null ? this.id()!.unpack() : null), (this.id() !== null ? this.id()!.unpack() : null),
this.state(), this.state(),
this.fleet() this.fleet(),
this.race()
); );
} }
@@ -212,6 +224,7 @@ unpackTo(_o: LocalGroupT): void {
_o.id = (this.id() !== null ? this.id()!.unpack() : null); _o.id = (this.id() !== null ? this.id()!.unpack() : null);
_o.state = this.state(); _o.state = this.state();
_o.fleet = this.fleet(); _o.fleet = this.fleet();
_o.race = this.race();
} }
} }
@@ -229,7 +242,8 @@ constructor(
public mass: number = 0.0, public mass: number = 0.0,
public id: UUIDT|null = null, public id: UUIDT|null = null,
public state: string|Uint8Array|null = null, public state: string|Uint8Array|null = null,
public fleet: string|Uint8Array|null = null public fleet: string|Uint8Array|null = null,
public race: string|Uint8Array|null = null
){} ){}
@@ -239,6 +253,7 @@ pack(builder:flatbuffers.Builder): flatbuffers.Offset {
const cargo = (this.cargo !== null ? builder.createString(this.cargo!) : 0); const cargo = (this.cargo !== null ? builder.createString(this.cargo!) : 0);
const state = (this.state !== null ? builder.createString(this.state!) : 0); const state = (this.state !== null ? builder.createString(this.state!) : 0);
const fleet = (this.fleet !== null ? builder.createString(this.fleet!) : 0); const fleet = (this.fleet !== null ? builder.createString(this.fleet!) : 0);
const race = (this.race !== null ? builder.createString(this.race!) : 0);
LocalGroup.startLocalGroup(builder); LocalGroup.startLocalGroup(builder);
LocalGroup.addNumber(builder, this.number); LocalGroup.addNumber(builder, this.number);
@@ -256,6 +271,7 @@ pack(builder:flatbuffers.Builder): flatbuffers.Offset {
LocalGroup.addId(builder, (this.id !== null ? this.id!.pack(builder) : 0)); LocalGroup.addId(builder, (this.id !== null ? this.id!.pack(builder) : 0));
LocalGroup.addState(builder, state); LocalGroup.addState(builder, state);
LocalGroup.addFleet(builder, fleet); LocalGroup.addFleet(builder, fleet);
LocalGroup.addRace(builder, race);
return LocalGroup.endLocalGroup(builder); return LocalGroup.endLocalGroup(builder);
} }
@@ -84,8 +84,15 @@ mass():number {
return offset ? this.bb!.readFloat32(this.bb_pos + offset) : 0.0; return offset ? this.bb!.readFloat32(this.bb_pos + offset) : 0.0;
} }
race():string|null
race(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
race(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 24);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startOtherGroup(builder:flatbuffers.Builder) { static startOtherGroup(builder:flatbuffers.Builder) {
builder.startObject(10); builder.startObject(11);
} }
static addNumber(builder:flatbuffers.Builder, number:bigint) { static addNumber(builder:flatbuffers.Builder, number:bigint) {
@@ -140,12 +147,16 @@ static addMass(builder:flatbuffers.Builder, mass:number) {
builder.addFieldFloat32(9, mass, 0.0); builder.addFieldFloat32(9, mass, 0.0);
} }
static addRace(builder:flatbuffers.Builder, raceOffset:flatbuffers.Offset) {
builder.addFieldOffset(10, raceOffset, 0);
}
static endOtherGroup(builder:flatbuffers.Builder):flatbuffers.Offset { static endOtherGroup(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createOtherGroup(builder:flatbuffers.Builder, number:bigint, class_Offset:flatbuffers.Offset, techOffset:flatbuffers.Offset, cargoOffset:flatbuffers.Offset, load:number, destination:bigint, origin:bigint|null, range:number|null, speed:number, mass:number):flatbuffers.Offset { static createOtherGroup(builder:flatbuffers.Builder, number:bigint, class_Offset:flatbuffers.Offset, techOffset:flatbuffers.Offset, cargoOffset:flatbuffers.Offset, load:number, destination:bigint, origin:bigint|null, range:number|null, speed:number, mass:number, raceOffset:flatbuffers.Offset):flatbuffers.Offset {
OtherGroup.startOtherGroup(builder); OtherGroup.startOtherGroup(builder);
OtherGroup.addNumber(builder, number); OtherGroup.addNumber(builder, number);
OtherGroup.addClass(builder, class_Offset); OtherGroup.addClass(builder, class_Offset);
@@ -159,6 +170,7 @@ static createOtherGroup(builder:flatbuffers.Builder, number:bigint, class_Offset
OtherGroup.addRange(builder, range); OtherGroup.addRange(builder, range);
OtherGroup.addSpeed(builder, speed); OtherGroup.addSpeed(builder, speed);
OtherGroup.addMass(builder, mass); OtherGroup.addMass(builder, mass);
OtherGroup.addRace(builder, raceOffset);
return OtherGroup.endOtherGroup(builder); return OtherGroup.endOtherGroup(builder);
} }
@@ -173,7 +185,8 @@ unpack(): OtherGroupT {
this.origin(), this.origin(),
this.range(), this.range(),
this.speed(), this.speed(),
this.mass() this.mass(),
this.race()
); );
} }
@@ -189,6 +202,7 @@ unpackTo(_o: OtherGroupT): void {
_o.range = this.range(); _o.range = this.range();
_o.speed = this.speed(); _o.speed = this.speed();
_o.mass = this.mass(); _o.mass = this.mass();
_o.race = this.race();
} }
} }
@@ -203,7 +217,8 @@ constructor(
public origin: bigint|null = null, public origin: bigint|null = null,
public range: number|null = null, public range: number|null = null,
public speed: number = 0.0, public speed: number = 0.0,
public mass: number = 0.0 public mass: number = 0.0,
public race: string|Uint8Array|null = null
){} ){}
@@ -211,6 +226,7 @@ pack(builder:flatbuffers.Builder): flatbuffers.Offset {
const class_ = (this.class_ !== null ? builder.createString(this.class_!) : 0); const class_ = (this.class_ !== null ? builder.createString(this.class_!) : 0);
const tech = OtherGroup.createTechVector(builder, builder.createObjectOffsetList(this.tech)); const tech = OtherGroup.createTechVector(builder, builder.createObjectOffsetList(this.tech));
const cargo = (this.cargo !== null ? builder.createString(this.cargo!) : 0); const cargo = (this.cargo !== null ? builder.createString(this.cargo!) : 0);
const race = (this.race !== null ? builder.createString(this.race!) : 0);
return OtherGroup.createOtherGroup(builder, return OtherGroup.createOtherGroup(builder,
this.number, this.number,
@@ -222,7 +238,8 @@ pack(builder:flatbuffers.Builder): flatbuffers.Offset {
this.origin, this.origin,
this.range, this.range,
this.speed, this.speed,
this.mass this.mass,
race
); );
} }
} }
+10 -10
View File
@@ -44,17 +44,17 @@ test.describe("keyboard navigation", () => {
await expect(page.locator("#active-view-host")).toBeFocused(); await expect(page.locator("#active-view-host")).toBeFocused();
}); });
test("Escape closes the account menu and returns focus to its trigger", async ({ test("game-mode theme toggle is keyboard activatable", async ({ page }) => {
page,
}) => {
await bootShell(page); await bootShell(page);
await page.getByTestId("account-menu-trigger").click(); const toggle = page.getByTestId("game-mode-theme-toggle");
await expect(page.getByTestId("account-menu-list")).toBeVisible(); await toggle.focus();
// Move focus into the menu, then dismiss with Escape. await expect(toggle).toBeFocused();
await page.getByTestId("account-menu-theme-select").focus(); const before = await toggle.getAttribute("data-theme");
await page.keyboard.press("Escape"); await page.keyboard.press("Enter");
await expect(page.getByTestId("account-menu-list")).toBeHidden(); const after = await toggle.getAttribute("data-theme");
await expect(page.getByTestId("account-menu-trigger")).toBeFocused(); expect(after).not.toBe(before);
// The toggle keeps focus across an activation.
await expect(toggle).toBeFocused();
}); });
test("sidebar tabs move with the arrow keys (roving)", async ({ page }) => { test("sidebar tabs move with the arrow keys (roving)", async ({ page }) => {
+5 -4
View File
@@ -394,9 +394,8 @@ test("cargo-routes flow: pick a destination, arrow appears, reload restores", as
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText( await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText(
SOURCE_PLANET.name, SOURCE_PLANET.name,
); );
await expect( const typeSelect = sidebar.getByTestId("inspector-planet-cargo-type");
sidebar.getByTestId("inspector-planet-cargo-slot-col-empty"), await typeSelect.selectOption("COL");
).toBeVisible();
// Add a COL route. Expect pick-mode to open with `reachableIds` // Add a COL route. Expect pick-mode to open with `reachableIds`
// covering only the two near planets. // covering only the two near planets.
@@ -470,6 +469,7 @@ test("cargo-routes flow: pick a destination, arrow appears, reload restores", as
}); });
// Add a CAP route to confirm slots coexist. // Add a CAP route to confirm slots coexist.
await typeSelect.selectOption("CAP");
await page await page
.getByTestId("inspector-planet-cargo-slot-cap-add") .getByTestId("inspector-planet-cargo-slot-cap-add")
.first() .first()
@@ -495,12 +495,13 @@ test("cargo-routes flow: pick a destination, arrow appears, reload restores", as
.toBe(6); .toBe(6);
// Remove the COL route. // Remove the COL route.
await typeSelect.selectOption("COL");
await page await page
.getByTestId("inspector-planet-cargo-slot-col-remove") .getByTestId("inspector-planet-cargo-slot-col-remove")
.first() .first()
.click(); .click();
await expect( await expect(
page.getByTestId("inspector-planet-cargo-slot-col-empty").first(), page.getByTestId("inspector-planet-cargo-slot-col-add").first(),
).toBeVisible({ timeout: 10000 }); ).toBeVisible({ timeout: 10000 });
await expect await expect
.poll(() => handle.lastRouteRemove, { timeout: 10000 }) .poll(() => handle.lastRouteRemove, { timeout: 10000 })
+1 -1
View File
@@ -52,7 +52,7 @@ test("shell mounts with header / sidebar / active-view chrome", async ({
"turn", "turn",
); );
await expect(page.getByTestId("view-menu-trigger")).toBeVisible(); await expect(page.getByTestId("view-menu-trigger")).toBeVisible();
await expect(page.getByTestId("account-menu-trigger")).toBeVisible(); await expect(page.getByTestId("game-mode-theme-toggle")).toBeVisible();
}); });
test("header view-menu navigates to every active view", async ({ page }) => { test("header view-menu navigates to every active view", async ({ page }) => {
-44
View File
@@ -9,8 +9,6 @@
// flag mirroring the renderer's hide set. The spec counts the // flag mirroring the renderer's hide set. The spec counts the
// visible-foreign-planet primitives, etc. // visible-foreign-planet primitives, etc.
// * `getMapFog()` — the current visibility-fog circle list. // * `getMapFog()` — the current visibility-fog circle list.
// * `getMapCamera()` — the wrap-mode test reads the centre before
// and after the flip to confirm camera preservation.
// * `getMapRenderCount()` — painted-frame counter used by the // * `getMapRenderCount()` — painted-frame counter used by the
// render-on-demand specs at the bottom of this file: an idle map // render-on-demand specs at the bottom of this file: an idle map
// must not keep repainting, and a released drag must not coast // must not keep repainting, and a released drag must not coast
@@ -330,48 +328,6 @@ test("visibility fog toggles between the LOCAL-planet circle list and an empty o
); );
}); });
test("wrap mode radios flip the renderer and the camera centre survives", async ({
page,
}) => {
await mockGateway(page, { currentTurn: 1 });
await bootSession(page);
await openGame(page);
// Confirm the renderer starts in torus mode.
await page.waitForFunction(
() => window.__galaxyDebug?.getMapMode?.() === "torus",
);
const initial = await page.evaluate(() =>
window.__galaxyDebug!.getMapCamera!(),
);
expect(initial).not.toBeNull();
const startCentre = initial!.camera;
await page.getByTestId("map-toggles-trigger").click();
await page.getByTestId("map-toggles-wrap-no-wrap").click();
// `setWrapMode` triggers a full Pixi remount; wait for the
// renderer to settle into the new mode and the debug surface to
// re-register before reading the camera. The mode provider is
// re-bound inside `runSerializedMount` after `createRenderer`
// resolves, so observing `getMapMode() === "no-wrap"` is the
// canonical "remount complete" signal.
await page.waitForFunction(
() => window.__galaxyDebug?.getMapMode?.() === "no-wrap",
);
const after = await page.evaluate(() =>
window.__galaxyDebug!.getMapCamera!(),
);
expect(after).not.toBeNull();
expect(
Math.abs(after!.camera.centerX - startCentre.centerX),
).toBeLessThanOrEqual(1);
expect(
Math.abs(after!.camera.centerY - startCentre.centerY),
).toBeLessThanOrEqual(1);
});
test("toggle state persists across a page reload", async ({ page }) => { test("toggle state persists across a page reload", async ({ page }) => {
await mockGateway(page, { currentTurn: 1 }); await mockGateway(page, { currentTurn: 1 });
await bootSession(page); await bootSession(page);
+1 -1
View File
@@ -263,7 +263,7 @@ async function clickPlanetCentre(page: Page): Promise<void> {
async function startRename(page: Page, newName: string): Promise<void> { async function startRename(page: Page, newName: string): Promise<void> {
await clickPlanetCentre(page); await clickPlanetCentre(page);
const sidebar = page.getByTestId("sidebar-tool-inspector"); const sidebar = page.getByTestId("sidebar-tool-inspector");
await sidebar.getByTestId("inspector-planet-rename-action").click(); await sidebar.getByTestId("inspector-planet-name").click();
const input = sidebar.getByTestId("inspector-planet-rename-input"); const input = sidebar.getByTestId("inspector-planet-rename-input");
await input.fill(newName); await input.fill(newName);
await sidebar.getByTestId("inspector-planet-rename-confirm").click(); await sidebar.getByTestId("inspector-planet-rename-confirm").click();
+22 -20
View File
@@ -301,16 +301,22 @@ test("switching production three times collapses to one auto-synced row", async
const sidebar = page.getByTestId("sidebar-tool-inspector"); const sidebar = page.getByTestId("sidebar-tool-inspector");
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth"); await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth");
// Initial state: report.production = "Drive" → research segment is const mainSelect = sidebar.getByTestId("inspector-planet-production-main");
// active, sub-row reveals Drive as the highlighted tech. const targetSelect = sidebar.getByTestId(
await expect( "inspector-planet-production-target",
sidebar.getByTestId("inspector-planet-production-segment-research"), );
).toHaveClass(/active/); const applyBtn = sidebar.getByTestId("inspector-planet-production-apply");
// Click 1: Industry → CAP // Initial state: report.production = "Drive" → main is "research"
await sidebar // and the target is "DRIVE"; both apply/cancel start inert.
.getByTestId("inspector-planet-production-segment-industry") await expect(mainSelect).toHaveValue("research");
.click(); await expect(targetSelect).toHaveValue("DRIVE");
await expect(applyBtn).toBeDisabled();
// Pick 1: Industry + ✓ → CAP
await mainSelect.selectOption("industry");
await expect(applyBtn).toBeEnabled();
await applyBtn.click();
await page.getByTestId("sidebar-tab-order").click(); await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order"); const orderTool = page.getByTestId("sidebar-tool-order");
await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount( await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount(
@@ -323,11 +329,10 @@ test("switching production three times collapses to one auto-synced row", async
"applied", "applied",
); );
// Click 2: Materials → MAT (replaces CAP via collapse) // Pick 2: Materials + ✓ → MAT (replaces CAP via collapse)
await page.getByTestId("sidebar-tab-inspector").click(); await page.getByTestId("sidebar-tab-inspector").click();
await sidebar await mainSelect.selectOption("materials");
.getByTestId("inspector-planet-production-segment-materials") await applyBtn.click();
.click();
await page.getByTestId("sidebar-tab-order").click(); await page.getByTestId("sidebar-tab-order").click();
await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount( await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount(
1, 1,
@@ -336,14 +341,11 @@ test("switching production three times collapses to one auto-synced row", async
"Material", "Material",
); );
// Click 3: Build Ship → expand sub-row → Scout (replaces MAT) // Pick 3: Build Ship → target select appears → Scout + ✓ (replaces MAT)
await page.getByTestId("sidebar-tab-inspector").click(); await page.getByTestId("sidebar-tab-inspector").click();
await sidebar await mainSelect.selectOption("ship");
.getByTestId("inspector-planet-production-segment-ship") await targetSelect.selectOption(SHIP_CLASS);
.click(); await applyBtn.click();
await sidebar
.getByTestId(`inspector-planet-production-ship-${SHIP_CLASS}`)
.click();
await page.getByTestId("sidebar-tab-order").click(); await page.getByTestId("sidebar-tab-order").click();
await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount( await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount(
1, 1,
+2 -2
View File
@@ -245,7 +245,7 @@ test("rename a seeded planet auto-syncs and the overlay survives reload", async
const sidebar = page.getByTestId("sidebar-tool-inspector"); const sidebar = page.getByTestId("sidebar-tool-inspector");
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth"); await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth");
await sidebar.getByTestId("inspector-planet-rename-action").click(); await sidebar.getByTestId("inspector-planet-name").click();
const input = sidebar.getByTestId("inspector-planet-rename-input"); const input = sidebar.getByTestId("inspector-planet-rename-input");
await input.fill("New-Earth"); await input.fill("New-Earth");
await sidebar.getByTestId("inspector-planet-rename-confirm").click(); await sidebar.getByTestId("inspector-planet-rename-confirm").click();
@@ -312,7 +312,7 @@ test("rejected submit keeps the old name and surfaces the failure", async ({
); );
await clickPlanetCentre(page); await clickPlanetCentre(page);
const sidebar = page.getByTestId("sidebar-tool-inspector"); const sidebar = page.getByTestId("sidebar-tool-inspector");
await sidebar.getByTestId("inspector-planet-rename-action").click(); await sidebar.getByTestId("inspector-planet-name").click();
await sidebar.getByTestId("inspector-planet-rename-input").fill("Mars-2"); await sidebar.getByTestId("inspector-planet-rename-input").fill("Mars-2");
await sidebar.getByTestId("inspector-planet-rename-confirm").click(); await sidebar.getByTestId("inspector-planet-rename-confirm").click();
+19 -14
View File
@@ -419,23 +419,28 @@ test("planet production picker exposes user sciences in the Research sub-row", a
const sidebar = page.getByTestId("sidebar-tool-inspector"); const sidebar = page.getByTestId("sidebar-tool-inspector");
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth"); await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth");
// Expand the Research segment. const mainSelect = sidebar.getByTestId("inspector-planet-production-main");
await sidebar await mainSelect.selectOption("research");
.getByTestId("inspector-planet-production-segment-research")
.click();
// Tech buttons + the user's science button are both rendered. // Tech options and the user's science option are both rendered.
await expect( const targetSelect = sidebar.getByTestId(
sidebar.getByTestId("inspector-planet-production-research-drive"), "inspector-planet-production-target",
).toBeVisible();
const scienceButton = sidebar.getByTestId(
"inspector-planet-production-science-FirstStep",
); );
await expect(scienceButton).toBeVisible(); await expect(
targetSelect.locator(
'[data-testid="inspector-planet-production-target-option-drive"]',
),
).toHaveCount(1);
await expect(
targetSelect.locator(
'[data-testid="inspector-planet-production-target-option-science-FirstStep"]',
),
).toHaveCount(1);
// Click the science → setProductionType("SCIENCE", "FirstStep") // Select the science target + ✓ → setProductionType("SCIENCE",
// lands in the draft and auto-syncs. // "FirstStep") lands in the draft and auto-syncs.
await scienceButton.click(); await targetSelect.selectOption("FirstStep");
await sidebar.getByTestId("inspector-planet-production-apply").click();
await expect.poll(() => handle.lastProduce?.subject).toBe("FirstStep"); await expect.poll(() => handle.lastProduce?.subject).toBe("FirstStep");
expect(handle.lastProduce?.planetNumber).toBe(1); expect(handle.lastProduce?.planetNumber).toBe(1);
}); });
+20 -22
View File
@@ -2,11 +2,12 @@
// the identity strip (`<race> @ <game>`, falling back to `?` while // the identity strip (`<race> @ <game>`, falling back to `?` while
// the lobby / report calls are in flight), the Phase 26 turn // the lobby / report calls are in flight), the Phase 26 turn
// navigator (`← turn N →` with a popover of every turn), the // navigator (`← turn N →` with a popover of every turn), the
// view-menu, and the account-menu. The tests assert the visible // view-menu, and the in-game ephemeral light/dark theme toggle (F8-05
// copy, that every view-menu entry switches the active in-game view // replaced the previous account-menu — language picker and logout
// via `activeView.select(...)` (the single-URL app-shell has no // now live in the lobby). The tests assert the visible copy, that
// per-view routes), and that the Logout entry of the account-menu // every view-menu entry switches the active in-game view via
// calls `session.signOut("user")`. // `activeView.select(...)`, and that the theme toggle flips the
// in-memory `theme.override` channel.
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte"; import { fireEvent, render } from "@testing-library/svelte";
@@ -20,7 +21,7 @@ import {
} from "vitest"; } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte"; import { i18n } from "../src/lib/i18n/index.svelte";
import { session } from "../src/lib/session-store.svelte"; import { theme } from "../src/lib/theme/theme.svelte";
import Header from "../src/lib/header/header.svelte"; import Header from "../src/lib/header/header.svelte";
import { import {
GAME_STATE_CONTEXT_KEY, GAME_STATE_CONTEXT_KEY,
@@ -74,10 +75,11 @@ beforeEach(() => {
i18n.resetForTests("en"); i18n.resetForTests("en");
activeViewSelectSpy.mockReset(); activeViewSelectSpy.mockReset();
appScreenGoSpy.mockReset(); appScreenGoSpy.mockReset();
vi.spyOn(session, "signOut").mockResolvedValue(undefined); theme.clearOverride();
}); });
afterEach(() => { afterEach(() => {
theme.clearOverride();
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@@ -95,7 +97,7 @@ describe("game-shell header", () => {
"turn ?", "turn ?",
); );
expect(ui.getByTestId("view-menu-trigger")).toBeInTheDocument(); expect(ui.getByTestId("view-menu-trigger")).toBeInTheDocument();
expect(ui.getByTestId("account-menu-trigger")).toBeInTheDocument(); expect(ui.getByTestId("game-mode-theme-toggle")).toBeInTheDocument();
}); });
test("renders the live race / game / turn from GameStateStore", () => { test("renders the live race / game / turn from GameStateStore", () => {
@@ -194,22 +196,18 @@ describe("game-shell header", () => {
expect(appScreenGoSpy).toHaveBeenCalledWith("lobby"); expect(appScreenGoSpy).toHaveBeenCalledWith("lobby");
}); });
test("account-menu Logout triggers session.signOut('user')", async () => { test("theme toggle flips theme.override between light and dark", async () => {
const ui = render(Header, { const ui = render(Header, {
props: { sidebarOpen: false, onToggleSidebar: () => {} }, props: { sidebarOpen: false, onToggleSidebar: () => {} },
}); });
await fireEvent.click(ui.getByTestId("account-menu-trigger")); const toggle = ui.getByTestId("game-mode-theme-toggle");
await fireEvent.click(ui.getByTestId("account-menu-logout")); const initialResolved = theme.resolved;
expect(session.signOut).toHaveBeenCalledWith("user"); const opposite = initialResolved === "light" ? "dark" : "light";
}); await fireEvent.click(toggle);
expect(theme.override).toBe(opposite);
test("account-menu language picker switches the i18n locale", async () => { expect(theme.resolved).toBe(opposite);
const ui = render(Header, { await fireEvent.click(toggle);
props: { sidebarOpen: false, onToggleSidebar: () => {} }, expect(theme.override).toBe(initialResolved);
}); expect(theme.resolved).toBe(initialResolved);
await fireEvent.click(ui.getByTestId("account-menu-trigger"));
const select = ui.getByTestId("account-menu-language-select");
await fireEvent.change(select, { target: { value: "ru" } });
expect(i18n.locale).toBe("ru");
}); });
}); });
@@ -1,10 +1,12 @@
// Vitest component coverage for the Phase 16 cargo-routes // Vitest component coverage for the F8-05 cargo-routes subsection of
// subsection of the planet inspector. Drives the component against // the planet inspector. Pre-F8-05 the surface rendered all four
// a real `OrderDraftStore` (with `fake-indexeddb` standing in for // COL/CAP/MAT/EMP slots side-by-side; F8-05 collapsed it into a
// the browser IDB factory) and a stub `MapPickService` whose // single `<select>` with a placeholder (absorbing the old section
// `pick(...)` resolves to a script-controlled answer. The tests // title) and contextual `add` / `edit` + `remove` buttons that only
// assert the four-slot rendering, the picker invocation, the // appear once the player picks a type. The tests drive the component
// per-(source, loadType) collapse rule, and the cancel path. // against a real `OrderDraftStore` (with `fake-indexeddb` standing
// in for the browser IDB factory) and a stub `MapPickService` whose
// `pick(...)` resolves to a script-controlled answer.
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto"; import "fake-indexeddb/auto";
@@ -129,39 +131,48 @@ function mount(
return { ui, pick }; return { ui, pick };
} }
async function selectType(
ui: ReturnType<typeof mount>["ui"],
value: string,
): Promise<void> {
const select = ui.getByTestId(
"inspector-planet-cargo-type",
) as HTMLSelectElement;
await fireEvent.change(select, { target: { value } });
}
describe("planet inspector — cargo routes", () => { describe("planet inspector — cargo routes", () => {
test("renders four slots in COL/CAP/MAT/EMP order", () => { test("dropdown exposes COL/CAP/MAT/EMP plus the placeholder; nothing else is rendered until a type is picked", () => {
const { ui, pick } = mount( const { ui } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }), makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[makePlanet({ number: 1, name: "Earth", x: 100, y: 100 })], [makePlanet({ number: 1, name: "Earth", x: 100, y: 100 })],
); );
const slots = ui.container.querySelectorAll( const select = ui.getByTestId(
"[data-testid^='inspector-planet-cargo-slot-']", "inspector-planet-cargo-type",
); ) as HTMLSelectElement;
const slotIds = Array.from(slots).map((el) => expect(Array.from(select.options).map((o) => o.value)).toEqual([
el.getAttribute("data-testid"), "",
); "COL",
// Each slot generates several test ids (label + body items); "CAP",
// pick the row data-testid (slot itself, no suffix). "MAT",
const rowIds = slotIds.filter((id) => "EMP",
/^inspector-planet-cargo-slot-(col|cap|mat|emp)$/.test(id ?? ""),
);
expect(rowIds).toEqual([
"inspector-planet-cargo-slot-col",
"inspector-planet-cargo-slot-cap",
"inspector-planet-cargo-slot-mat",
"inspector-planet-cargo-slot-emp",
]); ]);
expect(select.value).toBe("");
// No action buttons surface before a type is picked.
expect(
ui.queryByTestId("inspector-planet-cargo-slot-col-add"),
).toBeNull();
expect(
ui.queryByTestId("inspector-planet-cargo-slot-col-destination"),
).toBeNull();
}); });
test("an empty slot exposes the Add button and the (no route) marker", () => { test("selecting an empty type reveals the Add button", async () => {
const { ui, pick } = mount( const { ui } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }), makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[makePlanet({ number: 1, name: "Earth", x: 100, y: 100 })], [makePlanet({ number: 1, name: "Earth", x: 100, y: 100 })],
); );
expect( await selectType(ui, "COL");
ui.getByTestId("inspector-planet-cargo-slot-col-empty"),
).toBeInTheDocument();
expect( expect(
ui.getByTestId("inspector-planet-cargo-slot-col-add"), ui.getByTestId("inspector-planet-cargo-slot-col-add"),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -170,8 +181,8 @@ describe("planet inspector — cargo routes", () => {
).toBeNull(); ).toBeNull();
}); });
test("a filled slot shows the destination name plus Edit and Remove", () => { test("selecting a filled type shows the destination plus Edit and Remove", async () => {
const { ui, pick } = mount( const { ui } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }), makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[ [
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }), makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
@@ -184,6 +195,7 @@ describe("planet inspector — cargo routes", () => {
}, },
], ],
); );
await selectType(ui, "COL");
expect( expect(
ui.getByTestId("inspector-planet-cargo-slot-col-destination"), ui.getByTestId("inspector-planet-cargo-slot-col-destination"),
).toHaveTextContent("Mars"); ).toHaveTextContent("Mars");
@@ -211,7 +223,10 @@ describe("planet inspector — cargo routes", () => {
[], [],
2, 2,
); );
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-col-add")); await selectType(ui, "COL");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-col-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1)); await waitFor(() => expect(pick.invocations.length).toBe(1));
const invocation = pick.invocations[0]!; const invocation = pick.invocations[0]!;
expect(invocation.request.sourcePlanetNumber).toBe(1); expect(invocation.request.sourcePlanetNumber).toBe(1);
@@ -222,11 +237,7 @@ describe("planet inspector — cargo routes", () => {
}); });
test("the reachable set spans every planet kind in range, not only own", async () => { test("the reachable set spans every planet kind in range, not only own", async () => {
// Reach = 40 * 1.5 = 60. Each candidate at distance 50 — in // Reach = 40 * 1.5 = 60. Each candidate at distance 50 — in reach.
// reach. The picker must include the foreign-race planet,
// the uninhabited rock, and the unidentified target so the
// engine's "destinations may be any planet" rule is honoured
// (route.go: only the source's ownership is enforced).
const { ui, pick } = mount( const { ui, pick } = mount(
makePlanet({ makePlanet({
number: 1, number: 1,
@@ -269,7 +280,10 @@ describe("planet inspector — cargo routes", () => {
[], [],
1.5, 1.5,
); );
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-col-add")); await selectType(ui, "COL");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-col-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1)); await waitFor(() => expect(pick.invocations.length).toBe(1));
expect( expect(
Array.from(pick.invocations[0]!.request.reachableIds).sort(), Array.from(pick.invocations[0]!.request.reachableIds).sort(),
@@ -304,7 +318,10 @@ describe("planet inspector — cargo routes", () => {
[], [],
1.5, 1.5,
); );
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-mat-add")); await selectType(ui, "MAT");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-mat-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1)); await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(9); pick.invocations[0]!.resolve(9);
await waitFor(() => expect(draft.commands).toHaveLength(1)); await waitFor(() => expect(draft.commands).toHaveLength(1));
@@ -325,7 +342,10 @@ describe("planet inspector — cargo routes", () => {
[], [],
2, 2,
); );
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-cap-add")); await selectType(ui, "CAP");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-cap-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1)); await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(2); pick.invocations[0]!.resolve(2);
await waitFor(() => expect(draft.commands).toHaveLength(1)); await waitFor(() => expect(draft.commands).toHaveLength(1));
@@ -342,6 +362,29 @@ describe("planet inspector — cargo routes", () => {
); );
}); });
test("dropdown stays on the just-picked type after add resolves", async () => {
const { ui, pick } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
],
[],
2,
);
await selectType(ui, "CAP");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-cap-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(2);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const select = ui.getByTestId(
"inspector-planet-cargo-type",
) as HTMLSelectElement;
expect(select.value).toBe("CAP");
});
test("cancel resolves null and emits no command", async () => { test("cancel resolves null and emits no command", async () => {
const { ui, pick } = mount( const { ui, pick } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }), makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
@@ -350,7 +393,10 @@ describe("planet inspector — cargo routes", () => {
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }), makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
], ],
); );
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-mat-add")); await selectType(ui, "MAT");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-mat-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1)); await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(null); pick.invocations[0]!.resolve(null);
await waitFor(() => await waitFor(() =>
@@ -361,8 +407,8 @@ describe("planet inspector — cargo routes", () => {
expect(draft.commands).toHaveLength(0); expect(draft.commands).toHaveLength(0);
}); });
test("Remove emits removeCargoRoute for the slot", async () => { test("Remove emits removeCargoRoute for the selected type", async () => {
const { ui, pick } = mount( const { ui } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }), makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[ [
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }), makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
@@ -375,6 +421,7 @@ describe("planet inspector — cargo routes", () => {
}, },
], ],
); );
await selectType(ui, "EMP");
await fireEvent.click( await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-emp-remove"), ui.getByTestId("inspector-planet-cargo-slot-emp-remove"),
); );
@@ -401,13 +448,14 @@ describe("planet inspector — cargo routes", () => {
}, },
], ],
); );
await selectType(ui, "COL");
await fireEvent.click( await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-col-edit"), ui.getByTestId("inspector-planet-cargo-slot-col-edit"),
); );
await waitFor(() => expect(pick.invocations.length).toBe(1)); await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(3); pick.invocations[0]!.resolve(3);
await waitFor(() => expect(draft.commands).toHaveLength(1)); await waitFor(() => expect(draft.commands).toHaveLength(1));
// Then a second edit to a different planet — collapse keeps a // A second edit to a different planet — collapse keeps a
// single row. // single row.
await fireEvent.click( await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-col-edit"), ui.getByTestId("inspector-planet-cargo-slot-col-edit"),
@@ -429,11 +477,17 @@ describe("planet inspector — cargo routes", () => {
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }), makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
], ],
); );
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-col-add")); await selectType(ui, "COL");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-col-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1)); await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(2); pick.invocations[0]!.resolve(2);
await waitFor(() => expect(draft.commands).toHaveLength(1)); await waitFor(() => expect(draft.commands).toHaveLength(1));
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-cap-add")); await selectType(ui, "CAP");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-cap-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(2)); await waitFor(() => expect(pick.invocations.length).toBe(2));
pick.invocations[1]!.resolve(2); pick.invocations[1]!.resolve(2);
await waitFor(() => expect(draft.commands).toHaveLength(2)); await waitFor(() => expect(draft.commands).toHaveLength(2));
@@ -444,8 +498,8 @@ describe("planet inspector — cargo routes", () => {
expect(types).toEqual(["CAP", "COL"]); expect(types).toEqual(["CAP", "COL"]);
}); });
test("no_destinations message appears when reach is positive but every planet is out of range", () => { test("no_destinations message appears once a type is picked and every planet is out of range", async () => {
const { ui, pick } = mount( const { ui } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }), makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[ [
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }), makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
@@ -454,6 +508,11 @@ describe("planet inspector — cargo routes", () => {
[], [],
0.1, // reach 4 — far less than 5000 distance 0.1, // reach 4 — far less than 5000 distance
); );
// Hidden until the player engages with the dropdown.
expect(
ui.queryByTestId("inspector-planet-cargo-no-destinations"),
).toBeNull();
await selectType(ui, "COL");
expect( expect(
ui.getByTestId("inspector-planet-cargo-no-destinations"), ui.getByTestId("inspector-planet-cargo-no-destinations"),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -1,13 +1,17 @@
// Vitest component coverage for the Phase 15 production-controls // Vitest component coverage for the F8-05 production-controls
// subsection of the planet inspector. Drives the component against a // subsection of the planet inspector. The pre-F8-05 surface was four
// real `OrderDraftStore` (with `fake-indexeddb` standing in for the // segmented main buttons (auto-submitting on click) plus a contextual
// browser's IDB factory) so the collapse-by-`planetNumber` rule and // sub-row; F8-05 replaced it with two `<select>`s (main / target) and
// the per-row status side-effects are exercised end-to-end. // a green ✓ apply / yellow ✗ cancel pair on the same row. The apply
// gate is the new behaviour: row state is dirty when the user picked
// something different from the planet's current effective production,
// and only then can the player commit via the ✓.
// //
// The active-segment derivation is covered by direct render-and- // The tests drive the component against a real `OrderDraftStore`
// query assertions: the parser is small enough that a table-driven // (with `fake-indexeddb` standing in for the browser's IDB factory)
// pass over the canonical engine display strings catches every // so the collapse-by-`planetNumber` rule remains exercised. The
// branch. // active-target derivation is covered by a table-driven pass over the
// canonical engine display strings.
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto"; import "fake-indexeddb/auto";
@@ -106,28 +110,55 @@ function mountProduction(
}); });
} }
function getMain(ui: ReturnType<typeof mountProduction>): HTMLSelectElement {
return ui.getByTestId("inspector-planet-production-main") as HTMLSelectElement;
}
function getTarget(
ui: ReturnType<typeof mountProduction>,
): HTMLSelectElement {
return ui.getByTestId(
"inspector-planet-production-target",
) as HTMLSelectElement;
}
describe("planet inspector — production controls", () => { describe("planet inspector — production controls", () => {
test("renders the four main segments with localised labels", () => { 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);
expect(main.value).toBe("industry");
const labels = Array.from(main.options).map((o) => o.textContent?.trim());
expect(labels).toEqual([
"industry",
"materials",
"research",
"build ship",
]);
// No secondary select until research / ship is chosen.
expect( expect(
ui.getByTestId("inspector-planet-production-segment-industry"), ui.queryByTestId("inspector-planet-production-target"),
).toHaveTextContent("industry"); ).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-segment-materials"), ui.getByTestId("inspector-planet-production-apply"),
).toHaveTextContent("materials"); ).not.toBeDisabled();
expect( expect(
ui.getByTestId("inspector-planet-production-segment-research"), ui.getByTestId("inspector-planet-production-cancel"),
).toHaveTextContent("research"); ).not.toBeDisabled();
expect(
ui.getByTestId("inspector-planet-production-segment-ship"),
).toHaveTextContent("build ship");
}); });
test("Industry click 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 }));
await fireEvent.click( expect(getMain(ui).value).toBe("industry");
ui.getByTestId("inspector-planet-production-segment-industry"), const apply = ui.getByTestId("inspector-planet-production-apply");
); expect(apply).not.toBeDisabled();
await fireEvent.click(apply);
await waitFor(() => expect(draft.commands).toHaveLength(1)); await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!; const cmd = draft.commands[0]!;
expect(cmd.kind).toBe("setProductionType"); expect(cmd.kind).toBe("setProductionType");
@@ -137,32 +168,29 @@ describe("planet inspector — production controls", () => {
expect(cmd.subject).toBe(""); expect(cmd.subject).toBe("");
}); });
test("Materials click emits a MAT setProductionType command", async () => { test("Materials pick + ✓ emits a MAT setProductionType command", async () => {
const ui = mountProduction(localPlanet({ number: 7 })); const ui = mountProduction(localPlanet({ number: 7 }));
await fireEvent.click( await fireEvent.change(getMain(ui), { target: { value: "materials" } });
ui.getByTestId("inspector-planet-production-segment-materials"), await fireEvent.click(ui.getByTestId("inspector-planet-production-apply"));
);
await waitFor(() => expect(draft.commands).toHaveLength(1)); await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!; const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind"); if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
expect(cmd.productionType).toBe("MAT"); expect(cmd.productionType).toBe("MAT");
}); });
test("Research click reveals the four tech sub-buttons without emitting", async () => { test("Research pick reveals the target select and apply stays disabled until a tech is chosen", async () => {
const ui = mountProduction(localPlanet({ number: 7 })); const ui = mountProduction(localPlanet({ number: 7 }));
expect( expect(
ui.queryByTestId("inspector-planet-production-research-row"), ui.queryByTestId("inspector-planet-production-target"),
).toBeNull(); ).toBeNull();
await fireEvent.click( await fireEvent.change(getMain(ui), { target: { value: "research" } });
ui.getByTestId("inspector-planet-production-segment-research"), const target = getTarget(ui);
); expect(target.value).toBe("");
expect( expect(
ui.getByTestId("inspector-planet-production-research-row"), ui.getByTestId("inspector-planet-production-apply"),
).toBeInTheDocument(); ).toBeDisabled();
expect(draft.commands).toHaveLength(0); await fireEvent.change(target, { target: { value: "DRIVE" } });
await fireEvent.click( await fireEvent.click(ui.getByTestId("inspector-planet-production-apply"));
ui.getByTestId("inspector-planet-production-research-drive"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1)); await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!; const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind"); if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
@@ -170,27 +198,39 @@ describe("planet inspector — production controls", () => {
expect(cmd.subject).toBe(""); expect(cmd.subject).toBe("");
}); });
test("Build-Ship segment shows the empty placeholder when no classes designed", async () => { test("Research target with a science name emits a SCIENCE subject", async () => {
const ui = mountProduction(localPlanet({ number: 7 }), [], [
{ name: "Genetics", drive: 0, weapons: 0, shields: 0, cargo: 0 },
]);
await fireEvent.change(getMain(ui), { target: { value: "research" } });
await fireEvent.change(getTarget(ui), { target: { value: "Genetics" } });
await fireEvent.click(ui.getByTestId("inspector-planet-production-apply"));
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
expect(cmd.productionType).toBe("SCIENCE");
expect(cmd.subject).toBe("Genetics");
});
test("Build-Ship with no classes shows the empty placeholder option", async () => {
const ui = mountProduction(localPlanet({ number: 7 }), []); const ui = mountProduction(localPlanet({ number: 7 }), []);
await fireEvent.click( await fireEvent.change(getMain(ui), { target: { value: "ship" } });
ui.getByTestId("inspector-planet-production-segment-ship"),
);
expect( expect(
ui.getByTestId("inspector-planet-production-ship-empty"), ui.getByTestId("inspector-planet-production-ship-empty"),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(
ui.getByTestId("inspector-planet-production-apply"),
).toBeDisabled();
}); });
test("Build-Ship click on a class emits a SHIP setProductionType command", async () => { test("Build-Ship + class pick + ✓ emits a SHIP setProductionType command", async () => {
const ui = mountProduction(localPlanet({ number: 7 }), [ const ui = mountProduction(localPlanet({ number: 7 }), [
shipClass({ name: "Scout" }), shipClass({ name: "Scout" }),
shipClass({ name: "Destroyer" }), shipClass({ name: "Destroyer" }),
]); ]);
await fireEvent.click( await fireEvent.change(getMain(ui), { target: { value: "ship" } });
ui.getByTestId("inspector-planet-production-segment-ship"), await fireEvent.change(getTarget(ui), { target: { value: "Scout" } });
); await fireEvent.click(ui.getByTestId("inspector-planet-production-apply"));
await fireEvent.click(
ui.getByTestId("inspector-planet-production-ship-Scout"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1)); await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!; const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind"); if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
@@ -198,32 +238,41 @@ describe("planet inspector — production controls", () => {
expect(cmd.subject).toBe("Scout"); expect(cmd.subject).toBe("Scout");
}); });
test("re-clicks on the same planet collapse to the latest entry via the store", async () => { test("Cancel resets the row to the current effective production without emitting", async () => {
const ui = mountProduction(localPlanet({ number: 7 }), [ const ui = mountProduction(
shipClass({ name: "Scout" }), localPlanet({ number: 7, production: "Capital" }),
]);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-industry"),
); );
const main = getMain(ui);
expect(main.value).toBe("industry");
await fireEvent.change(main, { target: { value: "research" } });
expect(
ui.getByTestId("inspector-planet-production-cancel"),
).not.toBeDisabled();
await fireEvent.click( await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-materials"), ui.getByTestId("inspector-planet-production-cancel"),
); );
await fireEvent.click( expect(getMain(ui).value).toBe("industry");
ui.getByTestId("inspector-planet-production-segment-research"), expect(draft.commands).toEqual([]);
);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-research-cargo"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
expect(cmd.productionType).toBe("CARGO");
}); });
test("active main segment derives from planet.production display string", () => { test("Apply gate is closed while the row matches the current effective production", () => {
const ui = mountProduction(
localPlanet({ number: 7, production: "Drive" }),
);
// On mount the parser seeds research + DRIVE, so the apply
// button is inert until the player actually changes something.
expect(
ui.getByTestId("inspector-planet-production-apply"),
).toBeDisabled();
expect(
ui.getByTestId("inspector-planet-production-cancel"),
).toBeDisabled();
});
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" | "none"; expected: "industry" | "materials" | "research" | "ship";
}> = [ }> = [
{ production: "Capital", expected: "industry" }, { production: "Capital", expected: "industry" },
{ production: "Material", expected: "materials" }, { production: "Material", expected: "materials" },
@@ -232,67 +281,48 @@ 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: "none" }, // Falls back to the documented `industry` default when the
{ production: null, expected: "none" }, // engine display string is missing or unrecognised.
{ production: "UnknownThing", expected: "none" }, { 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(
localPlanet({ number: 1, production: tc.production }), localPlanet({ number: 1, production: tc.production }),
[shipClass({ name: "Scout" })], [shipClass({ name: "Scout" })],
); );
const ids: ReadonlyArray< expect(getMain(ui).value).toBe(tc.expected);
"industry" | "materials" | "research" | "ship"
> = ["industry", "materials", "research", "ship"];
for (const id of ids) {
const el = ui.getByTestId(
`inspector-planet-production-segment-${id}`,
);
if (tc.expected === id) {
expect(el.classList.contains("active")).toBe(true);
} else {
expect(el.classList.contains("active")).toBe(false);
}
}
ui.unmount(); ui.unmount();
} }
}); });
test("active research sub-button highlights for known display strings", () => { test("active target seeds the secondary select for research display strings", () => {
const cases: ReadonlyArray<{ const cases: ReadonlyArray<{
production: string; production: string;
slug: "drive" | "weapons" | "shields" | "cargo"; expected: "DRIVE" | "WEAPONS" | "SHIELDS" | "CARGO";
}> = [ }> = [
{ production: "Drive", slug: "drive" }, { production: "Drive", expected: "DRIVE" },
{ production: "Weapons", slug: "weapons" }, { production: "Weapons", expected: "WEAPONS" },
{ production: "Shields", slug: "shields" }, { production: "Shields", expected: "SHIELDS" },
{ production: "Cargo", slug: "cargo" }, { production: "Cargo", expected: "CARGO" },
]; ];
for (const tc of cases) { for (const tc of cases) {
const ui = mountProduction( const ui = mountProduction(
localPlanet({ number: 1, production: tc.production }), localPlanet({ number: 1, production: tc.production }),
); );
const el = ui.getByTestId( expect(getMain(ui).value).toBe("research");
`inspector-planet-production-research-${tc.slug}`, expect(getTarget(ui).value).toBe(tc.expected);
);
expect(el.classList.contains("active")).toBe(true);
ui.unmount(); ui.unmount();
} }
}); });
test("ship class sub-row matches when production equals a class name", async () => { test("target select seeds the ship class when production is a class name", () => {
const ui = mountProduction( const ui = mountProduction(
localPlanet({ number: 1, production: "Scout" }), localPlanet({ number: 1, production: "Scout" }),
[shipClass({ name: "Scout" }), shipClass({ name: "Destroyer" })], [shipClass({ name: "Scout" }), shipClass({ name: "Destroyer" })],
); );
expect( expect(getMain(ui).value).toBe("ship");
ui.getByTestId("inspector-planet-production-ship-Scout").classList expect(getTarget(ui).value).toBe("Scout");
.contains("active"),
).toBe(true);
expect(
ui
.getByTestId("inspector-planet-production-ship-Destroyer")
.classList.contains("active"),
).toBe(false);
}); });
}); });
@@ -1,11 +1,17 @@
// Vitest coverage for the Phase 19 follow-up "stationed ship groups" // Vitest coverage for the "stationed ship groups" subsection of the
// subsection of the planet inspector. Phase 19 originally rendered // planet inspector. The map deliberately hides on-planet groups; this
// every in-orbit group as a small offset point on the map; the // subsection is the player's view of the fleets in orbit.
// resulting visual noise pushed the listing into this subsection //
// (`lib/inspectors/planet/ship-groups.svelte`) instead. // F8-05 (issue #48 п.32) moved the race column from the row into a
// dropdown above the table. The dropdown only renders when more than
// one race is stationed; it seeds with the player's own race when
// local groups are stationed here, otherwise with the first race
// alphabetically. Single-race cases skip the dropdown and render
// straight through. The race column is dropped in both modes — the
// dropdown's value already names the active race.
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { render } from "@testing-library/svelte"; import { fireEvent, render } from "@testing-library/svelte";
import { beforeEach, describe, expect, test } from "vitest"; import { beforeEach, describe, expect, test } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte"; import { i18n } from "../src/lib/i18n/index.svelte";
@@ -63,6 +69,7 @@ function localGroup(
mass: 12, mass: 12,
state: "In_Orbit", state: "In_Orbit",
fleet: null, fleet: null,
race: "Earthlings",
...overrides, ...overrides,
}; };
} }
@@ -81,12 +88,13 @@ function otherGroup(
range: null, range: null,
speed: 0, speed: 0,
mass: 25, mass: 25,
race: "Klingons",
...overrides, ...overrides,
}; };
} }
describe("planet inspector — stationed ship groups", () => { describe("planet inspector — stationed ship groups", () => {
test("renders one row per in-orbit local group with the player's race", () => { test("renders one row per in-orbit local group; the dropdown is hidden with a single race", () => {
const ui = render(ShipGroups, { const ui = render(ShipGroups, {
props: { props: {
planet: HOME_PLANET, planet: HOME_PLANET,
@@ -100,7 +108,13 @@ describe("planet inspector — stationed ship groups", () => {
}); });
const rows = ui.getAllByTestId("inspector-planet-ship-groups-row"); const rows = ui.getAllByTestId("inspector-planet-ship-groups-row");
expect(rows.length).toBe(2); expect(rows.length).toBe(2);
expect(rows[0]).toHaveTextContent("Earthlings"); // Race no longer appears in the row (it is hoisted to the
// dropdown — and the dropdown itself is hidden when only one
// race is present).
expect(
ui.queryByTestId("inspector-planet-ship-groups-race-filter"),
).toBeNull();
expect(rows[0]).not.toHaveTextContent("Earthlings");
expect(rows[0]).toHaveTextContent("Frontier"); expect(rows[0]).toHaveTextContent("Frontier");
expect(rows[0]).toHaveTextContent("2"); expect(rows[0]).toHaveTextContent("2");
expect(rows[0]).toHaveTextContent("24"); expect(rows[0]).toHaveTextContent("24");
@@ -108,6 +122,66 @@ describe("planet inspector — stationed ship groups", () => {
expect(rows[1]).toHaveTextContent("173.25"); expect(rows[1]).toHaveTextContent("173.25");
}); });
test("multiple races surface a dropdown that filters the table", async () => {
const ui = render(ShipGroups, {
props: {
planet: FOREIGN_PLANET,
localShipGroups: [
localGroup({ id: "own-1", destination: 99, class: "Frontier" }),
],
otherShipGroups: [
otherGroup({ class: "Bird-of-Prey", destination: 99 }),
],
localRace: "Earthlings",
},
});
const select = ui.getByTestId(
"inspector-planet-ship-groups-race-filter",
) as HTMLSelectElement;
// Own ships are stationed → own race wins as the default;
// alphabetical ordering puts the foreign one second.
expect(select.value).toBe("Earthlings");
expect(Array.from(select.options).map((o) => o.value)).toEqual([
"Earthlings",
"Klingons",
]);
expect(
ui.getAllByTestId("inspector-planet-ship-groups-row").length,
).toBe(1);
expect(
ui.getByTestId("inspector-planet-ship-groups-row"),
).toHaveTextContent("Frontier");
await fireEvent.change(select, { target: { value: "Klingons" } });
const rows = ui.getAllByTestId("inspector-planet-ship-groups-row");
expect(rows.length).toBe(1);
expect(rows[0]).toHaveTextContent("Bird-of-Prey");
});
test("with no own ships the dropdown collapses to the single foreign race", () => {
const ui = render(ShipGroups, {
props: {
planet: {
...HOME_PLANET,
owner: "Andorians",
kind: "other",
},
localShipGroups: [],
otherShipGroups: [
otherGroup({ class: "Bird-of-Prey", destination: 17 }),
],
localRace: "Earthlings",
},
});
// Single foreign race → no dropdown.
expect(
ui.queryByTestId("inspector-planet-ship-groups-race-filter"),
).toBeNull();
expect(
ui.getAllByTestId("inspector-planet-ship-groups-row").length,
).toBe(1);
});
test("filters out groups stationed on a different planet", () => { test("filters out groups stationed on a different planet", () => {
const ui = render(ShipGroups, { const ui = render(ShipGroups, {
props: { props: {
@@ -147,20 +221,6 @@ describe("planet inspector — stationed ship groups", () => {
); );
}); });
test("foreign-planet visitors fall back to the planet owner's race", () => {
const ui = render(ShipGroups, {
props: {
planet: FOREIGN_PLANET,
localShipGroups: [],
otherShipGroups: [otherGroup({ destination: 99 })],
localRace: "Earthlings",
},
});
const row = ui.getByTestId("inspector-planet-ship-groups-row");
expect(row).toHaveTextContent("Klingons");
expect(row).toHaveTextContent("Bird-of-Prey");
});
test("subsection collapses entirely when nothing is stationed", () => { test("subsection collapses entirely when nothing is stationed", () => {
const ui = render(ShipGroups, { const ui = render(ShipGroups, {
props: { props: {
+13 -8
View File
@@ -245,7 +245,7 @@ describe("planet inspector", () => {
expect(ui.queryByTestId("inspector-planet-field-natural_resources")).toBeNull(); expect(ui.queryByTestId("inspector-planet-field-natural_resources")).toBeNull();
}); });
test("Rename action is hidden for non-local planets", () => { test("Name is not editable for non-local planets", () => {
const ui = render(Planet, { const ui = render(Planet, {
props: { props: {
planet: makePlanet({ planet: makePlanet({
@@ -268,10 +268,13 @@ describe("planet inspector", () => {
localRace: "", localRace: "",
}, },
}); });
expect(ui.queryByTestId("inspector-planet-rename-action")).toBeNull(); const name = ui.getByTestId("inspector-planet-name");
// Non-local planets render the name as a plain heading, not a
// click-to-edit button.
expect(name.tagName).toBe("H3");
}); });
test("Rename action opens an inline editor and validates locally", async () => { test("Clicking the name opens an inline editor and validates locally", async () => {
const dbName = `galaxy-rename-${crypto.randomUUID()}`; const dbName = `galaxy-rename-${crypto.randomUUID()}`;
const db = await openGalaxyDB(dbName); const db = await openGalaxyDB(dbName);
const cache = new IDBCache(db); const cache = new IDBCache(db);
@@ -311,8 +314,9 @@ describe("planet inspector", () => {
context, context,
}); });
const action = ui.getByTestId("inspector-planet-rename-action"); const name = ui.getByTestId("inspector-planet-name");
await fireEvent.click(action); expect(name.tagName).toBe("BUTTON");
await fireEvent.click(name);
const input = ui.getByTestId("inspector-planet-rename-input") as HTMLInputElement; const input = ui.getByTestId("inspector-planet-rename-input") as HTMLInputElement;
expect(input.value).toBe("Earth"); expect(input.value).toBe("Earth");
@@ -344,7 +348,7 @@ describe("planet inspector", () => {
db.close(); db.close();
}); });
test("Cancel closes the editor without adding to the draft", async () => { test("Escape closes the editor without adding to the draft", async () => {
const dbName = `galaxy-rename-${crypto.randomUUID()}`; const dbName = `galaxy-rename-${crypto.randomUUID()}`;
const db = await openGalaxyDB(dbName); const db = await openGalaxyDB(dbName);
const cache = new IDBCache(db); const cache = new IDBCache(db);
@@ -382,8 +386,9 @@ describe("planet inspector", () => {
}, },
context, context,
}); });
await fireEvent.click(ui.getByTestId("inspector-planet-rename-action")); await fireEvent.click(ui.getByTestId("inspector-planet-name"));
await fireEvent.click(ui.getByTestId("inspector-planet-rename-cancel")); const input = ui.getByTestId("inspector-planet-rename-input");
await fireEvent.keyDown(input, { key: "Escape" });
expect(ui.queryByTestId("inspector-planet-rename")).toBeNull(); expect(ui.queryByTestId("inspector-planet-rename")).toBeNull();
expect(draft.commands).toEqual([]); expect(draft.commands).toEqual([]);
draft.dispose(); draft.dispose();
@@ -108,6 +108,7 @@ function localGroup(
mass: 12, mass: 12,
state: "In_Orbit", state: "In_Orbit",
fleet: null, fleet: null,
race: "Earthlings",
...overrides, ...overrides,
}; };
} }
@@ -119,6 +119,7 @@ function group(
mass: 12, mass: 12,
state: "In_Orbit", state: "In_Orbit",
fleet: null, fleet: null,
race: "Earthlings",
...overrides, ...overrides,
}; };
} }
@@ -104,6 +104,7 @@ function group(
mass: 25, mass: 25,
state: "In_Orbit", state: "In_Orbit",
fleet: null, fleet: null,
race: "Earthlings",
...overrides, ...overrides,
}; };
} }
@@ -79,6 +79,7 @@ function localGroup(
mass: 12, mass: 12,
state: "In_Orbit", state: "In_Orbit",
fleet: null, fleet: null,
race: "Earthlings",
...overrides, ...overrides,
}; };
} }
@@ -158,6 +159,7 @@ describe("ship-group inspector", () => {
range: null, range: null,
speed: 0, speed: 0,
mass: 50, mass: 50,
race: "Klingons",
}; };
const selection: ShipGroupSelection = { variant: "other", group }; const selection: ShipGroupSelection = { variant: "other", group };
const ui = render(ShipGroup, { props: { selection, planets: PLANETS } }); const ui = render(ShipGroup, { props: { selection, planets: PLANETS } });
@@ -1,9 +1,11 @@
// Phase 29 component coverage for `lib/active-view/map-toggles.svelte`. // Phase 29 component coverage for `lib/active-view/map-toggles.svelte`.
// The popover is a thin view of the `GameStateStore` runes — // The popover is a thin view of the `GameStateStore` runes —
// every control fires `setMapToggle` / `setWrapMode` on the store // every checkbox fires `setMapToggle` on the store and reads the
// and reads the current state through `store.mapToggles` / // current state through `store.mapToggles`. F8-05 (issue #48 п.8)
// `store.wrapMode`. The tests assert the wiring, the default // dropped the wrap-scrolling radio group from the UI; the
// rendering, and the popover lifecycle (open / Escape close). // `wrapMode` rune and the renderer's no-wrap path stay put for a
// future game-server-side feature flag, but no surface exposes
// the choice today.
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte"; import { fireEvent, render } from "@testing-library/svelte";
@@ -19,7 +21,6 @@ import {
function buildStore(): GameStateStore { function buildStore(): GameStateStore {
const store = new GameStateStore(); const store = new GameStateStore();
store.status = "ready"; store.status = "ready";
store.wrapMode = "torus";
store.mapToggles = { ...DEFAULT_MAP_TOGGLES }; store.mapToggles = { ...DEFAULT_MAP_TOGGLES };
return store; return store;
} }
@@ -59,8 +60,8 @@ describe("MapTogglesControl", () => {
expect(ui.getByTestId("map-toggles-unidentified-planets")).toBeChecked(); expect(ui.getByTestId("map-toggles-unidentified-planets")).toBeChecked();
expect(ui.getByTestId("map-toggles-unreachable-planets")).toBeChecked(); expect(ui.getByTestId("map-toggles-unreachable-planets")).toBeChecked();
expect(ui.getByTestId("map-toggles-visible-hyperspace")).toBeChecked(); expect(ui.getByTestId("map-toggles-visible-hyperspace")).toBeChecked();
expect(ui.getByTestId("map-toggles-wrap-torus")).toBeChecked(); expect(ui.queryByTestId("map-toggles-wrap-torus")).toBeNull();
expect(ui.getByTestId("map-toggles-wrap-no-wrap")).not.toBeChecked(); expect(ui.queryByTestId("map-toggles-wrap-no-wrap")).toBeNull();
}); });
test("flipping a checkbox calls setMapToggle with the new value", async () => { test("flipping a checkbox calls setMapToggle with the new value", async () => {
@@ -90,17 +91,6 @@ describe("MapTogglesControl", () => {
expect(setMapToggle).not.toHaveBeenCalledWith("bombingMarkers", false); expect(setMapToggle).not.toHaveBeenCalledWith("bombingMarkers", false);
}); });
test("selecting the no-wrap radio calls setWrapMode", async () => {
const store = buildStore();
const setWrapMode = vi
.spyOn(store, "setWrapMode")
.mockResolvedValue(undefined);
const ui = render(MapTogglesControl, { props: { store } });
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
await fireEvent.click(ui.getByTestId("map-toggles-wrap-no-wrap"));
expect(setWrapMode).toHaveBeenCalledWith("no-wrap");
});
test("Escape closes the popover", async () => { test("Escape closes the popover", async () => {
const store = buildStore(); const store = buildStore();
const ui = render(MapTogglesControl, { props: { store } }); const ui = render(MapTogglesControl, { props: { store } });
@@ -45,6 +45,7 @@ function localGroup(overrides: Partial<ReportLocalShipGroup> & Pick<ReportLocalS
mass: 1, mass: 1,
state: "In_Orbit", state: "In_Orbit",
fleet: null, fleet: null,
race: "Earthlings",
...overrides, ...overrides,
}; };
} }
@@ -79,6 +79,7 @@ function makeLocalShipGroup(
mass: 0, mass: 0,
state: "InOrbit", state: "InOrbit",
fleet: null, fleet: null,
race: "Earthlings",
...overrides, ...overrides,
}; };
} }
@@ -97,6 +98,7 @@ function makeOtherShipGroup(
range: null, range: null,
speed: 1, speed: 1,
mass: 0, mass: 0,
race: "Klingons",
...overrides, ...overrides,
}; };
} }
@@ -78,6 +78,7 @@ describe("reportToWorld — ship groups", () => {
mass: 12, mass: 12,
state: "In_Orbit", state: "In_Orbit",
fleet: null, fleet: null,
race: "Earthlings",
}, },
], ],
}), }),
@@ -112,6 +113,7 @@ describe("reportToWorld — ship groups", () => {
mass: 50, mass: 50,
state: "In_Space", state: "In_Space",
fleet: null, fleet: null,
race: "Earthlings",
}, },
], ],
}), }),
@@ -237,6 +239,7 @@ describe("reportToWorld — ship groups", () => {
origin: null, origin: null,
range: null, range: null,
speed: 0, speed: 0,
race: "Earthlings",
mass: 1, mass: 1,
state: "In_Orbit", state: "In_Orbit",
fleet: null, fleet: null,
+41
View File
@@ -86,4 +86,45 @@ describe("theme store", () => {
const { theme } = await freshStore(); const { theme } = await freshStore();
expect(theme.choice).toBe("system"); expect(theme.choice).toBe("system");
}); });
it("applies an ephemeral override without touching the persisted choice", async () => {
localStorage.setItem(STORAGE_KEY, "dark");
const { theme } = await freshStore();
expect(theme.resolved).toBe("dark");
expect(theme.override).toBeNull();
theme.setOverride("light");
expect(theme.override).toBe("light");
expect(theme.resolved).toBe("light");
expect(document.documentElement.dataset.theme).toBe("light");
// Persisted choice is untouched.
expect(theme.choice).toBe("dark");
expect(localStorage.getItem(STORAGE_KEY)).toBe("dark");
});
it("clearOverride re-projects the persisted choice", async () => {
localStorage.setItem(STORAGE_KEY, "light");
const { theme } = await freshStore();
theme.setOverride("dark");
expect(theme.resolved).toBe("dark");
theme.clearOverride();
expect(theme.override).toBeNull();
expect(theme.resolved).toBe("light");
expect(document.documentElement.dataset.theme).toBe("light");
});
it("override shadows setChoice until cleared", async () => {
const { theme } = await freshStore();
theme.setOverride("light");
theme.setChoice("dark");
// Override wins while it is non-null, but the choice is still
// persisted for the next lobby visit.
expect(theme.resolved).toBe("light");
expect(theme.choice).toBe("dark");
expect(localStorage.getItem(STORAGE_KEY)).toBe("dark");
theme.clearOverride();
expect(theme.resolved).toBe("dark");
});
}); });