Files
galaxy-game/backend/internal/server/handlers_user_lobby_helpers.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

321 lines
10 KiB
Go

package server
import (
"context"
"errors"
"net/http"
"time"
"galaxy/backend/internal/lobby"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/telemetry"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// respondLobbyError maps lobby-package sentinel errors to the standard
// JSON error envelope. Unknown errors land on a 500.
func respondLobbyError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
switch {
case errors.Is(err, lobby.ErrInvalidInput):
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
case errors.Is(err, lobby.ErrNotFound):
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "resource was not found")
case errors.Is(err, lobby.ErrForbidden):
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, err.Error())
case errors.Is(err, lobby.ErrConflict),
errors.Is(err, lobby.ErrInvalidStatus),
errors.Is(err, lobby.ErrRaceNameTaken),
errors.Is(err, lobby.ErrEntitlementExceeded),
errors.Is(err, lobby.ErrPendingExpired):
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error())
default:
logger.Error(op+" failed",
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
)
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
}
}
// parseGameIDParam reads `game_id` from the path. Writes 400 envelope on
// invalid input and returns false in that case.
func parseGameIDParam(c *gin.Context) (uuid.UUID, bool) {
parsed, err := uuid.Parse(c.Param("game_id"))
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "game_id must be a valid UUID")
return uuid.Nil, false
}
return parsed, true
}
func parseApplicationIDParam(c *gin.Context) (uuid.UUID, bool) {
parsed, err := uuid.Parse(c.Param("application_id"))
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "application_id must be a valid UUID")
return uuid.Nil, false
}
return parsed, true
}
func parseInviteIDParam(c *gin.Context) (uuid.UUID, bool) {
parsed, err := uuid.Parse(c.Param("invite_id"))
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "invite_id must be a valid UUID")
return uuid.Nil, false
}
return parsed, true
}
func parseMembershipIDParam(c *gin.Context) (uuid.UUID, bool) {
parsed, err := uuid.Parse(c.Param("membership_id"))
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "membership_id must be a valid UUID")
return uuid.Nil, false
}
return parsed, true
}
// gameSummaryWire mirrors `GameSummary` from openapi.yaml.
type gameSummaryWire 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,omitempty"`
MinPlayers int32 `json:"min_players"`
MaxPlayers int32 `json:"max_players"`
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"`
Description string `json:"description,omitempty"`
TurnSchedule string `json:"turn_schedule"`
TargetEngineVersion string `json:"target_engine_version"`
StartGapHours int32 `json:"start_gap_hours"`
StartGapPlayers int32 `json:"start_gap_players"`
RuntimeStatus string `json:"runtime_status"`
EngineHealth string `json:"engine_health,omitempty"`
StartedAt *string `json:"started_at,omitempty"`
FinishedAt *string `json:"finished_at,omitempty"`
}
func gameSummaryToWire(g lobby.GameRecord) gameSummaryWire {
out := gameSummaryWire{
GameID: g.GameID.String(),
GameName: g.GameName,
GameType: g.Visibility,
Status: g.Status,
MinPlayers: g.MinPlayers,
MaxPlayers: g.MaxPlayers,
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()
out.OwnerUserID = &s
}
return out
}
func lobbyGameDetailToWire(g lobby.GameRecord) lobbyGameDetailWire {
out := lobbyGameDetailWire{
gameSummaryWire: gameSummaryToWire(g),
Visibility: g.Visibility,
Description: g.Description,
TurnSchedule: g.TurnSchedule,
TargetEngineVersion: g.TargetEngineVersion,
StartGapHours: g.StartGapHours,
StartGapPlayers: g.StartGapPlayers,
RuntimeStatus: g.RuntimeSnapshot.RuntimeStatus,
EngineHealth: g.RuntimeSnapshot.EngineHealth,
}
if g.StartedAt != nil {
s := g.StartedAt.UTC().Format(timestampLayout)
out.StartedAt = &s
}
if g.FinishedAt != nil {
s := g.FinishedAt.UTC().Format(timestampLayout)
out.FinishedAt = &s
}
return out
}
// lobbyGameStateChangeWire mirrors `LobbyGameStateChange`.
type lobbyGameStateChangeWire struct {
GameID string `json:"game_id"`
Status string `json:"status"`
RuntimeStatus string `json:"runtime_status,omitempty"`
}
func lobbyGameStateChangeToWire(g lobby.GameRecord) lobbyGameStateChangeWire {
return lobbyGameStateChangeWire{
GameID: g.GameID.String(),
Status: g.Status,
RuntimeStatus: g.RuntimeSnapshot.RuntimeStatus,
}
}
// lobbyApplicationDetailWire mirrors `LobbyApplicationDetail`.
type lobbyApplicationDetailWire struct {
ApplicationID string `json:"application_id"`
GameID string `json:"game_id"`
ApplicantUserID string `json:"applicant_user_id"`
RaceName string `json:"race_name"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
DecidedAt *string `json:"decided_at,omitempty"`
}
func lobbyApplicationDetailToWire(a lobby.Application) lobbyApplicationDetailWire {
out := lobbyApplicationDetailWire{
ApplicationID: a.ApplicationID.String(),
GameID: a.GameID.String(),
ApplicantUserID: a.ApplicantUserID.String(),
RaceName: a.RaceName,
Status: a.Status,
CreatedAt: a.CreatedAt.UTC().Format(timestampLayout),
}
if a.DecidedAt != nil {
s := a.DecidedAt.UTC().Format(timestampLayout)
out.DecidedAt = &s
}
return out
}
// lobbyInviteDetailWire mirrors `LobbyInviteDetail`.
type lobbyInviteDetailWire struct {
InviteID string `json:"invite_id"`
GameID string `json:"game_id"`
InviterUserID string `json:"inviter_user_id"`
InvitedUserID *string `json:"invited_user_id,omitempty"`
Code *string `json:"code,omitempty"`
RaceName string `json:"race_name"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
DecidedAt *string `json:"decided_at,omitempty"`
}
func lobbyInviteDetailToWire(i lobby.Invite) lobbyInviteDetailWire {
out := lobbyInviteDetailWire{
InviteID: i.InviteID.String(),
GameID: i.GameID.String(),
InviterUserID: i.InviterUserID.String(),
RaceName: i.RaceName,
Status: i.Status,
CreatedAt: i.CreatedAt.UTC().Format(timestampLayout),
ExpiresAt: i.ExpiresAt.UTC().Format(timestampLayout),
}
if i.InvitedUserID != nil {
s := i.InvitedUserID.String()
out.InvitedUserID = &s
}
if i.Code != "" {
c := i.Code
out.Code = &c
}
if i.DecidedAt != nil {
s := i.DecidedAt.UTC().Format(timestampLayout)
out.DecidedAt = &s
}
return out
}
// lobbyMembershipDetailWire mirrors `LobbyMembershipDetail`.
type lobbyMembershipDetailWire struct {
MembershipID string `json:"membership_id"`
GameID string `json:"game_id"`
UserID string `json:"user_id"`
RaceName string `json:"race_name"`
CanonicalKey string `json:"canonical_key"`
Status string `json:"status"`
JoinedAt string `json:"joined_at"`
RemovedAt *string `json:"removed_at,omitempty"`
}
func lobbyMembershipDetailToWire(m lobby.Membership) lobbyMembershipDetailWire {
out := lobbyMembershipDetailWire{
MembershipID: m.MembershipID.String(),
GameID: m.GameID.String(),
UserID: m.UserID.String(),
RaceName: m.RaceName,
CanonicalKey: m.CanonicalKey,
Status: m.Status,
JoinedAt: m.JoinedAt.UTC().Format(timestampLayout),
}
if m.RemovedAt != nil {
s := m.RemovedAt.UTC().Format(timestampLayout)
out.RemovedAt = &s
}
return out
}
// raceNameDetailWire mirrors `RaceNameDetail`.
type raceNameDetailWire struct {
Name string `json:"name"`
Canonical string `json:"canonical"`
Status string `json:"status"`
OwnerUserID string `json:"owner_user_id"`
GameID *string `json:"game_id,omitempty"`
SourceGameID *string `json:"source_game_id,omitempty"`
ReservedAt *string `json:"reserved_at,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
RegisteredAt *string `json:"registered_at,omitempty"`
}
func raceNameDetailToWire(e lobby.RaceNameEntry) raceNameDetailWire {
out := raceNameDetailWire{
Name: e.Name,
Canonical: string(e.Canonical),
Status: e.Status,
OwnerUserID: e.OwnerUserID.String(),
}
if e.GameID != (uuid.UUID{}) {
s := e.GameID.String()
out.GameID = &s
}
if e.SourceGameID != nil {
s := e.SourceGameID.String()
out.SourceGameID = &s
}
if e.ReservedAt != nil {
s := e.ReservedAt.UTC().Format(timestampLayout)
out.ReservedAt = &s
}
if e.ExpiresAt != nil {
s := e.ExpiresAt.UTC().Format(timestampLayout)
out.ExpiresAt = &s
}
if e.RegisteredAt != nil {
s := e.RegisteredAt.UTC().Format(timestampLayout)
out.RegisteredAt = &s
}
return out
}
// parseTimePtrField parses a wire timestamp pointer into a time.Time
// pointer. Empty / nil input yields nil. Invalid timestamps return an
// error.
func parseTimePtrField(raw *string, field string) (*time.Time, error) {
if raw == nil || *raw == "" {
return nil, nil
}
t, err := time.Parse(time.RFC3339Nano, *raw)
if err != nil {
return nil, errors.New(field + " must be RFC 3339")
}
return &t, nil
}