33 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
Ilia Denisov 81d8be08b2 phase 22 2026-05-11 11:38:40 +02:00
Ilia Denisov e2a4790f6c ui/phase-22: skip the no-op stance click in the races table
Clicking the already-active WAR/PEACE button still appended a
\`setDiplomaticStance\` whose \`relation\` matched the row's current
value. The engine would accept the duplicate harmlessly, but the
order tab inflates with rows that say nothing and every auto-sync
re-ships the redundant payload. Compare against the overlayed
stance (so a queued-but-not-applied change suppresses a re-click
that matches the *intended* state, not just the server snapshot)
and short-circuit when they agree. Mirrors the vote picker, which
already had the same guard.

vitest.config.ts: \`mergeConfig\` refuses callback-form base
configs, so resolve \`vite.config.ts\`'s callback with the test
context first and merge the plain object. Surfaced after the
\`loadEnv\` migration switched the root config to the callback
form.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 11:19:57 +02:00
Ilia Denisov c0382117b8 ui: read dev-server config from .env files and add VITE_DEV_HOST opt-in
`vite.config.ts` read `VITE_DEV_PROXY_TARGET` /
`VITE_DEV_GRPC_PROXY_TARGET` straight from `process.env`, so the
gateway-override knob only worked when the variable was exported in
the shell that ran `pnpm dev`. Per-developer `.env.development.local`
files (the documented way to override) were silently ignored by the
config: Vite auto-populates `import.meta.env` for client code from
those files, but the config itself runs in Node and has to call
`loadEnv` explicitly.

Switch the config to the function-form + `loadEnv` so every
`VITE_*` entry in any `.env*` file reaches both client code and the
config. Now adding `VITE_DEV_PROXY_TARGET=http://localhost:18080` to
`.env.development.local` actually retargets the proxy, no shell
gymnastics required.

While there, introduce `VITE_DEV_HOST` as an opt-in for wider
listener binding: unset (default) keeps Vite's loopback-only
behaviour; `true`/`1`/`yes` flips to "all interfaces" (`0.0.0.0` +
IPv6); any other string is passed through verbatim to pin a
specific LAN address. Useful when reaching the dev server through
SSH port forwarding, a VM, or a container needs a non-loopback
bind, and intentionally opt-in so an unattended `pnpm dev` on a
laptop never exposes the unauthenticated dev surface to the LAN by
accident.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 10:46:08 +02:00
Ilia Denisov 5867afd168 local-dev: parameterize host-port mappings via LOCAL_DEV_*_PORT
The compose stack hard-coded host ports (postgres 5433, redis 6380,
mailpit 8025, gateway REST 8080, gateway gRPC 9090) — fine for a
clean dev machine, painful when those ports collide with other
services on the same host (e.g. a `crowdsec` sitting on
127.0.0.1:8080 or a Prometheus instance on :9090).

Every host-port mapping is now `${LOCAL_DEV_*_PORT:-<old-default>}`,
so the defaults match prior behaviour for everyone and a per-host
override is a single environment variable away. `.env` carries the
overrides as commented-out lines so the customisation surface is
discoverable without grepping the compose file. README's
"Port 8080 already in use" troubleshooting entry now points at the
new variables and the optional `docker-compose.override.yml`
workflow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 10:23:42 +02:00
Ilia Denisov 9111dd955a ui/phase-22: races table with stance toggle and vote slot
Adds the Races View in the in-game shell. The table lists every
non-extinct other race with tech levels (percent), totals,
planets, votes received, and a per-row WAR | PEACE segmented
control. A single vote-recipient slot above the table queues a
`CommandRaceVote`; per-row buttons queue `CommandRaceRelation`.
Both commands flow through the existing order draft store with
collapse-by-acceptor (stance) and singleton (vote) rules.

`GameReport` widens with `races`, `myVotes`, `myVoteFor`; the
decoder walks `report.player[]` once for the richer projection.
The optimistic overlay flips stance and vote target immediately;
`votesReceived`, `myVotes`, and the alliance summary stay
server-authoritative — alliance grouping and the 2/3 victory
check are tallied on the server at turn cutoff and explicitly
not surfaced client-side (`rules.txt` keeps foreign races'
outgoing vote targets private).

Includes Vitest component coverage of stance and vote
collapse rules + a Playwright e2e that drives both commands
through the dispatcher route and verifies the gateway saw the
expected `CommandRaceRelation` / `CommandRaceVote` payloads.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 01:52:23 +02:00
Ilia Denisov 7a7f2e4b98 chore: claude settings 2026-05-11 01:10:32 +02:00
Ilia Denisov 9c29f03d66 ui/phase-21: make MapView's mounted flag reactive
The renderer-mount effect in `lib/active-view/map.svelte` reads
`mounted` to gate the runSerializedMount call, but the variable was
declared as a plain `let`, not `$state`. On the first navigation to
/map this is benign: the effect's first pass returns early (gameState
still hydrating, `report` null), and once `report` arrives the
effect re-fires — by which point `onMount` has already flipped
`mounted = true`.

On every subsequent return to /map the report is already loaded by
the long-lived gameState in the layout. The effect therefore makes
exactly one pass on the freshly-mounted component, gates on
`mounted === false` (the brand-new instance has not run `onMount`
yet), and never wakes up again because no tracked state changes
afterwards. Symptom: black canvas — fresh DOM, no mount-error
overlay, but Pixi never rebuilt the world on the new canvas.

Convert `mounted` to `$state(false)` so flipping it true inside
`onMount` triggers the effect's second pass, which now finds all
preconditions satisfied and proceeds to `runSerializedMount`. The
detailed lifecycle reasoning is preserved as a code comment so the
next reader can see why this one variable must be reactive.

Add tests/e2e/map-roundtrip.spec.ts: navigates /map → {report,
ship-class designer, science designer, mail} → /map for each
non-map view, then asserts the renderer republished primitives onto
the DEV `__galaxyDebug.getMapPrimitives()` surface. The pre-fix
build failed every variant; the patch lands all four green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 22:58:32 +02:00
Ilia Denisov 85ea6f413e local-dev: thread pkg/calc into the dockerfile build context
Commit 408097e ('feat: move func to calc package') moved a helper
into pkg/calc and made pkg/util/map.go import galaxy/calc, but the
local-dev backend / gateway Dockerfiles never picked up the new
module. The synthesised go.work has no replace directive for
galaxy/calc and the build context never copies pkg/calc, so any
backend / gateway image rebuild fails with

    galaxy/calc@v0.0.0: malformed module path "galaxy/calc": missing
    dot in first path element

