#!/usr/bin/env node /** * Random Trigo Game Generator * * Generates random Trigo games and exports them as TGN files. * Useful for testing, development, and creating training data. * * Usage: * npm run generate:games * npm run generate:games -- --count 100 --min-moves 20 --max-moves 80 * npm run generate:games -- --board "3*3*3" --count 50 */ import { TrigoGame, StoneType } from "../inc/trigo/game.js"; import type { BoardShape, Position } from "../inc/trigo/types.js"; import { calculateTerritory } from "../inc/trigo/gameUtils.js"; import * as fs from "fs"; import * as path from "path"; import * as crypto from "crypto"; import { fileURLToPath } from "url"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); type BoardShapeTuple = [number, number, number]; const arangeShape = (min: BoardShapeTuple, max: BoardShapeTuple): BoardShapeTuple[] => { // traverse all shapes in the range between min & max (boundary includes) const result: BoardShapeTuple[] = []; for (let x = min[0]; x <= max[0]; x++) { for (let y = min[1]; y <= max[1]; y++) { for (let z = min[2]; z <= max[2]; z++) { result.push([x, y, z]); } } } return result; }; const CANDIDATE_BOARD_SHAPES = [ ...arangeShape([2, 1, 1], [19, 19, 1]), ...arangeShape([2, 2, 2], [9, 9, 9]), ]; /** * Generator configuration options */ interface GeneratorOptions { minMoves: number; maxMoves: number; passChance: number; boardShape?: BoardShape; outputDir: string; moveToEndGame: boolean; // true if using default, false if user-specified } /** * Parse board shape string (e.g., "5*5*5" or "9*9*1") * Special value "random" selects randomly from CANDIDATE_BOARD_SHAPES */ function parseBoardShape(shapeStr: string): BoardShape { // Handle random selection if (shapeStr.toLowerCase() === "random") { const randomIndex = Math.floor(Math.random() * CANDIDATE_BOARD_SHAPES.length); const [x, y, z] = CANDIDATE_BOARD_SHAPES[randomIndex]; console.log(` [Random board selected: ${x}×${y}×${z}]`); return { x, y, z }; } // Parse explicit board shape const parts = shapeStr.split(/[^0-9]+/).filter(Boolean).map(Number); if (parts.length !== 3) { throw new Error(`Invalid board shape: ${shapeStr}. Expected format: "X*Y*Z" or "random"`); } return { x: parts[0], y: parts[1], z: parts[2] }; } /** * Get all empty positions on the board */ function getAllEmptyPositions(game: TrigoGame): Position[] { const board = game.getBoard(); const shape = game.getShape(); const emptyPositions: Position[] = []; for (let x = 0; x < shape.x; x++) { for (let y = 0; y < shape.y; y++) { for (let z = 0; z < shape.z; z++) { if (board[x][y][z] === StoneType.EMPTY) { emptyPositions.push({ x, y, z }); } } } } return emptyPositions; } /** * Get all valid moves for the current player */ function getValidMoves(game: TrigoGame): Position[] { const emptyPositions = getAllEmptyPositions(game); return emptyPositions.filter((pos) => game.isValidMove(pos).valid); } /** * Select a random move from available positions */ function selectRandomMove(validMoves: Position[]): Position { const randomIndex = Math.floor(Math.random() * validMoves.length); return validMoves[randomIndex]; } /** * Generate a single random game */ function generateRandomGame(options: GeneratorOptions): string { // Select board shape (random if undefined) const boardShape = options.boardShape || selectRandomBoardShape(); const game = new TrigoGame(boardShape); game.startGame(); const totalPositions = boardShape.x * boardShape.y * boardShape.z; const coverageThreshold = Math.floor(totalPositions * 0.9); // 90% coverage let moveCount = 0; let consecutivePasses = 0; if (options.moveToEndGame) { // Default mode: Play until neutral territory is zero // Start checking territory after 90% coverage let territoryCheckStarted = false; while (game.getGameStatus() === "playing") { // Check if we should start territory checking if (!territoryCheckStarted && moveCount >= coverageThreshold) { territoryCheckStarted = true; } // Random chance to pass (if configured) if (options.passChance > 0 && Math.random() < options.passChance) { game.pass(); consecutivePasses++; moveCount++; // Game ends on double pass if (consecutivePasses >= 2) { break; } continue; } // Try to make a move const validMoves = getValidMoves(game); if (validMoves.length === 0) { // No valid moves available, must pass game.pass(); if (options.passChance <= 0) break; consecutivePasses++; moveCount++; if (consecutivePasses >= 2) break; } else { // Make a random valid move const move = selectRandomMove(validMoves); const success = game.drop(move); if (success) { consecutivePasses = 0; moveCount++; // Check territory after 90% coverage if (territoryCheckStarted) { const board = game.getBoard(); const territory = calculateTerritory(board, boardShape); // Stop if neutral territory is zero (game is settled) if (territory.neutral === 0) { break; } } } } } } else { // User-specified move count: Play for target number of moves const targetMoves = Math.floor(Math.random() * (options.maxMoves - options.minMoves)) + options.minMoves; while (moveCount < targetMoves && game.getGameStatus() === "playing") { // Random chance to pass if (Math.random() < options.passChance) { game.pass(); consecutivePasses++; moveCount++; // Game ends on double pass if (consecutivePasses >= 2) { break; } continue; } // Try to make a move const validMoves = getValidMoves(game); if (validMoves.length === 0) { // No valid moves available, must pass game.pass(); consecutivePasses++; moveCount++; if (consecutivePasses >= 2) { break; } } else { // Make a random valid move const move = selectRandomMove(validMoves); const success = game.drop(move); if (success) { consecutivePasses = 0; moveCount++; } } } } // Generate TGN const tgn = game.toTGN(); return tgn; } /** * Select a random board shape from CANDIDATE_BOARD_SHAPES */ function selectRandomBoardShape(): BoardShape { const randomIndex = Math.floor(Math.random() * CANDIDATE_BOARD_SHAPES.length); const [x, y, z] = CANDIDATE_BOARD_SHAPES[randomIndex]; return { x, y, z }; } /** * Generate a batch of random games */ function generateBatch(count: number, options: GeneratorOptions): void { // Create output directory if it doesn't exist const outputPath = path.resolve(__dirname, options.outputDir); if (!fs.existsSync(outputPath)) { fs.mkdirSync(outputPath, { recursive: true }); console.log(`Created output directory: ${outputPath}`); } console.log(`\nGenerating ${count} random games...`); if (options.boardShape) { console.log(`Board: ${options.boardShape.x}×${options.boardShape.y}×${options.boardShape.z}`); } else { console.log(`Board: Random selection from ${CANDIDATE_BOARD_SHAPES.length} candidates`); } if (options.moveToEndGame) { console.log(`Mode: Play until neutral territory = 0 (check after 90% coverage)`); } else { console.log(`Moves: ${options.minMoves}-${options.maxMoves}`); } console.log(`Pass chance: ${(options.passChance * 100).toFixed(0)}%`); console.log(`Output: ${outputPath}\n`); const startTime = Date.now(); process.stdout.write(".".repeat(count)); process.stdout.write("\r"); for (let i = 0; i < count; i++) { try { const tgn = generateRandomGame(options); // Generate filename based on content hash const hash = crypto.createHash('sha256').update(tgn).digest('hex'); const filename = `game_${hash.substring(0, 16)}.tgn`; const filepath = path.join(outputPath, filename); // Write TGN file fs.writeFileSync(filepath, tgn, "utf-8"); process.stdout.write("+"); } catch (error) { console.error(`\nError generating game ${i}:`, error); } } const elapsedTime = ((Date.now() - startTime) / 1000).toFixed(2); console.log(`\n\nGeneration complete!`); console.log(`Time: ${elapsedTime}s`); console.log(`Rate: ${(count / parseFloat(elapsedTime)).toFixed(2)} games/second`); console.log(`Output: ${outputPath}`); } /** * Parse moves range string (e.g., "10-50" or "20") * Returns [min, max] tuple */ function parseMovesRange(movesStr: string): [number, number] { // Check if it's a range (e.g., "10-50") if (movesStr.includes("-")) { const parts = movesStr.split("-").map(s => s.trim()); if (parts.length !== 2) { throw new Error(`Invalid moves range: ${movesStr}. Expected format: "MIN-MAX" or "N"`); } const min = parseInt(parts[0], 10); const max = parseInt(parts[1], 10); if (isNaN(min) || isNaN(max)) { throw new Error(`Invalid moves range: ${movesStr}. Values must be numbers`); } if (min < 0 || max < 0) { throw new Error("moves must be non-negative"); } if (max < min) { throw new Error("max moves must be greater than or equal to min moves"); } return [min, max]; } // Single number means exact move count (min = max) const moves = parseInt(movesStr, 10); if (isNaN(moves)) { throw new Error(`Invalid moves value: ${movesStr}. Must be a number or range like "10-50"`); } if (moves < 0) { throw new Error("moves must be non-negative"); } return [moves, moves]; } /** * Parse command line arguments using yargs */ function parseArgs(): { count: number; options: GeneratorOptions } { const argv = yargs(hideBin(process.argv)) .scriptName("npm run generate:games --") .usage("Usage: $0 [options]") .option("count", { alias: "c", type: "number", default: 10, description: "Number of games to generate", coerce: (value) => { if (value <= 0) { throw new Error("count must be greater than 0"); } return value; } }) .option("moves", { alias: "m", type: "string", description: "Moves per game as \"MIN-MAX\" or single number (default: play until settled)", }) .option("pass-chance", { type: "number", default: 0, description: "Probability of passing (0.0-1.0)", coerce: (value) => { if (value < 0 || value > 1) { throw new Error("pass-chance must be between 0.0 and 1.0"); } return value; } }) .option("board", { alias: "b", type: "string", default: "random", description: 'Board size as "X*Y*Z" or "random" for random selection', }) .option("output", { alias: "o", type: "string", default: "output", description: "Output directory (relative to tools/)" }) .example([ ["$0", "Generate 10 games until territory settled"], ["$0 --count 100 --moves 20-80", "Generate 100 games with 20-80 moves"], ["$0 --board '5*5*5' --count 50", "Generate 50 games on 5×5×5 board"], ["$0 --moves 30 --count 20", "Generate 20 games with exactly 30 moves"], ["$0 --pass-chance 0.3 --output test_games", "Generate games with 30% pass chance"] ]) .help("h") .alias("h", "help") .version(false) .strict() .parseSync(); // Check if moves was user-specified or default const moveToEndGame = !argv.moves; // Parse board shape: undefined for random mode, otherwise parse the string const boardShape = argv.board.toLowerCase() === "random" ? undefined : parseBoardShape(argv.board); // Parse moves range (use dummy range for default mode, will be ignored) const [minMoves, maxMoves] = moveToEndGame ? [0, 0] : parseMovesRange(argv.moves as string); return { count: argv.count, options: { minMoves, maxMoves, passChance: argv["pass-chance"], boardShape, outputDir: argv.output, moveToEndGame } }; } /** * Main entry point */ function main(): void { try { console.log("=== Trigo Random Game Generator ===\n"); const { count, options } = parseArgs(); generateBatch(count, options); } catch (error) { console.error("\nError:", error instanceof Error ? error.message : error); process.exit(1); } } // Run the generator main();