feat: game lobby service
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
// 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)
|
||||
Reference in New Issue
Block a user