From 15d35f6f1f1654069006df5760ceb5e595466f43 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 29 May 2026 13:13:31 +0200 Subject: [PATCH] feat(game): canonical gameId in POST /api/v1/admin/init 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) --- backend/README.md | 5 +- backend/docs/flows.md | 4 +- backend/docs/runtime.md | 5 +- backend/internal/engineclient/client_test.go | 9 +++- backend/internal/runtime/service.go | 2 +- backend/internal/runtime/service_e2e_test.go | 7 +++ docs/ARCHITECTURE.md | 8 ++++ game/README.md | 20 +++++++- game/internal/controller/controller.go | 31 +++++++++++- game/internal/controller/generate_game.go | 17 ++++--- .../internal/controller/generate_game_test.go | 37 ++++++++++++++- game/internal/controller/init_errors.go | 14 ++++++ game/internal/router/handler/handler.go | 6 +-- game/internal/router/handler/init.go | 19 ++++++-- game/internal/router/init_test.go | 39 ++++++++++++++- game/internal/router/router_helper_test.go | 4 +- game/internal/router/router_test.go | 4 +- game/openapi.yaml | 47 +++++++++++++++++-- game/openapi_contract_test.go | 14 +++++- pkg/model/rest/init.go | 15 +++++- 20 files changed, 270 insertions(+), 37 deletions(-) create mode 100644 game/internal/controller/init_errors.go 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"` }