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:
@@ -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" };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ function makeReport(overrides: Partial<GameReport> = {}): GameReport {
|
||||
planetCount: 0,
|
||||
planets: [],
|
||||
race: "",
|
||||
localShipClass: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user