diff --git a/backend/internal/server/handlers_user_lobby_helpers.go b/backend/internal/server/handlers_user_lobby_helpers.go index b10c8f6..d99c2cf 100644 --- a/backend/internal/server/handlers_user_lobby_helpers.go +++ b/backend/internal/server/handlers_user_lobby_helpers.go @@ -89,9 +89,12 @@ type gameSummaryWire struct { EnrollmentEndsAt string `json:"enrollment_ends_at"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` + CurrentTurn int32 `json:"current_turn"` } // lobbyGameDetailWire mirrors `LobbyGameDetail` from openapi.yaml. +// `current_turn` is inherited from `gameSummaryWire`; the runtime +// fields below carry the runtime projection on top of it. type lobbyGameDetailWire struct { gameSummaryWire Visibility string `json:"visibility"` @@ -100,7 +103,6 @@ type lobbyGameDetailWire struct { TargetEngineVersion string `json:"target_engine_version"` StartGapHours int32 `json:"start_gap_hours"` StartGapPlayers int32 `json:"start_gap_players"` - CurrentTurn int32 `json:"current_turn"` RuntimeStatus string `json:"runtime_status"` EngineHealth string `json:"engine_health,omitempty"` StartedAt *string `json:"started_at,omitempty"` @@ -118,6 +120,7 @@ func gameSummaryToWire(g lobby.GameRecord) gameSummaryWire { EnrollmentEndsAt: g.EnrollmentEndsAt.UTC().Format(timestampLayout), CreatedAt: g.CreatedAt.UTC().Format(timestampLayout), UpdatedAt: g.UpdatedAt.UTC().Format(timestampLayout), + CurrentTurn: g.RuntimeSnapshot.CurrentTurn, } if g.OwnerUserID != nil { s := g.OwnerUserID.String() @@ -135,7 +138,6 @@ func lobbyGameDetailToWire(g lobby.GameRecord) lobbyGameDetailWire { TargetEngineVersion: g.TargetEngineVersion, StartGapHours: g.StartGapHours, StartGapPlayers: g.StartGapPlayers, - CurrentTurn: g.RuntimeSnapshot.CurrentTurn, RuntimeStatus: g.RuntimeSnapshot.RuntimeStatus, EngineHealth: g.RuntimeSnapshot.EngineHealth, } diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 5226e40..ea72d40 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -2515,6 +2515,7 @@ components: - enrollment_ends_at - created_at - updated_at + - current_turn properties: game_id: type: string @@ -2563,6 +2564,13 @@ components: updated_at: type: string format: date-time + current_turn: + type: integer + description: | + Most recent turn number observed by backend's runtime + projection. Zero before the engine produces its first + snapshot. The user surface uses it to fetch the matching + `user.games.report` without a separate state query. GameSummaryPage: type: object additionalProperties: false @@ -2720,7 +2728,6 @@ components: - target_engine_version - start_gap_hours - start_gap_players - - current_turn - runtime_status properties: visibility: @@ -2736,8 +2743,6 @@ components: type: integer start_gap_players: type: integer - current_turn: - type: integer runtime_status: type: string engine_health: diff --git a/gateway/internal/backendclient/lobby_commands.go b/gateway/internal/backendclient/lobby_commands.go index c5bf290..e92b8c8 100644 --- a/gateway/internal/backendclient/lobby_commands.go +++ b/gateway/internal/backendclient/lobby_commands.go @@ -380,16 +380,17 @@ func (c *RESTClient) executeLobbyInviteDecline(ctx context.Context, userID strin // the UI. func decodeGameSummaryFromGameDetail(payload []byte) (lobbymodel.GameSummary, error) { var wire struct { - GameID string `json:"game_id"` - GameName string `json:"game_name"` - GameType string `json:"game_type"` - Status string `json:"status"` - OwnerUserID *string `json:"owner_user_id"` - MinPlayers int `json:"min_players"` - MaxPlayers int `json:"max_players"` + GameID string `json:"game_id"` + GameName string `json:"game_name"` + GameType string `json:"game_type"` + Status string `json:"status"` + OwnerUserID *string `json:"owner_user_id"` + MinPlayers int `json:"min_players"` + MaxPlayers int `json:"max_players"` EnrollmentEndsAt time.Time `json:"enrollment_ends_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + CurrentTurn int32 `json:"current_turn"` } if err := json.Unmarshal(payload, &wire); err != nil { return lobbymodel.GameSummary{}, fmt.Errorf("decode success response: %w", err) @@ -409,6 +410,7 @@ func decodeGameSummaryFromGameDetail(payload []byte) (lobbymodel.GameSummary, er EnrollmentEndsAt: wire.EnrollmentEndsAt.UTC(), CreatedAt: wire.CreatedAt.UTC(), UpdatedAt: wire.UpdatedAt.UTC(), + CurrentTurn: wire.CurrentTurn, }, nil } @@ -425,6 +427,7 @@ func decodePublicGamesPage(payload []byte) (*lobbymodel.PublicGamesListResponse, EnrollmentEndsAt time.Time `json:"enrollment_ends_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + CurrentTurn int32 `json:"current_turn"` } `json:"items"` Page int `json:"page"` PageSize int `json:"page_size"` @@ -455,6 +458,7 @@ func decodePublicGamesPage(payload []byte) (*lobbymodel.PublicGamesListResponse, EnrollmentEndsAt: w.EnrollmentEndsAt.UTC(), CreatedAt: w.CreatedAt.UTC(), UpdatedAt: w.UpdatedAt.UTC(), + CurrentTurn: w.CurrentTurn, }) } return out, nil diff --git a/pkg/model/lobby/lobby.go b/pkg/model/lobby/lobby.go index 62ebcb5..c16e9c3 100644 --- a/pkg/model/lobby/lobby.go +++ b/pkg/model/lobby/lobby.go @@ -62,7 +62,10 @@ type MyGamesListResponse struct { // GameSummary stores one game record returned by the various lobby // list endpoints. `OwnerUserID` is empty for public games (no human -// owner). +// owner). `CurrentTurn` carries the runtime's most recently observed +// turn number; the value is zero before the engine produces its first +// snapshot. The user surface uses it to fetch the corresponding +// `user.games.report` without an extra round-trip. type GameSummary struct { GameID string `json:"game_id"` GameName string `json:"game_name"` @@ -74,6 +77,7 @@ type GameSummary struct { EnrollmentEndsAt time.Time `json:"enrollment_ends_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + CurrentTurn int32 `json:"current_turn"` } // PublicGamesListRequest stores the paginated read request for joinable diff --git a/pkg/schema/fbs/lobby.fbs b/pkg/schema/fbs/lobby.fbs index 3ee27c0..623314b 100644 --- a/pkg/schema/fbs/lobby.fbs +++ b/pkg/schema/fbs/lobby.fbs @@ -6,7 +6,11 @@ namespace lobby; // GameSummary stores one game record returned by the lobby list // endpoints. owner_user_id is empty for public games (no human owner). -// The shape matches `lobby/openapi.yaml` `MyGameSummary`. +// current_turn carries the runtime's most recent observed turn number +// (zero before the engine produces its first snapshot); the user +// surface uses it to read the corresponding `user.games.report` +// without an extra round-trip. The shape matches `lobby/openapi.yaml` +// `MyGameSummary`. table GameSummary { game_id:string; game_name:string; @@ -18,6 +22,7 @@ table GameSummary { enrollment_ends_at_ms:int64; created_at_ms:int64; updated_at_ms:int64; + current_turn:int32; } // MyGamesListRequest stores the authenticated read request for the diff --git a/pkg/schema/fbs/lobby/GameSummary.go b/pkg/schema/fbs/lobby/GameSummary.go index 71958b0..6047aaa 100644 --- a/pkg/schema/fbs/lobby/GameSummary.go +++ b/pkg/schema/fbs/lobby/GameSummary.go @@ -141,8 +141,20 @@ func (rcv *GameSummary) MutateUpdatedAtMs(n int64) bool { return rcv._tab.MutateInt64Slot(22, n) } +func (rcv *GameSummary) CurrentTurn() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(24)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *GameSummary) MutateCurrentTurn(n int32) bool { + return rcv._tab.MutateInt32Slot(24, n) +} + func GameSummaryStart(builder *flatbuffers.Builder) { - builder.StartObject(10) + builder.StartObject(11) } func GameSummaryAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) @@ -174,6 +186,9 @@ func GameSummaryAddCreatedAtMs(builder *flatbuffers.Builder, createdAtMs int64) func GameSummaryAddUpdatedAtMs(builder *flatbuffers.Builder, updatedAtMs int64) { builder.PrependInt64Slot(9, updatedAtMs, 0) } +func GameSummaryAddCurrentTurn(builder *flatbuffers.Builder, currentTurn int32) { + builder.PrependInt32Slot(10, currentTurn, 0) +} func GameSummaryEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/transcoder/lobby.go b/pkg/transcoder/lobby.go index 9b54ab0..1d667e1 100644 --- a/pkg/transcoder/lobby.go +++ b/pkg/transcoder/lobby.go @@ -783,6 +783,7 @@ func encodeGameSummary(builder *flatbuffers.Builder, summary lobbymodel.GameSumm lobbyfbs.GameSummaryAddEnrollmentEndsAtMs(builder, summary.EnrollmentEndsAt.UTC().UnixMilli()) lobbyfbs.GameSummaryAddCreatedAtMs(builder, summary.CreatedAt.UTC().UnixMilli()) lobbyfbs.GameSummaryAddUpdatedAtMs(builder, summary.UpdatedAt.UTC().UnixMilli()) + lobbyfbs.GameSummaryAddCurrentTurn(builder, summary.CurrentTurn) return lobbyfbs.GameSummaryEnd(builder) } @@ -798,6 +799,7 @@ func decodeGameSummary(summary *lobbyfbs.GameSummary) lobbymodel.GameSummary { EnrollmentEndsAt: time.UnixMilli(summary.EnrollmentEndsAtMs()).UTC(), CreatedAt: time.UnixMilli(summary.CreatedAtMs()).UTC(), UpdatedAt: time.UnixMilli(summary.UpdatedAtMs()).UTC(), + CurrentTurn: summary.CurrentTurn(), } } diff --git a/pkg/transcoder/lobby_test.go b/pkg/transcoder/lobby_test.go index 65374bc..4f5bab3 100644 --- a/pkg/transcoder/lobby_test.go +++ b/pkg/transcoder/lobby_test.go @@ -29,13 +29,14 @@ func TestLobbyMyGamesListRoundTrip(t *testing.T) { GameID: "game-private-7c8f", GameName: "First Contact", GameType: "private", - Status: "draft", + Status: "running", OwnerUserID: "user-9912", MinPlayers: 2, MaxPlayers: 8, EnrollmentEndsAt: ends, CreatedAt: created, UpdatedAt: updated, + CurrentTurn: 7, }, { GameID: "game-public-aabb", @@ -48,6 +49,7 @@ func TestLobbyMyGamesListRoundTrip(t *testing.T) { EnrollmentEndsAt: ends, CreatedAt: created, UpdatedAt: updated, + CurrentTurn: 0, }, }, } diff --git a/ui/Makefile b/ui/Makefile index 06c9ad0..2053168 100644 --- a/ui/Makefile +++ b/ui/Makefile @@ -6,7 +6,7 @@ WASM_OUT := frontend/static/core.wasm WASM_EXEC := frontend/static/wasm_exec.js TINYGO_ROOT := $(shell tinygo env TINYGOROOT 2>/dev/null) FBS_OUT := frontend/src/proto/galaxy/fbs -FBS_INPUTS := ../pkg/schema/fbs/lobby.fbs ../pkg/schema/fbs/user.fbs +FBS_INPUTS := ../pkg/schema/fbs/common.fbs ../pkg/schema/fbs/lobby.fbs ../pkg/schema/fbs/user.fbs ../pkg/schema/fbs/report.fbs help: @echo "ui targets:" diff --git a/ui/PLAN.md b/ui/PLAN.md index aec7e80..0d645c7 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -1165,22 +1165,140 @@ Status: pending. Goal: replace the map fixture with real planet data fetched from the gateway for the selected game; planets only, read-only. -Artifacts: +Decisions taken with the project owner during implementation: -- `ui/frontend/src/api/game-state.ts` fetch latest game state via - `user.games.report` -- `ui/frontend/src/map/state-binding.ts` map-state synchroniser - applying planets to the renderer -- `ui/frontend/src/lib/active-view/map.svelte` integrates the renderer - with live data and a loading state, defaulting to torus mode and - reading the per-game wrap-scrolling preference from `Cache` (toggle - itself is exposed in Phase 29) -- `ui/frontend/src/lib/header/turn-counter.svelte` reads the live - turn number from game state +1. **`current_turn` on `GameSummary`.** The user-facing + `lobby.my.games.list` did not expose the runtime's current turn + number, but the in-game shell needs it to fetch the matching + `user.games.report`. Phase 11 extends `GameSummary` with a new + `current_turn:int32` field (FB schema, Go transcoder + model, + backend `gameSummaryWire`, gateway `decodeGameSummary*`, + `backend/openapi.yaml`, TS bindings, `api/lobby.ts`). The data + was already tracked in the runtime projection + (`backend/internal/lobby/types.go RuntimeSnapshot.CurrentTurn`); + exposing it is purely a wire change. Two alternatives were + rejected: a brand-new `user.games.state` message (full wire-flow + for a one-field response) and hard-coding `turn=0` (works for the + dev sandbox, but renders the initial state for any game past + turn zero). The decision crosses Phase 8's already-shipped + catalogue per the project's "decisions baked back into the live + plan" rule. +2. **Per-game state store with context.** A `GameStateStore` lives + in `lib/game-state.svelte.ts`; the in-game shell layout + instantiates one per game and exposes it through Svelte context + under `GAME_STATE_CONTEXT_KEY`. Header turn counter, map view, + and (in later phases) inspector tabs all consume the same + instance. A new instance is created on layout remount (game id + change), so each game gets a fresh snapshot. +3. **Lobby lookup for current turn.** The store does not assume the + caller passed `current_turn` through navigation state. On + `setGame`, it calls `lobby.my.games.list` itself, finds the game + record, reads `current_turn`, and then calls + `user.games.report`. A direct deep link to `/games/:id/map` for + a game the user is not a member of flips the store to `error` + with a `not in your list` message. +4. **Refresh on tab focus.** The store installs a + `visibilitychange` listener that calls `refresh()` when the + document becomes visible and the store is `ready`. The map + view's mount effect skips a re-render when the new snapshot's + turn matches the previously-mounted turn (and the wrap mode is + unchanged), so a no-op refresh does not flicker the canvas. +5. **Wrap-mode preference.** `Cache` namespace `game-prefs`, key + `/wrap-mode`, values `torus` (default) / `no-wrap`. + Phase 11 reads through `wrapMode`; `setWrapMode` writes back. + Phase 29 wires the toggle UI on top of these primitives. +6. **State binding.** `map/state-binding.ts::reportToWorld` emits + one Point primitive per planet across all four kinds (local / + other / uninhabited / unidentified) with distinct fill colours + and point radii. Each primitive's id reuses the engine planet + number so a hit-test result resolves directly to a planet + without an extra lookup table. Zero-planet reports yield a + well-formed empty world; the World constructor's positivity + check is guarded by a 1×1 fallback for the malformed-report + edge case. +7. **Renderer remount on snapshot change.** The map view disposes + and recreates the renderer when the report's turn changes (and + short-circuits when it does not). This is wasteful for the + tab-focus refresh path, but the renderer's external + `RendererHandle` does not yet expose a `setWorld` API and Phase + 11's per-game planet count is small enough that the remount + cost (a few hundred ms) is acceptable. A future phase that adds + high-frequency updates (Phase 24 push events, Phase 34 multi- + turn projection overlays) will extract a `replaceWorld` method. +8. **e2e bootstrap reuses `__galaxyDebug`.** The Phase 10 pattern + of seeding the device session through `/__debug/store` carries + over; the gateway is mocked through `page.route` for + `lobby.my.games.list`, `user.games.report`, and the + `SubscribeEvents` stream that the revocation watcher opens + (held open indefinitely so a clean end-of-stream does not + trigger `signOut("revoked")` and bounce the test back to + `/login`). + +Artifacts (delivered): + +- `ui/frontend/src/api/game-state.ts` — typed wrapper for + `user.games.report` plus `uuidToHiLo` and a TS-friendly + `GameReport` shape (planets only) +- `ui/frontend/src/lib/game-state.svelte.ts` — runes-based + `GameStateStore` with init / setGame / setTurn / refresh / + setWrapMode / failBootstrap / dispose; tab-focus listener; + `Cache`-backed wrap-mode persistence +- `ui/frontend/src/map/state-binding.ts` — `reportToWorld` and the + per-kind planet styling +- `ui/frontend/src/lib/active-view/map.svelte` — replaces the + Phase 10 stub with the live renderer integration plus loading / + error overlays and a `data-planet-count` testid hook +- `ui/frontend/src/lib/header/turn-counter.svelte` — reads + `store.report.turn` through context, falls back to the static + `?` placeholder when the store has not yet produced a snapshot +- `ui/frontend/src/routes/games/[id]/+layout.svelte` — instantiates + the `GameStateStore`, builds the `GalaxyClient`, exposes the + store via `setContext`, disposes on unmount +- `pkg/schema/fbs/lobby.fbs` — `current_turn:int32` field +- `pkg/schema/fbs/lobby/GameSummary.go` (regenerated) +- `pkg/transcoder/lobby.go` — encode/decode `current_turn` +- `pkg/transcoder/lobby_test.go` — non-zero `current_turn` in the + round-trip fixture +- `pkg/model/lobby/lobby.go` — `CurrentTurn int32` on `GameSummary` +- `backend/internal/server/handlers_user_lobby_helpers.go` — + `gameSummaryWire.CurrentTurn` + `gameSummaryToWire` reads it + from `RuntimeSnapshot.CurrentTurn`; `lobbyGameDetailWire` no + longer redeclares the field +- `backend/openapi.yaml` — `current_turn` on the `GameSummary` + schema (required); removed from the `LobbyGameDetail` allOf + block (now inherited) +- `gateway/internal/backendclient/lobby_commands.go` — + `decodeGameSummaryFromGameDetail` and `decodePublicGamesPage` + parse `current_turn` from JSON +- `ui/Makefile` — `FBS_INPUTS` adds `common.fbs` (so the + `common/uuid.ts` directory is generated) and `report.fbs` +- `ui/frontend/src/proto/galaxy/fbs/{common,report}/...` — + regenerated TS bindings +- `ui/frontend/src/api/lobby.ts` — `currentTurn: number` on + `GameSummary`; `decodeGameSummary` reads it +- `ui/frontend/tests/lobby-{fbs,api,page}.test.ts` and + `tests/e2e/fixtures/lobby-fbs.ts` — fixtures and assertions + cover `currentTurn` +- `ui/frontend/tests/state-binding.test.ts` — Vitest unit + coverage for `reportToWorld` (dimensions, kinds, ids, styling, + empty-planet, zero-dimension fallback, priority order) +- `ui/frontend/tests/game-state.test.ts` — Vitest coverage for + `GameStateStore` (init flow, missing-membership error, + forbidden-result error, `setTurn`, wrap-mode persistence + across instances, `failBootstrap`) +- `ui/frontend/tests/e2e/game-shell-map.spec.ts` — Playwright e2e + with a mocked gateway: live report renders the reported turn + and planet count, zero-planet game renders without errors, + missing-membership game surfaces the error overlay +- `ui/frontend/tests/e2e/fixtures/report-fbs.ts` — `buildReportPayload` + helper for forging FB Report payloads +- Topic doc `ui/docs/game-state.md` +- `ui/docs/lobby.md` — `current_turn` note pointing at the new + game-state doc Dependencies: Phases 9, 10. -Acceptance criteria: +Acceptance criteria (met): - entering `/games/:id/map` for a game with real planets renders them on the map; @@ -1189,14 +1307,20 @@ Acceptance criteria: - view mode (torus / no-wrap) honours the per-game preference if set, defaults to torus otherwise. -Targeted tests: +Targeted tests (delivered): -- Vitest unit tests for `state-binding.ts` translating report data to - primitives; -- Playwright e2e against a local stack with seeded game state; -- regression test: zero-planet game shows the map empty without errors; -- regression test: per-game wrap-scrolling preference persists and is - applied on next visit to the game. +- Vitest: `tests/state-binding.test.ts` covers the report→world + translation across every planet kind plus malformed-dimension + guards; `tests/game-state.test.ts` covers the store lifecycle + end-to-end with a stubbed `listMyGames` and a fake `GalaxyClient`; +- Playwright e2e: `tests/e2e/game-shell-map.spec.ts` exercises the + live data path with a mocked gateway across all four projects, + including the zero-planet regression and the + missing-membership error path; +- per-game wrap-scrolling preference round-trips through `Cache` + in `game-state.test.ts::setWrapMode persists across instances`; +- the existing Phase 10 chrome / navigation specs still pass + unchanged. ## Phase 12. Order Composer Skeleton diff --git a/ui/docs/game-state.md b/ui/docs/game-state.md new file mode 100644 index 0000000..d8c051c --- /dev/null +++ b/ui/docs/game-state.md @@ -0,0 +1,104 @@ +# Per-game state store + +This document describes the per-game state owned by the in-game shell +layout. Phase 11 introduces the store and uses it for two consumers +(the header turn counter and the map view); later phases plug +inspector tabs, the order composer, and the calculator on top of the +same instance. + +## Lifecycle + +`routes/games/[id]/+layout.svelte` instantiates one `GameStateStore` +per game (the layout remounts when the user navigates to a different +game id, so each game gets a fresh store). The layout exposes the +instance through Svelte context under `GAME_STATE_CONTEXT_KEY`; +descendants read it via `getContext(GAME_STATE_CONTEXT_KEY)`. + +The layout's `onMount` builds the `GalaxyClient`, loads `Cache` +through `loadStore()`, then calls `gameState.init({ client, cache, +gameId })`. `init`: + +1. installs a `visibilitychange` listener on `document` so the report + is refreshed when the tab regains focus; +2. calls `setGame(gameId)`, which: + - reads the per-game wrap-mode preference from `Cache` + (`game-prefs / /wrap-mode`, default `torus`); + - calls `lobby.my.games.list` and finds the game record (the + Phase 11 wire schema extension on `GameSummary` adds + `current_turn`); if the user is not a member, the store flips + to `error`; + - calls `user.games.report` for the discovered turn and decodes + the FlatBuffers response into a TS-friendly `GameReport` shape. + +The store exposes: + +| field | type | meaning | +| ------------- | ----------------------------- | ---------------------------------------------------- | +| `gameId` | `string` | active game id | +| `status` | `idle / loading / ready / error` | current lifecycle state | +| `report` | `GameReport \| null` | latest decoded report, `null` until first fetch | +| `wrapMode` | `torus / no-wrap` | per-game preference, persisted via `Cache` | +| `error` | `string \| null` | localised error message when `status === "error"` | + +## Phase boundaries + +- Phase 11 surfaces only the planet subset of the report. Later + phases extend `GameReport` and `decodeReport` as their slice of + the wire lands (ships, fleets, sciences, routes, battles, mail). +- Phase 26 wires history mode through `setTurn(turn)`. The store + already supports it; the navigator UI is what is missing. +- Phase 24 replaces the tab-focus refresh with push-event-driven + refreshes; the visibility listener stays as a fallback for + background tabs that miss a push. +- Phase 29 wires the wrap-mode toggle UI on top of `setWrapMode`. + +## Why `current_turn` lives on `GameSummary` + +The user-facing surface needs the current turn number to know which +report to fetch. Two alternatives were rejected: + +- a brand-new `user.games.state` message — adds a full wire-flow + (fbs schema, transcoder, gateway routing, backend handler) for a + one-field response; +- hard-coding `turn=0` for all games — works for the dev sandbox + (which never advances past turn zero) but renders the initial + state for any real game past turn zero. + +Extending `GameSummary` reuses the existing lobby pipeline; the +backend already tracks `current_turn` in its runtime projection +(`backend/internal/server/handlers_user_lobby_helpers.go` +`gameSummaryToWire` reads it from `g.RuntimeSnapshot.CurrentTurn`). + +The wire change touches Phase 8's already-shipped catalogue, but the +`current_turn` field defaults to zero on the FB side, so existing +tests and the dev sandbox flow continue to work unchanged. + +## State binding + +`map/state-binding.ts::reportToWorld(report)` translates a +`GameReport` into a renderer-ready `World`. Phase 11 emits one Point +primitive per planet across all four kinds (local / other / +uninhabited / unidentified). Each kind gets a distinct fill colour, +fill alpha, and point radius so the four classes are +visually-distinguishable at a glance; later phases will refine the +colour palette as the visual language stabilises (Phase 35 polish). + +The planet engine number is reused as the primitive id so a hit-test +result can resolve back to a planet without an extra lookup table. + +## Refresh discipline + +`refresh()` re-fetches the same turn snapshot. It is called by the +`visibilitychange` handler when `document.visibilityState === +"visible"` and the store is already in `ready` state. The map view's +mount effect skips a re-render when the new snapshot's turn matches +the previously-mounted turn (and the wrap mode is unchanged), so a +no-op refresh does not flicker the canvas. + +`setTurn(turn)` is the entry point for Phase 26 history mode: +calling it on a different turn loads that snapshot and the same +mount effect re-creates the renderer with the new world. + +`setWrapMode(mode)` writes to `Cache` and updates the rune; the +map view's effect picks the change up and re-mounts the renderer +with the new mode. diff --git a/ui/docs/lobby.md b/ui/docs/lobby.md index f8c010f..bbfd74e 100644 --- a/ui/docs/lobby.md +++ b/ui/docs/lobby.md @@ -18,7 +18,7 @@ width. | Section | Empty state | Source | Action | | -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- | | `create new game` | (always visible) | — | Navigates to `/lobby/create` | -| `my games` | `no games yet` | `lobby.my.games.list` | Click → `/games/:id/map` (placeholder until Phase 10) | +| `my games` | `no games yet` | `lobby.my.games.list` | Click → `/games/:id/map` | | `pending invitations`| `no invitations` | `lobby.my.invites.list` | Accept (`lobby.invite.redeem`) / Decline (`lobby.invite.decline`) | | `my applications` | `no applications` | `lobby.my.applications.list` | Status badge (`pending` / `approved` / `rejected`) | | `public games` | `no public games` | `lobby.public.games.list` | Submit application via inline race-name form (`lobby.application.submit`) | @@ -27,6 +27,12 @@ The header preserves the device-session-id `` block from the Phase 7 placeholder (kept as a debug affordance) plus a greeting if the gateway returns a `display_name` for the caller. +`GameSummary` carries an extra `current_turn` field (Phase 11 +extension) that the lobby UI does not display directly — the in-game +shell reads it from the same payload to load the matching +`user.games.report` for the map view without an additional gateway +call. See [`game-state.md`](game-state.md) for the consumer's view. + ## Application lifecycle `Submit application` on a public-game card toggles an inline race-name diff --git a/ui/frontend/src/api/game-state.ts b/ui/frontend/src/api/game-state.ts new file mode 100644 index 0000000..ccb3f22 --- /dev/null +++ b/ui/frontend/src/api/game-state.ts @@ -0,0 +1,178 @@ +// Typed wrapper around `GalaxyClient.executeCommand("user.games.report", +// ...)`. The signed-gRPC wire shape is the FlatBuffers +// `report.GameReportRequest` for the request and `report.Report` for +// the response (see `pkg/schema/fbs/report.fbs`). Phase 11 only +// surfaces the planet subset of the response — full ship / fleet / +// science decoding lands in Phases 17-22. + +import { Builder, ByteBuffer } from "flatbuffers"; + +import type { GalaxyClient } from "./galaxy-client"; +import { UUID } from "../proto/galaxy/fbs/common"; +import { + GameReportRequest, + Report, +} from "../proto/galaxy/fbs/report"; + +const MESSAGE_TYPE = "user.games.report"; + +export class GameStateError extends Error { + readonly resultCode: string; + readonly code: string; + + constructor(resultCode: string, code: string, message: string) { + super(message); + this.name = "GameStateError"; + this.resultCode = resultCode; + this.code = code; + } +} + +export interface ReportPlanet { + number: number; + name: string; + x: number; + y: number; + kind: "local" | "other" | "uninhabited" | "unidentified"; + owner: string | null; + size: number | null; + resources: number | null; +} + +export interface GameReport { + turn: number; + mapWidth: number; + mapHeight: number; + planetCount: number; + planets: ReportPlanet[]; +} + +export async function fetchGameReport( + client: GalaxyClient, + gameId: string, + turn: number, +): Promise { + const builder = new Builder(64); + const [hi, lo] = uuidToHiLo(gameId); + const gameIdOffset = UUID.createUUID(builder, hi, lo); + GameReportRequest.startGameReportRequest(builder); + GameReportRequest.addGameId(builder, gameIdOffset); + GameReportRequest.addTurn(builder, turn); + builder.finish(GameReportRequest.endGameReportRequest(builder)); + + const result = await client.executeCommand(MESSAGE_TYPE, builder.asUint8Array()); + if (result.resultCode !== "ok") { + const { code, message } = decodeErrorMessage(result.payloadBytes); + throw new GameStateError(result.resultCode, code, message); + } + const buffer = new ByteBuffer(result.payloadBytes); + const report = Report.getRootAsReport(buffer); + return decodeReport(report); +} + +function decodeReport(report: Report): GameReport { + const planets: ReportPlanet[] = []; + + for (let i = 0; i < report.localPlanetLength(); i++) { + const p = report.localPlanet(i); + if (p === null) continue; + planets.push({ + number: Number(p.number()), + name: p.name() ?? "", + x: p.x(), + y: p.y(), + kind: "local", + owner: null, + size: p.size(), + resources: p.resources(), + }); + } + + for (let i = 0; i < report.otherPlanetLength(); i++) { + const p = report.otherPlanet(i); + if (p === null) continue; + planets.push({ + number: Number(p.number()), + name: p.name() ?? "", + x: p.x(), + y: p.y(), + kind: "other", + owner: p.owner() ?? null, + size: p.size(), + resources: p.resources(), + }); + } + + for (let i = 0; i < report.uninhabitedPlanetLength(); i++) { + const p = report.uninhabitedPlanet(i); + if (p === null) continue; + planets.push({ + number: Number(p.number()), + name: p.name() ?? "", + x: p.x(), + y: p.y(), + kind: "uninhabited", + owner: null, + size: p.size(), + resources: p.resources(), + }); + } + + for (let i = 0; i < report.unidentifiedPlanetLength(); i++) { + const p = report.unidentifiedPlanet(i); + if (p === null) continue; + planets.push({ + number: Number(p.number()), + name: "", + x: p.x(), + y: p.y(), + kind: "unidentified", + owner: null, + size: null, + resources: null, + }); + } + + return { + turn: Number(report.turn()), + mapWidth: report.width(), + mapHeight: report.height(), + planetCount: report.planetCount(), + planets, + }; +} + +/** + * uuidToHiLo splits the canonical 36-character UUID string + * (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian + * uint64 halves used by `common.UUID`. Mirrors `pkg/transcoder/uuid.go`. + */ +export function uuidToHiLo(value: string): [bigint, bigint] { + const hex = value.replace(/-/g, "").toLowerCase(); + if (hex.length !== 32 || /[^0-9a-f]/.test(hex)) { + throw new GameStateError( + "invalid_request", + "invalid_request", + `invalid uuid: ${value}`, + ); + } + const hi = BigInt(`0x${hex.slice(0, 16)}`); + const lo = BigInt(`0x${hex.slice(16, 32)}`); + return [hi, lo]; +} + +function decodeErrorMessage(payload: Uint8Array): { code: string; message: string } { + if (payload.length === 0) { + return { code: "internal_error", message: "empty error payload" }; + } + try { + const text = new TextDecoder().decode(payload); + const parsed = JSON.parse(text) as { code?: string; message?: string }; + return { + code: typeof parsed.code === "string" ? parsed.code : "internal_error", + message: typeof parsed.message === "string" ? parsed.message : text, + }; + } catch { + return { code: "internal_error", message: "non-json error payload" }; + } +} diff --git a/ui/frontend/src/api/lobby.ts b/ui/frontend/src/api/lobby.ts index 07a85b3..567f149 100644 --- a/ui/frontend/src/api/lobby.ts +++ b/ui/frontend/src/api/lobby.ts @@ -55,6 +55,13 @@ export interface GameSummary { enrollmentEndsAt: Date; createdAt: Date; updatedAt: Date; + /** + * Most recent turn number observed by backend's runtime + * projection. Zero before the engine produces its first + * snapshot. The map view uses this to fetch the matching + * `user.games.report` without a separate state query. + */ + currentTurn: number; } export interface PublicGamesPage { @@ -319,6 +326,7 @@ function decodeGameSummary(summary: FbsGameSummary): GameSummary { enrollmentEndsAt: dateFromMs(summary.enrollmentEndsAtMs()), createdAt: dateFromMs(summary.createdAtMs()), updatedAt: dateFromMs(summary.updatedAtMs()), + currentTurn: summary.currentTurn(), }; } diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte index 3fdbe8c..f44dfbf 100644 --- a/ui/frontend/src/lib/active-view/map.svelte +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -1,29 +1,187 @@ -
-

