// 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)