feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
+174
View File
@@ -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)
@@ -0,0 +1,177 @@
package gmclient_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"galaxy/lobby/internal/adapters/gmclient"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/ports"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validRequest() ports.RegisterGameRequest {
return ports.RegisterGameRequest{
GameID: common.GameID("game-1"),
ContainerID: "container-1",
EngineEndpoint: "engine.local:9000",
TargetEngineVersion: "v1.2.3",
TurnSchedule: "0 18 * * *",
}
}
func TestNewClientValidatesConfig(t *testing.T) {
_, err := gmclient.NewClient(gmclient.Config{Timeout: time.Second})
require.Error(t, err)
_, err = gmclient.NewClient(gmclient.Config{BaseURL: "http://gm.local"})
require.Error(t, err)
}
func TestRegisterGameSendsExpectedRequest(t *testing.T) {
var observed struct {
method string
path string
contentType string
body []byte
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
observed.method = r.Method
observed.path = r.URL.Path
observed.contentType = r.Header.Get("Content-Type")
observed.body, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(server.Close)
client, err := gmclient.NewClient(gmclient.Config{BaseURL: server.URL, Timeout: time.Second})
require.NoError(t, err)
require.NoError(t, client.RegisterGame(context.Background(), validRequest()))
assert.Equal(t, http.MethodPost, observed.method)
assert.Equal(t, "/api/v1/internal/games/game-1/register-runtime", observed.path)
assert.Equal(t, "application/json", observed.contentType)
var decoded map[string]string
require.NoError(t, json.Unmarshal(observed.body, &decoded))
assert.Equal(t, "container-1", decoded["container_id"])
assert.Equal(t, "engine.local:9000", decoded["engine_endpoint"])
assert.Equal(t, "v1.2.3", decoded["target_engine_version"])
assert.Equal(t, "0 18 * * *", decoded["turn_schedule"])
}
func TestRegisterGameWrapsServerErrorWithUnavailable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(server.Close)
client, err := gmclient.NewClient(gmclient.Config{BaseURL: server.URL, Timeout: time.Second})
require.NoError(t, err)
err = client.RegisterGame(context.Background(), validRequest())
require.Error(t, err)
assert.ErrorIs(t, err, ports.ErrGMUnavailable)
}
func TestRegisterGameWrapsTimeoutWithUnavailable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-time.After(200 * time.Millisecond):
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(server.Close)
client, err := gmclient.NewClient(gmclient.Config{BaseURL: server.URL, Timeout: 10 * time.Millisecond})
require.NoError(t, err)
err = client.RegisterGame(context.Background(), validRequest())
require.Error(t, err)
assert.ErrorIs(t, err, ports.ErrGMUnavailable)
}
func TestPingHitsExpectedEndpoint(t *testing.T) {
var observed struct {
method string
path string
accept string
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
observed.method = r.Method
observed.path = r.URL.Path
observed.accept = r.Header.Get("Accept")
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(server.Close)
client, err := gmclient.NewClient(gmclient.Config{BaseURL: server.URL, Timeout: time.Second})
require.NoError(t, err)
require.NoError(t, client.Ping(context.Background()))
assert.Equal(t, http.MethodGet, observed.method)
assert.Equal(t, "/api/v1/internal/healthz", observed.path)
assert.Equal(t, "application/json", observed.accept)
}
func TestPingWrapsServerErrorWithUnavailable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
t.Cleanup(server.Close)
client, err := gmclient.NewClient(gmclient.Config{BaseURL: server.URL, Timeout: time.Second})
require.NoError(t, err)
err = client.Ping(context.Background())
require.Error(t, err)
assert.ErrorIs(t, err, ports.ErrGMUnavailable)
}
func TestPingWrapsTimeoutWithUnavailable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-time.After(200 * time.Millisecond):
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(server.Close)
client, err := gmclient.NewClient(gmclient.Config{BaseURL: server.URL, Timeout: 10 * time.Millisecond})
require.NoError(t, err)
err = client.Ping(context.Background())
require.Error(t, err)
assert.ErrorIs(t, err, ports.ErrGMUnavailable)
}
func TestRegisterGameValidatesRequest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(server.Close)
client, err := gmclient.NewClient(gmclient.Config{BaseURL: server.URL, Timeout: time.Second})
require.NoError(t, err)
bad := validRequest()
bad.ContainerID = ""
err = client.RegisterGame(context.Background(), bad)
require.Error(t, err)
bad = validRequest()
bad.GameID = common.GameID("bogus")
err = client.RegisterGame(context.Background(), bad)
require.Error(t, err)
}