{i18n.t("game.view.map")}

-

{i18n.t("game.shell.coming_soon")}

+
+ {#if store?.status === "error"} + + {:else if mountError !== null} + + {:else if store?.status !== "ready"} +

{i18n.t("common.loading")}

+ {/if} +
+ +
diff --git a/ui/frontend/src/lib/game-state.svelte.ts b/ui/frontend/src/lib/game-state.svelte.ts new file mode 100644 index 0000000..c3b2a43 --- /dev/null +++ b/ui/frontend/src/lib/game-state.svelte.ts @@ -0,0 +1,200 @@ +// Per-game runtime state owned by the in-game shell layout +// (`routes/games/[id]/+layout.svelte`). The store discovers the +// game's current turn through `lobby.my.games.list`, fetches the +// matching `user.games.report`, and exposes a TS-friendly `GameReport` +// snapshot to every consumer (header turn counter, map view, +// inspector tabs in later phases). +// +// Phase 11 covers planets only; later phases extend the report +// surface as their slice of state lands. Every consumer reads from +// the same store instance — instantiation per game guarantees the +// layout remount on `gameId` change reseeds the snapshot, while +// navigation between active views inside the same game keeps the +// instance alive (state-preservation rule, see ui/docs/navigation.md). + +import { + GameStateError, + fetchGameReport, + type GameReport, +} from "../api/game-state"; +import { listMyGames, type GameSummary } from "../api/lobby"; +import type { GalaxyClient } from "../api/galaxy-client"; +import type { Cache } from "../platform/store/index"; +import type { WrapMode } from "../map/world"; + +const PREF_NAMESPACE = "game-prefs"; +const PREF_KEY_WRAP_MODE = (gameId: string) => `${gameId}/wrap-mode`; + +/** + * GAME_STATE_CONTEXT_KEY is the Svelte context key the in-game shell + * layout uses to expose its `GameStateStore` instance to descendants. + * Header / map / inspector children resolve the store via + * `getContext(GAME_STATE_CONTEXT_KEY)`. + */ +export const GAME_STATE_CONTEXT_KEY = Symbol("game-state"); + +type Status = "idle" | "loading" | "ready" | "error"; + +export class GameStateStore { + gameId: string = $state(""); + status: Status = $state("idle"); + report: GameReport | null = $state(null); + wrapMode: WrapMode = $state("torus"); + error: string | null = $state(null); + + private client: GalaxyClient | null = null; + private cache: Cache | null = null; + private currentTurn = 0; + private destroyed = false; + private visibilityListener: (() => void) | null = null; + + /** + * init kicks off the per-game lifecycle. The call is idempotent on + * the same `gameId`; calling with a different game forwards through + * `setGame` so the layout can hand off across navigations. + */ + async init(opts: { + client: GalaxyClient; + cache: Cache; + gameId: string; + }): Promise { + this.client = opts.client; + this.cache = opts.cache; + await this.setGame(opts.gameId); + this.installVisibilityListener(); + } + + /** + * setGame switches the store to the supplied game id, fetches the + * matching lobby record to discover `current_turn`, then loads the + * report. Failure paths surface through `status === "error"` and + * the matching `error` string (already localised by the caller). + */ + async setGame(gameId: string): Promise { + if (this.client === null || this.cache === null) { + throw new Error("game-state: setGame called before init"); + } + this.gameId = gameId; + this.status = "loading"; + this.error = null; + this.report = null; + + this.wrapMode = await readWrapMode(this.cache, gameId); + + try { + const summary = await this.findGame(gameId); + if (summary === null) { + this.status = "error"; + this.error = `game ${gameId} is not in your list`; + return; + } + this.currentTurn = summary.currentTurn; + await this.loadTurn(summary.currentTurn); + } catch (err) { + if (this.destroyed) return; + this.status = "error"; + this.error = describe(err); + } + } + + /** + * setTurn loads a different turn snapshot — used by Phase 26 history + * mode. The current turn stays at whatever `setGame` discovered; + * calling without an argument refetches the same turn. + */ + async setTurn(turn: number): Promise { + if (this.client === null) return; + this.status = "loading"; + this.error = null; + try { + await this.loadTurn(turn); + } catch (err) { + this.status = "error"; + this.error = describe(err); + } + } + + /** + * refresh re-fetches the report at the current turn. Called on + * window `visibilitychange` so the map and the turn counter stay + * fresh after the user returns to the tab. + */ + refresh(): Promise { + return this.setTurn(this.currentTurn); + } + + /** + * setWrapMode persists the per-game preference into Cache so the + * next visit to the game restores it. Phase 29 wires the toggle UI; + * Phase 11 only reads through `wrapMode` and writes via this method. + */ + async setWrapMode(mode: WrapMode): Promise { + this.wrapMode = mode; + if (this.cache !== null) { + await this.cache.put(PREF_NAMESPACE, PREF_KEY_WRAP_MODE(this.gameId), mode); + } + } + + /** + * failBootstrap is used by the layout to surface errors that + * happen *before* `init` could be reached (missing keypair, missing + * gateway public key, core/store load failure). It does not need + * `init` to have run first. + */ + failBootstrap(message: string): void { + this.status = "error"; + this.error = message; + } + + dispose(): void { + this.destroyed = true; + if (this.visibilityListener !== null && typeof document !== "undefined") { + document.removeEventListener("visibilitychange", this.visibilityListener); + } + this.visibilityListener = null; + this.client = null; + this.cache = null; + } + + private async findGame(gameId: string): Promise { + if (this.client === null) return null; + const games = await listMyGames(this.client); + return games.find((g) => g.gameId === gameId) ?? null; + } + + private async loadTurn(turn: number): Promise { + if (this.client === null) return; + const report = await fetchGameReport(this.client, this.gameId, turn); + if (this.destroyed) return; + this.report = report; + this.currentTurn = turn; + this.status = "ready"; + } + + private installVisibilityListener(): void { + if (typeof document === "undefined") return; + const listener = (): void => { + if (document.visibilityState === "visible" && this.status === "ready") { + void this.refresh(); + } + }; + this.visibilityListener = listener; + document.addEventListener("visibilitychange", listener); + } +} + +async function readWrapMode(cache: Cache, gameId: string): Promise { + const stored = await cache.get(PREF_NAMESPACE, PREF_KEY_WRAP_MODE(gameId)); + if (stored === "no-wrap") return "no-wrap"; + return "torus"; +} + +function describe(err: unknown): string { + if (err instanceof GameStateError) { + return err.message; + } + if (err instanceof Error) { + return err.message; + } + return "request failed"; +} diff --git a/ui/frontend/src/lib/header/turn-counter.svelte b/ui/frontend/src/lib/header/turn-counter.svelte index 99e3570..fd87ed1 100644 --- a/ui/frontend/src/lib/header/turn-counter.svelte +++ b/ui/frontend/src/lib/header/turn-counter.svelte @@ -1,15 +1,31 @@ - - {i18n.t("game.shell.turn_label")} {i18n.t("game.shell.turn_unknown")} + + {i18n.t("game.shell.turn_label")} {display}