181 lines
6.0 KiB
Go
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)
|