ui/phase-23: turn-report view with twenty sections and TOC

Replaces the Phase 10 report stub with a scrollable orchestrator that
renders every FBS array as a dedicated section (galaxy summary, votes,
player status, my/foreign sciences, my/foreign ship classes, battles,
bombings, approaching groups, my/foreign/uninhabited/unknown planets,
ships in production, cargo routes, my fleets, my/foreign/unidentified
ship groups). A sticky table of contents (a <select> on mobile),
"back to map" affordance, IntersectionObserver-driven active-section
highlight, and SvelteKit Snapshot-based scroll save/restore round out
the view.

GameReport gains six new fields (players, otherScience, otherShipClass,
battleIds, bombings, shipProductions); decodeReport, the synthetic-
report loader, the e2e fixture builder, and EMPTY_SHIP_GROUPS extend
in lockstep. ~90 new i18n keys land in en + ru together.

The legacy-report parser is extended to populate the new sections from
the dg/gplus text formats (Your Sciences, <Race> Sciences, <Race> Ship
Types, Bombings, Ships In Production). Ships-in-production prod_used
is derived through a new pkg/calc.ShipBuildCost helper; the engine's
controller.ProduceShip refactors to call the same helper without any
behaviour change (engine tests stay unchanged and green). Battles
remain in the parser's Skipped list — the legacy text carries no
stable per-battle UUID.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-11 14:33:56 +02:00
parent 81d8be08b2
commit c58027c034
48 changed files with 5368 additions and 103 deletions
+169 -16
View File
@@ -1,28 +1,181 @@
<!--
Phase 10 stub for the turn-report active view. Phase 23 replaces the
body with the per-turn sections (cargo deliveries, completed sciences,
mail, etc.).
Phase 23 turn-report active view.
Composes the table of contents (`report/report-toc.svelte`) and the
twenty section components that render each `GameReport` array. Each
section is its own component under `lib/active-view/report/` — the
data shapes are too varied for one generic table, and the
component-per-section seam matches Phase 23's targeted-test contract.
Active-section highlighting and scroll save/restore land here:
- `IntersectionObserver` rooted on the active-view-host element
(`bind:this` in `+layout.svelte`, plumbed through
`ACTIVE_VIEW_HOST_CONTEXT_KEY`) watches every `<section
id="report-<slug>">` and updates a local `activeSlug` rune.
- The matching `+page.svelte` exports a SvelteKit `Snapshot` that
captures and restores `host.element.scrollTop`, so navigating to
/map and back lands on the same scroll position. The save lives in
`+page.svelte` because SvelteKit binds snapshots per route.
The 20-section list lives here as a single source of truth so the
TOC and the body iterate the same data.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
import { onMount } from "svelte";
import { page } from "$app/state";
import ReportToc, {
type TocEntry,
} from "./report/report-toc.svelte";
import SectionGalaxySummary from "./report/section-galaxy-summary.svelte";
import SectionVotes from "./report/section-votes.svelte";
import SectionPlayerStatus from "./report/section-player-status.svelte";
import SectionMySciences from "./report/section-my-sciences.svelte";
import SectionForeignSciences from "./report/section-foreign-sciences.svelte";
import SectionMyShipClasses from "./report/section-my-ship-classes.svelte";
import SectionForeignShipClasses from "./report/section-foreign-ship-classes.svelte";
import SectionBattles from "./report/section-battles.svelte";
import SectionBombings from "./report/section-bombings.svelte";
import SectionApproachingGroups from "./report/section-approaching-groups.svelte";
import SectionMyPlanets from "./report/section-my-planets.svelte";
import SectionShipsInProduction from "./report/section-ships-in-production.svelte";
import SectionCargoRoutes from "./report/section-cargo-routes.svelte";
import SectionForeignPlanets from "./report/section-foreign-planets.svelte";
import SectionUninhabitedPlanets from "./report/section-uninhabited-planets.svelte";
import SectionUnknownPlanets from "./report/section-unknown-planets.svelte";
import SectionMyFleets from "./report/section-my-fleets.svelte";
import SectionMyShipGroups from "./report/section-my-ship-groups.svelte";
import SectionForeignShipGroups from "./report/section-foreign-ship-groups.svelte";
import SectionUnidentifiedGroups from "./report/section-unidentified-groups.svelte";
const ENTRIES: readonly TocEntry[] = [
{ slug: "galaxy-summary", titleKey: "game.report.section.galaxy_summary.title" },
{ slug: "votes", titleKey: "game.report.section.votes.title" },
{ slug: "player-status", titleKey: "game.report.section.player_status.title" },
{ slug: "my-sciences", titleKey: "game.report.section.my_sciences.title" },
{ slug: "foreign-sciences", titleKey: "game.report.section.foreign_sciences.title" },
{ slug: "my-ship-classes", titleKey: "game.report.section.my_ship_classes.title" },
{ slug: "foreign-ship-classes", titleKey: "game.report.section.foreign_ship_classes.title" },
{ slug: "battles", titleKey: "game.report.section.battles.title" },
{ slug: "bombings", titleKey: "game.report.section.bombings.title" },
{ slug: "approaching-groups", titleKey: "game.report.section.approaching_groups.title" },
{ slug: "my-planets", titleKey: "game.report.section.my_planets.title" },
{ slug: "ships-in-production", titleKey: "game.report.section.ships_in_production.title" },
{ slug: "cargo-routes", titleKey: "game.report.section.cargo_routes.title" },
{ slug: "foreign-planets", titleKey: "game.report.section.foreign_planets.title" },
{ slug: "uninhabited-planets", titleKey: "game.report.section.uninhabited_planets.title" },
{ slug: "unknown-planets", titleKey: "game.report.section.unknown_planets.title" },
{ slug: "my-fleets", titleKey: "game.report.section.my_fleets.title" },
{ slug: "my-ship-groups", titleKey: "game.report.section.my_ship_groups.title" },
{ slug: "foreign-ship-groups", titleKey: "game.report.section.foreign_ship_groups.title" },
{ slug: "unidentified-groups", titleKey: "game.report.section.unidentified_groups.title" },
];
const gameId = $derived(page.params.id ?? "");
let activeSlug = $state<string>(ENTRIES[0]?.slug ?? "");
let bodyEl: HTMLDivElement | null = $state(null);
// `IntersectionObserver` rooted on the viewport (`root: null`)
// lets the TOC highlight follow the section currently in the
// upper portion of the visible area. The in-game shell layout
// expands the active-view-host to fit content rather than
// constraining it, so the document body scrolls — not the host.
// Targeting the viewport with a top-skewed `rootMargin` advances
// the highlight as a section enters the upper third of what the
// reader sees, without coupling to the layout's internal sizing.
onMount(() => {
if (typeof IntersectionObserver === "undefined") return;
const body = bodyEl;
if (body === null) return;
const targets = body.querySelectorAll<HTMLElement>("section[id^='report-']");
if (targets.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
let pick: { slug: string; ratio: number } | null = null;
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const slug = entry.target.id.replace(/^report-/, "");
if (pick === null || entry.intersectionRatio > pick.ratio) {
pick = { slug, ratio: entry.intersectionRatio };
}
}
if (pick !== null) {
activeSlug = pick.slug;
}
},
{
root: null,
rootMargin: "-30% 0px -60% 0px",
threshold: [0, 0.25, 0.5, 0.75, 1],
},
);
targets.forEach((t) => observer.observe(t));
return () => observer.disconnect();
});
</script>
<section class="active-view" data-testid="active-view-report">
<h2>{i18n.t("game.view.report")}</h2>
<p>{i18n.t("game.shell.coming_soon")}</p>
</section>
<div class="report-view" data-testid="active-view-report">
<ReportToc entries={ENTRIES} {activeSlug} {gameId} />
<div class="report-body" bind:this={bodyEl}>
<SectionGalaxySummary />
<SectionVotes />
<SectionPlayerStatus />
<SectionMySciences />
<SectionForeignSciences />
<SectionMyShipClasses />
<SectionForeignShipClasses />
<SectionBattles />
<SectionBombings />
<SectionApproachingGroups />
<SectionMyPlanets />
<SectionShipsInProduction />
<SectionCargoRoutes />
<SectionForeignPlanets />
<SectionUninhabitedPlanets />
<SectionUnknownPlanets />
<SectionMyFleets />
<SectionMyShipGroups />
<SectionForeignShipGroups />
<SectionUnidentifiedGroups />
</div>
</div>
<style>
.active-view {
padding: 1.5rem;
.report-view {
display: grid;
grid-template-columns: 14rem 1fr;
gap: 1.25rem;
padding: 1rem 1.25rem 2rem;
font-family: system-ui, sans-serif;
}
.active-view h2 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
.report-view > :global(.report-toc) {
position: sticky;
top: 0;
align-self: start;
padding: 0.5rem 0;
max-height: calc(100vh - 4rem);
overflow-y: auto;
}
.active-view p {
margin: 0;
color: #555;
.report-body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
@media (max-width: 767.98px) {
.report-view {
grid-template-columns: 1fr;
padding: 0.75rem;
gap: 0.75rem;
}
.report-view > :global(.report-toc) {
position: sticky;
top: 0;
background: #0a0e1a;
padding: 0.5rem 0;
z-index: 5;
}
}
</style>
@@ -0,0 +1,75 @@
// Shared number / planet formatters for the Phase 23 Report View
// sections. Inlined in 10+ components, so factoring keeps each
// section component focused on its data shape. The formatters
// match the conventions of the per-entity tables (tabular numerals,
// one-decimal percent without a `%` suffix — the header carries the
// unit) so the report's grids read the same way as the
// table-races / table-sciences views.
import type { ReportPlanet } from "../../../api/game-state";
/**
* formatPercent renders a `[0, 1]` fraction as a one-decimal
* percent (without a `%` suffix — the column header carries the
* unit). Matches the convention used by `table-races.svelte` and
* `table-sciences.svelte`.
*/
export function formatPercent(fraction: number): string {
return (fraction * 100).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
}
/**
* formatCount renders an integer-ish value (population, industry,
* planet count, …) without fractional digits and with locale-aware
* thousand separators.
*/
export function formatCount(value: number): string {
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
}
/**
* formatFloat renders a floating-point value with up to two
* fractional digits. Used for stockpiles, distances, cost, mass —
* everything the engine emits as a `Float` that is not a fraction.
*/
export function formatFloat(value: number): string {
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}
/**
* formatVotes renders a vote weight with up to two decimal digits —
* mirrors the races table's column convention so the cumulative
* vote totals line up across views.
*/
export function formatVotes(value: number): string {
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}
/**
* planetLabel renders a planet reference as `#<number> (<name>)` if
* the planet is known in the report, or just `#<number>` if the
* lookup fails (visibility lost between turns, foreign-only data).
* Sections that show planet numbers without a name column —
* Ships in Production, Bombings — rely on this resolver to keep
* cell width tight.
*/
export function planetLabel(
number: number,
planets: readonly ReportPlanet[],
): string {
const p = planets.find((row) => row.number === number);
if (p === undefined || p.name === "") return `#${number}`;
return `#${number} (${p.name})`;
}
@@ -0,0 +1,202 @@
<!--
Phase 23 Report View table of contents.
Responsibilities:
- "Back to map" button at the top — visible on both desktop sidebar
and mobile sticky toolbar. Navigates via `$app/navigation.goto` so
active-view-host scroll restoration plays through SvelteKit's
history machinery and the layout's `mobileTool` resets naturally.
- Desktop / tablet sidebar: a vertical list of anchor links, one per
section. The active link gets `aria-current="location"` and a
`.active` style. Click scrolls the active-view-host (not the
window) by calling `scrollIntoView` on the matching section.
- Mobile (`max-width: 767.98px`): the sidebar collapses to a sticky
`<select>` at the top of the body — a minimal contract that does
not stack with the layout's bottom-tab bar. The same option list
drives both surfaces.
The active section is computed by the orchestrator
(`report.svelte`) via `IntersectionObserver` and passed in via the
`activeSlug` prop. The TOC itself owns no observers.
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
export interface TocEntry {
slug: string;
titleKey: TranslationKey;
}
type Props = {
entries: readonly TocEntry[];
activeSlug: string;
gameId: string;
};
let { entries, activeSlug, gameId }: Props = $props();
function scrollToSlug(slug: string): void {
const target = document.getElementById(`report-${slug}`);
if (target === null) return;
const reduced = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
target.scrollIntoView({
behavior: reduced ? "auto" : "smooth",
block: "start",
});
}
function onAnchorClick(event: MouseEvent, slug: string): void {
event.preventDefault();
scrollToSlug(slug);
}
function onSelectChange(event: Event): void {
const select = event.currentTarget as HTMLSelectElement;
const slug = select.value;
if (slug === "") return;
scrollToSlug(slug);
}
async function backToMap(): Promise<void> {
await goto(`/games/${gameId}/map`);
}
</script>
<aside
class="report-toc"
data-testid="report-toc"
aria-label={i18n.t("game.report.toc.title")}
>
<button
type="button"
class="back-to-map"
data-testid="report-back-to-map"
onclick={() => void backToMap()}
>
{i18n.t("game.report.back_to_map")}
</button>
<nav class="desktop" aria-label={i18n.t("game.report.toc.title")}>
<ul>
{#each entries as entry (entry.slug)}
<li>
<a
href={`#report-${entry.slug}`}
class:active={activeSlug === entry.slug}
aria-current={activeSlug === entry.slug
? "location"
: undefined}
data-testid="report-toc-{entry.slug}"
onclick={(e) => onAnchorClick(e, entry.slug)}
>
{i18n.t(entry.titleKey)}
</a>
</li>
{/each}
</ul>
</nav>
<label class="mobile">
<span class="visually-hidden">
{i18n.t("game.report.toc.mobile_label")}
</span>
<select
data-testid="report-toc-mobile"
value={activeSlug}
onchange={onSelectChange}
>
{#each entries as entry (entry.slug)}
<option value={entry.slug}>{i18n.t(entry.titleKey)}</option>
{/each}
</select>
</label>
</aside>
<style>
.report-toc {
display: flex;
flex-direction: column;
gap: 0.75rem;
font-family: system-ui, sans-serif;
}
.back-to-map {
font: inherit;
font-size: 0.85rem;
text-align: left;
padding: 0.4rem 0.6rem;
background: #11172a;
color: #cfd7ff;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.back-to-map:hover {
background: #1a2240;
color: #e8eaf6;
}
.desktop {
display: block;
}
.desktop ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.desktop a {
display: block;
padding: 0.3rem 0.6rem;
color: #aab;
text-decoration: none;
font-size: 0.85rem;
line-height: 1.3;
border-left: 2px solid transparent;
border-radius: 0 3px 3px 0;
}
.desktop a:hover {
color: #e8eaf6;
background: #11172a;
}
.desktop a.active {
color: #e8eaf6;
background: #11172a;
border-left-color: #4a6cf7;
}
.mobile {
display: none;
}
.mobile select {
width: 100%;
font: inherit;
padding: 0.4rem 0.5rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 767.98px) {
.desktop {
display: none;
}
.mobile {
display: block;
}
}
</style>
@@ -0,0 +1,99 @@
<!--
Phase 23 Report View — approaching groups section. Renders the wire
`incomingGroup[]` projection as a compact grid: origin → destination
along with distance / speed / mass. The wire field carries no
ship-class info (a true blip on radar); the player only learns the
class when the group lands and a battle roster forms.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat, planetLabel } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.incomingShipGroups ?? []);
const planets = $derived(report?.planets ?? []);
</script>
<section
id="report-approaching-groups"
class="grid-section"
data-testid="report-section-approaching-groups"
>
<h2>{i18n.t("game.report.section.approaching_groups.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="approaching-groups-empty">
{i18n.t("game.report.section.approaching_groups.empty")}
</p>
{:else}
<table class="grid" data-testid="approaching-groups-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.approaching_groups.column.from")}</th>
<th>{i18n.t("game.report.section.approaching_groups.column.to")}</th>
<th>
{i18n.t("game.report.section.approaching_groups.column.distance")}
</th>
<th>{i18n.t("game.report.section.approaching_groups.column.speed")}</th>
<th>{i18n.t("game.report.section.approaching_groups.column.mass")}</th>
</tr>
</thead>
<tbody>
{#each rows as r, i (i)}
<tr data-testid="approaching-groups-row">
<td>{planetLabel(r.origin, planets)}</td>
<td>{planetLabel(r.destination, planets)}</td>
<td>{formatFloat(r.distance)}</td>
<td>{formatFloat(r.speed)}</td>
<td>{formatFloat(r.mass)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,91 @@
<!--
Phase 23 Report View — battles section. The wire only carries
battle UUIDs (the full battle report is fetched lazily by Phase 27),
so each row is a monospace, non-interactive `<span>` of the battle
identifier. Phase 27 will turn each row into a link to
`/games/<id>/battle/<uuid>`; until then dead links are worse than
plain text.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const ids = $derived(report?.battleIds ?? []);
</script>
<section
id="report-battles"
class="grid-section"
data-testid="report-section-battles"
>
<h2>{i18n.t("game.report.section.battles.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if ids.length === 0}
<p class="status" data-testid="battles-empty">
{i18n.t("game.report.section.battles.empty")}
</p>
{:else}
<ul class="ids" data-testid="battles-list">
{#each ids as id (id)}
<li>
<span class="label">
{i18n.t("game.report.section.battles.id_label")}
</span>
<span
class="uuid"
data-testid="report-battle-row"
data-id={id}
>{id}</span>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.ids {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
}
.ids li {
display: flex;
align-items: baseline;
gap: 0.6rem;
}
.label {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.7rem;
}
.uuid {
color: #cfd7ff;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
</style>
@@ -0,0 +1,139 @@
<!--
Phase 23 Report View — bombings section. One row per bombing
event; wiped planets get a visually-distinct row state plus a
"wiped" badge so the boolean is explicit for e2e assertions.
Decoder sorts by `planetNumber` already.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatCount, formatFloat } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.bombings ?? []);
</script>
<section
id="report-bombings"
class="grid-section"
data-testid="report-section-bombings"
>
<h2>{i18n.t("game.report.section.bombings.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="bombings-empty">
{i18n.t("game.report.section.bombings.empty")}
</p>
{:else}
<table class="grid" data-testid="bombings-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.bombings.column.planet")}</th>
<th>{i18n.t("game.report.section.bombings.column.owner")}</th>
<th>{i18n.t("game.report.section.bombings.column.attacker")}</th>
<th>{i18n.t("game.report.section.bombings.column.production")}</th>
<th>{i18n.t("game.report.section.bombings.column.industry")}</th>
<th>{i18n.t("game.report.section.bombings.column.population")}</th>
<th>{i18n.t("game.report.section.bombings.column.colonists")}</th>
<th>
{i18n.t("game.report.section.bombings.column.industry_stockpile")}
</th>
<th>
{i18n.t("game.report.section.bombings.column.materials_stockpile")}
</th>
<th>{i18n.t("game.report.section.bombings.column.attack_power")}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each rows as b (`${b.planetNumber}/${b.attacker}/${b.owner}`)}
<tr
data-testid="report-bombing-row"
data-planet={b.planetNumber}
data-wiped={b.wiped ? "true" : "false"}
class:wiped={b.wiped}
>
<td>#{b.planetNumber} ({b.planet})</td>
<td>{b.owner}</td>
<td>{b.attacker}</td>
<td>{b.production}</td>
<td>{formatFloat(b.industry)}</td>
<td>{formatFloat(b.population)}</td>
<td>{formatFloat(b.colonists)}</td>
<td>{formatFloat(b.industryStockpile)}</td>
<td>{formatFloat(b.materialsStockpile)}</td>
<td>{formatCount(b.attackPower)}</td>
<td>
{#if b.wiped}
<span
class="wiped-badge"
data-testid="report-bombing-wiped-badge"
>
{i18n.t("game.report.section.bombings.wiped")}
</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
.wiped td {
color: #c97a7a;
}
.wiped-badge {
display: inline-block;
padding: 0.1rem 0.45rem;
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #4a1010;
color: #ffcaca;
border: 1px solid #8a3030;
border-radius: 3px;
}
</style>
@@ -0,0 +1,114 @@
<!--
Phase 23 Report View — cargo routes section. The wire `routes[]`
groups by source planet; each entry inside a route is one
(loadType, destination) pair. The section flattens both to a single
table — anchor jumps into a single visual unit even when the player
has many routes.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { planetLabel } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const planets = $derived(report?.planets ?? []);
const rows = $derived.by(() => {
const out: {
sourcePlanetNumber: number;
loadType: string;
destinationPlanetNumber: number;
}[] = [];
for (const route of report?.routes ?? []) {
for (const entry of route.entries) {
out.push({
sourcePlanetNumber: route.sourcePlanetNumber,
loadType: entry.loadType,
destinationPlanetNumber: entry.destinationPlanetNumber,
});
}
}
return out;
});
</script>
<section
id="report-cargo-routes"
class="grid-section"
data-testid="report-section-cargo-routes"
>
<h2>{i18n.t("game.report.section.cargo_routes.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="cargo-routes-empty">
{i18n.t("game.report.section.cargo_routes.empty")}
</p>
{:else}
<table class="grid" data-testid="cargo-routes-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.cargo_routes.column.source")}</th>
<th>{i18n.t("game.report.section.cargo_routes.column.load")}</th>
<th>{i18n.t("game.report.section.cargo_routes.column.destination")}</th>
</tr>
</thead>
<tbody>
{#each rows as r (`${r.sourcePlanetNumber}/${r.loadType}`)}
<tr
data-testid="cargo-routes-row"
data-source={r.sourcePlanetNumber}
data-load={r.loadType}
>
<td>{planetLabel(r.sourcePlanetNumber, planets)}</td>
<td>{r.loadType}</td>
<td>{planetLabel(r.destinationPlanetNumber, planets)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,116 @@
<!--
Phase 23 Report View — foreign planets section. Filters `planets[]`
to the `kind === "other"` entries and renders the same column set
as the local planets table plus an `owner` column.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(
(report?.planets ?? []).filter((p) => p.kind === "other"),
);
</script>
<section
id="report-foreign-planets"
class="grid-section"
data-testid="report-section-foreign-planets"
>
<h2>{i18n.t("game.report.section.foreign_planets.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="foreign-planets-empty">
{i18n.t("game.report.section.foreign_planets.empty")}
</p>
{:else}
<table class="grid" data-testid="foreign-planets-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_planets.column.number")}</th>
<th>{i18n.t("game.report.section.my_planets.column.name")}</th>
<th>{i18n.t("game.report.section.foreign_planets.column.owner")}</th>
<th>{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
<th>{i18n.t("game.report.section.my_planets.column.size")}</th>
<th>{i18n.t("game.report.section.my_planets.column.resources")}</th>
<th>{i18n.t("game.report.section.my_planets.column.population")}</th>
<th>{i18n.t("game.report.section.my_planets.column.industry")}</th>
<th>
{i18n.t("game.report.section.my_planets.column.industry_stockpile")}
</th>
<th>
{i18n.t("game.report.section.my_planets.column.materials_stockpile")}
</th>
<th>{i18n.t("game.report.section.my_planets.column.colonists")}</th>
<th>{i18n.t("game.report.section.my_planets.column.production")}</th>
<th>{i18n.t("game.report.section.my_planets.column.free_industry")}</th>
</tr>
</thead>
<tbody>
{#each rows as p (p.number)}
<tr data-testid="foreign-planets-row" data-number={p.number}>
<td>{p.number}</td>
<td>{p.name}</td>
<td>{p.owner ?? ""}</td>
<td>{formatFloat(p.x)}, {formatFloat(p.y)}</td>
<td>{formatFloat(p.size ?? 0)}</td>
<td>{formatFloat(p.resources ?? 0)}</td>
<td>{formatFloat(p.population ?? 0)}</td>
<td>{formatFloat(p.industry ?? 0)}</td>
<td>{formatFloat(p.industryStockpile ?? 0)}</td>
<td>{formatFloat(p.materialsStockpile ?? 0)}</td>
<td>{formatFloat(p.colonists ?? 0)}</td>
<td>{p.production ?? "—"}</td>
<td>{formatFloat(p.freeIndustry ?? 0)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,135 @@
<!--
Phase 23 Report View — foreign sciences section. Renders one
sub-table per race, mirroring the legacy "<Race> Sciences" layout.
Sorted alphabetically by race name (the decoder already produces
the (race, name) order); the sub-table groups are built here so
that anchor navigation to the section lands on a single visual
unit even when the section spans many races.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import type { ReportOtherScience } from "../../../api/game-state";
import { formatPercent } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.otherScience ?? []);
// Decoder already sorts by (race, name); a simple linear walk
// builds an array of {race, rows[]} groups.
const grouped = $derived.by(() => {
const out: { race: string; entries: ReportOtherScience[] }[] = [];
let current: { race: string; entries: ReportOtherScience[] } | null = null;
for (const row of rows) {
if (current === null || current.race !== row.race) {
current = { race: row.race, entries: [] };
out.push(current);
}
current.entries.push(row);
}
return out;
});
</script>
<section
id="report-foreign-sciences"
class="grid-section"
data-testid="report-section-foreign-sciences"
>
<h2>{i18n.t("game.report.section.foreign_sciences.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if grouped.length === 0}
<p class="status" data-testid="foreign-sciences-empty">
{i18n.t("game.report.section.foreign_sciences.empty")}
</p>
{:else}
{#each grouped as group (group.race)}
<h3
class="race-header"
data-testid="report-other-science-race"
data-race={group.race}
>
{i18n.t("game.report.section.foreign_sciences.race_header", {
race: group.race,
})}
</h3>
<table class="grid" data-testid="foreign-sciences-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_sciences.column.name")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.drive")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.weapons")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.shields")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.cargo")}</th>
</tr>
</thead>
<tbody>
{#each group.entries as r (`${r.race}/${r.name}`)}
<tr
data-testid="foreign-sciences-row"
data-race={r.race}
data-name={r.name}
>
<td>{r.name}</td>
<td>{formatPercent(r.drive)}</td>
<td>{formatPercent(r.weapons)}</td>
<td>{formatPercent(r.shields)}</td>
<td>{formatPercent(r.cargo)}</td>
</tr>
{/each}
</tbody>
</table>
{/each}
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.race-header {
margin: 0.75rem 0 0.3rem;
font-size: 0.85rem;
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,137 @@
<!--
Phase 23 Report View — foreign ship classes section. One sub-table
per race (decoder sorts `(race, name)`); columns extend the local
ship-class layout with `mass`, which is exposed on the wire's
`OthersShipClass` and useful for fleet-mass comparison against
incoming groups.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import type { ReportOtherShipClass } from "../../../api/game-state";
import { formatFloat } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.otherShipClass ?? []);
const grouped = $derived.by(() => {
const out: { race: string; entries: ReportOtherShipClass[] }[] = [];
let current: { race: string; entries: ReportOtherShipClass[] } | null =
null;
for (const row of rows) {
if (current === null || current.race !== row.race) {
current = { race: row.race, entries: [] };
out.push(current);
}
current.entries.push(row);
}
return out;
});
</script>
<section
id="report-foreign-ship-classes"
class="grid-section"
data-testid="report-section-foreign-ship-classes"
>
<h2>{i18n.t("game.report.section.foreign_ship_classes.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if grouped.length === 0}
<p class="status" data-testid="foreign-ship-classes-empty">
{i18n.t("game.report.section.foreign_ship_classes.empty")}
</p>
{:else}
{#each grouped as group (group.race)}
<h3
class="race-header"
data-testid="report-other-ship-class-race"
data-race={group.race}
>
{i18n.t("game.report.section.foreign_ship_classes.race_header", {
race: group.race,
})}
</h3>
<table class="grid" data-testid="foreign-ship-classes-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_ship_classes.column.name")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.drive")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.armament")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.weapons")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.shields")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.cargo")}</th>
<th>{i18n.t("game.report.section.foreign_ship_classes.column.mass")}</th>
</tr>
</thead>
<tbody>
{#each group.entries as r (`${r.race}/${r.name}`)}
<tr
data-testid="foreign-ship-classes-row"
data-race={r.race}
data-name={r.name}
>
<td>{r.name}</td>
<td>{formatFloat(r.drive)}</td>
<td>{r.armament}</td>
<td>{formatFloat(r.weapons)}</td>
<td>{formatFloat(r.shields)}</td>
<td>{formatFloat(r.cargo)}</td>
<td>{formatFloat(r.mass)}</td>
</tr>
{/each}
</tbody>
</table>
{/each}
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.race-header {
margin: 0.75rem 0 0.3rem;
font-size: 0.85rem;
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,108 @@
<!--
Phase 23 Report View — foreign ship groups section. `otherShipGroups[]`
omits the local-only fields (id, state, fleet) — those don't apply
to groups the player doesn't own.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat, planetLabel } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.otherShipGroups ?? []);
const planets = $derived(report?.planets ?? []);
function cargoCell(cargo: string, load: number): string {
if (cargo === "NONE") return "—";
return `${cargo} (${formatFloat(load)})`;
}
</script>
<section
id="report-foreign-ship-groups"
class="grid-section"
data-testid="report-section-foreign-ship-groups"
>
<h2>{i18n.t("game.report.section.foreign_ship_groups.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="foreign-ship-groups-empty">
{i18n.t("game.report.section.foreign_ship_groups.empty")}
</p>
{:else}
<table class="grid" data-testid="foreign-ship-groups-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_ship_groups.column.class")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.count")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.cargo")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.destination")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.origin")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.range")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.speed")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.mass")}</th>
</tr>
</thead>
<tbody>
{#each rows as g, i (i)}
<tr data-testid="foreign-ship-groups-row">
<td>{g.class}</td>
<td>{g.count}</td>
<td>{cargoCell(g.cargo, g.load)}</td>
<td>{planetLabel(g.destination, planets)}</td>
<td>
{g.origin === null ? "—" : planetLabel(g.origin, planets)}
</td>
<td>{g.range === null ? "—" : formatFloat(g.range)}</td>
<td>{formatFloat(g.speed)}</td>
<td>{formatFloat(g.mass)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,76 @@
<!--
Phase 23 Report View — galaxy summary section. Renders the per-turn
header data (turn, map dimensions, planet count, calling race name)
as a definition-list. The data lives on `GameReport` directly; the
section is never empty as long as the report has loaded.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
</script>
<section
id="report-galaxy-summary"
class="grid-section"
data-testid="report-section-galaxy-summary"
>
<h2>{i18n.t("game.report.section.galaxy_summary.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else}
<dl class="kv">
<dt>{i18n.t("game.report.section.galaxy_summary.field.turn")}</dt>
<dd data-testid="galaxy-summary-field-turn">{report.turn}</dd>
<dt>{i18n.t("game.report.section.galaxy_summary.field.size")}</dt>
<dd data-testid="galaxy-summary-field-size">
{report.mapWidth} × {report.mapHeight}
</dd>
<dt>{i18n.t("game.report.section.galaxy_summary.field.planets")}</dt>
<dd data-testid="galaxy-summary-field-planets">{report.planetCount}</dd>
<dt>{i18n.t("game.report.section.galaxy_summary.field.race")}</dt>
<dd data-testid="galaxy-summary-field-race">{report.race}</dd>
</dl>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.kv {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.3rem 1rem;
margin: 0;
font-size: 0.9rem;
}
.kv dt {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.kv dd {
margin: 0;
color: #e8eaf6;
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,101 @@
<!--
Phase 23 Report View — my fleets section. Renders `localFleets[]`
with the wire fields. `origin` and `range` are nullable (a fleet
in orbit has neither); empty cells in those columns are normal.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat, planetLabel } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.localFleets ?? []);
const planets = $derived(report?.planets ?? []);
</script>
<section
id="report-my-fleets"
class="grid-section"
data-testid="report-section-my-fleets"
>
<h2>{i18n.t("game.report.section.my_fleets.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="my-fleets-empty">
{i18n.t("game.report.section.my_fleets.empty")}
</p>
{:else}
<table class="grid" data-testid="my-fleets-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_fleets.column.name")}</th>
<th>{i18n.t("game.report.section.my_fleets.column.groups")}</th>
<th>{i18n.t("game.report.section.my_fleets.column.state")}</th>
<th>{i18n.t("game.report.section.my_fleets.column.destination")}</th>
<th>{i18n.t("game.report.section.my_fleets.column.origin")}</th>
<th>{i18n.t("game.report.section.my_fleets.column.range")}</th>
<th>{i18n.t("game.report.section.my_fleets.column.speed")}</th>
</tr>
</thead>
<tbody>
{#each rows as f (f.name)}
<tr data-testid="my-fleets-row" data-name={f.name}>
<td>{f.name}</td>
<td>{f.groupCount}</td>
<td>{f.state}</td>
<td>{planetLabel(f.destination, planets)}</td>
<td>
{f.origin === null ? "—" : planetLabel(f.origin, planets)}
</td>
<td>{f.range === null ? "—" : formatFloat(f.range)}</td>
<td>{formatFloat(f.speed)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,114 @@
<!--
Phase 23 Report View — my planets section. Filters `planets[]` to
the `kind === "local"` entries and renders the full local-planet
column set (matches `ReportPlanet` shape).
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(
(report?.planets ?? []).filter((p) => p.kind === "local"),
);
</script>
<section
id="report-my-planets"
class="grid-section"
data-testid="report-section-my-planets"
>
<h2>{i18n.t("game.report.section.my_planets.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="my-planets-empty">
{i18n.t("game.report.section.my_planets.empty")}
</p>
{:else}
<table class="grid" data-testid="my-planets-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_planets.column.number")}</th>
<th>{i18n.t("game.report.section.my_planets.column.name")}</th>
<th>{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
<th>{i18n.t("game.report.section.my_planets.column.size")}</th>
<th>{i18n.t("game.report.section.my_planets.column.resources")}</th>
<th>{i18n.t("game.report.section.my_planets.column.population")}</th>
<th>{i18n.t("game.report.section.my_planets.column.industry")}</th>
<th>
{i18n.t("game.report.section.my_planets.column.industry_stockpile")}
</th>
<th>
{i18n.t("game.report.section.my_planets.column.materials_stockpile")}
</th>
<th>{i18n.t("game.report.section.my_planets.column.colonists")}</th>
<th>{i18n.t("game.report.section.my_planets.column.production")}</th>
<th>{i18n.t("game.report.section.my_planets.column.free_industry")}</th>
</tr>
</thead>
<tbody>
{#each rows as p (p.number)}
<tr data-testid="my-planets-row" data-number={p.number}>
<td>{p.number}</td>
<td>{p.name}</td>
<td>{formatFloat(p.x)}, {formatFloat(p.y)}</td>
<td>{formatFloat(p.size ?? 0)}</td>
<td>{formatFloat(p.resources ?? 0)}</td>
<td>{formatFloat(p.population ?? 0)}</td>
<td>{formatFloat(p.industry ?? 0)}</td>
<td>{formatFloat(p.industryStockpile ?? 0)}</td>
<td>{formatFloat(p.materialsStockpile ?? 0)}</td>
<td>{formatFloat(p.colonists ?? 0)}</td>
<td>{p.production ?? "—"}</td>
<td>{formatFloat(p.freeIndustry ?? 0)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,95 @@
<!--
Phase 23 Report View — my sciences section. Reads `localScience[]`
from the overlay-applied report (which means pending CreateScience
/ RemoveScience drafts surface here just like on the sciences
table).
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatPercent } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.localScience ?? []);
</script>
<section
id="report-my-sciences"
class="grid-section"
data-testid="report-section-my-sciences"
>
<h2>{i18n.t("game.report.section.my_sciences.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="my-sciences-empty">
{i18n.t("game.report.section.my_sciences.empty")}
</p>
{:else}
<table class="grid" data-testid="my-sciences-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_sciences.column.name")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.drive")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.weapons")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.shields")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.cargo")}</th>
</tr>
</thead>
<tbody>
{#each rows as r (r.name)}
<tr data-testid="my-sciences-row" data-name={r.name}>
<td>{r.name}</td>
<td>{formatPercent(r.drive)}</td>
<td>{formatPercent(r.weapons)}</td>
<td>{formatPercent(r.shields)}</td>
<td>{formatPercent(r.cargo)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,98 @@
<!--
Phase 23 Report View — my ship classes section. Mirrors the
sciences section's layout for `localShipClass[]`, with the
ship-class numeric columns (drive / armament / weapons / shields /
cargo). The overlay-applied report surfaces pending create/remove
drafts immediately, matching the ship-class designer's behaviour.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.localShipClass ?? []);
</script>
<section
id="report-my-ship-classes"
class="grid-section"
data-testid="report-section-my-ship-classes"
>
<h2>{i18n.t("game.report.section.my_ship_classes.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="my-ship-classes-empty">
{i18n.t("game.report.section.my_ship_classes.empty")}
</p>
{:else}
<table class="grid" data-testid="my-ship-classes-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_ship_classes.column.name")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.drive")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.armament")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.weapons")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.shields")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.cargo")}</th>
</tr>
</thead>
<tbody>
{#each rows as r (r.name)}
<tr data-testid="my-ship-classes-row" data-name={r.name}>
<td>{r.name}</td>
<td>{formatFloat(r.drive)}</td>
<td>{r.armament}</td>
<td>{formatFloat(r.weapons)}</td>
<td>{formatFloat(r.shields)}</td>
<td>{formatFloat(r.cargo)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,126 @@
<!--
Phase 23 Report View — my ship groups section. Renders the local
ship groups with a short-form id (first 8 hex chars; the full UUID
is in `data-id` for tests and copy-paste lookups). `cargo` is
shown together with `load` when carrying.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat, planetLabel } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.localShipGroups ?? []);
const planets = $derived(report?.planets ?? []);
function shortId(id: string): string {
return id.slice(0, 8);
}
function cargoCell(
cargo: string,
load: number,
): string {
if (cargo === "NONE") return "—";
return `${cargo} (${formatFloat(load)})`;
}
</script>
<section
id="report-my-ship-groups"
class="grid-section"
data-testid="report-section-my-ship-groups"
>
<h2>{i18n.t("game.report.section.my_ship_groups.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="my-ship-groups-empty">
{i18n.t("game.report.section.my_ship_groups.empty")}
</p>
{:else}
<table class="grid" data-testid="my-ship-groups-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_ship_groups.column.id")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.class")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.count")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.cargo")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.state")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.destination")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.origin")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.range")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.speed")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.mass")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.fleet")}</th>
</tr>
</thead>
<tbody>
{#each rows as g (g.id)}
<tr data-testid="my-ship-groups-row" data-id={g.id}>
<td><span class="uuid">{shortId(g.id)}</span></td>
<td>{g.class}</td>
<td>{g.count}</td>
<td>{cargoCell(g.cargo, g.load)}</td>
<td>{g.state}</td>
<td>{planetLabel(g.destination, planets)}</td>
<td>
{g.origin === null ? "—" : planetLabel(g.origin, planets)}
</td>
<td>{g.range === null ? "—" : formatFloat(g.range)}</td>
<td>{formatFloat(g.speed)}</td>
<td>{formatFloat(g.mass)}</td>
<td>{g.fleet ?? "—"}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
.uuid {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: #cfd7ff;
}
</style>
@@ -0,0 +1,138 @@
<!--
Phase 23 Report View — player status section. Mirrors the legacy
"Status of Players" table: every named row in the FBS player block,
local player included, extinct rows marked with the RIP suffix.
Rows are sorted alphabetically (case-insensitive) by the decoder.
The local player's row gets a "(you)" marker and a visual
highlight so the user can locate themselves quickly.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatCount, formatPercent, formatVotes } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const players = $derived(report?.players ?? []);
</script>
<section
id="report-player-status"
class="grid-section"
data-testid="report-section-player-status"
>
<h2>{i18n.t("game.report.section.player_status.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else}
<table class="grid" data-testid="player-status-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.player_status.column.name")}</th>
<th>{i18n.t("game.report.section.player_status.column.drive")}</th>
<th>{i18n.t("game.report.section.player_status.column.weapons")}</th>
<th>{i18n.t("game.report.section.player_status.column.shields")}</th>
<th>{i18n.t("game.report.section.player_status.column.cargo")}</th>
<th>{i18n.t("game.report.section.player_status.column.population")}</th>
<th>{i18n.t("game.report.section.player_status.column.industry")}</th>
<th>{i18n.t("game.report.section.player_status.column.planets")}</th>
<th>{i18n.t("game.report.section.player_status.column.votes")}</th>
</tr>
</thead>
<tbody>
{#each players as p (p.name)}
<tr
data-testid="player-status-row"
data-name={p.name}
data-local={p.isLocal ? "true" : "false"}
data-extinct={p.extinct ? "true" : "false"}
class:local={p.isLocal}
class:extinct={p.extinct}
>
<td>
<span>{p.name}</span>
{#if p.isLocal}
<span class="marker local-marker">
({i18n.t("game.report.section.player_status.local_marker")})
</span>
{/if}
{#if p.extinct}
<span
class="marker extinct-marker"
data-testid="player-status-extinct-marker"
>
{i18n.t("game.report.section.player_status.extinct_marker")}
</span>
{/if}
</td>
<td>{formatPercent(p.drive)}</td>
<td>{formatPercent(p.weapons)}</td>
<td>{formatPercent(p.shields)}</td>
<td>{formatPercent(p.cargo)}</td>
<td>{formatCount(p.population)}</td>
<td>{formatCount(p.industry)}</td>
<td>{formatCount(p.planets)}</td>
<td>{formatVotes(p.votesReceived)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
.local td {
background: #11203d;
}
.extinct td {
color: #889;
}
.marker {
margin-left: 0.4rem;
font-size: 0.75rem;
color: #aab;
}
.extinct-marker {
color: #c97a7a;
letter-spacing: 0.08em;
}
</style>
@@ -0,0 +1,104 @@
<!--
Phase 23 Report View — ships in production section. Sort follows
the decoder: `(planetNumber, class)` for a stable "find planet N"
scan. The planet name is resolved against `planets[]` so the row
reads `#17 (Castle)` rather than just `#17`.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat, planetLabel } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.shipProductions ?? []);
const planets = $derived(report?.planets ?? []);
</script>
<section
id="report-ships-in-production"
class="grid-section"
data-testid="report-section-ships-in-production"
>
<h2>{i18n.t("game.report.section.ships_in_production.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="ships-in-production-empty">
{i18n.t("game.report.section.ships_in_production.empty")}
</p>
{:else}
<table class="grid" data-testid="ships-in-production-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.ships_in_production.column.planet")}</th>
<th>{i18n.t("game.report.section.ships_in_production.column.class")}</th>
<th>{i18n.t("game.report.section.ships_in_production.column.cost")}</th>
<th>
{i18n.t("game.report.section.ships_in_production.column.prod_used")}
</th>
<th>{i18n.t("game.report.section.ships_in_production.column.percent")}</th>
<th>{i18n.t("game.report.section.ships_in_production.column.free")}</th>
</tr>
</thead>
<tbody>
{#each rows as r (`${r.planetNumber}/${r.class}`)}
<tr
data-testid="ships-in-production-row"
data-planet={r.planetNumber}
data-class={r.class}
>
<td>{planetLabel(r.planetNumber, planets)}</td>
<td>{r.class}</td>
<td>{formatFloat(r.cost)}</td>
<td>{formatFloat(r.prodUsed)}</td>
<td>{(r.percent * 100).toFixed(1)}</td>
<td>{formatFloat(r.freeIndustry)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,88 @@
<!--
Phase 23 Report View — unidentified groups section. The wire's
`UnidentifiedGroup` carries only absolute coordinates — a blip on
radar that doesn't even resolve to a planet.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.unidentifiedShipGroups ?? []);
</script>
<section
id="report-unidentified-groups"
class="grid-section"
data-testid="report-section-unidentified-groups"
>
<h2>{i18n.t("game.report.section.unidentified_groups.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="unidentified-groups-empty">
{i18n.t("game.report.section.unidentified_groups.empty")}
</p>
{:else}
<table class="grid" data-testid="unidentified-groups-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.unidentified_groups.column.x")}</th>
<th>{i18n.t("game.report.section.unidentified_groups.column.y")}</th>
</tr>
</thead>
<tbody>
{#each rows as g, i (i)}
<tr data-testid="unidentified-groups-row">
<td>{formatFloat(g.x)}</td>
<td>{formatFloat(g.y)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,105 @@
<!--
Phase 23 Report View — uninhabited planets section. The wire's
`UninhabitedPlanet` carries number / coordinates / size / resources /
stockpiles, but no production / population / industry — those columns
are intentionally omitted.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(
(report?.planets ?? []).filter((p) => p.kind === "uninhabited"),
);
</script>
<section
id="report-uninhabited-planets"
class="grid-section"
data-testid="report-section-uninhabited-planets"
>
<h2>{i18n.t("game.report.section.uninhabited_planets.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="uninhabited-planets-empty">
{i18n.t("game.report.section.uninhabited_planets.empty")}
</p>
{:else}
<table class="grid" data-testid="uninhabited-planets-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_planets.column.number")}</th>
<th>{i18n.t("game.report.section.my_planets.column.name")}</th>
<th>{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
<th>{i18n.t("game.report.section.my_planets.column.size")}</th>
<th>{i18n.t("game.report.section.my_planets.column.resources")}</th>
<th>
{i18n.t("game.report.section.my_planets.column.industry_stockpile")}
</th>
<th>
{i18n.t("game.report.section.my_planets.column.materials_stockpile")}
</th>
</tr>
</thead>
<tbody>
{#each rows as p (p.number)}
<tr data-testid="uninhabited-planets-row" data-number={p.number}>
<td>{p.number}</td>
<td>{p.name}</td>
<td>{formatFloat(p.x)}, {formatFloat(p.y)}</td>
<td>{formatFloat(p.size ?? 0)}</td>
<td>{formatFloat(p.resources ?? 0)}</td>
<td>{formatFloat(p.industryStockpile ?? 0)}</td>
<td>{formatFloat(p.materialsStockpile ?? 0)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,90 @@
<!--
Phase 23 Report View — unknown planets section. The wire's
`UnidentifiedPlanet` carries only coordinates and number; nothing
else is known.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(
(report?.planets ?? []).filter((p) => p.kind === "unidentified"),
);
</script>
<section
id="report-unknown-planets"
class="grid-section"
data-testid="report-section-unknown-planets"
>
<h2>{i18n.t("game.report.section.unknown_planets.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="unknown-planets-empty">
{i18n.t("game.report.section.unknown_planets.empty")}
</p>
{:else}
<table class="grid" data-testid="unknown-planets-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_planets.column.number")}</th>
<th>{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
</tr>
</thead>
<tbody>
{#each rows as p (p.number)}
<tr data-testid="unknown-planets-row" data-number={p.number}>
<td>{p.number}</td>
<td>{formatFloat(p.x)}, {formatFloat(p.y)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,130 @@
<!--
Phase 23 Report View — votes section. Surfaces the local player's
total vote weight (`myVotes`), the recipient they cast their vote
for (`myVoteFor`), and the per-other-race table of votes received
in the last tally. The full vote graph is not reconstructable from
the client side because each race's outgoing vote target is
private; the section shows only the public datums and mirrors the
explanatory text on the races table.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatVotes } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const races = $derived(report?.races ?? []);
const empty = $derived(report !== null && races.length === 0);
</script>
<section
id="report-votes"
class="grid-section"
data-testid="report-section-votes"
>
<h2>{i18n.t("game.report.section.votes.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else}
<dl class="kv">
<dt>{i18n.t("game.report.section.votes.mine")}</dt>
<dd data-testid="votes-mine">{formatVotes(report.myVotes)}</dd>
<dt>{i18n.t("game.report.section.votes.target")}</dt>
<dd data-testid="votes-target">
{#if report.myVoteFor === ""}
{i18n.t("game.report.section.votes.target_none")}
{:else}
{report.myVoteFor}
{/if}
</dd>
</dl>
{#if empty}
<p class="status" data-testid="votes-empty">
{i18n.t("game.report.section.votes.empty")}
</p>
{:else}
<h3>{i18n.t("game.report.section.votes.received_header")}</h3>
<table class="grid" data-testid="votes-received-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.votes.column.race")}</th>
<th>{i18n.t("game.report.section.votes.column.votes")}</th>
</tr>
</thead>
<tbody>
{#each races as r (r.name)}
<tr data-testid="votes-received-row" data-race={r.name}>
<td>{r.name}</td>
<td>{formatVotes(r.votesReceived)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.grid-section h3 {
margin: 1rem 0 0.4rem;
font-size: 0.85rem;
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.kv {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.3rem 1rem;
margin: 0;
font-size: 0.9rem;
}
.kv dt {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.kv dd {
margin: 0;
color: #e8eaf6;
font-variant-numeric: tabular-nums;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
</style>
+137
View File
@@ -408,6 +408,143 @@ const en = {
"game.inspector.planet.ship_groups.row.mass": "mass {mass}",
"game.inspector.planet.ship_groups.race.unknown": "unknown",
"game.inspector.planet.ship_groups.race.foreign": "foreign",
"game.report.loading": "loading report…",
"game.report.back_to_map": "back to map",
"game.report.toc.title": "sections",
"game.report.toc.mobile_label": "jump to section",
"game.report.section.galaxy_summary.title": "galaxy summary",
"game.report.section.galaxy_summary.field.turn": "turn",
"game.report.section.galaxy_summary.field.size": "map size",
"game.report.section.galaxy_summary.field.planets": "planet count",
"game.report.section.galaxy_summary.field.race": "your race",
"game.report.section.votes.title": "votes",
"game.report.section.votes.mine": "my votes",
"game.report.section.votes.target": "I vote for",
"game.report.section.votes.target_none": "(no recipient yet)",
"game.report.section.votes.received_header": "votes received last tally",
"game.report.section.votes.column.race": "race",
"game.report.section.votes.column.votes": "votes received",
"game.report.section.votes.empty": "no votes cast yet",
"game.report.section.player_status.title": "player status",
"game.report.section.player_status.column.name": "name",
"game.report.section.player_status.column.drive": "drive %",
"game.report.section.player_status.column.weapons": "weapons %",
"game.report.section.player_status.column.shields": "shields %",
"game.report.section.player_status.column.cargo": "cargo %",
"game.report.section.player_status.column.population": "population",
"game.report.section.player_status.column.industry": "production",
"game.report.section.player_status.column.planets": "planets",
"game.report.section.player_status.column.votes": "votes received",
"game.report.section.player_status.local_marker": "you",
"game.report.section.player_status.extinct_marker": "RIP",
"game.report.section.my_sciences.title": "my sciences",
"game.report.section.my_sciences.column.name": "name",
"game.report.section.my_sciences.column.drive": "drive %",
"game.report.section.my_sciences.column.weapons": "weapons %",
"game.report.section.my_sciences.column.shields": "shields %",
"game.report.section.my_sciences.column.cargo": "cargo %",
"game.report.section.my_sciences.empty": "no sciences defined yet",
"game.report.section.foreign_sciences.title": "foreign sciences",
"game.report.section.foreign_sciences.race_header": "{race} sciences",
"game.report.section.foreign_sciences.empty": "no foreign sciences observed yet",
"game.report.section.my_ship_classes.title": "my ship classes",
"game.report.section.my_ship_classes.column.name": "name",
"game.report.section.my_ship_classes.column.drive": "drive",
"game.report.section.my_ship_classes.column.armament": "armament",
"game.report.section.my_ship_classes.column.weapons": "weapons",
"game.report.section.my_ship_classes.column.shields": "shields",
"game.report.section.my_ship_classes.column.cargo": "cargo",
"game.report.section.my_ship_classes.empty": "no ship classes designed yet",
"game.report.section.foreign_ship_classes.title": "foreign ship classes",
"game.report.section.foreign_ship_classes.race_header": "{race} ship classes",
"game.report.section.foreign_ship_classes.column.mass": "mass",
"game.report.section.foreign_ship_classes.empty": "no foreign ship classes observed yet",
"game.report.section.battles.title": "battles",
"game.report.section.battles.empty": "no battles last turn",
"game.report.section.battles.id_label": "battle",
"game.report.section.bombings.title": "bombings",
"game.report.section.bombings.empty": "no bombings last turn",
"game.report.section.bombings.column.planet": "planet",
"game.report.section.bombings.column.owner": "owner",
"game.report.section.bombings.column.attacker": "attacker",
"game.report.section.bombings.column.production": "production",
"game.report.section.bombings.column.industry": "industry",
"game.report.section.bombings.column.population": "population",
"game.report.section.bombings.column.colonists": "colonists",
"game.report.section.bombings.column.industry_stockpile": "industry stockpile ($)",
"game.report.section.bombings.column.materials_stockpile": "materials stockpile (M)",
"game.report.section.bombings.column.attack_power": "attack power",
"game.report.section.bombings.wiped": "wiped",
"game.report.section.approaching_groups.title": "approaching groups",
"game.report.section.approaching_groups.empty": "no approaching groups",
"game.report.section.approaching_groups.column.from": "from",
"game.report.section.approaching_groups.column.to": "to",
"game.report.section.approaching_groups.column.distance": "distance",
"game.report.section.approaching_groups.column.speed": "speed",
"game.report.section.approaching_groups.column.mass": "mass",
"game.report.section.my_planets.title": "my planets",
"game.report.section.my_planets.empty": "no planets owned yet",
"game.report.section.my_planets.column.number": "#",
"game.report.section.my_planets.column.name": "name",
"game.report.section.my_planets.column.coordinates": "x, y",
"game.report.section.my_planets.column.size": "size",
"game.report.section.my_planets.column.resources": "resources",
"game.report.section.my_planets.column.population": "population",
"game.report.section.my_planets.column.industry": "production",
"game.report.section.my_planets.column.industry_stockpile": "$",
"game.report.section.my_planets.column.materials_stockpile": "M",
"game.report.section.my_planets.column.colonists": "colonists",
"game.report.section.my_planets.column.production": "current production",
"game.report.section.my_planets.column.free_industry": "free",
"game.report.section.ships_in_production.title": "ships in production",
"game.report.section.ships_in_production.empty": "no ships in production",
"game.report.section.ships_in_production.column.planet": "planet",
"game.report.section.ships_in_production.column.class": "class",
"game.report.section.ships_in_production.column.cost": "cost",
"game.report.section.ships_in_production.column.prod_used": "invested",
"game.report.section.ships_in_production.column.percent": "percent",
"game.report.section.ships_in_production.column.free": "free industry",
"game.report.section.cargo_routes.title": "cargo routes",
"game.report.section.cargo_routes.empty": "no cargo routes set",
"game.report.section.cargo_routes.column.source": "source",
"game.report.section.cargo_routes.column.load": "load type",
"game.report.section.cargo_routes.column.destination": "destination",
"game.report.section.foreign_planets.title": "foreign planets",
"game.report.section.foreign_planets.empty": "no foreign planets observed",
"game.report.section.foreign_planets.column.owner": "owner",
"game.report.section.uninhabited_planets.title": "uninhabited planets",
"game.report.section.uninhabited_planets.empty": "no uninhabited planets observed",
"game.report.section.unknown_planets.title": "unknown planets",
"game.report.section.unknown_planets.empty": "no unknown planets",
"game.report.section.my_fleets.title": "my fleets",
"game.report.section.my_fleets.empty": "no fleets created yet",
"game.report.section.my_fleets.column.name": "name",
"game.report.section.my_fleets.column.groups": "groups",
"game.report.section.my_fleets.column.state": "state",
"game.report.section.my_fleets.column.destination": "destination",
"game.report.section.my_fleets.column.origin": "origin",
"game.report.section.my_fleets.column.range": "range",
"game.report.section.my_fleets.column.speed": "speed",
"game.report.section.my_ship_groups.title": "my ship groups",
"game.report.section.my_ship_groups.empty": "no ship groups yet",
"game.report.section.my_ship_groups.column.id": "id",
"game.report.section.my_ship_groups.column.class": "class",
"game.report.section.my_ship_groups.column.count": "count",
"game.report.section.my_ship_groups.column.cargo": "cargo",
"game.report.section.my_ship_groups.column.state": "state",
"game.report.section.my_ship_groups.column.destination": "destination",
"game.report.section.my_ship_groups.column.origin": "origin",
"game.report.section.my_ship_groups.column.range": "range",
"game.report.section.my_ship_groups.column.speed": "speed",
"game.report.section.my_ship_groups.column.mass": "mass",
"game.report.section.my_ship_groups.column.fleet": "fleet",
"game.report.section.foreign_ship_groups.title": "foreign ship groups",
"game.report.section.foreign_ship_groups.empty": "no foreign ship groups observed",
"game.report.section.unidentified_groups.title": "unidentified groups",
"game.report.section.unidentified_groups.empty": "no unidentified groups",
"game.report.section.unidentified_groups.column.x": "x",
"game.report.section.unidentified_groups.column.y": "y",
} as const;
export default en;
+137
View File
@@ -409,6 +409,143 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.planet.ship_groups.row.mass": "масса {mass}",
"game.inspector.planet.ship_groups.race.unknown": "неизвестно",
"game.inspector.planet.ship_groups.race.foreign": "чужие",
"game.report.loading": "загрузка отчёта…",
"game.report.back_to_map": "назад к карте",
"game.report.toc.title": "разделы",
"game.report.toc.mobile_label": "перейти к разделу",
"game.report.section.galaxy_summary.title": "общие сведения о галактике",
"game.report.section.galaxy_summary.field.turn": "ход",
"game.report.section.galaxy_summary.field.size": "размер карты",
"game.report.section.galaxy_summary.field.planets": "всего планет",
"game.report.section.galaxy_summary.field.race": "ваша раса",
"game.report.section.votes.title": "голоса",
"game.report.section.votes.mine": "мои голоса",
"game.report.section.votes.target": "голосую за",
"game.report.section.votes.target_none": "(пока никого)",
"game.report.section.votes.received_header": "голосов получено в прошлой раздаче",
"game.report.section.votes.column.race": "раса",
"game.report.section.votes.column.votes": "получено голосов",
"game.report.section.votes.empty": "голосов ещё нет",
"game.report.section.player_status.title": "статус игроков",
"game.report.section.player_status.column.name": "имя",
"game.report.section.player_status.column.drive": "двигатель %",
"game.report.section.player_status.column.weapons": "оружие %",
"game.report.section.player_status.column.shields": "защита %",
"game.report.section.player_status.column.cargo": "трюм %",
"game.report.section.player_status.column.population": "население",
"game.report.section.player_status.column.industry": "производство",
"game.report.section.player_status.column.planets": "планет",
"game.report.section.player_status.column.votes": "получено голосов",
"game.report.section.player_status.local_marker": "вы",
"game.report.section.player_status.extinct_marker": "RIP",
"game.report.section.my_sciences.title": "мои науки",
"game.report.section.my_sciences.column.name": "имя",
"game.report.section.my_sciences.column.drive": "двигатель %",
"game.report.section.my_sciences.column.weapons": "оружие %",
"game.report.section.my_sciences.column.shields": "защита %",
"game.report.section.my_sciences.column.cargo": "трюм %",
"game.report.section.my_sciences.empty": "науки ещё не определены",
"game.report.section.foreign_sciences.title": "науки других рас",
"game.report.section.foreign_sciences.race_header": "науки расы {race}",
"game.report.section.foreign_sciences.empty": "наук других рас пока не видно",
"game.report.section.my_ship_classes.title": "мои классы кораблей",
"game.report.section.my_ship_classes.column.name": "имя",
"game.report.section.my_ship_classes.column.drive": "двигатель",
"game.report.section.my_ship_classes.column.armament": "вооружение",
"game.report.section.my_ship_classes.column.weapons": "оружие",
"game.report.section.my_ship_classes.column.shields": "защита",
"game.report.section.my_ship_classes.column.cargo": "трюм",
"game.report.section.my_ship_classes.empty": "классы кораблей ещё не спроектированы",
"game.report.section.foreign_ship_classes.title": "классы кораблей других рас",
"game.report.section.foreign_ship_classes.race_header": "классы кораблей расы {race}",
"game.report.section.foreign_ship_classes.column.mass": "масса",
"game.report.section.foreign_ship_classes.empty": "классов кораблей других рас пока не видно",
"game.report.section.battles.title": "сражения",
"game.report.section.battles.empty": "сражений в этом ходу не было",
"game.report.section.battles.id_label": "сражение",
"game.report.section.bombings.title": "бомбардировки",
"game.report.section.bombings.empty": "бомбардировок в этом ходу не было",
"game.report.section.bombings.column.planet": "планета",
"game.report.section.bombings.column.owner": "владелец",
"game.report.section.bombings.column.attacker": "атакующий",
"game.report.section.bombings.column.production": "производство",
"game.report.section.bombings.column.industry": "промышленность",
"game.report.section.bombings.column.population": "население",
"game.report.section.bombings.column.colonists": "колонисты",
"game.report.section.bombings.column.industry_stockpile": "запас промышленности ($)",
"game.report.section.bombings.column.materials_stockpile": "запас материалов (M)",
"game.report.section.bombings.column.attack_power": "сила удара",
"game.report.section.bombings.wiped": "уничтожена",
"game.report.section.approaching_groups.title": "приближающиеся группы",
"game.report.section.approaching_groups.empty": "приближающихся групп нет",
"game.report.section.approaching_groups.column.from": "откуда",
"game.report.section.approaching_groups.column.to": "куда",
"game.report.section.approaching_groups.column.distance": "расстояние",
"game.report.section.approaching_groups.column.speed": "скорость",
"game.report.section.approaching_groups.column.mass": "масса",
"game.report.section.my_planets.title": "мои планеты",
"game.report.section.my_planets.empty": "планет пока нет",
"game.report.section.my_planets.column.number": "#",
"game.report.section.my_planets.column.name": "имя",
"game.report.section.my_planets.column.coordinates": "x, y",
"game.report.section.my_planets.column.size": "размер",
"game.report.section.my_planets.column.resources": "ресурсы",
"game.report.section.my_planets.column.population": "население",
"game.report.section.my_planets.column.industry": "производство",
"game.report.section.my_planets.column.industry_stockpile": "$",
"game.report.section.my_planets.column.materials_stockpile": "M",
"game.report.section.my_planets.column.colonists": "колонисты",
"game.report.section.my_planets.column.production": "текущее производство",
"game.report.section.my_planets.column.free_industry": "своб.",
"game.report.section.ships_in_production.title": "в производстве",
"game.report.section.ships_in_production.empty": "в производстве пусто",
"game.report.section.ships_in_production.column.planet": "планета",
"game.report.section.ships_in_production.column.class": "класс",
"game.report.section.ships_in_production.column.cost": "стоимость",
"game.report.section.ships_in_production.column.prod_used": "вложено",
"game.report.section.ships_in_production.column.percent": "процент",
"game.report.section.ships_in_production.column.free": "своб. производство",
"game.report.section.cargo_routes.title": "маршруты грузов",
"game.report.section.cargo_routes.empty": "маршруты не заданы",
"game.report.section.cargo_routes.column.source": "откуда",
"game.report.section.cargo_routes.column.load": "груз",
"game.report.section.cargo_routes.column.destination": "куда",
"game.report.section.foreign_planets.title": "планеты других рас",
"game.report.section.foreign_planets.empty": "чужих планет пока не видно",
"game.report.section.foreign_planets.column.owner": "владелец",
"game.report.section.uninhabited_planets.title": "необитаемые планеты",
"game.report.section.uninhabited_planets.empty": "необитаемых планет пока не видно",
"game.report.section.unknown_planets.title": "неопознанные планеты",
"game.report.section.unknown_planets.empty": "неопознанных планет нет",
"game.report.section.my_fleets.title": "мои флоты",
"game.report.section.my_fleets.empty": "флотов пока нет",
"game.report.section.my_fleets.column.name": "имя",
"game.report.section.my_fleets.column.groups": "групп",
"game.report.section.my_fleets.column.state": "состояние",
"game.report.section.my_fleets.column.destination": "куда",
"game.report.section.my_fleets.column.origin": "откуда",
"game.report.section.my_fleets.column.range": "осталось",
"game.report.section.my_fleets.column.speed": "скорость",
"game.report.section.my_ship_groups.title": "мои группы кораблей",
"game.report.section.my_ship_groups.empty": "групп кораблей пока нет",
"game.report.section.my_ship_groups.column.id": "id",
"game.report.section.my_ship_groups.column.class": "класс",
"game.report.section.my_ship_groups.column.count": "числ.",
"game.report.section.my_ship_groups.column.cargo": "груз",
"game.report.section.my_ship_groups.column.state": "состояние",
"game.report.section.my_ship_groups.column.destination": "куда",
"game.report.section.my_ship_groups.column.origin": "откуда",
"game.report.section.my_ship_groups.column.range": "осталось",
"game.report.section.my_ship_groups.column.speed": "скорость",
"game.report.section.my_ship_groups.column.mass": "масса",
"game.report.section.my_ship_groups.column.fleet": "флот",
"game.report.section.foreign_ship_groups.title": "группы кораблей других рас",
"game.report.section.foreign_ship_groups.empty": "чужих групп пока не видно",
"game.report.section.unidentified_groups.title": "неопознанные группы",
"game.report.section.unidentified_groups.empty": "неопознанных групп нет",
"game.report.section.unidentified_groups.column.x": "x",
"game.report.section.unidentified_groups.column.y": "y",
};
export default ru;