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
@@ -0,0 +1,183 @@
// Package userservice provides the HTTP adapter for the
// ports.UserService eligibility port. It wraps the trusted
// User Service internal endpoint
// `GET /api/v1/internal/users/{user_id}/eligibility` and decodes the
// response into the lobby-side ports.Eligibility shape.
package userservice
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"galaxy/lobby/internal/ports"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
// permanentBlockSanctionCode mirrors policy.SanctionCodePermanentBlock in
// galaxy/user. The lobby adapter inspects the active_sanctions array for
// this string to populate Eligibility.PermanentBlocked without taking a
// build-time dependency on the user module.
const permanentBlockSanctionCode = "permanent_block"
// maxRegisteredRaceNamesLimitCode mirrors
// policy.LimitCodeMaxRegisteredRaceNames in galaxy/user. A snapshot value
// of 0 denotes unlimited per the lifetime tariff.
const maxRegisteredRaceNamesLimitCode = "max_registered_race_names"
// Client implements ports.UserService against the trusted internal HTTP
// surface of User Service.
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 User Service (no trailing slash
// required). The eligibility path is appended on every call.
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("user service base url must not be empty")
case cfg.Timeout <= 0:
return errors.New("user service timeout must be positive")
default:
return nil
}
}
// NewClient constructs a Client from cfg. Transport is wrapped with
// otelhttp.NewTransport so traces propagate to User Service.
func NewClient(cfg Config) (*Client, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("new user service client: %w", err)
}
httpClient := &http.Client{
Timeout: cfg.Timeout,
Transport: otelhttp.NewTransport(http.DefaultTransport),
}
return &Client{
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
httpClient: httpClient,
}, nil
}
// rawEligibility mirrors the lobby-relevant subset of
// lobbyeligibility.GetUserEligibilityResult. Unknown JSON fields are
// ignored intentionally so future user-side additions do not break the
// lobby decoder; see the decision record for context.
type rawEligibility struct {
Exists bool `json:"exists"`
Markers rawMarkers `json:"markers"`
ActiveSanctions []rawSanction `json:"active_sanctions"`
EffectiveLimits []rawLimit `json:"effective_limits"`
}
type rawMarkers struct {
CanLogin bool `json:"can_login"`
CanCreatePrivateGame bool `json:"can_create_private_game"`
CanManagePrivateGame bool `json:"can_manage_private_game"`
CanJoinGame bool `json:"can_join_game"`
CanUpdateProfile bool `json:"can_update_profile"`
}
type rawSanction struct {
SanctionCode string `json:"sanction_code"`
}
type rawLimit struct {
LimitCode string `json:"limit_code"`
Value int `json:"value"`
}
// GetEligibility issues GET /api/v1/internal/users/{user_id}/eligibility
// and decodes the response into a ports.Eligibility value. HTTP 404 is
// treated as a present-but-missing user (Exists=false). Transport errors,
// timeouts, and unexpected statuses surface as ports.ErrUserServiceUnavailable.
func (client *Client) GetEligibility(ctx context.Context, userID string) (ports.Eligibility, error) {
if client == nil || client.httpClient == nil {
return ports.Eligibility{}, errors.New("get eligibility: nil client")
}
if ctx == nil {
return ports.Eligibility{}, errors.New("get eligibility: nil context")
}
trimmed := strings.TrimSpace(userID)
if trimmed == "" {
return ports.Eligibility{}, errors.New("get eligibility: user id must not be empty")
}
endpoint := client.baseURL + "/api/v1/internal/users/" + url.PathEscape(trimmed) + "/eligibility"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return ports.Eligibility{}, fmt.Errorf("get eligibility: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := client.httpClient.Do(req)
if err != nil {
return ports.Eligibility{}, fmt.Errorf("get eligibility: %w", errors.Join(ports.ErrUserServiceUnavailable, err))
}
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusNotFound:
return ports.Eligibility{Exists: false}, nil
case resp.StatusCode < 200 || resp.StatusCode >= 300:
return ports.Eligibility{}, fmt.Errorf(
"get eligibility: unexpected status %d: %w",
resp.StatusCode, ports.ErrUserServiceUnavailable,
)
}
var raw rawEligibility
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return ports.Eligibility{}, fmt.Errorf("get eligibility: decode body: %w", err)
}
return ports.Eligibility{
Exists: raw.Exists,
CanLogin: raw.Markers.CanLogin,
CanCreatePrivateGame: raw.Markers.CanCreatePrivateGame,
CanManagePrivateGame: raw.Markers.CanManagePrivateGame,
CanJoinGame: raw.Markers.CanJoinGame,
CanUpdateProfile: raw.Markers.CanUpdateProfile,
PermanentBlocked: containsSanction(raw.ActiveSanctions, permanentBlockSanctionCode),
MaxRegisteredRaceNames: lookupLimit(raw.EffectiveLimits, maxRegisteredRaceNamesLimitCode),
}, nil
}
func containsSanction(records []rawSanction, code string) bool {
for _, record := range records {
if record.SanctionCode == code {
return true
}
}
return false
}
func lookupLimit(records []rawLimit, code string) int {
for _, record := range records {
if record.LimitCode == code {
return record.Value
}
}
return 0
}
// Compile-time interface assertion.
var _ ports.UserService = (*Client)(nil)