diff --git a/ui/PLAN.md b/ui/PLAN.md index ad8a7ba..aa4f63b 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -2177,14 +2177,15 @@ Artifacts: 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) -- 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 +- state-changing-command lock: a `Send` / `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. Load / + Unload / Split / JoinFleet do not lock — they stack legitimately + on the engine side. Send used to be unlocked too, but a queued + Send is the visible commitment to launch this orbit, so the + inspector treats it the same as the three destructive variants - `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 @@ -2277,16 +2278,34 @@ Decisions during stage: 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. +7. **State-changing-command lock**. Any `sendShipGroup`, + `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. All four + commands flip the group out of `StateInOrbit` at turn cutoff + (`StateLaunched` / `StateUpgrade` / removal / `StateTransfer`), + so any second action would race the engine's pre-condition + check anyway — the lock surfaces that commitment up-front. +8. **Pending-Send map overlay**. A queued `sendShipGroup` for an + own group still in orbit renders as a green dashed line from + the orbit planet to the destination, drawn on the same + overlay layer as cargo-route arrows. The line is wrap-aware + (uses `torusShortestDelta`) and skipped when the engine has + marked the command `rejected` or `invalid`. Removed when the + group leaves orbit (Send applied) or the player cancels the + command from the order tab. Implemented in + `ui/frontend/src/map/pending-send-routes.ts`; the overlay + fingerprint in `lib/active-view/map.svelte` is extended so the + renderer's `setExtraPrimitives` re-runs on draft changes. +9. **Yellow dashed track for own in-space groups**. The map + already drew the in-space group point in yellow (`0xfff176`); + Phase 20 adds the matching yellow dashed line from the origin + planet to the destination so the player reads "this group is + moving" even when zoomed out. Wrap-aware via the same torus + delta. Implemented in `ui/frontend/src/map/ship-groups.ts` + alongside the existing in-space point primitive. ## Phase 21. Sciences — CRUD List + Designer diff --git a/ui/docs/ship-group-actions.md b/ui/docs/ship-group-actions.md index 53b6217..dabfae6 100644 --- a/ui/docs/ship-group-actions.md +++ b/ui/docs/ship-group-actions.md @@ -92,33 +92,53 @@ every action with `ships are busy ({state})`. Per-action gates: in the same orbit (`fleet.go:135-137`); creating a new fleet always works. -## Destructive-command lock +## State-changing-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`: +`Send`, `Modernize`, `Dismantle`, and `Transfer` are +*state-changing* at turn cutoff: the engine moves the group into +`StateLaunched`, `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 four 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; + (send / 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 +Load, Unload, Split, and Join Fleet do not lock the group: those +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. +## Map overlays for in-flight and pending-Send groups + +Two dashed-line overlays run on the same renderer layer as the +cargo-route arrows: + +- **Yellow dashed track** for own ship groups currently in + hyperspace, drawn from the origin planet to the destination + (matches the colour of the in-space group point so the 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 flips + it `rejected` / `invalid`, or when the group has left orbit + (the Send was applied and the in-space track replaces it). + +Both tracks are wrap-aware via `torusShortestDelta` so a route +across the seam takes the wrap. Neither participates in +hit-test — the player still picks ship groups by clicking the +group point, not the track. + ## Modernize cost preview The form's preview line calls diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte index b5e2d89..1492f2b 100644 --- a/ui/frontend/src/lib/active-view/map.svelte +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -28,8 +28,14 @@ preference the store already manages. type RendererHandle, } from "../../map/index"; import { buildCargoRouteLines } from "../../map/cargo-routes"; + import { buildPendingSendLines } from "../../map/pending-send-routes"; import { reportToWorld, type HitTarget } from "../../map/state-binding"; import type { PrimitiveID } from "../../map/world"; + import { + ORDER_DRAFT_CONTEXT_KEY, + OrderDraftStore, + } from "../../sync/order-draft.svelte"; + import type { OrderCommand } from "../../sync/order-types"; import { GAME_STATE_CONTEXT_KEY, type GameStateStore, @@ -71,6 +77,12 @@ preference the store already manages. const pickService = getContext( MAP_PICK_CONTEXT_KEY, ); + // Pending Send commands turn into green dashed tracks on the + // map overlay; the draft is read alongside the report so the + // overlay stays in lock-step with the order tab. + const orderDraft = getContext( + ORDER_DRAFT_CONTEXT_KEY, + ); let canvasEl: HTMLCanvasElement | null = $state(null); let containerEl: HTMLDivElement | null = $state(null); @@ -114,15 +126,21 @@ preference the store already manages. if (!mounted || canvasEl === null || containerEl === null) return; if (status !== "ready" || !report) return; - // Cargo-route arrows are pushed onto the live renderer via - // `setExtraPrimitives` so the overlay can change inside a - // single turn without disposing the Pixi `Application` — - // Pixi 8 does not reliably re-init on the same canvas. The - // fingerprint guard avoids redundant Pixi rebuilds when the - // overlay computation re-runs but the routes content is - // unchanged (e.g. status transitions valid → submitting → - // applied for the same command). - const extrasFingerprint = computeRoutesFingerprint(report.routes); + // Cargo-route arrows and pending-Send tracks are pushed onto + // the live renderer via `setExtraPrimitives` so the overlay + // can change inside a single turn without disposing the Pixi + // `Application` — Pixi 8 does not reliably re-init on the + // same canvas. The fingerprint guard avoids redundant Pixi + // rebuilds when the overlay computation re-runs but the + // routes / pending-Send content is unchanged (e.g. status + // transitions valid → submitting → applied for the same + // command). + const draftCommands = orderDraft?.commands ?? []; + const draftStatuses = orderDraft?.statuses ?? {}; + const extrasFingerprint = + computeRoutesFingerprint(report.routes) + + "|" + + computePendingSendFingerprint(draftCommands, draftStatuses); const sameSnapshot = mountedTurn === report.turn && @@ -132,7 +150,10 @@ preference the store already manages. if (sameSnapshot) { if (lastExtrasFingerprint !== extrasFingerprint) { untrack(() => { - handle?.setExtraPrimitives(buildCargoRouteLines(report)); + handle?.setExtraPrimitives([ + ...buildCargoRouteLines(report), + ...buildPendingSendLines(report, draftCommands, draftStatuses), + ]); }); lastExtrasFingerprint = extrasFingerprint; } @@ -180,6 +201,20 @@ preference the store already manages. return parts.join(";"); } + function computePendingSendFingerprint( + commands: readonly OrderCommand[], + statuses: Readonly>, + ): string { + const parts: string[] = []; + for (const cmd of commands) { + if (cmd.kind !== "sendShipGroup") continue; + const status = statuses[cmd.id]; + if (status === "rejected" || status === "invalid") continue; + parts.push(`${cmd.groupId}->${cmd.destinationPlanetNumber}`); + } + return parts.join(";"); + } + async function mountRenderer( report: NonNullable, mode: "torus" | "no-wrap", diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index 246528a..1a919d2 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -310,6 +310,7 @@ const en = { "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.send": "send", "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", diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index 7ed7b7b..5731315 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -311,6 +311,7 @@ const ru: Record = { "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.send": "отправка", "game.inspector.ship_group.action.locked.kind.modernize": "модернизация", "game.inspector.ship_group.action.locked.kind.dismantle": "разборка", "game.inspector.ship_group.action.locked.kind.transfer": "передача", diff --git a/ui/frontend/src/lib/inspectors/ship-group/actions.svelte b/ui/frontend/src/lib/inspectors/ship-group/actions.svelte index 9b562a5..23808eb 100644 --- a/ui/frontend/src/lib/inspectors/ship-group/actions.svelte +++ b/ui/frontend/src/lib/inspectors/ship-group/actions.svelte @@ -125,7 +125,7 @@ modernize cost preview backed by `core.blockUpgradeCost`. // pending form would still allow Confirm despite the locked // banner above it. $effect(() => { - if (pendingDestructiveCommand !== null) { + if (pendingLockingCommand !== null) { if (sendPicking) { pick?.cancel(); sendPicking = false; @@ -245,20 +245,24 @@ modernize cost preview backed by `core.blockUpgradeCost`. return reason === null ? null : i18n.t(reason); } - // pendingDestructiveCommand watches the order draft for any + // pendingLockingCommand watches the order draft for any send / // modernize / dismantle / transfer command targeting this group. - // Once the player queues one of those three, every action on the + // Once the player queues one of those four, 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(() => { + // state-changing at turn cutoff (Send → state Launched, 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. Load / Unload / Split / + // Join Fleet stay non-locking — the engine accepts them stacked, + // and Send + the three destructive variants are the only commands + // that flip the group out of `In_Orbit` on the next tick. + const pendingLockingCommand = $derived.by(() => { if (draft === undefined) return null; for (const cmd of draft.commands) { if ( + cmd.kind !== "sendShipGroup" && cmd.kind !== "upgradeShipGroup" && cmd.kind !== "dismantleShipGroup" && cmd.kind !== "transferShipGroup" @@ -272,14 +276,16 @@ modernize cost preview backed by `core.blockUpgradeCost`. return null; }); const lockedReason: TranslationKey | null = $derived( - pendingDestructiveCommand === null + pendingLockingCommand === null ? null : "game.inspector.ship_group.action.disabled.locked", ); const lockedKindLabel = $derived.by(() => { - const cmd = pendingDestructiveCommand; + const cmd = pendingLockingCommand; if (cmd === null) return ""; switch (cmd.kind) { + case "sendShipGroup": + return i18n.t("game.inspector.ship_group.action.locked.kind.send"); case "upgradeShipGroup": return i18n.t("game.inspector.ship_group.action.locked.kind.modernize"); case "dismantleShipGroup": @@ -675,7 +681,7 @@ modernize cost preview backed by `core.blockUpgradeCost`.
- {#if pendingDestructiveCommand !== null} + {#if pendingLockingCommand !== null}

{i18n.t("game.inspector.ship_group.action.locked.banner", { command: lockedKindLabel, diff --git a/ui/frontend/src/map/pending-send-routes.ts b/ui/frontend/src/map/pending-send-routes.ts new file mode 100644 index 0000000..1c77c79 --- /dev/null +++ b/ui/frontend/src/map/pending-send-routes.ts @@ -0,0 +1,113 @@ +// Map overlay for pending Send commands. The order draft can carry +// `sendShipGroup` entries that have not yet reached the engine — +// the client wants to show them on the map straight away so the +// player can tell at a glance which orbits are about to launch +// where. Each pending Send becomes a green dashed line from the +// source group's current orbit planet to the chosen destination, +// drawn alongside the cargo-route arrows on the same overlay +// layer. +// +// The lines are *route hints*: they do not contribute to hit-test +// (the picker is the only way to issue Send), and they share the +// dashed style with in-space tracks so the player reads "this is +// motion" without confusing them with cargo arrows. + +import type { GameReport, ReportPlanet } from "../api/game-state"; +import type { OrderCommand } from "../sync/order-types"; +import { torusShortestDelta } from "./math"; +import type { LinePrim, PrimitiveID, Style } from "./world"; + +const STYLE_PENDING_SEND_LINE: Style = { + strokeColor: 0x66bb6a, + strokeAlpha: 0.85, + strokeWidthPx: 1, + strokeDashPx: 4, +}; + +// Sit between cargo-route arrows (5..8) and ship-group points (5..) +// in priority. The line never participates in hit-test (hitSlopPx=0) +// so the relative ordering only affects depth-stacking. +const PRIORITY_PENDING_SEND_LINE = 1; + +/** + * High-bit prefix on every pending-send line id so it cannot + * collide with planet numbers, ship-group ids, or cargo-route line + * ids. Cargo routes use `0x80000000`; pending-send routes use + * `0xa0000000`. The renderer's hit-test treats ids opaquely. + */ +export const PENDING_SEND_LINE_ID_PREFIX = 0xa0000000; + +/** + * buildPendingSendLines emits one `LinePrim` per `sendShipGroup` + * command in the supplied draft snapshot. Lines are drawn from the + * source group's current orbit planet to the chosen destination. + * Skipped silently when the source group is no longer in the + * report (history-mode snapshot, group already left orbit), when + * either planet is missing, or when the command's status is + * `invalid` / `rejected` (the engine refused it; do not visualise + * a route the engine will not take). + * + * The function is pure — it walks the supplied arrays and returns + * a new primitive list. Callers combine the result with cargo- + * route lines and feed both into `handle.setExtraPrimitives`. + */ +export function buildPendingSendLines( + report: GameReport, + commands: readonly OrderCommand[], + statuses: Readonly>, +): LinePrim[] { + if (commands.length === 0) return []; + const planetById = new Map(); + for (const planet of report.planets) { + planetById.set(planet.number, planet); + } + const groupById = new Map(); + for (const g of report.localShipGroups) { + groupById.set(g.id, g); + } + const lines: LinePrim[] = []; + let serial = 0; + for (const cmd of commands) { + if (cmd.kind !== "sendShipGroup") continue; + const status = statuses[cmd.id]; + if (status === "rejected" || status === "invalid") continue; + const group = groupById.get(cmd.groupId); + if (group === undefined) continue; + // The group must currently be on its orbit planet (origin + // null, range null) for the Send to make geometric sense in + // the report. Once the engine launches it the report flips + // origin / range to live coordinates and the in-space track + // renders instead. + if (group.origin !== null || group.range !== null) continue; + const source = planetById.get(group.destination); + const destination = planetById.get(cmd.destinationPlanetNumber); + if (source === undefined || destination === undefined) continue; + const dx = torusShortestDelta(source.x, destination.x, report.mapWidth); + const dy = torusShortestDelta(source.y, destination.y, report.mapHeight); + if (dx === 0 && dy === 0) continue; + lines.push({ + kind: "line", + id: pendingSendLineId(serial), + priority: PRIORITY_PENDING_SEND_LINE, + style: STYLE_PENDING_SEND_LINE, + hitSlopPx: 0, + x1: source.x, + y1: source.y, + x2: source.x + dx, + y2: source.y + dy, + }); + serial++; + } + return lines; +} + +/** + * pendingSendLineId returns the primitive id of the n-th pending- + * send line within the prefix-reserved range. Bit-OR rather than + * addition keeps the prefix unambiguous when `serial` overflows + * into the prefix bits — the renderer treats ids opaquely, but + * the encoding stays self-describing for debug dumps. + */ +function pendingSendLineId(serial: number): PrimitiveID { + return (PENDING_SEND_LINE_ID_PREFIX | (serial & 0x0fffffff)) >>> 0; +} diff --git a/ui/frontend/src/map/ship-groups.ts b/ui/frontend/src/map/ship-groups.ts index 3bcc132..24379c6 100644 --- a/ui/frontend/src/map/ship-groups.ts +++ b/ui/frontend/src/map/ship-groups.ts @@ -50,6 +50,7 @@ import type { LinePrim, PointPrim, PrimitiveID, Style } from "./world"; */ export const SHIP_GROUP_ID_OFFSETS = { local: 100_000_000, + localLine: 150_000_000, other: 200_000_000, incoming: 300_000_000, incomingLine: 350_000_000, @@ -62,6 +63,13 @@ const STYLE_LOCAL_GROUP: Style = { pointRadiusPx: 3, }; +const STYLE_LOCAL_INSPACE_LINE: Style = { + strokeColor: 0xfff176, + strokeAlpha: 0.7, + strokeWidthPx: 1, + strokeDashPx: 4, +}; + const STYLE_OTHER_GROUP: Style = { fillColor: 0xff6f40, fillAlpha: 0.9, @@ -93,6 +101,7 @@ const STYLE_UNIDENTIFIED_GROUP: Style = { // so a click on the dashed segment never "wins" over the clickable // point at the interpolated position. const PRIORITY_LOCAL = 5; +const PRIORITY_LOCAL_LINE = 0; const PRIORITY_OTHER = 5; const PRIORITY_INCOMING_POINT = 6; const PRIORITY_INCOMING_LINE = 0; @@ -120,6 +129,29 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives const id = SHIP_GROUP_ID_OFFSETS.local + i; primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_LOCAL, STYLE_LOCAL_GROUP)); lookup.set(id, { variant: "local", id: group.id }); + // Yellow dashed track from the origin planet to the destination + // planet. The colour matches the in-space group point so the + // player can read both as one entity at a glance. Wrap-aware + // like the incoming-line: we unwrap `destination` relative to + // `origin`, drawing the segment in a single tile, and PixiJS + // repeats the world in torus mode. + const origin = planetIndex.get(group.origin!); + const destination = planetIndex.get(group.destination); + if (origin !== undefined && destination !== undefined) { + const dx = torusShortestDelta(origin.x, destination.x, w); + const dy = torusShortestDelta(origin.y, destination.y, h); + primitives.push({ + kind: "line", + id: SHIP_GROUP_ID_OFFSETS.localLine + i, + priority: PRIORITY_LOCAL_LINE, + style: STYLE_LOCAL_INSPACE_LINE, + hitSlopPx: 0, + x1: origin.x, + y1: origin.y, + x2: origin.x + dx, + y2: origin.y + dy, + }); + } } for (let i = 0; i < report.otherShipGroups.length; i++) { diff --git a/ui/frontend/tests/inspector-ship-group-actions.test.ts b/ui/frontend/tests/inspector-ship-group-actions.test.ts index 5284676..69b0fe2 100644 --- a/ui/frontend/tests/inspector-ship-group-actions.test.ts +++ b/ui/frontend/tests/inspector-ship-group-actions.test.ts @@ -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(); diff --git a/ui/frontend/tests/pending-send-routes.test.ts b/ui/frontend/tests/pending-send-routes.test.ts new file mode 100644 index 0000000..b71c150 --- /dev/null +++ b/ui/frontend/tests/pending-send-routes.test.ts @@ -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 & Pick): 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 & Pick): 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 & Pick, +): 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( + [], + ); + }); +}); diff --git a/ui/frontend/tests/state-binding-groups.test.ts b/ui/frontend/tests/state-binding-groups.test.ts index 3ddf19a..90b47b8 100644 --- a/ui/frontend/tests/state-binding-groups.test.ts +++ b/ui/frontend/tests/state-binding-groups.test.ts @@ -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", () => {