Compare commits
2 Commits
81d8be08b2
...
5a2a977dc6
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a2a977dc6 | |||
| c58027c034 |
@@ -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
|
||||||
|
|||||||
@@ -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-модуль,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
@@ -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`).
|
||||||
@@ -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 ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// Vitest coverage for the Phase 23 Report View's table of contents.
|
||||||
|
// Smokes the anchor list, the active-link state, the back-to-map
|
||||||
|
// navigation, and the mobile <select> fallback. The
|
||||||
|
// IntersectionObserver-driven active-section computation lives in
|
||||||
|
// the orchestrator (`report.svelte`); this test only checks the
|
||||||
|
// presentational pieces of the TOC.
|
||||||
|
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { fireEvent, render } from "@testing-library/svelte";
|
||||||
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||||
|
import type { TranslationKey } from "../src/lib/i18n/index.svelte";
|
||||||
|
|
||||||
|
const gotoMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("$app/navigation", () => ({
|
||||||
|
goto: gotoMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import ReportToc, {
|
||||||
|
type TocEntry,
|
||||||
|
} from "../src/lib/active-view/report/report-toc.svelte";
|
||||||
|
|
||||||
|
const ENTRIES: readonly TocEntry[] = [
|
||||||
|
{ slug: "galaxy-summary", titleKey: "game.report.section.galaxy_summary.title" },
|
||||||
|
{ slug: "votes", titleKey: "game.report.section.votes.title" },
|
||||||
|
{ slug: "bombings", titleKey: "game.report.section.bombings.title" },
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
i18n.resetForTests("en");
|
||||||
|
gotoMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("report TOC", () => {
|
||||||
|
test("renders one anchor per entry and one option in the mobile select", () => {
|
||||||
|
const ui = render(ReportToc, {
|
||||||
|
props: { entries: ENTRIES, activeSlug: "galaxy-summary", gameId: "g-1" },
|
||||||
|
});
|
||||||
|
for (const e of ENTRIES) {
|
||||||
|
expect(ui.getByTestId(`report-toc-${e.slug}`)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
const mobile = ui.getByTestId("report-toc-mobile") as HTMLSelectElement;
|
||||||
|
expect(mobile.options).toHaveLength(ENTRIES.length);
|
||||||
|
expect(mobile.value).toBe("galaxy-summary");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("marks the active anchor with aria-current=location and a class", () => {
|
||||||
|
const ui = render(ReportToc, {
|
||||||
|
props: { entries: ENTRIES, activeSlug: "bombings", gameId: "g-1" },
|
||||||
|
});
|
||||||
|
const active = ui.getByTestId("report-toc-bombings");
|
||||||
|
expect(active).toHaveAttribute("aria-current", "location");
|
||||||
|
expect(active).toHaveClass("active");
|
||||||
|
|
||||||
|
const inactive = ui.getByTestId("report-toc-votes");
|
||||||
|
expect(inactive).not.toHaveAttribute("aria-current");
|
||||||
|
expect(inactive).not.toHaveClass("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("back-to-map button calls goto with the active game's map URL", async () => {
|
||||||
|
const ui = render(ReportToc, {
|
||||||
|
props: {
|
||||||
|
entries: ENTRIES,
|
||||||
|
activeSlug: "galaxy-summary",
|
||||||
|
gameId: "abc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const button = ui.getByTestId("report-back-to-map");
|
||||||
|
await fireEvent.click(button);
|
||||||
|
expect(gotoMock).toHaveBeenCalledWith("/games/abc/map");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("anchor click cancels the default jump and calls scrollIntoView on the target", async () => {
|
||||||
|
// Stub `scrollIntoView` on the target — jsdom does not
|
||||||
|
// implement it. The TOC also reads
|
||||||
|
// `prefers-reduced-motion`; the matchMedia stub forces a
|
||||||
|
// stable `behavior: "auto"` so the assertion is reproducible.
|
||||||
|
const scrollSpy = vi.fn();
|
||||||
|
const target = document.createElement("section");
|
||||||
|
target.id = "report-bombings";
|
||||||
|
target.scrollIntoView = scrollSpy;
|
||||||
|
document.body.appendChild(target);
|
||||||
|
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: query.includes("reduce"),
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ui = render(ReportToc, {
|
||||||
|
props: { entries: ENTRIES, activeSlug: "galaxy-summary", gameId: "g" },
|
||||||
|
});
|
||||||
|
await fireEvent.click(ui.getByTestId("report-toc-bombings"));
|
||||||
|
expect(scrollSpy).toHaveBeenCalledWith({
|
||||||
|
behavior: "auto",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
target.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile select scrolls to the chosen section without navigating", async () => {
|
||||||
|
const scrollSpy = vi.fn();
|
||||||
|
const target = document.createElement("section");
|
||||||
|
target.id = "report-votes";
|
||||||
|
target.scrollIntoView = scrollSpy;
|
||||||
|
document.body.appendChild(target);
|
||||||
|
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: () => ({
|
||||||
|
matches: false,
|
||||||
|
media: "(prefers-reduced-motion: no-preference)",
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ui = render(ReportToc, {
|
||||||
|
props: {
|
||||||
|
entries: ENTRIES,
|
||||||
|
activeSlug: "galaxy-summary",
|
||||||
|
gameId: "g",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const select = ui.getByTestId("report-toc-mobile") as HTMLSelectElement;
|
||||||
|
await fireEvent.change(select, { target: { value: "votes" } });
|
||||||
|
expect(scrollSpy).toHaveBeenCalled();
|
||||||
|
expect(gotoMock).not.toHaveBeenCalled();
|
||||||
|
target.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tests intentionally validate the *type* of the entries prop is
|
||||||
|
// exposed correctly so future widening of the list does not
|
||||||
|
// silently drop entries. TypeScript already enforces this through
|
||||||
|
// `TocEntry`; the assertion below is a soft check so a stray
|
||||||
|
// `as unknown as ...` cast surfaces fast.
|
||||||
|
test("TocEntry exposes a slug and a TranslationKey", () => {
|
||||||
|
const slug: string = ENTRIES[0]!.slug;
|
||||||
|
const key: TranslationKey = ENTRIES[0]!.titleKey;
|
||||||
|
expect(typeof slug).toBe("string");
|
||||||
|
expect(typeof key).toBe("string");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user