UNPKG

boardcast

Version:

Animation library for tabletop game rules on hex boards with CLI tools and game extensions

466 lines (403 loc) 14.4 kB
import { readFileSync } from 'fs'; import path from 'path'; import { parseBoardContent, formatParsingError } from './board-parser.js'; /** * Boardcast Board File Validator with Chevrotain * Validates .board files using robust parsing and reports detailed errors */ /** * Valid method signatures for BoardcastHexBoard */ const VALID_METHODS = { // Grid configuration setGridSize: { args: 1, types: ['number'], description: 'setGridSize(radius)' }, setGridSizeWithScaling: { args: 1, types: ['number'], description: 'setGridSizeWithScaling(radius)' }, showCoordinates: { args: 0, types: [], description: 'showCoordinates()' }, hideCoordinates: { args: 0, types: [], description: 'hideCoordinates()' }, resetBoard: { args: 0, types: [], description: 'resetBoard()' }, // Hex effects highlight: { args: [2, 3], types: [['number', 'number'], ['number', 'number', 'string']], description: 'highlight(q, r, color?)' }, blink: { args: [2, 3], types: [['number', 'number'], ['number', 'number', 'string']], description: 'blink(q, r, color?)' }, pulse: { args: [2, 3], types: [['number', 'number'], ['number', 'number', 'string']], description: 'pulse(q, r, color?)' }, // Visual indicators point: { args: [2, 3], types: [['number', 'number'], ['number', 'number', 'string']], description: 'point(q, r, label?)' }, caption: { args: [1, 2, 3], types: [['string'], ['string', 'number'], ['string', 'number', 'string']], description: 'caption(text, duration?, position?)' }, dice: { args: [2, 3, 4], types: [['string', 'number'], ['string', 'number', 'string'], ['string', 'number', 'string', 'string']], description: 'dice(dieType, displayedNumber, color?, label?)' }, // Game pieces token: { args: [5, 6], types: [['number', 'number', 'string', 'string', 'string'], ['number', 'number', 'string', 'string', 'string', 'string']], description: 'token(q, r, name, shape, color, label?)' }, move: { args: 3, types: ['string', 'number', 'number'], description: 'move(tokenName, q, r)' }, // Clearing clear: { args: [0, 1], types: [[], ['string']], description: 'clear(type?)' }, // Timing sleep: { args: 1, types: ['number'], description: 'sleep(milliseconds)' } }; /** * Valid values for specific parameters */ const VALID_VALUES = { shapes: ['circle', 'rect', 'triangle', 'star'], clearTypes: ['ALL', 'HIGHLIGHT', 'BLINK', 'PULSE', 'POINT', 'TOKEN', 'CAPTION', 'DICE'], positions: ['center', 'bottom'] }; /** * Calculate similarity score between two strings (higher is better) */ function calculateSimilarityScore(str1, str2) { // Exact match if (str1 === str2) return 100; // Contains check if (str2.includes(str1) || str1.includes(str2)) return 80; // Starts with check if (str2.startsWith(str1) || str1.startsWith(str2)) return 70; // Levenshtein distance based score const distance = levenshteinDistance(str1, str2); const maxLen = Math.max(str1.length, str2.length); if (distance <= 1) return 60; if (distance <= 2) return 40; if (distance <= 3) return 25; if (distance <= 4) return 15; if (distance <= 5 && maxLen > 6) return 10; return 0; } /** * Calculate Levenshtein distance between two strings */ function levenshteinDistance(str1, str2) { const matrix = []; for (let i = 0; i <= str2.length; i++) { matrix[i] = [i]; } for (let j = 0; j <= str1.length; j++) { matrix[0][j] = j; } for (let i = 1; i <= str2.length; i++) { for (let j = 1; j <= str1.length; j++) { if (str2.charAt(i - 1) === str1.charAt(j - 1)) { matrix[i][j] = matrix[i - 1][j - 1]; } else { matrix[i][j] = Math.min( matrix[i - 1][j - 1] + 1, // substitution matrix[i][j - 1] + 1, // insertion matrix[i - 1][j] + 1 // deletion ); } } } return matrix[str2.length][str1.length]; } /** * Valid color constants from the Colors palette */ const VALID_COLOR_CONSTANTS = [ // Primary palette 'BLUE', 'RED', 'GREEN', 'YELLOW', 'PURPLE', 'ORANGE', 'CYAN', 'PINK', // Secondary palette 'DARK_BLUE', 'DARK_RED', 'DARK_GREEN', 'DARK_YELLOW', 'DARK_PURPLE', 'DARK_ORANGE', 'DARK_CYAN', 'DARK_PINK', // Grays 'WHITE', 'LIGHT_GRAY', 'GRAY', 'DARK_GRAY', 'BLACK', // Special game colors 'ALLY', 'ENEMY', 'NEUTRAL', 'HIGHLIGHT', 'DANGER', 'DIFFICULT', 'ENGAGEMENT', // Legacy colors 'DEFAULT_HEX', 'HIGHLIGHT_BLUE', 'ENGAGEMENT_YELLOW' ]; /** * Validate color format */ function isValidColor(color) { // Hex color pattern if (/^#[0-9A-Fa-f]{6}$/.test(color)) { return true; } // Colors constant with prefix (e.g., "Colors.BLUE") if (color.startsWith('Colors.')) { const colorName = color.slice(7); return VALID_COLOR_CONSTANTS.includes(colorName); } // Direct color constant name (e.g., "BLUE") if (VALID_COLOR_CONSTANTS.includes(color)) { return true; } // Common CSS color names (basic validation for backward compatibility) const commonColors = ['red', 'green', 'blue', 'yellow', 'orange', 'purple', 'cyan', 'white', 'black']; if (commonColors.includes(color.toLowerCase())) { return true; } return false; } /** * Convert parsed argument to simple type for validation */ function getArgumentTypeAndValue(arg) { if (typeof arg === 'object' && arg.type) { return { type: arg.type, value: arg.value }; } // Fallback for simple values if (typeof arg === 'string') return { type: 'string', value: arg }; if (typeof arg === 'number') return { type: 'number', value: arg }; if (typeof arg === 'boolean') return { type: 'boolean', value: arg }; return { type: 'unknown', value: arg }; } /** * Validate specific argument values */ function validateArgumentValue(method, argIndex, value, type) { // Coordinate validation (q, r parameters) if ((method === 'highlight' || method === 'blink' || method === 'pulse' || method === 'point' || method === 'token' || method === 'move') && (argIndex === 0 || argIndex === 1) && type === 'number') { if (!Number.isInteger(value) || value < -20 || value > 20) { return `Coordinate must be an integer between -20 and 20, got ${value}.`; } } // Grid radius validation if ((method === 'setGridSize' || method === 'setGridSizeWithScaling') && argIndex === 0 && type === 'number') { if (!Number.isInteger(value) || value < 1 || value > 20) { return `Grid radius must be an integer between 1 and 20, got ${value}.`; } } // Shape validation if (method === 'token' && argIndex === 3 && type === 'string') { if (!VALID_VALUES.shapes.includes(value)) { return `Token shape must be one of: ${VALID_VALUES.shapes.join(', ')}, got "${value}".`; } } // Color validation (accept strings, identifiers, and enums for color constants) if ((type === 'string' || type === 'identifier' || type === 'enum') && ( (method === 'highlight' && argIndex === 2) || (method === 'blink' && argIndex === 2) || (method === 'pulse' && argIndex === 2) || (method === 'token' && argIndex === 4) || (method === 'dice' && argIndex === 2) )) { if (!isValidColor(value)) { return `Color must be a valid hex color (e.g., "#FF0000") or color constant (e.g., "BLUE"), got "${value}".`; } } // Clear type validation if (method === 'clear' && argIndex === 0 && type === 'string') { if (!VALID_VALUES.clearTypes.includes(value)) { return `Clear type must be one of: ${VALID_VALUES.clearTypes.join(', ')}, got "${value}".`; } } // Caption position validation if (method === 'caption' && argIndex === 2 && type === 'string') { if (!VALID_VALUES.positions.includes(value)) { return `Caption position must be one of: ${VALID_VALUES.positions.join(', ')}, got "${value}".`; } } // Duration validation if (method === 'caption' && argIndex === 1 && type === 'number') { if (!Number.isInteger(value) || value < 0 || value > 60000) { return `Caption duration must be an integer between 0 and 60000 milliseconds, got ${value}.`; } } return null; } /** * Validate a parsed command using semantic analysis */ function validateCommand(command) { const { method, args, location } = command; // Check if method exists if (!VALID_METHODS[method]) { const suggestions = Object.keys(VALID_METHODS) .map(m => ({ method: m, score: calculateSimilarityScore(method.toLowerCase(), m.toLowerCase()) })) .filter(item => item.score > 0) .sort((a, b) => b.score - a.score) .slice(0, 3) .map(item => item.method); let error = `Unknown method: "${method}".`; if (suggestions.length > 0) { error += ` Did you mean: ${suggestions.join(', ')}?`; } return { valid: false, error, line: location.startLine, column: location.startColumn }; } const methodSpec = VALID_METHODS[method]; // Check argument count const expectedArgs = Array.isArray(methodSpec.args) ? methodSpec.args : [methodSpec.args]; if (!expectedArgs.includes(args.length)) { return { valid: false, error: `Method "${method}" expects ${expectedArgs.join(' or ')} arguments, got ${args.length}. Usage: ${methodSpec.description}`, line: location.startLine, column: location.startColumn }; } // Validate argument types and values const expectedTypes = Array.isArray(methodSpec.types[0]) ? methodSpec.types.find(typePattern => typePattern.length === args.length) || methodSpec.types[0] : methodSpec.types; if (expectedTypes) { for (let i = 0; i < args.length; i++) { const expectedType = expectedTypes[i]; const argInfo = getArgumentTypeAndValue(args[i]); const actualType = argInfo.type; const actualValue = argInfo.value; // Special case: allow identifiers for string parameters in color positions const isColorPosition = ( (method === 'highlight' && i === 2) || (method === 'blink' && i === 2) || (method === 'pulse' && i === 2) || (method === 'token' && i === 4) || (method === 'dice' && i === 2) ); if (expectedType !== actualType) { // Allow identifiers and enums in place of strings for color parameters if (expectedType === 'string' && (actualType === 'identifier' || actualType === 'enum') && isColorPosition) { // This is okay - color constants can be identifiers or enums } else { return { valid: false, error: `Argument ${i + 1} of "${method}" expects ${expectedType}, got ${actualType} ("${actualValue}"). Usage: ${methodSpec.description}`, line: location.startLine, column: location.startColumn }; } } // Validate specific value constraints const validationError = validateArgumentValue(method, i, actualValue, actualType); if (validationError) { return { valid: false, error: `${validationError} Usage: ${methodSpec.description}`, line: location.startLine, column: location.startColumn }; } } } return { valid: true }; } /** * Validate a complete board file using Chevrotain parser */ function validateBoardFile(filePath) { try { const content = readFileSync(filePath, 'utf-8'); const parseResult = parseBoardContent(content); // Check for parsing errors first if (!parseResult.success) { const firstError = parseResult.errors[0]; return { valid: false, error: firstError.message, line: firstError.line, column: firstError.column, lineContent: firstError.line ? content.split('\\n')[firstError.line - 1]?.trim() : '', formattedError: formatParsingError(firstError, content) }; } // Perform semantic validation on each command for (const command of parseResult.commands) { const validation = validateCommand(command); if (!validation.valid) { const lines = content.split('\\n'); return { valid: false, error: validation.error, line: validation.line, column: validation.column, lineContent: validation.line ? lines[validation.line - 1]?.trim() : '' }; } } return { valid: true, message: 'Board file is valid!' }; } catch (error) { if (error.code === 'ENOENT') { return { valid: false, error: `File not found: ${filePath}` }; } return { valid: false, error: `Failed to read file: ${error.message}` }; } } // CLI interface if (import.meta.url === `file://${process.argv[1]}`) { const args = process.argv.slice(2); if (args.length === 0) { console.error('Usage: node board-validator-chevrotain.js <board-file>'); console.error(''); console.error('Validates a .board file using Chevrotain parser and reports the first error found.'); console.error(''); console.error('Example:'); console.error(' node board-validator-chevrotain.js example.board'); process.exit(1); } const boardFile = args[0]; // Validate file extension if (!boardFile.endsWith('.board')) { console.error('Error: Board file must have .board extension'); process.exit(1); } const fullPath = path.resolve(boardFile); console.log('Boardcast Board Validator (Chevrotain)'); console.log('====================================='); console.log(`Validating: ${fullPath}`); console.log(''); const result = validateBoardFile(fullPath); if (result.valid) { console.log('✅ ' + result.message); process.exit(0); } else { console.error('❌ Validation failed:'); if (result.line) { console.error(` Line ${result.line}${result.column ? ':' + result.column : ''}: ${result.lineContent}`); } console.error(` Error: ${result.error}`); // Show formatted error if available (for parsing errors) if (result.formattedError) { console.error(''); console.error('Detailed error:'); console.error(result.formattedError); } process.exit(1); } } export { validateBoardFile };