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
+33 -42
View File
@@ -19,6 +19,7 @@ import (
"galaxy/model/report"
"galaxy/game/internal/model/game"
"galaxy/game/internal/repo/fs"
"github.com/google/uuid"
)
@@ -42,11 +43,11 @@ func (o *storedOrder) UnmarshalBinary(data []byte) error {
return json.Unmarshal(data, o)
}
func (r *repo) SaveNewTurn(t uint, g *game.Game) error {
func (r *Repo) SaveNewTurn(t uint, g *game.Game) error {
return saveNewTurn(r.s, t, g)
}
func saveNewTurn(s Storage, t uint, g *game.Game) error {
func saveNewTurn(s *fs.FS, t uint, g *game.Game) error {
path := fmt.Sprintf("%s/state.json", TurnDir(t))
exist, err := s.Exists(path)
if err != nil {
@@ -61,27 +62,23 @@ func saveNewTurn(s Storage, t uint, g *game.Game) error {
return saveLastState(s, g)
}
func (r *repo) SaveLastState(g *game.Game) error {
func (r *Repo) SaveLastState(g *game.Game) error {
return saveLastState(r.s, g)
}
func saveLastState(s Storage, g *game.Game) error {
func saveLastState(s *fs.FS, g *game.Game) error {
if err := s.Write(statePath, g); err != nil {
return NewStorageError(err)
}
return nil
}
func (r *repo) LoadState() (*game.Game, error) {
return loadState(r.s, true)
func (r *Repo) LoadState() (*game.Game, error) {
return loadState(r.s)
}
func (r *repo) LoadStateSafe() (*game.Game, error) {
return loadState(r.s, false)
}
func loadState(s Storage, locked bool) (*game.Game, error) {
var result *game.Game = new(game.Game)
func loadState(s *fs.FS) (*game.Game, error) {
result := new(game.Game)
path := statePath
exist, err := s.Exists(path)
if err != nil {
@@ -90,19 +87,13 @@ func loadState(s Storage, locked bool) (*game.Game, error) {
if !exist {
return nil, NewGameNotInitializedError()
}
if locked {
if err := s.Read(path, result); err != nil {
return nil, NewStorageError(err)
}
} else {
if err := s.ReadSafe(path, result); err != nil {
return nil, NewStorageError(err)
}
if err := s.Read(path, result); err != nil {
return nil, NewStorageError(err)
}
return result, nil
}
func loadMeta(s Storage) (*game.GameMeta, error) {
func loadMeta(s *fs.FS) (*game.GameMeta, error) {
var result *game.GameMeta = new(game.GameMeta)
path := metaPath
exist, err := s.Exists(path)
@@ -112,13 +103,13 @@ func loadMeta(s Storage) (*game.GameMeta, error) {
if !exist {
return result, nil
}
if err := s.ReadSafe(path, result); err != nil {
if err := s.Read(path, result); err != nil {
return nil, NewStorageError(err)
}
return result, nil
}
func loadTurnMeta(s Storage, turn uint) (*game.GameMeta, error) {
func loadTurnMeta(s *fs.FS, turn uint) (*game.GameMeta, error) {
var result *game.GameMeta = new(game.GameMeta)
path := fmt.Sprintf("%s/%s", TurnDir(turn), metaPath)
exist, err := s.Exists(path)
@@ -128,13 +119,13 @@ func loadTurnMeta(s Storage, turn uint) (*game.GameMeta, error) {
if !exist {
return result, nil
}
if err := s.ReadSafe(path, result); err != nil {
if err := s.Read(path, result); err != nil {
return nil, NewStorageError(err)
}
return result, nil
}
func saveMeta(s Storage, turn uint, gm *game.GameMeta) error {
func saveMeta(s *fs.FS, turn uint, gm *game.GameMeta) error {
// save turn's meta
path := fmt.Sprintf("%s/%s", TurnDir(turn), metaPath)
if err := s.Write(path, gm); err != nil {
@@ -148,7 +139,7 @@ func saveMeta(s Storage, turn uint, gm *game.GameMeta) error {
return nil
}
func (r *repo) LoadBattle(turn uint, id uuid.UUID) (*report.BattleReport, bool, error) {
func (r *Repo) LoadBattle(turn uint, id uuid.UUID) (*report.BattleReport, bool, error) {
meta, err := loadTurnMeta(r.s, turn)
if err != nil {
return nil, false, err
@@ -164,7 +155,7 @@ func (r *repo) LoadBattle(turn uint, id uuid.UUID) (*report.BattleReport, bool,
return result, true, nil
}
func (r *repo) SaveBattle(turn uint, b *report.BattleReport, m *game.BattleMeta) error {
func (r *Repo) SaveBattle(turn uint, b *report.BattleReport, m *game.BattleMeta) error {
meta, err := loadMeta(r.s)
if err != nil {
return err
@@ -177,7 +168,7 @@ func (r *repo) SaveBattle(turn uint, b *report.BattleReport, m *game.BattleMeta)
return saveMeta(r.s, turn, meta)
}
func saveBattle(s Storage, turn uint, b *report.BattleReport) error {
func saveBattle(s *fs.FS, turn uint, b *report.BattleReport) error {
path := fmt.Sprintf("%s/battle/%s.json", TurnDir(turn), b.ID.String())
exist, err := s.Exists(path)
if err != nil {
@@ -192,7 +183,7 @@ func saveBattle(s Storage, turn uint, b *report.BattleReport) error {
return nil
}
func loadBattle(s Storage, turn uint, id uuid.UUID) (*report.BattleReport, error) {
func loadBattle(s *fs.FS, turn uint, id uuid.UUID) (*report.BattleReport, error) {
path := fmt.Sprintf("%s/battle/%s.json", TurnDir(turn), id.String())
exist, err := s.Exists(path)
if err != nil {
@@ -202,13 +193,13 @@ func loadBattle(s Storage, turn uint, id uuid.UUID) (*report.BattleReport, error
return nil, NewStateError(fmt.Sprintf("battle %v for turn %d never was saved", id, turn))
}
result := new(report.BattleReport)
if err := s.ReadSafe(path, result); err != nil {
if err := s.Read(path, result); err != nil {
return nil, NewStorageError(err)
}
return result, nil
}
func (r *repo) SaveBombings(turn uint, b []*game.Bombing) error {
func (r *Repo) SaveBombings(turn uint, b []*game.Bombing) error {
meta, err := loadMeta(r.s)
if err != nil {
return err
@@ -219,11 +210,11 @@ func (r *repo) SaveBombings(turn uint, b []*game.Bombing) error {
return saveMeta(r.s, turn, meta)
}
func (r *repo) SaveReport(turn uint, rep *report.Report) error {
func (r *Repo) SaveReport(turn uint, rep *report.Report) error {
return saveReport(r.s, turn, rep)
}
func saveReport(s Storage, t uint, v *report.Report) error {
func saveReport(s *fs.FS, t uint, v *report.Report) error {
path := ReportDir(t, v.RaceID)
if err := s.Write(path, v); err != nil {
return NewStorageError(err)
@@ -231,11 +222,11 @@ func saveReport(s Storage, t uint, v *report.Report) error {
return nil
}
func (r *repo) LoadReport(turn uint, id uuid.UUID) (*report.Report, error) {
func (r *Repo) LoadReport(turn uint, id uuid.UUID) (*report.Report, error) {
return loadReport(r.s, turn, id)
}
func loadReport(s Storage, turn uint, id uuid.UUID) (*report.Report, error) {
func loadReport(s *fs.FS, turn uint, id uuid.UUID) (*report.Report, error) {
path := ReportDir(turn, id)
result := new(report.Report)
exist, err := s.Exists(path)
@@ -245,29 +236,29 @@ func loadReport(s Storage, turn uint, id uuid.UUID) (*report.Report, error) {
if !exist {
return nil, NewReportNotFoundError()
}
if err := s.ReadSafe(path, result); err != nil {
if err := s.Read(path, result); err != nil {
return nil, NewStorageError(err)
}
return result, nil
}
func (r *repo) SaveOrder(t uint, id uuid.UUID, o *order.UserGamesOrder) error {
func (r *Repo) SaveOrder(t uint, id uuid.UUID, o *order.UserGamesOrder) error {
return saveOrder(r.s, t, id, o)
}
func saveOrder(s Storage, t uint, id uuid.UUID, o *order.UserGamesOrder) error {
func saveOrder(s *fs.FS, t uint, id uuid.UUID, o *order.UserGamesOrder) error {
path := OrderDir(t, id)
if err := s.WriteSafe(path, o); err != nil {
if err := s.Write(path, o); err != nil {
return NewStorageError(err)
}
return nil
}
func (r *repo) LoadOrder(t uint, id uuid.UUID) (*order.UserGamesOrder, bool, error) {
func (r *Repo) LoadOrder(t uint, id uuid.UUID) (*order.UserGamesOrder, bool, error) {
return loadOrder(r.s, t, id)
}
func loadOrder(s Storage, t uint, id uuid.UUID) (*order.UserGamesOrder, bool, error) {
func loadOrder(s *fs.FS, t uint, id uuid.UUID) (*order.UserGamesOrder, bool, error) {
path := OrderDir(t, id)
exist, err := s.Exists(path)
@@ -279,7 +270,7 @@ func loadOrder(s Storage, t uint, id uuid.UUID) (*order.UserGamesOrder, bool, er
}
stored := new(storedOrder)
if err := s.ReadSafe(path, stored); err != nil {
if err := s.Read(path, stored); err != nil {
return nil, false, NewStorageError(err)
}
// An empty stored batch is a valid state — the player either