mirror of
https://github.com/imjasonh/uno
synced 2026-07-06 22:52:55 +00:00
- Add HumanStrategy.js with Promise-based async input - Add Setup.jsx for game configuration (player count, human position) - Make Game.js async to support human player turns - Update GameController with human turn handling - Add human-basic.spec.js E2E tests with screenshots - Update existing tests for async game engine and setup screen Signed-off-by: Jason Hall <jason@chainguard.dev>
252 lines
7 KiB
JavaScript
Executable file
252 lines
7 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
|
|
import { Game } from "./src/game/Game.js";
|
|
import { BasicStrategy } from "./src/strategies/BasicStrategy.js";
|
|
import { Reporter } from "./src/report/Reporter.js";
|
|
import { mkdirSync, writeFileSync } from "fs";
|
|
import { dirname } from "path";
|
|
|
|
/**
|
|
* Parse command line arguments
|
|
*/
|
|
function parseArgs() {
|
|
const args = process.argv.slice(2);
|
|
const config = {
|
|
players: 2,
|
|
verbose: false,
|
|
count: 1,
|
|
};
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === "--players" && i + 1 < args.length) {
|
|
config.players = parseInt(args[i + 1], 10);
|
|
i++;
|
|
} else if (args[i] === "--count" && i + 1 < args.length) {
|
|
config.count = parseInt(args[i + 1], 10);
|
|
i++;
|
|
} else if (args[i] === "--verbose") {
|
|
config.verbose = true;
|
|
} else if (args[i] === "--help" || args[i] === "-h") {
|
|
console.log(`
|
|
Uno Game Simulator
|
|
|
|
Usage: node simulate.js [options]
|
|
|
|
Options:
|
|
--players N Number of players (2-6, default: 2)
|
|
--count N Number of games to simulate (default: 1)
|
|
--verbose Enable verbose output showing each turn (ignored when count > 1)
|
|
--help, -h Show this help message
|
|
|
|
Examples:
|
|
node simulate.js
|
|
node simulate.js --players 4
|
|
node simulate.js --verbose
|
|
node simulate.js --players 6 --verbose
|
|
node simulate.js --count 100
|
|
node simulate.js --count 1000 --players 4
|
|
`);
|
|
process.exit(0);
|
|
}
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
/**
|
|
* Run a single game simulation
|
|
* @param {Object} config - Configuration object with players and verbose settings
|
|
* @returns {Object} Game result with statistics
|
|
*/
|
|
async function runSingleGame(config) {
|
|
const { players, verbose } = config;
|
|
|
|
// Create player configurations
|
|
const playerConfigs = [];
|
|
for (let i = 0; i < players; i++) {
|
|
playerConfigs.push({
|
|
name: `Player ${i + 1}`,
|
|
strategy: new BasicStrategy(),
|
|
});
|
|
}
|
|
|
|
// Create game and reporter
|
|
const game = new Game(playerConfigs);
|
|
const reporter = new Reporter();
|
|
|
|
// Attach event listeners for verbose mode
|
|
if (verbose) {
|
|
game.onTurnStart = (player, topCard, activeColor) => {
|
|
console.log(
|
|
`\n[Turn ${game.turnCount + 1}] ${player.name}'s turn | Top card: ${topCard.toString()} | Active color: ${activeColor} | Hand: ${player.handSize()} cards`,
|
|
);
|
|
if (game.pendingDraw > 0) {
|
|
console.log(
|
|
` Pending draw: ${game.pendingDraw} cards (${game.pendingDrawType})`,
|
|
);
|
|
}
|
|
};
|
|
|
|
game.onCardPlayed = (player, card) => {
|
|
console.log(` → ${player.name} plays ${card.toString()}`);
|
|
};
|
|
|
|
game.onCardDrawn = (player, count) => {
|
|
console.log(` → ${player.name} draws ${count} card(s)`);
|
|
};
|
|
|
|
game.onUno = (player) => {
|
|
console.log(` → ${player.name} calls UNO!`);
|
|
};
|
|
|
|
game.onGameEnd = (winner) => {
|
|
console.log(`\n🎉 ${winner.name} wins in ${game.turnCount} turns!`);
|
|
};
|
|
}
|
|
|
|
// Attach reporter listeners
|
|
reporter.recordGameStart(players);
|
|
|
|
game.onCardPlayed = ((existingHandler) => {
|
|
return (player, card) => {
|
|
if (existingHandler) existingHandler(player, card);
|
|
reporter.recordCardPlayed(player, card);
|
|
};
|
|
})(game.onCardPlayed);
|
|
|
|
game.onCardDrawn = ((existingHandler) => {
|
|
return (player, count) => {
|
|
if (existingHandler) existingHandler(player, count);
|
|
reporter.recordCardDrawn(player, count);
|
|
};
|
|
})(game.onCardDrawn);
|
|
|
|
game.onUno = ((existingHandler) => {
|
|
return (player) => {
|
|
if (existingHandler) existingHandler(player);
|
|
reporter.recordUno(player);
|
|
};
|
|
})(game.onUno);
|
|
|
|
// Run the game
|
|
if (!verbose) {
|
|
console.log(`Starting Uno game with ${players} players...`);
|
|
}
|
|
|
|
const winner = await game.run();
|
|
|
|
reporter.recordGameEnd(winner, game.turnCount);
|
|
|
|
// Print result
|
|
if (!verbose) {
|
|
console.log(`${winner.name} wins in ${game.turnCount} turns!`);
|
|
}
|
|
|
|
// Return game result
|
|
return reporter.toJSON();
|
|
}
|
|
|
|
/**
|
|
* Run multiple game simulations and generate aggregate report
|
|
* @param {Object} config - Configuration object
|
|
* @returns {Object} Aggregate statistics
|
|
*/
|
|
async function runBatchGames(config) {
|
|
const { count, players } = config;
|
|
const results = [];
|
|
const startTime = Date.now();
|
|
|
|
console.log(`Running ${count} games with ${players} players...`);
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
// Show progress every 10% or every 100 games, whichever is smaller
|
|
const progressInterval = Math.min(100, Math.max(1, Math.floor(count / 10)));
|
|
if (i > 0 && i % progressInterval === 0) {
|
|
const percentage = ((i / count) * 100).toFixed(0);
|
|
console.log(`Progress: ${i}/${count} (${percentage}%)`);
|
|
}
|
|
|
|
// Run game without verbose output
|
|
const result = await runSingleGame({ players, verbose: false });
|
|
results.push(result);
|
|
}
|
|
|
|
const endTime = Date.now();
|
|
const totalDurationMs = endTime - startTime;
|
|
|
|
console.log(`Progress: ${count}/${count} (100%)`);
|
|
console.log(`Completed in ${(totalDurationMs / 1000).toFixed(1)}s`);
|
|
|
|
return Reporter.aggregateResults(results, totalDurationMs);
|
|
}
|
|
|
|
/**
|
|
* Main simulation function
|
|
*/
|
|
async function main() {
|
|
const config = parseArgs();
|
|
|
|
// Validate player count
|
|
if (config.players < 2 || config.players > 6) {
|
|
console.error("Error: Player count must be between 2 and 6");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Validate count
|
|
if (config.count < 1 || !Number.isInteger(config.count)) {
|
|
console.error("Error: Count must be a positive integer");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Run batch or single game
|
|
if (config.count === 1) {
|
|
// Single game mode
|
|
const result = await runSingleGame(config);
|
|
|
|
// Write report
|
|
const reportPath = "./reports/report.json";
|
|
try {
|
|
mkdirSync(dirname(reportPath), { recursive: true });
|
|
const json = JSON.stringify(result, null, 2);
|
|
writeFileSync(reportPath, json, "utf8");
|
|
console.log(`\nReport written to ${reportPath}`);
|
|
} catch (error) {
|
|
console.error(`Error writing report: ${error.message}`);
|
|
process.exit(1);
|
|
}
|
|
} else {
|
|
// Batch mode
|
|
const aggregateReport = await runBatchGames(config);
|
|
|
|
// Write aggregate report with timestamp
|
|
const timestamp = new Date()
|
|
.toISOString()
|
|
.replace(/[-:]/g, "")
|
|
.replace(/\..+/, "")
|
|
.replace("T", "-");
|
|
const reportPath = `./reports/aggregate-report-${timestamp}.json`;
|
|
|
|
try {
|
|
mkdirSync(dirname(reportPath), { recursive: true });
|
|
const json = JSON.stringify(aggregateReport, null, 2);
|
|
writeFileSync(reportPath, json, "utf8");
|
|
|
|
// Print summary
|
|
console.log(`\nWinner distribution:`);
|
|
for (const [player, stats] of Object.entries(aggregateReport.wins)) {
|
|
console.log(
|
|
` ${player}: ${stats.count} wins (${stats.percentage.toFixed(1)}%)`,
|
|
);
|
|
}
|
|
console.log(
|
|
`\nAverage game length: ${aggregateReport.turns.mean.toFixed(1)} turns (min: ${aggregateReport.turns.min}, max: ${aggregateReport.turns.max})`,
|
|
);
|
|
console.log(`\nReport written to ${reportPath}`);
|
|
} catch (error) {
|
|
console.error(`Error writing report: ${error.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
main();
|