535 lines
21 KiB
JavaScript
535 lines
21 KiB
JavaScript
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;
|
||
}
|