ops-center 幸运礼物 V2 高水位加权配置项

返奖与资金分配区块新增 fixed_v2 专属三字段:加权阈值/系数/恢复水位,
含帮助文案与表单校验;dynamic_v3 序列化强制归零,owner 拒绝混填。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zhx 2026-07-17 11:24:28 +08:00
parent f120ffbc9b
commit 59fe249886
6 changed files with 171 additions and 5 deletions

View File

@ -1019,6 +1019,19 @@ function luckyGiftConfigPayload(config = {}) {
: {}), : {}),
user_daily_payout_cap: numberValue(config.user_daily_payout_cap ?? config.userDailyPayoutCap), user_daily_payout_cap: numberValue(config.user_daily_payout_cap ?? config.userDailyPayoutCap),
user_hourly_payout_cap: numberValue(config.user_hourly_payout_cap ?? config.userHourlyPayoutCap), user_hourly_payout_cap: numberValue(config.user_hourly_payout_cap ?? config.userHourlyPayoutCap),
// V2 高水位加权是 fixed_v2 专属dynamic_v3 有独立水位因子owner 会拒绝混填,这里直接归零。
v2_high_water_boost_threshold_coins:
strategyVersion === "dynamic_v3"
? 0
: numberValue(config.v2_high_water_boost_threshold_coins ?? config.v2HighWaterBoostThresholdCoins),
v2_high_water_boost_factor_percent:
strategyVersion === "dynamic_v3"
? 0
: numberValue(config.v2_high_water_boost_factor_percent ?? config.v2HighWaterBoostFactorPercent),
v2_high_water_boost_recover_coins:
strategyVersion === "dynamic_v3"
? 0
: numberValue(config.v2_high_water_boost_recover_coins ?? config.v2HighWaterBoostRecoverCoins),
target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent), target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent),
}; };
} }

View File

@ -322,6 +322,10 @@ test("saves lucky gift config through admin ops route", async () => {
target_rtp_percent: 98, target_rtp_percent: 98,
user_daily_payout_cap: 615_600, user_daily_payout_cap: 615_600,
user_hourly_payout_cap: 34_200, user_hourly_payout_cap: 34_200,
// dynamic_v3 不解释 V2 高水位加权HTTP payload 契约要求三个字段显式归零owner 拒绝混填。
v2_high_water_boost_factor_percent: 0,
v2_high_water_boost_recover_coins: 0,
v2_high_water_boost_threshold_coins: 0,
}); });
}); });

View File

