16 Commits

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

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

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

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

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

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

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

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

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

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

Static cluster + visibility filter:

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

Duel layout (user suggestion):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:13:19 +02:00
115 changed files with 55810 additions and 12246 deletions
+36
View File
@@ -26,6 +26,7 @@ const (
pathPlayerCommand = "/api/v1/command" pathPlayerCommand = "/api/v1/command"
pathPlayerOrder = "/api/v1/order" pathPlayerOrder = "/api/v1/order"
pathPlayerReport = "/api/v1/report" pathPlayerReport = "/api/v1/report"
pathPlayerBattle = "/api/v1/battle"
pathHealthz = "/healthz" pathHealthz = "/healthz"
) )
@@ -269,6 +270,41 @@ func (c *Client) GetReport(ctx context.Context, baseURL, raceName string, turn i
} }
} }
// FetchBattle calls `GET /api/v1/battle/<turn>/<battleID>` and returns
// the engine response body verbatim alongside the engine status code.
// 200 carries the BattleReport JSON; 404 means the battle is unknown
// and the body may be empty. Other 4xx statuses come back wrapped in
// ErrEngineValidation, everything else in ErrEngineUnreachable.
func (c *Client) FetchBattle(ctx context.Context, baseURL string, turn int, battleID string) (json.RawMessage, int, error) {
if err := validateBaseURL(baseURL); err != nil {
return nil, 0, err
}
if turn < 0 {
return nil, 0, fmt.Errorf("engineclient battle get: turn must not be negative, got %d", turn)
}
if strings.TrimSpace(battleID) == "" {
return nil, 0, errors.New("engineclient battle get: battle id must not be empty")
}
target := baseURL + pathPlayerBattle + "/" + strconv.Itoa(turn) + "/" + url.PathEscape(battleID)
body, status, doErr := c.doRequest(ctx, http.MethodGet, target, nil, c.probeTimeout)
if doErr != nil {
return nil, 0, fmt.Errorf("%w: engine battle get: %w", ErrEngineUnreachable, doErr)
}
switch status {
case http.StatusOK:
if len(body) == 0 {
return nil, status, fmt.Errorf("%w: engine battle get: empty response body", ErrEngineProtocolViolation)
}
return json.RawMessage(body), status, nil
case http.StatusNotFound:
return nil, status, nil
case http.StatusBadRequest, http.StatusConflict:
return json.RawMessage(body), status, fmt.Errorf("%w: engine battle get: %s", ErrEngineValidation, summariseEngineError(body, status))
default:
return nil, status, fmt.Errorf("%w: engine battle get: %s", ErrEngineUnreachable, summariseEngineError(body, status))
}
}
// Healthz calls `GET /healthz`. Returns nil on 2xx. // Healthz calls `GET /healthz`. Returns nil on 2xx.
func (c *Client) Healthz(ctx context.Context, baseURL string) error { func (c *Client) Healthz(ctx context.Context, baseURL string) error {
if err := validateBaseURL(baseURL); err != nil { if err := validateBaseURL(baseURL); err != nil {
@@ -257,6 +257,63 @@ func TestClientGetOrderRejectsBadInput(t *testing.T) {
} }
} }
func TestClientFetchBattleForwardsPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("unexpected method: %s", r.Method)
}
want := pathPlayerBattle + "/3/" + "11111111-1111-1111-1111-111111111111"
if r.URL.Path != want {
t.Fatalf("path = %q, want %q", r.URL.Path, want)
}
_, _ = w.Write([]byte(`{"id":"11111111-1111-1111-1111-111111111111","planet":4}`))
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
body, status, err := cli.FetchBattle(context.Background(), srv.URL, 3, "11111111-1111-1111-1111-111111111111")
if err != nil {
t.Fatalf("FetchBattle: %v", err)
}
if status != http.StatusOK {
t.Fatalf("status = %d", status)
}
if !strings.Contains(string(body), `"planet":4`) {
t.Fatalf("body = %s", body)
}
}
func TestClientFetchBattleNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
body, status, err := cli.FetchBattle(context.Background(), srv.URL, 0, "11111111-1111-1111-1111-111111111111")
if err != nil {
t.Fatalf("FetchBattle: %v", err)
}
if status != http.StatusNotFound {
t.Fatalf("status = %d", status)
}
if body != nil {
t.Fatalf("expected nil body on 404, got %s", body)
}
}
func TestClientFetchBattleRejectsBadInput(t *testing.T) {
cli := newTestClient(t, httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("server must not be hit on bad input")
})))
if _, _, err := cli.FetchBattle(context.Background(), "http://example.com", -1, "11111111-1111-1111-1111-111111111111"); err == nil {
t.Fatal("expected error on negative turn")
}
if _, _, err := cli.FetchBattle(context.Background(), "http://example.com", 0, ""); err == nil {
t.Fatal("expected error on empty battle id")
}
}
func TestClientHealthzSuccess(t *testing.T) { func TestClientHealthzSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathHealthz { if r.URL.Path != pathHealthz {
+1
View File
@@ -45,6 +45,7 @@ var pathParamStubs = map[string]string{
"delivery_id": "00000000-0000-0000-0000-000000000006", "delivery_id": "00000000-0000-0000-0000-000000000006",
"user_id": "00000000-0000-0000-0000-000000000007", "user_id": "00000000-0000-0000-0000-000000000007",
"device_session_id": "00000000-0000-0000-0000-000000000008", "device_session_id": "00000000-0000-0000-0000-000000000008",
"battle_id": "00000000-0000-0000-0000-000000000009",
"id": "1.2.3", "id": "1.2.3",
"username": "alice", "username": "alice",
"turn": "42", "turn": "42",
@@ -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 // rebindActor decodes a JSON object from raw, sets `actor` to
// raceName, and re-encodes. Backend never trusts the actor field // raceName, and re-encodes. Backend never trusts the actor field
// supplied by the client (per ARCHITECTURE.md §9). // supplied by the client (per ARCHITECTURE.md §9).
+1
View File
@@ -263,6 +263,7 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
userGames.POST("/:game_id/orders", deps.UserGames.Orders()) userGames.POST("/:game_id/orders", deps.UserGames.Orders())
userGames.GET("/:game_id/orders", deps.UserGames.GetOrders()) userGames.GET("/:game_id/orders", deps.UserGames.GetOrders())
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report()) userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
userGames.GET("/:game_id/battles/:turn/:battle_id", deps.UserGames.Battle())
userSessions := group.Group("/sessions") userSessions := group.Group("/sessions")
userSessions.GET("", deps.UserSessions.List()) userSessions.GET("", deps.UserSessions.List())
+38
View File
@@ -1106,6 +1106,44 @@ paths:
$ref: "#/components/responses/NotImplementedError" $ref: "#/components/responses/NotImplementedError"
"500": "500":
$ref: "#/components/responses/InternalError" $ref: "#/components/responses/InternalError"
/api/v1/user/games/{game_id}/battles/{turn}/{battle_id}:
get:
tags: [User]
operationId: userGamesBattle
summary: Read one engine battle report
description: |
Forwards to the engine's `GET /api/v1/battle/:turn/:uuid`. The
engine response body is passed through verbatim. `404 Not Found`
is returned when the battle does not exist for the supplied
`turn` / `battle_id` pair.
security:
- UserHeader: []
parameters:
- $ref: "#/components/parameters/XUserID"
- $ref: "#/components/parameters/GameID"
- $ref: "#/components/parameters/Turn"
- name: battle_id
in: path
required: true
description: Battle identifier (RFC 4122 UUID).
schema:
type: string
format: uuid
responses:
"200":
description: Engine battle report passed through.
content:
application/json:
schema:
$ref: "#/components/schemas/PassthroughObject"
"400":
$ref: "#/components/responses/InvalidRequestError"
"404":
$ref: "#/components/responses/NotFoundError"
"501":
$ref: "#/components/responses/NotImplementedError"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/user/sessions: /api/v1/user/sessions:
get: get:
tags: [User] tags: [User]
+65 -3
View File
@@ -657,7 +657,7 @@ in `runtime_records.turn_schedule`. The backend scheduler
- After a failed tick (`engine_unreachable` / - After a failed tick (`engine_unreachable` /
`generation_failed`): the lobby's `OnRuntimeSnapshot` flips the `generation_failed`): the lobby's `OnRuntimeSnapshot` flips the
game from `running` to `paused` and publishes a `game.paused` 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. + `code = game_paused` until an admin resume succeeds.
`force-next-turn` (admin) schedules a one-shot extra tick that `force-next-turn` (admin) schedules a one-shot extra tick that
@@ -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 and the scroll position is preserved across active-view switches
via SvelteKit's `Snapshot` API. 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 A successful turn generation publishes a runtime snapshot into the
lobby module, which updates the denormalised view (current turn, lobby module, which updates the denormalised view (current turn,
@@ -719,7 +781,7 @@ producer; adding one is purely additive (register the kind in the
catalog, extend the migration `CHECK` constraint, and call catalog, extend the migration `CHECK` constraint, and call
`notification.Submit` from the appropriate domain module). `notification.Submit` from the appropriate domain module).
### 6.6 Cross-references ### 6.7 Cross-references
- Backend ↔ engine wire contract (`pkg/model/{order,report,rest}`): - Backend ↔ engine wire contract (`pkg/model/{order,report,rest}`):
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication). [ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
+65 -3
View File
@@ -675,7 +675,7 @@ engine `/admin/turn` двумя `runtime_status`-флипами:
- После провалившегося тика (`engine_unreachable` / - После провалившегося тика (`engine_unreachable` /
`generation_failed`): `lobby.OnRuntimeSnapshot` переводит игру `generation_failed`): `lobby.OnRuntimeSnapshot` переводит игру
`running → paused` и публикует push-эвент `game.paused` `running → paused` и публикует push-эвент `game.paused`
(см. §6.5). Order-handler'ы отклоняют запросы с HTTP 409 + (см. §6.6). Order-handler'ы отклоняют запросы с HTTP 409 +
`code = game_paused`, пока админ не выполнит resume. `code = game_paused`, пока админ не выполнит resume.
`force-next-turn` (admin) планирует one-shot-доп-тик, который `force-next-turn` (admin) планирует one-shot-доп-тик, который
@@ -704,7 +704,69 @@ empty-state. Якоря секций отображены в sticky-TOC (на м
`<select>`); позиция скролла сохраняется при переключении активного `<select>`); позиция скролла сохраняется при переключении активного
представления через SvelteKit `Snapshot` API. представления через 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-snapshot в lobby-модуль,
который обновляет денормализованное вью (текущий ход, runtime- который обновляет денормализованное вью (текущий ход, runtime-
@@ -740,7 +802,7 @@ Directory-промоушен ([Раздел 5](#5-реестр-названий-
каталоге, расширить `CHECK`-констрейнт миграции и вызвать каталоге, расширить `CHECK`-констрейнт миграции и вызвать
`notification.Submit` из подходящего доменного модуля). `notification.Submit` из подходящего доменного модуля).
### 6.6 Перекрёстные ссылки ### 6.7 Перекрёстные ссылки
- Backend ↔ engine wire-контракт (`pkg/model/{order,report,rest}`): - Backend ↔ engine wire-контракт (`pkg/model/{order,report,rest}`):
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication). [ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
+87
View File
@@ -8,6 +8,7 @@ import (
"galaxy/calc" "galaxy/calc"
"galaxy/game/internal/controller" "galaxy/game/internal/controller"
"galaxy/game/internal/model/game" "galaxy/game/internal/model/game"
"galaxy/model/report"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -184,3 +185,89 @@ func TestProduceBattles(t *testing.T) {
assert.Zero(t, c.ShipGroup(3).Number) assert.Zero(t, c.ShipGroup(3).Number)
} }
} }
// TestTransformBattleAggregatesSameShipClass guards against the
// engine-side variant of the duplicate-class bug. Several ShipGroups
// of the same ShipClass.ID can take part in the same battle (arrivals
// from different planets, tech splits, etc.); they must collapse into
// a single BattleReportGroup with summed Number and NumberLeft. The
// pre-fix engine cached the first group's index and silently dropped
// every subsequent group's initial / survivor counts, which manifested
// downstream as more Destroyed shots in the protocol than the
// recorded initial roster could account for.
func TestTransformBattleAggregatesSameShipClass(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.RaceRelation(Race_0.Name, Race_1.Name, game.RelationWar.String()))
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
// Two Race_0 groups of the SAME ship class (Race_0_Gunship) plus
// one Race_1 group of Race_1_Gunship — all parked on Planet_0
// (owned by Race_0; the Race_1 group lands there via the Unsafe
// helper that bypasses the ownership check). Group indices land
// at 0, 1, 2 in creation order.
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10))
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10))
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R0_Planet_0_num, 5)
// Simulate post-battle survivor counts: Group 0 ended the battle
// with 8 ships, Group 1 with 6. The aggregated BattleReportGroup
// must report NumberLeft = 8 + 6 = 14 (not just the last cached
// group's 6 — that's the regression).
c.ShipGroup(0).Number = 8
c.ShipGroup(1).Number = 6
b := &controller.Battle{
Planet: R0_Planet_0_num,
ObserverGroups: map[int]bool{0: true, 1: true, 2: true},
InitialNumbers: map[int]uint{0: 10, 1: 10, 2: 5},
// Protocol must reference every in-battle group at least once
// (otherwise TransformBattle won't register it through the
// `ship()` path). Two shots from Race_1 against each Race_0
// group hits both groupIds.
Protocol: []controller.BattleAction{
{Attacker: 2, Defender: 0, Destroyed: true},
{Attacker: 2, Defender: 1, Destroyed: true},
},
}
r := controller.TransformBattle(c, b)
// Two BattleReportGroup entries total: one merged Race_0_Gunship
// (groups 0 + 1) and one Race_1_Gunship. NOT three.
if got, want := len(r.Ships), 2; got != want {
t.Fatalf("len(r.Ships) = %d, want %d (duplicate ShipClass.ID must merge)", got, want)
}
var gunship0, gunship1 *report.BattleReportGroup
for i := range r.Ships {
grp := r.Ships[i]
switch grp.Race {
case Race_0.Name:
gunship0 = &grp
case Race_1.Name:
gunship1 = &grp
}
}
if gunship0 == nil || gunship1 == nil {
t.Fatalf("missing race entry: race0=%v race1=%v", gunship0, gunship1)
}
if gunship0.ClassName != Race_0_Gunship {
t.Errorf("race0.ClassName = %q, want %q", gunship0.ClassName, Race_0_Gunship)
}
if gunship0.Number != 20 {
t.Errorf("race0.Number = %d, want 20 (10+10)", gunship0.Number)
}
if gunship0.NumberLeft != 14 {
t.Errorf("race0.NumberLeft = %d, want 14 (8+6)", gunship0.NumberLeft)
}
if !gunship0.InBattle {
t.Errorf("race0.InBattle = false, want true (both source groups were in-battle)")
}
if gunship1.Number != 5 || gunship1.NumberLeft != 5 {
t.Errorf("race1 = (Number=%d, NumberLeft=%d), want (5, 5)",
gunship1.Number, gunship1.NumberLeft)
}
}
+27 -5
View File
@@ -18,10 +18,35 @@ func TransformBattle(c *Cache, b *Battle) *report.BattleReport {
cacheShipClass := make(map[uuid.UUID]int) cacheShipClass := make(map[uuid.UUID]int)
cacheRaceName := make(map[uuid.UUID]int) cacheRaceName := make(map[uuid.UUID]int)
processedGroup := make(map[int]bool)
addShipGroup := func(groupId int, inBattle bool) int { addShipGroup := func(groupId int, inBattle bool) int {
shipClass := c.ShipGroupShipClass(groupId) shipClass := c.ShipGroupShipClass(groupId)
sg := c.ShipGroup(groupId) sg := c.ShipGroup(groupId)
// Several ship-groups of the same race/class can take part
// in the same battle (different tech upgrades, arrivals from
// different planets, …). They share a single
// BattleReportGroup entry keyed by ShipClass.ID — when a
// later group lands on a cached class we add its Number and
// NumberLeft into the existing entry instead of dropping
// them, so the protocol's per-class destroy counts reconcile
// with the recorded totals. `processedGroup` guards against
// double-counting a single groupId across multiple shots in
// the protocol — `ship()` runs on every attacker and defender
// reference, the merge must happen once per groupId.
if existing, ok := cacheShipClass[shipClass.ID]; ok {
if !processedGroup[groupId] {
bg := r.Ships[existing]
bg.Number += b.InitialNumbers[groupId]
bg.NumberLeft += sg.Number
if inBattle {
bg.InBattle = true
}
r.Ships[existing] = bg
processedGroup[groupId] = true
}
return existing
}
itemNumber := len(r.Ships) itemNumber := len(r.Ships)
bg := &report.BattleReportGroup{ bg := &report.BattleReportGroup{
Race: c.g.Race[c.RaceIndex(sg.OwnerID)].Name, Race: c.g.Race[c.RaceIndex(sg.OwnerID)].Name,
@@ -31,23 +56,20 @@ func TransformBattle(c *Cache, b *Battle) *report.BattleReport {
ClassName: shipClass.Name, ClassName: shipClass.Name,
LoadType: sg.CargoString(), LoadType: sg.CargoString(),
LoadQuantity: report.F(sg.Load.F()), LoadQuantity: report.F(sg.Load.F()),
Tech: make(map[string]report.Float, len(sg.Tech)),
} }
for t, v := range sg.Tech { for t, v := range sg.Tech {
bg.Tech[t.String()] = report.F(v.F()) bg.Tech[t.String()] = report.F(v.F())
} }
r.Ships[itemNumber] = *bg r.Ships[itemNumber] = *bg
cacheShipClass[shipClass.ID] = itemNumber cacheShipClass[shipClass.ID] = itemNumber
processedGroup[groupId] = true
return itemNumber return itemNumber
} }
ship := func(groupId int) int { ship := func(groupId int) int {
shipClass := c.ShipGroupShipClass(groupId)
if v, ok := cacheShipClass[shipClass.ID]; ok {
return v
} else {
return addShipGroup(groupId, true) return addShipGroup(groupId, true)
} }
}
race := func(groupId int) int { race := func(groupId int) int {
race := c.ShipGroupOwnerRace(groupId) race := c.ShipGroupOwnerRace(groupId)
+20
View File
@@ -38,6 +38,10 @@ type Repo interface {
// SaveBattle stores a new battle protocol and battle meta data for turn t // SaveBattle stores a new battle protocol and battle meta data for turn t
SaveBattle(uint, *report.BattleReport, *game.BattleMeta) error SaveBattle(uint, *report.BattleReport, *game.BattleMeta) error
// LoadBattle reads battle's protocol for turn t and battle id.
// Returns false if battle with such id was never stored at turn t
LoadBattle(t uint, id uuid.UUID) (*report.BattleReport, bool, error)
// SaveBombing stores all prodused bombings for turn t // SaveBombing stores all prodused bombings for turn t
SaveBombings(uint, []*game.Bombing) error SaveBombings(uint, []*game.Bombing) error
@@ -143,6 +147,14 @@ func FetchOrder(configure func(*Param), actor string, turn uint) (order *order.U
return ec.fetchOrder(actor, turn) return ec.fetchOrder(actor, turn)
} }
func FetchBattle(configure func(*Param), turn uint, ID uuid.UUID) (b *report.BattleReport, exists bool, err error) {
ec, err := NewRepoController(configure)
if err != nil {
return nil, false, err
}
return ec.fetchBattle(turn, ID)
}
func BanishRace(configure func(*Param), actor string) error { func BanishRace(configure func(*Param), actor string) error {
ec, err := NewRepoController(configure) ec, err := NewRepoController(configure)
if err != nil { if err != nil {
@@ -261,6 +273,14 @@ func (ec *RepoController) fetchOrder(actor string, turn uint) (order *order.User
return return
} }
func (ec *RepoController) fetchBattle(turn uint, ID uuid.UUID) (order *report.BattleReport, exists bool, err error) {
err = ec.executeSafe(func(t uint, c *Controller) error {
order, exists, err = ec.Repo.LoadBattle(turn, ID)
return err
})
return
}
func (ec *RepoController) loadReport(actor string, turn uint) (r *report.Report, err error) { func (ec *RepoController) loadReport(actor string, turn uint) (r *report.Report, err error) {
execErr := ec.executeSafe(func(t uint, c *Controller) (exErr error) { execErr := ec.executeSafe(func(t uint, c *Controller) (exErr error) {
id, exErr := c.RaceID(actor) id, exErr := c.RaceID(actor)
+6 -2
View File
@@ -37,7 +37,7 @@ func (c *Cache) InitReport(t uint) *mr.Report {
OtherScience: make([]mr.OtherScience, 0, 10), OtherScience: make([]mr.OtherScience, 0, 10),
LocalShipClass: make([]mr.ShipClass, 0, 20), LocalShipClass: make([]mr.ShipClass, 0, 20),
OtherShipClass: make([]mr.OthersShipClass, 0, 50), OtherShipClass: make([]mr.OthersShipClass, 0, 50),
Battle: make([]uuid.UUID, 0, 10), Battle: make([]mr.BattleSummary, 0, 10),
Bombing: make([]*mr.Bombing, 0, 10), Bombing: make([]*mr.Bombing, 0, 10),
IncomingGroup: make([]mr.IncomingGroup, 0, 10), IncomingGroup: make([]mr.IncomingGroup, 0, 10),
OnPlanetGroupCache: make(map[uint][]int), OnPlanetGroupCache: make(map[uint][]int),
@@ -342,7 +342,11 @@ func (c *Cache) ReportBattle(ri int, rep *mr.Report, br []*mr.BattleReport) {
} }
sliceIndexValidate(&rep.Battle, i) sliceIndexValidate(&rep.Battle, i)
rep.Battle[i] = br[bi].ID rep.Battle[i] = mr.BattleSummary{
ID: br[bi].ID,
Planet: br[bi].Planet,
Shots: uint(len(br[bi].Protocol)),
}
i++ i++
} }
} }
+65 -16
View File
@@ -13,6 +13,7 @@ package repo
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"slices"
"galaxy/model/order" "galaxy/model/order"
"galaxy/model/report" "galaxy/model/report"
@@ -117,9 +118,25 @@ func loadMeta(s Storage) (*game.GameMeta, error) {
return result, nil return result, nil
} }
func saveMeta(s Storage, t uint, gm *game.GameMeta) error { func loadTurnMeta(s Storage, turn uint) (*game.GameMeta, error) {
var result *game.GameMeta = new(game.GameMeta)
path := fmt.Sprintf("%s/%s", TurnDir(turn), metaPath)
exist, err := s.Exists(path)
if err != nil {
return nil, NewStorageError(err)
}
if !exist {
return result, nil
}
if err := s.ReadSafe(path, result); err != nil {
return nil, NewStorageError(err)
}
return result, nil
}
func saveMeta(s Storage, turn uint, gm *game.GameMeta) error {
// save turn's meta // save turn's meta
path := fmt.Sprintf("%s/%s", TurnDir(t), metaPath) path := fmt.Sprintf("%s/%s", TurnDir(turn), metaPath)
if err := s.Write(path, gm); err != nil { if err := s.Write(path, gm); err != nil {
return NewStorageError(err) return NewStorageError(err)
} }
@@ -131,27 +148,43 @@ func saveMeta(s Storage, t uint, gm *game.GameMeta) error {
return nil return nil
} }
func (r *repo) SaveBattle(t uint, b *report.BattleReport, m *game.BattleMeta) error { func (r *repo) LoadBattle(turn uint, id uuid.UUID) (*report.BattleReport, bool, error) {
meta, err := loadTurnMeta(r.s, turn)
if err != nil {
return nil, false, err
}
i := slices.IndexFunc(meta.Battles, func(m game.BattleMeta) bool { return m.BattleID == id })
if i < 0 {
return nil, false, nil
}
result, err := loadBattle(r.s, turn, meta.Battles[i].BattleID)
if err != nil {
return nil, false, err
}
return result, true, nil
}
func (r *repo) SaveBattle(turn uint, b *report.BattleReport, m *game.BattleMeta) error {
meta, err := loadMeta(r.s) meta, err := loadMeta(r.s)
if err != nil { if err != nil {
return err return err
} }
err = saveBattle(r.s, t, b) err = saveBattle(r.s, turn, b)
if err != nil { if err != nil {
return err return err
} }
meta.Battles = append(meta.Battles, *m) meta.Battles = append(meta.Battles, *m)
return saveMeta(r.s, t, meta) return saveMeta(r.s, turn, meta)
} }
func saveBattle(s Storage, t uint, b *report.BattleReport) error { func saveBattle(s Storage, turn uint, b *report.BattleReport) error {
path := fmt.Sprintf("%s/battle/%s.json", TurnDir(t), b.ID.String()) path := fmt.Sprintf("%s/battle/%s.json", TurnDir(turn), b.ID.String())
exist, err := s.Exists(path) exist, err := s.Exists(path)
if err != nil { if err != nil {
return NewStorageError(err) return NewStorageError(err)
} }
if exist { if exist {
return NewStateError(fmt.Sprintf("battle %v for turn %d already has been saved", b.ID, t)) return NewStateError(fmt.Sprintf("battle %v for turn %d already has been saved", b.ID, turn))
} }
if err := s.Write(path, b); err != nil { if err := s.Write(path, b); err != nil {
return NewStorageError(err) return NewStorageError(err)
@@ -159,7 +192,23 @@ func saveBattle(s Storage, t uint, b *report.BattleReport) error {
return nil return nil
} }
func (r *repo) SaveBombings(t uint, b []*game.Bombing) error { func loadBattle(s Storage, turn uint, id uuid.UUID) (*report.BattleReport, error) {
path := fmt.Sprintf("%s/battle/%s.json", TurnDir(turn), id.String())
exist, err := s.Exists(path)
if err != nil {
return nil, NewStorageError(err)
}
if !exist {
return nil, NewStateError(fmt.Sprintf("battle %v for turn %d never was saved", id, turn))
}
result := new(report.BattleReport)
if err := s.ReadSafe(path, result); err != nil {
return nil, NewStorageError(err)
}
return result, nil
}
func (r *repo) SaveBombings(turn uint, b []*game.Bombing) error {
meta, err := loadMeta(r.s) meta, err := loadMeta(r.s)
if err != nil { if err != nil {
return err return err
@@ -167,11 +216,11 @@ func (r *repo) SaveBombings(t uint, b []*game.Bombing) error {
for i := range b { for i := range b {
meta.Bombings = append(meta.Bombings, *b[i]) meta.Bombings = append(meta.Bombings, *b[i])
} }
return saveMeta(r.s, t, meta) return saveMeta(r.s, turn, meta)
} }
func (r *repo) SaveReport(t uint, rep *report.Report) error { func (r *repo) SaveReport(turn uint, rep *report.Report) error {
return saveReport(r.s, t, rep) return saveReport(r.s, turn, rep)
} }
func saveReport(s Storage, t uint, v *report.Report) error { func saveReport(s Storage, t uint, v *report.Report) error {
@@ -182,12 +231,12 @@ func saveReport(s Storage, t uint, v *report.Report) error {
return nil return nil
} }
func (r *repo) LoadReport(t uint, id uuid.UUID) (*report.Report, error) { func (r *repo) LoadReport(turn uint, id uuid.UUID) (*report.Report, error) {
return loadReport(r.s, t, id) return loadReport(r.s, turn, id)
} }
func loadReport(s Storage, t uint, id uuid.UUID) (*report.Report, error) { func loadReport(s Storage, turn uint, id uuid.UUID) (*report.Report, error) {
path := ReportDir(t, id) path := ReportDir(turn, id)
result := new(report.Report) result := new(report.Report)
exist, err := s.Exists(path) exist, err := s.Exists(path)
if err != nil { if err != nil {
+152
View File
@@ -0,0 +1,152 @@
package router_test
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"galaxy/model/report"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetBattleValidation(t *testing.T) {
validUUID := uuid.New().String()
for _, tc := range []struct {
description string
turn string
battleID string
expectStatus int
}{
{"Negative turn", "-1", validUUID, http.StatusBadRequest},
{"Non-numeric turn", "abc", validUUID, http.StatusBadRequest},
{"Invalid uuid", "0", invalidId, http.StatusBadRequest},
} {
t.Run(tc.description, func(t *testing.T) {
e := &dummyExecutor{}
r := setupRouterExecutor(e)
w := httptest.NewRecorder()
path := fmt.Sprintf("/api/v1/battle/%s/%s", tc.turn, tc.battleID)
req, _ := http.NewRequest(http.MethodGet, path, nil)
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
assert.Equal(t, uuid.Nil, e.FetchBattleID, "FetchBattle must not be called on validation error")
})
}
}
func TestGetBattleFound(t *testing.T) {
id := uuid.New()
raceA := uuid.New()
raceB := uuid.New()
stored := &report.BattleReport{
ID: id,
Planet: 42,
PlanetName: "X-Prime",
Races: map[int]uuid.UUID{
0: raceA,
1: raceB,
},
Ships: map[int]report.BattleReportGroup{
10: {
Race: "Alpha",
ClassName: "Drone",
Tech: map[string]report.Float{"WEAPONS": report.F(1)},
Number: 5,
NumberLeft: 3,
LoadType: "EMP",
LoadQuantity: report.F(0),
InBattle: true,
},
20: {
Race: "Beta",
ClassName: "Spy",
Tech: map[string]report.Float{"SHIELDS": report.F(2)},
Number: 4,
NumberLeft: 0,
LoadType: "EMP",
LoadQuantity: report.F(0),
InBattle: true,
},
},
Protocol: []report.BattleActionReport{
{Attacker: 0, AttackerShipClass: 10, Defender: 1, DefenderShipClass: 20, Destroyed: true},
},
}
e := &dummyExecutor{
FetchBattleResult: stored,
FetchBattleOK: true,
}
r := setupRouterExecutor(e)
w := httptest.NewRecorder()
path := fmt.Sprintf("/api/v1/battle/%d/%s", 7, id.String())
req, _ := http.NewRequest(http.MethodGet, path, nil)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body)
assert.Equal(t, uint(7), e.FetchBattleTurn)
assert.Equal(t, id, e.FetchBattleID)
var got report.BattleReport
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
assert.Equal(t, stored.ID, got.ID)
assert.Equal(t, stored.Planet, got.Planet)
assert.Equal(t, stored.PlanetName, got.PlanetName)
assert.Equal(t, stored.Races, got.Races)
require.Len(t, got.Ships, len(stored.Ships))
assert.Equal(t, stored.Ships[10].ClassName, got.Ships[10].ClassName)
assert.Equal(t, stored.Ships[20].NumberLeft, got.Ships[20].NumberLeft)
require.Len(t, got.Protocol, 1)
assert.Equal(t, stored.Protocol[0], got.Protocol[0])
}
func TestGetBattleTurnZero(t *testing.T) {
id := uuid.New()
e := &dummyExecutor{
FetchBattleResult: &report.BattleReport{ID: id},
FetchBattleOK: true,
}
r := setupRouterExecutor(e)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/battle/0/%s", id.String()), nil)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body)
assert.Equal(t, uint(0), e.FetchBattleTurn)
assert.Equal(t, id, e.FetchBattleID)
}
func TestGetBattleNotFound(t *testing.T) {
id := uuid.New()
e := &dummyExecutor{FetchBattleOK: false}
r := setupRouterExecutor(e)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/battle/3/%s", id.String()), nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code, w.Body)
assert.Equal(t, uint(3), e.FetchBattleTurn)
assert.Equal(t, id, e.FetchBattleID)
}
func TestGetBattleEngineError(t *testing.T) {
e := &dummyExecutor{FetchBattleErr: errors.New("engine boom")}
r := setupRouterExecutor(e)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/battle/3/%s", uuid.NewString()), nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code, w.Body)
}
+37
View File
@@ -0,0 +1,37 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func BattleHandler(c *gin.Context, executor CommandExecutor) {
turn := c.Param("turn")
t, err := strconv.Atoi(turn)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if t < 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "turn number can't be negative"})
return
}
id := c.Param("uuid")
battleID, err := uuid.Parse(id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
r, exists, err := executor.FetchBattle(uint(t), battleID)
if errorResponse(c, err) {
return
}
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "unknown battle"})
return
}
c.JSON(http.StatusOK, r)
}
+6
View File
@@ -17,6 +17,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/google/uuid"
) )
type CommandExecutor interface { type CommandExecutor interface {
@@ -29,6 +30,7 @@ type CommandExecutor interface {
Execute(cmd ...Command) error Execute(cmd ...Command) error
ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error)
FetchOrder(actor string, turn uint) (*order.UserGamesOrder, bool, error) FetchOrder(actor string, turn uint) (*order.UserGamesOrder, bool, error)
FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, bool, error)
} }
type Command func(controller.Ctrl) error type Command func(controller.Ctrl) error
@@ -86,6 +88,10 @@ func (e *executor) FetchOrder(actor string, turn uint) (*order.UserGamesOrder, b
return controller.FetchOrder(e.cfg, actor, turn) return controller.FetchOrder(e.cfg, actor, turn)
} }
func (e *executor) FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, bool, error) {
return controller.FetchBattle(e.cfg, turn, ID)
}
func (e *executor) GenerateGame(races []string) (rest.StateResponse, error) { func (e *executor) GenerateGame(races []string) (rest.StateResponse, error) {
s, err := controller.GenerateGame(e.cfg, races) s, err := controller.GenerateGame(e.cfg, races)
if err != nil { if err != nil {
+1
View File
@@ -76,6 +76,7 @@ func setupRouter(executor handler.CommandExecutor) *gin.Engine {
groupV1.GET("/report", func(ctx *gin.Context) { handler.ReportHandler(ctx, executor) }) groupV1.GET("/report", func(ctx *gin.Context) { handler.ReportHandler(ctx, executor) })
groupV1.PUT("/order", func(ctx *gin.Context) { handler.PutOrderHandler(ctx, executor) }) groupV1.PUT("/order", func(ctx *gin.Context) { handler.PutOrderHandler(ctx, executor) })
groupV1.GET("/order", func(ctx *gin.Context) { handler.GetOrderHandler(ctx, executor) }) groupV1.GET("/order", func(ctx *gin.Context) { handler.GetOrderHandler(ctx, executor) })
groupV1.GET("/battle/:turn/:uuid", func(ctx *gin.Context) { handler.BattleHandler(ctx, executor) })
// /command is reserved for future use; any API request for orders should use /order // /command is reserved for future use; any API request for orders should use /order
groupV1.PUT("/command", LimitMiddleware(1), func(ctx *gin.Context) { handler.CommandHandler(ctx, executor) }) groupV1.PUT("/command", LimitMiddleware(1), func(ctx *gin.Context) { handler.CommandHandler(ctx, executor) })
@@ -45,6 +45,13 @@ type dummyExecutor struct {
FetchOrderResult *order.UserGamesOrder FetchOrderResult *order.UserGamesOrder
FetchOrderOK bool FetchOrderOK bool
FetchOrderErr error FetchOrderErr error
// FetchBattle controls and observes calls to FetchBattle.
FetchBattleTurn uint
FetchBattleID uuid.UUID
FetchBattleResult *report.BattleReport
FetchBattleOK bool
FetchBattleErr error
} }
func (e *dummyExecutor) ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) { func (e *dummyExecutor) ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) {
@@ -68,6 +75,12 @@ func (e *dummyExecutor) FetchOrder(actor string, turn uint) (*order.UserGamesOrd
return e.FetchOrderResult, e.FetchOrderOK, e.FetchOrderErr return e.FetchOrderResult, e.FetchOrderOK, e.FetchOrderErr
} }
func (e *dummyExecutor) FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, bool, error) {
e.FetchBattleTurn = turn
e.FetchBattleID = ID
return e.FetchBattleResult, e.FetchBattleOK, e.FetchBattleErr
}
func (e *dummyExecutor) Execute(command ...handler.Command) error { func (e *dummyExecutor) Execute(command ...handler.Command) error {
e.CommandsExecuted = len(command) e.CommandsExecuted = len(command)
return nil return nil
+187 -3
View File
@@ -207,6 +207,33 @@ paths:
$ref: "#/components/responses/ValidationError" $ref: "#/components/responses/ValidationError"
"500": "500":
$ref: "#/components/responses/InternalError" $ref: "#/components/responses/InternalError"
/api/v1/battle/{turn}/{uuid}:
get:
tags:
- PlayerActions
operationId: getBattle
summary: Fetch a single battle report
description: |
Returns the full `BattleReport` for the supplied `turn` and battle
identifier. The `turn` segment must be a non-negative integer; the
`uuid` segment must be a valid RFC 4122 UUID. Responds with
`404 Not Found` when no battle is stored for the supplied pair.
parameters:
- $ref: "#/components/parameters/BattleTurnParam"
- $ref: "#/components/parameters/BattleIDParam"
responses:
"200":
description: Battle report for the supplied turn and identifier.
content:
application/json:
schema:
$ref: "#/components/schemas/BattleReport"
"400":
$ref: "#/components/responses/ValidationError"
"404":
description: No battle exists for the supplied turn and identifier.
"500":
$ref: "#/components/responses/InternalError"
/api/v1/admin/turn: /api/v1/admin/turn:
put: put:
tags: tags:
@@ -265,6 +292,22 @@ components:
type: integer type: integer
minimum: 0 minimum: 0
default: 0 default: 0
BattleTurnParam:
name: turn
in: path
required: true
description: Turn number the battle was generated on.
schema:
type: integer
minimum: 0
BattleIDParam:
name: uuid
in: path
required: true
description: Battle identifier (RFC 4122 UUID).
schema:
type: string
format: uuid
schemas: schemas:
HealthzResponse: HealthzResponse:
type: object type: object
@@ -541,10 +584,9 @@ components:
$ref: "#/components/schemas/OtherShipClass" $ref: "#/components/schemas/OtherShipClass"
battle: battle:
type: array type: array
description: UUIDs of battle reports relevant to this turn. description: Battle summaries relevant to this turn.
items: items:
type: string $ref: "#/components/schemas/BattleSummary"
format: uuid
bombing: bombing:
type: array type: array
description: Bombing events that occurred during this turn. description: Bombing events that occurred during this turn.
@@ -788,6 +830,148 @@ components:
wiped: wiped:
type: boolean type: boolean
description: True when all population was eliminated by the bombing. description: True when all population was eliminated by the bombing.
BattleSummary:
type: object
description: |
Identifies one battle relevant to the report recipient. Used by
clients to render a battle marker on the map without fetching
the full BattleReport. `planet` locates the marker; `shots`
scales the marker stroke with the battle length.
required:
- id
- planet
- shots
properties:
id:
type: string
format: uuid
description: Battle identifier; fetch the full report via `/api/v1/battle/{turn}/{uuid}`.
planet:
type: integer
minimum: 0
description: Planet number the battle took place on.
shots:
type: integer
minimum: 0
description: Number of shots exchanged during the battle.
BattleReport:
type: object
description: |
Full battle report. `races` and `ships` are JSON objects whose
keys are stringified integers used to cross-reference entries
from `protocol`: a `BattleActionReport` carries integer indices
into both maps. The serialised key is a string because JSON
object keys are always strings.
required:
- id
- planet
- planetName
- races
- ships
- protocol
properties:
id:
type: string
format: uuid
description: Battle identifier.
planet:
type: integer
minimum: 0
description: Planet number the battle took place on.
planetName:
type: string
description: Planet name at battle start.
races:
type: object
description: |
Participating races keyed by the integer index used in
`protocol.a` / `protocol.d`. Values are race identifiers.
additionalProperties:
type: string
format: uuid
ships:
type: object
description: |
Participating ship groups keyed by the integer index used
in `protocol.sa` / `protocol.sd`.
additionalProperties:
$ref: "#/components/schemas/BattleReportGroup"
protocol:
type: array
description: Ordered list of shots exchanged during the battle.
items:
$ref: "#/components/schemas/BattleActionReport"
BattleReportGroup:
type: object
description: One ship group participating in the battle.
required:
- race
- className
- tech
- num
- numLeft
- loadType
- loadQuantity
- inBattle
properties:
race:
type: string
description: Race name of the group owner.
className:
type: string
description: Ship class name; resolvable through `LocalShipClass` or `OtherShipClass`.
tech:
type: object
description: Technology levels keyed by tech type name.
additionalProperties:
type: number
num:
type: integer
minimum: 0
description: Initial number of ships in this group.
numLeft:
type: integer
minimum: 0
description: Number of ships remaining at the end of the battle.
loadType:
type: string
description: Type of cargo loaded.
loadQuantity:
type: number
description: Quantity of cargo loaded.
inBattle:
type: boolean
description: |
True when the group actually fights. False groups observe
the battle in peace state and never fire or take damage.
BattleActionReport:
type: object
description: |
One shot in the battle. Attacker and defender indices reference
`BattleReport.races`; ship-class indices reference
`BattleReport.ships`.
required:
- a
- sa
- d
- sd
- x
properties:
a:
type: integer
description: Index into `BattleReport.races` for the attacker.
sa:
type: integer
description: Index into `BattleReport.ships` for the attacker's group.
d:
type: integer
description: Index into `BattleReport.races` for the defender.
sd:
type: integer
description: Index into `BattleReport.ships` for the defender's group.
x:
type: boolean
description: True when the defender ship was destroyed by this shot.
IncomingGroup: IncomingGroup:
type: object type: object
description: An identified ship group inbound toward a planet of this race. description: An identified ship group inbound toward a planet of this race.
+72
View File
@@ -79,6 +79,13 @@ func TestGameOpenAPISpecFreezesResponseSchemas(t *testing.T) {
status: http.StatusOK, status: http.StatusOK,
wantRef: "#/components/schemas/HealthzResponse", wantRef: "#/components/schemas/HealthzResponse",
}, },
{
name: "get battle",
path: "/api/v1/battle/{turn}/{uuid}",
method: http.MethodGet,
status: http.StatusOK,
wantRef: "#/components/schemas/BattleReport",
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -271,6 +278,71 @@ func TestGameOpenAPISpecFreezesCommandRequest(t *testing.T) {
require.Equal(t, uint64(1), cmdSchema.Value.MinItems, "CommandRequest.cmd minItems must be 1") require.Equal(t, uint64(1), cmdSchema.Value.MinItems, "CommandRequest.cmd minItems must be 1")
} }
func TestGameOpenAPISpecFreezesGetBattleOperation(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
operation := getOpenAPIOperation(t, doc, "/api/v1/battle/{turn}/{uuid}", http.MethodGet)
require.Equal(t, "getBattle", operation.OperationID, "GET /api/v1/battle/{turn}/{uuid} operation id")
paramRefs := make(map[string]bool)
for _, p := range operation.Parameters {
require.NotNil(t, p.Value, "parameter must have value")
paramRefs[p.Ref] = true
}
require.True(t, paramRefs["#/components/parameters/BattleTurnParam"], "GET /api/v1/battle/{turn}/{uuid} must reference BattleTurnParam")
require.True(t, paramRefs["#/components/parameters/BattleIDParam"], "GET /api/v1/battle/{turn}/{uuid} must reference BattleIDParam")
require.NotNil(t, operation.Responses, "operation must declare responses")
notFound := operation.Responses.Status(http.StatusNotFound)
require.NotNil(t, notFound, "operation must declare 404 response")
require.NotNil(t, notFound.Value, "404 response must have a value")
}
func TestGameOpenAPISpecFreezesBattleReport(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
reportSchema := componentSchemaRef(t, doc, "BattleReport")
assertRequiredFields(t, reportSchema, "id", "planet", "planetName", "races", "ships", "protocol")
groupSchema := componentSchemaRef(t, doc, "BattleReportGroup")
assertRequiredFields(t, groupSchema, "race", "className", "tech", "num", "numLeft", "loadType", "loadQuantity", "inBattle")
actionSchema := componentSchemaRef(t, doc, "BattleActionReport")
assertRequiredFields(t, actionSchema, "a", "sa", "d", "sd", "x")
protocolSchema := reportSchema.Value.Properties["protocol"]
require.NotNil(t, protocolSchema, "BattleReport.protocol schema must exist")
require.True(t, protocolSchema.Value.Type.Is("array"), "BattleReport.protocol must be array")
require.NotNil(t, protocolSchema.Value.Items, "BattleReport.protocol items must be defined")
assertSchemaRef(t, protocolSchema.Value.Items, "#/components/schemas/BattleActionReport", "BattleReport.protocol items schema")
shipsSchema := reportSchema.Value.Properties["ships"]
require.NotNil(t, shipsSchema, "BattleReport.ships schema must exist")
require.True(t, shipsSchema.Value.Type.Is("object"), "BattleReport.ships must be object")
require.NotNil(t, shipsSchema.Value.AdditionalProperties.Schema, "BattleReport.ships additionalProperties must be a schema")
assertSchemaRef(t, shipsSchema.Value.AdditionalProperties.Schema, "#/components/schemas/BattleReportGroup", "BattleReport.ships additionalProperties schema")
}
func TestGameOpenAPISpecFreezesBattleSummary(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
summary := componentSchemaRef(t, doc, "BattleSummary")
assertRequiredFields(t, summary, "id", "planet", "shots")
report := componentSchemaRef(t, doc, "Report")
battle := report.Value.Properties["battle"]
require.NotNil(t, battle, "Report.battle schema must exist")
require.True(t, battle.Value.Type.Is("array"), "Report.battle must be array")
require.NotNil(t, battle.Value.Items, "Report.battle items must be defined")
assertSchemaRef(t, battle.Value.Items, "#/components/schemas/BattleSummary", "Report.battle items schema")
}
func TestGameOpenAPISpecHealthzStatusEnum(t *testing.T) { func TestGameOpenAPISpecHealthzStatusEnum(t *testing.T) {
t.Parallel() t.Parallel()
+38 -6
View File
@@ -6,31 +6,63 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
type BattleReport struct { // BattleSummary identifies one battle relevant to the report recipient
// and carries the data needed to render a battle marker on the map
// without fetching the full BattleReport. Planet locates the marker;
// Shots scales the marker stroke with the battle length.
type BattleSummary struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
Planet uint `json:"planet"` Planet uint `json:"planet"`
Shots uint `json:"shots"`
}
type BattleReport struct {
// Battle unique ID
ID uuid.UUID `json:"id"`
// Planet number
Planet uint `json:"planet"`
// Planet name at battle start
PlanetName string `json:"planetName"` PlanetName string `json:"planetName"`
// Races participating map: <key:RaceID>
Races map[int]uuid.UUID `json:"races"` Races map[int]uuid.UUID `json:"races"`
// Ships Groups participating map: <key:BattleReportGroup>
Ships map[int]BattleReportGroup `json:"ships"` Ships map[int]BattleReportGroup `json:"ships"`
// Battle's firing protocol
Protocol []BattleActionReport `json:"protocol"` Protocol []BattleActionReport `json:"protocol"`
} }
type BattleReportGroup struct { type BattleReportGroup struct {
InBattle bool `json:"inBattle"` // Name of the race
Number uint `json:"num"`
NumberLeft uint `json:"numLeft"`
LoadQuantity Float `json:"loadQuantity"`
Tech map[string]Float `json:"tech"`
Race string `json:"race"` Race string `json:"race"`
// Name of the Ship Class.
// By design, ship's info MUST be present in Game's Repors in 'LocalShipClass' or 'OtherShipClass'
ClassName string `json:"className"` ClassName string `json:"className"`
// Ship Group's technologies mapping <tech:level>
Tech map[string]Float `json:"tech"`
// Initial number of ships in this group
Number uint `json:"num"`
// Number of ships left after battle
NumberLeft uint `json:"numLeft"`
// Type of cargo loaded
LoadType string `json:"loadType"` LoadType string `json:"loadType"`
// Quantity of cargo loaded
LoadQuantity Float `json:"loadQuantity"`
// A Race with its ships can be in Peace state with all participants,
// so no shots will be fired and no damage taken, participating only as viewer
// when InBattle=false
InBattle bool `json:"inBattle"`
} }
type BattleActionReport struct { type BattleActionReport struct {
// `key` from BattleReport.Races map
Attacker int `json:"a"` Attacker int `json:"a"`
// `key` from BattleReport.Ships map
AttackerShipClass int `json:"sa"` AttackerShipClass int `json:"sa"`
// `key` from BattleReport.Races map
Defender int `json:"d"` Defender int `json:"d"`
// `key` from BattleReport.Ships map
DefenderShipClass int `json:"sd"` DefenderShipClass int `json:"sd"`
// Was ship destroyed after attack or survived under shields
Destroyed bool `json:"x"` Destroyed bool `json:"x"`
} }
+1 -1
View File
@@ -33,7 +33,7 @@ type Report struct {
OtherScience []OtherScience `json:"otherScience,omitempty"` OtherScience []OtherScience `json:"otherScience,omitempty"`
LocalShipClass []ShipClass `json:"localShipClass,omitempty"` LocalShipClass []ShipClass `json:"localShipClass,omitempty"`
OtherShipClass []OthersShipClass `json:"otherShipClass,omitempty"` OtherShipClass []OthersShipClass `json:"otherShipClass,omitempty"`
Battle []uuid.UUID `json:"battle,omitempty"` Battle []BattleSummary `json:"battle,omitempty"`
Bombing []*Bombing `json:"bombing,omitempty"` Bombing []*Bombing `json:"bombing,omitempty"`
IncomingGroup []IncomingGroup `json:"incomingGroup,omitempty"` IncomingGroup []IncomingGroup `json:"incomingGroup,omitempty"`
LocalPlanet []LocalPlanet `json:"localPlanet,omitempty"` LocalPlanet []LocalPlanet `json:"localPlanet,omitempty"`
+12 -1
View File
@@ -196,6 +196,17 @@ table LocalFleet {
state:string; state:string;
} }
// BattleSummary identifies one battle the report recipient
// participated in or could see on a planet. `planet` lets the map
// place a battle marker without fetching the full BattleReport;
// `shots` lets the marker scale its stroke with the protocol length
// (1 shot → thinnest cross, 100+ shots → maximum cross thickness).
table BattleSummary {
id:common.UUID (required);
planet:uint64;
shots:uint64;
}
table Report { table Report {
version:uint64; version:uint64;
turn:uint64; turn:uint64;
@@ -210,7 +221,7 @@ table Report {
other_science:[OtherScience]; other_science:[OtherScience];
local_ship_class:[ShipClass]; local_ship_class:[ShipClass];
other_ship_class:[OthersShipClass]; other_ship_class:[OthersShipClass];
battle:[common.UUID]; battle:[BattleSummary];
bombing:[Bombing]; bombing:[Bombing];
incoming_group:[IncomingGroup]; incoming_group:[IncomingGroup];
local_planet:[LocalPlanet]; local_planet:[LocalPlanet];
+97
View File
@@ -0,0 +1,97 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package report
import (
flatbuffers "github.com/google/flatbuffers/go"
common "galaxy/schema/fbs/common"
)
type BattleSummary struct {
_tab flatbuffers.Table
}
func GetRootAsBattleSummary(buf []byte, offset flatbuffers.UOffsetT) *BattleSummary {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &BattleSummary{}
x.Init(buf, n+offset)
return x
}
func FinishBattleSummaryBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsBattleSummary(buf []byte, offset flatbuffers.UOffsetT) *BattleSummary {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &BattleSummary{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedBattleSummaryBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *BattleSummary) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *BattleSummary) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *BattleSummary) Id(obj *common.UUID) *common.UUID {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
x := o + rcv._tab.Pos
if obj == nil {
obj = new(common.UUID)
}
obj.Init(rcv._tab.Bytes, x)
return obj
}
return nil
}
func (rcv *BattleSummary) Planet() uint64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetUint64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *BattleSummary) MutatePlanet(n uint64) bool {
return rcv._tab.MutateUint64Slot(6, n)
}
func (rcv *BattleSummary) Shots() uint64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.GetUint64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *BattleSummary) MutateShots(n uint64) bool {
return rcv._tab.MutateUint64Slot(8, n)
}
func BattleSummaryStart(builder *flatbuffers.Builder) {
builder.StartObject(3)
}
func BattleSummaryAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
builder.PrependStructSlot(0, flatbuffers.UOffsetT(id), 0)
}
func BattleSummaryAddPlanet(builder *flatbuffers.Builder, planet uint64) {
builder.PrependUint64Slot(1, planet, 0)
}
func BattleSummaryAddShots(builder *flatbuffers.Builder, shots uint64) {
builder.PrependUint64Slot(2, shots, 0)
}
func BattleSummaryEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+4 -5
View File
@@ -4,8 +4,6 @@ package report
import ( import (
flatbuffers "github.com/google/flatbuffers/go" flatbuffers "github.com/google/flatbuffers/go"
common "galaxy/schema/fbs/common"
) )
type Report struct { type Report struct {
@@ -231,11 +229,12 @@ func (rcv *Report) OtherShipClassLength() int {
return 0 return 0
} }
func (rcv *Report) Battle(obj *common.UUID, j int) bool { func (rcv *Report) Battle(obj *BattleSummary, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(30)) o := flatbuffers.UOffsetT(rcv._tab.Offset(30))
if o != 0 { if o != 0 {
x := rcv._tab.Vector(o) x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 16 x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x) obj.Init(rcv._tab.Bytes, x)
return true return true
} }
@@ -551,7 +550,7 @@ func ReportAddBattle(builder *flatbuffers.Builder, battle flatbuffers.UOffsetT)
builder.PrependUOffsetTSlot(13, flatbuffers.UOffsetT(battle), 0) builder.PrependUOffsetTSlot(13, flatbuffers.UOffsetT(battle), 0)
} }
func ReportStartBattleVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { func ReportStartBattleVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(16, numElems, 8) return builder.StartVector(4, numElems, 4)
} }
func ReportAddBombing(builder *flatbuffers.Builder, bombing flatbuffers.UOffsetT) { func ReportAddBombing(builder *flatbuffers.Builder, bombing flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(14, flatbuffers.UOffsetT(bombing), 0) builder.PrependUOffsetTSlot(14, flatbuffers.UOffsetT(bombing), 0)
+36 -12
View File
@@ -10,7 +10,6 @@ import (
fbs "galaxy/schema/fbs/report" fbs "galaxy/schema/fbs/report"
flatbuffers "github.com/google/flatbuffers/go" flatbuffers "github.com/google/flatbuffers/go"
"github.com/google/uuid"
) )
// ReportToPayload converts model.Report from the internal representation to // ReportToPayload converts model.Report from the internal representation to
@@ -120,7 +119,7 @@ func ReportToPayload(report *model.Report) ([]byte, error) {
otherScienceVector := encodeReportOffsetVector(builder, len(otherScienceOffsets), fbs.ReportStartOtherScienceVector, otherScienceOffsets) otherScienceVector := encodeReportOffsetVector(builder, len(otherScienceOffsets), fbs.ReportStartOtherScienceVector, otherScienceOffsets)
localShipClassVector := encodeReportOffsetVector(builder, len(localShipClassOffsets), fbs.ReportStartLocalShipClassVector, localShipClassOffsets) localShipClassVector := encodeReportOffsetVector(builder, len(localShipClassOffsets), fbs.ReportStartLocalShipClassVector, localShipClassOffsets)
otherShipClassVector := encodeReportOffsetVector(builder, len(otherShipClassOffsets), fbs.ReportStartOtherShipClassVector, otherShipClassOffsets) otherShipClassVector := encodeReportOffsetVector(builder, len(otherShipClassOffsets), fbs.ReportStartOtherShipClassVector, otherShipClassOffsets)
battleVector := encodeReportUUIDVector(builder, report.Battle) battleVector := encodeReportBattleSummaries(builder, report.Battle)
bombingVector := encodeReportOffsetVector(builder, len(bombingOffsets), fbs.ReportStartBombingVector, bombingOffsets) bombingVector := encodeReportOffsetVector(builder, len(bombingOffsets), fbs.ReportStartBombingVector, bombingOffsets)
incomingGroupVector := encodeReportOffsetVector(builder, len(incomingGroupOffsets), fbs.ReportStartIncomingGroupVector, incomingGroupOffsets) incomingGroupVector := encodeReportOffsetVector(builder, len(incomingGroupOffsets), fbs.ReportStartIncomingGroupVector, incomingGroupOffsets)
localPlanetVector := encodeReportOffsetVector(builder, len(localPlanetOffsets), fbs.ReportStartLocalPlanetVector, localPlanetOffsets) localPlanetVector := encodeReportOffsetVector(builder, len(localPlanetOffsets), fbs.ReportStartLocalPlanetVector, localPlanetOffsets)
@@ -734,13 +733,29 @@ func decodeReportBattleVector(flatReport *fbs.Report, result *model.Report) erro
return nil return nil
} }
result.Battle = make([]uuid.UUID, length) result.Battle = make([]model.BattleSummary, length)
item := new(commonfbs.UUID) item := new(fbs.BattleSummary)
idHolder := new(commonfbs.UUID)
for i := 0; i < length; i++ { for i := 0; i < length; i++ {
if !flatReport.Battle(item, i) { if !flatReport.Battle(item, i) {
return fmt.Errorf("decode report battle %d: battle is missing", i)
}
if item.Id(idHolder) == nil {
return fmt.Errorf("decode report battle %d: battle id is missing", i) return fmt.Errorf("decode report battle %d: battle id is missing", i)
} }
result.Battle[i] = uuidFromHiLo(item.Hi(), item.Lo()) planet, err := uint64ToUint(item.Planet(), "planet")
if err != nil {
return fmt.Errorf("decode report battle %d: %w", i, err)
}
shots, err := uint64ToUint(item.Shots(), "shots")
if err != nil {
return fmt.Errorf("decode report battle %d: %w", i, err)
}
result.Battle[i] = model.BattleSummary{
ID: uuidFromHiLo(idHolder.Hi(), idHolder.Lo()),
Planet: planet,
Shots: shots,
}
} }
return nil return nil
@@ -1299,17 +1314,26 @@ func encodeReportOffsetVector(
return builder.EndVector(length) return builder.EndVector(length)
} }
func encodeReportUUIDVector(builder *flatbuffers.Builder, ids []uuid.UUID) flatbuffers.UOffsetT { func encodeReportBattleSummaries(builder *flatbuffers.Builder, summaries []model.BattleSummary) flatbuffers.UOffsetT {
if len(ids) == 0 { if len(summaries) == 0 {
return 0 return 0
} }
fbs.ReportStartBattleVector(builder, len(ids)) offsets := make([]flatbuffers.UOffsetT, len(summaries))
for i := len(ids) - 1; i >= 0; i-- { for i := range summaries {
hi, lo := uuidToHiLo(ids[i]) hi, lo := uuidToHiLo(summaries[i].ID)
commonfbs.CreateUUID(builder, hi, lo) fbs.BattleSummaryStart(builder)
fbs.BattleSummaryAddId(builder, commonfbs.CreateUUID(builder, hi, lo))
fbs.BattleSummaryAddPlanet(builder, uint64(summaries[i].Planet))
fbs.BattleSummaryAddShots(builder, uint64(summaries[i].Shots))
offsets[i] = fbs.BattleSummaryEnd(builder)
} }
return builder.EndVector(len(ids))
fbs.ReportStartBattleVector(builder, len(offsets))
for i := len(offsets) - 1; i >= 0; i-- {
builder.PrependUOffsetT(offsets[i])
}
return builder.EndVector(len(offsets))
} }
func encodeReportRouteEntryVector(builder *flatbuffers.Builder, route map[uint]string) flatbuffers.UOffsetT { func encodeReportRouteEntryVector(builder *flatbuffers.Builder, route map[uint]string) flatbuffers.UOffsetT {
+11 -3
View File
@@ -255,9 +255,17 @@ func sampleReport() *model.Report {
OtherShipClass: []model.OthersShipClass{ OtherShipClass: []model.OthersShipClass{
{Race: "Martians", ShipClass: model.ShipClass{Name: "destroyer", Drive: model.Float(1.75), Armament: 6, Weapons: model.Float(2.25), Shields: model.Float(2.75), Cargo: model.Float(3.25), Mass: model.Float(10.5)}}, {Race: "Martians", ShipClass: model.ShipClass{Name: "destroyer", Drive: model.Float(1.75), Armament: 6, Weapons: model.Float(2.25), Shields: model.Float(2.75), Cargo: model.Float(3.25), Mass: model.Float(10.5)}},
}, },
Battle: []uuid.UUID{ Battle: []model.BattleSummary{
uuid.MustParse("11111111-1111-1111-1111-111111111111"), {
uuid.MustParse("22222222-2222-2222-2222-222222222222"), ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
Planet: 4,
Shots: 17,
},
{
ID: uuid.MustParse("22222222-2222-2222-2222-222222222222"),
Planet: 11,
Shots: 3,
},
}, },
Bombing: []*model.Bombing{ Bombing: []*model.Bombing{
{ {
+38 -12
View File
@@ -1,8 +1,25 @@
# legacy-report-to-json # legacy-report-to-json
Converts legacy text-format Galaxy turn reports (the *dg* and *gplus* Converts legacy text-format Galaxy turn reports (the *dg* and *gplus*
engines that lived under `tools/local-dev/reports/`) into the JSON engines that lived under `tools/local-dev/reports/`) into a JSON
shape of [`pkg/model/report.Report`](../../../pkg/model/report). envelope around [`pkg/model/report.Report`](../../../pkg/model/report)
plus full `BattleReport`s (Phase 27).
## Output envelope
```jsonc
{
"version": 1,
"report": { /* report.Report */ },
"battles": { "<uuid>": { /* report.BattleReport */ }, ... }
}
```
`version: 1` lets the UI distinguish a current-format envelope from a
bare `Report` JSON. The synthetic-report loader accepts both — pre-
envelope synthetic JSON files still load, just without battle
fixtures. `battles` is omitted when the legacy file has no combat
events.
The output is consumed by the **DEV-only synthetic-report loader** on The output is consumed by the **DEV-only synthetic-report loader** on
the UI client's lobby (`import.meta.env.DEV`). With it, the map view, the UI client's lobby (`import.meta.env.DEV`). With it, the map view,
@@ -17,8 +34,8 @@ The tool is part of the synthetic-report parity rule documented in
```sh ```sh
# from the repo root, with the Go workspace active # from the repo root, with the Go workspace active
go run ./tools/local-dev/legacy-report/cmd/legacy-report-to-json \ go run ./tools/local-dev/legacy-report/cmd/legacy-report-to-json \
--in tools/local-dev/reports/dg/KNNTS039.REP \ --in tools/local-dev/reports/dg/KNNTS041.REP \
--out tools/local-dev/reports/dg/KNNTS039.json --out tools/local-dev/reports/dg/KNNTS041.json
``` ```
`--in` reads `-` as stdin; `--out` defaults to stdout when empty or `--in` reads `-` as stdin; `--out` defaults to stdout when empty or
@@ -68,6 +85,21 @@ already decodes from server responses
| `LocalGroup[]` | `Your Groups` (Phase 19) | | `LocalGroup[]` | `Your Groups` (Phase 19) |
| `LocalFleet[]` | `Your Fleets` (Phase 19) | | `LocalFleet[]` | `Your Fleets` (Phase 19) |
| `IncomingGroup[]` | `Incoming Groups` (Phase 19) | | `IncomingGroup[]` | `Incoming Groups` (Phase 19) |
| `Battle[]` (summary) | `Battle at (#N) Name` headers + `Battle Protocol` (Phase 27 follow-up) |
The envelope's `battles` map carries the full `BattleReport`-s parsed
out of the same blocks: every roster row turns into a
`BattleReportGroup` (`Number`/`Tech`/`LoadType`/`LoadQuantity`/
`NumberLeft`/`InBattle`), every `... fires on ... : Destroyed|Shields`
line turns into a `BattleActionReport`. UUIDs are synthesised
deterministically — `syntheticBattleID(idx)` for the battle
identifier (per-report 0-based index, SHA1 namespace
`be01a000-0000-0000-0000-000000000002`) and
`syntheticBattleRaceID(name)` for `BattleReport.Races` entries (SHA1
namespace `be01a000-0000-0000-0000-000000000003`). Re-running the
converter on the same input file yields byte-identical JSON, so
synthetic-mode UI URLs (`/games/synthetic-…/battle/<uuid>?turn=N`)
stay stable across regenerations.
Players whose name in the legacy file ends with `_RIP` are emitted with Players whose name in the legacy file ends with `_RIP` are emitted with
the suffix stripped and `Extinct: true`. the suffix stripped and `Extinct: true`.
@@ -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 text format at all. Each could become in-scope if a strong enough
reason arises (see "Adding a new field" below). 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 - `OtherGroup[]` — no top-level legacy section. Foreign groups appear
only inside battle rosters (see above), with stripped columns; the only inside battle rosters; the synthetic JSON emits
synthetic JSON emits `otherGroup: []`. `otherGroup: []`.
- `UnidentifiedGroup[]` — no legacy section at all; synthetic JSON - `UnidentifiedGroup[]` — no legacy section at all; synthetic JSON
emits `unidentifiedGroup: []`. emits `unidentifiedGroup: []`.
- Cargo routes — no dedicated section in the legacy text format; the - 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 // Command legacy-report-to-json converts a legacy text-format Galaxy
// turn report (the "dg" / "gplus" engines) into the JSON shape of // turn report (the "dg" / "gplus" engines) into a JSON envelope
// pkg/model/report.Report. The resulting file is what the UI client's // readable by the UI client's DEV-only synthetic-report loader:
// DEV-only synthetic-report loader on the lobby consumes. //
// {
// "version": 1,
// "report": <Report JSON>,
// "battles": { "<uuid>": <BattleReport JSON>, ... }
// }
//
// Carrying the per-turn report and the full BattleReports in one
// payload lets the synthetic loader register the battles up-front
// so the Battle Viewer can render any battle without a network
// fetch. The bare Report shape (no envelope) the lobby loader
// historically accepted remains backward-compatible on the UI side.
package main package main
import ( import (
@@ -12,8 +23,18 @@ import (
"os" "os"
legacyreport "galaxy/legacy-report" legacyreport "galaxy/legacy-report"
"galaxy/model/report"
) )
// envelope is the on-disk shape emitted by this CLI. `Version` lets
// the UI loader distinguish a v1 envelope from a bare Report; future
// versions can bump it without breaking older synthetic JSON files.
type envelope struct {
Version int `json:"version"`
Report report.Report `json:"report"`
Battles map[string]report.BattleReport `json:"battles,omitempty"`
}
func main() { func main() {
in := flag.String("in", "", "path to legacy .REP file (use - for stdin)") in := flag.String("in", "", "path to legacy .REP file (use - for stdin)")
out := flag.String("out", "", "path to write JSON to (use - or empty for stdout)") out := flag.String("out", "", "path to write JSON to (use - or empty for stdout)")
@@ -31,7 +52,7 @@ func main() {
} }
defer closeIn() defer closeIn()
rep, err := legacyreport.Parse(r) rep, battles, err := legacyreport.Parse(r)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "parse: %v\n", err) fmt.Fprintf(os.Stderr, "parse: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -44,9 +65,17 @@ func main() {
} }
defer closeOut() defer closeOut()
env := envelope{Version: 1, Report: rep}
if len(battles) > 0 {
env.Battles = make(map[string]report.BattleReport, len(battles))
for i := range battles {
env.Battles[battles[i].ID.String()] = battles[i]
}
}
enc := json.NewEncoder(w) enc := json.NewEncoder(w)
enc.SetIndent("", " ") enc.SetIndent("", " ")
if err := enc.Encode(rep); err != nil { if err := enc.Encode(env); err != nil {
fmt.Fprintf(os.Stderr, "encode: %v\n", err) fmt.Fprintf(os.Stderr, "encode: %v\n", err)
os.Exit(1) os.Exit(1)
} }
+361 -14
View File
@@ -26,22 +26,29 @@ import (
) )
// Parse reads a legacy text report and returns a [report.Report] // Parse reads a legacy text report and returns a [report.Report]
// carrying the in-scope subset of fields. The Width and Height of the // carrying the in-scope subset of fields, plus the per-battle
// returned report are both set to the legacy "Size" value (galaxies // [report.BattleReport] payloads parsed out of the "Battle at (#N)"
// are square in the legacy engines). // blocks. The Width and Height of the returned report are both set
func Parse(r io.Reader) (report.Report, error) { // to the legacy "Size" value (galaxies are square in the legacy
// engines). The battle slice is empty when the legacy file carries
// no combat events.
func Parse(r io.Reader) (report.Report, []report.BattleReport, error) {
p := newParser() p := newParser()
sc := bufio.NewScanner(r) sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 1024*1024), 4*1024*1024) sc.Buffer(make([]byte, 1024*1024), 4*1024*1024)
for sc.Scan() { for sc.Scan() {
if err := p.handle(sc.Text()); err != nil { if err := p.handle(sc.Text()); err != nil {
return report.Report{}, err return report.Report{}, nil, err
} }
} }
if err := sc.Err(); err != nil { if err := sc.Err(); err != nil {
return report.Report{}, fmt.Errorf("legacyreport: scan: %w", err) return report.Report{}, nil, fmt.Errorf("legacyreport: scan: %w", err)
} }
return p.finish() battles, err := p.finish()
if err != nil {
return report.Report{}, nil, err
}
return p.rep, battles, nil
} }
type section int type section int
@@ -63,6 +70,8 @@ const (
sectionOtherShipTypes sectionOtherShipTypes
sectionBombings sectionBombings
sectionShipsInProduction sectionShipsInProduction
sectionBattle
sectionBattleProtocol
) )
type parser struct { type parser struct {
@@ -85,6 +94,40 @@ type parser struct {
pendingFleets []pendingFleet pendingFleets []pendingFleet
pendingIncomings []pendingIncoming pendingIncomings []pendingIncoming
pendingShipProducts []pendingShipProduction pendingShipProducts []pendingShipProduction
// Battle accumulator. `battles` collects every parsed BattleReport;
// `pendingBattle` carries the in-flight battle until its block
// ends (next "Battle at " header, a top-level section header, or
// end-of-file). `battleIndex` is the per-report 0-based index used
// to derive a stable synthetic UUID through `syntheticBattleID`.
// `pendingBattleRace` holds the race name currently being
// rostered, set by the "<Race> Groups" sub-header that opens each
// race's roster table inside the battle block.
battles []report.BattleReport
pendingBattle *pendingBattle
battleIndex uint
pendingBattleRace string
}
type pendingBattle struct {
id uuid.UUID
planet uint
planetName string
// Race name → race index used in Protocol.{a,d}. Indices are
// 0-based and assigned in first-seen order across the battle.
raceIndex map[string]int
// (race name, class name) → ship-group index used in
// Protocol.{sa,sd}. Indices are 0-based and assigned in
// first-seen order across the battle, across all races.
shipIndex map[shipKey]int
races map[int]uuid.UUID
ships map[int]report.BattleReportGroup
protocol []report.BattleActionReport
}
type shipKey struct {
race string
class string
} }
type pendingGroup struct { type pendingGroup struct {
@@ -155,10 +198,62 @@ func (p *parser) handle(line string) error {
return nil return nil
} }
// Inside a battle block, "<Race> Groups" lines open a per-race
// roster sub-table. The line matches singleTokenPrefix(_, " Groups")
// and would otherwise be treated as a top-level section transition
// by classifySection. Trap it here so the battle state stays open.
if (p.sec == sectionBattle || p.sec == sectionBattleProtocol) && p.pendingBattle != nil {
if race, ok := singleTokenPrefix(trimmed, " Groups"); ok {
// New roster — the protocol block, if it had started,
// cannot reopen; but the engine never emits "<Race> Groups"
// after "Battle Protocol" inside the same battle.
p.sec = sectionBattle
p.pendingBattleRace = race
p.skipHeader = true
return nil
}
}
if newSec, owner, isHeader := classifySection(trimmed); isHeader { if newSec, owner, isHeader := classifySection(trimmed); isHeader {
// Flush the previous battle on any header transition that
// moves us out of the battle block. Sub-transitions
// (sectionBattle → sectionBattleProtocol or vice-versa)
// inside the same battle do not flush.
switch {
case newSec == sectionBattle:
p.flushPendingBattle()
planet, planetName, ok := parseBattleHeader(trimmed)
if ok {
p.pendingBattle = &pendingBattle{
id: syntheticBattleID(p.battleIndex),
planet: planet,
planetName: planetName,
raceIndex: make(map[string]int),
shipIndex: make(map[shipKey]int),
races: make(map[int]uuid.UUID),
ships: make(map[int]report.BattleReportGroup),
}
p.battleIndex++
}
p.pendingBattleRace = ""
case newSec == sectionBattleProtocol:
// Stay in the same battle; the protocol header itself
// has no column header to skip — `Battle Protocol` is
// followed by the shot lines directly. Reset
// pendingBattleRace because the roster phase ended.
p.pendingBattleRace = ""
default:
// Any other section transition closes the battle.
p.flushPendingBattle()
}
p.sec = newSec p.sec = newSec
p.otherOwner = owner p.otherOwner = owner
p.skipHeader = newSec != sectionNone // `Battle Protocol` has no column header to skip; ditto for
// the per-race `<Race> Groups` sub-header trapped above (we
// handle that branch separately). For sectionBattle the
// header line is "Battle at (#N) Name" with no following
// column row, so skipHeader stays false there as well.
p.skipHeader = newSec != sectionNone && newSec != sectionBattle && newSec != sectionBattleProtocol
return nil return nil
} }
@@ -205,16 +300,21 @@ func (p *parser) handle(line string) error {
p.parseBombing(fields) p.parseBombing(fields)
case sectionShipsInProduction: case sectionShipsInProduction:
p.parseShipProductionRow(fields) p.parseShipProductionRow(fields)
case sectionBattle:
p.parseBattleRosterRow(fields)
case sectionBattleProtocol:
p.parseBattleProtocolLine(fields)
} }
return nil return nil
} }
func (p *parser) finish() (report.Report, error) { func (p *parser) finish() ([]report.BattleReport, error) {
if !p.sawHeader { if !p.sawHeader {
return report.Report{}, errors.New("legacyreport: missing report header line") return nil, errors.New("legacyreport: missing report header line")
} }
p.flushPendingBattle()
p.resolvePending() p.resolvePending()
return p.rep, nil return p.battles, nil
} }
// parseHeader extracts (race, turn) from // parseHeader extracts (race, turn) from
@@ -294,15 +394,16 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
case "Ships In Production": case "Ships In Production":
return sectionShipsInProduction, "", true return sectionShipsInProduction, "", true
case "Approaching Groups", case "Approaching Groups",
"Broadcast Message", "Broadcast Message":
"Battle Protocol":
return sectionNone, "", true return sectionNone, "", true
case "Battle Protocol":
return sectionBattleProtocol, "", true
} }
if strings.HasPrefix(line, "Status of Players") { if strings.HasPrefix(line, "Status of Players") {
return sectionStatusOfPlayers, "", true return sectionStatusOfPlayers, "", true
} }
if strings.HasPrefix(line, "Battle at ") { if strings.HasPrefix(line, "Battle at ") {
return sectionNone, "", true return sectionBattle, "", true
} }
if strings.HasPrefix(line, "=== ATTENTION") { if strings.HasPrefix(line, "=== ATTENTION") {
return sectionNone, "", true return sectionNone, "", true
@@ -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 // parseShipProductionRow buffers a "Ships In Production" row for
// post-processing in [parser.finish]. Columns: // 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)) return uuid.NewSHA1(syntheticGroupNamespace, fmt.Appendf(nil, "legacy-local-group-%d", g))
} }
// syntheticBattleNamespace seeds [uuid.NewSHA1] for the per-report
// battle-index → UUID derivation used by `Report.Battle[i].ID` and
// `BattleReport.ID`. Distinct from `syntheticGroupNamespace` so a
// per-report battle index can never collide with a ship-group id.
// Mirrors the rationale in `syntheticGroupNamespace`: arbitrary
// value, stable across releases.
var syntheticBattleNamespace = uuid.MustParse("be01a000-0000-0000-0000-000000000002")
// syntheticBattleRaceNamespace seeds [uuid.NewSHA1] for the
// per-battle race name → race UUID derivation that fills
// `BattleReport.Races`. Engine-side reports carry the real race
// UUID; the legacy text only carries the race name, so we derive a
// stable identifier from the name. The constant is independent of
// `syntheticBattleNamespace` so race UUIDs can never collide with
// battle UUIDs.
var syntheticBattleRaceNamespace = uuid.MustParse("be01a000-0000-0000-0000-000000000003")
func syntheticBattleID(idx uint) uuid.UUID {
return uuid.NewSHA1(syntheticBattleNamespace, fmt.Appendf(nil, "legacy-battle-%d", idx))
}
func syntheticBattleRaceID(name string) uuid.UUID {
return uuid.NewSHA1(syntheticBattleRaceNamespace, fmt.Appendf(nil, "legacy-battle-race-%s", name))
}
func dashOrEmpty(s string) string { func dashOrEmpty(s string) string {
if s == "-" { if s == "-" {
return "" return ""
+261 -29
View File
@@ -18,7 +18,7 @@ func TestParseHeaderAndSize(t *testing.T) {
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -54,7 +54,7 @@ func TestParseStatusOfPlayers(t *testing.T) {
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -87,7 +87,7 @@ func TestParseYourVote(t *testing.T) {
"KnightErrants 16.02", "KnightErrants 16.02",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -115,7 +115,7 @@ func TestParseLocalAndOtherPlanets(t *testing.T) {
" 12 303.84 579.23 Skarabei 500.00 500.00 500.00 10.00 Capital 0.00 70.99 20.03 341.78", " 12 303.84 579.23 Skarabei 500.00 500.00 500.00 10.00 Capital 0.00 70.99 20.03 341.78",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -160,7 +160,7 @@ func TestParseUninhabitedAndUnidentified(t *testing.T) {
" 1 579.12 489.37", " 1 579.12 489.37",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -196,7 +196,7 @@ func TestParseShipClasses(t *testing.T) {
"Dragon 16.70 1 1.10 1.00 1 19.80", "Dragon 16.70 1 1.10 1.00 1 19.80",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -241,7 +241,7 @@ func TestParseSciences(t *testing.T) {
"_Drift 1 0 0 0", "_Drift 1 0 0 0",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -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", "Knights Ricksha 332 PEHKE 500.00 258.64 Dron 184.39 0.00 6.42 331.93 Damaged",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -339,7 +339,7 @@ func TestParseShipsInProduction(t *testing.T) {
" 17 Castle CombatFlame 990.10 0.07 1000.00", " 17 Castle CombatFlame 990.10 0.07 1000.00",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -381,7 +381,7 @@ func TestParseShipsInProductionDropsUnknownPlanet(t *testing.T) {
" 99 Lost Frigate 100.00 0.05 500.00", " 99 Lost Frigate 100.00 0.05 500.00",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -391,23 +391,57 @@ func TestParseShipsInProductionDropsUnknownPlanet(t *testing.T) {
} }
} }
// TestParseSkipsBattles covers the only remaining legacy section the // TestParseBattles exercises the battle-block parser end-to-end:
// parser ignores: "Battle at ..." headers and the following "Battle // two battles with two races each, full rosters, and protocols. The
// Protocol" block. Bombings, Ships In Production, and the per-race // inline fixture mirrors the KNNTS-style layout (race-named roster
// Sciences / Ship Types blocks now flow through real parsers; the // sub-headers, 10-column roster rows, 8-token shot lines) so any
// dedicated section tests below cover them. // drift from the real engine format breaks this test before a smoke
func TestParseSkipsBattles(t *testing.T) { // regression. Asserts:
// - report.Battle carries one BattleSummary per "Battle at"
// - BattleReport slice mirrors that with full Races/Ships/Protocol
// - Battle Protocol "Foo fires on Bar : <Destroyed|Shields>" lines
// map to BattleActionReport entries with the correct destroyed flag
// - Roster column 8 (the "L" column) populates NumberLeft
// - Top-level sections after a battle (Your Planets) still parse
// — battle state must close cleanly without leaking rows.
func TestParseBattles(t *testing.T) {
in := strings.Join([]string{ in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1", "Race Report for Galaxy PLUS Turn 1",
"", "",
"Battle at (#7) B-007", "Battle at (#7) B-007",
"", "",
"Foo Groups",
"",
"# T D W S C T Q L", "# T D W S C T Q L",
"1 PeaceShip 4 0 0 0 - 0 1 Out_Battle", "1 PeaceShip 4.0 0 0 0 - 0 1 In_Battle",
"2 Drone 0.0 1 1 0 - 0 0 In_Battle",
"",
"Bar Groups",
"",
"# T D W S C T Q L",
"1 Pistolet 1.0 1.0 0 0 - 0 1 In_Battle",
"", "",
"Battle Protocol", "Battle Protocol",
"", "",
"Foo fires on Bar : Destroyed", "Foo PeaceShip fires on Bar Pistolet : Shields",
"Bar Pistolet fires on Foo Drone : Destroyed",
"Bar Pistolet fires on Foo Drone : Destroyed",
"",
"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", "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", " 17 171.05 700.24 Castle 1000.00 1000.00 1000.00 10.00 Capital 0.00 0.68 88.78 1000.00",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, battles, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
// The trailing Your Planets section must still parse — battle
// state must close before the next top-level header.
if got, want := len(rep.LocalPlanet), 1; got != want { if got, want := len(rep.LocalPlanet), 1; got != want {
t.Fatalf("len(LocalPlanet) = %d, want %d (battle 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", " 2 1 Tormoz 11.19 0.00 0.00 1.0 CAP 4 North Castle 7.5 60.66 49.50 - In_Space",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -515,7 +721,7 @@ func TestParseYourFleets(t *testing.T) {
" 1 Far 2 North Castle 4.50 20 In_Space", " 1 Far 2 North Castle 4.50 20 In_Space",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -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", " 87 169.59 694.49 North 500.00 500.00 500.00 10.00 Capital 0.00 0.52 35.76 500.00",
"", "",
}, "\n") }, "\n")
rep, err := Parse(strings.NewReader(in)) rep, _, err := Parse(strings.NewReader(in))
if err != nil { if err != nil {
t.Fatalf("Parse: %v", err) t.Fatalf("Parse: %v", err)
} }
@@ -597,11 +803,12 @@ type smokeWant struct {
localGroups, localFleets, incomingGroups int localGroups, localFleets, incomingGroups int
localScience, otherScience, otherShipClass int localScience, otherScience, otherShipClass int
bombings, shipProductions int bombings, shipProductions int
battles int
} }
func runSmoke(t *testing.T, path string, want smokeWant) { func runSmoke(t *testing.T, path string, want smokeWant) {
t.Helper() t.Helper()
rep, err := parseFile(t, path) rep, battles, err := parseFile(t, path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
t.Skipf("legacy report fixture missing: %s", path) t.Skipf("legacy report fixture missing: %s", path)
@@ -647,12 +854,31 @@ func runSmoke(t *testing.T, path string, want smokeWant) {
{"OtherShipClass", len(rep.OtherShipClass), want.otherShipClass}, {"OtherShipClass", len(rep.OtherShipClass), want.otherShipClass},
{"Bombing", len(rep.Bombing), want.bombings}, {"Bombing", len(rep.Bombing), want.bombings},
{"ShipProduction", len(rep.ShipProduction), want.shipProductions}, {"ShipProduction", len(rep.ShipProduction), want.shipProductions},
{"Battle (summary)", len(rep.Battle), want.battles},
{"BattleReport", len(battles), want.battles},
} }
for _, c := range checks { for _, c := range checks {
if c.got != c.want { if c.got != c.want {
t.Errorf("%s = %d, want %d", c.name, c.got, c.want) t.Errorf("%s = %d, want %d", c.name, c.got, c.want)
} }
} }
for i, summary := range rep.Battle {
if i >= len(battles) {
break
}
if summary.ID != battles[i].ID {
t.Errorf("battle[%d].ID summary=%s vs report=%s",
i, summary.ID, battles[i].ID)
}
if summary.Shots != uint(len(battles[i].Protocol)) {
t.Errorf("battle[%d].Shots = %d, want %d (len(Protocol))",
i, summary.Shots, len(battles[i].Protocol))
}
if summary.Planet != battles[i].Planet {
t.Errorf("battle[%d].Planet summary=%d vs report=%d",
i, summary.Planet, battles[i].Planet)
}
}
} }
// TestParseDgKNNTS039 is a smoke test: the parser must produce // TestParseDgKNNTS039 is a smoke test: the parser must produce
@@ -676,6 +902,7 @@ func TestParseDgKNNTS039(t *testing.T) {
otherShipClass: 170, otherShipClass: 170,
bombings: 16, bombings: 16,
shipProductions: 6, shipProductions: 6,
battles: 28,
}) })
} }
@@ -694,6 +921,7 @@ func TestParseDgKNNTS040(t *testing.T) {
otherShipClass: 160, otherShipClass: 160,
bombings: 24, bombings: 24,
shipProductions: 16, shipProductions: 16,
battles: 79,
}) })
} }
@@ -715,6 +943,7 @@ func TestParseDgKNNTS041(t *testing.T) {
otherShipClass: 218, otherShipClass: 218,
bombings: 12, bombings: 12,
shipProductions: 22, shipProductions: 22,
battles: 56,
}) })
} }
@@ -736,6 +965,7 @@ func TestParseGplus40(t *testing.T) {
otherShipClass: 183, otherShipClass: 183,
bombings: 4, bombings: 4,
shipProductions: 8, shipProductions: 8,
battles: 30,
}) })
} }
@@ -757,6 +987,7 @@ func TestParseDgKiller031(t *testing.T) {
otherShipClass: 161, otherShipClass: 161,
bombings: 18, bombings: 18,
shipProductions: 0, shipProductions: 0,
battles: 83,
}) })
} }
@@ -779,18 +1010,19 @@ func TestParseDgTancordia037(t *testing.T) {
otherShipClass: 123, otherShipClass: 123,
bombings: 22, bombings: 22,
shipProductions: 20, shipProductions: 20,
battles: 57,
}) })
} }
func parseFile(t *testing.T, rel string) (report.Report, error) { func parseFile(t *testing.T, rel string) (report.Report, []report.BattleReport, error) {
t.Helper() t.Helper()
abs, err := filepath.Abs(rel) abs, err := filepath.Abs(rel)
if err != nil { if err != nil {
return report.Report{}, err return report.Report{}, nil, err
} }
f, err := os.Open(abs) f, err := os.Open(abs)
if err != nil { if err != nil {
return report.Report{}, err return report.Report{}, nil, err
} }
defer func() { _ = f.Close() }() defer func() { _ = f.Close() }()
return Parse(f) return Parse(f)
File diff suppressed because it is too large Load Diff
+249 -52
View File
@@ -1381,9 +1381,13 @@ Decisions taken with the project owner during implementation:
6. **`historyMode` as a prop, not a module.** Layout passes 6. **`historyMode` as a prop, not a module.** Layout passes
`historyMode={false}` (a constant in Phase 12) to `Sidebar` and `historyMode={false}` (a constant in Phase 12) to `Sidebar` and
`BottomTabs`; both forward to their tab-bar children which omit `BottomTabs`; both forward to their tab-bar children which omit
the order entry when the flag is true. Phase 26 introduces the the order entry when the flag is true. Phase 26 superseded the
real `lib/history-mode.ts` module and replaces the constant in "introduce `lib/history-mode.ts`" half of this decision: the
one place. single derivation `historyMode = $derived(gameState.historyMode)`
lives directly in `+layout.svelte`, the rune split between
`currentTurn` and `viewedTurn` lives in `GameStateStore`, and
no separate module is introduced. See Phase 26 decisions for
the rationale.
7. **Empty-state copy is `order is empty` / `приказ пуст`.** The 7. **Empty-state copy is `order is empty` / `приказ пуст`.** The
`coming soon` placeholder text is replaced; per-row delete `coming soon` placeholder text is replaced; per-row delete
button reads `delete` / `удалить`. button reads `delete` / `удалить`.
@@ -2803,26 +2807,118 @@ Targeted tests:
banner on `turn_already_closed` reply and paused banner on banner on `turn_already_closed` reply and paused banner on
the signed `game.paused` frame. the signed `game.paused` frame.
## Phase 26. History Mode ## ~~Phase 26. History Mode~~
Status: pending. Status: done. Verified on local-ci run 6 (`success`, 2d17760).
Goal: let the user navigate to past turns and view all data as it was, Goal: let the user navigate to past turns and view all data as it was,
with no order composition allowed. with no order composition allowed.
Artifacts: Decisions baked in during implementation:
- `ui/frontend/src/lib/header/turn-navigator.svelte` clickable turn 1. **History state lives in `GameStateStore`, no separate module.**
counter expansion: popover (desktop) / bottom-sheet (mobile) listing The Phase 12 plan-line "introduce `lib/history-mode.ts`" is
recent turns and a search field for jumping to a turn number superseded: the only consumer needs a one-line derivation
- `ui/frontend/src/lib/history-mode.ts` global toggle wired into every (`historyMode = $derived(gameState.historyMode)`), and the
view's data source: when active, all `state-binding`, table, report, project's compactness rule rejects an abstraction with no second
inspector, and map sources read from the historical snapshot for the caller. The store ships two distinct turn runes — `currentTurn`
selected turn (server's authoritative latest, set by `setGame` /
- `ui/frontend/src/lib/header/history-banner.svelte` persistent banner `advanceToPending`) and `viewedTurn` (what the UI displays, set
reading `Viewing turn N · read-only` with a `Return to current turn` by `viewTurn` / `returnToCurrent`) — plus the derived
action `historyMode` rune that flips when `viewedTurn < currentTurn`.
- order tab hidden in history mode (already prepared in Phase 12) 2. **`OrderDraftStore` gates mutations at one chokepoint.**
`bindClient` gains an optional `getHistoryMode: () => boolean`
alongside the existing `getCurrentTurn`; `add` / `remove` /
`move` return early when it reports `true`. Every Phase 1422
inspector that calls `orderDraft.add(...)` becomes inert in
history mode without per-component edits.
3. **Turn navigator UX.** Header replaces the static `turn N` text
with `← turn N →`: arrows step ±1 (disabled at `0` and
`currentTurn`), the middle button opens a dropdown of every
turn `Turn #0`…`Turn #currentTurn` with the current row carrying
a badge. No free-text input. Desktop uses an absolute popover
under the header; mobile reuses `view-menu.svelte`'s fixed-
drawer pattern (no new primitive). Selecting the current row
routes through `returnToCurrent()` so the "leave history" path
has one canonical entry.
4. **History is ephemeral across reloads.** `last-viewed-turn` is
written only when `viewedTurn === currentTurn`; historical
excursions never advance the resume bookmark. Page reload exits
history mode. The visibility-refresh listener is a no-op while
`historyMode` is true so a tab-focus event cannot silently kick
the user back onto the live turn. Push events (Phase 24) continue
to deliver new-turn notifications, so the pending-turn toast
still appears.
5. **Past-turn report cache.** New `game-history/{gameId}/turn/{N}`
namespace stores past-turn reports; `viewTurn(N)` reads cache
first and falls back to the network on miss. Past turns are
immutable so the cache has no TTL and no eviction. The current
turn deliberately skips the cache (it is mutable until the next
tick).
6. **Order overlay short-circuits in history mode.**
`RenderedReportSource.report` returns the raw server snapshot
instead of running `applyOrderOverlay`: the draft is composed
against the current turn, projecting it onto a past report would
render fictional intent.
7. **`game.shell.headline` removed.** The Phase 11 i18n key that
formatted `{race} @ {game}, turn {turn}` is deleted; the header
composes `race @ game` in plain text and delegates `turn N` to
`turn-navigator.svelte`. The existing `game-shell-headline`
testid moves to the `.left` wrapper so e2e specs that match
`toContainText("turn N")` continue to find the substring inside
the navigator's button.
Artifacts (delivered):
- `ui/frontend/src/lib/game-state.svelte.ts` — `viewedTurn` rune,
derived `historyMode` rune, `viewTurn(turn)` /
`returnToCurrent()` public methods, `loadTurn(turn, { isCurrent })`
refactor that gates `last-viewed-turn` writes, `readReport` cache
layer over the `game-history` namespace, visibility-refresh
short-circuit in history mode, `initSynthetic` keeps
`currentTurn === viewedTurn`.
- `ui/frontend/src/sync/order-draft.svelte.ts` — `bindClient` accepts
`getHistoryMode`, `add` / `remove` / `move` no-op when active.
- `ui/frontend/src/lib/rendered-report.svelte.ts` — overlay short-
circuit when `gameState.historyMode === true`.
- `ui/frontend/src/lib/header/turn-navigator.svelte` (new) — header
triplet `← turn N →` + dropdown popover / drawer, reuses
`view-menu.svelte`'s outside-click / Escape pattern.
- `ui/frontend/src/lib/header/history-banner.svelte` (new) — sticky
read-only banner under the header with a `Return to current turn`
action.
- `ui/frontend/src/lib/header/header.svelte` — embeds
`<TurnNavigator />` next to the race-and-game identity span;
drops the static turn portion.
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
`historyMode` derived rune, `getHistoryMode` passed to
`orderDraft.bindClient`, `<HistoryBanner />` mounted between
header and body.
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new
`game.shell.history.*` and `game.shell.turn.*` keys; the now-
unused `game.shell.headline` entry is removed.
- `ui/docs/storage.md` — `game-history` namespace row; also adds
the `game-prefs/{gameId}/last-viewed-turn` row (Phase 11 doc
gap).
- `ui/docs/game-state.md` — current/viewed-turn rune table, the
new History mode section.
- `ui/docs/navigation.md` — describes the navigator, the read-only
banner, and the `historyMode` derivation wiring.
- `ui/docs/order-composer.md` — notes the mutation gate, the
overlay short-circuit, and the cross-doc references.
- Vitest: `tests/game-state.test.ts` extended with `viewTurn` /
`returnToCurrent` / `historyMode` derivation / cache hit /
visibility-refresh short-circuit / resume-from-stale-bookmark
flips; `tests/order-draft.test.ts` extended with the history-
mode gate cases; `tests/turn-navigator.test.ts` and
`tests/history-banner.test.ts` (new) cover the components in
isolation.
- Playwright: `tests/e2e/history-mode.spec.ts` (new) — drives the
full chrome flow against `/games/<id>/table/planets`. The map
view is deliberately avoided because the Pixi renderer can
monopolise the headless Chromium main thread long enough to let
the `toContainText` poll find stale "turn ?" content; the table
view exercises the same wiring without that rendering tail.
Dependencies: Phases 11, 12, 23. Dependencies: Phases 11, 12, 23.
@@ -2834,59 +2930,145 @@ Acceptance criteria:
available; available;
- returning to the current turn restores live data and re-shows the - returning to the current turn restores live data and re-shows the
order tab with the prior draft intact (state preservation); order tab with the prior draft intact (state preservation);
- all UI views (map, tables, report, battle, mail) work in history - battle / mail stub views still render correctly while the
mode. read-only banner is visible (Phases 27 / 28 will replace the
stubs with real implementations; the wiring is sufficient
today).
Targeted tests: Targeted tests:
- Vitest unit tests for `history-mode` toggle and per-view source - Vitest unit tests for current/viewed turn rune split, view-turn
selection; cache behaviour, visibility-refresh short-circuit, order-draft
- Playwright e2e: enter history mode, navigate three views, return, history-mode gate, turn-navigator interactions, history-banner
confirm the order draft survived. rendering / action;
- Playwright e2e: enter history mode via arrow, navigate via
dropdown, return via banner action, confirm the order draft
survives the round-trip.
## Phase 27. Battle Viewer ## ~~Phase 27. Battle Viewer~~
Status: pending. Status: done (local-ci run 14).
Goal: render battles as a dedicated view with playback controls Goal: ship a dedicated Battle Viewer rendering radial scenes from
(play, pause, step forward, step backward, rewind), driven by the `BattleReport` data (planet centred, races on the outer ring, per
server-side combat log; render battle and bombing markers on the map. ship-class clusters, animated shot lines), plus battle and bombing
markers on the map. Battles and bombings stay strictly separate —
bombings remain a static table in the Reports view, only battles
get the animated viewer.
Artifacts: Artifacts:
- `ui/frontend/src/map/battle-markers.ts` renders markers on the map - engine: `game/internal/router/handler/battle.go` for
for current-turn battles and bombings within visibility, clickable `GET /api/v1/battle/:turn/:uuid` (handler pre-existed; Phase 27
to open the battle viewer added the tests + openapi schemas)
- `ui/frontend/src/routes/games/[id]/battle/[battleId]/+page.svelte` - engine wire: `pkg/model/report/battle.go` ships a new
view with the combatant list, the round-by-round log, and a player `BattleSummary{id, planet, shots}`; `Report.battle` carries a
control bar slice of these summaries so the map can place markers without
- `ui/frontend/src/lib/battle-player/` round timeline, current-round fetching every full report
highlight, per-shot animation - backend: `backend/internal/engineclient/client.go.FetchBattle`
- entry points to the viewer: marker on map, row in the report's and `backend/internal/server/handlers_user_games.go.Battle`
battles section, push-event toast when a battle this turn involved expose `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`
the player - UI viewer: `ui/frontend/src/lib/battle-player/`
- topic doc `ui/docs/battle-viewer-ux.md` covering playback (`radial-layout.ts`, `timeline.ts`, `battle-scene.svelte`,
semantics, accessibility (the combat log must be readable as text `playback-controls.svelte`, `battle-viewer.svelte`); SVG-based,
for users who skip animations) one frame per protocol entry, full controls (play/pause + step
back + step forward + rewind + 1x/2x/4x speed switch)
- UI route + page wrapper:
`ui/frontend/src/routes/games/[id]/battle/[[battleId]]/+page.svelte`
feeds `gameId` / `turn` / `battleId` into
`ui/frontend/src/lib/active-view/battle.svelte`, which loads the
report via `api/battle-fetch.ts` (synthetic-fixture path + real
engine fetch through the backend gateway)
- UI report link: `lib/active-view/report/section-battles.svelte`
now links every battle UUID into
`/games/{id}/battle/{uuid}?turn={turn}`
- UI map markers: `ui/frontend/src/map/battle-markers.ts` emits a
yellow X cross per battle (two `LinePrim` through the planet's
bounding-square diagonals; stroke width scales 1px..5px with
protocol length) plus a stroke-only ring per bombing (yellow when
damaged, red when wiped). Wired into `state-binding.ts`; the map
click handler dispatches battle clicks to the viewer and bombing
clicks to a scroll-into-view of the matching row in Reports.
- topic doc `ui/docs/battle-viewer-ux.md` covers playback
semantics, accessibility (the always-visible `<ol>` log), the
radial layout, and the marker click behaviour
- docs/FUNCTIONAL.md §6.5 (Battle viewer) + mirror in
docs/FUNCTIONAL_ru.md
Dependencies: Phase 23. Dependencies: Phase 23.
Acceptance criteria: Acceptance criteria:
- battle and bombing markers render on the map for the seeded - battle and bombing markers render on the map for the seeded
current-turn report and are clickable to open the viewer; current-turn report and are clickable: battle → Battle Viewer for
- the viewer plays back any battle in the seeded report including the corresponding UUID, bombing → scroll to its row in Reports;
multi-round and one-sided battles; - the Battle Viewer plays back any `BattleReport` end-to-end with
- step controls allow precise inspection; step back / step forward / rewind / 1x-2x-4x speeds; observers
- the same data is accessible as a static text log for accessibility. (`inBattle === false`) are not drawn; eliminated races drop out
and survivors re-distribute on the next frame;
- the same protocol is mirrored as an always-visible text log under
the scene for accessibility;
- bombings keep their Phase 23 static table layout in Reports; no
Battle Viewer entry-point is wired from them.
Targeted tests: Targeted tests:
- Vitest unit tests for round-state transitions; - Vitest unit: radial layout (1/2/3 races) and timeline frame-
- Vitest unit tests for marker rendering on torus and no-wrap builder (initial state, shot decrement, race-elimination drop-out)
fixtures; in `tests/battle-player.test.ts`
- Playwright e2e: click a battle marker on the map, play through, - Vitest unit: marker primitives + stroke-width formula
step backward, return to the report. (1→1, 50→2.98, 100→5, 200→5) in `tests/battle-markers.test.ts`
- Go unit: engine HTTP handler validations (400 / 404 / 500) in
`game/internal/router/battle_test.go`
- Go contract: openapi freezes for the new endpoint and schemas in
`game/openapi_contract_test.go`
- Playwright e2e: click battle marker → viewer; play / step back;
click battle UUID in Reports → viewer; click bombing marker →
Reports bombings row scrolled into view.
Decisions during stage:
1. **Bombings stay a static table.** `section-bombings.svelte`
already covers the "who bombed, with what power, wiped or not"
requirement; nothing in Phase 27 touches it. Bombings explicitly
do not open the Battle Viewer.
2. **Wire change.** `Report.Battle` switched from `[]uuid.UUID` to
`[]BattleSummary{id, planet, shots}` so the map renderer can
place markers without N extra fetches and so the cross-marker
stroke can scale with protocol length.
3. **Battle marker = yellow X cross** drawn as 2 `LinePrim` through
the corners of the planet's circumscribed square; stroke width
`clamp(1 + (shots - 1) * 4 / 99, 1, 5)` px.
4. **Bombing marker = stroke-only ring** slightly larger than the
planet circle. Yellow when damaged, red when wiped. Click =
scroll to the matching row in Reports (not the viewer).
5. **Viewer URL** `/games/[id]/battle/[battleId]?turn=N`. Turn is a
query param so the same route works in history mode.
6. **SVG, not PixiJS** for the radial scene — isolated component,
no need for WebGL; PixiJS stays as the map renderer.
7. **Playback controls full set**: play / pause + step back + step
forward + rewind + 1x / 2x / 4x switch. 1x = 400 ms per frame.
8. **Observer groups (`inBattle: false`)** are filtered out of both
the scene and the text log.
9. **Cluster aggregation by `(race, className)`** so a race with
multiple groups of the same class collapses to one labelled
circle. Stable target for shot-line endpoints.
10. **Page loader switches on `synthetic-` gameId prefix** —
synthetic mode uses `api/synthetic-battle.ts` fixtures; live
games hit `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`.
BattleViewer component itself is a logically isolated prop sink.
11. **Always-visible `<ol>` text protocol** under the scene satisfies
the accessibility requirement without a separate "skip
animation" toggle.
TODO carried into Phase 27 deferred items
(see Phase 27 of this PLAN's deferred-followups list, near the
bottom):
- push event `game.battle.new` + toast deep-link;
- richer ship-class visuals derived from class characteristics;
- animated transitions when survivors re-distribute after an
elimination (currently hard-jumps).
## Phase 28. Diplomatic Mail View ## Phase 28. Diplomatic Mail View
@@ -3358,3 +3540,18 @@ phase listed in the parenthesis when that phase lands.
exercises a unary Connect call and a server-streaming Connect call exercises a unary Connect call and a server-streaming Connect call
through `testenv.Bootstrap`. (Phase 7+, fold into the phase that through `testenv.Bootstrap`. (Phase 7+, fold into the phase that
needs it.) needs it.)
- **Battle viewer — push event `game.battle.new`** — when a battle
involving the current player lands, emit a backend notification
intent (idempotency `battle-new:<game_id>:<turn>:<battle_id>`,
payload `{game_id, turn, battle_id}`) so the in-game shell
surfaces a toast with a deep link into the Battle Viewer.
(Phase 27 deferred; needs an engine emit-side change.)
- **Battle viewer — richer ship-class visuals** — current MVP draws
one small circle plus `<class>:<numLeft>` label per `(race,
className)` pair. Future work derives shape / scale from mass,
armament, shields, and the number of ships in the group.
(Phase 27 deferred.)
- **Battle viewer — animated re-distribution on elimination** —
current implementation hard-jumps to the new spacing on the next
frame; replace with an easing so the survivors visibly slide
along the outer ring. (Phase 27 deferred.)
+258
View File
@@ -0,0 +1,258 @@
# Battle Viewer UX
Phase 27 ships a dedicated viewer for battles (`/games/<id>/battle/<battleId>`).
Bombings stay where they were in Phase 23 — a static table in the
Reports view (`section-bombings.svelte`). The two domains are
deliberately not mixed in any visual surface or click target.
## Data shape
The `BattleViewer` component (`lib/battle-player/battle-viewer.svelte`)
is logically isolated. It accepts a `BattleReport` matching
`pkg/model/report/battle.go`. The fields it uses:
- `id`, `planet`, `planetName` — header + the central-planet glyph.
- `races: { [raceId]: raceUUID }` — race index space used by the
protocol's `a` / `d` fields.
- `ships: { [groupKey]: BattleReportGroup }` — ship-group rosters
with `race` name, `className`, initial `num`, end-state `numLeft`,
and the `inBattle` flag. Observer groups (`inBattle: false`) are
never drawn.
- `protocol: BattleActionReport[]` — flat list of shots. Each carries
attacker `(a, sa)`, defender `(d, sd)`, and `x` (destroyed?).
The component asks `timeline.ts.buildFrames(report)` to expand the
protocol into `protocol.length + 1` frames; frame 0 is the initial
state and frame `N` reflects state after action `protocol[N-1]`. The
race index per ship group is derived from the protocol itself —
every in-battle group appears at least once as attacker or defender,
and the engine never crosses these wires.
## Radial scene
The scene (`lib/battle-player/battle-scene.svelte`, SVG) places the
planet at the centre and arrays the still-active races on an outer
ring at equal angular spacing. Each race anchor hosts a *cloud* of
class circles arranged on a Vogel sunflower spiral biased toward the
planet (the cluster anchor is pushed inward by a quarter step so the
rank-0 node — the heaviest group by NumberLeft — sits closest to the
planet, and the spiral fans the rest behind it). When a race is
wiped out, it drops out of the active list and the survivors are
re-spaced on the next frame.
Each class circle is one *bucket* keyed by `(race, className)`:
tech-variants of the same class collapse into one node so the scene
stays readable when a race fields a dozen tech levels of the same
hull. The per-bucket label `<className>:<numLeft>` sums NumberLeft
across the underlying groups; per-tech detail is available in the
Reports view (Foreign Ship Classes / My Ship Types).
Bucket order inside a cluster is **locked at battle start** by the
initial ship count (`num` summed across tech variants, descending),
together with mass, radius and local position. The static layout
lives in `staticBucketsByRace`; the per-frame derivation
`renderedByRace` overlays the live `NumberLeft` and drops buckets
once they hit zero. The remaining buckets keep their slots in the
cloud, so the cluster does not reshuffle when a class empties — the
empty bucket simply disappears.
Vogel positions are reassigned per rank by their inward distance
toward the planet, so the rank-0 bucket (the largest by initial
ship count) always sits at the most-inward spiral slot.
When two races remain in battle the radial layout switches to the
horizontal duel: race 0 at 9 o'clock, race 1 at 3 o'clock. This
keeps both race labels clear of the SVG top edge and reads as the
two sides facing off naturally.
Circle radius scales with per-ship FullMass (Empty + Carrying via
the per-ship `LoadQuantity`). The viewer resolves a
`(race, className) → ShipClassRef` lookup from the surrounding
`GameReport.localShipClass` + `otherShipClass` tables and runs it
through the existing wasm bridge to `pkg/calc/ship.go`
(`emptyMass` + `carryingMass` + `fullMass`). The radius is then
`MIN_RADIUS + (MAX_RADIUS MIN_RADIUS) × sqrt(mass / maxMassInBattle)`
clamped to `[6, 24]` pixels — per-battle normalisation, so the
heaviest ship in any given battle renders at the cap. Unknown class
or invalid params fall back to MAX_RADIUS so the bucket stays
visible.
The current frame's shot is drawn as a thin line from the attacker's
class circle to the defender's class circle. Colour:
- red (`#ee3344`) when the action's `x === true` (the defender
ship was destroyed),
- green (`#44dd66`) otherwise.
Each frame redraws the line in isolation, so continuous playback
produces the "shot-shot-shot" pulse the user wanted.
## Playback controls
`lib/battle-player/playback-controls.svelte` ships:
| Control | Effect |
| ------------------ | ------------------------------------------------------- |
| ⏮ rewind | Stop, jump to frame 0 |
| ◀︎◀︎ step back | Stop, frame ← frame 1 |
| ▶︎ / ⏸ play | Toggle continuous playback |
| ▶︎▶︎ step forward | Stop, frame ← frame + 1 |
| `Nx` cycle speed | Single button, cycles 1x → 2x → 4x → 6x → 1x; the label shows the current speed (400 / 200 / 100 / 67 ms per frame) |
| `Log ▲▼` toggle | Collapses / expands the always-visible text protocol so the user can give the scene the full viewer height |
When the timeline is at its end and the user hits play, the frame
counter wraps to 0 and continues. Step buttons disable themselves at
their boundary.
A drag-seek slider sits between the scene and the controls. Dragging
pauses playback and lands `frameIndex` on the chosen shot — handy
for jumping to the moment a particular race started losing ground.
## Accessibility
Below the scene the viewer renders a static `<ol>` text protocol —
one line per action, formatted from `BattleReportGroup.race` and
`BattleReportGroup.className`. The line for the current frame is
highlighted so a non-visual reader can follow along by scrolling
the log instead of watching the SVG. The list is always present
and never hidden, satisfying the original Phase 27 acceptance "the
same data is accessible as a static text log".
Each log row is also a `<button>`: a click or Enter/Space jumps
playback to that shot (pauses and seeks). The list auto-scrolls
the current row into view as the timeline advances, so the user
does not have to chase the highlight on long battles.
## Playback details
On play, the shot line + the defender circle's colour flash gate
on a per-frame timer that blinks them off during the last 10 % of
the frame's duration. Two consecutive shots from the same attacker
on the same defender therefore look like two distinct pulses
rather than one continuous line. On pause the line and flash
stay drawn so the user can study the current shot.
## Aggregated ship-class buckets
Real legacy reports list the same `(race, className)` pair across
several roster rows — different tech variants, ships pulled from
multiple stacks or planets. The legacy-report parser
([parser.go](../../tools/local-dev/legacy-report/parser.go))
collapses those rows into a single `BattleReportGroup` keyed by
`(race, className)` by SUMMING `Number` and `NumberLeft`; the
engine's `TransformBattle`
([battle_transform.go](../../game/internal/controller/battle_transform.go))
applies the same merge keyed by `ShipClass.ID`, guarded by a
processed-group set so the same source `groupId` is not summed
twice across multiple protocol references.
Without this aggregation only the last roster row's counts
survived, and the protocol's destroy count against the class
would dwarf the recorded initial count — KNNTS041 turn 41 planet
\#7 had 7 separate `Nails:pup` rows totalling 1168 ships; the
buggy parser stored only the last row's 88, so the 1082 destroys
in the protocol looked like phantom hits past the empty bucket.
After the fix both sides reconcile: 1168 initial 86 survivors =
1082 destroys.
`buildFrames`
([timeline.ts](../src/lib/battle-player/timeline.ts)) keeps a
defence-in-depth clamp `if (left > 0)` on the destroy decrement so
a malformed protocol never pushes a race below zero; in normal
operation the clamp is a no-op because parser + engine already
folded duplicate rows together.
## Final-frame freeze
When the last protocol action eliminates a race, the surviving
side would otherwise reflow alone to the planet ring at the very
last shot — visually jarring and uninformative. `displayFrame`
freezes the layout-determining state (`remaining` and
`activeRaceIds`) at the penultimate frame's values while keeping
the final frame's `shotIndex` and `lastAction`, so the killing
shot still renders as a line + flash against the picture the user
saw a moment earlier.
## Header + layout
The viewer header carries three rows of chrome in a single line:
the back-navigation buttons (`back to map` / `back to report`) on
the left, a centred title — `Battle on planet <name> (<#number>)`,
i18n key `game.battle.header_title` — and the frame counter on the
right. Pulling navigation into the header frees the entire viewer
area for the scene; the `.viewer` container has no `max-width` cap,
so on wide monitors the scene scales up while the log keeps its
internal 30 dvh scroll.
## Height fit
The viewer is pinned to the viewport: `.active-view` uses
`calc(100dvh 80px)` so the in-game-shell header + optional
HistoryBanner do not push the scene below the fold. Inside the
viewer, the scene grows (`flex: 1`), the scrubber + controls hold
their natural height, and the log (when expanded) shrinks to a
30 dvh ceiling with its own internal scroll, so the page itself
never scrolls vertically. The 80 px allowance maps to the current
Header + HistoryBanner total on desktop; mobile breakpoints reuse
the same calc because dvh tracks the dynamic viewport.
## Map markers
`map/battle-markers.ts` emits two marker kinds per
current-turn report. Both are wired into the binding's
`hitLookup` so a click goes through the existing hit-test plumbing.
### Battle marker — yellow cross
For every `report.battles[i]` whose `planet` resolves to a visible
planet, the marker emits two `LinePrim` lines through the opposite
corners of the square circumscribed around the planet circle. The
result is an X-shaped cross overlaid on the planet glyph.
The stroke width is computed by `battleMarkerStrokeWidth(shots)`:
1 shot → 1 px, 100 shots → 5 px, linearly interpolated in between
(`width = 1 + (shots 1) × 4 / 99`, clamped). A click on either
line navigates to `/games/<id>/battle/<battleId>?turn=<turn>`.
### Bombing marker — colored ring
For every `report.bombings[i]`, the marker emits a single
stroke-only `CirclePrim` slightly larger than the planet circle.
Colour:
- yellow (`#FFD400`) when `wiped: false`,
- red (`#FF3030`) when `wiped: true`.
A click on the ring navigates to `/games/<id>/report#report-bombings`
and scrolls the matching `report-bombing-row` (by `data-planet`)
into view. Bombing markers never open the Battle Viewer — the two
domains stay separate.
## Data source
The Battle Viewer page (`lib/active-view/battle.svelte`) calls
`api/battle-fetch.ts.fetchBattle(gameId, turn, battleId)`. The
loader has two modes:
- **Synthetic** — when `gameId` carries the
`synthetic-` prefix, the lookup is served from
`api/synthetic-battle.ts`. Vitest unit tests and Playwright e2e
tests register fixture battles via `registerSyntheticBattle`
before mounting the route.
- **Production** — otherwise the loader issues
`GET /api/v1/user/games/{gameId}/battles/{turn}/{battleId}`
against the backend gateway route added in
`backend/internal/server/handlers_user_games.go.Battle`. The
gateway forwards verbatim to the engine's
`GET /api/v1/battle/:turn/:uuid`.
## TODOs
- Push event `game.battle.new` + toast → viewer link (deferred —
needs an engine emit-side change).
- Richer ship-class visuals derived from the class's mass,
armament, shields. Current MVP uses a small circle plus
`<class>:<numLeft>` label.
- Animated transitions when a race drops out and the survivors
re-distribute. Current implementation hard-jumps on the next
frame.
+64 -11
View File
@@ -37,6 +37,10 @@ The store exposes:
| `gameId` | `string` | active game id | | `gameId` | `string` | active game id |
| `status` | `idle / loading / ready / error` | current lifecycle state | | `status` | `idle / loading / ready / error` | current lifecycle state |
| `report` | `GameReport \| null` | latest decoded report, `null` until first fetch | | `report` | `GameReport \| null` | latest decoded report, `null` until first fetch |
| `currentTurn` | `number` | server's authoritative current turn (live snapshot) |
| `viewedTurn` | `number` | turn whose snapshot is in `report`; equals `currentTurn` in live mode |
| `historyMode` | `boolean` (derived) | true while `status === "ready"` and `viewedTurn < currentTurn` |
| `pendingTurn` | `number \| null` | latest server turn the user has not yet opened |
| `wrapMode` | `torus / no-wrap` | per-game preference, persisted via `Cache` | | `wrapMode` | `torus / no-wrap` | per-game preference, persisted via `Cache` |
| `error` | `string \| null` | localised error message when `status === "error"` | | `error` | `string \| null` | localised error message when `status === "error"` |
@@ -45,8 +49,15 @@ The store exposes:
- Phase 11 surfaces only the planet subset of the report. Later - Phase 11 surfaces only the planet subset of the report. Later
phases extend `GameReport` and `decodeReport` as their slice of phases extend `GameReport` and `decodeReport` as their slice of
the wire lands (ships, fleets, sciences, routes, battles, mail). the wire lands (ships, fleets, sciences, routes, battles, mail).
- Phase 26 wires history mode through `setTurn(turn)`. The store - Phase 26 splits `currentTurn` from the turn whose snapshot is
already supports it; the navigator UI is what is missing. displayed (`viewedTurn`) and adds `viewTurn(turn)` /
`returnToCurrent()` for history navigation. The derived
`historyMode` rune flips automatically when `viewedTurn <
currentTurn`; the layout passes it to Phase 12's sidebar /
bottom-tabs wiring (which hides the order tab) and to
`OrderDraftStore.bindClient` (which gates `add` / `remove` /
`move`). See "History mode" below for the cache and refresh
rules.
- Phase 24 replaces the tab-focus refresh with push-event-driven - Phase 24 replaces the tab-focus refresh with push-event-driven
refreshes; the visibility listener stays as a fallback for refreshes; the visibility listener stays as a fallback for
background tabs that miss a push. background tabs that miss a push.
@@ -88,17 +99,59 @@ result can resolve back to a planet without an extra lookup table.
## Refresh discipline ## Refresh discipline
`refresh()` re-fetches the same turn snapshot. It is called by the `refresh()` re-fetches the current-turn snapshot. It is called by
`visibilitychange` handler when `document.visibilityState === the `visibilitychange` handler when `document.visibilityState ===
"visible"` and the store is already in `ready` state. The map view's "visible"` and the store is already in `ready` state. The map
mount effect skips a re-render when the new snapshot's turn matches view's mount effect skips a re-render when the new snapshot's turn
the previously-mounted turn (and the wrap mode is unchanged), so a matches the previously-mounted turn (and the wrap mode is
no-op refresh does not flicker the canvas. unchanged), so a no-op refresh does not flicker the canvas.
`setTurn(turn)` is the entry point for Phase 26 history mode: In history mode `refresh()` is a no-op — forcing a reload would
calling it on a different turn loads that snapshot and the same silently bump the user back onto the current turn while they are
mount effect re-creates the renderer with the new world. intentionally viewing a past one. Push events (Phase 24) still
deliver new-turn notifications asynchronously while the user
explores history, so the pending-turn toast continues to work.
`setWrapMode(mode)` writes to `Cache` and updates the rune; the `setWrapMode(mode)` writes to `Cache` and updates the rune; the
map view's effect picks the change up and re-mounts the renderer map view's effect picks the change up and re-mounts the renderer
with the new mode. with the new mode.
## History mode
Phase 26 lets the user step backward through the report timeline
without losing the live snapshot. The store keeps two turn runes:
- `currentTurn` — the server's authoritative latest. Only
`setGame` and `advanceToPending` write to it.
- `viewedTurn` — the turn currently rendered. `viewTurn(N)` flips
this rune and the underlying `report` to `N` without touching
`currentTurn`. `returnToCurrent()` is a one-line wrapper that
navigates back to live.
The derived `historyMode` rune (`status === "ready" && viewedTurn
< currentTurn`) drives every history-aware consumer:
- the layout passes it to `Sidebar` / `BottomTabs` so the order
tab vanishes (Phase 12 prop wiring);
- the layout passes a `getHistoryMode` getter to
`OrderDraftStore.bindClient` so `add` / `remove` / `move` are
no-ops while the user is looking at a past turn;
- `RenderedReportSource` returns the raw report (no order overlay)
because the draft is composed against the current turn;
- the new `HistoryBanner` component renders the sticky "Viewing
turn N · read-only" strip when the flag is true.
`last-viewed-turn` semantics keep their Phase 11 meaning: "the
latest turn the user was caught up on". `loadTurn` only writes the
cache row when called with `isCurrent === true` (i.e. when the
load matches `currentTurn`). Historical excursions are therefore
ephemeral: closing the tab and reopening the game resumes on the
last caught-up turn, not on the last clicked one.
Past-turn reports are cached in the `game-history` namespace
(`{gameId}/turn/{N}``GameReport`). The cache is written by
`loadTurn` on every successful historical fetch and read first by
`viewTurn(N)` before falling back to the network. Past turns are
immutable, so the cache has no TTL and no eviction in Phase 26.
The current-turn snapshot is deliberately *not* cached — it is
mutable until the next engine tick.
+34 -5
View File
@@ -75,17 +75,46 @@ end-to-end command flow) can set it on navigation.
The Order entry is hidden when the layout's `historyMode` flag is The Order entry is hidden when the layout's `historyMode` flag is
true. Phase 12 plumbs the flag end-to-end as a prop — true. Phase 12 plumbs the flag end-to-end as a prop —
`+layout.svelte` passes a constant `false` to `Sidebar`, which `+layout.svelte` forwards a derived value to `Sidebar`, which
forwards `hideOrder` to its `TabBar`; the same flag goes to forwards `hideOrder` to its `TabBar`; the same flag goes to
`BottomTabs` so the mobile `Order` button is also suppressed. A `BottomTabs` so the mobile `Order` button is also suppressed. A
`?sidebar=order` URL seed that arrives while the flag is true falls `?sidebar=order` URL seed that arrives while the flag is true falls
back to `inspector`, and an `$effect` on the sidebar resets back to `inspector`, and an `$effect` on the sidebar resets
`activeTab` away from `order` if the flag flips on mid-session. `activeTab` away from `order` if the flag flips on mid-session.
Phase 26 introduces `lib/history-mode.ts` and replaces the constant
with the live signal; the order draft survives the toggle because Phase 26 wires the flag to the live history signal owned by
`GameStateStore`. The derivation lives directly in `+layout.svelte`
(`const historyMode = $derived(gameState.historyMode)`) — no
separate `lib/history-mode.ts` module ships, because the layout is
the single consumer and the project's compactness rule rejects a
one-line indirection. The order draft survives the toggle because
`OrderDraftStore` lives one level above the sidebar in the layout `OrderDraftStore` lives one level above the sidebar in the layout
hierarchy. See [`order-composer.md`](order-composer.md) for the hierarchy; the same `historyMode` derivation is also fed into
draft-store side of the flow. `OrderDraftStore.bindClient` so inspector-driven mutations
(`add` / `remove` / `move`) become no-ops while the user is
viewing a past turn. See [`order-composer.md`](order-composer.md)
for the draft-store side of the flow and
[`game-state.md`](game-state.md) for the rune split between
`currentTurn` and `viewedTurn`.
## Header turn navigator and history banner
The header replaces the Phase 11 inline `turn N` text with a
`← Turn N →` triplet (`lib/header/turn-navigator.svelte`). The
arrows step `viewedTurn` by ±1 (disabled at boundaries `0` and
`currentTurn`); clicking the middle button opens an absolute
popover (desktop) or a fixed full-width drawer (mobile, ≤ 767.98
px) listing every turn from `currentTurn` down to `0`. Selecting
the current-turn row routes through `gameState.returnToCurrent()`;
any other row calls `gameState.viewTurn(N)`. The popover reuses
`view-menu.svelte`'s outside-click / Escape pattern.
`lib/header/history-banner.svelte` renders directly under the
header whenever `gameState.historyMode === true`. It shows
"Viewing turn {N} · read-only" with a "Return to current turn"
button that delegates back to `gameState.returnToCurrent()`. Both
the navigator and the banner read `gameState` through context, so
the layout is the only place where the wiring lives.
## Layout breakpoints ## Layout breakpoints
+30 -8
View File
@@ -300,13 +300,14 @@ order composer uses the namespace.
## History mode wiring ## History mode wiring
Phase 26 introduces a global history-mode flag. The IA section Phase 26 implements history mode: the user can step back through
specifies that the Order tab is hidden when history mode is active — past turns and see the report as it was. The IA section specifies
the player is browsing a past turn snapshot, and composing commands that the Order tab is hidden when history mode is active — the
against an immutable snapshot would be confusing. player is browsing an immutable snapshot, and composing commands
against it would be confusing.
Phase 12 wires the flag end-to-end as a prop. The layout owns the Phase 12 wires the flag end-to-end as a prop. The layout owns the
flag (a constant `false` until Phase 26) and passes it to: flag and passes it to:
- `Sidebar` as `historyMode`. The sidebar forwards it to its - `Sidebar` as `historyMode`. The sidebar forwards it to its
`TabBar` as `hideOrder`. The Order entry is filtered out of the `TabBar` as `hideOrder`. The Order entry is filtered out of the
@@ -317,10 +318,31 @@ flag (a constant `false` until Phase 26) and passes it to:
- `BottomTabs` as `hideOrder`. The mobile bottom-tab `Order` - `BottomTabs` as `hideOrder`. The mobile bottom-tab `Order`
button is suppressed when true. button is suppressed when true.
Phase 26 turns the constant into a derived value driven by
`GameStateStore.historyMode` (`viewedTurn < currentTurn` while
`status === "ready"`). The same getter is also passed into
`OrderDraftStore.bindClient` as `getHistoryMode`, which short-
circuits the `add` / `remove` / `move` mutations to a no-op while
the flag is true. This makes every Phase 1422 inspector affordance
that calls `orderDraft.add(...)` inert in history mode without
per-component edits — the gate lives in the one chokepoint that
all callers go through. The conflict / paused banners and the
in-flight sync pipeline are untouched: they describe state that
exists independently of the user's current view.
The store itself stays alive across history-mode round-trips so The store itself stays alive across history-mode round-trips so
the draft survives. Phase 26 will replace the constant with the the draft survives the toggle. The `RenderedReportSource` overlay
real signal from `lib/history-mode.ts` and exercise the toggle in (`lib/rendered-report.svelte.ts`) additionally short-circuits in
its own test suite. history mode: when `gameState.historyMode === true` it returns
the raw report so the map / inspector do not project pending
renames composed for the *current* turn onto a *past* report.
See [`game-state.md`](game-state.md) for the `viewTurn` /
`returnToCurrent` API, the cache namespace
(`game-history/{gameId}/turn/{N}`), and the visibility-refresh
short-circuit; see [`navigation.md`](navigation.md) for the turn
navigator and the read-only banner that surface history mode in
the chrome.
## Testing ## Testing
+3 -1
View File
@@ -113,10 +113,12 @@ wipes every namespace.
Namespaces in current use: Namespaces in current use:
| Namespace | Key | Value type | Owner | | Namespace | Key | Value type | Owner |
|-----------------|---------------------|------------------|-----------------------------| |-----------------|--------------------------------|------------------|------------------------------------|
| `session` | `device-session-id` | `string` | Phase 7+ | | `session` | `device-session-id` | `string` | Phase 7+ |
| `game-prefs` | `{gameId}/wrap-mode` | `WrapMode` | Phase 11+ (`game-state.md`) | | `game-prefs` | `{gameId}/wrap-mode` | `WrapMode` | Phase 11+ (`game-state.md`) |
| `game-prefs` | `{gameId}/last-viewed-turn` | `number` | Phase 11+ (`game-state.md`) |
| `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | Phase 12+ (`order-composer.md`) | | `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | Phase 12+ (`order-composer.md`) |
| `game-history` | `{gameId}/turn/{N}` | `GameReport` | Phase 26+ (`game-state.md`) |
Later phases will add more per-feature namespaces (fixtures, lobby Later phases will add more per-feature namespaces (fixtures, lobby
snapshot, etc.). The contract is namespace-strings stay scoped to snapshot, etc.). The contract is namespace-strings stay scoped to
+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 * mirrors the producing planet's free industry. Stable order: sorted
* by `(planetNumber, class)`. * 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 { export interface ReportShipProduction {
planetNumber: number; planetNumber: number;
class: string; class: string;
@@ -524,11 +536,17 @@ export interface GameReport {
*/ */
otherShipClass: ReportOtherShipClass[]; otherShipClass: ReportOtherShipClass[];
/** /**
* battleIds is the list of battle UUIDs the engine recorded for * battles is the list of battle summaries the engine recorded for
* the current turn. Phase 23 renders them as inactive * the current turn. Each entry carries the battle UUID, the planet
* monospace identifiers; Phase 27 will turn them into navigation * it happened on, and the number of shots exchanged. The Reports
* targets once the battle viewer lands. Empty when no battles * View uses `id` to link into the Battle Viewer; the map renderer
* occurred last turn. * 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[]; battleIds: string[];
/** /**
@@ -700,7 +718,8 @@ function decodeReport(report: Report): GameReport {
const localFleets = decodeLocalFleets(report); const localFleets = decodeLocalFleets(report);
const otherScience = decodeOtherScience(report); const otherScience = decodeOtherScience(report);
const otherShipClass = decodeOtherShipClass(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 bombings = decodeBombings(report);
const shipProductions = decodeShipProductions(report); const shipProductions = decodeShipProductions(report);
@@ -730,6 +749,7 @@ function decodeReport(report: Report): GameReport {
players, players,
otherScience, otherScience,
otherShipClass, otherShipClass,
battles,
battleIds, battleIds,
bombings, bombings,
shipProductions, shipProductions,
@@ -1153,13 +1173,18 @@ function decodeOtherShipClass(report: Report): ReportOtherShipClass[] {
return out; return out;
} }
function decodeBattleIds(report: Report): string[] { function decodeBattles(report: Report): ReportBattle[] {
const out: string[] = []; const out: ReportBattle[] = [];
for (let i = 0; i < report.battleLength(); i++) { for (let i = 0; i < report.battleLength(); i++) {
const uuid = report.battle(i); const summary = report.battle(i);
const value = uuidStringFromFB(uuid); if (summary === null) continue;
if (value === null) continue; const id = uuidStringFromFB(summary.id());
out.push(value); if (id === null) continue;
out.push({
id,
planet: Number(summary.planet()),
shots: Number(summary.shots()),
});
} }
return out; return out;
} }
@@ -1439,6 +1464,7 @@ export function applyOrderOverlay(
players: report.players ?? [], players: report.players ?? [],
otherScience: report.otherScience ?? [], otherScience: report.otherScience ?? [],
otherShipClass: report.otherShipClass ?? [], otherShipClass: report.otherShipClass ?? [],
battles: report.battles ?? [],
battleIds: report.battleIds ?? [], battleIds: report.battleIds ?? [],
bombings: report.bombings ?? [], bombings: report.bombings ?? [],
shipProductions: report.shipProductions ?? [], 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"; } from "./game-state";
import type { CargoLoadType, Relation } from "../sync/order-types"; import type { CargoLoadType, Relation } from "../sync/order-types";
import { isCargoLoadType, isRelation } from "../sync/order-types"; import { isCargoLoadType, isRelation } from "../sync/order-types";
import type { BattleReport } from "./battle-fetch";
import { registerSyntheticBattle } from "./synthetic-battle";
export const SYNTHETIC_GAME_ID_PREFIX = "synthetic-"; export const SYNTHETIC_GAME_ID_PREFIX = "synthetic-";
@@ -59,18 +61,71 @@ export class SyntheticReportError extends Error {
* loadSyntheticReportFromJSON validates the passed payload, decodes * loadSyntheticReportFromJSON validates the passed payload, decodes
* it into a `GameReport`, registers it in the in-memory map under a * it into a `GameReport`, registers it in the in-memory map under a
* fresh `synthetic-<uuid>` id, and returns both the id and the * fresh `synthetic-<uuid>` id, and returns both the id and the
* decoded report. Throws `SyntheticReportError` for malformed input. * decoded report.
*
* Accepts two on-disk shapes:
*
* 1. Envelope (Phase 27 legacy-report CLI):
* `{ "version": 1, "report": <Report>, "battles": { <uuid>: <BattleReport> } }`
* — battles are forwarded to `registerSyntheticBattle` so the
* Battle Viewer can resolve them offline.
* 2. Bare Report (pre-envelope synthetic JSON files) — same as
* before; battle UUIDs in the report can still be clicked, but
* the Viewer page will show "battle not found" because no
* fixture was registered.
*
* Throws `SyntheticReportError` for malformed input in either shape.
*/ */
export function loadSyntheticReportFromJSON(json: unknown): { export function loadSyntheticReportFromJSON(json: unknown): {
gameId: string; gameId: string;
report: GameReport; report: GameReport;
} { } {
const report = decodeSyntheticReport(json); const { reportPayload, battles } = extractEnvelope(json);
const report = decodeSyntheticReport(reportPayload);
for (const battle of battles) {
registerSyntheticBattle(battle);
}
const gameId = SYNTHETIC_GAME_ID_PREFIX + crypto.randomUUID(); const gameId = SYNTHETIC_GAME_ID_PREFIX + crypto.randomUUID();
SYNTHETIC_REPORTS.set(gameId, report); SYNTHETIC_REPORTS.set(gameId, report);
return { gameId, report }; return { gameId, report };
} }
interface SyntheticEnvelope {
version?: number;
report?: unknown;
battles?: Record<string, BattleReport>;
}
/**
* extractEnvelope distinguishes the v1 envelope shape from a bare
* Report payload. The envelope check is `version === 1` to leave room
* for future format bumps and to avoid mistaking a bare Report whose
* top-level fields happen to include `report`/`battles` (none do
* today) for an envelope.
*/
function extractEnvelope(json: unknown): {
reportPayload: unknown;
battles: BattleReport[];
} {
if (typeof json !== "object" || json === null) {
// Defer the error to `decodeSyntheticReport`; it already
// raises a `SyntheticReportError` with the right message.
return { reportPayload: json, battles: [] };
}
const env = json as SyntheticEnvelope;
if (env.version === 1 && env.report !== undefined) {
const battlesMap = env.battles ?? {};
const battles: BattleReport[] = [];
for (const value of Object.values(battlesMap)) {
if (value && typeof value === "object") {
battles.push(value);
}
}
return { reportPayload: env.report, battles };
}
return { reportPayload: json, battles: [] };
}
/** getSyntheticReport returns the report registered under `gameId`, /** getSyntheticReport returns the report registered under `gameId`,
* or `undefined` if the entry was lost (e.g. page reload). */ * or `undefined` if the entry was lost (e.g. page reload). */
export function getSyntheticReport(gameId: string): GameReport | undefined { export function getSyntheticReport(gameId: string): GameReport | undefined {
@@ -173,6 +228,12 @@ interface SyntheticOtherShipClass extends SyntheticShipClass {
mass?: number; mass?: number;
} }
interface SyntheticBattle {
id?: string;
planet?: number;
shots?: number;
}
interface SyntheticBombing { interface SyntheticBombing {
planet?: number; // wire field "number" planet?: number; // wire field "number"
planetName?: string; // wire field "planetName" planetName?: string; // wire field "planetName"
@@ -219,7 +280,7 @@ interface SyntheticReportRoot {
incomingGroup?: SyntheticIncomingGroup[]; incomingGroup?: SyntheticIncomingGroup[];
unidentifiedGroup?: SyntheticUnidentifiedGroup[]; unidentifiedGroup?: SyntheticUnidentifiedGroup[];
localFleet?: SyntheticLocalFleet[]; localFleet?: SyntheticLocalFleet[];
battle?: string[]; battle?: SyntheticBattle[];
bombing?: SyntheticBombing[]; bombing?: SyntheticBombing[];
shipProduction?: SyntheticShipProductionRow[]; shipProduction?: SyntheticShipProductionRow[];
} }
@@ -357,9 +418,17 @@ function decodeSyntheticReport(json: unknown): GameReport {
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });
const battleIds: string[] = (root.battle ?? []).filter( const battles = (root.battle ?? [])
(v): v is string => typeof v === "string" && v !== "", .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) => ({ const bombings: ReportBombing[] = (root.bombing ?? []).map((b) => ({
planetNumber: numOr0(b.planet), planetNumber: numOr0(b.planet),
@@ -419,6 +488,7 @@ function decodeSyntheticReport(json: unknown): GameReport {
players: collectPlayersFromSynthetic(root, race), players: collectPlayersFromSynthetic(root, race),
otherScience, otherScience,
otherShipClass, otherShipClass,
battles,
battleIds, battleIds,
bombings, bombings,
shipProductions, shipProductions,
+6
View File
@@ -5,6 +5,12 @@
<link rel="icon" href="%sveltekit.assets%/favicon.svg" /> <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Galaxy</title> <title>Galaxy</title>
<style>
html,
body {
margin: 0;
}
</style>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
+157 -15
View File
@@ -1,30 +1,172 @@
<!-- <!--
Phase 10 stub for the battle-log active view. Phase 27 wires the real Phase 27 — active-view wrapper around the BattleViewer. Loads the
battle viewer. BattleReport for the supplied `gameId`/`turn`/`battleId` and either
shows the radial playback (BattleViewer), a loading skeleton, or a
not-found state.
This wrapper also bridges the surrounding GameReport's ship-class
tables into a `(race, className) → ShipClassRef` lookup the viewer
needs to size class circles by ship mass. The back-navigation
buttons (`back to map` / `back to report`) live INSIDE the viewer
header now — we just hand the routes down as callbacks so the
viewer keeps its prop-driven contract.
--> -->
<script lang="ts"> <script lang="ts">
import { i18n } from "$lib/i18n/index.svelte"; import { getContext } from "svelte";
import { goto } from "$app/navigation";
type Props = { battleId: string }; import {
let { battleId }: Props = $props(); BattleFetchError,
fetchBattle,
type BattleReport,
} from "../../api/battle-fetch";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
MapShipClassLookup,
type ShipClassLookup,
type ShipClassRef,
} from "../battle-player/mass";
import BattleViewer from "../battle-player/battle-viewer.svelte";
let {
gameId,
turn,
battleId,
}: {
gameId: string;
turn: number;
battleId: string;
} = $props();
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const shipClassLookup = $derived.by<ShipClassLookup>(() => {
const map = new Map<string, ShipClassRef>();
const report = rendered?.report;
if (report) {
for (const cls of report.localShipClass) {
map.set(`${report.race}::${cls.name}`, {
drive: cls.drive,
weapons: cls.weapons,
armament: cls.armament,
shields: cls.shields,
cargo: cls.cargo,
});
}
for (const cls of report.otherShipClass) {
map.set(`${cls.race}::${cls.name}`, {
drive: cls.drive,
weapons: cls.weapons,
armament: cls.armament,
shields: cls.shields,
cargo: cls.cargo,
});
}
}
return new MapShipClassLookup(map);
});
let state = $state<
| { kind: "loading" }
| { kind: "ready"; report: BattleReport }
| { kind: "not_found" }
| { kind: "error"; message: string }
>({ kind: "loading" });
$effect(() => {
if (!battleId) {
state = { kind: "not_found" };
return;
}
state = { kind: "loading" };
fetchBattle(gameId, turn, battleId)
.then((report) => {
state = { kind: "ready", report };
})
.catch((err: unknown) => {
if (err instanceof BattleFetchError && err.status === 404) {
state = { kind: "not_found" };
} else {
state = {
kind: "error",
message: err instanceof Error ? err.message : String(err),
};
}
});
});
function backToReport() {
goto(`/games/${gameId}/report`);
}
function backToMap() {
goto(`/games/${gameId}/map`);
}
</script> </script>
<section class="active-view" data-testid="active-view-battle" data-battle-id={battleId}> <section
<h2>{i18n.t("game.view.battle")}</h2> class="active-view"
<p>{i18n.t("game.shell.coming_soon")}</p> data-testid="active-view-battle"
data-battle-id={battleId}
>
{#if state.kind === "loading"}
<p class="status" data-testid="battle-loading">
{i18n.t("game.battle.loading")}
</p>
{:else if state.kind === "ready"}
<BattleViewer
report={state.report}
{shipClassLookup}
onBackToMap={backToMap}
onBackToReport={backToReport}
/>
{:else if state.kind === "not_found"}
<p class="status" data-testid="battle-not-found">
{i18n.t("game.battle.not_found")}
</p>
{:else}
<p class="status error" data-testid="battle-error">{state.message}</p>
{/if}
</section> </section>
<style> <style>
.active-view { .active-view {
padding: 1.5rem; display: flex;
flex-direction: column;
/*
* The in-game shell renders this active view inside an
* `.active-view-host` with `flex: 1; overflow-y: auto`, but
* the surrounding `.game-shell` uses `min-height: 100vh`, so
* without a hard upper bound the viewer pushes the whole
* shell past the viewport. We pin the active view to `100dvh`
* minus a small allowance for the header chrome (in-game
* Header + optional HistoryBanner ≈ 66 px on desktop) so the
* internal flex chain can split the remaining height between
* the scene, scrubber, controls and log without forcing a
* page-level scroll.
*/
height: calc(100dvh - 80px);
max-height: calc(100dvh - 80px);
min-height: 0;
overflow: hidden;
box-sizing: border-box;
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
color: #d6dcf2;
} }
.active-view h2 { .status {
margin: 0 0 0.5rem; margin: 2rem auto;
font-size: 1.1rem; max-width: 880px;
color: #93a0d0;
font-size: 0.95rem;
text-align: center;
} }
.active-view p { .status.error {
margin: 0; color: #e08585;
color: #555;
} }
</style> </style>
+32 -3
View File
@@ -21,6 +21,8 @@ preference the store already manages.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext, onDestroy, onMount, untrack } from "svelte"; import { getContext, onDestroy, onMount, untrack } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { import {
createRenderer, createRenderer,
@@ -402,13 +404,40 @@ preference the store already manages.
if (selection === undefined) return; if (selection === undefined) return;
const hit = handle.hitAt(cursorPx); const hit = handle.hitAt(cursorPx);
if (hit === null) return; if (hit === null) return;
if (hit.primitive.kind !== "point") return;
const target = hitLookup.get(hit.primitive.id); const target = hitLookup.get(hit.primitive.id);
if (target === undefined) return; if (target === undefined) return;
if (target.kind === "planet") { switch (target.kind) {
case "planet":
if (hit.primitive.kind !== "point") return;
selection.selectPlanet(target.number); selection.selectPlanet(target.number);
} else { break;
case "shipGroup":
if (hit.primitive.kind !== "point") return;
selection.selectShipGroup(target.ref); selection.selectShipGroup(target.ref);
break;
case "battle": {
const gameId = page.params.id ?? "";
const turn = store?.report?.turn ?? 0;
void goto(
`/games/${gameId}/battle/${target.battleId}?turn=${turn}`,
);
break;
}
case "bombing": {
const gameId = page.params.id ?? "";
void goto(
`/games/${gameId}/report#report-bombings`,
).then(() => {
if (typeof document === "undefined") return;
const row = document.querySelector(
`[data-testid="report-bombing-row"][data-planet="${target.planet}"]`,
);
if (row && row.scrollIntoView) {
row.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
break;
}
} }
} }
@@ -1,13 +1,14 @@
<!-- <!--
Phase 23 Report View — battles section. The wire only carries Phase 27 Report View — battles section. Each row is a link into the
battle UUIDs (the full battle report is fetched lazily by Phase 27), Battle Viewer at `/games/<id>/battle/<uuid>?turn=<turn>` where
so each row is a monospace, non-interactive `<span>` of the battle `turn` follows the current report's turn so history-mode views land
identifier. Phase 27 will turn each row into a link to on the right battle. Phase 23 rendered the same rows as inactive
`/games/<id>/battle/<uuid>`; until then dead links are worse than monospace `<span>`; the rewire here is the one-liner the Phase 23
plain text. decision log called out.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
import { page } from "$app/state";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { import {
@@ -19,7 +20,9 @@ plain text.
RENDERED_REPORT_CONTEXT_KEY, RENDERED_REPORT_CONTEXT_KEY,
); );
const report = $derived(rendered?.report ?? null); 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> </script>
<section <section
@@ -31,22 +34,23 @@ plain text.
{#if report === null} {#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p> <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"> <p class="status" data-testid="battles-empty">
{i18n.t("game.report.section.battles.empty")} {i18n.t("game.report.section.battles.empty")}
</p> </p>
{:else} {:else}
<ul class="ids" data-testid="battles-list"> <ul class="ids" data-testid="battles-list">
{#each ids as id (id)} {#each battles as b (b.id)}
<li> <li>
<span class="label"> <span class="label">
{i18n.t("game.report.section.battles.id_label")} {i18n.t("game.report.section.battles.id_label")}
</span> </span>
<span <a
class="uuid" class="uuid"
href={`/games/${gameId}/battle/${b.id}?turn=${turn}`}
data-testid="report-battle-row" data-testid="report-battle-row"
data-id={id} data-id={b.id}
>{id}</span> >{b.id}</a>
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -87,5 +91,10 @@ plain text.
.uuid { .uuid {
color: #cfd7ff; color: #cfd7ff;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
text-decoration: underline;
text-underline-offset: 2px;
}
.uuid:hover {
color: #ffffff;
} }
</style> </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);
}
+112 -19
View File
@@ -27,6 +27,10 @@ const PREF_KEY_WRAP_MODE = (gameId: string) => `${gameId}/wrap-mode`;
const PREF_KEY_LAST_VIEWED_TURN = (gameId: string) => const PREF_KEY_LAST_VIEWED_TURN = (gameId: string) =>
`${gameId}/last-viewed-turn`; `${gameId}/last-viewed-turn`;
const HISTORY_NAMESPACE = "game-history";
const HISTORY_KEY_TURN = (gameId: string, turn: number) =>
`${gameId}/turn/${turn}`;
/** /**
* GAME_STATE_CONTEXT_KEY is the Svelte context key the in-game shell * GAME_STATE_CONTEXT_KEY is the Svelte context key the in-game shell
* layout uses to expose its `GameStateStore` instance to descendants. * layout uses to expose its `GameStateStore` instance to descendants.
@@ -55,9 +59,30 @@ export class GameStateStore {
* game (lifted from the lobby record on `setGame`). Phase 14 * game (lifted from the lobby record on `setGame`). Phase 14
* exposes it so the layout can pass it to * exposes it so the layout can pass it to
* `OrderDraftStore.hydrateFromServer` after both stores boot; * `OrderDraftStore.hydrateFromServer` after both stores boot;
* later phases (history mode, calc) will read it directly. * Phase 26 keeps the "authoritative server-side turn" meaning —
* only `setGame`, `advanceToPending`, and the visibility-listener
* lobby re-query update it. History navigation (`viewTurn`) leaves
* it alone so the "Return to current turn" affordance keeps a
* reliable target.
*/ */
currentTurn = $state(0); currentTurn = $state(0);
/**
* viewedTurn is the turn whose snapshot is currently displayed.
* In live mode it equals `currentTurn`. Phase 26 history mode
* decouples the two: `viewTurn(N)` flips this rune (and `report`)
* to N without touching `currentTurn` or `last-viewed-turn`.
*/
viewedTurn = $state(0);
/**
* historyMode is the derived "user is viewing a past turn" rune
* consumed by Phase 12 sidebar / bottom-tabs wiring, the Phase 26
* history banner, the rendered-report overlay short-circuit, and
* the order-draft mutation gate. It depends only on the rune state
* above, so every consumer reacts to a single source of truth.
*/
historyMode = $derived(
this.status === "ready" && this.viewedTurn < this.currentTurn,
);
/** /**
* synthetic is set by `initSynthetic` for DEV-only sessions backed * synthetic is set by `initSynthetic` for DEV-only sessions backed
* by a hand-loaded report (lobby's "Load synthetic report" * by a hand-loaded report (lobby's "Load synthetic report"
@@ -140,16 +165,18 @@ export class GameStateStore {
// server-side current turn, open the user on their last-seen // server-side current turn, open the user on their last-seen
// snapshot and surface the gap through `pendingTurn` so the // snapshot and surface the gap through `pendingTurn` so the
// shell can render a "new turn available" affordance instead // shell can render a "new turn available" affordance instead
// of silently auto-advancing. // of silently auto-advancing. After Phase 26 the same gap
// also flips `historyMode` to true (viewedTurn < currentTurn),
// so the read-only banner appears alongside the toast.
if ( if (
lastViewed !== null && lastViewed !== null &&
lastViewed >= 0 && lastViewed >= 0 &&
lastViewed < summary.currentTurn lastViewed < summary.currentTurn
) { ) {
this.pendingTurn = summary.currentTurn; this.pendingTurn = summary.currentTurn;
await this.loadTurn(lastViewed); await this.loadTurn(lastViewed, { isCurrent: false });
} else { } else {
await this.loadTurn(summary.currentTurn); await this.loadTurn(summary.currentTurn, { isCurrent: true });
} }
} catch (err) { } catch (err) {
if (this.destroyed) return; if (this.destroyed) return;
@@ -196,7 +223,7 @@ export class GameStateStore {
} }
this.gameName = summary.gameName; this.gameName = summary.gameName;
this.currentTurn = summary.currentTurn; this.currentTurn = summary.currentTurn;
await this.loadTurn(summary.currentTurn); await this.loadTurn(summary.currentTurn, { isCurrent: true });
this.pendingTurn = null; this.pendingTurn = null;
} catch (err) { } catch (err) {
if (this.destroyed) return; if (this.destroyed) return;
@@ -206,29 +233,57 @@ export class GameStateStore {
} }
/** /**
* setTurn loads a different turn snapshot — used by Phase 26 history * viewTurn loads the historical snapshot for `turn` and switches the
* mode. The current turn stays at whatever `setGame` discovered; * UI into history mode (Phase 26). The current turn is untouched —
* calling without an argument refetches the same turn. * `historyMode` flips on automatically through the derived rune, and
* the `last-viewed-turn` cache is only refreshed when the caller
* happens to ask for the currentTurn (e.g. `returnToCurrent`). A
* cache hit on `game-history/{gameId}/turn/{N}` skips the network;
* past turns are immutable so the cache never goes stale.
*/ */
async setTurn(turn: number): Promise<void> { async viewTurn(turn: number): Promise<void> {
if (this.client === null) return; if (this.client === null) return;
if (!Number.isFinite(turn) || turn < 0 || turn > this.currentTurn) {
return;
}
this.status = "loading"; this.status = "loading";
this.error = null; this.error = null;
try { try {
await this.loadTurn(turn); await this.loadTurn(turn, { isCurrent: turn === this.currentTurn });
} catch (err) { } catch (err) {
if (this.destroyed) return;
this.status = "error"; this.status = "error";
this.error = describe(err); this.error = describe(err);
} }
} }
/** /**
* refresh re-fetches the report at the current turn. Called on * returnToCurrent jumps back to the server's current turn after a
* window `visibilitychange` so the map and the turn counter stay * history excursion. Thin wrapper around `viewTurn(currentTurn)` so
* fresh after the user returns to the tab. * the banner / popover share the same call site.
*/ */
refresh(): Promise<void> { returnToCurrent(): Promise<void> {
return this.setTurn(this.currentTurn); return this.viewTurn(this.currentTurn);
}
/**
* refresh is fired from the `visibilitychange` listener. In live
* mode it re-fetches the report at the current turn so the map and
* the counter catch up after the user returns to the tab. In
* history mode it is a no-op: the user is intentionally viewing a
* past turn, push events (Phase 24) deliver new-turn notifications
* asynchronously, and forcing a reload would silently bump the
* user out of history mode.
*/
async refresh(): Promise<void> {
if (this.client === null) return;
if (this.historyMode) return;
try {
await this.loadTurn(this.currentTurn, { isCurrent: true });
} catch (err) {
if (this.destroyed) return;
console.warn("game-state: refresh failed", err);
}
} }
/** /**
@@ -276,6 +331,7 @@ export class GameStateStore {
this.wrapMode = await readWrapMode(opts.cache, opts.gameId); this.wrapMode = await readWrapMode(opts.cache, opts.gameId);
this.report = opts.report; this.report = opts.report;
this.currentTurn = opts.report.turn; this.currentTurn = opts.report.turn;
this.viewedTurn = opts.report.turn;
this.status = "ready"; this.status = "ready";
} }
@@ -295,20 +351,57 @@ export class GameStateStore {
return games.find((g) => g.gameId === gameId) ?? null; return games.find((g) => g.gameId === gameId) ?? null;
} }
private async loadTurn(turn: number): Promise<void> { private async loadTurn(
turn: number,
opts: { isCurrent: boolean },
): Promise<void> {
if (this.client === null) return; if (this.client === null) return;
const report = await fetchGameReport(this.client, this.gameId, turn); const report = await this.readReport(turn, opts.isCurrent);
if (this.destroyed) return; if (this.destroyed) return;
this.report = report; this.report = report;
this.currentTurn = turn; this.viewedTurn = turn;
this.status = "ready"; this.status = "ready";
if (this.cache !== null) { if (this.cache === null) return;
if (opts.isCurrent) {
// Persist last-viewed-turn only when the user is caught up
// on the live snapshot. Historical excursions are ephemeral
// (Phase 26 decision): the resume-on-open affordance from
// Phase 11 must keep meaning "the latest turn this player
// was caught up on", not "wherever they last clicked".
await this.cache.put( await this.cache.put(
PREF_NAMESPACE, PREF_NAMESPACE,
PREF_KEY_LAST_VIEWED_TURN(this.gameId), PREF_KEY_LAST_VIEWED_TURN(this.gameId),
turn, turn,
); );
return;
} }
// Past turns are immutable, so the snapshot is safe to cache
// for fast re-entry. The current-turn snapshot deliberately
// skips the cache — it is mutable until the next tick.
await this.cache.put(
HISTORY_NAMESPACE,
HISTORY_KEY_TURN(this.gameId, turn),
report,
);
}
private async readReport(
turn: number,
isCurrent: boolean,
): Promise<GameReport> {
if (this.client === null) {
throw new Error("game-state: readReport called without client");
}
if (!isCurrent && this.cache !== null) {
const cached = await this.cache.get<GameReport>(
HISTORY_NAMESPACE,
HISTORY_KEY_TURN(this.gameId, turn),
);
if (cached !== undefined && cached.turn === turn) {
return cached;
}
}
return await fetchGameReport(this.client, this.gameId, turn);
} }
private installVisibilityListener(): void { private installVisibilityListener(): void {
+11 -21
View File
@@ -1,8 +1,10 @@
<!-- <!--
Top header for the in-game shell. Composes the in-game ID strip Top header for the in-game shell. Composes the in-game ID strip
(race name @ game name, turn N), view dropdown / hamburger, and the (race name @ game name) followed by the Phase 26 turn navigator (a
account menu. The sidebar-toggle slot to its left appears only on `← Turn N →` triplet with a popover of every turn), the view
tablet viewports (7681024 px) and is wired by `+layout.svelte`. dropdown / hamburger, and the account menu. The sidebar-toggle slot
to its left appears only on tablet viewports (7681024 px) and is
wired by `+layout.svelte`.
The race name is read from the engine's `Report.race`, the game The race name is read from the engine's `Report.race`, the game
name from the lobby's `GameSummary.gameName`. While either piece name from the lobby's `GameSummary.gameName`. While either piece
@@ -22,6 +24,7 @@ absent until Phase 24 wires push-event state.
} from "$lib/game-state.svelte"; } from "$lib/game-state.svelte";
import ViewMenu from "./view-menu.svelte"; import ViewMenu from "./view-menu.svelte";
import AccountMenu from "./account-menu.svelte"; import AccountMenu from "./account-menu.svelte";
import TurnNavigator from "./turn-navigator.svelte";
type Props = { type Props = {
gameId: string; gameId: string;
@@ -44,27 +47,14 @@ absent until Phase 24 wires push-event state.
const name = gameState?.gameName ?? ""; const name = gameState?.gameName ?? "";
return name === "" ? i18n.t("game.shell.unknown") : name; return name === "" ? i18n.t("game.shell.unknown") : name;
}); });
const turn = $derived.by(() => {
const report = gameState?.report;
return report === null || report === undefined
? i18n.t("game.shell.unknown")
: String(report.turn);
});
const headline = $derived(
i18n.t("game.shell.headline", {
race: raceName,
game: gameName,
turn,
}),
);
</script> </script>
<header class="game-shell-header" data-testid="game-shell-header"> <header class="game-shell-header" data-testid="game-shell-header">
<div class="left"> <div class="left" data-testid="game-shell-headline">
<span class="headline" data-testid="game-shell-headline"> <span class="identity" data-testid="game-shell-identity">
{headline} {raceName} @ {gameName}
</span> </span>
<TurnNavigator />
</div> </div>
<div class="right"> <div class="right">
<button <button
@@ -106,7 +96,7 @@ absent until Phase 24 wires push-event state.
gap: 0.75rem; gap: 0.75rem;
min-width: 0; min-width: 0;
} }
.headline { .identity {
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -0,0 +1,80 @@
<!--
Phase 26 read-only banner. Renders directly under the shell header
whenever the user is viewing a past turn (`gameState.historyMode`).
Carries the turn number and a "Return to current turn" action that
delegates to `gameState.returnToCurrent()`. The banner is invisible
in live mode so the active-view chrome keeps its full vertical
budget.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
} from "$lib/game-state.svelte";
const gameState = getContext<GameStateStore | undefined>(
GAME_STATE_CONTEXT_KEY,
);
const viewedTurn = $derived(gameState?.viewedTurn ?? 0);
const visible = $derived(gameState?.historyMode === true);
function onReturn(): void {
void gameState?.returnToCurrent();
}
</script>
{#if visible}
<aside class="history-banner" data-testid="history-banner" role="status">
<span class="message">
{i18n.t("game.shell.history.viewing", { turn: String(viewedTurn) })}
</span>
<button
type="button"
class="return"
data-testid="history-banner-return"
onclick={onReturn}
>
{i18n.t("game.shell.history.return_to_current")}
</button>
</aside>
{/if}
<style>
.history-banner {
position: sticky;
top: 3rem;
z-index: 35;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0.9rem;
background: #2a2438;
color: #efe9c8;
border-bottom: 1px solid #45375a;
font-family: system-ui, sans-serif;
font-size: 0.9rem;
}
.message {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.return {
font: inherit;
padding: 0.25rem 0.65rem;
background: transparent;
color: inherit;
border: 1px solid #6c5a8a;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.return:hover {
background: #3a3050;
}
</style>
@@ -0,0 +1,263 @@
<!--
Phase 26 header turn navigator. Replaces the Phase 11 inline turn
number with a `← Turn N →` triplet. The arrows step ±1 (disabled at
boundaries `0` and `currentTurn`), the middle button opens a popover
listing every turn `Turn #0`…`Turn #currentTurn` with the current row
tagged. No free-text input — every reachable turn is in the list, so
there is nothing to validate.
Desktop and mobile share the same component: an absolute-positioned
popover anchored to the trigger on desktop becomes a fixed full-width
drawer below the 768 px breakpoint, mirroring `view-menu.svelte`.
Selecting a row calls `gameState.viewTurn(N)`; the row that matches
`currentTurn` delegates to `gameState.returnToCurrent()` so the
"leave history" path always flows through one method.
-->
<script lang="ts">
import { getContext, onMount } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
} from "$lib/game-state.svelte";
const gameState = getContext<GameStateStore | undefined>(
GAME_STATE_CONTEXT_KEY,
);
let open = $state(false);
let rootEl: HTMLDivElement | null = $state(null);
const currentTurn = $derived(gameState?.currentTurn ?? 0);
const viewedTurn = $derived(gameState?.viewedTurn ?? 0);
const ready = $derived(gameState?.status === "ready");
const canPrev = $derived(ready && viewedTurn > 0);
const canNext = $derived(ready && viewedTurn < currentTurn);
// Until the boot completes the store has no report, so the
// counter falls back to the same `?` placeholder Phase 11 used in
// the static headline. Avoids briefly flashing `turn 0` while the
// lobby / report calls are in flight.
const turnText = $derived(
ready ? String(viewedTurn) : i18n.t("game.shell.unknown"),
);
// Descending list newest → oldest. The popover stays compact for
// short games and scrolls for long ones; the current-turn row is
// pinned at the top with an explicit badge so the affordance to
// jump back is always reachable without scrolling.
const turns = $derived.by(() => {
if (!ready) return [] as number[];
const out: number[] = [];
for (let i = currentTurn; i >= 0; i--) {
out.push(i);
}
return out;
});
function toggleOpen(): void {
if (!ready) return;
open = !open;
}
async function goToTurn(turn: number): Promise<void> {
open = false;
if (gameState === undefined) return;
if (turn === gameState.currentTurn) {
await gameState.returnToCurrent();
return;
}
await gameState.viewTurn(turn);
}
async function step(delta: number): Promise<void> {
if (gameState === undefined) return;
const next = viewedTurn + delta;
if (next < 0 || next > currentTurn) return;
if (next === gameState.currentTurn) {
await gameState.returnToCurrent();
return;
}
await gameState.viewTurn(next);
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape" && open) {
open = false;
}
}
onMount(() => {
const handleClick = (event: MouseEvent): void => {
if (!open || rootEl === null) return;
const target = event.target;
if (target instanceof Node && rootEl.contains(target)) return;
open = false;
};
document.addEventListener("click", handleClick, true);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("click", handleClick, true);
document.removeEventListener("keydown", onKeyDown);
};
});
</script>
<div class="turn-navigator" bind:this={rootEl} data-testid="turn-navigator">
<button
type="button"
class="step"
data-testid="turn-navigator-prev"
aria-label={i18n.t("game.shell.turn.prev")}
disabled={!canPrev}
onclick={() => void step(-1)}
>
</button>
<button
type="button"
class="trigger"
data-testid="turn-navigator-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label={open
? i18n.t("game.shell.turn.close_navigator")
: i18n.t("game.shell.turn.open_navigator")}
disabled={!ready}
onclick={toggleOpen}
>
{i18n.t("game.shell.turn.label", { turn: turnText })}
</button>
<button
type="button"
class="step"
data-testid="turn-navigator-next"
aria-label={i18n.t("game.shell.turn.next")}
disabled={!canNext}
onclick={() => void step(1)}
>
</button>
{#if open}
<div class="surface" role="menu" data-testid="turn-navigator-list">
{#each turns as turn (turn)}
<button
type="button"
role="menuitem"
class="row"
class:viewed={turn === viewedTurn}
data-testid={`turn-navigator-item-${turn}`}
onclick={() => void goToTurn(turn)}
>
<span class="label">
{i18n.t("game.shell.turn.list_item", { turn: String(turn) })}
</span>
{#if turn === currentTurn}
<span class="badge" data-testid="turn-navigator-current-badge">
{i18n.t("game.shell.history.current_badge")}
</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<style>
.turn-navigator {
position: relative;
display: inline-flex;
align-items: stretch;
gap: 0.25rem;
}
.step,
.trigger {
font: inherit;
font-size: 1rem;
padding: 0.25rem 0.55rem;
background: transparent;
color: inherit;
border: 1px solid #2a3150;
border-radius: 4px;
cursor: pointer;
line-height: 1;
}
.step:hover:not(:disabled),
.trigger:hover:not(:disabled) {
background: #1c2238;
}
.step:disabled,
.trigger:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.trigger {
min-width: 5rem;
font-weight: 500;
}
.surface {
position: absolute;
top: calc(100% + 0.25rem);
left: 0;
min-width: 10rem;
max-height: 18rem;
overflow-y: auto;
display: flex;
flex-direction: column;
background: #14182a;
border: 1px solid #2a3150;
border-radius: 6px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
z-index: 50;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
text-align: left;
font: inherit;
padding: 0.4rem 0.75rem;
background: transparent;
color: inherit;
border: 0;
cursor: pointer;
}
.row:hover {
background: #1c2238;
}
.row.viewed {
font-weight: 600;
background: #1a2040;
}
.label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
flex: 0 0 auto;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.05rem 0.4rem;
background: #2a3150;
color: #d8def0;
border-radius: 999px;
}
@media (max-width: 767.98px) {
.surface {
position: fixed;
top: 3rem;
right: 0;
left: 0;
min-width: 0;
max-height: calc(100vh - 3rem);
border-radius: 0;
border-left: 0;
border-right: 0;
}
}
</style>
+30 -1
View File
@@ -89,7 +89,6 @@ const en = {
"lobby.error.unknown": "{message}", "lobby.error.unknown": "{message}",
"game.shell.unknown": "?", "game.shell.unknown": "?",
"game.shell.headline": "{race} @ {game}, turn {turn}",
"game.shell.connection.online": "online", "game.shell.connection.online": "online",
"game.shell.connection.reconnecting": "reconnecting…", "game.shell.connection.reconnecting": "reconnecting…",
"game.shell.connection.offline": "offline", "game.shell.connection.offline": "offline",
@@ -104,6 +103,15 @@ const en = {
"game.shell.menu.language": "language", "game.shell.menu.language": "language",
"game.shell.menu.logout": "logout", "game.shell.menu.logout": "logout",
"game.shell.coming_soon": "coming soon", "game.shell.coming_soon": "coming soon",
"game.shell.turn.label": "turn {turn}",
"game.shell.turn.list_item": "turn #{turn}",
"game.shell.turn.prev": "previous turn",
"game.shell.turn.next": "next turn",
"game.shell.turn.open_navigator": "open turn list",
"game.shell.turn.close_navigator": "close turn list",
"game.shell.history.viewing": "Viewing turn {turn} · read-only",
"game.shell.history.return_to_current": "Return to current turn",
"game.shell.history.current_badge": "current",
"game.view.map": "map", "game.view.map": "map",
"game.view.table": "table", "game.view.table": "table",
"game.view.table.planets": "planets", "game.view.table.planets": "planets",
@@ -475,6 +483,27 @@ const en = {
"game.report.section.battles.title": "battles", "game.report.section.battles.title": "battles",
"game.report.section.battles.empty": "no battles last turn", "game.report.section.battles.empty": "no battles last turn",
"game.report.section.battles.id_label": "battle", "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.title": "bombings",
"game.report.section.bombings.empty": "no bombings last turn", "game.report.section.bombings.empty": "no bombings last turn",
"game.report.section.bombings.column.planet": "planet", "game.report.section.bombings.column.planet": "planet",
+30 -1
View File
@@ -90,7 +90,6 @@ const ru: Record<keyof typeof en, string> = {
"lobby.error.unknown": "{message}", "lobby.error.unknown": "{message}",
"game.shell.unknown": "?", "game.shell.unknown": "?",
"game.shell.headline": "{race} @ {game}, ход {turn}",
"game.shell.connection.online": "онлайн", "game.shell.connection.online": "онлайн",
"game.shell.connection.reconnecting": "переподключение…", "game.shell.connection.reconnecting": "переподключение…",
"game.shell.connection.offline": "офлайн", "game.shell.connection.offline": "офлайн",
@@ -105,6 +104,15 @@ const ru: Record<keyof typeof en, string> = {
"game.shell.menu.language": "язык", "game.shell.menu.language": "язык",
"game.shell.menu.logout": "выйти", "game.shell.menu.logout": "выйти",
"game.shell.coming_soon": "скоро будет", "game.shell.coming_soon": "скоро будет",
"game.shell.turn.label": "ход {turn}",
"game.shell.turn.list_item": "ход #{turn}",
"game.shell.turn.prev": "предыдущий ход",
"game.shell.turn.next": "следующий ход",
"game.shell.turn.open_navigator": "открыть список ходов",
"game.shell.turn.close_navigator": "закрыть список ходов",
"game.shell.history.viewing": "Просмотр хода {turn} · только чтение",
"game.shell.history.return_to_current": "Вернуться к текущему ходу",
"game.shell.history.current_badge": "текущий",
"game.view.map": "карта", "game.view.map": "карта",
"game.view.table": "таблица", "game.view.table": "таблица",
"game.view.table.planets": "планеты", "game.view.table.planets": "планеты",
@@ -476,6 +484,27 @@ const ru: Record<keyof typeof en, string> = {
"game.report.section.battles.title": "сражения", "game.report.section.battles.title": "сражения",
"game.report.section.battles.empty": "сражений в этом ходу не было", "game.report.section.battles.empty": "сражений в этом ходу не было",
"game.report.section.battles.id_label": "сражение", "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.title": "бомбардировки",
"game.report.section.bombings.empty": "бомбардировок в этом ходу не было", "game.report.section.bombings.empty": "бомбардировок в этом ходу не было",
"game.report.section.bombings.column.planet": "планета", "game.report.section.bombings.column.planet": "планета",
@@ -37,6 +37,12 @@ export interface RenderedReportSource {
* underlying `$state` accesses inside `applyOrderOverlay`, so any * underlying `$state` accesses inside `applyOrderOverlay`, so any
* change to the report or the draft re-runs every dependent * change to the report or the draft re-runs every dependent
* `$derived` block. * `$derived` block.
*
* Phase 26: the order draft is composed against the *current* turn,
* so projecting it onto a historical snapshot would render fictional
* intent on a past report. In history mode the getter returns the
* raw server snapshot untouched — the order tab is hidden anyway and
* mutations are gated at the store, so nothing else needs to know.
*/ */
export function createRenderedReportSource( export function createRenderedReportSource(
gameState: GameStateStore, gameState: GameStateStore,
@@ -46,6 +52,7 @@ export function createRenderedReportSource(
get report(): GameReport | null { get report(): GameReport | null {
const raw = gameState.report; const raw = gameState.report;
if (raw === null) return null; if (raw === null) return null;
if (gameState.historyMode) return raw;
return applyOrderOverlay(raw, orderDraft.commands, orderDraft.statuses); return applyOrderOverlay(raw, orderDraft.commands, orderDraft.statuses);
}, },
}; };
+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 { GameReport, ReportPlanet } from "../api/game-state";
import type { ShipGroupRef } from "../lib/selection.svelte"; import type { ShipGroupRef } from "../lib/selection.svelte";
import { buildBattleAndBombingMarkers } from "./battle-markers";
import { shipGroupsToPrimitives } from "./ship-groups"; import { shipGroupsToPrimitives } from "./ship-groups";
import { World, type Primitive, type PrimitiveID, type Style } from "./world"; import { World, type Primitive, type PrimitiveID, type Style } from "./world";
@@ -83,7 +84,9 @@ function priorityFor(kind: ReportPlanet["kind"]): number {
*/ */
export type HitTarget = export type HitTarget =
| { kind: "planet"; number: number } | { 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 { export interface ReportToWorldResult {
world: World; world: World;
@@ -127,6 +130,14 @@ export function reportToWorld(report: GameReport): ReportToWorldResult {
hitLookup.set(primId, { kind: "shipGroup", ref }); 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 width = report.mapWidth > 0 ? report.mapWidth : 1;
const height = report.mapHeight > 0 ? report.mapHeight : 1; const height = report.mapHeight > 0 ? report.mapHeight : 1;
return { world: new World(width, height, primitives), hitLookup }; return { world: new World(width, height, primitives), hitLookup };
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class ApplicationSubmitResponse implements flatbuffers.IUnpackableObject<ApplicationSubmitResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class ErrorResponse implements flatbuffers.IUnpackableObject<ErrorResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class GameCreateResponse implements flatbuffers.IUnpackableObject<GameCreateResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class InviteDeclineResponse implements flatbuffers.IUnpackableObject<InviteDeclineResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class InviteRedeemResponse implements flatbuffers.IUnpackableObject<InviteRedeemResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class MyApplicationsListResponse implements flatbuffers.IUnpackableObject<MyApplicationsListResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class MyGamesListResponse implements flatbuffers.IUnpackableObject<MyGamesListResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class MyInvitesListResponse implements flatbuffers.IUnpackableObject<MyInvitesListResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class PublicGamesListResponse implements flatbuffers.IUnpackableObject<PublicGamesListResponseT> {
@@ -4,30 +4,30 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { CommandFleetMerge, CommandFleetMergeT } from './command-fleet-merge.js'; import { CommandFleetMerge, CommandFleetMergeT } from '../order/command-fleet-merge.js';
import { CommandFleetSend, CommandFleetSendT } from './command-fleet-send.js'; import { CommandFleetSend, CommandFleetSendT } from '../order/command-fleet-send.js';
import { CommandPayload, unionToCommandPayload, unionListToCommandPayload } from './command-payload.js'; import { CommandPayload, unionToCommandPayload, unionListToCommandPayload } from '../order/command-payload.js';
import { CommandPlanetProduce, CommandPlanetProduceT } from './command-planet-produce.js'; import { CommandPlanetProduce, CommandPlanetProduceT } from '../order/command-planet-produce.js';
import { CommandPlanetRename, CommandPlanetRenameT } from './command-planet-rename.js'; import { CommandPlanetRename, CommandPlanetRenameT } from '../order/command-planet-rename.js';
import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from './command-planet-route-remove.js'; import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from '../order/command-planet-route-remove.js';
import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from './command-planet-route-set.js'; import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from '../order/command-planet-route-set.js';
import { CommandRaceQuit, CommandRaceQuitT } from './command-race-quit.js'; import { CommandRaceQuit, CommandRaceQuitT } from '../order/command-race-quit.js';
import { CommandRaceRelation, CommandRaceRelationT } from './command-race-relation.js'; import { CommandRaceRelation, CommandRaceRelationT } from '../order/command-race-relation.js';
import { CommandRaceVote, CommandRaceVoteT } from './command-race-vote.js'; import { CommandRaceVote, CommandRaceVoteT } from '../order/command-race-vote.js';
import { CommandScienceCreate, CommandScienceCreateT } from './command-science-create.js'; import { CommandScienceCreate, CommandScienceCreateT } from '../order/command-science-create.js';
import { CommandScienceRemove, CommandScienceRemoveT } from './command-science-remove.js'; import { CommandScienceRemove, CommandScienceRemoveT } from '../order/command-science-remove.js';
import { CommandShipClassCreate, CommandShipClassCreateT } from './command-ship-class-create.js'; import { CommandShipClassCreate, CommandShipClassCreateT } from '../order/command-ship-class-create.js';
import { CommandShipClassMerge, CommandShipClassMergeT } from './command-ship-class-merge.js'; import { CommandShipClassMerge, CommandShipClassMergeT } from '../order/command-ship-class-merge.js';
import { CommandShipClassRemove, CommandShipClassRemoveT } from './command-ship-class-remove.js'; import { CommandShipClassRemove, CommandShipClassRemoveT } from '../order/command-ship-class-remove.js';
import { CommandShipGroupBreak, CommandShipGroupBreakT } from './command-ship-group-break.js'; import { CommandShipGroupBreak, CommandShipGroupBreakT } from '../order/command-ship-group-break.js';
import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from './command-ship-group-dismantle.js'; import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from '../order/command-ship-group-dismantle.js';
import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from './command-ship-group-join-fleet.js'; import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from '../order/command-ship-group-join-fleet.js';
import { CommandShipGroupLoad, CommandShipGroupLoadT } from './command-ship-group-load.js'; import { CommandShipGroupLoad, CommandShipGroupLoadT } from '../order/command-ship-group-load.js';
import { CommandShipGroupMerge, CommandShipGroupMergeT } from './command-ship-group-merge.js'; import { CommandShipGroupMerge, CommandShipGroupMergeT } from '../order/command-ship-group-merge.js';
import { CommandShipGroupSend, CommandShipGroupSendT } from './command-ship-group-send.js'; import { CommandShipGroupSend, CommandShipGroupSendT } from '../order/command-ship-group-send.js';
import { CommandShipGroupTransfer, CommandShipGroupTransferT } from './command-ship-group-transfer.js'; import { CommandShipGroupTransfer, CommandShipGroupTransferT } from '../order/command-ship-group-transfer.js';
import { CommandShipGroupUnload, CommandShipGroupUnloadT } from './command-ship-group-unload.js'; import { CommandShipGroupUnload, CommandShipGroupUnloadT } from '../order/command-ship-group-unload.js';
import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from './command-ship-group-upgrade.js'; import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from '../order/command-ship-group-upgrade.js';
export class CommandItem implements flatbuffers.IUnpackableObject<CommandItemT> { 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 */ /* 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 { CommandFleetMerge, CommandFleetMergeT } from '../order/command-fleet-merge.js';
import { CommandFleetSend, CommandFleetSendT } from './command-fleet-send.js'; import { CommandFleetSend, CommandFleetSendT } from '../order/command-fleet-send.js';
import { CommandPlanetProduce, CommandPlanetProduceT } from './command-planet-produce.js'; import { CommandPlanetProduce, CommandPlanetProduceT } from '../order/command-planet-produce.js';
import { CommandPlanetRename, CommandPlanetRenameT } from './command-planet-rename.js'; import { CommandPlanetRename, CommandPlanetRenameT } from '../order/command-planet-rename.js';
import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from './command-planet-route-remove.js'; import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from '../order/command-planet-route-remove.js';
import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from './command-planet-route-set.js'; import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from '../order/command-planet-route-set.js';
import { CommandRaceQuit, CommandRaceQuitT } from './command-race-quit.js'; import { CommandRaceQuit, CommandRaceQuitT } from '../order/command-race-quit.js';
import { CommandRaceRelation, CommandRaceRelationT } from './command-race-relation.js'; import { CommandRaceRelation, CommandRaceRelationT } from '../order/command-race-relation.js';
import { CommandRaceVote, CommandRaceVoteT } from './command-race-vote.js'; import { CommandRaceVote, CommandRaceVoteT } from '../order/command-race-vote.js';
import { CommandScienceCreate, CommandScienceCreateT } from './command-science-create.js'; import { CommandScienceCreate, CommandScienceCreateT } from '../order/command-science-create.js';
import { CommandScienceRemove, CommandScienceRemoveT } from './command-science-remove.js'; import { CommandScienceRemove, CommandScienceRemoveT } from '../order/command-science-remove.js';
import { CommandShipClassCreate, CommandShipClassCreateT } from './command-ship-class-create.js'; import { CommandShipClassCreate, CommandShipClassCreateT } from '../order/command-ship-class-create.js';
import { CommandShipClassMerge, CommandShipClassMergeT } from './command-ship-class-merge.js'; import { CommandShipClassMerge, CommandShipClassMergeT } from '../order/command-ship-class-merge.js';
import { CommandShipClassRemove, CommandShipClassRemoveT } from './command-ship-class-remove.js'; import { CommandShipClassRemove, CommandShipClassRemoveT } from '../order/command-ship-class-remove.js';
import { CommandShipGroupBreak, CommandShipGroupBreakT } from './command-ship-group-break.js'; import { CommandShipGroupBreak, CommandShipGroupBreakT } from '../order/command-ship-group-break.js';
import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from './command-ship-group-dismantle.js'; import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from '../order/command-ship-group-dismantle.js';
import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from './command-ship-group-join-fleet.js'; import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from '../order/command-ship-group-join-fleet.js';
import { CommandShipGroupLoad, CommandShipGroupLoadT } from './command-ship-group-load.js'; import { CommandShipGroupLoad, CommandShipGroupLoadT } from '../order/command-ship-group-load.js';
import { CommandShipGroupMerge, CommandShipGroupMergeT } from './command-ship-group-merge.js'; import { CommandShipGroupMerge, CommandShipGroupMergeT } from '../order/command-ship-group-merge.js';
import { CommandShipGroupSend, CommandShipGroupSendT } from './command-ship-group-send.js'; import { CommandShipGroupSend, CommandShipGroupSendT } from '../order/command-ship-group-send.js';
import { CommandShipGroupTransfer, CommandShipGroupTransferT } from './command-ship-group-transfer.js'; import { CommandShipGroupTransfer, CommandShipGroupTransferT } from '../order/command-ship-group-transfer.js';
import { CommandShipGroupUnload, CommandShipGroupUnloadT } from './command-ship-group-unload.js'; import { CommandShipGroupUnload, CommandShipGroupUnloadT } from '../order/command-ship-group-unload.js';
import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from './command-ship-group-upgrade.js'; import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from '../order/command-ship-group-upgrade.js';
export enum CommandPayload { export enum CommandPayload {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class CommandPlanetProduce implements flatbuffers.IUnpackableObject<CommandPlanetProduceT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class CommandPlanetRouteRemove implements flatbuffers.IUnpackableObject<CommandPlanetRouteRemoveT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class CommandPlanetRouteSet implements flatbuffers.IUnpackableObject<CommandPlanetRouteSetT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { Relation } from './relation.js'; import { Relation } from '../order/relation.js';
export class CommandRaceRelation implements flatbuffers.IUnpackableObject<CommandRaceRelationT> { export class CommandRaceRelation implements flatbuffers.IUnpackableObject<CommandRaceRelationT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class CommandShipGroupLoad implements flatbuffers.IUnpackableObject<CommandShipGroupLoadT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class CommandShipGroupUpgrade implements flatbuffers.IUnpackableObject<CommandShipGroupUpgradeT> {
@@ -5,7 +5,7 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js'; 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> { export class UserGamesCommand implements flatbuffers.IUnpackableObject<UserGamesCommandT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class UserGamesOrderGetResponse implements flatbuffers.IUnpackableObject<UserGamesOrderGetResponseT> {
@@ -5,7 +5,7 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js'; 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> { export class UserGamesOrderResponse implements flatbuffers.IUnpackableObject<UserGamesOrderResponseT> {
@@ -5,7 +5,7 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js'; 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> { 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 */ /* 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 { Bombing, BombingT } from './report/bombing.js';
export { GameReportRequest, GameReportRequestT } from './report/game-report-request.js'; export { GameReportRequest, GameReportRequestT } from './report/game-report-request.js';
export { IncomingGroup, IncomingGroupT } from './report/incoming-group.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 * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js'; 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> { export class LocalGroup implements flatbuffers.IUnpackableObject<LocalGroupT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class OtherGroup implements flatbuffers.IUnpackableObject<OtherGroupT> {
@@ -4,24 +4,24 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js'; import { BattleSummary, BattleSummaryT } from '../report/battle-summary.js';
import { Bombing, BombingT } from './bombing.js'; import { Bombing, BombingT } from '../report/bombing.js';
import { IncomingGroup, IncomingGroupT } from './incoming-group.js'; import { IncomingGroup, IncomingGroupT } from '../report/incoming-group.js';
import { LocalFleet, LocalFleetT } from './local-fleet.js'; import { LocalFleet, LocalFleetT } from '../report/local-fleet.js';
import { LocalGroup, LocalGroupT } from './local-group.js'; import { LocalGroup, LocalGroupT } from '../report/local-group.js';
import { LocalPlanet, LocalPlanetT } from './local-planet.js'; import { LocalPlanet, LocalPlanetT } from '../report/local-planet.js';
import { OtherGroup, OtherGroupT } from './other-group.js'; import { OtherGroup, OtherGroupT } from '../report/other-group.js';
import { OtherPlanet, OtherPlanetT } from './other-planet.js'; import { OtherPlanet, OtherPlanetT } from '../report/other-planet.js';
import { OtherScience, OtherScienceT } from './other-science.js'; import { OtherScience, OtherScienceT } from '../report/other-science.js';
import { OthersShipClass, OthersShipClassT } from './others-ship-class.js'; import { OthersShipClass, OthersShipClassT } from '../report/others-ship-class.js';
import { Player, PlayerT } from './player.js'; import { Player, PlayerT } from '../report/player.js';
import { Route, RouteT } from './route.js'; import { Route, RouteT } from '../report/route.js';
import { Science, ScienceT } from './science.js'; import { Science, ScienceT } from '../report/science.js';
import { ShipClass, ShipClassT } from './ship-class.js'; import { ShipClass, ShipClassT } from '../report/ship-class.js';
import { ShipProduction, ShipProductionT } from './ship-production.js'; import { ShipProduction, ShipProductionT } from '../report/ship-production.js';
import { UnidentifiedGroup, UnidentifiedGroupT } from './unidentified-group.js'; import { UnidentifiedGroup, UnidentifiedGroupT } from '../report/unidentified-group.js';
import { UnidentifiedPlanet, UnidentifiedPlanetT } from './unidentified-planet.js'; import { UnidentifiedPlanet, UnidentifiedPlanetT } from '../report/unidentified-planet.js';
import { UninhabitedPlanet, UninhabitedPlanetT } from './uninhabited-planet.js'; import { UninhabitedPlanet, UninhabitedPlanetT } from '../report/uninhabited-planet.js';
export class Report implements flatbuffers.IUnpackableObject<ReportT> { export class Report implements flatbuffers.IUnpackableObject<ReportT> {
@@ -136,9 +136,9 @@ otherShipClassLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; 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); 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 { battleLength():number {
@@ -386,8 +386,16 @@ static addBattle(builder:flatbuffers.Builder, battleOffset:flatbuffers.Offset) {
builder.addFieldOffset(13, battleOffset, 0); 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) { 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) { 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<OtherScience, OtherScienceT>(this.otherScience.bind(this), this.otherScienceLength()),
this.bb!.createObjList<ShipClass, ShipClassT>(this.localShipClass.bind(this), this.localShipClassLength()), 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<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<Bombing, BombingT>(this.bombing.bind(this), this.bombingLength()),
this.bb!.createObjList<IncomingGroup, IncomingGroupT>(this.incomingGroup.bind(this), this.incomingGroupLength()), this.bb!.createObjList<IncomingGroup, IncomingGroupT>(this.incomingGroup.bind(this), this.incomingGroupLength()),
this.bb!.createObjList<LocalPlanet, LocalPlanetT>(this.localPlanet.bind(this), this.localPlanetLength()), 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.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.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.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.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.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()); _o.localPlanet = this.bb!.createObjList<LocalPlanet, LocalPlanetT>(this.localPlanet.bind(this), this.localPlanetLength());
@@ -703,7 +711,7 @@ constructor(
public otherScience: (OtherScienceT)[] = [], public otherScience: (OtherScienceT)[] = [],
public localShipClass: (ShipClassT)[] = [], public localShipClass: (ShipClassT)[] = [],
public otherShipClass: (OthersShipClassT)[] = [], public otherShipClass: (OthersShipClassT)[] = [],
public battle: (UUIDT)[] = [], public battle: (BattleSummaryT)[] = [],
public bombing: (BombingT)[] = [], public bombing: (BombingT)[] = [],
public incomingGroup: (IncomingGroupT)[] = [], public incomingGroup: (IncomingGroupT)[] = [],
public localPlanet: (LocalPlanetT)[] = [], public localPlanet: (LocalPlanetT)[] = [],
@@ -727,7 +735,7 @@ pack(builder:flatbuffers.Builder): flatbuffers.Offset {
const otherScience = Report.createOtherScienceVector(builder, builder.createObjectOffsetList(this.otherScience)); const otherScience = Report.createOtherScienceVector(builder, builder.createObjectOffsetList(this.otherScience));
const localShipClass = Report.createLocalShipClassVector(builder, builder.createObjectOffsetList(this.localShipClass)); const localShipClass = Report.createLocalShipClassVector(builder, builder.createObjectOffsetList(this.localShipClass));
const otherShipClass = Report.createOtherShipClassVector(builder, builder.createObjectOffsetList(this.otherShipClass)); 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 bombing = Report.createBombingVector(builder, builder.createObjectOffsetList(this.bombing));
const incomingGroup = Report.createIncomingGroupVector(builder, builder.createObjectOffsetList(this.incomingGroup)); const incomingGroup = Report.createIncomingGroupVector(builder, builder.createObjectOffsetList(this.incomingGroup));
const localPlanet = Report.createLocalPlanetVector(builder, builder.createObjectOffsetList(this.localPlanet)); const localPlanet = Report.createLocalPlanetVector(builder, builder.createObjectOffsetList(this.localPlanet));
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class Route implements flatbuffers.IUnpackableObject<RouteT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class AccountResponse implements flatbuffers.IUnpackableObject<AccountResponseT> {
@@ -4,9 +4,9 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { ActiveLimit, ActiveLimitT } from './active-limit.js'; import { ActiveLimit, ActiveLimitT } from '../user/active-limit.js';
import { ActiveSanction, ActiveSanctionT } from './active-sanction.js'; import { ActiveSanction, ActiveSanctionT } from '../user/active-sanction.js';
import { EntitlementSnapshot, EntitlementSnapshotT } from './entitlement-snapshot.js'; import { EntitlementSnapshot, EntitlementSnapshotT } from '../user/entitlement-snapshot.js';
export class AccountView implements flatbuffers.IUnpackableObject<AccountViewT> { export class AccountView implements flatbuffers.IUnpackableObject<AccountViewT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class ActiveLimit implements flatbuffers.IUnpackableObject<ActiveLimitT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class ActiveSanction implements flatbuffers.IUnpackableObject<ActiveSanctionT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class EntitlementSnapshot implements flatbuffers.IUnpackableObject<EntitlementSnapshotT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class ErrorResponse implements flatbuffers.IUnpackableObject<ErrorResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class ListMySessionsResponse implements flatbuffers.IUnpackableObject<ListMySessionsResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class RevokeAllMySessionsResponse implements flatbuffers.IUnpackableObject<RevokeAllMySessionsResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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> { export class RevokeMySessionResponse implements flatbuffers.IUnpackableObject<RevokeMySessionResponseT> {
@@ -47,6 +47,7 @@ fresh.
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
import Header from "$lib/header/header.svelte"; import Header from "$lib/header/header.svelte";
import HistoryBanner from "$lib/header/history-banner.svelte";
import Sidebar from "$lib/sidebar/sidebar.svelte"; import Sidebar from "$lib/sidebar/sidebar.svelte";
import BottomTabs from "$lib/sidebar/bottom-tabs.svelte"; import BottomTabs from "$lib/sidebar/bottom-tabs.svelte";
import Calculator from "$lib/sidebar/calculator-tab.svelte"; import Calculator from "$lib/sidebar/calculator-tab.svelte";
@@ -101,9 +102,6 @@ fresh.
let sidebarOpen = $state(false); let sidebarOpen = $state(false);
let mobileTool: MobileTool = $state("map"); let mobileTool: MobileTool = $state("map");
let activeTab: SidebarTab = $state("inspector"); let activeTab: SidebarTab = $state("inspector");
// Phase 12 ships the prop wiring; Phase 26 replaces this constant
// with the real history-mode signal from `lib/history-mode.ts`.
const historyMode = false;
const gameId = $derived(page.params.id ?? ""); const gameId = $derived(page.params.id ?? "");
const isOnMap = $derived(/\/games\/[^/]+\/map\/?$/.test(page.url.pathname)); const isOnMap = $derived(/\/games\/[^/]+\/map\/?$/.test(page.url.pathname));
@@ -115,6 +113,13 @@ fresh.
setContext(GAME_STATE_CONTEXT_KEY, gameState); setContext(GAME_STATE_CONTEXT_KEY, gameState);
const orderDraft = new OrderDraftStore(); const orderDraft = new OrderDraftStore();
setContext(ORDER_DRAFT_CONTEXT_KEY, orderDraft); setContext(ORDER_DRAFT_CONTEXT_KEY, orderDraft);
// Phase 26: the order tab vanishes from the sidebar and bottom-tabs
// when the player is viewing a past turn. The flag is owned by
// `GameStateStore` (single source of truth for "what turn are we
// looking at") and surfaced here so the Phase 12 sidebar wiring,
// the new `HistoryBanner`, and `orderDraft.bindClient` all read
// from the same derivation.
const historyMode = $derived(gameState.historyMode);
const selection = new SelectionStore(); const selection = new SelectionStore();
setContext(SELECTION_CONTEXT_KEY, selection); setContext(SELECTION_CONTEXT_KEY, selection);
const renderedReport = createRenderedReportSource(gameState, orderDraft); const renderedReport = createRenderedReportSource(gameState, orderDraft);
@@ -321,7 +326,19 @@ fresh.
return; return;
} }
try { 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([ await Promise.all([
gameState.initSynthetic({ cache, gameId, report }), gameState.initSynthetic({ cache, gameId, report }),
orderDraft.init({ cache, gameId }), orderDraft.init({ cache, gameId }),
@@ -398,6 +415,7 @@ fresh.
galaxyClient.set(client); galaxyClient.set(client);
orderDraft.bindClient(client, { orderDraft.bindClient(client, {
getCurrentTurn: () => gameState.currentTurn, getCurrentTurn: () => gameState.currentTurn,
getHistoryMode: () => gameState.historyMode,
}); });
// The server is always polled at game boot — its // The server is always polled at game boot — its
// stored order may be fresher than the local cache // stored order may be fresher than the local cache
@@ -441,6 +459,7 @@ fresh.
{sidebarOpen} {sidebarOpen}
onToggleSidebar={toggleSidebar} onToggleSidebar={toggleSidebar}
/> />
<HistoryBanner />
<div class="body"> <div class="body">
<main class="active-view-host" data-testid="active-view-host"> <main class="active-view-host" data-testid="active-view-host">
{#if effectiveTool === "calc"} {#if effectiveTool === "calc"}
@@ -1,6 +1,16 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/state"; import { page } from "$app/state";
import BattleView from "$lib/active-view/battle.svelte"; 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> </script>
<BattleView battleId={page.params.battleId ?? ""} /> <BattleView
gameId={page.params.id ?? ""}
{turn}
battleId={page.params.battleId ?? ""}
/>
+21 -1
View File
@@ -148,6 +148,7 @@ export class OrderDraftStore {
private queue = new OrderQueue(); private queue = new OrderQueue();
private queueStarted = false; private queueStarted = false;
private getCurrentTurn: (() => number) | null = null; private getCurrentTurn: (() => number) | null = null;
private getHistoryMode: (() => boolean) | null = null;
/** /**
* init loads the persisted draft for `opts.gameId` from `opts.cache` * init loads the persisted draft for `opts.gameId` from `opts.cache`
@@ -195,13 +196,24 @@ export class OrderDraftStore {
* interpolate the turn number the player was composing for. The * interpolate the turn number the player was composing for. The
* layout passes `() => gameState.currentTurn`; tests may omit it, * layout passes `() => gameState.currentTurn`; tests may omit it,
* in which case the banner falls back to a turn-less template. * in which case the banner falls back to a turn-less template.
*
* Phase 26: `opts.getHistoryMode` lets `add` / `remove` / `move`
* short-circuit while the user is viewing a past turn. Without
* the gate, inspector affordances built in Phases 1422 would
* happily push commands into the draft even though the order tab
* is hidden and the read-only banner is visible. Tests may omit
* it; the default is "never in history mode".
*/ */
bindClient( bindClient(
client: GalaxyClient, client: GalaxyClient,
opts: { getCurrentTurn?: () => number } = {}, opts: {
getCurrentTurn?: () => number;
getHistoryMode?: () => boolean;
} = {},
): void { ): void {
this.client = client; this.client = client;
this.getCurrentTurn = opts.getCurrentTurn ?? null; this.getCurrentTurn = opts.getCurrentTurn ?? null;
this.getHistoryMode = opts.getHistoryMode ?? null;
} }
/** /**
@@ -305,6 +317,11 @@ export class OrderDraftStore {
*/ */
async add(command: OrderCommand): Promise<void> { async add(command: OrderCommand): Promise<void> {
if (this.status !== "ready") return; if (this.status !== "ready") return;
// Phase 26: history mode hides the order tab and treats every
// view as read-only. The inspector affordances are not aware of
// the mode, so the gate lives here — one chokepoint protects
// every Phase 1422 caller without per-component edits.
if (this.getHistoryMode?.() === true) return;
this.clearConflictForMutation(); this.clearConflictForMutation();
const removed: string[] = []; const removed: string[] = [];
let nextCommands: OrderCommand[]; let nextCommands: OrderCommand[];
@@ -385,6 +402,7 @@ export class OrderDraftStore {
*/ */
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
if (this.status !== "ready") return; if (this.status !== "ready") return;
if (this.getHistoryMode?.() === true) return;
const next = this.commands.filter((cmd) => cmd.id !== id); const next = this.commands.filter((cmd) => cmd.id !== id);
if (next.length === this.commands.length) return; if (next.length === this.commands.length) return;
this.clearConflictForMutation(); this.clearConflictForMutation();
@@ -406,6 +424,7 @@ export class OrderDraftStore {
*/ */
async move(fromIndex: number, toIndex: number): Promise<void> { async move(fromIndex: number, toIndex: number): Promise<void> {
if (this.status !== "ready") return; if (this.status !== "ready") return;
if (this.getHistoryMode?.() === true) return;
const length = this.commands.length; const length = this.commands.length;
if (fromIndex < 0 || fromIndex >= length) return; if (fromIndex < 0 || fromIndex >= length) return;
if (toIndex < 0 || toIndex >= length) return; if (toIndex < 0 || toIndex >= length) return;
@@ -479,6 +498,7 @@ export class OrderDraftStore {
this.cache = null; this.cache = null;
this.client = null; this.client = null;
this.getCurrentTurn = null; this.getCurrentTurn = null;
this.getHistoryMode = null;
if (this.queueStarted) { if (this.queueStarted) {
this.queue.stop(); this.queue.stop();
this.queueStarted = false; this.queueStarted = false;

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