refactor(game): lock-free storage, remove /command, flatten engine wrapper
Tests · Go / test (push) Successful in 2m27s
Tests · UI / test (push) Waiting to run
Tests · Integration / integration (pull_request) Successful in 1m45s
Tests · Go / test (pull_request) Successful in 3m13s
Tests · UI / test (pull_request) Successful in 3m8s

Three-stage refactor of the game-engine plumbing (game logic untouched):

Stage 1 — lock-free persistence + admin serialisation. Remove the file
lock from repo/fs (the .lock file, the Read/Write-vs-*Safe duality and the
dead ReadSafe polling) and replace the two-step rename with a single atomic
rename so concurrent reads are torn-free without a lock. Serialise the
state-mutating admin writers (init/turn/banish) with one shared router
LimitMiddleware, rewritten to block on the request context instead of a
racy shared 100ms timer.

Stage 2 — remove the obsolete immediate-command path end to end. Players
submit through PUT /api/v1/order; the legacy PUT /api/v1/command path is
deleted across game (route, handler, 24 command factories, Ctrl), backend
(Commands handler/route, engineclient.ExecuteCommands), gateway (dispatch +
executeUserGamesCommand + routing entry), the FlatBuffers/model contract
(UserGamesCommand[Response]) and transcoder, plus every affected
OpenAPI/README/FUNCTIONAL/ARCHITECTURE doc. The integration proxy test is
converted to the order path.

