ui: plan 01-27 done #1
+37
-18
@@ -2177,14 +2177,15 @@ Artifacts:
|
|||||||
planet with colonists onboard (engine reference
|
planet with colonists onboard (engine reference
|
||||||
`controller/ship_group.go:177-179` — `UnloadColonists` is not
|
`controller/ship_group.go:177-179` — `UnloadColonists` is not
|
||||||
called over a foreign planet, so the cargo is lost)
|
called over a foreign planet, so the cargo is lost)
|
||||||
- destructive-command lock: a `Modernize` / `Dismantle` /
|
- state-changing-command lock: a `Send` / `Modernize` /
|
||||||
`Transfer` order in the draft for a given group disables every
|
`Dismantle` / `Transfer` order in the draft for a given group
|
||||||
action button on that group's inspector and surfaces a banner
|
disables every action button on that group's inspector and
|
||||||
pointing to the order list. Cancelling the queued command in
|
surfaces a banner pointing to the order list. Cancelling the
|
||||||
the order tab releases the lock. Other commands (Send / Load /
|
queued command in the order tab releases the lock. Load /
|
||||||
Unload / Split / JoinFleet) do not lock — Send is naturally
|
Unload / Split / JoinFleet do not lock — they stack legitimately
|
||||||
followed by an out-of-orbit state at turn cutoff and the
|
on the engine side. Send used to be unlocked too, but a queued
|
||||||
remaining four can stack legitimately
|
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
|
- `pkg/calc/ship.go.BlockUpgradeCost` (migrated from
|
||||||
`game/internal/controller/ship_group_upgrade.go`) — the bridge
|
`game/internal/controller/ship_group_upgrade.go`) — the bridge
|
||||||
rule says `ui/core/calc/` only wraps `pkg/calc/` formulas, so
|
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
|
after a destination is chosen; cancelling the picker leaves no
|
||||||
form behind. Removing the destination control from the form
|
form behind. Removing the destination control from the form
|
||||||
keeps the surface to one editable field at any time.
|
keeps the surface to one editable field at any time.
|
||||||
7. **Destructive-command lock**. Any `upgradeShipGroup`,
|
7. **State-changing-command lock**. Any `sendShipGroup`,
|
||||||
`dismantleShipGroup`, or `transferShipGroup` in the draft for a
|
`upgradeShipGroup`, `dismantleShipGroup`, or `transferShipGroup`
|
||||||
given group id disables every action button on that group's
|
in the draft for a given group id disables every action button
|
||||||
inspector with a "command pending" tooltip and renders a
|
on that group's inspector with a "command pending" tooltip and
|
||||||
banner pointing the player at the order list. Cancellation
|
renders a banner pointing the player at the order list.
|
||||||
from the order tab releases the lock. The three commands all
|
Cancellation from the order tab releases the lock. All four
|
||||||
change the group's engine-side state at turn cutoff
|
commands flip the group out of `StateInOrbit` at turn cutoff
|
||||||
(`StateUpgrade` / removal / `StateTransfer`), so any second
|
(`StateLaunched` / `StateUpgrade` / removal / `StateTransfer`),
|
||||||
action would race the engine's pre-condition check anyway —
|
so any second action would race the engine's pre-condition
|
||||||
the lock surfaces that commitment up-front.
|
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
|
## 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
|
in the same orbit (`fleet.go:135-137`); creating a new fleet
|
||||||
always works.
|
always works.
|
||||||
|
|
||||||
## Destructive-command lock
|
## State-changing-command lock
|
||||||
|
|
||||||
`Modernize`, `Dismantle`, and `Transfer` are *state-changing* at
|
`Send`, `Modernize`, `Dismantle`, and `Transfer` are
|
||||||
turn cutoff: the engine moves the group into `StateUpgrade`,
|
*state-changing* at turn cutoff: the engine moves the group into
|
||||||
removes it, or marks it `StateTransfer` respectively. Issuing a
|
`StateLaunched`, `StateUpgrade`, removes it, or marks it
|
||||||
follow-up action against the same group during the same draft
|
`StateTransfer` respectively. Issuing a follow-up action against
|
||||||
window would race the engine's pre-condition check, so the
|
the same group during the same draft window would race the
|
||||||
inspector locks the group as soon as one of the three commands
|
engine's pre-condition check, so the inspector locks the group
|
||||||
lands in the draft for that `groupId`:
|
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
|
- every action button on the group's inspector becomes disabled
|
||||||
with the "an order is already queued" tooltip;
|
with the "an order is already queued" tooltip;
|
||||||
- a banner above the buttons row names the queued command
|
- a banner above the buttons row names the queued command
|
||||||
(modernize / dismantle / transfer) and tells the player to
|
(send / modernize / dismantle / transfer) and tells the player
|
||||||
cancel it in the order list to issue something else;
|
to cancel it in the order list to issue something else;
|
||||||
- removing the queued entry from the order tab releases the lock
|
- removing the queued entry from the order tab releases the lock
|
||||||
on the next render — the derivation watches `draft.commands`
|
on the next render — the derivation watches `draft.commands`
|
||||||
directly.
|
directly.
|
||||||
|
|
||||||
Send, Load, Unload, Split, and Join Fleet do not lock the group:
|
Load, Unload, Split, and Join Fleet do not lock the group: those
|
||||||
Send is naturally followed by an out-of-orbit state at turn
|
four can stack legitimately during the same window. The group
|
||||||
cutoff (the engine's busy check fires next turn anyway), and the
|
continues to appear in the planet inspector's stationed-ship
|
||||||
other four can stack legitimately during the same window. The
|
list while locked — the player can still navigate to 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.
|
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
|
## Modernize cost preview
|
||||||
|
|
||||||
The form's preview line calls
|
The form's preview line calls
|
||||||
|
|||||||
@@ -28,8 +28,14 @@ preference the store already manages.
|
|||||||
type RendererHandle,
|
type RendererHandle,
|
||||||
} from "../../map/index";
|
} from "../../map/index";
|
||||||
import { buildCargoRouteLines } from "../../map/cargo-routes";
|
import { buildCargoRouteLines } from "../../map/cargo-routes";
|
||||||
|
import { buildPendingSendLines } from "../../map/pending-send-routes";
|
||||||
import { reportToWorld, type HitTarget } from "../../map/state-binding";
|
import { reportToWorld, type HitTarget } from "../../map/state-binding";
|
||||||
import type { PrimitiveID } from "../../map/world";
|
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 {
|
import {
|
||||||
GAME_STATE_CONTEXT_KEY,
|
GAME_STATE_CONTEXT_KEY,
|
||||||
type GameStateStore,
|
type GameStateStore,
|
||||||
@@ -71,6 +77,12 @@ preference the store already manages.
|
|||||||
const pickService = getContext<MapPickService | undefined>(
|
const pickService = getContext<MapPickService | undefined>(
|
||||||
MAP_PICK_CONTEXT_KEY,
|
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 canvasEl: HTMLCanvasElement | null = $state(null);
|
||||||
let containerEl: HTMLDivElement | 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 (!mounted || canvasEl === null || containerEl === null) return;
|
||||||
if (status !== "ready" || !report) return;
|
if (status !== "ready" || !report) return;
|
||||||
|
|
||||||
// Cargo-route arrows are pushed onto the live renderer via
|
// Cargo-route arrows and pending-Send tracks are pushed onto
|
||||||
// `setExtraPrimitives` so the overlay can change inside a
|
// the live renderer via `setExtraPrimitives` so the overlay
|
||||||
// single turn without disposing the Pixi `Application` —
|
// can change inside a single turn without disposing the Pixi
|
||||||
// Pixi 8 does not reliably re-init on the same canvas. The
|
// `Application` — Pixi 8 does not reliably re-init on the
|
||||||
// fingerprint guard avoids redundant Pixi rebuilds when the
|
// same canvas. The fingerprint guard avoids redundant Pixi
|
||||||
// overlay computation re-runs but the routes content is
|
// rebuilds when the overlay computation re-runs but the
|
||||||
// unchanged (e.g. status transitions valid → submitting →
|
// routes / pending-Send content is unchanged (e.g. status
|
||||||
// applied for the same command).
|
// transitions valid → submitting → applied for the same
|
||||||
const extrasFingerprint = computeRoutesFingerprint(report.routes);
|
// command).
|
||||||
|
const draftCommands = orderDraft?.commands ?? [];
|
||||||
|
const draftStatuses = orderDraft?.statuses ?? {};
|
||||||
|
const extrasFingerprint =
|
||||||
|
computeRoutesFingerprint(report.routes) +
|
||||||
|
"|" +
|
||||||
|
computePendingSendFingerprint(draftCommands, draftStatuses);
|
||||||
|
|
||||||
const sameSnapshot =
|
const sameSnapshot =
|
||||||
mountedTurn === report.turn &&
|
mountedTurn === report.turn &&
|
||||||
@@ -132,7 +150,10 @@ preference the store already manages.
|
|||||||
if (sameSnapshot) {
|
if (sameSnapshot) {
|
||||||
if (lastExtrasFingerprint !== extrasFingerprint) {
|
if (lastExtrasFingerprint !== extrasFingerprint) {
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
handle?.setExtraPrimitives(buildCargoRouteLines(report));
|
handle?.setExtraPrimitives([
|
||||||
|
...buildCargoRouteLines(report),
|
||||||
|
...buildPendingSendLines(report, draftCommands, draftStatuses),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
lastExtrasFingerprint = extrasFingerprint;
|
lastExtrasFingerprint = extrasFingerprint;
|
||||||
}
|
}
|
||||||
@@ -180,6 +201,20 @@ preference the store already manages.
|
|||||||
return parts.join(";");
|
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(
|
async function mountRenderer(
|
||||||
report: NonNullable<GameStateStore["report"]>,
|
report: NonNullable<GameStateStore["report"]>,
|
||||||
mode: "torus" | "no-wrap",
|
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.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.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.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.modernize": "modernize",
|
||||||
"game.inspector.ship_group.action.locked.kind.dismantle": "dismantle",
|
"game.inspector.ship_group.action.locked.kind.dismantle": "dismantle",
|
||||||
"game.inspector.ship_group.action.locked.kind.transfer": "transfer",
|
"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.unknown_class": "класс корабля не найден в отчёте",
|
||||||
"game.inspector.ship_group.action.disabled.locked": "по группе уже отдан приказ; отмените его в списке приказов, чтобы дать новое действие",
|
"game.inspector.ship_group.action.disabled.locked": "по группе уже отдан приказ; отмените его в списке приказов, чтобы дать новое действие",
|
||||||
"game.inspector.ship_group.action.locked.banner": "по группе уже отдан приказ: {command}. Отмените его в списке приказов, чтобы дать другое действие.",
|
"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.modernize": "модернизация",
|
||||||
"game.inspector.ship_group.action.locked.kind.dismantle": "разборка",
|
"game.inspector.ship_group.action.locked.kind.dismantle": "разборка",
|
||||||
"game.inspector.ship_group.action.locked.kind.transfer": "передача",
|
"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
|
// pending form would still allow Confirm despite the locked
|
||||||
// banner above it.
|
// banner above it.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (pendingDestructiveCommand !== null) {
|
if (pendingLockingCommand !== null) {
|
||||||
if (sendPicking) {
|
if (sendPicking) {
|
||||||
pick?.cancel();
|
pick?.cancel();
|
||||||
sendPicking = false;
|
sendPicking = false;
|
||||||
@@ -245,20 +245,24 @@ modernize cost preview backed by `core.blockUpgradeCost`.
|
|||||||
return reason === null ? null : i18n.t(reason);
|
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.
|
// 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
|
// group is disabled until the draft entry is removed: each is
|
||||||
// state-changing at turn cutoff (Modernize → state Upgrade,
|
// state-changing at turn cutoff (Send → state Launched, Modernize
|
||||||
// Transfer → state Transfer, Dismantle → group removed), so a
|
// → state Upgrade, Transfer → state Transfer, Dismantle → group
|
||||||
// follow-up action would race the engine's pre-condition check
|
// removed), so a follow-up action would race the engine's
|
||||||
// and noisy-fail server-side. The lock surfaces the commitment
|
// pre-condition check and noisy-fail server-side. The lock
|
||||||
// up-front and points the player at the order list as the way
|
// surfaces the commitment up-front and points the player at the
|
||||||
// to release it.
|
// order list as the way to release it. Load / Unload / Split /
|
||||||
const pendingDestructiveCommand = $derived.by(() => {
|
// 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;
|
if (draft === undefined) return null;
|
||||||
for (const cmd of draft.commands) {
|
for (const cmd of draft.commands) {
|
||||||
if (
|
if (
|
||||||
|
cmd.kind !== "sendShipGroup" &&
|
||||||
cmd.kind !== "upgradeShipGroup" &&
|
cmd.kind !== "upgradeShipGroup" &&
|
||||||
cmd.kind !== "dismantleShipGroup" &&
|
cmd.kind !== "dismantleShipGroup" &&
|
||||||
cmd.kind !== "transferShipGroup"
|
cmd.kind !== "transferShipGroup"
|
||||||
@@ -272,14 +276,16 @@ modernize cost preview backed by `core.blockUpgradeCost`.
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
const lockedReason: TranslationKey | null = $derived(
|
const lockedReason: TranslationKey | null = $derived(
|
||||||
pendingDestructiveCommand === null
|
pendingLockingCommand === null
|
||||||
? null
|
? null
|
||||||
: "game.inspector.ship_group.action.disabled.locked",
|
: "game.inspector.ship_group.action.disabled.locked",
|
||||||
);
|
);
|
||||||
const lockedKindLabel = $derived.by(() => {
|
const lockedKindLabel = $derived.by(() => {
|
||||||
const cmd = pendingDestructiveCommand;
|
const cmd = pendingLockingCommand;
|
||||||
if (cmd === null) return "";
|
if (cmd === null) return "";
|
||||||
switch (cmd.kind) {
|
switch (cmd.kind) {
|
||||||
|
case "sendShipGroup":
|
||||||
|
return i18n.t("game.inspector.ship_group.action.locked.kind.send");
|
||||||
case "upgradeShipGroup":
|
case "upgradeShipGroup":
|
||||||
return i18n.t("game.inspector.ship_group.action.locked.kind.modernize");
|
return i18n.t("game.inspector.ship_group.action.locked.kind.modernize");
|
||||||
case "dismantleShipGroup":
|
case "dismantleShipGroup":
|
||||||
@@ -675,7 +681,7 @@ modernize cost preview backed by `core.blockUpgradeCost`.
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="actions" data-testid="inspector-ship-group-actions">
|
<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">
|
<p class="locked" data-testid="inspector-ship-group-actions-locked">
|
||||||
{i18n.t("game.inspector.ship_group.action.locked.banner", {
|
{i18n.t("game.inspector.ship_group.action.locked.banner", {
|
||||||
command: lockedKindLabel,
|
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 = {
|
export const SHIP_GROUP_ID_OFFSETS = {
|
||||||
local: 100_000_000,
|
local: 100_000_000,
|
||||||
|
localLine: 150_000_000,
|
||||||
other: 200_000_000,
|
other: 200_000_000,
|
||||||
incoming: 300_000_000,
|
incoming: 300_000_000,
|
||||||
incomingLine: 350_000_000,
|
incomingLine: 350_000_000,
|
||||||
@@ -62,6 +63,13 @@ const STYLE_LOCAL_GROUP: Style = {
|
|||||||
pointRadiusPx: 3,
|
pointRadiusPx: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STYLE_LOCAL_INSPACE_LINE: Style = {
|
||||||
|
strokeColor: 0xfff176,
|
||||||
|
strokeAlpha: 0.7,
|
||||||
|
strokeWidthPx: 1,
|
||||||
|
strokeDashPx: 4,
|
||||||
|
};
|
||||||
|
|
||||||
const STYLE_OTHER_GROUP: Style = {
|
const STYLE_OTHER_GROUP: Style = {
|
||||||
fillColor: 0xff6f40,
|
fillColor: 0xff6f40,
|
||||||
fillAlpha: 0.9,
|
fillAlpha: 0.9,
|
||||||
@@ -93,6 +101,7 @@ const STYLE_UNIDENTIFIED_GROUP: Style = {
|
|||||||
// so a click on the dashed segment never "wins" over the clickable
|
// so a click on the dashed segment never "wins" over the clickable
|
||||||
// point at the interpolated position.
|
// point at the interpolated position.
|
||||||
const PRIORITY_LOCAL = 5;
|
const PRIORITY_LOCAL = 5;
|
||||||
|
const PRIORITY_LOCAL_LINE = 0;
|
||||||
const PRIORITY_OTHER = 5;
|
const PRIORITY_OTHER = 5;
|
||||||
const PRIORITY_INCOMING_POINT = 6;
|
const PRIORITY_INCOMING_POINT = 6;
|
||||||
const PRIORITY_INCOMING_LINE = 0;
|
const PRIORITY_INCOMING_LINE = 0;
|
||||||
@@ -120,6 +129,29 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
|
|||||||
const id = SHIP_GROUP_ID_OFFSETS.local + i;
|
const id = SHIP_GROUP_ID_OFFSETS.local + i;
|
||||||
primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_LOCAL, STYLE_LOCAL_GROUP));
|
primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_LOCAL, STYLE_LOCAL_GROUP));
|
||||||
lookup.set(id, { variant: "local", id: group.id });
|
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++) {
|
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";
|
const groupId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||||
await draft.add({
|
await draft.add({
|
||||||
kind: "sendShipGroup",
|
kind: "sendShipGroup",
|
||||||
@@ -330,6 +330,22 @@ describe("ship-group inspector — destructive command lock", () => {
|
|||||||
destinationPlanetNumber: 99,
|
destinationPlanetNumber: 99,
|
||||||
});
|
});
|
||||||
const ui = mount(localGroup({ id: groupId, count: 3 }));
|
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(
|
expect(
|
||||||
ui.queryByTestId("inspector-ship-group-actions-locked"),
|
ui.queryByTestId("inspector-ship-group-actions-locked"),
|
||||||
).toBeNull();
|
).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).
|
// dest along the segment of length 100 → (25, 0).
|
||||||
expect(group.x).toBe(25);
|
expect(group.x).toBe(25);
|
||||||
expect(group.y).toBe(0);
|
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", () => {
|
test("incoming-group line crosses the torus seam via the shortest path", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user