diff --git a/src/features/lucky-gift/configModel.js b/src/features/lucky-gift/configModel.js index 3f0b9a1..d64cba8 100644 --- a/src/features/lucky-gift/configModel.js +++ b/src/features/lucky-gift/configModel.js @@ -130,12 +130,16 @@ export function addTierToStage(stages, stageKey) { if (stage.stage !== stageKey) { return stage; } - const index = stage.tiers.length + 1; + const nextMultiplier = nextSuggestedTierMultiplier(stage.tiers); return { ...stage, tiers: [ ...stage.tiers, - tier(`${stage.stage}_${index}x`, "", "", { broadcastLevel: "none", rewardSource: "base_rtp" }), + tier(tierIDFromMultiplier(stage.stage, nextMultiplier), nextMultiplier, "", { + broadcastLevel: "none", + highWaterOnly: nextMultiplier >= 5, + rewardSource: "base_rtp", + }), ], }; }); @@ -231,6 +235,28 @@ function defaultStages() { return stageOptions.map(([stage]) => ({ stage, tiers: defaultStageTiers[stage].map((item) => ({ ...item })) })); } +function nextSuggestedTierMultiplier(tiers) { + const usedMultipliers = (tiers || []) + .map((item) => decimal(item.multiplier)) + .filter((value) => Number.isFinite(value) && value > 0); + if (usedMultipliers.length === 0) { + return 1; + } + const maxMultiplier = Math.max(...usedMultipliers); + if (maxMultiplier >= 5) { + return maxMultiplier * 2; + } + if (maxMultiplier >= 2) { + return 5; + } + return maxMultiplier + 1; +} + +function tierIDFromMultiplier(stage, multiplier) { + const multiplierText = String(multiplier).replace(".", "_"); + return `${stage}_${multiplierText}x`; +} + function normalizeStages(stages) { // server 返回缺阶段时仍用默认三阶段补齐表单,保证运营看到的是完整发布形态; // 保存时 schema 会再次校验概率合计和 0x 档,不让半成品配置进入发布请求。 diff --git a/src/features/lucky-gift/configModel.test.js b/src/features/lucky-gift/configModel.test.js index b509263..c0db68c 100644 --- a/src/features/lucky-gift/configModel.test.js +++ b/src/features/lucky-gift/configModel.test.js @@ -56,7 +56,13 @@ describe("lucky gift config payload", () => { const advanced = nextStages.find((stage) => stage.stage === "advanced"); expect(advanced.tiers).toHaveLength(5); - expect(advanced.tiers[4]).toMatchObject({ broadcastLevel: "none", rewardSource: "base_rtp" }); + expect(advanced.tiers[4]).toMatchObject({ + broadcastLevel: "none", + highWaterOnly: true, + multiplier: 10, + rewardSource: "base_rtp", + tierId: "advanced_10x", + }); }); test("corrects a stage probability to requested RTP without zeroing enabled tiers", () => { diff --git a/src/features/lucky-gift/schema.test.ts b/src/features/lucky-gift/schema.test.ts index 005a6ea..28bca55 100644 --- a/src/features/lucky-gift/schema.test.ts +++ b/src/features/lucky-gift/schema.test.ts @@ -18,4 +18,28 @@ describe("lucky gift config schema", () => { expect(result.error.issues.map((issue) => issue.message)).toContain("启用奖档概率合计必须等于 100%"); } }); + + test("rejects duplicated tier id inside the same stage before submit", () => { + const form = emptyLuckyGiftForm(); + const advanced = form.stages.find((stage: { stage: string }) => stage.stage === "advanced"); + if (!advanced) { + throw new Error("advanced stage missing"); + } + advanced.tiers = [ + ...advanced.tiers, + { + ...advanced.tiers[advanced.tiers.length - 1], + multiplier: 10, + probabilityPercent: 0, + tierId: "advanced_5x", + }, + ]; + + const result = luckyGiftConfigFormSchema.safeParse(form); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.map((issue) => issue.message)).toContain("同一阶段内奖档 ID 不能重复"); + } + }); }); diff --git a/src/features/lucky-gift/schema.ts b/src/features/lucky-gift/schema.ts index 34f6aeb..de949d0 100644 --- a/src/features/lucky-gift/schema.ts +++ b/src/features/lucky-gift/schema.ts @@ -93,6 +93,18 @@ export const luckyGiftConfigFormSchema = z path: ["stages", stageIndex, "tiers"], }); } + const tierIDSet = new Set(); + stage.tiers.forEach((tier, tierIndex) => { + const tierID = String(tier.tierId || "").trim(); + if (tierIDSet.has(tierID)) { + context.addIssue({ + code: "custom", + message: "同一阶段内奖档 ID 不能重复", + path: ["stages", stageIndex, "tiers", tierIndex, "tierId"], + }); + } + tierIDSet.add(tierID); + }); const expectedRTPPPM = stage.tiers.reduce((sum, tier) => { if (!tier.enabled || tier.rewardSource !== "base_rtp") { return sum;