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>
650 lines
25 KiB
Go
650 lines
25 KiB
Go
package backendclient
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"galaxy/gateway/internal/downstream"
|
|
lobbymodel "galaxy/model/lobby"
|
|
"galaxy/transcoder"
|
|
)
|
|
|
|
const (
|
|
lobbyResultCodeOK = "ok"
|
|
defaultLobbyErrorCodeInvalid = "invalid_request"
|
|
defaultLobbyErrorCodeNoSubj = "subject_not_found"
|
|
defaultLobbyErrorCodeForbid = "forbidden"
|
|
defaultLobbyErrorCodeConfl = "conflict"
|
|
defaultLobbyErrorCodeIntErr = "internal_error"
|
|
)
|
|
|
|
var stableLobbyErrorMessages = map[string]string{
|
|
defaultLobbyErrorCodeInvalid: "request is invalid",
|
|
defaultLobbyErrorCodeNoSubj: "subject not found",
|
|
defaultLobbyErrorCodeForbid: "operation is forbidden for the calling user",
|
|
defaultLobbyErrorCodeConfl: "request conflicts with current state",
|
|
defaultLobbyErrorCodeIntErr: "internal server error",
|
|
}
|
|
|
|
// ExecuteLobbyCommand routes one authenticated lobby command into
|
|
// backend's `/api/v1/user/lobby/*` endpoints.
|
|
func (c *RESTClient) ExecuteLobbyCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
|
if c == nil || c.httpClient == nil {
|
|
return downstream.UnaryResult{}, errors.New("backendclient: execute lobby command: nil client")
|
|
}
|
|
if ctx == nil {
|
|
return downstream.UnaryResult{}, errors.New("backendclient: execute lobby command: nil context")
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return downstream.UnaryResult{}, err
|
|
}
|
|
if strings.TrimSpace(command.UserID) == "" {
|
|
return downstream.UnaryResult{}, errors.New("backendclient: execute lobby command: user_id must not be empty")
|
|
}
|
|
|
|
switch command.MessageType {
|
|
case lobbymodel.MessageTypeMyGamesList:
|
|
if _, err := transcoder.PayloadToMyGamesListRequest(command.PayloadBytes); err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
|
}
|
|
return c.executeLobbyMyGames(ctx, command.UserID)
|
|
case lobbymodel.MessageTypePublicGamesList:
|
|
req, err := transcoder.PayloadToPublicGamesListRequest(command.PayloadBytes)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
|
}
|
|
return c.executeLobbyPublicGames(ctx, command.UserID, req)
|
|
case lobbymodel.MessageTypeMyApplicationsList:
|
|
if _, err := transcoder.PayloadToMyApplicationsListRequest(command.PayloadBytes); err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
|
}
|
|
return c.executeLobbyMyApplications(ctx, command.UserID)
|
|
case lobbymodel.MessageTypeMyInvitesList:
|
|
if _, err := transcoder.PayloadToMyInvitesListRequest(command.PayloadBytes); err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
|
}
|
|
return c.executeLobbyMyInvites(ctx, command.UserID)
|
|
case lobbymodel.MessageTypeOpenEnrollment:
|
|
req, err := transcoder.PayloadToOpenEnrollmentRequest(command.PayloadBytes)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
|
}
|
|
return c.executeLobbyOpenEnrollment(ctx, command.UserID, req)
|
|
case lobbymodel.MessageTypeGameCreate:
|
|
req, err := transcoder.PayloadToGameCreateRequest(command.PayloadBytes)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
|
}
|
|
return c.executeLobbyGameCreate(ctx, command.UserID, req)
|
|
case lobbymodel.MessageTypeApplicationSubmit:
|
|
req, err := transcoder.PayloadToApplicationSubmitRequest(command.PayloadBytes)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
|
}
|
|
return c.executeLobbyApplicationSubmit(ctx, command.UserID, req)
|
|
case lobbymodel.MessageTypeInviteRedeem:
|
|
req, err := transcoder.PayloadToInviteRedeemRequest(command.PayloadBytes)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
|
}
|
|
return c.executeLobbyInviteRedeem(ctx, command.UserID, req)
|
|
case lobbymodel.MessageTypeInviteDecline:
|
|
req, err := transcoder.PayloadToInviteDeclineRequest(command.PayloadBytes)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
|
}
|
|
return c.executeLobbyInviteDecline(ctx, command.UserID, req)
|
|
default:
|
|
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command: unsupported message type %q", command.MessageType)
|
|
}
|
|
}
|
|
|
|
func (c *RESTClient) executeLobbyMyGames(ctx context.Context, userID string) (downstream.UnaryResult, error) {
|
|
body, status, err := c.do(ctx, http.MethodGet, c.baseURL+"/api/v1/user/lobby/my/games", userID, nil)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.my.games.list: %w", err)
|
|
}
|
|
if status == http.StatusOK {
|
|
var response lobbymodel.MyGamesListResponse
|
|
if err := decodeStrictJSON(body, &response); err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("decode success response: %w", err)
|
|
}
|
|
payloadBytes, err := transcoder.MyGamesListResponseToPayload(&response)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
|
}
|
|
return downstream.UnaryResult{
|
|
ResultCode: lobbyResultCodeOK,
|
|
PayloadBytes: payloadBytes,
|
|
}, nil
|
|
}
|
|
return projectLobbyErrorResponse(status, body)
|
|
}
|
|
|
|
func (c *RESTClient) executeLobbyPublicGames(ctx context.Context, userID string, req *lobbymodel.PublicGamesListRequest) (downstream.UnaryResult, error) {
|
|
page := req.Page
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
pageSize := req.PageSize
|
|
if pageSize <= 0 {
|
|
pageSize = 50
|
|
}
|
|
target := fmt.Sprintf("%s/api/v1/user/lobby/games?page=%d&page_size=%d", c.baseURL, page, pageSize)
|
|
body, status, err := c.do(ctx, http.MethodGet, target, userID, nil)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.public.games.list: %w", err)
|
|
}
|
|
if status == http.StatusOK {
|
|
page, err := decodePublicGamesPage(body)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, err
|
|
}
|
|
payloadBytes, err := transcoder.PublicGamesListResponseToPayload(page)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
|
}
|
|
return downstream.UnaryResult{
|
|
ResultCode: lobbyResultCodeOK,
|
|
PayloadBytes: payloadBytes,
|
|
}, nil
|
|
}
|
|
return projectLobbyErrorResponse(status, body)
|
|
}
|
|
|
|
func (c *RESTClient) executeLobbyMyApplications(ctx context.Context, userID string) (downstream.UnaryResult, error) {
|
|
body, status, err := c.do(ctx, http.MethodGet, c.baseURL+"/api/v1/user/lobby/my/applications", userID, nil)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.my.applications.list: %w", err)
|
|
}
|
|
if status == http.StatusOK {
|
|
response, err := decodeApplicationsList(body)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, err
|
|
}
|
|
payloadBytes, err := transcoder.MyApplicationsListResponseToPayload(response)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
|
}
|
|
return downstream.UnaryResult{
|
|
ResultCode: lobbyResultCodeOK,
|
|
PayloadBytes: payloadBytes,
|
|
}, nil
|
|
}
|
|
return projectLobbyErrorResponse(status, body)
|
|
}
|
|
|
|
func (c *RESTClient) executeLobbyMyInvites(ctx context.Context, userID string) (downstream.UnaryResult, error) {
|
|
body, status, err := c.do(ctx, http.MethodGet, c.baseURL+"/api/v1/user/lobby/my/invites", userID, nil)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.my.invites.list: %w", err)
|
|
}
|
|
if status == http.StatusOK {
|
|
response, err := decodeInvitesList(body)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, err
|
|
}
|
|
payloadBytes, err := transcoder.MyInvitesListResponseToPayload(response)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
|
}
|
|
return downstream.UnaryResult{
|
|
ResultCode: lobbyResultCodeOK,
|
|
PayloadBytes: payloadBytes,
|
|
}, nil
|
|
}
|
|
return projectLobbyErrorResponse(status, body)
|
|
}
|
|
|
|
func (c *RESTClient) executeLobbyOpenEnrollment(ctx context.Context, userID string, req *lobbymodel.OpenEnrollmentRequest) (downstream.UnaryResult, error) {
|
|
if req == nil || strings.TrimSpace(req.GameID) == "" {
|
|
return downstream.UnaryResult{}, errors.New("execute lobby.game.open-enrollment: game_id must not be empty")
|
|
}
|
|
target := c.baseURL + "/api/v1/user/lobby/games/" + url.PathEscape(req.GameID) + "/open-enrollment"
|
|
body, status, err := c.do(ctx, http.MethodPost, target, userID, struct{}{})
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.game.open-enrollment: %w", err)
|
|
}
|
|
if status == http.StatusOK {
|
|
// Backend returns the full LobbyGameDetail; gateway projects the
|
|
// minimal {game_id, status} pair onto the existing wire shape.
|
|
var detail struct {
|
|
GameID string `json:"game_id"`
|
|
Status string `json:"status"`
|
|
}
|
|
if err := json.NewDecoder(bytes.NewReader(body)).Decode(&detail); err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("decode success response: %w", err)
|
|
}
|
|
payloadBytes, err := transcoder.OpenEnrollmentResponseToPayload(&lobbymodel.OpenEnrollmentResponse{
|
|
GameID: detail.GameID,
|
|
Status: detail.Status,
|
|
})
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
|
}
|
|
return downstream.UnaryResult{
|
|
ResultCode: lobbyResultCodeOK,
|
|
PayloadBytes: payloadBytes,
|
|
}, nil
|
|
}
|
|
return projectLobbyErrorResponse(status, body)
|
|
}
|
|
|
|
func (c *RESTClient) executeLobbyGameCreate(ctx context.Context, userID string, req *lobbymodel.GameCreateRequest) (downstream.UnaryResult, error) {
|
|
if req == nil || strings.TrimSpace(req.GameName) == "" {
|
|
return downstream.UnaryResult{}, errors.New("execute lobby.game.create: game_name must not be empty")
|
|
}
|
|
if strings.TrimSpace(req.TurnSchedule) == "" {
|
|
return downstream.UnaryResult{}, errors.New("execute lobby.game.create: turn_schedule must not be empty")
|
|
}
|
|
if strings.TrimSpace(req.TargetEngineVersion) == "" {
|
|
return downstream.UnaryResult{}, errors.New("execute lobby.game.create: target_engine_version must not be empty")
|
|
}
|
|
if req.MinPlayers <= 0 || req.MaxPlayers <= 0 {
|
|
return downstream.UnaryResult{}, errors.New("execute lobby.game.create: min_players and max_players must be positive")
|
|
}
|
|
if req.MinPlayers > req.MaxPlayers {
|
|
return downstream.UnaryResult{}, errors.New("execute lobby.game.create: min_players must not exceed max_players")
|
|
}
|
|
if req.EnrollmentEndsAt.IsZero() {
|
|
return downstream.UnaryResult{}, errors.New("execute lobby.game.create: enrollment_ends_at must be set")
|
|
}
|
|
|
|
body := map[string]any{
|
|
"game_name": req.GameName,
|
|
"visibility": "private",
|
|
"description": req.Description,
|
|
"min_players": int32(req.MinPlayers),
|
|
"max_players": int32(req.MaxPlayers),
|
|
"start_gap_hours": int32(req.StartGapHours),
|
|
"start_gap_players": int32(req.StartGapPlayers),
|
|
"enrollment_ends_at": req.EnrollmentEndsAt.UTC().Format(time.RFC3339Nano),
|
|
"turn_schedule": req.TurnSchedule,
|
|
"target_engine_version": req.TargetEngineVersion,
|
|
}
|
|
payload, status, err := c.do(ctx, http.MethodPost, c.baseURL+"/api/v1/user/lobby/games", userID, body)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.game.create: %w", err)
|
|
}
|
|
if status == http.StatusOK || status == http.StatusCreated {
|
|
summary, err := decodeGameSummaryFromGameDetail(payload)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, err
|
|
}
|
|
payloadBytes, err := transcoder.GameCreateResponseToPayload(&lobbymodel.GameCreateResponse{Game: summary})
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
|
}
|
|
return downstream.UnaryResult{
|
|
ResultCode: lobbyResultCodeOK,
|
|
PayloadBytes: payloadBytes,
|
|
}, nil
|
|
}
|
|
return projectLobbyErrorResponse(status, payload)
|
|
}
|
|
|
|
func (c *RESTClient) executeLobbyApplicationSubmit(ctx context.Context, userID string, req *lobbymodel.ApplicationSubmitRequest) (downstream.UnaryResult, error) {
|
|
if req == nil || strings.TrimSpace(req.GameID) == "" {
|
|
return downstream.UnaryResult{}, errors.New("execute lobby.application.submit: game_id must not be empty")
|
|
}
|
|
if strings.TrimSpace(req.RaceName) == "" {
|
|
return downstream.UnaryResult{}, errors.New("execute lobby.application.submit: race_name must not be empty")
|
|
}
|
|
target := c.baseURL + "/api/v1/user/lobby/games/" + url.PathEscape(req.GameID) + "/applications"
|
|
body := map[string]any{"race_name": req.RaceName}
|
|
payload, status, err := c.do(ctx, http.MethodPost, target, userID, body)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.application.submit: %w", err)
|
|
}
|
|
if status == http.StatusOK || status == http.StatusCreated {
|
|
app, err := decodeApplicationDetail(payload)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, err
|
|
}
|
|
payloadBytes, err := transcoder.ApplicationSubmitResponseToPayload(&lobbymodel.ApplicationSubmitResponse{Application: app})
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
|
}
|
|
return downstream.UnaryResult{
|
|
ResultCode: lobbyResultCodeOK,
|
|
PayloadBytes: payloadBytes,
|
|
}, nil
|
|
}
|
|
return projectLobbyErrorResponse(status, payload)
|
|
}
|
|
|
|
func (c *RESTClient) executeLobbyInviteRedeem(ctx context.Context, userID string, req *lobbymodel.InviteRedeemRequest) (downstream.UnaryResult, error) {
|
|
if req == nil || strings.TrimSpace(req.GameID) == "" || strings.TrimSpace(req.InviteID) == "" {
|
|
return downstream.UnaryResult{}, errors.New("execute lobby.invite.redeem: game_id and invite_id must not be empty")
|
|
}
|
|
target := c.baseURL + "/api/v1/user/lobby/games/" + url.PathEscape(req.GameID) + "/invites/" + url.PathEscape(req.InviteID) + "/redeem"
|
|
payload, status, err := c.do(ctx, http.MethodPost, target, userID, nil)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.invite.redeem: %w", err)
|
|
}
|
|
if status == http.StatusOK {
|
|
invite, err := decodeInviteDetail(payload)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, err
|
|
}
|
|
payloadBytes, err := transcoder.InviteRedeemResponseToPayload(&lobbymodel.InviteRedeemResponse{Invite: invite})
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
|
}
|
|
return downstream.UnaryResult{
|
|
ResultCode: lobbyResultCodeOK,
|
|
PayloadBytes: payloadBytes,
|
|
}, nil
|
|
}
|
|
return projectLobbyErrorResponse(status, payload)
|
|
}
|
|
|
|
func (c *RESTClient) executeLobbyInviteDecline(ctx context.Context, userID string, req *lobbymodel.InviteDeclineRequest) (downstream.UnaryResult, error) {
|
|
if req == nil || strings.TrimSpace(req.GameID) == "" || strings.TrimSpace(req.InviteID) == "" {
|
|
return downstream.UnaryResult{}, errors.New("execute lobby.invite.decline: game_id and invite_id must not be empty")
|
|
}
|
|
target := c.baseURL + "/api/v1/user/lobby/games/" + url.PathEscape(req.GameID) + "/invites/" + url.PathEscape(req.InviteID) + "/decline"
|
|
payload, status, err := c.do(ctx, http.MethodPost, target, userID, nil)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.invite.decline: %w", err)
|
|
}
|
|
if status == http.StatusOK {
|
|
invite, err := decodeInviteDetail(payload)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, err
|
|
}
|
|
payloadBytes, err := transcoder.InviteDeclineResponseToPayload(&lobbymodel.InviteDeclineResponse{Invite: invite})
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
|
}
|
|
return downstream.UnaryResult{
|
|
ResultCode: lobbyResultCodeOK,
|
|
PayloadBytes: payloadBytes,
|
|
}, nil
|
|
}
|
|
return projectLobbyErrorResponse(status, payload)
|
|
}
|
|
|
|
// decodeGameSummaryFromGameDetail accepts the backend's full
|
|
// LobbyGameDetail wire shape and projects it onto the gateway's
|
|
// GameSummary contract. It uses non-strict JSON decoding so the
|
|
// gateway tolerates the runtime/engine fields it does not forward to
|
|
// 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"`
|
|
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)
|
|
}
|
|
owner := ""
|
|
if wire.OwnerUserID != nil {
|
|
owner = *wire.OwnerUserID
|
|
}
|
|
return lobbymodel.GameSummary{
|
|
GameID: wire.GameID,
|
|
GameName: wire.GameName,
|
|
GameType: wire.GameType,
|
|
Status: wire.Status,
|
|
OwnerUserID: owner,
|
|
MinPlayers: wire.MinPlayers,
|
|
MaxPlayers: wire.MaxPlayers,
|
|
EnrollmentEndsAt: wire.EnrollmentEndsAt.UTC(),
|
|
CreatedAt: wire.CreatedAt.UTC(),
|
|
UpdatedAt: wire.UpdatedAt.UTC(),
|
|
CurrentTurn: wire.CurrentTurn,
|
|
}, nil
|
|
}
|
|
|
|
func decodePublicGamesPage(payload []byte) (*lobbymodel.PublicGamesListResponse, error) {
|
|
var wire struct {
|
|
Items []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"`
|
|
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"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal(payload, &wire); err != nil {
|
|
return nil, fmt.Errorf("decode success response: %w", err)
|
|
}
|
|
out := &lobbymodel.PublicGamesListResponse{
|
|
Items: make([]lobbymodel.GameSummary, 0, len(wire.Items)),
|
|
Page: wire.Page,
|
|
PageSize: wire.PageSize,
|
|
Total: wire.Total,
|
|
}
|
|
for _, w := range wire.Items {
|
|
owner := ""
|
|
if w.OwnerUserID != nil {
|
|
owner = *w.OwnerUserID
|
|
}
|
|
out.Items = append(out.Items, lobbymodel.GameSummary{
|
|
GameID: w.GameID,
|
|
GameName: w.GameName,
|
|
GameType: w.GameType,
|
|
Status: w.Status,
|
|
OwnerUserID: owner,
|
|
MinPlayers: w.MinPlayers,
|
|
MaxPlayers: w.MaxPlayers,
|
|
EnrollmentEndsAt: w.EnrollmentEndsAt.UTC(),
|
|
CreatedAt: w.CreatedAt.UTC(),
|
|
UpdatedAt: w.UpdatedAt.UTC(),
|
|
CurrentTurn: w.CurrentTurn,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func decodeApplicationsList(payload []byte) (*lobbymodel.MyApplicationsListResponse, error) {
|
|
var wire struct {
|
|
Items []applicationDetailWire `json:"items"`
|
|
}
|
|
if err := json.Unmarshal(payload, &wire); err != nil {
|
|
return nil, fmt.Errorf("decode success response: %w", err)
|
|
}
|
|
out := &lobbymodel.MyApplicationsListResponse{
|
|
Items: make([]lobbymodel.ApplicationSummary, 0, len(wire.Items)),
|
|
}
|
|
for _, w := range wire.Items {
|
|
out.Items = append(out.Items, w.toModel())
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func decodeApplicationDetail(payload []byte) (lobbymodel.ApplicationSummary, error) {
|
|
var wire applicationDetailWire
|
|
if err := json.Unmarshal(payload, &wire); err != nil {
|
|
return lobbymodel.ApplicationSummary{}, fmt.Errorf("decode success response: %w", err)
|
|
}
|
|
return wire.toModel(), nil
|
|
}
|
|
|
|
func decodeInvitesList(payload []byte) (*lobbymodel.MyInvitesListResponse, error) {
|
|
var wire struct {
|
|
Items []inviteDetailWire `json:"items"`
|
|
}
|
|
if err := json.Unmarshal(payload, &wire); err != nil {
|
|
return nil, fmt.Errorf("decode success response: %w", err)
|
|
}
|
|
out := &lobbymodel.MyInvitesListResponse{
|
|
Items: make([]lobbymodel.InviteSummary, 0, len(wire.Items)),
|
|
}
|
|
for _, w := range wire.Items {
|
|
out.Items = append(out.Items, w.toModel())
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func decodeInviteDetail(payload []byte) (lobbymodel.InviteSummary, error) {
|
|
var wire inviteDetailWire
|
|
if err := json.Unmarshal(payload, &wire); err != nil {
|
|
return lobbymodel.InviteSummary{}, fmt.Errorf("decode success response: %w", err)
|
|
}
|
|
return wire.toModel(), nil
|
|
}
|
|
|
|
type applicationDetailWire 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 time.Time `json:"created_at"`
|
|
DecidedAt *time.Time `json:"decided_at,omitempty"`
|
|
}
|
|
|
|
func (w applicationDetailWire) toModel() lobbymodel.ApplicationSummary {
|
|
out := lobbymodel.ApplicationSummary{
|
|
ApplicationID: w.ApplicationID,
|
|
GameID: w.GameID,
|
|
ApplicantUserID: w.ApplicantUserID,
|
|
RaceName: w.RaceName,
|
|
Status: w.Status,
|
|
CreatedAt: w.CreatedAt.UTC(),
|
|
}
|
|
if w.DecidedAt != nil {
|
|
t := w.DecidedAt.UTC()
|
|
out.DecidedAt = &t
|
|
}
|
|
return out
|
|
}
|
|
|
|
type inviteDetailWire 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 time.Time `json:"created_at"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
DecidedAt *time.Time `json:"decided_at,omitempty"`
|
|
}
|
|
|
|
func (w inviteDetailWire) toModel() lobbymodel.InviteSummary {
|
|
out := lobbymodel.InviteSummary{
|
|
InviteID: w.InviteID,
|
|
GameID: w.GameID,
|
|
InviterUserID: w.InviterUserID,
|
|
RaceName: w.RaceName,
|
|
Status: w.Status,
|
|
CreatedAt: w.CreatedAt.UTC(),
|
|
ExpiresAt: w.ExpiresAt.UTC(),
|
|
}
|
|
if w.InvitedUserID != nil {
|
|
out.InvitedUserID = *w.InvitedUserID
|
|
}
|
|
if w.Code != nil {
|
|
out.Code = *w.Code
|
|
}
|
|
if w.DecidedAt != nil {
|
|
t := w.DecidedAt.UTC()
|
|
out.DecidedAt = &t
|
|
}
|
|
return out
|
|
}
|
|
|
|
func projectLobbyErrorResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) {
|
|
switch {
|
|
case statusCode == http.StatusServiceUnavailable:
|
|
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
|
case statusCode >= 400 && statusCode <= 599:
|
|
errResp, err := decodeLobbyError(statusCode, payload)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("decode error response: %w", err)
|
|
}
|
|
payloadBytes, err := transcoder.LobbyErrorResponseToPayload(errResp)
|
|
if err != nil {
|
|
return downstream.UnaryResult{}, fmt.Errorf("encode error response payload: %w", err)
|
|
}
|
|
return downstream.UnaryResult{
|
|
ResultCode: errResp.Error.Code,
|
|
PayloadBytes: payloadBytes,
|
|
}, nil
|
|
default:
|
|
return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode)
|
|
}
|
|
}
|
|
|
|
func decodeLobbyError(statusCode int, payload []byte) (*lobbymodel.ErrorResponse, error) {
|
|
var response lobbymodel.ErrorResponse
|
|
decoder := json.NewDecoder(bytes.NewReader(payload))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&response); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
|
if err == nil {
|
|
return nil, errors.New("unexpected trailing JSON input")
|
|
}
|
|
return nil, err
|
|
}
|
|
response.Error.Code = normalizeLobbyErrorCode(statusCode, response.Error.Code)
|
|
response.Error.Message = normalizeLobbyErrorMessage(response.Error.Code, response.Error.Message)
|
|
if strings.TrimSpace(response.Error.Code) == "" {
|
|
return nil, errors.New("missing error code")
|
|
}
|
|
if strings.TrimSpace(response.Error.Message) == "" {
|
|
return nil, errors.New("missing error message")
|
|
}
|
|
return &response, nil
|
|
}
|
|
|
|
func normalizeLobbyErrorCode(statusCode int, code string) string {
|
|
if trimmed := strings.TrimSpace(code); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
switch statusCode {
|
|
case http.StatusBadRequest:
|
|
return defaultLobbyErrorCodeInvalid
|
|
case http.StatusForbidden:
|
|
return defaultLobbyErrorCodeForbid
|
|
case http.StatusNotFound:
|
|
return defaultLobbyErrorCodeNoSubj
|
|
case http.StatusConflict:
|
|
return defaultLobbyErrorCodeConfl
|
|
default:
|
|
return defaultLobbyErrorCodeIntErr
|
|
}
|
|
}
|
|
|
|
func normalizeLobbyErrorMessage(code, message string) string {
|
|
if trimmed := strings.TrimSpace(message); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
if stable, ok := stableLobbyErrorMessages[code]; ok {
|
|
return stable
|
|
}
|
|
return stableLobbyErrorMessages[defaultLobbyErrorCodeIntErr]
|
|
}
|