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

167 lines
5.2 KiB
Go

package ports
import (
"context"
"fmt"
"strings"
"time"
"galaxy/gamemaster/internal/domain/runtime"
)
//go:generate go run go.uber.org/mock/mockgen -destination=../adapters/mocks/mock_lobbyeventspublisher.go -package=mocks galaxy/gamemaster/internal/ports LobbyEventsPublisher
// LobbyEventsPublisher is the producer port for the `gm:lobby_events`
// Redis Stream consumed by Game Lobby. Two message shapes share the
// stream, discriminated by `event_type` per
// `galaxy/gamemaster/api/runtime-events-asyncapi.yaml`:
//
// - runtime_snapshot_update — every turn generation outcome and every
// status / health-summary transition.
// - game_finished — the terminal event published once per game when
// the engine reports `finished:true`.
type LobbyEventsPublisher interface {
// PublishSnapshotUpdate appends a `runtime_snapshot_update` message
// to the stream. Adapters validate msg through msg.Validate before
// touching Redis.
PublishSnapshotUpdate(ctx context.Context, msg RuntimeSnapshotUpdate) error
// PublishGameFinished appends a `game_finished` message to the
// stream. Adapters validate msg through msg.Validate before
// touching Redis.
PublishGameFinished(ctx context.Context, msg GameFinished) error
}
// PlayerTurnStats stores the per-player projection carried on every
// `runtime_snapshot_update` and `game_finished` message. The shape is
// frozen in the AsyncAPI spec.
type PlayerTurnStats struct {
// UserID identifies the platform user.
UserID string
// Planets stores the planet count reported for this user on the
// most recent turn.
Planets int
// Population stores the population count reported for this user
// on the most recent turn.
Population int
}
// Validate reports whether stats carries valid per-player projection
// values.
func (stats PlayerTurnStats) Validate() error {
if strings.TrimSpace(stats.UserID) == "" {
return fmt.Errorf("player turn stats: user id must not be empty")
}
if stats.Planets < 0 {
return fmt.Errorf("player turn stats: planets must not be negative")
}
if stats.Population < 0 {
return fmt.Errorf("player turn stats: population must not be negative")
}
return nil
}
// RuntimeSnapshotUpdate stores the body of a `runtime_snapshot_update`
// message.
type RuntimeSnapshotUpdate struct {
// GameID identifies the game the snapshot belongs to.
GameID string
// CurrentTurn stores the latest completed turn number.
CurrentTurn int
// RuntimeStatus stores the latest GM-side status of the runtime.
RuntimeStatus runtime.Status
// EngineHealthSummary stores the current health summary string.
// Empty when no observation has been processed yet.
EngineHealthSummary string
// PlayerTurnStats stores the per-active-member projection. Empty
// when the snapshot is published for a status transition with no
// new turn payload.
PlayerTurnStats []PlayerTurnStats
// OccurredAt stores the wall-clock at which the snapshot was
// produced. Always UTC.
OccurredAt time.Time
}
// Validate reports whether msg satisfies the AsyncAPI-frozen invariants.
func (msg RuntimeSnapshotUpdate) Validate() error {
if strings.TrimSpace(msg.GameID) == "" {
return fmt.Errorf("runtime snapshot update: game id must not be empty")
}
if msg.CurrentTurn < 0 {
return fmt.Errorf("runtime snapshot update: current turn must not be negative")
}
if !msg.RuntimeStatus.IsKnown() {
return fmt.Errorf(
"runtime snapshot update: runtime status %q is unsupported",
msg.RuntimeStatus,
)
}
if msg.OccurredAt.IsZero() {
return fmt.Errorf("runtime snapshot update: occurred at must not be zero")
}
for i, stats := range msg.PlayerTurnStats {
if err := stats.Validate(); err != nil {
return fmt.Errorf(
"runtime snapshot update: player turn stats[%d]: %w",
i, err,
)
}
}
return nil
}
// GameFinished stores the body of a `game_finished` message.
type GameFinished struct {
// GameID identifies the game that finished.
GameID string
// FinalTurnNumber stores the turn number on which the engine
// reported `finished:true`.
FinalTurnNumber int
// RuntimeStatus is always runtime.StatusFinished. Carried in the
// message body so consumers can apply the same decoder to both
// stream shapes.
RuntimeStatus runtime.Status
// PlayerTurnStats stores the final per-player projection used by
// Lobby's capability evaluation.
PlayerTurnStats []PlayerTurnStats
// FinishedAt stores the wall-clock at which the engine returned
// the finished response. Always UTC.
FinishedAt time.Time
}
// Validate reports whether msg satisfies the AsyncAPI-frozen invariants.
func (msg GameFinished) Validate() error {
if strings.TrimSpace(msg.GameID) == "" {
return fmt.Errorf("game finished: game id must not be empty")
}
if msg.FinalTurnNumber < 0 {
return fmt.Errorf("game finished: final turn number must not be negative")
}
if msg.RuntimeStatus != runtime.StatusFinished {
return fmt.Errorf(
"game finished: runtime status must be %q, got %q",
runtime.StatusFinished, msg.RuntimeStatus,
)
}
if msg.FinishedAt.IsZero() {
return fmt.Errorf("game finished: finished at must not be zero")
}
for i, stats := range msg.PlayerTurnStats {
if err := stats.Validate(); err != nil {
return fmt.Errorf("game finished: player turn stats[%d]: %w", i, err)
}
}
return nil
}