feat(ui): F8-10 — tables planets / ship-groups / fleets, ship-classes delete guard (#53) #68

Merged
developer merged 2 commits from feature/issue-53-table-planets-groups-fleets into development 2026-05-27 19:28:09 +00:00
21 changed files with 2731 additions and 30 deletions
+80 -8
View File
@@ -63,8 +63,10 @@ preference the store already manages.
} from "$lib/game-state.svelte";
import {
SELECTION_CONTEXT_KEY,
type Selected,
type SelectionStore,
} from "$lib/selection.svelte";
import { computeInSpacePosition } from "../../map/ship-groups";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
@@ -465,15 +467,23 @@ preference the store already manages.
if (canvasEl === null || containerEl === null) return;
// Capture camera state before disposing so a remount inside
// the same game (e.g. cargo-route overlay change) keeps the
// user's pan/zoom. A new game / first mount has no prior
// camera, so `previousCamera` stays null and the default
// centring path runs.
// user's pan / zoom. On a cold mount with no live `handle` we
// fall back to the per-game `store.lastCamera` snapshot, so
// leaving the map for a table / report and coming back also
// restores the prior view. A new game / first mount has no
// prior camera in either source, so `previousCamera` stays
// null and the default centring path runs.
const previousGameId = mountedGameId;
const targetGameId = store?.gameId ?? "";
const previousCamera =
handle !== null && previousGameId === targetGameId
? handle.getCamera()
: null;
let previousCamera: ReturnType<RendererHandle["getCamera"]> | null = null;
if (handle !== null && previousGameId === targetGameId) {
previousCamera = handle.getCamera();
} else if (handle === null && store?.lastCamera) {
previousCamera = store.lastCamera;
}
if (handle !== null && store !== undefined) {
store.lastCamera = handle.getCamera();
}
if (detachClick !== null) {
detachClick();
detachClick = null;
@@ -515,7 +525,27 @@ preference the store already manages.
},
world,
);
if (previousCamera !== null) {
// Consume an F8-10 table click that asked the next map mount
// to centre on a particular target. The store self-clears on
// read, so any later remount inside the same session sees
// null and falls through to the default centring path. The
// coord-only `pendingCenter` is the fleet-row fallback: a
// fleet has no `Selected` variant, but its xy still feeds
// the camera. `pendingFocus` wins when both are queued.
const focusTarget = selection?.consumePendingFocus() ?? null;
const focusPoint =
resolveFocusPoint(focusTarget, report, world.width, world.height)
?? selection?.consumePendingCenter()
?? null;
if (focusPoint !== null) {
handle.viewport.moveCenter(focusPoint.x, focusPoint.y);
handle.viewport.setZoom(
previousCamera === null
? minScale * 1.05
: Math.max(previousCamera.scale, minScale),
true,
);
} else if (previousCamera !== null) {
// Same-game remount — preserve pan/zoom. Clamp zoom
// to `minScale` so a remount that re-derives the
// minimum (e.g. a viewport resize between renderers)
@@ -642,6 +672,45 @@ preference the store already manages.
}
}
// resolveFocusPoint maps an F8-10 table click target to world (x, y)
// for camera centring. Planets resolve via the report; in-space
// ship groups via the shared interpolation helper; on-planet ship
// groups fall back to the destination planet's xy (so a click on a
// group stationed at #5 centres on #5). Returns null when the
// target cannot be resolved — a stale ref after a fresh report,
// or a planet that is no longer in the visible set; the caller
// then falls through to the default centring path.
function resolveFocusPoint(
target: Selected | null,
report: NonNullable<GameStateStore["report"]>,
worldWidth: number,
worldHeight: number,
): { x: number; y: number } | null {
if (target === null) return null;
if (target.kind === "planet") {
const planet = report.planets.find((p) => p.number === target.id);
return planet === undefined ? null : { x: planet.x, y: planet.y };
}
const ref = target.ref;
const group =
ref.variant === "local"
? report.localShipGroups.find((g) => g.id === ref.id)
: ref.variant === "other"
? report.otherShipGroups[ref.index]
: undefined;
if (group === undefined) return null;
const planetIndex = new Map(report.planets.map((p) => [p.number, p]));
const inSpace = computeInSpacePosition(
group,
planetIndex,
worldWidth,
worldHeight,
);
if (inSpace !== null) return inSpace;
const dest = planetIndex.get(group.destination);
return dest === undefined ? null : { x: dest.x, y: dest.y };
}
// handleMapClick translates a renderer click into a selection
// update. A click that misses every primitive (empty space) is a
// deliberate no-op: the selection rule from Phase 13 is that only
@@ -748,6 +817,9 @@ preference the store already manages.
detachDebugSurface = null;
}
if (handle !== null) {
// Persist the camera snapshot to the per-game store so the
// next mount (active-view switch back to map) restores it.
if (store !== undefined) store.lastCamera = handle.getCamera();
handle.dispose();
handle = null;
}
@@ -0,0 +1,24 @@
// F8-10 fleets table — module-level filter / sort rune.
// Mirrors `table-planets-state.svelte.ts`. See that file for the
// rationale.
export type FleetsSortColumn = "name" | "groupCount" | "state" | "location" | "speed";
export type FleetsSortDirection = "asc" | "desc";
export interface FleetsTableState {
sortColumn: FleetsSortColumn;
sortDirection: FleetsSortDirection;
planetFilter: string;
}
const DEFAULT_STATE: FleetsTableState = {
sortColumn: "name",
sortDirection: "asc",
planetFilter: "",
};
export const fleetsTableState: FleetsTableState = $state({ ...DEFAULT_STATE });
export function resetFleetsTableState(): void {
Object.assign(fleetsTableState, DEFAULT_STATE);
}
@@ -0,0 +1,354 @@
<!--
F8-10 fleets table. The report only carries the player's own fleets
(`localFleets`) — there is no foreign analogue — so the table is a
single block without owner toggles. Filters are reduced to the
shared planet dropdown (matching destination OR origin).
Click semantics:
- on-planet fleet (origin === null && range === null) focuses the
destination planet via `SelectionStore.focus`, identical to the
ship-groups behaviour;
- in-space fleet centres the camera on the interpolated fleet
position via `SelectionStore.focusPoint`, leaving the selection
unchanged (the `Selected` union has no "fleet" variant and
extending it for one table would be needless surface area).
-->
<script lang="ts">
import { getContext } from "svelte";
import type { ReportLocalFleet, ReportPlanet } from "../../api/game-state";
import { activeView } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { planetLabel } from "./report/format";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
SELECTION_CONTEXT_KEY,
type SelectionStore,
} from "$lib/selection.svelte";
import ViewState from "$lib/ui/view-state.svelte";
import { formatFloat, formatInt } from "$lib/util/number-format";
import {
fleetsTableState as persistent,
type FleetsSortColumn as SortColumn,
} from "./table-fleets-state.svelte";
const COLUMN_LABELS: Record<SortColumn, TranslationKey> = {
name: "game.table.fleets.column.name",
groupCount: "game.table.fleets.column.groups",
state: "game.table.fleets.column.state",
location: "game.table.fleets.column.location",
speed: "game.table.fleets.column.speed",
};
const COLUMNS: readonly SortColumn[] = [
"name",
"groupCount",
"state",
"location",
"speed",
];
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
// `persistent` (module-level rune above) drives the dropdown
// selection so the user's planet filter survives navigation.
const reportLoaded = $derived(
rendered?.report !== null && rendered?.report !== undefined,
);
const fleets = $derived<ReportLocalFleet[]>(
rendered?.report?.localFleets ?? [],
);
const allPlanets = $derived<ReportPlanet[]>(rendered?.report?.planets ?? []);
const planetIndex = $derived(
new Map(allPlanets.map((p) => [p.number, p])),
);
const planets = $derived.by(() => {
const set = new Set<number>();
for (const f of fleets) {
set.add(f.destination);
if (f.origin !== null) set.add(f.origin);
}
return Array.from(set).sort((a, b) => a - b);
});
const filtered = $derived.by(() => {
const planet =
persistent.planetFilter === "" ? null : Number(persistent.planetFilter);
return fleets.filter((f) => {
if (planet === null) return true;
return f.destination === planet || f.origin === planet;
});
});
const sorted = $derived.by(() => {
const list = [...filtered];
const dir = persistent.sortDirection === "asc" ? 1 : -1;
list.sort((a, b) => compare(a, b, persistent.sortColumn) * dir);
return list;
});
function compare(
a: ReportLocalFleet,
b: ReportLocalFleet,
column: SortColumn,
): number {
switch (column) {
case "name":
return a.name.localeCompare(b.name);
case "groupCount":
return a.groupCount - b.groupCount;
case "state":
return a.state.localeCompare(b.state);
case "location": {
if (a.destination !== b.destination) return a.destination - b.destination;
const ao = a.origin ?? Number.MAX_SAFE_INTEGER;
const bo = b.origin ?? Number.MAX_SAFE_INTEGER;
return ao - bo;
}
case "speed":
return a.speed - b.speed;
}
}
function toggleSort(column: SortColumn): void {
if (persistent.sortColumn === column) {
persistent.sortDirection = persistent.sortDirection === "asc" ? "desc" : "asc";
return;
}
persistent.sortColumn = column;
persistent.sortDirection = "asc";
}
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
if (persistent.sortColumn !== column) return "none";
return persistent.sortDirection === "asc" ? "ascending" : "descending";
}
function isInSpace(f: ReportLocalFleet): boolean {
return f.origin !== null && f.range !== null;
}
function locationLabel(f: ReportLocalFleet): string {
if (!isInSpace(f)) {
return `@ ${planetLabel(f.destination, allPlanets)}`;
}
const from = planetLabel(f.origin!, allPlanets);
const to = planetLabel(f.destination, allPlanets);
return `${from}${to} (${formatFloat(f.range!)})`;
}
function openOnMap(f: ReportLocalFleet): void {
if (selection === undefined) {
activeView.select("map");
return;
}
if (!isInSpace(f)) {
selection.focus({ kind: "planet", id: f.destination });
activeView.select("map");
return;
}
const dest = planetIndex.get(f.destination);
const origin = planetIndex.get(f.origin!);
if (dest !== undefined && origin !== undefined) {
const dx = dest.x - origin.x;
const dy = dest.y - origin.y;
const total = Math.hypot(dx, dy);
if (total === 0 || f.range! <= 0) {
selection.focusPoint(dest.x, dest.y);
} else {
const t = Math.min(1, f.range! / total);
selection.focusPoint(dest.x + t * (origin.x - dest.x), dest.y + t * (origin.y - dest.y));
}
}
activeView.select("map");
}
</script>
<section
class="active-view"
data-testid="active-view-table"
data-entity="fleets"
>
<header>
<h2>{i18n.t("game.table.fleets.title")}</h2>
<div class="controls">
{#if planets.length > 0}
<label class="dropdown">
<span>{i18n.t("game.table.fleets.filter.planet")}</span>
<select
data-testid="fleets-filter-planet"
bind:value={persistent.planetFilter}
>
<option value=""
>{i18n.t("game.table.fleets.filter.planet.all")}</option
>
{#each planets as n (n)}
<option value={String(n)}
>{planetLabel(n, allPlanets)}</option
>
{/each}
</select>
</label>
{/if}
</div>
</header>
{#if !reportLoaded}
<ViewState
kind="loading"
testid="fleets-loading"
message={i18n.t("game.table.fleets.loading")}
/>
{:else if fleets.length === 0}
<ViewState
kind="empty"
testid="fleets-empty"
message={i18n.t("game.table.fleets.empty")}
/>
{:else}
<table class="grid" data-testid="fleets-table">
<thead>
<tr>
{#each COLUMNS as column (column)}
<th
aria-sort={ariaSort(column)}
class:numeric={column === "groupCount" || column === "speed"}
>
<button
type="button"
class="sort"
data-testid="fleets-column-{column}"
onclick={() => toggleSort(column)}
>
{i18n.t(COLUMN_LABELS[column])}
{#if persistent.sortColumn === column}
<span class="sort-indicator" aria-hidden="true">
{persistent.sortDirection === "asc" ? "▲" : "▼"}
</span>
{/if}
</button>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sorted as f (f.name)}
<tr
data-testid="fleets-row"
data-name={f.name}
data-in-space={isInSpace(f)}
onclick={() => openOnMap(f)}
>
<td data-testid="fleets-cell-name">{f.name || "—"}</td>
<td class="numeric" data-testid="fleets-cell-groups">
{formatInt(f.groupCount)}
</td>
<td data-testid="fleets-cell-state">{f.state || "—"}</td>
<td data-testid="fleets-cell-location">{locationLabel(f)}</td>
<td class="numeric" data-testid="fleets-cell-speed">
{formatFloat(f.speed)}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.active-view {
padding: 1.5rem;
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
gap: 1rem;
}
header {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
header h2 {
margin: 0;
font-size: 1.1rem;
}
.controls {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.dropdown {
display: inline-flex;
gap: 0.4rem;
align-items: center;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.dropdown select {
font: inherit;
font-size: 0.85rem;
padding: 0.15rem 0.3rem;
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid var(--color-border-subtle);
font-size: 0.85rem;
}
.grid th {
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid th.numeric .sort {
justify-content: flex-end;
}
.grid tbody tr {
cursor: pointer;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
.sort {
font: inherit;
font-size: inherit;
text-transform: inherit;
letter-spacing: inherit;
color: inherit;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
display: inline-flex;
gap: 0.3rem;
align-items: baseline;
}
.sort-indicator {
font-size: 0.7em;
}
</style>
@@ -0,0 +1,48 @@
// F8-10 planets table — module-level filter / sort rune.
//
// Held outside the component so the user's filter selections survive
// the component being unmounted (e.g. row click → map → back to the
// table). Held in memory only; an F5 reloads the report and the
// defaults take over.
export type PlanetSortColumn =
| "number"
| "name"
| "kind"
| "owner"
| "size"
| "resources";
export type PlanetSortDirection = "asc" | "desc";
export interface PlanetsTableState {
sortColumn: PlanetSortColumn;
sortDirection: PlanetSortDirection;
showLocal: boolean;
showOther: boolean;
showUninhabited: boolean;
showUnknown: boolean;
ownerFilter: string;
}
const DEFAULT_STATE: PlanetsTableState = {
sortColumn: "number",
sortDirection: "asc",
showLocal: true,
showOther: true,
showUninhabited: true,
showUnknown: true,
ownerFilter: "",
};
export const planetsTableState: PlanetsTableState = $state({ ...DEFAULT_STATE });
/**
* resetPlanetsTableState restores every field to its default value.
* Production code never calls it; the Vitest harness uses it from
* `beforeEach` to keep cases independent (the rune is a module-level
* singleton that otherwise carries state across test boundaries).
*/
export function resetPlanetsTableState(): void {
Object.assign(planetsTableState, DEFAULT_STATE);
}
@@ -0,0 +1,393 @@
<!--
F8-10 planets table. Lists every planet from the rendered report with
4 kind checkboxes (own / foreign / uninhabited / unknown) and a race
dropdown that filters foreign planets by owner. A row click focuses
the planet on the map (selection + camera centre) via the transient
`SelectionStore.focus` channel — `map.svelte` consumes it on mount.
Lives inside the active-view slot; reads the report and the selection
store from context, matching the surrounding tables. No data
fetching here.
-->
<script lang="ts">
import { getContext } from "svelte";
import type { ReportPlanet } from "../../api/game-state";
import { activeView } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
SELECTION_CONTEXT_KEY,
type SelectionStore,
} from "$lib/selection.svelte";
import ViewState from "$lib/ui/view-state.svelte";
import { formatFloat, formatInt } from "$lib/util/number-format";
import {
planetsTableState as persistent,
type PlanetSortColumn as SortColumn,
} from "./table-planets-state.svelte";
type PlanetKind = ReportPlanet["kind"];
const COLUMN_LABELS: Record<SortColumn, TranslationKey> = {
number: "game.table.planets.column.number",
name: "game.table.planets.column.name",
kind: "game.table.planets.column.kind",
owner: "game.table.planets.column.owner",
size: "game.table.planets.column.size",
resources: "game.table.planets.column.resources",
};
const COLUMNS: readonly SortColumn[] = [
"number",
"name",
"kind",
"owner",
"size",
"resources",
];
const KIND_LABELS: Record<PlanetKind, TranslationKey> = {
local: "game.table.planets.kind.own",
other: "game.table.planets.kind.foreign",
uninhabited: "game.table.planets.kind.uninhabited",
unidentified: "game.table.planets.kind.unknown",
};
const KIND_ORDER: Record<PlanetKind, number> = {
local: 0,
other: 1,
uninhabited: 2,
unidentified: 3,
};
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
// `persistent` (declared in the module script above) is the live
// source for every filter / sort selection so leaving the table
// and coming back restores the prior state. Kind toggles default
// to all-on; owner narrows the `other` slice only — "" means
// "all owners". The owner dropdown is conditional on
// `persistent.showOther`, so toggling foreign off hides the
// chooser; the stored owner value is preserved across that flip.
const planets = $derived<ReportPlanet[]>(rendered?.report?.planets ?? []);
const reportLoaded = $derived(
rendered?.report !== null && rendered?.report !== undefined,
);
const owners = $derived.by(() => {
const set = new Set<string>();
for (const p of planets) {
if (p.kind === "other" && p.owner !== null && p.owner !== "") {
set.add(p.owner);
}
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
});
const filtered = $derived.by(() => {
return planets.filter((p) => {
if (p.kind === "local" && !persistent.showLocal) return false;
if (p.kind === "uninhabited" && !persistent.showUninhabited) return false;
if (p.kind === "unidentified" && !persistent.showUnknown) return false;
if (p.kind === "other") {
if (!persistent.showOther) return false;
if (persistent.ownerFilter !== "" && p.owner !== persistent.ownerFilter) return false;
}
return true;
});
});
const sorted = $derived.by(() => {
const list = [...filtered];
const dir = persistent.sortDirection === "asc" ? 1 : -1;
list.sort((a, b) => compare(a, b, persistent.sortColumn) * dir);
return list;
});
function compare(a: ReportPlanet, b: ReportPlanet, column: SortColumn): number {
switch (column) {
case "number":
return a.number - b.number;
case "name":
return a.name.localeCompare(b.name);
case "kind":
return KIND_ORDER[a.kind] - KIND_ORDER[b.kind];
case "owner": {
const ao = a.owner ?? "";
const bo = b.owner ?? "";
return ao.localeCompare(bo);
}
case "size":
return (a.size ?? -1) - (b.size ?? -1);
case "resources":
return (a.resources ?? -1) - (b.resources ?? -1);
}
}
function toggleSort(column: SortColumn): void {
if (persistent.sortColumn === column) {
persistent.sortDirection = persistent.sortDirection === "asc" ? "desc" : "asc";
return;
}
persistent.sortColumn = column;
persistent.sortDirection = "asc";
}
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
if (persistent.sortColumn !== column) return "none";
return persistent.sortDirection === "asc" ? "ascending" : "descending";
}
function openOnMap(planet: ReportPlanet): void {
selection?.focus({ kind: "planet", id: planet.number });
activeView.select("map");
}
function ownerDisplay(p: ReportPlanet): string {
if (p.kind === "other") return p.owner ?? "";
return "—";
}
</script>
<section
class="active-view"
data-testid="active-view-table"
data-entity="planets"
>
<header>
<h2>{i18n.t("game.table.planets.title")}</h2>
<div class="controls">
<label class="check">
<input
type="checkbox"
data-testid="planets-filter-own"
bind:checked={persistent.showLocal}
/>
<span>{i18n.t("game.table.planets.kind.own")}</span>
</label>
<label class="check">
<input
type="checkbox"
data-testid="planets-filter-uninhabited"
bind:checked={persistent.showUninhabited}
/>
<span>{i18n.t("game.table.planets.kind.uninhabited")}</span>
</label>
<label class="check">
<input
type="checkbox"
data-testid="planets-filter-unknown"
bind:checked={persistent.showUnknown}
/>
<span>{i18n.t("game.table.planets.kind.unknown")}</span>
</label>
<label class="check">
<input
type="checkbox"
data-testid="planets-filter-foreign"
bind:checked={persistent.showOther}
/>
<span>{i18n.t("game.table.planets.kind.foreign")}</span>
</label>
{#if persistent.showOther && owners.length > 0}
<label class="owner">
<span>{i18n.t("game.table.planets.filter.owner")}</span>
<select
data-testid="planets-filter-owner"
bind:value={persistent.ownerFilter}
>
<option value=""
>{i18n.t("game.table.planets.filter.owner.all")}</option
>
{#each owners as owner (owner)}
<option value={owner}>{owner}</option>
{/each}
</select>
</label>
{/if}
</div>
</header>
{#if !reportLoaded}
<ViewState
kind="loading"
testid="planets-loading"
message={i18n.t("game.table.planets.loading")}
/>
{:else if planets.length === 0}
<ViewState
kind="empty"
testid="planets-empty"
message={i18n.t("game.table.planets.empty")}
/>
{:else}
<table class="grid" data-testid="planets-table">
<thead>
<tr>
{#each COLUMNS as column (column)}
<th
aria-sort={ariaSort(column)}
class:numeric={column === "number" ||
column === "size" ||
column === "resources"}
>
<button
type="button"
class="sort"
data-testid="planets-column-{column}"
onclick={() => toggleSort(column)}
>
{i18n.t(COLUMN_LABELS[column])}
{#if persistent.sortColumn === column}
<span class="sort-indicator" aria-hidden="true">
{persistent.sortDirection === "asc" ? "▲" : "▼"}
</span>
{/if}
</button>
</th>
{/each}
<th class="numeric">
{i18n.t("game.table.planets.column.coordinates")}
</th>
</tr>
</thead>
<tbody>
{#each sorted as p (p.number)}
<tr
data-testid="planets-row"
data-number={p.number}
data-kind={p.kind}
onclick={() => openOnMap(p)}
>
<td class="numeric" data-testid="planets-cell-number">
{p.number}
</td>
<td data-testid="planets-cell-name">{p.name || "—"}</td>
<td data-testid="planets-cell-kind">
{i18n.t(KIND_LABELS[p.kind])}
</td>
<td data-testid="planets-cell-owner">{ownerDisplay(p)}</td>
<td class="numeric" data-testid="planets-cell-size">
{p.size === null ? "—" : formatFloat(p.size)}
</td>
<td class="numeric" data-testid="planets-cell-resources">
{p.resources === null ? "—" : formatFloat(p.resources)}
</td>
<td class="numeric" data-testid="planets-cell-coordinates">
{formatInt(p.x)},{formatInt(p.y)}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.active-view {
padding: 1.5rem;
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
gap: 1rem;
}
header {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
header h2 {
margin: 0;
font-size: 1.1rem;
}
.controls {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.check {
display: inline-flex;
gap: 0.3rem;
align-items: center;
font-size: 0.85rem;
color: var(--color-text-muted);
cursor: pointer;
}
.check input {
margin: 0;
}
.owner {
display: inline-flex;
gap: 0.4rem;
align-items: center;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.owner select {
font: inherit;
font-size: 0.85rem;
padding: 0.15rem 0.3rem;
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid var(--color-border-subtle);
font-size: 0.85rem;
}
.grid th {
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid th.numeric .sort {
justify-content: flex-end;
}
.grid tbody tr {
cursor: pointer;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
.sort {
font: inherit;
font-size: inherit;
text-transform: inherit;
letter-spacing: inherit;
color: inherit;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
display: inline-flex;
gap: 0.3rem;
align-items: baseline;
}
.sort-indicator {
font-size: 0.7em;
}
</style>
@@ -73,6 +73,21 @@ data fetching is performed here — the layout is responsible.
);
const reportLoaded = $derived(rendered?.report !== null && rendered?.report !== undefined);
// inUseCounts is a derived index from class name → number of player
// ship groups currently referencing it. The engine refuses
// `removeShipClass` while any such group exists, so the table
// pre-emptively disables the per-row Delete affordance instead of
// surfacing a server-side rejection. The map is rebuilt whenever
// the rendered report changes; an empty report yields an empty map
// and every Delete stays enabled.
const inUseCounts = $derived.by(() => {
const map = new Map<string, number>();
for (const group of rendered?.report?.localShipGroups ?? []) {
map.set(group.class, (map.get(group.class) ?? 0) + 1);
}
return map;
});
const filtered = $derived.by(() => {
const needle = filter.trim().toLowerCase();
if (needle === "") return localShipClass;
@@ -123,6 +138,13 @@ data fetching is performed here — the layout is responsible.
name,
});
}
function deleteTooltip(count: number): string {
if (count === 0) return "";
return i18n.t("game.table.ship_classes.action.delete.in_use", {
count: String(count),
});
}
</script>
<section
@@ -189,6 +211,7 @@ data fetching is performed here — the layout is responsible.
</thead>
<tbody>
{#each sorted as cls (cls.name)}
{@const inUse = inUseCounts.get(cls.name) ?? 0}
<tr
data-testid="ship-classes-row"
data-name={cls.name}
@@ -215,6 +238,9 @@ data fetching is performed here — the layout is responsible.
type="button"
class="delete"
data-testid="ship-classes-delete"
data-in-use={inUse}
disabled={inUse > 0 || draft === undefined}
title={deleteTooltip(inUse)}
onclick={() => void deleteShipClass(cls.name)}
>
{i18n.t("game.table.ship_classes.action.delete")}
@@ -333,7 +359,12 @@ data fetching is performed here — the layout is responsible.
border-radius: 3px;
cursor: pointer;
}
.delete:hover {
.delete:not(:disabled):hover {
border-color: var(--color-danger);
}
.delete:disabled {
cursor: not-allowed;
color: var(--color-text-muted);
opacity: 0.55;
}
</style>
@@ -0,0 +1,40 @@
// F8-10 ship-groups table — module-level filter / sort rune.
// Mirrors `table-planets-state.svelte.ts`. See that file for the
// rationale.
export type ShipGroupsSortColumn =
| "owner"
| "class"
| "count"
| "race"
| "location"
| "mass"
| "speed";
export type ShipGroupsSortDirection = "asc" | "desc";
export interface ShipGroupsTableState {
sortColumn: ShipGroupsSortColumn;
sortDirection: ShipGroupsSortDirection;
showOwn: boolean;
showForeign: boolean;
planetFilter: string;
classFilter: string;
}
const DEFAULT_STATE: ShipGroupsTableState = {
sortColumn: "owner",
sortDirection: "asc",
showOwn: true,
showForeign: true,
planetFilter: "",
classFilter: "",
};
export const shipGroupsTableState: ShipGroupsTableState = $state({
...DEFAULT_STATE,
});
export function resetShipGroupsTableState(): void {
Object.assign(shipGroupsTableState, DEFAULT_STATE);
}
@@ -0,0 +1,490 @@
<!--
F8-10 ship-groups table. Lists own (`localShipGroups`) and foreign
(`otherShipGroups`) ship groups under a single grid, with checkboxes
for the two owner buckets, a planet dropdown that narrows to groups
touching the chosen planet (destination OR origin), and a ship-class
dropdown. A row click focuses the on-map target through the
transient `SelectionStore.focus` channel:
- on-planet groups (origin === null && range === null) focus the
destination planet so the map centres on it and the inspector
opens its planet card;
- in-space groups focus the group itself, so the map centres on
its interpolated position and the inspector shows the group.
`incomingShipGroups` and `unidentifiedShipGroups` are intentionally
out of scope here — the map and inspector already surface those
categories.
-->
<script lang="ts">
import { getContext } from "svelte";
import type {
ReportLocalShipGroup,
ReportOtherShipGroup,
} from "../../api/game-state";
import { activeView } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { planetLabel } from "./report/format";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
SELECTION_CONTEXT_KEY,
type Selected,
type SelectionStore,
type ShipGroupRef,
} from "$lib/selection.svelte";
import ViewState from "$lib/ui/view-state.svelte";
import { formatFloat, formatInt } from "$lib/util/number-format";
import {
shipGroupsTableState as persistent,
type ShipGroupsSortColumn as SortColumn,
} from "./table-ship-groups-state.svelte";
type OwnerKind = "own" | "foreign";
type Row = {
key: string;
owner: OwnerKind;
ref: ShipGroupRef;
class: string;
count: number;
race: string;
mass: number;
speed: number;
destination: number;
origin: number | null;
range: number | null;
state: string;
};
const COLUMN_LABELS: Record<SortColumn, TranslationKey> = {
owner: "game.table.ship_groups.column.owner",
class: "game.table.ship_groups.column.class",
count: "game.table.ship_groups.column.count",
race: "game.table.ship_groups.column.race",
location: "game.table.ship_groups.column.location",
mass: "game.table.ship_groups.column.mass",
speed: "game.table.ship_groups.column.speed",
};
const COLUMNS: readonly SortColumn[] = [
"owner",
"class",
"count",
"race",
"location",
"mass",
"speed",
];
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
// `persistent` (module-level rune above) drives every filter /
// sort selection so the user's choices survive table↔map round
// trips.
const reportLoaded = $derived(
rendered?.report !== null && rendered?.report !== undefined,
);
const local = $derived<ReportLocalShipGroup[]>(
rendered?.report?.localShipGroups ?? [],
);
const other = $derived<ReportOtherShipGroup[]>(
rendered?.report?.otherShipGroups ?? [],
);
const rows = $derived.by<Row[]>(() => {
const acc: Row[] = [];
for (const g of local) {
acc.push({
key: `local:${g.id}`,
owner: "own",
ref: { variant: "local", id: g.id },
class: g.class,
count: g.count,
race: g.race,
mass: g.mass,
speed: g.speed,
destination: g.destination,
origin: g.origin,
range: g.range,
state: g.state,
});
}
for (let i = 0; i < other.length; i++) {
const g = other[i]!;
acc.push({
key: `other:${i}`,
owner: "foreign",
ref: { variant: "other", index: i },
class: g.class,
count: g.count,
race: g.race,
mass: g.mass,
speed: g.speed,
destination: g.destination,
origin: g.origin,
range: g.range,
state: "",
});
}
return acc;
});
// rowsByOwner is the slice surviving just the owner checkboxes,
// reused for the planet / class dropdown options so toggling
// "foreign" off prunes the choices to only own-group planets and
// classes (and vice versa).
const rowsByOwner = $derived.by(() => {
return rows.filter((r) => {
if (r.owner === "own" && !persistent.showOwn) return false;
if (r.owner === "foreign" && !persistent.showForeign) return false;
return true;
});
});
const classes = $derived.by(() => {
const set = new Set<string>();
for (const r of rowsByOwner) if (r.class !== "") set.add(r.class);
return Array.from(set).sort((a, b) => a.localeCompare(b));
});
const planets = $derived.by(() => {
const set = new Set<number>();
for (const r of rowsByOwner) {
set.add(r.destination);
if (r.origin !== null) set.add(r.origin);
}
return Array.from(set).sort((a, b) => a - b);
});
const filtered = $derived.by(() => {
const planet =
persistent.planetFilter === "" ? null : Number(persistent.planetFilter);
return rowsByOwner.filter((r) => {
if (planet !== null && r.destination !== planet && r.origin !== planet) {
return false;
}
if (persistent.classFilter !== "" && r.class !== persistent.classFilter) {
return false;
}
return true;
});
});
const sorted = $derived.by(() => {
const list = [...filtered];
const dir = persistent.sortDirection === "asc" ? 1 : -1;
list.sort((a, b) => compare(a, b, persistent.sortColumn) * dir);
return list;
});
function compare(a: Row, b: Row, column: SortColumn): number {
switch (column) {
case "owner":
return a.owner.localeCompare(b.owner);
case "class":
return a.class.localeCompare(b.class);
case "count":
return a.count - b.count;
case "race":
return a.race.localeCompare(b.race);
case "location": {
if (a.destination !== b.destination) return a.destination - b.destination;
const ao = a.origin ?? Number.MAX_SAFE_INTEGER;
const bo = b.origin ?? Number.MAX_SAFE_INTEGER;
return ao - bo;
}
case "mass":
return a.mass - b.mass;
case "speed":
return a.speed - b.speed;
}
}
function toggleSort(column: SortColumn): void {
if (persistent.sortColumn === column) {
persistent.sortDirection = persistent.sortDirection === "asc" ? "desc" : "asc";
return;
}
persistent.sortColumn = column;
persistent.sortDirection = "asc";
}
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
if (persistent.sortColumn !== column) return "none";
return persistent.sortDirection === "asc" ? "ascending" : "descending";
}
function isInSpace(row: Row): boolean {
return row.origin !== null && row.range !== null;
}
function openOnMap(row: Row): void {
let target: Selected;
if (isInSpace(row)) {
target = { kind: "shipGroup", ref: row.ref };
} else {
target = { kind: "planet", id: row.destination };
}
selection?.focus(target);
activeView.select("map");
}
function locationLabel(row: Row): string {
const all = rendered?.report?.planets ?? [];
if (!isInSpace(row)) {
return `@ ${planetLabel(row.destination, all)}`;
}
const from = planetLabel(row.origin!, all);
const to = planetLabel(row.destination, all);
return `${from}${to} (${formatFloat(row.range!)})`;
}
function ownerLabel(row: Row): string {
return i18n.t(
row.owner === "own"
? "game.table.ship_groups.owner.own"
: "game.table.ship_groups.owner.foreign",
);
}
</script>
<section
class="active-view"
data-testid="active-view-table"
data-entity="ship-groups"
>
<header>
<h2>{i18n.t("game.table.ship_groups.title")}</h2>
<div class="controls">
<label class="check">
<input
type="checkbox"
data-testid="ship-groups-filter-own"
bind:checked={persistent.showOwn}
/>
<span>{i18n.t("game.table.ship_groups.owner.own")}</span>
</label>
<label class="check">
<input
type="checkbox"
data-testid="ship-groups-filter-foreign"
bind:checked={persistent.showForeign}
/>
<span>{i18n.t("game.table.ship_groups.owner.foreign")}</span>
</label>
{#if planets.length > 0}
<label class="dropdown">
<span>{i18n.t("game.table.ship_groups.filter.planet")}</span>
<select
data-testid="ship-groups-filter-planet"
bind:value={persistent.planetFilter}
>
<option value=""
>{i18n.t("game.table.ship_groups.filter.planet.all")}</option
>
{#each planets as n (n)}
<option value={String(n)}
>{planetLabel(n, rendered?.report?.planets ?? [])}</option
>
{/each}
</select>
</label>
{/if}
{#if classes.length > 0}
<label class="dropdown">
<span>{i18n.t("game.table.ship_groups.filter.class")}</span>
<select
data-testid="ship-groups-filter-class"
bind:value={persistent.classFilter}
>
<option value=""
>{i18n.t("game.table.ship_groups.filter.class.all")}</option
>
{#each classes as c (c)}
<option value={c}>{c}</option>
{/each}
</select>
</label>
{/if}
</div>
</header>
{#if !reportLoaded}
<ViewState
kind="loading"
testid="ship-groups-loading"
message={i18n.t("game.table.ship_groups.loading")}
/>
{:else if rows.length === 0}
<ViewState
kind="empty"
testid="ship-groups-empty"
message={i18n.t("game.table.ship_groups.empty")}
/>
{:else}
<table class="grid" data-testid="ship-groups-table">
<thead>
<tr>
{#each COLUMNS as column (column)}
<th
aria-sort={ariaSort(column)}
class:numeric={column === "count" ||
column === "mass" ||
column === "speed"}
>
<button
type="button"
class="sort"
data-testid="ship-groups-column-{column}"
onclick={() => toggleSort(column)}
>
{i18n.t(COLUMN_LABELS[column])}
{#if persistent.sortColumn === column}
<span class="sort-indicator" aria-hidden="true">
{persistent.sortDirection === "asc" ? "▲" : "▼"}
</span>
{/if}
</button>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sorted as row (row.key)}
<tr
data-testid="ship-groups-row"
data-key={row.key}
data-owner={row.owner}
data-in-space={isInSpace(row)}
onclick={() => openOnMap(row)}
>
<td data-testid="ship-groups-cell-owner">{ownerLabel(row)}</td>
<td data-testid="ship-groups-cell-class">{row.class || "—"}</td>
<td class="numeric" data-testid="ship-groups-cell-count">
{formatInt(row.count)}
</td>
<td data-testid="ship-groups-cell-race">{row.race || "—"}</td>
<td data-testid="ship-groups-cell-location">
{locationLabel(row)}
</td>
<td class="numeric" data-testid="ship-groups-cell-mass">
{formatFloat(row.mass)}
</td>
<td class="numeric" data-testid="ship-groups-cell-speed">
{formatFloat(row.speed)}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.active-view {
padding: 1.5rem;
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
gap: 1rem;
}
header {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
header h2 {
margin: 0;
font-size: 1.1rem;
}
.controls {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.check {
display: inline-flex;
gap: 0.3rem;
align-items: center;
font-size: 0.85rem;
color: var(--color-text-muted);
cursor: pointer;
}
.check input {
margin: 0;
}
.dropdown {
display: inline-flex;
gap: 0.4rem;
align-items: center;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.dropdown select {
font: inherit;
font-size: 0.85rem;
padding: 0.15rem 0.3rem;
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid var(--color-border-subtle);
font-size: 0.85rem;
}
.grid th {
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid th.numeric,
.grid td.numeric {
font-family: var(--font-mono);
text-align: right;
}
.grid th.numeric .sort {
justify-content: flex-end;
}
.grid tbody tr {
cursor: pointer;
}
.grid tbody tr:hover {
background: var(--color-surface);
}
.sort {
font: inherit;
font-size: inherit;
text-transform: inherit;
letter-spacing: inherit;
color: inherit;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
display: inline-flex;
gap: 0.3rem;
align-items: baseline;
}
.sort-indicator {
font-size: 0.7em;
}
</style>
+13 -6
View File
@@ -1,16 +1,17 @@
<!--
Active-view router for the per-entity tables. Phase 17 lights up
the ship-classes table; Phase 21 lights up the sciences table;
Phase 22 lights up the races table; the remaining slugs (planets,
ship-groups, fleets) keep the Phase 10 stub copy until their
respective phases land. The wrapper preserves
Active-view router for the per-entity tables. Phase 17 lit up
ship-classes; Phase 21 sciences; Phase 22 races; F8-10 lights up
planets, ship-groups, and fleets. The wrapper preserves
`data-testid="active-view-table"` and `data-entity={entity}` for
every branch (each leaf component mirrors them) so the navigation
e2e specs (`game-shell.spec.ts`, `view-menu`) keep matching.
-->
<script lang="ts">
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import TablePlanets from "./table-planets.svelte";
import TableShipClasses from "./table-ship-classes.svelte";
import TableShipGroups from "./table-ship-groups.svelte";
import TableFleets from "./table-fleets.svelte";
import TableSciences from "./table-sciences.svelte";
import TableRaces from "./table-races.svelte";
@@ -23,8 +24,14 @@ e2e specs (`game-shell.spec.ts`, `view-menu`) keep matching.
}
</script>
{#if entity === "ship-classes"}
{#if entity === "planets"}
<TablePlanets />
{:else if entity === "ship-classes"}
<TableShipClasses />
{:else if entity === "ship-groups"}
<TableShipGroups />
{:else if entity === "fleets"}
<TableFleets />
{:else if entity === "sciences"}
<TableSciences />
{:else if entity === "races"}
+11
View File
@@ -117,6 +117,17 @@ export class GameStateStore {
* `RendererHandle.setHiddenPrimitiveIds`.
*/
mapToggles: MapToggles = $state({ ...DEFAULT_MAP_TOGGLES });
/**
* lastCamera is the most recent map camera snapshot (centre + zoom)
* captured before the renderer was disposed — either because the
* map view unmounted (active-view switch) or because it remounted
* inside the same session. `map.svelte` reads it on cold mount so
* leaving the map for a table / report and coming back keeps the
* user's prior pan / zoom. Resetting to null falls back to the
* default world-centre + minScale fit. Held in memory only; an F5
* re-loads the report and the default fit takes over.
*/
lastCamera: { centerX: number; centerY: number; scale: number } | null = $state(null);
error: string | null = $state(null);
/**
* notFound is the distinct "this game is not in the player's list"
+43
View File
@@ -335,6 +335,48 @@ const en = {
"game.sidebar.order.label.ship_group_join_fleet": "assign group {group} → fleet {fleet}",
"game.sidebar.order.label.race_relation": "declare {relation} on {acceptor}",
"game.sidebar.order.label.race_vote": "give my votes to {acceptor}",
"game.table.planets.title": "planets",
"game.table.planets.loading": "loading planets…",
"game.table.planets.empty": "no planets in the report",
"game.table.planets.column.number": "#",
"game.table.planets.column.name": "name",
"game.table.planets.column.kind": "kind",
"game.table.planets.column.owner": "owner",
"game.table.planets.column.size": "size",
"game.table.planets.column.resources": "resources",
"game.table.planets.column.coordinates": "x,y",
"game.table.planets.kind.own": "own",
"game.table.planets.kind.foreign": "foreign",
"game.table.planets.kind.uninhabited": "uninhabited",
"game.table.planets.kind.unknown": "unknown",
"game.table.planets.filter.owner": "owner:",
"game.table.planets.filter.owner.all": "all races",
"game.table.ship_groups.title": "ship groups",
"game.table.ship_groups.loading": "loading ship groups…",
"game.table.ship_groups.empty": "no ship groups in the report",
"game.table.ship_groups.column.owner": "owner",
"game.table.ship_groups.column.class": "class",
"game.table.ship_groups.column.count": "count",
"game.table.ship_groups.column.race": "race",
"game.table.ship_groups.column.location": "location",
"game.table.ship_groups.column.mass": "mass",
"game.table.ship_groups.column.speed": "speed",
"game.table.ship_groups.owner.own": "own",
"game.table.ship_groups.owner.foreign": "foreign",
"game.table.ship_groups.filter.planet": "planet:",
"game.table.ship_groups.filter.planet.all": "all planets",
"game.table.ship_groups.filter.class": "class:",
"game.table.ship_groups.filter.class.all": "all classes",
"game.table.fleets.title": "fleets",
"game.table.fleets.loading": "loading fleets…",
"game.table.fleets.empty": "no fleets in the report",
"game.table.fleets.column.name": "name",
"game.table.fleets.column.groups": "groups",
"game.table.fleets.column.state": "state",
"game.table.fleets.column.location": "location",
"game.table.fleets.column.speed": "speed",
"game.table.fleets.filter.planet": "planet:",
"game.table.fleets.filter.planet.all": "all planets",
"game.table.ship_classes.title": "ship classes",
"game.table.ship_classes.column.name": "name",
"game.table.ship_classes.column.drive": "drive",
@@ -347,6 +389,7 @@ const en = {
"game.table.ship_classes.filter.placeholder": "filter by name",
"game.table.ship_classes.action.new": "+ new ship class",
"game.table.ship_classes.action.delete": "delete",
"game.table.ship_classes.action.delete.in_use": "in use by {count} ship group(s)",
"game.table.ship_classes.loading": "loading ship classes…",
"game.designer.ship_class.title.new": "design new ship class",
"game.designer.ship_class.title.view": "ship class {name}",
+43
View File
@@ -336,6 +336,48 @@ const ru: Record<keyof typeof en, string> = {
"game.sidebar.order.label.ship_group_join_fleet": "включить группу {group} → флот {fleet}",
"game.sidebar.order.label.race_relation": "объявить {relation} расе {acceptor}",
"game.sidebar.order.label.race_vote": "отдать голоса расе {acceptor}",
"game.table.planets.title": "планеты",
"game.table.planets.loading": "загрузка планет…",
"game.table.planets.empty": "в отчёте нет планет",
"game.table.planets.column.number": "#",
"game.table.planets.column.name": "название",
"game.table.planets.column.kind": "тип",
"game.table.planets.column.owner": "владелец",
"game.table.planets.column.size": "размер",
"game.table.planets.column.resources": "ресурсы",
"game.table.planets.column.coordinates": "x,y",
"game.table.planets.kind.own": "свои",
"game.table.planets.kind.foreign": "чужие",
"game.table.planets.kind.uninhabited": "ничейные",
"game.table.planets.kind.unknown": "неизвестные",
"game.table.planets.filter.owner": "владелец:",
"game.table.planets.filter.owner.all": "все расы",
"game.table.ship_groups.title": "группы кораблей",
"game.table.ship_groups.loading": "загрузка групп кораблей…",
"game.table.ship_groups.empty": "в отчёте нет групп кораблей",
"game.table.ship_groups.column.owner": "владелец",
"game.table.ship_groups.column.class": "класс",
"game.table.ship_groups.column.count": "количество",
"game.table.ship_groups.column.race": "раса",
"game.table.ship_groups.column.location": "положение",
"game.table.ship_groups.column.mass": "масса",
"game.table.ship_groups.column.speed": "скорость",
"game.table.ship_groups.owner.own": "свои",
"game.table.ship_groups.owner.foreign": "чужие",
"game.table.ship_groups.filter.planet": "планета:",
"game.table.ship_groups.filter.planet.all": "все планеты",
"game.table.ship_groups.filter.class": "класс:",
"game.table.ship_groups.filter.class.all": "все классы",
"game.table.fleets.title": "флоты",
"game.table.fleets.loading": "загрузка флотов…",
"game.table.fleets.empty": "в отчёте нет флотов",
"game.table.fleets.column.name": "название",
"game.table.fleets.column.groups": "групп",
"game.table.fleets.column.state": "состояние",
"game.table.fleets.column.location": "положение",
"game.table.fleets.column.speed": "скорость",
"game.table.fleets.filter.planet": "планета:",
"game.table.fleets.filter.planet.all": "все планеты",
"game.table.ship_classes.title": "классы кораблей",
"game.table.ship_classes.column.name": "название",
"game.table.ship_classes.column.drive": "двигатель",
@@ -348,6 +390,7 @@ const ru: Record<keyof typeof en, string> = {
"game.table.ship_classes.filter.placeholder": "фильтр по названию",
"game.table.ship_classes.action.new": "+ новый класс корабля",
"game.table.ship_classes.action.delete": "удалить",
"game.table.ship_classes.action.delete.in_use": "используется в {count} группе(-ах) кораблей",
"game.table.ship_classes.loading": "загрузка классов кораблей…",
"game.designer.ship_class.title.new": "конструктор нового класса корабля",
"game.designer.ship_class.title.view": "класс корабля {name}",
+64 -1
View File
@@ -56,6 +56,20 @@ export const SELECTION_CONTEXT_KEY = Symbol("selection");
export class SelectionStore {
selected: Selected | null = $state(null);
// pendingFocus is a transient one-shot request to centre the map
// camera on a selection. F8-10 tables raise it together with the
// view switch to map; map.svelte consumes it once on mount (see
// `consumePendingFocus`) and clears it. Held in memory only — not
// persisted, so an F5 after a click-through does not re-centre.
#pendingFocus: Selected | null = $state(null);
// pendingCenter is the coord-only sibling of pendingFocus: it
// asks map.svelte to centre on a world point without touching
// the selection. F8-10 uses it for in-space fleet rows (the
// `Selected` union has no "fleet" variant, but the user still
// expects the camera to find them). Also one-shot and transient.
#pendingCenter: { x: number; y: number } | null = $state(null);
private destroyed = false;
/**
@@ -77,10 +91,57 @@ export class SelectionStore {
this.selected = { kind: "shipGroup", ref };
}
/**
* focus sets the active selection to `target` and queues a one-shot
* camera-centre request for whichever next mount of the map view
* picks it up via `consumePendingFocus`. Used by the F8-10 tables to
* navigate from a row click to the map. A no-op once the store has
* been disposed.
*/
focus(target: Selected): void {
if (this.destroyed) return;
this.selected = target;
this.#pendingFocus = target;
}
/**
* consumePendingFocus returns the queued focus target (if any) and
* clears it in the same call. Designed for `map.svelte` to invoke
* once after the renderer mounts.
*/
consumePendingFocus(): Selected | null {
const target = this.#pendingFocus;
this.#pendingFocus = null;
return target;
}
/**
* focusPoint queues a one-shot camera-centre on a free-form world
* coordinate, without altering selection. Used by the fleets table
* when the user clicks an in-space fleet (there is no `"fleet"`
* variant in `Selected`, but the camera should still find it).
* A no-op once the store has been disposed.
*/
focusPoint(x: number, y: number): void {
if (this.destroyed) return;
this.#pendingCenter = { x, y };
}
/**
* consumePendingCenter returns the queued centre point (if any)
* and clears it.
*/
consumePendingCenter(): { x: number; y: number } | null {
const point = this.#pendingCenter;
this.#pendingCenter = null;
return point;
}
/**
* clear drops the current selection. The mobile sheet's close
* button calls this; otherwise selection persists across active-
* view switches.
* view switches. Does not affect any queued pending focus — that
* is a transient delivery channel, not selection state.
*/
clear(): void {
if (this.destroyed) return;
@@ -90,5 +151,7 @@ export class SelectionStore {
dispose(): void {
this.destroyed = true;
this.selected = null;
this.#pendingFocus = null;
this.#pendingCenter = null;
}
}
+5 -1
View File
@@ -293,8 +293,12 @@ export function shipGroupsToPrimitives(
* — the planet inspector lists them instead. Returns null when either
* the group is on-planet, or the origin / destination planets are
* not visible to the local player.
*
* Exported so the active-view map can centre the camera on an
* in-space group when the F8-10 tables raise a `selection.focus`
* request for one.
*/
function computeInSpacePosition(
export function computeInSpacePosition(
group: ReportLocalShipGroup | ReportOtherShipGroup,
planetIndex: Map<number, ReportPlanet>,
mapWidth: number,
+143
View File
@@ -0,0 +1,143 @@
// F8-10 end-to-end coverage for the planets table → map navigation.
// Boots an authenticated session, mocks the gateway with a small
// planets-only report, navigates to `table → planets`, clicks the
// first row, and asserts the active view switches to the map (which
// also implicitly proves that the `SelectionStore.focus` → map mount
// hand-off lands inside the live shell). Ship-groups and fleets are
// covered by their vitest specs — one e2e is enough to smoke the
// composed flow.
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { expect, test, type Page } from "@playwright/test";
import { ExecuteCommandRequestSchema } from "../../src/proto/edge/v1/edge_gateway_pb";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
import {
buildMyGamesListPayload,
type GameFixture,
} from "./fixtures/lobby-fbs";
import { buildReportPayload } from "./fixtures/report-fbs";
const SESSION_ID = "f8-10-tables-session";
const GAME_ID = "f8101010-0000-4000-8000-101010101010";
async function mockGateway(page: Page): Promise<void> {
const game: GameFixture = {
gameId: GAME_ID,
gameName: "F8-10 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: 1,
};
await page.route(
"**/edge.v1.Gateway/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 payload: Uint8Array;
switch (req.messageType) {
case "lobby.my.games.list":
payload = buildMyGamesListPayload([game]);
break;
case "user.games.report":
payload = buildReportPayload({
turn: 1,
mapWidth: 4000,
mapHeight: 4000,
localPlanets: [
{ number: 1, name: "Home", x: 1000, y: 1000 },
],
otherPlanets: [
{
number: 2,
name: "Frontier",
x: 2000,
y: 1500,
owner: "Federation",
},
],
uninhabitedPlanets: [
{ number: 3, name: "Rock", x: 1500, y: 2200 },
],
});
break;
default:
payload = new Uint8Array();
}
const body = await forgeExecuteCommandResponseJson({
requestId: req.requestId,
timestampMs: BigInt(Date.now()),
resultCode: "ok",
payloadBytes: payload,
});
await route.fulfill({
status: 200,
contentType: "application/json",
body,
});
},
);
// Hold SubscribeEvents open — mirrors the pattern in other e2e
// specs to avoid the revocation watcher signing the session out.
await page.route(
"**/edge.v1.Gateway/SubscribeEvents",
async () => {
await new Promise<void>(() => {});
},
);
}
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,
);
}
test("clicking a row in the planets table opens the map", async ({ page }) => {
await mockGateway(page);
await bootSession(page);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) =>
window.__galaxyNav!.enterGame(id, "table", {
tableEntity: "planets",
}),
GAME_ID,
);
const table = page.getByTestId("planets-table");
await expect(table).toBeVisible();
const rows = page.getByTestId("planets-row");
await expect(rows).toHaveCount(3);
// Click the foreign planet — the data-* stamps let the spec assert
// against a deterministic row regardless of default sort.
await page
.locator('[data-testid="planets-row"][data-number="2"]')
.click();
await expect(page.getByTestId("active-view-map")).toBeVisible();
});
+6 -11
View File
@@ -48,21 +48,16 @@ describe("active-view stubs", () => {
expect(ui.getByTestId("map-canvas-wrap")).toBeInTheDocument();
});
test("table stub falls back for not-yet-implemented entities", () => {
const ui = render(TableView, { props: { entity: "planets" } });
test("table stub falls back for unknown entities", () => {
// Every menu-known slug is wired to a real component by F8-10;
// the fallback branch still exists for defensive routing (e.g.
// a restored snapshot referencing a removed entity).
const ui = render(TableView, { props: { entity: "unknown-slug" } });
const node = ui.getByTestId("active-view-table");
expect(node).toHaveAttribute("data-entity", "planets");
expect(node).toHaveTextContent("planets");
expect(node).toHaveAttribute("data-entity", "unknown-slug");
expect(node).toHaveTextContent("coming soon");
});
test("table stub also handles multi-word entities", () => {
const ui = render(TableView, { props: { entity: "ship-groups" } });
const node = ui.getByTestId("active-view-table");
expect(node).toHaveAttribute("data-entity", "ship-groups");
expect(node).toHaveTextContent("ship groups");
});
test("report view mounts with the icon-popup TOC", () => {
// Phase 23 replaces the Phase 10 stub with the full report
// orchestrator. The orchestrator mounts the table of contents
+85
View File
@@ -44,4 +44,89 @@ describe("SelectionStore", () => {
store.clear();
expect(store.selected).toBeNull();
});
test("focus sets the selection and queues the pending focus", () => {
const store = new SelectionStore();
store.focus({ kind: "planet", id: 11 });
expect(store.selected).toEqual({ kind: "planet", id: 11 });
expect(store.consumePendingFocus()).toEqual({ kind: "planet", id: 11 });
});
test("focus also works for a ship group target", () => {
const store = new SelectionStore();
store.focus({ kind: "shipGroup", ref: { variant: "local", id: "abc" } });
expect(store.selected).toEqual({
kind: "shipGroup",
ref: { variant: "local", id: "abc" },
});
expect(store.consumePendingFocus()).toEqual({
kind: "shipGroup",
ref: { variant: "local", id: "abc" },
});
});
test("consumePendingFocus clears the queued request", () => {
const store = new SelectionStore();
store.focus({ kind: "planet", id: 1 });
expect(store.consumePendingFocus()).not.toBeNull();
expect(store.consumePendingFocus()).toBeNull();
});
test("consumePendingFocus returns null when no focus was queued", () => {
const store = new SelectionStore();
store.selectPlanet(5);
expect(store.consumePendingFocus()).toBeNull();
});
test("clear leaves any queued pending focus untouched", () => {
const store = new SelectionStore();
store.focus({ kind: "planet", id: 9 });
store.clear();
expect(store.selected).toBeNull();
expect(store.consumePendingFocus()).toEqual({ kind: "planet", id: 9 });
});
test("dispose drops a queued pending focus", () => {
const store = new SelectionStore();
store.focus({ kind: "planet", id: 2 });
store.dispose();
expect(store.consumePendingFocus()).toBeNull();
});
test("focus is a no-op after dispose", () => {
const store = new SelectionStore();
store.dispose();
store.focus({ kind: "planet", id: 7 });
expect(store.selected).toBeNull();
expect(store.consumePendingFocus()).toBeNull();
});
test("focusPoint queues a coord without touching selection", () => {
const store = new SelectionStore();
store.selectPlanet(1);
store.focusPoint(12, 34);
expect(store.selected).toEqual({ kind: "planet", id: 1 });
expect(store.consumePendingCenter()).toEqual({ x: 12, y: 34 });
});
test("consumePendingCenter clears the queued point", () => {
const store = new SelectionStore();
store.focusPoint(5, 7);
expect(store.consumePendingCenter()).toEqual({ x: 5, y: 7 });
expect(store.consumePendingCenter()).toBeNull();
});
test("dispose drops a queued pending centre", () => {
const store = new SelectionStore();
store.focusPoint(1, 2);
store.dispose();
expect(store.consumePendingCenter()).toBeNull();
});
test("focusPoint is a no-op after dispose", () => {
const store = new SelectionStore();
store.dispose();
store.focusPoint(1, 2);
expect(store.consumePendingCenter()).toBeNull();
});
});
+221
View File
@@ -0,0 +1,221 @@
// Vitest coverage for the F8-10 fleets table active view.
// Mounts the component against a synthetic `RenderedReportSource`
// and a real `SelectionStore`; verifies the click semantics for both
// on-planet (focus the destination planet) and in-space (centre the
// camera on the interpolated point, leave selection untouched).
import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import type {
GameReport,
ReportLocalFleet,
ReportPlanet,
} from "../src/api/game-state";
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte";
import {
SELECTION_CONTEXT_KEY,
SelectionStore,
} from "../src/lib/selection.svelte";
import { activeView } from "../src/lib/app-nav.svelte";
import { resetFleetsTableState } from "../src/lib/active-view/table-fleets-state.svelte";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
const pageMock = vi.hoisted(() => ({
url: new URL("http://localhost/games/g1/table/fleets"),
params: { id: "g1" } as Record<string, string>,
}));
const gotoMock = vi.hoisted(() => vi.fn());
vi.mock("$app/state", () => ({
page: pageMock,
}));
vi.mock("$app/navigation", () => ({
goto: gotoMock,
pushState: vi.fn(),
replaceState: vi.fn(),
}));
import TableFleets from "../src/lib/active-view/table-fleets.svelte";
let selection: SelectionStore;
beforeEach(() => {
selection = new SelectionStore();
i18n.resetForTests("en");
pageMock.params = { id: "g1" };
gotoMock.mockClear();
activeView.reset();
resetFleetsTableState();
});
afterEach(() => {
selection.dispose();
});
function planet(num: number, x = 0, y = 0): ReportPlanet {
return {
number: num,
name: `P${num}`,
x,
y,
kind: "local",
owner: null,
size: null,
resources: null,
industryStockpile: null,
materialsStockpile: null,
industry: null,
population: null,
colonists: null,
production: null,
freeIndustry: null,
};
}
function fleet(
overrides: Partial<ReportLocalFleet> & Pick<ReportLocalFleet, "name" | "destination">,
): ReportLocalFleet {
return {
groupCount: 1,
origin: null,
range: null,
speed: 0,
state: "In_Orbit",
...overrides,
};
}
function makeReport(opts: {
planets?: ReportPlanet[];
fleets?: ReportLocalFleet[];
}): GameReport {
return {
turn: 1,
mapWidth: 1000,
mapHeight: 1000,
planetCount: opts.planets?.length ?? 0,
planets: opts.planets ?? [],
race: "Me",
localShipClass: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
localFleets: opts.fleets ?? [],
};
}
function mount(report: GameReport | null) {
const renderedReport = {
get report() {
return report;
},
};
const context = new Map<unknown, unknown>([
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
[SELECTION_CONTEXT_KEY, selection],
]);
return render(TableFleets, { context });
}
describe("fleets table", () => {
test("renders a loading placeholder before the report lands", () => {
const ui = mount(null);
expect(ui.getByTestId("fleets-loading")).toBeInTheDocument();
});
test("renders an empty placeholder when no fleets exist", () => {
const ui = mount(makeReport({ planets: [planet(1)] }));
expect(ui.getByTestId("fleets-empty")).toBeInTheDocument();
});
test("renders one row per fleet", () => {
const ui = mount(
makeReport({
planets: [planet(1), planet(2)],
fleets: [
fleet({ name: "Alpha", destination: 1 }),
fleet({
name: "Bravo",
destination: 2,
origin: 1,
range: 4,
state: "In_Space",
}),
],
}),
);
expect(ui.getAllByTestId("fleets-row")).toHaveLength(2);
});
test("planet dropdown filters by destination OR origin", async () => {
const ui = mount(
makeReport({
planets: [planet(1), planet(2), planet(3)],
fleets: [
fleet({ name: "Alpha", destination: 1 }),
fleet({
name: "Bravo",
destination: 3,
origin: 2,
range: 4,
state: "In_Space",
}),
],
}),
);
const sel = ui.getByTestId("fleets-filter-planet") as HTMLSelectElement;
await fireEvent.change(sel, { target: { value: "2" } });
const names = ui
.getAllByTestId("fleets-row")
.map((r) => r.getAttribute("data-name"));
expect(names).toEqual(["Bravo"]);
});
test("click on on-planet fleet focuses the destination planet", async () => {
const ui = mount(
makeReport({
planets: [planet(7)],
fleets: [fleet({ name: "Alpha", destination: 7 })],
}),
);
await fireEvent.click(ui.getByTestId("fleets-row"));
expect(selection.selected).toEqual({ kind: "planet", id: 7 });
expect(selection.consumePendingFocus()).toEqual({
kind: "planet",
id: 7,
});
expect(activeView.view).toBe("map");
});
test("click on in-space fleet centres camera point and leaves selection alone", async () => {
const ui = mount(
makeReport({
planets: [planet(1, 0, 0), planet(2, 100, 0)],
fleets: [
fleet({
name: "Bravo",
destination: 2,
origin: 1,
range: 25,
state: "In_Space",
}),
],
}),
);
expect(selection.selected).toBeNull();
await fireEvent.click(ui.getByTestId("fleets-row"));
expect(selection.selected).toBeNull();
// 25 world units away from dest (2 at x=100) toward origin (1 at x=0):
// dest + (range/total)*(origin-dest) = 100 + 0.25 * -100 = 75
expect(selection.consumePendingCenter()).toEqual({ x: 75, y: 0 });
expect(activeView.view).toBe("map");
});
});
+231
View File
@@ -0,0 +1,231 @@
// Vitest coverage for the F8-10 planets table active view.
// The component is mounted against a synthetic `RenderedReportSource`
// (kind discriminants set per case) and a real `SelectionStore`, so
// the click → focus contract is exercised end-to-end without needing
// the live map renderer.
import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import type { GameReport, ReportPlanet } from "../src/api/game-state";
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte";
import {
SELECTION_CONTEXT_KEY,
SelectionStore,
} from "../src/lib/selection.svelte";
import { activeView } from "../src/lib/app-nav.svelte";
import { resetPlanetsTableState } from "../src/lib/active-view/table-planets-state.svelte";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
const pageMock = vi.hoisted(() => ({
url: new URL("http://localhost/games/g1/table/planets"),
params: { id: "g1" } as Record<string, string>,
}));
const gotoMock = vi.hoisted(() => vi.fn());
vi.mock("$app/state", () => ({
page: pageMock,
}));
vi.mock("$app/navigation", () => ({
goto: gotoMock,
pushState: vi.fn(),
replaceState: vi.fn(),
}));
import TablePlanets from "../src/lib/active-view/table-planets.svelte";
let selection: SelectionStore;
beforeEach(() => {
selection = new SelectionStore();
i18n.resetForTests("en");
pageMock.params = { id: "g1" };
gotoMock.mockClear();
activeView.reset();
resetPlanetsTableState();
});
afterEach(() => {
selection.dispose();
});
function planet(overrides: Partial<ReportPlanet> & { number: number }): ReportPlanet {
return {
name: `P${overrides.number}`,
x: 0,
y: 0,
kind: "uninhabited",
owner: null,
size: null,
resources: null,
industryStockpile: null,
materialsStockpile: null,
industry: null,
population: null,
colonists: null,
production: null,
freeIndustry: null,
...overrides,
};
}
function makeReport(planets: ReportPlanet[]): GameReport {
return {
turn: 1,
mapWidth: 1000,
mapHeight: 1000,
planetCount: planets.length,
planets,
race: "Me",
localShipClass: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
};
}
function mount(report: GameReport | null) {
const renderedReport = {
get report() {
return report;
},
};
const context = new Map<unknown, unknown>([
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
[SELECTION_CONTEXT_KEY, selection],
]);
return render(TablePlanets, { context });
}
describe("planets table", () => {
test("renders a loading placeholder before the report lands", () => {
const ui = mount(null);
expect(ui.getByTestId("planets-loading")).toBeInTheDocument();
});
test("renders an empty placeholder when the report has no planets", () => {
const ui = mount(makeReport([]));
expect(ui.getByTestId("planets-empty")).toBeInTheDocument();
});
test("renders one row per planet with kind classification", () => {
const ui = mount(
makeReport([
planet({ number: 1, name: "Earth", kind: "local" }),
planet({
number: 2,
name: "Vega",
kind: "other",
owner: "Klingon",
}),
planet({ number: 3, name: "Rock", kind: "uninhabited" }),
planet({ number: 4, name: "", kind: "unidentified" }),
]),
);
const rows = ui.getAllByTestId("planets-row");
expect(rows).toHaveLength(4);
expect(rows[0]).toHaveAttribute("data-kind", "local");
expect(rows[1]).toHaveAttribute("data-kind", "other");
});
test("kind checkboxes filter the visible rows independently", async () => {
const ui = mount(
makeReport([
planet({ number: 1, kind: "local" }),
planet({ number: 2, kind: "other", owner: "A" }),
planet({ number: 3, kind: "uninhabited" }),
planet({ number: 4, kind: "unidentified" }),
]),
);
await fireEvent.click(ui.getByTestId("planets-filter-own"));
let kinds = ui
.getAllByTestId("planets-row")
.map((r) => r.getAttribute("data-kind"));
expect(kinds).toEqual(["other", "uninhabited", "unidentified"]);
await fireEvent.click(ui.getByTestId("planets-filter-uninhabited"));
kinds = ui
.getAllByTestId("planets-row")
.map((r) => r.getAttribute("data-kind"));
expect(kinds).toEqual(["other", "unidentified"]);
});
test("owner dropdown narrows the foreign slice only", async () => {
const ui = mount(
makeReport([
planet({ number: 1, kind: "local" }),
planet({ number: 2, kind: "other", owner: "Klingon" }),
planet({ number: 3, kind: "other", owner: "Romulan" }),
planet({ number: 4, kind: "uninhabited" }),
]),
);
const select = ui.getByTestId(
"planets-filter-owner",
) as HTMLSelectElement;
await fireEvent.change(select, { target: { value: "Klingon" } });
const numbers = ui
.getAllByTestId("planets-row")
.map((r) => r.getAttribute("data-number"));
// own (1) + foreign Klingon (2) + uninhabited (4); Romulan dropped
expect(numbers).toEqual(["1", "2", "4"]);
});
test("clicking a row focuses the planet and switches the active view", async () => {
const ui = mount(
makeReport([
planet({ number: 7, kind: "local", name: "Earth", x: 12, y: 34 }),
]),
);
await fireEvent.click(ui.getByTestId("planets-row"));
expect(selection.selected).toEqual({ kind: "planet", id: 7 });
expect(selection.consumePendingFocus()).toEqual({
kind: "planet",
id: 7,
});
expect(activeView.view).toBe("map");
});
test("filter checkboxes survive an unmount/remount cycle", async () => {
const report = makeReport([
planet({ number: 1, kind: "local" }),
planet({ number: 2, kind: "other", owner: "Klingon" }),
]);
const first = mount(report);
await fireEvent.click(first.getByTestId("planets-filter-own"));
first.unmount();
const second = mount(report);
const ownCheckbox = second.getByTestId(
"planets-filter-own",
) as HTMLInputElement;
expect(ownCheckbox.checked).toBe(false);
const kinds = second
.getAllByTestId("planets-row")
.map((r) => r.getAttribute("data-kind"));
expect(kinds).toEqual(["other"]);
});
test("number column sorts ascending then descending", async () => {
const ui = mount(
makeReport([
planet({ number: 3, kind: "local" }),
planet({ number: 1, kind: "local" }),
planet({ number: 2, kind: "local" }),
]),
);
const numbers = ui
.getAllByTestId("planets-row")
.map((r) => r.getAttribute("data-number"));
expect(numbers).toEqual(["1", "2", "3"]);
await fireEvent.click(ui.getByTestId("planets-column-number"));
const reversed = ui
.getAllByTestId("planets-row")
.map((r) => r.getAttribute("data-number"));
expect(reversed).toEqual(["3", "2", "1"]);
});
});
+60 -2
View File
@@ -17,7 +17,11 @@ import {
} from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import type { GameReport, ShipClassSummary } from "../src/api/game-state";
import type {
GameReport,
ReportLocalShipGroup,
ShipClassSummary,
} from "../src/api/game-state";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
@@ -89,7 +93,10 @@ function shipClass(
};
}
function makeReport(localShipClass: ShipClassSummary[]): GameReport {
function makeReport(
localShipClass: ShipClassSummary[],
localShipGroups: ReportLocalShipGroup[] = [],
): GameReport {
return {
turn: 1,
mapWidth: 1000,
@@ -104,6 +111,29 @@ function makeReport(localShipClass: ShipClassSummary[]): GameReport {
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
localShipGroups,
};
}
function shipGroup(
overrides: Partial<ReportLocalShipGroup> &
Pick<ReportLocalShipGroup, "class">,
): ReportLocalShipGroup {
return {
id: crypto.randomUUID(),
state: "In_Orbit",
fleet: null,
count: 1,
tech: { drive: 0, weapons: 0, shields: 0, cargo: 0 },
cargo: "NONE",
load: 0,
destination: 1,
origin: null,
range: null,
speed: 0,
mass: 0,
race: "Foo",
...overrides,
};
}
@@ -210,6 +240,34 @@ describe("ship-classes table", () => {
expect(cmd.name).toBe("Drone");
});
test("delete is disabled when a local ship group uses the class", async () => {
const ui = mountTable(
makeReport(
[shipClass({ name: "Cruiser" }), shipClass({ name: "Drone" })],
[
shipGroup({ class: "Cruiser" }),
shipGroup({ class: "Cruiser", count: 4 }),
],
),
);
const buttons = ui.getAllByTestId("ship-classes-delete");
const map = new Map(
buttons.map((b) => {
const row = b.closest('[data-testid="ship-classes-row"]');
return [row?.getAttribute("data-name") ?? "", b];
}),
);
const cruiserBtn = map.get("Cruiser") as HTMLButtonElement;
const droneBtn = map.get("Drone") as HTMLButtonElement;
expect(cruiserBtn).toBeDisabled();
expect(cruiserBtn).toHaveAttribute(
"title",
expect.stringContaining("2"),
);
expect(droneBtn).not.toBeDisabled();
expect(droneBtn).toHaveAttribute("title", "");
});
test("new button requests a fresh calculator design", async () => {
const ui = mountTable(makeReport([]));
const before = calculatorLoadRequest.token;
+345
View File
@@ -0,0 +1,345 @@
// Vitest coverage for the F8-10 ship-groups table active view.
// Mounts the component against a synthetic `RenderedReportSource`
// and a real `SelectionStore`; the click → focus contract (planet
// for on-orbit groups, ship-group ref for in-space) is exercised
// directly through the store.
import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import type {
GameReport,
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
} from "../src/api/game-state";
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte";
import {
SELECTION_CONTEXT_KEY,
SelectionStore,
} from "../src/lib/selection.svelte";
import { activeView } from "../src/lib/app-nav.svelte";
import { resetShipGroupsTableState } from "../src/lib/active-view/table-ship-groups-state.svelte";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
const pageMock = vi.hoisted(() => ({
url: new URL("http://localhost/games/g1/table/ship-groups"),
params: { id: "g1" } as Record<string, string>,
}));
const gotoMock = vi.hoisted(() => vi.fn());
vi.mock("$app/state", () => ({
page: pageMock,
}));
vi.mock("$app/navigation", () => ({
goto: gotoMock,
pushState: vi.fn(),
replaceState: vi.fn(),
}));
import TableShipGroups from "../src/lib/active-view/table-ship-groups.svelte";
let selection: SelectionStore;
beforeEach(() => {
selection = new SelectionStore();
i18n.resetForTests("en");
pageMock.params = { id: "g1" };
gotoMock.mockClear();
activeView.reset();
resetShipGroupsTableState();
});
afterEach(() => {
selection.dispose();
});
function planet(num: number, name?: string): ReportPlanet {
return {
number: num,
name: name ?? `P${num}`,
x: 0,
y: 0,
kind: "local",
owner: null,
size: null,
resources: null,
industryStockpile: null,
materialsStockpile: null,
industry: null,
population: null,
colonists: null,
production: null,
freeIndustry: null,
};
}
function localGroup(
overrides: Partial<ReportLocalShipGroup> &
Pick<ReportLocalShipGroup, "id" | "class" | "destination">,
): ReportLocalShipGroup {
return {
state: "In_Orbit",
fleet: null,
count: 1,
tech: { drive: 0, weapons: 0, shields: 0, cargo: 0 },
cargo: "NONE",
load: 0,
origin: null,
range: null,
speed: 0,
mass: 0,
race: "Me",
...overrides,
};
}
function otherGroup(
overrides: Partial<ReportOtherShipGroup> &
Pick<ReportOtherShipGroup, "class" | "destination" | "race">,
): ReportOtherShipGroup {
return {
count: 1,
tech: { drive: 0, weapons: 0, shields: 0, cargo: 0 },
cargo: "NONE",
load: 0,
origin: null,
range: null,
speed: 0,
mass: 0,
...overrides,
};
}
function makeReport(opts: {
planets?: ReportPlanet[];
local?: ReportLocalShipGroup[];
other?: ReportOtherShipGroup[];
}): GameReport {
return {
turn: 1,
mapWidth: 1000,
mapHeight: 1000,
planetCount: opts.planets?.length ?? 0,
planets: opts.planets ?? [],
race: "Me",
localShipClass: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
localShipGroups: opts.local ?? [],
otherShipGroups: opts.other ?? [],
};
}
function mount(report: GameReport | null) {
const renderedReport = {
get report() {
return report;
},
};
const context = new Map<unknown, unknown>([
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
[SELECTION_CONTEXT_KEY, selection],
]);
return render(TableShipGroups, { context });
}
describe("ship-groups table", () => {
test("renders a loading placeholder before the report lands", () => {
const ui = mount(null);
expect(ui.getByTestId("ship-groups-loading")).toBeInTheDocument();
});
test("renders an empty placeholder when no groups are present", () => {
const ui = mount(makeReport({ planets: [planet(1)] }));
expect(ui.getByTestId("ship-groups-empty")).toBeInTheDocument();
});
test("renders local and foreign rows under one table", () => {
const ui = mount(
makeReport({
planets: [planet(1), planet(2)],
local: [localGroup({ id: "L1", class: "Cruiser", destination: 1 })],
other: [
otherGroup({ class: "Hunter", destination: 2, race: "Klingon" }),
],
}),
);
const rows = ui.getAllByTestId("ship-groups-row");
expect(rows).toHaveLength(2);
const owners = rows.map((r) => r.getAttribute("data-owner"));
expect(owners.sort()).toEqual(["foreign", "own"]);
});
test("owner checkboxes filter independently", async () => {
const ui = mount(
makeReport({
planets: [planet(1), planet(2)],
local: [localGroup({ id: "L1", class: "Cruiser", destination: 1 })],
other: [
otherGroup({ class: "Hunter", destination: 2, race: "Klingon" }),
],
}),
);
await fireEvent.click(ui.getByTestId("ship-groups-filter-foreign"));
const owners = ui
.getAllByTestId("ship-groups-row")
.map((r) => r.getAttribute("data-owner"));
expect(owners).toEqual(["own"]);
});
test("planet dropdown filters by destination OR origin", async () => {
const ui = mount(
makeReport({
planets: [planet(1), planet(2), planet(3)],
local: [
localGroup({ id: "L1", class: "C", destination: 1 }),
localGroup({
id: "L2",
class: "C",
destination: 3,
origin: 2,
range: 4,
state: "In_Space",
}),
localGroup({ id: "L3", class: "C", destination: 3 }),
],
}),
);
const sel = ui.getByTestId(
"ship-groups-filter-planet",
) as HTMLSelectElement;
await fireEvent.change(sel, { target: { value: "2" } });
// only L2 touches planet 2 (origin === 2)
const keys = ui
.getAllByTestId("ship-groups-row")
.map((r) => r.getAttribute("data-key"));
expect(keys).toEqual(["local:L2"]);
});
test("class dropdown filters by class name", async () => {
const ui = mount(
makeReport({
planets: [planet(1)],
local: [
localGroup({ id: "L1", class: "Cruiser", destination: 1 }),
localGroup({ id: "L2", class: "Drone", destination: 1 }),
],
}),
);
const sel = ui.getByTestId(
"ship-groups-filter-class",
) as HTMLSelectElement;
await fireEvent.change(sel, { target: { value: "Drone" } });
const keys = ui
.getAllByTestId("ship-groups-row")
.map((r) => r.getAttribute("data-key"));
expect(keys).toEqual(["local:L2"]);
});
test("click on on-planet group focuses the destination planet", async () => {
const ui = mount(
makeReport({
planets: [planet(7)],
local: [localGroup({ id: "L1", class: "Cruiser", destination: 7 })],
}),
);
await fireEvent.click(ui.getByTestId("ship-groups-row"));
expect(selection.selected).toEqual({ kind: "planet", id: 7 });
expect(selection.consumePendingFocus()).toEqual({
kind: "planet",
id: 7,
});
expect(activeView.view).toBe("map");
});
test("click on in-space local group focuses the ship-group ref", async () => {
const ui = mount(
makeReport({
planets: [planet(1), planet(2)],
local: [
localGroup({
id: "L1",
class: "Cruiser",
destination: 2,
origin: 1,
range: 3,
state: "In_Space",
}),
],
}),
);
await fireEvent.click(ui.getByTestId("ship-groups-row"));
expect(selection.selected).toEqual({
kind: "shipGroup",
ref: { variant: "local", id: "L1" },
});
expect(selection.consumePendingFocus()).toEqual({
kind: "shipGroup",
ref: { variant: "local", id: "L1" },
});
expect(activeView.view).toBe("map");
});
test("planet/class dropdowns narrow to the owner-checkbox cut", async () => {
const ui = mount(
makeReport({
planets: [planet(1), planet(2), planet(3)],
local: [localGroup({ id: "L1", class: "Cruiser", destination: 1 })],
other: [
otherGroup({ class: "Hunter", destination: 3, race: "Klingon" }),
],
}),
);
// All four planet options available when both owner kinds are on
const planetSelect = ui.getByTestId(
"ship-groups-filter-planet",
) as HTMLSelectElement;
const valuesFull = Array.from(planetSelect.options).map((o) => o.value);
expect(valuesFull).toContain("1");
expect(valuesFull).toContain("3");
// Hide foreign rows; planet 3 (only touched by Klingon) disappears
await fireEvent.click(ui.getByTestId("ship-groups-filter-foreign"));
const valuesNarrowed = Array.from(planetSelect.options).map((o) => o.value);
expect(valuesNarrowed).toContain("1");
expect(valuesNarrowed).not.toContain("3");
// Class dropdown narrows too: Hunter disappears
const classSelect = ui.getByTestId(
"ship-groups-filter-class",
) as HTMLSelectElement;
const classValues = Array.from(classSelect.options).map((o) => o.value);
expect(classValues).not.toContain("Hunter");
expect(classValues).toContain("Cruiser");
});
test("click on in-space foreign group focuses other variant by index", async () => {
const ui = mount(
makeReport({
planets: [planet(1), planet(2)],
other: [
otherGroup({
class: "Hunter",
destination: 2,
origin: 1,
range: 5,
race: "Klingon",
}),
],
}),
);
await fireEvent.click(ui.getByTestId("ship-groups-row"));
expect(selection.selected).toEqual({
kind: "shipGroup",
ref: { variant: "other", index: 0 },
});
expect(activeView.view).toBe("map");
});
});