319 lines
10 KiB
Go
319 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"`
|
|
}
|
|
|
|
// lobbyGameDetailWire mirrors `LobbyGameDetail` from openapi.yaml.
|
|
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"`
|
|
CurrentTurn int32 `json:"current_turn"`
|
|
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),
|
|
}
|
|
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,
|
|
CurrentTurn: g.RuntimeSnapshot.CurrentTurn,
|
|
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
|
|
}
|