Compare commits

2 Commits

Author SHA1 Message Date
Ilia Denisov 5a2a977dc6 ui/phase-23: mark stage done after local-ci run 2
ui-test / test (push) Failing after 2m11s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:41:35 +02:00
Ilia Denisov c58027c034 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>
2026-05-11 14:33:56 +02:00
48 changed files with 5370 additions and 105 deletions
+10
View File
@@ -654,6 +654,16 @@ Per-turn reports are read-only views fetched from the engine on
demand. Backend authorises the caller and forwards the request; demand. Backend authorises the caller and forwards the request;
there is no caching or denormalisation in this path. there is no caching or denormalisation in this path.
The web client renders the report as one section per FBS array
(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).
Empty sections render explicit empty-state copy. Section anchors
are exposed in a sticky table of contents (a `<select>` on mobile)
and the scroll position is preserved across active-view switches
via SvelteKit's `Snapshot` API.
### 6.5 Side effects ### 6.5 Side effects
A successful turn generation publishes a runtime snapshot into the A successful turn generation publishes a runtime snapshot into the
+10
View File
@@ -671,6 +671,16 @@ Per-turn-отчёты — read-only-вью, забираемые из движк
Backend авторизует вызывающего и форвардит запрос; в этом пути Backend авторизует вызывающего и форвардит запрос; в этом пути
нет ни кэширования, ни денормализации. нет ни кэширования, ни денормализации.
Web-клиент рендерит отчёт как одну секцию на каждый FBS-массив
(общие сведения, голоса, статус игроков, мои / чужие науки, мои /
чужие классы кораблей, сражения, бомбардировки, приближающиеся
группы, мои / чужие / необитаемые / неопознанные планеты, корабли в
производстве, грузовые маршруты, мои флоты, мои / чужие /
неопознанные группы кораблей). Пустые секции получают явную копию
empty-state. Якоря секций отображены в sticky-TOC (на мобильном —
`<select>`); позиция скролла сохраняется при переключении активного
представления через SvelteKit `Snapshot` API.
### 6.5 Побочные эффекты ### 6.5 Побочные эффекты
Успешная генерация хода публикует runtime-snapshot в lobby-модуль, Успешная генерация хода публикует runtime-snapshot в lobby-модуль,
+3 -4
View File
@@ -267,21 +267,20 @@ func (c *Cache) putMaterial(pn uint, v float64) {
c.MustPlanet(pn).Mat(v) c.MustPlanet(pn).Mat(v)
} }
// ProduceShip returns number of ships with shipMass planet p can produce in one turn
func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint { func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint {
if productionAvailable <= 0 { if productionAvailable <= 0 {
return 0 return 0
} }
ships := uint(0) ships := uint(0)
pa := productionAvailable pa := productionAvailable
PRODcost := calc.ShipProductionCost(shipMass) var MATneed, totalCost float64
var MATneed, MATfarm, totalCost float64
for { for {
MATneed = shipMass - float64(p.Material) MATneed = shipMass - float64(p.Material)
if MATneed < 0 { if MATneed < 0 {
MATneed = 0 MATneed = 0
} }
MATfarm = MATneed / float64(p.Resources) totalCost = calc.ShipBuildCost(shipMass, float64(p.Material), float64(p.Resources))
totalCost = PRODcost + MATfarm
if pa < totalCost { if pa < totalCost {
progress := pa / totalCost progress := pa / totalCost
pval := game.F(progress) pval := game.F(progress)
+26
View File
@@ -11,3 +11,29 @@ func PlanetProduceShipMass(L, Mat, Res float64) float64 {
} }
return (L + Mat/Res) / (10 + 1/Res) return (L + Mat/Res) / (10 + 1/Res)
} }
// ShipBuildCost returns the total per-turn cost (production units) to
// build one ship of empty mass shipMass on a planet that currently
// holds material stockpile and has natural resources. The cost is the
// ship's production cost ([ShipProductionCost]) plus the cost of
// farming any missing material from the planet (the missing-material
// volume divided by the planet's resources rating).
//
// resources is expected to be positive in normal play; the helper
// guards against a non-positive value by collapsing the material-
// farming term to zero, which keeps callers numerically stable on
// pathological synthetic data. Mirrors the per-iteration math inside
// the engine's controller.ProduceShip so both surfaces — and the
// legacy-report-to-json dev tool that needs to derive prod_used from
// percent — share the same formula.
func ShipBuildCost(shipMass, material, resources float64) float64 {
matNeed := shipMass - material
if matNeed < 0 {
matNeed = 0
}
matFarm := 0.
if resources > 0 {
matFarm = matNeed / resources
}
return ShipProductionCost(shipMass) + matFarm
}
+63
View File
@@ -0,0 +1,63 @@
package calc_test
import (
"math"
"testing"
"galaxy/calc"
)
func TestShipBuildCost(t *testing.T) {
cases := []struct {
name string
shipMass float64
material float64
resources float64
want float64
}{
{
name: "material exceeds mass: no farming needed",
shipMass: 5,
material: 10,
resources: 0.5,
want: 50, // ShipProductionCost(5) = 50; matFarm = 0.
},
{
name: "material equal to mass: no farming needed",
shipMass: 5,
material: 5,
resources: 0.5,
want: 50,
},
{
name: "material short of mass: farming term added",
shipMass: 10,
material: 3,
resources: 0.5,
want: 114, // 100 + (7 / 0.5).
},
{
name: "no material at all: full mass farmed",
shipMass: 4,
material: 0,
resources: 0.5,
want: 48, // 40 + (4 / 0.5).
},
{
name: "zero resources collapses farming term to zero",
shipMass: 10,
material: 3,
resources: 0,
want: 100, // 100 + 0; resources == 0 is a pathological guard.
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := calc.ShipBuildCost(tc.shipMass, tc.material, tc.resources)
if math.Abs(got-tc.want) > 1e-9 {
t.Errorf("ShipBuildCost(%v, %v, %v) = %v, want %v",
tc.shipMass, tc.material, tc.resources, got, tc.want)
}
})
}
}
+28 -15
View File
@@ -60,6 +60,11 @@ already decodes from server responses
| `UninhabitedPlanet[]` | `Uninhabited Planets` | | `UninhabitedPlanet[]` | `Uninhabited Planets` |
| `UnidentifiedPlanet[]`| `Unidentified Planets` | | `UnidentifiedPlanet[]`| `Unidentified Planets` |
| `LocalShipClass[]` | `Your Ship Types` | | `LocalShipClass[]` | `Your Ship Types` |
| `OtherShipClass[]` | `<Race> Ship Types` (Phase 23) |
| `LocalScience[]` | `Your Sciences` (Phase 23) |
| `OtherScience[]` | `<Race> Sciences` (Phase 23) |
| `Bombing[]` | `Bombings` (Phase 23) |
| `ShipProduction[]` | `Ships In Production` (Phase 23) |
| `LocalGroup[]` | `Your Groups` (Phase 19) | | `LocalGroup[]` | `Your Groups` (Phase 19) |
| `LocalFleet[]` | `Your Fleets` (Phase 19) | | `LocalFleet[]` | `Your Fleets` (Phase 19) |
| `IncomingGroup[]` | `Incoming Groups` (Phase 19) | | `IncomingGroup[]` | `Incoming Groups` (Phase 19) |
@@ -78,29 +83,37 @@ tables (foreign-only knowledge the local player lacks) cause the entire
group / fleet / incoming row to be dropped — preferable to fabricating group / fleet / incoming row to be dropped — preferable to fabricating
a destination. a destination.
`ShipProduction.ProdUsed` is derived from the on-disk `Percent` and the
producing planet's material/resources via [`pkg/calc.ShipBuildCost`]
(the same helper the engine's `controller.ProduceShip` uses). The
legacy text format does not carry a `prod_used` column directly; the
derivation gives the cumulative production-equivalent of the build
progress so far. The real engine's `ProdUsed` is the per-turn
residual production poured into the partial ship, which is not
recoverable from a single legacy snapshot. The two numbers stay in
the same units and the same ballpark, which is good enough for the
synthetic-mode UI — live engine reports come over the FBS wire and
do not flow through this parser. A ships-in-production row pointing
at a planet that did not appear in `Your Planets` (which would be a
malformed legacy file) is dropped.
## Skipped sections (today) ## Skipped sections (today)
These exist in legacy reports but either have no UI decoder yet or These exist in legacy reports but cannot be derived from the legacy
cannot be derived from the legacy text format at all. Each becomes text format at all. Each could become in-scope if a strong enough
in-scope as soon as its UI phase lands (see "Adding a new field" reason arises (see "Adding a new field" below).
below).
- Foreign / other ship types (`<Race> Ship Types`) - Battles (`Battle at (#N) Name`, `Battle Protocol`) — the wire schema
- Sciences, both local (`Your Sciences`) and foreign (`<Race> Sciences`) carries battle UUIDs (`Report.Battle: []uuid.UUID`); the legacy text
- Battles (`Battle at (#N) Name`, `Battle Protocol`) — battle rosters carries per-battle rosters with stripped columns (no origin / range /
inside these blocks carry minimal columns (no origin / range / destination) and no stable identifier. Synthesising UUIDs from the
destination) and are intentionally skipped: parsing them would text would invent data that future Phase 27 work would have to drop;
produce mostly-empty `OtherGroup` records that drift away from the the synthetic JSON therefore emits `battle: []`.
typed contract.
- Bombings (`Bombings`)
- Ships in production (`Ships In Production`)
- `OtherGroup[]` — no top-level legacy section. Foreign groups appear - `OtherGroup[]` — no top-level legacy section. Foreign groups appear
only inside battle rosters (see above), with stripped columns; the only inside battle rosters (see above), with stripped columns; the
synthetic JSON emits `otherGroup: []`. synthetic JSON emits `otherGroup: []`.
- `UnidentifiedGroup[]` — no legacy section at all; synthetic JSON - `UnidentifiedGroup[]` — no legacy section at all; synthetic JSON
emits `unidentifiedGroup: []`. emits `unidentifiedGroup: []`.
- `OtherShipClass[]` — present in legacy as `<Race> Ship Types`, but
no UI decoder yet; synthetic JSON emits `otherShipClass: []`.
- Cargo routes — no dedicated section in the legacy text format; the - Cargo routes — no dedicated section in the legacy text format; the
synthetic JSON emits `route: []`. The UI's overlay path synthetic JSON emits `route: []`. The UI's overlay path
(`applyOrderOverlay`) supports running on top of an empty `routes`. (`applyOrderOverlay`) supports running on top of an empty `routes`.
+242 -23
View File
@@ -21,6 +21,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"galaxy/calc"
"galaxy/model/report" "galaxy/model/report"
) )
@@ -57,6 +58,11 @@ const (
sectionYourGroups sectionYourGroups
sectionYourFleets sectionYourFleets
sectionIncomingGroups sectionIncomingGroups
sectionYourSciences
sectionOtherSciences
sectionOtherShipTypes
sectionBombings
sectionShipsInProduction
) )
type parser struct { type parser struct {
@@ -71,10 +77,14 @@ type parser struct {
// they carry destination/origin planet names that may resolve // they carry destination/origin planet names that may resolve
// against the planet tables only after the whole file has been // against the planet tables only after the whole file has been
// read — "Incoming Groups" can appear before "Your Planets" in // read — "Incoming Groups" can appear before "Your Planets" in
// some engine variants. // some engine variants. Ships-in-production rows are buffered
pendingGroups []pendingGroup // because their prod_used derivation needs the producing planet's
pendingFleets []pendingFleet // material and resources (read from "Your Planets") to call
pendingIncomings []pendingIncoming // [calc.ShipBuildCost], and the section order is not guaranteed.
pendingGroups []pendingGroup
pendingFleets []pendingFleet
pendingIncomings []pendingIncoming
pendingShipProducts []pendingShipProduction
} }
type pendingGroup struct { type pendingGroup struct {
@@ -112,6 +122,14 @@ type pendingIncoming struct {
mass float64 mass float64
} }
type pendingShipProduction struct {
planetNumber uint
class string
cost float64
percent float64
free float64
}
func newParser() *parser { func newParser() *parser {
return &parser{sec: sectionNone} return &parser{sec: sectionNone}
} }
@@ -177,6 +195,16 @@ func (p *parser) handle(line string) error {
p.parseYourFleet(fields) p.parseYourFleet(fields)
case sectionIncomingGroups: case sectionIncomingGroups:
p.parseIncomingGroup(fields) p.parseIncomingGroup(fields)
case sectionYourSciences:
p.parseYourScience(fields)
case sectionOtherSciences:
p.parseOtherScience(fields)
case sectionOtherShipTypes:
p.parseOtherShipClass(fields)
case sectionBombings:
p.parseBombing(fields)
case sectionShipsInProduction:
p.parseShipProductionRow(fields)
} }
return nil return nil
} }
@@ -259,10 +287,13 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
return sectionUnidentifiedPlanets, "", true return sectionUnidentifiedPlanets, "", true
case "Your vote:": case "Your vote:":
return sectionYourVote, "", true return sectionYourVote, "", true
case "Your Sciences", case "Your Sciences":
"Bombings", return sectionYourSciences, "", true
"Ships In Production", case "Bombings":
"Approaching Groups", return sectionBombings, "", true
case "Ships In Production":
return sectionShipsInProduction, "", true
case "Approaching Groups",
"Broadcast Message", "Broadcast Message",
"Battle Protocol": "Battle Protocol":
return sectionNone, "", true return sectionNone, "", true
@@ -279,11 +310,11 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
if owner, ok := singleTokenPrefix(line, " Planets"); ok { if owner, ok := singleTokenPrefix(line, " Planets"); ok {
return sectionOtherPlanets, owner, true return sectionOtherPlanets, owner, true
} }
if _, ok := singleTokenPrefix(line, " Ship Types"); ok { if owner, ok := singleTokenPrefix(line, " Ship Types"); ok {
return sectionNone, "", true return sectionOtherShipTypes, owner, true
} }
if _, ok := singleTokenPrefix(line, " Sciences"); ok { if owner, ok := singleTokenPrefix(line, " Sciences"); ok {
return sectionNone, "", true return sectionOtherSciences, owner, true
} }
if _, ok := singleTokenPrefix(line, " Groups"); ok { if _, ok := singleTokenPrefix(line, " Groups"); ok {
return sectionNone, "", true return sectionNone, "", true
@@ -468,30 +499,173 @@ func (p *parser) parseUnidentifiedPlanet(fields []string) {
// //
// N D A W S C M // N D A W S C M
func (p *parser) parseShipClass(fields []string) { func (p *parser) parseShipClass(fields []string) {
if len(fields) < 7 { sc, ok := decodeShipClassRow(fields)
if !ok {
return return
} }
p.rep.LocalShipClass = append(p.rep.LocalShipClass, sc)
}
// parseOtherShipClass parses one row of a "<Race> Ship Types" block.
// Same 7-column layout as [parser.parseShipClass]; the owning race is
// captured into [parser.otherOwner] when the section header is
// classified by [classifySection].
func (p *parser) parseOtherShipClass(fields []string) {
sc, ok := decodeShipClassRow(fields)
if !ok {
return
}
p.rep.OtherShipClass = append(p.rep.OtherShipClass, report.OthersShipClass{
Race: p.otherOwner,
ShipClass: sc,
})
}
func decodeShipClassRow(fields []string) (report.ShipClass, bool) {
var sc report.ShipClass
if len(fields) < 7 {
return sc, false
}
drive, err := parseFloat(fields[1]) drive, err := parseFloat(fields[1])
if err != nil { if err != nil {
return return sc, false
} }
armament, err := strconv.ParseUint(fields[2], 10, 32) armament, err := strconv.ParseUint(fields[2], 10, 32)
if err != nil { if err != nil {
return return sc, false
} }
weapons, _ := parseFloat(fields[3]) weapons, _ := parseFloat(fields[3])
shields, _ := parseFloat(fields[4]) shields, _ := parseFloat(fields[4])
cargo, _ := parseFloat(fields[5]) cargo, _ := parseFloat(fields[5])
mass, _ := parseFloat(fields[6]) mass, _ := parseFloat(fields[6])
sc.Name = fields[0]
sc.Drive = report.F(drive)
sc.Armament = uint(armament)
sc.Weapons = report.F(weapons)
sc.Shields = report.F(shields)
sc.Cargo = report.F(cargo)
sc.Mass = report.F(mass)
return sc, true
}
p.rep.LocalShipClass = append(p.rep.LocalShipClass, report.ShipClass{ // parseYourScience parses one row of the "Your Sciences" block.
Name: fields[0], // Columns:
Drive: report.F(drive), //
Armament: uint(armament), // N D W S C
Weapons: report.F(weapons), //
Shields: report.F(shields), // where D/W/S/C are the four tech proportions as fractions summing
Cargo: report.F(cargo), // to 1.0 (`pkg/calc/validator.go.ValidateScienceValues`).
Mass: report.F(mass), func (p *parser) parseYourScience(fields []string) {
sc, ok := decodeScienceRow(fields)
if !ok {
return
}
p.rep.LocalScience = append(p.rep.LocalScience, sc)
}
// parseOtherScience parses one row of a "<Race> Sciences" block.
// Same 5-column layout as [parser.parseYourScience]; the owning race
// is captured into [parser.otherOwner] by [classifySection].
func (p *parser) parseOtherScience(fields []string) {
sc, ok := decodeScienceRow(fields)
if !ok {
return
}
p.rep.OtherScience = append(p.rep.OtherScience, report.OtherScience{
Race: p.otherOwner,
Science: sc,
})
}
func decodeScienceRow(fields []string) (report.Science, bool) {
var sc report.Science
if len(fields) < 5 {
return sc, false
}
drive, err := parseFloat(fields[1])
if err != nil {
return sc, false
}
weapons, _ := parseFloat(fields[2])
shields, _ := parseFloat(fields[3])
cargo, _ := parseFloat(fields[4])
sc.Name = fields[0]
sc.Drive = report.F(drive)
sc.Weapons = report.F(weapons)
sc.Shields = report.F(shields)
sc.Cargo = report.F(cargo)
return sc, true
}
// parseBombing parses one row of the "Bombings" block. Columns
// (12 tokens, last is the wiped/damaged status word):
//
// W O # N P I P $ M C A status
//
// where the first P is the post-bombing population and the second
// P is the production string left on the planet. Status is parsed
// positionally — the header has a duplicate P, so a header-name
// lookup is not safe.
func (p *parser) parseBombing(fields []string) {
if len(fields) < 12 {
return
}
number, err := strconv.ParseUint(fields[2], 10, 32)
if err != nil {
return
}
population, _ := parseFloat(fields[4])
industry, _ := parseFloat(fields[5])
capital, _ := parseFloat(fields[7])
material, _ := parseFloat(fields[8])
colonists, _ := parseFloat(fields[9])
attack, _ := parseFloat(fields[10])
wiped := fields[11] == "Wiped"
p.rep.Bombing = append(p.rep.Bombing, &report.Bombing{
Attacker: fields[0],
Owner: fields[1],
Number: uint(number),
Planet: fields[3],
Population: report.F(population),
Industry: report.F(industry),
Production: fields[6],
Capital: report.F(capital),
Material: report.F(material),
Colonists: report.F(colonists),
AttackPower: report.F(attack),
Wiped: wiped,
})
}
// parseShipProductionRow buffers a "Ships In Production" row for
// post-processing in [parser.finish]. Columns:
//
// # N S C P L
//
// where # is the planet number, N is the planet name (decorative —
// resolution uses #), S is the building ship class, C is the cost
// (== shipMass * 10), P is the build progress as a fraction in
// [0, 1], and L is the producing planet's free industry. The wire
// shape's `prod_used` field is not carried by the legacy text; it is
// derived during [parser.resolvePending] from the planet's material
// and resources via [calc.ShipBuildCost].
func (p *parser) parseShipProductionRow(fields []string) {
if len(fields) < 6 {
return
}
number, err := strconv.ParseUint(fields[0], 10, 32)
if err != nil {
return
}
cost, _ := parseFloat(fields[3])
percent, _ := parseFloat(fields[4])
free, _ := parseFloat(fields[5])
p.pendingShipProducts = append(p.pendingShipProducts, pendingShipProduction{
planetNumber: uint(number),
class: fields[2],
cost: cost,
percent: percent,
free: free,
}) })
} }
@@ -695,6 +869,51 @@ func (p *parser) resolvePending() {
Mass: report.F(pi.mass), Mass: report.F(pi.mass),
}) })
} }
for _, ps := range p.pendingShipProducts {
lp, ok := p.findLocalPlanet(ps.planetNumber)
if !ok {
continue
}
shipMass := ps.cost / 10
totalCost := calc.ShipBuildCost(
shipMass,
float64(lp.Material),
float64(lp.Resources),
)
// `ProdUsed` is the cumulative production-equivalent of the
// build progress so far. The real engine's `Progress` field
// accumulates across turns and the per-turn `ProdUsed` is a
// transient residual — neither of those is recoverable from a
// single legacy report. The derivation here keeps the value in
// the same units (production points) and in the right ballpark
// for synthetic-mode UI rendering; live engine reports do not
// flow through this parser, so the approximation never reaches
// production traffic. README.md skips section explains.
prodUsed := totalCost * ps.percent
p.rep.ShipProduction = append(p.rep.ShipProduction, report.ShipProduction{
Planet: ps.planetNumber,
Class: ps.class,
Cost: report.F(ps.cost),
ProdUsed: report.F(prodUsed),
Percent: report.F(ps.percent),
Free: report.F(ps.free),
})
}
}
// findLocalPlanet returns the parsed "Your Planets" entry with the
// given number, used by the ships-in-production resolver to read
// material / resources for the [calc.ShipBuildCost] derivation.
// Ships-in-production only lists own ships, so the lookup against
// `LocalPlanet` is correct.
func (p *parser) findLocalPlanet(number uint) (report.LocalPlanet, bool) {
for _, lp := range p.rep.LocalPlanet {
if lp.Number == number {
return lp, true
}
}
return report.LocalPlanet{}, false
} }
// lookupPlanetNumber resolves a legacy planet reference — either a // lookupPlanetNumber resolves a legacy planet reference — either a
+259 -39
View File
@@ -201,7 +201,7 @@ func TestParseShipClasses(t *testing.T) {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
if got, want := len(rep.LocalShipClass), 2; got != want { if got, want := len(rep.LocalShipClass), 2; got != want {
t.Fatalf("len(LocalShipClass) = %d, want %d (foreign types must be ignored)", got, want) t.Fatalf("len(LocalShipClass) = %d, want %d", got, want)
} }
bow := rep.LocalShipClass[1] bow := rep.LocalShipClass[1]
if bow.Name != "Bow105" || bow.Armament != 105 { if bow.Name != "Bow105" || bow.Armament != 105 {
@@ -210,9 +210,193 @@ func TestParseShipClasses(t *testing.T) {
if got, want := float64(bow.Drive), 74.77; got != want { if got, want := float64(bow.Drive), 74.77; got != want {
t.Errorf("Bow105.Drive = %v, want %v", got, want) t.Errorf("Bow105.Drive = %v, want %v", got, want)
} }
if got, want := len(rep.OtherShipClass), 1; got != want {
t.Fatalf("len(OtherShipClass) = %d, want %d", got, want)
}
dragon := rep.OtherShipClass[0]
if dragon.Race != "Monstrai" || dragon.Name != "Dragon" || dragon.Armament != 1 {
t.Errorf("Dragon = (%q, %q, %d), want (Monstrai, Dragon, 1)",
dragon.Race, dragon.Name, dragon.Armament)
}
if got, want := float64(dragon.Mass), 19.80; got != want {
t.Errorf("Dragon.Mass = %v, want %v", got, want)
}
} }
func TestParseSkipsBattlesAndBombings(t *testing.T) { // TestParseSciences covers both "Your Sciences" and "<Race> Sciences"
// in one fixture. The five-column layout (N D W S C) is shared.
func TestParseSciences(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Your Sciences",
"",
"N D W S C",
"_TerraForm 1 0 0 0",
"BalancedMix 0.5 0.2 0.2 0.1",
"",
"Pahanchiks Sciences",
"",
"N D W S C",
"_Drift 1 0 0 0",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.LocalScience), 2; got != want {
t.Fatalf("len(LocalScience) = %d, want %d", got, want)
}
mix := rep.LocalScience[1]
if mix.Name != "BalancedMix" {
t.Errorf("LocalScience[1].Name = %q, want BalancedMix", mix.Name)
}
if float64(mix.Drive) != 0.5 || float64(mix.Cargo) != 0.1 {
t.Errorf("BalancedMix (Drive, Cargo) = (%v, %v), want (0.5, 0.1)",
float64(mix.Drive), float64(mix.Cargo))
}
if got, want := len(rep.OtherScience), 1; got != want {
t.Fatalf("len(OtherScience) = %d, want %d", got, want)
}
drift := rep.OtherScience[0]
if drift.Race != "Pahanchiks" || drift.Name != "_Drift" {
t.Errorf("OtherScience[0] = (%q, %q), want (Pahanchiks, _Drift)",
drift.Race, drift.Name)
}
}
// TestParseBombings covers a wiped row + a damaged row + the duplicate
// `P` column header (population vs production string) — assertions
// hit every wire field so a positional-index slip is caught.
func TestParseBombings(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Bombings",
"",
"W O # N P I P $ M C A",
"Knights Ricksha 20 DW-1207 1.56 0.00 Dron 0.00 0.00 0.00 7.62 Wiped",
"Knights Ricksha 332 PEHKE 500.00 258.64 Dron 184.39 0.00 6.42 331.93 Damaged",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.Bombing), 2; got != want {
t.Fatalf("len(Bombing) = %d, want %d", got, want)
}
wiped := rep.Bombing[0]
if !wiped.Wiped {
t.Errorf("Bombing[0].Wiped = false, want true")
}
if wiped.Attacker != "Knights" || wiped.Owner != "Ricksha" || wiped.Number != 20 {
t.Errorf("Bombing[0] head = (%q, %q, %d), want (Knights, Ricksha, 20)",
wiped.Attacker, wiped.Owner, wiped.Number)
}
if wiped.Planet != "DW-1207" || wiped.Production != "Dron" {
t.Errorf("Bombing[0] (planet, production) = (%q, %q), want (DW-1207, Dron)",
wiped.Planet, wiped.Production)
}
if float64(wiped.AttackPower) != 7.62 {
t.Errorf("Bombing[0].AttackPower = %v, want 7.62", float64(wiped.AttackPower))
}
damaged := rep.Bombing[1]
if damaged.Wiped {
t.Errorf("Bombing[1].Wiped = true, want false (Damaged)")
}
if float64(damaged.Capital) != 184.39 || float64(damaged.Colonists) != 6.42 {
t.Errorf("Bombing[1] (capital, colonists) = (%v, %v), want (184.39, 6.42)",
float64(damaged.Capital), float64(damaged.Colonists))
}
}
// TestParseShipsInProduction covers the prod_used derivation through
// [calc.ShipBuildCost]. The producing planet is mounted first with a
// non-zero material stockpile so the farming term contributes a
// non-trivial slice of totalCost; the expected prod_used number is
// derived from totalCost * percent with the same formula the parser
// uses.
func TestParseShipsInProduction(t *testing.T) {
// Planet: Material=0.68, Resources=10.00.
// Ship: cost=990.10 -> shipMass=99.01.
// totalCost = ShipProductionCost(99.01) + max(0, 99.01-0.68)/10
// = 990.10 + 9.833
// = 999.933
// prod_used = 999.933 * 0.07 (percent) ≈ 69.99531
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Your Planets",
"",
" # X Y N S P I R P $ M C L",
" 17 171.05 700.24 Castle 1000.00 1000.00 1000.00 10.00 CombatFlame 0.00 0.68 88.78 1000.00",
"",
"Ships In Production",
"",
" # N S C P L",
" 17 Castle CombatFlame 990.10 0.07 1000.00",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.ShipProduction), 1; got != want {
t.Fatalf("len(ShipProduction) = %d, want %d", got, want)
}
sp := rep.ShipProduction[0]
if sp.Planet != 17 || sp.Class != "CombatFlame" {
t.Errorf("ShipProduction[0] = (planet=%d, class=%q), want (17, CombatFlame)",
sp.Planet, sp.Class)
}
if got := float64(sp.Cost); got != 990.10 {
t.Errorf("ShipProduction[0].Cost = %v, want 990.10", got)
}
if got := float64(sp.Percent); got != 0.07 {
t.Errorf("ShipProduction[0].Percent = %v, want 0.07", got)
}
if got := float64(sp.Free); got != 1000.0 {
t.Errorf("ShipProduction[0].Free = %v, want 1000", got)
}
wantProdUsed := 69.995
if got := float64(sp.ProdUsed); got < wantProdUsed-0.01 || got > wantProdUsed+0.01 {
t.Errorf("ShipProduction[0].ProdUsed = %v, want ~%v (totalCost * percent)",
got, wantProdUsed)
}
}
// TestParseShipsInProductionDropsUnknownPlanet exercises the safety
// net: a ships-in-production row referencing a planet not seen in
// "Your Planets" is dropped, because the prod_used derivation needs
// the planet's material and resources.
func TestParseShipsInProductionDropsUnknownPlanet(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Ships In Production",
"",
" # N S C P L",
" 99 Lost Frigate 100.00 0.05 500.00",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.ShipProduction), 0; got != want {
t.Errorf("len(ShipProduction) = %d, want %d (planet #99 missing → drop)",
got, want)
}
}
// TestParseSkipsBattles covers the only remaining legacy section the
// parser ignores: "Battle at ..." headers and the following "Battle
// Protocol" block. Bombings, Ships In Production, and the per-race
// Sciences / Ship Types blocks now flow through real parsers; the
// dedicated section tests below cover them.
func TestParseSkipsBattles(t *testing.T) {
in := strings.Join([]string{ in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1", "Race Report for Galaxy PLUS Turn 1",
"", "",
@@ -225,10 +409,6 @@ func TestParseSkipsBattlesAndBombings(t *testing.T) {
"", "",
"Foo fires on Bar : Destroyed", "Foo fires on Bar : Destroyed",
"", "",
"Bombings",
"",
"# data line",
"",
"Your Planets", "Your Planets",
"", "",
" # X Y N S P I R P $ M C L", " # X Y N S P I R P $ M C L",
@@ -240,7 +420,10 @@ func TestParseSkipsBattlesAndBombings(t *testing.T) {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
if got, want := len(rep.LocalPlanet), 1; got != want { if got, want := len(rep.LocalPlanet), 1; got != want {
t.Fatalf("len(LocalPlanet) = %d, want %d (battle/bombing rows must not leak in)", got, want) t.Fatalf("len(LocalPlanet) = %d, want %d (battle rows must not leak in)", got, want)
}
if got, want := len(rep.Battle), 0; got != want {
t.Errorf("len(Battle) = %d, want %d (legacy parser does not synthesise battle UUIDs)", got, want)
} }
} }
@@ -404,14 +587,16 @@ func TestParseIncomingGroups(t *testing.T) {
// --- smoke tests ----------------------------------------------------- // --- smoke tests -----------------------------------------------------
type smokeWant struct { type smokeWant struct {
race string race string
turn uint turn uint
mapW, mapH, planetCount uint32 mapW, mapH, planetCount uint32
voteFor string voteFor string
votes float64 votes float64
players, extinct, local, other int players, extinct, local, other int
uninhabited, unidentified, shipClasses int uninhabited, unidentified, shipClasses int
localGroups, localFleets, incomingGroups int localGroups, localFleets, incomingGroups int
localScience, otherScience, otherShipClass int
bombings, shipProductions int
} }
func runSmoke(t *testing.T, path string, want smokeWant) { func runSmoke(t *testing.T, path string, want smokeWant) {
@@ -457,6 +642,11 @@ func runSmoke(t *testing.T, path string, want smokeWant) {
{"LocalGroup", len(rep.LocalGroup), want.localGroups}, {"LocalGroup", len(rep.LocalGroup), want.localGroups},
{"LocalFleet", len(rep.LocalFleet), want.localFleets}, {"LocalFleet", len(rep.LocalFleet), want.localFleets},
{"IncomingGroup", len(rep.IncomingGroup), want.incomingGroups}, {"IncomingGroup", len(rep.IncomingGroup), want.incomingGroups},
{"LocalScience", len(rep.LocalScience), want.localScience},
{"OtherScience", len(rep.OtherScience), want.otherScience},
{"OtherShipClass", len(rep.OtherShipClass), want.otherShipClass},
{"Bombing", len(rep.Bombing), want.bombings},
{"ShipProduction", len(rep.ShipProduction), want.shipProductions},
} }
for _, c := range checks { for _, c := range checks {
if c.got != c.want { if c.got != c.want {
@@ -477,10 +667,15 @@ func TestParseDgKNNTS039(t *testing.T) {
voteFor: "KnightErrants", votes: 16.02, voteFor: "KnightErrants", votes: 16.02,
players: 91, extinct: 49, players: 91, extinct: 49,
local: 22, other: 89, uninhabited: 17, unidentified: 572, local: 22, other: 89, uninhabited: 17, unidentified: 572,
shipClasses: 24, shipClasses: 24,
localGroups: 171, localGroups: 171,
localFleets: 0, localFleets: 0,
incomingGroups: 0, incomingGroups: 0,
localScience: 1,
otherScience: 1,
otherShipClass: 170,
bombings: 16,
shipProductions: 6,
}) })
} }
@@ -490,10 +685,15 @@ func TestParseDgKNNTS040(t *testing.T) {
mapW: 800, mapH: 800, planetCount: 700, mapW: 800, mapH: 800, planetCount: 700,
players: 91, extinct: 49, players: 91, extinct: 49,
local: 22, other: 93, uninhabited: 27, unidentified: 558, local: 22, other: 93, uninhabited: 27, unidentified: 558,
shipClasses: 34, shipClasses: 34,
localGroups: 207, localGroups: 207,
localFleets: 0, localFleets: 0,
incomingGroups: 0, incomingGroups: 0,
localScience: 1,
otherScience: 1,
otherShipClass: 160,
bombings: 24,
shipProductions: 16,
}) })
} }
@@ -506,10 +706,15 @@ func TestParseDgKNNTS041(t *testing.T) {
mapW: 800, mapH: 800, planetCount: 700, mapW: 800, mapH: 800, planetCount: 700,
players: 91, extinct: 50, players: 91, extinct: 50,
local: 29, other: 103, uninhabited: 23, unidentified: 545, local: 29, other: 103, uninhabited: 23, unidentified: 545,
shipClasses: 36, shipClasses: 36,
localGroups: 285, localGroups: 285,
localFleets: 0, localFleets: 0,
incomingGroups: 12, incomingGroups: 12,
localScience: 1,
otherScience: 1,
otherShipClass: 218,
bombings: 12,
shipProductions: 22,
}) })
} }
@@ -522,10 +727,15 @@ func TestParseGplus40(t *testing.T) {
mapW: 350, mapH: 350, planetCount: 300, mapW: 350, mapH: 350, planetCount: 300,
players: 26, extinct: 0, players: 26, extinct: 0,
local: 26, other: 116, uninhabited: 7, unidentified: 151, local: 26, other: 116, uninhabited: 7, unidentified: 151,
shipClasses: 56, shipClasses: 56,
localGroups: 255, localGroups: 255,
localFleets: 1, localFleets: 1,
incomingGroups: 10, incomingGroups: 10,
localScience: 0,
otherScience: 0,
otherShipClass: 183,
bombings: 4,
shipProductions: 8,
}) })
} }
@@ -538,10 +748,15 @@ func TestParseDgKiller031(t *testing.T) {
mapW: 250, mapH: 250, planetCount: 175, mapW: 250, mapH: 250, planetCount: 175,
players: 25, extinct: 12, players: 25, extinct: 12,
local: 18, other: 127, uninhabited: 20, unidentified: 10, local: 18, other: 127, uninhabited: 20, unidentified: 10,
shipClasses: 11, shipClasses: 11,
localGroups: 175, localGroups: 175,
localFleets: 2, localFleets: 2,
incomingGroups: 0, incomingGroups: 0,
localScience: 0,
otherScience: 0,
otherShipClass: 161,
bombings: 18,
shipProductions: 0,
}) })
} }
@@ -555,10 +770,15 @@ func TestParseDgTancordia037(t *testing.T) {
mapW: 210, mapH: 210, planetCount: 140, mapW: 210, mapH: 210, planetCount: 140,
players: 18, extinct: 7, players: 18, extinct: 7,
local: 23, other: 62, uninhabited: 26, unidentified: 29, local: 23, other: 62, uninhabited: 26, unidentified: 29,
shipClasses: 40, shipClasses: 40,
localGroups: 311, localGroups: 311,
localFleets: 30, localFleets: 30,
incomingGroups: 2, incomingGroups: 2,
localScience: 1,
otherScience: 1,
otherShipClass: 123,
bombings: 22,
shipProductions: 20,
}) })
} }
+77 -2
View File
@@ -2470,9 +2470,9 @@ Targeted tests:
observe both commands as `applied` in the sidebar order tab and observe both commands as `applied` in the sidebar order tab and
verify the decoded gateway payload. verify the decoded gateway payload.
## Phase 23. Reports View — Current Turn Sections ## ~~Phase 23. Reports View — Current Turn Sections~~
Status: pending. Status: done (local-ci run 2).
Goal: present every section of the current turn's report as readable Goal: present every section of the current turn's report as readable
panels, mirroring the structure documented in [`rules.txt`](../game/rules.txt) and panels, mirroring the structure documented in [`rules.txt`](../game/rules.txt) and
@@ -2506,6 +2506,81 @@ Targeted tests:
- Playwright e2e: open the report, scroll to each section via anchor - Playwright e2e: open the report, scroll to each section via anchor
navigation, assert content present. navigation, assert content present.
Decisions during stage:
1. **Component decomposition.** The orchestrator
`lib/active-view/report.svelte` is one file; each of the twenty
sections is its own component under
`lib/active-view/report/section-<slug>.svelte`. Six distinct data
shapes (kv-list, races-style grid, planets-style grid, sub-table-
per-race, raw UUID list, fleet/group grids) sit too unevenly in one
monolith; per-section components also map directly onto the Vitest
targeted-test seam. No shared `<Section>` abstraction was extracted
— CLAUDE.md "wait for the third real caller" still holds with one
shape per section. Shared formatters live in `report/format.ts`.
2. **`races` vs `players`.** A parallel
`GameReport.players: ReportPlayer[]` was added (full roster, self
row included, extinct rows kept with `extinct: true`). The Phase 22
`races[]` (non-extinct, self excluded) stays untouched so no Phase
22 surface had to change. Extinct races are shown in Player Status
with a `RIP` marker; the orchestrator highlights the local row.
3. **Scroll save / restore.** Wired through SvelteKit's `Snapshot`
API on `routes/games/[id]/report/+page.svelte`. Captures
`window.scrollY` (the in-game shell layout expands its
`active-view-host` to fit content, so the document body is the real
scroll container) and restores via a `requestAnimationFrame` poll
that waits for `documentElement.scrollHeight` to catch up before
calling `window.scrollTo`. The earlier plan to track the host's
`scrollTop` did not survive contact with the layout's
no-explicit-height contract; the change is contained to the route
file. No new context plumbing was introduced.
4. **Active-section highlight.** `IntersectionObserver` rooted on the
viewport (`root: null`) with `rootMargin: "-30% 0px -60% 0px"`
tracks which section sits in the upper third of the visible area
and updates the TOC. Cheaper than a scroll handler and degrades
gracefully where IO is not available.
5. **Mobile TOC.** A sticky `<select>` at the top of the report body
replaces the desktop anchor sidebar on viewports below 768 px. No
new overlay primitive is introduced; the existing layout-owned
bottom-tab bar stays unobstructed. Picking an option scrolls the
chosen section into view.
6. **Battles section.** Battle UUIDs render as inactive monospace
`<span>` rows until Phase 27 lights up `/games/:id/battle/:battleId`.
The earlier plan to link them now was reverted: a dead link is a
worse experience than a plain identifier, and the rewire when
Phase 27 lands is one line.
7. **Foreign sciences / ship classes layout.** One sub-table per race
with a `{race} sciences` / `{race} ship classes` sub-header. The
`(race, name)` decoder sort produces stable groups; cross-race
sorting is intentionally avoided (it would be semantically
meaningless across races).
8. **Bombings wiped state.** Wiped rows get a `.wiped` CSS class plus
a dedicated `report-bombing-wiped-badge` element so the boolean is
visually explicit and easy to assert in e2e.
9. **Ships in production `prodUsed` derivation (Go side).** The legacy
text reports do not carry the engine's per-turn `ProdUsed` field —
only `Cost`, `Percent`, `Free`. The legacy parser derives an
approximation as `ShipBuildCost(shipMass, material, resources) * percent`
using a new shared helper `pkg/calc.ShipBuildCost`. The engine's
`controller.ProduceShip` was refactored to call the same helper
(behavior-preserving — engine tests stay unchanged and pass). The
approximation is documented in
`tools/local-dev/legacy-report/README.md`; live engine reports come
over FBS and never flow through this parser.
10. **Legacy parser scope.** Per user direction, the parser was
extended to populate `LocalScience`, `OtherScience`,
`OthersShipClass`, `Bombing`, and `ShipProduction` from their
legacy text sections. Battles stay in the parser's Skipped list:
the legacy text carries per-battle rosters with no stable UUID,
and synthesising IDs would invent data Phase 27 would have to
drop. `OtherGroup[]`, `UnidentifiedGroup[]`, and cargo routes
remain skipped (no legacy section).
11. **i18n namespace.** All Phase 23 strings live under
`game.report.section.<slug>.*`; the duplicate-looking entries
(sciences / ship classes columns) are deliberately separate from
`game.table.*` so the two surfaces evolve independently. ≈90 new
keys, en + ru in lockstep.
## Phase 24. Push Events — Turn-Ready ## Phase 24. Push Events — Turn-Ready
Status: pending. Status: pending.
+1 -1
View File
@@ -24,7 +24,7 @@ separate dispatch component.
| ------------------------------------------ | ---------------------------------------------- | ----------------------- | | ------------------------------------------ | ---------------------------------------------- | ----------------------- |
| `/games/:id/map` | `lib/active-view/map.svelte` | Phase 11 | | `/games/:id/map` | `lib/active-view/map.svelte` | Phase 11 |
| `/games/:id/table/:entity` | `lib/active-view/table.svelte` | Phase 11 / 17 / 19 / 22 | | `/games/:id/table/:entity` | `lib/active-view/table.svelte` | Phase 11 / 17 / 19 / 22 |
| `/games/:id/report` | `lib/active-view/report.svelte` | Phase 23 | | `/games/:id/report` | `lib/active-view/report.svelte` (see [report-view.md](report-view.md)) | Phase 23 |
| `/games/:id/battle/:battleId?` | `lib/active-view/battle.svelte` | Phase 27 | | `/games/:id/battle/:battleId?` | `lib/active-view/battle.svelte` | Phase 27 |
| `/games/:id/mail` | `lib/active-view/mail.svelte` | Phase 28 | | `/games/:id/mail` | `lib/active-view/mail.svelte` | Phase 28 |
| `/games/:id/designer/ship-class/:classId?` | `lib/active-view/designer-ship-class.svelte` | Phase 17 (CRUD) / 18 (calc preview) | | `/games/:id/designer/ship-class/:classId?` | `lib/active-view/designer-ship-class.svelte` | Phase 17 (CRUD) / 18 (calc preview) |
+179
View File
@@ -0,0 +1,179 @@
# Report view — Phase 23
The Phase 23 in-game "turn report" view is a single scrollable
layout with twenty sections, one per array on the FBS `Report`
table. The route file is the standard two-line wrapper; the
orchestrator and the per-section components live under
`ui/frontend/src/lib/active-view/report/`.
## Component layout
`lib/active-view/report.svelte` is the orchestrator. It owns the
section list, instantiates `IntersectionObserver` to track which
section is active, and renders the table of contents alongside the
section column.
```
report.svelte
├── report/report-toc.svelte // anchor list + mobile <select>
├── report/section-galaxy-summary.svelte
├── report/section-votes.svelte
├── report/section-player-status.svelte
├── report/section-my-sciences.svelte
├── report/section-foreign-sciences.svelte
├── report/section-my-ship-classes.svelte
├── report/section-foreign-ship-classes.svelte
├── report/section-battles.svelte
├── report/section-bombings.svelte
├── report/section-approaching-groups.svelte
├── report/section-my-planets.svelte
├── report/section-ships-in-production.svelte
├── report/section-cargo-routes.svelte
├── report/section-foreign-planets.svelte
├── report/section-uninhabited-planets.svelte
├── report/section-unknown-planets.svelte
├── report/section-my-fleets.svelte
├── report/section-my-ship-groups.svelte
├── report/section-foreign-ship-groups.svelte
└── report/section-unidentified-groups.svelte
```
Each section component is self-contained:
- reads `RenderedReportSource` from context;
- renders the loading copy when `rendered.report === null`;
- renders the empty-state copy when its array is empty;
- otherwise emits a `<section id="report-<slug>" data-testid="report-section-<slug>">`
containing the relevant grid / list / kv-list.
No shared `<Section>` wrapper exists. The visual scaffolding (dark
grid CSS, header style, status paragraph) is inlined per
component. The CLAUDE.md "wait for the third real caller before
extracting an abstraction" rule applies; with one shape per
section, the per-section inline CSS is the smallest correct
solution.
Shared formatters live in `report/format.ts` (`formatPercent`,
`formatCount`, `formatFloat`, `formatVotes`, `planetLabel`).
## Section order, data sources, empty copy
| # | Slug | Data | Empty copy (en) |
|---|------|------|-----------------|
| 1 | `galaxy-summary` | header turn / size / planet count / race | never empty |
| 2 | `votes` | `myVotes`, `myVoteFor`, `races[].votesReceived` | "no votes cast yet" |
| 3 | `player-status` | `players[]` (full roster, self + extinct) | never empty |
| 4 | `my-sciences` | `localScience[]` | "no sciences defined yet" |
| 5 | `foreign-sciences` | `otherScience[]`, one sub-table per race | "no foreign sciences observed yet" |
| 6 | `my-ship-classes` | `localShipClass[]` | "no ship classes designed yet" |
| 7 | `foreign-ship-classes` | `otherShipClass[]`, one sub-table per race | "no foreign ship classes observed yet" |
| 8 | `battles` | `battleIds[]` (inactive monospace spans) | "no battles last turn" |
| 9 | `bombings` | `bombings[]`, wiped rows visually distinct | "no bombings last turn" |
| 10 | `approaching-groups` | `incomingShipGroups[]` | "no approaching groups" |
| 11 | `my-planets` | `planets.filter(kind==="local")` | "no planets owned yet" |
| 12 | `ships-in-production` | `shipProductions[]` | "no ships in production" |
| 13 | `cargo-routes` | `routes[]` (flattened to one row per entry) | "no cargo routes set" |
| 14 | `foreign-planets` | `planets.filter(kind==="other")` | "no foreign planets observed" |
| 15 | `uninhabited-planets` | `planets.filter(kind==="uninhabited")` | "no uninhabited planets observed" |
| 16 | `unknown-planets` | `planets.filter(kind==="unidentified")` | "no unknown planets" |
| 17 | `my-fleets` | `localFleets[]` | "no fleets created yet" |
| 18 | `my-ship-groups` | `localShipGroups[]` | "no ship groups yet" |
| 19 | `foreign-ship-groups` | `otherShipGroups[]` | "no foreign ship groups observed" |
| 20 | `unidentified-groups` | `unidentifiedShipGroups[]` | "no unidentified groups" |
The orchestrator iterates this list once for the TOC and once for
the body — both surfaces stay in sync by construction.
## Table of contents and active highlight
`report/report-toc.svelte` renders two surfaces driven by the same
entry list:
- **Desktop / tablet sidebar** — sticky `<aside>` with vertical
anchor list. The anchor for the currently-visible section gets
`aria-current="location"` and an `.active` CSS class.
- **Mobile (< 768 px)** — the desktop sidebar is hidden via CSS
and a sticky `<select>` takes its place at the top of the body.
Picking an option scrolls the matching section into view. The
mobile contract intentionally avoids stacking another overlay on
top of the existing layout-owned bottom-tabs.
Both surfaces also expose a "Back to map" affordance
(`report-back-to-map`) at the top.
The active slug is computed by an `IntersectionObserver` rooted on
the viewport (`root: null`) with `rootMargin: "-30% 0px -60% 0px"`.
The skew biases the active band toward the upper third of the
visible area so that scrolling down advances the highlight
naturally. The observer is created on mount and torn down on
unmount.
The in-game shell layout (`routes/games/[id]/+layout.svelte`)
expands `<main class="active-view-host">` to fit content rather
than constraining it, so the document body is the actual scroll
container — not the host. The IntersectionObserver root is `null`
to match.
## Scroll save / restore
`routes/games/[id]/report/+page.svelte` exports a SvelteKit
`Snapshot<{ scrollY: number }>`:
- `capture()` reads `window.scrollY` when SvelteKit's
`beforeNavigate` cycle runs.
- `restore(value)` schedules a short
`requestAnimationFrame` poll that waits for
`document.documentElement.scrollHeight` to grow tall enough to
honour the saved offset, then calls `window.scrollTo(0, value)`.
The poll caps at ~60 frames (≈ 1 second) so a never-tall-enough
body never pins a frame loop.
The capture / restore pair is keyed by route, so:
- Forward navigation from `/report` to `/map` lands `/map` at
scrollY 0 (no snapshot for `/map` to restore from).
- History-back from `/map` to `/report` restores the previously
captured scrollY — the user returns to the same section.
The Snapshot API does not capture the active sidebar slug; the
IntersectionObserver re-derives it from the restored scroll
position on the next animation frame, which keeps the TOC
highlight consistent without a second source of truth.
## i18n namespace
All Phase 23 strings live under `game.report.*`:
- `game.report.loading` — section loading placeholder.
- `game.report.back_to_map`, `game.report.toc.title`,
`game.report.toc.mobile_label` — shell-level strings.
- `game.report.section.<slug>.title` — section heading.
- `game.report.section.<slug>.empty` — empty-state copy (where
applicable).
- `game.report.section.<slug>.column.<column>` — column headings.
- A small number of section-specific keys (`bombings.wiped`,
`player_status.local_marker`, `player_status.extinct_marker`,
`foreign_sciences.race_header`, `foreign_ship_classes.race_header`,
`battles.id_label`, `votes.target_none`).
The namespace is intentionally separate from `game.table.*` even
where the data shape overlaps (e.g. sciences, ship classes); the
two surfaces evolve independently and a shared key set would
couple them silently.
## Test seams
- **Vitest** — four representative specs cover the four section
shapes: kv-list (`report-section-galaxy-summary.test.ts`), grid
with conditional row state (`report-section-bombings.test.ts`),
per-race sub-table (`report-section-foreign-sciences.test.ts`),
TOC (`report-toc.test.ts`). Each spec mounts the component
against a synthetic `RenderedReportSource`, so the orchestrator
/ IntersectionObserver are out of scope.
- **Playwright** — `tests/e2e/report-sections.spec.ts` exercises
the full integration: every TOC anchor lands its section in
view, the snapshot mechanism preserves `window.scrollY` on
history navigation, the back-to-map button reaches `/map`, the
mobile `<select>` scrolls to the chosen section on a narrow
viewport.
Test IDs follow the pattern `report-section-<slug>` for section
roots, `report-toc-<slug>` for TOC anchors, and per-section row
identifiers (e.g. `report-bombing-row`, `my-planets-row`).
+317
View File
@@ -38,12 +38,16 @@ import { Builder, ByteBuffer } from "flatbuffers";
import type { GalaxyClient } from "./galaxy-client"; import type { GalaxyClient } from "./galaxy-client";
import { UUID } from "../proto/galaxy/fbs/common"; import { UUID } from "../proto/galaxy/fbs/common";
import { import {
Bombing,
GameReportRequest, GameReportRequest,
IncomingGroup, IncomingGroup,
LocalFleet, LocalFleet,
LocalGroup, LocalGroup,
OtherGroup, OtherGroup,
OtherScience,
OthersShipClass,
Report, Report,
ShipProduction,
UnidentifiedGroup, UnidentifiedGroup,
} from "../proto/galaxy/fbs/report"; } from "../proto/galaxy/fbs/report";
import type { import type {
@@ -280,6 +284,113 @@ export interface ReportOtherRace {
votesReceived: number; 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 { export interface GameReport {
turn: number; turn: number;
mapWidth: number; mapWidth: number;
@@ -389,6 +500,50 @@ export interface GameReport {
* report always carries a non-empty value. * report always carries a non-empty value.
*/ */
myVoteFor: string; 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( export async function fetchGameReport(
@@ -537,11 +692,17 @@ function decodeReport(report: Report): GameReport {
const localTech = findLocalPlayerTech(report, raceName); const localTech = findLocalPlayerTech(report, raceName);
const otherRaces = collectOtherRaces(report, raceName); const otherRaces = collectOtherRaces(report, raceName);
const races = collectOtherRaceRows(report, raceName); const races = collectOtherRaceRows(report, raceName);
const players = decodePlayers(report, raceName);
const localShipGroups = decodeLocalShipGroups(report); const localShipGroups = decodeLocalShipGroups(report);
const otherShipGroups = decodeOtherShipGroups(report); const otherShipGroups = decodeOtherShipGroups(report);
const incomingShipGroups = decodeIncomingShipGroups(report); const incomingShipGroups = decodeIncomingShipGroups(report);
const unidentifiedShipGroups = decodeUnidentifiedShipGroups(report); const unidentifiedShipGroups = decodeUnidentifiedShipGroups(report);
const localFleets = decodeLocalFleets(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 { return {
turn: Number(report.turn()), turn: Number(report.turn()),
@@ -566,6 +727,12 @@ function decodeReport(report: Report): GameReport {
races, races,
myVotes: report.votes(), myVotes: report.votes(),
myVoteFor: report.voteFor() ?? "", myVoteFor: report.voteFor() ?? "",
players,
otherScience,
otherShipClass,
battleIds,
bombings,
shipProductions,
}; };
} }
@@ -903,6 +1070,146 @@ function collectOtherRaceRows(
return out; 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 * uuidToHiLo splits the canonical 36-character UUID string
* (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian * (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian
@@ -1125,6 +1432,16 @@ export function applyOrderOverlay(
localScience: mutatedScience ?? report.localScience ?? [], localScience: mutatedScience ?? report.localScience ?? [],
races: mutatedRaces ?? report.races ?? [], races: mutatedRaces ?? report.races ?? [],
myVoteFor: mutatedVoteFor ?? report.myVoteFor, 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 ?? [],
}; };
} }
+149
View File
@@ -20,13 +20,18 @@
import type { import type {
GameReport, GameReport,
ReportBombing,
ReportIncomingShipGroup, ReportIncomingShipGroup,
ReportLocalFleet, ReportLocalFleet,
ReportLocalShipGroup, ReportLocalShipGroup,
ReportOtherRace, ReportOtherRace,
ReportOtherScience,
ReportOtherShipClass,
ReportOtherShipGroup, ReportOtherShipGroup,
ReportPlanet, ReportPlanet,
ReportPlayer,
ReportRoute, ReportRoute,
ReportShipProduction,
ReportUnidentifiedShipGroup, ReportUnidentifiedShipGroup,
ScienceSummary, ScienceSummary,
ShipClassSummary, ShipClassSummary,
@@ -159,6 +164,39 @@ interface SyntheticScience {
cargo?: number; 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 { interface SyntheticReportRoot {
turn?: number; turn?: number;
mapWidth?: number; mapWidth?: number;
@@ -173,12 +211,17 @@ interface SyntheticReportRoot {
uninhabitedPlanet?: SyntheticPlanet[]; uninhabitedPlanet?: SyntheticPlanet[];
unidentifiedPlanet?: SyntheticPlanet[]; unidentifiedPlanet?: SyntheticPlanet[];
localShipClass?: SyntheticShipClass[]; localShipClass?: SyntheticShipClass[];
otherShipClass?: SyntheticOtherShipClass[];
localScience?: SyntheticScience[]; localScience?: SyntheticScience[];
otherScience?: SyntheticOtherScience[];
localGroup?: SyntheticShipGroup[]; localGroup?: SyntheticShipGroup[];
otherGroup?: SyntheticShipGroup[]; otherGroup?: SyntheticShipGroup[];
incomingGroup?: SyntheticIncomingGroup[]; incomingGroup?: SyntheticIncomingGroup[];
unidentifiedGroup?: SyntheticUnidentifiedGroup[]; unidentifiedGroup?: SyntheticUnidentifiedGroup[];
localFleet?: SyntheticLocalFleet[]; localFleet?: SyntheticLocalFleet[];
battle?: string[];
bombing?: SyntheticBombing[];
shipProduction?: SyntheticShipProductionRow[];
} }
function decodeSyntheticReport(json: unknown): GameReport { function decodeSyntheticReport(json: unknown): GameReport {
@@ -278,6 +321,78 @@ function decodeSyntheticReport(json: unknown): GameReport {
state: typeof f.state === "string" ? f.state : "", 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 { return {
turn: numOr0(root.turn), turn: numOr0(root.turn),
mapWidth: numOr0(root.mapWidth), mapWidth: numOr0(root.mapWidth),
@@ -301,9 +416,43 @@ function decodeSyntheticReport(json: unknown): GameReport {
races: collectOtherRaceRowsFromSynthetic(root, race), races: collectOtherRaceRowsFromSynthetic(root, race),
myVotes: numOr0(root.votes), myVotes: numOr0(root.votes),
myVoteFor: typeof root.voteFor === "string" ? root.voteFor : "", 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( function collectOtherRacesFromSynthetic(
root: SyntheticReportRoot, root: SyntheticReportRoot,
raceName: string, raceName: string,
+169 -16
View File
@@ -1,28 +1,181 @@
<!-- <!--
Phase 10 stub for the turn-report active view. Phase 23 replaces the Phase 23 turn-report active view.
body with the per-turn sections (cargo deliveries, completed sciences,
mail, etc.). 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"> <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> </script>
<section class="active-view" data-testid="active-view-report"> <div class="report-view" data-testid="active-view-report">
<h2>{i18n.t("game.view.report")}</h2> <ReportToc entries={ENTRIES} {activeSlug} {gameId} />
<p>{i18n.t("game.shell.coming_soon")}</p>
</section> <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> <style>
.active-view { .report-view {
padding: 1.5rem; display: grid;
grid-template-columns: 14rem 1fr;
gap: 1.25rem;
padding: 1rem 1.25rem 2rem;
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
} }
.active-view h2 { .report-view > :global(.report-toc) {
margin: 0 0 0.5rem; position: sticky;
font-size: 1.1rem; top: 0;
align-self: start;
padding: 0.5rem 0;
max-height: calc(100vh - 4rem);
overflow-y: auto;
} }
.active-view p { .report-body {
margin: 0; min-width: 0;
color: #555; 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> </style>
@@ -0,0 +1,75 @@
// Shared number / planet formatters for the Phase 23 Report View
// sections. Inlined in 10+ components, so factoring keeps each
// section component focused on its data shape. The formatters
// match the conventions of the per-entity tables (tabular numerals,
// one-decimal percent without a `%` suffix — the header carries the
// unit) so the report's grids read the same way as the
// table-races / table-sciences views.
import type { ReportPlanet } from "../../../api/game-state";
/**
* formatPercent renders a `[0, 1]` fraction as a one-decimal
* percent (without a `%` suffix — the column header carries the
* unit). Matches the convention used by `table-races.svelte` and
* `table-sciences.svelte`.
*/
export function formatPercent(fraction: number): string {
return (fraction * 100).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
}
/**
* formatCount renders an integer-ish value (population, industry,
* planet count, …) without fractional digits and with locale-aware
* thousand separators.
*/
export function formatCount(value: number): string {
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
}
/**
* formatFloat renders a floating-point value with up to two
* fractional digits. Used for stockpiles, distances, cost, mass —
* everything the engine emits as a `Float` that is not a fraction.
*/
export function formatFloat(value: number): string {
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}
/**
* formatVotes renders a vote weight with up to two decimal digits —
* mirrors the races table's column convention so the cumulative
* vote totals line up across views.
*/
export function formatVotes(value: number): string {
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}
/**
* planetLabel renders a planet reference as `#<number> (<name>)` if
* the planet is known in the report, or just `#<number>` if the
* lookup fails (visibility lost between turns, foreign-only data).
* Sections that show planet numbers without a name column —
* Ships in Production, Bombings — rely on this resolver to keep
* cell width tight.
*/
export function planetLabel(
number: number,
planets: readonly ReportPlanet[],
): string {
const p = planets.find((row) => row.number === number);
if (p === undefined || p.name === "") return `#${number}`;
return `#${number} (${p.name})`;
}
@@ -0,0 +1,202 @@
<!--
Phase 23 Report View table of contents.
Responsibilities:
- "Back to map" button at the top — visible on both desktop sidebar
and mobile sticky toolbar. Navigates via `$app/navigation.goto` so
active-view-host scroll restoration plays through SvelteKit's
history machinery and the layout's `mobileTool` resets naturally.
- Desktop / tablet sidebar: a vertical list of anchor links, one per
section. The active link gets `aria-current="location"` and a
`.active` style. Click scrolls the active-view-host (not the
window) by calling `scrollIntoView` on the matching section.
- Mobile (`max-width: 767.98px`): the sidebar collapses to a sticky
`<select>` at the top of the body — a minimal contract that does
not stack with the layout's bottom-tab bar. The same option list
drives both surfaces.
The active section is computed by the orchestrator
(`report.svelte`) via `IntersectionObserver` and passed in via the
`activeSlug` prop. The TOC itself owns no observers.
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
export interface TocEntry {
slug: string;
titleKey: TranslationKey;
}
type Props = {
entries: readonly TocEntry[];
activeSlug: string;
gameId: string;
};
let { entries, activeSlug, gameId }: Props = $props();
function scrollToSlug(slug: string): void {
const target = document.getElementById(`report-${slug}`);
if (target === null) return;
const reduced = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
target.scrollIntoView({
behavior: reduced ? "auto" : "smooth",
block: "start",
});
}
function onAnchorClick(event: MouseEvent, slug: string): void {
event.preventDefault();
scrollToSlug(slug);
}
function onSelectChange(event: Event): void {
const select = event.currentTarget as HTMLSelectElement;
const slug = select.value;
if (slug === "") return;
scrollToSlug(slug);
}
async function backToMap(): Promise<void> {
await goto(`/games/${gameId}/map`);
}
</script>
<aside
class="report-toc"
data-testid="report-toc"
aria-label={i18n.t("game.report.toc.title")}
>
<button
type="button"
class="back-to-map"
data-testid="report-back-to-map"
onclick={() => void backToMap()}
>
{i18n.t("game.report.back_to_map")}
</button>
<nav class="desktop" aria-label={i18n.t("game.report.toc.title")}>
<ul>
{#each entries as entry (entry.slug)}
<li>
<a
href={`#report-${entry.slug}`}
class:active={activeSlug === entry.slug}
aria-current={activeSlug === entry.slug
? "location"
: undefined}
data-testid="report-toc-{entry.slug}"
onclick={(e) => onAnchorClick(e, entry.slug)}
>
{i18n.t(entry.titleKey)}
</a>
</li>
{/each}
</ul>
</nav>
<label class="mobile">
<span class="visually-hidden">
{i18n.t("game.report.toc.mobile_label")}
</span>
<select
data-testid="report-toc-mobile"
value={activeSlug}
onchange={onSelectChange}
>
{#each entries as entry (entry.slug)}
<option value={entry.slug}>{i18n.t(entry.titleKey)}</option>
{/each}
</select>
</label>
</aside>
<style>
.report-toc {
display: flex;
flex-direction: column;
gap: 0.75rem;
font-family: system-ui, sans-serif;
}
.back-to-map {
font: inherit;
font-size: 0.85rem;
text-align: left;
padding: 0.4rem 0.6rem;
background: #11172a;
color: #cfd7ff;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.back-to-map:hover {
background: #1a2240;
color: #e8eaf6;
}
.desktop {
display: block;
}
.desktop ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.desktop a {
display: block;
padding: 0.3rem 0.6rem;
color: #aab;
text-decoration: none;
font-size: 0.85rem;
line-height: 1.3;
border-left: 2px solid transparent;
border-radius: 0 3px 3px 0;
}
.desktop a:hover {
color: #e8eaf6;
background: #11172a;
}
.desktop a.active {
color: #e8eaf6;
background: #11172a;
border-left-color: #4a6cf7;
}
.mobile {
display: none;
}
.mobile select {
width: 100%;
font: inherit;
padding: 0.4rem 0.5rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 767.98px) {
.desktop {
display: none;
}
.mobile {
display: block;
}
}
</style>
@@ -0,0 +1,99 @@
<!--
Phase 23 Report View — approaching groups section. Renders the wire
`incomingGroup[]` projection as a compact grid: origin → destination
along with distance / speed / mass. The wire field carries no
ship-class info (a true blip on radar); the player only learns the
class when the group lands and a battle roster forms.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat, planetLabel } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.incomingShipGroups ?? []);
const planets = $derived(report?.planets ?? []);
</script>
<section
id="report-approaching-groups"
class="grid-section"
data-testid="report-section-approaching-groups"
>
<h2>{i18n.t("game.report.section.approaching_groups.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="approaching-groups-empty">
{i18n.t("game.report.section.approaching_groups.empty")}
</p>
{:else}
<table class="grid" data-testid="approaching-groups-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.approaching_groups.column.from")}</th>
<th>{i18n.t("game.report.section.approaching_groups.column.to")}</th>
<th>
{i18n.t("game.report.section.approaching_groups.column.distance")}
</th>
<th>{i18n.t("game.report.section.approaching_groups.column.speed")}</th>
<th>{i18n.t("game.report.section.approaching_groups.column.mass")}</th>
</tr>
</thead>
<tbody>
{#each rows as r, i (i)}
<tr data-testid="approaching-groups-row">
<td>{planetLabel(r.origin, planets)}</td>
<td>{planetLabel(r.destination, planets)}</td>
<td>{formatFloat(r.distance)}</td>
<td>{formatFloat(r.speed)}</td>
<td>{formatFloat(r.mass)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,91 @@
<!--
Phase 23 Report View — battles section. The wire only carries
battle UUIDs (the full battle report is fetched lazily by Phase 27),
so each row is a monospace, non-interactive `<span>` of the battle
identifier. Phase 27 will turn each row into a link to
`/games/<id>/battle/<uuid>`; until then dead links are worse than
plain text.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const ids = $derived(report?.battleIds ?? []);
</script>
<section
id="report-battles"
class="grid-section"
data-testid="report-section-battles"
>
<h2>{i18n.t("game.report.section.battles.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if ids.length === 0}
<p class="status" data-testid="battles-empty">
{i18n.t("game.report.section.battles.empty")}
</p>
{:else}
<ul class="ids" data-testid="battles-list">
{#each ids as id (id)}
<li>
<span class="label">
{i18n.t("game.report.section.battles.id_label")}
</span>
<span
class="uuid"
data-testid="report-battle-row"
data-id={id}
>{id}</span>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.ids {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
}
.ids li {
display: flex;
align-items: baseline;
gap: 0.6rem;
}
.label {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.7rem;
}
.uuid {
color: #cfd7ff;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
</style>
@@ -0,0 +1,139 @@
<!--
Phase 23 Report View — bombings section. One row per bombing
event; wiped planets get a visually-distinct row state plus a
"wiped" badge so the boolean is explicit for e2e assertions.
Decoder sorts by `planetNumber` already.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatCount, formatFloat } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.bombings ?? []);
</script>
<section
id="report-bombings"
class="grid-section"
data-testid="report-section-bombings"
>
<h2>{i18n.t("game.report.section.bombings.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="bombings-empty">
{i18n.t("game.report.section.bombings.empty")}
</p>
{:else}
<table class="grid" data-testid="bombings-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.bombings.column.planet")}</th>
<th>{i18n.t("game.report.section.bombings.column.owner")}</th>
<th>{i18n.t("game.report.section.bombings.column.attacker")}</th>
<th>{i18n.t("game.report.section.bombings.column.production")}</th>
<th>{i18n.t("game.report.section.bombings.column.industry")}</th>
<th>{i18n.t("game.report.section.bombings.column.population")}</th>
<th>{i18n.t("game.report.section.bombings.column.colonists")}</th>
<th>
{i18n.t("game.report.section.bombings.column.industry_stockpile")}
</th>
<th>
{i18n.t("game.report.section.bombings.column.materials_stockpile")}
</th>
<th>{i18n.t("game.report.section.bombings.column.attack_power")}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each rows as b (`${b.planetNumber}/${b.attacker}/${b.owner}`)}
<tr
data-testid="report-bombing-row"
data-planet={b.planetNumber}
data-wiped={b.wiped ? "true" : "false"}
class:wiped={b.wiped}
>
<td>#{b.planetNumber} ({b.planet})</td>
<td>{b.owner}</td>
<td>{b.attacker}</td>
<td>{b.production}</td>
<td>{formatFloat(b.industry)}</td>
<td>{formatFloat(b.population)}</td>
<td>{formatFloat(b.colonists)}</td>
<td>{formatFloat(b.industryStockpile)}</td>
<td>{formatFloat(b.materialsStockpile)}</td>
<td>{formatCount(b.attackPower)}</td>
<td>
{#if b.wiped}
<span
class="wiped-badge"
data-testid="report-bombing-wiped-badge"
>
{i18n.t("game.report.section.bombings.wiped")}
</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
.wiped td {
color: #c97a7a;
}
.wiped-badge {
display: inline-block;
padding: 0.1rem 0.45rem;
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
background: #4a1010;
color: #ffcaca;
border: 1px solid #8a3030;
border-radius: 3px;
}
</style>
@@ -0,0 +1,114 @@
<!--
Phase 23 Report View — cargo routes section. The wire `routes[]`
groups by source planet; each entry inside a route is one
(loadType, destination) pair. The section flattens both to a single
table — anchor jumps into a single visual unit even when the player
has many routes.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { planetLabel } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const planets = $derived(report?.planets ?? []);
const rows = $derived.by(() => {
const out: {
sourcePlanetNumber: number;
loadType: string;
destinationPlanetNumber: number;
}[] = [];
for (const route of report?.routes ?? []) {
for (const entry of route.entries) {
out.push({
sourcePlanetNumber: route.sourcePlanetNumber,
loadType: entry.loadType,
destinationPlanetNumber: entry.destinationPlanetNumber,
});
}
}
return out;
});
</script>
<section
id="report-cargo-routes"
class="grid-section"
data-testid="report-section-cargo-routes"
>
<h2>{i18n.t("game.report.section.cargo_routes.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="cargo-routes-empty">
{i18n.t("game.report.section.cargo_routes.empty")}
</p>
{:else}
<table class="grid" data-testid="cargo-routes-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.cargo_routes.column.source")}</th>
<th>{i18n.t("game.report.section.cargo_routes.column.load")}</th>
<th>{i18n.t("game.report.section.cargo_routes.column.destination")}</th>
</tr>
</thead>
<tbody>
{#each rows as r (`${r.sourcePlanetNumber}/${r.loadType}`)}
<tr
data-testid="cargo-routes-row"
data-source={r.sourcePlanetNumber}
data-load={r.loadType}
>
<td>{planetLabel(r.sourcePlanetNumber, planets)}</td>
<td>{r.loadType}</td>
<td>{planetLabel(r.destinationPlanetNumber, planets)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,116 @@
<!--
Phase 23 Report View — foreign planets section. Filters `planets[]`
to the `kind === "other"` entries and renders the same column set
as the local planets table plus an `owner` column.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(
(report?.planets ?? []).filter((p) => p.kind === "other"),
);
</script>
<section
id="report-foreign-planets"
class="grid-section"
data-testid="report-section-foreign-planets"
>
<h2>{i18n.t("game.report.section.foreign_planets.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="foreign-planets-empty">
{i18n.t("game.report.section.foreign_planets.empty")}
</p>
{:else}
<table class="grid" data-testid="foreign-planets-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_planets.column.number")}</th>
<th>{i18n.t("game.report.section.my_planets.column.name")}</th>
<th>{i18n.t("game.report.section.foreign_planets.column.owner")}</th>
<th>{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
<th>{i18n.t("game.report.section.my_planets.column.size")}</th>
<th>{i18n.t("game.report.section.my_planets.column.resources")}</th>
<th>{i18n.t("game.report.section.my_planets.column.population")}</th>
<th>{i18n.t("game.report.section.my_planets.column.industry")}</th>
<th>
{i18n.t("game.report.section.my_planets.column.industry_stockpile")}
</th>
<th>
{i18n.t("game.report.section.my_planets.column.materials_stockpile")}
</th>
<th>{i18n.t("game.report.section.my_planets.column.colonists")}</th>
<th>{i18n.t("game.report.section.my_planets.column.production")}</th>
<th>{i18n.t("game.report.section.my_planets.column.free_industry")}</th>
</tr>
</thead>
<tbody>
{#each rows as p (p.number)}
<tr data-testid="foreign-planets-row" data-number={p.number}>
<td>{p.number}</td>
<td>{p.name}</td>
<td>{p.owner ?? ""}</td>
<td>{formatFloat(p.x)}, {formatFloat(p.y)}</td>
<td>{formatFloat(p.size ?? 0)}</td>
<td>{formatFloat(p.resources ?? 0)}</td>
<td>{formatFloat(p.population ?? 0)}</td>
<td>{formatFloat(p.industry ?? 0)}</td>
<td>{formatFloat(p.industryStockpile ?? 0)}</td>
<td>{formatFloat(p.materialsStockpile ?? 0)}</td>
<td>{formatFloat(p.colonists ?? 0)}</td>
<td>{p.production ?? "—"}</td>
<td>{formatFloat(p.freeIndustry ?? 0)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,135 @@
<!--
Phase 23 Report View — foreign sciences section. Renders one
sub-table per race, mirroring the legacy "<Race> Sciences" layout.
Sorted alphabetically by race name (the decoder already produces
the (race, name) order); the sub-table groups are built here so
that anchor navigation to the section lands on a single visual
unit even when the section spans many races.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import type { ReportOtherScience } from "../../../api/game-state";
import { formatPercent } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.otherScience ?? []);
// Decoder already sorts by (race, name); a simple linear walk
// builds an array of {race, rows[]} groups.
const grouped = $derived.by(() => {
const out: { race: string; entries: ReportOtherScience[] }[] = [];
let current: { race: string; entries: ReportOtherScience[] } | null = null;
for (const row of rows) {
if (current === null || current.race !== row.race) {
current = { race: row.race, entries: [] };
out.push(current);
}
current.entries.push(row);
}
return out;
});
</script>
<section
id="report-foreign-sciences"
class="grid-section"
data-testid="report-section-foreign-sciences"
>
<h2>{i18n.t("game.report.section.foreign_sciences.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if grouped.length === 0}
<p class="status" data-testid="foreign-sciences-empty">
{i18n.t("game.report.section.foreign_sciences.empty")}
</p>
{:else}
{#each grouped as group (group.race)}
<h3
class="race-header"
data-testid="report-other-science-race"
data-race={group.race}
>
{i18n.t("game.report.section.foreign_sciences.race_header", {
race: group.race,
})}
</h3>
<table class="grid" data-testid="foreign-sciences-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_sciences.column.name")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.drive")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.weapons")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.shields")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.cargo")}</th>
</tr>
</thead>
<tbody>
{#each group.entries as r (`${r.race}/${r.name}`)}
<tr
data-testid="foreign-sciences-row"
data-race={r.race}
data-name={r.name}
>
<td>{r.name}</td>
<td>{formatPercent(r.drive)}</td>
<td>{formatPercent(r.weapons)}</td>
<td>{formatPercent(r.shields)}</td>
<td>{formatPercent(r.cargo)}</td>
</tr>
{/each}
</tbody>
</table>
{/each}
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.race-header {
margin: 0.75rem 0 0.3rem;
font-size: 0.85rem;
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,137 @@
<!--
Phase 23 Report View — foreign ship classes section. One sub-table
per race (decoder sorts `(race, name)`); columns extend the local
ship-class layout with `mass`, which is exposed on the wire's
`OthersShipClass` and useful for fleet-mass comparison against
incoming groups.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import type { ReportOtherShipClass } from "../../../api/game-state";
import { formatFloat } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.otherShipClass ?? []);
const grouped = $derived.by(() => {
const out: { race: string; entries: ReportOtherShipClass[] }[] = [];
let current: { race: string; entries: ReportOtherShipClass[] } | null =
null;
for (const row of rows) {
if (current === null || current.race !== row.race) {
current = { race: row.race, entries: [] };
out.push(current);
}
current.entries.push(row);
}
return out;
});
</script>
<section
id="report-foreign-ship-classes"
class="grid-section"
data-testid="report-section-foreign-ship-classes"
>
<h2>{i18n.t("game.report.section.foreign_ship_classes.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if grouped.length === 0}
<p class="status" data-testid="foreign-ship-classes-empty">
{i18n.t("game.report.section.foreign_ship_classes.empty")}
</p>
{:else}
{#each grouped as group (group.race)}
<h3
class="race-header"
data-testid="report-other-ship-class-race"
data-race={group.race}
>
{i18n.t("game.report.section.foreign_ship_classes.race_header", {
race: group.race,
})}
</h3>
<table class="grid" data-testid="foreign-ship-classes-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_ship_classes.column.name")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.drive")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.armament")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.weapons")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.shields")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.cargo")}</th>
<th>{i18n.t("game.report.section.foreign_ship_classes.column.mass")}</th>
</tr>
</thead>
<tbody>
{#each group.entries as r (`${r.race}/${r.name}`)}
<tr
data-testid="foreign-ship-classes-row"
data-race={r.race}
data-name={r.name}
>
<td>{r.name}</td>
<td>{formatFloat(r.drive)}</td>
<td>{r.armament}</td>
<td>{formatFloat(r.weapons)}</td>
<td>{formatFloat(r.shields)}</td>
<td>{formatFloat(r.cargo)}</td>
<td>{formatFloat(r.mass)}</td>
</tr>
{/each}
</tbody>
</table>
{/each}
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.race-header {
margin: 0.75rem 0 0.3rem;
font-size: 0.85rem;
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,108 @@
<!--
Phase 23 Report View — foreign ship groups section. `otherShipGroups[]`
omits the local-only fields (id, state, fleet) — those don't apply
to groups the player doesn't own.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat, planetLabel } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.otherShipGroups ?? []);
const planets = $derived(report?.planets ?? []);
function cargoCell(cargo: string, load: number): string {
if (cargo === "NONE") return "—";
return `${cargo} (${formatFloat(load)})`;
}
</script>
<section
id="report-foreign-ship-groups"
class="grid-section"
data-testid="report-section-foreign-ship-groups"
>
<h2>{i18n.t("game.report.section.foreign_ship_groups.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="foreign-ship-groups-empty">
{i18n.t("game.report.section.foreign_ship_groups.empty")}
</p>
{:else}
<table class="grid" data-testid="foreign-ship-groups-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_ship_groups.column.class")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.count")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.cargo")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.destination")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.origin")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.range")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.speed")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.mass")}</th>
</tr>
</thead>
<tbody>
{#each rows as g, i (i)}
<tr data-testid="foreign-ship-groups-row">
<td>{g.class}</td>
<td>{g.count}</td>
<td>{cargoCell(g.cargo, g.load)}</td>
<td>{planetLabel(g.destination, planets)}</td>
<td>
{g.origin === null ? "—" : planetLabel(g.origin, planets)}
</td>
<td>{g.range === null ? "—" : formatFloat(g.range)}</td>
<td>{formatFloat(g.speed)}</td>
<td>{formatFloat(g.mass)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,76 @@
<!--
Phase 23 Report View — galaxy summary section. Renders the per-turn
header data (turn, map dimensions, planet count, calling race name)
as a definition-list. The data lives on `GameReport` directly; the
section is never empty as long as the report has loaded.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
</script>
<section
id="report-galaxy-summary"
class="grid-section"
data-testid="report-section-galaxy-summary"
>
<h2>{i18n.t("game.report.section.galaxy_summary.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else}
<dl class="kv">
<dt>{i18n.t("game.report.section.galaxy_summary.field.turn")}</dt>
<dd data-testid="galaxy-summary-field-turn">{report.turn}</dd>
<dt>{i18n.t("game.report.section.galaxy_summary.field.size")}</dt>
<dd data-testid="galaxy-summary-field-size">
{report.mapWidth} × {report.mapHeight}
</dd>
<dt>{i18n.t("game.report.section.galaxy_summary.field.planets")}</dt>
<dd data-testid="galaxy-summary-field-planets">{report.planetCount}</dd>
<dt>{i18n.t("game.report.section.galaxy_summary.field.race")}</dt>
<dd data-testid="galaxy-summary-field-race">{report.race}</dd>
</dl>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.kv {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.3rem 1rem;
margin: 0;
font-size: 0.9rem;
}
.kv dt {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.kv dd {
margin: 0;
color: #e8eaf6;
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,101 @@
<!--
Phase 23 Report View — my fleets section. Renders `localFleets[]`
with the wire fields. `origin` and `range` are nullable (a fleet
in orbit has neither); empty cells in those columns are normal.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat, planetLabel } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.localFleets ?? []);
const planets = $derived(report?.planets ?? []);
</script>
<section
id="report-my-fleets"
class="grid-section"
data-testid="report-section-my-fleets"
>
<h2>{i18n.t("game.report.section.my_fleets.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="my-fleets-empty">
{i18n.t("game.report.section.my_fleets.empty")}
</p>
{:else}
<table class="grid" data-testid="my-fleets-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_fleets.column.name")}</th>
<th>{i18n.t("game.report.section.my_fleets.column.groups")}</th>
<th>{i18n.t("game.report.section.my_fleets.column.state")}</th>
<th>{i18n.t("game.report.section.my_fleets.column.destination")}</th>
<th>{i18n.t("game.report.section.my_fleets.column.origin")}</th>
<th>{i18n.t("game.report.section.my_fleets.column.range")}</th>
<th>{i18n.t("game.report.section.my_fleets.column.speed")}</th>
</tr>
</thead>
<tbody>
{#each rows as f (f.name)}
<tr data-testid="my-fleets-row" data-name={f.name}>
<td>{f.name}</td>
<td>{f.groupCount}</td>
<td>{f.state}</td>
<td>{planetLabel(f.destination, planets)}</td>
<td>
{f.origin === null ? "—" : planetLabel(f.origin, planets)}
</td>
<td>{f.range === null ? "—" : formatFloat(f.range)}</td>
<td>{formatFloat(f.speed)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,114 @@
<!--
Phase 23 Report View — my planets section. Filters `planets[]` to
the `kind === "local"` entries and renders the full local-planet
column set (matches `ReportPlanet` shape).
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(
(report?.planets ?? []).filter((p) => p.kind === "local"),
);
</script>
<section
id="report-my-planets"
class="grid-section"
data-testid="report-section-my-planets"
>
<h2>{i18n.t("game.report.section.my_planets.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="my-planets-empty">
{i18n.t("game.report.section.my_planets.empty")}
</p>
{:else}
<table class="grid" data-testid="my-planets-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_planets.column.number")}</th>
<th>{i18n.t("game.report.section.my_planets.column.name")}</th>
<th>{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
<th>{i18n.t("game.report.section.my_planets.column.size")}</th>
<th>{i18n.t("game.report.section.my_planets.column.resources")}</th>
<th>{i18n.t("game.report.section.my_planets.column.population")}</th>
<th>{i18n.t("game.report.section.my_planets.column.industry")}</th>
<th>
{i18n.t("game.report.section.my_planets.column.industry_stockpile")}
</th>
<th>
{i18n.t("game.report.section.my_planets.column.materials_stockpile")}
</th>
<th>{i18n.t("game.report.section.my_planets.column.colonists")}</th>
<th>{i18n.t("game.report.section.my_planets.column.production")}</th>
<th>{i18n.t("game.report.section.my_planets.column.free_industry")}</th>
</tr>
</thead>
<tbody>
{#each rows as p (p.number)}
<tr data-testid="my-planets-row" data-number={p.number}>
<td>{p.number}</td>
<td>{p.name}</td>
<td>{formatFloat(p.x)}, {formatFloat(p.y)}</td>
<td>{formatFloat(p.size ?? 0)}</td>
<td>{formatFloat(p.resources ?? 0)}</td>
<td>{formatFloat(p.population ?? 0)}</td>
<td>{formatFloat(p.industry ?? 0)}</td>
<td>{formatFloat(p.industryStockpile ?? 0)}</td>
<td>{formatFloat(p.materialsStockpile ?? 0)}</td>
<td>{formatFloat(p.colonists ?? 0)}</td>
<td>{p.production ?? "—"}</td>
<td>{formatFloat(p.freeIndustry ?? 0)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,95 @@
<!--
Phase 23 Report View — my sciences section. Reads `localScience[]`
from the overlay-applied report (which means pending CreateScience
/ RemoveScience drafts surface here just like on the sciences
table).
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatPercent } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.localScience ?? []);
</script>
<section
id="report-my-sciences"
class="grid-section"
data-testid="report-section-my-sciences"
>
<h2>{i18n.t("game.report.section.my_sciences.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="my-sciences-empty">
{i18n.t("game.report.section.my_sciences.empty")}
</p>
{:else}
<table class="grid" data-testid="my-sciences-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_sciences.column.name")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.drive")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.weapons")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.shields")}</th>
<th>{i18n.t("game.report.section.my_sciences.column.cargo")}</th>
</tr>
</thead>
<tbody>
{#each rows as r (r.name)}
<tr data-testid="my-sciences-row" data-name={r.name}>
<td>{r.name}</td>
<td>{formatPercent(r.drive)}</td>
<td>{formatPercent(r.weapons)}</td>
<td>{formatPercent(r.shields)}</td>
<td>{formatPercent(r.cargo)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,98 @@
<!--
Phase 23 Report View — my ship classes section. Mirrors the
sciences section's layout for `localShipClass[]`, with the
ship-class numeric columns (drive / armament / weapons / shields /
cargo). The overlay-applied report surfaces pending create/remove
drafts immediately, matching the ship-class designer's behaviour.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.localShipClass ?? []);
</script>
<section
id="report-my-ship-classes"
class="grid-section"
data-testid="report-section-my-ship-classes"
>
<h2>{i18n.t("game.report.section.my_ship_classes.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="my-ship-classes-empty">
{i18n.t("game.report.section.my_ship_classes.empty")}
</p>
{:else}
<table class="grid" data-testid="my-ship-classes-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_ship_classes.column.name")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.drive")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.armament")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.weapons")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.shields")}</th>
<th>{i18n.t("game.report.section.my_ship_classes.column.cargo")}</th>
</tr>
</thead>
<tbody>
{#each rows as r (r.name)}
<tr data-testid="my-ship-classes-row" data-name={r.name}>
<td>{r.name}</td>
<td>{formatFloat(r.drive)}</td>
<td>{r.armament}</td>
<td>{formatFloat(r.weapons)}</td>
<td>{formatFloat(r.shields)}</td>
<td>{formatFloat(r.cargo)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,126 @@
<!--
Phase 23 Report View — my ship groups section. Renders the local
ship groups with a short-form id (first 8 hex chars; the full UUID
is in `data-id` for tests and copy-paste lookups). `cargo` is
shown together with `load` when carrying.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat, planetLabel } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.localShipGroups ?? []);
const planets = $derived(report?.planets ?? []);
function shortId(id: string): string {
return id.slice(0, 8);
}
function cargoCell(
cargo: string,
load: number,
): string {
if (cargo === "NONE") return "—";
return `${cargo} (${formatFloat(load)})`;
}
</script>
<section
id="report-my-ship-groups"
class="grid-section"
data-testid="report-section-my-ship-groups"
>
<h2>{i18n.t("game.report.section.my_ship_groups.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="my-ship-groups-empty">
{i18n.t("game.report.section.my_ship_groups.empty")}
</p>
{:else}
<table class="grid" data-testid="my-ship-groups-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_ship_groups.column.id")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.class")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.count")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.cargo")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.state")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.destination")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.origin")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.range")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.speed")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.mass")}</th>
<th>{i18n.t("game.report.section.my_ship_groups.column.fleet")}</th>
</tr>
</thead>
<tbody>
{#each rows as g (g.id)}
<tr data-testid="my-ship-groups-row" data-id={g.id}>
<td><span class="uuid">{shortId(g.id)}</span></td>
<td>{g.class}</td>
<td>{g.count}</td>
<td>{cargoCell(g.cargo, g.load)}</td>
<td>{g.state}</td>
<td>{planetLabel(g.destination, planets)}</td>
<td>
{g.origin === null ? "—" : planetLabel(g.origin, planets)}
</td>
<td>{g.range === null ? "—" : formatFloat(g.range)}</td>
<td>{formatFloat(g.speed)}</td>
<td>{formatFloat(g.mass)}</td>
<td>{g.fleet ?? "—"}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
.uuid {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: #cfd7ff;
}
</style>
@@ -0,0 +1,138 @@
<!--
Phase 23 Report View — player status section. Mirrors the legacy
"Status of Players" table: every named row in the FBS player block,
local player included, extinct rows marked with the RIP suffix.
Rows are sorted alphabetically (case-insensitive) by the decoder.
The local player's row gets a "(you)" marker and a visual
highlight so the user can locate themselves quickly.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatCount, formatPercent, formatVotes } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const players = $derived(report?.players ?? []);
</script>
<section
id="report-player-status"
class="grid-section"
data-testid="report-section-player-status"
>
<h2>{i18n.t("game.report.section.player_status.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else}
<table class="grid" data-testid="player-status-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.player_status.column.name")}</th>
<th>{i18n.t("game.report.section.player_status.column.drive")}</th>
<th>{i18n.t("game.report.section.player_status.column.weapons")}</th>
<th>{i18n.t("game.report.section.player_status.column.shields")}</th>
<th>{i18n.t("game.report.section.player_status.column.cargo")}</th>
<th>{i18n.t("game.report.section.player_status.column.population")}</th>
<th>{i18n.t("game.report.section.player_status.column.industry")}</th>
<th>{i18n.t("game.report.section.player_status.column.planets")}</th>
<th>{i18n.t("game.report.section.player_status.column.votes")}</th>
</tr>
</thead>
<tbody>
{#each players as p (p.name)}
<tr
data-testid="player-status-row"
data-name={p.name}
data-local={p.isLocal ? "true" : "false"}
data-extinct={p.extinct ? "true" : "false"}
class:local={p.isLocal}
class:extinct={p.extinct}
>
<td>
<span>{p.name}</span>
{#if p.isLocal}
<span class="marker local-marker">
({i18n.t("game.report.section.player_status.local_marker")})
</span>
{/if}
{#if p.extinct}
<span
class="marker extinct-marker"
data-testid="player-status-extinct-marker"
>
{i18n.t("game.report.section.player_status.extinct_marker")}
</span>
{/if}
</td>
<td>{formatPercent(p.drive)}</td>
<td>{formatPercent(p.weapons)}</td>
<td>{formatPercent(p.shields)}</td>
<td>{formatPercent(p.cargo)}</td>
<td>{formatCount(p.population)}</td>
<td>{formatCount(p.industry)}</td>
<td>{formatCount(p.planets)}</td>
<td>{formatVotes(p.votesReceived)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
.local td {
background: #11203d;
}
.extinct td {
color: #889;
}
.marker {
margin-left: 0.4rem;
font-size: 0.75rem;
color: #aab;
}
.extinct-marker {
color: #c97a7a;
letter-spacing: 0.08em;
}
</style>
@@ -0,0 +1,104 @@
<!--
Phase 23 Report View — ships in production section. Sort follows
the decoder: `(planetNumber, class)` for a stable "find planet N"
scan. The planet name is resolved against `planets[]` so the row
reads `#17 (Castle)` rather than just `#17`.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat, planetLabel } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.shipProductions ?? []);
const planets = $derived(report?.planets ?? []);
</script>
<section
id="report-ships-in-production"
class="grid-section"
data-testid="report-section-ships-in-production"
>
<h2>{i18n.t("game.report.section.ships_in_production.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="ships-in-production-empty">
{i18n.t("game.report.section.ships_in_production.empty")}
</p>
{:else}
<table class="grid" data-testid="ships-in-production-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.ships_in_production.column.planet")}</th>
<th>{i18n.t("game.report.section.ships_in_production.column.class")}</th>
<th>{i18n.t("game.report.section.ships_in_production.column.cost")}</th>
<th>
{i18n.t("game.report.section.ships_in_production.column.prod_used")}
</th>
<th>{i18n.t("game.report.section.ships_in_production.column.percent")}</th>
<th>{i18n.t("game.report.section.ships_in_production.column.free")}</th>
</tr>
</thead>
<tbody>
{#each rows as r (`${r.planetNumber}/${r.class}`)}
<tr
data-testid="ships-in-production-row"
data-planet={r.planetNumber}
data-class={r.class}
>
<td>{planetLabel(r.planetNumber, planets)}</td>
<td>{r.class}</td>
<td>{formatFloat(r.cost)}</td>
<td>{formatFloat(r.prodUsed)}</td>
<td>{(r.percent * 100).toFixed(1)}</td>
<td>{formatFloat(r.freeIndustry)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,88 @@
<!--
Phase 23 Report View — unidentified groups section. The wire's
`UnidentifiedGroup` carries only absolute coordinates — a blip on
radar that doesn't even resolve to a planet.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.unidentifiedShipGroups ?? []);
</script>
<section
id="report-unidentified-groups"
class="grid-section"
data-testid="report-section-unidentified-groups"
>
<h2>{i18n.t("game.report.section.unidentified_groups.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="unidentified-groups-empty">
{i18n.t("game.report.section.unidentified_groups.empty")}
</p>
{:else}
<table class="grid" data-testid="unidentified-groups-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.unidentified_groups.column.x")}</th>
<th>{i18n.t("game.report.section.unidentified_groups.column.y")}</th>
</tr>
</thead>
<tbody>
{#each rows as g, i (i)}
<tr data-testid="unidentified-groups-row">
<td>{formatFloat(g.x)}</td>
<td>{formatFloat(g.y)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,105 @@
<!--
Phase 23 Report View — uninhabited planets section. The wire's
`UninhabitedPlanet` carries number / coordinates / size / resources /
stockpiles, but no production / population / industry — those columns
are intentionally omitted.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(
(report?.planets ?? []).filter((p) => p.kind === "uninhabited"),
);
</script>
<section
id="report-uninhabited-planets"
class="grid-section"
data-testid="report-section-uninhabited-planets"
>
<h2>{i18n.t("game.report.section.uninhabited_planets.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="uninhabited-planets-empty">
{i18n.t("game.report.section.uninhabited_planets.empty")}
</p>
{:else}
<table class="grid" data-testid="uninhabited-planets-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_planets.column.number")}</th>
<th>{i18n.t("game.report.section.my_planets.column.name")}</th>
<th>{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
<th>{i18n.t("game.report.section.my_planets.column.size")}</th>
<th>{i18n.t("game.report.section.my_planets.column.resources")}</th>
<th>
{i18n.t("game.report.section.my_planets.column.industry_stockpile")}
</th>
<th>
{i18n.t("game.report.section.my_planets.column.materials_stockpile")}
</th>
</tr>
</thead>
<tbody>
{#each rows as p (p.number)}
<tr data-testid="uninhabited-planets-row" data-number={p.number}>
<td>{p.number}</td>
<td>{p.name}</td>
<td>{formatFloat(p.x)}, {formatFloat(p.y)}</td>
<td>{formatFloat(p.size ?? 0)}</td>
<td>{formatFloat(p.resources ?? 0)}</td>
<td>{formatFloat(p.industryStockpile ?? 0)}</td>
<td>{formatFloat(p.materialsStockpile ?? 0)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,90 @@
<!--
Phase 23 Report View — unknown planets section. The wire's
`UnidentifiedPlanet` carries only coordinates and number; nothing
else is known.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(
(report?.planets ?? []).filter((p) => p.kind === "unidentified"),
);
</script>
<section
id="report-unknown-planets"
class="grid-section"
data-testid="report-section-unknown-planets"
>
<h2>{i18n.t("game.report.section.unknown_planets.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="unknown-planets-empty">
{i18n.t("game.report.section.unknown_planets.empty")}
</p>
{:else}
<table class="grid" data-testid="unknown-planets-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.my_planets.column.number")}</th>
<th>{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
</tr>
</thead>
<tbody>
{#each rows as p (p.number)}
<tr data-testid="unknown-planets-row" data-number={p.number}>
<td>{p.number}</td>
<td>{formatFloat(p.x)}, {formatFloat(p.y)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,130 @@
<!--
Phase 23 Report View — votes section. Surfaces the local player's
total vote weight (`myVotes`), the recipient they cast their vote
for (`myVoteFor`), and the per-other-race table of votes received
in the last tally. The full vote graph is not reconstructable from
the client side because each race's outgoing vote target is
private; the section shows only the public datums and mirrors the
explanatory text on the races table.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatVotes } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const races = $derived(report?.races ?? []);
const empty = $derived(report !== null && races.length === 0);
</script>
<section
id="report-votes"
class="grid-section"
data-testid="report-section-votes"
>
<h2>{i18n.t("game.report.section.votes.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else}
<dl class="kv">
<dt>{i18n.t("game.report.section.votes.mine")}</dt>
<dd data-testid="votes-mine">{formatVotes(report.myVotes)}</dd>
<dt>{i18n.t("game.report.section.votes.target")}</dt>
<dd data-testid="votes-target">
{#if report.myVoteFor === ""}
{i18n.t("game.report.section.votes.target_none")}
{:else}
{report.myVoteFor}
{/if}
</dd>
</dl>
{#if empty}
<p class="status" data-testid="votes-empty">
{i18n.t("game.report.section.votes.empty")}
</p>
{:else}
<h3>{i18n.t("game.report.section.votes.received_header")}</h3>
<table class="grid" data-testid="votes-received-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.votes.column.race")}</th>
<th>{i18n.t("game.report.section.votes.column.votes")}</th>
</tr>
</thead>
<tbody>
{#each races as r (r.name)}
<tr data-testid="votes-received-row" data-race={r.name}>
<td>{r.name}</td>
<td>{formatVotes(r.votesReceived)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.grid-section h3 {
margin: 1rem 0 0.4rem;
font-size: 0.85rem;
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.kv {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.3rem 1rem;
margin: 0;
font-size: 0.9rem;
}
.kv dt {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.kv dd {
margin: 0;
color: #e8eaf6;
font-variant-numeric: tabular-nums;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
</style>
+137
View File
@@ -408,6 +408,143 @@ const en = {
"game.inspector.planet.ship_groups.row.mass": "mass {mass}", "game.inspector.planet.ship_groups.row.mass": "mass {mass}",
"game.inspector.planet.ship_groups.race.unknown": "unknown", "game.inspector.planet.ship_groups.race.unknown": "unknown",
"game.inspector.planet.ship_groups.race.foreign": "foreign", "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; } as const;
export default en; export default en;
+137
View File
@@ -409,6 +409,143 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.planet.ship_groups.row.mass": "масса {mass}", "game.inspector.planet.ship_groups.row.mass": "масса {mass}",
"game.inspector.planet.ship_groups.race.unknown": "неизвестно", "game.inspector.planet.ship_groups.race.unknown": "неизвестно",
"game.inspector.planet.ship_groups.race.foreign": "чужие", "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; 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"> <script lang="ts">
import type { Snapshot } from "@sveltejs/kit";
import ReportView from "$lib/active-view/report.svelte"; 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> </script>
<ReportView /> <ReportView />
@@ -17,15 +17,20 @@
import { Builder } from "flatbuffers"; import { Builder } from "flatbuffers";
import { UUID } from "../../../src/proto/galaxy/fbs/common";
import { import {
Bombing,
LocalPlanet, LocalPlanet,
OtherPlanet, OtherPlanet,
OtherScience,
OthersShipClass,
Player, Player,
Report, Report,
Route, Route,
RouteEntry, RouteEntry,
Science, Science,
ShipClass, ShipClass,
ShipProduction,
UnidentifiedPlanet, UnidentifiedPlanet,
UninhabitedPlanet, UninhabitedPlanet,
} from "../../../src/proto/galaxy/fbs/report"; } from "../../../src/proto/galaxy/fbs/report";
@@ -94,6 +99,39 @@ export interface RouteFixture {
entries: RouteEntryFixture[]; 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 { export interface ReportFixture {
turn: number; turn: number;
mapWidth?: number; mapWidth?: number;
@@ -109,6 +147,11 @@ export interface ReportFixture {
routes?: RouteFixture[]; routes?: RouteFixture[];
myVotes?: number; myVotes?: number;
myVoteFor?: string; myVoteFor?: string;
otherScience?: OtherScienceFixture[];
otherShipClass?: OtherShipClassFixture[];
battles?: string[];
bombings?: BombingFixture[];
shipProductions?: ShipProductionFixture[];
} }
export function buildReportPayload(fixture: ReportFixture): Uint8Array { export function buildReportPayload(fixture: ReportFixture): Uint8Array {
@@ -245,6 +288,67 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
return Route.endRoute(builder); 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 = const localVec =
localOffsets.length === 0 localOffsets.length === 0
? null ? null
@@ -277,6 +381,36 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
routeOffsets.length === 0 routeOffsets.length === 0
? null ? null
: Report.createRouteVector(builder, routeOffsets); : 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 = const raceOffset =
fixture.race === undefined ? null : builder.createString(fixture.race); fixture.race === undefined ? null : builder.createString(fixture.race);
const voteForOffset = const voteForOffset =
@@ -308,7 +442,25 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
if (localScienceVec !== null) if (localScienceVec !== null)
Report.addLocalScience(builder, localScienceVec); Report.addLocalScience(builder, localScienceVec);
if (routeVec !== null) Report.addRoute(builder, routeVec); 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); const reportOff = Report.endReport(builder);
builder.finish(reportOff); builder.finish(reportOff);
return builder.asUint8Array(); 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();
});
});
+12 -4
View File
@@ -56,12 +56,20 @@ describe("active-view stubs", () => {
expect(node).toHaveTextContent("ship groups"); 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); const r = render(ReportView);
expect(r.getByTestId("active-view-report")).toHaveTextContent( expect(r.getByTestId("active-view-report")).toBeInTheDocument();
"turn report", 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); const m = render(MailView);
expect(m.getByTestId("active-view-mail")).toHaveTextContent( expect(m.getByTestId("active-view-mail")).toHaveTextContent(
"diplomatic mail", "diplomatic mail",
+20 -1
View File
@@ -1,16 +1,23 @@
// EMPTY_SHIP_GROUPS supplies empty arrays / zero defaults for the // EMPTY_SHIP_GROUPS supplies empty arrays / zero defaults for the
// ancillary report fields added in Phase 19 (ship-groups + fleets), // 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 // Test fixtures spread it into their report objects so the fixture
// body still focuses on the fields under test, without forcing // body still focuses on the fields under test, without forcing
// every spec to enumerate the full GameReport surface. // every spec to enumerate the full GameReport surface.
import type { import type {
ReportBombing,
ReportIncomingShipGroup, ReportIncomingShipGroup,
ReportLocalFleet, ReportLocalFleet,
ReportLocalShipGroup, ReportLocalShipGroup,
ReportOtherRace, ReportOtherRace,
ReportOtherScience,
ReportOtherShipClass,
ReportOtherShipGroup, ReportOtherShipGroup,
ReportPlayer,
ReportShipProduction,
ReportUnidentifiedShipGroup, ReportUnidentifiedShipGroup,
ScienceSummary, ScienceSummary,
} from "../../src/api/game-state"; } from "../../src/api/game-state";
@@ -26,6 +33,12 @@ export const EMPTY_SHIP_GROUPS: {
races: ReportOtherRace[]; races: ReportOtherRace[];
myVotes: number; myVotes: number;
myVoteFor: string; myVoteFor: string;
players: ReportPlayer[];
otherScience: ReportOtherScience[];
otherShipClass: ReportOtherShipClass[];
battleIds: string[];
bombings: ReportBombing[];
shipProductions: ReportShipProduction[];
} = { } = {
localShipGroups: [], localShipGroups: [],
otherShipGroups: [], otherShipGroups: [],
@@ -37,4 +50,10 @@ export const EMPTY_SHIP_GROUPS: {
races: [], races: [],
myVotes: 0, myVotes: 0,
myVoteFor: "", myVoteFor: "",
players: [],
otherScience: [],
otherShipClass: [],
battleIds: [],
bombings: [],
shipProductions: [],
}; };
@@ -72,6 +72,12 @@ function makeReport(
races: [], races: [],
myVotes: 0, myVotes: 0,
myVoteFor: "", myVoteFor: "",
players: [],
otherScience: [],
otherShipClass: [],
battleIds: [],
bombings: [],
shipProductions: [],
...overrides, ...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("");
});
});
+156
View File
@@ -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");
});
});