ui: plan 01-27 done #1
+31
-6
@@ -2160,12 +2160,14 @@ Artifacts:
|
|||||||
payload carries the *target* group UUID (the source group, or
|
payload carries the *target* group UUID (the source group, or
|
||||||
the freshly-minted `newGroupId` when an implicit split precedes
|
the freshly-minted `newGroupId` when an implicit split precedes
|
||||||
the action)
|
the action)
|
||||||
- `Send` action picks destination through a planet picker filtered
|
- `Send` action drops the inspector straight into map-pick mode
|
||||||
by the group's reach (`localPlayerDrive * 40`, computed inline
|
on click and only mounts the form (ship count + confirm) after
|
||||||
via the existing `torusShortestDelta` from
|
the player chooses a destination — there is no destination
|
||||||
`cargo-routes.svelte`); the player's tech levels are already on
|
control inside the form. The picker is filtered by the group's
|
||||||
`GameReport.localPlayer*` from Phase 18, no extra plumbing
|
reach (`localPlayerDrive * 40`, computed inline via the existing
|
||||||
needed
|
`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`
|
- `Modernize` cost preview through `core.blockUpgradeCost`
|
||||||
(Phase 20 bridge), summed over the four ship-class blocks for
|
(Phase 20 bridge), summed over the four ship-class blocks for
|
||||||
the targeted ship count; preview hides when `Core` is not yet
|
the targeted ship count; preview hides when `Core` is not yet
|
||||||
@@ -2175,6 +2177,14 @@ Artifacts:
|
|||||||
planet with colonists onboard (engine reference
|
planet with colonists onboard (engine reference
|
||||||
`controller/ship_group.go:177-179` — `UnloadColonists` is not
|
`controller/ship_group.go:177-179` — `UnloadColonists` is not
|
||||||
called over a foreign planet, so the cargo is lost)
|
called over a foreign planet, so the cargo is lost)
|
||||||
|
- destructive-command lock: a `Modernize` / `Dismantle` /
|
||||||
|
`Transfer` order in the draft for a given group disables every
|
||||||
|
action button on that group's inspector and surfaces a banner
|
||||||
|
pointing to the order list. Cancelling the queued command in
|
||||||
|
the order tab releases the lock. Other commands (Send / Load /
|
||||||
|
Unload / Split / JoinFleet) do not lock — Send is naturally
|
||||||
|
followed by an out-of-orbit state at turn cutoff and the
|
||||||
|
remaining four can stack legitimately
|
||||||
- `pkg/calc/ship.go.BlockUpgradeCost` (migrated from
|
- `pkg/calc/ship.go.BlockUpgradeCost` (migrated from
|
||||||
`game/internal/controller/ship_group_upgrade.go`) — the bridge
|
`game/internal/controller/ship_group_upgrade.go`) — the bridge
|
||||||
rule says `ui/core/calc/` only wraps `pkg/calc/` formulas, so
|
rule says `ui/core/calc/` only wraps `pkg/calc/` formulas, so
|
||||||
@@ -2262,6 +2272,21 @@ Decisions during stage:
|
|||||||
at `newId`. JoinFleet and Split do not get a counter (JoinFleet
|
at `newId`. JoinFleet and Split do not get a counter (JoinFleet
|
||||||
is whole-group atomically per the engine; Split *is* the break
|
is whole-group atomically per the engine; Split *is* the break
|
||||||
command).
|
command).
|
||||||
|
6. **Send is pick-first, form-second**. Click → enter map-pick
|
||||||
|
mode immediately. The form (ship count + confirm) only appears
|
||||||
|
after a destination is chosen; cancelling the picker leaves no
|
||||||
|
form behind. Removing the destination control from the form
|
||||||
|
keeps the surface to one editable field at any time.
|
||||||
|
7. **Destructive-command lock**. Any `upgradeShipGroup`,
|
||||||
|
`dismantleShipGroup`, or `transferShipGroup` in the draft for a
|
||||||
|
given group id disables every action button on that group's
|
||||||
|
inspector with a "command pending" tooltip and renders a
|
||||||
|
banner pointing the player at the order list. Cancellation
|
||||||
|
from the order tab releases the lock. The three commands all
|
||||||
|
change the group's engine-side state at turn cutoff
|
||||||
|
(`StateUpgrade` / removal / `StateTransfer`), so any second
|
||||||
|
action would race the engine's pre-condition check anyway —
|
||||||
|
the lock surfaces that commitment up-front.
|
||||||
|
|
||||||
## Phase 21. Sciences — CRUD List + Designer
|
## Phase 21. Sciences — CRUD List + Designer
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ every action with `ships are busy ({state})`. Per-action gates:
|
|||||||
pre-filters destinations by reach
|
pre-filters destinations by reach
|
||||||
(`localPlayerDrive * 40`), so a valid pick is always within
|
(`localPlayerDrive * 40`), so a valid pick is always within
|
||||||
range. With no reachable planet, the action is disabled with
|
range. With no reachable planet, the action is disabled with
|
||||||
the "no planets in drive range" tooltip.
|
the "no planets in drive range" tooltip. Click drops the
|
||||||
|
inspector straight into map-pick mode; the form (ship count +
|
||||||
|
confirm) appears only after the player chooses a destination —
|
||||||
|
there is no destination control inside the form, so cancelling
|
||||||
|
the picker leaves the inspector untouched.
|
||||||
- **Load**: requires the orbit planet to be owned by the player
|
- **Load**: requires the orbit planet to be owned by the player
|
||||||
or unowned (`controller/ship_group.go:215`) and the ship class
|
or unowned (`controller/ship_group.go:215`) and the ship class
|
||||||
to have a cargo block (`shipGroupLoad:220`). The dropdown is
|
to have a cargo block (`shipGroupLoad:220`). The dropdown is
|
||||||
@@ -88,6 +92,33 @@ every action with `ships are busy ({state})`. Per-action gates:
|
|||||||
in the same orbit (`fleet.go:135-137`); creating a new fleet
|
in the same orbit (`fleet.go:135-137`); creating a new fleet
|
||||||
always works.
|
always works.
|
||||||
|
|
||||||
|
## Destructive-command lock
|
||||||
|
|
||||||
|
`Modernize`, `Dismantle`, and `Transfer` are *state-changing* at
|
||||||
|
turn cutoff: the engine moves the group into `StateUpgrade`,
|
||||||
|
removes it, or marks it `StateTransfer` respectively. Issuing a
|
||||||
|
follow-up action against the same group during the same draft
|
||||||
|
window would race the engine's pre-condition check, so the
|
||||||
|
inspector locks the group as soon as one of the three commands
|
||||||
|
lands in the draft for that `groupId`:
|
||||||
|
|
||||||
|
- every action button on the group's inspector becomes disabled
|
||||||
|
with the "an order is already queued" tooltip;
|
||||||
|
- a banner above the buttons row names the queued command
|
||||||
|
(modernize / dismantle / transfer) and tells the player to
|
||||||
|
cancel it in the order list to issue something else;
|
||||||
|
- removing the queued entry from the order tab releases the lock
|
||||||
|
on the next render — the derivation watches `draft.commands`
|
||||||
|
directly.
|
||||||
|
|
||||||
|
Send, Load, Unload, Split, and Join Fleet do not lock the group:
|
||||||
|
Send is naturally followed by an out-of-orbit state at turn
|
||||||
|
cutoff (the engine's busy check fires next turn anyway), and the
|
||||||
|
other four can stack legitimately during the same window. The
|
||||||
|
group continues to appear in the planet inspector's stationed-
|
||||||
|
ship list while locked — the player can still navigate to the
|
||||||
|
inspector to read the state and find the order to cancel.
|
||||||
|
|
||||||
## Modernize cost preview
|
## Modernize cost preview
|
||||||
|
|
||||||
The form's preview line calls
|
The form's preview line calls
|
||||||
|
|||||||
@@ -308,6 +308,11 @@ const en = {
|
|||||||
"game.inspector.ship_group.action.disabled.full_load": "the group is fully loaded",
|
"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.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.disabled.unknown_class": "the ship class is missing from the report",
|
||||||
|
"game.inspector.ship_group.action.disabled.locked": "an order is already queued for this group; cancel it in the order list to issue a new one",
|
||||||
|
"game.inspector.ship_group.action.locked.banner": "an order is already queued for this group: {command}. Cancel it in the order list to issue another action.",
|
||||||
|
"game.inspector.ship_group.action.locked.kind.modernize": "modernize",
|
||||||
|
"game.inspector.ship_group.action.locked.kind.dismantle": "dismantle",
|
||||||
|
"game.inspector.ship_group.action.locked.kind.transfer": "transfer",
|
||||||
"game.inspector.ship_group.action.field.ships": "ships ({max} total)",
|
"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.cargo": "cargo type",
|
||||||
"game.inspector.ship_group.action.field.quantity": "quantity",
|
"game.inspector.ship_group.action.field.quantity": "quantity",
|
||||||
@@ -322,7 +327,6 @@ const en = {
|
|||||||
"game.inspector.ship_group.action.tech.shields": "shields",
|
"game.inspector.ship_group.action.tech.shields": "shields",
|
||||||
"game.inspector.ship_group.action.tech.cargo": "cargo",
|
"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.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": "estimated cost: {cost}",
|
||||||
"game.inspector.ship_group.action.modernize.cost_unavailable": "cost preview unavailable",
|
"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.dismantle.warning": "the group is over a foreign planet with colonists aboard — they will die",
|
||||||
|
|||||||
@@ -309,6 +309,11 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.inspector.ship_group.action.disabled.full_load": "трюм полностью заполнен",
|
"game.inspector.ship_group.action.disabled.full_load": "трюм полностью заполнен",
|
||||||
"game.inspector.ship_group.action.disabled.no_other_races": "нет других нерасправленных рас для передачи",
|
"game.inspector.ship_group.action.disabled.no_other_races": "нет других нерасправленных рас для передачи",
|
||||||
"game.inspector.ship_group.action.disabled.unknown_class": "класс корабля не найден в отчёте",
|
"game.inspector.ship_group.action.disabled.unknown_class": "класс корабля не найден в отчёте",
|
||||||
|
"game.inspector.ship_group.action.disabled.locked": "по группе уже отдан приказ; отмените его в списке приказов, чтобы дать новое действие",
|
||||||
|
"game.inspector.ship_group.action.locked.banner": "по группе уже отдан приказ: {command}. Отмените его в списке приказов, чтобы дать другое действие.",
|
||||||
|
"game.inspector.ship_group.action.locked.kind.modernize": "модернизация",
|
||||||
|
"game.inspector.ship_group.action.locked.kind.dismantle": "разборка",
|
||||||
|
"game.inspector.ship_group.action.locked.kind.transfer": "передача",
|
||||||
"game.inspector.ship_group.action.field.ships": "кораблей (всего {max})",
|
"game.inspector.ship_group.action.field.ships": "кораблей (всего {max})",
|
||||||
"game.inspector.ship_group.action.field.cargo": "тип груза",
|
"game.inspector.ship_group.action.field.cargo": "тип груза",
|
||||||
"game.inspector.ship_group.action.field.quantity": "количество",
|
"game.inspector.ship_group.action.field.quantity": "количество",
|
||||||
@@ -323,7 +328,6 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.inspector.ship_group.action.tech.shields": "защита",
|
"game.inspector.ship_group.action.tech.shields": "защита",
|
||||||
"game.inspector.ship_group.action.tech.cargo": "груз",
|
"game.inspector.ship_group.action.tech.cargo": "груз",
|
||||||
"game.inspector.ship_group.action.send.pick_prompt": "выберите планету на карте (Esc — отмена)",
|
"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": "ожидаемая стоимость: {cost}",
|
||||||
"game.inspector.ship_group.action.modernize.cost_unavailable": "предпросмотр недоступен",
|
"game.inspector.ship_group.action.modernize.cost_unavailable": "предпросмотр недоступен",
|
||||||
"game.inspector.ship_group.action.dismantle.warning": "группа над чужой планетой везёт колонистов — они погибнут",
|
"game.inspector.ship_group.action.dismantle.warning": "группа над чужой планетой везёт колонистов — они погибнут",
|
||||||
|
|||||||
@@ -118,6 +118,22 @@ modernize cost preview backed by `core.blockUpgradeCost`.
|
|||||||
openForm = null;
|
openForm = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Close any open form the moment the group becomes locked
|
||||||
|
// (a destructive command landed in the draft from elsewhere —
|
||||||
|
// e.g. another browser tab editing the same draft, or a
|
||||||
|
// concurrent action on this inspector). Without this guard a
|
||||||
|
// pending form would still allow Confirm despite the locked
|
||||||
|
// banner above it.
|
||||||
|
$effect(() => {
|
||||||
|
if (pendingDestructiveCommand !== null) {
|
||||||
|
if (sendPicking) {
|
||||||
|
pick?.cancel();
|
||||||
|
sendPicking = false;
|
||||||
|
}
|
||||||
|
openForm = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const inOrbit = $derived(group.state === "In_Orbit");
|
const inOrbit = $derived(group.state === "In_Orbit");
|
||||||
const orbitPlanet = $derived(
|
const orbitPlanet = $derived(
|
||||||
inOrbit ? (planets.find((p) => p.number === group.destination) ?? null) : null,
|
inOrbit ? (planets.find((p) => p.number === group.destination) ?? null) : null,
|
||||||
@@ -229,14 +245,58 @@ modernize cost preview backed by `core.blockUpgradeCost`.
|
|||||||
return reason === null ? null : i18n.t(reason);
|
return reason === null ? null : i18n.t(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
const splitDisabledReason = $derived(
|
// pendingDestructiveCommand watches the order draft for any
|
||||||
!inOrbit
|
// modernize / dismantle / transfer command targeting this group.
|
||||||
? "game.inspector.ship_group.action.disabled.not_in_orbit"
|
// Once the player queues one of those three, every action on the
|
||||||
: group.count < 2
|
// group is disabled until the draft entry is removed: each is
|
||||||
? "game.inspector.ship_group.action.invalid.ship_count"
|
// state-changing at turn cutoff (Modernize → state Upgrade,
|
||||||
: null,
|
// Transfer → state Transfer, Dismantle → group removed), so a
|
||||||
|
// follow-up action would race the engine's pre-condition check
|
||||||
|
// and noisy-fail server-side. The lock surfaces the commitment
|
||||||
|
// up-front and points the player at the order list as the way
|
||||||
|
// to release it.
|
||||||
|
const pendingDestructiveCommand = $derived.by(() => {
|
||||||
|
if (draft === undefined) return null;
|
||||||
|
for (const cmd of draft.commands) {
|
||||||
|
if (
|
||||||
|
cmd.kind !== "upgradeShipGroup" &&
|
||||||
|
cmd.kind !== "dismantleShipGroup" &&
|
||||||
|
cmd.kind !== "transferShipGroup"
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
if (cmd.groupId !== group.id) continue;
|
||||||
|
const status = draft.statuses[cmd.id];
|
||||||
|
if (status === "rejected" || status === "invalid") continue;
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const lockedReason: TranslationKey | null = $derived(
|
||||||
|
pendingDestructiveCommand === null
|
||||||
|
? null
|
||||||
|
: "game.inspector.ship_group.action.disabled.locked",
|
||||||
);
|
);
|
||||||
|
const lockedKindLabel = $derived.by(() => {
|
||||||
|
const cmd = pendingDestructiveCommand;
|
||||||
|
if (cmd === null) return "";
|
||||||
|
switch (cmd.kind) {
|
||||||
|
case "upgradeShipGroup":
|
||||||
|
return i18n.t("game.inspector.ship_group.action.locked.kind.modernize");
|
||||||
|
case "dismantleShipGroup":
|
||||||
|
return i18n.t("game.inspector.ship_group.action.locked.kind.dismantle");
|
||||||
|
case "transferShipGroup":
|
||||||
|
return i18n.t("game.inspector.ship_group.action.locked.kind.transfer");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const splitDisabledReason = $derived.by((): TranslationKey | null => {
|
||||||
|
if (lockedReason !== null) return lockedReason;
|
||||||
|
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
||||||
|
if (group.count < 2) return "game.inspector.ship_group.action.invalid.ship_count";
|
||||||
|
return null;
|
||||||
|
});
|
||||||
const sendDisabledReason = $derived.by((): TranslationKey | null => {
|
const sendDisabledReason = $derived.by((): TranslationKey | null => {
|
||||||
|
if (lockedReason !== null) return lockedReason;
|
||||||
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
||||||
if (!hasDriveBlock) return "game.inspector.ship_group.action.disabled.no_drive";
|
if (!hasDriveBlock) return "game.inspector.ship_group.action.disabled.no_drive";
|
||||||
if (orbitPlanet === null) return "game.inspector.ship_group.action.disabled.no_planet";
|
if (orbitPlanet === null) return "game.inspector.ship_group.action.disabled.no_planet";
|
||||||
@@ -244,6 +304,7 @@ modernize cost preview backed by `core.blockUpgradeCost`.
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
const loadDisabledReason = $derived.by((): TranslationKey | null => {
|
const loadDisabledReason = $derived.by((): TranslationKey | null => {
|
||||||
|
if (lockedReason !== null) return lockedReason;
|
||||||
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
||||||
if (!hasCargoBlock) return "game.inspector.ship_group.action.disabled.no_cargo_block";
|
if (!hasCargoBlock) return "game.inspector.ship_group.action.disabled.no_cargo_block";
|
||||||
if (orbitPlanet === null) return "game.inspector.ship_group.action.disabled.no_planet";
|
if (orbitPlanet === null) return "game.inspector.ship_group.action.disabled.no_planet";
|
||||||
@@ -256,6 +317,7 @@ modernize cost preview backed by `core.blockUpgradeCost`.
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
const unloadDisabledReason = $derived.by((): TranslationKey | null => {
|
const unloadDisabledReason = $derived.by((): TranslationKey | null => {
|
||||||
|
if (lockedReason !== null) return lockedReason;
|
||||||
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
||||||
if (!hasCargoBlock) return "game.inspector.ship_group.action.disabled.no_cargo_block";
|
if (!hasCargoBlock) return "game.inspector.ship_group.action.disabled.no_cargo_block";
|
||||||
if (!cargoLoaded) return "game.inspector.ship_group.action.disabled.empty_cargo";
|
if (!cargoLoaded) return "game.inspector.ship_group.action.disabled.empty_cargo";
|
||||||
@@ -264,6 +326,7 @@ modernize cost preview backed by `core.blockUpgradeCost`.
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
const modernizeDisabledReason = $derived.by((): TranslationKey | null => {
|
const modernizeDisabledReason = $derived.by((): TranslationKey | null => {
|
||||||
|
if (lockedReason !== null) return lockedReason;
|
||||||
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
||||||
if (orbitPlanet === null) return "game.inspector.ship_group.action.disabled.no_planet";
|
if (orbitPlanet === null) return "game.inspector.ship_group.action.disabled.no_planet";
|
||||||
if (!friendlyPlanet) return "game.inspector.ship_group.action.disabled.foreign_planet";
|
if (!friendlyPlanet) return "game.inspector.ship_group.action.disabled.foreign_planet";
|
||||||
@@ -272,16 +335,19 @@ modernize cost preview backed by `core.blockUpgradeCost`.
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
const dismantleDisabledReason = $derived.by((): TranslationKey | null => {
|
const dismantleDisabledReason = $derived.by((): TranslationKey | null => {
|
||||||
|
if (lockedReason !== null) return lockedReason;
|
||||||
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
const transferDisabledReason = $derived.by((): TranslationKey | null => {
|
const transferDisabledReason = $derived.by((): TranslationKey | null => {
|
||||||
|
if (lockedReason !== null) return lockedReason;
|
||||||
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
||||||
if (otherRaces.length === 0)
|
if (otherRaces.length === 0)
|
||||||
return "game.inspector.ship_group.action.disabled.no_other_races";
|
return "game.inspector.ship_group.action.disabled.no_other_races";
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
const joinFleetDisabledReason = $derived.by((): TranslationKey | null => {
|
const joinFleetDisabledReason = $derived.by((): TranslationKey | null => {
|
||||||
|
if (lockedReason !== null) return lockedReason;
|
||||||
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
@@ -302,10 +368,42 @@ modernize cost preview backed by `core.blockUpgradeCost`.
|
|||||||
closeOthers("split");
|
closeOthers("split");
|
||||||
splitShips = Math.max(1, Math.min(Math.floor(group.count / 2), group.count - 1));
|
splitShips = Math.max(1, Math.min(Math.floor(group.count / 2), group.count - 1));
|
||||||
}
|
}
|
||||||
function openSend(): void {
|
async function openSend(): Promise<void> {
|
||||||
closeOthers("send");
|
// "Send" is a two-stage flow: the click drops the inspector
|
||||||
|
// straight into map-pick mode, and the form (ship count +
|
||||||
|
// confirm) only appears after the player chooses a destination
|
||||||
|
// or cancels. The destination is therefore never an editable
|
||||||
|
// control inside the form — picking is the entry point, not a
|
||||||
|
// sub-step. Re-clicking the action while picking cancels the
|
||||||
|
// session; re-clicking while the form is open closes it.
|
||||||
|
if (sendPicking) {
|
||||||
|
pick?.cancel();
|
||||||
|
sendPicking = false;
|
||||||
|
openForm = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (openForm === "send") {
|
||||||
|
openForm = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openForm = null;
|
||||||
|
if (pick === undefined || draft === undefined) return;
|
||||||
|
if (orbitPlanet === null || reachableSet.size === 0) return;
|
||||||
sendShips = group.count;
|
sendShips = group.count;
|
||||||
sendDestination = null;
|
sendDestination = null;
|
||||||
|
sendPicking = true;
|
||||||
|
let picked: number | null = null;
|
||||||
|
try {
|
||||||
|
picked = await pick.pick({
|
||||||
|
sourcePlanetNumber: orbitPlanet.number,
|
||||||
|
reachableIds: reachableSet,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
sendPicking = false;
|
||||||
|
}
|
||||||
|
if (picked === null) return;
|
||||||
|
sendDestination = picked;
|
||||||
|
openForm = "send";
|
||||||
}
|
}
|
||||||
function openLoad(): void {
|
function openLoad(): void {
|
||||||
closeOthers("load");
|
closeOthers("load");
|
||||||
@@ -409,21 +507,6 @@ modernize cost preview backed by `core.blockUpgradeCost`.
|
|||||||
openForm = null;
|
openForm = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startSendPick(): Promise<void> {
|
|
||||||
if (pick === undefined || sendPicking) return;
|
|
||||||
if (reachableSet.size === 0 || orbitPlanet === null) return;
|
|
||||||
sendPicking = true;
|
|
||||||
try {
|
|
||||||
const picked = await pick.pick({
|
|
||||||
sourcePlanetNumber: orbitPlanet.number,
|
|
||||||
reachableIds: reachableSet,
|
|
||||||
});
|
|
||||||
if (picked !== null) sendDestination = picked;
|
|
||||||
} finally {
|
|
||||||
sendPicking = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmSend(): Promise<void> {
|
async function confirmSend(): Promise<void> {
|
||||||
if (sendDestination === null || draft === undefined) return;
|
if (sendDestination === null || draft === undefined) return;
|
||||||
const ships = clampShips(sendShips);
|
const ships = clampShips(sendShips);
|
||||||
@@ -592,13 +675,20 @@ modernize cost preview backed by `core.blockUpgradeCost`.
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="actions" data-testid="inspector-ship-group-actions">
|
<section class="actions" data-testid="inspector-ship-group-actions">
|
||||||
|
{#if pendingDestructiveCommand !== null}
|
||||||
|
<p class="locked" data-testid="inspector-ship-group-actions-locked">
|
||||||
|
{i18n.t("game.inspector.ship_group.action.locked.banner", {
|
||||||
|
command: lockedKindLabel,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="action"
|
class="action"
|
||||||
data-testid="inspector-ship-group-action-split"
|
data-testid="inspector-ship-group-action-split"
|
||||||
disabled={splitDisabledReason !== null || draft === undefined}
|
disabled={splitDisabledReason !== null || draft === undefined}
|
||||||
title={reasonTooltip(splitDisabledReason as TranslationKey | null) ?? ""}
|
title={reasonTooltip(splitDisabledReason) ?? ""}
|
||||||
onclick={openSplit}
|
onclick={openSplit}
|
||||||
>
|
>
|
||||||
{i18n.t("game.inspector.ship_group.action.split")}
|
{i18n.t("game.inspector.ship_group.action.split")}
|
||||||
@@ -609,7 +699,7 @@ modernize cost preview backed by `core.blockUpgradeCost`.
|
|||||||
data-testid="inspector-ship-group-action-send"
|
data-testid="inspector-ship-group-action-send"
|
||||||
disabled={sendDisabledReason !== null || draft === undefined}
|
disabled={sendDisabledReason !== null || draft === undefined}
|
||||||
title={reasonTooltip(sendDisabledReason) ?? ""}
|
title={reasonTooltip(sendDisabledReason) ?? ""}
|
||||||
onclick={openSend}
|
onclick={() => void openSend()}
|
||||||
>
|
>
|
||||||
{i18n.t("game.inspector.ship_group.action.send")}
|
{i18n.t("game.inspector.ship_group.action.send")}
|
||||||
</button>
|
</button>
|
||||||
@@ -704,8 +794,14 @@ modernize cost preview backed by `core.blockUpgradeCost`.
|
|||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if openForm === "send"}
|
{#if openForm === "send" && sendDestination !== null}
|
||||||
<form class="form" data-testid="inspector-ship-group-form-send" onsubmit={(e) => { e.preventDefault(); void confirmSend(); }}>
|
<form class="form" data-testid="inspector-ship-group-form-send" onsubmit={(e) => { e.preventDefault(); void confirmSend(); }}>
|
||||||
|
<p class="destination-readonly">
|
||||||
|
<span class="label">{i18n.t("game.inspector.ship_group.action.field.destination")}</span>
|
||||||
|
<span data-testid="inspector-ship-group-form-send-destination">
|
||||||
|
{planets.find((p) => p.number === sendDestination)?.name ?? `#${sendDestination}`}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
<label>
|
<label>
|
||||||
{i18n.t("game.inspector.ship_group.action.field.ships", { max: String(maxShips()) })}
|
{i18n.t("game.inspector.ship_group.action.field.ships", { max: String(maxShips()) })}
|
||||||
<input
|
<input
|
||||||
@@ -717,26 +813,6 @@ modernize cost preview backed by `core.blockUpgradeCost`.
|
|||||||
bind:value={sendShips}
|
bind:value={sendShips}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div class="destination-row">
|
|
||||||
<span class="label">{i18n.t("game.inspector.ship_group.action.field.destination")}</span>
|
|
||||||
<span data-testid="inspector-ship-group-form-send-destination">
|
|
||||||
{#if sendDestination !== null}
|
|
||||||
{planets.find((p) => p.number === sendDestination)?.name ?? `#${sendDestination}`}
|
|
||||||
{:else}
|
|
||||||
{i18n.t("game.inspector.ship_group.action.send.no_destination")}
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="inspector-ship-group-form-send-pick"
|
|
||||||
disabled={sendPicking || pick === undefined}
|
|
||||||
onclick={() => void startSendPick()}
|
|
||||||
>
|
|
||||||
{sendPicking
|
|
||||||
? i18n.t("game.inspector.ship_group.action.send.pick_prompt")
|
|
||||||
: i18n.t("game.inspector.ship_group.action.field.destination")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" data-testid="inspector-ship-group-form-send-cancel" onclick={cancelForm}>
|
<button type="button" data-testid="inspector-ship-group-form-send-cancel" onclick={cancelForm}>
|
||||||
{i18n.t("game.inspector.ship_group.action.cancel")}
|
{i18n.t("game.inspector.ship_group.action.cancel")}
|
||||||
@@ -745,7 +821,6 @@ modernize cost preview backed by `core.blockUpgradeCost`.
|
|||||||
type="submit"
|
type="submit"
|
||||||
class="primary"
|
class="primary"
|
||||||
data-testid="inspector-ship-group-form-send-confirm"
|
data-testid="inspector-ship-group-form-send-confirm"
|
||||||
disabled={sendDestination === null}
|
|
||||||
>
|
>
|
||||||
{i18n.t("game.inspector.ship_group.action.confirm")}
|
{i18n.t("game.inspector.ship_group.action.confirm")}
|
||||||
</button>
|
</button>
|
||||||
@@ -753,6 +828,12 @@ modernize cost preview backed by `core.blockUpgradeCost`.
|
|||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if sendPicking}
|
||||||
|
<p class="hint" data-testid="inspector-ship-group-form-send-pick-prompt">
|
||||||
|
{i18n.t("game.inspector.ship_group.action.send.pick_prompt")}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if openForm === "load"}
|
{#if openForm === "load"}
|
||||||
<form class="form" data-testid="inspector-ship-group-form-load" onsubmit={(e) => { e.preventDefault(); void confirmLoad(); }}>
|
<form class="form" data-testid="inspector-ship-group-form-load" onsubmit={(e) => { e.preventDefault(); void confirmLoad(); }}>
|
||||||
<label>
|
<label>
|
||||||
@@ -1103,14 +1184,15 @@ modernize cost preview backed by `core.blockUpgradeCost`.
|
|||||||
border: 1px solid #2a3150;
|
border: 1px solid #2a3150;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
.form .destination-row {
|
.form .destination-readonly {
|
||||||
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
gap: 0.5rem;
|
gap: 0.4rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.form .destination-row .label {
|
.form .destination-readonly .label {
|
||||||
color: #aab;
|
color: #aab;
|
||||||
}
|
}
|
||||||
.form-actions {
|
.form-actions {
|
||||||
@@ -1146,6 +1228,15 @@ modernize cost preview backed by `core.blockUpgradeCost`.
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #d9a07a;
|
color: #d9a07a;
|
||||||
}
|
}
|
||||||
|
.locked {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #aab;
|
||||||
|
background: #14182a;
|
||||||
|
border: 1px solid #2a3150;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
.hint {
|
.hint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|||||||
@@ -14,18 +14,9 @@ import { expect, test, type Page } from "@playwright/test";
|
|||||||
|
|
||||||
const SESSION_ID = "phase-20-send-session";
|
const SESSION_ID = "phase-20-send-session";
|
||||||
|
|
||||||
interface DebugSurface {
|
// `Window.__galaxyDebug` is declared as a global in
|
||||||
ready?: boolean;
|
// `tests/e2e/storage-keypair-persistence.spec.ts`; reuse that
|
||||||
loadSession(): Promise<unknown>;
|
// declaration so the two specs do not collide on the symbol type.
|
||||||
clearSession?(): Promise<void>;
|
|
||||||
setDeviceSessionId(id: string): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
__galaxyDebug?: DebugSurface;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const SYNTHETIC_FIXTURE = {
|
const SYNTHETIC_FIXTURE = {
|
||||||
turn: 1,
|
turn: 1,
|
||||||
@@ -226,15 +217,19 @@ test("send 2 of 3 ships emits implicit Break + Send into the order draft", async
|
|||||||
sidebar.getByTestId("inspector-ship-group-class"),
|
sidebar.getByTestId("inspector-ship-group-class"),
|
||||||
).toHaveText("Frontier");
|
).toHaveText("Frontier");
|
||||||
|
|
||||||
// Open Send.
|
// Click Send: the inspector enters map-pick mode immediately; the
|
||||||
|
// form (ship count + confirm) only mounts after the destination
|
||||||
|
// is chosen.
|
||||||
await sidebar.getByTestId("inspector-ship-group-action-send").click();
|
await sidebar.getByTestId("inspector-ship-group-action-send").click();
|
||||||
const sendShips = sidebar.getByTestId("inspector-ship-group-form-send-ships");
|
await expect(
|
||||||
await sendShips.fill("2");
|
sidebar.getByTestId("inspector-ship-group-form-send-pick-prompt"),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
// Pick Mars on the map.
|
|
||||||
await sidebar.getByTestId("inspector-ship-group-form-send-pick").click();
|
|
||||||
const marsScreen = await projectWorldToScreen(page, 110, 100);
|
const marsScreen = await projectWorldToScreen(page, 110, 100);
|
||||||
await page.mouse.click(marsScreen.x, marsScreen.y);
|
await page.mouse.click(marsScreen.x, marsScreen.y);
|
||||||
|
|
||||||
|
const sendShips = sidebar.getByTestId("inspector-ship-group-form-send-ships");
|
||||||
|
await sendShips.fill("2");
|
||||||
await expect(
|
await expect(
|
||||||
sidebar.getByTestId("inspector-ship-group-form-send-destination"),
|
sidebar.getByTestId("inspector-ship-group-form-send-destination"),
|
||||||
).toContainText("Mars");
|
).toContainText("Mars");
|
||||||
|
|||||||
@@ -262,3 +262,108 @@ describe("ship-group inspector — implicit split + action", () => {
|
|||||||
expect(cmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
expect(cmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ship-group inspector — destructive command lock", () => {
|
||||||
|
const ALL_ACTION_TESTIDS = [
|
||||||
|
"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",
|
||||||
|
];
|
||||||
|
|
||||||
|
test("a queued dismantleShipGroup disables every action with the lock tooltip", async () => {
|
||||||
|
const groupId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||||
|
await draft.add({
|
||||||
|
kind: "dismantleShipGroup",
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
groupId,
|
||||||
|
});
|
||||||
|
const ui = mount(localGroup({ id: groupId, count: 3, cargo: "MAT", load: 0.5 }));
|
||||||
|
const banner = ui.getByTestId("inspector-ship-group-actions-locked");
|
||||||
|
expect(banner).toHaveTextContent(/dismantle/i);
|
||||||
|
for (const id of ALL_ACTION_TESTIDS) {
|
||||||
|
const button = ui.getByTestId(id);
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
expect(button.getAttribute("title")).toMatch(/order is already queued/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a queued upgradeShipGroup locks the inspector and reports modernize as the kind", async () => {
|
||||||
|
const groupId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||||
|
await draft.add({
|
||||||
|
kind: "upgradeShipGroup",
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
groupId,
|
||||||
|
tech: "ALL",
|
||||||
|
level: 0,
|
||||||
|
});
|
||||||
|
const ui = mount(localGroup({ id: groupId, count: 2 }));
|
||||||
|
expect(ui.getByTestId("inspector-ship-group-actions-locked")).toHaveTextContent(
|
||||||
|
/modernize/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a queued transferShipGroup locks the inspector and reports transfer as the kind", async () => {
|
||||||
|
const groupId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||||
|
await draft.add({
|
||||||
|
kind: "transferShipGroup",
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
groupId,
|
||||||
|
acceptor: "Aliens",
|
||||||
|
});
|
||||||
|
const ui = mount(localGroup({ id: groupId }));
|
||||||
|
expect(ui.getByTestId("inspector-ship-group-actions-locked")).toHaveTextContent(
|
||||||
|
/transfer/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a queued sendShipGroup does NOT lock the group", async () => {
|
||||||
|
const groupId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||||
|
await draft.add({
|
||||||
|
kind: "sendShipGroup",
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
groupId,
|
||||||
|
destinationPlanetNumber: 99,
|
||||||
|
});
|
||||||
|
const ui = mount(localGroup({ id: groupId, count: 3 }));
|
||||||
|
expect(
|
||||||
|
ui.queryByTestId("inspector-ship-group-actions-locked"),
|
||||||
|
).toBeNull();
|
||||||
|
expect(ui.getByTestId("inspector-ship-group-action-split")).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a destructive command targeting a different group does not lock this one", async () => {
|
||||||
|
await draft.add({
|
||||||
|
kind: "dismantleShipGroup",
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
groupId: "ffffffff-ffff-ffff-ffff-ffffffffffff",
|
||||||
|
});
|
||||||
|
const ui = mount(localGroup({ count: 3 }));
|
||||||
|
expect(
|
||||||
|
ui.queryByTestId("inspector-ship-group-actions-locked"),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removing the destructive command from the draft releases the lock", async () => {
|
||||||
|
const groupId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||||
|
const cmdId = crypto.randomUUID();
|
||||||
|
await draft.add({
|
||||||
|
kind: "dismantleShipGroup",
|
||||||
|
id: cmdId,
|
||||||
|
groupId,
|
||||||
|
});
|
||||||
|
const ui = mount(localGroup({ id: groupId, count: 3 }));
|
||||||
|
expect(ui.getByTestId("inspector-ship-group-actions-locked")).toBeInTheDocument();
|
||||||
|
await draft.remove(cmdId);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
ui.queryByTestId("inspector-ship-group-actions-locked"),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
expect(ui.getByTestId("inspector-ship-group-action-split")).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user