feat: gamemaster

This commit is contained in:
Ilia Denisov
2026-05-03 07:59:03 +02:00
committed by GitHub
parent a7cee15115
commit 3e2622757e
229 changed files with 41521 additions and 1098 deletions
+47 -6
View File
@@ -39,13 +39,54 @@ do not pass per-game limits.
## Endpoints
The contract is the union of `openapi.yaml` and the technical liveness probe
described below.
described below. Endpoints split into two route classes:
| Class | Path | Caller | Purpose |
| --- | --- | --- | --- |
| Admin (GM-only) | `POST /api/v1/admin/init` | `Game Master` | Initialise the engine with the race roster. |
| Admin (GM-only) | `GET /api/v1/admin/status` | `Game Master` | Read the full game state. |
| Admin (GM-only) | `PUT /api/v1/admin/turn` | `Game Master` | Generate the next turn. |
| Admin (GM-only) | `POST /api/v1/admin/race/banish` | `Game Master` | Deactivate a race after a permanent platform removal. |
| Player | `PUT /api/v1/command` | `Game Master` (forwarded from `Edge Gateway`) | Execute a batch of player commands. |
| Player | `PUT /api/v1/order` | `Game Master` | Validate and store a batch of player orders. |
| Player | `GET /api/v1/report` | `Game Master` | Fetch the per-player turn report. |
| Probe | `GET /healthz` | `Runtime Manager` | Technical liveness probe. |
Admin paths are unauthenticated but are routed only from inside the
trusted network segment that connects `Game Master` to the engine
container. The engine does not enforce caller identity — network-level
segmentation is the boundary. Player paths apply the same rule and rely
on `Game Master` to forward only verified player payloads.
### Game endpoints
Documented in [`openapi.yaml`](openapi.yaml). When the engine has not been
initialised through `POST /api/v1/init`, game endpoints respond `501 Not
Implemented` to make the uninitialised state unambiguous.
initialised through `POST /api/v1/admin/init`, game endpoints respond
`501 Not Implemented` to make the uninitialised state unambiguous.
### `StateResponse.finished`
`StateResponse` (returned by `GET /api/v1/admin/status` and
`PUT /api/v1/admin/turn`) carries a required boolean `finished` field.
The engine sets it to `true` exactly once on the turn-generation response
that ends the game; otherwise it stays `false`. `Game Master` uses this
field as the sole signal to run the platform finish flow. The conditional
logic that flips `finished` to `true` lives in the engine's domain code
and is owned by the engine maintainers.
### `POST /api/v1/admin/race/banish`
Deactivates a race after a permanent platform-level membership removal.
`Game Master` calls this endpoint synchronously after a Lobby-driven
remove-and-banish flow.
- Request body: `{ "race_name": "<name>" }`. `race_name` must be
non-empty and must match an existing race in the engine's roster.
- Successful response: `204 No Content` with an empty body.
- Error responses follow the same `400` / `500` envelope shape as the
other admin endpoints. The engine-side mechanics of `banish` (what
exactly happens to the race's planets, fleets, and pending orders) are
owned by the engine maintainers.
### `GET /healthz`
@@ -53,9 +94,9 @@ Technical liveness probe used by `Runtime Manager` and operator tooling.
- Returns `{"status":"ok"}` with HTTP `200` whenever the HTTP server is
serving requests, regardless of whether the engine has been initialised
through `POST /api/v1/init`.
- Carries no game-state semantics. Use `GET /api/v1/status` for game-state
inspection.
through `POST /api/v1/admin/init`.
- Carries no game-state semantics. Use `GET /api/v1/admin/status` for
game-state inspection.
This endpoint exists so that `Runtime Manager` can probe a freshly started
container before `init` runs.
+1 -1
View File
@@ -34,7 +34,7 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/oasdiff/yaml v0.0.9 // indirect
github.com/oasdiff/yaml3 v0.0.9 // indirect
github.com/oasdiff/yaml3 v0.0.12 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+1 -2
View File
@@ -66,8 +66,7 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM=
github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g=
github.com/oasdiff/yaml3 v0.0.9/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
+9
View File
@@ -19,6 +19,15 @@ func (c Controller) RaceID(actor string) (uuid.UUID, error) {
return c.Cache.g.Race[ri].ID, nil
}
func (c Controller) RaceBanish(actor string) error {
ri, err := c.Cache.validRace(actor)
if err != nil {
return err
}
c.Cache.g.Race[ri].Extinct = true
return nil
}
func (c Controller) RaceQuit(actor string) error {
ri, err := c.Cache.validRace(actor)
if err != nil {
+23 -4
View File
@@ -134,6 +134,14 @@ func ValidateOrder(configure func(*Param), actor string, cmd ...order.DecodableC
return ec.validateOrder(actor, cmd...)
}
func BanishRace(configure func(*Param), actor string) error {
ec, err := NewRepoController(configure)
if err != nil {
return err
}
return ec.banishRace(actor)
}
func GameState(configure func(*Param)) (s game.State, err error) {
ec, err := NewRepoController(configure)
if err != nil {
@@ -146,10 +154,11 @@ func GameState(configure func(*Param)) (s game.State, err error) {
}
result := &game.State{
ID: g.ID,
Turn: g.Turn,
Stage: g.Stage,
Players: make([]game.PlayerState, len(g.Race)),
ID: g.ID,
Turn: g.Turn,
Stage: g.Stage,
Finished: g.Finished(),
Players: make([]game.PlayerState, len(g.Race)),
}
planetCount := make(map[uuid.UUID]uint)
@@ -243,6 +252,16 @@ func (ec *RepoController) executeCommand(consumer func(*Controller) error) (err
})
}
func (ec *RepoController) banishRace(actor string) (err error) {
return ec.executeLocked(func(c *Controller) error {
err = c.RaceBanish(actor)
if err != nil {
return err
}
return c.saveState()
})
}
func (ec *RepoController) executeSafe(consumer func(uint, *Controller) error) (err error) {
g, err := ec.Repo.LoadStateSafe()
if err != nil {
+1 -1
View File
@@ -118,7 +118,7 @@ func (c *Cache) raceTechLevel(ri int, t game.Tech, v float64) {
func (c *Cache) TurnWipeExtinctRaces() {
for i := range c.listRaceActingIdx() {
if c.g.Race[i].TTL == 0 {
if (c.g.Race[i].Extinct && c.g.Race[i].TTL > 0) || (!c.g.Race[i].Extinct && c.g.Race[i].TTL == 0) {
c.wipeRace(i)
}
}
+5 -4
View File
@@ -3,10 +3,11 @@ package game
import "github.com/google/uuid"
type State struct {
ID uuid.UUID
Turn uint
Stage uint
Players []PlayerState
ID uuid.UUID
Turn uint
Stage uint
Players []PlayerState
Finished bool
}
type PlayerState struct {
+45
View File
@@ -0,0 +1,45 @@
package router_test
import (
"net/http"
"net/http/httptest"
"testing"
"galaxy/model/rest"
"github.com/stretchr/testify/assert"
)
const apiBanishPath = "/api/v1/admin/race/banish"
func TestBanishHappyPath(t *testing.T) {
r := setupRouter()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, apiBanishPath, asBody(rest.BanishRequest{RaceName: "Aelinari"}))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code, w.Body)
assert.Empty(t, w.Body.String())
}
func TestBanishValidation(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
description string
body any
}{
{"missing race_name", struct{}{}},
{"empty race_name", rest.BanishRequest{RaceName: ""}},
{"blank race_name", rest.BanishRequest{RaceName: " "}},
} {
t.Run(tc.description, func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, apiBanishPath, asBody(tc.body))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
})
}
}
+22
View File
@@ -0,0 +1,22 @@
package handler
import (
"net/http"
"galaxy/model/rest"
"github.com/gin-gonic/gin"
)
func BanishHandler(c *gin.Context, executor CommandExecutor) {
var req rest.BanishRequest
if errorResponse(c, c.ShouldBindJSON(&req)) {
return
}
if errorResponse(c, executor.BanishRace(req.RaceName)) {
return
}
c.Status(http.StatusNoContent)
}
+10 -4
View File
@@ -23,6 +23,7 @@ type CommandExecutor interface {
GenerateGame([]string) (rest.StateResponse, error)
GenerateTurn() (rest.StateResponse, error)
GameState() (rest.StateResponse, error)
BanishRace(string) error
LoadReport(actor string, turn uint) (*report.Report, error)
Execute(cmd ...Command) error
ValidateOrder(actor string, cmd ...order.DecodableCommand) error
@@ -103,16 +104,21 @@ func (e *executor) GameState() (rest.StateResponse, error) {
return stateResponse(s), nil
}
func (e *executor) BanishRace(raceName string) error {
return controller.BanishRace(e.cfg, raceName)
}
func (e *executor) LoadReport(actor string, turn uint) (*report.Report, error) {
return controller.LoadReport(e.cfg, actor, turn)
}
func stateResponse(s game.State) rest.StateResponse {
result := &rest.StateResponse{
ID: s.ID,
Turn: s.Turn,
Stage: s.Stage,
Players: make([]rest.PlayerState, len(s.Players)),
ID: s.ID,
Turn: s.Turn,
Stage: s.Stage,
Finished: s.Finished,
Players: make([]rest.PlayerState, len(s.Players)),
}
for i := range s.Players {
result.Players[i].ID = s.Players[i].ID
+1 -1
View File
@@ -8,7 +8,7 @@ import (
// HealthzHandler is the technical liveness probe used by Runtime Manager
// and operator tooling. It returns 200 with {"status":"ok"} regardless
// of whether the engine has been initialised through POST /api/v1/init.
// of whether the engine has been initialised through POST /api/v1/admin/init.
func HealthzHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
+2 -2
View File
@@ -27,7 +27,7 @@ func TestInit(t *testing.T) {
payload := generateInitRequest(10)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/init", asBody(payload))
req, _ := http.NewRequest("POST", "/api/v1/admin/init", asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code, w.Body)
@@ -42,7 +42,7 @@ func TestInitValidators(t *testing.T) {
payload := generateInitRequest(9)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/init", asBody(payload))
req, _ := http.NewRequest("POST", "/api/v1/admin/init", asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
+1 -1
View File
@@ -24,7 +24,7 @@ func TestGetReport(t *testing.T) {
payload := generateInitRequest(10)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/init", asBody(payload))
req, _ := http.NewRequest("POST", "/api/v1/admin/init", asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code, w.Body)
+6 -3
View File
@@ -67,12 +67,15 @@ func setupRouter(executor handler.CommandExecutor) *gin.Engine {
groupV1 := r.Group("/api/v1")
groupV1.GET("/status", func(ctx *gin.Context) { handler.StatusHandler(ctx, executor) })
groupV1.POST("/init", func(ctx *gin.Context) { handler.InitHandler(ctx, executor) })
groupAdmin := groupV1.Group("/admin")
groupAdmin.GET("/status", func(ctx *gin.Context) { handler.StatusHandler(ctx, executor) })
groupAdmin.POST("/init", func(ctx *gin.Context) { handler.InitHandler(ctx, executor) })
groupAdmin.PUT("/turn", func(ctx *gin.Context) { handler.TurnHandler(ctx, executor) })
groupAdmin.POST("/race/banish", func(ctx *gin.Context) { handler.BanishHandler(ctx, executor) })
groupV1.GET("/report", func(ctx *gin.Context) { handler.ReportHandler(ctx, executor) })
groupV1.PUT("/command", LimitMiddleware(1), func(ctx *gin.Context) { handler.CommandHandler(ctx, executor) })
groupV1.PUT("/order", func(ctx *gin.Context) { handler.OrderHandler(ctx, executor) })
groupV1.PUT("/turn", func(ctx *gin.Context) { handler.TurnHandler(ctx, executor) })
return r
}
@@ -52,6 +52,10 @@ func (e *dummyExecutor) GenerateTurn() (rest.StateResponse, error) {
return rest.StateResponse{}, nil
}
func (e *dummyExecutor) BanishRace(raceName string) error {
return nil
}
func (e *dummyExecutor) GameState() (rest.StateResponse, error) {
return rest.StateResponse{}, nil
}
+3 -2
View File
@@ -27,7 +27,7 @@ func TestGetStatus(t *testing.T) {
payload := generateInitRequest(10)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/init", asBody(payload))
req, _ := http.NewRequest("POST", "/api/v1/admin/init", asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code, w.Body)
@@ -37,7 +37,7 @@ func TestGetStatus(t *testing.T) {
assert.NotEqual(t, uuid.Nil, uuid.MustParse(initResponse.ID.String()))
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/api/v1/status", nil)
req, _ = http.NewRequest("GET", "/api/v1/admin/status", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, w.Body)
@@ -47,6 +47,7 @@ func TestGetStatus(t *testing.T) {
assert.Equal(t, initResponse.ID, stateResponse.ID)
assert.Equal(t, uint(0), stateResponse.Turn)
assert.Equal(t, uint(0), stateResponse.Stage)
assert.False(t, stateResponse.Finished)
assert.Len(t, stateResponse.Players, 10)
for i := range stateResponse.Players {
assert.NoError(t, uuid.Validate(stateResponse.Players[i].ID.String()))
+3 -3
View File
@@ -29,7 +29,7 @@ func TestGetTurn(t *testing.T) {
payload := generateInitRequest(10)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/init", asBody(payload))
req, _ := http.NewRequest("POST", "/api/v1/admin/init", asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code, w.Body)
@@ -50,7 +50,7 @@ func TestGetTurn(t *testing.T) {
// generate next turn
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/api/v1/turn", nil)
req, _ = http.NewRequest("PUT", "/api/v1/admin/turn", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, w.Body)
@@ -72,7 +72,7 @@ func TestGetTurn(t *testing.T) {
// validate status
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/api/v1/status", nil)
req, _ = http.NewRequest("GET", "/api/v1/admin/status", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, w.Body)
+63 -13
View File
@@ -30,16 +30,17 @@ tags:
- name: Health
description: Technical liveness probes used by Runtime Manager and operator tooling.
paths:
/api/v1/status:
/api/v1/admin/status:
get:
tags:
- GameLifecycle
operationId: getGameStatus
operationId: adminGetGameStatus
summary: Get the current game state
description: |
Returns the current game state including turn number, stage, and a
summary of all players. Returns `501` if the game has not yet been
initialized.
initialized. Routed only from the trusted network segment that
connects `Game Master` to the engine container.
responses:
"200":
description: Current game state.
@@ -51,15 +52,17 @@ paths:
description: Game has not been initialized yet.
"500":
$ref: "#/components/responses/InternalError"
/api/v1/init:
/api/v1/admin/init:
post:
tags:
- GameLifecycle
operationId: initGame
operationId: adminInitGame
summary: Initialize a new game
description: |
Generates a new game instance with the supplied list of races.
Requires at least 10 race entries.
Requires at least 10 race entries. Routed only from the trusted
network segment that connects `Game Master` to the engine
container.
requestBody:
required: true
content:
@@ -77,6 +80,30 @@ paths:
$ref: "#/components/responses/ValidationError"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/admin/race/banish:
post:
tags:
- GameLifecycle
operationId: adminBanishRace
summary: Deactivate a race after a permanent platform-level removal
description: |
Deactivates the named race in the running engine. Called by `Game
Master` after a Lobby-driven permanent membership removal. Routed
only from the trusted network segment that connects `Game Master`
to the engine container.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/BanishRequest"
responses:
"204":
description: Race deactivated; no response body.
"400":
$ref: "#/components/responses/ValidationError"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/report:
get:
tags:
@@ -148,15 +175,16 @@ paths:
$ref: "#/components/responses/ValidationError"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/turn:
/api/v1/admin/turn:
put:
tags:
- GameLifecycle
operationId: generateTurn
operationId: adminGenerateTurn
summary: Advance the game to the next turn
description: |
Processes the current turn and generates the next one. Returns the
updated game state.
updated game state. Routed only from the trusted network segment
that connects `Game Master` to the engine container.
responses:
"200":
description: Updated game state after turn generation.
@@ -175,10 +203,10 @@ paths:
description: |
Returns `{"status":"ok"}` with HTTP `200` whenever the HTTP server
is serving requests, regardless of whether the engine has been
initialised through `POST /api/v1/init`. Used by `Runtime Manager`
to probe a freshly started container before `init` runs. Carries
no game-state semantics; use `GET /api/v1/status` for game-state
inspection.
initialised through `POST /api/v1/admin/init`. Used by `Runtime
Manager` to probe a freshly started container before `init` runs.
Carries no game-state semantics; use `GET /api/v1/admin/status`
for game-state inspection.
responses:
"200":
description: Engine HTTP server is up.
@@ -225,6 +253,7 @@ components:
- turn
- stage
- player
- finished
properties:
id:
type: string
@@ -243,6 +272,15 @@ components:
description: Summary state for each player participating in the game.
items:
$ref: "#/components/schemas/PlayerState"
finished:
type: boolean
description: |
True exactly once on the turn-generation response that ends the
game; otherwise false. Server default: false. `Game Master`
uses this flag as the sole signal to run the platform finish
flow. The conditional logic that flips it to true lives in
the engine's domain code and is owned by the engine
maintainers.
PlayerState:
type: object
description: Brief player state returned as part of the game state response.
@@ -292,6 +330,18 @@ components:
type: string
description: Name of the race. Must be non-blank and satisfy the entity-name format.
minLength: 1
BanishRequest:
type: object
description: |
Request body for the admin banish endpoint. `race_name` must
identify an existing race in the engine roster.
required:
- race_name
properties:
race_name:
type: string
description: Name of the race to banish. Must be non-blank.
minLength: 1
CommandRequest:
type: object
description: |
+69 -7
View File
@@ -31,15 +31,15 @@ func TestGameOpenAPISpecFreezesResponseSchemas(t *testing.T) {
wantRef string
}{
{
name: "get game status",
path: "/api/v1/status",
name: "admin get game status",
path: "/api/v1/admin/status",
method: http.MethodGet,
status: http.StatusOK,
wantRef: "#/components/schemas/StateResponse",
},
{
name: "init game",
path: "/api/v1/init",
name: "admin init game",
path: "/api/v1/admin/init",
method: http.MethodPost,
status: http.StatusCreated,
wantRef: "#/components/schemas/StateResponse",
@@ -52,8 +52,8 @@ func TestGameOpenAPISpecFreezesResponseSchemas(t *testing.T) {
wantRef: "#/components/schemas/Report",
},
{
name: "generate turn",
path: "/api/v1/turn",
name: "admin generate turn",
path: "/api/v1/admin/turn",
method: http.MethodPut,
status: http.StatusOK,
wantRef: "#/components/schemas/StateResponse",
@@ -81,7 +81,7 @@ func TestGameOpenAPISpecFreezesInitRequest(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
operation := getOpenAPIOperation(t, doc, "/api/v1/init", http.MethodPost)
operation := getOpenAPIOperation(t, doc, "/api/v1/admin/init", http.MethodPost)
assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/InitRequest", "init request schema")
@@ -93,6 +93,68 @@ func TestGameOpenAPISpecFreezesInitRequest(t *testing.T) {
require.Equal(t, uint64(10), racesSchema.Value.MinItems, "InitRequest.races minItems must be 10")
}
func TestGameOpenAPISpecFreezesAdminOperationIDs(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
tests := []struct {
path string
method string
opID string
}{
{"/api/v1/admin/init", http.MethodPost, "adminInitGame"},
{"/api/v1/admin/status", http.MethodGet, "adminGetGameStatus"},
{"/api/v1/admin/turn", http.MethodPut, "adminGenerateTurn"},
{"/api/v1/admin/race/banish", http.MethodPost, "adminBanishRace"},
}
for _, tt := range tests {
t.Run(tt.opID, func(t *testing.T) {
t.Parallel()
operation := getOpenAPIOperation(t, doc, tt.path, tt.method)
require.Equal(t, tt.opID, operation.OperationID, "operation id for %s %s", tt.method, tt.path)
})
}
}
func TestGameOpenAPISpecFreezesBanishRequest(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
operation := getOpenAPIOperation(t, doc, "/api/v1/admin/race/banish", http.MethodPost)
assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/BanishRequest", "banish request schema")
if operation.Responses == nil {
require.FailNow(t, "banish operation is missing responses")
}
noContent := operation.Responses.Status(http.StatusNoContent)
require.NotNil(t, noContent, "banish operation must declare 204 response")
require.NotNil(t, noContent.Value, "banish 204 response must have a value")
schema := componentSchemaRef(t, doc, "BanishRequest")
assertRequiredFields(t, schema, "race_name")
raceNameSchema := schema.Value.Properties["race_name"]
require.NotNil(t, raceNameSchema, "BanishRequest.race_name schema must exist")
require.Equal(t, uint64(1), raceNameSchema.Value.MinLength, "BanishRequest.race_name minLength must be 1")
}
func TestGameOpenAPISpecFreezesStateResponseFinished(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
schema := componentSchemaRef(t, doc, "StateResponse")
assertRequiredFields(t, schema, "id", "turn", "stage", "player", "finished")
finishedSchema := schema.Value.Properties["finished"]
require.NotNil(t, finishedSchema, "StateResponse.finished schema must exist")
require.True(t, finishedSchema.Value.Type.Is("boolean"), "StateResponse.finished must be boolean")
}
func TestGameOpenAPISpecFreezesCommandRequest(t *testing.T) {
t.Parallel()