#!/usr/bin/env node /** * 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] [ ...] Arguments: 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 { 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();