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
+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,