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,73 @@
// Package notificationpublisher provides the Redis-Streams-backed
// notification-intent publisher Game Master uses for the three GM-owned
// types listed in `gamemaster/README.md §Notification Contracts`:
// `game.turn.ready`, `game.finished`, `game.generation_failed`.
//
// The adapter is a thin shim over `galaxy/notificationintent.Publisher`
// that drops the entry id at the wrapper boundary; it mirrors
// `rtmanager/internal/adapters/notificationpublisher` byte-for-byte
// (`rtmanager/docs/domain-and-ports.md §7` justifies that decision and
// applies here for the same reason).
package notificationpublisher
import (
"context"
"errors"
"fmt"
"github.com/redis/go-redis/v9"
"galaxy/notificationintent"
"galaxy/gamemaster/internal/ports"
)
// 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 intents are published to.
// When empty, `notificationintent.DefaultIntentsStream` is used.
Stream string
}
// Publisher implements `ports.NotificationIntentPublisher` on top of
// the shared `notificationintent.Publisher`.
type Publisher struct {
inner *notificationintent.Publisher
}
// NewPublisher constructs a Publisher from cfg. Validation errors and
// transport errors propagate verbatim.
func NewPublisher(cfg Config) (*Publisher, error) {
if cfg.Client == nil {
return nil, errors.New("new gamemaster notification publisher: nil redis client")
}
inner, err := notificationintent.NewPublisher(notificationintent.PublisherConfig{
Client: cfg.Client,
Stream: cfg.Stream,
})
if err != nil {
return nil, fmt.Errorf("new gamemaster notification publisher: %w", err)
}
return &Publisher{inner: inner}, nil
}
// Publish forwards intent to the underlying notificationintent
// publisher and discards the resulting Redis Stream entry id. A failed
// publish surfaces as the underlying error.
func (publisher *Publisher) Publish(ctx context.Context, intent notificationintent.Intent) error {
if publisher == nil || publisher.inner == nil {
return errors.New("publish notification intent: nil publisher")
}
if _, err := publisher.inner.Publish(ctx, intent); err != nil {
return err
}
return nil
}
// Compile-time assertion: Publisher implements
// ports.NotificationIntentPublisher.
var _ ports.NotificationIntentPublisher = (*Publisher)(nil)
@@ -0,0 +1,167 @@
package notificationpublisher
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"galaxy/notificationintent"
)
func newRedis(t *testing.T) (*redis.Client, *miniredis.Miniredis) {
t.Helper()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { _ = client.Close() })
return client, server
}
func readStream(t *testing.T, client *redis.Client, stream string) []redis.XMessage {
t.Helper()
messages, err := client.XRange(context.Background(), stream, "-", "+").Result()
require.NoError(t, err)
return messages
}
func TestNewPublisherValidation(t *testing.T) {
t.Run("nil client", func(t *testing.T) {
_, err := NewPublisher(Config{})
require.Error(t, err)
assert.Contains(t, err.Error(), "nil redis client")
})
}
func TestPublishGameTurnReady(t *testing.T) {
client, _ := newRedis(t)
publisher, err := NewPublisher(Config{Client: client, Stream: "notification:intents"})
require.NoError(t, err)
intent, err := notificationintent.NewGameTurnReadyIntent(
notificationintent.Metadata{
IdempotencyKey: "gamemaster:turn:game-1:42",
OccurredAt: time.UnixMilli(1714200000000).UTC(),
},
[]string{"u-2", "u-1"},
notificationintent.GameTurnReadyPayload{
GameID: "game-1",
GameName: "Galaxy",
TurnNumber: 42,
},
)
require.NoError(t, err)
require.NoError(t, publisher.Publish(context.Background(), intent))
messages := readStream(t, client, "notification:intents")
require.Len(t, messages, 1)
values := messages[0].Values
assert.Equal(t, "game.turn.ready", values["notification_type"])
assert.Equal(t, "game_master", values["producer"])
assert.Equal(t, "user", values["audience_kind"])
assert.Equal(t, "gamemaster:turn:game-1:42", values["idempotency_key"])
recipients, ok := values["recipient_user_ids_json"].(string)
require.True(t, ok)
var ids []string
require.NoError(t, json.Unmarshal([]byte(recipients), &ids))
assert.ElementsMatch(t, []string{"u-1", "u-2"}, ids)
payloadRaw, ok := values["payload_json"].(string)
require.True(t, ok)
var payload map[string]any
require.NoError(t, json.Unmarshal([]byte(payloadRaw), &payload))
assert.Equal(t, "game-1", payload["game_id"])
assert.Equal(t, float64(42), payload["turn_number"])
}
func TestPublishGameFinished(t *testing.T) {
client, _ := newRedis(t)
publisher, err := NewPublisher(Config{Client: client, Stream: "notification:intents"})
require.NoError(t, err)
intent, err := notificationintent.NewGameFinishedIntent(
notificationintent.Metadata{
IdempotencyKey: "gamemaster:finished:g-1",
OccurredAt: time.UnixMilli(1714200000000).UTC(),
},
[]string{"u-1"},
notificationintent.GameFinishedPayload{
GameID: "g-1",
GameName: "Galaxy",
FinalTurnNumber: 100,
},
)
require.NoError(t, err)
require.NoError(t, publisher.Publish(context.Background(), intent))
messages := readStream(t, client, "notification:intents")
require.Len(t, messages, 1)
assert.Equal(t, "game.finished", messages[0].Values["notification_type"])
assert.Equal(t, "user", messages[0].Values["audience_kind"])
}
func TestPublishGameGenerationFailed(t *testing.T) {
client, _ := newRedis(t)
publisher, err := NewPublisher(Config{Client: client, Stream: "notification:intents"})
require.NoError(t, err)
intent, err := notificationintent.NewGameGenerationFailedIntent(
notificationintent.Metadata{
IdempotencyKey: "gamemaster:gen-failed:g-1:42",
OccurredAt: time.UnixMilli(1714200000000).UTC(),
},
notificationintent.GameGenerationFailedPayload{
GameID: "g-1",
GameName: "Galaxy",
FailureReason: "engine timeout",
},
)
require.NoError(t, err)
require.NoError(t, publisher.Publish(context.Background(), intent))
messages := readStream(t, client, "notification:intents")
require.Len(t, messages, 1)
values := messages[0].Values
assert.Equal(t, "game.generation_failed", values["notification_type"])
assert.Equal(t, "admin_email", values["audience_kind"])
_, hasRecipients := values["recipient_user_ids_json"]
assert.False(t, hasRecipients, "admin_email audience must not carry recipient ids")
}
func TestPublishForwardsValidationError(t *testing.T) {
client, _ := newRedis(t)
publisher, err := NewPublisher(Config{Client: client})
require.NoError(t, err)
bad := notificationintent.Intent{
NotificationType: notificationintent.NotificationTypeGameTurnReady,
Producer: notificationintent.ProducerGameMaster,
AudienceKind: notificationintent.AudienceKindUser,
IdempotencyKey: "k",
PayloadJSON: `{"game_id":"g","game_name":"x","turn_number":1}`,
}
require.Error(t, publisher.Publish(context.Background(), bad))
}
func TestPublishDefaultStream(t *testing.T) {
client, _ := newRedis(t)
publisher, err := NewPublisher(Config{Client: client, Stream: ""})
require.NoError(t, err)
intent, err := notificationintent.NewGameTurnReadyIntent(
notificationintent.Metadata{IdempotencyKey: "k", OccurredAt: time.UnixMilli(1).UTC()},
[]string{"u-1"},
notificationintent.GameTurnReadyPayload{GameID: "g", GameName: "n", TurnNumber: 1},
)
require.NoError(t, err)
require.NoError(t, publisher.Publish(context.Background(), intent))
messages := readStream(t, client, notificationintent.DefaultIntentsStream)
require.Len(t, messages, 1)
}