2026-06-18 17:13:59 +08:00

108 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { z } from "zod";
const rewardTypes = ["coin", "gift", "prop", "dress"] as const;
const propRewardTypes = new Set(["prop", "dress"]);
const wheelTierCounts: Record<string, number> = {
advanced: 12,
classic: 9,
luxury: 8,
};
const wheelTierSchema = z.object({
displayName: z.string().trim().min(1, "奖品名称不能为空").max(80, "奖品名称不能超过 80 个字符"),
enabled: z.boolean(),
metadataJson: z.string().trim().default("{}"),
probabilityPercent: z.coerce.number().min(0, "概率不能小于 0").max(100, "概率不能超过 100%"),
rewardCoins: z.coerce.number().int("金币数量必须是整数").min(0, "金币数量不能小于 0"),
rewardCount: z.coerce.number().int("奖励数量必须是整数").min(0, "奖励数量不能小于 0"),
rewardId: z.string().trim().default(""),
rewardType: z.enum(rewardTypes),
rtpValueCoins: z.coerce.number().int("RTP 价值必须是整数").min(0, "RTP 价值不能小于 0"),
tierId: z.string().trim().min(1, "档位 ID 不能为空").max(64, "档位 ID 不能超过 64 个字符"),
});
export const wheelConfigFormSchema = z
.object({
drawPriceCoins: z.coerce.number().int("单抽价格必须是整数").gt(0, "单抽价格必须大于 0"),
effectiveFromMs: z.coerce.number().int("生效时间必须是毫秒整数").min(0, "生效时间不能小于 0"),
enabled: z.boolean(),
initialPoolCoins: z.coerce.number().int("初始奖池必须是整数").min(0, "初始奖池不能小于 0"),
maxSingleRTPPayout: z.coerce.number().int("单次 RTP 上限必须是整数").min(0, "单次 RTP 上限不能小于 0"),
poolRatePercent: z.coerce.number().min(0, "入池比例不能小于 0").max(100, "入池比例不能超过 100%"),
poolReserveCoins: z.coerce.number().int("奖池保底必须是整数").min(0, "奖池保底不能小于 0"),
settlementWindowDraws: z.coerce.number().int("结算窗口必须是整数").gt(0, "结算窗口必须大于 0"),
targetRTPPercent: z.coerce.number().min(0, "RTP 不能小于 0").max(100, "RTP 不能超过 100%"),
tiers: z.array(wheelTierSchema).min(1, "必须配置奖品档位"),
wheelId: z.string().trim().min(1, "转盘 ID 不能为空").max(64, "转盘 ID 不能超过 64 个字符"),
})
.superRefine((value, context) => {
const expectedCount = wheelTierCounts[value.wheelId] || wheelTierCounts.classic;
if (value.tiers.length !== expectedCount) {
context.addIssue({
code: "custom",
message: `${value.wheelId} 转盘必须配置 ${expectedCount} 个奖品档位`,
path: ["tiers"],
});
}
const totalPPM = value.tiers.reduce((sum, tier) => sum + Math.round(tier.probabilityPercent * 10_000), 0);
if (totalPPM !== 1_000_000) {
context.addIssue({
code: "custom",
message: "奖品档位概率合计必须等于 100%",
path: ["tiers"],
});
}
value.tiers.forEach((tier, index) => {
if (tier.enabled && tier.probabilityPercent <= 0) {
context.addIssue({
code: "custom",
message: "启用奖品的概率必须大于 0",
path: ["tiers", index, "probabilityPercent"],
});
}
if (tier.rewardType === "coin" && tier.rewardCoins <= 0) {
context.addIssue({
code: "custom",
message: "金币档 reward_coins 必须大于 0",
path: ["tiers", index, "rewardCoins"],
});
}
if (tier.rewardType === "gift" && !tier.rewardId) {
context.addIssue({
code: "custom",
message: "礼物档 reward_id 不能为空",
path: ["tiers", index, "rewardId"],
});
}
if (propRewardTypes.has(tier.rewardType) && !tier.rewardId) {
context.addIssue({
code: "custom",
message: "道具或装扮档 reward_id 不能为空",
path: ["tiers", index, "rewardId"],
});
}
if (propRewardTypes.has(tier.rewardType) && tier.rtpValueCoins !== 0) {
context.addIssue({
code: "custom",
message: "道具或装扮不计入 RTPRTP 价值必须为 0",
path: ["tiers", index, "rtpValueCoins"],
});
}
if (tier.metadataJson) {
try {
JSON.parse(tier.metadataJson);
} catch {
context.addIssue({
code: "custom",
message: "扩展 JSON 格式不正确",
path: ["tiers", index, "metadataJson"],
});
}
}
});
});
export type WheelConfigForm = z.infer<typeof wheelConfigFormSchema>;