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