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:
+37
-18
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<MapPickService | undefined>(
|
||||
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<OrderDraftStore | undefined>(
|
||||
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<Record<string, string>>,
|
||||
): 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<GameStateStore["report"]>,
|
||||
mode: "torus" | "no-wrap",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -311,6 +311,7 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"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": "передача",
|
||||
|
||||
@@ -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`.
|
||||
</script>
|
||||
|
||||
<section class="actions" data-testid="inspector-ship-group-actions">
|
||||
{#if pendingDestructiveCommand !== null}
|
||||
{#if pendingLockingCommand !== null}
|
||||
<p class="locked" data-testid="inspector-ship-group-actions-locked">
|
||||
{i18n.t("game.inspector.ship_group.action.locked.banner", {
|
||||
command: lockedKindLabel,
|
||||
|
||||
@@ -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<Record<string, string>>,
|
||||
): LinePrim[] {
|
||||
if (commands.length === 0) return [];
|
||||
const planetById = new Map<number, ReportPlanet>();
|
||||
for (const planet of report.planets) {
|
||||
planetById.set(planet.number, planet);
|
||||
}
|
||||
const groupById = new Map<string, (typeof report.localShipGroups)[number]>();
|
||||
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;
|
||||
}
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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