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
+118 -281
View File
@@ -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)
}
+1 -2
View File
@@ -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))
+3 -2
View File
@@ -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)
}
+4 -4
View File
@@ -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
}
}
+2 -2
View File
@@ -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],
+76
View File
@@ -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")
}