feat: gamemaster
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user