Skip to content

Commit f3af405

Browse files
author
Sorra
authored
Merge pull request #429 from TheWizardsCode/copilot/extend-monte-carlo-harness-ai-strategies
Merge PR #429: wire greedy/random into Monte Carlo harness, add CLI flags and CI guardrail\n\nWork item: CG-0MMN8V9UU0MF2GHK
2 parents 615c317 + 32746fa commit f3af405

File tree

5 files changed

+100
-16
lines changed

5 files changed

+100
-16
lines changed

.github/workflows/deploy.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,34 @@ permissions:
1717
id-token: write
1818

1919
jobs:
20+
monte-carlo:
21+
runs-on: ubuntu-latest
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@v4
25+
26+
- name: Setup Node.js
27+
uses: actions/setup-node@v4
28+
with:
29+
node-version: 20
30+
cache: npm
31+
32+
- name: Install dependencies
33+
run: npm ci
34+
35+
- name: Run Monte Carlo harness (greedy)
36+
run: npm run monte-carlo
37+
38+
- name: Upload Monte Carlo artifacts
39+
uses: actions/upload-artifact@v4
40+
with:
41+
name: main-street-monte-carlo-greedy
42+
path: results/
43+
retention-days: 30
44+
2045
build-and-deploy:
2146
runs-on: ubuntu-latest
47+
needs: monte-carlo
2248
environment:
2349
name: github-pages
2450
url: ${{ steps.deployment.outputs.page_url }}
@@ -56,3 +82,4 @@ jobs:
5682
- name: Deploy to GitHub Pages
5783
id: deployment
5884
uses: actions/deploy-pages@v4
85+

example-games/main-street/MainStreetMonteCarlo.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { setupMainStreetGame, type MainStreetState } from './MainStreetState';
1+
import { createSeededRng } from '../../src/core-engine';
2+
import { setupMainStreetGame, seedToNumber, type MainStreetState } from './MainStreetState';
23
import { executeAction, executeDayStart, processEndOfTurn, type PlayerAction } from './MainStreetEngine';
34
import { canPurchaseEvent, getAffordableBusinessCards, getAffordableUpgradeCards, getEmptySlots } from './MainStreetMarket';
5+
import { GreedyStrategy, RandomStrategy, MainStreetAiPlayer } from './MainStreetAiStrategy';
46

