diff --git a/ui/PLAN.md b/ui/PLAN.md index 63a2cb3..bae4fac 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -1947,35 +1947,67 @@ Decisions baked into Phase 16 (vs. the original stage description): Status: pending. -Goal: list, view, and edit ship classes through a dedicated table view -and a designer view; numeric calculations are stubbed pending Phase -18. +Goal: list, view, create, and delete ship classes through a +dedicated table view and a designer view; numeric calculations are +stubbed pending Phase 18. + +Per `game/rules.txt`, ship classes are designed once and cannot be +modified after creation — values are baked into existing ships at +build time. The future "upgrade" command (Phase 19/20, +`CommandShipGroupUpgrade`) raises an existing ship group's tech +levels but does not edit the class blueprint. Phase 17 therefore +exposes only Create and Delete; an "edit" affordance is +deliberately absent and the designer renders an existing class +read-only. Artifacts: -- `ui/frontend/src/routes/games/[id]/table/ship-classes/+page.svelte` - table of ship classes with sort and filter -- `ui/frontend/src/routes/games/[id]/designer/ship-class/[id]/+page.svelte` - designer form with all five fields (Drive, Armament, Weapons, - Shields, Cargo) plus name; validation rules from [`rules.txt`](../game/rules.txt) - (each field 0 or ≥1; armament integer; weapons and armament both - zero or both nonzero) +- `ui/frontend/src/lib/active-view/table-ship-classes.svelte` + table of ship classes with sort and filter, plus per-row Delete + affordance (the existing `routes/games/[id]/table/[entity]/+page.svelte` + already wires this active view through the `[entity]` parameter, + so no new route file lands). +- `ui/frontend/src/lib/active-view/designer-ship-class.svelte` + rewritten from the Phase 10 stub: empty form for the Create flow + (name plus the five fields Drive, Armament, Weapons, Shields, + Cargo) and read-only view + Delete affordance for an existing + class. Validation rules from [`rules.txt`](../game/rules.txt) live + in `lib/util/ship-class-validation.ts` (TS port of + `pkg/calc/validator.go.ValidateShipTypeValues`): each of drive / + weapons / shields / cargo is 0 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 existing + `routes/games/[id]/designer/ship-class/[[classId]]/+page.svelte` + is already wired and consumes the optional `classId` URL segment + through `page.params`. - `ui/frontend/src/sync/order-types.ts` extends with - `CreateShipClass` and `UpdateShipClass` command variants + `CreateShipClassCommand` and `RemoveShipClassCommand` variants + (mapped to `CommandShipClassCreate` and `CommandShipClassRemove` + on the wire by `sync/submit.ts` and `sync/order-load.ts`). +- `ui/frontend/src/api/game-state.ts` widens `ShipClassSummary` + to carry the full attribute set; `applyOrderOverlay` projects + pending Save / Delete actions onto `localShipClass` so the table + reflects the player's intent before auto-sync lands. Dependencies: Phase 14. Acceptance criteria: -- the user can create, list, edit, and delete ship classes; -- field validation matches [`rules.txt`](../game/rules.txt) constraints with disabled - Submit + tooltip when invalid; -- double-tapping a row in the ship-classes table opens its designer. +- the user can create, list, view, and delete ship classes; +- field validation matches [`rules.txt`](../game/rules.txt) + constraints with disabled Submit + tooltip when invalid; +- double-tapping a row in the ship-classes table opens its + designer (read-only view of the existing class). Targeted tests: -- Vitest component tests for designer field validation; -- Playwright e2e: create a class, list it, edit it, delete it. +- Vitest component tests for designer field validation + (`tests/designer-ship-class.test.ts`) and the table + (`tests/table-ship-classes.test.ts`); Vitest unit tests for the + validator (`tests/ship-class-validation.test.ts`); +- Playwright e2e (`tests/e2e/ship-classes.spec.ts`): create a + class, list it, delete it; rejected-submit kept; field-validation + kept (Save disabled with localised tooltip). ## Phase 18. Ship Classes — Calc Bridge diff --git a/ui/docs/navigation.md b/ui/docs/navigation.md index 95dd93b..93f3614 100644 --- a/ui/docs/navigation.md +++ b/ui/docs/navigation.md @@ -20,23 +20,30 @@ separate dispatch component. | URL | Active view component | Phase that fills it | | ------------------------------------- | ---------------------------------------------- | ----------------------- | -| `/games/:id/map` | `lib/active-view/map.svelte` | Phase 11 | -| `/games/:id/table/:entity` | `lib/active-view/table.svelte` | Phase 11 / 17 / 19 / 22 | -| `/games/:id/report` | `lib/active-view/report.svelte` | Phase 23 | -| `/games/:id/battle/:battleId?` | `lib/active-view/battle.svelte` | Phase 27 | -| `/games/:id/mail` | `lib/active-view/mail.svelte` | Phase 28 | -| `/games/:id/designer/ship-class/:id?` | `lib/active-view/designer-ship-class.svelte` | Phase 17 / 18 | -| `/games/:id/designer/science/:id?` | `lib/active-view/designer-science.svelte` | Phase 21 | +| URL | Active view component | Phase that fills it | +| ------------------------------------------ | ---------------------------------------------- | ----------------------- | +| `/games/:id/map` | `lib/active-view/map.svelte` | Phase 11 | +| `/games/:id/table/:entity` | `lib/active-view/table.svelte` | Phase 11 / 17 / 19 / 22 | +| `/games/:id/report` | `lib/active-view/report.svelte` | Phase 23 | +| `/games/:id/battle/:battleId?` | `lib/active-view/battle.svelte` | Phase 27 | +| `/games/:id/mail` | `lib/active-view/mail.svelte` | Phase 28 | +| `/games/:id/designer/ship-class/:classId?` | `lib/active-view/designer-ship-class.svelte` | Phase 17 (CRUD) / 18 (calc preview) | +| `/games/:id/designer/science/:scienceId?` | `lib/active-view/designer-science.svelte` | Phase 21 | `/games/:id` (no trailing view) redirects to `/games/:id/map`. The -optional `:id?` segments on the designer routes match SvelteKit's -`[[id]]` syntax — they accept both the new-draft and editing URLs; -later phases read the param when wiring real content. +optional `:classId?` / `:scienceId?` segments on the designer +routes match SvelteKit's `[[classId]]` syntax — `/designer/ship-class` +opens the empty new-class form, `/designer/ship-class/{name}` +opens the read-only view of the named class with the Delete +affordance. Phase 17 lights up the ship-class CRUD path; Phase 18 +adds the live `pkg/calc/`-backed preview pane on top. The `entity` slug on the table route is kebab-case (`planets`, -`ship-classes`, `ship-groups`, `fleets`, `sciences`, `races`); the -table stub maps it to the matching `game.view.table.` i18n -key. +`ship-classes`, `ship-groups`, `fleets`, `sciences`, `races`). +`table.svelte` is the active-view router: it dispatches by slug to +the per-entity component (`ship-classes` → `table-ship-classes.svelte` +in Phase 17; the others fall back to the Phase 10 stub copy until +their respective phases land). ## Sidebar tools and state preservation diff --git a/ui/frontend/src/api/game-state.ts b/ui/frontend/src/api/game-state.ts index b5188f3..0445a8b 100644 --- a/ui/frontend/src/api/game-state.ts +++ b/ui/frontend/src/api/game-state.ts @@ -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, }; } diff --git a/ui/frontend/src/lib/active-view/designer-ship-class.svelte b/ui/frontend/src/lib/active-view/designer-ship-class.svelte index 0dd5363..7bb39cb 100644 --- a/ui/frontend/src/lib/active-view/designer-ship-class.svelte +++ b/ui/frontend/src/lib/active-view/designer-ship-class.svelte @@ -1,28 +1,434 @@ -
-

