ui/phase-18: ship-class calc bridge with live designer preview
Wires pkg/calc/ship.go into the WASM Core boundary as seven thin
wrappers (DriveEffective, EmptyMass, WeaponsBlockMass, FullMass,
Speed, CargoCapacity, CarryingMass). The ship-class designer reads
Core through a new CORE_CONTEXT_KEY populated by the in-game layout
and renders a five-row preview pane (mass, full-load mass, max
speed, range at full load, cargo capacity) that updates reactively
on every form edit and on the player's localPlayer{Drive,Weapons,
Shields,Cargo} tech levels — three of which are now decoded from
the report's Player block alongside the existing localPlayerDrive.
CarryingMass is the seventh wrapper added to the original six-function
list so that "full-load mass" composes through pkg/calc/ functions
without putting math in TypeScript.
This commit is contained in:
@@ -149,15 +149,23 @@ export interface GameReport {
|
||||
*/
|
||||
routes: ReportRoute[];
|
||||
/**
|
||||
* localPlayerDrive is the local player's drive tech level. The
|
||||
* engine's reach formula is `40 * driveTech`
|
||||
* (`game/internal/model/game/race.go.FlightDistance`); the
|
||||
* cargo-route picker filters destinations through it, so the
|
||||
* value is propagated all the way through `applyOrderOverlay`
|
||||
* to the inspector subsection. Zero on boot or when the
|
||||
* report's player block is missing the local entry.
|
||||
* localPlayerDrive, localPlayerWeapons, localPlayerShields,
|
||||
* localPlayerCargo carry the local player's four tech levels,
|
||||
* read from the matching `Player` row in the report. Drive
|
||||
* powers reach (`40 * driveTech`,
|
||||
* `game/internal/model/game/race.go.FlightDistance`) and the
|
||||
* cargo-route picker; cargo feeds the ship-class designer's
|
||||
* cargo-capacity preview (`pkg/calc/ship.go.CargoCapacity` and
|
||||
* `CarryingMass`); weapons and shields are surfaced ahead of
|
||||
* Phases 19-21 (ship-group inspector, science designer) so
|
||||
* future patches do not need to re-extend the report decoder.
|
||||
* All four are zero on boot or when the report's player block
|
||||
* is missing the local entry.
|
||||
*/
|
||||
localPlayerDrive: number;
|
||||
localPlayerWeapons: number;
|
||||
localPlayerShields: number;
|
||||
localPlayerCargo: number;
|
||||
}
|
||||
|
||||
export async function fetchGameReport(
|
||||
@@ -290,7 +298,7 @@ function decodeReport(report: Report): GameReport {
|
||||
|
||||
const raceName = report.race() ?? "";
|
||||
const routes = decodeReportRoutes(report);
|
||||
const localPlayerDrive = findLocalPlayerDrive(report, raceName);
|
||||
const localTech = findLocalPlayerTech(report, raceName);
|
||||
|
||||
return {
|
||||
turn: Number(report.turn()),
|
||||
@@ -301,7 +309,10 @@ function decodeReport(report: Report): GameReport {
|
||||
race: raceName,
|
||||
localShipClass,
|
||||
routes,
|
||||
localPlayerDrive,
|
||||
localPlayerDrive: localTech.drive,
|
||||
localPlayerWeapons: localTech.weapons,
|
||||
localPlayerShields: localTech.shields,
|
||||
localPlayerCargo: localTech.cargo,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -356,24 +367,39 @@ function compareRouteEntriesByLoadType(
|
||||
return LOAD_TYPE_ORDER[a.loadType] - LOAD_TYPE_ORDER[b.loadType];
|
||||
}
|
||||
|
||||
interface LocalPlayerTech {
|
||||
drive: number;
|
||||
weapons: number;
|
||||
shields: number;
|
||||
cargo: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* findLocalPlayerDrive locates the local player's drive tech
|
||||
* level by matching `Player.name` against the report's `race`
|
||||
* field (the engine uses race name as the runtime player
|
||||
* identifier). Returns 0 when the lookup fails — boot state, an
|
||||
* findLocalPlayerTech locates the local player's four tech levels
|
||||
* by matching `Player.name` against the report's `race` field (the
|
||||
* engine uses race name as the runtime player identifier). Returns
|
||||
* a zero-filled record when the lookup fails — boot state, an
|
||||
* incomplete report, or a future schema bump that switches to
|
||||
* UUIDs. Wrapping the lookup in one helper keeps the migration
|
||||
* cost contained.
|
||||
*/
|
||||
function findLocalPlayerDrive(report: Report, raceName: string): number {
|
||||
if (raceName === "") return 0;
|
||||
function findLocalPlayerTech(
|
||||
report: Report,
|
||||
raceName: string,
|
||||
): LocalPlayerTech {
|
||||
if (raceName === "") return { drive: 0, weapons: 0, shields: 0, cargo: 0 };
|
||||
for (let i = 0; i < report.playerLength(); i++) {
|
||||
const player = report.player(i);
|
||||
if (player === null) continue;
|
||||
if ((player.name() ?? "") !== raceName) continue;
|
||||
return player.drive();
|
||||
return {
|
||||
drive: player.drive(),
|
||||
weapons: player.weapons(),
|
||||
shields: player.shields(),
|
||||
cargo: player.cargo(),
|
||||
};
|
||||
}
|
||||
return 0;
|
||||
return { drive: 0, weapons: 0, shields: 0, cargo: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,9 +18,12 @@ Phase 17 ship-class designer. Two modes driven by the optional
|
||||
referenced by active production / ship groups) and a Back
|
||||
button.
|
||||
|
||||
Phase 18 wires `pkg/calc/` into the form for live mass / speed /
|
||||
range / cargo previews; the markup keeps a placeholder slot near
|
||||
the value fields so the diff in Phase 18 stays minimal.
|
||||
Phase 18 wires `pkg/calc/` (via the `Core` WASM bridge) into the
|
||||
new-mode form: an `<aside class="preview">` block recomputes mass,
|
||||
full-load mass, max speed, range at full load, and cargo capacity
|
||||
on every form change, using the local player's tech levels off the
|
||||
rendered report. The preview hides itself until the form passes
|
||||
validation, so it never displays half-cooked numbers.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext, tick } from "svelte";
|
||||
@@ -41,6 +44,10 @@ the value fields so the diff in Phase 18 stays minimal.
|
||||
validateShipClass,
|
||||
type ShipClassInvalidReason,
|
||||
} from "$lib/util/ship-class-validation";
|
||||
import {
|
||||
CORE_CONTEXT_KEY,
|
||||
type CoreHandle,
|
||||
} from "$lib/core-context.svelte";
|
||||
|
||||
const rendered = getContext<RenderedReportSource | undefined>(
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
@@ -48,6 +55,7 @@ the value fields so the diff in Phase 18 stays minimal.
|
||||
const draft = getContext<OrderDraftStore | undefined>(
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
);
|
||||
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
|
||||
|
||||
const gameId = $derived(page.params.id ?? "");
|
||||
const classId = $derived(page.params.classId ?? "");
|
||||
@@ -105,6 +113,54 @@ the value fields so the diff in Phase 18 stays minimal.
|
||||
);
|
||||
const canSave = $derived(validation.ok && draft !== undefined);
|
||||
|
||||
const driveTech = $derived(rendered?.report?.localPlayerDrive ?? 0);
|
||||
const cargoTech = $derived(rendered?.report?.localPlayerCargo ?? 0);
|
||||
|
||||
interface PreviewValues {
|
||||
mass: number;
|
||||
fullLoadMass: number;
|
||||
maxSpeed: number;
|
||||
rangeAtFull: number;
|
||||
cargoCapacity: number;
|
||||
}
|
||||
|
||||
const preview = $derived.by<PreviewValues | null>(() => {
|
||||
const core = coreHandle?.core;
|
||||
if (core === undefined || core === null) return null;
|
||||
if (!validation.ok) return null;
|
||||
const v = validation.value;
|
||||
const mass = core.emptyMass({
|
||||
drive: v.drive,
|
||||
weapons: v.weapons,
|
||||
armament: v.armament,
|
||||
shields: v.shields,
|
||||
cargo: v.cargo,
|
||||
});
|
||||
if (mass === null) return null;
|
||||
const cargoCapacity = core.cargoCapacity({
|
||||
cargo: v.cargo,
|
||||
cargoTech,
|
||||
});
|
||||
const carryAtFull =
|
||||
cargoTech > 0
|
||||
? core.carryingMass({ load: cargoCapacity, cargoTech })
|
||||
: 0;
|
||||
const fullLoadMass = core.fullMass({
|
||||
emptyMass: mass,
|
||||
carryingMass: carryAtFull,
|
||||
});
|
||||
const driveEffective = core.driveEffective({
|
||||
drive: v.drive,
|
||||
driveTech,
|
||||
});
|
||||
const maxSpeed = core.speed({ driveEffective, fullMass: mass });
|
||||
const rangeAtFull = core.speed({
|
||||
driveEffective,
|
||||
fullMass: fullLoadMass,
|
||||
});
|
||||
return { mass, fullLoadMass, maxSpeed, rangeAtFull, cargoCapacity };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!isViewMode) {
|
||||
void tick().then(() => nameInputEl?.focus());
|
||||
@@ -309,6 +365,52 @@ the value fields so the diff in Phase 18 stays minimal.
|
||||
{invalidMessage}
|
||||
</p>
|
||||
{/if}
|
||||
{#if preview !== null}
|
||||
<aside
|
||||
class="preview"
|
||||
data-testid="designer-ship-class-preview"
|
||||
>
|
||||
<h3>{i18n.t("game.designer.ship_class.preview.title")}</h3>
|
||||
<dl>
|
||||
<div class="row">
|
||||
<dt>{i18n.t("game.designer.ship_class.preview.mass")}</dt>
|
||||
<dd data-testid="designer-ship-class-preview-mass">
|
||||
{formatNumber(preview.mass)}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="row">
|
||||
<dt>
|
||||
{i18n.t("game.designer.ship_class.preview.full_load_mass")}
|
||||
</dt>
|
||||
<dd data-testid="designer-ship-class-preview-full-load-mass">
|
||||
{formatNumber(preview.fullLoadMass)}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="row">
|
||||
<dt>
|
||||
{i18n.t("game.designer.ship_class.preview.max_speed")}
|
||||
</dt>
|
||||
<dd data-testid="designer-ship-class-preview-max-speed">
|
||||
{formatNumber(preview.maxSpeed)}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="row">
|
||||
<dt>{i18n.t("game.designer.ship_class.preview.range")}</dt>
|
||||
<dd data-testid="designer-ship-class-preview-range">
|
||||
{formatNumber(preview.rangeAtFull)}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="row">
|
||||
<dt>
|
||||
{i18n.t("game.designer.ship_class.preview.cargo_capacity")}
|
||||
</dt>
|
||||
<dd data-testid="designer-ship-class-preview-cargo-capacity">
|
||||
{formatNumber(preview.cargoCapacity)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</aside>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
@@ -383,6 +485,42 @@ the value fields so the diff in Phase 18 stays minimal.
|
||||
font-size: 0.8rem;
|
||||
color: #d97a7a;
|
||||
}
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
background: #0a0e1a;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 4px;
|
||||
max-width: 30rem;
|
||||
}
|
||||
.preview h3 {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #aab;
|
||||
font-weight: 500;
|
||||
}
|
||||
.preview dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
row-gap: 0.2rem;
|
||||
column-gap: 0.75rem;
|
||||
}
|
||||
.preview .row {
|
||||
display: contents;
|
||||
}
|
||||
.preview dt {
|
||||
color: #aab;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.preview dd {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.9rem;
|
||||
text-align: right;
|
||||
}
|
||||
.fields {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Exposes the WASM `Core` instance through a Svelte context so views
|
||||
// that need its math bridge (Phase 18 ship-class preview, future
|
||||
// inspector calculators) can read it without re-booting the module.
|
||||
// The layout populates `core` after `loadCore()` resolves; consumers
|
||||
// observe `null` while the boot is in flight and the live `Core`
|
||||
// once the runtime is ready.
|
||||
|
||||
import type { Core } from "../platform/core/index";
|
||||
|
||||
/**
|
||||
* CORE_CONTEXT_KEY is the Svelte context key the in-game shell
|
||||
* layout uses to expose its booted `Core` to descendants such as
|
||||
* the ship-class designer preview pane.
|
||||
*/
|
||||
export const CORE_CONTEXT_KEY = Symbol("core");
|
||||
|
||||
export interface CoreHandle {
|
||||
readonly core: Core | null;
|
||||
}
|
||||
|
||||
export class CoreHolder implements CoreHandle {
|
||||
#core: Core | null = $state(null);
|
||||
|
||||
get core(): Core | null {
|
||||
return this.#core;
|
||||
}
|
||||
|
||||
set(core: Core | null): void {
|
||||
this.#core = core;
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,13 @@ const en = {
|
||||
"game.designer.ship_class.invalid.cargo_value": "cargo must be 0 or ≥ 1",
|
||||
"game.designer.ship_class.invalid.armament_weapons_pair": "armament and weapons must be both zero or both nonzero",
|
||||
"game.designer.ship_class.invalid.all_zero": "at least one value must be nonzero",
|
||||
"game.designer.ship_class.preview.title": "preview at your tech levels",
|
||||
"game.designer.ship_class.preview.mass": "mass",
|
||||
"game.designer.ship_class.preview.full_load_mass": "full-load mass",
|
||||
"game.designer.ship_class.preview.max_speed": "max speed (ly/turn)",
|
||||
"game.designer.ship_class.preview.range": "range at full load (ly/turn)",
|
||||
"game.designer.ship_class.preview.cargo_capacity": "cargo capacity per ship",
|
||||
"game.designer.ship_class.preview.unavailable": "—",
|
||||
} as const;
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -239,6 +239,13 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.designer.ship_class.invalid.cargo_value": "трюм должен быть 0 или ≥ 1",
|
||||
"game.designer.ship_class.invalid.armament_weapons_pair": "вооружённость и оружие должны быть оба нулевыми или оба ненулевыми",
|
||||
"game.designer.ship_class.invalid.all_zero": "хотя бы одно значение должно быть ненулевым",
|
||||
"game.designer.ship_class.preview.title": "превью при ваших технологиях",
|
||||
"game.designer.ship_class.preview.mass": "масса",
|
||||
"game.designer.ship_class.preview.full_load_mass": "масса с полной загрузкой",
|
||||
"game.designer.ship_class.preview.max_speed": "максимальная скорость (св.лет/ход)",
|
||||
"game.designer.ship_class.preview.range": "дальность при полной загрузке (св.лет/ход)",
|
||||
"game.designer.ship_class.preview.cargo_capacity": "грузоподъёмность одного корабля",
|
||||
"game.designer.ship_class.preview.unavailable": "—",
|
||||
};
|
||||
|
||||
export default ru;
|
||||
|
||||
@@ -35,6 +35,44 @@ export interface EventSigningFields {
|
||||
payloadHash: Uint8Array;
|
||||
}
|
||||
|
||||
export interface DriveEffectiveInput {
|
||||
drive: number;
|
||||
driveTech: number;
|
||||
}
|
||||
|
||||
export interface ShipBlocksInput {
|
||||
drive: number;
|
||||
weapons: number;
|
||||
armament: number;
|
||||
shields: number;
|
||||
cargo: number;
|
||||
}
|
||||
|
||||
export interface WeaponsBlockInput {
|
||||
weapons: number;
|
||||
armament: number;
|
||||
}
|
||||
|
||||
export interface FullMassInput {
|
||||
emptyMass: number;
|
||||
carryingMass: number;
|
||||
}
|
||||
|
||||
export interface SpeedInput {
|
||||
driveEffective: number;
|
||||
fullMass: number;
|
||||
}
|
||||
|
||||
export interface CargoCapacityInput {
|
||||
cargo: number;
|
||||
cargoTech: number;
|
||||
}
|
||||
|
||||
export interface CarryingMassInput {
|
||||
load: number;
|
||||
cargoTech: number;
|
||||
}
|
||||
|
||||
export interface Core {
|
||||
/**
|
||||
* signRequest returns the canonical signing input bytes for a v1
|
||||
@@ -71,6 +109,54 @@ export interface Core {
|
||||
payloadBytes: Uint8Array,
|
||||
payloadHash: Uint8Array,
|
||||
): boolean;
|
||||
|
||||
/**
|
||||
* driveEffective wraps `pkg/calc/ship.go.DriveEffective`: effective
|
||||
* drive power = ship drive block × player drive tech.
|
||||
*/
|
||||
driveEffective(input: DriveEffectiveInput): number;
|
||||
|
||||
/**
|
||||
* emptyMass wraps `pkg/calc/ship.go.EmptyMass`: mass of the ship
|
||||
* with empty holds. Returns null when the upstream validator
|
||||
* rejects the weapons/armament pair (one zero and the other
|
||||
* non-zero).
|
||||
*/
|
||||
emptyMass(input: ShipBlocksInput): number | null;
|
||||
|
||||
/**
|
||||
* weaponsBlockMass wraps `pkg/calc/ship.go.WeaponsBlockMass`: mass
|
||||
* of the weapons sub-block. Returns null on the same invalid
|
||||
* pairing as emptyMass.
|
||||
*/
|
||||
weaponsBlockMass(input: WeaponsBlockInput): number | null;
|
||||
|
||||
/**
|
||||
* fullMass wraps `pkg/calc/ship.go.FullMass`: empty mass plus the
|
||||
* mass of the carried cargo.
|
||||
*/
|
||||
fullMass(input: FullMassInput): number;
|
||||
|
||||
/**
|
||||
* speed wraps `pkg/calc/ship.go.Speed`: light-years per turn,
|
||||
* driveEffective × 20 / fullMass; zero when fullMass ≤ 0.
|
||||
*/
|
||||
speed(input: SpeedInput): number;
|
||||
|
||||
/**
|
||||
* cargoCapacity wraps `pkg/calc/ship.go.CargoCapacity`: hold
|
||||
* capacity of one ship in cargo units, scaled by the player's
|
||||
* cargo tech.
|
||||
*/
|
||||
cargoCapacity(input: CargoCapacityInput): number;
|
||||
|
||||
/**
|
||||
* carryingMass wraps `pkg/calc/ship.go.CarryingMass`: mass of a
|
||||
* payload of `load` cargo units at the player's cargo tech. Used
|
||||
* by the ship-class designer to derive full-load mass from
|
||||
* cargoCapacity.
|
||||
*/
|
||||
carryingMass(input: CarryingMassInput): number;
|
||||
}
|
||||
|
||||
export type CoreLoader = () => Promise<Core>;
|
||||
|
||||
@@ -9,10 +9,17 @@
|
||||
// served from `static/core.wasm`.
|
||||
|
||||
import type {
|
||||
CargoCapacityInput,
|
||||
CarryingMassInput,
|
||||
Core,
|
||||
DriveEffectiveInput,
|
||||
EventSigningFields,
|
||||
FullMassInput,
|
||||
RequestSigningFields,
|
||||
ResponseSigningFields,
|
||||
ShipBlocksInput,
|
||||
SpeedInput,
|
||||
WeaponsBlockInput,
|
||||
} from "./index";
|
||||
|
||||
/**
|
||||
@@ -36,6 +43,13 @@ interface GalaxyCoreBridge {
|
||||
payloadBytes: Uint8Array,
|
||||
payloadHash: Uint8Array,
|
||||
): boolean;
|
||||
driveEffective(input: DriveEffectiveInput): number;
|
||||
emptyMass(input: ShipBlocksInput): number | null;
|
||||
weaponsBlockMass(input: WeaponsBlockInput): number | null;
|
||||
fullMass(input: FullMassInput): number;
|
||||
speed(input: SpeedInput): number;
|
||||
cargoCapacity(input: CargoCapacityInput): number;
|
||||
carryingMass(input: CarryingMassInput): number;
|
||||
}
|
||||
|
||||
interface BridgeRequestFields {
|
||||
@@ -175,6 +189,27 @@ export function adaptBridge(bridge: GalaxyCoreBridge): Core {
|
||||
): boolean {
|
||||
return bridge.verifyPayloadHash(payloadBytes, payloadHash);
|
||||
},
|
||||
driveEffective(input: DriveEffectiveInput): number {
|
||||
return bridge.driveEffective(input);
|
||||
},
|
||||
emptyMass(input: ShipBlocksInput): number | null {
|
||||
return bridge.emptyMass(input);
|
||||
},
|
||||
weaponsBlockMass(input: WeaponsBlockInput): number | null {
|
||||
return bridge.weaponsBlockMass(input);
|
||||
},
|
||||
fullMass(input: FullMassInput): number {
|
||||
return bridge.fullMass(input);
|
||||
},
|
||||
speed(input: SpeedInput): number {
|
||||
return bridge.speed(input);
|
||||
},
|
||||
cargoCapacity(input: CargoCapacityInput): number {
|
||||
return bridge.cargoCapacity(input);
|
||||
},
|
||||
carryingMass(input: CarryingMassInput): number {
|
||||
return bridge.carryingMass(input);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,10 @@ fresh.
|
||||
GALAXY_CLIENT_CONTEXT_KEY,
|
||||
GalaxyClientHolder,
|
||||
} from "$lib/galaxy-client-context.svelte";
|
||||
import {
|
||||
CORE_CONTEXT_KEY,
|
||||
CoreHolder,
|
||||
} from "$lib/core-context.svelte";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
import { loadStore } from "../../../platform/store/index";
|
||||
import { loadCore } from "../../../platform/core/index";
|
||||
@@ -105,6 +109,8 @@ fresh.
|
||||
setContext(RENDERED_REPORT_CONTEXT_KEY, renderedReport);
|
||||
const galaxyClient = new GalaxyClientHolder();
|
||||
setContext(GALAXY_CLIENT_CONTEXT_KEY, galaxyClient);
|
||||
const coreHolder = new CoreHolder();
|
||||
setContext(CORE_CONTEXT_KEY, coreHolder);
|
||||
// `MapPickService` lives at the layout so both the active map
|
||||
// view (which binds the renderer-side resolver) and the
|
||||
// inspector subsections (which call `pick(...)`) see the same
|
||||
@@ -172,6 +178,7 @@ fresh.
|
||||
const deviceSessionId = session.deviceSessionId;
|
||||
try {
|
||||
const [{ cache }, core] = await Promise.all([loadStore(), loadCore()]);
|
||||
coreHolder.set(core);
|
||||
const client = new GalaxyClient({
|
||||
core,
|
||||
edge: createEdgeGatewayClient(GATEWAY_BASE_URL),
|
||||
|
||||
Binary file not shown.
@@ -25,6 +25,12 @@ import {
|
||||
OrderDraftStore,
|
||||
} from "../src/sync/order-draft.svelte";
|
||||
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte";
|
||||
import {
|
||||
CORE_CONTEXT_KEY,
|
||||
type CoreHandle,
|
||||
} from "../src/lib/core-context.svelte";
|
||||
import { loadWasmCoreForTest } from "./setup-wasm";
|
||||
import type { Core } from "../src/platform/core/index";
|
||||
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";
|
||||
@@ -100,21 +106,27 @@ function makeReport(localShipClass: ShipClassSummary[] = []): GameReport {
|
||||
localShipClass,
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function mountDesigner(opts: {
|
||||
classId?: string;
|
||||
report?: GameReport | null;
|
||||
core?: Core | null;
|
||||
}) {
|
||||
const report = opts.report ?? makeReport();
|
||||
pageMock.params = opts.classId
|
||||
? { id: "g1", classId: opts.classId }
|
||||
: { id: "g1" };
|
||||
const renderedReport = { get report() { return report; } };
|
||||
const coreHandle: CoreHandle = { core: opts.core ?? null };
|
||||
const context = new Map<unknown, unknown>([
|
||||
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
||||
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
|
||||
[CORE_CONTEXT_KEY, coreHandle],
|
||||
]);
|
||||
return render(DesignerShipClass, { context });
|
||||
}
|
||||
@@ -260,3 +272,136 @@ describe("ship-class designer (view mode)", () => {
|
||||
).toHaveTextContent("Ghost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ship-class designer preview pane (Phase 18)", () => {
|
||||
test("hides preview while validation fails", () => {
|
||||
const ui = mountDesigner({});
|
||||
expect(
|
||||
ui.queryByTestId("designer-ship-class-preview"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides preview when no Core is provided", async () => {
|
||||
const ui = mountDesigner({});
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-name"), {
|
||||
target: { value: "Drone" },
|
||||
});
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-drive"), {
|
||||
target: { value: "1" },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(ui.getByTestId("designer-ship-class-save")).not.toBeDisabled(),
|
||||
);
|
||||
expect(
|
||||
ui.queryByTestId("designer-ship-class-preview"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders five rows once form is valid and Core is ready", async () => {
|
||||
const core = await loadWasmCoreForTest();
|
||||
const report: GameReport = {
|
||||
turn: 1,
|
||||
mapWidth: 1000,
|
||||
mapHeight: 1000,
|
||||
planetCount: 0,
|
||||
planets: [],
|
||||
race: "",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 1.5,
|
||||
localPlayerWeapons: 1,
|
||||
localPlayerShields: 1,
|
||||
localPlayerCargo: 1.2,
|
||||
};
|
||||
const ui = mountDesigner({ report, core });
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-name"), {
|
||||
target: { value: "Cruiser" },
|
||||
});
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-drive"), {
|
||||
target: { value: "8" },
|
||||
});
|
||||
await fireEvent.input(
|
||||
ui.getByTestId("designer-ship-class-input-armament"),
|
||||
{ target: { value: "2" } },
|
||||
);
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-weapons"), {
|
||||
target: { value: "5" },
|
||||
});
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-shields"), {
|
||||
target: { value: "3" },
|
||||
});
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-cargo"), {
|
||||
target: { value: "4" },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
ui.getByTestId("designer-ship-class-preview"),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
// Empty mass = drive + shields + cargo + (armament+1)*(weapons/2)
|
||||
// = 8 + 3 + 4 + 3 * 2.5 = 22.5
|
||||
expect(
|
||||
ui.getByTestId("designer-ship-class-preview-mass"),
|
||||
).toHaveTextContent("22.5");
|
||||
// CargoCapacity = cargoTech * (cargo + cargo²/20)
|
||||
// = 1.2 * (4 + 16/20) = 1.2 * 4.8 = 5.76
|
||||
expect(
|
||||
ui.getByTestId("designer-ship-class-preview-cargo-capacity"),
|
||||
).toHaveTextContent("5.76");
|
||||
// CarryingMass at full = capacity / cargoTech = 5.76 / 1.2 = 4.8
|
||||
// FullLoadMass = 22.5 + 4.8 = 27.3
|
||||
expect(
|
||||
ui.getByTestId("designer-ship-class-preview-full-load-mass"),
|
||||
).toHaveTextContent("27.3");
|
||||
// DriveEffective = 8 * 1.5 = 12
|
||||
// MaxSpeed = 12 * 20 / 22.5 = 10.666… → "10.67"
|
||||
expect(
|
||||
ui.getByTestId("designer-ship-class-preview-max-speed"),
|
||||
).toHaveTextContent("10.67");
|
||||
// RangeAtFull = 12 * 20 / 27.3 = 8.791… → "8.79"
|
||||
expect(
|
||||
ui.getByTestId("designer-ship-class-preview-range"),
|
||||
).toHaveTextContent("8.79");
|
||||
});
|
||||
|
||||
test("preview reacts to subsequent edits", async () => {
|
||||
const core = await loadWasmCoreForTest();
|
||||
const report: GameReport = {
|
||||
turn: 1,
|
||||
mapWidth: 1000,
|
||||
mapHeight: 1000,
|
||||
planetCount: 0,
|
||||
planets: [],
|
||||
race: "",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 1,
|
||||
localPlayerWeapons: 1,
|
||||
localPlayerShields: 1,
|
||||
localPlayerCargo: 1,
|
||||
};
|
||||
const ui = mountDesigner({ report, core });
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-name"), {
|
||||
target: { value: "Hauler" },
|
||||
});
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-drive"), {
|
||||
target: { value: "1" },
|
||||
});
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-cargo"), {
|
||||
target: { value: "5" },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
ui.getByTestId("designer-ship-class-preview-cargo-capacity"),
|
||||
).toHaveTextContent("6.25"),
|
||||
);
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-cargo"), {
|
||||
target: { value: "10" },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
ui.getByTestId("designer-ship-class-preview-cargo-capacity"),
|
||||
).toHaveTextContent("15"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -208,5 +208,14 @@ function mockCore(opts: MockCoreOptions): Core & {
|
||||
verifyResponse: vi.fn(opts.verifyResponseImpl),
|
||||
verifyEvent: vi.fn(() => true),
|
||||
verifyPayloadHash: vi.fn(opts.verifyPayloadHashImpl),
|
||||
// `GalaxyClient` does not exercise the Phase 18 calc bridge,
|
||||
// so these stubs only need to satisfy the `Core` interface.
|
||||
driveEffective: () => 0,
|
||||
emptyMass: () => 0,
|
||||
weaponsBlockMass: () => 0,
|
||||
fullMass: () => 0,
|
||||
speed: () => 0,
|
||||
cargoCapacity: () => 0,
|
||||
carryingMass: () => 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ function withGameState(opts: {
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
};
|
||||
store.status = "ready";
|
||||
}
|
||||
|
||||
@@ -76,6 +76,9 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,9 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@ function makeReport(
|
||||
localShipClass: [],
|
||||
routes: [{ sourcePlanetNumber: source, entries }],
|
||||
localPlayerDrive: 1,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -199,6 +202,9 @@ describe("buildCargoRouteLines", () => {
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 1,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
};
|
||||
expect(buildCargoRouteLines(report)).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -50,6 +50,9 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ function makeReport(overrides: Partial<GameReport> = {}): GameReport {
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,6 +98,9 @@ function makeReport(localShipClass: ShipClassSummary[]): GameReport {
|
||||
localShipClass,
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user