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>