Files
galaxy-game/user/internal/service/lobbyeligibility/service.go
T
2026-04-25 23:20:55 +02:00

434 lines
13 KiB
Go

// 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 the frozen default quota for every tariff plan
// plus the toggle that decides whether a `free` default is materialized at
// all.
type limitCatalogEntry struct {
code policy.LimitCode
freeValue int
monthlyValue int
yearlyValue int
lifetimeValue int
freeEnabled bool
}
// planValue returns the frozen default quota for plan.
func (entry limitCatalogEntry) planValue(plan entitlement.PlanCode) int {
switch plan {
case entitlement.PlanCodePaidMonthly:
return entry.monthlyValue
case entitlement.PlanCodePaidYearly:
return entry.yearlyValue
case entitlement.PlanCodePaidLifetime:
return entry.lifetimeValue
default:
return entry.freeValue
}
}
// limitCatalog stores the frozen lobby-facing effective limit defaults used
// to materialize numeric quotas from the current entitlement state. Paid
// plans share the same default unless stated otherwise; per-plan values
// diverge only for `max_registered_race_names`.
var limitCatalog = []limitCatalogEntry{
{
code: policy.LimitCodeMaxOwnedPrivateGames,
monthlyValue: 3,
yearlyValue: 3,
lifetimeValue: 3,
},
{
code: policy.LimitCodeMaxPendingPublicApplications,
freeValue: 3,
monthlyValue: 10,
yearlyValue: 10,
lifetimeValue: 10,
freeEnabled: true,
},
{
code: policy.LimitCodeMaxActiveGameMemberships,
freeValue: 3,
monthlyValue: 10,
yearlyValue: 10,
lifetimeValue: 10,
freeEnabled: true,
},
{
code: policy.LimitCodeMaxRegisteredRaceNames,
freeValue: 1,
monthlyValue: 2,
yearlyValue: 6,
lifetimeValue: 0,
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.PlanCode, 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(plan entitlement.PlanCode, overrides []policy.LimitRecord) []EffectiveLimitView {
overrideValues := make(map[policy.LimitCode]int, len(overrides))
for _, record := range overrides {
overrideValues[record.LimitCode] = record.Value
}
isPaid := plan.IsPaid()
limits := make([]EffectiveLimitView, 0, len(limitCatalog))
for _, entry := range limitCatalog {
if !isPaid && !entry.freeEnabled {
continue
}
value := entry.planValue(plan)
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 {
if hasActiveSanction(activeSanctions, policy.SanctionCodePermanentBlock) {
return 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,
policy.SanctionCodePermanentBlock:
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
}