ui/phase-17: ship-class CRUD without calc

Phase 17 lights up the ship-class table and designer active views,
extends the order-draft pipeline with createShipClass and
removeShipClass commands, and projects pending Save/Delete actions
through applyOrderOverlay so the table reflects the player's
intent before auto-sync lands. The plan is corrected in the same
patch: per game/rules.txt, ship classes are designed once and
cannot be edited — the engine has no Update command, so the UI
exposes only Create + Delete.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-09 21:44:21 +02:00
parent 8a236bef14
commit 785c3483f8
23 changed files with 2456 additions and 99 deletions
@@ -27,6 +27,7 @@ import { fetchOrder } from "./order-load";
import type { CommandStatus, OrderCommand } from "./order-types";
import { submitOrder } from "./submit";
import { validateEntityName } from "$lib/util/entity-name";
import { validateShipClass } from "$lib/util/ship-class-validation";
const NAMESPACE = "order-drafts";
const draftKey = (gameId: string): string => `${gameId}/draft`;
@@ -487,6 +488,31 @@ function validateCommand(cmd: OrderCommand): CommandStatus {
// which the inspector enforces by only mounting the
// component on `kind === "local"`.
return "valid";
case "createShipClass":
// Mirrors `pkg/calc/validator.go.ValidateShipTypeValues`
// plus the entity-name rules. The duplicate-name check is
// the designer's responsibility (it sees the live overlay
// list); here the validator runs without `existingNames`
// so a draft that was valid at creation time does not flip
// to invalid just because another `createShipClass` for
// the same name landed in the draft afterwards — both
// rows ride out the wire and the engine arbitrates.
return validateShipClass({
name: cmd.name,
drive: cmd.drive,
armament: cmd.armament,
weapons: cmd.weapons,
shields: cmd.shields,
cargo: cmd.cargo,
}).ok
? "valid"
: "invalid";
case "removeShipClass":
// `removeShipClass` carries only the name; the engine
// checks that the class exists and is not referenced by
// active production / ship groups. Local validation only
// guards the name shape.
return validateEntityName(cmd.name).ok ? "valid" : "invalid";
case "placeholder":
// Phase 12 placeholder entries are content-free and never
// transition out of `draft` — they are not submittable.
+25
View File
@@ -16,6 +16,8 @@ import {
CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
CommandShipClassCreate,
CommandShipClassRemove,
PlanetProduction,
PlanetRouteLoadType,
UserGamesOrderGet,
@@ -197,6 +199,29 @@ function decodeCommand(item: CommandItemView): OrderCommand | null {
loadType,
};
}
case CommandPayload.CommandShipClassCreate: {
const inner = new CommandShipClassCreate();
item.payload(inner);
return {
kind: "createShipClass",
id,
name: inner.name() ?? "",
drive: inner.drive(),
armament: Number(inner.armament()),
weapons: inner.weapons(),
shields: inner.shields(),
cargo: inner.cargo(),
};
}
case CommandPayload.CommandShipClassRemove: {
const inner = new CommandShipClassRemove();
item.payload(inner);
return {
kind: "removeShipClass",
id,
name: inner.name() ?? "",
};
}
default:
console.warn(
`fetchOrder: skipping unknown command kind (payloadType=${payloadType})`,
+42 -1
View File
@@ -127,6 +127,45 @@ export interface RemoveCargoRouteCommand {
readonly loadType: CargoLoadType;
}
/**
* CreateShipClassCommand designs a new ship class with the five
* tech-derived numbers plus a name. The numeric fields obey
* `pkg/calc/validator.go.ValidateShipTypeValues`: each of `drive`,
* `weapons`, `shields`, `cargo` is either zero or ≥ 1; `armament`
* is a non-negative integer; `armament` and `weapons` are both zero
* or both nonzero; not all five values may be zero. The TS-side
* mirror lives in `lib/util/ship-class-validation.ts`. Phase 17
* lands the CRUD UI; Phase 18 wires `pkg/calc/` for live previews.
*
* No collapse rule applies — each create is a distinct user-visible
* action and the engine refuses duplicate names server-side.
*/
export interface CreateShipClassCommand {
readonly kind: "createShipClass";
readonly id: string;
readonly name: string;
readonly drive: number;
readonly armament: number;
readonly weapons: number;
readonly shields: number;
readonly cargo: number;
}
/**
* RemoveShipClassCommand drops a designed ship class by name. The
* engine refuses removals when the class is referenced by an active
* planet production target or by an existing ship group
* (`game/internal/controller/ship_class.go.shipClassRemove`); both
* surface as `cmdApplied=false` on the response and the order tab
* row reads `rejected`. No client-side pre-check is needed — Phase
* 17 has no projection of ship groups yet.
*/
export interface RemoveShipClassCommand {
readonly kind: "removeShipClass";
readonly id: string;
readonly name: string;
}
/**
* OrderCommand is the discriminated union of every command shape the
* local order draft can hold. The `kind` field is the discriminator;
@@ -138,7 +177,9 @@ export type OrderCommand =
| PlanetRenameCommand
| SetProductionTypeCommand
| SetCargoRouteCommand
| RemoveCargoRouteCommand;
| RemoveCargoRouteCommand
| CreateShipClassCommand
| RemoveShipClassCommand;
/**
* PRODUCTION_TYPE_VALUES is the canonical tuple of `ProductionType`
+29
View File
@@ -31,6 +31,8 @@ import {
CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
CommandShipClassCreate,
CommandShipClassRemove,
PlanetProduction,
PlanetRouteLoadType,
UserGamesOrder,
@@ -193,6 +195,33 @@ function encodeCommandPayload(
payloadOffset: offset,
};
}
case "createShipClass": {
const nameOffset = builder.createString(cmd.name);
const offset = CommandShipClassCreate.createCommandShipClassCreate(
builder,
nameOffset,
cmd.drive,
BigInt(cmd.armament),
cmd.weapons,
cmd.shields,
cmd.cargo,
);
return {
payloadType: CommandPayload.CommandShipClassCreate,
payloadOffset: offset,
};
}
case "removeShipClass": {
const nameOffset = builder.createString(cmd.name);
const offset = CommandShipClassRemove.createCommandShipClassRemove(
builder,
nameOffset,
);
return {
payloadType: CommandPayload.CommandShipClassRemove,
payloadOffset: offset,
};
}
case "placeholder":
throw new SubmitError(
"invalid_request",