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