Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,34 @@ permissions:
id-token: write

jobs:
monte-carlo:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Run Monte Carlo harness (greedy)
run: npm run monte-carlo

- name: Upload Monte Carlo artifacts
uses: actions/upload-artifact@v4
with:
name: main-street-monte-carlo-greedy
path: results/
retention-days: 30

build-and-deploy:
runs-on: ubuntu-latest
needs: monte-carlo
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
Expand Down Expand Up @@ -56,3 +82,4 @@ jobs:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

46 changes: 38 additions & 8 deletions example-games/main-street/MainStreetMonteCarlo.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { setupMainStreetGame, type MainStreetState } from './MainStreetState';
import { createSeededRng } from '../../src/core-engine';
import { setupMainStreetGame, seedToNumber, type MainStreetState } from './MainStreetState';
import { executeAction, executeDayStart, processEndOfTurn, type PlayerAction } from './MainStreetEngine';
import { canPurchaseEvent, getAffordableBusinessCards, getAffordableUpgradeCards, getEmptySlots } from './MainStreetMarket';
import { GreedyStrategy, RandomStrategy, MainStreetAiPlayer } from './MainStreetAiStrategy';

export interface MonteCarloRunSummary {
seed: string;
Expand Down Expand Up @@ -39,7 +41,7 @@ export interface RunMonteCarloOptions {
strategy?: MonteCarloStrategy;
}

export type MonteCarloStrategy = 'market-greedy' | 'demo-greedy';
export type MonteCarloStrategy = 'market-greedy' | 'demo-greedy' | 'greedy' | 'random';

function chooseMarketGreedyActions(state: MainStreetState): PlayerAction[] {
const actions: PlayerAction[] = [];
Expand Down Expand Up @@ -123,8 +125,25 @@ function chooseActionsForStrategy(state: MainStreetState, strategy: MonteCarloSt
return chooseMarketGreedyActions(state);
}

/**
* Creates a `MainStreetAiPlayer` bound to the named strategy and a deterministic
* RNG derived from the run seed. Returns a `MainStreetAiPlayer` for `greedy` and
* `random` strategies, or `null` for legacy harness strategies (`market-greedy`,
* `demo-greedy`) that use their own action choosers.
*/
function createAiPlayerForStrategy(strategy: MonteCarloStrategy, seed: string): MainStreetAiPlayer | null {
if (strategy === 'greedy') {
return new MainStreetAiPlayer(GreedyStrategy, createSeededRng(seedToNumber(`${seed}-ai`)));
}
if (strategy === 'random') {
return new MainStreetAiPlayer(RandomStrategy, createSeededRng(seedToNumber(`${seed}-ai`)));
}
return null;
}

function runSeed(seed: string, maxTurns: number, strategy: MonteCarloStrategy): MonteCarloRunSummary {
const state = setupMainStreetGame({ seed });
const aiPlayer = createAiPlayerForStrategy(strategy, seed);

let turns = 0;
let noActionTurns = 0;
Expand All @@ -133,16 +152,27 @@ function runSeed(seed: string, maxTurns: number, strategy: MonteCarloStrategy):

while (state.gameResult === 'playing' && turns < maxTurns) {
executeDayStart(state);
const planned = chooseActionsForStrategy(state, strategy);
let executedAction = false;

for (const action of planned) {
if (action.type === 'end-turn') break;
try {
if (aiPlayer !== null) {
// AI strategy: choose actions one at a time until end-turn or game ends.
let action = aiPlayer.chooseAction(state);
while (action.type !== 'end-turn' && state.gameResult === 'playing') {
executeAction(state, action);
executedAction = true;
} catch {
// Ignore illegal actions selected by greedy strategy.
action = aiPlayer.chooseAction(state);
}
} else {
// Legacy harness strategies: plan a list of actions upfront.
const planned = chooseActionsForStrategy(state, strategy);
for (const action of planned) {
if (action.type === 'end-turn') break;
try {
executeAction(state, action);
executedAction = true;
} catch {
// Ignore illegal actions selected by legacy strategy.
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run --project unit && vitest run --project browser",
"monte-carlo": "tsx scripts/monte-carlo.ts --runs 200 --seed-prefix mc-balance --max-turns 25 --strategy market-greedy --out results/main-street-monte-carlo.json --csv-out results/main-street-monte-carlo.csv",
"monte-carlo": "tsx scripts/monte-carlo.ts --seeds 200 --seed-prefix mc-balance --maxTurns 25 --strategy greedy --out results/main-street-monte-carlo.json --csv-out results/main-street-monte-carlo.csv",
"replay": "tsx scripts/replay.ts",
"transcripts:export": "tsx scripts/export-transcripts.ts",
"save-load-smoke": "tsx scripts/save-load-smoke.ts"
Expand Down
15 changes: 8 additions & 7 deletions scripts/monte-carlo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,24 @@ function parseArgs(argv: readonly string[]): CliArgs {
return args[idx + 1];
};

const runs = Number.parseInt(get('--runs') ?? '200', 10);
const runs = Number.parseInt(get('--seeds') ?? get('--runs') ?? '200', 10);
const out = get('--out') ?? 'results/main-street-monte-carlo.json';
const csvOut = get('--csv-out');
const seedPrefix = get('--seed-prefix') ?? 'mc-balance';
const maxTurns = Number.parseInt(get('--max-turns') ?? '30', 10);
const maxTurns = Number.parseInt(get('--maxTurns') ?? get('--max-turns') ?? '25', 10);
const seedFile = get('--seed-file');
const strategyArg = (get('--strategy') ?? 'market-greedy') as MonteCarloStrategy;
const strategyArg = (get('--strategy') ?? 'greedy') as MonteCarloStrategy;

if (!Number.isFinite(runs) || runs <= 0) {
throw new Error('--runs must be a positive integer');
throw new Error('--seeds/--runs must be a positive integer');
}
if (!Number.isFinite(maxTurns) || maxTurns <= 0) {
throw new Error('--max-turns must be a positive integer');
throw new Error('--maxTurns/--max-turns must be a positive integer');
}

if (strategyArg !== 'market-greedy' && strategyArg !== 'demo-greedy') {
throw new Error('--strategy must be one of: market-greedy, demo-greedy');
const validStrategies: MonteCarloStrategy[] = ['market-greedy', 'demo-greedy', 'greedy', 'random'];
if (!validStrategies.includes(strategyArg)) {
throw new Error(`--strategy must be one of: ${validStrategies.join(', ')}`);
}

return { runs, out, csvOut, seedPrefix, maxTurns, seedFile, strategy: strategyArg };
Expand Down
26 changes: 26 additions & 0 deletions tests/main-street/monte-carlo-greedy-guardrail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { runMonteCarlo } from '../../example-games/main-street/MainStreetMonteCarlo';

// CI guardrail: greedy AI strategy win rate must stay within 20–80% on Medium difficulty.
// Work item: CG-0MMN8V9UU0MF2GHK
describe('Main Street greedy AI strategy CI guardrail', () => {
it('greedy strategy win rate stays within 20–80% over 100 deterministic seeds', () => {
const seeds = Array.from({ length: 100 }, (_, i) => `mc-greedy-${i}`);
const { metrics } = runMonteCarlo({ seeds, maxTurns: 25, strategy: 'greedy' });

expect(metrics.runs).toBe(100);
// Guardrail: greedy win rate must be within 20–80% on Medium difficulty.
expect(metrics.winRate).toBeGreaterThanOrEqual(0.2);
expect(metrics.winRate).toBeLessThanOrEqual(0.8);
});

it('random strategy produces valid win rate over 100 deterministic seeds', () => {
const seeds = Array.from({ length: 100 }, (_, i) => `mc-random-${i}`);
const { metrics } = runMonteCarlo({ seeds, maxTurns: 25, strategy: 'random' });

expect(metrics.runs).toBe(100);
// Random strategy should produce at least some wins (basic sanity check).
expect(metrics.winRate).toBeGreaterThanOrEqual(0);
expect(metrics.winRate).toBeLessThanOrEqual(1);
});
});