Skip to content

Commit 6789756

Browse files
author
Sorra
authored
Merge pull request #423 from TheWizardsCode/copilot/create-mainstreet-ai-strategy
Main Street M3: AI Strategy Interface and Legal Action Enumeration
2 parents 04ca5d3 + ae004a9 commit 6789756

File tree

2 files changed

+706
-0
lines changed

2 files changed

+706
-0
lines changed
Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
/**
2+
* AI strategies for Main Street.
3+
*
4+
* Provides:
5+
* - MainStreetAiStrategy interface: chooseAction(state, rng)
6+
* - enumerateLegalActions(state): all valid PlayerAction options
7+
* - RandomStrategy: uniformly random legal action
8+
* - GreedyStrategy: heuristic priority chain
9+
* - MainStreetAiPlayer: wrapper binding a strategy and RNG
10+
*
11+
* Uses shared AI module (`@ai`) for base types and utility functions.
12+
*
13+
* @module
14+
*/
15+
16+
import type { AiStrategyBase } from '../../src/ai';
17+
import { AiPlayer as AiPlayerBase, pickRandom, pickBest } from '../../src/ai';
18+
import type { MainStreetState } from './MainStreetState';
19+
import {
20+
executeDayStart,
21+
processEndOfTurn,
22+
executeAction,
23+
type PlayerAction,
24+
type BuyBusinessAction,
25+
type BuyUpgradeAction,
26+
type BuyEventAction,
27+
} from './MainStreetEngine';
28+
import {
29+
canPurchaseBusiness,
30+
canPurchaseUpgrade,
31+
canPurchaseEvent,
32+
getEmptySlots,
33+
} from './MainStreetMarket';
34+
import type { BusinessCard, UpgradeCard, EventCard } from './MainStreetCards';
35+
import { GRID_SIZE } from './MainStreetCards';
36+
import { computeSynergyBonus } from './MainStreetAdjacency';
37+
38+
// ── Scoring constants ───────────────────────────────────────
39+
40+
/** Weight applied to upgrade income bonus in scoring (higher = prefer better income). */
41+
const UPGRADE_INCOME_WEIGHT = 10;
42+
43+
/** Weight applied to base income when scoring business placement. */
44+
const BASE_INCOME_WEIGHT = 5;
45+
46+
/** Weight applied to synergy gain when scoring business placement. */
47+
const SYNERGY_WEIGHT = 10;
48+
49+
/** Coins-equivalent value of one reputation point when scoring events. */
50+
const REPUTATION_COIN_WEIGHT = 2;
51+
52+
// ── Strategy Interface ──────────────────────────────────────
53+
54+
/**
55+
* An AI strategy for Main Street.
56+
*
57+
* The strategy receives the full game state and an RNG, and returns
58+
* a single PlayerAction to execute during the MarketPhase.
59+
*
60+
* Strategies should call `enumerateLegalActions` to discover valid
61+
* actions rather than hard-coding game logic.
62+
*/
63+
export interface MainStreetAiStrategy extends AiStrategyBase {
64+
/**
65+
* Choose an action for the current market phase.
66+
*
67+
* @param state Current game state (read-only by convention).
68+
* @param rng Seeded random number generator.
69+
* @returns The chosen PlayerAction.
70+
*/
71+
chooseAction(state: MainStreetState, rng: () => number): PlayerAction;
72+
}
73+
74+
// ── Legal Action Enumeration ────────────────────────────────
75+
76+
/**
77+
* Produces all valid PlayerAction options for the given state.
78+
*
79+
* Covers all action types:
80+
* - `buy-business`: one entry per (affordable card × empty slot) pair
81+
* - `buy-upgrade`: one entry per (upgrade card × valid target slot) pair
82+
* - `buy-event`: one entry per purchasable Investment event
83+
* - `play-event`: one entry if the player holds an Investment event
84+
* - `end-turn`: always included
85+
*
86+
* Every action returned here is guaranteed to be accepted by `executeAction`
87+
* (i.e. `canPurchase*` checks all pass).
88+
*
89+
* @param state Current game state.
90+
* @returns Array of legal PlayerActions.
91+
*/
92+
export function enumerateLegalActions(state: MainStreetState): PlayerAction[] {
93+
const actions: PlayerAction[] = [];
94+
95+
// ── buy-business ─────────────────────────────────────────
96+
const emptySlots = getEmptySlots(state);
97+
for (const card of state.market.business as BusinessCard[]) {
98+
for (const slotIndex of emptySlots) {
99+
const result = canPurchaseBusiness(state, card.id, slotIndex);
100+
if (result.legal) {
101+
actions.push({ type: 'buy-business', cardId: card.id, slotIndex });
102+
}
103+
}
104+
}
105+
106+
// ── buy-upgrade ───────────────────────────────────────────
107+
const upgradeCards = state.market.investments.filter(
108+
c => c.family === 'upgrade',
109+
) as UpgradeCard[];
110+
for (const card of upgradeCards) {
111+
const canBuy = canPurchaseUpgrade(state, card.id);
112+
if (!canBuy.legal) continue;
113+
114+
// Generate one action per valid target slot so the AI can choose
115+
// which slot to upgrade (important for branching upgrade paths).
116+
const requiredLevel = card.requiredLevel ?? 0;
117+
for (let i = 0; i < GRID_SIZE; i++) {
118+
const biz = state.streetGrid[i];
119+
if (
120+
biz !== null &&
121+
biz.name === card.targetBusiness &&
122+
biz.level === requiredLevel &&
123+
biz.level < biz.maxLevel
124+
) {
125+
actions.push({ type: 'buy-upgrade', cardId: card.id, targetSlot: i });
126+
}
127+
}
128+
}
129+
130+
// ── buy-event ─────────────────────────────────────────────
131+
const eventCards = state.market.investments.filter(
132+
c => c.family === 'event',
133+
) as EventCard[];
134+
for (const card of eventCards) {
135+
const result = canPurchaseEvent(state, card.id);
136+
if (result.legal) {
137+
actions.push({ type: 'buy-event', cardId: card.id });
138+
}
139+
}
140+
141+
// ── play-event ────────────────────────────────────────────
142+
if (state.heldEvent !== null) {
143+
actions.push({ type: 'play-event' });
144+
}
145+
146+
// ── end-turn ──────────────────────────────────────────────
147+
actions.push({ type: 'end-turn' });
148+
149+
return actions;
150+
}
151+
152+
// ── RandomStrategy ──────────────────────────────────────────
153+
154+
/**
155+
* Selects a uniformly random legal action each turn.
156+
*
157+
* Baseline strategy used for Monte Carlo balance testing and as a
158+
* fallback when no heuristic improvement is available.
159+
*/
160+
export const RandomStrategy: MainStreetAiStrategy = {
161+
name: 'Random',
162+
163+
chooseAction(state: MainStreetState, rng: () => number): PlayerAction {
164+
const legalActions = enumerateLegalActions(state);
165+
return pickRandom(legalActions, rng);
166+
},
167+
};
168+
169+
// ── GreedyStrategy ──────────────────────────────────────────
170+
171+
/**
172+
* A heuristic greedy strategy following the PRD M3 priority chain:
173+
*
174+
* 1. Buy an upgrade (if affordable and available) — best income delta
175+
* 2. Buy a business (best synergy placement score)
176+
* 3. Buy an Investment event (positive expected ROI only)
177+
* 4. Play a held event (if holding one)
178+
* 5. End turn
179+
*
180+
* Ties at each priority level are broken randomly via `pickBest`.
181+
*/
182+
export const GreedyStrategy: MainStreetAiStrategy = {
183+
name: 'Greedy',
184+
185+
chooseAction(state: MainStreetState, rng: () => number): PlayerAction {
186+
const legalActions = enumerateLegalActions(state);
187+
188+
// Priority 1: upgrades (highest income gain per coin)
189+
const upgradeActions = legalActions.filter(a => a.type === 'buy-upgrade') as BuyUpgradeAction[];
190+
if (upgradeActions.length > 0) {
191+
return pickBest(upgradeActions, a => scoreUpgradeAction(state, a), rng);
192+
}
193+
194+
// Priority 2: buy business for best synergy placement
195+
const businessActions = legalActions.filter(a => a.type === 'buy-business') as BuyBusinessAction[];
196+
if (businessActions.length > 0) {
197+
return pickBest(businessActions, a => scoreBusinessAction(state, a), rng);
198+
}
199+
200+
// Priority 3: buy Investment event with positive coinDelta ROI
201+
const eventActions = legalActions.filter(a => a.type === 'buy-event') as BuyEventAction[];
202+
if (eventActions.length > 0) {
203+
const bestEvent = pickBest(eventActions, a => scoreEventAction(state, a), rng);
204+
if (scoreEventAction(state, bestEvent) > 0) {
205+
return bestEvent;
206+
}
207+
}
208+
209+
// Priority 4: play held event
210+
const playEventAction = legalActions.find(a => a.type === 'play-event');
211+
if (playEventAction) {
212+
return playEventAction;
213+
}
214+
215+
// Priority 5: end turn
216+
return { type: 'end-turn' };
217+
},
218+
};
219+
220+
// ── AiPlayer ────────────────────────────────────────────────
221+
222+
/**
223+
* Main Street AI player binding a strategy and RNG.
224+
*
225+
* Extends the shared {@link AiPlayerBase} and adds `chooseAction` and
226+
* `playGame` convenience methods.
227+
*/
228+
export class MainStreetAiPlayer extends AiPlayerBase<MainStreetAiStrategy> {
229+
constructor(
230+
strategy: MainStreetAiStrategy = GreedyStrategy,
231+
rng: () => number = Math.random,
232+
) {
233+
super(strategy, rng);
234+
}
235+
236+
/**
237+
* Choose a single action for the current market phase.
238+
*
239+
* Delegates to the strategy, hiding the `rng` parameter from callers.
240+
*/
241+
chooseAction(state: MainStreetState): PlayerAction {
242+
return this.strategy.chooseAction(state, this.rng);
243+
}
244+
245+
/**
246+
* Run a complete game from setup to game-end.
247+
*
248+
* The caller is responsible for setting up the state (via
249+
* `setupMainStreetGame`). This method drives the game loop,
250+
* choosing actions each turn until the game ends.
251+
*
252+
* @param state An already-set-up MainStreetState (mutated in-place).
253+
*/
254+
playGame(state: MainStreetState): void {
255+
while (state.gameResult === 'playing') {
256+
executeDayStart(state);
257+
258+
// Execute actions until end-turn is chosen or game ends
259+
let action = this.chooseAction(state);
260+
while (action.type !== 'end-turn') {
261+
executeAction(state, action);
262+
if (state.gameResult !== 'playing') break;
263+
action = this.chooseAction(state);
264+
}
265+
266+
processEndOfTurn(state);
267+
}
268+
}
269+
}
270+
271+
// ── Scoring Helpers ─────────────────────────────────────────
272+
273+
/**
274+
* Score an upgrade action by the net income gain per coin spent.
275+
*
276+
* Higher income bonus upgrades are preferred over cheaper ones.
277+
*/
278+
function scoreUpgradeAction(
279+
state: MainStreetState,
280+
action: BuyUpgradeAction,
281+
): number {
282+
const card = state.market.investments.find(
283+
c => c.id === action.cardId && c.family === 'upgrade',
284+
) as UpgradeCard | undefined;
285+
if (!card) return 0;
286+
287+
// Prefer upgrades with higher income bonus; use cost as tiebreaker (cheaper is better)
288+
return card.incomeBonus * UPGRADE_INCOME_WEIGHT - card.cost;
289+
}
290+
291+
/**
292+
* Score a business placement by the synergy bonus gained at the target slot.
293+
*
294+
* Considers both the new business's synergy with existing neighbors and the
295+
* increase in neighbor synergies caused by placing the new business.
296+
*
297+
* Higher scores indicate better placement (more synergy gained minus cost).
298+
*/
299+
function scoreBusinessAction(
300+
state: MainStreetState,
301+
action: BuyBusinessAction,
302+
): number {
303+
const card = state.market.business.find(c => c.id === action.cardId) as BusinessCard | undefined;
304+
if (!card) return 0;
305+
306+
// Simulate placement: clone the grid row, place card, compute synergy gain
307+
const simulatedGrid = [...state.streetGrid];
308+
simulatedGrid[action.slotIndex] = card;
309+
310+
// Synergy for the new card itself
311+
const newCardSynergy = computeSynergyBonus(
312+
simulatedGrid,
313+
action.slotIndex,
314+
state.config.synergyBonusPerNeighbor,
315+
);
316+
317+
// Increase in synergy for existing neighbors
318+
let neighborSynergyGain = 0;
319+
for (let i = 0; i < GRID_SIZE; i++) {
320+
if (i === action.slotIndex) continue;
321+
if (state.streetGrid[i] === null) continue;
322+
const before = computeSynergyBonus(state.streetGrid, i, state.config.synergyBonusPerNeighbor);
323+
const after = computeSynergyBonus(simulatedGrid, i, state.config.synergyBonusPerNeighbor);
324+
neighborSynergyGain += after - before;
325+
}
326+
327+
const totalSynergyGain = newCardSynergy + neighborSynergyGain;
328+
329+
// Also weight by base income and subtract cost
330+
return card.baseIncome * BASE_INCOME_WEIGHT + totalSynergyGain * SYNERGY_WEIGHT - card.cost;
331+
}
332+
333+
/**
334+
* Score an event purchase by its expected net coin return.
335+
*
336+
* Investment events with positive coinDelta (relative to cost) are preferred.
337+
* A score > 0 means the event is worth buying.
338+
*/
339+
function scoreEventAction(
340+
state: MainStreetState,
341+
action: BuyEventAction,
342+
): number {
343+
const card = state.market.investments.find(
344+
c => c.id === action.cardId && c.family === 'event',
345+
) as EventCard | undefined;
346+
if (!card) return 0;
347+
348+
// Net value: coinDelta (expected return) minus cost to buy, plus reputation bonus
349+
return card.coinDelta - card.cost + card.reputationDelta * REPUTATION_COIN_WEIGHT;
350+
}

0 commit comments

Comments
 (0)