diff --git a/game/internal/controller/ship_group_upgrade.go b/game/internal/controller/ship_group_upgrade.go index 3dda566..0ec3593 100644 --- a/game/internal/controller/ship_group_upgrade.go +++ b/game/internal/controller/ship_group_upgrade.go @@ -5,6 +5,7 @@ import ( "slices" "strings" + "galaxy/calc" e "galaxy/error" "galaxy/game/internal/model/game" @@ -156,26 +157,19 @@ func (uc UpgradeCalc) UpgradeMaxShips(resources float64) uint { 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 { uc := &UpgradeCalc{Cost: make(map[game.Tech]float64)} 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 { - 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 { - 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 { - 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 } @@ -218,7 +212,7 @@ func UpgradeGroupPreference(sg game.ShipGroup, st game.ShipType, tech game.Tech, ti = len(su.UpgradeTech) - 1 } 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 return sg diff --git a/game/internal/controller/ship_group_upgrade_test.go b/game/internal/controller/ship_group_upgrade_test.go index 393abdf..df885fc 100644 --- a/game/internal/controller/ship_group_upgrade_test.go +++ b/game/internal/controller/ship_group_upgrade_test.go @@ -13,12 +13,6 @@ import ( "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) { sg := &g.ShipGroup{ Tech: map[g.Tech]g.Float{ diff --git a/pkg/calc/ship.go b/pkg/calc/ship.go index 0b96271..b0919f3 100644 --- a/pkg/calc/ship.go +++ b/pkg/calc/ship.go @@ -52,6 +52,17 @@ func WeaponsBlockMass(weapons float64, armament uint) (float64, bool) { 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( attackingWeapons, attackingWeaponsTech, diff --git a/pkg/calc/ship_test.go b/pkg/calc/ship_test.go new file mode 100644 index 0000000..09b0ba1 --- /dev/null +++ b/pkg/calc/ship_test.go @@ -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) + } + }) + } +} diff --git a/ui/PLAN.md b/ui/PLAN.md index 295578d..7fe2776 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -2135,27 +2135,63 @@ Targeted tests: - Playwright e2e: click each variant from a seeded game, assert all 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, unload, modernize, dismantle, transfer to race, add to fleet. Artifacts: -- action buttons in `ui/frontend/src/lib/inspectors/ship-group.svelte` - with disabled-state and tooltip when local validation rejects -- `ui/frontend/src/sync/order-types.ts` extends with `SplitGroup`, - `SendGroup`, `LoadCargo`, `UnloadCargo`, `Modernize`, `Dismantle`, - `TransferToRace`, `AssignToFleet` command variants -- `Send` action picks destination through a planet picker filtered by - the group's reach (uses `pkg/calc/` reach function via Core; the - player's tech levels are already on `GameReport.localPlayer*` from - Phase 18, no extra plumbing needed) -- `Modernize` cost preview using `pkg/calc/` formula via Core -- confirmation dialog for `Dismantle` over a foreign planet with - colonists onboard (special-case from [`rules.txt`](../game/rules.txt): colonists die) +- action panel `ui/frontend/src/lib/inspectors/ship-group/actions.svelte` + mounted by the read-only inspector for the local variant; eight + inline forms (one per action) with disabled-button tooltips that + mirror the engine's pre-conditions + (`controller/ship_group*.go`) +- `ui/frontend/src/sync/order-types.ts` extends with eight new + command variants — `breakShipGroup`, `sendShipGroup`, + `loadShipGroup`, `unloadShipGroup`, `upgradeShipGroup`, + `dismantleShipGroup`, `transferShipGroup`, `joinFleetShipGroup` — + plus `ShipGroupCargo` and `ShipGroupUpgradeTech` literal types +- `sync/submit.ts` and `sync/order-load.ts` round-trip every new + 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. @@ -2171,10 +2207,61 @@ Acceptance criteria: Targeted tests: -- Vitest unit tests for action enablement logic per action; -- Vitest component tests for the dismantle-with-colonists confirmation; -- Playwright e2e for at least one complete flow (send a group between - two planets) against a local stack. +- `pkg/calc/ship_test.go.TestBlockUpgradeCost` — formula coverage + on the migrated function; +- `ui/core/calc/ship_test.go.TestBlockUpgradeCostParity` — bridge + 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 @@ -2226,7 +2313,12 @@ Artifacts: - `ui/frontend/src/routes/games/[id]/table/races/+page.svelte` table with one row per race, including name, tech levels, total 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 `SetDiplomaticStance` command) - voting control: a single slot for `give my votes to ` (adds diff --git a/ui/core/calc/ship.go b/ui/core/calc/ship.go index d1bf5e8..1a446c9 100644 --- a/ui/core/calc/ship.go +++ b/ui/core/calc/ship.go @@ -55,3 +55,13 @@ func CargoCapacity(cargo, cargoTech float64) float64 { func CarryingMass(load, cargoTech float64) float64 { 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) +} diff --git a/ui/core/calc/ship_test.go b/ui/core/calc/ship_test.go index de6f3c1..529802f 100644 --- a/ui/core/calc/ship_test.go +++ b/ui/core/calc/ship_test.go @@ -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 // ship-class designer performs: empty mass, full-load mass via // CarryingMass(CargoCapacity), max speed at empty, and range at full diff --git a/ui/docs/calc-bridge.md b/ui/docs/calc-bridge.md index 15446a2..6d88ebb 100644 --- a/ui/docs/calc-bridge.md +++ b/ui/docs/calc-bridge.md @@ -9,30 +9,33 @@ Go → WASM → TypeScript bridge mounted under `ui/core/calc/` and a matching TS adapter in `ui/frontend/src/platform/core/`. Phase 18 lands the **ship-math slice** of the bridge — everything -the ship-class designer needs to render its preview pane. Other -slices (production forecast, science research, ship build progress) -remain deferred to dedicated future 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. +the ship-class designer needs to render its preview pane. Phase 20 +extends it with `BlockUpgradeCost` so the ship-group inspector can +preview modernize cost. Other slices (production forecast, science +research, ship build progress) remain deferred to dedicated future +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 -seven thin wrappers around `pkg/calc/ship.go`. Each is a one-line -passthrough — the bridge contains zero math. The same seven names -appear on the JS-side `globalThis.galaxyCore` (registered in +thin wrappers around `pkg/calc/ship.go`. Each is a one-line +passthrough — the bridge contains zero math. The same names appear +on the JS-side `globalThis.galaxyCore` (registered in `ui/wasm/main.go`) and on the typed `Core` interface (`ui/frontend/src/platform/core/index.ts`). -| Bridge function | `pkg/calc/` source | JS return shape | Used by | -| ------------------ | --------------------------------------------------- | --------------- | -------------------------------- | -| `driveEffective` | `calc.DriveEffective(drive, driveTech)` | `number` | designer preview (`Speed` input) | -| `emptyMass` | `calc.EmptyMass(drive, weapons, armament, …)` | `number\|null` | designer preview (mass row) | -| `weaponsBlockMass` | `calc.WeaponsBlockMass(weapons, armament)` | `number\|null` | reserved for future stages | -| `fullMass` | `calc.FullMass(emptyMass, carryingMass)` | `number` | designer preview (full-load row) | -| `speed` | `calc.Speed(driveEffective, fullMass)` | `number` | designer preview (speed + range) | -| `cargoCapacity` | `calc.CargoCapacity(cargo, cargoTech)` | `number` | designer preview (cargo row) | -| `carryingMass` | `calc.CarryingMass(load, cargoTech)` | `number` | designer preview (full-load mass)| +| Bridge function | `pkg/calc/` source | JS return shape | Used by | +| ------------------- | -------------------------------------------------------- | --------------- | ---------------------------------------- | +| `driveEffective` | `calc.DriveEffective(drive, driveTech)` | `number` | designer preview (`Speed` input) | +| `emptyMass` | `calc.EmptyMass(drive, weapons, armament, …)` | `number\|null` | designer preview (mass row) | +| `weaponsBlockMass` | `calc.WeaponsBlockMass(weapons, armament)` | `number\|null` | designer preview, modernize cost preview | +| `fullMass` | `calc.FullMass(emptyMass, carryingMass)` | `number` | designer preview (full-load row) | +| `speed` | `calc.Speed(driveEffective, fullMass)` | `number` | designer preview (speed + range) | +| `cargoCapacity` | `calc.CargoCapacity(cargo, cargoTech)` | `number` | designer preview (cargo row) | +| `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 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). | | `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`). | +| `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`). | 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? | | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | :-------------: | :-------------: | | 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 | | 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 | diff --git a/ui/docs/ship-group-actions.md b/ui/docs/ship-group-actions.md new file mode 100644 index 0000000..eb5cf98 --- /dev/null +++ b/ui/docs/ship-group-actions.md @@ -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. diff --git a/ui/frontend/src/api/game-state.ts b/ui/frontend/src/api/game-state.ts index 3078bc1..2475840 100644 --- a/ui/frontend/src/api/game-state.ts +++ b/ui/frontend/src/api/game-state.ts @@ -272,6 +272,18 @@ export interface GameReport { incomingShipGroups: ReportIncomingShipGroup[]; unidentifiedShipGroups: ReportUnidentifiedShipGroup[]; 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( @@ -405,6 +417,7 @@ function decodeReport(report: Report): GameReport { const raceName = report.race() ?? ""; const routes = decodeReportRoutes(report); const localTech = findLocalPlayerTech(report, raceName); + const otherRaces = collectOtherRaces(report, raceName); const localShipGroups = decodeLocalShipGroups(report); const otherShipGroups = decodeOtherShipGroups(report); const incomingShipGroups = decodeIncomingShipGroups(report); @@ -429,6 +442,7 @@ function decodeReport(report: Report): GameReport { incomingShipGroups, unidentifiedShipGroups, localFleets, + otherRaces, }; } @@ -705,6 +719,27 @@ function findLocalPlayerTech( 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 * (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian diff --git a/ui/frontend/src/api/synthetic-report.ts b/ui/frontend/src/api/synthetic-report.ts index a3b6915..d9e3398 100644 --- a/ui/frontend/src/api/synthetic-report.ts +++ b/ui/frontend/src/api/synthetic-report.ts @@ -102,6 +102,7 @@ interface SyntheticPlayer { weapons: number; shields: number; cargo: number; + extinct?: boolean; } interface SyntheticShipGroup { @@ -269,9 +270,25 @@ function decodeSyntheticReport(json: unknown): GameReport { incomingShipGroups, unidentifiedShipGroups, 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 | undefined): ShipGroupTech { const out: ShipGroupTech = { drive: 0, weapons: 0, shields: 0, cargo: 0 }; if (raw === undefined || raw === null) return out; diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index 8c37f75..df699b8 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -194,6 +194,14 @@ const en = { "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_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.column.name": "name", "game.table.ship_classes.column.drive": "drive", @@ -276,6 +284,54 @@ const en = { "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.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.row.count": "{count} ships", "game.inspector.planet.ship_groups.row.mass": "mass {mass}", diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index 2f2512d..4f91cb8 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -195,6 +195,14 @@ const ru: Record = { "game.sidebar.order.label.cargo_route_remove": "удалить маршрут {loadType} с планеты {source}", "game.sidebar.order.label.ship_class_create": "сконструировать класс корабля {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.column.name": "название", "game.table.ship_classes.column.drive": "двигатель", @@ -277,6 +285,54 @@ const ru: Record = { "game.inspector.ship_group.fleet.none": "—", "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.row.count": "{count} кораблей", "game.inspector.planet.ship_groups.row.mass": "масса {mass}", diff --git a/ui/frontend/src/lib/inspectors/planet/ship-groups.svelte b/ui/frontend/src/lib/inspectors/planet/ship-groups.svelte index e5131c5..fe5270b 100644 --- a/ui/frontend/src/lib/inspectors/planet/ship-groups.svelte +++ b/ui/frontend/src/lib/inspectors/planet/ship-groups.svelte @@ -1,29 +1,37 @@ {#if stationedRows.length > 0} @@ -83,20 +106,45 @@ deep-link into that table with a `(planet, race)` filter pre-applied.
    {#each stationedRows as row (row.key)}
  • - - {row.race} - - {row.class} - - {i18n.t("game.inspector.planet.ship_groups.row.count", { - count: String(row.count), - })} - - - {i18n.t("game.inspector.planet.ship_groups.row.mass", { - mass: formatNumber(row.mass), - })} - + {#if row.selectable && row.groupId !== null} + {@const groupId = row.groupId} + + {:else} + + {row.race} + + {row.class} + + {i18n.t("game.inspector.planet.ship_groups.row.count", { + count: String(row.count), + })} + + + {i18n.t("game.inspector.planet.ship_groups.row.mass", { + mass: formatNumber(row.mass), + })} + + {/if}
  • {/each}
@@ -125,11 +173,30 @@ deep-link into that table with a `(planet, race)` filter pre-applied. gap: 0.2rem; } .row { + display: block; + font-size: 0.85rem; + font-variant-numeric: tabular-nums; + } + .row > span, + .row > .select { display: grid; grid-template-columns: 1fr 1fr auto auto; 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 { font-weight: 600; diff --git a/ui/frontend/src/lib/inspectors/ship-group-sheet.svelte b/ui/frontend/src/lib/inspectors/ship-group-sheet.svelte index 314785c..26e1509 100644 --- a/ui/frontend/src/lib/inspectors/ship-group-sheet.svelte +++ b/ui/frontend/src/lib/inspectors/ship-group-sheet.svelte @@ -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. --> {#if selection !== null && onMap} @@ -34,7 +61,19 @@ mounted by the in-game shell layout only while the active tool is > ✕ - + {/if} diff --git a/ui/frontend/src/lib/inspectors/ship-group.svelte b/ui/frontend/src/lib/inspectors/ship-group.svelte index dc2d40a..8c5a440 100644 --- a/ui/frontend/src/lib/inspectors/ship-group.svelte +++ b/ui/frontend/src/lib/inspectors/ship-group.svelte @@ -12,12 +12,15 @@ variant — for Phase 19 the inspector is intentionally read-only. + +
+
+ + + + + + + + +
+ + {#if openForm === "split"} +
{ e.preventDefault(); void confirmSplit(); }}> + +
+ + +
+
+ {/if} + + {#if openForm === "send"} +
{ e.preventDefault(); void confirmSend(); }}> + +
+ {i18n.t("game.inspector.ship_group.action.field.destination")} + + {#if sendDestination !== null} + {planets.find((p) => p.number === sendDestination)?.name ?? `#${sendDestination}`} + {:else} + {i18n.t("game.inspector.ship_group.action.send.no_destination")} + {/if} + + +
+
+ + +
+
+ {/if} + + {#if openForm === "load"} +
{ e.preventDefault(); void confirmLoad(); }}> + + + +
+ + +
+
+ {/if} + + {#if openForm === "unload"} +
{ e.preventDefault(); void confirmUnload(); }}> + + +
+ + +
+
+ {/if} + + {#if openForm === "modernize"} +
{ e.preventDefault(); void confirmModernize(); }}> + + + {#if modernizeTech !== "ALL"} + + {/if} +

+ {#if modernizeCostPreview === null} + {i18n.t("game.inspector.ship_group.action.modernize.cost_unavailable")} + {:else} + {i18n.t("game.inspector.ship_group.action.modernize.cost", { + cost: formatNumber(modernizeCostPreview), + })} + {/if} +

+
+ + +
+
+ {/if} + + {#if openForm === "dismantle"} +
{ e.preventDefault(); void confirmDismantle(); }}> + + {#if !ownPlanet && !uninhabitedPlanet && carryingColonists} +

+ {i18n.t("game.inspector.ship_group.action.dismantle.warning")} +

+ {/if} +
+ + +
+
+ {/if} + + {#if openForm === "transfer"} +
{ e.preventDefault(); void confirmTransfer(); }}> + + +
+ + +
+
+ {/if} + + {#if openForm === "joinFleet"} +
{ e.preventDefault(); void confirmJoinFleet(); }}> + {#if fleetsOnSamePlanet.length > 0} + + {#if joinFleetMode === "existing"} + + {/if} + {/if} + + {#if joinFleetMode === "new"} + + {/if} +
+ + +
+
+ {/if} + + {#if disabledStateTooltip() !== null && openForm === null} +

+ {disabledStateTooltip()} +

+ {/if} +
+ + diff --git a/ui/frontend/src/lib/sidebar/inspector-tab.svelte b/ui/frontend/src/lib/sidebar/inspector-tab.svelte index 50be659..031f382 100644 --- a/ui/frontend/src/lib/sidebar/inspector-tab.svelte +++ b/ui/frontend/src/lib/sidebar/inspector-tab.svelte @@ -89,12 +89,23 @@ from the Phase 10 stub. const localPlayerDrive = $derived( 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( renderedReport?.report?.localShipGroups ?? [], ); const otherShipGroups = $derived( renderedReport?.report?.otherShipGroups ?? [], ); + const localFleets = $derived(renderedReport?.report?.localFleets ?? []); + const otherRaces = $derived(renderedReport?.report?.otherRaces ?? []); const localRace = $derived(renderedReport?.report?.race ?? ""); @@ -113,7 +124,19 @@ from the Phase 10 stub. {localRace} /> {:else if selectedShipGroup !== null} - + {:else}

{i18n.t("game.sidebar.tab.inspector")}

{i18n.t("game.sidebar.empty.inspector")}

diff --git a/ui/frontend/src/lib/sidebar/order-tab.svelte b/ui/frontend/src/lib/sidebar/order-tab.svelte index 7442ba4..f2fa213 100644 --- a/ui/frontend/src/lib/sidebar/order-tab.svelte +++ b/ui/frontend/src/lib/sidebar/order-tab.svelte @@ -77,9 +77,57 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft` return i18n.t("game.sidebar.order.label.ship_class_remove", { 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 { return draft?.statuses[cmd.id] ?? "draft"; } diff --git a/ui/frontend/src/platform/core/index.ts b/ui/frontend/src/platform/core/index.ts index 669cacb..c604b5c 100644 --- a/ui/frontend/src/platform/core/index.ts +++ b/ui/frontend/src/platform/core/index.ts @@ -73,6 +73,12 @@ export interface CarryingMassInput { cargoTech: number; } +export interface BlockUpgradeCostInput { + blockMass: number; + currentTech: number; + targetTech: number; +} + export interface Core { /** * signRequest returns the canonical signing input bytes for a v1 @@ -157,6 +163,17 @@ export interface Core { * cargoCapacity. */ 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; diff --git a/ui/frontend/src/platform/core/wasm.ts b/ui/frontend/src/platform/core/wasm.ts index ab88fc6..499bb26 100644 --- a/ui/frontend/src/platform/core/wasm.ts +++ b/ui/frontend/src/platform/core/wasm.ts @@ -9,6 +9,7 @@ // served from `static/core.wasm`. import type { + BlockUpgradeCostInput, CargoCapacityInput, CarryingMassInput, Core, @@ -50,6 +51,7 @@ interface GalaxyCoreBridge { speed(input: SpeedInput): number; cargoCapacity(input: CargoCapacityInput): number; carryingMass(input: CarryingMassInput): number; + blockUpgradeCost(input: BlockUpgradeCostInput): number; } interface BridgeRequestFields { @@ -210,6 +212,9 @@ export function adaptBridge(bridge: GalaxyCoreBridge): Core { carryingMass(input: CarryingMassInput): number { return bridge.carryingMass(input); }, + blockUpgradeCost(input: BlockUpgradeCostInput): number { + return bridge.blockUpgradeCost(input); + }, }; } diff --git a/ui/frontend/src/routes/games/[id]/+layout.svelte b/ui/frontend/src/routes/games/[id]/+layout.svelte index 1c84ef4..32cfe5e 100644 --- a/ui/frontend/src/routes/games/[id]/+layout.svelte +++ b/ui/frontend/src/routes/games/[id]/+layout.svelte @@ -180,12 +180,27 @@ fresh. const inspectorLocalDrive = $derived( 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( renderedReport.report?.localShipGroups ?? [], ); const inspectorOtherShipGroups = $derived( renderedReport.report?.otherShipGroups ?? [], ); + const inspectorLocalFleets = $derived( + renderedReport.report?.localFleets ?? [], + ); + const inspectorOtherRaces = $derived( + renderedReport.report?.otherRaces ?? [], + ); const inspectorLocalRace = $derived(renderedReport.report?.race ?? ""); // Reveal the inspector whenever a new planet selection lands. @@ -340,6 +355,15 @@ fresh. selection.clear()} /> diff --git a/ui/frontend/src/sync/order-draft.svelte.ts b/ui/frontend/src/sync/order-draft.svelte.ts index c9aa874..5f81bb1 100644 --- a/ui/frontend/src/sync/order-draft.svelte.ts +++ b/ui/frontend/src/sync/order-draft.svelte.ts @@ -24,7 +24,12 @@ import type { Cache } from "../platform/store/index"; import type { GalaxyClient } from "../api/galaxy-client"; 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 { validateEntityName } from "$lib/util/entity-name"; import { validateShipClass } from "$lib/util/ship-class-validation"; @@ -513,6 +518,68 @@ function validateCommand(cmd: OrderCommand): CommandStatus { // active production / ship groups. Local validation only // guards the name shape. 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": // Phase 12 placeholder entries are content-free and never // transition out of `draft` — they are not submittable. diff --git a/ui/frontend/src/sync/order-load.ts b/ui/frontend/src/sync/order-load.ts index 067d428..b258d29 100644 --- a/ui/frontend/src/sync/order-load.ts +++ b/ui/frontend/src/sync/order-load.ts @@ -18,8 +18,18 @@ import { CommandPlanetRouteSet, CommandShipClassCreate, CommandShipClassRemove, + CommandShipGroupBreak, + CommandShipGroupDismantle, + CommandShipGroupJoinFleet, + CommandShipGroupLoad, + CommandShipGroupSend, + CommandShipGroupTransfer, + CommandShipGroupUnload, + CommandShipGroupUpgrade, PlanetProduction, PlanetRouteLoadType, + ShipGroupCargo, + ShipGroupUpgradeTech, UserGamesOrderGet, UserGamesOrderGetResponse, } from "../proto/galaxy/fbs/order"; @@ -27,6 +37,8 @@ import type { CargoLoadType, OrderCommand, ProductionType, + ShipGroupCargo as ShipGroupCargoLiteral, + ShipGroupUpgradeTech as ShipGroupUpgradeTechLiteral, } from "./order-types"; const MESSAGE_TYPE = "user.games.order.get"; @@ -222,6 +234,102 @@ function decodeCommand(item: CommandItemView): OrderCommand | null { 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: console.warn( `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( payload: Uint8Array, resultCode: string, diff --git a/ui/frontend/src/sync/order-types.ts b/ui/frontend/src/sync/order-types.ts index 2086a29..7fdc37d 100644 --- a/ui/frontend/src/sync/order-types.ts +++ b/ui/frontend/src/sync/order-types.ts @@ -166,6 +166,209 @@ export interface RemoveShipClassCommand { 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 * local order draft can hold. The `kind` field is the discriminator; @@ -179,7 +382,15 @@ export type OrderCommand = | SetCargoRouteCommand | RemoveCargoRouteCommand | CreateShipClassCommand - | RemoveShipClassCommand; + | RemoveShipClassCommand + | BreakShipGroupCommand + | SendShipGroupCommand + | LoadShipGroupCommand + | UnloadShipGroupCommand + | UpgradeShipGroupCommand + | DismantleShipGroupCommand + | TransferShipGroupCommand + | JoinFleetShipGroupCommand; /** * PRODUCTION_TYPE_VALUES is the canonical tuple of `ProductionType` diff --git a/ui/frontend/src/sync/submit.ts b/ui/frontend/src/sync/submit.ts index d402cb6..9e46e21 100644 --- a/ui/frontend/src/sync/submit.ts +++ b/ui/frontend/src/sync/submit.ts @@ -33,8 +33,18 @@ import { CommandPlanetRouteSet, CommandShipClassCreate, CommandShipClassRemove, + CommandShipGroupBreak, + CommandShipGroupDismantle, + CommandShipGroupJoinFleet, + CommandShipGroupLoad, + CommandShipGroupSend, + CommandShipGroupTransfer, + CommandShipGroupUnload, + CommandShipGroupUpgrade, PlanetProduction, PlanetRouteLoadType, + ShipGroupCargo, + ShipGroupUpgradeTech, UserGamesOrder, UserGamesOrderResponse, } from "../proto/galaxy/fbs/order"; @@ -42,6 +52,8 @@ import type { CargoLoadType, OrderCommand, ProductionType, + ShipGroupCargo as ShipGroupCargoLiteral, + ShipGroupUpgradeTech as ShipGroupUpgradeTechLiteral, } from "./order-types"; const MESSAGE_TYPE = "user.games.order"; @@ -222,6 +234,109 @@ function encodeCommandPayload( 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": throw new SubmitError( "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( payload: Uint8Array, commands: OrderCommand[], diff --git a/ui/frontend/static/core.wasm b/ui/frontend/static/core.wasm index 0755a91..95ed1a1 100644 Binary files a/ui/frontend/static/core.wasm and b/ui/frontend/static/core.wasm differ diff --git a/ui/frontend/tests/e2e/ship-group-send.spec.ts b/ui/frontend/tests/e2e/ship-group-send.spec.ts new file mode 100644 index 0000000..bd21af7 --- /dev/null +++ b/ui/frontend/tests/e2e/ship-group-send.spec.ts @@ -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; + clearSession?(): Promise; + setDeviceSessionId(id: string): Promise; +} + +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 { + 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 { + 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", + ); +}); diff --git a/ui/frontend/tests/galaxy-client.test.ts b/ui/frontend/tests/galaxy-client.test.ts index edf9e49..718e6be 100644 --- a/ui/frontend/tests/galaxy-client.test.ts +++ b/ui/frontend/tests/galaxy-client.test.ts @@ -217,5 +217,6 @@ function mockCore(opts: MockCoreOptions): Core & { speed: () => 0, cargoCapacity: () => 0, carryingMass: () => 0, + blockUpgradeCost: () => 0, }; } diff --git a/ui/frontend/tests/helpers/empty-ship-groups.ts b/ui/frontend/tests/helpers/empty-ship-groups.ts index aeaf15d..acea40d 100644 --- a/ui/frontend/tests/helpers/empty-ship-groups.ts +++ b/ui/frontend/tests/helpers/empty-ship-groups.ts @@ -18,10 +18,12 @@ export const EMPTY_SHIP_GROUPS: { incomingShipGroups: ReportIncomingShipGroup[]; unidentifiedShipGroups: ReportUnidentifiedShipGroup[]; localFleets: ReportLocalFleet[]; + otherRaces: string[]; } = { localShipGroups: [], otherShipGroups: [], incomingShipGroups: [], unidentifiedShipGroups: [], localFleets: [], + otherRaces: [], }; diff --git a/ui/frontend/tests/inspector-ship-group-actions.test.ts b/ui/frontend/tests/inspector-ship-group-actions.test.ts new file mode 100644 index 0000000..95bc07c --- /dev/null +++ b/ui/frontend/tests/inspector-ship-group-actions.test.ts @@ -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; +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((resolve) => { + const req = indexedDB.deleteDatabase(dbName); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); +}); + +function planet( + overrides: Partial & Pick, +): 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 { + 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([ + [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"); + }); +}); diff --git a/ui/frontend/tests/inspector-ship-group-dismantle-confirm.test.ts b/ui/frontend/tests/inspector-ship-group-dismantle-confirm.test.ts new file mode 100644 index 0000000..71c1618 --- /dev/null +++ b/ui/frontend/tests/inspector-ship-group-dismantle-confirm.test.ts @@ -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; +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((resolve) => { + const req = indexedDB.deleteDatabase(dbName); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); +}); + +function group( + overrides: Partial = {}, +): 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([ + [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)); + }); +}); diff --git a/ui/frontend/tests/inspector-ship-group-modernize-cost.test.ts b/ui/frontend/tests/inspector-ship-group-modernize-cost.test.ts new file mode 100644 index 0000000..b4c5807 --- /dev/null +++ b/ui/frontend/tests/inspector-ship-group-modernize-cost.test.ts @@ -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; +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((resolve) => { + const req = indexedDB.deleteDatabase(dbName); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); +}); + +function group( + overrides: Partial = {}, +): 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([ + [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); + }); +}); diff --git a/ui/frontend/tests/sync-order-types-ship-group.test.ts b/ui/frontend/tests/sync-order-types-ship-group.test.ts new file mode 100644 index 0000000..e432081 --- /dev/null +++ b/ui/frontend/tests/sync-order-types-ship-group.test.ts @@ -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; +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((resolve) => { + const req = indexedDB.deleteDatabase(dbName); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); +}); + +async function statusOf(cmd: OrderCommand): Promise { + 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"); + }); +}); diff --git a/ui/frontend/tests/sync-submit-ship-group.test.ts b/ui/frontend/tests/sync-submit-ship-group.test.ts new file mode 100644 index 0000000..95b0a33 --- /dev/null +++ b/ui/frontend/tests/sync-submit-ship-group.test.ts @@ -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 { + 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>, + 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 { + 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); + }); +}); diff --git a/ui/wasm/main.go b/ui/wasm/main.go index 7bc311c..dfb13b2 100644 --- a/ui/wasm/main.go +++ b/ui/wasm/main.go @@ -25,6 +25,7 @@ // - speed(fields) -> number // - cargoCapacity(fields) -> number // - carryingMass(fields) -> number +// - blockUpgradeCost(fields) -> number (Phase 20: modernize cost preview) // // Field objects are plain JS objects with camelCase keys matching the // TypeScript `Core` interface, and bytes fields are Uint8Array. @@ -59,6 +60,7 @@ func main() { "speed": js.FuncOf(speed), "cargoCapacity": js.FuncOf(cargoCapacity), "carryingMass": js.FuncOf(carryingMass), + "blockUpgradeCost": js.FuncOf(blockUpgradeCost), })) // 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)) } +// 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 // byte-shaped value) into a Go byte slice. We avoid `js.CopyBytesToGo` // because TinyGo's implementation panics on values it does not