Files
galaxy-game/pkg/transcoder/lobby_test.go
T
Ilia Denisov ce7a66b3e6 ui/phase-11: map wired to live game state
Replaces the Phase 10 map stub with live planet rendering driven by
`user.games.report`, and wires the header turn counter to the same
data. Phase 11's frontend sits on a per-game `GameStateStore` that
lives in `lib/game-state.svelte.ts`: the in-game shell layout
instantiates one per game, exposes it through Svelte context, and
disposes it on remount. The store discovers the game's current turn
through `lobby.my.games.list`, fetches the matching report, and
exposes a TS-friendly snapshot to the header turn counter, the map
view, and the inspector / order / calculator tabs that later phases
will plug onto the same instance.

The pipeline forced one cross-stage decision: the user surface needs
the current turn number to know which report to fetch, but
`GameSummary` did not expose it. Phase 11 extends the lobby
catalogue (FB schema, transcoder, Go model, backend
gameSummaryWire, gateway decoders, openapi, TS bindings,
api/lobby.ts) with `current_turn:int32`. The data was already
tracked in backend's `RuntimeSnapshot.CurrentTurn`; surfacing it is
a wire change only. Two alternatives were rejected: a brand-new
`user.games.state` message (full wire-flow for one field) and
hard-coding `turn=0` (works for the dev sandbox, which never
advances past zero, but renders the initial state for any real
game). The change crosses Phase 8's already-shipped catalogue per
the project's "decisions baked back into the live plan" rule —
existing tests and fixtures are updated in the same patch.

The state binding lives in `map/state-binding.ts::reportToWorld`:
one Point primitive per planet across all four kinds (local /
other / uninhabited / unidentified) with distinct fill colours,
fill alphas, and point radii so the user can tell them apart at a
glance. The planet engine number is reused as the primitive id so
a hit-test result resolves directly to a planet without an extra
lookup table. Zero-planet reports yield a well-formed empty world;
malformed dimensions fall back to 1×1 so a bad report cannot crash
the renderer.

The map view's mount effect creates the renderer once and skips
re-mount on no-op refreshes (same turn, same wrap mode); a turn
change or wrap-mode flip disposes and recreates it. The renderer's
external API does not yet expose `setWorld`; Phase 24 / 34 will
extract it once high-frequency updates land. The store installs a
`visibilitychange` listener that calls `refresh()` when the tab
regains focus.

Wrap-mode preference uses `Cache` namespace `game-prefs`, key
`<gameId>/wrap-mode`, default `torus`. Phase 11 reads through
`store.wrapMode`; Phase 29 wires the toggle UI on top of
`setWrapMode`.

Tests: Vitest unit coverage for `reportToWorld` (every kind,
ids, styling, empty / zero-dimension edges, priority order) and
for the store lifecycle (init success, missing-membership error,
forbidden-result error, `setTurn`, wrap-mode persistence across
instances, `failBootstrap`). Playwright e2e mocks the gateway for
`lobby.my.games.list` and `user.games.report` and asserts the
live data path: turn counter shows the reported turn,
`active-view-map` flips to `data-status="ready"`, and
`data-planet-count` matches the fixture count. The zero-planet
regression and the missing-membership error path are covered.

Phase 11 status stays `pending` in `ui/PLAN.md` until the local-ci
run lands green; flipping to `done` follows in the next commit per
the per-stage CI gate in `CLAUDE.md`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 21:17:17 +02:00

520 lines
15 KiB
Go

