feat: gamemaster

This commit is contained in:
Ilia Denisov
2026-05-03 07:59:03 +02:00
committed by GitHub
parent a7cee15115
commit 3e2622757e
229 changed files with 41521 additions and 1098 deletions
@@ -0,0 +1,180 @@
// Package lobbyeventspublisher provides the Redis-Streams-backed
// publisher for `gm:lobby_events`. The stream carries two distinct
// message types — `runtime_snapshot_update` and `game_finished` —
// discriminated by the `event_type` field as fixed by
// `gamemaster/api/runtime-events-asyncapi.yaml`.
//
// The adapter mirrors `rtmanager/internal/adapters/healtheventspublisher`
// behaviourally: the publisher validates the message before XADDing,
// emits one entry per call, and never trims the stream (consumers own
// their consumer-group offsets).
package lobbyeventspublisher
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"github.com/redis/go-redis/v9"
"galaxy/gamemaster/internal/domain/runtime"
"galaxy/gamemaster/internal/ports"
)
// Wire field names used by the Redis Streams payload. Frozen by
// `gamemaster/api/runtime-events-asyncapi.yaml`; renaming any of them
// breaks Game Lobby's consumer.
const (
fieldEventType = "event_type"
fieldGameID = "game_id"
fieldCurrentTurn = "current_turn"
fieldFinalTurnNumber = "final_turn_number"
fieldRuntimeStatus = "runtime_status"
fieldEngineHealthSummary = "engine_health_summary"
fieldPlayerTurnStats = "player_turn_stats"
fieldOccurredAtMS = "occurred_at_ms"
fieldFinishedAtMS = "finished_at_ms"
eventTypeRuntimeSnapshotUpdate = "runtime_snapshot_update"
eventTypeGameFinished = "game_finished"
emptyPlayerTurnStatsJSON = "[]"
)
// Config groups the dependencies and stream name required to
// construct a Publisher.
type Config struct {
// Client appends entries to Redis Streams. Must be non-nil.
Client *redis.Client
// Stream stores the Redis Stream key events are published to.
// Must not be empty (typically `gm:lobby_events`).
Stream string
}
// Publisher implements `ports.LobbyEventsPublisher` on top of a shared
// Redis client.
type Publisher struct {
client *redis.Client
stream string
}
// NewPublisher constructs a Publisher from cfg. Validation errors
// surface the missing collaborator verbatim.
func NewPublisher(cfg Config) (*Publisher, error) {
if cfg.Client == nil {
return nil, errors.New("new gamemaster lobby events publisher: nil redis client")
}
if cfg.Stream == "" {
return nil, errors.New("new gamemaster lobby events publisher: stream must not be empty")
}
return &Publisher{client: cfg.Client, stream: cfg.Stream}, nil
}
// PublishSnapshotUpdate appends a `runtime_snapshot_update` message to
// the stream after validating msg through msg.Validate.
func (publisher *Publisher) PublishSnapshotUpdate(ctx context.Context, msg ports.RuntimeSnapshotUpdate) error {
if err := publisher.guardCall(ctx); err != nil {
return err
}
if err := msg.Validate(); err != nil {
return fmt.Errorf("publish runtime snapshot update: %w", err)
}
statsJSON, err := encodePlayerTurnStats(msg.PlayerTurnStats)
if err != nil {
return fmt.Errorf("publish runtime snapshot update: %w", err)
}
values := map[string]any{
fieldEventType: eventTypeRuntimeSnapshotUpdate,
fieldGameID: msg.GameID,
fieldCurrentTurn: strconv.Itoa(msg.CurrentTurn),
fieldRuntimeStatus: string(msg.RuntimeStatus),
fieldEngineHealthSummary: msg.EngineHealthSummary,
fieldPlayerTurnStats: statsJSON,
fieldOccurredAtMS: strconv.FormatInt(msg.OccurredAt.UTC().UnixMilli(), 10),
}
if err := publisher.client.XAdd(ctx, &redis.XAddArgs{
Stream: publisher.stream,
Values: values,
}).Err(); err != nil {
return fmt.Errorf("publish runtime snapshot update: xadd: %w", err)
}
return nil
}
// PublishGameFinished appends a `game_finished` message to the stream
// after validating msg through msg.Validate.
func (publisher *Publisher) PublishGameFinished(ctx context.Context, msg ports.GameFinished) error {
if err := publisher.guardCall(ctx); err != nil {
return err
}
if err := msg.Validate(); err != nil {
return fmt.Errorf("publish game finished: %w", err)
}
if msg.RuntimeStatus != runtime.StatusFinished {
return fmt.Errorf("publish game finished: runtime status must be %q, got %q", runtime.StatusFinished, msg.RuntimeStatus)
}
statsJSON, err := encodePlayerTurnStats(msg.PlayerTurnStats)
if err != nil {
return fmt.Errorf("publish game finished: %w", err)
}
values := map[string]any{
fieldEventType: eventTypeGameFinished,
fieldGameID: msg.GameID,
fieldFinalTurnNumber: strconv.Itoa(msg.FinalTurnNumber),
fieldRuntimeStatus: string(msg.RuntimeStatus),
fieldPlayerTurnStats: statsJSON,
fieldFinishedAtMS: strconv.FormatInt(msg.FinishedAt.UTC().UnixMilli(), 10),
}
if err := publisher.client.XAdd(ctx, &redis.XAddArgs{
Stream: publisher.stream,
Values: values,
}).Err(); err != nil {
return fmt.Errorf("publish game finished: xadd: %w", err)
}
return nil
}
func (publisher *Publisher) guardCall(ctx context.Context) error {
if publisher == nil || publisher.client == nil {
return errors.New("nil publisher")
}
if ctx == nil {
return errors.New("nil context")
}
return nil
}
// encodePlayerTurnStats returns the JSON serialisation of the per-player
// stats array. Empty input becomes the literal `[]` so the stream entry
// always carries a valid JSON document for the field.
func encodePlayerTurnStats(stats []ports.PlayerTurnStats) (string, error) {
if len(stats) == 0 {
return emptyPlayerTurnStatsJSON, nil
}
envelope := make([]playerTurnStatEnvelope, 0, len(stats))
for _, item := range stats {
envelope = append(envelope, playerTurnStatEnvelope{
UserID: item.UserID,
Planets: item.Planets,
Population: item.Population,
})
}
encoded, err := json.Marshal(envelope)
if err != nil {
return "", fmt.Errorf("encode player turn stats: %w", err)
}
return string(encoded), nil
}
type playerTurnStatEnvelope struct {
UserID string `json:"user_id"`
Planets int `json:"planets"`
Population int `json:"population"`
}
// Compile-time assertion: Publisher implements
// ports.LobbyEventsPublisher.
var _ ports.LobbyEventsPublisher = (*Publisher)(nil)
@@ -0,0 +1,186 @@
package lobbyeventspublisher
import (
"context"
"encoding/json"
"strconv"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"galaxy/gamemaster/internal/domain/runtime"
"galaxy/gamemaster/internal/ports"
)
const testStream = "gm:lobby_events"
func newTestPublisher(t *testing.T) (*Publisher, *redis.Client) {
t.Helper()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { _ = client.Close() })
publisher, err := NewPublisher(Config{Client: client, Stream: testStream})
require.NoError(t, err)
return publisher, client
}
func TestNewPublisherValidation(t *testing.T) {
t.Run("nil client", func(t *testing.T) {
_, err := NewPublisher(Config{Stream: testStream})
require.Error(t, err)
})
t.Run("empty stream", func(t *testing.T) {
client := redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"})
t.Cleanup(func() { _ = client.Close() })
_, err := NewPublisher(Config{Client: client})
require.Error(t, err)
})
}
func TestPublishSnapshotUpdateHappyPath(t *testing.T) {
publisher, client := newTestPublisher(t)
occurredAt := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
msg := ports.RuntimeSnapshotUpdate{
GameID: "game-1",
CurrentTurn: 17,
RuntimeStatus: runtime.StatusRunning,
EngineHealthSummary: "healthy",
PlayerTurnStats: []ports.PlayerTurnStats{
{UserID: "user-1", Planets: 4, Population: 12000},
{UserID: "user-2", Planets: 3, Population: 9000},
},
OccurredAt: occurredAt,
}
require.NoError(t, publisher.PublishSnapshotUpdate(context.Background(), msg))
entries, err := client.XRange(context.Background(), testStream, "-", "+").Result()
require.NoError(t, err)
require.Len(t, entries, 1)
values := entries[0].Values
assert.Equal(t, "runtime_snapshot_update", values[fieldEventType])
assert.Equal(t, "game-1", values[fieldGameID])
assert.Equal(t, "17", values[fieldCurrentTurn])
assert.Equal(t, "running", values[fieldRuntimeStatus])
assert.Equal(t, "healthy", values[fieldEngineHealthSummary])
assert.Equal(t, strconv.FormatInt(occurredAt.UnixMilli(), 10), values[fieldOccurredAtMS])
statsRaw, ok := values[fieldPlayerTurnStats].(string)
require.True(t, ok)
var stats []playerTurnStatEnvelope
require.NoError(t, json.Unmarshal([]byte(statsRaw), &stats))
assert.Equal(t, []playerTurnStatEnvelope{
{UserID: "user-1", Planets: 4, Population: 12000},
{UserID: "user-2", Planets: 3, Population: 9000},
}, stats)
}
func TestPublishSnapshotUpdateEmptyStatsBecomesArray(t *testing.T) {
publisher, client := newTestPublisher(t)
msg := ports.RuntimeSnapshotUpdate{
GameID: "g",
CurrentTurn: 0,
RuntimeStatus: runtime.StatusStarting,
EngineHealthSummary: "",
OccurredAt: time.Now().UTC(),
}
require.NoError(t, publisher.PublishSnapshotUpdate(context.Background(), msg))
entries, err := client.XRange(context.Background(), testStream, "-", "+").Result()
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "[]", entries[0].Values[fieldPlayerTurnStats])
}
func TestPublishSnapshotUpdateRejectsInvalid(t *testing.T) {
publisher, client := newTestPublisher(t)
require.Error(t, publisher.PublishSnapshotUpdate(context.Background(), ports.RuntimeSnapshotUpdate{}))
entries, err := client.XRange(context.Background(), testStream, "-", "+").Result()
require.NoError(t, err)
assert.Empty(t, entries, "invalid messages must not reach the stream")
}
func TestPublishGameFinishedHappyPath(t *testing.T) {
publisher, client := newTestPublisher(t)
finishedAt := time.Date(2026, 4, 28, 8, 30, 0, 0, time.UTC)
msg := ports.GameFinished{
GameID: "game-1",
FinalTurnNumber: 42,
RuntimeStatus: runtime.StatusFinished,
PlayerTurnStats: []ports.PlayerTurnStats{
{UserID: "user-1", Planets: 6, Population: 25000},
{UserID: "user-2", Planets: 0, Population: 0},
},
FinishedAt: finishedAt,
}
require.NoError(t, publisher.PublishGameFinished(context.Background(), msg))
entries, err := client.XRange(context.Background(), testStream, "-", "+").Result()
require.NoError(t, err)
require.Len(t, entries, 1)
values := entries[0].Values
assert.Equal(t, "game_finished", values[fieldEventType])
assert.Equal(t, "game-1", values[fieldGameID])
assert.Equal(t, "42", values[fieldFinalTurnNumber])
assert.Equal(t, "finished", values[fieldRuntimeStatus])
assert.Equal(t, strconv.FormatInt(finishedAt.UnixMilli(), 10), values[fieldFinishedAtMS])
_, hasOccurred := values[fieldOccurredAtMS]
assert.False(t, hasOccurred, "game_finished must not carry occurred_at_ms")
_, hasCurrentTurn := values[fieldCurrentTurn]
assert.False(t, hasCurrentTurn, "game_finished must not carry current_turn")
_, hasHealth := values[fieldEngineHealthSummary]
assert.False(t, hasHealth, "game_finished must not carry engine_health_summary")
}
func TestPublishGameFinishedRejectsBadStatus(t *testing.T) {
publisher, client := newTestPublisher(t)
require.Error(t, publisher.PublishGameFinished(context.Background(), ports.GameFinished{
GameID: "g",
FinalTurnNumber: 1,
RuntimeStatus: runtime.StatusRunning, // wrong status
FinishedAt: time.Now().UTC(),
}))
entries, err := client.XRange(context.Background(), testStream, "-", "+").Result()
require.NoError(t, err)
assert.Empty(t, entries)
}
func TestTimestampsNormalisedToUTC(t *testing.T) {
publisher, client := newTestPublisher(t)
loc, err := time.LoadLocation("Asia/Tokyo")
require.NoError(t, err)
msg := ports.RuntimeSnapshotUpdate{
GameID: "g",
CurrentTurn: 1,
RuntimeStatus: runtime.StatusRunning,
OccurredAt: time.Date(2026, 4, 27, 21, 0, 0, 0, loc),
}
require.NoError(t, publisher.PublishSnapshotUpdate(context.Background(), msg))
entries, err := client.XRange(context.Background(), testStream, "-", "+").Result()
require.NoError(t, err)
require.Len(t, entries, 1)
wantMs := msg.OccurredAt.UTC().UnixMilli()
assert.Equal(t, strconv.FormatInt(wantMs, 10), entries[0].Values[fieldOccurredAtMS])
}
func TestRejectsNilContext(t *testing.T) {
publisher, _ := newTestPublisher(t)
//nolint:staticcheck // explicitly testing nil-context rejection.
err := publisher.PublishSnapshotUpdate(nil, ports.RuntimeSnapshotUpdate{
GameID: "g",
CurrentTurn: 0,
RuntimeStatus: runtime.StatusStarting,
OccurredAt: time.Now().UTC(),
})
require.Error(t, err)
}