feat(game): canonical gameId in POST /api/v1/admin/init #72
+4
-1
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}}})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user