package transcoder
import (
"reflect"
"testing"
"time"
lobbymodel "galaxy/model/lobby"
)
// fixedTimes returns deterministic UTC timestamps for round-trip
// fixtures. Millisecond precision matches the FlatBuffers `*Ms` ints,
// which is what the transcoder preserves.
func fixedTimes() (created, updated, ends time.Time) {
created = time.Date(2026, time.May, 7, 9, 30, 15, 123_000_000, time.UTC)
updated = created.Add(2 * time.Minute)
ends = created.Add(48 * time.Hour)
return
}
func TestLobbyMyGamesListRoundTrip(t *testing.T) {
t.Parallel()
created, updated, ends := fixedTimes()
source := &lobbymodel.MyGamesListResponse{
Items: []lobbymodel.GameSummary{
{
GameID: "game-private-7c8f",
GameName: "First Contact",
GameType: "private",
Status: "running",
OwnerUserID: "user-9912",
MinPlayers: 2,
MaxPlayers: 8,
EnrollmentEndsAt: ends,
CreatedAt: created,
UpdatedAt: updated,
CurrentTurn: 7,
},
{
GameID: "game-public-aabb",
GameName: "Open Lobby",
GameType: "public",
Status: "enrollment_open",
OwnerUserID: "",
MinPlayers: 4,
MaxPlayers: 12,
EnrollmentEndsAt: ends,
CreatedAt: created,
UpdatedAt: updated,
CurrentTurn: 0,
},
},
}
payload, err := MyGamesListResponseToPayload(source)
if err != nil {
t.Fatalf("encode: %v", err)
}
decoded, err := PayloadToMyGamesListResponse(payload)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !reflect.DeepEqual(source, decoded) {
t.Fatalf("round-trip mismatch\nsource: %#v\ndecoded:%#v", source, decoded)
}
requestBytes, err := MyGamesListRequestToPayload(&lobbymodel.MyGamesListRequest{})
if err != nil {
t.Fatalf("encode request: %v", err)
}
if _, err := PayloadToMyGamesListRequest(requestBytes); err != nil {
t.Fatalf("decode request: %v", err)
}
}
func TestLobbyMyGamesListEmpty(t *testing.T) {
t.Parallel()
payload, err := MyGamesListResponseToPayload(&lobbymodel.MyGamesListResponse{Items: nil})
if err != nil {
t.Fatalf("encode: %v", err)
}
decoded, err := PayloadToMyGamesListResponse(payload)
if err != nil {
t.Fatalf("decode: %v", err)
}
if got := len(decoded.Items); got != 0 {
t.Fatalf("expected empty items, got %d", got)
}
}
func TestLobbyPublicGamesListRoundTrip(t *testing.T) {
t.Parallel()
created, updated, ends := fixedTimes()
requestSource := &lobbymodel.PublicGamesListRequest{Page: 3, PageSize: 25}
requestBytes, err := PublicGamesListRequestToPayload(requestSource)
if err != nil {
t.Fatalf("encode request: %v", err)
}
requestDecoded, err := PayloadToPublicGamesListRequest(requestBytes)
if err != nil {
t.Fatalf("decode request: %v", err)
}
if !reflect.DeepEqual(requestSource, requestDecoded) {
t.Fatalf("request round-trip mismatch\nsource: %#v\ndecoded:%#v", requestSource, requestDecoded)
}
responseSource := &lobbymodel.PublicGamesListResponse{
Items: []lobbymodel.GameSummary{
{
GameID: "game-public-aabb",
GameName: "Open Lobby",
GameType: "public",
Status: "enrollment_open",
OwnerUserID: "",
MinPlayers: 4,
MaxPlayers: 12,
EnrollmentEndsAt: ends,
CreatedAt: created,
UpdatedAt: updated,
},
},
Page: 3,
PageSize: 25,
Total: 51,
}
responseBytes, err := PublicGamesListResponseToPayload(responseSource)
if err != nil {
t.Fatalf("encode response: %v", err)
}
responseDecoded, err := PayloadToPublicGamesListResponse(responseBytes)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if !reflect.DeepEqual(responseSource, responseDecoded) {
t.Fatalf("response round-trip mismatch\nsource: %#v\ndecoded:%#v", responseSource, responseDecoded)
}
}
func TestLobbyMyApplicationsListRoundTrip(t *testing.T) {
t.Parallel()
created, _, _ := fixedTimes()
decided := created.Add(2 * time.Hour)
requestBytes, err := MyApplicationsListRequestToPayload(&lobbymodel.MyApplicationsListRequest{})
if err != nil {
t.Fatalf("encode request: %v", err)
}
if _, err := PayloadToMyApplicationsListRequest(requestBytes); err != nil {
t.Fatalf("decode request: %v", err)
}
source := &lobbymodel.MyApplicationsListResponse{
Items: []lobbymodel.ApplicationSummary{
{
ApplicationID: "app-1",
GameID: "game-public-aabb",
ApplicantUserID: "user-9912",
RaceName: "Vegan Federation",
Status: "pending",
CreatedAt: created,
DecidedAt: nil,
},
{
ApplicationID: "app-2",
GameID: "game-public-ccdd",
ApplicantUserID: "user-9912",
RaceName: "Lithic Compact",
Status: "approved",
CreatedAt: created,
DecidedAt: &decided,
},
},
}
payload, err := MyApplicationsListResponseToPayload(source)
if err != nil {
t.Fatalf("encode: %v", err)
}
decoded, err := PayloadToMyApplicationsListResponse(payload)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !reflect.DeepEqual(source, decoded) {
t.Fatalf("round-trip mismatch\nsource: %#v\ndecoded:%#v", source, decoded)
}
}
func TestLobbyMyInvitesListRoundTrip(t *testing.T) {
t.Parallel()
created, _, _ := fixedTimes()
expires := created.Add(72 * time.Hour)
decided := created.Add(1 * time.Hour)
requestBytes, err := MyInvitesListRequestToPayload(&lobbymodel.MyInvitesListRequest{})
if err != nil {
t.Fatalf("encode request: %v", err)
}
if _, err := PayloadToMyInvitesListRequest(requestBytes); err != nil {
t.Fatalf("decode request: %v", err)
}
source := &lobbymodel.MyInvitesListResponse{
Items: []lobbymodel.InviteSummary{
{
InviteID: "invite-user-bound",
GameID: "game-private-7c8f",
InviterUserID: "user-1111",
InvitedUserID: "user-9912",
Code: "",
RaceName: "Vegan Federation",
Status: "pending",
CreatedAt: created,
ExpiresAt: expires,
DecidedAt: nil,
},
{
InviteID: "invite-code-based",
GameID: "game-private-3322",
InviterUserID: "user-1111",
InvitedUserID: "",
Code: "ABCDEF12",
RaceName: "Lithic Compact",
Status: "accepted",
CreatedAt: created,
ExpiresAt: expires,
DecidedAt: &decided,
},
},
}
payload, err := MyInvitesListResponseToPayload(source)
if err != nil {
t.Fatalf("encode: %v", err)
}
decoded, err := PayloadToMyInvitesListResponse(payload)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !reflect.DeepEqual(source, decoded) {
t.Fatalf("round-trip mismatch\nsource: %#v\ndecoded:%#v", source, decoded)
}
}
func TestLobbyOpenEnrollmentRoundTrip(t *testing.T) {
t.Parallel()
requestSource := &lobbymodel.OpenEnrollmentRequest{GameID: "game-private-7c8f"}
requestBytes, err := OpenEnrollmentRequestToPayload(requestSource)
if err != nil {
t.Fatalf("encode request: %v", err)
}
requestDecoded, err := PayloadToOpenEnrollmentRequest(requestBytes)
if err != nil {
t.Fatalf("decode request: %v", err)
}
if !reflect.DeepEqual(requestSource, requestDecoded) {
t.Fatalf("request round-trip mismatch\nsource: %#v\ndecoded:%#v", requestSource, requestDecoded)
}
responseSource := &lobbymodel.OpenEnrollmentResponse{GameID: "game-private-7c8f", Status: "enrollment_open"}
responseBytes, err := OpenEnrollmentResponseToPayload(responseSource)
if err != nil {
t.Fatalf("encode response: %v", err)
}
responseDecoded, err := PayloadToOpenEnrollmentResponse(responseBytes)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if !reflect.DeepEqual(responseSource, responseDecoded) {
t.Fatalf("response round-trip mismatch\nsource: %#v\ndecoded:%#v", responseSource, responseDecoded)
}
}
func TestLobbyGameCreateRoundTrip(t *testing.T) {
t.Parallel()
_, _, ends := fixedTimes()
requestSource := &lobbymodel.GameCreateRequest{
GameName: "First Contact",
Description: "First Phase 8 sandbox game",
MinPlayers: 2,
MaxPlayers: 8,
StartGapHours: 24,
StartGapPlayers: 2,
EnrollmentEndsAt: ends,
TurnSchedule: "0 0 * * *",
TargetEngineVersion: "v1",
}
requestBytes, err := GameCreateRequestToPayload(requestSource)
if err != nil {
t.Fatalf("encode request: %v", err)
}
requestDecoded, err := PayloadToGameCreateRequest(requestBytes)
if err != nil {
t.Fatalf("decode request: %v", err)
}
if !reflect.DeepEqual(requestSource, requestDecoded) {
t.Fatalf("request round-trip mismatch\nsource: %#v\ndecoded:%#v", requestSource, requestDecoded)
}
created, updated, _ := fixedTimes()
responseSource := &lobbymodel.GameCreateResponse{
Game: lobbymodel.GameSummary{
GameID: "game-private-newly-created",
GameName: "First Contact",
GameType: "private",
Status: "draft",
OwnerUserID: "user-9912",
MinPlayers: 2,
MaxPlayers: 8,
EnrollmentEndsAt: ends,
CreatedAt: created,
UpdatedAt: updated,
},
}
responseBytes, err := GameCreateResponseToPayload(responseSource)
if err != nil {
t.Fatalf("encode response: %v", err)
}
responseDecoded, err := PayloadToGameCreateResponse(responseBytes)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if !reflect.DeepEqual(responseSource, responseDecoded) {
t.Fatalf("response round-trip mismatch\nsource: %#v\ndecoded:%#v", responseSource, responseDecoded)
}
}
func TestLobbyApplicationSubmitRoundTrip(t *testing.T) {
t.Parallel()
requestSource := &lobbymodel.ApplicationSubmitRequest{GameID: "game-public-aabb", RaceName: "Vegan Federation"}
requestBytes, err := ApplicationSubmitRequestToPayload(requestSource)
if err != nil {
t.Fatalf("encode request: %v", err)
}
requestDecoded, err := PayloadToApplicationSubmitRequest(requestBytes)
if err != nil {
t.Fatalf("decode request: %v", err)
}
if !reflect.DeepEqual(requestSource, requestDecoded) {
t.Fatalf("request round-trip mismatch\nsource: %#v\ndecoded:%#v", requestSource, requestDecoded)
}
created, _, _ := fixedTimes()
responseSource := &lobbymodel.ApplicationSubmitResponse{
Application: lobbymodel.ApplicationSummary{
ApplicationID: "app-3",
GameID: "game-public-aabb",
ApplicantUserID: "user-9912",
RaceName: "Vegan Federation",
Status: "pending",
CreatedAt: created,
DecidedAt: nil,
},
}
responseBytes, err := ApplicationSubmitResponseToPayload(responseSource)
if err != nil {
t.Fatalf("encode response: %v", err)
}
responseDecoded, err := PayloadToApplicationSubmitResponse(responseBytes)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if !reflect.DeepEqual(responseSource, responseDecoded) {
t.Fatalf("response round-trip mismatch\nsource: %#v\ndecoded:%#v", responseSource, responseDecoded)
}
}
func TestLobbyInviteRedeemAndDeclineRoundTrip(t *testing.T) {
t.Parallel()
created, _, _ := fixedTimes()
expires := created.Add(72 * time.Hour)
decided := created.Add(1 * time.Hour)
redeemReq := &lobbymodel.InviteRedeemRequest{GameID: "game-private-7c8f", InviteID: "invite-user-bound"}
redeemBytes, err := InviteRedeemRequestToPayload(redeemReq)
if err != nil {
t.Fatalf("encode redeem request: %v", err)
}
redeemDecoded, err := PayloadToInviteRedeemRequest(redeemBytes)
if err != nil {
t.Fatalf("decode redeem request: %v", err)
}
if !reflect.DeepEqual(redeemReq, redeemDecoded) {
t.Fatalf("redeem request mismatch")
}
redeemResp := &lobbymodel.InviteRedeemResponse{
Invite: lobbymodel.InviteSummary{
InviteID: "invite-user-bound",
GameID: "game-private-7c8f",
InviterUserID: "user-1111",
InvitedUserID: "user-9912",
Code: "",
RaceName: "Vegan Federation",
Status: "accepted",
CreatedAt: created,
ExpiresAt: expires,
DecidedAt: &decided,
},
}
redeemRespBytes, err := InviteRedeemResponseToPayload(redeemResp)
if err != nil {
t.Fatalf("encode redeem response: %v", err)
}
redeemRespDecoded, err := PayloadToInviteRedeemResponse(redeemRespBytes)
if err != nil {
t.Fatalf("decode redeem response: %v", err)
}
if !reflect.DeepEqual(redeemResp, redeemRespDecoded) {
t.Fatalf("redeem response mismatch")
}
declineReq := &lobbymodel.InviteDeclineRequest{GameID: "game-private-7c8f", InviteID: "invite-user-bound"}
declineBytes, err := InviteDeclineRequestToPayload(declineReq)
if err != nil {
t.Fatalf("encode decline request: %v", err)
}
declineDecoded, err := PayloadToInviteDeclineRequest(declineBytes)
if err != nil {
t.Fatalf("decode decline request: %v", err)
}
if !reflect.DeepEqual(declineReq, declineDecoded) {
t.Fatalf("decline request mismatch")
}
declineResp := &lobbymodel.InviteDeclineResponse{
Invite: lobbymodel.InviteSummary{
InviteID: "invite-user-bound",
GameID: "game-private-7c8f",
InviterUserID: "user-1111",
InvitedUserID: "user-9912",
Code: "",
RaceName: "Vegan Federation",
Status: "declined",
CreatedAt: created,
ExpiresAt: expires,
DecidedAt: &decided,
},
}
declineRespBytes, err := InviteDeclineResponseToPayload(declineResp)
if err != nil {
t.Fatalf("encode decline response: %v", err)
}
declineRespDecoded, err := PayloadToInviteDeclineResponse(declineRespBytes)
if err != nil {
t.Fatalf("decode decline response: %v", err)
}
if !reflect.DeepEqual(declineResp, declineRespDecoded) {
t.Fatalf("decline response mismatch")
}
}
func TestLobbyErrorResponseRoundTrip(t *testing.T) {
t.Parallel()
source := &lobbymodel.ErrorResponse{
Error: lobbymodel.ErrorBody{
Code: "conflict",
Message: "request conflicts with current state",
},
}
payload, err := LobbyErrorResponseToPayload(source)
if err != nil {
t.Fatalf("encode: %v", err)
}
decoded, err := PayloadToLobbyErrorResponse(payload)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !reflect.DeepEqual(source, decoded) {
t.Fatalf("round-trip mismatch\nsource: %#v\ndecoded:%#v", source, decoded)
}
}
func TestLobbyDecodersRecoverFromCorruption(t *testing.T) {
t.Parallel()
garbage := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
if _, err := PayloadToMyGamesListResponse(garbage); err == nil {
t.Fatal("expected error decoding corrupt my games payload")
}
if _, err := PayloadToPublicGamesListResponse(garbage); err == nil {
t.Fatal("expected error decoding corrupt public games payload")
}
if _, err := PayloadToApplicationSubmitResponse(garbage); err == nil {
t.Fatal("expected error decoding corrupt application submit response")
}
if _, err := PayloadToInviteRedeemResponse(garbage); err == nil {
t.Fatal("expected error decoding corrupt invite redeem response")
}
if _, err := PayloadToLobbyErrorResponse(garbage); err == nil {
t.Fatal("expected error decoding corrupt error payload")
}
}
func TestLobbyDecodersRejectEmptyPayload(t *testing.T) {
t.Parallel()
if _, err := PayloadToMyGamesListRequest(nil); err == nil {
t.Fatal("expected error decoding nil request")
}
if _, err := PayloadToPublicGamesListResponse(nil); err == nil {
t.Fatal("expected error decoding nil response")
}
if _, err := PayloadToInviteDeclineRequest(nil); err == nil {
t.Fatal("expected error decoding nil decline request")
}
}