golfgame/tests/e2e/bot/ai-brain.ts
Aaron D. Lee 6950769bc3 Version 2.0.0: Animation fixes, timing improvements, and E2E test suite
Animation fixes:
- Fix held card positioning bug (was appearing at bottom of page)
- Fix discard pile blank/white flash on turn transitions
- Fix blank card at round end by skipping animations during round_over/game_over
- Set card content before triggering flip animation to prevent flash
- Center suit symbol on 10 cards

Timing improvements:
- Reduce post-discard delay from 700ms to 500ms
- Reduce post-swap delay from 1800ms to 1000ms
- Speed up swap flip animation from 1150ms to 550ms
- Reduce CPU initial thinking delay from 150-250ms to 80-150ms
- Pause now happens after swap completes (showing result) instead of before

E2E test suite:
- Add Playwright-based test bot that plays full games
- State parser extracts game state from DOM for validation
- AI brain ports decision logic for automated play
- Freeze detector monitors for UI hangs
- Visual validator checks CSS states
- Full game, stress, and visual test specs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:33:28 -05:00

335 lines
9.0 KiB
TypeScript

/**
* AI decision-making logic for the test bot
* Simplified port of server/ai.py for client-side decision making
*/
import { CardState, PlayerState } from './state-parser';
/**
* Card value mapping (standard rules)
*/
const CARD_VALUES: Record<string, number> = {
'★': -2, // Joker
'2': -2,
'A': 1,
'K': 0,
'3': 3,
'4': 4,
'5': 5,
'6': 6,
'7': 7,
'8': 8,
'9': 9,
'10': 10,
'J': 10,
'Q': 10,
};
/**
* Game options that affect card values
*/
export interface GameOptions {
superKings?: boolean; // K = -2 instead of 0
tenPenny?: boolean; // 10 = 1 instead of 10
oneEyedJacks?: boolean; // J♥/J♠ = 0
eagleEye?: boolean; // Jokers +2 unpaired, -4 paired
}
/**
* Get the point value of a card
*/
export function getCardValue(card: { rank: string | null; suit?: string | null }, options: GameOptions = {}): number {
if (!card.rank) return 5; // Unknown card estimated at average
let value = CARD_VALUES[card.rank] ?? 5;
// Super Kings rule
if (options.superKings && card.rank === 'K') {
value = -2;
}
// Ten Penny rule
if (options.tenPenny && card.rank === '10') {
value = 1;
}
// One-Eyed Jacks rule
if (options.oneEyedJacks && card.rank === 'J') {
if (card.suit === 'hearts' || card.suit === 'spades') {
value = 0;
}
}
// Eagle Eye rule (Jokers are +2 when unpaired)
// Note: We can't know pairing status from just one card, so this is informational
if (options.eagleEye && card.rank === '★') {
value = 2; // Default to unpaired value
}
return value;
}
/**
* Get column partner position (cards that can form pairs)
* Column pairs: (0,3), (1,4), (2,5)
*/
export function getColumnPartner(position: number): number {
return position < 3 ? position + 3 : position - 3;
}
/**
* AI Brain - makes decisions for the test bot
*/
export class AIBrain {
constructor(private options: GameOptions = {}) {}
/**
* Choose 2 cards for initial flip
* Prefer different columns for better pair information
*/
chooseInitialFlips(cards: CardState[]): number[] {
const faceDown = cards.filter(c => !c.faceUp);
if (faceDown.length === 0) return [];
if (faceDown.length === 1) return [faceDown[0].position];
// Good initial flip patterns (different columns)
const patterns = [
[0, 4], [2, 4], [3, 1], [5, 1],
[0, 5], [2, 3],
];
// Find a valid pattern
for (const pattern of patterns) {
const valid = pattern.every(p =>
faceDown.some(c => c.position === p)
);
if (valid) return pattern;
}
// Fallback: pick any two face-down cards in different columns
const result: number[] = [];
const usedColumns = new Set<number>();
for (const card of faceDown) {
const col = card.position % 3;
if (!usedColumns.has(col)) {
result.push(card.position);
usedColumns.add(col);
if (result.length === 2) break;
}
}
// If we couldn't get different columns, just take first two
if (result.length < 2) {
for (const card of faceDown) {
if (!result.includes(card.position)) {
result.push(card.position);
if (result.length === 2) break;
}
}
}
return result;
}
/**
* Decide whether to take from discard pile
*/
shouldTakeDiscard(
discardCard: { rank: string; suit: string } | null,
myCards: CardState[]
): boolean {
if (!discardCard) return false;
const value = getCardValue(discardCard, this.options);
// Always take Jokers and Kings (excellent cards)
if (discardCard.rank === '★' || discardCard.rank === 'K') {
return true;
}
// Always take negative/low value cards
if (value <= 2) {
return true;
}
// Check if discard can form a pair with a visible card
for (const card of myCards) {
if (card.faceUp && card.rank === discardCard.rank) {
const partnerPos = getColumnPartner(card.position);
const partnerCard = myCards.find(c => c.position === partnerPos);
// Only pair if partner is face-down (unknown) - pairing negative cards is wasteful
if (partnerCard && !partnerCard.faceUp && value > 0) {
return true;
}
}
}
// Take medium cards if we have visible bad cards to replace
if (value <= 5) {
for (const card of myCards) {
if (card.faceUp && card.rank) {
const cardValue = getCardValue(card, this.options);
if (cardValue > value + 1) {
return true;
}
}
}
}
// Default: draw from deck
return false;
}
/**
* Choose position to swap drawn card, or null to discard
*/
chooseSwapPosition(
drawnCard: { rank: string; suit?: string | null },
myCards: CardState[],
mustSwap: boolean = false // True if drawn from discard
): number | null {
const drawnValue = getCardValue(drawnCard, this.options);
// Calculate score for each position
const scores: { pos: number; score: number }[] = [];
for (let pos = 0; pos < 6; pos++) {
const card = myCards.find(c => c.position === pos);
if (!card) continue;
let score = 0;
const partnerPos = getColumnPartner(pos);
const partnerCard = myCards.find(c => c.position === partnerPos);
// Check for pair creation
if (partnerCard?.faceUp && partnerCard.rank === drawnCard.rank) {
const partnerValue = getCardValue(partnerCard, this.options);
if (drawnValue >= 0) {
// Good pair! Both cards become 0
score += drawnValue + partnerValue;
} else {
// Pairing negative cards is wasteful (unless special rules)
score -= Math.abs(drawnValue) * 2;
}
}
// Point improvement
if (card.faceUp && card.rank) {
const currentValue = getCardValue(card, this.options);
score += currentValue - drawnValue;
} else {
// Face-down card - expected value ~4.5
const expectedHidden = 4.5;
score += (expectedHidden - drawnValue) * 0.7; // Discount for uncertainty
}
// Bonus for revealing hidden cards with good drawn cards
if (!card.faceUp && drawnValue <= 3) {
score += 2;
}
scores.push({ pos, score });
}
// Sort by score descending
scores.sort((a, b) => b.score - a.score);
// If best score is positive, swap there
if (scores.length > 0 && scores[0].score > 0) {
return scores[0].pos;
}
// Must swap if drawn from discard
if (mustSwap && scores.length > 0) {
// Find a face-down position if possible
const faceDownScores = scores.filter(s => {
const card = myCards.find(c => c.position === s.pos);
return card && !card.faceUp;
});
if (faceDownScores.length > 0) {
return faceDownScores[0].pos;
}
// Otherwise take the best score even if negative
return scores[0].pos;
}
// Discard the drawn card
return null;
}
/**
* Choose which card to flip after discarding
*/
chooseFlipPosition(myCards: CardState[]): number {
const faceDown = myCards.filter(c => !c.faceUp);
if (faceDown.length === 0) return 0;
// Prefer flipping cards where the partner is visible (pair info)
for (const card of faceDown) {
const partnerPos = getColumnPartner(card.position);
const partner = myCards.find(c => c.position === partnerPos);
if (partner?.faceUp) {
return card.position;
}
}
// Random face-down card
return faceDown[Math.floor(Math.random() * faceDown.length)].position;
}
/**
* Decide whether to skip optional flip (endgame mode)
*/
shouldSkipFlip(myCards: CardState[]): boolean {
const faceDown = myCards.filter(c => !c.faceUp);
// Always flip if we have many hidden cards
if (faceDown.length >= 3) {
return false;
}
// Small chance to skip with 1-2 hidden cards
return faceDown.length <= 2 && Math.random() < 0.15;
}
/**
* Calculate estimated hand score
*/
estimateScore(cards: CardState[]): number {
let score = 0;
// Group cards by column for pair detection
const columns: (CardState | undefined)[][] = [
[cards.find(c => c.position === 0), cards.find(c => c.position === 3)],
[cards.find(c => c.position === 1), cards.find(c => c.position === 4)],
[cards.find(c => c.position === 2), cards.find(c => c.position === 5)],
];
for (const [top, bottom] of columns) {
if (top?.faceUp && bottom?.faceUp) {
if (top.rank === bottom.rank) {
// Pair - contributes 0
continue;
}
score += getCardValue(top, this.options);
score += getCardValue(bottom, this.options);
} else if (top?.faceUp) {
score += getCardValue(top, this.options);
score += 4.5; // Estimate for hidden bottom
} else if (bottom?.faceUp) {
score += 4.5; // Estimate for hidden top
score += getCardValue(bottom, this.options);
} else {
score += 9; // Both hidden, estimate 4.5 each
}
}
return Math.round(score);
}
}