refactor(game): lock-free storage, remove /command, flatten engine wrapper
Tests · Go / test (push) Successful in 2m27s
Tests · UI / test (push) Waiting to run
Tests · Integration / integration (pull_request) Successful in 1m45s
Tests · Go / test (pull_request) Successful in 3m13s
Tests · UI / test (pull_request) Successful in 3m8s

Three-stage refactor of the game-engine plumbing (game logic untouched):

Stage 1 — lock-free persistence + admin serialisation. Remove the file
lock from repo/fs (the .lock file, the Read/Write-vs-*Safe duality and the
dead ReadSafe polling) and replace the two-step rename with a single atomic
rename so concurrent reads are torn-free without a lock. Serialise the
state-mutating admin writers (init/turn/banish) with one shared router
LimitMiddleware, rewritten to block on the request context instead of a
racy shared 100ms timer.

Stage 2 — remove the obsolete immediate-command path end to end. Players
submit through PUT /api/v1/order; the legacy PUT /api/v1/command path is
deleted across game (route, handler, 24 command factories, Ctrl), backend
(Commands handler/route, engineclient.ExecuteCommands), gateway (dispatch +
executeUserGamesCommand + routing entry), the FlatBuffers/model contract
(UserGamesCommand[Response]) and transcoder, plus every affected
OpenAPI/README/FUNCTIONAL/ARCHITECTURE doc. The integration proxy test is
converted to the order path.

