diff --git a/ops-center/src/api.test.js b/ops-center/src/api.test.js index 310c404..f8f0111 100644 --- a/ops-center/src/api.test.js +++ b/ops-center/src/api.test.js @@ -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", + }, ], }, ], diff --git a/ops-center/src/components/LuckyGiftConfigDialog.test.jsx b/ops-center/src/components/LuckyGiftConfigDialog.test.jsx index 2b54a32..1002ae1 100644 --- a/ops-center/src/components/LuckyGiftConfigDialog.test.jsx +++ b/ops-center/src/components/LuckyGiftConfigDialog.test.jsx @@ -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 }); diff --git a/ops-center/src/components/LuckyGiftConfigSections.jsx b/ops-center/src/components/LuckyGiftConfigSections.jsx index 2d8e00b..92df6c1 100644 --- a/ops-center/src/components/LuckyGiftConfigSections.jsx +++ b/ops-center/src/components/LuckyGiftConfigSections.jsx @@ -354,14 +354,17 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } data-config-section="jackpot" id="ops-config-section-jackpot" > - +
+ } required size="small" @@ -391,7 +394,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } /> @@ -475,17 +478,23 @@ function JackpotFlowSummary() { return (
- 1. 大盘先通过 - 本轮大盘 RTP 不高于全局触发线,才继续检查用户;等于触发线也可通过。 -
-
- 2. 用户两项都通过 - 用户本轮 RTP 和近 48 小时 RTP 都必须严格低于各自触发线,少一项都不能出大奖。 -
-
- 3. 下次送礼尝试大奖 + 1. 检查独立大奖资格 - 用户下次送礼时,先剔除奖池或限额付不起的倍率,再随机选择;没有可支付倍率就不出大奖,本轮资格也不会顺延或重复使用。 + 大盘 RTP 通过后,还要同时满足用户本轮、近 48 小时返还条件和当日大奖次数限制。 + +
+
+ 2. 尝试可支付大奖 + 资格通过后,从奖池余额和各项返奖上限都付得起的独立大奖倍率中选择一档。 +
+
+ 3. 大奖成功直接结束 + 本抽直接采用独立大奖结果并累计一次大奖次数,不再叠加或继续抽普通奖档。 +
+
+ 4. 未成功才走普通开奖 + + 大奖资格未通过或没有可支付倍率时,才按当前阶段普通概率开奖;普通同倍率不需要大奖资格,也不计大奖次数。
diff --git a/ops-center/src/components/LuckyGiftFieldHelp.jsx b/ops-center/src/components/LuckyGiftFieldHelp.jsx index 84bbddd..4db296e 100644 --- a/ops-center/src/components/LuckyGiftFieldHelp.jsx +++ b/ops-center/src/components/LuckyGiftFieldHelp.jsx @@ -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 倍档;关闭这里只影响普通开奖,若同倍率另列为独立大奖,大奖机制仍可单独选中它。", }, }; diff --git a/ops-center/src/components/LuckyGiftStageDesigner.jsx b/ops-center/src/components/LuckyGiftStageDesigner.jsx index 2878b14..0c3b6d4 100644 --- a/ops-center/src/components/LuckyGiftStageDesigner.jsx +++ b/ops-center/src/components/LuckyGiftStageDesigner.jsx @@ -40,8 +40,8 @@ export function LuckyGiftStageDesigner({ }; return ( -
-
+
+
{stageSummaries.map((summary, index) => { const isActive = summary.stageKey === activeStageKey; return ( @@ -144,7 +144,7 @@ export function LuckyGiftStageDesigner({ - + diff --git a/ops-center/src/configForm.js b/ops-center/src/configForm.js index 316b750..1c8de77 100644 --- a/ops-center/src/configForm.js +++ b/ops-center/src/configForm.js @@ -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") || diff --git a/ops-center/src/configForm.test.js b/ops-center/src/configForm.test.js index 1c302dd..787fb84 100644 --- a/ops-center/src/configForm.test.js +++ b/ops-center/src/configForm.test.js @@ -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 及以上奖档必须限制为高水位"); }); }); diff --git a/ops-center/src/styles/index.css b/ops-center/src/styles/index.css index 636911b..10312cd 100644 --- a/ops-center/src/styles/index.css +++ b/ops-center/src/styles/index.css @@ -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);