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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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_global_rtp_max_ppm = 28;
|
||||||
int64 jackpot_user_round_rtp_max_ppm = 29;
|
int64 jackpot_user_round_rtp_max_ppm = 29;
|
||||||
int64 jackpot_user_48h_rtp_max_ppm = 30;
|
int64 jackpot_user_48h_rtp_max_ppm = 30;
|
||||||
// 历史消费门槛字段只用于旧配置回读;dynamic_v3 当前大奖资格不再消费该值。
|
// 用户在同一应用、奖池和 UTC 自然日每累计消费满该金币数,生成一次可跨日保留的消费大奖资格。
|
||||||
int64 jackpot_spend_threshold_coins = 31;
|
int64 jackpot_spend_threshold_coins = 31;
|
||||||
int64 max_jackpot_hits_per_user_day = 32;
|
int64 max_jackpot_hits_per_user_day = 32;
|
||||||
int64 max_single_payout = 33;
|
int64 max_single_payout = 33;
|
||||||
|
|||||||
@ -103,6 +103,7 @@ func runSimulation(options simulationOptions) simulationReport {
|
|||||||
simulateSixRiskCapacities(config),
|
simulateSixRiskCapacities(config),
|
||||||
simulateRechargeStages(config),
|
simulateRechargeStages(config),
|
||||||
simulateCombinationPriority(config, options.Seed+70),
|
simulateCombinationPriority(config, options.Seed+70),
|
||||||
|
simulateDualJackpotSpendStrategy(config, options.Seed+75),
|
||||||
simulateConcurrencyAndIdempotency(config, options.Seed+80),
|
simulateConcurrencyAndIdempotency(config, options.Seed+80),
|
||||||
simulateLongRunGroups(config, options.Seed+90, options.GroupLongRunN),
|
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.HighWaterFactorPPM = 1_300_000
|
||||||
config.RechargeFactorPPM = 1_100_000
|
config.RechargeFactorPPM = 1_100_000
|
||||||
state := qualifiedSettledRoundState(10_000, 100*60*60*1000)
|
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
|
state.ConsecutiveZeroDraws = 5
|
||||||
input := domain.StrategyInput{
|
input := domain.StrategyInput{
|
||||||
GiftPriceCoins: 10, NowMS: 1_000_000, HasRechargeFact: true,
|
GiftPriceCoins: 10, NowMS: 1_000_000, HasRechargeFact: true,
|
||||||
@ -947,8 +951,8 @@ func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scena
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return failedScenario(name, err)
|
return failedScenario(name, err)
|
||||||
}
|
}
|
||||||
// 已结算轮次资格在普通概率、第6抽保底和动态因子之前尝试;独立消费里程碑机制已经停用。
|
// 两套资格同时存在时,48h 补偿先支付,消费 token 保留到下一个子抽。
|
||||||
priorityPass := priority.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation && !priority.NextState.SettledRoundPending && priority.Stage == domain.StageAdvanced
|
priorityPass := priority.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation && !priority.NextState.SettledRoundPending && priority.NextState.PendingSpendJackpotTokens == 1 && priority.Stage == domain.StageAdvanced
|
||||||
|
|
||||||
blockedState := state
|
blockedState := state
|
||||||
blockedState.PoolBalanceCoins = 50
|
blockedState.PoolBalanceCoins = 50
|
||||||
@ -956,15 +960,118 @@ func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scena
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return failedScenario(name, err)
|
return failedScenario(name, err)
|
||||||
}
|
}
|
||||||
// 大奖资格不能透支奖池;该轮资格被消费后,同一抽仍可按普通第6抽保底支付恰好可负担的5x。
|
// 大奖资格不能透支奖池;48h 资格本轮结束,消费 token 则等待后续补水。
|
||||||
blockedPass := !blocked.NextState.SettledRoundPending && blocked.JackpotMechanism == "" && blocked.PayoutCoins == 50 && blocked.PoolAfterCoins == 0
|
blockedPass := !blocked.NextState.SettledRoundPending && blocked.NextState.PendingSpendJackpotTokens == 1 && blocked.JackpotMechanism == "" && blocked.PayoutCoins == 50 && blocked.PoolAfterCoins == 0
|
||||||
return scenarioResult{
|
return scenarioResult{
|
||||||
Name: name, Passed: priorityPass && blockedPass,
|
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},
|
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:
|
// simulationStore models the repository contract that the pure kernel expects:
|
||||||
// command idempotency and state locking wrap the draw in one critical section. The
|
// 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
|
// 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 '该用户在当前奖池累计消耗金币',
|
cumulative_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '该用户在当前奖池累计消耗金币',
|
||||||
equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT '累计金币按规则参考价格折算后的等价抽数',
|
equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT '累计金币按规则参考价格折算后的等价抽数',
|
||||||
loss_streak 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',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
updated_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)
|
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"
|
StrategyReasonPoolInsufficient = "pool_insufficient_w_gt_p"
|
||||||
StrategyReasonRiskCapacity = "risk_capacity_exceeded"
|
StrategyReasonRiskCapacity = "risk_capacity_exceeded"
|
||||||
StrategyReasonNoPayableJackpot = "no_payable_jackpot_candidate"
|
StrategyReasonNoPayableJackpot = "no_payable_jackpot_candidate"
|
||||||
StrategyJackpotMechanismMilestone = "milestone_token"
|
StrategyReasonSpendGlobalRTPBlocked = "spend_global_rtp_gate_failed"
|
||||||
|
StrategyJackpotMechanismMilestone = "spend_milestone"
|
||||||
StrategyJackpotMechanismRTPCompensation = "rtp_compensation"
|
StrategyJackpotMechanismRTPCompensation = "rtp_compensation"
|
||||||
StrategyDrawKindOriginal = "original"
|
StrategyDrawKindOriginal = "original"
|
||||||
StrategyDrawKindRedraw = "redraw"
|
StrategyDrawKindRedraw = "redraw"
|
||||||
@ -35,6 +36,8 @@ const (
|
|||||||
StrategyRemovalMissProtection = "miss_protection_excludes_zero"
|
StrategyRemovalMissProtection = "miss_protection_excludes_zero"
|
||||||
StrategyRemovalPWRedrawZero = "w_gt_p_redraw_excludes_zero"
|
StrategyRemovalPWRedrawZero = "w_gt_p_redraw_excludes_zero"
|
||||||
StrategyConditionGlobalRTP = "global_rtp"
|
StrategyConditionGlobalRTP = "global_rtp"
|
||||||
|
StrategyConditionSpendGlobalRTP = "spend_global_rtp"
|
||||||
|
StrategyConditionSpendDailyJackpotLimit = "spend_daily_jackpot_limit"
|
||||||
StrategyConditionSettledRound = "settled_round_qualification"
|
StrategyConditionSettledRound = "settled_round_qualification"
|
||||||
StrategyConditionUserRoundRTP = "user_round_rtp"
|
StrategyConditionUserRoundRTP = "user_round_rtp"
|
||||||
StrategyConditionUser48HourRTP = "user_48h_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
|
// must update it atomically with the draw record; Version is provided for CAS/optimistic
|
||||||
// locking but the kernel itself intentionally performs no I/O.
|
// locking but the kernel itself intentionally performs no I/O.
|
||||||
type StrategyState struct {
|
type StrategyState struct {
|
||||||
PoolBalanceCoins int64 `json:"pool_balance_coins"`
|
PoolBalanceCoins int64 `json:"pool_balance_coins"`
|
||||||
ConsecutiveZeroDraws int64 `json:"consecutive_zero_draws"`
|
ConsecutiveZeroDraws int64 `json:"consecutive_zero_draws"`
|
||||||
PendingMilestoneTokens int64 `json:"pending_milestone_tokens"`
|
// PendingMilestoneTokens is the legacy pure-kernel field. Production dynamic_v3 never reads it:
|
||||||
UserDailyJackpotWins int64 `json:"user_daily_jackpot_wins"`
|
// the separate field below prevents tokens created by the retired implementation from becoming
|
||||||
UserDaySpendCoins int64 `json:"user_day_spend_coins"`
|
// 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"`
|
GlobalRTP StrategyRTP `json:"global_rtp"`
|
||||||
UserRoundRTP StrategyRTP `json:"user_round_rtp"`
|
UserRoundRTP StrategyRTP `json:"user_round_rtp"`
|
||||||
User48HourRTP StrategyRTP `json:"user_48h_rtp"`
|
User48HourRTP StrategyRTP `json:"user_48h_rtp"`
|
||||||
@ -255,9 +265,14 @@ type DecisionTrace struct {
|
|||||||
FinalReason string `json:"final_reason"`
|
FinalReason string `json:"final_reason"`
|
||||||
BlockedReason string `json:"blocked_reason,omitempty"`
|
BlockedReason string `json:"blocked_reason,omitempty"`
|
||||||
JackpotMechanism string `json:"jackpot_mechanism,omitempty"`
|
JackpotMechanism string `json:"jackpot_mechanism,omitempty"`
|
||||||
MilestoneTokenConsumed bool `json:"milestone_token_consumed"`
|
MilestoneTokenConsumed bool `json:"spend_milestone_token_consumed"`
|
||||||
MilestoneTokenRetained bool `json:"milestone_token_retained"`
|
MilestoneTokenRetained bool `json:"spend_milestone_token_retained"`
|
||||||
MilestoneTokensEarned int64 `json:"milestone_tokens_earned"`
|
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 {
|
type StrategyDecision struct {
|
||||||
@ -401,10 +416,10 @@ func PreviewLuckyGiftStrategyWeights(config StrategyConfig, state StrategyState,
|
|||||||
return stage, traces, err
|
return stage, traces, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecideLuckyGiftStrategy is the shared production/simulation kernel. Its ordering is
|
// DecideLuckyGiftStrategy is the shared production/simulation kernel. Production V3 evaluates the
|
||||||
// intentional and traceable: persisted mechanism-2 token -> mechanism-1 RTP gates ->
|
// one-shot closed-round/48h qualification first, then the independent durable spend milestone, and
|
||||||
// ordinary weighted P/W draw and redraw -> state transition. A blocked special
|
// finally the ordinary weighted draw. A spend token is consumed only by a paid spend jackpot; all
|
||||||
// mechanism falls through to an ordinary draw while preserving its token.
|
// 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) {
|
func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input StrategyInput, random StrategyRandomSource) (StrategyDecision, error) {
|
||||||
if random == nil {
|
if random == nil {
|
||||||
return StrategyDecision{}, ErrStrategyRandomSource
|
return StrategyDecision{}, ErrStrategyRandomSource
|
||||||
@ -425,48 +440,41 @@ func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input S
|
|||||||
Stage: stage, PoolBeforeCoins: state.PoolBalanceCoins,
|
Stage: stage, PoolBeforeCoins: state.PoolBalanceCoins,
|
||||||
PoolContributionCoins: input.PoolContributionCoins, AvailablePoolCoins: available,
|
PoolContributionCoins: input.PoolContributionCoins, AvailablePoolCoins: available,
|
||||||
EffectiveRiskCapacityCoins: input.RiskCapacity.capacity(), Weights: weightTrace,
|
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
|
if config.SettledRoundRTPEligibility {
|
||||||
// opportunistic RTP compensation mechanism. Insufficient pool/risk capacity keeps
|
decision, decided, err := tryRTPCompensationJackpot(config, state, input, available, random, &trace)
|
||||||
// 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 err != nil {
|
if err != nil {
|
||||||
return StrategyDecision{}, err
|
return StrategyDecision{}, err
|
||||||
}
|
}
|
||||||
trace.Removed = append(trace.Removed, removed...)
|
if decided {
|
||||||
if ok {
|
return decision, 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})
|
|
||||||
return finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismMilestone, true, trace)
|
|
||||||
}
|
}
|
||||||
trace.MilestoneTokenRetained = true
|
decision, decided, err = trySpendMilestoneJackpot(config, state, input, available, random, true, &trace)
|
||||||
trace.BlockedReason = reason
|
if err != nil {
|
||||||
trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: false, Reason: reason})
|
return StrategyDecision{}, err
|
||||||
}
|
}
|
||||||
|
if decided {
|
||||||
if config.JackpotMechanism1Enabled {
|
return decision, nil
|
||||||
conditions, eligible := mechanismOneConditions(config, state)
|
}
|
||||||
trace.Conditions = append(trace.Conditions, conditions...)
|
} else {
|
||||||
if eligible {
|
// The isolated legacy kernel keeps its historical order and gate shape. Runtime dynamic_v3
|
||||||
tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random)
|
// always takes the branch above and therefore never reads PendingMilestoneTokens.
|
||||||
if err != nil {
|
decision, decided, err := trySpendMilestoneJackpot(config, state, input, available, random, false, &trace)
|
||||||
return StrategyDecision{}, err
|
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 decided {
|
||||||
if ok {
|
return decision, nil
|
||||||
trace.JackpotMechanism = StrategyJackpotMechanismRTPCompensation
|
}
|
||||||
trace.FinalReason = StrategyReasonRTPCompensationJackpot
|
decision, decided, err = tryRTPCompensationJackpot(config, state, input, available, random, &trace)
|
||||||
trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindRTPCompensation, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout})
|
if err != nil {
|
||||||
return finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismRTPCompensation, false, trace)
|
return StrategyDecision{}, err
|
||||||
}
|
}
|
||||||
if trace.BlockedReason == "" {
|
if decided {
|
||||||
trace.BlockedReason = reason
|
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)
|
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 {
|
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)
|
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 {
|
if rtp.WagerCoins < 0 || rtp.PayoutCoins < 0 {
|
||||||
return fmt.Errorf("%w: RTP numerator and denominator cannot be negative", ErrStrategyInput)
|
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}
|
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 {
|
func rtpBelowExact(rtp StrategyRTP, limitPPM int64) bool {
|
||||||
return compareRTPExact(rtp, limitPPM) < 0
|
return compareRTPExact(rtp, limitPPM) < 0
|
||||||
}
|
}
|
||||||
@ -908,10 +1006,14 @@ func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input
|
|||||||
next.ConsecutiveZeroDraws = 0
|
next.ConsecutiveZeroDraws = 0
|
||||||
}
|
}
|
||||||
if consumeToken {
|
if consumeToken {
|
||||||
if next.PendingMilestoneTokens <= 0 {
|
if pendingSpendJackpotTokens(config, next) <= 0 {
|
||||||
return StrategyDecision{}, fmt.Errorf("%w: cannot consume absent milestone token", ErrStrategyInput)
|
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
|
jackpot := payout > 0 && tier.Jackpot
|
||||||
if 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 {
|
if next.UserDaySpendCoins, err = safeAdd(next.UserDaySpendCoins, input.GiftPriceCoins); err != nil {
|
||||||
return StrategyDecision{}, err
|
return StrategyDecision{}, err
|
||||||
}
|
}
|
||||||
// A threshold crossed by this draw is persisted after selection, so the newly
|
// A threshold crossed by this draw is persisted after selection, so the newly earned token can
|
||||||
// earned token can affect only the next locked draw and never the current one. Production V3's
|
// affect only the next locked subdraw and never the current one. Production V3 stores it in the
|
||||||
// settled-round mode has no independent spend-token qualification, so it must neither earn nor
|
// dedicated field; the retired legacy token is neither read nor migrated into the new strategy.
|
||||||
// carry a hidden third jackpot condition even if an old rule row still contains the legacy value.
|
|
||||||
earnedTokens := int64(0)
|
earnedTokens := int64(0)
|
||||||
if !config.SettledRoundRTPEligibility {
|
if config.JackpotMechanism2Enabled {
|
||||||
earnedTokens = MilestoneTokensEarned(previousDaySpend, next.UserDaySpendCoins, config.MilestoneSpendCoins)
|
earnedTokens = MilestoneTokensEarned(previousDaySpend, next.UserDaySpendCoins, config.MilestoneSpendCoins)
|
||||||
}
|
}
|
||||||
if earnedTokens > 0 {
|
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 {
|
if err != nil {
|
||||||
return StrategyDecision{}, err
|
return StrategyDecision{}, err
|
||||||
}
|
}
|
||||||
trace.MilestoneTokensEarned = earnedTokens
|
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 !config.SettledRoundRTPEligibility {
|
||||||
if next.GlobalRTP, err = next.GlobalRTP.add(input.GiftPriceCoins, payout); err != nil {
|
if next.GlobalRTP, err = next.GlobalRTP.add(input.GiftPriceCoins, payout); err != nil {
|
||||||
return StrategyDecision{}, err
|
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) {
|
func TestLuckyGiftStrategyDailyJackpotLimitDoesNotConstrainOrdinarySameMultiplier(t *testing.T) {
|
||||||
config := strategyTestConfig()
|
config := strategyTestConfig()
|
||||||
config.JackpotMechanism2Enabled = true
|
config.JackpotMechanism2Enabled = true
|
||||||
|
|||||||
@ -244,9 +244,9 @@ func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domai
|
|||||||
// disabled 草稿允许金额口径仍为 0;启用前必须由各 App 按自身业务口径和风控预算显式补齐。
|
// disabled 草稿允许金额口径仍为 0;启用前必须由各 App 按自身业务口径和风控预算显式补齐。
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !config.SettledRoundRTPEligibility && config.JackpotSpendThresholdCoins <= 0 {
|
if config.JackpotSpendThresholdCoins <= 0 {
|
||||||
// Only the old draft contract required the now-retired milestone amount. New round/48h configs
|
// Enabled V3 runs the 48h compensation and spend-milestone strategies together. A zero amount
|
||||||
// preserve the column for read compatibility but never require or consume it.
|
// 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 xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot cumulative spend threshold coins must be configured as a positive amount before enabling")
|
||||||
}
|
}
|
||||||
return validateDynamicPayoutCaps(config, true)
|
return validateDynamicPayoutCaps(config, true)
|
||||||
|
|||||||
@ -94,6 +94,12 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
giftWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool_gift", windowScopeID, config.SettlementWindowWager, config.GiftWindowDraws, config.TargetRTPPPM, paidAtMS, nowMS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -151,16 +157,19 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
|
|||||||
Strategy: domain.StrategyState{
|
Strategy: domain.StrategyState{
|
||||||
PoolBalanceCoins: basePool.Balance,
|
PoolBalanceCoins: basePool.Balance,
|
||||||
ConsecutiveZeroDraws: userState.LossStreak,
|
ConsecutiveZeroDraws: userState.LossStreak,
|
||||||
// Consumption milestones were retired from dynamic_v3 jackpot eligibility. Old durable tokens
|
// The old pending_jackpot_tokens column is intentionally not copied. It is cleared in the same
|
||||||
// are intentionally cleared on the next paid draw rather than becoming a hidden third gate.
|
// transaction while only the new column can authorize the restored spend strategy.
|
||||||
PendingMilestoneTokens: 0,
|
PendingSpendJackpotTokens: userState.PendingSpendJackpotTokens,
|
||||||
UserDailyJackpotWins: dayState.JackpotHits,
|
UserDailyJackpotWins: dayState.JackpotHits,
|
||||||
UserDaySpendCoins: dayState.SpendCoins,
|
UserDaySpendCoins: dayState.SpendCoins,
|
||||||
UserRegisteredAtMS: cmd.UserRegisteredAtMS,
|
UserRegisteredAtMS: cmd.UserRegisteredAtMS,
|
||||||
UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins},
|
UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins},
|
||||||
Version: userState.PaidDraws,
|
Version: userState.PaidDraws,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if hasSpendGlobalWindow {
|
||||||
|
state.Strategy.SpendGlobalRTP = domain.StrategyRTP{WagerCoins: spendGlobalWindow.WagerCoins, PayoutCoins: spendGlobalWindow.ActualPayoutCoins}
|
||||||
|
}
|
||||||
if hasQualification {
|
if hasQualification {
|
||||||
if err := r.prepareLuckySettledRoundQualification(ctx, tx, appCode, config, cmd, qualificationGlobal, qualificationWindow, &state, nowMS); err != nil {
|
if err := r.prepareLuckySettledRoundQualification(ctx, tx, appCode, config, cmd, qualificationGlobal, qualificationWindow, &state, nowMS); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -241,6 +250,9 @@ func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, ap
|
|||||||
closed.Status = "closed"
|
closed.Status = "closed"
|
||||||
closed.ClosedAtMS = maxInt64(closed.LastPaidAtMS, paidAtMS)
|
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
|
closedUser := state.UserWindow
|
||||||
if err := r.prepareLuckySettledRoundQualification(ctx, tx, appCode, config, cmd, closed, closedUser, state, nowMS); err != nil {
|
if err := r.prepareLuckySettledRoundQualification(ctx, tx, appCode, config, cmd, closed, closedUser, state, nowMS); err != nil {
|
||||||
return err
|
return err
|
||||||
@ -365,7 +377,8 @@ func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Con
|
|||||||
state.UserState.CumulativeWagerCoins += cmd.CoinSpent
|
state.UserState.CumulativeWagerCoins += cmd.CoinSpent
|
||||||
state.UserState.EquivalentDraws = luckyEquivalentDraws(state.UserState.CumulativeWagerCoins, referencePrice)
|
state.UserState.EquivalentDraws = luckyEquivalentDraws(state.UserState.CumulativeWagerCoins, referencePrice)
|
||||||
state.UserState.LossStreak = decision.NextState.ConsecutiveZeroDraws
|
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.WagerCoins += cmd.CoinSpent
|
||||||
state.DayState.PayoutCoins += decision.PayoutCoins
|
state.DayState.PayoutCoins += decision.PayoutCoins
|
||||||
state.DayState.SpendCoins = decision.NextState.UserDaySpendCoins
|
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, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
UPDATE lucky_user_states
|
UPDATE lucky_user_states
|
||||||
SET paid_draws = ?, cumulative_wager_coins = ?, equivalent_draws = ?, loss_streak = ?,
|
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 = ?`,
|
WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
|
||||||
state.UserState.PaidDraws, state.UserState.CumulativeWagerCoins, state.UserState.EquivalentDraws, state.UserState.LossStreak,
|
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 {
|
); err != nil {
|
||||||
return err
|
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)
|
assertDynamicMySQLBatchFacts(t, schema.DB, "lalu", internalRule.PoolID, command.UserID, command.CommandID, 99, 100, 98, 1, 1)
|
||||||
assertDynamicTokenSurvivesUTCDayBoundary(t, repo, schema.DB, ctx, now)
|
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")
|
externalRule := dynamicMySQLTestRule("external-dynamic")
|
||||||
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, externalRule, now-500); err != nil {
|
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(`
|
if _, err := db.Exec(`
|
||||||
INSERT INTO lucky_rtp_windows (
|
INSERT INTO lucky_rtp_windows (
|
||||||
app_code,scope_type,scope_id,window_index,target_rtp_ppm,control_window_draws,
|
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
|
paid_draws,wager_coins,target_payout_coins,actual_base_payout_coins,carry_ppm,status,
|
||||||
) VALUES ('lalu',?,?,1,980000,10,10,98,96,0,40000,'open',?,?)`,
|
started_at_ms,last_paid_at_ms,closed_at_ms,created_at_ms,updated_at_ms
|
||||||
scopeType, scopeID, nowMS, nowMS,
|
) VALUES ('lalu',?,?,1,980000,10,10,98,96,0,40000,'open',?,?,0,?,?)`,
|
||||||
|
scopeType, scopeID, nowMS-1_000, nowMS, nowMS, nowMS,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("seed %s RTP window: %v", scopeType, err)
|
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)
|
t.Fatalf("earn milestone token: %v", err)
|
||||||
}
|
}
|
||||||
assertPendingTokenCount(t, db, rule.PoolID, userID, 1)
|
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 {
|
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)
|
t.Fatalf("drain token test pool: %v", err)
|
||||||
} else if rows, _ := result.RowsAffected(); rows != 1 {
|
} 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)
|
t.Fatalf("retain token across UTC day: %v", err)
|
||||||
}
|
}
|
||||||
assertPendingTokenCount(t, db, rule.PoolID, userID, 1)
|
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 {
|
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)
|
t.Fatalf("refill token test pool: %v", err)
|
||||||
} else if rows, _ := result.RowsAffected(); rows != 1 {
|
} else if rows, _ := result.RowsAffected(); rows != 1 {
|
||||||
t.Fatalf("refill token test pool affected=%d, want 1", rows)
|
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 := retain
|
||||||
consume.CommandID = "token-consume-next-day"
|
consume.CommandID = "token-consume-next-day"
|
||||||
consume.PaidAtMS++
|
consume.PaidAtMS++
|
||||||
@ -349,11 +373,228 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db
|
|||||||
assertPendingTokenCount(t, db, rule.PoolID, userID, 0)
|
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) {
|
func assertPendingTokenCount(t *testing.T, db *sql.DB, poolID string, userID, want int64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var got int64
|
var got int64
|
||||||
if err := db.QueryRow(`
|
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,
|
WHERE app_code='lalu' AND user_id=? AND gift_id=?`, userID, poolID,
|
||||||
).Scan(&got); err != nil {
|
).Scan(&got); err != nil {
|
||||||
t.Fatalf("query pending token: %v", err)
|
t.Fatalf("query pending token: %v", err)
|
||||||
@ -556,6 +797,9 @@ func TestLuckyDynamicStrategyConfigSeparatesOrdinaryAndJackpotSources(t *testing
|
|||||||
if runtimeConfig.TargetRTPPPM != 890_000 || strategy.DailyJackpotLimit != 10 {
|
if runtimeConfig.TargetRTPPPM != 890_000 || strategy.DailyJackpotLimit != 10 {
|
||||||
t.Fatalf("v5 runtime target/cap=%d/%d want=890000/10", runtimeConfig.TargetRTPPPM, strategy.DailyJackpotLimit)
|
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)
|
ordinary := make(map[int64]*domain.StrategyTier)
|
||||||
jackpot := make(map[int64]*domain.StrategyTier)
|
jackpot := make(map[int64]*domain.StrategyTier)
|
||||||
for index := range strategy.Tiers {
|
for index := range strategy.Tiers {
|
||||||
|
|||||||
@ -127,6 +127,33 @@ func (r *Repository) getLatestPendingLuckyUserRTPWindow(ctx context.Context, tx
|
|||||||
return userWindow, global, true, nil
|
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 {
|
func (r *Repository) persistLuckyUserRTPWindow(ctx context.Context, tx *sql.Tx, appCode string, state luckyUserRTPWindow, nowMS int64) error {
|
||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
UPDATE lucky_user_rtp_windows
|
UPDATE lucky_user_rtp_windows
|
||||||
|
|||||||
@ -131,9 +131,9 @@ func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig {
|
|||||||
RechargeBoostEndMS: config.RechargeBoostWindowMS,
|
RechargeBoostEndMS: config.RechargeBoostWindowMS,
|
||||||
MissProtectionZeroDraws: config.LossStreakGuarantee,
|
MissProtectionZeroDraws: config.LossStreakGuarantee,
|
||||||
DailyJackpotLimit: config.MaxJackpotHitsPerUserDay,
|
DailyJackpotLimit: config.MaxJackpotHitsPerUserDay,
|
||||||
MilestoneSpendCoins: 0,
|
MilestoneSpendCoins: config.JackpotSpendThresholdCoins,
|
||||||
JackpotMechanism1Enabled: true,
|
JackpotMechanism1Enabled: true,
|
||||||
JackpotMechanism2Enabled: false,
|
JackpotMechanism2Enabled: config.JackpotSpendThresholdCoins > 0,
|
||||||
GlobalRTPMaxPPM: config.JackpotGlobalRTPMaxPPM,
|
GlobalRTPMaxPPM: config.JackpotGlobalRTPMaxPPM,
|
||||||
UserRoundRTPMaxPPM: config.JackpotUserRoundRTPMaxPPM,
|
UserRoundRTPMaxPPM: config.JackpotUserRoundRTPMaxPPM,
|
||||||
User48HourRTPMaxPPM: config.JackpotUser48hRTPMaxPPM,
|
User48HourRTPMaxPPM: config.JackpotUser48hRTPMaxPPM,
|
||||||
|
|||||||
@ -78,9 +78,12 @@ type luckyUserState struct {
|
|||||||
CumulativeWagerCoins int64
|
CumulativeWagerCoins int64
|
||||||
EquivalentDraws int64
|
EquivalentDraws int64
|
||||||
LossStreak int64
|
LossStreak int64
|
||||||
// PendingJackpotTokens 是按 UTC 日消费赚取、但允许跨日等待资金的未消费大奖资格。
|
// PendingJackpotTokens is the retired token column. Dynamic V3 reads it only so the next paid
|
||||||
// 它属于 pool/user 生命周期,不能和“当天消费/当天命中次数”一起在午夜静默清零。
|
// transaction can clear it; its value must never become a new spend jackpot qualification.
|
||||||
PendingJackpotTokens int64
|
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 {
|
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) {
|
func (r *Repository) getLuckyUserState(ctx context.Context, appCode string, userID int64, giftID string) (luckyUserState, error) {
|
||||||
var state luckyUserState
|
var state luckyUserState
|
||||||
err := r.db.QueryRowContext(ctx, `
|
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
|
FROM lucky_user_states
|
||||||
WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
|
WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
|
||||||
appCode, userID, giftID,
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return luckyUserState{}, nil
|
return luckyUserState{}, nil
|
||||||
}
|
}
|
||||||
@ -2438,8 +2443,8 @@ func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx,
|
|||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO lucky_user_states (
|
INSERT INTO lucky_user_states (
|
||||||
app_code, user_id, gift_id, paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak,
|
app_code, user_id, gift_id, paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak,
|
||||||
pending_jackpot_tokens, created_at_ms, updated_at_ms
|
pending_jackpot_tokens, pending_spend_jackpot_tokens, created_at_ms, updated_at_ms
|
||||||
) VALUES (?, ?, ?, 0, 0, 0, 0, 0, ?, ?)
|
) VALUES (?, ?, ?, 0, 0, 0, 0, 0, 0, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
|
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
|
||||||
appCode, userID, giftID, nowMS, nowMS,
|
appCode, userID, giftID, nowMS, nowMS,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@ -2447,12 +2452,14 @@ func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx,
|
|||||||
}
|
}
|
||||||
var state luckyUserState
|
var state luckyUserState
|
||||||
err := tx.QueryRowContext(ctx, `
|
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
|
FROM lucky_user_states
|
||||||
WHERE app_code = ? AND user_id = ? AND gift_id = ?
|
WHERE app_code = ? AND user_id = ? AND gift_id = ?
|
||||||
FOR UPDATE`,
|
FOR UPDATE`,
|
||||||
appCode, userID, giftID,
|
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
|
return state, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -153,6 +153,14 @@ func TestDynamicStrategyMigrationIsIdempotent(t *testing.T) {
|
|||||||
// initdb 已包含最终结构,004 连续运行两次仍必须成功,覆盖全新环境与线上重复发布 migration 两条路径。
|
// initdb 已包含最终结构,004 连续运行两次仍必须成功,覆盖全新环境与线上重复发布 migration 两条路径。
|
||||||
runLuckyGiftSQLScript(t, schema.DB, migrationPath)
|
runLuckyGiftSQLScript(t, schema.DB, migrationPath)
|
||||||
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{
|
for _, table := range []string{
|
||||||
"lucky_risk_counters", "lucky_user_rtp_hour_buckets", "lucky_user_rtp_boundary_events",
|
"lucky_risk_counters", "lucky_user_rtp_hour_buckets", "lucky_user_rtp_boundary_events",
|
||||||
@ -171,6 +179,7 @@ func TestDynamicStrategyMigrationIsIdempotent(t *testing.T) {
|
|||||||
minLength int64
|
minLength int64
|
||||||
}{
|
}{
|
||||||
{table: "lucky_user_states", name: "pending_jackpot_tokens"},
|
{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_rtp_windows", name: "scope_id", minLength: 128},
|
||||||
{table: "lucky_risk_counters", name: "scope_id", minLength: 255},
|
{table: "lucky_risk_counters", name: "scope_id", minLength: 255},
|
||||||
{table: "lucky_gift_command_locks", name: "command_id", minLength: 128},
|
{table: "lucky_gift_command_locks", name: "command_id", minLength: 128},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user