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