File size: 12,202 Bytes
466436b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
#!/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();