ui/phase-14: rename planet end-to-end + order read-back
Wires the first end-to-end command through the full pipeline:
inspector rename action → local order draft → user.games.order
submit → optimistic overlay on map / inspector → server hydration
on cache miss via the new user.games.order.get message type.
Backend: GET /api/v1/user/games/{id}/orders forwards to engine
GET /api/v1/order. Gateway parses the engine PUT response into the
extended UserGamesOrderResponse FBS envelope and adds
executeUserGamesOrderGet for the read-back path. Frontend ports
ValidateTypeName to TS, lands the inline rename editor + Submit
button, and exposes a renderedReport context so consumers see the
overlay-applied snapshot.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -36,8 +36,15 @@ preference the store already manages.
|
||||
SELECTION_CONTEXT_KEY,
|
||||
type SelectionStore,
|
||||
} from "$lib/selection.svelte";
|
||||
import {
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
type RenderedReportSource,
|
||||
} from "$lib/rendered-report.svelte";
|
||||
|
||||
const store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
|
||||
const renderedReport = getContext<RenderedReportSource | undefined>(
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
);
|
||||
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
|
||||
|
||||
let canvasEl: HTMLCanvasElement | null = $state(null);
|
||||
@@ -52,7 +59,11 @@ preference the store already manages.
|
||||
let mounted = false;
|
||||
|
||||
$effect(() => {
|
||||
const report = store?.report;
|
||||
// Read the overlay-applied report so the map labels reflect
|
||||
// pending renames immediately. Falls back to raw report when
|
||||
// the rendered source is missing (e.g. component used outside
|
||||
// the in-game shell layout).
|
||||
const report = renderedReport?.report ?? store?.report;
|
||||
const status = store?.status ?? "idle";
|
||||
// Track the wrap mode so the renderer remounts when Phase 29's
|
||||
// toggle UI flips it; the read here also subscribes the effect.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Exposes the per-game `GalaxyClient` instance through a Svelte
|
||||
// context so command-driven UI (the order-tab submit button,
|
||||
// later phases' inspector actions) can issue gateway calls without
|
||||
// re-instantiating the client. The handle is intentionally a thin
|
||||
// reactive wrapper: the layout populates `client` after the boot
|
||||
// `Promise.all` resolves, and consumers read the latest value
|
||||
// through the getter — `null` while the boot is in flight, set to
|
||||
// the live client once the keypair / gateway public key are loaded.
|
||||
|
||||
import type { GalaxyClient } from "../api/galaxy-client";
|
||||
|
||||
/**
|
||||
* GALAXY_CLIENT_CONTEXT_KEY is the Svelte context key the in-game
|
||||
* shell layout uses to expose its bound `GalaxyClient` to
|
||||
* descendants. The order-tab submit button reads this to call
|
||||
* `submitOrder`.
|
||||
*/
|
||||
export const GALAXY_CLIENT_CONTEXT_KEY = Symbol("galaxy-client");
|
||||
|
||||
export interface GalaxyClientHandle {
|
||||
readonly client: GalaxyClient | null;
|
||||
}
|
||||
|
||||
export class GalaxyClientHolder implements GalaxyClientHandle {
|
||||
#client: GalaxyClient | null = $state(null);
|
||||
|
||||
get client(): GalaxyClient | null {
|
||||
return this.#client;
|
||||
}
|
||||
|
||||
set(client: GalaxyClient | null): void {
|
||||
this.#client = client;
|
||||
}
|
||||
}
|
||||
@@ -41,10 +41,17 @@ export class GameStateStore {
|
||||
report: GameReport | null = $state(null);
|
||||
wrapMode: WrapMode = $state("torus");
|
||||
error: string | null = $state(null);
|
||||
/**
|
||||
* currentTurn mirrors the engine's turn number for the running
|
||||
* game (lifted from the lobby record on `setGame`). Phase 14
|
||||
* exposes it so the layout can pass it to
|
||||
* `OrderDraftStore.hydrateFromServer` after both stores boot;
|
||||
* later phases (history mode, calc) will read it directly.
|
||||
*/
|
||||
currentTurn = $state(0);
|
||||
|
||||
private client: GalaxyClient | null = null;
|
||||
private cache: Cache | null = null;
|
||||
private currentTurn = 0;
|
||||
private destroyed = false;
|
||||
private visibilityListener: (() => void) | null = null;
|
||||
|
||||
|
||||
@@ -120,6 +120,17 @@ const en = {
|
||||
"game.sidebar.empty.inspector": "select an object on the map",
|
||||
"game.sidebar.empty.order": "order is empty",
|
||||
"game.sidebar.order.command_delete": "delete",
|
||||
"game.sidebar.order.submit": "submit",
|
||||
"game.sidebar.order.submit_in_flight": "submitting…",
|
||||
"game.sidebar.order.status.draft": "draft",
|
||||
"game.sidebar.order.status.valid": "valid",
|
||||
"game.sidebar.order.status.invalid": "invalid",
|
||||
"game.sidebar.order.status.submitting": "submitting",
|
||||
"game.sidebar.order.status.applied": "applied",
|
||||
"game.sidebar.order.status.rejected": "rejected",
|
||||
"game.sidebar.order.label.placeholder": "{label}",
|
||||
"game.sidebar.order.label.planet_rename": "rename planet {planet} → {name}",
|
||||
"game.sidebar.order.error.batch_failed": "submit failed: {message}",
|
||||
"game.bottom_tabs.map": "map",
|
||||
"game.bottom_tabs.calc": "calc",
|
||||
"game.bottom_tabs.order": "order",
|
||||
@@ -144,6 +155,17 @@ const en = {
|
||||
"game.inspector.planet.production_none": "none",
|
||||
"game.inspector.planet.unidentified_no_data": "no data — only the location is known",
|
||||
"game.inspector.sheet_close": "close",
|
||||
"game.inspector.planet.action.rename": "rename",
|
||||
"game.inspector.planet.rename.title": "rename planet",
|
||||
"game.inspector.planet.rename.confirm": "save",
|
||||
"game.inspector.planet.rename.cancel": "cancel",
|
||||
"game.inspector.planet.rename.invalid.empty": "name cannot be empty",
|
||||
"game.inspector.planet.rename.invalid.too_long": "name is too long (30 characters max)",
|
||||
"game.inspector.planet.rename.invalid.starts_with_special": "name cannot start with a special character",
|
||||
"game.inspector.planet.rename.invalid.ends_with_special": "name cannot end with a special character",
|
||||
"game.inspector.planet.rename.invalid.consecutive_specials": "too many special characters in a row",
|
||||
"game.inspector.planet.rename.invalid.whitespace": "name cannot contain spaces",
|
||||
"game.inspector.planet.rename.invalid.disallowed_character": "name contains disallowed characters",
|
||||
} as const;
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -121,6 +121,17 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.sidebar.empty.inspector": "выберите объект на карте",
|
||||
"game.sidebar.empty.order": "приказ пуст",
|
||||
"game.sidebar.order.command_delete": "удалить",
|
||||
"game.sidebar.order.submit": "отправить",
|
||||
"game.sidebar.order.submit_in_flight": "отправка…",
|
||||
"game.sidebar.order.status.draft": "черновик",
|
||||
"game.sidebar.order.status.valid": "готова",
|
||||
"game.sidebar.order.status.invalid": "ошибка",
|
||||
"game.sidebar.order.status.submitting": "отправка",
|
||||
"game.sidebar.order.status.applied": "принята",
|
||||
"game.sidebar.order.status.rejected": "отклонена",
|
||||
"game.sidebar.order.label.placeholder": "{label}",
|
||||
"game.sidebar.order.label.planet_rename": "переименовать планету {planet} → {name}",
|
||||
"game.sidebar.order.error.batch_failed": "ошибка отправки: {message}",
|
||||
"game.bottom_tabs.map": "карта",
|
||||
"game.bottom_tabs.calc": "калк",
|
||||
"game.bottom_tabs.order": "приказ",
|
||||
@@ -145,6 +156,17 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.inspector.planet.production_none": "не задано",
|
||||
"game.inspector.planet.unidentified_no_data": "нет данных — известно только местоположение",
|
||||
"game.inspector.sheet_close": "закрыть",
|
||||
"game.inspector.planet.action.rename": "переименовать",
|
||||
"game.inspector.planet.rename.title": "переименование планеты",
|
||||
"game.inspector.planet.rename.confirm": "сохранить",
|
||||
"game.inspector.planet.rename.cancel": "отмена",
|
||||
"game.inspector.planet.rename.invalid.empty": "имя не может быть пустым",
|
||||
"game.inspector.planet.rename.invalid.too_long": "имя слишком длинное (максимум 30 символов)",
|
||||
"game.inspector.planet.rename.invalid.starts_with_special": "имя не может начинаться со спецсимвола",
|
||||
"game.inspector.planet.rename.invalid.ends_with_special": "имя не может заканчиваться спецсимволом",
|
||||
"game.inspector.planet.rename.invalid.consecutive_specials": "слишком много спецсимволов подряд",
|
||||
"game.inspector.planet.rename.invalid.whitespace": "имя не может содержать пробелы",
|
||||
"game.inspector.planet.rename.invalid.disallowed_character": "имя содержит недопустимые символы",
|
||||
};
|
||||
|
||||
export default ru;
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
<!--
|
||||
Phase 13 read-only planet inspector. Renders the documented field
|
||||
set for the planet kind in question:
|
||||
Planet inspector. Renders the documented field set for each planet
|
||||
kind (local / other / uninhabited / unidentified) and exposes a
|
||||
Rename action on owned (`local`) planets that opens an inline
|
||||
editor. The editor runs the same `validateEntityName` rules as the
|
||||
server-side validator (parity with `pkg/util/string.go`) and, on
|
||||
confirm, appends a `planetRename` command to the local order draft
|
||||
through the `OrderDraftStore` provided via context.
|
||||
|
||||
- `local` / `other` carry the full economy: name, owner (other only),
|
||||
coordinates, size, population, colonists, industry, both stockpiles,
|
||||
natural resources, current production, free production potential.
|
||||
- `uninhabited` keeps name, coordinates, size, both stockpiles, and
|
||||
natural resources — the engine does not project industry or
|
||||
population for unowned planets.
|
||||
- `unidentified` is reduced to coordinates plus a no-data hint.
|
||||
|
||||
The component is purely presentational: the parent supplies a
|
||||
`ReportPlanet` snapshot resolved from `GameStateStore`, no store
|
||||
lookups happen here. Phase 14 will extend the same component with a
|
||||
`Rename` action; the read-only layout stays the structural baseline.
|
||||
The read-only path stays unchanged for non-`local` planets. The
|
||||
inline editor lives directly inside this component per PLAN.md
|
||||
Phase 14 — a separate file would be over-abstraction for one input
|
||||
field with five buttons.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext, tick } from "svelte";
|
||||
import type { ReportPlanet } from "../../api/game-state";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
OrderDraftStore,
|
||||
} from "../../sync/order-draft.svelte";
|
||||
import {
|
||||
validateEntityName,
|
||||
type EntityNameInvalidReason,
|
||||
} from "$lib/util/entity-name";
|
||||
|
||||
type Props = {
|
||||
planet: ReportPlanet;
|
||||
@@ -31,6 +37,34 @@ lookups happen here. Phase 14 will extend the same component with a
|
||||
unidentified: "game.inspector.planet.kind.unidentified",
|
||||
};
|
||||
|
||||
const invalidReasonKeyMap: Record<EntityNameInvalidReason, TranslationKey> = {
|
||||
empty: "game.inspector.planet.rename.invalid.empty",
|
||||
too_long: "game.inspector.planet.rename.invalid.too_long",
|
||||
starts_with_special:
|
||||
"game.inspector.planet.rename.invalid.starts_with_special",
|
||||
ends_with_special: "game.inspector.planet.rename.invalid.ends_with_special",
|
||||
consecutive_specials:
|
||||
"game.inspector.planet.rename.invalid.consecutive_specials",
|
||||
whitespace: "game.inspector.planet.rename.invalid.whitespace",
|
||||
disallowed_character:
|
||||
"game.inspector.planet.rename.invalid.disallowed_character",
|
||||
};
|
||||
|
||||
const draft = getContext<OrderDraftStore | undefined>(
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
);
|
||||
|
||||
let renameOpen = $state(false);
|
||||
let renameInput = $state("");
|
||||
let inputEl: HTMLInputElement | null = $state(null);
|
||||
|
||||
const renameValidation = $derived(validateEntityName(renameInput));
|
||||
const renameInvalidMessage = $derived(
|
||||
renameValidation.ok
|
||||
? ""
|
||||
: i18n.t(invalidReasonKeyMap[renameValidation.reason]),
|
||||
);
|
||||
|
||||
const kindLabel = $derived(i18n.t(kindKeyMap[planet.kind]));
|
||||
const coordinates = $derived(
|
||||
`(${formatNumber(planet.x)}, ${formatNumber(planet.y)})`,
|
||||
@@ -47,6 +81,44 @@ lookups happen here. Phase 14 will extend the same component with a
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function openRename(): Promise<void> {
|
||||
renameInput = planet.name;
|
||||
renameOpen = true;
|
||||
await tick();
|
||||
inputEl?.focus();
|
||||
inputEl?.select();
|
||||
}
|
||||
|
||||
function cancelRename(): void {
|
||||
renameOpen = false;
|
||||
renameInput = "";
|
||||
}
|
||||
|
||||
async function confirmRename(): Promise<void> {
|
||||
const result = validateEntityName(renameInput);
|
||||
if (!result.ok || draft === undefined) return;
|
||||
await draft.add({
|
||||
kind: "planetRename",
|
||||
id: crypto.randomUUID(),
|
||||
planetNumber: planet.number,
|
||||
name: result.value,
|
||||
});
|
||||
renameOpen = false;
|
||||
renameInput = "";
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelRename();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void confirmRename();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section
|
||||
@@ -60,8 +132,65 @@ lookups happen here. Phase 14 will extend the same component with a
|
||||
{#if planet.kind !== "unidentified"}
|
||||
<h3 class="name" data-testid="inspector-planet-name">{planet.name}</h3>
|
||||
{/if}
|
||||
{#if planet.kind === "local" && !renameOpen}
|
||||
<button
|
||||
type="button"
|
||||
class="action"
|
||||
data-testid="inspector-planet-rename-action"
|
||||
onclick={openRename}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.action.rename")}
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if planet.kind === "local" && renameOpen}
|
||||
<div class="rename" data-testid="inspector-planet-rename">
|
||||
<label class="rename-label" for="planet-rename-input">
|
||||
{i18n.t("game.inspector.planet.rename.title")}
|
||||
</label>
|
||||
<input
|
||||
id="planet-rename-input"
|
||||
type="text"
|
||||
class="rename-input"
|
||||
data-testid="inspector-planet-rename-input"
|
||||
bind:value={renameInput}
|
||||
bind:this={inputEl}
|
||||
onkeydown={onKeyDown}
|
||||
aria-invalid={renameValidation.ok ? "false" : "true"}
|
||||
aria-describedby={renameValidation.ok ? undefined : "planet-rename-error"}
|
||||
/>
|
||||
{#if !renameValidation.ok}
|
||||
<p
|
||||
id="planet-rename-error"
|
||||
class="rename-error"
|
||||
data-testid="inspector-planet-rename-error"
|
||||
>
|
||||
{renameInvalidMessage}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="rename-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="rename-cancel"
|
||||
data-testid="inspector-planet-rename-cancel"
|
||||
onclick={cancelRename}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.rename.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rename-confirm"
|
||||
data-testid="inspector-planet-rename-confirm"
|
||||
disabled={!renameValidation.ok || draft === undefined}
|
||||
onclick={() => void confirmRename()}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.rename.confirm")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<dl class="fields">
|
||||
{#if planet.kind === "other" && planet.owner !== null}
|
||||
<div class="field" data-testid="inspector-planet-field-owner">
|
||||
@@ -194,4 +323,69 @@ lookups happen here. Phase 14 will extend the same component with a
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.action {
|
||||
align-self: flex-start;
|
||||
margin-top: 0.25rem;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
background: transparent;
|
||||
color: #aab;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.action:hover {
|
||||
color: #e8eaf6;
|
||||
border-color: #6d8cff;
|
||||
}
|
||||
.rename {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.rename-label {
|
||||
font-size: 0.85rem;
|
||||
color: #aab;
|
||||
}
|
||||
.rename-input {
|
||||
font: inherit;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: #0a0e1a;
|
||||
color: #e8eaf6;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.rename-input[aria-invalid="true"] {
|
||||
border-color: #d97a7a;
|
||||
}
|
||||
.rename-error {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: #d97a7a;
|
||||
}
|
||||
.rename-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.rename-cancel,
|
||||
.rename-confirm {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0.65rem;
|
||||
background: transparent;
|
||||
color: #aab;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rename-confirm:not(:disabled):hover,
|
||||
.rename-cancel:hover {
|
||||
color: #e8eaf6;
|
||||
border-color: #6d8cff;
|
||||
}
|
||||
.rename-confirm:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Provides a derived view of the server `GameReport` overlaid with
|
||||
// the player's local order draft. Every consumer that needs to
|
||||
// render the player's current intent (inspector, map, mobile sheet)
|
||||
// subscribes through this context instead of reading `gameState.report`
|
||||
// directly.
|
||||
//
|
||||
// Lifetime matches the in-game shell layout: one source per game,
|
||||
// rebuilt on layout remount. The source itself is a thin reactive
|
||||
// wrapper — the actual overlay computation lives in
|
||||
// `applyOrderOverlay` (api/game-state.ts) and runs lazily on every
|
||||
// access through the `report` getter.
|
||||
|
||||
import {
|
||||
applyOrderOverlay,
|
||||
type GameReport,
|
||||
} from "../api/game-state";
|
||||
import type { GameStateStore } from "./game-state.svelte";
|
||||
import type { OrderDraftStore } from "../sync/order-draft.svelte";
|
||||
|
||||
/**
|
||||
* RENDERED_REPORT_CONTEXT_KEY is the Svelte context key the in-game
|
||||
* shell layout uses to expose a `RenderedReportSource` instance to
|
||||
* descendants. Consumers read the latest overlay through `source.report`
|
||||
* (a reactive getter) and re-render when the underlying stores
|
||||
* change.
|
||||
*/
|
||||
export const RENDERED_REPORT_CONTEXT_KEY = Symbol("rendered-report");
|
||||
|
||||
export interface RenderedReportSource {
|
||||
readonly report: GameReport | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* createRenderedReportSource binds the live `GameStateStore` and
|
||||
* `OrderDraftStore` to a getter that returns the overlay-applied
|
||||
* report on every read. The getter is reactive: Svelte tracks the
|
||||
* underlying `$state` accesses inside `applyOrderOverlay`, so any
|
||||
* change to the report or the draft re-runs every dependent
|
||||
* `$derived` block.
|
||||
*/
|
||||
export function createRenderedReportSource(
|
||||
gameState: GameStateStore,
|
||||
orderDraft: OrderDraftStore,
|
||||
): RenderedReportSource {
|
||||
return {
|
||||
get report(): GameReport | null {
|
||||
const raw = gameState.report;
|
||||
if (raw === null) return null;
|
||||
return applyOrderOverlay(raw, orderDraft.commands, orderDraft.statuses);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,18 +14,18 @@ from the Phase 10 stub.
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
type GameStateStore,
|
||||
} from "$lib/game-state.svelte";
|
||||
import {
|
||||
SELECTION_CONTEXT_KEY,
|
||||
type SelectionStore,
|
||||
} from "$lib/selection.svelte";
|
||||
import {
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
type RenderedReportSource,
|
||||
} from "$lib/rendered-report.svelte";
|
||||
import Planet from "$lib/inspectors/planet.svelte";
|
||||
|
||||
const gameState = getContext<GameStateStore | undefined>(
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
const renderedReport = getContext<RenderedReportSource | undefined>(
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
);
|
||||
const selection = getContext<SelectionStore | undefined>(
|
||||
SELECTION_CONTEXT_KEY,
|
||||
@@ -34,7 +34,7 @@ from the Phase 10 stub.
|
||||
const selectedPlanet = $derived.by(() => {
|
||||
const sel = selection?.selected;
|
||||
if (sel === undefined || sel === null || sel.kind !== "planet") return null;
|
||||
const report = gameState?.report;
|
||||
const report = renderedReport?.report;
|
||||
if (report === undefined || report === null) return null;
|
||||
return report.planets.find((p) => p.number === sel.id) ?? null;
|
||||
});
|
||||
|
||||
@@ -1,31 +1,143 @@
|
||||
<!--
|
||||
Order composer tool. Resolves the per-game `OrderDraftStore` from
|
||||
context (set by `routes/games/[id]/+layout.svelte`) and renders the
|
||||
draft as a vertical, top-to-bottom command list. Empty state shows
|
||||
the i18n empty-state copy; non-empty state shows an ordered list of
|
||||
rows, each with a stable `data-testid` plus a per-row delete button.
|
||||
Order composer tool. Resolves the per-game `OrderDraftStore`,
|
||||
`GameStateStore`, and `GalaxyClient` from context (all set by
|
||||
`routes/games/[id]/+layout.svelte`) and renders the local draft as
|
||||
a vertical list with per-row status, a delete button, and a Submit
|
||||
button at the bottom.
|
||||
|
||||
Phase 12 has no UI for adding commands — Phase 14 lands the first
|
||||
end-to-end command (`planetRename`) and the inspector affordance
|
||||
that pushes it into the draft. Tests exercise the skeleton through
|
||||
`__galaxyDebug.seedOrderDraft` (Playwright) and via direct store
|
||||
construction (Vitest).
|
||||
Phase 14 wires the first end-to-end command: clicking Submit calls
|
||||
`submitOrder` for every entry in `valid` status, flips the in-flight
|
||||
rows to `submitting`, then merges the per-command verdict back into
|
||||
the draft once the gateway responds. The optimistic overlay in
|
||||
`renderedReport` continues to show the player's intent while the
|
||||
order is in flight, so the inspector and the map reflect the new
|
||||
name even before the server applies it at turn cutoff.
|
||||
|
||||
Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
|
||||
(Playwright) and via direct store / mocked-client construction
|
||||
(Vitest).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
OrderDraftStore,
|
||||
} from "../../sync/order-draft.svelte";
|
||||
import {
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
type GameStateStore,
|
||||
} from "$lib/game-state.svelte";
|
||||
import {
|
||||
GALAXY_CLIENT_CONTEXT_KEY,
|
||||
type GalaxyClientHandle,
|
||||
} from "$lib/galaxy-client-context.svelte";
|
||||
import type { CommandStatus, OrderCommand } from "../../sync/order-types";
|
||||
import { submitOrder } from "../../sync/submit";
|
||||
|
||||
const draft = getContext<OrderDraftStore | undefined>(
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
);
|
||||
const gameState = getContext<GameStateStore | undefined>(
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
);
|
||||
const galaxyClient = getContext<GalaxyClientHandle | undefined>(
|
||||
GALAXY_CLIENT_CONTEXT_KEY,
|
||||
);
|
||||
|
||||
function describe(cmd: { kind: string; label?: string }): string {
|
||||
if (cmd.kind === "placeholder") return cmd.label ?? cmd.kind;
|
||||
return cmd.kind;
|
||||
const statusKeyMap: Record<CommandStatus, TranslationKey> = {
|
||||
draft: "game.sidebar.order.status.draft",
|
||||
valid: "game.sidebar.order.status.valid",
|
||||
invalid: "game.sidebar.order.status.invalid",
|
||||
submitting: "game.sidebar.order.status.submitting",
|
||||
applied: "game.sidebar.order.status.applied",
|
||||
rejected: "game.sidebar.order.status.rejected",
|
||||
};
|
||||
|
||||
let submitInFlight = $state(false);
|
||||
let submitError = $state<string | null>(null);
|
||||
|
||||
const submittable = $derived.by(() => {
|
||||
if (draft === undefined) return [] as OrderCommand[];
|
||||
return draft.commands.filter(
|
||||
(cmd) => draft.statuses[cmd.id] === "valid",
|
||||
);
|
||||
});
|
||||
|
||||
const hasInvalid = $derived.by(() => {
|
||||
if (draft === undefined) return false;
|
||||
return draft.commands.some((cmd) => draft.statuses[cmd.id] === "invalid");
|
||||
});
|
||||
|
||||
const submitDisabled = $derived(
|
||||
draft === undefined ||
|
||||
galaxyClient === undefined ||
|
||||
galaxyClient.client === null ||
|
||||
submitInFlight ||
|
||||
submittable.length === 0 ||
|
||||
hasInvalid,
|
||||
);
|
||||
|
||||
function describe(cmd: OrderCommand): string {
|
||||
switch (cmd.kind) {
|
||||
case "placeholder":
|
||||
return i18n.t("game.sidebar.order.label.placeholder", {
|
||||
label: cmd.label,
|
||||
});
|
||||
case "planetRename":
|
||||
return i18n.t("game.sidebar.order.label.planet_rename", {
|
||||
planet: String(cmd.planetNumber),
|
||||
name: cmd.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function statusOf(cmd: OrderCommand): CommandStatus {
|
||||
return draft?.statuses[cmd.id] ?? "draft";
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (
|
||||
draft === undefined ||
|
||||
galaxyClient === undefined ||
|
||||
galaxyClient.client === null ||
|
||||
gameState === undefined
|
||||
)
|
||||
return;
|
||||
if (submittable.length === 0 || hasInvalid) return;
|
||||
const ids = submittable.map((cmd) => cmd.id);
|
||||
const snapshot = submittable.slice();
|
||||
submitInFlight = true;
|
||||
submitError = null;
|
||||
draft.markSubmitting(ids);
|
||||
try {
|
||||
const result = await submitOrder(
|
||||
galaxyClient.client,
|
||||
gameState.gameId,
|
||||
snapshot,
|
||||
{ updatedAt: draft.updatedAt },
|
||||
);
|
||||
if (result.ok) {
|
||||
draft.applyResults({
|
||||
results: result.results,
|
||||
updatedAt: result.updatedAt,
|
||||
});
|
||||
if (gameState !== undefined) {
|
||||
await gameState.refresh();
|
||||
}
|
||||
} else {
|
||||
draft.markRejected(ids);
|
||||
submitError = i18n.t("game.sidebar.order.error.batch_failed", {
|
||||
message: result.message,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
draft.revertSubmittingToValid();
|
||||
submitError =
|
||||
err instanceof Error ? err.message : "submit failed";
|
||||
} finally {
|
||||
submitInFlight = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -38,11 +150,22 @@ construction (Vitest).
|
||||
{:else}
|
||||
<ol class="commands" data-testid="order-list">
|
||||
{#each draft.commands as cmd, index (cmd.id)}
|
||||
<li class="command" data-testid="order-command-{index}">
|
||||
{@const status = statusOf(cmd)}
|
||||
<li
|
||||
class="command"
|
||||
data-testid="order-command-{index}"
|
||||
data-command-status={status}
|
||||
>
|
||||
<span class="index" aria-hidden="true">{index + 1}.</span>
|
||||
<span class="label" data-testid="order-command-label-{index}">
|
||||
{describe(cmd)}
|
||||
</span>
|
||||
<span
|
||||
class="status status-{status}"
|
||||
data-testid="order-command-status-{index}"
|
||||
>
|
||||
{i18n.t(statusKeyMap[status])}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="delete"
|
||||
@@ -54,6 +177,20 @@ construction (Vitest).
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
<button
|
||||
type="button"
|
||||
class="submit"
|
||||
data-testid="order-submit"
|
||||
disabled={submitDisabled}
|
||||
onclick={() => void submit()}
|
||||
>
|
||||
{submitInFlight
|
||||
? i18n.t("game.sidebar.order.submit_in_flight")
|
||||
: i18n.t("game.sidebar.order.submit")}
|
||||
</button>
|
||||
{#if submitError !== null}
|
||||
<p class="error" data-testid="order-submit-error">{submitError}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -72,14 +209,15 @@ construction (Vitest).
|
||||
}
|
||||
.commands {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.command {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
@@ -88,17 +226,40 @@ construction (Vitest).
|
||||
border-radius: 4px;
|
||||
}
|
||||
.index {
|
||||
min-width: 1.5rem;
|
||||
color: #aab;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #2a3150;
|
||||
color: #aab;
|
||||
}
|
||||
.status-applied {
|
||||
color: #8be9a3;
|
||||
border-color: #2f6d3f;
|
||||
}
|
||||
.status-rejected {
|
||||
color: #d97a7a;
|
||||
border-color: #6d2f2f;
|
||||
}
|
||||
.status-invalid {
|
||||
color: #d6b86c;
|
||||
border-color: #6d562f;
|
||||
}
|
||||
.status-submitting {
|
||||
color: #6d8cff;
|
||||
border-color: #2f3f6d;
|
||||
}
|
||||
.delete {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
@@ -113,4 +274,26 @@ construction (Vitest).
|
||||
color: #e8eaf6;
|
||||
border-color: #6d8cff;
|
||||
}
|
||||
.submit {
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.4rem 1rem;
|
||||
background: #1d2440;
|
||||
color: #e8eaf6;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.submit:not(:disabled):hover {
|
||||
border-color: #6d8cff;
|
||||
}
|
||||
.submit:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.error {
|
||||
margin: 0.5rem 0 0;
|
||||
color: #d97a7a;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// TS port of `pkg/util/string.go.ValidateTypeName` — every entity
|
||||
// name (planet, ship class, science, …) the player edits goes
|
||||
// through this validator before reaching the order draft, so the
|
||||
// client-side check is identical to the server-side one. A
|
||||
// locally-valid name is always accepted at the wire level; an
|
||||
// invalid name never produces a network round-trip.
|
||||
|
||||
const MAX_LENGTH = 30;
|
||||
|
||||
const ALLOWED_SPECIALS = new Set<string>("!@#$%^*-_=+~()[]{}");
|
||||
|
||||
const SPECIAL_RUN_LIMIT = 2;
|
||||
|
||||
/**
|
||||
* EntityNameInvalidReason is the closed enumeration of reasons a
|
||||
* name can fail validation. The values are stable identifiers so
|
||||
* the inspector tooltip and the order-tab status row can map them
|
||||
* to localised copy via `i18n.t("game.order.invalid." + reason)`.
|
||||
*/
|
||||
export type EntityNameInvalidReason =
|
||||
| "empty"
|
||||
| "too_long"
|
||||
| "starts_with_special"
|
||||
| "ends_with_special"
|
||||
| "consecutive_specials"
|
||||
| "whitespace"
|
||||
| "disallowed_character";
|
||||
|
||||
export type EntityNameValidation =
|
||||
| { ok: true; value: string }
|
||||
| { ok: false; reason: EntityNameInvalidReason };
|
||||
|
||||
/**
|
||||
* validateEntityName mirrors `ValidateTypeName` exactly: the input
|
||||
* is trimmed, must be non-empty, must fit in 30 runes, must not
|
||||
* start or end with a special character, and must contain only
|
||||
* letters, digits, combining marks, or the allowed specials with at
|
||||
* most two in a row. Returns the trimmed value on success or a
|
||||
* structured reason on failure.
|
||||
*/
|
||||
export function validateEntityName(input: string): EntityNameValidation {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return { ok: false, reason: "empty" };
|
||||
}
|
||||
|
||||
const runes = Array.from(trimmed);
|
||||
if (runes.length > MAX_LENGTH) {
|
||||
return { ok: false, reason: "too_long" };
|
||||
}
|
||||
|
||||
const first = runes[0]!;
|
||||
const last = runes[runes.length - 1]!;
|
||||
if (ALLOWED_SPECIALS.has(first)) {
|
||||
return { ok: false, reason: "starts_with_special" };
|
||||
}
|
||||
if (ALLOWED_SPECIALS.has(last)) {
|
||||
return { ok: false, reason: "ends_with_special" };
|
||||
}
|
||||
|
||||
let specialRun = 0;
|
||||
for (const rune of runes) {
|
||||
if (isWhitespace(rune)) {
|
||||
return { ok: false, reason: "whitespace" };
|
||||
}
|
||||
if (isLetter(rune) || isDigit(rune) || isCombiningMark(rune)) {
|
||||
specialRun = 0;
|
||||
continue;
|
||||
}
|
||||
if (ALLOWED_SPECIALS.has(rune)) {
|
||||
specialRun += 1;
|
||||
if (specialRun > SPECIAL_RUN_LIMIT) {
|
||||
return { ok: false, reason: "consecutive_specials" };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return { ok: false, reason: "disallowed_character" };
|
||||
}
|
||||
|
||||
return { ok: true, value: trimmed };
|
||||
}
|
||||
|
||||
function isWhitespace(rune: string): boolean {
|
||||
// Matches Go's `unicode.IsSpace`.
|
||||
return /\s/u.test(rune);
|
||||
}
|
||||
|
||||
function isLetter(rune: string): boolean {
|
||||
return /\p{L}/u.test(rune);
|
||||
}
|
||||
|
||||
function isDigit(rune: string): boolean {
|
||||
return /\p{N}/u.test(rune);
|
||||
}
|
||||
|
||||
function isCombiningMark(rune: string): boolean {
|
||||
return /\p{M}/u.test(rune);
|
||||
}
|
||||
Reference in New Issue
Block a user