feat(game): canonical gameId in POST /api/v1/admin/init #72

Merged
developer merged 1 commits from feature/canonical-game-id-init into development 2026-05-29 11:29:20 +00:00
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"`
}