|
| 1 | +/** |
| 2 | + * headlessGame.ts |
| 3 | + * |
| 4 | + * Headless (no Phaser) game runner for The Mind. |
| 5 | + * |
| 6 | + * Simulates a complete AI-vs-AI game by computing timing delays for |
| 7 | + * both players, then resolving plays in chronological order. Produces |
| 8 | + * a valid MindTranscript identical in structure to interactive games. |
| 9 | + * |
| 10 | + * Usage: |
| 11 | + * import { runGame } from './headlessGame'; |
| 12 | + * const result = runGame({ seed: 42 }); |
| 13 | + * console.log(result.transcript.results); |
| 14 | + * |
| 15 | + * @module |
| 16 | + */ |
| 17 | + |
| 18 | +import { createSeededRng } from '../../src/core-engine/SeededRng'; |
| 19 | +import type { PlayerId, TheMindSession } from './TheMindGameState'; |
| 20 | +import { |
| 21 | + setupTheMindGame, |
| 22 | + playCard, |
| 23 | + isGameOver, |
| 24 | + getPileTopValue, |
| 25 | +} from './TheMindGameState'; |
| 26 | +import { MindAiPlayer } from './AiStrategy'; |
| 27 | +import type { MindAiTimingConfig } from './AiStrategy'; |
| 28 | +import { MindTranscriptRecorder } from './GameTranscript'; |
| 29 | +import type { MindTranscript, MindInitialState } from './GameTranscript'; |
| 30 | + |
| 31 | +// --------------------------------------------------------------------------- |
| 32 | +// Types |
| 33 | +// --------------------------------------------------------------------------- |
| 34 | + |
| 35 | +/** Configuration for a headless game run. */ |
| 36 | +export interface HeadlessGameConfig { |
| 37 | + /** Seed for the game RNG (deck shuffling). Defaults to 42. */ |
| 38 | + seed?: number; |
| 39 | + /** |
| 40 | + * Seed for Player 0's AI timing RNG. Defaults to seed + 1. |
| 41 | + * Using a different seed from Player 1 ensures independent timing. |
| 42 | + */ |
| 43 | + player0AiSeed?: number; |
| 44 | + /** |
| 45 | + * Seed for Player 1's AI timing RNG. Defaults to seed + 2. |
| 46 | + */ |
| 47 | + player1AiSeed?: number; |
| 48 | + /** Optional timing config override for both AI players. */ |
| 49 | + timingConfig?: Partial<MindAiTimingConfig>; |
| 50 | + /** Player names. Defaults to ['AI-0', 'AI-1']. */ |
| 51 | + playerNames?: [string, string]; |
| 52 | +} |
| 53 | + |
| 54 | +/** Result of a headless game run. */ |
| 55 | +export interface HeadlessGameResult { |
| 56 | + /** The finalized transcript. */ |
| 57 | + readonly transcript: MindTranscript; |
| 58 | + /** Total number of card plays across all levels. */ |
| 59 | + readonly totalPlays: number; |
| 60 | + /** Total number of penalties incurred. */ |
| 61 | + readonly totalPenalties: number; |
| 62 | + /** Final game outcome. */ |
| 63 | + readonly outcome: 'win' | 'loss'; |
| 64 | + /** Final level reached. */ |
| 65 | + readonly finalLevel: number; |
| 66 | + /** Lives remaining at game end. */ |
| 67 | + readonly finalLives: number; |
| 68 | +} |
| 69 | + |
| 70 | +/** A pending card play in the simulation queue. */ |
| 71 | +interface PendingPlay { |
| 72 | + /** Which player will play this card. */ |
| 73 | + readonly playerId: PlayerId; |
| 74 | + /** The card value to play. */ |
| 75 | + readonly cardValue: number; |
| 76 | + /** Absolute simulation time (ms) when this play fires. */ |
| 77 | + readonly fireTime: number; |
| 78 | +} |
| 79 | + |
| 80 | +// --------------------------------------------------------------------------- |
| 81 | +// Headless runner |
| 82 | +// --------------------------------------------------------------------------- |
| 83 | + |
| 84 | +/** |
| 85 | + * Run a complete headless AI-vs-AI game of The Mind. |
| 86 | + * |
| 87 | + * Both players use the linear timing strategy with independent seeded |
| 88 | + * RNGs. The simulation resolves plays in chronological order by their |
| 89 | + * computed fire times, handling penalties and level transitions. |
| 90 | + * |
| 91 | + * @param config - Optional configuration for seeds, timing, and names. |
| 92 | + * @returns A HeadlessGameResult with the finalized transcript and stats. |
| 93 | + */ |
| 94 | +export function runGame(config?: HeadlessGameConfig): HeadlessGameResult { |
| 95 | + const seed = config?.seed ?? 42; |
| 96 | + const p0AiSeed = config?.player0AiSeed ?? seed + 1; |
| 97 | + const p1AiSeed = config?.player1AiSeed ?? seed + 2; |
| 98 | + const names = config?.playerNames ?? ['AI-0', 'AI-1']; |
| 99 | + |
| 100 | + // Create game session with seeded RNG |
| 101 | + const gameRng = createSeededRng(seed); |
| 102 | + const session = setupTheMindGame({ |
| 103 | + playerNames: names, |
| 104 | + isAI: [true, true], |
| 105 | + rng: gameRng, |
| 106 | + }); |
| 107 | + |
| 108 | + // Create AI players with independent RNGs |
| 109 | + const aiPlayers: [MindAiPlayer, MindAiPlayer] = [ |
| 110 | + new MindAiPlayer(undefined, createSeededRng(p0AiSeed), config?.timingConfig), |
| 111 | + new MindAiPlayer(undefined, createSeededRng(p1AiSeed), config?.timingConfig), |
| 112 | + ]; |
| 113 | + |
| 114 | + // Create transcript recorder |
| 115 | + const initialState: MindInitialState = { |
| 116 | + playerNames: names, |
| 117 | + isAI: [true, true], |
| 118 | + startingLives: session.lives, |
| 119 | + startingLevel: session.currentLevel, |
| 120 | + hands: [ |
| 121 | + session.players[0].hand.map((c) => c.value), |
| 122 | + session.players[1].hand.map((c) => c.value), |
| 123 | + ], |
| 124 | + }; |
| 125 | + const recorder = new MindTranscriptRecorder(initialState); |
| 126 | + |
| 127 | + // Simulation state |
| 128 | + let totalPlays = 0; |
| 129 | + let totalPenalties = 0; |
| 130 | + let levelStartTime = 0; |
| 131 | + |
| 132 | + // Commit initial level delays |
| 133 | + commitLevelDelays(session, aiPlayers, levelStartTime); |
| 134 | + |
| 135 | + // Main simulation loop |
| 136 | + while (!isGameOver(session)) { |
| 137 | + // Build queue of pending plays from both players |
| 138 | + const queue = buildPlayQueue(aiPlayers, levelStartTime); |
| 139 | + |
| 140 | + if (queue.length === 0) { |
| 141 | + // No cards left but game not over — shouldn't happen, but guard |
| 142 | + break; |
| 143 | + } |
| 144 | + |
| 145 | + // Pick the earliest play |
| 146 | + const next = queue[0]; |
| 147 | + const timestamp = next.fireTime - levelStartTime; |
| 148 | + |
| 149 | + // Execute the play |
| 150 | + const result = playCard(session, next.playerId, next.cardValue); |
| 151 | + |
| 152 | + if (!result.success) { |
| 153 | + // Card was already removed (e.g., by penalty). Remove from AI and retry. |
| 154 | + aiPlayers[next.playerId].removeCard(next.cardValue); |
| 155 | + continue; |
| 156 | + } |
| 157 | + |
| 158 | + totalPlays++; |
| 159 | + |
| 160 | + // Record card play |
| 161 | + recorder.recordCardPlay( |
| 162 | + timestamp, |
| 163 | + next.playerId, |
| 164 | + next.cardValue, |
| 165 | + getPileTopValue(session), |
| 166 | + session.pile.size(), |
| 167 | + ); |
| 168 | + |
| 169 | + // Remove from both AI players (the played card, and any penalty cards) |
| 170 | + aiPlayers[next.playerId].removeCard(next.cardValue); |
| 171 | + |
| 172 | + if (result.lifeLost) { |
| 173 | + totalPenalties++; |
| 174 | + |
| 175 | + recorder.recordPenalty( |
| 176 | + timestamp, |
| 177 | + session.lives, |
| 178 | + result.penaltyCards.map((p) => ({ |
| 179 | + playerId: p.playerId, |
| 180 | + cardValue: p.card.value, |
| 181 | + })), |
| 182 | + ); |
| 183 | + |
| 184 | + // Remove penalty cards from AI players |
| 185 | + for (const pc of result.penaltyCards) { |
| 186 | + aiPlayers[pc.playerId].removeCard(pc.card.value); |
| 187 | + } |
| 188 | + } |
| 189 | + |
| 190 | + // Handle level completion (must be recorded before game-over check, |
| 191 | + // since completing the final level triggers both levelComplete and |
| 192 | + // game-over simultaneously) |
| 193 | + if (result.levelComplete) { |
| 194 | + // When the game is won, currentLevel stays at MAX_LEVEL (no dealLevel call). |
| 195 | + // When a non-final level completes, dealLevel advances currentLevel. |
| 196 | + const completedLevel = session.outcome === 'win' |
| 197 | + ? session.currentLevel |
| 198 | + : session.currentLevel - 1; |
| 199 | + |
| 200 | + recorder.recordLevelComplete( |
| 201 | + timestamp, |
| 202 | + completedLevel, |
| 203 | + result.bonusLifeAwarded, |
| 204 | + session.lives, |
| 205 | + ); |
| 206 | + |
| 207 | + if (isGameOver(session)) { |
| 208 | + break; |
| 209 | + } |
| 210 | + |
| 211 | + // New level has been dealt by playCard — commit new delays |
| 212 | + levelStartTime = next.fireTime; |
| 213 | + commitLevelDelays(session, aiPlayers, levelStartTime); |
| 214 | + } |
| 215 | + |
| 216 | + // Check game over (loss from penalty with no level completion) |
| 217 | + if (isGameOver(session)) { |
| 218 | + break; |
| 219 | + } |
| 220 | + } |
| 221 | + |
| 222 | + // Finalize transcript |
| 223 | + const outcome = session.outcome as 'win' | 'loss'; |
| 224 | + const finalTimestamp = Date.now(); // arbitrary for headless |
| 225 | + const transcript = recorder.finalize( |
| 226 | + finalTimestamp, |
| 227 | + outcome, |
| 228 | + session.currentLevel, |
| 229 | + session.lives, |
| 230 | + ); |
| 231 | + |
| 232 | + return { |
| 233 | + transcript, |
| 234 | + totalPlays, |
| 235 | + totalPenalties, |
| 236 | + outcome, |
| 237 | + finalLevel: session.currentLevel, |
| 238 | + finalLives: session.lives, |
| 239 | + }; |
| 240 | +} |
| 241 | + |
| 242 | +// --------------------------------------------------------------------------- |
| 243 | +// Helpers |
| 244 | +// --------------------------------------------------------------------------- |
| 245 | + |
| 246 | +/** |
| 247 | + * Commit level delays for both AI players based on their current hands. |
| 248 | + */ |
| 249 | +function commitLevelDelays( |
| 250 | + session: TheMindSession, |
| 251 | + aiPlayers: [MindAiPlayer, MindAiPlayer], |
| 252 | + _levelStartTime: number, |
| 253 | +): void { |
| 254 | + aiPlayers[0].commitLevel(session.players[0].hand); |
| 255 | + aiPlayers[1].commitLevel(session.players[1].hand); |
| 256 | +} |
| 257 | + |
| 258 | +/** |
| 259 | + * Build a sorted queue of pending plays from both AI players. |
| 260 | + * Returns plays sorted by fire time (earliest first). |
| 261 | + */ |
| 262 | +function buildPlayQueue( |
| 263 | + aiPlayers: [MindAiPlayer, MindAiPlayer], |
| 264 | + levelStartTime: number, |
| 265 | +): PendingPlay[] { |
| 266 | + const queue: PendingPlay[] = []; |
| 267 | + |
| 268 | + for (let p = 0; p < 2; p++) { |
| 269 | + const playerId = p as PlayerId; |
| 270 | + const delays = aiPlayers[p].getCardDelays(); |
| 271 | + for (const d of delays) { |
| 272 | + queue.push({ |
| 273 | + playerId, |
| 274 | + cardValue: d.card.value, |
| 275 | + fireTime: levelStartTime + Math.max(d.delay, 0), |
| 276 | + }); |
| 277 | + } |
| 278 | + } |
| 279 | + |
| 280 | + // Sort by fire time, then by card value (lower card first on ties) |
| 281 | + queue.sort((a, b) => a.fireTime - b.fireTime || a.cardValue - b.cardValue); |
| 282 | + return queue; |
| 283 | +} |
0 commit comments