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>
This commit is contained in:
255
tests/e2e/bot/actions.ts
Normal file
255
tests/e2e/bot/actions.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Game action executors with proper animation timing
|
||||
*/
|
||||
|
||||
import { Page } from '@playwright/test';
|
||||
import { SELECTORS } from '../utils/selectors';
|
||||
import { TIMING, waitForAnimations } from '../utils/timing';
|
||||
|
||||
/**
|
||||
* Result of a game action
|
||||
*/
|
||||
export interface ActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes game actions on the page
|
||||
*/
|
||||
export class Actions {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
/**
|
||||
* Draw a card from the deck
|
||||
*/
|
||||
async drawFromDeck(): Promise<ActionResult> {
|
||||
try {
|
||||
// Wait for any ongoing animations first
|
||||
await waitForAnimations(this.page);
|
||||
|
||||
const deck = this.page.locator(SELECTORS.game.deck);
|
||||
await deck.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Wait for deck to become clickable (may take a moment after turn starts)
|
||||
let isClickable = false;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
isClickable = await deck.evaluate(el => el.classList.contains('clickable'));
|
||||
if (isClickable) break;
|
||||
await this.page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
if (!isClickable) {
|
||||
return { success: false, error: 'Deck is not clickable' };
|
||||
}
|
||||
|
||||
// Use force:true because deck-area has a pulsing animation that makes it "unstable"
|
||||
await deck.click({ force: true, timeout: 5000 });
|
||||
await this.page.waitForTimeout(TIMING.drawComplete);
|
||||
await waitForAnimations(this.page);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a card from the discard pile
|
||||
*/
|
||||
async drawFromDiscard(): Promise<ActionResult> {
|
||||
try {
|
||||
// Wait for any ongoing animations first
|
||||
await waitForAnimations(this.page);
|
||||
|
||||
const discard = this.page.locator(SELECTORS.game.discard);
|
||||
await discard.waitFor({ state: 'visible', timeout: 5000 });
|
||||
// Use force:true because deck-area has a pulsing animation
|
||||
await discard.click({ force: true, timeout: 5000 });
|
||||
await this.page.waitForTimeout(TIMING.drawComplete);
|
||||
await waitForAnimations(this.page);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap drawn card with a card at position
|
||||
*/
|
||||
async swapCard(position: number): Promise<ActionResult> {
|
||||
try {
|
||||
const cardSelector = SELECTORS.cards.playerCard(position);
|
||||
const card = this.page.locator(cardSelector);
|
||||
await card.waitFor({ state: 'visible', timeout: 5000 });
|
||||
// Use force:true to handle any CSS animations
|
||||
await card.click({ force: true, timeout: 5000 });
|
||||
await this.page.waitForTimeout(TIMING.swapComplete);
|
||||
await waitForAnimations(this.page);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard the drawn card
|
||||
*/
|
||||
async discardDrawn(): Promise<ActionResult> {
|
||||
try {
|
||||
const discardBtn = this.page.locator(SELECTORS.game.discardBtn);
|
||||
await discardBtn.click();
|
||||
await this.page.waitForTimeout(TIMING.pauseAfterDiscard);
|
||||
await waitForAnimations(this.page);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip a card at position
|
||||
*/
|
||||
async flipCard(position: number): Promise<ActionResult> {
|
||||
try {
|
||||
// Wait for animations before clicking
|
||||
await waitForAnimations(this.page);
|
||||
|
||||
const cardSelector = SELECTORS.cards.playerCard(position);
|
||||
const card = this.page.locator(cardSelector);
|
||||
await card.waitFor({ state: 'visible', timeout: 5000 });
|
||||
// Use force:true to handle any CSS animations
|
||||
await card.click({ force: true, timeout: 5000 });
|
||||
await this.page.waitForTimeout(TIMING.flipComplete);
|
||||
await waitForAnimations(this.page);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip the optional flip (endgame mode)
|
||||
*/
|
||||
async skipFlip(): Promise<ActionResult> {
|
||||
try {
|
||||
const skipBtn = this.page.locator(SELECTORS.game.skipFlipBtn);
|
||||
await skipBtn.click();
|
||||
await this.page.waitForTimeout(TIMING.turnTransition);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Knock early (flip all remaining cards)
|
||||
*/
|
||||
async knockEarly(): Promise<ActionResult> {
|
||||
try {
|
||||
const knockBtn = this.page.locator(SELECTORS.game.knockEarlyBtn);
|
||||
await knockBtn.click();
|
||||
await this.page.waitForTimeout(TIMING.swapComplete);
|
||||
await waitForAnimations(this.page);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for turn to start
|
||||
*/
|
||||
async waitForMyTurn(timeout: number = 30000): Promise<boolean> {
|
||||
try {
|
||||
await this.page.waitForFunction(
|
||||
(sel) => {
|
||||
const deckArea = document.querySelector(sel);
|
||||
return deckArea?.classList.contains('your-turn-to-draw');
|
||||
},
|
||||
SELECTORS.game.deckArea,
|
||||
{ timeout }
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for game phase change
|
||||
*/
|
||||
async waitForPhase(
|
||||
expectedPhases: string[],
|
||||
timeout: number = 30000
|
||||
): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
// Check for round over
|
||||
const nextRoundBtn = this.page.locator(SELECTORS.game.nextRoundBtn);
|
||||
if (await nextRoundBtn.isVisible().catch(() => false)) {
|
||||
if (expectedPhases.includes('round_over')) return true;
|
||||
}
|
||||
|
||||
// Check for game over
|
||||
const newGameBtn = this.page.locator(SELECTORS.game.newGameBtn);
|
||||
if (await newGameBtn.isVisible().catch(() => false)) {
|
||||
if (expectedPhases.includes('game_over')) return true;
|
||||
}
|
||||
|
||||
// Check for final turn
|
||||
const finalTurnBadge = this.page.locator(SELECTORS.game.finalTurnBadge);
|
||||
if (await finalTurnBadge.isVisible().catch(() => false)) {
|
||||
if (expectedPhases.includes('final_turn')) return true;
|
||||
}
|
||||
|
||||
// Check for my turn (playing phase)
|
||||
const deckArea = this.page.locator(SELECTORS.game.deckArea);
|
||||
const isMyTurn = await deckArea.evaluate(el =>
|
||||
el.classList.contains('your-turn-to-draw')
|
||||
).catch(() => false);
|
||||
if (isMyTurn && expectedPhases.includes('playing')) return true;
|
||||
|
||||
await this.page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Next Hole" button to start next round
|
||||
*/
|
||||
async nextRound(): Promise<ActionResult> {
|
||||
try {
|
||||
const btn = this.page.locator(SELECTORS.game.nextRoundBtn);
|
||||
await btn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await btn.click();
|
||||
await this.page.waitForTimeout(TIMING.roundOverDelay);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "New Game" button to return to waiting room
|
||||
*/
|
||||
async newGame(): Promise<ActionResult> {
|
||||
try {
|
||||
const btn = this.page.locator(SELECTORS.game.newGameBtn);
|
||||
await btn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await btn.click();
|
||||
await this.page.waitForTimeout(TIMING.turnTransition);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for animations to complete
|
||||
*/
|
||||
async waitForAnimationComplete(timeout: number = 5000): Promise<void> {
|
||||
await waitForAnimations(this.page, timeout);
|
||||
}
|
||||
}
|
||||
334
tests/e2e/bot/ai-brain.ts
Normal file
334
tests/e2e/bot/ai-brain.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
599
tests/e2e/bot/golf-bot.ts
Normal file
599
tests/e2e/bot/golf-bot.ts
Normal file
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* GolfBot - Main orchestrator for the test bot
|
||||
* Controls browser and coordinates game actions
|
||||
*/
|
||||
|
||||
import { Page, expect } from '@playwright/test';
|
||||
import { StateParser, GamePhase, ParsedGameState } from './state-parser';
|
||||
import { AIBrain, GameOptions } from './ai-brain';
|
||||
import { Actions, ActionResult } from './actions';
|
||||
import { SELECTORS } from '../utils/selectors';
|
||||
import { TIMING, waitForAnimations } from '../utils/timing';
|
||||
|
||||
/**
|
||||
* Options for starting a game
|
||||
*/
|
||||
export interface StartGameOptions {
|
||||
holes?: number;
|
||||
decks?: number;
|
||||
initialFlips?: number;
|
||||
flipMode?: 'never' | 'always' | 'endgame';
|
||||
knockPenalty?: boolean;
|
||||
jokerMode?: 'none' | 'standard' | 'lucky-swing' | 'eagle-eye';
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a turn
|
||||
*/
|
||||
export interface TurnResult {
|
||||
success: boolean;
|
||||
action: string;
|
||||
details?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GolfBot - automated game player for testing
|
||||
*/
|
||||
export class GolfBot {
|
||||
private stateParser: StateParser;
|
||||
private actions: Actions;
|
||||
private brain: AIBrain;
|
||||
private screenshots: { label: string; buffer: Buffer }[] = [];
|
||||
private consoleErrors: string[] = [];
|
||||
private turnCount = 0;
|
||||
|
||||
constructor(
|
||||
private page: Page,
|
||||
aiOptions: GameOptions = {}
|
||||
) {
|
||||
this.stateParser = new StateParser(page);
|
||||
this.actions = new Actions(page);
|
||||
this.brain = new AIBrain(aiOptions);
|
||||
|
||||
// Capture console errors
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') {
|
||||
this.consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
page.on('pageerror', err => {
|
||||
this.consoleErrors.push(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the game
|
||||
*/
|
||||
async goto(url?: string): Promise<void> {
|
||||
await this.page.goto(url || '/');
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new game room
|
||||
*/
|
||||
async createGame(playerName: string): Promise<string> {
|
||||
// Enter name
|
||||
const nameInput = this.page.locator(SELECTORS.lobby.playerNameInput);
|
||||
await nameInput.fill(playerName);
|
||||
|
||||
// Click create room
|
||||
const createBtn = this.page.locator(SELECTORS.lobby.createRoomBtn);
|
||||
await createBtn.click();
|
||||
|
||||
// Wait for waiting room
|
||||
await this.page.waitForSelector(SELECTORS.screens.waiting, {
|
||||
state: 'visible',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Get room code
|
||||
const roomCodeEl = this.page.locator(SELECTORS.waiting.roomCode);
|
||||
const roomCode = await roomCodeEl.textContent() || '';
|
||||
|
||||
return roomCode.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Join an existing game room
|
||||
*/
|
||||
async joinGame(roomCode: string, playerName: string): Promise<void> {
|
||||
// Enter name
|
||||
const nameInput = this.page.locator(SELECTORS.lobby.playerNameInput);
|
||||
await nameInput.fill(playerName);
|
||||
|
||||
// Enter room code
|
||||
const codeInput = this.page.locator(SELECTORS.lobby.roomCodeInput);
|
||||
await codeInput.fill(roomCode);
|
||||
|
||||
// Click join
|
||||
const joinBtn = this.page.locator(SELECTORS.lobby.joinRoomBtn);
|
||||
await joinBtn.click();
|
||||
|
||||
// Wait for waiting room
|
||||
await this.page.waitForSelector(SELECTORS.screens.waiting, {
|
||||
state: 'visible',
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a CPU player
|
||||
*/
|
||||
async addCPU(profileName?: string): Promise<void> {
|
||||
// Click add CPU button
|
||||
const addBtn = this.page.locator(SELECTORS.waiting.addCpuBtn);
|
||||
await addBtn.click();
|
||||
|
||||
// Wait for modal
|
||||
await this.page.waitForSelector(SELECTORS.waiting.cpuModal, {
|
||||
state: 'visible',
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Select profile if specified
|
||||
if (profileName) {
|
||||
const profileCard = this.page.locator(
|
||||
`${SELECTORS.waiting.cpuProfilesGrid} .profile-card:has-text("${profileName}")`
|
||||
);
|
||||
await profileCard.click();
|
||||
} else {
|
||||
// Select first available profile
|
||||
const firstProfile = this.page.locator(
|
||||
`${SELECTORS.waiting.cpuProfilesGrid} .profile-card:not(.unavailable)`
|
||||
).first();
|
||||
await firstProfile.click();
|
||||
}
|
||||
|
||||
// Click add button
|
||||
const addSelectedBtn = this.page.locator(SELECTORS.waiting.addSelectedCpusBtn);
|
||||
await addSelectedBtn.click();
|
||||
|
||||
// Wait for modal to close
|
||||
await this.page.waitForSelector(SELECTORS.waiting.cpuModal, {
|
||||
state: 'hidden',
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the game
|
||||
*/
|
||||
async startGame(options: StartGameOptions = {}): Promise<void> {
|
||||
// Set game options if host
|
||||
const hostSettings = this.page.locator(SELECTORS.waiting.hostSettings);
|
||||
|
||||
if (await hostSettings.isVisible()) {
|
||||
if (options.holes) {
|
||||
await this.page.selectOption(SELECTORS.waiting.numRounds, String(options.holes));
|
||||
}
|
||||
if (options.decks) {
|
||||
await this.page.selectOption(SELECTORS.waiting.numDecks, String(options.decks));
|
||||
}
|
||||
if (options.initialFlips !== undefined) {
|
||||
await this.page.selectOption(SELECTORS.waiting.initialFlips, String(options.initialFlips));
|
||||
}
|
||||
|
||||
// Advanced options require opening the details section first
|
||||
if (options.flipMode) {
|
||||
const advancedSection = this.page.locator('.advanced-options-section');
|
||||
if (await advancedSection.isVisible()) {
|
||||
// Check if it's already open
|
||||
const isOpen = await advancedSection.evaluate(el => el.hasAttribute('open'));
|
||||
if (!isOpen) {
|
||||
await advancedSection.locator('summary').click();
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
await this.page.selectOption(SELECTORS.waiting.flipMode, options.flipMode);
|
||||
}
|
||||
}
|
||||
|
||||
// Click start game
|
||||
const startBtn = this.page.locator(SELECTORS.waiting.startGameBtn);
|
||||
await startBtn.click();
|
||||
|
||||
// Wait for game screen
|
||||
await this.page.waitForSelector(SELECTORS.screens.game, {
|
||||
state: 'visible',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await waitForAnimations(this.page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current game phase
|
||||
*/
|
||||
async getGamePhase(): Promise<GamePhase> {
|
||||
return this.stateParser.getPhase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full game state
|
||||
*/
|
||||
async getGameState(): Promise<ParsedGameState> {
|
||||
return this.stateParser.getState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if it's bot's turn
|
||||
*/
|
||||
async isMyTurn(): Promise<boolean> {
|
||||
return this.stateParser.isMyTurn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for bot's turn
|
||||
*/
|
||||
async waitForMyTurn(timeout: number = 30000): Promise<boolean> {
|
||||
return this.actions.waitForMyTurn(timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for any animation to complete
|
||||
*/
|
||||
async waitForAnimation(): Promise<void> {
|
||||
await waitForAnimations(this.page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a complete turn
|
||||
*/
|
||||
async playTurn(): Promise<TurnResult> {
|
||||
this.turnCount++;
|
||||
const state = await this.getGameState();
|
||||
|
||||
// Handle initial flip phase
|
||||
if (state.phase === 'initial_flip') {
|
||||
return this.handleInitialFlip(state);
|
||||
}
|
||||
|
||||
// Handle waiting for flip after discard
|
||||
if (state.phase === 'waiting_for_flip') {
|
||||
return this.handleWaitingForFlip(state);
|
||||
}
|
||||
|
||||
// Regular turn
|
||||
if (!state.heldCard.visible) {
|
||||
// Need to draw
|
||||
return this.handleDraw(state);
|
||||
} else {
|
||||
// Have a card, need to swap or discard
|
||||
return this.handleSwapOrDiscard(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle initial flip phase
|
||||
*/
|
||||
private async handleInitialFlip(state: ParsedGameState): Promise<TurnResult> {
|
||||
const myCards = state.myPlayer?.cards || [];
|
||||
const faceDownPositions = myCards.filter(c => !c.faceUp).map(c => c.position);
|
||||
|
||||
if (faceDownPositions.length === 0) {
|
||||
return { success: true, action: 'initial_flip_complete' };
|
||||
}
|
||||
|
||||
// Choose cards to flip
|
||||
const toFlip = this.brain.chooseInitialFlips(myCards);
|
||||
|
||||
for (const pos of toFlip) {
|
||||
if (faceDownPositions.includes(pos)) {
|
||||
const result = await this.actions.flipCard(pos);
|
||||
if (!result.success) {
|
||||
return { success: false, action: 'initial_flip', error: result.error };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: 'initial_flip',
|
||||
details: { positions: toFlip },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle draw phase
|
||||
*/
|
||||
private async handleDraw(state: ParsedGameState): Promise<TurnResult> {
|
||||
const myCards = state.myPlayer?.cards || [];
|
||||
const discardTop = state.discard.topCard;
|
||||
|
||||
// Decide: discard or deck
|
||||
const takeDiscard = this.brain.shouldTakeDiscard(discardTop, myCards);
|
||||
|
||||
let result: ActionResult;
|
||||
let source: string;
|
||||
|
||||
if (takeDiscard && state.discard.clickable) {
|
||||
result = await this.actions.drawFromDiscard();
|
||||
source = 'discard';
|
||||
} else {
|
||||
result = await this.actions.drawFromDeck();
|
||||
source = 'deck';
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, action: 'draw', error: result.error };
|
||||
}
|
||||
|
||||
// Wait for held card to be visible
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
// Now handle swap or discard
|
||||
const newState = await this.getGameState();
|
||||
|
||||
// If held card still not visible, wait a bit more and retry
|
||||
if (!newState.heldCard.visible) {
|
||||
await this.page.waitForTimeout(500);
|
||||
const retryState = await this.getGameState();
|
||||
return this.handleSwapOrDiscard(retryState, source === 'discard');
|
||||
}
|
||||
|
||||
return this.handleSwapOrDiscard(newState, source === 'discard');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle swap or discard decision
|
||||
*/
|
||||
private async handleSwapOrDiscard(
|
||||
state: ParsedGameState,
|
||||
mustSwap: boolean = false
|
||||
): Promise<TurnResult> {
|
||||
const myCards = state.myPlayer?.cards || [];
|
||||
const heldCard = state.heldCard.card;
|
||||
|
||||
if (!heldCard) {
|
||||
return { success: false, action: 'swap_or_discard', error: 'No held card' };
|
||||
}
|
||||
|
||||
// Decide: swap position or discard
|
||||
const swapPos = this.brain.chooseSwapPosition(heldCard, myCards, mustSwap);
|
||||
|
||||
if (swapPos !== null) {
|
||||
// Swap
|
||||
const result = await this.actions.swapCard(swapPos);
|
||||
return {
|
||||
success: result.success,
|
||||
action: 'swap',
|
||||
details: { position: swapPos, card: heldCard },
|
||||
error: result.error,
|
||||
};
|
||||
} else {
|
||||
// Discard
|
||||
const result = await this.actions.discardDrawn();
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, action: 'discard', error: result.error };
|
||||
}
|
||||
|
||||
// Check if we need to flip
|
||||
await this.page.waitForTimeout(200);
|
||||
const afterState = await this.getGameState();
|
||||
|
||||
if (afterState.phase === 'waiting_for_flip') {
|
||||
return this.handleWaitingForFlip(afterState);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: 'discard',
|
||||
details: { card: heldCard },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle waiting for flip after discard
|
||||
*/
|
||||
private async handleWaitingForFlip(state: ParsedGameState): Promise<TurnResult> {
|
||||
const myCards = state.myPlayer?.cards || [];
|
||||
|
||||
// Check if flip is optional
|
||||
if (state.canSkipFlip) {
|
||||
if (this.brain.shouldSkipFlip(myCards)) {
|
||||
const result = await this.actions.skipFlip();
|
||||
return {
|
||||
success: result.success,
|
||||
action: 'skip_flip',
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Choose a card to flip
|
||||
const pos = this.brain.chooseFlipPosition(myCards);
|
||||
const result = await this.actions.flipCard(pos);
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
action: 'flip',
|
||||
details: { position: pos },
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot with label
|
||||
*/
|
||||
async takeScreenshot(label: string): Promise<Buffer> {
|
||||
const buffer = await this.page.screenshot();
|
||||
this.screenshots.push({ label, buffer });
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all collected screenshots
|
||||
*/
|
||||
getScreenshots(): { label: string; buffer: Buffer }[] {
|
||||
return this.screenshots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get console errors collected
|
||||
*/
|
||||
getConsoleErrors(): string[] {
|
||||
return this.consoleErrors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear console errors
|
||||
*/
|
||||
clearConsoleErrors(): void {
|
||||
this.consoleErrors = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the UI appears frozen (animation stuck)
|
||||
*/
|
||||
async isFrozen(timeout: number = 3000): Promise<boolean> {
|
||||
try {
|
||||
await waitForAnimations(this.page, timeout);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get turn count
|
||||
*/
|
||||
getTurnCount(): number {
|
||||
return this.turnCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play through initial flip phase completely
|
||||
*/
|
||||
async completeInitialFlips(): Promise<void> {
|
||||
let phase = await this.getGamePhase();
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
|
||||
while (phase === 'initial_flip' && attempts < maxAttempts) {
|
||||
if (await this.isMyTurn()) {
|
||||
await this.playTurn();
|
||||
}
|
||||
await this.page.waitForTimeout(500);
|
||||
phase = await this.getGamePhase();
|
||||
attempts++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play through entire round
|
||||
*/
|
||||
async playRound(maxTurns: number = 100): Promise<{ success: boolean; turns: number }> {
|
||||
let turns = 0;
|
||||
|
||||
while (turns < maxTurns) {
|
||||
const phase = await this.getGamePhase();
|
||||
|
||||
if (phase === 'round_over' || phase === 'game_over') {
|
||||
return { success: true, turns };
|
||||
}
|
||||
|
||||
if (await this.isMyTurn()) {
|
||||
const result = await this.playTurn();
|
||||
if (!result.success) {
|
||||
console.warn(`Turn ${turns} failed:`, result.error);
|
||||
}
|
||||
turns++;
|
||||
}
|
||||
|
||||
await this.page.waitForTimeout(200);
|
||||
|
||||
// Check for frozen state
|
||||
if (await this.isFrozen()) {
|
||||
return { success: false, turns };
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, turns };
|
||||
}
|
||||
|
||||
/**
|
||||
* Play through entire game (all rounds)
|
||||
*/
|
||||
async playGame(maxRounds: number = 18): Promise<{
|
||||
success: boolean;
|
||||
rounds: number;
|
||||
totalTurns: number;
|
||||
}> {
|
||||
let rounds = 0;
|
||||
let totalTurns = 0;
|
||||
|
||||
while (rounds < maxRounds) {
|
||||
const phase = await this.getGamePhase();
|
||||
|
||||
if (phase === 'game_over') {
|
||||
return { success: true, rounds, totalTurns };
|
||||
}
|
||||
|
||||
// Complete initial flips first
|
||||
await this.completeInitialFlips();
|
||||
|
||||
// Play the round
|
||||
const roundResult = await this.playRound();
|
||||
totalTurns += roundResult.turns;
|
||||
rounds++;
|
||||
|
||||
if (!roundResult.success) {
|
||||
return { success: false, rounds, totalTurns };
|
||||
}
|
||||
|
||||
// Check for game over
|
||||
let newPhase = await this.getGamePhase();
|
||||
if (newPhase === 'game_over') {
|
||||
return { success: true, rounds, totalTurns };
|
||||
}
|
||||
|
||||
// Check if this was the final round
|
||||
const state = await this.getGameState();
|
||||
const isLastRound = state.currentRound >= state.totalRounds;
|
||||
|
||||
// If last round just ended, wait for game_over or trigger it
|
||||
if (newPhase === 'round_over' && isLastRound) {
|
||||
// Wait a few seconds for auto-transition or countdown
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await this.page.waitForTimeout(1000);
|
||||
newPhase = await this.getGamePhase();
|
||||
if (newPhase === 'game_over') {
|
||||
return { success: true, rounds, totalTurns };
|
||||
}
|
||||
}
|
||||
|
||||
// Game might require clicking Next Hole to show Final Results
|
||||
// Try clicking the button to trigger the transition
|
||||
const nextResult = await this.actions.nextRound();
|
||||
|
||||
// Wait for Final Results modal to appear
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await this.page.waitForTimeout(1000);
|
||||
newPhase = await this.getGamePhase();
|
||||
if (newPhase === 'game_over') {
|
||||
return { success: true, rounds, totalTurns };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start next round if available
|
||||
if (newPhase === 'round_over') {
|
||||
await this.page.waitForTimeout(1000);
|
||||
const nextResult = await this.actions.nextRound();
|
||||
if (!nextResult.success) {
|
||||
// Maybe we're not the host, wait for host to start
|
||||
await this.page.waitForTimeout(5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, rounds, totalTurns };
|
||||
}
|
||||
}
|
||||
10
tests/e2e/bot/index.ts
Normal file
10
tests/e2e/bot/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { GolfBot, StartGameOptions, TurnResult } from './golf-bot';
|
||||
export { AIBrain, GameOptions, getCardValue, getColumnPartner } from './ai-brain';
|
||||
export { Actions, ActionResult } from './actions';
|
||||
export {
|
||||
StateParser,
|
||||
CardState,
|
||||
PlayerState,
|
||||
ParsedGameState,
|
||||
GamePhase,
|
||||
} from './state-parser';
|
||||
524
tests/e2e/bot/state-parser.ts
Normal file
524
tests/e2e/bot/state-parser.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
import { SELECTORS } from '../utils/selectors';
|
||||
|
||||
/**
|
||||
* Represents a card's state as extracted from the DOM
|
||||
*/
|
||||
export interface CardState {
|
||||
position: number;
|
||||
faceUp: boolean;
|
||||
rank: string | null;
|
||||
suit: string | null;
|
||||
clickable: boolean;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a player's state as extracted from the DOM
|
||||
*/
|
||||
export interface PlayerState {
|
||||
name: string;
|
||||
cards: CardState[];
|
||||
isCurrentTurn: boolean;
|
||||
score: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the overall game state as extracted from the DOM
|
||||
*/
|
||||
export interface ParsedGameState {
|
||||
phase: GamePhase;
|
||||
currentRound: number;
|
||||
totalRounds: number;
|
||||
statusMessage: string;
|
||||
isFinalTurn: boolean;
|
||||
myPlayer: PlayerState | null;
|
||||
opponents: PlayerState[];
|
||||
deck: {
|
||||
clickable: boolean;
|
||||
};
|
||||
discard: {
|
||||
hasCard: boolean;
|
||||
clickable: boolean;
|
||||
pickedUp: boolean;
|
||||
topCard: { rank: string; suit: string } | null;
|
||||
};
|
||||
heldCard: {
|
||||
visible: boolean;
|
||||
card: { rank: string; suit: string } | null;
|
||||
};
|
||||
canDiscard: boolean;
|
||||
canSkipFlip: boolean;
|
||||
canKnockEarly: boolean;
|
||||
}
|
||||
|
||||
export type GamePhase =
|
||||
| 'lobby'
|
||||
| 'waiting'
|
||||
| 'initial_flip'
|
||||
| 'playing'
|
||||
| 'waiting_for_flip'
|
||||
| 'final_turn'
|
||||
| 'round_over'
|
||||
| 'game_over';
|
||||
|
||||
/**
|
||||
* Parses game state from the DOM
|
||||
* This allows visual validation - the DOM should reflect the internal game state
|
||||
*/
|
||||
export class StateParser {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
/**
|
||||
* Get the current screen/phase
|
||||
*/
|
||||
async getPhase(): Promise<GamePhase> {
|
||||
// Check which screen is active
|
||||
const lobbyVisible = await this.isVisible(SELECTORS.screens.lobby);
|
||||
if (lobbyVisible) return 'lobby';
|
||||
|
||||
const waitingVisible = await this.isVisible(SELECTORS.screens.waiting);
|
||||
if (waitingVisible) return 'waiting';
|
||||
|
||||
const gameVisible = await this.isVisible(SELECTORS.screens.game);
|
||||
if (!gameVisible) return 'lobby';
|
||||
|
||||
// We're in the game screen - determine game phase
|
||||
const statusText = await this.getStatusMessage();
|
||||
const gameButtons = await this.isVisible(SELECTORS.game.gameButtons);
|
||||
|
||||
// Check for game over - Final Results modal or "New Game" button visible
|
||||
const finalResultsModal = this.page.locator('#final-results-modal');
|
||||
if (await finalResultsModal.isVisible().catch(() => false)) {
|
||||
return 'game_over';
|
||||
}
|
||||
const newGameBtn = this.page.locator(SELECTORS.game.newGameBtn);
|
||||
if (await newGameBtn.isVisible().catch(() => false)) {
|
||||
return 'game_over';
|
||||
}
|
||||
|
||||
// Check for round over (Next Hole button visible)
|
||||
const nextRoundBtn = this.page.locator(SELECTORS.game.nextRoundBtn);
|
||||
if (await nextRoundBtn.isVisible().catch(() => false)) {
|
||||
// Check if this is the last round - if so, might be transitioning to game_over
|
||||
const currentRound = await this.getCurrentRound();
|
||||
const totalRounds = await this.getTotalRounds();
|
||||
|
||||
// If on last round and all cards revealed, this is effectively game_over
|
||||
if (currentRound >= totalRounds) {
|
||||
// Check the button text - if it doesn't mention "Next", might be game over
|
||||
const btnText = await nextRoundBtn.textContent().catch(() => '');
|
||||
if (btnText && !btnText.toLowerCase().includes('next')) {
|
||||
return 'game_over';
|
||||
}
|
||||
// Still round_over but will transition to game_over soon
|
||||
}
|
||||
return 'round_over';
|
||||
}
|
||||
|
||||
// Check for final turn badge
|
||||
const finalTurnBadge = this.page.locator(SELECTORS.game.finalTurnBadge);
|
||||
if (await finalTurnBadge.isVisible().catch(() => false)) {
|
||||
return 'final_turn';
|
||||
}
|
||||
|
||||
// Check if waiting for initial flip
|
||||
if (statusText.toLowerCase().includes('flip') &&
|
||||
statusText.toLowerCase().includes('card')) {
|
||||
// Could be initial flip or flip after discard
|
||||
const skipFlipBtn = this.page.locator(SELECTORS.game.skipFlipBtn);
|
||||
if (await skipFlipBtn.isVisible().catch(() => false)) {
|
||||
return 'waiting_for_flip';
|
||||
}
|
||||
|
||||
// Check if we're in initial flip phase (multiple cards to flip)
|
||||
const myCards = await this.getMyCards();
|
||||
const faceUpCount = myCards.filter(c => c.faceUp).length;
|
||||
if (faceUpCount < 2) {
|
||||
return 'initial_flip';
|
||||
}
|
||||
return 'waiting_for_flip';
|
||||
}
|
||||
|
||||
return 'playing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full parsed game state
|
||||
*/
|
||||
async getState(): Promise<ParsedGameState> {
|
||||
const phase = await this.getPhase();
|
||||
|
||||
return {
|
||||
phase,
|
||||
currentRound: await this.getCurrentRound(),
|
||||
totalRounds: await this.getTotalRounds(),
|
||||
statusMessage: await this.getStatusMessage(),
|
||||
isFinalTurn: await this.isFinalTurn(),
|
||||
myPlayer: await this.getMyPlayer(),
|
||||
opponents: await this.getOpponents(),
|
||||
deck: {
|
||||
clickable: await this.isDeckClickable(),
|
||||
},
|
||||
discard: {
|
||||
hasCard: await this.discardHasCard(),
|
||||
clickable: await this.isDiscardClickable(),
|
||||
pickedUp: await this.isDiscardPickedUp(),
|
||||
topCard: await this.getDiscardTop(),
|
||||
},
|
||||
heldCard: {
|
||||
visible: await this.isHeldCardVisible(),
|
||||
card: await this.getHeldCard(),
|
||||
},
|
||||
canDiscard: await this.isVisible(SELECTORS.game.discardBtn),
|
||||
canSkipFlip: await this.isVisible(SELECTORS.game.skipFlipBtn),
|
||||
canKnockEarly: await this.isVisible(SELECTORS.game.knockEarlyBtn),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current round number
|
||||
*/
|
||||
async getCurrentRound(): Promise<number> {
|
||||
const text = await this.getText(SELECTORS.game.currentRound);
|
||||
return parseInt(text) || 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total rounds
|
||||
*/
|
||||
async getTotalRounds(): Promise<number> {
|
||||
const text = await this.getText(SELECTORS.game.totalRounds);
|
||||
return parseInt(text) || 9;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status message text
|
||||
*/
|
||||
async getStatusMessage(): Promise<string> {
|
||||
return this.getText(SELECTORS.game.statusMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if final turn badge is visible
|
||||
*/
|
||||
async isFinalTurn(): Promise<boolean> {
|
||||
return this.isVisible(SELECTORS.game.finalTurnBadge);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local player's state
|
||||
*/
|
||||
async getMyPlayer(): Promise<PlayerState | null> {
|
||||
const playerArea = this.page.locator(SELECTORS.game.playerArea).first();
|
||||
if (!await playerArea.isVisible().catch(() => false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nameEl = playerArea.locator('.player-name');
|
||||
const name = await nameEl.textContent().catch(() => 'You') || 'You';
|
||||
|
||||
const scoreEl = playerArea.locator(SELECTORS.game.yourScore);
|
||||
const scoreText = await scoreEl.textContent().catch(() => '0') || '0';
|
||||
const score = parseInt(scoreText) || 0;
|
||||
|
||||
const cards = await this.getMyCards();
|
||||
const isCurrentTurn = await this.isMyTurn();
|
||||
|
||||
return { name, cards, isCurrentTurn, score };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cards for local player
|
||||
*/
|
||||
async getMyCards(): Promise<CardState[]> {
|
||||
const cards: CardState[] = [];
|
||||
const cardContainer = this.page.locator(SELECTORS.game.playerCards);
|
||||
|
||||
const cardEls = cardContainer.locator('.card, .card-slot .card');
|
||||
const count = await cardEls.count();
|
||||
|
||||
for (let i = 0; i < Math.min(count, 6); i++) {
|
||||
const cardEl = cardEls.nth(i);
|
||||
cards.push(await this.parseCard(cardEl, i));
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get opponent players' states
|
||||
*/
|
||||
async getOpponents(): Promise<PlayerState[]> {
|
||||
const opponents: PlayerState[] = [];
|
||||
const opponentAreas = this.page.locator('.opponent-area');
|
||||
const count = await opponentAreas.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const area = opponentAreas.nth(i);
|
||||
const nameEl = area.locator('.opponent-name');
|
||||
const name = await nameEl.textContent().catch(() => `Opponent ${i + 1}`) || `Opponent ${i + 1}`;
|
||||
|
||||
const scoreEl = area.locator('.opponent-showing');
|
||||
const scoreText = await scoreEl.textContent().catch(() => null);
|
||||
const score = scoreText ? parseInt(scoreText) : null;
|
||||
|
||||
const isCurrentTurn = await area.evaluate(el =>
|
||||
el.classList.contains('current-turn')
|
||||
);
|
||||
|
||||
const cards: CardState[] = [];
|
||||
const cardEls = area.locator('.card-grid .card');
|
||||
const cardCount = await cardEls.count();
|
||||
|
||||
for (let j = 0; j < Math.min(cardCount, 6); j++) {
|
||||
cards.push(await this.parseCard(cardEls.nth(j), j));
|
||||
}
|
||||
|
||||
opponents.push({ name, cards, isCurrentTurn, score });
|
||||
}
|
||||
|
||||
return opponents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single card element
|
||||
*/
|
||||
private async parseCard(cardEl: Locator, position: number): Promise<CardState> {
|
||||
const classList = await cardEl.evaluate(el => Array.from(el.classList));
|
||||
|
||||
// Face-down cards have 'card-back' class, face-up have 'card-front' class
|
||||
const faceUp = classList.includes('card-front');
|
||||
const clickable = classList.includes('clickable');
|
||||
const selected = classList.includes('selected');
|
||||
|
||||
let rank: string | null = null;
|
||||
let suit: string | null = null;
|
||||
|
||||
if (faceUp) {
|
||||
const content = await cardEl.textContent().catch(() => '') || '';
|
||||
|
||||
// Check for joker
|
||||
if (classList.includes('joker') || content.toLowerCase().includes('joker')) {
|
||||
rank = '★';
|
||||
// Determine suit from icon
|
||||
if (content.includes('🐉')) {
|
||||
suit = 'hearts';
|
||||
} else if (content.includes('👹')) {
|
||||
suit = 'spades';
|
||||
}
|
||||
} else {
|
||||
// Parse rank and suit from text
|
||||
const lines = content.split('\n').map(l => l.trim()).filter(l => l);
|
||||
if (lines.length >= 2) {
|
||||
rank = lines[0];
|
||||
suit = this.parseSuitSymbol(lines[1]);
|
||||
} else if (lines.length === 1) {
|
||||
// Try to extract rank from combined text
|
||||
const text = lines[0];
|
||||
const rankMatch = text.match(/^([AKQJ]|10|[2-9])/);
|
||||
if (rankMatch) {
|
||||
rank = rankMatch[1];
|
||||
const suitPart = text.slice(rank.length);
|
||||
suit = this.parseSuitSymbol(suitPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { position, faceUp, rank, suit, clickable, selected };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse suit symbol to suit name
|
||||
*/
|
||||
private parseSuitSymbol(symbol: string): string | null {
|
||||
const cleaned = symbol.trim();
|
||||
if (cleaned.includes('♥') || cleaned.includes('hearts')) return 'hearts';
|
||||
if (cleaned.includes('♦') || cleaned.includes('diamonds')) return 'diamonds';
|
||||
if (cleaned.includes('♣') || cleaned.includes('clubs')) return 'clubs';
|
||||
if (cleaned.includes('♠') || cleaned.includes('spades')) return 'spades';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if it's the local player's turn
|
||||
*/
|
||||
async isMyTurn(): Promise<boolean> {
|
||||
// Check if deck area has your-turn-to-draw class
|
||||
const deckArea = this.page.locator(SELECTORS.game.deckArea);
|
||||
const hasClass = await deckArea.evaluate(el =>
|
||||
el.classList.contains('your-turn-to-draw')
|
||||
).catch(() => false);
|
||||
|
||||
if (hasClass) return true;
|
||||
|
||||
// Check status message
|
||||
const status = await this.getStatusMessage();
|
||||
const statusLower = status.toLowerCase();
|
||||
|
||||
// Various indicators that it's our turn
|
||||
if (statusLower.includes('your turn')) return true;
|
||||
if (statusLower.includes('select') && statusLower.includes('card')) return true; // Initial flip
|
||||
if (statusLower.includes('flip a card')) return true;
|
||||
if (statusLower.includes('choose a card')) return true;
|
||||
|
||||
// Check if our cards are clickable (another indicator)
|
||||
const clickableCards = await this.getClickablePositions();
|
||||
if (clickableCards.length > 0) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if deck is clickable
|
||||
*/
|
||||
async isDeckClickable(): Promise<boolean> {
|
||||
const deck = this.page.locator(SELECTORS.game.deck);
|
||||
return deck.evaluate(el => el.classList.contains('clickable')).catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if discard pile has a card
|
||||
*/
|
||||
async discardHasCard(): Promise<boolean> {
|
||||
const discard = this.page.locator(SELECTORS.game.discard);
|
||||
return discard.evaluate(el =>
|
||||
el.classList.contains('has-card') || el.classList.contains('card-front')
|
||||
).catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if discard is clickable
|
||||
*/
|
||||
async isDiscardClickable(): Promise<boolean> {
|
||||
const discard = this.page.locator(SELECTORS.game.discard);
|
||||
return discard.evaluate(el =>
|
||||
el.classList.contains('clickable') && !el.classList.contains('disabled')
|
||||
).catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if discard card is picked up (floating)
|
||||
*/
|
||||
async isDiscardPickedUp(): Promise<boolean> {
|
||||
const discard = this.page.locator(SELECTORS.game.discard);
|
||||
return discard.evaluate(el => el.classList.contains('picked-up')).catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the top card of the discard pile
|
||||
*/
|
||||
async getDiscardTop(): Promise<{ rank: string; suit: string } | null> {
|
||||
const hasCard = await this.discardHasCard();
|
||||
if (!hasCard) return null;
|
||||
|
||||
const content = await this.page.locator(SELECTORS.game.discardContent).textContent()
|
||||
.catch(() => null);
|
||||
if (!content) return null;
|
||||
|
||||
return this.parseCardContent(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if held card is visible
|
||||
*/
|
||||
async isHeldCardVisible(): Promise<boolean> {
|
||||
const floating = this.page.locator(SELECTORS.game.heldCardFloating);
|
||||
return floating.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get held card details
|
||||
*/
|
||||
async getHeldCard(): Promise<{ rank: string; suit: string } | null> {
|
||||
const visible = await this.isHeldCardVisible();
|
||||
if (!visible) return null;
|
||||
|
||||
const content = await this.page.locator(SELECTORS.game.heldCardFloatingContent)
|
||||
.textContent().catch(() => null);
|
||||
if (!content) return null;
|
||||
|
||||
return this.parseCardContent(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse card content text (from held card, discard, etc.)
|
||||
*/
|
||||
private parseCardContent(content: string): { rank: string; suit: string } | null {
|
||||
// Handle jokers
|
||||
if (content.toLowerCase().includes('joker')) {
|
||||
const suit = content.includes('🐉') ? 'hearts' : 'spades';
|
||||
return { rank: '★', suit };
|
||||
}
|
||||
|
||||
// Try to parse rank and suit
|
||||
// Content may be "7\n♥" (with newline) or "7♥" (combined)
|
||||
const lines = content.split('\n').map(l => l.trim()).filter(l => l);
|
||||
|
||||
if (lines.length >= 2) {
|
||||
// Two separate lines
|
||||
return {
|
||||
rank: lines[0],
|
||||
suit: this.parseSuitSymbol(lines[1]) || 'unknown',
|
||||
};
|
||||
} else if (lines.length === 1) {
|
||||
const text = lines[0];
|
||||
// Try to extract rank (A, K, Q, J, 10, or 2-9)
|
||||
const rankMatch = text.match(/^(10|[AKQJ2-9])/);
|
||||
if (rankMatch) {
|
||||
const rank = rankMatch[1];
|
||||
const suitPart = text.slice(rank.length);
|
||||
const suit = this.parseSuitSymbol(suitPart);
|
||||
if (suit) {
|
||||
return { rank, suit };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count face-up cards for local player
|
||||
*/
|
||||
async countFaceUpCards(): Promise<number> {
|
||||
const cards = await this.getMyCards();
|
||||
return cards.filter(c => c.faceUp).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count face-down cards for local player
|
||||
*/
|
||||
async countFaceDownCards(): Promise<number> {
|
||||
const cards = await this.getMyCards();
|
||||
return cards.filter(c => !c.faceUp).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get positions of clickable cards
|
||||
*/
|
||||
async getClickablePositions(): Promise<number[]> {
|
||||
const cards = await this.getMyCards();
|
||||
return cards.filter(c => c.clickable).map(c => c.position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get positions of face-down cards
|
||||
*/
|
||||
async getFaceDownPositions(): Promise<number[]> {
|
||||
const cards = await this.getMyCards();
|
||||
return cards.filter(c => !c.faceUp).map(c => c.position);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private async isVisible(selector: string): Promise<boolean> {
|
||||
const el = this.page.locator(selector);
|
||||
return el.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
private async getText(selector: string): Promise<string> {
|
||||
const el = this.page.locator(selector);
|
||||
return (await el.textContent().catch(() => '')) || '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user