// Package lobbyeligibility implements the trusted lobby-facing eligibility // snapshot read owned by User Service. package lobbyeligibility import ( "context" "errors" "fmt" "time" "galaxy/user/internal/domain/common" "galaxy/user/internal/domain/entitlement" "galaxy/user/internal/domain/policy" "galaxy/user/internal/ports" "galaxy/user/internal/service/shared" ) // limitCatalogEntry stores one frozen default quota for free and paid // entitlement states. type limitCatalogEntry struct { code policy.LimitCode freeValue int paidValue int freeEnabled bool } // limitCatalog stores the frozen lobby-facing effective limit defaults used // to materialize numeric quotas from the current entitlement state. var limitCatalog = []limitCatalogEntry{ { code: policy.LimitCodeMaxOwnedPrivateGames, paidValue: 3, }, { code: policy.LimitCodeMaxPendingPublicApplications, freeValue: 3, paidValue: 10, freeEnabled: true, }, { code: policy.LimitCodeMaxActiveGameMemberships, freeValue: 3, paidValue: 10, freeEnabled: true, }, } // ActorRefView stores transport-ready audit actor metadata. type ActorRefView struct { // Type stores the machine-readable actor type. Type string `json:"type"` // ID stores the optional stable actor identifier. ID string `json:"id,omitempty"` } // EntitlementSnapshotView stores the transport-ready current entitlement // snapshot used by lobby reads. type EntitlementSnapshotView struct { // PlanCode stores the effective entitlement plan code. PlanCode string `json:"plan_code"` // IsPaid reports whether the effective plan is paid. IsPaid bool `json:"is_paid"` // Source stores the machine-readable mutation source. Source string `json:"source"` // Actor stores the audit actor metadata attached to the snapshot. Actor ActorRefView `json:"actor"` // ReasonCode stores the machine-readable reason attached to the snapshot. ReasonCode string `json:"reason_code"` // StartsAt stores when the effective state started. StartsAt time.Time `json:"starts_at"` // EndsAt stores the optional finite effective expiry. EndsAt *time.Time `json:"ends_at,omitempty"` // UpdatedAt stores when the snapshot was last recomputed. UpdatedAt time.Time `json:"updated_at"` } // ActiveSanctionView stores one transport-ready active sanction that matters // to lobby flows. type ActiveSanctionView struct { // SanctionCode stores the active sanction code. SanctionCode string `json:"sanction_code"` // Scope stores the machine-readable sanction scope. Scope string `json:"scope"` // ReasonCode stores the machine-readable sanction reason. ReasonCode string `json:"reason_code"` // Actor stores the audit actor metadata attached to the sanction. Actor ActorRefView `json:"actor"` // AppliedAt stores when the sanction became active. AppliedAt time.Time `json:"applied_at"` // ExpiresAt stores the optional planned sanction expiry. ExpiresAt *time.Time `json:"expires_at,omitempty"` } // EffectiveLimitView stores one materialized effective lobby quota. type EffectiveLimitView struct { // LimitCode stores the machine-readable quota identifier. LimitCode string `json:"limit_code"` // Value stores the effective numeric quota after defaults and user // overrides are applied. Value int `json:"value"` } // EligibilityMarkersView stores the derived booleans consumed by Game Lobby. type EligibilityMarkersView struct { // CanLogin reports whether the user may currently log in. CanLogin bool `json:"can_login"` // CanCreatePrivateGame reports whether the user may currently create a // private game. CanCreatePrivateGame bool `json:"can_create_private_game"` // CanManagePrivateGame reports whether the user may currently manage a // private game. CanManagePrivateGame bool `json:"can_manage_private_game"` // CanJoinGame reports whether the user may currently join a game. CanJoinGame bool `json:"can_join_game"` // CanUpdateProfile reports whether the user may currently update self- // service profile and settings fields. CanUpdateProfile bool `json:"can_update_profile"` } // GetUserEligibilityInput stores one lobby-facing eligibility read request. type GetUserEligibilityInput struct { // UserID identifies the regular user whose effective lobby state is needed. UserID string } // GetUserEligibilityResult stores one lobby-facing eligibility snapshot. type GetUserEligibilityResult struct { // Exists reports whether UserID currently identifies a stored user. Exists bool `json:"exists"` // UserID echoes the requested stable user identifier. UserID string `json:"user_id"` // Entitlement stores the current effective entitlement snapshot for known // users. Entitlement *EntitlementSnapshotView `json:"entitlement,omitempty"` // ActiveSanctions stores only the currently active sanctions relevant to // lobby decisions. ActiveSanctions []ActiveSanctionView `json:"active_sanctions"` // EffectiveLimits stores the materialized numeric quotas used by Game // Lobby. EffectiveLimits []EffectiveLimitView `json:"effective_limits"` // Markers stores the derived decision booleans consumed by Game Lobby. Markers EligibilityMarkersView `json:"markers"` } type entitlementReader interface { GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) } // SnapshotReader executes the trusted lobby-facing eligibility snapshot read. type SnapshotReader struct { accounts ports.UserAccountStore entitlements entitlementReader sanctions ports.SanctionStore limits ports.LimitStore clock ports.Clock } // NewSnapshotReader constructs one lobby-facing eligibility snapshot reader. func NewSnapshotReader( accounts ports.UserAccountStore, entitlements entitlementReader, sanctions ports.SanctionStore, limits ports.LimitStore, clock ports.Clock, ) (*SnapshotReader, error) { switch { case accounts == nil: return nil, fmt.Errorf("lobby eligibility snapshot reader: user account store must not be nil") case entitlements == nil: return nil, fmt.Errorf("lobby eligibility snapshot reader: entitlement reader must not be nil") case sanctions == nil: return nil, fmt.Errorf("lobby eligibility snapshot reader: sanction store must not be nil") case limits == nil: return nil, fmt.Errorf("lobby eligibility snapshot reader: limit store must not be nil") case clock == nil: return nil, fmt.Errorf("lobby eligibility snapshot reader: clock must not be nil") default: return &SnapshotReader{ accounts: accounts, entitlements: entitlements, sanctions: sanctions, limits: limits, clock: clock, }, nil } } // Execute returns one read-optimized eligibility snapshot for Game Lobby. func (service *SnapshotReader) Execute( ctx context.Context, input GetUserEligibilityInput, ) (GetUserEligibilityResult, error) { if ctx == nil { return GetUserEligibilityResult{}, shared.InvalidRequest("context must not be nil") } userID, err := shared.ParseUserID(input.UserID) if err != nil { return GetUserEligibilityResult{}, err } result := GetUserEligibilityResult{ UserID: userID.String(), ActiveSanctions: []ActiveSanctionView{}, EffectiveLimits: []EffectiveLimitView{}, } exists, err := service.accounts.ExistsByUserID(ctx, userID) if err != nil { return GetUserEligibilityResult{}, shared.ServiceUnavailable(err) } if !exists { return result, nil } now := service.clock.Now().UTC() entitlementSnapshot, err := service.entitlements.GetByUserID(ctx, userID) switch { case err == nil: case errors.Is(err, ports.ErrNotFound): return GetUserEligibilityResult{}, shared.InternalError(fmt.Errorf("user %q is missing entitlement snapshot", userID)) default: return GetUserEligibilityResult{}, shared.ServiceUnavailable(err) } sanctionRecords, err := service.sanctions.ListByUserID(ctx, userID) if err != nil { return GetUserEligibilityResult{}, shared.ServiceUnavailable(err) } activeSanctions, err := policy.ActiveSanctionsAt(sanctionRecords, now) if err != nil { return GetUserEligibilityResult{}, shared.InternalError(fmt.Errorf("evaluate active sanctions for user %q: %w", userID, err)) } limitRecords, err := service.limits.ListByUserID(ctx, userID) if err != nil { return GetUserEligibilityResult{}, shared.ServiceUnavailable(err) } activeLimits, err := policy.ActiveLimitsAt(limitRecords, now) if err != nil { return GetUserEligibilityResult{}, shared.InternalError(fmt.Errorf("evaluate active limits for user %q: %w", userID, err)) } result.Exists = true result.Entitlement = entitlementSnapshotView(entitlementSnapshot) result.ActiveSanctions = lobbyRelevantSanctionViews(activeSanctions) result.EffectiveLimits = materializeEffectiveLimits(entitlementSnapshot.IsPaid, activeLimits) result.Markers = deriveEligibilityMarkers(entitlementSnapshot.IsPaid, activeSanctions) return result, nil } func entitlementSnapshotView(snapshot entitlement.CurrentSnapshot) *EntitlementSnapshotView { return &EntitlementSnapshotView{ PlanCode: string(snapshot.PlanCode), IsPaid: snapshot.IsPaid, Source: snapshot.Source.String(), Actor: actorRefView(snapshot.Actor), ReasonCode: snapshot.ReasonCode.String(), StartsAt: snapshot.StartsAt.UTC(), EndsAt: cloneOptionalTime(snapshot.EndsAt), UpdatedAt: snapshot.UpdatedAt.UTC(), } } func lobbyRelevantSanctionViews(records []policy.SanctionRecord) []ActiveSanctionView { views := make([]ActiveSanctionView, 0, len(records)) for _, record := range records { if !isLobbyRelevantSanction(record.SanctionCode) { continue } views = append(views, ActiveSanctionView{ SanctionCode: string(record.SanctionCode), Scope: record.Scope.String(), ReasonCode: record.ReasonCode.String(), Actor: actorRefView(record.Actor), AppliedAt: record.AppliedAt.UTC(), ExpiresAt: cloneOptionalTime(record.ExpiresAt), }) } return views } func materializeEffectiveLimits(isPaid bool, overrides []policy.LimitRecord) []EffectiveLimitView { overrideValues := make(map[policy.LimitCode]int, len(overrides)) for _, record := range overrides { overrideValues[record.LimitCode] = record.Value } limits := make([]EffectiveLimitView, 0, len(limitCatalog)) for _, entry := range limitCatalog { if !isPaid && !entry.freeEnabled { continue } value := entry.freeValue if isPaid { value = entry.paidValue } if override, ok := overrideValues[entry.code]; ok { value = override } limits = append(limits, EffectiveLimitView{ LimitCode: string(entry.code), Value: value, }) } return limits } func deriveEligibilityMarkers( isPaid bool, activeSanctions []policy.SanctionRecord, ) EligibilityMarkersView { loginBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodeLoginBlock) createBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodePrivateGameCreateBlock) manageBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodePrivateGameManageBlock) joinBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodeGameJoinBlock) profileBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodeProfileUpdateBlock) canLogin := !loginBlocked return EligibilityMarkersView{ CanLogin: canLogin, CanCreatePrivateGame: canLogin && isPaid && !createBlocked, CanManagePrivateGame: canLogin && isPaid && !manageBlocked, CanJoinGame: canLogin && !joinBlocked, CanUpdateProfile: canLogin && !profileBlocked, } } func hasActiveSanction(records []policy.SanctionRecord, code policy.SanctionCode) bool { for _, record := range records { if record.SanctionCode == code { return true } } return false } func isLobbyRelevantSanction(code policy.SanctionCode) bool { switch code { case policy.SanctionCodeLoginBlock, policy.SanctionCodePrivateGameCreateBlock, policy.SanctionCodePrivateGameManageBlock, policy.SanctionCodeGameJoinBlock: return true default: return false } } func actorRefView(actor common.ActorRef) ActorRefView { return ActorRefView{ Type: actor.Type.String(), ID: actor.ID.String(), } } func cloneOptionalTime(value *time.Time) *time.Time { if value == nil { return nil } cloned := value.UTC() return &cloned }