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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user