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:
+10
-13
@@ -47,7 +47,6 @@ described below. Endpoints split into two route classes:
|
||||
| Admin (GM-only) | `GET /api/v1/admin/status` | `Game Master` | Read the full game state. |
|
||||
| Admin (GM-only) | `PUT /api/v1/admin/turn` | `Game Master` | Generate the next turn. |
|
||||
| Admin (GM-only) | `POST /api/v1/admin/race/banish` | `Game Master` | Deactivate a race after a permanent platform removal. |
|
||||
| Player | `PUT /api/v1/command` | `Game Master` (forwarded from `Edge Gateway`) | Execute a batch of player commands. |
|
||||
| Player | `PUT /api/v1/order` | `Game Master` | Validate and store a batch of player orders. |
|
||||
| Player | `GET /api/v1/order` | `Game Master` | Fetch the previously stored player order for a turn. |
|
||||
| Player | `GET /api/v1/report` | `Game Master` | Fetch the per-player turn report. |
|
||||
@@ -166,19 +165,17 @@ Alternatives considered and rejected:
|
||||
|
||||
`game/internal/router/handler/handler.go` exports `ResolveStoragePath`,
|
||||
which returns the engine storage path from the env-var pair above and
|
||||
an error when neither is set. `cmd/http/main.go` calls it before
|
||||
constructing the router, prints the error to stderr, and exits non-zero.
|
||||
The existing `initConfig` closure also calls `ResolveStoragePath` to
|
||||
populate `controller.Param.StoragePath` at request time; the error there
|
||||
is dropped because `main` already validated the environment at startup.
|
||||
an error when neither is set. `cmd/http/main.go` calls it once at
|
||||
startup, prints the error to stderr and exits non-zero on failure, then
|
||||
builds the engine service (`controller.NewService(path)`) and hands it
|
||||
to `router.NewRouter`.
|
||||
|
||||
This keeps the public router surface (`router.NewRouter`) unchanged —
|
||||
the env binding is satisfied by one helper plus a startup check, with
|
||||
no API ripple. Moving env reading entirely into `main` and changing
|
||||
`NewRouter` / `NewDefaultExecutor` to accept an explicit path was
|
||||
rejected: it churns multiple call sites for no functional gain. The
|
||||
current shape leaves the configurer closure ready for future
|
||||
config-injection refactors without forcing one now.
|
||||
Storage is resolved exactly once, at construction, rather than per
|
||||
request: the `Service` holds the file-backed repo for the process
|
||||
lifetime and `router.NewRouter` takes the `handler.Engine` it routes
|
||||
to (in production, the `Service`). This keeps the env binding in one
|
||||
place — a startup helper plus the `main` check — and leaves the
|
||||
handlers free of configuration concerns.
|
||||
|
||||
## Build
|
||||
|
||||
|
||||
+10
-2
@@ -4,17 +4,25 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"galaxy/game/internal/controller"
|
||||
"galaxy/game/internal/router"
|
||||
"galaxy/game/internal/router/handler"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if _, err := handler.ResolveStoragePath(); err != nil {
|
||||
path, err := handler.ResolveStoragePath()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
r := router.NewRouter()
|
||||
svc, err := controller.NewService(path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
r := router.NewRouter(svc)
|
||||
if err := r.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
+41
-144
@@ -5,26 +5,19 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"galaxy/util"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPerm = 0o644
|
||||
lockFile = ".lock"
|
||||
oldFileSuffix = ".old"
|
||||
newFileSuffix = ".new"
|
||||
)
|
||||
const defaultPerm = 0o644
|
||||
|
||||
type fs struct {
|
||||
// FS is the file-backed Storage implementation: atomic, lock-free reads and
|
||||
// writes rooted at a single per-game directory.
|
||||
type FS struct {
|
||||
root string
|
||||
lock *os.File
|
||||
}
|
||||
|
||||
func NewFileStorage(path string) (*fs, error) {
|
||||
filepath.Join("", "")
|
||||
func NewFileStorage(path string) (*FS, error) {
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("path %s invalid: %s", path, err)
|
||||
@@ -41,55 +34,26 @@ func NewFileStorage(path string) (*fs, error) {
|
||||
return nil, errors.New("directory should have read-write access: " + absPath)
|
||||
}
|
||||
|
||||
fs := &fs{
|
||||
root: path,
|
||||
}
|
||||
return fs, nil
|
||||
return &FS{root: path}, nil
|
||||
}
|
||||
|
||||
func (f *fs) Lock() (func() error, error) {
|
||||
lockPath := f.lockFilePath()
|
||||
exists, err := util.FileExists(lockPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check lock file exists: %s", err)
|
||||
}
|
||||
if exists {
|
||||
return nil, errors.New("lock file already exists")
|
||||
}
|
||||
fd, err := os.OpenFile(lockPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create lock file: %s", err)
|
||||
}
|
||||
f.lock = fd
|
||||
unlock := func() error {
|
||||
if err := f.lock.Close(); err != nil {
|
||||
return fmt.Errorf("close lock file: %s", err)
|
||||
}
|
||||
if err := os.Remove(f.lock.Name()); err != nil {
|
||||
return fmt.Errorf("remove lock file: %s", err)
|
||||
}
|
||||
f.lock = nil
|
||||
return nil
|
||||
}
|
||||
if _, err := f.lock.Write(big.NewInt(time.Now().UnixMilli()).Bytes()); err != nil {
|
||||
return nil, errors.Join(fmt.Errorf("write lock file: %s", err), unlock())
|
||||
}
|
||||
return unlock, nil
|
||||
}
|
||||
|
||||
func (f *fs) Exists(path string) (bool, error) {
|
||||
func (f *FS) Exists(path string) (bool, error) {
|
||||
return util.FileExists(filepath.Join(f.root, path))
|
||||
}
|
||||
|
||||
func (f *fs) WriteSafe(path string, v encoding.BinaryMarshaler) error {
|
||||
// Write atomically persists v at path: it stages the payload in a temporary
|
||||
// file and swaps it into place with a single rename. On POSIX rename replaces
|
||||
// the destination atomically, so a concurrent reader always observes either
|
||||
// the previous file or the new one in full — the target is never absent
|
||||
// mid-write and never half-written. This atomic replace is the only
|
||||
// protection against torn reads; the storage holds no lock, and concurrent
|
||||
// writers to the same state file are serialised one layer up, at the router.
|
||||
func (f *FS) Write(path string, v encoding.BinaryMarshaler) error {
|
||||
if v == nil {
|
||||
return errors.New("cant't marshal from nil object")
|
||||
}
|
||||
|
||||
targetFilePath := filepath.Join(f.root, path)
|
||||
if targetFilePath == f.lockFilePath() {
|
||||
return errors.New("can't write to the lock file")
|
||||
}
|
||||
|
||||
data, err := v.MarshalBinary()
|
||||
if err != nil {
|
||||
@@ -103,120 +67,53 @@ func (f *fs) WriteSafe(path string, v encoding.BinaryMarshaler) error {
|
||||
return fmt.Errorf("check target dir exists: %s", err)
|
||||
}
|
||||
if !ok {
|
||||
err := os.MkdirAll(targetDir, os.ModePerm)
|
||||
if err != nil {
|
||||
if err := os.MkdirAll(targetDir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("create target dirs: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
oldFilePath := targetFilePath + oldFileSuffix
|
||||
|
||||
targetExists, err := util.FileExists(targetFilePath)
|
||||
// Stage the payload in a uniquely named temporary file next to the target
|
||||
// and swap it in with a single rename. A unique temp name means a crashed
|
||||
// write leaves no fixed-name leftover that would block later writes, and a
|
||||
// single rename is the atomic replace POSIX guarantees.
|
||||
tmp, err := os.CreateTemp(targetDir, filepath.Base(targetFilePath)+".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("check target file exists: %s", err)
|
||||
return fmt.Errorf("create temp file: %s", err)
|
||||
}
|
||||
if targetExists {
|
||||
oldFileExists, err := util.FileExists(oldFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check old file exists: %s", err)
|
||||
}
|
||||
if oldFileExists {
|
||||
return fmt.Errorf("old file exists at: %s", oldFilePath)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("write temp file: %s", err)
|
||||
}
|
||||
|
||||
newFilePath := targetFilePath + newFileSuffix
|
||||
newFileExists, err := util.FileExists(newFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check new file exists: %s", err)
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("close temp file: %s", err)
|
||||
}
|
||||
if newFileExists {
|
||||
return fmt.Errorf("new file exists at: %s", oldFilePath)
|
||||
if err := os.Chmod(tmpPath, defaultPerm); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("chmod temp file: %s", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(newFilePath, data, defaultPerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write data to the new file: %s", err)
|
||||
}
|
||||
|
||||
if targetExists {
|
||||
if err := os.Rename(targetFilePath, oldFilePath); err != nil {
|
||||
return fmt.Errorf("rename target file to the old file: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Rename(newFilePath, targetFilePath); err != nil {
|
||||
return fmt.Errorf("rename new file to the target file: %s", err)
|
||||
}
|
||||
|
||||
if targetExists {
|
||||
err := os.Remove(oldFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove old file: %s", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, targetFilePath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("replace target file: %s", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fs) Write(path string, v encoding.BinaryMarshaler) error {
|
||||
if f.lock == nil {
|
||||
return errors.New("lock must be acquired before write")
|
||||
}
|
||||
|
||||
return f.WriteSafe(path, v)
|
||||
}
|
||||
|
||||
func (f *fs) Read(path string, v encoding.BinaryUnmarshaler) error {
|
||||
if f.lock == nil {
|
||||
return errors.New("lock must be acquired before read")
|
||||
}
|
||||
|
||||
return f.readUnsafe(path, v)
|
||||
}
|
||||
|
||||
func (f *fs) ReadSafe(path string, v encoding.BinaryUnmarshaler) error {
|
||||
if f.lock != nil {
|
||||
timeout := time.NewTimer(time.Millisecond * 100)
|
||||
checker := time.NewTicker(time.Millisecond)
|
||||
out:
|
||||
for {
|
||||
select {
|
||||
case <-checker.C:
|
||||
if f.lock == nil {
|
||||
checker.Stop()
|
||||
timeout.Stop()
|
||||
break out
|
||||
}
|
||||
case <-timeout.C:
|
||||
checker.Stop()
|
||||
return errors.New("timeout waiting for lock release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return f.readUnsafe(path, v)
|
||||
}
|
||||
|
||||
// readUnsafe reads the file contents without locking mechanism in mind.
|
||||
// Using readUnsafe directly may cause errors if file being written at the moment.
|
||||
func (f *fs) readUnsafe(file string, v encoding.BinaryUnmarshaler) error {
|
||||
// Read loads path into v. Reads need no lock: because Write swaps files into
|
||||
// place atomically with rename, a reader always observes a complete file even
|
||||
// when a write is in flight.
|
||||
func (f *FS) Read(path string, v encoding.BinaryUnmarshaler) error {
|
||||
if v == nil {
|
||||
return errors.New("can't unmarshal to a nil object")
|
||||
}
|
||||
|
||||
targetFilePath := filepath.Join(f.root, file)
|
||||
if targetFilePath == f.lockFilePath() {
|
||||
return errors.New("can't read from the lock file")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(targetFilePath)
|
||||
data, err := os.ReadFile(filepath.Join(f.root, path))
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading data file: %s", err)
|
||||
}
|
||||
|
||||
return v.UnmarshalBinary(data)
|
||||
}
|
||||
|
||||
func (f *fs) lockFilePath() string {
|
||||
return filepath.Join(f.root, lockFile)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package fs_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"galaxy/game/internal/repo/fs"
|
||||
@@ -12,10 +14,6 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const (
|
||||
lockFile = ".lock"
|
||||
)
|
||||
|
||||
type sampleData struct {
|
||||
data []byte
|
||||
}
|
||||
@@ -36,20 +34,6 @@ func TestNewFileStorageSuccess(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestLock(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
fs, err := fs.NewFileStorage(root)
|
||||
assert.NoError(t, err, "create file storage")
|
||||
unlock, err := fs.Lock()
|
||||
assert.NoError(t, err, "acquire lock")
|
||||
lockPath := filepath.Join(root, lockFile)
|
||||
assert.FileExists(t, lockPath, "lock file should be created")
|
||||
err = unlock()
|
||||
assert.NoError(t, err, "unlocking existing lock")
|
||||
assert.NoFileExists(t, lockPath, "lock file must be removed")
|
||||
}
|
||||
|
||||
func TestExist(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
@@ -78,9 +62,6 @@ func TestWrite(t *testing.T) {
|
||||
fs, err := fs.NewFileStorage(root)
|
||||
assert.NoError(t, err, "create file storage: %s", err)
|
||||
|
||||
unlock, err := fs.Lock()
|
||||
assert.NoError(t, err, "acquire lock: %s", err)
|
||||
|
||||
dirName := "some-dir"
|
||||
if err := os.Mkdir(filepath.Join(root, dirName), os.ModePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -93,9 +74,8 @@ func TestWrite(t *testing.T) {
|
||||
{path: "file-1.ext"},
|
||||
{path: "/dir/file-2.ext"},
|
||||
{path: "dir/subdir/file-3.ext"},
|
||||
{path: lockFile, err: "write to the lock file"},
|
||||
{path: dirName, err: "wrong type"},
|
||||
{path: "/" + dirName, err: "wrong type"},
|
||||
{path: dirName, err: "file exists"},
|
||||
{path: "/" + dirName, err: "file exists"},
|
||||
} {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
sd := &sampleData{[]byte{0, 1, 2, 3}}
|
||||
@@ -103,13 +83,26 @@ func TestWrite(t *testing.T) {
|
||||
if tc.err == "" {
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(root, tc.path), "the written file should exist")
|
||||
} else if tc.err != "" {
|
||||
} else {
|
||||
assert.ErrorContains(t, err, tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
assert.NoError(t, unlock(), "unlocking existing lock")
|
||||
func TestWriteLeavesNoTempLeftovers(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
|
||||
s, err := fs.NewFileStorage(root)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.NoError(t, s.Write("state.bin", &sampleData{[]byte{1, 2, 3}}))
|
||||
|
||||
entries, err := os.ReadDir(root)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, entries, 1, "a successful write must leave only the target file, no temporaries")
|
||||
assert.Equal(t, "state.bin", entries[0].Name())
|
||||
}
|
||||
|
||||
func TestRead(t *testing.T) {
|
||||
@@ -121,11 +114,6 @@ func TestRead(t *testing.T) {
|
||||
fs, err := fs.NewFileStorage(root)
|
||||
assert.NoError(t, err, "create file storage: %s", err)
|
||||
|
||||
assert.EqualError(t, fs.Read("some.file", sd), "lock must be acquired before read")
|
||||
|
||||
unlock, err := fs.Lock()
|
||||
assert.NoError(t, err, "acquire lock: %s", err)
|
||||
|
||||
dirName := "some-dir"
|
||||
if err := os.Mkdir(filepath.Join(root, dirName), os.ModePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -142,33 +130,82 @@ func TestRead(t *testing.T) {
|
||||
}{
|
||||
{path: fileName},
|
||||
{path: "/" + fileName},
|
||||
{path: lockFile, err: "read from the lock file"},
|
||||
{path: "dir/subdir/file-3.ext", err: "no such file"},
|
||||
{path: lockFile, err: "read from the lock file"},
|
||||
{path: dirName, err: "is a directory"},
|
||||
} {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
err = fs.Read(tc.path, sd)
|
||||
if tc.err == "" {
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(root, tc.path), "the written file should exist")
|
||||
} else if tc.err != "" {
|
||||
} else {
|
||||
assert.ErrorContains(t, err, tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
assert.NoError(t, unlock(), "unlocking existing lock")
|
||||
}
|
||||
|
||||
func TestWriteErrorWithoutLock(t *testing.T) {
|
||||
// TestReadAtomicUnderConcurrentWrites is the regression that guards the
|
||||
// lock-free contract: with Write swapping files in via a single rename, a
|
||||
// concurrent Read must always observe one previously written payload in full —
|
||||
// never a torn mix and never a missing file. The two payloads differ in length
|
||||
// so any partial read is detectable.
|
||||
func TestReadAtomicUnderConcurrentWrites(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
fs, err := fs.NewFileStorage(root)
|
||||
assert.NoError(t, err, "create file storage")
|
||||
sd := &sampleData{[]byte{0, 1, 2, 3}}
|
||||
err = fs.Write("some/path", sd)
|
||||
assert.Error(t, err, "should return error when no lock acquired")
|
||||
assert.EqualError(t, err, "lock must be acquired before write")
|
||||
|
||||
s, err := fs.NewFileStorage(root)
|
||||
assert.NoError(t, err)
|
||||
|
||||
const path = "state.bin"
|
||||
payloads := [][]byte{
|
||||
bytes.Repeat([]byte{0xAA}, 4096),
|
||||
bytes.Repeat([]byte{0xBB}, 8192),
|
||||
}
|
||||
assert.NoError(t, s.Write(path, &sampleData{slices.Clone(payloads[0])}))
|
||||
|
||||
stop := make(chan struct{})
|
||||
var writers sync.WaitGroup
|
||||
for w := range 4 {
|
||||
writers.Go(func() {
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
_ = s.Write(path, &sampleData{slices.Clone(payloads[w%len(payloads)])})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var readers sync.WaitGroup
|
||||
for range 8 {
|
||||
readers.Go(func() {
|
||||
for range 1000 {
|
||||
sd := new(sampleData)
|
||||
if err := s.Read(path, sd); err != nil {
|
||||
t.Errorf("read during concurrent write failed: %v", err)
|
||||
return
|
||||
}
|
||||
if !knownPayload(sd.data, payloads) {
|
||||
t.Errorf("read observed a torn payload (len=%d)", len(sd.data))
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
readers.Wait()
|
||||
close(stop)
|
||||
writers.Wait()
|
||||
}
|
||||
|
||||
func knownPayload(got []byte, want [][]byte) bool {
|
||||
for _, w := range want {
|
||||
if bytes.Equal(got, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestNewFileStorageErrorNotExists(t *testing.T) {
|
||||
|
||||
+33
-42
@@ -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
|
||||
|
||||
+10
-56
@@ -1,9 +1,6 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"errors"
|
||||
|
||||
e "galaxy/error"
|
||||
|
||||
"galaxy/game/internal/repo/fs"
|
||||
@@ -25,66 +22,23 @@ func NewStateError(msg string) error {
|
||||
return e.NewGameStateError(msg)
|
||||
}
|
||||
|
||||
type Storage interface {
|
||||
Lock() (func() error, error)
|
||||
Exists(string) (bool, error)
|
||||
Write(string, encoding.BinaryMarshaler) error
|
||||
WriteSafe(string, encoding.BinaryMarshaler) error
|
||||
Read(string, encoding.BinaryUnmarshaler) error
|
||||
ReadSafe(string, encoding.BinaryUnmarshaler) error
|
||||
// Repo persists game state through a file-backed FS. Reads and writes are
|
||||
// atomic and lock-free: Write swaps a fully written file into place with
|
||||
// rename, so Read never observes a partial file. Serialising concurrent
|
||||
// writers to the same state file is the caller's concern (the engine does it
|
||||
// at the router, see LimitMiddleware).
|
||||
type Repo struct {
|
||||
s *fs.FS
|
||||
}
|
||||
|
||||
type repo struct {
|
||||
s Storage
|
||||
release func() error
|
||||
func NewRepo(s *fs.FS) (*Repo, error) {
|
||||
return &Repo{s: s}, nil
|
||||
}
|
||||
|
||||
func NewRepo(s Storage) (*repo, error) {
|
||||
r := &repo{
|
||||
s: s,
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func NewFileRepo(path string) (*repo, error) {
|
||||
func NewFileRepo(path string) (*Repo, error) {
|
||||
s, err := fs.NewFileStorage(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewRepo(s)
|
||||
}
|
||||
|
||||
func (r *repo) Lock() (err error) {
|
||||
if r.s == nil {
|
||||
return errors.New("storage is closed")
|
||||
}
|
||||
if r.release != nil {
|
||||
return errors.New("storage already locked")
|
||||
}
|
||||
r.release, err = r.s.Lock()
|
||||
if err != nil {
|
||||
r.close()
|
||||
return
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repo) Release() (err error) {
|
||||
if r.s == nil {
|
||||
return errors.New("storage is closed")
|
||||
}
|
||||
if r.release == nil {
|
||||
return errors.New("storage was never locked")
|
||||
}
|
||||
err = r.release()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r.close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repo) close() {
|
||||
r.release = nil
|
||||
r.s = nil
|
||||
}
|
||||
|
||||
@@ -3,13 +3,15 @@ package repo
|
||||
import (
|
||||
"galaxy/model/order"
|
||||
|
||||
"galaxy/game/internal/repo/fs"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func LoadOrder_T(s Storage, t uint, id uuid.UUID) (*order.UserGamesOrder, bool, error) {
|
||||
func LoadOrder_T(s *fs.FS, t uint, id uuid.UUID) (*order.UserGamesOrder, bool, error) {
|
||||
return loadOrder(s, t, id)
|
||||
}
|
||||
|
||||
func SaveOrder_T(s Storage, t uint, id uuid.UUID, o *order.UserGamesOrder) error {
|
||||
func SaveOrder_T(s *fs.FS, t uint, id uuid.UUID, o *order.UserGamesOrder) error {
|
||||
return saveOrder(s, t, id, o)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestSaveOrder(t *testing.T) {
|
||||
LoadOrderTest(t, s, root, turn, id, o)
|
||||
}
|
||||
|
||||
func LoadOrderTest(t *testing.T, s repo.Storage, root string, turn uint, id uuid.UUID, expected *order.UserGamesOrder) {
|
||||
func LoadOrderTest(t *testing.T, s *fs.FS, root string, turn uint, id uuid.UUID, expected *order.UserGamesOrder) {
|
||||
o, ok, err := repo.LoadOrder_T(s, turn, id)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
|
||||
@@ -1,942 +0,0 @@
|
||||
package router_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"galaxy/model/order"
|
||||
"galaxy/model/rest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCommandRaceQuit(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandRaceQuit{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeRaceQuit},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, commandNoErrorsStatus, w.Code, w.Body)
|
||||
|
||||
// error: actor not set
|
||||
payload.Actor = ""
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
|
||||
|
||||
payload.Actor = " "
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
|
||||
|
||||
// unrecognized command type
|
||||
payload.Commands = []json.RawMessage{
|
||||
encodeCommand(&order.CommandRaceQuit{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandType("-unknown-")},
|
||||
}),
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
|
||||
|
||||
// error: no commands
|
||||
payload = &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
|
||||
}
|
||||
|
||||
func TestCommandRaceVote(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
acceptor string
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request", "AnotherRace"},
|
||||
{http.StatusBadRequest, "Empty acceptor", ""},
|
||||
{http.StatusBadRequest, "Blank acceptor", " "},
|
||||
{http.StatusBadRequest, "Invalid acceptor", "Race_👽"},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandRaceVote{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeRaceVote},
|
||||
Acceptor: tc.acceptor,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandRaceRelation(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
relation string
|
||||
acceptor string
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request 1", "WAR", "Opponent"},
|
||||
{commandNoErrorsStatus, "Valid request 2", "PEACE", "Opponent"},
|
||||
{http.StatusBadRequest, "Empty relation", "", "Opponent"},
|
||||
{http.StatusBadRequest, "Blank relation", " ", "Opponent"},
|
||||
{http.StatusBadRequest, "Invalid relation", "Woina", "Opponent"},
|
||||
{http.StatusBadRequest, "Empty acceptor", "WAR", ""},
|
||||
{http.StatusBadRequest, "Blank acceptor", "WAR", " "},
|
||||
{http.StatusBadRequest, "Invalid acceptor", "PEACE", "Race_👽"},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandRaceRelation{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeRaceRelation},
|
||||
Acceptor: tc.acceptor,
|
||||
Relation: tc.relation,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandShipClassCreate(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
D float64
|
||||
A int
|
||||
W, S, C float64
|
||||
name string
|
||||
expectStatus int
|
||||
description string
|
||||
}{
|
||||
{1, 0, 0, 0, 0, "Drone", commandNoErrorsStatus, "Simple Drone"},
|
||||
{1, 1, 1, 0, 0, "Drone", commandNoErrorsStatus, "Armed Drone"},
|
||||
{1, 0, 0, 1, 0, "Drone", commandNoErrorsStatus, "Shielded Drone"},
|
||||
{1, 0, 0, 0, 1, "Drone", commandNoErrorsStatus, "Carrying Drone"},
|
||||
{1, 0, 0, 0, 0, "", http.StatusBadRequest, "Empty name"},
|
||||
{1, 0, 0, 0, 0, " ", http.StatusBadRequest, "Blank name"},
|
||||
{1, 0, 0, 0, 0, "Drone🚀", http.StatusBadRequest, "Invalid name"},
|
||||
{-0.5, 0, 0, 0, 0, "Drone", http.StatusBadRequest, "Drive less than 0"},
|
||||
{0.9, 0, 0, 0, 0, "Drone", http.StatusBadRequest, "Drive less than 1"},
|
||||
{1, 1, 0, 0, 0, "Drone", http.StatusBadRequest, "Ammo without Weapons"},
|
||||
{1, 0, 1, 0, 0, "Drone", http.StatusBadRequest, "Weapons without Ammo"},
|
||||
{1, -1, 1, 0, 0, "Drone", http.StatusBadRequest, "Ammo less than 0"},
|
||||
{1, 1, 0.9, 0, 0, "Drone", http.StatusBadRequest, "Weapons less than 1"},
|
||||
{1, 1, -0.5, 0, 0, "Drone", http.StatusBadRequest, "Weapons less than 0"},
|
||||
{1, 0, 0, -0.5, 0, "Drone", http.StatusBadRequest, "Shields less than 0"},
|
||||
{1, 0, 0, 0.9, 0, "Drone", http.StatusBadRequest, "Shields less than 1"},
|
||||
{1, 0, 0, 0, -0.5, "Drone", http.StatusBadRequest, "Cargo less than 0"},
|
||||
{1, 0, 0, 0, 0.9, "Drone", http.StatusBadRequest, "Cargo less than 1"},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandShipClassCreate{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipClassCreate},
|
||||
Name: tc.name,
|
||||
Drive: tc.D,
|
||||
Armament: tc.A,
|
||||
Weapons: tc.W,
|
||||
Shields: tc.S,
|
||||
Cargo: tc.C,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandShipClassMerge(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
name string
|
||||
target string
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request", "Drone", "Spy"},
|
||||
{http.StatusBadRequest, "Empty name", "", "Spy"},
|
||||
{http.StatusBadRequest, "Blank name", " ", "Spy"},
|
||||
{http.StatusBadRequest, "Invalid name", "Drone🚀", "Spy"},
|
||||
{http.StatusBadRequest, "Empty name", "Drone", " "},
|
||||
{http.StatusBadRequest, "Blank name", "Drone", " "},
|
||||
{http.StatusBadRequest, "Invalid name", "Drone", "Spy🚀"},
|
||||
{http.StatusBadRequest, "Equal names", "Drone", "Drone"},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandShipClassMerge{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipClassMerge},
|
||||
Name: tc.name,
|
||||
Target: tc.target,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandShipClassRemove(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
name string
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request", "Drone"},
|
||||
{http.StatusBadRequest, "Empty name", ""},
|
||||
{http.StatusBadRequest, "Blank name", " "},
|
||||
{http.StatusBadRequest, "Invalid name", "Drone🚀"},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandShipClassRemove{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipClassRemove},
|
||||
Name: tc.name,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandShipGroupBreak(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
id string
|
||||
newId string
|
||||
quantity int
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request #1", validId1, validId2, 1},
|
||||
{commandNoErrorsStatus, "Valid request #2", validId1, validId2, 0},
|
||||
{http.StatusBadRequest, "Negative quantity", validId1, validId2, -1},
|
||||
{http.StatusBadRequest, "Empty id", "", validId2, 1},
|
||||
{http.StatusBadRequest, "Invalid id", invalidId, validId2, 1},
|
||||
{http.StatusBadRequest, "Empty newId", validId1, "", 1},
|
||||
{http.StatusBadRequest, "Invalid newId", validId1, invalidId, 1},
|
||||
{http.StatusBadRequest, "Equal id and newId", validId1, validId1, 1},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandShipGroupBreak{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupBreak},
|
||||
ID: tc.id,
|
||||
NewID: tc.newId,
|
||||
Quantity: tc.quantity,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandShipGroupLoad(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
id string
|
||||
cargo string
|
||||
quantity float64
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request #1", validId1, "COL", 0},
|
||||
{commandNoErrorsStatus, "Valid request #2", validId1, "MAT", 1},
|
||||
{commandNoErrorsStatus, "Valid request #2", validId1, "CAP", 2},
|
||||
{http.StatusBadRequest, "Invalid quantity", validId1, "COL", -0.5},
|
||||
{http.StatusBadRequest, "Empty cargo", validId1, "", 1},
|
||||
{http.StatusBadRequest, "Invalid cargo", validId1, "IND", 1},
|
||||
{http.StatusBadRequest, "Empty id", "", "COL", 1},
|
||||
{http.StatusBadRequest, "Invalid id", invalidId, "COL", 1},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandShipGroupLoad{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupLoad},
|
||||
ID: tc.id,
|
||||
Cargo: tc.cargo,
|
||||
Quantity: tc.quantity,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandShipGroupUnload(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
id string
|
||||
quantity float64
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request #1", validId1, 0},
|
||||
{commandNoErrorsStatus, "Valid request #2", validId1, 1},
|
||||
{http.StatusBadRequest, "Invalid quantity", validId1, -0.5},
|
||||
{http.StatusBadRequest, "Empty id", "", 1},
|
||||
{http.StatusBadRequest, "Invalid id", invalidId, 1},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandShipGroupUnload{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupUnload},
|
||||
ID: tc.id,
|
||||
Quantity: tc.quantity,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandShipGroupSend(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
id string
|
||||
destination int
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request #1", validId1, 0},
|
||||
{commandNoErrorsStatus, "Valid request #1", validId1, 1},
|
||||
{http.StatusBadRequest, "Invalid destination", validId1, -1},
|
||||
{http.StatusBadRequest, "Empty id", "", 1},
|
||||
{http.StatusBadRequest, "Invalid id", invalidId, 1},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandShipGroupSend{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupSend},
|
||||
ID: tc.id,
|
||||
Destination: tc.destination,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandShipGroupUpgrade(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
id string
|
||||
tech string
|
||||
level float64
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request #1", validId1, "ALL", 0},
|
||||
{commandNoErrorsStatus, "Valid request #1", validId1, "DRIVE", 1.1},
|
||||
{commandNoErrorsStatus, "Valid request #1", validId1, "WEAPONS", 2.1},
|
||||
{commandNoErrorsStatus, "Valid request #1", validId1, "SHIELDS", 3.1},
|
||||
{commandNoErrorsStatus, "Valid request #1", validId1, "CARGO", 4.1},
|
||||
{http.StatusBadRequest, "Negative level", validId1, "DRIVE", -0.5},
|
||||
{http.StatusBadRequest, "Invalid level 0.5", validId1, "DRIVE", 0.5},
|
||||
{http.StatusBadRequest, "Invalid level 1.0", validId1, "DRIVE", 1.0},
|
||||
{http.StatusBadRequest, "Empty id", "", "ALL", 0},
|
||||
{http.StatusBadRequest, "Invalid id", invalidId, "ALL", 0},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandShipGroupUpgrade{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupUpgrade},
|
||||
ID: tc.id,
|
||||
Tech: tc.tech,
|
||||
Level: tc.level,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandShipGroupMerge(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request"},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandShipGroupMerge{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupMerge},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandShipGroupDismantle(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
id string
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request", validId1},
|
||||
{http.StatusBadRequest, "Empty id", ""},
|
||||
{http.StatusBadRequest, "Invalid id", invalidId},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandShipGroupDismantle{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupDismantle},
|
||||
ID: tc.id,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandShipGroupTransfer(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
id string
|
||||
acceptor string
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request", validId1, "AnotherRace"},
|
||||
{http.StatusBadRequest, "Blank id", "", "AnotherRace"},
|
||||
{http.StatusBadRequest, "Invalid id", invalidId, "AnotherRace"},
|
||||
{http.StatusBadRequest, "Empty acceptor", validId1, ""},
|
||||
{http.StatusBadRequest, "Blank acceptor", validId1, " "},
|
||||
{http.StatusBadRequest, "Invalid acceptor", validId1, "Race_👽"},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandShipGroupTransfer{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupTransfer},
|
||||
ID: tc.id,
|
||||
Acceptor: tc.acceptor,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandShipGroupJoinFleet(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
id string
|
||||
name string
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request", validId1, "AnotherRace"},
|
||||
{http.StatusBadRequest, "Blank id", "", "AnotherRace"},
|
||||
{http.StatusBadRequest, "Invalid id", invalidId, "AnotherRace"},
|
||||
{http.StatusBadRequest, "Empty name", validId1, ""},
|
||||
{http.StatusBadRequest, "Blank name", validId1, " "},
|
||||
{http.StatusBadRequest, "Invalid name", validId1, "Fleet_🚢"},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandShipGroupJoinFleet{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupJoinFleet},
|
||||
ID: tc.id,
|
||||
Name: tc.name,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandFleetMerge(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
name string
|
||||
target string
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request", "Fleet", "Bomber"},
|
||||
{http.StatusBadRequest, "Empty name", "", "Bomber"},
|
||||
{http.StatusBadRequest, "Invalid name", "Fleet_🚢", "Bomber"},
|
||||
{http.StatusBadRequest, "Empty target", "Fleet", ""},
|
||||
{http.StatusBadRequest, "Invalid target", "Fleet", "Bomber_🚢"},
|
||||
{http.StatusBadRequest, "Equal name and target", "Fleet", "Fleet"},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandFleetMerge{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeFleetMerge},
|
||||
Name: tc.name,
|
||||
Target: tc.target,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandFleetSend(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
name string
|
||||
destination int
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request #1", "Fleet", 0},
|
||||
{commandNoErrorsStatus, "Valid request #2", "Fleet", 1},
|
||||
{http.StatusBadRequest, "Invalid destination", "Fleet", -1},
|
||||
{http.StatusBadRequest, "Empty name", "", 1},
|
||||
{http.StatusBadRequest, "Invalid name", "Fleet_🚢", 1},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandFleetSend{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeFleetSend},
|
||||
Name: tc.name,
|
||||
Destination: tc.destination,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandScienceCreate(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
D, W, S, C float64
|
||||
name string
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request", 0.25, 0.25, 0.25, 0.25, "Science"},
|
||||
{http.StatusBadRequest, "Empty name", 0.25, 0.25, 0.25, 0.25, ""},
|
||||
{http.StatusBadRequest, "Invalid name", 0.25, 0.25, 0.25, 0.25, "Science🧪"},
|
||||
{http.StatusBadRequest, "Negative drive", -.5, 0.25, 0.25, 0.25, "Science"},
|
||||
{http.StatusBadRequest, "Negative weapons", 0.25, -.5, 0.25, 0.25, "Science"},
|
||||
{http.StatusBadRequest, "Negative shields", 0.25, 0.25, -.5, 0.25, "Science"},
|
||||
{http.StatusBadRequest, "Negative cargo", 0.25, 0.25, 0.25, -.5, "Science"},
|
||||
{http.StatusBadRequest, "Too big drive", 1.1, 0.25, 0.25, 0.25, "Science"},
|
||||
{http.StatusBadRequest, "Too big weapons", 0.25, 1.05, 0.25, 0.25, "Science"},
|
||||
{http.StatusBadRequest, "Too big shields", 0.25, 0.25, 1.5, 0.25, "Science"},
|
||||
{http.StatusBadRequest, "Too big cargo", 0.25, 0.25, 0.25, 1.01, "Science"},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandScienceCreate{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeScienceCreate},
|
||||
Name: tc.name,
|
||||
Drive: tc.D,
|
||||
Weapons: tc.W,
|
||||
Shields: tc.S,
|
||||
Cargo: tc.C,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandScienceRemove(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
name string
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request", "Drone"},
|
||||
{http.StatusBadRequest, "Empty name", ""},
|
||||
{http.StatusBadRequest, "Blank name", " "},
|
||||
{http.StatusBadRequest, "Invalid name", "Science🧪"},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandScienceRemove{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeScienceRemove},
|
||||
Name: tc.name,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandPlanetRename(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
number int
|
||||
name string
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request #1", 0, "HW"},
|
||||
{commandNoErrorsStatus, "Valid request #2", 1, "HW"},
|
||||
{http.StatusBadRequest, "Invalid number", -1, "HW"},
|
||||
{http.StatusBadRequest, "Empty name", 1, ""},
|
||||
{http.StatusBadRequest, "Blank name", 1, " "},
|
||||
{http.StatusBadRequest, "Invalid name", 1, "Planet🪐"},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandPlanetRename{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypePlanetRename},
|
||||
Number: tc.number,
|
||||
Name: tc.name,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandPlanetProduce(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
number int
|
||||
production, subject string
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request MAT", 0, "MAT", ""},
|
||||
{commandNoErrorsStatus, "Valid request CAP", 1, "CAP", ""},
|
||||
{commandNoErrorsStatus, "Valid request DRIVE", 2, "DRIVE", ""},
|
||||
{commandNoErrorsStatus, "Valid request WEAPONS", 3, "WEAPONS", ""},
|
||||
{commandNoErrorsStatus, "Valid request SHIELDS", 4, "SHIELDS", ""},
|
||||
{commandNoErrorsStatus, "Valid request CARGO", 5, "CARGO", ""},
|
||||
{commandNoErrorsStatus, "Valid request SCIENCE", 6, "SCIENCE", "Science"},
|
||||
{commandNoErrorsStatus, "Valid request SHIP", 7, "SHIP", "Ship"},
|
||||
{http.StatusBadRequest, "Empty production", 0, "", ""},
|
||||
{http.StatusBadRequest, "Invalid production", 0, "IND", ""},
|
||||
{http.StatusBadRequest, "Invalid planet", -1, "DRIVE", ""},
|
||||
{http.StatusBadRequest, "Empty science subject", 6, "SCIENCE", ""},
|
||||
{http.StatusBadRequest, "Invalid science subject", 6, "SCIENCE", "Science🧪"},
|
||||
{http.StatusBadRequest, "Empty ship subject", 6, "SHIP", ""},
|
||||
{http.StatusBadRequest, "Invalid ship subject", 6, "SHIP", "Ship🚀"},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandPlanetProduce{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypePlanetProduce},
|
||||
Number: tc.number,
|
||||
Production: tc.production,
|
||||
Subject: tc.subject,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandPlanetRouteSet(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
origin, destination int
|
||||
loadType string
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request MAT", 1, 0, "MAT"},
|
||||
{commandNoErrorsStatus, "Valid request CAP", 0, 1, "CAP"},
|
||||
{commandNoErrorsStatus, "Valid request COL", 1, 2, "COL"},
|
||||
{commandNoErrorsStatus, "Valid request EMP", 3, 0, "EMP"},
|
||||
{http.StatusBadRequest, "Empty loadType", 0, 1, ""},
|
||||
{http.StatusBadRequest, "Invalid loadType", 0, 1, "IND"},
|
||||
{http.StatusBadRequest, "Invalid origin", -1, 1, "MAT"},
|
||||
{http.StatusBadRequest, "Invalid destination", 1, -1, "MAT"},
|
||||
{http.StatusBadRequest, "Origin equals destination", 1, 1, "COL"},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandPlanetRouteSet{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypePlanetRouteSet},
|
||||
Origin: tc.origin,
|
||||
Destination: tc.destination,
|
||||
LoadType: tc.loadType,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandPlanetRouteRemove(t *testing.T) {
|
||||
r := setupRouter()
|
||||
|
||||
for _, tc := range []struct {
|
||||
expectStatus int
|
||||
description string
|
||||
origin int
|
||||
loadType string
|
||||
}{
|
||||
{commandNoErrorsStatus, "Valid request MAT", 0, "MAT"},
|
||||
{commandNoErrorsStatus, "Valid request CAP", 1, "CAP"},
|
||||
{commandNoErrorsStatus, "Valid request COL", 2, "COL"},
|
||||
{commandNoErrorsStatus, "Valid request EMP", 0, "EMP"},
|
||||
{http.StatusBadRequest, "Empty loadType", 1, ""},
|
||||
{http.StatusBadRequest, "Invalid loadType", 1, "IND"},
|
||||
{http.StatusBadRequest, "Invalid origin", -1, "MAT"},
|
||||
} {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandPlanetRouteRemove{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypePlanetRouteRemove},
|
||||
Origin: tc.origin,
|
||||
LoadType: tc.loadType,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleCommands(t *testing.T) {
|
||||
e := newExecutor()
|
||||
r := setupRouterExecutor(e)
|
||||
|
||||
payload := &rest.Command{
|
||||
Actor: commandDefaultActor,
|
||||
Commands: []json.RawMessage{
|
||||
encodeCommand(&order.CommandRaceRelation{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeRaceRelation},
|
||||
Acceptor: "Opponent",
|
||||
Relation: "PEACE",
|
||||
}),
|
||||
encodeCommand(&order.CommandRaceVote{
|
||||
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeRaceVote},
|
||||
Acceptor: "Opponent",
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, commandNoErrorsStatus, w.Code, w.Body)
|
||||
|
||||
assert.Equal(t, 2, e.(*dummyExecutor).CommandsExecuted)
|
||||
}
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func BanishHandler(c *gin.Context, executor CommandExecutor) {
|
||||
func BanishHandler(c *gin.Context, engine Engine) {
|
||||
var req rest.BanishRequest
|
||||
if errorResponse(c, c.ShouldBindJSON(&req)) {
|
||||
return
|
||||
}
|
||||
|
||||
if errorResponse(c, executor.BanishRace(req.RaceName)) {
|
||||
if errorResponse(c, engine.BanishRace(req.RaceName)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func BattleHandler(c *gin.Context, executor CommandExecutor) {
|
||||
func BattleHandler(c *gin.Context, engine Engine) {
|
||||
turn := c.Param("turn")
|
||||
t, err := strconv.Atoi(turn)
|
||||
if err != nil {
|
||||
@@ -25,7 +25,7 @@ func BattleHandler(c *gin.Context, executor CommandExecutor) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
r, exists, err := executor.FetchBattle(uint(t), battleID)
|
||||
r, exists, err := engine.FetchBattle(uint(t), battleID)
|
||||
if errorResponse(c, err) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"galaxy/game/internal/controller"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"galaxy/model/order"
|
||||
"galaxy/model/rest"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
)
|
||||
|
||||
func CommandHandler(c *gin.Context, executor CommandExecutor) {
|
||||
var cmd rest.Command
|
||||
if errorResponse(c, c.ShouldBindJSON(&cmd)) {
|
||||
return
|
||||
}
|
||||
|
||||
commands := make([]Command, len(cmd.Commands))
|
||||
for i := range cmd.Commands {
|
||||
command, err := parseCommand(cmd.Actor, cmd.Commands[i])
|
||||
if errorResponse(c, err) {
|
||||
return
|
||||
}
|
||||
commands[i] = command
|
||||
}
|
||||
if len(commands) == 0 {
|
||||
// `PUT /api/v1/command` is the immediate-execution path —
|
||||
// running an empty batch is a meaningless no-op, so we
|
||||
// reject it with `400` rather than rely on the validator.
|
||||
// `PUT /api/v1/order` keeps an empty list (the player
|
||||
// cleared their draft) — see `OrderHandler`.
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no commands given"})
|
||||
return
|
||||
}
|
||||
|
||||
if errorResponse(c, executor.Execute(commands...)) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusAccepted)
|
||||
}
|
||||
|
||||
func parseCommand(actor string, c json.RawMessage) (Command, error) {
|
||||
meta := new(order.CommandMeta)
|
||||
if err := json.Unmarshal(c, meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch t := meta.CmdType; t {
|
||||
case order.CommandTypeRaceQuit:
|
||||
return commandRaceQuit(actor)
|
||||
case order.CommandTypeRaceVote:
|
||||
return commandRaceVote(actor, c)
|
||||
case order.CommandTypeRaceRelation:
|
||||
return commandRaceRelation(actor, c)
|
||||
case order.CommandTypeShipClassCreate:
|
||||
return commandShipClassCreate(actor, c)
|
||||
case order.CommandTypeShipClassMerge:
|
||||
return commandShipClassMerge(actor, c)
|
||||
case order.CommandTypeShipClassRemove:
|
||||
return commandShipClassRemove(actor, c)
|
||||
case order.CommandTypeShipGroupBreak:
|
||||
return commandShipGroupBreak(actor, c)
|
||||
case order.CommandTypeShipGroupLoad:
|
||||
return commandShipGroupLoad(actor, c)
|
||||
case order.CommandTypeShipGroupUnload:
|
||||
return commandShipGroupUnload(actor, c)
|
||||
case order.CommandTypeShipGroupSend:
|
||||
return commandShipGroupSend(actor, c)
|
||||
case order.CommandTypeShipGroupUpgrade:
|
||||
return commandShipGroupUpgrade(actor, c)
|
||||
case order.CommandTypeShipGroupMerge:
|
||||
return commandShipGroupMerge(actor, c)
|
||||
case order.CommandTypeShipGroupDismantle:
|
||||
return commandShipGroupDismantle(actor, c)
|
||||
case order.CommandTypeShipGroupTransfer:
|
||||
return commandShipGroupTransfer(actor, c)
|
||||
case order.CommandTypeShipGroupJoinFleet:
|
||||
return commandShipGroupJoinFleet(actor, c)
|
||||
case order.CommandTypeFleetMerge:
|
||||
return commandFleetMerge(actor, c)
|
||||
case order.CommandTypeFleetSend:
|
||||
return commandFleetSend(actor, c)
|
||||
case order.CommandTypeScienceCreate:
|
||||
return commandScienceCreate(actor, c)
|
||||
case order.CommandTypeScienceRemove:
|
||||
return commandScienceRemove(actor, c)
|
||||
case order.CommandTypePlanetRename:
|
||||
return commandPlanetRename(actor, c)
|
||||
case order.CommandTypePlanetProduce:
|
||||
return commandPlanetProduce(actor, c)
|
||||
case order.CommandTypePlanetRouteSet:
|
||||
return commandPlanetRouteSet(actor, c)
|
||||
case order.CommandTypePlanetRouteRemove:
|
||||
return commandPlanetRouteRemove(actor, c)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown comman type: %s", t)
|
||||
}
|
||||
}
|
||||
|
||||
func commandRaceQuit(actor string) (Command, error) {
|
||||
return func(c controller.Ctrl) error { return c.RaceQuit(actor) }, nil
|
||||
}
|
||||
|
||||
func commandRaceVote(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandRaceVote)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.RaceVote(actor, v.Acceptor)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandRaceRelation(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandRaceRelation)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.RaceRelation(actor, v.Acceptor, v.Relation)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandShipClassCreate(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandShipClassCreate)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.ShipClassCreate(actor, v.Name, v.Drive, int(v.Armament), v.Weapons, v.Shields, v.Cargo)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandShipClassMerge(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandShipClassMerge)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.ShipClassMerge(actor, v.Name, v.Target)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandShipClassRemove(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandShipClassRemove)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.ShipClassRemove(actor, v.Name)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandShipGroupBreak(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandShipGroupBreak)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.ShipGroupBreak(actor, uuid.MustParse(v.ID), uuid.MustParse(v.NewID), uint(v.Quantity))
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandShipGroupLoad(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandShipGroupLoad)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.ShipGroupLoad(actor, uuid.MustParse(v.ID), v.Cargo, v.Quantity)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandShipGroupUnload(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandShipGroupUnload)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.ShipGroupUnload(actor, uuid.MustParse(v.ID), v.Quantity)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandShipGroupSend(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandShipGroupSend)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.ShipGroupSend(actor, uuid.MustParse(v.ID), uint(v.Destination))
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandShipGroupUpgrade(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandShipGroupUpgrade)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.ShipGroupUpgrade(actor, uuid.MustParse(v.ID), v.Tech, v.Level)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandShipGroupMerge(actor string, c json.RawMessage) (Command, error) {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.ShipGroupMerge(actor)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func commandShipGroupDismantle(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandShipGroupDismantle)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.ShipGroupDismantle(actor, uuid.MustParse(v.ID))
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandShipGroupTransfer(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandShipGroupTransfer)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.ShipGroupTransfer(actor, v.Acceptor, uuid.MustParse(v.ID))
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandShipGroupJoinFleet(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandShipGroupJoinFleet)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.ShipGroupJoinFleet(actor, v.Name, uuid.MustParse(v.ID))
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandFleetMerge(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandFleetMerge)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.FleetMerge(actor, v.Name, v.Target)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandFleetSend(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandFleetSend)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.FleetSend(actor, v.Name, uint(v.Destination))
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandScienceCreate(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandScienceCreate)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.ScienceCreate(actor, v.Name, v.Drive, v.Weapons, v.Shields, v.Cargo)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandScienceRemove(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandScienceRemove)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.ScienceRemove(actor, v.Name)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandPlanetRename(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandPlanetRename)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.PlanetRename(actor, v.Number, v.Name)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandPlanetProduce(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandPlanetProduce)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.PlanetProduce(actor, v.Number, v.Production, v.Subject)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandPlanetRouteSet(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandPlanetRouteSet)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.PlanetRouteSet(actor, v.LoadType, uint(v.Origin), uint(v.Destination))
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func commandPlanetRouteRemove(actor string, c json.RawMessage) (Command, error) {
|
||||
if v, err := unmarshallCommand(c, new(order.CommandPlanetRouteRemove)); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return func(c controller.Ctrl) error {
|
||||
return c.PlanetRouteRemove(actor, v.LoadType, uint(v.Origin))
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
func unmarshallCommand[T order.DecodableCommand](c json.RawMessage, v T) (T, error) {
|
||||
if err := json.Unmarshal(c, v); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err := validateCommand(v); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func validateCommand(v order.DecodableCommand) error {
|
||||
if ve, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||
if err := ve.Struct(v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
e "galaxy/error"
|
||||
|
||||
"galaxy/game/internal/controller"
|
||||
"galaxy/game/internal/model/game"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -20,25 +19,22 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CommandExecutor interface {
|
||||
GenerateGame(gameID uuid.UUID, races []string) (rest.StateResponse, error)
|
||||
GenerateTurn() (rest.StateResponse, error)
|
||||
GameState() (rest.StateResponse, error)
|
||||
BanishRace(string) error
|
||||
// Engine is the set of operations the HTTP handlers invoke on the game engine.
|
||||
// Its sole production implementation is *controller.Service; the interface
|
||||
// exists so the transport layer can be exercised against a lightweight fake
|
||||
// without standing up real storage. Methods return domain types — handlers own
|
||||
// the projection into the REST wire shapes.
|
||||
type Engine interface {
|
||||
GenerateGame(gameID uuid.UUID, races []string) (game.State, error)
|
||||
GenerateTurn() (game.State, error)
|
||||
GameState() (game.State, error)
|
||||
BanishRace(actor string) error
|
||||
LoadReport(actor string, turn uint) (*report.Report, error)
|
||||
// Execute is reserved for future use; any API request for orders should use ValidateOrder
|
||||
Execute(cmd ...Command) error
|
||||
ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error)
|
||||
FetchOrder(actor string, turn uint) (*order.UserGamesOrder, bool, error)
|
||||
FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, bool, error)
|
||||
}
|
||||
|
||||
type Command func(controller.Ctrl) error
|
||||
|
||||
type executor struct {
|
||||
cfg controller.Configurer
|
||||
}
|
||||
|
||||
// ResolveStoragePath returns the engine storage path resolved from
|
||||
// STORAGE_PATH (preferred, historical name) or GAME_STATE_PATH (canonical
|
||||
// name written by Runtime Manager). It returns an error when neither
|
||||
@@ -53,77 +49,8 @@ func ResolveStoragePath() (string, error) {
|
||||
return "", errors.New("storage path is not set: provide STORAGE_PATH or GAME_STATE_PATH")
|
||||
}
|
||||
|
||||
func initConfig() controller.Configurer {
|
||||
return func(p *controller.Param) {
|
||||
// Validated once at startup by ResolveStoragePath; the error
|
||||
// is dropped here to keep the Configurer signature simple.
|
||||
p.StoragePath, _ = ResolveStoragePath()
|
||||
}
|
||||
}
|
||||
|
||||
func NewDefaultExecutor() CommandExecutor {
|
||||
return NewDefaultConfigExecutor(initConfig())
|
||||
}
|
||||
|
||||
func NewDefaultConfigExecutor(configurer controller.Configurer) CommandExecutor {
|
||||
return &executor{cfg: configurer}
|
||||
}
|
||||
|
||||
func (e *executor) Execute(cmd ...Command) error {
|
||||
return controller.ExecuteCommand(e.cfg, func(c controller.Ctrl) error {
|
||||
for i := range cmd {
|
||||
if err := cmd[i](c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (e *executor) ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) {
|
||||
return controller.ValidateOrder(e.cfg, actor, cmd...)
|
||||
}
|
||||
|
||||
func (e *executor) FetchOrder(actor string, turn uint) (*order.UserGamesOrder, bool, error) {
|
||||
return controller.FetchOrder(e.cfg, actor, turn)
|
||||
}
|
||||
|
||||
func (e *executor) FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, bool, error) {
|
||||
return controller.FetchBattle(e.cfg, turn, ID)
|
||||
}
|
||||
|
||||
func (e *executor) GenerateGame(gameID uuid.UUID, races []string) (rest.StateResponse, error) {
|
||||
s, err := controller.GenerateGame(e.cfg, gameID, races)
|
||||
if err != nil {
|
||||
return rest.StateResponse{}, err
|
||||
}
|
||||
return stateResponse(s), nil
|
||||
}
|
||||
|
||||
func (e *executor) GenerateTurn() (rest.StateResponse, error) {
|
||||
err := controller.GenerateTurn(e.cfg)
|
||||
if err != nil {
|
||||
return rest.StateResponse{}, err
|
||||
}
|
||||
return e.GameState()
|
||||
}
|
||||
|
||||
func (e *executor) GameState() (rest.StateResponse, error) {
|
||||
s, err := controller.GameState(e.cfg)
|
||||
if err != nil {
|
||||
return rest.StateResponse{}, err
|
||||
}
|
||||
return stateResponse(s), nil
|
||||
}
|
||||
|
||||
func (e *executor) BanishRace(raceName string) error {
|
||||
return controller.BanishRace(e.cfg, raceName)
|
||||
}
|
||||
|
||||
func (e *executor) LoadReport(actor string, turn uint) (*report.Report, error) {
|
||||
return controller.LoadReport(e.cfg, actor, turn)
|
||||
}
|
||||
|
||||
// stateResponse projects the engine's domain game.State into the REST
|
||||
// StateResponse wire shape.
|
||||
func stateResponse(s game.State) rest.StateResponse {
|
||||
result := &rest.StateResponse{
|
||||
ID: s.ID,
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func InitHandler(c *gin.Context, executor CommandExecutor) {
|
||||
func InitHandler(c *gin.Context, engine Engine) {
|
||||
var init rest.InitRequest
|
||||
if errorResponse(c, c.ShouldBindJSON(&init)) {
|
||||
return
|
||||
@@ -26,7 +26,7 @@ func InitHandler(c *gin.Context, executor CommandExecutor) {
|
||||
races[i] = init.Races[i].RaceName
|
||||
}
|
||||
|
||||
s, err := executor.GenerateGame(init.GameID, races)
|
||||
s, err := engine.GenerateGame(init.GameID, races)
|
||||
if err != nil {
|
||||
if errors.Is(err, controller.ErrGameAlreadyInit) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
@@ -37,5 +37,5 @@ func InitHandler(c *gin.Context, executor CommandExecutor) {
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, s)
|
||||
c.JSON(http.StatusCreated, stateResponse(s))
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ import (
|
||||
"galaxy/game/internal/repo"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
func PutOrderHandler(c *gin.Context, executor CommandExecutor) {
|
||||
func PutOrderHandler(c *gin.Context, engine Engine) {
|
||||
var cmd rest.Command
|
||||
if errorResponse(c, c.ShouldBindJSON(&cmd)) {
|
||||
return
|
||||
@@ -30,7 +32,7 @@ func PutOrderHandler(c *gin.Context, executor CommandExecutor) {
|
||||
commands[i] = command
|
||||
}
|
||||
|
||||
result, err := executor.ValidateOrder(cmd.Actor, commands...)
|
||||
result, err := engine.ValidateOrder(cmd.Actor, commands...)
|
||||
if errorResponse(c, err) {
|
||||
return
|
||||
}
|
||||
@@ -43,7 +45,7 @@ type orderParam struct {
|
||||
Turn int `form:"turn" binding:"gte=0"`
|
||||
}
|
||||
|
||||
func GetOrderHandler(c *gin.Context, executor CommandExecutor) {
|
||||
func GetOrderHandler(c *gin.Context, engine Engine) {
|
||||
p := &orderParam{}
|
||||
// ShouldBindQuery surfaces both validator failures and strconv parse
|
||||
// errors; both are client-side faults, so 400 is the correct mapping.
|
||||
@@ -52,7 +54,7 @@ func GetOrderHandler(c *gin.Context, executor CommandExecutor) {
|
||||
return
|
||||
}
|
||||
|
||||
o, ok, err := executor.FetchOrder(p.Player, uint(p.Turn))
|
||||
o, ok, err := engine.FetchOrder(p.Player, uint(p.Turn))
|
||||
if errorResponse(c, err) {
|
||||
return
|
||||
}
|
||||
@@ -64,3 +66,15 @@ func GetOrderHandler(c *gin.Context, executor CommandExecutor) {
|
||||
|
||||
c.JSON(http.StatusOK, o)
|
||||
}
|
||||
|
||||
// validateCommand runs the gin-registered struct validators against a
|
||||
// decoded command. It is the per-command validation hook shared by the
|
||||
// order-submission path (PutOrderHandler) and repo.ParseOrder.
|
||||
func validateCommand(v order.DecodableCommand) error {
|
||||
if ve, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||
if err := ve.Struct(v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,14 +11,14 @@ type reportParam struct {
|
||||
Turn int `form:"turn" binding:"gte=0"`
|
||||
}
|
||||
|
||||
func ReportHandler(c *gin.Context, executor CommandExecutor) {
|
||||
func ReportHandler(c *gin.Context, engine Engine) {
|
||||
p := &reportParam{}
|
||||
err := c.ShouldBindQuery(p)
|
||||
if errorResponse(c, err) {
|
||||
return
|
||||
}
|
||||
|
||||
r, err := executor.LoadReport(p.Player, uint(p.Turn))
|
||||
r, err := engine.LoadReport(p.Player, uint(p.Turn))
|
||||
if errorResponse(c, err) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func StatusHandler(c *gin.Context, executor CommandExecutor) {
|
||||
state, err := executor.GameState()
|
||||
func StatusHandler(c *gin.Context, engine Engine) {
|
||||
state, err := engine.GameState()
|
||||
|
||||
if errorResponse(c, err) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, state)
|
||||
c.JSON(http.StatusOK, stateResponse(state))
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TurnHandler(c *gin.Context, executor CommandExecutor) {
|
||||
state, err := executor.GenerateTurn()
|
||||
func TurnHandler(c *gin.Context, engine Engine) {
|
||||
state, err := engine.GenerateTurn()
|
||||
|
||||
if errorResponse(c, err) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, state)
|
||||
c.JSON(http.StatusOK, stateResponse(state))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"galaxy/game/internal/controller"
|
||||
"galaxy/util"
|
||||
|
||||
"galaxy/game/internal/router"
|
||||
"galaxy/game/internal/router/handler"
|
||||
|
||||
@@ -15,9 +16,10 @@ import (
|
||||
)
|
||||
|
||||
func TestHealthzReturnsOKWithoutInit(t *testing.T) {
|
||||
r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) {
|
||||
p.StoragePath = ""
|
||||
}))
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
|
||||
r := router.SetupRouter(newService(t, root))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
|
||||
@@ -10,9 +10,7 @@ import (
|
||||
|
||||
"galaxy/util"
|
||||
|
||||
"galaxy/game/internal/controller"
|
||||
"galaxy/game/internal/router"
|
||||
"galaxy/game/internal/router/handler"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -22,7 +20,7 @@ func TestInit(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
|
||||
r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root }))
|
||||
r := router.SetupRouter(newService(t, root))
|
||||
|
||||
payload := generateInitRequest(10)
|
||||
|
||||
@@ -51,7 +49,7 @@ func TestInitRejectsNilUUID(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
|
||||
r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root }))
|
||||
r := router.SetupRouter(newService(t, root))
|
||||
|
||||
payload := generateInitRequest(10)
|
||||
payload.GameID = uuid.Nil
|
||||
@@ -67,7 +65,7 @@ func TestInitRejectsExistingGameWithDifferentID(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
|
||||
r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root }))
|
||||
r := router.SetupRouter(newService(t, root))
|
||||
|
||||
first := generateInitRequest(10)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -2,27 +2,31 @@ package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// LimitMiddleware limits number of concurrent connections using a buffered channel with limit spaces
|
||||
// LimitMiddleware caps the number of requests executing the routes it guards
|
||||
// at limit. A request blocks until a slot frees up; if the request context is
|
||||
// cancelled or expires while waiting, it answers 503 Service Unavailable.
|
||||
//
|
||||
// The semaphore is owned by the returned handler, so sharing a single instance
|
||||
// across several routes serialises those routes against each other. The engine
|
||||
// relies on this to serialise every operation that mutates the canonical game
|
||||
// state file, which must never run concurrently against one storage directory.
|
||||
func LimitMiddleware(limit int) gin.HandlerFunc {
|
||||
if limit <= 0 {
|
||||
panic("limit must be greater than 0")
|
||||
}
|
||||
semaphore := make(chan bool, limit)
|
||||
t := time.NewTimer(time.Millisecond * 100)
|
||||
semaphore := make(chan struct{}, limit)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
t.Reset(time.Millisecond * 100)
|
||||
select {
|
||||
case semaphore <- true:
|
||||
case semaphore <- struct{}{}:
|
||||
defer func() { <-semaphore }()
|
||||
c.Next()
|
||||
<-semaphore
|
||||
case <-t.C:
|
||||
c.Status(http.StatusGatewayTimeout)
|
||||
case <-c.Request.Context().Done():
|
||||
c.AbortWithStatus(http.StatusServiceUnavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,7 @@ import (
|
||||
|
||||
"galaxy/model/rest"
|
||||
|
||||
"galaxy/game/internal/controller"
|
||||
"galaxy/game/internal/router"
|
||||
"galaxy/game/internal/router/handler"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -19,7 +17,7 @@ import (
|
||||
func TestGetReport(t *testing.T) {
|
||||
root := t.ArtifactDir()
|
||||
|
||||
r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root }))
|
||||
r := router.SetupRouter(newService(t, root))
|
||||
|
||||
payload := generateInitRequest(10)
|
||||
|
||||
|
||||
@@ -18,24 +18,20 @@ const (
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
r *gin.Engine
|
||||
executor handler.CommandExecutor
|
||||
r *gin.Engine
|
||||
}
|
||||
|
||||
func (r Router) Run() error {
|
||||
return r.r.Run()
|
||||
}
|
||||
|
||||
func NewRouter() Router {
|
||||
// NewRouter builds the HTTP router around the supplied engine.
|
||||
func NewRouter(engine handler.Engine) Router {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
return NewRouterExecutor(handler.NewDefaultExecutor())
|
||||
return Router{r: setupRouter(engine)}
|
||||
}
|
||||
|
||||
func NewRouterExecutor(executor handler.CommandExecutor) Router {
|
||||
return Router{r: setupRouter(executor)}
|
||||
}
|
||||
|
||||
func setupRouter(executor handler.CommandExecutor) *gin.Engine {
|
||||
func setupRouter(engine handler.Engine) *gin.Engine {
|
||||
r := gin.New()
|
||||
|
||||
// Logger middleware will write the logs to gin.DefaultWriter even if you set with GIN_MODE=release.
|
||||
@@ -67,19 +63,22 @@ func setupRouter(executor handler.CommandExecutor) *gin.Engine {
|
||||
|
||||
groupV1 := r.Group("/api/v1")
|
||||
|
||||
// One shared limiter serialises every operation that mutates the
|
||||
// canonical game state file (state.json): there is at most one such
|
||||
// write in flight at a time. Orders write independent per-player files
|
||||
// and stay unsynchronised; reads are lock-free.
|
||||
stateMutationLimit := LimitMiddleware(1)
|
||||
|
||||
groupAdmin := groupV1.Group("/admin")
|
||||
groupAdmin.GET("/status", func(ctx *gin.Context) { handler.StatusHandler(ctx, executor) })
|
||||
groupAdmin.POST("/init", func(ctx *gin.Context) { handler.InitHandler(ctx, executor) })
|
||||
groupAdmin.PUT("/turn", func(ctx *gin.Context) { handler.TurnHandler(ctx, executor) })
|
||||
groupAdmin.POST("/race/banish", func(ctx *gin.Context) { handler.BanishHandler(ctx, executor) })
|
||||
groupAdmin.GET("/status", func(ctx *gin.Context) { handler.StatusHandler(ctx, engine) })
|
||||
groupAdmin.POST("/init", stateMutationLimit, func(ctx *gin.Context) { handler.InitHandler(ctx, engine) })
|
||||
groupAdmin.PUT("/turn", stateMutationLimit, func(ctx *gin.Context) { handler.TurnHandler(ctx, engine) })
|
||||
groupAdmin.POST("/race/banish", stateMutationLimit, func(ctx *gin.Context) { handler.BanishHandler(ctx, engine) })
|
||||
|
||||
groupV1.GET("/report", func(ctx *gin.Context) { handler.ReportHandler(ctx, executor) })
|
||||
groupV1.PUT("/order", func(ctx *gin.Context) { handler.PutOrderHandler(ctx, executor) })
|
||||
groupV1.GET("/order", func(ctx *gin.Context) { handler.GetOrderHandler(ctx, executor) })
|
||||
groupV1.GET("/battle/:turn/:uuid", func(ctx *gin.Context) { handler.BattleHandler(ctx, executor) })
|
||||
|
||||
// /command is reserved for future use; any API request for orders should use /order
|
||||
groupV1.PUT("/command", LimitMiddleware(1), func(ctx *gin.Context) { handler.CommandHandler(ctx, executor) })
|
||||
groupV1.GET("/report", func(ctx *gin.Context) { handler.ReportHandler(ctx, engine) })
|
||||
groupV1.PUT("/order", func(ctx *gin.Context) { handler.PutOrderHandler(ctx, engine) })
|
||||
groupV1.GET("/order", func(ctx *gin.Context) { handler.GetOrderHandler(ctx, engine) })
|
||||
groupV1.GET("/battle/:turn/:uuid", func(ctx *gin.Context) { handler.BattleHandler(ctx, engine) })
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetupRouter(e handler.CommandExecutor) *gin.Engine {
|
||||
func SetupRouter(e handler.Engine) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
return setupRouter(e)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ package router_test
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"galaxy/model/order"
|
||||
"galaxy/model/report"
|
||||
"galaxy/model/rest"
|
||||
|
||||
"galaxy/game/internal/controller"
|
||||
"galaxy/game/internal/model/game"
|
||||
"galaxy/game/internal/router"
|
||||
"galaxy/game/internal/router/handler"
|
||||
|
||||
@@ -19,7 +21,6 @@ var (
|
||||
commandNoErrorsStatus = http.StatusAccepted
|
||||
commandDefaultActor = "Gorlum"
|
||||
apiCommandMethod = "PUT"
|
||||
apiCommandPath = "/api/v1/command"
|
||||
apiOrderPath = "/api/v1/order"
|
||||
validId1 = id()
|
||||
validId2 = id()
|
||||
@@ -81,25 +82,20 @@ func (e *dummyExecutor) FetchBattle(turn uint, ID uuid.UUID) (*report.BattleRepo
|
||||
return e.FetchBattleResult, e.FetchBattleOK, e.FetchBattleErr
|
||||
}
|
||||
|
||||
func (e *dummyExecutor) Execute(command ...handler.Command) error {
|
||||
e.CommandsExecuted = len(command)
|
||||
return nil
|
||||
func (e *dummyExecutor) GenerateGame(gameID uuid.UUID, races []string) (game.State, error) {
|
||||
return game.State{ID: gameID}, nil
|
||||
}
|
||||
|
||||
func (e *dummyExecutor) GenerateGame(gameID uuid.UUID, races []string) (rest.StateResponse, error) {
|
||||
return rest.StateResponse{ID: gameID}, nil
|
||||
}
|
||||
|
||||
func (e *dummyExecutor) GenerateTurn() (rest.StateResponse, error) {
|
||||
return rest.StateResponse{}, nil
|
||||
func (e *dummyExecutor) GenerateTurn() (game.State, error) {
|
||||
return game.State{}, nil
|
||||
}
|
||||
|
||||
func (e *dummyExecutor) BanishRace(raceName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *dummyExecutor) GameState() (rest.StateResponse, error) {
|
||||
return rest.StateResponse{}, nil
|
||||
func (e *dummyExecutor) GameState() (game.State, error) {
|
||||
return game.State{}, nil
|
||||
}
|
||||
|
||||
func (e *dummyExecutor) LoadReport(actor string, turn uint) (*report.Report, error) {
|
||||
@@ -110,14 +106,25 @@ func setupRouter() *gin.Engine {
|
||||
return setupRouterExecutor(newExecutor())
|
||||
}
|
||||
|
||||
func setupRouterExecutor(e handler.CommandExecutor) *gin.Engine {
|
||||
func setupRouterExecutor(e handler.Engine) *gin.Engine {
|
||||
return router.SetupRouter(e)
|
||||
}
|
||||
|
||||
func newExecutor() handler.CommandExecutor {
|
||||
func newExecutor() handler.Engine {
|
||||
return &dummyExecutor{}
|
||||
}
|
||||
|
||||
// newService builds a real controller.Service backed by a storage directory,
|
||||
// for handler tests that exercise the engine end to end rather than the fake.
|
||||
func newService(t *testing.T, root string) *controller.Service {
|
||||
t.Helper()
|
||||
svc, err := controller.NewService(root)
|
||||
if err != nil {
|
||||
t.Fatalf("new service: %v", err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
func encodeCommand(cmd any) json.RawMessage {
|
||||
v, err := json.Marshal(cmd)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package router_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/model/rest"
|
||||
|
||||
@@ -38,6 +40,92 @@ func TestLimitConnections(t *testing.T) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestLimitSharedInstanceSerialisesRoutes pins the property the engine relies
|
||||
// on to serialise state mutations: a single LimitMiddleware(1) instance shared
|
||||
// across several routes admits at most one request across all of them at a
|
||||
// time. The handler tracks the high-water concurrency and asserts it never
|
||||
// exceeds one.
|
||||
func TestLimitSharedInstanceSerialisesRoutes(t *testing.T) {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
shared := router.LimitMiddleware(1)
|
||||
|
||||
var inFlight, maxSeen atomic.Int32
|
||||
handler := func(c *gin.Context) {
|
||||
n := inFlight.Add(1)
|
||||
for {
|
||||
cur := maxSeen.Load()
|
||||
if n <= cur || maxSeen.CompareAndSwap(cur, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Millisecond) // widen the overlap window
|
||||
inFlight.Add(-1)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
r.GET("/a", shared, handler)
|
||||
r.PUT("/b", shared, handler)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
for i := range 200 {
|
||||
method, path := http.MethodGet, "/a"
|
||||
if i%2 == 1 {
|
||||
method, path = http.MethodPut, "/b"
|
||||
}
|
||||
wg.Go(func() {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(method, path, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code, w.Body)
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
assert.Equal(t, int32(1), maxSeen.Load(), "a shared limiter must serialise across every route it guards")
|
||||
}
|
||||
|
||||
// TestLimitReleasesOnContextCancel verifies the wait path: while one request
|
||||
// holds the only slot, a second request blocked on the limiter answers 503
|
||||
// once its request context is cancelled, instead of hanging.
|
||||
func TestLimitReleasesOnContextCancel(t *testing.T) {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
shared := router.LimitMiddleware(1)
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
r.GET("/hold", shared, func(c *gin.Context) {
|
||||
close(entered)
|
||||
<-release
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
// First request grabs and holds the only slot.
|
||||
go func() {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/hold", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
}()
|
||||
<-entered
|
||||
|
||||
// Second request blocks on the limiter, then loses its context.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "/hold", nil)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
r.ServeHTTP(w, req)
|
||||
close(done)
|
||||
}()
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
close(release)
|
||||
}
|
||||
|
||||
func asBody(body any) *strings.Reader {
|
||||
commandJson, _ := json.Marshal(body)
|
||||
return strings.NewReader(string(commandJson))
|
||||
|
||||
@@ -10,9 +10,7 @@ import (
|
||||
|
||||
"galaxy/util"
|
||||
|
||||
"galaxy/game/internal/controller"
|
||||
"galaxy/game/internal/router"
|
||||
"galaxy/game/internal/router/handler"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -22,7 +20,7 @@ func TestGetStatus(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
|
||||
r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root }))
|
||||
r := router.SetupRouter(newService(t, root))
|
||||
|
||||
payload := generateInitRequest(10)
|
||||
|
||||
|
||||
@@ -10,9 +10,7 @@ import (
|
||||
|
||||
"galaxy/util"
|
||||
|
||||
"galaxy/game/internal/controller"
|
||||
"galaxy/game/internal/router"
|
||||
"galaxy/game/internal/router/handler"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -22,7 +20,7 @@ func TestGetTurn(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
|
||||
r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root }))
|
||||
r := router.SetupRouter(newService(t, root))
|
||||
|
||||
// create game
|
||||
|
||||
|
||||
+5
-31
@@ -7,13 +7,14 @@ info:
|
||||
|
||||
The service hosts a single game instance and exposes endpoints for game
|
||||
initialization, turn advancement, game-state queries, player reports, and
|
||||
batched player command execution.
|
||||
player order submission.
|
||||
|
||||
Transport rules:
|
||||
- request bodies are JSON
|
||||
- `PUT /api/v1/command` is rate-limited to one concurrent execution;
|
||||
requests that cannot acquire the execution slot within 100 ms receive
|
||||
`504 Gateway Timeout`
|
||||
- operations that mutate the persisted game state are serialised engine-wide
|
||||
to one at a time; such a request blocks until the in-flight mutation
|
||||
finishes and receives `503 Service Unavailable` if its context is
|
||||
cancelled while it is still waiting
|
||||
- `501 Not Implemented` is returned without a body when the game has not
|
||||
been initialized
|
||||
- request-binding validation errors return `400` with `{"error": "message"}`
|
||||
@@ -141,33 +142,6 @@ paths:
|
||||
$ref: "#/components/responses/ValidationError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/command:
|
||||
put:
|
||||
tags:
|
||||
- PlayerActions
|
||||
operationId: executeCommands
|
||||
summary: Execute a batch of player commands
|
||||
description: |
|
||||
Applies one or more game commands for the specified actor. Serialized
|
||||
to one concurrent execution; requests that cannot acquire the execution
|
||||
slot within 100 ms return `504 Gateway Timeout`. Returns `202 Accepted`
|
||||
with no body on success. Reserved for future use; player order
|
||||
submissions go through `/api/v1/order`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CommandRequest"
|
||||
responses:
|
||||
"202":
|
||||
description: All commands accepted.
|
||||
"400":
|
||||
$ref: "#/components/responses/ValidationError"
|
||||
"504":
|
||||
description: Command execution slot not acquired within 100 ms.
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/order:
|
||||
put:
|
||||
tags:
|
||||
|
||||
@@ -109,12 +109,6 @@ func TestGameOpenAPISpecFreezesEmptyResponses(t *testing.T) {
|
||||
method string
|
||||
status int
|
||||
}{
|
||||
{
|
||||
name: "command accepted",
|
||||
path: "/api/v1/command",
|
||||
method: http.MethodPut,
|
||||
status: http.StatusAccepted,
|
||||
},
|
||||
{
|
||||
name: "get order no content",
|
||||
path: "/api/v1/order",
|
||||
@@ -273,14 +267,8 @@ func TestGameOpenAPISpecFreezesCommandRequest(t *testing.T) {
|
||||
|
||||
doc := loadOpenAPISpec(t)
|
||||
|
||||
for _, path := range []string{"/api/v1/command", "/api/v1/order"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
operation := getOpenAPIOperation(t, doc, path, http.MethodPut)
|
||||
assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/CommandRequest", path+" command request schema")
|
||||
})
|
||||
}
|
||||
operation := getOpenAPIOperation(t, doc, "/api/v1/order", http.MethodPut)
|
||||
assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/CommandRequest", "/api/v1/order command request schema")
|
||||
|
||||
schema := componentSchemaRef(t, doc, "CommandRequest")
|
||||
assertRequiredFields(t, schema, "actor", "cmd")
|
||||
|
||||
Reference in New Issue
Block a user