diff --git a/src/features/lucky-gift/api.ts b/src/features/lucky-gift/api.ts index 66bc6cd..fe58717 100644 --- a/src/features/lucky-gift/api.ts +++ b/src/features/lucky-gift/api.ts @@ -2,99 +2,72 @@ import { apiRequest } from "@/shared/api/request"; import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; export interface LuckyGiftTierDto { - pool: string; tierId: string; - rewardCoins: number; - multiplierPPM: number; - weight: number; + multiplier: number; + probabilityPercent: number; + rewardSource: string; highWaterOnly: boolean; + broadcastLevel: string; enabled: boolean; } +export interface LuckyGiftStageDto { + stage: string; + tiers: LuckyGiftTierDto[]; +} + export interface LuckyGiftConfigDto { appCode?: string; poolId: string; enabled: boolean; ruleVersion: number; - giftPrice: number; - targetRTPPPM: number; - poolRatePPM: number; - globalWindowDraws: number; - giftWindowDraws: number; - noviceDrawLimit: number; - intermediateDrawLimit: number; - highMultiplier: number; - highWaterPoolMultiple: number; - platformPoolWeightPPM: number; - roomPoolWeightPPM: number; - giftPoolWeightPPM: number; - initialPlatformPool: number; - initialGiftPool: number; - initialRoomPool: number; - platformReserve: number; - giftReserve: number; - roomReserve: number; + targetRTPPercent: number; + poolRatePercent: number; + settlementWindowWager: number; + controlBandPercent: number; + giftPriceReference: number; + noviceMaxEquivalentDraws: number; + normalMaxEquivalentDraws: number; maxSinglePayout: number; userHourlyPayoutCap: number; userDailyPayoutCap: number; deviceDailyPayoutCap: number; roomHourlyPayoutCap: number; anchorDailyPayoutCap: number; - roomAtmosphereRatePPM: number; - roomAtmosphereInitial: number; - roomAtmosphereReserve: number; - activityBudget: number; - activityDailyLimit: number; - largeTierEnabled: boolean; - multiplierPPMs: number[]; - tiers: LuckyGiftTierDto[]; - updatedByAdminId?: number; + effectiveFromMs: number; + createdByAdminId?: number; createdAtMs?: number; - updatedAtMs?: number; + stages: LuckyGiftStageDto[]; } export interface LuckyGiftConfigPayload { pool_id: string; enabled: boolean; - gift_price: number; - target_rtp_ppm: number; - pool_rate_ppm: number; - global_window_draws: number; - gift_window_draws: number; - novice_draw_limit: number; - intermediate_draw_limit: number; - high_multiplier: number; - high_water_pool_multiple: number; - platform_pool_weight_ppm: number; - room_pool_weight_ppm: number; - gift_pool_weight_ppm: number; - initial_platform_pool: number; - initial_gift_pool: number; - initial_room_pool: number; - platform_reserve: number; - gift_reserve: number; - room_reserve: number; + target_rtp_percent: number; + pool_rate_percent: number; + settlement_window_wager: number; + control_band_percent: number; + gift_price_reference: number; + novice_max_equivalent_draws: number; + normal_max_equivalent_draws: number; max_single_payout: number; user_hourly_payout_cap: number; user_daily_payout_cap: number; device_daily_payout_cap: number; room_hourly_payout_cap: number; anchor_daily_payout_cap: number; - room_atmosphere_rate_ppm: number; - room_atmosphere_initial: number; - room_atmosphere_reserve: number; - activity_budget: number; - activity_daily_limit: number; - large_tier_enabled: boolean; - multiplier_ppms: number[]; - tiers: Array<{ - pool: string; - tier_id: string; - reward_coins: number; - multiplier_ppm: number; - weight: number; - high_water_only: boolean; - enabled: boolean; + effective_from_ms: number; + stages: Array<{ + stage: string; + tiers: Array<{ + tier_id: string; + multiplier: number; + probability_percent: number; + reward_source: string; + high_water_only: boolean; + broadcast_level: string; + enabled: boolean; + }>; }>; } @@ -115,6 +88,7 @@ export interface LuckyGiftDrawSummaryDto { } type RawConfig = LuckyGiftConfigDto & Record; +type RawStage = LuckyGiftStageDto & Record; type RawTier = LuckyGiftTierDto & Record; type RawSummary = LuckyGiftDrawSummaryDto & Record; type LuckyGiftDrawSummaryQuery = Record; @@ -152,59 +126,52 @@ export function getLuckyGiftDrawSummary(query: LuckyGiftDrawSummaryQuery = {}): } function normalizeConfig(item: RawConfig): LuckyGiftConfigDto { + // admin-server 已经把内部 ppm 转回业务单位;前端只接受 percent/multiplier 字段, + // 这样页面展示、表单校验和提交 payload 使用同一套单位。 return { appCode: stringValue(item.appCode ?? item.app_code), - poolId: stringValue(item.poolId ?? item.pool_id ?? item.gift_id), + createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms), + createdByAdminId: numberValue(item.createdByAdminId ?? item.created_by_admin_id), + effectiveFromMs: numberValue(item.effectiveFromMs ?? item.effective_from_ms), enabled: booleanValue(item.enabled), - ruleVersion: numberValue(item.ruleVersion ?? item.rule_version), - giftPrice: numberValue(item.giftPrice ?? item.gift_price), - targetRTPPPM: numberValue(item.targetRTPPPM ?? item.target_rtp_ppm), - poolRatePPM: numberValue(item.poolRatePPM ?? item.pool_rate_ppm), - globalWindowDraws: numberValue(item.globalWindowDraws ?? item.global_window_draws), - giftWindowDraws: numberValue(item.giftWindowDraws ?? item.gift_window_draws), - noviceDrawLimit: numberValue(item.noviceDrawLimit ?? item.novice_draw_limit), - intermediateDrawLimit: numberValue(item.intermediateDrawLimit ?? item.intermediate_draw_limit), - highMultiplier: numberValue(item.highMultiplier ?? item.high_multiplier), - highWaterPoolMultiple: numberValue(item.highWaterPoolMultiple ?? item.high_water_pool_multiple), - platformPoolWeightPPM: numberValue(item.platformPoolWeightPPM ?? item.platform_pool_weight_ppm), - roomPoolWeightPPM: numberValue(item.roomPoolWeightPPM ?? item.room_pool_weight_ppm), - giftPoolWeightPPM: numberValue(item.giftPoolWeightPPM ?? item.gift_pool_weight_ppm), - initialPlatformPool: numberValue(item.initialPlatformPool ?? item.initial_platform_pool), - initialGiftPool: numberValue(item.initialGiftPool ?? item.initial_gift_pool), - initialRoomPool: numberValue(item.initialRoomPool ?? item.initial_room_pool), - platformReserve: numberValue(item.platformReserve ?? item.platform_reserve), - giftReserve: numberValue(item.giftReserve ?? item.gift_reserve), - roomReserve: numberValue(item.roomReserve ?? item.room_reserve), + giftPriceReference: numberValue(item.giftPriceReference ?? item.gift_price_reference), + noviceMaxEquivalentDraws: numberValue(item.noviceMaxEquivalentDraws ?? item.novice_max_equivalent_draws), + normalMaxEquivalentDraws: numberValue(item.normalMaxEquivalentDraws ?? item.normal_max_equivalent_draws), maxSinglePayout: numberValue(item.maxSinglePayout ?? item.max_single_payout), userHourlyPayoutCap: numberValue(item.userHourlyPayoutCap ?? item.user_hourly_payout_cap), userDailyPayoutCap: numberValue(item.userDailyPayoutCap ?? item.user_daily_payout_cap), deviceDailyPayoutCap: numberValue(item.deviceDailyPayoutCap ?? item.device_daily_payout_cap), roomHourlyPayoutCap: numberValue(item.roomHourlyPayoutCap ?? item.room_hourly_payout_cap), anchorDailyPayoutCap: numberValue(item.anchorDailyPayoutCap ?? item.anchor_daily_payout_cap), - roomAtmosphereRatePPM: numberValue(item.roomAtmosphereRatePPM ?? item.room_atmosphere_rate_ppm), - roomAtmosphereInitial: numberValue(item.roomAtmosphereInitial ?? item.room_atmosphere_initial), - roomAtmosphereReserve: numberValue(item.roomAtmosphereReserve ?? item.room_atmosphere_reserve), - activityBudget: numberValue(item.activityBudget ?? item.activity_budget), - activityDailyLimit: numberValue(item.activityDailyLimit ?? item.activity_daily_limit), - largeTierEnabled: booleanValue(item.largeTierEnabled ?? item.large_tier_enabled), - multiplierPPMs: arrayValue(item.multiplierPPMs ?? item.multiplier_ppms).map(numberValue), + poolId: stringValue(item.poolId ?? item.pool_id), + poolRatePercent: numberValue(item.poolRatePercent ?? item.pool_rate_percent), + ruleVersion: numberValue(item.ruleVersion ?? item.rule_version), + settlementWindowWager: numberValue(item.settlementWindowWager ?? item.settlement_window_wager), + stages: arrayValue(item.stages).map(normalizeStage), + targetRTPPercent: numberValue(item.targetRTPPercent ?? item.target_rtp_percent), + controlBandPercent: numberValue(item.controlBandPercent ?? item.control_band_percent), + }; +} + +function normalizeStage(raw: unknown): LuckyGiftStageDto { + const item = asRecord(raw) as RawStage; + return { + stage: stringValue(item.stage), tiers: arrayValue(item.tiers).map(normalizeTier), - updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id), - createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms), - updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms), }; } function normalizeTier(raw: unknown): LuckyGiftTierDto { const item = asRecord(raw) as RawTier; + // 奖档概率是发布配置的一部分,不再使用旧 weight=0 的运行时自动权重模型。 return { - pool: stringValue(item.pool), - tierId: stringValue(item.tierId ?? item.tier_id), - rewardCoins: numberValue(item.rewardCoins ?? item.reward_coins), - multiplierPPM: numberValue(item.multiplierPPM ?? item.multiplier_ppm), - weight: numberValue(item.weight), - highWaterOnly: booleanValue(item.highWaterOnly ?? item.high_water_only), + broadcastLevel: stringValue(item.broadcastLevel ?? item.broadcast_level), enabled: booleanValue(item.enabled), + highWaterOnly: booleanValue(item.highWaterOnly ?? item.high_water_only), + multiplier: numberValue(item.multiplier), + probabilityPercent: numberValue(item.probabilityPercent ?? item.probability_percent), + rewardSource: stringValue(item.rewardSource ?? item.reward_source), + tierId: stringValue(item.tierId ?? item.tier_id), }; } diff --git a/src/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx b/src/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx index b533ca3..4a32bfe 100644 --- a/src/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx +++ b/src/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx @@ -1,7 +1,10 @@ +import { useEffect, useState } from "react"; import AddOutlined from "@mui/icons-material/AddOutlined"; import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; import SaveOutlined from "@mui/icons-material/SaveOutlined"; +import Checkbox from "@mui/material/Checkbox"; import Drawer from "@mui/material/Drawer"; +import FormControlLabel from "@mui/material/FormControlLabel"; import IconButton from "@mui/material/IconButton"; import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; @@ -9,29 +12,34 @@ import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import { Button } from "@/shared/ui/Button.jsx"; import { FieldLabel } from "@/features/lucky-gift/components/FieldLabel.jsx"; import { - atmosphereLevelOptions, - controlStrengthOptions, - jackpotStrategyOptions, - noviceStrengthOptions, - poolModeOptions, - riskLevelOptions, -} from "@/features/lucky-gift/constants.js"; + addTierToStage, + correctStageTierProbabilities, + removeTierFromStage, + stageExpectedRTP, + stageProbabilityTotal, + tierRTPContribution, + updateStageTier, +} from "@/features/lucky-gift/configModel.js"; +import { broadcastLevelOptions, rewardSourceOptions, stageOptions, optionLabel } from "@/features/lucky-gift/constants.js"; import styles from "@/features/lucky-gift/lucky-gift.module.css"; const helpText = { - enabled: "控制全站幸运礼物抽奖是否生效,关闭后用户仍可送礼但不触发幸运抽奖。", - poolId: "奖池 ID 是请求接口传入的隔离键,不同奖池的 RTP、预算、风控和用户阶段互不影响。", - targetRtpPercent: "基础返奖成本比例,95 表示长期每消耗 100 金币,基础奖池目标返还 95 金币。", - controlStrength: "控制 RTP 收敛窗口大小,越精准越快贴近目标,越自然短期波动越大。", - poolMode: "决定平台池、房间池、整体幸运礼物池分别承担多少基础返奖责任。", - noviceStrength: "控制新手池持续抽数,越强表示用户前期更长时间处于低波动体验。", - multiplierValues: "配置用户可能命中的倍率,例如 0、0.5、1、2、10、100;每个倍率的概率由后端按 RTP 窗口自动生成。", - jackpotStrategy: "控制高倍奖档开放强度,保守需要更高水位,关闭则不出高倍大奖。", - riskLevel: "控制单用户、设备、房间和主播关联的奖励上限,越严格越防套利。", - atmosphereLevel: "控制房间气氛池入账强度,用于房间共享爆点,不计入基础 RTP。", - activitySubsidyEnabled: "活动补贴是独立预算,开启后会提高用户可见奖励,但不污染基础 RTP。", - activityBudget: "活动期最多可额外补贴的金币总额。", - activityDailyLimit: "活动补贴每天最多释放的金币额度。", + controlBandPercent: "RTP 正常波动带。1 表示允许当前窗口在目标值上下 1% 内自然波动。", + enabled: "控制全站幸运礼物抽奖是否生效。关闭后用户仍可送礼,但不会触发幸运抽奖。", + giftPriceReference: "配置奖档时使用的参考礼物价格。真实抽奖会按用户实际消耗金币等比缩放奖励。", + noviceMaxEquivalentDraws: "新手阶段截止等价抽数。等价抽数=累计消耗金币/参考价格,不同价格礼物会按金币流水推进。", + normalMaxEquivalentDraws: "正常阶段截止等价抽数。超过该值后进入高阶阶段,不能小于新手阶段阈值。", + maxSinglePayout: "单次基础返奖上限。超过该值的候选会被风控剪掉,真实抽奖按实际消耗金币等比缩放。", + poolId: "奖池 ID 是幸运礼物规则隔离键。不同奖池拥有独立 RTP、窗口和阶段奖档。", + poolRatePercent: "每笔幸运礼物扣费进入基础奖池的比例,必须大于等于 RTP,避免奖池长期亏空。", + userHourlyPayoutCap: "同一用户每小时基础返奖上限。达到上限后,该用户会优先进入 0x 或低倍率。", + userDailyPayoutCap: "同一用户每日基础返奖上限。按 UTC 自然日统计。", + deviceDailyPayoutCap: "同一设备每日基础返奖上限。用于限制多账号集中领取。", + roomHourlyPayoutCap: "同一房间每小时基础返奖上限。房间触顶后,新用户也会被该房间上限影响。", + anchorDailyPayoutCap: "同一收礼主播每日基础返奖上限。按 UTC 自然日统计。", + settlementWindowWager: "RTP 结算窗口按金币流水计算,不按抽数计算,低价和高价礼物会自然等比参与控制。", + stages: "新手、正常、高阶三个阶段必须各自配置概率,抽奖引擎按用户阶段读取对应奖档。", + targetRtpPercent: "基础返奖目标。95 表示长期每消耗 100 金币,基础 RTP 目标返还 95 金币。", }; export function LuckyGiftConfigDrawer({ @@ -62,19 +70,65 @@ export function LuckyGiftConfigDrawer({ } + inputProps={{ max: 99, min: 10, step: 0.01 }} + label={} type="number" value={form.targetRtpPercent} onChange={(event) => updateForm(setForm, "targetRtpPercent", event.target.value)} /> - updateForm(setForm, "controlStrength", value)} + inputProps={{ max: 100, min: 10, step: 0.01 }} + label={} + type="number" + value={form.poolRatePercent} + onChange={(event) => updateForm(setForm, "poolRatePercent", event.target.value)} + /> + } + type="number" + value={form.settlementWindowWager} + onChange={(event) => updateForm(setForm, "settlementWindowWager", event.target.value)} + /> + } + type="number" + value={form.controlBandPercent} + onChange={(event) => updateForm(setForm, "controlBandPercent", event.target.value)} + /> + } + type="number" + value={form.giftPriceReference} + onChange={(event) => updateForm(setForm, "giftPriceReference", event.target.value)} + /> + } + type="number" + value={form.noviceMaxEquivalentDraws} + onChange={(event) => updateForm(setForm, "noviceMaxEquivalentDraws", event.target.value)} + /> + } + type="number" + value={form.normalMaxEquivalentDraws} + onChange={(event) => updateForm(setForm, "normalMaxEquivalentDraws", event.target.value)} />
-
体验策略
+
风控上限
- updateForm(setForm, "poolMode", value)} + inputProps={{ min: 1, step: 1 }} + label={} + type="number" + value={form.maxSinglePayout} + onChange={(event) => updateForm(setForm, "maxSinglePayout", event.target.value)} /> - updateForm(setForm, "noviceStrength", value)} + inputProps={{ min: 1, step: 1 }} + label={} + type="number" + value={form.userHourlyPayoutCap} + onChange={(event) => updateForm(setForm, "userHourlyPayoutCap", event.target.value)} /> - } + type="number" + value={form.userDailyPayoutCap} + onChange={(event) => updateForm(setForm, "userDailyPayoutCap", event.target.value)} /> - updateForm(setForm, "jackpotStrategy", value)} + inputProps={{ min: 1, step: 1 }} + label={} + type="number" + value={form.deviceDailyPayoutCap} + onChange={(event) => updateForm(setForm, "deviceDailyPayoutCap", event.target.value)} + /> + } + type="number" + value={form.roomHourlyPayoutCap} + onChange={(event) => updateForm(setForm, "roomHourlyPayoutCap", event.target.value)} + /> + } + type="number" + value={form.anchorDailyPayoutCap} + onChange={(event) => updateForm(setForm, "anchorDailyPayoutCap", event.target.value)} />
-
保护和补贴
-
- updateForm(setForm, "riskLevel", value)} - /> - updateForm(setForm, "atmosphereLevel", value)} - /> -
- updateForm(setForm, "activitySubsidyEnabled", event.target.checked)} - /> - {form.activitySubsidyEnabled ? ( -
- } - type="number" - value={form.activityBudget} - onChange={(event) => updateForm(setForm, "activityBudget", event.target.value)} - /> - } - type="number" - value={form.activityDailyLimit} - onChange={(event) => updateForm(setForm, "activityDailyLimit", event.target.value)} - /> -
- ) : null} -
-
+
阶段奖档
+
@@ -188,59 +219,173 @@ export function LuckyGiftConfigDrawer({ ); } -function MultiplierField({ disabled, help, setForm, values }) { - const list = values.length > 0 ? values : ["0"]; +function StageTierEditor({ disabled, form, setForm }) { + const [stageTargetRTP, setStageTargetRTP] = useState(() => defaultStageTargetRTP(form.targetRtpPercent)); + + useEffect(() => { + setStageTargetRTP(defaultStageTargetRTP(form.targetRtpPercent)); + }, [form.targetRtpPercent]); + return ( -
-
- - -
-
- {list.map((value, index) => ( -
- updateMultiplier(setForm, index, event.target.value)} - /> - removeMultiplier(setForm, index)} - > - - +
+ {stageOptions.map(([stageKey, stageLabel]) => { + 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); + const rtpInBand = expectedRTP >= lowerRTP && expectedRTP <= upperRTP; + return ( +
+
+ +
+ 概率 {formatPercent(stageProbabilityTotal(stage))} + + 期望 RTP {formatPercent(expectedRTP)} + +
+
+ + setStageTargetRTP((current) => ({ ...current, [stageKey]: event.target.value })) + } + /> + + +
+
+
+ {stage.tiers.map((tier, index) => ( + + ))} +
- ))} -
+ ); + })}
); } -function SelectField({ disabled, help, label, onChange, options, value }) { +function defaultStageTargetRTP(targetRTPPercent) { + return Object.fromEntries(stageOptions.map(([stageKey]) => [stageKey, String(targetRTPPercent || 95)])); +} + +function TierRow({ disabled, setForm, stageKey, stageLabel, tier, tierCount, tierIndex }) { + const updateTier = (patch) => updateStages(setForm, (stages) => updateStageTier(stages, stageKey, tierIndex, patch)); return ( - } - value={value} - onChange={(event) => onChange(event.target.value)} - > - {options.map(([optionValue, optionLabel]) => ( +
+ updateTier({ tierId: event.target.value })} + /> + updateTier({ multiplier: event.target.value })} + /> + updateTier({ probabilityPercent: event.target.value })} + /> +
贡献 {formatPercent(tierRTPContribution(tier))}
+ updateTier({ rewardSource: value })} + /> + updateTier({ broadcastLevel: value })} + /> +
+ updateTier({ highWaterOnly: event.target.checked })} + /> + } + label="高水位" + /> + updateTier({ enabled: event.target.checked })} + /> + } + label="启用" + /> +
+ updateStages(setForm, (stages) => removeTierFromStage(stages, stageKey, tierIndex))} + > + + +
+ ); +} + +function SelectField({ disabled, label, onChange, options, value }) { + return ( + onChange(event.target.value)}> + {options.map(([optionValue, optionLabelText]) => ( - {optionLabel} + {optionLabelText || optionLabel(options, optionValue)} ))} @@ -267,24 +412,11 @@ function updateForm(setForm, key, value) { setForm((current) => ({ ...current, [key]: value })); } -function updateMultiplier(setForm, index, value) { - setForm((current) => { - const nextValues = [...(current.multiplierValues || [])]; - nextValues[index] = value; - return { ...current, multiplierValues: nextValues }; - }); +function updateStages(setForm, updater) { + setForm((current) => ({ ...current, stages: updater(current.stages || []) })); } -function addMultiplier(setForm) { - setForm((current) => ({ - ...current, - multiplierValues: [...(current.multiplierValues || []), ""], - })); -} - -function removeMultiplier(setForm, index) { - setForm((current) => ({ - ...current, - multiplierValues: (current.multiplierValues || []).filter((_, itemIndex) => itemIndex !== index), - })); +function formatPercent(value) { + const number = Number(value || 0); + return `${number.toFixed(2).replace(/\.?0+$/, "")}%`; } diff --git a/src/features/lucky-gift/components/LuckyGiftConfigSummary.jsx b/src/features/lucky-gift/components/LuckyGiftConfigSummary.jsx index b767435..71369f3 100644 --- a/src/features/lucky-gift/components/LuckyGiftConfigSummary.jsx +++ b/src/features/lucky-gift/components/LuckyGiftConfigSummary.jsx @@ -6,14 +6,7 @@ import TextField from "@mui/material/TextField"; import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx"; import { Button } from "@/shared/ui/Button.jsx"; import { formatMillis } from "@/shared/utils/time.js"; -import { - atmosphereLevelOptions, - controlStrengthOptions, - jackpotStrategyOptions, - noviceStrengthOptions, - optionLabel, - poolModeOptions, -} from "@/features/lucky-gift/constants.js"; +import { stageOptions } from "@/features/lucky-gift/constants.js"; import { formFromConfig } from "@/features/lucky-gift/configModel.js"; import styles from "@/features/lucky-gift/lucky-gift.module.css"; @@ -40,23 +33,18 @@ export function LuckyGiftConfigSummary({
- {formatPercent(config?.targetRTPPPM)} - - {optionLabel(controlStrengthOptions, form.controlStrength)} - - {optionLabel(poolModeOptions, form.poolMode)} - - {optionLabel(noviceStrengthOptions, form.noviceStrength)} - - {formatMultipliers(form.multiplierValues)} - - {optionLabel(jackpotStrategyOptions, form.jackpotStrategy)} - - - {optionLabel(atmosphereLevelOptions, form.atmosphereLevel)} - + {formatPercent(config?.targetRTPPercent)} + {formatPercent(config?.poolRatePercent)} + {formatNumber(config?.settlementWindowWager)} + {formatPercent(config?.controlBandPercent)} + {formatNumber(config?.giftPriceReference)} + {formatNumber(config?.noviceMaxEquivalentDraws)} + {formatNumber(config?.normalMaxEquivalentDraws)} + {formatNumber(config?.maxSinglePayout)} + {formatNumber(config?.roomHourlyPayoutCap)} + {formatStages(form.stages)} - {config?.updatedAtMs ? formatMillis(config.updatedAtMs) : configLoading ? "加载中" : "-"} + {config?.createdAtMs ? formatMillis(config.createdAtMs) : configLoading ? "加载中" : "-"}
@@ -96,11 +84,16 @@ export function LuckyGiftConfigSummary({ ); } -function formatMultipliers(values) { - if (!Array.isArray(values) || values.length === 0) { +function formatStages(stages) { + if (!Array.isArray(stages) || stages.length === 0) { return "-"; } - return values.join(", "); + return stageOptions + .map(([stageKey, stageLabel]) => { + const stage = stages.find((item) => item.stage === stageKey); + return `${stageLabel} ${stage?.tiers?.length || 0}`; + }) + .join(" / "); } function SummaryItem({ children, label }) { @@ -126,7 +119,18 @@ function ConfigStatus({ config, loading }) { ); } -function formatPercent(ppm) { - const value = Number(ppm || 0) / 10_000; - return `${value.toFixed(2).replace(/\.?0+$/, "")}%`; +function formatPercent(value) { + const number = Number(value || 0); + if (!number) { + return "-"; + } + return `${number.toFixed(2).replace(/\.?0+$/, "")}%`; +} + +function formatNumber(value) { + const number = Number(value || 0); + if (!number) { + return "-"; + } + return new Intl.NumberFormat("zh-CN").format(number); } diff --git a/src/features/lucky-gift/configModel.js b/src/features/lucky-gift/configModel.js index 54f3a6f..3f0b9a1 100644 --- a/src/features/lucky-gift/configModel.js +++ b/src/features/lucky-gift/configModel.js @@ -1,31 +1,48 @@ -import { - atmosphereLevelConfig, - controlStrengthConfig, - jackpotStrategyConfig, - noviceStrengthConfig, - poolModeConfig, -} from "@/features/lucky-gift/constants.js"; +import { stageOptions } from "@/features/lucky-gift/constants.js"; +export const luckyGiftReferenceCost = 100; +export const minimumCorrectedProbabilityPercent = 0.01; const ppmScale = 1_000_000; -export const luckyGiftReferenceCost = 10_000; -const defaultTargetRTPPPM = 950_000; -const defaultDrawsPerHour = 3_600; -const defaultUsersPerRoom = 20; + +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 { - activityBudget: "0", - activityDailyLimit: "0", - activitySubsidyEnabled: false, - atmosphereLevel: "medium", - controlStrength: "standard", + controlBandPercent: "1", + effectiveFromMs: "0", enabled: false, - jackpotStrategy: "standard", - multiplierValues: ["0", "0.2", "0.5", "1", "2", "5", "10", "20", "50", "100", "500"], - noviceStrength: "standard", + giftPriceReference: String(luckyGiftReferenceCost), + noviceMaxEquivalentDraws: "2000", + normalMaxEquivalentDraws: "20000", + maxSinglePayout: "50000", + userHourlyPayoutCap: "34200", + userDailyPayoutCap: "615600", + deviceDailyPayoutCap: "1026000", + roomHourlyPayoutCap: "684000", + anchorDailyPayoutCap: "12312000", poolId: "default", - poolMode: "standard", - riskLevel: "standard", + poolRatePercent: "96", + settlementWindowWager: "1000000", + stages: cloneStages(defaultStages()), targetRtpPercent: "95", }; } @@ -34,252 +51,458 @@ export function formFromConfig(config) { if (!config) { return emptyLuckyGiftForm(); } - const activityBudget = Number(config.activityBudget || 0); - const activityDailyLimit = Number(config.activityDailyLimit || 0); + const fallback = emptyLuckyGiftForm(); return { - activityBudget: String(activityBudget), - activityDailyLimit: String(activityDailyLimit), - activitySubsidyEnabled: activityBudget > 0 || activityDailyLimit > 0, - atmosphereLevel: inferAtmosphereLevel(config.roomAtmosphereRatePPM), - controlStrength: inferControlStrength(config.globalWindowDraws, config.giftWindowDraws), + controlBandPercent: formatDecimal(config.controlBandPercent || fallback.controlBandPercent), + effectiveFromMs: String(config.effectiveFromMs || 0), enabled: Boolean(config.enabled), - jackpotStrategy: inferJackpotStrategy(config.largeTierEnabled, config.highWaterPoolMultiple), - multiplierValues: formatMultiplierValues(config.multiplierPPMs, config.tiers || []), - noviceStrength: inferNoviceStrength(config.noviceDrawLimit, config.intermediateDrawLimit), + 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", - poolMode: inferPoolMode(config), - riskLevel: inferRiskLevel(config), - targetRtpPercent: formatPercentInput(config.targetRTPPPM || defaultTargetRTPPPM), + 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, currentConfig) { - const giftPrice = luckyGiftReferenceCost; - const targetRTPPPM = percentToPPM(form.targetRtpPercent); - const control = controlStrengthConfig[form.controlStrength] || controlStrengthConfig.standard; - const poolMode = poolModeConfig[form.poolMode] || poolModeConfig.standard; - const novice = noviceStrengthConfig[form.noviceStrength] || noviceStrengthConfig.standard; - const jackpot = jackpotStrategyConfig[form.jackpotStrategy] || jackpotStrategyConfig.standard; - const atmosphere = atmosphereLevelConfig[form.atmosphereLevel] || atmosphereLevelConfig.medium; - const risk = riskCapsForLevel(form.riskLevel, giftPrice, targetRTPPPM); - const base = currentConfig || {}; - const multiplierPPMs = parseMultiplierValues(form.multiplierValues); - const maxMultiplierPPM = Math.max(...multiplierPPMs); - const highMultiplier = positiveNumber(base.highMultiplier, 100); - const activityBudget = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityBudget) : 0; - const activityDailyLimit = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityDailyLimit) : 0; - +export function payloadFromLuckyGiftForm(form) { + // 第一阶段的新契约明确要求后台只提交百分比和倍数;ppm 是 server 的持久化细节, + // 这里保留业务单位可以避免再次出现 target_rtp_ppm=95 这类单位错配。 return { - activity_budget: activityBudget, - activity_daily_limit: activityDailyLimit, - anchor_daily_payout_cap: risk.anchorDailyPayoutCap, - device_daily_payout_cap: risk.deviceDailyPayoutCap, + control_band_percent: decimal(form.controlBandPercent), + effective_from_ms: integer(form.effectiveFromMs), enabled: Boolean(form.enabled), - gift_pool_weight_ppm: poolMode.giftPoolWeightPPM, - gift_price: giftPrice, - gift_reserve: positiveNumber(base.giftReserve, 1_000_000), - global_window_draws: control.globalWindowDraws, - gift_window_draws: control.giftWindowDraws, - high_multiplier: highMultiplier, - high_water_pool_multiple: jackpot.highWaterPoolMultiple, - initial_gift_pool: positiveNumber(base.initialGiftPool, expectedPayout(giftPrice, targetRTPPPM, control.giftWindowDraws) / 2), - initial_platform_pool: positiveNumber( - base.initialPlatformPool, - weighted(expectedPayout(giftPrice, targetRTPPPM, control.globalWindowDraws), poolMode.platformPoolWeightPPM), - ), - initial_room_pool: positiveNumber(base.initialRoomPool, giftPrice * 10_000), - intermediate_draw_limit: novice.intermediateDrawLimit, - large_tier_enabled: jackpot.largeTierEnabled, - max_single_payout: Math.max(risk.maxSinglePayout, Math.ceil((giftPrice * maxMultiplierPPM) / ppmScale)), - multiplier_ppms: multiplierPPMs, - novice_draw_limit: novice.noviceDrawLimit, - platform_pool_weight_ppm: poolMode.platformPoolWeightPPM, - platform_reserve: positiveNumber(base.platformReserve, 1_000_000), - pool_id: String(form.poolId || base.poolId || "default").trim() || "default", - pool_rate_ppm: Math.max(targetRTPPPM, positiveNumber(base.poolRatePPM, targetRTPPPM)), - room_atmosphere_initial: positiveNumber(base.roomAtmosphereInitial, giftPrice * 200), - room_atmosphere_rate_ppm: atmosphere.roomAtmosphereRatePPM, - room_atmosphere_reserve: positiveNumber(base.roomAtmosphereReserve, giftPrice * 20), - room_hourly_payout_cap: risk.roomHourlyPayoutCap, - room_pool_weight_ppm: poolMode.roomPoolWeightPPM, - room_reserve: positiveNumber(base.roomReserve, giftPrice * 200), - target_rtp_ppm: targetRTPPPM, - tiers: buildTiers(giftPrice, multiplierPPMs, highMultiplier), - user_daily_payout_cap: risk.userDailyPayoutCap, - user_hourly_payout_cap: risk.userHourlyPayoutCap, + 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) { - const giftPrice = luckyGiftReferenceCost; - const targetRTPPPM = percentToPPM(form.targetRtpPercent); - const income = giftPrice * 100_000; - const basePayout = Math.round((income * targetRTPPPM) / ppmScale); - const activityBudget = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityBudget) : 0; - const activityDailyLimit = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityDailyLimit) : 0; + // 预览只用于管理员在保存前核对预算规模,不反推线上中奖概率; + // 真实抽奖控制必须以 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 { - activityBudget, - activityDailyLimit, basePayout, - income, - netBeforeSubsidy: income - basePayout, - targetRTPPPM, - visibleRTPPercent: ((basePayout + Math.min(activityBudget, activityDailyLimit || activityBudget)) * 100) / income, + controlBandPercent: decimal(form.controlBandPercent), + poolInflow, + spend, + targetRTPPercent: decimal(form.targetRtpPercent), }; } -function buildTiers(cost, multiplierPPMs, highMultiplier) { - return ["novice", "intermediate", "advanced"].flatMap((pool) => - multiplierPPMs.map((multiplierPPM) => - tier( - pool, - `${pool}_${multiplierLabel(multiplierPPM)}`, - Math.floor((cost * multiplierPPM) / ppmScale), - multiplierPPM, - highMultiplier > 0 && multiplierPPM >= highMultiplier * ppmScale, - ), - ), +export function addTierToStage(stages, stageKey) { + return stages.map((stage) => { + if (stage.stage !== stageKey) { + return stage; + } + const index = stage.tiers.length + 1; + return { + ...stage, + tiers: [ + ...stage.tiers, + tier(`${stage.stage}_${index}x`, "", "", { broadcastLevel: "none", 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 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 tier(pool, tierId, rewardCoins, multiplierPPM, highWaterOnly = false) { +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 { - enabled: true, - high_water_only: Boolean(highWaterOnly), - multiplier_ppm: multiplierPPM, - pool, - reward_coins: rewardCoins, - tier_id: tierId, - weight: 0, + broadcastLevel: options.broadcastLevel || "none", + enabled: options.enabled ?? true, + highWaterOnly: Boolean(options.highWaterOnly), + multiplier, + probabilityPercent, + rewardSource: options.rewardSource || "base_rtp", + tierId, }; } -export function parseMultiplierList(value) { - const numbers = String(value || "") - .split(/[,,\s]+/) - .map((item) => item.trim()) - .filter(Boolean) - .map((item) => Number(item.replace(/x$/i, ""))) - .filter((item) => Number.isFinite(item) && item >= 0) - .map((item) => Math.round(item * ppmScale)); - const unique = Array.from(new Set(numbers)).sort((a, b) => a - b); - return unique.length > 1 ? unique : [0, 1_000_000]; -} - -export function parseMultiplierValues(values) { - return parseMultiplierList((values || []).join(",")); -} - -function formatMultiplierValues(multiplierPPMs, tiers) { - const values = Array.isArray(multiplierPPMs) && multiplierPPMs.length > 0 ? multiplierPPMs : multipliersFromTiers(tiers); - return values.map(multiplierText); -} - -function multipliersFromTiers(tiers) { - const values = (tiers || []) - .map((tierItem) => Number(tierItem.multiplierPPM || 0)) - .filter((item) => item >= 0); - return values.length > 0 ? Array.from(new Set(values)).sort((a, b) => a - b) : parseMultiplierList(""); -} - -function multiplierLabel(multiplierPPM) { - return multiplierPPM === 0 ? "none" : `${multiplierText(multiplierPPM).replace(".", "_")}x`; -} - -function multiplierText(multiplierPPM) { - const value = multiplierPPM / ppmScale; - return Number.isInteger(value) ? String(value) : value.toFixed(6).replace(/\.?0+$/, ""); -} - -function riskCapsForLevel(level, giftPrice, targetRTPPPM) { - const expectedHour = expectedPayout(giftPrice, targetRTPPPM, defaultDrawsPerHour); - const expectedDay = expectedHour * 24; - const profile = - level === "strict" - ? { anchorDay: 1.2, deviceDay: 1.5, roomHour: 1.2, single: 200, userDay: 1.2, userHour: 1.25 } - : level === "loose" - ? { anchorDay: 2, deviceDay: 4, roomHour: 3, single: 500, userDay: 2, userHour: 3 } - : { anchorDay: 1.5, deviceDay: 2.5, roomHour: 2, single: 500, userDay: 1.5, userHour: 2 }; - return { - anchorDailyPayoutCap: Math.round(expectedDay * defaultUsersPerRoom * profile.anchorDay), - deviceDailyPayoutCap: Math.round(expectedDay * profile.deviceDay), - maxSinglePayout: giftPrice * profile.single, - roomHourlyPayoutCap: Math.round(expectedHour * defaultUsersPerRoom * profile.roomHour), - userDailyPayoutCap: Math.round(expectedDay * profile.userDay), - userHourlyPayoutCap: Math.round(expectedHour * profile.userHour), - }; -} - -function inferControlStrength(globalWindowDraws, giftWindowDraws) { - return matchConfig(controlStrengthConfig, (item) => { - return item.globalWindowDraws === globalWindowDraws && item.giftWindowDraws === giftWindowDraws; - }); -} - -function inferPoolMode(config) { - return matchConfig(poolModeConfig, (item) => { - return ( - item.platformPoolWeightPPM === config.platformPoolWeightPPM && - item.roomPoolWeightPPM === config.roomPoolWeightPPM && - item.giftPoolWeightPPM === config.giftPoolWeightPPM - ); - }); -} - -function inferNoviceStrength(noviceDrawLimit, intermediateDrawLimit) { - return matchConfig(noviceStrengthConfig, (item) => { - return item.noviceDrawLimit === noviceDrawLimit && item.intermediateDrawLimit === intermediateDrawLimit; - }); -} - -function inferJackpotStrategy(largeTierEnabled, highWaterPoolMultiple) { - if (!largeTierEnabled) { - return "off"; - } - if (highWaterPoolMultiple >= 3) { - return "conservative"; - } - if (highWaterPoolMultiple <= 1) { - return "aggressive"; - } - return "standard"; -} - -function inferAtmosphereLevel(ratePPM) { - return matchConfig(atmosphereLevelConfig, (item) => item.roomAtmosphereRatePPM === ratePPM); -} - -function inferRiskLevel() { - return "standard"; -} - -function matchConfig(configMap, matcher) { - return Object.entries(configMap).find(([, item]) => matcher(item))?.[0] || "standard"; -} - -function formatPercentInput(ppm) { - const percent = Number(ppm || 0) / 10_000; - return Number.isInteger(percent) ? String(percent) : percent.toFixed(2).replace(/\.?0+$/, ""); -} - -function percentToPPM(value) { - return Math.round(Number(value || 0) * 10_000); -} - -function expectedPayout(cost, targetRTPPPM, draws) { - return Math.round((cost * targetRTPPPM * draws) / ppmScale); -} - -function weighted(amount, weightPPM) { - return Math.round((amount * weightPPM) / ppmScale); -} - -function positiveNumber(value, fallback) { +function formatDecimal(value) { const number = Number(value || 0); - return Number.isFinite(number) && number > 0 ? Math.round(number) : Math.round(fallback); + if (!Number.isFinite(number)) { + return "0"; + } + return Number.isInteger(number) ? String(number) : number.toFixed(6).replace(/\.?0+$/, ""); } -function nonNegativeNumber(value) { +function decimal(value) { const number = Number(value || 0); - return Number.isFinite(number) && number >= 0 ? Math.round(number) : 0; + return Number.isFinite(number) ? number : 0; +} + +function integer(value) { + const number = Number(value || 0); + return Number.isFinite(number) ? Math.round(number) : 0; } diff --git a/src/features/lucky-gift/configModel.test.js b/src/features/lucky-gift/configModel.test.js index 6e923a9..b509263 100644 --- a/src/features/lucky-gift/configModel.test.js +++ b/src/features/lucky-gift/configModel.test.js @@ -1,28 +1,130 @@ import { describe, expect, test } from "vitest"; -import { emptyLuckyGiftForm, parseMultiplierList, payloadFromLuckyGiftForm } from "@/features/lucky-gift/configModel.js"; +import { + addTierToStage, + correctStageTierProbabilities, + emptyLuckyGiftForm, + payloadFromLuckyGiftForm, + stageExpectedRTP, + stageProbabilityTotal, + updateStageTier, +} from "@/features/lucky-gift/configModel.js"; describe("lucky gift config payload", () => { - test("submits both RTP control windows for backend validation", () => { - const payload = payloadFromLuckyGiftForm(emptyLuckyGiftForm(), null); + test("submits business units required by the v2 admin contract", () => { + const payload = payloadFromLuckyGiftForm(emptyLuckyGiftForm()); - expect(payload.global_window_draws).toBeGreaterThan(0); - expect(payload.gift_window_draws).toBeGreaterThan(0); - expect(payload.pool_id).toBe("default"); - expect(payload.multiplier_ppms).toContain(500_000_000); - expect(payload.tiers.every((tier) => tier.weight === 0)).toBe(true); + expect(payload.target_rtp_percent).toBe(95); + expect(payload.pool_rate_percent).toBe(96); + expect(payload.control_band_percent).toBe(1); + expect(payload.gift_price_reference).toBe(100); + expect(payload.novice_max_equivalent_draws).toBe(2000); + expect(payload.normal_max_equivalent_draws).toBe(20000); + expect(payload.max_single_payout).toBe(50000); + expect(payload.user_hourly_payout_cap).toBe(34200); + expect(payload.user_daily_payout_cap).toBe(615600); + expect(payload.device_daily_payout_cap).toBe(1026000); + expect(payload.room_hourly_payout_cap).toBe(684000); + expect(payload.anchor_daily_payout_cap).toBe(12312000); + expect(payload).not.toHaveProperty("target_rtp_ppm"); + expect(payload).not.toHaveProperty("multiplier_ppms"); + expect(payload.stages.map((stage) => stage.stage)).toEqual(["novice", "normal", "advanced"]); }); - test("parses custom multipliers without configured probabilities", () => { - expect(parseMultiplierList("0, 0.5x, 1, 2")).toEqual([0, 500_000, 1_000_000, 2_000_000]); + test("keeps each default stage probability at 100 percent", () => { + const form = emptyLuckyGiftForm(); + + expect(form.stages.map(stageProbabilityTotal)).toEqual([100, 100, 100]); }); - test("does not add a losing tier when tester only enters positive multipliers", () => { - const payload = payloadFromLuckyGiftForm( - { ...emptyLuckyGiftForm(), multiplierValues: ["1", "2"] }, - null, - ); + test("keeps each default stage expected RTP at target percent", () => { + const form = emptyLuckyGiftForm(); - expect(payload.multiplier_ppms).toEqual([1_000_000, 2_000_000]); - expect(payload.tiers.some((tier) => tier.multiplier_ppm === 0 || tier.reward_coins === 0)).toBe(false); + expect(form.stages.map(stageExpectedRTP)).toEqual([95, 95, 95]); + }); + + test("updates a single tier without mutating other stages", () => { + const form = emptyLuckyGiftForm(); + const nextStages = updateStageTier(form.stages, "normal", 0, { probabilityPercent: "39.5" }); + + expect(nextStages.find((stage) => stage.stage === "normal").tiers[0].probabilityPercent).toBe("39.5"); + expect(nextStages.find((stage) => stage.stage === "novice").tiers[0].probabilityPercent).toBe(10); + }); + + test("adds explicit editable tier to requested stage", () => { + const form = emptyLuckyGiftForm(); + const nextStages = addTierToStage(form.stages, "advanced"); + const advanced = nextStages.find((stage) => stage.stage === "advanced"); + + expect(advanced.tiers).toHaveLength(5); + expect(advanced.tiers[4]).toMatchObject({ broadcastLevel: "none", rewardSource: "base_rtp" }); + }); + + test("corrects a stage probability to requested RTP without zeroing enabled tiers", () => { + const form = emptyLuckyGiftForm(); + const nextStages = correctStageTierProbabilities(form.stages, "normal", 95); + const normal = nextStages.find((stage) => stage.stage === "normal"); + + expect(stageProbabilityTotal(normal)).toBeCloseTo(100, 6); + expect(stageExpectedRTP(normal)).toBeCloseTo(95, 6); + expect(enabledProbabilityPPMTotal(normal)).toBe(1_000_000); + expect(normal.tiers.map((tier) => Number(tier.multiplier))).toEqual([0, 0.5, 1, 2]); + expect(normal.tiers.every((tier) => Number(tier.probabilityPercent) > 0)).toBe(true); + }); + + test("clamps correction to closest reachable RTP when multiplier range cannot cover target", () => { + const form = emptyLuckyGiftForm(); + const nextStages = correctStageTierProbabilities(form.stages, "novice", 250); + const novice = nextStages.find((stage) => stage.stage === "novice"); + + expect(stageProbabilityTotal(novice)).toBeCloseTo(100, 6); + expect(stageExpectedRTP(novice)).toBeCloseTo(199.955, 6); + expect(novice.tiers.every((tier) => Number(tier.probabilityPercent) > 0)).toBe(true); + }); + + test("balances RTP contribution across high multipliers including 100x", () => { + const form = emptyLuckyGiftForm(); + const advanced = form.stages.find((stage) => stage.stage === "advanced"); + advanced.tiers = [ + luckyTier("advanced_none", 0), + luckyTier("advanced_1x", 1), + luckyTier("advanced_2x", 2), + luckyTier("advanced_5x", 5, { highWaterOnly: true }), + luckyTier("advanced_10x", 10, { highWaterOnly: true }), + luckyTier("advanced_100x", 100, { highWaterOnly: true }), + ]; + + const nextStages = correctStageTierProbabilities(form.stages, "advanced", 95); + const corrected = nextStages.find((stage) => stage.stage === "advanced"); + const positiveTiers = corrected.tiers.filter((tier) => Number(tier.multiplier) > 0); + const contributions = positiveTiers.map((tier) => Number(tier.multiplier) * Number(tier.probabilityPercent)); + const tier100x = corrected.tiers.find((tier) => tier.tierId === "advanced_100x"); + + expect(stageProbabilityTotal(corrected)).toBeCloseTo(100, 6); + expect(stageExpectedRTP(corrected)).toBeCloseTo(95, 6); + expect(enabledProbabilityPPMTotal(corrected)).toBe(1_000_000); + expect(corrected.tiers.every((tier) => Number(tier.probabilityPercent) > 0)).toBe(true); + expect(Number(tier100x.probabilityPercent)).toBeGreaterThan(0.01); + expect(Math.max(...contributions) - Math.min(...contributions)).toBeLessThan(1.1); }); }); + +function luckyTier(tierId, multiplier, options = {}) { + return { + broadcastLevel: "none", + enabled: true, + highWaterOnly: Boolean(options.highWaterOnly), + multiplier, + probabilityPercent: 1, + rewardSource: "base_rtp", + tierId, + }; +} + +function enabledProbabilityPPMTotal(stage) { + return stage.tiers.reduce((sum, tier) => { + if (tier.enabled === false) { + return sum; + } + return sum + Math.round(Number(tier.probabilityPercent) * 10_000); + }, 0); +} diff --git a/src/features/lucky-gift/constants.js b/src/features/lucky-gift/constants.js index b41e4d2..f8a2364 100644 --- a/src/features/lucky-gift/constants.js +++ b/src/features/lucky-gift/constants.js @@ -1,40 +1,20 @@ -export const controlStrengthOptions = [ - ["precise", "精准"], - ["standard", "标准"], - ["natural", "自然"], +export const stageOptions = [ + ["novice", "新手阶段"], + ["normal", "正常阶段"], + ["advanced", "高阶阶段"], ]; -export const poolModeOptions = [ - ["platform_guard", "平台兜底"], - ["standard", "标准模式"], - ["room_burst", "房间氛围"], - ["gift_isolated", "礼物独立"], +export const rewardSourceOptions = [ + ["base_rtp", "基础 RTP"], + ["activity_subsidy", "活动补贴"], + ["presentation_only", "表现奖励"], ]; -export const noviceStrengthOptions = [ - ["weak", "弱"], - ["standard", "标准"], - ["strong", "强"], -]; - -export const jackpotStrategyOptions = [ - ["off", "关闭"], - ["conservative", "保守"], - ["standard", "标准"], - ["aggressive", "激进"], -]; - -export const riskLevelOptions = [ - ["strict", "严格"], - ["standard", "标准"], - ["loose", "宽松"], -]; - -export const atmosphereLevelOptions = [ - ["off", "关闭"], - ["low", "低"], - ["medium", "中"], - ["high", "高"], +export const broadcastLevelOptions = [ + ["none", "不广播"], + ["room", "房间"], + ["region", "区域"], + ["global", "全站"], ]; export const drawStatusOptions = [ @@ -43,39 +23,6 @@ export const drawStatusOptions = [ ["failed", "发放失败"], ]; -export const controlStrengthConfig = { - precise: { globalWindowDraws: 50_000, giftWindowDraws: 50_000 }, - standard: { globalWindowDraws: 100_000, giftWindowDraws: 100_000 }, - natural: { globalWindowDraws: 300_000, giftWindowDraws: 300_000 }, -}; - -export const poolModeConfig = { - platform_guard: { platformPoolWeightPPM: 500_000, roomPoolWeightPPM: 200_000, giftPoolWeightPPM: 300_000 }, - standard: { platformPoolWeightPPM: 200_000, roomPoolWeightPPM: 300_000, giftPoolWeightPPM: 500_000 }, - room_burst: { platformPoolWeightPPM: 200_000, roomPoolWeightPPM: 450_000, giftPoolWeightPPM: 350_000 }, - gift_isolated: { platformPoolWeightPPM: 150_000, roomPoolWeightPPM: 200_000, giftPoolWeightPPM: 650_000 }, -}; - -export const noviceStrengthConfig = { - weak: { noviceDrawLimit: 1_000, intermediateDrawLimit: 10_000 }, - standard: { noviceDrawLimit: 2_000, intermediateDrawLimit: 20_000 }, - strong: { noviceDrawLimit: 5_000, intermediateDrawLimit: 30_000 }, -}; - -export const jackpotStrategyConfig = { - off: { largeTierEnabled: false, highWaterPoolMultiple: 3 }, - conservative: { largeTierEnabled: true, highWaterPoolMultiple: 3 }, - standard: { largeTierEnabled: true, highWaterPoolMultiple: 2 }, - aggressive: { largeTierEnabled: true, highWaterPoolMultiple: 1 }, -}; - -export const atmosphereLevelConfig = { - off: { roomAtmosphereRatePPM: 0 }, - low: { roomAtmosphereRatePPM: 5_000 }, - medium: { roomAtmosphereRatePPM: 10_000 }, - high: { roomAtmosphereRatePPM: 20_000 }, -}; - export function optionLabel(options, value) { return options.find(([optionValue]) => optionValue === value)?.[1] || value || "-"; } diff --git a/src/features/lucky-gift/lucky-gift.module.css b/src/features/lucky-gift/lucky-gift.module.css index 8330cb9..6b3b9f8 100644 --- a/src/features/lucky-gift/lucky-gift.module.css +++ b/src/features/lucky-gift/lucky-gift.module.css @@ -89,7 +89,7 @@ .configDrawer { box-sizing: border-box; - width: min(520px, 100vw); + width: min(960px, calc(100vw - 16px)); max-width: 100vw; overflow-x: hidden; } @@ -203,6 +203,110 @@ gap: var(--space-1); } +.stageStack { + display: grid; + min-width: 0; + gap: var(--space-3); +} + +.stageBlock { + display: grid; + min-width: 0; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--border-soft); + border-radius: var(--radius-control); + background: var(--bg-card-strong); +} + +.stageHeader { + display: grid; + min-width: 0; + grid-template-columns: minmax(0, 1fr) auto minmax(360px, auto); + align-items: center; + gap: var(--space-2); +} + +.stageMetrics { + display: inline-flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); +} + +.stageTotal { + overflow: hidden; + color: var(--text-tertiary); + font-size: var(--admin-font-size); + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stageRTPError { + overflow: hidden; + color: var(--danger, #dc2626); + font-size: var(--admin-font-size); + font-weight: 760; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stageTools { + display: inline-grid; + min-width: 0; + grid-template-columns: minmax(120px, 150px) auto auto; + align-items: center; + justify-content: end; + gap: var(--space-2); +} + +.tierList { + display: grid; + min-width: 0; + gap: var(--space-2); +} + +.tierRow { + display: grid; + min-width: 0; + grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); + align-items: center; + gap: var(--space-2); + padding: var(--space-2); + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); + background: var(--bg-card); +} + +.tierRow > :global(.MuiTextField-root), +.tierRow > :global(.MuiFormControl-root) { + width: 100%; + min-width: 0; +} + +.tierContribution { + min-width: 96px; + color: var(--text-tertiary); + font-size: var(--admin-font-size-sm); + font-weight: 700; + white-space: nowrap; +} + +.tierFlags { + display: inline-flex; + min-width: 0; + align-items: center; + gap: var(--space-1); + white-space: nowrap; +} + +.tierRow > :global(.MuiIconButton-root) { + justify-self: end; +} + .simulationPanel { display: grid; gap: var(--space-4); @@ -369,9 +473,14 @@ .simulationGrid, .simulationOverview, .drawSummaryGrid, - .multiplierList { + .multiplierList, + .tierRow { grid-template-columns: repeat(2, minmax(140px, 1fr)); } + + .tierRow > :global(.MuiIconButton-root) { + justify-self: end; + } } @media (max-width: 560px) { @@ -379,7 +488,13 @@ .subsidyInputs, .simulationGrid, .simulationOverview, - .drawSummaryGrid { + .drawSummaryGrid, + .stageHeader, + .tierRow { grid-template-columns: minmax(0, 1fr); } + + .tierFlags { + justify-content: space-between; + } } diff --git a/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx b/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx index 97d6a78..996a738 100644 --- a/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx +++ b/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx @@ -68,7 +68,7 @@ function LuckyGiftDrawSummaryPanel({ page }) { value: formatNumber(summary.totalDraws), }, { - hint: `目标 ${formatPPM(page.config?.targetRTPPPM)}`, + hint: `目标 ${formatPercent(page.config?.targetRTPPercent)}`, label: "实际 RTP", value: formatPPM(summary.actualRTPPPM), }, @@ -193,6 +193,12 @@ function formatPPM(value) { return `${(Number(value || 0) / 10_000).toFixed(2).replace(/\.?0+$/, "")}%`; } +function formatPercent(value) { + return `${Number(value || 0) + .toFixed(2) + .replace(/\.?0+$/, "")}%`; +} + function formatNumber(value) { return new Intl.NumberFormat("zh-CN").format(Number(value || 0)); } diff --git a/src/features/lucky-gift/schema.test.ts b/src/features/lucky-gift/schema.test.ts new file mode 100644 index 0000000..005a6ea --- /dev/null +++ b/src/features/lucky-gift/schema.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "vitest"; +import { emptyLuckyGiftForm } from "@/features/lucky-gift/configModel.js"; +import { luckyGiftConfigFormSchema } from "@/features/lucky-gift/schema"; + +describe("lucky gift config schema", () => { + test("validates enabled stage probability with backend ppm semantics", () => { + const form = emptyLuckyGiftForm(); + const normal = form.stages.find((stage: { stage: string }) => stage.stage === "normal"); + if (!normal) { + throw new Error("normal stage missing"); + } + normal.tiers[0].enabled = false; + + const result = luckyGiftConfigFormSchema.safeParse(form); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.map((issue) => issue.message)).toContain("启用奖档概率合计必须等于 100%"); + } + }); +}); diff --git a/src/features/lucky-gift/schema.ts b/src/features/lucky-gift/schema.ts index d94de72..34f6aeb 100644 --- a/src/features/lucky-gift/schema.ts +++ b/src/features/lucky-gift/schema.ts @@ -1,42 +1,139 @@ import { z } from "zod"; +const stageKeys = ["novice", "normal", "advanced"] as const; +const rewardSources = ["base_rtp", "activity_subsidy", "presentation_only"] as const; +const broadcastLevels = ["none", "room", "region", "global"] as const; +const ppmScale = 1_000_000; +const stageLabels: Record<(typeof stageKeys)[number], string> = { + novice: "新手阶段", + normal: "正常阶段", + advanced: "高阶阶段", +}; + +const stageTierSchema = z.object({ + broadcastLevel: z.enum(broadcastLevels), + enabled: z.boolean(), + highWaterOnly: z.boolean(), + 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 个字符"), +}); + +const stageSchema = z.object({ + stage: z.enum(stageKeys), + tiers: z.array(stageTierSchema).min(1, "阶段至少需要一个奖档"), +}); + export const luckyGiftConfigFormSchema = z .object({ - activityBudget: z.coerce.number().int("活动总预算必须是整数").min(0, "活动总预算不能小于 0"), - activityDailyLimit: z.coerce.number().int("每日预算必须是整数").min(0, "每日预算不能小于 0"), - activitySubsidyEnabled: z.boolean(), - atmosphereLevel: z.enum(["off", "low", "medium", "high"]), - controlStrength: z.enum(["precise", "standard", "natural"]), + controlBandPercent: z.coerce.number().gt(0, "波动带必须大于 0").max(5, "波动带不能超过 5%"), + effectiveFromMs: z.coerce.number().int("生效时间必须是毫秒整数").min(0, "生效时间不能小于 0"), enabled: z.boolean(), - jackpotStrategy: z.enum(["off", "conservative", "standard", "aggressive"]), - multiplierValues: z.array(z.coerce.number().min(0, "倍率不能小于 0")).min(1, "倍率配置不能为空"), - noviceStrength: z.enum(["weak", "standard", "strong"]), + giftPriceReference: z.coerce.number().int("参考价格必须是整数").gt(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"), + deviceDailyPayoutCap: z.coerce.number().int("设备每日上限必须是整数").gt(0, "设备每日上限必须大于 0"), + roomHourlyPayoutCap: z.coerce.number().int("房间小时上限必须是整数").gt(0, "房间小时上限必须大于 0"), + anchorDailyPayoutCap: z.coerce.number().int("主播每日上限必须是整数").gt(0, "主播每日上限必须大于 0"), poolId: z.string().trim().min(1, "奖池 ID 不能为空").max(96, "奖池 ID 不能超过 96 个字符"), - poolMode: z.enum(["platform_guard", "standard", "room_burst", "gift_isolated"]), - riskLevel: z.enum(["strict", "standard", "loose"]), - targetRtpPercent: z.coerce.number().min(1, "RTP 必须大于 1%").max(100, "RTP 不能超过 100%"), + poolRatePercent: z.coerce.number().min(10, "入池比例不能低于 10%").max(100, "入池比例不能超过 100%"), + settlementWindowWager: z.coerce.number().int("结算窗口必须是整数").gt(0, "结算窗口必须大于 0"), + stages: z.array(stageSchema).length(3, "必须配置新手、正常、高阶三个阶段"), + targetRtpPercent: z.coerce.number().min(10, "RTP 不能低于 10%").max(99, "RTP 不能超过 99%"), }) .superRefine((value, context) => { - if (value.activitySubsidyEnabled && value.activityBudget <= 0) { - context.addIssue({ code: "custom", message: "开启活动补贴后必须配置总预算", path: ["activityBudget"] }); + if (value.poolRatePercent < value.targetRtpPercent) { + context.addIssue({ code: "custom", message: "入池比例不能低于 RTP", path: ["poolRatePercent"] }); } - if (value.activitySubsidyEnabled && value.activityBudget > 0 && value.activityDailyLimit <= 0) { - context.addIssue({ code: "custom", message: "配置活动总预算时必须配置每日预算", path: ["activityDailyLimit"] }); + if (value.normalMaxEquivalentDraws < value.noviceMaxEquivalentDraws) { + context.addIssue({ + code: "custom", + message: "正常阶段等价抽数不能小于新手阶段", + path: ["normalMaxEquivalentDraws"], + }); } - if (value.activitySubsidyEnabled && value.activityDailyLimit > value.activityBudget && value.activityBudget > 0) { - context.addIssue({ code: "custom", message: "每日预算不能大于活动总预算", path: ["activityDailyLimit"] }); - } - const multipliers = value.multiplierValues - .map((item) => Number(item)) - .filter((item) => Number.isFinite(item) && item >= 0); - if (multipliers.length === 0 || Math.max(...multipliers) <= 0) { - context.addIssue({ code: "custom", message: "至少配置一个大于 0 的中奖倍率", path: ["multiplierValues"] }); - } - const maxMultiplier = Math.max(...multipliers, 0); - const targetMultiplier = Number(value.targetRtpPercent || 0) / 100; - if (maxMultiplier < targetMultiplier) { - context.addIssue({ code: "custom", message: "最大倍率不能低于 RTP 对应返还倍数", path: ["multiplierValues"] }); + + // 阶段奖档是第一步最关键的发布边界:每个阶段的概率必须闭合到 100%, + // 且必须显式保留 0x 档,否则低价礼物会被配置成不可控的“每抽必返”体验。 + for (const stageKey of stageKeys) { + const stageIndex = value.stages.findIndex((stage) => stage.stage === stageKey); + if (stageIndex < 0) { + context.addIssue({ code: "custom", message: "必须配置新手、正常、高阶三个阶段", path: ["stages"] }); + continue; + } + const stage = value.stages[stageIndex]; + const probabilityTotalPPM = stage.tiers.reduce((sum, tier) => { + if (!tier.enabled) { + return sum; + } + return sum + percentToPPM(tier.probabilityPercent); + }, 0); + if (probabilityTotalPPM !== ppmScale) { + context.addIssue({ + code: "custom", + message: "启用奖档概率合计必须等于 100%", + path: ["stages", stageIndex, "tiers"], + }); + } + if (!stage.tiers.some((tier) => tier.enabled && tier.rewardSource === "base_rtp")) { + context.addIssue({ + code: "custom", + message: "每个阶段至少需要一个启用的基础 RTP 奖档", + path: ["stages", stageIndex, "tiers"], + }); + } + if (!stage.tiers.some((tier) => tier.enabled && Number(tier.multiplier) === 0)) { + context.addIssue({ + code: "custom", + message: "每个阶段必须显式配置 0x 未中奖档", + path: ["stages", stageIndex, "tiers"], + }); + } + 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); + }, 0); + const expectedRTP = expectedRTPPPM / 10_000; + const lowerRTPPPM = percentToPPM(value.targetRtpPercent - value.controlBandPercent); + const upperRTPPPM = percentToPPM(value.targetRtpPercent + value.controlBandPercent); + if (expectedRTPPPM < lowerRTPPPM || expectedRTPPPM > upperRTPPPM) { + context.addIssue({ + code: "custom", + message: `${stageLabels[stage.stage]}期望 RTP 为 ${expectedRTP.toFixed(2)}%,必须在 ${(lowerRTPPPM / 10_000).toFixed(2)}%-${(upperRTPPPM / 10_000).toFixed(2)}% 内`, + path: ["stages", stageIndex, "tiers"], + }); + } + stage.tiers.forEach((tier, tierIndex) => { + if (tier.enabled && Number(tier.probabilityPercent) <= 0) { + context.addIssue({ + code: "custom", + message: "启用奖档概率必须大于 0;不想让该倍率出现请关闭奖档", + path: ["stages", stageIndex, "tiers", tierIndex, "probabilityPercent"], + }); + } + if (tier.enabled && Number(tier.multiplier) >= 5 && !tier.highWaterOnly) { + context.addIssue({ + code: "custom", + message: "5x 及以上奖档必须开启高水位限制", + path: ["stages", stageIndex, "tiers", tierIndex, "highWaterOnly"], + }); + } + }); } }); export type LuckyGiftConfigForm = z.infer; + +function percentToPPM(value: number): number { + return Math.round(Number(value || 0) * 10_000); +} + +function multiplierToPPM(value: number): number { + return Math.round(Number(value || 0) * ppmScale); +} diff --git a/src/features/lucky-gift/simulation.js b/src/features/lucky-gift/simulation.js index fb15e87..4e070dd 100644 --- a/src/features/lucky-gift/simulation.js +++ b/src/features/lucky-gift/simulation.js @@ -1,5 +1,3 @@ -const ppmScale = 1_000_000; - export function runLuckyGiftSimulation(config, input) { const users = clampInt(input.users, 1, 20_000); const rooms = clampInt(input.rooms, 1, users); @@ -7,7 +5,7 @@ export function runLuckyGiftSimulation(config, input) { const costMax = Math.max(costMin, clampInt(input.costMax, costMin, 1_000_000_000)); const drawsMin = clampInt(input.drawsMin, 0, 10_000_000); const drawsMax = Math.max(drawsMin, clampInt(input.drawsMax, drawsMin, 10_000_000)); - const targetRTP = Number(config?.targetRTPPPM || 950_000) / ppmScale; + const targetRTP = Number(config?.targetRTPPercent || 95) / 100; const seed = finiteSeed(input.seed) ?? runtimeSeed(); const random = createRandom(seed + users * 31 + rooms * 17 + costMin + costMax + drawsMin + drawsMax);