diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go
index 07435f9..3c2ab5a 100644
--- a/backend/cmd/backend/main.go
+++ b/backend/cmd/backend/main.go
@@ -375,10 +375,13 @@ func run(ctx context.Context) (err error) {
}
adminConsoleHandlers := backendserver.NewAdminConsoleHandlers(backendserver.AdminConsoleDeps{
CSRF: consoleCSRF,
- Monitor: opsstatus.NewStore(db),
- Ready: ready,
- Users: userSvc,
- Logger: logger,
+ Monitor: opsstatus.NewStore(db),
+ Ready: ready,
+ Users: userSvc,
+ Games: lobbySvc,
+ Runtime: runtimeSvc,
+ EngineVersions: engineVersionSvc,
+ Logger: logger,
})
handler, err := backendserver.NewRouter(backendserver.RouterDependencies{
diff --git a/backend/docs/admin-console.md b/backend/docs/admin-console.md
index 929234f..d181a81 100644
--- a/backend/docs/admin-console.md
+++ b/backend/docs/admin-console.md
@@ -91,10 +91,23 @@ changes.
| `/_gm/users/{id}/block` | POST | Apply a permanent block (reason required). |
| `/_gm/users/{id}/entitlement` | POST | Set the entitlement tier. |
| `/_gm/users/{id}/soft-delete` | POST | Soft-delete the account (cascades). |
+| `/_gm/games` | GET/POST | Paginated game list; POST creates a public game. |
+| `/_gm/games/{id}` | GET | Game detail with the runtime snapshot. |
+| `/_gm/games/{id}/force-start` | POST | Force-start the game. |
+| `/_gm/games/{id}/force-stop` | POST | Force-stop the game. |
+| `/_gm/games/{id}/ban-member` | POST | Ban a member (user id + reason). |
+| `/_gm/games/{id}/runtime/restart` | POST | Restart the engine container. |
+| `/_gm/games/{id}/runtime/patch` | POST | Patch the runtime to a target version. |
+| `/_gm/games/{id}/runtime/force-next-turn` | POST | Force the next turn now. |
+| `/_gm/engine-versions` | GET/POST | Version registry; POST registers a version. |
+| `/_gm/engine-versions/{ver}/disable` | POST | Disable a registered version. |
Each page reuses the same service layer as the corresponding `/api/v1/admin/*`
-JSON endpoint; the console adds no business logic. Unblocking a user is not yet
-available because the JSON admin API exposes no remove-sanction endpoint.
+JSON endpoint; the console adds no business logic. Collection-mutating POSTs are
+mounted on the collection path (`POST /_gm/games`, `POST /_gm/engine-versions`)
+so a static action segment never collides with a path parameter in the gin
+router. Unblocking a user is not yet available because the JSON admin API
+exposes no remove-sanction endpoint.
## Configuration
diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css
index a34bc8d..b4550df 100644
--- a/backend/internal/adminconsole/assets/console.css
+++ b/backend/internal/adminconsole/assets/console.css
@@ -98,3 +98,5 @@ button {
button:hover { filter: brightness(1.1); }
button.danger { background: var(--danger); color: #1a0606; }
code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
+.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
+.actions form { margin: 0; }
diff --git a/backend/internal/adminconsole/games.go b/backend/internal/adminconsole/games.go
new file mode 100644
index 0000000..eb733ff
--- /dev/null
+++ b/backend/internal/adminconsole/games.go
@@ -0,0 +1,67 @@
+package adminconsole
+
+// GameRow is one line in the games list table.
+type GameRow struct {
+ GameID string
+ GameName string
+ Visibility string
+ Status string
+ Owner string
+ Players string
+ TurnSchedule string
+ CreatedAt string
+}
+
+// GamesListData is the view model for the paginated games list.
+type GamesListData struct {
+ Items []GameRow
+ Page int
+ PageSize int
+ Total int
+ HasPrev bool
+ HasNext bool
+ PrevPage int
+ NextPage int
+}
+
+// GameDetailData is the view model for a single game, combining the lobby
+// record with the runtime snapshot and the available actions.
+type GameDetailData struct {
+ GameID string
+ GameName string
+ Description string
+ Visibility string
+ Status string
+ Owner string
+ MinPlayers int32
+ MaxPlayers int32
+ StartGapHours int32
+ StartGapPlayers int32
+ TurnSchedule string
+ TargetEngineVersion string
+ EnrollmentEndsAt string
+ CreatedAt string
+ StartedAt string
+ FinishedAt string
+
+ HasRuntime bool
+ RuntimeStatus string
+ CurrentEngineVersion string
+ EngineHealth string
+ CurrentTurn int32
+ NextGenerationAt string
+ Paused bool
+}
+
+// EngineVersionRow is one line in the engine-version registry table.
+type EngineVersionRow struct {
+ Version string
+ ImageRef string
+ Enabled bool
+ CreatedAt string
+}
+
+// EngineVersionsData is the view model for the engine-version registry page.
+type EngineVersionsData struct {
+ Items []EngineVersionRow
+}
diff --git a/backend/internal/adminconsole/templates/pages/engine_versions.gohtml b/backend/internal/adminconsole/templates/pages/engine_versions.gohtml
new file mode 100644
index 0000000..c68e7c3
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/engine_versions.gohtml
@@ -0,0 +1,30 @@
+{{define "content" -}}
+{{$csrf := .CSRFToken}}
+
Engine versions
+{{with .Data}}
+
+Version Image Enabled Created
+
+{{range .Items}}
+
+{{.Version}}
+{{.ImageRef}}
+{{if .Enabled}}yes {{else}}no {{end}}
+{{.CreatedAt}}
+{{if .Enabled}}{{end}}
+
+{{else}}no engine versions {{end}}
+
+
+{{end}}
+
+{{- end}}
diff --git a/backend/internal/adminconsole/templates/pages/game_detail.gohtml b/backend/internal/adminconsole/templates/pages/game_detail.gohtml
new file mode 100644
index 0000000..990a96b
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/game_detail.gohtml
@@ -0,0 +1,65 @@
+{{define "content" -}}
+{{$csrf := .CSRFToken}}
+{{with .Data}}
+« all games
+{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}
+
+
+Game
+
+Game ID: {{.GameID}}
+Visibility: {{.Visibility}}
+Status: {{.Status}}
+Owner: {{.Owner}}
+Players: {{.MinPlayers}}–{{.MaxPlayers}}
+Start gap: {{.StartGapHours}}h / {{.StartGapPlayers}} players
+Turn schedule: {{.TurnSchedule}}
+Target engine: {{.TargetEngineVersion}}
+Enrollment ends: {{.EnrollmentEndsAt}}
+Created: {{.CreatedAt}}
+Started: {{if .StartedAt}}{{.StartedAt}}{{else}}—{{end}}
+Finished: {{if .FinishedAt}}{{.FinishedAt}}{{else}}—{{end}}
+
+{{if .Description}}{{.Description}}
{{end}}
+
+
+
+
+
+
+
+Runtime
+{{if .HasRuntime}}
+
+Status: {{.RuntimeStatus}}
+Engine version: {{.CurrentEngineVersion}}
+Engine health: {{.EngineHealth}}
+Current turn: {{.CurrentTurn}}
+Next generation: {{if .NextGenerationAt}}{{.NextGenerationAt}}{{else}}—{{end}}
+Paused: {{if .Paused}}yes{{else}}no{{end}}
+
+
+
+
+
+
+{{else}}
+No runtime record for this game yet.
+{{end}}
+
+
+
+{{end}}
+{{- end}}
diff --git a/backend/internal/adminconsole/templates/pages/games.gohtml b/backend/internal/adminconsole/templates/pages/games.gohtml
new file mode 100644
index 0000000..d213cdb
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/games.gohtml
@@ -0,0 +1,43 @@
+{{define "content" -}}
+{{$csrf := .CSRFToken}}
+Games
+{{with .Data}}
+
+
+{{end}}
+
+{{- end}}
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index 6821728..cf9b834 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -26,10 +26,13 @@ type AdminConsoleHandlers struct {
renderer *adminconsole.Renderer
csrf *adminconsole.CSRF
assets http.Handler
- monitor opsstatus.Reader
- ready func() bool
- users UserAdmin
- logger *zap.Logger
+ monitor opsstatus.Reader
+ ready func() bool
+ users UserAdmin
+ games GameAdmin
+ runtime RuntimeAdmin
+ engineVersions EngineVersionAdmin
+ logger *zap.Logger
}
// AdminConsoleDeps bundles the collaborators for the operator console. Every
@@ -40,10 +43,13 @@ type AdminConsoleHandlers struct {
type AdminConsoleDeps struct {
Renderer *adminconsole.Renderer
CSRF *adminconsole.CSRF
- Monitor opsstatus.Reader
- Ready func() bool
- Users UserAdmin
- Logger *zap.Logger
+ Monitor opsstatus.Reader
+ Ready func() bool
+ Users UserAdmin
+ Games GameAdmin
+ Runtime RuntimeAdmin
+ EngineVersions EngineVersionAdmin
+ Logger *zap.Logger
}
// NewAdminConsoleHandlers constructs the console handler set from deps. It
@@ -77,10 +83,13 @@ func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers {
renderer: renderer,
csrf: csrf,
assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))),
- monitor: deps.Monitor,
- ready: deps.Ready,
- users: deps.Users,
- logger: logger.Named("http.admin.console"),
+ monitor: deps.Monitor,
+ ready: deps.Ready,
+ users: deps.Users,
+ games: deps.Games,
+ runtime: deps.Runtime,
+ engineVersions: deps.EngineVersions,
+ logger: logger.Named("http.admin.console"),
}
}
diff --git a/backend/internal/server/handlers_admin_console_games.go b/backend/internal/server/handlers_admin_console_games.go
new file mode 100644
index 0000000..b1ac5b9
--- /dev/null
+++ b/backend/internal/server/handlers_admin_console_games.go
@@ -0,0 +1,423 @@
+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()
+}
diff --git a/backend/internal/server/handlers_admin_console_games_test.go b/backend/internal/server/handlers_admin_console_games_test.go
new file mode 100644
index 0000000..3e31f42
--- /dev/null
+++ b/backend/internal/server/handlers_admin_console_games_test.go
@@ -0,0 +1,353 @@
+package server
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "galaxy/backend/internal/adminconsole"
+ "galaxy/backend/internal/lobby"
+ "galaxy/backend/internal/runtime"
+ "galaxy/backend/internal/server/middleware/basicauth"
+
+ "github.com/google/uuid"
+ "go.uber.org/zap"
+)
+
+type fakeGameAdmin struct {
+ page lobby.GamePage
+ game lobby.GameRecord
+ getErr error
+ created lobby.CreateGameInput
+
+ createCalls int
+ forceStartCalls int
+ forceStopCalls int
+ banCalls int
+ lastBanUser uuid.UUID
+ lastBanReason string
+}
+
+func (f *fakeGameAdmin) ListAdminGames(context.Context, int, int) (lobby.GamePage, error) {
+ return f.page, nil
+}
+func (f *fakeGameAdmin) GetGame(context.Context, uuid.UUID) (lobby.GameRecord, error) {
+ return f.game, f.getErr
+}
+func (f *fakeGameAdmin) CreateGame(_ context.Context, in lobby.CreateGameInput) (lobby.GameRecord, error) {
+ f.createCalls++
+ f.created = in
+ return f.game, nil
+}
+func (f *fakeGameAdmin) AdminForceStart(context.Context, uuid.UUID) (lobby.GameRecord, error) {
+ f.forceStartCalls++
+ return f.game, nil
+}
+func (f *fakeGameAdmin) AdminForceStop(context.Context, uuid.UUID) (lobby.GameRecord, error) {
+ f.forceStopCalls++
+ return f.game, nil
+}
+func (f *fakeGameAdmin) AdminBanMember(_ context.Context, _, userID uuid.UUID, reason string) (lobby.Membership, error) {
+ f.banCalls++
+ f.lastBanUser = userID
+ f.lastBanReason = reason
+ return lobby.Membership{}, nil
+}
+
+type fakeRuntimeAdmin struct {
+ record runtime.RuntimeRecord
+ getErr error
+ restartCalls int
+ forceNextCalls int
+ patchCalls int
+ lastPatchVersion string
+}
+
+func (f *fakeRuntimeAdmin) GetRuntime(context.Context, uuid.UUID) (runtime.RuntimeRecord, error) {
+ return f.record, f.getErr
+}
+func (f *fakeRuntimeAdmin) AdminRestart(context.Context, uuid.UUID) (runtime.OperationLog, error) {
+ f.restartCalls++
+ return runtime.OperationLog{}, nil
+}
+func (f *fakeRuntimeAdmin) AdminPatch(_ context.Context, _ uuid.UUID, target string) (runtime.OperationLog, error) {
+ f.patchCalls++
+ f.lastPatchVersion = target
+ return runtime.OperationLog{}, nil
+}
+func (f *fakeRuntimeAdmin) AdminForceNextTurn(context.Context, uuid.UUID) (runtime.OperationLog, error) {
+ f.forceNextCalls++
+ return runtime.OperationLog{}, nil
+}
+
+type fakeEngineVersionAdmin struct {
+ list []runtime.EngineVersion
+ registered runtime.RegisterInput
+ registerCalls int
+ disableCalls int
+ lastDisabled string
+}
+
+func (f *fakeEngineVersionAdmin) List(context.Context) ([]runtime.EngineVersion, error) {
+ return f.list, nil
+}
+func (f *fakeEngineVersionAdmin) Register(_ context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error) {
+ f.registerCalls++
+ f.registered = in
+ return runtime.EngineVersion{}, nil
+}
+func (f *fakeEngineVersionAdmin) Disable(_ context.Context, version string) (runtime.EngineVersion, error) {
+ f.disableCalls++
+ f.lastDisabled = version
+ return runtime.EngineVersion{}, nil
+}
+
+func newGamesConsoleRouter(t *testing.T, games GameAdmin, rt RuntimeAdmin, ev EngineVersionAdmin) (http.Handler, *adminconsole.CSRF) {
+ t.Helper()
+ csrf := adminconsole.NewCSRF([]byte("test-key"))
+ handler, err := NewRouter(RouterDependencies{
+ Logger: zap.NewNop(),
+ AdminVerifier: basicauth.NewStaticVerifier("secret"),
+ AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{
+ CSRF: csrf, Games: games, Runtime: rt, EngineVersions: ev,
+ }),
+ })
+ if err != nil {
+ t.Fatalf("NewRouter: %v", err)
+ }
+ return handler, csrf
+}
+
+func consoleGet(t *testing.T, router http.Handler, path string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ return rec
+}
+
+func consolePost(t *testing.T, router http.Handler, path, form string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan"+path, strings.NewReader(form))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", "https://galaxy.lan")
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ return rec
+}
+
+func TestConsoleGamesList(t *testing.T) {
+ games := &fakeGameAdmin{page: lobby.GamePage{
+ Items: []lobby.GameRecord{{GameID: uuid.New(), GameName: "Nova", Visibility: "public", Status: "enrollment_open"}},
+ Page: 1, PageSize: 50, Total: 1,
+ }}
+ router, _ := newGamesConsoleRouter(t, games, nil, nil)
+
+ rec := consoleGet(t, router, "/_gm/games")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ for _, want := range []string{"Nova", "public", "enrollment_open", "Create public game"} {
+ if !strings.Contains(rec.Body.String(), want) {
+ t.Errorf("games list missing %q", want)
+ }
+ }
+}
+
+func TestConsoleGameDetailWithRuntime(t *testing.T) {
+ id := uuid.New()
+ games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova", Status: "running"}}
+ rt := &fakeRuntimeAdmin{record: runtime.RuntimeRecord{GameID: id, Status: "running", CurrentEngineVersion: "0.1.0", CurrentTurn: 7}}
+ router, csrf := newGamesConsoleRouter(t, games, rt, nil)
+
+ rec := consoleGet(t, router, "/_gm/games/"+id.String())
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ for _, want := range []string{"Nova", "Force start", "Force stop", "0.1.0", "Patch", "Ban member", csrf.Token("ops")} {
+ if !strings.Contains(rec.Body.String(), want) {
+ t.Errorf("game detail missing %q", want)
+ }
+ }
+}
+
+func TestConsoleGameDetailNoRuntime(t *testing.T) {
+ id := uuid.New()
+ games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova"}}
+ rt := &fakeRuntimeAdmin{getErr: errors.New("not found")}
+ router, _ := newGamesConsoleRouter(t, games, rt, nil)
+
+ rec := consoleGet(t, router, "/_gm/games/"+id.String())
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "No runtime record") {
+ t.Error("expected a no-runtime note")
+ }
+}
+
+func TestConsoleGameCreate(t *testing.T) {
+ id := uuid.New()
+ games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
+ router, csrf := newGamesConsoleRouter(t, games, nil, nil)
+
+ form := "_csrf=" + csrf.Token("ops") +
+ "&game_name=Nova&description=d&min_players=2&max_players=8&start_gap_hours=0&start_gap_players=0" +
+ "&enrollment_ends_at=2030-01-02T15:04&turn_schedule=@every+24h&target_engine_version=0.1.0"
+ rec := consolePost(t, router, "/_gm/games", form)
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
+ }
+ if got := rec.Header().Get("Location"); got != "/_gm/games/"+id.String() {
+ t.Errorf("redirect = %q, want detail page", got)
+ }
+ if games.createCalls != 1 {
+ t.Fatalf("CreateGame called %d times, want 1", games.createCalls)
+ }
+ if games.created.Visibility != lobby.VisibilityPublic {
+ t.Errorf("visibility = %q, want public", games.created.Visibility)
+ }
+ if games.created.GameName != "Nova" {
+ t.Errorf("game name = %q", games.created.GameName)
+ }
+ if games.created.EnrollmentEndsAt.Year() != 2030 {
+ t.Errorf("enrollment year = %d, want 2030", games.created.EnrollmentEndsAt.Year())
+ }
+ if games.created.OwnerUserID != nil {
+ t.Error("public game must have a nil owner")
+ }
+}
+
+func TestConsoleGameForceStart(t *testing.T) {
+ id := uuid.New()
+ games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
+ router, csrf := newGamesConsoleRouter(t, games, nil, nil)
+
+ rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "_csrf="+csrf.Token("ops"))
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 303", rec.Code)
+ }
+ if games.forceStartCalls != 1 {
+ t.Errorf("AdminForceStart called %d times, want 1", games.forceStartCalls)
+ }
+}
+
+func TestConsoleGameForceStartRejectsBadCSRF(t *testing.T) {
+ id := uuid.New()
+ games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
+ router, _ := newGamesConsoleRouter(t, games, nil, nil)
+
+ rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "")
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want 403", rec.Code)
+ }
+ if games.forceStartCalls != 0 {
+ t.Error("force-start must not run without a CSRF token")
+ }
+}
+
+func TestConsoleGameBanMember(t *testing.T) {
+ gameID := uuid.New()
+ target := uuid.New()
+ games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}}
+ router, csrf := newGamesConsoleRouter(t, games, nil, nil)
+
+ form := "_csrf=" + csrf.Token("ops") + "&user_id=" + target.String() + "&reason=cheating"
+ rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", form)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
+ }
+ if games.banCalls != 1 || games.lastBanUser != target || games.lastBanReason != "cheating" {
+ t.Errorf("ban recorded %d user=%s reason=%q", games.banCalls, games.lastBanUser, games.lastBanReason)
+ }
+}
+
+func TestConsoleGameBanMemberRejectsBadUUID(t *testing.T) {
+ gameID := uuid.New()
+ games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}}
+ router, csrf := newGamesConsoleRouter(t, games, nil, nil)
+
+ rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", "_csrf="+csrf.Token("ops")+"&user_id=not-a-uuid")
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", rec.Code)
+ }
+ if games.banCalls != 0 {
+ t.Error("ban must not run with an invalid user id")
+ }
+}
+
+func TestConsoleRuntimePatch(t *testing.T) {
+ id := uuid.New()
+ games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
+ rt := &fakeRuntimeAdmin{}
+ router, csrf := newGamesConsoleRouter(t, games, rt, nil)
+
+ rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops")+"&target_version=0.1.1")
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 303", rec.Code)
+ }
+ if rt.patchCalls != 1 || rt.lastPatchVersion != "0.1.1" {
+ t.Errorf("patch recorded %d version=%q", rt.patchCalls, rt.lastPatchVersion)
+ }
+}
+
+func TestConsoleRuntimePatchMissingVersion(t *testing.T) {
+ id := uuid.New()
+ games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
+ rt := &fakeRuntimeAdmin{}
+ router, csrf := newGamesConsoleRouter(t, games, rt, nil)
+
+ rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops"))
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", rec.Code)
+ }
+ if rt.patchCalls != 0 {
+ t.Error("patch must not run without a target version")
+ }
+}
+
+func TestConsoleEngineVersions(t *testing.T) {
+ ev := &fakeEngineVersionAdmin{list: []runtime.EngineVersion{{Version: "0.1.0", ImageRef: "img:0.1.0", Enabled: true}}}
+ router, csrf := newGamesConsoleRouter(t, nil, nil, ev)
+
+ rec := consoleGet(t, router, "/_gm/engine-versions")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ for _, want := range []string{"0.1.0", "img:0.1.0", "Register version", "Disable"} {
+ if !strings.Contains(rec.Body.String(), want) {
+ t.Errorf("engine versions page missing %q", want)
+ }
+ }
+
+ rec = consolePost(t, router, "/_gm/engine-versions", "_csrf="+csrf.Token("ops")+"&version=0.2.0&image_ref=img:0.2.0&enabled=true")
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("register status = %d, want 303", rec.Code)
+ }
+ if ev.registerCalls != 1 || ev.registered.Version != "0.2.0" || ev.registered.Enabled == nil || !*ev.registered.Enabled {
+ t.Errorf("register recorded %d version=%q enabled=%v", ev.registerCalls, ev.registered.Version, ev.registered.Enabled)
+ }
+
+ rec = consolePost(t, router, "/_gm/engine-versions/0.1.0/disable", "_csrf="+csrf.Token("ops"))
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("disable status = %d, want 303", rec.Code)
+ }
+ if ev.disableCalls != 1 || ev.lastDisabled != "0.1.0" {
+ t.Errorf("disable recorded %d version=%q", ev.disableCalls, ev.lastDisabled)
+ }
+}
+
+func TestConsoleGamesUnavailable(t *testing.T) {
+ router, _ := newGamesConsoleRouter(t, nil, nil, nil)
+
+ rec := consoleGet(t, router, "/_gm/games")
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want 503", rec.Code)
+ }
+}
diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go
index 6568617..bab38d2 100644
--- a/backend/internal/server/router.go
+++ b/backend/internal/server/router.go
@@ -394,6 +394,20 @@ func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) {
group.POST("/users/:user_id/block", deps.AdminConsole.UserBlock())
group.POST("/users/:user_id/entitlement", deps.AdminConsole.UserEntitlement())
group.POST("/users/:user_id/soft-delete", deps.AdminConsole.UserSoftDelete())
+
+ group.GET("/games", deps.AdminConsole.GamesList())
+ group.POST("/games", deps.AdminConsole.GameCreate())
+ group.GET("/games/:game_id", deps.AdminConsole.GameDetail())
+ group.POST("/games/:game_id/force-start", deps.AdminConsole.GameForceStart())
+ group.POST("/games/:game_id/force-stop", deps.AdminConsole.GameForceStop())
+ group.POST("/games/:game_id/ban-member", deps.AdminConsole.GameBanMember())
+ group.POST("/games/:game_id/runtime/restart", deps.AdminConsole.RuntimeRestart())
+ group.POST("/games/:game_id/runtime/patch", deps.AdminConsole.RuntimePatch())
+ group.POST("/games/:game_id/runtime/force-next-turn", deps.AdminConsole.RuntimeForceNextTurn())
+
+ group.GET("/engine-versions", deps.AdminConsole.EngineVersionsList())
+ group.POST("/engine-versions", deps.AdminConsole.EngineVersionRegister())
+ group.POST("/engine-versions/:version/disable", deps.AdminConsole.EngineVersionDisable())
}
// allowedMethodsForPath returns the comma-separated list of methods