{i18n.t("game.view.designer.ship_class")}

-

{i18n.t("game.shell.coming_soon")}

+
+ {#if isViewMode} + {#if viewing === null} +

{i18n.t("game.view.designer.ship_class")}

+

+ {i18n.t("game.designer.ship_class.not_found", { name: classId })} +

+
+ +
+ {:else} +

+ {i18n.t("game.designer.ship_class.title.view", { name: viewing.name })} +

+

+ {i18n.t("game.designer.ship_class.read_only_notice")} +

+
+
+
{i18n.t("game.designer.ship_class.field.name")}
+
{viewing.name}
+
+
+
{i18n.t("game.designer.ship_class.field.drive")}
+
+ {formatNumber(viewing.drive)} +
+
+
+
{i18n.t("game.designer.ship_class.field.armament")}
+
+ {viewing.armament} +
+
+
+
{i18n.t("game.designer.ship_class.field.weapons")}
+
+ {formatNumber(viewing.weapons)} +
+
+
+
{i18n.t("game.designer.ship_class.field.shields")}
+
+ {formatNumber(viewing.shields)} +
+
+
+
{i18n.t("game.designer.ship_class.field.cargo")}
+
+ {formatNumber(viewing.cargo)} +
+
+
+
+ + +
+ {/if} + {:else} +

+ {i18n.t("game.designer.ship_class.title.new")} +

+

+ {i18n.t("game.designer.ship_class.hint.values")} +

+
{ + event.preventDefault(); + void save(); + }} + > + + + + + + + {#if !validation.ok} +

+ {invalidMessage} +

+ {/if} +
+ + +
+
+ {/if}
diff --git a/ui/frontend/src/lib/active-view/table-ship-classes.svelte b/ui/frontend/src/lib/active-view/table-ship-classes.svelte new file mode 100644 index 0000000..3a723bb --- /dev/null +++ b/ui/frontend/src/lib/active-view/table-ship-classes.svelte @@ -0,0 +1,328 @@ + + + +
+
+

{i18n.t("game.table.ship_classes.title")}

+
+ + +
+
+ + {#if !reportLoaded} +

+ {i18n.t("game.table.ship_classes.loading")} +

+ {:else if localShipClass.length === 0} +

+ {i18n.t("game.table.ship_classes.empty")} +

+ {:else} + + + + {#each COLUMNS as column (column)} + + {/each} + + + + + {#each sorted as cls (cls.name)} + openDesigner(cls.name)} + > + + + + + + + + + {/each} + +
+ + {i18n.t("game.table.ship_classes.column.actions")}
{cls.name}{formatNumber(cls.drive)}{cls.armament}{formatNumber(cls.weapons)}{formatNumber(cls.shields)}{formatNumber(cls.cargo)} + +
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/table.svelte b/ui/frontend/src/lib/active-view/table.svelte index bd15827..cba748d 100644 --- a/ui/frontend/src/lib/active-view/table.svelte +++ b/ui/frontend/src/lib/active-view/table.svelte @@ -1,11 +1,15 @@ -
-

- {i18n.t("game.view.table")}: {i18n.t(entityKey(entity))} -

-

{i18n.t("game.shell.coming_soon")}

-
+{#if entity === "ship-classes"} + +{:else} +
+

+ {i18n.t("game.view.table")}: {i18n.t(entityKey(entity))} +

+

{i18n.t("game.shell.coming_soon")}

+
+{/if}