幸运礼物V2
This commit is contained in:
parent
e0da37cec0
commit
19b09c3baa
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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 || "-";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/features/lucky-gift/schema.test.ts
Normal file
21
src/features/lucky-gift/schema.test.ts
Normal 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%");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user