Files
galaxy-game/gamemaster/internal/adapters/lobbyeventspublisher/publisher.go
T
2026-05-03 07:59:03 +02:00

181 lines
6.0 KiB
Go

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