package backendclient import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "galaxy/gateway/internal/downstream" ordermodel "galaxy/model/order" reportmodel "galaxy/model/report" gamerest "galaxy/model/rest" "galaxy/transcoder" "github.com/google/uuid" ) // 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. 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") } if ctx == nil { return downstream.UnaryResult{}, errors.New("backendclient: execute game command: nil context") } if err := ctx.Err(); err != nil { return downstream.UnaryResult{}, err } if strings.TrimSpace(command.UserID) == "" { return downstream.UnaryResult{}, errors.New("backendclient: execute game command: user_id must not be empty") } 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 { return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute game command %q: %w", command.MessageType, err) } return c.executeUserGamesOrder(ctx, command.UserID, req) case reportmodel.MessageTypeUserGamesReport: req, err := transcoder.PayloadToGameReportRequest(command.PayloadBytes) if err != nil { return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute game command %q: %w", command.MessageType, err) } return c.executeUserGamesReport(ctx, command.UserID, req) default: return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute game command: unsupported message type %q", command.MessageType) } } 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") } body, err := buildEngineCommandBody(req.Commands) if err != nil { return downstream.UnaryResult{}, fmt.Errorf("execute user.games.order: %w", err) } target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(req.GameID.String()) + "/orders" respBody, status, err := c.do(ctx, http.MethodPost, target, userID, body) if err != nil { return downstream.UnaryResult{}, fmt.Errorf("execute user.games.order: %w", err) } return projectUserGamesAckResponse(status, respBody, transcoder.EmptyUserGamesOrderResponsePayload) } func (c *RESTClient) executeUserGamesReport(ctx context.Context, userID string, req *reportmodel.GameReportRequest) (downstream.UnaryResult, error) { if req.GameID == uuid.Nil { return downstream.UnaryResult{}, errors.New("execute user.games.report: game_id must not be empty") } target := fmt.Sprintf("%s/api/v1/user/games/%s/reports/%d", c.baseURL, url.PathEscape(req.GameID.String()), req.Turn) respBody, status, err := c.do(ctx, http.MethodGet, target, userID, nil) if err != nil { return downstream.UnaryResult{}, fmt.Errorf("execute user.games.report: %w", err) } return projectUserGamesReportResponse(status, respBody) } // buildEngineCommandBody serialises a slice of typed commands into the // JSON shape expected by backend's command/order handlers (a // `gamerest.Command` with the actor field left empty — backend rebinds // it from the runtime player mapping before forwarding to the engine). func buildEngineCommandBody(commands []ordermodel.DecodableCommand) (gamerest.Command, error) { raw := make([]json.RawMessage, len(commands)) for i, cmd := range commands { encoded, err := json.Marshal(cmd) if err != nil { return gamerest.Command{}, fmt.Errorf("encode command %d: %w", i, err) } raw[i] = encoded } return gamerest.Command{Actor: "", Commands: raw}, nil } // projectUserGamesAckResponse turns a backend response for command / // order routes 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) } } // projectUserGamesReportResponse decodes the engine's Report JSON // payload (forwarded verbatim by backend) and re-encodes it as a // FlatBuffers Report for the signed-gRPC client. func projectUserGamesReportResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) { switch { case statusCode == http.StatusOK: var report reportmodel.Report if err := json.Unmarshal(payload, &report); err != nil { return downstream.UnaryResult{}, fmt.Errorf("decode engine report: %w", err) } encoded, err := transcoder.ReportToPayload(&report) if err != nil { return downstream.UnaryResult{}, fmt.Errorf("encode report payload: %w", err) } return downstream.UnaryResult{ ResultCode: userCommandResultCodeOK, PayloadBytes: encoded, }, 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) } }