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:
@@ -38,12 +38,16 @@ import { Builder, ByteBuffer } from "flatbuffers";
|
||||
import type { GalaxyClient } from "./galaxy-client";
|
||||
import { UUID } from "../proto/galaxy/fbs/common";
|
||||
import {
|
||||
Bombing,
|
||||
GameReportRequest,
|
||||
IncomingGroup,
|
||||
LocalFleet,
|
||||
LocalGroup,
|
||||
OtherGroup,
|
||||
OtherScience,
|
||||
OthersShipClass,
|
||||
Report,
|
||||
ShipProduction,
|
||||
UnidentifiedGroup,
|
||||
} from "../proto/galaxy/fbs/report";
|
||||
import type {
|
||||
@@ -280,6 +284,113 @@ export interface ReportOtherRace {
|
||||
votesReceived: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReportPlayer is the per-player projection consumed by the Phase 23
|
||||
* Report View's Player Status section. Unlike `ReportOtherRace`, this
|
||||
* row carries the local player and extinct rows too: the section is a
|
||||
* status overview, not a diplomacy surface. Sorted alphabetically by
|
||||
* name (case-insensitive); `isLocal` flags the calling player's row so
|
||||
* the section can highlight it. The wire `relation` field is
|
||||
* intentionally omitted — the self row carries the engine's "-"
|
||||
* sentinel and the other-race rows already expose it via
|
||||
* `GameReport.races`.
|
||||
*/
|
||||
export interface ReportPlayer {
|
||||
name: string;
|
||||
drive: number;
|
||||
weapons: number;
|
||||
shields: number;
|
||||
cargo: number;
|
||||
population: number;
|
||||
industry: number;
|
||||
planets: number;
|
||||
votesReceived: number;
|
||||
extinct: boolean;
|
||||
isLocal: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReportOtherScience is a single row in the Phase 23 Report View's
|
||||
* Foreign Sciences section. Mirrors the wire `OtherScience` (carries
|
||||
* the owning `race` alongside the four tech proportions). Stable
|
||||
* order: sorted by `(race, name)` so the report's per-race sub-tables
|
||||
* render deterministically.
|
||||
*/
|
||||
export interface ReportOtherScience {
|
||||
race: string;
|
||||
name: string;
|
||||
drive: number;
|
||||
weapons: number;
|
||||
shields: number;
|
||||
cargo: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReportOtherShipClass is a single row in the Phase 23 Report View's
|
||||
* Foreign Ship Classes section. Mirrors the wire `OthersShipClass`
|
||||
* (carries the owning `race`, the five tech-derived numbers, plus the
|
||||
* `mass` the local ship-classes table does not surface — useful for
|
||||
* fleet-mass comparison against incoming groups). Stable order:
|
||||
* sorted by `(race, name)`.
|
||||
*/
|
||||
export interface ReportOtherShipClass {
|
||||
race: string;
|
||||
name: string;
|
||||
drive: number;
|
||||
armament: number;
|
||||
weapons: number;
|
||||
shields: number;
|
||||
cargo: number;
|
||||
mass: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReportBombing is a single row in the Phase 23 Report View's
|
||||
* Bombings section. Mirrors the wire `Bombing` (post-bombing planet
|
||||
* snapshot, attacker/owner identity, attack power, and the boolean
|
||||
* `wiped` flag that drives a visually-distinct row state). Sorted by
|
||||
* `planetNumber` for deterministic rendering.
|
||||
*
|
||||
* Field naming follows the existing `ReportPlanet` convention:
|
||||
* `capital → industryStockpile`, `material → materialsStockpile`,
|
||||
* `number → planetNumber`.
|
||||
*/
|
||||
export interface ReportBombing {
|
||||
planetNumber: number;
|
||||
planet: string;
|
||||
owner: string;
|
||||
attacker: string;
|
||||
production: string;
|
||||
industry: number;
|
||||
population: number;
|
||||
colonists: number;
|
||||
industryStockpile: number;
|
||||
materialsStockpile: number;
|
||||
attackPower: number;
|
||||
wiped: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReportShipProduction is a single row in the Phase 23 Report View's
|
||||
* Ships In Production section. Mirrors the wire `ShipProduction`.
|
||||
* `planetNumber` resolves against `GameReport.planets` so the section
|
||||
* can render the producing planet's name; `cost` is the per-ship
|
||||
* production cost (`ShipProductionCost(shipMass)`, not including the
|
||||
* per-turn material-farming term); `prodUsed` is the engine's residual
|
||||
* production poured into the partial ship this turn; `percent` is the
|
||||
* cumulative build progress as a fraction in [0, 1]; `freeIndustry`
|
||||
* mirrors the producing planet's free industry. Stable order: sorted
|
||||
* by `(planetNumber, class)`.
|
||||
*/
|
||||
export interface ReportShipProduction {
|
||||
planetNumber: number;
|
||||
class: string;
|
||||
cost: number;
|
||||
prodUsed: number;
|
||||
percent: number;
|
||||
freeIndustry: number;
|
||||
}
|
||||
|
||||
export interface GameReport {
|
||||
turn: number;
|
||||
mapWidth: number;
|
||||
@@ -389,6 +500,50 @@ export interface GameReport {
|
||||
* report always carries a non-empty value.
|
||||
*/
|
||||
myVoteFor: string;
|
||||
/**
|
||||
* players is the richer per-player projection Phase 23 added for
|
||||
* the Report View's Player Status section. Same data source as
|
||||
* `races[]` (`report.player[]`) but with the local player and
|
||||
* extinct rows included, sorted alphabetically by name and tagged
|
||||
* with `isLocal`. `races[]` stays Phase 22's view (other,
|
||||
* non-extinct) so diplomatic-stance code paths do not churn.
|
||||
*/
|
||||
players: ReportPlayer[];
|
||||
/**
|
||||
* otherScience is the per-race foreign-sciences projection Phase
|
||||
* 23 added for the Report View's Foreign Sciences section. Sorted
|
||||
* by `(race, name)`. Empty when the report has no foreign science
|
||||
* data (boot state, single-race game, legacy synthetic data).
|
||||
*/
|
||||
otherScience: ReportOtherScience[];
|
||||
/**
|
||||
* otherShipClass is the per-race foreign-ship-classes projection
|
||||
* Phase 23 added for the Report View's Foreign Ship Classes
|
||||
* section. Sorted by `(race, name)`. Empty when the report has no
|
||||
* foreign ship-class data.
|
||||
*/
|
||||
otherShipClass: ReportOtherShipClass[];
|
||||
/**
|
||||
* battleIds is the list of battle UUIDs the engine recorded for
|
||||
* the current turn. Phase 23 renders them as inactive
|
||||
* monospace identifiers; Phase 27 will turn them into navigation
|
||||
* targets once the battle viewer lands. Empty when no battles
|
||||
* occurred last turn.
|
||||
*/
|
||||
battleIds: string[];
|
||||
/**
|
||||
* bombings is the per-bombing projection Phase 23 added for the
|
||||
* Report View's Bombings section. Sorted by `planetNumber`. Empty
|
||||
* when no planets were bombed last turn.
|
||||
*/
|
||||
bombings: ReportBombing[];
|
||||
/**
|
||||
* shipProductions is the per-ship-production projection Phase 23
|
||||
* added for the Report View's Ships In Production section.
|
||||
* Sorted by `(planetNumber, class)`. Empty when no planet is
|
||||
* currently producing a ship.
|
||||
*/
|
||||
shipProductions: ReportShipProduction[];
|
||||
}
|
||||
|
||||
export async function fetchGameReport(
|
||||
@@ -537,11 +692,17 @@ function decodeReport(report: Report): GameReport {
|
||||
const localTech = findLocalPlayerTech(report, raceName);
|
||||
const otherRaces = collectOtherRaces(report, raceName);
|
||||
const races = collectOtherRaceRows(report, raceName);
|
||||
const players = decodePlayers(report, raceName);
|
||||
const localShipGroups = decodeLocalShipGroups(report);
|
||||
const otherShipGroups = decodeOtherShipGroups(report);
|
||||
const incomingShipGroups = decodeIncomingShipGroups(report);
|
||||
const unidentifiedShipGroups = decodeUnidentifiedShipGroups(report);
|
||||
const localFleets = decodeLocalFleets(report);
|
||||
const otherScience = decodeOtherScience(report);
|
||||
const otherShipClass = decodeOtherShipClass(report);
|
||||
const battleIds = decodeBattleIds(report);
|
||||
const bombings = decodeBombings(report);
|
||||
const shipProductions = decodeShipProductions(report);
|
||||
|
||||
return {
|
||||
turn: Number(report.turn()),
|
||||
@@ -566,6 +727,12 @@ function decodeReport(report: Report): GameReport {
|
||||
races,
|
||||
myVotes: report.votes(),
|
||||
myVoteFor: report.voteFor() ?? "",
|
||||
players,
|
||||
otherScience,
|
||||
otherShipClass,
|
||||
battleIds,
|
||||
bombings,
|
||||
shipProductions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -903,6 +1070,146 @@ function collectOtherRaceRows(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* decodePlayers walks `report.player[]` and emits the full status
|
||||
* roster the Phase 23 Report View's Player Status section renders:
|
||||
* every named row including the local player and extinct races,
|
||||
* sorted alphabetically (case-insensitive). The local row carries
|
||||
* `isLocal: true` so the section can highlight it; the wire
|
||||
* `relation` field is intentionally dropped (self carries the engine
|
||||
* "-" sentinel, other rows already surface relation through
|
||||
* `GameReport.races`).
|
||||
*/
|
||||
function decodePlayers(report: Report, raceName: string): ReportPlayer[] {
|
||||
const out: ReportPlayer[] = [];
|
||||
for (let i = 0; i < report.playerLength(); i++) {
|
||||
const player = report.player(i);
|
||||
if (player === null) continue;
|
||||
const name = player.name() ?? "";
|
||||
if (name === "") continue;
|
||||
out.push({
|
||||
name,
|
||||
drive: player.drive(),
|
||||
weapons: player.weapons(),
|
||||
shields: player.shields(),
|
||||
cargo: player.cargo(),
|
||||
population: player.population(),
|
||||
industry: player.industry(),
|
||||
planets: player.planets(),
|
||||
votesReceived: player.votes(),
|
||||
extinct: player.extinct(),
|
||||
isLocal: name === raceName,
|
||||
});
|
||||
}
|
||||
out.sort((a, b) =>
|
||||
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
function decodeOtherScience(report: Report): ReportOtherScience[] {
|
||||
const out: ReportOtherScience[] = [];
|
||||
for (let i = 0; i < report.otherScienceLength(); i++) {
|
||||
const s = report.otherScience(i);
|
||||
if (s === null) continue;
|
||||
out.push({
|
||||
race: s.race() ?? "",
|
||||
name: s.name() ?? "",
|
||||
drive: s.drive(),
|
||||
weapons: s.weapons(),
|
||||
shields: s.shields(),
|
||||
cargo: s.cargo(),
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => {
|
||||
const byRace = a.race.localeCompare(b.race);
|
||||
if (byRace !== 0) return byRace;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function decodeOtherShipClass(report: Report): ReportOtherShipClass[] {
|
||||
const out: ReportOtherShipClass[] = [];
|
||||
for (let i = 0; i < report.otherShipClassLength(); i++) {
|
||||
const sc = report.otherShipClass(i);
|
||||
if (sc === null) continue;
|
||||
out.push({
|
||||
race: sc.race() ?? "",
|
||||
name: sc.name() ?? "",
|
||||
drive: sc.drive(),
|
||||
armament: Number(sc.armament()),
|
||||
weapons: sc.weapons(),
|
||||
shields: sc.shields(),
|
||||
cargo: sc.cargo(),
|
||||
mass: sc.mass(),
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => {
|
||||
const byRace = a.race.localeCompare(b.race);
|
||||
if (byRace !== 0) return byRace;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function decodeBattleIds(report: Report): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < report.battleLength(); i++) {
|
||||
const uuid = report.battle(i);
|
||||
const value = uuidStringFromFB(uuid);
|
||||
if (value === null) continue;
|
||||
out.push(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function decodeBombings(report: Report): ReportBombing[] {
|
||||
const out: ReportBombing[] = [];
|
||||
for (let i = 0; i < report.bombingLength(); i++) {
|
||||
const b = report.bombing(i);
|
||||
if (b === null) continue;
|
||||
out.push({
|
||||
planetNumber: Number(b.number()),
|
||||
planet: b.planet() ?? "",
|
||||
owner: b.owner() ?? "",
|
||||
attacker: b.attacker() ?? "",
|
||||
production: b.production() ?? "",
|
||||
industry: b.industry(),
|
||||
population: b.population(),
|
||||
colonists: b.colonists(),
|
||||
industryStockpile: b.capital(),
|
||||
materialsStockpile: b.material(),
|
||||
attackPower: b.attackPower(),
|
||||
wiped: b.wiped(),
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => a.planetNumber - b.planetNumber);
|
||||
return out;
|
||||
}
|
||||
|
||||
function decodeShipProductions(report: Report): ReportShipProduction[] {
|
||||
const out: ReportShipProduction[] = [];
|
||||
for (let i = 0; i < report.shipProductionLength(); i++) {
|
||||
const sp = report.shipProduction(i);
|
||||
if (sp === null) continue;
|
||||
out.push({
|
||||
planetNumber: Number(sp.planet()),
|
||||
class: sp.class_() ?? "",
|
||||
cost: sp.cost(),
|
||||
prodUsed: sp.prodUsed(),
|
||||
percent: sp.percent(),
|
||||
freeIndustry: sp.free(),
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => {
|
||||
const byPlanet = a.planetNumber - b.planetNumber;
|
||||
if (byPlanet !== 0) return byPlanet;
|
||||
return a.class.localeCompare(b.class);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* uuidToHiLo splits the canonical 36-character UUID string
|
||||
* (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian
|
||||
@@ -1125,6 +1432,16 @@ export function applyOrderOverlay(
|
||||
localScience: mutatedScience ?? report.localScience ?? [],
|
||||
races: mutatedRaces ?? report.races ?? [],
|
||||
myVoteFor: mutatedVoteFor ?? report.myVoteFor,
|
||||
// Phase 23 read-only fields. No overlay branches touch them
|
||||
// today; the `?? []` keeps a stale HMR-instance of `report`
|
||||
// (loaded before the shape bump) from blanking the Report
|
||||
// View when its section components iterate.
|
||||
players: report.players ?? [],
|
||||
otherScience: report.otherScience ?? [],
|
||||
otherShipClass: report.otherShipClass ?? [],
|
||||
battleIds: report.battleIds ?? [],
|
||||
bombings: report.bombings ?? [],
|
||||
shipProductions: report.shipProductions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,13 +20,18 @@
|
||||
|
||||
import type {
|
||||
GameReport,
|
||||
ReportBombing,
|
||||
ReportIncomingShipGroup,
|
||||
ReportLocalFleet,
|
||||
ReportLocalShipGroup,
|
||||
ReportOtherRace,
|
||||
ReportOtherScience,
|
||||
ReportOtherShipClass,
|
||||
ReportOtherShipGroup,
|
||||
ReportPlanet,
|
||||
ReportPlayer,
|
||||
ReportRoute,
|
||||
ReportShipProduction,
|
||||
ReportUnidentifiedShipGroup,
|
||||
ScienceSummary,
|
||||
ShipClassSummary,
|
||||
@@ -159,6 +164,39 @@ interface SyntheticScience {
|
||||
cargo?: number;
|
||||
}
|
||||
|
||||
interface SyntheticOtherScience extends SyntheticScience {
|
||||
race?: string;
|
||||
}
|
||||
|
||||
interface SyntheticOtherShipClass extends SyntheticShipClass {
|
||||
race?: string;
|
||||
mass?: number;
|
||||
}
|
||||
|
||||
interface SyntheticBombing {
|
||||
planet?: number; // wire field "number"
|
||||
planetName?: string; // wire field "planetName"
|
||||
owner?: string;
|
||||
attacker?: string;
|
||||
production?: string;
|
||||
industry?: number;
|
||||
population?: number;
|
||||
colonists?: number;
|
||||
capital?: number;
|
||||
material?: number;
|
||||
attack?: number;
|
||||
wiped?: boolean;
|
||||
}
|
||||
|
||||
interface SyntheticShipProductionRow {
|
||||
planet?: number;
|
||||
class?: string;
|
||||
cost?: number;
|
||||
prodUsed?: number;
|
||||
percent?: number;
|
||||
free?: number;
|
||||
}
|
||||
|
||||
interface SyntheticReportRoot {
|
||||
turn?: number;
|
||||
mapWidth?: number;
|
||||
@@ -173,12 +211,17 @@ interface SyntheticReportRoot {
|
||||
uninhabitedPlanet?: SyntheticPlanet[];
|
||||
unidentifiedPlanet?: SyntheticPlanet[];
|
||||
localShipClass?: SyntheticShipClass[];
|
||||
otherShipClass?: SyntheticOtherShipClass[];
|
||||
localScience?: SyntheticScience[];
|
||||
otherScience?: SyntheticOtherScience[];
|
||||
localGroup?: SyntheticShipGroup[];
|
||||
otherGroup?: SyntheticShipGroup[];
|
||||
incomingGroup?: SyntheticIncomingGroup[];
|
||||
unidentifiedGroup?: SyntheticUnidentifiedGroup[];
|
||||
localFleet?: SyntheticLocalFleet[];
|
||||
battle?: string[];
|
||||
bombing?: SyntheticBombing[];
|
||||
shipProduction?: SyntheticShipProductionRow[];
|
||||
}
|
||||
|
||||
function decodeSyntheticReport(json: unknown): GameReport {
|
||||
@@ -278,6 +321,78 @@ function decodeSyntheticReport(json: unknown): GameReport {
|
||||
state: typeof f.state === "string" ? f.state : "",
|
||||
}));
|
||||
|
||||
const otherScience: ReportOtherScience[] = (root.otherScience ?? []).map(
|
||||
(sc) => ({
|
||||
race: typeof sc.race === "string" ? sc.race : "",
|
||||
name: typeof sc.name === "string" ? sc.name : "",
|
||||
drive: numOr0(sc.drive),
|
||||
weapons: numOr0(sc.weapons),
|
||||
shields: numOr0(sc.shields),
|
||||
cargo: numOr0(sc.cargo),
|
||||
}),
|
||||
);
|
||||
otherScience.sort((a, b) => {
|
||||
const byRace = a.race.localeCompare(b.race);
|
||||
if (byRace !== 0) return byRace;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const otherShipClass: ReportOtherShipClass[] = (root.otherShipClass ?? []).map(
|
||||
(sc) => ({
|
||||
race: typeof sc.race === "string" ? sc.race : "",
|
||||
name: typeof sc.name === "string" ? sc.name : "",
|
||||
drive: numOr0(sc.drive),
|
||||
armament: Math.trunc(numOr0(sc.armament)),
|
||||
weapons: numOr0(sc.weapons),
|
||||
shields: numOr0(sc.shields),
|
||||
cargo: numOr0(sc.cargo),
|
||||
// `mass` is on the wire but synthetic fixtures may omit
|
||||
// it; fall back to 0 rather than reject the row.
|
||||
mass: typeof sc.mass === "number" ? sc.mass : 0,
|
||||
}),
|
||||
);
|
||||
otherShipClass.sort((a, b) => {
|
||||
const byRace = a.race.localeCompare(b.race);
|
||||
if (byRace !== 0) return byRace;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const battleIds: string[] = (root.battle ?? []).filter(
|
||||
(v): v is string => typeof v === "string" && v !== "",
|
||||
);
|
||||
|
||||
const bombings: ReportBombing[] = (root.bombing ?? []).map((b) => ({
|
||||
planetNumber: numOr0(b.planet),
|
||||
planet: typeof b.planetName === "string" ? b.planetName : "",
|
||||
owner: typeof b.owner === "string" ? b.owner : "",
|
||||
attacker: typeof b.attacker === "string" ? b.attacker : "",
|
||||
production: typeof b.production === "string" ? b.production : "",
|
||||
industry: numOr0(b.industry),
|
||||
population: numOr0(b.population),
|
||||
colonists: numOr0(b.colonists),
|
||||
industryStockpile: numOr0(b.capital),
|
||||
materialsStockpile: numOr0(b.material),
|
||||
attackPower: numOr0(b.attack),
|
||||
wiped: b.wiped === true,
|
||||
}));
|
||||
bombings.sort((a, b) => a.planetNumber - b.planetNumber);
|
||||
|
||||
const shipProductions: ReportShipProduction[] = (root.shipProduction ?? []).map(
|
||||
(sp) => ({
|
||||
planetNumber: numOr0(sp.planet),
|
||||
class: typeof sp.class === "string" ? sp.class : "",
|
||||
cost: numOr0(sp.cost),
|
||||
prodUsed: numOr0(sp.prodUsed),
|
||||
percent: numOr0(sp.percent),
|
||||
freeIndustry: numOr0(sp.free),
|
||||
}),
|
||||
);
|
||||
shipProductions.sort((a, b) => {
|
||||
const byPlanet = a.planetNumber - b.planetNumber;
|
||||
if (byPlanet !== 0) return byPlanet;
|
||||
return a.class.localeCompare(b.class);
|
||||
});
|
||||
|
||||
return {
|
||||
turn: numOr0(root.turn),
|
||||
mapWidth: numOr0(root.mapWidth),
|
||||
@@ -301,9 +416,43 @@ function decodeSyntheticReport(json: unknown): GameReport {
|
||||
races: collectOtherRaceRowsFromSynthetic(root, race),
|
||||
myVotes: numOr0(root.votes),
|
||||
myVoteFor: typeof root.voteFor === "string" ? root.voteFor : "",
|
||||
players: collectPlayersFromSynthetic(root, race),
|
||||
otherScience,
|
||||
otherShipClass,
|
||||
battleIds,
|
||||
bombings,
|
||||
shipProductions,
|
||||
};
|
||||
}
|
||||
|
||||
function collectPlayersFromSynthetic(
|
||||
root: SyntheticReportRoot,
|
||||
raceName: string,
|
||||
): ReportPlayer[] {
|
||||
const out: ReportPlayer[] = [];
|
||||
for (const player of root.player ?? []) {
|
||||
const name = typeof player.name === "string" ? player.name : "";
|
||||
if (name === "") continue;
|
||||
out.push({
|
||||
name,
|
||||
drive: numOr0(player.drive),
|
||||
weapons: numOr0(player.weapons),
|
||||
shields: numOr0(player.shields),
|
||||
cargo: numOr0(player.cargo),
|
||||
population: numOr0(player.population),
|
||||
industry: numOr0(player.industry),
|
||||
planets: Math.trunc(numOr0(player.planets)),
|
||||
votesReceived: numOr0(player.votes),
|
||||
extinct: player.extinct === true,
|
||||
isLocal: name === raceName,
|
||||
});
|
||||
}
|
||||
out.sort((a, b) =>
|
||||
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectOtherRacesFromSynthetic(
|
||||
root: SyntheticReportRoot,
|
||||
raceName: string,
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
<!--
|
||||
Phase 23 turn-report route. The orchestrator renders the table of
|
||||
contents and the twenty sections; scroll save/restore is wired
|
||||
through SvelteKit's `Snapshot` API on this route file.
|
||||
`window.scrollY` is captured before navigating away and restored
|
||||
after `afterNavigate` re-mounts the route. The in-game shell
|
||||
layout expands the active-view-host to fit content rather than
|
||||
constraining its own height, so the document body is what scrolls
|
||||
— hence `window.scroll` rather than a host-element scrollTop.
|
||||
|
||||
A short `requestAnimationFrame` poll waits for the body to grow
|
||||
tall enough to honour the saved offset, because the captured
|
||||
position usually exceeds the viewport height before the sections
|
||||
mount on return navigation.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Snapshot } from "@sveltejs/kit";
|
||||
|
||||
import ReportView from "$lib/active-view/report.svelte";
|
||||
|
||||
function restoreScroll(target: number): void {
|
||||
if (target <= 0) return;
|
||||
let attempts = 60;
|
||||
const tick = (): void => {
|
||||
const need = target + window.innerHeight;
|
||||
const have = document.documentElement.scrollHeight;
|
||||
if (have >= need || attempts === 0) {
|
||||
window.scrollTo(0, target);
|
||||
return;
|
||||
}
|
||||
attempts -= 1;
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
export const snapshot: Snapshot<{ scrollY: number }> = {
|
||||
capture() {
|
||||
return { scrollY: window.scrollY };
|
||||
},
|
||||
restore(value) {
|
||||
restoreScroll(value.scrollY);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<ReportView />
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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 <select> fallback scrolls a section into view on
|
||||
// a narrow viewport.
|
||||
|
||||
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { ByteBuffer } from "flatbuffers";
|
||||
|
||||
import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb";
|
||||
import { UUID } from "../../src/proto/galaxy/fbs/common";
|
||||
import { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
|
||||
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
|
||||
import {
|
||||
buildMyGamesListPayload,
|
||||
type GameFixture,
|
||||
} from "./fixtures/lobby-fbs";
|
||||
import { buildReportPayload } from "./fixtures/report-fbs";
|
||||
import {
|
||||
buildOrderGetResponsePayload,
|
||||
buildOrderResponsePayload,
|
||||
type CommandResultFixture,
|
||||
} from "./fixtures/order-fbs";
|
||||
|
||||
const SESSION_ID = "phase-23-report-session";
|
||||
const GAME_ID = "23232323-2323-2323-2323-232323232323";
|
||||
const BATTLE_ID = "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
// SECTIONS lists every TOC slug paired with a row-presence hook.
|
||||
// `expectRow` is null for sections that the seeded report
|
||||
// intentionally leaves empty so the empty-state copy is asserted
|
||||
// instead. The orchestrator's section order must match this
|
||||
// list — the spec relies on each slug having a `report-toc-<slug>`
|
||||
// and a `report-section-<slug>` testid.
|
||||
const SECTIONS: ReadonlyArray<{ slug: string; expectRow: string | null }> = [
|
||||
{ slug: "galaxy-summary", expectRow: "galaxy-summary-field-turn" },
|
||||
{ slug: "votes", expectRow: "votes-mine" },
|
||||
{ slug: "player-status", expectRow: "player-status-row" },
|
||||
{ slug: "my-sciences", expectRow: "my-sciences-row" },
|
||||
{ slug: "foreign-sciences", expectRow: "foreign-sciences-row" },
|
||||
{ slug: "my-ship-classes", expectRow: "my-ship-classes-row" },
|
||||
{ slug: "foreign-ship-classes", expectRow: "foreign-ship-classes-row" },
|
||||
{ slug: "battles", expectRow: "report-battle-row" },
|
||||
{ slug: "bombings", expectRow: "report-bombing-row" },
|
||||
// `incomingShipGroups` cannot be seeded through the current
|
||||
// e2e fixture (no builder); the orchestrator surfaces the
|
||||
// empty-state copy and that is sufficient coverage here.
|
||||
{ slug: "approaching-groups", expectRow: null },
|
||||
{ slug: "my-planets", expectRow: "my-planets-row" },
|
||||
{ slug: "ships-in-production", expectRow: "ships-in-production-row" },
|
||||
// `cargo-routes` is empty in the seeded report — no route fixtures.
|
||||
// The orchestrator surfaces the empty-state copy instead.
|
||||
{ slug: "cargo-routes", expectRow: null },
|
||||
{ slug: "foreign-planets", expectRow: "foreign-planets-row" },
|
||||
{ slug: "uninhabited-planets", expectRow: "uninhabited-planets-row" },
|
||||
{ slug: "unknown-planets", expectRow: "unknown-planets-row" },
|
||||
// `my-fleets`, `my-ship-groups`, `foreign-ship-groups`,
|
||||
// `unidentified-groups` are also empty — seeding them would
|
||||
// require a parallel name-resolution pipeline for the fixture
|
||||
// builder; the empty-state coverage is sufficient for the
|
||||
// acceptance criterion.
|
||||
{ slug: "my-fleets", expectRow: null },
|
||||
{ slug: "my-ship-groups", expectRow: null },
|
||||
{ slug: "foreign-ship-groups", expectRow: null },
|
||||
{ slug: "unidentified-groups", expectRow: null },
|
||||
];
|
||||
|
||||
async function mockGateway(page: Page): Promise<void> {
|
||||
const game: GameFixture = {
|
||||
gameId: GAME_ID,
|
||||
gameName: "Phase 23 Game",
|
||||
gameType: "private",
|
||||
status: "running",
|
||||
ownerUserId: "user-1",
|
||||
minPlayers: 2,
|
||||
maxPlayers: 8,
|
||||
enrollmentEndsAtMs: BigInt(Date.now() + 86_400_000),
|
||||
createdAtMs: BigInt(Date.now() - 86_400_000),
|
||||
updatedAtMs: BigInt(Date.now()),
|
||||
currentTurn: 1,
|
||||
};
|
||||
|
||||
const storedOrder: CommandResultFixture[] = [];
|
||||
|
||||
await page.route(
|
||||
"**/galaxy.gateway.v1.EdgeGateway/ExecuteCommand",
|
||||
async (route) => {
|
||||
const reqText = route.request().postData();
|
||||
if (reqText === null) {
|
||||
await route.fulfill({ status: 400 });
|
||||
return;
|
||||
}
|
||||
const req = fromJson(
|
||||
ExecuteCommandRequestSchema,
|
||||
JSON.parse(reqText) as JsonValue,
|
||||
);
|
||||
|
||||
let resultCode = "ok";
|
||||
let payload: Uint8Array;
|
||||
switch (req.messageType) {
|
||||
case "lobby.my.games.list":
|
||||
payload = buildMyGamesListPayload([game]);
|
||||
break;
|
||||
case "user.games.report": {
|
||||
GameReportRequest.getRootAsGameReportRequest(
|
||||
new ByteBuffer(req.payloadBytes),
|
||||
).gameId(new UUID());
|
||||
payload = buildReportPayload({
|
||||
turn: 1,
|
||||
mapWidth: 4000,
|
||||
mapHeight: 4000,
|
||||
race: "Earthlings",
|
||||
myVotes: 4,
|
||||
myVoteFor: "Andori",
|
||||
players: [
|
||||
{ name: "Earthlings", drive: 1, weapons: 1, shields: 1, cargo: 1, population: 4000, industry: 3000, planets: 2, relation: "-", votes: 4 },
|
||||
{ name: "Andori", drive: 0.8, weapons: 0.6, shields: 0.5, cargo: 0.5, population: 3000, industry: 2500, planets: 2, relation: "PEACE", votes: 3 },
|
||||
{ name: "Bajori", drive: 0.3, weapons: 0.2, shields: 0.2, cargo: 0.3, population: 2000, industry: 1500, planets: 1, relation: "WAR", votes: 2 },
|
||||
{ name: "Cardassian", drive: 0, weapons: 0, shields: 0, cargo: 0, population: 0, industry: 0, planets: 0, relation: "PEACE", votes: 0, extinct: true },
|
||||
],
|
||||
localPlanets: [
|
||||
{ number: 1, name: "Earth", x: 2000, y: 2000, size: 1000, resources: 5, population: 4000, industry: 3000, capital: 0, material: 0, colonists: 100, freeIndustry: 800, production: "Cruiser" },
|
||||
],
|
||||
otherPlanets: [
|
||||
{ number: 2, name: "Andoria", owner: "Andori", x: 2500, y: 2000, size: 800, resources: 4, population: 3000, industry: 2500, capital: 12, material: 7, colonists: 80, freeIndustry: 600, production: "Capital" },
|
||||
],
|
||||
uninhabitedPlanets: [
|
||||
{ number: 3, name: "Rock-1", x: 1800, y: 2300, size: 200, resources: 3, capital: 0, material: 25 },
|
||||
],
|
||||
unidentifiedPlanets: [{ number: 4, x: 2900, y: 1800 }],
|
||||
localShipClass: [
|
||||
{ name: "Cruiser", drive: 10, armament: 2, weapons: 5, shields: 5, cargo: 2 },
|
||||
],
|
||||
localScience: [
|
||||
{ name: "DriveResearch", drive: 1, weapons: 0, shields: 0, cargo: 0 },
|
||||
],
|
||||
otherScience: [
|
||||
{ race: "Andori", name: "AnDrive", drive: 1 },
|
||||
{ race: "Bajori", name: "BjMix", drive: 0.5, cargo: 0.5 },
|
||||
],
|
||||
otherShipClass: [
|
||||
{ race: "Andori", name: "Spear", drive: 8, armament: 4, weapons: 6, shields: 3, cargo: 1, mass: 90 },
|
||||
{ race: "Bajori", name: "Hawk", drive: 12, armament: 1, weapons: 4, shields: 2, cargo: 0, mass: 75 },
|
||||
],
|
||||
battles: [BATTLE_ID],
|
||||
bombings: [
|
||||
{ planetNumber: 1, planet: "Earth", owner: "Earthlings", attacker: "Bajori", production: "Cruiser", industry: 500, population: 200, colonists: 12, capital: 30, material: 5, attackPower: 250, wiped: false },
|
||||
{ planetNumber: 99, planet: "DW-99", owner: "Earthlings", attacker: "Bajori", production: "Dron", industry: 0, population: 0, colonists: 0, capital: 0, material: 0, attackPower: 800, wiped: true },
|
||||
],
|
||||
shipProductions: [
|
||||
{ planet: 1, class: "Cruiser", cost: 100, prodUsed: 25, percent: 0.25, free: 800 },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "user.games.order": {
|
||||
payload = buildOrderResponsePayload(GAME_ID, storedOrder, Date.now());
|
||||
break;
|
||||
}
|
||||
case "user.games.order.get": {
|
||||
payload = buildOrderGetResponsePayload(
|
||||
GAME_ID,
|
||||
storedOrder,
|
||||
Date.now(),
|
||||
false,
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
resultCode = "internal_error";
|
||||
payload = new Uint8Array();
|
||||
}
|
||||
|
||||
const body = await forgeExecuteCommandResponseJson({
|
||||
requestId: req.requestId,
|
||||
timestampMs: BigInt(Date.now()),
|
||||
resultCode,
|
||||
payloadBytes: payload,
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.route(
|
||||
"**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents",
|
||||
async () => {
|
||||
await new Promise<void>(() => {});
|
||||
},
|
||||
);
|
||||
|
||||
// Approaching groups: the wire decoder filters incoming-group
|
||||
// rows whose origin/destination names don't resolve against the
|
||||
// planet tables — but the FBS builder takes raw numbers, so we
|
||||
// inject one row directly through the buildReportPayload helper
|
||||
// extension by re-routing the call in `mockGateway`. The fixture
|
||||
// currently lacks an `incoming` builder; the seed above already
|
||||
// fills bombings/ship-production, so approaching-groups stays
|
||||
// empty here. The orchestrator surfaces the empty-state copy and
|
||||
// the spec records that explicitly via `SECTIONS[].expectRow`.
|
||||
}
|
||||
|
||||
async function bootSession(page: Page): Promise<void> {
|
||||
await page.goto("/__debug/store");
|
||||
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
|
||||
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
|
||||
await page.evaluate(() => window.__galaxyDebug!.clearSession());
|
||||
await page.evaluate(
|
||||
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
|
||||
SESSION_ID,
|
||||
);
|
||||
await page.evaluate(
|
||||
(gameId) => window.__galaxyDebug!.clearOrderDraft(gameId),
|
||||
GAME_ID,
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Phase 23 report view", () => {
|
||||
test("every TOC anchor lands its section in view", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"mobile coverage is the dedicated test below",
|
||||
);
|
||||
|
||||
await mockGateway(page);
|
||||
await bootSession(page);
|
||||
await page.goto(`/games/${GAME_ID}/report`);
|
||||
|
||||
await expect(page.getByTestId("active-view-report")).toBeVisible();
|
||||
await expect(page.getByTestId("report-toc")).toBeVisible();
|
||||
// Wait for the report to land. `galaxy-summary-field-turn`
|
||||
// only mounts once `RenderedReportSource.report !== null`, so
|
||||
// observing it confirms the gateway round-trip completed.
|
||||
await expect(
|
||||
page.getByTestId("galaxy-summary-field-turn"),
|
||||
).toBeVisible();
|
||||
|
||||
for (const entry of SECTIONS) {
|
||||
const anchor = page.getByTestId(`report-toc-${entry.slug}`);
|
||||
await anchor.click();
|
||||
const section = page.getByTestId(`report-section-${entry.slug}`);
|
||||
await expect(section).toBeInViewport();
|
||||
if (entry.expectRow !== null) {
|
||||
const row = section.getByTestId(entry.expectRow).first();
|
||||
await expect(row).toBeVisible();
|
||||
} else {
|
||||
// Empty-state copy is rendered as a single status
|
||||
// paragraph; the section still has visible content.
|
||||
await expect(section).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("scroll position survives a /map round-trip via Snapshot", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"snapshot mechanism is the same on mobile; one project is enough",
|
||||
);
|
||||
|
||||
await mockGateway(page);
|
||||
await bootSession(page);
|
||||
await page.goto(`/games/${GAME_ID}/report`);
|
||||
|
||||
await expect(page.getByTestId("active-view-report")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("galaxy-summary-field-turn"),
|
||||
).toBeVisible();
|
||||
|
||||
// Scroll the window. The report's host expands to fit
|
||||
// content rather than constraining its own height, so the
|
||||
// document body is the real scroll container. SvelteKit's
|
||||
// default scroll-restoration tracks `window.scrollY` on
|
||||
// history navigation, which is what the acceptance criterion
|
||||
// — "scroll position resets when switching to another view
|
||||
// and is restored on return" — requires.
|
||||
const target = 600;
|
||||
await page.evaluate((value) => {
|
||||
window.scrollTo(0, value);
|
||||
}, target);
|
||||
const savedScrollY = await page.evaluate(() => window.scrollY);
|
||||
expect(savedScrollY).toBeGreaterThan(0);
|
||||
|
||||
// Programmatically click the back-to-map button. Driving the
|
||||
// click through `evaluate` rather than the Playwright locator
|
||||
// skips its built-in scrollIntoViewIfNeeded(), which would
|
||||
// otherwise scroll the sticky TOC button into view and reset
|
||||
// `window.scrollY` to 0 before SvelteKit's Snapshot capture
|
||||
// fires.
|
||||
await page.evaluate(() => {
|
||||
const button = document.querySelector(
|
||||
"[data-testid='report-back-to-map']",
|
||||
) as HTMLButtonElement | null;
|
||||
button?.click();
|
||||
});
|
||||
await page.waitForURL(`**/games/${GAME_ID}/map`);
|
||||
await page.goBack();
|
||||
await page.waitForURL(`**/games/${GAME_ID}/report`);
|
||||
await expect(page.getByTestId("active-view-report")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("galaxy-summary-field-turn"),
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => window.scrollY), {
|
||||
timeout: 5_000,
|
||||
intervals: [100, 200, 400],
|
||||
})
|
||||
.toBeGreaterThan(savedScrollY / 2);
|
||||
});
|
||||
|
||||
test("back-to-map button navigates to the map URL", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"navigation is identical on mobile",
|
||||
);
|
||||
|
||||
await mockGateway(page);
|
||||
await bootSession(page);
|
||||
await page.goto(`/games/${GAME_ID}/report`);
|
||||
|
||||
await page.getByTestId("report-back-to-map").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/games/${GAME_ID}/map$`));
|
||||
await expect(page.getByTestId("active-view-map")).toBeVisible();
|
||||
});
|
||||
|
||||
test("mobile select scrolls to the chosen section", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
!testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"desktop branches are covered by the other tests above",
|
||||
);
|
||||
|
||||
await mockGateway(page);
|
||||
await bootSession(page);
|
||||
await page.goto(`/games/${GAME_ID}/report`);
|
||||
|
||||
const mobileSelect = page.getByTestId("report-toc-mobile");
|
||||
await expect(mobileSelect).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("galaxy-summary-field-turn"),
|
||||
).toBeVisible();
|
||||
await mobileSelect.selectOption("bombings");
|
||||
await expect(
|
||||
page.getByTestId("report-section-bombings"),
|
||||
).toBeInViewport();
|
||||
});
|
||||
});
|
||||
@@ -56,12 +56,20 @@ describe("active-view stubs", () => {
|
||||
expect(node).toHaveTextContent("ship groups");
|
||||
});
|
||||
|
||||
test("report / mail stubs render their localised titles", () => {
|
||||
test("report view mounts with the TOC and the back-to-map link", () => {
|
||||
// Phase 23 replaces the Phase 10 stub with the full report
|
||||
// orchestrator. The orchestrator mounts the table of contents
|
||||
// regardless of report state; the inner sections render
|
||||
// loading copy until a `RenderedReportSource` lands via
|
||||
// context. This test only smokes the orchestrator scaffold —
|
||||
// per-section assertions live in `report-section-*.test.ts`.
|
||||
const r = render(ReportView);
|
||||
expect(r.getByTestId("active-view-report")).toHaveTextContent(
|
||||
"turn report",
|
||||
);
|
||||
expect(r.getByTestId("active-view-report")).toBeInTheDocument();
|
||||
expect(r.getByTestId("report-toc")).toBeInTheDocument();
|
||||
expect(r.getByTestId("report-back-to-map")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("mail stub renders its localised title", () => {
|
||||
const m = render(MailView);
|
||||
expect(m.getByTestId("active-view-mail")).toHaveTextContent(
|
||||
"diplomatic mail",
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
// EMPTY_SHIP_GROUPS supplies empty arrays / zero defaults for the
|
||||
// ancillary report fields added in Phase 19 (ship-groups + fleets),
|
||||
// Phase 21 (sciences), and Phase 22 (races / diplomacy / voting).
|
||||
// Phase 21 (sciences), Phase 22 (races / diplomacy / voting), and
|
||||
// Phase 23 (full player roster, foreign sciences, foreign ship
|
||||
// classes, battle ids, bombings, ships in production).
|
||||
// Test fixtures spread it into their report objects so the fixture
|
||||
// body still focuses on the fields under test, without forcing
|
||||
// every spec to enumerate the full GameReport surface.
|
||||
|
||||
import type {
|
||||
ReportBombing,
|
||||
ReportIncomingShipGroup,
|
||||
ReportLocalFleet,
|
||||
ReportLocalShipGroup,
|
||||
ReportOtherRace,
|
||||
ReportOtherScience,
|
||||
ReportOtherShipClass,
|
||||
ReportOtherShipGroup,
|
||||
ReportPlayer,
|
||||
ReportShipProduction,
|
||||
ReportUnidentifiedShipGroup,
|
||||
ScienceSummary,
|
||||
} from "../../src/api/game-state";
|
||||
@@ -26,6 +33,12 @@ export const EMPTY_SHIP_GROUPS: {
|
||||
races: ReportOtherRace[];
|
||||
myVotes: number;
|
||||
myVoteFor: string;
|
||||
players: ReportPlayer[];
|
||||
otherScience: ReportOtherScience[];
|
||||
otherShipClass: ReportOtherShipClass[];
|
||||
battleIds: string[];
|
||||
bombings: ReportBombing[];
|
||||
shipProductions: ReportShipProduction[];
|
||||
} = {
|
||||
localShipGroups: [],
|
||||
otherShipGroups: [],
|
||||
@@ -37,4 +50,10 @@ export const EMPTY_SHIP_GROUPS: {
|
||||
races: [],
|
||||
myVotes: 0,
|
||||
myVoteFor: "",
|
||||
players: [],
|
||||
otherScience: [],
|
||||
otherShipClass: [],
|
||||
battleIds: [],
|
||||
bombings: [],
|
||||
shipProductions: [],
|
||||
};
|
||||
|
||||
@@ -72,6 +72,12 @@ function makeReport(
|
||||
races: [],
|
||||
myVotes: 0,
|
||||
myVoteFor: "",
|
||||
players: [],
|
||||
otherScience: [],
|
||||
otherShipClass: [],
|
||||
battleIds: [],
|
||||
bombings: [],
|
||||
shipProductions: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Vitest coverage for the Phase 23 Report View's bombings section.
|
||||
// Representative for grid-shape sections (foreign/uninhabited
|
||||
// planets, fleets, ship-groups, ships-in-production). Three
|
||||
// scenarios — empty list, populated row, wiped row with badge —
|
||||
// cover the empty-state copy and the conditional row state.
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import type {
|
||||
GameReport,
|
||||
ReportBombing,
|
||||
} from "../src/api/game-state";
|
||||
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte";
|
||||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||
|
||||
import SectionBombings from "../src/lib/active-view/report/section-bombings.svelte";
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.resetForTests("en");
|
||||
});
|
||||
|
||||
function bombing(
|
||||
overrides: Partial<ReportBombing> &
|
||||
Pick<ReportBombing, "planetNumber" | "planet" | "attacker">,
|
||||
): ReportBombing {
|
||||
return {
|
||||
owner: "Owner",
|
||||
production: "Capital",
|
||||
industry: 0,
|
||||
population: 0,
|
||||
colonists: 0,
|
||||
industryStockpile: 0,
|
||||
materialsStockpile: 0,
|
||||
attackPower: 0,
|
||||
wiped: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeReport(rows: ReportBombing[]): GameReport {
|
||||
return {
|
||||
turn: 1,
|
||||
mapWidth: 1000,
|
||||
mapHeight: 1000,
|
||||
planetCount: 0,
|
||||
planets: [],
|
||||
race: "Self",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
...EMPTY_SHIP_GROUPS,
|
||||
bombings: rows,
|
||||
};
|
||||
}
|
||||
|
||||
function mountSection(report: GameReport | null) {
|
||||
const context = new Map<unknown, unknown>([
|
||||
[RENDERED_REPORT_CONTEXT_KEY, { get report() {
|
||||
return report;
|
||||
} }],
|
||||
]);
|
||||
return render(SectionBombings, { context });
|
||||
}
|
||||
|
||||
describe("report bombings section", () => {
|
||||
test("renders the loading placeholder before the report lands", () => {
|
||||
const ui = mountSection(null);
|
||||
expect(ui.getByTestId("report-section-bombings")).toHaveTextContent(
|
||||
"loading report",
|
||||
);
|
||||
});
|
||||
|
||||
test("renders the empty-state copy when there are no bombings", () => {
|
||||
const ui = mountSection(makeReport([]));
|
||||
expect(ui.getByTestId("bombings-empty")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders a non-wiped row without the wiped badge", () => {
|
||||
const ui = mountSection(
|
||||
makeReport([
|
||||
bombing({
|
||||
planetNumber: 17,
|
||||
planet: "Castle",
|
||||
attacker: "Ricksha",
|
||||
owner: "Earthlings",
|
||||
production: "Capital",
|
||||
industry: 500.25,
|
||||
population: 200,
|
||||
colonists: 12,
|
||||
industryStockpile: 30,
|
||||
materialsStockpile: 5,
|
||||
attackPower: 250,
|
||||
wiped: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const rows = ui.getAllByTestId("report-bombing-row");
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toHaveAttribute("data-planet", "17");
|
||||
expect(rows[0]).toHaveAttribute("data-wiped", "false");
|
||||
expect(rows[0]).not.toHaveClass("wiped");
|
||||
expect(rows[0]).toHaveTextContent("#17 (Castle)");
|
||||
expect(rows[0]).toHaveTextContent("Ricksha");
|
||||
expect(ui.queryByTestId("report-bombing-wiped-badge")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders a wiped row with the wiped badge and the row state", () => {
|
||||
const ui = mountSection(
|
||||
makeReport([
|
||||
bombing({
|
||||
planetNumber: 20,
|
||||
planet: "DW-1207",
|
||||
attacker: "Ricksha",
|
||||
owner: "KnightErrants",
|
||||
production: "Dron",
|
||||
industry: 1.5,
|
||||
attackPower: 7.62,
|
||||
wiped: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const row = ui.getByTestId("report-bombing-row");
|
||||
expect(row).toHaveAttribute("data-wiped", "true");
|
||||
expect(row).toHaveClass("wiped");
|
||||
expect(ui.getByTestId("report-bombing-wiped-badge")).toHaveTextContent(
|
||||
"wiped",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// Vitest coverage for the Phase 23 Report View's foreign sciences
|
||||
// section. Representative for the per-race sub-table shape used by
|
||||
// `section-foreign-ship-classes.svelte` too. Three scenarios — empty
|
||||
// list, single-race table, multi-race grouping — exercise the
|
||||
// decoder's `(race, name)` order and the per-race sub-header.
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import type {
|
||||
GameReport,
|
||||
ReportOtherScience,
|
||||
} from "../src/api/game-state";
|
||||
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte";
|
||||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||
|
||||
import SectionForeignSciences from "../src/lib/active-view/report/section-foreign-sciences.svelte";
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.resetForTests("en");
|
||||
});
|
||||
|
||||
function science(
|
||||
overrides: Partial<ReportOtherScience> &
|
||||
Pick<ReportOtherScience, "race" | "name">,
|
||||
): ReportOtherScience {
|
||||
return {
|
||||
drive: 0,
|
||||
weapons: 0,
|
||||
shields: 0,
|
||||
cargo: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeReport(rows: ReportOtherScience[]): GameReport {
|
||||
return {
|
||||
turn: 1,
|
||||
mapWidth: 1000,
|
||||
mapHeight: 1000,
|
||||
planetCount: 0,
|
||||
planets: [],
|
||||
race: "Self",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
...EMPTY_SHIP_GROUPS,
|
||||
otherScience: rows,
|
||||
};
|
||||
}
|
||||
|
||||
function mountSection(report: GameReport | null) {
|
||||
const context = new Map<unknown, unknown>([
|
||||
[RENDERED_REPORT_CONTEXT_KEY, { get report() {
|
||||
return report;
|
||||
} }],
|
||||
]);
|
||||
return render(SectionForeignSciences, { context });
|
||||
}
|
||||
|
||||
describe("report foreign sciences section", () => {
|
||||
test("renders the empty-state copy when no foreign sciences are observed", () => {
|
||||
const ui = mountSection(makeReport([]));
|
||||
expect(ui.getByTestId("foreign-sciences-empty")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders one sub-table per race with rows sorted by name", () => {
|
||||
const ui = mountSection(
|
||||
makeReport([
|
||||
science({ race: "Andori", name: "AnDrive", drive: 1 }),
|
||||
science({ race: "Andori", name: "AnCargo", cargo: 1 }),
|
||||
science({ race: "Bajori", name: "BjMix", drive: 0.5, cargo: 0.5 }),
|
||||
]),
|
||||
);
|
||||
const headers = ui.getAllByTestId("report-other-science-race");
|
||||
expect(headers).toHaveLength(2);
|
||||
expect(headers[0]).toHaveAttribute("data-race", "Andori");
|
||||
expect(headers[1]).toHaveAttribute("data-race", "Bajori");
|
||||
|
||||
const rows = ui.getAllByTestId("foreign-sciences-row");
|
||||
expect(rows).toHaveLength(3);
|
||||
// Andori sub-table comes first; its rows precede Bajori.
|
||||
expect(rows[0]).toHaveAttribute("data-race", "Andori");
|
||||
expect(rows[0]).toHaveAttribute("data-name", "AnDrive");
|
||||
expect(rows[1]).toHaveAttribute("data-race", "Andori");
|
||||
expect(rows[1]).toHaveAttribute("data-name", "AnCargo");
|
||||
expect(rows[2]).toHaveAttribute("data-race", "Bajori");
|
||||
expect(rows[2]).toHaveAttribute("data-name", "BjMix");
|
||||
});
|
||||
|
||||
test("renders a single race block when only one foreign science is present", () => {
|
||||
const ui = mountSection(
|
||||
makeReport([science({ race: "Solo", name: "Singularity", drive: 1 })]),
|
||||
);
|
||||
const headers = ui.getAllByTestId("report-other-science-race");
|
||||
expect(headers).toHaveLength(1);
|
||||
expect(headers[0]).toHaveTextContent("Solo sciences");
|
||||
const rows = ui.getAllByTestId("foreign-sciences-row");
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// Vitest coverage for the Phase 23 Report View's galaxy summary
|
||||
// section. Representative for kv-list-shape sections (votes,
|
||||
// player-status row markers). Mounts the component against a
|
||||
// synthetic `RenderedReportSource` so the test focuses on shape,
|
||||
// not on the live store wiring.
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import type { GameReport } from "../src/api/game-state";
|
||||
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte";
|
||||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||
|
||||
import SectionGalaxySummary from "../src/lib/active-view/report/section-galaxy-summary.svelte";
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.resetForTests("en");
|
||||
});
|
||||
|
||||
function makeReport(overrides: Partial<GameReport> = {}): GameReport {
|
||||
return {
|
||||
turn: 0,
|
||||
mapWidth: 0,
|
||||
mapHeight: 0,
|
||||
planetCount: 0,
|
||||
planets: [],
|
||||
race: "",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
...EMPTY_SHIP_GROUPS,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mountSection(report: GameReport | null) {
|
||||
const context = new Map<unknown, unknown>([
|
||||
[RENDERED_REPORT_CONTEXT_KEY, { get report() {
|
||||
return report;
|
||||
} }],
|
||||
]);
|
||||
return render(SectionGalaxySummary, { context });
|
||||
}
|
||||
|
||||
describe("report galaxy summary section", () => {
|
||||
test("renders the loading placeholder before the report lands", () => {
|
||||
const ui = mountSection(null);
|
||||
expect(
|
||||
ui.getByTestId("report-section-galaxy-summary"),
|
||||
).toHaveTextContent("loading report");
|
||||
});
|
||||
|
||||
test("renders every kv pair for a populated report", () => {
|
||||
const ui = mountSection(
|
||||
makeReport({
|
||||
turn: 42,
|
||||
mapWidth: 1234,
|
||||
mapHeight: 4321,
|
||||
planetCount: 700,
|
||||
race: "KnightErrants",
|
||||
}),
|
||||
);
|
||||
expect(ui.getByTestId("galaxy-summary-field-turn")).toHaveTextContent("42");
|
||||
expect(ui.getByTestId("galaxy-summary-field-size")).toHaveTextContent(
|
||||
"1234 × 4321",
|
||||
);
|
||||
expect(ui.getByTestId("galaxy-summary-field-planets")).toHaveTextContent(
|
||||
"700",
|
||||
);
|
||||
expect(ui.getByTestId("galaxy-summary-field-race")).toHaveTextContent(
|
||||
"KnightErrants",
|
||||
);
|
||||
});
|
||||
|
||||
test("zero-value boot state still mounts every field", () => {
|
||||
const ui = mountSection(makeReport());
|
||||
// Loading state must be gone — the kv-list takes over.
|
||||
const section = ui.getByTestId("report-section-galaxy-summary");
|
||||
expect(section).not.toHaveTextContent("loading report");
|
||||
expect(ui.getByTestId("galaxy-summary-field-turn")).toHaveTextContent("0");
|
||||
expect(ui.getByTestId("galaxy-summary-field-race")).toHaveTextContent("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
// Vitest coverage for the Phase 23 Report View's table of contents.
|
||||
// Smokes the anchor list, the active-link state, the back-to-map
|
||||
// navigation, and the mobile <select> 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user