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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user