import { stageOptions } from "@/features/lucky-gift/constants.js"; export const luckyGiftReferenceCost = 100; export const minimumCorrectedProbabilityPercent = 0.01; const ppmScale = 1_000_000; const defaultStageTiers = { novice: [ tier("novice_none", 0, 10), tier("novice_0_5x", 0.5, 20), tier("novice_1x", 1, 55), tier("novice_2x", 2, 15), ], normal: [ tier("normal_none", 0, 10), tier("normal_0_5x", 0.5, 20), tier("normal_1x", 1, 55), tier("normal_2x", 2, 15), ], advanced: [ tier("advanced_none", 0, 27), tier("advanced_1x", 1, 60), tier("advanced_2x", 2, 10), tier("advanced_5x", 5, 3, { highWaterOnly: true, broadcastLevel: "room" }), ], }; export function emptyLuckyGiftForm() { return { controlBandPercent: "1", effectiveFromMs: "0", enabled: false, giftPriceReference: String(luckyGiftReferenceCost), noviceMaxEquivalentDraws: "2000", normalMaxEquivalentDraws: "20000", maxSinglePayout: "50000", userHourlyPayoutCap: "34200", userDailyPayoutCap: "615600", deviceDailyPayoutCap: "1026000", roomHourlyPayoutCap: "684000", anchorDailyPayoutCap: "12312000", poolId: "default", poolRatePercent: "96", settlementWindowWager: "1000000", stages: cloneStages(defaultStages()), targetRtpPercent: "95", }; } export function formFromConfig(config) { if (!config) { return emptyLuckyGiftForm(); } const fallback = emptyLuckyGiftForm(); return { controlBandPercent: formatDecimal(config.controlBandPercent || fallback.controlBandPercent), effectiveFromMs: String(config.effectiveFromMs || 0), enabled: Boolean(config.enabled), giftPriceReference: String(config.giftPriceReference || fallback.giftPriceReference), noviceMaxEquivalentDraws: String(config.noviceMaxEquivalentDraws || fallback.noviceMaxEquivalentDraws), normalMaxEquivalentDraws: String(config.normalMaxEquivalentDraws || fallback.normalMaxEquivalentDraws), maxSinglePayout: String(config.maxSinglePayout || fallback.maxSinglePayout), userHourlyPayoutCap: String(config.userHourlyPayoutCap || fallback.userHourlyPayoutCap), userDailyPayoutCap: String(config.userDailyPayoutCap || fallback.userDailyPayoutCap), deviceDailyPayoutCap: String(config.deviceDailyPayoutCap || fallback.deviceDailyPayoutCap), roomHourlyPayoutCap: String(config.roomHourlyPayoutCap || fallback.roomHourlyPayoutCap), anchorDailyPayoutCap: String(config.anchorDailyPayoutCap || fallback.anchorDailyPayoutCap), poolId: config.poolId || "default", poolRatePercent: formatDecimal(config.poolRatePercent || fallback.poolRatePercent), settlementWindowWager: String(config.settlementWindowWager || fallback.settlementWindowWager), stages: normalizeStages(config.stages), targetRtpPercent: formatDecimal(config.targetRTPPercent || fallback.targetRtpPercent), }; } export function payloadFromLuckyGiftForm(form) { // 第一阶段的新契约明确要求后台只提交百分比和倍数;ppm 是 server 的持久化细节, // 这里保留业务单位可以避免再次出现 target_rtp_ppm=95 这类单位错配。 return { control_band_percent: decimal(form.controlBandPercent), effective_from_ms: integer(form.effectiveFromMs), enabled: Boolean(form.enabled), gift_price_reference: integer(form.giftPriceReference), novice_max_equivalent_draws: integer(form.noviceMaxEquivalentDraws), normal_max_equivalent_draws: integer(form.normalMaxEquivalentDraws), max_single_payout: integer(form.maxSinglePayout), user_hourly_payout_cap: integer(form.userHourlyPayoutCap), user_daily_payout_cap: integer(form.userDailyPayoutCap), device_daily_payout_cap: integer(form.deviceDailyPayoutCap), room_hourly_payout_cap: integer(form.roomHourlyPayoutCap), anchor_daily_payout_cap: integer(form.anchorDailyPayoutCap), pool_id: String(form.poolId || "").trim(), pool_rate_percent: decimal(form.poolRatePercent), settlement_window_wager: integer(form.settlementWindowWager), stages: normalizeStages(form.stages).map((stage) => ({ stage: stage.stage, tiers: stage.tiers.map((item) => ({ broadcast_level: item.broadcastLevel, enabled: Boolean(item.enabled), high_water_only: Boolean(item.highWaterOnly), multiplier: decimal(item.multiplier), probability_percent: decimal(item.probabilityPercent), reward_source: item.rewardSource, tier_id: String(item.tierId || "").trim(), })), })), target_rtp_percent: decimal(form.targetRtpPercent), }; } export function previewForForm(form) { // 预览只用于管理员在保存前核对预算规模,不反推线上中奖概率; // 真实抽奖控制必须以 activity-service 发布后的规则版本为准。 const spend = integer(form.giftPriceReference) * 100_000; const targetRTP = decimal(form.targetRtpPercent) / 100; const poolRate = decimal(form.poolRatePercent) / 100; const basePayout = Math.round(spend * targetRTP); const poolInflow = Math.round(spend * poolRate); return { basePayout, controlBandPercent: decimal(form.controlBandPercent), poolInflow, spend, targetRTPPercent: decimal(form.targetRtpPercent), }; } 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", }), ], }; }); } export function removeTierFromStage(stages, stageKey, tierIndex) { return stages.map((stage) => { if (stage.stage !== stageKey) { return stage; } return { ...stage, tiers: stage.tiers.filter((_, index) => index !== tierIndex) }; }); } export function updateStageTier(stages, stageKey, tierIndex, patch) { return stages.map((stage) => { if (stage.stage !== stageKey) { return stage; } return { ...stage, tiers: stage.tiers.map((item, index) => (index === tierIndex ? { ...item, ...patch } : item)), }; }); } export function correctStageTierProbabilities(stages, stageKey, targetRTPPercent) { const targetRTP = decimal(targetRTPPercent); return stages.map((stage) => { if (stage.stage !== stageKey) { return stage; } 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 zeroTiers = baseTiers.filter((item) => item.multiplier === 0); if (enabledTiers.length === 0 || positiveTiers.length === 0) { return stage; } const probabilityByIndex = positiveBaselineProbabilities(enabledTiers); const fixedProbability = sumProbabilities(probabilityByIndex); if (fixedProbability >= 100) { return { ...stage, tiers: applyCorrectionProbabilities(stage.tiers, probabilityByIndex) }; } const fixedExpectedRTP = positiveTiers.reduce( (sum, item) => sum + item.multiplier * (probabilityByIndex.get(item.index) || 0), 0 ); const remainingProbability = 100 - fixedProbability; const targetExtraRTP = targetRTP - fixedExpectedRTP; const contributionPlan = balancedRTPContributionPlan(positiveTiers, targetExtraRTP, remainingProbability); addContributionProbabilities(probabilityByIndex, contributionPlan); assignResidualProbability(probabilityByIndex, zeroTiers, positiveTiers); alignExpectedRTPByWeightPPM(probabilityByIndex, baseTiers, targetRTP); return { ...stage, tiers: applyCorrectionProbabilities(stage.tiers, probabilityByIndex), }; }); } export function stageProbabilityTotal(stage) { // 与 activity-service 的发布校验保持同一口径:只有启用奖档参与 100% 概率闭合; // 禁用奖档只是草稿行,不能让前端显示通过但服务端按启用集合拒绝。 return (stage?.tiers || []).reduce((sum, item) => { if (item?.enabled === false) { return sum; } return sum + decimal(item.probabilityPercent); }, 0); } export function stageExpectedRTP(stage) { // 阶段期望 RTP 是发布时真正校验的概率口径:只统计启用的基础 RTP 奖档,活动补贴和表现奖励不参与基础返奖率。 return (stage?.tiers || []).reduce((sum, item) => { if (item?.enabled === false || item?.rewardSource !== "base_rtp") { return sum; } return sum + expectedContributionPercent(item.multiplier, item.probabilityPercent); }, 0); } export function tierRTPContribution(tier) { if (tier?.enabled === false || tier?.rewardSource !== "base_rtp") { return 0; } return expectedContributionPercent(tier.multiplier, tier.probabilityPercent); } 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 档,不让半成品配置进入发布请求。 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 || "", })), }; }); } function cloneStages(stages) { return stages.map((stage) => ({ ...stage, tiers: stage.tiers.map((item) => ({ ...item })) })); } function positiveBaselineProbabilities(enabled) { const probabilities = new Map(); const floor = enabled.length * minimumCorrectedProbabilityPercent <= 100 ? minimumCorrectedProbabilityPercent : 100 / enabled.length; for (const item of enabled) { probabilities.set(item.index, roundPercent(floor)); } return probabilities; } function balancedRTPContributionPlan(positiveTiers, targetExtraRTP, remainingProbability) { if (targetExtraRTP <= 0) { return []; } const maxMultiplier = positiveTiers[positiveTiers.length - 1].multiplier; if (targetExtraRTP >= remainingProbability * maxMultiplier) { // 目标 RTP 超过当前倍率表可达上限时,所有剩余概率只能压到最大倍率;保存校验会继续提示无法达标。 return [{ tier: positiveTiers[positiveTiers.length - 1], probability: remainingProbability }]; } const alpha = minimumFeasibleContributionAlpha(positiveTiers, targetExtraRTP, remainingProbability); const weighted = contributionWeights(positiveTiers, alpha); return positiveTiers.map((tier, index) => ({ tier, probability: roundPercent((targetExtraRTP * weighted[index]) / tier.multiplier), })); } function minimumFeasibleContributionAlpha(positiveTiers, targetExtraRTP, remainingProbability) { // alpha=0 代表所有正倍率平均分 RTP 贡献;如果低倍率太多导致概率总和超过 100%,逐步提高 alpha, // 让更高倍率承担更多 RTP 贡献。这样 10x/100x 不会被挤成 0,又能保持整体期望返奖率。 if (contributionProbabilityTotal(positiveTiers, targetExtraRTP, 0) <= remainingProbability) { return 0; } let low = 0; let high = 1; while (contributionProbabilityTotal(positiveTiers, targetExtraRTP, high) > remainingProbability && high < 64) { high *= 2; } for (let step = 0; step < 48; step += 1) { const mid = (low + high) / 2; if (contributionProbabilityTotal(positiveTiers, targetExtraRTP, mid) > remainingProbability) { low = mid; } else { high = mid; } } return high; } function contributionProbabilityTotal(positiveTiers, targetExtraRTP, alpha) { const weights = contributionWeights(positiveTiers, alpha); return positiveTiers.reduce((sum, tier, index) => sum + (targetExtraRTP * weights[index]) / tier.multiplier, 0); } function contributionWeights(positiveTiers, alpha) { const rawWeights = positiveTiers.map((tier) => Math.max(0, tier.multiplier) ** alpha); const total = rawWeights.reduce((sum, value) => sum + value, 0); if (total <= 0) { return positiveTiers.map(() => 1 / positiveTiers.length); } return rawWeights.map((value) => value / total); } function addContributionProbabilities(probabilities, contributionPlan) { for (const item of contributionPlan) { probabilities.set(item.tier.index, roundPercent((probabilities.get(item.tier.index) || 0) + item.probability)); } } function assignResidualProbability(probabilities, zeroTiers, positiveTiers) { const residual = roundPercent(100 - sumProbabilities(probabilities)); if (residual <= 0) { normalizeProbabilityTotal(probabilities, zeroTiers[0] || positiveTiers[0]); return; } const receiver = zeroTiers[0] || positiveTiers[0]; if (!receiver) { return; } probabilities.set(receiver.index, roundPercent((probabilities.get(receiver.index) || 0) + residual)); normalizeProbabilityTotal(probabilities, receiver); } function normalizeProbabilityTotal(probabilities, receiver) { if (!receiver) { return; } const diff = roundPercent(100 - sumProbabilities(probabilities)); if (diff !== 0) { probabilities.set(receiver.index, roundPercent((probabilities.get(receiver.index) || 0) + diff)); } } function alignExpectedRTPByWeightPPM(probabilities, baseTiers, targetRTP) { const targetRTPPPM = percentToWeightPPM(targetRTP); const weights = new Map(); for (const [index, probability] of probabilities.entries()) { weights.set(index, percentToWeightPPM(probability)); } normalizeWeightTotal(weights, baseTiers[0]); let currentRTPPPM = expectedRTPPPMByWeights(weights, baseTiers); const floorWeight = percentToWeightPPM(minimumCorrectedProbabilityPercent); for (let step = 0; step < 20_000 && currentRTPPPM !== targetRTPPPM; step += 1) { const diff = targetRTPPPM - currentRTPPPM; const move = bestExpectedRTPMove(weights, baseTiers, diff, floorWeight); if (!move || Math.abs(diff - move.delta) >= Math.abs(diff)) { break; } weights.set(move.donor.index, (weights.get(move.donor.index) || 0) - 1); weights.set(move.receiver.index, (weights.get(move.receiver.index) || 0) + 1); currentRTPPPM += move.delta; } for (const [index, weight] of weights.entries()) { probabilities.set(index, roundPercent(weight / 10_000)); } } function normalizeWeightTotal(weights, receiver) { if (!receiver) { return; } let total = 0; for (const weight of weights.values()) { total += weight; } const diff = ppmScale - total; if (diff !== 0) { weights.set(receiver.index, (weights.get(receiver.index) || 0) + diff); } } function bestExpectedRTPMove(weights, baseTiers, diff, floorWeight) { let best = null; for (const donor of baseTiers) { const donorWeight = weights.get(donor.index) || 0; if (donorWeight <= floorWeight) { continue; } for (const receiver of baseTiers) { if (receiver.index === donor.index) { continue; } const delta = expectedMoveDeltaPPM(weights, donor, receiver); if ((diff > 0 && delta <= 0) || (diff < 0 && delta >= 0)) { continue; } if (!best || betterMove(delta, best.delta, diff)) { best = { delta, donor, receiver }; } } } return best; } function betterMove(candidateDelta, bestDelta, diff) { const candidateAbs = Math.abs(candidateDelta); const bestAbs = Math.abs(bestDelta); const diffAbs = Math.abs(diff); const candidateUnder = candidateAbs <= diffAbs; const bestUnder = bestAbs <= diffAbs; if (candidateUnder !== bestUnder) { return candidateUnder; } return candidateUnder ? candidateAbs > bestAbs : candidateAbs < bestAbs; } function expectedMoveDeltaPPM(weights, donor, receiver) { const donorWeight = weights.get(donor.index) || 0; const receiverWeight = weights.get(receiver.index) || 0; return ( contributionPPM(receiver.multiplier, receiverWeight + 1) - contributionPPM(receiver.multiplier, receiverWeight) + contributionPPM(donor.multiplier, donorWeight - 1) - contributionPPM(donor.multiplier, donorWeight) ); } function expectedRTPPPMByWeights(weights, baseTiers) { return baseTiers.reduce((sum, tier) => sum + contributionPPM(tier.multiplier, weights.get(tier.index) || 0), 0); } function applyCorrectionProbabilities(tiers, probabilityByIndex) { return tiers.map((item, index) => ({ ...item, // 启用奖档保留最小正概率;如果运营不想让某个倍率出现,应关闭该奖档,而不是让概率为 0。 probabilityPercent: formatDecimal(probabilityByIndex.get(index) || 0), })); } function sumProbabilities(probabilities) { let total = 0; for (const value of probabilities.values()) { total += value; } return total; } function roundPercent(value) { // 后端会把 percent * 10000 转成 ppm 并要求每阶段启用奖档 ppm 精确合计 1,000,000; // 前端纠正结果也落到 0.0001% 粒度,避免 100.000000 这种浮点显示通过但 RPC 校验失败。 return Math.round(value * 10_000) / 10_000; } function expectedContributionPercent(multiplier, probabilityPercent) { return contributionPPM(decimal(multiplier), percentToWeightPPM(probabilityPercent)) / 10_000; } function contributionPPM(multiplier, weightPPM) { return Math.trunc((multiplierToPPM(multiplier) * weightPPM) / ppmScale); } function percentToWeightPPM(value) { return Math.round(decimal(value) * 10_000); } function multiplierToPPM(value) { return Math.round(decimal(value) * ppmScale); } function tier(tierId, multiplier, probabilityPercent, options = {}) { return { broadcastLevel: options.broadcastLevel || "none", enabled: options.enabled ?? true, highWaterOnly: Boolean(options.highWaterOnly), multiplier, probabilityPercent, rewardSource: options.rewardSource || "base_rtp", tierId, }; } function formatDecimal(value) { const number = Number(value || 0); if (!Number.isFinite(number)) { return "0"; } return Number.isInteger(number) ? String(number) : number.toFixed(6).replace(/\.?0+$/, ""); } function decimal(value) { const number = Number(value || 0); return Number.isFinite(number) ? number : 0; } function integer(value) { const number = Number(value || 0); return Number.isFinite(number) ? Math.round(number) : 0; }