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 }