Merge pull request 'refactor(game): lock-free storage, remove /command, flatten engine wrapper' (#73) from feature/game-engine-refactor into development
This commit was merged in pull request #73.
This commit is contained in:
@@ -264,7 +264,6 @@ Endpoints used:
|
|||||||
- `GET /api/v1/admin/status`
|
- `GET /api/v1/admin/status`
|
||||||
- `PUT /api/v1/admin/turn`
|
- `PUT /api/v1/admin/turn`
|
||||||
- `POST /api/v1/admin/race/banish`
|
- `POST /api/v1/admin/race/banish`
|
||||||
- `PUT /api/v1/command`
|
|
||||||
- `PUT /api/v1/order`
|
- `PUT /api/v1/order`
|
||||||
- `GET /api/v1/report`
|
- `GET /api/v1/report`
|
||||||
- `GET /healthz`
|
- `GET /healthz`
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const (
|
|||||||
pathAdminStatus = "/api/v1/admin/status"
|
pathAdminStatus = "/api/v1/admin/status"
|
||||||
pathAdminTurn = "/api/v1/admin/turn"
|
pathAdminTurn = "/api/v1/admin/turn"
|
||||||
pathAdminRaceBanish = "/api/v1/admin/race/banish"
|
pathAdminRaceBanish = "/api/v1/admin/race/banish"
|
||||||
pathPlayerCommand = "/api/v1/command"
|
|
||||||
pathPlayerOrder = "/api/v1/order"
|
pathPlayerOrder = "/api/v1/order"
|
||||||
pathPlayerReport = "/api/v1/report"
|
pathPlayerReport = "/api/v1/report"
|
||||||
pathPlayerBattle = "/api/v1/battle"
|
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
|
// verbatim. The engine response body is returned verbatim; on 4xx the
|
||||||
// body is returned alongside ErrEngineValidation so callers can
|
// body is returned alongside ErrEngineValidation so callers can forward
|
||||||
// forward the per-command error.
|
// 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.
|
|
||||||
func (c *Client) PutOrders(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, 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")
|
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) {
|
func TestClientReportsForwardsQuery(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != pathPlayerReport {
|
if r.URL.Path != pathPlayerReport {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ var (
|
|||||||
ErrTurnAlreadyClosed = errors.New("runtime: turn already closed")
|
ErrTurnAlreadyClosed = errors.New("runtime: turn already closed")
|
||||||
|
|
||||||
// ErrGamePaused reports that the game is not in a state that
|
// 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
|
// carries `paused = true`, or the runtime status lands on any
|
||||||
// terminal value (`engine_unreachable`, `generation_failed`,
|
// terminal value (`engine_unreachable`, `generation_failed`,
|
||||||
// `stopped`, `finished`, `removed`), or the game has not yet
|
// `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
|
// CheckOrdersAccept verifies that the runtime is in a state that
|
||||||
// accepts user-games commands and orders. It is called by the user
|
// accepts user-games orders. It is called by the user game-proxy
|
||||||
// game-proxy handlers (`Commands`, `Orders`) before forwarding to
|
// handler (`Orders`) before forwarding to engine, so the backend's
|
||||||
// engine, so the backend's turn-cutoff and pause guards run before
|
// turn-cutoff and pause guards run before network traffic leaves the
|
||||||
// network traffic leaves the host. The decision itself lives in the
|
// host. The decision itself lives in the
|
||||||
// pure helper `OrdersAcceptStatus` so it can be unit-tested without
|
// pure helper `OrdersAcceptStatus` so it can be unit-tested without
|
||||||
// constructing a full Service.
|
// 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
|
// 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`.
|
// - `runtime_status = generation_in_progress` → `ErrTurnAlreadyClosed`.
|
||||||
// The cron-driven `Scheduler.tick` has flipped the row before
|
// 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")}
|
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.
|
// Orders handles POST /api/v1/user/games/{game_id}/orders.
|
||||||
func (h *UserGamesHandlers) Orders() gin.HandlerFunc {
|
func (h *UserGamesHandlers) Orders() gin.HandlerFunc {
|
||||||
if h == nil || h.runtime == nil || h.engine == nil {
|
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())
|
raceNames.POST("/register", deps.UserLobbyRaceNames.Register())
|
||||||
|
|
||||||
userGames := group.Group("/games")
|
userGames := group.Group("/games")
|
||||||
userGames.POST("/:game_id/commands", deps.UserGames.Commands())
|
|
||||||
userGames.POST("/:game_id/orders", deps.UserGames.Orders())
|
userGames.POST("/:game_id/orders", deps.UserGames.Orders())
|
||||||
userGames.GET("/:game_id/orders", deps.UserGames.GetOrders())
|
userGames.GET("/:game_id/orders", deps.UserGames.GetOrders())
|
||||||
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
|
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
|
||||||
|
|||||||
@@ -981,37 +981,6 @@ paths:
|
|||||||
$ref: "#/components/responses/NotImplementedError"
|
$ref: "#/components/responses/NotImplementedError"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$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:
|
/api/v1/user/games/{game_id}/orders:
|
||||||
post:
|
post:
|
||||||
tags: [User]
|
tags: [User]
|
||||||
@@ -3538,14 +3507,6 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
name:
|
name:
|
||||||
type: string
|
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:
|
EngineOrder:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
|||||||
@@ -375,9 +375,9 @@ Authenticated client traffic for in-game operations crosses three
|
|||||||
serialisation boundaries: signed-gRPC FlatBuffers (client ↔ gateway),
|
serialisation boundaries: signed-gRPC FlatBuffers (client ↔ gateway),
|
||||||
JSON over REST (gateway ↔ backend), and JSON over REST again
|
JSON over REST (gateway ↔ backend), and JSON over REST again
|
||||||
(backend ↔ engine). Gateway owns the FB ↔ JSON transcoding for the
|
(backend ↔ engine). Gateway owns the FB ↔ JSON transcoding for the
|
||||||
four message types `user.games.command`, `user.games.order`,
|
three message types `user.games.order`, `user.games.order.get`,
|
||||||
`user.games.order.get`, `user.games.report` (FB schemas in
|
`user.games.report` (FB schemas in `pkg/schema/fbs/{order,report}`,
|
||||||
`pkg/schema/fbs/{order,report}`, encoders in `pkg/transcoder`).
|
encoders in `pkg/transcoder`).
|
||||||
`user.games.order.get` reads back the player's stored order for a
|
`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
|
given turn — paired with the POST `user.games.order` so the client
|
||||||
can hydrate its local draft after a cache loss without re-deriving
|
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
|
### 6.2 Backend's role: pass-through with authorisation
|
||||||
|
|
||||||
The signed authenticated-edge pipeline for in-game traffic uses four
|
The signed authenticated-edge pipeline for in-game traffic uses three
|
||||||
message types on the authenticated surface — `user.games.command`,
|
message types on the authenticated surface — `user.games.order`,
|
||||||
`user.games.order`, `user.games.order.get`, `user.games.report` —
|
`user.games.order.get`, `user.games.report` — each with a typed
|
||||||
each with a typed FlatBuffers payload. Gateway transcodes the FB
|
FlatBuffers payload. Gateway transcodes the FB
|
||||||
request into the JSON shape backend expects, forwards over plain
|
request into the JSON shape backend expects, forwards over plain
|
||||||
REST to the corresponding `/api/v1/user/games/{game_id}/*` endpoint,
|
REST to the corresponding `/api/v1/user/games/{game_id}/*` endpoint,
|
||||||
then transcodes the JSON response back into FB before signing the
|
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:
|
`/admin/turn` call between two `runtime_status` flips:
|
||||||
|
|
||||||
- Before the engine call: `running → generation_in_progress`.
|
- 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
|
(`backend/internal/server/handlers_user_games.go`) consult the
|
||||||
per-game runtime record on every request and reject with
|
per-game runtime record on every request and reject with
|
||||||
HTTP 409 + `code = turn_already_closed` while the runtime sits in
|
HTTP 409 + `code = turn_already_closed` while the runtime sits in
|
||||||
|
|||||||
@@ -637,9 +637,9 @@ Wire-формат команд, приказов и отчётов — собс
|
|||||||
### 6.2 Роль backend: pass-through с авторизацией
|
### 6.2 Роль backend: pass-through с авторизацией
|
||||||
|
|
||||||
Подписанный конвейер аутентифицированного edge для in-game-трафика
|
Подписанный конвейер аутентифицированного edge для in-game-трафика
|
||||||
использует четыре message types на аутентифицированной поверхности —
|
использует три message types на аутентифицированной поверхности —
|
||||||
`user.games.command`, `user.games.order`, `user.games.order.get`,
|
`user.games.order`, `user.games.order.get`, `user.games.report` —
|
||||||
`user.games.report` — у каждого типизированный FlatBuffers-payload.
|
у каждого типизированный FlatBuffers-payload.
|
||||||
Gateway транскодирует FB-запрос в JSON-форму, которую ждёт backend,
|
Gateway транскодирует FB-запрос в JSON-форму, которую ждёт backend,
|
||||||
форвардит её REST'ом в соответствующий
|
форвардит её REST'ом в соответствующий
|
||||||
`/api/v1/user/games/{game_id}/*` endpoint, после чего транскодирует
|
`/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) | `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) | `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. |
|
| 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 | `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/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. |
|
| 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`,
|
`game/internal/router/handler/handler.go` exports `ResolveStoragePath`,
|
||||||
which returns the engine storage path from the env-var pair above and
|
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
|
an error when neither is set. `cmd/http/main.go` calls it once at
|
||||||
constructing the router, prints the error to stderr, and exits non-zero.
|
startup, prints the error to stderr and exits non-zero on failure, then
|
||||||
The existing `initConfig` closure also calls `ResolveStoragePath` to
|
builds the engine service (`controller.NewService(path)`) and hands it
|
||||||
populate `controller.Param.StoragePath` at request time; the error there
|
to `router.NewRouter`.
|
||||||
is dropped because `main` already validated the environment at startup.
|
|
||||||
|
|
||||||
This keeps the public router surface (`router.NewRouter`) unchanged —
|
Storage is resolved exactly once, at construction, rather than per
|
||||||
the env binding is satisfied by one helper plus a startup check, with
|
request: the `Service` holds the file-backed repo for the process
|
||||||
no API ripple. Moving env reading entirely into `main` and changing
|
lifetime and `router.NewRouter` takes the `handler.Engine` it routes
|
||||||
`NewRouter` / `NewDefaultExecutor` to accept an explicit path was
|
to (in production, the `Service`). This keeps the env binding in one
|
||||||
rejected: it churns multiple call sites for no functional gain. The
|
place — a startup helper plus the `main` check — and leaves the
|
||||||
current shape leaves the configurer closure ready for future
|
handlers free of configuration concerns.
|
||||||
config-injection refactors without forcing one now.
|
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -4,17 +4,25 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"galaxy/game/internal/controller"
|
||||||
"galaxy/game/internal/router"
|
"galaxy/game/internal/router"
|
||||||
"galaxy/game/internal/router/handler"
|
"galaxy/game/internal/router/handler"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if _, err := handler.ResolveStoragePath(); err != nil {
|
path, err := handler.ResolveStoragePath()
|
||||||
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
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 {
|
if err := r.Run(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -16,187 +16,147 @@ import (
|
|||||||
"galaxy/game/internal/repo"
|
"galaxy/game/internal/repo"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Configurer func(*Param)
|
// Service is the engine's application service: it owns persistence and exposes
|
||||||
|
// the operations the HTTP handlers invoke. It is safe for concurrent use —
|
||||||
type Repo interface {
|
// reads are lock-free and the writers that mutate the canonical state file
|
||||||
// Lock must be called before any repository operations
|
// (init/turn/banish) are serialised at the router by a shared LimitMiddleware.
|
||||||
Lock() error
|
type Service struct {
|
||||||
|
repo *repo.Repo
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Ctrl interface {
|
// NewService opens the file-backed storage at storagePath and returns a ready
|
||||||
ValidateOrder(actor string, cmd ...order.DecodableCommand) error
|
// Service. The directory must already exist and be writable.
|
||||||
// remove below funcs if /command api will be deleted
|
func NewService(storagePath string) (*Service, error) {
|
||||||
RaceID(actor string) (uuid.UUID, error)
|
r, err := repo.NewFileRepo(storagePath)
|
||||||
RaceQuit(actor string) error
|
if err != nil {
|
||||||
RaceVote(actor, acceptor string) error
|
return nil, err
|
||||||
RaceRelation(actor, acceptor string, rel string) error
|
}
|
||||||
ShipClassCreate(actor, typeName string, drive float64, ammo int, weapons, shileds, cargo float64) error
|
return &Service{repo: r}, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateGame initialises a fresh game in storage under the supplied
|
// GenerateGame initialises a fresh game in storage under the supplied
|
||||||
// canonical gameID. The orchestrator must allocate gameID before the
|
// canonical gameID. The orchestrator must allocate gameID before the engine
|
||||||
// engine container is started and pass it here as the request body of
|
// container is started and pass it here as the request body of
|
||||||
// POST /api/v1/admin/init. A zero UUID is rejected with
|
// POST /api/v1/admin/init. A zero UUID is rejected with ErrGameInitNilUUID; an
|
||||||
// ErrGameInitNilUUID; an attempt to init on top of an existing
|
// attempt to init on top of an existing state.json is rejected with
|
||||||
// state.json is rejected with ErrGameAlreadyInit.
|
// ErrGameAlreadyInit.
|
||||||
func GenerateGame(configure func(*Param), gameID uuid.UUID, races []string) (s game.State, err error) {
|
func (s *Service) GenerateGame(gameID uuid.UUID, races []string) (game.State, error) {
|
||||||
if gameID == uuid.Nil {
|
if gameID == uuid.Nil {
|
||||||
return game.State{}, ErrGameInitNilUUID
|
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
|
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 {
|
return s.GameState()
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = NewGame(ec.Repo, gameID, races)
|
// GenerateTurn advances the game by one turn (applying every stored order) and
|
||||||
return
|
// 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
|
// isGameNotInitialized reports whether err is the engine's canonical
|
||||||
// "no state.json on disk" signal returned by Repo.LoadState on a
|
// "no state.json on disk" signal returned by Repo.LoadState on a fresh
|
||||||
// fresh storage directory.
|
// storage directory.
|
||||||
func isGameNotInitialized(err error) bool {
|
func isGameNotInitialized(err error) bool {
|
||||||
var ge *e.GenericError
|
var ge *e.GenericError
|
||||||
return errors.As(err, &ge) && ge.Code == e.ErrGameNotInitialized
|
return errors.As(err, &ge) && ge.Code == e.ErrGameNotInitialized
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateTurn(configure func(*Param)) (err error) {
|
// LoadReport returns the stored turn report for actor at the given turn.
|
||||||
ec, err := NewRepoController(configure)
|
func (s *Service) LoadReport(actor string, turn uint) (r *report.Report, err error) {
|
||||||
if err != nil {
|
execErr := s.execute(func(_ uint, c *Controller) (exErr error) {
|
||||||
return err
|
id, exErr := c.RaceID(actor)
|
||||||
}
|
if exErr == nil {
|
||||||
err = ec.executeLocked(func(c *Controller) error { return c.MakeTurn() })
|
r, exErr = s.repo.LoadReport(turn, id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
})
|
||||||
|
err = errors.Join(err, execErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadReport(configure func(*Param), actor string, turn uint) (*report.Report, error) {
|
// ValidateOrder validates cmd against a transient view of the current state,
|
||||||
ec, err := NewRepoController(configure)
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return ec.loadReport(actor, turn)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExecuteCommand(configure func(*Param), consumer func(c Ctrl) error) (err error) {
|
// FetchOrder returns the order actor stored for the given turn. ok is false
|
||||||
ec, err := NewRepoController(configure)
|
// when no order was ever stored.
|
||||||
if err != nil {
|
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
|
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) {
|
// FetchBattle returns the battle report stored at turn under ID. exists is
|
||||||
ec, err := NewRepoController(configure)
|
// false when no such battle was recorded.
|
||||||
if err != nil {
|
func (s *Service) FetchBattle(turn uint, ID uuid.UUID) (b *report.BattleReport, exists bool, err error) {
|
||||||
return nil, err
|
err = s.execute(func(_ uint, c *Controller) error {
|
||||||
}
|
b, exists, err = s.repo.LoadBattle(turn, ID)
|
||||||
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 {
|
|
||||||
return err
|
return err
|
||||||
}
|
})
|
||||||
return ec.banishRace(actor)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func GameState(configure func(*Param)) (s game.State, err error) {
|
// BanishRace deactivates actor's race after a permanent platform removal and
|
||||||
ec, err := NewRepoController(configure)
|
// persists the updated state.
|
||||||
if err != nil {
|
func (s *Service) BanishRace(actor string) error {
|
||||||
return game.State{}, err
|
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 {
|
if err != nil {
|
||||||
return game.State{}, err
|
return game.State{}, err
|
||||||
}
|
}
|
||||||
@@ -234,149 +194,26 @@ func GameState(configure func(*Param)) (s game.State, err error) {
|
|||||||
return *result, nil
|
return *result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type RepoController struct {
|
// execute loads the current game state, wraps it in a Controller and runs
|
||||||
Repo Repo
|
// 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 NewRepoController(config Configurer) (*RepoController, error) {
|
func (s *Service) execute(consumer func(uint, *Controller) error) error {
|
||||||
c := &Param{
|
g, err := s.repo.LoadState()
|
||||||
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()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return consumer(g.Turn, &Controller{repo: s.repo, Cache: NewCache(g)})
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
type Controller struct {
|
||||||
*RepoController
|
repo *repo.Repo
|
||||||
Cache *Cache
|
Cache *Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
type Param struct {
|
func (c *Controller) saveState() error {
|
||||||
StoragePath string
|
return c.repo.SaveLastState(c.Cache.g)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,8 +131,7 @@ func newGame() *game.Game {
|
|||||||
|
|
||||||
func newCache() (*controller.Cache, *controller.Controller) {
|
func newCache() (*controller.Cache, *controller.Controller) {
|
||||||
ctl := &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_Gunship, 60, 3, 30, 100, 0))
|
||||||
assertNoError(ctl.Cache.ShipClassCreate(Race_0_idx, Race_0_Freighter, 8, 0, 0, 2, 10))
|
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/generator"
|
||||||
"galaxy/game/internal/model/game"
|
"galaxy/game/internal/model/game"
|
||||||
|
"galaxy/game/internal/repo"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -14,7 +15,7 @@ import (
|
|||||||
// NewGame initialises a fresh game in storage under the supplied
|
// NewGame initialises a fresh game in storage under the supplied
|
||||||
// gameID. The caller is expected to have validated gameID against
|
// gameID. The caller is expected to have validated gameID against
|
||||||
// uuid.Nil and to have ruled out collisions with existing state.
|
// 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) {
|
m, err := generator.Generate(func(ms *generator.MapSetting) {
|
||||||
ms.Players = uint32(len(races))
|
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)
|
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)
|
g, err := buildGameOnMap(gameID, races, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.Nil, err
|
return uuid.Nil, err
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ func TestNewGame(t *testing.T) {
|
|||||||
races[i] = fmt.Sprintf("race_%02d", i)
|
races[i] = fmt.Sprintf("race_%02d", i)
|
||||||
}
|
}
|
||||||
requestedID := uuid.New()
|
requestedID := uuid.New()
|
||||||
assert.NoError(t, r.Lock())
|
|
||||||
gameID, err := controller.NewGame(r, requestedID, races)
|
gameID, err := controller.NewGame(r, requestedID, races)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, requestedID, gameID, "NewGame must echo the supplied gameID")
|
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)
|
numShuffled = numShuffled || p.Number != uint(i)
|
||||||
}
|
}
|
||||||
assert.True(t, numShuffled)
|
assert.True(t, numShuffled)
|
||||||
|
|
||||||
assert.NoError(t, r.Release())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateGameRejectsExistingState(t *testing.T) {
|
func TestGenerateGameRejectsExistingState(t *testing.T) {
|
||||||
@@ -79,13 +76,14 @@ func TestGenerateGameRejectsExistingState(t *testing.T) {
|
|||||||
for i := range races {
|
for i := range races {
|
||||||
races[i] = fmt.Sprintf("race_%02d", i)
|
races[i] = fmt.Sprintf("race_%02d", i)
|
||||||
}
|
}
|
||||||
configure := func(p *controller.Param) { p.StoragePath = root }
|
svc, err := controller.NewService(root)
|
||||||
|
|
||||||
firstID := uuid.New()
|
|
||||||
_, err := controller.GenerateGame(configure, firstID, races)
|
|
||||||
assert.NoError(t, err)
|
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)
|
assert.ErrorIs(t, err, controller.ErrGameAlreadyInit)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +96,8 @@ func TestGenerateGameRejectsNilUUID(t *testing.T) {
|
|||||||
races[i] = fmt.Sprintf("race_%02d", i)
|
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)
|
assert.ErrorIs(t, err, controller.ErrGameInitNilUUID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func (c *Controller) MakeTurn() error {
|
|||||||
// Store bombings
|
// Store bombings
|
||||||
bombingReport := make([]*report.Bombing, len(bombings))
|
bombingReport := make([]*report.Bombing, len(bombings))
|
||||||
if len(bombings) > 0 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
for i := range bombings {
|
for i := range bombings {
|
||||||
@@ -107,7 +107,7 @@ func (c *Controller) MakeTurn() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
report := TransformBattle(c.Cache, b)
|
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
|
return err
|
||||||
}
|
}
|
||||||
battleReport[i] = report
|
battleReport[i] = report
|
||||||
@@ -118,12 +118,12 @@ func (c *Controller) MakeTurn() error {
|
|||||||
c.Cache.DeleteKilledShipGroups()
|
c.Cache.DeleteKilledShipGroups()
|
||||||
|
|
||||||
// Store game state for the new turn and 'current' state as well
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for rep := range c.Cache.Report(c.Cache.g.Turn, battleReport, bombingReport) {
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ func (c *Controller) applyOrders(t uint) error {
|
|||||||
cmdApplied := make(map[string]bool)
|
cmdApplied := make(map[string]bool)
|
||||||
|
|
||||||
for ri := range c.Cache.listRaceActingIdx() {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -166,7 +166,7 @@ func (c *Controller) applyOrders(t uint) error {
|
|||||||
_ = c.applyCommand(commandRace[cmd.CommandID()], cmd)
|
_ = c.applyCommand(commandRace[cmd.CommandID()], cmd)
|
||||||
}
|
}
|
||||||
// re-save order to persist possible changed commands result outcome
|
// 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,
|
GameID: c.Cache.g.ID,
|
||||||
UpdatedAt: raceOrderUpdated[ri],
|
UpdatedAt: raceOrderUpdated[ri],
|
||||||
Commands: raceOrder[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"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"galaxy/util"
|
"galaxy/util"
|
||||||
"math/big"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const defaultPerm = 0o644
|
||||||
defaultPerm = 0o644
|
|
||||||
lockFile = ".lock"
|
|
||||||
oldFileSuffix = ".old"
|
|
||||||
newFileSuffix = ".new"
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
root string
|
||||||
lock *os.File
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFileStorage(path string) (*fs, error) {
|
func NewFileStorage(path string) (*FS, error) {
|
||||||
filepath.Join("", "")
|
|
||||||
absPath, err := filepath.Abs(path)
|
absPath, err := filepath.Abs(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("path %s invalid: %s", path, err)
|
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)
|
return nil, errors.New("directory should have read-write access: " + absPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
fs := &fs{
|
return &FS{root: path}, nil
|
||||||
root: path,
|
|
||||||
}
|
|
||||||
return fs, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fs) Lock() (func() error, error) {
|
func (f *FS) Exists(path string) (bool, 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) {
|
|
||||||
return util.FileExists(filepath.Join(f.root, path))
|
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 {
|
if v == nil {
|
||||||
return errors.New("cant't marshal from nil object")
|
return errors.New("cant't marshal from nil object")
|
||||||
}
|
}
|
||||||
|
|
||||||
targetFilePath := filepath.Join(f.root, path)
|
targetFilePath := filepath.Join(f.root, path)
|
||||||
if targetFilePath == f.lockFilePath() {
|
|
||||||
return errors.New("can't write to the lock file")
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := v.MarshalBinary()
|
data, err := v.MarshalBinary()
|
||||||
if err != nil {
|
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)
|
return fmt.Errorf("check target dir exists: %s", err)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
err := os.MkdirAll(targetDir, os.ModePerm)
|
if err := os.MkdirAll(targetDir, os.ModePerm); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create target dirs: %s", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("check target file exists: %s", err)
|
return fmt.Errorf("create temp file: %s", err)
|
||||||
}
|
}
|
||||||
if targetExists {
|
tmpPath := tmp.Name()
|
||||||
oldFileExists, err := util.FileExists(oldFilePath)
|
if _, err := tmp.Write(data); err != nil {
|
||||||
if err != nil {
|
tmp.Close()
|
||||||
return fmt.Errorf("check old file exists: %s", err)
|
os.Remove(tmpPath)
|
||||||
}
|
return fmt.Errorf("write temp file: %s", err)
|
||||||
if oldFileExists {
|
|
||||||
return fmt.Errorf("old file exists at: %s", oldFilePath)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
newFilePath := targetFilePath + newFileSuffix
|
os.Remove(tmpPath)
|
||||||
newFileExists, err := util.FileExists(newFilePath)
|
return fmt.Errorf("close temp file: %s", err)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("check new file exists: %s", err)
|
|
||||||
}
|
}
|
||||||
if newFileExists {
|
if err := os.Chmod(tmpPath, defaultPerm); err != nil {
|
||||||
return fmt.Errorf("new file exists at: %s", oldFilePath)
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("chmod temp file: %s", err)
|
||||||
}
|
}
|
||||||
|
if err := os.Rename(tmpPath, targetFilePath); err != nil {
|
||||||
err = os.WriteFile(newFilePath, data, defaultPerm)
|
os.Remove(tmpPath)
|
||||||
if err != nil {
|
return fmt.Errorf("replace target file: %s", err)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fs) Write(path string, v encoding.BinaryMarshaler) error {
|
// Read loads path into v. Reads need no lock: because Write swaps files into
|
||||||
if f.lock == nil {
|
// place atomically with rename, a reader always observes a complete file even
|
||||||
return errors.New("lock must be acquired before write")
|
// when a write is in flight.
|
||||||
}
|
func (f *FS) Read(path string, v encoding.BinaryUnmarshaler) error {
|
||||||
|
|
||||||
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 {
|
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return errors.New("can't unmarshal to a nil object")
|
return errors.New("can't unmarshal to a nil object")
|
||||||
}
|
}
|
||||||
|
|
||||||
targetFilePath := filepath.Join(f.root, file)
|
data, err := os.ReadFile(filepath.Join(f.root, path))
|
||||||
if targetFilePath == f.lockFilePath() {
|
|
||||||
return errors.New("can't read from the lock file")
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(targetFilePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reading data file: %s", err)
|
return fmt.Errorf("reading data file: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return v.UnmarshalBinary(data)
|
return v.UnmarshalBinary(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fs) lockFilePath() string {
|
|
||||||
return filepath.Join(f.root, lockFile)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package fs_test
|
package fs_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"galaxy/game/internal/repo/fs"
|
"galaxy/game/internal/repo/fs"
|
||||||
@@ -12,10 +14,6 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
lockFile = ".lock"
|
|
||||||
)
|
|
||||||
|
|
||||||
type sampleData struct {
|
type sampleData struct {
|
||||||
data []byte
|
data []byte
|
||||||
}
|
}
|
||||||
@@ -36,20 +34,6 @@ func TestNewFileStorageSuccess(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
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) {
|
func TestExist(t *testing.T) {
|
||||||
root, cleanup := util.CreateWorkDir(t)
|
root, cleanup := util.CreateWorkDir(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -78,9 +62,6 @@ func TestWrite(t *testing.T) {
|
|||||||
fs, err := fs.NewFileStorage(root)
|
fs, err := fs.NewFileStorage(root)
|
||||||
assert.NoError(t, err, "create file storage: %s", err)
|
assert.NoError(t, err, "create file storage: %s", err)
|
||||||
|
|
||||||
unlock, err := fs.Lock()
|
|
||||||
assert.NoError(t, err, "acquire lock: %s", err)
|
|
||||||
|
|
||||||
dirName := "some-dir"
|
dirName := "some-dir"
|
||||||
if err := os.Mkdir(filepath.Join(root, dirName), os.ModePerm); err != nil {
|
if err := os.Mkdir(filepath.Join(root, dirName), os.ModePerm); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -93,9 +74,8 @@ func TestWrite(t *testing.T) {
|
|||||||
{path: "file-1.ext"},
|
{path: "file-1.ext"},
|
||||||
{path: "/dir/file-2.ext"},
|
{path: "/dir/file-2.ext"},
|
||||||
{path: "dir/subdir/file-3.ext"},
|
{path: "dir/subdir/file-3.ext"},
|
||||||
{path: lockFile, err: "write to the lock file"},
|
{path: dirName, err: "file exists"},
|
||||||
{path: dirName, err: "wrong type"},
|
{path: "/" + dirName, err: "file exists"},
|
||||||
{path: "/" + dirName, err: "wrong type"},
|
|
||||||
} {
|
} {
|
||||||
t.Run(tc.path, func(t *testing.T) {
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
sd := &sampleData{[]byte{0, 1, 2, 3}}
|
sd := &sampleData{[]byte{0, 1, 2, 3}}
|
||||||
@@ -103,13 +83,26 @@ func TestWrite(t *testing.T) {
|
|||||||
if tc.err == "" {
|
if tc.err == "" {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.FileExists(t, filepath.Join(root, tc.path), "the written file should exist")
|
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.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) {
|
func TestRead(t *testing.T) {
|
||||||
@@ -121,11 +114,6 @@ func TestRead(t *testing.T) {
|
|||||||
fs, err := fs.NewFileStorage(root)
|
fs, err := fs.NewFileStorage(root)
|
||||||
assert.NoError(t, err, "create file storage: %s", err)
|
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"
|
dirName := "some-dir"
|
||||||
if err := os.Mkdir(filepath.Join(root, dirName), os.ModePerm); err != nil {
|
if err := os.Mkdir(filepath.Join(root, dirName), os.ModePerm); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -142,33 +130,82 @@ func TestRead(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{path: fileName},
|
{path: fileName},
|
||||||
{path: "/" + fileName},
|
{path: "/" + fileName},
|
||||||
{path: lockFile, err: "read from the lock file"},
|
|
||||||
{path: "dir/subdir/file-3.ext", err: "no such 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"},
|
{path: dirName, err: "is a directory"},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.path, func(t *testing.T) {
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
err = fs.Read(tc.path, sd)
|
err = fs.Read(tc.path, sd)
|
||||||
if tc.err == "" {
|
if tc.err == "" {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.FileExists(t, filepath.Join(root, tc.path), "the written file should exist")
|
} else {
|
||||||
} else if tc.err != "" {
|
|
||||||
assert.ErrorContains(t, err, tc.err)
|
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)
|
root, cleanup := util.CreateWorkDir(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
fs, err := fs.NewFileStorage(root)
|
|
||||||
assert.NoError(t, err, "create file storage")
|
s, err := fs.NewFileStorage(root)
|
||||||
sd := &sampleData{[]byte{0, 1, 2, 3}}
|
assert.NoError(t, err)
|
||||||
err = fs.Write("some/path", sd)
|
|
||||||
assert.Error(t, err, "should return error when no lock acquired")
|
const path = "state.bin"
|
||||||
assert.EqualError(t, err, "lock must be acquired before write")
|
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) {
|
func TestNewFileStorageErrorNotExists(t *testing.T) {
|
||||||
|
|||||||
+33
-42
@@ -19,6 +19,7 @@ import (
|
|||||||
"galaxy/model/report"
|
"galaxy/model/report"
|
||||||
|
|
||||||
"galaxy/game/internal/model/game"
|
"galaxy/game/internal/model/game"
|
||||||
|
"galaxy/game/internal/repo/fs"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -42,11 +43,11 @@ func (o *storedOrder) UnmarshalBinary(data []byte) error {
|
|||||||
return json.Unmarshal(data, o)
|
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)
|
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))
|
path := fmt.Sprintf("%s/state.json", TurnDir(t))
|
||||||
exist, err := s.Exists(path)
|
exist, err := s.Exists(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -61,27 +62,23 @@ func saveNewTurn(s Storage, t uint, g *game.Game) error {
|
|||||||
return saveLastState(s, g)
|
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)
|
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 {
|
if err := s.Write(statePath, g); err != nil {
|
||||||
return NewStorageError(err)
|
return NewStorageError(err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *repo) LoadState() (*game.Game, error) {
|
func (r *Repo) LoadState() (*game.Game, error) {
|
||||||
return loadState(r.s, true)
|
return loadState(r.s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *repo) LoadStateSafe() (*game.Game, error) {
|
func loadState(s *fs.FS) (*game.Game, error) {
|
||||||
return loadState(r.s, false)
|
result := new(game.Game)
|
||||||
}
|
|
||||||
|
|
||||||
func loadState(s Storage, locked bool) (*game.Game, error) {
|
|
||||||
var result *game.Game = new(game.Game)
|
|
||||||
path := statePath
|
path := statePath
|
||||||
exist, err := s.Exists(path)
|
exist, err := s.Exists(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -90,19 +87,13 @@ func loadState(s Storage, locked bool) (*game.Game, error) {
|
|||||||
if !exist {
|
if !exist {
|
||||||
return nil, NewGameNotInitializedError()
|
return nil, NewGameNotInitializedError()
|
||||||
}
|
}
|
||||||
if locked {
|
if err := s.Read(path, result); err != nil {
|
||||||
if err := s.Read(path, result); err != nil {
|
return nil, NewStorageError(err)
|
||||||
return nil, NewStorageError(err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := s.ReadSafe(path, result); err != nil {
|
|
||||||
return nil, NewStorageError(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return result, nil
|
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)
|
var result *game.GameMeta = new(game.GameMeta)
|
||||||
path := metaPath
|
path := metaPath
|
||||||
exist, err := s.Exists(path)
|
exist, err := s.Exists(path)
|
||||||
@@ -112,13 +103,13 @@ func loadMeta(s Storage) (*game.GameMeta, error) {
|
|||||||
if !exist {
|
if !exist {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
if err := s.ReadSafe(path, result); err != nil {
|
if err := s.Read(path, result); err != nil {
|
||||||
return nil, NewStorageError(err)
|
return nil, NewStorageError(err)
|
||||||
}
|
}
|
||||||
return result, nil
|
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)
|
var result *game.GameMeta = new(game.GameMeta)
|
||||||
path := fmt.Sprintf("%s/%s", TurnDir(turn), metaPath)
|
path := fmt.Sprintf("%s/%s", TurnDir(turn), metaPath)
|
||||||
exist, err := s.Exists(path)
|
exist, err := s.Exists(path)
|
||||||
@@ -128,13 +119,13 @@ func loadTurnMeta(s Storage, turn uint) (*game.GameMeta, error) {
|
|||||||
if !exist {
|
if !exist {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
if err := s.ReadSafe(path, result); err != nil {
|
if err := s.Read(path, result); err != nil {
|
||||||
return nil, NewStorageError(err)
|
return nil, NewStorageError(err)
|
||||||
}
|
}
|
||||||
return result, nil
|
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
|
// save turn's meta
|
||||||
path := fmt.Sprintf("%s/%s", TurnDir(turn), metaPath)
|
path := fmt.Sprintf("%s/%s", TurnDir(turn), metaPath)
|
||||||
if err := s.Write(path, gm); err != nil {
|
if err := s.Write(path, gm); err != nil {
|
||||||
@@ -148,7 +139,7 @@ func saveMeta(s Storage, turn uint, gm *game.GameMeta) error {
|
|||||||
return nil
|
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)
|
meta, err := loadTurnMeta(r.s, turn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
@@ -164,7 +155,7 @@ func (r *repo) LoadBattle(turn uint, id uuid.UUID) (*report.BattleReport, bool,
|
|||||||
return result, true, nil
|
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)
|
meta, err := loadMeta(r.s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -177,7 +168,7 @@ func (r *repo) SaveBattle(turn uint, b *report.BattleReport, m *game.BattleMeta)
|
|||||||
return saveMeta(r.s, turn, meta)
|
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())
|
path := fmt.Sprintf("%s/battle/%s.json", TurnDir(turn), b.ID.String())
|
||||||
exist, err := s.Exists(path)
|
exist, err := s.Exists(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -192,7 +183,7 @@ func saveBattle(s Storage, turn uint, b *report.BattleReport) error {
|
|||||||
return nil
|
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())
|
path := fmt.Sprintf("%s/battle/%s.json", TurnDir(turn), id.String())
|
||||||
exist, err := s.Exists(path)
|
exist, err := s.Exists(path)
|
||||||
if err != nil {
|
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))
|
return nil, NewStateError(fmt.Sprintf("battle %v for turn %d never was saved", id, turn))
|
||||||
}
|
}
|
||||||
result := new(report.BattleReport)
|
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 nil, NewStorageError(err)
|
||||||
}
|
}
|
||||||
return result, nil
|
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)
|
meta, err := loadMeta(r.s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -219,11 +210,11 @@ func (r *repo) SaveBombings(turn uint, b []*game.Bombing) error {
|
|||||||
return saveMeta(r.s, turn, meta)
|
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)
|
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)
|
path := ReportDir(t, v.RaceID)
|
||||||
if err := s.Write(path, v); err != nil {
|
if err := s.Write(path, v); err != nil {
|
||||||
return NewStorageError(err)
|
return NewStorageError(err)
|
||||||
@@ -231,11 +222,11 @@ func saveReport(s Storage, t uint, v *report.Report) error {
|
|||||||
return nil
|
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)
|
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)
|
path := ReportDir(turn, id)
|
||||||
result := new(report.Report)
|
result := new(report.Report)
|
||||||
exist, err := s.Exists(path)
|
exist, err := s.Exists(path)
|
||||||
@@ -245,29 +236,29 @@ func loadReport(s Storage, turn uint, id uuid.UUID) (*report.Report, error) {
|
|||||||
if !exist {
|
if !exist {
|
||||||
return nil, NewReportNotFoundError()
|
return nil, NewReportNotFoundError()
|
||||||
}
|
}
|
||||||
if err := s.ReadSafe(path, result); err != nil {
|
if err := s.Read(path, result); err != nil {
|
||||||
return nil, NewStorageError(err)
|
return nil, NewStorageError(err)
|
||||||
}
|
}
|
||||||
return result, nil
|
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)
|
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)
|
path := OrderDir(t, id)
|
||||||
if err := s.WriteSafe(path, o); err != nil {
|
if err := s.Write(path, o); err != nil {
|
||||||
return NewStorageError(err)
|
return NewStorageError(err)
|
||||||
}
|
}
|
||||||
return nil
|
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)
|
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)
|
path := OrderDir(t, id)
|
||||||
|
|
||||||
exist, err := s.Exists(path)
|
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)
|
stored := new(storedOrder)
|
||||||
if err := s.ReadSafe(path, stored); err != nil {
|
if err := s.Read(path, stored); err != nil {
|
||||||
return nil, false, NewStorageError(err)
|
return nil, false, NewStorageError(err)
|
||||||
}
|
}
|
||||||
// An empty stored batch is a valid state — the player either
|
// An empty stored batch is a valid state — the player either
|
||||||
|
|||||||
+10
-56
@@ -1,9 +1,6 @@
|
|||||||
package repo
|
package repo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
e "galaxy/error"
|
e "galaxy/error"
|
||||||
|
|
||||||
"galaxy/game/internal/repo/fs"
|
"galaxy/game/internal/repo/fs"
|
||||||
@@ -25,66 +22,23 @@ func NewStateError(msg string) error {
|
|||||||
return e.NewGameStateError(msg)
|
return e.NewGameStateError(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Storage interface {
|
// Repo persists game state through a file-backed FS. Reads and writes are
|
||||||
Lock() (func() error, error)
|
// atomic and lock-free: Write swaps a fully written file into place with
|
||||||
Exists(string) (bool, error)
|
// rename, so Read never observes a partial file. Serialising concurrent
|
||||||
Write(string, encoding.BinaryMarshaler) error
|
// writers to the same state file is the caller's concern (the engine does it
|
||||||
WriteSafe(string, encoding.BinaryMarshaler) error
|
// at the router, see LimitMiddleware).
|
||||||
Read(string, encoding.BinaryUnmarshaler) error
|
type Repo struct {
|
||||||
ReadSafe(string, encoding.BinaryUnmarshaler) error
|
s *fs.FS
|
||||||
}
|
}
|
||||||
|
|
||||||
type repo struct {
|
func NewRepo(s *fs.FS) (*Repo, error) {
|
||||||
s Storage
|
return &Repo{s: s}, nil
|
||||||
release func() error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRepo(s Storage) (*repo, error) {
|
func NewFileRepo(path string) (*Repo, error) {
|
||||||
r := &repo{
|
|
||||||
s: s,
|
|
||||||
}
|
|
||||||
return r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewFileRepo(path string) (*repo, error) {
|
|
||||||
s, err := fs.NewFileStorage(path)
|
s, err := fs.NewFileStorage(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return NewRepo(s)
|
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 (
|
import (
|
||||||
"galaxy/model/order"
|
"galaxy/model/order"
|
||||||
|
|
||||||
|
"galaxy/game/internal/repo/fs"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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)
|
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)
|
return saveOrder(s, t, id, o)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func TestSaveOrder(t *testing.T) {
|
|||||||
LoadOrderTest(t, s, root, turn, id, o)
|
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)
|
o, ok, err := repo.LoadOrder_T(s, turn, id)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, ok)
|
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"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func BanishHandler(c *gin.Context, executor CommandExecutor) {
|
func BanishHandler(c *gin.Context, engine Engine) {
|
||||||
var req rest.BanishRequest
|
var req rest.BanishRequest
|
||||||
if errorResponse(c, c.ShouldBindJSON(&req)) {
|
if errorResponse(c, c.ShouldBindJSON(&req)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if errorResponse(c, executor.BanishRace(req.RaceName)) {
|
if errorResponse(c, engine.BanishRace(req.RaceName)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func BattleHandler(c *gin.Context, executor CommandExecutor) {
|
func BattleHandler(c *gin.Context, engine Engine) {
|
||||||
turn := c.Param("turn")
|
turn := c.Param("turn")
|
||||||
t, err := strconv.Atoi(turn)
|
t, err := strconv.Atoi(turn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -25,7 +25,7 @@ func BattleHandler(c *gin.Context, executor CommandExecutor) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r, exists, err := executor.FetchBattle(uint(t), battleID)
|
r, exists, err := engine.FetchBattle(uint(t), battleID)
|
||||||
if errorResponse(c, err) {
|
if errorResponse(c, err) {
|
||||||
return
|
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"
|
e "galaxy/error"
|
||||||
|
|
||||||
"galaxy/game/internal/controller"
|
|
||||||
"galaxy/game/internal/model/game"
|
"galaxy/game/internal/model/game"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -20,25 +19,22 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CommandExecutor interface {
|
// Engine is the set of operations the HTTP handlers invoke on the game engine.
|
||||||
GenerateGame(gameID uuid.UUID, races []string) (rest.StateResponse, error)
|
// Its sole production implementation is *controller.Service; the interface
|
||||||
GenerateTurn() (rest.StateResponse, error)
|
// exists so the transport layer can be exercised against a lightweight fake
|
||||||
GameState() (rest.StateResponse, error)
|
// without standing up real storage. Methods return domain types — handlers own
|
||||||
BanishRace(string) error
|
// 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)
|
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)
|
ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error)
|
||||||
FetchOrder(actor string, turn uint) (*order.UserGamesOrder, bool, error)
|
FetchOrder(actor string, turn uint) (*order.UserGamesOrder, bool, error)
|
||||||
FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, 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
|
// ResolveStoragePath returns the engine storage path resolved from
|
||||||
// STORAGE_PATH (preferred, historical name) or GAME_STATE_PATH (canonical
|
// STORAGE_PATH (preferred, historical name) or GAME_STATE_PATH (canonical
|
||||||
// name written by Runtime Manager). It returns an error when neither
|
// 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")
|
return "", errors.New("storage path is not set: provide STORAGE_PATH or GAME_STATE_PATH")
|
||||||
}
|
}
|
||||||
|
|
||||||
func initConfig() controller.Configurer {
|
// stateResponse projects the engine's domain game.State into the REST
|
||||||
return func(p *controller.Param) {
|
// StateResponse wire shape.
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
func stateResponse(s game.State) rest.StateResponse {
|
func stateResponse(s game.State) rest.StateResponse {
|
||||||
result := &rest.StateResponse{
|
result := &rest.StateResponse{
|
||||||
ID: s.ID,
|
ID: s.ID,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func InitHandler(c *gin.Context, executor CommandExecutor) {
|
func InitHandler(c *gin.Context, engine Engine) {
|
||||||
var init rest.InitRequest
|
var init rest.InitRequest
|
||||||
if errorResponse(c, c.ShouldBindJSON(&init)) {
|
if errorResponse(c, c.ShouldBindJSON(&init)) {
|
||||||
return
|
return
|
||||||
@@ -26,7 +26,7 @@ func InitHandler(c *gin.Context, executor CommandExecutor) {
|
|||||||
races[i] = init.Races[i].RaceName
|
races[i] = init.Races[i].RaceName
|
||||||
}
|
}
|
||||||
|
|
||||||
s, err := executor.GenerateGame(init.GameID, races)
|
s, err := engine.GenerateGame(init.GameID, races)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, controller.ErrGameAlreadyInit) {
|
if errors.Is(err, controller.ErrGameAlreadyInit) {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
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"
|
"galaxy/game/internal/repo"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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
|
var cmd rest.Command
|
||||||
if errorResponse(c, c.ShouldBindJSON(&cmd)) {
|
if errorResponse(c, c.ShouldBindJSON(&cmd)) {
|
||||||
return
|
return
|
||||||
@@ -30,7 +32,7 @@ func PutOrderHandler(c *gin.Context, executor CommandExecutor) {
|
|||||||
commands[i] = command
|
commands[i] = command
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := executor.ValidateOrder(cmd.Actor, commands...)
|
result, err := engine.ValidateOrder(cmd.Actor, commands...)
|
||||||
if errorResponse(c, err) {
|
if errorResponse(c, err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -43,7 +45,7 @@ type orderParam struct {
|
|||||||
Turn int `form:"turn" binding:"gte=0"`
|
Turn int `form:"turn" binding:"gte=0"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetOrderHandler(c *gin.Context, executor CommandExecutor) {
|
func GetOrderHandler(c *gin.Context, engine Engine) {
|
||||||
p := &orderParam{}
|
p := &orderParam{}
|
||||||
// ShouldBindQuery surfaces both validator failures and strconv parse
|
// ShouldBindQuery surfaces both validator failures and strconv parse
|
||||||
// errors; both are client-side faults, so 400 is the correct mapping.
|
// errors; both are client-side faults, so 400 is the correct mapping.
|
||||||
@@ -52,7 +54,7 @@ func GetOrderHandler(c *gin.Context, executor CommandExecutor) {
|
|||||||
return
|
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) {
|
if errorResponse(c, err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -64,3 +66,15 @@ func GetOrderHandler(c *gin.Context, executor CommandExecutor) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, o)
|
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"`
|
Turn int `form:"turn" binding:"gte=0"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReportHandler(c *gin.Context, executor CommandExecutor) {
|
func ReportHandler(c *gin.Context, engine Engine) {
|
||||||
p := &reportParam{}
|
p := &reportParam{}
|
||||||
err := c.ShouldBindQuery(p)
|
err := c.ShouldBindQuery(p)
|
||||||
if errorResponse(c, err) {
|
if errorResponse(c, err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
r, err := executor.LoadReport(p.Player, uint(p.Turn))
|
r, err := engine.LoadReport(p.Player, uint(p.Turn))
|
||||||
if errorResponse(c, err) {
|
if errorResponse(c, err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func StatusHandler(c *gin.Context, executor CommandExecutor) {
|
func StatusHandler(c *gin.Context, engine Engine) {
|
||||||
state, err := executor.GameState()
|
state, err := engine.GameState()
|
||||||
|
|
||||||
if errorResponse(c, err) {
|
if errorResponse(c, err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, state)
|
c.JSON(http.StatusOK, stateResponse(state))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TurnHandler(c *gin.Context, executor CommandExecutor) {
|
func TurnHandler(c *gin.Context, engine Engine) {
|
||||||
state, err := executor.GenerateTurn()
|
state, err := engine.GenerateTurn()
|
||||||
|
|
||||||
if errorResponse(c, err) {
|
if errorResponse(c, err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, state)
|
c.JSON(http.StatusOK, stateResponse(state))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"galaxy/game/internal/controller"
|
"galaxy/util"
|
||||||
|
|
||||||
"galaxy/game/internal/router"
|
"galaxy/game/internal/router"
|
||||||
"galaxy/game/internal/router/handler"
|
"galaxy/game/internal/router/handler"
|
||||||
|
|
||||||
@@ -15,9 +16,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestHealthzReturnsOKWithoutInit(t *testing.T) {
|
func TestHealthzReturnsOKWithoutInit(t *testing.T) {
|
||||||
r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) {
|
root, cleanup := util.CreateWorkDir(t)
|
||||||
p.StoragePath = ""
|
defer cleanup()
|
||||||
}))
|
|
||||||
|
r := router.SetupRouter(newService(t, root))
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/healthz", nil)
|
req, _ := http.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ import (
|
|||||||
|
|
||||||
"galaxy/util"
|
"galaxy/util"
|
||||||
|
|
||||||
"galaxy/game/internal/controller"
|
|
||||||
"galaxy/game/internal/router"
|
"galaxy/game/internal/router"
|
||||||
"galaxy/game/internal/router/handler"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -22,7 +20,7 @@ func TestInit(t *testing.T) {
|
|||||||
root, cleanup := util.CreateWorkDir(t)
|
root, cleanup := util.CreateWorkDir(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root }))
|
r := router.SetupRouter(newService(t, root))
|
||||||
|
|
||||||
payload := generateInitRequest(10)
|
payload := generateInitRequest(10)
|
||||||
|
|
||||||
@@ -51,7 +49,7 @@ func TestInitRejectsNilUUID(t *testing.T) {
|
|||||||
root, cleanup := util.CreateWorkDir(t)
|
root, cleanup := util.CreateWorkDir(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root }))
|
r := router.SetupRouter(newService(t, root))
|
||||||
|
|
||||||
payload := generateInitRequest(10)
|
payload := generateInitRequest(10)
|
||||||
payload.GameID = uuid.Nil
|
payload.GameID = uuid.Nil
|
||||||
@@ -67,7 +65,7 @@ func TestInitRejectsExistingGameWithDifferentID(t *testing.T) {
|
|||||||
root, cleanup := util.CreateWorkDir(t)
|
root, cleanup := util.CreateWorkDir(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root }))
|
r := router.SetupRouter(newService(t, root))
|
||||||
|
|
||||||
first := generateInitRequest(10)
|
first := generateInitRequest(10)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -2,27 +2,31 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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 {
|
func LimitMiddleware(limit int) gin.HandlerFunc {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
panic("limit must be greater than 0")
|
panic("limit must be greater than 0")
|
||||||
}
|
}
|
||||||
semaphore := make(chan bool, limit)
|
semaphore := make(chan struct{}, limit)
|
||||||
t := time.NewTimer(time.Millisecond * 100)
|
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
t.Reset(time.Millisecond * 100)
|
|
||||||
select {
|
select {
|
||||||
case semaphore <- true:
|
case semaphore <- struct{}{}:
|
||||||
|
defer func() { <-semaphore }()
|
||||||
c.Next()
|
c.Next()
|
||||||
<-semaphore
|
case <-c.Request.Context().Done():
|
||||||
case <-t.C:
|
c.AbortWithStatus(http.StatusServiceUnavailable)
|
||||||
c.Status(http.StatusGatewayTimeout)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import (
|
|||||||
|
|
||||||
"galaxy/model/rest"
|
"galaxy/model/rest"
|
||||||
|
|
||||||
"galaxy/game/internal/controller"
|
|
||||||
"galaxy/game/internal/router"
|
"galaxy/game/internal/router"
|
||||||
"galaxy/game/internal/router/handler"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -19,7 +17,7 @@ import (
|
|||||||
func TestGetReport(t *testing.T) {
|
func TestGetReport(t *testing.T) {
|
||||||
root := t.ArtifactDir()
|
root := t.ArtifactDir()
|
||||||
|
|
||||||
r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root }))
|
r := router.SetupRouter(newService(t, root))
|
||||||
|
|
||||||
payload := generateInitRequest(10)
|
payload := generateInitRequest(10)
|
||||||
|
|
||||||
|
|||||||
@@ -18,24 +18,20 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Router struct {
|
type Router struct {
|
||||||
r *gin.Engine
|
r *gin.Engine
|
||||||
executor handler.CommandExecutor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Router) Run() error {
|
func (r Router) Run() error {
|
||||||
return r.r.Run()
|
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)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
return NewRouterExecutor(handler.NewDefaultExecutor())
|
return Router{r: setupRouter(engine)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRouterExecutor(executor handler.CommandExecutor) Router {
|
func setupRouter(engine handler.Engine) *gin.Engine {
|
||||||
return Router{r: setupRouter(executor)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupRouter(executor handler.CommandExecutor) *gin.Engine {
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
|
|
||||||
// Logger middleware will write the logs to gin.DefaultWriter even if you set with GIN_MODE=release.
|
// 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")
|
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 := groupV1.Group("/admin")
|
||||||
groupAdmin.GET("/status", func(ctx *gin.Context) { handler.StatusHandler(ctx, executor) })
|
groupAdmin.GET("/status", func(ctx *gin.Context) { handler.StatusHandler(ctx, engine) })
|
||||||
groupAdmin.POST("/init", func(ctx *gin.Context) { handler.InitHandler(ctx, executor) })
|
groupAdmin.POST("/init", stateMutationLimit, func(ctx *gin.Context) { handler.InitHandler(ctx, engine) })
|
||||||
groupAdmin.PUT("/turn", func(ctx *gin.Context) { handler.TurnHandler(ctx, executor) })
|
groupAdmin.PUT("/turn", stateMutationLimit, func(ctx *gin.Context) { handler.TurnHandler(ctx, engine) })
|
||||||
groupAdmin.POST("/race/banish", func(ctx *gin.Context) { handler.BanishHandler(ctx, executor) })
|
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.GET("/report", func(ctx *gin.Context) { handler.ReportHandler(ctx, engine) })
|
||||||
groupV1.PUT("/order", func(ctx *gin.Context) { handler.PutOrderHandler(ctx, executor) })
|
groupV1.PUT("/order", func(ctx *gin.Context) { handler.PutOrderHandler(ctx, engine) })
|
||||||
groupV1.GET("/order", func(ctx *gin.Context) { handler.GetOrderHandler(ctx, executor) })
|
groupV1.GET("/order", func(ctx *gin.Context) { handler.GetOrderHandler(ctx, engine) })
|
||||||
groupV1.GET("/battle/:turn/:uuid", func(ctx *gin.Context) { handler.BattleHandler(ctx, executor) })
|
groupV1.GET("/battle/:turn/:uuid", func(ctx *gin.Context) { handler.BattleHandler(ctx, engine) })
|
||||||
|
|
||||||
// /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) })
|
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetupRouter(e handler.CommandExecutor) *gin.Engine {
|
func SetupRouter(e handler.Engine) *gin.Engine {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
return setupRouter(e)
|
return setupRouter(e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package router_test
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
"galaxy/model/order"
|
"galaxy/model/order"
|
||||||
"galaxy/model/report"
|
"galaxy/model/report"
|
||||||
"galaxy/model/rest"
|
|
||||||
|
|
||||||
|
"galaxy/game/internal/controller"
|
||||||
|
"galaxy/game/internal/model/game"
|
||||||
"galaxy/game/internal/router"
|
"galaxy/game/internal/router"
|
||||||
"galaxy/game/internal/router/handler"
|
"galaxy/game/internal/router/handler"
|
||||||
|
|
||||||
@@ -19,7 +21,6 @@ var (
|
|||||||
commandNoErrorsStatus = http.StatusAccepted
|
commandNoErrorsStatus = http.StatusAccepted
|
||||||
commandDefaultActor = "Gorlum"
|
commandDefaultActor = "Gorlum"
|
||||||
apiCommandMethod = "PUT"
|
apiCommandMethod = "PUT"
|
||||||
apiCommandPath = "/api/v1/command"
|
|
||||||
apiOrderPath = "/api/v1/order"
|
apiOrderPath = "/api/v1/order"
|
||||||
validId1 = id()
|
validId1 = id()
|
||||||
validId2 = 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
|
return e.FetchBattleResult, e.FetchBattleOK, e.FetchBattleErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *dummyExecutor) Execute(command ...handler.Command) error {
|
func (e *dummyExecutor) GenerateGame(gameID uuid.UUID, races []string) (game.State, error) {
|
||||||
e.CommandsExecuted = len(command)
|
return game.State{ID: gameID}, nil
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *dummyExecutor) GenerateGame(gameID uuid.UUID, races []string) (rest.StateResponse, error) {
|
func (e *dummyExecutor) GenerateTurn() (game.State, error) {
|
||||||
return rest.StateResponse{ID: gameID}, nil
|
return game.State{}, nil
|
||||||
}
|
|
||||||
|
|
||||||
func (e *dummyExecutor) GenerateTurn() (rest.StateResponse, error) {
|
|
||||||
return rest.StateResponse{}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *dummyExecutor) BanishRace(raceName string) error {
|
func (e *dummyExecutor) BanishRace(raceName string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *dummyExecutor) GameState() (rest.StateResponse, error) {
|
func (e *dummyExecutor) GameState() (game.State, error) {
|
||||||
return rest.StateResponse{}, nil
|
return game.State{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *dummyExecutor) LoadReport(actor string, turn uint) (*report.Report, error) {
|
func (e *dummyExecutor) LoadReport(actor string, turn uint) (*report.Report, error) {
|
||||||
@@ -110,14 +106,25 @@ func setupRouter() *gin.Engine {
|
|||||||
return setupRouterExecutor(newExecutor())
|
return setupRouterExecutor(newExecutor())
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupRouterExecutor(e handler.CommandExecutor) *gin.Engine {
|
func setupRouterExecutor(e handler.Engine) *gin.Engine {
|
||||||
return router.SetupRouter(e)
|
return router.SetupRouter(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newExecutor() handler.CommandExecutor {
|
func newExecutor() handler.Engine {
|
||||||
return &dummyExecutor{}
|
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 {
|
func encodeCommand(cmd any) json.RawMessage {
|
||||||
v, err := json.Marshal(cmd)
|
v, err := json.Marshal(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package router_test
|
package router_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"galaxy/model/rest"
|
"galaxy/model/rest"
|
||||||
|
|
||||||
@@ -38,6 +40,92 @@ func TestLimitConnections(t *testing.T) {
|
|||||||
wg.Wait()
|
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 {
|
func asBody(body any) *strings.Reader {
|
||||||
commandJson, _ := json.Marshal(body)
|
commandJson, _ := json.Marshal(body)
|
||||||
return strings.NewReader(string(commandJson))
|
return strings.NewReader(string(commandJson))
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ import (
|
|||||||
|
|
||||||
"galaxy/util"
|
"galaxy/util"
|
||||||
|
|
||||||
"galaxy/game/internal/controller"
|
|
||||||
"galaxy/game/internal/router"
|
"galaxy/game/internal/router"
|
||||||
"galaxy/game/internal/router/handler"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -22,7 +20,7 @@ func TestGetStatus(t *testing.T) {
|
|||||||
root, cleanup := util.CreateWorkDir(t)
|
root, cleanup := util.CreateWorkDir(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root }))
|
r := router.SetupRouter(newService(t, root))
|
||||||
|
|
||||||
payload := generateInitRequest(10)
|
payload := generateInitRequest(10)
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ import (
|
|||||||
|
|
||||||
"galaxy/util"
|
"galaxy/util"
|
||||||
|
|
||||||
"galaxy/game/internal/controller"
|
|
||||||
"galaxy/game/internal/router"
|
"galaxy/game/internal/router"
|
||||||
"galaxy/game/internal/router/handler"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -22,7 +20,7 @@ func TestGetTurn(t *testing.T) {
|
|||||||
root, cleanup := util.CreateWorkDir(t)
|
root, cleanup := util.CreateWorkDir(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root }))
|
r := router.SetupRouter(newService(t, root))
|
||||||
|
|
||||||
// create game
|
// create game
|
||||||
|
|
||||||
|
|||||||
+5
-31
@@ -7,13 +7,14 @@ info:
|
|||||||
|
|
||||||
The service hosts a single game instance and exposes endpoints for game
|
The service hosts a single game instance and exposes endpoints for game
|
||||||
initialization, turn advancement, game-state queries, player reports, and
|
initialization, turn advancement, game-state queries, player reports, and
|
||||||
batched player command execution.
|
player order submission.
|
||||||
|
|
||||||
Transport rules:
|
Transport rules:
|
||||||
- request bodies are JSON
|
- request bodies are JSON
|
||||||
- `PUT /api/v1/command` is rate-limited to one concurrent execution;
|
- operations that mutate the persisted game state are serialised engine-wide
|
||||||
requests that cannot acquire the execution slot within 100 ms receive
|
to one at a time; such a request blocks until the in-flight mutation
|
||||||
`504 Gateway Timeout`
|
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
|
- `501 Not Implemented` is returned without a body when the game has not
|
||||||
been initialized
|
been initialized
|
||||||
- request-binding validation errors return `400` with `{"error": "message"}`
|
- request-binding validation errors return `400` with `{"error": "message"}`
|
||||||
@@ -141,33 +142,6 @@ paths:
|
|||||||
$ref: "#/components/responses/ValidationError"
|
$ref: "#/components/responses/ValidationError"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$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:
|
/api/v1/order:
|
||||||
put:
|
put:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
@@ -109,12 +109,6 @@ func TestGameOpenAPISpecFreezesEmptyResponses(t *testing.T) {
|
|||||||
method string
|
method string
|
||||||
status int
|
status int
|
||||||
}{
|
}{
|
||||||
{
|
|
||||||
name: "command accepted",
|
|
||||||
path: "/api/v1/command",
|
|
||||||
method: http.MethodPut,
|
|
||||||
status: http.StatusAccepted,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "get order no content",
|
name: "get order no content",
|
||||||
path: "/api/v1/order",
|
path: "/api/v1/order",
|
||||||
@@ -273,14 +267,8 @@ func TestGameOpenAPISpecFreezesCommandRequest(t *testing.T) {
|
|||||||
|
|
||||||
doc := loadOpenAPISpec(t)
|
doc := loadOpenAPISpec(t)
|
||||||
|
|
||||||
for _, path := range []string{"/api/v1/command", "/api/v1/order"} {
|
operation := getOpenAPIOperation(t, doc, "/api/v1/order", http.MethodPut)
|
||||||
t.Run(path, func(t *testing.T) {
|
assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/CommandRequest", "/api/v1/order command request schema")
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
operation := getOpenAPIOperation(t, doc, path, http.MethodPut)
|
|
||||||
assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/CommandRequest", path+" command request schema")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
schema := componentSchemaRef(t, doc, "CommandRequest")
|
schema := componentSchemaRef(t, doc, "CommandRequest")
|
||||||
assertRequiredFields(t, schema, "actor", "cmd")
|
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.list`
|
||||||
- `user.sessions.revoke`
|
- `user.sessions.revoke`
|
||||||
- `user.sessions.revoke_all`
|
- `user.sessions.revoke_all`
|
||||||
- `user.games.command`
|
|
||||||
- `user.games.order`
|
- `user.games.order`
|
||||||
- `user.games.report`
|
- `user.games.report`
|
||||||
- `lobby.my.games.list`
|
- `lobby.my.games.list`
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestParityWithUICoreCanonicalBytes(t *testing.T) {
|
|||||||
gatewayFields := authn.RequestSigningFields{
|
gatewayFields := authn.RequestSigningFields{
|
||||||
ProtocolVersion: "v1",
|
ProtocolVersion: "v1",
|
||||||
DeviceSessionID: "device-session-parity",
|
DeviceSessionID: "device-session-parity",
|
||||||
MessageType: "user.games.command",
|
MessageType: "user.games.order",
|
||||||
TimestampMS: 1_700_000_000_000,
|
TimestampMS: 1_700_000_000_000,
|
||||||
RequestID: "request-parity",
|
RequestID: "request-parity",
|
||||||
PayloadHash: sha256Of([]byte("payload")),
|
PayloadHash: sha256Of([]byte("payload")),
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ExecuteGameCommand routes one authenticated `user.games.*` command
|
// ExecuteGameCommand routes one authenticated `user.games.*` command
|
||||||
// into backend's `/api/v1/user/games/{game_id}/*` endpoints. Command
|
// into backend's `/api/v1/user/games/{game_id}/*` endpoints. Order
|
||||||
// and order requests transcode the typed FB-payload into the JSON
|
// requests transcode the typed FB-payload into the JSON shape the
|
||||||
// shape the engine expects (a `gamerest.Command` with empty actor —
|
// engine expects (a `gamerest.Command` with empty actor — backend
|
||||||
// backend rebinds the actor from the runtime player mapping). Report
|
// rebinds the actor from the runtime player mapping). Report requests
|
||||||
// requests transcode the response Report from JSON back to FB.
|
// transcode the response Report from JSON back to FB.
|
||||||
func (c *RESTClient) ExecuteGameCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
func (c *RESTClient) ExecuteGameCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||||
if c == nil || c.httpClient == nil {
|
if c == nil || c.httpClient == nil {
|
||||||
return downstream.UnaryResult{}, errors.New("backendclient: execute game command: nil client")
|
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 {
|
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:
|
case ordermodel.MessageTypeUserGamesOrder:
|
||||||
req, err := transcoder.PayloadToUserGamesOrder(command.PayloadBytes)
|
req, err := transcoder.PayloadToUserGamesOrder(command.PayloadBytes)
|
||||||
if err != nil {
|
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) {
|
func (c *RESTClient) executeUserGamesOrder(ctx context.Context, userID string, req *ordermodel.UserGamesOrder) (downstream.UnaryResult, error) {
|
||||||
if req.GameID == uuid.Nil {
|
if req.GameID == uuid.Nil {
|
||||||
return downstream.UnaryResult{}, errors.New("execute user.games.order: game_id must not be empty")
|
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
|
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`
|
// projectUserGamesOrderResponse decodes the engine's `PUT /api/v1/order`
|
||||||
// JSON body (forwarded by backend) and re-encodes it as a FlatBuffers
|
// JSON body (forwarded by backend) and re-encodes it as a FlatBuffers
|
||||||
// `UserGamesOrderResponse` envelope. The body carries per-command
|
// `UserGamesOrderResponse` envelope. The body carries per-command
|
||||||
|
|||||||
@@ -39,15 +39,15 @@ func LobbyRoutes(client *RESTClient) map[string]downstream.Client {
|
|||||||
target = lobbyCommandClient{rest: client}
|
target = lobbyCommandClient{rest: client}
|
||||||
}
|
}
|
||||||
return map[string]downstream.Client{
|
return map[string]downstream.Client{
|
||||||
lobbymodel.MessageTypeMyGamesList: target,
|
lobbymodel.MessageTypeMyGamesList: target,
|
||||||
lobbymodel.MessageTypePublicGamesList: target,
|
lobbymodel.MessageTypePublicGamesList: target,
|
||||||
lobbymodel.MessageTypeMyApplicationsList: target,
|
lobbymodel.MessageTypeMyApplicationsList: target,
|
||||||
lobbymodel.MessageTypeMyInvitesList: target,
|
lobbymodel.MessageTypeMyInvitesList: target,
|
||||||
lobbymodel.MessageTypeOpenEnrollment: target,
|
lobbymodel.MessageTypeOpenEnrollment: target,
|
||||||
lobbymodel.MessageTypeGameCreate: target,
|
lobbymodel.MessageTypeGameCreate: target,
|
||||||
lobbymodel.MessageTypeApplicationSubmit: target,
|
lobbymodel.MessageTypeApplicationSubmit: target,
|
||||||
lobbymodel.MessageTypeInviteRedeem: target,
|
lobbymodel.MessageTypeInviteRedeem: target,
|
||||||
lobbymodel.MessageTypeInviteDecline: target,
|
lobbymodel.MessageTypeInviteDecline: target,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,11 +61,10 @@ func GameRoutes(client *RESTClient) map[string]downstream.Client {
|
|||||||
target = gameCommandClient{rest: client}
|
target = gameCommandClient{rest: client}
|
||||||
}
|
}
|
||||||
return map[string]downstream.Client{
|
return map[string]downstream.Client{
|
||||||
ordermodel.MessageTypeUserGamesCommand: target,
|
ordermodel.MessageTypeUserGamesOrder: target,
|
||||||
ordermodel.MessageTypeUserGamesOrder: target,
|
ordermodel.MessageTypeUserGamesOrderGet: target,
|
||||||
ordermodel.MessageTypeUserGamesOrderGet: target,
|
reportmodel.MessageTypeUserGamesReport: target,
|
||||||
reportmodel.MessageTypeUserGamesReport: target,
|
reportmodel.MessageTypeUserGamesBattle: target,
|
||||||
reportmodel.MessageTypeUserGamesBattle: target,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ func TestRoutesCoverAllAuthenticatedMessageTypes(t *testing.T) {
|
|||||||
},
|
},
|
||||||
"game": {
|
"game": {
|
||||||
expected: []string{
|
expected: []string{
|
||||||
ordermodel.MessageTypeUserGamesCommand,
|
|
||||||
ordermodel.MessageTypeUserGamesOrder,
|
ordermodel.MessageTypeUserGamesOrder,
|
||||||
ordermodel.MessageTypeUserGamesOrderGet,
|
ordermodel.MessageTypeUserGamesOrderGet,
|
||||||
reportmodel.MessageTypeUserGamesReport,
|
reportmodel.MessageTypeUserGamesReport,
|
||||||
|
|||||||
@@ -10,12 +10,11 @@ import (
|
|||||||
"galaxy/integration/testenv"
|
"galaxy/integration/testenv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestEngineCommandProxy spins up a running game (10 enrolled
|
// TestEngineOrderProxy spins up a running game (10 enrolled pilots so
|
||||||
// pilots so engine init succeeds) and verifies that backend's
|
// engine init succeeds) and verifies that backend's user-side
|
||||||
// user-side `/api/v1/user/games/{id}/commands` proxy reaches the
|
// `/api/v1/user/games/{id}/orders` proxy reaches the engine and returns
|
||||||
// engine and returns its passthrough body without an internal-error
|
// its passthrough body without an internal-error response.
|
||||||
// response.
|
func TestEngineOrderProxy(t *testing.T) {
|
||||||
func TestEngineCommandProxy(t *testing.T) {
|
|
||||||
plat := testenv.Bootstrap(t, testenv.BootstrapOptions{})
|
plat := testenv.Bootstrap(t, testenv.BootstrapOptions{})
|
||||||
testenv.EnsureGameImage(t)
|
testenv.EnsureGameImage(t)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
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)
|
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)
|
testenv.PromoteToPaid(t, ctx, admin, plat, owner)
|
||||||
ownerID, err := owner.LookupUserID(ctx, plat)
|
ownerID, err := owner.LookupUserID(ctx, plat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -37,7 +36,7 @@ func TestEngineCommandProxy(t *testing.T) {
|
|||||||
ownerHTTP := testenv.NewBackendUserClient(plat.Backend.HTTPURL, ownerID)
|
ownerHTTP := testenv.NewBackendUserClient(plat.Backend.HTTPURL, ownerID)
|
||||||
|
|
||||||
gameBody := map[string]any{
|
gameBody := map[string]any{
|
||||||
"game_name": "Engine Command Proxy",
|
"game_name": "Engine Order Proxy",
|
||||||
"visibility": "private",
|
"visibility": "private",
|
||||||
"min_players": 10,
|
"min_players": 10,
|
||||||
"max_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 {
|
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)
|
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 {
|
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)
|
t.Fatalf("force-start: %v %d", err, resp.StatusCode)
|
||||||
@@ -81,17 +80,17 @@ func TestEngineCommandProxy(t *testing.T) {
|
|||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pilot 1 sends a command. Backend forwards to the engine; the
|
// Pilot 1 submits an (empty) order. Backend forwards to the engine;
|
||||||
// pass-through body comes back unchanged. We accept any status
|
// the pass-through body comes back unchanged. We accept any status
|
||||||
// the engine produces (200, 4xx) — what matters is that backend
|
// the engine produces (200, 4xx) — what matters is that backend did
|
||||||
// did not surface an internal error of its own.
|
// not surface an internal error of its own.
|
||||||
cmdBody := map[string]any{"actions": []map[string]any{}}
|
orderBody := map[string]any{"cmd": []map[string]any{}}
|
||||||
raw, resp, err = pilots[0].HTTP.Do(ctx, http.MethodPost, "/api/v1/user/games/"+game.GameID+"/commands", cmdBody)
|
raw, resp, err = pilots[0].HTTP.Do(ctx, http.MethodPost, "/api/v1/user/games/"+game.GameID+"/orders", orderBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("commands proxy: %v", err)
|
t.Fatalf("orders proxy: %v", err)
|
||||||
}
|
}
|
||||||
if resp.StatusCode == http.StatusInternalServerError || resp.StatusCode == http.StatusBadGateway {
|
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.
|
// 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
|
// Game-state runtime rejections that depend on the current state
|
||||||
// snapshot: entity-not-found, not-owned, in-use, ships-busy,
|
// snapshot: entity-not-found, not-owned, in-use, ships-busy,
|
||||||
// insufficient resources, send/upgrade/cargo dependencies. These
|
// insufficient resources, send/upgrade/cargo dependencies. These
|
||||||
// surface as per-command `cmdErrorCode` on PUT /api/v1/order
|
// surface as per-command `cmdErrorCode` on PUT /api/v1/order.
|
||||||
// (and only escape as HTTP 400 from PUT /api/v1/command).
|
|
||||||
//
|
//
|
||||||
// Code 0 represents "applied without error" and is reserved as the
|
// Code 0 represents "applied without error" and is reserved as the
|
||||||
// successful per-command outcome on CommandMeta.Result. Code -1
|
// 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 /
|
// IsGameStateCode reports whether code belongs to the game-state /
|
||||||
// per-command rejection shelf (3xxx). On PUT /api/v1/order these are
|
// per-command rejection shelf (3xxx). On PUT /api/v1/order these are
|
||||||
// recorded into CommandMeta.CmdErrCode; on PUT /api/v1/command they
|
// recorded into CommandMeta.CmdErrCode.
|
||||||
// map to HTTP 400.
|
|
||||||
func IsGameStateCode(code int) bool { return code >= 3000 && code < 4000 }
|
func IsGameStateCode(code int) bool { return code >= 3000 && code < 4000 }
|
||||||
|
|
||||||
func GenericErrorText(code int) string {
|
func GenericErrorText(code int) string {
|
||||||
|
|||||||
@@ -6,12 +6,6 @@ import (
|
|||||||
"github.com/google/uuid"
|
"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
|
// MessageTypeUserGamesOrder is the authenticated gateway message type
|
||||||
// used to validate / store a batch of in-game orders through
|
// used to validate / store a batch of in-game orders through
|
||||||
// `POST /api/v1/user/games/{game_id}/orders`. The signed payload is a
|
// `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`.
|
// signed payload is a FlatBuffers `order.UserGamesOrderGet`.
|
||||||
const MessageTypeUserGamesOrderGet = "user.games.order.get"
|
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.
|
// UserGamesOrder is the typed payload of MessageTypeUserGamesOrder.
|
||||||
// Mirrors `UserGamesCommand` plus an `UpdatedAt` field that lets the
|
// `GameID` selects the running engine container; `Commands` is the
|
||||||
// engine reject stale order submissions.
|
// 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 {
|
type UserGamesOrder struct {
|
||||||
// GameID identifies the running game for this batch.
|
// GameID identifies the running game for this batch.
|
||||||
GameID uuid.UUID `json:"game_id"`
|
GameID uuid.UUID `json:"game_id"`
|
||||||
|
|||||||
@@ -4,13 +4,10 @@ import "encoding/json"
|
|||||||
|
|
||||||
type Command struct {
|
type Command struct {
|
||||||
Actor string `json:"actor" binding:"notblank"`
|
Actor string `json:"actor" binding:"notblank"`
|
||||||
// Commands carries the engine-bound payload for either the
|
// Commands carries the engine-bound payload for the order
|
||||||
// command (`PUT /api/v1/command`, immediate) or the order
|
// (`PUT /api/v1/order`, validate-and-store) path. An empty array
|
||||||
// (`PUT /api/v1/order`, validate-and-store) path. The order
|
// means "the player has no orders for this turn" and is stored
|
||||||
// path treats an empty array as "the player has no orders for
|
// as-is.
|
||||||
// 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 []json.RawMessage `json:"cmd"`
|
Commands []json.RawMessage `json:"cmd"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -201,31 +201,15 @@ table CommandItem {
|
|||||||
cmd_error_message: string;
|
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
|
// UserGamesOrder is the signed-gRPC request payload for
|
||||||
// `MessageTypeUserGamesOrder`. Identical to UserGamesCommand but
|
// `MessageTypeUserGamesOrder`. game_id selects the target running game;
|
||||||
// carries `updated_at` so the order-validate path can reject stale
|
// `updated_at` lets the order-validate path reject stale submissions.
|
||||||
// submissions.
|
|
||||||
table UserGamesOrder {
|
table UserGamesOrder {
|
||||||
game_id: common.UUID (required);
|
game_id: common.UUID (required);
|
||||||
updated_at: int64;
|
updated_at: int64;
|
||||||
commands: [CommandItem];
|
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`
|
// UserGamesOrderResponse mirrors the engine's `PUT /api/v1/order`
|
||||||
// success body: it echoes the stored order back to the caller with
|
// success body: it echoes the stored order back to the caller with
|
||||||
// the engine-assigned `updated_at` timestamp and per-command
|
// 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
|
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
|
// UserGamesOrderToPayload converts model.UserGamesOrder to FlatBuffers
|
||||||
// bytes suitable for the authenticated gateway transport.
|
// bytes suitable for the authenticated gateway transport.
|
||||||
func UserGamesOrderToPayload(req *model.UserGamesOrder) ([]byte, error) {
|
func UserGamesOrderToPayload(req *model.UserGamesOrder) ([]byte, error) {
|
||||||
@@ -1068,19 +1002,6 @@ func PayloadToUserGamesOrder(data []byte) (result *model.UserGamesOrder, err err
|
|||||||
return out, nil
|
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
|
// UserGamesOrderResponseToPayload encodes the engine's response body
|
||||||
// for `PUT /api/v1/order` into the wire FlatBuffers envelope expected
|
// for `PUT /api/v1/order` into the wire FlatBuffers envelope expected
|
||||||
// for `MessageTypeUserGamesOrder`. The engine populates per-command
|
// 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
|
// encodeCommandItemVector serialises a slice of DecodableCommand into a
|
||||||
// FlatBuffers vector of CommandItem. Used by UserGamesCommandToPayload
|
// FlatBuffers vector of CommandItem. Used by UserGamesOrderToPayload to
|
||||||
// and UserGamesOrderToPayload to keep the per-command encoding logic in
|
// keep the per-command encoding logic in one place.
|
||||||
// one place.
|
|
||||||
func encodeCommandItemVector(builder *flatbuffers.Builder, commands []model.DecodableCommand, opLabel string) (flatbuffers.UOffsetT, error) {
|
func encodeCommandItemVector(builder *flatbuffers.Builder, commands []model.DecodableCommand, opLabel string) (flatbuffers.UOffsetT, error) {
|
||||||
offsets := make([]flatbuffers.UOffsetT, len(commands))
|
offsets := make([]flatbuffers.UOffsetT, len(commands))
|
||||||
for i := range commands {
|
for i := range commands {
|
||||||
@@ -1331,12 +1251,7 @@ func encodeCommandItemVector(builder *flatbuffers.Builder, commands []model.Deco
|
|||||||
if len(offsets) == 0 {
|
if len(offsets) == 0 {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
// `UserGamesCommandStartCommandsVector` and the corresponding
|
fbs.UserGamesOrderStartCommandsVector(builder, len(offsets))
|
||||||
// `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))
|
|
||||||
for i := len(offsets) - 1; i >= 0; i-- {
|
for i := len(offsets) - 1; i >= 0; i-- {
|
||||||
builder.PrependUOffsetT(offsets[i])
|
builder.PrependUOffsetT(offsets[i])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,32 +10,6 @@ import (
|
|||||||
"github.com/google/uuid"
|
"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) {
|
func TestUserGamesOrderPayloadRoundTrip(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -62,15 +36,9 @@ func TestUserGamesOrderPayloadRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUserGamesCommandRejectsNilAndEmpty(t *testing.T) {
|
func TestUserGamesOrderRejectsNilAndEmpty(t *testing.T) {
|
||||||
t.Parallel()
|
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 {
|
if _, err := UserGamesOrderToPayload(nil); err == nil {
|
||||||
t.Fatalf("expected error encoding nil user games order")
|
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 { Relation } from './order/relation.js';
|
||||||
export { ShipGroupCargo } from './order/ship-group-cargo.js';
|
export { ShipGroupCargo } from './order/ship-group-cargo.js';
|
||||||
export { ShipGroupUpgradeTech } from './order/ship-group-upgrade-tech.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 { UserGamesOrder, UserGamesOrderT } from './order/user-games-order.js';
|
||||||
export { UserGamesOrderGet, UserGamesOrderGetT } from './order/user-games-order-get.js';
|
export { UserGamesOrderGet, UserGamesOrderGetT } from './order/user-games-order-get.js';
|
||||||
export { UserGamesOrderGetResponse, UserGamesOrderGetResponseT } from './order/user-games-order-get-response.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