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:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user