// Package listgames implements the `lobby.games.list` message type. It // returns the public game list per `lobby/README.md` §«User-Facing Lists» // — public records in `enrollment_open`, `ready_to_start`, `running`, // or `finished` — and additionally surfaces every private game the // caller currently belongs to (membership in any status, except games // in `draft` or `cancelled`). Admin actors bypass the visibility filter // and see every record regardless of game type or status, which is the // surface used by the internal-port `adminListGames` route. package listgames import ( "context" "errors" "fmt" "log/slog" "sort" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/shared" ) // publicVisibleStatuses lists the user-visible public-game statuses // allowed in the public list per README §«Public game list». var publicVisibleStatuses = []game.Status{ game.StatusEnrollmentOpen, game.StatusReadyToStart, game.StatusRunning, game.StatusFinished, } // adminAllStatuses enumerates every status used by the admin variant to // produce an unrestricted list (see `lobby/api/internal-openapi.yaml` // `adminListGames`). var adminAllStatuses = []game.Status{ game.StatusDraft, game.StatusEnrollmentOpen, game.StatusReadyToStart, game.StatusStarting, game.StatusStartFailed, game.StatusRunning, game.StatusPaused, game.StatusFinished, game.StatusCancelled, } // privateMembershipExcludedStatuses lists the statuses removed from the // user-visible private list. Draft games are owner-only (handled by // `getgame`); cancelled games drop off the user-facing list entirely. var privateMembershipExcludedStatuses = map[game.Status]struct{}{ game.StatusDraft: {}, game.StatusCancelled: {}, } // Service executes the list-games use case. type Service struct { games ports.GameStore memberships ports.MembershipStore logger *slog.Logger } // Dependencies groups the collaborators used by Service. type Dependencies struct { // Games supplies the per-status indexes that drive both the public // list and the unrestricted admin list. Games ports.GameStore // Memberships is consulted only for user actors to enumerate the // private games the caller belongs to. Memberships ports.MembershipStore // Logger records structured diagnostics. Defaults to slog.Default // when nil. Logger *slog.Logger } // NewService constructs one Service with deps. func NewService(deps Dependencies) (*Service, error) { switch { case deps.Games == nil: return nil, errors.New("new list games service: nil game store") case deps.Memberships == nil: return nil, errors.New("new list games service: nil membership store") } logger := deps.Logger if logger == nil { logger = slog.Default() } return &Service{ games: deps.Games, memberships: deps.Memberships, logger: logger.With("service", "lobby.listgames"), }, nil } // Input stores the arguments required to list games for one caller. type Input struct { // Actor identifies the caller. Admin actors receive the // unrestricted list; user actors receive the public list plus // their own private games. Actor shared.Actor // Page stores the pagination request derived from page_size and // page_token by the transport layer. Page shared.Page } // Output stores the page returned by Handle. type Output struct { // Items stores the records included in the current page. Items []game.Game // NextPageToken stores the opaque continuation token. It is empty // when the current page is the last one. NextPageToken string } // Handle assembles the candidate set, sorts it deterministically, and // returns the requested page together with the next-page token. func (service *Service) Handle(ctx context.Context, input Input) (Output, error) { if service == nil { return Output{}, errors.New("list games: nil service") } if ctx == nil { return Output{}, errors.New("list games: nil context") } if err := input.Actor.Validate(); err != nil { return Output{}, fmt.Errorf("list games: actor: %w", err) } candidates, err := service.collect(ctx, input.Actor) if err != nil { return Output{}, fmt.Errorf("list games: %w", err) } sortRecords(candidates) start, end, nextOffset, hasMore := shared.Window(len(candidates), input.Page) out := Output{Items: append([]game.Game(nil), candidates[start:end]...)} if hasMore { out.NextPageToken = shared.EncodeToken(nextOffset) } return out, nil } // collect builds the deduplicated candidate set per actor class. func (service *Service) collect(ctx context.Context, actor shared.Actor) ([]game.Game, error) { if actor.IsAdmin() { return service.collectAdmin(ctx) } return service.collectUser(ctx, actor.UserID) } func (service *Service) collectAdmin(ctx context.Context) ([]game.Game, error) { seen := make(map[common.GameID]struct{}) candidates := make([]game.Game, 0) for _, status := range adminAllStatuses { records, err := service.games.GetByStatus(ctx, status) if err != nil { return nil, err } for _, record := range records { if _, dup := seen[record.GameID]; dup { continue } seen[record.GameID] = struct{}{} candidates = append(candidates, record) } } return candidates, nil } func (service *Service) collectUser(ctx context.Context, userID string) ([]game.Game, error) { seen := make(map[common.GameID]struct{}) candidates := make([]game.Game, 0) for _, status := range publicVisibleStatuses { records, err := service.games.GetByStatus(ctx, status) if err != nil { return nil, err } for _, record := range records { if record.GameType != game.GameTypePublic { continue } if _, dup := seen[record.GameID]; dup { continue } seen[record.GameID] = struct{}{} candidates = append(candidates, record) } } memberships, err := service.memberships.GetByUser(ctx, userID) if err != nil { return nil, err } for _, mb := range memberships { if _, dup := seen[mb.GameID]; dup { continue } record, err := service.games.Get(ctx, mb.GameID) if err != nil { if errors.Is(err, game.ErrNotFound) { service.logger.WarnContext(ctx, "membership references missing game", "user_id", userID, "game_id", mb.GameID.String(), ) continue } return nil, err } if record.GameType != game.GameTypePrivate { continue } if _, excluded := privateMembershipExcludedStatuses[record.Status]; excluded { continue } seen[record.GameID] = struct{}{} candidates = append(candidates, record) } return candidates, nil } // statusGroup returns the bucket index used to order records per // README §«Public game list»: enrollment_open and ready_to_start come // first, then running, then finished. Other statuses surface only on // the admin or user-private paths and are appended afterwards in a // stable order so `next_page_token` is deterministic. func statusGroup(status game.Status) int { switch status { case game.StatusEnrollmentOpen, game.StatusReadyToStart: return 0 case game.StatusRunning: return 1 case game.StatusFinished: return 2 case game.StatusPaused: return 3 case game.StatusStarting: return 4 case game.StatusStartFailed: return 5 case game.StatusDraft: return 6 case game.StatusCancelled: return 7 default: return 8 } } // sortRecords sorts the candidate set per the documented ordering. The // secondary key is CreatedAt descending («most recent first within each // group»), and the tertiary key is GameID ascending for stable output // when two records share the same group and CreatedAt. func sortRecords(items []game.Game) { sort.Slice(items, func(i, j int) bool { gi, gj := statusGroup(items[i].Status), statusGroup(items[j].Status) if gi != gj { return gi < gj } if !items[i].CreatedAt.Equal(items[j].CreatedAt) { return items[i].CreatedAt.After(items[j].CreatedAt) } return items[i].GameID < items[j].GameID }) }