feat(game): canonical gameId in POST /api/v1/admin/init
Tests · Go / test (push) Successful in 1m57s
Tests · Integration / integration (pull_request) Successful in 1m48s
Tests · Go / test (pull_request) Successful in 2m0s

Engine no longer mints its own game UUID. The orchestrator (backend)
generates the game UUID at game-create time and passes it in the
admin/init request body as the required `gameId` field, so the value
that names the engine container and host bind-mount directory also
ends up inside the engine's state.json.

The engine rejects the zero UUID with 400 and any init that conflicts
with an existing state.json with 409 (a second init on the same gameId
is also a conflict; full idempotency is not part of the contract).

Updates rest.InitRequest, openapi.yaml (schema + 409 response),
controller.GenerateGame/NewGame/buildGameOnMap signatures, the engine
HTTP handler/executor, the backend runtime worker, and the relevant
unit and contract tests. Documentation in game/README.md,
docs/ARCHITECTURE.md, backend/README.md, and backend/docs/{runtime,flows}.md
is updated in the same patch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-29 13:13:31 +02:00
parent 4a7bf0be61
commit 15d35f6f1f
20 changed files with 270 additions and 37 deletions
+4 -1
View File
@@ -257,7 +257,10 @@ introduce its own request/response types.
Endpoints used:
- `POST /api/v1/admin/init`
- `POST /api/v1/admin/init` — the runtime worker passes the canonical
`game_id` (the same UUID that names the engine container and the
host bind-mount directory) in the request body so the engine's
`state.json` shares identity with the backend's `games.game_id`.
- `GET /api/v1/admin/status`
- `PUT /api/v1/admin/turn`
- `POST /api/v1/admin/race/banish`
+2 -2
View File
@@ -234,8 +234,8 @@ sequenceDiagram
Workers->>Docker: pull / create / start engine container
Docker-->>Workers: container id
Workers->>Engine: POST /api/v1/admin/init
Engine-->>Workers: ok / error
Workers->>Engine: POST /api/v1/admin/init {gameId, races}
Engine-->>Workers: StateResponse{id == gameId} / error
Workers->>Runtime: write runtime_records (running or start_failed)
Workers->>Lobby: OnRuntimeJobResult
+4 -1
View File
@@ -141,7 +141,10 @@ boot).
polls the engine `/healthz` until the listener is bound (Docker
marks a container running as soon as the entrypoint starts; the
Go binary inside takes a moment to bind its TCP port). Only after
`/healthz` succeeds does the worker call `/admin/init`.
`/healthz` succeeds does the worker call `/admin/init`, passing the
same `game_id` the backend uses to mount the engine's storage
directory; the engine echoes it back in `StateResponse.id`. The
engine rejects a mismatched gameId with `409 Conflict`.
- **Runtime scheduler** (`internal/runtime.SchedulerComponent`) —
`pkg/cronutil` schedule per running game; each tick invokes the
engine `admin/turn`. Force-next-turn flips a one-shot skip flag in
+8 -1
View File
@@ -26,6 +26,7 @@ func newTestClient(t *testing.T, srv *httptest.Server) *Client {
func TestClientInitSuccess(t *testing.T) {
wantID := uuid.New()
var gotReq rest.InitRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathAdminInit {
t.Fatalf("unexpected path: %s", r.URL.Path)
@@ -33,13 +34,16 @@ func TestClientInitSuccess(t *testing.T) {
if r.Method != http.MethodPost {
t.Fatalf("unexpected method: %s", r.Method)
}
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatalf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: wantID, Turn: 1, Players: []rest.PlayerState{{ID: uuid.New(), RaceName: "alpha"}}})
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
got, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{Races: []rest.InitRace{{RaceName: "alpha"}}})
got, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{GameID: wantID, Races: []rest.InitRace{{RaceName: "alpha"}}})
if err != nil {
t.Fatalf("Init returned error: %v", err)
}
@@ -49,6 +53,9 @@ func TestClientInitSuccess(t *testing.T) {
if got.Turn != 1 {
t.Fatalf("Turn = %d, want 1", got.Turn)
}
if gotReq.GameID != wantID {
t.Fatalf("request gameId = %s, want %s", gotReq.GameID, wantID)
}
}
func TestClientInitValidationError(t *testing.T) {
+1 -1
View File
@@ -607,7 +607,7 @@ func (s *Service) runStart(ctx context.Context, op OperationLog) error {
return err
}
initResp, err := s.deps.Engine.Init(ctx, runResult.EngineEndpoint, rest.InitRequest{Races: races})
initResp, err := s.deps.Engine.Init(ctx, runResult.EngineEndpoint, rest.InitRequest{GameID: gameID, Races: races})
if err != nil {
s.deps.Logger.Warn("engine init failed",
zap.String("game_id", gameID.String()),
@@ -203,6 +203,13 @@ func TestServiceStartGameEndToEnd(t *testing.T) {
case "/healthz":
w.WriteHeader(http.StatusOK)
case "/api/v1/admin/init":
var got rest.InitRequest
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode init request: %v", err)
}
if got.GameID != gameID {
t.Errorf("init request gameId = %s, want %s", got.GameID, gameID)
}
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 0, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 3, Population: 10}}})
case "/api/v1/admin/status":
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 1, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 5, Population: 12}}})
+8
View File
@@ -402,6 +402,14 @@ Container state is owned by `backend/internal/runtime`:
always `http://galaxy-game-{game_id}:8080`.
- Engine probes (`/healthz`) feed `runtime` health observations and turn
generation status.
- Canonical game identity is owned by backend. The `game_id` allocated
at game-create time is reused everywhere downstream: it names the
container, the host bind-mount directory, and is passed verbatim to
the engine in `POST /api/v1/admin/init`'s `gameId` field. The engine
persists this value into `state.json` and echoes it in every
`StateResponse`; the engine never mints its own game UUID. A zero
UUID or a conflict with an existing `state.json` is rejected by the
engine (`400` / `409` respectively).
## 10. Geo Profile (reduced)
+19 -1
View File
@@ -43,7 +43,7 @@ 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) | `POST /api/v1/admin/init` | `Game Master` | Initialise the engine with a canonical `gameId` and 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. |
@@ -65,6 +65,24 @@ Documented in [`openapi.yaml`](openapi.yaml). When the engine has not been
initialised through `POST /api/v1/admin/init`, game endpoints respond
`501 Not Implemented` to make the uninitialised state unambiguous.
### `POST /api/v1/admin/init`
The canonical game identity is owned by the orchestrator (`Game Master`),
not by the engine. The request body is `{ "gameId": "<uuid>", "races": [...] }`
where:
- `gameId` is a non-zero UUID generated by the orchestrator before the
engine container is launched. The same value names the engine's host
storage directory and is persisted into `state.json`. The engine
rejects the zero UUID with `400 Bad Request` and any value that
conflicts with an existing `state.json` on disk with
`409 Conflict`. A second `init` on the same `gameId` is also
rejected with `409`; idempotency is not part of the contract.
- `races` is the race roster; minimum 10 entries.
On success the engine responds `201 Created` with a `StateResponse`
whose `id` echoes the supplied `gameId`.
### `StateResponse.finished`
`StateResponse` (returned by `GET /api/v1/admin/status` and
+29 -2
View File
@@ -2,8 +2,10 @@ package controller
import (
"errors"
"fmt"
"time"
e "galaxy/error"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
@@ -87,7 +89,16 @@ type Ctrl interface {
PlanetRouteRemove(actor, loadType string, origin uint) error
}
func GenerateGame(configure func(*Param), races []string) (s game.State, err error) {
// GenerateGame initialises a fresh game in storage under the supplied
// canonical gameID. The orchestrator must allocate gameID before the
// engine container is started and pass it here as the request body of
// POST /api/v1/admin/init. A zero UUID is rejected with
// ErrGameInitNilUUID; an attempt to init on top of an existing
// state.json is rejected with ErrGameAlreadyInit.
func GenerateGame(configure func(*Param), gameID uuid.UUID, races []string) (s game.State, err error) {
if gameID == uuid.Nil {
return game.State{}, ErrGameInitNilUUID
}
ec, err := NewRepoController(configure)
if err != nil {
return game.State{}, err
@@ -102,10 +113,26 @@ func GenerateGame(configure func(*Param), races []string) (s game.State, err err
}
}()
_, err = NewGame(ec.Repo, races)
if existing, loadErr := ec.Repo.LoadState(); loadErr == nil {
err = fmt.Errorf("%w: stored gameId=%s, requested=%s", ErrGameAlreadyInit, existing.ID, gameID)
return
} else if !isGameNotInitialized(loadErr) {
err = fmt.Errorf("check existing state: %w", loadErr)
return
}
_, err = NewGame(ec.Repo, gameID, races)
return
}
// isGameNotInitialized reports whether err is the engine's canonical
// "no state.json on disk" signal returned by Repo.LoadState on a
// fresh storage directory.
func isGameNotInitialized(err error) bool {
var ge *e.GenericError
return errors.As(err, &ge) && ge.Code == e.ErrGameNotInitialized
}
func GenerateTurn(configure func(*Param)) (err error) {
ec, err := NewRepoController(configure)
if err != nil {
+8 -9
View File
@@ -11,18 +11,21 @@ import (
"github.com/google/uuid"
)
func NewGame(r Repo, races []string) (uuid.UUID, error) {
// NewGame initialises a fresh game in storage under the supplied
// gameID. The caller is expected to have validated gameID against
// uuid.Nil and to have ruled out collisions with existing state.
func NewGame(r Repo, gameID uuid.UUID, races []string) (uuid.UUID, error) {
m, err := generator.Generate(func(ms *generator.MapSetting) {
ms.Players = uint32(len(races))
})
if err != nil {
return uuid.Nil, fmt.Errorf("generate map: %s", err)
}
return newGameOnMap(r, races, m)
return newGameOnMap(r, gameID, races, m)
}
func newGameOnMap(r Repo, races []string, m generator.Map) (uuid.UUID, error) {
g, err := buildGameOnMap(races, m)
func newGameOnMap(r Repo, gameID uuid.UUID, races []string, m generator.Map) (uuid.UUID, error) {
g, err := buildGameOnMap(gameID, races, m)
if err != nil {
return uuid.Nil, err
}
@@ -38,14 +41,10 @@ func newGameOnMap(r Repo, races []string, m generator.Map) (uuid.UUID, error) {
return g.ID, nil
}
func buildGameOnMap(races []string, m generator.Map) (*game.Game, error) {
func buildGameOnMap(gameID uuid.UUID, races []string, m generator.Map) (*game.Game, error) {
if len(races) != len(m.HomePlanets) {
return nil, fmt.Errorf("generate map: wrong number of home planets: %d, expected: %d ", len(m.HomePlanets), len(races))
}
gameID, err := uuid.NewRandom()
if err != nil {
return nil, fmt.Errorf("generate game uuid: %s", err)
}
g := &game.Game{
ID: gameID,
Turn: 0,
+35 -2
View File
@@ -28,16 +28,18 @@ func TestNewGame(t *testing.T) {
for i := range players {
races[i] = fmt.Sprintf("race_%02d", i)
}
requestedID := uuid.New()
assert.NoError(t, r.Lock())
gameID, err := controller.NewGame(r, races)
gameID, err := controller.NewGame(r, requestedID, races)
assert.NoError(t, err)
assert.Equal(t, requestedID, gameID, "NewGame must echo the supplied gameID")
assert.FileExists(t, filepath.Join(root, "state.json"))
assert.FileExists(t, filepath.Join(root, "0000/state.json"))
g, err := r.LoadState()
assert.NoError(t, err)
assert.Equal(t, gameID, g.ID)
assert.Equal(t, requestedID, g.ID, "persisted game.ID must match the supplied gameID")
assert.Equal(t, uint(0), g.Turn)
assert.Equal(t, players, len(g.Race))
@@ -68,3 +70,34 @@ func TestNewGame(t *testing.T) {
assert.NoError(t, r.Release())
}
func TestGenerateGameRejectsExistingState(t *testing.T) {
root, cleanup := util.CreateWorkDir(t)
defer cleanup()
races := make([]string, 10)
for i := range races {
races[i] = fmt.Sprintf("race_%02d", i)
}
configure := func(p *controller.Param) { p.StoragePath = root }
firstID := uuid.New()
_, err := controller.GenerateGame(configure, firstID, races)
assert.NoError(t, err)
_, err = controller.GenerateGame(configure, uuid.New(), races)
assert.ErrorIs(t, err, controller.ErrGameAlreadyInit)
}
func TestGenerateGameRejectsNilUUID(t *testing.T) {
root, cleanup := util.CreateWorkDir(t)
defer cleanup()
races := make([]string, 10)
for i := range races {
races[i] = fmt.Sprintf("race_%02d", i)
}
_, err := controller.GenerateGame(func(p *controller.Param) { p.StoragePath = root }, uuid.Nil, races)
assert.ErrorIs(t, err, controller.ErrGameInitNilUUID)
}
+14
View File
@@ -0,0 +1,14 @@
package controller
import "errors"
// ErrGameInitNilUUID is returned by GenerateGame when the supplied
// game UUID is the zero value. The HTTP layer maps it to 400.
var ErrGameInitNilUUID = errors.New("game init: gameId must not be the zero UUID")
// ErrGameAlreadyInit is returned by GenerateGame when the engine
// storage directory already contains a state.json. The HTTP layer
// maps it to 409. Repeated init on the same gameId is intentionally
// rejected rather than treated as a no-op; full idempotency is not
// part of the contract.
var ErrGameAlreadyInit = errors.New("game init: game already initialized")
+3 -3
View File
@@ -21,7 +21,7 @@ import (
)
type CommandExecutor interface {
GenerateGame([]string) (rest.StateResponse, error)
GenerateGame(gameID uuid.UUID, races []string) (rest.StateResponse, error)
GenerateTurn() (rest.StateResponse, error)
GameState() (rest.StateResponse, error)
BanishRace(string) error
@@ -92,8 +92,8 @@ func (e *executor) FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, b
return controller.FetchBattle(e.cfg, turn, ID)
}
func (e *executor) GenerateGame(races []string) (rest.StateResponse, error) {
s, err := controller.GenerateGame(e.cfg, races)
func (e *executor) GenerateGame(gameID uuid.UUID, races []string) (rest.StateResponse, error) {
s, err := controller.GenerateGame(e.cfg, gameID, races)
if err != nil {
return rest.StateResponse{}, err
}
+16 -3
View File
@@ -1,11 +1,14 @@
package handler
import (
"errors"
"net/http"
"galaxy/game/internal/controller"
"galaxy/model/rest"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func InitHandler(c *gin.Context, executor CommandExecutor) {
@@ -13,15 +16,25 @@ func InitHandler(c *gin.Context, executor CommandExecutor) {
if errorResponse(c, c.ShouldBindJSON(&init)) {
return
}
if init.GameID == uuid.Nil {
c.JSON(http.StatusBadRequest, gin.H{"error": controller.ErrGameInitNilUUID.Error()})
return
}
races := make([]string, len(init.Races))
for i := range init.Races {
races[i] = init.Races[i].RaceName
}
s, err := executor.GenerateGame(races)
if errorResponse(c, err) {
return
s, err := executor.GenerateGame(init.GameID, races)
if err != nil {
if errors.Is(err, controller.ErrGameAlreadyInit) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
if errorResponse(c, err) {
return
}
}
c.JSON(http.StatusCreated, s)
+37 -2
View File
@@ -33,8 +33,7 @@ func TestInit(t *testing.T) {
assert.Equal(t, http.StatusCreated, w.Code, w.Body)
var initResponse rest.StateResponse
assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &initResponse))
assert.NoError(t, uuid.Validate(initResponse.ID.String()))
assert.NotEqual(t, uuid.Nil, uuid.MustParse(initResponse.ID.String()))
assert.Equal(t, payload.GameID, initResponse.ID, "engine must echo the orchestrator-supplied gameId")
}
func TestInitValidators(t *testing.T) {
@@ -47,3 +46,39 @@ func TestInitValidators(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
}
func TestInitRejectsNilUUID(t *testing.T) {
root, cleanup := util.CreateWorkDir(t)
defer cleanup()
r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root }))
payload := generateInitRequest(10)
payload.GameID = uuid.Nil
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/admin/init", asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
}
func TestInitRejectsExistingGameWithDifferentID(t *testing.T) {
root, cleanup := util.CreateWorkDir(t)
defer cleanup()
r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root }))
first := generateInitRequest(10)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/admin/init", asBody(first))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code, w.Body)
second := generateInitRequest(10)
second.GameID = uuid.New()
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/api/v1/admin/init", asBody(second))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusConflict, w.Code, w.Body)
}
+2 -2
View File
@@ -86,8 +86,8 @@ func (e *dummyExecutor) Execute(command ...handler.Command) error {
return nil
}
func (e *dummyExecutor) GenerateGame(races []string) (rest.StateResponse, error) {
return rest.StateResponse{}, nil
func (e *dummyExecutor) GenerateGame(gameID uuid.UUID, races []string) (rest.StateResponse, error) {
return rest.StateResponse{ID: gameID}, nil
}
func (e *dummyExecutor) GenerateTurn() (rest.StateResponse, error) {
+3 -1
View File
@@ -15,6 +15,7 @@ import (
"galaxy/game/internal/router"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
@@ -70,7 +71,8 @@ func limitTestingRouter() *gin.Engine {
func generateInitRequest(races int) rest.InitRequest {
request := rest.InitRequest{
Races: make([]rest.InitRace, races),
GameID: uuid.New(),
Races: make([]rest.InitRace, races),
}
for i := range request.Races {
request.Races[i] = rest.InitRace{RaceName: raceName(i)}
+44 -3
View File
@@ -69,8 +69,10 @@ paths:
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. Routed only from the trusted
Generates a new game instance with the supplied canonical
`gameId` and list of races. Requires at least 10 race entries.
The engine refuses to overwrite an already-initialised storage
directory (responds with 409). Routed only from the trusted
network segment that connects `Game Master` to the engine
container.
requestBody:
@@ -88,6 +90,8 @@ paths:
$ref: "#/components/schemas/StateResponse"
"400":
$ref: "#/components/responses/ValidationError"
"409":
$ref: "#/components/responses/ConflictError"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/admin/race/banish:
@@ -410,10 +414,23 @@ components:
description: True when the race has been eliminated or voluntarily quit.
InitRequest:
type: object
description: Initialization request specifying the race list for a new game.
description: |
Initialization request specifying the canonical game identity
and the race list for a new game. `gameId` is generated by the
orchestrator (the platform's backend) before the engine
container is launched; the engine rejects the zero UUID and
any value that conflicts with an existing `state.json` on
disk.
required:
- gameId
- races
properties:
gameId:
type: string
format: uuid
description: |
Canonical game identity supplied by the orchestrator. Must
be a non-zero UUID.
races:
type: array
description: List of participating races. Minimum 10 entries required.
@@ -1299,6 +1316,19 @@ components:
error:
type: string
description: Human-readable validation error message from the binding layer.
ConflictErrorResponse:
type: object
description: |
Conflict error returned when the engine refuses an operation
that would clash with persisted state. Today the only producer
is `POST /api/v1/admin/init` when the storage directory
already contains a game state.
required:
- error
properties:
error:
type: string
description: Human-readable conflict description.
InternalErrorResponse:
type: object
description: |
@@ -1326,6 +1356,17 @@ components:
validationError:
value:
error: "Key: 'InitRequest.Races' Error:Field validation for 'Races' failed on the 'gte' tag"
ConflictError:
description: |
The requested operation conflicts with persisted engine state.
content:
application/json:
schema:
$ref: "#/components/schemas/ConflictErrorResponse"
examples:
alreadyInitialized:
value:
error: "game init: game already initialized"
InternalError:
description: Internal Game Service error.
content:
+13 -1
View File
@@ -187,11 +187,23 @@ func TestGameOpenAPISpecFreezesInitRequest(t *testing.T) {
assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/InitRequest", "init request schema")
schema := componentSchemaRef(t, doc, "InitRequest")
assertRequiredFields(t, schema, "races")
assertRequiredFields(t, schema, "gameId", "races")
gameIDSchema := schema.Value.Properties["gameId"]
require.NotNil(t, gameIDSchema, "InitRequest.gameId schema must exist")
require.True(t, gameIDSchema.Value.Type.Is("string"), "InitRequest.gameId must be string")
require.Equal(t, "uuid", gameIDSchema.Value.Format, "InitRequest.gameId format must be uuid")
racesSchema := schema.Value.Properties["races"]
require.NotNil(t, racesSchema, "InitRequest.races schema must exist")
require.Equal(t, uint64(10), racesSchema.Value.MinItems, "InitRequest.races minItems must be 10")
if operation.Responses == nil {
require.FailNow(t, "init operation is missing responses")
}
conflict := operation.Responses.Status(http.StatusConflict)
require.NotNil(t, conflict, "init operation must declare 409 response")
require.NotNil(t, conflict.Value, "init 409 response must have a value")
}
func TestGameOpenAPISpecFreezesAdminOperationIDs(t *testing.T) {
+13 -2
View File
@@ -1,11 +1,22 @@
package rest
import "github.com/google/uuid"
// InitRequest is the body of POST /api/v1/admin/init. The orchestrator
// (game backend) generates GameID before launching the engine container
// and creating the per-game storage directory, then passes the same
// value here so the engine state and the orchestrator's persisted
// identity stay aligned.
type InitRequest struct {
// List of the Races in the Game
// GameID is the canonical identity of the new game. The engine
// rejects requests with the zero UUID and requests that conflict
// with an existing state.json on disk.
GameID uuid.UUID `json:"gameId" binding:"required"`
// Races lists the participating races. Minimum 10 entries.
Races []InitRace `json:"races" binding:"required,gte=10"`
}
type InitRace struct {
// Name of the Race
// RaceName is the human-readable name of the race.
RaceName string `json:"raceName" binding:"required,notblank"`
}