trigo / trigo-web /tools /generateRandomGames.ts
k-l-lambda's picture
the first deploy
466436b
#!/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();