import { z } from "zod"; import { BET_LEVELS, DEFAULT_PROFANITY, RESERVED_USERNAMES } from "./constants"; export const USERNAME_RE = /^[a-z0-9][a-z0-9_-]{2,23}$/; export function normalizeUsername(raw: string): string { return raw.trim().toLowerCase(); } export interface UsernameCheck { ok: boolean; reason?: "length" | "charset" | "reserved" | "profanity"; } export function checkUsername(raw: string, extraProfanity: string[] = []): UsernameCheck { const u = normalizeUsername(raw); if (u.length < 3 || u.length > 24) return { ok: false, reason: "length" }; if (!USERNAME_RE.test(u)) return { ok: false, reason: "charset" }; if (RESERVED_USERNAMES.has(u)) return { ok: false, reason: "reserved" }; const squashed = u.replace(/[_-]/g, ""); for (const w of [...DEFAULT_PROFANITY, ...extraProfanity]) { if (w && squashed.includes(w.toLowerCase())) return { ok: false, reason: "profanity" }; } return { ok: true }; } export const usernameSchema = z .string() .min(3) .max(24) .transform(normalizeUsername) .refine((u) => USERNAME_RE.test(u), "Use 3–24 lowercase letters, numbers, _ or -"); export const passwordSchema = z.string().min(8, "At least 8 characters").max(256); export const registerSchema = z .object({ username: usernameSchema, password: passwordSchema, confirmPassword: z.string(), ageConfirmed: z.literal(true, { error: "You must confirm you are 18 or older" }), }) .refine((d) => d.password === d.confirmPassword, { message: "Passwords do not match", path: ["confirmPassword"] }); export const loginSchema = z.object({ username: usernameSchema, password: z.string().min(1).max(256), }); export const RECOVERY_RE = /^SPZ-[A-Z2-9]{4}-[A-Z2-9]{4}-[A-Z2-9]{4}$/; export const recoverSchema = z.object({ username: usernameSchema, recoveryCode: z .string() .transform((s) => s.trim().toUpperCase().replace(/\s+/g, "")) .refine((s) => RECOVERY_RE.test(s), "Recovery code format: SPZ-XXXX-XXXX-XXXX"), newPassword: passwordSchema, }); export const spinSchema = z.object({ bet: z.number().int().refine((b) => (BET_LEVELS as readonly number[]).includes(b), "Invalid bet"), clientRoundId: z.string().uuid(), }); export const settingsSchema = z.object({ soundEnabled: z.boolean().optional(), musicVolume: z.number().min(0).max(1).optional(), effectsVolume: z.number().min(0).max(1).optional(), masterVolume: z.number().min(0).max(1).optional(), reduceMotion: z.boolean().optional(), animationIntensity: z.enum(["low", "medium", "high"]).optional(), sessionReminderMinutes: z.number().int().min(0).max(240).optional(), breakReminder: z.boolean().optional(), leaderboardOptIn: z.boolean().optional(), }); export type SettingsInput = z.infer;