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:
@@ -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.
|
||||
|
||||
@@ -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})`,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user