15d35f6f1f
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>
182 lines
5.4 KiB
Go
182 lines
5.4 KiB
Go
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"galaxy/model/order"
|
|
"galaxy/model/report"
|
|
"galaxy/model/rest"
|
|
|
|
e "galaxy/error"
|
|
|
|
"galaxy/game/internal/controller"
|
|
"galaxy/game/internal/model/game"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/go-playground/validator/v10"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type CommandExecutor interface {
|
|
GenerateGame(gameID uuid.UUID, races []string) (rest.StateResponse, error)
|
|
GenerateTurn() (rest.StateResponse, error)
|
|
GameState() (rest.StateResponse, error)
|
|
BanishRace(string) error
|
|
LoadReport(actor string, turn uint) (*report.Report, error)
|
|
// Execute is reserved for future use; any API request for orders should use ValidateOrder
|
|
Execute(cmd ...Command) error
|
|
ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error)
|
|
FetchOrder(actor string, turn uint) (*order.UserGamesOrder, bool, error)
|
|
FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, bool, error)
|
|
}
|
|
|
|
type Command func(controller.Ctrl) error
|
|
|
|
type executor struct {
|
|
cfg controller.Configurer
|
|
}
|
|
|
|
// ResolveStoragePath returns the engine storage path resolved from
|
|
// STORAGE_PATH (preferred, historical name) or GAME_STATE_PATH (canonical
|
|
// name written by Runtime Manager). It returns an error when neither
|
|
// variable is set; callers are expected to fail fast at startup.
|
|
func ResolveStoragePath() (string, error) {
|
|
if v := strings.TrimSpace(os.Getenv("STORAGE_PATH")); v != "" {
|
|
return v, nil
|
|
}
|
|
if v := strings.TrimSpace(os.Getenv("GAME_STATE_PATH")); v != "" {
|
|
return v, nil
|
|
}
|
|
return "", errors.New("storage path is not set: provide STORAGE_PATH or GAME_STATE_PATH")
|
|
}
|
|
|
|
func initConfig() controller.Configurer {
|
|
return func(p *controller.Param) {
|
|
// Validated once at startup by ResolveStoragePath; the error
|
|
// is dropped here to keep the Configurer signature simple.
|
|
p.StoragePath, _ = ResolveStoragePath()
|
|
}
|
|
}
|
|
|
|
func NewDefaultExecutor() CommandExecutor {
|
|
return NewDefaultConfigExecutor(initConfig())
|
|
}
|
|
|
|
func NewDefaultConfigExecutor(configurer controller.Configurer) CommandExecutor {
|
|
return &executor{cfg: configurer}
|
|
}
|
|
|
|
func (e *executor) Execute(cmd ...Command) error {
|
|
return controller.ExecuteCommand(e.cfg, func(c controller.Ctrl) error {
|
|
for i := range cmd {
|
|
if err := cmd[i](c); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (e *executor) ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) {
|
|
return controller.ValidateOrder(e.cfg, actor, cmd...)
|
|
}
|
|
|
|
func (e *executor) FetchOrder(actor string, turn uint) (*order.UserGamesOrder, bool, error) {
|
|
return controller.FetchOrder(e.cfg, actor, turn)
|
|
}
|
|
|
|
func (e *executor) FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, bool, error) {
|
|
return controller.FetchBattle(e.cfg, turn, ID)
|
|
}
|
|
|
|
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
|
|
}
|
|
return stateResponse(s), nil
|
|
}
|
|
|
|
func (e *executor) GenerateTurn() (rest.StateResponse, error) {
|
|
err := controller.GenerateTurn(e.cfg)
|
|
if err != nil {
|
|
return rest.StateResponse{}, err
|
|
}
|
|
return e.GameState()
|
|
}
|
|
|
|
func (e *executor) GameState() (rest.StateResponse, error) {
|
|
s, err := controller.GameState(e.cfg)
|
|
if err != nil {
|
|
return rest.StateResponse{}, err
|
|
}
|
|
return stateResponse(s), nil
|
|
}
|
|
|
|
func (e *executor) BanishRace(raceName string) error {
|
|
return controller.BanishRace(e.cfg, raceName)
|
|
}
|
|
|
|
func (e *executor) LoadReport(actor string, turn uint) (*report.Report, error) {
|
|
return controller.LoadReport(e.cfg, actor, turn)
|
|
}
|
|
|
|
func stateResponse(s game.State) rest.StateResponse {
|
|
result := &rest.StateResponse{
|
|
ID: s.ID,
|
|
Turn: s.Turn,
|
|
Stage: s.Stage,
|
|
Finished: s.Finished,
|
|
Players: make([]rest.PlayerState, len(s.Players)),
|
|
}
|
|
for i := range s.Players {
|
|
result.Players[i].ID = s.Players[i].ID
|
|
result.Players[i].RaceName = s.Players[i].RaceName
|
|
result.Players[i].Planets = s.Players[i].Planets
|
|
result.Players[i].Population = s.Players[i].Population.F()
|
|
result.Players[i].Extinct = s.Players[i].Extinct
|
|
}
|
|
return *result
|
|
}
|
|
|
|
// errorResponse renders err onto c and reports whether the caller
|
|
// should stop further processing. The HTTP status is selected by the
|
|
// GenericError shelf (see pkg/error for the taxonomy):
|
|
//
|
|
// - validator.ValidationErrors (request struct binding) → 400 with
|
|
// {"error": ...}.
|
|
// - GenericError, ErrGameNotInitialized → 501 with no body.
|
|
// - GenericError on the internal shelf (1xxx) → 500 with
|
|
// {"generic_error", "code"}.
|
|
// - GenericError on the input-validation shelf (2xxx) or the
|
|
// game-state shelf (3xxx) → 400 with {"generic_error", "code"}.
|
|
// - everything else (non-GenericError) → 500 with {"error": ...}.
|
|
func errorResponse(c *gin.Context, err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
if v, ok := err.(validator.ValidationErrors); ok {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": v.Error()})
|
|
return true
|
|
}
|
|
|
|
if ge, ok := errors.AsType[*e.GenericError](err); ok {
|
|
switch {
|
|
case ge.Code == e.ErrGameNotInitialized:
|
|
c.Status(http.StatusNotImplemented)
|
|
case e.IsInputCode(ge.Code), e.IsGameStateCode(ge.Code):
|
|
c.JSON(http.StatusBadRequest, gin.H{"generic_error": ge.Error(), "code": ge.Code})
|
|
default:
|
|
c.JSON(http.StatusInternalServerError, gin.H{"generic_error": ge.Error(), "code": ge.Code})
|
|
}
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
}
|
|
|
|
return true
|
|
}
|