/** * 关卡可解性验证器 * 验证 arrow_pick_match 关卡数据的正确性和可解性 */ interface GridPoint { x: number; y: number; } interface LevelPath { points: GridPoint[]; head: GridPoint; direction: string; color: string; indices: number[]; pathIndex: number; } interface ParsedLevel { rows: number; cols: number; paths: LevelPath[]; trayCapacity: number; } interface ValidationResult { valid: boolean; errors: string[]; warnings: string[]; } export class LevelValidator { /** * 完整验证 */ static validate( level: ParsedLevel, matchCount: number = 3, trayCapacity?: number, ): ValidationResult { const cap = trayCapacity ?? level.trayCapacity ?? 7; const errors: string[] = []; const warnings: string[] = []; errors.push(...LevelValidator.checkNoOverlap(level)); errors.push(...LevelValidator.checkColorConstraint(level, matchCount)); warnings.push(...LevelValidator.checkAllUnblocked(level)); errors.push(...LevelValidator.checkSolvability(level, matchCount, cap)); return { valid: errors.length === 0, errors, warnings, }; } /** * 检查无格子冲突 */ static checkNoOverlap(level: ParsedLevel): string[] { const errors: string[] = []; const occupied = new Map(); // index → pathIndex for (const path of level.paths) { for (const idx of path.indices) { if (occupied.has(idx)) { const col = idx % level.cols; const row = Math.floor(idx / level.cols); errors.push( `Cell (${col},${row}) occupied by both path ${occupied.get(idx)} and path ${path.pathIndex}`, ); } else { occupied.set(idx, path.pathIndex); } } } return errors; } /** * 检查所有路径是否可无阻挡推出 */ static checkAllUnblocked(level: ParsedLevel): string[] { const warnings: string[] = []; // 构建全局占据集 const occupied = new Set(); for (const path of level.paths) { for (const pt of path.points) { occupied.add(`${pt.x},${pt.y}`); } } for (const path of level.paths) { const { dx, dy } = LevelValidator.dirToDelta(path.direction); if (dx === 0 && dy === 0) continue; let cx = path.head.x; let cy = path.head.y; let blocked = false; for (let step = 0; step < Math.max(level.rows, level.cols) + 1; step++) { cx += dx; cy += dy; if (cx < 0 || cy < 0 || cx >= level.cols || cy >= level.rows) break; const key = `${cx},${cy}`; // 检查是否被其他路径占据(排除自己的点) const isSelf = path.points.some(p => p.x === cx && p.y === cy); if (!isSelf && occupied.has(key)) { warnings.push(`Path ${path.pathIndex} (${path.color}) is blocked at (${cx},${cy})`); blocked = true; break; } } } return warnings; } /** * 检查颜色分组约束 */ static checkColorConstraint(level: ParsedLevel, matchCount: number): string[] { const errors: string[] = []; const colorCounts = new Map(); for (const path of level.paths) { colorCounts.set(path.color, (colorCounts.get(path.color) || 0) + 1); } for (const [color, count] of colorCounts) { if (count % matchCount !== 0) { errors.push( `Color "${color}" has ${count} paths, not a multiple of matchCount(${matchCount})`, ); } } return errors; } /** * 检查是否存在合法推出序列(贪心验证:同色连续推) */ static checkSolvability( level: ParsedLevel, matchCount: number, trayCapacity: number, ): string[] { const errors: string[] = []; // 按颜色分组 const colorGroups = new Map(); for (const path of level.paths) { const list = colorGroups.get(path.color) || []; list.push(path.pathIndex); colorGroups.set(path.color, list); } // 贪心策略:逐组推出 let traySize = 0; for (const [color, pathIndices] of colorGroups) { // 推出该组所有路径 traySize += pathIndices.length; // 消除 const matches = Math.floor(pathIndices.length / matchCount); traySize -= matches * matchCount; if (traySize > trayCapacity) { errors.push( `Greedy strategy overflows tray at color "${color}" (tray would have ${traySize}/${trayCapacity})`, ); } } return errors; } private static dirToDelta(direction: string): { dx: number; dy: number } { switch (direction) { case "left": return { dx: -1, dy: 0 }; case "right": return { dx: 1, dy: 0 }; case "up": return { dx: 0, dy: -1 }; case "down": return { dx: 0, dy: 1 }; default: return { dx: 0, dy: 0 }; } } }