From 3900866df0b47f405dd9599721cc5ba9004e8aef Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 12 Jun 2026 23:28:04 +0800 Subject: [PATCH] fix: auto generate lucky gift tier ids --- .../components/LuckyGiftConfigDrawer.jsx | 43 ++++--- src/features/lucky-gift/configModel.js | 111 ++++++++++++------ src/features/lucky-gift/configModel.test.js | 25 ++++ src/features/lucky-gift/schema.test.ts | 9 +- src/features/lucky-gift/schema.ts | 29 ++--- 5 files changed, 143 insertions(+), 74 deletions(-) diff --git a/src/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx b/src/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx index 1fd0ffd..0699b16 100644 --- a/src/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx +++ b/src/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx @@ -20,7 +20,12 @@ import { tierRTPContribution, updateStageTier, } from "@/features/lucky-gift/configModel.js"; -import { broadcastLevelOptions, rewardSourceOptions, stageOptions, optionLabel } from "@/features/lucky-gift/constants.js"; +import { + broadcastLevelOptions, + rewardSourceOptions, + stageOptions, + optionLabel, +} from "@/features/lucky-gift/constants.js"; import styles from "@/features/lucky-gift/lucky-gift.module.css"; const helpText = { @@ -210,7 +215,12 @@ export function LuckyGiftConfigDrawer({ - @@ -229,7 +239,10 @@ function StageTierEditor({ disabled, form, setForm }) { return (
{stageOptions.map(([stageKey, stageLabel]) => { - const stage = (form.stages || []).find((item) => item.stage === stageKey) || { stage: stageKey, tiers: [] }; + const stage = (form.stages || []).find((item) => item.stage === stageKey) || { + stage: stageKey, + tiers: [], + }; const expectedRTP = stageExpectedRTP(stage); const lowerRTP = Number(form.targetRtpPercent || 0) - Number(form.controlBandPercent || 0); const upperRTP = Number(form.targetRtpPercent || 0) + Number(form.controlBandPercent || 0); @@ -239,7 +252,9 @@ function StageTierEditor({ disabled, form, setForm }) {
- 概率 {formatPercent(stageProbabilityTotal(stage))} + + 概率 {formatPercent(stageProbabilityTotal(stage))} + 期望 RTP {formatPercent(expectedRTP)} @@ -261,7 +276,7 @@ function StageTierEditor({ disabled, form, setForm }) { type="button" onClick={() => updateStages(setForm, (stages) => - correctStageTierProbabilities(stages, stageKey, stageTargetRTP[stageKey]) + correctStageTierProbabilities(stages, stageKey, stageTargetRTP[stageKey]), ) } > @@ -303,16 +318,10 @@ function defaultStageTargetRTP(targetRTPPercent) { } function TierRow({ disabled, setForm, stageKey, stageLabel, tier, tierCount, tierIndex }) { - const updateTier = (patch) => updateStages(setForm, (stages) => updateStageTier(stages, stageKey, tierIndex, patch)); + const updateTier = (patch) => + updateStages(setForm, (stages) => updateStageTier(stages, stageKey, tierIndex, patch)); return (
- updateTier({ tierId: event.target.value })} - /> onChange(event.target.value)}> + onChange(event.target.value)} + > {options.map(([optionValue, optionLabelText]) => ( {optionLabelText || optionLabel(options, optionValue)} diff --git a/src/features/lucky-gift/configModel.js b/src/features/lucky-gift/configModel.js index d64cba8..e10b0df 100644 --- a/src/features/lucky-gift/configModel.js +++ b/src/features/lucky-gift/configModel.js @@ -76,6 +76,7 @@ export function formFromConfig(config) { export function payloadFromLuckyGiftForm(form) { // 第一阶段的新契约明确要求后台只提交百分比和倍数;ppm 是 server 的持久化细节, // 这里保留业务单位可以避免再次出现 target_rtp_ppm=95 这类单位错配。 + const stages = assignGeneratedTierIDs(normalizeStages(form.stages)); return { control_band_percent: decimal(form.controlBandPercent), effective_from_ms: integer(form.effectiveFromMs), @@ -92,7 +93,7 @@ export function payloadFromLuckyGiftForm(form) { pool_id: String(form.poolId || "").trim(), pool_rate_percent: decimal(form.poolRatePercent), settlement_window_wager: integer(form.settlementWindowWager), - stages: normalizeStages(form.stages).map((stage) => ({ + stages: stages.map((stage) => ({ stage: stage.stage, tiers: stage.tiers.map((item) => ({ broadcast_level: item.broadcastLevel, @@ -126,23 +127,25 @@ export function previewForForm(form) { } export function addTierToStage(stages, stageKey) { - return stages.map((stage) => { - if (stage.stage !== stageKey) { - return stage; - } - const nextMultiplier = nextSuggestedTierMultiplier(stage.tiers); - return { - ...stage, - tiers: [ - ...stage.tiers, - tier(tierIDFromMultiplier(stage.stage, nextMultiplier), nextMultiplier, "", { - broadcastLevel: "none", - highWaterOnly: nextMultiplier >= 5, - rewardSource: "base_rtp", - }), - ], - }; - }); + return assignGeneratedTierIDs( + stages.map((stage) => { + if (stage.stage !== stageKey) { + return stage; + } + const nextMultiplier = nextSuggestedTierMultiplier(stage.tiers); + return { + ...stage, + tiers: [ + ...stage.tiers, + tier(tierIDFromMultiplier(stage.stage, nextMultiplier), nextMultiplier, "", { + broadcastLevel: "none", + highWaterOnly: nextMultiplier >= 5, + rewardSource: "base_rtp", + }), + ], + }; + }), + ); } export function removeTierFromStage(stages, stageKey, tierIndex) { @@ -175,8 +178,12 @@ export function correctStageTierProbabilities(stages, stageKey, targetRTPPercent const enabledTiers = stage.tiers .map((item, index) => ({ index, multiplier: decimal(item.multiplier), tier: item })) .filter((item) => item.tier.enabled !== false); - const baseTiers = enabledTiers.filter((item) => item.tier.rewardSource === "base_rtp" && Number.isFinite(item.multiplier)); - const positiveTiers = baseTiers.filter((item) => item.multiplier > 0).sort((left, right) => left.multiplier - right.multiplier); + const baseTiers = enabledTiers.filter( + (item) => item.tier.rewardSource === "base_rtp" && Number.isFinite(item.multiplier), + ); + const positiveTiers = baseTiers + .filter((item) => item.multiplier > 0) + .sort((left, right) => left.multiplier - right.multiplier); const zeroTiers = baseTiers.filter((item) => item.multiplier === 0); if (enabledTiers.length === 0 || positiveTiers.length === 0) { return stage; @@ -188,7 +195,7 @@ export function correctStageTierProbabilities(stages, stageKey, targetRTPPercent } const fixedExpectedRTP = positiveTiers.reduce( (sum, item) => sum + item.multiplier * (probabilityByIndex.get(item.index) || 0), - 0 + 0, ); const remainingProbability = 100 - fixedProbability; const targetExtraRTP = targetRTP - fixedExpectedRTP; @@ -231,6 +238,22 @@ export function tierRTPContribution(tier) { return expectedContributionPercent(tier.multiplier, tier.probabilityPercent); } +export function assignGeneratedTierIDs(stages) { + return (stages || []).map((stage) => { + const used = new Map(); + const tiers = (stage.tiers || []).map((item) => { + const baseID = tierIDFromMultiplier(stage.stage, item.multiplier); + const usedCount = used.get(baseID) || 0; + used.set(baseID, usedCount + 1); + return { + ...item, + tierId: usedCount === 0 ? baseID : `${baseID}_${usedCount + 1}`, + }; + }); + return { ...stage, tiers }; + }); +} + function defaultStages() { return stageOptions.map(([stage]) => ({ stage, tiers: defaultStageTiers[stage].map((item) => ({ ...item })) })); } @@ -253,7 +276,11 @@ function nextSuggestedTierMultiplier(tiers) { } function tierIDFromMultiplier(stage, multiplier) { - const multiplierText = String(multiplier).replace(".", "_"); + const value = decimal(multiplier); + if (value <= 0) { + return `${stage}_none`; + } + const multiplierText = formatDecimal(value).replace(".", "_"); return `${stage}_${multiplierText}x`; } @@ -261,22 +288,25 @@ function normalizeStages(stages) { // server 返回缺阶段时仍用默认三阶段补齐表单,保证运营看到的是完整发布形态; // 保存时 schema 会再次校验概率合计和 0x 档,不让半成品配置进入发布请求。 const byStage = new Map((stages || []).map((stage) => [stage.stage, stage])); - return stageOptions.map(([stage]) => { - const source = byStage.get(stage); - const tiers = Array.isArray(source?.tiers) && source.tiers.length > 0 ? source.tiers : defaultStageTiers[stage]; - return { - stage, - tiers: tiers.map((item) => ({ - broadcastLevel: item.broadcastLevel || "none", - enabled: item.enabled !== false, - highWaterOnly: Boolean(item.highWaterOnly), - multiplier: formatDecimal(item.multiplier), - probabilityPercent: formatDecimal(item.probabilityPercent), - rewardSource: item.rewardSource || "base_rtp", - tierId: item.tierId || "", - })), - }; - }); + return assignGeneratedTierIDs( + stageOptions.map(([stage]) => { + const source = byStage.get(stage); + const tiers = + Array.isArray(source?.tiers) && source.tiers.length > 0 ? source.tiers : defaultStageTiers[stage]; + return { + stage, + tiers: tiers.map((item) => ({ + broadcastLevel: item.broadcastLevel || "none", + enabled: item.enabled !== false, + highWaterOnly: Boolean(item.highWaterOnly), + multiplier: formatDecimal(item.multiplier), + probabilityPercent: formatDecimal(item.probabilityPercent), + rewardSource: item.rewardSource || "base_rtp", + tierId: item.tierId || "", + })), + }; + }), + ); } function cloneStages(stages) { @@ -285,7 +315,10 @@ function cloneStages(stages) { function positiveBaselineProbabilities(enabled) { const probabilities = new Map(); - const floor = enabled.length * minimumCorrectedProbabilityPercent <= 100 ? minimumCorrectedProbabilityPercent : 100 / enabled.length; + const floor = + enabled.length * minimumCorrectedProbabilityPercent <= 100 + ? minimumCorrectedProbabilityPercent + : 100 / enabled.length; for (const item of enabled) { probabilities.set(item.index, roundPercent(floor)); } diff --git a/src/features/lucky-gift/configModel.test.js b/src/features/lucky-gift/configModel.test.js index c0db68c..1e108ea 100644 --- a/src/features/lucky-gift/configModel.test.js +++ b/src/features/lucky-gift/configModel.test.js @@ -65,6 +65,31 @@ describe("lucky gift config payload", () => { }); }); + test("regenerates unique tier ids when building submit payload", () => { + const form = emptyLuckyGiftForm(); + const advanced = form.stages.find((stage) => stage.stage === "advanced"); + advanced.tiers = [ + ...advanced.tiers, + { + ...advanced.tiers[advanced.tiers.length - 1], + multiplier: 5, + probabilityPercent: 0, + tierId: "manual_duplicate", + }, + ]; + + const payload = payloadFromLuckyGiftForm(form); + const advancedPayload = payload.stages.find((stage) => stage.stage === "advanced"); + + expect(advancedPayload.tiers.map((tier) => tier.tier_id)).toEqual([ + "advanced_none", + "advanced_1x", + "advanced_2x", + "advanced_5x", + "advanced_5x_2", + ]); + }); + test("corrects a stage probability to requested RTP without zeroing enabled tiers", () => { const form = emptyLuckyGiftForm(); const nextStages = correctStageTierProbabilities(form.stages, "normal", 95); diff --git a/src/features/lucky-gift/schema.test.ts b/src/features/lucky-gift/schema.test.ts index 28bca55..8854dff 100644 --- a/src/features/lucky-gift/schema.test.ts +++ b/src/features/lucky-gift/schema.test.ts @@ -19,16 +19,18 @@ describe("lucky gift config schema", () => { } }); - test("rejects duplicated tier id inside the same stage before submit", () => { + test("does not require manually maintained tier ids 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[0].tierId = ""; advanced.tiers = [ ...advanced.tiers, { ...advanced.tiers[advanced.tiers.length - 1], + enabled: false, multiplier: 10, probabilityPercent: 0, tierId: "advanced_5x", @@ -37,9 +39,6 @@ describe("lucky gift config schema", () => { const result = luckyGiftConfigFormSchema.safeParse(form); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues.map((issue) => issue.message)).toContain("同一阶段内奖档 ID 不能重复"); - } + expect(result.success).toBe(true); }); }); diff --git a/src/features/lucky-gift/schema.ts b/src/features/lucky-gift/schema.ts index de949d0..e0e57fc 100644 --- a/src/features/lucky-gift/schema.ts +++ b/src/features/lucky-gift/schema.ts @@ -17,7 +17,7 @@ const stageTierSchema = z.object({ multiplier: z.coerce.number().min(0, "倍率不能小于 0"), probabilityPercent: z.coerce.number().min(0, "概率不能小于 0").max(100, "概率不能超过 100%"), rewardSource: z.enum(rewardSources), - tierId: z.string().trim().min(1, "奖档 ID 不能为空").max(96, "奖档 ID 不能超过 96 个字符"), + tierId: z.string().trim().max(96, "奖档 ID 不能超过 96 个字符").optional().default(""), }); const stageSchema = z.object({ @@ -31,8 +31,14 @@ export const luckyGiftConfigFormSchema = z effectiveFromMs: z.coerce.number().int("生效时间必须是毫秒整数").min(0, "生效时间不能小于 0"), enabled: z.boolean(), giftPriceReference: z.coerce.number().int("参考价格必须是整数").gt(0, "参考价格必须大于 0"), - noviceMaxEquivalentDraws: z.coerce.number().int("新手阶段等价抽数必须是整数").min(0, "新手阶段等价抽数不能小于 0"), - normalMaxEquivalentDraws: z.coerce.number().int("正常阶段等价抽数必须是整数").min(0, "正常阶段等价抽数不能小于 0"), + noviceMaxEquivalentDraws: z.coerce + .number() + .int("新手阶段等价抽数必须是整数") + .min(0, "新手阶段等价抽数不能小于 0"), + normalMaxEquivalentDraws: z.coerce + .number() + .int("正常阶段等价抽数必须是整数") + .min(0, "正常阶段等价抽数不能小于 0"), maxSinglePayout: z.coerce.number().int("单次返奖上限必须是整数").gt(0, "单次返奖上限必须大于 0"), userHourlyPayoutCap: z.coerce.number().int("用户小时上限必须是整数").gt(0, "用户小时上限必须大于 0"), userDailyPayoutCap: z.coerce.number().int("用户每日上限必须是整数").gt(0, "用户每日上限必须大于 0"), @@ -93,23 +99,14 @@ 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; } - return sum + Math.trunc((multiplierToPPM(tier.multiplier) * percentToPPM(tier.probabilityPercent)) / ppmScale); + return ( + sum + + Math.trunc((multiplierToPPM(tier.multiplier) * percentToPPM(tier.probabilityPercent)) / ppmScale) + ); }, 0); const expectedRTP = expectedRTPPPM / 10_000; const lowerRTPPPM = percentToPPM(value.targetRtpPercent - value.controlBandPercent);