trigo / trigo-web /tools /tgnValidator.ts
k-l-lambda's picture
Update trigo-web with VS People multiplayer mode
15f353f
#!/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] <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();