Skip to content

Commit 0f613fb

Browse files
committed
Merge feature/CG-0MLZSLMEL0WJDTPZ-auto-play-spectator: Auto-Play Spectator Mode for The Mind
2 parents 42a1238 + 867756e commit 0f613fb

File tree

3 files changed

+806
-5
lines changed

3 files changed

+806
-5
lines changed
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
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

Comments
 (0)