220 lines
9.5 KiB
TypeScript
220 lines
9.5 KiB
TypeScript
import * as z from "zod";
|
|
|
|
import { targetStatOptions } from "@/lib/guides/stats";
|
|
import { parseYouTubeUrl } from "@/lib/guides/youtube";
|
|
import { extraSheetSchema, normalizeExtraSheetRichText } from "@/lib/guides/extra-sheet";
|
|
import { normalizeExtraRichText } from "@/lib/rich-text";
|
|
|
|
export const guideSectionKinds = ["weapon", "artifact", "constellations", "team"] as const;
|
|
export type GuideSectionKind = (typeof guideSectionKinds)[number];
|
|
|
|
const percent = z.coerce.number().finite().min(-9999).max(9999);
|
|
const catalogKey = z.string().trim().min(1).max(64);
|
|
export const weaponRefinementSchema = z.union([
|
|
z.number().int().min(1).max(5).transform(String),
|
|
z.enum(["1", "2", "3", "4", "5", "1-5"]),
|
|
]);
|
|
export type WeaponRefinement = z.output<typeof weaponRefinementSchema>;
|
|
|
|
export const createGuideSchema = z.strictObject({
|
|
characterKey: catalogKey,
|
|
coverMediaId: z.string().uuid(),
|
|
overview: z.string().trim().max(500),
|
|
});
|
|
|
|
const youtubeUrlSchema = z.string().trim().max(2_048).optional().transform((value, context) => {
|
|
if (value === undefined) return undefined;
|
|
if (!value) return null;
|
|
const video = parseYouTubeUrl(value);
|
|
if (video) return video.watchUrl;
|
|
context.addIssue({ code: "custom", message: "กรุณาใส่ลิงก์วิดีโอ YouTube ที่ถูกต้อง" });
|
|
return z.NEVER;
|
|
});
|
|
|
|
export const updateGuideSchema = z.strictObject({
|
|
expectedVersion: z.number().int().positive(),
|
|
coverMediaId: z.string().uuid(),
|
|
overview: z.string().trim().min(1).max(500),
|
|
youtubeUrl: youtubeUrlSchema,
|
|
posterArtMediaId: z.string().uuid().nullable().optional(),
|
|
posterArtOffsetX: z.number().finite().min(-50).max(50).optional(),
|
|
posterArtOffsetY: z.number().finite().min(-50).max(50).optional(),
|
|
posterArtScale: z.number().finite().min(50).max(200).optional(),
|
|
posterTopColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(),
|
|
posterBottomColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(),
|
|
posterBleeding: z.array(z.strictObject({
|
|
mediaId: z.string().uuid(),
|
|
amount: z.number().finite().min(0).max(100),
|
|
scale: z.number().finite().min(25).max(300),
|
|
blendMode: z.enum(["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity"]),
|
|
})).max(30).optional(),
|
|
});
|
|
|
|
export const weaponSectionSchema = z.strictObject({
|
|
expectedVersion: z.number().int().positive(),
|
|
note: z.string().trim().max(2_000).nullable(),
|
|
groups: z.array(z.strictObject({
|
|
note: z.string().trim().max(2_000).nullable(),
|
|
rows: z.array(z.strictObject({
|
|
weaponKey: catalogKey,
|
|
refinement: weaponRefinementSchema,
|
|
overallPercent: percent.nullable(),
|
|
personalPercent: percent.nullable(),
|
|
note: z.string().trim().max(2_000).nullable(),
|
|
}).refine((row) => row.overallPercent !== null || row.personalPercent !== null, {
|
|
message: "อาวุธต้องมีเปอร์เซ็นต์อย่างน้อยหนึ่งค่า",
|
|
})).max(100),
|
|
})).max(20),
|
|
});
|
|
|
|
export const mainStatOptions = {
|
|
sands: ["EM", "ER", "ATK%", "HP%", "DEF%"],
|
|
goblet: ["EM", "ATK%", "DEF%", "HP%", "Pyro DMG%", "Hydro DMG%", "Cryo DMG%", "Electro DMG%", "Anemo DMG%", "Geo DMG%", "Dendro DMG%", "Physical DMG%"],
|
|
circlet: ["HP%", "ATK%", "DEF%", "EM", "CRIT Rate%/CRIT DMG%", "Healing Bonus%"],
|
|
} as const;
|
|
|
|
export const substatOptions = ["CRIT Rate%/CRIT DMG%", "HP%", "HP", "ATK%", "ATK", "DEF%", "DEF", "EM", "ER"] as const;
|
|
export const talentLevelOptions = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"] as const;
|
|
export const talentRelationOptions = ["gt", "gte", "eq"] as const;
|
|
|
|
const artifactGroupSchema = (name: "Late Game" | "Early Game") => z.strictObject({
|
|
name: z.literal(name),
|
|
rows: z.array(z.strictObject({
|
|
firstArtifactKey: catalogKey,
|
|
secondArtifactKey: catalogKey.nullable(),
|
|
percent,
|
|
note: z.string().trim().max(2_000).nullable(),
|
|
})).max(50),
|
|
});
|
|
|
|
export const artifactSectionSchema = z.strictObject({
|
|
expectedVersion: z.number().int().positive(),
|
|
groups: z.tuple([
|
|
artifactGroupSchema("Late Game"),
|
|
artifactGroupSchema("Early Game"),
|
|
]),
|
|
sands: z.array(z.enum(mainStatOptions.sands)).max(3),
|
|
goblet: z.array(z.enum(mainStatOptions.goblet)).max(3),
|
|
circlet: z.array(z.enum(mainStatOptions.circlet)).max(3),
|
|
substats: z.array(z.enum(substatOptions)).max(substatOptions.length),
|
|
targets: z.array(z.strictObject({
|
|
stat: z.enum(targetStatOptions),
|
|
value: z.string().trim().min(1).max(80),
|
|
})).max(targetStatOptions.length),
|
|
talents: z.array(z.strictObject({
|
|
type: z.enum(["NA", "E", "Q"]),
|
|
priority: z.number().int().min(1).max(3),
|
|
level: z.enum(talentLevelOptions).optional(),
|
|
relationToNext: z.enum(talentRelationOptions).optional(),
|
|
})).max(3),
|
|
note: z.string().trim().max(2_000).nullable(),
|
|
});
|
|
|
|
const constellationRowsSchema = z.array(z.strictObject({
|
|
level: z.number().int().min(1).max(6),
|
|
detail: z.string().trim().min(1).max(2_000),
|
|
cumulativePercent: percent,
|
|
isGreat: z.boolean(),
|
|
})).max(6).refine((rows) => new Set(rows.map((row) => row.level)).size === rows.length, {
|
|
message: "แต่ละเงื่อนไขต้องมีระดับกลุ่มดาวไม่ซ้ำกัน",
|
|
});
|
|
|
|
export const constellationSectionSchema = z.strictObject({
|
|
expectedVersion: z.number().int().positive(),
|
|
note: z.string().trim().max(2_000).nullable(),
|
|
groups: z.array(z.strictObject({
|
|
note: z.string().trim().max(2_000).nullable(),
|
|
rows: constellationRowsSchema,
|
|
})).max(20),
|
|
});
|
|
|
|
export const teamSectionSchema = z.strictObject({
|
|
expectedVersion: z.number().int().positive(),
|
|
teams: z.array(z.strictObject({
|
|
title: z.string().trim().min(1).max(160),
|
|
rotationSeconds: z.coerce.number().finite().positive().max(9999),
|
|
combo: z.string().trim().min(1).max(2_000),
|
|
members: z.array(z.strictObject({
|
|
characterKey: catalogKey,
|
|
weaponKey: catalogKey.nullable(),
|
|
artifactKey: catalogKey.nullable(),
|
|
secondArtifactKey: catalogKey.nullable(),
|
|
constellation: z.number().int().min(0).max(6),
|
|
refinement: z.number().int().min(1).max(5),
|
|
damage: z.coerce.number().finite().nonnegative().max(999_999_999_999),
|
|
})).length(4).refine((members) => new Set(members.map((member) => member.characterKey)).size === 4, {
|
|
message: "Team ต้องมีตัวละครไม่ซ้ำกัน 4 ตัว",
|
|
}).refine((members) => members.some((member) => member.damage > 0), {
|
|
message: "ความเสียหายรวมต้องมากกว่า 0",
|
|
}),
|
|
})).max(30),
|
|
});
|
|
|
|
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/, "กรุณาเลือกสีที่ถูกต้อง");
|
|
|
|
const extraRowSchema = z.strictObject({
|
|
mediaId: z.string().uuid().nullable(),
|
|
heading: z.string().trim().min(1).max(200),
|
|
body: z.string().trim().max(10_000),
|
|
percent: percent.nullable(),
|
|
backgroundColor: hexColor.nullable().default(null),
|
|
textColor: hexColor.nullable().default(null),
|
|
catalogItems: z.array(z.strictObject({
|
|
kind: z.enum(["character", "weapon", "artifact"]),
|
|
key: z.string().trim().min(1).max(64),
|
|
})).max(30).refine(
|
|
(items) => new Set(items.map((item) => `${item.kind}:${item.key}`)).size === items.length,
|
|
"รายการตัวละคร อาวุธ และอาร์ติแฟกต์ต้องไม่ซ้ำกัน",
|
|
).default([]),
|
|
highlights: z.array(z.strictObject({
|
|
text: z.string().trim().min(1).max(80),
|
|
color: hexColor,
|
|
})).max(20).refine(
|
|
(highlights) => new Set(highlights.map((highlight) => highlight.text)).size === highlights.length,
|
|
"คำหรือวลีที่ไฮไลต์ต้องไม่ซ้ำกัน",
|
|
).default([]),
|
|
sheet: extraSheetSchema.nullable().default(null),
|
|
}).transform((row) => ({
|
|
...row,
|
|
body: normalizeExtraRichText(row.body, row.highlights),
|
|
sheet: row.sheet ? normalizeExtraSheetRichText(row.sheet) : null,
|
|
highlights: [],
|
|
}));
|
|
|
|
export const extraSectionItemSchema = z.strictObject({
|
|
title: z.string().trim().min(1).max(160),
|
|
rows: z.array(extraRowSchema).max(50),
|
|
});
|
|
|
|
export const extraSectionSchema = z.strictObject({
|
|
expectedVersion: z.number().int().positive(),
|
|
sections: z.array(extraSectionItemSchema).max(20),
|
|
});
|
|
|
|
export const createExtraSectionSchema = z.strictObject({
|
|
title: z.string().trim().min(1).max(160),
|
|
});
|
|
|
|
export const saveExtraSectionSchema = z.strictObject({
|
|
expectedVersion: z.number().int().positive(),
|
|
section: extraSectionItemSchema,
|
|
});
|
|
|
|
export const reorderExtraSectionsSchema = z.strictObject({
|
|
expectedVersion: z.number().int().positive(),
|
|
sectionIds: z.array(z.string().uuid()).max(20).refine(
|
|
(sectionIds) => new Set(sectionIds).size === sectionIds.length,
|
|
"Extra sections ต้องไม่ซ้ำกัน",
|
|
),
|
|
});
|
|
|
|
export type WeaponSectionInput = z.output<typeof weaponSectionSchema>;
|
|
export type ArtifactSectionInput = z.output<typeof artifactSectionSchema>;
|
|
export type ConstellationSectionInput = z.output<typeof constellationSectionSchema>;
|
|
export type TeamSectionInput = z.output<typeof teamSectionSchema>;
|
|
|
|
export function slugFromCharacterName(name: string, key: string): string {
|
|
const slug = name.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "");
|
|
return slug || `character-${key.toLowerCase()}`;
|
|
}
|