diff --git a/backend/README.md b/backend/README.md index a27452e..f9181f1 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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` diff --git a/backend/docs/flows.md b/backend/docs/flows.md index 6ae5aba..c6b91ef 100644 --- a/backend/docs/flows.md +++ b/backend/docs/flows.md @@ -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 diff --git a/backend/docs/runtime.md b/backend/docs/runtime.md index 0969019..bd500f4 100644 --- a/backend/docs/runtime.md +++ b/backend/docs/runtime.md @@ -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 diff --git a/backend/internal/engineclient/client_test.go b/backend/internal/engineclient/client_test.go index 6819f2f..f6f3c10 100644 --- a/backend/internal/engineclient/client_test.go +++ b/backend/internal/engineclient/client_test.go @@ -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) { diff --git a/backend/internal/runtime/service.go b/backend/internal/runtime/service.go index a7d13e8..c39099b 100644 --- a/backend/internal/runtime/service.go +++ b/backend/internal/runtime/service.go @@ -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()), diff --git a/backend/internal/runtime/service_e2e_test.go b/backend/internal/runtime/service_e2e_test.go index 78362c6..e441609 100644 --- a/backend/internal/runtime/service_e2e_test.go +++ b/backend/internal/runtime/service_e2e_test.go @@ -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}}}) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ecbed78..748ad81 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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) diff --git a/game/README.md b/game/README.md index bf3baa7..e30ace4 100644 --- a/game/README.md +++ b/game/README.md @@ -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": "", "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 diff --git a/game/internal/controller/controller.go b/game/internal/controller/controller.go index 542ba59..72b8f98 100644 --- a/game/internal/controller/controller.go +++ b/game/internal/controller/controller.go @@ -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 { diff --git a/game/internal/controller/generate_game.go b/game/internal/controller/generate_game.go index a564308..2be6528 100644 --- a/game/internal/controller/generate_game.go +++ b/game/internal/controller/generate_game.go @@ -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, diff --git a/game/internal/controller/generate_game_test.go b/game/internal/controller/generate_game_test.go index aded8d0..d6fa684 100644 --- a/game/internal/controller/generate_game_test.go +++ b/game/internal/controller/generate_game_test.go @@ -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) +} diff --git a/game/internal/controller/init_errors.go b/game/internal/controller/init_errors.go new file mode 100644 index 0000000..bdc8216 --- /dev/null +++ b/game/internal/controller/init_errors.go @@ -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") diff --git a/game/internal/router/handler/handler.go b/game/internal/router/handler/handler.go index 62ccfdc..bd04ea5 100644 --- a/game/internal/router/handler/handler.go +++ b/game/internal/router/handler/handler.go @@ -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 } diff --git a/game/internal/router/handler/init.go b/game/internal/router/handler/init.go index 7abfe78..08db7d3 100644 --- a/game/internal/router/handler/init.go +++ b/game/internal/router/handler/init.go @@ -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) diff --git a/game/internal/router/init_test.go b/game/internal/router/init_test.go index 8341ccb..addc680 100644 --- a/game/internal/router/init_test.go +++ b/game/internal/router/init_test.go @@ -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) +} diff --git a/game/internal/router/router_helper_test.go b/game/internal/router/router_helper_test.go index 8b87936..e7aa8b3 100644 --- a/game/internal/router/router_helper_test.go +++ b/game/internal/router/router_helper_test.go @@ -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) { diff --git a/game/internal/router/router_test.go b/game/internal/router/router_test.go index 4fac54c..a8dda9a 100644 --- a/game/internal/router/router_test.go +++ b/game/internal/router/router_test.go @@ -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)} diff --git a/game/openapi.yaml b/game/openapi.yaml index 42bfd75..b2208a9 100644 --- a/game/openapi.yaml +++ b/game/openapi.yaml @@ -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: diff --git a/game/openapi_contract_test.go b/game/openapi_contract_test.go index 9f1cfac..0b12728 100644 --- a/game/openapi_contract_test.go +++ b/game/openapi_contract_test.go @@ -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) { diff --git a/pkg/model/rest/init.go b/pkg/model/rest/init.go index 1bbcf8e..d9d11ca 100644 --- a/pkg/model/rest/init.go +++ b/pkg/model/rest/init.go @@ -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"` }