Files
galaxy-game/game/internal/controller/controller.go
T
Ilia Denisov 601970b028
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
refactor(game): lock-free storage, remove /command, flatten engine wrapper
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>
2026-05-30 13:37:07 +02:00

220 lines
6.5 KiB
Go

package controller
import (
"errors"
"fmt"
"time"
e "galaxy/error"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
"galaxy/model/order"
"galaxy/model/report"
"galaxy/game/internal/repo"
)
// Service is the engine's application service: it owns persistence and exposes
// the operations the HTTP handlers invoke. It is safe for concurrent use —
// reads are lock-free and the writers that mutate the canonical state file
// (init/turn/banish) are serialised at the router by a shared LimitMiddleware.
type Service struct {
repo *repo.Repo
}
// NewService opens the file-backed storage at storagePath and returns a ready
// Service. The directory must already exist and be writable.
func NewService(storagePath string) (*Service, error) {
r, err := repo.NewFileRepo(storagePath)
if err != nil {
return nil, err
}
return &Service{repo: r}, nil
}
// 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 (s *Service) GenerateGame(gameID uuid.UUID, races []string) (game.State, error) {
if gameID == uuid.Nil {
return game.State{}, ErrGameInitNilUUID
}
if existing, loadErr := s.repo.LoadState(); loadErr == nil {
return game.State{}, fmt.Errorf("%w: stored gameId=%s, requested=%s", ErrGameAlreadyInit, existing.ID, gameID)
} else if !isGameNotInitialized(loadErr) {
return game.State{}, fmt.Errorf("check existing state: %w", loadErr)
}
if _, err := NewGame(s.repo, gameID, races); err != nil {
return game.State{}, err
}
return s.GameState()
}
// GenerateTurn advances the game by one turn (applying every stored order) and
// returns the resulting game state.
func (s *Service) GenerateTurn() (game.State, error) {
if err := s.execute(func(_ uint, c *Controller) error { return c.MakeTurn() }); err != nil {
return game.State{}, err
}
return s.GameState()
}
// 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
}
// LoadReport returns the stored turn report for actor at the given turn.
func (s *Service) LoadReport(actor string, turn uint) (r *report.Report, err error) {
execErr := s.execute(func(_ uint, c *Controller) (exErr error) {
id, exErr := c.RaceID(actor)
if exErr == nil {
r, exErr = s.repo.LoadReport(turn, id)
}
return
})
err = errors.Join(err, execErr)
return
}
// ValidateOrder validates cmd against a transient view of the current state,
// records the per-command outcome on each command's meta, and stores the
// resulting order for the current turn. Game-state rejections are reported per
// command, not as a returned error.
func (s *Service) ValidateOrder(actor string, cmd ...order.DecodableCommand) (o *order.UserGamesOrder, err error) {
err = s.execute(func(t uint, c *Controller) error {
id, err := c.RaceID(actor)
if err != nil {
return err
}
if err := c.ValidateOrder(actor, cmd...); err != nil {
return err
}
o = &order.UserGamesOrder{
GameID: c.Cache.g.ID,
UpdatedAt: time.Now().UTC().UnixMilli(),
Commands: make([]order.DecodableCommand, len(cmd)),
}
copy(o.Commands, cmd)
return s.repo.SaveOrder(t, id, o)
})
if err != nil {
return nil, err
}
return
}
// FetchOrder returns the order actor stored for the given turn. ok is false
// when no order was ever stored.
func (s *Service) FetchOrder(actor string, turn uint) (o *order.UserGamesOrder, ok bool, err error) {
err = s.execute(func(_ uint, c *Controller) error {
id, err := c.RaceID(actor)
if err != nil {
return err
}
o, ok, err = s.repo.LoadOrder(turn, id)
return err
})
if err != nil {
return
}
return
}
// FetchBattle returns the battle report stored at turn under ID. exists is
// false when no such battle was recorded.
func (s *Service) FetchBattle(turn uint, ID uuid.UUID) (b *report.BattleReport, exists bool, err error) {
err = s.execute(func(_ uint, c *Controller) error {
b, exists, err = s.repo.LoadBattle(turn, ID)
return err
})
return
}
// BanishRace deactivates actor's race after a permanent platform removal and
// persists the updated state.
func (s *Service) BanishRace(actor string) error {
return s.execute(func(_ uint, c *Controller) error {
if err := c.RaceBanish(actor); err != nil {
return err
}
return c.saveState()
})
}
// GameState loads the current state and projects it into the transport-facing
// game.State summary (player roster with planet counts and population).
func (s *Service) GameState() (game.State, error) {
g, err := s.repo.LoadState()
if err != nil {
return game.State{}, err
}
result := &game.State{
ID: g.ID,
Turn: g.Turn,
Stage: g.Stage,
Finished: g.Finished(),
Players: make([]game.PlayerState, len(g.Race)),
}
planetCount := make(map[uuid.UUID]uint)
population := make(map[uuid.UUID]game.Float)
for i := range g.Map.Planet {
p := &g.Map.Planet[i]
if p.Owner == nil {
continue
}
owner := *p.Owner
planetCount[owner] += 1
population[owner] += p.Population
}
for i := range g.Race {
r := &g.Race[i]
result.Players[i].ID = r.ID
result.Players[i].RaceName = r.Name
result.Players[i].Planets = planetCount[r.ID]
result.Players[i].Population = population[r.ID]
result.Players[i].Extinct = r.Extinct
}
return *result, nil
}
// execute loads the current game state, wraps it in a Controller and runs
// consumer against it. Reads and writes are lock-free; concurrent writers to
// the state file (init/turn/banish) are serialised at the router by a shared
// LimitMiddleware, so this helper holds no lock of its own.
func (s *Service) execute(consumer func(uint, *Controller) error) error {
g, err := s.repo.LoadState()
if err != nil {
return err
}
return consumer(g.Turn, &Controller{repo: s.repo, Cache: NewCache(g)})
}
// Controller is the per-turn execution context: a loaded game state (Cache)
// plus the repo it persists through. It carries the engine's game-logic
// methods (in command.go, order.go, generate_turn.go, …).
type Controller struct {
repo *repo.Repo
Cache *Cache
}
func (c *Controller) saveState() error {
return c.repo.SaveLastState(c.Cache.g)
}