merge: independent lucky gift prize paths

This commit is contained in:
zhx 2026-07-16 11:14:16 +08:00
commit d2d125c74d
8 changed files with 143 additions and 53 deletions

View File

@ -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",
},
],
},
],

View File

@ -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 });

View File

@ -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>

View File

@ -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: "不能小于 010 倍及以上必须勾选“高倍率安全标记”,实际支付仍不能超过奖池和返奖上限。",
constraint:
"不能小于 010 倍及以上必须勾选“高倍率安全标记”,实际支付仍不能超过奖池和返奖上限。若同倍率也列为独立大奖,本行仍按普通概率开奖,不需要大奖资格且不累计大奖次数。",
},
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 倍档;关闭这里只影响普通开奖,若同倍率另列为独立大奖,大奖机制仍可单独选中它。",
},
};

View File

@ -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="高倍率安全标记" />

View File

@ -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") ||

View File

@ -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 及以上奖档必须限制为高水位");
});
});

View File

@ -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);