Stage 3 — flatten the REST->engine wrapper. Replace the executor adapter,
the controller package functions and RepoController with one concrete
controller.Service; drop the single-implementation Repo and Storage
interfaces (repo.Repo / fs.FS are now concrete). Handlers depend on a thin
handler.Engine seam and own the domain->REST projection; storage is
resolved once at startup instead of per request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-30 13:37:07 +02:00
parent e36d33482f
commit 601970b028
65 changed files with 681 additions and 2804 deletions
+2 -2
View File
@@ -8,13 +8,13 @@ import (
"github.com/gin-gonic/gin"
)
func BanishHandler(c *gin.Context, executor CommandExecutor) {
func BanishHandler(c *gin.Context, engine Engine) {
var req rest.BanishRequest
if errorResponse(c, c.ShouldBindJSON(&req)) {
return
}
if errorResponse(c, executor.BanishRace(req.RaceName)) {
if errorResponse(c, engine.BanishRace(req.RaceName)) {
return
}
+2 -2
View File
@@ -8,7 +8,7 @@ import (
"github.com/google/uuid"
)
func BattleHandler(c *gin.Context, executor CommandExecutor) {
func BattleHandler(c *gin.Context, engine Engine) {
turn := c.Param("turn")
t, err := strconv.Atoi(turn)
if err != nil {
@@ -25,7 +25,7 @@ func BattleHandler(c *gin.Context, executor CommandExecutor) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
r, exists, err := executor.FetchBattle(uint(t), battleID)
r, exists, err := engine.FetchBattle(uint(t), battleID)
if errorResponse(c, err) {
return
}
-347
View File
@@ -1,347 +0,0 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"galaxy/game/internal/controller"
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
"galaxy/model/order"
"galaxy/model/rest"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
)
func CommandHandler(c *gin.Context, executor CommandExecutor) {
var cmd rest.Command
if errorResponse(c, c.ShouldBindJSON(&cmd)) {
return
}
commands := make([]Command, len(cmd.Commands))
for i := range cmd.Commands {
command, err := parseCommand(cmd.Actor, cmd.Commands[i])
if errorResponse(c, err) {
return
}
commands[i] = command
}
if len(commands) == 0 {
// `PUT /api/v1/command` is the immediate-execution path —
// running an empty batch is a meaningless no-op, so we
// reject it with `400` rather than rely on the validator.
// `PUT /api/v1/order` keeps an empty list (the player
// cleared their draft) — see `OrderHandler`.
c.JSON(http.StatusBadRequest, gin.H{"error": "no commands given"})
return
}
if errorResponse(c, executor.Execute(commands...)) {
return
}
c.Status(http.StatusAccepted)
}
func parseCommand(actor string, c json.RawMessage) (Command, error) {
meta := new(order.CommandMeta)
if err := json.Unmarshal(c, meta); err != nil {
return nil, err
}
switch t := meta.CmdType; t {
case order.CommandTypeRaceQuit:
return commandRaceQuit(actor)
case order.CommandTypeRaceVote:
return commandRaceVote(actor, c)
case order.CommandTypeRaceRelation:
return commandRaceRelation(actor, c)
case order.CommandTypeShipClassCreate:
return commandShipClassCreate(actor, c)
case order.CommandTypeShipClassMerge:
return commandShipClassMerge(actor, c)
case order.CommandTypeShipClassRemove:
return commandShipClassRemove(actor, c)
case order.CommandTypeShipGroupBreak:
return commandShipGroupBreak(actor, c)
case order.CommandTypeShipGroupLoad:
return commandShipGroupLoad(actor, c)
case order.CommandTypeShipGroupUnload:
return commandShipGroupUnload(actor, c)
case order.CommandTypeShipGroupSend:
return commandShipGroupSend(actor, c)
case order.CommandTypeShipGroupUpgrade:
return commandShipGroupUpgrade(actor, c)
case order.CommandTypeShipGroupMerge:
return commandShipGroupMerge(actor, c)
case order.CommandTypeShipGroupDismantle:
return commandShipGroupDismantle(actor, c)
case order.CommandTypeShipGroupTransfer:
return commandShipGroupTransfer(actor, c)
case order.CommandTypeShipGroupJoinFleet:
return commandShipGroupJoinFleet(actor, c)
case order.CommandTypeFleetMerge:
return commandFleetMerge(actor, c)
case order.CommandTypeFleetSend:
return commandFleetSend(actor, c)
case order.CommandTypeScienceCreate:
return commandScienceCreate(actor, c)
case order.CommandTypeScienceRemove:
return commandScienceRemove(actor, c)
case order.CommandTypePlanetRename:
return commandPlanetRename(actor, c)
case order.CommandTypePlanetProduce:
return commandPlanetProduce(actor, c)
case order.CommandTypePlanetRouteSet:
return commandPlanetRouteSet(actor, c)
case order.CommandTypePlanetRouteRemove:
return commandPlanetRouteRemove(actor, c)
default:
return nil, fmt.Errorf("unknown comman type: %s", t)
}
}
func commandRaceQuit(actor string) (Command, error) {
return func(c controller.Ctrl) error { return c.RaceQuit(actor) }, nil
}
func commandRaceVote(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandRaceVote)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.RaceVote(actor, v.Acceptor)
}, nil
}
}
func commandRaceRelation(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandRaceRelation)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.RaceRelation(actor, v.Acceptor, v.Relation)
}, nil
}
}
func commandShipClassCreate(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipClassCreate)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipClassCreate(actor, v.Name, v.Drive, int(v.Armament), v.Weapons, v.Shields, v.Cargo)
}, nil
}
}
func commandShipClassMerge(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipClassMerge)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipClassMerge(actor, v.Name, v.Target)
}, nil
}
}
func commandShipClassRemove(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipClassRemove)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipClassRemove(actor, v.Name)
}, nil
}
}
func commandShipGroupBreak(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipGroupBreak)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipGroupBreak(actor, uuid.MustParse(v.ID), uuid.MustParse(v.NewID), uint(v.Quantity))
}, nil
}
}
func commandShipGroupLoad(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipGroupLoad)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipGroupLoad(actor, uuid.MustParse(v.ID), v.Cargo, v.Quantity)
}, nil
}
}
func commandShipGroupUnload(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipGroupUnload)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipGroupUnload(actor, uuid.MustParse(v.ID), v.Quantity)
}, nil
}
}
func commandShipGroupSend(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipGroupSend)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipGroupSend(actor, uuid.MustParse(v.ID), uint(v.Destination))
}, nil
}
}
func commandShipGroupUpgrade(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipGroupUpgrade)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipGroupUpgrade(actor, uuid.MustParse(v.ID), v.Tech, v.Level)
}, nil
}
}
func commandShipGroupMerge(actor string, c json.RawMessage) (Command, error) {
return func(c controller.Ctrl) error {
return c.ShipGroupMerge(actor)
}, nil
}
func commandShipGroupDismantle(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipGroupDismantle)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipGroupDismantle(actor, uuid.MustParse(v.ID))
}, nil
}
}
func commandShipGroupTransfer(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipGroupTransfer)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipGroupTransfer(actor, v.Acceptor, uuid.MustParse(v.ID))
}, nil
}
}
func commandShipGroupJoinFleet(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipGroupJoinFleet)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipGroupJoinFleet(actor, v.Name, uuid.MustParse(v.ID))
}, nil
}
}
func commandFleetMerge(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandFleetMerge)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.FleetMerge(actor, v.Name, v.Target)
}, nil
}
}
func commandFleetSend(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandFleetSend)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.FleetSend(actor, v.Name, uint(v.Destination))
}, nil
}
}
func commandScienceCreate(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandScienceCreate)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ScienceCreate(actor, v.Name, v.Drive, v.Weapons, v.Shields, v.Cargo)
}, nil
}
}
func commandScienceRemove(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandScienceRemove)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ScienceRemove(actor, v.Name)
}, nil
}
}
func commandPlanetRename(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandPlanetRename)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.PlanetRename(actor, v.Number, v.Name)
}, nil
}
}
func commandPlanetProduce(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandPlanetProduce)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.PlanetProduce(actor, v.Number, v.Production, v.Subject)
}, nil
}
}
func commandPlanetRouteSet(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandPlanetRouteSet)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.PlanetRouteSet(actor, v.LoadType, uint(v.Origin), uint(v.Destination))
}, nil
}
}
func commandPlanetRouteRemove(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandPlanetRouteRemove)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.PlanetRouteRemove(actor, v.LoadType, uint(v.Origin))
}, nil
}
}
// Helpers
func unmarshallCommand[T order.DecodableCommand](c json.RawMessage, v T) (T, error) {
if err := json.Unmarshal(c, v); err != nil {
return v, err
}
if err := validateCommand(v); err != nil {
return v, err
}
return v, nil
}
func validateCommand(v order.DecodableCommand) error {
if ve, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := ve.Struct(v); err != nil {
return err
}
}
return nil
}
+12 -85
View File
@@ -12,7 +12,6 @@ import (
e "galaxy/error"
"galaxy/game/internal/controller"
"galaxy/game/internal/model/game"
"github.com/gin-gonic/gin"
@@ -20,25 +19,22 @@ import (
"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
// Engine is the set of operations the HTTP handlers invoke on the game engine.
// Its sole production implementation is *controller.Service; the interface
// exists so the transport layer can be exercised against a lightweight fake
// without standing up real storage. Methods return domain types — handlers own
// the projection into the REST wire shapes.
type Engine interface {
GenerateGame(gameID uuid.UUID, races []string) (game.State, error)
GenerateTurn() (game.State, error)
GameState() (game.State, error)
BanishRace(actor 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
@@ -53,77 +49,8 @@ func ResolveStoragePath() (string, error) {
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)
}
// stateResponse projects the engine's domain game.State into the REST
// StateResponse wire shape.
func stateResponse(s game.State) rest.StateResponse {
result := &rest.StateResponse{
ID: s.ID,
+3 -3
View File
@@ -11,7 +11,7 @@ import (
"github.com/google/uuid"
)
func InitHandler(c *gin.Context, executor CommandExecutor) {
func InitHandler(c *gin.Context, engine Engine) {
var init rest.InitRequest
if errorResponse(c, c.ShouldBindJSON(&init)) {
return
@@ -26,7 +26,7 @@ func InitHandler(c *gin.Context, executor CommandExecutor) {
races[i] = init.Races[i].RaceName
}
s, err := executor.GenerateGame(init.GameID, races)
s, err := engine.GenerateGame(init.GameID, races)
if err != nil {
if errors.Is(err, controller.ErrGameAlreadyInit) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
@@ -37,5 +37,5 @@ func InitHandler(c *gin.Context, executor CommandExecutor) {
}
}
c.JSON(http.StatusCreated, s)
c.JSON(http.StatusCreated, stateResponse(s))
}
+18 -4
View File
@@ -9,9 +9,11 @@ import (
"galaxy/game/internal/repo"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
)
func PutOrderHandler(c *gin.Context, executor CommandExecutor) {
func PutOrderHandler(c *gin.Context, engine Engine) {
var cmd rest.Command
if errorResponse(c, c.ShouldBindJSON(&cmd)) {
return
@@ -30,7 +32,7 @@ func PutOrderHandler(c *gin.Context, executor CommandExecutor) {
commands[i] = command
}
result, err := executor.ValidateOrder(cmd.Actor, commands...)
result, err := engine.ValidateOrder(cmd.Actor, commands...)
if errorResponse(c, err) {
return
}
@@ -43,7 +45,7 @@ type orderParam struct {
Turn int `form:"turn" binding:"gte=0"`
}
func GetOrderHandler(c *gin.Context, executor CommandExecutor) {
func GetOrderHandler(c *gin.Context, engine Engine) {
p := &orderParam{}
// ShouldBindQuery surfaces both validator failures and strconv parse
// errors; both are client-side faults, so 400 is the correct mapping.
@@ -52,7 +54,7 @@ func GetOrderHandler(c *gin.Context, executor CommandExecutor) {
return
}
o, ok, err := executor.FetchOrder(p.Player, uint(p.Turn))
o, ok, err := engine.FetchOrder(p.Player, uint(p.Turn))
if errorResponse(c, err) {
return
}
@@ -64,3 +66,15 @@ func GetOrderHandler(c *gin.Context, executor CommandExecutor) {
c.JSON(http.StatusOK, o)
}
// validateCommand runs the gin-registered struct validators against a
// decoded command. It is the per-command validation hook shared by the
// order-submission path (PutOrderHandler) and repo.ParseOrder.
func validateCommand(v order.DecodableCommand) error {
if ve, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := ve.Struct(v); err != nil {
return err
}
}
return nil
}
+2 -2
View File
@@ -11,14 +11,14 @@ type reportParam struct {
Turn int `form:"turn" binding:"gte=0"`
}
func ReportHandler(c *gin.Context, executor CommandExecutor) {
func ReportHandler(c *gin.Context, engine Engine) {
p := &reportParam{}
err := c.ShouldBindQuery(p)
if errorResponse(c, err) {
return
}
r, err := executor.LoadReport(p.Player, uint(p.Turn))
r, err := engine.LoadReport(p.Player, uint(p.Turn))
if errorResponse(c, err) {
return
}
+3 -3
View File
@@ -6,12 +6,12 @@ import (
"github.com/gin-gonic/gin"
)
func StatusHandler(c *gin.Context, executor CommandExecutor) {
state, err := executor.GameState()
func StatusHandler(c *gin.Context, engine Engine) {
state, err := engine.GameState()
if errorResponse(c, err) {
return
}
c.JSON(http.StatusOK, state)
c.JSON(http.StatusOK, stateResponse(state))
}
+3 -3
View File
@@ -6,12 +6,12 @@ import (
"github.com/gin-gonic/gin"
)
func TurnHandler(c *gin.Context, executor CommandExecutor) {
state, err := executor.GenerateTurn()
func TurnHandler(c *gin.Context, engine Engine) {
state, err := engine.GenerateTurn()
if errorResponse(c, err) {
return
}
c.JSON(http.StatusOK, state)
c.JSON(http.StatusOK, stateResponse(state))
}