ui/phase-20: lock after Send + dashed tracks for in-flight & pending sends
Send joins Modernize / Dismantle / Transfer as a lockable command: once any of the four lands in the draft for a group, every action button on its inspector is disabled with a "command pending" tooltip and the banner names the queued kind. Load / Unload / Split / Join Fleet stay non-locking — they stack legitimately on the engine side. Two dashed overlays now run alongside the cargo-route arrows: - Yellow dashed track for own in-space groups, drawn from the origin planet to the destination (matches the in-space point colour so eye reads both as one entity). - Green dashed track for every wire-valid sendShipGroup command in the order draft, drawn from the source group's orbit planet to the chosen destination. Disappears when the command is removed from the order tab, when the engine rejects it, or when the group has left orbit (in-space track replaces it). Both tracks are wrap-aware via torusShortestDelta and never participate in hit-test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -321,7 +321,7 @@ describe("ship-group inspector — destructive command lock", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("a queued sendShipGroup does NOT lock the group", async () => {
|
||||
test("a queued sendShipGroup locks the inspector and reports send as the kind", async () => {
|
||||
const groupId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
await draft.add({
|
||||
kind: "sendShipGroup",
|
||||
@@ -330,6 +330,22 @@ describe("ship-group inspector — destructive command lock", () => {
|
||||
destinationPlanetNumber: 99,
|
||||
});
|
||||
const ui = mount(localGroup({ id: groupId, count: 3 }));
|
||||
expect(ui.getByTestId("inspector-ship-group-actions-locked")).toHaveTextContent(
|
||||
/send/i,
|
||||
);
|
||||
expect(ui.getByTestId("inspector-ship-group-action-split")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("a queued loadShipGroup does NOT lock the group", async () => {
|
||||
const groupId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
await draft.add({
|
||||
kind: "loadShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId,
|
||||
cargo: "MAT",
|
||||
quantity: 1,
|
||||
});
|
||||
const ui = mount(localGroup({ id: groupId, count: 3, cargo: "MAT", load: 0.5 }));
|
||||
expect(
|
||||
ui.queryByTestId("inspector-ship-group-actions-locked"),
|
||||
).toBeNull();
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// Vitest coverage for the pending-Send overlay. The overlay
|
||||
// renders a green dashed line from the source group's orbit
|
||||
// planet to the chosen destination for every wire-valid
|
||||
// `sendShipGroup` command in the order draft.
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type {
|
||||
GameReport,
|
||||
ReportLocalShipGroup,
|
||||
ReportPlanet,
|
||||
} from "../src/api/game-state";
|
||||
import type { OrderCommand } from "../src/sync/order-types";
|
||||
import { buildPendingSendLines } from "../src/map/pending-send-routes";
|
||||
|
||||
function planet(overrides: Partial<ReportPlanet> & Pick<ReportPlanet, "number" | "x" | "y">): ReportPlanet {
|
||||
return {
|
||||
name: `P${overrides.number}`,
|
||||
kind: "uninhabited",
|
||||
owner: null,
|
||||
size: 1,
|
||||
resources: 1,
|
||||
industryStockpile: 0,
|
||||
materialsStockpile: 0,
|
||||
industry: 0,
|
||||
population: 0,
|
||||
colonists: 0,
|
||||
production: null,
|
||||
freeIndustry: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function localGroup(overrides: Partial<ReportLocalShipGroup> & Pick<ReportLocalShipGroup, "id" | "destination">): ReportLocalShipGroup {
|
||||
return {
|
||||
count: 1,
|
||||
class: "Cruiser",
|
||||
tech: { drive: 1, weapons: 0, shields: 0, cargo: 0 },
|
||||
cargo: "NONE",
|
||||
load: 0,
|
||||
origin: null,
|
||||
range: null,
|
||||
speed: 0,
|
||||
mass: 1,
|
||||
state: "In_Orbit",
|
||||
fleet: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeReport(
|
||||
overrides: Partial<GameReport> & Pick<GameReport, "planets" | "localShipGroups">,
|
||||
): GameReport {
|
||||
return {
|
||||
turn: 1,
|
||||
mapWidth: 200,
|
||||
mapHeight: 200,
|
||||
planetCount: overrides.planets.length,
|
||||
race: "Earthlings",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
otherShipGroups: [],
|
||||
incomingShipGroups: [],
|
||||
unidentifiedShipGroups: [],
|
||||
localFleets: [],
|
||||
otherRaces: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const SOURCE_PLANET = planet({ number: 1, x: 100, y: 100, kind: "local" });
|
||||
const DEST_PLANET = planet({ number: 2, x: 110, y: 100, kind: "uninhabited" });
|
||||
const GROUP_ID = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
describe("buildPendingSendLines", () => {
|
||||
test("emits a dashed line from the orbit planet to the destination", () => {
|
||||
const report = makeReport({
|
||||
planets: [SOURCE_PLANET, DEST_PLANET],
|
||||
localShipGroups: [localGroup({ id: GROUP_ID, destination: 1 })],
|
||||
});
|
||||
const cmd: OrderCommand = {
|
||||
kind: "sendShipGroup",
|
||||
id: "cmd-1",
|
||||
groupId: GROUP_ID,
|
||||
destinationPlanetNumber: 2,
|
||||
};
|
||||
const lines = buildPendingSendLines(report, [cmd], { "cmd-1": "valid" });
|
||||
expect(lines).toHaveLength(1);
|
||||
const line = lines[0]!;
|
||||
expect(line.kind).toBe("line");
|
||||
expect(line.x1).toBe(100);
|
||||
expect(line.y1).toBe(100);
|
||||
expect(line.x2).toBe(110);
|
||||
expect(line.y2).toBe(100);
|
||||
expect(line.style.strokeDashPx).toBeGreaterThan(0);
|
||||
expect(line.style.strokeColor).toBe(0x66bb6a);
|
||||
});
|
||||
|
||||
test("uses the torus-shortest path across the seam", () => {
|
||||
const report = makeReport({
|
||||
mapWidth: 100,
|
||||
mapHeight: 100,
|
||||
planets: [
|
||||
planet({ number: 1, x: 95, y: 50, kind: "local" }),
|
||||
planet({ number: 2, x: 5, y: 50 }),
|
||||
],
|
||||
localShipGroups: [localGroup({ id: GROUP_ID, destination: 1 })],
|
||||
});
|
||||
const cmd: OrderCommand = {
|
||||
kind: "sendShipGroup",
|
||||
id: "cmd-1",
|
||||
groupId: GROUP_ID,
|
||||
destinationPlanetNumber: 2,
|
||||
};
|
||||
const lines = buildPendingSendLines(report, [cmd], { "cmd-1": "valid" });
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]!.x1).toBe(95);
|
||||
expect(lines[0]!.x2).toBe(105); // 95 + (+10) wrap delta
|
||||
});
|
||||
|
||||
test("ignores commands targeting groups missing from the report", () => {
|
||||
const report = makeReport({
|
||||
planets: [SOURCE_PLANET, DEST_PLANET],
|
||||
localShipGroups: [],
|
||||
});
|
||||
const cmd: OrderCommand = {
|
||||
kind: "sendShipGroup",
|
||||
id: "cmd-1",
|
||||
groupId: GROUP_ID,
|
||||
destinationPlanetNumber: 2,
|
||||
};
|
||||
expect(buildPendingSendLines(report, [cmd], { "cmd-1": "valid" })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores commands when the source group is in hyperspace", () => {
|
||||
const report = makeReport({
|
||||
planets: [SOURCE_PLANET, DEST_PLANET],
|
||||
localShipGroups: [
|
||||
localGroup({
|
||||
id: GROUP_ID,
|
||||
destination: 1,
|
||||
origin: 2,
|
||||
range: 5,
|
||||
state: "In_Space",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const cmd: OrderCommand = {
|
||||
kind: "sendShipGroup",
|
||||
id: "cmd-1",
|
||||
groupId: GROUP_ID,
|
||||
destinationPlanetNumber: 2,
|
||||
};
|
||||
expect(buildPendingSendLines(report, [cmd], { "cmd-1": "valid" })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("skips rejected and invalid commands", () => {
|
||||
const report = makeReport({
|
||||
planets: [SOURCE_PLANET, DEST_PLANET],
|
||||
localShipGroups: [localGroup({ id: GROUP_ID, destination: 1 })],
|
||||
});
|
||||
const cmd: OrderCommand = {
|
||||
kind: "sendShipGroup",
|
||||
id: "cmd-1",
|
||||
groupId: GROUP_ID,
|
||||
destinationPlanetNumber: 2,
|
||||
};
|
||||
expect(
|
||||
buildPendingSendLines(report, [cmd], { "cmd-1": "rejected" }),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
buildPendingSendLines(report, [cmd], { "cmd-1": "invalid" }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("ignores non-sendShipGroup commands", () => {
|
||||
const report = makeReport({
|
||||
planets: [SOURCE_PLANET, DEST_PLANET],
|
||||
localShipGroups: [localGroup({ id: GROUP_ID, destination: 1 })],
|
||||
});
|
||||
const cmd: OrderCommand = {
|
||||
kind: "dismantleShipGroup",
|
||||
id: "cmd-1",
|
||||
groupId: GROUP_ID,
|
||||
};
|
||||
expect(buildPendingSendLines(report, [cmd], { "cmd-1": "valid" })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -123,6 +123,18 @@ describe("reportToWorld — ship groups", () => {
|
||||
// dest along the segment of length 100 → (25, 0).
|
||||
expect(group.x).toBe(25);
|
||||
expect(group.y).toBe(0);
|
||||
|
||||
// Yellow dashed track from origin to destination matches the
|
||||
// in-space point colour.
|
||||
const lineId = SHIP_GROUP_ID_OFFSETS.localLine + 0;
|
||||
const line = world.primitives.find((p) => p.id === lineId);
|
||||
if (line?.kind !== "line") throw new Error("expected line");
|
||||
expect(line.x1).toBe(100);
|
||||
expect(line.y1).toBe(0);
|
||||
expect(line.x2).toBe(0);
|
||||
expect(line.y2).toBe(0);
|
||||
expect(line.style.strokeColor).toBe(0xfff176);
|
||||
expect(line.style.strokeDashPx).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("incoming-group line crosses the torus seam via the shortest path", () => {
|
||||
|
||||
Reference in New Issue
Block a user