Spaces:
Sleeping
Sleeping
| /** | |
| * TGN Validator - Validate TGN files by replaying the game | |
| * | |
| * Validates: | |
| * 1. TGN syntax parsing | |
| * 2. Move legality (no occupied positions, no suicide, etc.) | |
| * 3. Game integrity | |
| */ | |
| import { TrigoGame, validateTGN } from "@inc/trigo/game.js"; | |
| import { calculateTerritory } from "@inc/trigo/gameUtils.js"; | |
| import { initializeParsers } from "@inc/trigo/parserInit.js"; | |
| import { parseTGN } from "@inc/tgn/tgnParser.js"; | |
| import { decodeAb0yz } from "@inc/trigo/ab0yz.js"; | |
| import * as fs from "fs"; | |
| import * as path from "path"; | |
| interface ValidationResult { | |
| file: string; | |
| valid: boolean; | |
| parseError?: string; | |
| replayError?: string; | |
| moveCount?: number; | |
| boardShape?: { x: number; y: number; z: number }; | |
| territory?: { | |
| black: number; | |
| white: number; | |
| neutral: number; | |
| }; | |
| metadata?: { | |
| event?: string; | |
| black?: string; | |
| white?: string; | |
| date?: string; | |
| }; | |
| } | |
| function validateTgnFile(filePath: string): ValidationResult { | |
| const fileName = path.basename(filePath); | |
| // Check file exists | |
| if (!fs.existsSync(filePath)) { | |
| return { | |
| file: fileName, | |
| valid: false, | |
| parseError: "File not found" | |
| }; | |
| } | |
| // Read file | |
| let content: string; | |
| try { | |
| content = fs.readFileSync(filePath, "utf-8"); | |
| } catch (err) { | |
| return { | |
| file: fileName, | |
| valid: false, | |
| parseError: `Failed to read file: ${err instanceof Error ? err.message : String(err)}` | |
| }; | |
| } | |
| // Validate TGN syntax | |
| const syntaxResult = validateTGN(content); | |
| if (!syntaxResult.valid) { | |
| return { | |
| file: fileName, | |
| valid: false, | |
| parseError: syntaxResult.error | |
| }; | |
| } | |
| // Replay the game with strict move validation | |
| try { | |
| const parsed = parseTGN(content); | |
| // Extract board shape | |
| let boardShape: { x: number; y: number; z: number }; | |
| if (parsed.tags.Board && Array.isArray(parsed.tags.Board)) { | |
| const shape = parsed.tags.Board; | |
| boardShape = { | |
| x: shape[0] || 5, | |
| y: shape[1] || 5, | |
| z: shape[2] || 1 | |
| }; | |
| } else { | |
| boardShape = { x: 5, y: 5, z: 5 }; | |
| } | |
| // Create game and replay moves with strict validation | |
| const game = new TrigoGame(boardShape); | |
| game.startGame(); | |
| let moveIndex = 0; | |
| if (parsed.moves && parsed.moves.length > 0) { | |
| for (const round of parsed.moves) { | |
| // Validate black's move | |
| if (round.action_black) { | |
| moveIndex++; | |
| const error = applyMoveStrict(game, round.action_black, boardShape, moveIndex, "Black"); | |
| if (error) { | |
| return { | |
| file: fileName, | |
| valid: false, | |
| replayError: error | |
| }; | |
| } | |
| } | |
| // Validate white's move | |
| if (round.action_white) { | |
| moveIndex++; | |
| const error = applyMoveStrict(game, round.action_white, boardShape, moveIndex, "White"); | |
| if (error) { | |
| return { | |
| file: fileName, | |
| valid: false, | |
| replayError: error | |
| }; | |
| } | |
| } | |
| } | |
| } | |
| const shape = game.getShape(); | |
| const history = game.getHistory(); | |
| const board = game.getBoard(); | |
| const territory = calculateTerritory(board, shape); | |
| // Extract metadata from TGN | |
| const metadata: ValidationResult["metadata"] = {}; | |
| const eventMatch = content.match(/\[Event\s+"([^"]+)"\]/); | |
| const blackMatch = content.match(/\[Black\s+"([^"]+)"\]/); | |
| const whiteMatch = content.match(/\[White\s+"([^"]+)"\]/); | |
| const dateMatch = content.match(/\[Date\s+"([^"]+)"\]/); | |
| if (eventMatch) metadata.event = eventMatch[1]; | |
| if (blackMatch) metadata.black = blackMatch[1]; | |
| if (whiteMatch) metadata.white = whiteMatch[1]; | |
| if (dateMatch) metadata.date = dateMatch[1]; | |
| return { | |
| file: fileName, | |
| valid: true, | |
| moveCount: history.length, | |
| boardShape: shape, | |
| territory: { | |
| black: territory.black, | |
| white: territory.white, | |
| neutral: territory.neutral | |
| }, | |
| metadata: Object.keys(metadata).length > 0 ? metadata : undefined | |
| }; | |
| } catch (err) { | |
| return { | |
| file: fileName, | |
| valid: false, | |
| replayError: err instanceof Error ? err.message : String(err) | |
| }; | |
| } | |
| } | |
| /** | |
| * Apply a move with strict validation - returns error message if invalid | |
| */ | |
| function applyMoveStrict( | |
| game: TrigoGame, | |
| action: { type: string; position?: string }, | |
| boardShape: { x: number; y: number; z: number }, | |
| moveIndex: number, | |
| player: string | |
| ): string | null { | |
| if (action.type === "pass") { | |
| game.pass(); | |
| return null; | |
| } else if (action.type === "resign") { | |
| game.surrender(); | |
| return null; | |
| } else if (action.type === "move" && action.position) { | |
| const coords = decodeAb0yz(action.position, [boardShape.x, boardShape.y, boardShape.z]); | |
| const position = { x: coords[0], y: coords[1], z: coords[2] }; | |
| // Check move validity before applying | |
| const validation = game.isValidMove(position); | |
| if (!validation.valid) { | |
| return `Move ${moveIndex} (${player} at ${action.position}): ${validation.reason}`; | |
| } | |
| // Apply the move | |
| const success = game.drop(position); | |
| if (!success) { | |
| return `Move ${moveIndex} (${player} at ${action.position}): Move rejected`; | |
| } | |
| return null; | |
| } | |
| return `Move ${moveIndex} (${player}): Unknown action type "${action.type}"`; | |
| } | |
| function printResult(result: ValidationResult, verbose: boolean): void { | |
| const status = result.valid ? "✓" : "✗"; | |
| const statusColor = result.valid ? "\x1b[32m" : "\x1b[31m"; | |
| const reset = "\x1b[0m"; | |
| console.log(`${statusColor}${status}${reset} ${result.file}`); | |
| if (!result.valid) { | |
| if (result.parseError) { | |
| console.log(` Parse error: ${result.parseError}`); | |
| } | |
| if (result.replayError) { | |
| console.log(` Replay error: ${result.replayError}`); | |
| } | |
| } else if (verbose) { | |
| console.log(` Board: ${result.boardShape?.x}×${result.boardShape?.y}×${result.boardShape?.z}`); | |
| console.log(` Moves: ${result.moveCount}`); | |
| if (result.territory) { | |
| console.log(` Territory: B=${result.territory.black}, W=${result.territory.white}, N=${result.territory.neutral}`); | |
| } | |
| if (result.metadata) { | |
| if (result.metadata.event) console.log(` Event: ${result.metadata.event}`); | |
| if (result.metadata.black) console.log(` Black: ${result.metadata.black}`); | |
| if (result.metadata.white) console.log(` White: ${result.metadata.white}`); | |
| if (result.metadata.date) console.log(` Date: ${result.metadata.date}`); | |
| } | |
| } | |
| } | |
| /** | |
| * Expand paths - if path is a directory, return all .tgn files in it | |
| */ | |
| function expandPaths(paths: string[]): string[] { | |
| const result: string[] = []; | |
| for (const p of paths) { | |
| if (!fs.existsSync(p)) { | |
| // Keep non-existent paths - will report as error later | |
| result.push(p); | |
| continue; | |
| } | |
| const stat = fs.statSync(p); | |
| if (stat.isDirectory()) { | |
| // Find all .tgn files in directory | |
| const files = fs.readdirSync(p) | |
| .filter(f => f.endsWith(".tgn")) | |
| .map(f => path.join(p, f)) | |
| .sort(); | |
| result.push(...files); | |
| } else { | |
| result.push(p); | |
| } | |
| } | |
| return result; | |
| } | |
| function printUsage(): void { | |
| console.log(` | |
| Usage: npx tsx tools/tgnValidator.ts [options] <path> [<path> ...] | |
| Arguments: | |
| <path> TGN file or directory containing TGN files | |
| Options: | |
| --verbose, -v Show detailed information for valid files | |
| --json Output results as JSON | |
| --help, -h Show this help message | |
| Examples: | |
| npx tsx tools/tgnValidator.ts game.tgn | |
| npx tsx tools/tgnValidator.ts ./games/ # validate all .tgn in directory | |
| npx tsx tools/tgnValidator.ts -v ./games/*.tgn | |
| npx tsx tools/tgnValidator.ts --json ./output/ > results.json | |
| `); | |
| } | |
| async function main(): Promise<void> { | |
| const args = process.argv.slice(2); | |
| // Parse options | |
| let verbose = false; | |
| let jsonOutput = false; | |
| const files: string[] = []; | |
| for (const arg of args) { | |
| if (arg === "--verbose" || arg === "-v") { | |
| verbose = true; | |
| } else if (arg === "--json") { | |
| jsonOutput = true; | |
| } else if (arg === "--help" || arg === "-h") { | |
| printUsage(); | |
| process.exit(0); | |
| } else if (!arg.startsWith("-")) { | |
| files.push(arg); | |
| } else { | |
| console.error(`Unknown option: ${arg}`); | |
| printUsage(); | |
| process.exit(1); | |
| } | |
| } | |
| if (files.length === 0) { | |
| console.error("Error: No TGN files specified\n"); | |
| printUsage(); | |
| process.exit(1); | |
| } | |
| // Expand directories to individual files | |
| const expandedFiles = expandPaths(files); | |
| if (expandedFiles.length === 0) { | |
| console.error("Error: No TGN files found in specified path(s)\n"); | |
| process.exit(1); | |
| } | |
| // Initialize parser | |
| await initializeParsers(); | |
| // Validate all files | |
| const results: ValidationResult[] = []; | |
| for (const file of expandedFiles) { | |
| const result = validateTgnFile(file); | |
| results.push(result); | |
| process.stdout.write(result.valid ? "." : "!"); | |
| } | |
| // Output results | |
| if (jsonOutput) { | |
| console.log(JSON.stringify(results, null, 2)); | |
| } else { | |
| console.log(`\nValidating ${expandedFiles.length} TGN file(s)...\n`); | |
| for (const result of results) { | |
| printResult(result, verbose); | |
| } | |
| // Summary | |
| const validCount = results.filter(r => r.valid).length; | |
| const invalidCount = results.length - validCount; | |
| console.log(`\n${"─".repeat(40)}`); | |
| console.log(`Total: ${results.length} file(s)`); | |
| console.log(` Valid: ${validCount}`); | |
| console.log(` Invalid: ${invalidCount}`); | |
| if (invalidCount > 0) { | |
| process.exit(1); | |
| } | |
| } | |
| } | |
| main(); | |