fix: auto generate lucky gift tier ids
This commit is contained in:
parent
282c9f1734
commit
3900866df0
@ -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)}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user