Merge pull request 'feat(game): canonical gameId in POST /api/v1/admin/init' (#72) from feature/canonical-game-id-init into development
This commit was merged in pull request #72.
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