// Package listmygames implements the `lobby.my_games.list` message // type. It returns the games where the acting user holds an active // membership and the game status is `running` or `paused`, matching // `lobby/README.md` §«My active games». The denormalized runtime // snapshot already lives on the game record, so the response is // produced from `gameStore.Get` alone — no fan-out to Game Master. // // The service is exclusively self-service: admin actors are rejected // with `shared.ErrForbidden`, mirroring `listmyracenames`. package listmygames import ( "context" "errors" "fmt" "log/slog" "sort" "time" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/domain/membership" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/shared" ) // activeGameStatuses lists the game statuses included in the user's // «active games» view per README §«My active games». var activeGameStatuses = map[game.Status]struct{}{ game.StatusRunning: {}, game.StatusPaused: {}, } // Service executes the list-my-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 is loaded per active membership to read game name, owner, // status, and the denormalized runtime snapshot. Games ports.GameStore // Memberships supplies the per-user membership index. 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 my games service: nil game store") case deps.Memberships == nil: return nil, errors.New("new list my 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.listmygames"), }, nil } // Input stores the arguments required to compute the acting user's // active-games view. type Input struct { // Actor identifies the caller. Must be ActorKindUser. Actor shared.Actor // Page stores the pagination request. Page shared.Page } // Output stores the page returned by Handle. type Output struct { // Items stores the active game records included in the current page. Items []game.Game // NextPageToken stores the opaque continuation token; empty when // the current page is the last one. NextPageToken string } // Handle authorizes the actor as a user, fetches their active // memberships, joins them with the game records, filters by status, // sorts deterministically, and returns the requested page. func (service *Service) Handle(ctx context.Context, input Input) (Output, error) { if service == nil { return Output{}, errors.New("list my games: nil service") } if ctx == nil { return Output{}, errors.New("list my games: nil context") } if err := input.Actor.Validate(); err != nil { return Output{}, fmt.Errorf("list my games: actor: %w", err) } if !input.Actor.IsUser() { return Output{}, fmt.Errorf( "%w: only authenticated user actors may list their games", shared.ErrForbidden, ) } memberships, err := service.memberships.GetByUser(ctx, input.Actor.UserID) if err != nil { return Output{}, fmt.Errorf("list my games: %w", err) } seen := make(map[common.GameID]struct{}) candidates := make([]game.Game, 0, len(memberships)) for _, mb := range memberships { if mb.Status != membership.StatusActive { continue } 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", input.Actor.UserID, "game_id", mb.GameID.String(), ) continue } return Output{}, fmt.Errorf("list my games: %w", err) } if _, ok := activeGameStatuses[record.Status]; !ok { continue } seen[record.GameID] = struct{}{} candidates = append(candidates, record) } 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 } // sortRecords orders items by StartedAt descending so the most recently // started games surface first. Records without StartedAt fall back to // CreatedAt; GameID breaks ties for stable output. func sortRecords(items []game.Game) { sort.Slice(items, func(i, j int) bool { ti := startedOrCreated(items[i]) tj := startedOrCreated(items[j]) if !ti.Equal(tj) { return ti.After(tj) } return items[i].GameID < items[j].GameID }) } func startedOrCreated(record game.Game) time.Time { if record.StartedAt != nil { return *record.StartedAt } return record.CreatedAt }