ui/phase-20: ship-group inspector actions

Eight ship-group operations land on the inspector behind a single
inline-form panel: split, send, load, unload, modernize, dismantle,
transfer, join fleet. Each action either appends a typed command to
the local order draft or surfaces a tooltip explaining the
disabled state. Partial-ship operations emit an implicit
breakShipGroup command before the targeted action so the engine
sees a clean (Break, Action) pair on the wire.

`pkg/calc.BlockUpgradeCost` migrates from
`game/internal/controller/ship_group_upgrade.go` so the calc
bridge can wrap a pure pkg/calc formula; the controller now
imports it. The bridge surfaces the function as
`core.blockUpgradeCost`, which the inspector calls once per ship
block to render the modernize cost preview.

`GameReport.otherRaces` is decoded from the report's player block
(non-extinct, ≠ self) and feeds the transfer-to-race picker. The
planet inspector's stationed-ship rows become clickable for own
groups so the actions panel is reachable from the standard click
flow (the renderer continues to hide on-planet groups).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-10 16:27:55 +02:00
parent f7109af55c
commit 3626998a33
36 changed files with 4033 additions and 89 deletions
+6 -12
View File
@@ -5,6 +5,7 @@ import (
"slices" "slices"
"strings" "strings"
"galaxy/calc"
e "galaxy/error" e "galaxy/error"
"galaxy/game/internal/model/game" "galaxy/game/internal/model/game"
@@ -156,26 +157,19 @@ func (uc UpgradeCalc) UpgradeMaxShips(resources float64) uint {
return uint(math.Floor(resources / uc.UpgradeCost(1))) return uint(math.Floor(resources / uc.UpgradeCost(1)))
} }
func BlockUpgradeCost(blockMass, currentBlockTech, targetBlockTech float64) float64 {
if blockMass == 0 || targetBlockTech <= currentBlockTech {
return 0
}
return (1 - currentBlockTech/targetBlockTech) * 10 * blockMass
}
func GroupUpgradeCost(sg *game.ShipGroup, st game.ShipType, drive, weapons, shields, cargo float64) UpgradeCalc { func GroupUpgradeCost(sg *game.ShipGroup, st game.ShipType, drive, weapons, shields, cargo float64) UpgradeCalc {
uc := &UpgradeCalc{Cost: make(map[game.Tech]float64)} uc := &UpgradeCalc{Cost: make(map[game.Tech]float64)}
if drive > 0 { if drive > 0 {
uc.Cost[game.TechDrive] = BlockUpgradeCost(st.DriveBlockMass(), sg.TechLevel(game.TechDrive).F(), drive) uc.Cost[game.TechDrive] = calc.BlockUpgradeCost(st.DriveBlockMass(), sg.TechLevel(game.TechDrive).F(), drive)
} }
if weapons > 0 { if weapons > 0 {
uc.Cost[game.TechWeapons] = BlockUpgradeCost(st.WeaponsBlockMass(), sg.TechLevel(game.TechWeapons).F(), weapons) uc.Cost[game.TechWeapons] = calc.BlockUpgradeCost(st.WeaponsBlockMass(), sg.TechLevel(game.TechWeapons).F(), weapons)
} }
if shields > 0 { if shields > 0 {
uc.Cost[game.TechShields] = BlockUpgradeCost(st.ShieldsBlockMass(), sg.TechLevel(game.TechShields).F(), shields) uc.Cost[game.TechShields] = calc.BlockUpgradeCost(st.ShieldsBlockMass(), sg.TechLevel(game.TechShields).F(), shields)
} }
if cargo > 0 { if cargo > 0 {
uc.Cost[game.TechCargo] = BlockUpgradeCost(st.CargoBlockMass(), sg.TechLevel(game.TechCargo).F(), cargo) uc.Cost[game.TechCargo] = calc.BlockUpgradeCost(st.CargoBlockMass(), sg.TechLevel(game.TechCargo).F(), cargo)
} }
return *uc return *uc
} }
@@ -218,7 +212,7 @@ func UpgradeGroupPreference(sg game.ShipGroup, st game.ShipType, tech game.Tech,
ti = len(su.UpgradeTech) - 1 ti = len(su.UpgradeTech) - 1
} }
su.UpgradeTech[ti].Level = game.F(v) su.UpgradeTech[ti].Level = game.F(v)
su.UpgradeTech[ti].Cost = game.F(BlockUpgradeCost(st.BlockMass(tech), sg.TechLevel(tech).F(), v) * float64(sg.Number)) su.UpgradeTech[ti].Cost = game.F(calc.BlockUpgradeCost(st.BlockMass(tech), sg.TechLevel(tech).F(), v) * float64(sg.Number))
sg.StateUpgrade = &su sg.StateUpgrade = &su
return sg return sg
@@ -13,12 +13,6 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestBlockUpgradeCost(t *testing.T) {
assert.Equal(t, 00.0, controller.BlockUpgradeCost(1, 1.0, 1.0))
assert.Equal(t, 25.0, controller.BlockUpgradeCost(5, 1.0, 2.0))
assert.Equal(t, 50.0, controller.BlockUpgradeCost(10, 1.0, 2.0))
}
func TestGroupUpgradeCost(t *testing.T) { func TestGroupUpgradeCost(t *testing.T) {
sg := &g.ShipGroup{ sg := &g.ShipGroup{
Tech: map[g.Tech]g.Float{ Tech: map[g.Tech]g.Float{
+11
View File
@@ -52,6 +52,17 @@ func WeaponsBlockMass(weapons float64, armament uint) (float64, bool) {
return float64(armament+1) * (weapons / 2), true return float64(armament+1) * (weapons / 2), true
} }
// Стоимость модернизации одного блока корабля -
// доля недостающего технологического уровня (1 - currentBlockTech/targetBlockTech),
// умноженная на массу блока и нормирующий коэффициент 10.
// Возвращает 0, если масса блока равна нулю или целевой уровень не выше текущего.
func BlockUpgradeCost(blockMass, currentBlockTech, targetBlockTech float64) float64 {
if blockMass == 0 || targetBlockTech <= currentBlockTech {
return 0
}
return (1 - currentBlockTech/targetBlockTech) * 10 * blockMass
}
func DestructionProbability( func DestructionProbability(
attackingWeapons, attackingWeapons,
attackingWeaponsTech, attackingWeaponsTech,
+34
View File
@@ -0,0 +1,34 @@
package calc_test
import (
"math"
"testing"
"galaxy/calc"
)
func TestBlockUpgradeCost(t *testing.T) {
cases := []struct {
name string
blockMass float64
currentTech float64
targetTech float64
want float64
}{
{"zero block mass returns zero", 0, 1.0, 2.0, 0},
{"target equal to current returns zero", 5, 2.0, 2.0, 0},
{"target below current returns zero", 5, 2.0, 1.0, 0},
{"doubling tech on mass 5 costs 25", 5, 1.0, 2.0, 25},
{"doubling tech on mass 10 costs 50", 10, 1.0, 2.0, 50},
{"partial step from 2.0 to 2.5 on mass 5", 5, 2.0, 2.5, 10},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := calc.BlockUpgradeCost(tc.blockMass, tc.currentTech, tc.targetTech)
if math.Abs(got-tc.want) > 1e-9 {
t.Errorf("BlockUpgradeCost(%v, %v, %v) = %v, want %v",
tc.blockMass, tc.currentTech, tc.targetTech, got, tc.want)
}
})
}
}
+111 -19
View File
@@ -2135,27 +2135,63 @@ Targeted tests:
- Playwright e2e: click each variant from a seeded game, assert all - Playwright e2e: click each variant from a seeded game, assert all
expected fields render. expected fields render.
## Phase 20. Inspector — Ship Group Actions ## ~~Phase 20. Inspector — Ship Group Actions~~
Status: pending. Status: done.
Goal: enable group operations from the inspector: split, send, load, Goal: enable group operations from the inspector: split, send, load,
unload, modernize, dismantle, transfer to race, add to fleet. unload, modernize, dismantle, transfer to race, add to fleet.
Artifacts: Artifacts:
- action buttons in `ui/frontend/src/lib/inspectors/ship-group.svelte` - action panel `ui/frontend/src/lib/inspectors/ship-group/actions.svelte`
with disabled-state and tooltip when local validation rejects mounted by the read-only inspector for the local variant; eight
- `ui/frontend/src/sync/order-types.ts` extends with `SplitGroup`, inline forms (one per action) with disabled-button tooltips that
`SendGroup`, `LoadCargo`, `UnloadCargo`, `Modernize`, `Dismantle`, mirror the engine's pre-conditions
`TransferToRace`, `AssignToFleet` command variants (`controller/ship_group*.go`)
- `Send` action picks destination through a planet picker filtered by - `ui/frontend/src/sync/order-types.ts` extends with eight new
the group's reach (uses `pkg/calc/` reach function via Core; the command variants — `breakShipGroup`, `sendShipGroup`,
player's tech levels are already on `GameReport.localPlayer*` from `loadShipGroup`, `unloadShipGroup`, `upgradeShipGroup`,
Phase 18, no extra plumbing needed) `dismantleShipGroup`, `transferShipGroup`, `joinFleetShipGroup` —
- `Modernize` cost preview using `pkg/calc/` formula via Core plus `ShipGroupCargo` and `ShipGroupUpgradeTech` literal types
- confirmation dialog for `Dismantle` over a foreign planet with - `sync/submit.ts` and `sync/order-load.ts` round-trip every new
colonists onboard (special-case from [`rules.txt`](../game/rules.txt): colonists die) variant against the existing FBS classes in
`proto/galaxy/fbs/order/`; the `id` field on each ship-group
payload carries the *target* group UUID (the source group, or
the freshly-minted `newGroupId` when an implicit split precedes
the action)
- `Send` action picks destination through a planet picker filtered
by the group's reach (`localPlayerDrive * 40`, computed inline
via the existing `torusShortestDelta` from
`cargo-routes.svelte`); the player's tech levels are already on
`GameReport.localPlayer*` from Phase 18, no extra plumbing
needed
- `Modernize` cost preview through `core.blockUpgradeCost`
(Phase 20 bridge), summed over the four ship-class blocks for
the targeted ship count; preview hides when `Core` is not yet
booted or the form is invalid (see
`ui/docs/ship-group-actions.md` for the formula breakdown)
- two-step inline confirmation for `Dismantle` over a foreign
planet with colonists onboard (engine reference
`controller/ship_group.go:177-179` — `UnloadColonists` is not
called over a foreign planet, so the cargo is lost)
- `pkg/calc/ship.go.BlockUpgradeCost` (migrated from
`game/internal/controller/ship_group_upgrade.go`) — the bridge
rule says `ui/core/calc/` only wraps `pkg/calc/` formulas, so
the function moved upstream and the controller now imports it
- `GameReport.otherRaces: string[]` populated by the report
decoder from `report.player[]` (non-extinct, ≠ self) — used by
the transfer-to-race picker; Phase 22's Races View reuses the
same field
- planet inspector's stationed-ship rows
(`lib/inspectors/planet/ship-groups.svelte`) become clickable
for own groups, pivoting the `SelectionStore` to the matching
`shipGroup.local` ref so the actions panel is reachable from
the standard click flow (the map deliberately hides on-planet
groups, so this is the on-planet entry point)
- topic doc `ui/docs/ship-group-actions.md` covers the action
surface, disabled-state rules, implicit-split pattern, and the
modernize cost preview formula
Dependencies: Phases 18, 19. Dependencies: Phases 18, 19.
@@ -2171,10 +2207,61 @@ Acceptance criteria:
Targeted tests: Targeted tests:
- Vitest unit tests for action enablement logic per action; - `pkg/calc/ship_test.go.TestBlockUpgradeCost` — formula coverage
- Vitest component tests for the dismantle-with-colonists confirmation; on the migrated function;
- Playwright e2e for at least one complete flow (send a group between - `ui/core/calc/ship_test.go.TestBlockUpgradeCostParity` — bridge
two planets) against a local stack. parity against `pkg/calc/`;
- Vitest:
- `tests/inspector-ship-group-actions.test.ts` — disabled-state
rules per action and the implicit-split pattern;
- `tests/inspector-ship-group-dismantle-confirm.test.ts` —
two-step confirm over foreign-COL groups;
- `tests/inspector-ship-group-modernize-cost.test.ts` —
preview formula matches `BlockUpgradeCost` × ship count and
hides when `Core` is null;
- `tests/sync-order-types-ship-group.test.ts` —
`validateCommand` for each new variant;
- `tests/sync-submit-ship-group.test.ts` — encoder/decoder
round-trip per new variant;
- Playwright `tests/e2e/ship-group-send.spec.ts` — synthetic
report with a 3-ship group on Earth and a reachable Mars,
drives the planet inspector → ship-group inspector pivot, then
Send 2 of 3 with map-pick destination, asserts both Break and
Send land in the order draft via the order tab.
Decisions during stage:
1. **`BlockUpgradeCost` migration**. The pre-existing copy in
`game/internal/controller/ship_group_upgrade.go` moved to
`pkg/calc/ship.go`; the controller's `GroupUpgradeCost` and
`UpgradeGroupPreference` now call `calc.BlockUpgradeCost`.
The unit test moved from `controller/ship_group_upgrade_test.go`
to `pkg/calc/ship_test.go`.
2. **`GameReport.otherRaces`** field added to
`ui/frontend/src/api/game-state.ts`; the synthetic-report
decoder populates it the same way (`api/synthetic-report.ts`).
Phase 22's Races View can read this directly without a fresh
plumbing pass — the Phase 22 stage text below is updated to
reflect that.
3. **Stationed-ship rows are clickable**. The Phase 19 stationed-
ship subsection on the planet inspector becomes interactive
for own groups (Phase 21+ table view stays a separate target).
The map renderer continues to hide on-planet groups — this is
the cheaper navigational fix.
4. **Inline forms, no modal**. Every action opens an inline
editor under the buttons row, matching the Phase 14 rename and
Phase 16 cargo-route patterns. Send reuses
`MAP_PICK_CONTEXT_KEY` (Phase 16's renderer service) for the
destination picker. Foreign-COL Dismantle uses a two-step
inline confirm (button label flips to "confirm — colonists
die") rather than a separate modal component.
5. **Implicit split for Send/Load/Unload/Modernize/Dismantle/
Transfer**. The number-of-ships input defaults to the group's
full count; when the player picks a smaller M, the inspector
prepends `breakShipGroup(id, newId, M)` and routes the action
at `newId`. JoinFleet and Split do not get a counter (JoinFleet
is whole-group atomically per the engine; Split *is* the break
command).
## Phase 21. Sciences — CRUD List + Designer ## Phase 21. Sciences — CRUD List + Designer
@@ -2226,7 +2313,12 @@ Artifacts:
- `ui/frontend/src/routes/games/[id]/table/races/+page.svelte` table - `ui/frontend/src/routes/games/[id]/table/races/+page.svelte` table
with one row per race, including name, tech levels, total with one row per race, including name, tech levels, total
population, total production, planet count, war-or-peace from this population, total production, planet count, war-or-peace from this
race's perspective, votes received race's perspective, votes received. The race list itself is read
from `GameReport.otherRaces` (introduced in Phase 20 for the
ship-group transfer-to-race picker); the table view widens the
per-race shape (tech / population / production / planet count /
votes / relation) by walking `report.player[]` directly when those
fields are needed
- per-row toggle for declaring war or peace (adds - per-row toggle for declaring war or peace (adds
`SetDiplomaticStance` command) `SetDiplomaticStance` command)
- voting control: a single slot for `give my votes to <race>` (adds - voting control: a single slot for `give my votes to <race>` (adds
+10
View File
@@ -55,3 +55,13 @@ func CargoCapacity(cargo, cargoTech float64) float64 {
func CarryingMass(load, cargoTech float64) float64 { func CarryingMass(load, cargoTech float64) float64 {
return calc.CarryingMass(load, cargoTech) return calc.CarryingMass(load, cargoTech)
} }
// BlockUpgradeCost wraps `calc.BlockUpgradeCost` (`pkg/calc/ship.go`):
// production cost of upgrading a single ship block from currentBlockTech
// to targetBlockTech. Returns 0 when blockMass is zero or the target is
// not above the current level. Used by the ship-group inspector's
// modernize cost preview, with each of the four blocks (drive, weapons,
// shields, cargo) priced through a separate call.
func BlockUpgradeCost(blockMass, currentBlockTech, targetBlockTech float64) float64 {
return calc.BlockUpgradeCost(blockMass, currentBlockTech, targetBlockTech)
}
+25
View File
@@ -171,6 +171,31 @@ func TestCarryingMassParity(t *testing.T) {
} }
} }
func TestBlockUpgradeCostParity(t *testing.T) {
t.Parallel()
cases := []struct {
name string
blockMass float64
currentTech float64
targetTech float64
}{
{"zero_block_mass", 0, 1, 2},
{"target_equal_to_current", 5, 2, 2},
{"target_below_current", 5, 2, 1},
{"doubling_tech_on_mass_5", 5, 1, 2},
{"partial_step_2_to_2_5", 5, 2, 2.5},
{"high_tech_to_higher_tech", 12, 4, 6},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
want := source.BlockUpgradeCost(c.blockMass, c.currentTech, c.targetTech)
got := bridge.BlockUpgradeCost(c.blockMass, c.currentTech, c.targetTech)
assert.Equal(t, want, got)
})
}
}
// TestDesignerPreviewComposition exercises the exact composition the // TestDesignerPreviewComposition exercises the exact composition the
// ship-class designer performs: empty mass, full-load mass via // ship-class designer performs: empty mass, full-load mass via
// CarryingMass(CargoCapacity), max speed at empty, and range at full // CarryingMass(CargoCapacity), max speed at empty, and range at full
+24 -18
View File
@@ -9,30 +9,33 @@ Go → WASM → TypeScript bridge mounted under `ui/core/calc/` and a
matching TS adapter in `ui/frontend/src/platform/core/`. matching TS adapter in `ui/frontend/src/platform/core/`.
Phase 18 lands the **ship-math slice** of the bridge — everything Phase 18 lands the **ship-math slice** of the bridge — everything
the ship-class designer needs to render its preview pane. Other the ship-class designer needs to render its preview pane. Phase 20
slices (production forecast, science research, ship build progress) extends it with `BlockUpgradeCost` so the ship-group inspector can
remain deferred to dedicated future phases. This document is the preview modernize cost. Other slices (production forecast, science
running audit trail of what is live, what is missing, and how each research, ship build progress) remain deferred to dedicated future
function maps to its `pkg/calc/` source. phases. This document is the running audit trail of what is live,
what is missing, and how each function maps to its `pkg/calc/`
source.
## Live bridge surface (Phase 18) ## Live bridge surface
The Go module `galaxy/core/calc` (`ui/core/calc/ship.go`) exposes The Go module `galaxy/core/calc` (`ui/core/calc/ship.go`) exposes
seven thin wrappers around `pkg/calc/ship.go`. Each is a one-line thin wrappers around `pkg/calc/ship.go`. Each is a one-line
passthrough — the bridge contains zero math. The same seven names passthrough — the bridge contains zero math. The same names appear
appear on the JS-side `globalThis.galaxyCore` (registered in on the JS-side `globalThis.galaxyCore` (registered in
`ui/wasm/main.go`) and on the typed `Core` interface `ui/wasm/main.go`) and on the typed `Core` interface
(`ui/frontend/src/platform/core/index.ts`). (`ui/frontend/src/platform/core/index.ts`).
| Bridge function | `pkg/calc/` source | JS return shape | Used by | | Bridge function | `pkg/calc/` source | JS return shape | Used by |
| ------------------ | --------------------------------------------------- | --------------- | -------------------------------- | | ------------------- | -------------------------------------------------------- | --------------- | ---------------------------------------- |
| `driveEffective` | `calc.DriveEffective(drive, driveTech)` | `number` | designer preview (`Speed` input) | | `driveEffective` | `calc.DriveEffective(drive, driveTech)` | `number` | designer preview (`Speed` input) |
| `emptyMass` | `calc.EmptyMass(drive, weapons, armament, …)` | `number\|null` | designer preview (mass row) | | `emptyMass` | `calc.EmptyMass(drive, weapons, armament, …)` | `number\|null` | designer preview (mass row) |
| `weaponsBlockMass` | `calc.WeaponsBlockMass(weapons, armament)` | `number\|null` | reserved for future stages | | `weaponsBlockMass` | `calc.WeaponsBlockMass(weapons, armament)` | `number\|null` | designer preview, modernize cost preview |
| `fullMass` | `calc.FullMass(emptyMass, carryingMass)` | `number` | designer preview (full-load row) | | `fullMass` | `calc.FullMass(emptyMass, carryingMass)` | `number` | designer preview (full-load row) |
| `speed` | `calc.Speed(driveEffective, fullMass)` | `number` | designer preview (speed + range) | | `speed` | `calc.Speed(driveEffective, fullMass)` | `number` | designer preview (speed + range) |
| `cargoCapacity` | `calc.CargoCapacity(cargo, cargoTech)` | `number` | designer preview (cargo row) | | `cargoCapacity` | `calc.CargoCapacity(cargo, cargoTech)` | `number` | designer preview (cargo row) |
| `carryingMass` | `calc.CarryingMass(load, cargoTech)` | `number` | designer preview (full-load mass)| | `carryingMass` | `calc.CarryingMass(load, cargoTech)` | `number` | designer preview (full-load mass) |
| `blockUpgradeCost` | `calc.BlockUpgradeCost(blockMass, currentTech, target)` | `number` | ship-group inspector modernize preview |
`number|null` returns mirror the Go `(float64, bool)` signature: the `number|null` returns mirror the Go `(float64, bool)` signature: the
upstream validator rejects weapons/armament pairings with one zero upstream validator rejects weapons/armament pairings with one zero
@@ -64,6 +67,8 @@ waivers below for the rationale on each deferral.
| `ShipProductionCost(shipEmptyMass float64) float64` | Production units required per unit of ship empty mass (×10). | | `ShipProductionCost(shipEmptyMass float64) float64` | Production units required per unit of ship empty mass (×10). |
| `PlanetProduceShipMass(L, Mat, Res float64) float64` | Ship mass produced per turn given free production `L`, material stockpile `Mat`, resources `Res`.| | `PlanetProduceShipMass(L, Mat, Res float64) float64` | Ship mass produced per turn given free production `L`, material stockpile `Mat`, resources `Res`.|
| `DriveEffective`, `Speed`, `EmptyMass`, `FullMass`, … | Ship-level derivations (`pkg/calc/ship.go`). | | `DriveEffective`, `Speed`, `EmptyMass`, `FullMass`, … | Ship-level derivations (`pkg/calc/ship.go`). |
| `BlockUpgradeCost(blockMass, currentTech, target)` | Production cost of upgrading a single ship block (Phase 20 migrated this from `controller`). |
| `FligthDistance(driveTech)`, `VisibilityDistance(...)` | Race-level reach formulas (`pkg/calc/race.go`). |
| `ValidateShipTypeValues`, `CheckShipTypeValueDWSC` | Ship-design validators (`pkg/calc/validator.go`). | | `ValidateShipTypeValues`, `CheckShipTypeValueDWSC` | Ship-design validators (`pkg/calc/validator.go`). |
Nothing else lives in `pkg/calc/` today. Production-side formulas Nothing else lives in `pkg/calc/` today. Production-side formulas
@@ -79,6 +84,7 @@ whether the underlying Go function exists.
| UI feature | Go formula | In `pkg/calc/`? | Surfaced to TS? | | UI feature | Go formula | In `pkg/calc/`? | Surfaced to TS? |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | :-------------: | :-------------: | | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | :-------------: | :-------------: |
| Ship-class designer preview (Phase 18) | `EmptyMass`, `FullMass`, `Speed`, `DriveEffective`, `CargoCapacity`, `CarryingMass`, `WeaponsBlockMass` (`pkg/calc/ship.go`) | yes | yes | | Ship-class designer preview (Phase 18) | `EmptyMass`, `FullMass`, `Speed`, `DriveEffective`, `CargoCapacity`, `CarryingMass`, `WeaponsBlockMass` (`pkg/calc/ship.go`) | yes | yes |
| Ship-group modernize cost preview (Phase 20) | `BlockUpgradeCost` (`pkg/calc/ship.go`, migrated from `game/internal/controller/ship_group_upgrade.go`) | yes | yes |
| Free production potential (`freeIndustry`) | `Planet.ProductionCapacity``industry*0.75 + population*0.25` (`game/internal/model/game/planet.go`) | no | no | | Free production potential (`freeIndustry`) | `Planet.ProductionCapacity``industry*0.75 + population*0.25` (`game/internal/model/game/planet.go`) | no | no |
| Industry production output per turn | `Planet.ProduceIndustry(freeProduction)` (`planet.go`); `freeProduction/5` modulo material constraint | no | no | | Industry production output per turn | `Planet.ProduceIndustry(freeProduction)` (`planet.go`); `freeProduction/5` modulo material constraint | no | no |
| Materials production output per turn | `Planet.ProduceMaterial(freeProduction)` (`planet.go`); `freeProduction * resources` | no | no | | Materials production output per turn | `Planet.ProduceMaterial(freeProduction)` (`planet.go`); `freeProduction * resources` | no | no |
+151
View File
@@ -0,0 +1,151 @@
# Ship-group inspector actions
Phase 20 turns the read-only ship-group inspector
(`ui/frontend/src/lib/inspectors/ship-group.svelte`) into an
interactive command source for the player's own groups in orbit.
This document is the running spec for the actions panel
(`ui/frontend/src/lib/inspectors/ship-group/actions.svelte`):
which actions exist, what gates each one, how partial-ship
operations split a group on the fly, and what the modernize cost
preview shows.
## Reaching a group
The map renderer hides on-planet ship groups to avoid crowding
the canvas. The player reaches an own on-planet group through the
planet inspector's **stationed ship groups** subsection: clicking
a row pivots the `SelectionStore` to the matching
`shipGroup.local` ref, the sidebar swaps from the planet
inspector to the ship-group inspector, and the actions panel
mounts. In-flight (in-space) groups appear as map primitives and
can be selected by clicking the rendered point.
## Action surface
| Action | Implicit-split? | Partial input | FBS payload | Engine reference |
| ----------- | :-------------: | ------------- | ---------------------------- | ----------------------------------------------------- |
| Split | — | ships count | `CommandShipGroupBreak` | `controller/ship_group.go.breakGroup` |
| Send | yes | ships count + destination | `CommandShipGroupSend` | `controller/ship_group_send.go.shipGroupSend` |
| Load | yes | ships count + cargo + quantity | `CommandShipGroupLoad` | `controller/ship_group.go.shipGroupLoad` |
| Unload | yes | ships count + quantity | `CommandShipGroupUnload` | `controller/ship_group.go.shipGroupUnload` |
| Modernize | yes | ships count + tech + level | `CommandShipGroupUpgrade` | `controller/ship_group_upgrade.go.shipGroupUpgrade` |
| Dismantle | yes | ships count + foreign-COL confirm | `CommandShipGroupDismantle` | `controller/ship_group.go.shipGroupDismantle` |
| Transfer | yes | ships count + acceptor race | `CommandShipGroupTransfer` | `controller/ship_group.go.shipGroupTransfer` |
| Join Fleet | — | fleet name (existing or new) | `CommandShipGroupJoinFleet` | `controller/fleet.go.ShipGroupJoinFleet` |
"Implicit-split" means the inspector accepts a number of ships
`M < N` and emits a `CommandShipGroupBreak(id, newId, M)` command
*before* the action command, then targets the action at the
freshly-minted `newId`. The FBS schema only carries a per-ship
`quantity` on `CommandShipGroupBreak`; every other ship-group
command applies to the whole group, so the implicit-split
pattern is the only way to act on a subset without forcing the
player to pre-split manually. Acceptance criteria: "splitting a
group of N into K and N-K results in two valid commands" — that
is exactly the (Break, Action) pair this pipeline emits.
Split and Join Fleet do not accept a partial ship count: Split
*is* the break operation; Join Fleet attaches the whole group
atomically (the engine handles a partial detach by issuing Split
first, which the player drives explicitly).
## Disabled-state rules
The inspector mirrors the engine's pre-conditions per command
(see the references column above) and surfaces each as a
disabled-button tooltip. Any state other than `In_Orbit` disables
every action with `ships are busy ({state})`. Per-action gates:
- **Send**: requires the ship class to have a non-zero drive
block (`controller/ship_group_send.go:32`); the picker
pre-filters destinations by reach
(`localPlayerDrive * 40`), so a valid pick is always within
range. With no reachable planet, the action is disabled with
the "no planets in drive range" tooltip.
- **Load**: requires the orbit planet to be owned by the player
or unowned (`controller/ship_group.go:215`) and the ship class
to have a cargo block (`shipGroupLoad:220`). The dropdown is
pinned to the existing cargo type when the group is already
partially loaded (the engine refuses cargo-type changes at
`shipGroupLoad:223`).
- **Unload**: requires non-empty cargo. Colonists (`COL`) over a
foreign planet are blocked (`shipGroupUnload:283`), with the
matching tooltip in the disabled state.
- **Modernize**: requires the orbit planet to be own/unowned
(`shipGroupUpgrade:29`) and at least one block whose race tech
exceeds the group tech (otherwise nothing can be upgraded).
- **Dismantle**: always available in orbit. When the orbit is
over a foreign planet AND the group carries colonists, the
inline form replaces the normal "confirm" with "confirm —
colonists die"; the player has to click twice to commit
(engine reference `shipGroupDismantle:177-179` — over a
foreign planet, `UnloadColonists` is not called and the
cargo is lost).
- **Transfer**: requires at least one non-extinct race other
than the local player (sourced from
`GameReport.otherRaces`).
- **Join Fleet**: existing-fleet picker is restricted to fleets
in the same orbit (`fleet.go:135-137`); creating a new fleet
always works.
## Modernize cost preview
The form's preview line calls
`core.blockUpgradeCost({ blockMass, currentTech, targetTech })`
once per ship block (drive, weapons, shields, cargo) and sums
the per-ship totals before multiplying by the targeted ship
count. Block masses come from the player's
`ShipClassSummary` for the group's class:
- Drive / shields / cargo block mass = the corresponding ship-
class field (raw value).
- Weapons block mass = `core.weaponsBlockMass({ weapons,
armament })` (Phase 18 bridge); returns null on the invalid
weapons/armament pairing, in which case the row contributes
zero.
For `tech === "ALL"` every block whose mass is non-zero
contributes against the player's race tech as the target. For
per-block tech (`DRIVE` / `WEAPONS` / `SHIELDS` / `CARGO`) only
the chosen block contributes, with `level` as the target.
The preview hides when the form is invalid (`tech !== "ALL"`
with non-positive `level`) or when `Core` has not yet booted —
the bridge call is the only source of truth, so we surface
"preview unavailable" rather than fall back to a JS
re-implementation that could drift from the engine.
## Wire shape
Every emitted command carries:
- `id` — client-minted UUID (`crypto.randomUUID()`), used by the
order draft for status tracking; mirrored as
`CommandItem.cmdId` on the wire.
- `groupId` — the source ship-group's UUID (or `newGroupId` when
the action is the second half of an implicit split). On the
wire it is the `id` field of every ship-group payload type.
Per-action additional fields are documented on the
`OrderCommand` union in
`ui/frontend/src/sync/order-types.ts` next to the JSDoc for each
variant.
## Decisions baked into Phase 20
- **`BlockUpgradeCost` migrated to `pkg/calc`**. The cost
formula previously lived in
`game/internal/controller/ship_group_upgrade.go`. To keep the
`ui/core/calc` bridge a wrapper around pure `pkg/calc/`
formulas, the function moved to `pkg/calc/ship.go` and the
controller now imports it (`controller/ship_group_upgrade.go`).
- **`GameReport.otherRaces`**. The transfer-to-race picker reads
from a new `GameReport.otherRaces: string[]` field, populated
by walking `report.player[]` and excluding the local race plus
every `extinct` entry. Phase 22 (Races View) reuses the same
field.
- **Stationed-ship rows are clickable**. The map deliberately
hides on-planet groups; the planet inspector's stationed-ship
rows now pivot the selection to the corresponding ship-group
variant so the actions panel is reachable from the standard
click flow.
+35
View File
@@ -272,6 +272,18 @@ export interface GameReport {
incomingShipGroups: ReportIncomingShipGroup[]; incomingShipGroups: ReportIncomingShipGroup[];
unidentifiedShipGroups: ReportUnidentifiedShipGroup[]; unidentifiedShipGroups: ReportUnidentifiedShipGroup[];
localFleets: ReportLocalFleet[]; localFleets: ReportLocalFleet[];
/**
* otherRaces lists the names of every non-extinct race other than
* the local player, sorted alphabetically. Drawn from the
* `report.player[]` block in the FBS report (each `Player` row
* carries an `extinct` flag). The ship-group inspector consumes
* this list for the "transfer to race" picker; Phase 22's Races
* View reuses the same field so the read shape is stable across
* stages. Empty when the report has no `player` block (boot
* state, history-mode snapshots) or when the local player is the
* only non-extinct race.
*/
otherRaces: string[];
} }
export async function fetchGameReport( export async function fetchGameReport(
@@ -405,6 +417,7 @@ function decodeReport(report: Report): GameReport {
const raceName = report.race() ?? ""; const raceName = report.race() ?? "";
const routes = decodeReportRoutes(report); const routes = decodeReportRoutes(report);
const localTech = findLocalPlayerTech(report, raceName); const localTech = findLocalPlayerTech(report, raceName);
const otherRaces = collectOtherRaces(report, raceName);
const localShipGroups = decodeLocalShipGroups(report); const localShipGroups = decodeLocalShipGroups(report);
const otherShipGroups = decodeOtherShipGroups(report); const otherShipGroups = decodeOtherShipGroups(report);
const incomingShipGroups = decodeIncomingShipGroups(report); const incomingShipGroups = decodeIncomingShipGroups(report);
@@ -429,6 +442,7 @@ function decodeReport(report: Report): GameReport {
incomingShipGroups, incomingShipGroups,
unidentifiedShipGroups, unidentifiedShipGroups,
localFleets, localFleets,
otherRaces,
}; };
} }
@@ -705,6 +719,27 @@ function findLocalPlayerTech(
return { drive: 0, weapons: 0, shields: 0, cargo: 0 }; return { drive: 0, weapons: 0, shields: 0, cargo: 0 };
} }
/**
* collectOtherRaces walks the `report.player[]` block and returns
* the alphabetically-sorted names of every non-extinct race other
* than the local player. Used by `GameReport.otherRaces` to back the
* ship-group inspector's transfer-to-race picker (Phase 20) and the
* Races View list (Phase 22).
*/
function collectOtherRaces(report: Report, raceName: string): string[] {
const out: string[] = [];
for (let i = 0; i < report.playerLength(); i++) {
const player = report.player(i);
if (player === null) continue;
if (player.extinct()) continue;
const name = player.name() ?? "";
if (name === "" || name === raceName) continue;
out.push(name);
}
out.sort((a, b) => a.localeCompare(b));
return out;
}
/** /**
* uuidToHiLo splits the canonical 36-character UUID string * uuidToHiLo splits the canonical 36-character UUID string
* (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian * (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian
+17
View File
@@ -102,6 +102,7 @@ interface SyntheticPlayer {
weapons: number; weapons: number;
shields: number; shields: number;
cargo: number; cargo: number;
extinct?: boolean;
} }
interface SyntheticShipGroup { interface SyntheticShipGroup {
@@ -269,9 +270,25 @@ function decodeSyntheticReport(json: unknown): GameReport {
incomingShipGroups, incomingShipGroups,
unidentifiedShipGroups, unidentifiedShipGroups,
localFleets, localFleets,
otherRaces: collectOtherRacesFromSynthetic(root, race),
}; };
} }
function collectOtherRacesFromSynthetic(
root: SyntheticReportRoot,
raceName: string,
): string[] {
const out: string[] = [];
for (const player of root.player ?? []) {
if (player.extinct === true) continue;
const name = typeof player.name === "string" ? player.name : "";
if (name === "" || name === raceName) continue;
out.push(name);
}
out.sort((a, b) => a.localeCompare(b));
return out;
}
function toShipGroupTech(raw: Record<string, number> | undefined): ShipGroupTech { function toShipGroupTech(raw: Record<string, number> | undefined): ShipGroupTech {
const out: ShipGroupTech = { drive: 0, weapons: 0, shields: 0, cargo: 0 }; const out: ShipGroupTech = { drive: 0, weapons: 0, shields: 0, cargo: 0 };
if (raw === undefined || raw === null) return out; if (raw === undefined || raw === null) return out;
+56
View File
@@ -194,6 +194,14 @@ const en = {
"game.sidebar.order.label.cargo_route_remove": "remove {loadType} route from planet {source}", "game.sidebar.order.label.cargo_route_remove": "remove {loadType} route from planet {source}",
"game.sidebar.order.label.ship_class_create": "design ship class {name}", "game.sidebar.order.label.ship_class_create": "design ship class {name}",
"game.sidebar.order.label.ship_class_remove": "remove ship class {name}", "game.sidebar.order.label.ship_class_remove": "remove ship class {name}",
"game.sidebar.order.label.ship_group_break": "split group {group} → {quantity} ships into new group",
"game.sidebar.order.label.ship_group_send": "send group {group} → planet {destination}",
"game.sidebar.order.label.ship_group_load": "load {cargo} × {quantity} onto group {group}",
"game.sidebar.order.label.ship_group_unload": "unload × {quantity} from group {group}",
"game.sidebar.order.label.ship_group_upgrade": "modernize group {group} {tech} → {level}",
"game.sidebar.order.label.ship_group_dismantle": "dismantle group {group}",
"game.sidebar.order.label.ship_group_transfer": "transfer group {group} → {acceptor}",
"game.sidebar.order.label.ship_group_join_fleet": "assign group {group} → fleet {fleet}",
"game.table.ship_classes.title": "ship classes", "game.table.ship_classes.title": "ship classes",
"game.table.ship_classes.column.name": "name", "game.table.ship_classes.column.name": "name",
"game.table.ship_classes.column.drive": "drive", "game.table.ship_classes.column.drive": "drive",
@@ -276,6 +284,54 @@ const en = {
"game.inspector.ship_group.fleet.none": "—", "game.inspector.ship_group.fleet.none": "—",
"game.inspector.ship_group.unidentified_no_data": "no data — only the radar blip is known", "game.inspector.ship_group.unidentified_no_data": "no data — only the radar blip is known",
"game.inspector.ship_group.action.split": "split",
"game.inspector.ship_group.action.send": "send",
"game.inspector.ship_group.action.load": "load",
"game.inspector.ship_group.action.unload": "unload",
"game.inspector.ship_group.action.modernize": "modernize",
"game.inspector.ship_group.action.dismantle": "dismantle",
"game.inspector.ship_group.action.transfer": "transfer",
"game.inspector.ship_group.action.join_fleet": "join fleet",
"game.inspector.ship_group.action.confirm": "confirm",
"game.inspector.ship_group.action.cancel": "cancel",
"game.inspector.ship_group.action.confirm_destroy": "confirm — colonists die",
"game.inspector.ship_group.action.disabled.not_in_orbit": "ships are busy ({state}); only orbiting groups accept actions",
"game.inspector.ship_group.action.disabled.no_reach": "no planets are within drive range",
"game.inspector.ship_group.action.disabled.no_drive": "this ship class has no drive block",
"game.inspector.ship_group.action.disabled.no_cargo_block": "this ship class has no cargo block",
"game.inspector.ship_group.action.disabled.no_planet": "the orbit planet is not visible",
"game.inspector.ship_group.action.disabled.foreign_planet": "this action is only available on your own or unowned planets",
"game.inspector.ship_group.action.disabled.empty_cargo": "the group is empty",
"game.inspector.ship_group.action.disabled.foreign_unload_col": "colonists cannot be unloaded over a foreign planet",
"game.inspector.ship_group.action.disabled.no_headroom": "the group's tech is already at your race level",
"game.inspector.ship_group.action.disabled.no_planet_stock": "the planet has no available stock of this cargo",
"game.inspector.ship_group.action.disabled.full_load": "the group is fully loaded",
"game.inspector.ship_group.action.disabled.no_other_races": "no other non-extinct races to transfer to",
"game.inspector.ship_group.action.disabled.unknown_class": "the ship class is missing from the report",
"game.inspector.ship_group.action.field.ships": "ships ({max} total)",
"game.inspector.ship_group.action.field.cargo": "cargo type",
"game.inspector.ship_group.action.field.quantity": "quantity",
"game.inspector.ship_group.action.field.level": "tech level",
"game.inspector.ship_group.action.field.tech": "tech",
"game.inspector.ship_group.action.field.acceptor": "acceptor",
"game.inspector.ship_group.action.field.fleet": "fleet name",
"game.inspector.ship_group.action.field.destination": "destination planet",
"game.inspector.ship_group.action.tech.all": "all blocks",
"game.inspector.ship_group.action.tech.drive": "drive",
"game.inspector.ship_group.action.tech.weapons": "weapons",
"game.inspector.ship_group.action.tech.shields": "shields",
"game.inspector.ship_group.action.tech.cargo": "cargo",
"game.inspector.ship_group.action.send.pick_prompt": "click a planet on the map (Esc to cancel)",
"game.inspector.ship_group.action.send.no_destination": "no destination chosen",
"game.inspector.ship_group.action.modernize.cost": "estimated cost: {cost}",
"game.inspector.ship_group.action.modernize.cost_unavailable": "cost preview unavailable",
"game.inspector.ship_group.action.dismantle.warning": "the group is over a foreign planet with colonists aboard — they will die",
"game.inspector.ship_group.action.fleet.create_new": "+ new fleet",
"game.inspector.ship_group.action.invalid.ship_count": "ships must be in the range 1…{max}",
"game.inspector.ship_group.action.invalid.quantity": "quantity must be greater than zero",
"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.title": "stationed ship groups", "game.inspector.planet.ship_groups.title": "stationed ship groups",
"game.inspector.planet.ship_groups.row.count": "{count} ships", "game.inspector.planet.ship_groups.row.count": "{count} ships",
"game.inspector.planet.ship_groups.row.mass": "mass {mass}", "game.inspector.planet.ship_groups.row.mass": "mass {mass}",
+56
View File
@@ -195,6 +195,14 @@ const ru: Record<keyof typeof en, string> = {
"game.sidebar.order.label.cargo_route_remove": "удалить маршрут {loadType} с планеты {source}", "game.sidebar.order.label.cargo_route_remove": "удалить маршрут {loadType} с планеты {source}",
"game.sidebar.order.label.ship_class_create": "сконструировать класс корабля {name}", "game.sidebar.order.label.ship_class_create": "сконструировать класс корабля {name}",
"game.sidebar.order.label.ship_class_remove": "удалить класс корабля {name}", "game.sidebar.order.label.ship_class_remove": "удалить класс корабля {name}",
"game.sidebar.order.label.ship_group_break": "разделить группу {group} → новая группа из {quantity} кораблей",
"game.sidebar.order.label.ship_group_send": "отправить группу {group} → планета {destination}",
"game.sidebar.order.label.ship_group_load": "загрузить {cargo} × {quantity} в группу {group}",
"game.sidebar.order.label.ship_group_unload": "выгрузить × {quantity} из группы {group}",
"game.sidebar.order.label.ship_group_upgrade": "модернизация группы {group} {tech} → {level}",
"game.sidebar.order.label.ship_group_dismantle": "разобрать группу {group}",
"game.sidebar.order.label.ship_group_transfer": "передать группу {group} → {acceptor}",
"game.sidebar.order.label.ship_group_join_fleet": "включить группу {group} → флот {fleet}",
"game.table.ship_classes.title": "классы кораблей", "game.table.ship_classes.title": "классы кораблей",
"game.table.ship_classes.column.name": "название", "game.table.ship_classes.column.name": "название",
"game.table.ship_classes.column.drive": "двигатель", "game.table.ship_classes.column.drive": "двигатель",
@@ -277,6 +285,54 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.ship_group.fleet.none": "—", "game.inspector.ship_group.fleet.none": "—",
"game.inspector.ship_group.unidentified_no_data": "данных нет — известны только координаты", "game.inspector.ship_group.unidentified_no_data": "данных нет — известны только координаты",
"game.inspector.ship_group.action.split": "разделить",
"game.inspector.ship_group.action.send": "отправить",
"game.inspector.ship_group.action.load": "загрузить",
"game.inspector.ship_group.action.unload": "выгрузить",
"game.inspector.ship_group.action.modernize": "модернизировать",
"game.inspector.ship_group.action.dismantle": "разобрать",
"game.inspector.ship_group.action.transfer": "передать",
"game.inspector.ship_group.action.join_fleet": "во флот",
"game.inspector.ship_group.action.confirm": "подтвердить",
"game.inspector.ship_group.action.cancel": "отмена",
"game.inspector.ship_group.action.confirm_destroy": "подтвердить — колонисты погибнут",
"game.inspector.ship_group.action.disabled.not_in_orbit": "корабли заняты ({state}); действия доступны только на орбите",
"game.inspector.ship_group.action.disabled.no_reach": "в радиусе двигателей нет планет",
"game.inspector.ship_group.action.disabled.no_drive": "у класса корабля нет блока двигателей",
"game.inspector.ship_group.action.disabled.no_cargo_block": "у класса корабля нет грузового отсека",
"game.inspector.ship_group.action.disabled.no_planet": "планета орбиты не видна",
"game.inspector.ship_group.action.disabled.foreign_planet": "действие доступно только над вашей или ничейной планетой",
"game.inspector.ship_group.action.disabled.empty_cargo": "трюм пуст",
"game.inspector.ship_group.action.disabled.foreign_unload_col": "колонистов нельзя высадить на чужой планете",
"game.inspector.ship_group.action.disabled.no_headroom": "технологии группы уже на вашем расовом уровне",
"game.inspector.ship_group.action.disabled.no_planet_stock": "на планете нет такого ресурса",
"game.inspector.ship_group.action.disabled.full_load": "трюм полностью заполнен",
"game.inspector.ship_group.action.disabled.no_other_races": "нет других нерасправленных рас для передачи",
"game.inspector.ship_group.action.disabled.unknown_class": "класс корабля не найден в отчёте",
"game.inspector.ship_group.action.field.ships": "кораблей (всего {max})",
"game.inspector.ship_group.action.field.cargo": "тип груза",
"game.inspector.ship_group.action.field.quantity": "количество",
"game.inspector.ship_group.action.field.level": "уровень технологии",
"game.inspector.ship_group.action.field.tech": "технология",
"game.inspector.ship_group.action.field.acceptor": "получатель",
"game.inspector.ship_group.action.field.fleet": "имя флота",
"game.inspector.ship_group.action.field.destination": "планета назначения",
"game.inspector.ship_group.action.tech.all": "все блоки",
"game.inspector.ship_group.action.tech.drive": "двигатели",
"game.inspector.ship_group.action.tech.weapons": "оружие",
"game.inspector.ship_group.action.tech.shields": "защита",
"game.inspector.ship_group.action.tech.cargo": "груз",
"game.inspector.ship_group.action.send.pick_prompt": "выберите планету на карте (Esc — отмена)",
"game.inspector.ship_group.action.send.no_destination": "планета не выбрана",
"game.inspector.ship_group.action.modernize.cost": "ожидаемая стоимость: {cost}",
"game.inspector.ship_group.action.modernize.cost_unavailable": "предпросмотр недоступен",
"game.inspector.ship_group.action.dismantle.warning": "группа над чужой планетой везёт колонистов — они погибнут",
"game.inspector.ship_group.action.fleet.create_new": "+ новый флот",
"game.inspector.ship_group.action.invalid.ship_count": "число кораблей должно быть в диапазоне 1…{max}",
"game.inspector.ship_group.action.invalid.quantity": "количество должно быть больше нуля",
"game.inspector.ship_group.action.invalid.level": "уровень должен быть в ({current}, {max}]",
"game.inspector.ship_group.action.invalid.fleet_name": "имя флота не соответствует правилам имён сущностей",
"game.inspector.planet.ship_groups.title": "корабли на орбите", "game.inspector.planet.ship_groups.title": "корабли на орбите",
"game.inspector.planet.ship_groups.row.count": "{count} кораблей", "game.inspector.planet.ship_groups.row.count": "{count} кораблей",
"game.inspector.planet.ship_groups.row.mass": "масса {mass}", "game.inspector.planet.ship_groups.row.mass": "масса {mass}",
@@ -1,29 +1,37 @@
<!-- <!--
Phase 19 read-only "ship groups stationed here" subsection of the "Ship groups stationed here" subsection of the planet inspector.
planet inspector. Phase 19 originally rendered every in-orbit group The map deliberately hides on-planet groups (rendering them as
as an offset point near its planet on the map, which crowded the offset points crowds the canvas), so this list is the player's
canvas to the point of unreadability. The map now hides on-planet view of which fleets sit in this orbit. Race attribution is
groups and the planet inspector lists them instead — one row per best-effort:
group, showing its race, class, ship count, and mass.
Race attribution is best-effort:
- LocalGroup → the player's own race (`localRace` prop). - LocalGroup → the player's own race (`localRace` prop).
- OtherGroup on an `other`-kind planet → the planet's owner. - OtherGroup on an `other`-kind planet → the planet's owner.
- OtherGroup elsewhere → "foreign" placeholder; the engine's - OtherGroup elsewhere → "foreign" placeholder; the engine's
typed contract does not carry per-group ownership outside typed contract does not carry per-group ownership outside
battle rosters. battle rosters.
Rows are intentionally non-interactive in Phase 19. Phase 21+ will Phase 20 makes own-ship rows interactive: clicking a row pivots
deliver a full ship-groups table view; clicking a row will then the inspector to the corresponding ship-group inspector through
deep-link into that table with a `(planet, race)` filter pre-applied. 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.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte";
import type { import type {
ReportLocalShipGroup, ReportLocalShipGroup,
ReportOtherShipGroup, ReportOtherShipGroup,
ReportPlanet, ReportPlanet,
} from "../../../api/game-state"; } from "../../../api/game-state";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import {
SELECTION_CONTEXT_KEY,
type SelectionStore,
} from "$lib/selection.svelte";
type Props = { type Props = {
planet: ReportPlanet; planet: ReportPlanet;
@@ -33,12 +41,18 @@ deep-link into that table with a `(planet, race)` filter pre-applied.
}; };
let { planet, localShipGroups, otherShipGroups, localRace }: Props = $props(); let { planet, localShipGroups, otherShipGroups, localRace }: Props = $props();
const selection = getContext<SelectionStore | undefined>(
SELECTION_CONTEXT_KEY,
);
interface StationedRow { interface StationedRow {
key: string; key: string;
race: string; race: string;
class: string; class: string;
count: number; count: number;
mass: number; mass: number;
selectable: boolean;
groupId: string | null;
} }
const stationedRows: StationedRow[] = $derived.by(() => { const stationedRows: StationedRow[] = $derived.by(() => {
@@ -52,6 +66,8 @@ deep-link into that table with a `(planet, race)` filter pre-applied.
class: g.class, class: g.class,
count: g.count, count: g.count,
mass: g.mass, mass: g.mass,
selectable: true,
groupId: g.id,
}); });
} }
const foreignRace = const foreignRace =
@@ -67,6 +83,8 @@ deep-link into that table with a `(planet, race)` filter pre-applied.
class: g.class, class: g.class,
count: g.count, count: g.count,
mass: g.mass, mass: g.mass,
selectable: false,
groupId: null,
}); });
} }
return rows; return rows;
@@ -75,6 +93,11 @@ deep-link into that table with a `(planet, race)` filter pre-applied.
function formatNumber(value: number): string { function formatNumber(value: number): string {
return value.toLocaleString(undefined, { maximumFractionDigits: 2 }); return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
} }
function selectLocalGroup(groupId: string): void {
if (selection === undefined) return;
selection.selectShipGroup({ variant: "local", id: groupId });
}
</script> </script>
{#if stationedRows.length > 0} {#if stationedRows.length > 0}
@@ -83,20 +106,45 @@ deep-link into that table with a `(planet, race)` filter pre-applied.
<ul class="rows"> <ul class="rows">
{#each stationedRows as row (row.key)} {#each stationedRows as row (row.key)}
<li class="row" data-testid="inspector-planet-ship-groups-row"> <li class="row" data-testid="inspector-planet-ship-groups-row">
<span class="race" data-testid="inspector-planet-ship-groups-race"> {#if row.selectable && row.groupId !== null}
{row.race} {@const groupId = row.groupId}
</span> <button
<span class="class">{row.class}</span> type="button"
<span class="count"> class="select"
{i18n.t("game.inspector.planet.ship_groups.row.count", { data-testid="inspector-planet-ship-groups-select"
count: String(row.count), onclick={() => selectLocalGroup(groupId)}
})} >
</span> <span class="race" data-testid="inspector-planet-ship-groups-race">
<span class="mass"> {row.race}
{i18n.t("game.inspector.planet.ship_groups.row.mass", { </span>
mass: formatNumber(row.mass), <span class="class">{row.class}</span>
})} <span class="count">
</span> {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: formatNumber(row.mass),
})}
</span>
</button>
{:else}
<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: formatNumber(row.mass),
})}
</span>
{/if}
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -125,11 +173,30 @@ deep-link into that table with a `(planet, race)` filter pre-applied.
gap: 0.2rem; gap: 0.2rem;
} }
.row { .row {
display: block;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.row > span,
.row > .select {
display: grid; display: grid;
grid-template-columns: 1fr 1fr auto auto; grid-template-columns: 1fr 1fr auto auto;
gap: 0.5rem; gap: 0.5rem;
font-size: 0.85rem; }
font-variant-numeric: tabular-nums; .select {
width: 100%;
font: inherit;
text-align: left;
background: transparent;
color: inherit;
border: 1px solid transparent;
border-radius: 3px;
padding: 0.15rem 0.3rem;
cursor: pointer;
}
.select:hover {
border-color: #2a3150;
background: #0d1224;
} }
.race { .race {
font-weight: 600; font-weight: 600;
@@ -6,17 +6,44 @@ mounted by the in-game shell layout only while the active tool is
`map` so it does not stack on top of the calc / order overlays. `map` so it does not stack on top of the calc / order overlays.
--> -->
<script lang="ts"> <script lang="ts">
import type { ReportPlanet } from "../../api/game-state"; import type {
ReportLocalFleet,
ReportPlanet,
ShipClassSummary,
} from "../../api/game-state";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import ShipGroup, { type ShipGroupSelection } from "./ship-group.svelte"; import ShipGroup, { type ShipGroupSelection } from "./ship-group.svelte";
type Props = { type Props = {
selection: ShipGroupSelection | null; selection: ShipGroupSelection | null;
planets: ReportPlanet[]; planets: ReportPlanet[];
localShipClass: ShipClassSummary[];
localFleets: ReportLocalFleet[];
otherRaces: string[];
mapWidth: number;
mapHeight: number;
localPlayerDrive: number;
localPlayerWeapons: number;
localPlayerShields: number;
localPlayerCargo: number;
onMap: boolean; onMap: boolean;
onClose: () => void; onClose: () => void;
}; };
let { selection, planets, onMap, onClose }: Props = $props(); let {
selection,
planets,
localShipClass,
localFleets,
otherRaces,
mapWidth,
mapHeight,
localPlayerDrive,
localPlayerWeapons,
localPlayerShields,
localPlayerCargo,
onMap,
onClose,
}: Props = $props();
</script> </script>
{#if selection !== null && onMap} {#if selection !== null && onMap}
@@ -34,7 +61,19 @@ mounted by the in-game shell layout only while the active tool is
> >
</button> </button>
<ShipGroup {selection} {planets} /> <ShipGroup
{selection}
{planets}
{localShipClass}
{localFleets}
{otherRaces}
{mapWidth}
{mapHeight}
{localPlayerDrive}
{localPlayerWeapons}
{localPlayerShields}
{localPlayerCargo}
/>
</section> </section>
{/if} {/if}
@@ -12,12 +12,15 @@ variant — for Phase 19 the inspector is intentionally read-only.
<script lang="ts"> <script lang="ts">
import type { import type {
ReportIncomingShipGroup, ReportIncomingShipGroup,
ReportLocalFleet,
ReportLocalShipGroup, ReportLocalShipGroup,
ReportOtherShipGroup, ReportOtherShipGroup,
ReportPlanet, ReportPlanet,
ReportUnidentifiedShipGroup, ReportUnidentifiedShipGroup,
ShipClassSummary,
} from "../../api/game-state"; } from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import Actions from "./ship-group/actions.svelte";
export type ShipGroupSelection = export type ShipGroupSelection =
| { variant: "local"; group: ReportLocalShipGroup } | { variant: "local"; group: ReportLocalShipGroup }
@@ -28,8 +31,29 @@ variant — for Phase 19 the inspector is intentionally read-only.
type Props = { type Props = {
selection: ShipGroupSelection; selection: ShipGroupSelection;
planets: ReportPlanet[]; planets: ReportPlanet[];
localShipClass?: ShipClassSummary[];
localFleets?: ReportLocalFleet[];
otherRaces?: string[];
mapWidth?: number;
mapHeight?: number;
localPlayerDrive?: number;
localPlayerWeapons?: number;
localPlayerShields?: number;
localPlayerCargo?: number;
}; };
let { selection, planets }: Props = $props(); let {
selection,
planets,
localShipClass = [],
localFleets = [],
otherRaces = [],
mapWidth = 1,
mapHeight = 1,
localPlayerDrive = 0,
localPlayerWeapons = 0,
localPlayerShields = 0,
localPlayerCargo = 0,
}: Props = $props();
const kindKeyMap: Record<ShipGroupSelection["variant"], TranslationKey> = { const kindKeyMap: Record<ShipGroupSelection["variant"], TranslationKey> = {
local: "game.inspector.ship_group.kind.local", local: "game.inspector.ship_group.kind.local",
@@ -86,6 +110,22 @@ variant — for Phase 19 the inspector is intentionally read-only.
{/if} {/if}
</header> </header>
{#if selection.variant === "local"}
<Actions
group={selection.group}
{planets}
{localShipClass}
{localFleets}
{otherRaces}
{mapWidth}
{mapHeight}
{localPlayerDrive}
{localPlayerWeapons}
{localPlayerShields}
{localPlayerCargo}
/>
{/if}
{#if selection.variant === "local" || selection.variant === "other"} {#if selection.variant === "local" || selection.variant === "other"}
{@const g = selection.group} {@const g = selection.group}
{@const onPlanet = g.origin === null || g.range === null} {@const onPlanet = g.origin === null || g.range === null}
File diff suppressed because it is too large Load Diff
@@ -89,12 +89,23 @@ from the Phase 10 stub.
const localPlayerDrive = $derived( const localPlayerDrive = $derived(
renderedReport?.report?.localPlayerDrive ?? 0, renderedReport?.report?.localPlayerDrive ?? 0,
); );
const localPlayerWeapons = $derived(
renderedReport?.report?.localPlayerWeapons ?? 0,
);
const localPlayerShields = $derived(
renderedReport?.report?.localPlayerShields ?? 0,
);
const localPlayerCargo = $derived(
renderedReport?.report?.localPlayerCargo ?? 0,
);
const localShipGroups = $derived( const localShipGroups = $derived(
renderedReport?.report?.localShipGroups ?? [], renderedReport?.report?.localShipGroups ?? [],
); );
const otherShipGroups = $derived( const otherShipGroups = $derived(
renderedReport?.report?.otherShipGroups ?? [], renderedReport?.report?.otherShipGroups ?? [],
); );
const localFleets = $derived(renderedReport?.report?.localFleets ?? []);
const otherRaces = $derived(renderedReport?.report?.otherRaces ?? []);
const localRace = $derived(renderedReport?.report?.race ?? ""); const localRace = $derived(renderedReport?.report?.race ?? "");
</script> </script>
@@ -113,7 +124,19 @@ from the Phase 10 stub.
{localRace} {localRace}
/> />
{:else if selectedShipGroup !== null} {:else if selectedShipGroup !== null}
<ShipGroup selection={selectedShipGroup} planets={allPlanets} /> <ShipGroup
selection={selectedShipGroup}
planets={allPlanets}
{localShipClass}
{localFleets}
{otherRaces}
{mapWidth}
{mapHeight}
{localPlayerDrive}
{localPlayerWeapons}
{localPlayerShields}
{localPlayerCargo}
/>
{:else} {:else}
<h3>{i18n.t("game.sidebar.tab.inspector")}</h3> <h3>{i18n.t("game.sidebar.tab.inspector")}</h3>
<p>{i18n.t("game.sidebar.empty.inspector")}</p> <p>{i18n.t("game.sidebar.empty.inspector")}</p>
@@ -77,9 +77,57 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
return i18n.t("game.sidebar.order.label.ship_class_remove", { return i18n.t("game.sidebar.order.label.ship_class_remove", {
name: cmd.name, name: cmd.name,
}); });
case "breakShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_break", {
group: shortGroupId(cmd.groupId),
quantity: String(cmd.quantity),
});
case "sendShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_send", {
group: shortGroupId(cmd.groupId),
destination: String(cmd.destinationPlanetNumber),
});
case "loadShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_load", {
group: shortGroupId(cmd.groupId),
cargo: cmd.cargo,
quantity: String(cmd.quantity),
});
case "unloadShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_unload", {
group: shortGroupId(cmd.groupId),
quantity: String(cmd.quantity),
});
case "upgradeShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_upgrade", {
group: shortGroupId(cmd.groupId),
tech: cmd.tech,
level: String(cmd.level),
});
case "dismantleShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_dismantle", {
group: shortGroupId(cmd.groupId),
});
case "transferShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_transfer", {
group: shortGroupId(cmd.groupId),
acceptor: cmd.acceptor,
});
case "joinFleetShipGroup":
return i18n.t("game.sidebar.order.label.ship_group_join_fleet", {
group: shortGroupId(cmd.groupId),
fleet: cmd.name,
});
} }
} }
// Short identifier for the order-tab so the human-readable label
// stays glanceable; the full UUID is still in the underlying
// command and visible in the inspector overlay.
function shortGroupId(uuid: string): string {
return uuid.length > 8 ? uuid.slice(0, 8) : uuid;
}
function statusOf(cmd: OrderCommand): CommandStatus { function statusOf(cmd: OrderCommand): CommandStatus {
return draft?.statuses[cmd.id] ?? "draft"; return draft?.statuses[cmd.id] ?? "draft";
} }
+17
View File
@@ -73,6 +73,12 @@ export interface CarryingMassInput {
cargoTech: number; cargoTech: number;
} }
export interface BlockUpgradeCostInput {
blockMass: number;
currentTech: number;
targetTech: number;
}
export interface Core { export interface Core {
/** /**
* signRequest returns the canonical signing input bytes for a v1 * signRequest returns the canonical signing input bytes for a v1
@@ -157,6 +163,17 @@ export interface Core {
* cargoCapacity. * cargoCapacity.
*/ */
carryingMass(input: CarryingMassInput): number; carryingMass(input: CarryingMassInput): number;
/**
* blockUpgradeCost wraps `pkg/calc/ship.go.BlockUpgradeCost`:
* production cost of moving one ship block from currentTech to
* targetTech, scaled by the block mass and a constant 10. Returns
* 0 when blockMass is zero or targetTech is not above currentTech.
* Phase 20's ship-group inspector calls this once per block
* (drive, weapons, shields, cargo) to render the modernize cost
* preview.
*/
blockUpgradeCost(input: BlockUpgradeCostInput): number;
} }
export type CoreLoader = () => Promise<Core>; export type CoreLoader = () => Promise<Core>;
+5
View File
@@ -9,6 +9,7 @@
// served from `static/core.wasm`. // served from `static/core.wasm`.
import type { import type {
BlockUpgradeCostInput,
CargoCapacityInput, CargoCapacityInput,
CarryingMassInput, CarryingMassInput,
Core, Core,
@@ -50,6 +51,7 @@ interface GalaxyCoreBridge {
speed(input: SpeedInput): number; speed(input: SpeedInput): number;
cargoCapacity(input: CargoCapacityInput): number; cargoCapacity(input: CargoCapacityInput): number;
carryingMass(input: CarryingMassInput): number; carryingMass(input: CarryingMassInput): number;
blockUpgradeCost(input: BlockUpgradeCostInput): number;
} }
interface BridgeRequestFields { interface BridgeRequestFields {
@@ -210,6 +212,9 @@ export function adaptBridge(bridge: GalaxyCoreBridge): Core {
carryingMass(input: CarryingMassInput): number { carryingMass(input: CarryingMassInput): number {
return bridge.carryingMass(input); return bridge.carryingMass(input);
}, },
blockUpgradeCost(input: BlockUpgradeCostInput): number {
return bridge.blockUpgradeCost(input);
},
}; };
} }
@@ -180,12 +180,27 @@ fresh.
const inspectorLocalDrive = $derived( const inspectorLocalDrive = $derived(
renderedReport.report?.localPlayerDrive ?? 0, renderedReport.report?.localPlayerDrive ?? 0,
); );
const inspectorLocalWeapons = $derived(
renderedReport.report?.localPlayerWeapons ?? 0,
);
const inspectorLocalShields = $derived(
renderedReport.report?.localPlayerShields ?? 0,
);
const inspectorLocalCargo = $derived(
renderedReport.report?.localPlayerCargo ?? 0,
);
const inspectorLocalShipGroups = $derived( const inspectorLocalShipGroups = $derived(
renderedReport.report?.localShipGroups ?? [], renderedReport.report?.localShipGroups ?? [],
); );
const inspectorOtherShipGroups = $derived( const inspectorOtherShipGroups = $derived(
renderedReport.report?.otherShipGroups ?? [], renderedReport.report?.otherShipGroups ?? [],
); );
const inspectorLocalFleets = $derived(
renderedReport.report?.localFleets ?? [],
);
const inspectorOtherRaces = $derived(
renderedReport.report?.otherRaces ?? [],
);
const inspectorLocalRace = $derived(renderedReport.report?.race ?? ""); const inspectorLocalRace = $derived(renderedReport.report?.race ?? "");
// Reveal the inspector whenever a new planet selection lands. // Reveal the inspector whenever a new planet selection lands.
@@ -340,6 +355,15 @@ fresh.
<ShipGroupSheet <ShipGroupSheet
selection={selectedShipGroup} selection={selectedShipGroup}
planets={inspectorPlanets} planets={inspectorPlanets}
{localShipClass}
localFleets={inspectorLocalFleets}
otherRaces={inspectorOtherRaces}
mapWidth={inspectorMapWidth}
mapHeight={inspectorMapHeight}
localPlayerDrive={inspectorLocalDrive}
localPlayerWeapons={inspectorLocalWeapons}
localPlayerShields={inspectorLocalShields}
localPlayerCargo={inspectorLocalCargo}
onMap={effectiveTool === "map"} onMap={effectiveTool === "map"}
onClose={() => selection.clear()} onClose={() => selection.clear()}
/> />
+68 -1
View File
@@ -24,7 +24,12 @@
import type { Cache } from "../platform/store/index"; import type { Cache } from "../platform/store/index";
import type { GalaxyClient } from "../api/galaxy-client"; import type { GalaxyClient } from "../api/galaxy-client";
import { fetchOrder } from "./order-load"; import { fetchOrder } from "./order-load";
import type { CommandStatus, OrderCommand } from "./order-types"; import {
isShipGroupCargo,
isShipGroupUpgradeTech,
type CommandStatus,
type OrderCommand,
} from "./order-types";
import { submitOrder } from "./submit"; import { submitOrder } from "./submit";
import { validateEntityName } from "$lib/util/entity-name"; import { validateEntityName } from "$lib/util/entity-name";
import { validateShipClass } from "$lib/util/ship-class-validation"; import { validateShipClass } from "$lib/util/ship-class-validation";
@@ -513,6 +518,68 @@ function validateCommand(cmd: OrderCommand): CommandStatus {
// active production / ship groups. Local validation only // active production / ship groups. Local validation only
// guards the name shape. // guards the name shape.
return validateEntityName(cmd.name).ok ? "valid" : "invalid"; return validateEntityName(cmd.name).ok ? "valid" : "invalid";
case "breakShipGroup":
// Engine rule (`controller/ship_group.go.breakGroup`):
// quantity must be at least 1 and strictly less than the
// source group size. We do not know the source size here
// (it lives on the report), so the inspector enforces the
// upper bound before emitting; locally we only refuse the
// degenerate cases — non-positive `quantity`, missing or
// equal UUIDs.
if (cmd.quantity <= 0) return "invalid";
if (!isUuid(cmd.groupId) || !isUuid(cmd.newGroupId)) return "invalid";
if (cmd.groupId === cmd.newGroupId) return "invalid";
return "valid";
case "sendShipGroup":
// Reach is enforced by the picker before the command lands
// in the draft. Locally we only refuse a degenerate
// destination (the engine uses planet number `0` as the
// "no planet" sentinel; FBS encodes as `int64`, so any
// strictly-positive number is wire-valid).
if (cmd.destinationPlanetNumber <= 0) return "invalid";
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "loadShipGroup":
// Cargo type and quantity are pre-checked by the inspector
// against the planet stock and the group's free capacity;
// local validation only guards the wire-valid shape.
if (!isShipGroupCargo(cmd.cargo)) return "invalid";
if (cmd.quantity <= 0) return "invalid";
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "unloadShipGroup":
if (cmd.quantity <= 0) return "invalid";
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "upgradeShipGroup":
// Engine rule
// (`controller/ship_group_upgrade.go.shipGroupUpgrade:56`):
// `tech === "ALL"` requires `level === 0`; per-block tech
// requires a strictly positive level. The inspector also
// caps the level to the player's race tech, but the
// engine re-validates server-side.
if (!isShipGroupUpgradeTech(cmd.tech)) return "invalid";
if (cmd.tech === "ALL") {
if (cmd.level !== 0) return "invalid";
} else if (cmd.level <= 0) {
return "invalid";
}
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "dismantleShipGroup":
return isUuid(cmd.groupId) ? "valid" : "invalid";
case "transferShipGroup":
// `acceptor` is a race name; race names follow the same
// entity-name rules as planet/fleet names. The inspector
// restricts the picker to `GameReport.otherRaces`, so a
// locally-valid name is always a real race.
if (!validateEntityName(cmd.acceptor).ok) return "invalid";
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "joinFleetShipGroup":
if (!validateEntityName(cmd.name).ok) return "invalid";
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "placeholder": case "placeholder":
// Phase 12 placeholder entries are content-free and never // Phase 12 placeholder entries are content-free and never
// transition out of `draft` — they are not submittable. // transition out of `draft` — they are not submittable.
+157
View File
@@ -18,8 +18,18 @@ import {
CommandPlanetRouteSet, CommandPlanetRouteSet,
CommandShipClassCreate, CommandShipClassCreate,
CommandShipClassRemove, CommandShipClassRemove,
CommandShipGroupBreak,
CommandShipGroupDismantle,
CommandShipGroupJoinFleet,
CommandShipGroupLoad,
CommandShipGroupSend,
CommandShipGroupTransfer,
CommandShipGroupUnload,
CommandShipGroupUpgrade,
PlanetProduction, PlanetProduction,
PlanetRouteLoadType, PlanetRouteLoadType,
ShipGroupCargo,
ShipGroupUpgradeTech,
UserGamesOrderGet, UserGamesOrderGet,
UserGamesOrderGetResponse, UserGamesOrderGetResponse,
} from "../proto/galaxy/fbs/order"; } from "../proto/galaxy/fbs/order";
@@ -27,6 +37,8 @@ import type {
CargoLoadType, CargoLoadType,
OrderCommand, OrderCommand,
ProductionType, ProductionType,
ShipGroupCargo as ShipGroupCargoLiteral,
ShipGroupUpgradeTech as ShipGroupUpgradeTechLiteral,
} from "./order-types"; } from "./order-types";
const MESSAGE_TYPE = "user.games.order.get"; const MESSAGE_TYPE = "user.games.order.get";
@@ -222,6 +234,102 @@ function decodeCommand(item: CommandItemView): OrderCommand | null {
name: inner.name() ?? "", name: inner.name() ?? "",
}; };
} }
case CommandPayload.CommandShipGroupBreak: {
const inner = new CommandShipGroupBreak();
item.payload(inner);
return {
kind: "breakShipGroup",
id,
groupId: inner.id() ?? "",
newGroupId: inner.newId() ?? "",
quantity: Number(inner.quantity()),
};
}
case CommandPayload.CommandShipGroupSend: {
const inner = new CommandShipGroupSend();
item.payload(inner);
return {
kind: "sendShipGroup",
id,
groupId: inner.id() ?? "",
destinationPlanetNumber: Number(inner.destination()),
};
}
case CommandPayload.CommandShipGroupLoad: {
const inner = new CommandShipGroupLoad();
item.payload(inner);
const cargo = shipGroupCargoFromFBS(inner.cargo());
if (cargo === null) {
console.warn(
`fetchOrder: skipping CommandShipGroupLoad with unknown cargo enum (${inner.cargo()})`,
);
return null;
}
return {
kind: "loadShipGroup",
id,
groupId: inner.id() ?? "",
cargo,
quantity: inner.quantity(),
};
}
case CommandPayload.CommandShipGroupUnload: {
const inner = new CommandShipGroupUnload();
item.payload(inner);
return {
kind: "unloadShipGroup",
id,
groupId: inner.id() ?? "",
quantity: inner.quantity(),
};
}
case CommandPayload.CommandShipGroupUpgrade: {
const inner = new CommandShipGroupUpgrade();
item.payload(inner);
const tech = shipGroupUpgradeTechFromFBS(inner.tech());
if (tech === null) {
console.warn(
`fetchOrder: skipping CommandShipGroupUpgrade with unknown tech enum (${inner.tech()})`,
);
return null;
}
return {
kind: "upgradeShipGroup",
id,
groupId: inner.id() ?? "",
tech,
level: inner.level(),
};
}
case CommandPayload.CommandShipGroupDismantle: {
const inner = new CommandShipGroupDismantle();
item.payload(inner);
return {
kind: "dismantleShipGroup",
id,
groupId: inner.id() ?? "",
};
}
case CommandPayload.CommandShipGroupTransfer: {
const inner = new CommandShipGroupTransfer();
item.payload(inner);
return {
kind: "transferShipGroup",
id,
groupId: inner.id() ?? "",
acceptor: inner.acceptor() ?? "",
};
}
case CommandPayload.CommandShipGroupJoinFleet: {
const inner = new CommandShipGroupJoinFleet();
item.payload(inner);
return {
kind: "joinFleetShipGroup",
id,
groupId: inner.id() ?? "",
name: inner.name() ?? "",
};
}
default: default:
console.warn( console.warn(
`fetchOrder: skipping unknown command kind (payloadType=${payloadType})`, `fetchOrder: skipping unknown command kind (payloadType=${payloadType})`,
@@ -288,6 +396,55 @@ export function cargoLoadTypeFromFBS(
} }
} }
/**
* shipGroupCargoFromFBS reverses `shipGroupCargoToFBS` from
* `submit.ts`. `ShipGroupCargo.UNKNOWN` and any out-of-band value
* yield `null` so the caller drops the entry rather than
* fabricating a synthetic cargo type.
*/
export function shipGroupCargoFromFBS(
value: ShipGroupCargo,
): ShipGroupCargoLiteral | null {
switch (value) {
case ShipGroupCargo.COL:
return "COL";
case ShipGroupCargo.CAP:
return "CAP";
case ShipGroupCargo.MAT:
return "MAT";
case ShipGroupCargo.UNKNOWN:
return null;
default:
return null;
}
}
/**
* shipGroupUpgradeTechFromFBS reverses `shipGroupUpgradeTechToFBS`
* from `submit.ts`. `ShipGroupUpgradeTech.UNKNOWN` and any
* out-of-band value yield `null`.
*/
export function shipGroupUpgradeTechFromFBS(
value: ShipGroupUpgradeTech,
): ShipGroupUpgradeTechLiteral | null {
switch (value) {
case ShipGroupUpgradeTech.ALL:
return "ALL";
case ShipGroupUpgradeTech.DRIVE:
return "DRIVE";
case ShipGroupUpgradeTech.WEAPONS:
return "WEAPONS";
case ShipGroupUpgradeTech.SHIELDS:
return "SHIELDS";
case ShipGroupUpgradeTech.CARGO:
return "CARGO";
case ShipGroupUpgradeTech.UNKNOWN:
return null;
default:
return null;
}
}
function decodeError( function decodeError(
payload: Uint8Array, payload: Uint8Array,
resultCode: string, resultCode: string,
+212 -1
View File
@@ -166,6 +166,209 @@ export interface RemoveShipClassCommand {
readonly name: string; readonly name: string;
} }
/**
* ShipGroupCargo mirrors the engine `ShipGroupCargo` enum
* (`pkg/schema/fbs/order.fbs`). Three values: colonists, capital
* (industry crates), and materials. Empty (`EMP`) is a route-level
* concept (`PlanetRouteLoadType`) and is not a valid cargo type for a
* ship-group load command — the FBS enum deliberately omits it.
*/
export type ShipGroupCargo = "COL" | "CAP" | "MAT";
/**
* SHIP_GROUP_CARGO_VALUES is the canonical tuple of `ShipGroupCargo`
* literals. Used by validators and the FBS converters in
* `submit.ts` and `order-load.ts` to narrow incoming strings.
*/
export const SHIP_GROUP_CARGO_VALUES = [
"COL",
"CAP",
"MAT",
] as const satisfies readonly ShipGroupCargo[];
/**
* isShipGroupCargo narrows an arbitrary string to the
* `ShipGroupCargo` union.
*/
export function isShipGroupCargo(value: string): value is ShipGroupCargo {
return (SHIP_GROUP_CARGO_VALUES as readonly string[]).includes(value);
}
/**
* ShipGroupUpgradeTech mirrors the engine `ShipGroupUpgradeTech`
* enum (`pkg/schema/fbs/order.fbs`): `ALL` upgrades every applicable
* block to the player's current race tech (level argument must be 0
* — see `controller/ship_group_upgrade.go:56`); the four per-block
* values upgrade exactly that block to the requested level.
*/
export type ShipGroupUpgradeTech =
| "ALL"
| "DRIVE"
| "WEAPONS"
| "SHIELDS"
| "CARGO";
/**
* SHIP_GROUP_UPGRADE_TECH_VALUES is the canonical tuple of
* `ShipGroupUpgradeTech` literals. The order matches the FBS enum.
*/
export const SHIP_GROUP_UPGRADE_TECH_VALUES = [
"ALL",
"DRIVE",
"WEAPONS",
"SHIELDS",
"CARGO",
] as const satisfies readonly ShipGroupUpgradeTech[];
/**
* isShipGroupUpgradeTech narrows an arbitrary string to the
* `ShipGroupUpgradeTech` union.
*/
export function isShipGroupUpgradeTech(
value: string,
): value is ShipGroupUpgradeTech {
return (SHIP_GROUP_UPGRADE_TECH_VALUES as readonly string[]).includes(value);
}
/**
* BreakShipGroupCommand splits a player-owned ship group into two:
* the original keeps `originalCount - quantity` ships and a new group
* with `newGroupId` carries `quantity`. Used both as a stand-alone
* action and as the implicit prelude to Send / Load / Unload /
* Modernize / Dismantle / Transfer when the player picks fewer than
* all ships. Engine rules (`controller/ship_group.go.breakGroup`):
* source group must be `StateInOrbit`, `quantity` must be in `[1,
* originalCount - 1]` for a real split. The new group carries a
* proportional slice of the cargo and starts unattached to any fleet.
*/
export interface BreakShipGroupCommand {
readonly kind: "breakShipGroup";
readonly id: string;
readonly groupId: string;
readonly newGroupId: string;
readonly quantity: number;
}
/**
* SendShipGroupCommand launches a player-owned ship group toward a
* destination planet. Engine rules
* (`controller/ship_group_send.go.shipGroupSend`): group must be
* `StateInOrbit`; ship class must have a non-zero drive block; the
* destination must be within the player's current
* `FlightDistance() = localPlayerDrive * 40` (torus-aware).
* The picker filters the planet list before emitting, so a draft
* entry that survives validation is always reachable at submit time.
*/
export interface SendShipGroupCommand {
readonly kind: "sendShipGroup";
readonly id: string;
readonly groupId: string;
readonly destinationPlanetNumber: number;
}
/**
* LoadShipGroupCommand loads cargo of one of the three ship-group
* cargo types onto a player-owned group. Engine rules
* (`controller/ship_group.go.shipGroupLoad`): group must be
* `StateInOrbit`; planet must be owned by the player or unowned;
* ship class must have a non-zero cargo block; the existing cargo
* type (if any) must equal `cargo`; `quantity` is bounded by the
* planet's stock and the group's free capacity. The inspector
* pre-checks each of these so a draft entry is always wire-valid.
*/
export interface LoadShipGroupCommand {
readonly kind: "loadShipGroup";
readonly id: string;
readonly groupId: string;
readonly cargo: ShipGroupCargo;
readonly quantity: number;
}
/**
* UnloadShipGroupCommand drops cargo from a player-owned group at
* its current orbit. Engine rules
* (`controller/ship_group.go.shipGroupUnload`): group must be
* `StateInOrbit`; ship class must have a non-zero cargo block; group
* must currently carry cargo. Colonists (`COL`) cannot be unloaded
* over a foreign planet — the inspector disables the action with a
* tooltip in that case. The cargo type is implicit (whatever the
* group is carrying); only `quantity` is sent on the wire.
*/
export interface UnloadShipGroupCommand {
readonly kind: "unloadShipGroup";
readonly id: string;
readonly groupId: string;
readonly quantity: number;
}
/**
* UpgradeShipGroupCommand schedules a tech upgrade for a player-
* owned group at its current orbit. Engine rules
* (`controller/ship_group_upgrade.go.shipGroupUpgrade`): group must
* be `StateInOrbit`; the planet must be owned by the player or
* unowned; for per-block techs the requested `level` must be in
* `(group.tech, race.tech]`; for `tech === "ALL"` the `level` must
* be 0 (the engine fans the upgrade out to every block whose mass is
* non-zero). The inspector renders a live cost preview through
* `core.blockUpgradeCost` to make the production cost visible before
* the player commits.
*/
export interface UpgradeShipGroupCommand {
readonly kind: "upgradeShipGroup";
readonly id: string;
readonly groupId: string;
readonly tech: ShipGroupUpgradeTech;
readonly level: number;
}
/**
* DismantleShipGroupCommand deconstructs a player-owned group at its
* current orbit, returning the empty mass to the planet's materials
* stockpile. Engine rules (`controller/ship_group.go.shipGroupDismantle`):
* group must be `StateInOrbit`; over a foreign planet, colonists
* (`COL`) on board are *lost* — the inspector surfaces an explicit
* two-step confirmation in that case before adding the command to
* the draft.
*/
export interface DismantleShipGroupCommand {
readonly kind: "dismantleShipGroup";
readonly id: string;
readonly groupId: string;
}
/**
* TransferShipGroupCommand hands a player-owned group to another
* race. Engine rules (`controller/ship_group.go.shipGroupTransfer`):
* acceptor must be a different, non-extinct race; group must not
* already be in `StateTransfer`. The inspector restricts the
* acceptor picker to `GameReport.otherRaces` (non-extinct ≠ self),
* so a draft entry always names a real race.
*/
export interface TransferShipGroupCommand {
readonly kind: "transferShipGroup";
readonly id: string;
readonly groupId: string;
readonly acceptor: string;
}
/**
* JoinFleetShipGroupCommand attaches a player-owned group to a fleet
* (creating it on the fly if no fleet by that name exists). Engine
* rules (`controller/fleet.go.ShipGroupJoinFleet`): group must be
* `StateInOrbit`; the target fleet, when it already exists, must
* sit in the same orbit as the group; `name` must pass
* `validateEntityName`. Because the engine handles the whole-group
* attach atomically (no per-ship counter), this command does not
* support implicit-split — the inspector exposes Split as a
* separate explicit action when partial detachment is desired.
*/
export interface JoinFleetShipGroupCommand {
readonly kind: "joinFleetShipGroup";
readonly id: string;
readonly groupId: string;
readonly name: string;
}
/** /**
* OrderCommand is the discriminated union of every command shape the * OrderCommand is the discriminated union of every command shape the
* local order draft can hold. The `kind` field is the discriminator; * local order draft can hold. The `kind` field is the discriminator;
@@ -179,7 +382,15 @@ export type OrderCommand =
| SetCargoRouteCommand | SetCargoRouteCommand
| RemoveCargoRouteCommand | RemoveCargoRouteCommand
| CreateShipClassCommand | CreateShipClassCommand
| RemoveShipClassCommand; | RemoveShipClassCommand
| BreakShipGroupCommand
| SendShipGroupCommand
| LoadShipGroupCommand
| UnloadShipGroupCommand
| UpgradeShipGroupCommand
| DismantleShipGroupCommand
| TransferShipGroupCommand
| JoinFleetShipGroupCommand;
/** /**
* PRODUCTION_TYPE_VALUES is the canonical tuple of `ProductionType` * PRODUCTION_TYPE_VALUES is the canonical tuple of `ProductionType`
+158
View File
@@ -33,8 +33,18 @@ import {
CommandPlanetRouteSet, CommandPlanetRouteSet,
CommandShipClassCreate, CommandShipClassCreate,
CommandShipClassRemove, CommandShipClassRemove,
CommandShipGroupBreak,
CommandShipGroupDismantle,
CommandShipGroupJoinFleet,
CommandShipGroupLoad,
CommandShipGroupSend,
CommandShipGroupTransfer,
CommandShipGroupUnload,
CommandShipGroupUpgrade,
PlanetProduction, PlanetProduction,
PlanetRouteLoadType, PlanetRouteLoadType,
ShipGroupCargo,
ShipGroupUpgradeTech,
UserGamesOrder, UserGamesOrder,
UserGamesOrderResponse, UserGamesOrderResponse,
} from "../proto/galaxy/fbs/order"; } from "../proto/galaxy/fbs/order";
@@ -42,6 +52,8 @@ import type {
CargoLoadType, CargoLoadType,
OrderCommand, OrderCommand,
ProductionType, ProductionType,
ShipGroupCargo as ShipGroupCargoLiteral,
ShipGroupUpgradeTech as ShipGroupUpgradeTechLiteral,
} from "./order-types"; } from "./order-types";
const MESSAGE_TYPE = "user.games.order"; const MESSAGE_TYPE = "user.games.order";
@@ -222,6 +234,109 @@ function encodeCommandPayload(
payloadOffset: offset, payloadOffset: offset,
}; };
} }
case "breakShipGroup": {
const idOffset = builder.createString(cmd.groupId);
const newIdOffset = builder.createString(cmd.newGroupId);
CommandShipGroupBreak.startCommandShipGroupBreak(builder);
CommandShipGroupBreak.addId(builder, idOffset);
CommandShipGroupBreak.addNewId(builder, newIdOffset);
CommandShipGroupBreak.addQuantity(builder, BigInt(cmd.quantity));
const offset = CommandShipGroupBreak.endCommandShipGroupBreak(builder);
return {
payloadType: CommandPayload.CommandShipGroupBreak,
payloadOffset: offset,
};
}
case "sendShipGroup": {
const idOffset = builder.createString(cmd.groupId);
const offset = CommandShipGroupSend.createCommandShipGroupSend(
builder,
idOffset,
BigInt(cmd.destinationPlanetNumber),
);
return {
payloadType: CommandPayload.CommandShipGroupSend,
payloadOffset: offset,
};
}
case "loadShipGroup": {
const idOffset = builder.createString(cmd.groupId);
CommandShipGroupLoad.startCommandShipGroupLoad(builder);
CommandShipGroupLoad.addId(builder, idOffset);
CommandShipGroupLoad.addCargo(builder, shipGroupCargoToFBS(cmd.cargo));
CommandShipGroupLoad.addQuantity(builder, cmd.quantity);
const offset = CommandShipGroupLoad.endCommandShipGroupLoad(builder);
return {
payloadType: CommandPayload.CommandShipGroupLoad,
payloadOffset: offset,
};
}
case "unloadShipGroup": {
const idOffset = builder.createString(cmd.groupId);
const offset = CommandShipGroupUnload.createCommandShipGroupUnload(
builder,
idOffset,
cmd.quantity,
);
return {
payloadType: CommandPayload.CommandShipGroupUnload,
payloadOffset: offset,
};
}
case "upgradeShipGroup": {
const idOffset = builder.createString(cmd.groupId);
CommandShipGroupUpgrade.startCommandShipGroupUpgrade(builder);
CommandShipGroupUpgrade.addId(builder, idOffset);
CommandShipGroupUpgrade.addTech(
builder,
shipGroupUpgradeTechToFBS(cmd.tech),
);
CommandShipGroupUpgrade.addLevel(builder, cmd.level);
const offset = CommandShipGroupUpgrade.endCommandShipGroupUpgrade(builder);
return {
payloadType: CommandPayload.CommandShipGroupUpgrade,
payloadOffset: offset,
};
}
case "dismantleShipGroup": {
const idOffset = builder.createString(cmd.groupId);
const offset =
CommandShipGroupDismantle.createCommandShipGroupDismantle(
builder,
idOffset,
);
return {
payloadType: CommandPayload.CommandShipGroupDismantle,
payloadOffset: offset,
};
}
case "transferShipGroup": {
const idOffset = builder.createString(cmd.groupId);
const acceptorOffset = builder.createString(cmd.acceptor);
const offset = CommandShipGroupTransfer.createCommandShipGroupTransfer(
builder,
idOffset,
acceptorOffset,
);
return {
payloadType: CommandPayload.CommandShipGroupTransfer,
payloadOffset: offset,
};
}
case "joinFleetShipGroup": {
const idOffset = builder.createString(cmd.groupId);
const nameOffset = builder.createString(cmd.name);
const offset =
CommandShipGroupJoinFleet.createCommandShipGroupJoinFleet(
builder,
idOffset,
nameOffset,
);
return {
payloadType: CommandPayload.CommandShipGroupJoinFleet,
payloadOffset: offset,
};
}
case "placeholder": case "placeholder":
throw new SubmitError( throw new SubmitError(
"invalid_request", "invalid_request",
@@ -277,6 +392,49 @@ export function cargoLoadTypeToFBS(value: CargoLoadType): PlanetRouteLoadType {
} }
} }
/**
* shipGroupCargoToFBS converts the wire-stable `ShipGroupCargo`
* literal to the FlatBuffers enum value. Mirrors the engine
* `ShipGroupCargo` enum (`pkg/schema/fbs/order.fbs`). The FBS enum
* carries an `UNKNOWN` zero value as the default; the encoder always
* emits one of the three real values.
*/
export function shipGroupCargoToFBS(
value: ShipGroupCargoLiteral,
): ShipGroupCargo {
switch (value) {
case "COL":
return ShipGroupCargo.COL;
case "CAP":
return ShipGroupCargo.CAP;
case "MAT":
return ShipGroupCargo.MAT;
}
}
/**
* shipGroupUpgradeTechToFBS converts the wire-stable
* `ShipGroupUpgradeTech` literal to the FlatBuffers enum value.
* Mirrors the engine `ShipGroupUpgradeTech` enum
* (`pkg/schema/fbs/order.fbs`).
*/
export function shipGroupUpgradeTechToFBS(
value: ShipGroupUpgradeTechLiteral,
): ShipGroupUpgradeTech {
switch (value) {
case "ALL":
return ShipGroupUpgradeTech.ALL;
case "DRIVE":
return ShipGroupUpgradeTech.DRIVE;
case "WEAPONS":
return ShipGroupUpgradeTech.WEAPONS;
case "SHIELDS":
return ShipGroupUpgradeTech.SHIELDS;
case "CARGO":
return ShipGroupUpgradeTech.CARGO;
}
}
function decodeOrderResponse( function decodeOrderResponse(
payload: Uint8Array, payload: Uint8Array,
commands: OrderCommand[], commands: OrderCommand[],
Binary file not shown.
@@ -0,0 +1,254 @@
// Phase 20 end-to-end coverage for the ship-group Send action.
// Loads a synthetic report with a local group of three Frontier
// ships in orbit over Earth and a reachable destination planet
// (Mars), opens the inspector by clicking the rendered group,
// drives the Send form (asking for 2 ships out of 3), picks Mars
// through the map-pick service, and asserts the resulting order
// draft has both an implicit `breakShipGroup` and the targeted
// `sendShipGroup` whose `groupId` references the freshly minted
// sub-group ID. The synthetic flow uses a non-UUID game id, so
// the auto-sync pipeline skips the network — the assertion
// targets the in-memory draft via the order-tab UI.
import { expect, test, type Page } from "@playwright/test";
const SESSION_ID = "phase-20-send-session";
interface DebugSurface {
ready?: boolean;
loadSession(): Promise<unknown>;
clearSession?(): Promise<void>;
setDeviceSessionId(id: string): Promise<void>;
}
declare global {
interface Window {
__galaxyDebug?: DebugSurface;
}
}
const SYNTHETIC_FIXTURE = {
turn: 1,
mapWidth: 200,
mapHeight: 200,
mapPlanets: 2,
race: "Earthlings",
player: [
{
name: "Earthlings",
drive: 5,
weapons: 0,
shields: 0,
cargo: 1,
population: 1000,
industry: 1000,
planets: 1,
relation: "-",
votes: 0,
extinct: false,
},
{
name: "Aliens",
drive: 4,
weapons: 2,
shields: 1,
cargo: 1,
population: 800,
industry: 800,
planets: 1,
relation: "-",
votes: 0,
extinct: false,
},
],
localPlanet: [
{
number: 1,
name: "Earth",
x: 100,
y: 100,
size: 1000,
population: 1000,
industry: 1000,
resources: 10,
production: "Capital",
capital: 0,
material: 0,
colonists: 100,
freeIndustry: 1000,
},
],
otherPlanet: [
{
number: 2,
name: "Mars",
x: 110,
y: 100,
size: 800,
population: 800,
industry: 800,
resources: 8,
production: "Capital",
capital: 0,
material: 0,
colonists: 80,
freeIndustry: 800,
owner: "Aliens",
},
],
uninhabitedPlanet: [],
unidentifiedPlanet: [],
localShipClass: [
{
name: "Frontier",
drive: 5,
armament: 0,
weapons: 0,
shields: 0,
cargo: 1,
mass: 12,
},
],
localGroup: [
{
id: "11111111-2222-3333-4444-555555555555",
number: 3,
class: "Frontier",
tech: { drive: 5, weapons: 0, shields: 0, cargo: 1 },
cargo: "-",
load: 0,
destination: 1,
speed: 25,
mass: 12,
state: "In_Orbit",
},
],
otherGroup: [],
incomingGroup: [],
unidentifiedGroup: [],
localFleet: [],
};
async function bootSession(page: Page): Promise<void> {
await page.goto("/__debug/store");
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
await page.waitForFunction(
() => window.__galaxyDebug?.ready === true,
);
await page.evaluate(async () => {
const debug = window.__galaxyDebug!;
await debug.loadSession();
await debug.setDeviceSessionId("phase-20-send-session");
});
void SESSION_ID;
}
async function loadSyntheticGame(page: Page): Promise<void> {
await page.goto("/lobby");
await expect(page.getByTestId("lobby-synthetic-section")).toBeVisible();
const file = page.getByTestId("lobby-synthetic-file");
await file.setInputFiles({
name: "phase20.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(SYNTHETIC_FIXTURE)),
});
await page.waitForURL(/\/games\/synthetic-[^/]+\/map$/, {
timeout: 10_000,
});
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
}
// projectWorldToScreen returns the pixel coordinates of a world-space
// point (x, y) relative to the document, using the renderer's
// debug-surface camera snapshot. Waits for the renderer to register
// its debug providers (the in-game shell calls
// `installRendererDebugSurface` on mount, then the providers attach
// when `mountRenderer` resolves) so the spec is robust against the
// async Pixi boot.
async function projectWorldToScreen(
page: Page,
x: number,
y: number,
): Promise<{ x: number; y: number }> {
await page.waitForFunction(() => {
const dbg = window.__galaxyDebug as unknown as
| { getMapCamera(): unknown }
| undefined;
if (dbg === undefined) return false;
return dbg.getMapCamera() !== null;
});
return page.evaluate(({ wx, wy }) => {
const debug = window.__galaxyDebug as unknown as {
getMapCamera(): {
camera: { centerX: number; centerY: number; scale: number };
viewport: { widthPx: number; heightPx: number };
canvasOrigin: { x: number; y: number };
} | null;
};
const cam = debug.getMapCamera();
if (cam === null) throw new Error("camera unavailable");
const sx = cam.canvasOrigin.x + cam.viewport.widthPx / 2 +
(wx - cam.camera.centerX) * cam.camera.scale;
const sy = cam.canvasOrigin.y + cam.viewport.heightPx / 2 +
(wy - cam.camera.centerY) * cam.camera.scale;
return { x: sx, y: sy };
}, { wx: x, wy: y });
}
test("send 2 of 3 ships emits implicit Break + Send into the order draft", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"phase 20 spec covers desktop layout; mobile inherits the same store",
);
await bootSession(page);
await loadSyntheticGame(page);
// On-planet ship groups are *not* rendered as map primitives (the
// renderer hides them to avoid crowding); the player navigates to
// them through the planet inspector's stationed-ship row, which
// pivots the SelectionStore to the ship-group variant.
const earthScreen = await projectWorldToScreen(page, 100, 100);
await page.mouse.click(earthScreen.x, earthScreen.y);
const sidebar = page.getByTestId("sidebar-tool-inspector");
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth");
await sidebar
.getByTestId("inspector-planet-ship-groups-select")
.first()
.click();
await expect(
sidebar.getByTestId("inspector-ship-group-class"),
).toHaveText("Frontier");
// Open Send.
await sidebar.getByTestId("inspector-ship-group-action-send").click();
const sendShips = sidebar.getByTestId("inspector-ship-group-form-send-ships");
await sendShips.fill("2");
// Pick Mars on the map.
await sidebar.getByTestId("inspector-ship-group-form-send-pick").click();
const marsScreen = await projectWorldToScreen(page, 110, 100);
await page.mouse.click(marsScreen.x, marsScreen.y);
await expect(
sidebar.getByTestId("inspector-ship-group-form-send-destination"),
).toContainText("Mars");
// Confirm.
await sidebar.getByTestId("inspector-ship-group-form-send-confirm").click();
// Verify the order tab carries both commands in submission order.
await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order");
await expect(orderTool.getByTestId("order-command-label-0")).toContainText(
"split group",
);
await expect(orderTool.getByTestId("order-command-label-1")).toContainText(
"send group",
);
});
+1
View File
@@ -217,5 +217,6 @@ function mockCore(opts: MockCoreOptions): Core & {
speed: () => 0, speed: () => 0,
cargoCapacity: () => 0, cargoCapacity: () => 0,
carryingMass: () => 0, carryingMass: () => 0,
blockUpgradeCost: () => 0,
}; };
} }
@@ -18,10 +18,12 @@ export const EMPTY_SHIP_GROUPS: {
incomingShipGroups: ReportIncomingShipGroup[]; incomingShipGroups: ReportIncomingShipGroup[];
unidentifiedShipGroups: ReportUnidentifiedShipGroup[]; unidentifiedShipGroups: ReportUnidentifiedShipGroup[];
localFleets: ReportLocalFleet[]; localFleets: ReportLocalFleet[];
otherRaces: string[];
} = { } = {
localShipGroups: [], localShipGroups: [],
otherShipGroups: [], otherShipGroups: [],
incomingShipGroups: [], incomingShipGroups: [],
unidentifiedShipGroups: [], unidentifiedShipGroups: [],
localFleets: [], localFleets: [],
otherRaces: [],
}; };
@@ -0,0 +1,264 @@
// Vitest coverage for Phase 20's ship-group action panel. Exercises
// the disabled-with-tooltip rules per action, the implicit-split
// pattern (an action targeting fewer ships than the group holds
// emits a `breakShipGroup` command before the action), and the
// happy-path commits of every variant. The dismantle confirmation
// for foreign-COL groups lives in its own file
// (`inspector-ship-group-dismantle-confirm.test.ts`); the modernize
// cost preview lives in `inspector-ship-group-modernize-cost.test.ts`.
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import type {
ReportLocalFleet,
ReportLocalShipGroup,
ReportPlanet,
ShipClassSummary,
} from "../src/api/game-state";
import ShipGroup, {
type ShipGroupSelection,
} from "../src/lib/inspectors/ship-group.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../src/sync/order-draft.svelte";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { Cache } from "../src/platform/store/index";
import type { IDBPDatabase } from "idb";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
let db: IDBPDatabase<GalaxyDB>;
let dbName: string;
let cache: Cache;
let draft: OrderDraftStore;
const PLANETS: ReportPlanet[] = [
planet({ number: 17, name: "Castle", x: 100, y: 100, kind: "local" }),
planet({ number: 99, name: "Outpost", x: 110, y: 110, kind: "other", owner: "Foreign" }),
planet({ number: 33, name: "Reach", x: 150, y: 150, kind: "uninhabited" }),
];
const SHIP_CLASS_FRONTIER: ShipClassSummary = {
name: "Frontier",
drive: 5,
armament: 0,
weapons: 0,
shields: 0,
cargo: 1,
};
beforeEach(async () => {
dbName = `galaxy-ship-group-actions-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
cache = new IDBCache(db);
draft = new OrderDraftStore();
await draft.init({ cache, gameId: GAME_ID });
i18n.resetForTests("en");
});
afterEach(async () => {
draft.dispose();
db.close();
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
});
function planet(
overrides: Partial<ReportPlanet> & Pick<ReportPlanet, "number" | "name" | "x" | "y" | "kind">,
): ReportPlanet {
return {
owner: null,
size: 1000,
resources: 5,
industryStockpile: 100,
materialsStockpile: 100,
industry: 100,
population: 100,
colonists: 100,
production: null,
freeIndustry: 100,
...overrides,
};
}
function localGroup(
overrides: Partial<ReportLocalShipGroup> = {},
): ReportLocalShipGroup {
return {
id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
count: 3,
class: "Frontier",
tech: { drive: 1, weapons: 0, shields: 0, cargo: 1 },
cargo: "NONE",
load: 0,
destination: 17,
origin: null,
range: null,
speed: 0,
mass: 12,
state: "In_Orbit",
fleet: null,
...overrides,
};
}
function mount(
group: ReportLocalShipGroup,
options: {
otherRaces?: string[];
localFleets?: ReportLocalFleet[];
localPlayerDrive?: number;
} = {},
) {
const selection: ShipGroupSelection = { variant: "local", group };
const context = new Map<unknown, unknown>([
[ORDER_DRAFT_CONTEXT_KEY, draft],
]);
return render(ShipGroup, {
props: {
selection,
planets: PLANETS,
localShipClass: [SHIP_CLASS_FRONTIER],
localFleets: options.localFleets ?? [],
otherRaces: options.otherRaces ?? ["Aliens"],
mapWidth: 1000,
mapHeight: 1000,
localPlayerDrive: options.localPlayerDrive ?? 5,
localPlayerWeapons: 1,
localPlayerShields: 1,
localPlayerCargo: 2,
},
context,
});
}
describe("ship-group inspector — action enablement", () => {
test("non-orbit groups disable every action with the busy tooltip", () => {
const ui = mount(localGroup({ state: "In_Space" }));
for (const id of [
"inspector-ship-group-action-split",
"inspector-ship-group-action-send",
"inspector-ship-group-action-load",
"inspector-ship-group-action-unload",
"inspector-ship-group-action-modernize",
"inspector-ship-group-action-dismantle",
"inspector-ship-group-action-transfer",
"inspector-ship-group-action-join-fleet",
]) {
const button = ui.getByTestId(id);
expect(button).toBeDisabled();
expect(button.getAttribute("title")).toMatch(/ships are busy/i);
}
});
test("send is disabled when no planet is in drive range", () => {
const ui = mount(localGroup({ destination: 17 }), { localPlayerDrive: 0 });
const button = ui.getByTestId("inspector-ship-group-action-send");
expect(button).toBeDisabled();
expect(button.getAttribute("title")).toMatch(/no planets are within drive range/i);
});
test("transfer is disabled when there are no other races", () => {
const ui = mount(localGroup(), { otherRaces: [] });
const button = ui.getByTestId("inspector-ship-group-action-transfer");
expect(button).toBeDisabled();
expect(button.getAttribute("title")).toMatch(/no other non-extinct races/i);
});
test("unload is disabled when the group carries no cargo", () => {
const ui = mount(localGroup({ cargo: "NONE", load: 0 }));
const button = ui.getByTestId("inspector-ship-group-action-unload");
expect(button).toBeDisabled();
expect(button.getAttribute("title")).toMatch(/empty/i);
});
test("unload of colonists is blocked over a foreign planet", () => {
const ui = mount(localGroup({ destination: 99, cargo: "COL", load: 1.5 }));
const button = ui.getByTestId("inspector-ship-group-action-unload");
expect(button).toBeDisabled();
expect(button.getAttribute("title")).toMatch(/colonists cannot be unloaded over a foreign planet/i);
});
test("load is blocked over a foreign planet", () => {
const ui = mount(localGroup({ destination: 99 }));
const button = ui.getByTestId("inspector-ship-group-action-load");
expect(button).toBeDisabled();
expect(button.getAttribute("title")).toMatch(/own or unowned planets/i);
});
});
describe("ship-group inspector — implicit split + action", () => {
test("split with K=1 of 3 emits a single breakShipGroup", async () => {
const ui = mount(localGroup({ count: 3 }));
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-split"));
const input = ui.getByTestId("inspector-ship-group-form-split-ships") as HTMLInputElement;
await fireEvent.input(input, { target: { value: "1" } });
await fireEvent.click(ui.getByTestId("inspector-ship-group-form-split-confirm"));
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
expect(cmd.kind).toBe("breakShipGroup");
if (cmd.kind !== "breakShipGroup") return;
expect(cmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
expect(cmd.quantity).toBe(1);
});
test("dismantle on the whole group emits a single dismantleShipGroup", async () => {
const ui = mount(localGroup({ count: 2 }));
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
await fireEvent.click(ui.getByTestId("inspector-ship-group-form-dismantle-confirm"));
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
expect(cmd.kind).toBe("dismantleShipGroup");
if (cmd.kind !== "dismantleShipGroup") return;
expect(cmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
});
test("dismantle on a subset emits implicit Break + Dismantle on the new group", async () => {
const ui = mount(localGroup({ count: 3 }));
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
const input = ui.getByTestId("inspector-ship-group-form-dismantle-ships") as HTMLInputElement;
await fireEvent.input(input, { target: { value: "2" } });
await fireEvent.click(ui.getByTestId("inspector-ship-group-form-dismantle-confirm"));
await waitFor(() => expect(draft.commands).toHaveLength(2));
const [breakCmd, action] = draft.commands;
if (breakCmd?.kind !== "breakShipGroup") throw new Error("expected break first");
if (action?.kind !== "dismantleShipGroup") throw new Error("expected dismantle second");
expect(breakCmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
expect(breakCmd.quantity).toBe(2);
expect(action.groupId).toBe(breakCmd.newGroupId);
});
test("transfer to the only available race emits a transferShipGroup", async () => {
const ui = mount(localGroup(), { otherRaces: ["Aliens"] });
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-transfer"));
await fireEvent.click(ui.getByTestId("inspector-ship-group-form-transfer-confirm"));
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "transferShipGroup") throw new Error("wrong kind");
expect(cmd.acceptor).toBe("Aliens");
expect(cmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
});
test("join fleet with a fresh name emits joinFleetShipGroup", async () => {
const ui = mount(localGroup());
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-join-fleet"));
const input = ui.getByTestId("inspector-ship-group-form-join-fleet-new") as HTMLInputElement;
await fireEvent.input(input, { target: { value: "Vanguard" } });
await fireEvent.click(ui.getByTestId("inspector-ship-group-form-join-fleet-confirm"));
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "joinFleetShipGroup") throw new Error("wrong kind");
expect(cmd.name).toBe("Vanguard");
expect(cmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
});
});
@@ -0,0 +1,201 @@
// Vitest coverage for the Phase 20 dismantle confirmation. The
// inspector requires an explicit second click ("colonists die") when
// the player tries to dismantle a colonist-laden group over a
// foreign planet — engine rule reference:
// `controller/ship_group.go.shipGroupDismantle:177-179` (over a
// foreign planet, `UnloadColonists` is not called and the cargo is
// lost).
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import type {
ReportLocalShipGroup,
ReportPlanet,
ShipClassSummary,
} from "../src/api/game-state";
import ShipGroup, {
type ShipGroupSelection,
} from "../src/lib/inspectors/ship-group.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../src/sync/order-draft.svelte";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { Cache } from "../src/platform/store/index";
import type { IDBPDatabase } from "idb";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
let db: IDBPDatabase<GalaxyDB>;
let dbName: string;
let cache: Cache;
let draft: OrderDraftStore;
const PLANETS: ReportPlanet[] = [
{
number: 99,
name: "Outpost",
x: 100,
y: 100,
kind: "other",
owner: "Foreign",
size: 500,
resources: 5,
industryStockpile: 0,
materialsStockpile: 0,
industry: 500,
population: 500,
colonists: 100,
production: "Capital",
freeIndustry: 500,
},
{
number: 17,
name: "Castle",
x: 50,
y: 50,
kind: "local",
owner: null,
size: 1000,
resources: 5,
industryStockpile: 0,
materialsStockpile: 0,
industry: 1000,
population: 1000,
colonists: 100,
production: "Capital",
freeIndustry: 1000,
},
];
const SHIP_CLASS_FRONTIER: ShipClassSummary = {
name: "Frontier",
drive: 5,
armament: 0,
weapons: 0,
shields: 0,
cargo: 1,
};
beforeEach(async () => {
dbName = `galaxy-ship-group-dismantle-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
cache = new IDBCache(db);
draft = new OrderDraftStore();
await draft.init({ cache, gameId: GAME_ID });
i18n.resetForTests("en");
});
afterEach(async () => {
draft.dispose();
db.close();
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
});
function group(
overrides: Partial<ReportLocalShipGroup> = {},
): ReportLocalShipGroup {
return {
id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
count: 2,
class: "Frontier",
tech: { drive: 1, weapons: 0, shields: 0, cargo: 1 },
cargo: "COL",
load: 1.5,
destination: 99,
origin: null,
range: null,
speed: 0,
mass: 12,
state: "In_Orbit",
fleet: null,
...overrides,
};
}
function mount(g: ReportLocalShipGroup) {
const selection: ShipGroupSelection = { variant: "local", group: g };
const context = new Map<unknown, unknown>([
[ORDER_DRAFT_CONTEXT_KEY, draft],
]);
return render(ShipGroup, {
props: {
selection,
planets: PLANETS,
localShipClass: [SHIP_CLASS_FRONTIER],
localFleets: [],
otherRaces: ["Aliens"],
mapWidth: 1000,
mapHeight: 1000,
localPlayerDrive: 5,
localPlayerWeapons: 1,
localPlayerShields: 1,
localPlayerCargo: 2,
},
context,
});
}
describe("ship-group inspector — dismantle confirmation", () => {
test("first click on dismantle of foreign-COL group shows the warning and adds nothing", async () => {
const ui = mount(group());
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
expect(
ui.getByTestId("inspector-ship-group-form-dismantle-warning"),
).toBeInTheDocument();
const confirm = ui.getByTestId(
"inspector-ship-group-form-dismantle-confirm",
);
expect(confirm).toHaveTextContent(/colonists die/i);
await fireEvent.click(confirm);
expect(draft.commands).toHaveLength(0);
});
test("second click on the colonists-die confirm emits dismantleShipGroup", async () => {
const ui = mount(group());
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
const confirm = ui.getByTestId(
"inspector-ship-group-form-dismantle-confirm",
);
await fireEvent.click(confirm);
await fireEvent.click(confirm);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
expect(cmd.kind).toBe("dismantleShipGroup");
});
test("dismantle over own planet skips the warning even with COL aboard", async () => {
const ui = mount(group({ destination: 17 }));
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
expect(
ui.queryByTestId("inspector-ship-group-form-dismantle-warning"),
).toBeNull();
await fireEvent.click(
ui.getByTestId("inspector-ship-group-form-dismantle-confirm"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
expect(draft.commands[0]!.kind).toBe("dismantleShipGroup");
});
test("dismantle over foreign planet without colonists skips the warning", async () => {
const ui = mount(group({ cargo: "NONE", load: 0 }));
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
expect(
ui.queryByTestId("inspector-ship-group-form-dismantle-warning"),
).toBeNull();
await fireEvent.click(
ui.getByTestId("inspector-ship-group-form-dismantle-confirm"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
});
});
@@ -0,0 +1,204 @@
// Vitest coverage for the Phase 20 modernize cost preview. The
// preview line in the inspector calls `core.blockUpgradeCost` once
// per ship block and multiplies the per-ship total by the number of
// targeted ships. The preview hides when `Core` is unavailable; when
// `tech === "ALL"` the targets are the player's race tech levels;
// otherwise only the picked block contributes to the cost.
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import { fireEvent, render } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import type {
ReportLocalShipGroup,
ReportPlanet,
ShipClassSummary,
} from "../src/api/game-state";
import ShipGroup, {
type ShipGroupSelection,
} from "../src/lib/inspectors/ship-group.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../src/sync/order-draft.svelte";
import { CORE_CONTEXT_KEY, CoreHolder } from "../src/lib/core-context.svelte";
import type { Core } from "../src/platform/core/index";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { Cache } from "../src/platform/store/index";
import type { IDBPDatabase } from "idb";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
let db: IDBPDatabase<GalaxyDB>;
let dbName: string;
let cache: Cache;
let draft: OrderDraftStore;
const PLANETS: ReportPlanet[] = [
{
number: 17,
name: "Castle",
x: 100,
y: 100,
kind: "local",
owner: null,
size: 1000,
resources: 5,
industryStockpile: 0,
materialsStockpile: 0,
industry: 1000,
population: 1000,
colonists: 0,
production: "Capital",
freeIndustry: 1000,
},
];
const SHIP_CLASS_CRUISER: ShipClassSummary = {
name: "Cruiser",
drive: 5,
armament: 0,
weapons: 0,
shields: 5,
cargo: 5,
};
beforeEach(async () => {
dbName = `galaxy-ship-group-modernize-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
cache = new IDBCache(db);
draft = new OrderDraftStore();
await draft.init({ cache, gameId: GAME_ID });
i18n.resetForTests("en");
});
afterEach(async () => {
draft.dispose();
db.close();
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
});
function group(
overrides: Partial<ReportLocalShipGroup> = {},
): ReportLocalShipGroup {
return {
id: "cccccccc-cccc-cccc-cccc-cccccccccccc",
count: 4,
class: "Cruiser",
tech: { drive: 1, weapons: 0, shields: 1, cargo: 1 },
cargo: "NONE",
load: 0,
destination: 17,
origin: null,
range: null,
speed: 0,
mass: 25,
state: "In_Orbit",
fleet: null,
...overrides,
};
}
// stubCore mirrors `pkg/calc/ship.go.BlockUpgradeCost` exactly so the
// preview line shows the same number the WASM bridge would produce.
// The other Core methods are no-ops because the modernize preview
// only consults `weaponsBlockMass` (returns null when armament is
// zero) and `blockUpgradeCost`.
function stubCore(): Core {
return {
signRequest: () => new Uint8Array(),
verifyResponse: () => true,
verifyEvent: () => true,
verifyPayloadHash: () => true,
driveEffective: ({ drive, driveTech }) => drive * driveTech,
emptyMass: () => 0,
weaponsBlockMass: ({ weapons, armament }) => {
if ((armament === 0 && weapons !== 0) || (armament !== 0 && weapons === 0)) {
return null;
}
return (armament + 1) * (weapons / 2);
},
fullMass: ({ emptyMass, carryingMass }) => emptyMass + carryingMass,
speed: () => 0,
cargoCapacity: () => 0,
carryingMass: () => 0,
blockUpgradeCost: ({ blockMass, currentTech, targetTech }) => {
if (blockMass === 0 || targetTech <= currentTech) return 0;
return (1 - currentTech / targetTech) * 10 * blockMass;
},
};
}
function mount(
g: ReportLocalShipGroup,
options: { core?: Core | null } = {},
) {
const selection: ShipGroupSelection = { variant: "local", group: g };
const holder = new CoreHolder();
if (options.core !== undefined) holder.set(options.core);
const context = new Map<unknown, unknown>([
[ORDER_DRAFT_CONTEXT_KEY, draft],
[CORE_CONTEXT_KEY, holder],
]);
return render(ShipGroup, {
props: {
selection,
planets: PLANETS,
localShipClass: [SHIP_CLASS_CRUISER],
localFleets: [],
otherRaces: [],
mapWidth: 1000,
mapHeight: 1000,
localPlayerDrive: 2,
localPlayerWeapons: 2,
localPlayerShields: 2,
localPlayerCargo: 2,
},
context,
});
}
describe("ship-group inspector — modernize cost preview", () => {
test("ALL upgrade preview matches the BlockUpgradeCost formula × ship count", async () => {
// drive: mass=5 current=1 target=2 → (1 - 0.5) * 10 * 5 = 25
// shields: mass=5 current=1 target=2 → 25
// cargo: mass=5 current=1 target=2 → 25
// weapons: armament=0 weapons=0 → block mass 0 → 0
// per-ship = 75; group of 4 → 300
const ui = mount(group(), { core: stubCore() });
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-modernize"));
const preview = ui.getByTestId("inspector-ship-group-form-modernize-cost");
expect(preview).toHaveTextContent("300");
});
test("per-block tech with custom level uses only that block", async () => {
// DRIVE only, target=2: 25 per ship × 4 = 100.
const ui = mount(group(), { core: stubCore() });
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-modernize"));
await fireEvent.change(
ui.getByTestId("inspector-ship-group-form-modernize-tech"),
{ target: { value: "DRIVE" } },
);
await fireEvent.input(
ui.getByTestId("inspector-ship-group-form-modernize-level"),
{ target: { value: "2" } },
);
const preview = ui.getByTestId("inspector-ship-group-form-modernize-cost");
expect(preview).toHaveTextContent("100");
});
test("preview is unavailable when Core is not loaded", async () => {
const ui = mount(group(), { core: null });
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-modernize"));
const preview = ui.getByTestId("inspector-ship-group-form-modernize-cost");
expect(preview).toHaveTextContent(/preview unavailable/i);
});
});
@@ -0,0 +1,244 @@
// Vitest coverage for the Phase 20 ship-group command shapes —
// `validateCommand` for each of the eight new variants. The
// validator is invoked through the public `OrderDraftStore.add`
// path so a regression in either layer surfaces here.
import "fake-indexeddb/auto";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { OrderDraftStore } from "../src/sync/order-draft.svelte";
import type { OrderCommand } from "../src/sync/order-types";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { Cache } from "../src/platform/store/index";
import type { IDBPDatabase } from "idb";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
const GROUP_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
const NEW_GROUP_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
let db: IDBPDatabase<GalaxyDB>;
let dbName: string;
let cache: Cache;
let draft: OrderDraftStore;
beforeEach(async () => {
dbName = `galaxy-validate-ship-group-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
cache = new IDBCache(db);
draft = new OrderDraftStore();
await draft.init({ cache, gameId: GAME_ID });
});
afterEach(async () => {
draft.dispose();
db.close();
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
});
async function statusOf(cmd: OrderCommand): Promise<string> {
await draft.add(cmd);
return draft.statuses[cmd.id]!;
}
describe("validateCommand — ship-group variants", () => {
test("breakShipGroup with positive quantity is valid", async () => {
expect(
await statusOf({
kind: "breakShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
newGroupId: NEW_GROUP_ID,
quantity: 2,
}),
).toBe("valid");
});
test("breakShipGroup with quantity 0 is invalid", async () => {
expect(
await statusOf({
kind: "breakShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
newGroupId: NEW_GROUP_ID,
quantity: 0,
}),
).toBe("invalid");
});
test("breakShipGroup with same source and new id is invalid", async () => {
expect(
await statusOf({
kind: "breakShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
newGroupId: GROUP_ID,
quantity: 1,
}),
).toBe("invalid");
});
test("sendShipGroup with positive destination is valid", async () => {
expect(
await statusOf({
kind: "sendShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
destinationPlanetNumber: 7,
}),
).toBe("valid");
});
test("sendShipGroup to planet 0 is invalid", async () => {
expect(
await statusOf({
kind: "sendShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
destinationPlanetNumber: 0,
}),
).toBe("invalid");
});
test("loadShipGroup with valid cargo and quantity is valid", async () => {
expect(
await statusOf({
kind: "loadShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
cargo: "COL",
quantity: 1.5,
}),
).toBe("valid");
});
test("loadShipGroup with zero quantity is invalid", async () => {
expect(
await statusOf({
kind: "loadShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
cargo: "COL",
quantity: 0,
}),
).toBe("invalid");
});
test("unloadShipGroup with positive quantity is valid", async () => {
expect(
await statusOf({
kind: "unloadShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
quantity: 0.5,
}),
).toBe("valid");
});
test("upgradeShipGroup ALL with level 0 is valid", async () => {
expect(
await statusOf({
kind: "upgradeShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
tech: "ALL",
level: 0,
}),
).toBe("valid");
});
test("upgradeShipGroup ALL with non-zero level is invalid", async () => {
expect(
await statusOf({
kind: "upgradeShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
tech: "ALL",
level: 2,
}),
).toBe("invalid");
});
test("upgradeShipGroup DRIVE with positive level is valid", async () => {
expect(
await statusOf({
kind: "upgradeShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
tech: "DRIVE",
level: 1.5,
}),
).toBe("valid");
});
test("upgradeShipGroup DRIVE with level 0 is invalid", async () => {
expect(
await statusOf({
kind: "upgradeShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
tech: "DRIVE",
level: 0,
}),
).toBe("invalid");
});
test("dismantleShipGroup with valid uuid is valid", async () => {
expect(
await statusOf({
kind: "dismantleShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
}),
).toBe("valid");
});
test("transferShipGroup with valid acceptor name is valid", async () => {
expect(
await statusOf({
kind: "transferShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
acceptor: "Aliens",
}),
).toBe("valid");
});
test("transferShipGroup with empty acceptor is invalid", async () => {
expect(
await statusOf({
kind: "transferShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
acceptor: "",
}),
).toBe("invalid");
});
test("joinFleetShipGroup with valid name is valid", async () => {
expect(
await statusOf({
kind: "joinFleetShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
name: "Vanguard",
}),
).toBe("valid");
});
test("joinFleetShipGroup with empty name is invalid", async () => {
expect(
await statusOf({
kind: "joinFleetShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
name: "",
}),
).toBe("invalid");
});
});
@@ -0,0 +1,266 @@
// Vitest round-trip coverage for the eight Phase 20 ship-group
// command shapes. The encoder lives in `sync/submit.ts`; the
// decoder lives in `sync/order-load.ts`. We capture the request
// bytes the encoder produces, re-emit them inside a
// `UserGamesOrderGetResponse` envelope, and feed that to
// `fetchOrder`. The decoded command must match the original — any
// drift between encoder and decoder fails here first.
import { Builder, ByteBuffer } from "flatbuffers";
import { describe, expect, test, vi } from "vitest";
import type { GalaxyClient } from "../src/api/galaxy-client";
import { uuidToHiLo } from "../src/api/game-state";
import { UUID } from "../src/proto/galaxy/fbs/common";
import {
CommandItem,
CommandPayload,
CommandShipGroupBreak,
CommandShipGroupDismantle,
CommandShipGroupJoinFleet,
CommandShipGroupLoad,
CommandShipGroupSend,
CommandShipGroupTransfer,
CommandShipGroupUnload,
CommandShipGroupUpgrade,
UserGamesOrder,
UserGamesOrderGetResponse,
UserGamesOrderResponse,
} from "../src/proto/galaxy/fbs/order";
import { fetchOrder } from "../src/sync/order-load";
import { submitOrder } from "../src/sync/submit";
import type { OrderCommand } from "../src/sync/order-types";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
const GROUP_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
function mockClient(
executeCommand: (
messageType: string,
payload: Uint8Array,
) => Promise<{ resultCode: string; payloadBytes: Uint8Array }>,
): GalaxyClient {
return { executeCommand } as unknown as GalaxyClient;
}
// captureRequestBytes runs submitOrder against a mock that records
// the outgoing payload, then returns those bytes (which are a valid
// `UserGamesOrder` envelope).
async function captureRequestBytes(cmds: OrderCommand[]): Promise<Uint8Array> {
let captured: Uint8Array | null = null;
const exec = vi.fn(async (_msg: string, payload: Uint8Array) => {
captured = payload;
const builder = new Builder(64);
const [hi, lo] = uuidToHiLo(GAME_ID);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(0));
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
builder.finish(offset);
return { resultCode: "ok", payloadBytes: builder.asUint8Array() };
});
const result = await submitOrder(mockClient(exec), GAME_ID, cmds);
expect(result.ok).toBe(true);
expect(captured).not.toBeNull();
return captured!;
}
// wrapAsGetResponse rebuilds the captured `UserGamesOrder` inside a
// `UserGamesOrderGetResponse` envelope by walking each
// `CommandItem`, copying its identity fields, and re-packing each
// payload through `unpack().pack(builder)` — the FBS-generated
// helper that round-trips a typed table into a fresh builder.
function wrapAsGetResponse(orderBytes: Uint8Array): Uint8Array {
const order = UserGamesOrder.getRootAsUserGamesOrder(
new ByteBuffer(orderBytes),
);
const builder = new Builder(256);
const itemOffsets: number[] = [];
for (let i = 0; i < order.commandsLength(); i++) {
const item = order.commands(i);
if (item === null) continue;
const cmdIdOffset = builder.createString(item.cmdId() ?? "");
const payloadType = item.payloadType();
const payloadOffset = packPayload(builder, item, payloadType);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addPayloadType(builder, payloadType);
CommandItem.addPayload(builder, payloadOffset);
itemOffsets.push(CommandItem.endCommandItem(builder));
}
const commandsVec = UserGamesOrder.createCommandsVector(builder, itemOffsets);
const [hi, lo] = uuidToHiLo(GAME_ID);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrder.startUserGamesOrder(builder);
UserGamesOrder.addGameId(builder, gameIdOffset);
UserGamesOrder.addUpdatedAt(builder, order.updatedAt());
UserGamesOrder.addCommands(builder, commandsVec);
const orderOffset = UserGamesOrder.endUserGamesOrder(builder);
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
UserGamesOrderGetResponse.addFound(builder, true);
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
const resOffset =
UserGamesOrderGetResponse.endUserGamesOrderGetResponse(builder);
builder.finish(resOffset);
return builder.asUint8Array();
}
function packPayload(
builder: Builder,
item: NonNullable<ReturnType<UserGamesOrder["commands"]>>,
payloadType: CommandPayload,
): number {
switch (payloadType) {
case CommandPayload.CommandShipGroupBreak: {
const inner = new CommandShipGroupBreak();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupSend: {
const inner = new CommandShipGroupSend();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupLoad: {
const inner = new CommandShipGroupLoad();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupUnload: {
const inner = new CommandShipGroupUnload();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupUpgrade: {
const inner = new CommandShipGroupUpgrade();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupDismantle: {
const inner = new CommandShipGroupDismantle();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupTransfer: {
const inner = new CommandShipGroupTransfer();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupJoinFleet: {
const inner = new CommandShipGroupJoinFleet();
item.payload(inner);
return inner.unpack().pack(builder);
}
default:
throw new Error(`unsupported payload type ${payloadType}`);
}
}
async function roundTrip(cmd: OrderCommand): Promise<OrderCommand> {
const requestBytes = await captureRequestBytes([cmd]);
const responseBytes = wrapAsGetResponse(requestBytes);
const exec = vi.fn(async () => ({
resultCode: "ok",
payloadBytes: responseBytes,
}));
const result = await fetchOrder(mockClient(exec), GAME_ID, 0);
expect(result.commands).toHaveLength(1);
return result.commands[0]!;
}
describe("submit + order-load round-trip — ship-group commands", () => {
test("breakShipGroup", async () => {
const cmd: OrderCommand = {
kind: "breakShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
newGroupId: "11112222-3333-4444-5555-666677778888",
quantity: 3,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("sendShipGroup", async () => {
const cmd: OrderCommand = {
kind: "sendShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
destinationPlanetNumber: 42,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("loadShipGroup", async () => {
const cmd: OrderCommand = {
kind: "loadShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
cargo: "MAT",
quantity: 12.5,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("unloadShipGroup", async () => {
const cmd: OrderCommand = {
kind: "unloadShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
quantity: 6.5,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("upgradeShipGroup ALL", async () => {
const cmd: OrderCommand = {
kind: "upgradeShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
tech: "ALL",
level: 0,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("upgradeShipGroup DRIVE level 1.5", async () => {
const cmd: OrderCommand = {
kind: "upgradeShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
tech: "DRIVE",
level: 1.5,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("dismantleShipGroup", async () => {
const cmd: OrderCommand = {
kind: "dismantleShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("transferShipGroup", async () => {
const cmd: OrderCommand = {
kind: "transferShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
acceptor: "Aliens",
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("joinFleetShipGroup", async () => {
const cmd: OrderCommand = {
kind: "joinFleetShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
name: "Vanguard",
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
});
+17
View File
@@ -25,6 +25,7 @@
// - speed(fields) -> number // - speed(fields) -> number
// - cargoCapacity(fields) -> number // - cargoCapacity(fields) -> number
// - carryingMass(fields) -> number // - carryingMass(fields) -> number
// - blockUpgradeCost(fields) -> number (Phase 20: modernize cost preview)
// //
// Field objects are plain JS objects with camelCase keys matching the // Field objects are plain JS objects with camelCase keys matching the
// TypeScript `Core` interface, and bytes fields are Uint8Array. // TypeScript `Core` interface, and bytes fields are Uint8Array.
@@ -59,6 +60,7 @@ func main() {
"speed": js.FuncOf(speed), "speed": js.FuncOf(speed),
"cargoCapacity": js.FuncOf(cargoCapacity), "cargoCapacity": js.FuncOf(cargoCapacity),
"carryingMass": js.FuncOf(carryingMass), "carryingMass": js.FuncOf(carryingMass),
"blockUpgradeCost": js.FuncOf(blockUpgradeCost),
})) }))
// Block forever so the Go runtime stays alive while JS keeps calling // Block forever so the Go runtime stays alive while JS keeps calling
@@ -224,6 +226,21 @@ func carryingMass(_ js.Value, args []js.Value) any {
return js.ValueOf(calc.CarryingMass(load, cargoTech)) return js.ValueOf(calc.CarryingMass(load, cargoTech))
} }
// blockUpgradeCost bridges `calc.BlockUpgradeCost`. Input
// `{ blockMass, currentTech, targetTech }`, output a JS number
// (production cost of moving one block from currentTech to
// targetTech; zero when blockMass is zero or targetTech is not
// above currentTech).
func blockUpgradeCost(_ js.Value, args []js.Value) any {
if len(args) != 1 {
return js.Null()
}
blockMass := args[0].Get("blockMass").Float()
currentTech := args[0].Get("currentTech").Float()
targetTech := args[0].Get("targetTech").Float()
return js.ValueOf(calc.BlockUpgradeCost(blockMass, currentTech, targetTech))
}
// copyBytesFromJS materialises a JS Uint8Array (or any indexable // copyBytesFromJS materialises a JS Uint8Array (or any indexable
// byte-shaped value) into a Go byte slice. We avoid `js.CopyBytesToGo` // byte-shaped value) into a Go byte slice. We avoid `js.CopyBytesToGo`
// because TinyGo's implementation panics on values it does not // because TinyGo's implementation panics on values it does not