package server import ( "context" "errors" "net/http" "strconv" "strings" "time" "galaxy/backend/internal/adminconsole" "galaxy/backend/internal/lobby" "galaxy/backend/internal/runtime" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" ) // GameAdmin is the subset of the lobby service the console uses for games. type GameAdmin interface { ListAdminGames(ctx context.Context, page, pageSize int) (lobby.GamePage, error) GetGame(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error) CreateGame(ctx context.Context, input lobby.CreateGameInput) (lobby.GameRecord, error) AdminForceStart(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error) AdminForceStop(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error) AdminBanMember(ctx context.Context, gameID, userID uuid.UUID, reason string) (lobby.Membership, error) } // RuntimeAdmin is the subset of the runtime service the console uses. type RuntimeAdmin interface { GetRuntime(ctx context.Context, gameID uuid.UUID) (runtime.RuntimeRecord, error) AdminRestart(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error) AdminPatch(ctx context.Context, gameID uuid.UUID, targetVersion string) (runtime.OperationLog, error) AdminForceNextTurn(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error) } // EngineVersionAdmin is the subset of the engine-version service the console uses. type EngineVersionAdmin interface { List(ctx context.Context) ([]runtime.EngineVersion, error) Register(ctx context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error) Disable(ctx context.Context, version string) (runtime.EngineVersion, error) } // GamesList renders GET /_gm/games. func (h *AdminConsoleHandlers) GamesList() gin.HandlerFunc { return func(c *gin.Context) { if h.games == nil { h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/") return } page := parsePositiveQueryInt(c.Query("page"), 1) pageSize := parsePositiveQueryInt(c.Query("page_size"), 50) result, err := h.games.ListAdminGames(c.Request.Context(), page, pageSize) if err != nil { h.logger.Error("admin console: list games", zap.Error(err)) h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load games.", "bad", "/_gm/") return } h.render(c, http.StatusOK, "games", "games", "Games", toGamesListData(result)) } } // GameCreate handles POST /_gm/games — create a public game. func (h *AdminConsoleHandlers) GameCreate() gin.HandlerFunc { return func(c *gin.Context) { if h.games == nil { h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/") return } enrollmentEndsAt, err := parseConsoleDateTime(c.PostForm("enrollment_ends_at")) if err != nil { h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "Enrollment end must be a valid date/time.", "bad", "/_gm/games") return } game, err := h.games.CreateGame(c.Request.Context(), lobby.CreateGameInput{ OwnerUserID: nil, Visibility: lobby.VisibilityPublic, GameName: strings.TrimSpace(c.PostForm("game_name")), Description: strings.TrimSpace(c.PostForm("description")), MinPlayers: formInt32(c, "min_players"), MaxPlayers: formInt32(c, "max_players"), StartGapHours: formInt32(c, "start_gap_hours"), StartGapPlayers: formInt32(c, "start_gap_players"), EnrollmentEndsAt: enrollmentEndsAt, TurnSchedule: strings.TrimSpace(c.PostForm("turn_schedule")), TargetEngineVersion: strings.TrimSpace(c.PostForm("target_engine_version")), }) if err != nil { if errors.Is(err, lobby.ErrInvalidInput) { h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "The game could not be created: check the fields.", "bad", "/_gm/games") return } h.logger.Error("admin console: create game", zap.Error(err)) h.renderMessage(c, http.StatusInternalServerError, "games", "Create failed", "Failed to create the game.", "bad", "/_gm/games") return } c.Redirect(http.StatusSeeOther, "/_gm/games/"+game.GameID.String()) } } // GameDetail renders GET /_gm/games/:game_id with the runtime snapshot. func (h *AdminConsoleHandlers) GameDetail() gin.HandlerFunc { return func(c *gin.Context) { if h.games == nil { h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/") return } gameID, ok := parseGameIDParam(c) if !ok { return } game, err := h.games.GetGame(c.Request.Context(), gameID) if err != nil { if errors.Is(err, lobby.ErrNotFound) { h.renderMessage(c, http.StatusNotFound, "games", "Game not found", "No such game.", "bad", "/_gm/games") return } h.logger.Error("admin console: get game", zap.Error(err)) h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load the game.", "bad", "/_gm/games") return } var runtimeRecord *runtime.RuntimeRecord if h.runtime != nil { if record, rtErr := h.runtime.GetRuntime(c.Request.Context(), gameID); rtErr == nil { runtimeRecord = &record } } h.render(c, http.StatusOK, "game_detail", "games", game.GameName, toGameDetailData(game, runtimeRecord)) } } // GameForceStart handles POST /_gm/games/:game_id/force-start. func (h *AdminConsoleHandlers) GameForceStart() gin.HandlerFunc { return h.gameAction("force-start", func(ctx context.Context, gameID uuid.UUID) error { _, err := h.games.AdminForceStart(ctx, gameID) return err }) } // GameForceStop handles POST /_gm/games/:game_id/force-stop. func (h *AdminConsoleHandlers) GameForceStop() gin.HandlerFunc { return h.gameAction("force-stop", func(ctx context.Context, gameID uuid.UUID) error { _, err := h.games.AdminForceStop(ctx, gameID) return err }) } // GameBanMember handles POST /_gm/games/:game_id/ban-member. func (h *AdminConsoleHandlers) GameBanMember() gin.HandlerFunc { return func(c *gin.Context) { if h.games == nil { h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/") return } gameID, ok := parseGameIDParam(c) if !ok { return } back := "/_gm/games/" + gameID.String() userID, err := uuid.Parse(strings.TrimSpace(c.PostForm("user_id"))) if err != nil { h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "User ID must be a valid UUID.", "bad", back) return } if _, err := h.games.AdminBanMember(c.Request.Context(), gameID, userID, strings.TrimSpace(c.PostForm("reason"))); err != nil { h.logger.Error("admin console: ban member", zap.Error(err)) h.renderMessage(c, http.StatusInternalServerError, "games", "Ban failed", "Failed to ban the member.", "bad", back) return } c.Redirect(http.StatusSeeOther, back) } } // RuntimeRestart handles POST /_gm/games/:game_id/runtime/restart. func (h *AdminConsoleHandlers) RuntimeRestart() gin.HandlerFunc { return h.runtimeAction("restart", func(ctx context.Context, gameID uuid.UUID) error { _, err := h.runtime.AdminRestart(ctx, gameID) return err }) } // RuntimeForceNextTurn handles POST /_gm/games/:game_id/runtime/force-next-turn. func (h *AdminConsoleHandlers) RuntimeForceNextTurn() gin.HandlerFunc { return h.runtimeAction("force-next-turn", func(ctx context.Context, gameID uuid.UUID) error { _, err := h.runtime.AdminForceNextTurn(ctx, gameID) return err }) } // RuntimePatch handles POST /_gm/games/:game_id/runtime/patch. func (h *AdminConsoleHandlers) RuntimePatch() gin.HandlerFunc { return func(c *gin.Context) { if h.runtime == nil { h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games") return } gameID, ok := parseGameIDParam(c) if !ok { return } back := "/_gm/games/" + gameID.String() target := strings.TrimSpace(c.PostForm("target_version")) if target == "" { h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "A target version is required.", "bad", back) return } if _, err := h.runtime.AdminPatch(c.Request.Context(), gameID, target); err != nil { h.logger.Error("admin console: runtime patch", zap.Error(err)) h.renderMessage(c, http.StatusInternalServerError, "games", "Patch failed", "Failed to patch the runtime.", "bad", back) return } c.Redirect(http.StatusSeeOther, back) } } // gameAction is the shared shape for game-state POST actions that take only the // game id and redirect back to the detail page. func (h *AdminConsoleHandlers) gameAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc { return func(c *gin.Context) { if h.games == nil { h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/") return } gameID, ok := parseGameIDParam(c) if !ok { return } back := "/_gm/games/" + gameID.String() if err := run(c.Request.Context(), gameID); err != nil { h.logger.Error("admin console: game "+label, zap.Error(err)) h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The "+label+" action failed.", "bad", back) return } c.Redirect(http.StatusSeeOther, back) } } // runtimeAction is the shared shape for runtime POST actions that take only the // game id and redirect back to the detail page. func (h *AdminConsoleHandlers) runtimeAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc { return func(c *gin.Context) { if h.runtime == nil { h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games") return } gameID, ok := parseGameIDParam(c) if !ok { return } back := "/_gm/games/" + gameID.String() if err := run(c.Request.Context(), gameID); err != nil { h.logger.Error("admin console: runtime "+label, zap.Error(err)) h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The runtime "+label+" action failed.", "bad", back) return } c.Redirect(http.StatusSeeOther, back) } } // EngineVersionsList renders GET /_gm/engine-versions. func (h *AdminConsoleHandlers) EngineVersionsList() gin.HandlerFunc { return func(c *gin.Context) { if h.engineVersions == nil { h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/") return } items, err := h.engineVersions.List(c.Request.Context()) if err != nil { h.logger.Error("admin console: list engine versions", zap.Error(err)) h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Engine versions", "Failed to load engine versions.", "bad", "/_gm/") return } h.render(c, http.StatusOK, "engine_versions", "games", "Engine versions", toEngineVersionsData(items)) } } // EngineVersionRegister handles POST /_gm/engine-versions. func (h *AdminConsoleHandlers) EngineVersionRegister() gin.HandlerFunc { return func(c *gin.Context) { if h.engineVersions == nil { h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/") return } enabled := c.PostForm("enabled") == "true" _, err := h.engineVersions.Register(c.Request.Context(), runtime.RegisterInput{ Version: strings.TrimSpace(c.PostForm("version")), ImageRef: strings.TrimSpace(c.PostForm("image_ref")), Enabled: &enabled, }) if err != nil { if errors.Is(err, runtime.ErrInvalidInput) || errors.Is(err, runtime.ErrEngineVersionTaken) { h.renderMessage(c, http.StatusBadRequest, "engine-versions", "Invalid input", "The version could not be registered (invalid semver, missing image, or duplicate).", "bad", "/_gm/engine-versions") return } h.logger.Error("admin console: register engine version", zap.Error(err)) h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Register failed", "Failed to register the engine version.", "bad", "/_gm/engine-versions") return } c.Redirect(http.StatusSeeOther, "/_gm/engine-versions") } } // EngineVersionDisable handles POST /_gm/engine-versions/:version/disable. func (h *AdminConsoleHandlers) EngineVersionDisable() gin.HandlerFunc { return func(c *gin.Context) { if h.engineVersions == nil { h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/") return } version := strings.TrimSpace(c.Param("version")) if _, err := h.engineVersions.Disable(c.Request.Context(), version); err != nil { h.logger.Error("admin console: disable engine version", zap.Error(err)) h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Disable failed", "Failed to disable the engine version.", "bad", "/_gm/engine-versions") return } c.Redirect(http.StatusSeeOther, "/_gm/engine-versions") } } // formInt32 reads a non-negative int32 form field, defaulting to 0. func formInt32(c *gin.Context, name string) int32 { parsed, err := strconv.Atoi(strings.TrimSpace(c.PostForm(name))) if err != nil || parsed < 0 { return 0 } return int32(parsed) } // parseConsoleDateTime parses the value of an // (or an RFC 3339 timestamp) as UTC. func parseConsoleDateTime(raw string) (time.Time, error) { raw = strings.TrimSpace(raw) for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", time.RFC3339} { if t, err := time.ParseInLocation(layout, raw, time.UTC); err == nil { return t.UTC(), nil } } return time.Time{}, errors.New("invalid date/time") } // toGamesListData maps a game page into the games list view model. func toGamesListData(page lobby.GamePage) adminconsole.GamesListData { data := adminconsole.GamesListData{ Items: make([]adminconsole.GameRow, 0, len(page.Items)), Page: page.Page, PageSize: page.PageSize, Total: page.Total, PrevPage: page.Page - 1, NextPage: page.Page + 1, HasPrev: page.Page > 1, HasNext: page.Page*page.PageSize < page.Total, } for _, game := range page.Items { data.Items = append(data.Items, adminconsole.GameRow{ GameID: game.GameID.String(), GameName: game.GameName, Visibility: game.Visibility, Status: game.Status, Owner: ownerLabel(game.OwnerUserID), Players: strconv.Itoa(int(game.MinPlayers)) + "–" + strconv.Itoa(int(game.MaxPlayers)), TurnSchedule: game.TurnSchedule, CreatedAt: fmtConsoleTime(game.CreatedAt), }) } return data } // toGameDetailData maps a game record and optional runtime record into the // detail view model. func toGameDetailData(game lobby.GameRecord, rec *runtime.RuntimeRecord) adminconsole.GameDetailData { data := adminconsole.GameDetailData{ GameID: game.GameID.String(), GameName: game.GameName, Description: game.Description, Visibility: game.Visibility, Status: game.Status, Owner: ownerLabel(game.OwnerUserID), MinPlayers: game.MinPlayers, MaxPlayers: game.MaxPlayers, StartGapHours: game.StartGapHours, StartGapPlayers: game.StartGapPlayers, TurnSchedule: game.TurnSchedule, TargetEngineVersion: game.TargetEngineVersion, EnrollmentEndsAt: fmtConsoleTime(game.EnrollmentEndsAt), CreatedAt: fmtConsoleTime(game.CreatedAt), StartedAt: fmtConsoleTimePtr(game.StartedAt), FinishedAt: fmtConsoleTimePtr(game.FinishedAt), } if rec != nil { data.HasRuntime = true data.RuntimeStatus = rec.Status data.CurrentEngineVersion = rec.CurrentEngineVersion data.EngineHealth = rec.EngineHealth data.CurrentTurn = rec.CurrentTurn data.NextGenerationAt = fmtConsoleTimePtr(rec.NextGenerationAt) data.Paused = rec.Paused } return data } // toEngineVersionsData maps engine versions into the registry view model. func toEngineVersionsData(items []runtime.EngineVersion) adminconsole.EngineVersionsData { data := adminconsole.EngineVersionsData{Items: make([]adminconsole.EngineVersionRow, 0, len(items))} for _, v := range items { data.Items = append(data.Items, adminconsole.EngineVersionRow{ Version: v.Version, ImageRef: v.ImageRef, Enabled: v.Enabled, CreatedAt: fmtConsoleTime(v.CreatedAt), }) } return data } // ownerLabel renders an optional owner id; public games have no owner. func ownerLabel(ownerID *uuid.UUID) string { if ownerID == nil { return "—" } return ownerID.String() }