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