F8-05 — game-mode chrome cleanup + inspector compact rows #66
+6
-2
@@ -820,8 +820,12 @@ every change applies within one frame (no Pixi remount):
|
||||
`VisibilityDistance(localPlayerDrive)` circles around LOCAL
|
||||
planets; LOCAL planets are always exempt — the toggle is
|
||||
named after the visible part of the map rather than the
|
||||
obscured one) plus the torus / no-wrap radio that switches
|
||||
the renderer mode while preserving the camera centre.
|
||||
obscured one). The renderer always runs in torus mode; the
|
||||
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
|
||||
other toggle defaults to ON. Hiding a planet cascades onto every
|
||||
|
||||
@@ -840,9 +840,12 @@ Directory-промоушен ([Раздел 5](#5-реестр-названий-
|
||||
объединения окружностей
|
||||
`VisibilityDistance(localPlayerDrive)` вокруг LOCAL-планет;
|
||||
LOCAL-планеты всегда вне фильтра — тоггл назван по видимой
|
||||
области карты, а не по затемнённой) плюс радиогруппа
|
||||
«торус / без переноса», переключающая режим рендерера с
|
||||
сохранением центра камеры.
|
||||
области карты, а не по затемнённой). Рендерер всегда работает
|
||||
в торическом режиме; прежняя радиогруппа «торус / без
|
||||
переноса» была удалена в полишинге F8 (issue #48 п.8),
|
||||
поскольку топология карты — серверная сущность, а не
|
||||
per-session UI-настройка. Код-путь без переноса в рендерере
|
||||
оставлен на день, когда движок выставит режим bounded plane.
|
||||
|
||||
LOCAL-планеты отрисовываются всегда — для них тоггла нет.
|
||||
Остальные тогглы по умолчанию включены. Скрытие планеты
|
||||
|
||||
@@ -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.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) {
|
||||
|
||||
@@ -52,6 +52,13 @@ type OtherGroup struct {
|
||||
Range *Float `json:"range,omitempty"`
|
||||
Speed Float `json:"speed"`
|
||||
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 {
|
||||
|
||||
@@ -168,6 +168,7 @@ table OtherGroup {
|
||||
range:float32 = null;
|
||||
speed:float32;
|
||||
mass:float32;
|
||||
race:string;
|
||||
}
|
||||
|
||||
table LocalGroup {
|
||||
@@ -184,6 +185,7 @@ table LocalGroup {
|
||||
id:common.UUID (required);
|
||||
state:string;
|
||||
fleet:string;
|
||||
race:string;
|
||||
}
|
||||
|
||||
table LocalFleet {
|
||||
|
||||
@@ -194,8 +194,16 @@ func (rcv *LocalGroup) Fleet() []byte {
|
||||
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) {
|
||||
builder.StartObject(13)
|
||||
builder.StartObject(14)
|
||||
}
|
||||
func LocalGroupAddNumber(builder *flatbuffers.Builder, number uint64) {
|
||||
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) {
|
||||
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 {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -163,8 +163,16 @@ func (rcv *OtherGroup) MutateMass(n float32) bool {
|
||||
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) {
|
||||
builder.StartObject(10)
|
||||
builder.StartObject(11)
|
||||
}
|
||||
func OtherGroupAddNumber(builder *flatbuffers.Builder, number uint64) {
|
||||
builder.PrependUint64Slot(0, number, 0)
|
||||
@@ -201,6 +209,9 @@ func OtherGroupAddSpeed(builder *flatbuffers.Builder, speed float32) {
|
||||
func OtherGroupAddMass(builder *flatbuffers.Builder, mass float32) {
|
||||
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 {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -513,6 +513,7 @@ func encodeReportLocalGroup(builder *flatbuffers.Builder, group *model.LocalGrou
|
||||
class := builder.CreateString(group.Class)
|
||||
cargo := builder.CreateString(group.Cargo)
|
||||
state := builder.CreateString(group.State)
|
||||
race := builder.CreateString(group.Race)
|
||||
|
||||
tech := encodeReportTechEntryVector(builder, group.Tech)
|
||||
var fleet flatbuffers.UOffsetT
|
||||
@@ -544,6 +545,7 @@ func encodeReportLocalGroup(builder *flatbuffers.Builder, group *model.LocalGrou
|
||||
if group.Fleet != nil {
|
||||
fbs.LocalGroupAddFleet(builder, fleet)
|
||||
}
|
||||
fbs.LocalGroupAddRace(builder, race)
|
||||
return fbs.LocalGroupEnd(builder)
|
||||
}
|
||||
|
||||
@@ -551,6 +553,7 @@ func encodeReportOtherGroup(builder *flatbuffers.Builder, group *model.OtherGrou
|
||||
class := builder.CreateString(group.Class)
|
||||
cargo := builder.CreateString(group.Cargo)
|
||||
tech := encodeReportTechEntryVector(builder, group.Tech)
|
||||
race := builder.CreateString(group.Race)
|
||||
|
||||
fbs.OtherGroupStart(builder)
|
||||
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.OtherGroupAddMass(builder, reportFloatToFBS(group.Mass))
|
||||
fbs.OtherGroupAddRace(builder, race)
|
||||
return fbs.OtherGroupEnd(builder)
|
||||
}
|
||||
|
||||
@@ -1134,6 +1138,7 @@ func decodeReportLocalGroupVector(flatReport *fbs.Report, result *model.Report)
|
||||
Destination: destination,
|
||||
Speed: reportFloatFromFBS(item.Speed()),
|
||||
Mass: reportFloatFromFBS(item.Mass()),
|
||||
Race: string(item.Race()),
|
||||
},
|
||||
ID: uuidFromHiLo(id.Hi(), id.Lo()),
|
||||
State: string(item.State()),
|
||||
@@ -1196,6 +1201,7 @@ func decodeReportOtherGroupVector(flatReport *fbs.Report, result *model.Report)
|
||||
Destination: destination,
|
||||
Speed: reportFloatFromFBS(item.Speed()),
|
||||
Mass: reportFloatFromFBS(item.Mass()),
|
||||
Race: string(item.Race()),
|
||||
}
|
||||
|
||||
if origin := item.Origin(); origin != nil {
|
||||
|
||||
@@ -352,6 +352,7 @@ func sampleReport() *model.Report {
|
||||
Range: &rangeB,
|
||||
Speed: model.Float(2.5),
|
||||
Mass: model.Float(12.0),
|
||||
Race: "Earthlings",
|
||||
},
|
||||
ID: uuid.MustParse("33333333-3333-3333-3333-333333333333"),
|
||||
State: "in_orbit",
|
||||
@@ -359,7 +360,7 @@ func sampleReport() *model.Report {
|
||||
},
|
||||
},
|
||||
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{
|
||||
{X: model.Float(10.0), Y: model.Float(11.0)},
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
These exist in legacy reports but cannot be derived from the legacy
|
||||
text format at all. Each could become in-scope if a strong enough
|
||||
reason arises (see "Adding a new field" below).
|
||||
|
||||
- `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
|
||||
synthetic JSON emits `route: []`. The UI's overlay path
|
||||
(`applyOrderOverlay`) supports running on top of an empty `routes`.
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
// Scope is intentionally narrow: only the fields the UI client decodes
|
||||
// from server reports today (planets, players, own ship classes,
|
||||
// header data, plus — added in Phase 19 — own ship groups, own fleets
|
||||
// and incoming groups). Everything else in the legacy file is silently
|
||||
// skipped. The synthetic-report parity rule in ui/PLAN.md is the
|
||||
// source of truth 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.
|
||||
// and incoming groups; and — added in F8-05 — foreign `<Race> Groups`
|
||||
// blocks outside battles together with `Unidentified Groups`).
|
||||
// Everything else in the legacy file is silently skipped. The
|
||||
// synthetic-report parity rule in ui/PLAN.md is the source of truth
|
||||
// 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
|
||||
|
||||
import (
|
||||
@@ -65,6 +67,8 @@ const (
|
||||
sectionYourGroups
|
||||
sectionYourFleets
|
||||
sectionIncomingGroups
|
||||
sectionOtherGroups
|
||||
sectionUnidentifiedGroups
|
||||
sectionYourSciences
|
||||
sectionOtherSciences
|
||||
sectionOtherShipTypes
|
||||
@@ -94,6 +98,7 @@ type parser struct {
|
||||
pendingFleets []pendingFleet
|
||||
pendingIncomings []pendingIncoming
|
||||
pendingShipProducts []pendingShipProduction
|
||||
pendingOtherGroups []pendingOtherGroup
|
||||
|
||||
// Battle accumulator. `battles` collects every parsed BattleReport;
|
||||
// `pendingBattle` carries the in-flight battle until its block
|
||||
@@ -148,6 +153,29 @@ type pendingGroup struct {
|
||||
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 {
|
||||
name string
|
||||
groups uint
|
||||
@@ -290,6 +318,10 @@ func (p *parser) handle(line string) error {
|
||||
p.parseYourFleet(fields)
|
||||
case sectionIncomingGroups:
|
||||
p.parseIncomingGroup(fields)
|
||||
case sectionOtherGroups:
|
||||
p.parseOtherGroup(fields)
|
||||
case sectionUnidentifiedGroups:
|
||||
p.parseUnidentifiedGroup(fields)
|
||||
case sectionYourSciences:
|
||||
p.parseYourScience(fields)
|
||||
case sectionOtherSciences:
|
||||
@@ -381,6 +413,8 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
|
||||
return sectionYourFleets, "", true
|
||||
case "Incoming Groups":
|
||||
return sectionIncomingGroups, "", true
|
||||
case "Unidentified Groups":
|
||||
return sectionUnidentifiedGroups, "", true
|
||||
case "Uninhabited Planets":
|
||||
return sectionUninhabitedPlanets, "", true
|
||||
case "Unidentified Planets":
|
||||
@@ -417,8 +451,8 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
|
||||
if owner, ok := singleTokenPrefix(line, " Sciences"); ok {
|
||||
return sectionOtherSciences, owner, true
|
||||
}
|
||||
if _, ok := singleTokenPrefix(line, " Groups"); ok {
|
||||
return sectionNone, "", true
|
||||
if owner, ok := singleTokenPrefix(line, " Groups"); ok {
|
||||
return sectionOtherGroups, owner, true
|
||||
}
|
||||
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:
|
||||
//
|
||||
// O D R S M
|
||||
@@ -1138,6 +1235,7 @@ func (p *parser) resolvePending() {
|
||||
Origin: origin,
|
||||
Range: rng,
|
||||
Mass: report.F(pg.mass),
|
||||
Race: p.rep.Race,
|
||||
},
|
||||
ID: syntheticGroupID(pg.g),
|
||||
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 {
|
||||
dest, ok := p.lookupPlanetNumber(pf.destinationName)
|
||||
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 -----------------------------------------------------
|
||||
|
||||
type smokeWant struct {
|
||||
@@ -801,6 +888,7 @@ type smokeWant struct {
|
||||
players, extinct, local, other int
|
||||
uninhabited, unidentified, shipClasses int
|
||||
localGroups, localFleets, incomingGroups int
|
||||
otherGroups, unidentifiedGroups int
|
||||
localScience, otherScience, otherShipClass int
|
||||
bombings, shipProductions int
|
||||
battles int
|
||||
@@ -849,6 +937,8 @@ func runSmoke(t *testing.T, path string, want smokeWant) {
|
||||
{"LocalGroup", len(rep.LocalGroup), want.localGroups},
|
||||
{"LocalFleet", len(rep.LocalFleet), want.localFleets},
|
||||
{"IncomingGroup", len(rep.IncomingGroup), want.incomingGroups},
|
||||
{"OtherGroup", len(rep.OtherGroup), want.otherGroups},
|
||||
{"UnidentifiedGroup", len(rep.UnidentifiedGroup), want.unidentifiedGroups},
|
||||
{"LocalScience", len(rep.LocalScience), want.localScience},
|
||||
{"OtherScience", len(rep.OtherScience), want.otherScience},
|
||||
{"OtherShipClass", len(rep.OtherShipClass), want.otherShipClass},
|
||||
@@ -888,6 +978,7 @@ func runSmoke(t *testing.T, path string, want smokeWant) {
|
||||
// silently drops half the data.
|
||||
func TestParseDgKNNTS039(t *testing.T) {
|
||||
runSmoke(t, "../reports/dg/KNNTS039.REP", smokeWant{
|
||||
otherGroups: 723, unidentifiedGroups: 72,
|
||||
race: "KnightErrants", turn: 39,
|
||||
mapW: 800, mapH: 800, planetCount: 700,
|
||||
voteFor: "KnightErrants", votes: 16.02,
|
||||
@@ -908,6 +999,7 @@ func TestParseDgKNNTS039(t *testing.T) {
|
||||
|
||||
func TestParseDgKNNTS040(t *testing.T) {
|
||||
runSmoke(t, "../reports/dg/KNNTS040.REP", smokeWant{
|
||||
otherGroups: 734, unidentifiedGroups: 109,
|
||||
race: "KnightErrants", turn: 40,
|
||||
mapW: 800, mapH: 800, planetCount: 700,
|
||||
players: 91, extinct: 49,
|
||||
@@ -930,6 +1022,7 @@ func TestParseDgKNNTS040(t *testing.T) {
|
||||
// exercises the deferred name-resolution path in [parser.finish].
|
||||
func TestParseDgKNNTS041(t *testing.T) {
|
||||
runSmoke(t, "../reports/dg/KNNTS041.REP", smokeWant{
|
||||
otherGroups: 772, unidentifiedGroups: 349,
|
||||
race: "KnightErrants", turn: 41,
|
||||
mapW: 800, mapH: 800, planetCount: 700,
|
||||
players: 91, extinct: 50,
|
||||
@@ -952,6 +1045,7 @@ func TestParseDgKNNTS041(t *testing.T) {
|
||||
// gplus also sneaks "Incoming Groups" between sections.
|
||||
func TestParseGplus40(t *testing.T) {
|
||||
runSmoke(t, "../reports/gplus/40.REP", smokeWant{
|
||||
otherGroups: 1042, unidentifiedGroups: 44,
|
||||
race: "MbI", turn: 40,
|
||||
mapW: 350, mapH: 350, planetCount: 300,
|
||||
players: 26, extinct: 0,
|
||||
@@ -974,6 +1068,7 @@ func TestParseGplus40(t *testing.T) {
|
||||
// membership shape (no "Incoming Groups" this turn).
|
||||
func TestParseDgKiller031(t *testing.T) {
|
||||
runSmoke(t, "../reports/dg/Killer031.rep", smokeWant{
|
||||
otherGroups: 925, unidentifiedGroups: 34,
|
||||
race: "Killer", turn: 31,
|
||||
mapW: 250, mapH: 250, planetCount: 175,
|
||||
players: 25, extinct: 12,
|
||||
@@ -997,6 +1092,7 @@ func TestParseDgKiller031(t *testing.T) {
|
||||
// deferred name resolution is exercised in production conditions).
|
||||
func TestParseDgTancordia037(t *testing.T) {
|
||||
runSmoke(t, "../reports/dg/Tancordia037.rep", smokeWant{
|
||||
otherGroups: 580, unidentifiedGroups: 24,
|
||||
race: "Tancordia", turn: 37,
|
||||
mapW: 210, mapH: 210, planetCount: 140,
|
||||
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
@@ -1,9 +1,10 @@
|
||||
# Cargo routes UX
|
||||
|
||||
This document covers the cargo-route surface: the four-slot
|
||||
inspector subsection, the map-driven destination pick, and the
|
||||
optimistic overlay that keeps the inspector and the map in lock-step
|
||||
with the local order draft. The engine semantics are quoted from
|
||||
This document covers the cargo-route surface: the inspector
|
||||
subsection (a single-row dropdown + contextual actions after
|
||||
F8-05), the map-driven destination pick, and the optimistic
|
||||
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 "Грузовые маршруты"
|
||||
(lines 808–843); this file is the source of truth for how the UI
|
||||
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
|
||||
(`RemoveUnreachableRoutes`).
|
||||
|
||||
## Four-slot inspector subsection
|
||||
## Single-row inspector subsection
|
||||
|
||||
The cargo-routes subsection renders below the production controls
|
||||
on every owned planet inspector. Slots appear in
|
||||
`CARGO_LOAD_TYPE_VALUES` order (COL, CAP, MAT, EMP) so visual order
|
||||
matches the engine's load priority — players who scan top-down see
|
||||
the highest-priority cargo first.
|
||||
on every owned planet inspector. F8-05 collapsed the previous
|
||||
four-slot grid into a single `<select>` that lists the load-types
|
||||
in `CARGO_LOAD_TYPE_VALUES` order (COL, CAP, MAT, EMP) — same
|
||||
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** — `(no route)` text plus a single `Add` button.
|
||||
- **Empty** — a single `Add` button.
|
||||
- **Filled** — `→ {destination name}` plus `Edit` and `Remove`.
|
||||
|
||||
`Add` and `Edit` open a renderer-driven destination pick (see next
|
||||
section). `Remove` emits a `removeCargoRoute` command. The collapse
|
||||
rule on the order draft store ensures only one entry per
|
||||
section). `Remove` emits a `removeCargoRoute` command. The
|
||||
collapse rule on the order draft store ensures only one entry per
|
||||
`(source, loadType)` slot survives in the draft at any time, so a
|
||||
sequence of `Add → Edit → Remove` collapses to the latest verb only
|
||||
(matching the production-controls pattern).
|
||||
sequence of `Add → Edit → Remove` collapses to the latest verb
|
||||
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
|
||||
`OrderDraftStore` or `MapPickService` context is missing (the
|
||||
component is mounted outside the in-game shell, in tests, etc.).
|
||||
Disabled state: the dropdown and every action button are disabled
|
||||
when the `OrderDraftStore` or `MapPickService` context is missing
|
||||
(the component is mounted outside the in-game shell, in tests,
|
||||
etc.).
|
||||
|
||||
## Map-driven destination pick
|
||||
|
||||
|
||||
@@ -71,13 +71,24 @@ the colour block in `tokens.css`.
|
||||
`theme.resolved` (`light` | `dark`), and `theme.setChoice(…)`. It
|
||||
persists the choice, applies `data-theme`, and — while the choice is
|
||||
`system` — follows OS theme changes via `matchMedia`.
|
||||
- The account menu (`account-menu.svelte`) exposes the picker. The
|
||||
default is `system` (it follows the OS preference); `light` / `dark`
|
||||
pin a theme.
|
||||
- The persisted picker lives in the lobby profile screen
|
||||
([`screens/profile-screen.svelte`](../frontend/src/lib/screens/profile-screen.svelte)) —
|
||||
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
|
||||
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
|
||||
|
||||
@@ -126,4 +137,5 @@ battle-scene palette, both defined in code rather than as tokens), the
|
||||
overlay scrims, and the directional / deliberate drop shadows.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
in the inline editor and re-run by the store on every `add`. The
|
||||
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
|
||||
|
||||
@@ -178,6 +182,17 @@ optimistic overlay rewrites `planet.production` using
|
||||
mirrors the engine's `Cache.PlanetProductionDisplayName` so the
|
||||
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
|
||||
|
||||
`setProductionType` carries a collapse-by-target rule.
|
||||
|
||||
@@ -74,22 +74,26 @@ which surfaces as `rejected` in the order tab.
|
||||
|
||||
## Production-picker integration
|
||||
|
||||
The planet inspector's Research sub-row
|
||||
(`lib/inspectors/planet/production.svelte`) renders the four tech
|
||||
buttons and one extra button per defined science from the player's
|
||||
`localScience` overlay. A click on a science button dispatches
|
||||
The planet inspector's production row
|
||||
(`lib/inspectors/planet/production.svelte`) is two `<select>`s
|
||||
plus a green ✓ apply / yellow ✗ cancel pair after F8-05. With
|
||||
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
|
||||
wire-level `CommandPlanetProduce` shape
|
||||
(`pkg/schema/fbs/order.fbs.CommandPlanetProduce`).
|
||||
|
||||
The active highlight is derived from `planet.production` — the
|
||||
display string the engine emits in the report. A science name
|
||||
shadows the matching tech display string when they collide (a
|
||||
science deliberately named `Drive` wins over the Drive tech
|
||||
button), because the wire string is ambiguous and the user clearly
|
||||
intended the named science. This is a pragmatic accept; a
|
||||
structured production tag on the wire would let us disambiguate
|
||||
without the shadow rule, but that is a separate backend concern.
|
||||
The active value of both selects is derived from
|
||||
`planet.production` — the display string the engine emits in the
|
||||
report. A science name shadows the matching tech display string
|
||||
when they collide (a science deliberately named `Drive` wins over
|
||||
the Drive tech option), because the wire string is ambiguous and
|
||||
the user clearly intended the named science. This is a pragmatic
|
||||
accept; a structured production tag on the wire would let us
|
||||
disambiguate without the shadow rule, but that is a separate
|
||||
backend concern.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -102,5 +106,5 @@ without the shadow rule, but that is a separate backend concern.
|
||||
fractions, view-mode Delete dispatches `removeScience`,
|
||||
duplicate-name guard against the overlay.
|
||||
- `tests/e2e/sciences.spec.ts` — full Playwright walkthrough:
|
||||
create → list → set planet production via the Research sub-row
|
||||
→ delete.
|
||||
create → list → set planet production via the research/target
|
||||
dropdown pair + ✓ apply → delete.
|
||||
|
||||
@@ -193,6 +193,15 @@ export interface ReportShipGroupBase {
|
||||
range: number | null;
|
||||
speed: 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(),
|
||||
state: g.state() ?? "",
|
||||
fleet: g.fleet(),
|
||||
race: g.race() ?? "",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
@@ -895,6 +905,7 @@ function decodeOtherShipGroups(report: Report): ReportOtherShipGroup[] {
|
||||
range,
|
||||
speed: g.speed(),
|
||||
mass: g.mass(),
|
||||
race: g.race() ?? "",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -186,6 +186,7 @@ interface SyntheticShipGroup {
|
||||
mass?: number;
|
||||
state?: string;
|
||||
fleet?: string;
|
||||
race?: string;
|
||||
}
|
||||
|
||||
interface SyntheticIncomingGroup {
|
||||
@@ -344,6 +345,7 @@ function decodeSyntheticReport(json: unknown): GameReport {
|
||||
mass: numOr0(g.mass),
|
||||
state: typeof g.state === "string" ? g.state : "",
|
||||
fleet: typeof g.fleet === "string" ? g.fleet : null,
|
||||
race: typeof g.race === "string" ? g.race : race,
|
||||
}),
|
||||
);
|
||||
const otherShipGroups: ReportOtherShipGroup[] = (root.otherGroup ?? []).map(
|
||||
@@ -358,6 +360,7 @@ function decodeSyntheticReport(json: unknown): GameReport {
|
||||
range: typeof g.range === "number" ? g.range : null,
|
||||
speed: numOr0(g.speed),
|
||||
mass: numOr0(g.mass),
|
||||
race: typeof g.race === "string" ? g.race : "",
|
||||
}),
|
||||
);
|
||||
const incomingShipGroups: ReportIncomingShipGroup[] = (
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<!--
|
||||
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
|
||||
store — every checkbox / radio fires `store.setMapToggle(...)` or
|
||||
`store.setWrapMode(...)` and reads back the current state through
|
||||
the rune.
|
||||
store — every checkbox fires `store.setMapToggle(...)` and reads
|
||||
back the current state through 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
|
||||
`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 { restoreFocus } from "$lib/a11y/restore-focus";
|
||||
import type { MapToggles, GameStateStore } from "$lib/game-state.svelte";
|
||||
import type { WrapMode } from "../../map/world";
|
||||
|
||||
type Props = { store: GameStateStore };
|
||||
let { store }: Props = $props();
|
||||
@@ -35,18 +39,6 @@ bottom-tabs bar.
|
||||
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 {
|
||||
if (event.key === "Escape" && open) {
|
||||
open = false;
|
||||
@@ -197,31 +189,6 @@ bottom-tabs bar.
|
||||
/>
|
||||
<span>{i18n.t("game.map.toggles.visible_hyperspace")}</span>
|
||||
</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>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -295,27 +262,9 @@ bottom-tabs bar.
|
||||
label:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
input[type="checkbox"] {
|
||||
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) {
|
||||
.surface {
|
||||
position: fixed;
|
||||
|
||||
@@ -45,6 +45,7 @@ return to the lobby still disposes the stores via `onDestroy`.
|
||||
<script lang="ts">
|
||||
import { onDestroy, setContext, untrack } from "svelte";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import { theme } from "$lib/theme/theme.svelte";
|
||||
import { appScreen, activeView } from "$lib/app-nav.svelte";
|
||||
import Header from "$lib/header/header.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();
|
||||
orderDraft.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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -2,9 +2,13 @@
|
||||
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
|
||||
`← Turn N →` triplet with a popover of every turn), the view
|
||||
dropdown / hamburger, and the account menu. The sidebar-toggle slot
|
||||
to its left appears only on tablet viewports (768–1024 px) and is
|
||||
wired by `+layout.svelte`.
|
||||
dropdown / hamburger, and the in-game ephemeral light/dark theme
|
||||
toggle. The sidebar-toggle slot to its left appears only on tablet
|
||||
viewports (768–1024 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
|
||||
name from the lobby's `GameSummary.gameName`. While either piece
|
||||
@@ -24,7 +28,7 @@ absent until Phase 24 wires push-event state.
|
||||
type GameStateStore,
|
||||
} from "$lib/game-state.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";
|
||||
|
||||
type Props = {
|
||||
@@ -78,7 +82,7 @@ absent until Phase 24 wires push-event state.
|
||||
⤧
|
||||
</button>
|
||||
<ViewMenu />
|
||||
<AccountMenu />
|
||||
<GameModeThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -144,16 +144,9 @@ const en = {
|
||||
"game.shell.menu.close_sidebar": "close sidebar",
|
||||
"game.shell.menu.open_views": "open 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.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.turn.label": "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.unreachable_planets": "show unreachable planets",
|
||||
"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.planets": "planets",
|
||||
"game.view.table.ship_classes": "ship classes",
|
||||
@@ -293,7 +283,6 @@ const en = {
|
||||
"game.inspector.planet.action.rename": "rename",
|
||||
"game.inspector.planet.rename.title": "rename planet",
|
||||
"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.too_long": "name is too long (30 characters max)",
|
||||
"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.disallowed_character": "name contains disallowed characters",
|
||||
"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.materials": "materials",
|
||||
"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.shields": "shields",
|
||||
"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.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.cap": "industry",
|
||||
"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.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.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.foreign": "foreign",
|
||||
|
||||
"game.report.loading": "loading report…",
|
||||
"game.report.back_to_map": "back to map",
|
||||
|
||||
@@ -145,16 +145,9 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.shell.menu.close_sidebar": "закрыть боковую панель",
|
||||
"game.shell.menu.open_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.logout": "выйти",
|
||||
"game.shell.theme_toggle.to_light": "переключить на светлую тему",
|
||||
"game.shell.theme_toggle.to_dark": "переключить на тёмную тему",
|
||||
"game.shell.coming_soon": "скоро будет",
|
||||
"game.shell.turn.label": "ход {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.unreachable_planets": "показывать недостижимые планеты",
|
||||
"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.planets": "планеты",
|
||||
"game.view.table.ship_classes": "классы кораблей",
|
||||
@@ -294,7 +284,6 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.inspector.planet.action.rename": "переименовать",
|
||||
"game.inspector.planet.rename.title": "переименование планеты",
|
||||
"game.inspector.planet.rename.confirm": "сохранить",
|
||||
"game.inspector.planet.rename.cancel": "отмена",
|
||||
"game.inspector.planet.rename.invalid.empty": "имя не может быть пустым",
|
||||
"game.inspector.planet.rename.invalid.too_long": "имя слишком длинное (максимум 30 символов)",
|
||||
"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.disallowed_character": "имя содержит недопустимые символы",
|
||||
"game.inspector.planet.production.title": "производство",
|
||||
"game.inspector.planet.production.main.aria": "тип производства",
|
||||
"game.inspector.planet.production.option.industry": "промышленность",
|
||||
"game.inspector.planet.production.option.materials": "сырьё",
|
||||
"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.shields": "щиты",
|
||||
"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.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.cap": "промышленность",
|
||||
"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.fleet_name": "имя флота не соответствует правилам имён сущностей",
|
||||
|
||||
"game.inspector.planet.ship_groups.race_filter.aria": "раса в орбите",
|
||||
"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.foreign": "чужие",
|
||||
|
||||
"game.report.loading": "загрузка отчёта…",
|
||||
"game.report.back_to_map": "назад к карте",
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
<!--
|
||||
Planet inspector. Renders the documented field set for each planet
|
||||
kind (local / other / uninhabited / unidentified) and exposes a
|
||||
Rename action on owned (`local`) planets that opens an inline
|
||||
editor. The editor runs the same `validateEntityName` rules as the
|
||||
server-side validator (parity with `pkg/util/string.go`) and, on
|
||||
confirm, appends a `planetRename` command to the local order draft
|
||||
through the `OrderDraftStore` provided via context.
|
||||
click-to-edit affordance on the name itself for owned (`local`)
|
||||
planets: a click on the name turns it into an inline input with a
|
||||
single ✓ confirm icon (Escape cancels). The editor runs the same
|
||||
`validateEntityName` rules as the server-side validator (parity with
|
||||
`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
|
||||
inline editor lives directly inside this component per PLAN.md
|
||||
Phase 14 — a separate file would be over-abstraction for one input
|
||||
field with five buttons.
|
||||
inline editor lives directly inside this component — a separate
|
||||
file would be over-abstraction for one input field and a confirm
|
||||
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">
|
||||
import { getContext, tick } from "svelte";
|
||||
@@ -158,37 +162,32 @@ field with five buttons.
|
||||
>
|
||||
<header>
|
||||
<p class="kind" data-testid="inspector-planet-kind">{kindLabel}</p>
|
||||
{#if planet.kind !== "unidentified"}
|
||||
<h3 class="name" data-testid="inspector-planet-name">{planet.name}</h3>
|
||||
{/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>
|
||||
|
||||
{#if planet.kind === "local" && renameOpen}
|
||||
{#if planet.kind === "local"}
|
||||
{#if 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"
|
||||
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"
|
||||
@@ -198,27 +197,21 @@ field with five buttons.
|
||||
{renameInvalidMessage}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="rename-actions">
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="rename-cancel"
|
||||
data-testid="inspector-planet-rename-cancel"
|
||||
onclick={cancelRename}
|
||||
class="name name--editable"
|
||||
data-testid="inspector-planet-name"
|
||||
aria-label={i18n.t("game.inspector.planet.action.rename")}
|
||||
onclick={openRename}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.rename.cancel")}
|
||||
{planet.name}
|
||||
</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}
|
||||
{:else if planet.kind !== "unidentified"}
|
||||
<h3 class="name" data-testid="inspector-planet-name">{planet.name}</h3>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if planet.kind === "local"}
|
||||
<Production {planet} {localShipClass} {localScience} />
|
||||
@@ -374,34 +367,35 @@ field with five buttons.
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.action {
|
||||
align-self: flex-start;
|
||||
margin-top: 0.25rem;
|
||||
.name--editable {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 0.1rem 0.25rem;
|
||||
margin: 0 -0.25rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
color: inherit;
|
||||
border: 1px dashed transparent;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
cursor: text;
|
||||
}
|
||||
.action:hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-accent);
|
||||
.name--editable:hover,
|
||||
.name--editable:focus-visible {
|
||||
border-color: var(--color-border);
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
.rename {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.rename-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.rename-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 1rem;
|
||||
padding: 0.25rem 0.45rem;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -411,32 +405,31 @@ field with five buttons.
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.rename-error {
|
||||
margin: 0;
|
||||
margin: 0.2rem 0 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.rename-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.rename-cancel,
|
||||
.rename-confirm {
|
||||
.icon-action {
|
||||
flex: 0 0 auto;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0.65rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rename-confirm:not(:disabled):hover,
|
||||
.rename-cancel:hover {
|
||||
.icon-action:not(:disabled):hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.rename-confirm:disabled {
|
||||
.icon-action:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.icon-action--apply:not(:disabled) {
|
||||
color: var(--color-success);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
<!--
|
||||
Phase 16 cargo-routes subsection of the planet inspector. Shows a
|
||||
fixed COL/CAP/MAT/EMP four-slot table for the active local planet,
|
||||
each slot either empty (with a single Add button) or filled (with
|
||||
the destination planet's name plus Edit and Remove buttons). Add
|
||||
and Edit hand off to the renderer-driven `MapPickService`: the map
|
||||
dims out-of-reach planets, draws the cursor-line anchor, and
|
||||
resolves with either a chosen destination id or `null` (cancel).
|
||||
F8-05 cargo-routes subsection of the planet inspector. Renders one
|
||||
compact row: a single `<select>` with the COL/CAP/MAT/EMP load-types
|
||||
(plus a default placeholder option that absorbs the old section
|
||||
title) and a context block to its right showing either an `add`
|
||||
button when the selected type has no route, or `→ destination` plus
|
||||
`edit` and `remove` buttons when a route is in place.
|
||||
|
||||
The component is purposely deferential to the existing infrastructure:
|
||||
- `OrderDraftStore` enforces the `(source, loadType)` collapse rule,
|
||||
so the optimistic overlay always matches what the server sees.
|
||||
- `MapPickService.pick(...)` is a renderer-side abstraction; its
|
||||
source/destination semantics live in `lib/active-view/map.svelte`.
|
||||
- Reach (`40 * driveTech` per `game/internal/model/game/race.go`)
|
||||
is computed inline using `torusShortestDelta` to mirror the
|
||||
engine's torus distance — see `pkg/util/map.go.deltas`.
|
||||
Picking a load-type out of the dropdown does not commit anything by
|
||||
itself: the player still has to press add / edit (which hand off to
|
||||
the renderer-driven `MapPickService`) or remove (which appends a
|
||||
`removeCargoRoute` command directly). After every action the
|
||||
dropdown stays on the type that was just acted on, so the result is
|
||||
visible in place. The `OrderDraftStore.add()` collapse rule keeps at
|
||||
most one entry per `(source, loadType)` pair, mirroring the engine's
|
||||
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">
|
||||
import { getContext } from "svelte";
|
||||
@@ -59,13 +63,15 @@ The component is purposely deferential to the existing infrastructure:
|
||||
const disabled = $derived(draft === undefined || pick === undefined);
|
||||
|
||||
let pendingSlot: CargoLoadType | null = $state(null);
|
||||
let selected = $state<CargoLoadType | "">("");
|
||||
|
||||
$effect(() => {
|
||||
// Reset the in-flight slot whenever the inspector switches to a
|
||||
// different planet so a stale "pick in progress" prompt does
|
||||
// not leak across the selection boundary.
|
||||
// Reset the local UI whenever the inspector switches to a
|
||||
// different planet so a stale dropdown selection (or in-flight
|
||||
// pick prompt) does not leak across the selection boundary.
|
||||
void planet.number;
|
||||
pendingSlot = null;
|
||||
selected = "";
|
||||
});
|
||||
|
||||
const SLOT_LABELS: Record<CargoLoadType, TranslationKey> = {
|
||||
@@ -78,9 +84,9 @@ The component is purposely deferential to the existing infrastructure:
|
||||
const currentEntries = $derived(
|
||||
routes.find((r) => r.sourcePlanetNumber === planet.number)?.entries ?? [],
|
||||
);
|
||||
// Per-slot derived map keeps the template's {#each} block free of
|
||||
// the {@const}/`find` chain that Svelte 5 sometimes mis-tracks
|
||||
// when the source array is freshly cloned by `applyOrderOverlay`.
|
||||
// Per-slot derived map keeps the template free of the
|
||||
// `.find(...)` chain that Svelte 5 sometimes mis-tracks when the
|
||||
// source array is freshly cloned by `applyOrderOverlay`.
|
||||
const slotEntries = $derived.by(() => {
|
||||
const map: Record<CargoLoadType, ReportRoute["entries"][number] | null> = {
|
||||
COL: null,
|
||||
@@ -162,67 +168,82 @@ The component is purposely deferential to the existing infrastructure:
|
||||
function cancelPick(): void {
|
||||
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>
|
||||
|
||||
<section class="cargo" data-testid="inspector-planet-cargo">
|
||||
<h4 class="title">
|
||||
{i18n.t("game.inspector.planet.cargo.title")}
|
||||
</h4>
|
||||
<dl class="slots">
|
||||
{#each CARGO_LOAD_TYPE_VALUES as loadType (loadType)}
|
||||
{@const entry = slotEntries[loadType]}
|
||||
{@const slug = loadType.toLowerCase()}
|
||||
<div class="slot" data-testid={`inspector-planet-cargo-slot-${slug}`}>
|
||||
<dt class="slot-label" data-testid={`inspector-planet-cargo-slot-${slug}-label`}>
|
||||
{i18n.t(SLOT_LABELS[loadType])}
|
||||
</dt>
|
||||
<dd class="slot-body">
|
||||
{#if entry === null}
|
||||
<span
|
||||
class="empty"
|
||||
data-testid={`inspector-planet-cargo-slot-${slug}-empty`}
|
||||
<div class="row">
|
||||
<select
|
||||
class="select"
|
||||
data-testid="inspector-planet-cargo-type"
|
||||
aria-label={i18n.t("game.inspector.planet.cargo.placeholder")}
|
||||
value={selected}
|
||||
onchange={pickType}
|
||||
disabled={disabled || pendingSlot !== null}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.cargo.empty")}
|
||||
</span>
|
||||
<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])}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if selected !== ""}
|
||||
{#if activeEntry === null}
|
||||
<button
|
||||
type="button"
|
||||
class="action add"
|
||||
data-testid={`inspector-planet-cargo-slot-${slug}-add`}
|
||||
data-testid={`inspector-planet-cargo-slot-${selectedSlug}-add`}
|
||||
disabled={disabled || pendingSlot !== null}
|
||||
onclick={() => void startPick(loadType)}
|
||||
onclick={() => void startPick(selected as CargoLoadType)}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.cargo.add")}
|
||||
</button>
|
||||
{:else}
|
||||
<span
|
||||
class="destination"
|
||||
data-testid={`inspector-planet-cargo-slot-${slug}-destination`}
|
||||
data-testid={`inspector-planet-cargo-slot-${selectedSlug}-destination`}
|
||||
>
|
||||
→ {destinationName(entry.destinationPlanetNumber)}
|
||||
→ {destinationName(activeEntry.destinationPlanetNumber)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="action edit"
|
||||
data-testid={`inspector-planet-cargo-slot-${slug}-edit`}
|
||||
data-testid={`inspector-planet-cargo-slot-${selectedSlug}-edit`}
|
||||
disabled={disabled || pendingSlot !== null}
|
||||
onclick={() => void startPick(loadType)}
|
||||
onclick={() => void startPick(selected as CargoLoadType)}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.cargo.edit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action remove"
|
||||
data-testid={`inspector-planet-cargo-slot-${slug}-remove`}
|
||||
data-testid={`inspector-planet-cargo-slot-${selectedSlug}-remove`}
|
||||
disabled={disabled || pendingSlot !== null}
|
||||
onclick={() => void removeRoute(loadType)}
|
||||
onclick={() => void removeRoute(selected as CargoLoadType)}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.cargo.remove")}
|
||||
</button>
|
||||
{/if}
|
||||
</dd>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
|
||||
{#if pendingSlot !== null}
|
||||
<div
|
||||
class="pick-prompt"
|
||||
@@ -241,7 +262,7 @@ The component is purposely deferential to the existing infrastructure:
|
||||
{i18n.t("game.inspector.planet.cargo.pick.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
{:else if reach > 0 && reachableSet().size === 0}
|
||||
{:else if selected !== "" && reach > 0 && reachableSet().size === 0}
|
||||
<p
|
||||
class="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;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.title {
|
||||
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;
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
@@ -289,7 +288,23 @@ The component is purposely deferential to the existing infrastructure:
|
||||
font-size: 0.9rem;
|
||||
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);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -1,35 +1,31 @@
|
||||
<!--
|
||||
Phase 15 production-controls subsection of the planet inspector.
|
||||
Renders four main segments — industry / materials / research / build
|
||||
ship — and reveals a sub-row when the player picks a category that
|
||||
needs a target (research → tech field or science, build ship →
|
||||
designed class). Every leaf click 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.
|
||||
F8-05 production-controls subsection of the planet inspector. Renders
|
||||
two dropdowns on a single row (primary: industry / materials /
|
||||
research / ship; secondary: tech / science / ship class — only for
|
||||
the research and ship contexts) together with green ✓ apply and
|
||||
yellow ✗ cancel icon buttons. The apply button becomes enabled when
|
||||
the row selection differs from the planet's current effective
|
||||
production (post-overlay) and the choice is complete (industry /
|
||||
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
|
||||
`Cache.PlanetProductionDisplayName` mapping. While the player is
|
||||
mid-navigation (e.g. clicked Research but has not picked a tech yet)
|
||||
a transient `expandedMain` override widens the visible state so the
|
||||
sub-row can appear without forcing the player to commit a choice
|
||||
first; the override resets whenever the inspector switches to a
|
||||
different planet or after any leaf click.
|
||||
`Cache.PlanetProductionDisplayName` mapping. Whenever the inspector
|
||||
switches to a different planet or `planet.production` itself changes
|
||||
(typically after a successful apply round-trip projected by
|
||||
`applyOrderOverlay`), the row re-seeds from the new parsed value.
|
||||
|
||||
Phase 15 deliberately defers the per-type forecast number — see
|
||||
`ui/docs/calc-bridge.md` for the gap analysis. The component does
|
||||
not render forecast text; the existing `freeIndustry` ("free
|
||||
production") row in the parent inspector is unchanged.
|
||||
|
||||
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).
|
||||
The Research target list combines the four tech display strings with
|
||||
each defined science from `localScience`. A science name shadows the
|
||||
tech display string with the same text — the engine sends a single
|
||||
ambiguous display string in `planet.production`; user-defined
|
||||
sciences win because they carry more user intent.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
@@ -59,26 +55,10 @@ they carry more user intent).
|
||||
);
|
||||
const disabled = draft === undefined;
|
||||
|
||||
let expandedMain: MainSegment | null = $state(null);
|
||||
|
||||
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;
|
||||
});
|
||||
type TechFbs = "DRIVE" | "WEAPONS" | "SHIELDS" | "CARGO";
|
||||
|
||||
const RESEARCH_OPTIONS: ReadonlyArray<{
|
||||
fbs: ProductionType;
|
||||
fbs: TechFbs;
|
||||
slug: "drive" | "weapons" | "shields" | "cargo";
|
||||
labelKey: TranslationKey;
|
||||
}> = [
|
||||
@@ -128,14 +108,14 @@ they carry more user intent).
|
||||
return classes.some((c) => c.name === value) ? "ship" : null;
|
||||
}
|
||||
|
||||
function parseResearch(
|
||||
function parseTarget(
|
||||
value: string | null,
|
||||
classes: ShipClassSummary[],
|
||||
sciences: ScienceSummary[],
|
||||
): ProductionType | null {
|
||||
// A science name shadows the four tech display strings — when a
|
||||
// science matches we surface no tech-button highlight so the
|
||||
// science button gets the active styling instead.
|
||||
if (value !== null && sciences.some((s) => s.name === value)) return null;
|
||||
): string | null {
|
||||
if (value === null || value === "") return null;
|
||||
// Science wins over tech display string (same shadowing rule).
|
||||
if (sciences.some((s) => s.name === value)) return value;
|
||||
switch (value) {
|
||||
case "Drive":
|
||||
return "DRIVE";
|
||||
@@ -145,54 +125,93 @@ they carry more user intent).
|
||||
return "SHIELDS";
|
||||
case "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;
|
||||
}
|
||||
|
||||
function clickMain(segment: MainSegment): void {
|
||||
if (segment === "industry") {
|
||||
void emit("CAP", "");
|
||||
expandedMain = null;
|
||||
const parsedMain = $derived(
|
||||
parseMain(planet.production, localShipClass, localScience),
|
||||
);
|
||||
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;
|
||||
}
|
||||
if (segment === "materials") {
|
||||
void emit("MAT", "");
|
||||
expandedMain = null;
|
||||
if (mainSel === "materials") {
|
||||
await emit("MAT", "");
|
||||
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 {
|
||||
void emit(value, "");
|
||||
expandedMain = null;
|
||||
}
|
||||
|
||||
function clickScience(name: string): void {
|
||||
void emit("SCIENCE", name);
|
||||
expandedMain = null;
|
||||
}
|
||||
|
||||
function clickShip(name: string): void {
|
||||
void emit("SHIP", name);
|
||||
expandedMain = null;
|
||||
function cancelRow(): void {
|
||||
mainSel = parsedMain ?? DEFAULT_MAIN;
|
||||
targetSel = parsedTarget ?? "";
|
||||
}
|
||||
|
||||
async function emit(
|
||||
@@ -214,103 +233,110 @@ they carry more user intent).
|
||||
<h4 class="title">
|
||||
{i18n.t("game.inspector.planet.production.title")}
|
||||
</h4>
|
||||
<div class="row main">
|
||||
<button
|
||||
type="button"
|
||||
class="seg"
|
||||
class:active={selectedMain === "industry"}
|
||||
data-testid="inspector-planet-production-segment-industry"
|
||||
disabled={disabled}
|
||||
onclick={() => clickMain("industry")}
|
||||
<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")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="seg"
|
||||
class:active={selectedMain === "materials"}
|
||||
data-testid="inspector-planet-production-segment-materials"
|
||||
disabled={disabled}
|
||||
onclick={() => clickMain("materials")}
|
||||
>
|
||||
</option>
|
||||
<option value="materials">
|
||||
{i18n.t("game.inspector.planet.production.option.materials")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="seg"
|
||||
class:active={selectedMain === "research"}
|
||||
data-testid="inspector-planet-production-segment-research"
|
||||
disabled={disabled}
|
||||
onclick={() => clickMain("research")}
|
||||
>
|
||||
</option>
|
||||
<option value="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")}
|
||||
>
|
||||
</option>
|
||||
<option value="ship">
|
||||
{i18n.t("game.inspector.planet.production.option.ship")}
|
||||
</button>
|
||||
</div>
|
||||
</option>
|
||||
</select>
|
||||
|
||||
{#if selectedMain === "research"}
|
||||
<div class="row sub" data-testid="inspector-planet-production-research-row">
|
||||
{#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)}
|
||||
<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)}
|
||||
<option
|
||||
value={option.fbs}
|
||||
data-testid={`inspector-planet-production-target-option-${option.slug}`}
|
||||
>
|
||||
{i18n.t(option.labelKey)}
|
||||
</button>
|
||||
</option>
|
||||
{/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)}
|
||||
<option
|
||||
value={sci.name}
|
||||
data-testid={`inspector-planet-production-target-option-science-${sci.name}`}
|
||||
>
|
||||
{sci.name}
|
||||
</button>
|
||||
</option>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedMain === "ship"}
|
||||
<div class="row sub" data-testid="inspector-planet-production-ship-row">
|
||||
{:else if mainSel === "ship"}
|
||||
{#if localShipClass.length === 0}
|
||||
<p
|
||||
class="empty"
|
||||
<option
|
||||
value=""
|
||||
disabled
|
||||
data-testid="inspector-planet-production-ship-empty"
|
||||
>
|
||||
{i18n.t("game.inspector.planet.production.ship.no_classes")}
|
||||
</p>
|
||||
</option>
|
||||
{: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)}
|
||||
<option
|
||||
value={cls.name}
|
||||
data-testid={`inspector-planet-production-target-option-ship-${cls.name}`}
|
||||
>
|
||||
{cls.name}
|
||||
</button>
|
||||
</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</select>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="icon-action icon-action--apply"
|
||||
data-testid="inspector-planet-production-apply"
|
||||
disabled={applyDisabled}
|
||||
aria-label={i18n.t("game.inspector.planet.production.apply")}
|
||||
onclick={() => void applyRow()}
|
||||
>
|
||||
<span aria-hidden="true">✓</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-action icon-action--cancel"
|
||||
data-testid="inspector-planet-production-cancel"
|
||||
disabled={cancelDisabled}
|
||||
aria-label={i18n.t("game.inspector.planet.production.cancel")}
|
||||
onclick={cancelRow}
|
||||
>
|
||||
<span aria-hidden="true">✗</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@@ -327,43 +353,49 @@ they carry more user intent).
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.row.sub {
|
||||
padding-left: 0.6rem;
|
||||
}
|
||||
.seg,
|
||||
.sub-seg {
|
||||
.select {
|
||||
flex: 1 1 6rem;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
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;
|
||||
}
|
||||
.seg:not(:disabled):hover,
|
||||
.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 {
|
||||
.select:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.empty {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
.icon-action {
|
||||
flex: 0 0 auto;
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
padding: 0.15rem 0.2rem;
|
||||
background: transparent;
|
||||
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>
|
||||
|
||||
@@ -10,14 +10,24 @@ best-effort:
|
||||
typed contract does not carry per-group ownership outside
|
||||
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 shared `SelectionStore`. The actions panel mounts on top of
|
||||
the existing ship-group inspector, so the row is the on-planet
|
||||
entry point for Send / Load / Modernize / etc. Foreign rows stay
|
||||
non-interactive — there are no actions to drive against another
|
||||
race's fleet. Phase 21+ will reuse the same row shape inside the
|
||||
ship-groups table view with an additional `(planet, race)` filter.
|
||||
race's fleet.
|
||||
|
||||
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">
|
||||
import { getContext } from "svelte";
|
||||
@@ -56,6 +66,17 @@ ship-groups table view with an additional `(planet, race)` filter.
|
||||
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 rows: StationedRow[] = [];
|
||||
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;
|
||||
rows.push({
|
||||
key: `local:${g.id}`,
|
||||
race: localRace || i18n.t("game.inspector.planet.ship_groups.race.unknown"),
|
||||
race: g.race || localRace || unknownRace,
|
||||
class: g.class,
|
||||
count: g.count,
|
||||
mass: g.mass,
|
||||
@@ -71,16 +92,13 @@ ship-groups table view with an additional `(planet, race)` filter.
|
||||
groupId: g.id,
|
||||
});
|
||||
}
|
||||
const foreignRace =
|
||||
planet.owner ??
|
||||
i18n.t("game.inspector.planet.ship_groups.race.foreign");
|
||||
for (let i = 0; i < otherShipGroups.length; i++) {
|
||||
const g = otherShipGroups[i]!;
|
||||
if (g.destination !== planet.number) continue;
|
||||
if (g.origin !== null || g.range !== null) continue;
|
||||
rows.push({
|
||||
key: `other:${i}`,
|
||||
race: foreignRace,
|
||||
race: g.race || unknownRace,
|
||||
class: g.class,
|
||||
count: g.count,
|
||||
mass: g.mass,
|
||||
@@ -91,6 +109,55 @@ ship-groups table view with an additional `(planet, race)` filter.
|
||||
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 {
|
||||
if (selection === undefined) return;
|
||||
selection.selectShipGroup({ variant: "local", id: groupId });
|
||||
@@ -99,48 +166,45 @@ ship-groups table view with an additional `(planet, race)` filter.
|
||||
|
||||
{#if stationedRows.length > 0}
|
||||
<section class="ship-groups" data-testid="inspector-planet-ship-groups">
|
||||
<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">
|
||||
{#each stationedRows as row (row.key)}
|
||||
{#each filteredRows as row (row.key)}
|
||||
<li class="row" data-testid="inspector-planet-ship-groups-row">
|
||||
{#if row.selectable && row.groupId !== null}
|
||||
{@const groupId = row.groupId}
|
||||
<button
|
||||
type="button"
|
||||
class="select"
|
||||
class="cells select"
|
||||
data-testid="inspector-planet-ship-groups-select"
|
||||
onclick={() => selectLocalGroup(groupId)}
|
||||
>
|
||||
<span class="race" data-testid="inspector-planet-ship-groups-race">
|
||||
{row.race}
|
||||
</span>
|
||||
<span class="class">{row.class}</span>
|
||||
<span class="count">
|
||||
{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>
|
||||
<span class="count">{row.count} ×</span>
|
||||
<span class="mass">{formatFloat(row.mass)}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="race" data-testid="inspector-planet-ship-groups-race">
|
||||
{row.race}
|
||||
</span>
|
||||
<div class="cells">
|
||||
<span class="class">{row.class}</span>
|
||||
<span class="count">
|
||||
{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>
|
||||
<span class="count">{row.count} ×</span>
|
||||
<span class="mass">{formatFloat(row.mass)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
@@ -154,6 +218,12 @@ ship-groups table view with an additional `(planet, race)` filter.
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
@@ -161,6 +231,18 @@ ship-groups table view with an additional `(planet, race)` filter.
|
||||
letter-spacing: 0.05em;
|
||||
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 {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -172,13 +254,12 @@ ship-groups table view with an additional `(planet, race)` filter.
|
||||
.row {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.row > span,
|
||||
.row > .select {
|
||||
.cells {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto auto;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
.select {
|
||||
width: 100%;
|
||||
@@ -195,14 +276,18 @@ ship-groups table view with an additional `(planet, race)` filter.
|
||||
border-color: var(--color-border);
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
.race {
|
||||
font-weight: 600;
|
||||
}
|
||||
.class {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.count,
|
||||
.count {
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.mass {
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
* `data-theme` from the same `localStorage` key before the app boots;
|
||||
* this store mirrors that logic and takes over once mounted, including
|
||||
* 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. */
|
||||
@@ -45,6 +52,7 @@ function systemTheme(): ResolvedTheme {
|
||||
class ThemeStore {
|
||||
#choice = $state<ThemeChoice>(readStoredChoice());
|
||||
#system = $state<ResolvedTheme>(systemTheme());
|
||||
#override = $state<ResolvedTheme | null>(null);
|
||||
|
||||
constructor() {
|
||||
if (
|
||||
@@ -55,7 +63,9 @@ class ThemeStore {
|
||||
.matchMedia(SYSTEM_LIGHT_QUERY)
|
||||
.addEventListener("change", (event: MediaQueryListEvent) => {
|
||||
this.#system = event.matches ? "light" : "dark";
|
||||
if (this.#choice === "system") this.#apply();
|
||||
if (this.#choice === "system" && this.#override === null) {
|
||||
this.#apply();
|
||||
}
|
||||
});
|
||||
}
|
||||
this.#apply();
|
||||
@@ -66,8 +76,17 @@ class ThemeStore {
|
||||
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. */
|
||||
get resolved(): ResolvedTheme {
|
||||
if (this.#override !== null) return this.#override;
|
||||
return this.#choice === "system" ? this.#system : this.#choice;
|
||||
}
|
||||
|
||||
@@ -80,6 +99,26 @@ class ThemeStore {
|
||||
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 {
|
||||
if (typeof document !== "undefined") {
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
builder.startObject(13);
|
||||
builder.startObject(14);
|
||||
}
|
||||
|
||||
static addNumber(builder:flatbuffers.Builder, number:bigint) {
|
||||
@@ -172,6 +179,10 @@ static addFleet(builder:flatbuffers.Builder, fleetOffset:flatbuffers.Offset) {
|
||||
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 {
|
||||
const offset = builder.endObject();
|
||||
builder.requiredField(offset, 24) // id
|
||||
@@ -193,7 +204,8 @@ unpack(): LocalGroupT {
|
||||
this.mass(),
|
||||
(this.id() !== null ? this.id()!.unpack() : null),
|
||||
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.state = this.state();
|
||||
_o.fleet = this.fleet();
|
||||
_o.race = this.race();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +242,8 @@ constructor(
|
||||
public mass: number = 0.0,
|
||||
public id: UUIDT|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 state = (this.state !== null ? builder.createString(this.state!) : 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.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.addState(builder, state);
|
||||
LocalGroup.addFleet(builder, fleet);
|
||||
LocalGroup.addRace(builder, race);
|
||||
|
||||
return LocalGroup.endLocalGroup(builder);
|
||||
}
|
||||
|
||||
@@ -84,8 +84,15 @@ mass():number {
|
||||
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) {
|
||||
builder.startObject(10);
|
||||
builder.startObject(11);
|
||||
}
|
||||
|
||||
static addNumber(builder:flatbuffers.Builder, number:bigint) {
|
||||
@@ -140,12 +147,16 @@ static addMass(builder:flatbuffers.Builder, mass:number) {
|
||||
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 {
|
||||
const offset = builder.endObject();
|
||||
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.addNumber(builder, number);
|
||||
OtherGroup.addClass(builder, class_Offset);
|
||||
@@ -159,6 +170,7 @@ static createOtherGroup(builder:flatbuffers.Builder, number:bigint, class_Offset
|
||||
OtherGroup.addRange(builder, range);
|
||||
OtherGroup.addSpeed(builder, speed);
|
||||
OtherGroup.addMass(builder, mass);
|
||||
OtherGroup.addRace(builder, raceOffset);
|
||||
return OtherGroup.endOtherGroup(builder);
|
||||
}
|
||||
|
||||
@@ -173,7 +185,8 @@ unpack(): OtherGroupT {
|
||||
this.origin(),
|
||||
this.range(),
|
||||
this.speed(),
|
||||
this.mass()
|
||||
this.mass(),
|
||||
this.race()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,6 +202,7 @@ unpackTo(_o: OtherGroupT): void {
|
||||
_o.range = this.range();
|
||||
_o.speed = this.speed();
|
||||
_o.mass = this.mass();
|
||||
_o.race = this.race();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +217,8 @@ constructor(
|
||||
public origin: bigint|null = null,
|
||||
public range: number|null = null,
|
||||
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 tech = OtherGroup.createTechVector(builder, builder.createObjectOffsetList(this.tech));
|
||||
const cargo = (this.cargo !== null ? builder.createString(this.cargo!) : 0);
|
||||
const race = (this.race !== null ? builder.createString(this.race!) : 0);
|
||||
|
||||
return OtherGroup.createOtherGroup(builder,
|
||||
this.number,
|
||||
@@ -222,7 +238,8 @@ pack(builder:flatbuffers.Builder): flatbuffers.Offset {
|
||||
this.origin,
|
||||
this.range,
|
||||
this.speed,
|
||||
this.mass
|
||||
this.mass,
|
||||
race
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,17 +44,17 @@ test.describe("keyboard navigation", () => {
|
||||
await expect(page.locator("#active-view-host")).toBeFocused();
|
||||
});
|
||||
|
||||
test("Escape closes the account menu and returns focus to its trigger", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("game-mode theme toggle is keyboard activatable", async ({ page }) => {
|
||||
await bootShell(page);
|
||||
await page.getByTestId("account-menu-trigger").click();
|
||||
await expect(page.getByTestId("account-menu-list")).toBeVisible();
|
||||
// Move focus into the menu, then dismiss with Escape.
|
||||
await page.getByTestId("account-menu-theme-select").focus();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("account-menu-list")).toBeHidden();
|
||||
await expect(page.getByTestId("account-menu-trigger")).toBeFocused();
|
||||
const toggle = page.getByTestId("game-mode-theme-toggle");
|
||||
await toggle.focus();
|
||||
await expect(toggle).toBeFocused();
|
||||
const before = await toggle.getAttribute("data-theme");
|
||||
await page.keyboard.press("Enter");
|
||||
const after = await toggle.getAttribute("data-theme");
|
||||
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 }) => {
|
||||
|
||||
@@ -394,9 +394,8 @@ test("cargo-routes flow: pick a destination, arrow appears, reload restores", as
|
||||
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText(
|
||||
SOURCE_PLANET.name,
|
||||
);
|
||||
await expect(
|
||||
sidebar.getByTestId("inspector-planet-cargo-slot-col-empty"),
|
||||
).toBeVisible();
|
||||
const typeSelect = sidebar.getByTestId("inspector-planet-cargo-type");
|
||||
await typeSelect.selectOption("COL");
|
||||
|
||||
// Add a COL route. Expect pick-mode to open with `reachableIds`
|
||||
// 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.
|
||||
await typeSelect.selectOption("CAP");
|
||||
await page
|
||||
.getByTestId("inspector-planet-cargo-slot-cap-add")
|
||||
.first()
|
||||
@@ -495,12 +495,13 @@ test("cargo-routes flow: pick a destination, arrow appears, reload restores", as
|
||||
.toBe(6);
|
||||
|
||||
// Remove the COL route.
|
||||
await typeSelect.selectOption("COL");
|
||||
await page
|
||||
.getByTestId("inspector-planet-cargo-slot-col-remove")
|
||||
.first()
|
||||
.click();
|
||||
await expect(
|
||||
page.getByTestId("inspector-planet-cargo-slot-col-empty").first(),
|
||||
page.getByTestId("inspector-planet-cargo-slot-col-add").first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect
|
||||
.poll(() => handle.lastRouteRemove, { timeout: 10000 })
|
||||
|
||||
@@ -52,7 +52,7 @@ test("shell mounts with header / sidebar / active-view chrome", async ({
|
||||
"turn",
|
||||
);
|
||||
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 }) => {
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
// flag mirroring the renderer's hide set. The spec counts the
|
||||
// visible-foreign-planet primitives, etc.
|
||||
// * `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
|
||||
// render-on-demand specs at the bottom of this file: an idle map
|
||||
// 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 }) => {
|
||||
await mockGateway(page, { currentTurn: 1 });
|
||||
await bootSession(page);
|
||||
|
||||
@@ -263,7 +263,7 @@ async function clickPlanetCentre(page: Page): Promise<void> {
|
||||
async function startRename(page: Page, newName: string): Promise<void> {
|
||||
await clickPlanetCentre(page);
|
||||
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");
|
||||
await input.fill(newName);
|
||||
await sidebar.getByTestId("inspector-planet-rename-confirm").click();
|
||||
|
||||
@@ -301,16 +301,22 @@ test("switching production three times collapses to one auto-synced row", async
|
||||
const sidebar = page.getByTestId("sidebar-tool-inspector");
|
||||
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth");
|
||||
|
||||
// Initial state: report.production = "Drive" → research segment is
|
||||
// active, sub-row reveals Drive as the highlighted tech.
|
||||
await expect(
|
||||
sidebar.getByTestId("inspector-planet-production-segment-research"),
|
||||
).toHaveClass(/active/);
|
||||
const mainSelect = sidebar.getByTestId("inspector-planet-production-main");
|
||||
const targetSelect = sidebar.getByTestId(
|
||||
"inspector-planet-production-target",
|
||||
);
|
||||
const applyBtn = sidebar.getByTestId("inspector-planet-production-apply");
|
||||
|
||||
// Click 1: Industry → CAP
|
||||
await sidebar
|
||||
.getByTestId("inspector-planet-production-segment-industry")
|
||||
.click();
|
||||
// Initial state: report.production = "Drive" → main is "research"
|
||||
// and the target is "DRIVE"; both apply/cancel start inert.
|
||||
await expect(mainSelect).toHaveValue("research");
|
||||
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();
|
||||
const orderTool = page.getByTestId("sidebar-tool-order");
|
||||
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",
|
||||
);
|
||||
|
||||
// Click 2: Materials → MAT (replaces CAP via collapse)
|
||||
// Pick 2: Materials + ✓ → MAT (replaces CAP via collapse)
|
||||
await page.getByTestId("sidebar-tab-inspector").click();
|
||||
await sidebar
|
||||
.getByTestId("inspector-planet-production-segment-materials")
|
||||
.click();
|
||||
await mainSelect.selectOption("materials");
|
||||
await applyBtn.click();
|
||||
await page.getByTestId("sidebar-tab-order").click();
|
||||
await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount(
|
||||
1,
|
||||
@@ -336,14 +341,11 @@ test("switching production three times collapses to one auto-synced row", async
|
||||
"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 sidebar
|
||||
.getByTestId("inspector-planet-production-segment-ship")
|
||||
.click();
|
||||
await sidebar
|
||||
.getByTestId(`inspector-planet-production-ship-${SHIP_CLASS}`)
|
||||
.click();
|
||||
await mainSelect.selectOption("ship");
|
||||
await targetSelect.selectOption(SHIP_CLASS);
|
||||
await applyBtn.click();
|
||||
await page.getByTestId("sidebar-tab-order").click();
|
||||
await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount(
|
||||
1,
|
||||
|
||||
@@ -245,7 +245,7 @@ test("rename a seeded planet auto-syncs and the overlay survives reload", async
|
||||
const sidebar = page.getByTestId("sidebar-tool-inspector");
|
||||
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");
|
||||
await input.fill("New-Earth");
|
||||
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);
|
||||
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-confirm").click();
|
||||
|
||||
|
||||
@@ -419,23 +419,28 @@ test("planet production picker exposes user sciences in the Research sub-row", a
|
||||
const sidebar = page.getByTestId("sidebar-tool-inspector");
|
||||
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth");
|
||||
|
||||
// Expand the Research segment.
|
||||
await sidebar
|
||||
.getByTestId("inspector-planet-production-segment-research")
|
||||
.click();
|
||||
const mainSelect = sidebar.getByTestId("inspector-planet-production-main");
|
||||
await mainSelect.selectOption("research");
|
||||
|
||||
// Tech buttons + the user's science button are both rendered.
|
||||
await expect(
|
||||
sidebar.getByTestId("inspector-planet-production-research-drive"),
|
||||
).toBeVisible();
|
||||
const scienceButton = sidebar.getByTestId(
|
||||
"inspector-planet-production-science-FirstStep",
|
||||
// Tech options and the user's science option are both rendered.
|
||||
const targetSelect = sidebar.getByTestId(
|
||||
"inspector-planet-production-target",
|
||||
);
|
||||
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")
|
||||
// lands in the draft and auto-syncs.
|
||||
await scienceButton.click();
|
||||
// Select the science target + ✓ → setProductionType("SCIENCE",
|
||||
// "FirstStep") lands in the draft and auto-syncs.
|
||||
await targetSelect.selectOption("FirstStep");
|
||||
await sidebar.getByTestId("inspector-planet-production-apply").click();
|
||||
await expect.poll(() => handle.lastProduce?.subject).toBe("FirstStep");
|
||||
expect(handle.lastProduce?.planetNumber).toBe(1);
|
||||
});
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
// the identity strip (`<race> @ <game>`, falling back to `?` while
|
||||
// the lobby / report calls are in flight), the Phase 26 turn
|
||||
// navigator (`← turn N →` with a popover of every turn), the
|
||||
// view-menu, and the account-menu. The tests assert the visible
|
||||
// copy, that every view-menu entry switches the active in-game view
|
||||
// via `activeView.select(...)` (the single-URL app-shell has no
|
||||
// per-view routes), and that the Logout entry of the account-menu
|
||||
// calls `session.signOut("user")`.
|
||||
// view-menu, and the in-game ephemeral light/dark theme toggle (F8-05
|
||||
// replaced the previous account-menu — language picker and logout
|
||||
// now live in the lobby). The tests assert the visible copy, that
|
||||
// every view-menu entry switches the active in-game view via
|
||||
// `activeView.select(...)`, and that the theme toggle flips the
|
||||
// in-memory `theme.override` channel.
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { fireEvent, render } from "@testing-library/svelte";
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
} from "vitest";
|
||||
|
||||
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 {
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
@@ -74,10 +75,11 @@ beforeEach(() => {
|
||||
i18n.resetForTests("en");
|
||||
activeViewSelectSpy.mockReset();
|
||||
appScreenGoSpy.mockReset();
|
||||
vi.spyOn(session, "signOut").mockResolvedValue(undefined);
|
||||
theme.clearOverride();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
theme.clearOverride();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -95,7 +97,7 @@ describe("game-shell header", () => {
|
||||
"turn ?",
|
||||
);
|
||||
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", () => {
|
||||
@@ -194,22 +196,18 @@ describe("game-shell header", () => {
|
||||
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, {
|
||||
props: { sidebarOpen: false, onToggleSidebar: () => {} },
|
||||
});
|
||||
await fireEvent.click(ui.getByTestId("account-menu-trigger"));
|
||||
await fireEvent.click(ui.getByTestId("account-menu-logout"));
|
||||
expect(session.signOut).toHaveBeenCalledWith("user");
|
||||
});
|
||||
|
||||
test("account-menu language picker switches the i18n locale", async () => {
|
||||
const ui = render(Header, {
|
||||
props: { sidebarOpen: false, onToggleSidebar: () => {} },
|
||||
});
|
||||
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");
|
||||
const toggle = ui.getByTestId("game-mode-theme-toggle");
|
||||
const initialResolved = theme.resolved;
|
||||
const opposite = initialResolved === "light" ? "dark" : "light";
|
||||
await fireEvent.click(toggle);
|
||||
expect(theme.override).toBe(opposite);
|
||||
expect(theme.resolved).toBe(opposite);
|
||||
await fireEvent.click(toggle);
|
||||
expect(theme.override).toBe(initialResolved);
|
||||
expect(theme.resolved).toBe(initialResolved);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// Vitest component coverage for the Phase 16 cargo-routes
|
||||
// subsection of the planet inspector. Drives the component 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. The tests
|
||||
// assert the four-slot rendering, the picker invocation, the
|
||||
// per-(source, loadType) collapse rule, and the cancel path.
|
||||
// Vitest component coverage for the F8-05 cargo-routes subsection of
|
||||
// the planet inspector. Pre-F8-05 the surface rendered all four
|
||||
// COL/CAP/MAT/EMP slots side-by-side; F8-05 collapsed it into a
|
||||
// single `<select>` with a placeholder (absorbing the old section
|
||||
// title) and contextual `add` / `edit` + `remove` buttons that only
|
||||
// appear once the player picks a type. The tests drive the component
|
||||
// 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 "fake-indexeddb/auto";
|
||||
@@ -129,39 +131,48 @@ function mount(
|
||||
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", () => {
|
||||
test("renders four slots in COL/CAP/MAT/EMP order", () => {
|
||||
const { ui, pick } = mount(
|
||||
test("dropdown exposes COL/CAP/MAT/EMP plus the placeholder; nothing else is rendered until a type is picked", () => {
|
||||
const { ui } = mount(
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
[makePlanet({ number: 1, name: "Earth", x: 100, y: 100 })],
|
||||
);
|
||||
const slots = ui.container.querySelectorAll(
|
||||
"[data-testid^='inspector-planet-cargo-slot-']",
|
||||
);
|
||||
const slotIds = Array.from(slots).map((el) =>
|
||||
el.getAttribute("data-testid"),
|
||||
);
|
||||
// Each slot generates several test ids (label + body items);
|
||||
// pick the row data-testid (slot itself, no suffix).
|
||||
const rowIds = slotIds.filter((id) =>
|
||||
/^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",
|
||||
const select = ui.getByTestId(
|
||||
"inspector-planet-cargo-type",
|
||||
) as HTMLSelectElement;
|
||||
expect(Array.from(select.options).map((o) => o.value)).toEqual([
|
||||
"",
|
||||
"COL",
|
||||
"CAP",
|
||||
"MAT",
|
||||
"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", () => {
|
||||
const { ui, pick } = mount(
|
||||
test("selecting an empty type reveals the Add button", async () => {
|
||||
const { ui } = mount(
|
||||
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
|
||||
[makePlanet({ number: 1, name: "Earth", x: 100, y: 100 })],
|
||||
);
|
||||
expect(
|
||||
ui.getByTestId("inspector-planet-cargo-slot-col-empty"),
|
||||
).toBeInTheDocument();
|
||||
await selectType(ui, "COL");
|
||||
expect(
|
||||
ui.getByTestId("inspector-planet-cargo-slot-col-add"),
|
||||
).toBeInTheDocument();
|
||||
@@ -170,8 +181,8 @@ describe("planet inspector — cargo routes", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("a filled slot shows the destination name plus Edit and Remove", () => {
|
||||
const { ui, pick } = mount(
|
||||
test("selecting a filled type shows the destination plus Edit and Remove", async () => {
|
||||
const { ui } = mount(
|
||||
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(
|
||||
ui.getByTestId("inspector-planet-cargo-slot-col-destination"),
|
||||
).toHaveTextContent("Mars");
|
||||
@@ -211,7 +223,10 @@ describe("planet inspector — cargo routes", () => {
|
||||
[],
|
||||
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));
|
||||
const invocation = pick.invocations[0]!;
|
||||
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 () => {
|
||||
// Reach = 40 * 1.5 = 60. Each candidate at distance 50 — in
|
||||
// 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).
|
||||
// Reach = 40 * 1.5 = 60. Each candidate at distance 50 — in reach.
|
||||
const { ui, pick } = mount(
|
||||
makePlanet({
|
||||
number: 1,
|
||||
@@ -269,7 +280,10 @@ describe("planet inspector — cargo routes", () => {
|
||||
[],
|
||||
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));
|
||||
expect(
|
||||
Array.from(pick.invocations[0]!.request.reachableIds).sort(),
|
||||
@@ -304,7 +318,10 @@ describe("planet inspector — cargo routes", () => {
|
||||
[],
|
||||
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));
|
||||
pick.invocations[0]!.resolve(9);
|
||||
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||
@@ -325,7 +342,10 @@ describe("planet inspector — cargo routes", () => {
|
||||
[],
|
||||
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));
|
||||
pick.invocations[0]!.resolve(2);
|
||||
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 () => {
|
||||
const { ui, pick } = mount(
|
||||
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 }),
|
||||
],
|
||||
);
|
||||
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));
|
||||
pick.invocations[0]!.resolve(null);
|
||||
await waitFor(() =>
|
||||
@@ -361,8 +407,8 @@ describe("planet inspector — cargo routes", () => {
|
||||
expect(draft.commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("Remove emits removeCargoRoute for the slot", async () => {
|
||||
const { ui, pick } = mount(
|
||||
test("Remove emits removeCargoRoute for the selected type", async () => {
|
||||
const { ui } = mount(
|
||||
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(
|
||||
ui.getByTestId("inspector-planet-cargo-slot-emp-remove"),
|
||||
);
|
||||
@@ -401,13 +448,14 @@ describe("planet inspector — cargo routes", () => {
|
||||
},
|
||||
],
|
||||
);
|
||||
await selectType(ui, "COL");
|
||||
await fireEvent.click(
|
||||
ui.getByTestId("inspector-planet-cargo-slot-col-edit"),
|
||||
);
|
||||
await waitFor(() => expect(pick.invocations.length).toBe(1));
|
||||
pick.invocations[0]!.resolve(3);
|
||||
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.
|
||||
await fireEvent.click(
|
||||
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 }),
|
||||
],
|
||||
);
|
||||
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));
|
||||
pick.invocations[0]!.resolve(2);
|
||||
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));
|
||||
pick.invocations[1]!.resolve(2);
|
||||
await waitFor(() => expect(draft.commands).toHaveLength(2));
|
||||
@@ -444,8 +498,8 @@ describe("planet inspector — cargo routes", () => {
|
||||
expect(types).toEqual(["CAP", "COL"]);
|
||||
});
|
||||
|
||||
test("no_destinations message appears when reach is positive but every planet is out of range", () => {
|
||||
const { ui, pick } = mount(
|
||||
test("no_destinations message appears once a type is picked and every planet is out of range", async () => {
|
||||
const { ui } = mount(
|
||||
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
|
||||
);
|
||||
// Hidden until the player engages with the dropdown.
|
||||
expect(
|
||||
ui.queryByTestId("inspector-planet-cargo-no-destinations"),
|
||||
).toBeNull();
|
||||
await selectType(ui, "COL");
|
||||
expect(
|
||||
ui.getByTestId("inspector-planet-cargo-no-destinations"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
// Vitest component coverage for the Phase 15 production-controls
|
||||
// subsection of the planet inspector. Drives the component against a
|
||||
// real `OrderDraftStore` (with `fake-indexeddb` standing in for the
|
||||
// browser's IDB factory) so the collapse-by-`planetNumber` rule and
|
||||
// the per-row status side-effects are exercised end-to-end.
|
||||
// Vitest component coverage for the F8-05 production-controls
|
||||
// subsection of the planet inspector. The pre-F8-05 surface was four
|
||||
// segmented main buttons (auto-submitting on click) plus a contextual
|
||||
// sub-row; F8-05 replaced it with two `<select>`s (main / target) and
|
||||
// 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-
|
||||
// query assertions: the parser is small enough that a table-driven
|
||||
// pass over the canonical engine display strings catches every
|
||||
// branch.
|
||||
// The tests drive the component against a real `OrderDraftStore`
|
||||
// (with `fake-indexeddb` standing in for the browser's IDB factory)
|
||||
// so the collapse-by-`planetNumber` rule remains exercised. The
|
||||
// active-target derivation is covered by a table-driven pass over the
|
||||
// canonical engine display strings.
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
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", () => {
|
||||
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 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(
|
||||
ui.getByTestId("inspector-planet-production-segment-industry"),
|
||||
).toHaveTextContent("industry");
|
||||
ui.queryByTestId("inspector-planet-production-target"),
|
||||
).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(
|
||||
ui.getByTestId("inspector-planet-production-segment-materials"),
|
||||
).toHaveTextContent("materials");
|
||||
ui.getByTestId("inspector-planet-production-apply"),
|
||||
).not.toBeDisabled();
|
||||
expect(
|
||||
ui.getByTestId("inspector-planet-production-segment-research"),
|
||||
).toHaveTextContent("research");
|
||||
expect(
|
||||
ui.getByTestId("inspector-planet-production-segment-ship"),
|
||||
).toHaveTextContent("build ship");
|
||||
ui.getByTestId("inspector-planet-production-cancel"),
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test("Industry click emits a CAP setProductionType command", async () => {
|
||||
test("Industry default + ✓ emits a CAP setProductionType command", async () => {
|
||||
const ui = mountProduction(localPlanet({ number: 7 }));
|
||||
await fireEvent.click(
|
||||
ui.getByTestId("inspector-planet-production-segment-industry"),
|
||||
);
|
||||
expect(getMain(ui).value).toBe("industry");
|
||||
const apply = ui.getByTestId("inspector-planet-production-apply");
|
||||
expect(apply).not.toBeDisabled();
|
||||
await fireEvent.click(apply);
|
||||
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||
const cmd = draft.commands[0]!;
|
||||
expect(cmd.kind).toBe("setProductionType");
|
||||
@@ -137,32 +168,29 @@ describe("planet inspector — production controls", () => {
|
||||
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 }));
|
||||
await fireEvent.click(
|
||||
ui.getByTestId("inspector-planet-production-segment-materials"),
|
||||
);
|
||||
await fireEvent.change(getMain(ui), { target: { value: "materials" } });
|
||||
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("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 }));
|
||||
expect(
|
||||
ui.queryByTestId("inspector-planet-production-research-row"),
|
||||
ui.queryByTestId("inspector-planet-production-target"),
|
||||
).toBeNull();
|
||||
await fireEvent.click(
|
||||
ui.getByTestId("inspector-planet-production-segment-research"),
|
||||
);
|
||||
await fireEvent.change(getMain(ui), { target: { value: "research" } });
|
||||
const target = getTarget(ui);
|
||||
expect(target.value).toBe("");
|
||||
expect(
|
||||
ui.getByTestId("inspector-planet-production-research-row"),
|
||||
).toBeInTheDocument();
|
||||
expect(draft.commands).toHaveLength(0);
|
||||
await fireEvent.click(
|
||||
ui.getByTestId("inspector-planet-production-research-drive"),
|
||||
);
|
||||
ui.getByTestId("inspector-planet-production-apply"),
|
||||
).toBeDisabled();
|
||||
await fireEvent.change(target, { target: { value: "DRIVE" } });
|
||||
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");
|
||||
@@ -170,27 +198,39 @@ describe("planet inspector — production controls", () => {
|
||||
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 }), []);
|
||||
await fireEvent.click(
|
||||
ui.getByTestId("inspector-planet-production-segment-ship"),
|
||||
);
|
||||
await fireEvent.change(getMain(ui), { target: { value: "ship" } });
|
||||
expect(
|
||||
ui.getByTestId("inspector-planet-production-ship-empty"),
|
||||
).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 }), [
|
||||
shipClass({ name: "Scout" }),
|
||||
shipClass({ name: "Destroyer" }),
|
||||
]);
|
||||
await fireEvent.click(
|
||||
ui.getByTestId("inspector-planet-production-segment-ship"),
|
||||
);
|
||||
await fireEvent.click(
|
||||
ui.getByTestId("inspector-planet-production-ship-Scout"),
|
||||
);
|
||||
await fireEvent.change(getMain(ui), { target: { value: "ship" } });
|
||||
await fireEvent.change(getTarget(ui), { target: { value: "Scout" } });
|
||||
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");
|
||||
@@ -198,32 +238,41 @@ describe("planet inspector — production controls", () => {
|
||||
expect(cmd.subject).toBe("Scout");
|
||||
});
|
||||
|
||||
test("re-clicks on the same planet collapse to the latest entry via the store", async () => {
|
||||
const ui = mountProduction(localPlanet({ number: 7 }), [
|
||||
shipClass({ name: "Scout" }),
|
||||
]);
|
||||
await fireEvent.click(
|
||||
ui.getByTestId("inspector-planet-production-segment-industry"),
|
||||
test("Cancel resets the row to the current effective production without emitting", async () => {
|
||||
const ui = mountProduction(
|
||||
localPlanet({ number: 7, production: "Capital" }),
|
||||
);
|
||||
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(
|
||||
ui.getByTestId("inspector-planet-production-segment-materials"),
|
||||
ui.getByTestId("inspector-planet-production-cancel"),
|
||||
);
|
||||
await fireEvent.click(
|
||||
ui.getByTestId("inspector-planet-production-segment-research"),
|
||||
);
|
||||
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");
|
||||
expect(getMain(ui).value).toBe("industry");
|
||||
expect(draft.commands).toEqual([]);
|
||||
});
|
||||
|
||||
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<{
|
||||
production: string | null;
|
||||
expected: "industry" | "materials" | "research" | "ship" | "none";
|
||||
expected: "industry" | "materials" | "research" | "ship";
|
||||
}> = [
|
||||
{ production: "Capital", expected: "industry" },
|
||||
{ production: "Material", expected: "materials" },
|
||||
@@ -232,67 +281,48 @@ describe("planet inspector — production controls", () => {
|
||||
{ production: "Shields", expected: "research" },
|
||||
{ production: "Cargo", expected: "research" },
|
||||
{ production: "Scout", expected: "ship" },
|
||||
{ production: "-", expected: "none" },
|
||||
{ production: null, expected: "none" },
|
||||
{ production: "UnknownThing", expected: "none" },
|
||||
// Falls back to the documented `industry` default when the
|
||||
// engine display string is missing or unrecognised.
|
||||
{ production: "-", expected: "industry" },
|
||||
{ production: null, expected: "industry" },
|
||||
{ production: "UnknownThing", expected: "industry" },
|
||||
];
|
||||
for (const tc of cases) {
|
||||
const ui = mountProduction(
|
||||
localPlanet({ number: 1, production: tc.production }),
|
||||
[shipClass({ name: "Scout" })],
|
||||
);
|
||||
const ids: ReadonlyArray<
|
||||
"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);
|
||||
}
|
||||
}
|
||||
expect(getMain(ui).value).toBe(tc.expected);
|
||||
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<{
|
||||
production: string;
|
||||
slug: "drive" | "weapons" | "shields" | "cargo";
|
||||
expected: "DRIVE" | "WEAPONS" | "SHIELDS" | "CARGO";
|
||||
}> = [
|
||||
{ production: "Drive", slug: "drive" },
|
||||
{ production: "Weapons", slug: "weapons" },
|
||||
{ production: "Shields", slug: "shields" },
|
||||
{ production: "Cargo", slug: "cargo" },
|
||||
{ production: "Drive", expected: "DRIVE" },
|
||||
{ production: "Weapons", expected: "WEAPONS" },
|
||||
{ production: "Shields", expected: "SHIELDS" },
|
||||
{ production: "Cargo", expected: "CARGO" },
|
||||
];
|
||||
for (const tc of cases) {
|
||||
const ui = mountProduction(
|
||||
localPlanet({ number: 1, production: tc.production }),
|
||||
);
|
||||
const el = ui.getByTestId(
|
||||
`inspector-planet-production-research-${tc.slug}`,
|
||||
);
|
||||
expect(el.classList.contains("active")).toBe(true);
|
||||
expect(getMain(ui).value).toBe("research");
|
||||
expect(getTarget(ui).value).toBe(tc.expected);
|
||||
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(
|
||||
localPlanet({ number: 1, production: "Scout" }),
|
||||
[shipClass({ name: "Scout" }), shipClass({ name: "Destroyer" })],
|
||||
);
|
||||
expect(
|
||||
ui.getByTestId("inspector-planet-production-ship-Scout").classList
|
||||
.contains("active"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
ui
|
||||
.getByTestId("inspector-planet-production-ship-Destroyer")
|
||||
.classList.contains("active"),
|
||||
).toBe(false);
|
||||
expect(getMain(ui).value).toBe("ship");
|
||||
expect(getTarget(ui).value).toBe("Scout");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
// Vitest coverage for the Phase 19 follow-up "stationed ship groups"
|
||||
// subsection of the planet inspector. Phase 19 originally rendered
|
||||
// every in-orbit group as a small offset point on the map; the
|
||||
// resulting visual noise pushed the listing into this subsection
|
||||
// (`lib/inspectors/planet/ship-groups.svelte`) instead.
|
||||
// Vitest coverage for the "stationed ship groups" subsection of the
|
||||
// planet inspector. The map deliberately hides on-planet groups; this
|
||||
// subsection is the player's view of the fleets in orbit.
|
||||
//
|
||||
// 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 { render } from "@testing-library/svelte";
|
||||
import { fireEvent, render } from "@testing-library/svelte";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
@@ -63,6 +69,7 @@ function localGroup(
|
||||
mass: 12,
|
||||
state: "In_Orbit",
|
||||
fleet: null,
|
||||
race: "Earthlings",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -81,12 +88,13 @@ function otherGroup(
|
||||
range: null,
|
||||
speed: 0,
|
||||
mass: 25,
|
||||
race: "Klingons",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
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, {
|
||||
props: {
|
||||
planet: HOME_PLANET,
|
||||
@@ -100,7 +108,13 @@ describe("planet inspector — stationed ship groups", () => {
|
||||
});
|
||||
const rows = ui.getAllByTestId("inspector-planet-ship-groups-row");
|
||||
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("2");
|
||||
expect(rows[0]).toHaveTextContent("24");
|
||||
@@ -108,6 +122,66 @@ describe("planet inspector — stationed ship groups", () => {
|
||||
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", () => {
|
||||
const ui = render(ShipGroups, {
|
||||
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", () => {
|
||||
const ui = render(ShipGroups, {
|
||||
props: {
|
||||
|
||||
@@ -245,7 +245,7 @@ describe("planet inspector", () => {
|
||||
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, {
|
||||
props: {
|
||||
planet: makePlanet({
|
||||
@@ -268,10 +268,13 @@ describe("planet inspector", () => {
|
||||
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 db = await openGalaxyDB(dbName);
|
||||
const cache = new IDBCache(db);
|
||||
@@ -311,8 +314,9 @@ describe("planet inspector", () => {
|
||||
context,
|
||||
});
|
||||
|
||||
const action = ui.getByTestId("inspector-planet-rename-action");
|
||||
await fireEvent.click(action);
|
||||
const name = ui.getByTestId("inspector-planet-name");
|
||||
expect(name.tagName).toBe("BUTTON");
|
||||
await fireEvent.click(name);
|
||||
|
||||
const input = ui.getByTestId("inspector-planet-rename-input") as HTMLInputElement;
|
||||
expect(input.value).toBe("Earth");
|
||||
@@ -344,7 +348,7 @@ describe("planet inspector", () => {
|
||||
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 db = await openGalaxyDB(dbName);
|
||||
const cache = new IDBCache(db);
|
||||
@@ -382,8 +386,9 @@ describe("planet inspector", () => {
|
||||
},
|
||||
context,
|
||||
});
|
||||
await fireEvent.click(ui.getByTestId("inspector-planet-rename-action"));
|
||||
await fireEvent.click(ui.getByTestId("inspector-planet-rename-cancel"));
|
||||
await fireEvent.click(ui.getByTestId("inspector-planet-name"));
|
||||
const input = ui.getByTestId("inspector-planet-rename-input");
|
||||
await fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(ui.queryByTestId("inspector-planet-rename")).toBeNull();
|
||||
expect(draft.commands).toEqual([]);
|
||||
draft.dispose();
|
||||
|
||||
@@ -108,6 +108,7 @@ function localGroup(
|
||||
mass: 12,
|
||||
state: "In_Orbit",
|
||||
fleet: null,
|
||||
race: "Earthlings",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ function group(
|
||||
mass: 12,
|
||||
state: "In_Orbit",
|
||||
fleet: null,
|
||||
race: "Earthlings",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ function group(
|
||||
mass: 25,
|
||||
state: "In_Orbit",
|
||||
fleet: null,
|
||||
race: "Earthlings",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ function localGroup(
|
||||
mass: 12,
|
||||
state: "In_Orbit",
|
||||
fleet: null,
|
||||
race: "Earthlings",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -158,6 +159,7 @@ describe("ship-group inspector", () => {
|
||||
range: null,
|
||||
speed: 0,
|
||||
mass: 50,
|
||||
race: "Klingons",
|
||||
};
|
||||
const selection: ShipGroupSelection = { variant: "other", group };
|
||||
const ui = render(ShipGroup, { props: { selection, planets: PLANETS } });
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// Phase 29 component coverage for `lib/active-view/map-toggles.svelte`.
|
||||
// The popover is a thin view of the `GameStateStore` runes —
|
||||
// every control fires `setMapToggle` / `setWrapMode` on the store
|
||||
// and reads the current state through `store.mapToggles` /
|
||||
// `store.wrapMode`. The tests assert the wiring, the default
|
||||
// rendering, and the popover lifecycle (open / Escape close).
|
||||
// every checkbox fires `setMapToggle` on the store and reads the
|
||||
// current state through `store.mapToggles`. F8-05 (issue #48 п.8)
|
||||
// dropped the wrap-scrolling radio group from the UI; the
|
||||
// `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 { fireEvent, render } from "@testing-library/svelte";
|
||||
@@ -19,7 +21,6 @@ import {
|
||||
function buildStore(): GameStateStore {
|
||||
const store = new GameStateStore();
|
||||
store.status = "ready";
|
||||
store.wrapMode = "torus";
|
||||
store.mapToggles = { ...DEFAULT_MAP_TOGGLES };
|
||||
return store;
|
||||
}
|
||||
@@ -59,8 +60,8 @@ describe("MapTogglesControl", () => {
|
||||
expect(ui.getByTestId("map-toggles-unidentified-planets")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-unreachable-planets")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-visible-hyperspace")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-wrap-torus")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-wrap-no-wrap")).not.toBeChecked();
|
||||
expect(ui.queryByTestId("map-toggles-wrap-torus")).toBeNull();
|
||||
expect(ui.queryByTestId("map-toggles-wrap-no-wrap")).toBeNull();
|
||||
});
|
||||
|
||||
test("flipping a checkbox calls setMapToggle with the new value", async () => {
|
||||
@@ -90,17 +91,6 @@ describe("MapTogglesControl", () => {
|
||||
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 () => {
|
||||
const store = buildStore();
|
||||
const ui = render(MapTogglesControl, { props: { store } });
|
||||
|
||||
@@ -45,6 +45,7 @@ function localGroup(overrides: Partial<ReportLocalShipGroup> & Pick<ReportLocalS
|
||||
mass: 1,
|
||||
state: "In_Orbit",
|
||||
fleet: null,
|
||||
race: "Earthlings",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ function makeLocalShipGroup(
|
||||
mass: 0,
|
||||
state: "InOrbit",
|
||||
fleet: null,
|
||||
race: "Earthlings",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -97,6 +98,7 @@ function makeOtherShipGroup(
|
||||
range: null,
|
||||
speed: 1,
|
||||
mass: 0,
|
||||
race: "Klingons",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ describe("reportToWorld — ship groups", () => {
|
||||
mass: 12,
|
||||
state: "In_Orbit",
|
||||
fleet: null,
|
||||
race: "Earthlings",
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -112,6 +113,7 @@ describe("reportToWorld — ship groups", () => {
|
||||
mass: 50,
|
||||
state: "In_Space",
|
||||
fleet: null,
|
||||
race: "Earthlings",
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -237,6 +239,7 @@ describe("reportToWorld — ship groups", () => {
|
||||
origin: null,
|
||||
range: null,
|
||||
speed: 0,
|
||||
race: "Earthlings",
|
||||
mass: 1,
|
||||
state: "In_Orbit",
|
||||
fleet: null,
|
||||
|
||||
@@ -86,4 +86,45 @@ describe("theme store", () => {
|
||||
const { theme } = await freshStore();
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user