ui/phase-15: planet inspector production controls + order-draft collapse

Adds the second end-to-end command (`setProductionType`) with a
collapse-by-`planetNumber` rule on the order draft, the segmented
production-controls component on the planet inspector, the FBS
encoder/decoder pair for `CommandPlanetProduce`, and the
`localShipClass` projection on `GameReport`. Forecast number is
deferred and tracked in the new `ui/docs/calc-bridge.md`.
This commit is contained in:
Ilia Denisov
2026-05-09 15:54:30 +02:00
parent c4f1409329
commit 915b4372dd
31 changed files with 2200 additions and 76 deletions
+109 -21
View File
@@ -1686,38 +1686,126 @@ Verified on local-ci run 11 (`success`, f80c623).
Status: pending.
Goal: let the user switch a planet's production type to industry,
materials, research a science, or build a ship class; each change
appends a command to the order draft.
materials, research a tech field, or build a ship class; each change
appends a command to the order draft. Repeated changes for the same
planet collapse to the latest choice.
Artifacts:
Decisions taken with the project owner during implementation:
- `ui/frontend/src/lib/inspectors/planet/production.svelte` segmented
control with the four production options; a sub-picker for science
and ship class targets
- `ui/frontend/src/sync/order-types.ts` extends with
`SetProductionType` command variant
- references to `pkg/calc/` predictions (free production potential,
forecast output for current type) — wired through `ui/core/calc/`
- audit `ui/docs/calc-bridge.md` updates this phase's required calc
functions; if any are missing in `pkg/calc/`, raise as blocker
1. **Forecast is deferred and raised as a blocker.** The plan's audit
clause discovered that `pkg/calc/` only carries the two ship-side
functions (`ShipProductionCost`, `PlanetProduceShipMass`); every
other forecast formula (industry, materials, per-tech research,
production capacity) lives inside
`game/internal/model/game/planet.go` and is not exported.
`ui/core/calc/` and `ui/docs/calc-bridge.md` did not exist at all.
Phase 15 creates `ui/docs/calc-bridge.md` documenting the gap and
waives the forecast deliverable until a dedicated future phase
builds the real Go → WASM → TS bridge. The inspector continues to
show only the existing `freeIndustry` (free production potential)
number, which is computed engine-side and ships in the report
payload.
2. **Sub-pickers expose only what the game data already supports.**
"Research" sub-row shows the four implicit tech fields
(DRIVE / WEAPONS / SHIELDS / CARGO); custom `LocalScience`
entries are deferred until the science designer phase introduces
them. "Build Ship" sub-row shows `LocalShipClass` entries; the
`GameReport` projection is extended with a minimal
`ShipClassSummary { name }` so the e2e spec can seed one ship
class and exercise the SHIP branch end-to-end. Empty
`LocalShipClass` collapses to a localised "no ship classes
designed yet" placeholder.
3. **Re-clicks always emit a command.** The collapse-by-`planetNumber`
rule keeps at most one `setProductionType` per planet in the
draft. A click that lands on the segment matching `report.production`
still emits a command; the engine accepts repeat submits
idempotently. Avoids a fragile reverse-mapping from
`report.production` display strings (`"Drive"`, ship-class name,
science name) back to the FBS enum.
4. **Inspector layout split.** `ui/frontend/src/lib/inspectors/planet/
production.svelte` is the new component; the parent
`inspectors/planet.svelte` mounts it for `kind === "local"`
planets and drops the static read-only "current production" row
on that branch (the row stays for non-local planets). The mobile
sheet (`planet-sheet.svelte`) and the sidebar
(`sidebar/inspector-tab.svelte`) both forward
`localShipClass` from the rendered-report context.
Artifacts (delivered):
- `ui/frontend/src/sync/order-types.ts` — `SetProductionTypeCommand`
variant + `ProductionType` literal union + `PRODUCTION_TYPE_VALUES`
/ `isProductionType` helpers.
- `ui/frontend/src/sync/order-draft.svelte.ts` — `validateCommand`
branch (mirrors the engine's `subject=Production` rule); `add`
enforces collapse-by-`planetNumber` for the new variant only.
- `ui/frontend/src/sync/submit.ts` — encodes
`CommandPlanetProduce` via the new `productionTypeToFBS` helper.
- `ui/frontend/src/sync/order-load.ts` — decodes
`CommandPlanetProduce` via `productionTypeFromFBS` and skips
`PlanetProduction.UNKNOWN` rows.
- `ui/frontend/src/api/game-state.ts` — `applyOrderOverlay` rewrites
`planet.production` for `setProductionType` (helper
`productionDisplayFromCommand` mirrors
`Cache.PlanetProductionDisplayName`); new `ShipClassSummary` type
and `GameReport.localShipClass` projection (decoded from
`report.localShipClass`).
- `ui/frontend/src/lib/inspectors/planet/production.svelte` — new
segmented control with Research / Build-Ship sub-rows.
- `ui/frontend/src/lib/inspectors/planet.svelte` — accepts
`localShipClass` prop, mounts `<Production />` for local planets,
drops the static production row on that branch only.
- `ui/frontend/src/lib/inspectors/planet-sheet.svelte` and
`ui/frontend/src/lib/sidebar/inspector-tab.svelte` — forward
`localShipClass` from the rendered report context.
- `ui/frontend/src/routes/games/[id]/+layout.svelte` — derives
`localShipClass` and passes it to the mobile sheet.
- `ui/frontend/src/lib/sidebar/order-tab.svelte` — new label branch
for `setProductionType` using the new locale key.
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — production-control
copy plus the new order-tab label.
- `ui/frontend/tests/e2e/fixtures/report-fbs.ts` — extended with a
`localShipClass` fixture vector.
- `ui/frontend/tests/e2e/fixtures/order-fbs.ts` — discriminated
fixture union supporting both `planetRename` and
`setProductionType` payloads.
- `ui/docs/calc-bridge.md` (new) — calc-bridge gap analysis and the
Phase 15 waiver.
- `ui/docs/order-composer.md` — updated discriminated-union
reference + new "Collapse-by-target rule" section.
- Tests: extended `order-draft.test.ts`, `submit.test.ts`,
`order-load.test.ts`, `order-overlay.test.ts`,
`game-state.test.ts`, `inspector-planet.test.ts`; new
`inspector-planet-production.test.ts` Vitest component spec; new
`tests/e2e/planet-production.spec.ts` Playwright spec.
Dependencies: Phase 14.
Acceptance criteria:
- changing production type adds exactly one `SetProductionType`
command to the order draft;
- changing production type adds exactly one `setProductionType`
command to the order draft, with the engine wire shape
(`CommandPlanetProduce` + `subject` rule for `SCIENCE` / `SHIP`);
- repeated changes for the same planet collapse to the latest choice
(no duplicate commands per planet);
- forecast output number reflects the chosen production type and
matches `pkg/calc/` outputs.
(no duplicate `setProductionType` commands per planet); other
variants (e.g. `planetRename`) keep their append-only behaviour;
- forecast output number is intentionally **not** rendered in this
phase (waived per decision 1; tracked in `ui/docs/calc-bridge.md`).
Targeted tests:
- Vitest unit tests for the collapse-duplicates logic in order draft;
- Vitest component tests for forecast number rendering;
- Playwright e2e: switch production three times, submit, confirm
server reflects the latest choice.
- Vitest unit tests for the collapse-by-`planetNumber` logic in
`OrderDraftStore.add` and the `setProductionType` branch of
`validateCommand`;
- Vitest unit tests for the FBS encoder / decoder round-trip and the
`productionDisplayFromCommand` helper;
- Vitest component tests for the segmented control's segment
emission, sub-row reveal, empty-classes placeholder, and active-
highlight derivation;
- Playwright e2e: switch production three times across all four
segments, confirm the order tab carries exactly one row at every
step, gateway records the latest choice (`SHIP` + class name),
reload preserves the row through `user.games.order.get`.
## Phase 16. Inspector — Cargo Routes
+82
View File
@@ -0,0 +1,82 @@
# Calc bridge
The Galaxy frontend renders predictive numbers (free production
potential, forecast output for a chosen production type, ship build
progress, tech progress) that depend on the same formulas the engine
uses at turn cutoff. To keep one source of truth, those formulas live
in Go under `pkg/calc/` and are surfaced to the UI through a planned
Go → WASM → TypeScript bridge mounted under `ui/core/calc/` and a
matching TS adapter in `ui/frontend/src/`.
The bridge does not exist yet. This document is the audit trail for
what it must expose, what is already in place, and what is missing.
## Current `pkg/calc/` exports
| Function | Purpose |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| `ShipProductionCost(shipEmptyMass float64) float64` | Production units required per unit of ship empty mass (×10). |
| `PlanetProduceShipMass(L, Mat, Res float64) float64` | Ship mass produced per turn given free production `L`, material stockpile `Mat`, resources `Res`.|
| `DriveEffective`, `Speed`, `EmptyMass`, `FullMass`, … | Ship-level derivations (`pkg/calc/ship.go`). |
| `ValidateShipTypeValues`, `CheckShipTypeValueDWSC` | Ship-design validators (`pkg/calc/validator.go`). |
Nothing else lives in `pkg/calc/` today. Production-side formulas
(industry / materials / per-tech research / production capacity) sit
in `game/internal/model/game/planet.go` and `…/science.go` and have
never been exported.
## Required calc functions per UI feature
The table below tracks what UI features need from the bridge and
whether the underlying Go function exists.
| UI feature | Go formula | In `pkg/calc/`? | Surfaced to TS? |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | :-------------: | :-------------: |
| Free production potential (`freeIndustry`) | `Planet.ProductionCapacity``industry*0.75 + population*0.25` (`game/internal/model/game/planet.go`) | no | no |
| Industry production output per turn | `Planet.ProduceIndustry(freeProduction)` (`planet.go`); `freeProduction/5` modulo material constraint | no | no |
| Materials production output per turn | `Planet.ProduceMaterial(freeProduction)` (`planet.go`); `freeProduction * resources` | no | no |
| Per-tech research progress (DRIVE/WEAPONS/…) | `ResearchTech` (`game/internal/model/game/science.go`); `freeProduction / 5000` per tech level | no | no |
| Custom-science progress | weighted form of `ResearchTech` driven by `Race.Sciences[].(Drive\|Weapons\|Shields\|Cargo)` (`science.go`) | no | no |
| Ship build progress | `PlanetProduceShipMass(L, Mat, Res) / ShipProductionCost(class.EmptyMass)` (combination of two existing exports) | partial | no |
`partial` means the Go primitives exist in `pkg/calc/` but the
composition (and the conversion of TS-side `ReportPlanet`/
`ShipClass` to the formula inputs) is not implemented anywhere.
## Phase 15 waiver
Phase 15 ships the inspector's planet production controls
(segmented control + sub-pickers + collapse-by-`planetNumber`
order command) but **deliberately does not surface the per-type
forecast number**. The planning gate explicitly raised the gap as
a blocker per the plan's audit clause ("if any are missing in
`pkg/calc/`, raise as blocker") and the project owner approved
deferring the forecast to a dedicated future bridge phase. The
inspector still renders the existing `freeIndustry` row (free
production potential) — that number is computed engine-side and
ships in the report payload, so no calc-bridge access is required
for it today.
Acceptance criterion 3 of Phase 15 ("forecast output number
reflects the chosen production type and matches `pkg/calc/`
outputs") is therefore intentionally not satisfied; the rewritten
Phase 15 stage text records this decision and points back at this
document.
## Planned bridge shape (follow-up phase)
When the bridge phase lands, the contract should be:
1. Promote every formula in the table above into `pkg/calc/` so the
engine and the UI share one Go-side implementation. The engine
continues to call them through `game/internal/...` wrappers.
2. Mount a `ui/core/calc/` Go module that re-exports the subset the
UI needs. Keep it WASM-friendly (no `unsafe`, no goroutines,
simple in/out values).
3. Wire the WASM glue in `ui/wasm/main.go` so each calc function is
reachable from `globalThis.galaxyCore`.
4. Add a TypeScript adapter under `ui/frontend/src/platform/core/`
that wraps the WASM calls in typed helpers
(`forecastIndustry(freeProduction, …)` etc.).
5. Update this document with the live function inventory and
delete the "missing" rows above.
+44 -2
View File
@@ -95,7 +95,7 @@ stored value).
`OrderCommand` is a discriminated union on the `kind` field. Phase
12 shipped the skeleton with a single content-free variant; Phase
14 adds the first real one:
14 added the first real one and Phase 15 added the second:
```ts
interface PlaceholderCommand {
@@ -111,7 +111,20 @@ interface PlanetRenameCommand {
readonly name: string;
}
type OrderCommand = PlaceholderCommand | PlanetRenameCommand;
interface SetProductionTypeCommand {
readonly kind: "setProductionType";
readonly id: string;
readonly planetNumber: number;
readonly productionType:
| "MAT" | "CAP" | "DRIVE" | "WEAPONS"
| "SHIELDS" | "CARGO" | "SCIENCE" | "SHIP";
readonly subject: string;
}
type OrderCommand =
| PlaceholderCommand
| PlanetRenameCommand
| SetProductionTypeCommand;
```
The `id` field is the canonical identifier the store uses for
@@ -123,6 +136,35 @@ with the inline editor in `lib/inspectors/planet.svelte`, the
local validator (`lib/util/entity-name.ts`, parity with
`pkg/util/string.go.ValidateTypeName`), and the submit pipeline.
`setProductionType` is the wire-mirror of the engine's
`CommandPlanetProduce` (`pkg/model/order/order.go`). The local
validator runs the same `subject=Production` rule as
`game/internal/router/validator.go`: `subject` is required and
must satisfy `validateEntityName` when `productionType` is
`SCIENCE` or `SHIP`; otherwise it is the empty string. The
optimistic overlay rewrites `planet.production` using
`productionDisplayFromCommand` (`api/game-state.ts`), which
mirrors the engine's `Cache.PlanetProductionDisplayName` so the
overlay stays byte-equal with the next server report.
### Collapse-by-target rule (Phase 15)
`setProductionType` is the first variant to carry a
collapse-by-target rule. `OrderDraftStore.add` enforces it:
when the incoming command's `kind` is `"setProductionType"` it
drops every prior `setProductionType` entry with the same
`planetNumber` (and the matching keys from `statuses`) before
appending. Other variants keep their append-only behaviour —
each `planetRename` is a distinct user-visible action and
collapsing them would lose intent.
Net effect on the order tab: at most one `setProductionType`
row per planet, regardless of how many times the player clicks
through the inspector segments. Auto-sync still fires on every
mutation; the engine accepts repeat submits idempotently. A
`setProductionType` and a `planetRename` for the same planet
coexist — the rules apply within a `kind`, not across.
## Store
`OrderDraftStore` lives in
+101 -11
View File
@@ -15,6 +15,11 @@
// rename in the local draft swaps the planet name on the rendered
// 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.
import { Builder, ByteBuffer } from "flatbuffers";
@@ -24,7 +29,11 @@ import {
GameReportRequest,
Report,
} from "../proto/galaxy/fbs/report";
import type { CommandStatus, OrderCommand } from "../sync/order-types";
import type {
CommandStatus,
OrderCommand,
ProductionType,
} from "../sync/order-types";
const MESSAGE_TYPE = "user.games.report";
@@ -61,6 +70,18 @@ export interface ReportPlanet {
freeIndustry: number | null;
}
/**
* 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.
*/
export interface ShipClassSummary {
name: string;
}
export interface GameReport {
turn: number;
mapWidth: number;
@@ -73,6 +94,14 @@ export interface GameReport {
* has not produced a report yet (boot state).
*/
race: string;
/**
* localShipClass enumerates the player's own designed ship classes
* by name. Empty until at least one class is created
* (`CommandShipClassCreate`, Phase 17). The Build-Ship sub-picker
* shows a localized "no ship classes" placeholder when this is
* empty.
*/
localShipClass: ShipClassSummary[];
}
export async function fetchGameReport(
@@ -189,6 +218,13 @@ function decodeReport(report: Report): GameReport {
});
}
const localShipClass: ShipClassSummary[] = [];
for (let i = 0; i < report.localShipClassLength(); i++) {
const sc = report.localShipClass(i);
if (sc === null) continue;
localShipClass.push({ name: sc.name() ?? "" });
}
return {
turn: Number(report.turn()),
mapWidth: report.width(),
@@ -196,6 +232,7 @@ function decodeReport(report: Report): GameReport {
planetCount: report.planetCount(),
planets,
race: report.race() ?? "",
localShipClass,
};
}
@@ -221,10 +258,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 understands `planetRename` only —
* every other variant passes through. The function is pure:
* callers re-derive the overlay whenever the draft or the report
* change.
* 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.
*
* `statuses` maps command id → status. Entries with `valid`,
* `submitting`, or `applied` participate in the overlay — together
@@ -250,18 +289,69 @@ export function applyOrderOverlay(
) {
continue;
}
if (cmd.kind !== "planetRename") continue;
const idx = report.planets.findIndex((p) => p.number === cmd.planetNumber);
if (idx < 0) continue;
if (mutatedPlanets === null) {
mutatedPlanets = [...report.planets];
if (cmd.kind === "planetRename") {
const idx = report.planets.findIndex(
(p) => p.number === cmd.planetNumber,
);
if (idx < 0) continue;
if (mutatedPlanets === null) {
mutatedPlanets = [...report.planets];
}
mutatedPlanets[idx] = { ...mutatedPlanets[idx]!, name: cmd.name };
continue;
}
if (cmd.kind === "setProductionType") {
const idx = report.planets.findIndex(
(p) => p.number === cmd.planetNumber,
);
if (idx < 0) continue;
if (mutatedPlanets === null) {
mutatedPlanets = [...report.planets];
}
mutatedPlanets[idx] = {
...mutatedPlanets[idx]!,
production: productionDisplayFromCommand(
cmd.productionType,
cmd.subject,
),
};
continue;
}
mutatedPlanets[idx] = { ...mutatedPlanets[idx]!, name: cmd.name };
}
if (mutatedPlanets === null) return report;
return { ...report, planets: mutatedPlanets };
}
/**
* productionDisplayFromCommand mirrors the engine's
* `Cache.PlanetProductionDisplayName`
* (`game/internal/controller/planet.go`) for the optimistic overlay.
* Keeping the strings byte-equal with the next server report avoids
* a flicker when the overlay drops on the next turn cutoff.
*/
export function productionDisplayFromCommand(
productionType: ProductionType,
subject: string,
): string {
switch (productionType) {
case "MAT":
return "Material";
case "CAP":
return "Capital";
case "DRIVE":
return "Drive";
case "WEAPONS":
return "Weapons";
case "SHIELDS":
return "Shields";
case "CARGO":
return "Cargo";
case "SCIENCE":
case "SHIP":
return subject;
}
}
function decodeErrorMessage(payload: Uint8Array): { code: string; message: string } {
if (payload.length === 0) {
return { code: "internal_error", message: "empty error payload" };
+11
View File
@@ -132,6 +132,7 @@ const en = {
"game.sidebar.order.status.rejected": "rejected",
"game.sidebar.order.label.placeholder": "{label}",
"game.sidebar.order.label.planet_rename": "rename planet {planet} → {name}",
"game.sidebar.order.label.planet_production": "set production on planet {planet} → {target}",
"game.bottom_tabs.map": "map",
"game.bottom_tabs.calc": "calc",
"game.bottom_tabs.order": "order",
@@ -167,6 +168,16 @@ const en = {
"game.inspector.planet.rename.invalid.consecutive_specials": "too many special characters in a row",
"game.inspector.planet.rename.invalid.whitespace": "name cannot contain spaces",
"game.inspector.planet.rename.invalid.disallowed_character": "name contains disallowed characters",
"game.inspector.planet.production.title": "production",
"game.inspector.planet.production.option.industry": "industry",
"game.inspector.planet.production.option.materials": "materials",
"game.inspector.planet.production.option.research": "research",
"game.inspector.planet.production.option.ship": "build ship",
"game.inspector.planet.production.research.drive": "drive",
"game.inspector.planet.production.research.weapons": "weapons",
"game.inspector.planet.production.research.shields": "shields",
"game.inspector.planet.production.research.cargo": "cargo",
"game.inspector.planet.production.ship.no_classes": "no ship classes designed yet",
} as const;
export default en;
+11
View File
@@ -133,6 +133,7 @@ const ru: Record<keyof typeof en, string> = {
"game.sidebar.order.status.rejected": "отклонена",
"game.sidebar.order.label.placeholder": "{label}",
"game.sidebar.order.label.planet_rename": "переименовать планету {planet} → {name}",
"game.sidebar.order.label.planet_production": "сменить производство планеты {planet} → {target}",
"game.bottom_tabs.map": "карта",
"game.bottom_tabs.calc": "калк",
"game.bottom_tabs.order": "приказ",
@@ -168,6 +169,16 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.planet.rename.invalid.consecutive_specials": "слишком много спецсимволов подряд",
"game.inspector.planet.rename.invalid.whitespace": "имя не может содержать пробелы",
"game.inspector.planet.rename.invalid.disallowed_character": "имя содержит недопустимые символы",
"game.inspector.planet.production.title": "производство",
"game.inspector.planet.production.option.industry": "промышленность",
"game.inspector.planet.production.option.materials": "сырьё",
"game.inspector.planet.production.option.research": "исследование",
"game.inspector.planet.production.option.ship": "корабль",
"game.inspector.planet.production.research.drive": "двигатель",
"game.inspector.planet.production.research.weapons": "оружие",
"game.inspector.planet.production.research.shields": "щиты",
"game.inspector.planet.production.research.cargo": "трюм",
"game.inspector.planet.production.ship.no_classes": "классы кораблей ещё не спроектированы",
};
export default ru;
@@ -11,16 +11,20 @@ that clears the selection. Swipe-to-dismiss and tap-outside-to-
dismiss from the IA section §6 land in Phase 35 polish.
-->
<script lang="ts">
import type { ReportPlanet } from "../../api/game-state";
import type {
ReportPlanet,
ShipClassSummary,
} from "../../api/game-state";
import { i18n } from "$lib/i18n/index.svelte";
import Planet from "./planet.svelte";
type Props = {
planet: ReportPlanet | null;
localShipClass: ShipClassSummary[];
onMap: boolean;
onClose: () => void;
};
let { planet, onMap, onClose }: Props = $props();
let { planet, localShipClass, onMap, onClose }: Props = $props();
</script>
{#if planet !== null && onMap}
@@ -38,7 +42,7 @@ dismiss from the IA section §6 land in Phase 35 polish.
>
</button>
<Planet {planet} />
<Planet {planet} {localShipClass} />
</section>
{/if}
+12 -3
View File
@@ -14,7 +14,10 @@ field with five buttons.
-->
<script lang="ts">
import { getContext, tick } from "svelte";
import type { ReportPlanet } from "../../api/game-state";
import type {
ReportPlanet,
ShipClassSummary,
} from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
@@ -24,11 +27,13 @@ field with five buttons.
validateEntityName,
type EntityNameInvalidReason,
} from "$lib/util/entity-name";
import Production from "./planet/production.svelte";
type Props = {
planet: ReportPlanet;
localShipClass: ShipClassSummary[];
};
let { planet }: Props = $props();
let { planet, localShipClass }: Props = $props();
const kindKeyMap: Record<ReportPlanet["kind"], TranslationKey> = {
local: "game.inspector.planet.kind.local",
@@ -191,6 +196,10 @@ field with five buttons.
</div>
{/if}
{#if planet.kind === "local"}
<Production {planet} {localShipClass} />
{/if}
<dl class="fields">
{#if planet.kind === "other" && planet.owner !== null}
<div class="field" data-testid="inspector-planet-field-owner">
@@ -253,7 +262,7 @@ field with five buttons.
</div>
{/if}
{#if planet.production !== null}
{#if planet.production !== null && planet.kind !== "local"}
<div class="field" data-testid="inspector-planet-field-production">
<dt>{i18n.t("game.inspector.planet.field.production")}</dt>
<dd>{productionLabel}</dd>
@@ -0,0 +1,317 @@
<!--
Phase 15 production-controls subsection of the planet inspector.
Renders four main segments — industry / materials / research / build
ship — and reveals a sub-row when the player picks a category that
needs a target (research → tech field, build ship → designed class).
Every leaf click appends a `setProductionType` command to the local
order draft via `OrderDraftStore`; the collapse-by-`planetNumber`
rule inside `add` keeps at most one production choice per planet.
The currently-active segment is derived from `planet.production`
through a parser that mirrors the engine's
`Cache.PlanetProductionDisplayName` mapping. While the player is
mid-navigation (e.g. clicked Research but has not picked a tech yet)
a transient `expandedMain` override widens the visible state so the
sub-row can appear without forcing the player to commit a choice
first; the override resets whenever the inspector switches to a
different planet or after any leaf click.
Phase 15 deliberately defers the per-type forecast number — see
`ui/docs/calc-bridge.md` for the gap analysis. The component does
not render forecast text; the existing `freeIndustry` ("free
production") row in the parent inspector is unchanged.
-->
<script lang="ts">
import { getContext } from "svelte";
import type {
ReportPlanet,
ShipClassSummary,
} from "../../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../../sync/order-draft.svelte";
import type { ProductionType } from "../../../sync/order-types";
type Props = {
planet: ReportPlanet;
localShipClass: ShipClassSummary[];
};
let { planet, localShipClass }: Props = $props();
type MainSegment = "industry" | "materials" | "research" | "ship";
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
const disabled = draft === undefined;
let expandedMain: MainSegment | null = $state(null);
const parsedMain = $derived(parseMain(planet.production, localShipClass));
const selectedMain = $derived(expandedMain ?? parsedMain);
const activeResearch = $derived(parseResearch(planet.production));
const activeShip = $derived(parseShip(planet.production, localShipClass));
$effect(() => {
// Reset the expand-override whenever the inspector switches to a
// different planet so a stale category does not leak across the
// selection boundary.
void planet.number;
expandedMain = null;
});
const RESEARCH_OPTIONS: ReadonlyArray<{
fbs: ProductionType;
slug: "drive" | "weapons" | "shields" | "cargo";
labelKey: TranslationKey;
}> = [
{
fbs: "DRIVE",
slug: "drive",
labelKey: "game.inspector.planet.production.research.drive",
},
{
fbs: "WEAPONS",
slug: "weapons",
labelKey: "game.inspector.planet.production.research.weapons",
},
{
fbs: "SHIELDS",
slug: "shields",
labelKey: "game.inspector.planet.production.research.shields",
},
{
fbs: "CARGO",
slug: "cargo",
labelKey: "game.inspector.planet.production.research.cargo",
},
];
function parseMain(
value: string | null,
classes: ShipClassSummary[],
): MainSegment | null {
if (value === null || value === "" || value === "-") return null;
switch (value) {
case "Capital":
return "industry";
case "Material":
return "materials";
case "Drive":
case "Weapons":
case "Shields":
case "Cargo":
return "research";
}
return classes.some((c) => c.name === value) ? "ship" : null;
}
function parseResearch(value: string | null): ProductionType | null {
switch (value) {
case "Drive":
return "DRIVE";
case "Weapons":
return "WEAPONS";
case "Shields":
return "SHIELDS";
case "Cargo":
return "CARGO";
default:
return null;
}
}
function parseShip(
value: string | null,
classes: ShipClassSummary[],
): string | null {
if (value === null || value === "") return null;
return classes.some((c) => c.name === value) ? value : null;
}
function clickMain(segment: MainSegment): void {
if (segment === "industry") {
void emit("CAP", "");
expandedMain = null;
return;
}
if (segment === "materials") {
void emit("MAT", "");
expandedMain = null;
return;
}
expandedMain = segment;
}
function clickResearch(value: ProductionType): void {
void emit(value, "");
expandedMain = null;
}
function clickShip(name: string): void {
void emit("SHIP", name);
expandedMain = null;
}
async function emit(
productionType: ProductionType,
subject: string,
): Promise<void> {
if (draft === undefined) return;
await draft.add({
kind: "setProductionType",
id: crypto.randomUUID(),
planetNumber: planet.number,
productionType,
subject,
});
}
</script>
<section class="production" data-testid="inspector-planet-production">
<h4 class="title">
{i18n.t("game.inspector.planet.production.title")}
</h4>
<div class="row main">
<button
type="button"
class="seg"
class:active={selectedMain === "industry"}
data-testid="inspector-planet-production-segment-industry"
disabled={disabled}
onclick={() => clickMain("industry")}
>
{i18n.t("game.inspector.planet.production.option.industry")}
</button>
<button
type="button"
class="seg"
class:active={selectedMain === "materials"}
data-testid="inspector-planet-production-segment-materials"
disabled={disabled}
onclick={() => clickMain("materials")}
>
{i18n.t("game.inspector.planet.production.option.materials")}
</button>
<button
type="button"
class="seg"
class:active={selectedMain === "research"}
data-testid="inspector-planet-production-segment-research"
disabled={disabled}
onclick={() => clickMain("research")}
>
{i18n.t("game.inspector.planet.production.option.research")}
</button>
<button
type="button"
class="seg"
class:active={selectedMain === "ship"}
data-testid="inspector-planet-production-segment-ship"
disabled={disabled}
onclick={() => clickMain("ship")}
>
{i18n.t("game.inspector.planet.production.option.ship")}
</button>
</div>
{#if selectedMain === "research"}
<div class="row sub" data-testid="inspector-planet-production-research-row">
{#each RESEARCH_OPTIONS as option (option.fbs)}
<button
type="button"
class="sub-seg"
class:active={activeResearch === option.fbs}
data-testid={`inspector-planet-production-research-${option.slug}`}
disabled={disabled}
onclick={() => clickResearch(option.fbs)}
>
{i18n.t(option.labelKey)}
</button>
{/each}
</div>
{/if}
{#if selectedMain === "ship"}
<div class="row sub" data-testid="inspector-planet-production-ship-row">
{#if localShipClass.length === 0}
<p
class="empty"
data-testid="inspector-planet-production-ship-empty"
>
{i18n.t("game.inspector.planet.production.ship.no_classes")}
</p>
{:else}
{#each localShipClass as cls (cls.name)}
<button
type="button"
class="sub-seg"
class:active={activeShip === cls.name}
data-testid={`inspector-planet-production-ship-${cls.name}`}
disabled={disabled}
onclick={() => clickShip(cls.name)}
>
{cls.name}
</button>
{/each}
{/if}
</div>
{/if}
</section>
<style>
.production {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.title {
margin: 0;
font-size: 0.85rem;
color: #aab;
font-weight: 500;
}
.row {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
}
.row.sub {
padding-left: 0.6rem;
}
.seg,
.sub-seg {
font: inherit;
font-size: 0.85rem;
padding: 0.25rem 0.55rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.seg:not(:disabled):hover,
.sub-seg:not(:disabled):hover {
color: #e8eaf6;
border-color: #6d8cff;
}
.seg.active,
.sub-seg.active {
color: #e8eaf6;
border-color: #6d8cff;
background: rgba(109, 140, 255, 0.15);
}
.seg:disabled,
.sub-seg:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.empty {
margin: 0;
font-size: 0.8rem;
color: #888;
font-style: italic;
}
</style>
@@ -38,11 +38,14 @@ from the Phase 10 stub.
if (report === undefined || report === null) return null;
return report.planets.find((p) => p.number === sel.id) ?? null;
});
const localShipClass = $derived(
renderedReport?.report?.localShipClass ?? [],
);
</script>
<section class="tool" data-testid="sidebar-tool-inspector">
{#if selectedPlanet !== null}
<Planet planet={selectedPlanet} />
<Planet planet={selectedPlanet} {localShipClass} />
{:else}
<h3>{i18n.t("game.sidebar.tab.inspector")}</h3>
<p>{i18n.t("game.sidebar.empty.inspector")}</p>
@@ -19,6 +19,7 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
<script lang="ts">
import { getContext } from "svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { productionDisplayFromCommand } from "../../api/game-state";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
@@ -49,6 +50,14 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
planet: String(cmd.planetNumber),
name: cmd.name,
});
case "setProductionType":
return i18n.t("game.sidebar.order.label.planet_production", {
planet: String(cmd.planetNumber),
target: productionDisplayFromCommand(
cmd.productionType,
cmd.subject,
),
});
}
}
@@ -117,6 +117,9 @@ fresh.
if (report === null) return null;
return report.planets.find((p) => p.number === sel.id) ?? null;
});
const localShipClass = $derived(
renderedReport.report?.localShipClass ?? [],
);
// Reveal the inspector whenever a new planet selection lands.
// Reading `selection.selected` once outside the effect keeps the
@@ -224,6 +227,7 @@ fresh.
/>
<PlanetSheet
planet={selectedPlanet}
{localShipClass}
onMap={effectiveTool === "map"}
onClose={() => selection.clear()}
/>
+46 -2
View File
@@ -173,11 +173,41 @@ export class OrderDraftStore {
* triggers an auto-sync to keep the server in lock-step.
* Mutations made before `init` resolves are ignored — the layout
* always awaits `init` before exposing the store.
*
* `setProductionType` carries a collapse-by-`planetNumber` rule:
* a new entry supersedes any prior `setProductionType` for the
* same planet, so the draft holds at most one production choice
* per planet at any time. Other variants append unconditionally —
* `planetRename` keeps its append-only behaviour because each
* rename is a distinct user-visible action.
*/
async add(command: OrderCommand): Promise<void> {
if (this.status !== "ready") return;
this.commands = [...this.commands, command];
this.statuses = { ...this.statuses, [command.id]: validateCommand(command) };
const removed: string[] = [];
let nextCommands: OrderCommand[];
if (command.kind === "setProductionType") {
nextCommands = [];
for (const existing of this.commands) {
if (
existing.kind === "setProductionType" &&
existing.planetNumber === command.planetNumber
) {
removed.push(existing.id);
continue;
}
nextCommands.push(existing);
}
nextCommands.push(command);
} else {
nextCommands = [...this.commands, command];
}
this.commands = nextCommands;
const nextStatuses = { ...this.statuses };
for (const id of removed) {
delete nextStatuses[id];
}
nextStatuses[command.id] = validateCommand(command);
this.statuses = nextStatuses;
await this.persist();
this.scheduleSync();
}
@@ -400,6 +430,20 @@ function validateCommand(cmd: OrderCommand): CommandStatus {
switch (cmd.kind) {
case "planetRename":
return validateEntityName(cmd.name).ok ? "valid" : "invalid";
case "setProductionType":
// Mirrors the engine's `subject=Production` validator
// (`game/internal/router/validator.go`): SCIENCE and SHIP
// require a non-empty entity-name-valid subject; the other
// six production types accept any subject (typically empty)
// because the engine only consults the subject for those
// two cases.
if (
cmd.productionType === "SCIENCE" ||
cmd.productionType === "SHIP"
) {
return validateEntityName(cmd.subject).ok ? "valid" : "invalid";
}
return "valid";
case "placeholder":
// Phase 12 placeholder entries are content-free and never
// transition out of `draft` — they are not submittable.
+54 -1
View File
@@ -12,11 +12,13 @@ import { uuidToHiLo } from "../api/game-state";
import { UUID } from "../proto/galaxy/fbs/common";
import {
CommandPayload,
CommandPlanetProduce,
CommandPlanetRename,
PlanetProduction,
UserGamesOrderGet,
UserGamesOrderGetResponse,
} from "../proto/galaxy/fbs/order";
import type { OrderCommand } from "./order-types";
import type { OrderCommand, ProductionType } from "./order-types";
const MESSAGE_TYPE = "user.games.order.get";
@@ -135,6 +137,24 @@ function decodeCommand(item: CommandItemView): OrderCommand | null {
name: inner.name() ?? "",
};
}
case CommandPayload.CommandPlanetProduce: {
const inner = new CommandPlanetProduce();
item.payload(inner);
const productionType = productionTypeFromFBS(inner.production());
if (productionType === null) {
console.warn(
`fetchOrder: skipping CommandPlanetProduce with unknown production enum (${inner.production()})`,
);
return null;
}
return {
kind: "setProductionType",
id,
planetNumber: Number(inner.number()),
productionType,
subject: inner.subject() ?? "",
};
}
default:
console.warn(
`fetchOrder: skipping unknown command kind (payloadType=${payloadType})`,
@@ -143,6 +163,39 @@ function decodeCommand(item: CommandItemView): OrderCommand | null {
}
}
/**
* productionTypeFromFBS reverses `productionTypeToFBS` from
* `submit.ts`. `PlanetProduction.UNKNOWN` and any out-of-band value
* yield `null` so the caller drops the entry instead of fabricating a
* synthetic kind.
*/
export function productionTypeFromFBS(
value: PlanetProduction,
): ProductionType | null {
switch (value) {
case PlanetProduction.MAT:
return "MAT";
case PlanetProduction.CAP:
return "CAP";
case PlanetProduction.DRIVE:
return "DRIVE";
case PlanetProduction.WEAPONS:
return "WEAPONS";
case PlanetProduction.SHIELDS:
return "SHIELDS";
case PlanetProduction.CARGO:
return "CARGO";
case PlanetProduction.SCIENCE:
return "SCIENCE";
case PlanetProduction.SHIP:
return "SHIP";
case PlanetProduction.UNKNOWN:
return null;
default:
return null;
}
}
function decodeError(
payload: Uint8Array,
resultCode: string,
+73 -1
View File
@@ -40,13 +40,85 @@ export interface PlanetRenameCommand {
readonly name: string;
}
/**
* ProductionType mirrors the engine `PlanetProduction` enum
* (`pkg/schema/fbs/order.fbs`) and the binding tag on
* `pkg/model/order/order.go.CommandPlanetProduce.Production`. The
* values are wire-stable: the submit encoder maps them to the FBS
* enum, the read-back decoder maps them back, and the optimistic
* overlay derives the engine's display string from the same set.
*
* `MAT` is materials production, `CAP` is industry (the engine names
* carry historical meaning — "Material" and "Capital" in the display
* mapping). `DRIVE` / `WEAPONS` / `SHIELDS` / `CARGO` are the four
* implicit per-tech research tracks (no subject required). `SCIENCE`
* is research of a custom science package authored via
* `CommandScienceCreate`; `SHIP` is build of a ship class authored
* via `CommandShipClassCreate`. Both `SCIENCE` and `SHIP` require a
* non-empty `subject` that passes `validateEntityName`; the engine
* validator (`game/internal/router/validator.go`) enforces the same.
*/
export type ProductionType =
| "MAT"
| "CAP"
| "DRIVE"
| "WEAPONS"
| "SHIELDS"
| "CARGO"
| "SCIENCE"
| "SHIP";
/**
* SetProductionTypeCommand switches a planet's production target.
* Phase 15 is the first variant to carry a collapse-by-target rule:
* the order draft store keeps at most one `setProductionType` per
* `planetNumber`, replacing any earlier entry on `add`. `subject` is
* the science or ship-class name when `productionType` is `SCIENCE`
* or `SHIP`; for the other six values it is the empty string.
*/
export interface SetProductionTypeCommand {
readonly kind: "setProductionType";
readonly id: string;
readonly planetNumber: number;
readonly productionType: ProductionType;
readonly subject: string;
}
/**
* OrderCommand is the discriminated union of every command shape the
* local order draft can hold. The `kind` field is the discriminator;
* narrowing on it enables exhaustive `switch` statements at every
* call site.
*/
export type OrderCommand = PlaceholderCommand | PlanetRenameCommand;
export type OrderCommand =
| PlaceholderCommand
| PlanetRenameCommand
| SetProductionTypeCommand;
/**
* PRODUCTION_TYPE_VALUES is the canonical tuple of `ProductionType`
* literals. Used by validators and by the FBS converters in
* `submit.ts` and `order-load.ts` to assert that an incoming string
* is one of the wire-stable values.
*/
export const PRODUCTION_TYPE_VALUES = [
"MAT",
"CAP",
"DRIVE",
"WEAPONS",
"SHIELDS",
"CARGO",
"SCIENCE",
"SHIP",
] as const satisfies readonly ProductionType[];
/**
* isProductionType narrows an arbitrary string to the
* `ProductionType` union.
*/
export function isProductionType(value: string): value is ProductionType {
return (PRODUCTION_TYPE_VALUES as readonly string[]).includes(value);
}
/**
* CommandStatus is the lifecycle of a single command from the moment
+44 -1
View File
@@ -27,11 +27,13 @@ import { UUID } from "../proto/galaxy/fbs/common";
import {
CommandItem,
CommandPayload,
CommandPlanetProduce,
CommandPlanetRename,
PlanetProduction,
UserGamesOrder,
UserGamesOrderResponse,
} from "../proto/galaxy/fbs/order";
import type { OrderCommand } from "./order-types";
import type { OrderCommand, ProductionType } from "./order-types";
const MESSAGE_TYPE = "user.games.order";
@@ -148,6 +150,19 @@ function encodeCommandPayload(
payloadOffset: offset,
};
}
case "setProductionType": {
const subjectOffset = builder.createString(cmd.subject);
const offset = CommandPlanetProduce.createCommandPlanetProduce(
builder,
BigInt(cmd.planetNumber),
productionTypeToFBS(cmd.productionType),
subjectOffset,
);
return {
payloadType: CommandPayload.CommandPlanetProduce,
payloadOffset: offset,
};
}
case "placeholder":
throw new SubmitError(
"invalid_request",
@@ -157,6 +172,34 @@ function encodeCommandPayload(
}
}
/**
* productionTypeToFBS converts the wire-stable `ProductionType` literal
* to the FlatBuffers enum value. Mirrors `planetProductionToFBS` in
* `pkg/transcoder/order.go`. The two sides are kept in lock-step so the
* gateway can decode whatever the frontend produces without a
* translation step.
*/
export function productionTypeToFBS(value: ProductionType): PlanetProduction {
switch (value) {
case "MAT":
return PlanetProduction.MAT;
case "CAP":
return PlanetProduction.CAP;
case "DRIVE":
return PlanetProduction.DRIVE;
case "WEAPONS":
return PlanetProduction.WEAPONS;
case "SHIELDS":
return PlanetProduction.SHIELDS;
case "CARGO":
return PlanetProduction.CARGO;
case "SCIENCE":
return PlanetProduction.SCIENCE;
case "SHIP":
return PlanetProduction.SHIP;
}
}
function decodeOrderResponse(
payload: Uint8Array,
commands: OrderCommand[],
+84 -14
View File
@@ -1,7 +1,9 @@
// FlatBuffers payload builders for the Phase 14 Playwright suite.
// Mirrors what `pkg/transcoder/order.go` produces in production for
// the `user.games.order` POST response and the
// `user.games.order.get` GET response.
// FlatBuffers payload builders for the Phase 14 / Phase 15 Playwright
// suites. Mirrors what `pkg/transcoder/order.go` produces in production
// for the `user.games.order` POST response and the
// `user.games.order.get` GET response. Phase 15 extends the fixture
// with a `setProductionType` variant so a single mocked gateway can
// echo either rename or production-switch commands back to the client.
import { Builder } from "flatbuffers";
@@ -10,20 +12,46 @@ import { UUID } from "../../../src/proto/galaxy/fbs/common";
import {
CommandItem,
CommandPayload,
CommandPlanetProduce,
CommandPlanetRename,
PlanetProduction,
UserGamesOrder,
UserGamesOrderGetResponse,
UserGamesOrderResponse,
} from "../../../src/proto/galaxy/fbs/order";
export interface CommandResultFixture {
interface CommandResultFixtureBase {
cmdId: string;
planetNumber: number;
name: string;
applied: boolean | null;
errorCode: number | null;
}
export interface PlanetRenameResultFixture extends CommandResultFixtureBase {
kind: "planetRename";
planetNumber: number;
name: string;
}
export interface SetProductionTypeResultFixture
extends CommandResultFixtureBase {
kind: "setProductionType";
planetNumber: number;
productionType:
| "MAT"
| "CAP"
| "DRIVE"
| "WEAPONS"
| "SHIELDS"
| "CARGO"
| "SCIENCE"
| "SHIP";
subject: string;
}
export type CommandResultFixture =
| PlanetRenameResultFixture
| SetProductionTypeResultFixture;
export function buildOrderResponsePayload(
gameId: string,
commands: CommandResultFixture[],
@@ -83,19 +111,61 @@ export function buildOrderGetResponsePayload(
function encodeItem(builder: Builder, c: CommandResultFixture): number {
const cmdIdOffset = builder.createString(c.cmdId);
const nameOffset = builder.createString(c.name);
const inner = CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(c.planetNumber),
nameOffset,
);
let payloadType: CommandPayload;
let inner: number;
switch (c.kind) {
case "planetRename": {
const nameOffset = builder.createString(c.name);
inner = CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(c.planetNumber),
nameOffset,
);
payloadType = CommandPayload.CommandPlanetRename;
break;
}
case "setProductionType": {
const subjectOffset = builder.createString(c.subject);
inner = CommandPlanetProduce.createCommandPlanetProduce(
builder,
BigInt(c.planetNumber),
productionTypeToFBS(c.productionType),
subjectOffset,
);
payloadType = CommandPayload.CommandPlanetProduce;
break;
}
}
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
if (c.applied !== null) CommandItem.addCmdApplied(builder, c.applied);
if (c.errorCode !== null) {
CommandItem.addCmdErrorCode(builder, BigInt(c.errorCode));
}
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
CommandItem.addPayloadType(builder, payloadType);
CommandItem.addPayload(builder, inner);
return CommandItem.endCommandItem(builder);
}
function productionTypeToFBS(
value: SetProductionTypeResultFixture["productionType"],
): PlanetProduction {
switch (value) {
case "MAT":
return PlanetProduction.MAT;
case "CAP":
return PlanetProduction.CAP;
case "DRIVE":
return PlanetProduction.DRIVE;
case "WEAPONS":
return PlanetProduction.WEAPONS;
case "SHIELDS":
return PlanetProduction.SHIELDS;
case "CARGO":
return PlanetProduction.CARGO;
case "SCIENCE":
return PlanetProduction.SCIENCE;
case "SHIP":
return PlanetProduction.SHIP;
}
}
+24 -2
View File
@@ -8,8 +8,11 @@
// fixture with the optional rich planet fields (size, resources,
// stockpiles, population, industry, colonists, production, free
// industry) so the inspector e2e can drive the read-only display
// against realistic values. Later phases extend the helper as ships,
// fleets, sciences, etc. land.
// against realistic values. Phase 15 adds a minimal `LocalShipClass`
// projection so the planet inspector's Build-Ship sub-picker has data
// in e2e specs (`name` only — Phase 17 widens this when ship-class
// CRUD lands). Later phases extend the helper as fleets, sciences,
// etc. land.
import { Builder } from "flatbuffers";
@@ -17,6 +20,7 @@ import {
LocalPlanet,
OtherPlanet,
Report,
ShipClass,
UnidentifiedPlanet,
UninhabitedPlanet,
} from "../../../src/proto/galaxy/fbs/report";
@@ -44,6 +48,10 @@ export interface OtherPlanetFixture extends InhabitedFixture {
owner: string;
}
export interface ShipClassFixture {
name: string;
}
export interface ReportFixture {
turn: number;
mapWidth?: number;
@@ -52,6 +60,7 @@ export interface ReportFixture {
otherPlanets?: OtherPlanetFixture[];
uninhabitedPlanets?: PlanetFixture[];
unidentifiedPlanets?: { number: number; x: number; y: number }[];
localShipClass?: ShipClassFixture[];
}
export function buildReportPayload(fixture: ReportFixture): Uint8Array {
@@ -131,6 +140,13 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
},
);
const localShipClassOffsets = (fixture.localShipClass ?? []).map((cls) => {
const name = builder.createString(cls.name);
ShipClass.startShipClass(builder);
ShipClass.addName(builder, name);
return ShipClass.endShipClass(builder);
});
const localVec =
localOffsets.length === 0
? null
@@ -147,6 +163,10 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
unidentifiedOffsets.length === 0
? null
: Report.createUnidentifiedPlanetVector(builder, unidentifiedOffsets);
const localShipClassVec =
localShipClassOffsets.length === 0
? null
: Report.createLocalShipClassVector(builder, localShipClassOffsets);
const totalPlanets =
(fixture.localPlanets ?? []).length +
@@ -163,6 +183,8 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
if (otherVec !== null) Report.addOtherPlanet(builder, otherVec);
if (uninhabitedVec !== null) Report.addUninhabitedPlanet(builder, uninhabitedVec);
if (unidentifiedVec !== null) Report.addUnidentifiedPlanet(builder, unidentifiedVec);
if (localShipClassVec !== null)
Report.addLocalShipClass(builder, localShipClassVec);
const reportOff = Report.endReport(builder);
builder.finish(reportOff);
return builder.asUint8Array();
@@ -0,0 +1,375 @@
// Phase 15 end-to-end coverage for the planet-production flow. Boots
// an authenticated session, mocks the lobby + report + order routes
// (including a seeded `Scout` ship class so the Build-Ship branch is
// reachable), drives a click into the renderer to select a planet,
// then walks the segmented control through three production choices.
// The final assertion verifies that the order tab carries exactly
// one row at all times (the collapse-by-`planetNumber` rule), that
// the gateway received the latest choice, and that the row survives
// a reload via `user.games.order.get`.
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { expect, test, type Page } from "@playwright/test";
import { ByteBuffer } from "flatbuffers";
import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb";
import { UUID } from "../../src/proto/galaxy/fbs/common";
import {
CommandPlanetProduce,
PlanetProduction,
UserGamesOrder,
UserGamesOrderGet,
} from "../../src/proto/galaxy/fbs/order";
import { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
import {
buildMyGamesListPayload,
type GameFixture,
} from "./fixtures/lobby-fbs";
import { buildReportPayload } from "./fixtures/report-fbs";
import {
buildOrderGetResponsePayload,
buildOrderResponsePayload,
type CommandResultFixture,
} from "./fixtures/order-fbs";
const SESSION_ID = "phase-15-production-session";
const GAME_ID = "15151515-1515-1515-1515-151515151515";
const WORLD = 4000;
const CENTRE = WORLD / 2;
const TURN = 5;
const SHIP_CLASS = "Scout";
interface MockHandle {
get lastSubmitted(): {
productionType: PlanetProduction;
subject: string;
planetNumber: number;
} | null;
get submitCount(): number;
}
async function mockGateway(page: Page): Promise<MockHandle> {
const game: GameFixture = {
gameId: GAME_ID,
gameName: "Phase 15 Game",
gameType: "private",
status: "running",
ownerUserId: "user-1",
minPlayers: 2,
maxPlayers: 8,
enrollmentEndsAtMs: BigInt(Date.now() + 86_400_000),
createdAtMs: BigInt(Date.now() - 86_400_000),
updatedAtMs: BigInt(Date.now()),
currentTurn: TURN,
};
let storedOrder: CommandResultFixture[] = [];
let lastReportProduction = "Drive";
let lastSubmitted: {
productionType: PlanetProduction;
subject: string;
planetNumber: number;
} | null = null;
let submitCount = 0;
await page.route(
"**/galaxy.gateway.v1.EdgeGateway/ExecuteCommand",
async (route) => {
const reqText = route.request().postData();
if (reqText === null) {
await route.fulfill({ status: 400 });
return;
}
const req = fromJson(
ExecuteCommandRequestSchema,
JSON.parse(reqText) as JsonValue,
);
let resultCode = "ok";
let payload: Uint8Array;
switch (req.messageType) {
case "lobby.my.games.list":
payload = buildMyGamesListPayload([game]);
break;
case "user.games.report": {
GameReportRequest.getRootAsGameReportRequest(
new ByteBuffer(req.payloadBytes),
).gameId(new UUID());
payload = buildReportPayload({
turn: TURN,
mapWidth: WORLD,
mapHeight: WORLD,
localPlanets: [
{
number: 17,
name: "Earth",
x: CENTRE,
y: CENTRE,
size: 1000,
resources: 10,
capital: 0,
material: 0,
population: 850,
colonists: 25,
industry: 700,
production: lastReportProduction,
freeIndustry: 175,
},
],
localShipClass: [{ name: SHIP_CLASS }],
});
break;
}
case "user.games.order": {
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
new ByteBuffer(req.payloadBytes),
);
submitCount += 1;
const length = decoded.commandsLength();
const fixtures: CommandResultFixture[] = [];
for (let i = 0; i < length; i++) {
const item = decoded.commands(i);
if (item === null) continue;
const cmdId = item.cmdId() ?? "";
const inner = new CommandPlanetProduce();
item.payload(inner);
const productionType = inner.production();
const subject = inner.subject() ?? "";
const planetNumber = Number(inner.number());
lastSubmitted = { productionType, subject, planetNumber };
fixtures.push({
kind: "setProductionType",
cmdId,
planetNumber,
productionType: planetProductionToLiteral(productionType),
subject,
applied: true,
errorCode: null,
});
}
storedOrder = fixtures;
if (lastSubmitted !== null) {
lastReportProduction = displayFromSubmitted(lastSubmitted);
}
payload = buildOrderResponsePayload(GAME_ID, fixtures, Date.now());
break;
}
case "user.games.order.get": {
UserGamesOrderGet.getRootAsUserGamesOrderGet(
new ByteBuffer(req.payloadBytes),
);
payload = buildOrderGetResponsePayload(
GAME_ID,
storedOrder,
Date.now(),
storedOrder.length > 0,
);
break;
}
default:
resultCode = "internal_error";
payload = new Uint8Array();
}
const body = await forgeExecuteCommandResponseJson({
requestId: req.requestId,
timestampMs: BigInt(Date.now()),
resultCode,
payloadBytes: payload,
});
await route.fulfill({
status: 200,
contentType: "application/json",
body,
});
},
);
await page.route(
"**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents",
async () => {
await new Promise<void>(() => {});
},
);
return {
get lastSubmitted() {
return lastSubmitted;
},
get submitCount() {
return submitCount;
},
};
}
async function bootSession(page: Page): Promise<void> {
await page.goto("/__debug/store");
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
await page.evaluate(() => window.__galaxyDebug!.clearSession());
await page.evaluate(
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
SESSION_ID,
);
await page.evaluate(
(gameId) => window.__galaxyDebug!.clearOrderDraft(gameId),
GAME_ID,
);
}
async function clickPlanetCentre(page: Page): Promise<void> {
const canvas = page.locator("canvas");
const box = await canvas.boundingBox();
expect(box).not.toBeNull();
if (box === null) throw new Error("canvas has no bounding box");
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
}
function planetProductionToLiteral(
value: PlanetProduction,
): "MAT" | "CAP" | "DRIVE" | "WEAPONS" | "SHIELDS" | "CARGO" | "SCIENCE" | "SHIP" {
switch (value) {
case PlanetProduction.MAT:
return "MAT";
case PlanetProduction.CAP:
return "CAP";
case PlanetProduction.DRIVE:
return "DRIVE";
case PlanetProduction.WEAPONS:
return "WEAPONS";
case PlanetProduction.SHIELDS:
return "SHIELDS";
case PlanetProduction.CARGO:
return "CARGO";
case PlanetProduction.SCIENCE:
return "SCIENCE";
case PlanetProduction.SHIP:
return "SHIP";
default:
throw new Error(`unexpected production enum ${value}`);
}
}
function displayFromSubmitted(value: {
productionType: PlanetProduction;
subject: string;
}): string {
switch (value.productionType) {
case PlanetProduction.MAT:
return "Material";
case PlanetProduction.CAP:
return "Capital";
case PlanetProduction.DRIVE:
return "Drive";
case PlanetProduction.WEAPONS:
return "Weapons";
case PlanetProduction.SHIELDS:
return "Shields";
case PlanetProduction.CARGO:
return "Cargo";
case PlanetProduction.SCIENCE:
case PlanetProduction.SHIP:
return value.subject;
default:
return "";
}
}
test("switching production three times collapses to one auto-synced row", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"phase 15 spec covers desktop layout; mobile inherits the same store",
);
const handle = await mockGateway(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
await clickPlanetCentre(page);
const sidebar = page.getByTestId("sidebar-tool-inspector");
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth");
// Initial state: report.production = "Drive" → research segment is
// active, sub-row reveals Drive as the highlighted tech.
await expect(
sidebar.getByTestId("inspector-planet-production-segment-research"),
).toHaveClass(/active/);
// Click 1: Industry → CAP
await sidebar
.getByTestId("inspector-planet-production-segment-industry")
.click();
await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order");
await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount(
1,
);
await expect(orderTool.getByTestId("order-command-label-0")).toContainText(
"Capital",
);
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
"applied",
);
// Click 2: Materials → MAT (replaces CAP via collapse)
await page.getByTestId("sidebar-tab-inspector").click();
await sidebar
.getByTestId("inspector-planet-production-segment-materials")
.click();
await page.getByTestId("sidebar-tab-order").click();
await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount(
1,
);
await expect(orderTool.getByTestId("order-command-label-0")).toContainText(
"Material",
);
// Click 3: Build Ship → expand sub-row → Scout (replaces MAT)
await page.getByTestId("sidebar-tab-inspector").click();
await sidebar
.getByTestId("inspector-planet-production-segment-ship")
.click();
await sidebar
.getByTestId(`inspector-planet-production-ship-${SHIP_CLASS}`)
.click();
await page.getByTestId("sidebar-tab-order").click();
await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount(
1,
);
await expect(orderTool.getByTestId("order-command-label-0")).toContainText(
SHIP_CLASS,
);
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"synced",
);
expect(handle.lastSubmitted).not.toBeNull();
expect(handle.lastSubmitted!.planetNumber).toBe(17);
expect(handle.lastSubmitted!.productionType).toBe(PlanetProduction.SHIP);
expect(handle.lastSubmitted!.subject).toBe(SHIP_CLASS);
expect(handle.submitCount).toBeGreaterThanOrEqual(3);
// Reload: the layout polls user.games.order.get on boot, so the
// row is restored from the server's stored state even when the
// local cache is wiped.
await page.reload();
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
await page.getByTestId("sidebar-tab-order").click();
await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount(
1,
);
await expect(orderTool.getByTestId("order-command-label-0")).toContainText(
SHIP_CLASS,
);
});
+5 -1
View File
@@ -131,6 +131,7 @@ async function mockGateway(page: Page, opts: MockOpts): Promise<MockHandle> {
lastSubmittedName = submittedName;
const applied = opts.submitOutcome === "applied";
fixtures.push({
kind: "planetRename",
cmdId,
planetNumber: Number(inner.number()),
name: submittedName,
@@ -140,7 +141,10 @@ async function mockGateway(page: Page, opts: MockOpts): Promise<MockHandle> {
}
if (opts.submitOutcome === "applied") {
storedOrder = fixtures;
lastReportName = fixtures[0]?.name ?? lastReportName;
const head = fixtures[0];
if (head !== undefined && head.kind === "planetRename") {
lastReportName = head.name;
}
}
payload = buildOrderResponsePayload(GAME_ID, fixtures, Date.now());
break;
@@ -39,6 +39,7 @@ function withGameState(opts: {
planetCount: 0,
planets: [],
race: opts.race ?? "",
localShipClass: [],
};
store.status = "ready";
}
@@ -73,6 +73,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
planetCount: planets.length,
planets,
race: "",
localShipClass: [],
};
}
+34
View File
@@ -26,6 +26,7 @@ import { UUID } from "../src/proto/galaxy/fbs/common";
import {
LocalPlanet,
Report,
ShipClass,
} from "../src/proto/galaxy/fbs/report";
const listMyGamesSpy = vi.fn();
@@ -102,6 +103,7 @@ function buildReportPayload(opts: {
width?: number;
height?: number;
planets?: PlanetFixture[];
shipClasses?: { name: string }[];
}): Uint8Array {
const builder = new Builder(256);
const planetOffsets = (opts.planets ?? []).map((planet) => {
@@ -115,10 +117,20 @@ function buildReportPayload(opts: {
LocalPlanet.addResources(builder, 0.5);
return LocalPlanet.endLocalPlanet(builder);
});
const shipClassOffsets = (opts.shipClasses ?? []).map((cls) => {
const name = builder.createString(cls.name);
ShipClass.startShipClass(builder);
ShipClass.addName(builder, name);
return ShipClass.endShipClass(builder);
});
const localPlanetVec =
planetOffsets.length === 0
? null
: Report.createLocalPlanetVector(builder, planetOffsets);
const localShipClassVec =
shipClassOffsets.length === 0
? null
: Report.createLocalShipClassVector(builder, shipClassOffsets);
Report.startReport(builder);
Report.addTurn(builder, BigInt(opts.turn));
@@ -128,6 +140,9 @@ function buildReportPayload(opts: {
if (localPlanetVec !== null) {
Report.addLocalPlanet(builder, localPlanetVec);
}
if (localShipClassVec !== null) {
Report.addLocalShipClass(builder, localShipClassVec);
}
const reportOff = Report.endReport(builder);
builder.finish(reportOff);
return builder.asUint8Array();
@@ -261,4 +276,23 @@ describe("GameStateStore", () => {
expect(store.status).toBe("error");
expect(store.error).toBe("device session missing");
});
test("decodeReport surfaces the localShipClass projection by name", async () => {
listMyGamesSpy.mockResolvedValue([makeGameSummary(1)]);
const client = makeFakeClient(async () => ({
resultCode: "ok",
payloadBytes: buildReportPayload({
turn: 1,
planets: [{ number: 1, name: "Earth", x: 100, y: 100 }],
shipClasses: [{ name: "Scout" }, { name: "Destroyer" }],
}),
}));
const store = new GameStateStore();
await store.init({ client, cache, gameId: GAME_ID });
expect(store.report?.localShipClass).toEqual([
{ name: "Scout" },
{ name: "Destroyer" },
]);
store.dispose();
});
});
@@ -80,6 +80,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
planetCount: planets.length,
planets,
race: "",
localShipClass: [],
};
}
@@ -0,0 +1,283 @@
// Vitest component coverage for the Phase 15 production-controls
// subsection of the planet inspector. Drives the component against a
// real `OrderDraftStore` (with `fake-indexeddb` standing in for the
// browser's IDB factory) so the collapse-by-`planetNumber` rule and
// the per-row status side-effects are exercised end-to-end.
//
// The active-segment derivation is covered by direct render-and-
// query assertions: the parser is small enough that a table-driven
// pass over the canonical engine display strings catches every
// branch.
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import type {
ReportPlanet,
ShipClassSummary,
} from "../src/api/game-state";
import Production from "../src/lib/inspectors/planet/production.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../src/sync/order-draft.svelte";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { Cache } from "../src/platform/store/index";
import type { IDBPDatabase } from "idb";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
let db: IDBPDatabase<GalaxyDB>;
let dbName: string;
let cache: Cache;
let draft: OrderDraftStore;
beforeEach(async () => {
dbName = `galaxy-production-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
cache = new IDBCache(db);
draft = new OrderDraftStore();
await draft.init({ cache, gameId: GAME_ID });
i18n.resetForTests("en");
});
afterEach(async () => {
draft.dispose();
db.close();
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
});
function localPlanet(
overrides: Partial<ReportPlanet> & Pick<ReportPlanet, "number">,
): ReportPlanet {
return {
name: "Earth",
x: 0,
y: 0,
kind: "local",
owner: null,
size: 1000,
resources: 5,
industryStockpile: 0,
materialsStockpile: 0,
industry: 100,
population: 100,
colonists: 0,
production: null,
freeIndustry: 100,
...overrides,
};
}
function mountProduction(
planet: ReportPlanet,
localShipClass: ShipClassSummary[] = [],
) {
const context = new Map<unknown, unknown>([
[ORDER_DRAFT_CONTEXT_KEY, draft],
]);
return render(Production, {
props: { planet, localShipClass },
context,
});
}
describe("planet inspector — production controls", () => {
test("renders the four main segments with localised labels", () => {
const ui = mountProduction(localPlanet({ number: 1 }));
expect(
ui.getByTestId("inspector-planet-production-segment-industry"),
).toHaveTextContent("industry");
expect(
ui.getByTestId("inspector-planet-production-segment-materials"),
).toHaveTextContent("materials");
expect(
ui.getByTestId("inspector-planet-production-segment-research"),
).toHaveTextContent("research");
expect(
ui.getByTestId("inspector-planet-production-segment-ship"),
).toHaveTextContent("build ship");
});
test("Industry click emits a CAP setProductionType command", async () => {
const ui = mountProduction(localPlanet({ number: 7 }));
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-industry"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
expect(cmd.kind).toBe("setProductionType");
if (cmd.kind !== "setProductionType") return;
expect(cmd.planetNumber).toBe(7);
expect(cmd.productionType).toBe("CAP");
expect(cmd.subject).toBe("");
});
test("Materials click emits a MAT setProductionType command", async () => {
const ui = mountProduction(localPlanet({ number: 7 }));
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-materials"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
expect(cmd.productionType).toBe("MAT");
});
test("Research click reveals the four tech sub-buttons without emitting", async () => {
const ui = mountProduction(localPlanet({ number: 7 }));
expect(
ui.queryByTestId("inspector-planet-production-research-row"),
).toBeNull();
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-research"),
);
expect(
ui.getByTestId("inspector-planet-production-research-row"),
).toBeInTheDocument();
expect(draft.commands).toHaveLength(0);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-research-drive"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
expect(cmd.productionType).toBe("DRIVE");
expect(cmd.subject).toBe("");
});
test("Build-Ship segment shows the empty placeholder when no classes designed", async () => {
const ui = mountProduction(localPlanet({ number: 7 }), []);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-ship"),
);
expect(
ui.getByTestId("inspector-planet-production-ship-empty"),
).toBeInTheDocument();
});
test("Build-Ship click on a class emits a SHIP setProductionType command", async () => {
const ui = mountProduction(localPlanet({ number: 7 }), [
{ name: "Scout" },
{ name: "Destroyer" },
]);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-ship"),
);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-ship-Scout"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
expect(cmd.productionType).toBe("SHIP");
expect(cmd.subject).toBe("Scout");
});
test("re-clicks on the same planet collapse to the latest entry via the store", async () => {
const ui = mountProduction(localPlanet({ number: 7 }), [
{ name: "Scout" },
]);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-industry"),
);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-materials"),
);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-research"),
);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-research-cargo"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
expect(cmd.productionType).toBe("CARGO");
});
test("active main segment derives from planet.production display string", () => {
const cases: ReadonlyArray<{
production: string | null;
expected: "industry" | "materials" | "research" | "ship" | "none";
}> = [
{ production: "Capital", expected: "industry" },
{ production: "Material", expected: "materials" },
{ production: "Drive", expected: "research" },
{ production: "Weapons", expected: "research" },
{ production: "Shields", expected: "research" },
{ production: "Cargo", expected: "research" },
{ production: "Scout", expected: "ship" },
{ production: "-", expected: "none" },
{ production: null, expected: "none" },
{ production: "UnknownThing", expected: "none" },
];
for (const tc of cases) {
const ui = mountProduction(
localPlanet({ number: 1, production: tc.production }),
[{ name: "Scout" }],
);
const ids: ReadonlyArray<
"industry" | "materials" | "research" | "ship"
> = ["industry", "materials", "research", "ship"];
for (const id of ids) {
const el = ui.getByTestId(
`inspector-planet-production-segment-${id}`,
);
if (tc.expected === id) {
expect(el.classList.contains("active")).toBe(true);
} else {
expect(el.classList.contains("active")).toBe(false);
}
}
ui.unmount();
}
});
test("active research sub-button highlights for known display strings", () => {
const cases: ReadonlyArray<{
production: string;
slug: "drive" | "weapons" | "shields" | "cargo";
}> = [
{ production: "Drive", slug: "drive" },
{ production: "Weapons", slug: "weapons" },
{ production: "Shields", slug: "shields" },
{ production: "Cargo", slug: "cargo" },
];
for (const tc of cases) {
const ui = mountProduction(
localPlanet({ number: 1, production: tc.production }),
);
const el = ui.getByTestId(
`inspector-planet-production-research-${tc.slug}`,
);
expect(el.classList.contains("active")).toBe(true);
ui.unmount();
}
});
test("ship class sub-row matches when production equals a class name", async () => {
const ui = mountProduction(
localPlanet({ number: 1, production: "Scout" }),
[{ name: "Scout" }, { name: "Destroyer" }],
);
expect(
ui.getByTestId("inspector-planet-production-ship-Scout").classList
.contains("active"),
).toBe(true);
expect(
ui
.getByTestId("inspector-planet-production-ship-Destroyer")
.classList.contains("active"),
).toBe(false);
});
});
+27 -8
View File
@@ -61,9 +61,10 @@ describe("planet inspector", () => {
industry: 800,
industryStockpile: 12.5,
materialsStockpile: 30,
production: "drive",
production: "Drive",
freeIndustry: 187.5,
}),
localShipClass: [],
},
});
const section = ui.getByTestId("inspector-planet");
@@ -99,9 +100,10 @@ describe("planet inspector", () => {
expect(
ui.getByTestId("inspector-planet-field-materials_stockpile"),
).toHaveTextContent("30");
expect(
ui.getByTestId("inspector-planet-field-production"),
).toHaveTextContent("drive");
// Phase 15: the static "current production" row is replaced by
// the interactive Production component for owned planets.
expect(ui.queryByTestId("inspector-planet-field-production")).toBeNull();
expect(ui.getByTestId("inspector-planet-production")).toBeInTheDocument();
expect(
ui.getByTestId("inspector-planet-field-free_industry"),
).toHaveTextContent("187.5");
@@ -127,6 +129,7 @@ describe("planet inspector", () => {
production: "weapons",
freeIndustry: 75,
}),
localShipClass: [],
},
});
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
@@ -138,6 +141,11 @@ describe("planet inspector", () => {
expect(
ui.getByTestId("inspector-planet-field-population"),
).toHaveTextContent("500");
// Non-local planets keep the read-only production row.
expect(
ui.getByTestId("inspector-planet-field-production"),
).toHaveTextContent("weapons");
expect(ui.queryByTestId("inspector-planet-production")).toBeNull();
});
test("uninhabited planet hides population, industry, and production rows", () => {
@@ -152,6 +160,7 @@ describe("planet inspector", () => {
industryStockpile: 0,
materialsStockpile: 0,
}),
localShipClass: [],
},
});
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
@@ -183,6 +192,7 @@ describe("planet inspector", () => {
x: 1234,
y: -5,
}),
localShipClass: [],
},
});
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
@@ -210,6 +220,7 @@ describe("planet inspector", () => {
size: 100,
resources: 5,
}),
localShipClass: [],
},
});
expect(ui.queryByTestId("inspector-planet-rename-action")).toBeNull();
@@ -238,9 +249,10 @@ describe("planet inspector", () => {
industry: 0,
industryStockpile: 0,
materialsStockpile: 0,
production: "drive",
production: "Drive",
freeIndustry: 0,
}),
localShipClass: [],
},
context,
});
@@ -300,9 +312,10 @@ describe("planet inspector", () => {
industry: 0,
industryStockpile: 0,
materialsStockpile: 0,
production: "drive",
production: "Drive",
freeIndustry: 0,
}),
localShipClass: [],
},
context,
});
@@ -314,13 +327,14 @@ describe("planet inspector", () => {
db.close();
});
test("missing production string falls back to the localised placeholder", () => {
test("non-local planets fall back to the localised production placeholder", () => {
const ui = render(Planet, {
props: {
planet: makePlanet({
number: 5,
name: "Idle",
kind: "local",
kind: "other",
owner: "Drift",
size: 800,
resources: 1,
population: 1,
@@ -331,8 +345,13 @@ describe("planet inspector", () => {
production: "",
freeIndustry: 0,
}),
localShipClass: [],
},
});
// Empty production strings collapse to the localised "none"
// placeholder on the read-only path. The local-planet branch
// owns the production surface via the interactive component
// instead and is covered by `inspector-planet-production.test.ts`.
expect(
ui.getByTestId("inspector-planet-field-production"),
).toHaveTextContent("none");
+131
View File
@@ -197,6 +197,137 @@ describe("OrderDraftStore", () => {
store.dispose();
});
test("setProductionType validates locally per the engine's subject rule", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add({
kind: "setProductionType",
id: "cap",
planetNumber: 1,
productionType: "CAP",
subject: "",
});
await store.add({
kind: "setProductionType",
id: "drive",
planetNumber: 2,
productionType: "DRIVE",
subject: "",
});
await store.add({
kind: "setProductionType",
id: "ship-ok",
planetNumber: 3,
productionType: "SHIP",
subject: "Scout",
});
await store.add({
kind: "setProductionType",
id: "ship-empty",
planetNumber: 4,
productionType: "SHIP",
subject: "",
});
await store.add({
kind: "setProductionType",
id: "science-bad",
planetNumber: 5,
productionType: "SCIENCE",
subject: "Bad Name",
});
expect(store.statuses["cap"]).toBe("valid");
expect(store.statuses["drive"]).toBe("valid");
expect(store.statuses["ship-ok"]).toBe("valid");
expect(store.statuses["ship-empty"]).toBe("invalid");
expect(store.statuses["science-bad"]).toBe("invalid");
store.dispose();
});
test("setProductionType collapses to the latest entry per planet", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add({
kind: "setProductionType",
id: "first",
planetNumber: 7,
productionType: "CAP",
subject: "",
});
await store.add({
kind: "setProductionType",
id: "second",
planetNumber: 7,
productionType: "MAT",
subject: "",
});
await store.add({
kind: "setProductionType",
id: "third",
planetNumber: 7,
productionType: "DRIVE",
subject: "",
});
expect(store.commands).toHaveLength(1);
const only = store.commands[0]!;
expect(only.id).toBe("third");
if (only.kind !== "setProductionType") {
throw new Error("expected setProductionType");
}
expect(only.productionType).toBe("DRIVE");
// Old ids are scrubbed from statuses so the order tab does not
// keep ghost rows.
expect(store.statuses["first"]).toBeUndefined();
expect(store.statuses["second"]).toBeUndefined();
expect(store.statuses["third"]).toBe("valid");
store.dispose();
});
test("setProductionType for different planets stay independent", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add({
kind: "setProductionType",
id: "p7-cap",
planetNumber: 7,
productionType: "CAP",
subject: "",
});
await store.add({
kind: "setProductionType",
id: "p9-mat",
planetNumber: 9,
productionType: "MAT",
subject: "",
});
expect(store.commands.map((c) => c.id)).toEqual([
"p7-cap",
"p9-mat",
]);
store.dispose();
});
test("planetRename and setProductionType on the same planet keep both", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add({
kind: "planetRename",
id: "ren",
planetNumber: 7,
name: "Earth",
});
await store.add({
kind: "setProductionType",
id: "prod",
planetNumber: 7,
productionType: "CAP",
subject: "",
});
expect(store.commands.map((c) => c.id)).toEqual(["ren", "prod"]);
expect(store.statuses["ren"]).toBe("valid");
expect(store.statuses["prod"]).toBe("valid");
store.dispose();
});
test("hydrateFromServer overwrites the local cache with the server snapshot", async () => {
const { fakeFetchClient } = await import("./helpers/fake-order-client");
const { client } = fakeFetchClient(GAME_ID, [
+89
View File
@@ -11,7 +11,9 @@ import { UUID } from "../src/proto/galaxy/fbs/common";
import {
CommandItem,
CommandPayload,
CommandPlanetProduce,
CommandPlanetRename,
PlanetProduction,
UserGamesOrder,
UserGamesOrderGet,
UserGamesOrderGetResponse,
@@ -130,6 +132,93 @@ describe("fetchOrder", () => {
});
});
test("decodes a CommandPlanetProduce envelope into setProductionType", async () => {
const builder = new Builder(256);
const cmdIdOffset = builder.createString("cmd-prod");
const subjectOffset = builder.createString("Scout");
const inner = CommandPlanetProduce.createCommandPlanetProduce(
builder,
BigInt(17),
PlanetProduction.SHIP,
subjectOffset,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetProduce);
CommandItem.addPayload(builder, inner);
const item = CommandItem.endCommandItem(builder);
const commandsVec = UserGamesOrder.createCommandsVector(builder, [item]);
const [hi, lo] = uuidToHiLo(GAME_ID);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrder.startUserGamesOrder(builder);
UserGamesOrder.addGameId(builder, gameIdOffset);
UserGamesOrder.addUpdatedAt(builder, BigInt(13));
UserGamesOrder.addCommands(builder, commandsVec);
const orderOffset = UserGamesOrder.endUserGamesOrder(builder);
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
UserGamesOrderGetResponse.addFound(builder, true);
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
const offset = UserGamesOrderGetResponse.endUserGamesOrderGetResponse(
builder,
);
builder.finish(offset);
const responsePayload = builder.asUint8Array();
const exec = vi.fn(async () => ({
resultCode: "ok",
payloadBytes: responsePayload,
}));
const result = await fetchOrder(mockClient(exec), GAME_ID, 5);
expect(result.commands).toHaveLength(1);
const cmd = result.commands[0]!;
expect(cmd.kind).toBe("setProductionType");
if (cmd.kind !== "setProductionType") return;
expect(cmd.id).toBe("cmd-prod");
expect(cmd.planetNumber).toBe(17);
expect(cmd.productionType).toBe("SHIP");
expect(cmd.subject).toBe("Scout");
expect(result.updatedAt).toBe(13);
});
test("skips a CommandPlanetProduce with PlanetProduction.UNKNOWN", async () => {
const builder = new Builder(256);
const cmdIdOffset = builder.createString("cmd-unknown");
const subjectOffset = builder.createString("");
const inner = CommandPlanetProduce.createCommandPlanetProduce(
builder,
BigInt(0),
PlanetProduction.UNKNOWN,
subjectOffset,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetProduce);
CommandItem.addPayload(builder, inner);
const item = CommandItem.endCommandItem(builder);
const commandsVec = UserGamesOrder.createCommandsVector(builder, [item]);
const [hi, lo] = uuidToHiLo(GAME_ID);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrder.startUserGamesOrder(builder);
UserGamesOrder.addGameId(builder, gameIdOffset);
UserGamesOrder.addUpdatedAt(builder, BigInt(0));
UserGamesOrder.addCommands(builder, commandsVec);
const orderOffset = UserGamesOrder.endUserGamesOrder(builder);
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
UserGamesOrderGetResponse.addFound(builder, true);
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
const offset = UserGamesOrderGetResponse.endUserGamesOrderGetResponse(
builder,
);
builder.finish(offset);
const exec = vi.fn(async () => ({
resultCode: "ok",
payloadBytes: builder.asUint8Array(),
}));
const result = await fetchOrder(mockClient(exec), GAME_ID, 5);
expect(result.commands).toEqual([]);
});
test("posts a well-formed UserGamesOrderGet payload", async () => {
let captured: Uint8Array | null = null;
const exec = vi.fn(async (_messageType, payload: Uint8Array) => {
+124 -4
View File
@@ -1,16 +1,22 @@
// Vitest unit coverage for the pure `applyOrderOverlay` projection.
// Phase 14 understands `planetRename` only; future phases (set
// production, route updates) will extend the overlay and need
// equivalent cases here.
// Phase 14 introduced the overlay for `planetRename`; Phase 15
// extends it to `setProductionType` and shares the same eligibility
// rule. Future phases (route updates, etc.) will extend the overlay
// and need equivalent cases here.
import { describe, expect, test } from "vitest";
import {
applyOrderOverlay,
productionDisplayFromCommand,
type GameReport,
type ReportPlanet,
} from "../src/api/game-state";
import type { CommandStatus, OrderCommand } from "../src/sync/order-types";
import type {
CommandStatus,
OrderCommand,
ProductionType,
} from "../src/sync/order-types";
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
return {
@@ -41,6 +47,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
planetCount: planets.length,
planets,
race: "",
localShipClass: [],
};
}
@@ -153,4 +160,117 @@ describe("applyOrderOverlay", () => {
});
expect(out.planets[0]!.name).toBe("Final");
});
test("setProductionType rewrites planet.production for valid statuses", () => {
const report = makeReport([
makePlanet({ number: 1, name: "Earth", production: "Capital" }),
]);
const cmd: OrderCommand = {
kind: "setProductionType",
id: "cmd-1",
planetNumber: 1,
productionType: "DRIVE",
subject: "",
};
for (const status of ["valid", "submitting", "applied"] as const) {
const out = applyOrderOverlay(report, [cmd], { "cmd-1": status });
expect(out.planets[0]!.production).toBe("Drive");
}
});
test("setProductionType skips draft / invalid / rejected statuses", () => {
const report = makeReport([
makePlanet({ number: 1, name: "Earth", production: "Capital" }),
]);
const cmd: OrderCommand = {
kind: "setProductionType",
id: "cmd-1",
planetNumber: 1,
productionType: "DRIVE",
subject: "",
};
for (const status of ["draft", "invalid", "rejected"] as const) {
const out = applyOrderOverlay(report, [cmd], { "cmd-1": status });
expect(out.planets[0]!.production).toBe("Capital");
}
});
test("setProductionType applied with subject mirrors the engine's display", () => {
const report = makeReport([
makePlanet({ number: 1, name: "Earth", production: "Capital" }),
]);
const cmd: OrderCommand = {
kind: "setProductionType",
id: "cmd-1",
planetNumber: 1,
productionType: "SHIP",
subject: "Scout",
};
const out = applyOrderOverlay(report, [cmd], { "cmd-1": "applied" });
expect(out.planets[0]!.production).toBe("Scout");
});
test("setProductionType + planetRename for the same planet compose", () => {
const report = makeReport([
makePlanet({ number: 1, name: "Earth", production: "Capital" }),
]);
const rename: OrderCommand = {
kind: "planetRename",
id: "cmd-rename",
planetNumber: 1,
name: "New-Earth",
};
const setProd: OrderCommand = {
kind: "setProductionType",
id: "cmd-prod",
planetNumber: 1,
productionType: "DRIVE",
subject: "",
};
const out = applyOrderOverlay(report, [rename, setProd], {
"cmd-rename": "applied",
"cmd-prod": "applied",
});
expect(out.planets[0]!.name).toBe("New-Earth");
expect(out.planets[0]!.production).toBe("Drive");
});
test("ignores setProductionType for missing planet (visibility lost)", () => {
const report = makeReport([
makePlanet({ number: 1, name: "Earth", production: "Capital" }),
]);
const cmd: OrderCommand = {
kind: "setProductionType",
id: "cmd-1",
planetNumber: 99,
productionType: "DRIVE",
subject: "",
};
const out = applyOrderOverlay(report, [cmd], { "cmd-1": "applied" });
expect(out).toBe(report);
});
});
describe("productionDisplayFromCommand", () => {
const cases: ReadonlyArray<{
productionType: ProductionType;
subject: string;
expected: string;
}> = [
{ productionType: "MAT", subject: "", expected: "Material" },
{ productionType: "CAP", subject: "", expected: "Capital" },
{ productionType: "DRIVE", subject: "", expected: "Drive" },
{ productionType: "WEAPONS", subject: "", expected: "Weapons" },
{ productionType: "SHIELDS", subject: "", expected: "Shields" },
{ productionType: "CARGO", subject: "", expected: "Cargo" },
{ productionType: "SCIENCE", subject: "AlphaSci", expected: "AlphaSci" },
{ productionType: "SHIP", subject: "Scout", expected: "Scout" },
];
for (const tc of cases) {
test(`${tc.productionType}${tc.expected}`, () => {
expect(productionDisplayFromCommand(tc.productionType, tc.subject)).toBe(
tc.expected,
);
});
}
});
+1
View File
@@ -20,6 +20,7 @@ function makeReport(overrides: Partial<GameReport> = {}): GameReport {
planetCount: 0,
planets: [],
race: "",
localShipClass: [],
...overrides,
};
}
+92 -1
View File
@@ -11,13 +11,18 @@ import { uuidToHiLo } from "../src/api/game-state";
import { UUID } from "../src/proto/galaxy/fbs/common";
import {
CommandItem,
CommandPlanetProduce,
CommandPlanetRename,
CommandPayload,
PlanetProduction,
UserGamesOrder,
UserGamesOrderResponse,
} from "../src/proto/galaxy/fbs/order";
import { submitOrder } from "../src/sync/submit";
import type { OrderCommand } from "../src/sync/order-types";
import type {
OrderCommand,
ProductionType,
} from "../src/sync/order-types";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
@@ -178,4 +183,90 @@ describe("submitOrder", () => {
expect(Number(inner.number())).toBe(7);
expect(inner.name()).toBe("Earth");
});
test("encodes setProductionType as CommandPlanetProduce on the wire", async () => {
let captured: Uint8Array | null = null;
const exec = vi.fn(async (_messageType, payload: Uint8Array) => {
captured = payload;
return { resultCode: "ok", payloadBytes: new Uint8Array() };
});
const cmd: OrderCommand = {
kind: "setProductionType",
id: "00000000-0000-0000-0000-00000000cccc",
planetNumber: 17,
productionType: "SHIP",
subject: "Scout",
};
await submitOrder(mockClient(exec), GAME_ID, [cmd]);
expect(captured).not.toBeNull();
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
new (await import("flatbuffers")).ByteBuffer(captured!),
);
expect(decoded.commandsLength()).toBe(1);
const item = decoded.commands(0);
expect(item).not.toBeNull();
expect(item!.cmdId()).toBe(cmd.id);
expect(item!.payloadType()).toBe(CommandPayload.CommandPlanetProduce);
const inner = new CommandPlanetProduce();
item!.payload(inner);
expect(Number(inner.number())).toBe(17);
expect(inner.production()).toBe(PlanetProduction.SHIP);
expect(inner.subject()).toBe("Scout");
});
test("maps every productionType literal to its FBS enum value", async () => {
const cases: Array<{
productionType: ProductionType;
fbs: PlanetProduction;
subject: string;
}> = [
{ productionType: "MAT", fbs: PlanetProduction.MAT, subject: "" },
{ productionType: "CAP", fbs: PlanetProduction.CAP, subject: "" },
{ productionType: "DRIVE", fbs: PlanetProduction.DRIVE, subject: "" },
{
productionType: "WEAPONS",
fbs: PlanetProduction.WEAPONS,
subject: "",
},
{
productionType: "SHIELDS",
fbs: PlanetProduction.SHIELDS,
subject: "",
},
{ productionType: "CARGO", fbs: PlanetProduction.CARGO, subject: "" },
{
productionType: "SCIENCE",
fbs: PlanetProduction.SCIENCE,
subject: "AlphaSci",
},
{
productionType: "SHIP",
fbs: PlanetProduction.SHIP,
subject: "Scout",
},
];
for (const tc of cases) {
let captured: Uint8Array | null = null;
const exec = vi.fn(async (_messageType, payload: Uint8Array) => {
captured = payload;
return { resultCode: "ok", payloadBytes: new Uint8Array() };
});
const cmd: OrderCommand = {
kind: "setProductionType",
id: `id-${tc.productionType}`,
planetNumber: 5,
productionType: tc.productionType,
subject: tc.subject,
};
await submitOrder(mockClient(exec), GAME_ID, [cmd]);
expect(captured).not.toBeNull();
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
new (await import("flatbuffers")).ByteBuffer(captured!),
);
const inner = new CommandPlanetProduce();
decoded.commands(0)!.payload(inner);
expect(inner.production()).toBe(tc.fbs);
expect(inner.subject()).toBe(tc.subject);
}
});
});