23 Commits

Author SHA1 Message Date
developer c2f811640b Merge pull request 'ui: plan 01-27 done' (#1) from ai/ui-client into main
ui-test / test (push) Failing after 10s
Reviewed-on: https://gitea.dev/developer/galaxy-game/pulls/1
2026-05-13 18:55:13 +00:00
Ilia Denisov 6921c70df7 ui/phase-27: mark stage done after local-ci run 14
ui-test / test (push) Failing after 11s
ui-test / test (pull_request) Failing after 56s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:59:00 +02:00
Ilia Denisov bd11cd80da ui/phase-27: root-cause aggregation of duplicate (race, className) rows
Legacy reports list the same `(race, className)` pair across several
roster rows; the engine likewise creates one ShipGroup per arrival.
Both the legacy parser and `TransformBattle` were keyed on shipClass
without summing — only the last row / group's counts survived, so a
protocol's destroy count appeared to exceed the recorded initial
roster. The UI worked around this with phantom-frame logic.

Both parser and engine now SUM `Number`/`NumberLeft` across rows /
groups sharing the same class; the phantom-frame workaround is gone.
KNNTS041 turn 41 planet #7 reconciles: `Nails:pup` 1168 initial −
86 survivors = 1082 destroys.

The engine's previously latent nil-map write on `bg.Tech` (would
have paniced on any group with non-empty Tech) is fixed in the same
patch — it blocked the aggregation regression test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:52:40 +02:00
Ilia Denisov 2e7478f5ea ui/phase-27: skip phantom frames during play + freeze final layout
Two more KNNTS041 viewer fixes:

1. Phantom-frame fast-forward. `buildFrames` now flags every frame
   whose shot landed on an already-empty defender group as
   `phantom: true`. During play the BattleViewer effect detects a
   phantom frame and chains a 0 ms timer to the next non-phantom,
   so streaks of phantoms (the ~30 frames between shots 224 and
   255, and the 401..414 stretch) collapse from "the player just
   mots the timeline" into a single visual tick. Step controls and
   the scrubber can still land on a phantom deliberately for
   protocol inspection.

2. Final-frame layout freeze. `displayFrame` derives from the raw
   `frames[i]` and, on the very last frame when `activeRaceIds`
   shrinks vs the penultimate frame (the killing blow eliminates a
   race), substitutes the penultimate's `remaining` and
   `activeRaceIds` while keeping the current `shotIndex` and
   `lastAction`. The result: the surviving cluster no longer
   reflows onto the planet ring on the very last shot — the user
   sees the killing line + defender flash rendered against the
   picture they saw a moment earlier.

Tests: `phantom-destroy clamp` case extended with `frame.phantom`
flag assertions across the protocol; 644 Vitest cases stay green,
4 Playwright `battle-viewer` cases stay green.

Docs: `ui/docs/battle-viewer-ux.md` documents the fast-forward
behaviour and the final-frame freeze.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:16:11 +02:00
Ilia Denisov e2aba856b5 ui/phase-27: viewer layout pass + static cluster + duel layout
Layout reshuffle so the scene captures the maximum viewer area:

- Header collapses three rows into one: `back to map` / `back to
  report` on the left, the centred title `Battle on planet <name>
  (#<number>)` (new i18n key `game.battle.header_title`), and the
  frame counter on the right. The wrapper `.active-view` no longer
  renders its own back-row; routes flow through props.
- Viewer drops the `max-width: 880px` cap so on a wide monitor the
  scene scales up across the full active-view-host.
- A drag-seek `<input type="range">` sits between the scene and the
  controls; dragging pauses playback and lands `frameIndex` on the
  chosen shot.
- Speed control is one cycling button: `1x → 2x → 4x → 6x → 1x`.
  The label shows the current speed; the new 6x adds a 67 ms frame
  interval for skimming a long timeline.
- The text protocol log is now collapsible behind a `Log ▲▼`
  toggle in the controls bar. The toggle is its own button; the
  default state stays expanded. Collapsing the log hands the
  remaining height to the scene.
- Numerical list markers (`1. 2. 3.`) are dropped from the log;
  `list-style: none` keeps each row visually clean.

Static cluster + visibility filter:

- `staticBucketsByRace` now locks bucket order, mass, radius and
  local Vogel-spiral positions for the lifetime of the viewer; it
  only re-derives when `report` or the wasm `core` change.
- `renderedByRace` overlays the per-frame `remaining` map and drops
  buckets whose `numLeft` hits zero. The surviving buckets keep
  their slots, so a class emptying never reshuffles the cluster —
  the empty bucket simply disappears.
- A shot whose attacker or defender bucket is no longer visible
  draws no line (phantom shots into already-empty buckets are
  silently skipped, matching the user expectation that pup at 0
  should stop attracting fire visually).
- Race label clamps to a minimum y inside the SVG viewport so
  three-or-more-race layouts with a north anchor never clip the
  top race name off-canvas.

Duel layout (user suggestion):

- `layoutRaces` rotates the radial start angle by 90° when only
  two participants remain, so race 0 lands at 9 o'clock and race 1
  at 3 o'clock. The pair faces off horizontally; neither label
  pushes against the SVG top edge. The existing test for two-race
  positions is updated accordingly.

Tests: the existing `layoutRaces` two-race case is rewritten for
the horizontal duel; the `game-shell-stubs` battle case checks the
loading placeholder (back buttons now live in the loaded viewer,
not the wrapper). 644 Vitest cases stay green; 4 Playwright
battle-viewer cases stay green.

Docs: `ui/docs/battle-viewer-ux.md` documents the static cluster /
visibility filter, the duel layout, the scrubber, the cycling
speed button and the collapsible log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:38:46 +02:00
Ilia Denisov 17a3afd5e9 ui/phase-27: viewer polish + phantom-destroy clamp
Nine BattleViewer refinements from the latest review pass:

1. Mass radii were uniform in synthetic mode because
   `+layout.svelte` skipped `loadCore()` on the synthetic branch.
   The wasm bridge to `pkg/calc/ship.go` now boots in both modes
   so `computeBattleGroupMass` resolves a real FullMass and
   `radiusForMass` produces a per-battle scale.

2. Phantom-destroy clamp in `buildFrames`. Legacy emitters
   (KNNTS041 planet #7) log many more `Destroyed` lines against a
   group than the group's initial population — at frame 406 of
   2317 the race totals previously hit zero on phantom shots and
   the scene blanked while playback continued silently. We now
   only shrink the per-group remaining count and the race totals
   when the group still has ships. The line still draws on
   phantom frames; only the counters stay sane.

3. Vogel sunflower positions are now reassigned by inward dot
   product before being handed to ranks: the rank-0 bucket — the
   one with the largest initial ship count — always lands at the
   most-inward spiral slot. The previous quarter-step anchor bias
   was too weak; ranks r ≥ 2 routinely overtook rank-0 toward
   the planet. The anchor offset is gone.

4. Bucket order inside a cluster is locked at battle start by
   each bucket's *initial* ship count (`num`), not its live
   `numLeft`. The position of every class circle stays put for
   the whole battle; only the label number changes as ships die.

5. Shot line + defender flash blink on a per-frame timer during
   play. The line stays on for the first 90 % of frame duration,
   off for the last 10 %, so two consecutive shots from the same
   attacker on the same defender look like two distinct pulses.
   On pause the line and flash stay drawn for inspection.

6. The defender's class circle now flashes red (destroyed) or
   green (shielded) in sync with the shot line, so the eye
   catches *who* was hit, not just where the line lands.

7. Battle log rows are buttons. Click / Enter / Space pauses
   playback and seeks to that shot. The list also auto-scrolls
   the current row into view so the highlight does not race off
   the bottom on long battles.

8. Race labels now sit above the cloud's bounding top instead of
   a fixed offset, so a dense cluster does not swallow its own
   race name.

9. Planet glyph + label switch to neutral grey
   (`#2a2f40` / `#4a5066` / `#6d7388`), keeping the planet "in the
   background" rather than competing with the combatants.

Step-back icon switched to `◀︎◀︎` to mirror step-forward.

Tests: two new Vitest cases cover the phantom-destroy clamp
(single-race wipe, mixed-class race survives a class wipe). The
existing 642 Vitest tests stay green; all four `battle-viewer`
Playwright cases pass.

Docs: `ui/docs/battle-viewer-ux.md` rewrites the cluster section
(locked order + Vogel reassignment), adds Playback Details (blink
+ flash semantics), and a Phantom Destroys section explaining the
clamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:44:46 +02:00
Ilia Denisov 8c260f8715 ui/phase-27: mass-based circles + cloud cluster + height fit
Three Phase-27 BattleViewer refinements on top of the radial scene:

1. Height fit. The viewer is pinned to `calc(100dvh − 80px)` so it
   never pushes the in-game shell past the viewport. `.active-view`
   gains `overflow: hidden` + flex column; `.viewer` becomes a
   `flex: 1` child; the always-visible text log shrinks to a 30 dvh
   ceiling with its own scroll. A global `body { margin: 0 }`
   reset (added to `app.html`) plugs the 16 px the browser's
   default body margin used to leak.

2. Mass-based ship-class circles. New `lib/battle-player/mass.ts`
   carries the radius formula and the per-battle FullMass compute:
   `MIN_RADIUS + (MAX_RADIUS − MIN_RADIUS) * sqrt(mass / max)`,
   clamped to `[6, 24] px`. FullMass goes through the existing
   wasm bridge (`emptyMass` → `carryingMass` → `fullMass`) — no
   new wire fields. The viewer page resolves a
   `(race, className) → ShipClassRef` lookup from the parent
   GameReport's `localShipClass` + `otherShipClass` tables and
   passes it to the viewer via context. Unknown class or
   degenerate (weapons/armament) params fall back to MAX_RADIUS
   so the bucket stays visible.

3. Cloud cluster layout. Cluster key shifts from per-group
   `g.key` to `(raceId, className)` so tech-variants of the same
   hull collapse into one visual bucket. The horizontal
   classCircleX row is replaced by a Vogel sunflower spiral in
   the local `(u, v)` basis — `u` points from the race anchor to
   the planet, `v` is `u` rotated 90° clockwise. Buckets are
   sorted by NumberLeft desc; the cluster anchor is pushed inward
   by a quarter step so rank-0 sits closest to the planet. The
   step is adaptive (`min(baseStep, MAX_CLUSTER_RADIUS / sqrt(N))`)
   so clusters with many classes do not spill into neighbours.

Tests:
- Vitest: `radiusForMass` covering zero / max / quarter-mass /
  out-of-range cases (6 cases).
- Playwright: new `battle-viewer.spec.ts` case asserts
  `document.documentElement.scrollHeight - window.innerHeight ≤ 4`
  at a 1280×720 desktop viewport. The existing fixture gains
  `localShipClass` + `otherShipClass` so the lookup has data to
  render proportional circles.

Docs: `ui/docs/battle-viewer-ux.md` rewrites the "Radial scene"
section (cloud layout, mass-based radius, height fit) and adds
a "Height fit" subsection. `docs/FUNCTIONAL.md` §6.5 (+ ru
mirror) get the one-line story about per-mass sizing, cluster
aggregation, and the viewport-locked layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:51:31 +02:00
Ilia Denisov b23649059f legacy-report: parse battles + envelope JSON output
Side activity on top of Phase 27: the legacy-report tool now extracts
the "Battle at (#N) Name" / "Battle Protocol" blocks the parser used
to skip. Both the per-battle summary (Report.Battle: []BattleSummary)
and the full BattleReport (rosters + protocol) flow through.

Parser:
- new sectionBattle / sectionBattleProtocol states, with handle()
  trapping the per-race "<Race> Groups" sub-headers so the roster
  stays attributed to the right race;
- parseBattleHeader extracts (planet, planetName) from
  "Battle at (#NN) <Name>";
- parseBattleRosterRow maps the 10-token row into
  BattleReportGroup; column 8 ("L") is NumberLeft, confirmed against
  KNNTS fixtures;
- parseBattleProtocolLine counts shots and builds
  BattleActionReport entries from the 8-token "X Y fires on A B :
  Destroyed|Shields" lines;
- flushPendingBattle finalises a battle on next "Battle at" or any
  top-level section change and appends both the summary and the
  full report;
- syntheticBattleID(idx) + syntheticBattleRaceID(name) synthesise
  stable UUIDs in dedicated namespaces so re-runs produce
  byte-identical JSON.

Parse() signature widens to (Report, []BattleReport, error); the
single caller — the CLI — is updated.

CLI emits a v1 envelope:
  { "version": 1, "report": <Report>, "battles": { <uuid>: <BR>, ... } }
Bare-Report JSONs still load on the UI side for backward compat.

UI synthetic loader: loadSyntheticReportFromJSON detects the v1
envelope, decodes the report as before, and forwards every battle
through registerSyntheticBattle so the Battle Viewer resolves any
UUID offline. Pre-envelope JSON files (no `version` field) still
load — the battle registry stays empty for them.

Docs: legacy-report README moves Battles from "Skipped" to
in-scope, documents the envelope and UUID namespaces;
docs/FUNCTIONAL.md §6.5 (and the ru mirror) note that synthetic
mode is now end-to-end via the envelope.

Tests:
- TestParseBattles covers two battles with full rosters,
  per-shot destroyed/shielded mapping, NumberLeft from column 8,
  deterministic UUIDs across re-parses, and proves a trailing
  top-level section still parses (battle state closes cleanly);
- smokeWant gains a battles count; runSmoke cross-checks
  BattleSummary ↔ BattleReport alignment (id/planet/shots);
- all six real-fixture smoke tests pinned to their `Battle at`
  counts (28, 79, 56, 30, 83, 57);
- Vitest covers the synthetic-report envelope path (battles
  forwarded, missing-battles tolerated, bare-Report backward
  compat);
- KNNTS041.json regenerated against the new parser (existing
  diff was stale w.r.t. Phase 23 anyway; this commit brings it
  in line with the v1 envelope).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:22:53 +02:00
Ilia Denisov 46996ebf31 docs: clarify BattleSummary.shots scaling in FBS schema
Doc-only nit; triggers a CI rerun on the workflow's path filter to
verify the new Monitor permission lets local-CI polling run without
prompts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:03:10 +02:00
Ilia Denisov 37cf34a587 ci: rerun local-ci to verify monitor permission
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:01:46 +02:00
Ilia Denisov 659ba00ebf ui/phase-27: mark stage done after local-ci run 7
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:58:34 +02:00
Ilia Denisov 969c0480ba ui/phase-27: battle viewer (radial scene, playback, map markers)
Engine wire change: Report.battle switched from []uuid.UUID to
[]BattleSummary{id, planet, shots} so the map can place battle
markers without N extra fetches. FBS schema + generated Go/TS
regenerated; transcoder + report controller updated; openapi
adds the BattleSummary schema with a freeze test.

Backend gateway forwards engine GET /api/v1/battle/:turn/:uuid as
/api/v1/user/games/{game_id}/battles/{turn}/{battle_id} (handler
plus engineclient.FetchBattle, contract test stub, openapi spec).

UI:
- BattleViewer (lib/battle-player/) is a logically isolated SVG
  radial scene that consumes a BattleReport prop. Planet at the
  centre, races on the outer ring at equal angular spacing, race
  clusters by (race, className) with <class>:<numLeft> labels;
  observer groups (inBattle: false) are not drawn; eliminated
  races drop out and survivors re-distribute on the next frame.
- Shot line per frame: red on destroyed, green otherwise; erased
  on the next frame. Playback controls: play/pause + step ± +
  rewind + 1x/2x/4x speed (400/200/100 ms per frame).
- Page wrapper (lib/active-view/battle.svelte) loads BattleReport
  via api/battle-fetch.ts; synthetic-gameId prefix routes to a
  fixture loader, otherwise REST through the gateway. Always-
  visible <ol> text protocol satisfies the accessibility ask.
- section-battles.svelte links every battle UUID into the viewer.
- map/battle-markers.ts: yellow X cross of 2 LinePrim through the
  corners of the planet's circumscribed square (stroke width
  clamps from 1 px at 1 shot to 5 px at 100+ shots); bombing
  marker is a stroke-only ring (yellow when damaged, red when
  wiped). Wired into state-binding.ts; click handler dispatches
  battle clicks to the viewer and bombing clicks to the matching
  Reports row.
- i18n keys for the viewer in en + ru.

Docs: ui/docs/battle-viewer-ux.md, FUNCTIONAL.md §6.5 + ru
mirror, ui/PLAN.md Phase 27 decisions + deferred TODOs (push
event, richer class visuals, animated re-distribution).

Tests: Vitest unit (radial layout + timeline frame builder +
marker stroke formula + marker primitives), Playwright e2e for
the viewer (Reports link → viewer, playback step, not-found),
backend engineclient FetchBattle (200 / 404 / bad input), engine
openapi freezes (BattleReport, BattleReportGroup,
BattleActionReport, BattleSummary, Report.battle items).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:24:20 +02:00
Ilia Denisov 4ffcac00d0 tests, docs: game engine fetch battle api
ui-test / test (push) Failing after 37s
2026-05-13 11:28:28 +02:00
Ilia Denisov a9adbad7ef feat: game engine fetch battle api
ui-test / test (push) Failing after 47s
2026-05-13 10:50:45 +02:00
Ilia Denisov ce8e869731 ui/phase-26: mark stage done after local-ci run 6
ui-test / test (push) Failing after 41s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:27:29 +02:00
Ilia Denisov 2d17760a5e ui/phase-26: history mode (turn navigator + read-only banner)
Split GameStateStore into currentTurn (server's latest) and viewedTurn
(displayed snapshot) so history excursions don't corrupt the resume
bookmark or the live-turn bound. Add viewTurn / returnToCurrent /
historyMode rune, plus a game-history cache namespace that stores
past-turn reports for fast re-entry. OrderDraftStore.bindClient takes
a getHistoryMode getter and short-circuits add / remove / move while
the user is viewing a past turn; RenderedReportSource skips the order
overlay in the same case. Header replaces the static "turn N" with a
clickable triplet (TurnNavigator), the layout mounts HistoryBanner
under the header, and visibility-refresh is a no-op while history is
active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:13:19 +02:00
Ilia Denisov 070fdc0ee5 update gitattributes
ui-test / test (push) Failing after 38s
2026-05-11 22:18:16 +02:00
Ilia Denisov e98e6bda73 ui/phase-25: mark stage done after local-ci run 5
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:07:03 +02:00
Ilia Denisov 2ca47eb4df ui/phase-25: backend turn-cutoff guard + auto-pause + UI sync protocol
Backend now owns the turn-cutoff and pause guards the order tab
relies on: the scheduler flips runtime_status between
generation_in_progress and running around every engine tick, a
failed tick auto-pauses the game through OnRuntimeSnapshot, and a
new game.paused notification kind fans out alongside
game.turn.ready. The user-games handlers reject submits with
HTTP 409 turn_already_closed or game_paused depending on the
runtime state.

UI delegates auto-sync to a new OrderQueue: offline detection,
single retry on reconnect, conflict / paused classification.
OrderDraftStore surfaces conflictBanner / pausedBanner runes,
clears them on local mutation or on a game.turn.ready push via
resetForNewTurn. The order tab renders the matching banners and
the new conflict per-row badge; i18n bundles cover en + ru.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:00:16 +02:00
Ilia Denisov bbdcc36e05 ui/phase-24: declare game.turn.ready as JSON-friendly catalog kind
ui-test / test (push) Failing after 40s
TestBuildClientPushEventCoversCatalog required every catalog kind to
encode through a FlatBuffers `preMarshaledEvent`. game.turn.ready
intentionally rides on the JSON fallback because its payload is just
`{game_id, turn}` and the only consumer (Phase 24 UI handler) parses
JSON inline. Make the policy explicit through a jsonFriendlyKinds
allow-list so the test still asserts each kind is covered and a future
producer that picks the wrong encoding fails loudly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 17:27:29 +02:00
Ilia Denisov 5b07bb4e14 ui/phase-24: push events, turn-ready toast, single SubscribeEvents consumer
Wires the gateway's signed SubscribeEvents stream end-to-end:

- backend: emit game.turn.ready from lobby.OnRuntimeSnapshot on every
  current_turn advance, addressed to every active membership, push-only
  channel, idempotency key turn-ready:<game_id>:<turn>;
- ui: single EventStream singleton replaces revocation-watcher.ts and
  carries both per-event dispatch and revocation detection; toast
  primitive (store + host) lives in lib/; GameStateStore gains
  pendingTurn/markPendingTurn/advanceToPending and a persisted
  lastViewedTurn so a return after multiple turns surfaces the same
  "view now" affordance as a live push event;
- mandatory event-signature verification through ui/core
  (verifyPayloadHash + verifyEvent), full-jitter exponential backoff
  1s -> 30s on transient failure, signOut("revoked") on
  Unauthenticated or clean end-of-stream;
- catalog and migration accept the new kind; tests cover producer
  (testcontainers + capturing publisher), consumer (Vitest event
  stream, toast, game-state extensions), and a Playwright e2e
  delivering a signed frame to the live UI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 16:16:31 +02:00
Ilia Denisov 5a2a977dc6 ui/phase-23: mark stage done after local-ci run 2
ui-test / test (push) Failing after 2m11s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:41:35 +02:00
Ilia Denisov c58027c034 ui/phase-23: turn-report view with twenty sections and TOC
Replaces the Phase 10 report stub with a scrollable orchestrator that
renders every FBS array as a dedicated section (galaxy summary, votes,
player status, my/foreign sciences, my/foreign ship classes, battles,
bombings, approaching groups, my/foreign/uninhabited/unknown planets,
ships in production, cargo routes, my fleets, my/foreign/unidentified
ship groups). A sticky table of contents (a <select> on mobile),
"back to map" affordance, IntersectionObserver-driven active-section
highlight, and SvelteKit Snapshot-based scroll save/restore round out
the view.

GameReport gains six new fields (players, otherScience, otherShipClass,
battleIds, bombings, shipProductions); decodeReport, the synthetic-
report loader, the e2e fixture builder, and EMPTY_SHIP_GROUPS extend
in lockstep. ~90 new i18n keys land in en + ru together.

The legacy-report parser is extended to populate the new sections from
the dg/gplus text formats (Your Sciences, <Race> Sciences, <Race> Ship
Types, Bombings, Ships In Production). Ships-in-production prod_used
is derived through a new pkg/calc.ShipBuildCost helper; the engine's
controller.ProduceShip refactors to call the same helper without any
behaviour change (engine tests stay unchanged and green). Battles
remain in the parser's Skipped list — the legacy text carries no
stable per-battle UUID.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:33:56 +02:00
182 changed files with 65834 additions and 12611 deletions
+4
View File
@@ -1 +1,5 @@
*.wasm binary *.wasm binary
*.ts linguist-language=TypeScript
*.ts linguist-detectable=true
*.ts linguist-vendored=false
*.ts linguist-generated=false
+30 -3
View File
@@ -333,15 +333,42 @@ cannot guarantee.
| `runtime.image_pull_failed` | admin email | `game_id`, `image_ref` | | `runtime.image_pull_failed` | admin email | `game_id`, `image_ref` |
| `runtime.container_start_failed` | admin email | `game_id` | | `runtime.container_start_failed` | admin email | `game_id` |
| `runtime.start_config_invalid` | admin email | `game_id`, `reason` | | `runtime.start_config_invalid` | admin email | `game_id`, `reason` |
| `game.turn.ready` | push | `game_id`, `turn` |
| `game.paused` | push | `game_id`, `turn`, `reason` |
Admin-channel kinds (`runtime.*`) deliver email to Admin-channel kinds (`runtime.*`) deliver email to
`BACKEND_NOTIFICATION_ADMIN_EMAIL`; when the variable is empty, those `BACKEND_NOTIFICATION_ADMIN_EMAIL`; when the variable is empty, those
routes land in `notification_routes` with `status='skipped'` and the routes land in `notification_routes` with `status='skipped'` and the
operator log line records the configuration miss. operator log line records the configuration miss.
`game.*` (`game.started`, `game.turn.ready`, `game.generation.failed`, `game.turn.ready` and `game.paused` are emitted by
`game.finished`) and `mail.dead_lettered` are reserved kinds without a `lobby.Service.OnRuntimeSnapshot`
producer in the catalog; adding them is an additive change to the (`backend/internal/lobby/runtime_hooks.go`):
- `game.turn.ready` fires whenever the engine's `current_turn`
advances. Idempotency key `turn-ready:<game_id>:<turn>`, JSON
payload `{game_id, turn}`.
- `game.paused` fires whenever the same hook flips the game
`running → paused` because a runtime snapshot landed with
`engine_unreachable` / `generation_failed`. Idempotency key
`paused:<game_id>:<turn>`, JSON payload
`{game_id, turn, reason}` (reason carries the runtime status
that triggered the transition). The runtime scheduler
(`backend/internal/runtime/scheduler.go`) forwards the failing
snapshot through `Service.publishFailureSnapshot` so a single
failing tick reliably reaches lobby.
Both kinds target every active membership and route through the
push channel only — per-turn / per-pause email would be spam — so
the UI's signed `SubscribeEvents` stream
(`ui/frontend/src/api/events.svelte.ts`) is the sole delivery
path. The order tab consumes them via
`OrderDraftStore.resetForNewTurn` / `markPaused`
(`ui/docs/sync-protocol.md`).
The remaining `game.*` (`game.started`, `game.generation.failed`,
`game.finished`) and `mail.dead_lettered` are reserved kinds without
a producer in the catalog; adding them is an additive change to the
catalog vocabulary and the migration CHECK constraint. catalog vocabulary and the migration CHECK constraint.
Templates ship in English only; localisation belongs to clients that Templates ship in English only; localisation belongs to clients that
+36
View File
@@ -26,6 +26,7 @@ const (
pathPlayerCommand = "/api/v1/command" pathPlayerCommand = "/api/v1/command"
pathPlayerOrder = "/api/v1/order" pathPlayerOrder = "/api/v1/order"
pathPlayerReport = "/api/v1/report" pathPlayerReport = "/api/v1/report"
pathPlayerBattle = "/api/v1/battle"
pathHealthz = "/healthz" pathHealthz = "/healthz"
) )
@@ -269,6 +270,41 @@ func (c *Client) GetReport(ctx context.Context, baseURL, raceName string, turn i
} }
} }
// FetchBattle calls `GET /api/v1/battle/<turn>/<battleID>` and returns
// the engine response body verbatim alongside the engine status code.
// 200 carries the BattleReport JSON; 404 means the battle is unknown
// and the body may be empty. Other 4xx statuses come back wrapped in
// ErrEngineValidation, everything else in ErrEngineUnreachable.
func (c *Client) FetchBattle(ctx context.Context, baseURL string, turn int, battleID string) (json.RawMessage, int, error) {
if err := validateBaseURL(baseURL); err != nil {
return nil, 0, err
}
if turn < 0 {
return nil, 0, fmt.Errorf("engineclient battle get: turn must not be negative, got %d", turn)
}
if strings.TrimSpace(battleID) == "" {
return nil, 0, errors.New("engineclient battle get: battle id must not be empty")
}
target := baseURL + pathPlayerBattle + "/" + strconv.Itoa(turn) + "/" + url.PathEscape(battleID)
body, status, doErr := c.doRequest(ctx, http.MethodGet, target, nil, c.probeTimeout)
if doErr != nil {
return nil, 0, fmt.Errorf("%w: engine battle get: %w", ErrEngineUnreachable, doErr)
}
switch status {
case http.StatusOK:
if len(body) == 0 {
return nil, status, fmt.Errorf("%w: engine battle get: empty response body", ErrEngineProtocolViolation)
}
return json.RawMessage(body), status, nil
case http.StatusNotFound:
return nil, status, nil
case http.StatusBadRequest, http.StatusConflict:
return json.RawMessage(body), status, fmt.Errorf("%w: engine battle get: %s", ErrEngineValidation, summariseEngineError(body, status))
default:
return nil, status, fmt.Errorf("%w: engine battle get: %s", ErrEngineUnreachable, summariseEngineError(body, status))
}
}
// Healthz calls `GET /healthz`. Returns nil on 2xx. // Healthz calls `GET /healthz`. Returns nil on 2xx.
func (c *Client) Healthz(ctx context.Context, baseURL string) error { func (c *Client) Healthz(ctx context.Context, baseURL string) error {
if err := validateBaseURL(baseURL); err != nil { if err := validateBaseURL(baseURL); err != nil {
@@ -257,6 +257,63 @@ func TestClientGetOrderRejectsBadInput(t *testing.T) {
} }
} }
func TestClientFetchBattleForwardsPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("unexpected method: %s", r.Method)
}
want := pathPlayerBattle + "/3/" + "11111111-1111-1111-1111-111111111111"
if r.URL.Path != want {
t.Fatalf("path = %q, want %q", r.URL.Path, want)
}
_, _ = w.Write([]byte(`{"id":"11111111-1111-1111-1111-111111111111","planet":4}`))
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
body, status, err := cli.FetchBattle(context.Background(), srv.URL, 3, "11111111-1111-1111-1111-111111111111")
if err != nil {
t.Fatalf("FetchBattle: %v", err)
}
if status != http.StatusOK {
t.Fatalf("status = %d", status)
}
if !strings.Contains(string(body), `"planet":4`) {
t.Fatalf("body = %s", body)
}
}
func TestClientFetchBattleNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
body, status, err := cli.FetchBattle(context.Background(), srv.URL, 0, "11111111-1111-1111-1111-111111111111")
if err != nil {
t.Fatalf("FetchBattle: %v", err)
}
if status != http.StatusNotFound {
t.Fatalf("status = %d", status)
}
if body != nil {
t.Fatalf("expected nil body on 404, got %s", body)
}
}
func TestClientFetchBattleRejectsBadInput(t *testing.T) {
cli := newTestClient(t, httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("server must not be hit on bad input")
})))
if _, _, err := cli.FetchBattle(context.Background(), "http://example.com", -1, "11111111-1111-1111-1111-111111111111"); err == nil {
t.Fatal("expected error on negative turn")
}
if _, _, err := cli.FetchBattle(context.Background(), "http://example.com", 0, ""); err == nil {
t.Fatal("expected error on empty battle id")
}
}
func TestClientHealthzSuccess(t *testing.T) { func TestClientHealthzSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathHealthz { if r.URL.Path != pathHealthz {
+2
View File
@@ -109,6 +109,8 @@ const (
NotificationLobbyRaceNameRegistered = "lobby.race_name.registered" NotificationLobbyRaceNameRegistered = "lobby.race_name.registered"
NotificationLobbyRaceNamePending = "lobby.race_name.pending" NotificationLobbyRaceNamePending = "lobby.race_name.pending"
NotificationLobbyRaceNameExpired = "lobby.race_name.expired" NotificationLobbyRaceNameExpired = "lobby.race_name.expired"
NotificationGameTurnReady = "game.turn.ready"
NotificationGamePaused = "game.paused"
) )
// Deps aggregates every collaborator the lobby Service depends on. // Deps aggregates every collaborator the lobby Service depends on.
+121 -1
View File
@@ -30,12 +30,14 @@ func (s *Service) OnRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snaps
if err != nil { if err != nil {
return err return err
} }
prevTurn := game.RuntimeSnapshot.CurrentTurn
merged := mergeRuntimeSnapshot(game.RuntimeSnapshot, snapshot) merged := mergeRuntimeSnapshot(game.RuntimeSnapshot, snapshot)
now := s.deps.Now().UTC() now := s.deps.Now().UTC()
updated, err := s.deps.Store.UpdateGameRuntimeSnapshot(ctx, gameID, merged, now) updated, err := s.deps.Store.UpdateGameRuntimeSnapshot(ctx, gameID, merged, now)
if err != nil { if err != nil {
return err return err
} }
transitionedToPaused := false
if next, transition := nextStatusFromSnapshot(updated.Status, snapshot); transition { if next, transition := nextStatusFromSnapshot(updated.Status, snapshot); transition {
switch next { switch next {
case GameStatusFinished: case GameStatusFinished:
@@ -52,12 +54,115 @@ func (s *Service) OnRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snaps
return err return err
} }
updated = rec updated = rec
if next == GameStatusPaused {
transitionedToPaused = true
}
} }
} }
s.deps.Cache.PutGame(updated) s.deps.Cache.PutGame(updated)
if merged.CurrentTurn > prevTurn {
s.publishTurnReady(ctx, gameID, merged.CurrentTurn)
}
if transitionedToPaused {
s.publishGamePaused(ctx, gameID, merged.CurrentTurn, snapshot.RuntimeStatus)
}
return nil return nil
} }
// publishTurnReady fans out a `game.turn.ready` notification to every
// active member of the game once the engine reports a new
// `current_turn`. The intent is best-effort: a publisher failure is
// logged at warn level (matching the rest of OnRuntimeSnapshot's
// notification calls) and does not abort the snapshot bookkeeping.
// Idempotency is anchored on (game_id, turn), so a duplicate snapshot
// for the same turn collapses into a single notification at the
// notification.Submit boundary.
func (s *Service) publishTurnReady(ctx context.Context, gameID uuid.UUID, turn int32) {
memberships, err := s.deps.Store.ListMembershipsForGame(ctx, gameID)
if err != nil {
s.deps.Logger.Warn("turn-ready notification: list memberships failed",
zap.String("game_id", gameID.String()),
zap.Int32("turn", turn),
zap.Error(err))
return
}
recipients := make([]uuid.UUID, 0, len(memberships))
for _, m := range memberships {
if m.Status != MembershipStatusActive {
continue
}
recipients = append(recipients, m.UserID)
}
if len(recipients) == 0 {
return
}
intent := LobbyNotification{
Kind: NotificationGameTurnReady,
IdempotencyKey: fmt.Sprintf("turn-ready:%s:%d", gameID, turn),
Recipients: recipients,
Payload: map[string]any{
"game_id": gameID.String(),
"turn": turn,
},
}
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
s.deps.Logger.Warn("turn-ready notification failed",
zap.String("game_id", gameID.String()),
zap.Int32("turn", turn),
zap.Error(pubErr))
}
}
// publishGamePaused fans out a `game.paused` notification to every
// active member of the game when the lobby flips the game to
// `paused` in reaction to a runtime snapshot (typically a failed
// turn generation). The intent is best-effort: a publisher failure
// is logged at warn level and does not abort the snapshot
// bookkeeping. Idempotency is anchored on (game_id, turn) so a
// repeated `generation_failed` snapshot for the same turn collapses
// into a single notification at the notification.Submit boundary.
//
// reason carries the raw runtime status that triggered the pause
// (`engine_unreachable` / `generation_failed`); the UI displays a
// status-agnostic banner today but the payload is preserved so a
// future revision of the order tab can differentiate.
func (s *Service) publishGamePaused(ctx context.Context, gameID uuid.UUID, turn int32, reason string) {
memberships, err := s.deps.Store.ListMembershipsForGame(ctx, gameID)
if err != nil {
s.deps.Logger.Warn("game-paused notification: list memberships failed",
zap.String("game_id", gameID.String()),
zap.Int32("turn", turn),
zap.Error(err))
return
}
recipients := make([]uuid.UUID, 0, len(memberships))
for _, m := range memberships {
if m.Status != MembershipStatusActive {
continue
}
recipients = append(recipients, m.UserID)
}
if len(recipients) == 0 {
return
}
intent := LobbyNotification{
Kind: NotificationGamePaused,
IdempotencyKey: fmt.Sprintf("paused:%s:%d", gameID, turn),
Recipients: recipients,
Payload: map[string]any{
"game_id": gameID.String(),
"turn": turn,
"reason": reason,
},
}
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
s.deps.Logger.Warn("game-paused notification failed",
zap.String("game_id", gameID.String()),
zap.Int32("turn", turn),
zap.Error(pubErr))
}
}
// OnGameFinished completes the game lifecycle: marks the game as // OnGameFinished completes the game lifecycle: marks the game as
// `finished`, evaluates capable-finish per active member, and // `finished`, evaluates capable-finish per active member, and
// transitions reservation rows to either `pending_registration` // transitions reservation rows to either `pending_registration`
@@ -230,13 +335,28 @@ func mergeRuntimeSnapshot(prev, next RuntimeSnapshot) RuntimeSnapshot {
// nextStatusFromSnapshot maps the runtime-reported runtime status into // nextStatusFromSnapshot maps the runtime-reported runtime status into
// a lobby status transition. Returns (next, true) when the lobby // a lobby status transition. Returns (next, true) when the lobby
// status must change; (current, false) otherwise. // status must change; (current, false) otherwise.
//
// The map intentionally distinguishes the pre-running boot path
// (`starting → start_failed`) from the in-flight failure path
// (`running → paused`). Paused games can be resumed by the admin via
// the explicit `/resume` transition; the runtime keeps the engine
// container alive, the scheduler short-circuits ticks while paused,
// and any user-games command/order is rejected by the order handler
// with `turn_already_closed` until the game resumes.
func nextStatusFromSnapshot(currentStatus string, snapshot RuntimeSnapshot) (string, bool) { func nextStatusFromSnapshot(currentStatus string, snapshot RuntimeSnapshot) (string, bool) {
switch snapshot.RuntimeStatus { switch snapshot.RuntimeStatus {
case "running": case "running":
if currentStatus == GameStatusStarting { if currentStatus == GameStatusStarting {
return GameStatusRunning, true return GameStatusRunning, true
} }
case "engine_unreachable", "start_failed", "generation_failed": case "engine_unreachable", "generation_failed":
if currentStatus == GameStatusStarting {
return GameStatusStartFailed, true
}
if currentStatus == GameStatusRunning {
return GameStatusPaused, true
}
case "start_failed":
if currentStatus == GameStatusStarting { if currentStatus == GameStatusStarting {
return GameStatusStartFailed, true return GameStatusStartFailed, true
} }
@@ -0,0 +1,207 @@
package lobby_test
import (
"context"
"database/sql"
"fmt"
"sync"
"testing"
"time"
"galaxy/backend/internal/config"
"galaxy/backend/internal/lobby"
"github.com/google/uuid"
)
// capturingPublisher records every `LobbyNotification` intent that the
// lobby service emits, so a test can assert the producer side without
// running the real notification.Submit pipeline.
type capturingPublisher struct {
mu sync.Mutex
items []lobby.LobbyNotification
}
func (p *capturingPublisher) PublishLobbyEvent(_ context.Context, ev lobby.LobbyNotification) error {
p.mu.Lock()
defer p.mu.Unlock()
p.items = append(p.items, ev)
return nil
}
func (p *capturingPublisher) byKind(kind string) []lobby.LobbyNotification {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]lobby.LobbyNotification, 0, len(p.items))
for _, ev := range p.items {
if ev.Kind == kind {
out = append(out, ev)
}
}
return out
}
// newServiceWithPublisher mirrors `newServiceForTest` but lets the
// caller inject a custom NotificationPublisher; the runtime-hooks
// emit path needs to observe intents directly.
func newServiceWithPublisher(t *testing.T, db *sql.DB, now func() time.Time, max int32, publisher lobby.NotificationPublisher) *lobby.Service {
t.Helper()
store := lobby.NewStore(db)
cache := lobby.NewCache()
if err := cache.Warm(context.Background(), store); err != nil {
t.Fatalf("warm cache: %v", err)
}
svc, err := lobby.NewService(lobby.Deps{
Store: store,
Cache: cache,
Notification: publisher,
Entitlement: stubEntitlement{max: max},
Config: config.LobbyConfig{
SweeperInterval: time.Second,
PendingRegistrationTTL: time.Hour,
InviteDefaultTTL: time.Hour,
},
Now: now,
})
if err != nil {
t.Fatalf("new service: %v", err)
}
return svc
}
// TestOnRuntimeSnapshotEmitsTurnReady verifies that an engine snapshot
// advancing `current_turn` fans out a `game.turn.ready` intent to every
// active member, that the idempotency key is anchored on (game_id, turn),
// and that a snapshot with the same turn does not re-emit.
func TestOnRuntimeSnapshotEmitsTurnReady(t *testing.T) {
db := startPostgres(t)
now := time.Now().UTC()
clock := func() time.Time { return now }
publisher := &capturingPublisher{}
svc := newServiceWithPublisher(t, db, clock, 5, publisher)
owner := uuid.New()
seedAccount(t, db, owner)
game, err := svc.CreateGame(context.Background(), lobby.CreateGameInput{
OwnerUserID: &owner,
Visibility: lobby.VisibilityPrivate,
GameName: "Turn-Ready Fan-Out",
MinPlayers: 1,
MaxPlayers: 4,
StartGapHours: 1,
StartGapPlayers: 1,
EnrollmentEndsAt: now.Add(time.Hour),
TurnSchedule: "0 0 * * *",
TargetEngineVersion: "1.0.0",
})
if err != nil {
t.Fatalf("create game: %v", err)
}
if _, err := svc.OpenEnrollment(context.Background(), &owner, false, game.GameID); err != nil {
t.Fatalf("open enrollment: %v", err)
}
// Seed two active members through the store so the test focuses on
// the runtime hook, not the membership state machine.
store := lobby.NewStore(db)
canonicalPolicy, err := lobby.NewPolicy()
if err != nil {
t.Fatalf("new policy: %v", err)
}
memberA := uuid.New()
memberB := uuid.New()
seedAccount(t, db, memberA)
seedAccount(t, db, memberB)
for i, m := range []uuid.UUID{memberA, memberB} {
race := fmt.Sprintf("Race%d", i+1)
canonical, err := canonicalPolicy.Canonical(race)
if err != nil {
t.Fatalf("canonical %q: %v", race, err)
}
if _, err := db.ExecContext(context.Background(), `
INSERT INTO backend.memberships (
membership_id, game_id, user_id, race_name, canonical_key, status
) VALUES ($1, $2, $3, $4, $5, 'active')
`, uuid.New(), game.GameID, m, race, string(canonical)); err != nil {
t.Fatalf("seed membership %s: %v", m, err)
}
}
if err := svc.Cache().Warm(context.Background(), store); err != nil {
t.Fatalf("re-warm cache: %v", err)
}
if _, err := svc.ReadyToStart(context.Background(), &owner, false, game.GameID); err != nil {
t.Fatalf("ready-to-start: %v", err)
}
if _, err := svc.Start(context.Background(), &owner, false, game.GameID); err != nil {
t.Fatalf("start: %v", err)
}
// First snapshot: prev=0, current_turn=1 → emit on the very first
// turn after the engine starts producing.
if err := svc.OnRuntimeSnapshot(context.Background(), game.GameID, lobby.RuntimeSnapshot{
CurrentTurn: 1,
RuntimeStatus: "running",
}); err != nil {
t.Fatalf("on-runtime-snapshot 1: %v", err)
}
intents := publisher.byKind(lobby.NotificationGameTurnReady)
if len(intents) != 1 {
t.Fatalf("after turn 1 want 1 turn-ready intent, got %d", len(intents))
}
first := intents[0]
wantKey := fmt.Sprintf("turn-ready:%s:1", game.GameID)
if first.IdempotencyKey != wantKey {
t.Errorf("turn 1 idempotency key = %q, want %q", first.IdempotencyKey, wantKey)
}
if got := first.Payload["turn"]; got != int32(1) {
t.Errorf("turn 1 payload turn = %v, want 1", got)
}
if got := first.Payload["game_id"]; got != game.GameID.String() {
t.Errorf("turn 1 payload game_id = %v, want %s", got, game.GameID)
}
if len(first.Recipients) != 2 {
t.Errorf("turn 1 recipients = %d, want 2", len(first.Recipients))
}
recipientSet := map[uuid.UUID]struct{}{}
for _, r := range first.Recipients {
recipientSet[r] = struct{}{}
}
if _, ok := recipientSet[memberA]; !ok {
t.Errorf("turn 1 missing memberA in recipients")
}
if _, ok := recipientSet[memberB]; !ok {
t.Errorf("turn 1 missing memberB in recipients")
}
// Same turn re-delivered (duplicate snapshot, gateway replay) must
// not re-emit at the lobby layer: prev catches up to merged.
if err := svc.OnRuntimeSnapshot(context.Background(), game.GameID, lobby.RuntimeSnapshot{
CurrentTurn: 1,
RuntimeStatus: "running",
}); err != nil {
t.Fatalf("on-runtime-snapshot 1 replay: %v", err)
}
if got := len(publisher.byKind(lobby.NotificationGameTurnReady)); got != 1 {
t.Fatalf("after duplicate turn 1 want 1 intent, got %d", got)
}
// Next turn advances → second emit with key anchored on turn 2.
if err := svc.OnRuntimeSnapshot(context.Background(), game.GameID, lobby.RuntimeSnapshot{
CurrentTurn: 2,
RuntimeStatus: "running",
}); err != nil {
t.Fatalf("on-runtime-snapshot 2: %v", err)
}
intents = publisher.byKind(lobby.NotificationGameTurnReady)
if len(intents) != 2 {
t.Fatalf("after turn 2 want 2 turn-ready intents, got %d", len(intents))
}
wantKey2 := fmt.Sprintf("turn-ready:%s:2", game.GameID)
if intents[1].IdempotencyKey != wantKey2 {
t.Errorf("turn 2 idempotency key = %q, want %q", intents[1].IdempotencyKey, wantKey2)
}
if got := intents[1].Payload["turn"]; got != int32(2) {
t.Errorf("turn 2 payload turn = %v, want 2", got)
}
}
@@ -0,0 +1,127 @@
package lobby
import "testing"
// TestNextStatusFromSnapshot covers the pure status-mapping function
// that drives `OnRuntimeSnapshot`'s lifecycle transitions. The Phase
// 25 contribution is the `running → paused` branch on
// `engine_unreachable` / `generation_failed`: the order handler relies
// on the `paused` game status to reject late submits with
// `turn_already_closed`.
func TestNextStatusFromSnapshot(t *testing.T) {
t.Parallel()
tests := []struct {
name string
currentStatus string
runtimeStatus string
wantStatus string
wantTransit bool
}{
{
name: "starting then running flips to running",
currentStatus: GameStatusStarting,
runtimeStatus: "running",
wantStatus: GameStatusRunning,
wantTransit: true,
},
{
name: "running on running snapshot does not transit",
currentStatus: GameStatusRunning,
runtimeStatus: "running",
wantStatus: GameStatusRunning,
wantTransit: false,
},
{
name: "starting then engine_unreachable flips to start_failed",
currentStatus: GameStatusStarting,
runtimeStatus: "engine_unreachable",
wantStatus: GameStatusStartFailed,
wantTransit: true,
},
{
name: "starting then generation_failed flips to start_failed",
currentStatus: GameStatusStarting,
runtimeStatus: "generation_failed",
wantStatus: GameStatusStartFailed,
wantTransit: true,
},
{
name: "running then engine_unreachable flips to paused",
currentStatus: GameStatusRunning,
runtimeStatus: "engine_unreachable",
wantStatus: GameStatusPaused,
wantTransit: true,
},
{
name: "running then generation_failed flips to paused",
currentStatus: GameStatusRunning,
runtimeStatus: "generation_failed",
wantStatus: GameStatusPaused,
wantTransit: true,
},
{
name: "paused stays paused on repeated failed snapshot",
currentStatus: GameStatusPaused,
runtimeStatus: "generation_failed",
wantStatus: GameStatusPaused,
wantTransit: false,
},
{
name: "starting then start_failed flips to start_failed",
currentStatus: GameStatusStarting,
runtimeStatus: "start_failed",
wantStatus: GameStatusStartFailed,
wantTransit: true,
},
{
name: "running ignores start_failed",
currentStatus: GameStatusRunning,
runtimeStatus: "start_failed",
wantStatus: GameStatusRunning,
wantTransit: false,
},
{
name: "running on finished flips to finished",
currentStatus: GameStatusRunning,
runtimeStatus: "finished",
wantStatus: GameStatusFinished,
wantTransit: true,
},
{
name: "finished stays finished on finished snapshot",
currentStatus: GameStatusFinished,
runtimeStatus: "finished",
wantStatus: GameStatusFinished,
wantTransit: false,
},
{
name: "cancelled stays cancelled on finished snapshot",
currentStatus: GameStatusCancelled,
runtimeStatus: "finished",
wantStatus: GameStatusCancelled,
wantTransit: false,
},
{
name: "paused on stopped snapshot flips to finished",
currentStatus: GameStatusPaused,
runtimeStatus: "stopped",
wantStatus: GameStatusFinished,
wantTransit: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, transit := nextStatusFromSnapshot(tt.currentStatus, RuntimeSnapshot{
RuntimeStatus: tt.runtimeStatus,
})
if got != tt.wantStatus {
t.Errorf("status = %q, want %q", got, tt.wantStatus)
}
if transit != tt.wantTransit {
t.Errorf("transit = %v, want %v", transit, tt.wantTransit)
}
})
}
}
+10
View File
@@ -17,6 +17,8 @@ const (
KindRuntimeImagePullFailed = "runtime.image_pull_failed" KindRuntimeImagePullFailed = "runtime.image_pull_failed"
KindRuntimeContainerStartFailed = "runtime.container_start_failed" KindRuntimeContainerStartFailed = "runtime.container_start_failed"
KindRuntimeStartConfigInvalid = "runtime.start_config_invalid" KindRuntimeStartConfigInvalid = "runtime.start_config_invalid"
KindGameTurnReady = "game.turn.ready"
KindGamePaused = "game.paused"
) )
// CatalogEntry describes the per-kind delivery policy: which channels // CatalogEntry describes the per-kind delivery policy: which channels
@@ -95,6 +97,12 @@ var catalog = map[string]CatalogEntry{
Admin: true, Admin: true,
MailTemplateID: KindRuntimeStartConfigInvalid, MailTemplateID: KindRuntimeStartConfigInvalid,
}, },
KindGameTurnReady: {
Channels: []string{ChannelPush},
},
KindGamePaused: {
Channels: []string{ChannelPush},
},
} }
// LookupCatalog returns the per-kind policy and a boolean reporting // LookupCatalog returns the per-kind policy and a boolean reporting
@@ -123,5 +131,7 @@ func SupportedKinds() []string {
KindRuntimeImagePullFailed, KindRuntimeImagePullFailed,
KindRuntimeContainerStartFailed, KindRuntimeContainerStartFailed,
KindRuntimeStartConfigInvalid, KindRuntimeStartConfigInvalid,
KindGameTurnReady,
KindGamePaused,
} }
} }
@@ -39,6 +39,8 @@ func TestCatalogChannels(t *testing.T) {
KindRuntimeImagePullFailed: {ChannelEmail}, KindRuntimeImagePullFailed: {ChannelEmail},
KindRuntimeContainerStartFailed: {ChannelEmail}, KindRuntimeContainerStartFailed: {ChannelEmail},
KindRuntimeStartConfigInvalid: {ChannelEmail}, KindRuntimeStartConfigInvalid: {ChannelEmail},
KindGameTurnReady: {ChannelPush},
KindGamePaused: {ChannelPush},
} }
for kind, want := range expect { for kind, want := range expect {
entry, ok := LookupCatalog(kind) entry, ok := LookupCatalog(kind)
+37 -4
View File
@@ -9,9 +9,31 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// jsonFriendlyKinds lists catalog kinds whose payload is small and
// stable enough that the gateway-bound encoding stays JSON instead of
// FlatBuffers. The default for new producers is still FB; declaring a
// kind here is a deliberate decision baked into the build target's
// payload contract.
//
// `game.turn.ready` ships `{game_id, turn}` only, the UI parses it
// inline in `routes/games/[id]/+layout.svelte` (Phase 24), and no
// other consumer reads the payload — adopting the FB encoder would
// require a new TS notification stub set and the regen tooling for
// `pkg/schema/fbs/notification.fbs` without buying anything.
//
// `game.paused` (Phase 25) follows the same JSON-friendly contract:
// payload is `{game_id, turn, reason}` consumed by the same in-game
// shell layout, so there is no value in dragging a FB schema in for
// one consumer.
var jsonFriendlyKinds = map[string]bool{
KindGameTurnReady: true,
KindGamePaused: true,
}
// TestBuildClientPushEventCoversCatalog asserts that every catalog kind // TestBuildClientPushEventCoversCatalog asserts that every catalog kind
// returns a typed FB event (preMarshaledEvent) and that an unknown kind // is exercised by this test, that FB-typed kinds return a
// falls through to the JSON safety net. // `preMarshaledEvent`, and that JSON-friendly kinds (see
// `jsonFriendlyKinds` above) return a `push.JSONEvent`.
func TestBuildClientPushEventCoversCatalog(t *testing.T) { func TestBuildClientPushEventCoversCatalog(t *testing.T) {
t.Parallel() t.Parallel()
@@ -57,6 +79,15 @@ func TestBuildClientPushEventCoversCatalog(t *testing.T) {
"game_id": gameID.String(), "game_id": gameID.String(),
"reason": "missing engine version", "reason": "missing engine version",
}}, }},
{"game turn ready", KindGameTurnReady, map[string]any{
"game_id": gameID.String(),
"turn": int32(7),
}},
{"game paused", KindGamePaused, map[string]any{
"game_id": gameID.String(),
"turn": int32(7),
"reason": "generation_failed",
}},
} }
seenKinds := map[string]bool{} seenKinds := map[string]bool{}
@@ -78,8 +109,10 @@ func TestBuildClientPushEventCoversCatalog(t *testing.T) {
if len(bytes) == 0 { if len(bytes) == 0 {
t.Fatalf("Marshal returned empty bytes") t.Fatalf("Marshal returned empty bytes")
} }
if _, isJSON := event.(push.JSONEvent); isJSON { _, isJSON := event.(push.JSONEvent)
t.Fatalf("expected typed FB event for %s, got JSONEvent", tt.kind) wantJSON := jsonFriendlyKinds[tt.kind]
if isJSON != wantJSON {
t.Fatalf("kind %s: JSONEvent=%v, want JSONEvent=%v", tt.kind, isJSON, wantJSON)
} }
}) })
seenKinds[tt.kind] = true seenKinds[tt.kind] = true
@@ -605,7 +605,8 @@ CREATE TABLE notifications (
'lobby.race_name.registered', 'lobby.race_name.pending', 'lobby.race_name.registered', 'lobby.race_name.pending',
'lobby.race_name.expired', 'lobby.race_name.expired',
'runtime.image_pull_failed', 'runtime.container_start_failed', 'runtime.image_pull_failed', 'runtime.container_start_failed',
'runtime.start_config_invalid' 'runtime.start_config_invalid',
'game.turn.ready', 'game.paused'
)) ))
); );
+19
View File
@@ -42,4 +42,23 @@ var (
// ErrShutdown means the runtime service has stopped accepting // ErrShutdown means the runtime service has stopped accepting
// work because the parent context was cancelled. // work because the parent context was cancelled.
ErrShutdown = errors.New("runtime: shutting down") ErrShutdown = errors.New("runtime: shutting down")
// ErrTurnAlreadyClosed reports that the runtime is currently
// producing a turn — runtime status is `generation_in_progress`
// — and the engine is not accepting writes for the closing
// turn. Handlers map this to HTTP 409 with httperr code
// `turn_already_closed`; the UI shows a conflict banner and
// waits for the next `game.turn.ready` push.
ErrTurnAlreadyClosed = errors.New("runtime: turn already closed")
// ErrGamePaused reports that the game is not in a state that
// accepts user-games commands or orders: the runtime row
// carries `paused = true`, or the runtime status lands on any
// terminal value (`engine_unreachable`, `generation_failed`,
// `stopped`, `finished`, `removed`), or the game has not yet
// finished bootstrapping (`starting`). Handlers map this to
// HTTP 409 with httperr code `game_paused`; the UI surfaces a
// pause banner and waits for an admin resume or a fresh
// snapshot.
ErrGamePaused = errors.New("runtime: game paused")
) )
@@ -0,0 +1,82 @@
package runtime
import (
"errors"
"testing"
)
// TestOrdersAcceptStatus pins down the Phase 25 pre-check that
// gates the user-games command/order handlers against the runtime
// record. The decision must distinguish a turn cutoff (engine is
// producing) from a paused game so the UI can surface the right
// banner; all other non-running runtime statuses collapse into
// `ErrGamePaused`.
func TestOrdersAcceptStatus(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rec RuntimeRecord
want error
}{
{
name: "running and not paused accepts orders",
rec: RuntimeRecord{Status: RuntimeStatusRunning, Paused: false},
want: nil,
},
{
name: "running but paused returns game paused",
rec: RuntimeRecord{Status: RuntimeStatusRunning, Paused: true},
want: ErrGamePaused,
},
{
name: "generation in progress returns turn already closed",
rec: RuntimeRecord{Status: RuntimeStatusGenerationInProgress},
want: ErrTurnAlreadyClosed,
},
{
name: "generation failed returns game paused",
rec: RuntimeRecord{Status: RuntimeStatusGenerationFailed},
want: ErrGamePaused,
},
{
name: "engine unreachable returns game paused",
rec: RuntimeRecord{Status: RuntimeStatusEngineUnreachable},
want: ErrGamePaused,
},
{
name: "stopped returns game paused",
rec: RuntimeRecord{Status: RuntimeStatusStopped},
want: ErrGamePaused,
},
{
name: "finished returns game paused",
rec: RuntimeRecord{Status: RuntimeStatusFinished},
want: ErrGamePaused,
},
{
name: "removed returns game paused",
rec: RuntimeRecord{Status: RuntimeStatusRemoved},
want: ErrGamePaused,
},
{
name: "starting returns game paused",
rec: RuntimeRecord{Status: RuntimeStatusStarting},
want: ErrGamePaused,
},
{
name: "paused takes precedence over generation in progress",
rec: RuntimeRecord{Status: RuntimeStatusGenerationInProgress, Paused: true},
want: ErrGamePaused,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := OrdersAcceptStatus(tt.rec)
if !errors.Is(got, tt.want) {
t.Errorf("OrdersAcceptStatus = %v, want %v", got, tt.want)
}
})
}
}
+38 -1
View File
@@ -7,6 +7,7 @@ import (
"time" "time"
"galaxy/backend/internal/dockerclient" "galaxy/backend/internal/dockerclient"
"galaxy/backend/internal/engineclient"
"galaxy/cronutil" "galaxy/cronutil"
"github.com/google/uuid" "github.com/google/uuid"
@@ -213,6 +214,22 @@ func (sch *Scheduler) loop(ctx context.Context, rec RuntimeRecord, done chan str
// tick runs one engine /admin/turn call under the per-game mutex, // tick runs one engine /admin/turn call under the per-game mutex,
// publishes the resulting snapshot, and clears `skip_next_tick`. // publishes the resulting snapshot, and clears `skip_next_tick`.
//
// Phase 25 wraps the engine call between two runtime-status flips so
// the backend order handler can reject late submits while the engine
// is producing:
//
// - before `Engine.Turn`: runtime status moves to
// `generation_in_progress`; the loop's running-only guard tolerates
// this because the flip back happens inside the same tick.
// - on success: runtime status moves back to `running` (unless the
// engine reports `finished`, in which case `publishSnapshot` has
// already promoted the row to `finished`).
// - on error: runtime status moves to `generation_failed` (engine
// validation failure) or `engine_unreachable` (transport / 5xx).
// The matching snapshot is forwarded to lobby through
// `publishFailureSnapshot` so lobby can flip the game to `paused`
// and emit `game.paused`.
func (sch *Scheduler) tick(ctx context.Context, rec RuntimeRecord) error { func (sch *Scheduler) tick(ctx context.Context, rec RuntimeRecord) error {
mu := sch.svc.gameLock(rec.GameID) mu := sch.svc.gameLock(rec.GameID)
if !mu.TryLock() { if !mu.TryLock() {
@@ -224,10 +241,24 @@ func (sch *Scheduler) tick(ctx context.Context, rec RuntimeRecord) error {
if err != nil { if err != nil {
return err return err
} }
if _, err := sch.svc.transitionRuntimeStatus(ctx, rec.GameID, RuntimeStatusGenerationInProgress, ""); err != nil {
sch.svc.completeOperation(ctx, op, err)
return err
}
state, err := sch.svc.deps.Engine.Turn(ctx, rec.EngineEndpoint) state, err := sch.svc.deps.Engine.Turn(ctx, rec.EngineEndpoint)
if err != nil { if err != nil {
sch.svc.completeOperation(ctx, op, err) sch.svc.completeOperation(ctx, op, err)
_, _ = sch.svc.transitionRuntimeStatus(ctx, rec.GameID, RuntimeStatusEngineUnreachable, "") failureStatus := RuntimeStatusEngineUnreachable
if errors.Is(err, engineclient.ErrEngineValidation) {
failureStatus = RuntimeStatusGenerationFailed
}
_, _ = sch.svc.transitionRuntimeStatus(ctx, rec.GameID, failureStatus, "down")
if pubErr := sch.svc.publishFailureSnapshot(ctx, rec.GameID, failureStatus); pubErr != nil {
sch.svc.deps.Logger.Warn("publish failure snapshot to lobby",
zap.String("game_id", rec.GameID.String()),
zap.String("runtime_status", failureStatus),
zap.Error(pubErr))
}
// On engine unreachable, also clear skip_next_tick so the next // On engine unreachable, also clear skip_next_tick so the next
// real tick can start fresh. // real tick can start fresh.
_ = sch.clearSkipFlag(ctx, rec.GameID) _ = sch.clearSkipFlag(ctx, rec.GameID)
@@ -244,6 +275,12 @@ func (sch *Scheduler) tick(ctx context.Context, rec RuntimeRecord) error {
sch.svc.completeOperation(ctx, op, err) sch.svc.completeOperation(ctx, op, err)
return err return err
} }
if !state.Finished {
// `publishSnapshot` patches CurrentTurn / EngineHealth but does
// not reset the status column; reopen the orders window here so
// the next loop iteration finds the runtime back in `running`.
_, _ = sch.svc.transitionRuntimeStatus(ctx, rec.GameID, RuntimeStatusRunning, "ok")
}
sch.svc.completeOperation(ctx, op, nil) sch.svc.completeOperation(ctx, op, nil)
_ = sch.clearSkipFlag(ctx, rec.GameID) _ = sch.clearSkipFlag(ctx, rec.GameID)
return nil return nil
+78
View File
@@ -257,6 +257,57 @@ func (s *Service) ResolvePlayerMapping(ctx context.Context, gameID, userID uuid.
return s.deps.Store.LoadPlayerMapping(ctx, gameID, userID) return s.deps.Store.LoadPlayerMapping(ctx, gameID, userID)
} }
// CheckOrdersAccept verifies that the runtime is in a state that
// accepts user-games commands and orders. It is called by the user
// game-proxy handlers (`Commands`, `Orders`) before forwarding to
// engine, so the backend's turn-cutoff and pause guards run before
// network traffic leaves the host. The decision itself lives in the
// pure helper `OrdersAcceptStatus` so it can be unit-tested without
// constructing a full Service.
//
// A missing runtime row is surfaced as `ErrNotFound` so the handler
// keeps its existing 404 behaviour.
func (s *Service) CheckOrdersAccept(ctx context.Context, gameID uuid.UUID) error {
rec, err := s.GetRuntime(ctx, gameID)
if err != nil {
return err
}
return OrdersAcceptStatus(rec)
}
// OrdersAcceptStatus inspects a runtime record and returns the
// matching sentinel for the user-games order/command pre-check:
//
// - `runtime_status = generation_in_progress` → `ErrTurnAlreadyClosed`.
// The cron-driven `Scheduler.tick` has flipped the row before
// calling the engine. The order window reopens once the tick
// completes successfully.
//
// - `runtime_status ∈ {engine_unreachable, generation_failed,
// stopped, finished, removed, starting}` → `ErrGamePaused`.
// The game is not in a state that accepts writes; the lobby
// state machine has either already flipped the game to
// `paused` / `finished` or is still bootstrapping.
//
// - `runtime.Paused = true` → `ErrGamePaused`. The lobby admin
// paused the game explicitly.
//
// - `runtime_status = running` and `Paused = false` → nil
// (forward).
func OrdersAcceptStatus(rec RuntimeRecord) error {
if rec.Paused {
return ErrGamePaused
}
switch rec.Status {
case RuntimeStatusRunning:
return nil
case RuntimeStatusGenerationInProgress:
return ErrTurnAlreadyClosed
default:
return ErrGamePaused
}
}
// EngineEndpoint returns the engine endpoint URL for gameID. Used by // EngineEndpoint returns the engine endpoint URL for gameID. Used by
// the user game-proxy handlers. // the user game-proxy handlers.
func (s *Service) EngineEndpoint(ctx context.Context, gameID uuid.UUID) (string, error) { func (s *Service) EngineEndpoint(ctx context.Context, gameID uuid.UUID) (string, error) {
@@ -812,6 +863,33 @@ func (s *Service) publishSnapshot(ctx context.Context, gameID uuid.UUID, state r
return nil return nil
} }
// publishFailureSnapshot forwards a runtime-failure observation to
// lobby so the game lifecycle can react (e.g. flipping `running` to
// `paused` on `engine_unreachable` / `generation_failed` per Phase
// 25). The snapshot carries the unchanged `current_turn` because no
// new turn has been produced; lobby uses the turn number to anchor
// the `game.paused` idempotency key.
//
// The call is best-effort: lobby errors are returned to the caller
// (the scheduler tick) so the warn-level logging stays in one place.
// A missing runtime cache entry (e.g. the row was just removed by
// the reconciler) collapses into a silent no-op.
func (s *Service) publishFailureSnapshot(ctx context.Context, gameID uuid.UUID, runtimeStatus string) error {
if s.deps.Lobby == nil {
return nil
}
rec, ok := s.deps.Cache.GetRuntime(gameID)
if !ok {
return nil
}
return s.deps.Lobby.OnRuntimeSnapshot(ctx, gameID, LobbySnapshot{
CurrentTurn: rec.CurrentTurn,
RuntimeStatus: runtimeStatus,
EngineHealth: "down",
ObservedAt: s.deps.Now().UTC(),
})
}
// transitionRuntimeStatus updates the status / engine_health columns // transitionRuntimeStatus updates the status / engine_health columns
// and refreshes the cache. // and refreshes the cache.
func (s *Service) transitionRuntimeStatus(ctx context.Context, gameID uuid.UUID, status, health string) (RuntimeRecord, error) { func (s *Service) transitionRuntimeStatus(ctx context.Context, gameID uuid.UUID, status, health string) (RuntimeRecord, error) {
+1
View File
@@ -45,6 +45,7 @@ var pathParamStubs = map[string]string{
"delivery_id": "00000000-0000-0000-0000-000000000006", "delivery_id": "00000000-0000-0000-0000-000000000006",
"user_id": "00000000-0000-0000-0000-000000000007", "user_id": "00000000-0000-0000-0000-000000000007",
"device_session_id": "00000000-0000-0000-0000-000000000008", "device_session_id": "00000000-0000-0000-0000-000000000008",
"battle_id": "00000000-0000-0000-0000-000000000009",
"id": "1.2.3", "id": "1.2.3",
"username": "alice", "username": "alice",
"turn": "42", "turn": "42",
@@ -60,6 +60,10 @@ func (h *UserGamesHandlers) Commands() gin.HandlerFunc {
return return
} }
ctx := c.Request.Context() ctx := c.Request.Context()
if err := h.runtime.CheckOrdersAccept(ctx, gameID); err != nil {
respondGameProxyError(c, h.logger, "user games commands", ctx, err)
return
}
mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID) mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID)
if err != nil { if err != nil {
respondGameProxyError(c, h.logger, "user games commands", ctx, err) respondGameProxyError(c, h.logger, "user games commands", ctx, err)
@@ -105,6 +109,10 @@ func (h *UserGamesHandlers) Orders() gin.HandlerFunc {
return return
} }
ctx := c.Request.Context() ctx := c.Request.Context()
if err := h.runtime.CheckOrdersAccept(ctx, gameID); err != nil {
respondGameProxyError(c, h.logger, "user games orders", ctx, err)
return
}
mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID) mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID)
if err != nil { if err != nil {
respondGameProxyError(c, h.logger, "user games orders", ctx, err) respondGameProxyError(c, h.logger, "user games orders", ctx, err)
@@ -235,6 +243,60 @@ func (h *UserGamesHandlers) Report() gin.HandlerFunc {
} }
} }
// Battle handles GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}.
// Forwards to the engine's `GET /api/v1/battle/:turn/:uuid`. Path
// parameters are validated up-front to save a network hop. 404 from
// the engine is forwarded as 404. The recipient race is resolved
// from the runtime mapping but not forwarded — engine returns the
// battle by id, visibility is enforced by the engine state.
func (h *UserGamesHandlers) Battle() gin.HandlerFunc {
if h == nil || h.runtime == nil || h.engine == nil {
return handlers.NotImplemented("userGamesBattle")
}
return func(c *gin.Context) {
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
turnRaw := c.Param("turn")
turn, err := strconv.Atoi(turnRaw)
if err != nil || turn < 0 {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "turn must be a non-negative integer")
return
}
battleID := c.Param("battle_id")
if battleID == "" {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "battle id is required")
return
}
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user id missing")
return
}
ctx := c.Request.Context()
if _, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID); err != nil {
respondGameProxyError(c, h.logger, "user games battle", ctx, err)
return
}
endpoint, err := h.runtime.EngineEndpoint(ctx, gameID)
if err != nil {
respondGameProxyError(c, h.logger, "user games battle", ctx, err)
return
}
body, status, err := h.engine.FetchBattle(ctx, endpoint, turn, battleID)
if err != nil {
respondEngineProxyError(c, h.logger, "user games battle", ctx, body, err)
return
}
if status == http.StatusNotFound {
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "battle not found")
return
}
c.Data(http.StatusOK, "application/json", body)
}
}
// rebindActor decodes a JSON object from raw, sets `actor` to // rebindActor decodes a JSON object from raw, sets `actor` to
// raceName, and re-encodes. Backend never trusts the actor field // raceName, and re-encodes. Backend never trusts the actor field
// supplied by the client (per ARCHITECTURE.md §9). // supplied by the client (per ARCHITECTURE.md §9).
@@ -257,6 +319,12 @@ func respondGameProxyError(c *gin.Context, logger *zap.Logger, op string, ctx co
switch { switch {
case errors.Is(err, runtime.ErrNotFound): case errors.Is(err, runtime.ErrNotFound):
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "no runtime mapping for this user/game") httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "no runtime mapping for this user/game")
case errors.Is(err, runtime.ErrTurnAlreadyClosed):
httperr.Abort(c, http.StatusConflict, httperr.CodeTurnAlreadyClosed,
"turn already closed; orders are not accepted while the engine is producing")
case errors.Is(err, runtime.ErrGamePaused):
httperr.Abort(c, http.StatusConflict, httperr.CodeGamePaused,
"game is paused; orders are not accepted until it resumes")
case errors.Is(err, runtime.ErrConflict): case errors.Is(err, runtime.ErrConflict):
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error()) httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error())
default: default:
@@ -23,6 +23,22 @@ const (
CodeMethodNotAllowed = "method_not_allowed" CodeMethodNotAllowed = "method_not_allowed"
CodeInternalError = "internal_error" CodeInternalError = "internal_error"
CodeServiceUnavailable = "service_unavailable" CodeServiceUnavailable = "service_unavailable"
// CodeTurnAlreadyClosed marks a user-games command or order rejection
// caused by the backend's turn-cutoff guard: the request arrived
// after the active turn started generating (runtime status
// `generation_in_progress` / `generation_failed` / `engine_unreachable`)
// and the engine no longer accepts writes for the closing turn. The
// caller is expected to wait for the next `game.turn.ready` push and
// resubmit against the new turn.
CodeTurnAlreadyClosed = "turn_already_closed"
// CodeGamePaused marks a user-games command or order rejection caused
// by the lobby-side game lifecycle: the game is in `paused`,
// `finished`, or any other status that does not accept writes. The
// caller is expected to wait for the game to resume before
// resubmitting.
CodeGamePaused = "game_paused"
) )
// Body stores the inner `error` object of the standard envelope. // Body stores the inner `error` object of the standard envelope.
+1
View File
@@ -263,6 +263,7 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
userGames.POST("/:game_id/orders", deps.UserGames.Orders()) userGames.POST("/:game_id/orders", deps.UserGames.Orders())
userGames.GET("/:game_id/orders", deps.UserGames.GetOrders()) userGames.GET("/:game_id/orders", deps.UserGames.GetOrders())
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report()) userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
userGames.GET("/:game_id/battles/:turn/:battle_id", deps.UserGames.Battle())
userSessions := group.Group("/sessions") userSessions := group.Group("/sessions")
userSessions.GET("", deps.UserSessions.List()) userSessions.GET("", deps.UserSessions.List())
+44 -3
View File
@@ -1106,6 +1106,44 @@ paths:
$ref: "#/components/responses/NotImplementedError" $ref: "#/components/responses/NotImplementedError"
"500": "500":
$ref: "#/components/responses/InternalError" $ref: "#/components/responses/InternalError"
/api/v1/user/games/{game_id}/battles/{turn}/{battle_id}:
get:
tags: [User]
operationId: userGamesBattle
summary: Read one engine battle report
description: |
Forwards to the engine's `GET /api/v1/battle/:turn/:uuid`. The
engine response body is passed through verbatim. `404 Not Found`
is returned when the battle does not exist for the supplied
`turn` / `battle_id` pair.
security:
- UserHeader: []
parameters:
- $ref: "#/components/parameters/XUserID"
- $ref: "#/components/parameters/GameID"
- $ref: "#/components/parameters/Turn"
- name: battle_id
in: path
required: true
description: Battle identifier (RFC 4122 UUID).
schema:
type: string
format: uuid
responses:
"200":
description: Engine battle report passed through.
content:
application/json:
schema:
$ref: "#/components/schemas/PassthroughObject"
"400":
$ref: "#/components/responses/InvalidRequestError"
"404":
$ref: "#/components/responses/NotFoundError"
"501":
$ref: "#/components/responses/NotImplementedError"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/user/sessions: /api/v1/user/sessions:
get: get:
tags: [User] tags: [User]
@@ -2314,9 +2352,10 @@ components:
type: string type: string
description: | description: |
Stable machine-readable failure marker. The closed set is Stable machine-readable failure marker. The closed set is
`not_implemented`, `invalid_request`, `unauthorized`, `not_found`, `not_implemented`, `invalid_request`, `unauthorized`,
`conflict`, `method_not_allowed`, `internal_error`, `forbidden`, `not_found`, `conflict`, `method_not_allowed`,
`service_unavailable`. `internal_error`, `service_unavailable`,
`turn_already_closed`, `game_paused`.
enum: enum:
- not_implemented - not_implemented
- invalid_request - invalid_request
@@ -2327,6 +2366,8 @@ components:
- method_not_allowed - method_not_allowed
- internal_error - internal_error
- service_unavailable - service_unavailable
- turn_already_closed
- game_paused
message: message:
type: string type: string
description: Human-readable client-safe failure description. description: Human-readable client-safe failure description.
+15 -3
View File
@@ -785,9 +785,21 @@ Future scale-out hooks (not in MVP):
- **runtime snapshot** — engine-status read materialised into the lobby's - **runtime snapshot** — engine-status read materialised into the lobby's
denormalised view: `current_turn`, `runtime_status`, denormalised view: `current_turn`, `runtime_status`,
`engine_health_summary`, `player_turn_stats`. `engine_health_summary`, `player_turn_stats`.
- **turn cutoff** — the `running → generation_in_progress` CAS transition - **turn cutoff** — the `running → generation_in_progress` runtime-status
that closes the command window. Commands arriving after the CAS are flip performed by `backend/internal/runtime/scheduler.go` before each
rejected. engine `/admin/turn` call. Commands and orders arriving while the
flag is set are rejected by the user-games handlers with HTTP 409
`turn_already_closed`. The matching reopening flip
(`generation_in_progress → running`) happens on a successful tick;
a failing tick instead drives the lobby to `paused` and fans out
`game.paused` (FUNCTIONAL.md §6.3, §6.5).
- **auto-pause** — the lobby reaction to a failed runtime snapshot
(`engine_unreachable` / `generation_failed`): the game flips
`running → paused`, the order handlers refuse new submits with
HTTP 409 `game_paused`, and `lobby.publishGamePaused` fans out the
push event. Only an admin `/resume` followed by a successful tick
recovers the game; the UI relies on the next `game.turn.ready` to
clear the paused banner.
- **outbox** — the durable queue of pending mail rows in - **outbox** — the durable queue of pending mail rows in
`mail_deliveries`, drained by the mail worker. `mail_deliveries`, drained by the mail worker.
- **freshness window** — the symmetric ±5-minute interval around server - **freshness window** — the symmetric ±5-minute interval around server
+128 -17
View File
@@ -635,18 +635,40 @@ validity and ordering of in-game decisions. Gateway needs to know
the typed FB shape only to transcode the wire format; the per-command the typed FB shape only to transcode the wire format; the per-command
semantics live in the engine. semantics live in the engine.
### 6.3 Turn cutoff ### 6.3 Turn cutoff and auto-pause
A running game continuously alternates between a command-accepting A running game continuously alternates between a command-accepting
window and a generation phase. The transition `running → window and a generation phase, driven by the cron expression stored
generation_in_progress` is the cutoff: any command or order that in `runtime_records.turn_schedule`. The backend scheduler
arrives after the cutoff is rejected by backend before forwarding, (`backend/internal/runtime/scheduler.go`) wraps each engine
because the engine no longer accepts writes for the closing turn. `/admin/turn` call between two `runtime_status` flips:
After generation finishes, backend re-opens the window for the next
turn. - Before the engine call: `running → generation_in_progress`.
The user-games command/order handlers
(`backend/internal/server/handlers_user_games.go`) consult the
per-game runtime record on every request and reject with
HTTP 409 + `code = turn_already_closed` while the runtime sits in
`generation_in_progress`. The error envelope mirrors backend's
standard `httperr` shape: `{"error": {"code":
"turn_already_closed", "message": "..."}}`.
- After a successful tick: `generation_in_progress → running`.
The order window re-opens for the new turn and the next
scheduled tick continues normally.
- After a failed tick (`engine_unreachable` /
`generation_failed`): the lobby's `OnRuntimeSnapshot` flips the
game from `running` to `paused` and publishes a `game.paused`
push event (see §6.6). The order handlers reject with HTTP 409
+ `code = game_paused` until an admin resume succeeds.
`force-next-turn` (admin) schedules a one-shot extra tick that `force-next-turn` (admin) schedules a one-shot extra tick that
advances the next scheduled turn by one cron step. advances the next scheduled turn by one cron step; the same
status-flip and rejection rules apply.
Clients distinguish the two rejections by `code`:
`turn_already_closed` means "wait for the next `game.turn.ready`
and resubmit", whereas `game_paused` means "wait for an admin
resume". The web client implements both reactions in
`ui/docs/sync-protocol.md`.
### 6.4 Reports ### 6.4 Reports
@@ -654,7 +676,79 @@ 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.
### 6.5 Side effects 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.
The Bombings section is a flat read-only table — one row per
bombing event, columns for `attacker`, `attack_power`, `wiped`
state and the post-bombing resource snapshot. The Battles section
is a list of links into the Battle Viewer (see [§6.5](#65-battle-viewer)).
### 6.5 Battle viewer
The Battle Viewer is a dedicated view that replaces the map and
renders one battle at a time. Entry points:
- A row in the Reports view's Battles section (link with the
current turn pinned via `?turn=`).
- A battle marker on the map (yellow cross drawn through the
corners of the square that circumscribes the planet circle;
stroke width scales with the protocol length).
The viewer is a logically isolated component that consumes a
`BattleReport` (shape per `pkg/model/report/battle.go`). The page
loader (`ui/frontend/src/lib/active-view/battle.svelte`) fetches
the report through the backend gateway route
`GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`,
which forwards verbatim to the engine's
`GET /api/v1/battle/:turn/:uuid`.
Visual model is radial: the planet sits at the centre, races are
placed at equal angular spacing on an outer ring, and each race is
rendered as a cloud of ship-class circles arranged on a Vogel
sunflower spiral biased toward the planet (the largest group by
NumberLeft sits closest to the planet, lighter buckets fan behind).
Tech-variants of the same `(race, className)` collapse into one
visual bucket labelled `<className>:<numLeft>`; per-class detail
stays available in the Reports view. Circle radius scales with
per-ship FullMass (range `[6, 24] px`, per-battle normalisation)
so heavy ships visually dominate. Observer groups (`inBattle:
false`) are not drawn. Eliminated races drop out and the survivors
re-spread on the next frame. The viewer is pinned to the viewport
(scene grows, log scrolls internally) so no page-level scroll
appears.
Each frame is one protocol entry; the shot is drawn as a thin line
from attacker to defender, red on `destroyed`, green otherwise.
Continuous playback offers 1x / 2x / 4x speeds (400 / 200 / 100 ms
per frame), plus play/pause, step ±, and rewind. The accessibility
text protocol below the scene mirrors the same events line-by-line.
Bombings and battles are intentionally not mixed: bombings remain a
static table in the Reports view; the bombing marker on the map is
a thin stroke-only ring around the planet (yellow when damaged, red
when wiped) and a click scrolls the corresponding row into view.
The current report wire carries a `battle: [{ id, planet, shots }]`
summary per battle so the map markers know where to anchor without
fetching every full `BattleReport`.
For DEV / e2e the legacy-report CLI
(`tools/local-dev/legacy-report/cmd/legacy-report-to-json`) emits an
envelope `{version: 1, report, battles}` where `battles` carries the
full `BattleReport`-s parsed out of legacy `Battle at (#N)` blocks.
The synthetic-report loader on the lobby unwraps the envelope and
hands every battle to `registerSyntheticBattle`, so the Battle Viewer
resolves any UUID without a network fetch.
### 6.6 Side effects
A successful turn generation publishes a runtime snapshot into the A successful turn generation publishes a runtime snapshot into the
lobby module, which updates the denormalised view (current turn, lobby module, which updates the denormalised view (current turn,
@@ -662,15 +756,32 @@ runtime status, per-player stats). The engine's "game finished"
report drives the `running → finished` transition ([Section 3.5](#35-cancellation-and-finish)) report drives the `running → finished` transition ([Section 3.5](#35-cancellation-and-finish))
and triggers Race Name Directory promotions ([Section 5](#5-race-name-directory)). and triggers Race Name Directory promotions ([Section 5](#5-race-name-directory)).
The `game.*` notification kinds (`game.started`, `game.turn.ready`, Among the `game.*` notification kinds, `game.turn.ready` and
`game.generation.failed`, `game.finished`) are reserved in the `game.paused` are wired:
documentation but have **no producer** in the codebase today; the
notification catalog explicitly omits them (`backend/internal/notification/catalog.go`).
Adding a producer is purely additive: register the kind in the
catalog, populate `MailTemplateID` if email fan-out is desired, and
have the appropriate domain module call `notification.Submit`.
### 6.6 Cross-references - `game.turn.ready`
`lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`)
emits one intent per advancing `current_turn`, addressed to every
active membership of the game, with idempotency key
`turn-ready:<game_id>:<turn>` and JSON payload `{game_id, turn}`.
- `game.paused` — the same hook publishes one intent per transition
into `paused` driven by an `engine_unreachable` /
`generation_failed` runtime snapshot, addressed to every active
membership, with idempotency key `paused:<game_id>:<turn>` and
JSON payload `{game_id, turn, reason}`. The runtime status that
triggered the transition is carried as `reason` so the UI can
differentiate the copy in a future revision.
Both kinds route through the push channel only; email is
deliberately omitted to avoid per-turn / per-pause spam.
The remaining `game.*` kinds (`game.started`, `game.generation.failed`,
`game.finished`) and `mail.dead_lettered` are reserved without a
producer; adding one is purely additive (register the kind in the
catalog, extend the migration `CHECK` constraint, and call
`notification.Submit` from the appropriate domain module).
### 6.7 Cross-references
- Backend ↔ engine wire contract (`pkg/model/{order,report,rest}`): - Backend ↔ engine wire contract (`pkg/model/{order,report,rest}`):
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication). [ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
+130 -17
View File
@@ -653,17 +653,40 @@ Backend не парсит содержимое payload команд или пр
FB-форму только чтобы транскодировать wire-формат; per-command- FB-форму только чтобы транскодировать wire-формат; per-command-
семантика живёт в движке. семантика живёт в движке.
### 6.3 Окно хода ### 6.3 Окно хода и auto-pause
Запущенная игра постоянно чередуется между окном приёма команд Запущенная игра постоянно чередуется между окном приёма команд
и фазой генерации. Переход `running → generation_in_progress` и фазой генерации, управляемой cron-выражением из
cutoff: любая команда или приказ, пришедшие после cutoff, `runtime_records.turn_schedule`. Backend-планировщик
отклоняются backend до форварда, потому что движок больше не (`backend/internal/runtime/scheduler.go`) оборачивает каждый
принимает запись для закрывающегося хода. После окончания engine `/admin/turn` двумя `runtime_status`-флипами:
генерации backend заново открывает окно для следующего хода.
- Перед engine-вызовом: `running → generation_in_progress`.
User-games-handler'ы команд/приказов
(`backend/internal/server/handlers_user_games.go`) на каждом
запросе сверяются с per-game runtime-записью и отклоняют с
HTTP 409 + `code = turn_already_closed`, пока runtime в
`generation_in_progress`. Тело ошибки — стандартный
`httperr`-конверт: `{"error": {"code": "turn_already_closed",
"message": "..."}}`.
- После успешного тика: `generation_in_progress → running`.
Окно приказов открывается на новый ход, следующий тик идёт
как обычно.
- После провалившегося тика (`engine_unreachable` /
`generation_failed`): `lobby.OnRuntimeSnapshot` переводит игру
`running → paused` и публикует push-эвент `game.paused`
(см. §6.6). Order-handler'ы отклоняют запросы с HTTP 409 +
`code = game_paused`, пока админ не выполнит resume.
`force-next-turn` (admin) планирует one-shot-доп-тик, который `force-next-turn` (admin) планирует one-shot-доп-тик, который
сдвигает следующий запланированный ход на один cron-шаг. сдвигает следующий запланированный ход на один cron-шаг; те же
правила status-flip и отклонения применимы.
Клиенты различают два варианта отказа по `code`:
`turn_already_closed` — «дождись следующего `game.turn.ready` и
отправь ещё раз», `game_paused` — «дождись resume администратором».
Web-клиент реализует оба сценария согласно
`ui/docs/sync-protocol.md`.
### 6.4 Отчёты ### 6.4 Отчёты
@@ -671,7 +694,79 @@ Per-turn-отчёты — read-only-вью, забираемые из движк
Backend авторизует вызывающего и форвардит запрос; в этом пути Backend авторизует вызывающего и форвардит запрос; в этом пути
нет ни кэширования, ни денормализации. нет ни кэширования, ни денормализации.
### 6.5 Побочные эффекты Web-клиент рендерит отчёт как одну секцию на каждый FBS-массив
(общие сведения, голоса, статус игроков, мои / чужие науки, мои /
чужие классы кораблей, сражения, бомбардировки, приближающиеся
группы, мои / чужие / необитаемые / неопознанные планеты, корабли в
производстве, грузовые маршруты, мои флоты, мои / чужие /
неопознанные группы кораблей). Пустые секции получают явную копию
empty-state. Якоря секций отображены в sticky-TOC (на мобильном —
`<select>`); позиция скролла сохраняется при переключении активного
представления через SvelteKit `Snapshot` API.
Секция бомбардировок — это плоская read-only-таблица: одна строка на
событие, колонки `attacker`, `attack_power`, признак `wiped` и
ресурсный снимок после удара. Секция сражений — список ссылок в
Battle Viewer (см. [§6.5](#65-battle-viewer)).
### 6.5 Battle viewer
Battle Viewer — отдельное представление, заменяющее карту и
показывающее одну битву. Входы:
- Строка в секции «сражения» в Reports (ссылка с пиннингом
текущего хода через `?turn=`).
- Battle-marker на карте (жёлтый крест через противоположные углы
квадрата, описанного вокруг круга планеты; толщина линий растёт
с длиной протокола).
Сам Viewer — логически изолированный компонент, потребляющий
`BattleReport` в форме `pkg/model/report/battle.go`. Страница-обёртка
(`ui/frontend/src/lib/active-view/battle.svelte`) забирает отчёт
через backend-маршрут
`GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`,
который проксирует ответ engine-эндпоинта
`GET /api/v1/battle/:turn/:uuid`.
Визуальная модель — радиальная: планета в центре, расы по внешней
окружности на равных угловых интервалах, внутри расы — облако
кружков по классам кораблей, выложенное Vogel-спиралью с биасом к
планете (самая многочисленная группа по NumberLeft — ближе к
планете, остальные раскручиваются спиралью позади). Tech-варианты
одного `(race, className)` схлопываются в один визуальный нод
`<className>:<numLeft>`; детали по тех-уровням остаются в Reports.
Радиус кружка масштабируется по FullMass корабля (диапазон
`[6, 24] px`, нормировка на самую тяжёлую группу в битве), так что
тяжёлые корабли визуально доминируют. Наблюдатели (`inBattle:
false`) не рисуются. Выбывшие расы убираются из сцены, оставшиеся
перераспределяются на следующем кадре. Viewer закреплён по высоте
viewport-а: сцена растягивается, лог скроллит внутри — никаких
скроллов на уровне страницы.
Каждый кадр — одна запись протокола; выстрел рисуется тонкой линией
от атакующего к защитнику, красной при `destroyed`, зелёной иначе.
Непрерывное воспроизведение: 1x / 2x / 4x (400 / 200 / 100 мс на
кадр), плюс play/pause, шаг вперёд/назад, rewind. Текстовый протокол
доступности под сценой дублирует те же события построчно.
Бомбардировки и сражения умышленно не смешиваются: бомбардировки
остаются статической таблицей в Reports; bombing-marker на карте —
тонкая окружность вокруг планеты (жёлтая при damaged, красная при
wiped), клик скроллит соответствующую строку в Reports.
Текущая wire-форма отчёта несёт `battle: [{ id, planet, shots }]`
на каждую битву, чтобы map-маркеры могли расположиться без
дополнительного запроса полного `BattleReport`.
Для DEV / e2e легаси-CLI
(`tools/local-dev/legacy-report/cmd/legacy-report-to-json`) выдаёт
envelope `{version: 1, report, battles}`, где `battles` несёт полные
`BattleReport`-ы, распарсенные из `Battle at (#N)`-блоков. Synthetic-
загрузчик в лобби разбирает envelope и регистрирует каждую битву
через `registerSyntheticBattle`, так что Battle Viewer открывает
любой UUID без сетевого запроса.
### 6.6 Побочные эффекты
Успешная генерация хода публикует runtime-snapshot в lobby-модуль, Успешная генерация хода публикует runtime-snapshot в lobby-модуль,
который обновляет денормализованное вью (текущий ход, runtime- который обновляет денормализованное вью (текущий ход, runtime-
@@ -680,16 +775,34 @@ status, per-player-stats). Engine-отчёт "game finished" гонит
([Раздел 3.5](#35-отмена-и-завершение)) и триггерит Race Name ([Раздел 3.5](#35-отмена-и-завершение)) и триггерит Race Name
Directory-промоушен ([Раздел 5](#5-реестр-названий-рас)). Directory-промоушен ([Раздел 5](#5-реестр-названий-рас)).
`game.*`-виды уведомлений (`game.started`, `game.turn.ready`, Из `game.*`-видов уведомлений подключены `game.turn.ready` и
`game.generation.failed`, `game.finished`) зарезервированы в `game.paused`:
документации, но **не имеют поставщика** в кодовой базе сегодня;
notification-каталог явно их опускает
(`backend/internal/notification/catalog.go`). Добавление поставщика
аддитивно: зарегистрировать вид в каталоге, заполнить
`MailTemplateID`, если нужен email-веер, и заставить нужный
доменный модуль вызвать `notification.Submit`.
### 6.6 Перекрёстные ссылки - `game.turn.ready`
`lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`)
выпускает один intent на каждое увеличение `current_turn`,
адресуя его всем активным membership-ам игры, с
idempotency-ключом `turn-ready:<game_id>:<turn>` и
JSON-payload-ом `{game_id, turn}`.
- `game.paused` — тот же хук публикует один intent на каждое
выставление статуса `paused` по runtime-снапшоту
(`engine_unreachable` / `generation_failed`), адресуя его всем
активным membership-ам игры, с idempotency-ключом
`paused:<game_id>:<turn>` и JSON-payload-ом
`{game_id, turn, reason}`. `reason` несёт runtime-статус,
спровоцировавший переход, чтобы UI смог в будущем
дифференцировать копию.
Оба вида направляются только в push-канал; email-фан-аут
сознательно опущен, чтобы избежать спама на каждом ходе/паузе.
Остальные `game.*`-виды (`game.started`, `game.generation.failed`,
`game.finished`) и `mail.dead_lettered` зарезервированы без поставщика;
добавление поставщика чисто аддитивное (зарегистрировать вид в
каталоге, расширить `CHECK`-констрейнт миграции и вызвать
`notification.Submit` из подходящего доменного модуля).
### 6.7 Перекрёстные ссылки
- Backend ↔ engine wire-контракт (`pkg/model/{order,report,rest}`): - Backend ↔ engine wire-контракт (`pkg/model/{order,report,rest}`):
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication). [ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
+87
View File
@@ -8,6 +8,7 @@ import (
"galaxy/calc" "galaxy/calc"
"galaxy/game/internal/controller" "galaxy/game/internal/controller"
"galaxy/game/internal/model/game" "galaxy/game/internal/model/game"
"galaxy/model/report"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -184,3 +185,89 @@ func TestProduceBattles(t *testing.T) {
assert.Zero(t, c.ShipGroup(3).Number) assert.Zero(t, c.ShipGroup(3).Number)
} }
} }
// TestTransformBattleAggregatesSameShipClass guards against the
// engine-side variant of the duplicate-class bug. Several ShipGroups
// of the same ShipClass.ID can take part in the same battle (arrivals
// from different planets, tech splits, etc.); they must collapse into
// a single BattleReportGroup with summed Number and NumberLeft. The
// pre-fix engine cached the first group's index and silently dropped
// every subsequent group's initial / survivor counts, which manifested
// downstream as more Destroyed shots in the protocol than the
// recorded initial roster could account for.
func TestTransformBattleAggregatesSameShipClass(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.RaceRelation(Race_0.Name, Race_1.Name, game.RelationWar.String()))
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
// Two Race_0 groups of the SAME ship class (Race_0_Gunship) plus
// one Race_1 group of Race_1_Gunship — all parked on Planet_0
// (owned by Race_0; the Race_1 group lands there via the Unsafe
// helper that bypasses the ownership check). Group indices land
// at 0, 1, 2 in creation order.
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10))
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10))
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R0_Planet_0_num, 5)
// Simulate post-battle survivor counts: Group 0 ended the battle
// with 8 ships, Group 1 with 6. The aggregated BattleReportGroup
// must report NumberLeft = 8 + 6 = 14 (not just the last cached
// group's 6 — that's the regression).
c.ShipGroup(0).Number = 8
c.ShipGroup(1).Number = 6
b := &controller.Battle{
Planet: R0_Planet_0_num,
ObserverGroups: map[int]bool{0: true, 1: true, 2: true},
InitialNumbers: map[int]uint{0: 10, 1: 10, 2: 5},
// Protocol must reference every in-battle group at least once
// (otherwise TransformBattle won't register it through the
// `ship()` path). Two shots from Race_1 against each Race_0
// group hits both groupIds.
Protocol: []controller.BattleAction{
{Attacker: 2, Defender: 0, Destroyed: true},
{Attacker: 2, Defender: 1, Destroyed: true},
},
}
r := controller.TransformBattle(c, b)
// Two BattleReportGroup entries total: one merged Race_0_Gunship
// (groups 0 + 1) and one Race_1_Gunship. NOT three.
if got, want := len(r.Ships), 2; got != want {
t.Fatalf("len(r.Ships) = %d, want %d (duplicate ShipClass.ID must merge)", got, want)
}
var gunship0, gunship1 *report.BattleReportGroup
for i := range r.Ships {
grp := r.Ships[i]
switch grp.Race {
case Race_0.Name:
gunship0 = &grp
case Race_1.Name:
gunship1 = &grp
}
}
if gunship0 == nil || gunship1 == nil {
t.Fatalf("missing race entry: race0=%v race1=%v", gunship0, gunship1)
}
if gunship0.ClassName != Race_0_Gunship {
t.Errorf("race0.ClassName = %q, want %q", gunship0.ClassName, Race_0_Gunship)
}
if gunship0.Number != 20 {
t.Errorf("race0.Number = %d, want 20 (10+10)", gunship0.Number)
}
if gunship0.NumberLeft != 14 {
t.Errorf("race0.NumberLeft = %d, want 14 (8+6)", gunship0.NumberLeft)
}
if !gunship0.InBattle {
t.Errorf("race0.InBattle = false, want true (both source groups were in-battle)")
}
if gunship1.Number != 5 || gunship1.NumberLeft != 5 {
t.Errorf("race1 = (Number=%d, NumberLeft=%d), want (5, 5)",
gunship1.Number, gunship1.NumberLeft)
}
}
+28 -6
View File
@@ -18,10 +18,35 @@ func TransformBattle(c *Cache, b *Battle) *report.BattleReport {
cacheShipClass := make(map[uuid.UUID]int) cacheShipClass := make(map[uuid.UUID]int)
cacheRaceName := make(map[uuid.UUID]int) cacheRaceName := make(map[uuid.UUID]int)
processedGroup := make(map[int]bool)
addShipGroup := func(groupId int, inBattle bool) int { addShipGroup := func(groupId int, inBattle bool) int {
shipClass := c.ShipGroupShipClass(groupId) shipClass := c.ShipGroupShipClass(groupId)
sg := c.ShipGroup(groupId) sg := c.ShipGroup(groupId)
// Several ship-groups of the same race/class can take part
// in the same battle (different tech upgrades, arrivals from
// different planets, …). They share a single
// BattleReportGroup entry keyed by ShipClass.ID — when a
// later group lands on a cached class we add its Number and
// NumberLeft into the existing entry instead of dropping
// them, so the protocol's per-class destroy counts reconcile
// with the recorded totals. `processedGroup` guards against
// double-counting a single groupId across multiple shots in
// the protocol — `ship()` runs on every attacker and defender
// reference, the merge must happen once per groupId.
if existing, ok := cacheShipClass[shipClass.ID]; ok {
if !processedGroup[groupId] {
bg := r.Ships[existing]
bg.Number += b.InitialNumbers[groupId]
bg.NumberLeft += sg.Number
if inBattle {
bg.InBattle = true
}
r.Ships[existing] = bg
processedGroup[groupId] = true
}
return existing
}
itemNumber := len(r.Ships) itemNumber := len(r.Ships)
bg := &report.BattleReportGroup{ bg := &report.BattleReportGroup{
Race: c.g.Race[c.RaceIndex(sg.OwnerID)].Name, Race: c.g.Race[c.RaceIndex(sg.OwnerID)].Name,
@@ -31,22 +56,19 @@ func TransformBattle(c *Cache, b *Battle) *report.BattleReport {
ClassName: shipClass.Name, ClassName: shipClass.Name,
LoadType: sg.CargoString(), LoadType: sg.CargoString(),
LoadQuantity: report.F(sg.Load.F()), LoadQuantity: report.F(sg.Load.F()),
Tech: make(map[string]report.Float, len(sg.Tech)),
} }
for t, v := range sg.Tech { for t, v := range sg.Tech {
bg.Tech[t.String()] = report.F(v.F()) bg.Tech[t.String()] = report.F(v.F())
} }
r.Ships[itemNumber] = *bg r.Ships[itemNumber] = *bg
cacheShipClass[shipClass.ID] = itemNumber cacheShipClass[shipClass.ID] = itemNumber
processedGroup[groupId] = true
return itemNumber return itemNumber
} }
ship := func(groupId int) int { ship := func(groupId int) int {
shipClass := c.ShipGroupShipClass(groupId) return addShipGroup(groupId, true)
if v, ok := cacheShipClass[shipClass.ID]; ok {
return v
} else {
return addShipGroup(groupId, true)
}
} }
race := func(groupId int) int { race := func(groupId int) int {
+20
View File
@@ -38,6 +38,10 @@ type Repo interface {
// SaveBattle stores a new battle protocol and battle meta data for turn t // SaveBattle stores a new battle protocol and battle meta data for turn t
SaveBattle(uint, *report.BattleReport, *game.BattleMeta) error SaveBattle(uint, *report.BattleReport, *game.BattleMeta) error
// LoadBattle reads battle's protocol for turn t and battle id.
// Returns false if battle with such id was never stored at turn t
LoadBattle(t uint, id uuid.UUID) (*report.BattleReport, bool, error)
// SaveBombing stores all prodused bombings for turn t // SaveBombing stores all prodused bombings for turn t
SaveBombings(uint, []*game.Bombing) error SaveBombings(uint, []*game.Bombing) error
@@ -143,6 +147,14 @@ func FetchOrder(configure func(*Param), actor string, turn uint) (order *order.U
return ec.fetchOrder(actor, turn) return ec.fetchOrder(actor, turn)
} }
func FetchBattle(configure func(*Param), turn uint, ID uuid.UUID) (b *report.BattleReport, exists bool, err error) {
ec, err := NewRepoController(configure)
if err != nil {
return nil, false, err
}
return ec.fetchBattle(turn, ID)
}
func BanishRace(configure func(*Param), actor string) error { func BanishRace(configure func(*Param), actor string) error {
ec, err := NewRepoController(configure) ec, err := NewRepoController(configure)
if err != nil { if err != nil {
@@ -261,6 +273,14 @@ func (ec *RepoController) fetchOrder(actor string, turn uint) (order *order.User
return return
} }
func (ec *RepoController) fetchBattle(turn uint, ID uuid.UUID) (order *report.BattleReport, exists bool, err error) {
err = ec.executeSafe(func(t uint, c *Controller) error {
order, exists, err = ec.Repo.LoadBattle(turn, ID)
return err
})
return
}
func (ec *RepoController) loadReport(actor string, turn uint) (r *report.Report, err error) { func (ec *RepoController) loadReport(actor string, turn uint) (r *report.Report, err error) {
execErr := ec.executeSafe(func(t uint, c *Controller) (exErr error) { execErr := ec.executeSafe(func(t uint, c *Controller) (exErr error) {
id, exErr := c.RaceID(actor) id, exErr := c.RaceID(actor)
+3 -4
View File
@@ -267,21 +267,20 @@ func (c *Cache) putMaterial(pn uint, v float64) {
c.MustPlanet(pn).Mat(v) c.MustPlanet(pn).Mat(v)
} }
// ProduceShip returns number of ships with shipMass planet p can produce in one turn
func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint { func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint {
if productionAvailable <= 0 { if productionAvailable <= 0 {
return 0 return 0
} }
ships := uint(0) ships := uint(0)
pa := productionAvailable pa := productionAvailable
PRODcost := calc.ShipProductionCost(shipMass) var MATneed, totalCost float64
var MATneed, MATfarm, totalCost float64
for { for {
MATneed = shipMass - float64(p.Material) MATneed = shipMass - float64(p.Material)
if MATneed < 0 { if MATneed < 0 {
MATneed = 0 MATneed = 0
} }
MATfarm = MATneed / float64(p.Resources) totalCost = calc.ShipBuildCost(shipMass, float64(p.Material), float64(p.Resources))
totalCost = PRODcost + MATfarm
if pa < totalCost { if pa < totalCost {
progress := pa / totalCost progress := pa / totalCost
pval := game.F(progress) pval := game.F(progress)
+6 -2
View File
@@ -37,7 +37,7 @@ func (c *Cache) InitReport(t uint) *mr.Report {
OtherScience: make([]mr.OtherScience, 0, 10), OtherScience: make([]mr.OtherScience, 0, 10),
LocalShipClass: make([]mr.ShipClass, 0, 20), LocalShipClass: make([]mr.ShipClass, 0, 20),
OtherShipClass: make([]mr.OthersShipClass, 0, 50), OtherShipClass: make([]mr.OthersShipClass, 0, 50),
Battle: make([]uuid.UUID, 0, 10), Battle: make([]mr.BattleSummary, 0, 10),
Bombing: make([]*mr.Bombing, 0, 10), Bombing: make([]*mr.Bombing, 0, 10),
IncomingGroup: make([]mr.IncomingGroup, 0, 10), IncomingGroup: make([]mr.IncomingGroup, 0, 10),
OnPlanetGroupCache: make(map[uint][]int), OnPlanetGroupCache: make(map[uint][]int),
@@ -342,7 +342,11 @@ func (c *Cache) ReportBattle(ri int, rep *mr.Report, br []*mr.BattleReport) {
} }
sliceIndexValidate(&rep.Battle, i) sliceIndexValidate(&rep.Battle, i)
rep.Battle[i] = br[bi].ID rep.Battle[i] = mr.BattleSummary{
ID: br[bi].ID,
Planet: br[bi].Planet,
Shots: uint(len(br[bi].Protocol)),
}
i++ i++
} }
} }
+65 -16
View File
@@ -13,6 +13,7 @@ package repo
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"slices"
"galaxy/model/order" "galaxy/model/order"
"galaxy/model/report" "galaxy/model/report"
@@ -117,9 +118,25 @@ func loadMeta(s Storage) (*game.GameMeta, error) {
return result, nil return result, nil
} }
func saveMeta(s Storage, t uint, gm *game.GameMeta) error { func loadTurnMeta(s Storage, turn uint) (*game.GameMeta, error) {
var result *game.GameMeta = new(game.GameMeta)
path := fmt.Sprintf("%s/%s", TurnDir(turn), metaPath)
exist, err := s.Exists(path)
if err != nil {
return nil, NewStorageError(err)
}
if !exist {
return result, nil
}
if err := s.ReadSafe(path, result); err != nil {
return nil, NewStorageError(err)
}
return result, nil
}
func saveMeta(s Storage, turn uint, gm *game.GameMeta) error {
// save turn's meta // save turn's meta
path := fmt.Sprintf("%s/%s", TurnDir(t), metaPath) path := fmt.Sprintf("%s/%s", TurnDir(turn), metaPath)
if err := s.Write(path, gm); err != nil { if err := s.Write(path, gm); err != nil {
return NewStorageError(err) return NewStorageError(err)
} }
@@ -131,27 +148,43 @@ func saveMeta(s Storage, t uint, gm *game.GameMeta) error {
return nil return nil
} }
func (r *repo) SaveBattle(t uint, b *report.BattleReport, m *game.BattleMeta) error { func (r *repo) LoadBattle(turn uint, id uuid.UUID) (*report.BattleReport, bool, error) {
meta, err := loadTurnMeta(r.s, turn)
if err != nil {
return nil, false, err
}
i := slices.IndexFunc(meta.Battles, func(m game.BattleMeta) bool { return m.BattleID == id })
if i < 0 {
return nil, false, nil
}
result, err := loadBattle(r.s, turn, meta.Battles[i].BattleID)
if err != nil {
return nil, false, err
}
return result, true, nil
}
func (r *repo) SaveBattle(turn uint, b *report.BattleReport, m *game.BattleMeta) error {
meta, err := loadMeta(r.s) meta, err := loadMeta(r.s)
if err != nil { if err != nil {
return err return err
} }
err = saveBattle(r.s, t, b) err = saveBattle(r.s, turn, b)
if err != nil { if err != nil {
return err return err
} }
meta.Battles = append(meta.Battles, *m) meta.Battles = append(meta.Battles, *m)
return saveMeta(r.s, t, meta) return saveMeta(r.s, turn, meta)
} }
func saveBattle(s Storage, t uint, b *report.BattleReport) error { func saveBattle(s Storage, turn uint, b *report.BattleReport) error {
path := fmt.Sprintf("%s/battle/%s.json", TurnDir(t), b.ID.String()) path := fmt.Sprintf("%s/battle/%s.json", TurnDir(turn), b.ID.String())
exist, err := s.Exists(path) exist, err := s.Exists(path)
if err != nil { if err != nil {
return NewStorageError(err) return NewStorageError(err)
} }
if exist { if exist {
return NewStateError(fmt.Sprintf("battle %v for turn %d already has been saved", b.ID, t)) return NewStateError(fmt.Sprintf("battle %v for turn %d already has been saved", b.ID, turn))
} }
if err := s.Write(path, b); err != nil { if err := s.Write(path, b); err != nil {
return NewStorageError(err) return NewStorageError(err)
@@ -159,7 +192,23 @@ func saveBattle(s Storage, t uint, b *report.BattleReport) error {
return nil return nil
} }
func (r *repo) SaveBombings(t uint, b []*game.Bombing) error { func loadBattle(s Storage, turn uint, id uuid.UUID) (*report.BattleReport, error) {
path := fmt.Sprintf("%s/battle/%s.json", TurnDir(turn), id.String())
exist, err := s.Exists(path)
if err != nil {
return nil, NewStorageError(err)
}
if !exist {
return nil, NewStateError(fmt.Sprintf("battle %v for turn %d never was saved", id, turn))
}
result := new(report.BattleReport)
if err := s.ReadSafe(path, result); err != nil {
return nil, NewStorageError(err)
}
return result, nil
}
func (r *repo) SaveBombings(turn uint, b []*game.Bombing) error {
meta, err := loadMeta(r.s) meta, err := loadMeta(r.s)
if err != nil { if err != nil {
return err return err
@@ -167,11 +216,11 @@ func (r *repo) SaveBombings(t uint, b []*game.Bombing) error {
for i := range b { for i := range b {
meta.Bombings = append(meta.Bombings, *b[i]) meta.Bombings = append(meta.Bombings, *b[i])
} }
return saveMeta(r.s, t, meta) return saveMeta(r.s, turn, meta)
} }
func (r *repo) SaveReport(t uint, rep *report.Report) error { func (r *repo) SaveReport(turn uint, rep *report.Report) error {
return saveReport(r.s, t, rep) return saveReport(r.s, turn, rep)
} }
func saveReport(s Storage, t uint, v *report.Report) error { func saveReport(s Storage, t uint, v *report.Report) error {
@@ -182,12 +231,12 @@ func saveReport(s Storage, t uint, v *report.Report) error {
return nil return nil
} }
func (r *repo) LoadReport(t uint, id uuid.UUID) (*report.Report, error) { func (r *repo) LoadReport(turn uint, id uuid.UUID) (*report.Report, error) {
return loadReport(r.s, t, id) return loadReport(r.s, turn, id)
} }
func loadReport(s Storage, t uint, id uuid.UUID) (*report.Report, error) { func loadReport(s Storage, turn uint, id uuid.UUID) (*report.Report, error) {
path := ReportDir(t, id) path := ReportDir(turn, id)
result := new(report.Report) result := new(report.Report)
exist, err := s.Exists(path) exist, err := s.Exists(path)
if err != nil { if err != nil {
+152
View File
@@ -0,0 +1,152 @@
package router_test
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"galaxy/model/report"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetBattleValidation(t *testing.T) {
validUUID := uuid.New().String()
for _, tc := range []struct {
description string
turn string
battleID string
expectStatus int
}{
{"Negative turn", "-1", validUUID, http.StatusBadRequest},
{"Non-numeric turn", "abc", validUUID, http.StatusBadRequest},
{"Invalid uuid", "0", invalidId, http.StatusBadRequest},
} {
t.Run(tc.description, func(t *testing.T) {
e := &dummyExecutor{}
r := setupRouterExecutor(e)
w := httptest.NewRecorder()
path := fmt.Sprintf("/api/v1/battle/%s/%s", tc.turn, tc.battleID)
req, _ := http.NewRequest(http.MethodGet, path, nil)
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
assert.Equal(t, uuid.Nil, e.FetchBattleID, "FetchBattle must not be called on validation error")
})
}
}
func TestGetBattleFound(t *testing.T) {
id := uuid.New()
raceA := uuid.New()
raceB := uuid.New()
stored := &report.BattleReport{
ID: id,
Planet: 42,
PlanetName: "X-Prime",
Races: map[int]uuid.UUID{
0: raceA,
1: raceB,
},
Ships: map[int]report.BattleReportGroup{
10: {
Race: "Alpha",
ClassName: "Drone",
Tech: map[string]report.Float{"WEAPONS": report.F(1)},
Number: 5,
NumberLeft: 3,
LoadType: "EMP",
LoadQuantity: report.F(0),
InBattle: true,
},
20: {
Race: "Beta",
ClassName: "Spy",
Tech: map[string]report.Float{"SHIELDS": report.F(2)},
Number: 4,
NumberLeft: 0,
LoadType: "EMP",
LoadQuantity: report.F(0),
InBattle: true,
},
},
Protocol: []report.BattleActionReport{
{Attacker: 0, AttackerShipClass: 10, Defender: 1, DefenderShipClass: 20, Destroyed: true},
},
}
e := &dummyExecutor{
FetchBattleResult: stored,
FetchBattleOK: true,
}
r := setupRouterExecutor(e)
w := httptest.NewRecorder()
path := fmt.Sprintf("/api/v1/battle/%d/%s", 7, id.String())
req, _ := http.NewRequest(http.MethodGet, path, nil)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body)
assert.Equal(t, uint(7), e.FetchBattleTurn)
assert.Equal(t, id, e.FetchBattleID)
var got report.BattleReport
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
assert.Equal(t, stored.ID, got.ID)
assert.Equal(t, stored.Planet, got.Planet)
assert.Equal(t, stored.PlanetName, got.PlanetName)
assert.Equal(t, stored.Races, got.Races)
require.Len(t, got.Ships, len(stored.Ships))
assert.Equal(t, stored.Ships[10].ClassName, got.Ships[10].ClassName)
assert.Equal(t, stored.Ships[20].NumberLeft, got.Ships[20].NumberLeft)
require.Len(t, got.Protocol, 1)
assert.Equal(t, stored.Protocol[0], got.Protocol[0])
}
func TestGetBattleTurnZero(t *testing.T) {
id := uuid.New()
e := &dummyExecutor{
FetchBattleResult: &report.BattleReport{ID: id},
FetchBattleOK: true,
}
r := setupRouterExecutor(e)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/battle/0/%s", id.String()), nil)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body)
assert.Equal(t, uint(0), e.FetchBattleTurn)
assert.Equal(t, id, e.FetchBattleID)
}
func TestGetBattleNotFound(t *testing.T) {
id := uuid.New()
e := &dummyExecutor{FetchBattleOK: false}
r := setupRouterExecutor(e)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/battle/3/%s", id.String()), nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code, w.Body)
assert.Equal(t, uint(3), e.FetchBattleTurn)
assert.Equal(t, id, e.FetchBattleID)
}
func TestGetBattleEngineError(t *testing.T) {
e := &dummyExecutor{FetchBattleErr: errors.New("engine boom")}
r := setupRouterExecutor(e)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/battle/3/%s", uuid.NewString()), nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code, w.Body)
}
+37
View File
@@ -0,0 +1,37 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func BattleHandler(c *gin.Context, executor CommandExecutor) {
turn := c.Param("turn")
t, err := strconv.Atoi(turn)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if t < 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "turn number can't be negative"})
return
}
id := c.Param("uuid")
battleID, err := uuid.Parse(id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
r, exists, err := executor.FetchBattle(uint(t), battleID)
if errorResponse(c, err) {
return
}
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "unknown battle"})
return
}
c.JSON(http.StatusOK, r)
}
+6
View File
@@ -17,6 +17,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/google/uuid"
) )
type CommandExecutor interface { type CommandExecutor interface {
@@ -29,6 +30,7 @@ type CommandExecutor interface {
Execute(cmd ...Command) error Execute(cmd ...Command) error
ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error)
FetchOrder(actor string, turn uint) (*order.UserGamesOrder, bool, error) FetchOrder(actor string, turn uint) (*order.UserGamesOrder, bool, error)
FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, bool, error)
} }
type Command func(controller.Ctrl) error type Command func(controller.Ctrl) error
@@ -86,6 +88,10 @@ func (e *executor) FetchOrder(actor string, turn uint) (*order.UserGamesOrder, b
return controller.FetchOrder(e.cfg, actor, turn) return controller.FetchOrder(e.cfg, actor, turn)
} }
func (e *executor) FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, bool, error) {
return controller.FetchBattle(e.cfg, turn, ID)
}
func (e *executor) GenerateGame(races []string) (rest.StateResponse, error) { func (e *executor) GenerateGame(races []string) (rest.StateResponse, error) {
s, err := controller.GenerateGame(e.cfg, races) s, err := controller.GenerateGame(e.cfg, races)
if err != nil { if err != nil {
+1
View File
@@ -76,6 +76,7 @@ func setupRouter(executor handler.CommandExecutor) *gin.Engine {
groupV1.GET("/report", func(ctx *gin.Context) { handler.ReportHandler(ctx, executor) }) groupV1.GET("/report", func(ctx *gin.Context) { handler.ReportHandler(ctx, executor) })
groupV1.PUT("/order", func(ctx *gin.Context) { handler.PutOrderHandler(ctx, executor) }) groupV1.PUT("/order", func(ctx *gin.Context) { handler.PutOrderHandler(ctx, executor) })
groupV1.GET("/order", func(ctx *gin.Context) { handler.GetOrderHandler(ctx, executor) }) groupV1.GET("/order", func(ctx *gin.Context) { handler.GetOrderHandler(ctx, executor) })
groupV1.GET("/battle/:turn/:uuid", func(ctx *gin.Context) { handler.BattleHandler(ctx, executor) })
// /command is reserved for future use; any API request for orders should use /order // /command is reserved for future use; any API request for orders should use /order
groupV1.PUT("/command", LimitMiddleware(1), func(ctx *gin.Context) { handler.CommandHandler(ctx, executor) }) groupV1.PUT("/command", LimitMiddleware(1), func(ctx *gin.Context) { handler.CommandHandler(ctx, executor) })
@@ -45,6 +45,13 @@ type dummyExecutor struct {
FetchOrderResult *order.UserGamesOrder FetchOrderResult *order.UserGamesOrder
FetchOrderOK bool FetchOrderOK bool
FetchOrderErr error FetchOrderErr error
// FetchBattle controls and observes calls to FetchBattle.
FetchBattleTurn uint
FetchBattleID uuid.UUID
FetchBattleResult *report.BattleReport
FetchBattleOK bool
FetchBattleErr error
} }
func (e *dummyExecutor) ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) { func (e *dummyExecutor) ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) {
@@ -68,6 +75,12 @@ func (e *dummyExecutor) FetchOrder(actor string, turn uint) (*order.UserGamesOrd
return e.FetchOrderResult, e.FetchOrderOK, e.FetchOrderErr return e.FetchOrderResult, e.FetchOrderOK, e.FetchOrderErr
} }
func (e *dummyExecutor) FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, bool, error) {
e.FetchBattleTurn = turn
e.FetchBattleID = ID
return e.FetchBattleResult, e.FetchBattleOK, e.FetchBattleErr
}
func (e *dummyExecutor) Execute(command ...handler.Command) error { func (e *dummyExecutor) Execute(command ...handler.Command) error {
e.CommandsExecuted = len(command) e.CommandsExecuted = len(command)
return nil return nil
+187 -3
View File
@@ -207,6 +207,33 @@ paths:
$ref: "#/components/responses/ValidationError" $ref: "#/components/responses/ValidationError"
"500": "500":
$ref: "#/components/responses/InternalError" $ref: "#/components/responses/InternalError"
/api/v1/battle/{turn}/{uuid}:
get:
tags:
- PlayerActions
operationId: getBattle
summary: Fetch a single battle report
description: |
Returns the full `BattleReport` for the supplied `turn` and battle
identifier. The `turn` segment must be a non-negative integer; the
`uuid` segment must be a valid RFC 4122 UUID. Responds with
`404 Not Found` when no battle is stored for the supplied pair.
parameters:
- $ref: "#/components/parameters/BattleTurnParam"
- $ref: "#/components/parameters/BattleIDParam"
responses:
"200":
description: Battle report for the supplied turn and identifier.
content:
application/json:
schema:
$ref: "#/components/schemas/BattleReport"
"400":
$ref: "#/components/responses/ValidationError"
"404":
description: No battle exists for the supplied turn and identifier.
"500":
$ref: "#/components/responses/InternalError"
/api/v1/admin/turn: /api/v1/admin/turn:
put: put:
tags: tags:
@@ -265,6 +292,22 @@ components:
type: integer type: integer
minimum: 0 minimum: 0
default: 0 default: 0
BattleTurnParam:
name: turn
in: path
required: true
description: Turn number the battle was generated on.
schema:
type: integer
minimum: 0
BattleIDParam:
name: uuid
in: path
required: true
description: Battle identifier (RFC 4122 UUID).
schema:
type: string
format: uuid
schemas: schemas:
HealthzResponse: HealthzResponse:
type: object type: object
@@ -541,10 +584,9 @@ components:
$ref: "#/components/schemas/OtherShipClass" $ref: "#/components/schemas/OtherShipClass"
battle: battle:
type: array type: array
description: UUIDs of battle reports relevant to this turn. description: Battle summaries relevant to this turn.
items: items:
type: string $ref: "#/components/schemas/BattleSummary"
format: uuid
bombing: bombing:
type: array type: array
description: Bombing events that occurred during this turn. description: Bombing events that occurred during this turn.
@@ -788,6 +830,148 @@ components:
wiped: wiped:
type: boolean type: boolean
description: True when all population was eliminated by the bombing. description: True when all population was eliminated by the bombing.
BattleSummary:
type: object
description: |
Identifies one battle relevant to the report recipient. Used by
clients to render a battle marker on the map without fetching
the full BattleReport. `planet` locates the marker; `shots`
scales the marker stroke with the battle length.
required:
- id
- planet
- shots
properties:
id:
type: string
format: uuid
description: Battle identifier; fetch the full report via `/api/v1/battle/{turn}/{uuid}`.
planet:
type: integer
minimum: 0
description: Planet number the battle took place on.
shots:
type: integer
minimum: 0
description: Number of shots exchanged during the battle.
BattleReport:
type: object
description: |
Full battle report. `races` and `ships` are JSON objects whose
keys are stringified integers used to cross-reference entries
from `protocol`: a `BattleActionReport` carries integer indices
into both maps. The serialised key is a string because JSON
object keys are always strings.
required:
- id
- planet
- planetName
- races
- ships
- protocol
properties:
id:
type: string
format: uuid
description: Battle identifier.
planet:
type: integer
minimum: 0
description: Planet number the battle took place on.
planetName:
type: string
description: Planet name at battle start.
races:
type: object
description: |
Participating races keyed by the integer index used in
`protocol.a` / `protocol.d`. Values are race identifiers.
additionalProperties:
type: string
format: uuid
ships:
type: object
description: |
Participating ship groups keyed by the integer index used
in `protocol.sa` / `protocol.sd`.
additionalProperties:
$ref: "#/components/schemas/BattleReportGroup"
protocol:
type: array
description: Ordered list of shots exchanged during the battle.
items:
$ref: "#/components/schemas/BattleActionReport"
BattleReportGroup:
type: object
description: One ship group participating in the battle.
required:
- race
- className
- tech
- num
- numLeft
- loadType
- loadQuantity
- inBattle
properties:
race:
type: string
description: Race name of the group owner.
className:
type: string
description: Ship class name; resolvable through `LocalShipClass` or `OtherShipClass`.
tech:
type: object
description: Technology levels keyed by tech type name.
additionalProperties:
type: number
num:
type: integer
minimum: 0
description: Initial number of ships in this group.
numLeft:
type: integer
minimum: 0
description: Number of ships remaining at the end of the battle.
loadType:
type: string
description: Type of cargo loaded.
loadQuantity:
type: number
description: Quantity of cargo loaded.
inBattle:
type: boolean
description: |
True when the group actually fights. False groups observe
the battle in peace state and never fire or take damage.
BattleActionReport:
type: object
description: |
One shot in the battle. Attacker and defender indices reference
`BattleReport.races`; ship-class indices reference
`BattleReport.ships`.
required:
- a
- sa
- d
- sd
- x
properties:
a:
type: integer
description: Index into `BattleReport.races` for the attacker.
sa:
type: integer
description: Index into `BattleReport.ships` for the attacker's group.
d:
type: integer
description: Index into `BattleReport.races` for the defender.
sd:
type: integer
description: Index into `BattleReport.ships` for the defender's group.
x:
type: boolean
description: True when the defender ship was destroyed by this shot.
IncomingGroup: IncomingGroup:
type: object type: object
description: An identified ship group inbound toward a planet of this race. description: An identified ship group inbound toward a planet of this race.
+72
View File
@@ -79,6 +79,13 @@ func TestGameOpenAPISpecFreezesResponseSchemas(t *testing.T) {
status: http.StatusOK, status: http.StatusOK,
wantRef: "#/components/schemas/HealthzResponse", wantRef: "#/components/schemas/HealthzResponse",
}, },
{
name: "get battle",
path: "/api/v1/battle/{turn}/{uuid}",
method: http.MethodGet,
status: http.StatusOK,
wantRef: "#/components/schemas/BattleReport",
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -271,6 +278,71 @@ func TestGameOpenAPISpecFreezesCommandRequest(t *testing.T) {
require.Equal(t, uint64(1), cmdSchema.Value.MinItems, "CommandRequest.cmd minItems must be 1") require.Equal(t, uint64(1), cmdSchema.Value.MinItems, "CommandRequest.cmd minItems must be 1")
} }
func TestGameOpenAPISpecFreezesGetBattleOperation(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
operation := getOpenAPIOperation(t, doc, "/api/v1/battle/{turn}/{uuid}", http.MethodGet)
require.Equal(t, "getBattle", operation.OperationID, "GET /api/v1/battle/{turn}/{uuid} operation id")
paramRefs := make(map[string]bool)
for _, p := range operation.Parameters {
require.NotNil(t, p.Value, "parameter must have value")
paramRefs[p.Ref] = true
}
require.True(t, paramRefs["#/components/parameters/BattleTurnParam"], "GET /api/v1/battle/{turn}/{uuid} must reference BattleTurnParam")
require.True(t, paramRefs["#/components/parameters/BattleIDParam"], "GET /api/v1/battle/{turn}/{uuid} must reference BattleIDParam")
require.NotNil(t, operation.Responses, "operation must declare responses")
notFound := operation.Responses.Status(http.StatusNotFound)
require.NotNil(t, notFound, "operation must declare 404 response")
require.NotNil(t, notFound.Value, "404 response must have a value")
}
func TestGameOpenAPISpecFreezesBattleReport(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
reportSchema := componentSchemaRef(t, doc, "BattleReport")
assertRequiredFields(t, reportSchema, "id", "planet", "planetName", "races", "ships", "protocol")
groupSchema := componentSchemaRef(t, doc, "BattleReportGroup")
assertRequiredFields(t, groupSchema, "race", "className", "tech", "num", "numLeft", "loadType", "loadQuantity", "inBattle")
actionSchema := componentSchemaRef(t, doc, "BattleActionReport")
assertRequiredFields(t, actionSchema, "a", "sa", "d", "sd", "x")
protocolSchema := reportSchema.Value.Properties["protocol"]
require.NotNil(t, protocolSchema, "BattleReport.protocol schema must exist")
require.True(t, protocolSchema.Value.Type.Is("array"), "BattleReport.protocol must be array")
require.NotNil(t, protocolSchema.Value.Items, "BattleReport.protocol items must be defined")
assertSchemaRef(t, protocolSchema.Value.Items, "#/components/schemas/BattleActionReport", "BattleReport.protocol items schema")
shipsSchema := reportSchema.Value.Properties["ships"]
require.NotNil(t, shipsSchema, "BattleReport.ships schema must exist")
require.True(t, shipsSchema.Value.Type.Is("object"), "BattleReport.ships must be object")
require.NotNil(t, shipsSchema.Value.AdditionalProperties.Schema, "BattleReport.ships additionalProperties must be a schema")
assertSchemaRef(t, shipsSchema.Value.AdditionalProperties.Schema, "#/components/schemas/BattleReportGroup", "BattleReport.ships additionalProperties schema")
}
func TestGameOpenAPISpecFreezesBattleSummary(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
summary := componentSchemaRef(t, doc, "BattleSummary")
assertRequiredFields(t, summary, "id", "planet", "shots")
report := componentSchemaRef(t, doc, "Report")
battle := report.Value.Properties["battle"]
require.NotNil(t, battle, "Report.battle schema must exist")
require.True(t, battle.Value.Type.Is("array"), "Report.battle must be array")
require.NotNil(t, battle.Value.Items, "Report.battle items must be defined")
assertSchemaRef(t, battle.Value.Items, "#/components/schemas/BattleSummary", "Report.battle items schema")
}
func TestGameOpenAPISpecHealthzStatusEnum(t *testing.T) { func TestGameOpenAPISpecHealthzStatusEnum(t *testing.T) {
t.Parallel() t.Parallel()
+6
View File
@@ -385,6 +385,12 @@ The current direct `Gateway -> User` self-service boundary uses that pattern:
- business error projection: - business error projection:
- gateway `result_code` - gateway `result_code`
- FlatBuffers error payload mirroring User Service `code` and `message` - FlatBuffers error payload mirroring User Service `code` and `message`
- User Service `code` values pass through verbatim as `result_code`
via `projectUserBackendError`; known non-`ok` codes that clients
branch on include `turn_already_closed` (Phase 25 turn cutoff,
HTTP 409 from `Orders` / `Commands` while the runtime is in
`generation_in_progress`) and `game_paused` (Phase 25 auto-pause,
HTTP 409 while the game is in `paused` / `finished` / `removed`).
The request envelope version literal is `v1`. The request envelope version literal is `v1`.
`payload_hash` is the raw 32-byte SHA-256 digest of `payload_bytes`. `payload_hash` is the raw 32-byte SHA-256 digest of `payload_bytes`.
+26
View File
@@ -11,3 +11,29 @@ func PlanetProduceShipMass(L, Mat, Res float64) float64 {
} }
return (L + Mat/Res) / (10 + 1/Res) return (L + Mat/Res) / (10 + 1/Res)
} }
// ShipBuildCost returns the total per-turn cost (production units) to
// build one ship of empty mass shipMass on a planet that currently
// holds material stockpile and has natural resources. The cost is the
// ship's production cost ([ShipProductionCost]) plus the cost of
// farming any missing material from the planet (the missing-material
// volume divided by the planet's resources rating).
//
// resources is expected to be positive in normal play; the helper
// guards against a non-positive value by collapsing the material-
// farming term to zero, which keeps callers numerically stable on
// pathological synthetic data. Mirrors the per-iteration math inside
// the engine's controller.ProduceShip so both surfaces — and the
// legacy-report-to-json dev tool that needs to derive prod_used from
// percent — share the same formula.
func ShipBuildCost(shipMass, material, resources float64) float64 {
matNeed := shipMass - material
if matNeed < 0 {
matNeed = 0
}
matFarm := 0.
if resources > 0 {
matFarm = matNeed / resources
}
return ShipProductionCost(shipMass) + matFarm
}
+63
View File
@@ -0,0 +1,63 @@
package calc_test
import (
"math"
"testing"
"galaxy/calc"
)
func TestShipBuildCost(t *testing.T) {
cases := []struct {
name string
shipMass float64
material float64
resources float64
want float64
}{
{
name: "material exceeds mass: no farming needed",
shipMass: 5,
material: 10,
resources: 0.5,
want: 50, // ShipProductionCost(5) = 50; matFarm = 0.
},
{
name: "material equal to mass: no farming needed",
shipMass: 5,
material: 5,
resources: 0.5,
want: 50,
},
{
name: "material short of mass: farming term added",
shipMass: 10,
material: 3,
resources: 0.5,
want: 114, // 100 + (7 / 0.5).
},
{
name: "no material at all: full mass farmed",
shipMass: 4,
material: 0,
resources: 0.5,
want: 48, // 40 + (4 / 0.5).
},
{
name: "zero resources collapses farming term to zero",
shipMass: 10,
material: 3,
resources: 0,
want: 100, // 100 + 0; resources == 0 is a pathological guard.
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := calc.ShipBuildCost(tc.shipMass, tc.material, tc.resources)
if math.Abs(got-tc.want) > 1e-9 {
t.Errorf("ShipBuildCost(%v, %v, %v) = %v, want %v",
tc.shipMass, tc.material, tc.resources, got, tc.want)
}
})
}
}
+51 -19
View File
@@ -6,32 +6,64 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// BattleSummary identifies one battle relevant to the report recipient
// and carries the data needed to render a battle marker on the map
// without fetching the full BattleReport. Planet locates the marker;
// Shots scales the marker stroke with the battle length.
type BattleSummary struct {
ID uuid.UUID `json:"id"`
Planet uint `json:"planet"`
Shots uint `json:"shots"`
}
type BattleReport struct { type BattleReport struct {
ID uuid.UUID `json:"id"` // Battle unique ID
Planet uint `json:"planet"` ID uuid.UUID `json:"id"`
PlanetName string `json:"planetName"` // Planet number
Races map[int]uuid.UUID `json:"races"` Planet uint `json:"planet"`
Ships map[int]BattleReportGroup `json:"ships"` // Planet name at battle start
Protocol []BattleActionReport `json:"protocol"` PlanetName string `json:"planetName"`
// Races participating map: <key:RaceID>
Races map[int]uuid.UUID `json:"races"`
// Ships Groups participating map: <key:BattleReportGroup>
Ships map[int]BattleReportGroup `json:"ships"`
// Battle's firing protocol
Protocol []BattleActionReport `json:"protocol"`
} }
type BattleReportGroup struct { type BattleReportGroup struct {
InBattle bool `json:"inBattle"` // Name of the race
Number uint `json:"num"` Race string `json:"race"`
NumberLeft uint `json:"numLeft"` // Name of the Ship Class.
LoadQuantity Float `json:"loadQuantity"` // By design, ship's info MUST be present in Game's Repors in 'LocalShipClass' or 'OtherShipClass'
Tech map[string]Float `json:"tech"` ClassName string `json:"className"`
Race string `json:"race"` // Ship Group's technologies mapping <tech:level>
ClassName string `json:"className"` Tech map[string]Float `json:"tech"`
LoadType string `json:"loadType"` // Initial number of ships in this group
Number uint `json:"num"`
// Number of ships left after battle
NumberLeft uint `json:"numLeft"`
// Type of cargo loaded
LoadType string `json:"loadType"`
// Quantity of cargo loaded
LoadQuantity Float `json:"loadQuantity"`
// A Race with its ships can be in Peace state with all participants,
// so no shots will be fired and no damage taken, participating only as viewer
// when InBattle=false
InBattle bool `json:"inBattle"`
} }
type BattleActionReport struct { type BattleActionReport struct {
Attacker int `json:"a"` // `key` from BattleReport.Races map
AttackerShipClass int `json:"sa"` Attacker int `json:"a"`
Defender int `json:"d"` // `key` from BattleReport.Ships map
DefenderShipClass int `json:"sd"` AttackerShipClass int `json:"sa"`
Destroyed bool `json:"x"` // `key` from BattleReport.Races map
Defender int `json:"d"`
// `key` from BattleReport.Ships map
DefenderShipClass int `json:"sd"`
// Was ship destroyed after attack or survived under shields
Destroyed bool `json:"x"`
} }
func (b BattleReport) MarshalBinary() (data []byte, err error) { func (b BattleReport) MarshalBinary() (data []byte, err error) {
+1 -1
View File
@@ -33,7 +33,7 @@ type Report struct {
OtherScience []OtherScience `json:"otherScience,omitempty"` OtherScience []OtherScience `json:"otherScience,omitempty"`
LocalShipClass []ShipClass `json:"localShipClass,omitempty"` LocalShipClass []ShipClass `json:"localShipClass,omitempty"`
OtherShipClass []OthersShipClass `json:"otherShipClass,omitempty"` OtherShipClass []OthersShipClass `json:"otherShipClass,omitempty"`
Battle []uuid.UUID `json:"battle,omitempty"` Battle []BattleSummary `json:"battle,omitempty"`
Bombing []*Bombing `json:"bombing,omitempty"` Bombing []*Bombing `json:"bombing,omitempty"`
IncomingGroup []IncomingGroup `json:"incomingGroup,omitempty"` IncomingGroup []IncomingGroup `json:"incomingGroup,omitempty"`
LocalPlanet []LocalPlanet `json:"localPlanet,omitempty"` LocalPlanet []LocalPlanet `json:"localPlanet,omitempty"`
+12 -1
View File
@@ -196,6 +196,17 @@ table LocalFleet {
state:string; state:string;
} }
// BattleSummary identifies one battle the report recipient
// participated in or could see on a planet. `planet` lets the map
// place a battle marker without fetching the full BattleReport;
// `shots` lets the marker scale its stroke with the protocol length
// (1 shot → thinnest cross, 100+ shots → maximum cross thickness).
table BattleSummary {
id:common.UUID (required);
planet:uint64;
shots:uint64;
}
table Report { table Report {
version:uint64; version:uint64;
turn:uint64; turn:uint64;
@@ -210,7 +221,7 @@ table Report {
other_science:[OtherScience]; other_science:[OtherScience];
local_ship_class:[ShipClass]; local_ship_class:[ShipClass];
other_ship_class:[OthersShipClass]; other_ship_class:[OthersShipClass];
battle:[common.UUID]; battle:[BattleSummary];
bombing:[Bombing]; bombing:[Bombing];
incoming_group:[IncomingGroup]; incoming_group:[IncomingGroup];
local_planet:[LocalPlanet]; local_planet:[LocalPlanet];
+97
View File
@@ -0,0 +1,97 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package report
import (
flatbuffers "github.com/google/flatbuffers/go"
common "galaxy/schema/fbs/common"
)
type BattleSummary struct {
_tab flatbuffers.Table
}
func GetRootAsBattleSummary(buf []byte, offset flatbuffers.UOffsetT) *BattleSummary {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &BattleSummary{}
x.Init(buf, n+offset)
return x
}
func FinishBattleSummaryBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsBattleSummary(buf []byte, offset flatbuffers.UOffsetT) *BattleSummary {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &BattleSummary{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedBattleSummaryBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *BattleSummary) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *BattleSummary) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *BattleSummary) Id(obj *common.UUID) *common.UUID {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
x := o + rcv._tab.Pos
if obj == nil {
obj = new(common.UUID)
}
obj.Init(rcv._tab.Bytes, x)
return obj
}
return nil
}
func (rcv *BattleSummary) Planet() uint64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetUint64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *BattleSummary) MutatePlanet(n uint64) bool {
return rcv._tab.MutateUint64Slot(6, n)
}
func (rcv *BattleSummary) Shots() uint64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.GetUint64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *BattleSummary) MutateShots(n uint64) bool {
return rcv._tab.MutateUint64Slot(8, n)
}
func BattleSummaryStart(builder *flatbuffers.Builder) {
builder.StartObject(3)
}
func BattleSummaryAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
builder.PrependStructSlot(0, flatbuffers.UOffsetT(id), 0)
}
func BattleSummaryAddPlanet(builder *flatbuffers.Builder, planet uint64) {
builder.PrependUint64Slot(1, planet, 0)
}
func BattleSummaryAddShots(builder *flatbuffers.Builder, shots uint64) {
builder.PrependUint64Slot(2, shots, 0)
}
func BattleSummaryEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+4 -5
View File
@@ -4,8 +4,6 @@ package report
import ( import (
flatbuffers "github.com/google/flatbuffers/go" flatbuffers "github.com/google/flatbuffers/go"
common "galaxy/schema/fbs/common"
) )
type Report struct { type Report struct {
@@ -231,11 +229,12 @@ func (rcv *Report) OtherShipClassLength() int {
return 0 return 0
} }
func (rcv *Report) Battle(obj *common.UUID, j int) bool { func (rcv *Report) Battle(obj *BattleSummary, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(30)) o := flatbuffers.UOffsetT(rcv._tab.Offset(30))
if o != 0 { if o != 0 {
x := rcv._tab.Vector(o) x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 16 x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x) obj.Init(rcv._tab.Bytes, x)
return true return true
} }
@@ -551,7 +550,7 @@ func ReportAddBattle(builder *flatbuffers.Builder, battle flatbuffers.UOffsetT)
builder.PrependUOffsetTSlot(13, flatbuffers.UOffsetT(battle), 0) builder.PrependUOffsetTSlot(13, flatbuffers.UOffsetT(battle), 0)
} }
func ReportStartBattleVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { func ReportStartBattleVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(16, numElems, 8) return builder.StartVector(4, numElems, 4)
} }
func ReportAddBombing(builder *flatbuffers.Builder, bombing flatbuffers.UOffsetT) { func ReportAddBombing(builder *flatbuffers.Builder, bombing flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(14, flatbuffers.UOffsetT(bombing), 0) builder.PrependUOffsetTSlot(14, flatbuffers.UOffsetT(bombing), 0)
+36 -12
View File
@@ -10,7 +10,6 @@ import (
fbs "galaxy/schema/fbs/report" fbs "galaxy/schema/fbs/report"
flatbuffers "github.com/google/flatbuffers/go" flatbuffers "github.com/google/flatbuffers/go"
"github.com/google/uuid"
) )
// ReportToPayload converts model.Report from the internal representation to // ReportToPayload converts model.Report from the internal representation to
@@ -120,7 +119,7 @@ func ReportToPayload(report *model.Report) ([]byte, error) {
otherScienceVector := encodeReportOffsetVector(builder, len(otherScienceOffsets), fbs.ReportStartOtherScienceVector, otherScienceOffsets) otherScienceVector := encodeReportOffsetVector(builder, len(otherScienceOffsets), fbs.ReportStartOtherScienceVector, otherScienceOffsets)
localShipClassVector := encodeReportOffsetVector(builder, len(localShipClassOffsets), fbs.ReportStartLocalShipClassVector, localShipClassOffsets) localShipClassVector := encodeReportOffsetVector(builder, len(localShipClassOffsets), fbs.ReportStartLocalShipClassVector, localShipClassOffsets)
otherShipClassVector := encodeReportOffsetVector(builder, len(otherShipClassOffsets), fbs.ReportStartOtherShipClassVector, otherShipClassOffsets) otherShipClassVector := encodeReportOffsetVector(builder, len(otherShipClassOffsets), fbs.ReportStartOtherShipClassVector, otherShipClassOffsets)
battleVector := encodeReportUUIDVector(builder, report.Battle) battleVector := encodeReportBattleSummaries(builder, report.Battle)
bombingVector := encodeReportOffsetVector(builder, len(bombingOffsets), fbs.ReportStartBombingVector, bombingOffsets) bombingVector := encodeReportOffsetVector(builder, len(bombingOffsets), fbs.ReportStartBombingVector, bombingOffsets)
incomingGroupVector := encodeReportOffsetVector(builder, len(incomingGroupOffsets), fbs.ReportStartIncomingGroupVector, incomingGroupOffsets) incomingGroupVector := encodeReportOffsetVector(builder, len(incomingGroupOffsets), fbs.ReportStartIncomingGroupVector, incomingGroupOffsets)
localPlanetVector := encodeReportOffsetVector(builder, len(localPlanetOffsets), fbs.ReportStartLocalPlanetVector, localPlanetOffsets) localPlanetVector := encodeReportOffsetVector(builder, len(localPlanetOffsets), fbs.ReportStartLocalPlanetVector, localPlanetOffsets)
@@ -734,13 +733,29 @@ func decodeReportBattleVector(flatReport *fbs.Report, result *model.Report) erro
return nil return nil
} }
result.Battle = make([]uuid.UUID, length) result.Battle = make([]model.BattleSummary, length)
item := new(commonfbs.UUID) item := new(fbs.BattleSummary)
idHolder := new(commonfbs.UUID)
for i := 0; i < length; i++ { for i := 0; i < length; i++ {
if !flatReport.Battle(item, i) { if !flatReport.Battle(item, i) {
return fmt.Errorf("decode report battle %d: battle is missing", i)
}
if item.Id(idHolder) == nil {
return fmt.Errorf("decode report battle %d: battle id is missing", i) return fmt.Errorf("decode report battle %d: battle id is missing", i)
} }
result.Battle[i] = uuidFromHiLo(item.Hi(), item.Lo()) planet, err := uint64ToUint(item.Planet(), "planet")
if err != nil {
return fmt.Errorf("decode report battle %d: %w", i, err)
}
shots, err := uint64ToUint(item.Shots(), "shots")
if err != nil {
return fmt.Errorf("decode report battle %d: %w", i, err)
}
result.Battle[i] = model.BattleSummary{
ID: uuidFromHiLo(idHolder.Hi(), idHolder.Lo()),
Planet: planet,
Shots: shots,
}
} }
return nil return nil
@@ -1299,17 +1314,26 @@ func encodeReportOffsetVector(
return builder.EndVector(length) return builder.EndVector(length)
} }
func encodeReportUUIDVector(builder *flatbuffers.Builder, ids []uuid.UUID) flatbuffers.UOffsetT { func encodeReportBattleSummaries(builder *flatbuffers.Builder, summaries []model.BattleSummary) flatbuffers.UOffsetT {
if len(ids) == 0 { if len(summaries) == 0 {
return 0 return 0
} }
fbs.ReportStartBattleVector(builder, len(ids)) offsets := make([]flatbuffers.UOffsetT, len(summaries))
for i := len(ids) - 1; i >= 0; i-- { for i := range summaries {
hi, lo := uuidToHiLo(ids[i]) hi, lo := uuidToHiLo(summaries[i].ID)
commonfbs.CreateUUID(builder, hi, lo) fbs.BattleSummaryStart(builder)
fbs.BattleSummaryAddId(builder, commonfbs.CreateUUID(builder, hi, lo))
fbs.BattleSummaryAddPlanet(builder, uint64(summaries[i].Planet))
fbs.BattleSummaryAddShots(builder, uint64(summaries[i].Shots))
offsets[i] = fbs.BattleSummaryEnd(builder)
} }
return builder.EndVector(len(ids))
fbs.ReportStartBattleVector(builder, len(offsets))
for i := len(offsets) - 1; i >= 0; i-- {
builder.PrependUOffsetT(offsets[i])
}
return builder.EndVector(len(offsets))
} }
func encodeReportRouteEntryVector(builder *flatbuffers.Builder, route map[uint]string) flatbuffers.UOffsetT { func encodeReportRouteEntryVector(builder *flatbuffers.Builder, route map[uint]string) flatbuffers.UOffsetT {
+11 -3
View File
@@ -255,9 +255,17 @@ func sampleReport() *model.Report {
OtherShipClass: []model.OthersShipClass{ OtherShipClass: []model.OthersShipClass{
{Race: "Martians", ShipClass: model.ShipClass{Name: "destroyer", Drive: model.Float(1.75), Armament: 6, Weapons: model.Float(2.25), Shields: model.Float(2.75), Cargo: model.Float(3.25), Mass: model.Float(10.5)}}, {Race: "Martians", ShipClass: model.ShipClass{Name: "destroyer", Drive: model.Float(1.75), Armament: 6, Weapons: model.Float(2.25), Shields: model.Float(2.75), Cargo: model.Float(3.25), Mass: model.Float(10.5)}},
}, },
Battle: []uuid.UUID{ Battle: []model.BattleSummary{
uuid.MustParse("11111111-1111-1111-1111-111111111111"), {
uuid.MustParse("22222222-2222-2222-2222-222222222222"), ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
Planet: 4,
Shots: 17,
},
{
ID: uuid.MustParse("22222222-2222-2222-2222-222222222222"),
Planet: 11,
Shots: 3,
},
}, },
Bombing: []*model.Bombing{ Bombing: []*model.Bombing{
{ {
+60 -21
View File
@@ -1,8 +1,25 @@
# legacy-report-to-json # legacy-report-to-json
Converts legacy text-format Galaxy turn reports (the *dg* and *gplus* Converts legacy text-format Galaxy turn reports (the *dg* and *gplus*
engines that lived under `tools/local-dev/reports/`) into the JSON engines that lived under `tools/local-dev/reports/`) into a JSON
shape of [`pkg/model/report.Report`](../../../pkg/model/report). envelope around [`pkg/model/report.Report`](../../../pkg/model/report)
plus full `BattleReport`s (Phase 27).
## Output envelope
```jsonc
{
"version": 1,
"report": { /* report.Report */ },
"battles": { "<uuid>": { /* report.BattleReport */ }, ... }
}
```
`version: 1` lets the UI distinguish a current-format envelope from a
bare `Report` JSON. The synthetic-report loader accepts both — pre-
envelope synthetic JSON files still load, just without battle
fixtures. `battles` is omitted when the legacy file has no combat
events.
The output is consumed by the **DEV-only synthetic-report loader** on The output is consumed by the **DEV-only synthetic-report loader** on
the UI client's lobby (`import.meta.env.DEV`). With it, the map view, the UI client's lobby (`import.meta.env.DEV`). With it, the map view,
@@ -17,8 +34,8 @@ The tool is part of the synthetic-report parity rule documented in
```sh ```sh
# from the repo root, with the Go workspace active # from the repo root, with the Go workspace active
go run ./tools/local-dev/legacy-report/cmd/legacy-report-to-json \ go run ./tools/local-dev/legacy-report/cmd/legacy-report-to-json \
--in tools/local-dev/reports/dg/KNNTS039.REP \ --in tools/local-dev/reports/dg/KNNTS041.REP \
--out tools/local-dev/reports/dg/KNNTS039.json --out tools/local-dev/reports/dg/KNNTS041.json
``` ```
`--in` reads `-` as stdin; `--out` defaults to stdout when empty or `--in` reads `-` as stdin; `--out` defaults to stdout when empty or
@@ -60,9 +77,29 @@ 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) |
| `Battle[]` (summary) | `Battle at (#N) Name` headers + `Battle Protocol` (Phase 27 follow-up) |
The envelope's `battles` map carries the full `BattleReport`-s parsed
out of the same blocks: every roster row turns into a
`BattleReportGroup` (`Number`/`Tech`/`LoadType`/`LoadQuantity`/
`NumberLeft`/`InBattle`), every `... fires on ... : Destroyed|Shields`
line turns into a `BattleActionReport`. UUIDs are synthesised
deterministically — `syntheticBattleID(idx)` for the battle
identifier (per-report 0-based index, SHA1 namespace
`be01a000-0000-0000-0000-000000000002`) and
`syntheticBattleRaceID(name)` for `BattleReport.Races` entries (SHA1
namespace `be01a000-0000-0000-0000-000000000003`). Re-running the
converter on the same input file yields byte-identical JSON, so
synthetic-mode UI URLs (`/games/synthetic-…/battle/<uuid>?turn=N`)
stay stable across regenerations.
Players whose name in the legacy file ends with `_RIP` are emitted with Players whose name in the legacy file ends with `_RIP` are emitted with
the suffix stripped and `Extinct: true`. the suffix stripped and `Extinct: true`.
@@ -78,29 +115,31 @@ 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`)
- Sciences, both local (`Your Sciences`) and foreign (`<Race> Sciences`)
- Battles (`Battle at (#N) Name`, `Battle Protocol`) — battle rosters
inside these blocks carry minimal columns (no origin / range /
destination) and are intentionally skipped: parsing them would
produce mostly-empty `OtherGroup` records that drift away from the
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; the synthetic JSON emits
synthetic JSON emits `otherGroup: []`. `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`.
@@ -1,7 +1,18 @@
// Command legacy-report-to-json converts a legacy text-format Galaxy // Command legacy-report-to-json converts a legacy text-format Galaxy
// turn report (the "dg" / "gplus" engines) into the JSON shape of // turn report (the "dg" / "gplus" engines) into a JSON envelope
// pkg/model/report.Report. The resulting file is what the UI client's // readable by the UI client's DEV-only synthetic-report loader:
// DEV-only synthetic-report loader on the lobby consumes. //
// {
// "version": 1,
// "report": <Report JSON>,
// "battles": { "<uuid>": <BattleReport JSON>, ... }
// }
//
// Carrying the per-turn report and the full BattleReports in one
// payload lets the synthetic loader register the battles up-front
// so the Battle Viewer can render any battle without a network
// fetch. The bare Report shape (no envelope) the lobby loader
// historically accepted remains backward-compatible on the UI side.
package main package main
import ( import (
@@ -12,8 +23,18 @@ import (
"os" "os"
legacyreport "galaxy/legacy-report" legacyreport "galaxy/legacy-report"
"galaxy/model/report"
) )
// envelope is the on-disk shape emitted by this CLI. `Version` lets
// the UI loader distinguish a v1 envelope from a bare Report; future
// versions can bump it without breaking older synthetic JSON files.
type envelope struct {
Version int `json:"version"`
Report report.Report `json:"report"`
Battles map[string]report.BattleReport `json:"battles,omitempty"`
}
func main() { func main() {
in := flag.String("in", "", "path to legacy .REP file (use - for stdin)") in := flag.String("in", "", "path to legacy .REP file (use - for stdin)")
out := flag.String("out", "", "path to write JSON to (use - or empty for stdout)") out := flag.String("out", "", "path to write JSON to (use - or empty for stdout)")
@@ -31,7 +52,7 @@ func main() {
} }
defer closeIn() defer closeIn()
rep, err := legacyreport.Parse(r) rep, battles, err := legacyreport.Parse(r)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "parse: %v\n", err) fmt.Fprintf(os.Stderr, "parse: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -44,9 +65,17 @@ func main() {
} }
defer closeOut() defer closeOut()
env := envelope{Version: 1, Report: rep}
if len(battles) > 0 {
env.Battles = make(map[string]report.BattleReport, len(battles))
for i := range battles {
env.Battles[battles[i].ID.String()] = battles[i]
}
}
enc := json.NewEncoder(w) enc := json.NewEncoder(w)
enc.SetIndent("", " ") enc.SetIndent("", " ")
if err := enc.Encode(rep); err != nil { if err := enc.Encode(env); err != nil {
fmt.Fprintf(os.Stderr, "encode: %v\n", err) fmt.Fprintf(os.Stderr, "encode: %v\n", err)
os.Exit(1) os.Exit(1)
} }
+603 -37
View File
@@ -21,26 +21,34 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"galaxy/calc"
"galaxy/model/report" "galaxy/model/report"
) )
// Parse reads a legacy text report and returns a [report.Report] // Parse reads a legacy text report and returns a [report.Report]
// carrying the in-scope subset of fields. The Width and Height of the // carrying the in-scope subset of fields, plus the per-battle
// returned report are both set to the legacy "Size" value (galaxies // [report.BattleReport] payloads parsed out of the "Battle at (#N)"
// are square in the legacy engines). // blocks. The Width and Height of the returned report are both set
func Parse(r io.Reader) (report.Report, error) { // to the legacy "Size" value (galaxies are square in the legacy
// engines). The battle slice is empty when the legacy file carries
// no combat events.
func Parse(r io.Reader) (report.Report, []report.BattleReport, error) {
p := newParser() p := newParser()
sc := bufio.NewScanner(r) sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 1024*1024), 4*1024*1024) sc.Buffer(make([]byte, 1024*1024), 4*1024*1024)
for sc.Scan() { for sc.Scan() {
if err := p.handle(sc.Text()); err != nil { if err := p.handle(sc.Text()); err != nil {
return report.Report{}, err return report.Report{}, nil, err
} }
} }
if err := sc.Err(); err != nil { if err := sc.Err(); err != nil {
return report.Report{}, fmt.Errorf("legacyreport: scan: %w", err) return report.Report{}, nil, fmt.Errorf("legacyreport: scan: %w", err)
} }
return p.finish() battles, err := p.finish()
if err != nil {
return report.Report{}, nil, err
}
return p.rep, battles, nil
} }
type section int type section int
@@ -57,6 +65,13 @@ const (
sectionYourGroups sectionYourGroups
sectionYourFleets sectionYourFleets
sectionIncomingGroups sectionIncomingGroups
sectionYourSciences
sectionOtherSciences
sectionOtherShipTypes
sectionBombings
sectionShipsInProduction
sectionBattle
sectionBattleProtocol
) )
type parser struct { type parser struct {
@@ -71,10 +86,48 @@ 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
// Battle accumulator. `battles` collects every parsed BattleReport;
// `pendingBattle` carries the in-flight battle until its block
// ends (next "Battle at " header, a top-level section header, or
// end-of-file). `battleIndex` is the per-report 0-based index used
// to derive a stable synthetic UUID through `syntheticBattleID`.
// `pendingBattleRace` holds the race name currently being
// rostered, set by the "<Race> Groups" sub-header that opens each
// race's roster table inside the battle block.
battles []report.BattleReport
pendingBattle *pendingBattle
battleIndex uint
pendingBattleRace string
}
type pendingBattle struct {
id uuid.UUID
planet uint
planetName string
// Race name → race index used in Protocol.{a,d}. Indices are
// 0-based and assigned in first-seen order across the battle.
raceIndex map[string]int
// (race name, class name) → ship-group index used in
// Protocol.{sa,sd}. Indices are 0-based and assigned in
// first-seen order across the battle, across all races.
shipIndex map[shipKey]int
races map[int]uuid.UUID
ships map[int]report.BattleReportGroup
protocol []report.BattleActionReport
}
type shipKey struct {
race string
class string
} }
type pendingGroup struct { type pendingGroup struct {
@@ -112,6 +165,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}
} }
@@ -137,10 +198,62 @@ func (p *parser) handle(line string) error {
return nil return nil
} }
// Inside a battle block, "<Race> Groups" lines open a per-race
// roster sub-table. The line matches singleTokenPrefix(_, " Groups")
// and would otherwise be treated as a top-level section transition
// by classifySection. Trap it here so the battle state stays open.
if (p.sec == sectionBattle || p.sec == sectionBattleProtocol) && p.pendingBattle != nil {
if race, ok := singleTokenPrefix(trimmed, " Groups"); ok {
// New roster — the protocol block, if it had started,
// cannot reopen; but the engine never emits "<Race> Groups"
// after "Battle Protocol" inside the same battle.
p.sec = sectionBattle
p.pendingBattleRace = race
p.skipHeader = true
return nil
}
}
if newSec, owner, isHeader := classifySection(trimmed); isHeader { if newSec, owner, isHeader := classifySection(trimmed); isHeader {
// Flush the previous battle on any header transition that
// moves us out of the battle block. Sub-transitions
// (sectionBattle → sectionBattleProtocol or vice-versa)
// inside the same battle do not flush.
switch {
case newSec == sectionBattle:
p.flushPendingBattle()
planet, planetName, ok := parseBattleHeader(trimmed)
if ok {
p.pendingBattle = &pendingBattle{
id: syntheticBattleID(p.battleIndex),
planet: planet,
planetName: planetName,
raceIndex: make(map[string]int),
shipIndex: make(map[shipKey]int),
races: make(map[int]uuid.UUID),
ships: make(map[int]report.BattleReportGroup),
}
p.battleIndex++
}
p.pendingBattleRace = ""
case newSec == sectionBattleProtocol:
// Stay in the same battle; the protocol header itself
// has no column header to skip — `Battle Protocol` is
// followed by the shot lines directly. Reset
// pendingBattleRace because the roster phase ended.
p.pendingBattleRace = ""
default:
// Any other section transition closes the battle.
p.flushPendingBattle()
}
p.sec = newSec p.sec = newSec
p.otherOwner = owner p.otherOwner = owner
p.skipHeader = newSec != sectionNone // `Battle Protocol` has no column header to skip; ditto for
// the per-race `<Race> Groups` sub-header trapped above (we
// handle that branch separately). For sectionBattle the
// header line is "Battle at (#N) Name" with no following
// column row, so skipHeader stays false there as well.
p.skipHeader = newSec != sectionNone && newSec != sectionBattle && newSec != sectionBattleProtocol
return nil return nil
} }
@@ -177,16 +290,31 @@ 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)
case sectionBattle:
p.parseBattleRosterRow(fields)
case sectionBattleProtocol:
p.parseBattleProtocolLine(fields)
} }
return nil return nil
} }
func (p *parser) finish() (report.Report, error) { func (p *parser) finish() ([]report.BattleReport, error) {
if !p.sawHeader { if !p.sawHeader {
return report.Report{}, errors.New("legacyreport: missing report header line") return nil, errors.New("legacyreport: missing report header line")
} }
p.flushPendingBattle()
p.resolvePending() p.resolvePending()
return p.rep, nil return p.battles, nil
} }
// parseHeader extracts (race, turn) from // parseHeader extracts (race, turn) from
@@ -259,19 +387,23 @@ 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
"Broadcast Message", case "Ships In Production":
"Battle Protocol": return sectionShipsInProduction, "", true
case "Approaching Groups",
"Broadcast Message":
return sectionNone, "", true return sectionNone, "", true
case "Battle Protocol":
return sectionBattleProtocol, "", true
} }
if strings.HasPrefix(line, "Status of Players") { if strings.HasPrefix(line, "Status of Players") {
return sectionStatusOfPlayers, "", true return sectionStatusOfPlayers, "", true
} }
if strings.HasPrefix(line, "Battle at ") { if strings.HasPrefix(line, "Battle at ") {
return sectionNone, "", true return sectionBattle, "", true
} }
if strings.HasPrefix(line, "=== ATTENTION") { if strings.HasPrefix(line, "=== ATTENTION") {
return sectionNone, "", true return sectionNone, "", true
@@ -279,11 +411,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 +600,394 @@ 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,
})
}
// parseBattleHeader extracts (planet, planetName) from a
// "Battle at (#N) <PlanetName>" line. The planet number is the
// integer between "(#" and ")"; the planet name is the rest of the
// line after the closing parenthesis (trimmed).
func parseBattleHeader(line string) (uint, string, bool) {
const prefix = "Battle at "
if !strings.HasPrefix(line, prefix) {
return 0, "", false
}
rest := strings.TrimSpace(line[len(prefix):])
if !strings.HasPrefix(rest, "(#") {
return 0, "", false
}
closing := strings.IndexByte(rest, ')')
if closing < 0 {
return 0, "", false
}
num, err := strconv.ParseUint(rest[2:closing], 10, 32)
if err != nil {
return 0, "", false
}
name := strings.TrimSpace(rest[closing+1:])
return uint(num), name, true
}
// parseBattleRosterRow consumes one ship-group line from a battle
// roster sub-table. Columns (10 tokens; the last is the per-group
// state word):
//
// # T D W S C T Q L state
// 1 Pistolet 1.6 1.00 1.00 0 - 0 1 In_Battle
//
// where column "L" carries the number of ships remaining after the
// battle (confirmed against KNNTS fixtures). Rows are appended to
// `pendingBattle.ships` under the race name currently held in
// `pendingBattleRace`.
func (p *parser) parseBattleRosterRow(fields []string) {
if p.pendingBattle == nil || p.pendingBattleRace == "" {
return
}
if len(fields) < 10 {
return
}
number, err := strconv.ParseUint(fields[0], 10, 32)
if err != nil {
return
}
className := fields[1]
drive, _ := parseFloat(fields[2])
weapons, _ := parseFloat(fields[3])
shields, _ := parseFloat(fields[4])
cargo, _ := parseFloat(fields[5])
loadQuantity, _ := parseFloat(fields[7])
numLeft, err := strconv.ParseUint(fields[8], 10, 32)
if err != nil {
return
}
state := fields[9]
tech := make(map[string]report.Float, 4)
if drive != 0 {
tech["DRIVE"] = report.F(drive)
}
if weapons != 0 {
tech["WEAPONS"] = report.F(weapons)
}
if shields != 0 {
tech["SHIELDS"] = report.F(shields)
}
if cargo != 0 {
tech["CARGO"] = report.F(cargo)
}
p.assignRaceIndex(p.pendingBattleRace)
key := shipKey{race: p.pendingBattleRace, class: className}
idx := p.assignShipIndex(key)
// Legacy battle rosters may list the same `(race, className)`
// across multiple rows — different tech variants, ships pulled
// from several stacks / planets, etc. We collapse those rows
// into one BattleReportGroup keyed by `(race, className)` (the
// viewer aggregates per class anyway) by SUMMING Number and
// NumberLeft instead of overwriting; otherwise only the last
// row's counts survive and the battle protocol's destroy count
// would dwarf the recorded initial count (the original
// motivation for the now-removed "phantom destroy" workaround).
if existing, found := p.pendingBattle.ships[idx]; found {
existing.Number += uint(number)
existing.NumberLeft += uint(numLeft)
// LoadQuantity is per-ship cargo — average is a fair fallback
// when several stacks of the same class merge into one bucket.
existing.LoadQuantity = report.F(
(existing.LoadQuantity.F() + loadQuantity) / 2,
)
// Tech / LoadType / InBattle keep their first-seen values:
// the viewer treats them as bucket-wide attributes and the
// first row is normally the most representative tech variant.
p.pendingBattle.ships[idx] = existing
return
}
p.pendingBattle.ships[idx] = report.BattleReportGroup{
Race: p.pendingBattleRace,
ClassName: className,
Tech: tech,
Number: uint(number),
NumberLeft: uint(numLeft),
LoadType: dashOrEmpty(fields[6]),
LoadQuantity: report.F(loadQuantity),
InBattle: state == "In_Battle",
}
}
// parseBattleProtocolLine consumes one shot line of the
// "Battle Protocol" sub-block. Required shape (8 tokens):
//
// <atkRace> <atkClass> fires on <defRace> <defClass> : <Destroyed|Shields>
//
// Anything else (including the empty line separating the protocol
// from the preceding rosters) is silently skipped — the engine never
// emits other text inside this block.
func (p *parser) parseBattleProtocolLine(fields []string) {
if p.pendingBattle == nil {
return
}
if len(fields) != 8 {
return
}
if fields[2] != "fires" || fields[3] != "on" || fields[6] != ":" {
return
}
atkRace, atkClass := fields[0], fields[1]
defRace, defClass := fields[4], fields[5]
destroyed := fields[7] == "Destroyed"
aRace := p.assignRaceIndex(atkRace)
dRace := p.assignRaceIndex(defRace)
sa := p.assignShipIndex(shipKey{race: atkRace, class: atkClass})
sd := p.assignShipIndex(shipKey{race: defRace, class: defClass})
// Synthesise a minimal BattleReportGroup entry when the shot
// references a (race, class) pair that the roster did not
// declare. This happens when the legacy emitter trims a roster
// row but the engine logged a shot for that group.
if _, ok := p.pendingBattle.ships[sa]; !ok {
p.pendingBattle.ships[sa] = report.BattleReportGroup{
Race: atkRace, ClassName: atkClass, InBattle: true,
Tech: map[string]report.Float{},
}
}
if _, ok := p.pendingBattle.ships[sd]; !ok {
p.pendingBattle.ships[sd] = report.BattleReportGroup{
Race: defRace, ClassName: defClass, InBattle: true,
Tech: map[string]report.Float{},
}
}
p.pendingBattle.protocol = append(p.pendingBattle.protocol, report.BattleActionReport{
Attacker: aRace,
AttackerShipClass: sa,
Defender: dRace,
DefenderShipClass: sd,
Destroyed: destroyed,
})
}
// assignRaceIndex returns the in-battle race index for raceName,
// creating a new entry on first sight. Race indices are 0-based and
// monotonically increasing in first-seen order. The synthetic race
// UUID is derived from the race name through
// `syntheticBattleRaceNamespace`.
func (p *parser) assignRaceIndex(raceName string) int {
if idx, ok := p.pendingBattle.raceIndex[raceName]; ok {
return idx
}
idx := len(p.pendingBattle.raceIndex)
p.pendingBattle.raceIndex[raceName] = idx
p.pendingBattle.races[idx] = syntheticBattleRaceID(raceName)
return idx
}
// assignShipIndex returns the in-battle ship-group index for
// (race, class), creating a new entry on first sight. Indices are
// 0-based and monotonically increasing in first-seen order across
// all races.
func (p *parser) assignShipIndex(key shipKey) int {
if idx, ok := p.pendingBattle.shipIndex[key]; ok {
return idx
}
idx := len(p.pendingBattle.shipIndex)
p.pendingBattle.shipIndex[key] = idx
return idx
}
// flushPendingBattle finalises the in-flight battle: appends the
// BattleReport to `p.battles` and a matching BattleSummary
// (id/planet/shots) to `p.rep.Battle`. No-op when no battle is
// pending. Idempotent — clears `pendingBattle` on completion.
func (p *parser) flushPendingBattle() {
if p.pendingBattle == nil {
return
}
pb := p.pendingBattle
p.pendingBattle = nil
p.pendingBattleRace = ""
br := report.BattleReport{
ID: pb.id,
Planet: pb.planet,
PlanetName: pb.planetName,
Races: pb.races,
Ships: pb.ships,
Protocol: pb.protocol,
}
p.battles = append(p.battles, br)
p.rep.Battle = append(p.rep.Battle, report.BattleSummary{
ID: pb.id,
Planet: pb.planet,
Shots: uint(len(pb.protocol)),
})
}
// 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 +1191,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
@@ -738,6 +1279,31 @@ func syntheticGroupID(g uint) uuid.UUID {
return uuid.NewSHA1(syntheticGroupNamespace, fmt.Appendf(nil, "legacy-local-group-%d", g)) return uuid.NewSHA1(syntheticGroupNamespace, fmt.Appendf(nil, "legacy-local-group-%d", g))
} }
// syntheticBattleNamespace seeds [uuid.NewSHA1] for the per-report
// battle-index → UUID derivation used by `Report.Battle[i].ID` and
// `BattleReport.ID`. Distinct from `syntheticGroupNamespace` so a
// per-report battle index can never collide with a ship-group id.
// Mirrors the rationale in `syntheticGroupNamespace`: arbitrary
// value, stable across releases.
var syntheticBattleNamespace = uuid.MustParse("be01a000-0000-0000-0000-000000000002")
// syntheticBattleRaceNamespace seeds [uuid.NewSHA1] for the
// per-battle race name → race UUID derivation that fills
// `BattleReport.Races`. Engine-side reports carry the real race
// UUID; the legacy text only carries the race name, so we derive a
// stable identifier from the name. The constant is independent of
// `syntheticBattleNamespace` so race UUIDs can never collide with
// battle UUIDs.
var syntheticBattleRaceNamespace = uuid.MustParse("be01a000-0000-0000-0000-000000000003")
func syntheticBattleID(idx uint) uuid.UUID {
return uuid.NewSHA1(syntheticBattleNamespace, fmt.Appendf(nil, "legacy-battle-%d", idx))
}
func syntheticBattleRaceID(name string) uuid.UUID {
return uuid.NewSHA1(syntheticBattleRaceNamespace, fmt.Appendf(nil, "legacy-battle-race-%s", name))
}
func dashOrEmpty(s string) string { func dashOrEmpty(s string) string {
if s == "-" { if s == "-" {
return "" return ""
+506 -54
View File
@@ -18,7 +18,7 @@ func TestParseHeaderAndSize(t *testing.T) {
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -54,7 +54,7 @@ func TestParseStatusOfPlayers(t *testing.T) {
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -87,7 +87,7 @@ func TestParseYourVote(t *testing.T) {
"KnightErrants 16.02", "KnightErrants 16.02",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -115,7 +115,7 @@ func TestParseLocalAndOtherPlanets(t *testing.T) {
" 12 303.84 579.23 Skarabei 500.00 500.00 500.00 10.00 Capital 0.00 70.99 20.03 341.78", " 12 303.84 579.23 Skarabei 500.00 500.00 500.00 10.00 Capital 0.00 70.99 20.03 341.78",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -160,7 +160,7 @@ func TestParseUninhabitedAndUnidentified(t *testing.T) {
" 1 579.12 489.37", " 1 579.12 489.37",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -196,12 +196,12 @@ func TestParseShipClasses(t *testing.T) {
"Dragon 16.70 1 1.10 1.00 1 19.80", "Dragon 16.70 1 1.10 1.00 1 19.80",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
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,24 +210,238 @@ 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)
}
}
// TestParseBattles exercises the battle-block parser end-to-end:
// two battles with two races each, full rosters, and protocols. The
// inline fixture mirrors the KNNTS-style layout (race-named roster
// sub-headers, 10-column roster rows, 8-token shot lines) so any
// drift from the real engine format breaks this test before a smoke
// regression. Asserts:
// - report.Battle carries one BattleSummary per "Battle at"
// - BattleReport slice mirrors that with full Races/Ships/Protocol
// - Battle Protocol "Foo fires on Bar : <Destroyed|Shields>" lines
// map to BattleActionReport entries with the correct destroyed flag
// - Roster column 8 (the "L" column) populates NumberLeft
// - Top-level sections after a battle (Your Planets) still parse
// — battle state must close cleanly without leaking rows.
func TestParseBattles(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",
"", "",
"Battle at (#7) B-007", "Battle at (#7) B-007",
"", "",
"# T D W S C T Q L", "Foo Groups",
"1 PeaceShip 4 0 0 0 - 0 1 Out_Battle", "",
"# T D W S C T Q L",
"1 PeaceShip 4.0 0 0 0 - 0 1 In_Battle",
"2 Drone 0.0 1 1 0 - 0 0 In_Battle",
"",
"Bar Groups",
"",
"# T D W S C T Q L",
"1 Pistolet 1.0 1.0 0 0 - 0 1 In_Battle",
"", "",
"Battle Protocol", "Battle Protocol",
"", "",
"Foo fires on Bar : Destroyed", "Foo PeaceShip fires on Bar Pistolet : Shields",
"Bar Pistolet fires on Foo Drone : Destroyed",
"Bar Pistolet fires on Foo Drone : Destroyed",
"", "",
"Bombings", "Battle at (#11) X-011",
"", "",
"# data line", "Foo Groups",
"",
"# T D W S C T Q L",
"1 Scout 1.0 0 0 0 - 0 1 In_Battle",
"",
"Bar Groups",
"",
"# T D W S C T Q L",
"1 Sniper 2.0 1 0 0 - 0 0 In_Battle",
"",
"Battle Protocol",
"",
"Foo Scout fires on Bar Sniper : Destroyed",
"", "",
"Your Planets", "Your Planets",
"", "",
@@ -235,12 +449,187 @@ func TestParseSkipsBattlesAndBombings(t *testing.T) {
" 17 171.05 700.24 Castle 1000.00 1000.00 1000.00 10.00 Capital 0.00 0.68 88.78 1000.00", " 17 171.05 700.24 Castle 1000.00 1000.00 1000.00 10.00 Capital 0.00 0.68 88.78 1000.00",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, battles, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
// The trailing Your Planets section must still parse — battle
// state must close before the next top-level header.
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 state did not close)", got, want)
}
if got, want := len(rep.Battle), 2; got != want {
t.Fatalf("len(rep.Battle) = %d, want %d", got, want)
}
if got, want := len(battles), 2; got != want {
t.Fatalf("len(battles) = %d, want %d", got, want)
}
// First battle: planet 7, 3 shots; protocol shape with one
// shielded shot and two destroyed shots.
b0 := battles[0]
if b0.Planet != 7 || b0.PlanetName != "B-007" {
t.Errorf("battle[0] = (planet=%d, name=%q), want (7, %q)",
b0.Planet, b0.PlanetName, "B-007")
}
if got, want := len(b0.Protocol), 3; got != want {
t.Fatalf("battle[0].Protocol = %d shots, want %d", got, want)
}
if b0.Protocol[0].Destroyed {
t.Errorf("battle[0].Protocol[0].Destroyed = true (Shields hit), want false")
}
if !b0.Protocol[1].Destroyed || !b0.Protocol[2].Destroyed {
t.Errorf("battle[0].Protocol[1..2].Destroyed must be true (Destroyed hits)")
}
// First battle: roster size and NumberLeft mapping.
if got, want := len(b0.Ships), 3; got != want {
t.Fatalf("battle[0].Ships = %d groups, want %d", got, want)
}
// 'Drone' has NumberLeft=0 in the roster (column 8 = 0). The
// protocol corroborates: Pistolet destroyed Drone twice.
dronePresent := false
for _, ship := range b0.Ships {
if ship.ClassName == "Drone" {
dronePresent = true
if ship.NumberLeft != 0 {
t.Errorf("Drone.NumberLeft = %d, want 0", ship.NumberLeft)
}
if ship.Number != 2 {
t.Errorf("Drone.Number = %d, want 2", ship.Number)
}
}
}
if !dronePresent {
t.Errorf("Drone roster row not parsed into battle[0].Ships")
}
// Summary mirrors the BattleReport ID and shot count.
if rep.Battle[0].ID != b0.ID {
t.Errorf("rep.Battle[0].ID = %s, want %s", rep.Battle[0].ID, b0.ID)
}
if rep.Battle[0].Shots != 3 {
t.Errorf("rep.Battle[0].Shots = %d, want 3", rep.Battle[0].Shots)
}
if rep.Battle[0].Planet != 7 {
t.Errorf("rep.Battle[0].Planet = %d, want 7", rep.Battle[0].Planet)
}
// Second battle: planet 11, 1 shot.
if rep.Battle[1].Planet != 11 || rep.Battle[1].Shots != 1 {
t.Errorf("rep.Battle[1] = (planet=%d, shots=%d), want (11, 1)",
rep.Battle[1].Planet, rep.Battle[1].Shots)
}
// Battle IDs are stable across re-parses.
rep2, battles2, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse (second pass): %v", err)
}
if rep.Battle[0].ID != rep2.Battle[0].ID || battles[0].ID != battles2[0].ID {
t.Errorf("battle id must be deterministic across re-parses")
}
}
// TestParseBattleAggregatesDuplicateClasses guards against the
// regression that produced the original "phantom destroys" symptom:
// the same `(race, className)` pair appearing on multiple roster
// rows must collapse into a single BattleReportGroup whose `Number`
// (the "#" column, initial ship count) and `NumberLeft` (the "L"
// column, survivors) are the sums across rows. Without the
// aggregation only the last row's counts survived and the protocol's
// destroy count dwarfed the recorded initial count (e.g. KNNTS041
// turn-41 planet #7 lists `pup` seven separate times: 99 + 105 + 291 +
// 287 + 166 + 132 + 88 = 1168 ships, 86 survivors, 1082 destroys).
func TestParseBattleAggregatesDuplicateClasses(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Battle at (#7) B-007",
"",
"Foo Groups",
"",
" # T D W S C T Q L",
" 3 Drone 1.0 1.0 0 0 - 0 1 In_Battle",
" 4 Drone 1.2 1.0 0 0 - 0 2 In_Battle",
"10 Cruiser 3.0 2.0 0 0 - 0 9 In_Battle",
"",
"Bar Groups",
"",
"# T D W S C T Q L",
"5 Pistolet 1.0 1.0 0 0 - 0 3 In_Battle",
"",
"Battle Protocol",
"",
"Bar Pistolet fires on Foo Drone : Destroyed",
"Bar Pistolet fires on Foo Drone : Destroyed",
"Bar Pistolet fires on Foo Drone : Destroyed",
"Bar Pistolet fires on Foo Drone : Destroyed",
"",
}, "\n")
rep, battles, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(battles), 1; got != want {
t.Fatalf("len(battles) = %d, want %d", got, want)
}
b := battles[0]
// The three Foo roster rows collapse into two BattleReportGroup
// entries: one Foo:Drone (rows 1+2) and one Foo:Cruiser (row 3),
// plus one Bar:Pistolet. Total 3 groups, NOT 4.
if got, want := len(b.Ships), 3; got != want {
t.Fatalf("battle.Ships = %d groups, want %d (duplicate class rows must merge)", got, want)
}
var drone, cruiser, pistolet *report.BattleReportGroup
for i := range b.Ships {
g := b.Ships[i]
switch g.ClassName {
case "Drone":
drone = &g
case "Cruiser":
cruiser = &g
case "Pistolet":
pistolet = &g
}
}
if drone == nil || cruiser == nil || pistolet == nil {
t.Fatalf("missing class: drone=%v cruiser=%v pistolet=%v", drone, cruiser, pistolet)
}
// Drone rows sum to Number = 3 + 4 = 7 and NumberLeft = 1 + 2 = 3.
// Protocol corroborates: four Destroyed shots against Drone, so
// 7 - 3 = 4 — the protocol's destroy count reconciles with the
// recorded delta only when both rows are summed.
if drone.Number != 7 {
t.Errorf("Drone.Number = %d, want 7 (3+4)", drone.Number)
}
if drone.NumberLeft != 3 {
t.Errorf("Drone.NumberLeft = %d, want 3 (1+2)", drone.NumberLeft)
}
// Cruiser and Pistolet are single-row classes — counts must match
// the file verbatim with no spurious merging across classes.
if cruiser.Number != 10 || cruiser.NumberLeft != 9 {
t.Errorf("Cruiser = (Number=%d, NumberLeft=%d), want (10, 9)",
cruiser.Number, cruiser.NumberLeft)
}
if pistolet.Number != 5 || pistolet.NumberLeft != 3 {
t.Errorf("Pistolet = (Number=%d, NumberLeft=%d), want (5, 3)",
pistolet.Number, pistolet.NumberLeft)
}
// rep-level summary must reflect the merged shape: 4 shots, one
// battle, no crash or spurious extra battles.
if got, want := len(rep.Battle), 1; got != want {
t.Fatalf("len(rep.Battle) = %d, want %d", got, want)
}
if rep.Battle[0].Shots != 4 {
t.Errorf("rep.Battle[0].Shots = %d, want 4", rep.Battle[0].Shots)
} }
} }
@@ -268,7 +657,7 @@ func TestParseYourGroups(t *testing.T) {
" 2 1 Tormoz 11.19 0.00 0.00 1.0 CAP 4 North Castle 7.5 60.66 49.50 - In_Space", " 2 1 Tormoz 11.19 0.00 0.00 1.0 CAP 4 North Castle 7.5 60.66 49.50 - In_Space",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -332,7 +721,7 @@ func TestParseYourFleets(t *testing.T) {
" 1 Far 2 North Castle 4.50 20 In_Space", " 1 Far 2 North Castle 4.50 20 In_Space",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -381,7 +770,7 @@ func TestParseIncomingGroups(t *testing.T) {
" 87 169.59 694.49 North 500.00 500.00 500.00 10.00 Capital 0.00 0.52 35.76 500.00", " 87 169.59 694.49 North 500.00 500.00 500.00 10.00 Capital 0.00 0.52 35.76 500.00",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -404,19 +793,22 @@ 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
battles int
} }
func runSmoke(t *testing.T, path string, want smokeWant) { func runSmoke(t *testing.T, path string, want smokeWant) {
t.Helper() t.Helper()
rep, err := parseFile(t, path) rep, battles, err := parseFile(t, path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
t.Skipf("legacy report fixture missing: %s", path) t.Skipf("legacy report fixture missing: %s", path)
@@ -457,12 +849,36 @@ 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},
{"Battle (summary)", len(rep.Battle), want.battles},
{"BattleReport", len(battles), want.battles},
} }
for _, c := range checks { for _, c := range checks {
if c.got != c.want { if c.got != c.want {
t.Errorf("%s = %d, want %d", c.name, c.got, c.want) t.Errorf("%s = %d, want %d", c.name, c.got, c.want)
} }
} }
for i, summary := range rep.Battle {
if i >= len(battles) {
break
}
if summary.ID != battles[i].ID {
t.Errorf("battle[%d].ID summary=%s vs report=%s",
i, summary.ID, battles[i].ID)
}
if summary.Shots != uint(len(battles[i].Protocol)) {
t.Errorf("battle[%d].Shots = %d, want %d (len(Protocol))",
i, summary.Shots, len(battles[i].Protocol))
}
if summary.Planet != battles[i].Planet {
t.Errorf("battle[%d].Planet summary=%d vs report=%d",
i, summary.Planet, battles[i].Planet)
}
}
} }
// TestParseDgKNNTS039 is a smoke test: the parser must produce // TestParseDgKNNTS039 is a smoke test: the parser must produce
@@ -477,10 +893,16 @@ 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,
battles: 28,
}) })
} }
@@ -490,10 +912,16 @@ 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,
battles: 79,
}) })
} }
@@ -506,10 +934,16 @@ 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,
battles: 56,
}) })
} }
@@ -522,10 +956,16 @@ 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,
battles: 30,
}) })
} }
@@ -538,10 +978,16 @@ 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,
battles: 83,
}) })
} }
@@ -555,22 +1001,28 @@ 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,
battles: 57,
}) })
} }
func parseFile(t *testing.T, rel string) (report.Report, error) { func parseFile(t *testing.T, rel string) (report.Report, []report.BattleReport, error) {
t.Helper() t.Helper()
abs, err := filepath.Abs(rel) abs, err := filepath.Abs(rel)
if err != nil { if err != nil {
return report.Report{}, err return report.Report{}, nil, err
} }
f, err := os.Open(abs) f, err := os.Open(abs)
if err != nil { if err != nil {
return report.Report{}, err return report.Report{}, nil, err
} }
defer func() { _ = f.Close() }() defer func() { _ = f.Close() }()
return Parse(f) return Parse(f)
File diff suppressed because it is too large Load Diff
+523 -102
View File
@@ -1381,9 +1381,13 @@ Decisions taken with the project owner during implementation:
6. **`historyMode` as a prop, not a module.** Layout passes 6. **`historyMode` as a prop, not a module.** Layout passes
`historyMode={false}` (a constant in Phase 12) to `Sidebar` and `historyMode={false}` (a constant in Phase 12) to `Sidebar` and
`BottomTabs`; both forward to their tab-bar children which omit `BottomTabs`; both forward to their tab-bar children which omit
the order entry when the flag is true. Phase 26 introduces the the order entry when the flag is true. Phase 26 superseded the
real `lib/history-mode.ts` module and replaces the constant in "introduce `lib/history-mode.ts`" half of this decision: the
one place. single derivation `historyMode = $derived(gameState.historyMode)`
lives directly in `+layout.svelte`, the rune split between
`currentTurn` and `viewedTurn` lives in `GameStateStore`, and
no separate module is introduced. See Phase 26 decisions for
the rationale.
7. **Empty-state copy is `order is empty` / `приказ пуст`.** The 7. **Empty-state copy is `order is empty` / `приказ пуст`.** The
`coming soon` placeholder text is replaced; per-row delete `coming soon` placeholder text is replaced; per-row delete
button reads `delete` / `удалить`. button reads `delete` / `удалить`.
@@ -2470,9 +2474,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,99 +2510,415 @@ 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.
## Phase 24. Push Events — Turn-Ready Decisions during stage:
Status: pending. 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~~
Status: done.
Goal: subscribe to the server push stream and refresh client state Goal: subscribe to the server push stream and refresh client state
when a turn-ready event arrives. when a turn-ready event arrives.
Artifacts: Artifacts (delivered):
- `ui/frontend/src/api/events.ts` push-stream subscription wired - `ui/frontend/src/api/events.svelte.ts` — single
through `GalaxyClient.subscribeEvents` and Connect server-streaming `SubscribeEvents` consumer per session. Absorbs the previous
- on `game.turn.ready` event: invalidate `(game_id, current_turn)` `revocation-watcher.ts` (now deleted) so there is exactly one
cache entries and trigger a fresh report fetch authenticated stream per device session; clean end-of-stream and
- a top-of-screen toast: `Turn N+1 is ready. View now.` with a button `Unauthenticated` ConnectError both funnel into
that re-renders the active view against the new turn `session.signOut("revoked")`. Exposes a `connectionStatus` rune
- mandatory event signature verification through `ui/core` — any for the future header indicator.
verification failure tears down the stream and reconnects with - `ui/frontend/src/lib/toast.svelte.ts` and `toast-host.svelte` —
exponential backoff single-slot transient-notification primitive mounted from the
root layout; later phases (battle, mail) reuse it.
- `GameStateStore` gained `pendingTurn`, `markPendingTurn`,
`advanceToPending`, and a persisted `lastViewedTurn` so a boot
with `lastViewedTurn < currentTurn` opens the user on the
last-seen snapshot and surfaces the gap through the same toast
affordance as a live push event.
- Backend producer: `lobby.Service.OnRuntimeSnapshot` emits
`game.turn.ready` on every `current_turn` advance, addressed to
every active membership, idempotency key
`turn-ready:<game_id>:<turn>`, payload `{game_id, turn}`.
Catalog routes it through the push channel only.
- Mandatory event-signature verification through `ui/core`:
`core.verifyPayloadHash` + `core.verifyEvent` on every frame.
Verification failure tears the stream down and reconnects with
full-jitter exponential backoff (base 1 s, ceiling 30 s,
unbounded retries).
- Topic doc: `ui/docs/events.md`.
Dependencies: Phases 23, 4 (Connect streaming in gateway). Dependencies: Phases 23, 4 (Connect streaming in gateway).
Acceptance criteria: Decisions baked back in (this stage):
- a server-side turn cutoff produces a toast within one second; - **Minimum traffic on `game.turn.ready`.** The event flips
- accepting the toast refreshes the active view to the new turn's data `gameState.pendingTurn` only; the report for the new turn is not
without a full page reload; fetched until the user activates the toast's "view now" action.
- a forged event (test fixture with bad signature) is rejected and the This is the same affordance the boot-time `lastViewedTurn < currentTurn`
stream reconnects. branch surfaces, so a player who returns after several turns sees
one "view now" path instead of an auto-jump.
- **Revocation-watcher folded into `events.svelte.ts`.** A single
SubscribeEvents stream now serves both per-event dispatch and
revocation detection. Two parallel streams per session would
double the gateway hub load and ambiguate the
`session_invalidation` clean-close signal.
- **Integration test scope.** Backend producer is covered by
`lobby/runtime_hooks_test.go` (testcontainers); UI consumer by
`tests/events.test.ts` and the Playwright e2e in
`tests/e2e/turn-ready.spec.ts`. A dedicated
`integration/turn_ready_flow_test.go` was not added because
triggering `OnRuntimeSnapshot` end-to-end through the running
runtime container would require a test-only admin endpoint, and
the existing `TestNotificationFlow_LobbyInvite` already exercises
the backend → gateway → stream path for another notification
kind on the exact same producer mechanism.
Targeted tests: Acceptance criteria (met):
- Vitest unit tests for `events.ts` handling subscribe, event - a server-side turn cutoff produces a toast within one second
dispatch, error backoff; (Phase 24's stream propagation; the producer side ships with the
- Playwright e2e: trigger a server turn, observe toast and refresh. backend changes above);
- activating the toast refreshes the active view to the new turn's
data without a full page reload
(`gameState.advanceToPending` → fresh `lobby.my.games.list` +
`user.games.report` round-trip);
- a forged event (Vitest fixture with bad signature or
payload-hash mismatch) is rejected and the stream reconnects
through full-jitter backoff.
## Phase 25. Sync Protocol — Order Queue, Retry, Conflict Targeted tests (delivered):
Status: pending. - Vitest: `tests/events.test.ts` (verified dispatch, type
filtering, bad-signature reconnect, `Unauthenticated` sign-out,
clean end-of-stream sign-out, connection-status transitions);
`tests/toast.test.ts`; extensions in `tests/game-state.test.ts`
for `pendingTurn` / `lastViewedTurn` / `advanceToPending`.
- Backend: `internal/notification/catalog_test.go` (kind +
channels); `internal/lobby/runtime_hooks_test.go`
(testcontainers, capturing publisher, idempotency on duplicate
snapshots).
- Playwright: `tests/e2e/turn-ready.spec.ts` (signed
`game.turn.ready` frame surfaces the toast, manual dismiss
clears it).
Goal: make the order draft survive network failures and turn cutoffs ## ~~Phase 25. Sync Protocol — Turn Cutoff, Conflict, Auto-Pause~~
gracefully, with explicit user feedback on conflicts.
Artifacts: Status: done.
- `ui/frontend/src/sync/order-queue.ts` send loop: on disconnect, hold Goal: make the order draft survive transient connectivity issues
the most recent submit; on reconnect, retry once; on persistent **and** the real turn-cutoff machinery, with explicit user feedback
failure, surface error to the order tab on conflicts and on admin-pause states. The phase is intentionally
- conflict detection: if the server returns `turn_already_closed` for cross-module: the UI side leans on a backend turn-cutoff guard and
a submit, mark the entire draft as `conflict` and surface a auto-pause that did not exist before; both land together so the
`Turn N closed before your order was accepted. Edit and resubmit.` contract is end-to-end.
banner in the order tab
- topic doc `ui/docs/sync-protocol.md` covering queue semantics,
retry budgets, and conflict UX
Dependencies: Phases 14, 24. Decisions baked in during implementation:
- Turn-cutoff enforcement lives in `backend` (not in `game-engine`).
The scheduler flips `runtime_status` to `generation_in_progress`
before each engine tick and back to `running` after; the
user-games handlers reject every command/order in
non-running runtime states.
- A failed engine tick auto-pauses the game (`running → paused`)
through `lobby.OnRuntimeSnapshot`, and the lobby publishes a
matching `game.paused` push event. Admin resume remains the
only way out of `paused`.
- The wire-level error codes are `turn_already_closed` (cutoff
conflict) and `game_paused` (paused / starting / finished / removed).
Gateway carries them through `projectUserBackendError` unchanged.
- The UI draft store delegates to a new `OrderQueue` (single-slot
pending, single retry on reconnect via `onOnline` callback). On
`game.turn.ready` after a conflict / pause, the layout calls
`OrderDraftStore.resetForNewTurn` which wipes the draft and
re-hydrates from the server for the new turn (old commands are
preserved server-side and can be read back via
`user.games.order.get?turn=N`).
Backend artifacts:
- `backend/internal/notification/catalog.go`: new
`KindGamePaused = "game.paused"` and `catalog`/`SupportedKinds`
entries; matching `NotificationGamePaused` constant in
`backend/internal/lobby/lobby.go`; CHECK-constraint widened in
`backend/internal/postgres/migrations/00001_init.sql`.
- `backend/internal/lobby/runtime_hooks.go`:
`nextStatusFromSnapshot` flips `running → paused` on
`engine_unreachable` / `generation_failed`; new
`publishGamePaused` mirrors `publishTurnReady`, idempotency key
`paused:<game_id>:<turn>`, payload `{game_id, turn, reason}`.
- `backend/internal/runtime/scheduler.go`: `tick` wraps the engine
call with `generation_in_progress` / `running` flips and forwards
failure snapshots to lobby through
`Service.publishFailureSnapshot`.
- `backend/internal/runtime/service.go`: `CheckOrdersAccept` plus
the pure `OrdersAcceptStatus` helper used by both `Orders` and
`Commands` user-games handlers.
- `backend/internal/server/httperr/httperr.go`: new
`CodeTurnAlreadyClosed`, `CodeGamePaused`; openapi.yaml
`ErrorBody.code` enum extended.
- `backend/internal/server/handlers_user_games.go`:
`requireOrdersOpen` runs before forwarding, maps sentinels to
HTTP 409 + the matching code.
UI artifacts:
- `ui/frontend/src/sync/order-queue.svelte.ts` (new) — `OrderQueue`
class with offline detection, classification of
`turn_already_closed` / `game_paused`, dependency-injected
online probe + event listeners. Pure-function helper
`classifyResult` reused from tests.
- `ui/frontend/src/sync/order-types.ts` — `CommandStatus` gains
`conflict`.
- `ui/frontend/src/sync/order-draft.svelte.ts` — wires
`OrderQueue` through `runSync`, adds `conflict` / `paused` /
`offline` to `SyncStatus`, plus `conflictBanner` /
`pausedBanner` runes, `markPaused`, `resetForNewTurn`,
`clearConflictForMutation`, sticky-`paused` guard in
`hydrateFromServer`. `bindClient(client, { getCurrentTurn })`
lets the conflict banner interpolate the turn number.
- `ui/frontend/src/lib/sidebar/order-tab.svelte` — renders
conflict / paused banners and the new `conflict` per-row badge;
status bar carries the offline / conflict / paused copy.
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new keys for
`sync.{offline,conflict,paused}`, `conflict.banner`
(with `{turn}` interpolation) plus `banner_no_turn` fallback,
`paused.banner`, `status.conflict`.
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
subscribes to `game.paused`; `game.turn.ready` handler now
triggers `resetForNewTurn` when the prior `syncStatus` was
`conflict` / `paused`. `bindClient` is invoked with
`getCurrentTurn: () => gameState.currentTurn`.
- `ui/docs/sync-protocol.md` (new) — send-loop semantics, retry
budget, conflict and paused UX, recovery paths.
- `ui/docs/order-composer.md` — stale Phase 25 paragraph
replaced with a pointer to the new topic doc; state-machine
diagram extended with the `conflict` transition.
Dependencies: Phases 14, 24; backend notification / lobby /
runtime modules.
Acceptance criteria: Acceptance criteria:
- submitting an order while offline queues it and submits successfully - submitting an order while offline queues it and submits
on reconnect; successfully on reconnect (one attempt on the next `online`
- a turn cutoff between draft and submit produces a visible conflict event, no inline retry storm);
banner with no data loss; - a turn cutoff between draft and submit produces a visible
- the order tab clearly distinguishes `draft`, `submitting`, conflict banner with the turn number; the local draft is
`accepted`, `rejected`, `conflict` states per command. preserved until the next `game.turn.ready`, then the layout
wipes it and re-hydrates from the server for `turn = N+1`;
- a runtime failure during generation flips the game into
`paused`, emits `game.paused`, and the order tab shows the
pause banner; submits are blocked until the next
`game.turn.ready` clears the state;
- the order tab clearly distinguishes `draft`, `valid`,
`invalid`, `submitting`, `applied`, `rejected`, and
`conflict` states per command.
Targeted tests: Targeted tests:
- Vitest unit tests for `order-queue` covering all state transitions; - Backend: `runtime_hooks_unit_test.go` for
- Playwright e2e: simulate network drop using Playwright's offline `nextStatusFromSnapshot`, `orders_accept_test.go` for the
mode, submit an order, restore network, confirm submission; per-record decision, plus existing testcontainer-backed
- regression test: force a turn cutoff during submit, assert conflict `runtime_hooks_test.go` covering the published intent. Catalog
banner appears. / event tests extended with `game.paused`.
- UI Vitest: `tests/order-queue.test.ts` (classification +
offline plumbing), extended `tests/order-draft.test.ts`
(conflict marks commands, mutation clears banner, pause
blocks sync, offline holds + flushes on `online`,
`resetForNewTurn` re-hydrates), extended
`tests/order-tab.test.ts` (banner DOM + sync-status
attribute), extended `tests/events.test.ts` (`game.paused`
dispatch).
- Playwright e2e: `tests/e2e/order-sync.spec.ts` — conflict
banner on `turn_already_closed` reply and paused banner on
the signed `game.paused` frame.
## Phase 26. History Mode ## ~~Phase 26. History Mode~~
Status: pending. Status: done. Verified on local-ci run 6 (`success`, 2d17760).
Goal: let the user navigate to past turns and view all data as it was, Goal: let the user navigate to past turns and view all data as it was,
with no order composition allowed. with no order composition allowed.
Artifacts: Decisions baked in during implementation:
- `ui/frontend/src/lib/header/turn-navigator.svelte` clickable turn 1. **History state lives in `GameStateStore`, no separate module.**
counter expansion: popover (desktop) / bottom-sheet (mobile) listing The Phase 12 plan-line "introduce `lib/history-mode.ts`" is
recent turns and a search field for jumping to a turn number superseded: the only consumer needs a one-line derivation
- `ui/frontend/src/lib/history-mode.ts` global toggle wired into every (`historyMode = $derived(gameState.historyMode)`), and the
view's data source: when active, all `state-binding`, table, report, project's compactness rule rejects an abstraction with no second
inspector, and map sources read from the historical snapshot for the caller. The store ships two distinct turn runes — `currentTurn`
selected turn (server's authoritative latest, set by `setGame` /
- `ui/frontend/src/lib/header/history-banner.svelte` persistent banner `advanceToPending`) and `viewedTurn` (what the UI displays, set
reading `Viewing turn N · read-only` with a `Return to current turn` by `viewTurn` / `returnToCurrent`) — plus the derived
action `historyMode` rune that flips when `viewedTurn < currentTurn`.
- order tab hidden in history mode (already prepared in Phase 12) 2. **`OrderDraftStore` gates mutations at one chokepoint.**
`bindClient` gains an optional `getHistoryMode: () => boolean`
alongside the existing `getCurrentTurn`; `add` / `remove` /
`move` return early when it reports `true`. Every Phase 1422
inspector that calls `orderDraft.add(...)` becomes inert in
history mode without per-component edits.
3. **Turn navigator UX.** Header replaces the static `turn N` text
with `← turn N →`: arrows step ±1 (disabled at `0` and
`currentTurn`), the middle button opens a dropdown of every
turn `Turn #0`…`Turn #currentTurn` with the current row carrying
a badge. No free-text input. Desktop uses an absolute popover
under the header; mobile reuses `view-menu.svelte`'s fixed-
drawer pattern (no new primitive). Selecting the current row
routes through `returnToCurrent()` so the "leave history" path
has one canonical entry.
4. **History is ephemeral across reloads.** `last-viewed-turn` is
written only when `viewedTurn === currentTurn`; historical
excursions never advance the resume bookmark. Page reload exits
history mode. The visibility-refresh listener is a no-op while
`historyMode` is true so a tab-focus event cannot silently kick
the user back onto the live turn. Push events (Phase 24) continue
to deliver new-turn notifications, so the pending-turn toast
still appears.
5. **Past-turn report cache.** New `game-history/{gameId}/turn/{N}`
namespace stores past-turn reports; `viewTurn(N)` reads cache
first and falls back to the network on miss. Past turns are
immutable so the cache has no TTL and no eviction. The current
turn deliberately skips the cache (it is mutable until the next
tick).
6. **Order overlay short-circuits in history mode.**
`RenderedReportSource.report` returns the raw server snapshot
instead of running `applyOrderOverlay`: the draft is composed
against the current turn, projecting it onto a past report would
render fictional intent.
7. **`game.shell.headline` removed.** The Phase 11 i18n key that
formatted `{race} @ {game}, turn {turn}` is deleted; the header
composes `race @ game` in plain text and delegates `turn N` to
`turn-navigator.svelte`. The existing `game-shell-headline`
testid moves to the `.left` wrapper so e2e specs that match
`toContainText("turn N")` continue to find the substring inside
the navigator's button.
Artifacts (delivered):
- `ui/frontend/src/lib/game-state.svelte.ts` — `viewedTurn` rune,
derived `historyMode` rune, `viewTurn(turn)` /
`returnToCurrent()` public methods, `loadTurn(turn, { isCurrent })`
refactor that gates `last-viewed-turn` writes, `readReport` cache
layer over the `game-history` namespace, visibility-refresh
short-circuit in history mode, `initSynthetic` keeps
`currentTurn === viewedTurn`.
- `ui/frontend/src/sync/order-draft.svelte.ts` — `bindClient` accepts
`getHistoryMode`, `add` / `remove` / `move` no-op when active.
- `ui/frontend/src/lib/rendered-report.svelte.ts` — overlay short-
circuit when `gameState.historyMode === true`.
- `ui/frontend/src/lib/header/turn-navigator.svelte` (new) — header
triplet `← turn N →` + dropdown popover / drawer, reuses
`view-menu.svelte`'s outside-click / Escape pattern.
- `ui/frontend/src/lib/header/history-banner.svelte` (new) — sticky
read-only banner under the header with a `Return to current turn`
action.
- `ui/frontend/src/lib/header/header.svelte` — embeds
`<TurnNavigator />` next to the race-and-game identity span;
drops the static turn portion.
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
`historyMode` derived rune, `getHistoryMode` passed to
`orderDraft.bindClient`, `<HistoryBanner />` mounted between
header and body.
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new
`game.shell.history.*` and `game.shell.turn.*` keys; the now-
unused `game.shell.headline` entry is removed.
- `ui/docs/storage.md` — `game-history` namespace row; also adds
the `game-prefs/{gameId}/last-viewed-turn` row (Phase 11 doc
gap).
- `ui/docs/game-state.md` — current/viewed-turn rune table, the
new History mode section.
- `ui/docs/navigation.md` — describes the navigator, the read-only
banner, and the `historyMode` derivation wiring.
- `ui/docs/order-composer.md` — notes the mutation gate, the
overlay short-circuit, and the cross-doc references.
- Vitest: `tests/game-state.test.ts` extended with `viewTurn` /
`returnToCurrent` / `historyMode` derivation / cache hit /
visibility-refresh short-circuit / resume-from-stale-bookmark
flips; `tests/order-draft.test.ts` extended with the history-
mode gate cases; `tests/turn-navigator.test.ts` and
`tests/history-banner.test.ts` (new) cover the components in
isolation.
- Playwright: `tests/e2e/history-mode.spec.ts` (new) — drives the
full chrome flow against `/games/<id>/table/planets`. The map
view is deliberately avoided because the Pixi renderer can
monopolise the headless Chromium main thread long enough to let
the `toContainText` poll find stale "turn ?" content; the table
view exercises the same wiring without that rendering tail.
Dependencies: Phases 11, 12, 23. Dependencies: Phases 11, 12, 23.
@@ -2610,59 +2930,145 @@ Acceptance criteria:
available; available;
- returning to the current turn restores live data and re-shows the - returning to the current turn restores live data and re-shows the
order tab with the prior draft intact (state preservation); order tab with the prior draft intact (state preservation);
- all UI views (map, tables, report, battle, mail) work in history - battle / mail stub views still render correctly while the
mode. read-only banner is visible (Phases 27 / 28 will replace the
stubs with real implementations; the wiring is sufficient
today).
Targeted tests: Targeted tests:
- Vitest unit tests for `history-mode` toggle and per-view source - Vitest unit tests for current/viewed turn rune split, view-turn
selection; cache behaviour, visibility-refresh short-circuit, order-draft
- Playwright e2e: enter history mode, navigate three views, return, history-mode gate, turn-navigator interactions, history-banner
confirm the order draft survived. rendering / action;
- Playwright e2e: enter history mode via arrow, navigate via
dropdown, return via banner action, confirm the order draft
survives the round-trip.
## Phase 27. Battle Viewer ## ~~Phase 27. Battle Viewer~~
Status: pending. Status: done (local-ci run 14).
Goal: render battles as a dedicated view with playback controls Goal: ship a dedicated Battle Viewer rendering radial scenes from
(play, pause, step forward, step backward, rewind), driven by the `BattleReport` data (planet centred, races on the outer ring, per
server-side combat log; render battle and bombing markers on the map. ship-class clusters, animated shot lines), plus battle and bombing
markers on the map. Battles and bombings stay strictly separate —
bombings remain a static table in the Reports view, only battles
get the animated viewer.
Artifacts: Artifacts:
- `ui/frontend/src/map/battle-markers.ts` renders markers on the map - engine: `game/internal/router/handler/battle.go` for
for current-turn battles and bombings within visibility, clickable `GET /api/v1/battle/:turn/:uuid` (handler pre-existed; Phase 27
to open the battle viewer added the tests + openapi schemas)
- `ui/frontend/src/routes/games/[id]/battle/[battleId]/+page.svelte` - engine wire: `pkg/model/report/battle.go` ships a new
view with the combatant list, the round-by-round log, and a player `BattleSummary{id, planet, shots}`; `Report.battle` carries a
control bar slice of these summaries so the map can place markers without
- `ui/frontend/src/lib/battle-player/` round timeline, current-round fetching every full report
highlight, per-shot animation - backend: `backend/internal/engineclient/client.go.FetchBattle`
- entry points to the viewer: marker on map, row in the report's and `backend/internal/server/handlers_user_games.go.Battle`
battles section, push-event toast when a battle this turn involved expose `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`
the player - UI viewer: `ui/frontend/src/lib/battle-player/`
- topic doc `ui/docs/battle-viewer-ux.md` covering playback (`radial-layout.ts`, `timeline.ts`, `battle-scene.svelte`,
semantics, accessibility (the combat log must be readable as text `playback-controls.svelte`, `battle-viewer.svelte`); SVG-based,
for users who skip animations) one frame per protocol entry, full controls (play/pause + step
back + step forward + rewind + 1x/2x/4x speed switch)
- UI route + page wrapper:
`ui/frontend/src/routes/games/[id]/battle/[[battleId]]/+page.svelte`
feeds `gameId` / `turn` / `battleId` into
`ui/frontend/src/lib/active-view/battle.svelte`, which loads the
report via `api/battle-fetch.ts` (synthetic-fixture path + real
engine fetch through the backend gateway)
- UI report link: `lib/active-view/report/section-battles.svelte`
now links every battle UUID into
`/games/{id}/battle/{uuid}?turn={turn}`
- UI map markers: `ui/frontend/src/map/battle-markers.ts` emits a
yellow X cross per battle (two `LinePrim` through the planet's
bounding-square diagonals; stroke width scales 1px..5px with
protocol length) plus a stroke-only ring per bombing (yellow when
damaged, red when wiped). Wired into `state-binding.ts`; the map
click handler dispatches battle clicks to the viewer and bombing
clicks to a scroll-into-view of the matching row in Reports.
- topic doc `ui/docs/battle-viewer-ux.md` covers playback
semantics, accessibility (the always-visible `<ol>` log), the
radial layout, and the marker click behaviour
- docs/FUNCTIONAL.md §6.5 (Battle viewer) + mirror in
docs/FUNCTIONAL_ru.md
Dependencies: Phase 23. Dependencies: Phase 23.
Acceptance criteria: Acceptance criteria:
- battle and bombing markers render on the map for the seeded - battle and bombing markers render on the map for the seeded
current-turn report and are clickable to open the viewer; current-turn report and are clickable: battle → Battle Viewer for
- the viewer plays back any battle in the seeded report including the corresponding UUID, bombing → scroll to its row in Reports;
multi-round and one-sided battles; - the Battle Viewer plays back any `BattleReport` end-to-end with
- step controls allow precise inspection; step back / step forward / rewind / 1x-2x-4x speeds; observers
- the same data is accessible as a static text log for accessibility. (`inBattle === false`) are not drawn; eliminated races drop out
and survivors re-distribute on the next frame;
- the same protocol is mirrored as an always-visible text log under
the scene for accessibility;
- bombings keep their Phase 23 static table layout in Reports; no
Battle Viewer entry-point is wired from them.
Targeted tests: Targeted tests:
- Vitest unit tests for round-state transitions; - Vitest unit: radial layout (1/2/3 races) and timeline frame-
- Vitest unit tests for marker rendering on torus and no-wrap builder (initial state, shot decrement, race-elimination drop-out)
fixtures; in `tests/battle-player.test.ts`
- Playwright e2e: click a battle marker on the map, play through, - Vitest unit: marker primitives + stroke-width formula
step backward, return to the report. (1→1, 50→2.98, 100→5, 200→5) in `tests/battle-markers.test.ts`
- Go unit: engine HTTP handler validations (400 / 404 / 500) in
`game/internal/router/battle_test.go`
- Go contract: openapi freezes for the new endpoint and schemas in
`game/openapi_contract_test.go`
- Playwright e2e: click battle marker → viewer; play / step back;
click battle UUID in Reports → viewer; click bombing marker →
Reports bombings row scrolled into view.
Decisions during stage:
1. **Bombings stay a static table.** `section-bombings.svelte`
already covers the "who bombed, with what power, wiped or not"
requirement; nothing in Phase 27 touches it. Bombings explicitly
do not open the Battle Viewer.
2. **Wire change.** `Report.Battle` switched from `[]uuid.UUID` to
`[]BattleSummary{id, planet, shots}` so the map renderer can
place markers without N extra fetches and so the cross-marker
stroke can scale with protocol length.
3. **Battle marker = yellow X cross** drawn as 2 `LinePrim` through
the corners of the planet's circumscribed square; stroke width
`clamp(1 + (shots - 1) * 4 / 99, 1, 5)` px.
4. **Bombing marker = stroke-only ring** slightly larger than the
planet circle. Yellow when damaged, red when wiped. Click =
scroll to the matching row in Reports (not the viewer).
5. **Viewer URL** `/games/[id]/battle/[battleId]?turn=N`. Turn is a
query param so the same route works in history mode.
6. **SVG, not PixiJS** for the radial scene — isolated component,
no need for WebGL; PixiJS stays as the map renderer.
7. **Playback controls full set**: play / pause + step back + step
forward + rewind + 1x / 2x / 4x switch. 1x = 400 ms per frame.
8. **Observer groups (`inBattle: false`)** are filtered out of both
the scene and the text log.
9. **Cluster aggregation by `(race, className)`** so a race with
multiple groups of the same class collapses to one labelled
circle. Stable target for shot-line endpoints.
10. **Page loader switches on `synthetic-` gameId prefix** —
synthetic mode uses `api/synthetic-battle.ts` fixtures; live
games hit `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`.
BattleViewer component itself is a logically isolated prop sink.
11. **Always-visible `<ol>` text protocol** under the scene satisfies
the accessibility requirement without a separate "skip
animation" toggle.
TODO carried into Phase 27 deferred items
(see Phase 27 of this PLAN's deferred-followups list, near the
bottom):
- push event `game.battle.new` + toast deep-link;
- richer ship-class visuals derived from class characteristics;
- animated transitions when survivors re-distribute after an
elimination (currently hard-jumps).
## Phase 28. Diplomatic Mail View ## Phase 28. Diplomatic Mail View
@@ -3134,3 +3540,18 @@ phase listed in the parenthesis when that phase lands.
exercises a unary Connect call and a server-streaming Connect call exercises a unary Connect call and a server-streaming Connect call
through `testenv.Bootstrap`. (Phase 7+, fold into the phase that through `testenv.Bootstrap`. (Phase 7+, fold into the phase that
needs it.) needs it.)
- **Battle viewer — push event `game.battle.new`** — when a battle
involving the current player lands, emit a backend notification
intent (idempotency `battle-new:<game_id>:<turn>:<battle_id>`,
payload `{game_id, turn, battle_id}`) so the in-game shell
surfaces a toast with a deep link into the Battle Viewer.
(Phase 27 deferred; needs an engine emit-side change.)
- **Battle viewer — richer ship-class visuals** — current MVP draws
one small circle plus `<class>:<numLeft>` label per `(race,
className)` pair. Future work derives shape / scale from mass,
armament, shields, and the number of ships in the group.
(Phase 27 deferred.)
- **Battle viewer — animated re-distribution on elimination** —
current implementation hard-jumps to the new spacing on the next
frame; replace with an easing so the survivors visibly slide
along the outer ring. (Phase 27 deferred.)
+258
View File
@@ -0,0 +1,258 @@
# Battle Viewer UX
Phase 27 ships a dedicated viewer for battles (`/games/<id>/battle/<battleId>`).
Bombings stay where they were in Phase 23 — a static table in the
Reports view (`section-bombings.svelte`). The two domains are
deliberately not mixed in any visual surface or click target.
## Data shape
The `BattleViewer` component (`lib/battle-player/battle-viewer.svelte`)
is logically isolated. It accepts a `BattleReport` matching
`pkg/model/report/battle.go`. The fields it uses:
- `id`, `planet`, `planetName` — header + the central-planet glyph.
- `races: { [raceId]: raceUUID }` — race index space used by the
protocol's `a` / `d` fields.
- `ships: { [groupKey]: BattleReportGroup }` — ship-group rosters
with `race` name, `className`, initial `num`, end-state `numLeft`,
and the `inBattle` flag. Observer groups (`inBattle: false`) are
never drawn.
- `protocol: BattleActionReport[]` — flat list of shots. Each carries
attacker `(a, sa)`, defender `(d, sd)`, and `x` (destroyed?).
The component asks `timeline.ts.buildFrames(report)` to expand the
protocol into `protocol.length + 1` frames; frame 0 is the initial
state and frame `N` reflects state after action `protocol[N-1]`. The
race index per ship group is derived from the protocol itself —
every in-battle group appears at least once as attacker or defender,
and the engine never crosses these wires.
## Radial scene
The scene (`lib/battle-player/battle-scene.svelte`, SVG) places the
planet at the centre and arrays the still-active races on an outer
ring at equal angular spacing. Each race anchor hosts a *cloud* of
class circles arranged on a Vogel sunflower spiral biased toward the
planet (the cluster anchor is pushed inward by a quarter step so the
rank-0 node — the heaviest group by NumberLeft — sits closest to the
planet, and the spiral fans the rest behind it). When a race is
wiped out, it drops out of the active list and the survivors are
re-spaced on the next frame.
Each class circle is one *bucket* keyed by `(race, className)`:
tech-variants of the same class collapse into one node so the scene
stays readable when a race fields a dozen tech levels of the same
hull. The per-bucket label `<className>:<numLeft>` sums NumberLeft
across the underlying groups; per-tech detail is available in the
Reports view (Foreign Ship Classes / My Ship Types).
Bucket order inside a cluster is **locked at battle start** by the
initial ship count (`num` summed across tech variants, descending),
together with mass, radius and local position. The static layout
lives in `staticBucketsByRace`; the per-frame derivation
`renderedByRace` overlays the live `NumberLeft` and drops buckets
once they hit zero. The remaining buckets keep their slots in the
cloud, so the cluster does not reshuffle when a class empties — the
empty bucket simply disappears.
Vogel positions are reassigned per rank by their inward distance
toward the planet, so the rank-0 bucket (the largest by initial
ship count) always sits at the most-inward spiral slot.
When two races remain in battle the radial layout switches to the
horizontal duel: race 0 at 9 o'clock, race 1 at 3 o'clock. This
keeps both race labels clear of the SVG top edge and reads as the
two sides facing off naturally.
Circle radius scales with per-ship FullMass (Empty + Carrying via
the per-ship `LoadQuantity`). The viewer resolves a
`(race, className) → ShipClassRef` lookup from the surrounding
`GameReport.localShipClass` + `otherShipClass` tables and runs it
through the existing wasm bridge to `pkg/calc/ship.go`
(`emptyMass` + `carryingMass` + `fullMass`). The radius is then
`MIN_RADIUS + (MAX_RADIUS MIN_RADIUS) × sqrt(mass / maxMassInBattle)`
clamped to `[6, 24]` pixels — per-battle normalisation, so the
heaviest ship in any given battle renders at the cap. Unknown class
or invalid params fall back to MAX_RADIUS so the bucket stays
visible.
The current frame's shot is drawn as a thin line from the attacker's
class circle to the defender's class circle. Colour:
- red (`#ee3344`) when the action's `x === true` (the defender
ship was destroyed),
- green (`#44dd66`) otherwise.
Each frame redraws the line in isolation, so continuous playback
produces the "shot-shot-shot" pulse the user wanted.
## Playback controls
`lib/battle-player/playback-controls.svelte` ships:
| Control | Effect |
| ------------------ | ------------------------------------------------------- |
| ⏮ rewind | Stop, jump to frame 0 |
| ◀︎◀︎ step back | Stop, frame ← frame 1 |
| ▶︎ / ⏸ play | Toggle continuous playback |
| ▶︎▶︎ step forward | Stop, frame ← frame + 1 |
| `Nx` cycle speed | Single button, cycles 1x → 2x → 4x → 6x → 1x; the label shows the current speed (400 / 200 / 100 / 67 ms per frame) |
| `Log ▲▼` toggle | Collapses / expands the always-visible text protocol so the user can give the scene the full viewer height |
When the timeline is at its end and the user hits play, the frame
counter wraps to 0 and continues. Step buttons disable themselves at
their boundary.
A drag-seek slider sits between the scene and the controls. Dragging
pauses playback and lands `frameIndex` on the chosen shot — handy
for jumping to the moment a particular race started losing ground.
## Accessibility
Below the scene the viewer renders a static `<ol>` text protocol —
one line per action, formatted from `BattleReportGroup.race` and
`BattleReportGroup.className`. The line for the current frame is
highlighted so a non-visual reader can follow along by scrolling
the log instead of watching the SVG. The list is always present
and never hidden, satisfying the original Phase 27 acceptance "the
same data is accessible as a static text log".
Each log row is also a `<button>`: a click or Enter/Space jumps
playback to that shot (pauses and seeks). The list auto-scrolls
the current row into view as the timeline advances, so the user
does not have to chase the highlight on long battles.
## Playback details
On play, the shot line + the defender circle's colour flash gate
on a per-frame timer that blinks them off during the last 10 % of
the frame's duration. Two consecutive shots from the same attacker
on the same defender therefore look like two distinct pulses
rather than one continuous line. On pause the line and flash
stay drawn so the user can study the current shot.
## Aggregated ship-class buckets
Real legacy reports list the same `(race, className)` pair across
several roster rows — different tech variants, ships pulled from
multiple stacks or planets. The legacy-report parser
([parser.go](../../tools/local-dev/legacy-report/parser.go))
collapses those rows into a single `BattleReportGroup` keyed by
`(race, className)` by SUMMING `Number` and `NumberLeft`; the
engine's `TransformBattle`
([battle_transform.go](../../game/internal/controller/battle_transform.go))
applies the same merge keyed by `ShipClass.ID`, guarded by a
processed-group set so the same source `groupId` is not summed
twice across multiple protocol references.
Without this aggregation only the last roster row's counts
survived, and the protocol's destroy count against the class
would dwarf the recorded initial count — KNNTS041 turn 41 planet
\#7 had 7 separate `Nails:pup` rows totalling 1168 ships; the
buggy parser stored only the last row's 88, so the 1082 destroys
in the protocol looked like phantom hits past the empty bucket.
After the fix both sides reconcile: 1168 initial 86 survivors =
1082 destroys.
`buildFrames`
([timeline.ts](../src/lib/battle-player/timeline.ts)) keeps a
defence-in-depth clamp `if (left > 0)` on the destroy decrement so
a malformed protocol never pushes a race below zero; in normal
operation the clamp is a no-op because parser + engine already
folded duplicate rows together.
## Final-frame freeze
When the last protocol action eliminates a race, the surviving
side would otherwise reflow alone to the planet ring at the very
last shot — visually jarring and uninformative. `displayFrame`
freezes the layout-determining state (`remaining` and
`activeRaceIds`) at the penultimate frame's values while keeping
the final frame's `shotIndex` and `lastAction`, so the killing
shot still renders as a line + flash against the picture the user
saw a moment earlier.
## Header + layout
The viewer header carries three rows of chrome in a single line:
the back-navigation buttons (`back to map` / `back to report`) on
the left, a centred title — `Battle on planet <name> (<#number>)`,
i18n key `game.battle.header_title` — and the frame counter on the
right. Pulling navigation into the header frees the entire viewer
area for the scene; the `.viewer` container has no `max-width` cap,
so on wide monitors the scene scales up while the log keeps its
internal 30 dvh scroll.
## Height fit
The viewer is pinned to the viewport: `.active-view` uses
`calc(100dvh 80px)` so the in-game-shell header + optional
HistoryBanner do not push the scene below the fold. Inside the
viewer, the scene grows (`flex: 1`), the scrubber + controls hold
their natural height, and the log (when expanded) shrinks to a
30 dvh ceiling with its own internal scroll, so the page itself
never scrolls vertically. The 80 px allowance maps to the current
Header + HistoryBanner total on desktop; mobile breakpoints reuse
the same calc because dvh tracks the dynamic viewport.
## Map markers
`map/battle-markers.ts` emits two marker kinds per
current-turn report. Both are wired into the binding's
`hitLookup` so a click goes through the existing hit-test plumbing.
### Battle marker — yellow cross
For every `report.battles[i]` whose `planet` resolves to a visible
planet, the marker emits two `LinePrim` lines through the opposite
corners of the square circumscribed around the planet circle. The
result is an X-shaped cross overlaid on the planet glyph.
The stroke width is computed by `battleMarkerStrokeWidth(shots)`:
1 shot → 1 px, 100 shots → 5 px, linearly interpolated in between
(`width = 1 + (shots 1) × 4 / 99`, clamped). A click on either
line navigates to `/games/<id>/battle/<battleId>?turn=<turn>`.
### Bombing marker — colored ring
For every `report.bombings[i]`, the marker emits a single
stroke-only `CirclePrim` slightly larger than the planet circle.
Colour:
- yellow (`#FFD400`) when `wiped: false`,
- red (`#FF3030`) when `wiped: true`.
A click on the ring navigates to `/games/<id>/report#report-bombings`
and scrolls the matching `report-bombing-row` (by `data-planet`)
into view. Bombing markers never open the Battle Viewer — the two
domains stay separate.
## Data source
The Battle Viewer page (`lib/active-view/battle.svelte`) calls
`api/battle-fetch.ts.fetchBattle(gameId, turn, battleId)`. The
loader has two modes:
- **Synthetic** — when `gameId` carries the
`synthetic-` prefix, the lookup is served from
`api/synthetic-battle.ts`. Vitest unit tests and Playwright e2e
tests register fixture battles via `registerSyntheticBattle`
before mounting the route.
- **Production** — otherwise the loader issues
`GET /api/v1/user/games/{gameId}/battles/{turn}/{battleId}`
against the backend gateway route added in
`backend/internal/server/handlers_user_games.go.Battle`. The
gateway forwards verbatim to the engine's
`GET /api/v1/battle/:turn/:uuid`.
## TODOs
- Push event `game.battle.new` + toast → viewer link (deferred —
needs an engine emit-side change).
- Richer ship-class visuals derived from the class's mass,
armament, shields. Current MVP uses a small circle plus
`<class>:<numLeft>` label.
- Animated transitions when a race drops out and the survivors
re-distribute. Current implementation hard-jumps on the next
frame.
+118
View File
@@ -0,0 +1,118 @@
# UI events stream (`api/events.svelte.ts`)
This document describes how the SvelteKit frontend consumes the
gateway's `SubscribeEvents` server-streaming RPC. The single
authenticated session keeps **one** stream open through the
`EventStream` singleton declared in `src/api/events.svelte.ts`; the
root layout starts it once the session reaches `authenticated` and
stops it on sign-out.
## Why a single consumer
Before Phase 24, the watcher in `lib/revocation-watcher.ts` opened a
parallel stream just to observe session revocation. Phase 24 folds
that watcher into `EventStream` so that:
- there is only **one** SubscribeEvents connection per session
(avoids doubling the gateway hub load);
- both clean end-of-stream on an authenticated session and an
`Unauthenticated` ConnectError funnel through one
`session.signOut("revoked")` call site;
- per-event-type dispatch (turn-ready toasts, lobby/mail/battle
notifications later) shares the same verification path.
## Lifecycle
```
SessionStore.status = "authenticated"
↓ (root layout $effect)
EventStream.start({ core, keypair, deviceSessionId, gatewayResponsePublicKey })
loop: open SubscribeEvents → verify each frame → dispatch to handlers
EventStream.stop() (on logout, unmount, or session id change)
```
`start` is idempotent for the same session: re-calling while the
stream is running is a no-op. The root layout detects a session id
flip (re-login on the same tab) by storing the bound id and calling
`stop()` + `start()` against the fresh credentials.
## Frame handling
Every `GatewayEvent` is verified before dispatch:
1. `core.verifyPayloadHash(payloadBytes, payloadHash)` — the SHA-256
digest of `payloadBytes` must equal `payloadHash`. A mismatch is
treated as a forgery.
2. `core.verifyEvent(gatewayResponsePublicKey, signature, fields)`
Ed25519 verification using the canonical signing input defined in
`ui/core/canon/event.go` (mirrored by `gateway/authn/event.go`).
3. On success the verified projection (`VerifiedEvent`) is passed to
every handler registered via `eventStream.on(eventType, handler)`.
Any verification failure throws `SignatureError`, which falls into
the same retry path as a transport error: the loop classifies it as
transient, tears the stream down, and reconnects with full-jitter
exponential backoff (base 1 s, ceiling 30 s, unbounded retries).
## Connection status
`EventStream.connectionStatus` is a Svelte rune with five values:
- `idle` — stream not running.
- `connecting``subscribeEvents()` issued, no frame received yet.
- `connected` — first frame verified and dispatched, attempt counter
reset to zero.
- `reconnecting` — transient failure, backoff in flight.
- `offline``navigator.onLine === false` at the moment of failure.
The header connection-state indicator planned in `PLAN.md`
cross-cutting shell reads this rune; it is not part of Phase 24 but
the rune is wired now so a later phase can add the dot without
touching this module.
## Revocation semantics
Two paths lead to `session.signOut("revoked")`:
- a `ConnectError` with code `Unauthenticated`: the gateway rejected
the stream credentials (revoked device session);
- a clean end-of-stream while `session.status === "authenticated"`:
the gateway's documented `session_invalidation` signal closes the
stream once the device session flips to revoked.
Any other error (network drop, gateway 5xx, transient close,
signature failure) keeps the session alive and triggers backoff +
reconnect.
## Adding a new event type
1. Register a handler from the consumer module:
```ts
const off = eventStream.on("mail.received", (event) => {
// parse event.payloadBytes
});
onDestroy(off);
```
2. If the handler reads scoped data (per-game, per-route), register
from a layout that owns that scope and pass the gameId via a
closure. The handler should filter events whose payload does not
match its scope (see `routes/games/[id]/+layout.svelte` for the
`game.turn.ready` filter).
3. The payload encoding is owned by the producer side: the
`game.turn.ready` event uses JSON `{game_id, turn}`. Document
the schema next to the producer (e.g. `backend/README.md` §10).
## Tests
- Unit (Vitest): `tests/events.test.ts` mocks the transport via
`createRouterTransport` and covers verified dispatch, type
filtering, bad-signature reconnect, `Unauthenticated` sign-out,
clean end-of-stream sign-out, and connection-status transitions.
- E2E (Playwright): `tests/e2e/turn-ready.spec.ts` serves a signed
`game.turn.ready` frame through `page.route`, asserts the toast
surfaces, and verifies manual dismiss without advance. The
advance roundtrip (toast → click "view now" → fresh report) is
covered by Vitest at the store level because it is sensitive to
Playwright-side network ordering.
+64 -11
View File
@@ -37,6 +37,10 @@ The store exposes:
| `gameId` | `string` | active game id | | `gameId` | `string` | active game id |
| `status` | `idle / loading / ready / error` | current lifecycle state | | `status` | `idle / loading / ready / error` | current lifecycle state |
| `report` | `GameReport \| null` | latest decoded report, `null` until first fetch | | `report` | `GameReport \| null` | latest decoded report, `null` until first fetch |
| `currentTurn` | `number` | server's authoritative current turn (live snapshot) |
| `viewedTurn` | `number` | turn whose snapshot is in `report`; equals `currentTurn` in live mode |
| `historyMode` | `boolean` (derived) | true while `status === "ready"` and `viewedTurn < currentTurn` |
| `pendingTurn` | `number \| null` | latest server turn the user has not yet opened |
| `wrapMode` | `torus / no-wrap` | per-game preference, persisted via `Cache` | | `wrapMode` | `torus / no-wrap` | per-game preference, persisted via `Cache` |
| `error` | `string \| null` | localised error message when `status === "error"` | | `error` | `string \| null` | localised error message when `status === "error"` |
@@ -45,8 +49,15 @@ The store exposes:
- Phase 11 surfaces only the planet subset of the report. Later - Phase 11 surfaces only the planet subset of the report. Later
phases extend `GameReport` and `decodeReport` as their slice of phases extend `GameReport` and `decodeReport` as their slice of
the wire lands (ships, fleets, sciences, routes, battles, mail). the wire lands (ships, fleets, sciences, routes, battles, mail).
- Phase 26 wires history mode through `setTurn(turn)`. The store - Phase 26 splits `currentTurn` from the turn whose snapshot is
already supports it; the navigator UI is what is missing. displayed (`viewedTurn`) and adds `viewTurn(turn)` /
`returnToCurrent()` for history navigation. The derived
`historyMode` rune flips automatically when `viewedTurn <
currentTurn`; the layout passes it to Phase 12's sidebar /
bottom-tabs wiring (which hides the order tab) and to
`OrderDraftStore.bindClient` (which gates `add` / `remove` /
`move`). See "History mode" below for the cache and refresh
rules.
- Phase 24 replaces the tab-focus refresh with push-event-driven - Phase 24 replaces the tab-focus refresh with push-event-driven
refreshes; the visibility listener stays as a fallback for refreshes; the visibility listener stays as a fallback for
background tabs that miss a push. background tabs that miss a push.
@@ -88,17 +99,59 @@ result can resolve back to a planet without an extra lookup table.
## Refresh discipline ## Refresh discipline
`refresh()` re-fetches the same turn snapshot. It is called by the `refresh()` re-fetches the current-turn snapshot. It is called by
`visibilitychange` handler when `document.visibilityState === the `visibilitychange` handler when `document.visibilityState ===
"visible"` and the store is already in `ready` state. The map view's "visible"` and the store is already in `ready` state. The map
mount effect skips a re-render when the new snapshot's turn matches view's mount effect skips a re-render when the new snapshot's turn
the previously-mounted turn (and the wrap mode is unchanged), so a matches the previously-mounted turn (and the wrap mode is
no-op refresh does not flicker the canvas. unchanged), so a no-op refresh does not flicker the canvas.
`setTurn(turn)` is the entry point for Phase 26 history mode: In history mode `refresh()` is a no-op — forcing a reload would
calling it on a different turn loads that snapshot and the same silently bump the user back onto the current turn while they are
mount effect re-creates the renderer with the new world. intentionally viewing a past one. Push events (Phase 24) still
deliver new-turn notifications asynchronously while the user
explores history, so the pending-turn toast continues to work.
`setWrapMode(mode)` writes to `Cache` and updates the rune; the `setWrapMode(mode)` writes to `Cache` and updates the rune; the
map view's effect picks the change up and re-mounts the renderer map view's effect picks the change up and re-mounts the renderer
with the new mode. with the new mode.
## History mode
Phase 26 lets the user step backward through the report timeline
without losing the live snapshot. The store keeps two turn runes:
- `currentTurn` — the server's authoritative latest. Only
`setGame` and `advanceToPending` write to it.
- `viewedTurn` — the turn currently rendered. `viewTurn(N)` flips
this rune and the underlying `report` to `N` without touching
`currentTurn`. `returnToCurrent()` is a one-line wrapper that
navigates back to live.
The derived `historyMode` rune (`status === "ready" && viewedTurn
< currentTurn`) drives every history-aware consumer:
- the layout passes it to `Sidebar` / `BottomTabs` so the order
tab vanishes (Phase 12 prop wiring);
- the layout passes a `getHistoryMode` getter to
`OrderDraftStore.bindClient` so `add` / `remove` / `move` are
no-ops while the user is looking at a past turn;
- `RenderedReportSource` returns the raw report (no order overlay)
because the draft is composed against the current turn;
- the new `HistoryBanner` component renders the sticky "Viewing
turn N · read-only" strip when the flag is true.
`last-viewed-turn` semantics keep their Phase 11 meaning: "the
latest turn the user was caught up on". `loadTurn` only writes the
cache row when called with `isCurrent === true` (i.e. when the
load matches `currentTurn`). Historical excursions are therefore
ephemeral: closing the tab and reopening the game resumes on the
last caught-up turn, not on the last clicked one.
Past-turn reports are cached in the `game-history` namespace
(`{gameId}/turn/{N}``GameReport`). The cache is written by
`loadTurn` on every successful historical fetch and read first by
`viewTurn(N)` before falling back to the network. Past turns are
immutable, so the cache has no TTL and no eviction in Phase 26.
The current-turn snapshot is deliberately *not* cached — it is
mutable until the next engine tick.
+35 -6
View File
@@ -24,7 +24,7 @@ separate dispatch component.
| ------------------------------------------ | ---------------------------------------------- | ----------------------- | | ------------------------------------------ | ---------------------------------------------- | ----------------------- |
| `/games/:id/map` | `lib/active-view/map.svelte` | Phase 11 | | `/games/:id/map` | `lib/active-view/map.svelte` | Phase 11 |
| `/games/:id/table/:entity` | `lib/active-view/table.svelte` | Phase 11 / 17 / 19 / 22 | | `/games/:id/table/:entity` | `lib/active-view/table.svelte` | Phase 11 / 17 / 19 / 22 |
| `/games/:id/report` | `lib/active-view/report.svelte` | Phase 23 | | `/games/:id/report` | `lib/active-view/report.svelte` (see [report-view.md](report-view.md)) | Phase 23 |
| `/games/:id/battle/:battleId?` | `lib/active-view/battle.svelte` | Phase 27 | | `/games/:id/battle/:battleId?` | `lib/active-view/battle.svelte` | Phase 27 |
| `/games/:id/mail` | `lib/active-view/mail.svelte` | Phase 28 | | `/games/:id/mail` | `lib/active-view/mail.svelte` | Phase 28 |
| `/games/:id/designer/ship-class/:classId?` | `lib/active-view/designer-ship-class.svelte` | Phase 17 (CRUD) / 18 (calc preview) | | `/games/:id/designer/ship-class/:classId?` | `lib/active-view/designer-ship-class.svelte` | Phase 17 (CRUD) / 18 (calc preview) |
@@ -75,17 +75,46 @@ end-to-end command flow) can set it on navigation.
The Order entry is hidden when the layout's `historyMode` flag is The Order entry is hidden when the layout's `historyMode` flag is
true. Phase 12 plumbs the flag end-to-end as a prop — true. Phase 12 plumbs the flag end-to-end as a prop —
`+layout.svelte` passes a constant `false` to `Sidebar`, which `+layout.svelte` forwards a derived value to `Sidebar`, which
forwards `hideOrder` to its `TabBar`; the same flag goes to forwards `hideOrder` to its `TabBar`; the same flag goes to
`BottomTabs` so the mobile `Order` button is also suppressed. A `BottomTabs` so the mobile `Order` button is also suppressed. A
`?sidebar=order` URL seed that arrives while the flag is true falls `?sidebar=order` URL seed that arrives while the flag is true falls
back to `inspector`, and an `$effect` on the sidebar resets back to `inspector`, and an `$effect` on the sidebar resets
`activeTab` away from `order` if the flag flips on mid-session. `activeTab` away from `order` if the flag flips on mid-session.
Phase 26 introduces `lib/history-mode.ts` and replaces the constant
with the live signal; the order draft survives the toggle because Phase 26 wires the flag to the live history signal owned by
`GameStateStore`. The derivation lives directly in `+layout.svelte`
(`const historyMode = $derived(gameState.historyMode)`) — no
separate `lib/history-mode.ts` module ships, because the layout is
the single consumer and the project's compactness rule rejects a
one-line indirection. The order draft survives the toggle because
`OrderDraftStore` lives one level above the sidebar in the layout `OrderDraftStore` lives one level above the sidebar in the layout
hierarchy. See [`order-composer.md`](order-composer.md) for the hierarchy; the same `historyMode` derivation is also fed into
draft-store side of the flow. `OrderDraftStore.bindClient` so inspector-driven mutations
(`add` / `remove` / `move`) become no-ops while the user is
viewing a past turn. See [`order-composer.md`](order-composer.md)
for the draft-store side of the flow and
[`game-state.md`](game-state.md) for the rune split between
`currentTurn` and `viewedTurn`.
## Header turn navigator and history banner
The header replaces the Phase 11 inline `turn N` text with a
`← Turn N →` triplet (`lib/header/turn-navigator.svelte`). The
arrows step `viewedTurn` by ±1 (disabled at boundaries `0` and
`currentTurn`); clicking the middle button opens an absolute
popover (desktop) or a fixed full-width drawer (mobile, ≤ 767.98
px) listing every turn from `currentTurn` down to `0`. Selecting
the current-turn row routes through `gameState.returnToCurrent()`;
any other row calls `gameState.viewTurn(N)`. The popover reuses
`view-menu.svelte`'s outside-click / Escape pattern.
`lib/header/history-banner.svelte` renders directly under the
header whenever `gameState.historyMode === true`. It shows
"Viewing turn {N} · read-only" with a "Return to current turn"
button that delegates back to `gameState.returnToCurrent()`. Both
the navigator and the banner read `gameState` through context, so
the layout is the only place where the wiring lives.
## Layout breakpoints ## Layout breakpoints
+54 -15
View File
@@ -36,11 +36,18 @@ entry by `cmdId`. Successfully applied entries stay visible in
the draft (the player keeps composing until turn cutoff); the draft (the player keeps composing until turn cutoff);
rejected entries stay until the player edits or removes them. rejected entries stay until the player edits or removes them.
Phase 25 is reserved for one extension on top of this: per-line Phase 25 layers a transport-level policy on top of this baseline
sequencing if a future use case needs to submit commands without changing the batch semantics. The submit pipeline now
individually rather than in one batch. The wire shape is already goes through `OrderQueue` (see
flexible enough — the response carries an array of results — so [`sync-protocol.md`](sync-protocol.md)): the queue holds the
Phase 25 only changes the client-side iteration policy. submit while the browser is offline, classifies
`turn_already_closed` and `game_paused` server replies into
matching banners on the order tab, and exits the loop on the
sticky states so a stream of mutations does not re-elicit the
same gateway reply. Recovery from a `conflict` or `paused`
banner happens on the next `game.turn.ready` push frame via
`OrderDraftStore.resetForNewTurn`, which clears the local draft
and re-hydrates from the server for the new turn.
## Local-validation invariant ## Local-validation invariant
@@ -63,8 +70,10 @@ submit pipeline filters the draft to `valid` entries only — any
```text ```text
draft ──validate──▶ valid ──submit──▶ submitting ──ack──▶ applied draft ──validate──▶ valid ──submit──▶ submitting ──ack──▶ applied
╲ │ ╲ │
╲──validate──▶ invalid ╲──nack──▶ rejected ╲──validate──▶ invalid ╲──nack──▶ rejected
╲────turn_already_closed──▶ conflict
``` ```
Transitions: Transitions:
@@ -76,6 +85,14 @@ Transitions:
the draft and sends it to the gateway. the draft and sends it to the gateway.
- **`submitting → applied` / `submitting → rejected`**: the gateway - **`submitting → applied` / `submitting → rejected`**: the gateway
responded; the entry is no longer in flight. responded; the entry is no longer in flight.
- **`submitting → conflict`** (Phase 25): the gateway returned
`resultCode = "turn_already_closed"`. The order tab surfaces a
banner above the command list. Any subsequent mutation
re-validates the conflict row back to `valid` / `invalid`; a
matching `game.turn.ready` push frame triggers
`resetForNewTurn`, which wipes the draft entirely. See
[`sync-protocol.md`](sync-protocol.md) for the full state
table and recovery paths.
Phase 14 lands the local validators (`draft → valid | invalid`), Phase 14 lands the local validators (`draft → valid | invalid`),
the submit pipeline (`valid → submitting → applied | rejected`), the submit pipeline (`valid → submitting → applied | rejected`),
@@ -283,13 +300,14 @@ order composer uses the namespace.
## History mode wiring ## History mode wiring
Phase 26 introduces a global history-mode flag. The IA section Phase 26 implements history mode: the user can step back through
specifies that the Order tab is hidden when history mode is active — past turns and see the report as it was. The IA section specifies
the player is browsing a past turn snapshot, and composing commands that the Order tab is hidden when history mode is active — the
against an immutable snapshot would be confusing. player is browsing an immutable snapshot, and composing commands
against it would be confusing.
Phase 12 wires the flag end-to-end as a prop. The layout owns the Phase 12 wires the flag end-to-end as a prop. The layout owns the
flag (a constant `false` until Phase 26) and passes it to: flag and passes it to:
- `Sidebar` as `historyMode`. The sidebar forwards it to its - `Sidebar` as `historyMode`. The sidebar forwards it to its
`TabBar` as `hideOrder`. The Order entry is filtered out of the `TabBar` as `hideOrder`. The Order entry is filtered out of the
@@ -300,10 +318,31 @@ flag (a constant `false` until Phase 26) and passes it to:
- `BottomTabs` as `hideOrder`. The mobile bottom-tab `Order` - `BottomTabs` as `hideOrder`. The mobile bottom-tab `Order`
button is suppressed when true. button is suppressed when true.
Phase 26 turns the constant into a derived value driven by
`GameStateStore.historyMode` (`viewedTurn < currentTurn` while
`status === "ready"`). The same getter is also passed into
`OrderDraftStore.bindClient` as `getHistoryMode`, which short-
circuits the `add` / `remove` / `move` mutations to a no-op while
the flag is true. This makes every Phase 1422 inspector affordance
that calls `orderDraft.add(...)` inert in history mode without
per-component edits — the gate lives in the one chokepoint that
all callers go through. The conflict / paused banners and the
in-flight sync pipeline are untouched: they describe state that
exists independently of the user's current view.
The store itself stays alive across history-mode round-trips so The store itself stays alive across history-mode round-trips so
the draft survives. Phase 26 will replace the constant with the the draft survives the toggle. The `RenderedReportSource` overlay
real signal from `lib/history-mode.ts` and exercise the toggle in (`lib/rendered-report.svelte.ts`) additionally short-circuits in
its own test suite. history mode: when `gameState.historyMode === true` it returns
the raw report so the map / inspector do not project pending
renames composed for the *current* turn onto a *past* report.
See [`game-state.md`](game-state.md) for the `viewTurn` /
`returnToCurrent` API, the cache namespace
(`game-history/{gameId}/turn/{N}`), and the visibility-refresh
short-circuit; see [`navigation.md`](navigation.md) for the turn
navigator and the read-only banner that surface history mode in
the chrome.
## Testing ## Testing
+179
View File
@@ -0,0 +1,179 @@
# Report view — Phase 23
The Phase 23 in-game "turn report" view is a single scrollable
layout with twenty sections, one per array on the FBS `Report`
table. The route file is the standard two-line wrapper; the
orchestrator and the per-section components live under
`ui/frontend/src/lib/active-view/report/`.
## Component layout
`lib/active-view/report.svelte` is the orchestrator. It owns the
section list, instantiates `IntersectionObserver` to track which
section is active, and renders the table of contents alongside the
section column.
```
report.svelte
├── report/report-toc.svelte // anchor list + mobile <select>
├── report/section-galaxy-summary.svelte
├── report/section-votes.svelte
├── report/section-player-status.svelte
├── report/section-my-sciences.svelte
├── report/section-foreign-sciences.svelte
├── report/section-my-ship-classes.svelte
├── report/section-foreign-ship-classes.svelte
├── report/section-battles.svelte
├── report/section-bombings.svelte
├── report/section-approaching-groups.svelte
├── report/section-my-planets.svelte
├── report/section-ships-in-production.svelte
├── report/section-cargo-routes.svelte
├── report/section-foreign-planets.svelte
├── report/section-uninhabited-planets.svelte
├── report/section-unknown-planets.svelte
├── report/section-my-fleets.svelte
├── report/section-my-ship-groups.svelte
├── report/section-foreign-ship-groups.svelte
└── report/section-unidentified-groups.svelte
```
Each section component is self-contained:
- reads `RenderedReportSource` from context;
- renders the loading copy when `rendered.report === null`;
- renders the empty-state copy when its array is empty;
- otherwise emits a `<section id="report-<slug>" data-testid="report-section-<slug>">`
containing the relevant grid / list / kv-list.
No shared `<Section>` wrapper exists. The visual scaffolding (dark
grid CSS, header style, status paragraph) is inlined per
component. The CLAUDE.md "wait for the third real caller before
extracting an abstraction" rule applies; with one shape per
section, the per-section inline CSS is the smallest correct
solution.
Shared formatters live in `report/format.ts` (`formatPercent`,
`formatCount`, `formatFloat`, `formatVotes`, `planetLabel`).
## Section order, data sources, empty copy
| # | Slug | Data | Empty copy (en) |
|---|------|------|-----------------|
| 1 | `galaxy-summary` | header turn / size / planet count / race | never empty |
| 2 | `votes` | `myVotes`, `myVoteFor`, `races[].votesReceived` | "no votes cast yet" |
| 3 | `player-status` | `players[]` (full roster, self + extinct) | never empty |
| 4 | `my-sciences` | `localScience[]` | "no sciences defined yet" |
| 5 | `foreign-sciences` | `otherScience[]`, one sub-table per race | "no foreign sciences observed yet" |
| 6 | `my-ship-classes` | `localShipClass[]` | "no ship classes designed yet" |
| 7 | `foreign-ship-classes` | `otherShipClass[]`, one sub-table per race | "no foreign ship classes observed yet" |
| 8 | `battles` | `battleIds[]` (inactive monospace spans) | "no battles last turn" |
| 9 | `bombings` | `bombings[]`, wiped rows visually distinct | "no bombings last turn" |
| 10 | `approaching-groups` | `incomingShipGroups[]` | "no approaching groups" |
| 11 | `my-planets` | `planets.filter(kind==="local")` | "no planets owned yet" |
| 12 | `ships-in-production` | `shipProductions[]` | "no ships in production" |
| 13 | `cargo-routes` | `routes[]` (flattened to one row per entry) | "no cargo routes set" |
| 14 | `foreign-planets` | `planets.filter(kind==="other")` | "no foreign planets observed" |
| 15 | `uninhabited-planets` | `planets.filter(kind==="uninhabited")` | "no uninhabited planets observed" |
| 16 | `unknown-planets` | `planets.filter(kind==="unidentified")` | "no unknown planets" |
| 17 | `my-fleets` | `localFleets[]` | "no fleets created yet" |
| 18 | `my-ship-groups` | `localShipGroups[]` | "no ship groups yet" |
| 19 | `foreign-ship-groups` | `otherShipGroups[]` | "no foreign ship groups observed" |
| 20 | `unidentified-groups` | `unidentifiedShipGroups[]` | "no unidentified groups" |
The orchestrator iterates this list once for the TOC and once for
the body — both surfaces stay in sync by construction.
## Table of contents and active highlight
`report/report-toc.svelte` renders two surfaces driven by the same
entry list:
- **Desktop / tablet sidebar** — sticky `<aside>` with vertical
anchor list. The anchor for the currently-visible section gets
`aria-current="location"` and an `.active` CSS class.
- **Mobile (< 768 px)** — the desktop sidebar is hidden via CSS
and a sticky `<select>` takes its place at the top of the body.
Picking an option scrolls the matching section into view. The
mobile contract intentionally avoids stacking another overlay on
top of the existing layout-owned bottom-tabs.
Both surfaces also expose a "Back to map" affordance
(`report-back-to-map`) at the top.
The active slug is computed by an `IntersectionObserver` rooted on
the viewport (`root: null`) with `rootMargin: "-30% 0px -60% 0px"`.
The skew biases the active band toward the upper third of the
visible area so that scrolling down advances the highlight
naturally. The observer is created on mount and torn down on
unmount.
The in-game shell layout (`routes/games/[id]/+layout.svelte`)
expands `<main class="active-view-host">` to fit content rather
than constraining it, so the document body is the actual scroll
container — not the host. The IntersectionObserver root is `null`
to match.
## Scroll save / restore
`routes/games/[id]/report/+page.svelte` exports a SvelteKit
`Snapshot<{ scrollY: number }>`:
- `capture()` reads `window.scrollY` when SvelteKit's
`beforeNavigate` cycle runs.
- `restore(value)` schedules a short
`requestAnimationFrame` poll that waits for
`document.documentElement.scrollHeight` to grow tall enough to
honour the saved offset, then calls `window.scrollTo(0, value)`.
The poll caps at ~60 frames (≈ 1 second) so a never-tall-enough
body never pins a frame loop.
The capture / restore pair is keyed by route, so:
- Forward navigation from `/report` to `/map` lands `/map` at
scrollY 0 (no snapshot for `/map` to restore from).
- History-back from `/map` to `/report` restores the previously
captured scrollY — the user returns to the same section.
The Snapshot API does not capture the active sidebar slug; the
IntersectionObserver re-derives it from the restored scroll
position on the next animation frame, which keeps the TOC
highlight consistent without a second source of truth.
## i18n namespace
All Phase 23 strings live under `game.report.*`:
- `game.report.loading` — section loading placeholder.
- `game.report.back_to_map`, `game.report.toc.title`,
`game.report.toc.mobile_label` — shell-level strings.
- `game.report.section.<slug>.title` — section heading.
- `game.report.section.<slug>.empty` — empty-state copy (where
applicable).
- `game.report.section.<slug>.column.<column>` — column headings.
- A small number of section-specific keys (`bombings.wiped`,
`player_status.local_marker`, `player_status.extinct_marker`,
`foreign_sciences.race_header`, `foreign_ship_classes.race_header`,
`battles.id_label`, `votes.target_none`).
The namespace is intentionally separate from `game.table.*` even
where the data shape overlaps (e.g. sciences, ship classes); the
two surfaces evolve independently and a shared key set would
couple them silently.
## Test seams
- **Vitest** — four representative specs cover the four section
shapes: kv-list (`report-section-galaxy-summary.test.ts`), grid
with conditional row state (`report-section-bombings.test.ts`),
per-race sub-table (`report-section-foreign-sciences.test.ts`),
TOC (`report-toc.test.ts`). Each spec mounts the component
against a synthetic `RenderedReportSource`, so the orchestrator
/ IntersectionObserver are out of scope.
- **Playwright** — `tests/e2e/report-sections.spec.ts` exercises
the full integration: every TOC anchor lands its section in
view, the snapshot mechanism preserves `window.scrollY` on
history navigation, the back-to-map button reaches `/map`, the
mobile `<select>` scrolls to the chosen section on a narrow
viewport.
Test IDs follow the pattern `report-section-<slug>` for section
roots, `report-toc-<slug>` for TOC anchors, and per-section row
identifiers (e.g. `report-bombing-row`, `my-planets-row`).
+7 -5
View File
@@ -112,11 +112,13 @@ wipes every namespace.
Namespaces in current use: Namespaces in current use:
| Namespace | Key | Value type | Owner | | Namespace | Key | Value type | Owner |
|-----------------|---------------------|------------------|-----------------------------| |-----------------|--------------------------------|------------------|------------------------------------|
| `session` | `device-session-id` | `string` | Phase 7+ | | `session` | `device-session-id` | `string` | Phase 7+ |
| `game-prefs` | `{gameId}/wrap-mode` | `WrapMode` | Phase 11+ (`game-state.md`) | | `game-prefs` | `{gameId}/wrap-mode` | `WrapMode` | Phase 11+ (`game-state.md`) |
| `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | Phase 12+ (`order-composer.md`) | | `game-prefs` | `{gameId}/last-viewed-turn` | `number` | Phase 11+ (`game-state.md`) |
| `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | Phase 12+ (`order-composer.md`) |
| `game-history` | `{gameId}/turn/{N}` | `GameReport` | Phase 26+ (`game-state.md`) |
Later phases will add more per-feature namespaces (fixtures, lobby Later phases will add more per-feature namespaces (fixtures, lobby
snapshot, etc.). The contract is namespace-strings stay scoped to snapshot, etc.). The contract is namespace-strings stay scoped to
+217
View File
@@ -0,0 +1,217 @@
# UI sync protocol
Phase 25 wires the transport-level policy that keeps the local
order draft consistent with the gateway across two failure modes
that Phase 14 punted on: transient network outages and turn
cutoffs the player did not anticipate. The wiring also reacts to
admin-initiated game pauses signalled by the new `game.paused`
push event.
The contract lives at three layers:
- **Backend** owns the turn-cutoff guard and the auto-pause on
generation failure (`backend/internal/runtime/scheduler.go`,
`backend/internal/lobby/runtime_hooks.go`,
`backend/internal/server/handlers_user_games.go`); see
`docs/FUNCTIONAL.md §6.3` for the user-visible spec.
- **Gateway** translates backend's `httperr` envelope into the
`ExecuteCommandResponse` envelope without re-interpreting the
code; `turn_already_closed` and `game_paused` surface as
`resultCode` values verbatim.
- **UI** detects those codes in
[`src/sync/order-queue.svelte.ts`](../frontend/src/sync/order-queue.svelte.ts)
and projects them onto the
[`OrderDraftStore`](../frontend/src/sync/order-draft.svelte.ts)
state machine consumed by
[`order-tab.svelte`](../frontend/src/lib/sidebar/order-tab.svelte).
This document covers the UI side of the protocol — send-loop
semantics, retry budgets, conflict UX, paused UX, and the
recovery paths back to a normal `synced` state.
## Send-loop semantics
The order draft store no longer calls `submitOrder` directly. Every
auto-sync attempt goes through `OrderQueue.send(submitFn)`, which
acts as a thin policy gate:
```text
mutation ──▶ scheduleSync ──▶ runSync ──▶ queue.send(submitFn)
classify outcome:
- success
- rejected
- conflict
- paused
- offline
- failed
```
The classification is dispatched into the store:
| outcome | command status flip | syncStatus | banner side-effect |
| ----------- | ---------------------------------- | ---------- | ----------------------------------------- |
| `success` | per-command `applied` / `rejected` | `synced` | none |
| `rejected` | submitting → `rejected` | `error` | none (the row colour is enough) |
| `conflict` | submitting → `conflict` | `conflict` | `conflictBanner = { turn, code, message }` |
| `paused` | submitting → `valid` | `paused` | `pausedBanner = { reason, code, message }` |
| `offline` | submitting → `valid` | `offline` | none — the status bar carries the copy |
| `failed` | submitting → `valid` | `error` | `syncError = reason` |
Only one submit is in flight at a time. Mutations made during a
flight set `pending = true` so the loop runs one more iteration
with a fresh snapshot once the active call settles.
## Offline detection and retry budget
`OrderQueue.start` subscribes to the browser's `online` / `offline`
events and primes `OrderQueue.online` from `navigator.onLine`. The
queue intentionally treats offline as a fast-fail:
- A `send()` invocation with `online === false` returns
`{kind: "offline"}` without invoking `submitFn`. The draft store
reverts every submitting row back to `valid` and parks the loop
with `syncStatus = "offline"`. No further sends fire until the
browser re-emits `online`.
- When the browser flips back to `online`, the queue invokes the
`onOnline` callback supplied at `start()`. The draft store wires
this callback to `scheduleSync()` — exactly one new attempt per
online flip. The store's existing single-slot pending machinery
takes care of the rest (further mutations during that attempt
coalesce into one follow-up).
There is no inline retry inside `OrderQueue.send`. The
plan's "retry once on reconnect" budget is therefore literal:
- offline ⇒ hold (no attempt)
- next `online` event ⇒ one attempt
- attempt succeeds ⇒ `syncStatus = "synced"`
- attempt throws ⇒ `syncStatus = "error"` and the existing
manual-retry affordance in the order tab applies
A throw mid-flight while `navigator.onLine` is still `true` is
treated as a regular failure (`failed` outcome). A throw whose
`onlineProbe` check returns `false` collapses into `offline`, so
flaky connectivity does not get classified as a hard error.
## Conflict UX — `turn_already_closed`
When the gateway returns `resultCode = "turn_already_closed"` (or
the per-error-body `code` matches it), the queue emits a
`conflict` outcome. The store:
1. Marks every command that was in `submitting` as `conflict`
the matching row shows the new badge.
2. Records `conflictBanner = { turn, code, message }`. `turn` is
read from the `getCurrentTurn` callback the layout supplied at
`bindClient` (the turn the player was composing for); it may be
`null` in tests that omit the callback, in which case the
banner falls back to a turn-less template.
3. Sets `syncStatus = "conflict"`. The loop exits without firing
the `pending` follow-up — `scheduleSync` short-circuits while
the store is in conflict, so a flurry of keystrokes does not
re-elicit the same gateway reply.
The banner clears on either of two signals:
- **Any local mutation** (`add`, `remove`, `move`) calls
`clearConflictForMutation`, which drops the banner and
re-validates every `conflict`-marked command back through the
local validator. The mutation then auto-syncs as usual — likely
a fresh attempt against the new turn, often resulting in
`success`.
- **A `game.turn.ready` push** arriving while the store is in
`conflict` triggers `resetForNewTurn`. The local draft is
wiped, `hydrateFromServer` pulls the new turn's empty order,
and the banner clears. Old commands for the prior turn become
history (read-only) and live on the server's `user.games.order`
for `?turn=N`.
## Paused UX — `game_paused` / `game.paused`
The paused-banner has two entry points:
- **`Orders` handler reply with `code = "game_paused"`** — the
player attempted to submit while the game was paused. The
queue emits a `paused` outcome; the store reverts submitting
rows to `valid`, records `pausedBanner = { reason, code, message }`,
and locks `syncStatus = "paused"`.
- **`game.paused` push frame** — lobby published the event when
it flipped the game to `paused` (see backend §6.5). The layout
subscribes via `eventStream.on("game.paused", ...)` and calls
`orderDraft.markPaused({ reason })`, which arrives at the same
state via a different path.
While in `paused` the auto-sync loop refuses to fire (the
`scheduleSync` early-exit). The store's `hydrateFromServer` also
short-circuits if `syncStatus === "paused"` to avoid clobbering
the banner with a fresh `synced` flip.
Recovery is the same as conflict: a `game.turn.ready` push
clears the pause via `resetForNewTurn`. The matching admin flow
on the backend is an explicit `/resume` followed by a successful
scheduler tick that emits the next turn.
## Recovery via `resetForNewTurn`
`resetForNewTurn` is the single entry point that wipes both
banners and rebuilds the draft against a fresh turn:
```ts
async resetForNewTurn(opts: { client; turn }) {
this.commands = [];
this.statuses = {};
this.updatedAt = 0;
this.conflictBanner = null;
this.pausedBanner = null;
this.syncStatus = "idle";
this.syncError = null;
await this.persist();
await this.hydrateFromServer({ client, turn });
}
```
The layout calls it from the `game.turn.ready` subscription
whenever the prior `syncStatus` was either `conflict` or
`paused`. A regular turn advance (no banner active) keeps the
existing behaviour: `markPendingTurn` shows the toast and the
player chooses when to navigate; the local draft survives the
transition unchanged.
## Test surface
- **Vitest unit**:
[`tests/order-queue.test.ts`](../frontend/tests/order-queue.test.ts)
covers the queue's classification + online/offline plumbing in
isolation;
[`tests/order-draft.test.ts`](../frontend/tests/order-draft.test.ts)
covers the store's reaction to each outcome and the
`resetForNewTurn` / `markPaused` paths;
[`tests/order-tab.test.ts`](../frontend/tests/order-tab.test.ts)
asserts the banner DOM;
[`tests/events.test.ts`](../frontend/tests/events.test.ts)
pins the `game.paused` dispatch.
- **Playwright e2e**:
[`tests/e2e/order-sync.spec.ts`](../frontend/tests/e2e/order-sync.spec.ts)
drives the order tab through the conflict and paused-push
scenarios with mocked gateway and signed-event frames.
The offline-online round-trip is covered at the Vitest level
because Playwright's `context.setOffline(true)` is a coarse
network mute that conflicts with the dev-server bootstrap and
the in-test fixture key wiring; the store-level test uses
injected `onlineProbe` / `addEventListener` to drive the same
state machine deterministically.
## Known follow-ups
- Admin resume currently produces a `running → paused → running`
status flip on the lobby side without an explicit push event.
The UI relies on the next `game.turn.ready` for recovery; a
dedicated `game.resumed` event would let the banner clear
immediately without waiting for the next cron tick. Not part
of this phase.
- The conflict banner shows the player-facing template
unmodified; a future revision may interpolate the explicit
cutoff timestamp once the server adds it to the error body.
+88
View File
@@ -0,0 +1,88 @@
// Battle-report fetcher used by the Battle Viewer page.
//
// Phase 27 ships the BattleViewer as a logically isolated component
// that accepts a `BattleReport` matching `pkg/model/report/battle.go`.
// This module owns the type mirror and a single `fetchBattle` entry
// point. In synthetic mode (development & e2e fixtures), the loader
// falls back to a local fixture so the UI tests don't depend on a
// running engine; otherwise it issues a real `GET` against the
// backend gateway route added in Phase 27 step 3.
import { isSyntheticGameId } from "./synthetic-report";
import { lookupSyntheticBattle } from "./synthetic-battle";
/**
* BattleReport is the wire shape returned by the engine endpoint
* `GET /api/v1/battle/:turn/:uuid` and forwarded by the backend
* gateway as `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`.
* Fields mirror `pkg/model/report/battle.go`.
*/
export interface BattleReport {
id: string;
planet: number;
planetName: string;
races: Record<string, string>;
ships: Record<string, BattleReportGroup>;
protocol: BattleActionReport[];
}
export interface BattleReportGroup {
race: string;
className: string;
tech: Record<string, number>;
num: number;
numLeft: number;
loadType: string;
loadQuantity: number;
inBattle: boolean;
}
export interface BattleActionReport {
a: number;
sa: number;
d: number;
sd: number;
x: boolean;
}
export class BattleFetchError extends Error {
constructor(public readonly status: number, message: string) {
super(message);
this.name = "BattleFetchError";
}
}
/**
* fetchBattle returns the `BattleReport` for the supplied game, turn,
* and battle id. In synthetic-report mode (DEV / e2e) the lookup is
* served from `synthetic-battle.ts`; otherwise the function calls the
* backend gateway route. Throws `BattleFetchError` with the upstream
* status on validation or transport failure.
*/
export async function fetchBattle(
gameId: string,
turn: number,
battleId: string,
): Promise<BattleReport> {
if (isSyntheticGameId(gameId)) {
const fixture = lookupSyntheticBattle(battleId);
if (fixture === null) {
throw new BattleFetchError(404, "battle not found");
}
return fixture;
}
const path = `/api/v1/user/games/${encodeURIComponent(gameId)}/battles/${turn}/${encodeURIComponent(battleId)}`;
const response = await fetch(path, {
headers: { Accept: "application/json" },
});
if (response.status === 404) {
throw new BattleFetchError(404, "battle not found");
}
if (!response.ok) {
throw new BattleFetchError(
response.status,
`battle fetch failed: ${response.status}`,
);
}
return (await response.json()) as BattleReport;
}
+376
View File
@@ -0,0 +1,376 @@
// `EventStream` is the single SubscribeEvents consumer for the
// authenticated UI session. It opens one server-streaming RPC against
// the gateway, verifies every incoming event (payload-hash +
// Ed25519 signature) through `ui/core`, dispatches verified events to
// type-keyed handlers, and reconnects with full-jitter exponential
// backoff on transient failure.
//
// Phase 24 introduces this module in place of `revocation-watcher.ts`.
// The watcher's revocation semantics are absorbed: a clean
// end-of-stream while the session is authenticated, or an
// `Unauthenticated` ConnectError, both call `session.signOut("revoked")`.
// Per-event-type dispatch (turn-ready toasts in this phase; battle and
// mail toasts in later phases) is registered through `on(eventType,
// handler)`.
//
// The store exposes `connectionStatus` as a Svelte rune so the
// connection-state indicator in the shell header (see PLAN.md
// cross-cutting shell) can subscribe without ceremony. The indicator
// itself is not part of Phase 24, but the rune is wired here so the
// next phase that adds the dot can read it directly.
import { create } from "@bufbuild/protobuf";
import { ConnectError } from "@connectrpc/connect";
import type { Core } from "../platform/core/index";
import type { DeviceKeypair } from "../platform/store/index";
import {
GatewayEventSchema,
SubscribeEventsRequestSchema,
type GatewayEvent,
} from "../proto/galaxy/gateway/v1/edge_gateway_pb";
import { GATEWAY_BASE_URL } from "../lib/env";
import { session } from "../lib/session-store.svelte";
import { createEdgeGatewayClient, type EdgeGatewayClient } from "./connect";
const PROTOCOL_VERSION = "v1";
const SUBSCRIBE_MESSAGE_TYPE = "gateway.subscribe";
// Connect error code numerical values used by the watcher. The full
// enum lives in `@connectrpc/connect` but importing the runtime enum
// would pull a large surface into this small module.
const CONNECT_CODE_CANCELED = 1;
const CONNECT_CODE_UNAUTHENTICATED = 16;
const BACKOFF_BASE_MS = 1_000;
const BACKOFF_MAX_MS = 30_000;
/**
* VerifiedEvent is the verified projection of a `GatewayEvent` handed
* to user handlers. The signature and payload-hash fields are dropped
* because verification has already succeeded; consumers only need the
* envelope plus the opaque payload bytes.
*/
export interface VerifiedEvent {
eventType: string;
eventId: string;
timestampMs: bigint;
requestId: string;
traceId: string;
payloadBytes: Uint8Array;
}
export type EventHandler = (event: VerifiedEvent) => void;
export type ConnectionStatus =
| "idle"
| "connecting"
| "connected"
| "reconnecting"
| "offline";
/**
* EventStreamStartOptions carries the live primitives the stream
* consumer cannot resolve by itself. Production code reads `core`,
* `keypair`, and `deviceSessionId` from the session store and the
* gateway public key from `lib/env`; tests inject a fake
* `EdgeGatewayClient` and deterministic `sleep`/`random` to drive
* backoff in fake-timer mode.
*/
export interface EventStreamStartOptions {
core: Core;
keypair: DeviceKeypair;
deviceSessionId: string;
gatewayResponsePublicKey: Uint8Array;
/** Custom transport client. Defaults to `createEdgeGatewayClient(GATEWAY_BASE_URL)`. */
client?: EdgeGatewayClient;
/** Sleep hook for tests; defaults to a real-time `setTimeout`. */
sleep?: (ms: number) => Promise<void>;
/** Random source for full-jitter backoff; defaults to `Math.random`. */
random?: () => number;
/** Function reporting `navigator.onLine`; defaults to the browser global. */
onlineProbe?: () => boolean;
}
/**
* SignatureError marks a verification failure (payload-hash mismatch
* or invalid Ed25519 signature). The stream loop classifies it as a
* forgery and reconnects through the same backoff path used for
* transient transport errors.
*/
export class SignatureError extends Error {
constructor(message: string) {
super(message);
this.name = "SignatureError";
}
}
export class EventStream {
connectionStatus: ConnectionStatus = $state("idle");
private handlers = new Map<string, Set<EventHandler>>();
private controller: AbortController | null = null;
private running = false;
/**
* on registers a handler for a specific event type. Returns a
* disposer that removes the handler. Multiple handlers per type
* are supported so future phases (battle, mail) can subscribe
* alongside turn-ready without coordination.
*/
on(eventType: string, handler: EventHandler): () => void {
let bucket = this.handlers.get(eventType);
if (bucket === undefined) {
bucket = new Set();
this.handlers.set(eventType, bucket);
}
bucket.add(handler);
return () => {
const current = this.handlers.get(eventType);
if (current === undefined) {
return;
}
current.delete(handler);
if (current.size === 0) {
this.handlers.delete(eventType);
}
};
}
/**
* start opens the stream. Calling start while the stream is
* already running is a no-op so the root layout's `$effect`-based
* lifecycle stays idempotent across re-renders.
*/
start(opts: EventStreamStartOptions): void {
if (this.running) {
return;
}
this.running = true;
this.controller = new AbortController();
void this.run(opts, this.controller.signal);
}
/**
* stop tears down the stream. Used by the root layout on logout
* or unmount. Re-calling start after stop opens a fresh stream.
*/
stop(): void {
this.running = false;
if (this.controller !== null) {
this.controller.abort();
this.controller = null;
}
this.connectionStatus = "idle";
}
/**
* resetForTests is used by the Vitest harness to forget all
* handlers and force the rune back to `idle` between cases.
*/
resetForTests(): void {
this.stop();
this.handlers.clear();
}
private async run(
opts: EventStreamStartOptions,
signal: AbortSignal,
): Promise<void> {
const sleep = opts.sleep ?? defaultSleep;
const random = opts.random ?? Math.random;
const onlineProbe = opts.onlineProbe ?? defaultOnlineProbe;
const client = opts.client ?? createEdgeGatewayClient(GATEWAY_BASE_URL);
let attempt = 0;
while (!signal.aborted && this.running) {
this.connectionStatus = "connecting";
let stream: AsyncIterable<GatewayEvent>;
try {
stream = await openStream(client, opts, signal);
} catch (err) {
if (signal.aborted) {
return;
}
if (handleAuthenticationError(err)) {
return;
}
this.connectionStatus = onlineProbe() ? "reconnecting" : "offline";
await sleep(backoffDelay(attempt, random));
attempt += 1;
continue;
}
let firstEventSeen = false;
let terminated = false;
try {
for await (const event of stream) {
if (signal.aborted) {
return;
}
this.verifyEvent(event, opts);
if (!firstEventSeen) {
firstEventSeen = true;
this.connectionStatus = "connected";
attempt = 0;
}
this.dispatch(event);
}
terminated = true;
} catch (err) {
if (signal.aborted) {
return;
}
if (handleAuthenticationError(err)) {
return;
}
this.connectionStatus = onlineProbe() ? "reconnecting" : "offline";
await sleep(backoffDelay(attempt, random));
attempt += 1;
continue;
}
if (terminated) {
// Clean end-of-stream on an authenticated session is the
// gateway's documented session-invalidation signal.
if (session.status === "authenticated") {
await session.signOut("revoked");
return;
}
this.connectionStatus = "idle";
return;
}
}
}
private verifyEvent(event: GatewayEvent, opts: EventStreamStartOptions): void {
if (!opts.core.verifyPayloadHash(event.payloadBytes, event.payloadHash)) {
throw new SignatureError("event payload_hash mismatch");
}
const ok = opts.core.verifyEvent(
opts.gatewayResponsePublicKey,
event.signature,
{
eventType: event.eventType,
eventId: event.eventId,
timestampMs: event.timestampMs,
requestId: event.requestId,
traceId: event.traceId,
payloadHash: event.payloadHash,
},
);
if (!ok) {
throw new SignatureError("event signature verification failed");
}
}
private dispatch(event: GatewayEvent): void {
const bucket = this.handlers.get(event.eventType);
if (bucket === undefined || bucket.size === 0) {
return;
}
const projection: VerifiedEvent = {
eventType: event.eventType,
eventId: event.eventId,
timestampMs: event.timestampMs,
requestId: event.requestId,
traceId: event.traceId,
payloadBytes: event.payloadBytes,
};
for (const handler of [...bucket]) {
try {
handler(projection);
} catch (err) {
console.info("events: handler threw", event.eventType, err);
}
}
}
}
async function openStream(
client: EdgeGatewayClient,
opts: EventStreamStartOptions,
signal: AbortSignal,
): Promise<AsyncIterable<GatewayEvent>> {
const requestId = newRequestId();
const timestampMs = BigInt(Date.now());
const emptyPayload = new Uint8Array();
const payloadHash = await sha256(emptyPayload);
const canonical = opts.core.signRequest({
protocolVersion: PROTOCOL_VERSION,
deviceSessionId: opts.deviceSessionId,
messageType: SUBSCRIBE_MESSAGE_TYPE,
timestampMs,
requestId,
payloadHash,
});
const signature = await opts.keypair.sign(canonical);
const request = create(SubscribeEventsRequestSchema, {
protocolVersion: PROTOCOL_VERSION,
deviceSessionId: opts.deviceSessionId,
messageType: SUBSCRIBE_MESSAGE_TYPE,
timestampMs,
requestId,
payloadHash,
signature,
payloadBytes: emptyPayload,
});
return client.subscribeEvents(request, { signal });
}
function handleAuthenticationError(err: unknown): boolean {
if (!(err instanceof ConnectError)) {
return false;
}
if (err.code === CONNECT_CODE_CANCELED) {
return true;
}
if (err.code === CONNECT_CODE_UNAUTHENTICATED) {
void session.signOut("revoked");
return true;
}
return false;
}
function backoffDelay(attempt: number, random: () => number): number {
const cap = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** attempt);
return Math.floor(random() * cap);
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function defaultOnlineProbe(): boolean {
if (typeof navigator === "undefined") {
return true;
}
return navigator.onLine !== false;
}
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest(
"SHA-256",
payload as BufferSource,
);
return new Uint8Array(digest);
}
function newRequestId(): string {
if (typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
const buf = new Uint8Array(16);
crypto.getRandomValues(buf);
let hex = "";
for (let i = 0; i < buf.length; i++) {
hex += buf[i]!.toString(16).padStart(2, "0");
}
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
}
/**
* eventStream is the singleton stream consumer the root layout starts
* once the session becomes authenticated and stops on logout. Tests
* call `resetForTests()` between cases.
*/
export const eventStream = new EventStream();
+343
View File
@@ -38,12 +38,16 @@ import { Builder, ByteBuffer } from "flatbuffers";
import type { GalaxyClient } from "./galaxy-client"; import type { GalaxyClient } from "./galaxy-client";
import { UUID } from "../proto/galaxy/fbs/common"; import { UUID } from "../proto/galaxy/fbs/common";
import { import {
Bombing,
GameReportRequest, GameReportRequest,
IncomingGroup, IncomingGroup,
LocalFleet, LocalFleet,
LocalGroup, LocalGroup,
OtherGroup, OtherGroup,
OtherScience,
OthersShipClass,
Report, Report,
ShipProduction,
UnidentifiedGroup, UnidentifiedGroup,
} from "../proto/galaxy/fbs/report"; } from "../proto/galaxy/fbs/report";
import type { import type {
@@ -280,6 +284,125 @@ 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)`.
*/
/**
* ReportBattle is one battle summary in the current turn. Carries the
* battle UUID, planet number, and shot count — enough to render a
* battle marker on the map and to link into the Battle Viewer without
* fetching the full BattleReport.
*/
export interface ReportBattle {
id: string;
planet: number;
shots: number;
}
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 +512,56 @@ 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[];
/**
* battles is the list of battle summaries the engine recorded for
* the current turn. Each entry carries the battle UUID, the planet
* it happened on, and the number of shots exchanged. The Reports
* View uses `id` to link into the Battle Viewer; the map renderer
* uses `planet` to locate the marker and `shots` to scale its
* stroke. Empty when no battles occurred last turn.
*/
battles: ReportBattle[];
/**
* battleIds is a convenience derived list of UUIDs from `battles`,
* preserved for legacy callers (Phase 23 report section, fixtures).
*/
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 +710,18 @@ 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 battles = decodeBattles(report);
const battleIds = battles.map((b) => b.id);
const bombings = decodeBombings(report);
const shipProductions = decodeShipProductions(report);
return { return {
turn: Number(report.turn()), turn: Number(report.turn()),
@@ -566,6 +746,13 @@ function decodeReport(report: Report): GameReport {
races, races,
myVotes: report.votes(), myVotes: report.votes(),
myVoteFor: report.voteFor() ?? "", myVoteFor: report.voteFor() ?? "",
players,
otherScience,
otherShipClass,
battles,
battleIds,
bombings,
shipProductions,
}; };
} }
@@ -903,6 +1090,151 @@ 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 decodeBattles(report: Report): ReportBattle[] {
const out: ReportBattle[] = [];
for (let i = 0; i < report.battleLength(); i++) {
const summary = report.battle(i);
if (summary === null) continue;
const id = uuidStringFromFB(summary.id());
if (id === null) continue;
out.push({
id,
planet: Number(summary.planet()),
shots: Number(summary.shots()),
});
}
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 +1457,17 @@ 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 ?? [],
battles: report.battles ?? [],
battleIds: report.battleIds ?? [],
bombings: report.bombings ?? [],
shipProductions: report.shipProductions ?? [],
}; };
} }
+37
View File
@@ -0,0 +1,37 @@
// Synthetic battle reports for DEV / e2e mode.
//
// Mirrors the shape of `pkg/model/report/battle.go` so the
// BattleViewer can be exercised without a running engine. Fixtures
// are registered by battle UUID; the synthetic-report loader fills
// the report's `battles[]` with these same UUIDs so the report ↔
// battle link is consistent.
import type { BattleReport } from "./battle-fetch";
const SYNTHETIC_BATTLES = new Map<string, BattleReport>();
/**
* registerSyntheticBattle adds a fixture battle to the in-memory map
* keyed by its `id`. Used by the synthetic-report DEV loader and by
* Vitest unit tests that need a deterministic BattleReport without a
* live engine.
*/
export function registerSyntheticBattle(report: BattleReport): void {
SYNTHETIC_BATTLES.set(report.id, report);
}
/**
* lookupSyntheticBattle returns the fixture stored under `battleId`,
* or `null` if nothing was registered (mirrors the engine's 404).
*/
export function lookupSyntheticBattle(battleId: string): BattleReport | null {
return SYNTHETIC_BATTLES.get(battleId) ?? null;
}
/**
* resetSyntheticBattles clears every registered fixture. Test
* harnesses call this between cases to avoid bleed-through.
*/
export function resetSyntheticBattles(): void {
SYNTHETIC_BATTLES.clear();
}
+221 -2
View File
@@ -20,13 +20,18 @@
import type { import type {
GameReport, GameReport,
ReportBombing,
ReportIncomingShipGroup, ReportIncomingShipGroup,
ReportLocalFleet, ReportLocalFleet,
ReportLocalShipGroup, ReportLocalShipGroup,
ReportOtherRace, ReportOtherRace,
ReportOtherScience,
ReportOtherShipClass,
ReportOtherShipGroup, ReportOtherShipGroup,
ReportPlanet, ReportPlanet,
ReportPlayer,
ReportRoute, ReportRoute,
ReportShipProduction,
ReportUnidentifiedShipGroup, ReportUnidentifiedShipGroup,
ScienceSummary, ScienceSummary,
ShipClassSummary, ShipClassSummary,
@@ -34,6 +39,8 @@ import type {
} from "./game-state"; } from "./game-state";
import type { CargoLoadType, Relation } from "../sync/order-types"; import type { CargoLoadType, Relation } from "../sync/order-types";
import { isCargoLoadType, isRelation } from "../sync/order-types"; import { isCargoLoadType, isRelation } from "../sync/order-types";
import type { BattleReport } from "./battle-fetch";
import { registerSyntheticBattle } from "./synthetic-battle";
export const SYNTHETIC_GAME_ID_PREFIX = "synthetic-"; export const SYNTHETIC_GAME_ID_PREFIX = "synthetic-";
@@ -54,18 +61,71 @@ export class SyntheticReportError extends Error {
* loadSyntheticReportFromJSON validates the passed payload, decodes * loadSyntheticReportFromJSON validates the passed payload, decodes
* it into a `GameReport`, registers it in the in-memory map under a * it into a `GameReport`, registers it in the in-memory map under a
* fresh `synthetic-<uuid>` id, and returns both the id and the * fresh `synthetic-<uuid>` id, and returns both the id and the
* decoded report. Throws `SyntheticReportError` for malformed input. * decoded report.
*
* Accepts two on-disk shapes:
*
* 1. Envelope (Phase 27 legacy-report CLI):
* `{ "version": 1, "report": <Report>, "battles": { <uuid>: <BattleReport> } }`
* — battles are forwarded to `registerSyntheticBattle` so the
* Battle Viewer can resolve them offline.
* 2. Bare Report (pre-envelope synthetic JSON files) — same as
* before; battle UUIDs in the report can still be clicked, but
* the Viewer page will show "battle not found" because no
* fixture was registered.
*
* Throws `SyntheticReportError` for malformed input in either shape.
*/ */
export function loadSyntheticReportFromJSON(json: unknown): { export function loadSyntheticReportFromJSON(json: unknown): {
gameId: string; gameId: string;
report: GameReport; report: GameReport;
} { } {
const report = decodeSyntheticReport(json); const { reportPayload, battles } = extractEnvelope(json);
const report = decodeSyntheticReport(reportPayload);
for (const battle of battles) {
registerSyntheticBattle(battle);
}
const gameId = SYNTHETIC_GAME_ID_PREFIX + crypto.randomUUID(); const gameId = SYNTHETIC_GAME_ID_PREFIX + crypto.randomUUID();
SYNTHETIC_REPORTS.set(gameId, report); SYNTHETIC_REPORTS.set(gameId, report);
return { gameId, report }; return { gameId, report };
} }
interface SyntheticEnvelope {
version?: number;
report?: unknown;
battles?: Record<string, BattleReport>;
}
/**
* extractEnvelope distinguishes the v1 envelope shape from a bare
* Report payload. The envelope check is `version === 1` to leave room
* for future format bumps and to avoid mistaking a bare Report whose
* top-level fields happen to include `report`/`battles` (none do
* today) for an envelope.
*/
function extractEnvelope(json: unknown): {
reportPayload: unknown;
battles: BattleReport[];
} {
if (typeof json !== "object" || json === null) {
// Defer the error to `decodeSyntheticReport`; it already
// raises a `SyntheticReportError` with the right message.
return { reportPayload: json, battles: [] };
}
const env = json as SyntheticEnvelope;
if (env.version === 1 && env.report !== undefined) {
const battlesMap = env.battles ?? {};
const battles: BattleReport[] = [];
for (const value of Object.values(battlesMap)) {
if (value && typeof value === "object") {
battles.push(value);
}
}
return { reportPayload: env.report, battles };
}
return { reportPayload: json, battles: [] };
}
/** getSyntheticReport returns the report registered under `gameId`, /** getSyntheticReport returns the report registered under `gameId`,
* or `undefined` if the entry was lost (e.g. page reload). */ * or `undefined` if the entry was lost (e.g. page reload). */
export function getSyntheticReport(gameId: string): GameReport | undefined { export function getSyntheticReport(gameId: string): GameReport | undefined {
@@ -159,6 +219,45 @@ interface SyntheticScience {
cargo?: number; cargo?: number;
} }
interface SyntheticOtherScience extends SyntheticScience {
race?: string;
}
interface SyntheticOtherShipClass extends SyntheticShipClass {
race?: string;
mass?: number;
}
interface SyntheticBattle {
id?: string;
planet?: number;
shots?: 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 +272,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?: SyntheticBattle[];
bombing?: SyntheticBombing[];
shipProduction?: SyntheticShipProductionRow[];
} }
function decodeSyntheticReport(json: unknown): GameReport { function decodeSyntheticReport(json: unknown): GameReport {
@@ -278,6 +382,86 @@ 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 battles = (root.battle ?? [])
.filter(
(v): v is SyntheticBattle =>
typeof v === "object" && v !== null && typeof v.id === "string" && v.id !== "",
)
.map((b) => ({
id: b.id as string,
planet: numOr0(b.planet),
shots: numOr0(b.shots),
}));
const battleIds = battles.map((b) => b.id);
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 +485,44 @@ 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,
battles,
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,
+6
View File
@@ -5,6 +5,12 @@
<link rel="icon" href="%sveltekit.assets%/favicon.svg" /> <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Galaxy</title> <title>Galaxy</title>
<style>
html,
body {
margin: 0;
}
</style>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
+157 -15
View File
@@ -1,30 +1,172 @@
<!-- <!--
Phase 10 stub for the battle-log active view. Phase 27 wires the real Phase 27 — active-view wrapper around the BattleViewer. Loads the
battle viewer. BattleReport for the supplied `gameId`/`turn`/`battleId` and either
shows the radial playback (BattleViewer), a loading skeleton, or a
not-found state.
This wrapper also bridges the surrounding GameReport's ship-class
tables into a `(race, className) → ShipClassRef` lookup the viewer
needs to size class circles by ship mass. The back-navigation
buttons (`back to map` / `back to report`) live INSIDE the viewer
header now — we just hand the routes down as callbacks so the
viewer keeps its prop-driven contract.
--> -->
<script lang="ts"> <script lang="ts">
import { i18n } from "$lib/i18n/index.svelte"; import { getContext } from "svelte";
import { goto } from "$app/navigation";
type Props = { battleId: string }; import {
let { battleId }: Props = $props(); BattleFetchError,
fetchBattle,
type BattleReport,
} from "../../api/battle-fetch";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
MapShipClassLookup,
type ShipClassLookup,
type ShipClassRef,
} from "../battle-player/mass";
import BattleViewer from "../battle-player/battle-viewer.svelte";
let {
gameId,
turn,
battleId,
}: {
gameId: string;
turn: number;
battleId: string;
} = $props();
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const shipClassLookup = $derived.by<ShipClassLookup>(() => {
const map = new Map<string, ShipClassRef>();
const report = rendered?.report;
if (report) {
for (const cls of report.localShipClass) {
map.set(`${report.race}::${cls.name}`, {
drive: cls.drive,
weapons: cls.weapons,
armament: cls.armament,
shields: cls.shields,
cargo: cls.cargo,
});
}
for (const cls of report.otherShipClass) {
map.set(`${cls.race}::${cls.name}`, {
drive: cls.drive,
weapons: cls.weapons,
armament: cls.armament,
shields: cls.shields,
cargo: cls.cargo,
});
}
}
return new MapShipClassLookup(map);
});
let state = $state<
| { kind: "loading" }
| { kind: "ready"; report: BattleReport }
| { kind: "not_found" }
| { kind: "error"; message: string }
>({ kind: "loading" });
$effect(() => {
if (!battleId) {
state = { kind: "not_found" };
return;
}
state = { kind: "loading" };
fetchBattle(gameId, turn, battleId)
.then((report) => {
state = { kind: "ready", report };
})
.catch((err: unknown) => {
if (err instanceof BattleFetchError && err.status === 404) {
state = { kind: "not_found" };
} else {
state = {
kind: "error",
message: err instanceof Error ? err.message : String(err),
};
}
});
});
function backToReport() {
goto(`/games/${gameId}/report`);
}
function backToMap() {
goto(`/games/${gameId}/map`);
}
</script> </script>
<section class="active-view" data-testid="active-view-battle" data-battle-id={battleId}> <section
<h2>{i18n.t("game.view.battle")}</h2> class="active-view"
<p>{i18n.t("game.shell.coming_soon")}</p> data-testid="active-view-battle"
data-battle-id={battleId}
>
{#if state.kind === "loading"}
<p class="status" data-testid="battle-loading">
{i18n.t("game.battle.loading")}
</p>
{:else if state.kind === "ready"}
<BattleViewer
report={state.report}
{shipClassLookup}
onBackToMap={backToMap}
onBackToReport={backToReport}
/>
{:else if state.kind === "not_found"}
<p class="status" data-testid="battle-not-found">
{i18n.t("game.battle.not_found")}
</p>
{:else}
<p class="status error" data-testid="battle-error">{state.message}</p>
{/if}
</section> </section>
<style> <style>
.active-view { .active-view {
padding: 1.5rem; display: flex;
flex-direction: column;
/*
* The in-game shell renders this active view inside an
* `.active-view-host` with `flex: 1; overflow-y: auto`, but
* the surrounding `.game-shell` uses `min-height: 100vh`, so
* without a hard upper bound the viewer pushes the whole
* shell past the viewport. We pin the active view to `100dvh`
* minus a small allowance for the header chrome (in-game
* Header + optional HistoryBanner ≈ 66 px on desktop) so the
* internal flex chain can split the remaining height between
* the scene, scrubber, controls and log without forcing a
* page-level scroll.
*/
height: calc(100dvh - 80px);
max-height: calc(100dvh - 80px);
min-height: 0;
overflow: hidden;
box-sizing: border-box;
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
color: #d6dcf2;
} }
.active-view h2 { .status {
margin: 0 0 0.5rem; margin: 2rem auto;
font-size: 1.1rem; max-width: 880px;
color: #93a0d0;
font-size: 0.95rem;
text-align: center;
} }
.active-view p { .status.error {
margin: 0; color: #e08585;
color: #555;
} }
</style> </style>
+34 -5
View File
@@ -21,6 +21,8 @@ preference the store already manages.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext, onDestroy, onMount, untrack } from "svelte"; import { getContext, onDestroy, onMount, untrack } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { import {
createRenderer, createRenderer,
@@ -402,13 +404,40 @@ preference the store already manages.
if (selection === undefined) return; if (selection === undefined) return;
const hit = handle.hitAt(cursorPx); const hit = handle.hitAt(cursorPx);
if (hit === null) return; if (hit === null) return;
if (hit.primitive.kind !== "point") return;
const target = hitLookup.get(hit.primitive.id); const target = hitLookup.get(hit.primitive.id);
if (target === undefined) return; if (target === undefined) return;
if (target.kind === "planet") { switch (target.kind) {
selection.selectPlanet(target.number); case "planet":
} else { if (hit.primitive.kind !== "point") return;
selection.selectShipGroup(target.ref); selection.selectPlanet(target.number);
break;
case "shipGroup":
if (hit.primitive.kind !== "point") return;
selection.selectShipGroup(target.ref);
break;
case "battle": {
const gameId = page.params.id ?? "";
const turn = store?.report?.turn ?? 0;
void goto(
`/games/${gameId}/battle/${target.battleId}?turn=${turn}`,
);
break;
}
case "bombing": {
const gameId = page.params.id ?? "";
void goto(
`/games/${gameId}/report#report-bombings`,
).then(() => {
if (typeof document === "undefined") return;
const row = document.querySelector(
`[data-testid="report-bombing-row"][data-planet="${target.planet}"]`,
);
if (row && row.scrollIntoView) {
row.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
break;
}
} }
} }
+169 -16
View File
@@ -1,28 +1,181 @@
<!-- <!--
Phase 10 stub for the turn-report active view. Phase 23 replaces the Phase 23 turn-report active view.
body with the per-turn sections (cargo deliveries, completed sciences,
mail, etc.). Composes the table of contents (`report/report-toc.svelte`) and the
twenty section components that render each `GameReport` array. Each
section is its own component under `lib/active-view/report/` — the
data shapes are too varied for one generic table, and the
component-per-section seam matches Phase 23's targeted-test contract.
Active-section highlighting and scroll save/restore land here:
- `IntersectionObserver` rooted on the active-view-host element
(`bind:this` in `+layout.svelte`, plumbed through
`ACTIVE_VIEW_HOST_CONTEXT_KEY`) watches every `<section
id="report-<slug>">` and updates a local `activeSlug` rune.
- The matching `+page.svelte` exports a SvelteKit `Snapshot` that
captures and restores `host.element.scrollTop`, so navigating to
/map and back lands on the same scroll position. The save lives in
`+page.svelte` because SvelteKit binds snapshots per route.
The 20-section list lives here as a single source of truth so the
TOC and the body iterate the same data.
--> -->
<script lang="ts"> <script lang="ts">
import { i18n } from "$lib/i18n/index.svelte"; import { onMount } from "svelte";
import { page } from "$app/state";
import ReportToc, {
type TocEntry,
} from "./report/report-toc.svelte";
import SectionGalaxySummary from "./report/section-galaxy-summary.svelte";
import SectionVotes from "./report/section-votes.svelte";
import SectionPlayerStatus from "./report/section-player-status.svelte";
import SectionMySciences from "./report/section-my-sciences.svelte";
import SectionForeignSciences from "./report/section-foreign-sciences.svelte";
import SectionMyShipClasses from "./report/section-my-ship-classes.svelte";
import SectionForeignShipClasses from "./report/section-foreign-ship-classes.svelte";
import SectionBattles from "./report/section-battles.svelte";
import SectionBombings from "./report/section-bombings.svelte";
import SectionApproachingGroups from "./report/section-approaching-groups.svelte";
import SectionMyPlanets from "./report/section-my-planets.svelte";
import SectionShipsInProduction from "./report/section-ships-in-production.svelte";
import SectionCargoRoutes from "./report/section-cargo-routes.svelte";
import SectionForeignPlanets from "./report/section-foreign-planets.svelte";
import SectionUninhabitedPlanets from "./report/section-uninhabited-planets.svelte";
import SectionUnknownPlanets from "./report/section-unknown-planets.svelte";
import SectionMyFleets from "./report/section-my-fleets.svelte";
import SectionMyShipGroups from "./report/section-my-ship-groups.svelte";
import SectionForeignShipGroups from "./report/section-foreign-ship-groups.svelte";
import SectionUnidentifiedGroups from "./report/section-unidentified-groups.svelte";
const ENTRIES: readonly TocEntry[] = [
{ slug: "galaxy-summary", titleKey: "game.report.section.galaxy_summary.title" },
{ slug: "votes", titleKey: "game.report.section.votes.title" },
{ slug: "player-status", titleKey: "game.report.section.player_status.title" },
{ slug: "my-sciences", titleKey: "game.report.section.my_sciences.title" },
{ slug: "foreign-sciences", titleKey: "game.report.section.foreign_sciences.title" },
{ slug: "my-ship-classes", titleKey: "game.report.section.my_ship_classes.title" },
{ slug: "foreign-ship-classes", titleKey: "game.report.section.foreign_ship_classes.title" },
{ slug: "battles", titleKey: "game.report.section.battles.title" },
{ slug: "bombings", titleKey: "game.report.section.bombings.title" },
{ slug: "approaching-groups", titleKey: "game.report.section.approaching_groups.title" },
{ slug: "my-planets", titleKey: "game.report.section.my_planets.title" },
{ slug: "ships-in-production", titleKey: "game.report.section.ships_in_production.title" },
{ slug: "cargo-routes", titleKey: "game.report.section.cargo_routes.title" },
{ slug: "foreign-planets", titleKey: "game.report.section.foreign_planets.title" },
{ slug: "uninhabited-planets", titleKey: "game.report.section.uninhabited_planets.title" },
{ slug: "unknown-planets", titleKey: "game.report.section.unknown_planets.title" },
{ slug: "my-fleets", titleKey: "game.report.section.my_fleets.title" },
{ slug: "my-ship-groups", titleKey: "game.report.section.my_ship_groups.title" },
{ slug: "foreign-ship-groups", titleKey: "game.report.section.foreign_ship_groups.title" },
{ slug: "unidentified-groups", titleKey: "game.report.section.unidentified_groups.title" },
];
const gameId = $derived(page.params.id ?? "");
let activeSlug = $state<string>(ENTRIES[0]?.slug ?? "");
let bodyEl: HTMLDivElement | null = $state(null);
// `IntersectionObserver` rooted on the viewport (`root: null`)
// lets the TOC highlight follow the section currently in the
// upper portion of the visible area. The in-game shell layout
// expands the active-view-host to fit content rather than
// constraining it, so the document body scrolls — not the host.
// Targeting the viewport with a top-skewed `rootMargin` advances
// the highlight as a section enters the upper third of what the
// reader sees, without coupling to the layout's internal sizing.
onMount(() => {
if (typeof IntersectionObserver === "undefined") return;
const body = bodyEl;
if (body === null) return;
const targets = body.querySelectorAll<HTMLElement>("section[id^='report-']");
if (targets.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
let pick: { slug: string; ratio: number } | null = null;
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const slug = entry.target.id.replace(/^report-/, "");
if (pick === null || entry.intersectionRatio > pick.ratio) {
pick = { slug, ratio: entry.intersectionRatio };
}
}
if (pick !== null) {
activeSlug = pick.slug;
}
},
{
root: null,
rootMargin: "-30% 0px -60% 0px",
threshold: [0, 0.25, 0.5, 0.75, 1],
},
);
targets.forEach((t) => observer.observe(t));
return () => observer.disconnect();
});
</script> </script>
<section class="active-view" data-testid="active-view-report"> <div class="report-view" data-testid="active-view-report">
<h2>{i18n.t("game.view.report")}</h2> <ReportToc entries={ENTRIES} {activeSlug} {gameId} />
<p>{i18n.t("game.shell.coming_soon")}</p>
</section> <div class="report-body" bind:this={bodyEl}>
<SectionGalaxySummary />
<SectionVotes />
<SectionPlayerStatus />
<SectionMySciences />
<SectionForeignSciences />
<SectionMyShipClasses />
<SectionForeignShipClasses />
<SectionBattles />
<SectionBombings />
<SectionApproachingGroups />
<SectionMyPlanets />
<SectionShipsInProduction />
<SectionCargoRoutes />
<SectionForeignPlanets />
<SectionUninhabitedPlanets />
<SectionUnknownPlanets />
<SectionMyFleets />
<SectionMyShipGroups />
<SectionForeignShipGroups />
<SectionUnidentifiedGroups />
</div>
</div>
<style> <style>
.active-view { .report-view {
padding: 1.5rem; display: grid;
grid-template-columns: 14rem 1fr;
gap: 1.25rem;
padding: 1rem 1.25rem 2rem;
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
} }
.active-view h2 { .report-view > :global(.report-toc) {
margin: 0 0 0.5rem; position: sticky;
font-size: 1.1rem; top: 0;
align-self: start;
padding: 0.5rem 0;
max-height: calc(100vh - 4rem);
overflow-y: auto;
} }
.active-view p { .report-body {
margin: 0; min-width: 0;
color: #555; display: flex;
flex-direction: column;
gap: 1.75rem;
}
@media (max-width: 767.98px) {
.report-view {
grid-template-columns: 1fr;
padding: 0.75rem;
gap: 0.75rem;
}
.report-view > :global(.report-toc) {
position: sticky;
top: 0;
background: #0a0e1a;
padding: 0.5rem 0;
z-index: 5;
}
} }
</style> </style>
@@ -0,0 +1,75 @@
// Shared number / planet formatters for the Phase 23 Report View
// sections. Inlined in 10+ components, so factoring keeps each
// section component focused on its data shape. The formatters
// match the conventions of the per-entity tables (tabular numerals,
// one-decimal percent without a `%` suffix — the header carries the
// unit) so the report's grids read the same way as the
// table-races / table-sciences views.
import type { ReportPlanet } from "../../../api/game-state";
/**
* formatPercent renders a `[0, 1]` fraction as a one-decimal
* percent (without a `%` suffix — the column header carries the
* unit). Matches the convention used by `table-races.svelte` and
* `table-sciences.svelte`.
*/
export function formatPercent(fraction: number): string {
return (fraction * 100).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
}
/**
* formatCount renders an integer-ish value (population, industry,
* planet count, …) without fractional digits and with locale-aware
* thousand separators.
*/
export function formatCount(value: number): string {
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
}
/**
* formatFloat renders a floating-point value with up to two
* fractional digits. Used for stockpiles, distances, cost, mass —
* everything the engine emits as a `Float` that is not a fraction.
*/
export function formatFloat(value: number): string {
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}
/**
* formatVotes renders a vote weight with up to two decimal digits —
* mirrors the races table's column convention so the cumulative
* vote totals line up across views.
*/
export function formatVotes(value: number): string {
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}
/**
* planetLabel renders a planet reference as `#<number> (<name>)` if
* the planet is known in the report, or just `#<number>` if the
* lookup fails (visibility lost between turns, foreign-only data).
* Sections that show planet numbers without a name column —
* Ships in Production, Bombings — rely on this resolver to keep
* cell width tight.
*/
export function planetLabel(
number: number,
planets: readonly ReportPlanet[],
): string {
const p = planets.find((row) => row.number === number);
if (p === undefined || p.name === "") return `#${number}`;
return `#${number} (${p.name})`;
}
@@ -0,0 +1,202 @@
<!--
Phase 23 Report View table of contents.
Responsibilities:
- "Back to map" button at the top — visible on both desktop sidebar
and mobile sticky toolbar. Navigates via `$app/navigation.goto` so
active-view-host scroll restoration plays through SvelteKit's
history machinery and the layout's `mobileTool` resets naturally.
- Desktop / tablet sidebar: a vertical list of anchor links, one per
section. The active link gets `aria-current="location"` and a
`.active` style. Click scrolls the active-view-host (not the
window) by calling `scrollIntoView` on the matching section.
- Mobile (`max-width: 767.98px`): the sidebar collapses to a sticky
`<select>` at the top of the body — a minimal contract that does
not stack with the layout's bottom-tab bar. The same option list
drives both surfaces.
The active section is computed by the orchestrator
(`report.svelte`) via `IntersectionObserver` and passed in via the
`activeSlug` prop. The TOC itself owns no observers.
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
export interface TocEntry {
slug: string;
titleKey: TranslationKey;
}
type Props = {
entries: readonly TocEntry[];
activeSlug: string;
gameId: string;
};
let { entries, activeSlug, gameId }: Props = $props();
function scrollToSlug(slug: string): void {
const target = document.getElementById(`report-${slug}`);
if (target === null) return;
const reduced = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
target.scrollIntoView({
behavior: reduced ? "auto" : "smooth",
block: "start",
});
}
function onAnchorClick(event: MouseEvent, slug: string): void {
event.preventDefault();
scrollToSlug(slug);
}
function onSelectChange(event: Event): void {
const select = event.currentTarget as HTMLSelectElement;
const slug = select.value;
if (slug === "") return;
scrollToSlug(slug);
}
async function backToMap(): Promise<void> {
await goto(`/games/${gameId}/map`);
}
</script>
<aside
class="report-toc"
data-testid="report-toc"
aria-label={i18n.t("game.report.toc.title")}
>
<button
type="button"
class="back-to-map"
data-testid="report-back-to-map"
onclick={() => void backToMap()}
>
{i18n.t("game.report.back_to_map")}
</button>
<nav class="desktop" aria-label={i18n.t("game.report.toc.title")}>
<ul>
{#each entries as entry (entry.slug)}
<li>
<a
href={`#report-${entry.slug}`}
class:active={activeSlug === entry.slug}
aria-current={activeSlug === entry.slug
? "location"
: undefined}
data-testid="report-toc-{entry.slug}"
onclick={(e) => onAnchorClick(e, entry.slug)}
>
{i18n.t(entry.titleKey)}
</a>
</li>
{/each}
</ul>
</nav>
<label class="mobile">
<span class="visually-hidden">
{i18n.t("game.report.toc.mobile_label")}
</span>
<select
data-testid="report-toc-mobile"
value={activeSlug}
onchange={onSelectChange}
>
{#each entries as entry (entry.slug)}
<option value={entry.slug}>{i18n.t(entry.titleKey)}</option>
{/each}
</select>
</label>
</aside>
<style>
.report-toc {
display: flex;
flex-direction: column;
gap: 0.75rem;
font-family: system-ui, sans-serif;
}
.back-to-map {
font: inherit;
font-size: 0.85rem;
text-align: left;
padding: 0.4rem 0.6rem;
background: #11172a;
color: #cfd7ff;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.back-to-map:hover {
background: #1a2240;
color: #e8eaf6;
}
.desktop {
display: block;
}
.desktop ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.desktop a {
display: block;
padding: 0.3rem 0.6rem;
color: #aab;
text-decoration: none;
font-size: 0.85rem;
line-height: 1.3;
border-left: 2px solid transparent;
border-radius: 0 3px 3px 0;
}
.desktop a:hover {
color: #e8eaf6;
background: #11172a;
}
.desktop a.active {
color: #e8eaf6;
background: #11172a;
border-left-color: #4a6cf7;
}
.mobile {
display: none;
}
.mobile select {
width: 100%;
font: inherit;
padding: 0.4rem 0.5rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 767.98px) {
.desktop {
display: none;
}
.mobile {
display: block;
}
}
</style>
@@ -0,0 +1,99 @@
<!--
Phase 23 Report View — approaching groups section. Renders the wire
`incomingGroup[]` projection as a compact grid: origin → destination
along with distance / speed / mass. The wire field carries no
ship-class info (a true blip on radar); the player only learns the
class when the group lands and a battle roster forms.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import { formatFloat, planetLabel } from "./format";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.incomingShipGroups ?? []);
const planets = $derived(report?.planets ?? []);
</script>
<section
id="report-approaching-groups"
class="grid-section"
data-testid="report-section-approaching-groups"
>
<h2>{i18n.t("game.report.section.approaching_groups.title")}</h2>
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if rows.length === 0}
<p class="status" data-testid="approaching-groups-empty">
{i18n.t("game.report.section.approaching_groups.empty")}
</p>
{:else}
<table class="grid" data-testid="approaching-groups-table">
<thead>
<tr>
<th>{i18n.t("game.report.section.approaching_groups.column.from")}</th>
<th>{i18n.t("game.report.section.approaching_groups.column.to")}</th>
<th>
{i18n.t("game.report.section.approaching_groups.column.distance")}
</th>
<th>{i18n.t("game.report.section.approaching_groups.column.speed")}</th>
<th>{i18n.t("game.report.section.approaching_groups.column.mass")}</th>
</tr>
</thead>
<tbody>
{#each rows as r, i (i)}
<tr data-testid="approaching-groups-row">
<td>{planetLabel(r.origin, planets)}</td>
<td>{planetLabel(r.destination, planets)}</td>
<td>{formatFloat(r.distance)}</td>
<td>{formatFloat(r.speed)}</td>
<td>{formatFloat(r.mass)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #e8eaf6;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
</style>
@@ -0,0 +1,100 @@
<!--
Phase 27 Report View — battles section. Each row is a link into the
Battle Viewer at `/games/<id>/battle/<uuid>?turn=<turn>` where
`turn` follows the current report's turn so history-mode views land
on the right battle. Phase 23 rendered the same rows as inactive
monospace `<span>`; the rewire here is the one-liner the Phase 23
decision log called out.
-->
<script lang="ts">
import { getContext } from "svelte";
import { page } from "$app/state";
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 battles = $derived(report?.battles ?? []);
const gameId = $derived(page.params.id ?? "");
const turn = $derived(report?.turn ?? 0);
</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 battles.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 battles as b (b.id)}
<li>
<span class="label">
{i18n.t("game.report.section.battles.id_label")}
</span>
<a
class="uuid"
href={`/games/${gameId}/battle/${b.id}?turn=${turn}`}
data-testid="report-battle-row"
data-id={b.id}
>{b.id}</a>
</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;
text-decoration: underline;
text-underline-offset: 2px;
}
.uuid:hover {
color: #ffffff;
}
</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>
@@ -0,0 +1,438 @@
<!--
BattleScene — radial SVG visualisation of one battle frame.
Layout: planet at the centre, race anchors equally spaced on an
outer ring, each race rendered as a *cloud* of class circles
arranged on a Vogel sunflower spiral. Spiral positions are
reassigned per rank by their inward distance toward the planet so
the rank-0 bucket (the bucket with the largest initial ship count)
always sits at the most-inward Vogel slot.
Tech-variant groups of the same `(race, className)` collapse to one
visual node — per-tech detail lives in Reports. Each circle's
radius scales with the per-ship FullMass (sqrt) so heavy ships
visually dominate. Order, position, radius and mass are locked at
battle start; only NumberLeft (the label number) and per-bucket
visibility change per frame. Empty buckets are hidden so the
remaining ones keep their original spots without reshuffling.
Observer groups (`inBattle === false`) are filtered out by
`buildFrames`. Same-race opponents are forbidden by the engine's
combat filter, so a shot never collapses to a single visual node.
-->
<script lang="ts">
import { getContext } from "svelte";
import type { BattleReport } from "../../api/battle-fetch";
import {
CORE_CONTEXT_KEY,
type CoreHandle,
} from "$lib/core-context.svelte";
import { layoutRaces } from "./radial-layout";
import {
computeBattleGroupMass,
radiusForMass,
MAX_RADIUS,
type ShipClassLookup,
} from "./mass";
import {
buildGroupRaceMap,
normaliseGroups,
type Frame,
} from "./timeline";
let {
report,
frame,
shipClassLookup,
shotVisible = true,
}: {
report: BattleReport;
frame: Frame;
shipClassLookup?: ShipClassLookup;
shotVisible?: boolean;
} = $props();
const VIEW_BOX = 800;
const CENTER = { x: VIEW_BOX / 2, y: VIEW_BOX / 2 };
const PLANET_RADIUS = 60;
const RACE_RING_RADIUS = 280;
const BASE_STEP = 1.8 * MAX_RADIUS;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const MAX_CLUSTER_RADIUS = 0.6 * (RACE_RING_RADIUS - PLANET_RADIUS);
const LABEL_MIN_Y = 24;
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
const groupRace = $derived(buildGroupRaceMap(report.protocol));
const allGroups = $derived(normaliseGroups(report));
type StaticBucket = {
bucketKey: string;
className: string;
race: string;
raceId: number;
groupKeys: number[];
initialNum: number;
mass: number;
radius: number;
// Local offsets in the cluster's (u, v) basis. `u` always
// points from the race anchor toward the planet, so a
// constant local-frame layout produces the same "inward" feel
// regardless of which slot on the outer ring the race
// currently occupies (races rotate when peers die).
offsetU: number;
offsetV: number;
};
// staticBucketsByRace locks the bucket roster, ordering, masses,
// radii and local positions for the lifetime of this viewer. The
// derivation only re-runs when `report` or the wasm `core` flip
// (initial mount and core boot completion). Per-frame NumberLeft
// changes do not touch this map — they live in `renderedByRace`.
const staticBucketsByRace = $derived.by(() => {
const core = coreHandle?.core ?? null;
const out = new Map<number, StaticBucket[]>();
const bucketIndex = new Map<string, StaticBucket>();
for (const g of allGroups) {
const bucketKey = `${g.raceId}::${g.group.className}`;
let bucket = bucketIndex.get(bucketKey);
if (bucket === undefined) {
const classDef =
shipClassLookup?.get(g.group.race, g.group.className) ?? null;
const mass = core
? computeBattleGroupMass(g.group, classDef, core)
: 0;
bucket = {
bucketKey,
className: g.group.className,
race: g.group.race,
raceId: g.raceId,
groupKeys: [],
initialNum: 0,
mass,
radius: MAX_RADIUS,
offsetU: 0,
offsetV: 0,
};
bucketIndex.set(bucketKey, bucket);
const list = out.get(g.raceId) ?? [];
list.push(bucket);
out.set(g.raceId, list);
}
bucket.groupKeys.push(g.key);
bucket.initialNum += g.group.num;
}
// Per-battle mass normalisation: the heaviest bucket renders
// at MAX_RADIUS; lighter ones scale by sqrt(m/max).
let maxMass = 0;
for (const bucket of bucketIndex.values()) {
if (bucket.mass > maxMass) maxMass = bucket.mass;
}
for (const bucket of bucketIndex.values()) {
bucket.radius = radiusForMass(bucket.mass, maxMass);
}
// Sort each race's buckets by initial count (descending) +
// className as a stable tie-break, then assign Vogel positions
// reordered by inward dot product (offsetU desc) so the
// largest-by-num bucket lands at the most-inward Vogel slot.
for (const list of out.values()) {
list.sort((a, b) => {
if (b.initialNum !== a.initialNum) return b.initialNum - a.initialNum;
return a.className.localeCompare(b.className);
});
const N = list.length;
const denom = Math.max(1, Math.sqrt(Math.max(N, 1)));
const step = Math.min(BASE_STEP, MAX_CLUSTER_RADIUS / denom);
const positions = Array.from({ length: N }, (_, r) => {
const radius = step * Math.sqrt(r);
const angle = r * GOLDEN_ANGLE;
return {
offsetU: radius * Math.cos(angle),
offsetV: radius * Math.sin(angle),
};
});
positions.sort((a, b) => {
if (b.offsetU !== a.offsetU) return b.offsetU - a.offsetU;
return a.offsetV - b.offsetV;
});
for (let r = 0; r < N; r++) {
list[r].offsetU = positions[r].offsetU;
list[r].offsetV = positions[r].offsetV;
}
}
return out;
});
type RenderedBucket = StaticBucket & { numLeft: number };
// renderedByRace overlays the per-frame `remaining` map onto the
// static cluster: only buckets with `numLeft > 0` survive into
// the render list, so an emptied class disappears from the cloud
// while its neighbours keep their slots.
const renderedByRace = $derived.by(() => {
const out = new Map<number, RenderedBucket[]>();
for (const [raceId, list] of staticBucketsByRace) {
const filtered: RenderedBucket[] = [];
for (const bucket of list) {
let numLeft = 0;
for (const key of bucket.groupKeys) {
numLeft += frame.remaining.get(key) ?? 0;
}
if (numLeft > 0) filtered.push({ ...bucket, numLeft });
}
if (filtered.length > 0) out.set(raceId, filtered);
}
return out;
});
// visibleBucketByGroupKey lets shot endpoints resolve to a node
// only when the bucket is currently rendered. A phantom shot
// against an already-empty bucket therefore returns `null` and
// no line is drawn.
const visibleBucketByGroupKey = $derived.by(() => {
const out = new Map<number, RenderedBucket>();
for (const list of renderedByRace.values()) {
for (const bucket of list) {
for (const key of bucket.groupKeys) {
out.set(key, bucket);
}
}
}
return out;
});
const raceLayout = $derived(
layoutRaces(frame.activeRaceIds, {
center: CENTER,
radius: RACE_RING_RADIUS,
}),
);
type ClusterBasis = {
anchorX: number;
anchorY: number;
ux: number;
uy: number;
vx: number;
vy: number;
};
const clusterBasisById = $derived.by(() => {
const out = new Map<number, ClusterBasis>();
for (const anchor of raceLayout) {
const dx = CENTER.x - anchor.x;
const dy = CENTER.y - anchor.y;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len;
const uy = dy / len;
const vx = uy;
const vy = -ux;
out.set(anchor.raceId, {
anchorX: anchor.x,
anchorY: anchor.y,
ux,
uy,
vx,
vy,
});
}
return out;
});
function worldPosition(basis: ClusterBasis, bucket: StaticBucket) {
return {
x: basis.anchorX + bucket.offsetU * basis.ux + bucket.offsetV * basis.vx,
y: basis.anchorY + bucket.offsetU * basis.uy + bucket.offsetV * basis.vy,
};
}
function findClassCircleCenter(groupKey: number) {
const bucket = visibleBucketByGroupKey.get(groupKey);
if (!bucket) return null;
const basis = clusterBasisById.get(bucket.raceId);
if (!basis) return null;
return worldPosition(basis, bucket);
}
const shotLine = $derived.by(() => {
const action = frame.lastAction;
if (!action) return null;
const from = findClassCircleCenter(action.sa);
const to = findClassCircleCenter(action.sd);
if (!from || !to) return null;
return { from, to, destroyed: action.x, defenderKey: action.sd };
});
const flashDefenderBucketKey = $derived.by(() => {
if (!shotLine || !shotVisible) return null;
const bucket = visibleBucketByGroupKey.get(shotLine.defenderKey);
return bucket?.bucketKey ?? null;
});
const raceLabelById = $derived.by(() => {
const out = new Map<number, string>();
for (const g of allGroups) {
out.set(g.raceId, g.group.race);
}
for (const [, raceId] of groupRace) {
if (!out.has(raceId)) out.set(raceId, `race ${raceId}`);
}
return out;
});
// raceLabelYById finds a y just above the visible cluster's top
// edge and clamps it to the SVG viewport so the north race
// (anchor near the top) never has its label clipped off-canvas.
const raceLabelYById = $derived.by(() => {
const out = new Map<number, number>();
for (const [raceId, list] of renderedByRace) {
const basis = clusterBasisById.get(raceId);
if (!basis || list.length === 0) continue;
let topY = basis.anchorY;
for (const bucket of list) {
const world = worldPosition(basis, bucket);
const top = world.y - bucket.radius;
if (top < topY) topY = top;
}
const fallback = basis.anchorY - MAX_RADIUS - 12;
const target = Math.min(topY - 12, fallback);
out.set(raceId, Math.max(target, LABEL_MIN_Y));
}
return out;
});
</script>
<svg
class="battle-scene"
viewBox="0 0 {VIEW_BOX} {VIEW_BOX}"
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="battle scene"
data-testid="battle-scene"
>
<circle
cx={CENTER.x}
cy={CENTER.y}
r={PLANET_RADIUS}
class="planet"
data-testid="battle-scene-planet"
/>
<text
x={CENTER.x}
y={CENTER.y + PLANET_RADIUS + 24}
text-anchor="middle"
class="planet-label"
>{report.planetName} (#{report.planet})</text>
{#each raceLayout as anchor (anchor.raceId)}
{@const cluster = renderedByRace.get(anchor.raceId) ?? []}
{@const basis = clusterBasisById.get(anchor.raceId)}
{#if basis && cluster.length > 0}
<g
class="race-cluster"
data-testid="battle-race-cluster"
data-race-id={anchor.raceId}
>
<text
x={anchor.x}
y={raceLabelYById.get(anchor.raceId) ?? anchor.y - MAX_RADIUS - 12}
text-anchor="middle"
class="race-label"
>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
{#each cluster as entry (entry.bucketKey)}
{@const pos = worldPosition(basis, entry)}
{@const flash =
entry.bucketKey === flashDefenderBucketKey
? shotLine?.destroyed
? "destroyed"
: "shielded"
: null}
<g
class="class-marker"
data-testid="battle-class-marker"
data-bucket-key={entry.bucketKey}
data-class-name={entry.className}
data-flash={flash}
>
<circle cx={pos.x} cy={pos.y} r={entry.radius} />
<text
x={pos.x}
y={pos.y + entry.radius + 12}
text-anchor="middle"
class="class-label"
>{entry.className}:{entry.numLeft}</text>
</g>
{/each}
</g>
{/if}
{/each}
{#if shotLine && shotVisible}
<line
x1={shotLine.from.x}
y1={shotLine.from.y}
x2={shotLine.to.x}
y2={shotLine.to.y}
class="shot"
class:destroyed={shotLine.destroyed}
data-testid="battle-shot"
data-destroyed={shotLine.destroyed ? "true" : "false"}
/>
{/if}
</svg>
<style>
.battle-scene {
width: 100%;
height: 100%;
background: #0a0d1a;
display: block;
}
.planet {
fill: #2a2f40;
stroke: #4a5066;
stroke-width: 1.5;
}
.planet-label {
fill: #6d7388;
font-size: 18px;
font-family: ui-sans-serif, system-ui, sans-serif;
}
.race-label {
fill: #e2e6ff;
font-size: 16px;
font-weight: 600;
font-family: ui-sans-serif, system-ui, sans-serif;
}
.class-marker circle {
fill: #1a2042;
stroke: #6d7bb5;
stroke-width: 1.5;
transition:
fill 80ms ease-in,
stroke 80ms ease-in;
}
.class-marker[data-flash="destroyed"] circle {
fill: #ee3344;
stroke: #ee3344;
}
.class-marker[data-flash="shielded"] circle {
fill: #44dd66;
stroke: #44dd66;
}
.class-label {
fill: #b8c0e6;
font-size: 11px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.shot {
stroke: #44dd66;
stroke-width: 2;
}
.shot.destroyed {
stroke: #ee3344;
}
</style>
@@ -0,0 +1,348 @@
<!--
BattleViewer — orchestrates the radial battle scene, the playback
controls, and the accessibility text log for one BattleReport. Owns
the playback state (`frameIndex`, `playing`, `speed`, `logOpen`).
Layout reorganisation (latest iteration):
- The header carries the planet title, the back-navigation links and
the frame counter so the scene captures the full viewer width and
height beneath them.
- A drag-seek slider sits between the scene and the controls; the
user can scrub the playback timeline at any speed.
- The text log collapses behind a toggle in the controls bar so a
user who wants the biggest scene possible can hide it entirely.
The component is logically isolated: feed it any `BattleReport`
matching `pkg/model/report/battle.go` and it plays back.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
import type { BattleReport } from "../../api/battle-fetch";
import type { ShipClassLookup } from "./mass";
import BattleScene from "./battle-scene.svelte";
import PlaybackControls, {
type PlaybackSpeed,
} from "./playback-controls.svelte";
import { buildFrames } from "./timeline";
let {
report,
shipClassLookup,
onBackToMap,
onBackToReport,
}: {
report: BattleReport;
shipClassLookup?: ShipClassLookup;
onBackToMap?: () => void;
onBackToReport?: () => void;
} = $props();
const frames = $derived(buildFrames(report));
let frameIndex = $state(0);
let playing = $state(false);
let speed = $state<PlaybackSpeed>(1);
let logOpen = $state(true);
let shotVisible = $state(true);
let logEl = $state<HTMLOListElement | null>(null);
const rawFrame = $derived(frames[Math.min(frameIndex, frames.length - 1)]);
// displayFrame freezes the layout at the penultimate frame's
// state once the protocol's last action eliminates a race, so
// the surviving cluster does not suddenly reflow onto the
// planet ring on the very last shot. The frame counter still
// advances to the final shot and `lastAction` still drives the
// killing line + flash; only `remaining` and `activeRaceIds`
// (the layout-determining state) freeze.
const displayFrame = $derived.by(() => {
const last = frames.length - 1;
if (
frameIndex === last &&
last >= 1 &&
frames[last].activeRaceIds.length < frames[last - 1].activeRaceIds.length
) {
const prev = frames[last - 1];
const cur = frames[last];
return {
shotIndex: cur.shotIndex,
lastAction: cur.lastAction,
remaining: prev.remaining,
activeRaceIds: prev.activeRaceIds,
};
}
return rawFrame;
});
// One tick per frame: blink the shot line off during the last
// 10 % of the frame's interval, then advance. Effect re-arms
// whenever frameIndex / playing / speed changes; previous
// timers clean up through the return.
$effect(() => {
void frameIndex;
void speed;
shotVisible = true;
if (!playing) return;
const intervalMs = 400 / speed;
const blinkOff = setTimeout(() => {
shotVisible = false;
}, intervalMs * 0.9);
const advance = setTimeout(() => {
if (frameIndex >= frames.length - 1) {
playing = false;
return;
}
frameIndex = frameIndex + 1;
}, intervalMs);
return () => {
clearTimeout(blinkOff);
clearTimeout(advance);
};
});
// Auto-scroll the visible log row into view so the highlight
// keeps up with the timeline on long battles.
$effect(() => {
void displayFrame.shotIndex;
if (!logOpen || logEl === null) return;
const current = logEl.querySelector(
'li[data-current="true"]',
) as HTMLElement | null;
if (current !== null) {
current.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
});
function seekToShot(actionIndex: number) {
playing = false;
frameIndex = Math.max(
0,
Math.min(frames.length - 1, actionIndex + 1),
);
}
function onScrub(event: Event) {
const target = event.currentTarget as HTMLInputElement;
const value = Number(target.value);
if (!Number.isFinite(value)) return;
playing = false;
frameIndex = Math.max(0, Math.min(frames.length - 1, Math.trunc(value)));
}
function describeAction(index: number): string {
const action = report.protocol[index];
const attackerGroup = report.ships[String(action.sa)];
const defenderGroup = report.ships[String(action.sd)];
const attackerRace = attackerGroup?.race ?? `race ${action.a}`;
const attackerClass = attackerGroup?.className ?? `class ${action.sa}`;
const defenderRace = defenderGroup?.race ?? `race ${action.d}`;
const defenderClass = defenderGroup?.className ?? `class ${action.sd}`;
const key = action.x
? "game.battle.log.destroyed"
: "game.battle.log.shielded";
return i18n.t(key, {
attacker_race: attackerRace,
attacker_class: attackerClass,
defender_race: defenderRace,
defender_class: defenderClass,
});
}
</script>
<div class="viewer" data-testid="battle-viewer">
<header class="header">
<div class="back-row">
{#if onBackToMap}
<button
type="button"
class="back-btn"
onclick={onBackToMap}
data-testid="battle-back-to-map"
>{i18n.t("game.battle.back_to_map")}</button>
{/if}
{#if onBackToReport}
<button
type="button"
class="back-btn"
onclick={onBackToReport}
data-testid="battle-back-to-report"
>{i18n.t("game.battle.back_to_report")}</button>
{/if}
</div>
<h2 data-testid="battle-viewer-title">
{i18n.t("game.battle.header_title", {
planet_name: report.planetName,
planet_number: String(report.planet),
})}
</h2>
<span class="progress" data-testid="battle-frame-index">
{displayFrame.shotIndex} / {report.protocol.length}
</span>
</header>
<div class="scene">
<BattleScene {report} frame={displayFrame} {shipClassLookup} {shotVisible} />
</div>
<input
class="scrubber"
type="range"
min="0"
max={Math.max(0, frames.length - 1)}
step="1"
value={frameIndex}
oninput={onScrub}
aria-label={i18n.t("game.battle.controls.scrub")}
data-testid="battle-scrubber"
/>
<PlaybackControls
bind:playing
bind:frameIndex
bind:speed
bind:logOpen
frameCount={frames.length}
/>
{#if logOpen}
<section
class="log"
aria-label={i18n.t("game.battle.accessibility.protocol_heading")}
>
<h3>{i18n.t("game.battle.accessibility.protocol_heading")}</h3>
<ol bind:this={logEl} data-testid="battle-protocol-log">
{#each report.protocol as _action, i (i)}
<li
data-testid="battle-protocol-log-item"
data-current={i + 1 === displayFrame.shotIndex ? "true" : "false"}
>
<button
type="button"
class="log-row-btn"
onclick={() => seekToShot(i)}
>{describeAction(i)}</button>
</li>
{/each}
</ol>
</section>
{/if}
</div>
<style>
.viewer {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
flex: 1 1 auto;
min-height: 0;
margin: 0 auto;
padding: 0.75rem 1rem;
color: #d6dcf2;
font-family: inherit;
box-sizing: border-box;
}
.header {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 0 0 auto;
}
.header h2 {
flex: 1 1 auto;
margin: 0;
font-size: 1.05rem;
text-transform: uppercase;
letter-spacing: 0.04em;
text-align: center;
}
.back-row {
display: flex;
gap: 0.3rem;
flex: 0 0 auto;
}
.back-btn {
appearance: none;
background: #1f2748;
color: #d6dcf2;
border: 1px solid #2c3568;
padding: 0.3rem 0.6rem;
border-radius: 3px;
cursor: pointer;
font-size: 0.8rem;
}
.back-btn:hover {
background: #2a3463;
}
.progress {
color: #93a0d0;
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
flex: 0 0 auto;
min-width: 5rem;
text-align: right;
}
.scene {
background: #0a0d1a;
border: 1px solid #1e264a;
border-radius: 4px;
overflow: hidden;
flex: 1 1 auto;
min-height: 0;
}
.scrubber {
width: 100%;
margin: 0;
flex: 0 0 auto;
accent-color: #6d7bb5;
}
.log {
flex: 0 1 auto;
min-height: 4rem;
max-height: 30vh;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.log h3 {
margin: 0 0 0.3rem;
color: #93a0d0;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
flex: 0 0 auto;
}
.log ol {
list-style: none;
margin: 0;
padding: 0;
font-size: 0.85rem;
overflow-y: auto;
color: #c6cdf0;
flex: 1 1 auto;
min-height: 0;
}
.log li {
border-bottom: 1px solid #1c2240;
}
.log-row-btn {
display: block;
width: 100%;
text-align: left;
padding: 0.15rem 0.4rem;
background: transparent;
border: 0;
color: inherit;
font: inherit;
cursor: pointer;
}
.log-row-btn:hover,
.log-row-btn:focus-visible {
background: #131a36;
}
.log li[data-current="true"] .log-row-btn {
color: #ffe27a;
font-weight: 600;
background: #1a2240;
}
</style>
+109
View File
@@ -0,0 +1,109 @@
// Mass helpers for the Battle Viewer.
//
// Phase 27 refinement: ship-class circles are sized by per-ship
// FullMass (Empty + carrying), with a 6..24 px range. The viewer
// resolves a `(race, className) → ShipClassRef` lookup from the
// surrounding GameReport's `localShipClass` / `otherShipClass`
// tables and feeds it through the wasm bridge to `pkg/calc/ship.go`.
//
// Pure utilities live here; the Svelte components consume them.
import type { Core } from "../../platform/core/index";
import type { BattleReportGroup } from "../../api/battle-fetch";
/** Smallest visible ship circle. Picked so the `<class>:<n>` label
* stays legible on every viewport. */
export const MIN_RADIUS = 6;
/** Largest ship circle. Matches the Phase-27 baseline so heavy
* ships keep their previous visual prominence. */
export const MAX_RADIUS = 24;
/**
* ShipClassRef is the minimum slice of a ship class needed to
* compute its mass. Mirrors the relevant fields of
* `ShipClassSummary` (own classes) and `ReportOtherShipClass`
* (foreign classes) without coupling the viewer to either type.
*/
export interface ShipClassRef {
drive: number;
weapons: number;
armament: number;
shields: number;
cargo: number;
}
/**
* ShipClassLookup resolves `(race, className)` to a ship-class
* descriptor. Returns `null` when the class is not in the parent
* report — happens with legacy-mode foreign races that lack a
* `<Race> Ship Types` block.
*/
export interface ShipClassLookup {
get(race: string, className: string): ShipClassRef | null;
}
/**
* computeBattleGroupMass returns the per-ship FullMass for a given
* battle group. Mass=0 means "unknown" — either the wasm bridge
* rejected the ship-class params (degenerate weapons/armament pair)
* or the class did not resolve in the lookup. Either way the
* caller's downstream `radiusForMass` falls back to MAX_RADIUS so
* the node stays visible.
*
* Cargo never changes during a battle, so this can be cached per
* `(race, className)` bucket for the lifetime of the viewer
* session.
*/
export function computeBattleGroupMass(
group: BattleReportGroup,
classDef: ShipClassRef | null,
core: Core,
): number {
if (classDef === null) return 0;
const empty = core.emptyMass({
drive: classDef.drive,
weapons: classDef.weapons,
armament: classDef.armament,
shields: classDef.shields,
cargo: classDef.cargo,
});
if (empty === null) return 0;
const cargoTech = classDef.cargo * (group.tech.CARGO ?? 0);
const carrying = core.carryingMass({
load: group.loadQuantity,
cargoTech,
});
return core.fullMass({ emptyMass: empty, carryingMass: carrying });
}
/**
* radiusForMass maps an absolute ship mass to a circle radius via
* a per-battle normalisation: the heaviest visual node always
* renders at MAX_RADIUS, lighter ones scale by sqrt(mass /
* maxMassInBattle) so the smallest ships don't disappear and the
* heaviest ones don't dominate the scene at >MAX_RADIUS. mass<=0
* falls back to MAX_RADIUS so unresolved/invalid classes stay
* visible.
*/
export function radiusForMass(mass: number, maxMassInBattle: number): number {
if (maxMassInBattle <= 0 || mass <= 0) return MAX_RADIUS;
const scaled = MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * Math.sqrt(mass / maxMassInBattle);
if (scaled < MIN_RADIUS) return MIN_RADIUS;
if (scaled > MAX_RADIUS) return MAX_RADIUS;
return scaled;
}
/**
* MapShipClassLookup is a `Map<string, ShipClassRef>`-backed
* implementation of `ShipClassLookup`. Key encoding mirrors the
* one battle.svelte uses when populating the lookup from the
* parent GameReport.
*/
export class MapShipClassLookup implements ShipClassLookup {
constructor(private readonly map: Map<string, ShipClassRef>) {}
get(race: string, className: string): ShipClassRef | null {
return this.map.get(`${race}::${className}`) ?? null;
}
}
@@ -0,0 +1,155 @@
<!--
PlaybackControls — rewind / step-back / play-pause / step-forward
plus a single cycling speed button (1x → 2x → 4x → 6x → 1x) and a
"log" toggle that the orchestrator uses to collapse the always-on
text protocol when the user wants more space for the scene. Owns no
state of its own; binds `playing`, `frameIndex`, `speed`, and
`logOpen` from the orchestrator. Disables step/rewind when there's
nowhere to go and step-forward when the timeline is at its end.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
export type PlaybackSpeed = 1 | 2 | 4 | 6;
const SPEED_CYCLE: PlaybackSpeed[] = [1, 2, 4, 6];
let {
playing = $bindable(),
frameIndex = $bindable(),
speed = $bindable(),
logOpen = $bindable(),
frameCount,
}: {
playing: boolean;
frameIndex: number;
speed: PlaybackSpeed;
logOpen: boolean;
frameCount: number;
} = $props();
function rewind() {
playing = false;
frameIndex = 0;
}
function stepBack() {
playing = false;
if (frameIndex > 0) frameIndex = frameIndex - 1;
}
function togglePlay() {
if (frameIndex >= frameCount - 1) {
frameIndex = 0;
}
playing = !playing;
}
function stepForward() {
playing = false;
if (frameIndex < frameCount - 1) frameIndex = frameIndex + 1;
}
function cycleSpeed() {
const idx = SPEED_CYCLE.indexOf(speed);
const next = SPEED_CYCLE[(idx + 1) % SPEED_CYCLE.length];
speed = next;
}
function toggleLog() {
logOpen = !logOpen;
}
const speedLabel = $derived(`${speed}x`);
</script>
<div class="controls" data-testid="battle-controls">
<button
type="button"
onclick={rewind}
disabled={frameIndex === 0}
aria-label={i18n.t("game.battle.controls.rewind")}
data-testid="battle-control-rewind"
></button>
<button
type="button"
onclick={stepBack}
disabled={frameIndex === 0}
aria-label={i18n.t("game.battle.controls.step_backward")}
data-testid="battle-control-step-back"
>◀︎◀︎</button>
<button
type="button"
onclick={togglePlay}
aria-label={playing
? i18n.t("game.battle.controls.pause")
: i18n.t("game.battle.controls.play")}
data-testid="battle-control-play"
data-playing={playing ? "true" : "false"}
>{playing ? "⏸" : "▶︎"}</button>
<button
type="button"
onclick={stepForward}
disabled={frameIndex >= frameCount - 1}
aria-label={i18n.t("game.battle.controls.step_forward")}
data-testid="battle-control-step-forward"
>▶︎▶︎</button>
<div class="spacer" aria-hidden="true"></div>
<button
type="button"
class="speed-btn"
onclick={cycleSpeed}
title={i18n.t("game.battle.controls.speed_label")}
aria-label={i18n.t("game.battle.controls.speed_label")}
data-testid="battle-control-speed"
data-speed={speed}
>{speedLabel}</button>
<button
type="button"
class="log-toggle"
class:active={logOpen}
onclick={toggleLog}
aria-pressed={logOpen}
aria-label={i18n.t("game.battle.controls.log_toggle")}
data-testid="battle-control-log-toggle"
>{i18n.t("game.battle.controls.log_toggle")} {logOpen ? "▲" : "▼"}</button>
</div>
<style>
.controls {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.75rem;
background: #131934;
border: 1px solid #1e264a;
border-radius: 4px;
}
.spacer {
flex: 1 1 auto;
}
button {
appearance: none;
background: #1f2748;
color: #d6dcf2;
border: 1px solid #2c3568;
padding: 0.35rem 0.7rem;
border-radius: 3px;
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
min-width: 2.5rem;
}
button:hover:not(:disabled) {
background: #2a3463;
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.speed-btn {
min-width: 3rem;
font-variant-numeric: tabular-nums;
}
.log-toggle.active {
background: #2a3463;
}
</style>
@@ -0,0 +1,59 @@
// Radial layout for the BattleViewer.
//
// Places race anchors on a circle of radius `radius` around `center`
// at equal angular spacing. For three or more races the first anchor
// sits at the top (12 o'clock) and subsequent anchors march
// clockwise. For exactly two races the pair is rotated 90° so they
// face each other horizontally (3 o'clock vs 9 o'clock) — that keeps
// every race label clear of the SVG top edge when only two clusters
// remain, and reads as "the two sides facing off" naturally.
//
// When a race is eliminated mid-battle the caller filters it out of
// `activeRaceIds` and the survivors are re-spaced on the next frame
// through the same helper.
export interface RaceAnchor {
raceId: number;
x: number;
y: number;
/** Angle in radians measured from the positive Y axis clockwise. */
angle: number;
}
export interface RadialLayoutOptions {
center: { x: number; y: number };
radius: number;
}
/**
* layoutRaces returns anchor positions for each `activeRaceIds`
* entry, placed at equal angular spacing on a circle. The input
* order is preserved so consumers get a stable mapping across
* frames; eliminated entries should simply be filtered out before
* the call.
*/
export function layoutRaces(
activeRaceIds: number[],
options: RadialLayoutOptions,
): RaceAnchor[] {
const count = activeRaceIds.length;
if (count === 0) return [];
const { center, radius } = options;
const out: RaceAnchor[] = [];
// For two participants we want a horizontal duel layout: race 0
// at 9 o'clock, race 1 at 3 o'clock. For any other count the
// first anchor lands at the top (12 o'clock) and the rest march
// clockwise at equal spacing.
const startAngle = count === 2 ? Math.PI : -Math.PI / 2;
for (let i = 0; i < count; i++) {
const step = (2 * Math.PI) / count;
const angle = startAngle + i * step;
out.push({
raceId: activeRaceIds[i],
x: center.x + radius * Math.cos(angle),
y: center.y + radius * Math.sin(angle),
angle,
});
}
return out;
}
@@ -0,0 +1,143 @@
// Timeline builder for the BattleViewer.
//
// Given a `BattleReport`, expands the flat `protocol` into a
// sequence of frames. Frame 0 carries the initial state; frame N
// (1 ≤ N ≤ protocol.length) reflects the state right after the
// (N-1)-th action has been applied. Each frame is self-contained so
// stepping forward and backward is a constant-time index lookup, no
// rewind logic needed.
import type {
BattleActionReport,
BattleReport,
BattleReportGroup,
} from "../../api/battle-fetch";
/**
* Frame is one tick of the battle playback. `remaining` carries the
* surviving ship count for each ship-group key from
* `BattleReport.ships`; `activeRaceIds` are the race indices with at
* least one surviving in-battle group. `lastAction` is the action
* applied to produce this frame, or `null` for the initial frame.
*/
export interface Frame {
shotIndex: number;
remaining: Map<number, number>;
activeRaceIds: number[];
lastAction: BattleActionReport | null;
}
export interface NormalisedGroup {
key: number;
group: BattleReportGroup;
raceId: number;
}
/**
* normaliseGroups returns the in-battle ship groups from a
* BattleReport indexed by their integer key. Observer groups
* (`inBattle === false`) are skipped because they are neither
* targeted nor drawn. The race index per group is derived from the
* protocol — every in-battle group appears at least once as
* attacker or defender, and the engine's pairing (a, sa) / (d, sd)
* defines the relationship.
*/
export function normaliseGroups(report: BattleReport): NormalisedGroup[] {
const raceByKey = buildGroupRaceMap(report.protocol);
const out: NormalisedGroup[] = [];
for (const [keyRaw, group] of Object.entries(report.ships)) {
if (!group.inBattle) continue;
const key = Number(keyRaw);
if (!Number.isFinite(key)) continue;
const raceId = raceByKey.get(key);
if (raceId === undefined) continue;
out.push({ key, group, raceId });
}
return out;
}
/**
* buildGroupRaceMap extracts the `ship-group key → race index`
* mapping from a battle protocol. Same key appearing twice always
* carries the same race index — protocol entries are emitted by the
* engine, which never crosses these wires.
*/
export function buildGroupRaceMap(
protocol: BattleActionReport[],
): Map<number, number> {
const out = new Map<number, number>();
for (const action of protocol) {
if (!out.has(action.sa)) out.set(action.sa, action.a);
if (!out.has(action.sd)) out.set(action.sd, action.d);
}
return out;
}
/**
* buildFrames walks the protocol once and emits a frame after each
* applied action plus the initial frame. The remaining-ships map is
* cloned per frame so callers can step backward without manual
* bookkeeping. Eliminated races drop out of `activeRaceIds` as soon
* as their last in-battle group hits zero.
*/
export function buildFrames(report: BattleReport): Frame[] {
const groups = normaliseGroups(report);
const initialRemaining = new Map<number, number>();
const raceTotals = new Map<number, number>();
for (const g of groups) {
initialRemaining.set(g.key, g.group.num);
raceTotals.set(g.raceId, (raceTotals.get(g.raceId) ?? 0) + g.group.num);
}
const frames: Frame[] = [];
frames.push({
shotIndex: 0,
remaining: new Map(initialRemaining),
activeRaceIds: collectActiveRaces(raceTotals),
lastAction: null,
});
const groupRaceByKey = new Map<number, number>();
for (const g of groups) groupRaceByKey.set(g.key, g.raceId);
const current = new Map(initialRemaining);
const runningRaceTotals = new Map(raceTotals);
for (let i = 0; i < report.protocol.length; i++) {
const action = report.protocol[i];
if (action.x) {
// Defence in depth: a malformed protocol that fires more
// `Destroyed` rows than the group has ships would push
// `runningRaceTotals` below zero and drop the race from
// `activeRaceIds` prematurely. Real legacy data folds
// duplicate `(race, className)` roster rows into the
// same `BattleReportGroup` (parser + engine), so this
// branch is hit only on a real shrink — but the clamp
// keeps the math from going negative either way.
const left = current.get(action.sd) ?? 0;
if (left > 0) {
current.set(action.sd, left - 1);
const raceId = groupRaceByKey.get(action.sd);
if (raceId !== undefined) {
const t = (runningRaceTotals.get(raceId) ?? 0) - 1;
runningRaceTotals.set(raceId, Math.max(0, t));
}
}
}
frames.push({
shotIndex: i + 1,
remaining: new Map(current),
activeRaceIds: collectActiveRaces(runningRaceTotals),
lastAction: action,
});
}
return frames;
}
function collectActiveRaces(totals: Map<number, number>): number[] {
const out: number[] = [];
for (const [raceId, total] of totals.entries()) {
if (total > 0) out.push(raceId);
}
return out.sort((a, b) => a - b);
}
+213 -16
View File
@@ -24,6 +24,12 @@ import type { WrapMode } from "../map/world";
const PREF_NAMESPACE = "game-prefs"; const PREF_NAMESPACE = "game-prefs";
const PREF_KEY_WRAP_MODE = (gameId: string) => `${gameId}/wrap-mode`; const PREF_KEY_WRAP_MODE = (gameId: string) => `${gameId}/wrap-mode`;
const PREF_KEY_LAST_VIEWED_TURN = (gameId: string) =>
`${gameId}/last-viewed-turn`;
const HISTORY_NAMESPACE = "game-history";
const HISTORY_KEY_TURN = (gameId: string, turn: number) =>
`${gameId}/turn/${turn}`;
/** /**
* GAME_STATE_CONTEXT_KEY is the Svelte context key the in-game shell * GAME_STATE_CONTEXT_KEY is the Svelte context key the in-game shell
@@ -53,9 +59,30 @@ export class GameStateStore {
* game (lifted from the lobby record on `setGame`). Phase 14 * game (lifted from the lobby record on `setGame`). Phase 14
* exposes it so the layout can pass it to * exposes it so the layout can pass it to
* `OrderDraftStore.hydrateFromServer` after both stores boot; * `OrderDraftStore.hydrateFromServer` after both stores boot;
* later phases (history mode, calc) will read it directly. * Phase 26 keeps the "authoritative server-side turn" meaning —
* only `setGame`, `advanceToPending`, and the visibility-listener
* lobby re-query update it. History navigation (`viewTurn`) leaves
* it alone so the "Return to current turn" affordance keeps a
* reliable target.
*/ */
currentTurn = $state(0); currentTurn = $state(0);
/**
* viewedTurn is the turn whose snapshot is currently displayed.
* In live mode it equals `currentTurn`. Phase 26 history mode
* decouples the two: `viewTurn(N)` flips this rune (and `report`)
* to N without touching `currentTurn` or `last-viewed-turn`.
*/
viewedTurn = $state(0);
/**
* historyMode is the derived "user is viewing a past turn" rune
* consumed by Phase 12 sidebar / bottom-tabs wiring, the Phase 26
* history banner, the rendered-report overlay short-circuit, and
* the order-draft mutation gate. It depends only on the rune state
* above, so every consumer reacts to a single source of truth.
*/
historyMode = $derived(
this.status === "ready" && this.viewedTurn < this.currentTurn,
);
/** /**
* synthetic is set by `initSynthetic` for DEV-only sessions backed * synthetic is set by `initSynthetic` for DEV-only sessions backed
* by a hand-loaded report (lobby's "Load synthetic report" * by a hand-loaded report (lobby's "Load synthetic report"
@@ -66,6 +93,17 @@ export class GameStateStore {
* this flag is enough to keep the network silent. * this flag is enough to keep the network silent.
*/ */
synthetic = $state(false); synthetic = $state(false);
/**
* pendingTurn carries the latest server-side turn the user has not
* yet opened: it is `> currentTurn` whenever the server reports a
* new turn (either through a `game.turn.ready` push event after
* boot, or through the boot-time discovery that the persisted
* `lastViewedTurn` is behind the lobby's `current_turn`). The
* layout's `$effect` renders a toast/banner when it is non-null;
* `advanceToPending()` refreshes the store onto the new turn and
* clears the rune.
*/
pendingTurn: number | null = $state(null);
private client: GalaxyClient | null = null; private client: GalaxyClient | null = null;
private cache: Cache | null = null; private cache: Cache | null = null;
@@ -98,12 +136,21 @@ export class GameStateStore {
if (this.client === null || this.cache === null) { if (this.client === null || this.cache === null) {
throw new Error("game-state: setGame called before init"); throw new Error("game-state: setGame called before init");
} }
// Only forget the pending indicator when the consumer is
// actually switching games. On the initial `setGame` after
// `init` the previous `gameId` is the empty string, and a
// concurrent `markPendingTurn` from a push event arriving
// while we were still bootstrapping must not be erased.
if (this.gameId !== "" && this.gameId !== gameId) {
this.pendingTurn = null;
}
this.gameId = gameId; this.gameId = gameId;
this.status = "loading"; this.status = "loading";
this.error = null; this.error = null;
this.report = null; this.report = null;
this.wrapMode = await readWrapMode(this.cache, gameId); this.wrapMode = await readWrapMode(this.cache, gameId);
const lastViewed = await readLastViewedTurn(this.cache, gameId);
try { try {
const summary = await this.findGame(gameId); const summary = await this.findGame(gameId);
@@ -114,7 +161,23 @@ export class GameStateStore {
} }
this.gameName = summary.gameName; this.gameName = summary.gameName;
this.currentTurn = summary.currentTurn; this.currentTurn = summary.currentTurn;
await this.loadTurn(summary.currentTurn); // If the persisted last-viewed turn is older than the
// server-side current turn, open the user on their last-seen
// snapshot and surface the gap through `pendingTurn` so the
// shell can render a "new turn available" affordance instead
// of silently auto-advancing. After Phase 26 the same gap
// also flips `historyMode` to true (viewedTurn < currentTurn),
// so the read-only banner appears alongside the toast.
if (
lastViewed !== null &&
lastViewed >= 0 &&
lastViewed < summary.currentTurn
) {
this.pendingTurn = summary.currentTurn;
await this.loadTurn(lastViewed, { isCurrent: false });
} else {
await this.loadTurn(summary.currentTurn, { isCurrent: true });
}
} catch (err) { } catch (err) {
if (this.destroyed) return; if (this.destroyed) return;
this.status = "error"; this.status = "error";
@@ -123,29 +186,104 @@ export class GameStateStore {
} }
/** /**
* setTurn loads a different turn snapshot — used by Phase 26 history * markPendingTurn records a server-reported new turn (typically
* mode. The current turn stays at whatever `setGame` discovered; * delivered through `game.turn.ready`). Values that are not
* calling without an argument refetches the same turn. * strictly ahead of the latest known turn (current or already
* pending) are ignored so a replayed event cannot regress the
* indicator.
*/ */
async setTurn(turn: number): Promise<void> { markPendingTurn(turn: number): void {
if (this.client === null) return; const latest = this.pendingTurn ?? this.currentTurn;
if (turn > latest) {
this.pendingTurn = turn;
}
}
/**
* advanceToPending re-queries the lobby record and loads the
* report at the server's latest `current_turn`, then clears the
* pending indicator. Unlike `setGame`, this skips the
* `lastViewedTurn` lookup — the user has explicitly asked to
* jump to the new turn, so any persisted bookmark from the
* previous session is irrelevant. Failures keep the indicator
* set so the user can retry from the same affordance.
*/
async advanceToPending(): Promise<void> {
if (this.pendingTurn === null || this.client === null) {
return;
}
this.status = "loading"; this.status = "loading";
this.error = null; this.error = null;
try { try {
await this.loadTurn(turn); const summary = await this.findGame(this.gameId);
if (summary === null) {
this.status = "error";
this.error = `game ${this.gameId} is not in your list`;
return;
}
this.gameName = summary.gameName;
this.currentTurn = summary.currentTurn;
await this.loadTurn(summary.currentTurn, { isCurrent: true });
this.pendingTurn = null;
} catch (err) { } catch (err) {
if (this.destroyed) return;
this.status = "error"; this.status = "error";
this.error = describe(err); this.error = describe(err);
} }
} }
/** /**
* refresh re-fetches the report at the current turn. Called on * viewTurn loads the historical snapshot for `turn` and switches the
* window `visibilitychange` so the map and the turn counter stay * UI into history mode (Phase 26). The current turn is untouched —
* fresh after the user returns to the tab. * `historyMode` flips on automatically through the derived rune, and
* the `last-viewed-turn` cache is only refreshed when the caller
* happens to ask for the currentTurn (e.g. `returnToCurrent`). A
* cache hit on `game-history/{gameId}/turn/{N}` skips the network;
* past turns are immutable so the cache never goes stale.
*/ */
refresh(): Promise<void> { async viewTurn(turn: number): Promise<void> {
return this.setTurn(this.currentTurn); if (this.client === null) return;
if (!Number.isFinite(turn) || turn < 0 || turn > this.currentTurn) {
return;
}
this.status = "loading";
this.error = null;
try {
await this.loadTurn(turn, { isCurrent: turn === this.currentTurn });
} catch (err) {
if (this.destroyed) return;
this.status = "error";
this.error = describe(err);
}
}
/**
* returnToCurrent jumps back to the server's current turn after a
* history excursion. Thin wrapper around `viewTurn(currentTurn)` so
* the banner / popover share the same call site.
*/
returnToCurrent(): Promise<void> {
return this.viewTurn(this.currentTurn);
}
/**
* refresh is fired from the `visibilitychange` listener. In live
* mode it re-fetches the report at the current turn so the map and
* the counter catch up after the user returns to the tab. In
* history mode it is a no-op: the user is intentionally viewing a
* past turn, push events (Phase 24) deliver new-turn notifications
* asynchronously, and forcing a reload would silently bump the
* user out of history mode.
*/
async refresh(): Promise<void> {
if (this.client === null) return;
if (this.historyMode) return;
try {
await this.loadTurn(this.currentTurn, { isCurrent: true });
} catch (err) {
if (this.destroyed) return;
console.warn("game-state: refresh failed", err);
}
} }
/** /**
@@ -193,6 +331,7 @@ export class GameStateStore {
this.wrapMode = await readWrapMode(opts.cache, opts.gameId); this.wrapMode = await readWrapMode(opts.cache, opts.gameId);
this.report = opts.report; this.report = opts.report;
this.currentTurn = opts.report.turn; this.currentTurn = opts.report.turn;
this.viewedTurn = opts.report.turn;
this.status = "ready"; this.status = "ready";
} }
@@ -212,13 +351,57 @@ export class GameStateStore {
return games.find((g) => g.gameId === gameId) ?? null; return games.find((g) => g.gameId === gameId) ?? null;
} }
private async loadTurn(turn: number): Promise<void> { private async loadTurn(
turn: number,
opts: { isCurrent: boolean },
): Promise<void> {
if (this.client === null) return; if (this.client === null) return;
const report = await fetchGameReport(this.client, this.gameId, turn); const report = await this.readReport(turn, opts.isCurrent);
if (this.destroyed) return; if (this.destroyed) return;
this.report = report; this.report = report;
this.currentTurn = turn; this.viewedTurn = turn;
this.status = "ready"; this.status = "ready";
if (this.cache === null) return;
if (opts.isCurrent) {
// Persist last-viewed-turn only when the user is caught up
// on the live snapshot. Historical excursions are ephemeral
// (Phase 26 decision): the resume-on-open affordance from
// Phase 11 must keep meaning "the latest turn this player
// was caught up on", not "wherever they last clicked".
await this.cache.put(
PREF_NAMESPACE,
PREF_KEY_LAST_VIEWED_TURN(this.gameId),
turn,
);
return;
}
// Past turns are immutable, so the snapshot is safe to cache
// for fast re-entry. The current-turn snapshot deliberately
// skips the cache — it is mutable until the next tick.
await this.cache.put(
HISTORY_NAMESPACE,
HISTORY_KEY_TURN(this.gameId, turn),
report,
);
}
private async readReport(
turn: number,
isCurrent: boolean,
): Promise<GameReport> {
if (this.client === null) {
throw new Error("game-state: readReport called without client");
}
if (!isCurrent && this.cache !== null) {
const cached = await this.cache.get<GameReport>(
HISTORY_NAMESPACE,
HISTORY_KEY_TURN(this.gameId, turn),
);
if (cached !== undefined && cached.turn === turn) {
return cached;
}
}
return await fetchGameReport(this.client, this.gameId, turn);
} }
private installVisibilityListener(): void { private installVisibilityListener(): void {
@@ -239,6 +422,20 @@ async function readWrapMode(cache: Cache, gameId: string): Promise<WrapMode> {
return "torus"; return "torus";
} }
async function readLastViewedTurn(
cache: Cache,
gameId: string,
): Promise<number | null> {
const stored = await cache.get<number>(
PREF_NAMESPACE,
PREF_KEY_LAST_VIEWED_TURN(gameId),
);
if (typeof stored !== "number" || !Number.isFinite(stored)) {
return null;
}
return stored;
}
function describe(err: unknown): string { function describe(err: unknown): string {
if (err instanceof GameStateError) { if (err instanceof GameStateError) {
return err.message; return err.message;
+11 -21
View File
@@ -1,8 +1,10 @@
<!-- <!--
Top header for the in-game shell. Composes the in-game ID strip Top header for the in-game shell. Composes the in-game ID strip
(race name @ game name, turn N), view dropdown / hamburger, and the (race name @ game name) followed by the Phase 26 turn navigator (a
account menu. The sidebar-toggle slot to its left appears only on `← Turn N →` triplet with a popover of every turn), the view
tablet viewports (7681024 px) and is wired by `+layout.svelte`. dropdown / hamburger, and the account menu. The sidebar-toggle slot
to its left appears only on tablet viewports (7681024 px) and is
wired by `+layout.svelte`.
The race name is read from the engine's `Report.race`, the game The race name is read from the engine's `Report.race`, the game
name from the lobby's `GameSummary.gameName`. While either piece name from the lobby's `GameSummary.gameName`. While either piece
@@ -22,6 +24,7 @@ absent until Phase 24 wires push-event state.
} from "$lib/game-state.svelte"; } from "$lib/game-state.svelte";
import ViewMenu from "./view-menu.svelte"; import ViewMenu from "./view-menu.svelte";
import AccountMenu from "./account-menu.svelte"; import AccountMenu from "./account-menu.svelte";
import TurnNavigator from "./turn-navigator.svelte";
type Props = { type Props = {
gameId: string; gameId: string;
@@ -44,27 +47,14 @@ absent until Phase 24 wires push-event state.
const name = gameState?.gameName ?? ""; const name = gameState?.gameName ?? "";
return name === "" ? i18n.t("game.shell.unknown") : name; return name === "" ? i18n.t("game.shell.unknown") : name;
}); });
const turn = $derived.by(() => {
const report = gameState?.report;
return report === null || report === undefined
? i18n.t("game.shell.unknown")
: String(report.turn);
});
const headline = $derived(
i18n.t("game.shell.headline", {
race: raceName,
game: gameName,
turn,
}),
);
</script> </script>
<header class="game-shell-header" data-testid="game-shell-header"> <header class="game-shell-header" data-testid="game-shell-header">
<div class="left"> <div class="left" data-testid="game-shell-headline">
<span class="headline" data-testid="game-shell-headline"> <span class="identity" data-testid="game-shell-identity">
{headline} {raceName} @ {gameName}
</span> </span>
<TurnNavigator />
</div> </div>
<div class="right"> <div class="right">
<button <button
@@ -106,7 +96,7 @@ absent until Phase 24 wires push-event state.
gap: 0.75rem; gap: 0.75rem;
min-width: 0; min-width: 0;
} }
.headline { .identity {
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;

Some files were not shown because too many files have changed in this diff Show More