ui: plan 01-27 done #1
+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