291 lines
11 KiB
JavaScript
291 lines
11 KiB
JavaScript
import { describe, expect, test } from "vitest";
|
|
import { applyDynamicStrategyDefaults, configToForm, formToConfig, validateLuckyGiftForm } from "./configForm.js";
|
|
|
|
describe("ops center lucky gift dynamic_v3 form", () => {
|
|
test("loads safe dynamic defaults without inventing app-specific monetary caps", () => {
|
|
const form = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" });
|
|
|
|
expect(form).toMatchObject({
|
|
anchor_rate_percent: "1",
|
|
enabled: false,
|
|
initial_pool_coins: "0",
|
|
jackpot_multipliers: "200, 500, 1000",
|
|
max_single_payout: "0",
|
|
pool_rate_percent: "98",
|
|
profit_rate_percent: "1",
|
|
strategy_version: "dynamic_v3",
|
|
target_rtp_percent: "98",
|
|
});
|
|
expect(form.stages.map((stage) => [stage.min_recharge_7d_coins, stage.min_recharge_30d_coins])).toEqual([
|
|
["0", "0"],
|
|
["0", "0"],
|
|
["0", "0"],
|
|
]);
|
|
expect(form.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([
|
|
[5, 4, 86, 5],
|
|
[5, 4, 86, 5],
|
|
[5, 4, 86, 5],
|
|
]);
|
|
});
|
|
|
|
test("round-trips every dynamic strategy field and canonical stage keys", () => {
|
|
const config = completeDynamicConfig();
|
|
// 使用空原对象可证明每个字段都真正经过 formToConfig 写回;若沿用 config 作为 spread 基底,
|
|
// 漏掉的动态字段也会被旧值掩盖,测试无法发现发布 payload 的字段丢失。
|
|
const output = formToConfig({}, configToForm(config));
|
|
|
|
expect(output).toEqual(config);
|
|
expect(output.stages[1]).toMatchObject({
|
|
min_recharge_30d_coins: 20_000,
|
|
min_recharge_7d_coins: 10_000,
|
|
stage: "normal",
|
|
});
|
|
expect(output.stages[1].tiers[0]).toEqual({
|
|
enabled: true,
|
|
high_water_only: false,
|
|
multiplier: 0,
|
|
probability_percent: 5,
|
|
tier_id: "normal_none",
|
|
});
|
|
});
|
|
|
|
test("drops legacy dynamic initial seed when loading and publishing a new version", () => {
|
|
const legacy = completeDynamicConfig();
|
|
legacy.initial_pool_coins = 1_000_000;
|
|
legacy.stages[0].min_recharge_7d_coins = 12_345;
|
|
legacy.stages[0].min_recharge_30d_coins = 67_890;
|
|
|
|
const form = configToForm(legacy);
|
|
expect(form.initial_pool_coins).toBe("0");
|
|
expect(form.stages[0]).toMatchObject({ min_recharge_7d_coins: "0", min_recharge_30d_coins: "0" });
|
|
const output = formToConfig(legacy, form);
|
|
expect(output.initial_pool_coins).toBe(0);
|
|
expect(output.stages[0]).toMatchObject({ min_recharge_7d_coins: 0, min_recharge_30d_coins: 0 });
|
|
});
|
|
|
|
test("allows disabled drafts but blocks enabled dynamic rules until app monetary limits are filled", () => {
|
|
const draft = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" });
|
|
expect(validateLuckyGiftForm(draft)).toEqual([]);
|
|
|
|
const enabled = { ...draft, enabled: true };
|
|
const errors = validateLuckyGiftForm(enabled);
|
|
|
|
expect(errors).toContain("启用前普通阶段至少一个最低充值门槛必须大于 0");
|
|
expect(errors).toContain("高阶充值门槛必须逐维不低于正常阶段且至少一项更高");
|
|
expect(errors).toContain("启用前必须填写单次返奖上限");
|
|
expect(errors).toContain("启用前必须填写主播每日上限");
|
|
});
|
|
|
|
test("keeps fixed_v2 readable and does not apply dynamic-only validation", () => {
|
|
const form = 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,
|
|
});
|
|
|
|
expect(form.strategy_version).toBe("fixed_v2");
|
|
expect(validateLuckyGiftForm(form)).toEqual([]);
|
|
});
|
|
|
|
test("switching a legacy form to dynamic creates a disabled safe draft", () => {
|
|
const legacy = 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,
|
|
});
|
|
const dynamic = applyDynamicStrategyDefaults(legacy);
|
|
|
|
expect(dynamic).toMatchObject({
|
|
enabled: false,
|
|
initial_pool_coins: "0",
|
|
pool_rate_percent: "98",
|
|
strategy_version: "dynamic_v3",
|
|
});
|
|
expect(dynamic.stages.map((stage) => stage.tiers.map((tier) => Number(tier.multiplier)))).toEqual([
|
|
[0, 0.5, 1, 2],
|
|
[0, 0.5, 1, 2],
|
|
[0, 0.5, 1, 2],
|
|
]);
|
|
expect(dynamic.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([
|
|
[5, 4, 86, 5],
|
|
[5, 4, 86, 5],
|
|
[5, 4, 86, 5],
|
|
]);
|
|
expect(validateLuckyGiftForm(dynamic)).toEqual([]);
|
|
});
|
|
|
|
test("rejects invalid disabled draft values that the owner never accepts", () => {
|
|
const draft = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" });
|
|
const errors = validateLuckyGiftForm({
|
|
...draft,
|
|
jackpot_spend_threshold_coins: "-1",
|
|
max_single_payout: "-1",
|
|
profit_rate_percent: "-1",
|
|
anchor_rate_percent: "2",
|
|
stages: draft.stages.map((stage) =>
|
|
stage.stage === "normal" ? { ...stage, min_recharge_7d_coins: "-1" } : stage,
|
|
),
|
|
});
|
|
|
|
expect(errors).toContain("平台盈利和主播收益比例不能小于 0");
|
|
expect(errors).toContain("大奖消费门槛不能小于 0");
|
|
expect(errors).toContain("单次返奖上限不能小于 0");
|
|
expect(errors).toContain("充值阶段门槛不能小于 0");
|
|
});
|
|
|
|
test("keeps ordinary and jackpot paths independent when they use the same multiplier", () => {
|
|
const form = configToForm(completeDynamicConfig());
|
|
const overlapping = {
|
|
...form,
|
|
stages: form.stages.map((stage) =>
|
|
stage.stage === "normal"
|
|
? {
|
|
...stage,
|
|
tiers: [
|
|
...stage.tiers.map((tier, index) =>
|
|
index === 0 ? { ...tier, probabilityPercent: "4.99" } : tier,
|
|
),
|
|
{
|
|
enabled: true,
|
|
highWaterOnly: true,
|
|
multiplier: "200",
|
|
probabilityPercent: "0.01",
|
|
tierId: "normal_200x",
|
|
},
|
|
],
|
|
}
|
|
: stage,
|
|
),
|
|
};
|
|
|
|
expect(validateLuckyGiftForm(overlapping)).toEqual([]);
|
|
const output = formToConfig({}, overlapping);
|
|
expect(output.jackpot_multipliers).toEqual([200, 500, 1000]);
|
|
expect(output.stages.find((stage) => stage.stage === "normal")?.tiers).toContainEqual({
|
|
enabled: true,
|
|
high_water_only: true,
|
|
multiplier: 200,
|
|
probability_percent: 0.01,
|
|
tier_id: "normal_200x",
|
|
});
|
|
|
|
// 放开同倍率不等于放开高倍率安全要求;普通 200x 未确认安全标记时仍必须阻断。
|
|
const unsafe = {
|
|
...overlapping,
|
|
stages: overlapping.stages.map((stage) =>
|
|
stage.stage === "normal"
|
|
? {
|
|
...stage,
|
|
tiers: stage.tiers.map((tier) =>
|
|
Number(tier.multiplier) === 200 ? { ...tier, highWaterOnly: false } : tier,
|
|
),
|
|
}
|
|
: stage,
|
|
),
|
|
};
|
|
expect(validateLuckyGiftForm(unsafe)).toContain("正常阶段10x 及以上奖档必须限制为高水位");
|
|
});
|
|
});
|
|
|
|
function completeDynamicConfig() {
|
|
return {
|
|
anchor_daily_payout_cap: 6_000_000,
|
|
anchor_rate_percent: 1,
|
|
app_code: "lalu",
|
|
control_band_percent: 3,
|
|
device_daily_payout_cap: 4_000_000,
|
|
effective_from_ms: 0,
|
|
enabled: true,
|
|
gift_price_reference: 100,
|
|
high_water_nonzero_factor_percent: 130,
|
|
high_watermark_coins: 20_000_000,
|
|
initial_pool_coins: 0,
|
|
jackpot_global_rtp_max_percent: 98,
|
|
jackpot_multipliers: [200, 500, 1000],
|
|
jackpot_spend_threshold_coins: 50_000,
|
|
jackpot_user_72h_rtp_max_percent: 96,
|
|
jackpot_user_day_rtp_max_percent: 96,
|
|
loss_streak_guarantee: 5,
|
|
low_water_nonzero_factor_percent: 70,
|
|
low_watermark_coins: 10_000_000,
|
|
max_jackpot_hits_per_user_day: 5,
|
|
max_single_payout: 1_000_000,
|
|
normal_max_equivalent_draws: 20_000,
|
|
novice_max_equivalent_draws: 2_000,
|
|
pool_id: "default",
|
|
pool_rate_percent: 98,
|
|
profit_rate_percent: 1,
|
|
recharge_boost_factor_percent: 110,
|
|
recharge_boost_window_ms: 300_000,
|
|
room_hourly_payout_cap: 5_000_000,
|
|
settlement_window_wager: 1_000_000,
|
|
stages: dynamicStages(),
|
|
strategy_version: "dynamic_v3",
|
|
target_rtp_percent: 98,
|
|
user_daily_payout_cap: 3_000_000,
|
|
user_hourly_payout_cap: 2_000_000,
|
|
};
|
|
}
|
|
|
|
function dynamicStages() {
|
|
return [
|
|
dynamicStage("novice", 0, 0),
|
|
dynamicStage("normal", 10_000, 20_000),
|
|
dynamicStage("advanced", 20_000, 30_000),
|
|
];
|
|
}
|
|
|
|
function dynamicStage(stage, min7d, min30d) {
|
|
return {
|
|
min_recharge_30d_coins: min30d,
|
|
min_recharge_7d_coins: min7d,
|
|
stage,
|
|
tiers: [
|
|
tier(`${stage}_none`, 0, 5),
|
|
tier(`${stage}_0_5x`, 0.5, 4),
|
|
tier(`${stage}_1x`, 1, 86),
|
|
tier(`${stage}_2x`, 2, 5),
|
|
],
|
|
};
|
|
}
|
|
|
|
function fixedStages() {
|
|
return ["novice", "normal", "advanced"].map((stage) => ({
|
|
stage,
|
|
tiers: [
|
|
tier(`${stage}_none`, 0, 10),
|
|
tier(`${stage}_0_5x`, 0.5, 20),
|
|
tier(`${stage}_1x`, 1, 55),
|
|
tier(`${stage}_2x`, 2, 15),
|
|
],
|
|
}));
|
|
}
|
|
|
|
function tier(tierId, multiplier, probabilityPercent) {
|
|
return {
|
|
enabled: true,
|
|
high_water_only: false,
|
|
multiplier,
|
|
probability_percent: probabilityPercent,
|
|
tier_id: tierId,
|
|
};
|
|
}
|