12 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
92 changed files with 53685 additions and 12092 deletions
+36
View File
@@ -26,6 +26,7 @@ const (
pathPlayerCommand = "/api/v1/command"
pathPlayerOrder = "/api/v1/order"
pathPlayerReport = "/api/v1/report"
pathPlayerBattle = "/api/v1/battle"
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.
func (c *Client) Healthz(ctx context.Context, baseURL string) error {
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) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathHealthz {
+1
View File
@@ -45,6 +45,7 @@ var pathParamStubs = map[string]string{
"delivery_id": "00000000-0000-0000-0000-000000000006",
"user_id": "00000000-0000-0000-0000-000000000007",
"device_session_id": "00000000-0000-0000-0000-000000000008",
"battle_id": "00000000-0000-0000-0000-000000000009",
"id": "1.2.3",
"username": "alice",
"turn": "42",
@@ -243,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
// raceName, and re-encodes. Backend never trusts the actor field
// supplied by the client (per ARCHITECTURE.md §9).
+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.GET("/:game_id/orders", deps.UserGames.GetOrders())
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.GET("", deps.UserSessions.List())
+38
View File
@@ -1106,6 +1106,44 @@ paths:
$ref: "#/components/responses/NotImplementedError"
"500":
$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:
get:
tags: [User]
+65 -3
View File
@@ -657,7 +657,7 @@ in `runtime_records.turn_schedule`. The backend scheduler
- 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.5). The order handlers reject with HTTP 409
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
@@ -686,7 +686,69 @@ are exposed in a sticky table of contents (a `<select>` on mobile)
and the scroll position is preserved across active-view switches
via SvelteKit's `Snapshot` API.
### 6.5 Side effects
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
lobby module, which updates the denormalised view (current turn,
@@ -719,7 +781,7 @@ 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.6 Cross-references
### 6.7 Cross-references
- Backend ↔ engine wire contract (`pkg/model/{order,report,rest}`):
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
+65 -3
View File
@@ -675,7 +675,7 @@ engine `/admin/turn` двумя `runtime_status`-флипами:
- После провалившегося тика (`engine_unreachable` /
`generation_failed`): `lobby.OnRuntimeSnapshot` переводит игру
`running → paused` и публикует push-эвент `game.paused`
(см. §6.5). Order-handler'ы отклоняют запросы с HTTP 409 +
(см. §6.6). Order-handler'ы отклоняют запросы с HTTP 409 +
`code = game_paused`, пока админ не выполнит resume.
`force-next-turn` (admin) планирует one-shot-доп-тик, который
@@ -704,7 +704,69 @@ empty-state. Якоря секций отображены в sticky-TOC (на м
`<select>`); позиция скролла сохраняется при переключении активного
представления через SvelteKit `Snapshot` API.
### 6.5 Побочные эффекты
Секция бомбардировок — это плоская 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-
@@ -740,7 +802,7 @@ Directory-промоушен ([Раздел 5](#5-реестр-названий-
каталоге, расширить `CHECK`-констрейнт миграции и вызвать
`notification.Submit` из подходящего доменного модуля).
### 6.6 Перекрёстные ссылки
### 6.7 Перекрёстные ссылки
- Backend ↔ engine wire-контракт (`pkg/model/{order,report,rest}`):
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
+87
View File
@@ -8,6 +8,7 @@ import (
"galaxy/calc"
"galaxy/game/internal/controller"
"galaxy/game/internal/model/game"
"galaxy/model/report"
"github.com/stretchr/testify/assert"
)
@@ -184,3 +185,89 @@ func TestProduceBattles(t *testing.T) {
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)
cacheRaceName := make(map[uuid.UUID]int)
processedGroup := make(map[int]bool)
addShipGroup := func(groupId int, inBattle bool) int {
shipClass := c.ShipGroupShipClass(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)
bg := &report.BattleReportGroup{
Race: c.g.Race[c.RaceIndex(sg.OwnerID)].Name,
@@ -31,23 +56,20 @@ func TransformBattle(c *Cache, b *Battle) *report.BattleReport {
ClassName: shipClass.Name,
LoadType: sg.CargoString(),
LoadQuantity: report.F(sg.Load.F()),
Tech: make(map[string]report.Float, len(sg.Tech)),
}
for t, v := range sg.Tech {
bg.Tech[t.String()] = report.F(v.F())
}
r.Ships[itemNumber] = *bg
cacheShipClass[shipClass.ID] = itemNumber
processedGroup[groupId] = true
return itemNumber
}
ship := func(groupId int) int {
shipClass := c.ShipGroupShipClass(groupId)
if v, ok := cacheShipClass[shipClass.ID]; ok {
return v
} else {
return addShipGroup(groupId, true)
}
}
race := func(groupId int) int {
race := c.ShipGroupOwnerRace(groupId)
+6 -2
View File
@@ -37,7 +37,7 @@ func (c *Cache) InitReport(t uint) *mr.Report {
OtherScience: make([]mr.OtherScience, 0, 10),
LocalShipClass: make([]mr.ShipClass, 0, 20),
OtherShipClass: make([]mr.OthersShipClass, 0, 50),
Battle: make([]uuid.UUID, 0, 10),
Battle: make([]mr.BattleSummary, 0, 10),
Bombing: make([]*mr.Bombing, 0, 10),
IncomingGroup: make([]mr.IncomingGroup, 0, 10),
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)
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++
}
}
+26 -3
View File
@@ -584,10 +584,9 @@ components:
$ref: "#/components/schemas/OtherShipClass"
battle:
type: array
description: UUIDs of battle reports relevant to this turn.
description: Battle summaries relevant to this turn.
items:
type: string
format: uuid
$ref: "#/components/schemas/BattleSummary"
bombing:
type: array
description: Bombing events that occurred during this turn.
@@ -831,6 +830,30 @@ components:
wiped:
type: boolean
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: |
+16
View File
@@ -327,6 +327,22 @@ func TestGameOpenAPISpecFreezesBattleReport(t *testing.T) {
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) {
t.Parallel()
+10
View File
@@ -6,6 +6,16 @@ import (
"github.com/google/uuid"
)
// BattleSummary identifies one battle relevant to the report recipient
// and carries the data needed to render a battle marker on the map
// without fetching the full BattleReport. Planet locates the marker;
// Shots scales the marker stroke with the battle length.
type BattleSummary struct {
ID uuid.UUID `json:"id"`
Planet uint `json:"planet"`
Shots uint `json:"shots"`
}
type BattleReport struct {
// Battle unique ID
ID uuid.UUID `json:"id"`
+1 -1
View File
@@ -33,7 +33,7 @@ type Report struct {
OtherScience []OtherScience `json:"otherScience,omitempty"`
LocalShipClass []ShipClass `json:"localShipClass,omitempty"`
OtherShipClass []OthersShipClass `json:"otherShipClass,omitempty"`
Battle []uuid.UUID `json:"battle,omitempty"`
Battle []BattleSummary `json:"battle,omitempty"`
Bombing []*Bombing `json:"bombing,omitempty"`
IncomingGroup []IncomingGroup `json:"incomingGroup,omitempty"`
LocalPlanet []LocalPlanet `json:"localPlanet,omitempty"`
+12 -1
View File
@@ -196,6 +196,17 @@ table LocalFleet {
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 {
version:uint64;
turn:uint64;
@@ -210,7 +221,7 @@ table Report {
other_science:[OtherScience];
local_ship_class:[ShipClass];
other_ship_class:[OthersShipClass];
battle:[common.UUID];
battle:[BattleSummary];
bombing:[Bombing];
incoming_group:[IncomingGroup];
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 (
flatbuffers "github.com/google/flatbuffers/go"
common "galaxy/schema/fbs/common"
)
type Report struct {
@@ -231,11 +229,12 @@ func (rcv *Report) OtherShipClassLength() int {
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))
if o != 0 {
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)
return true
}
@@ -551,7 +550,7 @@ func ReportAddBattle(builder *flatbuffers.Builder, battle flatbuffers.UOffsetT)
builder.PrependUOffsetTSlot(13, flatbuffers.UOffsetT(battle), 0)
}
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) {
builder.PrependUOffsetTSlot(14, flatbuffers.UOffsetT(bombing), 0)
+36 -12
View File
@@ -10,7 +10,6 @@ import (
fbs "galaxy/schema/fbs/report"
flatbuffers "github.com/google/flatbuffers/go"
"github.com/google/uuid"
)
// 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)
localShipClassVector := encodeReportOffsetVector(builder, len(localShipClassOffsets), fbs.ReportStartLocalShipClassVector, localShipClassOffsets)
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)
incomingGroupVector := encodeReportOffsetVector(builder, len(incomingGroupOffsets), fbs.ReportStartIncomingGroupVector, incomingGroupOffsets)
localPlanetVector := encodeReportOffsetVector(builder, len(localPlanetOffsets), fbs.ReportStartLocalPlanetVector, localPlanetOffsets)
@@ -734,13 +733,29 @@ func decodeReportBattleVector(flatReport *fbs.Report, result *model.Report) erro
return nil
}
result.Battle = make([]uuid.UUID, length)
item := new(commonfbs.UUID)
result.Battle = make([]model.BattleSummary, length)
item := new(fbs.BattleSummary)
idHolder := new(commonfbs.UUID)
for i := 0; i < length; 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)
}
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
@@ -1299,17 +1314,26 @@ func encodeReportOffsetVector(
return builder.EndVector(length)
}
func encodeReportUUIDVector(builder *flatbuffers.Builder, ids []uuid.UUID) flatbuffers.UOffsetT {
if len(ids) == 0 {
func encodeReportBattleSummaries(builder *flatbuffers.Builder, summaries []model.BattleSummary) flatbuffers.UOffsetT {
if len(summaries) == 0 {
return 0
}
fbs.ReportStartBattleVector(builder, len(ids))
for i := len(ids) - 1; i >= 0; i-- {
hi, lo := uuidToHiLo(ids[i])
commonfbs.CreateUUID(builder, hi, lo)
offsets := make([]flatbuffers.UOffsetT, len(summaries))
for i := range summaries {
hi, lo := uuidToHiLo(summaries[i].ID)
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 {
+11 -3
View File
@@ -255,9 +255,17 @@ func sampleReport() *model.Report {
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)}},
},
Battle: []uuid.UUID{
uuid.MustParse("11111111-1111-1111-1111-111111111111"),
uuid.MustParse("22222222-2222-2222-2222-222222222222"),
Battle: []model.BattleSummary{
{
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{
{
+38 -12
View File
@@ -1,8 +1,25 @@
# legacy-report-to-json
Converts legacy text-format Galaxy turn reports (the *dg* and *gplus*
engines that lived under `tools/local-dev/reports/`) into the JSON
shape of [`pkg/model/report.Report`](../../../pkg/model/report).
engines that lived under `tools/local-dev/reports/`) into a JSON
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 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
# from the repo root, with the Go workspace active
go run ./tools/local-dev/legacy-report/cmd/legacy-report-to-json \
--in tools/local-dev/reports/dg/KNNTS039.REP \
--out tools/local-dev/reports/dg/KNNTS039.json
--in tools/local-dev/reports/dg/KNNTS041.REP \
--out tools/local-dev/reports/dg/KNNTS041.json
```
`--in` reads `-` as stdin; `--out` defaults to stdout when empty or
@@ -68,6 +85,21 @@ already decodes from server responses
| `LocalGroup[]` | `Your Groups` (Phase 19) |
| `LocalFleet[]` | `Your Fleets` (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
the suffix stripped and `Extinct: true`.
@@ -103,15 +135,9 @@ These exist in legacy reports but cannot be derived from the legacy
text format at all. Each could become in-scope if a strong enough
reason arises (see "Adding a new field" below).
- Battles (`Battle at (#N) Name`, `Battle Protocol`) — the wire schema
carries battle UUIDs (`Report.Battle: []uuid.UUID`); the legacy text
carries per-battle rosters with stripped columns (no origin / range /
destination) and no stable identifier. Synthesising UUIDs from the
text would invent data that future Phase 27 work would have to drop;
the synthetic JSON therefore emits `battle: []`.
- `OtherGroup[]` — no top-level legacy section. Foreign groups appear
only inside battle rosters (see above), with stripped columns; the
synthetic JSON emits `otherGroup: []`.
only inside battle rosters; the synthetic JSON emits
`otherGroup: []`.
- `UnidentifiedGroup[]` — no legacy section at all; synthetic JSON
emits `unidentifiedGroup: []`.
- Cargo routes — no dedicated section in the legacy text format; the
@@ -1,7 +1,18 @@
// Command legacy-report-to-json converts a legacy text-format Galaxy
// turn report (the "dg" / "gplus" engines) into the JSON shape of
// pkg/model/report.Report. The resulting file is what the UI client's
// DEV-only synthetic-report loader on the lobby consumes.
// turn report (the "dg" / "gplus" engines) into a JSON envelope
// readable by the UI client's DEV-only synthetic-report loader:
//
// {
// "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
import (
@@ -12,8 +23,18 @@ import (
"os"
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() {
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)")
@@ -31,7 +52,7 @@ func main() {
}
defer closeIn()
rep, err := legacyreport.Parse(r)
rep, battles, err := legacyreport.Parse(r)
if err != nil {
fmt.Fprintf(os.Stderr, "parse: %v\n", err)
os.Exit(1)
@@ -44,9 +65,17 @@ func main() {
}
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.SetIndent("", " ")
if err := enc.Encode(rep); err != nil {
if err := enc.Encode(env); err != nil {
fmt.Fprintf(os.Stderr, "encode: %v\n", err)
os.Exit(1)
}
+361 -14
View File
@@ -26,22 +26,29 @@ import (
)
// Parse reads a legacy text report and returns a [report.Report]
// carrying the in-scope subset of fields. The Width and Height of the
// returned report are both set to the legacy "Size" value (galaxies
// are square in the legacy engines).
func Parse(r io.Reader) (report.Report, error) {
// carrying the in-scope subset of fields, plus the per-battle
// [report.BattleReport] payloads parsed out of the "Battle at (#N)"
// blocks. The Width and Height of the returned report are both set
// 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()
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 1024*1024), 4*1024*1024)
for sc.Scan() {
if err := p.handle(sc.Text()); err != nil {
return report.Report{}, err
return report.Report{}, nil, err
}
}
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
@@ -63,6 +70,8 @@ const (
sectionOtherShipTypes
sectionBombings
sectionShipsInProduction
sectionBattle
sectionBattleProtocol
)
type parser struct {
@@ -85,6 +94,40 @@ type parser struct {
pendingFleets []pendingFleet
pendingIncomings []pendingIncoming
pendingShipProducts []pendingShipProduction
// Battle accumulator. `battles` collects every parsed BattleReport;
// `pendingBattle` carries the in-flight battle until its block
// ends (next "Battle at " header, a top-level section header, or
// end-of-file). `battleIndex` is the per-report 0-based index used
// to derive a stable synthetic UUID through `syntheticBattleID`.
// `pendingBattleRace` holds the race name currently being
// rostered, set by the "<Race> Groups" sub-header that opens each
// race's roster table inside the battle block.
battles []report.BattleReport
pendingBattle *pendingBattle
battleIndex uint
pendingBattleRace string
}
type pendingBattle struct {
id uuid.UUID
planet uint
planetName string
// Race name → race index used in Protocol.{a,d}. Indices are
// 0-based and assigned in first-seen order across the battle.
raceIndex map[string]int
// (race name, class name) → ship-group index used in
// Protocol.{sa,sd}. Indices are 0-based and assigned in
// first-seen order across the battle, across all races.
shipIndex map[shipKey]int
races map[int]uuid.UUID
ships map[int]report.BattleReportGroup
protocol []report.BattleActionReport
}
type shipKey struct {
race string
class string
}
type pendingGroup struct {
@@ -155,10 +198,62 @@ func (p *parser) handle(line string) error {
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 {
// 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.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
}
@@ -205,16 +300,21 @@ func (p *parser) handle(line string) error {
p.parseBombing(fields)
case sectionShipsInProduction:
p.parseShipProductionRow(fields)
case sectionBattle:
p.parseBattleRosterRow(fields)
case sectionBattleProtocol:
p.parseBattleProtocolLine(fields)
}
return nil
}
func (p *parser) finish() (report.Report, error) {
func (p *parser) finish() ([]report.BattleReport, error) {
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()
return p.rep, nil
return p.battles, nil
}
// parseHeader extracts (race, turn) from
@@ -294,15 +394,16 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
case "Ships In Production":
return sectionShipsInProduction, "", true
case "Approaching Groups",
"Broadcast Message",
"Battle Protocol":
"Broadcast Message":
return sectionNone, "", true
case "Battle Protocol":
return sectionBattleProtocol, "", true
}
if strings.HasPrefix(line, "Status of Players") {
return sectionStatusOfPlayers, "", true
}
if strings.HasPrefix(line, "Battle at ") {
return sectionNone, "", true
return sectionBattle, "", true
}
if strings.HasPrefix(line, "=== ATTENTION") {
return sectionNone, "", true
@@ -637,6 +738,227 @@ func (p *parser) parseBombing(fields []string) {
})
}
// 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:
//
@@ -957,6 +1279,31 @@ func syntheticGroupID(g uint) uuid.UUID {
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 {
if s == "-" {
return ""
+261 -29
View File
@@ -18,7 +18,7 @@ func TestParseHeaderAndSize(t *testing.T) {
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
@@ -54,7 +54,7 @@ func TestParseStatusOfPlayers(t *testing.T) {
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
@@ -87,7 +87,7 @@ func TestParseYourVote(t *testing.T) {
"KnightErrants 16.02",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
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",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
@@ -160,7 +160,7 @@ func TestParseUninhabitedAndUnidentified(t *testing.T) {
" 1 579.12 489.37",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
@@ -196,7 +196,7 @@ func TestParseShipClasses(t *testing.T) {
"Dragon 16.70 1 1.10 1.00 1 19.80",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
@@ -241,7 +241,7 @@ func TestParseSciences(t *testing.T) {
"_Drift 1 0 0 0",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
@@ -280,7 +280,7 @@ func TestParseBombings(t *testing.T) {
"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))
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
@@ -339,7 +339,7 @@ func TestParseShipsInProduction(t *testing.T) {
" 17 Castle CombatFlame 990.10 0.07 1000.00",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
@@ -381,7 +381,7 @@ func TestParseShipsInProductionDropsUnknownPlanet(t *testing.T) {
" 99 Lost Frigate 100.00 0.05 500.00",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
@@ -391,23 +391,57 @@ func TestParseShipsInProductionDropsUnknownPlanet(t *testing.T) {
}
}
// TestParseSkipsBattles covers the only remaining legacy section the
// parser ignores: "Battle at ..." headers and the following "Battle
// Protocol" block. Bombings, Ships In Production, and the per-race
// Sciences / Ship Types blocks now flow through real parsers; the
// dedicated section tests below cover them.
func TestParseSkipsBattles(t *testing.T) {
// 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{
"Race Report for Galaxy PLUS Turn 1",
"",
"Battle at (#7) B-007",
"",
"Foo Groups",
"",
"# 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",
"",
"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",
"",
"Battle at (#11) X-011",
"",
"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",
"",
@@ -415,15 +449,187 @@ func TestParseSkipsBattles(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",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
rep, battles, err := Parse(strings.NewReader(in))
if err != nil {
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 {
t.Fatalf("len(LocalPlanet) = %d, want %d (battle 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), 0; got != want {
t.Errorf("len(Battle) = %d, want %d (legacy parser does not synthesise battle UUIDs)", 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)
}
}
@@ -451,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",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
@@ -515,7 +721,7 @@ func TestParseYourFleets(t *testing.T) {
" 1 Far 2 North Castle 4.50 20 In_Space",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
@@ -564,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",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
rep, _, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
@@ -597,11 +803,12 @@ type smokeWant struct {
localGroups, localFleets, incomingGroups int
localScience, otherScience, otherShipClass int
bombings, shipProductions int
battles int
}
func runSmoke(t *testing.T, path string, want smokeWant) {
t.Helper()
rep, err := parseFile(t, path)
rep, battles, err := parseFile(t, path)
if err != nil {
if os.IsNotExist(err) {
t.Skipf("legacy report fixture missing: %s", path)
@@ -647,12 +854,31 @@ func runSmoke(t *testing.T, path string, want smokeWant) {
{"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 {
if 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
@@ -676,6 +902,7 @@ func TestParseDgKNNTS039(t *testing.T) {
otherShipClass: 170,
bombings: 16,
shipProductions: 6,
battles: 28,
})
}
@@ -694,6 +921,7 @@ func TestParseDgKNNTS040(t *testing.T) {
otherShipClass: 160,
bombings: 24,
shipProductions: 16,
battles: 79,
})
}
@@ -715,6 +943,7 @@ func TestParseDgKNNTS041(t *testing.T) {
otherShipClass: 218,
bombings: 12,
shipProductions: 22,
battles: 56,
})
}
@@ -736,6 +965,7 @@ func TestParseGplus40(t *testing.T) {
otherShipClass: 183,
bombings: 4,
shipProductions: 8,
battles: 30,
})
}
@@ -757,6 +987,7 @@ func TestParseDgKiller031(t *testing.T) {
otherShipClass: 161,
bombings: 18,
shipProductions: 0,
battles: 83,
})
}
@@ -779,18 +1010,19 @@ func TestParseDgTancordia037(t *testing.T) {
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()
abs, err := filepath.Abs(rel)
if err != nil {
return report.Report{}, err
return report.Report{}, nil, err
}
f, err := os.Open(abs)
if err != nil {
return report.Report{}, err
return report.Report{}, nil, err
}
defer func() { _ = f.Close() }()
return Parse(f)
File diff suppressed because it is too large Load Diff
+125 -29
View File
@@ -2945,49 +2945,130 @@ Targeted tests:
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
(play, pause, step forward, step backward, rewind), driven by the
server-side combat log; render battle and bombing markers on the map.
Goal: ship a dedicated Battle Viewer rendering radial scenes from
`BattleReport` data (planet centred, races on the outer ring, per
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:
- `ui/frontend/src/map/battle-markers.ts` renders markers on the map
for current-turn battles and bombings within visibility, clickable
to open the battle viewer
- `ui/frontend/src/routes/games/[id]/battle/[battleId]/+page.svelte`
view with the combatant list, the round-by-round log, and a player
control bar
- `ui/frontend/src/lib/battle-player/` round timeline, current-round
highlight, per-shot animation
- entry points to the viewer: marker on map, row in the report's
battles section, push-event toast when a battle this turn involved
the player
- topic doc `ui/docs/battle-viewer-ux.md` covering playback
semantics, accessibility (the combat log must be readable as text
for users who skip animations)
- engine: `game/internal/router/handler/battle.go` for
`GET /api/v1/battle/:turn/:uuid` (handler pre-existed; Phase 27
added the tests + openapi schemas)
- engine wire: `pkg/model/report/battle.go` ships a new
`BattleSummary{id, planet, shots}`; `Report.battle` carries a
slice of these summaries so the map can place markers without
fetching every full report
- backend: `backend/internal/engineclient/client.go.FetchBattle`
and `backend/internal/server/handlers_user_games.go.Battle`
expose `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`
- UI viewer: `ui/frontend/src/lib/battle-player/`
(`radial-layout.ts`, `timeline.ts`, `battle-scene.svelte`,
`playback-controls.svelte`, `battle-viewer.svelte`); SVG-based,
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.
Acceptance criteria:
- battle and bombing markers render on the map for the seeded
current-turn report and are clickable to open the viewer;
- the viewer plays back any battle in the seeded report including
multi-round and one-sided battles;
- step controls allow precise inspection;
- the same data is accessible as a static text log for accessibility.
current-turn report and are clickable: battle → Battle Viewer for
the corresponding UUID, bombing → scroll to its row in Reports;
- the Battle Viewer plays back any `BattleReport` end-to-end with
step back / step forward / rewind / 1x-2x-4x speeds; observers
(`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:
- Vitest unit tests for round-state transitions;
- Vitest unit tests for marker rendering on torus and no-wrap
fixtures;
- Playwright e2e: click a battle marker on the map, play through,
step backward, return to the report.
- Vitest unit: radial layout (1/2/3 races) and timeline frame-
builder (initial state, shot decrement, race-elimination drop-out)
in `tests/battle-player.test.ts`
- Vitest unit: marker primitives + stroke-width formula
(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
@@ -3459,3 +3540,18 @@ phase listed in the parenthesis when that phase lands.
exercises a unary Connect call and a server-streaming Connect call
through `testenv.Bootstrap`. (Phase 7+, fold into the phase that
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.
+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;
}
+38 -12
View File
@@ -382,6 +382,18 @@ export interface ReportBombing {
* 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;
@@ -524,11 +536,17 @@ export interface GameReport {
*/
otherShipClass: ReportOtherShipClass[];
/**
* battleIds is the list of battle UUIDs the engine recorded for
* the current turn. Phase 23 renders them as inactive
* monospace identifiers; Phase 27 will turn them into navigation
* targets once the battle viewer lands. Empty when no battles
* occurred last turn.
* 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[];
/**
@@ -700,7 +718,8 @@ function decodeReport(report: Report): GameReport {
const localFleets = decodeLocalFleets(report);
const otherScience = decodeOtherScience(report);
const otherShipClass = decodeOtherShipClass(report);
const battleIds = decodeBattleIds(report);
const battles = decodeBattles(report);
const battleIds = battles.map((b) => b.id);
const bombings = decodeBombings(report);
const shipProductions = decodeShipProductions(report);
@@ -730,6 +749,7 @@ function decodeReport(report: Report): GameReport {
players,
otherScience,
otherShipClass,
battles,
battleIds,
bombings,
shipProductions,
@@ -1153,13 +1173,18 @@ function decodeOtherShipClass(report: Report): ReportOtherShipClass[] {
return out;
}
function decodeBattleIds(report: Report): string[] {
const out: string[] = [];
function decodeBattles(report: Report): ReportBattle[] {
const out: ReportBattle[] = [];
for (let i = 0; i < report.battleLength(); i++) {
const uuid = report.battle(i);
const value = uuidStringFromFB(uuid);
if (value === null) continue;
out.push(value);
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;
}
@@ -1439,6 +1464,7 @@ export function applyOrderOverlay(
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();
}
+76 -6
View File
@@ -39,6 +39,8 @@ import type {
} from "./game-state";
import type { CargoLoadType, Relation } 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-";
@@ -59,18 +61,71 @@ export class SyntheticReportError extends Error {
* loadSyntheticReportFromJSON validates the passed payload, decodes
* it into a `GameReport`, registers it in the in-memory map under a
* 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): {
gameId: string;
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();
SYNTHETIC_REPORTS.set(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`,
* or `undefined` if the entry was lost (e.g. page reload). */
export function getSyntheticReport(gameId: string): GameReport | undefined {
@@ -173,6 +228,12 @@ interface SyntheticOtherShipClass extends SyntheticShipClass {
mass?: number;
}
interface SyntheticBattle {
id?: string;
planet?: number;
shots?: number;
}
interface SyntheticBombing {
planet?: number; // wire field "number"
planetName?: string; // wire field "planetName"
@@ -219,7 +280,7 @@ interface SyntheticReportRoot {
incomingGroup?: SyntheticIncomingGroup[];
unidentifiedGroup?: SyntheticUnidentifiedGroup[];
localFleet?: SyntheticLocalFleet[];
battle?: string[];
battle?: SyntheticBattle[];
bombing?: SyntheticBombing[];
shipProduction?: SyntheticShipProductionRow[];
}
@@ -357,9 +418,17 @@ function decodeSyntheticReport(json: unknown): GameReport {
return a.name.localeCompare(b.name);
});
const battleIds: string[] = (root.battle ?? []).filter(
(v): v is string => typeof v === "string" && v !== "",
);
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),
@@ -419,6 +488,7 @@ function decodeSyntheticReport(json: unknown): GameReport {
players: collectPlayersFromSynthetic(root, race),
otherScience,
otherShipClass,
battles,
battleIds,
bombings,
shipProductions,
+6
View File
@@ -5,6 +5,12 @@
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Galaxy</title>
<style>
html,
body {
margin: 0;
}
</style>
%sveltekit.head%
</head>
<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
battle viewer.
Phase 27 — active-view wrapper around the BattleViewer. Loads the
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">
import { i18n } from "$lib/i18n/index.svelte";
import { getContext } from "svelte";
import { goto } from "$app/navigation";
type Props = { battleId: string };
let { battleId }: Props = $props();
import {
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>
<section class="active-view" data-testid="active-view-battle" data-battle-id={battleId}>
<h2>{i18n.t("game.view.battle")}</h2>
<p>{i18n.t("game.shell.coming_soon")}</p>
<section
class="active-view"
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>
<style>
.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;
color: #d6dcf2;
}
.active-view h2 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
.status {
margin: 2rem auto;
max-width: 880px;
color: #93a0d0;
font-size: 0.95rem;
text-align: center;
}
.active-view p {
margin: 0;
color: #555;
.status.error {
color: #e08585;
}
</style>
+32 -3
View File
@@ -21,6 +21,8 @@ preference the store already manages.
-->
<script lang="ts">
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 {
createRenderer,
@@ -402,13 +404,40 @@ preference the store already manages.
if (selection === undefined) return;
const hit = handle.hitAt(cursorPx);
if (hit === null) return;
if (hit.primitive.kind !== "point") return;
const target = hitLookup.get(hit.primitive.id);
if (target === undefined) return;
if (target.kind === "planet") {
switch (target.kind) {
case "planet":
if (hit.primitive.kind !== "point") return;
selection.selectPlanet(target.number);
} else {
break;
case "shipGroup":
if (hit.primitive.kind !== "point") return;
selection.selectShipGroup(target.ref);
break;
case "battle": {
const gameId = page.params.id ?? "";
const turn = store?.report?.turn ?? 0;
void goto(
`/games/${gameId}/battle/${target.battleId}?turn=${turn}`,
);
break;
}
case "bombing": {
const gameId = page.params.id ?? "";
void goto(
`/games/${gameId}/report#report-bombings`,
).then(() => {
if (typeof document === "undefined") return;
const row = document.querySelector(
`[data-testid="report-bombing-row"][data-planet="${target.planet}"]`,
);
if (row && row.scrollIntoView) {
row.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
break;
}
}
}
@@ -1,13 +1,14 @@
<!--
Phase 23 Report View — battles section. The wire only carries
battle UUIDs (the full battle report is fetched lazily by Phase 27),
so each row is a monospace, non-interactive `<span>` of the battle
identifier. Phase 27 will turn each row into a link to
`/games/<id>/battle/<uuid>`; until then dead links are worse than
plain text.
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 {
@@ -19,7 +20,9 @@ plain text.
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const ids = $derived(report?.battleIds ?? []);
const battles = $derived(report?.battles ?? []);
const gameId = $derived(page.params.id ?? "");
const turn = $derived(report?.turn ?? 0);
</script>
<section
@@ -31,22 +34,23 @@ plain text.
{#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p>
{:else if ids.length === 0}
{: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 ids as id (id)}
{#each battles as b (b.id)}
<li>
<span class="label">
{i18n.t("game.report.section.battles.id_label")}
</span>
<span
<a
class="uuid"
href={`/games/${gameId}/battle/${b.id}?turn=${turn}`}
data-testid="report-battle-row"
data-id={id}
>{id}</span>
data-id={b.id}
>{b.id}</a>
</li>
{/each}
</ul>
@@ -87,5 +91,10 @@ plain text.
.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,438 @@
<!--
BattleScene — radial SVG visualisation of one battle frame.
Layout: planet at the centre, race anchors equally spaced on an
outer ring, each race rendered as a *cloud* of class circles
arranged on a Vogel sunflower spiral. Spiral positions are
reassigned per rank by their inward distance toward the planet so
the rank-0 bucket (the bucket with the largest initial ship count)
always sits at the most-inward Vogel slot.
Tech-variant groups of the same `(race, className)` collapse to one
visual node — per-tech detail lives in Reports. Each circle's
radius scales with the per-ship FullMass (sqrt) so heavy ships
visually dominate. Order, position, radius and mass are locked at
battle start; only NumberLeft (the label number) and per-bucket
visibility change per frame. Empty buckets are hidden so the
remaining ones keep their original spots without reshuffling.
Observer groups (`inBattle === false`) are filtered out by
`buildFrames`. Same-race opponents are forbidden by the engine's
combat filter, so a shot never collapses to a single visual node.
-->
<script lang="ts">
import { getContext } from "svelte";
import type { BattleReport } from "../../api/battle-fetch";
import {
CORE_CONTEXT_KEY,
type CoreHandle,
} from "$lib/core-context.svelte";
import { layoutRaces } from "./radial-layout";
import {
computeBattleGroupMass,
radiusForMass,
MAX_RADIUS,
type ShipClassLookup,
} from "./mass";
import {
buildGroupRaceMap,
normaliseGroups,
type Frame,
} from "./timeline";
let {
report,
frame,
shipClassLookup,
shotVisible = true,
}: {
report: BattleReport;
frame: Frame;
shipClassLookup?: ShipClassLookup;
shotVisible?: boolean;
} = $props();
const VIEW_BOX = 800;
const CENTER = { x: VIEW_BOX / 2, y: VIEW_BOX / 2 };
const PLANET_RADIUS = 60;
const RACE_RING_RADIUS = 280;
const BASE_STEP = 1.8 * MAX_RADIUS;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const MAX_CLUSTER_RADIUS = 0.6 * (RACE_RING_RADIUS - PLANET_RADIUS);
const LABEL_MIN_Y = 24;
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
const groupRace = $derived(buildGroupRaceMap(report.protocol));
const allGroups = $derived(normaliseGroups(report));
type StaticBucket = {
bucketKey: string;
className: string;
race: string;
raceId: number;
groupKeys: number[];
initialNum: number;
mass: number;
radius: number;
// Local offsets in the cluster's (u, v) basis. `u` always
// points from the race anchor toward the planet, so a
// constant local-frame layout produces the same "inward" feel
// regardless of which slot on the outer ring the race
// currently occupies (races rotate when peers die).
offsetU: number;
offsetV: number;
};
// staticBucketsByRace locks the bucket roster, ordering, masses,
// radii and local positions for the lifetime of this viewer. The
// derivation only re-runs when `report` or the wasm `core` flip
// (initial mount and core boot completion). Per-frame NumberLeft
// changes do not touch this map — they live in `renderedByRace`.
const staticBucketsByRace = $derived.by(() => {
const core = coreHandle?.core ?? null;
const out = new Map<number, StaticBucket[]>();
const bucketIndex = new Map<string, StaticBucket>();
for (const g of allGroups) {
const bucketKey = `${g.raceId}::${g.group.className}`;
let bucket = bucketIndex.get(bucketKey);
if (bucket === undefined) {
const classDef =
shipClassLookup?.get(g.group.race, g.group.className) ?? null;
const mass = core
? computeBattleGroupMass(g.group, classDef, core)
: 0;
bucket = {
bucketKey,
className: g.group.className,
race: g.group.race,
raceId: g.raceId,
groupKeys: [],
initialNum: 0,
mass,
radius: MAX_RADIUS,
offsetU: 0,
offsetV: 0,
};
bucketIndex.set(bucketKey, bucket);
const list = out.get(g.raceId) ?? [];
list.push(bucket);
out.set(g.raceId, list);
}
bucket.groupKeys.push(g.key);
bucket.initialNum += g.group.num;
}
// Per-battle mass normalisation: the heaviest bucket renders
// at MAX_RADIUS; lighter ones scale by sqrt(m/max).
let maxMass = 0;
for (const bucket of bucketIndex.values()) {
if (bucket.mass > maxMass) maxMass = bucket.mass;
}
for (const bucket of bucketIndex.values()) {
bucket.radius = radiusForMass(bucket.mass, maxMass);
}
// Sort each race's buckets by initial count (descending) +
// className as a stable tie-break, then assign Vogel positions
// reordered by inward dot product (offsetU desc) so the
// largest-by-num bucket lands at the most-inward Vogel slot.
for (const list of out.values()) {
list.sort((a, b) => {
if (b.initialNum !== a.initialNum) return b.initialNum - a.initialNum;
return a.className.localeCompare(b.className);
});
const N = list.length;
const denom = Math.max(1, Math.sqrt(Math.max(N, 1)));
const step = Math.min(BASE_STEP, MAX_CLUSTER_RADIUS / denom);
const positions = Array.from({ length: N }, (_, r) => {
const radius = step * Math.sqrt(r);
const angle = r * GOLDEN_ANGLE;
return {
offsetU: radius * Math.cos(angle),
offsetV: radius * Math.sin(angle),
};
});
positions.sort((a, b) => {
if (b.offsetU !== a.offsetU) return b.offsetU - a.offsetU;
return a.offsetV - b.offsetV;
});
for (let r = 0; r < N; r++) {
list[r].offsetU = positions[r].offsetU;
list[r].offsetV = positions[r].offsetV;
}
}
return out;
});
type RenderedBucket = StaticBucket & { numLeft: number };
// renderedByRace overlays the per-frame `remaining` map onto the
// static cluster: only buckets with `numLeft > 0` survive into
// the render list, so an emptied class disappears from the cloud
// while its neighbours keep their slots.
const renderedByRace = $derived.by(() => {
const out = new Map<number, RenderedBucket[]>();
for (const [raceId, list] of staticBucketsByRace) {
const filtered: RenderedBucket[] = [];
for (const bucket of list) {
let numLeft = 0;
for (const key of bucket.groupKeys) {
numLeft += frame.remaining.get(key) ?? 0;
}
if (numLeft > 0) filtered.push({ ...bucket, numLeft });
}
if (filtered.length > 0) out.set(raceId, filtered);
}
return out;
});
// visibleBucketByGroupKey lets shot endpoints resolve to a node
// only when the bucket is currently rendered. A phantom shot
// against an already-empty bucket therefore returns `null` and
// no line is drawn.
const visibleBucketByGroupKey = $derived.by(() => {
const out = new Map<number, RenderedBucket>();
for (const list of renderedByRace.values()) {
for (const bucket of list) {
for (const key of bucket.groupKeys) {
out.set(key, bucket);
}
}
}
return out;
});
const raceLayout = $derived(
layoutRaces(frame.activeRaceIds, {
center: CENTER,
radius: RACE_RING_RADIUS,
}),
);
type ClusterBasis = {
anchorX: number;
anchorY: number;
ux: number;
uy: number;
vx: number;
vy: number;
};
const clusterBasisById = $derived.by(() => {
const out = new Map<number, ClusterBasis>();
for (const anchor of raceLayout) {
const dx = CENTER.x - anchor.x;
const dy = CENTER.y - anchor.y;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len;
const uy = dy / len;
const vx = uy;
const vy = -ux;
out.set(anchor.raceId, {
anchorX: anchor.x,
anchorY: anchor.y,
ux,
uy,
vx,
vy,
});
}
return out;
});
function worldPosition(basis: ClusterBasis, bucket: StaticBucket) {
return {
x: basis.anchorX + bucket.offsetU * basis.ux + bucket.offsetV * basis.vx,
y: basis.anchorY + bucket.offsetU * basis.uy + bucket.offsetV * basis.vy,
};
}
function findClassCircleCenter(groupKey: number) {
const bucket = visibleBucketByGroupKey.get(groupKey);
if (!bucket) return null;
const basis = clusterBasisById.get(bucket.raceId);
if (!basis) return null;
return worldPosition(basis, bucket);
}
const shotLine = $derived.by(() => {
const action = frame.lastAction;
if (!action) return null;
const from = findClassCircleCenter(action.sa);
const to = findClassCircleCenter(action.sd);
if (!from || !to) return null;
return { from, to, destroyed: action.x, defenderKey: action.sd };
});
const flashDefenderBucketKey = $derived.by(() => {
if (!shotLine || !shotVisible) return null;
const bucket = visibleBucketByGroupKey.get(shotLine.defenderKey);
return bucket?.bucketKey ?? null;
});
const raceLabelById = $derived.by(() => {
const out = new Map<number, string>();
for (const g of allGroups) {
out.set(g.raceId, g.group.race);
}
for (const [, raceId] of groupRace) {
if (!out.has(raceId)) out.set(raceId, `race ${raceId}`);
}
return out;
});
// raceLabelYById finds a y just above the visible cluster's top
// edge and clamps it to the SVG viewport so the north race
// (anchor near the top) never has its label clipped off-canvas.
const raceLabelYById = $derived.by(() => {
const out = new Map<number, number>();
for (const [raceId, list] of renderedByRace) {
const basis = clusterBasisById.get(raceId);
if (!basis || list.length === 0) continue;
let topY = basis.anchorY;
for (const bucket of list) {
const world = worldPosition(basis, bucket);
const top = world.y - bucket.radius;
if (top < topY) topY = top;
}
const fallback = basis.anchorY - MAX_RADIUS - 12;
const target = Math.min(topY - 12, fallback);
out.set(raceId, Math.max(target, LABEL_MIN_Y));
}
return out;
});
</script>
<svg
class="battle-scene"
viewBox="0 0 {VIEW_BOX} {VIEW_BOX}"
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="battle scene"
data-testid="battle-scene"
>
<circle
cx={CENTER.x}
cy={CENTER.y}
r={PLANET_RADIUS}
class="planet"
data-testid="battle-scene-planet"
/>
<text
x={CENTER.x}
y={CENTER.y + PLANET_RADIUS + 24}
text-anchor="middle"
class="planet-label"
>{report.planetName} (#{report.planet})</text>
{#each raceLayout as anchor (anchor.raceId)}
{@const cluster = renderedByRace.get(anchor.raceId) ?? []}
{@const basis = clusterBasisById.get(anchor.raceId)}
{#if basis && cluster.length > 0}
<g
class="race-cluster"
data-testid="battle-race-cluster"
data-race-id={anchor.raceId}
>
<text
x={anchor.x}
y={raceLabelYById.get(anchor.raceId) ?? anchor.y - MAX_RADIUS - 12}
text-anchor="middle"
class="race-label"
>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
{#each cluster as entry (entry.bucketKey)}
{@const pos = worldPosition(basis, entry)}
{@const flash =
entry.bucketKey === flashDefenderBucketKey
? shotLine?.destroyed
? "destroyed"
: "shielded"
: null}
<g
class="class-marker"
data-testid="battle-class-marker"
data-bucket-key={entry.bucketKey}
data-class-name={entry.className}
data-flash={flash}
>
<circle cx={pos.x} cy={pos.y} r={entry.radius} />
<text
x={pos.x}
y={pos.y + entry.radius + 12}
text-anchor="middle"
class="class-label"
>{entry.className}:{entry.numLeft}</text>
</g>
{/each}
</g>
{/if}
{/each}
{#if shotLine && shotVisible}
<line
x1={shotLine.from.x}
y1={shotLine.from.y}
x2={shotLine.to.x}
y2={shotLine.to.y}
class="shot"
class:destroyed={shotLine.destroyed}
data-testid="battle-shot"
data-destroyed={shotLine.destroyed ? "true" : "false"}
/>
{/if}
</svg>
<style>
.battle-scene {
width: 100%;
height: 100%;
background: #0a0d1a;
display: block;
}
.planet {
fill: #2a2f40;
stroke: #4a5066;
stroke-width: 1.5;
}
.planet-label {
fill: #6d7388;
font-size: 18px;
font-family: ui-sans-serif, system-ui, sans-serif;
}
.race-label {
fill: #e2e6ff;
font-size: 16px;
font-weight: 600;
font-family: ui-sans-serif, system-ui, sans-serif;
}
.class-marker circle {
fill: #1a2042;
stroke: #6d7bb5;
stroke-width: 1.5;
transition:
fill 80ms ease-in,
stroke 80ms ease-in;
}
.class-marker[data-flash="destroyed"] circle {
fill: #ee3344;
stroke: #ee3344;
}
.class-marker[data-flash="shielded"] circle {
fill: #44dd66;
stroke: #44dd66;
}
.class-label {
fill: #b8c0e6;
font-size: 11px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.shot {
stroke: #44dd66;
stroke-width: 2;
}
.shot.destroyed {
stroke: #ee3344;
}
</style>
@@ -0,0 +1,348 @@
<!--
BattleViewer — orchestrates the radial battle scene, the playback
controls, and the accessibility text log for one BattleReport. Owns
the playback state (`frameIndex`, `playing`, `speed`, `logOpen`).
Layout reorganisation (latest iteration):
- The header carries the planet title, the back-navigation links and
the frame counter so the scene captures the full viewer width and
height beneath them.
- A drag-seek slider sits between the scene and the controls; the
user can scrub the playback timeline at any speed.
- The text log collapses behind a toggle in the controls bar so a
user who wants the biggest scene possible can hide it entirely.
The component is logically isolated: feed it any `BattleReport`
matching `pkg/model/report/battle.go` and it plays back.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
import type { BattleReport } from "../../api/battle-fetch";
import type { ShipClassLookup } from "./mass";
import BattleScene from "./battle-scene.svelte";
import PlaybackControls, {
type PlaybackSpeed,
} from "./playback-controls.svelte";
import { buildFrames } from "./timeline";
let {
report,
shipClassLookup,
onBackToMap,
onBackToReport,
}: {
report: BattleReport;
shipClassLookup?: ShipClassLookup;
onBackToMap?: () => void;
onBackToReport?: () => void;
} = $props();
const frames = $derived(buildFrames(report));
let frameIndex = $state(0);
let playing = $state(false);
let speed = $state<PlaybackSpeed>(1);
let logOpen = $state(true);
let shotVisible = $state(true);
let logEl = $state<HTMLOListElement | null>(null);
const rawFrame = $derived(frames[Math.min(frameIndex, frames.length - 1)]);
// displayFrame freezes the layout at the penultimate frame's
// state once the protocol's last action eliminates a race, so
// the surviving cluster does not suddenly reflow onto the
// planet ring on the very last shot. The frame counter still
// advances to the final shot and `lastAction` still drives the
// killing line + flash; only `remaining` and `activeRaceIds`
// (the layout-determining state) freeze.
const displayFrame = $derived.by(() => {
const last = frames.length - 1;
if (
frameIndex === last &&
last >= 1 &&
frames[last].activeRaceIds.length < frames[last - 1].activeRaceIds.length
) {
const prev = frames[last - 1];
const cur = frames[last];
return {
shotIndex: cur.shotIndex,
lastAction: cur.lastAction,
remaining: prev.remaining,
activeRaceIds: prev.activeRaceIds,
};
}
return rawFrame;
});
// One tick per frame: blink the shot line off during the last
// 10 % of the frame's interval, then advance. Effect re-arms
// whenever frameIndex / playing / speed changes; previous
// timers clean up through the return.
$effect(() => {
void frameIndex;
void speed;
shotVisible = true;
if (!playing) return;
const intervalMs = 400 / speed;
const blinkOff = setTimeout(() => {
shotVisible = false;
}, intervalMs * 0.9);
const advance = setTimeout(() => {
if (frameIndex >= frames.length - 1) {
playing = false;
return;
}
frameIndex = frameIndex + 1;
}, intervalMs);
return () => {
clearTimeout(blinkOff);
clearTimeout(advance);
};
});
// Auto-scroll the visible log row into view so the highlight
// keeps up with the timeline on long battles.
$effect(() => {
void displayFrame.shotIndex;
if (!logOpen || logEl === null) return;
const current = logEl.querySelector(
'li[data-current="true"]',
) as HTMLElement | null;
if (current !== null) {
current.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
});
function seekToShot(actionIndex: number) {
playing = false;
frameIndex = Math.max(
0,
Math.min(frames.length - 1, actionIndex + 1),
);
}
function onScrub(event: Event) {
const target = event.currentTarget as HTMLInputElement;
const value = Number(target.value);
if (!Number.isFinite(value)) return;
playing = false;
frameIndex = Math.max(0, Math.min(frames.length - 1, Math.trunc(value)));
}
function describeAction(index: number): string {
const action = report.protocol[index];
const attackerGroup = report.ships[String(action.sa)];
const defenderGroup = report.ships[String(action.sd)];
const attackerRace = attackerGroup?.race ?? `race ${action.a}`;
const attackerClass = attackerGroup?.className ?? `class ${action.sa}`;
const defenderRace = defenderGroup?.race ?? `race ${action.d}`;
const defenderClass = defenderGroup?.className ?? `class ${action.sd}`;
const key = action.x
? "game.battle.log.destroyed"
: "game.battle.log.shielded";
return i18n.t(key, {
attacker_race: attackerRace,
attacker_class: attackerClass,
defender_race: defenderRace,
defender_class: defenderClass,
});
}
</script>
<div class="viewer" data-testid="battle-viewer">
<header class="header">
<div class="back-row">
{#if onBackToMap}
<button
type="button"
class="back-btn"
onclick={onBackToMap}
data-testid="battle-back-to-map"
>{i18n.t("game.battle.back_to_map")}</button>
{/if}
{#if onBackToReport}
<button
type="button"
class="back-btn"
onclick={onBackToReport}
data-testid="battle-back-to-report"
>{i18n.t("game.battle.back_to_report")}</button>
{/if}
</div>
<h2 data-testid="battle-viewer-title">
{i18n.t("game.battle.header_title", {
planet_name: report.planetName,
planet_number: String(report.planet),
})}
</h2>
<span class="progress" data-testid="battle-frame-index">
{displayFrame.shotIndex} / {report.protocol.length}
</span>
</header>
<div class="scene">
<BattleScene {report} frame={displayFrame} {shipClassLookup} {shotVisible} />
</div>
<input
class="scrubber"
type="range"
min="0"
max={Math.max(0, frames.length - 1)}
step="1"
value={frameIndex}
oninput={onScrub}
aria-label={i18n.t("game.battle.controls.scrub")}
data-testid="battle-scrubber"
/>
<PlaybackControls
bind:playing
bind:frameIndex
bind:speed
bind:logOpen
frameCount={frames.length}
/>
{#if logOpen}
<section
class="log"
aria-label={i18n.t("game.battle.accessibility.protocol_heading")}
>
<h3>{i18n.t("game.battle.accessibility.protocol_heading")}</h3>
<ol bind:this={logEl} data-testid="battle-protocol-log">
{#each report.protocol as _action, i (i)}
<li
data-testid="battle-protocol-log-item"
data-current={i + 1 === displayFrame.shotIndex ? "true" : "false"}
>
<button
type="button"
class="log-row-btn"
onclick={() => seekToShot(i)}
>{describeAction(i)}</button>
</li>
{/each}
</ol>
</section>
{/if}
</div>
<style>
.viewer {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
flex: 1 1 auto;
min-height: 0;
margin: 0 auto;
padding: 0.75rem 1rem;
color: #d6dcf2;
font-family: inherit;
box-sizing: border-box;
}
.header {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 0 0 auto;
}
.header h2 {
flex: 1 1 auto;
margin: 0;
font-size: 1.05rem;
text-transform: uppercase;
letter-spacing: 0.04em;
text-align: center;
}
.back-row {
display: flex;
gap: 0.3rem;
flex: 0 0 auto;
}
.back-btn {
appearance: none;
background: #1f2748;
color: #d6dcf2;
border: 1px solid #2c3568;
padding: 0.3rem 0.6rem;
border-radius: 3px;
cursor: pointer;
font-size: 0.8rem;
}
.back-btn:hover {
background: #2a3463;
}
.progress {
color: #93a0d0;
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
flex: 0 0 auto;
min-width: 5rem;
text-align: right;
}
.scene {
background: #0a0d1a;
border: 1px solid #1e264a;
border-radius: 4px;
overflow: hidden;
flex: 1 1 auto;
min-height: 0;
}
.scrubber {
width: 100%;
margin: 0;
flex: 0 0 auto;
accent-color: #6d7bb5;
}
.log {
flex: 0 1 auto;
min-height: 4rem;
max-height: 30vh;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.log h3 {
margin: 0 0 0.3rem;
color: #93a0d0;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
flex: 0 0 auto;
}
.log ol {
list-style: none;
margin: 0;
padding: 0;
font-size: 0.85rem;
overflow-y: auto;
color: #c6cdf0;
flex: 1 1 auto;
min-height: 0;
}
.log li {
border-bottom: 1px solid #1c2240;
}
.log-row-btn {
display: block;
width: 100%;
text-align: left;
padding: 0.15rem 0.4rem;
background: transparent;
border: 0;
color: inherit;
font: inherit;
cursor: pointer;
}
.log-row-btn:hover,
.log-row-btn:focus-visible {
background: #131a36;
}
.log li[data-current="true"] .log-row-btn {
color: #ffe27a;
font-weight: 600;
background: #1a2240;
}
</style>
+109
View File
@@ -0,0 +1,109 @@
// Mass helpers for the Battle Viewer.
//
// Phase 27 refinement: ship-class circles are sized by per-ship
// FullMass (Empty + carrying), with a 6..24 px range. The viewer
// resolves a `(race, className) → ShipClassRef` lookup from the
// surrounding GameReport's `localShipClass` / `otherShipClass`
// tables and feeds it through the wasm bridge to `pkg/calc/ship.go`.
//
// Pure utilities live here; the Svelte components consume them.
import type { Core } from "../../platform/core/index";
import type { BattleReportGroup } from "../../api/battle-fetch";
/** Smallest visible ship circle. Picked so the `<class>:<n>` label
* stays legible on every viewport. */
export const MIN_RADIUS = 6;
/** Largest ship circle. Matches the Phase-27 baseline so heavy
* ships keep their previous visual prominence. */
export const MAX_RADIUS = 24;
/**
* ShipClassRef is the minimum slice of a ship class needed to
* compute its mass. Mirrors the relevant fields of
* `ShipClassSummary` (own classes) and `ReportOtherShipClass`
* (foreign classes) without coupling the viewer to either type.
*/
export interface ShipClassRef {
drive: number;
weapons: number;
armament: number;
shields: number;
cargo: number;
}
/**
* ShipClassLookup resolves `(race, className)` to a ship-class
* descriptor. Returns `null` when the class is not in the parent
* report — happens with legacy-mode foreign races that lack a
* `<Race> Ship Types` block.
*/
export interface ShipClassLookup {
get(race: string, className: string): ShipClassRef | null;
}
/**
* computeBattleGroupMass returns the per-ship FullMass for a given
* battle group. Mass=0 means "unknown" — either the wasm bridge
* rejected the ship-class params (degenerate weapons/armament pair)
* or the class did not resolve in the lookup. Either way the
* caller's downstream `radiusForMass` falls back to MAX_RADIUS so
* the node stays visible.
*
* Cargo never changes during a battle, so this can be cached per
* `(race, className)` bucket for the lifetime of the viewer
* session.
*/
export function computeBattleGroupMass(
group: BattleReportGroup,
classDef: ShipClassRef | null,
core: Core,
): number {
if (classDef === null) return 0;
const empty = core.emptyMass({
drive: classDef.drive,
weapons: classDef.weapons,
armament: classDef.armament,
shields: classDef.shields,
cargo: classDef.cargo,
});
if (empty === null) return 0;
const cargoTech = classDef.cargo * (group.tech.CARGO ?? 0);
const carrying = core.carryingMass({
load: group.loadQuantity,
cargoTech,
});
return core.fullMass({ emptyMass: empty, carryingMass: carrying });
}
/**
* radiusForMass maps an absolute ship mass to a circle radius via
* a per-battle normalisation: the heaviest visual node always
* renders at MAX_RADIUS, lighter ones scale by sqrt(mass /
* maxMassInBattle) so the smallest ships don't disappear and the
* heaviest ones don't dominate the scene at >MAX_RADIUS. mass<=0
* falls back to MAX_RADIUS so unresolved/invalid classes stay
* visible.
*/
export function radiusForMass(mass: number, maxMassInBattle: number): number {
if (maxMassInBattle <= 0 || mass <= 0) return MAX_RADIUS;
const scaled = MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * Math.sqrt(mass / maxMassInBattle);
if (scaled < MIN_RADIUS) return MIN_RADIUS;
if (scaled > MAX_RADIUS) return MAX_RADIUS;
return scaled;
}
/**
* MapShipClassLookup is a `Map<string, ShipClassRef>`-backed
* implementation of `ShipClassLookup`. Key encoding mirrors the
* one battle.svelte uses when populating the lookup from the
* parent GameReport.
*/
export class MapShipClassLookup implements ShipClassLookup {
constructor(private readonly map: Map<string, ShipClassRef>) {}
get(race: string, className: string): ShipClassRef | null {
return this.map.get(`${race}::${className}`) ?? null;
}
}
@@ -0,0 +1,155 @@
<!--
PlaybackControls — rewind / step-back / play-pause / step-forward
plus a single cycling speed button (1x → 2x → 4x → 6x → 1x) and a
"log" toggle that the orchestrator uses to collapse the always-on
text protocol when the user wants more space for the scene. Owns no
state of its own; binds `playing`, `frameIndex`, `speed`, and
`logOpen` from the orchestrator. Disables step/rewind when there's
nowhere to go and step-forward when the timeline is at its end.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
export type PlaybackSpeed = 1 | 2 | 4 | 6;
const SPEED_CYCLE: PlaybackSpeed[] = [1, 2, 4, 6];
let {
playing = $bindable(),
frameIndex = $bindable(),
speed = $bindable(),
logOpen = $bindable(),
frameCount,
}: {
playing: boolean;
frameIndex: number;
speed: PlaybackSpeed;
logOpen: boolean;
frameCount: number;
} = $props();
function rewind() {
playing = false;
frameIndex = 0;
}
function stepBack() {
playing = false;
if (frameIndex > 0) frameIndex = frameIndex - 1;
}
function togglePlay() {
if (frameIndex >= frameCount - 1) {
frameIndex = 0;
}
playing = !playing;
}
function stepForward() {
playing = false;
if (frameIndex < frameCount - 1) frameIndex = frameIndex + 1;
}
function cycleSpeed() {
const idx = SPEED_CYCLE.indexOf(speed);
const next = SPEED_CYCLE[(idx + 1) % SPEED_CYCLE.length];
speed = next;
}
function toggleLog() {
logOpen = !logOpen;
}
const speedLabel = $derived(`${speed}x`);
</script>
<div class="controls" data-testid="battle-controls">
<button
type="button"
onclick={rewind}
disabled={frameIndex === 0}
aria-label={i18n.t("game.battle.controls.rewind")}
data-testid="battle-control-rewind"
></button>
<button
type="button"
onclick={stepBack}
disabled={frameIndex === 0}
aria-label={i18n.t("game.battle.controls.step_backward")}
data-testid="battle-control-step-back"
>◀︎◀︎</button>
<button
type="button"
onclick={togglePlay}
aria-label={playing
? i18n.t("game.battle.controls.pause")
: i18n.t("game.battle.controls.play")}
data-testid="battle-control-play"
data-playing={playing ? "true" : "false"}
>{playing ? "⏸" : "▶︎"}</button>
<button
type="button"
onclick={stepForward}
disabled={frameIndex >= frameCount - 1}
aria-label={i18n.t("game.battle.controls.step_forward")}
data-testid="battle-control-step-forward"
>▶︎▶︎</button>
<div class="spacer" aria-hidden="true"></div>
<button
type="button"
class="speed-btn"
onclick={cycleSpeed}
title={i18n.t("game.battle.controls.speed_label")}
aria-label={i18n.t("game.battle.controls.speed_label")}
data-testid="battle-control-speed"
data-speed={speed}
>{speedLabel}</button>
<button
type="button"
class="log-toggle"
class:active={logOpen}
onclick={toggleLog}
aria-pressed={logOpen}
aria-label={i18n.t("game.battle.controls.log_toggle")}
data-testid="battle-control-log-toggle"
>{i18n.t("game.battle.controls.log_toggle")} {logOpen ? "▲" : "▼"}</button>
</div>
<style>
.controls {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.75rem;
background: #131934;
border: 1px solid #1e264a;
border-radius: 4px;
}
.spacer {
flex: 1 1 auto;
}
button {
appearance: none;
background: #1f2748;
color: #d6dcf2;
border: 1px solid #2c3568;
padding: 0.35rem 0.7rem;
border-radius: 3px;
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
min-width: 2.5rem;
}
button:hover:not(:disabled) {
background: #2a3463;
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.speed-btn {
min-width: 3rem;
font-variant-numeric: tabular-nums;
}
.log-toggle.active {
background: #2a3463;
}
</style>
@@ -0,0 +1,59 @@
// Radial layout for the BattleViewer.
//
// Places race anchors on a circle of radius `radius` around `center`
// at equal angular spacing. For three or more races the first anchor
// sits at the top (12 o'clock) and subsequent anchors march
// clockwise. For exactly two races the pair is rotated 90° so they
// face each other horizontally (3 o'clock vs 9 o'clock) — that keeps
// every race label clear of the SVG top edge when only two clusters
// remain, and reads as "the two sides facing off" naturally.
//
// When a race is eliminated mid-battle the caller filters it out of
// `activeRaceIds` and the survivors are re-spaced on the next frame
// through the same helper.
export interface RaceAnchor {
raceId: number;
x: number;
y: number;
/** Angle in radians measured from the positive Y axis clockwise. */
angle: number;
}
export interface RadialLayoutOptions {
center: { x: number; y: number };
radius: number;
}
/**
* layoutRaces returns anchor positions for each `activeRaceIds`
* entry, placed at equal angular spacing on a circle. The input
* order is preserved so consumers get a stable mapping across
* frames; eliminated entries should simply be filtered out before
* the call.
*/
export function layoutRaces(
activeRaceIds: number[],
options: RadialLayoutOptions,
): RaceAnchor[] {
const count = activeRaceIds.length;
if (count === 0) return [];
const { center, radius } = options;
const out: RaceAnchor[] = [];
// For two participants we want a horizontal duel layout: race 0
// at 9 o'clock, race 1 at 3 o'clock. For any other count the
// first anchor lands at the top (12 o'clock) and the rest march
// clockwise at equal spacing.
const startAngle = count === 2 ? Math.PI : -Math.PI / 2;
for (let i = 0; i < count; i++) {
const step = (2 * Math.PI) / count;
const angle = startAngle + i * step;
out.push({
raceId: activeRaceIds[i],
x: center.x + radius * Math.cos(angle),
y: center.y + radius * Math.sin(angle),
angle,
});
}
return out;
}
@@ -0,0 +1,143 @@
// Timeline builder for the BattleViewer.
//
// Given a `BattleReport`, expands the flat `protocol` into a
// sequence of frames. Frame 0 carries the initial state; frame N
// (1 ≤ N ≤ protocol.length) reflects the state right after the
// (N-1)-th action has been applied. Each frame is self-contained so
// stepping forward and backward is a constant-time index lookup, no
// rewind logic needed.
import type {
BattleActionReport,
BattleReport,
BattleReportGroup,
} from "../../api/battle-fetch";
/**
* Frame is one tick of the battle playback. `remaining` carries the
* surviving ship count for each ship-group key from
* `BattleReport.ships`; `activeRaceIds` are the race indices with at
* least one surviving in-battle group. `lastAction` is the action
* applied to produce this frame, or `null` for the initial frame.
*/
export interface Frame {
shotIndex: number;
remaining: Map<number, number>;
activeRaceIds: number[];
lastAction: BattleActionReport | null;
}
export interface NormalisedGroup {
key: number;
group: BattleReportGroup;
raceId: number;
}
/**
* normaliseGroups returns the in-battle ship groups from a
* BattleReport indexed by their integer key. Observer groups
* (`inBattle === false`) are skipped because they are neither
* targeted nor drawn. The race index per group is derived from the
* protocol — every in-battle group appears at least once as
* attacker or defender, and the engine's pairing (a, sa) / (d, sd)
* defines the relationship.
*/
export function normaliseGroups(report: BattleReport): NormalisedGroup[] {
const raceByKey = buildGroupRaceMap(report.protocol);
const out: NormalisedGroup[] = [];
for (const [keyRaw, group] of Object.entries(report.ships)) {
if (!group.inBattle) continue;
const key = Number(keyRaw);
if (!Number.isFinite(key)) continue;
const raceId = raceByKey.get(key);
if (raceId === undefined) continue;
out.push({ key, group, raceId });
}
return out;
}
/**
* buildGroupRaceMap extracts the `ship-group key → race index`
* mapping from a battle protocol. Same key appearing twice always
* carries the same race index — protocol entries are emitted by the
* engine, which never crosses these wires.
*/
export function buildGroupRaceMap(
protocol: BattleActionReport[],
): Map<number, number> {
const out = new Map<number, number>();
for (const action of protocol) {
if (!out.has(action.sa)) out.set(action.sa, action.a);
if (!out.has(action.sd)) out.set(action.sd, action.d);
}
return out;
}
/**
* buildFrames walks the protocol once and emits a frame after each
* applied action plus the initial frame. The remaining-ships map is
* cloned per frame so callers can step backward without manual
* bookkeeping. Eliminated races drop out of `activeRaceIds` as soon
* as their last in-battle group hits zero.
*/
export function buildFrames(report: BattleReport): Frame[] {
const groups = normaliseGroups(report);
const initialRemaining = new Map<number, number>();
const raceTotals = new Map<number, number>();
for (const g of groups) {
initialRemaining.set(g.key, g.group.num);
raceTotals.set(g.raceId, (raceTotals.get(g.raceId) ?? 0) + g.group.num);
}
const frames: Frame[] = [];
frames.push({
shotIndex: 0,
remaining: new Map(initialRemaining),
activeRaceIds: collectActiveRaces(raceTotals),
lastAction: null,
});
const groupRaceByKey = new Map<number, number>();
for (const g of groups) groupRaceByKey.set(g.key, g.raceId);
const current = new Map(initialRemaining);
const runningRaceTotals = new Map(raceTotals);
for (let i = 0; i < report.protocol.length; i++) {
const action = report.protocol[i];
if (action.x) {
// Defence in depth: a malformed protocol that fires more
// `Destroyed` rows than the group has ships would push
// `runningRaceTotals` below zero and drop the race from
// `activeRaceIds` prematurely. Real legacy data folds
// duplicate `(race, className)` roster rows into the
// same `BattleReportGroup` (parser + engine), so this
// branch is hit only on a real shrink — but the clamp
// keeps the math from going negative either way.
const left = current.get(action.sd) ?? 0;
if (left > 0) {
current.set(action.sd, left - 1);
const raceId = groupRaceByKey.get(action.sd);
if (raceId !== undefined) {
const t = (runningRaceTotals.get(raceId) ?? 0) - 1;
runningRaceTotals.set(raceId, Math.max(0, t));
}
}
}
frames.push({
shotIndex: i + 1,
remaining: new Map(current),
activeRaceIds: collectActiveRaces(runningRaceTotals),
lastAction: action,
});
}
return frames;
}
function collectActiveRaces(totals: Map<number, number>): number[] {
const out: number[] = [];
for (const [raceId, total] of totals.entries()) {
if (total > 0) out.push(raceId);
}
return out.sort((a, b) => a - b);
}
+21
View File
@@ -483,6 +483,27 @@ const en = {
"game.report.section.battles.title": "battles",
"game.report.section.battles.empty": "no battles last turn",
"game.report.section.battles.id_label": "battle",
"game.battle.title": "battle",
"game.battle.header_title": "Battle on planet {planet_name} (#{planet_number})",
"game.battle.loading": "loading battle…",
"game.battle.not_found": "battle not found",
"game.battle.back_to_report": "back to report",
"game.battle.back_to_map": "back to map",
"game.battle.controls.play": "play",
"game.battle.controls.pause": "pause",
"game.battle.controls.step_forward": "step forward",
"game.battle.controls.step_backward": "step back",
"game.battle.controls.rewind": "rewind to start",
"game.battle.controls.speed_label": "speed",
"game.battle.controls.speed_1x": "1x",
"game.battle.controls.speed_2x": "2x",
"game.battle.controls.speed_4x": "4x",
"game.battle.controls.speed_6x": "6x",
"game.battle.controls.scrub": "scrub battle timeline",
"game.battle.controls.log_toggle": "Log",
"game.battle.log.destroyed": "{attacker_race}'s {attacker_class} destroyed {defender_race}'s {defender_class}",
"game.battle.log.shielded": "{attacker_race}'s {attacker_class} hit {defender_race}'s {defender_class}, shields held",
"game.battle.accessibility.protocol_heading": "battle log",
"game.report.section.bombings.title": "bombings",
"game.report.section.bombings.empty": "no bombings last turn",
"game.report.section.bombings.column.planet": "planet",
+21
View File
@@ -484,6 +484,27 @@ const ru: Record<keyof typeof en, string> = {
"game.report.section.battles.title": "сражения",
"game.report.section.battles.empty": "сражений в этом ходу не было",
"game.report.section.battles.id_label": "сражение",
"game.battle.title": "сражение",
"game.battle.header_title": "Битва на планете {planet_name} (#{planet_number})",
"game.battle.controls.speed_6x": "6x",
"game.battle.controls.scrub": "перемотать таймлайн битвы",
"game.battle.controls.log_toggle": "Лог",
"game.battle.loading": "загрузка сражения…",
"game.battle.not_found": "сражение не найдено",
"game.battle.back_to_report": "к отчёту",
"game.battle.back_to_map": "к карте",
"game.battle.controls.play": "запустить",
"game.battle.controls.pause": "пауза",
"game.battle.controls.step_forward": "шаг вперёд",
"game.battle.controls.step_backward": "шаг назад",
"game.battle.controls.rewind": "к началу",
"game.battle.controls.speed_label": "скорость",
"game.battle.controls.speed_1x": "1x",
"game.battle.controls.speed_2x": "2x",
"game.battle.controls.speed_4x": "4x",
"game.battle.log.destroyed": "{attacker_class} расы {attacker_race} уничтожает {defender_class} расы {defender_race}",
"game.battle.log.shielded": "{attacker_class} расы {attacker_race} попадает в {defender_class} расы {defender_race}, щиты выдержали",
"game.battle.accessibility.protocol_heading": "протокол сражения",
"game.report.section.bombings.title": "бомбардировки",
"game.report.section.bombings.empty": "бомбардировок в этом ходу не было",
"game.report.section.bombings.column.planet": "планета",
+168
View File
@@ -0,0 +1,168 @@
// Phase 27 battle and bombing markers on the map.
//
// Two visual markers per planet:
//
// * Battle marker — an X cross drawn through the corners of the
// square that circumscribes the planet circle. Two yellow
// LinePrim, stroke width scales linearly with the number of
// shots: 1 shot → 1px, 100+ shots → 5px (capped). Clicking
// either line opens the Battle Viewer for the corresponding
// UUID.
// * Bombing marker — a thin stroke-only circle slightly larger
// than the planet circle. Yellow on damaged planets, red on
// wiped planets. Clicking it deep-links to the bombings row in
// the Reports view for the planet number.
//
// Both markers are wired into `state-binding.ts` so they live in the
// same `world` / `hitLookup` plumbing as planets and ship groups.
import type { GameReport, ReportPlanet } from "../api/game-state";
import type {
CirclePrim,
LinePrim,
Primitive,
PrimitiveID,
Style,
} from "./world";
export const BATTLE_MARKER_COLOR = 0xffd400;
export const BOMBING_MARKER_COLOR_DAMAGED = 0xffd400;
export const BOMBING_MARKER_COLOR_WIPED = 0xff3030;
/** Battle and bombing marker primitive ids use a high-bit prefix to
* avoid colliding with planet numbers or cargo-route line ids. */
export const BATTLE_MARKER_ID_PREFIX = 0xa0000000;
export const BOMBING_MARKER_ID_PREFIX = 0xc0000000;
const PLANET_RADIUS_WORLD = 6;
const BOMBING_RING_RADIUS = PLANET_RADIUS_WORLD + 3;
const BATTLE_CROSS_HALF = PLANET_RADIUS_WORLD + 2;
/** Battle marker priority sits between planets (1..4) and cargo
* routes; the cross is over the planet but loses clicks against the
* planet glyph itself. */
const BATTLE_MARKER_PRIORITY = 9;
const BOMBING_MARKER_PRIORITY = 10;
const BATTLE_LINE_INDEX_A = 0;
const BATTLE_LINE_INDEX_B = 1;
export interface BattleMarkerTarget {
kind: "battle";
battleId: string;
planet: number;
}
export interface BombingMarkerTarget {
kind: "bombing";
planet: number;
}
export type MarkerTarget = BattleMarkerTarget | BombingMarkerTarget;
export interface BuildMarkersResult {
primitives: Primitive[];
lookup: Map<PrimitiveID, MarkerTarget>;
}
/**
* battleMarkerStrokeWidth maps a battle's `shots` count to a stroke
* width in pixels. 1 shot → 1 px (the thinnest visible), 100+ shots
* → 5 px (the cap). Linearly interpolated between those bounds.
*/
export function battleMarkerStrokeWidth(shots: number): number {
if (shots <= 1) return 1;
if (shots >= 100) return 5;
return 1 + ((shots - 1) * 4) / 99;
}
/**
* buildBattleAndBombingMarkers emits battle and bombing marker
* primitives plus a hit-lookup mapping for the current-turn report.
* Battles whose planet is not visible (e.g. observer-only without a
* report.planets entry) are skipped — they have no on-map location
* to anchor against.
*/
export function buildBattleAndBombingMarkers(
report: GameReport,
): BuildMarkersResult {
const planetByNumber = new Map<number, ReportPlanet>();
for (const planet of report.planets) {
planetByNumber.set(planet.number, planet);
}
const primitives: Primitive[] = [];
const lookup = new Map<PrimitiveID, MarkerTarget>();
for (let i = 0; i < report.battles.length; i++) {
const battle = report.battles[i];
const planet = planetByNumber.get(battle.planet);
if (planet === undefined) continue;
const strokeWidthPx = battleMarkerStrokeWidth(battle.shots);
const style: Style = {
strokeColor: BATTLE_MARKER_COLOR,
strokeAlpha: 0.95,
strokeWidthPx,
};
const baseId = BATTLE_MARKER_ID_PREFIX | (i << 4);
const lineA: LinePrim = {
kind: "line",
id: baseId | BATTLE_LINE_INDEX_A,
priority: BATTLE_MARKER_PRIORITY,
style,
hitSlopPx: 0,
x1: planet.x - BATTLE_CROSS_HALF,
y1: planet.y - BATTLE_CROSS_HALF,
x2: planet.x + BATTLE_CROSS_HALF,
y2: planet.y + BATTLE_CROSS_HALF,
};
const lineB: LinePrim = {
kind: "line",
id: baseId | BATTLE_LINE_INDEX_B,
priority: BATTLE_MARKER_PRIORITY,
style,
hitSlopPx: 0,
x1: planet.x - BATTLE_CROSS_HALF,
y1: planet.y + BATTLE_CROSS_HALF,
x2: planet.x + BATTLE_CROSS_HALF,
y2: planet.y - BATTLE_CROSS_HALF,
};
const target: BattleMarkerTarget = {
kind: "battle",
battleId: battle.id,
planet: battle.planet,
};
primitives.push(lineA, lineB);
lookup.set(lineA.id, target);
lookup.set(lineB.id, target);
}
for (let i = 0; i < report.bombings.length; i++) {
const bombing = report.bombings[i];
const planet = planetByNumber.get(bombing.planetNumber);
if (planet === undefined) continue;
const color = bombing.wiped
? BOMBING_MARKER_COLOR_WIPED
: BOMBING_MARKER_COLOR_DAMAGED;
const style: Style = {
strokeColor: color,
strokeAlpha: 0.9,
strokeWidthPx: 1.5,
};
const id = BOMBING_MARKER_ID_PREFIX | i;
const ring: CirclePrim = {
kind: "circle",
id,
priority: BOMBING_MARKER_PRIORITY,
style,
hitSlopPx: 0,
x: planet.x,
y: planet.y,
radius: BOMBING_RING_RADIUS,
};
primitives.push(ring);
lookup.set(id, { kind: "bombing", planet: bombing.planetNumber });
}
return { primitives, lookup };
}
+12 -1
View File
@@ -15,6 +15,7 @@
import type { GameReport, ReportPlanet } from "../api/game-state";
import type { ShipGroupRef } from "../lib/selection.svelte";
import { buildBattleAndBombingMarkers } from "./battle-markers";
import { shipGroupsToPrimitives } from "./ship-groups";
import { World, type Primitive, type PrimitiveID, type Style } from "./world";
@@ -83,7 +84,9 @@ function priorityFor(kind: ReportPlanet["kind"]): number {
*/
export type HitTarget =
| { kind: "planet"; number: number }
| { kind: "shipGroup"; ref: ShipGroupRef };
| { kind: "shipGroup"; ref: ShipGroupRef }
| { kind: "battle"; battleId: string; planet: number }
| { kind: "bombing"; planet: number };
export interface ReportToWorldResult {
world: World;
@@ -127,6 +130,14 @@ export function reportToWorld(report: GameReport): ReportToWorldResult {
hitLookup.set(primId, { kind: "shipGroup", ref });
}
const markers = buildBattleAndBombingMarkers(report);
for (const prim of markers.primitives) {
primitives.push(prim);
}
for (const [primId, target] of markers.lookup) {
hitLookup.set(primId, target);
}
const width = report.mapWidth > 0 ? report.mapWidth : 1;
const height = report.mapHeight > 0 ? report.mapHeight : 1;
return { world: new World(width, height, primitives), hitLookup };
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { ApplicationSummary, ApplicationSummaryT } from './application-summary.js';
import { ApplicationSummary, ApplicationSummaryT } from '../lobby/application-summary.js';
export class ApplicationSubmitResponse implements flatbuffers.IUnpackableObject<ApplicationSubmitResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { ErrorBody, ErrorBodyT } from './error-body.js';
import { ErrorBody, ErrorBodyT } from '../lobby/error-body.js';
export class ErrorResponse implements flatbuffers.IUnpackableObject<ErrorResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { GameSummary, GameSummaryT } from './game-summary.js';
import { GameSummary, GameSummaryT } from '../lobby/game-summary.js';
export class GameCreateResponse implements flatbuffers.IUnpackableObject<GameCreateResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { InviteSummary, InviteSummaryT } from './invite-summary.js';
import { InviteSummary, InviteSummaryT } from '../lobby/invite-summary.js';
export class InviteDeclineResponse implements flatbuffers.IUnpackableObject<InviteDeclineResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { InviteSummary, InviteSummaryT } from './invite-summary.js';
import { InviteSummary, InviteSummaryT } from '../lobby/invite-summary.js';
export class InviteRedeemResponse implements flatbuffers.IUnpackableObject<InviteRedeemResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { ApplicationSummary, ApplicationSummaryT } from './application-summary.js';
import { ApplicationSummary, ApplicationSummaryT } from '../lobby/application-summary.js';
export class MyApplicationsListResponse implements flatbuffers.IUnpackableObject<MyApplicationsListResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { GameSummary, GameSummaryT } from './game-summary.js';
import { GameSummary, GameSummaryT } from '../lobby/game-summary.js';
export class MyGamesListResponse implements flatbuffers.IUnpackableObject<MyGamesListResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { InviteSummary, InviteSummaryT } from './invite-summary.js';
import { InviteSummary, InviteSummaryT } from '../lobby/invite-summary.js';
export class MyInvitesListResponse implements flatbuffers.IUnpackableObject<MyInvitesListResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { GameSummary, GameSummaryT } from './game-summary.js';
import { GameSummary, GameSummaryT } from '../lobby/game-summary.js';
export class PublicGamesListResponse implements flatbuffers.IUnpackableObject<PublicGamesListResponseT> {
@@ -4,30 +4,30 @@
import * as flatbuffers from 'flatbuffers';
import { CommandFleetMerge, CommandFleetMergeT } from './command-fleet-merge.js';
import { CommandFleetSend, CommandFleetSendT } from './command-fleet-send.js';
import { CommandPayload, unionToCommandPayload, unionListToCommandPayload } from './command-payload.js';
import { CommandPlanetProduce, CommandPlanetProduceT } from './command-planet-produce.js';
import { CommandPlanetRename, CommandPlanetRenameT } from './command-planet-rename.js';
import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from './command-planet-route-remove.js';
import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from './command-planet-route-set.js';
import { CommandRaceQuit, CommandRaceQuitT } from './command-race-quit.js';
import { CommandRaceRelation, CommandRaceRelationT } from './command-race-relation.js';
import { CommandRaceVote, CommandRaceVoteT } from './command-race-vote.js';
import { CommandScienceCreate, CommandScienceCreateT } from './command-science-create.js';
import { CommandScienceRemove, CommandScienceRemoveT } from './command-science-remove.js';
import { CommandShipClassCreate, CommandShipClassCreateT } from './command-ship-class-create.js';
import { CommandShipClassMerge, CommandShipClassMergeT } from './command-ship-class-merge.js';
import { CommandShipClassRemove, CommandShipClassRemoveT } from './command-ship-class-remove.js';
import { CommandShipGroupBreak, CommandShipGroupBreakT } from './command-ship-group-break.js';
import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from './command-ship-group-dismantle.js';
import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from './command-ship-group-join-fleet.js';
import { CommandShipGroupLoad, CommandShipGroupLoadT } from './command-ship-group-load.js';
import { CommandShipGroupMerge, CommandShipGroupMergeT } from './command-ship-group-merge.js';
import { CommandShipGroupSend, CommandShipGroupSendT } from './command-ship-group-send.js';
import { CommandShipGroupTransfer, CommandShipGroupTransferT } from './command-ship-group-transfer.js';
import { CommandShipGroupUnload, CommandShipGroupUnloadT } from './command-ship-group-unload.js';
import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from './command-ship-group-upgrade.js';
import { CommandFleetMerge, CommandFleetMergeT } from '../order/command-fleet-merge.js';
import { CommandFleetSend, CommandFleetSendT } from '../order/command-fleet-send.js';
import { CommandPayload, unionToCommandPayload, unionListToCommandPayload } from '../order/command-payload.js';
import { CommandPlanetProduce, CommandPlanetProduceT } from '../order/command-planet-produce.js';
import { CommandPlanetRename, CommandPlanetRenameT } from '../order/command-planet-rename.js';
import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from '../order/command-planet-route-remove.js';
import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from '../order/command-planet-route-set.js';
import { CommandRaceQuit, CommandRaceQuitT } from '../order/command-race-quit.js';
import { CommandRaceRelation, CommandRaceRelationT } from '../order/command-race-relation.js';
import { CommandRaceVote, CommandRaceVoteT } from '../order/command-race-vote.js';
import { CommandScienceCreate, CommandScienceCreateT } from '../order/command-science-create.js';
import { CommandScienceRemove, CommandScienceRemoveT } from '../order/command-science-remove.js';
import { CommandShipClassCreate, CommandShipClassCreateT } from '../order/command-ship-class-create.js';
import { CommandShipClassMerge, CommandShipClassMergeT } from '../order/command-ship-class-merge.js';
import { CommandShipClassRemove, CommandShipClassRemoveT } from '../order/command-ship-class-remove.js';
import { CommandShipGroupBreak, CommandShipGroupBreakT } from '../order/command-ship-group-break.js';
import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from '../order/command-ship-group-dismantle.js';
import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from '../order/command-ship-group-join-fleet.js';
import { CommandShipGroupLoad, CommandShipGroupLoadT } from '../order/command-ship-group-load.js';
import { CommandShipGroupMerge, CommandShipGroupMergeT } from '../order/command-ship-group-merge.js';
import { CommandShipGroupSend, CommandShipGroupSendT } from '../order/command-ship-group-send.js';
import { CommandShipGroupTransfer, CommandShipGroupTransferT } from '../order/command-ship-group-transfer.js';
import { CommandShipGroupUnload, CommandShipGroupUnloadT } from '../order/command-ship-group-unload.js';
import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from '../order/command-ship-group-upgrade.js';
export class CommandItem implements flatbuffers.IUnpackableObject<CommandItemT> {
@@ -2,29 +2,29 @@
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
import { CommandFleetMerge, CommandFleetMergeT } from './command-fleet-merge.js';
import { CommandFleetSend, CommandFleetSendT } from './command-fleet-send.js';
import { CommandPlanetProduce, CommandPlanetProduceT } from './command-planet-produce.js';
import { CommandPlanetRename, CommandPlanetRenameT } from './command-planet-rename.js';
import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from './command-planet-route-remove.js';
import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from './command-planet-route-set.js';
import { CommandRaceQuit, CommandRaceQuitT } from './command-race-quit.js';
import { CommandRaceRelation, CommandRaceRelationT } from './command-race-relation.js';
import { CommandRaceVote, CommandRaceVoteT } from './command-race-vote.js';
import { CommandScienceCreate, CommandScienceCreateT } from './command-science-create.js';
import { CommandScienceRemove, CommandScienceRemoveT } from './command-science-remove.js';
import { CommandShipClassCreate, CommandShipClassCreateT } from './command-ship-class-create.js';
import { CommandShipClassMerge, CommandShipClassMergeT } from './command-ship-class-merge.js';
import { CommandShipClassRemove, CommandShipClassRemoveT } from './command-ship-class-remove.js';
import { CommandShipGroupBreak, CommandShipGroupBreakT } from './command-ship-group-break.js';
import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from './command-ship-group-dismantle.js';
import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from './command-ship-group-join-fleet.js';
import { CommandShipGroupLoad, CommandShipGroupLoadT } from './command-ship-group-load.js';
import { CommandShipGroupMerge, CommandShipGroupMergeT } from './command-ship-group-merge.js';
import { CommandShipGroupSend, CommandShipGroupSendT } from './command-ship-group-send.js';
import { CommandShipGroupTransfer, CommandShipGroupTransferT } from './command-ship-group-transfer.js';
import { CommandShipGroupUnload, CommandShipGroupUnloadT } from './command-ship-group-unload.js';
import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from './command-ship-group-upgrade.js';
import { CommandFleetMerge, CommandFleetMergeT } from '../order/command-fleet-merge.js';
import { CommandFleetSend, CommandFleetSendT } from '../order/command-fleet-send.js';
import { CommandPlanetProduce, CommandPlanetProduceT } from '../order/command-planet-produce.js';
import { CommandPlanetRename, CommandPlanetRenameT } from '../order/command-planet-rename.js';
import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from '../order/command-planet-route-remove.js';
import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from '../order/command-planet-route-set.js';
import { CommandRaceQuit, CommandRaceQuitT } from '../order/command-race-quit.js';
import { CommandRaceRelation, CommandRaceRelationT } from '../order/command-race-relation.js';
import { CommandRaceVote, CommandRaceVoteT } from '../order/command-race-vote.js';
import { CommandScienceCreate, CommandScienceCreateT } from '../order/command-science-create.js';
import { CommandScienceRemove, CommandScienceRemoveT } from '../order/command-science-remove.js';
import { CommandShipClassCreate, CommandShipClassCreateT } from '../order/command-ship-class-create.js';
import { CommandShipClassMerge, CommandShipClassMergeT } from '../order/command-ship-class-merge.js';
import { CommandShipClassRemove, CommandShipClassRemoveT } from '../order/command-ship-class-remove.js';
import { CommandShipGroupBreak, CommandShipGroupBreakT } from '../order/command-ship-group-break.js';
import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from '../order/command-ship-group-dismantle.js';
import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from '../order/command-ship-group-join-fleet.js';
import { CommandShipGroupLoad, CommandShipGroupLoadT } from '../order/command-ship-group-load.js';
import { CommandShipGroupMerge, CommandShipGroupMergeT } from '../order/command-ship-group-merge.js';
import { CommandShipGroupSend, CommandShipGroupSendT } from '../order/command-ship-group-send.js';
import { CommandShipGroupTransfer, CommandShipGroupTransferT } from '../order/command-ship-group-transfer.js';
import { CommandShipGroupUnload, CommandShipGroupUnloadT } from '../order/command-ship-group-unload.js';
import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from '../order/command-ship-group-upgrade.js';
export enum CommandPayload {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { PlanetProduction } from './planet-production.js';
import { PlanetProduction } from '../order/planet-production.js';
export class CommandPlanetProduce implements flatbuffers.IUnpackableObject<CommandPlanetProduceT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { PlanetRouteLoadType } from './planet-route-load-type.js';
import { PlanetRouteLoadType } from '../order/planet-route-load-type.js';
export class CommandPlanetRouteRemove implements flatbuffers.IUnpackableObject<CommandPlanetRouteRemoveT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { PlanetRouteLoadType } from './planet-route-load-type.js';
import { PlanetRouteLoadType } from '../order/planet-route-load-type.js';
export class CommandPlanetRouteSet implements flatbuffers.IUnpackableObject<CommandPlanetRouteSetT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { Relation } from './relation.js';
import { Relation } from '../order/relation.js';
export class CommandRaceRelation implements flatbuffers.IUnpackableObject<CommandRaceRelationT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { ShipGroupCargo } from './ship-group-cargo.js';
import { ShipGroupCargo } from '../order/ship-group-cargo.js';
export class CommandShipGroupLoad implements flatbuffers.IUnpackableObject<CommandShipGroupLoadT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { ShipGroupUpgradeTech } from './ship-group-upgrade-tech.js';
import { ShipGroupUpgradeTech } from '../order/ship-group-upgrade-tech.js';
export class CommandShipGroupUpgrade implements flatbuffers.IUnpackableObject<CommandShipGroupUpgradeT> {
@@ -5,7 +5,7 @@
import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js';
import { CommandItem, CommandItemT } from './command-item.js';
import { CommandItem, CommandItemT } from '../order/command-item.js';
export class UserGamesCommand implements flatbuffers.IUnpackableObject<UserGamesCommandT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { UserGamesOrder, UserGamesOrderT } from './user-games-order.js';
import { UserGamesOrder, UserGamesOrderT } from '../order/user-games-order.js';
export class UserGamesOrderGetResponse implements flatbuffers.IUnpackableObject<UserGamesOrderGetResponseT> {
@@ -5,7 +5,7 @@
import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js';
import { CommandItem, CommandItemT } from './command-item.js';
import { CommandItem, CommandItemT } from '../order/command-item.js';
export class UserGamesOrderResponse implements flatbuffers.IUnpackableObject<UserGamesOrderResponseT> {
@@ -5,7 +5,7 @@
import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js';
import { CommandItem, CommandItemT } from './command-item.js';
import { CommandItem, CommandItemT } from '../order/command-item.js';
export class UserGamesOrder implements flatbuffers.IUnpackableObject<UserGamesOrderT> {
@@ -2,6 +2,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
export { BattleSummary, BattleSummaryT } from './report/battle-summary.js';
export { Bombing, BombingT } from './report/bombing.js';
export { GameReportRequest, GameReportRequestT } from './report/game-report-request.js';
export { IncomingGroup, IncomingGroupT } from './report/incoming-group.js';
@@ -0,0 +1,104 @@
// automatically generated by the FlatBuffers compiler, do not modify
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js';
export class BattleSummary implements flatbuffers.IUnpackableObject<BattleSummaryT> {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):BattleSummary {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsBattleSummary(bb:flatbuffers.ByteBuffer, obj?:BattleSummary):BattleSummary {
return (obj || new BattleSummary()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsBattleSummary(bb:flatbuffers.ByteBuffer, obj?:BattleSummary):BattleSummary {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new BattleSummary()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
id(obj?:UUID):UUID|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? (obj || new UUID()).__init(this.bb_pos + offset, this.bb!) : null;
}
planet():bigint {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readUint64(this.bb_pos + offset) : BigInt('0');
}
shots():bigint {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.readUint64(this.bb_pos + offset) : BigInt('0');
}
static startBattleSummary(builder:flatbuffers.Builder) {
builder.startObject(3);
}
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
builder.addFieldStruct(0, idOffset, 0);
}
static addPlanet(builder:flatbuffers.Builder, planet:bigint) {
builder.addFieldInt64(1, planet, BigInt('0'));
}
static addShots(builder:flatbuffers.Builder, shots:bigint) {
builder.addFieldInt64(2, shots, BigInt('0'));
}
static endBattleSummary(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
builder.requiredField(offset, 4) // id
return offset;
}
static createBattleSummary(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, planet:bigint, shots:bigint):flatbuffers.Offset {
BattleSummary.startBattleSummary(builder);
BattleSummary.addId(builder, idOffset);
BattleSummary.addPlanet(builder, planet);
BattleSummary.addShots(builder, shots);
return BattleSummary.endBattleSummary(builder);
}
unpack(): BattleSummaryT {
return new BattleSummaryT(
(this.id() !== null ? this.id()!.unpack() : null),
this.planet(),
this.shots()
);
}
unpackTo(_o: BattleSummaryT): void {
_o.id = (this.id() !== null ? this.id()!.unpack() : null);
_o.planet = this.planet();
_o.shots = this.shots();
}
}
export class BattleSummaryT implements flatbuffers.IGeneratedObject {
constructor(
public id: UUIDT|null = null,
public planet: bigint = BigInt('0'),
public shots: bigint = BigInt('0')
){}
pack(builder:flatbuffers.Builder): flatbuffers.Offset {
return BattleSummary.createBattleSummary(builder,
(this.id !== null ? this.id!.pack(builder) : 0),
this.planet,
this.shots
);
}
}
@@ -5,7 +5,7 @@
import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js';
import { TechEntry, TechEntryT } from './tech-entry.js';
import { TechEntry, TechEntryT } from '../report/tech-entry.js';
export class LocalGroup implements flatbuffers.IUnpackableObject<LocalGroupT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { TechEntry, TechEntryT } from './tech-entry.js';
import { TechEntry, TechEntryT } from '../report/tech-entry.js';
export class OtherGroup implements flatbuffers.IUnpackableObject<OtherGroupT> {
@@ -4,24 +4,24 @@
import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js';
import { Bombing, BombingT } from './bombing.js';
import { IncomingGroup, IncomingGroupT } from './incoming-group.js';
import { LocalFleet, LocalFleetT } from './local-fleet.js';
import { LocalGroup, LocalGroupT } from './local-group.js';
import { LocalPlanet, LocalPlanetT } from './local-planet.js';
import { OtherGroup, OtherGroupT } from './other-group.js';
import { OtherPlanet, OtherPlanetT } from './other-planet.js';
import { OtherScience, OtherScienceT } from './other-science.js';
import { OthersShipClass, OthersShipClassT } from './others-ship-class.js';
import { Player, PlayerT } from './player.js';
import { Route, RouteT } from './route.js';
import { Science, ScienceT } from './science.js';
import { ShipClass, ShipClassT } from './ship-class.js';
import { ShipProduction, ShipProductionT } from './ship-production.js';
import { UnidentifiedGroup, UnidentifiedGroupT } from './unidentified-group.js';
import { UnidentifiedPlanet, UnidentifiedPlanetT } from './unidentified-planet.js';
import { UninhabitedPlanet, UninhabitedPlanetT } from './uninhabited-planet.js';
import { BattleSummary, BattleSummaryT } from '../report/battle-summary.js';
import { Bombing, BombingT } from '../report/bombing.js';
import { IncomingGroup, IncomingGroupT } from '../report/incoming-group.js';
import { LocalFleet, LocalFleetT } from '../report/local-fleet.js';
import { LocalGroup, LocalGroupT } from '../report/local-group.js';
import { LocalPlanet, LocalPlanetT } from '../report/local-planet.js';
import { OtherGroup, OtherGroupT } from '../report/other-group.js';
import { OtherPlanet, OtherPlanetT } from '../report/other-planet.js';
import { OtherScience, OtherScienceT } from '../report/other-science.js';
import { OthersShipClass, OthersShipClassT } from '../report/others-ship-class.js';
import { Player, PlayerT } from '../report/player.js';
import { Route, RouteT } from '../report/route.js';
import { Science, ScienceT } from '../report/science.js';
import { ShipClass, ShipClassT } from '../report/ship-class.js';
import { ShipProduction, ShipProductionT } from '../report/ship-production.js';
import { UnidentifiedGroup, UnidentifiedGroupT } from '../report/unidentified-group.js';
import { UnidentifiedPlanet, UnidentifiedPlanetT } from '../report/unidentified-planet.js';
import { UninhabitedPlanet, UninhabitedPlanetT } from '../report/uninhabited-planet.js';
export class Report implements flatbuffers.IUnpackableObject<ReportT> {
@@ -136,9 +136,9 @@ otherShipClassLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
battle(index: number, obj?:UUID):UUID|null {
battle(index: number, obj?:BattleSummary):BattleSummary|null {
const offset = this.bb!.__offset(this.bb_pos, 30);
return offset ? (obj || new UUID()).__init(this.bb!.__vector(this.bb_pos + offset) + index * 16, this.bb!) : null;
return offset ? (obj || new BattleSummary()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
battleLength():number {
@@ -386,8 +386,16 @@ static addBattle(builder:flatbuffers.Builder, battleOffset:flatbuffers.Offset) {
builder.addFieldOffset(13, battleOffset, 0);
}
static createBattleVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startBattleVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(16, numElems, 8);
builder.startVector(4, numElems, 4);
}
static addBombing(builder:flatbuffers.Builder, bombingOffset:flatbuffers.Offset) {
@@ -641,7 +649,7 @@ unpack(): ReportT {
this.bb!.createObjList<OtherScience, OtherScienceT>(this.otherScience.bind(this), this.otherScienceLength()),
this.bb!.createObjList<ShipClass, ShipClassT>(this.localShipClass.bind(this), this.localShipClassLength()),
this.bb!.createObjList<OthersShipClass, OthersShipClassT>(this.otherShipClass.bind(this), this.otherShipClassLength()),
this.bb!.createObjList<UUID, UUIDT>(this.battle.bind(this), this.battleLength()),
this.bb!.createObjList<BattleSummary, BattleSummaryT>(this.battle.bind(this), this.battleLength()),
this.bb!.createObjList<Bombing, BombingT>(this.bombing.bind(this), this.bombingLength()),
this.bb!.createObjList<IncomingGroup, IncomingGroupT>(this.incomingGroup.bind(this), this.incomingGroupLength()),
this.bb!.createObjList<LocalPlanet, LocalPlanetT>(this.localPlanet.bind(this), this.localPlanetLength()),
@@ -672,7 +680,7 @@ unpackTo(_o: ReportT): void {
_o.otherScience = this.bb!.createObjList<OtherScience, OtherScienceT>(this.otherScience.bind(this), this.otherScienceLength());
_o.localShipClass = this.bb!.createObjList<ShipClass, ShipClassT>(this.localShipClass.bind(this), this.localShipClassLength());
_o.otherShipClass = this.bb!.createObjList<OthersShipClass, OthersShipClassT>(this.otherShipClass.bind(this), this.otherShipClassLength());
_o.battle = this.bb!.createObjList<UUID, UUIDT>(this.battle.bind(this), this.battleLength());
_o.battle = this.bb!.createObjList<BattleSummary, BattleSummaryT>(this.battle.bind(this), this.battleLength());
_o.bombing = this.bb!.createObjList<Bombing, BombingT>(this.bombing.bind(this), this.bombingLength());
_o.incomingGroup = this.bb!.createObjList<IncomingGroup, IncomingGroupT>(this.incomingGroup.bind(this), this.incomingGroupLength());
_o.localPlanet = this.bb!.createObjList<LocalPlanet, LocalPlanetT>(this.localPlanet.bind(this), this.localPlanetLength());
@@ -703,7 +711,7 @@ constructor(
public otherScience: (OtherScienceT)[] = [],
public localShipClass: (ShipClassT)[] = [],
public otherShipClass: (OthersShipClassT)[] = [],
public battle: (UUIDT)[] = [],
public battle: (BattleSummaryT)[] = [],
public bombing: (BombingT)[] = [],
public incomingGroup: (IncomingGroupT)[] = [],
public localPlanet: (LocalPlanetT)[] = [],
@@ -727,7 +735,7 @@ pack(builder:flatbuffers.Builder): flatbuffers.Offset {
const otherScience = Report.createOtherScienceVector(builder, builder.createObjectOffsetList(this.otherScience));
const localShipClass = Report.createLocalShipClassVector(builder, builder.createObjectOffsetList(this.localShipClass));
const otherShipClass = Report.createOtherShipClassVector(builder, builder.createObjectOffsetList(this.otherShipClass));
const battle = builder.createStructOffsetList(this.battle, Report.startBattleVector);
const battle = Report.createBattleVector(builder, builder.createObjectOffsetList(this.battle));
const bombing = Report.createBombingVector(builder, builder.createObjectOffsetList(this.bombing));
const incomingGroup = Report.createIncomingGroupVector(builder, builder.createObjectOffsetList(this.incomingGroup));
const localPlanet = Report.createLocalPlanetVector(builder, builder.createObjectOffsetList(this.localPlanet));
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { RouteEntry, RouteEntryT } from './route-entry.js';
import { RouteEntry, RouteEntryT } from '../report/route-entry.js';
export class Route implements flatbuffers.IUnpackableObject<RouteT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { AccountView, AccountViewT } from './account-view.js';
import { AccountView, AccountViewT } from '../user/account-view.js';
export class AccountResponse implements flatbuffers.IUnpackableObject<AccountResponseT> {
@@ -4,9 +4,9 @@
import * as flatbuffers from 'flatbuffers';
import { ActiveLimit, ActiveLimitT } from './active-limit.js';
import { ActiveSanction, ActiveSanctionT } from './active-sanction.js';
import { EntitlementSnapshot, EntitlementSnapshotT } from './entitlement-snapshot.js';
import { ActiveLimit, ActiveLimitT } from '../user/active-limit.js';
import { ActiveSanction, ActiveSanctionT } from '../user/active-sanction.js';
import { EntitlementSnapshot, EntitlementSnapshotT } from '../user/entitlement-snapshot.js';
export class AccountView implements flatbuffers.IUnpackableObject<AccountViewT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { ActorRef, ActorRefT } from './actor-ref.js';
import { ActorRef, ActorRefT } from '../user/actor-ref.js';
export class ActiveLimit implements flatbuffers.IUnpackableObject<ActiveLimitT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { ActorRef, ActorRefT } from './actor-ref.js';
import { ActorRef, ActorRefT } from '../user/actor-ref.js';
export class ActiveSanction implements flatbuffers.IUnpackableObject<ActiveSanctionT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { ActorRef, ActorRefT } from './actor-ref.js';
import { ActorRef, ActorRefT } from '../user/actor-ref.js';
export class EntitlementSnapshot implements flatbuffers.IUnpackableObject<EntitlementSnapshotT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { ErrorBody, ErrorBodyT } from './error-body.js';
import { ErrorBody, ErrorBodyT } from '../user/error-body.js';
export class ErrorResponse implements flatbuffers.IUnpackableObject<ErrorResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { DeviceSessionView, DeviceSessionViewT } from './device-session-view.js';
import { DeviceSessionView, DeviceSessionViewT } from '../user/device-session-view.js';
export class ListMySessionsResponse implements flatbuffers.IUnpackableObject<ListMySessionsResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { DeviceSessionRevocationSummaryView, DeviceSessionRevocationSummaryViewT } from './device-session-revocation-summary-view.js';
import { DeviceSessionRevocationSummaryView, DeviceSessionRevocationSummaryViewT } from '../user/device-session-revocation-summary-view.js';
export class RevokeAllMySessionsResponse implements flatbuffers.IUnpackableObject<RevokeAllMySessionsResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
import { DeviceSessionView, DeviceSessionViewT } from './device-session-view.js';
import { DeviceSessionView, DeviceSessionViewT } from '../user/device-session-view.js';
export class RevokeMySessionResponse implements flatbuffers.IUnpackableObject<RevokeMySessionResponseT> {
@@ -326,7 +326,19 @@ fresh.
return;
}
try {
const { cache } = await loadStore();
// Synthetic mode still needs the wasm `Core` so
// components that bridge to `pkg/calc/ship.go`
// (designer preview, BattleViewer mass radii) can
// resolve their math against the same engine helpers
// the live path uses. The live branch below also
// calls `loadCore()`; without it here the Battle
// Viewer rendered every ship-class circle at
// MAX_RADIUS in synthetic mode.
const [{ cache }, core] = await Promise.all([
loadStore(),
loadCore(),
]);
coreHolder.set(core);
await Promise.all([
gameState.initSynthetic({ cache, gameId, report }),
orderDraft.init({ cache, gameId }),
@@ -1,6 +1,16 @@
<script lang="ts">
import { page } from "$app/state";
import BattleView from "$lib/active-view/battle.svelte";
const turn = $derived.by(() => {
const raw = page.url.searchParams.get("turn");
const n = raw === null ? NaN : Number(raw);
return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : 0;
});
</script>
<BattleView battleId={page.params.battleId ?? ""} />
<BattleView
gameId={page.params.id ?? ""}
{turn}
battleId={page.params.battleId ?? ""}
/>
+190
View File
@@ -0,0 +1,190 @@
// Phase 27 unit tests for battle and bombing map markers.
import { describe, expect, it } from "vitest";
import type { GameReport } from "../src/api/game-state";
import {
battleMarkerStrokeWidth,
BATTLE_MARKER_COLOR,
BOMBING_MARKER_COLOR_DAMAGED,
BOMBING_MARKER_COLOR_WIPED,
buildBattleAndBombingMarkers,
} from "../src/map/battle-markers";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
describe("battleMarkerStrokeWidth", () => {
it("clamps to 1 px at one shot", () => {
expect(battleMarkerStrokeWidth(1)).toBe(1);
});
it("clamps to 5 px at 100 shots", () => {
expect(battleMarkerStrokeWidth(100)).toBe(5);
});
it("caps above 100 shots at 5 px", () => {
expect(battleMarkerStrokeWidth(250)).toBe(5);
});
it("interpolates linearly between 1 and 100 shots", () => {
// ~halfway: 50 shots → 1 + 49 * 4 / 99 ≈ 2.98
expect(battleMarkerStrokeWidth(50)).toBeCloseTo(2.98, 2);
});
});
function makeReport(overrides: Partial<GameReport>): GameReport {
return {
turn: 1,
mapWidth: 200,
mapHeight: 200,
planetCount: 0,
race: "Earthlings",
planets: [],
localShipClass: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
...overrides,
};
}
describe("buildBattleAndBombingMarkers", () => {
it("returns no primitives when both battles and bombings are empty", () => {
const report = makeReport({});
const out = buildBattleAndBombingMarkers(report);
expect(out.primitives).toEqual([]);
expect(out.lookup.size).toBe(0);
});
it("emits two yellow lines through opposite corners of the planet square per battle", () => {
const report = makeReport({
planets: [
{
number: 4,
name: "Test",
kind: "local",
x: 10,
y: 20,
size: 50,
resources: 0,
industryStockpile: 0,
materialsStockpile: 0,
population: 0,
colonists: 0,
industry: 0,
freeIndustry: 0,
production: "MAT",
owner: null,
},
],
battles: [
{ id: "11111111-1111-1111-1111-111111111111", planet: 4, shots: 100 },
],
});
const out = buildBattleAndBombingMarkers(report);
const lines = out.primitives.filter((p) => p.kind === "line");
expect(lines).toHaveLength(2);
// Same yellow colour, 5 px wide for a 100-shot battle.
for (const l of lines) {
expect(l.style.strokeColor).toBe(BATTLE_MARKER_COLOR);
expect(l.style.strokeWidthPx).toBe(5);
}
// First line: top-left → bottom-right corner of the planet square.
const [a, b] = lines as Array<typeof lines[number] & { x1: number; y1: number; x2: number; y2: number }>;
expect(a.x1).toBeLessThan(a.x2);
expect(a.y1).toBeLessThan(a.y2);
// Second line: top-right → bottom-left.
expect(b.x1).toBeLessThan(b.x2);
expect(b.y1).toBeGreaterThan(b.y2);
});
it("skips battles whose planet is not in the planet list", () => {
const report = makeReport({
battles: [
{ id: "11111111-1111-1111-1111-111111111111", planet: 99, shots: 4 },
],
});
const out = buildBattleAndBombingMarkers(report);
expect(out.primitives).toHaveLength(0);
});
it("emits one yellow ring per damaged bombing and red per wiped", () => {
const report = makeReport({
planets: [
{
number: 1,
name: "A",
kind: "local",
x: 1,
y: 2,
size: 50,
resources: 0,
industryStockpile: 0,
materialsStockpile: 0,
population: 0,
colonists: 0,
industry: 0,
freeIndustry: 0,
production: "MAT",
owner: null,
},
{
number: 2,
name: "B",
kind: "local",
x: 5,
y: 6,
size: 50,
resources: 0,
industryStockpile: 0,
materialsStockpile: 0,
population: 0,
colonists: 0,
industry: 0,
freeIndustry: 0,
production: "MAT",
owner: null,
},
],
bombings: [
{
planetNumber: 1,
planet: "A",
owner: "X",
attacker: "Y",
production: "MAT",
industry: 0,
population: 0,
colonists: 0,
industryStockpile: 0,
materialsStockpile: 0,
attackPower: 1,
wiped: false,
},
{
planetNumber: 2,
planet: "B",
owner: "X",
attacker: "Y",
production: "MAT",
industry: 0,
population: 0,
colonists: 0,
industryStockpile: 0,
materialsStockpile: 0,
attackPower: 1,
wiped: true,
},
],
});
const out = buildBattleAndBombingMarkers(report);
const rings = out.primitives.filter((p) => p.kind === "circle");
expect(rings).toHaveLength(2);
expect(rings[0].style.strokeColor).toBe(BOMBING_MARKER_COLOR_DAMAGED);
expect(rings[1].style.strokeColor).toBe(BOMBING_MARKER_COLOR_WIPED);
});
});
+302
View File
@@ -0,0 +1,302 @@
// Unit tests for the BattleViewer's pure helpers: radial layout and
// the timeline frame builder. Both are pure functions and don't
// require DOM mounting, so they exercise the playback semantics in
// isolation.
import { describe, expect, it } from "vitest";
import type { BattleReport } from "../src/api/battle-fetch";
import { layoutRaces } from "../src/lib/battle-player/radial-layout";
import {
MAX_RADIUS,
MIN_RADIUS,
radiusForMass,
} from "../src/lib/battle-player/mass";
import {
buildFrames,
buildGroupRaceMap,
normaliseGroups,
} from "../src/lib/battle-player/timeline";
describe("layoutRaces", () => {
const center = { x: 100, y: 100 };
const radius = 50;
it("returns no anchors for an empty input", () => {
expect(layoutRaces([], { center, radius })).toEqual([]);
});
it("places one race at the 12 o'clock position", () => {
const result = layoutRaces([0], { center, radius });
expect(result).toHaveLength(1);
expect(result[0].raceId).toBe(0);
expect(result[0].x).toBeCloseTo(center.x, 5);
expect(result[0].y).toBeCloseTo(center.y - radius, 5);
});
it("places two races on the horizontal axis (9 vs 3 o'clock)", () => {
// Special-case duel layout: two anchors face each other on
// the horizontal axis so neither cluster's race label clips
// against the SVG top edge.
const result = layoutRaces([0, 1], { center, radius });
expect(result).toHaveLength(2);
expect(result[0].x).toBeCloseTo(center.x - radius, 5);
expect(result[0].y).toBeCloseTo(center.y, 5);
expect(result[1].x).toBeCloseTo(center.x + radius, 5);
expect(result[1].y).toBeCloseTo(center.y, 5);
});
it("places three races at 120° intervals", () => {
const result = layoutRaces([0, 1, 2], { center, radius });
expect(result).toHaveLength(3);
expect(result[0].angle).toBeCloseTo(-Math.PI / 2, 5);
expect(result[1].angle - result[0].angle).toBeCloseTo((2 * Math.PI) / 3, 5);
expect(result[2].angle - result[1].angle).toBeCloseTo((2 * Math.PI) / 3, 5);
});
it("preserves the input race order", () => {
const result = layoutRaces([7, 2, 5], { center, radius });
expect(result.map((a) => a.raceId)).toEqual([7, 2, 5]);
});
});
const TWO_RACE_BATTLE: BattleReport = {
id: "battle-1",
planet: 4,
planetName: "Test",
races: { "0": "race-A-uuid", "1": "race-B-uuid" },
ships: {
"10": {
race: "Alpha",
className: "Drone",
tech: {},
num: 3,
numLeft: 1,
loadType: "EMP",
loadQuantity: 0,
inBattle: true,
},
"20": {
race: "Beta",
className: "Spy",
tech: {},
num: 2,
numLeft: 0,
loadType: "EMP",
loadQuantity: 0,
inBattle: true,
},
"99": {
race: "Gamma",
className: "Observer",
tech: {},
num: 4,
numLeft: 4,
loadType: "EMP",
loadQuantity: 0,
inBattle: false,
},
},
protocol: [
{ a: 0, sa: 10, d: 1, sd: 20, x: false },
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
{ a: 0, sa: 10, d: 1, sd: 20, x: true },
{ a: 0, sa: 10, d: 1, sd: 20, x: true },
],
};
describe("buildGroupRaceMap", () => {
it("derives group → race from protocol entries", () => {
const map = buildGroupRaceMap(TWO_RACE_BATTLE.protocol);
expect(map.get(10)).toBe(0);
expect(map.get(20)).toBe(1);
});
});
describe("normaliseGroups", () => {
it("returns only in-battle groups with race index attached", () => {
const groups = normaliseGroups(TWO_RACE_BATTLE);
expect(groups.map((g) => g.key).sort((a, b) => a - b)).toEqual([10, 20]);
expect(groups.every((g) => g.group.inBattle)).toBe(true);
});
});
describe("buildFrames", () => {
it("produces protocol.length + 1 frames", () => {
const frames = buildFrames(TWO_RACE_BATTLE);
expect(frames).toHaveLength(TWO_RACE_BATTLE.protocol.length + 1);
});
it("frame 0 reports initial ship counts and all active races", () => {
const [first] = buildFrames(TWO_RACE_BATTLE);
expect(first.shotIndex).toBe(0);
expect(first.lastAction).toBeNull();
expect(first.remaining.get(10)).toBe(3);
expect(first.remaining.get(20)).toBe(2);
expect(first.activeRaceIds).toEqual([0, 1]);
});
it("decrements destroyed defenders only on x === true", () => {
const frames = buildFrames(TWO_RACE_BATTLE);
// Action 1: x=false → no decrement on defender 20.
expect(frames[1].remaining.get(20)).toBe(2);
// Action 2: x=true → attacker is race 1 group 20, defender
// is race 0 group 10 → group 10 drops 3→2.
expect(frames[2].remaining.get(10)).toBe(2);
});
it("drops a race from activeRaceIds once its last in-battle group reaches zero", () => {
const frames = buildFrames(TWO_RACE_BATTLE);
// After the 4-th action both Beta ships have been destroyed.
expect(frames[4].remaining.get(20)).toBe(0);
expect(frames[4].activeRaceIds).toEqual([0]);
});
});
describe("buildFrames phantom-destroy clamp", () => {
it("does not drop a race when destroyed shots exceed initial counts", () => {
// Race "Phantom" has a single group with 2 ships; the engine
// emits five Destroyed shots against it (legacy emitter quirk
// reproduced in KNNTS041 planet #7). The group goes to 0
// after two real destroys; the remaining three are phantoms
// and must not push raceTotals into negatives or drop the
// race from activeRaceIds prematurely. Race "Survivor" keeps
// its single ship throughout so it stays active alongside
// Phantom until Phantom legitimately empties.
const report: BattleReport = {
id: "phantom-battle",
planet: 1,
planetName: "P",
races: { "0": "phantom-uuid", "1": "survivor-uuid" },
ships: {
"10": {
race: "Phantom",
className: "Drone",
tech: {},
num: 2,
numLeft: 0,
loadType: "",
loadQuantity: 0,
inBattle: true,
},
"20": {
race: "Survivor",
className: "Hawk",
tech: {},
num: 1,
numLeft: 1,
loadType: "",
loadQuantity: 0,
inBattle: true,
},
},
protocol: [
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // real kill #1
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // real kill #2 → group=0
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #1
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #2
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #3
],
};
const frames = buildFrames(report);
expect(frames[2].remaining.get(10)).toBe(0);
// After the 2nd real destroy Phantom has 0 ships in its only
// group and must drop out of activeRaceIds.
expect(frames[2].activeRaceIds).toEqual([1]);
// Phantoms past frame 2 must NOT keep decrementing — group
// stays at 0, totals don't go negative, and Survivor remains
// the only active race for the remainder of the protocol.
expect(frames[5].remaining.get(10)).toBe(0);
expect(frames[5].activeRaceIds).toEqual([1]);
});
it("keeps a race active while phantom destroys hit one of its empty groups", () => {
// One race ("Doublet"), two groups of different class. Class
// A gets all five Destroyed shots; class B never gets hit.
// Class A only has 2 ships → 3 phantoms. The race must stay
// active because class B's single ship is intact.
const report: BattleReport = {
id: "doublet-battle",
planet: 2,
planetName: "P2",
races: { "0": "doublet-uuid", "1": "attacker-uuid" },
ships: {
"10": {
race: "Doublet",
className: "A",
tech: {},
num: 2,
numLeft: 0,
loadType: "",
loadQuantity: 0,
inBattle: true,
},
"11": {
race: "Doublet",
className: "B",
tech: {},
num: 1,
numLeft: 1,
loadType: "",
loadQuantity: 0,
inBattle: true,
},
"20": {
race: "Attacker",
className: "Gun",
tech: {},
num: 1,
numLeft: 1,
loadType: "",
loadQuantity: 0,
inBattle: true,
},
},
protocol: [
// Open the protocol with a shot that names class B so
// normaliseGroups picks it up (groups never referenced
// in the protocol are filtered out of the visual
// roster); the shot misses so class B stays intact.
{ a: 1, sa: 20, d: 0, sd: 11, x: false },
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
],
};
const frames = buildFrames(report);
// After all 6 actions: Doublet:A is at 0 (group capped at 2
// real destroys + 3 phantoms), Doublet:B unchanged at 1, so
// race totals = 1 → race stays active.
expect(frames[6].remaining.get(10)).toBe(0);
expect(frames[6].remaining.get(11)).toBe(1);
expect(frames[6].activeRaceIds.sort()).toEqual([0, 1]);
});
});
describe("radiusForMass", () => {
it("returns MAX_RADIUS when mass is zero", () => {
expect(radiusForMass(0, 100)).toBe(MAX_RADIUS);
});
it("returns MAX_RADIUS when maxMassInBattle is zero", () => {
expect(radiusForMass(50, 0)).toBe(MAX_RADIUS);
});
it("returns MAX_RADIUS at the per-battle ceiling", () => {
expect(radiusForMass(100, 100)).toBeCloseTo(MAX_RADIUS, 5);
});
it("scales by sqrt(mass / maxMass): one quarter of max mass = halfway", () => {
const r = radiusForMass(25, 100);
const expected = MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * 0.5;
expect(r).toBeCloseTo(expected, 5);
});
it("never returns a radius below MIN_RADIUS or above MAX_RADIUS", () => {
expect(radiusForMass(1, Number.MAX_SAFE_INTEGER)).toBeGreaterThanOrEqual(MIN_RADIUS);
expect(radiusForMass(Number.MAX_SAFE_INTEGER, 1)).toBeLessThanOrEqual(MAX_RADIUS);
});
});
+305
View File
@@ -0,0 +1,305 @@
// Phase 27 — Playwright coverage for the Battle Viewer.
//
// Mocks both the Connect-RPC `user.games.report` (so the report
// renders battles + bombings) and the REST forwarder
// `/api/v1/user/games/{game_id}/battles/{turn}/{battle_id}` (so the
// viewer page loads its `BattleReport` without an engine).
// Drives three flows:
// 1. Reports view → click battle UUID → viewer renders.
// 2. Playback controls: play / step back.
// 3. Reports view → click bombing marker proxy → row scrolls
// (here approximated by clicking the link in Reports — the
// map e2e flow is exercised separately by `map-roundtrip`).
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { ByteBuffer } from "flatbuffers";
import { expect, test, type Page } from "@playwright/test";
import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb";
import { UUID } from "../../src/proto/galaxy/fbs/common";
import { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
import {
buildOrderResponsePayload,
buildOrderGetResponsePayload,
} from "./fixtures/order-fbs";
import { buildMyGamesListPayload, type GameFixture } from "./fixtures/lobby-fbs";
import { buildReportPayload } from "./fixtures/report-fbs";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
const GAME_ID = "00000000-0000-0000-0000-000000000010";
const BATTLE_ID = "11111111-1111-1111-1111-111111111111";
const SESSION_ID = "device-session-battle";
const RACE_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
const RACE_B = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
const SAMPLE_BATTLE = {
id: BATTLE_ID,
planet: 1,
planetName: "Earth",
races: { "0": RACE_A, "1": RACE_B },
ships: {
"10": {
race: "Earthlings",
className: "Cruiser",
tech: { WEAPONS: 1 },
num: 3,
numLeft: 2,
loadType: "EMP",
loadQuantity: 0,
inBattle: true,
},
"20": {
race: "Bajori",
className: "Hawk",
tech: { SHIELDS: 1 },
num: 2,
numLeft: 0,
loadType: "EMP",
loadQuantity: 0,
inBattle: true,
},
},
protocol: [
{ a: 0, sa: 10, d: 1, sd: 20, x: false },
{ a: 0, sa: 10, d: 1, sd: 20, x: true },
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
{ a: 0, sa: 10, d: 1, sd: 20, x: true },
],
};
async function mockGatewayAndBattle(page: Page): Promise<void> {
const game: GameFixture = {
gameId: GAME_ID,
gameName: "Phase 27 Game",
gameType: "private",
status: "running",
ownerUserId: "user-1",
minPlayers: 2,
maxPlayers: 8,
enrollmentEndsAtMs: BigInt(Date.now() + 86_400_000),
createdAtMs: BigInt(Date.now() - 86_400_000),
updatedAtMs: BigInt(Date.now()),
currentTurn: 1,
};
await page.route(
"**/galaxy.gateway.v1.EdgeGateway/ExecuteCommand",
async (route) => {
const reqText = route.request().postData();
if (reqText === null) {
await route.fulfill({ status: 400 });
return;
}
const req = fromJson(
ExecuteCommandRequestSchema,
JSON.parse(reqText) as JsonValue,
);
let resultCode = "ok";
let payload: Uint8Array;
switch (req.messageType) {
case "lobby.my.games.list":
payload = buildMyGamesListPayload([game]);
break;
case "user.games.report": {
GameReportRequest.getRootAsGameReportRequest(
new ByteBuffer(req.payloadBytes),
).gameId(new UUID());
payload = buildReportPayload({
turn: 1,
mapWidth: 4000,
mapHeight: 4000,
race: "Earthlings",
localPlanets: [
{
number: 1,
name: "Earth",
x: 2000,
y: 2000,
size: 1000,
resources: 5,
population: 4000,
industry: 3000,
capital: 0,
material: 0,
colonists: 100,
freeIndustry: 800,
production: "Cruiser",
},
],
battles: [{ id: BATTLE_ID, planet: 1, shots: 4 }],
localShipClass: [
{
name: "Cruiser",
drive: 10,
armament: 2,
weapons: 5,
shields: 5,
cargo: 2,
},
],
otherShipClass: [
{
race: "Bajori",
name: "Hawk",
drive: 12,
armament: 1,
weapons: 4,
shields: 2,
cargo: 0,
mass: 75,
},
],
});
break;
}
case "user.games.order":
payload = buildOrderResponsePayload(GAME_ID, [], Date.now());
break;
case "user.games.order.get":
payload = buildOrderGetResponsePayload(GAME_ID, [], Date.now(), false);
break;
default:
resultCode = "internal_error";
payload = new Uint8Array();
}
const body = await forgeExecuteCommandResponseJson({
requestId: req.requestId,
timestampMs: BigInt(Date.now()),
resultCode,
payloadBytes: payload,
});
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body,
});
},
);
await page.route(
`**/api/v1/user/games/${GAME_ID}/battles/1/${BATTLE_ID}`,
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(SAMPLE_BATTLE),
});
},
);
await page.route(
`**/api/v1/user/games/${GAME_ID}/battles/1/missing-uuid`,
async (route) => {
await route.fulfill({ status: 404 });
},
);
}
async function bootSession(page: Page): Promise<void> {
await page.goto("/__debug/store");
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
await page.evaluate(() => window.__galaxyDebug!.clearSession());
await page.evaluate(
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
SESSION_ID,
);
}
test.describe("Phase 27 battle viewer", () => {
test("Reports UUID link opens the battle viewer", async ({ page }, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"desktop variant covers the link flow",
);
await mockGatewayAndBattle(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/report`);
await expect(page.getByTestId("active-view-report")).toBeVisible();
const row = page.getByTestId("report-battle-row").first();
await expect(row).toBeVisible();
await row.click();
await expect(page).toHaveURL(
new RegExp(`/games/${GAME_ID}/battle/${BATTLE_ID}\\?turn=1`),
);
await expect(page.getByTestId("battle-viewer")).toBeVisible();
await expect(page.getByTestId("battle-scene")).toBeVisible();
await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
});
test("playback play + step back updates the frame counter", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"desktop variant covers playback controls",
);
await mockGatewayAndBattle(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/battle/${BATTLE_ID}?turn=1`);
await expect(page.getByTestId("battle-viewer")).toBeVisible();
await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
// Step forward once → 1 / 4.
await page.getByTestId("battle-control-step-forward").click();
await expect(page.getByTestId("battle-frame-index")).toContainText("1 / 4");
// Step back to 0 / 4.
await page.getByTestId("battle-control-step-back").click();
await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
});
test("missing battle id surfaces the not-found state", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"desktop variant covers the negative path",
);
await mockGatewayAndBattle(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/battle/missing-uuid?turn=1`);
await expect(page.getByTestId("battle-not-found")).toBeVisible();
});
test("viewer fits the desktop viewport without a vertical scroll", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"desktop-only height-fit check",
);
await page.setViewportSize({ width: 1280, height: 720 });
await mockGatewayAndBattle(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/battle/${BATTLE_ID}?turn=1`);
await expect(page.getByTestId("battle-viewer")).toBeVisible();
await expect(page.getByTestId("battle-scene")).toBeVisible();
// Phase 27 refinement: viewer + log fit the viewport; the
// internal log scrolls inside its own pane rather than
// growing the page. Allow a small tolerance for fractional
// pixel rounding around flex math, but reject any
// scrollable overflow beyond a couple of pixels.
// Phase 27 refinement: viewer + log fit the viewport; the
// internal log scrolls inside its own pane rather than
// growing the page. Allow a small tolerance for fractional
// pixel rounding around flex math.
const overflow = await page.evaluate(
() => document.documentElement.scrollHeight - window.innerHeight,
);
expect(overflow).toBeLessThanOrEqual(4);
});
});
+23 -11
View File
@@ -19,6 +19,7 @@ import { Builder } from "flatbuffers";
import { UUID } from "../../../src/proto/galaxy/fbs/common";
import {
BattleSummary,
Bombing,
LocalPlanet,
OtherPlanet,
@@ -108,6 +109,12 @@ export interface OtherShipClassFixture extends ShipClassFixture {
mass?: number;
}
export interface BattleSummaryFixture {
id: string;
planet: number;
shots: number;
}
export interface BombingFixture {
planetNumber: number;
planet: string;
@@ -149,7 +156,7 @@ export interface ReportFixture {
myVoteFor?: string;
otherScience?: OtherScienceFixture[];
otherShipClass?: OtherShipClassFixture[];
battles?: string[];
battles?: BattleSummaryFixture[];
bombings?: BombingFixture[];
shipProductions?: ShipProductionFixture[];
}
@@ -397,17 +404,22 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
shipProductionOffsets.length === 0
? null
: Report.createShipProductionVector(builder, shipProductionOffsets);
// `battle` is a struct vector (16 bytes per UUID, alignment 8), so
// it uses the start/inline-write/end pattern rather than a typical
// offset-list helper. Iterating in reverse matches the FlatBuffers
// convention that the vector is built end-to-start.
// Phase 27 — `battle` carries `BattleSummary` tables, each with
// an inline `id:UUID` struct plus `planet` and `shots` slots.
const battleVec = (() => {
const ids = fixture.battles ?? [];
if (ids.length === 0) return null;
Report.startBattleVector(builder, ids.length);
for (let i = ids.length - 1; i >= 0; i--) {
const [hi, lo] = uuidToHiLo(ids[i]!);
UUID.createUUID(builder, hi, lo);
const summaries = fixture.battles ?? [];
if (summaries.length === 0) return null;
const offsets = summaries.map((s) => {
const [hi, lo] = uuidToHiLo(s.id);
BattleSummary.startBattleSummary(builder);
BattleSummary.addId(builder, UUID.createUUID(builder, hi, lo));
BattleSummary.addPlanet(builder, BigInt(s.planet));
BattleSummary.addShots(builder, BigInt(s.shots));
return BattleSummary.endBattleSummary(builder);
});
Report.startBattleVector(builder, offsets.length);
for (let i = offsets.length - 1; i >= 0; i--) {
builder.addOffset(offsets[i]);
}
return builder.endVector();
})();
@@ -151,7 +151,7 @@ async function mockGateway(page: Page): Promise<void> {
{ race: "Andori", name: "Spear", drive: 8, armament: 4, weapons: 6, shields: 3, cargo: 1, mass: 90 },
{ race: "Bajori", name: "Hawk", drive: 12, armament: 1, weapons: 4, shields: 2, cargo: 0, mass: 75 },
],
battles: [BATTLE_ID],
battles: [{ id: BATTLE_ID, planet: 1, shots: 12 }],
bombings: [
{ planetNumber: 1, planet: "Earth", owner: "Earthlings", attacker: "Bajori", production: "Cruiser", industry: 500, population: 200, colonists: 12, capital: 30, material: 5, attackPower: 250, wiped: false },
{ planetNumber: 99, planet: "DW-99", owner: "Earthlings", attacker: "Bajori", production: "Dron", industry: 0, population: 0, colonists: 0, capital: 0, material: 0, attackPower: 800, wiped: true },
+17 -5
View File
@@ -76,18 +76,30 @@ describe("active-view stubs", () => {
);
});
test("battle stub stamps the battleId on the host element", () => {
const ui = render(BattleView, { props: { battleId: "b-42" } });
test("battle view stamps the battleId and shows the loading placeholder", () => {
// Phase 27 replaces the Phase 10 stub with the Battle Viewer
// wrapper. The latest layout iteration moved the back-
// navigation buttons inside `BattleViewer` so they only mount
// once the BattleReport finishes loading. The wrapper itself
// always renders the `active-view-battle` host with the
// `data-battle-id` stamp and a localized loading copy until
// the fetcher resolves.
const ui = render(BattleView, {
props: { gameId: "synthetic-test", turn: 0, battleId: "b-42" },
});
const node = ui.getByTestId("active-view-battle");
expect(node).toHaveAttribute("data-battle-id", "b-42");
expect(node).toHaveTextContent("battle log");
expect(ui.getByTestId("battle-loading")).toBeInTheDocument();
});
test("battle stub accepts an empty battleId for the list URL", () => {
const ui = render(BattleView, { props: { battleId: "" } });
test("battle view surfaces the not-found state for an empty battleId", () => {
const ui = render(BattleView, {
props: { gameId: "synthetic-test", turn: 0, battleId: "" },
});
expect(ui.getByTestId("active-view-battle")).toHaveAttribute(
"data-battle-id",
"",
);
expect(ui.getByTestId("battle-not-found")).toBeInTheDocument();
});
});
@@ -8,6 +8,7 @@
// every spec to enumerate the full GameReport surface.
import type {
ReportBattle,
ReportBombing,
ReportIncomingShipGroup,
ReportLocalFleet,
@@ -36,6 +37,7 @@ export const EMPTY_SHIP_GROUPS: {
players: ReportPlayer[];
otherScience: ReportOtherScience[];
otherShipClass: ReportOtherShipClass[];
battles: ReportBattle[];
battleIds: string[];
bombings: ReportBombing[];
shipProductions: ReportShipProduction[];
@@ -53,6 +55,7 @@ export const EMPTY_SHIP_GROUPS: {
players: [],
otherScience: [],
otherShipClass: [],
battles: [],
battleIds: [],
bombings: [],
shipProductions: [],
@@ -75,6 +75,7 @@ function makeReport(
players: [],
otherScience: [],
otherShipClass: [],
battles: [],
battleIds: [],
bombings: [],
shipProductions: [],
@@ -16,6 +16,11 @@ import {
isSyntheticGameId,
loadSyntheticReportFromJSON,
} from "../src/api/synthetic-report";
import type { BattleReport } from "../src/api/battle-fetch";
import {
lookupSyntheticBattle,
resetSyntheticBattles,
} from "../src/api/synthetic-battle";
function syntheticJSON(extra: Record<string, unknown> = {}): unknown {
return {
@@ -244,3 +249,55 @@ describe("getSyntheticReport", () => {
expect(getSyntheticReport("synthetic-missing")).toBeUndefined();
});
});
describe("envelope shape (v1)", () => {
test("forwards battles to the synthetic-battle registry", () => {
resetSyntheticBattles();
const battle: BattleReport = {
id: "11111111-1111-1111-1111-111111111111",
planet: 17,
planetName: "Castle",
races: { "0": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" },
ships: {
"0": {
race: "KnightErrants",
className: "Drone",
tech: { DRIVE: 1 },
num: 1,
numLeft: 0,
loadType: "",
loadQuantity: 0,
inBattle: true,
},
},
protocol: [],
};
const envelope = {
version: 1,
report: syntheticJSON(),
battles: { [battle.id]: battle },
};
const { gameId, report } = loadSyntheticReportFromJSON(envelope);
expect(gameId.startsWith(SYNTHETIC_GAME_ID_PREFIX)).toBe(true);
expect(report.turn).toBe(39);
expect(lookupSyntheticBattle(battle.id)).toEqual(battle);
});
test("missing battles field leaves the registry untouched", () => {
resetSyntheticBattles();
const envelope = {
version: 1,
report: syntheticJSON(),
};
loadSyntheticReportFromJSON(envelope);
expect(lookupSyntheticBattle("any")).toBeNull();
});
test("bare Report (no envelope) still loads — backward compat", () => {
resetSyntheticBattles();
const { report } = loadSyntheticReportFromJSON(syntheticJSON());
expect(report.turn).toBe(39);
expect(lookupSyntheticBattle("any")).toBeNull();
});
});