import { z } from "zod"; import type { RoomView } from "./multiplayer"; const safeName = z .string() .trim() .min(3) .max(80) .refine( (value) => !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(value), "Names cannot contain hidden controls", ); export const tournamentSettings = z .object({ name: safeName, datasetId: z.string().uuid(), difficulty: z.union([ z.enum(["easy", "normal", "hard", "void"]), z.string().uuid(), ]), playerLimit: z.number().int().min(2).max(32), roundCount: z.number().int().min(1).max(10), roundTimeSeconds: z.number().int().min(10).max(3000), timerMode: z.enum(["shared", "perMini"]).default("shared"), startsAt: z.string().datetime({ offset: true }), announceDiscord: z.boolean(), }) .strict() .refine( (value) => value.difficulty === "void" ? value.timerMode === "perMini" && value.roundTimeSeconds === 10 : value.roundTimeSeconds >= 15 && value.roundTimeSeconds % 15 === 0, "Void requires a separate 10-second timer; other tournament timers use 15-second steps", ) .refine( (value) => value.timerMode !== "perMini" || value.roundTimeSeconds <= 600, "Mini-round timers allow at most 600 seconds", ); export const tournamentEdit = z .object({ startsAt: z.string().datetime({ offset: true }), playerLimit: z.number().int().min(2).max(32), announceDiscord: z.boolean(), }) .strict(); export type TournamentRecord = { id: string; name: string; owner_id: string | null; starts_at: string; server_timezone: string; player_limit: number; round_count: number; round_time_seconds: number; timer_mode: "shared" | "perMini"; status: "scheduled" | "running" | "finished" | "cancelled"; announce_discord: boolean; finished_at: string | null; winner_id: string | null; }; export type TournamentView = { id: string; name: string; ownerId: string | null; datasetId: string; startsAt: string; serverTimezone: string; playerLimit: number; roundCount: number; roundTimeSeconds: number; timerMode: "shared" | "perMini"; roundDeadline?: string | null; announceDiscord: boolean; status: TournamentRecord["status"]; currentRound: number; finishedAt: string | null; winnerId: string | null; you: string | null; roomId: string | null; currentRoom?: RoomView | null; announcementState?: | "pending" | "sending" | "sent" | "skipped" | "failed" | null; serverNow: number; players: { accountId: string; name: string; total: number; rank: number }[]; }; /** Interpret a datetime-local value in the advertised server zone, never the browser zone. */ export function serverLocalToIso(value: string, zone: string) { if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(value)) throw new Error("Choose a valid start time"); const target = Date.parse(value + ":00Z"); if (!Number.isFinite(target)) throw new Error("Choose a valid start time"); const formatter = new Intl.DateTimeFormat("sv-SE", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23", }); let instant = target; for (let i = 0; i < 4; i++) { const shown = formatter.format(new Date(instant)).replace(" ", "T"); if (shown === value) return new Date(instant).toISOString(); instant += target - Date.parse(shown + ":00Z"); } throw new Error("That local time does not exist in the server timezone"); }