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
+74 -17
View File
@@ -16,10 +16,15 @@
// report so the player sees their intent reflected immediately,
// without waiting for the next turn cutoff.
//
// Phase 15 extends the projection with a minimal `localShipClass`
// summary so the planet inspector's Build-Ship sub-picker has data
// to render. Phase 17 (ship-class CRUD) widens `ShipClassSummary`
// when the designer ships need the full attribute set.
// Phase 15 added a name-only `localShipClass` projection so the
// planet inspector's Build-Ship sub-picker had data to render.
// Phase 17 widens `ShipClassSummary` to the full attribute set
// (drive / armament / weapons / shields / cargo) so the ship-class
// table and designer can render every documented field, and
// extends `applyOrderOverlay` with the `createShipClass` /
// `removeShipClass` variants — pending Save / Delete actions are
// reflected in the table immediately, without waiting for the
// auto-sync round-trip.
import { Builder, ByteBuffer } from "flatbuffers";
@@ -73,15 +78,22 @@ export interface ReportPlanet {
}
/**
* ShipClassSummary is the slim projection of `report.ShipClass` the
* planet inspector's Build-Ship sub-picker needs in Phase 15. Only
* the human-visible `name` is carried the engine command shape
* (`CommandPlanetProduce.subject`) takes the class name, not its
* underlying tech values. Phase 17 widens this type when the ship
* designer needs the full attribute set.
* ShipClassSummary is the projection of `report.ShipClass` the
* ship-class table and designer render. Phase 15 carried just the
* `name` for the Build-Ship sub-picker; Phase 17 added the five
* tech-derived numbers so the table can sort / filter on them and
* the designer can populate read-only previews. The numeric ranges
* mirror `pkg/calc/validator.go.ValidateShipTypeValues` exactly:
* each of `drive`, `weapons`, `shields`, `cargo` is either zero or
* ≥ 1, and `armament` is a non-negative integer.
*/
export interface ShipClassSummary {
name: string;
drive: number;
armament: number;
weapons: number;
shields: number;
cargo: number;
}
/**
@@ -266,7 +278,14 @@ function decodeReport(report: Report): GameReport {
for (let i = 0; i < report.localShipClassLength(); i++) {
const sc = report.localShipClass(i);
if (sc === null) continue;
localShipClass.push({ name: sc.name() ?? "" });
localShipClass.push({
name: sc.name() ?? "",
drive: sc.drive(),
armament: Number(sc.armament()),
weapons: sc.weapons(),
shields: sc.shields(),
cargo: sc.cargo(),
});
}
const raceName = report.race() ?? "";
@@ -380,11 +399,12 @@ export function uuidToHiLo(value: string): [bigint, bigint] {
* applyOrderOverlay returns a copy of `report` with every locally-
* valid or still-in-flight or applied command from `commands`
* projected on top. Phase 14 introduced the overlay for
* `planetRename`; Phase 15 extends it to `setProductionType` so the
* inspector segment / map label reflect the chosen production target
* before the engine confirms it. Other variants pass through. The
* function is pure: callers re-derive the overlay whenever the draft
* or the report change.
* `planetRename`; Phase 15 extended it to `setProductionType`;
* Phase 16 to `setCargoRoute` / `removeCargoRoute`; Phase 17 to
* `createShipClass` / `removeShipClass` so the ship-class table
* shows pending Save / Delete actions immediately. Other variants
* pass through. The function is pure: callers re-derive the overlay
* whenever the draft or the report change.
*
* `statuses` maps command id → status. Entries with `valid`,
* `submitting`, or `applied` participate in the overlay — together
@@ -402,6 +422,7 @@ export function applyOrderOverlay(
if (commands.length === 0) return report;
let mutatedPlanets: ReportPlanet[] | null = null;
let mutatedRoutes: ReportRoute[] | null = null;
let mutatedShipClass: ShipClassSummary[] | null = null;
for (const cmd of commands) {
const status = statuses[cmd.id];
if (
@@ -456,12 +477,48 @@ export function applyOrderOverlay(
deleteRouteEntry(mutatedRoutes, cmd.sourcePlanetNumber, cmd.loadType);
continue;
}
if (cmd.kind === "createShipClass") {
if (mutatedShipClass === null) {
mutatedShipClass = [...report.localShipClass];
}
// Skip duplicates: the engine refuses them server-side and
// the designer's local validator prevents them client-side,
// but a stale draft could still carry a row whose name now
// collides with the server snapshot. Keeping the projection
// unique avoids two rows in the table for the same name.
if (mutatedShipClass.some((cls) => cls.name === cmd.name)) continue;
mutatedShipClass.push({
name: cmd.name,
drive: cmd.drive,
armament: cmd.armament,
weapons: cmd.weapons,
shields: cmd.shields,
cargo: cmd.cargo,
});
continue;
}
if (cmd.kind === "removeShipClass") {
if (mutatedShipClass === null) {
mutatedShipClass = [...report.localShipClass];
}
const idx = mutatedShipClass.findIndex((cls) => cls.name === cmd.name);
if (idx < 0) continue;
mutatedShipClass.splice(idx, 1);
continue;
}
}
if (
mutatedPlanets === null &&
mutatedRoutes === null &&
mutatedShipClass === null
) {
return report;
}
if (mutatedPlanets === null && mutatedRoutes === null) return report;
return {
...report,
planets: mutatedPlanets ?? report.planets,
routes: mutatedRoutes ?? report.routes,
localShipClass: mutatedShipClass ?? report.localShipClass,
};
}