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:
@@ -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