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) <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -257,7 +257,10 @@ introduce its own request/response types.
|
|||||||
|
|
||||||
Endpoints used:
|
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`
|
- `GET /api/v1/admin/status`
|
||||||
- `PUT /api/v1/admin/turn`
|
- `PUT /api/v1/admin/turn`
|
||||||
- `POST /api/v1/admin/race/banish`
|
- `POST /api/v1/admin/race/banish`
|
||||||
|
|||||||
@@ -234,8 +234,8 @@ sequenceDiagram
|
|||||||
|
|
||||||
Workers->>Docker: pull / create / start engine container
|
Workers->>Docker: pull / create / start engine container
|
||||||
Docker-->>Workers: container id
|
Docker-->>Workers: container id
|
||||||
Workers->>Engine: POST /api/v1/admin/init
|
Workers->>Engine: POST /api/v1/admin/init {gameId, races}
|
||||||
Engine-->>Workers: ok / error
|
Engine-->>Workers: StateResponse{id == gameId} / error
|
||||||
Workers->>Runtime: write runtime_records (running or start_failed)
|
Workers->>Runtime: write runtime_records (running or start_failed)
|
||||||
Workers->>Lobby: OnRuntimeJobResult
|
Workers->>Lobby: OnRuntimeJobResult
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,10 @@ boot).
|
|||||||
polls the engine `/healthz` until the listener is bound (Docker
|
polls the engine `/healthz` until the listener is bound (Docker
|
||||||
marks a container running as soon as the entrypoint starts; the
|
marks a container running as soon as the entrypoint starts; the
|
||||||
Go binary inside takes a moment to bind its TCP port). Only after
|
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`) —
|
- **Runtime scheduler** (`internal/runtime.SchedulerComponent`) —
|
||||||
`pkg/cronutil` schedule per running game; each tick invokes the
|
`pkg/cronutil` schedule per running game; each tick invokes the
|
||||||
engine `admin/turn`. Force-next-turn flips a one-shot skip flag in
|
engine `admin/turn`. Force-next-turn flips a one-shot skip flag in
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func newTestClient(t *testing.T, srv *httptest.Server) *Client {
|
|||||||
|
|
||||||
func TestClientInitSuccess(t *testing.T) {
|
func TestClientInitSuccess(t *testing.T) {
|
||||||
wantID := uuid.New()
|
wantID := uuid.New()
|
||||||
|
var gotReq rest.InitRequest
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != pathAdminInit {
|
if r.URL.Path != pathAdminInit {
|
||||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
@@ -33,13 +34,16 @@ func TestClientInitSuccess(t *testing.T) {
|
|||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
t.Fatalf("unexpected method: %s", r.Method)
|
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")
|
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"}}})
|
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: wantID, Turn: 1, Players: []rest.PlayerState{{ID: uuid.New(), RaceName: "alpha"}}})
|
||||||
}))
|
}))
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
cli := newTestClient(t, srv)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Init returned error: %v", err)
|
t.Fatalf("Init returned error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -49,6 +53,9 @@ func TestClientInitSuccess(t *testing.T) {
|
|||||||
if got.Turn != 1 {
|
if got.Turn != 1 {
|
||||||
t.Fatalf("Turn = %d, want 1", got.Turn)
|
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) {
|
func TestClientInitValidationError(t *testing.T) {
|
||||||
|
|||||||
@@ -607,7 +607,7 @@ func (s *Service) runStart(ctx context.Context, op OperationLog) error {
|
|||||||
return err
|
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 {
|
if err != nil {
|
||||||
s.deps.Logger.Warn("engine init failed",
|
s.deps.Logger.Warn("engine init failed",
|
||||||
zap.String("game_id", gameID.String()),
|
zap.String("game_id", gameID.String()),
|
||||||
|
|||||||
@@ -203,6 +203,13 @@ func TestServiceStartGameEndToEnd(t *testing.T) {
|
|||||||
case "/healthz":
|
case "/healthz":
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
case "/api/v1/admin/init":
|
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}}})
|
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 0, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 3, Population: 10}}})
|
||||||
case "/api/v1/admin/status":
|
case "/api/v1/admin/status":
|
||||||
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 1, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 5, Population: 12}}})
|
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: gameID, Turn: 1, Players: []rest.PlayerState{{RaceName: "Alpha", Planets: 5, Population: 12}}})
|
||||||
|
|||||||
@@ -402,6 +402,14 @@ Container state is owned by `backend/internal/runtime`:
|
|||||||
always `http://galaxy-game-{game_id}:8080`.
|
always `http://galaxy-game-{game_id}:8080`.
|
||||||
- Engine probes (`/healthz`) feed `runtime` health observations and turn
|
- Engine probes (`/healthz`) feed `runtime` health observations and turn
|
||||||
generation status.
|
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)
|
## 10. Geo Profile (reduced)
|
||||||
|
|
||||||
|
|||||||
+19
-1
@@ -43,7 +43,7 @@ described below. Endpoints split into two route classes:
|
|||||||
|
|
||||||
| Class | Path | Caller | Purpose |
|
| 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) | `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) | `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. |
|
| 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
|
initialised through `POST /api/v1/admin/init`, game endpoints respond
|
||||||
`501 Not Implemented` to make the uninitialised state unambiguous.
|
`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.finished`
|
||||||
|
|
||||||
`StateResponse` (returned by `GET /api/v1/admin/status` and
|
`StateResponse` (returned by `GET /api/v1/admin/status` and
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
e "galaxy/error"
|
||||||
"galaxy/game/internal/model/game"
|
"galaxy/game/internal/model/game"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -87,7 +89,16 @@ type Ctrl interface {
|
|||||||
PlanetRouteRemove(actor, loadType string, origin uint) error
|
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)
|
ec, err := NewRepoController(configure)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return game.State{}, err
|
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
|
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) {
|
func GenerateTurn(configure func(*Param)) (err error) {
|
||||||
ec, err := NewRepoController(configure)
|
ec, err := NewRepoController(configure)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -11,18 +11,21 @@ import (
|
|||||||
"github.com/google/uuid"
|
"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) {
|
m, err := generator.Generate(func(ms *generator.MapSetting) {
|
||||||
ms.Players = uint32(len(races))
|
ms.Players = uint32(len(races))
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.Nil, fmt.Errorf("generate map: %s", err)
|
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) {
|
func newGameOnMap(r Repo, gameID uuid.UUID, races []string, m generator.Map) (uuid.UUID, error) {
|
||||||
g, err := buildGameOnMap(races, m)
|
g, err := buildGameOnMap(gameID, races, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.Nil, err
|
return uuid.Nil, err
|
||||||
}
|
}
|
||||||
@@ -38,14 +41,10 @@ func newGameOnMap(r Repo, races []string, m generator.Map) (uuid.UUID, error) {
|
|||||||
return g.ID, nil
|
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) {
|
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))
|
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{
|
g := &game.Game{
|
||||||
ID: gameID,
|
ID: gameID,
|
||||||
Turn: 0,
|
Turn: 0,
|
||||||
|
|||||||
@@ -28,16 +28,18 @@ func TestNewGame(t *testing.T) {
|
|||||||
for i := range players {
|
for i := range players {
|
||||||
races[i] = fmt.Sprintf("race_%02d", i)
|
races[i] = fmt.Sprintf("race_%02d", i)
|
||||||
}
|
}
|
||||||
|
requestedID := uuid.New()
|
||||||
assert.NoError(t, r.Lock())
|
assert.NoError(t, r.Lock())
|
||||||
gameID, err := controller.NewGame(r, races)
|
gameID, err := controller.NewGame(r, requestedID, races)
|
||||||
assert.NoError(t, err)
|
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, "state.json"))
|
||||||
assert.FileExists(t, filepath.Join(root, "0000/state.json"))
|
assert.FileExists(t, filepath.Join(root, "0000/state.json"))
|
||||||
|
|
||||||
g, err := r.LoadState()
|
g, err := r.LoadState()
|
||||||
assert.NoError(t, err)
|
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, uint(0), g.Turn)
|
||||||
assert.Equal(t, players, len(g.Race))
|
assert.Equal(t, players, len(g.Race))
|
||||||
|
|
||||||
@@ -68,3 +70,34 @@ func TestNewGame(t *testing.T) {
|
|||||||
|
|
||||||
assert.NoError(t, r.Release())
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -21,7 +21,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type CommandExecutor interface {
|
type CommandExecutor interface {
|
||||||
GenerateGame([]string) (rest.StateResponse, error)
|
GenerateGame(gameID uuid.UUID, races []string) (rest.StateResponse, error)
|
||||||
GenerateTurn() (rest.StateResponse, error)
|
GenerateTurn() (rest.StateResponse, error)
|
||||||
GameState() (rest.StateResponse, error)
|
GameState() (rest.StateResponse, error)
|
||||||
BanishRace(string) 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)
|
return controller.FetchBattle(e.cfg, turn, ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *executor) GenerateGame(races []string) (rest.StateResponse, error) {
|
func (e *executor) GenerateGame(gameID uuid.UUID, races []string) (rest.StateResponse, error) {
|
||||||
s, err := controller.GenerateGame(e.cfg, races)
|
s, err := controller.GenerateGame(e.cfg, gameID, races)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rest.StateResponse{}, err
|
return rest.StateResponse{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"galaxy/game/internal/controller"
|
||||||
"galaxy/model/rest"
|
"galaxy/model/rest"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func InitHandler(c *gin.Context, executor CommandExecutor) {
|
func InitHandler(c *gin.Context, executor CommandExecutor) {
|
||||||
@@ -13,15 +16,25 @@ func InitHandler(c *gin.Context, executor CommandExecutor) {
|
|||||||
if errorResponse(c, c.ShouldBindJSON(&init)) {
|
if errorResponse(c, c.ShouldBindJSON(&init)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if init.GameID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": controller.ErrGameInitNilUUID.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
races := make([]string, len(init.Races))
|
races := make([]string, len(init.Races))
|
||||||
for i := range init.Races {
|
for i := range init.Races {
|
||||||
races[i] = init.Races[i].RaceName
|
races[i] = init.Races[i].RaceName
|
||||||
}
|
}
|
||||||
|
|
||||||
s, err := executor.GenerateGame(races)
|
s, err := executor.GenerateGame(init.GameID, races)
|
||||||
if errorResponse(c, err) {
|
if err != nil {
|
||||||
return
|
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)
|
c.JSON(http.StatusCreated, s)
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ func TestInit(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusCreated, w.Code, w.Body)
|
assert.Equal(t, http.StatusCreated, w.Code, w.Body)
|
||||||
var initResponse rest.StateResponse
|
var initResponse rest.StateResponse
|
||||||
assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &initResponse))
|
assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &initResponse))
|
||||||
assert.NoError(t, uuid.Validate(initResponse.ID.String()))
|
assert.Equal(t, payload.GameID, initResponse.ID, "engine must echo the orchestrator-supplied gameId")
|
||||||
assert.NotEqual(t, uuid.Nil, uuid.MustParse(initResponse.ID.String()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInitValidators(t *testing.T) {
|
func TestInitValidators(t *testing.T) {
|
||||||
@@ -47,3 +46,39 @@ func TestInitValidators(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ func (e *dummyExecutor) Execute(command ...handler.Command) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *dummyExecutor) GenerateGame(races []string) (rest.StateResponse, error) {
|
func (e *dummyExecutor) GenerateGame(gameID uuid.UUID, races []string) (rest.StateResponse, error) {
|
||||||
return rest.StateResponse{}, nil
|
return rest.StateResponse{ID: gameID}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *dummyExecutor) GenerateTurn() (rest.StateResponse, error) {
|
func (e *dummyExecutor) GenerateTurn() (rest.StateResponse, error) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"galaxy/game/internal/router"
|
"galaxy/game/internal/router"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,7 +71,8 @@ func limitTestingRouter() *gin.Engine {
|
|||||||
|
|
||||||
func generateInitRequest(races int) rest.InitRequest {
|
func generateInitRequest(races int) rest.InitRequest {
|
||||||
request := rest.InitRequest{
|
request := rest.InitRequest{
|
||||||
Races: make([]rest.InitRace, races),
|
GameID: uuid.New(),
|
||||||
|
Races: make([]rest.InitRace, races),
|
||||||
}
|
}
|
||||||
for i := range request.Races {
|
for i := range request.Races {
|
||||||
request.Races[i] = rest.InitRace{RaceName: raceName(i)}
|
request.Races[i] = rest.InitRace{RaceName: raceName(i)}
|
||||||
|
|||||||
+44
-3
@@ -69,8 +69,10 @@ paths:
|
|||||||
operationId: adminInitGame
|
operationId: adminInitGame
|
||||||
summary: Initialize a new game
|
summary: Initialize a new game
|
||||||
description: |
|
description: |
|
||||||
Generates a new game instance with the supplied list of races.
|
Generates a new game instance with the supplied canonical
|
||||||
Requires at least 10 race entries. Routed only from the trusted
|
`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
|
network segment that connects `Game Master` to the engine
|
||||||
container.
|
container.
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -88,6 +90,8 @@ paths:
|
|||||||
$ref: "#/components/schemas/StateResponse"
|
$ref: "#/components/schemas/StateResponse"
|
||||||
"400":
|
"400":
|
||||||
$ref: "#/components/responses/ValidationError"
|
$ref: "#/components/responses/ValidationError"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/ConflictError"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
/api/v1/admin/race/banish:
|
/api/v1/admin/race/banish:
|
||||||
@@ -410,10 +414,23 @@ components:
|
|||||||
description: True when the race has been eliminated or voluntarily quit.
|
description: True when the race has been eliminated or voluntarily quit.
|
||||||
InitRequest:
|
InitRequest:
|
||||||
type: object
|
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:
|
required:
|
||||||
|
- gameId
|
||||||
- races
|
- races
|
||||||
properties:
|
properties:
|
||||||
|
gameId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: |
|
||||||
|
Canonical game identity supplied by the orchestrator. Must
|
||||||
|
be a non-zero UUID.
|
||||||
races:
|
races:
|
||||||
type: array
|
type: array
|
||||||
description: List of participating races. Minimum 10 entries required.
|
description: List of participating races. Minimum 10 entries required.
|
||||||
@@ -1299,6 +1316,19 @@ components:
|
|||||||
error:
|
error:
|
||||||
type: string
|
type: string
|
||||||
description: Human-readable validation error message from the binding layer.
|
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:
|
InternalErrorResponse:
|
||||||
type: object
|
type: object
|
||||||
description: |
|
description: |
|
||||||
@@ -1326,6 +1356,17 @@ components:
|
|||||||
validationError:
|
validationError:
|
||||||
value:
|
value:
|
||||||
error: "Key: 'InitRequest.Races' Error:Field validation for 'Races' failed on the 'gte' tag"
|
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:
|
InternalError:
|
||||||
description: Internal Game Service error.
|
description: Internal Game Service error.
|
||||||
content:
|
content:
|
||||||
|
|||||||
@@ -187,11 +187,23 @@ func TestGameOpenAPISpecFreezesInitRequest(t *testing.T) {
|
|||||||
assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/InitRequest", "init request schema")
|
assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/InitRequest", "init request schema")
|
||||||
|
|
||||||
schema := componentSchemaRef(t, doc, "InitRequest")
|
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"]
|
racesSchema := schema.Value.Properties["races"]
|
||||||
require.NotNil(t, racesSchema, "InitRequest.races schema must exist")
|
require.NotNil(t, racesSchema, "InitRequest.races schema must exist")
|
||||||
require.Equal(t, uint64(10), racesSchema.Value.MinItems, "InitRequest.races minItems must be 10")
|
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) {
|
func TestGameOpenAPISpecFreezesAdminOperationIDs(t *testing.T) {
|
||||||
|
|||||||
+13
-2
@@ -1,11 +1,22 @@
|
|||||||
package rest
|
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 {
|
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"`
|
Races []InitRace `json:"races" binding:"required,gte=10"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type InitRace struct {
|
type InitRace struct {
|
||||||
// Name of the Race
|
// RaceName is the human-readable name of the race.
|
||||||
RaceName string `json:"raceName" binding:"required,notblank"`
|
RaceName string `json:"raceName" binding:"required,notblank"`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user