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>
This commit is contained in:
@@ -16,187 +16,147 @@ import (
|
||||
"galaxy/game/internal/repo"
|
||||
)
|
||||
|
||||
type Configurer func(*Param)
|
||||
|
||||
type Repo interface {
|
||||
// Lock must be called before any repository operations
|
||||
Lock() error
|
||||
|
||||
// Release must be called after first and only repository operation
|
||||
Release() error
|
||||
|
||||
// SaveTurn stores just generated new turn
|
||||
SaveNewTurn(uint, *game.Game) error
|
||||
|
||||
// SaveState stores current game state updated between turns
|
||||
SaveLastState(*game.Game) error
|
||||
|
||||
// LoadState retrieves game current state with required lock acquisition
|
||||
LoadState() (*game.Game, error)
|
||||
|
||||
// LoadStateSafe retrieves game current state without preliminary locking
|
||||
LoadStateSafe() (*game.Game, error)
|
||||
|
||||
// SaveBattle stores a new battle protocol and battle meta data for turn t
|
||||
SaveBattle(uint, *report.BattleReport, *game.BattleMeta) error
|
||||
|
||||
// LoadBattle reads battle's protocol for turn t and battle id.
|
||||
// Returns false if battle with such id was never stored at turn t
|
||||
LoadBattle(t uint, id uuid.UUID) (*report.BattleReport, bool, error)
|
||||
|
||||
// SaveBombing stores all prodused bombings for turn t
|
||||
SaveBombings(uint, []*game.Bombing) error
|
||||
|
||||
// SaveReport stores latest report for a race
|
||||
SaveReport(uint, *report.Report) error
|
||||
|
||||
// LoadReport loads report for specific turn and player id
|
||||
LoadReport(uint, uuid.UUID) (*report.Report, error)
|
||||
|
||||
// SaveOrder stores order for given turn
|
||||
SaveOrder(uint, uuid.UUID, *order.UserGamesOrder) error
|
||||
|
||||
// LoadOrder loads order for specific turn and player id
|
||||
LoadOrder(uint, uuid.UUID) (*order.UserGamesOrder, bool, error)
|
||||
// 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
|
||||
}
|
||||
|
||||
type Ctrl interface {
|
||||
ValidateOrder(actor string, cmd ...order.DecodableCommand) error
|
||||
// remove below funcs if /command api will be deleted
|
||||
RaceID(actor string) (uuid.UUID, error)
|
||||
RaceQuit(actor string) error
|
||||
RaceVote(actor, acceptor string) error
|
||||
RaceRelation(actor, acceptor string, rel string) error
|
||||
ShipClassCreate(actor, typeName string, drive float64, ammo int, weapons, shileds, cargo float64) error
|
||||
ShipClassMerge(actor, name, targetName string) error
|
||||
ShipClassRemove(actor, typeName string) error
|
||||
ShipGroupLoad(actor string, groupID uuid.UUID, cargoType string, quantity float64) error
|
||||
ShipGroupUnload(actor string, groupID uuid.UUID, quantity float64) error
|
||||
ShipGroupSend(actor string, groupID uuid.UUID, planetNumber uint) error
|
||||
ShipGroupUpgrade(actor string, groupID uuid.UUID, techInput string, limitLevel float64) error
|
||||
ShipGroupBreak(actor string, groupID, newID uuid.UUID, quantity uint) error
|
||||
ShipGroupMerge(actor string) error
|
||||
ShipGroupDismantle(actor string, groupID uuid.UUID) error
|
||||
ShipGroupTransfer(actor, acceptor string, groupID uuid.UUID) error
|
||||
ShipGroupJoinFleet(actor, fleetName string, groupID uuid.UUID) error
|
||||
FleetMerge(actor, fleetSourceName, fleetTargetName string) error
|
||||
FleetSend(actor, fleetName string, planetNumber uint) error
|
||||
ScienceCreate(actor, typeName string, drive, weapons, shields, cargo float64) error
|
||||
ScienceRemove(actor, typeName string) error
|
||||
PlanetRename(actor string, planetNumber int, typeName string) error
|
||||
PlanetProduce(actor string, planetNumber int, prodType, subject string) error
|
||||
PlanetRouteSet(actor, loadType string, origin, destination uint) error
|
||||
PlanetRouteRemove(actor, loadType string, origin uint) error
|
||||
// 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 GenerateGame(configure func(*Param), gameID uuid.UUID, races []string) (s game.State, err error) {
|
||||
// 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
|
||||
}
|
||||
ec, err := NewRepoController(configure)
|
||||
if err != nil {
|
||||
|
||||
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
|
||||
}
|
||||
if err = ec.Repo.Lock(); err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, ec.Repo.Release())
|
||||
if err == nil {
|
||||
s, err = GameState(configure)
|
||||
}
|
||||
}()
|
||||
|
||||
if existing, loadErr := ec.Repo.LoadState(); loadErr == nil {
|
||||
err = fmt.Errorf("%w: stored gameId=%s, requested=%s", ErrGameAlreadyInit, existing.ID, gameID)
|
||||
return
|
||||
} else if !isGameNotInitialized(loadErr) {
|
||||
err = fmt.Errorf("check existing state: %w", loadErr)
|
||||
return
|
||||
}
|
||||
return s.GameState()
|
||||
}
|
||||
|
||||
_, err = NewGame(ec.Repo, gameID, races)
|
||||
return
|
||||
// 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.
|
||||
// "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
|
||||
}
|
||||
|
||||
func GenerateTurn(configure func(*Param)) (err error) {
|
||||
ec, err := NewRepoController(configure)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = ec.executeLocked(func(c *Controller) error { return c.MakeTurn() })
|
||||
// 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
|
||||
}
|
||||
|
||||
func LoadReport(configure func(*Param), actor string, turn uint) (*report.Report, error) {
|
||||
ec, err := NewRepoController(configure)
|
||||
// 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 ec.loadReport(actor, turn)
|
||||
return
|
||||
}
|
||||
|
||||
func ExecuteCommand(configure func(*Param), consumer func(c Ctrl) error) (err error) {
|
||||
ec, err := NewRepoController(configure)
|
||||
if err != nil {
|
||||
// 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 ec.executeCommand(func(c *Controller) error { return consumer(c) })
|
||||
return
|
||||
}
|
||||
|
||||
func ValidateOrder(configure func(*Param), actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) {
|
||||
ec, err := NewRepoController(configure)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ec.validateOrder(actor, cmd...)
|
||||
}
|
||||
|
||||
func FetchOrder(configure func(*Param), actor string, turn uint) (order *order.UserGamesOrder, ok bool, err error) {
|
||||
ec, err := NewRepoController(configure)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return ec.fetchOrder(actor, turn)
|
||||
}
|
||||
|
||||
func FetchBattle(configure func(*Param), turn uint, ID uuid.UUID) (b *report.BattleReport, exists bool, err error) {
|
||||
ec, err := NewRepoController(configure)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return ec.fetchBattle(turn, ID)
|
||||
}
|
||||
|
||||
func BanishRace(configure func(*Param), actor string) error {
|
||||
ec, err := NewRepoController(configure)
|
||||
if err != nil {
|
||||
// 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 ec.banishRace(actor)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func GameState(configure func(*Param)) (s game.State, err error) {
|
||||
ec, err := NewRepoController(configure)
|
||||
if err != nil {
|
||||
return game.State{}, err
|
||||
}
|
||||
// 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()
|
||||
})
|
||||
}
|
||||
|
||||
g, err := ec.Repo.LoadStateSafe()
|
||||
// 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
|
||||
}
|
||||
@@ -234,149 +194,26 @@ func GameState(configure func(*Param)) (s game.State, err error) {
|
||||
return *result, nil
|
||||
}
|
||||
|
||||
type RepoController struct {
|
||||
Repo Repo
|
||||
}
|
||||
|
||||
func NewRepoController(config Configurer) (*RepoController, error) {
|
||||
c := &Param{
|
||||
StoragePath: ".",
|
||||
}
|
||||
if config != nil {
|
||||
config(c)
|
||||
}
|
||||
r, err := repo.NewFileRepo(c.StoragePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RepoController{
|
||||
Repo: r,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (ec *RepoController) NewGameController(g *game.Game) *Controller {
|
||||
return &Controller{
|
||||
RepoController: ec,
|
||||
Cache: NewCache(g),
|
||||
}
|
||||
}
|
||||
|
||||
func (ec *RepoController) validateOrder(actor string, cmd ...order.DecodableCommand) (o *order.UserGamesOrder, err error) {
|
||||
err = ec.executeSafe(func(t uint, c *Controller) error {
|
||||
id, err := c.RaceID(actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = c.ValidateOrder(actor, cmd...)
|
||||
if 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 ec.Repo.SaveOrder(t, id, o)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (ec *RepoController) fetchOrder(actor string, turn uint) (order *order.UserGamesOrder, ok bool, err error) {
|
||||
err = ec.executeSafe(func(t uint, c *Controller) error {
|
||||
id, err := c.RaceID(actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
order, ok, err = ec.Repo.LoadOrder(turn, id)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (ec *RepoController) fetchBattle(turn uint, ID uuid.UUID) (order *report.BattleReport, exists bool, err error) {
|
||||
err = ec.executeSafe(func(t uint, c *Controller) error {
|
||||
order, exists, err = ec.Repo.LoadBattle(turn, ID)
|
||||
return err
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func (ec *RepoController) loadReport(actor string, turn uint) (r *report.Report, err error) {
|
||||
execErr := ec.executeSafe(func(t uint, c *Controller) (exErr error) {
|
||||
id, exErr := c.RaceID(actor)
|
||||
if exErr == nil {
|
||||
r, exErr = ec.Repo.LoadReport(turn, id)
|
||||
}
|
||||
return
|
||||
})
|
||||
err = errors.Join(err, execErr)
|
||||
return
|
||||
}
|
||||
|
||||
func (ec *RepoController) executeCommand(consumer func(*Controller) error) (err error) {
|
||||
return ec.executeLocked(func(c *Controller) error {
|
||||
err = consumer(c)
|
||||
if err == nil {
|
||||
c.Cache.StageCommand()
|
||||
err = c.saveState()
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (ec *RepoController) banishRace(actor string) (err error) {
|
||||
return ec.executeLocked(func(c *Controller) error {
|
||||
err = c.RaceBanish(actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.saveState()
|
||||
})
|
||||
}
|
||||
|
||||
func (ec *RepoController) executeSafe(consumer func(uint, *Controller) error) (err error) {
|
||||
g, err := ec.Repo.LoadStateSafe()
|
||||
// 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
|
||||
}
|
||||
|
||||
err = consumer(g.Turn, ec.NewGameController(g))
|
||||
return
|
||||
}
|
||||
|
||||
func (ec *RepoController) executeLocked(consumer func(*Controller) error) (err error) {
|
||||
if err := ec.Repo.Lock(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, ec.Repo.Release())
|
||||
}()
|
||||
|
||||
g, err := ec.Repo.LoadState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = consumer(ec.NewGameController(g))
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Controller) saveState() error {
|
||||
return c.Repo.SaveLastState(c.Cache.g)
|
||||
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 {
|
||||
*RepoController
|
||||
repo *repo.Repo
|
||||
Cache *Cache
|
||||
}
|
||||
|
||||
type Param struct {
|
||||
StoragePath string
|
||||
func (c *Controller) saveState() error {
|
||||
return c.repo.SaveLastState(c.Cache.g)
|
||||
}
|
||||
|
||||
@@ -131,8 +131,7 @@ func newGame() *game.Game {
|
||||
|
||||
func newCache() (*controller.Cache, *controller.Controller) {
|
||||
ctl := &controller.Controller{
|
||||
RepoController: nil,
|
||||
Cache: controller.NewCache(newGame()),
|
||||
Cache: controller.NewCache(newGame()),
|
||||
}
|
||||
assertNoError(ctl.Cache.ShipClassCreate(Race_0_idx, Race_0_Gunship, 60, 3, 30, 100, 0))
|
||||
assertNoError(ctl.Cache.ShipClassCreate(Race_0_idx, Race_0_Freighter, 8, 0, 0, 2, 10))
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"galaxy/game/internal/generator"
|
||||
"galaxy/game/internal/model/game"
|
||||
"galaxy/game/internal/repo"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -14,7 +15,7 @@ import (
|
||||
// NewGame initialises a fresh game in storage under the supplied
|
||||
// gameID. The caller is expected to have validated gameID against
|
||||
// uuid.Nil and to have ruled out collisions with existing state.
|
||||
func NewGame(r Repo, gameID uuid.UUID, races []string) (uuid.UUID, error) {
|
||||
func NewGame(r *repo.Repo, gameID uuid.UUID, races []string) (uuid.UUID, error) {
|
||||
m, err := generator.Generate(func(ms *generator.MapSetting) {
|
||||
ms.Players = uint32(len(races))
|
||||
})
|
||||
@@ -24,7 +25,7 @@ func NewGame(r Repo, gameID uuid.UUID, races []string) (uuid.UUID, error) {
|
||||
return newGameOnMap(r, gameID, races, m)
|
||||
}
|
||||
|
||||
func newGameOnMap(r Repo, gameID uuid.UUID, races []string, m generator.Map) (uuid.UUID, error) {
|
||||
func newGameOnMap(r *repo.Repo, gameID uuid.UUID, races []string, m generator.Map) (uuid.UUID, error) {
|
||||
g, err := buildGameOnMap(gameID, races, m)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
|
||||
@@ -29,7 +29,6 @@ func TestNewGame(t *testing.T) {
|
||||
races[i] = fmt.Sprintf("race_%02d", i)
|
||||
}
|
||||
requestedID := uuid.New()
|
||||
assert.NoError(t, r.Lock())
|
||||
gameID, err := controller.NewGame(r, requestedID, races)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, requestedID, gameID, "NewGame must echo the supplied gameID")
|
||||
@@ -67,8 +66,6 @@ func TestNewGame(t *testing.T) {
|
||||
numShuffled = numShuffled || p.Number != uint(i)
|
||||
}
|
||||
assert.True(t, numShuffled)
|
||||
|
||||
assert.NoError(t, r.Release())
|
||||
}
|
||||
|
||||
func TestGenerateGameRejectsExistingState(t *testing.T) {
|
||||
@@ -79,13 +76,14 @@ func TestGenerateGameRejectsExistingState(t *testing.T) {
|
||||
for i := range races {
|
||||
races[i] = fmt.Sprintf("race_%02d", i)
|
||||
}
|
||||
configure := func(p *controller.Param) { p.StoragePath = root }
|
||||
|
||||
firstID := uuid.New()
|
||||
_, err := controller.GenerateGame(configure, firstID, races)
|
||||
svc, err := controller.NewService(root)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = controller.GenerateGame(configure, uuid.New(), races)
|
||||
firstID := uuid.New()
|
||||
_, err = svc.GenerateGame(firstID, races)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = svc.GenerateGame(uuid.New(), races)
|
||||
assert.ErrorIs(t, err, controller.ErrGameAlreadyInit)
|
||||
}
|
||||
|
||||
@@ -98,6 +96,8 @@ func TestGenerateGameRejectsNilUUID(t *testing.T) {
|
||||
races[i] = fmt.Sprintf("race_%02d", i)
|
||||
}
|
||||
|
||||
_, err := controller.GenerateGame(func(p *controller.Param) { p.StoragePath = root }, uuid.Nil, races)
|
||||
svc, err := controller.NewService(root)
|
||||
assert.NoError(t, err)
|
||||
_, err = svc.GenerateGame(uuid.Nil, races)
|
||||
assert.ErrorIs(t, err, controller.ErrGameInitNilUUID)
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func (c *Controller) MakeTurn() error {
|
||||
// Store bombings
|
||||
bombingReport := make([]*report.Bombing, len(bombings))
|
||||
if len(bombings) > 0 {
|
||||
if err := c.Repo.SaveBombings(c.Cache.g.Turn, bombings); err != nil {
|
||||
if err := c.repo.SaveBombings(c.Cache.g.Turn, bombings); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range bombings {
|
||||
@@ -107,7 +107,7 @@ func (c *Controller) MakeTurn() error {
|
||||
}
|
||||
|
||||
report := TransformBattle(c.Cache, b)
|
||||
if err := c.Repo.SaveBattle(c.Cache.g.Turn, report, &battleMeta[i]); err != nil {
|
||||
if err := c.repo.SaveBattle(c.Cache.g.Turn, report, &battleMeta[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
battleReport[i] = report
|
||||
@@ -118,12 +118,12 @@ func (c *Controller) MakeTurn() error {
|
||||
c.Cache.DeleteKilledShipGroups()
|
||||
|
||||
// Store game state for the new turn and 'current' state as well
|
||||
if err := c.Repo.SaveNewTurn(c.Cache.g.Turn, c.Cache.g); err != nil {
|
||||
if err := c.repo.SaveNewTurn(c.Cache.g.Turn, c.Cache.g); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for rep := range c.Cache.Report(c.Cache.g.Turn, battleReport, bombingReport) {
|
||||
if err := c.Repo.SaveReport(c.Cache.g.Turn, rep); err != nil {
|
||||
if err := c.repo.SaveReport(c.Cache.g.Turn, rep); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ func (c *Controller) applyOrders(t uint) error {
|
||||
cmdApplied := make(map[string]bool)
|
||||
|
||||
for ri := range c.Cache.listRaceActingIdx() {
|
||||
o, ok, err := c.Repo.LoadOrder(t, c.Cache.g.Race[ri].ID)
|
||||
o, ok, err := c.repo.LoadOrder(t, c.Cache.g.Race[ri].ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func (c *Controller) applyOrders(t uint) error {
|
||||
_ = c.applyCommand(commandRace[cmd.CommandID()], cmd)
|
||||
}
|
||||
// re-save order to persist possible changed commands result outcome
|
||||
if err := c.Repo.SaveOrder(t, c.Cache.g.Race[ri].ID, &order.UserGamesOrder{
|
||||
if err := c.repo.SaveOrder(t, c.Cache.g.Race[ri].ID, &order.UserGamesOrder{
|
||||
GameID: c.Cache.g.ID,
|
||||
UpdatedAt: raceOrderUpdated[ri],
|
||||
Commands: raceOrder[ri],
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package controller_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"galaxy/model/order"
|
||||
|
||||
"galaxy/util"
|
||||
|
||||
"galaxy/game/internal/controller"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestServiceOrderStoredThenAppliedAtTurn is the end-to-end regression for the
|
||||
// order lifecycle against a real Service backed by a temporary storage
|
||||
// directory: an order submitted through ValidateOrder is persisted (FetchOrder
|
||||
// returns it before the turn), applied when the turn is produced (GenerateTurn
|
||||
// advances the turn), and its per-command verdict survives turn production
|
||||
// (FetchOrder still returns it with cmdApplied set). It guards the wiring the
|
||||
// Stage 3 collapse reworked — Service methods threading the concrete repo
|
||||
// through validate → store → produce → read-back.
|
||||
func TestServiceOrderStoredThenAppliedAtTurn(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
|
||||
svc, err := controller.NewService(root)
|
||||
require.NoError(t, err)
|
||||
|
||||
races := make([]string, 10)
|
||||
for i := range races {
|
||||
races[i] = fmt.Sprintf("race_%02d", i)
|
||||
}
|
||||
if _, err := svc.GenerateGame(uuid.New(), races); err != nil {
|
||||
t.Fatalf("init game: %v", err)
|
||||
}
|
||||
|
||||
vote := &order.CommandRaceVote{
|
||||
CommandMeta: order.CommandMeta{CmdID: uuid.NewString(), CmdType: order.CommandTypeRaceVote},
|
||||
Acceptor: races[1],
|
||||
}
|
||||
stored, err := svc.ValidateOrder(races[0], vote)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stored.Commands, 1)
|
||||
|
||||
// The order is persisted and retrievable for the current turn (0)
|
||||
// before the turn is produced.
|
||||
got, ok, err := svc.FetchOrder(races[0], 0)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok, "submitted order must be retrievable before the turn")
|
||||
require.Len(t, got.Commands, 1)
|
||||
|
||||
// Producing the turn applies stored orders and advances the turn.
|
||||
state, err := svc.GenerateTurn()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint(1), state.Turn, "turn must advance after production")
|
||||
|
||||
// The turn-0 order still carries its per-command verdict, recorded by
|
||||
// turn production.
|
||||
applied, ok, err := svc.FetchOrder(races[0], 0)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Len(t, applied.Commands, 1)
|
||||
v, ok := order.AsCommand[*order.CommandRaceVote](applied.Commands[0])
|
||||
require.True(t, ok, "stored command must round-trip to its concrete type")
|
||||
require.NotNil(t, v.CmdApplied, "turn production must record cmdApplied")
|
||||
assert.True(t, *v.CmdApplied, "a valid vote must apply at turn production")
|
||||
|
||||
// Orders are per-turn: the freshly produced turn carries no order yet.
|
||||
_, ok, err = svc.FetchOrder(races[0], 1)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok, "the freshly produced turn carries no stored order")
|
||||
}
|
||||
Reference in New Issue
Block a user