ui/phase-20: pick-first Send + lock after Modernize/Dismantle/Transfer

Send no longer carries a destination control inside the form: a
click on the action drops the inspector straight into map-pick
mode, and the form (ship count + confirm) only mounts after the
player chooses a destination. Cancelling the picker leaves no
form behind.

A queued Modernize / Dismantle / Transfer for a given group
locks every action button on its inspector and surfaces a banner
that points the player at the order list. Cancelling the queued
entry from the order tab releases the lock on the next render —
the derivation watches draft.commands directly. Send / Load /
Unload / Split / Join Fleet do not lock; Send is naturally
followed by an out-of-orbit state at turn cutoff, the rest can
stack legitimately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-10 17:20:48 +02:00
parent de824dfc9a
commit ac14eaff10
7 changed files with 332 additions and 77 deletions
+5 -1
View File
@@ -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.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.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.cargo": "cargo type",
"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.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",
+5 -1
View File
@@ -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.no_other_races": "нет других нерасправленных рас для передачи",
"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.cargo": "тип груза",
"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.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": "группа над чужой планетой везёт колонистов — они погибнут",
@@ -118,6 +118,22 @@ modernize cost preview backed by `core.blockUpgradeCost`.
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 orbitPlanet = $derived(
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);
}
const splitDisabledReason = $derived(
!inOrbit
? "game.inspector.ship_group.action.disabled.not_in_orbit"
: group.count < 2
? "game.inspector.ship_group.action.invalid.ship_count"
: null,
// pendingDestructiveCommand watches the order draft for any
// modernize / dismantle / transfer command targeting this group.
// Once the player queues one of those three, every action on the
// group is disabled until the draft entry is removed: each is
// state-changing at turn cutoff (Modernize → state Upgrade,
// 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 => {
if (lockedReason !== null) return lockedReason;
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
if (!hasDriveBlock) return "game.inspector.ship_group.action.disabled.no_drive";
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;
});
const loadDisabledReason = $derived.by((): TranslationKey | null => {
if (lockedReason !== null) return lockedReason;
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 (orbitPlanet === null) return "game.inspector.ship_group.action.disabled.no_planet";
@@ -256,6 +317,7 @@ modernize cost preview backed by `core.blockUpgradeCost`.
return 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 (!hasCargoBlock) return "game.inspector.ship_group.action.disabled.no_cargo_block";
if (!cargoLoaded) return "game.inspector.ship_group.action.disabled.empty_cargo";
@@ -264,6 +326,7 @@ modernize cost preview backed by `core.blockUpgradeCost`.
return 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 (orbitPlanet === null) return "game.inspector.ship_group.action.disabled.no_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;
});
const dismantleDisabledReason = $derived.by((): TranslationKey | null => {
if (lockedReason !== null) return lockedReason;
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
return 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 (otherRaces.length === 0)
return "game.inspector.ship_group.action.disabled.no_other_races";
return null;
});
const joinFleetDisabledReason = $derived.by((): TranslationKey | null => {
if (lockedReason !== null) return lockedReason;
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
return null;
});
@@ -302,10 +368,42 @@ modernize cost preview backed by `core.blockUpgradeCost`.
closeOthers("split");
splitShips = Math.max(1, Math.min(Math.floor(group.count / 2), group.count - 1));
}
function openSend(): void {
closeOthers("send");
async function openSend(): Promise<void> {
// "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;
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 {
closeOthers("load");
@@ -409,21 +507,6 @@ modernize cost preview backed by `core.blockUpgradeCost`.
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> {
if (sendDestination === null || draft === undefined) return;
const ships = clampShips(sendShips);
@@ -592,13 +675,20 @@ modernize cost preview backed by `core.blockUpgradeCost`.
</script>
<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">
<button
type="button"
class="action"
data-testid="inspector-ship-group-action-split"
disabled={splitDisabledReason !== null || draft === undefined}
title={reasonTooltip(splitDisabledReason as TranslationKey | null) ?? ""}
title={reasonTooltip(splitDisabledReason) ?? ""}
onclick={openSplit}
>
{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"
disabled={sendDisabledReason !== null || draft === undefined}
title={reasonTooltip(sendDisabledReason) ?? ""}
onclick={openSend}
onclick={() => void openSend()}
>
{i18n.t("game.inspector.ship_group.action.send")}
</button>
@@ -704,8 +794,14 @@ modernize cost preview backed by `core.blockUpgradeCost`.
</form>
{/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(); }}>
<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>
{i18n.t("game.inspector.ship_group.action.field.ships", { max: String(maxShips()) })}
<input
@@ -717,26 +813,6 @@ modernize cost preview backed by `core.blockUpgradeCost`.
bind:value={sendShips}
/>
</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">
<button type="button" data-testid="inspector-ship-group-form-send-cancel" onclick={cancelForm}>
{i18n.t("game.inspector.ship_group.action.cancel")}
@@ -745,7 +821,6 @@ modernize cost preview backed by `core.blockUpgradeCost`.
type="submit"
class="primary"
data-testid="inspector-ship-group-form-send-confirm"
disabled={sendDestination === null}
>
{i18n.t("game.inspector.ship_group.action.confirm")}
</button>
@@ -753,6 +828,12 @@ modernize cost preview backed by `core.blockUpgradeCost`.
</form>
{/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"}
<form class="form" data-testid="inspector-ship-group-form-load" onsubmit={(e) => { e.preventDefault(); void confirmLoad(); }}>
<label>
@@ -1103,14 +1184,15 @@ modernize cost preview backed by `core.blockUpgradeCost`.
border: 1px solid #2a3150;
border-radius: 3px;
}
.form .destination-row {
.form .destination-readonly {
margin: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
align-items: baseline;
gap: 0.4rem;
font-size: 0.85rem;
}
.form .destination-row .label {
.form .destination-readonly .label {
color: #aab;
}
.form-actions {
@@ -1146,6 +1228,15 @@ modernize cost preview backed by `core.blockUpgradeCost`.
font-size: 0.85rem;
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 {
margin: 0;
font-size: 0.8rem;
+12 -17
View File
@@ -14,18 +14,9 @@ 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;
}
}
// `Window.__galaxyDebug` is declared as a global in
// `tests/e2e/storage-keypair-persistence.spec.ts`; reuse that
// declaration so the two specs do not collide on the symbol type.
const SYNTHETIC_FIXTURE = {
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"),
).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();
const sendShips = sidebar.getByTestId("inspector-ship-group-form-send-ships");
await sendShips.fill("2");
await expect(
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);
await page.mouse.click(marsScreen.x, marsScreen.y);
const sendShips = sidebar.getByTestId("inspector-ship-group-form-send-ships");
await sendShips.fill("2");
await expect(
sidebar.getByTestId("inspector-ship-group-form-send-destination"),
).toContainText("Mars");
@@ -262,3 +262,108 @@ describe("ship-group inspector — implicit split + action", () => {
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();
});
});