535 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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