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:
Aaron D. Lee 2026-01-29 18:33:28 -05:00
parent 724bf87c43
commit 6950769bc3
29 changed files with 5153 additions and 348 deletions

View File

@ -12,12 +12,15 @@ class AnimationQueue {
this.animationInProgress = false; this.animationInProgress = false;
// Timing configuration (ms) // Timing configuration (ms)
// Rhythm: action → settle → action → breathe
this.timing = { this.timing = {
flipDuration: 400, flipDuration: 540, // Must match CSS .card-inner transition (0.54s)
moveDuration: 300, moveDuration: 270,
pauseAfterMove: 200, pauseAfterFlip: 144, // Brief settle after flip before move
pauseAfterFlip: 100, pauseAfterDiscard: 550, // Let discard land + pulse (400ms) + settle
pauseBetweenAnimations: 100 pauseBeforeNewCard: 150, // Anticipation before new card moves in
pauseAfterSwapComplete: 400, // Breathing room after swap completes
pauseBetweenAnimations: 90
}; };
} }
@ -159,21 +162,17 @@ class AnimationQueue {
inner.classList.add('flipped'); inner.classList.add('flipped');
// Step 1: If card was face down, flip to reveal it // Step 1: If card was face down, flip to reveal it
if (!oldCard.face_up) {
// Set up the front with the old card content (what we're discarding)
this.setCardFront(front, oldCard); this.setCardFront(front, oldCard);
if (!oldCard.face_up) {
this.playSound('flip'); this.playSound('flip');
inner.classList.remove('flipped'); inner.classList.remove('flipped');
await this.delay(this.timing.flipDuration); await this.delay(this.timing.flipDuration);
await this.delay(this.timing.pauseAfterFlip);
} else { } else {
// Already face up, just show it // Already face up, just show it immediately
this.setCardFront(front, oldCard);
inner.classList.remove('flipped'); inner.classList.remove('flipped');
} }
await this.delay(100);
// Step 2: Move card to discard pile // Step 2: Move card to discard pile
this.playSound('card'); this.playSound('card');
animCard.classList.add('moving'); animCard.classList.add('moving');
@ -181,8 +180,8 @@ class AnimationQueue {
await this.delay(this.timing.moveDuration); await this.delay(this.timing.moveDuration);
animCard.classList.remove('moving'); animCard.classList.remove('moving');
// Pause to show the card landing on discard // Let discard land and pulse settle
await this.delay(this.timing.pauseAfterMove + 200); await this.delay(this.timing.pauseAfterDiscard);
// Step 3: Create second card for the new card coming into hand // Step 3: Create second card for the new card coming into hand
const newAnimCard = this.createAnimCard(); const newAnimCard = this.createAnimCard();
@ -197,6 +196,9 @@ class AnimationQueue {
this.setCardFront(newFront, newCard); this.setCardFront(newFront, newCard);
newInner.classList.remove('flipped'); newInner.classList.remove('flipped');
// Brief anticipation before new card moves
await this.delay(this.timing.pauseBeforeNewCard);
// Step 4: Move new card to the hand slot // Step 4: Move new card to the hand slot
this.playSound('card'); this.playSound('card');
newAnimCard.classList.add('moving'); newAnimCard.classList.add('moving');
@ -204,8 +206,8 @@ class AnimationQueue {
await this.delay(this.timing.moveDuration); await this.delay(this.timing.moveDuration);
newAnimCard.classList.remove('moving'); newAnimCard.classList.remove('moving');
// Clean up animation cards // Breathing room after swap completes
await this.delay(this.timing.pauseAfterMove); await this.delay(this.timing.pauseAfterSwapComplete);
animCard.remove(); animCard.remove();
newAnimCard.remove(); newAnimCard.remove();
} }
@ -297,7 +299,8 @@ class AnimationQueue {
await this.delay(this.timing.moveDuration); await this.delay(this.timing.moveDuration);
animCard.classList.remove('moving'); animCard.classList.remove('moving');
await this.delay(this.timing.pauseAfterMove); // Same timing as player swap - let discard land and pulse settle
await this.delay(this.timing.pauseAfterDiscard);
// Clean up // Clean up
animCard.remove(); animCard.remove();
@ -322,17 +325,13 @@ class AnimationQueue {
// Move to holding position // Move to holding position
this.playSound('card'); this.playSound('card');
await this.delay(50);
animCard.classList.add('moving'); animCard.classList.add('moving');
this.setCardPosition(animCard, holdingRect); this.setCardPosition(animCard, holdingRect);
await this.delay(this.timing.moveDuration); await this.delay(this.timing.moveDuration);
animCard.classList.remove('moving'); animCard.classList.remove('moving');
// The card stays face down until the player decides what to do // Brief settle before state updates
// (the actual card reveal happens when server sends card_drawn) await this.delay(this.timing.pauseBeforeNewCard);
await this.delay(this.timing.pauseAfterMove);
// Clean up - renderGame will show the holding card state // Clean up - renderGame will show the holding card state
animCard.remove(); animCard.remove();

View File

@ -8,6 +8,7 @@ class GolfGame {
this.isHost = false; this.isHost = false;
this.gameState = null; this.gameState = null;
this.drawnCard = null; this.drawnCard = null;
this.drawnFromDiscard = false;
this.selectedCards = []; this.selectedCards = [];
this.waitingForFlip = false; this.waitingForFlip = false;
this.currentPlayers = []; this.currentPlayers = [];
@ -189,6 +190,7 @@ class GolfGame {
this.leaveRoomBtn = document.getElementById('leave-room-btn'); this.leaveRoomBtn = document.getElementById('leave-room-btn');
this.addCpuBtn = document.getElementById('add-cpu-btn'); this.addCpuBtn = document.getElementById('add-cpu-btn');
this.removeCpuBtn = document.getElementById('remove-cpu-btn'); this.removeCpuBtn = document.getElementById('remove-cpu-btn');
this.cpuControlsSection = document.getElementById('cpu-controls-section');
this.cpuSelectModal = document.getElementById('cpu-select-modal'); this.cpuSelectModal = document.getElementById('cpu-select-modal');
this.cpuProfilesGrid = document.getElementById('cpu-profiles-grid'); this.cpuProfilesGrid = document.getElementById('cpu-profiles-grid');
this.cancelCpuBtn = document.getElementById('cancel-cpu-btn'); this.cancelCpuBtn = document.getElementById('cancel-cpu-btn');
@ -202,17 +204,22 @@ class GolfGame {
this.yourScore = document.getElementById('your-score'); this.yourScore = document.getElementById('your-score');
this.muteBtn = document.getElementById('mute-btn'); this.muteBtn = document.getElementById('mute-btn');
this.opponentsRow = document.getElementById('opponents-row'); this.opponentsRow = document.getElementById('opponents-row');
this.deckArea = document.querySelector('.deck-area');
this.deck = document.getElementById('deck'); this.deck = document.getElementById('deck');
this.discard = document.getElementById('discard'); this.discard = document.getElementById('discard');
this.discardContent = document.getElementById('discard-content'); this.discardContent = document.getElementById('discard-content');
this.discardBtn = document.getElementById('discard-btn'); this.discardBtn = document.getElementById('discard-btn');
this.cancelDrawBtn = document.getElementById('cancel-draw-btn');
this.skipFlipBtn = document.getElementById('skip-flip-btn'); this.skipFlipBtn = document.getElementById('skip-flip-btn');
this.knockEarlyBtn = document.getElementById('knock-early-btn'); this.knockEarlyBtn = document.getElementById('knock-early-btn');
this.playerCards = document.getElementById('player-cards'); this.playerCards = document.getElementById('player-cards');
this.playerArea = this.playerCards.closest('.player-area'); this.playerArea = this.playerCards.closest('.player-area');
this.swapAnimation = document.getElementById('swap-animation'); this.swapAnimation = document.getElementById('swap-animation');
this.swapCardFromHand = document.getElementById('swap-card-from-hand'); this.swapCardFromHand = document.getElementById('swap-card-from-hand');
this.heldCardSlot = document.getElementById('held-card-slot');
this.heldCardDisplay = document.getElementById('held-card-display');
this.heldCardContent = document.getElementById('held-card-content');
this.heldCardFloating = document.getElementById('held-card-floating');
this.heldCardFloatingContent = document.getElementById('held-card-floating-content');
this.scoreboard = document.getElementById('scoreboard'); this.scoreboard = document.getElementById('scoreboard');
this.scoreTable = document.getElementById('score-table').querySelector('tbody'); this.scoreTable = document.getElementById('score-table').querySelector('tbody');
this.standingsList = document.getElementById('standings-list'); this.standingsList = document.getElementById('standings-list');
@ -223,6 +230,11 @@ class GolfGame {
this.activeRulesBar = document.getElementById('active-rules-bar'); this.activeRulesBar = document.getElementById('active-rules-bar');
this.activeRulesList = document.getElementById('active-rules-list'); this.activeRulesList = document.getElementById('active-rules-list');
this.finalTurnBadge = document.getElementById('final-turn-badge'); this.finalTurnBadge = document.getElementById('final-turn-badge');
// In-game auth elements
this.gameUsername = document.getElementById('game-username');
this.gameLogoutBtn = document.getElementById('game-logout-btn');
this.authBar = document.getElementById('auth-bar');
} }
bindEvents() { bindEvents() {
@ -233,7 +245,6 @@ class GolfGame {
this.deck.addEventListener('click', () => { this.drawFromDeck(); }); this.deck.addEventListener('click', () => { this.drawFromDeck(); });
this.discard.addEventListener('click', () => { this.drawFromDiscard(); }); this.discard.addEventListener('click', () => { this.drawFromDiscard(); });
this.discardBtn.addEventListener('click', () => { this.playSound('card'); this.discardDrawn(); }); this.discardBtn.addEventListener('click', () => { this.playSound('card'); this.discardDrawn(); });
this.cancelDrawBtn.addEventListener('click', () => { this.playSound('click'); this.cancelDraw(); });
this.skipFlipBtn.addEventListener('click', () => { this.playSound('click'); this.skipFlip(); }); this.skipFlipBtn.addEventListener('click', () => { this.playSound('click'); this.skipFlip(); });
this.knockEarlyBtn.addEventListener('click', () => { this.playSound('success'); this.knockEarly(); }); this.knockEarlyBtn.addEventListener('click', () => { this.playSound('success'); this.knockEarly(); });
this.nextRoundBtn.addEventListener('click', () => { this.playSound('click'); this.nextRound(); }); this.nextRoundBtn.addEventListener('click', () => { this.playSound('click'); this.nextRound(); });
@ -244,6 +255,7 @@ class GolfGame {
this.addSelectedCpusBtn.addEventListener('click', () => { this.playSound('success'); this.addSelectedCpus(); }); this.addSelectedCpusBtn.addEventListener('click', () => { this.playSound('success'); this.addSelectedCpus(); });
this.muteBtn.addEventListener('click', () => this.toggleSound()); this.muteBtn.addEventListener('click', () => this.toggleSound());
this.leaveGameBtn.addEventListener('click', () => { this.playSound('click'); this.leaveGame(); }); this.leaveGameBtn.addEventListener('click', () => { this.playSound('click'); this.leaveGame(); });
this.gameLogoutBtn.addEventListener('click', () => { this.playSound('click'); this.auth?.logout(); });
// Copy room code to clipboard // Copy room code to clipboard
this.copyRoomCodeBtn.addEventListener('click', () => { this.copyRoomCodeBtn.addEventListener('click', () => {
@ -401,8 +413,9 @@ class GolfGame {
this.gameState = data.game_state; this.gameState = data.game_state;
// Deep copy for previousState to avoid reference issues // Deep copy for previousState to avoid reference issues
this.previousState = JSON.parse(JSON.stringify(data.game_state)); this.previousState = JSON.parse(JSON.stringify(data.game_state));
// Reset tracking for new round // Reset all tracking for new round
this.locallyFlippedCards = new Set(); this.locallyFlippedCards = new Set();
this.selectedCards = [];
this.animatingPositions = new Set(); this.animatingPositions = new Set();
this.playSound('shuffle'); this.playSound('shuffle');
this.showGameScreen(); this.showGameScreen();
@ -443,6 +456,8 @@ class GolfGame {
break; break;
case 'your_turn': case 'your_turn':
// Brief delay to let animations settle
setTimeout(() => {
// Build toast based on available actions // Build toast based on available actions
const canFlip = this.gameState && this.gameState.flip_as_action; const canFlip = this.gameState && this.gameState.flip_as_action;
let canKnock = false; let canKnock = false;
@ -460,11 +475,14 @@ class GolfGame {
} else { } else {
this.showToast('Your turn! Draw a card', 'your-turn'); this.showToast('Your turn! Draw a card', 'your-turn');
} }
}, 200);
break; break;
case 'card_drawn': case 'card_drawn':
this.drawnCard = data.card; this.drawnCard = data.card;
this.drawnFromDiscard = data.source === 'discard';
this.showDrawnCard(); this.showDrawnCard();
this.renderGame(); // Re-render to update discard pile
this.showToast('Swap with a card or discard', '', 3000); this.showToast('Swap with a card or discard', '', 3000);
break; break;
@ -750,6 +768,13 @@ class GolfGame {
} }
drawFromDiscard() { drawFromDiscard() {
// If holding a card drawn from discard, clicking discard puts it back
if (this.drawnCard && !this.gameState.can_discard) {
this.playSound('click');
this.cancelDraw();
return;
}
if (!this.isMyTurn() || this.drawnCard || this.gameState.has_drawn_card) { if (!this.isMyTurn() || this.drawnCard || this.gameState.has_drawn_card) {
if (this.gameState && !this.gameState.waiting_for_initial_flip) { if (this.gameState && !this.gameState.waiting_for_initial_flip) {
this.playSound('reject'); this.playSound('reject');
@ -764,10 +789,98 @@ class GolfGame {
discardDrawn() { discardDrawn() {
if (!this.drawnCard) return; if (!this.drawnCard) return;
const discardedCard = this.drawnCard;
const wasFromDeck = !this.drawnFromDiscard;
this.send({ type: 'discard' }); this.send({ type: 'discard' });
this.drawnCard = null; this.drawnCard = null;
this.hideDrawnCard();
this.hideToast(); this.hideToast();
this.discardBtn.classList.add('hidden');
// Pre-emptively skip the flip animation - the server may broadcast the new state
// before our animation completes, and we don't want renderGame() to trigger
// the flip-in animation (which starts with opacity: 0, causing a flash)
this.skipNextDiscardFlip = true;
// Also update lastDiscardKey so renderGame() won't see a "change"
this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`;
if (wasFromDeck) {
// Swoop animation: deck → discard (makes it clear the card is being tossed)
this.animateDeckToDiscardSwoop(discardedCard);
} else {
// Simple drop (drawn from discard, putting it back - though this requires swap usually)
this.heldCardFloating.classList.add('dropping');
this.playSound('card');
setTimeout(() => {
this.heldCardFloating.classList.add('hidden');
this.heldCardFloating.classList.remove('dropping');
this.updateDiscardPileDisplay(discardedCard);
this.pulseDiscardLand();
this.skipNextDiscardFlip = true;
}, 250);
}
}
// Swoop animation for discarding a card drawn from deck
animateDeckToDiscardSwoop(card) {
const deckRect = this.deck.getBoundingClientRect();
const discardRect = this.discard.getBoundingClientRect();
const floater = this.heldCardFloating;
// Reset any previous animation state
floater.classList.remove('dropping', 'swooping', 'landed');
// Instantly position at deck (card appears to come from deck)
floater.style.transition = 'none';
floater.style.left = `${deckRect.left}px`;
floater.style.top = `${deckRect.top}px`;
floater.style.width = `${deckRect.width}px`;
floater.style.height = `${deckRect.height}px`;
floater.style.transform = 'scale(1) rotate(0deg)';
// Force reflow
floater.offsetHeight;
// Start swoop to discard
floater.style.transition = '';
floater.classList.add('swooping');
floater.style.left = `${discardRect.left}px`;
floater.style.top = `${discardRect.top}px`;
floater.style.width = `${discardRect.width}px`;
floater.style.height = `${discardRect.height}px`;
this.playSound('card');
// After swoop completes, settle and show on discard pile
setTimeout(() => {
floater.classList.add('landed');
setTimeout(() => {
floater.classList.add('hidden');
floater.classList.remove('swooping', 'landed');
// Clear all inline styles from the animation
floater.style.cssText = '';
this.updateDiscardPileDisplay(card);
this.pulseDiscardLand();
this.skipNextDiscardFlip = true;
}, 150); // Brief settle
}, 350); // Match swoop transition duration
}
// Update the discard pile display with a card
updateDiscardPileDisplay(card) {
this.discard.classList.remove('picked-up', 'disabled');
this.discard.classList.add('has-card', 'card-front');
this.discard.classList.remove('red', 'black', 'joker');
if (card.rank === '★') {
this.discard.classList.add('joker');
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
this.discardContent.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
} else {
this.discard.classList.add(card.suit === 'hearts' || card.suit === 'diamonds' ? 'red' : 'black');
this.discardContent.innerHTML = this.renderCardContent(card);
}
this.lastDiscardKey = `${card.rank}-${card.suit}`;
} }
cancelDraw() { cancelDraw() {
@ -786,6 +899,7 @@ class GolfGame {
} }
// Animate player swapping drawn card with a card in their hand // Animate player swapping drawn card with a card in their hand
// Uses flip-in-place + teleport (no zipping movement)
animateSwap(position) { animateSwap(position) {
const cardElements = this.playerCards.querySelectorAll('.card'); const cardElements = this.playerCards.querySelectorAll('.card');
const handCardEl = cardElements[position]; const handCardEl = cardElements[position];
@ -801,12 +915,16 @@ class GolfGame {
// Get positions // Get positions
const handRect = handCardEl.getBoundingClientRect(); const handRect = handCardEl.getBoundingClientRect();
const discardRect = this.discard.getBoundingClientRect();
// Set up the animated card at hand position // Set up the animated card at hand position
const swapCard = this.swapCardFromHand; const swapCard = this.swapCardFromHand;
if (!swapCard) {
// Animation element missing - fall back to non-animated swap
console.error('Swap animation element missing, falling back to direct swap');
this.swapCard(position);
return;
}
const swapCardFront = swapCard.querySelector('.swap-card-front'); const swapCardFront = swapCard.querySelector('.swap-card-front');
const swapCardInner = swapCard.querySelector('.swap-card-inner');
// Position at the hand card location // Position at the hand card location
swapCard.style.left = handRect.left + 'px'; swapCard.style.left = handRect.left + 'px';
@ -814,97 +932,67 @@ class GolfGame {
swapCard.style.width = handRect.width + 'px'; swapCard.style.width = handRect.width + 'px';
swapCard.style.height = handRect.height + 'px'; swapCard.style.height = handRect.height + 'px';
// Reset state // Reset state - no moving class needed
swapCard.classList.remove('flipping', 'moving'); swapCard.classList.remove('flipping', 'moving');
swapCardFront.innerHTML = ''; swapCardFront.innerHTML = '';
swapCardFront.className = 'swap-card-front'; swapCardFront.className = 'swap-card-front';
if (isAlreadyFaceUp && card) {
// FACE-UP CARD: Show card content immediately, then slide to discard
if (card.rank === '★') {
swapCardFront.classList.add('joker');
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
swapCardFront.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
} else {
swapCardFront.classList.add(card.suit === 'hearts' || card.suit === 'diamonds' ? 'red' : 'black');
const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[card.suit];
swapCardFront.innerHTML = `${card.rank}<br>${suitSymbol}`;
}
swapCard.classList.add('flipping'); // Show front immediately
// Hide the actual hand card and discard
handCardEl.classList.add('swap-out');
this.discard.classList.add('swap-to-hand');
this.swapAnimation.classList.remove('hidden');
// Mark animating // Mark animating
this.swapAnimationInProgress = true; this.swapAnimationInProgress = true;
this.swapAnimationCardEl = handCardEl; this.swapAnimationCardEl = handCardEl;
this.swapAnimationHandCardEl = handCardEl;
if (isAlreadyFaceUp && card) {
// FACE-UP CARD: Subtle pulse animation (no flip needed)
this.swapAnimationContentSet = true; this.swapAnimationContentSet = true;
// Send swap // Apply subtle swap pulse to both cards
handCardEl.classList.add('swap-pulse');
this.heldCardFloating.classList.add('swap-pulse');
// Play a soft sound for the swap
this.playSound('card');
// Send swap and let render handle the update
this.send({ type: 'swap', position }); this.send({ type: 'swap', position });
this.drawnCard = null; this.drawnCard = null;
this.skipNextDiscardFlip = true;
// Slide to discard // Complete after pulse animation
setTimeout(() => { setTimeout(() => {
swapCard.classList.add('moving'); handCardEl.classList.remove('swap-pulse');
swapCard.style.left = discardRect.left + 'px'; this.heldCardFloating.classList.remove('swap-pulse');
swapCard.style.top = discardRect.top + 'px'; this.completeSwapAnimation(null);
}, 50); }, 440);
// Complete
setTimeout(() => {
this.swapAnimation.classList.add('hidden');
swapCard.classList.remove('flipping', 'moving');
handCardEl.classList.remove('swap-out');
this.discard.classList.remove('swap-to-hand');
this.swapAnimationInProgress = false;
this.hideDrawnCard();
if (this.pendingGameState) {
this.gameState = this.pendingGameState;
this.pendingGameState = null;
this.renderGame();
}
}, 500);
} else { } else {
// FACE-DOWN CARD: Just slide card-back to discard (no flip mid-air) // FACE-DOWN CARD: Flip in place to reveal, then teleport
// The new card will appear instantly when state updates
// Don't use overlay for face-down - just send swap and let state handle it // Hide the actual hand card
// This avoids the clunky "flip to empty front" issue handCardEl.classList.add('swap-out');
this.swapAnimationInProgress = true; this.swapAnimation.classList.remove('hidden');
this.swapAnimationCardEl = handCardEl;
// Store references for updateSwapAnimation
this.swapAnimationFront = swapCardFront;
this.swapAnimationCard = swapCard;
this.swapAnimationContentSet = false; this.swapAnimationContentSet = false;
// Send swap // Send swap - the flip will happen in updateSwapAnimation when server responds
this.send({ type: 'swap', position }); this.send({ type: 'swap', position });
this.drawnCard = null; this.drawnCard = null;
this.skipNextDiscardFlip = true;
// Brief visual feedback - hide drawn card area
this.discard.classList.add('swap-to-hand');
handCardEl.classList.add('swap-out');
// Short timeout then let state update handle it
setTimeout(() => {
this.discard.classList.remove('swap-to-hand');
handCardEl.classList.remove('swap-out');
this.swapAnimationInProgress = false;
this.hideDrawnCard();
if (this.pendingGameState) {
this.gameState = this.pendingGameState;
this.pendingGameState = null;
this.renderGame();
}
}, 300);
} }
} }
// Update the animated card with actual card content when server responds // Update the animated card with actual card content when server responds
updateSwapAnimation(card) { updateSwapAnimation(card) {
if (!this.swapAnimationFront || !card) return; // Safety: if animation references are missing, complete immediately to avoid freeze
if (!this.swapAnimationFront || !card) {
if (this.swapAnimationInProgress && !this.swapAnimationContentSet) {
console.error('Swap animation incomplete: missing front element or card data');
this.completeSwapAnimation(null);
}
return;
}
// Skip if we already set the content (face-up card swap) // Skip if we already set the content (face-up card swap)
if (this.swapAnimationContentSet) return; if (this.swapAnimationContentSet) return;
@ -923,6 +1011,61 @@ class GolfGame {
} }
this.swapAnimationFront.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`; this.swapAnimationFront.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`;
} }
this.swapAnimationContentSet = true;
// Quick flip to reveal, then complete - server will pause before next turn
if (this.swapAnimationCard) {
// Step 1: Flip to reveal (quick)
this.swapAnimationCard.classList.add('flipping');
this.playSound('flip');
// Step 2: Brief pulse after flip completes
setTimeout(() => {
this.swapAnimationCard.classList.add('swap-pulse');
this.playSound('card');
}, 350);
// Step 3: Complete animation - the pause to see the result happens
// on the server side before the next CPU turn starts
setTimeout(() => {
this.completeSwapAnimation(null);
}, 550);
} else {
// Fallback: animation element missing, complete immediately to avoid freeze
console.error('Swap animation element missing, completing immediately');
this.completeSwapAnimation(null);
}
}
completeSwapAnimation(heldCard) {
// Hide everything
this.swapAnimation.classList.add('hidden');
if (this.swapAnimationCard) {
this.swapAnimationCard.classList.remove('hidden', 'flipping', 'moving', 'swap-pulse');
}
if (heldCard) {
heldCard.classList.remove('flipping', 'moving');
heldCard.classList.add('hidden');
}
if (this.swapAnimationHandCardEl) {
this.swapAnimationHandCardEl.classList.remove('swap-out');
}
this.discard.classList.remove('swap-to-hand');
this.swapAnimationInProgress = false;
this.swapAnimationFront = null;
this.swapAnimationCard = null;
this.swapAnimationDiscardRect = null;
this.swapAnimationHandCardEl = null;
this.swapAnimationHandRect = null;
this.discardBtn.classList.add('hidden');
this.heldCardFloating.classList.add('hidden');
if (this.pendingGameState) {
this.gameState = this.pendingGameState;
this.pendingGameState = null;
this.renderGame();
}
} }
flipCard(position) { flipCard(position) {
@ -987,7 +1130,7 @@ class GolfGame {
} }
} }
if (discardChanged && wasOtherPlayer) { if (discardChanged) {
// Check if the previous player actually SWAPPED (has a new face-up card) // Check if the previous player actually SWAPPED (has a new face-up card)
// vs just discarding the drawn card (no hand change) // vs just discarding the drawn card (no hand change)
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId); const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
@ -998,34 +1141,38 @@ class GolfGame {
// Could be: face-down -> face-up (new reveal) // Could be: face-down -> face-up (new reveal)
// Or: different card at same position (replaced visible card) // Or: different card at same position (replaced visible card)
let swappedPosition = -1; let swappedPosition = -1;
let wasFaceUp = false; // Track if old card was already face-up
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {
const oldCard = oldPlayer.cards[i]; const oldCard = oldPlayer.cards[i];
const newCard = newPlayer.cards[i]; const newCard = newPlayer.cards[i];
const wasUp = oldCard?.face_up; const wasUp = oldCard?.face_up;
const isUp = newCard?.face_up; const isUp = newCard?.face_up;
// Case 1: face-down became face-up // Case 1: face-down became face-up (needs flip)
if (!wasUp && isUp) { if (!wasUp && isUp) {
swappedPosition = i; swappedPosition = i;
wasFaceUp = false;
break; break;
} }
// Case 2: both face-up but different card (rank or suit changed) // Case 2: both face-up but different card (no flip needed)
if (wasUp && isUp && oldCard.rank && newCard.rank) { if (wasUp && isUp && oldCard.rank && newCard.rank) {
if (oldCard.rank !== newCard.rank || oldCard.suit !== newCard.suit) { if (oldCard.rank !== newCard.rank || oldCard.suit !== newCard.suit) {
swappedPosition = i; swappedPosition = i;
wasFaceUp = true; // Face-to-face swap
break; break;
} }
} }
} }
if (swappedPosition >= 0) { if (swappedPosition >= 0 && wasOtherPlayer) {
// Player swapped - animate from the actual position that changed // Opponent swapped - animate from the actual position that changed
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition); this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
} else { } else if (swappedPosition < 0) {
// Player drew and discarded without swapping // Player drew and discarded without swapping - pulse for everyone
// Animate card going from deck area to discard
this.fireDiscardAnimation(newDiscard); this.fireDiscardAnimation(newDiscard);
} }
// Skip the card-flip-in animation since we just did our own
this.skipNextDiscardFlip = true;
} }
} }
@ -1041,54 +1188,21 @@ class GolfGame {
void pile.offsetWidth; void pile.offsetWidth;
pile.classList.add('draw-pulse'); pile.classList.add('draw-pulse');
// Remove class after animation completes // Remove class after animation completes
setTimeout(() => pile.classList.remove('draw-pulse'), 400); setTimeout(() => pile.classList.remove('draw-pulse'), 450);
} }
// Fire animation for discard without swap (card goes deck -> discard) // Pulse discard pile when a card lands on it
pulseDiscardLand() {
this.discard.classList.remove('discard-land');
void this.discard.offsetWidth;
this.discard.classList.add('discard-land');
setTimeout(() => this.discard.classList.remove('discard-land'), 460);
}
// Fire animation for discard without swap (card lands on discard pile face-up)
fireDiscardAnimation(discardCard) { fireDiscardAnimation(discardCard) {
const deckRect = this.deck.getBoundingClientRect(); // Card is already known - just pulse to show it landed (no flip needed)
const discardRect = this.discard.getBoundingClientRect(); this.pulseDiscardLand();
const swapCard = this.swapCardFromHand;
const swapCardFront = swapCard.querySelector('.swap-card-front');
// Start at deck position
swapCard.style.left = deckRect.left + 'px';
swapCard.style.top = deckRect.top + 'px';
swapCard.style.width = deckRect.width + 'px';
swapCard.style.height = deckRect.height + 'px';
swapCard.classList.remove('flipping', 'moving');
// Set card content
swapCardFront.className = 'swap-card-front';
if (discardCard.rank === '★') {
swapCardFront.classList.add('joker');
const jokerIcon = discardCard.suit === 'hearts' ? '🐉' : '👹';
swapCardFront.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
} else {
swapCardFront.classList.add(discardCard.suit === 'hearts' || discardCard.suit === 'diamonds' ? 'red' : 'black');
swapCardFront.innerHTML = `${discardCard.rank}<br>${this.getSuitSymbol(discardCard.suit)}`;
}
this.swapAnimation.classList.remove('hidden');
// Flip to reveal card
setTimeout(() => {
swapCard.classList.add('flipping');
this.playSound('flip');
}, 50);
// Move to discard
setTimeout(() => {
swapCard.classList.add('moving');
swapCard.style.left = discardRect.left + 'px';
swapCard.style.top = discardRect.top + 'px';
}, 400);
// Complete
setTimeout(() => {
this.swapAnimation.classList.add('hidden');
swapCard.classList.remove('flipping', 'moving');
}, 800);
} }
// Get rotation angle from an element's computed transform // Get rotation angle from an element's computed transform
@ -1108,8 +1222,9 @@ class GolfGame {
return 0; return 0;
} }
// Fire a swap animation (non-blocking) // Fire a swap animation (non-blocking) - flip in place at opponent's position
fireSwapAnimation(playerId, discardCard, position) { // Uses flip-in-place for face-down cards, subtle pulse for face-up cards
fireSwapAnimation(playerId, discardCard, position, wasFaceUp = false) {
// Find source position - the actual card that was swapped // Find source position - the actual card that was swapped
const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area'); const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area');
@ -1132,26 +1247,37 @@ class GolfGame {
} }
} }
if (!sourceRect) { // Face-to-face swap: use subtle pulse on the card, no flip needed
const discardRect = this.discard.getBoundingClientRect(); if (wasFaceUp && sourceCardEl) {
sourceRect = { left: discardRect.left, top: discardRect.top - 100, width: discardRect.width, height: discardRect.height }; sourceCardEl.classList.add('swap-pulse');
this.playSound('card');
setTimeout(() => {
sourceCardEl.classList.remove('swap-pulse');
}, 400);
return;
} }
// Face-down to face-up: flip to reveal, pause to see it, then pulse before swap
if (!sourceRect) {
// Fallback: just show flip at discard position
const discardRect = this.discard.getBoundingClientRect(); const discardRect = this.discard.getBoundingClientRect();
sourceRect = { left: discardRect.left, top: discardRect.top, width: discardRect.width, height: discardRect.height };
}
const swapCard = this.swapCardFromHand; const swapCard = this.swapCardFromHand;
const swapCardFront = swapCard.querySelector('.swap-card-front'); const swapCardFront = swapCard.querySelector('.swap-card-front');
const swapCardInner = swapCard.querySelector('.swap-card-inner');
// Position at opponent's card location (flip in place there)
swapCard.style.left = sourceRect.left + 'px'; swapCard.style.left = sourceRect.left + 'px';
swapCard.style.top = sourceRect.top + 'px'; swapCard.style.top = sourceRect.top + 'px';
swapCard.style.width = sourceRect.width + 'px'; swapCard.style.width = sourceRect.width + 'px';
swapCard.style.height = sourceRect.height + 'px'; swapCard.style.height = sourceRect.height + 'px';
swapCard.classList.remove('flipping', 'moving'); swapCard.classList.remove('flipping', 'moving', 'swap-pulse');
// Apply source rotation to match the arch layout // Apply source rotation to match the arch layout
swapCard.style.transform = `rotate(${sourceRotation}deg)`; swapCard.style.transform = `rotate(${sourceRotation}deg)`;
// Set card content // Set card content (the card being discarded - what was hidden)
swapCardFront.className = 'swap-card-front'; swapCardFront.className = 'swap-card-front';
if (discardCard.rank === '★') { if (discardCard.rank === '★') {
swapCardFront.classList.add('joker'); swapCardFront.classList.add('joker');
@ -1165,24 +1291,26 @@ class GolfGame {
if (sourceCardEl) sourceCardEl.classList.add('swap-out'); if (sourceCardEl) sourceCardEl.classList.add('swap-out');
this.swapAnimation.classList.remove('hidden'); this.swapAnimation.classList.remove('hidden');
// Timing: flip takes ~400ms, then move takes ~400ms // Step 1: Flip to reveal the hidden card
setTimeout(() => { setTimeout(() => {
swapCard.classList.add('flipping'); swapCard.classList.add('flipping');
this.playSound('flip'); this.playSound('flip');
}, 50); }, 50);
// Step 2: After flip, pause to see the card then pulse before being replaced
setTimeout(() => { setTimeout(() => {
// Start move AFTER flip completes - also animate rotation back to 0 swapCard.classList.add('swap-pulse');
swapCard.classList.add('moving'); this.playSound('card');
swapCard.style.left = discardRect.left + 'px'; }, 850);
swapCard.style.top = discardRect.top + 'px';
swapCard.style.transform = 'rotate(0deg)'; // Step 3: Strategic pause to show discarded card, then complete
}, 500);
setTimeout(() => { setTimeout(() => {
this.swapAnimation.classList.add('hidden'); this.swapAnimation.classList.add('hidden');
swapCard.classList.remove('flipping', 'moving'); swapCard.classList.remove('flipping', 'moving', 'swap-pulse');
swapCard.style.transform = ''; swapCard.style.transform = '';
if (sourceCardEl) sourceCardEl.classList.remove('swap-out'); if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
}, 1000); this.pulseDiscardLand();
}, 1400);
} }
// Fire a flip animation for local player's card (non-blocking) // Fire a flip animation for local player's card (non-blocking)
@ -1233,7 +1361,7 @@ class GolfGame {
swapCard.classList.remove('flipping'); swapCard.classList.remove('flipping');
cardEl.classList.remove('swap-out'); cardEl.classList.remove('swap-out');
this.animatingPositions.delete(key); this.animatingPositions.delete(key);
}, 450); }, 550);
} }
// Fire a flip animation for opponent card (non-blocking) // Fire a flip animation for opponent card (non-blocking)
@ -1295,7 +1423,7 @@ class GolfGame {
setTimeout(() => { setTimeout(() => {
swapCard.classList.add('flipping'); swapCard.classList.add('flipping');
this.playSound('flip'); this.playSound('flip');
}, 50); }, 60);
setTimeout(() => { setTimeout(() => {
this.swapAnimation.classList.add('hidden'); this.swapAnimation.classList.add('hidden');
@ -1303,7 +1431,7 @@ class GolfGame {
swapCard.style.transform = ''; swapCard.style.transform = '';
cardEl.classList.remove('swap-out'); cardEl.classList.remove('swap-out');
this.animatingPositions.delete(key); this.animatingPositions.delete(key);
}, 450); }, 560);
} }
handleCardClick(position) { handleCardClick(position) {
@ -1339,7 +1467,9 @@ class GolfGame {
// Initial flip phase // Initial flip phase
if (this.gameState.waiting_for_initial_flip) { if (this.gameState.waiting_for_initial_flip) {
if (card.face_up) return; if (card.face_up) return;
// Use Set to prevent duplicates - check both tracking mechanisms
if (this.locallyFlippedCards.has(position)) return; if (this.locallyFlippedCards.has(position)) return;
if (this.selectedCards.includes(position)) return;
const requiredFlips = this.gameState.initial_flips || 2; const requiredFlips = this.gameState.initial_flips || 2;
@ -1353,12 +1483,15 @@ class GolfGame {
// Re-render to show flipped state // Re-render to show flipped state
this.renderGame(); this.renderGame();
if (this.selectedCards.length === requiredFlips) { // Use Set to ensure unique positions when sending to server
this.send({ type: 'flip_initial', positions: this.selectedCards }); const uniquePositions = [...new Set(this.selectedCards)];
if (uniquePositions.length === requiredFlips) {
this.send({ type: 'flip_initial', positions: uniquePositions });
this.selectedCards = []; this.selectedCards = [];
// Note: locallyFlippedCards is cleared when server confirms (in game_state handler)
this.hideToast(); this.hideToast();
} else { } else {
const remaining = requiredFlips - this.selectedCards.length; const remaining = requiredFlips - uniquePositions.length;
this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, '', 5000); this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, '', 5000);
} }
return; return;
@ -1417,6 +1550,25 @@ class GolfGame {
this.rulesScreen.classList.remove('active'); this.rulesScreen.classList.remove('active');
} }
screen.classList.add('active'); screen.classList.add('active');
// Handle auth bar visibility - hide global bar during game, show in-game controls instead
const isGameScreen = screen === this.gameScreen;
const user = this.auth?.user;
if (isGameScreen && user) {
// Hide global auth bar, show in-game auth controls
this.authBar?.classList.add('hidden');
this.gameUsername.textContent = user.username;
this.gameUsername.classList.remove('hidden');
this.gameLogoutBtn.classList.remove('hidden');
} else {
// Show global auth bar (if logged in), hide in-game auth controls
if (user) {
this.authBar?.classList.remove('hidden');
}
this.gameUsername.classList.add('hidden');
this.gameLogoutBtn.classList.add('hidden');
}
} }
showLobby() { showLobby() {
@ -1435,9 +1587,11 @@ class GolfGame {
if (this.isHost) { if (this.isHost) {
this.hostSettings.classList.remove('hidden'); this.hostSettings.classList.remove('hidden');
this.cpuControlsSection.classList.remove('hidden');
this.waitingMessage.classList.add('hidden'); this.waitingMessage.classList.add('hidden');
} else { } else {
this.hostSettings.classList.add('hidden'); this.hostSettings.classList.add('hidden');
this.cpuControlsSection.classList.add('hidden');
this.waitingMessage.classList.remove('hidden'); this.waitingMessage.classList.remove('hidden');
} }
} }
@ -1499,13 +1653,8 @@ class GolfGame {
if (player.is_host) badges += '<span class="host-badge">HOST</span>'; if (player.is_host) badges += '<span class="host-badge">HOST</span>';
if (player.is_cpu) badges += '<span class="cpu-badge">CPU</span>'; if (player.is_cpu) badges += '<span class="cpu-badge">CPU</span>';
let nameDisplay = player.name;
if (player.style) {
nameDisplay += ` <span class="cpu-style">(${player.style})</span>`;
}
li.innerHTML = ` li.innerHTML = `
<span>${nameDisplay}</span> <span>${player.name}</span>
<span>${badges}</span> <span>${badges}</span>
`; `;
if (player.id === this.playerId) { if (player.id === this.playerId) {
@ -1516,6 +1665,7 @@ class GolfGame {
if (player.id === this.playerId && player.is_host) { if (player.id === this.playerId && player.is_host) {
this.isHost = true; this.isHost = true;
this.hostSettings.classList.remove('hidden'); this.hostSettings.classList.remove('hidden');
this.cpuControlsSection.classList.remove('hidden');
this.waitingMessage.classList.add('hidden'); this.waitingMessage.classList.add('hidden');
} }
}); });
@ -1573,6 +1723,18 @@ class GolfGame {
return; return;
} }
// Check for round/game over states
if (this.gameState.phase === 'round_over') {
this.setStatus('Hole Complete!', 'round-over');
this.finalTurnBadge.classList.add('hidden');
return;
}
if (this.gameState.phase === 'game_over') {
this.setStatus('Game Over!', 'game-over');
this.finalTurnBadge.classList.add('hidden');
return;
}
const isFinalTurn = this.gameState.phase === 'final_turn'; const isFinalTurn = this.gameState.phase === 'final_turn';
const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id); const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id);
@ -1610,33 +1772,37 @@ class GolfGame {
} }
showDrawnCard() { showDrawnCard() {
// Show drawn card in the discard pile position, highlighted // Show drawn card floating over the discard pile (larger, closer to viewer)
const card = this.drawnCard; const card = this.drawnCard;
this.discard.className = 'card card-front holding'; // Set up the floating held card display
this.heldCardFloating.className = 'card card-front held-card-floating';
// Clear any inline styles left over from swoop animations
this.heldCardFloating.style.cssText = '';
if (card.rank === '★') { if (card.rank === '★') {
this.discard.classList.add('joker'); this.heldCardFloating.classList.add('joker');
} else if (this.isRedSuit(card.suit)) { const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
this.discard.classList.add('red'); this.heldCardFloatingContent.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
} else { } else {
this.discard.classList.add('black'); if (this.isRedSuit(card.suit)) {
this.heldCardFloating.classList.add('red');
} else {
this.heldCardFloating.classList.add('black');
}
this.heldCardFloatingContent.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`;
} }
// Render card directly without checking face_up (drawn card is always visible to drawer) // Show the floating card and discard button
if (card.rank === '★') { this.heldCardFloating.classList.remove('hidden');
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
this.discardContent.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
} else {
this.discardContent.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`;
}
this.discardBtn.classList.remove('hidden'); this.discardBtn.classList.remove('hidden');
} }
hideDrawnCard() { hideDrawnCard() {
// Restore discard pile to show actual top card (handled by renderGame) // Hide the floating held card
this.discard.classList.remove('holding'); this.heldCardFloating.classList.add('hidden');
// Clear any inline styles from animations
this.heldCardFloating.style.cssText = '';
this.discardBtn.classList.add('hidden'); this.discardBtn.classList.add('hidden');
this.cancelDrawBtn.classList.add('hidden');
} }
isRedSuit(suit) { isRedSuit(suit) {
@ -1745,19 +1911,50 @@ class GolfGame {
playerNameSpan.innerHTML = crownHtml + displayName + checkmark; playerNameSpan.innerHTML = crownHtml + displayName + checkmark;
} }
// Update discard pile (skip if holding a drawn card) // Update discard pile
if (!this.drawnCard) { if (this.drawnCard) {
// Holding a drawn card - show discard pile as greyed/disabled
// If drawn from discard, show what's underneath (new discard_top or empty)
// If drawn from deck, show current discard_top greyed
this.discard.classList.add('picked-up');
this.discard.classList.remove('holding');
if (this.gameState.discard_top) {
const discardCard = this.gameState.discard_top;
this.discard.classList.add('has-card', 'card-front');
this.discard.classList.remove('card-back', 'red', 'black', 'joker');
if (discardCard.rank === '★') {
this.discard.classList.add('joker');
} else if (this.isRedSuit(discardCard.suit)) {
this.discard.classList.add('red');
} else {
this.discard.classList.add('black');
}
this.discardContent.innerHTML = this.renderCardContent(discardCard);
} else {
// No card underneath - show empty
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker');
this.discardContent.innerHTML = '';
}
} else {
// Not holding - show normal discard pile
this.discard.classList.remove('picked-up');
if (this.gameState.discard_top) { if (this.gameState.discard_top) {
const discardCard = this.gameState.discard_top; const discardCard = this.gameState.discard_top;
const cardKey = `${discardCard.rank}-${discardCard.suit}`; const cardKey = `${discardCard.rank}-${discardCard.suit}`;
// Animate if discard changed // Only animate discard flip during active gameplay, not at round/game end
if (this.lastDiscardKey && this.lastDiscardKey !== cardKey) { const isActivePlay = this.gameState.phase !== 'round_over' &&
this.discard.classList.add('card-flip-in'); this.gameState.phase !== 'game_over';
setTimeout(() => this.discard.classList.remove('card-flip-in'), 400); const shouldAnimate = isActivePlay && this.lastDiscardKey &&
} this.lastDiscardKey !== cardKey && !this.skipNextDiscardFlip;
this.skipNextDiscardFlip = false;
this.lastDiscardKey = cardKey; this.lastDiscardKey = cardKey;
// Set card content and styling FIRST (before any animation)
this.discard.classList.add('has-card', 'card-front'); this.discard.classList.add('has-card', 'card-front');
this.discard.classList.remove('card-back', 'red', 'black', 'joker', 'holding'); this.discard.classList.remove('card-back', 'red', 'black', 'joker', 'holding');
@ -1769,6 +1966,15 @@ class GolfGame {
this.discard.classList.add('black'); this.discard.classList.add('black');
} }
this.discardContent.innerHTML = this.renderCardContent(discardCard); this.discardContent.innerHTML = this.renderCardContent(discardCard);
// THEN animate if needed (content is already set, so no blank flash)
if (shouldAnimate) {
// Remove any existing animation first to allow re-trigger
this.discard.classList.remove('card-flip-in');
void this.discard.offsetWidth; // Force reflow
this.discard.classList.add('card-flip-in');
setTimeout(() => this.discard.classList.remove('card-flip-in'), 560);
}
} else { } else {
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker', 'holding'); this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker', 'holding');
this.discardContent.innerHTML = ''; this.discardContent.innerHTML = '';
@ -1781,22 +1987,30 @@ class GolfGame {
const hasDrawn = this.drawnCard || this.gameState.has_drawn_card; const hasDrawn = this.drawnCard || this.gameState.has_drawn_card;
const canDraw = this.isMyTurn() && !hasDrawn && !this.gameState.waiting_for_initial_flip; const canDraw = this.isMyTurn() && !hasDrawn && !this.gameState.waiting_for_initial_flip;
// Pulse the deck area when it's player's turn to draw
this.deckArea.classList.toggle('your-turn-to-draw', canDraw);
this.deck.classList.toggle('clickable', canDraw); this.deck.classList.toggle('clickable', canDraw);
this.deck.classList.toggle('disabled', hasDrawn); // Only show disabled on deck when it's my turn and I've drawn
this.deck.classList.toggle('disabled', this.isMyTurn() && hasDrawn);
this.discard.classList.toggle('clickable', canDraw && this.gameState.discard_top); this.discard.classList.toggle('clickable', canDraw && this.gameState.discard_top);
// Don't show disabled state when we're holding a drawn card (it's displayed in discard position) // Only show disabled state when it's my turn and I've drawn (not holding visible card)
this.discard.classList.toggle('disabled', hasDrawn && !this.drawnCard); // Don't grey out when opponents are playing
this.discard.classList.toggle('disabled', this.isMyTurn() && hasDrawn && !this.drawnCard);
// Render opponents in a single row // Render opponents in a single row
const opponents = this.gameState.players.filter(p => p.id !== this.playerId); const opponents = this.gameState.players.filter(p => p.id !== this.playerId);
this.opponentsRow.innerHTML = ''; this.opponentsRow.innerHTML = '';
// Don't highlight current player during round/game over
const isPlaying = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over';
opponents.forEach((player) => { opponents.forEach((player) => {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'opponent-area'; div.className = 'opponent-area';
if (player.id === this.gameState.current_player_id) { if (isPlaying && player.id === this.gameState.current_player_id) {
div.classList.add('current-turn'); div.classList.add('current-turn');
} }
@ -1810,7 +2024,7 @@ class GolfGame {
const crownHtml = isRoundWinner ? '<span class="winner-crown">👑</span>' : ''; const crownHtml = isRoundWinner ? '<span class="winner-crown">👑</span>' : '';
div.innerHTML = ` div.innerHTML = `
<h4>${crownHtml}${displayName}${player.all_face_up ? ' ✓' : ''}<span class="opponent-showing">${showingScore}</span></h4> <h4><span class="opponent-name">${crownHtml}${displayName}${player.all_face_up ? ' ✓' : ''}</span><span class="opponent-showing">${showingScore}</span></h4>
<div class="card-grid"> <div class="card-grid">
${player.cards.map(card => this.renderCard(card, false, false)).join('')} ${player.cards.map(card => this.renderCard(card, false, false)).join('')}
</div> </div>
@ -1862,12 +2076,9 @@ class GolfGame {
if (this.drawnCard && !this.gameState.can_discard) { if (this.drawnCard && !this.gameState.can_discard) {
this.discardBtn.disabled = true; this.discardBtn.disabled = true;
this.discardBtn.classList.add('disabled'); this.discardBtn.classList.add('disabled');
// Show cancel button when drawn from discard (can put it back)
this.cancelDrawBtn.classList.remove('hidden');
} else { } else {
this.discardBtn.disabled = false; this.discardBtn.disabled = false;
this.discardBtn.classList.remove('disabled'); this.discardBtn.classList.remove('disabled');
this.cancelDrawBtn.classList.add('hidden');
} }
// Show/hide skip flip button (only when flip is optional in endgame mode) // Show/hide skip flip button (only when flip is optional in endgame mode)
@ -1907,14 +2118,20 @@ class GolfGame {
// Update standings (left panel) // Update standings (left panel)
this.updateStandings(); this.updateStandings();
// Skip score table update during round_over/game_over - showScoreboard handles these
if (this.gameState.phase === 'round_over' || this.gameState.phase === 'game_over') {
return;
}
// Update score table (right panel) // Update score table (right panel)
this.scoreTable.innerHTML = ''; this.scoreTable.innerHTML = '';
this.gameState.players.forEach(player => { this.gameState.players.forEach(player => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
// Highlight current player // Highlight current player (but not during round/game over)
if (player.id === this.gameState.current_player_id) { const isPlaying = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over';
if (isPlaying && player.id === this.gameState.current_player_id) {
tr.classList.add('current-player'); tr.classList.add('current-player');
} }

View File

@ -51,11 +51,10 @@
<!-- Waiting Room Screen --> <!-- Waiting Room Screen -->
<div id="waiting-screen" class="screen"> <div id="waiting-screen" class="screen">
<div class="room-code-banner"> <div class="room-code-banner">
<span class="room-code-label">ROOM CODE</span>
<span class="room-code-value" id="display-room-code"></span> <span class="room-code-value" id="display-room-code"></span>
<div class="room-code-buttons"> <div class="room-code-buttons">
<button class="room-code-copy" id="copy-room-code" title="Copy code">📋</button> <button class="room-code-copy" id="copy-room-code" title="Copy code">📋</button>
<button class="room-code-copy" id="share-room-link" title="Copy link">🌐</button> <button class="room-code-copy" id="share-room-link" title="Copy invite link">🌐</button>
</div> </div>
</div> </div>
@ -65,19 +64,19 @@
<h3>Players</h3> <h3>Players</h3>
<ul id="players-list"></ul> <ul id="players-list"></ul>
</div> </div>
<div id="cpu-controls-section" class="cpu-controls-section hidden">
<h4>Add CPU Opponents</h4>
<div class="cpu-controls">
<button id="remove-cpu-btn" class="btn btn-small btn-danger" title="Remove last CPU"></button>
<button id="add-cpu-btn" class="btn btn-small btn-success" title="Add CPU player">+</button>
</div>
</div>
<button id="leave-room-btn" class="btn btn-danger">Leave Room</button> <button id="leave-room-btn" class="btn btn-danger">Leave Room</button>
</div> </div>
<div id="host-settings" class="settings hidden"> <div id="host-settings" class="settings hidden">
<h3>Game Settings</h3> <h3>Game Settings</h3>
<div class="basic-settings-row"> <div class="basic-settings-row">
<div class="form-group">
<label>CPU Players</label>
<div class="cpu-controls">
<button id="remove-cpu-btn" class="btn btn-small btn-danger">(-) Delete</button>
<button id="add-cpu-btn" class="btn btn-small btn-success">(+) Add</button>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="num-decks">Decks</label> <label for="num-decks">Decks</label>
<select id="num-decks"> <select id="num-decks">
@ -262,6 +261,8 @@
<div id="final-turn-badge" class="final-turn-badge hidden">⚡ FINAL TURN</div> <div id="final-turn-badge" class="final-turn-badge hidden">⚡ FINAL TURN</div>
</div> </div>
<div class="header-col header-col-right"> <div class="header-col header-col-right">
<span id="game-username" class="game-username hidden"></span>
<button id="game-logout-btn" class="btn btn-small hidden">Logout</button>
<button id="mute-btn" class="mute-btn" title="Toggle sound">🔊</button> <button id="mute-btn" class="mute-btn" title="Toggle sound">🔊</button>
<button id="leave-game-btn" class="btn btn-small btn-danger">Leave</button> <button id="leave-game-btn" class="btn btn-small btn-danger">Leave</button>
</div> </div>
@ -273,6 +274,13 @@
<div class="player-row"> <div class="player-row">
<div class="table-center"> <div class="table-center">
<div class="deck-area"> <div class="deck-area">
<!-- Held card slot (left of deck) -->
<div id="held-card-slot" class="held-card-slot hidden">
<div id="held-card-display" class="card card-front">
<span id="held-card-content"></span>
</div>
<span class="held-label">Holding</span>
</div>
<div id="deck" class="card card-back"> <div id="deck" class="card card-back">
<span>?</span> <span>?</span>
</div> </div>
@ -280,8 +288,11 @@
<div id="discard" class="card"> <div id="discard" class="card">
<span id="discard-content"></span> <span id="discard-content"></span>
</div> </div>
<!-- Floating held card (appears larger over discard when holding) -->
<div id="held-card-floating" class="card card-front held-card-floating hidden">
<span id="held-card-floating-content"></span>
</div>
<button id="discard-btn" class="btn btn-small hidden">Discard</button> <button id="discard-btn" class="btn btn-small hidden">Discard</button>
<button id="cancel-draw-btn" class="btn btn-small btn-secondary hidden">Put Back</button>
<button id="skip-flip-btn" class="btn btn-small btn-secondary hidden">Skip Flip</button> <button id="skip-flip-btn" class="btn btn-small btn-secondary hidden">Skip Flip</button>
<button id="knock-early-btn" class="btn btn-small btn-danger hidden">Knock!</button> <button id="knock-early-btn" class="btn btn-small btn-danger hidden">Knock!</button>
</div> </div>
@ -295,14 +306,22 @@
</div> </div>
</div> </div>
<!-- Legacy swap animation overlay (kept for rollback) --> <!-- Animation overlay for card movements -->
<div id="swap-animation" class="swap-animation hidden"> <div id="swap-animation" class="swap-animation hidden">
<!-- Card being discarded from hand -->
<div id="swap-card-from-hand" class="swap-card"> <div id="swap-card-from-hand" class="swap-card">
<div class="swap-card-inner"> <div class="swap-card-inner">
<div class="swap-card-front"></div> <div class="swap-card-front"></div>
<div class="swap-card-back">?</div> <div class="swap-card-back">?</div>
</div> </div>
</div> </div>
<!-- Drawn card being held (animates to hand) -->
<div id="held-card" class="swap-card hidden">
<div class="swap-card-inner">
<div class="swap-card-front"></div>
<div class="swap-card-back">?</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -317,6 +336,10 @@
<!-- Right panel: Scores --> <!-- Right panel: Scores -->
<div id="scoreboard" class="side-panel right-panel"> <div id="scoreboard" class="side-panel right-panel">
<h4>Scores</h4> <h4>Scores</h4>
<div id="game-buttons" class="game-buttons hidden">
<button id="next-round-btn" class="btn btn-next-round hidden">Next Hole</button>
<button id="new-game-btn" class="btn btn-small btn-secondary hidden">New Game</button>
</div>
<table id="score-table"> <table id="score-table">
<thead> <thead>
<tr> <tr>
@ -328,10 +351,6 @@
</thead> </thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
<div id="game-buttons" class="game-buttons hidden">
<button id="next-round-btn" class="btn btn-small btn-primary hidden">Next Hole</button>
<button id="new-game-btn" class="btn btn-small btn-secondary hidden">New Game</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -195,12 +195,13 @@ body {
grid-template-columns: 220px 1fr; grid-template-columns: 220px 1fr;
gap: 25px; gap: 25px;
align-items: start; align-items: start;
padding-top: 70px;
} }
.waiting-left-col { .waiting-left-col {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 15px; gap: 10px;
} }
.waiting-left-col .players-list { .waiting-left-col .players-list {
@ -227,7 +228,7 @@ body {
/* Basic settings in a row */ /* Basic settings in a row */
.basic-settings-row { .basic-settings-row {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 12px; gap: 12px;
margin-bottom: 15px; margin-bottom: 15px;
align-items: end; align-items: end;
@ -248,14 +249,29 @@ body {
padding: 8px 4px; padding: 8px 4px;
} }
.basic-settings-row .cpu-controls { /* CPU Controls Section - below players list */
display: flex; .cpu-controls-section {
gap: 5px; background: rgba(0,0,0,0.2);
border-radius: 8px;
padding: 10px 12px;
} }
.basic-settings-row .cpu-controls .btn { .cpu-controls-section h4 {
margin: 0 0 6px 0;
font-size: 0.8rem;
color: #f4a460;
}
.cpu-controls-section .cpu-controls {
display: flex;
gap: 6px;
}
.cpu-controls-section .cpu-controls .btn {
flex: 1; flex: 1;
padding: 8px 0; padding: 6px 0;
font-size: 1rem;
font-weight: bold;
} }
#waiting-message { #waiting-message {
@ -282,14 +298,14 @@ body {
left: 20px; left: 20px;
z-index: 100; z-index: 100;
background: linear-gradient(180deg, #d4845a 0%, #c4723f 50%, #b8663a 100%); background: linear-gradient(180deg, #d4845a 0%, #c4723f 50%, #b8663a 100%);
padding: 12px 16px 20px; padding: 10px 14px 18px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 4px; gap: 6px;
box-shadow: 2px 4px 12px rgba(0, 0, 0, 0.3); box-shadow: 2px 4px 12px rgba(0, 0, 0, 0.3);
/* Ribbon forked end (snake tongue style) */ /* Ribbon forked end (snake tongue style) */
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% calc(100% - 12px), 0 100%); clip-path: polygon(0 0, 100% 0, 100% 100%, 50% calc(100% - 10px), 0 100%);
} }
.room-code-banner::before { .room-code-banner::before {
@ -302,39 +318,27 @@ body {
background: linear-gradient(180deg, rgba(255,255,255,0.3) 0%, transparent 100%); background: linear-gradient(180deg, rgba(255,255,255,0.3) 0%, transparent 100%);
} }
.room-code-label {
font-size: 0.55rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.25em;
color: rgba(255, 255, 255, 0.85);
text-align: center;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
}
.room-code-value { .room-code-value {
font-size: 1.6rem; font-size: 1.5rem;
font-weight: 800; font-weight: 800;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
letter-spacing: 0.2em; letter-spacing: 0.15em;
color: #fff; color: #fff;
text-shadow: 0 2px 2px rgba(0, 0, 0, 0.3); text-shadow: 0 2px 2px rgba(0, 0, 0, 0.3);
padding: 2px 0;
} }
.room-code-buttons { .room-code-buttons {
display: flex; display: flex;
gap: 6px; gap: 5px;
margin-top: 2px;
} }
.room-code-copy { .room-code-copy {
background: rgba(255, 255, 255, 0.85); background: rgba(255, 255, 255, 0.85);
border: none; border: none;
border-radius: 4px; border-radius: 4px;
padding: 4px 8px; padding: 4px 6px;
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.85rem;
transition: all 0.2s; transition: all 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
} }
@ -349,6 +353,7 @@ body {
} }
h1 { h1 {
font-size: 3rem; font-size: 3rem;
text-align: center; text-align: center;
@ -463,6 +468,15 @@ input::placeholder {
width: auto; width: auto;
} }
.game-buttons .btn-next-round {
padding: 10px 20px;
font-size: 1rem;
font-weight: 600;
width: 100%;
background: #f4a460;
color: #1a472a;
}
.btn.disabled, .btn.disabled,
.btn:disabled { .btn:disabled {
opacity: 0.4; opacity: 0.4;
@ -625,7 +639,7 @@ input::placeholder {
/* Game Screen */ /* Game Screen */
.game-header { .game-header {
display: grid; display: grid;
grid-template-columns: 1fr 2fr 1fr; grid-template-columns: auto 1fr auto;
align-items: center; align-items: center;
padding: 10px 20px; padding: 10px 20px;
background: rgba(0,0,0,0.35); background: rgba(0,0,0,0.35);
@ -653,8 +667,18 @@ input::placeholder {
} }
.header-col-right { .header-col-right {
display: flex;
flex-direction: row;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; align-items: center;
gap: 6px;
flex-wrap: nowrap;
min-width: max-content;
}
#game-logout-btn {
padding: 4px 8px;
font-size: 0.75rem;
} }
.game-header .round-info { .game-header .round-info {
@ -773,6 +797,7 @@ input::placeholder {
background: #fff; background: #fff;
border: 2px solid #ddd; border: 2px solid #ddd;
color: #333; color: #333;
text-align: center;
} }
.card-front.red { .card-front.red {
@ -962,7 +987,73 @@ input::placeholder {
.deck-area { .deck-area {
display: flex; display: flex;
gap: 15px; gap: 15px;
align-items: center; align-items: flex-start;
}
/* Gentle pulse when it's your turn to draw */
.deck-area.your-turn-to-draw {
animation: deckAreaPulse 2s ease-in-out infinite;
}
@keyframes deckAreaPulse {
0%, 100% {
filter: brightness(1);
transform: scale(1);
}
50% {
filter: brightness(1.08);
transform: scale(1.02);
}
}
/* Held card slot - hidden, using floating card over discard instead */
.held-card-slot {
display: none !important;
}
/* Held card floating over discard pile (larger, closer to viewer) */
.held-card-floating {
position: absolute;
top: 0;
left: 0;
z-index: 100;
transform: scale(1.2) translateY(-12px);
border: 3px solid #f4a460 !important;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 25px rgba(244, 164, 96, 0.7) !important;
pointer-events: none;
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
}
.held-card-floating.hidden {
opacity: 0;
transform: scale(1) translateY(0);
pointer-events: none;
}
/* Animate floating card dropping to discard pile (when drawn from discard) */
.held-card-floating.dropping {
transform: scale(1) translateY(0);
border-color: transparent !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
transition: transform 0.25s ease-out, border-color 0.25s ease-out, box-shadow 0.25s ease-out;
}
/* Swoop animation for deck → immediate discard */
.held-card-floating.swooping {
transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1),
top 0.35s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.35s ease-out,
border-color 0.35s ease-out,
box-shadow 0.35s ease-out;
transform: scale(1.15) rotate(-8deg);
border-color: rgba(244, 164, 96, 0.8) !important;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4), 0 0 20px rgba(244, 164, 96, 0.6) !important;
}
.held-card-floating.swooping.landed {
transform: scale(1) rotate(0deg);
border-color: transparent !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
} }
.deck-area .card { .deck-area .card {
@ -989,11 +1080,19 @@ input::placeholder {
transform: scale(1.05); transform: scale(1.05);
} }
/* Picked-up state - showing card underneath after drawing from discard */
#discard.picked-up {
opacity: 0.5;
filter: grayscale(40%);
transform: scale(0.95);
}
.discard-stack { .discard-stack {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
position: relative;
} }
.discard-stack .btn { .discard-stack .btn {
@ -1016,7 +1115,7 @@ input::placeholder {
/* Highlight flash when opponent draws from a pile */ /* Highlight flash when opponent draws from a pile */
#deck.draw-pulse, #deck.draw-pulse,
#discard.draw-pulse { #discard.draw-pulse {
animation: draw-highlight 0.4s ease-out; animation: draw-highlight 0.45s ease-out;
z-index: 100; z-index: 100;
} }
@ -1044,7 +1143,7 @@ input::placeholder {
/* Card flip animation for discard pile */ /* Card flip animation for discard pile */
.card-flip-in { .card-flip-in {
animation: cardFlipIn 0.5s ease-out; animation: cardFlipIn 0.56s ease-out;
} }
@keyframes cardFlipIn { @keyframes cardFlipIn {
@ -1069,6 +1168,26 @@ input::placeholder {
} }
} }
/* Discard pile pulse when card lands */
#discard.discard-land {
animation: discardLand 0.46s ease-out;
}
@keyframes discardLand {
0% {
transform: scale(1);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
40% {
transform: scale(1.18);
box-shadow: 0 0 25px rgba(244, 164, 96, 0.9);
}
100% {
transform: scale(1);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
}
/* Swap animation overlay */ /* Swap animation overlay */
.swap-animation { .swap-animation {
position: fixed; position: fixed;
@ -1092,12 +1211,16 @@ input::placeholder {
perspective: 1000px; perspective: 1000px;
} }
.swap-card.hidden {
display: none;
}
.swap-card-inner { .swap-card-inner {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
transform-style: preserve-3d; transform-style: preserve-3d;
transition: transform 0.4s ease-in-out; transition: transform 0.54s ease-in-out;
} }
.swap-card.flipping .swap-card-inner { .swap-card.flipping .swap-card-inner {
@ -1163,7 +1286,7 @@ input::placeholder {
} }
.swap-card.moving { .swap-card.moving {
transition: top 0.4s cubic-bezier(0.4, 0, 0.2, 1), left 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.4s ease-out; transition: top 0.45s cubic-bezier(0.4, 0, 0.2, 1), left 0.45s cubic-bezier(0.4, 0, 0.2, 1), transform 0.45s ease-out;
transform: scale(1.1) rotate(-5deg); transform: scale(1.1) rotate(-5deg);
filter: drop-shadow(0 0 25px rgba(244, 164, 96, 1)); filter: drop-shadow(0 0 25px rgba(244, 164, 96, 1));
} }
@ -1180,6 +1303,31 @@ input::placeholder {
transition: opacity 0.2s; transition: opacity 0.2s;
} }
/* Subtle swap pulse for face-to-face swaps (no flip needed) */
.card.swap-pulse {
animation: swapPulse 0.4s ease-out;
}
@keyframes swapPulse {
0% {
transform: scale(1);
filter: brightness(1);
}
20% {
transform: scale(0.92);
filter: brightness(0.85);
}
50% {
transform: scale(1.08);
filter: brightness(1.15);
box-shadow: 0 0 12px rgba(255, 255, 255, 0.4);
}
100% {
transform: scale(1);
filter: brightness(1);
}
}
/* Player Area */ /* Player Area */
.player-section { .player-section {
text-align: center; text-align: center;
@ -1483,14 +1631,14 @@ input::placeholder {
} }
.game-buttons { .game-buttons {
margin-top: 8px; margin-bottom: 8px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 5px; gap: 5px;
} }
.game-buttons .btn { .game-buttons .btn {
font-size: 0.7rem; font-size: 0.8rem;
padding: 6px 8px; padding: 6px 8px;
width: 100%; width: 100%;
} }
@ -1728,11 +1876,27 @@ input::placeholder {
} }
.game-header { .game-header {
display: flex;
flex-direction: column; flex-direction: column;
text-align: center; text-align: center;
gap: 3px; gap: 3px;
} }
.header-col-right {
justify-content: center;
}
#game-logout-btn,
#leave-game-btn {
padding: 3px 6px;
font-size: 0.7rem;
}
.game-username {
font-size: 0.7rem;
max-width: 60px;
}
.table-center { .table-center {
padding: 10px 15px; padding: 10px 15px;
} }
@ -1793,7 +1957,7 @@ input::placeholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
transform-style: preserve-3d; transform-style: preserve-3d;
transition: transform 0.4s ease-in-out; transition: transform 0.54s ease-in-out;
} }
.real-card .card-inner.flipped { .real-card .card-inner.flipped {
@ -1867,9 +2031,9 @@ input::placeholder {
.real-card.moving, .real-card.moving,
.real-card.anim-card.moving { .real-card.anim-card.moving {
z-index: 600; z-index: 600;
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1), transition: left 0.27s cubic-bezier(0.4, 0, 0.2, 1),
top 0.3s cubic-bezier(0.4, 0, 0.2, 1), top 0.27s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.3s ease-out; transform 0.27s ease-out;
filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8)); filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8));
transform: scale(1.08) rotate(-3deg); transform: scale(1.08) rotate(-3deg);
} }
@ -1881,7 +2045,7 @@ input::placeholder {
} }
.real-card.anim-card .card-inner { .real-card.anim-card .card-inner {
transition: transform 0.4s ease-in-out; transition: transform 0.54s ease-in-out;
} }
.real-card.holding { .real-card.holding {
@ -2881,11 +3045,30 @@ input::placeholder {
display: none; display: none;
} }
/* Hide global auth-bar when game screen is active */
#app:has(#game-screen.active) > .auth-bar {
display: none !important;
}
#auth-username { #auth-username {
color: #f4a460; color: #f4a460;
font-weight: 500; font-weight: 500;
} }
/* Username in game header */
.game-username {
color: #f4a460;
font-weight: 500;
font-size: 0.75rem;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
}
.game-username.hidden {
display: none;
}
/* Auth buttons in lobby */ /* Auth buttons in lobby */
.auth-buttons { .auth-buttons {
display: flex; display: flex;

View File

@ -47,15 +47,74 @@ def can_make_pair(card1: Card, card2: Card) -> bool:
return card1.rank == card2.rank return card1.rank == card2.rank
def estimate_opponent_min_score(player: Player, game: Game) -> int: def get_discard_thinking_time(card: Optional[Card], options: GameOptions) -> float:
"""Estimate minimum opponent score from visible cards.""" """Calculate CPU 'thinking time' based on how obvious the discard decision is.
Easy decisions (obviously good or bad cards) = quick (400-600ms)
Hard decisions (medium value cards) = slower (900-1100ms)
Returns time in seconds.
"""
if not card:
# No discard available - quick decision to draw from deck
return random.uniform(0.4, 0.5)
value = get_card_value(card, options)
# Obviously good cards (easy take): 2 (-2), Joker (-2/-5), K (0), A (1)
if value <= 1:
return random.uniform(0.4, 0.6)
# Obviously bad cards (easy pass): 10, J, Q (value 10)
if value >= 10:
return random.uniform(0.4, 0.6)
# Medium cards require more thought: 3-9
# 5, 6, 7 are the hardest decisions (middle of the range)
if value in (5, 6, 7):
return random.uniform(0.9, 1.1)
# 3, 4, 8, 9 - moderate difficulty
return random.uniform(0.6, 0.85)
def estimate_opponent_min_score(player: Player, game: Game, optimistic: bool = False) -> int:
"""Estimate minimum opponent score from visible cards.
Args:
player: The player making the estimation (excluded from opponents)
game: The game state
optimistic: If True, assume opponents' hidden cards are average (4.5).
If False, assume opponents could get lucky (lower estimate).
"""
min_est = 999 min_est = 999
for p in game.players: for p in game.players:
if p.id == player.id: if p.id == player.id:
continue continue
visible = sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up) visible = sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up)
hidden = sum(1 for c in p.cards if not c.face_up) hidden = sum(1 for c in p.cards if not c.face_up)
estimate = visible + int(hidden * 4.5) # Assume ~4.5 avg for hidden
if optimistic:
# Assume average hidden cards
estimate = visible + int(hidden * 4.5)
else:
# Assume opponents could get lucky - hidden cards might be low
# or could complete pairs, so use lower estimate
# Check for potential pairs in opponent's hand
pair_potential = 0
for col in range(3):
top, bot = p.cards[col], p.cards[col + 3]
# If one card is visible and the other is hidden, there's pair potential
if top.face_up and not bot.face_up:
pair_potential += get_ai_card_value(top, game.options)
elif bot.face_up and not top.face_up:
pair_potential += get_ai_card_value(bot, game.options)
# Conservative estimate: assume 2.5 avg for hidden (could be low cards)
# and subtract some pair potential (hidden cards might match visible)
base_estimate = visible + int(hidden * 2.5)
estimate = base_estimate - int(pair_potential * 0.25) # 25% chance of pair
min_est = min(min_est, estimate) min_est = min(min_est, estimate)
return min_est return min_est
@ -365,60 +424,91 @@ CPU_PROFILES = [
), ),
] ]
# Track which profiles are in use # Track profiles per room (room_code -> set of used profile names)
_used_profiles: set[str] = set() _room_used_profiles: dict[str, set[str]] = {}
_cpu_profiles: dict[str, CPUProfile] = {} # Track cpu_id -> (room_code, profile) mapping
_cpu_profiles: dict[str, tuple[str, CPUProfile]] = {}
def get_available_profile() -> Optional[CPUProfile]: def get_available_profile(room_code: str) -> Optional[CPUProfile]:
"""Get a random available CPU profile.""" """Get a random available CPU profile for a specific room."""
available = [p for p in CPU_PROFILES if p.name not in _used_profiles] used_in_room = _room_used_profiles.get(room_code, set())
available = [p for p in CPU_PROFILES if p.name not in used_in_room]
if not available: if not available:
return None return None
profile = random.choice(available) profile = random.choice(available)
_used_profiles.add(profile.name) if room_code not in _room_used_profiles:
_room_used_profiles[room_code] = set()
_room_used_profiles[room_code].add(profile.name)
return profile return profile
def release_profile(name: str): def release_profile(name: str, room_code: str):
"""Release a CPU profile back to the pool.""" """Release a CPU profile back to the room's pool."""
_used_profiles.discard(name) if room_code in _room_used_profiles:
# Also remove from cpu_profiles by finding the cpu_id with this profile _room_used_profiles[room_code].discard(name)
to_remove = [cpu_id for cpu_id, profile in _cpu_profiles.items() if profile.name == name] # Clean up empty room entries
if not _room_used_profiles[room_code]:
del _room_used_profiles[room_code]
# Also remove from cpu_profiles by finding the cpu_id with this profile in this room
to_remove = [
cpu_id for cpu_id, (rc, profile) in _cpu_profiles.items()
if profile.name == name and rc == room_code
]
for cpu_id in to_remove:
del _cpu_profiles[cpu_id]
def cleanup_room_profiles(room_code: str):
"""Clean up all profile tracking for a room when it's deleted."""
if room_code in _room_used_profiles:
del _room_used_profiles[room_code]
# Remove all cpu_profiles for this room
to_remove = [cpu_id for cpu_id, (rc, _) in _cpu_profiles.items() if rc == room_code]
for cpu_id in to_remove: for cpu_id in to_remove:
del _cpu_profiles[cpu_id] del _cpu_profiles[cpu_id]
def reset_all_profiles(): def reset_all_profiles():
"""Reset all profile tracking (for cleanup).""" """Reset all profile tracking (for cleanup)."""
_used_profiles.clear() _room_used_profiles.clear()
_cpu_profiles.clear() _cpu_profiles.clear()
def get_profile(cpu_id: str) -> Optional[CPUProfile]: def get_profile(cpu_id: str) -> Optional[CPUProfile]:
"""Get the profile for a CPU player.""" """Get the profile for a CPU player."""
return _cpu_profiles.get(cpu_id) entry = _cpu_profiles.get(cpu_id)
return entry[1] if entry else None
def assign_profile(cpu_id: str) -> Optional[CPUProfile]: def assign_profile(cpu_id: str, room_code: str) -> Optional[CPUProfile]:
"""Assign a random profile to a CPU player.""" """Assign a random profile to a CPU player in a specific room."""
profile = get_available_profile() profile = get_available_profile(room_code)
if profile: if profile:
_cpu_profiles[cpu_id] = profile _cpu_profiles[cpu_id] = (room_code, profile)
return profile return profile
def assign_specific_profile(cpu_id: str, profile_name: str) -> Optional[CPUProfile]: def assign_specific_profile(cpu_id: str, profile_name: str, room_code: str) -> Optional[CPUProfile]:
"""Assign a specific profile to a CPU player by name.""" """Assign a specific profile to a CPU player by name in a specific room."""
# Check if profile exists and is available used_in_room = _room_used_profiles.get(room_code, set())
# Check if profile exists and is available in this room
for profile in CPU_PROFILES: for profile in CPU_PROFILES:
if profile.name == profile_name and profile.name not in _used_profiles: if profile.name == profile_name and profile.name not in used_in_room:
_used_profiles.add(profile.name) if room_code not in _room_used_profiles:
_cpu_profiles[cpu_id] = profile _room_used_profiles[room_code] = set()
_room_used_profiles[room_code].add(profile.name)
_cpu_profiles[cpu_id] = (room_code, profile)
return profile return profile
return None return None
def get_available_profiles(room_code: str) -> list[dict]:
"""Get available CPU profiles for a specific room."""
used_in_room = _room_used_profiles.get(room_code, set())
return [p.to_dict() for p in CPU_PROFILES if p.name not in used_in_room]
def get_all_profiles() -> list[dict]: def get_all_profiles() -> list[dict]:
"""Get all CPU profiles for display.""" """Get all CPU profiles for display."""
return [p.to_dict() for p in CPU_PROFILES] return [p.to_dict() for p in CPU_PROFILES]
@ -1150,7 +1240,7 @@ class GolfAI:
# Knock Penalty (+10 if not lowest): Need to be confident we're lowest # Knock Penalty (+10 if not lowest): Need to be confident we're lowest
if options.knock_penalty: if options.knock_penalty:
opponent_min = estimate_opponent_min_score(player, game) opponent_min = estimate_opponent_min_score(player, game, optimistic=False)
# Conservative players require bigger lead # Conservative players require bigger lead
safety_margin = 5 if profile.aggression < 0.4 else 2 safety_margin = 5 if profile.aggression < 0.4 else 2
if estimated_score > opponent_min - safety_margin: if estimated_score > opponent_min - safety_margin:
@ -1174,8 +1264,37 @@ class GolfAI:
if options.underdog_bonus: if options.underdog_bonus:
go_out_threshold -= 1 go_out_threshold -= 1
# HIGH SCORE CAUTION: When our score is >10, be extra careful
# Opponents' hidden cards could easily beat us with pairs or low cards
if estimated_score > 10:
# Get pessimistic estimate of opponent's potential score
opponent_min_pessimistic = estimate_opponent_min_score(player, game, optimistic=False)
opponent_min_optimistic = estimate_opponent_min_score(player, game, optimistic=True)
ai_log(f" High score caution: our score={estimated_score}, "
f"opponent estimates: optimistic={opponent_min_optimistic}, pessimistic={opponent_min_pessimistic}")
# If opponents could potentially beat us, reduce our willingness to go out
if opponent_min_pessimistic < estimated_score:
# Calculate how risky this is
risk_margin = estimated_score - opponent_min_pessimistic
# Reduce threshold based on risk (more risk = lower threshold)
risk_penalty = min(risk_margin, 8) # Cap at 8 point penalty
go_out_threshold -= risk_penalty
ai_log(f" Risk penalty: -{risk_penalty} (opponents could score {opponent_min_pessimistic})")
# Additional penalty for very high scores (>15) - almost never go out
if estimated_score > 15:
extra_penalty = (estimated_score - 15) * 2
go_out_threshold -= extra_penalty
ai_log(f" Very high score penalty: -{extra_penalty}")
ai_log(f" Go-out decision: score={estimated_score}, threshold={go_out_threshold}, "
f"aggression={profile.aggression:.2f}")
if estimated_score <= go_out_threshold: if estimated_score <= go_out_threshold:
if random.random() < profile.aggression: if random.random() < profile.aggression:
ai_log(f" >> GOING OUT with score {estimated_score}")
return True return True
return False return False
@ -1196,11 +1315,22 @@ async def process_cpu_turn(
# Get logger if game_id provided # Get logger if game_id provided
logger = get_logger() if game_id else None logger = get_logger() if game_id else None
# Add delay based on unpredictability (chaotic players are faster/slower) # Brief initial delay before CPU "looks at" the discard pile
delay = 0.8 + random.uniform(0, 0.5) await asyncio.sleep(random.uniform(0.08, 0.15))
# "Thinking" delay based on how obvious the discard decision is
# Easy decisions (good/bad cards) are quick, medium cards take longer
discard_top = game.discard_top()
thinking_time = get_discard_thinking_time(discard_top, game.options)
# Adjust for personality - chaotic players have more variance
if profile.unpredictability > 0.2: if profile.unpredictability > 0.2:
delay = random.uniform(0.3, 1.2) thinking_time *= random.uniform(0.6, 1.4)
await asyncio.sleep(delay)
discard_str = f"{discard_top.rank.value}" if discard_top else "empty"
ai_log(f"{cpu_player.name} thinking for {thinking_time:.2f}s (discard: {discard_str})")
await asyncio.sleep(thinking_time)
ai_log(f"{cpu_player.name} done thinking, making decision")
# Check if we should try to go out early # Check if we should try to go out early
GolfAI.should_go_out_early(cpu_player, game, profile) GolfAI.should_go_out_early(cpu_player, game, profile)
@ -1243,8 +1373,7 @@ async def process_cpu_turn(
await broadcast_callback() await broadcast_callback()
return # Turn is over return # Turn is over
# Decide whether to draw from discard or deck # Decide whether to draw from discard or deck (discard_top already fetched above)
discard_top = game.discard_top()
take_discard = GolfAI.should_take_discard(discard_top, cpu_player, profile, game) take_discard = GolfAI.should_take_discard(discard_top, cpu_player, profile, game)
source = "discard" if take_discard else "deck" source = "discard" if take_discard else "deck"

View File

@ -15,7 +15,7 @@ import redis.asyncio as redis
from config import config from config import config
from room import RoomManager, Room from room import RoomManager, Room
from game import GamePhase, GameOptions from game import GamePhase, GameOptions
from ai import GolfAI, process_cpu_turn, get_all_profiles, reset_all_profiles from ai import GolfAI, process_cpu_turn, get_all_profiles, reset_all_profiles, cleanup_room_profiles
from game_log import get_logger from game_log import get_logger
# Import production components # Import production components
@ -407,12 +407,17 @@ async def require_admin(user: User = Depends(require_user)) -> User:
@app.get("/api/debug/cpu-profiles") @app.get("/api/debug/cpu-profiles")
async def get_cpu_profile_status(): async def get_cpu_profile_status():
"""Get current CPU profile allocation status.""" """Get current CPU profile allocation status."""
from ai import _used_profiles, _cpu_profiles, CPU_PROFILES from ai import _room_used_profiles, _cpu_profiles, CPU_PROFILES
return { return {
"total_profiles": len(CPU_PROFILES), "total_profiles": len(CPU_PROFILES),
"used_count": len(_used_profiles), "room_profiles": {
"used_profiles": list(_used_profiles), room_code: list(profiles)
"cpu_mappings": {cpu_id: profile.name for cpu_id, profile in _cpu_profiles.items()}, for room_code, profiles in _room_used_profiles.items()
},
"cpu_mappings": {
cpu_id: {"room": room_code, "profile": profile.name}
for cpu_id, (room_code, profile) in _cpu_profiles.items()
},
"active_rooms": len(room_manager.rooms), "active_rooms": len(room_manager.rooms),
"rooms": { "rooms": {
code: { code: {
@ -431,6 +436,19 @@ async def reset_cpu_profiles():
return {"status": "ok", "message": "All CPU profiles reset"} return {"status": "ok", "message": "All CPU profiles reset"}
MAX_CONCURRENT_GAMES = 4
def count_user_games(user_id: str) -> int:
"""Count how many games this authenticated user is currently in."""
count = 0
for room in room_manager.rooms.values():
for player in room.players.values():
if player.auth_user_id == user_id:
count += 1
return count
@app.websocket("/ws") @app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
await websocket.accept() await websocket.accept()
@ -444,13 +462,17 @@ async def websocket_endpoint(websocket: WebSocket):
except Exception as e: except Exception as e:
logger.debug(f"WebSocket auth failed: {e}") logger.debug(f"WebSocket auth failed: {e}")
# Use authenticated user ID if available, otherwise generate random UUID # Each connection gets a unique ID (allows multi-tab play)
connection_id = str(uuid.uuid4())
player_id = connection_id
# Track auth user separately for stats/limits (can be None)
auth_user_id = str(authenticated_user.id) if authenticated_user else None
if authenticated_user: if authenticated_user:
player_id = str(authenticated_user.id) logger.debug(f"WebSocket authenticated as user {auth_user_id}, connection {connection_id}")
logger.debug(f"WebSocket authenticated as user {player_id}")
else: else:
player_id = str(uuid.uuid4()) logger.debug(f"WebSocket connected anonymously as {connection_id}")
logger.debug(f"WebSocket connected anonymously as {player_id}")
current_room: Room | None = None current_room: Room | None = None
@ -460,12 +482,20 @@ async def websocket_endpoint(websocket: WebSocket):
msg_type = data.get("type") msg_type = data.get("type")
if msg_type == "create_room": if msg_type == "create_room":
# Check concurrent game limit for authenticated users
if auth_user_id and count_user_games(auth_user_id) >= MAX_CONCURRENT_GAMES:
await websocket.send_json({
"type": "error",
"message": f"Maximum {MAX_CONCURRENT_GAMES} concurrent games allowed",
})
continue
player_name = data.get("player_name", "Player") player_name = data.get("player_name", "Player")
# Use authenticated user's name if available # Use authenticated user's name if available
if authenticated_user and authenticated_user.display_name: if authenticated_user and authenticated_user.display_name:
player_name = authenticated_user.display_name player_name = authenticated_user.display_name
room = room_manager.create_room() room = room_manager.create_room()
room.add_player(player_id, player_name, websocket) room.add_player(player_id, player_name, websocket, auth_user_id)
current_room = room current_room = room
await websocket.send_json({ await websocket.send_json({
@ -484,6 +514,14 @@ async def websocket_endpoint(websocket: WebSocket):
room_code = data.get("room_code", "").upper() room_code = data.get("room_code", "").upper()
player_name = data.get("player_name", "Player") player_name = data.get("player_name", "Player")
# Check concurrent game limit for authenticated users
if auth_user_id and count_user_games(auth_user_id) >= MAX_CONCURRENT_GAMES:
await websocket.send_json({
"type": "error",
"message": f"Maximum {MAX_CONCURRENT_GAMES} concurrent games allowed",
})
continue
room = room_manager.get_room(room_code) room = room_manager.get_room(room_code)
if not room: if not room:
await websocket.send_json({ await websocket.send_json({
@ -509,7 +547,7 @@ async def websocket_endpoint(websocket: WebSocket):
# Use authenticated user's name if available # Use authenticated user's name if available
if authenticated_user and authenticated_user.display_name: if authenticated_user and authenticated_user.display_name:
player_name = authenticated_user.display_name player_name = authenticated_user.display_name
room.add_player(player_id, player_name, websocket) room.add_player(player_id, player_name, websocket, auth_user_id)
current_room = room current_room = room
await websocket.send_json({ await websocket.send_json({
@ -744,6 +782,9 @@ async def websocket_endpoint(websocket: WebSocket):
) )
await broadcast_game_state(current_room) await broadcast_game_state(current_room)
# Let client swap animation complete (~550ms), then pause to show result
# Total 1.0s = 550ms animation + 450ms visible pause
await asyncio.sleep(1.0)
await check_and_run_cpu_turn(current_room) await check_and_run_cpu_turn(current_room)
elif msg_type == "discard": elif msg_type == "discard":
@ -782,9 +823,15 @@ async def websocket_endpoint(websocket: WebSocket):
"optional": current_room.game.flip_is_optional, "optional": current_room.game.flip_is_optional,
}) })
else: else:
# Let client animation complete before CPU turn
await asyncio.sleep(0.5)
await check_and_run_cpu_turn(current_room) await check_and_run_cpu_turn(current_room)
else: else:
# Turn ended, check for CPU # Turn ended - let client animation complete before CPU turn
# (player discard swoop animation is ~500ms: 350ms swoop + 150ms settle)
logger.debug(f"Player discarded, waiting 0.5s before CPU turn")
await asyncio.sleep(0.5)
logger.debug(f"Post-discard delay complete, checking for CPU turn")
await check_and_run_cpu_turn(current_room) await check_and_run_cpu_turn(current_room)
elif msg_type == "cancel_draw": elif msg_type == "cancel_draw":
@ -954,9 +1001,11 @@ async def websocket_endpoint(websocket: WebSocket):
}) })
# Clean up the room # Clean up the room
room_code = current_room.code
for cpu in list(current_room.get_cpu_players()): for cpu in list(current_room.get_cpu_players()):
current_room.remove_player(cpu.id) current_room.remove_player(cpu.id)
room_manager.remove_room(current_room.code) cleanup_room_profiles(room_code)
room_manager.remove_room(room_code)
current_room = None current_room = None
except WebSocketDisconnect: except WebSocketDisconnect:
@ -972,12 +1021,12 @@ async def _process_stats_safe(room: Room):
notifications while stats are being processed. notifications while stats are being processed.
""" """
try: try:
# Build mapping - for non-CPU players, the player_id is their user_id # Build mapping - use auth_user_id for authenticated players
# (assigned during authentication or as a session UUID) # Only authenticated players get their stats tracked
player_user_ids = {} player_user_ids = {}
for player_id, room_player in room.players.items(): for player_id, room_player in room.players.items():
if not room_player.is_cpu: if not room_player.is_cpu and room_player.auth_user_id:
player_user_ids[player_id] = player_id player_user_ids[player_id] = room_player.auth_user_id
# Find winner # Find winner
winner_id = None winner_id = None
@ -1095,6 +1144,7 @@ async def check_and_run_cpu_turn(room: Room):
async def handle_player_leave(room: Room, player_id: str): async def handle_player_leave(room: Room, player_id: str):
"""Handle a player leaving a room.""" """Handle a player leaving a room."""
room_code = room.code
room_player = room.remove_player(player_id) room_player = room.remove_player(player_id)
# If no human players left, clean up the room entirely # If no human players left, clean up the room entirely
@ -1102,7 +1152,9 @@ async def handle_player_leave(room: Room, player_id: str):
# Remove all remaining CPU players to release their profiles # Remove all remaining CPU players to release their profiles
for cpu in list(room.get_cpu_players()): for cpu in list(room.get_cpu_players()):
room.remove_player(cpu.id) room.remove_player(cpu.id)
room_manager.remove_room(room.code) # Clean up any remaining profile tracking for this room
cleanup_room_profiles(room_code)
room_manager.remove_room(room_code)
elif room_player: elif room_player:
await room.broadcast({ await room.broadcast({
"type": "player_left", "type": "player_left",

View File

@ -19,7 +19,7 @@ from typing import Optional
from fastapi import WebSocket from fastapi import WebSocket
from ai import assign_profile, assign_specific_profile, get_profile, release_profile from ai import assign_profile, assign_specific_profile, get_profile, release_profile, cleanup_room_profiles
from game import Game, Player from game import Game, Player
@ -33,11 +33,12 @@ class RoomPlayer:
in-game state like cards and scores. in-game state like cards and scores.
Attributes: Attributes:
id: Unique player identifier. id: Unique player identifier (connection_id for multi-tab support).
name: Display name. name: Display name.
websocket: WebSocket connection (None for CPU players). websocket: WebSocket connection (None for CPU players).
is_host: Whether this player controls game settings. is_host: Whether this player controls game settings.
is_cpu: Whether this is an AI-controlled player. is_cpu: Whether this is an AI-controlled player.
auth_user_id: Authenticated user ID for stats/limits (None for guests).
""" """
id: str id: str
@ -45,6 +46,7 @@ class RoomPlayer:
websocket: Optional[WebSocket] = None websocket: Optional[WebSocket] = None
is_host: bool = False is_host: bool = False
is_cpu: bool = False is_cpu: bool = False
auth_user_id: Optional[str] = None
@dataclass @dataclass
@ -73,6 +75,7 @@ class Room:
player_id: str, player_id: str,
name: str, name: str,
websocket: WebSocket, websocket: WebSocket,
auth_user_id: Optional[str] = None,
) -> RoomPlayer: ) -> RoomPlayer:
""" """
Add a human player to the room. Add a human player to the room.
@ -80,9 +83,10 @@ class Room:
The first player to join becomes the host. The first player to join becomes the host.
Args: Args:
player_id: Unique identifier for the player. player_id: Unique identifier for the player (connection_id).
name: Display name. name: Display name.
websocket: The player's WebSocket connection. websocket: The player's WebSocket connection.
auth_user_id: Authenticated user ID for stats/limits (None for guests).
Returns: Returns:
The created RoomPlayer object. The created RoomPlayer object.
@ -93,6 +97,7 @@ class Room:
name=name, name=name,
websocket=websocket, websocket=websocket,
is_host=is_host, is_host=is_host,
auth_user_id=auth_user_id,
) )
self.players[player_id] = room_player self.players[player_id] = room_player
@ -117,9 +122,9 @@ class Room:
The created RoomPlayer, or None if profile unavailable. The created RoomPlayer, or None if profile unavailable.
""" """
if profile_name: if profile_name:
profile = assign_specific_profile(cpu_id, profile_name) profile = assign_specific_profile(cpu_id, profile_name, self.code)
else: else:
profile = assign_profile(cpu_id) profile = assign_profile(cpu_id, self.code)
if not profile: if not profile:
return None return None
@ -157,9 +162,9 @@ class Room:
room_player = self.players.pop(player_id) room_player = self.players.pop(player_id)
self.game.remove_player(player_id) self.game.remove_player(player_id)
# Release CPU profile back to the pool # Release CPU profile back to the room's pool
if room_player.is_cpu: if room_player.is_cpu:
release_profile(room_player.name) release_profile(room_player.name, self.code)
# Assign new host if needed # Assign new host if needed
if room_player.is_host and self.players: if room_player.is_host and self.players:

5
tests/e2e/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
playwright-report/
test-results/
*.db
*.db-journal

255
tests/e2e/bot/actions.ts Normal file
View 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
View 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
View 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
View 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';

View 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(() => '')) || '';
}
}

View File

@ -0,0 +1,231 @@
/**
* Animation tracker - monitors animation completion and timing
*/
import { Page } from '@playwright/test';
import { TIMING } from '../utils/timing';
/**
* Animation event
*/
export interface AnimationEvent {
type: 'start' | 'complete' | 'stall';
animationType?: string;
duration?: number;
timestamp: number;
}
/**
* AnimationTracker - tracks animation states
*/
export class AnimationTracker {
private events: AnimationEvent[] = [];
private animationStartTime: number | null = null;
constructor(private page: Page) {}
/**
* Record animation start
*/
recordStart(type?: string): void {
this.animationStartTime = Date.now();
this.events.push({
type: 'start',
animationType: type,
timestamp: this.animationStartTime,
});
}
/**
* Record animation complete
*/
recordComplete(type?: string): void {
const now = Date.now();
const duration = this.animationStartTime
? now - this.animationStartTime
: undefined;
this.events.push({
type: 'complete',
animationType: type,
duration,
timestamp: now,
});
this.animationStartTime = null;
}
/**
* Record animation stall
*/
recordStall(type?: string): void {
const now = Date.now();
const duration = this.animationStartTime
? now - this.animationStartTime
: undefined;
this.events.push({
type: 'stall',
animationType: type,
duration,
timestamp: now,
});
}
/**
* Check if animation queue is animating
*/
async isAnimating(): Promise<boolean> {
try {
return await this.page.evaluate(() => {
const game = (window as any).game;
return game?.animationQueue?.isAnimating() ?? false;
});
} catch {
return false;
}
}
/**
* Get animation queue length
*/
async getQueueLength(): Promise<number> {
try {
return await this.page.evaluate(() => {
const game = (window as any).game;
return game?.animationQueue?.queue?.length ?? 0;
});
} catch {
return 0;
}
}
/**
* Wait for animation to complete with tracking
*/
async waitForAnimation(
type: string,
timeoutMs: number = 5000
): Promise<{ completed: boolean; duration: number }> {
this.recordStart(type);
const startTime = Date.now();
try {
await this.page.waitForFunction(
() => {
const game = (window as any).game;
if (!game?.animationQueue) return true;
return !game.animationQueue.isAnimating();
},
{ timeout: timeoutMs }
);
const duration = Date.now() - startTime;
this.recordComplete(type);
return { completed: true, duration };
} catch {
const duration = Date.now() - startTime;
this.recordStall(type);
return { completed: false, duration };
}
}
/**
* Wait for specific animation type by watching DOM changes
*/
async waitForFlipAnimation(timeoutMs: number = 2000): Promise<boolean> {
return this.waitForAnimationClass('flipping', timeoutMs);
}
async waitForSwapAnimation(timeoutMs: number = 3000): Promise<boolean> {
return this.waitForAnimationClass('swap-animation', timeoutMs);
}
/**
* Wait for animation class to appear and disappear
*/
private async waitForAnimationClass(
className: string,
timeoutMs: number
): Promise<boolean> {
try {
// Wait for class to appear
await this.page.waitForSelector(`.${className}`, {
state: 'attached',
timeout: timeoutMs / 2,
});
// Wait for class to disappear (animation complete)
await this.page.waitForSelector(`.${className}`, {
state: 'detached',
timeout: timeoutMs / 2,
});
return true;
} catch {
return false;
}
}
/**
* Get animation events
*/
getEvents(): AnimationEvent[] {
return [...this.events];
}
/**
* Get stall events
*/
getStalls(): AnimationEvent[] {
return this.events.filter(e => e.type === 'stall');
}
/**
* Get average animation duration by type
*/
getAverageDuration(type?: string): number | null {
const completed = this.events.filter(e =>
e.type === 'complete' &&
e.duration !== undefined &&
(!type || e.animationType === type)
);
if (completed.length === 0) return null;
const total = completed.reduce((sum, e) => sum + (e.duration || 0), 0);
return total / completed.length;
}
/**
* Check if animations are within expected timing
*/
validateTiming(
type: string,
expectedMs: number,
tolerancePercent: number = 50
): { valid: boolean; actual: number | null } {
const avgDuration = this.getAverageDuration(type);
if (avgDuration === null) {
return { valid: true, actual: null };
}
const tolerance = expectedMs * (tolerancePercent / 100);
const minOk = expectedMs - tolerance;
const maxOk = expectedMs + tolerance;
return {
valid: avgDuration >= minOk && avgDuration <= maxOk,
actual: avgDuration,
};
}
/**
* Clear tracked events
*/
clear(): void {
this.events = [];
this.animationStartTime = null;
}
}

View File

@ -0,0 +1,209 @@
/**
* Freeze detector - monitors for UI responsiveness issues
*/
import { Page } from '@playwright/test';
import { SELECTORS } from '../utils/selectors';
import { TIMING } from '../utils/timing';
/**
* Health check result
*/
export interface HealthCheck {
healthy: boolean;
issues: HealthIssue[];
}
export interface HealthIssue {
type: 'animation_stall' | 'websocket_closed' | 'console_error' | 'unresponsive';
message: string;
timestamp: number;
}
/**
* FreezeDetector - monitors UI health
*/
export class FreezeDetector {
private issues: HealthIssue[] = [];
private consoleErrors: string[] = [];
private wsState: number | null = null;
constructor(private page: Page) {
// Monitor console errors
page.on('console', msg => {
if (msg.type() === 'error') {
const text = msg.text();
this.consoleErrors.push(text);
this.addIssue('console_error', text);
}
});
page.on('pageerror', err => {
this.consoleErrors.push(err.message);
this.addIssue('console_error', err.message);
});
}
/**
* Add a health issue
*/
private addIssue(type: HealthIssue['type'], message: string): void {
this.issues.push({
type,
message,
timestamp: Date.now(),
});
}
/**
* Clear recorded issues
*/
clearIssues(): void {
this.issues = [];
this.consoleErrors = [];
}
/**
* Get all recorded issues
*/
getIssues(): HealthIssue[] {
return [...this.issues];
}
/**
* Get recent issues (within timeframe)
*/
getRecentIssues(withinMs: number = 10000): HealthIssue[] {
const cutoff = Date.now() - withinMs;
return this.issues.filter(i => i.timestamp > cutoff);
}
/**
* Check for animation stall
*/
async checkAnimationStall(timeoutMs: number = 5000): Promise<boolean> {
try {
await this.page.waitForFunction(
() => {
const game = (window as any).game;
if (!game?.animationQueue) return true;
return !game.animationQueue.isAnimating();
},
{ timeout: timeoutMs }
);
return false; // No stall
} catch {
this.addIssue('animation_stall', `Animation did not complete within ${timeoutMs}ms`);
return true; // Stalled
}
}
/**
* Check WebSocket health
*/
async checkWebSocket(): Promise<boolean> {
try {
const state = await this.page.evaluate(() => {
const game = (window as any).game;
return game?.ws?.readyState;
});
this.wsState = state;
// WebSocket.OPEN = 1
if (state !== 1) {
const stateNames: Record<number, string> = {
0: 'CONNECTING',
1: 'OPEN',
2: 'CLOSING',
3: 'CLOSED',
};
this.addIssue('websocket_closed', `WebSocket is ${stateNames[state] || 'UNKNOWN'}`);
return false;
}
return true;
} catch (error) {
this.addIssue('websocket_closed', `Failed to check WebSocket: ${error}`);
return false;
}
}
/**
* Check if element is responsive to clicks
*/
async checkClickResponsiveness(
selector: string,
timeoutMs: number = 2000
): Promise<boolean> {
try {
const el = this.page.locator(selector);
if (!await el.isVisible()) {
return true; // Element not visible is not necessarily an issue
}
// Check if element is clickable
await el.click({ timeout: timeoutMs, trial: true });
return true;
} catch {
this.addIssue('unresponsive', `Element ${selector} not responsive`);
return false;
}
}
/**
* Run full health check
*/
async runHealthCheck(): Promise<HealthCheck> {
const animationOk = !(await this.checkAnimationStall());
const wsOk = await this.checkWebSocket();
const healthy = animationOk && wsOk && this.consoleErrors.length === 0;
return {
healthy,
issues: this.getRecentIssues(),
};
}
/**
* Monitor game loop for issues
* Returns when an issue is detected or timeout
*/
async monitorUntilIssue(
timeoutMs: number = 60000,
checkIntervalMs: number = 500
): Promise<HealthIssue | null> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
// Check animation
const animStall = await this.checkAnimationStall(3000);
if (animStall) {
return this.issues[this.issues.length - 1];
}
// Check WebSocket
const wsOk = await this.checkWebSocket();
if (!wsOk) {
return this.issues[this.issues.length - 1];
}
// Check for new console errors
if (this.consoleErrors.length > 0) {
return this.issues[this.issues.length - 1];
}
await this.page.waitForTimeout(checkIntervalMs);
}
return null;
}
/**
* Get console errors
*/
getConsoleErrors(): string[] {
return [...this.consoleErrors];
}
}

View File

@ -0,0 +1,2 @@
export { FreezeDetector, HealthCheck, HealthIssue } from './freeze-detector';
export { AnimationTracker, AnimationEvent } from './animation-tracker';

111
tests/e2e/package-lock.json generated Normal file
View File

@ -0,0 +1,111 @@
{
"name": "golf-e2e-tests",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "golf-e2e-tests",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/node": "^20.10.0",
"typescript": "^5.3.0"
}
},
"node_modules/@playwright/test": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz",
"integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "20.19.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz",
"integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

22
tests/e2e/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "golf-e2e-tests",
"version": "1.0.0",
"description": "End-to-end tests for Golf Card Game",
"private": true,
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:debug": "playwright test --debug",
"test:ui": "playwright test --ui",
"test:full-game": "playwright test specs/full-game.spec.ts",
"test:visual": "playwright test specs/visual.spec.ts",
"test:stress": "playwright test specs/stress.spec.ts",
"test:report": "playwright show-report",
"install:browsers": "playwright install chromium"
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/node": "^20.10.0",
"typescript": "^5.3.0"
}
}

View File

@ -0,0 +1,36 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './specs',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { outputFolder: 'playwright-report' }],
['list'],
],
use: {
baseURL: process.env.TEST_URL || 'http://localhost:8000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'mobile',
use: { ...devices['iPhone 13'] },
},
{
name: 'tablet',
use: { ...devices['iPad Pro 11'] },
},
],
// Note: The server must be running before tests
// Start it with: cd ../.. && python server/main.py
// webServer config removed because it requires PostgreSQL which may not be available
});

View File

@ -0,0 +1,253 @@
/**
* Full game playthrough tests
* Tests complete game sessions with the bot
*/
import { test, expect } from '@playwright/test';
import { GolfBot } from '../bot/golf-bot';
import { FreezeDetector } from '../health/freeze-detector';
import { ScreenshotValidator } from '../visual/screenshot-validator';
test.describe('Full Game Playthrough', () => {
test('bot completes 3-hole game against CPU', async ({ page }) => {
test.setTimeout(180000); // 3 minutes for 3-hole game
const bot = new GolfBot(page);
const freezeDetector = new FreezeDetector(page);
const validator = new ScreenshotValidator(page);
// Navigate to game
await bot.goto();
// Create game and add CPU
const roomCode = await bot.createGame('TestBot');
expect(roomCode).toHaveLength(4);
await bot.addCPU('Sofia');
// Take screenshot of waiting room
await validator.capture('waiting-room');
// Start game with 3 holes
await bot.startGame({ holes: 3 });
// Verify game started
const phase = await bot.getGamePhase();
expect(['initial_flip', 'playing']).toContain(phase);
// Take screenshot of game start
await validator.capture('game-start', phase);
// Play through the entire game
const result = await bot.playGame(3);
// Take final screenshot
await validator.capture('game-over', 'game_over');
// Verify game completed
expect(result.success).toBe(true);
expect(result.rounds).toBeGreaterThanOrEqual(1);
// Check for errors
const errors = bot.getConsoleErrors();
expect(errors).toHaveLength(0);
// Verify no freezes occurred
const health = await freezeDetector.runHealthCheck();
expect(health.healthy).toBe(true);
});
test('bot completes 9-hole game against CPU', async ({ page }) => {
test.setTimeout(900000); // 15 minutes for 9-hole game
const bot = new GolfBot(page);
const freezeDetector = new FreezeDetector(page);
await bot.goto();
const roomCode = await bot.createGame('TestBot');
await bot.addCPU('Marcus');
await bot.startGame({ holes: 9 });
// Play full game
const result = await bot.playGame(9);
expect(result.success).toBe(true);
expect(result.rounds).toBe(9);
// Verify game ended properly
const finalPhase = await bot.getGamePhase();
expect(finalPhase).toBe('game_over');
// Check health
const health = await freezeDetector.runHealthCheck();
expect(health.healthy).toBe(true);
});
test('bot handles initial flip phase correctly', async ({ page }) => {
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1, initialFlips: 2 });
// Wait for initial flip phase
await page.waitForTimeout(500);
// Take screenshot before flips
await validator.capture('before-initial-flip');
// Complete initial flips
await bot.completeInitialFlips();
// Take screenshot after flips
await validator.capture('after-initial-flip');
// Verify 2 cards are face-up
const state = await bot.getGameState();
const faceUpCount = state.myPlayer?.cards.filter(c => c.faceUp).length || 0;
expect(faceUpCount).toBeGreaterThanOrEqual(2);
});
test('bot recovers from rapid turn changes', async ({ page }) => {
test.setTimeout(90000); // 90 seconds
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('TestBot');
// Add multiple fast CPUs
await bot.addCPU('Maya'); // Aggressive
await bot.addCPU('Sage'); // Sneaky finisher
await bot.startGame({ holes: 1 });
// Play with health monitoring
let frozenCount = 0;
let turnCount = 0;
while (await bot.getGamePhase() !== 'round_over' && turnCount < 50) {
if (await bot.isMyTurn()) {
const result = await bot.playTurn();
expect(result.success).toBe(true);
turnCount++;
}
// Check for freeze
if (await bot.isFrozen(2000)) {
frozenCount++;
}
await page.waitForTimeout(100);
}
// Should not have frozen
expect(frozenCount).toBe(0);
});
test('game handles all players finishing', async ({ page }) => {
test.setTimeout(90000); // 90 seconds for single round
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
// Play until round over
const roundResult = await bot.playRound(100);
expect(roundResult.success).toBe(true);
// Take screenshot of round end
await validator.capture('round-end');
// Verify all player cards are revealed
const state = await bot.getGameState();
const allRevealed = state.myPlayer?.cards.every(c => c.faceUp) ?? false;
expect(allRevealed).toBe(true);
// Verify scoreboard is visible
const scoreboardVisible = await validator.expectVisible('#game-buttons');
expect(scoreboardVisible.passed).toBe(true);
});
});
test.describe('Game Settings', () => {
test('Speed Golf mode (flip on discard)', async ({ page }) => {
test.setTimeout(90000); // 90 seconds
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
// Start with Speed Golf (always flip)
await bot.startGame({
holes: 1,
flipMode: 'always',
});
// Play through
const result = await bot.playRound(50);
expect(result.success).toBe(true);
// No errors should occur
expect(bot.getConsoleErrors()).toHaveLength(0);
});
test('Endgame mode (optional flip)', async ({ page }) => {
test.setTimeout(90000); // 90 seconds
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
// Start with Endgame mode
await bot.startGame({
holes: 1,
flipMode: 'endgame',
});
// Play through
const result = await bot.playRound(50);
expect(result.success).toBe(true);
expect(bot.getConsoleErrors()).toHaveLength(0);
});
test('Multiple decks with many players', async ({ page }) => {
test.setTimeout(90000);
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('TestBot');
// Add 4 CPUs (5 total players)
await bot.addCPU('Sofia');
await bot.addCPU('Marcus');
await bot.addCPU('Maya');
await bot.addCPU('Kenji');
// Start with 2 decks
await bot.startGame({
holes: 1,
decks: 2,
});
// Play through
const result = await bot.playRound(100);
expect(result.success).toBe(true);
expect(bot.getConsoleErrors()).toHaveLength(0);
});
});

View File

@ -0,0 +1,401 @@
/**
* Stress tests
* Tests for race conditions, memory leaks, and edge cases
*/
import { test, expect } from '@playwright/test';
import { GolfBot } from '../bot/golf-bot';
import { FreezeDetector } from '../health/freeze-detector';
import { AnimationTracker } from '../health/animation-tracker';
test.describe('Stress Tests', () => {
test('rapid action sequence (race condition detection)', async ({ page }) => {
const bot = new GolfBot(page);
const freezeDetector = new FreezeDetector(page);
await bot.goto();
await bot.createGame('StressBot');
await bot.addCPU('Maya'); // Aggressive, fast player
await bot.startGame({ holes: 1 });
await bot.completeInitialFlips();
let actionCount = 0;
let errorCount = 0;
// Rapid turns with minimal delays
while (await bot.getGamePhase() !== 'round_over' && actionCount < 100) {
if (await bot.isMyTurn()) {
// Reduce normal waits
const state = await bot.getGameState();
if (!state.heldCard.visible) {
// Quick draw
const deck = page.locator('#deck');
await deck.click({ timeout: 1000 }).catch(() => { errorCount++; });
await page.waitForTimeout(100);
} else {
// Quick swap or discard
const faceDown = state.myPlayer?.cards.find(c => !c.faceUp);
if (faceDown) {
const card = page.locator(`#player-cards .card:nth-child(${faceDown.position + 1})`);
await card.click({ timeout: 1000 }).catch(() => { errorCount++; });
} else {
const discardBtn = page.locator('#discard-btn');
await discardBtn.click({ timeout: 1000 }).catch(() => { errorCount++; });
}
await page.waitForTimeout(100);
}
actionCount++;
} else {
await page.waitForTimeout(50);
}
// Check for freezes
if (await bot.isFrozen(2000)) {
console.warn(`Freeze detected at action ${actionCount}`);
break;
}
}
// Verify no critical errors
const health = await freezeDetector.runHealthCheck();
expect(health.issues.filter(i => i.type === 'websocket_closed')).toHaveLength(0);
// Some click errors are acceptable (timing issues), but not too many
expect(errorCount).toBeLessThan(10);
console.log(`Completed ${actionCount} rapid actions with ${errorCount} minor errors`);
});
test('multiple games in succession (memory leak detection)', async ({ page }) => {
test.setTimeout(300000); // 5 minutes
const bot = new GolfBot(page);
const gamesCompleted: number[] = [];
// Get initial memory if available
const getMemory = async () => {
try {
return await page.evaluate(() => {
if ('memory' in performance) {
return (performance as any).memory.usedJSHeapSize;
}
return null;
});
} catch {
return null;
}
};
const initialMemory = await getMemory();
console.log(`Initial memory: ${initialMemory ? Math.round(initialMemory / 1024 / 1024) + 'MB' : 'N/A'}`);
// Play 10 quick games
for (let game = 0; game < 10; game++) {
await bot.goto();
await bot.createGame(`MemBot${game}`);
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
const result = await bot.playRound(50);
if (result.success) {
gamesCompleted.push(game);
}
// Check memory every few games
if (game % 3 === 2) {
const currentMemory = await getMemory();
if (currentMemory) {
console.log(`Game ${game + 1}: memory = ${Math.round(currentMemory / 1024 / 1024)}MB`);
}
}
// Clear any accumulated errors
bot.clearConsoleErrors();
}
// Should complete most games
expect(gamesCompleted.length).toBeGreaterThanOrEqual(8);
// Final memory check
const finalMemory = await getMemory();
if (initialMemory && finalMemory) {
const memoryGrowth = finalMemory - initialMemory;
console.log(`Memory growth: ${Math.round(memoryGrowth / 1024 / 1024)}MB`);
// Memory shouldn't grow excessively (allow 50MB growth for 10 games)
expect(memoryGrowth).toBeLessThan(50 * 1024 * 1024);
}
});
test('6-player game with 5 CPUs (max players)', async ({ page }) => {
test.setTimeout(180000); // 3 minutes
const bot = new GolfBot(page);
const freezeDetector = new FreezeDetector(page);
await bot.goto();
await bot.createGame('TestBot');
// Add 5 CPU players (max typical setup)
await bot.addCPU('Sofia');
await bot.addCPU('Maya');
await bot.addCPU('Priya');
await bot.addCPU('Marcus');
await bot.addCPU('Kenji');
// Start with 2 decks (recommended for 6 players)
await bot.startGame({
holes: 3,
decks: 2,
});
// Play through all rounds
const result = await bot.playGame(3);
expect(result.success).toBe(true);
expect(result.rounds).toBe(3);
// Check for issues
const health = await freezeDetector.runHealthCheck();
expect(health.healthy).toBe(true);
console.log(`6-player game completed in ${result.totalTurns} turns`);
});
test('animation queue under load', async ({ page }) => {
const bot = new GolfBot(page);
const animTracker = new AnimationTracker(page);
await bot.goto();
await bot.createGame('AnimBot');
await bot.addCPU('Maya'); // Fast player
await bot.addCPU('Sage'); // Sneaky finisher
await bot.startGame({ holes: 1 });
await bot.completeInitialFlips();
let animationCount = 0;
let stallCount = 0;
while (await bot.getGamePhase() !== 'round_over' && animationCount < 50) {
if (await bot.isMyTurn()) {
// Track animation timing
animTracker.recordStart('turn');
await bot.playTurn();
const result = await animTracker.waitForAnimation('turn', 5000);
if (!result.completed) {
stallCount++;
}
animationCount++;
}
await page.waitForTimeout(100);
}
// Check animation timing is reasonable
const avgDuration = animTracker.getAverageDuration('turn');
console.log(`Average turn animation: ${avgDuration?.toFixed(0) || 'N/A'}ms`);
// Stalls should be rare
expect(stallCount).toBeLessThan(3);
// Check stall events
const stalls = animTracker.getStalls();
if (stalls.length > 0) {
console.log(`Animation stalls:`, stalls);
}
});
test('websocket reconnection handling', async ({ page }) => {
const bot = new GolfBot(page);
const freezeDetector = new FreezeDetector(page);
await bot.goto();
await bot.createGame('ReconnectBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
await bot.completeInitialFlips();
// Play a few turns
for (let i = 0; i < 3; i++) {
await bot.waitForMyTurn(10000);
if (await bot.isMyTurn()) {
await bot.playTurn();
}
}
// Check WebSocket is healthy
const wsHealthy = await freezeDetector.checkWebSocket();
expect(wsHealthy).toBe(true);
// Note: Actually closing/reopening websocket would require
// server cooperation or network manipulation
});
test('concurrent clicks during animation', async ({ page }) => {
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('ClickBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
await bot.completeInitialFlips();
await bot.waitForMyTurn(10000);
// Draw a card
const deck = page.locator('#deck');
await deck.click();
await page.waitForTimeout(200);
// Try rapid clicks on multiple elements while animation might be running
const clickPromises: Promise<void>[] = [];
for (let i = 0; i < 6; i++) {
const card = page.locator(`#player-cards .card:nth-child(${i + 1})`);
clickPromises.push(
card.click({ timeout: 500 }).catch(() => {})
);
}
// Wait for all clicks to complete or timeout
await Promise.all(clickPromises);
// Wait for any animations
await page.waitForTimeout(2000);
// Game should still be in a valid state
const phase = await bot.getGamePhase();
expect(['playing', 'waiting_for_flip', 'round_over']).toContain(phase);
// No console errors
const errors = bot.getConsoleErrors();
expect(errors.filter(e => e.includes('undefined') || e.includes('null'))).toHaveLength(0);
});
});
test.describe('Edge Cases', () => {
test('all cards revealed simultaneously', async ({ page }) => {
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('EdgeBot');
await bot.addCPU('Sofia');
// Start with Speed Golf (flip on discard) to reveal cards faster
await bot.startGame({
holes: 1,
flipMode: 'always',
initialFlips: 2,
});
// Play until we trigger round end
const result = await bot.playRound(100);
expect(result.success).toBe(true);
// Verify game handled the transition
const phase = await bot.getGamePhase();
expect(phase).toBe('round_over');
});
test('deck reshuffle scenario', async ({ page }) => {
test.setTimeout(180000); // 3 minutes for longer game
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('ShuffleBot');
// Add many players to deplete deck faster
await bot.addCPU('Sofia');
await bot.addCPU('Maya');
await bot.addCPU('Marcus');
await bot.addCPU('Kenji');
// Use only 1 deck to force reshuffle
await bot.startGame({
holes: 1,
decks: 1,
});
// Play through - deck should reshuffle during game
const result = await bot.playRound(200);
expect(result.success).toBe(true);
});
test('empty discard pile handling', async ({ page }) => {
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('EmptyBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
// At game start, discard might be empty briefly
const initialState = await bot.getGameState();
// Game should still function
await bot.completeInitialFlips();
await bot.waitForMyTurn(10000);
// Should be able to draw from deck even if discard is empty
if (await bot.isMyTurn()) {
const state = await bot.getGameState();
if (!state.discard.hasCard) {
// Draw from deck should work
const deck = page.locator('#deck');
await deck.click();
await page.waitForTimeout(500);
// Should have a held card now
const afterState = await bot.getGameState();
expect(afterState.heldCard.visible).toBe(true);
}
}
});
test('final turn badge timing', async ({ page }) => {
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('BadgeBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
// Monitor for final turn badge
let sawFinalTurnBadge = false;
let turnsAfterBadge = 0;
while (await bot.getGamePhase() !== 'round_over') {
const state = await bot.getGameState();
if (state.isFinalTurn) {
sawFinalTurnBadge = true;
}
if (sawFinalTurnBadge && await bot.isMyTurn()) {
turnsAfterBadge++;
}
if (await bot.isMyTurn()) {
await bot.playTurn();
}
await page.waitForTimeout(100);
}
// If final turn happened, we should have had at most 1 turn after badge appeared
// (this depends on whether we're the one who triggered final turn)
if (sawFinalTurnBadge) {
expect(turnsAfterBadge).toBeLessThanOrEqual(2);
}
});
});

View File

@ -0,0 +1,348 @@
/**
* Visual regression tests
* Validates visual correctness at key game moments
*/
import { test, expect, devices } from '@playwright/test';
import { GolfBot } from '../bot/golf-bot';
import { ScreenshotValidator } from '../visual/screenshot-validator';
import {
validateGameStart,
validateAfterInitialFlip,
validateDrawPhase,
validateAfterDraw,
validateRoundOver,
validateFinalTurn,
validateResponsiveLayout,
} from '../visual/visual-rules';
test.describe('Visual Validation', () => {
test('game start visual state', async ({ page }) => {
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
// Wait for game to fully render
await page.waitForTimeout(1000);
// Capture game start
await validator.capture('game-start-visual');
// Validate visual state
const result = await validateGameStart(validator);
expect(result.passed).toBe(true);
if (!result.passed) {
console.log('Failures:', result.failures);
}
});
test('initial flip visual state', async ({ page }) => {
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1, initialFlips: 2 });
// Complete initial flips
await bot.completeInitialFlips();
await page.waitForTimeout(500);
// Capture after flips
await validator.capture('after-initial-flip-visual');
// Validate
const result = await validateAfterInitialFlip(validator, 2);
expect(result.passed).toBe(true);
if (!result.passed) {
console.log('Failures:', result.failures);
}
});
test('draw phase visual state', async ({ page }) => {
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
// Complete initial phase and wait for our turn
await bot.completeInitialFlips();
// Wait for our turn
await bot.waitForMyTurn(10000);
// Capture draw phase
await validator.capture('draw-phase-visual');
// Validate
const result = await validateDrawPhase(validator);
expect(result.passed).toBe(true);
if (!result.passed) {
console.log('Failures:', result.failures);
}
});
test('held card visual state', async ({ page }) => {
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
await bot.completeInitialFlips();
await bot.waitForMyTurn(10000);
// Draw a card
const deck = page.locator('#deck');
await deck.click();
// Wait for draw animation
await page.waitForTimeout(500);
// Capture held card state
await validator.capture('held-card-visual');
// Validate held card is visible
const heldResult = await validator.expectHeldCardVisible();
expect(heldResult.passed).toBe(true);
// Validate cards are clickable
const clickableResult = await validator.expectCount(
'#player-cards .card.clickable',
6
);
expect(clickableResult.passed).toBe(true);
});
test('round over visual state', async ({ page }) => {
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
// Play until round over
await bot.playRound(50);
// Wait for animations
await page.waitForTimeout(1000);
// Capture round over
await validator.capture('round-over-visual');
// Validate
const result = await validateRoundOver(validator);
expect(result.passed).toBe(true);
if (!result.passed) {
console.log('Failures:', result.failures);
}
});
test('card flip animation renders correctly', async ({ page }) => {
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1, initialFlips: 0 }); // No initial flips
// Wait for our turn
await bot.waitForMyTurn(10000);
// Capture before flip
await validator.capture('before-flip');
// Get a face-down card position
const state = await bot.getGameState();
const faceDownPos = state.myPlayer?.cards.find(c => !c.faceUp)?.position ?? 0;
// Draw and swap to trigger flip
const deck = page.locator('#deck');
await deck.click();
await page.waitForTimeout(300);
// Click the face-down card to swap
const card = page.locator(`#player-cards .card:nth-child(${faceDownPos + 1})`);
await card.click();
// Wait for animation to complete
await page.waitForTimeout(1500);
// Capture after flip
await validator.capture('after-flip');
// Verify card is now face-up
const afterState = await bot.getGameState();
const cardAfter = afterState.myPlayer?.cards.find(c => c.position === faceDownPos);
expect(cardAfter?.faceUp).toBe(true);
});
test('opponent highlighting on their turn', async ({ page }) => {
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
await bot.completeInitialFlips();
// Play our turn and then wait for opponent's turn
if (await bot.isMyTurn()) {
await bot.playTurn();
}
// Wait for opponent turn indicator
await page.waitForTimeout(1000);
// Check if it's opponent's turn
const state = await bot.getGameState();
const opponentPlaying = state.opponents.some(o => o.isCurrentTurn);
if (opponentPlaying) {
// Capture opponent turn
await validator.capture('opponent-turn-visual');
// Find which opponent has current turn
const currentOpponentIndex = state.opponents.findIndex(o => o.isCurrentTurn);
if (currentOpponentIndex >= 0) {
const result = await validator.expectOpponentCurrentTurn(currentOpponentIndex);
expect(result.passed).toBe(true);
}
}
});
test('discard pile updates correctly', async ({ page }) => {
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
await bot.completeInitialFlips();
await bot.waitForMyTurn(10000);
// Get initial discard state
const beforeState = await bot.getGameState();
const beforeDiscard = beforeState.discard.topCard;
// Draw from deck and discard
const deck = page.locator('#deck');
await deck.click();
await page.waitForTimeout(500);
// Get the held card
const heldCard = (await bot.getGameState()).heldCard.card;
// Discard the drawn card
const discardBtn = page.locator('#discard-btn');
await discardBtn.click();
await page.waitForTimeout(800);
// Capture after discard
await validator.capture('after-discard-visual');
// Verify discard pile has the card we discarded
const afterState = await bot.getGameState();
expect(afterState.discard.hasCard).toBe(true);
});
});
test.describe('Responsive Layout', () => {
test('mobile layout (375px)', async ({ browser }) => {
const context = await browser.newContext({
...devices['iPhone 13'],
});
const page = await context.newPage();
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
await page.waitForTimeout(1000);
// Capture mobile layout
await validator.capture('mobile-375-layout');
// Validate responsive elements
const result = await validateResponsiveLayout(validator, 375);
expect(result.passed).toBe(true);
if (!result.passed) {
console.log('Mobile failures:', result.failures);
}
await context.close();
});
test('tablet layout (768px)', async ({ browser }) => {
const context = await browser.newContext({
viewport: { width: 768, height: 1024 },
});
const page = await context.newPage();
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
await page.waitForTimeout(1000);
// Capture tablet layout
await validator.capture('tablet-768-layout');
// Validate responsive elements
const result = await validateResponsiveLayout(validator, 768);
expect(result.passed).toBe(true);
await context.close();
});
test('desktop layout (1920px)', async ({ browser }) => {
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
});
const page = await context.newPage();
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
await page.waitForTimeout(1000);
// Capture desktop layout
await validator.capture('desktop-1920-layout');
// Validate responsive elements
const result = await validateResponsiveLayout(validator, 1920);
expect(result.passed).toBe(true);
await context.close();
});
});

32
tests/e2e/tsconfig.json Normal file
View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"declarationMap": false,
"sourceMap": true,
"outDir": "./dist",
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@bot/*": ["bot/*"],
"@health/*": ["health/*"],
"@visual/*": ["visual/*"],
"@utils/*": ["utils/*"]
}
},
"include": [
"**/*.ts"
],
"exclude": [
"node_modules",
"dist",
"playwright-report"
]
}

12
tests/e2e/utils/index.ts Normal file
View File

@ -0,0 +1,12 @@
export {
TIMING,
waitForAnimations,
waitForWebSocket,
safeWait,
} from './timing';
export {
SELECTORS,
playerCardSelector,
clickableCardSelector,
opponentCardSelector,
} from './selectors';

View File

@ -0,0 +1,157 @@
/**
* DOM selector constants for the Golf game
* Extracted from client/index.html and client/app.js
*/
export const SELECTORS = {
// Screens
screens: {
lobby: '#lobby-screen',
waiting: '#waiting-screen',
game: '#game-screen',
rules: '#rules-screen',
},
// Lobby elements
lobby: {
playerNameInput: '#player-name',
roomCodeInput: '#room-code',
createRoomBtn: '#create-room-btn',
joinRoomBtn: '#join-room-btn',
error: '#lobby-error',
},
// Waiting room elements
waiting: {
roomCode: '#display-room-code',
copyCodeBtn: '#copy-room-code',
shareBtn: '#share-room-link',
playersList: '#players-list',
hostSettings: '#host-settings',
startGameBtn: '#start-game-btn',
leaveRoomBtn: '#leave-room-btn',
addCpuBtn: '#add-cpu-btn',
removeCpuBtn: '#remove-cpu-btn',
cpuModal: '#cpu-select-modal',
cpuProfilesGrid: '#cpu-profiles-grid',
cancelCpuBtn: '#cancel-cpu-btn',
addSelectedCpusBtn: '#add-selected-cpus-btn',
// Settings
numDecks: '#num-decks',
numRounds: '#num-rounds',
initialFlips: '#initial-flips',
flipMode: '#flip-mode',
knockPenalty: '#knock-penalty',
},
// Game screen elements
game: {
// Header
currentRound: '#current-round',
totalRounds: '#total-rounds',
statusMessage: '#status-message',
finalTurnBadge: '#final-turn-badge',
muteBtn: '#mute-btn',
leaveGameBtn: '#leave-game-btn',
activeRulesBar: '#active-rules-bar',
// Table
opponentsRow: '#opponents-row',
playerArea: '.player-area',
playerCards: '#player-cards',
playerHeader: '#player-header',
yourScore: '#your-score',
// Deck and discard
deckArea: '.deck-area',
deck: '#deck',
discard: '#discard',
discardContent: '#discard-content',
discardBtn: '#discard-btn',
skipFlipBtn: '#skip-flip-btn',
knockEarlyBtn: '#knock-early-btn',
// Held card
heldCardSlot: '#held-card-slot',
heldCardDisplay: '#held-card-display',
heldCardFloating: '#held-card-floating',
heldCardFloatingContent: '#held-card-floating-content',
// Scoreboard
scoreboard: '#scoreboard',
scoreTable: '#score-table tbody',
standingsList: '#standings-list',
nextRoundBtn: '#next-round-btn',
newGameBtn: '#new-game-btn',
gameButtons: '#game-buttons',
// Card layer for animations
cardLayer: '#card-layer',
},
// Card-related selectors
cards: {
// Player's own cards (0-5)
playerCard: (index: number) => `#player-cards .card:nth-child(${index + 1})`,
playerCardSlot: (index: number) => `#player-cards .card-slot:nth-child(${index + 1})`,
// Opponent cards
opponentArea: (index: number) => `.opponent-area:nth-child(${index + 1})`,
opponentCard: (oppIndex: number, cardIndex: number) =>
`.opponent-area:nth-child(${oppIndex + 1}) .card-grid .card:nth-child(${cardIndex + 1})`,
// Card states
faceUp: '.card-front',
faceDown: '.card-back',
clickable: '.clickable',
selected: '.selected',
},
// CSS classes for state detection
classes: {
active: 'active',
hidden: 'hidden',
clickable: 'clickable',
selected: 'selected',
faceUp: 'card-front',
faceDown: 'card-back',
red: 'red',
black: 'black',
joker: 'joker',
currentTurn: 'current-turn',
roundWinner: 'round-winner',
yourTurnToDraw: 'your-turn-to-draw',
hasCard: 'has-card',
pickedUp: 'picked-up',
disabled: 'disabled',
},
// Animation-related
animations: {
swapAnimation: '#swap-animation',
swapCardFromHand: '#swap-card-from-hand',
animCard: '.anim-card',
realCard: '.real-card',
},
};
/**
* Build a selector for a card in the player's grid
*/
export function playerCardSelector(position: number): string {
return SELECTORS.cards.playerCard(position);
}
/**
* Build a selector for a clickable card
*/
export function clickableCardSelector(position: number): string {
return `${SELECTORS.cards.playerCard(position)}.${SELECTORS.classes.clickable}`;
}
/**
* Build a selector for an opponent's card
*/
export function opponentCardSelector(opponentIndex: number, cardPosition: number): string {
return SELECTORS.cards.opponentCard(opponentIndex, cardPosition);
}

71
tests/e2e/utils/timing.ts Normal file
View File

@ -0,0 +1,71 @@
/**
* Animation timing constants from animation-queue.js
* Used to wait for animations to complete before asserting state
*/
export const TIMING = {
// Core animation durations (from CSS/animation-queue.js)
flipDuration: 540,
moveDuration: 270,
pauseAfterFlip: 144,
pauseAfterDiscard: 550,
pauseBeforeNewCard: 150,
pauseAfterSwapComplete: 400,
pauseBetweenAnimations: 90,
// Derived waits for test actions
get flipComplete() {
return this.flipDuration + this.pauseAfterFlip + 100;
},
get swapComplete() {
return this.flipDuration + this.pauseAfterFlip + this.moveDuration +
this.pauseAfterDiscard + this.pauseBeforeNewCard +
this.moveDuration + this.pauseAfterSwapComplete + 200;
},
get drawComplete() {
return this.moveDuration + this.pauseBeforeNewCard + 100;
},
// Safety margins for network/processing
networkBuffer: 200,
safetyMargin: 300,
// Longer waits
turnTransition: 500,
cpuThinkingMin: 400,
cpuThinkingMax: 1200,
roundOverDelay: 1000,
};
/**
* Wait for animation queue to drain
*/
export async function waitForAnimations(page: import('@playwright/test').Page, timeout = 5000): Promise<void> {
await page.waitForFunction(
() => {
const game = (window as any).game;
if (!game?.animationQueue) return true;
return !game.animationQueue.isAnimating();
},
{ timeout }
);
}
/**
* Wait for WebSocket to be ready
*/
export async function waitForWebSocket(page: import('@playwright/test').Page, timeout = 5000): Promise<void> {
await page.waitForFunction(
() => {
const game = (window as any).game;
return game?.ws?.readyState === WebSocket.OPEN;
},
{ timeout }
);
}
/**
* Wait a fixed time plus safety margin
*/
export function safeWait(duration: number): number {
return duration + TIMING.safetyMargin;
}

15
tests/e2e/visual/index.ts Normal file
View File

@ -0,0 +1,15 @@
export {
ScreenshotValidator,
VisualExpectation,
CaptureResult,
} from './screenshot-validator';
export {
validateGameStart,
validateAfterInitialFlip,
validateDrawPhase,
validateAfterDraw,
validateRoundOver,
validateFinalTurn,
validateOpponentTurn,
validateResponsiveLayout,
} from './visual-rules';

View File

@ -0,0 +1,342 @@
/**
* Screenshot validator - captures screenshots and validates visual states
*/
import { Page, expect } from '@playwright/test';
import { SELECTORS } from '../utils/selectors';
/**
* Visual expectation result
*/
export interface VisualExpectation {
passed: boolean;
selector: string;
expected: string;
actual?: string;
error?: string;
}
/**
* Screenshot capture result
*/
export interface CaptureResult {
label: string;
buffer: Buffer;
timestamp: number;
phase?: string;
}
/**
* ScreenshotValidator - semantic visual validation
*/
export class ScreenshotValidator {
private captures: CaptureResult[] = [];
constructor(private page: Page) {}
/**
* Capture a screenshot with metadata
*/
async capture(label: string, phase?: string): Promise<CaptureResult> {
const buffer = await this.page.screenshot({ fullPage: true });
const result: CaptureResult = {
label,
buffer,
timestamp: Date.now(),
phase,
};
this.captures.push(result);
return result;
}
/**
* Capture element-specific screenshot
*/
async captureElement(
selector: string,
label: string
): Promise<CaptureResult | null> {
try {
const element = this.page.locator(selector);
const buffer = await element.screenshot();
const result: CaptureResult = {
label,
buffer,
timestamp: Date.now(),
};
this.captures.push(result);
return result;
} catch {
return null;
}
}
/**
* Get all captures
*/
getCaptures(): CaptureResult[] {
return [...this.captures];
}
/**
* Clear captures
*/
clearCaptures(): void {
this.captures = [];
}
// ============== Semantic Validators ==============
/**
* Expect element to be visible
*/
async expectVisible(selector: string): Promise<VisualExpectation> {
try {
const el = this.page.locator(selector);
await expect(el).toBeVisible({ timeout: 2000 });
return { passed: true, selector, expected: 'visible' };
} catch (error) {
return {
passed: false,
selector,
expected: 'visible',
actual: 'not visible',
error: String(error),
};
}
}
/**
* Expect element to be hidden
*/
async expectNotVisible(selector: string): Promise<VisualExpectation> {
try {
const el = this.page.locator(selector);
await expect(el).toBeHidden({ timeout: 2000 });
return { passed: true, selector, expected: 'hidden' };
} catch (error) {
return {
passed: false,
selector,
expected: 'hidden',
actual: 'visible',
error: String(error),
};
}
}
/**
* Expect element to have specific CSS class
*/
async expectHasClass(
selector: string,
className: string
): Promise<VisualExpectation> {
try {
const el = this.page.locator(selector);
const hasClass = await el.evaluate(
(node, cls) => node.classList.contains(cls),
className
);
return {
passed: hasClass,
selector,
expected: `has class "${className}"`,
actual: hasClass ? `has class "${className}"` : `missing class "${className}"`,
};
} catch (error) {
return {
passed: false,
selector,
expected: `has class "${className}"`,
error: String(error),
};
}
}
/**
* Expect element to NOT have specific CSS class
*/
async expectNoClass(
selector: string,
className: string
): Promise<VisualExpectation> {
try {
const el = this.page.locator(selector);
const hasClass = await el.evaluate(
(node, cls) => node.classList.contains(cls),
className
);
return {
passed: !hasClass,
selector,
expected: `no class "${className}"`,
actual: hasClass ? `has class "${className}"` : `no class "${className}"`,
};
} catch (error) {
return {
passed: false,
selector,
expected: `no class "${className}"`,
error: String(error),
};
}
}
/**
* Expect text content to match
*/
async expectText(
selector: string,
expected: string | RegExp
): Promise<VisualExpectation> {
try {
const el = this.page.locator(selector);
const text = await el.textContent() || '';
const matches = expected instanceof RegExp
? expected.test(text)
: text.includes(expected);
return {
passed: matches,
selector,
expected: String(expected),
actual: text,
};
} catch (error) {
return {
passed: false,
selector,
expected: String(expected),
error: String(error),
};
}
}
/**
* Expect specific number of elements
*/
async expectCount(
selector: string,
count: number
): Promise<VisualExpectation> {
try {
const els = this.page.locator(selector);
const actual = await els.count();
return {
passed: actual === count,
selector,
expected: `count=${count}`,
actual: `count=${actual}`,
};
} catch (error) {
return {
passed: false,
selector,
expected: `count=${count}`,
error: String(error),
};
}
}
/**
* Expect card at position to be face-up
*/
async expectCardFaceUp(position: number): Promise<VisualExpectation> {
const selector = SELECTORS.cards.playerCard(position);
return this.expectHasClass(selector, 'card-front');
}
/**
* Expect card at position to be face-down
*/
async expectCardFaceDown(position: number): Promise<VisualExpectation> {
const selector = SELECTORS.cards.playerCard(position);
return this.expectHasClass(selector, 'card-back');
}
/**
* Expect card at position to be clickable
*/
async expectCardClickable(position: number): Promise<VisualExpectation> {
const selector = SELECTORS.cards.playerCard(position);
return this.expectHasClass(selector, 'clickable');
}
/**
* Expect deck to be clickable
*/
async expectDeckClickable(): Promise<VisualExpectation> {
return this.expectHasClass(SELECTORS.game.deck, 'clickable');
}
/**
* Expect discard pile to have a card
*/
async expectDiscardHasCard(): Promise<VisualExpectation> {
return this.expectHasClass(SELECTORS.game.discard, 'has-card');
}
/**
* Expect final turn badge visible
*/
async expectFinalTurnBadge(): Promise<VisualExpectation> {
return this.expectVisible(SELECTORS.game.finalTurnBadge);
}
/**
* Expect held card floating visible
*/
async expectHeldCardVisible(): Promise<VisualExpectation> {
return this.expectVisible(SELECTORS.game.heldCardFloating);
}
/**
* Expect held card floating hidden
*/
async expectHeldCardHidden(): Promise<VisualExpectation> {
return this.expectNotVisible(SELECTORS.game.heldCardFloating);
}
/**
* Expect opponent to have current-turn class
*/
async expectOpponentCurrentTurn(opponentIndex: number): Promise<VisualExpectation> {
const selector = SELECTORS.cards.opponentArea(opponentIndex);
return this.expectHasClass(selector, 'current-turn');
}
/**
* Expect status message to contain text
*/
async expectStatusMessage(text: string | RegExp): Promise<VisualExpectation> {
return this.expectText(SELECTORS.game.statusMessage, text);
}
/**
* Run a batch of visual checks
*/
async runChecks(
checks: Array<() => Promise<VisualExpectation>>
): Promise<{ passed: number; failed: number; results: VisualExpectation[] }> {
const results: VisualExpectation[] = [];
let passed = 0;
let failed = 0;
for (const check of checks) {
const result = await check();
results.push(result);
if (result.passed) {
passed++;
} else {
failed++;
}
}
return { passed, failed, results };
}
}

View File

@ -0,0 +1,232 @@
/**
* Visual rules - expected visual states for different game phases
*/
import { ScreenshotValidator } from './screenshot-validator';
/**
* Expected visual states for game start
*/
export async function validateGameStart(
validator: ScreenshotValidator
): Promise<{ passed: boolean; failures: string[] }> {
const failures: string[] = [];
// All cards should be visible (face up or down)
for (let i = 0; i < 6; i++) {
const result = await validator.expectCount(
`#player-cards .card:nth-child(${i + 1})`,
1
);
if (!result.passed) {
failures.push(`Card ${i} not present`);
}
}
// Status message should indicate game phase
const statusResult = await validator.expectVisible('#status-message');
if (!statusResult.passed) {
failures.push('Status message not visible');
}
// Deck should be visible
const deckResult = await validator.expectVisible('#deck');
if (!deckResult.passed) {
failures.push('Deck not visible');
}
// Discard should be visible
const discardResult = await validator.expectVisible('#discard');
if (!discardResult.passed) {
failures.push('Discard not visible');
}
return { passed: failures.length === 0, failures };
}
/**
* Expected visual states after initial flip
*/
export async function validateAfterInitialFlip(
validator: ScreenshotValidator,
expectedFaceUp: number = 2
): Promise<{ passed: boolean; failures: string[] }> {
const failures: string[] = [];
// Count face-up cards
const faceUpResult = await validator.expectCount(
'#player-cards .card.card-front',
expectedFaceUp
);
if (!faceUpResult.passed) {
failures.push(`Expected ${expectedFaceUp} face-up cards, got ${faceUpResult.actual}`);
}
// Count face-down cards
const faceDownResult = await validator.expectCount(
'#player-cards .card.card-back',
6 - expectedFaceUp
);
if (!faceDownResult.passed) {
failures.push(`Expected ${6 - expectedFaceUp} face-down cards, got ${faceDownResult.actual}`);
}
return { passed: failures.length === 0, failures };
}
/**
* Expected visual states during player's turn (draw phase)
*/
export async function validateDrawPhase(
validator: ScreenshotValidator
): Promise<{ passed: boolean; failures: string[] }> {
const failures: string[] = [];
// Deck should be clickable
const deckResult = await validator.expectDeckClickable();
if (!deckResult.passed) {
failures.push('Deck should be clickable');
}
// Held card should NOT be visible yet
const heldResult = await validator.expectHeldCardHidden();
if (!heldResult.passed) {
failures.push('Held card should not be visible before draw');
}
// Discard button should be hidden
const discardBtnResult = await validator.expectNotVisible('#discard-btn');
if (!discardBtnResult.passed) {
failures.push('Discard button should be hidden before draw');
}
return { passed: failures.length === 0, failures };
}
/**
* Expected visual states after drawing a card
*/
export async function validateAfterDraw(
validator: ScreenshotValidator
): Promise<{ passed: boolean; failures: string[] }> {
const failures: string[] = [];
// Held card should be visible (floating)
const heldResult = await validator.expectHeldCardVisible();
if (!heldResult.passed) {
failures.push('Held card should be visible after draw');
}
// Player cards should be clickable
const clickableResult = await validator.expectCount(
'#player-cards .card.clickable',
6
);
if (!clickableResult.passed) {
failures.push('All player cards should be clickable');
}
return { passed: failures.length === 0, failures };
}
/**
* Expected visual states for round over
*/
export async function validateRoundOver(
validator: ScreenshotValidator
): Promise<{ passed: boolean; failures: string[] }> {
const failures: string[] = [];
// All player cards should be face-up
const faceUpResult = await validator.expectCount(
'#player-cards .card.card-front',
6
);
if (!faceUpResult.passed) {
failures.push('All cards should be face-up at round end');
}
// Next round button OR new game button should be visible
const nextRoundResult = await validator.expectVisible('#next-round-btn');
const newGameResult = await validator.expectVisible('#new-game-btn');
if (!nextRoundResult.passed && !newGameResult.passed) {
failures.push('Neither next round nor new game button visible');
}
// Game buttons container should be visible
const gameButtonsResult = await validator.expectVisible('#game-buttons');
if (!gameButtonsResult.passed) {
failures.push('Game buttons should be visible');
}
return { passed: failures.length === 0, failures };
}
/**
* Expected visual states for final turn
*/
export async function validateFinalTurn(
validator: ScreenshotValidator
): Promise<{ passed: boolean; failures: string[] }> {
const failures: string[] = [];
// Final turn badge should be visible
const badgeResult = await validator.expectFinalTurnBadge();
if (!badgeResult.passed) {
failures.push('Final turn badge should be visible');
}
return { passed: failures.length === 0, failures };
}
/**
* Expected visual states during opponent's turn
*/
export async function validateOpponentTurn(
validator: ScreenshotValidator,
opponentIndex: number
): Promise<{ passed: boolean; failures: string[] }> {
const failures: string[] = [];
// Opponent should have current-turn highlight
const turnResult = await validator.expectOpponentCurrentTurn(opponentIndex);
if (!turnResult.passed) {
failures.push(`Opponent ${opponentIndex} should have current-turn class`);
}
// Deck should NOT be clickable (not our turn)
const deckResult = await validator.expectNoClass('#deck', 'clickable');
if (!deckResult.passed) {
failures.push('Deck should not be clickable during opponent turn');
}
return { passed: failures.length === 0, failures };
}
/**
* Validate responsive layout at specific width
*/
export async function validateResponsiveLayout(
validator: ScreenshotValidator,
width: number
): Promise<{ passed: boolean; failures: string[] }> {
const failures: string[] = [];
// Core elements should still be visible
const elements = [
'#deck',
'#discard',
'#player-cards',
'#status-message',
];
for (const selector of elements) {
const result = await validator.expectVisible(selector);
if (!result.passed) {
failures.push(`${selector} not visible at ${width}px width`);
}
}
return { passed: failures.length === 0, failures };
}