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
+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[],