108 lines
5.0 KiB
TypeScript
108 lines
5.0 KiB
TypeScript
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: "道具或装扮不计入 RTP,RTP 价值必须为 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>;
|