feat(lucky-gift): restore dual jackpot strategies
This commit is contained in:
parent
506d49de9c
commit
c5acecca49
@ -518,7 +518,7 @@ type LuckyGiftRuleConfig struct {
|
||||
JackpotGlobalRtpMaxPpm int64 `protobuf:"varint,28,opt,name=jackpot_global_rtp_max_ppm,json=jackpotGlobalRtpMaxPpm,proto3" json:"jackpot_global_rtp_max_ppm,omitempty"`
|
||||
JackpotUserRoundRtpMaxPpm int64 `protobuf:"varint,29,opt,name=jackpot_user_round_rtp_max_ppm,json=jackpotUserRoundRtpMaxPpm,proto3" json:"jackpot_user_round_rtp_max_ppm,omitempty"`
|
||||
JackpotUser_48HRtpMaxPpm int64 `protobuf:"varint,30,opt,name=jackpot_user_48h_rtp_max_ppm,json=jackpotUser48hRtpMaxPpm,proto3" json:"jackpot_user_48h_rtp_max_ppm,omitempty"`
|
||||
// 历史消费门槛字段只用于旧配置回读;dynamic_v3 当前大奖资格不再消费该值。
|
||||
// 用户在同一应用、奖池和 UTC 自然日每累计消费满该金币数,生成一次可跨日保留的消费大奖资格。
|
||||
JackpotSpendThresholdCoins int64 `protobuf:"varint,31,opt,name=jackpot_spend_threshold_coins,json=jackpotSpendThresholdCoins,proto3" json:"jackpot_spend_threshold_coins,omitempty"`
|
||||
MaxJackpotHitsPerUserDay int64 `protobuf:"varint,32,opt,name=max_jackpot_hits_per_user_day,json=maxJackpotHitsPerUserDay,proto3" json:"max_jackpot_hits_per_user_day,omitempty"`
|
||||
MaxSinglePayout int64 `protobuf:"varint,33,opt,name=max_single_payout,json=maxSinglePayout,proto3" json:"max_single_payout,omitempty"`
|
||||
|
||||
@ -96,7 +96,7 @@ message LuckyGiftRuleConfig {
|
||||
int64 jackpot_global_rtp_max_ppm = 28;
|
||||
int64 jackpot_user_round_rtp_max_ppm = 29;
|
||||
int64 jackpot_user_48h_rtp_max_ppm = 30;
|
||||
// 历史消费门槛字段只用于旧配置回读;dynamic_v3 当前大奖资格不再消费该值。
|
||||
// 用户在同一应用、奖池和 UTC 自然日每累计消费满该金币数,生成一次可跨日保留的消费大奖资格。
|
||||
int64 jackpot_spend_threshold_coins = 31;
|
||||
int64 max_jackpot_hits_per_user_day = 32;
|
||||
int64 max_single_payout = 33;
|
||||
|
||||
@ -103,6 +103,7 @@ func runSimulation(options simulationOptions) simulationReport {
|
||||
simulateSixRiskCapacities(config),
|
||||
simulateRechargeStages(config),
|
||||
simulateCombinationPriority(config, options.Seed+70),
|
||||
simulateDualJackpotSpendStrategy(config, options.Seed+75),
|
||||
simulateConcurrencyAndIdempotency(config, options.Seed+80),
|
||||
simulateLongRunGroups(config, options.Seed+90, options.GroupLongRunN),
|
||||
}
|
||||
|
||||
@ -938,6 +938,10 @@ func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scena
|
||||
config.HighWaterFactorPPM = 1_300_000
|
||||
config.RechargeFactorPPM = 1_100_000
|
||||
state := qualifiedSettledRoundState(10_000, 100*60*60*1000)
|
||||
config.JackpotMechanism2Enabled = true
|
||||
config.MilestoneSpendCoins = 50
|
||||
state.PendingSpendJackpotTokens = 1
|
||||
state.SpendGlobalRTP = domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_800}
|
||||
state.ConsecutiveZeroDraws = 5
|
||||
input := domain.StrategyInput{
|
||||
GiftPriceCoins: 10, NowMS: 1_000_000, HasRechargeFact: true,
|
||||
@ -947,8 +951,8 @@ func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scena
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
// 已结算轮次资格在普通概率、第6抽保底和动态因子之前尝试;独立消费里程碑机制已经停用。
|
||||
priorityPass := priority.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation && !priority.NextState.SettledRoundPending && priority.Stage == domain.StageAdvanced
|
||||
// 两套资格同时存在时,48h 补偿先支付,消费 token 保留到下一个子抽。
|
||||
priorityPass := priority.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation && !priority.NextState.SettledRoundPending && priority.NextState.PendingSpendJackpotTokens == 1 && priority.Stage == domain.StageAdvanced
|
||||
|
||||
blockedState := state
|
||||
blockedState.PoolBalanceCoins = 50
|
||||
@ -956,15 +960,118 @@ func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scena
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
// 大奖资格不能透支奖池;该轮资格被消费后,同一抽仍可按普通第6抽保底支付恰好可负担的5x。
|
||||
blockedPass := !blocked.NextState.SettledRoundPending && blocked.JackpotMechanism == "" && blocked.PayoutCoins == 50 && blocked.PoolAfterCoins == 0
|
||||
// 大奖资格不能透支奖池;48h 资格本轮结束,消费 token 则等待后续补水。
|
||||
blockedPass := !blocked.NextState.SettledRoundPending && blocked.NextState.PendingSpendJackpotTokens == 1 && blocked.JackpotMechanism == "" && blocked.PayoutCoins == 50 && blocked.PoolAfterCoins == 0
|
||||
return scenarioResult{
|
||||
Name: name, Passed: priorityPass && blockedPass,
|
||||
Summary: fmt.Sprintf("优先级=已结算轮次资格>普通P/W/第6抽;大奖池不足时资格消费,本抽仅赔可支付%s", blocked.SelectedTier.ID),
|
||||
Summary: fmt.Sprintf("优先级=48h补偿>消费达标>普通P/W;48h成功不扣消费token,池不足时消费token保留,本抽仅赔%s", blocked.SelectedTier.ID),
|
||||
Data: map[string]any{"all_eligible": priority, "special_pool_blocked": blocked},
|
||||
}
|
||||
}
|
||||
|
||||
func simulateDualJackpotSpendStrategy(config domain.StrategyConfig, seed int64) scenarioResult {
|
||||
const name = "双大奖消费资格矩阵"
|
||||
config = settledRoundJackpotConfig(config)
|
||||
config.JackpotMechanism2Enabled = true
|
||||
config.MilestoneSpendCoins = 50
|
||||
input := domain.StrategyInput{GiftPriceCoins: 10}
|
||||
base := domain.StrategyState{
|
||||
PoolBalanceCoins: 10_000, PendingSpendJackpotTokens: 1,
|
||||
SpendGlobalRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_800},
|
||||
}
|
||||
type row struct {
|
||||
Case string `json:"case"`
|
||||
GlobalWager int64 `json:"global_wager"`
|
||||
GlobalPayout int64 `json:"global_payout"`
|
||||
TokensBefore int64 `json:"tokens_before"`
|
||||
TokensAfter int64 `json:"tokens_after"`
|
||||
Mechanism string `json:"mechanism"`
|
||||
Jackpot bool `json:"jackpot"`
|
||||
Payout int64 `json:"payout"`
|
||||
PoolAfter int64 `json:"pool_after"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
rows := make([]row, 0, 8)
|
||||
run := func(caseName string, state domain.StrategyState, drawInput domain.StrategyInput, offset int64) (domain.StrategyDecision, error) {
|
||||
decision, err := domain.DecideLuckyGiftStrategy(config, state, drawInput, domain.NewSeededStrategyRandom(seed+offset))
|
||||
if err == nil {
|
||||
rows = append(rows, row{
|
||||
Case: caseName, GlobalWager: state.SpendGlobalRTP.WagerCoins, GlobalPayout: state.SpendGlobalRTP.PayoutCoins,
|
||||
TokensBefore: state.PendingSpendJackpotTokens, TokensAfter: decision.NextState.PendingSpendJackpotTokens,
|
||||
Mechanism: decision.JackpotMechanism, Jackpot: decision.Jackpot, Payout: decision.PayoutCoins,
|
||||
PoolAfter: decision.PoolAfterCoins, Reason: decision.Trace.BlockedReason,
|
||||
})
|
||||
}
|
||||
return decision, err
|
||||
}
|
||||
|
||||
equal, err := run("大盘等于98%", base, input, 0)
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
aboveState := base
|
||||
aboveState.SpendGlobalRTP.PayoutCoins = 9_801
|
||||
above, err := run("大盘高于98%", aboveState, input, 1)
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
noSampleState := base
|
||||
noSampleState.SpendGlobalRTP = domain.StrategyRTP{}
|
||||
noSample, err := run("无已结算大盘", noSampleState, input, 2)
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
|
||||
// 40->150 crosses 50/100/150 in one paid child draw. A scripted zero proves those three new
|
||||
// tokens cannot influence the current selection; the following child draw consumes only one.
|
||||
crossState := base
|
||||
crossState.PendingSpendJackpotTokens = 0
|
||||
crossState.UserDaySpendCoins = 40
|
||||
crossInput := domain.StrategyInput{GiftPriceCoins: 110}
|
||||
crossed, err := domain.DecideLuckyGiftStrategy(config, crossState, crossInput, &scriptedRandom{indexes: []int64{0}, bounds: []int64{1_000_000}})
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
rows = append(rows, row{Case: "当前子抽跨三档", GlobalWager: crossState.SpendGlobalRTP.WagerCoins, GlobalPayout: crossState.SpendGlobalRTP.PayoutCoins, TokensBefore: 0, TokensAfter: crossed.NextState.PendingSpendJackpotTokens, Mechanism: crossed.JackpotMechanism, Jackpot: crossed.Jackpot, Payout: crossed.PayoutCoins, PoolAfter: crossed.PoolAfterCoins, Reason: crossed.Trace.BlockedReason})
|
||||
next, err := run("下一子抽消费一档", crossed.NextState, input, 3)
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
|
||||
qualified := qualifiedSettledRoundState(10_000, 100*60*60*1000)
|
||||
qualified.PendingSpendJackpotTokens = 1
|
||||
qualified.SpendGlobalRTP = base.SpendGlobalRTP
|
||||
priority, err := run("双资格48h优先", qualified, input, 4)
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
qualified.UserRoundRTP.PayoutCoins = 9_600
|
||||
fallback, err := run("48h不合格消费接续", qualified, input, 5)
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
poolBlockedState := base
|
||||
poolBlockedState.PoolBalanceCoins = 49
|
||||
poolBlocked, err := run("奖池不足资格保留", poolBlockedState, input, 6)
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
|
||||
passed := equal.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && equal.NextState.PendingSpendJackpotTokens == 0 &&
|
||||
!above.Jackpot && above.NextState.PendingSpendJackpotTokens == 1 &&
|
||||
!noSample.Jackpot && noSample.NextState.PendingSpendJackpotTokens == 1 &&
|
||||
!crossed.Jackpot && crossed.NextState.PendingSpendJackpotTokens == 3 &&
|
||||
next.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && next.NextState.PendingSpendJackpotTokens == 2 &&
|
||||
priority.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation && priority.NextState.PendingSpendJackpotTokens == 1 &&
|
||||
fallback.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && fallback.NextState.PendingSpendJackpotTokens == 0 &&
|
||||
!poolBlocked.Jackpot && poolBlocked.NextState.PendingSpendJackpotTokens == 1
|
||||
return scenarioResult{
|
||||
Name: name, Passed: passed,
|
||||
Summary: "验证大盘<=边界、无样本关闭、每档累计、下一子抽生效、48h优先/接续和资金不足跨轮保留",
|
||||
Data: rows,
|
||||
}
|
||||
}
|
||||
|
||||
// simulationStore models the repository contract that the pure kernel expects:
|
||||
// command idempotency and state locking wrap the draw in one critical section. The
|
||||
// simulator does not claim an in-memory mutex is the production implementation; it
|
||||
|
||||
@ -280,7 +280,8 @@ CREATE TABLE IF NOT EXISTS lucky_user_states (
|
||||
cumulative_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '该用户在当前奖池累计消耗金币',
|
||||
equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT '累计金币按规则参考价格折算后的等价抽数',
|
||||
loss_streak BIGINT NOT NULL DEFAULT 0 COMMENT '连续未获得可见奖励次数,中奖后清零',
|
||||
pending_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '未消费大奖机会;按UTC日消费产生,资金不足时允许跨日保留',
|
||||
pending_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '旧版消费资格;新版运行时仅惰性清零,永不参与开奖',
|
||||
pending_spend_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '新版累计消费大奖资格;受阻时跨轮次和UTC日保留,成功开奖仅扣1',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, user_id, gift_id)
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_lucky_gift;
|
||||
|
||||
-- 性能边界:lucky_user_states 是热状态表,本迁移只添加一个常量默认列并强制
|
||||
-- ALGORITHM=INSTANT。若目标 MySQL 不支持即时 DDL 就直接失败,绝不退化成 COPY;
|
||||
-- 上线前仍需检查长事务,避免短暂 MDL 等待。迁移不扫描、不更新历史用户行。
|
||||
-- 历史 pending_jackpot_tokens 保持原值,业务在该用户下一次付费抽奖事务内按主键惰性清零,
|
||||
-- 且从不把旧值复制到新列,因此停用期间的资格和消费都不会被追溯补发。
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_user_states'
|
||||
AND COLUMN_NAME = 'pending_spend_jackpot_tokens') = 0,
|
||||
'ALTER TABLE lucky_user_states ADD COLUMN pending_spend_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT ''新版累计消费大奖资格;受阻时跨轮次和UTC日保留,成功开奖仅扣1'' AFTER pending_jackpot_tokens, ALGORITHM=INSTANT',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
@ -26,7 +26,8 @@ const (
|
||||
StrategyReasonPoolInsufficient = "pool_insufficient_w_gt_p"
|
||||
StrategyReasonRiskCapacity = "risk_capacity_exceeded"
|
||||
StrategyReasonNoPayableJackpot = "no_payable_jackpot_candidate"
|
||||
StrategyJackpotMechanismMilestone = "milestone_token"
|
||||
StrategyReasonSpendGlobalRTPBlocked = "spend_global_rtp_gate_failed"
|
||||
StrategyJackpotMechanismMilestone = "spend_milestone"
|
||||
StrategyJackpotMechanismRTPCompensation = "rtp_compensation"
|
||||
StrategyDrawKindOriginal = "original"
|
||||
StrategyDrawKindRedraw = "redraw"
|
||||
@ -35,6 +36,8 @@ const (
|
||||
StrategyRemovalMissProtection = "miss_protection_excludes_zero"
|
||||
StrategyRemovalPWRedrawZero = "w_gt_p_redraw_excludes_zero"
|
||||
StrategyConditionGlobalRTP = "global_rtp"
|
||||
StrategyConditionSpendGlobalRTP = "spend_global_rtp"
|
||||
StrategyConditionSpendDailyJackpotLimit = "spend_daily_jackpot_limit"
|
||||
StrategyConditionSettledRound = "settled_round_qualification"
|
||||
StrategyConditionUserRoundRTP = "user_round_rtp"
|
||||
StrategyConditionUser48HourRTP = "user_48h_rtp"
|
||||
@ -152,11 +155,18 @@ type StrategyRTP struct {
|
||||
// must update it atomically with the draw record; Version is provided for CAS/optimistic
|
||||
// locking but the kernel itself intentionally performs no I/O.
|
||||
type StrategyState struct {
|
||||
PoolBalanceCoins int64 `json:"pool_balance_coins"`
|
||||
ConsecutiveZeroDraws int64 `json:"consecutive_zero_draws"`
|
||||
PendingMilestoneTokens int64 `json:"pending_milestone_tokens"`
|
||||
UserDailyJackpotWins int64 `json:"user_daily_jackpot_wins"`
|
||||
UserDaySpendCoins int64 `json:"user_day_spend_coins"`
|
||||
PoolBalanceCoins int64 `json:"pool_balance_coins"`
|
||||
ConsecutiveZeroDraws int64 `json:"consecutive_zero_draws"`
|
||||
// PendingMilestoneTokens is the legacy pure-kernel field. Production dynamic_v3 never reads it:
|
||||
// the separate field below prevents tokens created by the retired implementation from becoming
|
||||
// a jackpot promise after the dual-strategy rollout.
|
||||
PendingMilestoneTokens int64 `json:"pending_milestone_tokens"`
|
||||
PendingSpendJackpotTokens int64 `json:"pending_spend_jackpot_tokens"`
|
||||
UserDailyJackpotWins int64 `json:"user_daily_jackpot_wins"`
|
||||
UserDaySpendCoins int64 `json:"user_day_spend_coins"`
|
||||
// SpendGlobalRTP is the latest fully closed platform window observed before this subdraw. It is
|
||||
// deliberately independent from GlobalRTP, which is frozen with a user's one-shot 48h qualification.
|
||||
SpendGlobalRTP StrategyRTP `json:"spend_global_rtp"`
|
||||
GlobalRTP StrategyRTP `json:"global_rtp"`
|
||||
UserRoundRTP StrategyRTP `json:"user_round_rtp"`
|
||||
User48HourRTP StrategyRTP `json:"user_48h_rtp"`
|
||||
@ -255,9 +265,14 @@ type DecisionTrace struct {
|
||||
FinalReason string `json:"final_reason"`
|
||||
BlockedReason string `json:"blocked_reason,omitempty"`
|
||||
JackpotMechanism string `json:"jackpot_mechanism,omitempty"`
|
||||
MilestoneTokenConsumed bool `json:"milestone_token_consumed"`
|
||||
MilestoneTokenRetained bool `json:"milestone_token_retained"`
|
||||
MilestoneTokensEarned int64 `json:"milestone_tokens_earned"`
|
||||
MilestoneTokenConsumed bool `json:"spend_milestone_token_consumed"`
|
||||
MilestoneTokenRetained bool `json:"spend_milestone_token_retained"`
|
||||
MilestoneTokensEarned int64 `json:"spend_milestone_tokens_earned"`
|
||||
// The exact before/after balance is persisted for every draw, including a successful 48h
|
||||
// compensation that deliberately leaves an existing spend token untouched. The boolean fields
|
||||
// above keep their event meaning; these counters make multi-token incident replay unambiguous.
|
||||
SpendMilestoneTokensBefore int64 `json:"pending_spend_jackpot_tokens_before"`
|
||||
SpendMilestoneTokensAfter int64 `json:"pending_spend_jackpot_tokens_after"`
|
||||
}
|
||||
|
||||
type StrategyDecision struct {
|
||||
@ -401,10 +416,10 @@ func PreviewLuckyGiftStrategyWeights(config StrategyConfig, state StrategyState,
|
||||
return stage, traces, err
|
||||
}
|
||||
|
||||
// DecideLuckyGiftStrategy is the shared production/simulation kernel. Its ordering is
|
||||
// intentional and traceable: persisted mechanism-2 token -> mechanism-1 RTP gates ->
|
||||
// ordinary weighted P/W draw and redraw -> state transition. A blocked special
|
||||
// mechanism falls through to an ordinary draw while preserving its token.
|
||||
// DecideLuckyGiftStrategy is the shared production/simulation kernel. Production V3 evaluates the
|
||||
// one-shot closed-round/48h qualification first, then the independent durable spend milestone, and
|
||||
// finally the ordinary weighted draw. A spend token is consumed only by a paid spend jackpot; all
|
||||
// failed global-RTP, daily-limit, pool and risk checks leave it available for a later paid draw.
|
||||
func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input StrategyInput, random StrategyRandomSource) (StrategyDecision, error) {
|
||||
if random == nil {
|
||||
return StrategyDecision{}, ErrStrategyRandomSource
|
||||
@ -425,48 +440,41 @@ func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input S
|
||||
Stage: stage, PoolBeforeCoins: state.PoolBalanceCoins,
|
||||
PoolContributionCoins: input.PoolContributionCoins, AvailablePoolCoins: available,
|
||||
EffectiveRiskCapacityCoins: input.RiskCapacity.capacity(), Weights: weightTrace,
|
||||
SpendMilestoneTokensBefore: pendingSpendJackpotTokens(config, state),
|
||||
SpendMilestoneTokensAfter: pendingSpendJackpotTokens(config, state),
|
||||
}
|
||||
|
||||
// A persisted milestone token is a promise about the next draw, so it outranks the
|
||||
// opportunistic RTP compensation mechanism. Insufficient pool/risk capacity keeps
|
||||
// the token durable and lets the user retain the ordinary draw they paid for.
|
||||
if config.JackpotMechanism2Enabled && state.PendingMilestoneTokens > 0 {
|
||||
tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random)
|
||||
if config.SettledRoundRTPEligibility {
|
||||
decision, decided, err := tryRTPCompensationJackpot(config, state, input, available, random, &trace)
|
||||
if err != nil {
|
||||
return StrategyDecision{}, err
|
||||
}
|
||||
trace.Removed = append(trace.Removed, removed...)
|
||||
if ok {
|
||||
trace.JackpotMechanism = StrategyJackpotMechanismMilestone
|
||||
trace.MilestoneTokenConsumed = true
|
||||
trace.FinalReason = StrategyReasonMilestoneJackpot
|
||||
trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindMilestone, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout})
|
||||
return finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismMilestone, true, trace)
|
||||
if decided {
|
||||
return decision, nil
|
||||
}
|
||||
trace.MilestoneTokenRetained = true
|
||||
trace.BlockedReason = reason
|
||||
trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: false, Reason: reason})
|
||||
}
|
||||
|
||||
if config.JackpotMechanism1Enabled {
|
||||
conditions, eligible := mechanismOneConditions(config, state)
|
||||
trace.Conditions = append(trace.Conditions, conditions...)
|
||||
if eligible {
|
||||
tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random)
|
||||
if err != nil {
|
||||
return StrategyDecision{}, err
|
||||
}
|
||||
trace.Removed = append(trace.Removed, removed...)
|
||||
trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: ok, Reason: reason})
|
||||
if ok {
|
||||
trace.JackpotMechanism = StrategyJackpotMechanismRTPCompensation
|
||||
trace.FinalReason = StrategyReasonRTPCompensationJackpot
|
||||
trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindRTPCompensation, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout})
|
||||
return finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismRTPCompensation, false, trace)
|
||||
}
|
||||
if trace.BlockedReason == "" {
|
||||
trace.BlockedReason = reason
|
||||
}
|
||||
decision, decided, err = trySpendMilestoneJackpot(config, state, input, available, random, true, &trace)
|
||||
if err != nil {
|
||||
return StrategyDecision{}, err
|
||||
}
|
||||
if decided {
|
||||
return decision, nil
|
||||
}
|
||||
} else {
|
||||
// The isolated legacy kernel keeps its historical order and gate shape. Runtime dynamic_v3
|
||||
// always takes the branch above and therefore never reads PendingMilestoneTokens.
|
||||
decision, decided, err := trySpendMilestoneJackpot(config, state, input, available, random, false, &trace)
|
||||
if err != nil {
|
||||
return StrategyDecision{}, err
|
||||
}
|
||||
if decided {
|
||||
return decision, nil
|
||||
}
|
||||
decision, decided, err = tryRTPCompensationJackpot(config, state, input, available, random, &trace)
|
||||
if err != nil {
|
||||
return StrategyDecision{}, err
|
||||
}
|
||||
if decided {
|
||||
return decision, nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -485,11 +493,91 @@ func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input S
|
||||
return finalizeStrategyDecision(config, state, input, available, tier, payout, "", false, trace)
|
||||
}
|
||||
|
||||
func tryRTPCompensationJackpot(config StrategyConfig, state StrategyState, input StrategyInput, available int64, random StrategyRandomSource, trace *DecisionTrace) (StrategyDecision, bool, error) {
|
||||
if !config.JackpotMechanism1Enabled {
|
||||
return StrategyDecision{}, false, nil
|
||||
}
|
||||
conditions, eligible := mechanismOneConditions(config, state)
|
||||
trace.Conditions = append(trace.Conditions, conditions...)
|
||||
if !eligible {
|
||||
return StrategyDecision{}, false, nil
|
||||
}
|
||||
tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random)
|
||||
if err != nil {
|
||||
return StrategyDecision{}, false, err
|
||||
}
|
||||
trace.Removed = append(trace.Removed, removed...)
|
||||
trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: ok, Reason: reason})
|
||||
if !ok {
|
||||
if trace.BlockedReason == "" {
|
||||
trace.BlockedReason = reason
|
||||
}
|
||||
return StrategyDecision{}, false, nil
|
||||
}
|
||||
trace.JackpotMechanism = StrategyJackpotMechanismRTPCompensation
|
||||
trace.FinalReason = StrategyReasonRTPCompensationJackpot
|
||||
trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindRTPCompensation, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout})
|
||||
decision, err := finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismRTPCompensation, false, *trace)
|
||||
return decision, err == nil, err
|
||||
}
|
||||
|
||||
func trySpendMilestoneJackpot(config StrategyConfig, state StrategyState, input StrategyInput, available int64, random StrategyRandomSource, requireSettledGlobal bool, trace *DecisionTrace) (StrategyDecision, bool, error) {
|
||||
if !config.JackpotMechanism2Enabled || pendingSpendJackpotTokens(config, state) <= 0 {
|
||||
return StrategyDecision{}, false, nil
|
||||
}
|
||||
if requireSettledGlobal {
|
||||
ratio, valid := state.SpendGlobalRTP.RatioPPM()
|
||||
globalPassed := valid && rtpAtOrBelowExact(state.SpendGlobalRTP, config.GlobalRTPMaxPPM)
|
||||
trace.Conditions = append(trace.Conditions, rtpAtOrBelowConditionTrace(StrategyConditionSpendGlobalRTP, state.SpendGlobalRTP, ratio, config.GlobalRTPMaxPPM, valid, globalPassed))
|
||||
dailyPassed := state.UserDailyJackpotWins < config.DailyJackpotLimit
|
||||
trace.Conditions = append(trace.Conditions, StrategyConditionTrace{
|
||||
Name: StrategyConditionSpendDailyJackpotLimit, Numerator: state.UserDailyJackpotWins, LimitPPM: config.DailyJackpotLimit,
|
||||
Passed: dailyPassed, Reason: boolReason(dailyPassed, "below_limit", StrategyReasonDailyJackpotLimit),
|
||||
})
|
||||
if !globalPassed || !dailyPassed {
|
||||
trace.MilestoneTokenRetained = true
|
||||
if trace.BlockedReason == "" {
|
||||
trace.BlockedReason = StrategyReasonSpendGlobalRTPBlocked
|
||||
if globalPassed {
|
||||
trace.BlockedReason = StrategyReasonDailyJackpotLimit
|
||||
}
|
||||
}
|
||||
return StrategyDecision{}, false, nil
|
||||
}
|
||||
}
|
||||
tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random)
|
||||
if err != nil {
|
||||
return StrategyDecision{}, false, err
|
||||
}
|
||||
trace.Removed = append(trace.Removed, removed...)
|
||||
trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: ok, Reason: reason})
|
||||
if !ok {
|
||||
trace.MilestoneTokenRetained = true
|
||||
if trace.BlockedReason == "" {
|
||||
trace.BlockedReason = reason
|
||||
}
|
||||
return StrategyDecision{}, false, nil
|
||||
}
|
||||
trace.JackpotMechanism = StrategyJackpotMechanismMilestone
|
||||
trace.MilestoneTokenConsumed = true
|
||||
trace.FinalReason = StrategyReasonMilestoneJackpot
|
||||
trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindMilestone, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout})
|
||||
decision, err := finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismMilestone, true, *trace)
|
||||
return decision, err == nil, err
|
||||
}
|
||||
|
||||
func pendingSpendJackpotTokens(config StrategyConfig, state StrategyState) int64 {
|
||||
if config.SettledRoundRTPEligibility {
|
||||
return state.PendingSpendJackpotTokens
|
||||
}
|
||||
return state.PendingMilestoneTokens
|
||||
}
|
||||
|
||||
func validateStrategy(config StrategyConfig, state StrategyState, input StrategyInput, requireTiers bool) error {
|
||||
if state.PoolBalanceCoins < 0 || state.ConsecutiveZeroDraws < 0 || state.PendingMilestoneTokens < 0 || state.UserDailyJackpotWins < 0 || state.UserDaySpendCoins < 0 || state.Version < 0 {
|
||||
if state.PoolBalanceCoins < 0 || state.ConsecutiveZeroDraws < 0 || state.PendingMilestoneTokens < 0 || state.PendingSpendJackpotTokens < 0 || state.UserDailyJackpotWins < 0 || state.UserDaySpendCoins < 0 || state.Version < 0 {
|
||||
return fmt.Errorf("%w: state counters and money cannot be negative", ErrStrategyInput)
|
||||
}
|
||||
for _, rtp := range []StrategyRTP{state.GlobalRTP, state.UserRoundRTP, state.User48HourRTP, state.UserDayRTP, state.User72HourRTP} {
|
||||
for _, rtp := range []StrategyRTP{state.SpendGlobalRTP, state.GlobalRTP, state.UserRoundRTP, state.User48HourRTP, state.UserDayRTP, state.User72HourRTP} {
|
||||
if rtp.WagerCoins < 0 || rtp.PayoutCoins < 0 {
|
||||
return fmt.Errorf("%w: RTP numerator and denominator cannot be negative", ErrStrategyInput)
|
||||
}
|
||||
@ -748,6 +836,16 @@ func rtpConditionTrace(name string, rtp StrategyRTP, ratio, limit int64, valid,
|
||||
return StrategyConditionTrace{Name: name, Numerator: rtp.PayoutCoins, Denominator: rtp.WagerCoins, RatioPPM: ratio, LimitPPM: limit, Passed: passed, Reason: reason}
|
||||
}
|
||||
|
||||
func rtpAtOrBelowConditionTrace(name string, rtp StrategyRTP, ratio, limit int64, valid, passed bool) StrategyConditionTrace {
|
||||
reason := "within_limit"
|
||||
if !valid {
|
||||
reason = "denominator_zero"
|
||||
} else if !passed {
|
||||
reason = "above_limit"
|
||||
}
|
||||
return StrategyConditionTrace{Name: name, Numerator: rtp.PayoutCoins, Denominator: rtp.WagerCoins, RatioPPM: ratio, LimitPPM: limit, Passed: passed, Reason: reason}
|
||||
}
|
||||
|
||||
func rtpBelowExact(rtp StrategyRTP, limitPPM int64) bool {
|
||||
return compareRTPExact(rtp, limitPPM) < 0
|
||||
}
|
||||
@ -908,10 +1006,14 @@ func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input
|
||||
next.ConsecutiveZeroDraws = 0
|
||||
}
|
||||
if consumeToken {
|
||||
if next.PendingMilestoneTokens <= 0 {
|
||||
if pendingSpendJackpotTokens(config, next) <= 0 {
|
||||
return StrategyDecision{}, fmt.Errorf("%w: cannot consume absent milestone token", ErrStrategyInput)
|
||||
}
|
||||
next.PendingMilestoneTokens--
|
||||
if config.SettledRoundRTPEligibility {
|
||||
next.PendingSpendJackpotTokens--
|
||||
} else {
|
||||
next.PendingMilestoneTokens--
|
||||
}
|
||||
}
|
||||
jackpot := payout > 0 && tier.Jackpot
|
||||
if jackpot {
|
||||
@ -922,21 +1024,28 @@ func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input
|
||||
if next.UserDaySpendCoins, err = safeAdd(next.UserDaySpendCoins, input.GiftPriceCoins); err != nil {
|
||||
return StrategyDecision{}, err
|
||||
}
|
||||
// A threshold crossed by this draw is persisted after selection, so the newly
|
||||
// earned token can affect only the next locked draw and never the current one. Production V3's
|
||||
// settled-round mode has no independent spend-token qualification, so it must neither earn nor
|
||||
// carry a hidden third jackpot condition even if an old rule row still contains the legacy value.
|
||||
// A threshold crossed by this draw is persisted after selection, so the newly earned token can
|
||||
// affect only the next locked subdraw and never the current one. Production V3 stores it in the
|
||||
// dedicated field; the retired legacy token is neither read nor migrated into the new strategy.
|
||||
earnedTokens := int64(0)
|
||||
if !config.SettledRoundRTPEligibility {
|
||||
if config.JackpotMechanism2Enabled {
|
||||
earnedTokens = MilestoneTokensEarned(previousDaySpend, next.UserDaySpendCoins, config.MilestoneSpendCoins)
|
||||
}
|
||||
if earnedTokens > 0 {
|
||||
next.PendingMilestoneTokens, err = safeAdd(next.PendingMilestoneTokens, earnedTokens)
|
||||
if config.SettledRoundRTPEligibility {
|
||||
next.PendingSpendJackpotTokens, err = safeAdd(next.PendingSpendJackpotTokens, earnedTokens)
|
||||
} else {
|
||||
next.PendingMilestoneTokens, err = safeAdd(next.PendingMilestoneTokens, earnedTokens)
|
||||
}
|
||||
if err != nil {
|
||||
return StrategyDecision{}, err
|
||||
}
|
||||
trace.MilestoneTokensEarned = earnedTokens
|
||||
}
|
||||
// Set the final balance after both the optional consumption and threshold-crossing accrual.
|
||||
// This assignment runs for ordinary, 48h and spend-jackpot decisions alike, so a 48h win with
|
||||
// one pending spend promise is durably recorded as 1 -> 1 instead of looking like no promise existed.
|
||||
trace.SpendMilestoneTokensAfter = pendingSpendJackpotTokens(config, next)
|
||||
if !config.SettledRoundRTPEligibility {
|
||||
if next.GlobalRTP, err = next.GlobalRTP.add(input.GiftPriceCoins, payout); err != nil {
|
||||
return StrategyDecision{}, err
|
||||
|
||||
@ -294,6 +294,179 @@ func TestLuckyGiftStrategyMilestoneTokenRetainedThenConsumed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuckyGiftStrategyDualJackpotSpendGlobalGateAndDurability(t *testing.T) {
|
||||
config := strategyTestConfig()
|
||||
config.SettledRoundRTPEligibility = true
|
||||
config.JackpotMechanism1Enabled = true
|
||||
config.JackpotMechanism2Enabled = true
|
||||
config.MilestoneSpendCoins = 50
|
||||
config.GlobalRTPMaxPPM = 980_000
|
||||
config.UserRoundRTPMaxPPM = 960_000
|
||||
config.User48HourRTPMaxPPM = 960_000
|
||||
config.DailyJackpotLimit = 5
|
||||
|
||||
base := StrategyState{PoolBalanceCoins: 10_000, PendingSpendJackpotTokens: 1, SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98}}
|
||||
paid, err := DecideLuckyGiftStrategy(config, base, strategyTestInput(10), NewSeededStrategyRandom(80))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if paid.JackpotMechanism != StrategyJackpotMechanismMilestone || !paid.ConsumedMilestoneToken || paid.NextState.PendingSpendJackpotTokens != 0 ||
|
||||
paid.Trace.SpendMilestoneTokensBefore != 1 || paid.Trace.SpendMilestoneTokensAfter != 0 {
|
||||
t.Fatalf("global equality should pay exactly one spend token: %+v", paid)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
rtp StrategyRTP
|
||||
}{
|
||||
{name: "no closed platform sample", rtp: StrategyRTP{}},
|
||||
{name: "platform above configured maximum", rtp: StrategyRTP{WagerCoins: 100, PayoutCoins: 99}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
state := base
|
||||
state.SpendGlobalRTP = test.rtp
|
||||
blocked, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(81))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if blocked.JackpotMechanism != "" || blocked.NextState.PendingSpendJackpotTokens != 1 || !blocked.Trace.MilestoneTokenRetained {
|
||||
t.Fatalf("blocked spend token must remain durable: %+v", blocked)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
capState := base
|
||||
capState.UserDailyJackpotWins = config.DailyJackpotLimit
|
||||
blockedBySharedCap, err := DecideLuckyGiftStrategy(config, capState, strategyTestInput(10), NewSeededStrategyRandom(82))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if blockedBySharedCap.NextState.PendingSpendJackpotTokens != 1 || blockedBySharedCap.Jackpot {
|
||||
t.Fatalf("daily cap must be shared without consuming spend token: %+v", blockedBySharedCap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuckyGiftStrategyDualJackpotCrossingAndPriority(t *testing.T) {
|
||||
config := strategyTestConfig()
|
||||
config.SettledRoundRTPEligibility = true
|
||||
config.JackpotMechanism1Enabled = true
|
||||
config.JackpotMechanism2Enabled = true
|
||||
config.MilestoneSpendCoins = 50
|
||||
config.GlobalRTPMaxPPM = 980_000
|
||||
config.UserRoundRTPMaxPPM = 960_000
|
||||
config.User48HourRTPMaxPPM = 960_000
|
||||
config.DailyJackpotLimit = 5
|
||||
|
||||
// One paid subdraw can cross several exact multiples, but its new promises are appended only
|
||||
// after selection. Even with a valid global sample this draw must stay on the ordinary path.
|
||||
crossingState := StrategyState{
|
||||
PoolBalanceCoins: 10_000, UserDaySpendCoins: 40,
|
||||
SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
|
||||
}
|
||||
crossing, err := DecideLuckyGiftStrategy(config, crossingState, strategyTestInput(110), &scriptedStrategyRandom{t: t, indexes: []int64{0}, bounds: []int64{1_000_000}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if crossing.Jackpot || crossing.NextState.PendingSpendJackpotTokens != 3 || crossing.Trace.MilestoneTokensEarned != 3 ||
|
||||
crossing.Trace.SpendMilestoneTokensBefore != 0 || crossing.Trace.SpendMilestoneTokensAfter != 3 {
|
||||
t.Fatalf("current draw must only earn three next-draw tokens: %+v", crossing)
|
||||
}
|
||||
|
||||
const hourMS = int64(60 * 60 * 1000)
|
||||
qualified := StrategyState{
|
||||
PoolBalanceCoins: 10_000,
|
||||
PendingSpendJackpotTokens: 1,
|
||||
SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
|
||||
SettledRoundPending: true,
|
||||
GlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
|
||||
UserRoundRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 95},
|
||||
User48HourRTP: StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_500},
|
||||
UserRegisteredAtMS: hourMS,
|
||||
QualificationClosedAtMS: 49 * hourMS,
|
||||
}
|
||||
compensated, err := DecideLuckyGiftStrategy(config, qualified, strategyTestInput(10), NewSeededStrategyRandom(83))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if compensated.JackpotMechanism != StrategyJackpotMechanismRTPCompensation || compensated.NextState.PendingSpendJackpotTokens != 1 || compensated.NextState.SettledRoundPending ||
|
||||
compensated.Trace.SpendMilestoneTokensBefore != 1 || compensated.Trace.SpendMilestoneTokensAfter != 1 {
|
||||
t.Fatalf("48h qualification must win first without consuming spend token: %+v", compensated)
|
||||
}
|
||||
|
||||
// A failed personal RTP condition consumes only the one-shot 48h qualification. The same child
|
||||
// draw may still use the independent spend promise because registration and personal RTP do not gate it.
|
||||
qualified.UserRoundRTP.PayoutCoins = 96
|
||||
spendFallback, err := DecideLuckyGiftStrategy(config, qualified, strategyTestInput(10), NewSeededStrategyRandom(84))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spendFallback.JackpotMechanism != StrategyJackpotMechanismMilestone || spendFallback.NextState.PendingSpendJackpotTokens != 0 || spendFallback.NextState.SettledRoundPending {
|
||||
t.Fatalf("ineligible 48h path should fall through to spend jackpot: %+v", spendFallback)
|
||||
}
|
||||
|
||||
qualified.PoolBalanceCoins = 49
|
||||
qualified.UserRoundRTP.PayoutCoins = 95
|
||||
poolBlocked, err := DecideLuckyGiftStrategy(config, qualified, strategyTestInput(10), NewSeededStrategyRandom(85))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if poolBlocked.Jackpot || poolBlocked.NextState.SettledRoundPending || poolBlocked.NextState.PendingSpendJackpotTokens != 1 {
|
||||
t.Fatalf("48h is one-shot while pool-blocked spend token remains: %+v", poolBlocked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuckyGiftStrategySpendJackpotSharesAllSixInclusiveRiskCaps(t *testing.T) {
|
||||
config := strategyTestConfig()
|
||||
config.SettledRoundRTPEligibility = true
|
||||
config.JackpotMechanism1Enabled = true
|
||||
config.JackpotMechanism2Enabled = true
|
||||
config.MilestoneSpendCoins = 50
|
||||
config.GlobalRTPMaxPPM = 980_000
|
||||
config.UserRoundRTPMaxPPM = 960_000
|
||||
config.User48HourRTPMaxPPM = 960_000
|
||||
config.DailyJackpotLimit = 5
|
||||
setters := []struct {
|
||||
name string
|
||||
set func(*StrategyRiskCapacity, int64)
|
||||
}{
|
||||
{name: "single", set: func(c *StrategyRiskCapacity, v int64) { c.SingleDrawCoins = v }},
|
||||
{name: "user-hour", set: func(c *StrategyRiskCapacity, v int64) { c.UserHourCoins = v }},
|
||||
{name: "user-day", set: func(c *StrategyRiskCapacity, v int64) { c.UserDayCoins = v }},
|
||||
{name: "device-day", set: func(c *StrategyRiskCapacity, v int64) { c.DeviceDayCoins = v }},
|
||||
{name: "room-hour", set: func(c *StrategyRiskCapacity, v int64) { c.RoomHourCoins = v }},
|
||||
{name: "anchor-day", set: func(c *StrategyRiskCapacity, v int64) { c.AnchorDayCoins = v }},
|
||||
}
|
||||
for _, test := range setters {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
state := StrategyState{
|
||||
PoolBalanceCoins: 10_000, PendingSpendJackpotTokens: 1,
|
||||
SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
|
||||
}
|
||||
capacity := StrategyRiskCapacity{Enabled: true, SingleDrawCoins: 10_000, UserHourCoins: 10_000, UserDayCoins: 10_000, DeviceDayCoins: 10_000, RoomHourCoins: 10_000, AnchorDayCoins: 10_000}
|
||||
test.set(&capacity, 1_999)
|
||||
input := strategyTestInput(10)
|
||||
input.RiskCapacity = capacity
|
||||
blocked, err := DecideLuckyGiftStrategy(config, state, input, NewSeededStrategyRandom(90))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if blocked.Jackpot || blocked.NextState.PendingSpendJackpotTokens != 1 {
|
||||
t.Fatalf("capacity below minimum jackpot must retain token: %+v", blocked)
|
||||
}
|
||||
|
||||
test.set(&capacity, 2_000)
|
||||
input.RiskCapacity = capacity
|
||||
paid, err := DecideLuckyGiftStrategy(config, state, input, NewSeededStrategyRandom(91))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !paid.Jackpot || paid.PayoutCoins != 2_000 || paid.NextState.PendingSpendJackpotTokens != 0 {
|
||||
t.Fatalf("capacity equality must permit the 200x jackpot: %+v", paid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuckyGiftStrategyDailyJackpotLimitDoesNotConstrainOrdinarySameMultiplier(t *testing.T) {
|
||||
config := strategyTestConfig()
|
||||
config.JackpotMechanism2Enabled = true
|
||||
|
||||
@ -244,9 +244,9 @@ func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domai
|
||||
// disabled 草稿允许金额口径仍为 0;启用前必须由各 App 按自身业务口径和风控预算显式补齐。
|
||||
return nil
|
||||
}
|
||||
if !config.SettledRoundRTPEligibility && config.JackpotSpendThresholdCoins <= 0 {
|
||||
// Only the old draft contract required the now-retired milestone amount. New round/48h configs
|
||||
// preserve the column for read compatibility but never require or consume it.
|
||||
if config.JackpotSpendThresholdCoins <= 0 {
|
||||
// Enabled V3 runs the 48h compensation and spend-milestone strategies together. A zero amount
|
||||
// would silently disable one of the two published promises, so only disabled drafts may keep 0.
|
||||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot cumulative spend threshold coins must be configured as a positive amount before enabling")
|
||||
}
|
||||
return validateDynamicPayoutCaps(config, true)
|
||||
|
||||
@ -94,6 +94,12 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Consumption milestones use the newest fully closed platform sample, independent of whether
|
||||
// this user participated in that round. Missing history deliberately leaves a zero denominator.
|
||||
spendGlobalWindow, hasSpendGlobalWindow, err := r.getLatestClosedLuckyDynamicRTPWindow(ctx, tx, appCode, windowScopeID, globalWindow.WindowIndex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
giftWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool_gift", windowScopeID, config.SettlementWindowWager, config.GiftWindowDraws, config.TargetRTPPPM, paidAtMS, nowMS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -151,16 +157,19 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
|
||||
Strategy: domain.StrategyState{
|
||||
PoolBalanceCoins: basePool.Balance,
|
||||
ConsecutiveZeroDraws: userState.LossStreak,
|
||||
// Consumption milestones were retired from dynamic_v3 jackpot eligibility. Old durable tokens
|
||||
// are intentionally cleared on the next paid draw rather than becoming a hidden third gate.
|
||||
PendingMilestoneTokens: 0,
|
||||
UserDailyJackpotWins: dayState.JackpotHits,
|
||||
UserDaySpendCoins: dayState.SpendCoins,
|
||||
UserRegisteredAtMS: cmd.UserRegisteredAtMS,
|
||||
UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins},
|
||||
Version: userState.PaidDraws,
|
||||
// The old pending_jackpot_tokens column is intentionally not copied. It is cleared in the same
|
||||
// transaction while only the new column can authorize the restored spend strategy.
|
||||
PendingSpendJackpotTokens: userState.PendingSpendJackpotTokens,
|
||||
UserDailyJackpotWins: dayState.JackpotHits,
|
||||
UserDaySpendCoins: dayState.SpendCoins,
|
||||
UserRegisteredAtMS: cmd.UserRegisteredAtMS,
|
||||
UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins},
|
||||
Version: userState.PaidDraws,
|
||||
},
|
||||
}
|
||||
if hasSpendGlobalWindow {
|
||||
state.Strategy.SpendGlobalRTP = domain.StrategyRTP{WagerCoins: spendGlobalWindow.WagerCoins, PayoutCoins: spendGlobalWindow.ActualPayoutCoins}
|
||||
}
|
||||
if hasQualification {
|
||||
if err := r.prepareLuckySettledRoundQualification(ctx, tx, appCode, config, cmd, qualificationGlobal, qualificationWindow, &state, nowMS); err != nil {
|
||||
return nil, err
|
||||
@ -241,6 +250,9 @@ func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, ap
|
||||
closed.Status = "closed"
|
||||
closed.ClosedAtMS = maxInt64(closed.LastPaidAtMS, paidAtMS)
|
||||
}
|
||||
// The next subdraw in this batch must immediately see the just-closed platform sample. The draw
|
||||
// that completed the window was already selected before this assignment and cannot use it.
|
||||
state.Strategy.SpendGlobalRTP = domain.StrategyRTP{WagerCoins: closed.WagerCoins, PayoutCoins: closed.ActualPayoutCoins}
|
||||
closedUser := state.UserWindow
|
||||
if err := r.prepareLuckySettledRoundQualification(ctx, tx, appCode, config, cmd, closed, closedUser, state, nowMS); err != nil {
|
||||
return err
|
||||
@ -365,7 +377,8 @@ func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Con
|
||||
state.UserState.CumulativeWagerCoins += cmd.CoinSpent
|
||||
state.UserState.EquivalentDraws = luckyEquivalentDraws(state.UserState.CumulativeWagerCoins, referencePrice)
|
||||
state.UserState.LossStreak = decision.NextState.ConsecutiveZeroDraws
|
||||
state.UserState.PendingJackpotTokens = decision.NextState.PendingMilestoneTokens
|
||||
state.UserState.PendingJackpotTokens = 0
|
||||
state.UserState.PendingSpendJackpotTokens = decision.NextState.PendingSpendJackpotTokens
|
||||
state.DayState.WagerCoins += cmd.CoinSpent
|
||||
state.DayState.PayoutCoins += decision.PayoutCoins
|
||||
state.DayState.SpendCoins = decision.NextState.UserDaySpendCoins
|
||||
@ -509,10 +522,10 @@ func (r *Repository) persistLuckyDynamicBatch(ctx context.Context, tx *sql.Tx, a
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE lucky_user_states
|
||||
SET paid_draws = ?, cumulative_wager_coins = ?, equivalent_draws = ?, loss_streak = ?,
|
||||
pending_jackpot_tokens = ?, updated_at_ms = ?
|
||||
pending_jackpot_tokens = 0, pending_spend_jackpot_tokens = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
|
||||
state.UserState.PaidDraws, state.UserState.CumulativeWagerCoins, state.UserState.EquivalentDraws, state.UserState.LossStreak,
|
||||
state.UserState.PendingJackpotTokens, nowMS, appCode, cmd.UserID, config.GiftID,
|
||||
state.UserState.PendingSpendJackpotTokens, nowMS, appCode, cmd.UserID, config.GiftID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -131,6 +131,8 @@ func TestDynamicLuckyGiftMySQLBatchAndExternalPaths(t *testing.T) {
|
||||
}
|
||||
assertDynamicMySQLBatchFacts(t, schema.DB, "lalu", internalRule.PoolID, command.UserID, command.CommandID, 99, 100, 98, 1, 1)
|
||||
assertDynamicTokenSurvivesUTCDayBoundary(t, repo, schema.DB, ctx, now)
|
||||
assertDynamicSpendBatchRollbackAndRestart(t, repo, schema.DB, ctx, now+10_000)
|
||||
assertExternalNewUserSpendBatch(t, repo, schema.DB, ctx, now+20_000)
|
||||
|
||||
externalRule := dynamicMySQLTestRule("external-dynamic")
|
||||
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, externalRule, now-500); err != nil {
|
||||
@ -244,9 +246,10 @@ func seedDynamicRTPWindowsBelowWagerThreshold(t *testing.T, db *sql.DB, poolID s
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO lucky_rtp_windows (
|
||||
app_code,scope_type,scope_id,window_index,target_rtp_ppm,control_window_draws,
|
||||
paid_draws,wager_coins,target_payout_coins,actual_base_payout_coins,carry_ppm,status,created_at_ms,updated_at_ms
|
||||
) VALUES ('lalu',?,?,1,980000,10,10,98,96,0,40000,'open',?,?)`,
|
||||
scopeType, scopeID, nowMS, nowMS,
|
||||
paid_draws,wager_coins,target_payout_coins,actual_base_payout_coins,carry_ppm,status,
|
||||
started_at_ms,last_paid_at_ms,closed_at_ms,created_at_ms,updated_at_ms
|
||||
) VALUES ('lalu',?,?,1,980000,10,10,98,96,0,40000,'open',?,?,0,?,?)`,
|
||||
scopeType, scopeID, nowMS-1_000, nowMS, nowMS, nowMS,
|
||||
); err != nil {
|
||||
t.Fatalf("seed %s RTP window: %v", scopeType, err)
|
||||
}
|
||||
@ -316,6 +319,11 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db
|
||||
t.Fatalf("earn milestone token: %v", err)
|
||||
}
|
||||
assertPendingTokenCount(t, db, rule.PoolID, userID, 1)
|
||||
// A retired token is deliberately planted after the new token was earned. The next transaction
|
||||
// must clear it without copying or adding it to the new durable balance.
|
||||
if _, err := db.Exec(`UPDATE lucky_user_states SET pending_jackpot_tokens=7 WHERE app_code='lalu' AND user_id=? AND gift_id=?`, userID, rule.PoolID); err != nil {
|
||||
t.Fatalf("seed retired pending token: %v", err)
|
||||
}
|
||||
if result, err := db.Exec(`UPDATE lucky_pools SET balance=0 WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, rule.PoolID); err != nil {
|
||||
t.Fatalf("drain token test pool: %v", err)
|
||||
} else if rows, _ := result.RowsAffected(); rows != 1 {
|
||||
@ -331,11 +339,27 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db
|
||||
t.Fatalf("retain token across UTC day: %v", err)
|
||||
}
|
||||
assertPendingTokenCount(t, db, rule.PoolID, userID, 1)
|
||||
var retired int64
|
||||
if err := db.QueryRow(`SELECT pending_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, userID, rule.PoolID).Scan(&retired); err != nil || retired != 0 {
|
||||
t.Fatalf("retired token was not lazily cleared: got=%d err=%v", retired, err)
|
||||
}
|
||||
if result, err := db.Exec(`UPDATE lucky_pools SET balance=1000000 WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, rule.PoolID); err != nil {
|
||||
t.Fatalf("refill token test pool: %v", err)
|
||||
} else if rows, _ := result.RowsAffected(); rows != 1 {
|
||||
t.Fatalf("refill token test pool affected=%d, want 1", rows)
|
||||
}
|
||||
// Freeze the current platform window as a valid <=98% sample. Until this close, the spend token
|
||||
// correctly remains blocked because an open/current RTP can never authorize a spend jackpot.
|
||||
if result, err := db.Exec(`
|
||||
UPDATE lucky_rtp_windows
|
||||
SET wager_coins=1000000, actual_base_payout_coins=980000, status='closed',
|
||||
started_at_ms=?, last_paid_at_ms=?, closed_at_ms=?
|
||||
WHERE app_code='lalu' AND scope_type='pool' AND scope_id LIKE CONCAT(?, ':v%') AND status='open'`,
|
||||
dayOneMS, dayTwoMS, dayTwoMS, rule.PoolID); err != nil {
|
||||
t.Fatalf("close platform RTP sample: %v", err)
|
||||
} else if rows, _ := result.RowsAffected(); rows != 1 {
|
||||
t.Fatalf("closed platform RTP windows=%d want=1", rows)
|
||||
}
|
||||
consume := retain
|
||||
consume.CommandID = "token-consume-next-day"
|
||||
consume.PaidAtMS++
|
||||
@ -349,11 +373,228 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db
|
||||
assertPendingTokenCount(t, db, rule.PoolID, userID, 0)
|
||||
}
|
||||
|
||||
// assertDynamicSpendBatchRollbackAndRestart exercises the production transaction rather than only
|
||||
// the pure kernel: child 2 earns the token after selection and child 3 consumes it. A trigger fails
|
||||
// the first attempt at the final fact insert, proving all earlier in-transaction state transitions
|
||||
// roll back; rebuilding Repository and retrying the same command must then apply the sequence once.
|
||||
func assertDynamicSpendBatchRollbackAndRestart(t *testing.T, repo *Repository, db *sql.DB, ctx context.Context, nowMS int64) {
|
||||
t.Helper()
|
||||
rule := dynamicMySQLTestRule("dual-spend-batch")
|
||||
rule.JackpotSpendThresholdCoins = 20
|
||||
published, err := repo.PublishLuckyGiftRuleConfig(ctx, rule, nowMS-2_000)
|
||||
if err != nil {
|
||||
t.Fatalf("publish dual spend batch rule: %v", err)
|
||||
}
|
||||
fundDynamicSpendTestPool(t, repo, ctx, published.PoolID, "dual-spend-batch-credit", nowMS-1_900)
|
||||
seedClosedDynamicSpendGlobalWindow(t, db, published, nowMS-1_500)
|
||||
|
||||
command := domain.DrawCommand{
|
||||
CommandID: "dual-spend-batch-command", PoolID: published.PoolID, UserID: 4_040, TargetUserID: 1,
|
||||
DeviceID: "dual-spend-batch-device", RoomID: "dual-spend-batch-room", AnchorID: "dual-spend-batch-anchor", GiftID: "super-lucky",
|
||||
GiftCount: 3, CoinSpent: 30, GiftIncomeCoins: 0, PaidAtMS: nowMS,
|
||||
}
|
||||
const rollbackTrigger = "trg_lucky_dual_spend_rollback"
|
||||
if _, err := db.Exec("DROP TRIGGER IF EXISTS " + rollbackTrigger); err != nil {
|
||||
t.Fatalf("drop stale dual spend rollback trigger: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = db.Exec("DROP TRIGGER IF EXISTS " + rollbackTrigger) })
|
||||
if _, err := db.Exec(`
|
||||
CREATE TRIGGER trg_lucky_dual_spend_rollback
|
||||
BEFORE INSERT ON lucky_draw_records
|
||||
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'forced dual spend rollback'`); err != nil {
|
||||
t.Fatalf("create dual spend rollback trigger: %v", err)
|
||||
}
|
||||
if _, err := repo.ExecuteLuckyGiftDraw(ctx, command, nowMS); err == nil {
|
||||
t.Fatal("forced persistence failure unexpectedly committed dual spend batch")
|
||||
}
|
||||
if _, err := db.Exec("DROP TRIGGER " + rollbackTrigger); err != nil {
|
||||
t.Fatalf("drop dual spend rollback trigger: %v", err)
|
||||
}
|
||||
|
||||
var stateRows, drawRows, dayRows, balanceAfterRollback int64
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, command.UserID, published.PoolID).Scan(&stateRows); err != nil {
|
||||
t.Fatalf("query rolled-back spend state: %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id LIKE 'dual-spend-batch-command#%'`).Scan(&drawRows); err != nil {
|
||||
t.Fatalf("query rolled-back spend draws: %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_user_strategy_days WHERE app_code='lalu' AND pool_id=? AND user_id=?`, published.PoolID, command.UserID).Scan(&dayRows); err != nil {
|
||||
t.Fatalf("query rolled-back spend day: %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, published.PoolID).Scan(&balanceAfterRollback); err != nil {
|
||||
t.Fatalf("query rolled-back spend pool: %v", err)
|
||||
}
|
||||
if stateRows != 0 || drawRows != 0 || dayRows != 0 || balanceAfterRollback != 1_000_000 {
|
||||
t.Fatalf("failed spend batch leaked state/draw/day/pool=%d/%d/%d/%d", stateRows, drawRows, dayRows, balanceAfterRollback)
|
||||
}
|
||||
|
||||
restarted := &Repository{db: db}
|
||||
first, err := restarted.ExecuteLuckyGiftDraw(ctx, command, nowMS+1)
|
||||
if err != nil {
|
||||
t.Fatalf("retry dual spend batch after repository restart: %v", err)
|
||||
}
|
||||
assertSpendTraceSnapshot(t, db, "lucky_draw_records", "command_id", luckyDrawSubCommandID(command.CommandID, 1, 3), 0, 0, 0, "")
|
||||
assertSpendTraceSnapshot(t, db, "lucky_draw_records", "command_id", luckyDrawSubCommandID(command.CommandID, 2, 3), 0, 1, 1, "")
|
||||
assertSpendTraceSnapshot(t, db, "lucky_draw_records", "command_id", luckyDrawSubCommandID(command.CommandID, 3, 3), 1, 0, 0, domain.StrategyJackpotMechanismMilestone)
|
||||
|
||||
var paidDraws, pendingTokens, daySpend, committedDraws, balanceBeforeReplay int64
|
||||
if err := db.QueryRow(`SELECT paid_draws, pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, command.UserID, published.PoolID).Scan(&paidDraws, &pendingTokens); err != nil {
|
||||
t.Fatalf("query committed dual spend state: %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT spend_coins FROM lucky_user_strategy_days WHERE app_code='lalu' AND pool_id=? AND user_id=? AND stat_day=?`, published.PoolID, command.UserID, luckyPaidTime(command, nowMS).Format("2006-01-02")).Scan(&daySpend); err != nil {
|
||||
t.Fatalf("query committed dual spend day: %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id LIKE 'dual-spend-batch-command#%'`).Scan(&committedDraws); err != nil {
|
||||
t.Fatalf("query committed dual spend draws: %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, published.PoolID).Scan(&balanceBeforeReplay); err != nil {
|
||||
t.Fatalf("query committed dual spend pool: %v", err)
|
||||
}
|
||||
if paidDraws != 3 || pendingTokens != 0 || daySpend != 30 || committedDraws != 3 {
|
||||
t.Fatalf("committed spend batch state paid/token/day/draws=%d/%d/%d/%d", paidDraws, pendingTokens, daySpend, committedDraws)
|
||||
}
|
||||
|
||||
replayed, err := (&Repository{db: db}).ExecuteLuckyGiftDraw(ctx, command, nowMS+2)
|
||||
if err != nil {
|
||||
t.Fatalf("replay committed dual spend batch: %v", err)
|
||||
}
|
||||
var replayPaidDraws, replayPendingTokens, replayDaySpend, replayDraws, balanceAfterReplay int64
|
||||
if err := db.QueryRow(`SELECT paid_draws, pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, command.UserID, published.PoolID).Scan(&replayPaidDraws, &replayPendingTokens); err != nil {
|
||||
t.Fatalf("query replayed dual spend state: %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT spend_coins FROM lucky_user_strategy_days WHERE app_code='lalu' AND pool_id=? AND user_id=? AND stat_day=?`, published.PoolID, command.UserID, luckyPaidTime(command, nowMS).Format("2006-01-02")).Scan(&replayDaySpend); err != nil {
|
||||
t.Fatalf("query replayed dual spend day: %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id LIKE 'dual-spend-batch-command#%'`).Scan(&replayDraws); err != nil {
|
||||
t.Fatalf("query replayed dual spend draws: %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, published.PoolID).Scan(&balanceAfterReplay); err != nil {
|
||||
t.Fatalf("query replayed dual spend pool: %v", err)
|
||||
}
|
||||
if replayed.DrawID != first.DrawID || replayPaidDraws != 3 || replayPendingTokens != 0 || replayDaySpend != 30 || replayDraws != 3 || balanceAfterReplay != balanceBeforeReplay {
|
||||
t.Fatalf("replay duplicated spend batch: first/replay=%s/%s paid/token/day/draws/pool=%d/%d/%d/%d/%d want pool=%d", first.DrawID, replayed.DrawID, replayPaidDraws, replayPendingTokens, replayDaySpend, replayDraws, balanceAfterReplay, balanceBeforeReplay)
|
||||
}
|
||||
}
|
||||
|
||||
// assertExternalNewUserSpendBatch proves the external path needs neither recharge facts nor a
|
||||
// 48-hour-old account: a one-hour-old authenticated user earns at child 2 and spends at child 3.
|
||||
func assertExternalNewUserSpendBatch(t *testing.T, repo *Repository, db *sql.DB, ctx context.Context, nowMS int64) {
|
||||
t.Helper()
|
||||
rule := dynamicMySQLTestRule("dual-spend-external-new-user")
|
||||
rule.JackpotSpendThresholdCoins = 20
|
||||
published, err := repo.PublishLuckyGiftRuleConfig(ctx, rule, nowMS-2_000)
|
||||
if err != nil {
|
||||
t.Fatalf("publish external new-user spend rule: %v", err)
|
||||
}
|
||||
fundDynamicSpendTestPool(t, repo, ctx, published.PoolID, "dual-spend-external-credit", nowMS-1_900)
|
||||
seedClosedDynamicSpendGlobalWindow(t, db, published, nowMS-1_500)
|
||||
|
||||
command := domain.ExternalDrawCommand{
|
||||
AppCode: "lalu", ExternalUserID: "dual-spend-new-external-user", DeviceID: "dual-spend-new-external-device",
|
||||
RequestID: "dual-spend-new-external-request", GiftCount: 3, UnitAmount: 10, TotalAmount: 30,
|
||||
Currency: "COIN", PaidAtMS: nowMS, UserRegisteredAtMS: nowMS - int64(time.Hour/time.Millisecond), PoolID: published.PoolID,
|
||||
}
|
||||
first, err := (&Repository{db: db}).ExecuteExternalGiftDraw(ctx, command, nowMS)
|
||||
if err != nil {
|
||||
t.Fatalf("execute external new-user spend batch: %v", err)
|
||||
}
|
||||
assertSpendTraceSnapshot(t, db, "external_lucky_gift_draw_items", "request_id", command.RequestID, 1, 0, 0, domain.StrategyJackpotMechanismMilestone)
|
||||
|
||||
externalUserID := externalLuckyUserID(command.AppCode, command.ExternalUserID)
|
||||
var paidDraws, pendingTokens, itemCount, aggregateCount int64
|
||||
if err := db.QueryRow(`SELECT paid_draws, pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, externalUserID, published.PoolID).Scan(&paidDraws, &pendingTokens); err != nil {
|
||||
t.Fatalf("query external new-user spend state: %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draw_items WHERE app_code='lalu' AND request_id=?`, command.RequestID).Scan(&itemCount); err != nil {
|
||||
t.Fatalf("query external new-user spend items: %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draws WHERE app_code='lalu' AND request_id=?`, command.RequestID).Scan(&aggregateCount); err != nil {
|
||||
t.Fatalf("query external new-user spend aggregate: %v", err)
|
||||
}
|
||||
if first.RewardAmount <= 0 || paidDraws != 3 || pendingTokens != 0 || itemCount != 3 || aggregateCount != 1 {
|
||||
t.Fatalf("external new-user spend result reward/paid/token/items/aggregate=%d/%d/%d/%d/%d", first.RewardAmount, paidDraws, pendingTokens, itemCount, aggregateCount)
|
||||
}
|
||||
|
||||
replayed, err := (&Repository{db: db}).ExecuteExternalGiftDraw(ctx, command, nowMS+1)
|
||||
if err != nil {
|
||||
t.Fatalf("replay external new-user spend batch: %v", err)
|
||||
}
|
||||
var replayPaidDraws, replayItems int64
|
||||
if err := db.QueryRow(`SELECT paid_draws FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, externalUserID, published.PoolID).Scan(&replayPaidDraws); err != nil {
|
||||
t.Fatalf("query replayed external new-user state: %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draw_items WHERE app_code='lalu' AND request_id=?`, command.RequestID).Scan(&replayItems); err != nil {
|
||||
t.Fatalf("query replayed external new-user items: %v", err)
|
||||
}
|
||||
if replayed.DrawID != first.DrawID || replayPaidDraws != 3 || replayItems != 3 {
|
||||
t.Fatalf("external retry duplicated spend batch: first/replay=%s/%s paid/items=%d/%d", first.DrawID, replayed.DrawID, replayPaidDraws, replayItems)
|
||||
}
|
||||
}
|
||||
|
||||
func fundDynamicSpendTestPool(t *testing.T, repo *Repository, ctx context.Context, poolID, adjustmentID string, nowMS int64) {
|
||||
t.Helper()
|
||||
result, err := repo.AdjustLuckyGiftPoolBalance(ctx, domain.PoolAdjustmentCommand{
|
||||
AppCode: "lalu", PoolID: poolID, StrategyVersion: domain.StrategyDynamicV3,
|
||||
AdjustmentID: adjustmentID, Direction: domain.PoolAdjustmentIn, AmountCoins: 1_000_000,
|
||||
Reason: "dual spend integration fixture", OperatorAdminID: 1,
|
||||
}, nowMS)
|
||||
if err != nil {
|
||||
t.Fatalf("fund dynamic spend test pool: %v", err)
|
||||
}
|
||||
if result.Pool.Balance != 1_000_000 {
|
||||
t.Fatalf("funded dynamic spend pool balance=%d want=1000000", result.Pool.Balance)
|
||||
}
|
||||
}
|
||||
|
||||
func seedClosedDynamicSpendGlobalWindow(t *testing.T, db *sql.DB, rule domain.RuleConfig, nowMS int64) {
|
||||
t.Helper()
|
||||
runtimeConfig, err := luckyRuntimeConfigFromRuleConfig(rule)
|
||||
if err != nil {
|
||||
t.Fatalf("build dynamic spend runtime config: %v", err)
|
||||
}
|
||||
scopeID := luckyRuleWindowScopeID(runtimeConfig)
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO lucky_rtp_windows (
|
||||
app_code,scope_type,scope_id,window_index,target_rtp_ppm,control_window_draws,
|
||||
paid_draws,wager_coins,target_payout_coins,actual_base_payout_coins,carry_ppm,status,
|
||||
started_at_ms,last_paid_at_ms,closed_at_ms,created_at_ms,updated_at_ms
|
||||
) VALUES ('lalu','pool',?,1,980000,1000,100,1000,980,980,0,'closed',?,?,?,?,?)`,
|
||||
scopeID, nowMS-1_000, nowMS, nowMS, nowMS-1_000, nowMS,
|
||||
); err != nil {
|
||||
t.Fatalf("seed closed dynamic spend global window: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSpendTraceSnapshot(t *testing.T, db *sql.DB, table, keyColumn, key string, wantBefore, wantAfter, wantEarned int64, wantMechanism string) {
|
||||
t.Helper()
|
||||
jsonColumn := "candidate_tiers_json"
|
||||
itemPredicate := ""
|
||||
if table == "external_lucky_gift_draw_items" {
|
||||
jsonColumn = "candidate_snapshot_json"
|
||||
itemPredicate = " AND item_index = 3"
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
CAST(JSON_EXTRACT(%s, '$.dynamic_v3.pending_spend_jackpot_tokens_before') AS SIGNED),
|
||||
CAST(JSON_EXTRACT(%s, '$.dynamic_v3.pending_spend_jackpot_tokens_after') AS SIGNED),
|
||||
CAST(JSON_EXTRACT(%s, '$.dynamic_v3.spend_milestone_tokens_earned') AS SIGNED),
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(%s, '$.dynamic_v3.jackpot_mechanism')), '')
|
||||
FROM %s WHERE app_code='lalu' AND %s=?%s`, jsonColumn, jsonColumn, jsonColumn, jsonColumn, table, keyColumn, itemPredicate)
|
||||
var before, after, earned int64
|
||||
var mechanism string
|
||||
if err := db.QueryRow(query, key).Scan(&before, &after, &earned, &mechanism); err != nil {
|
||||
t.Fatalf("query %s spend trace for %s: %v", table, key, err)
|
||||
}
|
||||
if before != wantBefore || after != wantAfter || earned != wantEarned || mechanism != wantMechanism {
|
||||
t.Fatalf("%s spend trace for %s before/after/earned/mechanism=%d/%d/%d/%q want=%d/%d/%d/%q", table, key, before, after, earned, mechanism, wantBefore, wantAfter, wantEarned, wantMechanism)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPendingTokenCount(t *testing.T, db *sql.DB, poolID string, userID, want int64) {
|
||||
t.Helper()
|
||||
var got int64
|
||||
if err := db.QueryRow(`
|
||||
SELECT pending_jackpot_tokens FROM lucky_user_states
|
||||
SELECT pending_spend_jackpot_tokens FROM lucky_user_states
|
||||
WHERE app_code='lalu' AND user_id=? AND gift_id=?`, userID, poolID,
|
||||
).Scan(&got); err != nil {
|
||||
t.Fatalf("query pending token: %v", err)
|
||||
@ -556,6 +797,9 @@ func TestLuckyDynamicStrategyConfigSeparatesOrdinaryAndJackpotSources(t *testing
|
||||
if runtimeConfig.TargetRTPPPM != 890_000 || strategy.DailyJackpotLimit != 10 {
|
||||
t.Fatalf("v5 runtime target/cap=%d/%d want=890000/10", runtimeConfig.TargetRTPPPM, strategy.DailyJackpotLimit)
|
||||
}
|
||||
if !strategy.JackpotMechanism2Enabled || strategy.MilestoneSpendCoins != rule.JackpotSpendThresholdCoins {
|
||||
t.Fatalf("runtime spend strategy enabled/threshold=%v/%d want=true/%d", strategy.JackpotMechanism2Enabled, strategy.MilestoneSpendCoins, rule.JackpotSpendThresholdCoins)
|
||||
}
|
||||
ordinary := make(map[int64]*domain.StrategyTier)
|
||||
jackpot := make(map[int64]*domain.StrategyTier)
|
||||
for index := range strategy.Tiers {
|
||||
|
||||
@ -127,6 +127,33 @@ func (r *Repository) getLatestPendingLuckyUserRTPWindow(ctx context.Context, tx
|
||||
return userWindow, global, true, nil
|
||||
}
|
||||
|
||||
// getLatestClosedLuckyDynamicRTPWindow returns the platform sample used only by the durable spend
|
||||
// strategy. It intentionally does not join a user window: a user may have been inactive throughout
|
||||
// the latest platform round. Legacy rows without an exact start/close boundary are excluded, because
|
||||
// treating an administrative migration close as a settled sample could manufacture jackpot access.
|
||||
func (r *Repository) getLatestClosedLuckyDynamicRTPWindow(ctx context.Context, tx *sql.Tx, appCode, globalScopeID string, beforeWindowIndex int64) (luckyRTPWindow, bool, error) {
|
||||
var window luckyRTPWindow
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
|
||||
paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins,
|
||||
carry_ppm, status, started_at_ms, last_paid_at_ms, closed_at_ms
|
||||
FROM lucky_rtp_windows
|
||||
WHERE app_code = ? AND scope_type = 'pool' AND scope_id = ? AND status = 'closed'
|
||||
AND window_index < ? AND wager_coins > 0 AND started_at_ms > 0 AND closed_at_ms > 0
|
||||
ORDER BY window_index DESC
|
||||
LIMIT 1`, appCode, globalScopeID, beforeWindowIndex,
|
||||
).Scan(&window.ScopeType, &window.ScopeID, &window.WindowIndex, &window.TargetRTPPPM, &window.ControlDraws,
|
||||
&window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins,
|
||||
&window.CarryPPM, &window.Status, &window.StartedAtMS, &window.LastPaidAtMS, &window.ClosedAtMS)
|
||||
if err == sql.ErrNoRows {
|
||||
return luckyRTPWindow{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return luckyRTPWindow{}, false, err
|
||||
}
|
||||
return window, true, nil
|
||||
}
|
||||
|
||||
func (r *Repository) persistLuckyUserRTPWindow(ctx context.Context, tx *sql.Tx, appCode string, state luckyUserRTPWindow, nowMS int64) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE lucky_user_rtp_windows
|
||||
|
||||
@ -131,9 +131,9 @@ func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig {
|
||||
RechargeBoostEndMS: config.RechargeBoostWindowMS,
|
||||
MissProtectionZeroDraws: config.LossStreakGuarantee,
|
||||
DailyJackpotLimit: config.MaxJackpotHitsPerUserDay,
|
||||
MilestoneSpendCoins: 0,
|
||||
MilestoneSpendCoins: config.JackpotSpendThresholdCoins,
|
||||
JackpotMechanism1Enabled: true,
|
||||
JackpotMechanism2Enabled: false,
|
||||
JackpotMechanism2Enabled: config.JackpotSpendThresholdCoins > 0,
|
||||
GlobalRTPMaxPPM: config.JackpotGlobalRTPMaxPPM,
|
||||
UserRoundRTPMaxPPM: config.JackpotUserRoundRTPMaxPPM,
|
||||
User48HourRTPMaxPPM: config.JackpotUser48hRTPMaxPPM,
|
||||
|
||||
@ -78,9 +78,12 @@ type luckyUserState struct {
|
||||
CumulativeWagerCoins int64
|
||||
EquivalentDraws int64
|
||||
LossStreak int64
|
||||
// PendingJackpotTokens 是按 UTC 日消费赚取、但允许跨日等待资金的未消费大奖资格。
|
||||
// 它属于 pool/user 生命周期,不能和“当天消费/当天命中次数”一起在午夜静默清零。
|
||||
// PendingJackpotTokens is the retired token column. Dynamic V3 reads it only so the next paid
|
||||
// transaction can clear it; its value must never become a new spend jackpot qualification.
|
||||
PendingJackpotTokens int64
|
||||
// PendingSpendJackpotTokens belongs to the app/pool/user lifetime. UTC-day spend creates tokens,
|
||||
// but a blocked token survives midnight until one spend jackpot is actually paid.
|
||||
PendingSpendJackpotTokens int64
|
||||
}
|
||||
|
||||
type luckyCandidate struct {
|
||||
@ -2421,11 +2424,13 @@ func luckyCeilDiv(value, divisor int64) int64 {
|
||||
func (r *Repository) getLuckyUserState(ctx context.Context, appCode string, userID int64, giftID string) (luckyUserState, error) {
|
||||
var state luckyUserState
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, pending_jackpot_tokens
|
||||
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak,
|
||||
pending_jackpot_tokens, pending_spend_jackpot_tokens
|
||||
FROM lucky_user_states
|
||||
WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
|
||||
appCode, userID, giftID,
|
||||
).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak, &state.PendingJackpotTokens)
|
||||
).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak,
|
||||
&state.PendingJackpotTokens, &state.PendingSpendJackpotTokens)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return luckyUserState{}, nil
|
||||
}
|
||||
@ -2438,8 +2443,8 @@ func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx,
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO lucky_user_states (
|
||||
app_code, user_id, gift_id, paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak,
|
||||
pending_jackpot_tokens, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, 0, 0, 0, 0, 0, ?, ?)
|
||||
pending_jackpot_tokens, pending_spend_jackpot_tokens, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, 0, 0, 0, 0, 0, 0, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
|
||||
appCode, userID, giftID, nowMS, nowMS,
|
||||
); err != nil {
|
||||
@ -2447,12 +2452,14 @@ func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx,
|
||||
}
|
||||
var state luckyUserState
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, pending_jackpot_tokens
|
||||
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak,
|
||||
pending_jackpot_tokens, pending_spend_jackpot_tokens
|
||||
FROM lucky_user_states
|
||||
WHERE app_code = ? AND user_id = ? AND gift_id = ?
|
||||
FOR UPDATE`,
|
||||
appCode, userID, giftID,
|
||||
).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak, &state.PendingJackpotTokens)
|
||||
).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak,
|
||||
&state.PendingJackpotTokens, &state.PendingSpendJackpotTokens)
|
||||
return state, err
|
||||
}
|
||||
|
||||
|
||||
@ -153,6 +153,14 @@ func TestDynamicStrategyMigrationIsIdempotent(t *testing.T) {
|
||||
// initdb 已包含最终结构,004 连续运行两次仍必须成功,覆盖全新环境与线上重复发布 migration 两条路径。
|
||||
runLuckyGiftSQLScript(t, schema.DB, migrationPath)
|
||||
runLuckyGiftSQLScript(t, schema.DB, migrationPath)
|
||||
// 007 must also work on a pre-dual-strategy schema and remain replay-safe. Dropping only the new
|
||||
// constant-default column reproduces that shape without scanning or rewriting any user row.
|
||||
if _, err := schema.DB.Exec(`ALTER TABLE lucky_user_states DROP COLUMN pending_spend_jackpot_tokens, ALGORITHM=INSTANT`); err != nil {
|
||||
t.Fatalf("prepare pre-007 schema: %v", err)
|
||||
}
|
||||
spendTokenMigrationPath := mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "migrations", "007_dual_jackpot_spend_tokens.sql")
|
||||
runLuckyGiftSQLScript(t, schema.DB, spendTokenMigrationPath)
|
||||
runLuckyGiftSQLScript(t, schema.DB, spendTokenMigrationPath)
|
||||
|
||||
for _, table := range []string{
|
||||
"lucky_risk_counters", "lucky_user_rtp_hour_buckets", "lucky_user_rtp_boundary_events",
|
||||
@ -171,6 +179,7 @@ func TestDynamicStrategyMigrationIsIdempotent(t *testing.T) {
|
||||
minLength int64
|
||||
}{
|
||||
{table: "lucky_user_states", name: "pending_jackpot_tokens"},
|
||||
{table: "lucky_user_states", name: "pending_spend_jackpot_tokens"},
|
||||
{table: "lucky_rtp_windows", name: "scope_id", minLength: 128},
|
||||
{table: "lucky_risk_counters", name: "scope_id", minLength: 255},
|
||||
{table: "lucky_gift_command_locks", name: "command_id", minLength: 128},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user