Files
buzz-sheet/lib/guides/schemas.ts
T
gunshiz de76a27c86
CI / Verify (push) Successful in 1m16s
CI / Build immutable images and deploy (push) Successful in 3m1s
feat : update
2026-09-06 15:26:08 +07:00

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()}`;
}