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:
Ilia Denisov
2026-05-09 11:50:09 +02:00
parent 381e41b325
commit f80c623a74
86 changed files with 7505 additions and 138 deletions
+12 -1
View File
@@ -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;
}
}
+8 -1
View File
@@ -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;
+22
View File
@@ -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;
+22
View File
@@ -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;
+208 -14
View File
@@ -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;
});
+202 -19
View File
@@ -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>
+98
View File
@@ -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);
}