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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-30 13:37:07 +02:00
parent e36d33482f
commit 601970b028
65 changed files with 681 additions and 2804 deletions
-1
View File
@@ -391,7 +391,6 @@ The current direct `Gateway -> User` self-service boundary uses that pattern:
- `user.sessions.list`
- `user.sessions.revoke`
- `user.sessions.revoke_all`
- `user.games.command`
- `user.games.order`
- `user.games.report`
- `lobby.my.games.list`
+1 -1
View File
@@ -34,7 +34,7 @@ func TestParityWithUICoreCanonicalBytes(t *testing.T) {
gatewayFields := authn.RequestSigningFields{
ProtocolVersion: "v1",
DeviceSessionID: "device-session-parity",
MessageType: "user.games.command",
MessageType: "user.games.order",
TimestampMS: 1_700_000_000_000,
RequestID: "request-parity",
PayloadHash: sha256Of([]byte("payload")),
@@ -19,11 +19,11 @@ import (
)
// ExecuteGameCommand routes one authenticated `user.games.*` command
// into backend's `/api/v1/user/games/{game_id}/*` endpoints. Command
// and order requests transcode the typed FB-payload into the JSON
// shape the engine expects (a `gamerest.Command` with empty actor —
// backend rebinds the actor from the runtime player mapping). Report
// requests transcode the response Report from JSON back to FB.
// into backend's `/api/v1/user/games/{game_id}/*` endpoints. Order
// requests transcode the typed FB-payload into the JSON shape the
// engine expects (a `gamerest.Command` with empty actor — backend
// rebinds the actor from the runtime player mapping). Report requests
// transcode the response Report from JSON back to FB.
func (c *RESTClient) ExecuteGameCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
if c == nil || c.httpClient == nil {
return downstream.UnaryResult{}, errors.New("backendclient: execute game command: nil client")
@@ -39,12 +39,6 @@ func (c *RESTClient) ExecuteGameCommand(ctx context.Context, command downstream.
}
switch command.MessageType {
case ordermodel.MessageTypeUserGamesCommand:
req, err := transcoder.PayloadToUserGamesCommand(command.PayloadBytes)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute game command %q: %w", command.MessageType, err)
}
return c.executeUserGamesCommand(ctx, command.UserID, req)
case ordermodel.MessageTypeUserGamesOrder:
req, err := transcoder.PayloadToUserGamesOrder(command.PayloadBytes)
if err != nil {
@@ -74,22 +68,6 @@ func (c *RESTClient) ExecuteGameCommand(ctx context.Context, command downstream.
}
}
func (c *RESTClient) executeUserGamesCommand(ctx context.Context, userID string, req *ordermodel.UserGamesCommand) (downstream.UnaryResult, error) {
if req.GameID == uuid.Nil {
return downstream.UnaryResult{}, errors.New("execute user.games.command: game_id must not be empty")
}
body, err := buildEngineCommandBody(req.Commands)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.command: %w", err)
}
target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(req.GameID.String()) + "/commands"
respBody, status, err := c.do(ctx, http.MethodPost, target, userID, body)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.command: %w", err)
}
return projectUserGamesAckResponse(status, respBody, transcoder.EmptyUserGamesCommandResponsePayload)
}
func (c *RESTClient) executeUserGamesOrder(ctx context.Context, userID string, req *ordermodel.UserGamesOrder) (downstream.UnaryResult, error) {
if req.GameID == uuid.Nil {
return downstream.UnaryResult{}, errors.New("execute user.games.order: game_id must not be empty")
@@ -169,26 +147,6 @@ func buildEngineCommandBody(commands []ordermodel.DecodableCommand) (gamerest.Co
return gamerest.Command{Actor: "", Commands: raw}, nil
}
// projectUserGamesAckResponse turns a backend response for the
// `user.games.command` route into a UnaryResult. Engine returns 204
// on success, so any 2xx status is treated as ok and answered with
// the empty typed FB envelope produced by ackBuilder.
func projectUserGamesAckResponse(statusCode int, payload []byte, ackBuilder func() []byte) (downstream.UnaryResult, error) {
switch {
case statusCode >= 200 && statusCode < 300:
return downstream.UnaryResult{
ResultCode: userCommandResultCodeOK,
PayloadBytes: ackBuilder(),
}, nil
case statusCode == http.StatusServiceUnavailable:
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
case statusCode >= 400 && statusCode <= 599:
return projectUserBackendError(statusCode, payload)
default:
return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode)
}
}
// projectUserGamesOrderResponse decodes the engine's `PUT /api/v1/order`
// JSON body (forwarded by backend) and re-encodes it as a FlatBuffers
// `UserGamesOrderResponse` envelope. The body carries per-command
+13 -14
View File
@@ -39,15 +39,15 @@ func LobbyRoutes(client *RESTClient) map[string]downstream.Client {
target = lobbyCommandClient{rest: client}
}
return map[string]downstream.Client{
lobbymodel.MessageTypeMyGamesList: target,
lobbymodel.MessageTypePublicGamesList: target,
lobbymodel.MessageTypeMyApplicationsList: target,
lobbymodel.MessageTypeMyInvitesList: target,
lobbymodel.MessageTypeOpenEnrollment: target,
lobbymodel.MessageTypeGameCreate: target,
lobbymodel.MessageTypeApplicationSubmit: target,
lobbymodel.MessageTypeInviteRedeem: target,
lobbymodel.MessageTypeInviteDecline: target,
lobbymodel.MessageTypeMyGamesList: target,
lobbymodel.MessageTypePublicGamesList: target,
lobbymodel.MessageTypeMyApplicationsList: target,
lobbymodel.MessageTypeMyInvitesList: target,
lobbymodel.MessageTypeOpenEnrollment: target,
lobbymodel.MessageTypeGameCreate: target,
lobbymodel.MessageTypeApplicationSubmit: target,
lobbymodel.MessageTypeInviteRedeem: target,
lobbymodel.MessageTypeInviteDecline: target,
}
}
@@ -61,11 +61,10 @@ func GameRoutes(client *RESTClient) map[string]downstream.Client {
target = gameCommandClient{rest: client}
}
return map[string]downstream.Client{
ordermodel.MessageTypeUserGamesCommand: target,
ordermodel.MessageTypeUserGamesOrder: target,
ordermodel.MessageTypeUserGamesOrderGet: target,
reportmodel.MessageTypeUserGamesReport: target,
reportmodel.MessageTypeUserGamesBattle: target,
ordermodel.MessageTypeUserGamesOrder: target,
ordermodel.MessageTypeUserGamesOrderGet: target,
reportmodel.MessageTypeUserGamesReport: target,
reportmodel.MessageTypeUserGamesBattle: target,
}
}
@@ -56,7 +56,6 @@ func TestRoutesCoverAllAuthenticatedMessageTypes(t *testing.T) {
},
"game": {
expected: []string{
ordermodel.MessageTypeUserGamesCommand,
ordermodel.MessageTypeUserGamesOrder,
ordermodel.MessageTypeUserGamesOrderGet,
reportmodel.MessageTypeUserGamesReport,