Add the missing COPY, the matching `use ./pkg/calc` line, and the
`galaxy/calc v0.0.0 => ./pkg/calc` replace to both local-dev
Dockerfiles. The local-dev stack now rebuilds cleanly and the
auto-heal flow (prune-broken-engines + pre-bootstrap reconciler
tick) finishes by spawning a fresh engine container for the new
sandbox game.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 22:45:54 +02:00
Ilia Denisov ff53cc0ad3 local-dev: prune broken engines on rebuild + document one-time bake
`make rebuild` runs `compose build --no-cache backend gateway` plus
a fresh `up -d --wait`. It must therefore also reap any engine
container whose bind-mount source went away during host downtime,
otherwise the new backend image boots into a stack with the same
orphan that triggered the heal flow in the first place.

Also extend the troubleshooting note: pulling the heal-cycle fix
requires one explicit `make rebuild` so the backend image picks up
the pre-bootstrap reconciler tick. Without that, `make up` runs
the new Makefile target but the legacy backend cannot follow
through, and the developer is left staring at a `cancelled`
sandbox with no running replacement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 22:40:27 +02:00
Ilia Denisov edc9709bd6 local-dev: auto-recreate engine containers when bind-mount disappears
After a host reboot macOS clears /private/tmp, so the per-game
bind-mount source under /tmp/galaxy-game-state/<uuid> vanishes and
Docker refuses to restart the long-lived engine container under
`restart: unless-stopped`. The container then sits in `exited` state
and the dev sandbox is unreachable until the developer manually rms
it and runs `make up` twice.

Fix `make -C tools/local-dev up` to heal this in one cycle:

1. `prune-broken-engines` (new make target wired into `up`) walks
   every container labelled `galaxy-game-engine` and removes the ones
   not in `running` / `restarting` state. Healthy long-lived
   containers survive normal up/down cycles untouched.
2. The backend now runs a single reconciliation pass before the
   dev-sandbox bootstrap (`Reconciler().Tick(ctx)` in main.go).
   Without it, bootstrap would reuse the soon-to-be-cancelled game
   that the periodic ticker is about to mark `removed`. The pre-tick
   cascades the orphan runtime row through markRemoved → lobby
   cancel before bootstrap purges terminal sandbox games and creates
   a fresh one — so a single `make up` lands a working sandbox with
   a brand new state directory.

