From 601970b028f4520257c712d71e4c9f02ce7e42d0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 30 May 2026 13:37:07 +0200 Subject: [PATCH] refactor(game): lock-free storage, remove /command, flatten engine wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-stage refactor of the game-engine plumbing (game logic untouched): Stage 1 — lock-free persistence + admin serialisation. Remove the file lock from repo/fs (the .lock file, the Read/Write-vs-*Safe duality and the dead ReadSafe polling) and replace the two-step rename with a single atomic rename so concurrent reads are torn-free without a lock. Serialise the state-mutating admin writers (init/turn/banish) with one shared router LimitMiddleware, rewritten to block on the request context instead of a racy shared 100ms timer. Stage 2 — remove the obsolete immediate-command path end to end. Players submit through PUT /api/v1/order; the legacy PUT /api/v1/command path is deleted across game (route, handler, 24 command factories, Ctrl), backend (Commands handler/route, engineclient.ExecuteCommands), gateway (dispatch + executeUserGamesCommand + routing entry), the FlatBuffers/model contract (UserGamesCommand[Response]) and transcoder, plus every affected OpenAPI/README/FUNCTIONAL/ARCHITECTURE doc. The integration proxy test is converted to the order path. Stage 3 — flatten the REST->engine wrapper. Replace the executor adapter, the controller package functions and RepoController with one concrete controller.Service; drop the single-implementation Repo and Storage interfaces (repo.Repo / fs.FS are now concrete). Handlers depend on a thin handler.Engine seam and own the domain->REST projection; storage is resolved once at startup instead of per request. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/README.md | 1 - backend/internal/engineclient/client.go | 13 +- backend/internal/engineclient/client_test.go | 21 - backend/internal/runtime/errors.go | 2 +- backend/internal/runtime/service.go | 10 +- .../internal/server/handlers_user_games.go | 49 - backend/internal/server/router.go | 1 - backend/openapi.yaml | 39 - docs/ARCHITECTURE.md | 6 +- docs/FUNCTIONAL.md | 10 +- docs/FUNCTIONAL_ru.md | 6 +- game/README.md | 23 +- game/cmd/http/main.go | 12 +- game/internal/controller/controller.go | 399 +++----- game/internal/controller/controller_test.go | 3 +- game/internal/controller/generate_game.go | 5 +- .../internal/controller/generate_game_test.go | 18 +- game/internal/controller/generate_turn.go | 8 +- game/internal/controller/order.go | 4 +- game/internal/controller/service_test.go | 76 ++ game/internal/repo/fs/fs.go | 185 +--- game/internal/repo/fs/fs_test.go | 123 ++- game/internal/repo/game.go | 75 +- game/internal/repo/repo.go | 66 +- game/internal/repo/repo_export_test.go | 6 +- game/internal/repo/repo_test.go | 2 +- game/internal/router/command_test.go | 942 ------------------ game/internal/router/handler/banish.go | 4 +- game/internal/router/handler/battle.go | 4 +- game/internal/router/handler/command.go | 347 ------- game/internal/router/handler/handler.go | 97 +- game/internal/router/handler/init.go | 6 +- game/internal/router/handler/order.go | 22 +- game/internal/router/handler/report.go | 4 +- game/internal/router/handler/status.go | 6 +- game/internal/router/handler/turn.go | 6 +- game/internal/router/healthz_test.go | 10 +- game/internal/router/init_test.go | 8 +- game/internal/router/middleware.go | 22 +- game/internal/router/report_test.go | 4 +- game/internal/router/router.go | 39 +- game/internal/router/router_export_test.go | 2 +- game/internal/router/router_helper_test.go | 37 +- game/internal/router/router_test.go | 88 ++ game/internal/router/status_test.go | 4 +- game/internal/router/turn_test.go | 4 +- game/openapi.yaml | 36 +- game/openapi_contract_test.go | 16 +- gateway/README.md | 1 - gateway/authn/parity_with_ui_core_test.go | 2 +- .../internal/backendclient/games_commands.go | 52 +- gateway/internal/backendclient/routes.go | 27 +- gateway/internal/backendclient/routes_test.go | 1 - ...oxy_test.go => engine_order_proxy_test.go} | 33 +- pkg/error/generic.go | 6 +- pkg/model/order/order.go | 26 +- pkg/model/rest/command.go | 11 +- pkg/schema/fbs/order.fbs | 20 +- pkg/schema/fbs/order/UserGamesCommand.go | 93 -- .../fbs/order/UserGamesCommandResponse.go | 49 - pkg/transcoder/order.go | 91 +- pkg/transcoder/order_test.go | 34 +- ui/frontend/src/proto/galaxy/fbs/order.ts | 2 - .../fbs/order/user-games-command-response.ts | 56 -- .../galaxy/fbs/order/user-games-command.ts | 110 -- 65 files changed, 681 insertions(+), 2804 deletions(-) create mode 100644 game/internal/controller/service_test.go delete mode 100644 game/internal/router/command_test.go delete mode 100644 game/internal/router/handler/command.go rename integration/{engine_command_proxy_test.go => engine_order_proxy_test.go} (77%) delete mode 100644 pkg/schema/fbs/order/UserGamesCommand.go delete mode 100644 pkg/schema/fbs/order/UserGamesCommandResponse.go delete mode 100644 ui/frontend/src/proto/galaxy/fbs/order/user-games-command-response.ts delete mode 100644 ui/frontend/src/proto/galaxy/fbs/order/user-games-command.ts diff --git a/backend/README.md b/backend/README.md index f9181f1..05ec325 100644 --- a/backend/README.md +++ b/backend/README.md @@ -264,7 +264,6 @@ Endpoints used: - `GET /api/v1/admin/status` - `PUT /api/v1/admin/turn` - `POST /api/v1/admin/race/banish` -- `PUT /api/v1/command` - `PUT /api/v1/order` - `GET /api/v1/report` - `GET /healthz` diff --git a/backend/internal/engineclient/client.go b/backend/internal/engineclient/client.go index 454d93d..ec04c1a 100644 --- a/backend/internal/engineclient/client.go +++ b/backend/internal/engineclient/client.go @@ -23,7 +23,6 @@ const ( pathAdminStatus = "/api/v1/admin/status" pathAdminTurn = "/api/v1/admin/turn" pathAdminRaceBanish = "/api/v1/admin/race/banish" - pathPlayerCommand = "/api/v1/command" pathPlayerOrder = "/api/v1/order" pathPlayerReport = "/api/v1/report" pathPlayerBattle = "/api/v1/battle" @@ -183,16 +182,10 @@ func (c *Client) BanishRace(ctx context.Context, baseURL, raceName string) error } } -// ExecuteCommands calls `PUT /api/v1/command` with payload forwarded +// PutOrders calls `PUT /api/v1/order` with the payload forwarded // verbatim. The engine response body is returned verbatim; on 4xx the -// body is returned alongside ErrEngineValidation so callers can -// forward the per-command error. -func (c *Client) ExecuteCommands(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) { - return c.forwardPlayerWrite(ctx, baseURL, pathPlayerCommand, payload, "engine command") -} - -// PutOrders calls `PUT /api/v1/order` with the same forwarding -// semantics as ExecuteCommands. +// body is returned alongside ErrEngineValidation so callers can forward +// the per-command error. func (c *Client) PutOrders(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) { return c.forwardPlayerWrite(ctx, baseURL, pathPlayerOrder, payload, "engine order") } diff --git a/backend/internal/engineclient/client_test.go b/backend/internal/engineclient/client_test.go index f6f3c10..207f129 100644 --- a/backend/internal/engineclient/client_test.go +++ b/backend/internal/engineclient/client_test.go @@ -156,27 +156,6 @@ func TestClientBanishRace(t *testing.T) { } } -func TestClientCommandsForwardsBody(t *testing.T) { - want := json.RawMessage(`{"actor":"alpha","cmd":[{"@type":"raceQuit"}]}`) - gotResp := json.RawMessage(`{"applied":true}`) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != pathPlayerCommand || r.Method != http.MethodPut { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - _, _ = w.Write(gotResp) - })) - t.Cleanup(srv.Close) - - cli := newTestClient(t, srv) - resp, err := cli.ExecuteCommands(context.Background(), srv.URL, want) - if err != nil { - t.Fatalf("ExecuteCommands: %v", err) - } - if string(resp) != string(gotResp) { - t.Fatalf("response = %s, want %s", string(resp), string(gotResp)) - } -} - func TestClientReportsForwardsQuery(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != pathPlayerReport { diff --git a/backend/internal/runtime/errors.go b/backend/internal/runtime/errors.go index bfb228e..a46a599 100644 --- a/backend/internal/runtime/errors.go +++ b/backend/internal/runtime/errors.go @@ -52,7 +52,7 @@ var ( ErrTurnAlreadyClosed = errors.New("runtime: turn already closed") // ErrGamePaused reports that the game is not in a state that - // accepts user-games commands or orders: the runtime row + // accepts user-games orders: the runtime row // carries `paused = true`, or the runtime status lands on any // terminal value (`engine_unreachable`, `generation_failed`, // `stopped`, `finished`, `removed`), or the game has not yet diff --git a/backend/internal/runtime/service.go b/backend/internal/runtime/service.go index c39099b..91db9ba 100644 --- a/backend/internal/runtime/service.go +++ b/backend/internal/runtime/service.go @@ -258,10 +258,10 @@ func (s *Service) ResolvePlayerMapping(ctx context.Context, gameID, userID uuid. } // CheckOrdersAccept verifies that the runtime is in a state that -// accepts user-games commands and orders. It is called by the user -// game-proxy handlers (`Commands`, `Orders`) before forwarding to -// engine, so the backend's turn-cutoff and pause guards run before -// network traffic leaves the host. The decision itself lives in the +// accepts user-games orders. It is called by the user game-proxy +// handler (`Orders`) before forwarding to engine, so the backend's +// turn-cutoff and pause guards run before network traffic leaves the +// host. The decision itself lives in the // pure helper `OrdersAcceptStatus` so it can be unit-tested without // constructing a full Service. // @@ -276,7 +276,7 @@ func (s *Service) CheckOrdersAccept(ctx context.Context, gameID uuid.UUID) error } // OrdersAcceptStatus inspects a runtime record and returns the -// matching sentinel for the user-games order/command pre-check: +// matching sentinel for the user-games order pre-check: // // - `runtime_status = generation_in_progress` → `ErrTurnAlreadyClosed`. // The cron-driven `Scheduler.tick` has flipped the row before diff --git a/backend/internal/server/handlers_user_games.go b/backend/internal/server/handlers_user_games.go index e66559b..ba678a9 100644 --- a/backend/internal/server/handlers_user_games.go +++ b/backend/internal/server/handlers_user_games.go @@ -39,55 +39,6 @@ func NewUserGamesHandlers(rt *runtime.Service, engine *engineclient.Client, logg return &UserGamesHandlers{runtime: rt, engine: engine, logger: logger.Named("http.user.games")} } -// Commands handles POST /api/v1/user/games/{game_id}/commands. -func (h *UserGamesHandlers) Commands() gin.HandlerFunc { - if h == nil || h.runtime == nil || h.engine == nil { - return handlers.NotImplemented("userGamesCommands") - } - return func(c *gin.Context) { - gameID, ok := parseGameIDParam(c) - if !ok { - return - } - userID, ok := userid.FromContext(c.Request.Context()) - if !ok { - httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user id missing") - return - } - body, err := io.ReadAll(c.Request.Body) - if err != nil { - httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body could not be read") - return - } - ctx := c.Request.Context() - if err := h.runtime.CheckOrdersAccept(ctx, gameID); err != nil { - respondGameProxyError(c, h.logger, "user games commands", ctx, err) - return - } - mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID) - if err != nil { - respondGameProxyError(c, h.logger, "user games commands", ctx, err) - return - } - endpoint, err := h.runtime.EngineEndpoint(ctx, gameID) - if err != nil { - respondGameProxyError(c, h.logger, "user games commands", ctx, err) - return - } - payload, err := rebindActor(body, mapping.RaceName) - if err != nil { - httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be a JSON object") - return - } - resp, err := h.engine.ExecuteCommands(ctx, endpoint, payload) - if err != nil { - respondEngineProxyError(c, h.logger, "user games commands", ctx, resp, err) - return - } - c.Data(http.StatusOK, "application/json", resp) - } -} - // Orders handles POST /api/v1/user/games/{game_id}/orders. func (h *UserGamesHandlers) Orders() gin.HandlerFunc { if h == nil || h.runtime == nil || h.engine == nil { diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index bef1433..d26b339 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -270,7 +270,6 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de raceNames.POST("/register", deps.UserLobbyRaceNames.Register()) userGames := group.Group("/games") - userGames.POST("/:game_id/commands", deps.UserGames.Commands()) userGames.POST("/:game_id/orders", deps.UserGames.Orders()) userGames.GET("/:game_id/orders", deps.UserGames.GetOrders()) userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report()) diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 5acf584..4782abe 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -981,37 +981,6 @@ paths: $ref: "#/components/responses/NotImplementedError" "500": $ref: "#/components/responses/InternalError" - /api/v1/user/games/{game_id}/commands: - post: - tags: [User] - operationId: userGamesCommands - summary: Forward an engine command batch - security: - - UserHeader: [] - parameters: - - $ref: "#/components/parameters/XUserID" - - $ref: "#/components/parameters/GameID" - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/EngineCommand" - responses: - "200": - description: Engine command result passed through. - content: - application/json: - schema: - $ref: "#/components/schemas/PassthroughObject" - "400": - $ref: "#/components/responses/InvalidRequestError" - "404": - $ref: "#/components/responses/NotFoundError" - "501": - $ref: "#/components/responses/NotImplementedError" - "500": - $ref: "#/components/responses/InternalError" /api/v1/user/games/{game_id}/orders: post: tags: [User] @@ -3538,14 +3507,6 @@ components: properties: name: type: string - EngineCommand: - type: object - additionalProperties: true - description: | - Engine command request body. The schema is permissive because the - engine proxy passes the body through verbatim; the typed shape - lives in `pkg/model/rest.Command` and is enforced by - `internal/engineclient` before the engine call leaves backend. EngineOrder: type: object additionalProperties: true diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 748ad81..3a7f321 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -375,9 +375,9 @@ Authenticated client traffic for in-game operations crosses three serialisation boundaries: signed-gRPC FlatBuffers (client ↔ gateway), JSON over REST (gateway ↔ backend), and JSON over REST again (backend ↔ engine). Gateway owns the FB ↔ JSON transcoding for the -four message types `user.games.command`, `user.games.order`, -`user.games.order.get`, `user.games.report` (FB schemas in -`pkg/schema/fbs/{order,report}`, encoders in `pkg/transcoder`). +three message types `user.games.order`, `user.games.order.get`, +`user.games.report` (FB schemas in `pkg/schema/fbs/{order,report}`, +encoders in `pkg/transcoder`). `user.games.order.get` reads back the player's stored order for a given turn — paired with the POST `user.games.order` so the client can hydrate its local draft after a cache loss without re-deriving diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index b9c46c4..26a33b0 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -619,10 +619,10 @@ not duplicated here. ### 6.2 Backend's role: pass-through with authorisation -The signed authenticated-edge pipeline for in-game traffic uses four -message types on the authenticated surface — `user.games.command`, -`user.games.order`, `user.games.order.get`, `user.games.report` — -each with a typed FlatBuffers payload. Gateway transcodes the FB +The signed authenticated-edge pipeline for in-game traffic uses three +message types on the authenticated surface — `user.games.order`, +`user.games.order.get`, `user.games.report` — each with a typed +FlatBuffers payload. Gateway transcodes the FB request into the JSON shape backend expects, forwards over plain REST to the corresponding `/api/v1/user/games/{game_id}/*` endpoint, then transcodes the JSON response back into FB before signing the @@ -671,7 +671,7 @@ in `runtime_records.turn_schedule`. The backend scheduler `/admin/turn` call between two `runtime_status` flips: - Before the engine call: `running → generation_in_progress`. - The user-games command/order handlers + The user-games order handlers (`backend/internal/server/handlers_user_games.go`) consult the per-game runtime record on every request and reject with HTTP 409 + `code = turn_already_closed` while the runtime sits in diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index e93ff7b..a732dcd 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -637,9 +637,9 @@ Wire-формат команд, приказов и отчётов — собс ### 6.2 Роль backend: pass-through с авторизацией Подписанный конвейер аутентифицированного edge для in-game-трафика -использует четыре message types на аутентифицированной поверхности — -`user.games.command`, `user.games.order`, `user.games.order.get`, -`user.games.report` — у каждого типизированный FlatBuffers-payload. +использует три message types на аутентифицированной поверхности — +`user.games.order`, `user.games.order.get`, `user.games.report` — +у каждого типизированный FlatBuffers-payload. Gateway транскодирует FB-запрос в JSON-форму, которую ждёт backend, форвардит её REST'ом в соответствующий `/api/v1/user/games/{game_id}/*` endpoint, после чего транскодирует diff --git a/game/README.md b/game/README.md index e30ace4..4311bf4 100644 --- a/game/README.md +++ b/game/README.md @@ -47,7 +47,6 @@ described below. Endpoints split into two route classes: | Admin (GM-only) | `GET /api/v1/admin/status` | `Game Master` | Read the full game state. | | Admin (GM-only) | `PUT /api/v1/admin/turn` | `Game Master` | Generate the next turn. | | Admin (GM-only) | `POST /api/v1/admin/race/banish` | `Game Master` | Deactivate a race after a permanent platform removal. | -| Player | `PUT /api/v1/command` | `Game Master` (forwarded from `Edge Gateway`) | Execute a batch of player commands. | | Player | `PUT /api/v1/order` | `Game Master` | Validate and store a batch of player orders. | | Player | `GET /api/v1/order` | `Game Master` | Fetch the previously stored player order for a turn. | | Player | `GET /api/v1/report` | `Game Master` | Fetch the per-player turn report. | @@ -166,19 +165,17 @@ Alternatives considered and rejected: `game/internal/router/handler/handler.go` exports `ResolveStoragePath`, which returns the engine storage path from the env-var pair above and -an error when neither is set. `cmd/http/main.go` calls it before -constructing the router, prints the error to stderr, and exits non-zero. -The existing `initConfig` closure also calls `ResolveStoragePath` to -populate `controller.Param.StoragePath` at request time; the error there -is dropped because `main` already validated the environment at startup. +an error when neither is set. `cmd/http/main.go` calls it once at +startup, prints the error to stderr and exits non-zero on failure, then +builds the engine service (`controller.NewService(path)`) and hands it +to `router.NewRouter`. -This keeps the public router surface (`router.NewRouter`) unchanged — -the env binding is satisfied by one helper plus a startup check, with -no API ripple. Moving env reading entirely into `main` and changing -`NewRouter` / `NewDefaultExecutor` to accept an explicit path was -rejected: it churns multiple call sites for no functional gain. The -current shape leaves the configurer closure ready for future -config-injection refactors without forcing one now. +Storage is resolved exactly once, at construction, rather than per +request: the `Service` holds the file-backed repo for the process +lifetime and `router.NewRouter` takes the `handler.Engine` it routes +to (in production, the `Service`). This keeps the env binding in one +place — a startup helper plus the `main` check — and leaves the +handlers free of configuration concerns. ## Build diff --git a/game/cmd/http/main.go b/game/cmd/http/main.go index 82685cf..b9f5cb2 100644 --- a/game/cmd/http/main.go +++ b/game/cmd/http/main.go @@ -4,17 +4,25 @@ import ( "fmt" "os" + "galaxy/game/internal/controller" "galaxy/game/internal/router" "galaxy/game/internal/router/handler" ) func main() { - if _, err := handler.ResolveStoragePath(); err != nil { + path, err := handler.ResolveStoragePath() + if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - r := router.NewRouter() + svc, err := controller.NewService(path) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + r := router.NewRouter(svc) if err := r.Run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/game/internal/controller/controller.go b/game/internal/controller/controller.go index 72b8f98..47daafa 100644 --- a/game/internal/controller/controller.go +++ b/game/internal/controller/controller.go @@ -16,187 +16,147 @@ import ( "galaxy/game/internal/repo" ) -type Configurer func(*Param) - -type Repo interface { - // Lock must be called before any repository operations - Lock() error - - // Release must be called after first and only repository operation - Release() error - - // SaveTurn stores just generated new turn - SaveNewTurn(uint, *game.Game) error - - // SaveState stores current game state updated between turns - SaveLastState(*game.Game) error - - // LoadState retrieves game current state with required lock acquisition - LoadState() (*game.Game, error) - - // LoadStateSafe retrieves game current state without preliminary locking - LoadStateSafe() (*game.Game, error) - - // SaveBattle stores a new battle protocol and battle meta data for turn t - SaveBattle(uint, *report.BattleReport, *game.BattleMeta) error - - // LoadBattle reads battle's protocol for turn t and battle id. - // Returns false if battle with such id was never stored at turn t - LoadBattle(t uint, id uuid.UUID) (*report.BattleReport, bool, error) - - // SaveBombing stores all prodused bombings for turn t - SaveBombings(uint, []*game.Bombing) error - - // SaveReport stores latest report for a race - SaveReport(uint, *report.Report) error - - // LoadReport loads report for specific turn and player id - LoadReport(uint, uuid.UUID) (*report.Report, error) - - // SaveOrder stores order for given turn - SaveOrder(uint, uuid.UUID, *order.UserGamesOrder) error - - // LoadOrder loads order for specific turn and player id - LoadOrder(uint, uuid.UUID) (*order.UserGamesOrder, bool, error) +// Service is the engine's application service: it owns persistence and exposes +// the operations the HTTP handlers invoke. It is safe for concurrent use — +// reads are lock-free and the writers that mutate the canonical state file +// (init/turn/banish) are serialised at the router by a shared LimitMiddleware. +type Service struct { + repo *repo.Repo } -type Ctrl interface { - ValidateOrder(actor string, cmd ...order.DecodableCommand) error - // remove below funcs if /command api will be deleted - RaceID(actor string) (uuid.UUID, error) - RaceQuit(actor string) error - RaceVote(actor, acceptor string) error - RaceRelation(actor, acceptor string, rel string) error - ShipClassCreate(actor, typeName string, drive float64, ammo int, weapons, shileds, cargo float64) error - ShipClassMerge(actor, name, targetName string) error - ShipClassRemove(actor, typeName string) error - ShipGroupLoad(actor string, groupID uuid.UUID, cargoType string, quantity float64) error - ShipGroupUnload(actor string, groupID uuid.UUID, quantity float64) error - ShipGroupSend(actor string, groupID uuid.UUID, planetNumber uint) error - ShipGroupUpgrade(actor string, groupID uuid.UUID, techInput string, limitLevel float64) error - ShipGroupBreak(actor string, groupID, newID uuid.UUID, quantity uint) error - ShipGroupMerge(actor string) error - ShipGroupDismantle(actor string, groupID uuid.UUID) error - ShipGroupTransfer(actor, acceptor string, groupID uuid.UUID) error - ShipGroupJoinFleet(actor, fleetName string, groupID uuid.UUID) error - FleetMerge(actor, fleetSourceName, fleetTargetName string) error - FleetSend(actor, fleetName string, planetNumber uint) error - ScienceCreate(actor, typeName string, drive, weapons, shields, cargo float64) error - ScienceRemove(actor, typeName string) error - PlanetRename(actor string, planetNumber int, typeName string) error - PlanetProduce(actor string, planetNumber int, prodType, subject string) error - PlanetRouteSet(actor, loadType string, origin, destination uint) error - PlanetRouteRemove(actor, loadType string, origin uint) error +// NewService opens the file-backed storage at storagePath and returns a ready +// Service. The directory must already exist and be writable. +func NewService(storagePath string) (*Service, error) { + r, err := repo.NewFileRepo(storagePath) + if err != nil { + return nil, err + } + return &Service{repo: r}, nil } // GenerateGame initialises a fresh game in storage under the supplied -// canonical gameID. The orchestrator must allocate gameID before the -// engine container is started and pass it here as the request body of -// POST /api/v1/admin/init. A zero UUID is rejected with -// ErrGameInitNilUUID; an attempt to init on top of an existing -// state.json is rejected with ErrGameAlreadyInit. -func GenerateGame(configure func(*Param), gameID uuid.UUID, races []string) (s game.State, err error) { +// canonical gameID. The orchestrator must allocate gameID before the engine +// container is started and pass it here as the request body of +// POST /api/v1/admin/init. A zero UUID is rejected with ErrGameInitNilUUID; an +// attempt to init on top of an existing state.json is rejected with +// ErrGameAlreadyInit. +func (s *Service) GenerateGame(gameID uuid.UUID, races []string) (game.State, error) { if gameID == uuid.Nil { return game.State{}, ErrGameInitNilUUID } - ec, err := NewRepoController(configure) - if err != nil { + + if existing, loadErr := s.repo.LoadState(); loadErr == nil { + return game.State{}, fmt.Errorf("%w: stored gameId=%s, requested=%s", ErrGameAlreadyInit, existing.ID, gameID) + } else if !isGameNotInitialized(loadErr) { + return game.State{}, fmt.Errorf("check existing state: %w", loadErr) + } + + if _, err := NewGame(s.repo, gameID, races); err != nil { return game.State{}, err } - if err = ec.Repo.Lock(); err != nil { - return - } - defer func() { - err = errors.Join(err, ec.Repo.Release()) - if err == nil { - s, err = GameState(configure) - } - }() - if existing, loadErr := ec.Repo.LoadState(); loadErr == nil { - err = fmt.Errorf("%w: stored gameId=%s, requested=%s", ErrGameAlreadyInit, existing.ID, gameID) - return - } else if !isGameNotInitialized(loadErr) { - err = fmt.Errorf("check existing state: %w", loadErr) - return - } + return s.GameState() +} - _, err = NewGame(ec.Repo, gameID, races) - return +// GenerateTurn advances the game by one turn (applying every stored order) and +// returns the resulting game state. +func (s *Service) GenerateTurn() (game.State, error) { + if err := s.execute(func(_ uint, c *Controller) error { return c.MakeTurn() }); err != nil { + return game.State{}, err + } + return s.GameState() } // isGameNotInitialized reports whether err is the engine's canonical -// "no state.json on disk" signal returned by Repo.LoadState on a -// fresh storage directory. +// "no state.json on disk" signal returned by Repo.LoadState on a fresh +// storage directory. func isGameNotInitialized(err error) bool { var ge *e.GenericError return errors.As(err, &ge) && ge.Code == e.ErrGameNotInitialized } -func GenerateTurn(configure func(*Param)) (err error) { - ec, err := NewRepoController(configure) - if err != nil { - return err - } - err = ec.executeLocked(func(c *Controller) error { return c.MakeTurn() }) +// LoadReport returns the stored turn report for actor at the given turn. +func (s *Service) LoadReport(actor string, turn uint) (r *report.Report, err error) { + execErr := s.execute(func(_ uint, c *Controller) (exErr error) { + id, exErr := c.RaceID(actor) + if exErr == nil { + r, exErr = s.repo.LoadReport(turn, id) + } + return + }) + err = errors.Join(err, execErr) return } -func LoadReport(configure func(*Param), actor string, turn uint) (*report.Report, error) { - ec, err := NewRepoController(configure) +// ValidateOrder validates cmd against a transient view of the current state, +// records the per-command outcome on each command's meta, and stores the +// resulting order for the current turn. Game-state rejections are reported per +// command, not as a returned error. +func (s *Service) ValidateOrder(actor string, cmd ...order.DecodableCommand) (o *order.UserGamesOrder, err error) { + err = s.execute(func(t uint, c *Controller) error { + id, err := c.RaceID(actor) + if err != nil { + return err + } + if err := c.ValidateOrder(actor, cmd...); err != nil { + return err + } + o = &order.UserGamesOrder{ + GameID: c.Cache.g.ID, + UpdatedAt: time.Now().UTC().UnixMilli(), + Commands: make([]order.DecodableCommand, len(cmd)), + } + copy(o.Commands, cmd) + return s.repo.SaveOrder(t, id, o) + }) if err != nil { return nil, err } - return ec.loadReport(actor, turn) + return } -func ExecuteCommand(configure func(*Param), consumer func(c Ctrl) error) (err error) { - ec, err := NewRepoController(configure) - if err != nil { +// FetchOrder returns the order actor stored for the given turn. ok is false +// when no order was ever stored. +func (s *Service) FetchOrder(actor string, turn uint) (o *order.UserGamesOrder, ok bool, err error) { + err = s.execute(func(_ uint, c *Controller) error { + id, err := c.RaceID(actor) + if err != nil { + return err + } + o, ok, err = s.repo.LoadOrder(turn, id) return err + }) + if err != nil { + return } - return ec.executeCommand(func(c *Controller) error { return consumer(c) }) + return } -func ValidateOrder(configure func(*Param), actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) { - ec, err := NewRepoController(configure) - if err != nil { - return nil, err - } - return ec.validateOrder(actor, cmd...) -} - -func FetchOrder(configure func(*Param), actor string, turn uint) (order *order.UserGamesOrder, ok bool, err error) { - ec, err := NewRepoController(configure) - if err != nil { - return nil, false, err - } - return ec.fetchOrder(actor, turn) -} - -func FetchBattle(configure func(*Param), turn uint, ID uuid.UUID) (b *report.BattleReport, exists bool, err error) { - ec, err := NewRepoController(configure) - if err != nil { - return nil, false, err - } - return ec.fetchBattle(turn, ID) -} - -func BanishRace(configure func(*Param), actor string) error { - ec, err := NewRepoController(configure) - if err != nil { +// FetchBattle returns the battle report stored at turn under ID. exists is +// false when no such battle was recorded. +func (s *Service) FetchBattle(turn uint, ID uuid.UUID) (b *report.BattleReport, exists bool, err error) { + err = s.execute(func(_ uint, c *Controller) error { + b, exists, err = s.repo.LoadBattle(turn, ID) return err - } - return ec.banishRace(actor) + }) + return } -func GameState(configure func(*Param)) (s game.State, err error) { - ec, err := NewRepoController(configure) - if err != nil { - return game.State{}, err - } +// BanishRace deactivates actor's race after a permanent platform removal and +// persists the updated state. +func (s *Service) BanishRace(actor string) error { + return s.execute(func(_ uint, c *Controller) error { + if err := c.RaceBanish(actor); err != nil { + return err + } + return c.saveState() + }) +} - g, err := ec.Repo.LoadStateSafe() +// GameState loads the current state and projects it into the transport-facing +// game.State summary (player roster with planet counts and population). +func (s *Service) GameState() (game.State, error) { + g, err := s.repo.LoadState() if err != nil { return game.State{}, err } @@ -234,149 +194,26 @@ func GameState(configure func(*Param)) (s game.State, err error) { return *result, nil } -type RepoController struct { - Repo Repo -} - -func NewRepoController(config Configurer) (*RepoController, error) { - c := &Param{ - StoragePath: ".", - } - if config != nil { - config(c) - } - r, err := repo.NewFileRepo(c.StoragePath) - if err != nil { - return nil, err - } - return &RepoController{ - Repo: r, - }, nil -} - -func (ec *RepoController) NewGameController(g *game.Game) *Controller { - return &Controller{ - RepoController: ec, - Cache: NewCache(g), - } -} - -func (ec *RepoController) validateOrder(actor string, cmd ...order.DecodableCommand) (o *order.UserGamesOrder, err error) { - err = ec.executeSafe(func(t uint, c *Controller) error { - id, err := c.RaceID(actor) - if err != nil { - return err - } - err = c.ValidateOrder(actor, cmd...) - if err != nil { - return err - } - o = &order.UserGamesOrder{ - GameID: c.Cache.g.ID, - UpdatedAt: time.Now().UTC().UnixMilli(), - Commands: make([]order.DecodableCommand, len(cmd)), - } - copy(o.Commands, cmd) - return ec.Repo.SaveOrder(t, id, o) - }) - if err != nil { - return nil, err - } - return -} - -func (ec *RepoController) fetchOrder(actor string, turn uint) (order *order.UserGamesOrder, ok bool, err error) { - err = ec.executeSafe(func(t uint, c *Controller) error { - id, err := c.RaceID(actor) - if err != nil { - return err - } - order, ok, err = ec.Repo.LoadOrder(turn, id) - return err - }) - if err != nil { - return - } - return -} - -func (ec *RepoController) fetchBattle(turn uint, ID uuid.UUID) (order *report.BattleReport, exists bool, err error) { - err = ec.executeSafe(func(t uint, c *Controller) error { - order, exists, err = ec.Repo.LoadBattle(turn, ID) - return err - }) - return -} - -func (ec *RepoController) loadReport(actor string, turn uint) (r *report.Report, err error) { - execErr := ec.executeSafe(func(t uint, c *Controller) (exErr error) { - id, exErr := c.RaceID(actor) - if exErr == nil { - r, exErr = ec.Repo.LoadReport(turn, id) - } - return - }) - err = errors.Join(err, execErr) - return -} - -func (ec *RepoController) executeCommand(consumer func(*Controller) error) (err error) { - return ec.executeLocked(func(c *Controller) error { - err = consumer(c) - if err == nil { - c.Cache.StageCommand() - err = c.saveState() - } - return err - }) -} - -func (ec *RepoController) banishRace(actor string) (err error) { - return ec.executeLocked(func(c *Controller) error { - err = c.RaceBanish(actor) - if err != nil { - return err - } - return c.saveState() - }) -} - -func (ec *RepoController) executeSafe(consumer func(uint, *Controller) error) (err error) { - g, err := ec.Repo.LoadStateSafe() +// execute loads the current game state, wraps it in a Controller and runs +// consumer against it. Reads and writes are lock-free; concurrent writers to +// the state file (init/turn/banish) are serialised at the router by a shared +// LimitMiddleware, so this helper holds no lock of its own. +func (s *Service) execute(consumer func(uint, *Controller) error) error { + g, err := s.repo.LoadState() if err != nil { return err } - - err = consumer(g.Turn, ec.NewGameController(g)) - return -} - -func (ec *RepoController) executeLocked(consumer func(*Controller) error) (err error) { - if err := ec.Repo.Lock(); err != nil { - return err - } - defer func() { - err = errors.Join(err, ec.Repo.Release()) - }() - - g, err := ec.Repo.LoadState() - if err != nil { - return err - } - - err = consumer(ec.NewGameController(g)) - return -} - -func (c *Controller) saveState() error { - return c.Repo.SaveLastState(c.Cache.g) + return consumer(g.Turn, &Controller{repo: s.repo, Cache: NewCache(g)}) } +// Controller is the per-turn execution context: a loaded game state (Cache) +// plus the repo it persists through. It carries the engine's game-logic +// methods (in command.go, order.go, generate_turn.go, …). type Controller struct { - *RepoController + repo *repo.Repo Cache *Cache } -type Param struct { - StoragePath string +func (c *Controller) saveState() error { + return c.repo.SaveLastState(c.Cache.g) } diff --git a/game/internal/controller/controller_test.go b/game/internal/controller/controller_test.go index fadae18..1e2ff4c 100644 --- a/game/internal/controller/controller_test.go +++ b/game/internal/controller/controller_test.go @@ -131,8 +131,7 @@ func newGame() *game.Game { func newCache() (*controller.Cache, *controller.Controller) { ctl := &controller.Controller{ - RepoController: nil, - Cache: controller.NewCache(newGame()), + Cache: controller.NewCache(newGame()), } assertNoError(ctl.Cache.ShipClassCreate(Race_0_idx, Race_0_Gunship, 60, 3, 30, 100, 0)) assertNoError(ctl.Cache.ShipClassCreate(Race_0_idx, Race_0_Freighter, 8, 0, 0, 2, 10)) diff --git a/game/internal/controller/generate_game.go b/game/internal/controller/generate_game.go index 2be6528..b53b053 100644 --- a/game/internal/controller/generate_game.go +++ b/game/internal/controller/generate_game.go @@ -7,6 +7,7 @@ import ( "galaxy/game/internal/generator" "galaxy/game/internal/model/game" + "galaxy/game/internal/repo" "github.com/google/uuid" ) @@ -14,7 +15,7 @@ import ( // NewGame initialises a fresh game in storage under the supplied // gameID. The caller is expected to have validated gameID against // uuid.Nil and to have ruled out collisions with existing state. -func NewGame(r Repo, gameID uuid.UUID, races []string) (uuid.UUID, error) { +func NewGame(r *repo.Repo, gameID uuid.UUID, races []string) (uuid.UUID, error) { m, err := generator.Generate(func(ms *generator.MapSetting) { ms.Players = uint32(len(races)) }) @@ -24,7 +25,7 @@ func NewGame(r Repo, gameID uuid.UUID, races []string) (uuid.UUID, error) { return newGameOnMap(r, gameID, races, m) } -func newGameOnMap(r Repo, gameID uuid.UUID, races []string, m generator.Map) (uuid.UUID, error) { +func newGameOnMap(r *repo.Repo, gameID uuid.UUID, races []string, m generator.Map) (uuid.UUID, error) { g, err := buildGameOnMap(gameID, races, m) if err != nil { return uuid.Nil, err diff --git a/game/internal/controller/generate_game_test.go b/game/internal/controller/generate_game_test.go index d6fa684..2a0e8d6 100644 --- a/game/internal/controller/generate_game_test.go +++ b/game/internal/controller/generate_game_test.go @@ -29,7 +29,6 @@ func TestNewGame(t *testing.T) { races[i] = fmt.Sprintf("race_%02d", i) } requestedID := uuid.New() - assert.NoError(t, r.Lock()) gameID, err := controller.NewGame(r, requestedID, races) assert.NoError(t, err) assert.Equal(t, requestedID, gameID, "NewGame must echo the supplied gameID") @@ -67,8 +66,6 @@ func TestNewGame(t *testing.T) { numShuffled = numShuffled || p.Number != uint(i) } assert.True(t, numShuffled) - - assert.NoError(t, r.Release()) } func TestGenerateGameRejectsExistingState(t *testing.T) { @@ -79,13 +76,14 @@ func TestGenerateGameRejectsExistingState(t *testing.T) { for i := range races { races[i] = fmt.Sprintf("race_%02d", i) } - configure := func(p *controller.Param) { p.StoragePath = root } - - firstID := uuid.New() - _, err := controller.GenerateGame(configure, firstID, races) + svc, err := controller.NewService(root) assert.NoError(t, err) - _, err = controller.GenerateGame(configure, uuid.New(), races) + firstID := uuid.New() + _, err = svc.GenerateGame(firstID, races) + assert.NoError(t, err) + + _, err = svc.GenerateGame(uuid.New(), races) assert.ErrorIs(t, err, controller.ErrGameAlreadyInit) } @@ -98,6 +96,8 @@ func TestGenerateGameRejectsNilUUID(t *testing.T) { races[i] = fmt.Sprintf("race_%02d", i) } - _, err := controller.GenerateGame(func(p *controller.Param) { p.StoragePath = root }, uuid.Nil, races) + svc, err := controller.NewService(root) + assert.NoError(t, err) + _, err = svc.GenerateGame(uuid.Nil, races) assert.ErrorIs(t, err, controller.ErrGameInitNilUUID) } diff --git a/game/internal/controller/generate_turn.go b/game/internal/controller/generate_turn.go index 34460ee..79896c3 100644 --- a/game/internal/controller/generate_turn.go +++ b/game/internal/controller/generate_turn.go @@ -67,7 +67,7 @@ func (c *Controller) MakeTurn() error { // Store bombings bombingReport := make([]*report.Bombing, len(bombings)) if len(bombings) > 0 { - if err := c.Repo.SaveBombings(c.Cache.g.Turn, bombings); err != nil { + if err := c.repo.SaveBombings(c.Cache.g.Turn, bombings); err != nil { return err } for i := range bombings { @@ -107,7 +107,7 @@ func (c *Controller) MakeTurn() error { } report := TransformBattle(c.Cache, b) - if err := c.Repo.SaveBattle(c.Cache.g.Turn, report, &battleMeta[i]); err != nil { + if err := c.repo.SaveBattle(c.Cache.g.Turn, report, &battleMeta[i]); err != nil { return err } battleReport[i] = report @@ -118,12 +118,12 @@ func (c *Controller) MakeTurn() error { c.Cache.DeleteKilledShipGroups() // Store game state for the new turn and 'current' state as well - if err := c.Repo.SaveNewTurn(c.Cache.g.Turn, c.Cache.g); err != nil { + if err := c.repo.SaveNewTurn(c.Cache.g.Turn, c.Cache.g); err != nil { return err } for rep := range c.Cache.Report(c.Cache.g.Turn, battleReport, bombingReport) { - if err := c.Repo.SaveReport(c.Cache.g.Turn, rep); err != nil { + if err := c.repo.SaveReport(c.Cache.g.Turn, rep); err != nil { return err } } diff --git a/game/internal/controller/order.go b/game/internal/controller/order.go index 6a30d30..7c87da9 100644 --- a/game/internal/controller/order.go +++ b/game/internal/controller/order.go @@ -127,7 +127,7 @@ func (c *Controller) applyOrders(t uint) error { cmdApplied := make(map[string]bool) for ri := range c.Cache.listRaceActingIdx() { - o, ok, err := c.Repo.LoadOrder(t, c.Cache.g.Race[ri].ID) + o, ok, err := c.repo.LoadOrder(t, c.Cache.g.Race[ri].ID) if err != nil { return err } @@ -166,7 +166,7 @@ func (c *Controller) applyOrders(t uint) error { _ = c.applyCommand(commandRace[cmd.CommandID()], cmd) } // re-save order to persist possible changed commands result outcome - if err := c.Repo.SaveOrder(t, c.Cache.g.Race[ri].ID, &order.UserGamesOrder{ + if err := c.repo.SaveOrder(t, c.Cache.g.Race[ri].ID, &order.UserGamesOrder{ GameID: c.Cache.g.ID, UpdatedAt: raceOrderUpdated[ri], Commands: raceOrder[ri], diff --git a/game/internal/controller/service_test.go b/game/internal/controller/service_test.go new file mode 100644 index 0000000..b0a51c7 --- /dev/null +++ b/game/internal/controller/service_test.go @@ -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") +} diff --git a/game/internal/repo/fs/fs.go b/game/internal/repo/fs/fs.go index e3f4c04..0c30c59 100644 --- a/game/internal/repo/fs/fs.go +++ b/game/internal/repo/fs/fs.go @@ -5,26 +5,19 @@ import ( "errors" "fmt" "galaxy/util" - "math/big" "os" "path/filepath" - "time" ) -const ( - defaultPerm = 0o644 - lockFile = ".lock" - oldFileSuffix = ".old" - newFileSuffix = ".new" -) +const defaultPerm = 0o644 -type fs struct { +// FS is the file-backed Storage implementation: atomic, lock-free reads and +// writes rooted at a single per-game directory. +type FS struct { root string - lock *os.File } -func NewFileStorage(path string) (*fs, error) { - filepath.Join("", "") +func NewFileStorage(path string) (*FS, error) { absPath, err := filepath.Abs(path) if err != nil { return nil, fmt.Errorf("path %s invalid: %s", path, err) @@ -41,55 +34,26 @@ func NewFileStorage(path string) (*fs, error) { return nil, errors.New("directory should have read-write access: " + absPath) } - fs := &fs{ - root: path, - } - return fs, nil + return &FS{root: path}, nil } -func (f *fs) Lock() (func() error, error) { - lockPath := f.lockFilePath() - exists, err := util.FileExists(lockPath) - if err != nil { - return nil, fmt.Errorf("check lock file exists: %s", err) - } - if exists { - return nil, errors.New("lock file already exists") - } - fd, err := os.OpenFile(lockPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) - if err != nil { - return nil, fmt.Errorf("create lock file: %s", err) - } - f.lock = fd - unlock := func() error { - if err := f.lock.Close(); err != nil { - return fmt.Errorf("close lock file: %s", err) - } - if err := os.Remove(f.lock.Name()); err != nil { - return fmt.Errorf("remove lock file: %s", err) - } - f.lock = nil - return nil - } - if _, err := f.lock.Write(big.NewInt(time.Now().UnixMilli()).Bytes()); err != nil { - return nil, errors.Join(fmt.Errorf("write lock file: %s", err), unlock()) - } - return unlock, nil -} - -func (f *fs) Exists(path string) (bool, error) { +func (f *FS) Exists(path string) (bool, error) { return util.FileExists(filepath.Join(f.root, path)) } -func (f *fs) WriteSafe(path string, v encoding.BinaryMarshaler) error { +// Write atomically persists v at path: it stages the payload in a temporary +// file and swaps it into place with a single rename. On POSIX rename replaces +// the destination atomically, so a concurrent reader always observes either +// the previous file or the new one in full — the target is never absent +// mid-write and never half-written. This atomic replace is the only +// protection against torn reads; the storage holds no lock, and concurrent +// writers to the same state file are serialised one layer up, at the router. +func (f *FS) Write(path string, v encoding.BinaryMarshaler) error { if v == nil { return errors.New("cant't marshal from nil object") } targetFilePath := filepath.Join(f.root, path) - if targetFilePath == f.lockFilePath() { - return errors.New("can't write to the lock file") - } data, err := v.MarshalBinary() if err != nil { @@ -103,120 +67,53 @@ func (f *fs) WriteSafe(path string, v encoding.BinaryMarshaler) error { return fmt.Errorf("check target dir exists: %s", err) } if !ok { - err := os.MkdirAll(targetDir, os.ModePerm) - if err != nil { + if err := os.MkdirAll(targetDir, os.ModePerm); err != nil { return fmt.Errorf("create target dirs: %s", err) } } } - oldFilePath := targetFilePath + oldFileSuffix - targetExists, err := util.FileExists(targetFilePath) + // Stage the payload in a uniquely named temporary file next to the target + // and swap it in with a single rename. A unique temp name means a crashed + // write leaves no fixed-name leftover that would block later writes, and a + // single rename is the atomic replace POSIX guarantees. + tmp, err := os.CreateTemp(targetDir, filepath.Base(targetFilePath)+".tmp-*") if err != nil { - return fmt.Errorf("check target file exists: %s", err) + return fmt.Errorf("create temp file: %s", err) } - if targetExists { - oldFileExists, err := util.FileExists(oldFilePath) - if err != nil { - return fmt.Errorf("check old file exists: %s", err) - } - if oldFileExists { - return fmt.Errorf("old file exists at: %s", oldFilePath) - } + tmpPath := tmp.Name() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpPath) + return fmt.Errorf("write temp file: %s", err) } - - newFilePath := targetFilePath + newFileSuffix - newFileExists, err := util.FileExists(newFilePath) - if err != nil { - return fmt.Errorf("check new file exists: %s", err) + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("close temp file: %s", err) } - if newFileExists { - return fmt.Errorf("new file exists at: %s", oldFilePath) + if err := os.Chmod(tmpPath, defaultPerm); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("chmod temp file: %s", err) } - - err = os.WriteFile(newFilePath, data, defaultPerm) - if err != nil { - return fmt.Errorf("write data to the new file: %s", err) - } - - if targetExists { - if err := os.Rename(targetFilePath, oldFilePath); err != nil { - return fmt.Errorf("rename target file to the old file: %s", err) - } - } - - if err := os.Rename(newFilePath, targetFilePath); err != nil { - return fmt.Errorf("rename new file to the target file: %s", err) - } - - if targetExists { - err := os.Remove(oldFilePath) - if err != nil { - return fmt.Errorf("remove old file: %s", err) - } + if err := os.Rename(tmpPath, targetFilePath); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("replace target file: %s", err) } return nil } -func (f *fs) Write(path string, v encoding.BinaryMarshaler) error { - if f.lock == nil { - return errors.New("lock must be acquired before write") - } - - return f.WriteSafe(path, v) -} - -func (f *fs) Read(path string, v encoding.BinaryUnmarshaler) error { - if f.lock == nil { - return errors.New("lock must be acquired before read") - } - - return f.readUnsafe(path, v) -} - -func (f *fs) ReadSafe(path string, v encoding.BinaryUnmarshaler) error { - if f.lock != nil { - timeout := time.NewTimer(time.Millisecond * 100) - checker := time.NewTicker(time.Millisecond) - out: - for { - select { - case <-checker.C: - if f.lock == nil { - checker.Stop() - timeout.Stop() - break out - } - case <-timeout.C: - checker.Stop() - return errors.New("timeout waiting for lock release") - } - } - } - - return f.readUnsafe(path, v) -} - -// readUnsafe reads the file contents without locking mechanism in mind. -// Using readUnsafe directly may cause errors if file being written at the moment. -func (f *fs) readUnsafe(file string, v encoding.BinaryUnmarshaler) error { +// Read loads path into v. Reads need no lock: because Write swaps files into +// place atomically with rename, a reader always observes a complete file even +// when a write is in flight. +func (f *FS) Read(path string, v encoding.BinaryUnmarshaler) error { if v == nil { return errors.New("can't unmarshal to a nil object") } - targetFilePath := filepath.Join(f.root, file) - if targetFilePath == f.lockFilePath() { - return errors.New("can't read from the lock file") - } - - data, err := os.ReadFile(targetFilePath) + data, err := os.ReadFile(filepath.Join(f.root, path)) if err != nil { return fmt.Errorf("reading data file: %s", err) } return v.UnmarshalBinary(data) } - -func (f *fs) lockFilePath() string { - return filepath.Join(f.root, lockFile) -} diff --git a/game/internal/repo/fs/fs_test.go b/game/internal/repo/fs/fs_test.go index 85ab6a8..dd21b51 100644 --- a/game/internal/repo/fs/fs_test.go +++ b/game/internal/repo/fs/fs_test.go @@ -1,9 +1,11 @@ package fs_test import ( + "bytes" "os" "path/filepath" "slices" + "sync" "testing" "galaxy/game/internal/repo/fs" @@ -12,10 +14,6 @@ import ( "github.com/stretchr/testify/assert" ) -const ( - lockFile = ".lock" -) - type sampleData struct { data []byte } @@ -36,20 +34,6 @@ func TestNewFileStorageSuccess(t *testing.T) { assert.NoError(t, err) } -func TestLock(t *testing.T) { - root, cleanup := util.CreateWorkDir(t) - defer cleanup() - fs, err := fs.NewFileStorage(root) - assert.NoError(t, err, "create file storage") - unlock, err := fs.Lock() - assert.NoError(t, err, "acquire lock") - lockPath := filepath.Join(root, lockFile) - assert.FileExists(t, lockPath, "lock file should be created") - err = unlock() - assert.NoError(t, err, "unlocking existing lock") - assert.NoFileExists(t, lockPath, "lock file must be removed") -} - func TestExist(t *testing.T) { root, cleanup := util.CreateWorkDir(t) defer cleanup() @@ -78,9 +62,6 @@ func TestWrite(t *testing.T) { fs, err := fs.NewFileStorage(root) assert.NoError(t, err, "create file storage: %s", err) - unlock, err := fs.Lock() - assert.NoError(t, err, "acquire lock: %s", err) - dirName := "some-dir" if err := os.Mkdir(filepath.Join(root, dirName), os.ModePerm); err != nil { t.Fatal(err) @@ -93,9 +74,8 @@ func TestWrite(t *testing.T) { {path: "file-1.ext"}, {path: "/dir/file-2.ext"}, {path: "dir/subdir/file-3.ext"}, - {path: lockFile, err: "write to the lock file"}, - {path: dirName, err: "wrong type"}, - {path: "/" + dirName, err: "wrong type"}, + {path: dirName, err: "file exists"}, + {path: "/" + dirName, err: "file exists"}, } { t.Run(tc.path, func(t *testing.T) { sd := &sampleData{[]byte{0, 1, 2, 3}} @@ -103,13 +83,26 @@ func TestWrite(t *testing.T) { if tc.err == "" { assert.NoError(t, err) assert.FileExists(t, filepath.Join(root, tc.path), "the written file should exist") - } else if tc.err != "" { + } else { assert.ErrorContains(t, err, tc.err) } }) } +} - assert.NoError(t, unlock(), "unlocking existing lock") +func TestWriteLeavesNoTempLeftovers(t *testing.T) { + root, cleanup := util.CreateWorkDir(t) + defer cleanup() + + s, err := fs.NewFileStorage(root) + assert.NoError(t, err) + + assert.NoError(t, s.Write("state.bin", &sampleData{[]byte{1, 2, 3}})) + + entries, err := os.ReadDir(root) + assert.NoError(t, err) + assert.Len(t, entries, 1, "a successful write must leave only the target file, no temporaries") + assert.Equal(t, "state.bin", entries[0].Name()) } func TestRead(t *testing.T) { @@ -121,11 +114,6 @@ func TestRead(t *testing.T) { fs, err := fs.NewFileStorage(root) assert.NoError(t, err, "create file storage: %s", err) - assert.EqualError(t, fs.Read("some.file", sd), "lock must be acquired before read") - - unlock, err := fs.Lock() - assert.NoError(t, err, "acquire lock: %s", err) - dirName := "some-dir" if err := os.Mkdir(filepath.Join(root, dirName), os.ModePerm); err != nil { t.Fatal(err) @@ -142,33 +130,82 @@ func TestRead(t *testing.T) { }{ {path: fileName}, {path: "/" + fileName}, - {path: lockFile, err: "read from the lock file"}, {path: "dir/subdir/file-3.ext", err: "no such file"}, - {path: lockFile, err: "read from the lock file"}, {path: dirName, err: "is a directory"}, } { t.Run(tc.path, func(t *testing.T) { err = fs.Read(tc.path, sd) if tc.err == "" { assert.NoError(t, err) - assert.FileExists(t, filepath.Join(root, tc.path), "the written file should exist") - } else if tc.err != "" { + } else { assert.ErrorContains(t, err, tc.err) } }) } - assert.NoError(t, unlock(), "unlocking existing lock") } -func TestWriteErrorWithoutLock(t *testing.T) { +// TestReadAtomicUnderConcurrentWrites is the regression that guards the +// lock-free contract: with Write swapping files in via a single rename, a +// concurrent Read must always observe one previously written payload in full — +// never a torn mix and never a missing file. The two payloads differ in length +// so any partial read is detectable. +func TestReadAtomicUnderConcurrentWrites(t *testing.T) { root, cleanup := util.CreateWorkDir(t) defer cleanup() - fs, err := fs.NewFileStorage(root) - assert.NoError(t, err, "create file storage") - sd := &sampleData{[]byte{0, 1, 2, 3}} - err = fs.Write("some/path", sd) - assert.Error(t, err, "should return error when no lock acquired") - assert.EqualError(t, err, "lock must be acquired before write") + + s, err := fs.NewFileStorage(root) + assert.NoError(t, err) + + const path = "state.bin" + payloads := [][]byte{ + bytes.Repeat([]byte{0xAA}, 4096), + bytes.Repeat([]byte{0xBB}, 8192), + } + assert.NoError(t, s.Write(path, &sampleData{slices.Clone(payloads[0])})) + + stop := make(chan struct{}) + var writers sync.WaitGroup + for w := range 4 { + writers.Go(func() { + for { + select { + case <-stop: + return + default: + _ = s.Write(path, &sampleData{slices.Clone(payloads[w%len(payloads)])}) + } + } + }) + } + + var readers sync.WaitGroup + for range 8 { + readers.Go(func() { + for range 1000 { + sd := new(sampleData) + if err := s.Read(path, sd); err != nil { + t.Errorf("read during concurrent write failed: %v", err) + return + } + if !knownPayload(sd.data, payloads) { + t.Errorf("read observed a torn payload (len=%d)", len(sd.data)) + return + } + } + }) + } + readers.Wait() + close(stop) + writers.Wait() +} + +func knownPayload(got []byte, want [][]byte) bool { + for _, w := range want { + if bytes.Equal(got, w) { + return true + } + } + return false } func TestNewFileStorageErrorNotExists(t *testing.T) { diff --git a/game/internal/repo/game.go b/game/internal/repo/game.go index f466c3a..37580c6 100644 --- a/game/internal/repo/game.go +++ b/game/internal/repo/game.go @@ -19,6 +19,7 @@ import ( "galaxy/model/report" "galaxy/game/internal/model/game" + "galaxy/game/internal/repo/fs" "github.com/google/uuid" ) @@ -42,11 +43,11 @@ func (o *storedOrder) UnmarshalBinary(data []byte) error { return json.Unmarshal(data, o) } -func (r *repo) SaveNewTurn(t uint, g *game.Game) error { +func (r *Repo) SaveNewTurn(t uint, g *game.Game) error { return saveNewTurn(r.s, t, g) } -func saveNewTurn(s Storage, t uint, g *game.Game) error { +func saveNewTurn(s *fs.FS, t uint, g *game.Game) error { path := fmt.Sprintf("%s/state.json", TurnDir(t)) exist, err := s.Exists(path) if err != nil { @@ -61,27 +62,23 @@ func saveNewTurn(s Storage, t uint, g *game.Game) error { return saveLastState(s, g) } -func (r *repo) SaveLastState(g *game.Game) error { +func (r *Repo) SaveLastState(g *game.Game) error { return saveLastState(r.s, g) } -func saveLastState(s Storage, g *game.Game) error { +func saveLastState(s *fs.FS, g *game.Game) error { if err := s.Write(statePath, g); err != nil { return NewStorageError(err) } return nil } -func (r *repo) LoadState() (*game.Game, error) { - return loadState(r.s, true) +func (r *Repo) LoadState() (*game.Game, error) { + return loadState(r.s) } -func (r *repo) LoadStateSafe() (*game.Game, error) { - return loadState(r.s, false) -} - -func loadState(s Storage, locked bool) (*game.Game, error) { - var result *game.Game = new(game.Game) +func loadState(s *fs.FS) (*game.Game, error) { + result := new(game.Game) path := statePath exist, err := s.Exists(path) if err != nil { @@ -90,19 +87,13 @@ func loadState(s Storage, locked bool) (*game.Game, error) { if !exist { return nil, NewGameNotInitializedError() } - if locked { - if err := s.Read(path, result); err != nil { - return nil, NewStorageError(err) - } - } else { - if err := s.ReadSafe(path, result); err != nil { - return nil, NewStorageError(err) - } + if err := s.Read(path, result); err != nil { + return nil, NewStorageError(err) } return result, nil } -func loadMeta(s Storage) (*game.GameMeta, error) { +func loadMeta(s *fs.FS) (*game.GameMeta, error) { var result *game.GameMeta = new(game.GameMeta) path := metaPath exist, err := s.Exists(path) @@ -112,13 +103,13 @@ func loadMeta(s Storage) (*game.GameMeta, error) { if !exist { return result, nil } - if err := s.ReadSafe(path, result); err != nil { + if err := s.Read(path, result); err != nil { return nil, NewStorageError(err) } return result, nil } -func loadTurnMeta(s Storage, turn uint) (*game.GameMeta, error) { +func loadTurnMeta(s *fs.FS, turn uint) (*game.GameMeta, error) { var result *game.GameMeta = new(game.GameMeta) path := fmt.Sprintf("%s/%s", TurnDir(turn), metaPath) exist, err := s.Exists(path) @@ -128,13 +119,13 @@ func loadTurnMeta(s Storage, turn uint) (*game.GameMeta, error) { if !exist { return result, nil } - if err := s.ReadSafe(path, result); err != nil { + if err := s.Read(path, result); err != nil { return nil, NewStorageError(err) } return result, nil } -func saveMeta(s Storage, turn uint, gm *game.GameMeta) error { +func saveMeta(s *fs.FS, turn uint, gm *game.GameMeta) error { // save turn's meta path := fmt.Sprintf("%s/%s", TurnDir(turn), metaPath) if err := s.Write(path, gm); err != nil { @@ -148,7 +139,7 @@ func saveMeta(s Storage, turn uint, gm *game.GameMeta) error { return nil } -func (r *repo) LoadBattle(turn uint, id uuid.UUID) (*report.BattleReport, bool, error) { +func (r *Repo) LoadBattle(turn uint, id uuid.UUID) (*report.BattleReport, bool, error) { meta, err := loadTurnMeta(r.s, turn) if err != nil { return nil, false, err @@ -164,7 +155,7 @@ func (r *repo) LoadBattle(turn uint, id uuid.UUID) (*report.BattleReport, bool, return result, true, nil } -func (r *repo) SaveBattle(turn uint, b *report.BattleReport, m *game.BattleMeta) error { +func (r *Repo) SaveBattle(turn uint, b *report.BattleReport, m *game.BattleMeta) error { meta, err := loadMeta(r.s) if err != nil { return err @@ -177,7 +168,7 @@ func (r *repo) SaveBattle(turn uint, b *report.BattleReport, m *game.BattleMeta) return saveMeta(r.s, turn, meta) } -func saveBattle(s Storage, turn uint, b *report.BattleReport) error { +func saveBattle(s *fs.FS, turn uint, b *report.BattleReport) error { path := fmt.Sprintf("%s/battle/%s.json", TurnDir(turn), b.ID.String()) exist, err := s.Exists(path) if err != nil { @@ -192,7 +183,7 @@ func saveBattle(s Storage, turn uint, b *report.BattleReport) error { return nil } -func loadBattle(s Storage, turn uint, id uuid.UUID) (*report.BattleReport, error) { +func loadBattle(s *fs.FS, turn uint, id uuid.UUID) (*report.BattleReport, error) { path := fmt.Sprintf("%s/battle/%s.json", TurnDir(turn), id.String()) exist, err := s.Exists(path) if err != nil { @@ -202,13 +193,13 @@ func loadBattle(s Storage, turn uint, id uuid.UUID) (*report.BattleReport, error return nil, NewStateError(fmt.Sprintf("battle %v for turn %d never was saved", id, turn)) } result := new(report.BattleReport) - if err := s.ReadSafe(path, result); err != nil { + if err := s.Read(path, result); err != nil { return nil, NewStorageError(err) } return result, nil } -func (r *repo) SaveBombings(turn uint, b []*game.Bombing) error { +func (r *Repo) SaveBombings(turn uint, b []*game.Bombing) error { meta, err := loadMeta(r.s) if err != nil { return err @@ -219,11 +210,11 @@ func (r *repo) SaveBombings(turn uint, b []*game.Bombing) error { return saveMeta(r.s, turn, meta) } -func (r *repo) SaveReport(turn uint, rep *report.Report) error { +func (r *Repo) SaveReport(turn uint, rep *report.Report) error { return saveReport(r.s, turn, rep) } -func saveReport(s Storage, t uint, v *report.Report) error { +func saveReport(s *fs.FS, t uint, v *report.Report) error { path := ReportDir(t, v.RaceID) if err := s.Write(path, v); err != nil { return NewStorageError(err) @@ -231,11 +222,11 @@ func saveReport(s Storage, t uint, v *report.Report) error { return nil } -func (r *repo) LoadReport(turn uint, id uuid.UUID) (*report.Report, error) { +func (r *Repo) LoadReport(turn uint, id uuid.UUID) (*report.Report, error) { return loadReport(r.s, turn, id) } -func loadReport(s Storage, turn uint, id uuid.UUID) (*report.Report, error) { +func loadReport(s *fs.FS, turn uint, id uuid.UUID) (*report.Report, error) { path := ReportDir(turn, id) result := new(report.Report) exist, err := s.Exists(path) @@ -245,29 +236,29 @@ func loadReport(s Storage, turn uint, id uuid.UUID) (*report.Report, error) { if !exist { return nil, NewReportNotFoundError() } - if err := s.ReadSafe(path, result); err != nil { + if err := s.Read(path, result); err != nil { return nil, NewStorageError(err) } return result, nil } -func (r *repo) SaveOrder(t uint, id uuid.UUID, o *order.UserGamesOrder) error { +func (r *Repo) SaveOrder(t uint, id uuid.UUID, o *order.UserGamesOrder) error { return saveOrder(r.s, t, id, o) } -func saveOrder(s Storage, t uint, id uuid.UUID, o *order.UserGamesOrder) error { +func saveOrder(s *fs.FS, t uint, id uuid.UUID, o *order.UserGamesOrder) error { path := OrderDir(t, id) - if err := s.WriteSafe(path, o); err != nil { + if err := s.Write(path, o); err != nil { return NewStorageError(err) } return nil } -func (r *repo) LoadOrder(t uint, id uuid.UUID) (*order.UserGamesOrder, bool, error) { +func (r *Repo) LoadOrder(t uint, id uuid.UUID) (*order.UserGamesOrder, bool, error) { return loadOrder(r.s, t, id) } -func loadOrder(s Storage, t uint, id uuid.UUID) (*order.UserGamesOrder, bool, error) { +func loadOrder(s *fs.FS, t uint, id uuid.UUID) (*order.UserGamesOrder, bool, error) { path := OrderDir(t, id) exist, err := s.Exists(path) @@ -279,7 +270,7 @@ func loadOrder(s Storage, t uint, id uuid.UUID) (*order.UserGamesOrder, bool, er } stored := new(storedOrder) - if err := s.ReadSafe(path, stored); err != nil { + if err := s.Read(path, stored); err != nil { return nil, false, NewStorageError(err) } // An empty stored batch is a valid state — the player either diff --git a/game/internal/repo/repo.go b/game/internal/repo/repo.go index 592dce5..a521da9 100644 --- a/game/internal/repo/repo.go +++ b/game/internal/repo/repo.go @@ -1,9 +1,6 @@ package repo import ( - "encoding" - "errors" - e "galaxy/error" "galaxy/game/internal/repo/fs" @@ -25,66 +22,23 @@ func NewStateError(msg string) error { return e.NewGameStateError(msg) } -type Storage interface { - Lock() (func() error, error) - Exists(string) (bool, error) - Write(string, encoding.BinaryMarshaler) error - WriteSafe(string, encoding.BinaryMarshaler) error - Read(string, encoding.BinaryUnmarshaler) error - ReadSafe(string, encoding.BinaryUnmarshaler) error +// Repo persists game state through a file-backed FS. Reads and writes are +// atomic and lock-free: Write swaps a fully written file into place with +// rename, so Read never observes a partial file. Serialising concurrent +// writers to the same state file is the caller's concern (the engine does it +// at the router, see LimitMiddleware). +type Repo struct { + s *fs.FS } -type repo struct { - s Storage - release func() error +func NewRepo(s *fs.FS) (*Repo, error) { + return &Repo{s: s}, nil } -func NewRepo(s Storage) (*repo, error) { - r := &repo{ - s: s, - } - return r, nil -} - -func NewFileRepo(path string) (*repo, error) { +func NewFileRepo(path string) (*Repo, error) { s, err := fs.NewFileStorage(path) if err != nil { return nil, err } return NewRepo(s) } - -func (r *repo) Lock() (err error) { - if r.s == nil { - return errors.New("storage is closed") - } - if r.release != nil { - return errors.New("storage already locked") - } - r.release, err = r.s.Lock() - if err != nil { - r.close() - return - } - return nil -} - -func (r *repo) Release() (err error) { - if r.s == nil { - return errors.New("storage is closed") - } - if r.release == nil { - return errors.New("storage was never locked") - } - err = r.release() - if err != nil { - return - } - r.close() - return nil -} - -func (r *repo) close() { - r.release = nil - r.s = nil -} diff --git a/game/internal/repo/repo_export_test.go b/game/internal/repo/repo_export_test.go index e544141..6ac148a 100644 --- a/game/internal/repo/repo_export_test.go +++ b/game/internal/repo/repo_export_test.go @@ -3,13 +3,15 @@ package repo import ( "galaxy/model/order" + "galaxy/game/internal/repo/fs" + "github.com/google/uuid" ) -func LoadOrder_T(s Storage, t uint, id uuid.UUID) (*order.UserGamesOrder, bool, error) { +func LoadOrder_T(s *fs.FS, t uint, id uuid.UUID) (*order.UserGamesOrder, bool, error) { return loadOrder(s, t, id) } -func SaveOrder_T(s Storage, t uint, id uuid.UUID, o *order.UserGamesOrder) error { +func SaveOrder_T(s *fs.FS, t uint, id uuid.UUID, o *order.UserGamesOrder) error { return saveOrder(s, t, id, o) } diff --git a/game/internal/repo/repo_test.go b/game/internal/repo/repo_test.go index b504df8..c36d162 100644 --- a/game/internal/repo/repo_test.go +++ b/game/internal/repo/repo_test.go @@ -92,7 +92,7 @@ func TestSaveOrder(t *testing.T) { LoadOrderTest(t, s, root, turn, id, o) } -func LoadOrderTest(t *testing.T, s repo.Storage, root string, turn uint, id uuid.UUID, expected *order.UserGamesOrder) { +func LoadOrderTest(t *testing.T, s *fs.FS, root string, turn uint, id uuid.UUID, expected *order.UserGamesOrder) { o, ok, err := repo.LoadOrder_T(s, turn, id) assert.NoError(t, err) assert.True(t, ok) diff --git a/game/internal/router/command_test.go b/game/internal/router/command_test.go deleted file mode 100644 index 92f6ed4..0000000 --- a/game/internal/router/command_test.go +++ /dev/null @@ -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) -} diff --git a/game/internal/router/handler/banish.go b/game/internal/router/handler/banish.go index 2448ba5..59c4277 100644 --- a/game/internal/router/handler/banish.go +++ b/game/internal/router/handler/banish.go @@ -8,13 +8,13 @@ import ( "github.com/gin-gonic/gin" ) -func BanishHandler(c *gin.Context, executor CommandExecutor) { +func BanishHandler(c *gin.Context, engine Engine) { var req rest.BanishRequest if errorResponse(c, c.ShouldBindJSON(&req)) { return } - if errorResponse(c, executor.BanishRace(req.RaceName)) { + if errorResponse(c, engine.BanishRace(req.RaceName)) { return } diff --git a/game/internal/router/handler/battle.go b/game/internal/router/handler/battle.go index 4322b23..66f2c0d 100644 --- a/game/internal/router/handler/battle.go +++ b/game/internal/router/handler/battle.go @@ -8,7 +8,7 @@ import ( "github.com/google/uuid" ) -func BattleHandler(c *gin.Context, executor CommandExecutor) { +func BattleHandler(c *gin.Context, engine Engine) { turn := c.Param("turn") t, err := strconv.Atoi(turn) if err != nil { @@ -25,7 +25,7 @@ func BattleHandler(c *gin.Context, executor CommandExecutor) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - r, exists, err := executor.FetchBattle(uint(t), battleID) + r, exists, err := engine.FetchBattle(uint(t), battleID) if errorResponse(c, err) { return } diff --git a/game/internal/router/handler/command.go b/game/internal/router/handler/command.go deleted file mode 100644 index 23c9202..0000000 --- a/game/internal/router/handler/command.go +++ /dev/null @@ -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 -} diff --git a/game/internal/router/handler/handler.go b/game/internal/router/handler/handler.go index bd04ea5..42741a7 100644 --- a/game/internal/router/handler/handler.go +++ b/game/internal/router/handler/handler.go @@ -12,7 +12,6 @@ import ( e "galaxy/error" - "galaxy/game/internal/controller" "galaxy/game/internal/model/game" "github.com/gin-gonic/gin" @@ -20,25 +19,22 @@ import ( "github.com/google/uuid" ) -type CommandExecutor interface { - GenerateGame(gameID uuid.UUID, races []string) (rest.StateResponse, error) - GenerateTurn() (rest.StateResponse, error) - GameState() (rest.StateResponse, error) - BanishRace(string) error +// Engine is the set of operations the HTTP handlers invoke on the game engine. +// Its sole production implementation is *controller.Service; the interface +// exists so the transport layer can be exercised against a lightweight fake +// without standing up real storage. Methods return domain types — handlers own +// the projection into the REST wire shapes. +type Engine interface { + GenerateGame(gameID uuid.UUID, races []string) (game.State, error) + GenerateTurn() (game.State, error) + GameState() (game.State, error) + BanishRace(actor string) error LoadReport(actor string, turn uint) (*report.Report, error) - // Execute is reserved for future use; any API request for orders should use ValidateOrder - Execute(cmd ...Command) error ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) FetchOrder(actor string, turn uint) (*order.UserGamesOrder, bool, error) FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, bool, error) } -type Command func(controller.Ctrl) error - -type executor struct { - cfg controller.Configurer -} - // ResolveStoragePath returns the engine storage path resolved from // STORAGE_PATH (preferred, historical name) or GAME_STATE_PATH (canonical // name written by Runtime Manager). It returns an error when neither @@ -53,77 +49,8 @@ func ResolveStoragePath() (string, error) { return "", errors.New("storage path is not set: provide STORAGE_PATH or GAME_STATE_PATH") } -func initConfig() controller.Configurer { - return func(p *controller.Param) { - // Validated once at startup by ResolveStoragePath; the error - // is dropped here to keep the Configurer signature simple. - p.StoragePath, _ = ResolveStoragePath() - } -} - -func NewDefaultExecutor() CommandExecutor { - return NewDefaultConfigExecutor(initConfig()) -} - -func NewDefaultConfigExecutor(configurer controller.Configurer) CommandExecutor { - return &executor{cfg: configurer} -} - -func (e *executor) Execute(cmd ...Command) error { - return controller.ExecuteCommand(e.cfg, func(c controller.Ctrl) error { - for i := range cmd { - if err := cmd[i](c); err != nil { - return err - } - } - return nil - }) -} - -func (e *executor) ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) { - return controller.ValidateOrder(e.cfg, actor, cmd...) -} - -func (e *executor) FetchOrder(actor string, turn uint) (*order.UserGamesOrder, bool, error) { - return controller.FetchOrder(e.cfg, actor, turn) -} - -func (e *executor) FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, bool, error) { - return controller.FetchBattle(e.cfg, turn, ID) -} - -func (e *executor) GenerateGame(gameID uuid.UUID, races []string) (rest.StateResponse, error) { - s, err := controller.GenerateGame(e.cfg, gameID, races) - if err != nil { - return rest.StateResponse{}, err - } - return stateResponse(s), nil -} - -func (e *executor) GenerateTurn() (rest.StateResponse, error) { - err := controller.GenerateTurn(e.cfg) - if err != nil { - return rest.StateResponse{}, err - } - return e.GameState() -} - -func (e *executor) GameState() (rest.StateResponse, error) { - s, err := controller.GameState(e.cfg) - if err != nil { - return rest.StateResponse{}, err - } - return stateResponse(s), nil -} - -func (e *executor) BanishRace(raceName string) error { - return controller.BanishRace(e.cfg, raceName) -} - -func (e *executor) LoadReport(actor string, turn uint) (*report.Report, error) { - return controller.LoadReport(e.cfg, actor, turn) -} - +// stateResponse projects the engine's domain game.State into the REST +// StateResponse wire shape. func stateResponse(s game.State) rest.StateResponse { result := &rest.StateResponse{ ID: s.ID, diff --git a/game/internal/router/handler/init.go b/game/internal/router/handler/init.go index 08db7d3..2a27c2d 100644 --- a/game/internal/router/handler/init.go +++ b/game/internal/router/handler/init.go @@ -11,7 +11,7 @@ import ( "github.com/google/uuid" ) -func InitHandler(c *gin.Context, executor CommandExecutor) { +func InitHandler(c *gin.Context, engine Engine) { var init rest.InitRequest if errorResponse(c, c.ShouldBindJSON(&init)) { return @@ -26,7 +26,7 @@ func InitHandler(c *gin.Context, executor CommandExecutor) { races[i] = init.Races[i].RaceName } - s, err := executor.GenerateGame(init.GameID, races) + s, err := engine.GenerateGame(init.GameID, races) if err != nil { if errors.Is(err, controller.ErrGameAlreadyInit) { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) @@ -37,5 +37,5 @@ func InitHandler(c *gin.Context, executor CommandExecutor) { } } - c.JSON(http.StatusCreated, s) + c.JSON(http.StatusCreated, stateResponse(s)) } diff --git a/game/internal/router/handler/order.go b/game/internal/router/handler/order.go index 0d424b1..16856dd 100644 --- a/game/internal/router/handler/order.go +++ b/game/internal/router/handler/order.go @@ -9,9 +9,11 @@ import ( "galaxy/game/internal/repo" "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "github.com/go-playground/validator/v10" ) -func PutOrderHandler(c *gin.Context, executor CommandExecutor) { +func PutOrderHandler(c *gin.Context, engine Engine) { var cmd rest.Command if errorResponse(c, c.ShouldBindJSON(&cmd)) { return @@ -30,7 +32,7 @@ func PutOrderHandler(c *gin.Context, executor CommandExecutor) { commands[i] = command } - result, err := executor.ValidateOrder(cmd.Actor, commands...) + result, err := engine.ValidateOrder(cmd.Actor, commands...) if errorResponse(c, err) { return } @@ -43,7 +45,7 @@ type orderParam struct { Turn int `form:"turn" binding:"gte=0"` } -func GetOrderHandler(c *gin.Context, executor CommandExecutor) { +func GetOrderHandler(c *gin.Context, engine Engine) { p := &orderParam{} // ShouldBindQuery surfaces both validator failures and strconv parse // errors; both are client-side faults, so 400 is the correct mapping. @@ -52,7 +54,7 @@ func GetOrderHandler(c *gin.Context, executor CommandExecutor) { return } - o, ok, err := executor.FetchOrder(p.Player, uint(p.Turn)) + o, ok, err := engine.FetchOrder(p.Player, uint(p.Turn)) if errorResponse(c, err) { return } @@ -64,3 +66,15 @@ func GetOrderHandler(c *gin.Context, executor CommandExecutor) { c.JSON(http.StatusOK, o) } + +// validateCommand runs the gin-registered struct validators against a +// decoded command. It is the per-command validation hook shared by the +// order-submission path (PutOrderHandler) and repo.ParseOrder. +func validateCommand(v order.DecodableCommand) error { + if ve, ok := binding.Validator.Engine().(*validator.Validate); ok { + if err := ve.Struct(v); err != nil { + return err + } + } + return nil +} diff --git a/game/internal/router/handler/report.go b/game/internal/router/handler/report.go index f15c0c8..3209275 100644 --- a/game/internal/router/handler/report.go +++ b/game/internal/router/handler/report.go @@ -11,14 +11,14 @@ type reportParam struct { Turn int `form:"turn" binding:"gte=0"` } -func ReportHandler(c *gin.Context, executor CommandExecutor) { +func ReportHandler(c *gin.Context, engine Engine) { p := &reportParam{} err := c.ShouldBindQuery(p) if errorResponse(c, err) { return } - r, err := executor.LoadReport(p.Player, uint(p.Turn)) + r, err := engine.LoadReport(p.Player, uint(p.Turn)) if errorResponse(c, err) { return } diff --git a/game/internal/router/handler/status.go b/game/internal/router/handler/status.go index 5762e58..3063c98 100644 --- a/game/internal/router/handler/status.go +++ b/game/internal/router/handler/status.go @@ -6,12 +6,12 @@ import ( "github.com/gin-gonic/gin" ) -func StatusHandler(c *gin.Context, executor CommandExecutor) { - state, err := executor.GameState() +func StatusHandler(c *gin.Context, engine Engine) { + state, err := engine.GameState() if errorResponse(c, err) { return } - c.JSON(http.StatusOK, state) + c.JSON(http.StatusOK, stateResponse(state)) } diff --git a/game/internal/router/handler/turn.go b/game/internal/router/handler/turn.go index 4b9aff7..8bb6e30 100644 --- a/game/internal/router/handler/turn.go +++ b/game/internal/router/handler/turn.go @@ -6,12 +6,12 @@ import ( "github.com/gin-gonic/gin" ) -func TurnHandler(c *gin.Context, executor CommandExecutor) { - state, err := executor.GenerateTurn() +func TurnHandler(c *gin.Context, engine Engine) { + state, err := engine.GenerateTurn() if errorResponse(c, err) { return } - c.JSON(http.StatusOK, state) + c.JSON(http.StatusOK, stateResponse(state)) } diff --git a/game/internal/router/healthz_test.go b/game/internal/router/healthz_test.go index 50211ec..257abb5 100644 --- a/game/internal/router/healthz_test.go +++ b/game/internal/router/healthz_test.go @@ -6,7 +6,8 @@ import ( "net/http/httptest" "testing" - "galaxy/game/internal/controller" + "galaxy/util" + "galaxy/game/internal/router" "galaxy/game/internal/router/handler" @@ -15,9 +16,10 @@ import ( ) func TestHealthzReturnsOKWithoutInit(t *testing.T) { - r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { - p.StoragePath = "" - })) + root, cleanup := util.CreateWorkDir(t) + defer cleanup() + + r := router.SetupRouter(newService(t, root)) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, "/healthz", nil) diff --git a/game/internal/router/init_test.go b/game/internal/router/init_test.go index addc680..8d16f65 100644 --- a/game/internal/router/init_test.go +++ b/game/internal/router/init_test.go @@ -10,9 +10,7 @@ import ( "galaxy/util" - "galaxy/game/internal/controller" "galaxy/game/internal/router" - "galaxy/game/internal/router/handler" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -22,7 +20,7 @@ func TestInit(t *testing.T) { root, cleanup := util.CreateWorkDir(t) defer cleanup() - r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root })) + r := router.SetupRouter(newService(t, root)) payload := generateInitRequest(10) @@ -51,7 +49,7 @@ func TestInitRejectsNilUUID(t *testing.T) { root, cleanup := util.CreateWorkDir(t) defer cleanup() - r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root })) + r := router.SetupRouter(newService(t, root)) payload := generateInitRequest(10) payload.GameID = uuid.Nil @@ -67,7 +65,7 @@ func TestInitRejectsExistingGameWithDifferentID(t *testing.T) { root, cleanup := util.CreateWorkDir(t) defer cleanup() - r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root })) + r := router.SetupRouter(newService(t, root)) first := generateInitRequest(10) w := httptest.NewRecorder() diff --git a/game/internal/router/middleware.go b/game/internal/router/middleware.go index dc6c66d..9ff6bf1 100644 --- a/game/internal/router/middleware.go +++ b/game/internal/router/middleware.go @@ -2,27 +2,31 @@ package router import ( "net/http" - "time" "github.com/gin-gonic/gin" ) -// LimitMiddleware limits number of concurrent connections using a buffered channel with limit spaces +// LimitMiddleware caps the number of requests executing the routes it guards +// at limit. A request blocks until a slot frees up; if the request context is +// cancelled or expires while waiting, it answers 503 Service Unavailable. +// +// The semaphore is owned by the returned handler, so sharing a single instance +// across several routes serialises those routes against each other. The engine +// relies on this to serialise every operation that mutates the canonical game +// state file, which must never run concurrently against one storage directory. func LimitMiddleware(limit int) gin.HandlerFunc { if limit <= 0 { panic("limit must be greater than 0") } - semaphore := make(chan bool, limit) - t := time.NewTimer(time.Millisecond * 100) + semaphore := make(chan struct{}, limit) return func(c *gin.Context) { - t.Reset(time.Millisecond * 100) select { - case semaphore <- true: + case semaphore <- struct{}{}: + defer func() { <-semaphore }() c.Next() - <-semaphore - case <-t.C: - c.Status(http.StatusGatewayTimeout) + case <-c.Request.Context().Done(): + c.AbortWithStatus(http.StatusServiceUnavailable) } } } diff --git a/game/internal/router/report_test.go b/game/internal/router/report_test.go index 73d0d69..0e1d7e7 100644 --- a/game/internal/router/report_test.go +++ b/game/internal/router/report_test.go @@ -8,9 +8,7 @@ import ( "galaxy/model/rest" - "galaxy/game/internal/controller" "galaxy/game/internal/router" - "galaxy/game/internal/router/handler" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -19,7 +17,7 @@ import ( func TestGetReport(t *testing.T) { root := t.ArtifactDir() - r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root })) + r := router.SetupRouter(newService(t, root)) payload := generateInitRequest(10) diff --git a/game/internal/router/router.go b/game/internal/router/router.go index 15efea9..359d0e8 100644 --- a/game/internal/router/router.go +++ b/game/internal/router/router.go @@ -18,24 +18,20 @@ const ( ) type Router struct { - r *gin.Engine - executor handler.CommandExecutor + r *gin.Engine } func (r Router) Run() error { return r.r.Run() } -func NewRouter() Router { +// NewRouter builds the HTTP router around the supplied engine. +func NewRouter(engine handler.Engine) Router { gin.SetMode(gin.ReleaseMode) - return NewRouterExecutor(handler.NewDefaultExecutor()) + return Router{r: setupRouter(engine)} } -func NewRouterExecutor(executor handler.CommandExecutor) Router { - return Router{r: setupRouter(executor)} -} - -func setupRouter(executor handler.CommandExecutor) *gin.Engine { +func setupRouter(engine handler.Engine) *gin.Engine { r := gin.New() // Logger middleware will write the logs to gin.DefaultWriter even if you set with GIN_MODE=release. @@ -67,19 +63,22 @@ func setupRouter(executor handler.CommandExecutor) *gin.Engine { groupV1 := r.Group("/api/v1") + // One shared limiter serialises every operation that mutates the + // canonical game state file (state.json): there is at most one such + // write in flight at a time. Orders write independent per-player files + // and stay unsynchronised; reads are lock-free. + stateMutationLimit := LimitMiddleware(1) + groupAdmin := groupV1.Group("/admin") - groupAdmin.GET("/status", func(ctx *gin.Context) { handler.StatusHandler(ctx, executor) }) - groupAdmin.POST("/init", func(ctx *gin.Context) { handler.InitHandler(ctx, executor) }) - groupAdmin.PUT("/turn", func(ctx *gin.Context) { handler.TurnHandler(ctx, executor) }) - groupAdmin.POST("/race/banish", func(ctx *gin.Context) { handler.BanishHandler(ctx, executor) }) + groupAdmin.GET("/status", func(ctx *gin.Context) { handler.StatusHandler(ctx, engine) }) + groupAdmin.POST("/init", stateMutationLimit, func(ctx *gin.Context) { handler.InitHandler(ctx, engine) }) + groupAdmin.PUT("/turn", stateMutationLimit, func(ctx *gin.Context) { handler.TurnHandler(ctx, engine) }) + groupAdmin.POST("/race/banish", stateMutationLimit, func(ctx *gin.Context) { handler.BanishHandler(ctx, engine) }) - groupV1.GET("/report", func(ctx *gin.Context) { handler.ReportHandler(ctx, executor) }) - groupV1.PUT("/order", func(ctx *gin.Context) { handler.PutOrderHandler(ctx, executor) }) - groupV1.GET("/order", func(ctx *gin.Context) { handler.GetOrderHandler(ctx, executor) }) - groupV1.GET("/battle/:turn/:uuid", func(ctx *gin.Context) { handler.BattleHandler(ctx, executor) }) - - // /command is reserved for future use; any API request for orders should use /order - groupV1.PUT("/command", LimitMiddleware(1), func(ctx *gin.Context) { handler.CommandHandler(ctx, executor) }) + groupV1.GET("/report", func(ctx *gin.Context) { handler.ReportHandler(ctx, engine) }) + groupV1.PUT("/order", func(ctx *gin.Context) { handler.PutOrderHandler(ctx, engine) }) + groupV1.GET("/order", func(ctx *gin.Context) { handler.GetOrderHandler(ctx, engine) }) + groupV1.GET("/battle/:turn/:uuid", func(ctx *gin.Context) { handler.BattleHandler(ctx, engine) }) return r } diff --git a/game/internal/router/router_export_test.go b/game/internal/router/router_export_test.go index 1041bb7..344c7b8 100644 --- a/game/internal/router/router_export_test.go +++ b/game/internal/router/router_export_test.go @@ -6,7 +6,7 @@ import ( "github.com/gin-gonic/gin" ) -func SetupRouter(e handler.CommandExecutor) *gin.Engine { +func SetupRouter(e handler.Engine) *gin.Engine { gin.SetMode(gin.TestMode) return setupRouter(e) } diff --git a/game/internal/router/router_helper_test.go b/game/internal/router/router_helper_test.go index e7aa8b3..2b3ba2d 100644 --- a/game/internal/router/router_helper_test.go +++ b/game/internal/router/router_helper_test.go @@ -3,11 +3,13 @@ package router_test import ( "encoding/json" "net/http" + "testing" "galaxy/model/order" "galaxy/model/report" - "galaxy/model/rest" + "galaxy/game/internal/controller" + "galaxy/game/internal/model/game" "galaxy/game/internal/router" "galaxy/game/internal/router/handler" @@ -19,7 +21,6 @@ var ( commandNoErrorsStatus = http.StatusAccepted commandDefaultActor = "Gorlum" apiCommandMethod = "PUT" - apiCommandPath = "/api/v1/command" apiOrderPath = "/api/v1/order" validId1 = id() validId2 = id() @@ -81,25 +82,20 @@ func (e *dummyExecutor) FetchBattle(turn uint, ID uuid.UUID) (*report.BattleRepo return e.FetchBattleResult, e.FetchBattleOK, e.FetchBattleErr } -func (e *dummyExecutor) Execute(command ...handler.Command) error { - e.CommandsExecuted = len(command) - return nil +func (e *dummyExecutor) GenerateGame(gameID uuid.UUID, races []string) (game.State, error) { + return game.State{ID: gameID}, nil } -func (e *dummyExecutor) GenerateGame(gameID uuid.UUID, races []string) (rest.StateResponse, error) { - return rest.StateResponse{ID: gameID}, nil -} - -func (e *dummyExecutor) GenerateTurn() (rest.StateResponse, error) { - return rest.StateResponse{}, nil +func (e *dummyExecutor) GenerateTurn() (game.State, error) { + return game.State{}, nil } func (e *dummyExecutor) BanishRace(raceName string) error { return nil } -func (e *dummyExecutor) GameState() (rest.StateResponse, error) { - return rest.StateResponse{}, nil +func (e *dummyExecutor) GameState() (game.State, error) { + return game.State{}, nil } func (e *dummyExecutor) LoadReport(actor string, turn uint) (*report.Report, error) { @@ -110,14 +106,25 @@ func setupRouter() *gin.Engine { return setupRouterExecutor(newExecutor()) } -func setupRouterExecutor(e handler.CommandExecutor) *gin.Engine { +func setupRouterExecutor(e handler.Engine) *gin.Engine { return router.SetupRouter(e) } -func newExecutor() handler.CommandExecutor { +func newExecutor() handler.Engine { return &dummyExecutor{} } +// newService builds a real controller.Service backed by a storage directory, +// for handler tests that exercise the engine end to end rather than the fake. +func newService(t *testing.T, root string) *controller.Service { + t.Helper() + svc, err := controller.NewService(root) + if err != nil { + t.Fatalf("new service: %v", err) + } + return svc +} + func encodeCommand(cmd any) json.RawMessage { v, err := json.Marshal(cmd) if err != nil { diff --git a/game/internal/router/router_test.go b/game/internal/router/router_test.go index a8dda9a..cf60f93 100644 --- a/game/internal/router/router_test.go +++ b/game/internal/router/router_test.go @@ -1,6 +1,7 @@ package router_test import ( + "context" "encoding/json" "fmt" "net/http" @@ -9,6 +10,7 @@ import ( "sync" "sync/atomic" "testing" + "time" "galaxy/model/rest" @@ -38,6 +40,92 @@ func TestLimitConnections(t *testing.T) { wg.Wait() } +// TestLimitSharedInstanceSerialisesRoutes pins the property the engine relies +// on to serialise state mutations: a single LimitMiddleware(1) instance shared +// across several routes admits at most one request across all of them at a +// time. The handler tracks the high-water concurrency and asserts it never +// exceeds one. +func TestLimitSharedInstanceSerialisesRoutes(t *testing.T) { + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.Use(gin.Recovery()) + + shared := router.LimitMiddleware(1) + + var inFlight, maxSeen atomic.Int32 + handler := func(c *gin.Context) { + n := inFlight.Add(1) + for { + cur := maxSeen.Load() + if n <= cur || maxSeen.CompareAndSwap(cur, n) { + break + } + } + time.Sleep(time.Millisecond) // widen the overlap window + inFlight.Add(-1) + c.Status(http.StatusOK) + } + r.GET("/a", shared, handler) + r.PUT("/b", shared, handler) + + wg := sync.WaitGroup{} + for i := range 200 { + method, path := http.MethodGet, "/a" + if i%2 == 1 { + method, path = http.MethodPut, "/b" + } + wg.Go(func() { + w := httptest.NewRecorder() + req, _ := http.NewRequest(method, path, nil) + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, w.Body) + }) + } + wg.Wait() + assert.Equal(t, int32(1), maxSeen.Load(), "a shared limiter must serialise across every route it guards") +} + +// TestLimitReleasesOnContextCancel verifies the wait path: while one request +// holds the only slot, a second request blocked on the limiter answers 503 +// once its request context is cancelled, instead of hanging. +func TestLimitReleasesOnContextCancel(t *testing.T) { + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.Use(gin.Recovery()) + + shared := router.LimitMiddleware(1) + entered := make(chan struct{}) + release := make(chan struct{}) + r.GET("/hold", shared, func(c *gin.Context) { + close(entered) + <-release + c.Status(http.StatusOK) + }) + + // First request grabs and holds the only slot. + go func() { + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/hold", nil) + r.ServeHTTP(w, req) + }() + <-entered + + // Second request blocks on the limiter, then loses its context. + ctx, cancel := context.WithCancel(context.Background()) + w := httptest.NewRecorder() + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "/hold", nil) + done := make(chan struct{}) + go func() { + r.ServeHTTP(w, req) + close(done) + }() + cancel() + <-done + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + close(release) +} + func asBody(body any) *strings.Reader { commandJson, _ := json.Marshal(body) return strings.NewReader(string(commandJson)) diff --git a/game/internal/router/status_test.go b/game/internal/router/status_test.go index 9608e0a..62da3f2 100644 --- a/game/internal/router/status_test.go +++ b/game/internal/router/status_test.go @@ -10,9 +10,7 @@ import ( "galaxy/util" - "galaxy/game/internal/controller" "galaxy/game/internal/router" - "galaxy/game/internal/router/handler" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -22,7 +20,7 @@ func TestGetStatus(t *testing.T) { root, cleanup := util.CreateWorkDir(t) defer cleanup() - r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root })) + r := router.SetupRouter(newService(t, root)) payload := generateInitRequest(10) diff --git a/game/internal/router/turn_test.go b/game/internal/router/turn_test.go index c07bc8f..5ac3a31 100644 --- a/game/internal/router/turn_test.go +++ b/game/internal/router/turn_test.go @@ -10,9 +10,7 @@ import ( "galaxy/util" - "galaxy/game/internal/controller" "galaxy/game/internal/router" - "galaxy/game/internal/router/handler" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -22,7 +20,7 @@ func TestGetTurn(t *testing.T) { root, cleanup := util.CreateWorkDir(t) defer cleanup() - r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root })) + r := router.SetupRouter(newService(t, root)) // create game diff --git a/game/openapi.yaml b/game/openapi.yaml index b2208a9..9ee4e35 100644 --- a/game/openapi.yaml +++ b/game/openapi.yaml @@ -7,13 +7,14 @@ info: The service hosts a single game instance and exposes endpoints for game initialization, turn advancement, game-state queries, player reports, and - batched player command execution. + player order submission. Transport rules: - request bodies are JSON - - `PUT /api/v1/command` is rate-limited to one concurrent execution; - requests that cannot acquire the execution slot within 100 ms receive - `504 Gateway Timeout` + - operations that mutate the persisted game state are serialised engine-wide + to one at a time; such a request blocks until the in-flight mutation + finishes and receives `503 Service Unavailable` if its context is + cancelled while it is still waiting - `501 Not Implemented` is returned without a body when the game has not been initialized - request-binding validation errors return `400` with `{"error": "message"}` @@ -141,33 +142,6 @@ paths: $ref: "#/components/responses/ValidationError" "500": $ref: "#/components/responses/InternalError" - /api/v1/command: - put: - tags: - - PlayerActions - operationId: executeCommands - summary: Execute a batch of player commands - description: | - Applies one or more game commands for the specified actor. Serialized - to one concurrent execution; requests that cannot acquire the execution - slot within 100 ms return `504 Gateway Timeout`. Returns `202 Accepted` - with no body on success. Reserved for future use; player order - submissions go through `/api/v1/order`. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CommandRequest" - responses: - "202": - description: All commands accepted. - "400": - $ref: "#/components/responses/ValidationError" - "504": - description: Command execution slot not acquired within 100 ms. - "500": - $ref: "#/components/responses/InternalError" /api/v1/order: put: tags: diff --git a/game/openapi_contract_test.go b/game/openapi_contract_test.go index 0b12728..da84a98 100644 --- a/game/openapi_contract_test.go +++ b/game/openapi_contract_test.go @@ -109,12 +109,6 @@ func TestGameOpenAPISpecFreezesEmptyResponses(t *testing.T) { method string status int }{ - { - name: "command accepted", - path: "/api/v1/command", - method: http.MethodPut, - status: http.StatusAccepted, - }, { name: "get order no content", path: "/api/v1/order", @@ -273,14 +267,8 @@ func TestGameOpenAPISpecFreezesCommandRequest(t *testing.T) { doc := loadOpenAPISpec(t) - for _, path := range []string{"/api/v1/command", "/api/v1/order"} { - t.Run(path, func(t *testing.T) { - t.Parallel() - - operation := getOpenAPIOperation(t, doc, path, http.MethodPut) - assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/CommandRequest", path+" command request schema") - }) - } + operation := getOpenAPIOperation(t, doc, "/api/v1/order", http.MethodPut) + assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/CommandRequest", "/api/v1/order command request schema") schema := componentSchemaRef(t, doc, "CommandRequest") assertRequiredFields(t, schema, "actor", "cmd") diff --git a/gateway/README.md b/gateway/README.md index e9a927b..28fb905 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -391,7 +391,6 @@ The current direct `Gateway -> User` self-service boundary uses that pattern: - `user.sessions.list` - `user.sessions.revoke` - `user.sessions.revoke_all` - - `user.games.command` - `user.games.order` - `user.games.report` - `lobby.my.games.list` diff --git a/gateway/authn/parity_with_ui_core_test.go b/gateway/authn/parity_with_ui_core_test.go index f54cef6..b84e2c5 100644 --- a/gateway/authn/parity_with_ui_core_test.go +++ b/gateway/authn/parity_with_ui_core_test.go @@ -34,7 +34,7 @@ func TestParityWithUICoreCanonicalBytes(t *testing.T) { gatewayFields := authn.RequestSigningFields{ ProtocolVersion: "v1", DeviceSessionID: "device-session-parity", - MessageType: "user.games.command", + MessageType: "user.games.order", TimestampMS: 1_700_000_000_000, RequestID: "request-parity", PayloadHash: sha256Of([]byte("payload")), diff --git a/gateway/internal/backendclient/games_commands.go b/gateway/internal/backendclient/games_commands.go index 2688464..a247222 100644 --- a/gateway/internal/backendclient/games_commands.go +++ b/gateway/internal/backendclient/games_commands.go @@ -19,11 +19,11 @@ import ( ) // ExecuteGameCommand routes one authenticated `user.games.*` command -// into backend's `/api/v1/user/games/{game_id}/*` endpoints. Command -// and order requests transcode the typed FB-payload into the JSON -// shape the engine expects (a `gamerest.Command` with empty actor — -// backend rebinds the actor from the runtime player mapping). Report -// requests transcode the response Report from JSON back to FB. +// into backend's `/api/v1/user/games/{game_id}/*` endpoints. Order +// requests transcode the typed FB-payload into the JSON shape the +// engine expects (a `gamerest.Command` with empty actor — backend +// rebinds the actor from the runtime player mapping). Report requests +// transcode the response Report from JSON back to FB. func (c *RESTClient) ExecuteGameCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) { if c == nil || c.httpClient == nil { return downstream.UnaryResult{}, errors.New("backendclient: execute game command: nil client") @@ -39,12 +39,6 @@ func (c *RESTClient) ExecuteGameCommand(ctx context.Context, command downstream. } switch command.MessageType { - case ordermodel.MessageTypeUserGamesCommand: - req, err := transcoder.PayloadToUserGamesCommand(command.PayloadBytes) - if err != nil { - return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute game command %q: %w", command.MessageType, err) - } - return c.executeUserGamesCommand(ctx, command.UserID, req) case ordermodel.MessageTypeUserGamesOrder: req, err := transcoder.PayloadToUserGamesOrder(command.PayloadBytes) if err != nil { @@ -74,22 +68,6 @@ func (c *RESTClient) ExecuteGameCommand(ctx context.Context, command downstream. } } -func (c *RESTClient) executeUserGamesCommand(ctx context.Context, userID string, req *ordermodel.UserGamesCommand) (downstream.UnaryResult, error) { - if req.GameID == uuid.Nil { - return downstream.UnaryResult{}, errors.New("execute user.games.command: game_id must not be empty") - } - body, err := buildEngineCommandBody(req.Commands) - if err != nil { - return downstream.UnaryResult{}, fmt.Errorf("execute user.games.command: %w", err) - } - target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(req.GameID.String()) + "/commands" - respBody, status, err := c.do(ctx, http.MethodPost, target, userID, body) - if err != nil { - return downstream.UnaryResult{}, fmt.Errorf("execute user.games.command: %w", err) - } - return projectUserGamesAckResponse(status, respBody, transcoder.EmptyUserGamesCommandResponsePayload) -} - func (c *RESTClient) executeUserGamesOrder(ctx context.Context, userID string, req *ordermodel.UserGamesOrder) (downstream.UnaryResult, error) { if req.GameID == uuid.Nil { return downstream.UnaryResult{}, errors.New("execute user.games.order: game_id must not be empty") @@ -169,26 +147,6 @@ func buildEngineCommandBody(commands []ordermodel.DecodableCommand) (gamerest.Co return gamerest.Command{Actor: "", Commands: raw}, nil } -// projectUserGamesAckResponse turns a backend response for the -// `user.games.command` route into a UnaryResult. Engine returns 204 -// on success, so any 2xx status is treated as ok and answered with -// the empty typed FB envelope produced by ackBuilder. -func projectUserGamesAckResponse(statusCode int, payload []byte, ackBuilder func() []byte) (downstream.UnaryResult, error) { - switch { - case statusCode >= 200 && statusCode < 300: - return downstream.UnaryResult{ - ResultCode: userCommandResultCodeOK, - PayloadBytes: ackBuilder(), - }, nil - case statusCode == http.StatusServiceUnavailable: - return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable - case statusCode >= 400 && statusCode <= 599: - return projectUserBackendError(statusCode, payload) - default: - return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode) - } -} - // projectUserGamesOrderResponse decodes the engine's `PUT /api/v1/order` // JSON body (forwarded by backend) and re-encodes it as a FlatBuffers // `UserGamesOrderResponse` envelope. The body carries per-command diff --git a/gateway/internal/backendclient/routes.go b/gateway/internal/backendclient/routes.go index 86d6ae5..f58abfd 100644 --- a/gateway/internal/backendclient/routes.go +++ b/gateway/internal/backendclient/routes.go @@ -39,15 +39,15 @@ func LobbyRoutes(client *RESTClient) map[string]downstream.Client { target = lobbyCommandClient{rest: client} } return map[string]downstream.Client{ - lobbymodel.MessageTypeMyGamesList: target, - lobbymodel.MessageTypePublicGamesList: target, - lobbymodel.MessageTypeMyApplicationsList: target, - lobbymodel.MessageTypeMyInvitesList: target, - lobbymodel.MessageTypeOpenEnrollment: target, - lobbymodel.MessageTypeGameCreate: target, - lobbymodel.MessageTypeApplicationSubmit: target, - lobbymodel.MessageTypeInviteRedeem: target, - lobbymodel.MessageTypeInviteDecline: target, + lobbymodel.MessageTypeMyGamesList: target, + lobbymodel.MessageTypePublicGamesList: target, + lobbymodel.MessageTypeMyApplicationsList: target, + lobbymodel.MessageTypeMyInvitesList: target, + lobbymodel.MessageTypeOpenEnrollment: target, + lobbymodel.MessageTypeGameCreate: target, + lobbymodel.MessageTypeApplicationSubmit: target, + lobbymodel.MessageTypeInviteRedeem: target, + lobbymodel.MessageTypeInviteDecline: target, } } @@ -61,11 +61,10 @@ func GameRoutes(client *RESTClient) map[string]downstream.Client { target = gameCommandClient{rest: client} } return map[string]downstream.Client{ - ordermodel.MessageTypeUserGamesCommand: target, - ordermodel.MessageTypeUserGamesOrder: target, - ordermodel.MessageTypeUserGamesOrderGet: target, - reportmodel.MessageTypeUserGamesReport: target, - reportmodel.MessageTypeUserGamesBattle: target, + ordermodel.MessageTypeUserGamesOrder: target, + ordermodel.MessageTypeUserGamesOrderGet: target, + reportmodel.MessageTypeUserGamesReport: target, + reportmodel.MessageTypeUserGamesBattle: target, } } diff --git a/gateway/internal/backendclient/routes_test.go b/gateway/internal/backendclient/routes_test.go index aba6d75..934a1bf 100644 --- a/gateway/internal/backendclient/routes_test.go +++ b/gateway/internal/backendclient/routes_test.go @@ -56,7 +56,6 @@ func TestRoutesCoverAllAuthenticatedMessageTypes(t *testing.T) { }, "game": { expected: []string{ - ordermodel.MessageTypeUserGamesCommand, ordermodel.MessageTypeUserGamesOrder, ordermodel.MessageTypeUserGamesOrderGet, reportmodel.MessageTypeUserGamesReport, diff --git a/integration/engine_command_proxy_test.go b/integration/engine_order_proxy_test.go similarity index 77% rename from integration/engine_command_proxy_test.go rename to integration/engine_order_proxy_test.go index c9d3979..cf49062 100644 --- a/integration/engine_command_proxy_test.go +++ b/integration/engine_order_proxy_test.go @@ -10,12 +10,11 @@ import ( "galaxy/integration/testenv" ) -// TestEngineCommandProxy spins up a running game (10 enrolled -// pilots so engine init succeeds) and verifies that backend's -// user-side `/api/v1/user/games/{id}/commands` proxy reaches the -// engine and returns its passthrough body without an internal-error -// response. -func TestEngineCommandProxy(t *testing.T) { +// TestEngineOrderProxy spins up a running game (10 enrolled pilots so +// engine init succeeds) and verifies that backend's user-side +// `/api/v1/user/games/{id}/orders` proxy reaches the engine and returns +// its passthrough body without an internal-error response. +func TestEngineOrderProxy(t *testing.T) { plat := testenv.Bootstrap(t, testenv.BootstrapOptions{}) testenv.EnsureGameImage(t) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) @@ -28,7 +27,7 @@ func TestEngineCommandProxy(t *testing.T) { t.Fatalf("seed engine_version: err=%v resp=%v", err, resp) } - owner := testenv.RegisterSession(t, plat, "owner+cmd@example.com") + owner := testenv.RegisterSession(t, plat, "owner+order@example.com") testenv.PromoteToPaid(t, ctx, admin, plat, owner) ownerID, err := owner.LookupUserID(ctx, plat) if err != nil { @@ -37,7 +36,7 @@ func TestEngineCommandProxy(t *testing.T) { ownerHTTP := testenv.NewBackendUserClient(plat.Backend.HTTPURL, ownerID) gameBody := map[string]any{ - "game_name": "Engine Command Proxy", + "game_name": "Engine Order Proxy", "visibility": "private", "min_players": 10, "max_players": 10, @@ -59,7 +58,7 @@ func TestEngineCommandProxy(t *testing.T) { if _, resp, err := ownerHTTP.Do(ctx, http.MethodPost, "/api/v1/user/lobby/games/"+game.GameID+"/open-enrollment", nil); err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("open enrollment: %v %d", err, resp.StatusCode) } - pilots := testenv.EnrollPilots(t, plat, ownerHTTP, game.GameID, 10, "cmd") + pilots := testenv.EnrollPilots(t, plat, ownerHTTP, game.GameID, 10, "order") if _, resp, err := admin.Do(ctx, http.MethodPost, "/api/v1/admin/games/"+game.GameID+"/force-start", nil); err != nil || resp.StatusCode/100 != 2 { t.Fatalf("force-start: %v %d", err, resp.StatusCode) @@ -81,17 +80,17 @@ func TestEngineCommandProxy(t *testing.T) { time.Sleep(500 * time.Millisecond) } - // Pilot 1 sends a command. Backend forwards to the engine; the - // pass-through body comes back unchanged. We accept any status - // the engine produces (200, 4xx) — what matters is that backend - // did not surface an internal error of its own. - cmdBody := map[string]any{"actions": []map[string]any{}} - raw, resp, err = pilots[0].HTTP.Do(ctx, http.MethodPost, "/api/v1/user/games/"+game.GameID+"/commands", cmdBody) + // Pilot 1 submits an (empty) order. Backend forwards to the engine; + // the pass-through body comes back unchanged. We accept any status + // the engine produces (200, 4xx) — what matters is that backend did + // not surface an internal error of its own. + orderBody := map[string]any{"cmd": []map[string]any{}} + raw, resp, err = pilots[0].HTTP.Do(ctx, http.MethodPost, "/api/v1/user/games/"+game.GameID+"/orders", orderBody) if err != nil { - t.Fatalf("commands proxy: %v", err) + t.Fatalf("orders proxy: %v", err) } if resp.StatusCode == http.StatusInternalServerError || resp.StatusCode == http.StatusBadGateway { - t.Fatalf("commands proxy: backend internal-error %d body=%s", resp.StatusCode, string(raw)) + t.Fatalf("orders proxy: backend internal-error %d body=%s", resp.StatusCode, string(raw)) } // Cleanup: stop the container so the test does not leak it. diff --git a/pkg/error/generic.go b/pkg/error/generic.go index b38337d..5bd5198 100644 --- a/pkg/error/generic.go +++ b/pkg/error/generic.go @@ -22,8 +22,7 @@ // Game-state runtime rejections that depend on the current state // snapshot: entity-not-found, not-owned, in-use, ships-busy, // insufficient resources, send/upgrade/cargo dependencies. These -// surface as per-command `cmdErrorCode` on PUT /api/v1/order -// (and only escape as HTTP 400 from PUT /api/v1/command). +// surface as per-command `cmdErrorCode` on PUT /api/v1/order. // // Code 0 represents "applied without error" and is reserved as the // successful per-command outcome on CommandMeta.Result. Code -1 @@ -115,8 +114,7 @@ func IsInputCode(code int) bool { return code >= 2000 && code < 3000 } // IsGameStateCode reports whether code belongs to the game-state / // per-command rejection shelf (3xxx). On PUT /api/v1/order these are -// recorded into CommandMeta.CmdErrCode; on PUT /api/v1/command they -// map to HTTP 400. +// recorded into CommandMeta.CmdErrCode. func IsGameStateCode(code int) bool { return code >= 3000 && code < 4000 } func GenericErrorText(code int) string { diff --git a/pkg/model/order/order.go b/pkg/model/order/order.go index 5eef406..4eebe4e 100644 --- a/pkg/model/order/order.go +++ b/pkg/model/order/order.go @@ -6,12 +6,6 @@ import ( "github.com/google/uuid" ) -// MessageTypeUserGamesCommand is the authenticated gateway message type -// used to send a batch of in-game commands to the engine through -// `POST /api/v1/user/games/{game_id}/commands`. The signed payload is -// a FlatBuffers `order.UserGamesCommand`. -const MessageTypeUserGamesCommand = "user.games.command" - // MessageTypeUserGamesOrder is the authenticated gateway message type // used to validate / store a batch of in-game orders through // `POST /api/v1/user/games/{game_id}/orders`. The signed payload is a @@ -24,22 +18,12 @@ const MessageTypeUserGamesOrder = "user.games.order" // signed payload is a FlatBuffers `order.UserGamesOrderGet`. const MessageTypeUserGamesOrderGet = "user.games.order.get" -// UserGamesCommand is the typed payload of MessageTypeUserGamesCommand. -// `GameID` selects the running engine container; `Commands` is the -// player command batch executed atomically by the engine. The `Actor` -// field present in the engine's JSON shape is rebuilt by backend from -// the runtime player mapping — clients never carry it. -type UserGamesCommand struct { - // GameID identifies the running game for this batch. - GameID uuid.UUID `json:"game_id"` - - // Commands is the player command batch. - Commands []DecodableCommand `json:"cmd"` -} - // UserGamesOrder is the typed payload of MessageTypeUserGamesOrder. -// Mirrors `UserGamesCommand` plus an `UpdatedAt` field that lets the -// engine reject stale order submissions. +// `GameID` selects the running engine container; `Commands` is the +// player order batch; `UpdatedAt` lets the engine reject stale order +// submissions. The `Actor` field present in the engine's JSON shape is +// rebuilt by backend from the runtime player mapping — clients never +// carry it. type UserGamesOrder struct { // GameID identifies the running game for this batch. GameID uuid.UUID `json:"game_id"` diff --git a/pkg/model/rest/command.go b/pkg/model/rest/command.go index bc45ddb..a3d8f30 100644 --- a/pkg/model/rest/command.go +++ b/pkg/model/rest/command.go @@ -4,13 +4,10 @@ import "encoding/json" type Command struct { Actor string `json:"actor" binding:"notblank"` - // Commands carries the engine-bound payload for either the - // command (`PUT /api/v1/command`, immediate) or the order - // (`PUT /api/v1/order`, validate-and-store) path. The order - // path treats an empty array as "the player has no orders for - // this turn" and stores it. The command handler still rejects - // an empty array by hand because immediate execution of a - // no-op makes no sense. + // Commands carries the engine-bound payload for the order + // (`PUT /api/v1/order`, validate-and-store) path. An empty array + // means "the player has no orders for this turn" and is stored + // as-is. Commands []json.RawMessage `json:"cmd"` } diff --git a/pkg/schema/fbs/order.fbs b/pkg/schema/fbs/order.fbs index 32b161c..037717b 100644 --- a/pkg/schema/fbs/order.fbs +++ b/pkg/schema/fbs/order.fbs @@ -201,31 +201,15 @@ table CommandItem { cmd_error_message: string; } -// UserGamesCommand is the signed-gRPC request payload for -// `MessageTypeUserGamesCommand`. game_id selects the target running -// game; gateway re-encodes commands into the engine JSON shape and -// forwards through `POST /api/v1/user/games/{game_id}/commands`. -table UserGamesCommand { - game_id: common.UUID (required); - commands: [CommandItem]; -} - // UserGamesOrder is the signed-gRPC request payload for -// `MessageTypeUserGamesOrder`. Identical to UserGamesCommand but -// carries `updated_at` so the order-validate path can reject stale -// submissions. +// `MessageTypeUserGamesOrder`. game_id selects the target running game; +// `updated_at` lets the order-validate path reject stale submissions. table UserGamesOrder { game_id: common.UUID (required); updated_at: int64; commands: [CommandItem]; } -// UserGamesCommandResponse is the success acknowledgement returned -// for `MessageTypeUserGamesCommand`. The engine answers with -// `204 No Content` on success, so the FB shape is intentionally empty -// — kept as a typed envelope for future extension. -table UserGamesCommandResponse {} - // UserGamesOrderResponse mirrors the engine's `PUT /api/v1/order` // success body: it echoes the stored order back to the caller with // the engine-assigned `updated_at` timestamp and per-command diff --git a/pkg/schema/fbs/order/UserGamesCommand.go b/pkg/schema/fbs/order/UserGamesCommand.go deleted file mode 100644 index 21e4c05..0000000 --- a/pkg/schema/fbs/order/UserGamesCommand.go +++ /dev/null @@ -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() -} diff --git a/pkg/schema/fbs/order/UserGamesCommandResponse.go b/pkg/schema/fbs/order/UserGamesCommandResponse.go deleted file mode 100644 index a711474..0000000 --- a/pkg/schema/fbs/order/UserGamesCommandResponse.go +++ /dev/null @@ -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() -} diff --git a/pkg/transcoder/order.go b/pkg/transcoder/order.go index bb13a84..f3be482 100644 --- a/pkg/transcoder/order.go +++ b/pkg/transcoder/order.go @@ -931,72 +931,6 @@ func cloneStringPointer(value *string) *string { return &cloned } -// UserGamesCommandToPayload converts model.UserGamesCommand to -// FlatBuffers bytes suitable for the authenticated gateway transport. -// `GameID` is required. -func UserGamesCommandToPayload(req *model.UserGamesCommand) ([]byte, error) { - if req == nil { - return nil, errors.New("encode user games command payload: request is nil") - } - - builder := flatbuffers.NewBuilder(1024) - commandsVec, err := encodeCommandItemVector(builder, req.Commands, "user games command") - if err != nil { - return nil, err - } - - fbs.UserGamesCommandStart(builder) - hi, lo := uuidToHiLo(req.GameID) - fbs.UserGamesCommandAddGameId(builder, commonfbs.CreateUUID(builder, hi, lo)) - if commandsVec != 0 { - fbs.UserGamesCommandAddCommands(builder, commandsVec) - } - offset := fbs.UserGamesCommandEnd(builder) - fbs.FinishUserGamesCommandBuffer(builder, offset) - - return builder.FinishedBytes(), nil -} - -// PayloadToUserGamesCommand converts FlatBuffers payload bytes into -// model.UserGamesCommand. -func PayloadToUserGamesCommand(data []byte) (result *model.UserGamesCommand, err error) { - if len(data) == 0 { - return nil, errors.New("decode user games command payload: data is empty") - } - - defer func() { - if recovered := recover(); recovered != nil { - result = nil - err = fmt.Errorf("decode user games command payload: panic recovered: %v", recovered) - } - }() - - flat := fbs.GetRootAsUserGamesCommand(data, 0) - gameID := flat.GameId(nil) - if gameID == nil { - return nil, errors.New("decode user games command payload: game_id is missing") - } - out := &model.UserGamesCommand{ - GameID: uuidFromHiLo(gameID.Hi(), gameID.Lo()), - } - count := flat.CommandsLength() - if count > 0 { - out.Commands = make([]model.DecodableCommand, count) - flatCommand := new(fbs.CommandItem) - for i := 0; i < count; i++ { - if !flat.Commands(flatCommand, i) { - return nil, fmt.Errorf("decode user games command %d: command item is missing", i) - } - cmd, decodeErr := decodeOrderCommand(flatCommand, i) - if decodeErr != nil { - return nil, decodeErr - } - out.Commands[i] = cmd - } - } - return out, nil -} - // UserGamesOrderToPayload converts model.UserGamesOrder to FlatBuffers // bytes suitable for the authenticated gateway transport. func UserGamesOrderToPayload(req *model.UserGamesOrder) ([]byte, error) { @@ -1068,19 +1002,6 @@ func PayloadToUserGamesOrder(data []byte) (result *model.UserGamesOrder, err err return out, nil } -// EmptyUserGamesCommandResponsePayload returns a FlatBuffers-encoded -// empty `UserGamesCommandResponse` buffer. Used by gateway to ack a -// successful `MessageTypeUserGamesCommand` even though the engine -// returns 204 No Content — the typed envelope keeps the message-type -// contract symmetric with other authenticated routes. -func EmptyUserGamesCommandResponsePayload() []byte { - builder := flatbuffers.NewBuilder(16) - fbs.UserGamesCommandResponseStart(builder) - offset := fbs.UserGamesCommandResponseEnd(builder) - fbs.FinishUserGamesCommandResponseBuffer(builder, offset) - return builder.FinishedBytes() -} - // UserGamesOrderResponseToPayload encodes the engine's response body // for `PUT /api/v1/order` into the wire FlatBuffers envelope expected // for `MessageTypeUserGamesOrder`. The engine populates per-command @@ -1298,9 +1219,8 @@ func PayloadToUserGamesOrderGetResponse(data []byte) (order *model.UserGamesOrde } // encodeCommandItemVector serialises a slice of DecodableCommand into a -// FlatBuffers vector of CommandItem. Used by UserGamesCommandToPayload -// and UserGamesOrderToPayload to keep the per-command encoding logic in -// one place. +// FlatBuffers vector of CommandItem. Used by UserGamesOrderToPayload to +// keep the per-command encoding logic in one place. func encodeCommandItemVector(builder *flatbuffers.Builder, commands []model.DecodableCommand, opLabel string) (flatbuffers.UOffsetT, error) { offsets := make([]flatbuffers.UOffsetT, len(commands)) for i := range commands { @@ -1331,12 +1251,7 @@ func encodeCommandItemVector(builder *flatbuffers.Builder, commands []model.Deco if len(offsets) == 0 { return 0, nil } - // `UserGamesCommandStartCommandsVector` and the corresponding - // `UserGamesOrderStartCommandsVector` are identical helpers (both - // expand to `builder.StartVector(4, numElems, 4)`); we use the - // command flavour for both message types so the helper has a - // single dependency point. - fbs.UserGamesCommandStartCommandsVector(builder, len(offsets)) + fbs.UserGamesOrderStartCommandsVector(builder, len(offsets)) for i := len(offsets) - 1; i >= 0; i-- { builder.PrependUOffsetT(offsets[i]) } diff --git a/pkg/transcoder/order_test.go b/pkg/transcoder/order_test.go index d64fbb2..e549217 100644 --- a/pkg/transcoder/order_test.go +++ b/pkg/transcoder/order_test.go @@ -10,32 +10,6 @@ import ( "github.com/google/uuid" ) -func TestUserGamesCommandPayloadRoundTrip(t *testing.T) { - t.Parallel() - - source := &model.UserGamesCommand{ - GameID: uuid.MustParse("11111111-2222-3333-4444-555555555555"), - Commands: []model.DecodableCommand{ - &model.CommandRaceVote{CommandMeta: commandMeta("cmd-01", model.CommandTypeRaceVote, nil, nil), Acceptor: "race-a"}, - &model.CommandShipGroupSend{CommandMeta: commandMeta("cmd-02", model.CommandTypeShipGroupSend, nil, nil), ID: "group-1", Destination: 7}, - }, - } - - payload, err := UserGamesCommandToPayload(source) - if err != nil { - t.Fatalf("encode user games command: %v", err) - } - - decoded, err := PayloadToUserGamesCommand(payload) - if err != nil { - t.Fatalf("decode user games command: %v", err) - } - - if !reflect.DeepEqual(source, decoded) { - t.Fatalf("round-trip mismatch\nsource: %#v\ndecoded:%#v", source, decoded) - } -} - func TestUserGamesOrderPayloadRoundTrip(t *testing.T) { t.Parallel() @@ -62,15 +36,9 @@ func TestUserGamesOrderPayloadRoundTrip(t *testing.T) { } } -func TestUserGamesCommandRejectsNilAndEmpty(t *testing.T) { +func TestUserGamesOrderRejectsNilAndEmpty(t *testing.T) { t.Parallel() - if _, err := UserGamesCommandToPayload(nil); err == nil { - t.Fatalf("expected error encoding nil user games command") - } - if _, err := PayloadToUserGamesCommand(nil); err == nil { - t.Fatalf("expected error decoding empty user games command") - } if _, err := UserGamesOrderToPayload(nil); err == nil { t.Fatalf("expected error encoding nil user games order") } diff --git a/ui/frontend/src/proto/galaxy/fbs/order.ts b/ui/frontend/src/proto/galaxy/fbs/order.ts index a98666a..e8bceec 100644 --- a/ui/frontend/src/proto/galaxy/fbs/order.ts +++ b/ui/frontend/src/proto/galaxy/fbs/order.ts @@ -32,8 +32,6 @@ export { PlanetRouteLoadType } from './order/planet-route-load-type.js'; export { Relation } from './order/relation.js'; export { ShipGroupCargo } from './order/ship-group-cargo.js'; export { ShipGroupUpgradeTech } from './order/ship-group-upgrade-tech.js'; -export { UserGamesCommand, UserGamesCommandT } from './order/user-games-command.js'; -export { UserGamesCommandResponse, UserGamesCommandResponseT } from './order/user-games-command-response.js'; export { UserGamesOrder, UserGamesOrderT } from './order/user-games-order.js'; export { UserGamesOrderGet, UserGamesOrderGetT } from './order/user-games-order-get.js'; export { UserGamesOrderGetResponse, UserGamesOrderGetResponseT } from './order/user-games-order-get-response.js'; diff --git a/ui/frontend/src/proto/galaxy/fbs/order/user-games-command-response.ts b/ui/frontend/src/proto/galaxy/fbs/order/user-games-command-response.ts deleted file mode 100644 index 5e15dfb..0000000 --- a/ui/frontend/src/proto/galaxy/fbs/order/user-games-command-response.ts +++ /dev/null @@ -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 { - 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); -} -} diff --git a/ui/frontend/src/proto/galaxy/fbs/order/user-games-command.ts b/ui/frontend/src/proto/galaxy/fbs/order/user-games-command.ts deleted file mode 100644 index 2afc8a8..0000000 --- a/ui/frontend/src/proto/galaxy/fbs/order/user-games-command.ts +++ /dev/null @@ -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 { - 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(this.commands.bind(this), this.commandsLength()) - ); -} - - -unpackTo(_o: UserGamesCommandT): void { - _o.gameId = (this.gameId() !== null ? this.gameId()!.unpack() : null); - _o.commands = this.bb!.createObjList(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 - ); -} -}