diff --git a/ui/PLAN.md b/ui/PLAN.md index e163ead..b198aba 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -2307,43 +2307,89 @@ Decisions during stage: delta. Implemented in `ui/frontend/src/map/ship-groups.ts` alongside the existing in-space point primitive. -## Phase 21. Sciences — CRUD List + Designer +## ~~Phase 21. Sciences — CRUD List + Designer~~ -Status: pending. +Status: done (local-ci run TBD). Goal: define and manage sciences (named mixes of tech proportions -summing to 1.0) through a table view and a designer. +summing to 1.0) through a table view and a designer, plus surface +them in the planet production picker. Artifacts: -- `ui/frontend/src/routes/games/[id]/table/sciences/+page.svelte` - list of sciences with name and four tech proportions -- `ui/frontend/src/routes/games/[id]/designer/science/[id]/+page.svelte` - designer with four numeric inputs that auto-normalise to 1.0 and a - name field +- `ui/frontend/src/lib/active-view/table-sciences.svelte` — sciences + list with sort / filter / Delete, mounted by the existing + `routes/games/[id]/table/[entity]` catch-all when `entity === + "sciences"`. +- `ui/frontend/src/lib/active-view/designer-science.svelte` — + designer with four percent inputs (`step="0.1"`, range + `[0, 100]`), live sum readout, strict sum-equals-100 gate, and a + read-only view mode for the existing + `routes/games/[id]/designer/science/[[scienceId]]` route. - `ui/frontend/src/sync/order-types.ts` extends with `CreateScience` - and `UpdateScience` command variants -- topic doc `ui/docs/science-designer-ux.md` covering - auto-normalisation, validation, and the relationship to the planet - production picker (Phase 15) + and `RemoveScience` command variants (the original plan mentioned + `UpdateScience`; the wire only carries Create + Remove, so the + decision below replaces Update with Remove). +- `ui/frontend/src/lib/util/science-validation.ts` — the TS-side + mirror of `pkg/calc/validator.go.ValidateScienceValues` plus the + entity-name rules and the percent → fraction conversion. +- `ui/frontend/src/api/game-state.ts` — adds `ScienceSummary`, + `localScience` on `GameReport`, decoder, and overlay branches for + `createScience` / `removeScience`. +- `ui/frontend/src/lib/inspectors/planet/production.svelte` — the + Research sub-row gains one button per defined science; click + emits `setProductionType("SCIENCE", "")`. +- topic doc `ui/docs/science-designer-ux.md` covering the percent + input model, validation, and the planet-production-picker + integration. Dependencies: Phase 17. +Decisions during stage: + +1. `UpdateScience` was a planning error: the wire schema + (`pkg/schema/fbs/order.fbs`) only carries + `CommandScienceCreate` + `CommandScienceRemove`. Sciences are + write-once on the wire — the designer's view mode therefore has + no Save-edits affordance, and an "edit" is a Remove + Create + sequence the player drives manually. Mirrors Phase 17's + ship-class pattern. +2. The production-picker integration places science buttons inside + the existing Research sub-row, alongside the four tech buttons, + instead of adding a fifth top-level segment. A science wins + over a same-named tech display when the engine sends an + ambiguous production string (a science named `Drive` shadows + the Drive tech button). +3. Designer inputs are percentages (`step="0.1"`, `[0, 100]`) with + a strict sum-equals-100 gate (`SUM_EPSILON_PERCENT = 1e-3`), + not auto-rebalanced fractions. The user controls the sum; the + designer converts to fractions only on Save before dispatching + `createScience`. + Acceptance criteria: -- the user can create, edit, and delete sciences; -- proportions auto-normalise on edit so the sum is always 1.0; +- the user can create and delete sciences (no in-place edit — see + decision 1); +- proportions are entered as one-decimal percentages and the four + must sum to exactly `100` for Save to enable; - the planet production picker (Phase 15) lists the user's sciences - and lets the user select one for research production; -- name validation matches [`rules.txt`](../game/rules.txt) constraints (length, allowed - characters, special characters not at start/end, no triple repeats). + in the Research sub-row and lets the user select one for research + production; +- name validation matches [`rules.txt`](../game/rules.txt) + constraints (length, allowed characters, special characters not + at start/end, no triple repeats). Targeted tests: -- Vitest unit tests for proportion normalisation; -- Vitest unit tests for science name validation; -- Playwright e2e: create a science, set a planet to research it, - submit, confirm. +- Vitest unit tests for percent-range validation, sum-equals-100 + gate, and percent → fraction conversion + (`tests/science-validation.test.ts`); +- Vitest component tests for the table + (`tests/table-sciences.test.ts`) and the designer + (`tests/designer-science.test.ts`); +- Playwright e2e: create a science, set a planet's production to it + via the Research sub-row, delete it + (`tests/e2e/sciences.spec.ts`). ## Phase 22. Races View — War/Peace Toggle and Votes diff --git a/ui/docs/science-designer-ux.md b/ui/docs/science-designer-ux.md new file mode 100644 index 0000000..bc0417b --- /dev/null +++ b/ui/docs/science-designer-ux.md @@ -0,0 +1,106 @@ +# Science designer UX + +A *science* is a named mix of four tech proportions — +`drive`, `weapons`, `shields`, `cargo` — that sum to `1.0`. When a +planet's production is set to a science, the planet's industry +output for that turn is split between the four tech research tracks +in those proportions +(`game/internal/controller/planet/production.go.runScienceResearch`). +Phase 21 lights up the CRUD list, the designer, and the +production-picker integration. The wire and the engine validation +are unchanged from earlier phases — only the UI is new. + +## Engine semantics in one paragraph + +`pkg/schema/fbs/order.fbs.CommandScienceCreate` carries +`name + drive + weapons + shields + cargo` as four `float64` +proportions. The engine validator +(`pkg/calc/validator.go.ValidateScienceValues`) refuses any value +outside `[0, 1]` and any sum that drifts further than its float +tolerance from `1.0`. Names follow the universal entity-name rules +(`pkg/util/string.go.ValidateTypeName`): trimmed, non-empty, ≤ 30 +runes, only letters / digits / combining marks / the allowed special +set `!@#$%^*-_=+~()[]{}`, no special at start or end, ≤ 2 specials +in a row, no whitespace. There is no `CommandScienceUpdate` on the +wire — sciences are write-once, and an "edit" is a Remove + Create +sequence. + +## Percent input model + +The designer presents the four proportions as percentages +(`step="0.1"`, range `[0, 100]`) so the player can type and reason +about whole-number splits — closer to how `game/rules.txt` describes +sciences (`game/rules.txt:345-362`: "10 parts Drive, 5 parts +Weapons, 30 parts Shields, 0 parts Cargo, …"). The wire shape is +still fractions; conversion happens inside `validateScience` only on +Save (`value / 100` for each of the four). + +The four inputs are *not* auto-rebalanced. The validator refuses a +draft whose sum drifts further than `SUM_EPSILON_PERCENT` (`1e-3`) +from `100`, and the form's Save button stays disabled until the sum +matches. A live readout under the inputs displays the running total +so the player can chase it down without trial-and-error guessing. + +The strict-sum gate is a Phase 21 decision (alternatives — +auto-rebalance, raw-parts-with-engine-normalisation — were +considered and rejected): keeping the input model close to "what +gets sent on the wire" minimises surprises when the engine returns +the science exactly as typed. See `lib/util/science-validation.ts` +for the validator and the conversion helper. + +## Name validation + +`validateScience` runs `validateEntityName` first and returns its +invalid-reason verbatim, so the designer's `aria-describedby` +mapping reuses the existing translation keys for `empty`, +`too_long`, `starts_with_special`, `ends_with_special`, +`consecutive_specials`, `whitespace`, `disallowed_character`. A +new key `duplicate_name` covers the UX-only check against the +optimistic-overlay `localScience` projection — the engine would +refuse the duplicate at submit time, but catching it locally keeps +the Save button disabled with a clear hint instead of letting a +red-badge `rejected` row land in the order tab. + +## Read-only view mode + +A `scienceId`-bearing URL renders the designer in view mode: a +read-only table of the four percentages plus name, with Back and +Delete affordances. Sciences are write-once on the wire, so there +is no Save-edits affordance — to change a science, the player +deletes it and creates a new one. Delete dispatches a +`removeScience` order command; the engine refuses removals when the +science is referenced by an active production target on any planet, +which surfaces as `rejected` in the order tab. + +## Production-picker integration + +The planet inspector's Research sub-row +(`lib/inspectors/planet/production.svelte`) renders the four tech +buttons and one extra button per defined science from the player's +`localScience` overlay. A click on a science button dispatches +`setProductionType("SCIENCE", "")`, mirroring the +wire-level `CommandPlanetProduce` shape +(`pkg/schema/fbs/order.fbs.CommandPlanetProduce`). + +The active highlight is derived from `planet.production` — the +display string the engine emits in the report. A science name +shadows the matching tech display string when they collide (a +science deliberately named `Drive` wins over the Drive tech +button), because the wire string is ambiguous and the user clearly +intended the named science. This is a pragmatic accept; a +structured production tag on the wire would let us disambiguate +without the shadow rule, but that is a separate backend concern. + +## Tests + +- `tests/science-validation.test.ts` — validator branches, percent + → fraction conversion, sum tolerance, duplicate-name detection. +- `tests/table-sciences.test.ts` — table rendering, filter, sort, + Delete dispatches `removeScience`, navigation to the designer. +- `tests/designer-science.test.ts` — empty form Save disabled, + live sum readout, valid Save dispatches `createScience` with + fractions, view-mode Delete dispatches `removeScience`, + duplicate-name guard against the overlay. +- `tests/e2e/sciences.spec.ts` — full Playwright walkthrough: + create → list → set planet production via the Research sub-row + → delete. diff --git a/ui/frontend/src/api/game-state.ts b/ui/frontend/src/api/game-state.ts index 2475840..661daa2 100644 --- a/ui/frontend/src/api/game-state.ts +++ b/ui/frontend/src/api/game-state.ts @@ -25,6 +25,13 @@ // `removeShipClass` variants — pending Save / Delete actions are // reflected in the table immediately, without waiting for the // auto-sync round-trip. +// +// Phase 21 adds `localScience` (a list of `ScienceSummary` rows +// decoded from `Report.local_science`) so the sciences table and +// designer have data to render, and extends `applyOrderOverlay` with +// the `createScience` / `removeScience` variants — pending Save / +// Delete actions surface in the table and the planet production +// picker's Research sub-row immediately. import { Builder, ByteBuffer } from "flatbuffers"; @@ -101,6 +108,25 @@ export interface ShipClassSummary { cargo: number; } +/** + * ScienceSummary is the projection of `report.Science` the sciences + * table and designer render. The four tech proportions are fractions + * in `[0, 1]` summing to `1.0`, mirroring + * `pkg/calc/validator.go.ValidateScienceValues` exactly. The designer + * presents them as percentages (`value * 100`) so users can type and + * reason about whole-number proportions; the wire shape stays + * fractional. Used by `lib/active-view/table-sciences.svelte`, + * `lib/active-view/designer-science.svelte`, and the planet + * production picker (`lib/inspectors/planet/production.svelte`). + */ +export interface ScienceSummary { + name: string; + drive: number; + weapons: number; + shields: number; + cargo: number; +} + /** * ReportRouteEntry is one slot of a planet's cargo-route table — * a (loadType, destinationPlanetNumber) pair. The engine stores @@ -233,6 +259,16 @@ export interface GameReport { * empty. */ localShipClass: ShipClassSummary[]; + /** + * localScience enumerates the player's own defined sciences. Each + * entry carries the four tech proportions as fractions in `[0, 1]` + * summing to `1.0`. Empty until at least one science is created + * (`CommandScienceCreate`, Phase 21). The sciences table and the + * planet production picker's Research sub-row read from this + * projection (after `applyOrderOverlay`) so freshly-queued + * `createScience` / `removeScience` actions surface immediately. + */ + localScience: ScienceSummary[]; /** * routes lists every cargo route the player has configured. * Each entry is keyed by source planet; the per-planet @@ -414,6 +450,19 @@ function decodeReport(report: Report): GameReport { }); } + const localScience: ScienceSummary[] = []; + for (let i = 0; i < report.localScienceLength(); i++) { + const s = report.localScience(i); + if (s === null) continue; + localScience.push({ + name: s.name() ?? "", + drive: s.drive(), + weapons: s.weapons(), + shields: s.shields(), + cargo: s.cargo(), + }); + } + const raceName = report.race() ?? ""; const routes = decodeReportRoutes(report); const localTech = findLocalPlayerTech(report, raceName); @@ -432,6 +481,7 @@ function decodeReport(report: Report): GameReport { planets, race: raceName, localShipClass, + localScience, routes, localPlayerDrive: localTech.drive, localPlayerWeapons: localTech.weapons, @@ -766,9 +816,12 @@ export function uuidToHiLo(value: string): [bigint, bigint] { * `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. + * shows pending Save / Delete actions immediately; Phase 21 to + * `createScience` / `removeScience` so the sciences table and the + * planet production picker's Research sub-row mirror pending Save / + * Delete actions. 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 @@ -787,6 +840,7 @@ export function applyOrderOverlay( let mutatedPlanets: ReportPlanet[] | null = null; let mutatedRoutes: ReportRoute[] | null = null; let mutatedShipClass: ShipClassSummary[] | null = null; + let mutatedScience: ScienceSummary[] | null = null; for (const cmd of commands) { const status = statuses[cmd.id]; if ( @@ -870,11 +924,41 @@ export function applyOrderOverlay( mutatedShipClass.splice(idx, 1); continue; } + if (cmd.kind === "createScience") { + if (mutatedScience === null) { + mutatedScience = [...report.localScience]; + } + // Skip duplicates by name: the engine refuses duplicates + // server-side and the designer's local validator pre-checks + // against the live overlay, but a stale draft could still + // carry an entry whose name now collides with the server + // snapshot. Keeping the projection unique avoids two rows in + // the table for the same name. + if (mutatedScience.some((sci) => sci.name === cmd.name)) continue; + mutatedScience.push({ + name: cmd.name, + drive: cmd.drive, + weapons: cmd.weapons, + shields: cmd.shields, + cargo: cmd.cargo, + }); + continue; + } + if (cmd.kind === "removeScience") { + if (mutatedScience === null) { + mutatedScience = [...report.localScience]; + } + const idx = mutatedScience.findIndex((sci) => sci.name === cmd.name); + if (idx < 0) continue; + mutatedScience.splice(idx, 1); + continue; + } } if ( mutatedPlanets === null && mutatedRoutes === null && - mutatedShipClass === null + mutatedShipClass === null && + mutatedScience === null ) { return report; } @@ -883,6 +967,7 @@ export function applyOrderOverlay( planets: mutatedPlanets ?? report.planets, routes: mutatedRoutes ?? report.routes, localShipClass: mutatedShipClass ?? report.localShipClass, + localScience: mutatedScience ?? report.localScience, }; } diff --git a/ui/frontend/src/api/synthetic-report.ts b/ui/frontend/src/api/synthetic-report.ts index d9e3398..f6a1174 100644 --- a/ui/frontend/src/api/synthetic-report.ts +++ b/ui/frontend/src/api/synthetic-report.ts @@ -27,6 +27,7 @@ import type { ReportPlanet, ReportRoute, ReportUnidentifiedShipGroup, + ScienceSummary, ShipClassSummary, ShipGroupTech, } from "./game-state"; @@ -144,6 +145,14 @@ interface SyntheticLocalFleet { state?: string; } +interface SyntheticScience { + name?: string; + drive?: number; + weapons?: number; + shields?: number; + cargo?: number; +} + interface SyntheticReportRoot { turn?: number; mapWidth?: number; @@ -156,6 +165,7 @@ interface SyntheticReportRoot { uninhabitedPlanet?: SyntheticPlanet[]; unidentifiedPlanet?: SyntheticPlanet[]; localShipClass?: SyntheticShipClass[]; + localScience?: SyntheticScience[]; localGroup?: SyntheticShipGroup[]; otherGroup?: SyntheticShipGroup[]; incomingGroup?: SyntheticIncomingGroup[]; @@ -194,6 +204,14 @@ function decodeSyntheticReport(json: unknown): GameReport { }), ); + const localScience: ScienceSummary[] = (root.localScience ?? []).map((sc) => ({ + name: typeof sc.name === "string" ? sc.name : "", + drive: numOr0(sc.drive), + weapons: numOr0(sc.weapons), + shields: numOr0(sc.shields), + cargo: numOr0(sc.cargo), + })); + const race = typeof root.race === "string" ? root.race : ""; const tech = findLocalPlayerTech(root.player ?? [], race); @@ -260,6 +278,7 @@ function decodeSyntheticReport(json: unknown): GameReport { planets, race, localShipClass, + localScience, routes, localPlayerDrive: tech.drive, localPlayerWeapons: tech.weapons, diff --git a/ui/frontend/src/lib/active-view/designer-science.svelte b/ui/frontend/src/lib/active-view/designer-science.svelte index d5a7f05..c2af3cb 100644 --- a/ui/frontend/src/lib/active-view/designer-science.svelte +++ b/ui/frontend/src/lib/active-view/designer-science.svelte @@ -1,28 +1,448 @@ -
-

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

-

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

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

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

+

+ {i18n.t("game.designer.science.not_found", { name: scienceId })} +

+
+ +
+ {:else} +

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

+

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

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

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

+

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

+
{ + event.preventDefault(); + void save(); + }} + > + + + + + +

+ {i18n.t("game.designer.science.field.sum", { value: sumDisplay })} +

+ {#if !validation.ok} +

+ {invalidMessage} +

+ {/if} +
+ + +
+
+ {/if}
diff --git a/ui/frontend/src/lib/active-view/table-sciences.svelte b/ui/frontend/src/lib/active-view/table-sciences.svelte new file mode 100644 index 0000000..a3caa84 --- /dev/null +++ b/ui/frontend/src/lib/active-view/table-sciences.svelte @@ -0,0 +1,333 @@ + + + +
+
+

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

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

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

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

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

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