// Package gmclient provides the HTTP adapter for the ports.GMClient // surface. It implements the registration path // `POST /api/v1/internal/games/{game_id}/register-runtime` and the // liveness probe `GET /api/v1/internal/healthz` used by the voluntary // resume flow. // // Every transport-level failure (timeout, network error, non-2xx // response) is wrapped with ports.ErrGMUnavailable so callers can // detect the GM-unavailable case via errors.Is and follow the // `lobby.runtime_paused_after_start` branch or the // `service_unavailable` branch documented in the // README Game Start Flow. package gmclient import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "time" "galaxy/lobby/internal/ports" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) // Client implements ports.GMClient against the trusted internal HTTP // surface of Game Master. type Client struct { baseURL string httpClient *http.Client } // Config groups the construction parameters of Client. type Config struct { // BaseURL is the absolute root URL of Game Master (no trailing // slash required). BaseURL string // Timeout bounds one round trip including TLS handshake. It must // be positive. Timeout time.Duration } // Validate reports whether cfg stores a usable Client configuration. func (cfg Config) Validate() error { switch { case strings.TrimSpace(cfg.BaseURL) == "": return errors.New("gm client base url must not be empty") case cfg.Timeout <= 0: return errors.New("gm client timeout must be positive") default: return nil } } // NewClient constructs a Client from cfg. The transport is wrapped with // otelhttp.NewTransport so traces propagate to Game Master. func NewClient(cfg Config) (*Client, error) { if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("new gm client: %w", err) } httpClient := &http.Client{ Timeout: cfg.Timeout, Transport: otelhttp.NewTransport(http.DefaultTransport), } return &Client{ baseURL: strings.TrimRight(cfg.BaseURL, "/"), httpClient: httpClient, }, nil } // registerRuntimeBody mirrors the JSON body Lobby sends to Game Master. // The shape is owned by Lobby for the Game Master is expected to // accept it as-is when it implements the receiving handler. type registerRuntimeBody struct { ContainerID string `json:"container_id"` EngineEndpoint string `json:"engine_endpoint"` TargetEngineVersion string `json:"target_engine_version"` TurnSchedule string `json:"turn_schedule"` } // RegisterGame issues // POST /api/v1/internal/games/{game_id}/register-runtime against Game // Master. Any non-success outcome (validation error, transport error, // timeout, non-2xx response) is wrapped with ports.ErrGMUnavailable so // the caller can branch on errors.Is(err, ports.ErrGMUnavailable). func (client *Client) RegisterGame(ctx context.Context, request ports.RegisterGameRequest) error { if client == nil || client.httpClient == nil { return errors.New("register game: nil client") } if ctx == nil { return errors.New("register game: nil context") } if err := request.Validate(); err != nil { return fmt.Errorf("register game: %w", err) } endpoint := client.baseURL + "/api/v1/internal/games/" + url.PathEscape(request.GameID.String()) + "/register-runtime" body := registerRuntimeBody{ ContainerID: request.ContainerID, EngineEndpoint: request.EngineEndpoint, TargetEngineVersion: request.TargetEngineVersion, TurnSchedule: request.TurnSchedule, } encoded, err := json.Marshal(body) if err != nil { return fmt.Errorf("register game: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(encoded)) if err != nil { return fmt.Errorf("register game: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := client.httpClient.Do(req) if err != nil { return fmt.Errorf("register game: %w", errors.Join(ports.ErrGMUnavailable, err)) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf( "register game: unexpected status %d: %w", resp.StatusCode, ports.ErrGMUnavailable, ) } return nil } // Ping issues GET /api/v1/internal/healthz against Game Master. Any // non-success outcome (validation error, transport error, timeout, // non-2xx response) is wrapped with ports.ErrGMUnavailable so the // caller can branch on errors.Is(err, ports.ErrGMUnavailable). Stage // 16 voluntary resume uses this method as the liveness gate before // transitioning a paused game back to running. func (client *Client) Ping(ctx context.Context) error { if client == nil || client.httpClient == nil { return errors.New("ping: nil client") } if ctx == nil { return errors.New("ping: nil context") } endpoint := client.baseURL + "/api/v1/internal/healthz" req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return fmt.Errorf("ping: %w", err) } req.Header.Set("Accept", "application/json") resp, err := client.httpClient.Do(req) if err != nil { return fmt.Errorf("ping: %w", errors.Join(ports.ErrGMUnavailable, err)) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf( "ping: unexpected status %d: %w", resp.StatusCode, ports.ErrGMUnavailable, ) } return nil } // Compile-time interface assertion. var _ ports.GMClient = (*Client)(nil)