fix: auto generate lucky gift tier ids

This commit is contained in:
zhx 2026-06-12 23:28:04 +08:00
parent 282c9f1734
commit 3900866df0
5 changed files with 143 additions and 74 deletions

View File

@ -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({
<Button disabled={configSaving} type="button" onClick={onClose}>
取消
</Button>
<Button disabled={disabled} startIcon={<SaveOutlined fontSize="small" />} type="submit" variant="primary">
<Button
disabled={disabled}
startIcon={<SaveOutlined fontSize="small" />}
type="submit"
variant="primary"
>
保存
</Button>
</div>
@ -229,7 +239,10 @@ function StageTierEditor({ disabled, form, setForm }) {
return (
<div className={styles.stageStack}>
{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 }) {
<div className={styles.stageHeader}>
<FieldLabel help={helpText.stages} text={stageLabel} />
<div className={styles.stageMetrics}>
<span className={styles.stageTotal}>概率 {formatPercent(stageProbabilityTotal(stage))}</span>
<span className={styles.stageTotal}>
概率 {formatPercent(stageProbabilityTotal(stage))}
</span>
<span className={rtpInBand ? styles.stageTotal : styles.stageRTPError}>
期望 RTP {formatPercent(expectedRTP)}
</span>
@ -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 (
<div className={styles.tierRow}>
<TextField
required
disabled={disabled}
label="奖档 ID"
value={tier.tierId}
onChange={(event) => updateTier({ tierId: event.target.value })}
/>
<TextField
required
disabled={disabled}
@ -382,7 +391,13 @@ function TierRow({ disabled, setForm, stageKey, stageLabel, tier, tierCount, tie
function SelectField({ disabled, label, onChange, options, value }) {
return (
<TextField select disabled={disabled} label={label} value={value} onChange={(event) => onChange(event.target.value)}>
<TextField
select
disabled={disabled}
label={label}
value={value}
onChange={(event) => onChange(event.target.value)}
>
{options.map(([optionValue, optionLabelText]) => (
<MenuItem key={optionValue} value={optionValue}>
{optionLabelText || optionLabel(options, optionValue)}

View File

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

View File

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

View File

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

View File

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