// Package ports defines the stable interfaces that connect Game Lobby // Service use cases to external state and external services. package ports import ( "context" "fmt" "time" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" ) // GameStore stores game records and their secondary indexes. Adapters are // responsible for maintaining the status index together with the record. type GameStore interface { // Save upserts record. It is used for draft creation and for // field-only edits. The adapter must rewrite the status secondary // index when the status field changes. Save does not apply the // domain transition gate; callers that intend a status transition // must use UpdateStatus instead. Save(ctx context.Context, record game.Game) error // Get returns the record identified by gameID. It returns // game.ErrNotFound when no record exists. Get(ctx context.Context, gameID common.GameID) (game.Game, error) // GetByStatus returns every record currently indexed under status. // The slice is ordered by the created-at score ascending; callers // may reorder as needed. GetByStatus(ctx context.Context, status game.Status) ([]game.Game, error) // CountByStatus returns the number of game records indexed under // each known status. The map carries one entry per game.Status from // game.AllStatuses, with zero counts for empty buckets. Telemetry // uses the result to emit the `lobby.active_games` observable gauge // without scanning record payloads. CountByStatus(ctx context.Context) (map[game.Status]int, error) // GetByOwner returns every record whose OwnerUserID equals userID. // The order is adapter-defined; callers may reorder as needed. The // secondary index is maintained alongside the per-status index; // cascade-release callers consume it without touching the // status listings. GetByOwner(ctx context.Context, userID string) ([]game.Game, error) // UpdateStatus applies one status transition in a compare-and-swap // fashion. The adapter must first call game.Transition to reject // invalid triplets without touching the store; on success it must // verify that the current status equals input.ExpectedFrom, update // the primary record, and rewrite the status secondary index. // Adapters set StartedAt when transitioning to running and // FinishedAt when transitioning to finished. UpdateStatus(ctx context.Context, input UpdateStatusInput) error // UpdateRuntimeSnapshot overwrites the denormalized runtime snapshot // fields on the record identified by input.GameID. It does not // mutate the status field or the status secondary index. UpdateRuntimeSnapshot(ctx context.Context, input UpdateRuntimeSnapshotInput) error // UpdateRuntimeBinding overwrites the runtime binding metadata on the // record identified by input.GameID. It does not mutate the status // field or the status secondary index. The binding must satisfy the // domain invariants (see game.RuntimeBinding.Validate). The adapter // returns game.ErrNotFound when no record exists. UpdateRuntimeBinding(ctx context.Context, input UpdateRuntimeBindingInput) error } // UpdateStatusInput stores the arguments required to apply one status // transition through a GameStore. type UpdateStatusInput struct { // GameID identifies the record to mutate. GameID common.GameID // ExpectedFrom stores the status the caller believes the record // currently has. A mismatch results in game.ErrConflict. ExpectedFrom game.Status // To stores the destination status. To game.Status // Trigger stores the transition trigger used by the domain gate. Trigger game.Trigger // At stores the wall-clock used for UpdatedAt, and for StartedAt or // FinishedAt when the destination status requires it. At time.Time } // Validate reports whether input contains a structurally valid status // transition request. func (input UpdateStatusInput) Validate() error { if err := input.GameID.Validate(); err != nil { return fmt.Errorf("update status: game id: %w", err) } if !input.ExpectedFrom.IsKnown() { return fmt.Errorf("update status: expected from status %q is unsupported", input.ExpectedFrom) } if !input.To.IsKnown() { return fmt.Errorf("update status: to status %q is unsupported", input.To) } if !input.Trigger.IsKnown() { return fmt.Errorf("update status: trigger %q is unsupported", input.Trigger) } if input.At.IsZero() { return fmt.Errorf("update status: at must not be zero") } return nil } // UpdateRuntimeSnapshotInput stores the arguments required to update the // denormalized runtime snapshot on one game record. type UpdateRuntimeSnapshotInput struct { // GameID identifies the record to mutate. GameID common.GameID // Snapshot stores the new snapshot values to persist. Snapshot game.RuntimeSnapshot // At stores the wall-clock used for UpdatedAt. At time.Time } // Validate reports whether input contains a structurally valid runtime // snapshot update request. func (input UpdateRuntimeSnapshotInput) Validate() error { if err := input.GameID.Validate(); err != nil { return fmt.Errorf("update runtime snapshot: game id: %w", err) } if input.Snapshot.CurrentTurn < 0 { return fmt.Errorf("update runtime snapshot: current turn must not be negative") } if input.At.IsZero() { return fmt.Errorf("update runtime snapshot: at must not be zero") } return nil } // UpdateRuntimeBindingInput stores the arguments required to persist // runtime binding metadata on one game record. type UpdateRuntimeBindingInput struct { // GameID identifies the record to mutate. GameID common.GameID // Binding stores the runtime binding values to persist. The adapter // validates the binding before writing. Binding game.RuntimeBinding // At stores the wall-clock used for UpdatedAt. At time.Time } // Validate reports whether input contains a structurally valid runtime // binding update request. func (input UpdateRuntimeBindingInput) Validate() error { if err := input.GameID.Validate(); err != nil { return fmt.Errorf("update runtime binding: game id: %w", err) } if err := input.Binding.Validate(); err != nil { return fmt.Errorf("update runtime binding: %w", err) } if input.At.IsZero() { return fmt.Errorf("update runtime binding: at must not be zero") } return nil }