57
export interface MonteCarloRunSummary {
68
seed: string;
@@ -39,7 +41,7 @@ export interface RunMonteCarloOptions {
3941
strategy?: MonteCarloStrategy;
4042
}
4143

42-
export type MonteCarloStrategy = 'market-greedy' | 'demo-greedy';
44+
export type MonteCarloStrategy = 'market-greedy' | 'demo-greedy' | 'greedy' | 'random';
4345

4446
function chooseMarketGreedyActions(state: MainStreetState): PlayerAction[] {
4547
const actions: PlayerAction[] = [];
@@ -123,8 +125,25 @@ function chooseActionsForStrategy(state: MainStreetState, strategy: MonteCarloSt
123125
return chooseMarketGreedyActions(state);
124126
}
125127

128+
/**
129+
* Creates a `MainStreetAiPlayer` bound to the named strategy and a deterministic
130+
* RNG derived from the run seed. Returns a `MainStreetAiPlayer` for `greedy` and
131+
* `random` strategies, or `null` for legacy harness strategies (`market-greedy`,
132+
* `demo-greedy`) that use their own action choosers.
133+
*/
134+
function createAiPlayerForStrategy(strategy: MonteCarloStrategy, seed: string): MainStreetAiPlayer | null {
135+
if (strategy === 'greedy') {
136+
return new MainStreetAiPlayer(GreedyStrategy, createSeededRng(seedToNumber(`${seed}-ai`)));
137+
}
138+
if (strategy === 'random') {
139+
return new MainStreetAiPlayer(RandomStrategy, createSeededRng(seedToNumber(`${seed}-ai`)));
140+
}
141+
return null;
142+
}
143+
126144
function runSeed(seed: string, maxTurns: number, strategy: MonteCarloStrategy): MonteCarloRunSummary {
127145
const state = setupMainStreetGame({ seed });
146+
const aiPlayer = createAiPlayerForStrategy(strategy, seed);
128147

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

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

139-
for (const action of planned) {
140-
if (action.type === 'end-turn') break;
141-
try {
157+
if (aiPlayer !== null) {
158+
// AI strategy: choose actions one at a time until end-turn or game ends.
159+
let action = aiPlayer.chooseAction(state);
160+
while (action.type !== 'end-turn' && state.gameResult === 'playing') {
142161
executeAction(state, action);
143162
executedAction = true;
144-
} catch {
145-
// Ignore illegal actions selected by greedy strategy.
163+
action = aiPlayer.chooseAction(state);
164+
}
165+
} else {
166+
// Legacy harness strategies: plan a list of actions upfront.
167+
const planned = chooseActionsForStrategy(state, strategy);
168+
for (const action of planned) {
169+
if (action.type === 'end-turn') break;
170+
try {
171+
executeAction(state, action);
172+
executedAction = true;
173+
} catch {
174+
// Ignore illegal actions selected by legacy strategy.
175+
}
146176
}
147177
}
148178

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"build": "tsc --noEmit && vite build",
1010
"preview": "vite preview",
1111
"test": "vitest run --project unit && vitest run --project browser",
12-
"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",
12+
"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",
1313
"replay": "tsx scripts/replay.ts",
1414
"transcripts:export": "tsx scripts/export-transcripts.ts",
1515
"save-load-smoke": "tsx scripts/save-load-smoke.ts"

scripts/monte-carlo.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,23 +22,24 @@ function parseArgs(argv: readonly string[]): CliArgs {
2222
return args[idx + 1];
2323
};
2424

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

3333
if (!Number.isFinite(runs) || runs <= 0) {
34-
throw new Error('--runs must be a positive integer');
34+
throw new Error('--seeds/--runs must be a positive integer');
3535
}
3636
if (!Number.isFinite(maxTurns) || maxTurns <= 0) {
37-
throw new Error('--max-turns must be a positive integer');
37+
throw new Error('--maxTurns/--max-turns must be a positive integer');
3838
}
3939

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

4445
return { runs, out, csvOut, seedPrefix, maxTurns, seedFile, strategy: strategyArg };
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { runMonteCarlo } from '../../example-games/main-street/MainStreetMonteCarlo';
3+
4+
// CI guardrail: greedy AI strategy win rate must stay within 20–80% on Medium difficulty.
5+
// Work item: CG-0MMN8V9UU0MF2GHK
6+
describe('Main Street greedy AI strategy CI guardrail', () => {
7+
it('greedy strategy win rate stays within 20–80% over 100 deterministic seeds', () => {
8+
const seeds = Array.from({ length: 100 }, (_, i) => `mc-greedy-${i}`);
9+
const { metrics } = runMonteCarlo({ seeds, maxTurns: 25, strategy: 'greedy' });
10+
11+
expect(metrics.runs).toBe(100);
12+
// Guardrail: greedy win rate must be within 20–80% on Medium difficulty.
13+
expect(metrics.winRate).toBeGreaterThanOrEqual(0.2);
14+
expect(metrics.winRate).toBeLessThanOrEqual(0.8);
15+
});
16+
17+
it('random strategy produces valid win rate over 100 deterministic seeds', () => {
18+
const seeds = Array.from({ length: 100 }, (_, i) => `mc-random-${i}`);
19+
const { metrics } = runMonteCarlo({ seeds, maxTurns: 25, strategy: 'random' });
20+
21+
expect(metrics.runs).toBe(100);
22+
// Random strategy should produce at least some wins (basic sanity check).
23+
expect(metrics.winRate).toBeGreaterThanOrEqual(0);
24+
expect(metrics.winRate).toBeLessThanOrEqual(1);
25+
});
26+
});

0 commit comments

Comments
 (0)