@ -295,6 +295,38 @@ export function LuckyGiftConfigSections({ appOptions, experimentMode = false, fo
<p className="ops-field-note"> <p className="ops-field-note">
发布前请核对收礼对象收益与实际送礼返币比例不一致时V3 真实开奖会拒绝账务结算 发布前请核对收礼对象收益与实际送礼返币比例不一致时V3 真实开奖会拒绝账务结算
</p> </p>
{form.strategy_version !== "dynamic_v3" ? (
<>
<div className="ops-form-grid">
<NumberField
form={form}
label="高水位加权阈值(金币)"
name="v2_high_water_boost_threshold_coins"
required={false}
onChange={onChange}
/>
<NumberField
form={form}
label="高水位加权系数 (%)"
name="v2_high_water_boost_factor_percent"
required={false}
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="加权恢复水位(金币)"
name="v2_high_water_boost_recover_coins"
required={false}
onChange={onChange}
/>
</div>
<p className="ops-field-note">
V2 高水位加权奖池水位越过阈值后中奖返奖按系数放大150 表示 RTP
变为原来的 150%直到水位跌破恢复水位才恢复正常阈值填 0 表示关闭
</p>
</>
) : null}
</section> </section>
{form.strategy_version === "dynamic_v3" ? ( {form.strategy_version === "dynamic_v3" ? (

View File

@ -105,6 +105,24 @@ const FIELD_HELP = {
adjust: "调高会更快释放奖池、增加正奖机会;调低则更接近正常节奏。", adjust: "调高会更快释放奖池、增加正奖机会;调低则更接近正常节奏。",
constraint: "必须大于 100%;放大后会重新归一化,并继续受奖池和返奖上限限制;它不会直接提高大奖触发机会。", constraint: "必须大于 100%;放大后会重新归一化,并继续受奖池和返奖上限限制;它不会直接提高大奖触发机会。",
}, },
v2_high_water_boost_threshold_coins: {
summary:
"仅 V2 策略生效:本抽应进入奖池的金币先入账;入账后水位高于这个值时进入加权状态,中奖返奖金额按加权系数放大。",
adjust: "调低会更早触发加速返还;调高会让奖池积累更多后才开始放大返奖。",
constraint: "填 0 表示关闭;启用时必须大于 0且加权系数与恢复水位都必须一并配置。",
},
v2_high_water_boost_factor_percent: {
summary:
"加权状态下中奖返奖金额放大到多少。填 150 表示每笔中奖金额与整体 RTP 都变为原来的 150%2 倍中奖实付 3 倍)。",
adjust: "调高会更快消化奖池积压,也会让玩家短期收益更高;调低则更温和。",
constraint: "必须大于 100% 且不超过 1000%;放大后的返奖仍受奖池支付能力限制,不会透支。",
},
v2_high_water_boost_recover_coins: {
summary:
"退出加权的水位下沿:一旦触发加权,即使水位回落到阈值以下仍继续放大,直到水位低于这个值才恢复正常返奖。",
adjust: "调低会让加权持续更久、消化更彻底;调高会更快恢复正常节奏。",
constraint: "必须大于 0 且不高于加权阈值;与阈值相等表示不留滞回区间。",
},
recharge_boost_window_ms: { recharge_boost_window_ms: {
summary: "用户完成一次可验证充值后,普通开奖中非 0 倍档的机会加成持续多久。300000 毫秒等于 5 分钟。", summary: "用户完成一次可验证充值后,普通开奖中非 0 倍档的机会加成持续多久。300000 毫秒等于 5 分钟。",
adjust: "调大会延长充值后的加成时间;调小会让加成更快结束。", adjust: "调大会延长充值后的加成时间;调小会让加成更快结束。",

View File

@ -97,6 +97,9 @@ const numericFields = [
"user_24h_rtp_threshold_percent", "user_24h_rtp_threshold_percent",
"user_daily_payout_cap", "user_daily_payout_cap",
"user_hourly_payout_cap", "user_hourly_payout_cap",
"v2_high_water_boost_factor_percent",
"v2_high_water_boost_recover_coins",
"v2_high_water_boost_threshold_coins",
]; ];
const payoutCapFields = [ const payoutCapFields = [
@ -209,10 +212,11 @@ export function formToConfig(config, form) {
strategy_version: form.strategy_version, strategy_version: form.strategy_version,
}; };
for (const field of numericFields) { for (const field of numericFields) {
output[field] = // dynamic_v3 强制归零隐式初始注资已废弃V2 高水位加权字段属于 fixed_v2 专属owner 会拒绝混填。
field === "initial_pool_coins" && form.strategy_version === DYNAMIC_STRATEGY const forcedZero =
? 0 form.strategy_version === DYNAMIC_STRATEGY &&
: numberFromForm(form[field]); (field === "initial_pool_coins" || field.startsWith("v2_high_water_boost"));
output[field] = forcedZero ? 0 : numberFromForm(form[field]);
} }
if ( if (
form.strategy_version === DYNAMIC_STRATEGY && form.strategy_version === DYNAMIC_STRATEGY &&
@ -357,7 +361,23 @@ export function validateLuckyGiftForm(form) {
errors.push(`${stageLabel(stageKey)}期望 RTP 超出控制带宽`); errors.push(`${stageLabel(stageKey)}期望 RTP 超出控制带宽`);
} }
if (form.strategy_version !== DYNAMIC_STRATEGY) return unique(errors); if (form.strategy_version !== DYNAMIC_STRATEGY) {
// V2 高水位加权三元组:阈值 0 表示关闭且另两项必须为 0启用时系数放大 RTP恢复水位构成滞回下沿。
const boostThreshold = number("v2_high_water_boost_threshold_coins");
const boostFactor = number("v2_high_water_boost_factor_percent");
const boostRecover = number("v2_high_water_boost_recover_coins");
if (boostThreshold === 0) {
if (boostFactor !== 0 || boostRecover !== 0)
errors.push("关闭 V2 高水位加权时,加权系数与恢复水位必须为 0");
} else {
if (boostThreshold < 0) errors.push("V2 高水位加权阈值不能小于 0");
if (boostFactor <= 100 || boostFactor > 1000)
errors.push("V2 高水位加权系数必须大于 100% 且不超过 1000%");
if (boostRecover <= 0 || boostRecover > boostThreshold)
errors.push("V2 加权恢复水位必须大于 0 且不高于加权阈值");
}
return unique(errors);
}
if (number("profit_rate_percent") < 0 || number("anchor_rate_percent") < 0) if (number("profit_rate_percent") < 0 || number("anchor_rate_percent") < 0)
errors.push("平台盈利和主播收益比例不能小于 0"); errors.push("平台盈利和主播收益比例不能小于 0");

View File

@ -243,6 +243,10 @@ function completeDynamicConfig() {
target_rtp_percent: 98, target_rtp_percent: 98,
user_daily_payout_cap: 3_000_000, user_daily_payout_cap: 3_000_000,
user_hourly_payout_cap: 2_000_000, user_hourly_payout_cap: 2_000_000,
// dynamic_v3 不解释 V2 高水位加权;序列化契约要求这三个字段始终显式归零回传。
v2_high_water_boost_factor_percent: 0,
v2_high_water_boost_recover_coins: 0,
v2_high_water_boost_threshold_coins: 0,
}; };
} }
@ -289,3 +293,78 @@ function tier(tierId, multiplier, probabilityPercent) {
tier_id: tierId, tier_id: tierId,
}; };
} }
describe("ops center lucky gift v2 high-water boost", () => {
const fixedForm = (overrides = {}) =>
configToForm({
app_code: "yumi",
control_band_percent: 1,
enabled: true,
gift_price_reference: 100,
normal_max_equivalent_draws: 20_000,
novice_max_equivalent_draws: 2_000,
pool_id: "legacy",
pool_rate_percent: 96,
settlement_window_wager: 1_000_000,
stages: fixedStages(),
strategy_version: "fixed_v2",
target_rtp_percent: 95,
...overrides,
});
test("round-trips the boost triple for fixed_v2", () => {
const form = fixedForm({
v2_high_water_boost_factor_percent: 150,
v2_high_water_boost_recover_coins: 1_000_000,
v2_high_water_boost_threshold_coins: 2_000_000,
});
expect(form.v2_high_water_boost_threshold_coins).toBe("2000000");
expect(form.v2_high_water_boost_factor_percent).toBe("150");
expect(form.v2_high_water_boost_recover_coins).toBe("1000000");
expect(validateLuckyGiftForm(form)).toEqual([]);
const config = formToConfig({}, form);
expect(config.v2_high_water_boost_threshold_coins).toBe(2_000_000);
expect(config.v2_high_water_boost_factor_percent).toBe(150);
expect(config.v2_high_water_boost_recover_coins).toBe(1_000_000);
});
test("legacy fixed_v2 snapshots without the fields stay disabled and valid", () => {
const form = fixedForm();
expect(form.v2_high_water_boost_threshold_coins).toBe("0");
expect(validateLuckyGiftForm(form)).toEqual([]);
});
test("rejects partial or out-of-range boost triples", () => {
const halfConfigured = fixedForm({ v2_high_water_boost_factor_percent: 150 });
expect(validateLuckyGiftForm(halfConfigured)).toContain("关闭 V2 高水位加权时,加权系数与恢复水位必须为 0");
const factorTooLow = fixedForm({
v2_high_water_boost_factor_percent: 100,
v2_high_water_boost_recover_coins: 1_000_000,
v2_high_water_boost_threshold_coins: 2_000_000,
});
expect(validateLuckyGiftForm(factorTooLow)).toContain("V2 高水位加权系数必须大于 100% 且不超过 1000%");
const recoverAboveThreshold = fixedForm({
v2_high_water_boost_factor_percent: 150,
v2_high_water_boost_recover_coins: 3_000_000,
v2_high_water_boost_threshold_coins: 2_000_000,
});
expect(validateLuckyGiftForm(recoverAboveThreshold)).toContain("V2 加权恢复水位必须大于 0 且不高于加权阈值");
});
test("switching to dynamic_v3 zeroes the fixed_v2-only fields on serialization", () => {
const boosted = fixedForm({
v2_high_water_boost_factor_percent: 150,
v2_high_water_boost_recover_coins: 1_000_000,
v2_high_water_boost_threshold_coins: 2_000_000,
});
const dynamic = applyDynamicStrategyDefaults(boosted);
const config = formToConfig({}, dynamic);
// owner 拒绝 dynamic_v3 携带 v2 加权字段;序列化必须归零,不能把 fixed_v2 的残值带进新策略版本。
expect(config.v2_high_water_boost_threshold_coins).toBe(0);
expect(config.v2_high_water_boost_factor_percent).toBe(0);
expect(config.v2_high_water_boost_recover_coins).toBe(0);
expect(validateLuckyGiftForm(dynamic)).toEqual([]);
});
});