幸运礼物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"; import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
export interface LuckyGiftTierDto { export interface LuckyGiftTierDto {
pool: string;
tierId: string; tierId: string;
rewardCoins: number; multiplier: number;
multiplierPPM: number; probabilityPercent: number;
weight: number; rewardSource: string;
highWaterOnly: boolean; highWaterOnly: boolean;
broadcastLevel: string;
enabled: boolean; enabled: boolean;
} }
export interface LuckyGiftStageDto {
stage: string;
tiers: LuckyGiftTierDto[];
}
export interface LuckyGiftConfigDto { export interface LuckyGiftConfigDto {
appCode?: string; appCode?: string;
poolId: string; poolId: string;
enabled: boolean; enabled: boolean;
ruleVersion: number; ruleVersion: number;
giftPrice: number; targetRTPPercent: number;
targetRTPPPM: number; poolRatePercent: number;
poolRatePPM: number; settlementWindowWager: number;
globalWindowDraws: number; controlBandPercent: number;
giftWindowDraws: number; giftPriceReference: number;
noviceDrawLimit: number; noviceMaxEquivalentDraws: number;
intermediateDrawLimit: number; normalMaxEquivalentDraws: number;
highMultiplier: number;
highWaterPoolMultiple: number;
platformPoolWeightPPM: number;
roomPoolWeightPPM: number;
giftPoolWeightPPM: number;
initialPlatformPool: number;
initialGiftPool: number;
initialRoomPool: number;
platformReserve: number;
giftReserve: number;
roomReserve: number;
maxSinglePayout: number; maxSinglePayout: number;
userHourlyPayoutCap: number; userHourlyPayoutCap: number;
userDailyPayoutCap: number; userDailyPayoutCap: number;
deviceDailyPayoutCap: number; deviceDailyPayoutCap: number;
roomHourlyPayoutCap: number; roomHourlyPayoutCap: number;
anchorDailyPayoutCap: number; anchorDailyPayoutCap: number;
roomAtmosphereRatePPM: number; effectiveFromMs: number;
roomAtmosphereInitial: number; createdByAdminId?: number;
roomAtmosphereReserve: number;
activityBudget: number;
activityDailyLimit: number;
largeTierEnabled: boolean;
multiplierPPMs: number[];
tiers: LuckyGiftTierDto[];
updatedByAdminId?: number;
createdAtMs?: number; createdAtMs?: number;
updatedAtMs?: number; stages: LuckyGiftStageDto[];
} }
export interface LuckyGiftConfigPayload { export interface LuckyGiftConfigPayload {
pool_id: string; pool_id: string;
enabled: boolean; enabled: boolean;
gift_price: number; target_rtp_percent: number;
target_rtp_ppm: number; pool_rate_percent: number;
pool_rate_ppm: number; settlement_window_wager: number;
global_window_draws: number; control_band_percent: number;
gift_window_draws: number; gift_price_reference: number;
novice_draw_limit: number; novice_max_equivalent_draws: number;
intermediate_draw_limit: number; normal_max_equivalent_draws: 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;
max_single_payout: number; max_single_payout: number;
user_hourly_payout_cap: number; user_hourly_payout_cap: number;
user_daily_payout_cap: number; user_daily_payout_cap: number;
device_daily_payout_cap: number; device_daily_payout_cap: number;
room_hourly_payout_cap: number; room_hourly_payout_cap: number;
anchor_daily_payout_cap: number; anchor_daily_payout_cap: number;
room_atmosphere_rate_ppm: number; effective_from_ms: number;
room_atmosphere_initial: number; stages: Array<{
room_atmosphere_reserve: number; stage: string;
activity_budget: number; tiers: Array<{
activity_daily_limit: number; tier_id: string;
large_tier_enabled: boolean; multiplier: number;
multiplier_ppms: number[]; probability_percent: number;
tiers: Array<{ reward_source: string;
pool: string; high_water_only: boolean;
tier_id: string; broadcast_level: string;
reward_coins: number; enabled: boolean;
multiplier_ppm: number; }>;
weight: number;
high_water_only: boolean;
enabled: boolean;
}>; }>;
} }
@ -115,6 +88,7 @@ export interface LuckyGiftDrawSummaryDto {
} }
type RawConfig = LuckyGiftConfigDto & Record<string, unknown>; type RawConfig = LuckyGiftConfigDto & Record<string, unknown>;
type RawStage = LuckyGiftStageDto & Record<string, unknown>;
type RawTier = LuckyGiftTierDto & Record<string, unknown>; type RawTier = LuckyGiftTierDto & Record<string, unknown>;
type RawSummary = LuckyGiftDrawSummaryDto & Record<string, unknown>; type RawSummary = LuckyGiftDrawSummaryDto & Record<string, unknown>;
type LuckyGiftDrawSummaryQuery = Record<string, number | string | undefined>; type LuckyGiftDrawSummaryQuery = Record<string, number | string | undefined>;
@ -152,59 +126,52 @@ export function getLuckyGiftDrawSummary(query: LuckyGiftDrawSummaryQuery = {}):
} }
function normalizeConfig(item: RawConfig): LuckyGiftConfigDto { function normalizeConfig(item: RawConfig): LuckyGiftConfigDto {
// admin-server 已经把内部 ppm 转回业务单位;前端只接受 percent/multiplier 字段,
// 这样页面展示、表单校验和提交 payload 使用同一套单位。
return { return {
appCode: stringValue(item.appCode ?? item.app_code), 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), enabled: booleanValue(item.enabled),
ruleVersion: numberValue(item.ruleVersion ?? item.rule_version), giftPriceReference: numberValue(item.giftPriceReference ?? item.gift_price_reference),
giftPrice: numberValue(item.giftPrice ?? item.gift_price), noviceMaxEquivalentDraws: numberValue(item.noviceMaxEquivalentDraws ?? item.novice_max_equivalent_draws),
targetRTPPPM: numberValue(item.targetRTPPPM ?? item.target_rtp_ppm), normalMaxEquivalentDraws: numberValue(item.normalMaxEquivalentDraws ?? item.normal_max_equivalent_draws),
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),
maxSinglePayout: numberValue(item.maxSinglePayout ?? item.max_single_payout), maxSinglePayout: numberValue(item.maxSinglePayout ?? item.max_single_payout),
userHourlyPayoutCap: numberValue(item.userHourlyPayoutCap ?? item.user_hourly_payout_cap), userHourlyPayoutCap: numberValue(item.userHourlyPayoutCap ?? item.user_hourly_payout_cap),
userDailyPayoutCap: numberValue(item.userDailyPayoutCap ?? item.user_daily_payout_cap), userDailyPayoutCap: numberValue(item.userDailyPayoutCap ?? item.user_daily_payout_cap),
deviceDailyPayoutCap: numberValue(item.deviceDailyPayoutCap ?? item.device_daily_payout_cap), deviceDailyPayoutCap: numberValue(item.deviceDailyPayoutCap ?? item.device_daily_payout_cap),
roomHourlyPayoutCap: numberValue(item.roomHourlyPayoutCap ?? item.room_hourly_payout_cap), roomHourlyPayoutCap: numberValue(item.roomHourlyPayoutCap ?? item.room_hourly_payout_cap),
anchorDailyPayoutCap: numberValue(item.anchorDailyPayoutCap ?? item.anchor_daily_payout_cap), anchorDailyPayoutCap: numberValue(item.anchorDailyPayoutCap ?? item.anchor_daily_payout_cap),
roomAtmosphereRatePPM: numberValue(item.roomAtmosphereRatePPM ?? item.room_atmosphere_rate_ppm), poolId: stringValue(item.poolId ?? item.pool_id),
roomAtmosphereInitial: numberValue(item.roomAtmosphereInitial ?? item.room_atmosphere_initial), poolRatePercent: numberValue(item.poolRatePercent ?? item.pool_rate_percent),
roomAtmosphereReserve: numberValue(item.roomAtmosphereReserve ?? item.room_atmosphere_reserve), ruleVersion: numberValue(item.ruleVersion ?? item.rule_version),
activityBudget: numberValue(item.activityBudget ?? item.activity_budget), settlementWindowWager: numberValue(item.settlementWindowWager ?? item.settlement_window_wager),
activityDailyLimit: numberValue(item.activityDailyLimit ?? item.activity_daily_limit), stages: arrayValue(item.stages).map(normalizeStage),
largeTierEnabled: booleanValue(item.largeTierEnabled ?? item.large_tier_enabled), targetRTPPercent: numberValue(item.targetRTPPercent ?? item.target_rtp_percent),
multiplierPPMs: arrayValue(item.multiplierPPMs ?? item.multiplier_ppms).map(numberValue), 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), 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 { function normalizeTier(raw: unknown): LuckyGiftTierDto {
const item = asRecord(raw) as RawTier; const item = asRecord(raw) as RawTier;
// 奖档概率是发布配置的一部分,不再使用旧 weight=0 的运行时自动权重模型。
return { return {
pool: stringValue(item.pool), broadcastLevel: stringValue(item.broadcastLevel ?? item.broadcast_level),
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),
enabled: booleanValue(item.enabled), 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 AddOutlined from "@mui/icons-material/AddOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import SaveOutlined from "@mui/icons-material/SaveOutlined"; import SaveOutlined from "@mui/icons-material/SaveOutlined";
import Checkbox from "@mui/material/Checkbox";
import Drawer from "@mui/material/Drawer"; import Drawer from "@mui/material/Drawer";
import FormControlLabel from "@mui/material/FormControlLabel";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField"; 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 { Button } from "@/shared/ui/Button.jsx";
import { FieldLabel } from "@/features/lucky-gift/components/FieldLabel.jsx"; import { FieldLabel } from "@/features/lucky-gift/components/FieldLabel.jsx";
import { import {
atmosphereLevelOptions, addTierToStage,
controlStrengthOptions, correctStageTierProbabilities,
jackpotStrategyOptions, removeTierFromStage,
noviceStrengthOptions, stageExpectedRTP,
poolModeOptions, stageProbabilityTotal,
riskLevelOptions, tierRTPContribution,
} from "@/features/lucky-gift/constants.js"; 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"; import styles from "@/features/lucky-gift/lucky-gift.module.css";
const helpText = { const helpText = {
enabled: "控制全站幸运礼物抽奖是否生效,关闭后用户仍可送礼但不触发幸运抽奖。", controlBandPercent: "RTP 正常波动带。1 表示允许当前窗口在目标值上下 1% 内自然波动。",
poolId: "奖池 ID 是请求接口传入的隔离键,不同奖池的 RTP、预算、风控和用户阶段互不影响。", enabled: "控制全站幸运礼物抽奖是否生效。关闭后用户仍可送礼,但不会触发幸运抽奖。",
targetRtpPercent: "基础返奖成本比例95 表示长期每消耗 100 金币,基础奖池目标返还 95 金币。", giftPriceReference: "配置奖档时使用的参考礼物价格。真实抽奖会按用户实际消耗金币等比缩放奖励。",
controlStrength: "控制 RTP 收敛窗口大小,越精准越快贴近目标,越自然短期波动越大。", noviceMaxEquivalentDraws: "新手阶段截止等价抽数。等价抽数=累计消耗金币/参考价格,不同价格礼物会按金币流水推进。",
poolMode: "决定平台池、房间池、整体幸运礼物池分别承担多少基础返奖责任。", normalMaxEquivalentDraws: "正常阶段截止等价抽数。超过该值后进入高阶阶段,不能小于新手阶段阈值。",
noviceStrength: "控制新手池持续抽数,越强表示用户前期更长时间处于低波动体验。", maxSinglePayout: "单次基础返奖上限。超过该值的候选会被风控剪掉,真实抽奖按实际消耗金币等比缩放。",
multiplierValues: "配置用户可能命中的倍率,例如 0、0.5、1、2、10、100每个倍率的概率由后端按 RTP 窗口自动生成。", poolId: "奖池 ID 是幸运礼物规则隔离键。不同奖池拥有独立 RTP、窗口和阶段奖档。",
jackpotStrategy: "控制高倍奖档开放强度,保守需要更高水位,关闭则不出高倍大奖。", poolRatePercent: "每笔幸运礼物扣费进入基础奖池的比例,必须大于等于 RTP避免奖池长期亏空。",
riskLevel: "控制单用户、设备、房间和主播关联的奖励上限,越严格越防套利。", userHourlyPayoutCap: "同一用户每小时基础返奖上限。达到上限后,该用户会优先进入 0x 或低倍率。",
atmosphereLevel: "控制房间气氛池入账强度,用于房间共享爆点,不计入基础 RTP。", userDailyPayoutCap: "同一用户每日基础返奖上限。按 UTC 自然日统计。",
activitySubsidyEnabled: "活动补贴是独立预算,开启后会提高用户可见奖励,但不污染基础 RTP。", deviceDailyPayoutCap: "同一设备每日基础返奖上限。用于限制多账号集中领取。",
activityBudget: "活动期最多可额外补贴的金币总额。", roomHourlyPayoutCap: "同一房间每小时基础返奖上限。房间触顶后,新用户也会被该房间上限影响。",
activityDailyLimit: "活动补贴每天最多释放的金币额度。", anchorDailyPayoutCap: "同一收礼主播每日基础返奖上限。按 UTC 自然日统计。",
settlementWindowWager: "RTP 结算窗口按金币流水计算,不按抽数计算,低价和高价礼物会自然等比参与控制。",
stages: "新手、正常、高阶三个阶段必须各自配置概率,抽奖引擎按用户阶段读取对应奖档。",
targetRtpPercent: "基础返奖目标。95 表示长期每消耗 100 金币,基础 RTP 目标返还 95 金币。",
}; };
export function LuckyGiftConfigDrawer({ export function LuckyGiftConfigDrawer({
@ -62,19 +70,65 @@ export function LuckyGiftConfigDrawer({
<TextField <TextField
required required
disabled={disabled} disabled={disabled}
inputProps={{ max: 100, min: 1, step: 0.01 }} inputProps={{ max: 99, min: 10, step: 0.01 }}
label={<FieldLabel help={helpText.targetRtpPercent} text="RTP" />} label={<FieldLabel help={helpText.targetRtpPercent} text="RTP %" />}
type="number" type="number"
value={form.targetRtpPercent} value={form.targetRtpPercent}
onChange={(event) => updateForm(setForm, "targetRtpPercent", event.target.value)} onChange={(event) => updateForm(setForm, "targetRtpPercent", event.target.value)}
/> />
<SelectField <TextField
required
disabled={disabled} disabled={disabled}
help={helpText.controlStrength} inputProps={{ max: 100, min: 10, step: 0.01 }}
label="RTP 控制强度" label={<FieldLabel help={helpText.poolRatePercent} text="入池比例 %" />}
options={controlStrengthOptions} type="number"
value={form.controlStrength} value={form.poolRatePercent}
onChange={(value) => updateForm(setForm, "controlStrength", value)} 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 <SwitchField
checked={form.enabled} checked={form.enabled}
@ -88,91 +142,68 @@ export function LuckyGiftConfigDrawer({
</section> </section>
<section className="form-drawer__section"> <section className="form-drawer__section">
<div className="form-drawer__section-title">体验策略</div> <div className="form-drawer__section-title">风控上限</div>
<div className={styles.configGrid}> <div className={styles.configGrid}>
<SelectField <TextField
required
disabled={disabled} disabled={disabled}
help={helpText.poolMode} inputProps={{ min: 1, step: 1 }}
label="奖池模式" label={<FieldLabel help={helpText.maxSinglePayout} text="单次返奖上限" />}
options={poolModeOptions} type="number"
value={form.poolMode} value={form.maxSinglePayout}
onChange={(value) => updateForm(setForm, "poolMode", value)} onChange={(event) => updateForm(setForm, "maxSinglePayout", event.target.value)}
/> />
<SelectField <TextField
required
disabled={disabled} disabled={disabled}
help={helpText.noviceStrength} inputProps={{ min: 1, step: 1 }}
label="新手保护" label={<FieldLabel help={helpText.userHourlyPayoutCap} text="用户小时上限" />}
options={noviceStrengthOptions} type="number"
value={form.noviceStrength} value={form.userHourlyPayoutCap}
onChange={(value) => updateForm(setForm, "noviceStrength", value)} onChange={(event) => updateForm(setForm, "userHourlyPayoutCap", event.target.value)}
/> />
<MultiplierField <TextField
required
disabled={disabled} disabled={disabled}
help={helpText.multiplierValues} inputProps={{ min: 1, step: 1 }}
setForm={setForm} label={<FieldLabel help={helpText.userDailyPayoutCap} text="用户每日上限" />}
values={form.multiplierValues || []} type="number"
value={form.userDailyPayoutCap}
onChange={(event) => updateForm(setForm, "userDailyPayoutCap", event.target.value)}
/> />
<SelectField <TextField
required
disabled={disabled} disabled={disabled}
help={helpText.jackpotStrategy} inputProps={{ min: 1, step: 1 }}
label="大奖策略" label={<FieldLabel help={helpText.deviceDailyPayoutCap} text="设备每日上限" />}
options={jackpotStrategyOptions} type="number"
value={form.jackpotStrategy} value={form.deviceDailyPayoutCap}
onChange={(value) => updateForm(setForm, "jackpotStrategy", value)} 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> </div>
</section> </section>
<section className="form-drawer__section"> <section className="form-drawer__section">
<div className="form-drawer__section-title">保护和补贴</div> <div className="form-drawer__section-title">阶段奖档</div>
<div className={styles.configGrid}> <StageTierEditor disabled={disabled} form={form} setForm={setForm} />
<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>
</section> </section>
<div className="form-drawer__actions"> <div className="form-drawer__actions">
@ -188,59 +219,173 @@ export function LuckyGiftConfigDrawer({
); );
} }
function MultiplierField({ disabled, help, setForm, values }) { function StageTierEditor({ disabled, form, setForm }) {
const list = values.length > 0 ? values : ["0"]; const [stageTargetRTP, setStageTargetRTP] = useState(() => defaultStageTargetRTP(form.targetRtpPercent));
useEffect(() => {
setStageTargetRTP(defaultStageTargetRTP(form.targetRtpPercent));
}, [form.targetRtpPercent]);
return ( return (
<div className={[styles.multiplierBlock, styles.configWide].join(" ")}> <div className={styles.stageStack}>
<div className={styles.multiplierHeader}> {stageOptions.map(([stageKey, stageLabel]) => {
<FieldLabel help={help} text="倍率配置" /> const stage = (form.stages || []).find((item) => item.stage === stageKey) || { stage: stageKey, tiers: [] };
<Button const expectedRTP = stageExpectedRTP(stage);
disabled={disabled} const lowerRTP = Number(form.targetRtpPercent || 0) - Number(form.controlBandPercent || 0);
startIcon={<AddOutlined fontSize="small" />} const upperRTP = Number(form.targetRtpPercent || 0) + Number(form.controlBandPercent || 0);
type="button" const rtpInBand = expectedRTP >= lowerRTP && expectedRTP <= upperRTP;
onClick={() => addMultiplier(setForm)} return (
> <div key={stageKey} className={styles.stageBlock}>
添加倍率 <div className={styles.stageHeader}>
</Button> <FieldLabel help={helpText.stages} text={stageLabel} />
</div> <div className={styles.stageMetrics}>
<div className={styles.multiplierList}> <span className={styles.stageTotal}>概率 {formatPercent(stageProbabilityTotal(stage))}</span>
{list.map((value, index) => ( <span className={rtpInBand ? styles.stageTotal : styles.stageRTPError}>
<div key={index} className={styles.multiplierRow}> 期望 RTP {formatPercent(expectedRTP)}
<TextField </span>
disabled={disabled} </div>
inputProps={{ min: 0, step: 0.01 }} <div className={styles.stageTools}>
label={`倍率 ${index + 1}`} <TextField
type="number" disabled={disabled}
value={value} inputProps={{ max: 500, min: 0, step: 0.01 }}
onChange={(event) => updateMultiplier(setForm, index, event.target.value)} label="纠正 RTP %"
/> size="small"
<IconButton type="number"
aria-label="删除倍率" value={stageTargetRTP[stageKey] ?? form.targetRtpPercent}
disabled={disabled || list.length <= 1} onChange={(event) =>
size="small" setStageTargetRTP((current) => ({ ...current, [stageKey]: event.target.value }))
onClick={() => removeMultiplier(setForm, index)} }
> />
<DeleteOutlineOutlined fontSize="small" /> <Button
</IconButton> 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> })}
</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 ( return (
<TextField <div className={styles.tierRow}>
select <TextField
disabled={disabled} required
label={<FieldLabel help={help} text={label} />} disabled={disabled}
value={value} label="奖档 ID"
onChange={(event) => onChange(event.target.value)} value={tier.tierId}
> onChange={(event) => updateTier({ tierId: event.target.value })}
{options.map(([optionValue, optionLabel]) => ( />
<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}> <MenuItem key={optionValue} value={optionValue}>
{optionLabel} {optionLabelText || optionLabel(options, optionValue)}
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
@ -267,24 +412,11 @@ function updateForm(setForm, key, value) {
setForm((current) => ({ ...current, [key]: value })); setForm((current) => ({ ...current, [key]: value }));
} }
function updateMultiplier(setForm, index, value) { function updateStages(setForm, updater) {
setForm((current) => { setForm((current) => ({ ...current, stages: updater(current.stages || []) }));
const nextValues = [...(current.multiplierValues || [])];
nextValues[index] = value;
return { ...current, multiplierValues: nextValues };
});
} }
function addMultiplier(setForm) { function formatPercent(value) {
setForm((current) => ({ const number = Number(value || 0);
...current, return `${number.toFixed(2).replace(/\.?0+$/, "")}%`;
multiplierValues: [...(current.multiplierValues || []), ""],
}));
}
function removeMultiplier(setForm, index) {
setForm((current) => ({
...current,
multiplierValues: (current.multiplierValues || []).filter((_, itemIndex) => itemIndex !== index),
}));
} }

View File

@ -6,14 +6,7 @@ import TextField from "@mui/material/TextField";
import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx"; import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx";
import { Button } from "@/shared/ui/Button.jsx"; import { Button } from "@/shared/ui/Button.jsx";
import { formatMillis } from "@/shared/utils/time.js"; import { formatMillis } from "@/shared/utils/time.js";
import { import { stageOptions } from "@/features/lucky-gift/constants.js";
atmosphereLevelOptions,
controlStrengthOptions,
jackpotStrategyOptions,
noviceStrengthOptions,
optionLabel,
poolModeOptions,
} from "@/features/lucky-gift/constants.js";
import { formFromConfig } from "@/features/lucky-gift/configModel.js"; import { formFromConfig } from "@/features/lucky-gift/configModel.js";
import styles from "@/features/lucky-gift/lucky-gift.module.css"; import styles from "@/features/lucky-gift/lucky-gift.module.css";
@ -40,23 +33,18 @@ export function LuckyGiftConfigSummary({
</div> </div>
<div className={styles.summaryItems}> <div className={styles.summaryItems}>
<ConfigStatus config={config} loading={configLoading} /> <ConfigStatus config={config} loading={configLoading} />
<SummaryItem label="RTP">{formatPercent(config?.targetRTPPPM)}</SummaryItem> <SummaryItem label="RTP">{formatPercent(config?.targetRTPPercent)}</SummaryItem>
<SummaryItem label="控制强度"> <SummaryItem label="入池">{formatPercent(config?.poolRatePercent)}</SummaryItem>
{optionLabel(controlStrengthOptions, form.controlStrength)} <SummaryItem label="窗口">{formatNumber(config?.settlementWindowWager)}</SummaryItem>
</SummaryItem> <SummaryItem label="波动">{formatPercent(config?.controlBandPercent)}</SummaryItem>
<SummaryItem label="奖池模式">{optionLabel(poolModeOptions, form.poolMode)}</SummaryItem> <SummaryItem label="参考价格">{formatNumber(config?.giftPriceReference)}</SummaryItem>
<SummaryItem label="新手保护"> <SummaryItem label="新手等价抽">{formatNumber(config?.noviceMaxEquivalentDraws)}</SummaryItem>
{optionLabel(noviceStrengthOptions, form.noviceStrength)} <SummaryItem label="正常等价抽">{formatNumber(config?.normalMaxEquivalentDraws)}</SummaryItem>
</SummaryItem> <SummaryItem label="单次上限">{formatNumber(config?.maxSinglePayout)}</SummaryItem>
<SummaryItem label="倍率">{formatMultipliers(form.multiplierValues)}</SummaryItem> <SummaryItem label="房间小时">{formatNumber(config?.roomHourlyPayoutCap)}</SummaryItem>
<SummaryItem label="大奖策略"> <SummaryItem label="阶段">{formatStages(form.stages)}</SummaryItem>
{optionLabel(jackpotStrategyOptions, form.jackpotStrategy)}
</SummaryItem>
<SummaryItem label="房间爆点">
{optionLabel(atmosphereLevelOptions, form.atmosphereLevel)}
</SummaryItem>
<SummaryItem label="更新时间"> <SummaryItem label="更新时间">
{config?.updatedAtMs ? formatMillis(config.updatedAtMs) : configLoading ? "加载中" : "-"} {config?.createdAtMs ? formatMillis(config.createdAtMs) : configLoading ? "加载中" : "-"}
</SummaryItem> </SummaryItem>
</div> </div>
</div> </div>
@ -96,11 +84,16 @@ export function LuckyGiftConfigSummary({
); );
} }
function formatMultipliers(values) { function formatStages(stages) {
if (!Array.isArray(values) || values.length === 0) { if (!Array.isArray(stages) || stages.length === 0) {
return "-"; 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 }) { function SummaryItem({ children, label }) {
@ -126,7 +119,18 @@ function ConfigStatus({ config, loading }) {
); );
} }
function formatPercent(ppm) { function formatPercent(value) {
const value = Number(ppm || 0) / 10_000; const number = Number(value || 0);
return `${value.toFixed(2).replace(/\.?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 { import { stageOptions } from "@/features/lucky-gift/constants.js";
atmosphereLevelConfig,
controlStrengthConfig,
jackpotStrategyConfig,
noviceStrengthConfig,
poolModeConfig,
} from "@/features/lucky-gift/constants.js";
export const luckyGiftReferenceCost = 100;
export const minimumCorrectedProbabilityPercent = 0.01;
const ppmScale = 1_000_000; const ppmScale = 1_000_000;
export const luckyGiftReferenceCost = 10_000;
const defaultTargetRTPPPM = 950_000; const defaultStageTiers = {
const defaultDrawsPerHour = 3_600; novice: [
const defaultUsersPerRoom = 20; 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() { export function emptyLuckyGiftForm() {
return { return {
activityBudget: "0", controlBandPercent: "1",
activityDailyLimit: "0", effectiveFromMs: "0",
activitySubsidyEnabled: false,
atmosphereLevel: "medium",
controlStrength: "standard",
enabled: false, enabled: false,
jackpotStrategy: "standard", giftPriceReference: String(luckyGiftReferenceCost),
multiplierValues: ["0", "0.2", "0.5", "1", "2", "5", "10", "20", "50", "100", "500"], noviceMaxEquivalentDraws: "2000",
noviceStrength: "standard", normalMaxEquivalentDraws: "20000",
maxSinglePayout: "50000",
userHourlyPayoutCap: "34200",
userDailyPayoutCap: "615600",
deviceDailyPayoutCap: "1026000",
roomHourlyPayoutCap: "684000",
anchorDailyPayoutCap: "12312000",
poolId: "default", poolId: "default",
poolMode: "standard", poolRatePercent: "96",
riskLevel: "standard", settlementWindowWager: "1000000",
stages: cloneStages(defaultStages()),
targetRtpPercent: "95", targetRtpPercent: "95",
}; };
} }
@ -34,252 +51,458 @@ export function formFromConfig(config) {
if (!config) { if (!config) {
return emptyLuckyGiftForm(); return emptyLuckyGiftForm();
} }
const activityBudget = Number(config.activityBudget || 0); const fallback = emptyLuckyGiftForm();
const activityDailyLimit = Number(config.activityDailyLimit || 0);
return { return {
activityBudget: String(activityBudget), controlBandPercent: formatDecimal(config.controlBandPercent || fallback.controlBandPercent),
activityDailyLimit: String(activityDailyLimit), effectiveFromMs: String(config.effectiveFromMs || 0),
activitySubsidyEnabled: activityBudget > 0 || activityDailyLimit > 0,
atmosphereLevel: inferAtmosphereLevel(config.roomAtmosphereRatePPM),
controlStrength: inferControlStrength(config.globalWindowDraws, config.giftWindowDraws),
enabled: Boolean(config.enabled), enabled: Boolean(config.enabled),
jackpotStrategy: inferJackpotStrategy(config.largeTierEnabled, config.highWaterPoolMultiple), giftPriceReference: String(config.giftPriceReference || fallback.giftPriceReference),
multiplierValues: formatMultiplierValues(config.multiplierPPMs, config.tiers || []), noviceMaxEquivalentDraws: String(config.noviceMaxEquivalentDraws || fallback.noviceMaxEquivalentDraws),
noviceStrength: inferNoviceStrength(config.noviceDrawLimit, config.intermediateDrawLimit), 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", poolId: config.poolId || "default",
poolMode: inferPoolMode(config), poolRatePercent: formatDecimal(config.poolRatePercent || fallback.poolRatePercent),
riskLevel: inferRiskLevel(config), settlementWindowWager: String(config.settlementWindowWager || fallback.settlementWindowWager),
targetRtpPercent: formatPercentInput(config.targetRTPPPM || defaultTargetRTPPPM), stages: normalizeStages(config.stages),
targetRtpPercent: formatDecimal(config.targetRTPPercent || fallback.targetRtpPercent),
}; };
} }
export function payloadFromLuckyGiftForm(form, currentConfig) { export function payloadFromLuckyGiftForm(form) {
const giftPrice = luckyGiftReferenceCost; // 第一阶段的新契约明确要求后台只提交百分比和倍数ppm 是 server 的持久化细节,
const targetRTPPPM = percentToPPM(form.targetRtpPercent); // 这里保留业务单位可以避免再次出现 target_rtp_ppm=95 这类单位错配。
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;
return { return {
activity_budget: activityBudget, control_band_percent: decimal(form.controlBandPercent),
activity_daily_limit: activityDailyLimit, effective_from_ms: integer(form.effectiveFromMs),
anchor_daily_payout_cap: risk.anchorDailyPayoutCap,
device_daily_payout_cap: risk.deviceDailyPayoutCap,
enabled: Boolean(form.enabled), enabled: Boolean(form.enabled),
gift_pool_weight_ppm: poolMode.giftPoolWeightPPM, gift_price_reference: integer(form.giftPriceReference),
gift_price: giftPrice, novice_max_equivalent_draws: integer(form.noviceMaxEquivalentDraws),
gift_reserve: positiveNumber(base.giftReserve, 1_000_000), normal_max_equivalent_draws: integer(form.normalMaxEquivalentDraws),
global_window_draws: control.globalWindowDraws, max_single_payout: integer(form.maxSinglePayout),
gift_window_draws: control.giftWindowDraws, user_hourly_payout_cap: integer(form.userHourlyPayoutCap),
high_multiplier: highMultiplier, user_daily_payout_cap: integer(form.userDailyPayoutCap),
high_water_pool_multiple: jackpot.highWaterPoolMultiple, device_daily_payout_cap: integer(form.deviceDailyPayoutCap),
initial_gift_pool: positiveNumber(base.initialGiftPool, expectedPayout(giftPrice, targetRTPPPM, control.giftWindowDraws) / 2), room_hourly_payout_cap: integer(form.roomHourlyPayoutCap),
initial_platform_pool: positiveNumber( anchor_daily_payout_cap: integer(form.anchorDailyPayoutCap),
base.initialPlatformPool, pool_id: String(form.poolId || "").trim(),
weighted(expectedPayout(giftPrice, targetRTPPPM, control.globalWindowDraws), poolMode.platformPoolWeightPPM), pool_rate_percent: decimal(form.poolRatePercent),
), settlement_window_wager: integer(form.settlementWindowWager),
initial_room_pool: positiveNumber(base.initialRoomPool, giftPrice * 10_000), stages: normalizeStages(form.stages).map((stage) => ({
intermediate_draw_limit: novice.intermediateDrawLimit, stage: stage.stage,
large_tier_enabled: jackpot.largeTierEnabled, tiers: stage.tiers.map((item) => ({
max_single_payout: Math.max(risk.maxSinglePayout, Math.ceil((giftPrice * maxMultiplierPPM) / ppmScale)), broadcast_level: item.broadcastLevel,
multiplier_ppms: multiplierPPMs, enabled: Boolean(item.enabled),
novice_draw_limit: novice.noviceDrawLimit, high_water_only: Boolean(item.highWaterOnly),
platform_pool_weight_ppm: poolMode.platformPoolWeightPPM, multiplier: decimal(item.multiplier),
platform_reserve: positiveNumber(base.platformReserve, 1_000_000), probability_percent: decimal(item.probabilityPercent),
pool_id: String(form.poolId || base.poolId || "default").trim() || "default", reward_source: item.rewardSource,
pool_rate_ppm: Math.max(targetRTPPPM, positiveNumber(base.poolRatePPM, targetRTPPPM)), tier_id: String(item.tierId || "").trim(),
room_atmosphere_initial: positiveNumber(base.roomAtmosphereInitial, giftPrice * 200), })),
room_atmosphere_rate_ppm: atmosphere.roomAtmosphereRatePPM, })),
room_atmosphere_reserve: positiveNumber(base.roomAtmosphereReserve, giftPrice * 20), target_rtp_percent: decimal(form.targetRtpPercent),
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,
}; };
} }
export function previewForForm(form) { export function previewForForm(form) {
const giftPrice = luckyGiftReferenceCost; // 预览只用于管理员在保存前核对预算规模,不反推线上中奖概率;
const targetRTPPPM = percentToPPM(form.targetRtpPercent); // 真实抽奖控制必须以 activity-service 发布后的规则版本为准。
const income = giftPrice * 100_000; const spend = integer(form.giftPriceReference) * 100_000;
const basePayout = Math.round((income * targetRTPPPM) / ppmScale); const targetRTP = decimal(form.targetRtpPercent) / 100;
const activityBudget = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityBudget) : 0; const poolRate = decimal(form.poolRatePercent) / 100;
const activityDailyLimit = form.activitySubsidyEnabled ? nonNegativeNumber(form.activityDailyLimit) : 0; const basePayout = Math.round(spend * targetRTP);
const poolInflow = Math.round(spend * poolRate);
return { return {
activityBudget,
activityDailyLimit,
basePayout, basePayout,
income, controlBandPercent: decimal(form.controlBandPercent),
netBeforeSubsidy: income - basePayout, poolInflow,
targetRTPPPM, spend,
visibleRTPPercent: ((basePayout + Math.min(activityBudget, activityDailyLimit || activityBudget)) * 100) / income, targetRTPPercent: decimal(form.targetRtpPercent),
}; };
} }
function buildTiers(cost, multiplierPPMs, highMultiplier) { export function addTierToStage(stages, stageKey) {
return ["novice", "intermediate", "advanced"].flatMap((pool) => return stages.map((stage) => {
multiplierPPMs.map((multiplierPPM) => if (stage.stage !== stageKey) {
tier( return stage;
pool, }
`${pool}_${multiplierLabel(multiplierPPM)}`, const index = stage.tiers.length + 1;
Math.floor((cost * multiplierPPM) / ppmScale), return {
multiplierPPM, ...stage,
highMultiplier > 0 && multiplierPPM >= highMultiplier * ppmScale, 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 { return {
enabled: true, broadcastLevel: options.broadcastLevel || "none",
high_water_only: Boolean(highWaterOnly), enabled: options.enabled ?? true,
multiplier_ppm: multiplierPPM, highWaterOnly: Boolean(options.highWaterOnly),
pool, multiplier,
reward_coins: rewardCoins, probabilityPercent,
tier_id: tierId, rewardSource: options.rewardSource || "base_rtp",
weight: 0, tierId,
}; };
} }
export function parseMultiplierList(value) { function formatDecimal(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) {
const number = Number(value || 0); 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); 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 { 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", () => { describe("lucky gift config payload", () => {
test("submits both RTP control windows for backend validation", () => { test("submits business units required by the v2 admin contract", () => {
const payload = payloadFromLuckyGiftForm(emptyLuckyGiftForm(), null); const payload = payloadFromLuckyGiftForm(emptyLuckyGiftForm());
expect(payload.global_window_draws).toBeGreaterThan(0); expect(payload.target_rtp_percent).toBe(95);
expect(payload.gift_window_draws).toBeGreaterThan(0); expect(payload.pool_rate_percent).toBe(96);
expect(payload.pool_id).toBe("default"); expect(payload.control_band_percent).toBe(1);
expect(payload.multiplier_ppms).toContain(500_000_000); expect(payload.gift_price_reference).toBe(100);
expect(payload.tiers.every((tier) => tier.weight === 0)).toBe(true); 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", () => { test("keeps each default stage probability at 100 percent", () => {
expect(parseMultiplierList("0, 0.5x, 1, 2")).toEqual([0, 500_000, 1_000_000, 2_000_000]); 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", () => { test("keeps each default stage expected RTP at target percent", () => {
const payload = payloadFromLuckyGiftForm( const form = emptyLuckyGiftForm();
{ ...emptyLuckyGiftForm(), multiplierValues: ["1", "2"] },
null,
);
expect(payload.multiplier_ppms).toEqual([1_000_000, 2_000_000]); expect(form.stages.map(stageExpectedRTP)).toEqual([95, 95, 95]);
expect(payload.tiers.some((tier) => tier.multiplier_ppm === 0 || tier.reward_coins === 0)).toBe(false); });
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 = [ export const stageOptions = [
["precise", "精准"], ["novice", "新手阶段"],
["standard", "标准"], ["normal", "正常阶段"],
["natural", "自然"], ["advanced", "高阶阶段"],
]; ];
export const poolModeOptions = [ export const rewardSourceOptions = [
["platform_guard", "平台兜底"], ["base_rtp", "基础 RTP"],
["standard", "标准模式"], ["activity_subsidy", "活动补贴"],
["room_burst", "房间氛围"], ["presentation_only", "表现奖励"],
["gift_isolated", "礼物独立"],
]; ];
export const noviceStrengthOptions = [ export const broadcastLevelOptions = [
["weak", "弱"], ["none", "不广播"],
["standard", "标准"], ["room", "房间"],
["strong", "强"], ["region", "区域"],
]; ["global", "全站"],
export const jackpotStrategyOptions = [
["off", "关闭"],
["conservative", "保守"],
["standard", "标准"],
["aggressive", "激进"],
];
export const riskLevelOptions = [
["strict", "严格"],
["standard", "标准"],
["loose", "宽松"],
];
export const atmosphereLevelOptions = [
["off", "关闭"],
["low", "低"],
["medium", "中"],
["high", "高"],
]; ];
export const drawStatusOptions = [ export const drawStatusOptions = [
@ -43,39 +23,6 @@ export const drawStatusOptions = [
["failed", "发放失败"], ["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) { export function optionLabel(options, value) {
return options.find(([optionValue]) => optionValue === value)?.[1] || value || "-"; return options.find(([optionValue]) => optionValue === value)?.[1] || value || "-";
} }

View File

@ -89,7 +89,7 @@
.configDrawer { .configDrawer {
box-sizing: border-box; box-sizing: border-box;
width: min(520px, 100vw); width: min(960px, calc(100vw - 16px));
max-width: 100vw; max-width: 100vw;
overflow-x: hidden; overflow-x: hidden;
} }
@ -203,6 +203,110 @@
gap: var(--space-1); 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 { .simulationPanel {
display: grid; display: grid;
gap: var(--space-4); gap: var(--space-4);
@ -369,9 +473,14 @@
.simulationGrid, .simulationGrid,
.simulationOverview, .simulationOverview,
.drawSummaryGrid, .drawSummaryGrid,
.multiplierList { .multiplierList,
.tierRow {
grid-template-columns: repeat(2, minmax(140px, 1fr)); grid-template-columns: repeat(2, minmax(140px, 1fr));
} }
.tierRow > :global(.MuiIconButton-root) {
justify-self: end;
}
} }
@media (max-width: 560px) { @media (max-width: 560px) {
@ -379,7 +488,13 @@
.subsidyInputs, .subsidyInputs,
.simulationGrid, .simulationGrid,
.simulationOverview, .simulationOverview,
.drawSummaryGrid { .drawSummaryGrid,
.stageHeader,
.tierRow {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.tierFlags {
justify-content: space-between;
}
} }

View File

@ -68,7 +68,7 @@ function LuckyGiftDrawSummaryPanel({ page }) {
value: formatNumber(summary.totalDraws), value: formatNumber(summary.totalDraws),
}, },
{ {
hint: `目标 ${formatPPM(page.config?.targetRTPPPM)}`, hint: `目标 ${formatPercent(page.config?.targetRTPPercent)}`,
label: "实际 RTP", label: "实际 RTP",
value: formatPPM(summary.actualRTPPPM), value: formatPPM(summary.actualRTPPPM),
}, },
@ -193,6 +193,12 @@ function formatPPM(value) {
return `${(Number(value || 0) / 10_000).toFixed(2).replace(/\.?0+$/, "")}%`; return `${(Number(value || 0) / 10_000).toFixed(2).replace(/\.?0+$/, "")}%`;
} }
function formatPercent(value) {
return `${Number(value || 0)
.toFixed(2)
.replace(/\.?0+$/, "")}%`;
}
function formatNumber(value) { function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0)); 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"; 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 export const luckyGiftConfigFormSchema = z
.object({ .object({
activityBudget: z.coerce.number().int("活动总预算必须是整数").min(0, "活动总预算不能小于 0"), controlBandPercent: z.coerce.number().gt(0, "波动带必须大于 0").max(5, "波动带不能超过 5%"),
activityDailyLimit: z.coerce.number().int("每日预算必须是整数").min(0, "每日预算不能小于 0"), effectiveFromMs: z.coerce.number().int("生效时间必须是毫秒整数").min(0, "生效时间不能小于 0"),
activitySubsidyEnabled: z.boolean(),
atmosphereLevel: z.enum(["off", "low", "medium", "high"]),
controlStrength: z.enum(["precise", "standard", "natural"]),
enabled: z.boolean(), enabled: z.boolean(),
jackpotStrategy: z.enum(["off", "conservative", "standard", "aggressive"]), giftPriceReference: z.coerce.number().int("参考价格必须是整数").gt(0, "参考价格必须大于 0"),
multiplierValues: z.array(z.coerce.number().min(0, "倍率不能小于 0")).min(1, "倍率配置不能为空"), noviceMaxEquivalentDraws: z.coerce.number().int("新手阶段等价抽数必须是整数").min(0, "新手阶段等价抽数不能小于 0"),
noviceStrength: z.enum(["weak", "standard", "strong"]), 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 个字符"), poolId: z.string().trim().min(1, "奖池 ID 不能为空").max(96, "奖池 ID 不能超过 96 个字符"),
poolMode: z.enum(["platform_guard", "standard", "room_burst", "gift_isolated"]), poolRatePercent: z.coerce.number().min(10, "入池比例不能低于 10%").max(100, "入池比例不能超过 100%"),
riskLevel: z.enum(["strict", "standard", "loose"]), settlementWindowWager: z.coerce.number().int("结算窗口必须是整数").gt(0, "结算窗口必须大于 0"),
targetRtpPercent: z.coerce.number().min(1, "RTP 必须大于 1%").max(100, "RTP 不能超过 100%"), stages: z.array(stageSchema).length(3, "必须配置新手、正常、高阶三个阶段"),
targetRtpPercent: z.coerce.number().min(10, "RTP 不能低于 10%").max(99, "RTP 不能超过 99%"),
}) })
.superRefine((value, context) => { .superRefine((value, context) => {
if (value.activitySubsidyEnabled && value.activityBudget <= 0) { if (value.poolRatePercent < value.targetRtpPercent) {
context.addIssue({ code: "custom", message: "开启活动补贴后必须配置总预算", path: ["activityBudget"] }); context.addIssue({ code: "custom", message: "入池比例不能低于 RTP", path: ["poolRatePercent"] });
} }
if (value.activitySubsidyEnabled && value.activityBudget > 0 && value.activityDailyLimit <= 0) { if (value.normalMaxEquivalentDraws < value.noviceMaxEquivalentDraws) {
context.addIssue({ code: "custom", message: "配置活动总预算时必须配置每日预算", path: ["activityDailyLimit"] }); context.addIssue({
code: "custom",
message: "正常阶段等价抽数不能小于新手阶段",
path: ["normalMaxEquivalentDraws"],
});
} }
if (value.activitySubsidyEnabled && value.activityDailyLimit > value.activityBudget && value.activityBudget > 0) {
context.addIssue({ code: "custom", message: "每日预算不能大于活动总预算", path: ["activityDailyLimit"] }); // 阶段奖档是第一步最关键的发布边界:每个阶段的概率必须闭合到 100%
} // 且必须显式保留 0x 档,否则低价礼物会被配置成不可控的“每抽必返”体验。
const multipliers = value.multiplierValues for (const stageKey of stageKeys) {
.map((item) => Number(item)) const stageIndex = value.stages.findIndex((stage) => stage.stage === stageKey);
.filter((item) => Number.isFinite(item) && item >= 0); if (stageIndex < 0) {
if (multipliers.length === 0 || Math.max(...multipliers) <= 0) { context.addIssue({ code: "custom", message: "必须配置新手、正常、高阶三个阶段", path: ["stages"] });
context.addIssue({ code: "custom", message: "至少配置一个大于 0 的中奖倍率", path: ["multiplierValues"] }); continue;
} }
const maxMultiplier = Math.max(...multipliers, 0); const stage = value.stages[stageIndex];
const targetMultiplier = Number(value.targetRtpPercent || 0) / 100; const probabilityTotalPPM = stage.tiers.reduce((sum, tier) => {
if (maxMultiplier < targetMultiplier) { if (!tier.enabled) {
context.addIssue({ code: "custom", message: "最大倍率不能低于 RTP 对应返还倍数", path: ["multiplierValues"] }); 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>; 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) { export function runLuckyGiftSimulation(config, input) {
const users = clampInt(input.users, 1, 20_000); const users = clampInt(input.users, 1, 20_000);
const rooms = clampInt(input.rooms, 1, users); 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 costMax = Math.max(costMin, clampInt(input.costMax, costMin, 1_000_000_000));
const drawsMin = clampInt(input.drawsMin, 0, 10_000_000); const drawsMin = clampInt(input.drawsMin, 0, 10_000_000);
const drawsMax = Math.max(drawsMin, clampInt(input.drawsMax, drawsMin, 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 seed = finiteSeed(input.seed) ?? runtimeSeed();
const random = createRandom(seed + users * 31 + rooms * 17 + costMin + costMax + drawsMin + drawsMax); const random = createRandom(seed + users * 31 + rooms * 17 + costMin + costMax + drawsMin + drawsMax);