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