幸运礼物V2

This commit is contained in:
zhx 2026-05-29 12:55:44 +08:00
parent e0da37cec0
commit 19b09c3baa
11 changed files with 1265 additions and 653 deletions

View File

@ -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<string, unknown>;
type RawStage = LuckyGiftStageDto & Record<string, unknown>;
type RawTier = LuckyGiftTierDto & Record<string, unknown>;
type RawSummary = LuckyGiftDrawSummaryDto & Record<string, unknown>;
type LuckyGiftDrawSummaryQuery = Record<string, number | string | undefined>;
@ -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),
};
}

View File

@ -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({
<TextField
required
disabled={disabled}
inputProps={{ max: 100, min: 1, step: 0.01 }}
label={<FieldLabel help={helpText.targetRtpPercent} text="RTP" />}
inputProps={{ max: 99, min: 10, step: 0.01 }}
label={<FieldLabel help={helpText.targetRtpPercent} text="RTP %" />}
type="number"
value={form.targetRtpPercent}
onChange={(event) => updateForm(setForm, "targetRtpPercent", event.target.value)}
/>
<SelectField
<TextField
required
disabled={disabled}
help={helpText.controlStrength}
label="RTP 控制强度"
options={controlStrengthOptions}
value={form.controlStrength}
onChange={(value) => updateForm(setForm, "controlStrength", value)}
inputProps={{ max: 100, min: 10, step: 0.01 }}
label={<FieldLabel help={helpText.poolRatePercent} text="入池比例 %" />}
type="number"
value={form.poolRatePercent}
onChange={(event) => updateForm(setForm, "poolRatePercent", event.target.value)}
/>
<TextField
required
disabled={disabled}
inputProps={{ min: 1, step: 1 }}
label={<FieldLabel help={helpText.settlementWindowWager} text="结算窗口流水" />}
type="number"
value={form.settlementWindowWager}
onChange={(event) => updateForm(setForm, "settlementWindowWager", event.target.value)}
/>
<TextField
required
disabled={disabled}
inputProps={{ max: 5, min: 0.01, step: 0.01 }}
label={<FieldLabel help={helpText.controlBandPercent} text="波动带 %" />}
type="number"
value={form.controlBandPercent}
onChange={(event) => updateForm(setForm, "controlBandPercent", event.target.value)}
/>
<TextField
required
disabled={disabled}
inputProps={{ min: 1, step: 1 }}
label={<FieldLabel help={helpText.giftPriceReference} text="参考价格" />}
type="number"
value={form.giftPriceReference}
onChange={(event) => updateForm(setForm, "giftPriceReference", event.target.value)}
/>
<TextField
required
disabled={disabled}
inputProps={{ min: 0, step: 1 }}
label={<FieldLabel help={helpText.noviceMaxEquivalentDraws} text="新手等价抽数" />}
type="number"
value={form.noviceMaxEquivalentDraws}
onChange={(event) => updateForm(setForm, "noviceMaxEquivalentDraws", event.target.value)}
/>
<TextField
required
disabled={disabled}
inputProps={{ min: 0, step: 1 }}
label={<FieldLabel help={helpText.normalMaxEquivalentDraws} text="正常等价抽数" />}
type="number"
value={form.normalMaxEquivalentDraws}
onChange={(event) => updateForm(setForm, "normalMaxEquivalentDraws", event.target.value)}
/>
<SwitchField
checked={form.enabled}
@ -88,91 +142,68 @@ export function LuckyGiftConfigDrawer({
</section>
<section className="form-drawer__section">
<div className="form-drawer__section-title">体验策略</div>
<div className="form-drawer__section-title">风控上限</div>
<div className={styles.configGrid}>
<SelectField
<TextField
required
disabled={disabled}
help={helpText.poolMode}
label="奖池模式"
options={poolModeOptions}
value={form.poolMode}
onChange={(value) => updateForm(setForm, "poolMode", value)}
inputProps={{ min: 1, step: 1 }}
label={<FieldLabel help={helpText.maxSinglePayout} text="单次返奖上限" />}
type="number"
value={form.maxSinglePayout}
onChange={(event) => updateForm(setForm, "maxSinglePayout", event.target.value)}
/>
<SelectField
<TextField
required
disabled={disabled}
help={helpText.noviceStrength}
label="新手保护"
options={noviceStrengthOptions}
value={form.noviceStrength}
onChange={(value) => updateForm(setForm, "noviceStrength", value)}
inputProps={{ min: 1, step: 1 }}
label={<FieldLabel help={helpText.userHourlyPayoutCap} text="用户小时上限" />}
type="number"
value={form.userHourlyPayoutCap}
onChange={(event) => updateForm(setForm, "userHourlyPayoutCap", event.target.value)}
/>
<MultiplierField
<TextField
required
disabled={disabled}
help={helpText.multiplierValues}
setForm={setForm}
values={form.multiplierValues || []}
inputProps={{ min: 1, step: 1 }}
label={<FieldLabel help={helpText.userDailyPayoutCap} text="用户每日上限" />}
type="number"
value={form.userDailyPayoutCap}
onChange={(event) => updateForm(setForm, "userDailyPayoutCap", event.target.value)}
/>
<SelectField
<TextField
required
disabled={disabled}
help={helpText.jackpotStrategy}
label="大奖策略"
options={jackpotStrategyOptions}
value={form.jackpotStrategy}
onChange={(value) => updateForm(setForm, "jackpotStrategy", value)}
inputProps={{ min: 1, step: 1 }}
label={<FieldLabel help={helpText.deviceDailyPayoutCap} text="设备每日上限" />}
type="number"
value={form.deviceDailyPayoutCap}
onChange={(event) => updateForm(setForm, "deviceDailyPayoutCap", event.target.value)}
/>
<TextField
required
disabled={disabled}
inputProps={{ min: 1, step: 1 }}
label={<FieldLabel help={helpText.roomHourlyPayoutCap} text="房间小时上限" />}
type="number"
value={form.roomHourlyPayoutCap}
onChange={(event) => updateForm(setForm, "roomHourlyPayoutCap", event.target.value)}
/>
<TextField
required
disabled={disabled}
inputProps={{ min: 1, step: 1 }}
label={<FieldLabel help={helpText.anchorDailyPayoutCap} text="主播每日上限" />}
type="number"
value={form.anchorDailyPayoutCap}
onChange={(event) => updateForm(setForm, "anchorDailyPayoutCap", event.target.value)}
/>
</div>
</section>
<section className="form-drawer__section">
<div className="form-drawer__section-title">保护和补贴</div>
<div className={styles.configGrid}>
<SelectField
disabled={disabled}
help={helpText.riskLevel}
label="风控等级"
options={riskLevelOptions}
value={form.riskLevel}
onChange={(value) => updateForm(setForm, "riskLevel", value)}
/>
<SelectField
disabled={disabled}
help={helpText.atmosphereLevel}
label="房间爆点强度"
options={atmosphereLevelOptions}
value={form.atmosphereLevel}
onChange={(value) => updateForm(setForm, "atmosphereLevel", value)}
/>
<div className={styles.subsidyBlock}>
<SwitchField
checked={form.activitySubsidyEnabled}
className={styles.subsidyToggle}
disabled={disabled}
help={helpText.activitySubsidyEnabled}
label="活动补贴"
onChange={(event) => updateForm(setForm, "activitySubsidyEnabled", event.target.checked)}
/>
{form.activitySubsidyEnabled ? (
<div className={styles.subsidyInputs}>
<TextField
disabled={disabled}
inputProps={{ min: 0, step: 1 }}
label={<FieldLabel help={helpText.activityBudget} text="活动总预算" />}
type="number"
value={form.activityBudget}
onChange={(event) => updateForm(setForm, "activityBudget", event.target.value)}
/>
<TextField
disabled={disabled}
inputProps={{ min: 0, step: 1 }}
label={<FieldLabel help={helpText.activityDailyLimit} text="活动每日预算" />}
type="number"
value={form.activityDailyLimit}
onChange={(event) => updateForm(setForm, "activityDailyLimit", event.target.value)}
/>
</div>
) : null}
</div>
</div>
<div className="form-drawer__section-title">阶段奖档</div>
<StageTierEditor disabled={disabled} form={form} setForm={setForm} />
</section>
<div className="form-drawer__actions">
@ -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 (
<div className={[styles.multiplierBlock, styles.configWide].join(" ")}>
<div className={styles.multiplierHeader}>
<FieldLabel help={help} text="倍率配置" />
<Button
disabled={disabled}
startIcon={<AddOutlined fontSize="small" />}
type="button"
onClick={() => addMultiplier(setForm)}
>
添加倍率
</Button>
</div>
<div className={styles.multiplierList}>
{list.map((value, index) => (
<div key={index} className={styles.multiplierRow}>
<TextField
disabled={disabled}
inputProps={{ min: 0, step: 0.01 }}
label={`倍率 ${index + 1}`}
type="number"
value={value}
onChange={(event) => updateMultiplier(setForm, index, event.target.value)}
/>
<IconButton
aria-label="删除倍率"
disabled={disabled || list.length <= 1}
size="small"
onClick={() => removeMultiplier(setForm, index)}
>
<DeleteOutlineOutlined fontSize="small" />
</IconButton>
<div className={styles.stageStack}>
{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 (
<div key={stageKey} className={styles.stageBlock}>
<div className={styles.stageHeader}>
<FieldLabel help={helpText.stages} text={stageLabel} />
<div className={styles.stageMetrics}>
<span className={styles.stageTotal}>概率 {formatPercent(stageProbabilityTotal(stage))}</span>
<span className={rtpInBand ? styles.stageTotal : styles.stageRTPError}>
期望 RTP {formatPercent(expectedRTP)}
</span>
</div>
<div className={styles.stageTools}>
<TextField
disabled={disabled}
inputProps={{ max: 500, min: 0, step: 0.01 }}
label="纠正 RTP %"
size="small"
type="number"
value={stageTargetRTP[stageKey] ?? form.targetRtpPercent}
onChange={(event) =>
setStageTargetRTP((current) => ({ ...current, [stageKey]: event.target.value }))
}
/>
<Button
disabled={disabled}
type="button"
onClick={() =>
updateStages(setForm, (stages) =>
correctStageTierProbabilities(stages, stageKey, stageTargetRTP[stageKey])
)
}
>
纠正概率
</Button>
<Button
disabled={disabled}
startIcon={<AddOutlined fontSize="small" />}
type="button"
onClick={() => updateStages(setForm, (stages) => addTierToStage(stages, stageKey))}
>
添加奖档
</Button>
</div>
</div>
<div className={styles.tierList}>
{stage.tiers.map((tier, index) => (
<TierRow
key={`${stageKey}-${index}`}
disabled={disabled}
stageKey={stageKey}
stageLabel={stageLabel}
tier={tier}
tierIndex={index}
tierCount={stage.tiers.length}
setForm={setForm}
/>
))}
</div>
</div>
))}
</div>
);
})}
</div>
);
}
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 (
<TextField
select
disabled={disabled}
label={<FieldLabel help={help} text={label} />}
value={value}
onChange={(event) => onChange(event.target.value)}
>
{options.map(([optionValue, optionLabel]) => (
<div className={styles.tierRow}>
<TextField
required
disabled={disabled}
label="奖档 ID"
value={tier.tierId}
onChange={(event) => updateTier({ tierId: event.target.value })}
/>
<TextField
required
disabled={disabled}
inputProps={{ min: 0, step: 0.01 }}
label="倍率"
type="number"
value={tier.multiplier}
onChange={(event) => updateTier({ multiplier: event.target.value })}
/>
<TextField
required
disabled={disabled}
inputProps={{ max: 100, min: 0, step: 0.01 }}
label="概率 %"
type="number"
value={tier.probabilityPercent}
onChange={(event) => updateTier({ probabilityPercent: event.target.value })}
/>
<div className={styles.tierContribution}>贡献 {formatPercent(tierRTPContribution(tier))}</div>
<SelectField
disabled={disabled}
label="预算来源"
options={rewardSourceOptions}
value={tier.rewardSource}
onChange={(value) => updateTier({ rewardSource: value })}
/>
<SelectField
disabled={disabled}
label="广播"
options={broadcastLevelOptions}
value={tier.broadcastLevel}
onChange={(value) => updateTier({ broadcastLevel: value })}
/>
<div className={styles.tierFlags}>
<FormControlLabel
control={
<Checkbox
checked={Boolean(tier.highWaterOnly)}
disabled={disabled}
onChange={(event) => updateTier({ highWaterOnly: event.target.checked })}
/>
}
label="高水位"
/>
<FormControlLabel
control={
<Checkbox
checked={tier.enabled !== false}
disabled={disabled}
onChange={(event) => updateTier({ enabled: event.target.checked })}
/>
}
label="启用"
/>
</div>
<IconButton
aria-label={`删除${stageLabel}奖档`}
disabled={disabled || tierCount <= 1}
size="small"
onClick={() => updateStages(setForm, (stages) => removeTierFromStage(stages, stageKey, tierIndex))}
>
<DeleteOutlineOutlined fontSize="small" />
</IconButton>
</div>
);
}
function SelectField({ disabled, label, onChange, options, value }) {
return (
<TextField select disabled={disabled} label={label} value={value} onChange={(event) => onChange(event.target.value)}>
{options.map(([optionValue, optionLabelText]) => (
<MenuItem key={optionValue} value={optionValue}>
{optionLabel}
{optionLabelText || optionLabel(options, optionValue)}
</MenuItem>
))}
</TextField>
@ -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+$/, "")}%`;
}

View File

@ -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({
</div>
<div className={styles.summaryItems}>
<ConfigStatus config={config} loading={configLoading} />
<SummaryItem label="RTP">{formatPercent(config?.targetRTPPPM)}</SummaryItem>
<SummaryItem label="控制强度">
{optionLabel(controlStrengthOptions, form.controlStrength)}
</SummaryItem>
<SummaryItem label="奖池模式">{optionLabel(poolModeOptions, form.poolMode)}</SummaryItem>
<SummaryItem label="新手保护">
{optionLabel(noviceStrengthOptions, form.noviceStrength)}
</SummaryItem>
<SummaryItem label="倍率">{formatMultipliers(form.multiplierValues)}</SummaryItem>
<SummaryItem label="大奖策略">
{optionLabel(jackpotStrategyOptions, form.jackpotStrategy)}
</SummaryItem>
<SummaryItem label="房间爆点">
{optionLabel(atmosphereLevelOptions, form.atmosphereLevel)}
</SummaryItem>
<SummaryItem label="RTP">{formatPercent(config?.targetRTPPercent)}</SummaryItem>
<SummaryItem label="入池">{formatPercent(config?.poolRatePercent)}</SummaryItem>
<SummaryItem label="窗口">{formatNumber(config?.settlementWindowWager)}</SummaryItem>
<SummaryItem label="波动">{formatPercent(config?.controlBandPercent)}</SummaryItem>
<SummaryItem label="参考价格">{formatNumber(config?.giftPriceReference)}</SummaryItem>
<SummaryItem label="新手等价抽">{formatNumber(config?.noviceMaxEquivalentDraws)}</SummaryItem>
<SummaryItem label="正常等价抽">{formatNumber(config?.normalMaxEquivalentDraws)}</SummaryItem>
<SummaryItem label="单次上限">{formatNumber(config?.maxSinglePayout)}</SummaryItem>
<SummaryItem label="房间小时">{formatNumber(config?.roomHourlyPayoutCap)}</SummaryItem>
<SummaryItem label="阶段">{formatStages(form.stages)}</SummaryItem>
<SummaryItem label="更新时间">
{config?.updatedAtMs ? formatMillis(config.updatedAtMs) : configLoading ? "加载中" : "-"}
{config?.createdAtMs ? formatMillis(config.createdAtMs) : configLoading ? "加载中" : "-"}
</SummaryItem>
</div>
</div>
@ -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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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%");
}
});
});

View File

@ -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<typeof luckyGiftConfigFormSchema>;
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);
}

View File

@ -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);