import { z } from "zod"; const MAZE_CHARS = new Set(["#", ".", "S", "E", "*", "O", "G"]); const MAX_GHOSTS = 4; // Pac-Man-flavored timing/scoring — fixed, not hand-authored (same // "curve is fixed" precedent as the quiz money ladder / clicker HP curve). export const PLAYER_STEP_MS = 180; export const GHOST_STEP_MS = 220; // slightly slower than the player — keeps it beatable. export const FRIGHTEN_DURATION_MS = 7000; export const GHOST_RESPAWN_DELAY_MS = 3000; export const DOT_SCORE = 10; export const PELLET_SCORE = 50; export const GHOST_EAT_SCORE = 200; /** BFS from S — used both to find the exit and to prove every collectible tile is reachable. '#' is the only impassable tile. */ function reachableFrom(grid: string[], start: [number, number]): Set { const rows = grid.length; const cols = grid[0]?.length ?? 0; const visited = new Set([`${start[0]},${start[1]}`]); const queue: [number, number][] = [start]; while (queue.length > 0) { const [r, c] = queue.shift()!; for (const [dr, dc] of [ [-1, 0], [1, 0], [0, -1], [0, 1], ]) { const nr = r + dr; const nc = c + dc; if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue; const key = `${nr},${nc}`; if (visited.has(key) || grid[nr][nc] === "#") continue; visited.add(key); queue.push([nr, nc]); } } return visited; } function findStart(grid: string[]): [number, number] | null { for (let r = 0; r < grid.length; r++) { const c = grid[r].indexOf("S"); if (c !== -1) return [r, c]; } return null; } /** Every dot, power pellet, and the exit must be reachable — win condition requires collecting them all. */ function allCollectiblesReachable(grid: string[]): boolean { const start = findStart(grid); if (!start) return false; const reachable = reachableFrom(grid, start); for (let r = 0; r < grid.length; r++) { for (let c = 0; c < grid[r].length; c++) { const ch = grid[r][c]; if ((ch === "*" || ch === "O" || ch === "E") && !reachable.has(`${r},${c}`)) return false; } } return true; } export const mazeDefinitionSchema = z .object({ // Each string is one row. '#' wall, '.' floor, 'S' start, 'E' exit, // '*' dot, 'O' power pellet, 'G' ghost spawn (0-4 of them). grid: z.array(z.string().min(1)).min(3).max(30), // 0 = no limit. timeLimitSec: z.number().int().min(0).max(600).default(0), lives: z.number().int().min(1).max(9).default(3), }) .refine((def) => def.grid.every((row) => row.length === def.grid[0].length), { message: "All grid rows must be the same length", path: ["grid"], }) .refine((def) => def.grid.every((row) => [...row].every((c) => MAZE_CHARS.has(c))), { message: "Grid may only contain the characters # . S E * O G", path: ["grid"], }) .refine((def) => def.grid.flatMap((row) => [...row]).filter((c) => c === "S").length === 1, { message: "Grid must contain exactly one S (start)", path: ["grid"], }) .refine((def) => def.grid.some((row) => row.includes("E")), { message: "Grid must contain at least one E (exit)", path: ["grid"], }) .refine((def) => def.grid.flatMap((row) => [...row]).filter((c) => c === "G").length <= MAX_GHOSTS, { message: `Grid may contain at most ${MAX_GHOSTS} G (ghost spawns)`, path: ["grid"], }) .refine((def) => allCollectiblesReachable(def.grid), { message: "Every dot (*), power pellet (O), and the exit (E) must be reachable from the start (S)", path: ["grid"], }); export type MazeDefinition = z.infer; export const DEFAULT_MAZE_DEFINITION: MazeDefinition = { grid: [ "#############", "#O****#****O#", "#*###*#*###*#", "#*#*******#*#", "#*#*#####*#*#", "#****GGG****#", "#*#*#####*#*#", "#*#*******#*#", "#*###*#*###*#", "#S****#****E#", "#############", ], timeLimitSec: 0, lives: 3, }; export const mazeProgressSchema = z.object({ playerRow: z.number().int().min(0), playerCol: z.number().int().min(0), collectedKeys: z.array(z.string()).default([]), remainingMs: z.number().int().min(0), lives: z.number().int().min(0), }); export type MazeProgress = z.infer;