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
+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);
}