feat(ops-center): support independent jackpot tiers
This commit is contained in:
parent
379703cd76
commit
17a8781920
@ -218,7 +218,16 @@ test("saves lucky gift config through admin ops route", async () => {
|
||||
min_recharge_30d_coins: 0,
|
||||
min_recharge_7d_coins: 0,
|
||||
stage: "novice",
|
||||
tiers: [{ multiplier: 1, probabilityPercent: 100, enabled: true }],
|
||||
tiers: [
|
||||
{ multiplier: 1, probabilityPercent: 99.99, enabled: true },
|
||||
{
|
||||
enabled: true,
|
||||
highWaterOnly: true,
|
||||
multiplier: 200,
|
||||
probabilityPercent: 0.01,
|
||||
tierId: "novice_200x",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
strategy_version: "dynamic_v3",
|
||||
@ -273,9 +282,16 @@ test("saves lucky gift config through admin ops route", async () => {
|
||||
enabled: true,
|
||||
high_water_only: false,
|
||||
multiplier: 1,
|
||||
probability_percent: 100,
|
||||
probability_percent: 99.99,
|
||||
tier_id: "",
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
high_water_only: true,
|
||||
multiplier: 200,
|
||||
probability_percent: 0.01,
|
||||
tier_id: "novice_200x",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -22,7 +22,7 @@ test("keeps every dynamic_v3 control reachable while locking rule-time pool inje
|
||||
"低水位金币",
|
||||
"高水位金币",
|
||||
"充值加权窗口 (ms)",
|
||||
"大奖倍率",
|
||||
"独立大奖倍率",
|
||||
"全局大奖 RTP 上限 (%)",
|
||||
"用户日累计消费触发门槛(金币)",
|
||||
"单次返奖上限",
|
||||
@ -33,6 +33,9 @@ test("keeps every dynamic_v3 control reachable while locking rule-time pool inje
|
||||
|
||||
expect(within(dialog).getAllByRole("tab")).toHaveLength(3);
|
||||
expect(within(dialog).getByRole("columnheader", { name: "倍率" })).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole("columnheader", { name: "普通中奖机会 %" })).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole("heading", { name: "独立大奖路径" })).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("4. 未成功才走普通开奖")).toBeInTheDocument();
|
||||
expect(within(dialog).getByLabelText("新手阶段第 1 档倍率")).toBeInTheDocument();
|
||||
expect(within(dialog).getByLabelText("新手阶段第 2 档概率 %")).toHaveAttribute("readonly");
|
||||
expect(within(dialog).getByRole("button", { name: "删除新手阶段第 1 档" })).toBeInTheDocument();
|
||||
@ -227,6 +230,52 @@ test("fixed_v2 hides only dynamic controls and preserves immutable snapshot fiel
|
||||
);
|
||||
});
|
||||
|
||||
test("saves an ordinary tier and the independent jackpot list when they share a multiplier", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const stages = stagesFor(98).map((stage) =>
|
||||
stage.stage === "normal"
|
||||
? {
|
||||
...stage,
|
||||
tiers: [
|
||||
{ ...stage.tiers[0], probability_percent: 1.99 },
|
||||
stage.tiers[1],
|
||||
{
|
||||
enabled: true,
|
||||
high_water_only: true,
|
||||
multiplier: 200,
|
||||
probability_percent: 0.01,
|
||||
tier_id: "normal_200x",
|
||||
},
|
||||
],
|
||||
}
|
||||
: stage,
|
||||
);
|
||||
renderDialog(dynamicConfig({ stages }), { onSubmit });
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
|
||||
|
||||
expect(within(dialog).queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
jackpot_multipliers: [200, 500, 1000],
|
||||
stages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
stage: "normal",
|
||||
tiers: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
high_water_only: true,
|
||||
multiplier: 200,
|
||||
probability_percent: 0.01,
|
||||
tier_id: "normal_200x",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("focuses the validation summary when the fixed action bar submission is rejected", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderDialog(dynamicConfig({ low_watermark_coins: 0 }), { onSubmit });
|
||||
|
||||
@ -354,14 +354,17 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
|
||||
data-config-section="jackpot"
|
||||
id="ops-config-section-jackpot"
|
||||
>
|
||||
<SectionHeading id="ops-config-heading-jackpot" title="大奖触发条件" />
|
||||
<SectionHeading id="ops-config-heading-jackpot" title="独立大奖路径" />
|
||||
<JackpotFlowSummary />
|
||||
<div className="ops-form-grid">
|
||||
<TextField
|
||||
className="ops-form-grid__wide"
|
||||
helperText="按从小到大填写,使用逗号分隔"
|
||||
helperText="按从小到大填写;可与普通奖档使用相同倍率"
|
||||
label={
|
||||
<LuckyGiftFieldLabel field="jackpot_multipliers" label="大奖倍率(可选倍数)" />
|
||||
<LuckyGiftFieldLabel
|
||||
field="jackpot_multipliers"
|
||||
label="独立大奖倍率(可选倍数)"
|
||||
/>
|
||||
}
|
||||
required
|
||||
size="small"
|
||||
@ -391,7 +394,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
|
||||
/>
|
||||
<NumberField
|
||||
form={form}
|
||||
label="每位用户每日大奖次数"
|
||||
label="每位用户每日独立大奖总次数"
|
||||
name="max_jackpot_hits_per_user_day"
|
||||
onChange={onChange}
|
||||
/>
|
||||
@ -475,17 +478,23 @@ function JackpotFlowSummary() {
|
||||
return (
|
||||
<div className="ops-jackpot-flow" aria-label="大奖产生方式">
|
||||
<div>
|
||||
<strong>1. 大盘先通过</strong>
|
||||
<span>本轮大盘 RTP 不高于全局触发线,才继续检查用户;等于触发线也可通过。</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>2. 用户两项都通过</strong>
|
||||
<span>用户本轮 RTP 和近 48 小时 RTP 都必须严格低于各自触发线,少一项都不能出大奖。</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>3. 下次送礼尝试大奖</strong>
|
||||
<strong>1. 检查独立大奖资格</strong>
|
||||
<span>
|
||||
用户下次送礼时,先剔除奖池或限额付不起的倍率,再随机选择;没有可支付倍率就不出大奖,本轮资格也不会顺延或重复使用。
|
||||
大盘 RTP 通过后,还要同时满足用户本轮、近 48 小时返还条件和当日大奖次数限制。
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>2. 尝试可支付大奖</strong>
|
||||
<span>资格通过后,从奖池余额和各项返奖上限都付得起的独立大奖倍率中选择一档。</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>3. 大奖成功直接结束</strong>
|
||||
<span>本抽直接采用独立大奖结果并累计一次大奖次数,不再叠加或继续抽普通奖档。</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>4. 未成功才走普通开奖</strong>
|
||||
<span>
|
||||
大奖资格未通过或没有可支付倍率时,才按当前阶段普通概率开奖;普通同倍率不需要大奖资格,也不计大奖次数。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -116,17 +116,18 @@ const FIELD_HELP = {
|
||||
constraint: "必须大于 100%,会与高低余额调整相乘;它只改变普通奖档,不会直接提高大奖触发机会。",
|
||||
},
|
||||
jackpot_multipliers: {
|
||||
summary: "列出大奖可能采用的倍数,例如 200、500、1000。实际奖励按本抽消费金额乘以倍数计算。",
|
||||
adjust: "增加或调高倍数会放大奖励金额和资金压力;删除倍数会减少可选大奖。",
|
||||
summary:
|
||||
"列出独立大奖路径可能采用的倍数,例如 200、500、1000。它不并入普通奖档概率:同一抽先尝试独立大奖,未成功才进入普通开奖。",
|
||||
adjust: "增加或调高倍数会放大独立大奖金额和资金压力;删除倍数只会减少可选大奖,不会删除普通奖档。",
|
||||
constraint:
|
||||
"用逗号分隔,必须都大于 1 且从小到大;系统会在所有付得起的大奖倍数中等机会选择一档。这里的倍率不能再作为普通奖档设置正概率,否则配置无法发布。",
|
||||
"用逗号分隔,必须都大于 1 且从小到大;同倍率可以同时出现在普通奖档中。这里的“独立”只指触发条件、中奖概率和每日大奖次数独立;两条路径的真实返奖仍共用当前奖池、RTP 统计和六项返奖上限。普通命中不计入大奖次数。",
|
||||
},
|
||||
jackpot_global_rtp_max_percent: {
|
||||
summary:
|
||||
"系统每次达到结算流水后,会结束并重置一轮大盘 RTP。刚结算的本轮大盘 RTP 不高于这条线时,才继续检查用户的两项返还条件。",
|
||||
adjust: "调高会让补偿大奖更容易进入候选;调低会要求全局返还更低才触发。",
|
||||
constraint:
|
||||
"必须大于 0% 且不超过 100%。大盘 RTP 等于本项配置值时可以通过;还必须同时满足用户本轮 RTP 严格低于本轮触发线、用户近 48 小时 RTP 严格低于长期触发线,且奖池和各项限额足够。闭窗资格只在用户下一次送礼时尝试一次,奖池或限额不足也不会顺延。",
|
||||
"必须大于 0% 且不超过 100%。大盘 RTP 等于本项配置值时可以通过;还必须同时满足用户本轮 RTP 严格低于本轮触发线、用户近 48 小时 RTP 严格低于长期触发线,且奖池和各项限额足够。它只控制独立大奖,不会阻止普通概率命中相同倍率。",
|
||||
},
|
||||
jackpot_user_round_rtp_max_percent: {
|
||||
summary:
|
||||
@ -143,10 +144,11 @@ const FIELD_HELP = {
|
||||
"先看注册时间:注册未满 48 小时直接不通过;注册已满 48 小时后,不要求连续玩满 48 小时,只统计窗口内真实发生的消费和返奖。即使前 47 小时都没玩、最近只玩 5 分钟,也用这 5 分钟的数据计算;整段时间没有消费时不通过。它还必须与大盘条件、用户本轮条件同时通过,并受每日大奖次数、奖池余额和所有返奖上限限制。",
|
||||
},
|
||||
max_jackpot_hits_per_user_day: {
|
||||
summary: "限制同一用户在当前奖池的一个 UTC 自然日内最多真正拿到多少次大奖。",
|
||||
adjust: "调高允许同一用户一天获得更多大奖;调低会更早停止该用户当天的大奖,不保证转给其他用户。",
|
||||
summary: "限制同一用户在当前奖池的一个 UTC 自然日内,通过独立大奖路径合计最多真正拿到多少次大奖。",
|
||||
adjust:
|
||||
"调高允许同一用户一天通过独立大奖路径获得更多奖励;调低会更早停止该用户当天的独立大奖,不保证转给其他用户。",
|
||||
constraint:
|
||||
"必须大于 0;达到次数后,即使大盘、本轮和近 48 小时 RTP 都通过也不再出大奖,但仍可参与普通奖档;下一个 UTC 自然日重新累计。",
|
||||
"必须大于 0;所有独立大奖倍率合计使用这个次数。达到后不再走独立大奖,但仍可按普通概率命中任何普通奖档,包括相同倍率;下一个 UTC 自然日重新累计。",
|
||||
},
|
||||
jackpot_spend_threshold_coins: {
|
||||
summary:
|
||||
@ -217,14 +219,17 @@ const FIELD_HELP = {
|
||||
constraint: "用户还必须同时达到 7 日门槛;新手阶段固定为 0。",
|
||||
},
|
||||
tier_multiplier: {
|
||||
summary: "这一档返还本抽消费金额的多少倍。0 倍表示没有返还,1 倍表示返还与本抽消费相同。",
|
||||
summary:
|
||||
"这一普通奖档返还本抽消费金额的多少倍。0 倍表示没有返还,1 倍表示返还与本抽消费相同。",
|
||||
adjust: "调高会增加这一档的奖励金额;系统会自动重算各档基础概率来尽量保持平均返还目标。",
|
||||
constraint: "不能小于 0;10 倍及以上必须勾选“高倍率安全标记”,实际支付仍不能超过奖池和返奖上限。",
|
||||
constraint:
|
||||
"不能小于 0;10 倍及以上必须勾选“高倍率安全标记”,实际支付仍不能超过奖池和返奖上限。若同倍率也列为独立大奖,本行仍按普通概率开奖,不需要大奖资格且不累计大奖次数。",
|
||||
},
|
||||
tier_probability: {
|
||||
summary: "表示在当前用户阶段中抽到这一档的基础机会,所有启用奖档必须合计 100%。",
|
||||
summary: "表示在当前用户阶段中,通过普通开奖抽到这一档的基础机会,所有启用奖档必须合计 100%。",
|
||||
adjust: "该值由平均返还目标和各档倍数自动计算,只读展示;修改倍数或目标后会重新计算。",
|
||||
constraint: "V3 会按水位和可信充值调整普通非 0 档,并受六项上限限制;V2 保持基础概率,只过滤奖池付不起的档位。",
|
||||
constraint:
|
||||
"V3 会按水位和可信充值调整普通非 0 档,并受六项上限限制;V2 保持基础概率,只过滤奖池付不起的档位。相同倍率是否也配置为独立大奖,不会合并或替代这里的概率。",
|
||||
},
|
||||
tier_high_water_only: {
|
||||
summary: "这是发布高倍率基础奖档前的安全确认标记;当前运行逻辑不会根据它判断奖池处于哪种水位。",
|
||||
@ -236,7 +241,7 @@ const FIELD_HELP = {
|
||||
summary: "决定这一档是否参加当前阶段的普通基础抽奖。关闭后不会从普通奖档中选中。",
|
||||
adjust: "关闭高倍率档会降低大额结果种类;重新开启后系统会重新计算整组概率。",
|
||||
constraint:
|
||||
"每个阶段启用档位的概率必须合计 100%,并至少保留一个 0 倍档;若同倍率另列在大奖倍率中,大奖机制仍可能选中它。",
|
||||
"每个阶段启用档位的概率必须合计 100%,并至少保留一个 0 倍档;关闭这里只影响普通开奖,若同倍率另列为独立大奖,大奖机制仍可单独选中它。",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -40,8 +40,8 @@ export function LuckyGiftStageDesigner({
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="ops-stage-designer" aria-label="阶段奖档设计">
|
||||
<div className="ops-stage-tabs" role="tablist" aria-label="阶段奖档">
|
||||
<section className="ops-stage-designer" aria-label="普通奖档设计">
|
||||
<div className="ops-stage-tabs" role="tablist" aria-label="普通奖档阶段">
|
||||
{stageSummaries.map((summary, index) => {
|
||||
const isActive = summary.stageKey === activeStageKey;
|
||||
return (
|
||||
@ -144,7 +144,7 @@ export function LuckyGiftStageDesigner({
|
||||
<LuckyGiftFieldLabel field="tier_multiplier" label="倍率" />
|
||||
</span>
|
||||
<span role="columnheader">
|
||||
<LuckyGiftFieldLabel field="tier_probability" label="基础中奖机会 %" />
|
||||
<LuckyGiftFieldLabel field="tier_probability" label="普通中奖机会 %" />
|
||||
</span>
|
||||
<span role="columnheader">
|
||||
<LuckyGiftFieldLabel field="tier_high_water_only" label="高倍率安全标记" />
|
||||
|
||||
@ -284,9 +284,6 @@ export function validateLuckyGiftForm(form) {
|
||||
)
|
||||
errors.push("等价抽数阶段门槛不正确");
|
||||
|
||||
const jackpotMultipliers =
|
||||
form.strategy_version === DYNAMIC_STRATEGY ? new Set(multiplierListFromForm(form.jackpot_multipliers)) : null;
|
||||
|
||||
// 阶段概率与 RTP 是两代策略共用的 owner 校验,fixed_v2 也不能跳过。
|
||||
for (const stageKey of ["novice", "normal", "advanced"]) {
|
||||
const stage = form.stages?.find((item) => item.stage === stageKey);
|
||||
@ -305,19 +302,6 @@ export function validateLuckyGiftForm(form) {
|
||||
errors.push(`${stageLabel(stageKey)}启用奖档概率必须大于 0`);
|
||||
if (enabled.some((tier) => Number(tier.multiplier) >= 10 && !tier.highWaterOnly))
|
||||
errors.push(`${stageLabel(stageKey)}10x 及以上奖档必须限制为高水位`);
|
||||
const jackpotStageMultipliers =
|
||||
jackpotMultipliers &&
|
||||
enabled
|
||||
.filter(
|
||||
(tier) =>
|
||||
jackpotMultipliers.has(numberFromForm(tier.multiplier)) &&
|
||||
percentToPPM(tier.probabilityPercent) > 0,
|
||||
)
|
||||
.map((tier) => formatDecimal(numberFromForm(tier.multiplier)));
|
||||
if (jackpotStageMultipliers?.length) {
|
||||
// dynamic_v3 的大奖倍率只能走大奖补偿路径;普通阶段再给正概率会绕过 owner 的双 RTP 门槛。
|
||||
errors.push(`${stageLabel(stageKey)}普通奖档不能包含大奖倍率:${unique(jackpotStageMultipliers).join("x、")}x`);
|
||||
}
|
||||
const expected = expectedRTPPercent(enabled, { multiplier: (item) => item.multiplier });
|
||||
if (
|
||||
expected < number("target_rtp_percent") - number("control_band_percent") ||
|
||||
|
||||
@ -151,16 +151,18 @@ describe("ops center lucky gift dynamic_v3 form", () => {
|
||||
expect(errors).toContain("充值阶段门槛不能小于 0");
|
||||
});
|
||||
|
||||
test("rejects jackpot multipliers that still have ordinary stage probability", () => {
|
||||
test("keeps ordinary and jackpot paths independent when they use the same multiplier", () => {
|
||||
const form = configToForm(completeDynamicConfig());
|
||||
const errors = validateLuckyGiftForm({
|
||||
const overlapping = {
|
||||
...form,
|
||||
stages: form.stages.map((stage) =>
|
||||
stage.stage === "normal"
|
||||
? {
|
||||
...stage,
|
||||
tiers: [
|
||||
...stage.tiers,
|
||||
...stage.tiers.map((tier, index) =>
|
||||
index === 0 ? { ...tier, probabilityPercent: "4.99" } : tier,
|
||||
),
|
||||
{
|
||||
enabled: true,
|
||||
highWaterOnly: true,
|
||||
@ -172,9 +174,34 @@ describe("ops center lucky gift dynamic_v3 form", () => {
|
||||
}
|
||||
: 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",
|
||||
});
|
||||
|
||||
expect(errors).toContain("正常阶段普通奖档不能包含大奖倍率: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 及以上奖档必须限制为高水位");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -447,7 +447,7 @@ body {
|
||||
|
||||
.ops-jackpot-flow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user