ui/phase-21: sciences CRUD list, designer, and production-picker integration

Lights up the player-defined sciences feature: a table view with sort
and filter, a designer with four percent inputs and a strict
sum-equals-100 gate, and a Research-sub-row integration so the
planet production picker lists the user's sciences alongside the
four tech buttons. Phase 21 decisions are baked back into ui/PLAN.md
(no UpdateScience on the wire — write-once via createScience +
removeScience; percentages instead of fractions; sciences live under
the existing Research segment).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-10 21:32:37 +02:00
parent 0509f2cde2
commit 7bea22b0b5
31 changed files with 2751 additions and 71 deletions
+89 -4
View File
@@ -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,
};
}
+19
View File
@@ -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,