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; 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(), 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(), }); 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; export type ArtifactSectionInput = z.output; export type ConstellationSectionInput = z.output; export type TeamSectionInput = z.output; 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()}`; }