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"` } 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(), }, 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"` } `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(), }) } 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] }