README troubleshooting section documents the symptom and the
recovery so the bind-mount-source error message is greppable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 22:27:31 +02:00
202 changed files with 68061 additions and 12740 deletions
+9 -14
View File
@@ -1,8 +1,10 @@
{ {
"permissions": {
"allow": [],
"defaultMode": "default"
},
"sandbox": { "sandbox": {
"network": { "network": {
"allowLocalBinding": true,
"allowUnixSockets": ["/Users/id/.colima/default/docker.sock"],
"allowedDomains": [ "allowedDomains": [
"github.com", "github.com",
"registry.npmjs.org", "registry.npmjs.org",
@@ -11,18 +13,11 @@
"docker.io", "docker.io",
"gcr.io", "gcr.io",
"*.golang.org" "*.golang.org"
] ],
"allowUnixSockets": [
"/var/run/docker.sock"
],
"allowLocalBinding": true
} }
},
"enabledPlugins": {
"gopls-lsp@claude-plugins-official": true,
"context7@claude-plugins-official": true
},
"permissions": {
"defaultMode": "plan",
"allow": [
"mcp__context7__resolve-library-id",
"mcp__context7__get-library-docs"
]
} }
} }
+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
+6
View File
@@ -2,6 +2,12 @@
.vscode/ .vscode/
artifacts/.claude/scheduled_tasks.lock artifacts/.claude/scheduled_tasks.lock
# Per-developer Claude Code overrides. The committed
# `.claude/settings.json` holds the shared project defaults;
# `settings.local.json` is each developer's local override
# (looser permissions, disabled sandbox) and must not be staged.
.claude/settings.local.json
# Per-developer Vite dotenv overrides. The committed # Per-developer Vite dotenv overrides. The committed
# `ui/frontend/.env.development` ships sane defaults for the # `ui/frontend/.env.development` ships sane defaults for the
# `tools/local-dev/` stack; `.local` siblings stay personal and # `tools/local-dev/` stack; `.local` siblings stay personal and
+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
+15
View File
@@ -266,6 +266,21 @@ func run(ctx context.Context) (err error) {
) )
runtimeGateway.svc = runtimeSvc runtimeGateway.svc = runtimeSvc
// Run a single reconciliation pass before the dev-sandbox
// bootstrap so any runtime row pointing at a vanished engine
// container (host reboot wiped /tmp/galaxy-game-state/<uuid>;
// `tools/local-dev`'s `prune-broken-engines` target reaped the
// husk) is already cascaded through `markRemoved` → lobby
// `cancelled` by the time the bootstrap walks the sandbox list.
// Without this pre-tick the bootstrap would reuse the
// soon-to-be-cancelled game and force the developer into a
// second `make up` cycle to land a healthy sandbox. Failures are
// non-fatal: the periodic ticker started later catches up, and
// the worst case degrades to the legacy two-cycle recovery.
if err := runtimeSvc.Reconciler().Tick(ctx); err != nil {
logger.Warn("pre-bootstrap reconciler tick failed", zap.Error(err))
}
if err := devsandbox.Bootstrap(ctx, devsandbox.Deps{ if err := devsandbox.Bootstrap(ctx, devsandbox.Deps{
Users: userSvc, Users: userSvc,
Lobby: lobbySvc, Lobby: lobbySvc,
+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)
}
}
+27 -5
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,23 +56,20 @@ 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)
if v, ok := cacheShipClass[shipClass.ID]; ok {
return v
} else {
return addShipGroup(groupId, true) return addShipGroup(groupId, true)
} }
}
race := func(groupId int) int { race := func(groupId int) int {
race := c.ShipGroupOwnerRace(groupId) race := c.ShipGroupOwnerRace(groupId)
+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)
}
})
}
}
+38 -6
View File
@@ -6,31 +6,63 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
type BattleReport struct { // 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"` ID uuid.UUID `json:"id"`
Planet uint `json:"planet"` Planet uint `json:"planet"`
Shots uint `json:"shots"`
}
type BattleReport struct {
// Battle unique ID
ID uuid.UUID `json:"id"`
// Planet number
Planet uint `json:"planet"`
// Planet name at battle start
PlanetName string `json:"planetName"` PlanetName string `json:"planetName"`
// Races participating map: <key:RaceID>
Races map[int]uuid.UUID `json:"races"` Races map[int]uuid.UUID `json:"races"`
// Ships Groups participating map: <key:BattleReportGroup>
Ships map[int]BattleReportGroup `json:"ships"` Ships map[int]BattleReportGroup `json:"ships"`
// Battle's firing protocol
Protocol []BattleActionReport `json:"protocol"` Protocol []BattleActionReport `json:"protocol"`
} }
type BattleReportGroup struct { type BattleReportGroup struct {
InBattle bool `json:"inBattle"` // Name of the race
Number uint `json:"num"`
NumberLeft uint `json:"numLeft"`
LoadQuantity Float `json:"loadQuantity"`
Tech map[string]Float `json:"tech"`
Race string `json:"race"` Race string `json:"race"`
// Name of the Ship Class.
// By design, ship's info MUST be present in Game's Repors in 'LocalShipClass' or 'OtherShipClass'
ClassName string `json:"className"` ClassName string `json:"className"`
// Ship Group's technologies mapping <tech:level>
Tech map[string]Float `json:"tech"`
// 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"` 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 {
// `key` from BattleReport.Races map
Attacker int `json:"a"` Attacker int `json:"a"`
// `key` from BattleReport.Ships map
AttackerShipClass int `json:"sa"` AttackerShipClass int `json:"sa"`
// `key` from BattleReport.Races map
Defender int `json:"d"` Defender int `json:"d"`
// `key` from BattleReport.Ships map
DefenderShipClass int `json:"sd"` DefenderShipClass int `json:"sd"`
// Was ship destroyed after attack or survived under shields
Destroyed bool `json:"x"` Destroyed bool `json:"x"`
} }
+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{
{ {
@@ -0,0 +1,16 @@
# Local-only override: this developer's host already runs another
# Gitea instance bound to 0.0.0.0:3000 and 0.0.0.0:2222, so the
# default port mappings in docker-compose.yml conflict. Remap the
# local-ci Gitea to 13000 (HTTP) and 12222 (SSH) on the host. The
# in-network ports stay 3000 / 22 — runners and workflow containers
# keep reaching Gitea by hostname through the compose network.
#
# This file is intentionally NOT committed to the repo; it captures
# per-host port allocation. Use `make -C tools/local-ci push` only
# after pointing the `local-gitea` git remote at the override port.
services:
gitea:
ports: !override
- "13000:3000"
- "12222:22"
+15
View File
@@ -2,6 +2,21 @@
# file reads these via ${VAR:-} expansions; override per-developer by # file reads these via ${VAR:-} expansions; override per-developer by
# editing this file (it is committed only with the project defaults). # editing this file (it is committed only with the project defaults).
# Host-port mappings for the stack. The compose file reads each as
# ${LOCAL_DEV_*_PORT:-<default>}, so leaving them blank or removing
# the lines below keeps the defaults shown next to each entry. Set
# a non-default value when the default collides with something else
# on the host (a system Postgres, a Prometheus instance on :9090,
# a `crowdsec` sitting on :8080, etc.). The Vite dev server in
# ui/frontend reads the gateway REST address from
# VITE_DEV_PROXY_TARGET — point it at the same port (typically via
# ui/frontend/.env.local).
#LOCAL_DEV_POSTGRES_PORT=5433
#LOCAL_DEV_REDIS_PORT=6380
#LOCAL_DEV_MAILPIT_PORT=8025
LOCAL_DEV_GATEWAY_REST_PORT=18080
LOCAL_DEV_GATEWAY_GRPC_PORT=19090
# Six-digit decimal accepted by ConfirmEmailCode in addition to the # Six-digit decimal accepted by ConfirmEmailCode in addition to the
# real bcrypt-verified code. Leave the value blank to disable the # real bcrypt-verified code. Leave the value blank to disable the
# override and force every login through Mailpit. # override and force every login through Mailpit.
+32 -3
View File
@@ -1,4 +1,4 @@
.PHONY: help up down logs status rebuild clean psql logs-backend logs-gateway logs-mail build-engine stop-engines wait .PHONY: help up down logs status rebuild clean psql logs-backend logs-gateway logs-mail build-engine stop-engines prune-broken-engines wait
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
@@ -17,6 +17,7 @@ help:
@echo " make rebuild Force rebuild of backend / gateway images and bring up" @echo " make rebuild Force rebuild of backend / gateway images and bring up"
@echo " make build-engine Build the engine image $(ENGINE_IMAGE) used by the dev sandbox" @echo " make build-engine Build the engine image $(ENGINE_IMAGE) used by the dev sandbox"
@echo " make stop-engines Stop and remove only the per-game engine containers" @echo " make stop-engines Stop and remove only the per-game engine containers"
@echo " make prune-broken-engines Remove non-running engine containers Docker can't heal (run inside 'up')"
@echo " make clean Stop everything (incl. engines) and wipe volumes + game state" @echo " make clean Stop everything (incl. engines) and wipe volumes + game state"
@echo " make logs Tail all logs" @echo " make logs Tail all logs"
@echo " make logs-backend Tail only the backend logs" @echo " make logs-backend Tail only the backend logs"
@@ -32,10 +33,10 @@ help:
@echo "Default login for the auto-provisioned dev sandbox: dev@local.test" @echo "Default login for the auto-provisioned dev sandbox: dev@local.test"
@echo "(see BACKEND_DEV_SANDBOX_EMAIL in .env). Login code: 123456." @echo "(see BACKEND_DEV_SANDBOX_EMAIL in .env). Login code: 123456."
up: build-engine up: build-engine prune-broken-engines
$(COMPOSE) up -d --wait $(COMPOSE) up -d --wait
rebuild: build-engine rebuild: build-engine prune-broken-engines
$(COMPOSE) build --no-cache backend gateway $(COMPOSE) build --no-cache backend gateway
$(COMPOSE) up -d --wait $(COMPOSE) up -d --wait
@@ -70,6 +71,34 @@ stop-engines:
docker rm -f $$ids >/dev/null; \ docker rm -f $$ids >/dev/null; \
fi fi
# Remove engine containers Docker can no longer heal on its own.
# After a host reboot, the per-game bind-mount source under
# /tmp/galaxy-game-state/<uuid> may have been wiped (macOS clears
# /private/tmp on reboot), so `restart: unless-stopped` cannot
# revive the container — Docker refuses to start it with a missing
# bind-mount source and leaves it stuck in `exited` / `created`
# state. This target prunes the husks before `compose up`; the
# backend's pre-bootstrap reconciler tick (`backend/cmd/backend/main.go`)
# then cascades the orphan runtime row to `removed`, the lobby
# cancels the game, and the dev-sandbox bootstrap purges the
# cancelled tile and provisions a fresh sandbox in the same
# `make up` cycle. Healthy `running` / `restarting` containers are
# left intact so a long-lived sandbox survives normal up/down
# cycles.
prune-broken-engines:
@ids=""; \
for cid in $$(docker ps -aq --filter label=$(ENGINE_LABEL) 2>/dev/null); do \
state=$$(docker inspect -f '{{.State.Status}}' $$cid 2>/dev/null); \
case "$$state" in \
running|restarting) ;; \
*) ids="$$ids $$cid";; \
esac; \
done; \
if [ -n "$$ids" ]; then \
echo "removing non-running engine containers (post-reboot cleanup):$$ids"; \
docker rm -f $$ids >/dev/null; \
fi
logs: logs:
$(COMPOSE) logs -f --tail=100 $(COMPOSE) logs -f --tail=100
+43 -6
View File
@@ -223,9 +223,35 @@ make status docker compose ps
Application → Storage → Clear site data) and log in again. Application → Storage → Clear site data) and log in again.
- **`make down` leaves a `galaxy-game-…` container behind** — fixed - **`make down` leaves a `galaxy-game-…` container behind** — fixed
in this Makefile: `make down` and `make clean` now stop spawned in this Makefile: `make down` and `make clean` now stop spawned
engine containers via the `org.opencontainers.image.title= engine containers via the `galaxy.backend=1` label. To stop them by
galaxy-game-engine` label. To stop them by hand without touching hand without touching
the rest of the stack, `make stop-engines`. the rest of the stack, `make stop-engines`.
- **Engine container exits with `bind source path does not exist:
/tmp/galaxy-game-state/<uuid>` after a host reboot** — macOS clears
`/private/tmp` on reboot, so the per-game state directory the
long-lived engine container bind-mounts is gone and Docker refuses
to restart it under `restart: unless-stopped`. `make up` auto-heals
this in one cycle: `prune-broken-engines` (runs as part of `up`)
removes every engine container that is not in `running` /
`restarting` state, the backend's pre-bootstrap reconciler tick
cascades the orphan runtime row to `removed`, the lobby cancels
the matching sandbox game, and the dev-sandbox bootstrap purges
the cancelled tile and provisions a fresh sandbox with a brand
new state directory. To run the cleanup by hand without restarting
the rest of the stack, `make prune-broken-engines`.
The cycle relies on the backend image carrying the pre-bootstrap
reconciler tick (`backend/cmd/backend/main.go`). `make up` reuses
the cached image, so after pulling this commit the first time you
must `make rebuild` once to bake the fix in. Future `make up`
cycles will heal in one shot.
If after the heal cycle the lobby still shows only a `cancelled`
sandbox tile and no running game, the running backend image
predates the pre-bootstrap reconciler tick — the periodic ticker
cancels the orphan after bootstrap has already returned, leaving
the lobby in the half-baked state. `make rebuild` recreates the
image and then `make up` lands a fresh sandbox.
- **`make up` reports a build error mentioning `pkg/cronutil`** — - **`make up` reports a build error mentioning `pkg/cronutil`** —
upstream module list drifted; copy any new `pkg/<name>/` line into upstream module list drifted; copy any new `pkg/<name>/` line into
the local-dev `backend.Dockerfile` / `gateway.Dockerfile` to match the local-dev `backend.Dockerfile` / `gateway.Dockerfile` to match
@@ -242,10 +268,21 @@ make status docker compose ps
- **UI talks to old gateway**: Vite caches `import.meta.env` at boot. - **UI talks to old gateway**: Vite caches `import.meta.env` at boot.
Restart `pnpm dev` after editing Restart `pnpm dev` after editing
`ui/frontend/.env.development.local`. `ui/frontend/.env.development.local`.
- **Port 8080 already in use** — stop the conflicting service or - **Port 8080 already in use** (or any other host-port in the
edit the host-side mapping in `docker-compose.yml` (gateway's stack — postgres `5433`, redis `6380`, mailpit `8025`, gateway
`ports:` entry) plus the matching `VITE_GATEWAY_BASE_URL` in REST `8080`, gateway gRPC `9090`) — each host-port mapping in
`ui/frontend/.env.development.local`. `docker-compose.yml` is parameterised through
`LOCAL_DEV_*_PORT` with the listed values as defaults. Set a
non-conflicting value either by uncommenting / editing the entry
in `tools/local-dev/.env`, by exporting the variable in your
shell, or by dropping a local override into a
`tools/local-dev/docker-compose.override.yml` (compose
auto-merges that file and it stays untracked by git). When
moving the gateway REST port off `8080`, also point the Vite dev
server at the new host port via
`VITE_DEV_PROXY_TARGET=http://localhost:<port>` in
`ui/frontend/.env.development.local` (or exported per
`pnpm dev` invocation).
## Relationship to other infrastructure ## Relationship to other infrastructure
+3
View File
@@ -13,6 +13,7 @@ FROM golang:1.26.2-alpine AS builder
WORKDIR /src WORKDIR /src
ENV CGO_ENABLED=0 GOFLAGS=-trimpath ENV CGO_ENABLED=0 GOFLAGS=-trimpath
COPY pkg/calc/ ./pkg/calc/
COPY pkg/cronutil/ ./pkg/cronutil/ COPY pkg/cronutil/ ./pkg/cronutil/
COPY pkg/error/ ./pkg/error/ COPY pkg/error/ ./pkg/error/
COPY pkg/geoip/ ./pkg/geoip/ COPY pkg/geoip/ ./pkg/geoip/
@@ -28,6 +29,7 @@ go 1.26.2
use ( use (
./backend ./backend
./pkg/calc
./pkg/cronutil ./pkg/cronutil
./pkg/error ./pkg/error
./pkg/geoip ./pkg/geoip
@@ -39,6 +41,7 @@ use (
) )
replace ( replace (
galaxy/calc v0.0.0 => ./pkg/calc
galaxy/cronutil v0.0.0 => ./pkg/cronutil galaxy/cronutil v0.0.0 => ./pkg/cronutil
galaxy/error v0.0.0 => ./pkg/error galaxy/error v0.0.0 => ./pkg/error
galaxy/geoip v0.0.0 => ./pkg/geoip galaxy/geoip v0.0.0 => ./pkg/geoip
+5 -5
View File
@@ -29,7 +29,7 @@ services:
POSTGRES_PASSWORD: galaxy POSTGRES_PASSWORD: galaxy
POSTGRES_DB: galaxy_backend POSTGRES_DB: galaxy_backend
ports: ports:
- "5433:5432" - "${LOCAL_DEV_POSTGRES_PORT:-5433}:5432"
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
networks: networks:
@@ -54,7 +54,7 @@ services:
- --save - --save
- "" - ""
ports: ports:
- "6380:6379" - "${LOCAL_DEV_REDIS_PORT:-6380}:6379"
networks: networks:
- galaxy-net - galaxy-net
healthcheck: healthcheck:
@@ -69,7 +69,7 @@ services:
container_name: galaxy-local-dev-mailpit container_name: galaxy-local-dev-mailpit
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8025:8025" - "${LOCAL_DEV_MAILPIT_PORT:-8025}:8025"
networks: networks:
- galaxy-net - galaxy-net
healthcheck: healthcheck:
@@ -186,11 +186,11 @@ services:
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_REQUESTS: "10000" GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_REQUESTS: "10000"
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_BURST: "1000" GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_BURST: "1000"
ports: ports:
- "8080:8080" - "${LOCAL_DEV_GATEWAY_REST_PORT:-8080}:8080"
# Authenticated EdgeGateway connect-web/gRPC listener. The # Authenticated EdgeGateway connect-web/gRPC listener. The
# browser reaches it via the Vite dev proxy in # browser reaches it via the Vite dev proxy in
# ui/frontend/vite.config.ts. # ui/frontend/vite.config.ts.
- "9090:9090" - "${LOCAL_DEV_GATEWAY_GRPC_PORT:-9090}:9090"
volumes: volumes:
- ./keys/gateway-response.pem:/run/secrets/gateway-response.pem:ro - ./keys/gateway-response.pem:/run/secrets/gateway-response.pem:ro
networks: networks:
+3
View File
@@ -11,6 +11,7 @@ FROM golang:1.26.2-alpine AS builder
WORKDIR /src WORKDIR /src
ENV CGO_ENABLED=0 GOFLAGS=-trimpath ENV CGO_ENABLED=0 GOFLAGS=-trimpath
COPY pkg/calc/ ./pkg/calc/
COPY pkg/cronutil/ ./pkg/cronutil/ COPY pkg/cronutil/ ./pkg/cronutil/
COPY pkg/error/ ./pkg/error/ COPY pkg/error/ ./pkg/error/
COPY pkg/geoip/ ./pkg/geoip/ COPY pkg/geoip/ ./pkg/geoip/
@@ -30,6 +31,7 @@ go 1.26.2
use ( use (
./backend ./backend
./gateway ./gateway
./pkg/calc
./pkg/cronutil ./pkg/cronutil
./pkg/error ./pkg/error
./pkg/geoip ./pkg/geoip
@@ -43,6 +45,7 @@ use (
) )
replace ( replace (
galaxy/calc v0.0.0 => ./pkg/calc
galaxy/cronutil v0.0.0 => ./pkg/cronutil galaxy/cronutil v0.0.0 => ./pkg/cronutil
galaxy/error v0.0.0 => ./pkg/error galaxy/error v0.0.0 => ./pkg/error
galaxy/geoip v0.0.0 => ./pkg/geoip galaxy/geoip v0.0.0 => ./pkg/geoip
+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)
} }
+600 -34
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
// because their prod_used derivation needs the producing planet's
// material and resources (read from "Your Planets") to call
// [calc.ShipBuildCost], and the section order is not guaranteed.
pendingGroups []pendingGroup pendingGroups []pendingGroup
pendingFleets []pendingFleet pendingFleets []pendingFleet
pendingIncomings []pendingIncoming 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 ""
+473 -21
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",
"", "",
"Foo Groups",
"",
"# T D W S C T Q L", "# T D W S C T Q L",
"1 PeaceShip 4 0 0 0 - 0 1 Out_Battle", "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)
} }
@@ -412,11 +801,14 @@ type smokeWant struct {
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
@@ -481,6 +897,12 @@ func TestParseDgKNNTS039(t *testing.T) {
localGroups: 171, localGroups: 171,
localFleets: 0, localFleets: 0,
incomingGroups: 0, incomingGroups: 0,
localScience: 1,
otherScience: 1,
otherShipClass: 170,
bombings: 16,
shipProductions: 6,
battles: 28,
}) })
} }
@@ -494,6 +916,12 @@ func TestParseDgKNNTS040(t *testing.T) {
localGroups: 207, localGroups: 207,
localFleets: 0, localFleets: 0,
incomingGroups: 0, incomingGroups: 0,
localScience: 1,
otherScience: 1,
otherShipClass: 160,
bombings: 24,
shipProductions: 16,
battles: 79,
}) })
} }
@@ -510,6 +938,12 @@ func TestParseDgKNNTS041(t *testing.T) {
localGroups: 285, localGroups: 285,
localFleets: 0, localFleets: 0,
incomingGroups: 12, incomingGroups: 12,
localScience: 1,
otherScience: 1,
otherShipClass: 218,
bombings: 12,
shipProductions: 22,
battles: 56,
}) })
} }
@@ -526,6 +960,12 @@ func TestParseGplus40(t *testing.T) {
localGroups: 255, localGroups: 255,
localFleets: 1, localFleets: 1,
incomingGroups: 10, incomingGroups: 10,
localScience: 0,
otherScience: 0,
otherShipClass: 183,
bombings: 4,
shipProductions: 8,
battles: 30,
}) })
} }
@@ -542,6 +982,12 @@ func TestParseDgKiller031(t *testing.T) {
localGroups: 175, localGroups: 175,
localFleets: 2, localFleets: 2,
incomingGroups: 0, incomingGroups: 0,
localScience: 0,
otherScience: 0,
otherShipClass: 161,
bombings: 18,
shipProductions: 0,
battles: 83,
}) })
} }
@@ -559,18 +1005,24 @@ func TestParseDgTancordia037(t *testing.T) {
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
+587 -126
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` / `удалить`.
@@ -2391,48 +2395,88 @@ Targeted tests:
via the Research sub-row, delete it via the Research sub-row, delete it
(`tests/e2e/sciences.spec.ts`). (`tests/e2e/sciences.spec.ts`).
## Phase 22. Races View — War/Peace Toggle and Votes ## ~~Phase 22. Races View — War/Peace Toggle and Votes~~
Status: pending. Status: done.
Goal: list other races with their visible stats, expose war/peace Goal: list other races with their visible stats, expose the war/peace
toggle and the voting UI. toggle, and the voting UI.
Artifacts: Artifacts:
- `ui/frontend/src/routes/games/[id]/table/races/+page.svelte` table - `ui/frontend/src/lib/active-view/table-races.svelte` table mounted
with one row per race, including name, tech levels, total by the dispatcher in
population, total production, planet count, war-or-peace from this `ui/frontend/src/lib/active-view/table.svelte` (same pattern as
race's perspective, votes received. The race list itself is read Phase 21's sciences table). One row per non-extinct other race
from `GameReport.otherRaces` (introduced in Phase 20 for the carrying name, tech levels (drive / weapons / shields / cargo as
ship-group transfer-to-race picker); the table view widens the percent), total population, total production (engine `industry`),
per-race shape (tech / population / production / planet count / planet count, votes received, and the local player's stance
votes / relation) by walking `report.player[]` directly when those toward that race. The richer per-race projection
fields are needed (`GameReport.races: ReportOtherRace[]`) is decoded in
- per-row toggle for declaring war or peace (adds `ui/frontend/src/api/game-state.ts` by walking `report.player[]`
`SetDiplomaticStance` command) once and surfacing the row alongside the existing `otherRaces:
- voting control: a single slot for `give my votes to <race>` (adds string[]` (which keeps backing the ship-group transfer picker from
`SetVoteRecipient` command) Phase 20)
- alliance summary panel showing the current vote graph and any - per-row segmented `WAR | PEACE` control. The active stance is
alliance reaching ≥ 2/3 of total votes highlighted (`aria-pressed=true` + contrast colour); the inactive
button queues `setDiplomaticStance` (engine `CommandRaceRelation`).
The displayed stance is the local player's relation toward the
named race (`rules.txt` "(R) Ваше отношение к указанной расе, но
не наоборот") — not the other way round
- voting control: a single `<select>` populated with `races[].name`,
changing it queues `setVoteRecipient` (engine `CommandRaceVote`).
Disabled when the local player is the only non-extinct race. A
read-only `myVotes` total renders next to the picker
- explanatory note in the page header: alliance grouping and the 2/3
victory check are tallied on the server at turn cutoff and are
NOT projected on the client. The report carries each race's votes
received (`Player.votes`) and the local player's outgoing vote
(`Report.vote_for`), but foreign races' outgoing votes are
intentionally private, so a client-side vote graph would be
partial. The acceptance criterion "vote counts match server state
byte-for-byte" forbids a local recomputation
Cross-stack notes:
- No backend / wire changes. `CommandRaceRelation`,
`CommandRaceVote`, `Player.relation`, `Player.votes`,
`Report.votes`, and `Report.vote_for` already carry every datum
this stage needs
- TS draft store
(`ui/frontend/src/sync/order-draft.svelte.ts`) gains two collapse
rules: `setDiplomaticStance` collapses by `acceptor` (one stance
intent per opponent); `setVoteRecipient` collapses singleton (a
single outgoing vote slot per `rules.txt:1066`)
- The optimistic overlay (`applyOrderOverlay`) flips
`races[i].relation` and `myVoteFor` immediately so the controls
reflect the queued intent without waiting for the auto-sync
round-trip. `votesReceived`, `myVotes`, and the alliance state
stay server-authoritative
Dependencies: Phase 14. Dependencies: Phase 14.
Acceptance criteria: Acceptance criteria:
- the user can toggle war / peace and change vote recipient; - the user can toggle war / peace and change vote recipient;
- the alliance summary updates after a server roundtrip; - the per-row stance and the "I vote for" picker reflect the
- vote counts match server state byte-for-byte. queued intent immediately (optimistic overlay) and resolve to
`applied` in the sidebar order tab after the auto-sync round-trip;
- vote counts match server state byte-for-byte (no client tally).
Targeted tests: Targeted tests:
- Vitest component tests for the alliance summary on canonical fixtures - Vitest component test
(chain of votes, fork, win condition); (`ui/frontend/tests/table-races.test.ts`) covering: render rows
- Playwright e2e: change diplomatic stance and vote, submit, confirm. from a canonical fixture, filter, sort flip, stance click +
collapse-by-acceptor, vote pick + singleton collapse, empty state;
- Playwright e2e (`ui/frontend/tests/e2e/races.spec.ts`): open the
races table, toggle one row's stance, change the vote recipient,
observe both commands as `applied` in the sidebar order tab and
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
@@ -2466,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.
@@ -2570,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
@@ -3094,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`).
+3 -1
View File
@@ -113,10 +113,12 @@ 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`) |
| `game-prefs` | `{gameId}/last-viewed-turn` | `number` | Phase 11+ (`game-state.md`) |
| `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | Phase 12+ (`order-composer.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();
+491 -7
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 {
@@ -51,8 +55,13 @@ import type {
CommandStatus, CommandStatus,
OrderCommand, OrderCommand,
ProductionType, ProductionType,
Relation,
} from "../sync/order-types";
import {
CARGO_LOAD_TYPE_VALUES,
isCargoLoadType,
isRelation,
} from "../sync/order-types"; } from "../sync/order-types";
import { CARGO_LOAD_TYPE_VALUES, isCargoLoadType } from "../sync/order-types";
const MESSAGE_TYPE = "user.games.report"; const MESSAGE_TYPE = "user.games.report";
@@ -239,6 +248,161 @@ export interface ReportLocalFleet {
state: string; state: string;
} }
/**
* ReportOtherRace is the per-other-race projection rendered by the
* Phase 22 Races View. The fields mirror `report.fbs:Player` row-by-
* row, with `relation` narrowed to the wire-stable `Relation` union
* (the engine emits a `"-"` sentinel for the self row, which never
* appears in `GameReport.races` because self is filtered out by
* `decodeReport`). Tech values are float fractions — the table
* renders them through the same `formatPercent` helper the sciences
* table uses.
*
* `relation` reflects the local player's stance TOWARD this race,
* not the other way around (`rules.txt` line 1162). Per the engine
* (`controller/race.go.UpdateRelation`) the relation is stored
* unilaterally — race A can be at war with race B while race B is
* at peace with race A.
*
* `votesReceived` is the count of votes this race received in the
* last turn cutoff tally (`Player.votes` on the wire). The total
* game votes equal the sum of every non-extinct row's
* `votesReceived`, since every race always votes for someone
* (`controller/race.go` initialises `r.VoteFor = r.ID` on creation
* and reassigns to self on extinction of the voted-for race).
*/
export interface ReportOtherRace {
name: string;
drive: number;
weapons: number;
shields: number;
cargo: number;
population: number;
industry: number;
planets: number;
relation: Relation;
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;
@@ -314,12 +478,90 @@ export interface GameReport {
* `report.player[]` block in the FBS report (each `Player` row * `report.player[]` block in the FBS report (each `Player` row
* carries an `extinct` flag). The ship-group inspector consumes * carries an `extinct` flag). The ship-group inspector consumes
* this list for the "transfer to race" picker; Phase 22's Races * this list for the "transfer to race" picker; Phase 22's Races
* View reuses the same field so the read shape is stable across * View also uses it for the vote-recipient picker so the read
* stages. Empty when the report has no `player` block (boot * shape stays stable across stages. Empty when the report has no
* state, history-mode snapshots) or when the local player is the * `player` block (boot state, history-mode snapshots) or when the
* only non-extinct race. * local player is the only non-extinct race.
*/ */
otherRaces: string[]; otherRaces: string[];
/**
* races is the richer per-other-race projection Phase 22 added
* for the Races View table — same population (non-extinct, self
* excluded, alphabetical) as `otherRaces`, but with each row
* carrying tech levels, totals, planet count, the local player's
* stance toward that race, and the race's votes received. Rows
* with an unknown wire `relation` (anything other than `WAR` or
* `PEACE`) default to `PEACE` so the table never blanks out the
* toggle on an engine schema bump; the same row continues to
* appear in the table.
*/
races: ReportOtherRace[];
/**
* myVotes is the local player's total vote weight in the current
* report, read from `Report.votes` (the engine assigns one vote
* per 1000 population, see `rules.txt:1060`). Zero when the
* report has not been produced yet.
*/
myVotes: number;
/**
* myVoteFor is the race the local player currently votes for,
* read from `Report.vote_for`. Empty string when no value has
* been recorded yet (boot state) or when the engine emitted an
* empty string. The engine's default initial state is each race
* voting for itself (`controller/race.go`), so a stable game's
* report always carries a non-empty value.
*/
myVoteFor: string;
/**
* players is the richer per-player projection Phase 23 added for
* the Report View's Player Status section. Same data source as
* `races[]` (`report.player[]`) but with the local player and
* extinct rows included, sorted alphabetically by name and tagged
* with `isLocal`. `races[]` stays Phase 22's view (other,
* non-extinct) so diplomatic-stance code paths do not churn.
*/
players: ReportPlayer[];
/**
* otherScience is the per-race foreign-sciences projection Phase
* 23 added for the Report View's Foreign Sciences section. Sorted
* by `(race, name)`. Empty when the report has no foreign science
* data (boot state, single-race game, legacy synthetic data).
*/
otherScience: ReportOtherScience[];
/**
* otherShipClass is the per-race foreign-ship-classes projection
* Phase 23 added for the Report View's Foreign Ship Classes
* section. Sorted by `(race, name)`. Empty when the report has no
* foreign ship-class data.
*/
otherShipClass: ReportOtherShipClass[];
/**
* 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(
@@ -467,11 +709,19 @@ function decodeReport(report: Report): GameReport {
const routes = decodeReportRoutes(report); const routes = decodeReportRoutes(report);
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 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()),
@@ -493,6 +743,16 @@ function decodeReport(report: Report): GameReport {
unidentifiedShipGroups, unidentifiedShipGroups,
localFleets, localFleets,
otherRaces, otherRaces,
races,
myVotes: report.votes(),
myVoteFor: report.voteFor() ?? "",
players,
otherScience,
otherShipClass,
battles,
battleIds,
bombings,
shipProductions,
}; };
} }
@@ -774,7 +1034,7 @@ function findLocalPlayerTech(
* the alphabetically-sorted names of every non-extinct race other * the alphabetically-sorted names of every non-extinct race other
* than the local player. Used by `GameReport.otherRaces` to back the * than the local player. Used by `GameReport.otherRaces` to back the
* ship-group inspector's transfer-to-race picker (Phase 20) and the * ship-group inspector's transfer-to-race picker (Phase 20) and the
* Races View list (Phase 22). * Races View vote-recipient picker (Phase 22).
*/ */
function collectOtherRaces(report: Report, raceName: string): string[] { function collectOtherRaces(report: Report, raceName: string): string[] {
const out: string[] = []; const out: string[] = [];
@@ -790,6 +1050,191 @@ function collectOtherRaces(report: Report, raceName: string): string[] {
return out; return out;
} }
/**
* collectOtherRaceRows walks the `report.player[]` block and returns
* the richer per-race projection consumed by the Phase 22 Races
* View. Same filter as `collectOtherRaces` (non-extinct, named,
* self excluded), same alphabetical sort. The engine emits
* `Player.relation = "-"` on the self row only — that row is
* filtered out, so a non-`"WAR"`/`"PEACE"` value here would mean a
* schema bump; we fall back to `"PEACE"` and keep the row visible
* rather than dropping it silently.
*/
function collectOtherRaceRows(
report: Report,
raceName: string,
): ReportOtherRace[] {
const out: ReportOtherRace[] = [];
for (let i = 0; i < report.playerLength(); i++) {
const player = report.player(i);
if (player === null) continue;
if (player.extinct()) continue;
const name = player.name() ?? "";
if (name === "" || name === raceName) continue;
const wire = player.relation() ?? "";
const relation: Relation = isRelation(wire) ? wire : "PEACE";
out.push({
name,
drive: player.drive(),
weapons: player.weapons(),
shields: player.shields(),
cargo: player.cargo(),
population: player.population(),
industry: player.industry(),
planets: player.planets(),
relation,
votesReceived: player.votes(),
});
}
out.sort((a, b) => a.name.localeCompare(b.name));
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
@@ -841,6 +1286,8 @@ export function applyOrderOverlay(
let mutatedRoutes: ReportRoute[] | null = null; let mutatedRoutes: ReportRoute[] | null = null;
let mutatedShipClass: ShipClassSummary[] | null = null; let mutatedShipClass: ShipClassSummary[] | null = null;
let mutatedScience: ScienceSummary[] | null = null; let mutatedScience: ScienceSummary[] | null = null;
let mutatedRaces: ReportOtherRace[] | null = null;
let mutatedVoteFor: string | null = null;
for (const cmd of commands) { for (const cmd of commands) {
const status = statuses[cmd.id]; const status = statuses[cmd.id];
if ( if (
@@ -964,12 +1411,36 @@ export function applyOrderOverlay(
mutatedScience.splice(idx, 1); mutatedScience.splice(idx, 1);
continue; continue;
} }
if (cmd.kind === "setDiplomaticStance") {
if (mutatedRaces === null) {
// `?? []` mirrors the per-branch HMR guard pattern: a
// running `gameState.report` produced before Phase 22's
// shape bump may not carry `races` yet — preserve a
// well-defined array on the way out so downstream
// `$derived` blocks (`races.map`, `races.find`, …)
// never fault on `undefined`.
mutatedRaces = [...(report.races ?? [])];
}
const idx = mutatedRaces.findIndex((r) => r.name === cmd.acceptor);
if (idx < 0) continue;
mutatedRaces[idx] = {
...mutatedRaces[idx]!,
relation: cmd.relation,
};
continue;
}
if (cmd.kind === "setVoteRecipient") {
mutatedVoteFor = cmd.acceptor;
continue;
}
} }
if ( if (
mutatedPlanets === null && mutatedPlanets === null &&
mutatedRoutes === null && mutatedRoutes === null &&
mutatedShipClass === null && mutatedShipClass === null &&
mutatedScience === null mutatedScience === null &&
mutatedRaces === null &&
mutatedVoteFor === null
) { ) {
return report; return report;
} }
@@ -984,6 +1455,19 @@ export function applyOrderOverlay(
// `localScience.find`, …) fault and the active view blanks. // `localScience.find`, …) fault and the active view blanks.
localShipClass: mutatedShipClass ?? report.localShipClass ?? [], localShipClass: mutatedShipClass ?? report.localShipClass ?? [],
localScience: mutatedScience ?? report.localScience ?? [], localScience: mutatedScience ?? report.localScience ?? [],
races: mutatedRaces ?? report.races ?? [],
myVoteFor: mutatedVoteFor ?? report.myVoteFor,
// Phase 23 read-only fields. No overlay branches touch them
// today; the `?? []` keeps a stale HMR-instance of `report`
// (loaded before the shape bump) from blanking the Report
// View when its section components iterate.
players: report.players ?? [],
otherScience: report.otherScience ?? [],
otherShipClass: report.otherShipClass ?? [],
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();
}
+262 -4
View File
@@ -20,19 +20,27 @@
import type { import type {
GameReport, GameReport,
ReportBombing,
ReportIncomingShipGroup, ReportIncomingShipGroup,
ReportLocalFleet, ReportLocalFleet,
ReportLocalShipGroup, ReportLocalShipGroup,
ReportOtherRace,
ReportOtherScience,
ReportOtherShipClass,
ReportOtherShipGroup, ReportOtherShipGroup,
ReportPlanet, ReportPlanet,
ReportPlayer,
ReportRoute, ReportRoute,
ReportShipProduction,
ReportUnidentifiedShipGroup, ReportUnidentifiedShipGroup,
ScienceSummary, ScienceSummary,
ShipClassSummary, ShipClassSummary,
ShipGroupTech, ShipGroupTech,
} from "./game-state"; } from "./game-state";
import type { CargoLoadType } from "../sync/order-types"; import type { CargoLoadType, Relation } from "../sync/order-types";
import { isCargoLoadType } 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-";
@@ -53,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 {
@@ -103,6 +164,11 @@ interface SyntheticPlayer {
weapons: number; weapons: number;
shields: number; shields: number;
cargo: number; cargo: number;
population?: number;
industry?: number;
planets?: number;
relation?: string;
votes?: number;
extinct?: boolean; extinct?: boolean;
} }
@@ -153,24 +219,70 @@ 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;
mapHeight?: number; mapHeight?: number;
mapPlanets?: number; mapPlanets?: number;
race?: string; race?: string;
votes?: number;
voteFor?: string;
player?: SyntheticPlayer[]; player?: SyntheticPlayer[];
localPlanet?: SyntheticPlanet[]; localPlanet?: SyntheticPlanet[];
otherPlanet?: SyntheticPlanet[]; otherPlanet?: SyntheticPlanet[];
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 {
@@ -270,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),
@@ -290,9 +482,47 @@ function decodeSyntheticReport(json: unknown): GameReport {
unidentifiedShipGroups, unidentifiedShipGroups,
localFleets, localFleets,
otherRaces: collectOtherRacesFromSynthetic(root, race), otherRaces: collectOtherRacesFromSynthetic(root, race),
races: collectOtherRaceRowsFromSynthetic(root, race),
myVotes: numOr0(root.votes),
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,
@@ -308,6 +538,34 @@ function collectOtherRacesFromSynthetic(
return out; return out;
} }
function collectOtherRaceRowsFromSynthetic(
root: SyntheticReportRoot,
raceName: string,
): ReportOtherRace[] {
const out: ReportOtherRace[] = [];
for (const player of root.player ?? []) {
if (player.extinct === true) continue;
const name = typeof player.name === "string" ? player.name : "";
if (name === "" || name === raceName) continue;
const wire = typeof player.relation === "string" ? player.relation : "";
const relation: Relation = isRelation(wire) ? wire : "PEACE";
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)),
relation,
votesReceived: numOr0(player.votes),
});
}
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
function toShipGroupTech(raw: Record<string, number> | undefined): ShipGroupTech { function toShipGroupTech(raw: Record<string, number> | undefined): ShipGroupTech {
const out: ShipGroupTech = { drive: 0, weapons: 0, shields: 0, cargo: 0 }; const out: ShipGroupTech = { drive: 0, weapons: 0, shields: 0, cargo: 0 };
if (raw === undefined || raw === null) return out; if (raw === undefined || raw === null) return out;
+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>
+43 -4
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,
@@ -96,7 +98,17 @@ preference the store already manages.
let detachClick: (() => void) | null = null; let detachClick: (() => void) | null = null;
let detachDebugProviders: (() => void) | null = null; let detachDebugProviders: (() => void) | null = null;
let detachDebugSurface: (() => void) | null = null; let detachDebugSurface: (() => void) | null = null;
let mounted = false; // `mounted` must be `$state` so the renderer-mount effect re-runs
// once `onMount` flips it true. On the first map navigation the
// effect's initial pass returns early (gameState is still hydrating
// → `report` is null), and the subsequent server-driven `report`
// transition re-fires the effect after `onMount` has already
// completed. On a second navigation back to /map the report is
// already loaded — without reactivity here the effect's first
// pass would gate on `mounted === false`, and there would be no
// later state change to wake it up. The visible symptom is a
// black canvas (renderer never re-mounted on the new DOM).
let mounted = $state(false);
// Mount serialization. The `$effect` may re-fire while the // Mount serialization. The `$effect` may re-fire while the
// async `mountRenderer` is mid-flight (e.g. report transitions // async `mountRenderer` is mid-flight (e.g. report transitions
// from null → populated → overlay-mutated during boot). Without // from null → populated → overlay-mutated during boot). Without
@@ -392,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) {
case "planet":
if (hit.primitive.kind !== "point") return;
selection.selectPlanet(target.number); selection.selectPlanet(target.number);
} else { break;
case "shipGroup":
if (hit.primitive.kind !== "point") return;
selection.selectShipGroup(target.ref); 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;
}
.report-body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
@media (max-width: 767.98px) {
.report-view {
grid-template-columns: 1fr;
padding: 0.75rem;
gap: 0.75rem;
}
.report-view > :global(.report-toc) {
position: sticky;
top: 0;
background: #0a0e1a;
padding: 0.5rem 0;
z-index: 5;
} }
.active-view p {
margin: 0;
color: #555;
} }
</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>

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