Spaces:
Running
Running
| /** | |
| * 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(); | |