ce7a66b3e6
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>
321 lines
10 KiB
Go
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
|
|
}
|