601970b028
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>
220 lines
6.5 KiB
Go
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)
|
|
}
|