feat: game lobby service
This commit is contained in:
@@ -15,33 +15,66 @@ import (
|
||||
"galaxy/user/internal/service/shared"
|
||||
)
|
||||
|
||||
// limitCatalogEntry stores one frozen default quota for free and paid
|
||||
// entitlement states.
|
||||
// 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
|
||||
paidValue 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.
|
||||
// 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,
|
||||
paidValue: 3,
|
||||
code: policy.LimitCodeMaxOwnedPrivateGames,
|
||||
monthlyValue: 3,
|
||||
yearlyValue: 3,
|
||||
lifetimeValue: 3,
|
||||
},
|
||||
{
|
||||
code: policy.LimitCodeMaxPendingPublicApplications,
|
||||
freeValue: 3,
|
||||
paidValue: 10,
|
||||
freeEnabled: true,
|
||||
code: policy.LimitCodeMaxPendingPublicApplications,
|
||||
freeValue: 3,
|
||||
monthlyValue: 10,
|
||||
yearlyValue: 10,
|
||||
lifetimeValue: 10,
|
||||
freeEnabled: true,
|
||||
},
|
||||
{
|
||||
code: policy.LimitCodeMaxActiveGameMemberships,
|
||||
freeValue: 3,
|
||||
paidValue: 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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -268,7 +301,7 @@ func (service *SnapshotReader) Execute(
|
||||
result.Exists = true
|
||||
result.Entitlement = entitlementSnapshotView(entitlementSnapshot)
|
||||
result.ActiveSanctions = lobbyRelevantSanctionViews(activeSanctions)
|
||||
result.EffectiveLimits = materializeEffectiveLimits(entitlementSnapshot.IsPaid, activeLimits)
|
||||
result.EffectiveLimits = materializeEffectiveLimits(entitlementSnapshot.PlanCode, activeLimits)
|
||||
result.Markers = deriveEligibilityMarkers(entitlementSnapshot.IsPaid, activeSanctions)
|
||||
|
||||
return result, nil
|
||||
@@ -308,22 +341,20 @@ func lobbyRelevantSanctionViews(records []policy.SanctionRecord) []ActiveSanctio
|
||||
return views
|
||||
}
|
||||
|
||||
func materializeEffectiveLimits(isPaid bool, overrides []policy.LimitRecord) []EffectiveLimitView {
|
||||
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.freeValue
|
||||
if isPaid {
|
||||
value = entry.paidValue
|
||||
}
|
||||
value := entry.planValue(plan)
|
||||
if override, ok := overrideValues[entry.code]; ok {
|
||||
value = override
|
||||
}
|
||||
@@ -341,6 +372,10 @@ 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)
|
||||
@@ -373,7 +408,8 @@ func isLobbyRelevantSanction(code policy.SanctionCode) bool {
|
||||
case policy.SanctionCodeLoginBlock,
|
||||
policy.SanctionCodePrivateGameCreateBlock,
|
||||
policy.SanctionCodePrivateGameManageBlock,
|
||||
policy.SanctionCodeGameJoinBlock:
|
||||
policy.SanctionCodeGameJoinBlock,
|
||||
policy.SanctionCodePermanentBlock:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -95,6 +95,7 @@ func TestSnapshotReaderExecuteBuildsPaidSnapshotAndDerivedState(t *testing.T) {
|
||||
{LimitCode: "max_owned_private_games", Value: 3},
|
||||
{LimitCode: "max_pending_public_applications", Value: 10},
|
||||
{LimitCode: "max_active_game_memberships", Value: 10},
|
||||
{LimitCode: "max_registered_race_names", Value: 2},
|
||||
}, result.EffectiveLimits)
|
||||
}
|
||||
|
||||
@@ -128,6 +129,7 @@ func TestSnapshotReaderExecuteDeniesUnpaidAndLoginBlockedUsers(t *testing.T) {
|
||||
wantLimits: []EffectiveLimitView{
|
||||
{LimitCode: "max_pending_public_applications", Value: 3},
|
||||
{LimitCode: "max_active_game_memberships", Value: 3},
|
||||
{LimitCode: "max_registered_race_names", Value: 1},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -149,6 +151,7 @@ func TestSnapshotReaderExecuteDeniesUnpaidAndLoginBlockedUsers(t *testing.T) {
|
||||
{LimitCode: "max_owned_private_games", Value: 3},
|
||||
{LimitCode: "max_pending_public_applications", Value: 10},
|
||||
{LimitCode: "max_active_game_memberships", Value: 10},
|
||||
{LimitCode: "max_registered_race_names", Value: 2},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -181,6 +184,71 @@ func TestSnapshotReaderExecuteDeniesUnpaidAndLoginBlockedUsers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotReaderExecutePermanentBlockCollapsesMarkers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
snapshot entitlement.CurrentSnapshot
|
||||
sanctions []policy.SanctionRecord
|
||||
}{
|
||||
{
|
||||
name: "permanent_block alone on paid user",
|
||||
snapshot: paidEntitlementSnapshot(userID, now.Add(-24*time.Hour), now.Add(24*time.Hour)),
|
||||
sanctions: []policy.SanctionRecord{
|
||||
activeSanction(userID, policy.SanctionCodePermanentBlock, "platform", now.Add(-time.Hour)),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "permanent_block alone on free user",
|
||||
snapshot: freeEntitlementSnapshot(userID, now.Add(-24*time.Hour)),
|
||||
sanctions: []policy.SanctionRecord{
|
||||
activeSanction(userID, policy.SanctionCodePermanentBlock, "platform", now.Add(-time.Hour)),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "permanent_block dominates login_block",
|
||||
snapshot: paidEntitlementSnapshot(userID, now.Add(-24*time.Hour), now.Add(24*time.Hour)),
|
||||
sanctions: []policy.SanctionRecord{
|
||||
activeSanction(userID, policy.SanctionCodeLoginBlock, "auth", now.Add(-time.Hour)),
|
||||
activeSanction(userID, policy.SanctionCodePermanentBlock, "platform", now.Add(-30*time.Minute)),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, err := NewSnapshotReader(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
fakeEntitlementReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: tt.snapshot}},
|
||||
fakeSanctionStore{byUserID: map[common.UserID][]policy.SanctionRecord{userID: tt.sanctions}},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: now},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: userID.String()})
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.Exists)
|
||||
require.Equal(t, EligibilityMarkersView{}, result.Markers,
|
||||
"every can_* marker must be false under permanent_block")
|
||||
|
||||
gotSanctions := make([]string, 0, len(result.ActiveSanctions))
|
||||
for _, sanction := range result.ActiveSanctions {
|
||||
gotSanctions = append(gotSanctions, sanction.SanctionCode)
|
||||
}
|
||||
require.Contains(t, gotSanctions, string(policy.SanctionCodePermanentBlock),
|
||||
"permanent_block must surface in the eligibility snapshot")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotReaderExecuteRepairsExpiredPaidSnapshotWithStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -191,12 +259,6 @@ func TestSnapshotReaderExecuteRepairsExpiredPaidSnapshotWithStore(t *testing.T)
|
||||
|
||||
require.NoError(t, store.Accounts().Create(context.Background(), ports.CreateAccountInput{
|
||||
Account: accountRecord,
|
||||
Reservation: account.RaceNameReservation{
|
||||
CanonicalKey: account.RaceNameCanonicalKey("pilot nova"),
|
||||
UserID: userID,
|
||||
RaceName: accountRecord.RaceName,
|
||||
ReservedAt: accountRecord.UpdatedAt,
|
||||
},
|
||||
}))
|
||||
|
||||
expiredEndsAt := now.Add(-time.Minute)
|
||||
@@ -239,6 +301,7 @@ func TestSnapshotReaderExecuteRepairsExpiredPaidSnapshotWithStore(t *testing.T)
|
||||
require.Equal(t, []EffectiveLimitView{
|
||||
{LimitCode: "max_pending_public_applications", Value: 3},
|
||||
{LimitCode: "max_active_game_memberships", Value: 3},
|
||||
{LimitCode: "max_registered_race_names", Value: 1},
|
||||
}, result.EffectiveLimits)
|
||||
|
||||
storedSnapshot, err := store.EntitlementSnapshots().GetByUserID(context.Background(), userID)
|
||||
@@ -264,7 +327,7 @@ func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) GetByRaceName(context.Context, common.RaceName) (account.UserAccount, error) {
|
||||
func (store fakeAccountStore) GetByUserName(context.Context, common.UserName) (account.UserAccount, error) {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -276,10 +339,6 @@ func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.Us
|
||||
return store.existsByUserID[userID], nil
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) Update(context.Context, account.UserAccount) error {
|
||||
return nil
|
||||
}
|
||||
@@ -374,7 +433,7 @@ func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
func (generator fixedIDGenerator) NewUserName() (common.UserName, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -486,7 +545,7 @@ func validAccountRecord() account.UserAccount {
|
||||
return account.UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
||||
CreatedAt: createdAt,
|
||||
|
||||
Reference in New Issue
Block a user