diff --git a/client/animation-queue.js b/client/animation-queue.js
index 176fd28..8f88038 100644
--- a/client/animation-queue.js
+++ b/client/animation-queue.js
@@ -12,12 +12,15 @@ class AnimationQueue {
this.animationInProgress = false;
// Timing configuration (ms)
+ // Rhythm: action → settle → action → breathe
this.timing = {
- flipDuration: 400,
- moveDuration: 300,
- pauseAfterMove: 200,
- pauseAfterFlip: 100,
- pauseBetweenAnimations: 100
+ flipDuration: 540, // Must match CSS .card-inner transition (0.54s)
+ moveDuration: 270,
+ pauseAfterFlip: 144, // Brief settle after flip before move
+ pauseAfterDiscard: 550, // Let discard land + pulse (400ms) + settle
+ 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');
// Step 1: If card was face down, flip to reveal it
+ this.setCardFront(front, oldCard);
if (!oldCard.face_up) {
- // Set up the front with the old card content (what we're discarding)
- this.setCardFront(front, oldCard);
-
this.playSound('flip');
inner.classList.remove('flipped');
await this.delay(this.timing.flipDuration);
+ await this.delay(this.timing.pauseAfterFlip);
} else {
- // Already face up, just show it
- this.setCardFront(front, oldCard);
+ // Already face up, just show it immediately
inner.classList.remove('flipped');
}
- await this.delay(100);
-
// Step 2: Move card to discard pile
this.playSound('card');
animCard.classList.add('moving');
@@ -181,8 +180,8 @@ class AnimationQueue {
await this.delay(this.timing.moveDuration);
animCard.classList.remove('moving');
- // Pause to show the card landing on discard
- await this.delay(this.timing.pauseAfterMove + 200);
+ // Let discard land and pulse settle
+ await this.delay(this.timing.pauseAfterDiscard);
// Step 3: Create second card for the new card coming into hand
const newAnimCard = this.createAnimCard();
@@ -197,6 +196,9 @@ class AnimationQueue {
this.setCardFront(newFront, newCard);
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
this.playSound('card');
newAnimCard.classList.add('moving');
@@ -204,8 +206,8 @@ class AnimationQueue {
await this.delay(this.timing.moveDuration);
newAnimCard.classList.remove('moving');
- // Clean up animation cards
- await this.delay(this.timing.pauseAfterMove);
+ // Breathing room after swap completes
+ await this.delay(this.timing.pauseAfterSwapComplete);
animCard.remove();
newAnimCard.remove();
}
@@ -297,7 +299,8 @@ class AnimationQueue {
await this.delay(this.timing.moveDuration);
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
animCard.remove();
@@ -322,17 +325,13 @@ class AnimationQueue {
// Move to holding position
this.playSound('card');
- await this.delay(50);
-
animCard.classList.add('moving');
this.setCardPosition(animCard, holdingRect);
await this.delay(this.timing.moveDuration);
animCard.classList.remove('moving');
- // The card stays face down until the player decides what to do
- // (the actual card reveal happens when server sends card_drawn)
-
- await this.delay(this.timing.pauseAfterMove);
+ // Brief settle before state updates
+ await this.delay(this.timing.pauseBeforeNewCard);
// Clean up - renderGame will show the holding card state
animCard.remove();
diff --git a/client/app.js b/client/app.js
index c430e88..cf01da2 100644
--- a/client/app.js
+++ b/client/app.js
@@ -8,6 +8,7 @@ class GolfGame {
this.isHost = false;
this.gameState = null;
this.drawnCard = null;
+ this.drawnFromDiscard = false;
this.selectedCards = [];
this.waitingForFlip = false;
this.currentPlayers = [];
@@ -189,6 +190,7 @@ class GolfGame {
this.leaveRoomBtn = document.getElementById('leave-room-btn');
this.addCpuBtn = document.getElementById('add-cpu-btn');
this.removeCpuBtn = document.getElementById('remove-cpu-btn');
+ this.cpuControlsSection = document.getElementById('cpu-controls-section');
this.cpuSelectModal = document.getElementById('cpu-select-modal');
this.cpuProfilesGrid = document.getElementById('cpu-profiles-grid');
this.cancelCpuBtn = document.getElementById('cancel-cpu-btn');
@@ -202,17 +204,22 @@ class GolfGame {
this.yourScore = document.getElementById('your-score');
this.muteBtn = document.getElementById('mute-btn');
this.opponentsRow = document.getElementById('opponents-row');
+ this.deckArea = document.querySelector('.deck-area');
this.deck = document.getElementById('deck');
this.discard = document.getElementById('discard');
this.discardContent = document.getElementById('discard-content');
this.discardBtn = document.getElementById('discard-btn');
- this.cancelDrawBtn = document.getElementById('cancel-draw-btn');
this.skipFlipBtn = document.getElementById('skip-flip-btn');
this.knockEarlyBtn = document.getElementById('knock-early-btn');
this.playerCards = document.getElementById('player-cards');
this.playerArea = this.playerCards.closest('.player-area');
this.swapAnimation = document.getElementById('swap-animation');
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.scoreTable = document.getElementById('score-table').querySelector('tbody');
this.standingsList = document.getElementById('standings-list');
@@ -223,6 +230,11 @@ class GolfGame {
this.activeRulesBar = document.getElementById('active-rules-bar');
this.activeRulesList = document.getElementById('active-rules-list');
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() {
@@ -233,7 +245,6 @@ class GolfGame {
this.deck.addEventListener('click', () => { this.drawFromDeck(); });
this.discard.addEventListener('click', () => { this.drawFromDiscard(); });
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.knockEarlyBtn.addEventListener('click', () => { this.playSound('success'); this.knockEarly(); });
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.muteBtn.addEventListener('click', () => this.toggleSound());
this.leaveGameBtn.addEventListener('click', () => { this.playSound('click'); this.leaveGame(); });
+ this.gameLogoutBtn.addEventListener('click', () => { this.playSound('click'); this.auth?.logout(); });
// Copy room code to clipboard
this.copyRoomCodeBtn.addEventListener('click', () => {
@@ -401,8 +413,9 @@ class GolfGame {
this.gameState = data.game_state;
// Deep copy for previousState to avoid reference issues
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.selectedCards = [];
this.animatingPositions = new Set();
this.playSound('shuffle');
this.showGameScreen();
@@ -443,28 +456,33 @@ class GolfGame {
break;
case 'your_turn':
- // Build toast based on available actions
- const canFlip = this.gameState && this.gameState.flip_as_action;
- let canKnock = false;
- if (this.gameState && this.gameState.knock_early) {
- const myData = this.gameState.players.find(p => p.id === this.playerId);
- const faceDownCount = myData ? myData.cards.filter(c => !c.face_up).length : 0;
- canKnock = faceDownCount >= 1 && faceDownCount <= 2;
- }
- if (canFlip && canKnock) {
- this.showToast('Your turn! Draw, flip, or knock', 'your-turn');
- } else if (canFlip) {
- this.showToast('Your turn! Draw or flip a card', 'your-turn');
- } else if (canKnock) {
- this.showToast('Your turn! Draw or knock', 'your-turn');
- } else {
- this.showToast('Your turn! Draw a card', 'your-turn');
- }
+ // Brief delay to let animations settle
+ setTimeout(() => {
+ // Build toast based on available actions
+ const canFlip = this.gameState && this.gameState.flip_as_action;
+ let canKnock = false;
+ if (this.gameState && this.gameState.knock_early) {
+ const myData = this.gameState.players.find(p => p.id === this.playerId);
+ const faceDownCount = myData ? myData.cards.filter(c => !c.face_up).length : 0;
+ canKnock = faceDownCount >= 1 && faceDownCount <= 2;
+ }
+ if (canFlip && canKnock) {
+ this.showToast('Your turn! Draw, flip, or knock', 'your-turn');
+ } else if (canFlip) {
+ this.showToast('Your turn! Draw or flip a card', 'your-turn');
+ } else if (canKnock) {
+ this.showToast('Your turn! Draw or knock', 'your-turn');
+ } else {
+ this.showToast('Your turn! Draw a card', 'your-turn');
+ }
+ }, 200);
break;
case 'card_drawn':
this.drawnCard = data.card;
+ this.drawnFromDiscard = data.source === 'discard';
this.showDrawnCard();
+ this.renderGame(); // Re-render to update discard pile
this.showToast('Swap with a card or discard', '', 3000);
break;
@@ -750,6 +768,13 @@ class GolfGame {
}
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.gameState && !this.gameState.waiting_for_initial_flip) {
this.playSound('reject');
@@ -764,10 +789,98 @@ class GolfGame {
discardDrawn() {
if (!this.drawnCard) return;
+ const discardedCard = this.drawnCard;
+ const wasFromDeck = !this.drawnFromDiscard;
this.send({ type: 'discard' });
this.drawnCard = null;
- this.hideDrawnCard();
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 = `${jokerIcon}Joker`;
+ } 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() {
@@ -786,6 +899,7 @@ class GolfGame {
}
// Animate player swapping drawn card with a card in their hand
+ // Uses flip-in-place + teleport (no zipping movement)
animateSwap(position) {
const cardElements = this.playerCards.querySelectorAll('.card');
const handCardEl = cardElements[position];
@@ -801,12 +915,16 @@ class GolfGame {
// Get positions
const handRect = handCardEl.getBoundingClientRect();
- const discardRect = this.discard.getBoundingClientRect();
// Set up the animated card at hand position
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 swapCardInner = swapCard.querySelector('.swap-card-inner');
// Position at the hand card location
swapCard.style.left = handRect.left + 'px';
@@ -814,97 +932,67 @@ class GolfGame {
swapCard.style.width = handRect.width + 'px';
swapCard.style.height = handRect.height + 'px';
- // Reset state
+ // Reset state - no moving class needed
swapCard.classList.remove('flipping', 'moving');
swapCardFront.innerHTML = '';
swapCardFront.className = 'swap-card-front';
+ // Mark animating
+ this.swapAnimationInProgress = true;
+ this.swapAnimationCardEl = handCardEl;
+ this.swapAnimationHandCardEl = handCardEl;
+
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 = `${jokerIcon}Joker`;
- } else {
- swapCardFront.classList.add(card.suit === 'hearts' || card.suit === 'diamonds' ? 'red' : 'black');
- const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[card.suit];
- swapCardFront.innerHTML = `${card.rank}
${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
- this.swapAnimationInProgress = true;
- this.swapAnimationCardEl = handCardEl;
+ // FACE-UP CARD: Subtle pulse animation (no flip needed)
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.drawnCard = null;
+ this.skipNextDiscardFlip = true;
- // Slide to discard
+ // Complete after pulse animation
setTimeout(() => {
- swapCard.classList.add('moving');
- swapCard.style.left = discardRect.left + 'px';
- swapCard.style.top = discardRect.top + 'px';
- }, 50);
-
- // 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);
+ handCardEl.classList.remove('swap-pulse');
+ this.heldCardFloating.classList.remove('swap-pulse');
+ this.completeSwapAnimation(null);
+ }, 440);
} else {
- // FACE-DOWN CARD: Just slide card-back to discard (no flip mid-air)
- // The new card will appear instantly when state updates
+ // FACE-DOWN CARD: Flip in place to reveal, then teleport
- // Don't use overlay for face-down - just send swap and let state handle it
- // This avoids the clunky "flip to empty front" issue
- this.swapAnimationInProgress = true;
- this.swapAnimationCardEl = handCardEl;
+ // Hide the actual hand card
+ handCardEl.classList.add('swap-out');
+ this.swapAnimation.classList.remove('hidden');
+
+ // Store references for updateSwapAnimation
+ this.swapAnimationFront = swapCardFront;
+ this.swapAnimationCard = swapCard;
this.swapAnimationContentSet = false;
- // Send swap
+ // Send swap - the flip will happen in updateSwapAnimation when server responds
this.send({ type: 'swap', position });
this.drawnCard = null;
-
- // 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);
+ this.skipNextDiscardFlip = true;
}
}
// Update the animated card with actual card content when server responds
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)
if (this.swapAnimationContentSet) return;
@@ -923,6 +1011,61 @@ class GolfGame {
}
this.swapAnimationFront.innerHTML = `${card.rank}
${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) {
@@ -987,7 +1130,7 @@ class GolfGame {
}
}
- if (discardChanged && wasOtherPlayer) {
+ if (discardChanged) {
// Check if the previous player actually SWAPPED (has a new face-up card)
// vs just discarding the drawn card (no hand change)
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
@@ -998,34 +1141,38 @@ class GolfGame {
// Could be: face-down -> face-up (new reveal)
// Or: different card at same position (replaced visible card)
let swappedPosition = -1;
+ let wasFaceUp = false; // Track if old card was already face-up
for (let i = 0; i < 6; i++) {
const oldCard = oldPlayer.cards[i];
const newCard = newPlayer.cards[i];
const wasUp = oldCard?.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) {
swappedPosition = i;
+ wasFaceUp = false;
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 (oldCard.rank !== newCard.rank || oldCard.suit !== newCard.suit) {
swappedPosition = i;
+ wasFaceUp = true; // Face-to-face swap
break;
}
}
}
- if (swappedPosition >= 0) {
- // Player swapped - animate from the actual position that changed
- this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition);
- } else {
- // Player drew and discarded without swapping
- // Animate card going from deck area to discard
+ if (swappedPosition >= 0 && wasOtherPlayer) {
+ // Opponent swapped - animate from the actual position that changed
+ this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
+ } else if (swappedPosition < 0) {
+ // Player drew and discarded without swapping - pulse for everyone
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;
pile.classList.add('draw-pulse');
// 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) {
- const deckRect = this.deck.getBoundingClientRect();
- const discardRect = this.discard.getBoundingClientRect();
- 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 = `${jokerIcon}Joker`;
- } else {
- swapCardFront.classList.add(discardCard.suit === 'hearts' || discardCard.suit === 'diamonds' ? 'red' : 'black');
- swapCardFront.innerHTML = `${discardCard.rank}
${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);
+ // Card is already known - just pulse to show it landed (no flip needed)
+ this.pulseDiscardLand();
}
// Get rotation angle from an element's computed transform
@@ -1108,8 +1222,9 @@ class GolfGame {
return 0;
}
- // Fire a swap animation (non-blocking)
- fireSwapAnimation(playerId, discardCard, position) {
+ // Fire a swap animation (non-blocking) - flip in place at opponent's 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
const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area');
@@ -1132,26 +1247,37 @@ class GolfGame {
}
}
- if (!sourceRect) {
- const discardRect = this.discard.getBoundingClientRect();
- sourceRect = { left: discardRect.left, top: discardRect.top - 100, width: discardRect.width, height: discardRect.height };
+ // Face-to-face swap: use subtle pulse on the card, no flip needed
+ if (wasFaceUp && sourceCardEl) {
+ 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();
+ sourceRect = { left: discardRect.left, top: discardRect.top, width: discardRect.width, height: discardRect.height };
}
- const discardRect = this.discard.getBoundingClientRect();
const swapCard = this.swapCardFromHand;
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.top = sourceRect.top + 'px';
swapCard.style.width = sourceRect.width + '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
swapCard.style.transform = `rotate(${sourceRotation}deg)`;
- // Set card content
+ // Set card content (the card being discarded - what was hidden)
swapCardFront.className = 'swap-card-front';
if (discardCard.rank === '★') {
swapCardFront.classList.add('joker');
@@ -1165,24 +1291,26 @@ class GolfGame {
if (sourceCardEl) sourceCardEl.classList.add('swap-out');
this.swapAnimation.classList.remove('hidden');
- // Timing: flip takes ~400ms, then move takes ~400ms
+ // Step 1: Flip to reveal the hidden card
setTimeout(() => {
swapCard.classList.add('flipping');
this.playSound('flip');
}, 50);
+
+ // Step 2: After flip, pause to see the card then pulse before being replaced
setTimeout(() => {
- // Start move AFTER flip completes - also animate rotation back to 0
- swapCard.classList.add('moving');
- swapCard.style.left = discardRect.left + 'px';
- swapCard.style.top = discardRect.top + 'px';
- swapCard.style.transform = 'rotate(0deg)';
- }, 500);
+ swapCard.classList.add('swap-pulse');
+ this.playSound('card');
+ }, 850);
+
+ // Step 3: Strategic pause to show discarded card, then complete
setTimeout(() => {
this.swapAnimation.classList.add('hidden');
- swapCard.classList.remove('flipping', 'moving');
+ swapCard.classList.remove('flipping', 'moving', 'swap-pulse');
swapCard.style.transform = '';
if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
- }, 1000);
+ this.pulseDiscardLand();
+ }, 1400);
}
// Fire a flip animation for local player's card (non-blocking)
@@ -1233,7 +1361,7 @@ class GolfGame {
swapCard.classList.remove('flipping');
cardEl.classList.remove('swap-out');
this.animatingPositions.delete(key);
- }, 450);
+ }, 550);
}
// Fire a flip animation for opponent card (non-blocking)
@@ -1295,7 +1423,7 @@ class GolfGame {
setTimeout(() => {
swapCard.classList.add('flipping');
this.playSound('flip');
- }, 50);
+ }, 60);
setTimeout(() => {
this.swapAnimation.classList.add('hidden');
@@ -1303,7 +1431,7 @@ class GolfGame {
swapCard.style.transform = '';
cardEl.classList.remove('swap-out');
this.animatingPositions.delete(key);
- }, 450);
+ }, 560);
}
handleCardClick(position) {
@@ -1339,7 +1467,9 @@ class GolfGame {
// Initial flip phase
if (this.gameState.waiting_for_initial_flip) {
if (card.face_up) return;
+ // Use Set to prevent duplicates - check both tracking mechanisms
if (this.locallyFlippedCards.has(position)) return;
+ if (this.selectedCards.includes(position)) return;
const requiredFlips = this.gameState.initial_flips || 2;
@@ -1353,12 +1483,15 @@ class GolfGame {
// Re-render to show flipped state
this.renderGame();
- if (this.selectedCards.length === requiredFlips) {
- this.send({ type: 'flip_initial', positions: this.selectedCards });
+ // Use Set to ensure unique positions when sending to server
+ const uniquePositions = [...new Set(this.selectedCards)];
+ if (uniquePositions.length === requiredFlips) {
+ this.send({ type: 'flip_initial', positions: uniquePositions });
this.selectedCards = [];
+ // Note: locallyFlippedCards is cleared when server confirms (in game_state handler)
this.hideToast();
} else {
- const remaining = requiredFlips - this.selectedCards.length;
+ const remaining = requiredFlips - uniquePositions.length;
this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, '', 5000);
}
return;
@@ -1417,6 +1550,25 @@ class GolfGame {
this.rulesScreen.classList.remove('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() {
@@ -1435,9 +1587,11 @@ class GolfGame {
if (this.isHost) {
this.hostSettings.classList.remove('hidden');
+ this.cpuControlsSection.classList.remove('hidden');
this.waitingMessage.classList.add('hidden');
} else {
this.hostSettings.classList.add('hidden');
+ this.cpuControlsSection.classList.add('hidden');
this.waitingMessage.classList.remove('hidden');
}
}
@@ -1499,13 +1653,8 @@ class GolfGame {
if (player.is_host) badges += 'HOST';
if (player.is_cpu) badges += 'CPU';
- let nameDisplay = player.name;
- if (player.style) {
- nameDisplay += ` (${player.style})`;
- }
-
li.innerHTML = `
- ${nameDisplay}
+ ${player.name}
${badges}
`;
if (player.id === this.playerId) {
@@ -1516,6 +1665,7 @@ class GolfGame {
if (player.id === this.playerId && player.is_host) {
this.isHost = true;
this.hostSettings.classList.remove('hidden');
+ this.cpuControlsSection.classList.remove('hidden');
this.waitingMessage.classList.add('hidden');
}
});
@@ -1573,6 +1723,18 @@ class GolfGame {
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 currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id);
@@ -1610,33 +1772,37 @@ class GolfGame {
}
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;
- 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 === '★') {
- this.discard.classList.add('joker');
- } else if (this.isRedSuit(card.suit)) {
- this.discard.classList.add('red');
+ this.heldCardFloating.classList.add('joker');
+ const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
+ this.heldCardFloatingContent.innerHTML = `${jokerIcon}Joker`;
} 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}
${this.getSuitSymbol(card.suit)}`;
}
- // Render card directly without checking face_up (drawn card is always visible to drawer)
- if (card.rank === '★') {
- const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
- this.discardContent.innerHTML = `${jokerIcon}Joker`;
- } else {
- this.discardContent.innerHTML = `${card.rank}
${this.getSuitSymbol(card.suit)}`;
- }
+ // Show the floating card and discard button
+ this.heldCardFloating.classList.remove('hidden');
this.discardBtn.classList.remove('hidden');
}
hideDrawnCard() {
- // Restore discard pile to show actual top card (handled by renderGame)
- this.discard.classList.remove('holding');
+ // Hide the floating held card
+ this.heldCardFloating.classList.add('hidden');
+ // Clear any inline styles from animations
+ this.heldCardFloating.style.cssText = '';
this.discardBtn.classList.add('hidden');
- this.cancelDrawBtn.classList.add('hidden');
}
isRedSuit(suit) {
@@ -1745,19 +1911,50 @@ class GolfGame {
playerNameSpan.innerHTML = crownHtml + displayName + checkmark;
}
- // Update discard pile (skip if holding a drawn card)
- if (!this.drawnCard) {
+ // Update discard pile
+ 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) {
const discardCard = this.gameState.discard_top;
const cardKey = `${discardCard.rank}-${discardCard.suit}`;
- // Animate if discard changed
- if (this.lastDiscardKey && this.lastDiscardKey !== cardKey) {
- this.discard.classList.add('card-flip-in');
- setTimeout(() => this.discard.classList.remove('card-flip-in'), 400);
- }
+ // Only animate discard flip during active gameplay, not at round/game end
+ const isActivePlay = this.gameState.phase !== 'round_over' &&
+ this.gameState.phase !== 'game_over';
+ const shouldAnimate = isActivePlay && this.lastDiscardKey &&
+ this.lastDiscardKey !== cardKey && !this.skipNextDiscardFlip;
+
+ this.skipNextDiscardFlip = false;
this.lastDiscardKey = cardKey;
+ // Set card content and styling FIRST (before any animation)
this.discard.classList.add('has-card', 'card-front');
this.discard.classList.remove('card-back', 'red', 'black', 'joker', 'holding');
@@ -1769,6 +1966,15 @@ class GolfGame {
this.discard.classList.add('black');
}
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 {
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker', 'holding');
this.discardContent.innerHTML = '';
@@ -1781,22 +1987,30 @@ class GolfGame {
const hasDrawn = this.drawnCard || this.gameState.has_drawn_card;
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('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);
- // Don't show disabled state when we're holding a drawn card (it's displayed in discard position)
- this.discard.classList.toggle('disabled', hasDrawn && !this.drawnCard);
+ // Only show disabled state when it's my turn and I've drawn (not holding visible card)
+ // Don't grey out when opponents are playing
+ this.discard.classList.toggle('disabled', this.isMyTurn() && hasDrawn && !this.drawnCard);
// Render opponents in a single row
const opponents = this.gameState.players.filter(p => p.id !== this.playerId);
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) => {
const div = document.createElement('div');
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');
}
@@ -1810,7 +2024,7 @@ class GolfGame {
const crownHtml = isRoundWinner ? '👑' : '';
div.innerHTML = `
-
${crownHtml}${displayName}${player.all_face_up ? ' ✓' : ''}${showingScore}
+ ${crownHtml}${displayName}${player.all_face_up ? ' ✓' : ''}${showingScore}
${player.cards.map(card => this.renderCard(card, false, false)).join('')}
@@ -1862,12 +2076,9 @@ class GolfGame {
if (this.drawnCard && !this.gameState.can_discard) {
this.discardBtn.disabled = true;
this.discardBtn.classList.add('disabled');
- // Show cancel button when drawn from discard (can put it back)
- this.cancelDrawBtn.classList.remove('hidden');
} else {
this.discardBtn.disabled = false;
this.discardBtn.classList.remove('disabled');
- this.cancelDrawBtn.classList.add('hidden');
}
// Show/hide skip flip button (only when flip is optional in endgame mode)
@@ -1907,14 +2118,20 @@ class GolfGame {
// Update standings (left panel)
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)
this.scoreTable.innerHTML = '';
this.gameState.players.forEach(player => {
const tr = document.createElement('tr');
- // Highlight current player
- if (player.id === this.gameState.current_player_id) {
+ // Highlight current player (but not during round/game over)
+ 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');
}
diff --git a/client/index.html b/client/index.html
index 8716e6d..730b880 100644
--- a/client/index.html
+++ b/client/index.html
@@ -51,11 +51,10 @@
@@ -65,19 +64,19 @@
Players
+
+
Add CPU Opponents
+
+
+
+
+
Game Settings
-
@@ -273,6 +274,13 @@
+
+
?
@@ -280,8 +288,11 @@
+
+
+
+
-
@@ -295,14 +306,22 @@
-
+
@@ -317,6 +336,10 @@
Scores
+
+
+
+
-
-
-
-
diff --git a/client/style.css b/client/style.css
index 174461d..c54bf48 100644
--- a/client/style.css
+++ b/client/style.css
@@ -195,12 +195,13 @@ body {
grid-template-columns: 220px 1fr;
gap: 25px;
align-items: start;
+ padding-top: 70px;
}
.waiting-left-col {
display: flex;
flex-direction: column;
- gap: 15px;
+ gap: 10px;
}
.waiting-left-col .players-list {
@@ -227,7 +228,7 @@ body {
/* Basic settings in a row */
.basic-settings-row {
display: grid;
- grid-template-columns: repeat(4, 1fr);
+ grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 15px;
align-items: end;
@@ -248,14 +249,29 @@ body {
padding: 8px 4px;
}
-.basic-settings-row .cpu-controls {
- display: flex;
- gap: 5px;
+/* CPU Controls Section - below players list */
+.cpu-controls-section {
+ 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;
- padding: 8px 0;
+ padding: 6px 0;
+ font-size: 1rem;
+ font-weight: bold;
}
#waiting-message {
@@ -282,14 +298,14 @@ body {
left: 20px;
z-index: 100;
background: linear-gradient(180deg, #d4845a 0%, #c4723f 50%, #b8663a 100%);
- padding: 12px 16px 20px;
+ padding: 10px 14px 18px;
display: flex;
flex-direction: column;
align-items: center;
- gap: 4px;
+ gap: 6px;
box-shadow: 2px 4px 12px rgba(0, 0, 0, 0.3);
/* 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 {
@@ -302,39 +318,27 @@ body {
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 {
- font-size: 1.6rem;
+ font-size: 1.5rem;
font-weight: 800;
font-family: 'Courier New', monospace;
- letter-spacing: 0.2em;
+ letter-spacing: 0.15em;
color: #fff;
text-shadow: 0 2px 2px rgba(0, 0, 0, 0.3);
- padding: 2px 0;
}
.room-code-buttons {
display: flex;
- gap: 6px;
- margin-top: 2px;
+ gap: 5px;
}
.room-code-copy {
background: rgba(255, 255, 255, 0.85);
border: none;
border-radius: 4px;
- padding: 4px 8px;
+ padding: 4px 6px;
cursor: pointer;
- font-size: 0.9rem;
+ font-size: 0.85rem;
transition: all 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}
@@ -349,6 +353,7 @@ body {
}
+
h1 {
font-size: 3rem;
text-align: center;
@@ -463,6 +468,15 @@ input::placeholder {
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 {
opacity: 0.4;
@@ -625,7 +639,7 @@ input::placeholder {
/* Game Screen */
.game-header {
display: grid;
- grid-template-columns: 1fr 2fr 1fr;
+ grid-template-columns: auto 1fr auto;
align-items: center;
padding: 10px 20px;
background: rgba(0,0,0,0.35);
@@ -653,8 +667,18 @@ input::placeholder {
}
.header-col-right {
+ display: flex;
+ flex-direction: row;
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 {
@@ -773,6 +797,7 @@ input::placeholder {
background: #fff;
border: 2px solid #ddd;
color: #333;
+ text-align: center;
}
.card-front.red {
@@ -962,7 +987,73 @@ input::placeholder {
.deck-area {
display: flex;
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 {
@@ -989,11 +1080,19 @@ input::placeholder {
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 {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
+ position: relative;
}
.discard-stack .btn {
@@ -1016,7 +1115,7 @@ input::placeholder {
/* Highlight flash when opponent draws from a pile */
#deck.draw-pulse,
#discard.draw-pulse {
- animation: draw-highlight 0.4s ease-out;
+ animation: draw-highlight 0.45s ease-out;
z-index: 100;
}
@@ -1044,7 +1143,7 @@ input::placeholder {
/* Card flip animation for discard pile */
.card-flip-in {
- animation: cardFlipIn 0.5s ease-out;
+ animation: cardFlipIn 0.56s ease-out;
}
@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 {
position: fixed;
@@ -1092,12 +1211,16 @@ input::placeholder {
perspective: 1000px;
}
+.swap-card.hidden {
+ display: none;
+}
+
.swap-card-inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
- transition: transform 0.4s ease-in-out;
+ transition: transform 0.54s ease-in-out;
}
.swap-card.flipping .swap-card-inner {
@@ -1163,7 +1286,7 @@ input::placeholder {
}
.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);
filter: drop-shadow(0 0 25px rgba(244, 164, 96, 1));
}
@@ -1180,6 +1303,31 @@ input::placeholder {
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-section {
text-align: center;
@@ -1483,14 +1631,14 @@ input::placeholder {
}
.game-buttons {
- margin-top: 8px;
+ margin-bottom: 8px;
display: flex;
flex-direction: column;
gap: 5px;
}
.game-buttons .btn {
- font-size: 0.7rem;
+ font-size: 0.8rem;
padding: 6px 8px;
width: 100%;
}
@@ -1728,11 +1876,27 @@ input::placeholder {
}
.game-header {
+ display: flex;
flex-direction: column;
text-align: center;
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 {
padding: 10px 15px;
}
@@ -1793,7 +1957,7 @@ input::placeholder {
width: 100%;
height: 100%;
transform-style: preserve-3d;
- transition: transform 0.4s ease-in-out;
+ transition: transform 0.54s ease-in-out;
}
.real-card .card-inner.flipped {
@@ -1867,9 +2031,9 @@ input::placeholder {
.real-card.moving,
.real-card.anim-card.moving {
z-index: 600;
- transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1),
- top 0.3s cubic-bezier(0.4, 0, 0.2, 1),
- transform 0.3s ease-out;
+ transition: left 0.27s cubic-bezier(0.4, 0, 0.2, 1),
+ top 0.27s cubic-bezier(0.4, 0, 0.2, 1),
+ transform 0.27s ease-out;
filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8));
transform: scale(1.08) rotate(-3deg);
}
@@ -1881,7 +2045,7 @@ input::placeholder {
}
.real-card.anim-card .card-inner {
- transition: transform 0.4s ease-in-out;
+ transition: transform 0.54s ease-in-out;
}
.real-card.holding {
@@ -2881,11 +3045,30 @@ input::placeholder {
display: none;
}
+/* Hide global auth-bar when game screen is active */
+#app:has(#game-screen.active) > .auth-bar {
+ display: none !important;
+}
+
#auth-username {
color: #f4a460;
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 {
display: flex;
diff --git a/server/ai.py b/server/ai.py
index ed238d6..980485d 100644
--- a/server/ai.py
+++ b/server/ai.py
@@ -47,15 +47,74 @@ def can_make_pair(card1: Card, card2: Card) -> bool:
return card1.rank == card2.rank
-def estimate_opponent_min_score(player: Player, game: Game) -> int:
- """Estimate minimum opponent score from visible cards."""
+def get_discard_thinking_time(card: Optional[Card], options: GameOptions) -> float:
+ """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
for p in game.players:
if p.id == player.id:
continue
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)
- 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)
return min_est
@@ -365,60 +424,91 @@ CPU_PROFILES = [
),
]
-# Track which profiles are in use
-_used_profiles: set[str] = set()
-_cpu_profiles: dict[str, CPUProfile] = {}
+# Track profiles per room (room_code -> set of used profile names)
+_room_used_profiles: dict[str, set[str]] = {}
+# Track cpu_id -> (room_code, profile) mapping
+_cpu_profiles: dict[str, tuple[str, CPUProfile]] = {}
-def get_available_profile() -> Optional[CPUProfile]:
- """Get a random available CPU profile."""
- available = [p for p in CPU_PROFILES if p.name not in _used_profiles]
+def get_available_profile(room_code: str) -> Optional[CPUProfile]:
+ """Get a random available CPU profile for a specific room."""
+ 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:
return None
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
-def release_profile(name: str):
- """Release a CPU profile back to the pool."""
- _used_profiles.discard(name)
- # Also remove from cpu_profiles by finding the cpu_id with this profile
- to_remove = [cpu_id for cpu_id, profile in _cpu_profiles.items() if profile.name == name]
+def release_profile(name: str, room_code: str):
+ """Release a CPU profile back to the room's pool."""
+ if room_code in _room_used_profiles:
+ _room_used_profiles[room_code].discard(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:
del _cpu_profiles[cpu_id]
def reset_all_profiles():
"""Reset all profile tracking (for cleanup)."""
- _used_profiles.clear()
+ _room_used_profiles.clear()
_cpu_profiles.clear()
def get_profile(cpu_id: str) -> Optional[CPUProfile]:
"""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]:
- """Assign a random profile to a CPU player."""
- profile = get_available_profile()
+def assign_profile(cpu_id: str, room_code: str) -> Optional[CPUProfile]:
+ """Assign a random profile to a CPU player in a specific room."""
+ profile = get_available_profile(room_code)
if profile:
- _cpu_profiles[cpu_id] = profile
+ _cpu_profiles[cpu_id] = (room_code, profile)
return profile
-def assign_specific_profile(cpu_id: str, profile_name: str) -> Optional[CPUProfile]:
- """Assign a specific profile to a CPU player by name."""
- # Check if profile exists and is available
+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 in a specific room."""
+ 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:
- if profile.name == profile_name and profile.name not in _used_profiles:
- _used_profiles.add(profile.name)
- _cpu_profiles[cpu_id] = profile
+ if profile.name == profile_name and profile.name not in used_in_room:
+ if room_code not in _room_used_profiles:
+ _room_used_profiles[room_code] = set()
+ _room_used_profiles[room_code].add(profile.name)
+ _cpu_profiles[cpu_id] = (room_code, profile)
return profile
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]:
"""Get all CPU profiles for display."""
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
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
safety_margin = 5 if profile.aggression < 0.4 else 2
if estimated_score > opponent_min - safety_margin:
@@ -1174,8 +1264,37 @@ class GolfAI:
if options.underdog_bonus:
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 random.random() < profile.aggression:
+ ai_log(f" >> GOING OUT with score {estimated_score}")
return True
return False
@@ -1196,11 +1315,22 @@ async def process_cpu_turn(
# Get logger if game_id provided
logger = get_logger() if game_id else None
- # Add delay based on unpredictability (chaotic players are faster/slower)
- delay = 0.8 + random.uniform(0, 0.5)
+ # Brief initial delay before CPU "looks at" the discard pile
+ 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:
- delay = random.uniform(0.3, 1.2)
- await asyncio.sleep(delay)
+ thinking_time *= random.uniform(0.6, 1.4)
+
+ 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
GolfAI.should_go_out_early(cpu_player, game, profile)
@@ -1243,8 +1373,7 @@ async def process_cpu_turn(
await broadcast_callback()
return # Turn is over
- # Decide whether to draw from discard or deck
- discard_top = game.discard_top()
+ # Decide whether to draw from discard or deck (discard_top already fetched above)
take_discard = GolfAI.should_take_discard(discard_top, cpu_player, profile, game)
source = "discard" if take_discard else "deck"
diff --git a/server/main.py b/server/main.py
index 359a1cd..7b638e9 100644
--- a/server/main.py
+++ b/server/main.py
@@ -15,7 +15,7 @@ import redis.asyncio as redis
from config import config
from room import RoomManager, Room
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
# Import production components
@@ -407,12 +407,17 @@ async def require_admin(user: User = Depends(require_user)) -> User:
@app.get("/api/debug/cpu-profiles")
async def get_cpu_profile_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 {
"total_profiles": len(CPU_PROFILES),
- "used_count": len(_used_profiles),
- "used_profiles": list(_used_profiles),
- "cpu_mappings": {cpu_id: profile.name for cpu_id, profile in _cpu_profiles.items()},
+ "room_profiles": {
+ room_code: list(profiles)
+ 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),
"rooms": {
code: {
@@ -431,6 +436,19 @@ async def reset_cpu_profiles():
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")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
@@ -444,13 +462,17 @@ async def websocket_endpoint(websocket: WebSocket):
except Exception as 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:
- player_id = str(authenticated_user.id)
- logger.debug(f"WebSocket authenticated as user {player_id}")
+ logger.debug(f"WebSocket authenticated as user {auth_user_id}, connection {connection_id}")
else:
- player_id = str(uuid.uuid4())
- logger.debug(f"WebSocket connected anonymously as {player_id}")
+ logger.debug(f"WebSocket connected anonymously as {connection_id}")
current_room: Room | None = None
@@ -460,12 +482,20 @@ async def websocket_endpoint(websocket: WebSocket):
msg_type = data.get("type")
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")
# Use authenticated user's name if available
if authenticated_user and authenticated_user.display_name:
player_name = authenticated_user.display_name
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
await websocket.send_json({
@@ -484,6 +514,14 @@ async def websocket_endpoint(websocket: WebSocket):
room_code = data.get("room_code", "").upper()
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)
if not room:
await websocket.send_json({
@@ -509,7 +547,7 @@ async def websocket_endpoint(websocket: WebSocket):
# Use authenticated user's name if available
if authenticated_user and 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
await websocket.send_json({
@@ -744,6 +782,9 @@ async def websocket_endpoint(websocket: WebSocket):
)
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)
elif msg_type == "discard":
@@ -782,9 +823,15 @@ async def websocket_endpoint(websocket: WebSocket):
"optional": current_room.game.flip_is_optional,
})
else:
+ # Let client animation complete before CPU turn
+ await asyncio.sleep(0.5)
await check_and_run_cpu_turn(current_room)
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)
elif msg_type == "cancel_draw":
@@ -954,9 +1001,11 @@ async def websocket_endpoint(websocket: WebSocket):
})
# Clean up the room
+ room_code = current_room.code
for cpu in list(current_room.get_cpu_players()):
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
except WebSocketDisconnect:
@@ -972,12 +1021,12 @@ async def _process_stats_safe(room: Room):
notifications while stats are being processed.
"""
try:
- # Build mapping - for non-CPU players, the player_id is their user_id
- # (assigned during authentication or as a session UUID)
+ # Build mapping - use auth_user_id for authenticated players
+ # Only authenticated players get their stats tracked
player_user_ids = {}
for player_id, room_player in room.players.items():
- if not room_player.is_cpu:
- player_user_ids[player_id] = player_id
+ if not room_player.is_cpu and room_player.auth_user_id:
+ player_user_ids[player_id] = room_player.auth_user_id
# Find winner
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):
"""Handle a player leaving a room."""
+ room_code = room.code
room_player = room.remove_player(player_id)
# 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
for cpu in list(room.get_cpu_players()):
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:
await room.broadcast({
"type": "player_left",
diff --git a/server/room.py b/server/room.py
index 7a6074c..ee89dc7 100644
--- a/server/room.py
+++ b/server/room.py
@@ -19,7 +19,7 @@ from typing import Optional
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
@@ -33,11 +33,12 @@ class RoomPlayer:
in-game state like cards and scores.
Attributes:
- id: Unique player identifier.
+ id: Unique player identifier (connection_id for multi-tab support).
name: Display name.
websocket: WebSocket connection (None for CPU players).
is_host: Whether this player controls game settings.
is_cpu: Whether this is an AI-controlled player.
+ auth_user_id: Authenticated user ID for stats/limits (None for guests).
"""
id: str
@@ -45,6 +46,7 @@ class RoomPlayer:
websocket: Optional[WebSocket] = None
is_host: bool = False
is_cpu: bool = False
+ auth_user_id: Optional[str] = None
@dataclass
@@ -73,6 +75,7 @@ class Room:
player_id: str,
name: str,
websocket: WebSocket,
+ auth_user_id: Optional[str] = None,
) -> RoomPlayer:
"""
Add a human player to the room.
@@ -80,9 +83,10 @@ class Room:
The first player to join becomes the host.
Args:
- player_id: Unique identifier for the player.
+ player_id: Unique identifier for the player (connection_id).
name: Display name.
websocket: The player's WebSocket connection.
+ auth_user_id: Authenticated user ID for stats/limits (None for guests).
Returns:
The created RoomPlayer object.
@@ -93,6 +97,7 @@ class Room:
name=name,
websocket=websocket,
is_host=is_host,
+ auth_user_id=auth_user_id,
)
self.players[player_id] = room_player
@@ -117,9 +122,9 @@ class Room:
The created RoomPlayer, or None if profile unavailable.
"""
if profile_name:
- profile = assign_specific_profile(cpu_id, profile_name)
+ profile = assign_specific_profile(cpu_id, profile_name, self.code)
else:
- profile = assign_profile(cpu_id)
+ profile = assign_profile(cpu_id, self.code)
if not profile:
return None
@@ -157,9 +162,9 @@ class Room:
room_player = self.players.pop(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:
- release_profile(room_player.name)
+ release_profile(room_player.name, self.code)
# Assign new host if needed
if room_player.is_host and self.players:
diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore
new file mode 100644
index 0000000..6018a24
--- /dev/null
+++ b/tests/e2e/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+playwright-report/
+test-results/
+*.db
+*.db-journal
diff --git a/tests/e2e/bot/actions.ts b/tests/e2e/bot/actions.ts
new file mode 100644
index 0000000..c94bee7
--- /dev/null
+++ b/tests/e2e/bot/actions.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ await waitForAnimations(this.page, timeout);
+ }
+}
diff --git a/tests/e2e/bot/ai-brain.ts b/tests/e2e/bot/ai-brain.ts
new file mode 100644
index 0000000..2f47e6c
--- /dev/null
+++ b/tests/e2e/bot/ai-brain.ts
@@ -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 = {
+ '★': -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();
+
+ 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);
+ }
+}
diff --git a/tests/e2e/bot/golf-bot.ts b/tests/e2e/bot/golf-bot.ts
new file mode 100644
index 0000000..0d7e072
--- /dev/null
+++ b/tests/e2e/bot/golf-bot.ts
@@ -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;
+ 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 {
+ await this.page.goto(url || '/');
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ /**
+ * Create a new game room
+ */
+ async createGame(playerName: string): Promise {
+ // 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 {
+ // 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 {
+ // 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 {
+ // 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 {
+ return this.stateParser.getPhase();
+ }
+
+ /**
+ * Get full game state
+ */
+ async getGameState(): Promise {
+ return this.stateParser.getState();
+ }
+
+ /**
+ * Check if it's bot's turn
+ */
+ async isMyTurn(): Promise {
+ return this.stateParser.isMyTurn();
+ }
+
+ /**
+ * Wait for bot's turn
+ */
+ async waitForMyTurn(timeout: number = 30000): Promise {
+ return this.actions.waitForMyTurn(timeout);
+ }
+
+ /**
+ * Wait for any animation to complete
+ */
+ async waitForAnimation(): Promise {
+ await waitForAnimations(this.page);
+ }
+
+ /**
+ * Play a complete turn
+ */
+ async playTurn(): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 };
+ }
+}
diff --git a/tests/e2e/bot/index.ts b/tests/e2e/bot/index.ts
new file mode 100644
index 0000000..7731b12
--- /dev/null
+++ b/tests/e2e/bot/index.ts
@@ -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';
diff --git a/tests/e2e/bot/state-parser.ts b/tests/e2e/bot/state-parser.ts
new file mode 100644
index 0000000..6364b71
--- /dev/null
+++ b/tests/e2e/bot/state-parser.ts
@@ -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 {
+ // 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 {
+ 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 {
+ const text = await this.getText(SELECTORS.game.currentRound);
+ return parseInt(text) || 1;
+ }
+
+ /**
+ * Get total rounds
+ */
+ async getTotalRounds(): Promise {
+ const text = await this.getText(SELECTORS.game.totalRounds);
+ return parseInt(text) || 9;
+ }
+
+ /**
+ * Get status message text
+ */
+ async getStatusMessage(): Promise {
+ return this.getText(SELECTORS.game.statusMessage);
+ }
+
+ /**
+ * Check if final turn badge is visible
+ */
+ async isFinalTurn(): Promise {
+ return this.isVisible(SELECTORS.game.finalTurnBadge);
+ }
+
+ /**
+ * Get local player's state
+ */
+ async getMyPlayer(): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ // 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ const cards = await this.getMyCards();
+ return cards.filter(c => c.faceUp).length;
+ }
+
+ /**
+ * Count face-down cards for local player
+ */
+ async countFaceDownCards(): Promise {
+ const cards = await this.getMyCards();
+ return cards.filter(c => !c.faceUp).length;
+ }
+
+ /**
+ * Get positions of clickable cards
+ */
+ async getClickablePositions(): Promise {
+ const cards = await this.getMyCards();
+ return cards.filter(c => c.clickable).map(c => c.position);
+ }
+
+ /**
+ * Get positions of face-down cards
+ */
+ async getFaceDownPositions(): Promise {
+ const cards = await this.getMyCards();
+ return cards.filter(c => !c.faceUp).map(c => c.position);
+ }
+
+ // Helper methods
+ private async isVisible(selector: string): Promise {
+ const el = this.page.locator(selector);
+ return el.isVisible().catch(() => false);
+ }
+
+ private async getText(selector: string): Promise {
+ const el = this.page.locator(selector);
+ return (await el.textContent().catch(() => '')) || '';
+ }
+}
diff --git a/tests/e2e/health/animation-tracker.ts b/tests/e2e/health/animation-tracker.ts
new file mode 100644
index 0000000..d17d155
--- /dev/null
+++ b/tests/e2e/health/animation-tracker.ts
@@ -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 {
+ 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 {
+ 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 {
+ return this.waitForAnimationClass('flipping', timeoutMs);
+ }
+
+ async waitForSwapAnimation(timeoutMs: number = 3000): Promise {
+ return this.waitForAnimationClass('swap-animation', timeoutMs);
+ }
+
+ /**
+ * Wait for animation class to appear and disappear
+ */
+ private async waitForAnimationClass(
+ className: string,
+ timeoutMs: number
+ ): Promise {
+ 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;
+ }
+}
diff --git a/tests/e2e/health/freeze-detector.ts b/tests/e2e/health/freeze-detector.ts
new file mode 100644
index 0000000..990672c
--- /dev/null
+++ b/tests/e2e/health/freeze-detector.ts
@@ -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 {
+ 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 {
+ 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 = {
+ 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 {
+ 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 {
+ 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 {
+ 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];
+ }
+}
diff --git a/tests/e2e/health/index.ts b/tests/e2e/health/index.ts
new file mode 100644
index 0000000..ed90f9a
--- /dev/null
+++ b/tests/e2e/health/index.ts
@@ -0,0 +1,2 @@
+export { FreezeDetector, HealthCheck, HealthIssue } from './freeze-detector';
+export { AnimationTracker, AnimationEvent } from './animation-tracker';
diff --git a/tests/e2e/package-lock.json b/tests/e2e/package-lock.json
new file mode 100644
index 0000000..8cfed28
--- /dev/null
+++ b/tests/e2e/package-lock.json
@@ -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"
+ }
+ }
+}
diff --git a/tests/e2e/package.json b/tests/e2e/package.json
new file mode 100644
index 0000000..4fa6733
--- /dev/null
+++ b/tests/e2e/package.json
@@ -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"
+ }
+}
diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts
new file mode 100644
index 0000000..e98c921
--- /dev/null
+++ b/tests/e2e/playwright.config.ts
@@ -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
+});
diff --git a/tests/e2e/specs/full-game.spec.ts b/tests/e2e/specs/full-game.spec.ts
new file mode 100644
index 0000000..4fe9f8b
--- /dev/null
+++ b/tests/e2e/specs/full-game.spec.ts
@@ -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);
+ });
+});
diff --git a/tests/e2e/specs/stress.spec.ts b/tests/e2e/specs/stress.spec.ts
new file mode 100644
index 0000000..a3b8431
--- /dev/null
+++ b/tests/e2e/specs/stress.spec.ts
@@ -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[] = [];
+
+ 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);
+ }
+ });
+});
diff --git a/tests/e2e/specs/visual.spec.ts b/tests/e2e/specs/visual.spec.ts
new file mode 100644
index 0000000..76f6271
--- /dev/null
+++ b/tests/e2e/specs/visual.spec.ts
@@ -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();
+ });
+});
diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json
new file mode 100644
index 0000000..17e34e0
--- /dev/null
+++ b/tests/e2e/tsconfig.json
@@ -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"
+ ]
+}
diff --git a/tests/e2e/utils/index.ts b/tests/e2e/utils/index.ts
new file mode 100644
index 0000000..cee36d0
--- /dev/null
+++ b/tests/e2e/utils/index.ts
@@ -0,0 +1,12 @@
+export {
+ TIMING,
+ waitForAnimations,
+ waitForWebSocket,
+ safeWait,
+} from './timing';
+export {
+ SELECTORS,
+ playerCardSelector,
+ clickableCardSelector,
+ opponentCardSelector,
+} from './selectors';
diff --git a/tests/e2e/utils/selectors.ts b/tests/e2e/utils/selectors.ts
new file mode 100644
index 0000000..0cc3ee6
--- /dev/null
+++ b/tests/e2e/utils/selectors.ts
@@ -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);
+}
diff --git a/tests/e2e/utils/timing.ts b/tests/e2e/utils/timing.ts
new file mode 100644
index 0000000..5e03ac1
--- /dev/null
+++ b/tests/e2e/utils/timing.ts
@@ -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 {
+ 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 {
+ 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;
+}
diff --git a/tests/e2e/visual/index.ts b/tests/e2e/visual/index.ts
new file mode 100644
index 0000000..ae17750
--- /dev/null
+++ b/tests/e2e/visual/index.ts
@@ -0,0 +1,15 @@
+export {
+ ScreenshotValidator,
+ VisualExpectation,
+ CaptureResult,
+} from './screenshot-validator';
+export {
+ validateGameStart,
+ validateAfterInitialFlip,
+ validateDrawPhase,
+ validateAfterDraw,
+ validateRoundOver,
+ validateFinalTurn,
+ validateOpponentTurn,
+ validateResponsiveLayout,
+} from './visual-rules';
diff --git a/tests/e2e/visual/screenshot-validator.ts b/tests/e2e/visual/screenshot-validator.ts
new file mode 100644
index 0000000..69b5763
--- /dev/null
+++ b/tests/e2e/visual/screenshot-validator.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ const selector = SELECTORS.cards.playerCard(position);
+ return this.expectHasClass(selector, 'card-back');
+ }
+
+ /**
+ * Expect card at position to be clickable
+ */
+ async expectCardClickable(position: number): Promise {
+ const selector = SELECTORS.cards.playerCard(position);
+ return this.expectHasClass(selector, 'clickable');
+ }
+
+ /**
+ * Expect deck to be clickable
+ */
+ async expectDeckClickable(): Promise {
+ return this.expectHasClass(SELECTORS.game.deck, 'clickable');
+ }
+
+ /**
+ * Expect discard pile to have a card
+ */
+ async expectDiscardHasCard(): Promise {
+ return this.expectHasClass(SELECTORS.game.discard, 'has-card');
+ }
+
+ /**
+ * Expect final turn badge visible
+ */
+ async expectFinalTurnBadge(): Promise {
+ return this.expectVisible(SELECTORS.game.finalTurnBadge);
+ }
+
+ /**
+ * Expect held card floating visible
+ */
+ async expectHeldCardVisible(): Promise {
+ return this.expectVisible(SELECTORS.game.heldCardFloating);
+ }
+
+ /**
+ * Expect held card floating hidden
+ */
+ async expectHeldCardHidden(): Promise {
+ return this.expectNotVisible(SELECTORS.game.heldCardFloating);
+ }
+
+ /**
+ * Expect opponent to have current-turn class
+ */
+ async expectOpponentCurrentTurn(opponentIndex: number): Promise {
+ const selector = SELECTORS.cards.opponentArea(opponentIndex);
+ return this.expectHasClass(selector, 'current-turn');
+ }
+
+ /**
+ * Expect status message to contain text
+ */
+ async expectStatusMessage(text: string | RegExp): Promise {
+ return this.expectText(SELECTORS.game.statusMessage, text);
+ }
+
+ /**
+ * Run a batch of visual checks
+ */
+ async runChecks(
+ checks: Array<() => Promise>
+ ): 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 };
+ }
+}
diff --git a/tests/e2e/visual/visual-rules.ts b/tests/e2e/visual/visual-rules.ts
new file mode 100644
index 0000000..48cac31
--- /dev/null
+++ b/tests/e2e/visual/visual-rules.ts
@@ -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 };
+}