Stage 3 — flatten the REST->engine wrapper. Replace the executor adapter,
the controller package functions and RepoController with one concrete
controller.Service; drop the single-implementation Repo and Storage
interfaces (repo.Repo / fs.FS are now concrete). Handlers depend on a thin
handler.Engine seam and own the domain->REST projection; storage is
resolved once at startup instead of per request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-30 13:37:07 +02:00
parent e36d33482f
commit 601970b028
65 changed files with 681 additions and 2804 deletions
-1
View File
@@ -264,7 +264,6 @@ Endpoints used:
- `GET /api/v1/admin/status`
- `PUT /api/v1/admin/turn`
- `POST /api/v1/admin/race/banish`
- `PUT /api/v1/command`
- `PUT /api/v1/order`
- `GET /api/v1/report`
- `GET /healthz`
+3 -10
View File
@@ -23,7 +23,6 @@ const (
pathAdminStatus = "/api/v1/admin/status"
pathAdminTurn = "/api/v1/admin/turn"
pathAdminRaceBanish = "/api/v1/admin/race/banish"
pathPlayerCommand = "/api/v1/command"
pathPlayerOrder = "/api/v1/order"
pathPlayerReport = "/api/v1/report"
pathPlayerBattle = "/api/v1/battle"
@@ -183,16 +182,10 @@ func (c *Client) BanishRace(ctx context.Context, baseURL, raceName string) error
}
}
// ExecuteCommands calls `PUT /api/v1/command` with payload forwarded
// PutOrders calls `PUT /api/v1/order` with the payload forwarded
// verbatim. The engine response body is returned verbatim; on 4xx the
// body is returned alongside ErrEngineValidation so callers can
// forward the per-command error.
func (c *Client) ExecuteCommands(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
return c.forwardPlayerWrite(ctx, baseURL, pathPlayerCommand, payload, "engine command")
}
// PutOrders calls `PUT /api/v1/order` with the same forwarding
// semantics as ExecuteCommands.
// body is returned alongside ErrEngineValidation so callers can forward
// the per-command error.
func (c *Client) PutOrders(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
return c.forwardPlayerWrite(ctx, baseURL, pathPlayerOrder, payload, "engine order")
}
@@ -156,27 +156,6 @@ func TestClientBanishRace(t *testing.T) {
}
}
func TestClientCommandsForwardsBody(t *testing.T) {
want := json.RawMessage(`{"actor":"alpha","cmd":[{"@type":"raceQuit"}]}`)
gotResp := json.RawMessage(`{"applied":true}`)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathPlayerCommand || r.Method != http.MethodPut {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
_, _ = w.Write(gotResp)
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
resp, err := cli.ExecuteCommands(context.Background(), srv.URL, want)
if err != nil {
t.Fatalf("ExecuteCommands: %v", err)
}
if string(resp) != string(gotResp) {
t.Fatalf("response = %s, want %s", string(resp), string(gotResp))
}
}
func TestClientReportsForwardsQuery(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathPlayerReport {
+1 -1
View File
@@ -52,7 +52,7 @@ var (
ErrTurnAlreadyClosed = errors.New("runtime: turn already closed")
// ErrGamePaused reports that the game is not in a state that
// accepts user-games commands or orders: the runtime row
// accepts user-games orders: the runtime row
// carries `paused = true`, or the runtime status lands on any
// terminal value (`engine_unreachable`, `generation_failed`,
// `stopped`, `finished`, `removed`), or the game has not yet
+5 -5
View File
@@ -258,10 +258,10 @@ func (s *Service) ResolvePlayerMapping(ctx context.Context, gameID, userID uuid.
}
// CheckOrdersAccept verifies that the runtime is in a state that
// accepts user-games commands and orders. It is called by the user
// game-proxy handlers (`Commands`, `Orders`) before forwarding to
// engine, so the backend's turn-cutoff and pause guards run before
// network traffic leaves the host. The decision itself lives in the
// accepts user-games orders. It is called by the user game-proxy
// handler (`Orders`) before forwarding to engine, so the backend's
// turn-cutoff and pause guards run before network traffic leaves the
// host. The decision itself lives in the
// pure helper `OrdersAcceptStatus` so it can be unit-tested without
// constructing a full Service.
//
@@ -276,7 +276,7 @@ func (s *Service) CheckOrdersAccept(ctx context.Context, gameID uuid.UUID) error
}
// OrdersAcceptStatus inspects a runtime record and returns the
// matching sentinel for the user-games order/command pre-check:
// matching sentinel for the user-games order pre-check:
//
// - `runtime_status = generation_in_progress` → `ErrTurnAlreadyClosed`.
// The cron-driven `Scheduler.tick` has flipped the row before
@@ -39,55 +39,6 @@ func NewUserGamesHandlers(rt *runtime.Service, engine *engineclient.Client, logg
return &UserGamesHandlers{runtime: rt, engine: engine, logger: logger.Named("http.user.games")}
}
// Commands handles POST /api/v1/user/games/{game_id}/commands.
func (h *UserGamesHandlers) Commands() gin.HandlerFunc {
if h == nil || h.runtime == nil || h.engine == nil {
return handlers.NotImplemented("userGamesCommands")
}
return func(c *gin.Context) {
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user id missing")
return
}
body, err := io.ReadAll(c.Request.Body)
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body could not be read")
return
}
ctx := c.Request.Context()
if err := h.runtime.CheckOrdersAccept(ctx, gameID); err != nil {
respondGameProxyError(c, h.logger, "user games commands", ctx, err)
return
}
mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID)
if err != nil {
respondGameProxyError(c, h.logger, "user games commands", ctx, err)
return
}
endpoint, err := h.runtime.EngineEndpoint(ctx, gameID)
if err != nil {
respondGameProxyError(c, h.logger, "user games commands", ctx, err)
return
}
payload, err := rebindActor(body, mapping.RaceName)
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be a JSON object")
return
}
resp, err := h.engine.ExecuteCommands(ctx, endpoint, payload)
if err != nil {
respondEngineProxyError(c, h.logger, "user games commands", ctx, resp, err)
return
}
c.Data(http.StatusOK, "application/json", resp)
}
}
// Orders handles POST /api/v1/user/games/{game_id}/orders.
func (h *UserGamesHandlers) Orders() gin.HandlerFunc {
if h == nil || h.runtime == nil || h.engine == nil {
-1
View File
@@ -270,7 +270,6 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
raceNames.POST("/register", deps.UserLobbyRaceNames.Register())
userGames := group.Group("/games")
userGames.POST("/:game_id/commands", deps.UserGames.Commands())
userGames.POST("/:game_id/orders", deps.UserGames.Orders())
userGames.GET("/:game_id/orders", deps.UserGames.GetOrders())
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
-39
View File
@@ -981,37 +981,6 @@ paths:
$ref: "#/components/responses/NotImplementedError"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/user/games/{game_id}/commands:
post:
tags: [User]
operationId: userGamesCommands
summary: Forward an engine command batch
security:
- UserHeader: []
parameters:
- $ref: "#/components/parameters/XUserID"
- $ref: "#/components/parameters/GameID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/EngineCommand"
responses:
"200":
description: Engine command result passed through.
content:
application/json:
schema:
$ref: "#/components/schemas/PassthroughObject"
"400":
$ref: "#/components/responses/InvalidRequestError"
"404":
$ref: "#/components/responses/NotFoundError"
"501":
$ref: "#/components/responses/NotImplementedError"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/user/games/{game_id}/orders:
post:
tags: [User]
@@ -3538,14 +3507,6 @@ components:
properties:
name:
type: string
EngineCommand:
type: object
additionalProperties: true
description: |
Engine command request body. The schema is permissive because the
engine proxy passes the body through verbatim; the typed shape
lives in `pkg/model/rest.Command` and is enforced by
`internal/engineclient` before the engine call leaves backend.
EngineOrder:
type: object
additionalProperties: true
+3 -3
View File
@@ -375,9 +375,9 @@ Authenticated client traffic for in-game operations crosses three
serialisation boundaries: signed-gRPC FlatBuffers (client ↔ gateway),
JSON over REST (gateway ↔ backend), and JSON over REST again
(backend ↔ engine). Gateway owns the FB ↔ JSON transcoding for the
four message types `user.games.command`, `user.games.order`,
`user.games.order.get`, `user.games.report` (FB schemas in
`pkg/schema/fbs/{order,report}`, encoders in `pkg/transcoder`).
three message types `user.games.order`, `user.games.order.get`,
`user.games.report` (FB schemas in `pkg/schema/fbs/{order,report}`,
encoders in `pkg/transcoder`).
`user.games.order.get` reads back the player's stored order for a
given turn — paired with the POST `user.games.order` so the client
can hydrate its local draft after a cache loss without re-deriving
+5 -5
View File
@@ -619,10 +619,10 @@ not duplicated here.
### 6.2 Backend's role: pass-through with authorisation
The signed authenticated-edge pipeline for in-game traffic uses four
message types on the authenticated surface — `user.games.command`,
`user.games.order`, `user.games.order.get`, `user.games.report`
each with a typed FlatBuffers payload. Gateway transcodes the FB
The signed authenticated-edge pipeline for in-game traffic uses three
message types on the authenticated surface — `user.games.order`,
`user.games.order.get`, `user.games.report` each with a typed
FlatBuffers payload. Gateway transcodes the FB
request into the JSON shape backend expects, forwards over plain
REST to the corresponding `/api/v1/user/games/{game_id}/*` endpoint,
then transcodes the JSON response back into FB before signing the
@@ -671,7 +671,7 @@ in `runtime_records.turn_schedule`. The backend scheduler
`/admin/turn` call between two `runtime_status` flips:
- Before the engine call: `running → generation_in_progress`.
The user-games command/order handlers
The user-games order handlers
(`backend/internal/server/handlers_user_games.go`) consult the
per-game runtime record on every request and reject with
HTTP 409 + `code = turn_already_closed` while the runtime sits in
+3 -3
View File
@@ -637,9 +637,9 @@ Wire-формат команд, приказов и отчётов — собс
### 6.2 Роль backend: pass-through с авторизацией
Подписанный конвейер аутентифицированного edge для in-game-трафика
использует четыре message types на аутентифицированной поверхности —
`user.games.command`, `user.games.order`, `user.games.order.get`,
`user.games.report`у каждого типизированный FlatBuffers-payload.
использует три message types на аутентифицированной поверхности —
`user.games.order`, `user.games.order.get`, `user.games.report`
у каждого типизированный FlatBuffers-payload.
Gateway транскодирует FB-запрос в JSON-форму, которую ждёт backend,
форвардит её REST'ом в соответствующий
`/api/v1/user/games/{game_id}/*` endpoint, после чего транскодирует
+10 -13
View File
@@ -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
View File
@@ -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)
+118 -281
View File
@@ -16,187 +16,147 @@ import (
"galaxy/game/internal/repo"
)
type Configurer func(*Param)
type Repo interface {
// Lock must be called before any repository operations
Lock() error
// Release must be called after first and only repository operation
Release() error
// SaveTurn stores just generated new turn
SaveNewTurn(uint, *game.Game) error
// SaveState stores current game state updated between turns
SaveLastState(*game.Game) error
// LoadState retrieves game current state with required lock acquisition
LoadState() (*game.Game, error)
// LoadStateSafe retrieves game current state without preliminary locking
LoadStateSafe() (*game.Game, error)
// SaveBattle stores a new battle protocol and battle meta data for turn t
SaveBattle(uint, *report.BattleReport, *game.BattleMeta) error
// LoadBattle reads battle's protocol for turn t and battle id.
// Returns false if battle with such id was never stored at turn t
LoadBattle(t uint, id uuid.UUID) (*report.BattleReport, bool, error)
// SaveBombing stores all prodused bombings for turn t
SaveBombings(uint, []*game.Bombing) error
// SaveReport stores latest report for a race
SaveReport(uint, *report.Report) error
// LoadReport loads report for specific turn and player id
LoadReport(uint, uuid.UUID) (*report.Report, error)
// SaveOrder stores order for given turn
SaveOrder(uint, uuid.UUID, *order.UserGamesOrder) error
// LoadOrder loads order for specific turn and player id
LoadOrder(uint, uuid.UUID) (*order.UserGamesOrder, bool, error)
// Service is the engine's application service: it owns persistence and exposes
// the operations the HTTP handlers invoke. It is safe for concurrent use —
// reads are lock-free and the writers that mutate the canonical state file
// (init/turn/banish) are serialised at the router by a shared LimitMiddleware.
type Service struct {
repo *repo.Repo
}
type Ctrl interface {
ValidateOrder(actor string, cmd ...order.DecodableCommand) error
// remove below funcs if /command api will be deleted
RaceID(actor string) (uuid.UUID, error)
RaceQuit(actor string) error
RaceVote(actor, acceptor string) error
RaceRelation(actor, acceptor string, rel string) error
ShipClassCreate(actor, typeName string, drive float64, ammo int, weapons, shileds, cargo float64) error
ShipClassMerge(actor, name, targetName string) error
ShipClassRemove(actor, typeName string) error
ShipGroupLoad(actor string, groupID uuid.UUID, cargoType string, quantity float64) error
ShipGroupUnload(actor string, groupID uuid.UUID, quantity float64) error
ShipGroupSend(actor string, groupID uuid.UUID, planetNumber uint) error
ShipGroupUpgrade(actor string, groupID uuid.UUID, techInput string, limitLevel float64) error
ShipGroupBreak(actor string, groupID, newID uuid.UUID, quantity uint) error
ShipGroupMerge(actor string) error
ShipGroupDismantle(actor string, groupID uuid.UUID) error
ShipGroupTransfer(actor, acceptor string, groupID uuid.UUID) error
ShipGroupJoinFleet(actor, fleetName string, groupID uuid.UUID) error
FleetMerge(actor, fleetSourceName, fleetTargetName string) error
FleetSend(actor, fleetName string, planetNumber uint) error
ScienceCreate(actor, typeName string, drive, weapons, shields, cargo float64) error
ScienceRemove(actor, typeName string) error
PlanetRename(actor string, planetNumber int, typeName string) error
PlanetProduce(actor string, planetNumber int, prodType, subject string) error
PlanetRouteSet(actor, loadType string, origin, destination uint) error
PlanetRouteRemove(actor, loadType string, origin uint) error
// NewService opens the file-backed storage at storagePath and returns a ready
// Service. The directory must already exist and be writable.
func NewService(storagePath string) (*Service, error) {
r, err := repo.NewFileRepo(storagePath)
if err != nil {
return nil, err
}
return &Service{repo: r}, nil
}
// GenerateGame initialises a fresh game in storage under the supplied
// canonical gameID. The orchestrator must allocate gameID before the
// engine container is started and pass it here as the request body of
// POST /api/v1/admin/init. A zero UUID is rejected with
// ErrGameInitNilUUID; an attempt to init on top of an existing
// state.json is rejected with ErrGameAlreadyInit.
func GenerateGame(configure func(*Param), gameID uuid.UUID, races []string) (s game.State, err error) {
// canonical gameID. The orchestrator must allocate gameID before the engine
// container is started and pass it here as the request body of
// POST /api/v1/admin/init. A zero UUID is rejected with ErrGameInitNilUUID; an
// attempt to init on top of an existing state.json is rejected with
// ErrGameAlreadyInit.
func (s *Service) GenerateGame(gameID uuid.UUID, races []string) (game.State, error) {
if gameID == uuid.Nil {
return game.State{}, ErrGameInitNilUUID
}
ec, err := NewRepoController(configure)
if err != nil {
if existing, loadErr := s.repo.LoadState(); loadErr == nil {
return game.State{}, fmt.Errorf("%w: stored gameId=%s, requested=%s", ErrGameAlreadyInit, existing.ID, gameID)
} else if !isGameNotInitialized(loadErr) {
return game.State{}, fmt.Errorf("check existing state: %w", loadErr)
}
if _, err := NewGame(s.repo, gameID, races); err != nil {
return game.State{}, err
}
if err = ec.Repo.Lock(); err != nil {
return
}
defer func() {
err = errors.Join(err, ec.Repo.Release())
if err == nil {
s, err = GameState(configure)
}
}()
if existing, loadErr := ec.Repo.LoadState(); loadErr == nil {
err = fmt.Errorf("%w: stored gameId=%s, requested=%s", ErrGameAlreadyInit, existing.ID, gameID)
return
} else if !isGameNotInitialized(loadErr) {
err = fmt.Errorf("check existing state: %w", loadErr)
return
}
return s.GameState()
}
_, err = NewGame(ec.Repo, gameID, races)
return
// GenerateTurn advances the game by one turn (applying every stored order) and
// returns the resulting game state.
func (s *Service) GenerateTurn() (game.State, error) {
if err := s.execute(func(_ uint, c *Controller) error { return c.MakeTurn() }); err != nil {
return game.State{}, err
}
return s.GameState()
}
// isGameNotInitialized reports whether err is the engine's canonical
// "no state.json on disk" signal returned by Repo.LoadState on a
// fresh storage directory.
// "no state.json on disk" signal returned by Repo.LoadState on a fresh
// storage directory.
func isGameNotInitialized(err error) bool {
var ge *e.GenericError
return errors.As(err, &ge) && ge.Code == e.ErrGameNotInitialized
}
func GenerateTurn(configure func(*Param)) (err error) {
ec, err := NewRepoController(configure)
if err != nil {
return err
}
err = ec.executeLocked(func(c *Controller) error { return c.MakeTurn() })
// LoadReport returns the stored turn report for actor at the given turn.
func (s *Service) LoadReport(actor string, turn uint) (r *report.Report, err error) {
execErr := s.execute(func(_ uint, c *Controller) (exErr error) {
id, exErr := c.RaceID(actor)
if exErr == nil {
r, exErr = s.repo.LoadReport(turn, id)
}
return
})
err = errors.Join(err, execErr)
return
}
func LoadReport(configure func(*Param), actor string, turn uint) (*report.Report, error) {
ec, err := NewRepoController(configure)
// ValidateOrder validates cmd against a transient view of the current state,
// records the per-command outcome on each command's meta, and stores the
// resulting order for the current turn. Game-state rejections are reported per
// command, not as a returned error.
func (s *Service) ValidateOrder(actor string, cmd ...order.DecodableCommand) (o *order.UserGamesOrder, err error) {
err = s.execute(func(t uint, c *Controller) error {
id, err := c.RaceID(actor)
if err != nil {
return err
}
if err := c.ValidateOrder(actor, cmd...); err != nil {
return err
}
o = &order.UserGamesOrder{
GameID: c.Cache.g.ID,
UpdatedAt: time.Now().UTC().UnixMilli(),
Commands: make([]order.DecodableCommand, len(cmd)),
}
copy(o.Commands, cmd)
return s.repo.SaveOrder(t, id, o)
})
if err != nil {
return nil, err
}
return ec.loadReport(actor, turn)
return
}
func ExecuteCommand(configure func(*Param), consumer func(c Ctrl) error) (err error) {
ec, err := NewRepoController(configure)
if err != nil {
// FetchOrder returns the order actor stored for the given turn. ok is false
// when no order was ever stored.
func (s *Service) FetchOrder(actor string, turn uint) (o *order.UserGamesOrder, ok bool, err error) {
err = s.execute(func(_ uint, c *Controller) error {
id, err := c.RaceID(actor)
if err != nil {
return err
}
o, ok, err = s.repo.LoadOrder(turn, id)
return err
})
if err != nil {
return
}
return ec.executeCommand(func(c *Controller) error { return consumer(c) })
return
}
func ValidateOrder(configure func(*Param), actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) {
ec, err := NewRepoController(configure)
if err != nil {
return nil, err
}
return ec.validateOrder(actor, cmd...)
}
func FetchOrder(configure func(*Param), actor string, turn uint) (order *order.UserGamesOrder, ok bool, err error) {
ec, err := NewRepoController(configure)
if err != nil {
return nil, false, err
}
return ec.fetchOrder(actor, turn)
}
func FetchBattle(configure func(*Param), turn uint, ID uuid.UUID) (b *report.BattleReport, exists bool, err error) {
ec, err := NewRepoController(configure)
if err != nil {
return nil, false, err
}
return ec.fetchBattle(turn, ID)
}
func BanishRace(configure func(*Param), actor string) error {
ec, err := NewRepoController(configure)
if err != nil {
// FetchBattle returns the battle report stored at turn under ID. exists is
// false when no such battle was recorded.
func (s *Service) FetchBattle(turn uint, ID uuid.UUID) (b *report.BattleReport, exists bool, err error) {
err = s.execute(func(_ uint, c *Controller) error {
b, exists, err = s.repo.LoadBattle(turn, ID)
return err
}
return ec.banishRace(actor)
})
return
}
func GameState(configure func(*Param)) (s game.State, err error) {
ec, err := NewRepoController(configure)
if err != nil {
return game.State{}, err
}
// BanishRace deactivates actor's race after a permanent platform removal and
// persists the updated state.
func (s *Service) BanishRace(actor string) error {
return s.execute(func(_ uint, c *Controller) error {
if err := c.RaceBanish(actor); err != nil {
return err
}
return c.saveState()
})
}
g, err := ec.Repo.LoadStateSafe()
// GameState loads the current state and projects it into the transport-facing
// game.State summary (player roster with planet counts and population).
func (s *Service) GameState() (game.State, error) {
g, err := s.repo.LoadState()
if err != nil {
return game.State{}, err
}
@@ -234,149 +194,26 @@ func GameState(configure func(*Param)) (s game.State, err error) {
return *result, nil
}
type RepoController struct {
Repo Repo
}
func NewRepoController(config Configurer) (*RepoController, error) {
c := &Param{
StoragePath: ".",
}
if config != nil {
config(c)
}
r, err := repo.NewFileRepo(c.StoragePath)
if err != nil {
return nil, err
}
return &RepoController{
Repo: r,
}, nil
}
func (ec *RepoController) NewGameController(g *game.Game) *Controller {
return &Controller{
RepoController: ec,
Cache: NewCache(g),
}
}
func (ec *RepoController) validateOrder(actor string, cmd ...order.DecodableCommand) (o *order.UserGamesOrder, err error) {
err = ec.executeSafe(func(t uint, c *Controller) error {
id, err := c.RaceID(actor)
if err != nil {
return err
}
err = c.ValidateOrder(actor, cmd...)
if err != nil {
return err
}
o = &order.UserGamesOrder{
GameID: c.Cache.g.ID,
UpdatedAt: time.Now().UTC().UnixMilli(),
Commands: make([]order.DecodableCommand, len(cmd)),
}
copy(o.Commands, cmd)
return ec.Repo.SaveOrder(t, id, o)
})
if err != nil {
return nil, err
}
return
}
func (ec *RepoController) fetchOrder(actor string, turn uint) (order *order.UserGamesOrder, ok bool, err error) {
err = ec.executeSafe(func(t uint, c *Controller) error {
id, err := c.RaceID(actor)
if err != nil {
return err
}
order, ok, err = ec.Repo.LoadOrder(turn, id)
return err
})
if err != nil {
return
}
return
}
func (ec *RepoController) fetchBattle(turn uint, ID uuid.UUID) (order *report.BattleReport, exists bool, err error) {
err = ec.executeSafe(func(t uint, c *Controller) error {
order, exists, err = ec.Repo.LoadBattle(turn, ID)
return err
})
return
}
func (ec *RepoController) loadReport(actor string, turn uint) (r *report.Report, err error) {
execErr := ec.executeSafe(func(t uint, c *Controller) (exErr error) {
id, exErr := c.RaceID(actor)
if exErr == nil {
r, exErr = ec.Repo.LoadReport(turn, id)
}
return
})
err = errors.Join(err, execErr)
return
}
func (ec *RepoController) executeCommand(consumer func(*Controller) error) (err error) {
return ec.executeLocked(func(c *Controller) error {
err = consumer(c)
if err == nil {
c.Cache.StageCommand()
err = c.saveState()
}
return err
})
}
func (ec *RepoController) banishRace(actor string) (err error) {
return ec.executeLocked(func(c *Controller) error {
err = c.RaceBanish(actor)
if err != nil {
return err
}
return c.saveState()
})
}
func (ec *RepoController) executeSafe(consumer func(uint, *Controller) error) (err error) {
g, err := ec.Repo.LoadStateSafe()
// execute loads the current game state, wraps it in a Controller and runs
// consumer against it. Reads and writes are lock-free; concurrent writers to
// the state file (init/turn/banish) are serialised at the router by a shared
// LimitMiddleware, so this helper holds no lock of its own.
func (s *Service) execute(consumer func(uint, *Controller) error) error {
g, err := s.repo.LoadState()
if err != nil {
return err
}
err = consumer(g.Turn, ec.NewGameController(g))
return
}
func (ec *RepoController) executeLocked(consumer func(*Controller) error) (err error) {
if err := ec.Repo.Lock(); err != nil {
return err
}
defer func() {
err = errors.Join(err, ec.Repo.Release())
}()
g, err := ec.Repo.LoadState()
if err != nil {
return err
}
err = consumer(ec.NewGameController(g))
return
}
func (c *Controller) saveState() error {
return c.Repo.SaveLastState(c.Cache.g)
return consumer(g.Turn, &Controller{repo: s.repo, Cache: NewCache(g)})
}
// Controller is the per-turn execution context: a loaded game state (Cache)
// plus the repo it persists through. It carries the engine's game-logic
// methods (in command.go, order.go, generate_turn.go, …).
type Controller struct {
*RepoController
repo *repo.Repo
Cache *Cache
}
type Param struct {
StoragePath string
func (c *Controller) saveState() error {
return c.repo.SaveLastState(c.Cache.g)
}
+1 -2
View File
@@ -131,8 +131,7 @@ func newGame() *game.Game {
func newCache() (*controller.Cache, *controller.Controller) {
ctl := &controller.Controller{
RepoController: nil,
Cache: controller.NewCache(newGame()),
Cache: controller.NewCache(newGame()),
}
assertNoError(ctl.Cache.ShipClassCreate(Race_0_idx, Race_0_Gunship, 60, 3, 30, 100, 0))
assertNoError(ctl.Cache.ShipClassCreate(Race_0_idx, Race_0_Freighter, 8, 0, 0, 2, 10))
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"galaxy/game/internal/generator"
"galaxy/game/internal/model/game"
"galaxy/game/internal/repo"
"github.com/google/uuid"
)
@@ -14,7 +15,7 @@ import (
// NewGame initialises a fresh game in storage under the supplied
// gameID. The caller is expected to have validated gameID against
// uuid.Nil and to have ruled out collisions with existing state.
func NewGame(r Repo, gameID uuid.UUID, races []string) (uuid.UUID, error) {
func NewGame(r *repo.Repo, gameID uuid.UUID, races []string) (uuid.UUID, error) {
m, err := generator.Generate(func(ms *generator.MapSetting) {
ms.Players = uint32(len(races))
})
@@ -24,7 +25,7 @@ func NewGame(r Repo, gameID uuid.UUID, races []string) (uuid.UUID, error) {
return newGameOnMap(r, gameID, races, m)
}
func newGameOnMap(r Repo, gameID uuid.UUID, races []string, m generator.Map) (uuid.UUID, error) {
func newGameOnMap(r *repo.Repo, gameID uuid.UUID, races []string, m generator.Map) (uuid.UUID, error) {
g, err := buildGameOnMap(gameID, races, m)
if err != nil {
return uuid.Nil, err
@@ -29,7 +29,6 @@ func TestNewGame(t *testing.T) {
races[i] = fmt.Sprintf("race_%02d", i)
}
requestedID := uuid.New()
assert.NoError(t, r.Lock())
gameID, err := controller.NewGame(r, requestedID, races)
assert.NoError(t, err)
assert.Equal(t, requestedID, gameID, "NewGame must echo the supplied gameID")
@@ -67,8 +66,6 @@ func TestNewGame(t *testing.T) {
numShuffled = numShuffled || p.Number != uint(i)
}
assert.True(t, numShuffled)
assert.NoError(t, r.Release())
}
func TestGenerateGameRejectsExistingState(t *testing.T) {
@@ -79,13 +76,14 @@ func TestGenerateGameRejectsExistingState(t *testing.T) {
for i := range races {
races[i] = fmt.Sprintf("race_%02d", i)
}
configure := func(p *controller.Param) { p.StoragePath = root }
firstID := uuid.New()
_, err := controller.GenerateGame(configure, firstID, races)
svc, err := controller.NewService(root)
assert.NoError(t, err)
_, err = controller.GenerateGame(configure, uuid.New(), races)
firstID := uuid.New()
_, err = svc.GenerateGame(firstID, races)
assert.NoError(t, err)
_, err = svc.GenerateGame(uuid.New(), races)
assert.ErrorIs(t, err, controller.ErrGameAlreadyInit)
}
@@ -98,6 +96,8 @@ func TestGenerateGameRejectsNilUUID(t *testing.T) {
races[i] = fmt.Sprintf("race_%02d", i)
}
_, err := controller.GenerateGame(func(p *controller.Param) { p.StoragePath = root }, uuid.Nil, races)
svc, err := controller.NewService(root)
assert.NoError(t, err)
_, err = svc.GenerateGame(uuid.Nil, races)
assert.ErrorIs(t, err, controller.ErrGameInitNilUUID)
}
+4 -4
View File
@@ -67,7 +67,7 @@ func (c *Controller) MakeTurn() error {
// Store bombings
bombingReport := make([]*report.Bombing, len(bombings))
if len(bombings) > 0 {
if err := c.Repo.SaveBombings(c.Cache.g.Turn, bombings); err != nil {
if err := c.repo.SaveBombings(c.Cache.g.Turn, bombings); err != nil {
return err
}
for i := range bombings {
@@ -107,7 +107,7 @@ func (c *Controller) MakeTurn() error {
}
report := TransformBattle(c.Cache, b)
if err := c.Repo.SaveBattle(c.Cache.g.Turn, report, &battleMeta[i]); err != nil {
if err := c.repo.SaveBattle(c.Cache.g.Turn, report, &battleMeta[i]); err != nil {
return err
}
battleReport[i] = report
@@ -118,12 +118,12 @@ func (c *Controller) MakeTurn() error {
c.Cache.DeleteKilledShipGroups()
// Store game state for the new turn and 'current' state as well
if err := c.Repo.SaveNewTurn(c.Cache.g.Turn, c.Cache.g); err != nil {
if err := c.repo.SaveNewTurn(c.Cache.g.Turn, c.Cache.g); err != nil {
return err
}
for rep := range c.Cache.Report(c.Cache.g.Turn, battleReport, bombingReport) {
if err := c.Repo.SaveReport(c.Cache.g.Turn, rep); err != nil {
if err := c.repo.SaveReport(c.Cache.g.Turn, rep); err != nil {
return err
}
}
+2 -2
View File
@@ -127,7 +127,7 @@ func (c *Controller) applyOrders(t uint) error {
cmdApplied := make(map[string]bool)
for ri := range c.Cache.listRaceActingIdx() {
o, ok, err := c.Repo.LoadOrder(t, c.Cache.g.Race[ri].ID)
o, ok, err := c.repo.LoadOrder(t, c.Cache.g.Race[ri].ID)
if err != nil {
return err
}
@@ -166,7 +166,7 @@ func (c *Controller) applyOrders(t uint) error {
_ = c.applyCommand(commandRace[cmd.CommandID()], cmd)
}
// re-save order to persist possible changed commands result outcome
if err := c.Repo.SaveOrder(t, c.Cache.g.Race[ri].ID, &order.UserGamesOrder{
if err := c.repo.SaveOrder(t, c.Cache.g.Race[ri].ID, &order.UserGamesOrder{
GameID: c.Cache.g.ID,
UpdatedAt: raceOrderUpdated[ri],
Commands: raceOrder[ri],
+76
View File
@@ -0,0 +1,76 @@
package controller_test
import (
"fmt"
"testing"
"galaxy/model/order"
"galaxy/util"
"galaxy/game/internal/controller"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestServiceOrderStoredThenAppliedAtTurn is the end-to-end regression for the
// order lifecycle against a real Service backed by a temporary storage
// directory: an order submitted through ValidateOrder is persisted (FetchOrder
// returns it before the turn), applied when the turn is produced (GenerateTurn
// advances the turn), and its per-command verdict survives turn production
// (FetchOrder still returns it with cmdApplied set). It guards the wiring the
// Stage 3 collapse reworked — Service methods threading the concrete repo
// through validate → store → produce → read-back.
func TestServiceOrderStoredThenAppliedAtTurn(t *testing.T) {
root, cleanup := util.CreateWorkDir(t)
defer cleanup()
svc, err := controller.NewService(root)
require.NoError(t, err)
races := make([]string, 10)
for i := range races {
races[i] = fmt.Sprintf("race_%02d", i)
}
if _, err := svc.GenerateGame(uuid.New(), races); err != nil {
t.Fatalf("init game: %v", err)
}
vote := &order.CommandRaceVote{
CommandMeta: order.CommandMeta{CmdID: uuid.NewString(), CmdType: order.CommandTypeRaceVote},
Acceptor: races[1],
}
stored, err := svc.ValidateOrder(races[0], vote)
require.NoError(t, err)
require.Len(t, stored.Commands, 1)
// The order is persisted and retrievable for the current turn (0)
// before the turn is produced.
got, ok, err := svc.FetchOrder(races[0], 0)
require.NoError(t, err)
require.True(t, ok, "submitted order must be retrievable before the turn")
require.Len(t, got.Commands, 1)
// Producing the turn applies stored orders and advances the turn.
state, err := svc.GenerateTurn()
require.NoError(t, err)
assert.Equal(t, uint(1), state.Turn, "turn must advance after production")
// The turn-0 order still carries its per-command verdict, recorded by
// turn production.
applied, ok, err := svc.FetchOrder(races[0], 0)
require.NoError(t, err)
require.True(t, ok)
require.Len(t, applied.Commands, 1)
v, ok := order.AsCommand[*order.CommandRaceVote](applied.Commands[0])
require.True(t, ok, "stored command must round-trip to its concrete type")
require.NotNil(t, v.CmdApplied, "turn production must record cmdApplied")
assert.True(t, *v.CmdApplied, "a valid vote must apply at turn production")
// Orders are per-turn: the freshly produced turn carries no order yet.
_, ok, err = svc.FetchOrder(races[0], 1)
require.NoError(t, err)
assert.False(t, ok, "the freshly produced turn carries no stored order")
}
+41 -144
View File
@@ -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)
}
+80 -43
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+4 -2
View File
@@ -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)
}
+1 -1
View File
@@ -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)
-942
View File
@@ -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)
}
+2 -2
View File
@@ -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
}
+2 -2
View File
@@ -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
}
-347
View File
@@ -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 -85
View File
@@ -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,
+3 -3
View File
@@ -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))
}
+18 -4
View File
@@ -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
}
+2 -2
View File
@@ -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
}
+3 -3
View File
@@ -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))
}
+3 -3
View File
@@ -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 -4
View File
@@ -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)
+3 -5
View File
@@ -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()
+13 -9
View File
@@ -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)
}
}
}
+1 -3
View File
@@ -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)
+19 -20
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
+22 -15
View File
@@ -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 {
+88
View File
@@ -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))
+1 -3
View File
@@ -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)
+1 -3
View File
@@ -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
View File
@@ -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:
+2 -14
View File
@@ -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")
-1
View File
@@ -391,7 +391,6 @@ The current direct `Gateway -> User` self-service boundary uses that pattern:
- `user.sessions.list`
- `user.sessions.revoke`
- `user.sessions.revoke_all`
- `user.games.command`
- `user.games.order`
- `user.games.report`
- `lobby.my.games.list`
+1 -1
View File
@@ -34,7 +34,7 @@ func TestParityWithUICoreCanonicalBytes(t *testing.T) {
gatewayFields := authn.RequestSigningFields{
ProtocolVersion: "v1",
DeviceSessionID: "device-session-parity",
MessageType: "user.games.command",
MessageType: "user.games.order",
TimestampMS: 1_700_000_000_000,
RequestID: "request-parity",
PayloadHash: sha256Of([]byte("payload")),
@@ -19,11 +19,11 @@ import (
)
// ExecuteGameCommand routes one authenticated `user.games.*` command
// into backend's `/api/v1/user/games/{game_id}/*` endpoints. Command
// and order requests transcode the typed FB-payload into the JSON
// shape the engine expects (a `gamerest.Command` with empty actor —
// backend rebinds the actor from the runtime player mapping). Report
// requests transcode the response Report from JSON back to FB.
// into backend's `/api/v1/user/games/{game_id}/*` endpoints. Order
// requests transcode the typed FB-payload into the JSON shape the
// engine expects (a `gamerest.Command` with empty actor — backend
// rebinds the actor from the runtime player mapping). Report requests
// transcode the response Report from JSON back to FB.
func (c *RESTClient) ExecuteGameCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
if c == nil || c.httpClient == nil {
return downstream.UnaryResult{}, errors.New("backendclient: execute game command: nil client")
@@ -39,12 +39,6 @@ func (c *RESTClient) ExecuteGameCommand(ctx context.Context, command downstream.
}
switch command.MessageType {
case ordermodel.MessageTypeUserGamesCommand:
req, err := transcoder.PayloadToUserGamesCommand(command.PayloadBytes)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute game command %q: %w", command.MessageType, err)
}
return c.executeUserGamesCommand(ctx, command.UserID, req)
case ordermodel.MessageTypeUserGamesOrder:
req, err := transcoder.PayloadToUserGamesOrder(command.PayloadBytes)
if err != nil {
@@ -74,22 +68,6 @@ func (c *RESTClient) ExecuteGameCommand(ctx context.Context, command downstream.
}
}
func (c *RESTClient) executeUserGamesCommand(ctx context.Context, userID string, req *ordermodel.UserGamesCommand) (downstream.UnaryResult, error) {
if req.GameID == uuid.Nil {
return downstream.UnaryResult{}, errors.New("execute user.games.command: game_id must not be empty")
}
body, err := buildEngineCommandBody(req.Commands)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.command: %w", err)
}
target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(req.GameID.String()) + "/commands"
respBody, status, err := c.do(ctx, http.MethodPost, target, userID, body)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.command: %w", err)
}
return projectUserGamesAckResponse(status, respBody, transcoder.EmptyUserGamesCommandResponsePayload)
}
func (c *RESTClient) executeUserGamesOrder(ctx context.Context, userID string, req *ordermodel.UserGamesOrder) (downstream.UnaryResult, error) {
if req.GameID == uuid.Nil {
return downstream.UnaryResult{}, errors.New("execute user.games.order: game_id must not be empty")
@@ -169,26 +147,6 @@ func buildEngineCommandBody(commands []ordermodel.DecodableCommand) (gamerest.Co
return gamerest.Command{Actor: "", Commands: raw}, nil
}
// projectUserGamesAckResponse turns a backend response for the
// `user.games.command` route into a UnaryResult. Engine returns 204
// on success, so any 2xx status is treated as ok and answered with
// the empty typed FB envelope produced by ackBuilder.
func projectUserGamesAckResponse(statusCode int, payload []byte, ackBuilder func() []byte) (downstream.UnaryResult, error) {
switch {
case statusCode >= 200 && statusCode < 300:
return downstream.UnaryResult{
ResultCode: userCommandResultCodeOK,
PayloadBytes: ackBuilder(),
}, nil
case statusCode == http.StatusServiceUnavailable:
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
case statusCode >= 400 && statusCode <= 599:
return projectUserBackendError(statusCode, payload)
default:
return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode)
}
}
// projectUserGamesOrderResponse decodes the engine's `PUT /api/v1/order`
// JSON body (forwarded by backend) and re-encodes it as a FlatBuffers
// `UserGamesOrderResponse` envelope. The body carries per-command
+13 -14
View File
@@ -39,15 +39,15 @@ func LobbyRoutes(client *RESTClient) map[string]downstream.Client {
target = lobbyCommandClient{rest: client}
}
return map[string]downstream.Client{
lobbymodel.MessageTypeMyGamesList: target,
lobbymodel.MessageTypePublicGamesList: target,
lobbymodel.MessageTypeMyApplicationsList: target,
lobbymodel.MessageTypeMyInvitesList: target,
lobbymodel.MessageTypeOpenEnrollment: target,
lobbymodel.MessageTypeGameCreate: target,
lobbymodel.MessageTypeApplicationSubmit: target,
lobbymodel.MessageTypeInviteRedeem: target,
lobbymodel.MessageTypeInviteDecline: target,
lobbymodel.MessageTypeMyGamesList: target,
lobbymodel.MessageTypePublicGamesList: target,
lobbymodel.MessageTypeMyApplicationsList: target,
lobbymodel.MessageTypeMyInvitesList: target,
lobbymodel.MessageTypeOpenEnrollment: target,
lobbymodel.MessageTypeGameCreate: target,
lobbymodel.MessageTypeApplicationSubmit: target,
lobbymodel.MessageTypeInviteRedeem: target,
lobbymodel.MessageTypeInviteDecline: target,
}
}
@@ -61,11 +61,10 @@ func GameRoutes(client *RESTClient) map[string]downstream.Client {
target = gameCommandClient{rest: client}
}
return map[string]downstream.Client{
ordermodel.MessageTypeUserGamesCommand: target,
ordermodel.MessageTypeUserGamesOrder: target,
ordermodel.MessageTypeUserGamesOrderGet: target,
reportmodel.MessageTypeUserGamesReport: target,
reportmodel.MessageTypeUserGamesBattle: target,
ordermodel.MessageTypeUserGamesOrder: target,
ordermodel.MessageTypeUserGamesOrderGet: target,
reportmodel.MessageTypeUserGamesReport: target,
reportmodel.MessageTypeUserGamesBattle: target,
}
}
@@ -56,7 +56,6 @@ func TestRoutesCoverAllAuthenticatedMessageTypes(t *testing.T) {
},
"game": {
expected: []string{
ordermodel.MessageTypeUserGamesCommand,
ordermodel.MessageTypeUserGamesOrder,
ordermodel.MessageTypeUserGamesOrderGet,
reportmodel.MessageTypeUserGamesReport,
@@ -10,12 +10,11 @@ import (
"galaxy/integration/testenv"
)
// TestEngineCommandProxy spins up a running game (10 enrolled
// pilots so engine init succeeds) and verifies that backend's
// user-side `/api/v1/user/games/{id}/commands` proxy reaches the
// engine and returns its passthrough body without an internal-error
// response.
func TestEngineCommandProxy(t *testing.T) {
// TestEngineOrderProxy spins up a running game (10 enrolled pilots so
// engine init succeeds) and verifies that backend's user-side
// `/api/v1/user/games/{id}/orders` proxy reaches the engine and returns
// its passthrough body without an internal-error response.
func TestEngineOrderProxy(t *testing.T) {
plat := testenv.Bootstrap(t, testenv.BootstrapOptions{})
testenv.EnsureGameImage(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
@@ -28,7 +27,7 @@ func TestEngineCommandProxy(t *testing.T) {
t.Fatalf("seed engine_version: err=%v resp=%v", err, resp)
}
owner := testenv.RegisterSession(t, plat, "owner+cmd@example.com")
owner := testenv.RegisterSession(t, plat, "owner+order@example.com")
testenv.PromoteToPaid(t, ctx, admin, plat, owner)
ownerID, err := owner.LookupUserID(ctx, plat)
if err != nil {
@@ -37,7 +36,7 @@ func TestEngineCommandProxy(t *testing.T) {
ownerHTTP := testenv.NewBackendUserClient(plat.Backend.HTTPURL, ownerID)
gameBody := map[string]any{
"game_name": "Engine Command Proxy",
"game_name": "Engine Order Proxy",
"visibility": "private",
"min_players": 10,
"max_players": 10,
@@ -59,7 +58,7 @@ func TestEngineCommandProxy(t *testing.T) {
if _, resp, err := ownerHTTP.Do(ctx, http.MethodPost, "/api/v1/user/lobby/games/"+game.GameID+"/open-enrollment", nil); err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("open enrollment: %v %d", err, resp.StatusCode)
}
pilots := testenv.EnrollPilots(t, plat, ownerHTTP, game.GameID, 10, "cmd")
pilots := testenv.EnrollPilots(t, plat, ownerHTTP, game.GameID, 10, "order")
if _, resp, err := admin.Do(ctx, http.MethodPost, "/api/v1/admin/games/"+game.GameID+"/force-start", nil); err != nil || resp.StatusCode/100 != 2 {
t.Fatalf("force-start: %v %d", err, resp.StatusCode)
@@ -81,17 +80,17 @@ func TestEngineCommandProxy(t *testing.T) {
time.Sleep(500 * time.Millisecond)
}
// Pilot 1 sends a command. Backend forwards to the engine; the
// pass-through body comes back unchanged. We accept any status
// the engine produces (200, 4xx) — what matters is that backend
// did not surface an internal error of its own.
cmdBody := map[string]any{"actions": []map[string]any{}}
raw, resp, err = pilots[0].HTTP.Do(ctx, http.MethodPost, "/api/v1/user/games/"+game.GameID+"/commands", cmdBody)
// Pilot 1 submits an (empty) order. Backend forwards to the engine;
// the pass-through body comes back unchanged. We accept any status
// the engine produces (200, 4xx) — what matters is that backend did
// not surface an internal error of its own.
orderBody := map[string]any{"cmd": []map[string]any{}}
raw, resp, err = pilots[0].HTTP.Do(ctx, http.MethodPost, "/api/v1/user/games/"+game.GameID+"/orders", orderBody)
if err != nil {
t.Fatalf("commands proxy: %v", err)
t.Fatalf("orders proxy: %v", err)
}
if resp.StatusCode == http.StatusInternalServerError || resp.StatusCode == http.StatusBadGateway {
t.Fatalf("commands proxy: backend internal-error %d body=%s", resp.StatusCode, string(raw))
t.Fatalf("orders proxy: backend internal-error %d body=%s", resp.StatusCode, string(raw))
}
// Cleanup: stop the container so the test does not leak it.
+2 -4
View File
@@ -22,8 +22,7 @@
// Game-state runtime rejections that depend on the current state
// snapshot: entity-not-found, not-owned, in-use, ships-busy,
// insufficient resources, send/upgrade/cargo dependencies. These
// surface as per-command `cmdErrorCode` on PUT /api/v1/order
// (and only escape as HTTP 400 from PUT /api/v1/command).
// surface as per-command `cmdErrorCode` on PUT /api/v1/order.
//
// Code 0 represents "applied without error" and is reserved as the
// successful per-command outcome on CommandMeta.Result. Code -1
@@ -115,8 +114,7 @@ func IsInputCode(code int) bool { return code >= 2000 && code < 3000 }
// IsGameStateCode reports whether code belongs to the game-state /
// per-command rejection shelf (3xxx). On PUT /api/v1/order these are
// recorded into CommandMeta.CmdErrCode; on PUT /api/v1/command they
// map to HTTP 400.
// recorded into CommandMeta.CmdErrCode.
func IsGameStateCode(code int) bool { return code >= 3000 && code < 4000 }
func GenericErrorText(code int) string {
+5 -21
View File
@@ -6,12 +6,6 @@ import (
"github.com/google/uuid"
)
// MessageTypeUserGamesCommand is the authenticated gateway message type
// used to send a batch of in-game commands to the engine through
// `POST /api/v1/user/games/{game_id}/commands`. The signed payload is
// a FlatBuffers `order.UserGamesCommand`.
const MessageTypeUserGamesCommand = "user.games.command"
// MessageTypeUserGamesOrder is the authenticated gateway message type
// used to validate / store a batch of in-game orders through
// `POST /api/v1/user/games/{game_id}/orders`. The signed payload is a
@@ -24,22 +18,12 @@ const MessageTypeUserGamesOrder = "user.games.order"
// signed payload is a FlatBuffers `order.UserGamesOrderGet`.
const MessageTypeUserGamesOrderGet = "user.games.order.get"
// UserGamesCommand is the typed payload of MessageTypeUserGamesCommand.
// `GameID` selects the running engine container; `Commands` is the
// player command batch executed atomically by the engine. The `Actor`
// field present in the engine's JSON shape is rebuilt by backend from
// the runtime player mapping — clients never carry it.
type UserGamesCommand struct {
// GameID identifies the running game for this batch.
GameID uuid.UUID `json:"game_id"`
// Commands is the player command batch.
Commands []DecodableCommand `json:"cmd"`
}
// UserGamesOrder is the typed payload of MessageTypeUserGamesOrder.
// Mirrors `UserGamesCommand` plus an `UpdatedAt` field that lets the
// engine reject stale order submissions.
// `GameID` selects the running engine container; `Commands` is the
// player order batch; `UpdatedAt` lets the engine reject stale order
// submissions. The `Actor` field present in the engine's JSON shape is
// rebuilt by backend from the runtime player mapping — clients never
// carry it.
type UserGamesOrder struct {
// GameID identifies the running game for this batch.
GameID uuid.UUID `json:"game_id"`
+4 -7
View File
@@ -4,13 +4,10 @@ import "encoding/json"
type Command struct {
Actor string `json:"actor" binding:"notblank"`
// Commands carries the engine-bound payload for either the
// command (`PUT /api/v1/command`, immediate) or the order
// (`PUT /api/v1/order`, validate-and-store) path. The order
// path treats an empty array as "the player has no orders for
// this turn" and stores it. The command handler still rejects
// an empty array by hand because immediate execution of a
// no-op makes no sense.
// Commands carries the engine-bound payload for the order
// (`PUT /api/v1/order`, validate-and-store) path. An empty array
// means "the player has no orders for this turn" and is stored
// as-is.
Commands []json.RawMessage `json:"cmd"`
}
+2 -18
View File
@@ -201,31 +201,15 @@ table CommandItem {
cmd_error_message: string;
}
// UserGamesCommand is the signed-gRPC request payload for
// `MessageTypeUserGamesCommand`. game_id selects the target running
// game; gateway re-encodes commands into the engine JSON shape and
// forwards through `POST /api/v1/user/games/{game_id}/commands`.
table UserGamesCommand {
game_id: common.UUID (required);
commands: [CommandItem];
}
// UserGamesOrder is the signed-gRPC request payload for
// `MessageTypeUserGamesOrder`. Identical to UserGamesCommand but
// carries `updated_at` so the order-validate path can reject stale
// submissions.
// `MessageTypeUserGamesOrder`. game_id selects the target running game;
// `updated_at` lets the order-validate path reject stale submissions.
table UserGamesOrder {
game_id: common.UUID (required);
updated_at: int64;
commands: [CommandItem];
}
// UserGamesCommandResponse is the success acknowledgement returned
// for `MessageTypeUserGamesCommand`. The engine answers with
// `204 No Content` on success, so the FB shape is intentionally empty
// — kept as a typed envelope for future extension.
table UserGamesCommandResponse {}
// UserGamesOrderResponse mirrors the engine's `PUT /api/v1/order`
// success body: it echoes the stored order back to the caller with
// the engine-assigned `updated_at` timestamp and per-command
-93
View File
@@ -1,93 +0,0 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package order
import (
flatbuffers "github.com/google/flatbuffers/go"
common "galaxy/schema/fbs/common"
)
type UserGamesCommand struct {
_tab flatbuffers.Table
}
func GetRootAsUserGamesCommand(buf []byte, offset flatbuffers.UOffsetT) *UserGamesCommand {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &UserGamesCommand{}
x.Init(buf, n+offset)
return x
}
func FinishUserGamesCommandBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsUserGamesCommand(buf []byte, offset flatbuffers.UOffsetT) *UserGamesCommand {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &UserGamesCommand{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedUserGamesCommandBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *UserGamesCommand) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *UserGamesCommand) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *UserGamesCommand) GameId(obj *common.UUID) *common.UUID {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
x := o + rcv._tab.Pos
if obj == nil {
obj = new(common.UUID)
}
obj.Init(rcv._tab.Bytes, x)
return obj
}
return nil
}
func (rcv *UserGamesCommand) Commands(obj *CommandItem, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x)
return true
}
return false
}
func (rcv *UserGamesCommand) CommandsLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func UserGamesCommandStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func UserGamesCommandAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependStructSlot(0, flatbuffers.UOffsetT(gameId), 0)
}
func UserGamesCommandAddCommands(builder *flatbuffers.Builder, commands flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(commands), 0)
}
func UserGamesCommandStartCommandsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func UserGamesCommandEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
@@ -1,49 +0,0 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package order
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type UserGamesCommandResponse struct {
_tab flatbuffers.Table
}
func GetRootAsUserGamesCommandResponse(buf []byte, offset flatbuffers.UOffsetT) *UserGamesCommandResponse {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &UserGamesCommandResponse{}
x.Init(buf, n+offset)
return x
}
func FinishUserGamesCommandResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsUserGamesCommandResponse(buf []byte, offset flatbuffers.UOffsetT) *UserGamesCommandResponse {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &UserGamesCommandResponse{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedUserGamesCommandResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *UserGamesCommandResponse) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *UserGamesCommandResponse) Table() flatbuffers.Table {
return rcv._tab
}
func UserGamesCommandResponseStart(builder *flatbuffers.Builder) {
builder.StartObject(0)
}
func UserGamesCommandResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+3 -88
View File
@@ -931,72 +931,6 @@ func cloneStringPointer(value *string) *string {
return &cloned
}
// UserGamesCommandToPayload converts model.UserGamesCommand to
// FlatBuffers bytes suitable for the authenticated gateway transport.
// `GameID` is required.
func UserGamesCommandToPayload(req *model.UserGamesCommand) ([]byte, error) {
if req == nil {
return nil, errors.New("encode user games command payload: request is nil")
}
builder := flatbuffers.NewBuilder(1024)
commandsVec, err := encodeCommandItemVector(builder, req.Commands, "user games command")
if err != nil {
return nil, err
}
fbs.UserGamesCommandStart(builder)
hi, lo := uuidToHiLo(req.GameID)
fbs.UserGamesCommandAddGameId(builder, commonfbs.CreateUUID(builder, hi, lo))
if commandsVec != 0 {
fbs.UserGamesCommandAddCommands(builder, commandsVec)
}
offset := fbs.UserGamesCommandEnd(builder)
fbs.FinishUserGamesCommandBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToUserGamesCommand converts FlatBuffers payload bytes into
// model.UserGamesCommand.
func PayloadToUserGamesCommand(data []byte) (result *model.UserGamesCommand, err error) {
if len(data) == 0 {
return nil, errors.New("decode user games command payload: data is empty")
}
defer func() {
if recovered := recover(); recovered != nil {
result = nil
err = fmt.Errorf("decode user games command payload: panic recovered: %v", recovered)
}
}()
flat := fbs.GetRootAsUserGamesCommand(data, 0)
gameID := flat.GameId(nil)
if gameID == nil {
return nil, errors.New("decode user games command payload: game_id is missing")
}
out := &model.UserGamesCommand{
GameID: uuidFromHiLo(gameID.Hi(), gameID.Lo()),
}
count := flat.CommandsLength()
if count > 0 {
out.Commands = make([]model.DecodableCommand, count)
flatCommand := new(fbs.CommandItem)
for i := 0; i < count; i++ {
if !flat.Commands(flatCommand, i) {
return nil, fmt.Errorf("decode user games command %d: command item is missing", i)
}
cmd, decodeErr := decodeOrderCommand(flatCommand, i)
if decodeErr != nil {
return nil, decodeErr
}
out.Commands[i] = cmd
}
}
return out, nil
}
// UserGamesOrderToPayload converts model.UserGamesOrder to FlatBuffers
// bytes suitable for the authenticated gateway transport.
func UserGamesOrderToPayload(req *model.UserGamesOrder) ([]byte, error) {
@@ -1068,19 +1002,6 @@ func PayloadToUserGamesOrder(data []byte) (result *model.UserGamesOrder, err err
return out, nil
}
// EmptyUserGamesCommandResponsePayload returns a FlatBuffers-encoded
// empty `UserGamesCommandResponse` buffer. Used by gateway to ack a
// successful `MessageTypeUserGamesCommand` even though the engine
// returns 204 No Content — the typed envelope keeps the message-type
// contract symmetric with other authenticated routes.
func EmptyUserGamesCommandResponsePayload() []byte {
builder := flatbuffers.NewBuilder(16)
fbs.UserGamesCommandResponseStart(builder)
offset := fbs.UserGamesCommandResponseEnd(builder)
fbs.FinishUserGamesCommandResponseBuffer(builder, offset)
return builder.FinishedBytes()
}
// UserGamesOrderResponseToPayload encodes the engine's response body
// for `PUT /api/v1/order` into the wire FlatBuffers envelope expected
// for `MessageTypeUserGamesOrder`. The engine populates per-command
@@ -1298,9 +1219,8 @@ func PayloadToUserGamesOrderGetResponse(data []byte) (order *model.UserGamesOrde
}
// encodeCommandItemVector serialises a slice of DecodableCommand into a
// FlatBuffers vector of CommandItem. Used by UserGamesCommandToPayload
// and UserGamesOrderToPayload to keep the per-command encoding logic in
// one place.
// FlatBuffers vector of CommandItem. Used by UserGamesOrderToPayload to
// keep the per-command encoding logic in one place.
func encodeCommandItemVector(builder *flatbuffers.Builder, commands []model.DecodableCommand, opLabel string) (flatbuffers.UOffsetT, error) {
offsets := make([]flatbuffers.UOffsetT, len(commands))
for i := range commands {
@@ -1331,12 +1251,7 @@ func encodeCommandItemVector(builder *flatbuffers.Builder, commands []model.Deco
if len(offsets) == 0 {
return 0, nil
}
// `UserGamesCommandStartCommandsVector` and the corresponding
// `UserGamesOrderStartCommandsVector` are identical helpers (both
// expand to `builder.StartVector(4, numElems, 4)`); we use the
// command flavour for both message types so the helper has a
// single dependency point.
fbs.UserGamesCommandStartCommandsVector(builder, len(offsets))
fbs.UserGamesOrderStartCommandsVector(builder, len(offsets))
for i := len(offsets) - 1; i >= 0; i-- {
builder.PrependUOffsetT(offsets[i])
}
+1 -33
View File
@@ -10,32 +10,6 @@ import (
"github.com/google/uuid"
)
func TestUserGamesCommandPayloadRoundTrip(t *testing.T) {
t.Parallel()
source := &model.UserGamesCommand{
GameID: uuid.MustParse("11111111-2222-3333-4444-555555555555"),
Commands: []model.DecodableCommand{
&model.CommandRaceVote{CommandMeta: commandMeta("cmd-01", model.CommandTypeRaceVote, nil, nil), Acceptor: "race-a"},
&model.CommandShipGroupSend{CommandMeta: commandMeta("cmd-02", model.CommandTypeShipGroupSend, nil, nil), ID: "group-1", Destination: 7},
},
}
payload, err := UserGamesCommandToPayload(source)
if err != nil {
t.Fatalf("encode user games command: %v", err)
}
decoded, err := PayloadToUserGamesCommand(payload)
if err != nil {
t.Fatalf("decode user games command: %v", err)
}
if !reflect.DeepEqual(source, decoded) {
t.Fatalf("round-trip mismatch\nsource: %#v\ndecoded:%#v", source, decoded)
}
}
func TestUserGamesOrderPayloadRoundTrip(t *testing.T) {
t.Parallel()
@@ -62,15 +36,9 @@ func TestUserGamesOrderPayloadRoundTrip(t *testing.T) {
}
}
func TestUserGamesCommandRejectsNilAndEmpty(t *testing.T) {
func TestUserGamesOrderRejectsNilAndEmpty(t *testing.T) {
t.Parallel()
if _, err := UserGamesCommandToPayload(nil); err == nil {
t.Fatalf("expected error encoding nil user games command")
}
if _, err := PayloadToUserGamesCommand(nil); err == nil {
t.Fatalf("expected error decoding empty user games command")
}
if _, err := UserGamesOrderToPayload(nil); err == nil {
t.Fatalf("expected error encoding nil user games order")
}
@@ -32,8 +32,6 @@ export { PlanetRouteLoadType } from './order/planet-route-load-type.js';
export { Relation } from './order/relation.js';
export { ShipGroupCargo } from './order/ship-group-cargo.js';
export { ShipGroupUpgradeTech } from './order/ship-group-upgrade-tech.js';
export { UserGamesCommand, UserGamesCommandT } from './order/user-games-command.js';
export { UserGamesCommandResponse, UserGamesCommandResponseT } from './order/user-games-command-response.js';
export { UserGamesOrder, UserGamesOrderT } from './order/user-games-order.js';
export { UserGamesOrderGet, UserGamesOrderGetT } from './order/user-games-order-get.js';
export { UserGamesOrderGetResponse, UserGamesOrderGetResponseT } from './order/user-games-order-get-response.js';
@@ -1,56 +0,0 @@
// automatically generated by the FlatBuffers compiler, do not modify
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
import * as flatbuffers from 'flatbuffers';
export class UserGamesCommandResponse implements flatbuffers.IUnpackableObject<UserGamesCommandResponseT> {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):UserGamesCommandResponse {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsUserGamesCommandResponse(bb:flatbuffers.ByteBuffer, obj?:UserGamesCommandResponse):UserGamesCommandResponse {
return (obj || new UserGamesCommandResponse()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsUserGamesCommandResponse(bb:flatbuffers.ByteBuffer, obj?:UserGamesCommandResponse):UserGamesCommandResponse {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new UserGamesCommandResponse()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static startUserGamesCommandResponse(builder:flatbuffers.Builder) {
builder.startObject(0);
}
static endUserGamesCommandResponse(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createUserGamesCommandResponse(builder:flatbuffers.Builder):flatbuffers.Offset {
UserGamesCommandResponse.startUserGamesCommandResponse(builder);
return UserGamesCommandResponse.endUserGamesCommandResponse(builder);
}
unpack(): UserGamesCommandResponseT {
return new UserGamesCommandResponseT();
}
unpackTo(_o: UserGamesCommandResponseT): void {}
}
export class UserGamesCommandResponseT implements flatbuffers.IGeneratedObject {
constructor(){}
pack(builder:flatbuffers.Builder): flatbuffers.Offset {
return UserGamesCommandResponse.createUserGamesCommandResponse(builder);
}
}
@@ -1,110 +0,0 @@
// automatically generated by the FlatBuffers compiler, do not modify
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js';
import { CommandItem, CommandItemT } from '../order/command-item.js';
export class UserGamesCommand implements flatbuffers.IUnpackableObject<UserGamesCommandT> {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):UserGamesCommand {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsUserGamesCommand(bb:flatbuffers.ByteBuffer, obj?:UserGamesCommand):UserGamesCommand {
return (obj || new UserGamesCommand()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsUserGamesCommand(bb:flatbuffers.ByteBuffer, obj?:UserGamesCommand):UserGamesCommand {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new UserGamesCommand()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
gameId(obj?:UUID):UUID|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? (obj || new UUID()).__init(this.bb_pos + offset, this.bb!) : null;
}
commands(index: number, obj?:CommandItem):CommandItem|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? (obj || new CommandItem()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
commandsLength():number {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startUserGamesCommand(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldStruct(0, gameIdOffset, 0);
}
static addCommands(builder:flatbuffers.Builder, commandsOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, commandsOffset, 0);
}
static createCommandsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startCommandsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endUserGamesCommand(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
builder.requiredField(offset, 4) // game_id
return offset;
}
static createUserGamesCommand(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, commandsOffset:flatbuffers.Offset):flatbuffers.Offset {
UserGamesCommand.startUserGamesCommand(builder);
UserGamesCommand.addGameId(builder, gameIdOffset);
UserGamesCommand.addCommands(builder, commandsOffset);
return UserGamesCommand.endUserGamesCommand(builder);
}
unpack(): UserGamesCommandT {
return new UserGamesCommandT(
(this.gameId() !== null ? this.gameId()!.unpack() : null),
this.bb!.createObjList<CommandItem, CommandItemT>(this.commands.bind(this), this.commandsLength())
);
}
unpackTo(_o: UserGamesCommandT): void {
_o.gameId = (this.gameId() !== null ? this.gameId()!.unpack() : null);
_o.commands = this.bb!.createObjList<CommandItem, CommandItemT>(this.commands.bind(this), this.commandsLength());
}
}
export class UserGamesCommandT implements flatbuffers.IGeneratedObject {
constructor(
public gameId: UUIDT|null = null,
public commands: (CommandItemT)[] = []
){}
pack(builder:flatbuffers.Builder): flatbuffers.Offset {
const commands = UserGamesCommand.createCommandsVector(builder, builder.createObjectOffsetList(this.commands));
return UserGamesCommand.createUserGamesCommand(builder,
(this.gameId !== null ? this.gameId!.pack(builder) : 0),
commands
);
}
}