From c58027c03436258881d28e6e396f2100387da4bb Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 11 May 2026 14:33:56 +0200 Subject: [PATCH] ui/phase-23: turn-report view with twenty sections and TOC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` on mobile) +and the scroll position is preserved across active-view switches +via SvelteKit's `Snapshot` API. + ### 6.5 Side effects A successful turn generation publishes a runtime snapshot into the diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 26cd0d9..7f6c39d 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -671,6 +671,16 @@ Per-turn-отчёты — read-only-вью, забираемые из движк Backend авторизует вызывающего и форвардит запрос; в этом пути нет ни кэширования, ни денормализации. +Web-клиент рендерит отчёт как одну секцию на каждый FBS-массив +(общие сведения, голоса, статус игроков, мои / чужие науки, мои / +чужие классы кораблей, сражения, бомбардировки, приближающиеся +группы, мои / чужие / необитаемые / неопознанные планеты, корабли в +производстве, грузовые маршруты, мои флоты, мои / чужие / +неопознанные группы кораблей). Пустые секции получают явную копию +empty-state. Якоря секций отображены в sticky-TOC (на мобильном — +`` at the top of the report body + replaces the desktop anchor sidebar on viewports below 768 px. No + new overlay primitive is introduced; the existing layout-owned + bottom-tab bar stays unobstructed. Picking an option scrolls the + chosen section into view. +6. **Battles section.** Battle UUIDs render as inactive monospace + `` rows until Phase 27 lights up `/games/:id/battle/:battleId`. + The earlier plan to link them now was reverted: a dead link is a + worse experience than a plain identifier, and the rewire when + Phase 27 lands is one line. +7. **Foreign sciences / ship classes layout.** One sub-table per race + with a `{race} sciences` / `{race} ship classes` sub-header. The + `(race, name)` decoder sort produces stable groups; cross-race + sorting is intentionally avoided (it would be semantically + meaningless across races). +8. **Bombings wiped state.** Wiped rows get a `.wiped` CSS class plus + a dedicated `report-bombing-wiped-badge` element so the boolean is + visually explicit and easy to assert in e2e. +9. **Ships in production `prodUsed` derivation (Go side).** The legacy + text reports do not carry the engine's per-turn `ProdUsed` field — + only `Cost`, `Percent`, `Free`. The legacy parser derives an + approximation as `ShipBuildCost(shipMass, material, resources) * percent` + using a new shared helper `pkg/calc.ShipBuildCost`. The engine's + `controller.ProduceShip` was refactored to call the same helper + (behavior-preserving — engine tests stay unchanged and pass). The + approximation is documented in + `tools/local-dev/legacy-report/README.md`; live engine reports come + over FBS and never flow through this parser. +10. **Legacy parser scope.** Per user direction, the parser was + extended to populate `LocalScience`, `OtherScience`, + `OthersShipClass`, `Bombing`, and `ShipProduction` from their + legacy text sections. Battles stay in the parser's Skipped list: + the legacy text carries per-battle rosters with no stable UUID, + and synthesising IDs would invent data Phase 27 would have to + drop. `OtherGroup[]`, `UnidentifiedGroup[]`, and cargo routes + remain skipped (no legacy section). +11. **i18n namespace.** All Phase 23 strings live under + `game.report.section..*`; the duplicate-looking entries + (sciences / ship classes columns) are deliberately separate from + `game.table.*` so the two surfaces evolve independently. ≈90 new + keys, en + ru in lockstep. + ## Phase 24. Push Events — Turn-Ready Status: pending. diff --git a/ui/docs/navigation.md b/ui/docs/navigation.md index 93f3614..ff57868 100644 --- a/ui/docs/navigation.md +++ b/ui/docs/navigation.md @@ -24,7 +24,7 @@ separate dispatch component. | ------------------------------------------ | ---------------------------------------------- | ----------------------- | | `/games/:id/map` | `lib/active-view/map.svelte` | Phase 11 | | `/games/:id/table/:entity` | `lib/active-view/table.svelte` | Phase 11 / 17 / 19 / 22 | -| `/games/:id/report` | `lib/active-view/report.svelte` | Phase 23 | +| `/games/:id/report` | `lib/active-view/report.svelte` (see [report-view.md](report-view.md)) | Phase 23 | | `/games/:id/battle/:battleId?` | `lib/active-view/battle.svelte` | Phase 27 | | `/games/:id/mail` | `lib/active-view/mail.svelte` | Phase 28 | | `/games/:id/designer/ship-class/:classId?` | `lib/active-view/designer-ship-class.svelte` | Phase 17 (CRUD) / 18 (calc preview) | diff --git a/ui/docs/report-view.md b/ui/docs/report-view.md new file mode 100644 index 0000000..6ee99ef --- /dev/null +++ b/ui/docs/report-view.md @@ -0,0 +1,179 @@ +# Report view — Phase 23 + +The Phase 23 in-game "turn report" view is a single scrollable +layout with twenty sections, one per array on the FBS `Report` +table. The route file is the standard two-line wrapper; the +orchestrator and the per-section components live under +`ui/frontend/src/lib/active-view/report/`. + +## Component layout + +`lib/active-view/report.svelte` is the orchestrator. It owns the +section list, instantiates `IntersectionObserver` to track which +section is active, and renders the table of contents alongside the +section column. + +``` +report.svelte +├── report/report-toc.svelte // anchor list + mobile ` takes its place at the top of the body. + Picking an option scrolls the matching section into view. The + mobile contract intentionally avoids stacking another overlay on + top of the existing layout-owned bottom-tabs. + +Both surfaces also expose a "Back to map" affordance +(`report-back-to-map`) at the top. + +The active slug is computed by an `IntersectionObserver` rooted on +the viewport (`root: null`) with `rootMargin: "-30% 0px -60% 0px"`. +The skew biases the active band toward the upper third of the +visible area so that scrolling down advances the highlight +naturally. The observer is created on mount and torn down on +unmount. + +The in-game shell layout (`routes/games/[id]/+layout.svelte`) +expands `
` to fit content rather +than constraining it, so the document body is the actual scroll +container — not the host. The IntersectionObserver root is `null` +to match. + +## Scroll save / restore + +`routes/games/[id]/report/+page.svelte` exports a SvelteKit +`Snapshot<{ scrollY: number }>`: + +- `capture()` reads `window.scrollY` when SvelteKit's + `beforeNavigate` cycle runs. +- `restore(value)` schedules a short + `requestAnimationFrame` poll that waits for + `document.documentElement.scrollHeight` to grow tall enough to + honour the saved offset, then calls `window.scrollTo(0, value)`. + The poll caps at ~60 frames (≈ 1 second) so a never-tall-enough + body never pins a frame loop. + +The capture / restore pair is keyed by route, so: +- Forward navigation from `/report` to `/map` lands `/map` at + scrollY 0 (no snapshot for `/map` to restore from). +- History-back from `/map` to `/report` restores the previously + captured scrollY — the user returns to the same section. + +The Snapshot API does not capture the active sidebar slug; the +IntersectionObserver re-derives it from the restored scroll +position on the next animation frame, which keeps the TOC +highlight consistent without a second source of truth. + +## i18n namespace + +All Phase 23 strings live under `game.report.*`: +- `game.report.loading` — section loading placeholder. +- `game.report.back_to_map`, `game.report.toc.title`, + `game.report.toc.mobile_label` — shell-level strings. +- `game.report.section..title` — section heading. +- `game.report.section..empty` — empty-state copy (where + applicable). +- `game.report.section..column.` — column headings. +- A small number of section-specific keys (`bombings.wiped`, + `player_status.local_marker`, `player_status.extinct_marker`, + `foreign_sciences.race_header`, `foreign_ship_classes.race_header`, + `battles.id_label`, `votes.target_none`). + +The namespace is intentionally separate from `game.table.*` even +where the data shape overlaps (e.g. sciences, ship classes); the +two surfaces evolve independently and a shared key set would +couple them silently. + +## Test seams + +- **Vitest** — four representative specs cover the four section + shapes: kv-list (`report-section-galaxy-summary.test.ts`), grid + with conditional row state (`report-section-bombings.test.ts`), + per-race sub-table (`report-section-foreign-sciences.test.ts`), + TOC (`report-toc.test.ts`). Each spec mounts the component + against a synthetic `RenderedReportSource`, so the orchestrator + / IntersectionObserver are out of scope. +- **Playwright** — `tests/e2e/report-sections.spec.ts` exercises + the full integration: every TOC anchor lands its section in + view, the snapshot mechanism preserves `window.scrollY` on + history navigation, the back-to-map button reaches `/map`, the + mobile ` + {#each entries as entry (entry.slug)} + + {/each} + + + + + diff --git a/ui/frontend/src/lib/active-view/report/section-approaching-groups.svelte b/ui/frontend/src/lib/active-view/report/section-approaching-groups.svelte new file mode 100644 index 0000000..44cd37d --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-approaching-groups.svelte @@ -0,0 +1,99 @@ + + + +
+

{i18n.t("game.report.section.approaching_groups.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else if rows.length === 0} +

+ {i18n.t("game.report.section.approaching_groups.empty")} +

+ {:else} + + + + + + + + + + + + {#each rows as r, i (i)} + + + + + + + + {/each} + +
{i18n.t("game.report.section.approaching_groups.column.from")}{i18n.t("game.report.section.approaching_groups.column.to")} + {i18n.t("game.report.section.approaching_groups.column.distance")} + {i18n.t("game.report.section.approaching_groups.column.speed")}{i18n.t("game.report.section.approaching_groups.column.mass")}
{planetLabel(r.origin, planets)}{planetLabel(r.destination, planets)}{formatFloat(r.distance)}{formatFloat(r.speed)}{formatFloat(r.mass)}
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/report/section-battles.svelte b/ui/frontend/src/lib/active-view/report/section-battles.svelte new file mode 100644 index 0000000..8036818 --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-battles.svelte @@ -0,0 +1,91 @@ + + + +
+

{i18n.t("game.report.section.battles.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else if ids.length === 0} +

+ {i18n.t("game.report.section.battles.empty")} +

+ {:else} +
    + {#each ids as id (id)} +
  • + + {i18n.t("game.report.section.battles.id_label")} + + {id} +
  • + {/each} +
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/report/section-bombings.svelte b/ui/frontend/src/lib/active-view/report/section-bombings.svelte new file mode 100644 index 0000000..60ae565 --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-bombings.svelte @@ -0,0 +1,139 @@ + + + +
+

{i18n.t("game.report.section.bombings.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else if rows.length === 0} +

+ {i18n.t("game.report.section.bombings.empty")} +

+ {:else} + + + + + + + + + + + + + + + + + + {#each rows as b (`${b.planetNumber}/${b.attacker}/${b.owner}`)} + + + + + + + + + + + + + + {/each} + +
{i18n.t("game.report.section.bombings.column.planet")}{i18n.t("game.report.section.bombings.column.owner")}{i18n.t("game.report.section.bombings.column.attacker")}{i18n.t("game.report.section.bombings.column.production")}{i18n.t("game.report.section.bombings.column.industry")}{i18n.t("game.report.section.bombings.column.population")}{i18n.t("game.report.section.bombings.column.colonists")} + {i18n.t("game.report.section.bombings.column.industry_stockpile")} + + {i18n.t("game.report.section.bombings.column.materials_stockpile")} + {i18n.t("game.report.section.bombings.column.attack_power")}
#{b.planetNumber} ({b.planet}){b.owner}{b.attacker}{b.production}{formatFloat(b.industry)}{formatFloat(b.population)}{formatFloat(b.colonists)}{formatFloat(b.industryStockpile)}{formatFloat(b.materialsStockpile)}{formatCount(b.attackPower)} + {#if b.wiped} + + {i18n.t("game.report.section.bombings.wiped")} + + {/if} +
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/report/section-cargo-routes.svelte b/ui/frontend/src/lib/active-view/report/section-cargo-routes.svelte new file mode 100644 index 0000000..cb1a439 --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-cargo-routes.svelte @@ -0,0 +1,114 @@ + + + +
+

{i18n.t("game.report.section.cargo_routes.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else if rows.length === 0} +

+ {i18n.t("game.report.section.cargo_routes.empty")} +

+ {:else} + + + + + + + + + + {#each rows as r (`${r.sourcePlanetNumber}/${r.loadType}`)} + + + + + + {/each} + +
{i18n.t("game.report.section.cargo_routes.column.source")}{i18n.t("game.report.section.cargo_routes.column.load")}{i18n.t("game.report.section.cargo_routes.column.destination")}
{planetLabel(r.sourcePlanetNumber, planets)}{r.loadType}{planetLabel(r.destinationPlanetNumber, planets)}
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/report/section-foreign-planets.svelte b/ui/frontend/src/lib/active-view/report/section-foreign-planets.svelte new file mode 100644 index 0000000..85d5bad --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-foreign-planets.svelte @@ -0,0 +1,116 @@ + + + +
+

{i18n.t("game.report.section.foreign_planets.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else if rows.length === 0} +

+ {i18n.t("game.report.section.foreign_planets.empty")} +

+ {:else} + + + + + + + + + + + + + + + + + + + + {#each rows as p (p.number)} + + + + + + + + + + + + + + + + {/each} + +
{i18n.t("game.report.section.my_planets.column.number")}{i18n.t("game.report.section.my_planets.column.name")}{i18n.t("game.report.section.foreign_planets.column.owner")}{i18n.t("game.report.section.my_planets.column.coordinates")}{i18n.t("game.report.section.my_planets.column.size")}{i18n.t("game.report.section.my_planets.column.resources")}{i18n.t("game.report.section.my_planets.column.population")}{i18n.t("game.report.section.my_planets.column.industry")} + {i18n.t("game.report.section.my_planets.column.industry_stockpile")} + + {i18n.t("game.report.section.my_planets.column.materials_stockpile")} + {i18n.t("game.report.section.my_planets.column.colonists")}{i18n.t("game.report.section.my_planets.column.production")}{i18n.t("game.report.section.my_planets.column.free_industry")}
{p.number}{p.name}{p.owner ?? ""}{formatFloat(p.x)}, {formatFloat(p.y)}{formatFloat(p.size ?? 0)}{formatFloat(p.resources ?? 0)}{formatFloat(p.population ?? 0)}{formatFloat(p.industry ?? 0)}{formatFloat(p.industryStockpile ?? 0)}{formatFloat(p.materialsStockpile ?? 0)}{formatFloat(p.colonists ?? 0)}{p.production ?? "—"}{formatFloat(p.freeIndustry ?? 0)}
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/report/section-foreign-sciences.svelte b/ui/frontend/src/lib/active-view/report/section-foreign-sciences.svelte new file mode 100644 index 0000000..dc3924f --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-foreign-sciences.svelte @@ -0,0 +1,135 @@ + + + +
+

{i18n.t("game.report.section.foreign_sciences.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else if grouped.length === 0} +

+ {i18n.t("game.report.section.foreign_sciences.empty")} +

+ {:else} + {#each grouped as group (group.race)} +

+ {i18n.t("game.report.section.foreign_sciences.race_header", { + race: group.race, + })} +

+ + + + + + + + + + + + {#each group.entries as r (`${r.race}/${r.name}`)} + + + + + + + + {/each} + +
{i18n.t("game.report.section.my_sciences.column.name")}{i18n.t("game.report.section.my_sciences.column.drive")}{i18n.t("game.report.section.my_sciences.column.weapons")}{i18n.t("game.report.section.my_sciences.column.shields")}{i18n.t("game.report.section.my_sciences.column.cargo")}
{r.name}{formatPercent(r.drive)}{formatPercent(r.weapons)}{formatPercent(r.shields)}{formatPercent(r.cargo)}
+ {/each} + {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/report/section-foreign-ship-classes.svelte b/ui/frontend/src/lib/active-view/report/section-foreign-ship-classes.svelte new file mode 100644 index 0000000..96ae769 --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-foreign-ship-classes.svelte @@ -0,0 +1,137 @@ + + + +
+

{i18n.t("game.report.section.foreign_ship_classes.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else if grouped.length === 0} +

+ {i18n.t("game.report.section.foreign_ship_classes.empty")} +

+ {:else} + {#each grouped as group (group.race)} +

+ {i18n.t("game.report.section.foreign_ship_classes.race_header", { + race: group.race, + })} +

+ + + + + + + + + + + + + + {#each group.entries as r (`${r.race}/${r.name}`)} + + + + + + + + + + {/each} + +
{i18n.t("game.report.section.my_ship_classes.column.name")}{i18n.t("game.report.section.my_ship_classes.column.drive")}{i18n.t("game.report.section.my_ship_classes.column.armament")}{i18n.t("game.report.section.my_ship_classes.column.weapons")}{i18n.t("game.report.section.my_ship_classes.column.shields")}{i18n.t("game.report.section.my_ship_classes.column.cargo")}{i18n.t("game.report.section.foreign_ship_classes.column.mass")}
{r.name}{formatFloat(r.drive)}{r.armament}{formatFloat(r.weapons)}{formatFloat(r.shields)}{formatFloat(r.cargo)}{formatFloat(r.mass)}
+ {/each} + {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/report/section-foreign-ship-groups.svelte b/ui/frontend/src/lib/active-view/report/section-foreign-ship-groups.svelte new file mode 100644 index 0000000..3260556 --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-foreign-ship-groups.svelte @@ -0,0 +1,108 @@ + + + +
+

{i18n.t("game.report.section.foreign_ship_groups.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else if rows.length === 0} +

+ {i18n.t("game.report.section.foreign_ship_groups.empty")} +

+ {:else} + + + + + + + + + + + + + + + {#each rows as g, i (i)} + + + + + + + + + + + {/each} + +
{i18n.t("game.report.section.my_ship_groups.column.class")}{i18n.t("game.report.section.my_ship_groups.column.count")}{i18n.t("game.report.section.my_ship_groups.column.cargo")}{i18n.t("game.report.section.my_ship_groups.column.destination")}{i18n.t("game.report.section.my_ship_groups.column.origin")}{i18n.t("game.report.section.my_ship_groups.column.range")}{i18n.t("game.report.section.my_ship_groups.column.speed")}{i18n.t("game.report.section.my_ship_groups.column.mass")}
{g.class}{g.count}{cargoCell(g.cargo, g.load)}{planetLabel(g.destination, planets)} + {g.origin === null ? "—" : planetLabel(g.origin, planets)} + {g.range === null ? "—" : formatFloat(g.range)}{formatFloat(g.speed)}{formatFloat(g.mass)}
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/report/section-galaxy-summary.svelte b/ui/frontend/src/lib/active-view/report/section-galaxy-summary.svelte new file mode 100644 index 0000000..26001c7 --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-galaxy-summary.svelte @@ -0,0 +1,76 @@ + + + +
+

{i18n.t("game.report.section.galaxy_summary.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else} +
+
{i18n.t("game.report.section.galaxy_summary.field.turn")}
+
{report.turn}
+
{i18n.t("game.report.section.galaxy_summary.field.size")}
+
+ {report.mapWidth} × {report.mapHeight} +
+
{i18n.t("game.report.section.galaxy_summary.field.planets")}
+
{report.planetCount}
+
{i18n.t("game.report.section.galaxy_summary.field.race")}
+
{report.race}
+
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/report/section-my-fleets.svelte b/ui/frontend/src/lib/active-view/report/section-my-fleets.svelte new file mode 100644 index 0000000..ead8245 --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-my-fleets.svelte @@ -0,0 +1,101 @@ + + + +
+

{i18n.t("game.report.section.my_fleets.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else if rows.length === 0} +

+ {i18n.t("game.report.section.my_fleets.empty")} +

+ {:else} + + + + + + + + + + + + + + {#each rows as f (f.name)} + + + + + + + + + + {/each} + +
{i18n.t("game.report.section.my_fleets.column.name")}{i18n.t("game.report.section.my_fleets.column.groups")}{i18n.t("game.report.section.my_fleets.column.state")}{i18n.t("game.report.section.my_fleets.column.destination")}{i18n.t("game.report.section.my_fleets.column.origin")}{i18n.t("game.report.section.my_fleets.column.range")}{i18n.t("game.report.section.my_fleets.column.speed")}
{f.name}{f.groupCount}{f.state}{planetLabel(f.destination, planets)} + {f.origin === null ? "—" : planetLabel(f.origin, planets)} + {f.range === null ? "—" : formatFloat(f.range)}{formatFloat(f.speed)}
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/report/section-my-planets.svelte b/ui/frontend/src/lib/active-view/report/section-my-planets.svelte new file mode 100644 index 0000000..478b8d8 --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-my-planets.svelte @@ -0,0 +1,114 @@ + + + +
+

{i18n.t("game.report.section.my_planets.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else if rows.length === 0} +

+ {i18n.t("game.report.section.my_planets.empty")} +

+ {:else} + + + + + + + + + + + + + + + + + + + {#each rows as p (p.number)} + + + + + + + + + + + + + + + {/each} + +
{i18n.t("game.report.section.my_planets.column.number")}{i18n.t("game.report.section.my_planets.column.name")}{i18n.t("game.report.section.my_planets.column.coordinates")}{i18n.t("game.report.section.my_planets.column.size")}{i18n.t("game.report.section.my_planets.column.resources")}{i18n.t("game.report.section.my_planets.column.population")}{i18n.t("game.report.section.my_planets.column.industry")} + {i18n.t("game.report.section.my_planets.column.industry_stockpile")} + + {i18n.t("game.report.section.my_planets.column.materials_stockpile")} + {i18n.t("game.report.section.my_planets.column.colonists")}{i18n.t("game.report.section.my_planets.column.production")}{i18n.t("game.report.section.my_planets.column.free_industry")}
{p.number}{p.name}{formatFloat(p.x)}, {formatFloat(p.y)}{formatFloat(p.size ?? 0)}{formatFloat(p.resources ?? 0)}{formatFloat(p.population ?? 0)}{formatFloat(p.industry ?? 0)}{formatFloat(p.industryStockpile ?? 0)}{formatFloat(p.materialsStockpile ?? 0)}{formatFloat(p.colonists ?? 0)}{p.production ?? "—"}{formatFloat(p.freeIndustry ?? 0)}
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/report/section-my-sciences.svelte b/ui/frontend/src/lib/active-view/report/section-my-sciences.svelte new file mode 100644 index 0000000..c89fa77 --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-my-sciences.svelte @@ -0,0 +1,95 @@ + + + +
+

{i18n.t("game.report.section.my_sciences.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else if rows.length === 0} +

+ {i18n.t("game.report.section.my_sciences.empty")} +

+ {:else} + + + + + + + + + + + + {#each rows as r (r.name)} + + + + + + + + {/each} + +
{i18n.t("game.report.section.my_sciences.column.name")}{i18n.t("game.report.section.my_sciences.column.drive")}{i18n.t("game.report.section.my_sciences.column.weapons")}{i18n.t("game.report.section.my_sciences.column.shields")}{i18n.t("game.report.section.my_sciences.column.cargo")}
{r.name}{formatPercent(r.drive)}{formatPercent(r.weapons)}{formatPercent(r.shields)}{formatPercent(r.cargo)}
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/report/section-my-ship-classes.svelte b/ui/frontend/src/lib/active-view/report/section-my-ship-classes.svelte new file mode 100644 index 0000000..fd492be --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-my-ship-classes.svelte @@ -0,0 +1,98 @@ + + + +
+

{i18n.t("game.report.section.my_ship_classes.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else if rows.length === 0} +

+ {i18n.t("game.report.section.my_ship_classes.empty")} +

+ {:else} + + + + + + + + + + + + + {#each rows as r (r.name)} + + + + + + + + + {/each} + +
{i18n.t("game.report.section.my_ship_classes.column.name")}{i18n.t("game.report.section.my_ship_classes.column.drive")}{i18n.t("game.report.section.my_ship_classes.column.armament")}{i18n.t("game.report.section.my_ship_classes.column.weapons")}{i18n.t("game.report.section.my_ship_classes.column.shields")}{i18n.t("game.report.section.my_ship_classes.column.cargo")}
{r.name}{formatFloat(r.drive)}{r.armament}{formatFloat(r.weapons)}{formatFloat(r.shields)}{formatFloat(r.cargo)}
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/report/section-my-ship-groups.svelte b/ui/frontend/src/lib/active-view/report/section-my-ship-groups.svelte new file mode 100644 index 0000000..d00d81f --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-my-ship-groups.svelte @@ -0,0 +1,126 @@ + + + +
+

{i18n.t("game.report.section.my_ship_groups.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else if rows.length === 0} +

+ {i18n.t("game.report.section.my_ship_groups.empty")} +

+ {:else} + + + + + + + + + + + + + + + + + + {#each rows as g (g.id)} + + + + + + + + + + + + + + {/each} + +
{i18n.t("game.report.section.my_ship_groups.column.id")}{i18n.t("game.report.section.my_ship_groups.column.class")}{i18n.t("game.report.section.my_ship_groups.column.count")}{i18n.t("game.report.section.my_ship_groups.column.cargo")}{i18n.t("game.report.section.my_ship_groups.column.state")}{i18n.t("game.report.section.my_ship_groups.column.destination")}{i18n.t("game.report.section.my_ship_groups.column.origin")}{i18n.t("game.report.section.my_ship_groups.column.range")}{i18n.t("game.report.section.my_ship_groups.column.speed")}{i18n.t("game.report.section.my_ship_groups.column.mass")}{i18n.t("game.report.section.my_ship_groups.column.fleet")}
{shortId(g.id)}{g.class}{g.count}{cargoCell(g.cargo, g.load)}{g.state}{planetLabel(g.destination, planets)} + {g.origin === null ? "—" : planetLabel(g.origin, planets)} + {g.range === null ? "—" : formatFloat(g.range)}{formatFloat(g.speed)}{formatFloat(g.mass)}{g.fleet ?? "—"}
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/report/section-player-status.svelte b/ui/frontend/src/lib/active-view/report/section-player-status.svelte new file mode 100644 index 0000000..0fa3ae9 --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-player-status.svelte @@ -0,0 +1,138 @@ + + + +
+

{i18n.t("game.report.section.player_status.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else} + + + + + + + + + + + + + + + + {#each players as p (p.name)} + + + + + + + + + + + + {/each} + +
{i18n.t("game.report.section.player_status.column.name")}{i18n.t("game.report.section.player_status.column.drive")}{i18n.t("game.report.section.player_status.column.weapons")}{i18n.t("game.report.section.player_status.column.shields")}{i18n.t("game.report.section.player_status.column.cargo")}{i18n.t("game.report.section.player_status.column.population")}{i18n.t("game.report.section.player_status.column.industry")}{i18n.t("game.report.section.player_status.column.planets")}{i18n.t("game.report.section.player_status.column.votes")}
+ {p.name} + {#if p.isLocal} + + ({i18n.t("game.report.section.player_status.local_marker")}) + + {/if} + {#if p.extinct} + + {i18n.t("game.report.section.player_status.extinct_marker")} + + {/if} + {formatPercent(p.drive)}{formatPercent(p.weapons)}{formatPercent(p.shields)}{formatPercent(p.cargo)}{formatCount(p.population)}{formatCount(p.industry)}{formatCount(p.planets)}{formatVotes(p.votesReceived)}
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/report/section-ships-in-production.svelte b/ui/frontend/src/lib/active-view/report/section-ships-in-production.svelte new file mode 100644 index 0000000..5d624e5 --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-ships-in-production.svelte @@ -0,0 +1,104 @@ + + + +
+

{i18n.t("game.report.section.ships_in_production.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else if rows.length === 0} +

+ {i18n.t("game.report.section.ships_in_production.empty")} +

+ {:else} + + + + + + + + + + + + + {#each rows as r (`${r.planetNumber}/${r.class}`)} + + + + + + + + + {/each} + +
{i18n.t("game.report.section.ships_in_production.column.planet")}{i18n.t("game.report.section.ships_in_production.column.class")}{i18n.t("game.report.section.ships_in_production.column.cost")} + {i18n.t("game.report.section.ships_in_production.column.prod_used")} + {i18n.t("game.report.section.ships_in_production.column.percent")}{i18n.t("game.report.section.ships_in_production.column.free")}
{planetLabel(r.planetNumber, planets)}{r.class}{formatFloat(r.cost)}{formatFloat(r.prodUsed)}{(r.percent * 100).toFixed(1)}{formatFloat(r.freeIndustry)}
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/report/section-unidentified-groups.svelte b/ui/frontend/src/lib/active-view/report/section-unidentified-groups.svelte new file mode 100644 index 0000000..03f7d49 --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-unidentified-groups.svelte @@ -0,0 +1,88 @@ + + + +
+

{i18n.t("game.report.section.unidentified_groups.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else if rows.length === 0} +

+ {i18n.t("game.report.section.unidentified_groups.empty")} +

+ {:else} + + + + + + + + + {#each rows as g, i (i)} + + + + + {/each} + +
{i18n.t("game.report.section.unidentified_groups.column.x")}{i18n.t("game.report.section.unidentified_groups.column.y")}
{formatFloat(g.x)}{formatFloat(g.y)}
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/report/section-uninhabited-planets.svelte b/ui/frontend/src/lib/active-view/report/section-uninhabited-planets.svelte new file mode 100644 index 0000000..05f498a --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-uninhabited-planets.svelte @@ -0,0 +1,105 @@ + + + +
+

{i18n.t("game.report.section.uninhabited_planets.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else if rows.length === 0} +

+ {i18n.t("game.report.section.uninhabited_planets.empty")} +

+ {:else} + + + + + + + + + + + + + + {#each rows as p (p.number)} + + + + + + + + + + {/each} + +
{i18n.t("game.report.section.my_planets.column.number")}{i18n.t("game.report.section.my_planets.column.name")}{i18n.t("game.report.section.my_planets.column.coordinates")}{i18n.t("game.report.section.my_planets.column.size")}{i18n.t("game.report.section.my_planets.column.resources")} + {i18n.t("game.report.section.my_planets.column.industry_stockpile")} + + {i18n.t("game.report.section.my_planets.column.materials_stockpile")} +
{p.number}{p.name}{formatFloat(p.x)}, {formatFloat(p.y)}{formatFloat(p.size ?? 0)}{formatFloat(p.resources ?? 0)}{formatFloat(p.industryStockpile ?? 0)}{formatFloat(p.materialsStockpile ?? 0)}
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/report/section-unknown-planets.svelte b/ui/frontend/src/lib/active-view/report/section-unknown-planets.svelte new file mode 100644 index 0000000..332494d --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-unknown-planets.svelte @@ -0,0 +1,90 @@ + + + +
+

{i18n.t("game.report.section.unknown_planets.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else if rows.length === 0} +

+ {i18n.t("game.report.section.unknown_planets.empty")} +

+ {:else} + + + + + + + + + {#each rows as p (p.number)} + + + + + {/each} + +
{i18n.t("game.report.section.my_planets.column.number")}{i18n.t("game.report.section.my_planets.column.coordinates")}
{p.number}{formatFloat(p.x)}, {formatFloat(p.y)}
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/report/section-votes.svelte b/ui/frontend/src/lib/active-view/report/section-votes.svelte new file mode 100644 index 0000000..95d4627 --- /dev/null +++ b/ui/frontend/src/lib/active-view/report/section-votes.svelte @@ -0,0 +1,130 @@ + + + +
+

{i18n.t("game.report.section.votes.title")}

+ + {#if report === null} +

{i18n.t("game.report.loading")}

+ {:else} +
+
{i18n.t("game.report.section.votes.mine")}
+
{formatVotes(report.myVotes)}
+
{i18n.t("game.report.section.votes.target")}
+
+ {#if report.myVoteFor === ""} + {i18n.t("game.report.section.votes.target_none")} + {:else} + {report.myVoteFor} + {/if} +
+
+ + {#if empty} +

+ {i18n.t("game.report.section.votes.empty")} +

+ {:else} +

{i18n.t("game.report.section.votes.received_header")}

+ + + + + + + + + {#each races as r (r.name)} + + + + + {/each} + +
{i18n.t("game.report.section.votes.column.race")}{i18n.t("game.report.section.votes.column.votes")}
{r.name}{formatVotes(r.votesReceived)}
+ {/if} + {/if} +
+ + diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index d2fe904..b73061e 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -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; diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index 2da549d..d0aa8db 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -409,6 +409,143 @@ const ru: Record = { "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; diff --git a/ui/frontend/src/routes/games/[id]/report/+page.svelte b/ui/frontend/src/routes/games/[id]/report/+page.svelte index 385e371..26e6e59 100644 --- a/ui/frontend/src/routes/games/[id]/report/+page.svelte +++ b/ui/frontend/src/routes/games/[id]/report/+page.svelte @@ -1,5 +1,47 @@ + diff --git a/ui/frontend/tests/e2e/fixtures/report-fbs.ts b/ui/frontend/tests/e2e/fixtures/report-fbs.ts index 22ed803..45ffca4 100644 --- a/ui/frontend/tests/e2e/fixtures/report-fbs.ts +++ b/ui/frontend/tests/e2e/fixtures/report-fbs.ts @@ -17,15 +17,20 @@ import { Builder } from "flatbuffers"; +import { UUID } from "../../../src/proto/galaxy/fbs/common"; import { + Bombing, LocalPlanet, OtherPlanet, + OtherScience, + OthersShipClass, Player, Report, Route, RouteEntry, Science, ShipClass, + ShipProduction, UnidentifiedPlanet, UninhabitedPlanet, } from "../../../src/proto/galaxy/fbs/report"; @@ -94,6 +99,39 @@ export interface RouteFixture { entries: RouteEntryFixture[]; } +export interface OtherScienceFixture extends ScienceFixture { + race: string; +} + +export interface OtherShipClassFixture extends ShipClassFixture { + race: string; + mass?: number; +} + +export interface BombingFixture { + planetNumber: number; + planet: string; + owner: string; + attacker: string; + production?: string; + industry?: number; + population?: number; + colonists?: number; + capital?: number; + material?: number; + attackPower?: number; + wiped?: boolean; +} + +export interface ShipProductionFixture { + planet: number; + class: string; + cost?: number; + prodUsed?: number; + percent?: number; + free?: number; +} + export interface ReportFixture { turn: number; mapWidth?: number; @@ -109,6 +147,11 @@ export interface ReportFixture { routes?: RouteFixture[]; myVotes?: number; myVoteFor?: string; + otherScience?: OtherScienceFixture[]; + otherShipClass?: OtherShipClassFixture[]; + battles?: string[]; + bombings?: BombingFixture[]; + shipProductions?: ShipProductionFixture[]; } export function buildReportPayload(fixture: ReportFixture): Uint8Array { @@ -245,6 +288,67 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array { return Route.endRoute(builder); }); + const otherScienceOffsets = (fixture.otherScience ?? []).map((sci) => { + const race = builder.createString(sci.race); + const name = builder.createString(sci.name); + OtherScience.startOtherScience(builder); + OtherScience.addRace(builder, race); + OtherScience.addName(builder, name); + OtherScience.addDrive(builder, sci.drive ?? 0); + OtherScience.addWeapons(builder, sci.weapons ?? 0); + OtherScience.addShields(builder, sci.shields ?? 0); + OtherScience.addCargo(builder, sci.cargo ?? 0); + return OtherScience.endOtherScience(builder); + }); + + const otherShipClassOffsets = (fixture.otherShipClass ?? []).map((cls) => { + const race = builder.createString(cls.race); + const name = builder.createString(cls.name); + OthersShipClass.startOthersShipClass(builder); + OthersShipClass.addRace(builder, race); + OthersShipClass.addName(builder, name); + OthersShipClass.addDrive(builder, cls.drive ?? 0); + OthersShipClass.addArmament(builder, BigInt(cls.armament ?? 0)); + OthersShipClass.addWeapons(builder, cls.weapons ?? 0); + OthersShipClass.addShields(builder, cls.shields ?? 0); + OthersShipClass.addCargo(builder, cls.cargo ?? 0); + OthersShipClass.addMass(builder, cls.mass ?? 0); + return OthersShipClass.endOthersShipClass(builder); + }); + + const bombingOffsets = (fixture.bombings ?? []).map((b) => { + const planet = builder.createString(b.planet); + const owner = builder.createString(b.owner); + const attacker = builder.createString(b.attacker); + const production = builder.createString(b.production ?? ""); + Bombing.startBombing(builder); + Bombing.addNumber(builder, BigInt(b.planetNumber)); + Bombing.addPlanet(builder, planet); + Bombing.addOwner(builder, owner); + Bombing.addAttacker(builder, attacker); + Bombing.addProduction(builder, production); + Bombing.addIndustry(builder, b.industry ?? 0); + Bombing.addPopulation(builder, b.population ?? 0); + Bombing.addColonists(builder, b.colonists ?? 0); + Bombing.addCapital(builder, b.capital ?? 0); + Bombing.addMaterial(builder, b.material ?? 0); + Bombing.addAttackPower(builder, b.attackPower ?? 0); + Bombing.addWiped(builder, b.wiped ?? false); + return Bombing.endBombing(builder); + }); + + const shipProductionOffsets = (fixture.shipProductions ?? []).map((sp) => { + const className = builder.createString(sp.class); + ShipProduction.startShipProduction(builder); + ShipProduction.addPlanet(builder, BigInt(sp.planet)); + ShipProduction.addClass(builder, className); + ShipProduction.addCost(builder, sp.cost ?? 0); + ShipProduction.addProdUsed(builder, sp.prodUsed ?? 0); + ShipProduction.addPercent(builder, sp.percent ?? 0); + ShipProduction.addFree(builder, sp.free ?? 0); + return ShipProduction.endShipProduction(builder); + }); + const localVec = localOffsets.length === 0 ? null @@ -277,6 +381,36 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array { routeOffsets.length === 0 ? null : Report.createRouteVector(builder, routeOffsets); + const otherScienceVec = + otherScienceOffsets.length === 0 + ? null + : Report.createOtherScienceVector(builder, otherScienceOffsets); + const otherShipClassVec = + otherShipClassOffsets.length === 0 + ? null + : Report.createOtherShipClassVector(builder, otherShipClassOffsets); + const bombingVec = + bombingOffsets.length === 0 + ? null + : Report.createBombingVector(builder, bombingOffsets); + const shipProductionVec = + shipProductionOffsets.length === 0 + ? null + : Report.createShipProductionVector(builder, shipProductionOffsets); + // `battle` is a struct vector (16 bytes per UUID, alignment 8), so + // it uses the start/inline-write/end pattern rather than a typical + // offset-list helper. Iterating in reverse matches the FlatBuffers + // convention that the vector is built end-to-start. + const battleVec = (() => { + const ids = fixture.battles ?? []; + if (ids.length === 0) return null; + Report.startBattleVector(builder, ids.length); + for (let i = ids.length - 1; i >= 0; i--) { + const [hi, lo] = uuidToHiLo(ids[i]!); + UUID.createUUID(builder, hi, lo); + } + return builder.endVector(); + })(); const raceOffset = fixture.race === undefined ? null : builder.createString(fixture.race); const voteForOffset = @@ -308,7 +442,25 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array { if (localScienceVec !== null) Report.addLocalScience(builder, localScienceVec); if (routeVec !== null) Report.addRoute(builder, routeVec); + if (otherScienceVec !== null) + Report.addOtherScience(builder, otherScienceVec); + if (otherShipClassVec !== null) + Report.addOtherShipClass(builder, otherShipClassVec); + if (battleVec !== null) Report.addBattle(builder, battleVec); + if (bombingVec !== null) Report.addBombing(builder, bombingVec); + if (shipProductionVec !== null) + Report.addShipProduction(builder, shipProductionVec); const reportOff = Report.endReport(builder); builder.finish(reportOff); return builder.asUint8Array(); } + +function uuidToHiLo(value: string): [bigint, bigint] { + const hex = value.replace(/-/g, "").toLowerCase(); + if (hex.length !== 32 || /[^0-9a-f]/.test(hex)) { + throw new Error(`buildReportPayload: invalid battle uuid ${value}`); + } + const hi = BigInt(`0x${hex.slice(0, 16)}`); + const lo = BigInt(`0x${hex.slice(16, 32)}`); + return [hi, lo]; +} diff --git a/ui/frontend/tests/e2e/report-sections.spec.ts b/ui/frontend/tests/e2e/report-sections.spec.ts new file mode 100644 index 0000000..ba5fe17 --- /dev/null +++ b/ui/frontend/tests/e2e/report-sections.spec.ts @@ -0,0 +1,365 @@ +// Phase 23 end-to-end coverage for the Report View. Mocks the +// gateway with a single seeded report that fills every wire field +// the orchestrator's sections render, then drives the page through +// the targeted-test contract: +// +// 1. Every TOC anchor click scrolls the matching section into view +// and the section is present in the DOM with at least one row +// (or its empty-state copy when it is intentionally empty). +// 2. Snapshot save/restore on the active-view-host scroll +// container survives a /map navigation round-trip. +// 3. The "back to map" button navigates to the map URL. +// 4. The mobile fallback. The +// IntersectionObserver-driven active-section computation lives in +// the orchestrator (`report.svelte`); this test only checks the +// presentational pieces of the TOC. + +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render } from "@testing-library/svelte"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { i18n } from "../src/lib/i18n/index.svelte"; +import type { TranslationKey } from "../src/lib/i18n/index.svelte"; + +const gotoMock = vi.hoisted(() => vi.fn()); +vi.mock("$app/navigation", () => ({ + goto: gotoMock, +})); + +import ReportToc, { + type TocEntry, +} from "../src/lib/active-view/report/report-toc.svelte"; + +const ENTRIES: readonly TocEntry[] = [ + { slug: "galaxy-summary", titleKey: "game.report.section.galaxy_summary.title" }, + { slug: "votes", titleKey: "game.report.section.votes.title" }, + { slug: "bombings", titleKey: "game.report.section.bombings.title" }, +]; + +beforeEach(() => { + i18n.resetForTests("en"); + gotoMock.mockClear(); +}); + +describe("report TOC", () => { + test("renders one anchor per entry and one option in the mobile select", () => { + const ui = render(ReportToc, { + props: { entries: ENTRIES, activeSlug: "galaxy-summary", gameId: "g-1" }, + }); + for (const e of ENTRIES) { + expect(ui.getByTestId(`report-toc-${e.slug}`)).toBeInTheDocument(); + } + const mobile = ui.getByTestId("report-toc-mobile") as HTMLSelectElement; + expect(mobile.options).toHaveLength(ENTRIES.length); + expect(mobile.value).toBe("galaxy-summary"); + }); + + test("marks the active anchor with aria-current=location and a class", () => { + const ui = render(ReportToc, { + props: { entries: ENTRIES, activeSlug: "bombings", gameId: "g-1" }, + }); + const active = ui.getByTestId("report-toc-bombings"); + expect(active).toHaveAttribute("aria-current", "location"); + expect(active).toHaveClass("active"); + + const inactive = ui.getByTestId("report-toc-votes"); + expect(inactive).not.toHaveAttribute("aria-current"); + expect(inactive).not.toHaveClass("active"); + }); + + test("back-to-map button calls goto with the active game's map URL", async () => { + const ui = render(ReportToc, { + props: { + entries: ENTRIES, + activeSlug: "galaxy-summary", + gameId: "abc", + }, + }); + const button = ui.getByTestId("report-back-to-map"); + await fireEvent.click(button); + expect(gotoMock).toHaveBeenCalledWith("/games/abc/map"); + }); + + test("anchor click cancels the default jump and calls scrollIntoView on the target", async () => { + // Stub `scrollIntoView` on the target — jsdom does not + // implement it. The TOC also reads + // `prefers-reduced-motion`; the matchMedia stub forces a + // stable `behavior: "auto"` so the assertion is reproducible. + const scrollSpy = vi.fn(); + const target = document.createElement("section"); + target.id = "report-bombings"; + target.scrollIntoView = scrollSpy; + document.body.appendChild(target); + + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: query.includes("reduce"), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }); + + const ui = render(ReportToc, { + props: { entries: ENTRIES, activeSlug: "galaxy-summary", gameId: "g" }, + }); + await fireEvent.click(ui.getByTestId("report-toc-bombings")); + expect(scrollSpy).toHaveBeenCalledWith({ + behavior: "auto", + block: "start", + }); + target.remove(); + }); + + test("mobile select scrolls to the chosen section without navigating", async () => { + const scrollSpy = vi.fn(); + const target = document.createElement("section"); + target.id = "report-votes"; + target.scrollIntoView = scrollSpy; + document.body.appendChild(target); + + Object.defineProperty(window, "matchMedia", { + writable: true, + value: () => ({ + matches: false, + media: "(prefers-reduced-motion: no-preference)", + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }); + + const ui = render(ReportToc, { + props: { + entries: ENTRIES, + activeSlug: "galaxy-summary", + gameId: "g", + }, + }); + const select = ui.getByTestId("report-toc-mobile") as HTMLSelectElement; + await fireEvent.change(select, { target: { value: "votes" } }); + expect(scrollSpy).toHaveBeenCalled(); + expect(gotoMock).not.toHaveBeenCalled(); + target.remove(); + }); + + // Tests intentionally validate the *type* of the entries prop is + // exposed correctly so future widening of the list does not + // silently drop entries. TypeScript already enforces this through + // `TocEntry`; the assertion below is a soft check so a stray + // `as unknown as ...` cast surfaces fast. + test("TocEntry exposes a slug and a TranslationKey", () => { + const slug: string = ENTRIES[0]!.slug; + const key: TranslationKey = ENTRIES[0]!.titleKey; + expect(typeof slug).toBe("string"); + expect(typeof key).toBe("string"); + }); +});