Version 2.0.0: Animation fixes, timing improvements, and E2E test suite
Animation fixes: - Fix held card positioning bug (was appearing at bottom of page) - Fix discard pile blank/white flash on turn transitions - Fix blank card at round end by skipping animations during round_over/game_over - Set card content before triggering flip animation to prevent flash - Center suit symbol on 10 cards Timing improvements: - Reduce post-discard delay from 700ms to 500ms - Reduce post-swap delay from 1800ms to 1000ms - Speed up swap flip animation from 1150ms to 550ms - Reduce CPU initial thinking delay from 150-250ms to 80-150ms - Pause now happens after swap completes (showing result) instead of before E2E test suite: - Add Playwright-based test bot that plays full games - State parser extracts game state from DOM for validation - AI brain ports decision logic for automated play - Freeze detector monitors for UI hangs - Visual validator checks CSS states - Full game, stress, and visual test specs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
724bf87c43
commit
6950769bc3
@ -12,12 +12,15 @@ class AnimationQueue {
|
|||||||
this.animationInProgress = false;
|
this.animationInProgress = false;
|
||||||
|
|
||||||
// Timing configuration (ms)
|
// Timing configuration (ms)
|
||||||
|
// Rhythm: action → settle → action → breathe
|
||||||
this.timing = {
|
this.timing = {
|
||||||
flipDuration: 400,
|
flipDuration: 540, // Must match CSS .card-inner transition (0.54s)
|
||||||
moveDuration: 300,
|
moveDuration: 270,
|
||||||
pauseAfterMove: 200,
|
pauseAfterFlip: 144, // Brief settle after flip before move
|
||||||
pauseAfterFlip: 100,
|
pauseAfterDiscard: 550, // Let discard land + pulse (400ms) + settle
|
||||||
pauseBetweenAnimations: 100
|
pauseBeforeNewCard: 150, // Anticipation before new card moves in
|
||||||
|
pauseAfterSwapComplete: 400, // Breathing room after swap completes
|
||||||
|
pauseBetweenAnimations: 90
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,21 +162,17 @@ class AnimationQueue {
|
|||||||
inner.classList.add('flipped');
|
inner.classList.add('flipped');
|
||||||
|
|
||||||
// Step 1: If card was face down, flip to reveal it
|
// Step 1: If card was face down, flip to reveal it
|
||||||
if (!oldCard.face_up) {
|
|
||||||
// Set up the front with the old card content (what we're discarding)
|
|
||||||
this.setCardFront(front, oldCard);
|
this.setCardFront(front, oldCard);
|
||||||
|
if (!oldCard.face_up) {
|
||||||
this.playSound('flip');
|
this.playSound('flip');
|
||||||
inner.classList.remove('flipped');
|
inner.classList.remove('flipped');
|
||||||
await this.delay(this.timing.flipDuration);
|
await this.delay(this.timing.flipDuration);
|
||||||
|
await this.delay(this.timing.pauseAfterFlip);
|
||||||
} else {
|
} else {
|
||||||
// Already face up, just show it
|
// Already face up, just show it immediately
|
||||||
this.setCardFront(front, oldCard);
|
|
||||||
inner.classList.remove('flipped');
|
inner.classList.remove('flipped');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.delay(100);
|
|
||||||
|
|
||||||
// Step 2: Move card to discard pile
|
// Step 2: Move card to discard pile
|
||||||
this.playSound('card');
|
this.playSound('card');
|
||||||
animCard.classList.add('moving');
|
animCard.classList.add('moving');
|
||||||
@ -181,8 +180,8 @@ class AnimationQueue {
|
|||||||
await this.delay(this.timing.moveDuration);
|
await this.delay(this.timing.moveDuration);
|
||||||
animCard.classList.remove('moving');
|
animCard.classList.remove('moving');
|
||||||
|
|
||||||
// Pause to show the card landing on discard
|
// Let discard land and pulse settle
|
||||||
await this.delay(this.timing.pauseAfterMove + 200);
|
await this.delay(this.timing.pauseAfterDiscard);
|
||||||
|
|
||||||
// Step 3: Create second card for the new card coming into hand
|
// Step 3: Create second card for the new card coming into hand
|
||||||
const newAnimCard = this.createAnimCard();
|
const newAnimCard = this.createAnimCard();
|
||||||
@ -197,6 +196,9 @@ class AnimationQueue {
|
|||||||
this.setCardFront(newFront, newCard);
|
this.setCardFront(newFront, newCard);
|
||||||
newInner.classList.remove('flipped');
|
newInner.classList.remove('flipped');
|
||||||
|
|
||||||
|
// Brief anticipation before new card moves
|
||||||
|
await this.delay(this.timing.pauseBeforeNewCard);
|
||||||
|
|
||||||
// Step 4: Move new card to the hand slot
|
// Step 4: Move new card to the hand slot
|
||||||
this.playSound('card');
|
this.playSound('card');
|
||||||
newAnimCard.classList.add('moving');
|
newAnimCard.classList.add('moving');
|
||||||
@ -204,8 +206,8 @@ class AnimationQueue {
|
|||||||
await this.delay(this.timing.moveDuration);
|
await this.delay(this.timing.moveDuration);
|
||||||
newAnimCard.classList.remove('moving');
|
newAnimCard.classList.remove('moving');
|
||||||
|
|
||||||
// Clean up animation cards
|
// Breathing room after swap completes
|
||||||
await this.delay(this.timing.pauseAfterMove);
|
await this.delay(this.timing.pauseAfterSwapComplete);
|
||||||
animCard.remove();
|
animCard.remove();
|
||||||
newAnimCard.remove();
|
newAnimCard.remove();
|
||||||
}
|
}
|
||||||
@ -297,7 +299,8 @@ class AnimationQueue {
|
|||||||
await this.delay(this.timing.moveDuration);
|
await this.delay(this.timing.moveDuration);
|
||||||
animCard.classList.remove('moving');
|
animCard.classList.remove('moving');
|
||||||
|
|
||||||
await this.delay(this.timing.pauseAfterMove);
|
// Same timing as player swap - let discard land and pulse settle
|
||||||
|
await this.delay(this.timing.pauseAfterDiscard);
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
animCard.remove();
|
animCard.remove();
|
||||||
@ -322,17 +325,13 @@ class AnimationQueue {
|
|||||||
|
|
||||||
// Move to holding position
|
// Move to holding position
|
||||||
this.playSound('card');
|
this.playSound('card');
|
||||||
await this.delay(50);
|
|
||||||
|
|
||||||
animCard.classList.add('moving');
|
animCard.classList.add('moving');
|
||||||
this.setCardPosition(animCard, holdingRect);
|
this.setCardPosition(animCard, holdingRect);
|
||||||
await this.delay(this.timing.moveDuration);
|
await this.delay(this.timing.moveDuration);
|
||||||
animCard.classList.remove('moving');
|
animCard.classList.remove('moving');
|
||||||
|
|
||||||
// The card stays face down until the player decides what to do
|
// Brief settle before state updates
|
||||||
// (the actual card reveal happens when server sends card_drawn)
|
await this.delay(this.timing.pauseBeforeNewCard);
|
||||||
|
|
||||||
await this.delay(this.timing.pauseAfterMove);
|
|
||||||
|
|
||||||
// Clean up - renderGame will show the holding card state
|
// Clean up - renderGame will show the holding card state
|
||||||
animCard.remove();
|
animCard.remove();
|
||||||
|
|||||||
595
client/app.js
595
client/app.js
@ -8,6 +8,7 @@ class GolfGame {
|
|||||||
this.isHost = false;
|
this.isHost = false;
|
||||||
this.gameState = null;
|
this.gameState = null;
|
||||||
this.drawnCard = null;
|
this.drawnCard = null;
|
||||||
|
this.drawnFromDiscard = false;
|
||||||
this.selectedCards = [];
|
this.selectedCards = [];
|
||||||
this.waitingForFlip = false;
|
this.waitingForFlip = false;
|
||||||
this.currentPlayers = [];
|
this.currentPlayers = [];
|
||||||
@ -189,6 +190,7 @@ class GolfGame {
|
|||||||
this.leaveRoomBtn = document.getElementById('leave-room-btn');
|
this.leaveRoomBtn = document.getElementById('leave-room-btn');
|
||||||
this.addCpuBtn = document.getElementById('add-cpu-btn');
|
this.addCpuBtn = document.getElementById('add-cpu-btn');
|
||||||
this.removeCpuBtn = document.getElementById('remove-cpu-btn');
|
this.removeCpuBtn = document.getElementById('remove-cpu-btn');
|
||||||
|
this.cpuControlsSection = document.getElementById('cpu-controls-section');
|
||||||
this.cpuSelectModal = document.getElementById('cpu-select-modal');
|
this.cpuSelectModal = document.getElementById('cpu-select-modal');
|
||||||
this.cpuProfilesGrid = document.getElementById('cpu-profiles-grid');
|
this.cpuProfilesGrid = document.getElementById('cpu-profiles-grid');
|
||||||
this.cancelCpuBtn = document.getElementById('cancel-cpu-btn');
|
this.cancelCpuBtn = document.getElementById('cancel-cpu-btn');
|
||||||
@ -202,17 +204,22 @@ class GolfGame {
|
|||||||
this.yourScore = document.getElementById('your-score');
|
this.yourScore = document.getElementById('your-score');
|
||||||
this.muteBtn = document.getElementById('mute-btn');
|
this.muteBtn = document.getElementById('mute-btn');
|
||||||
this.opponentsRow = document.getElementById('opponents-row');
|
this.opponentsRow = document.getElementById('opponents-row');
|
||||||
|
this.deckArea = document.querySelector('.deck-area');
|
||||||
this.deck = document.getElementById('deck');
|
this.deck = document.getElementById('deck');
|
||||||
this.discard = document.getElementById('discard');
|
this.discard = document.getElementById('discard');
|
||||||
this.discardContent = document.getElementById('discard-content');
|
this.discardContent = document.getElementById('discard-content');
|
||||||
this.discardBtn = document.getElementById('discard-btn');
|
this.discardBtn = document.getElementById('discard-btn');
|
||||||
this.cancelDrawBtn = document.getElementById('cancel-draw-btn');
|
|
||||||
this.skipFlipBtn = document.getElementById('skip-flip-btn');
|
this.skipFlipBtn = document.getElementById('skip-flip-btn');
|
||||||
this.knockEarlyBtn = document.getElementById('knock-early-btn');
|
this.knockEarlyBtn = document.getElementById('knock-early-btn');
|
||||||
this.playerCards = document.getElementById('player-cards');
|
this.playerCards = document.getElementById('player-cards');
|
||||||
this.playerArea = this.playerCards.closest('.player-area');
|
this.playerArea = this.playerCards.closest('.player-area');
|
||||||
this.swapAnimation = document.getElementById('swap-animation');
|
this.swapAnimation = document.getElementById('swap-animation');
|
||||||
this.swapCardFromHand = document.getElementById('swap-card-from-hand');
|
this.swapCardFromHand = document.getElementById('swap-card-from-hand');
|
||||||
|
this.heldCardSlot = document.getElementById('held-card-slot');
|
||||||
|
this.heldCardDisplay = document.getElementById('held-card-display');
|
||||||
|
this.heldCardContent = document.getElementById('held-card-content');
|
||||||
|
this.heldCardFloating = document.getElementById('held-card-floating');
|
||||||
|
this.heldCardFloatingContent = document.getElementById('held-card-floating-content');
|
||||||
this.scoreboard = document.getElementById('scoreboard');
|
this.scoreboard = document.getElementById('scoreboard');
|
||||||
this.scoreTable = document.getElementById('score-table').querySelector('tbody');
|
this.scoreTable = document.getElementById('score-table').querySelector('tbody');
|
||||||
this.standingsList = document.getElementById('standings-list');
|
this.standingsList = document.getElementById('standings-list');
|
||||||
@ -223,6 +230,11 @@ class GolfGame {
|
|||||||
this.activeRulesBar = document.getElementById('active-rules-bar');
|
this.activeRulesBar = document.getElementById('active-rules-bar');
|
||||||
this.activeRulesList = document.getElementById('active-rules-list');
|
this.activeRulesList = document.getElementById('active-rules-list');
|
||||||
this.finalTurnBadge = document.getElementById('final-turn-badge');
|
this.finalTurnBadge = document.getElementById('final-turn-badge');
|
||||||
|
|
||||||
|
// In-game auth elements
|
||||||
|
this.gameUsername = document.getElementById('game-username');
|
||||||
|
this.gameLogoutBtn = document.getElementById('game-logout-btn');
|
||||||
|
this.authBar = document.getElementById('auth-bar');
|
||||||
}
|
}
|
||||||
|
|
||||||
bindEvents() {
|
bindEvents() {
|
||||||
@ -233,7 +245,6 @@ class GolfGame {
|
|||||||
this.deck.addEventListener('click', () => { this.drawFromDeck(); });
|
this.deck.addEventListener('click', () => { this.drawFromDeck(); });
|
||||||
this.discard.addEventListener('click', () => { this.drawFromDiscard(); });
|
this.discard.addEventListener('click', () => { this.drawFromDiscard(); });
|
||||||
this.discardBtn.addEventListener('click', () => { this.playSound('card'); this.discardDrawn(); });
|
this.discardBtn.addEventListener('click', () => { this.playSound('card'); this.discardDrawn(); });
|
||||||
this.cancelDrawBtn.addEventListener('click', () => { this.playSound('click'); this.cancelDraw(); });
|
|
||||||
this.skipFlipBtn.addEventListener('click', () => { this.playSound('click'); this.skipFlip(); });
|
this.skipFlipBtn.addEventListener('click', () => { this.playSound('click'); this.skipFlip(); });
|
||||||
this.knockEarlyBtn.addEventListener('click', () => { this.playSound('success'); this.knockEarly(); });
|
this.knockEarlyBtn.addEventListener('click', () => { this.playSound('success'); this.knockEarly(); });
|
||||||
this.nextRoundBtn.addEventListener('click', () => { this.playSound('click'); this.nextRound(); });
|
this.nextRoundBtn.addEventListener('click', () => { this.playSound('click'); this.nextRound(); });
|
||||||
@ -244,6 +255,7 @@ class GolfGame {
|
|||||||
this.addSelectedCpusBtn.addEventListener('click', () => { this.playSound('success'); this.addSelectedCpus(); });
|
this.addSelectedCpusBtn.addEventListener('click', () => { this.playSound('success'); this.addSelectedCpus(); });
|
||||||
this.muteBtn.addEventListener('click', () => this.toggleSound());
|
this.muteBtn.addEventListener('click', () => this.toggleSound());
|
||||||
this.leaveGameBtn.addEventListener('click', () => { this.playSound('click'); this.leaveGame(); });
|
this.leaveGameBtn.addEventListener('click', () => { this.playSound('click'); this.leaveGame(); });
|
||||||
|
this.gameLogoutBtn.addEventListener('click', () => { this.playSound('click'); this.auth?.logout(); });
|
||||||
|
|
||||||
// Copy room code to clipboard
|
// Copy room code to clipboard
|
||||||
this.copyRoomCodeBtn.addEventListener('click', () => {
|
this.copyRoomCodeBtn.addEventListener('click', () => {
|
||||||
@ -401,8 +413,9 @@ class GolfGame {
|
|||||||
this.gameState = data.game_state;
|
this.gameState = data.game_state;
|
||||||
// Deep copy for previousState to avoid reference issues
|
// Deep copy for previousState to avoid reference issues
|
||||||
this.previousState = JSON.parse(JSON.stringify(data.game_state));
|
this.previousState = JSON.parse(JSON.stringify(data.game_state));
|
||||||
// Reset tracking for new round
|
// Reset all tracking for new round
|
||||||
this.locallyFlippedCards = new Set();
|
this.locallyFlippedCards = new Set();
|
||||||
|
this.selectedCards = [];
|
||||||
this.animatingPositions = new Set();
|
this.animatingPositions = new Set();
|
||||||
this.playSound('shuffle');
|
this.playSound('shuffle');
|
||||||
this.showGameScreen();
|
this.showGameScreen();
|
||||||
@ -443,6 +456,8 @@ class GolfGame {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'your_turn':
|
case 'your_turn':
|
||||||
|
// Brief delay to let animations settle
|
||||||
|
setTimeout(() => {
|
||||||
// Build toast based on available actions
|
// Build toast based on available actions
|
||||||
const canFlip = this.gameState && this.gameState.flip_as_action;
|
const canFlip = this.gameState && this.gameState.flip_as_action;
|
||||||
let canKnock = false;
|
let canKnock = false;
|
||||||
@ -460,11 +475,14 @@ class GolfGame {
|
|||||||
} else {
|
} else {
|
||||||
this.showToast('Your turn! Draw a card', 'your-turn');
|
this.showToast('Your turn! Draw a card', 'your-turn');
|
||||||
}
|
}
|
||||||
|
}, 200);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'card_drawn':
|
case 'card_drawn':
|
||||||
this.drawnCard = data.card;
|
this.drawnCard = data.card;
|
||||||
|
this.drawnFromDiscard = data.source === 'discard';
|
||||||
this.showDrawnCard();
|
this.showDrawnCard();
|
||||||
|
this.renderGame(); // Re-render to update discard pile
|
||||||
this.showToast('Swap with a card or discard', '', 3000);
|
this.showToast('Swap with a card or discard', '', 3000);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -750,6 +768,13 @@ class GolfGame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
drawFromDiscard() {
|
drawFromDiscard() {
|
||||||
|
// If holding a card drawn from discard, clicking discard puts it back
|
||||||
|
if (this.drawnCard && !this.gameState.can_discard) {
|
||||||
|
this.playSound('click');
|
||||||
|
this.cancelDraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.isMyTurn() || this.drawnCard || this.gameState.has_drawn_card) {
|
if (!this.isMyTurn() || this.drawnCard || this.gameState.has_drawn_card) {
|
||||||
if (this.gameState && !this.gameState.waiting_for_initial_flip) {
|
if (this.gameState && !this.gameState.waiting_for_initial_flip) {
|
||||||
this.playSound('reject');
|
this.playSound('reject');
|
||||||
@ -764,10 +789,98 @@ class GolfGame {
|
|||||||
|
|
||||||
discardDrawn() {
|
discardDrawn() {
|
||||||
if (!this.drawnCard) return;
|
if (!this.drawnCard) return;
|
||||||
|
const discardedCard = this.drawnCard;
|
||||||
|
const wasFromDeck = !this.drawnFromDiscard;
|
||||||
this.send({ type: 'discard' });
|
this.send({ type: 'discard' });
|
||||||
this.drawnCard = null;
|
this.drawnCard = null;
|
||||||
this.hideDrawnCard();
|
|
||||||
this.hideToast();
|
this.hideToast();
|
||||||
|
this.discardBtn.classList.add('hidden');
|
||||||
|
|
||||||
|
// Pre-emptively skip the flip animation - the server may broadcast the new state
|
||||||
|
// before our animation completes, and we don't want renderGame() to trigger
|
||||||
|
// the flip-in animation (which starts with opacity: 0, causing a flash)
|
||||||
|
this.skipNextDiscardFlip = true;
|
||||||
|
// Also update lastDiscardKey so renderGame() won't see a "change"
|
||||||
|
this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`;
|
||||||
|
|
||||||
|
if (wasFromDeck) {
|
||||||
|
// Swoop animation: deck → discard (makes it clear the card is being tossed)
|
||||||
|
this.animateDeckToDiscardSwoop(discardedCard);
|
||||||
|
} else {
|
||||||
|
// Simple drop (drawn from discard, putting it back - though this requires swap usually)
|
||||||
|
this.heldCardFloating.classList.add('dropping');
|
||||||
|
this.playSound('card');
|
||||||
|
setTimeout(() => {
|
||||||
|
this.heldCardFloating.classList.add('hidden');
|
||||||
|
this.heldCardFloating.classList.remove('dropping');
|
||||||
|
this.updateDiscardPileDisplay(discardedCard);
|
||||||
|
this.pulseDiscardLand();
|
||||||
|
this.skipNextDiscardFlip = true;
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swoop animation for discarding a card drawn from deck
|
||||||
|
animateDeckToDiscardSwoop(card) {
|
||||||
|
const deckRect = this.deck.getBoundingClientRect();
|
||||||
|
const discardRect = this.discard.getBoundingClientRect();
|
||||||
|
const floater = this.heldCardFloating;
|
||||||
|
|
||||||
|
// Reset any previous animation state
|
||||||
|
floater.classList.remove('dropping', 'swooping', 'landed');
|
||||||
|
|
||||||
|
// Instantly position at deck (card appears to come from deck)
|
||||||
|
floater.style.transition = 'none';
|
||||||
|
floater.style.left = `${deckRect.left}px`;
|
||||||
|
floater.style.top = `${deckRect.top}px`;
|
||||||
|
floater.style.width = `${deckRect.width}px`;
|
||||||
|
floater.style.height = `${deckRect.height}px`;
|
||||||
|
floater.style.transform = 'scale(1) rotate(0deg)';
|
||||||
|
|
||||||
|
// Force reflow
|
||||||
|
floater.offsetHeight;
|
||||||
|
|
||||||
|
// Start swoop to discard
|
||||||
|
floater.style.transition = '';
|
||||||
|
floater.classList.add('swooping');
|
||||||
|
floater.style.left = `${discardRect.left}px`;
|
||||||
|
floater.style.top = `${discardRect.top}px`;
|
||||||
|
floater.style.width = `${discardRect.width}px`;
|
||||||
|
floater.style.height = `${discardRect.height}px`;
|
||||||
|
|
||||||
|
this.playSound('card');
|
||||||
|
|
||||||
|
// After swoop completes, settle and show on discard pile
|
||||||
|
setTimeout(() => {
|
||||||
|
floater.classList.add('landed');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
floater.classList.add('hidden');
|
||||||
|
floater.classList.remove('swooping', 'landed');
|
||||||
|
// Clear all inline styles from the animation
|
||||||
|
floater.style.cssText = '';
|
||||||
|
this.updateDiscardPileDisplay(card);
|
||||||
|
this.pulseDiscardLand();
|
||||||
|
this.skipNextDiscardFlip = true;
|
||||||
|
}, 150); // Brief settle
|
||||||
|
}, 350); // Match swoop transition duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the discard pile display with a card
|
||||||
|
updateDiscardPileDisplay(card) {
|
||||||
|
this.discard.classList.remove('picked-up', 'disabled');
|
||||||
|
this.discard.classList.add('has-card', 'card-front');
|
||||||
|
this.discard.classList.remove('red', 'black', 'joker');
|
||||||
|
|
||||||
|
if (card.rank === '★') {
|
||||||
|
this.discard.classList.add('joker');
|
||||||
|
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
|
||||||
|
this.discardContent.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
|
||||||
|
} else {
|
||||||
|
this.discard.classList.add(card.suit === 'hearts' || card.suit === 'diamonds' ? 'red' : 'black');
|
||||||
|
this.discardContent.innerHTML = this.renderCardContent(card);
|
||||||
|
}
|
||||||
|
this.lastDiscardKey = `${card.rank}-${card.suit}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelDraw() {
|
cancelDraw() {
|
||||||
@ -786,6 +899,7 @@ class GolfGame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Animate player swapping drawn card with a card in their hand
|
// Animate player swapping drawn card with a card in their hand
|
||||||
|
// Uses flip-in-place + teleport (no zipping movement)
|
||||||
animateSwap(position) {
|
animateSwap(position) {
|
||||||
const cardElements = this.playerCards.querySelectorAll('.card');
|
const cardElements = this.playerCards.querySelectorAll('.card');
|
||||||
const handCardEl = cardElements[position];
|
const handCardEl = cardElements[position];
|
||||||
@ -801,12 +915,16 @@ class GolfGame {
|
|||||||
|
|
||||||
// Get positions
|
// Get positions
|
||||||
const handRect = handCardEl.getBoundingClientRect();
|
const handRect = handCardEl.getBoundingClientRect();
|
||||||
const discardRect = this.discard.getBoundingClientRect();
|
|
||||||
|
|
||||||
// Set up the animated card at hand position
|
// Set up the animated card at hand position
|
||||||
const swapCard = this.swapCardFromHand;
|
const swapCard = this.swapCardFromHand;
|
||||||
|
if (!swapCard) {
|
||||||
|
// Animation element missing - fall back to non-animated swap
|
||||||
|
console.error('Swap animation element missing, falling back to direct swap');
|
||||||
|
this.swapCard(position);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const swapCardFront = swapCard.querySelector('.swap-card-front');
|
const swapCardFront = swapCard.querySelector('.swap-card-front');
|
||||||
const swapCardInner = swapCard.querySelector('.swap-card-inner');
|
|
||||||
|
|
||||||
// Position at the hand card location
|
// Position at the hand card location
|
||||||
swapCard.style.left = handRect.left + 'px';
|
swapCard.style.left = handRect.left + 'px';
|
||||||
@ -814,97 +932,67 @@ class GolfGame {
|
|||||||
swapCard.style.width = handRect.width + 'px';
|
swapCard.style.width = handRect.width + 'px';
|
||||||
swapCard.style.height = handRect.height + 'px';
|
swapCard.style.height = handRect.height + 'px';
|
||||||
|
|
||||||
// Reset state
|
// Reset state - no moving class needed
|
||||||
swapCard.classList.remove('flipping', 'moving');
|
swapCard.classList.remove('flipping', 'moving');
|
||||||
swapCardFront.innerHTML = '';
|
swapCardFront.innerHTML = '';
|
||||||
swapCardFront.className = 'swap-card-front';
|
swapCardFront.className = 'swap-card-front';
|
||||||
|
|
||||||
if (isAlreadyFaceUp && card) {
|
|
||||||
// FACE-UP CARD: Show card content immediately, then slide to discard
|
|
||||||
if (card.rank === '★') {
|
|
||||||
swapCardFront.classList.add('joker');
|
|
||||||
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
|
|
||||||
swapCardFront.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
|
|
||||||
} else {
|
|
||||||
swapCardFront.classList.add(card.suit === 'hearts' || card.suit === 'diamonds' ? 'red' : 'black');
|
|
||||||
const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[card.suit];
|
|
||||||
swapCardFront.innerHTML = `${card.rank}<br>${suitSymbol}`;
|
|
||||||
}
|
|
||||||
swapCard.classList.add('flipping'); // Show front immediately
|
|
||||||
|
|
||||||
// Hide the actual hand card and discard
|
|
||||||
handCardEl.classList.add('swap-out');
|
|
||||||
this.discard.classList.add('swap-to-hand');
|
|
||||||
this.swapAnimation.classList.remove('hidden');
|
|
||||||
|
|
||||||
// Mark animating
|
// Mark animating
|
||||||
this.swapAnimationInProgress = true;
|
this.swapAnimationInProgress = true;
|
||||||
this.swapAnimationCardEl = handCardEl;
|
this.swapAnimationCardEl = handCardEl;
|
||||||
|
this.swapAnimationHandCardEl = handCardEl;
|
||||||
|
|
||||||
|
if (isAlreadyFaceUp && card) {
|
||||||
|
// FACE-UP CARD: Subtle pulse animation (no flip needed)
|
||||||
this.swapAnimationContentSet = true;
|
this.swapAnimationContentSet = true;
|
||||||
|
|
||||||
// Send swap
|
// Apply subtle swap pulse to both cards
|
||||||
|
handCardEl.classList.add('swap-pulse');
|
||||||
|
this.heldCardFloating.classList.add('swap-pulse');
|
||||||
|
|
||||||
|
// Play a soft sound for the swap
|
||||||
|
this.playSound('card');
|
||||||
|
|
||||||
|
// Send swap and let render handle the update
|
||||||
this.send({ type: 'swap', position });
|
this.send({ type: 'swap', position });
|
||||||
this.drawnCard = null;
|
this.drawnCard = null;
|
||||||
|
this.skipNextDiscardFlip = true;
|
||||||
|
|
||||||
// Slide to discard
|
// Complete after pulse animation
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
swapCard.classList.add('moving');
|
handCardEl.classList.remove('swap-pulse');
|
||||||
swapCard.style.left = discardRect.left + 'px';
|
this.heldCardFloating.classList.remove('swap-pulse');
|
||||||
swapCard.style.top = discardRect.top + 'px';
|
this.completeSwapAnimation(null);
|
||||||
}, 50);
|
}, 440);
|
||||||
|
|
||||||
// Complete
|
|
||||||
setTimeout(() => {
|
|
||||||
this.swapAnimation.classList.add('hidden');
|
|
||||||
swapCard.classList.remove('flipping', 'moving');
|
|
||||||
handCardEl.classList.remove('swap-out');
|
|
||||||
this.discard.classList.remove('swap-to-hand');
|
|
||||||
this.swapAnimationInProgress = false;
|
|
||||||
this.hideDrawnCard();
|
|
||||||
|
|
||||||
if (this.pendingGameState) {
|
|
||||||
this.gameState = this.pendingGameState;
|
|
||||||
this.pendingGameState = null;
|
|
||||||
this.renderGame();
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
} else {
|
} else {
|
||||||
// FACE-DOWN CARD: Just slide card-back to discard (no flip mid-air)
|
// FACE-DOWN CARD: Flip in place to reveal, then teleport
|
||||||
// The new card will appear instantly when state updates
|
|
||||||
|
|
||||||
// Don't use overlay for face-down - just send swap and let state handle it
|
// Hide the actual hand card
|
||||||
// This avoids the clunky "flip to empty front" issue
|
handCardEl.classList.add('swap-out');
|
||||||
this.swapAnimationInProgress = true;
|
this.swapAnimation.classList.remove('hidden');
|
||||||
this.swapAnimationCardEl = handCardEl;
|
|
||||||
|
// Store references for updateSwapAnimation
|
||||||
|
this.swapAnimationFront = swapCardFront;
|
||||||
|
this.swapAnimationCard = swapCard;
|
||||||
this.swapAnimationContentSet = false;
|
this.swapAnimationContentSet = false;
|
||||||
|
|
||||||
// Send swap
|
// Send swap - the flip will happen in updateSwapAnimation when server responds
|
||||||
this.send({ type: 'swap', position });
|
this.send({ type: 'swap', position });
|
||||||
this.drawnCard = null;
|
this.drawnCard = null;
|
||||||
|
this.skipNextDiscardFlip = true;
|
||||||
// Brief visual feedback - hide drawn card area
|
|
||||||
this.discard.classList.add('swap-to-hand');
|
|
||||||
handCardEl.classList.add('swap-out');
|
|
||||||
|
|
||||||
// Short timeout then let state update handle it
|
|
||||||
setTimeout(() => {
|
|
||||||
this.discard.classList.remove('swap-to-hand');
|
|
||||||
handCardEl.classList.remove('swap-out');
|
|
||||||
this.swapAnimationInProgress = false;
|
|
||||||
this.hideDrawnCard();
|
|
||||||
|
|
||||||
if (this.pendingGameState) {
|
|
||||||
this.gameState = this.pendingGameState;
|
|
||||||
this.pendingGameState = null;
|
|
||||||
this.renderGame();
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the animated card with actual card content when server responds
|
// Update the animated card with actual card content when server responds
|
||||||
updateSwapAnimation(card) {
|
updateSwapAnimation(card) {
|
||||||
if (!this.swapAnimationFront || !card) return;
|
// Safety: if animation references are missing, complete immediately to avoid freeze
|
||||||
|
if (!this.swapAnimationFront || !card) {
|
||||||
|
if (this.swapAnimationInProgress && !this.swapAnimationContentSet) {
|
||||||
|
console.error('Swap animation incomplete: missing front element or card data');
|
||||||
|
this.completeSwapAnimation(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Skip if we already set the content (face-up card swap)
|
// Skip if we already set the content (face-up card swap)
|
||||||
if (this.swapAnimationContentSet) return;
|
if (this.swapAnimationContentSet) return;
|
||||||
@ -923,6 +1011,61 @@ class GolfGame {
|
|||||||
}
|
}
|
||||||
this.swapAnimationFront.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`;
|
this.swapAnimationFront.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.swapAnimationContentSet = true;
|
||||||
|
|
||||||
|
// Quick flip to reveal, then complete - server will pause before next turn
|
||||||
|
if (this.swapAnimationCard) {
|
||||||
|
// Step 1: Flip to reveal (quick)
|
||||||
|
this.swapAnimationCard.classList.add('flipping');
|
||||||
|
this.playSound('flip');
|
||||||
|
|
||||||
|
// Step 2: Brief pulse after flip completes
|
||||||
|
setTimeout(() => {
|
||||||
|
this.swapAnimationCard.classList.add('swap-pulse');
|
||||||
|
this.playSound('card');
|
||||||
|
}, 350);
|
||||||
|
|
||||||
|
// Step 3: Complete animation - the pause to see the result happens
|
||||||
|
// on the server side before the next CPU turn starts
|
||||||
|
setTimeout(() => {
|
||||||
|
this.completeSwapAnimation(null);
|
||||||
|
}, 550);
|
||||||
|
} else {
|
||||||
|
// Fallback: animation element missing, complete immediately to avoid freeze
|
||||||
|
console.error('Swap animation element missing, completing immediately');
|
||||||
|
this.completeSwapAnimation(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completeSwapAnimation(heldCard) {
|
||||||
|
// Hide everything
|
||||||
|
this.swapAnimation.classList.add('hidden');
|
||||||
|
if (this.swapAnimationCard) {
|
||||||
|
this.swapAnimationCard.classList.remove('hidden', 'flipping', 'moving', 'swap-pulse');
|
||||||
|
}
|
||||||
|
if (heldCard) {
|
||||||
|
heldCard.classList.remove('flipping', 'moving');
|
||||||
|
heldCard.classList.add('hidden');
|
||||||
|
}
|
||||||
|
if (this.swapAnimationHandCardEl) {
|
||||||
|
this.swapAnimationHandCardEl.classList.remove('swap-out');
|
||||||
|
}
|
||||||
|
this.discard.classList.remove('swap-to-hand');
|
||||||
|
this.swapAnimationInProgress = false;
|
||||||
|
this.swapAnimationFront = null;
|
||||||
|
this.swapAnimationCard = null;
|
||||||
|
this.swapAnimationDiscardRect = null;
|
||||||
|
this.swapAnimationHandCardEl = null;
|
||||||
|
this.swapAnimationHandRect = null;
|
||||||
|
this.discardBtn.classList.add('hidden');
|
||||||
|
this.heldCardFloating.classList.add('hidden');
|
||||||
|
|
||||||
|
if (this.pendingGameState) {
|
||||||
|
this.gameState = this.pendingGameState;
|
||||||
|
this.pendingGameState = null;
|
||||||
|
this.renderGame();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
flipCard(position) {
|
flipCard(position) {
|
||||||
@ -987,7 +1130,7 @@ class GolfGame {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (discardChanged && wasOtherPlayer) {
|
if (discardChanged) {
|
||||||
// Check if the previous player actually SWAPPED (has a new face-up card)
|
// Check if the previous player actually SWAPPED (has a new face-up card)
|
||||||
// vs just discarding the drawn card (no hand change)
|
// vs just discarding the drawn card (no hand change)
|
||||||
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
|
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
|
||||||
@ -998,34 +1141,38 @@ class GolfGame {
|
|||||||
// Could be: face-down -> face-up (new reveal)
|
// Could be: face-down -> face-up (new reveal)
|
||||||
// Or: different card at same position (replaced visible card)
|
// Or: different card at same position (replaced visible card)
|
||||||
let swappedPosition = -1;
|
let swappedPosition = -1;
|
||||||
|
let wasFaceUp = false; // Track if old card was already face-up
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
const oldCard = oldPlayer.cards[i];
|
const oldCard = oldPlayer.cards[i];
|
||||||
const newCard = newPlayer.cards[i];
|
const newCard = newPlayer.cards[i];
|
||||||
const wasUp = oldCard?.face_up;
|
const wasUp = oldCard?.face_up;
|
||||||
const isUp = newCard?.face_up;
|
const isUp = newCard?.face_up;
|
||||||
|
|
||||||
// Case 1: face-down became face-up
|
// Case 1: face-down became face-up (needs flip)
|
||||||
if (!wasUp && isUp) {
|
if (!wasUp && isUp) {
|
||||||
swappedPosition = i;
|
swappedPosition = i;
|
||||||
|
wasFaceUp = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Case 2: both face-up but different card (rank or suit changed)
|
// Case 2: both face-up but different card (no flip needed)
|
||||||
if (wasUp && isUp && oldCard.rank && newCard.rank) {
|
if (wasUp && isUp && oldCard.rank && newCard.rank) {
|
||||||
if (oldCard.rank !== newCard.rank || oldCard.suit !== newCard.suit) {
|
if (oldCard.rank !== newCard.rank || oldCard.suit !== newCard.suit) {
|
||||||
swappedPosition = i;
|
swappedPosition = i;
|
||||||
|
wasFaceUp = true; // Face-to-face swap
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (swappedPosition >= 0) {
|
if (swappedPosition >= 0 && wasOtherPlayer) {
|
||||||
// Player swapped - animate from the actual position that changed
|
// Opponent swapped - animate from the actual position that changed
|
||||||
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition);
|
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
|
||||||
} else {
|
} else if (swappedPosition < 0) {
|
||||||
// Player drew and discarded without swapping
|
// Player drew and discarded without swapping - pulse for everyone
|
||||||
// Animate card going from deck area to discard
|
|
||||||
this.fireDiscardAnimation(newDiscard);
|
this.fireDiscardAnimation(newDiscard);
|
||||||
}
|
}
|
||||||
|
// Skip the card-flip-in animation since we just did our own
|
||||||
|
this.skipNextDiscardFlip = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1041,54 +1188,21 @@ class GolfGame {
|
|||||||
void pile.offsetWidth;
|
void pile.offsetWidth;
|
||||||
pile.classList.add('draw-pulse');
|
pile.classList.add('draw-pulse');
|
||||||
// Remove class after animation completes
|
// Remove class after animation completes
|
||||||
setTimeout(() => pile.classList.remove('draw-pulse'), 400);
|
setTimeout(() => pile.classList.remove('draw-pulse'), 450);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire animation for discard without swap (card goes deck -> discard)
|
// Pulse discard pile when a card lands on it
|
||||||
|
pulseDiscardLand() {
|
||||||
|
this.discard.classList.remove('discard-land');
|
||||||
|
void this.discard.offsetWidth;
|
||||||
|
this.discard.classList.add('discard-land');
|
||||||
|
setTimeout(() => this.discard.classList.remove('discard-land'), 460);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire animation for discard without swap (card lands on discard pile face-up)
|
||||||
fireDiscardAnimation(discardCard) {
|
fireDiscardAnimation(discardCard) {
|
||||||
const deckRect = this.deck.getBoundingClientRect();
|
// Card is already known - just pulse to show it landed (no flip needed)
|
||||||
const discardRect = this.discard.getBoundingClientRect();
|
this.pulseDiscardLand();
|
||||||
const swapCard = this.swapCardFromHand;
|
|
||||||
const swapCardFront = swapCard.querySelector('.swap-card-front');
|
|
||||||
|
|
||||||
// Start at deck position
|
|
||||||
swapCard.style.left = deckRect.left + 'px';
|
|
||||||
swapCard.style.top = deckRect.top + 'px';
|
|
||||||
swapCard.style.width = deckRect.width + 'px';
|
|
||||||
swapCard.style.height = deckRect.height + 'px';
|
|
||||||
swapCard.classList.remove('flipping', 'moving');
|
|
||||||
|
|
||||||
// Set card content
|
|
||||||
swapCardFront.className = 'swap-card-front';
|
|
||||||
if (discardCard.rank === '★') {
|
|
||||||
swapCardFront.classList.add('joker');
|
|
||||||
const jokerIcon = discardCard.suit === 'hearts' ? '🐉' : '👹';
|
|
||||||
swapCardFront.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
|
|
||||||
} else {
|
|
||||||
swapCardFront.classList.add(discardCard.suit === 'hearts' || discardCard.suit === 'diamonds' ? 'red' : 'black');
|
|
||||||
swapCardFront.innerHTML = `${discardCard.rank}<br>${this.getSuitSymbol(discardCard.suit)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.swapAnimation.classList.remove('hidden');
|
|
||||||
|
|
||||||
// Flip to reveal card
|
|
||||||
setTimeout(() => {
|
|
||||||
swapCard.classList.add('flipping');
|
|
||||||
this.playSound('flip');
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
// Move to discard
|
|
||||||
setTimeout(() => {
|
|
||||||
swapCard.classList.add('moving');
|
|
||||||
swapCard.style.left = discardRect.left + 'px';
|
|
||||||
swapCard.style.top = discardRect.top + 'px';
|
|
||||||
}, 400);
|
|
||||||
|
|
||||||
// Complete
|
|
||||||
setTimeout(() => {
|
|
||||||
this.swapAnimation.classList.add('hidden');
|
|
||||||
swapCard.classList.remove('flipping', 'moving');
|
|
||||||
}, 800);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get rotation angle from an element's computed transform
|
// Get rotation angle from an element's computed transform
|
||||||
@ -1108,8 +1222,9 @@ class GolfGame {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire a swap animation (non-blocking)
|
// Fire a swap animation (non-blocking) - flip in place at opponent's position
|
||||||
fireSwapAnimation(playerId, discardCard, position) {
|
// Uses flip-in-place for face-down cards, subtle pulse for face-up cards
|
||||||
|
fireSwapAnimation(playerId, discardCard, position, wasFaceUp = false) {
|
||||||
|
|
||||||
// Find source position - the actual card that was swapped
|
// Find source position - the actual card that was swapped
|
||||||
const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area');
|
const opponentAreas = this.opponentsRow.querySelectorAll('.opponent-area');
|
||||||
@ -1132,26 +1247,37 @@ class GolfGame {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sourceRect) {
|
// Face-to-face swap: use subtle pulse on the card, no flip needed
|
||||||
const discardRect = this.discard.getBoundingClientRect();
|
if (wasFaceUp && sourceCardEl) {
|
||||||
sourceRect = { left: discardRect.left, top: discardRect.top - 100, width: discardRect.width, height: discardRect.height };
|
sourceCardEl.classList.add('swap-pulse');
|
||||||
|
this.playSound('card');
|
||||||
|
setTimeout(() => {
|
||||||
|
sourceCardEl.classList.remove('swap-pulse');
|
||||||
|
}, 400);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Face-down to face-up: flip to reveal, pause to see it, then pulse before swap
|
||||||
|
if (!sourceRect) {
|
||||||
|
// Fallback: just show flip at discard position
|
||||||
const discardRect = this.discard.getBoundingClientRect();
|
const discardRect = this.discard.getBoundingClientRect();
|
||||||
|
sourceRect = { left: discardRect.left, top: discardRect.top, width: discardRect.width, height: discardRect.height };
|
||||||
|
}
|
||||||
|
|
||||||
const swapCard = this.swapCardFromHand;
|
const swapCard = this.swapCardFromHand;
|
||||||
const swapCardFront = swapCard.querySelector('.swap-card-front');
|
const swapCardFront = swapCard.querySelector('.swap-card-front');
|
||||||
const swapCardInner = swapCard.querySelector('.swap-card-inner');
|
|
||||||
|
|
||||||
|
// Position at opponent's card location (flip in place there)
|
||||||
swapCard.style.left = sourceRect.left + 'px';
|
swapCard.style.left = sourceRect.left + 'px';
|
||||||
swapCard.style.top = sourceRect.top + 'px';
|
swapCard.style.top = sourceRect.top + 'px';
|
||||||
swapCard.style.width = sourceRect.width + 'px';
|
swapCard.style.width = sourceRect.width + 'px';
|
||||||
swapCard.style.height = sourceRect.height + 'px';
|
swapCard.style.height = sourceRect.height + 'px';
|
||||||
swapCard.classList.remove('flipping', 'moving');
|
swapCard.classList.remove('flipping', 'moving', 'swap-pulse');
|
||||||
|
|
||||||
// Apply source rotation to match the arch layout
|
// Apply source rotation to match the arch layout
|
||||||
swapCard.style.transform = `rotate(${sourceRotation}deg)`;
|
swapCard.style.transform = `rotate(${sourceRotation}deg)`;
|
||||||
|
|
||||||
// Set card content
|
// Set card content (the card being discarded - what was hidden)
|
||||||
swapCardFront.className = 'swap-card-front';
|
swapCardFront.className = 'swap-card-front';
|
||||||
if (discardCard.rank === '★') {
|
if (discardCard.rank === '★') {
|
||||||
swapCardFront.classList.add('joker');
|
swapCardFront.classList.add('joker');
|
||||||
@ -1165,24 +1291,26 @@ class GolfGame {
|
|||||||
if (sourceCardEl) sourceCardEl.classList.add('swap-out');
|
if (sourceCardEl) sourceCardEl.classList.add('swap-out');
|
||||||
this.swapAnimation.classList.remove('hidden');
|
this.swapAnimation.classList.remove('hidden');
|
||||||
|
|
||||||
// Timing: flip takes ~400ms, then move takes ~400ms
|
// Step 1: Flip to reveal the hidden card
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
swapCard.classList.add('flipping');
|
swapCard.classList.add('flipping');
|
||||||
this.playSound('flip');
|
this.playSound('flip');
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
|
// Step 2: After flip, pause to see the card then pulse before being replaced
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Start move AFTER flip completes - also animate rotation back to 0
|
swapCard.classList.add('swap-pulse');
|
||||||
swapCard.classList.add('moving');
|
this.playSound('card');
|
||||||
swapCard.style.left = discardRect.left + 'px';
|
}, 850);
|
||||||
swapCard.style.top = discardRect.top + 'px';
|
|
||||||
swapCard.style.transform = 'rotate(0deg)';
|
// Step 3: Strategic pause to show discarded card, then complete
|
||||||
}, 500);
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.swapAnimation.classList.add('hidden');
|
this.swapAnimation.classList.add('hidden');
|
||||||
swapCard.classList.remove('flipping', 'moving');
|
swapCard.classList.remove('flipping', 'moving', 'swap-pulse');
|
||||||
swapCard.style.transform = '';
|
swapCard.style.transform = '';
|
||||||
if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
|
if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
|
||||||
}, 1000);
|
this.pulseDiscardLand();
|
||||||
|
}, 1400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire a flip animation for local player's card (non-blocking)
|
// Fire a flip animation for local player's card (non-blocking)
|
||||||
@ -1233,7 +1361,7 @@ class GolfGame {
|
|||||||
swapCard.classList.remove('flipping');
|
swapCard.classList.remove('flipping');
|
||||||
cardEl.classList.remove('swap-out');
|
cardEl.classList.remove('swap-out');
|
||||||
this.animatingPositions.delete(key);
|
this.animatingPositions.delete(key);
|
||||||
}, 450);
|
}, 550);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire a flip animation for opponent card (non-blocking)
|
// Fire a flip animation for opponent card (non-blocking)
|
||||||
@ -1295,7 +1423,7 @@ class GolfGame {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
swapCard.classList.add('flipping');
|
swapCard.classList.add('flipping');
|
||||||
this.playSound('flip');
|
this.playSound('flip');
|
||||||
}, 50);
|
}, 60);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.swapAnimation.classList.add('hidden');
|
this.swapAnimation.classList.add('hidden');
|
||||||
@ -1303,7 +1431,7 @@ class GolfGame {
|
|||||||
swapCard.style.transform = '';
|
swapCard.style.transform = '';
|
||||||
cardEl.classList.remove('swap-out');
|
cardEl.classList.remove('swap-out');
|
||||||
this.animatingPositions.delete(key);
|
this.animatingPositions.delete(key);
|
||||||
}, 450);
|
}, 560);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCardClick(position) {
|
handleCardClick(position) {
|
||||||
@ -1339,7 +1467,9 @@ class GolfGame {
|
|||||||
// Initial flip phase
|
// Initial flip phase
|
||||||
if (this.gameState.waiting_for_initial_flip) {
|
if (this.gameState.waiting_for_initial_flip) {
|
||||||
if (card.face_up) return;
|
if (card.face_up) return;
|
||||||
|
// Use Set to prevent duplicates - check both tracking mechanisms
|
||||||
if (this.locallyFlippedCards.has(position)) return;
|
if (this.locallyFlippedCards.has(position)) return;
|
||||||
|
if (this.selectedCards.includes(position)) return;
|
||||||
|
|
||||||
const requiredFlips = this.gameState.initial_flips || 2;
|
const requiredFlips = this.gameState.initial_flips || 2;
|
||||||
|
|
||||||
@ -1353,12 +1483,15 @@ class GolfGame {
|
|||||||
// Re-render to show flipped state
|
// Re-render to show flipped state
|
||||||
this.renderGame();
|
this.renderGame();
|
||||||
|
|
||||||
if (this.selectedCards.length === requiredFlips) {
|
// Use Set to ensure unique positions when sending to server
|
||||||
this.send({ type: 'flip_initial', positions: this.selectedCards });
|
const uniquePositions = [...new Set(this.selectedCards)];
|
||||||
|
if (uniquePositions.length === requiredFlips) {
|
||||||
|
this.send({ type: 'flip_initial', positions: uniquePositions });
|
||||||
this.selectedCards = [];
|
this.selectedCards = [];
|
||||||
|
// Note: locallyFlippedCards is cleared when server confirms (in game_state handler)
|
||||||
this.hideToast();
|
this.hideToast();
|
||||||
} else {
|
} else {
|
||||||
const remaining = requiredFlips - this.selectedCards.length;
|
const remaining = requiredFlips - uniquePositions.length;
|
||||||
this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, '', 5000);
|
this.showToast(`Select ${remaining} more card${remaining > 1 ? 's' : ''} to flip`, '', 5000);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@ -1417,6 +1550,25 @@ class GolfGame {
|
|||||||
this.rulesScreen.classList.remove('active');
|
this.rulesScreen.classList.remove('active');
|
||||||
}
|
}
|
||||||
screen.classList.add('active');
|
screen.classList.add('active');
|
||||||
|
|
||||||
|
// Handle auth bar visibility - hide global bar during game, show in-game controls instead
|
||||||
|
const isGameScreen = screen === this.gameScreen;
|
||||||
|
const user = this.auth?.user;
|
||||||
|
|
||||||
|
if (isGameScreen && user) {
|
||||||
|
// Hide global auth bar, show in-game auth controls
|
||||||
|
this.authBar?.classList.add('hidden');
|
||||||
|
this.gameUsername.textContent = user.username;
|
||||||
|
this.gameUsername.classList.remove('hidden');
|
||||||
|
this.gameLogoutBtn.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
// Show global auth bar (if logged in), hide in-game auth controls
|
||||||
|
if (user) {
|
||||||
|
this.authBar?.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
this.gameUsername.classList.add('hidden');
|
||||||
|
this.gameLogoutBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showLobby() {
|
showLobby() {
|
||||||
@ -1435,9 +1587,11 @@ class GolfGame {
|
|||||||
|
|
||||||
if (this.isHost) {
|
if (this.isHost) {
|
||||||
this.hostSettings.classList.remove('hidden');
|
this.hostSettings.classList.remove('hidden');
|
||||||
|
this.cpuControlsSection.classList.remove('hidden');
|
||||||
this.waitingMessage.classList.add('hidden');
|
this.waitingMessage.classList.add('hidden');
|
||||||
} else {
|
} else {
|
||||||
this.hostSettings.classList.add('hidden');
|
this.hostSettings.classList.add('hidden');
|
||||||
|
this.cpuControlsSection.classList.add('hidden');
|
||||||
this.waitingMessage.classList.remove('hidden');
|
this.waitingMessage.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1499,13 +1653,8 @@ class GolfGame {
|
|||||||
if (player.is_host) badges += '<span class="host-badge">HOST</span>';
|
if (player.is_host) badges += '<span class="host-badge">HOST</span>';
|
||||||
if (player.is_cpu) badges += '<span class="cpu-badge">CPU</span>';
|
if (player.is_cpu) badges += '<span class="cpu-badge">CPU</span>';
|
||||||
|
|
||||||
let nameDisplay = player.name;
|
|
||||||
if (player.style) {
|
|
||||||
nameDisplay += ` <span class="cpu-style">(${player.style})</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
li.innerHTML = `
|
li.innerHTML = `
|
||||||
<span>${nameDisplay}</span>
|
<span>${player.name}</span>
|
||||||
<span>${badges}</span>
|
<span>${badges}</span>
|
||||||
`;
|
`;
|
||||||
if (player.id === this.playerId) {
|
if (player.id === this.playerId) {
|
||||||
@ -1516,6 +1665,7 @@ class GolfGame {
|
|||||||
if (player.id === this.playerId && player.is_host) {
|
if (player.id === this.playerId && player.is_host) {
|
||||||
this.isHost = true;
|
this.isHost = true;
|
||||||
this.hostSettings.classList.remove('hidden');
|
this.hostSettings.classList.remove('hidden');
|
||||||
|
this.cpuControlsSection.classList.remove('hidden');
|
||||||
this.waitingMessage.classList.add('hidden');
|
this.waitingMessage.classList.add('hidden');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1573,6 +1723,18 @@ class GolfGame {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for round/game over states
|
||||||
|
if (this.gameState.phase === 'round_over') {
|
||||||
|
this.setStatus('Hole Complete!', 'round-over');
|
||||||
|
this.finalTurnBadge.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.gameState.phase === 'game_over') {
|
||||||
|
this.setStatus('Game Over!', 'game-over');
|
||||||
|
this.finalTurnBadge.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isFinalTurn = this.gameState.phase === 'final_turn';
|
const isFinalTurn = this.gameState.phase === 'final_turn';
|
||||||
const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id);
|
const currentPlayer = this.gameState.players.find(p => p.id === this.gameState.current_player_id);
|
||||||
|
|
||||||
@ -1610,33 +1772,37 @@ class GolfGame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showDrawnCard() {
|
showDrawnCard() {
|
||||||
// Show drawn card in the discard pile position, highlighted
|
// Show drawn card floating over the discard pile (larger, closer to viewer)
|
||||||
const card = this.drawnCard;
|
const card = this.drawnCard;
|
||||||
|
|
||||||
this.discard.className = 'card card-front holding';
|
// Set up the floating held card display
|
||||||
|
this.heldCardFloating.className = 'card card-front held-card-floating';
|
||||||
|
// Clear any inline styles left over from swoop animations
|
||||||
|
this.heldCardFloating.style.cssText = '';
|
||||||
if (card.rank === '★') {
|
if (card.rank === '★') {
|
||||||
this.discard.classList.add('joker');
|
this.heldCardFloating.classList.add('joker');
|
||||||
} else if (this.isRedSuit(card.suit)) {
|
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
|
||||||
this.discard.classList.add('red');
|
this.heldCardFloatingContent.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
|
||||||
} else {
|
} else {
|
||||||
this.discard.classList.add('black');
|
if (this.isRedSuit(card.suit)) {
|
||||||
|
this.heldCardFloating.classList.add('red');
|
||||||
|
} else {
|
||||||
|
this.heldCardFloating.classList.add('black');
|
||||||
|
}
|
||||||
|
this.heldCardFloatingContent.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render card directly without checking face_up (drawn card is always visible to drawer)
|
// Show the floating card and discard button
|
||||||
if (card.rank === '★') {
|
this.heldCardFloating.classList.remove('hidden');
|
||||||
const jokerIcon = card.suit === 'hearts' ? '🐉' : '👹';
|
|
||||||
this.discardContent.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
|
|
||||||
} else {
|
|
||||||
this.discardContent.innerHTML = `${card.rank}<br>${this.getSuitSymbol(card.suit)}`;
|
|
||||||
}
|
|
||||||
this.discardBtn.classList.remove('hidden');
|
this.discardBtn.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
hideDrawnCard() {
|
hideDrawnCard() {
|
||||||
// Restore discard pile to show actual top card (handled by renderGame)
|
// Hide the floating held card
|
||||||
this.discard.classList.remove('holding');
|
this.heldCardFloating.classList.add('hidden');
|
||||||
|
// Clear any inline styles from animations
|
||||||
|
this.heldCardFloating.style.cssText = '';
|
||||||
this.discardBtn.classList.add('hidden');
|
this.discardBtn.classList.add('hidden');
|
||||||
this.cancelDrawBtn.classList.add('hidden');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isRedSuit(suit) {
|
isRedSuit(suit) {
|
||||||
@ -1745,19 +1911,50 @@ class GolfGame {
|
|||||||
playerNameSpan.innerHTML = crownHtml + displayName + checkmark;
|
playerNameSpan.innerHTML = crownHtml + displayName + checkmark;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update discard pile (skip if holding a drawn card)
|
// Update discard pile
|
||||||
if (!this.drawnCard) {
|
if (this.drawnCard) {
|
||||||
|
// Holding a drawn card - show discard pile as greyed/disabled
|
||||||
|
// If drawn from discard, show what's underneath (new discard_top or empty)
|
||||||
|
// If drawn from deck, show current discard_top greyed
|
||||||
|
this.discard.classList.add('picked-up');
|
||||||
|
this.discard.classList.remove('holding');
|
||||||
|
|
||||||
|
if (this.gameState.discard_top) {
|
||||||
|
const discardCard = this.gameState.discard_top;
|
||||||
|
this.discard.classList.add('has-card', 'card-front');
|
||||||
|
this.discard.classList.remove('card-back', 'red', 'black', 'joker');
|
||||||
|
|
||||||
|
if (discardCard.rank === '★') {
|
||||||
|
this.discard.classList.add('joker');
|
||||||
|
} else if (this.isRedSuit(discardCard.suit)) {
|
||||||
|
this.discard.classList.add('red');
|
||||||
|
} else {
|
||||||
|
this.discard.classList.add('black');
|
||||||
|
}
|
||||||
|
this.discardContent.innerHTML = this.renderCardContent(discardCard);
|
||||||
|
} else {
|
||||||
|
// No card underneath - show empty
|
||||||
|
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker');
|
||||||
|
this.discardContent.innerHTML = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not holding - show normal discard pile
|
||||||
|
this.discard.classList.remove('picked-up');
|
||||||
|
|
||||||
if (this.gameState.discard_top) {
|
if (this.gameState.discard_top) {
|
||||||
const discardCard = this.gameState.discard_top;
|
const discardCard = this.gameState.discard_top;
|
||||||
const cardKey = `${discardCard.rank}-${discardCard.suit}`;
|
const cardKey = `${discardCard.rank}-${discardCard.suit}`;
|
||||||
|
|
||||||
// Animate if discard changed
|
// Only animate discard flip during active gameplay, not at round/game end
|
||||||
if (this.lastDiscardKey && this.lastDiscardKey !== cardKey) {
|
const isActivePlay = this.gameState.phase !== 'round_over' &&
|
||||||
this.discard.classList.add('card-flip-in');
|
this.gameState.phase !== 'game_over';
|
||||||
setTimeout(() => this.discard.classList.remove('card-flip-in'), 400);
|
const shouldAnimate = isActivePlay && this.lastDiscardKey &&
|
||||||
}
|
this.lastDiscardKey !== cardKey && !this.skipNextDiscardFlip;
|
||||||
|
|
||||||
|
this.skipNextDiscardFlip = false;
|
||||||
this.lastDiscardKey = cardKey;
|
this.lastDiscardKey = cardKey;
|
||||||
|
|
||||||
|
// Set card content and styling FIRST (before any animation)
|
||||||
this.discard.classList.add('has-card', 'card-front');
|
this.discard.classList.add('has-card', 'card-front');
|
||||||
this.discard.classList.remove('card-back', 'red', 'black', 'joker', 'holding');
|
this.discard.classList.remove('card-back', 'red', 'black', 'joker', 'holding');
|
||||||
|
|
||||||
@ -1769,6 +1966,15 @@ class GolfGame {
|
|||||||
this.discard.classList.add('black');
|
this.discard.classList.add('black');
|
||||||
}
|
}
|
||||||
this.discardContent.innerHTML = this.renderCardContent(discardCard);
|
this.discardContent.innerHTML = this.renderCardContent(discardCard);
|
||||||
|
|
||||||
|
// THEN animate if needed (content is already set, so no blank flash)
|
||||||
|
if (shouldAnimate) {
|
||||||
|
// Remove any existing animation first to allow re-trigger
|
||||||
|
this.discard.classList.remove('card-flip-in');
|
||||||
|
void this.discard.offsetWidth; // Force reflow
|
||||||
|
this.discard.classList.add('card-flip-in');
|
||||||
|
setTimeout(() => this.discard.classList.remove('card-flip-in'), 560);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker', 'holding');
|
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker', 'holding');
|
||||||
this.discardContent.innerHTML = '';
|
this.discardContent.innerHTML = '';
|
||||||
@ -1781,22 +1987,30 @@ class GolfGame {
|
|||||||
const hasDrawn = this.drawnCard || this.gameState.has_drawn_card;
|
const hasDrawn = this.drawnCard || this.gameState.has_drawn_card;
|
||||||
const canDraw = this.isMyTurn() && !hasDrawn && !this.gameState.waiting_for_initial_flip;
|
const canDraw = this.isMyTurn() && !hasDrawn && !this.gameState.waiting_for_initial_flip;
|
||||||
|
|
||||||
|
// Pulse the deck area when it's player's turn to draw
|
||||||
|
this.deckArea.classList.toggle('your-turn-to-draw', canDraw);
|
||||||
|
|
||||||
this.deck.classList.toggle('clickable', canDraw);
|
this.deck.classList.toggle('clickable', canDraw);
|
||||||
this.deck.classList.toggle('disabled', hasDrawn);
|
// Only show disabled on deck when it's my turn and I've drawn
|
||||||
|
this.deck.classList.toggle('disabled', this.isMyTurn() && hasDrawn);
|
||||||
|
|
||||||
this.discard.classList.toggle('clickable', canDraw && this.gameState.discard_top);
|
this.discard.classList.toggle('clickable', canDraw && this.gameState.discard_top);
|
||||||
// Don't show disabled state when we're holding a drawn card (it's displayed in discard position)
|
// Only show disabled state when it's my turn and I've drawn (not holding visible card)
|
||||||
this.discard.classList.toggle('disabled', hasDrawn && !this.drawnCard);
|
// Don't grey out when opponents are playing
|
||||||
|
this.discard.classList.toggle('disabled', this.isMyTurn() && hasDrawn && !this.drawnCard);
|
||||||
|
|
||||||
// Render opponents in a single row
|
// Render opponents in a single row
|
||||||
const opponents = this.gameState.players.filter(p => p.id !== this.playerId);
|
const opponents = this.gameState.players.filter(p => p.id !== this.playerId);
|
||||||
|
|
||||||
this.opponentsRow.innerHTML = '';
|
this.opponentsRow.innerHTML = '';
|
||||||
|
|
||||||
|
// Don't highlight current player during round/game over
|
||||||
|
const isPlaying = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over';
|
||||||
|
|
||||||
opponents.forEach((player) => {
|
opponents.forEach((player) => {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'opponent-area';
|
div.className = 'opponent-area';
|
||||||
if (player.id === this.gameState.current_player_id) {
|
if (isPlaying && player.id === this.gameState.current_player_id) {
|
||||||
div.classList.add('current-turn');
|
div.classList.add('current-turn');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1810,7 +2024,7 @@ class GolfGame {
|
|||||||
const crownHtml = isRoundWinner ? '<span class="winner-crown">👑</span>' : '';
|
const crownHtml = isRoundWinner ? '<span class="winner-crown">👑</span>' : '';
|
||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<h4>${crownHtml}${displayName}${player.all_face_up ? ' ✓' : ''}<span class="opponent-showing">${showingScore}</span></h4>
|
<h4><span class="opponent-name">${crownHtml}${displayName}${player.all_face_up ? ' ✓' : ''}</span><span class="opponent-showing">${showingScore}</span></h4>
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
${player.cards.map(card => this.renderCard(card, false, false)).join('')}
|
${player.cards.map(card => this.renderCard(card, false, false)).join('')}
|
||||||
</div>
|
</div>
|
||||||
@ -1862,12 +2076,9 @@ class GolfGame {
|
|||||||
if (this.drawnCard && !this.gameState.can_discard) {
|
if (this.drawnCard && !this.gameState.can_discard) {
|
||||||
this.discardBtn.disabled = true;
|
this.discardBtn.disabled = true;
|
||||||
this.discardBtn.classList.add('disabled');
|
this.discardBtn.classList.add('disabled');
|
||||||
// Show cancel button when drawn from discard (can put it back)
|
|
||||||
this.cancelDrawBtn.classList.remove('hidden');
|
|
||||||
} else {
|
} else {
|
||||||
this.discardBtn.disabled = false;
|
this.discardBtn.disabled = false;
|
||||||
this.discardBtn.classList.remove('disabled');
|
this.discardBtn.classList.remove('disabled');
|
||||||
this.cancelDrawBtn.classList.add('hidden');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show/hide skip flip button (only when flip is optional in endgame mode)
|
// Show/hide skip flip button (only when flip is optional in endgame mode)
|
||||||
@ -1907,14 +2118,20 @@ class GolfGame {
|
|||||||
// Update standings (left panel)
|
// Update standings (left panel)
|
||||||
this.updateStandings();
|
this.updateStandings();
|
||||||
|
|
||||||
|
// Skip score table update during round_over/game_over - showScoreboard handles these
|
||||||
|
if (this.gameState.phase === 'round_over' || this.gameState.phase === 'game_over') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Update score table (right panel)
|
// Update score table (right panel)
|
||||||
this.scoreTable.innerHTML = '';
|
this.scoreTable.innerHTML = '';
|
||||||
|
|
||||||
this.gameState.players.forEach(player => {
|
this.gameState.players.forEach(player => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
|
|
||||||
// Highlight current player
|
// Highlight current player (but not during round/game over)
|
||||||
if (player.id === this.gameState.current_player_id) {
|
const isPlaying = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over';
|
||||||
|
if (isPlaying && player.id === this.gameState.current_player_id) {
|
||||||
tr.classList.add('current-player');
|
tr.classList.add('current-player');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -51,11 +51,10 @@
|
|||||||
<!-- Waiting Room Screen -->
|
<!-- Waiting Room Screen -->
|
||||||
<div id="waiting-screen" class="screen">
|
<div id="waiting-screen" class="screen">
|
||||||
<div class="room-code-banner">
|
<div class="room-code-banner">
|
||||||
<span class="room-code-label">ROOM CODE</span>
|
|
||||||
<span class="room-code-value" id="display-room-code"></span>
|
<span class="room-code-value" id="display-room-code"></span>
|
||||||
<div class="room-code-buttons">
|
<div class="room-code-buttons">
|
||||||
<button class="room-code-copy" id="copy-room-code" title="Copy code">📋</button>
|
<button class="room-code-copy" id="copy-room-code" title="Copy code">📋</button>
|
||||||
<button class="room-code-copy" id="share-room-link" title="Copy link">🌐</button>
|
<button class="room-code-copy" id="share-room-link" title="Copy invite link">🌐</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -65,19 +64,19 @@
|
|||||||
<h3>Players</h3>
|
<h3>Players</h3>
|
||||||
<ul id="players-list"></ul>
|
<ul id="players-list"></ul>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="cpu-controls-section" class="cpu-controls-section hidden">
|
||||||
|
<h4>Add CPU Opponents</h4>
|
||||||
|
<div class="cpu-controls">
|
||||||
|
<button id="remove-cpu-btn" class="btn btn-small btn-danger" title="Remove last CPU">−</button>
|
||||||
|
<button id="add-cpu-btn" class="btn btn-small btn-success" title="Add CPU player">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button id="leave-room-btn" class="btn btn-danger">Leave Room</button>
|
<button id="leave-room-btn" class="btn btn-danger">Leave Room</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="host-settings" class="settings hidden">
|
<div id="host-settings" class="settings hidden">
|
||||||
<h3>Game Settings</h3>
|
<h3>Game Settings</h3>
|
||||||
<div class="basic-settings-row">
|
<div class="basic-settings-row">
|
||||||
<div class="form-group">
|
|
||||||
<label>CPU Players</label>
|
|
||||||
<div class="cpu-controls">
|
|
||||||
<button id="remove-cpu-btn" class="btn btn-small btn-danger">(-) Delete</button>
|
|
||||||
<button id="add-cpu-btn" class="btn btn-small btn-success">(+) Add</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="num-decks">Decks</label>
|
<label for="num-decks">Decks</label>
|
||||||
<select id="num-decks">
|
<select id="num-decks">
|
||||||
@ -262,6 +261,8 @@
|
|||||||
<div id="final-turn-badge" class="final-turn-badge hidden">⚡ FINAL TURN</div>
|
<div id="final-turn-badge" class="final-turn-badge hidden">⚡ FINAL TURN</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-col header-col-right">
|
<div class="header-col header-col-right">
|
||||||
|
<span id="game-username" class="game-username hidden"></span>
|
||||||
|
<button id="game-logout-btn" class="btn btn-small hidden">Logout</button>
|
||||||
<button id="mute-btn" class="mute-btn" title="Toggle sound">🔊</button>
|
<button id="mute-btn" class="mute-btn" title="Toggle sound">🔊</button>
|
||||||
<button id="leave-game-btn" class="btn btn-small btn-danger">Leave</button>
|
<button id="leave-game-btn" class="btn btn-small btn-danger">Leave</button>
|
||||||
</div>
|
</div>
|
||||||
@ -273,6 +274,13 @@
|
|||||||
<div class="player-row">
|
<div class="player-row">
|
||||||
<div class="table-center">
|
<div class="table-center">
|
||||||
<div class="deck-area">
|
<div class="deck-area">
|
||||||
|
<!-- Held card slot (left of deck) -->
|
||||||
|
<div id="held-card-slot" class="held-card-slot hidden">
|
||||||
|
<div id="held-card-display" class="card card-front">
|
||||||
|
<span id="held-card-content"></span>
|
||||||
|
</div>
|
||||||
|
<span class="held-label">Holding</span>
|
||||||
|
</div>
|
||||||
<div id="deck" class="card card-back">
|
<div id="deck" class="card card-back">
|
||||||
<span>?</span>
|
<span>?</span>
|
||||||
</div>
|
</div>
|
||||||
@ -280,8 +288,11 @@
|
|||||||
<div id="discard" class="card">
|
<div id="discard" class="card">
|
||||||
<span id="discard-content"></span>
|
<span id="discard-content"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Floating held card (appears larger over discard when holding) -->
|
||||||
|
<div id="held-card-floating" class="card card-front held-card-floating hidden">
|
||||||
|
<span id="held-card-floating-content"></span>
|
||||||
|
</div>
|
||||||
<button id="discard-btn" class="btn btn-small hidden">Discard</button>
|
<button id="discard-btn" class="btn btn-small hidden">Discard</button>
|
||||||
<button id="cancel-draw-btn" class="btn btn-small btn-secondary hidden">Put Back</button>
|
|
||||||
<button id="skip-flip-btn" class="btn btn-small btn-secondary hidden">Skip Flip</button>
|
<button id="skip-flip-btn" class="btn btn-small btn-secondary hidden">Skip Flip</button>
|
||||||
<button id="knock-early-btn" class="btn btn-small btn-danger hidden">Knock!</button>
|
<button id="knock-early-btn" class="btn btn-small btn-danger hidden">Knock!</button>
|
||||||
</div>
|
</div>
|
||||||
@ -295,14 +306,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Legacy swap animation overlay (kept for rollback) -->
|
<!-- Animation overlay for card movements -->
|
||||||
<div id="swap-animation" class="swap-animation hidden">
|
<div id="swap-animation" class="swap-animation hidden">
|
||||||
|
<!-- Card being discarded from hand -->
|
||||||
<div id="swap-card-from-hand" class="swap-card">
|
<div id="swap-card-from-hand" class="swap-card">
|
||||||
<div class="swap-card-inner">
|
<div class="swap-card-inner">
|
||||||
<div class="swap-card-front"></div>
|
<div class="swap-card-front"></div>
|
||||||
<div class="swap-card-back">?</div>
|
<div class="swap-card-back">?</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Drawn card being held (animates to hand) -->
|
||||||
|
<div id="held-card" class="swap-card hidden">
|
||||||
|
<div class="swap-card-inner">
|
||||||
|
<div class="swap-card-front"></div>
|
||||||
|
<div class="swap-card-back">?</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -317,6 +336,10 @@
|
|||||||
<!-- Right panel: Scores -->
|
<!-- Right panel: Scores -->
|
||||||
<div id="scoreboard" class="side-panel right-panel">
|
<div id="scoreboard" class="side-panel right-panel">
|
||||||
<h4>Scores</h4>
|
<h4>Scores</h4>
|
||||||
|
<div id="game-buttons" class="game-buttons hidden">
|
||||||
|
<button id="next-round-btn" class="btn btn-next-round hidden">Next Hole</button>
|
||||||
|
<button id="new-game-btn" class="btn btn-small btn-secondary hidden">New Game</button>
|
||||||
|
</div>
|
||||||
<table id="score-table">
|
<table id="score-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -328,10 +351,6 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
</table>
|
</table>
|
||||||
<div id="game-buttons" class="game-buttons hidden">
|
|
||||||
<button id="next-round-btn" class="btn btn-small btn-primary hidden">Next Hole</button>
|
|
||||||
<button id="new-game-btn" class="btn btn-small btn-secondary hidden">New Game</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
265
client/style.css
265
client/style.css
@ -195,12 +195,13 @@ body {
|
|||||||
grid-template-columns: 220px 1fr;
|
grid-template-columns: 220px 1fr;
|
||||||
gap: 25px;
|
gap: 25px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
padding-top: 70px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waiting-left-col {
|
.waiting-left-col {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 15px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waiting-left-col .players-list {
|
.waiting-left-col .players-list {
|
||||||
@ -227,7 +228,7 @@ body {
|
|||||||
/* Basic settings in a row */
|
/* Basic settings in a row */
|
||||||
.basic-settings-row {
|
.basic-settings-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
@ -248,14 +249,29 @@ body {
|
|||||||
padding: 8px 4px;
|
padding: 8px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.basic-settings-row .cpu-controls {
|
/* CPU Controls Section - below players list */
|
||||||
display: flex;
|
.cpu-controls-section {
|
||||||
gap: 5px;
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.basic-settings-row .cpu-controls .btn {
|
.cpu-controls-section h4 {
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #f4a460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cpu-controls-section .cpu-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cpu-controls-section .cpu-controls .btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 8px 0;
|
padding: 6px 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
#waiting-message {
|
#waiting-message {
|
||||||
@ -282,14 +298,14 @@ body {
|
|||||||
left: 20px;
|
left: 20px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
background: linear-gradient(180deg, #d4845a 0%, #c4723f 50%, #b8663a 100%);
|
background: linear-gradient(180deg, #d4845a 0%, #c4723f 50%, #b8663a 100%);
|
||||||
padding: 12px 16px 20px;
|
padding: 10px 14px 18px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
box-shadow: 2px 4px 12px rgba(0, 0, 0, 0.3);
|
box-shadow: 2px 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
/* Ribbon forked end (snake tongue style) */
|
/* Ribbon forked end (snake tongue style) */
|
||||||
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% calc(100% - 12px), 0 100%);
|
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% calc(100% - 10px), 0 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-code-banner::before {
|
.room-code-banner::before {
|
||||||
@ -302,39 +318,27 @@ body {
|
|||||||
background: linear-gradient(180deg, rgba(255,255,255,0.3) 0%, transparent 100%);
|
background: linear-gradient(180deg, rgba(255,255,255,0.3) 0%, transparent 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-code-label {
|
|
||||||
font-size: 0.55rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.25em;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
text-align: center;
|
|
||||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-code-value {
|
.room-code-value {
|
||||||
font-size: 1.6rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
letter-spacing: 0.2em;
|
letter-spacing: 0.15em;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-shadow: 0 2px 2px rgba(0, 0, 0, 0.3);
|
text-shadow: 0 2px 2px rgba(0, 0, 0, 0.3);
|
||||||
padding: 2px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-code-buttons {
|
.room-code-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 5px;
|
||||||
margin-top: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-code-copy {
|
.room-code-copy {
|
||||||
background: rgba(255, 255, 255, 0.85);
|
background: rgba(255, 255, 255, 0.85);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px 8px;
|
padding: 4px 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
@ -349,6 +353,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -463,6 +468,15 @@ input::placeholder {
|
|||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.game-buttons .btn-next-round {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
width: 100%;
|
||||||
|
background: #f4a460;
|
||||||
|
color: #1a472a;
|
||||||
|
}
|
||||||
|
|
||||||
.btn.disabled,
|
.btn.disabled,
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
@ -625,7 +639,7 @@ input::placeholder {
|
|||||||
/* Game Screen */
|
/* Game Screen */
|
||||||
.game-header {
|
.game-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 2fr 1fr;
|
grid-template-columns: auto 1fr auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
background: rgba(0,0,0,0.35);
|
background: rgba(0,0,0,0.35);
|
||||||
@ -653,8 +667,18 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-col-right {
|
.header-col-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-logout-btn {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-header .round-info {
|
.game-header .round-info {
|
||||||
@ -773,6 +797,7 @@ input::placeholder {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
border: 2px solid #ddd;
|
border: 2px solid #ddd;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-front.red {
|
.card-front.red {
|
||||||
@ -962,7 +987,73 @@ input::placeholder {
|
|||||||
.deck-area {
|
.deck-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gentle pulse when it's your turn to draw */
|
||||||
|
.deck-area.your-turn-to-draw {
|
||||||
|
animation: deckAreaPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes deckAreaPulse {
|
||||||
|
0%, 100% {
|
||||||
|
filter: brightness(1);
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Held card slot - hidden, using floating card over discard instead */
|
||||||
|
.held-card-slot {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Held card floating over discard pile (larger, closer to viewer) */
|
||||||
|
.held-card-floating {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 100;
|
||||||
|
transform: scale(1.2) translateY(-12px);
|
||||||
|
border: 3px solid #f4a460 !important;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 25px rgba(244, 164, 96, 0.7) !important;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.held-card-floating.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animate floating card dropping to discard pile (when drawn from discard) */
|
||||||
|
.held-card-floating.dropping {
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
border-color: transparent !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
||||||
|
transition: transform 0.25s ease-out, border-color 0.25s ease-out, box-shadow 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Swoop animation for deck → immediate discard */
|
||||||
|
.held-card-floating.swooping {
|
||||||
|
transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
top 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
transform 0.35s ease-out,
|
||||||
|
border-color 0.35s ease-out,
|
||||||
|
box-shadow 0.35s ease-out;
|
||||||
|
transform: scale(1.15) rotate(-8deg);
|
||||||
|
border-color: rgba(244, 164, 96, 0.8) !important;
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4), 0 0 20px rgba(244, 164, 96, 0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.held-card-floating.swooping.landed {
|
||||||
|
transform: scale(1) rotate(0deg);
|
||||||
|
border-color: transparent !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deck-area .card {
|
.deck-area .card {
|
||||||
@ -989,11 +1080,19 @@ input::placeholder {
|
|||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Picked-up state - showing card underneath after drawing from discard */
|
||||||
|
#discard.picked-up {
|
||||||
|
opacity: 0.5;
|
||||||
|
filter: grayscale(40%);
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
.discard-stack {
|
.discard-stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.discard-stack .btn {
|
.discard-stack .btn {
|
||||||
@ -1016,7 +1115,7 @@ input::placeholder {
|
|||||||
/* Highlight flash when opponent draws from a pile */
|
/* Highlight flash when opponent draws from a pile */
|
||||||
#deck.draw-pulse,
|
#deck.draw-pulse,
|
||||||
#discard.draw-pulse {
|
#discard.draw-pulse {
|
||||||
animation: draw-highlight 0.4s ease-out;
|
animation: draw-highlight 0.45s ease-out;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1044,7 +1143,7 @@ input::placeholder {
|
|||||||
|
|
||||||
/* Card flip animation for discard pile */
|
/* Card flip animation for discard pile */
|
||||||
.card-flip-in {
|
.card-flip-in {
|
||||||
animation: cardFlipIn 0.5s ease-out;
|
animation: cardFlipIn 0.56s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes cardFlipIn {
|
@keyframes cardFlipIn {
|
||||||
@ -1069,6 +1168,26 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Discard pile pulse when card lands */
|
||||||
|
#discard.discard-land {
|
||||||
|
animation: discardLand 0.46s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes discardLand {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1.18);
|
||||||
|
box-shadow: 0 0 25px rgba(244, 164, 96, 0.9);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Swap animation overlay */
|
/* Swap animation overlay */
|
||||||
.swap-animation {
|
.swap-animation {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@ -1092,12 +1211,16 @@ input::placeholder {
|
|||||||
perspective: 1000px;
|
perspective: 1000px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.swap-card.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.swap-card-inner {
|
.swap-card-inner {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
transform-style: preserve-3d;
|
transform-style: preserve-3d;
|
||||||
transition: transform 0.4s ease-in-out;
|
transition: transform 0.54s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swap-card.flipping .swap-card-inner {
|
.swap-card.flipping .swap-card-inner {
|
||||||
@ -1163,7 +1286,7 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.swap-card.moving {
|
.swap-card.moving {
|
||||||
transition: top 0.4s cubic-bezier(0.4, 0, 0.2, 1), left 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.4s ease-out;
|
transition: top 0.45s cubic-bezier(0.4, 0, 0.2, 1), left 0.45s cubic-bezier(0.4, 0, 0.2, 1), transform 0.45s ease-out;
|
||||||
transform: scale(1.1) rotate(-5deg);
|
transform: scale(1.1) rotate(-5deg);
|
||||||
filter: drop-shadow(0 0 25px rgba(244, 164, 96, 1));
|
filter: drop-shadow(0 0 25px rgba(244, 164, 96, 1));
|
||||||
}
|
}
|
||||||
@ -1180,6 +1303,31 @@ input::placeholder {
|
|||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Subtle swap pulse for face-to-face swaps (no flip needed) */
|
||||||
|
.card.swap-pulse {
|
||||||
|
animation: swapPulse 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes swapPulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
transform: scale(0.92);
|
||||||
|
filter: brightness(0.85);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.08);
|
||||||
|
filter: brightness(1.15);
|
||||||
|
box-shadow: 0 0 12px rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Player Area */
|
/* Player Area */
|
||||||
.player-section {
|
.player-section {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -1483,14 +1631,14 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.game-buttons {
|
.game-buttons {
|
||||||
margin-top: 8px;
|
margin-bottom: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-buttons .btn {
|
.game-buttons .btn {
|
||||||
font-size: 0.7rem;
|
font-size: 0.8rem;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@ -1728,11 +1876,27 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.game-header {
|
.game-header {
|
||||||
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-col-right {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-logout-btn,
|
||||||
|
#leave-game-btn {
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-username {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
max-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
.table-center {
|
.table-center {
|
||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
}
|
}
|
||||||
@ -1793,7 +1957,7 @@ input::placeholder {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
transform-style: preserve-3d;
|
transform-style: preserve-3d;
|
||||||
transition: transform 0.4s ease-in-out;
|
transition: transform 0.54s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.real-card .card-inner.flipped {
|
.real-card .card-inner.flipped {
|
||||||
@ -1867,9 +2031,9 @@ input::placeholder {
|
|||||||
.real-card.moving,
|
.real-card.moving,
|
||||||
.real-card.anim-card.moving {
|
.real-card.anim-card.moving {
|
||||||
z-index: 600;
|
z-index: 600;
|
||||||
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
transition: left 0.27s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
top 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
top 0.27s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
transform 0.3s ease-out;
|
transform 0.27s ease-out;
|
||||||
filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8));
|
filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8));
|
||||||
transform: scale(1.08) rotate(-3deg);
|
transform: scale(1.08) rotate(-3deg);
|
||||||
}
|
}
|
||||||
@ -1881,7 +2045,7 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.real-card.anim-card .card-inner {
|
.real-card.anim-card .card-inner {
|
||||||
transition: transform 0.4s ease-in-out;
|
transition: transform 0.54s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.real-card.holding {
|
.real-card.holding {
|
||||||
@ -2881,11 +3045,30 @@ input::placeholder {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide global auth-bar when game screen is active */
|
||||||
|
#app:has(#game-screen.active) > .auth-bar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
#auth-username {
|
#auth-username {
|
||||||
color: #f4a460;
|
color: #f4a460;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Username in game header */
|
||||||
|
.game-username {
|
||||||
|
color: #f4a460;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
max-width: 80px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-username.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Auth buttons in lobby */
|
/* Auth buttons in lobby */
|
||||||
.auth-buttons {
|
.auth-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
197
server/ai.py
197
server/ai.py
@ -47,15 +47,74 @@ def can_make_pair(card1: Card, card2: Card) -> bool:
|
|||||||
return card1.rank == card2.rank
|
return card1.rank == card2.rank
|
||||||
|
|
||||||
|
|
||||||
def estimate_opponent_min_score(player: Player, game: Game) -> int:
|
def get_discard_thinking_time(card: Optional[Card], options: GameOptions) -> float:
|
||||||
"""Estimate minimum opponent score from visible cards."""
|
"""Calculate CPU 'thinking time' based on how obvious the discard decision is.
|
||||||
|
|
||||||
|
Easy decisions (obviously good or bad cards) = quick (400-600ms)
|
||||||
|
Hard decisions (medium value cards) = slower (900-1100ms)
|
||||||
|
|
||||||
|
Returns time in seconds.
|
||||||
|
"""
|
||||||
|
if not card:
|
||||||
|
# No discard available - quick decision to draw from deck
|
||||||
|
return random.uniform(0.4, 0.5)
|
||||||
|
|
||||||
|
value = get_card_value(card, options)
|
||||||
|
|
||||||
|
# Obviously good cards (easy take): 2 (-2), Joker (-2/-5), K (0), A (1)
|
||||||
|
if value <= 1:
|
||||||
|
return random.uniform(0.4, 0.6)
|
||||||
|
|
||||||
|
# Obviously bad cards (easy pass): 10, J, Q (value 10)
|
||||||
|
if value >= 10:
|
||||||
|
return random.uniform(0.4, 0.6)
|
||||||
|
|
||||||
|
# Medium cards require more thought: 3-9
|
||||||
|
# 5, 6, 7 are the hardest decisions (middle of the range)
|
||||||
|
if value in (5, 6, 7):
|
||||||
|
return random.uniform(0.9, 1.1)
|
||||||
|
|
||||||
|
# 3, 4, 8, 9 - moderate difficulty
|
||||||
|
return random.uniform(0.6, 0.85)
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_opponent_min_score(player: Player, game: Game, optimistic: bool = False) -> int:
|
||||||
|
"""Estimate minimum opponent score from visible cards.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player: The player making the estimation (excluded from opponents)
|
||||||
|
game: The game state
|
||||||
|
optimistic: If True, assume opponents' hidden cards are average (4.5).
|
||||||
|
If False, assume opponents could get lucky (lower estimate).
|
||||||
|
"""
|
||||||
min_est = 999
|
min_est = 999
|
||||||
for p in game.players:
|
for p in game.players:
|
||||||
if p.id == player.id:
|
if p.id == player.id:
|
||||||
continue
|
continue
|
||||||
visible = sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up)
|
visible = sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up)
|
||||||
hidden = sum(1 for c in p.cards if not c.face_up)
|
hidden = sum(1 for c in p.cards if not c.face_up)
|
||||||
estimate = visible + int(hidden * 4.5) # Assume ~4.5 avg for hidden
|
|
||||||
|
if optimistic:
|
||||||
|
# Assume average hidden cards
|
||||||
|
estimate = visible + int(hidden * 4.5)
|
||||||
|
else:
|
||||||
|
# Assume opponents could get lucky - hidden cards might be low
|
||||||
|
# or could complete pairs, so use lower estimate
|
||||||
|
# Check for potential pairs in opponent's hand
|
||||||
|
pair_potential = 0
|
||||||
|
for col in range(3):
|
||||||
|
top, bot = p.cards[col], p.cards[col + 3]
|
||||||
|
# If one card is visible and the other is hidden, there's pair potential
|
||||||
|
if top.face_up and not bot.face_up:
|
||||||
|
pair_potential += get_ai_card_value(top, game.options)
|
||||||
|
elif bot.face_up and not top.face_up:
|
||||||
|
pair_potential += get_ai_card_value(bot, game.options)
|
||||||
|
|
||||||
|
# Conservative estimate: assume 2.5 avg for hidden (could be low cards)
|
||||||
|
# and subtract some pair potential (hidden cards might match visible)
|
||||||
|
base_estimate = visible + int(hidden * 2.5)
|
||||||
|
estimate = base_estimate - int(pair_potential * 0.25) # 25% chance of pair
|
||||||
|
|
||||||
min_est = min(min_est, estimate)
|
min_est = min(min_est, estimate)
|
||||||
return min_est
|
return min_est
|
||||||
|
|
||||||
@ -365,60 +424,91 @@ CPU_PROFILES = [
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Track which profiles are in use
|
# Track profiles per room (room_code -> set of used profile names)
|
||||||
_used_profiles: set[str] = set()
|
_room_used_profiles: dict[str, set[str]] = {}
|
||||||
_cpu_profiles: dict[str, CPUProfile] = {}
|
# Track cpu_id -> (room_code, profile) mapping
|
||||||
|
_cpu_profiles: dict[str, tuple[str, CPUProfile]] = {}
|
||||||
|
|
||||||
|
|
||||||
def get_available_profile() -> Optional[CPUProfile]:
|
def get_available_profile(room_code: str) -> Optional[CPUProfile]:
|
||||||
"""Get a random available CPU profile."""
|
"""Get a random available CPU profile for a specific room."""
|
||||||
available = [p for p in CPU_PROFILES if p.name not in _used_profiles]
|
used_in_room = _room_used_profiles.get(room_code, set())
|
||||||
|
available = [p for p in CPU_PROFILES if p.name not in used_in_room]
|
||||||
if not available:
|
if not available:
|
||||||
return None
|
return None
|
||||||
profile = random.choice(available)
|
profile = random.choice(available)
|
||||||
_used_profiles.add(profile.name)
|
if room_code not in _room_used_profiles:
|
||||||
|
_room_used_profiles[room_code] = set()
|
||||||
|
_room_used_profiles[room_code].add(profile.name)
|
||||||
return profile
|
return profile
|
||||||
|
|
||||||
|
|
||||||
def release_profile(name: str):
|
def release_profile(name: str, room_code: str):
|
||||||
"""Release a CPU profile back to the pool."""
|
"""Release a CPU profile back to the room's pool."""
|
||||||
_used_profiles.discard(name)
|
if room_code in _room_used_profiles:
|
||||||
# Also remove from cpu_profiles by finding the cpu_id with this profile
|
_room_used_profiles[room_code].discard(name)
|
||||||
to_remove = [cpu_id for cpu_id, profile in _cpu_profiles.items() if profile.name == name]
|
# Clean up empty room entries
|
||||||
|
if not _room_used_profiles[room_code]:
|
||||||
|
del _room_used_profiles[room_code]
|
||||||
|
# Also remove from cpu_profiles by finding the cpu_id with this profile in this room
|
||||||
|
to_remove = [
|
||||||
|
cpu_id for cpu_id, (rc, profile) in _cpu_profiles.items()
|
||||||
|
if profile.name == name and rc == room_code
|
||||||
|
]
|
||||||
|
for cpu_id in to_remove:
|
||||||
|
del _cpu_profiles[cpu_id]
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_room_profiles(room_code: str):
|
||||||
|
"""Clean up all profile tracking for a room when it's deleted."""
|
||||||
|
if room_code in _room_used_profiles:
|
||||||
|
del _room_used_profiles[room_code]
|
||||||
|
# Remove all cpu_profiles for this room
|
||||||
|
to_remove = [cpu_id for cpu_id, (rc, _) in _cpu_profiles.items() if rc == room_code]
|
||||||
for cpu_id in to_remove:
|
for cpu_id in to_remove:
|
||||||
del _cpu_profiles[cpu_id]
|
del _cpu_profiles[cpu_id]
|
||||||
|
|
||||||
|
|
||||||
def reset_all_profiles():
|
def reset_all_profiles():
|
||||||
"""Reset all profile tracking (for cleanup)."""
|
"""Reset all profile tracking (for cleanup)."""
|
||||||
_used_profiles.clear()
|
_room_used_profiles.clear()
|
||||||
_cpu_profiles.clear()
|
_cpu_profiles.clear()
|
||||||
|
|
||||||
|
|
||||||
def get_profile(cpu_id: str) -> Optional[CPUProfile]:
|
def get_profile(cpu_id: str) -> Optional[CPUProfile]:
|
||||||
"""Get the profile for a CPU player."""
|
"""Get the profile for a CPU player."""
|
||||||
return _cpu_profiles.get(cpu_id)
|
entry = _cpu_profiles.get(cpu_id)
|
||||||
|
return entry[1] if entry else None
|
||||||
|
|
||||||
|
|
||||||
def assign_profile(cpu_id: str) -> Optional[CPUProfile]:
|
def assign_profile(cpu_id: str, room_code: str) -> Optional[CPUProfile]:
|
||||||
"""Assign a random profile to a CPU player."""
|
"""Assign a random profile to a CPU player in a specific room."""
|
||||||
profile = get_available_profile()
|
profile = get_available_profile(room_code)
|
||||||
if profile:
|
if profile:
|
||||||
_cpu_profiles[cpu_id] = profile
|
_cpu_profiles[cpu_id] = (room_code, profile)
|
||||||
return profile
|
return profile
|
||||||
|
|
||||||
|
|
||||||
def assign_specific_profile(cpu_id: str, profile_name: str) -> Optional[CPUProfile]:
|
def assign_specific_profile(cpu_id: str, profile_name: str, room_code: str) -> Optional[CPUProfile]:
|
||||||
"""Assign a specific profile to a CPU player by name."""
|
"""Assign a specific profile to a CPU player by name in a specific room."""
|
||||||
# Check if profile exists and is available
|
used_in_room = _room_used_profiles.get(room_code, set())
|
||||||
|
# Check if profile exists and is available in this room
|
||||||
for profile in CPU_PROFILES:
|
for profile in CPU_PROFILES:
|
||||||
if profile.name == profile_name and profile.name not in _used_profiles:
|
if profile.name == profile_name and profile.name not in used_in_room:
|
||||||
_used_profiles.add(profile.name)
|
if room_code not in _room_used_profiles:
|
||||||
_cpu_profiles[cpu_id] = profile
|
_room_used_profiles[room_code] = set()
|
||||||
|
_room_used_profiles[room_code].add(profile.name)
|
||||||
|
_cpu_profiles[cpu_id] = (room_code, profile)
|
||||||
return profile
|
return profile
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_profiles(room_code: str) -> list[dict]:
|
||||||
|
"""Get available CPU profiles for a specific room."""
|
||||||
|
used_in_room = _room_used_profiles.get(room_code, set())
|
||||||
|
return [p.to_dict() for p in CPU_PROFILES if p.name not in used_in_room]
|
||||||
|
|
||||||
|
|
||||||
def get_all_profiles() -> list[dict]:
|
def get_all_profiles() -> list[dict]:
|
||||||
"""Get all CPU profiles for display."""
|
"""Get all CPU profiles for display."""
|
||||||
return [p.to_dict() for p in CPU_PROFILES]
|
return [p.to_dict() for p in CPU_PROFILES]
|
||||||
@ -1150,7 +1240,7 @@ class GolfAI:
|
|||||||
|
|
||||||
# Knock Penalty (+10 if not lowest): Need to be confident we're lowest
|
# Knock Penalty (+10 if not lowest): Need to be confident we're lowest
|
||||||
if options.knock_penalty:
|
if options.knock_penalty:
|
||||||
opponent_min = estimate_opponent_min_score(player, game)
|
opponent_min = estimate_opponent_min_score(player, game, optimistic=False)
|
||||||
# Conservative players require bigger lead
|
# Conservative players require bigger lead
|
||||||
safety_margin = 5 if profile.aggression < 0.4 else 2
|
safety_margin = 5 if profile.aggression < 0.4 else 2
|
||||||
if estimated_score > opponent_min - safety_margin:
|
if estimated_score > opponent_min - safety_margin:
|
||||||
@ -1174,8 +1264,37 @@ class GolfAI:
|
|||||||
if options.underdog_bonus:
|
if options.underdog_bonus:
|
||||||
go_out_threshold -= 1
|
go_out_threshold -= 1
|
||||||
|
|
||||||
|
# HIGH SCORE CAUTION: When our score is >10, be extra careful
|
||||||
|
# Opponents' hidden cards could easily beat us with pairs or low cards
|
||||||
|
if estimated_score > 10:
|
||||||
|
# Get pessimistic estimate of opponent's potential score
|
||||||
|
opponent_min_pessimistic = estimate_opponent_min_score(player, game, optimistic=False)
|
||||||
|
opponent_min_optimistic = estimate_opponent_min_score(player, game, optimistic=True)
|
||||||
|
|
||||||
|
ai_log(f" High score caution: our score={estimated_score}, "
|
||||||
|
f"opponent estimates: optimistic={opponent_min_optimistic}, pessimistic={opponent_min_pessimistic}")
|
||||||
|
|
||||||
|
# If opponents could potentially beat us, reduce our willingness to go out
|
||||||
|
if opponent_min_pessimistic < estimated_score:
|
||||||
|
# Calculate how risky this is
|
||||||
|
risk_margin = estimated_score - opponent_min_pessimistic
|
||||||
|
# Reduce threshold based on risk (more risk = lower threshold)
|
||||||
|
risk_penalty = min(risk_margin, 8) # Cap at 8 point penalty
|
||||||
|
go_out_threshold -= risk_penalty
|
||||||
|
ai_log(f" Risk penalty: -{risk_penalty} (opponents could score {opponent_min_pessimistic})")
|
||||||
|
|
||||||
|
# Additional penalty for very high scores (>15) - almost never go out
|
||||||
|
if estimated_score > 15:
|
||||||
|
extra_penalty = (estimated_score - 15) * 2
|
||||||
|
go_out_threshold -= extra_penalty
|
||||||
|
ai_log(f" Very high score penalty: -{extra_penalty}")
|
||||||
|
|
||||||
|
ai_log(f" Go-out decision: score={estimated_score}, threshold={go_out_threshold}, "
|
||||||
|
f"aggression={profile.aggression:.2f}")
|
||||||
|
|
||||||
if estimated_score <= go_out_threshold:
|
if estimated_score <= go_out_threshold:
|
||||||
if random.random() < profile.aggression:
|
if random.random() < profile.aggression:
|
||||||
|
ai_log(f" >> GOING OUT with score {estimated_score}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@ -1196,11 +1315,22 @@ async def process_cpu_turn(
|
|||||||
# Get logger if game_id provided
|
# Get logger if game_id provided
|
||||||
logger = get_logger() if game_id else None
|
logger = get_logger() if game_id else None
|
||||||
|
|
||||||
# Add delay based on unpredictability (chaotic players are faster/slower)
|
# Brief initial delay before CPU "looks at" the discard pile
|
||||||
delay = 0.8 + random.uniform(0, 0.5)
|
await asyncio.sleep(random.uniform(0.08, 0.15))
|
||||||
|
|
||||||
|
# "Thinking" delay based on how obvious the discard decision is
|
||||||
|
# Easy decisions (good/bad cards) are quick, medium cards take longer
|
||||||
|
discard_top = game.discard_top()
|
||||||
|
thinking_time = get_discard_thinking_time(discard_top, game.options)
|
||||||
|
|
||||||
|
# Adjust for personality - chaotic players have more variance
|
||||||
if profile.unpredictability > 0.2:
|
if profile.unpredictability > 0.2:
|
||||||
delay = random.uniform(0.3, 1.2)
|
thinking_time *= random.uniform(0.6, 1.4)
|
||||||
await asyncio.sleep(delay)
|
|
||||||
|
discard_str = f"{discard_top.rank.value}" if discard_top else "empty"
|
||||||
|
ai_log(f"{cpu_player.name} thinking for {thinking_time:.2f}s (discard: {discard_str})")
|
||||||
|
await asyncio.sleep(thinking_time)
|
||||||
|
ai_log(f"{cpu_player.name} done thinking, making decision")
|
||||||
|
|
||||||
# Check if we should try to go out early
|
# Check if we should try to go out early
|
||||||
GolfAI.should_go_out_early(cpu_player, game, profile)
|
GolfAI.should_go_out_early(cpu_player, game, profile)
|
||||||
@ -1243,8 +1373,7 @@ async def process_cpu_turn(
|
|||||||
await broadcast_callback()
|
await broadcast_callback()
|
||||||
return # Turn is over
|
return # Turn is over
|
||||||
|
|
||||||
# Decide whether to draw from discard or deck
|
# Decide whether to draw from discard or deck (discard_top already fetched above)
|
||||||
discard_top = game.discard_top()
|
|
||||||
take_discard = GolfAI.should_take_discard(discard_top, cpu_player, profile, game)
|
take_discard = GolfAI.should_take_discard(discard_top, cpu_player, profile, game)
|
||||||
|
|
||||||
source = "discard" if take_discard else "deck"
|
source = "discard" if take_discard else "deck"
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import redis.asyncio as redis
|
|||||||
from config import config
|
from config import config
|
||||||
from room import RoomManager, Room
|
from room import RoomManager, Room
|
||||||
from game import GamePhase, GameOptions
|
from game import GamePhase, GameOptions
|
||||||
from ai import GolfAI, process_cpu_turn, get_all_profiles, reset_all_profiles
|
from ai import GolfAI, process_cpu_turn, get_all_profiles, reset_all_profiles, cleanup_room_profiles
|
||||||
from game_log import get_logger
|
from game_log import get_logger
|
||||||
|
|
||||||
# Import production components
|
# Import production components
|
||||||
@ -407,12 +407,17 @@ async def require_admin(user: User = Depends(require_user)) -> User:
|
|||||||
@app.get("/api/debug/cpu-profiles")
|
@app.get("/api/debug/cpu-profiles")
|
||||||
async def get_cpu_profile_status():
|
async def get_cpu_profile_status():
|
||||||
"""Get current CPU profile allocation status."""
|
"""Get current CPU profile allocation status."""
|
||||||
from ai import _used_profiles, _cpu_profiles, CPU_PROFILES
|
from ai import _room_used_profiles, _cpu_profiles, CPU_PROFILES
|
||||||
return {
|
return {
|
||||||
"total_profiles": len(CPU_PROFILES),
|
"total_profiles": len(CPU_PROFILES),
|
||||||
"used_count": len(_used_profiles),
|
"room_profiles": {
|
||||||
"used_profiles": list(_used_profiles),
|
room_code: list(profiles)
|
||||||
"cpu_mappings": {cpu_id: profile.name for cpu_id, profile in _cpu_profiles.items()},
|
for room_code, profiles in _room_used_profiles.items()
|
||||||
|
},
|
||||||
|
"cpu_mappings": {
|
||||||
|
cpu_id: {"room": room_code, "profile": profile.name}
|
||||||
|
for cpu_id, (room_code, profile) in _cpu_profiles.items()
|
||||||
|
},
|
||||||
"active_rooms": len(room_manager.rooms),
|
"active_rooms": len(room_manager.rooms),
|
||||||
"rooms": {
|
"rooms": {
|
||||||
code: {
|
code: {
|
||||||
@ -431,6 +436,19 @@ async def reset_cpu_profiles():
|
|||||||
return {"status": "ok", "message": "All CPU profiles reset"}
|
return {"status": "ok", "message": "All CPU profiles reset"}
|
||||||
|
|
||||||
|
|
||||||
|
MAX_CONCURRENT_GAMES = 4
|
||||||
|
|
||||||
|
|
||||||
|
def count_user_games(user_id: str) -> int:
|
||||||
|
"""Count how many games this authenticated user is currently in."""
|
||||||
|
count = 0
|
||||||
|
for room in room_manager.rooms.values():
|
||||||
|
for player in room.players.values():
|
||||||
|
if player.auth_user_id == user_id:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws")
|
@app.websocket("/ws")
|
||||||
async def websocket_endpoint(websocket: WebSocket):
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
@ -444,13 +462,17 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"WebSocket auth failed: {e}")
|
logger.debug(f"WebSocket auth failed: {e}")
|
||||||
|
|
||||||
# Use authenticated user ID if available, otherwise generate random UUID
|
# Each connection gets a unique ID (allows multi-tab play)
|
||||||
|
connection_id = str(uuid.uuid4())
|
||||||
|
player_id = connection_id
|
||||||
|
|
||||||
|
# Track auth user separately for stats/limits (can be None)
|
||||||
|
auth_user_id = str(authenticated_user.id) if authenticated_user else None
|
||||||
|
|
||||||
if authenticated_user:
|
if authenticated_user:
|
||||||
player_id = str(authenticated_user.id)
|
logger.debug(f"WebSocket authenticated as user {auth_user_id}, connection {connection_id}")
|
||||||
logger.debug(f"WebSocket authenticated as user {player_id}")
|
|
||||||
else:
|
else:
|
||||||
player_id = str(uuid.uuid4())
|
logger.debug(f"WebSocket connected anonymously as {connection_id}")
|
||||||
logger.debug(f"WebSocket connected anonymously as {player_id}")
|
|
||||||
|
|
||||||
current_room: Room | None = None
|
current_room: Room | None = None
|
||||||
|
|
||||||
@ -460,12 +482,20 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
msg_type = data.get("type")
|
msg_type = data.get("type")
|
||||||
|
|
||||||
if msg_type == "create_room":
|
if msg_type == "create_room":
|
||||||
|
# Check concurrent game limit for authenticated users
|
||||||
|
if auth_user_id and count_user_games(auth_user_id) >= MAX_CONCURRENT_GAMES:
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"message": f"Maximum {MAX_CONCURRENT_GAMES} concurrent games allowed",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
player_name = data.get("player_name", "Player")
|
player_name = data.get("player_name", "Player")
|
||||||
# Use authenticated user's name if available
|
# Use authenticated user's name if available
|
||||||
if authenticated_user and authenticated_user.display_name:
|
if authenticated_user and authenticated_user.display_name:
|
||||||
player_name = authenticated_user.display_name
|
player_name = authenticated_user.display_name
|
||||||
room = room_manager.create_room()
|
room = room_manager.create_room()
|
||||||
room.add_player(player_id, player_name, websocket)
|
room.add_player(player_id, player_name, websocket, auth_user_id)
|
||||||
current_room = room
|
current_room = room
|
||||||
|
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
@ -484,6 +514,14 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
room_code = data.get("room_code", "").upper()
|
room_code = data.get("room_code", "").upper()
|
||||||
player_name = data.get("player_name", "Player")
|
player_name = data.get("player_name", "Player")
|
||||||
|
|
||||||
|
# Check concurrent game limit for authenticated users
|
||||||
|
if auth_user_id and count_user_games(auth_user_id) >= MAX_CONCURRENT_GAMES:
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"message": f"Maximum {MAX_CONCURRENT_GAMES} concurrent games allowed",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
room = room_manager.get_room(room_code)
|
room = room_manager.get_room(room_code)
|
||||||
if not room:
|
if not room:
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
@ -509,7 +547,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
# Use authenticated user's name if available
|
# Use authenticated user's name if available
|
||||||
if authenticated_user and authenticated_user.display_name:
|
if authenticated_user and authenticated_user.display_name:
|
||||||
player_name = authenticated_user.display_name
|
player_name = authenticated_user.display_name
|
||||||
room.add_player(player_id, player_name, websocket)
|
room.add_player(player_id, player_name, websocket, auth_user_id)
|
||||||
current_room = room
|
current_room = room
|
||||||
|
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
@ -744,6 +782,9 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
)
|
)
|
||||||
|
|
||||||
await broadcast_game_state(current_room)
|
await broadcast_game_state(current_room)
|
||||||
|
# Let client swap animation complete (~550ms), then pause to show result
|
||||||
|
# Total 1.0s = 550ms animation + 450ms visible pause
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
await check_and_run_cpu_turn(current_room)
|
await check_and_run_cpu_turn(current_room)
|
||||||
|
|
||||||
elif msg_type == "discard":
|
elif msg_type == "discard":
|
||||||
@ -782,9 +823,15 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
"optional": current_room.game.flip_is_optional,
|
"optional": current_room.game.flip_is_optional,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
|
# Let client animation complete before CPU turn
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
await check_and_run_cpu_turn(current_room)
|
await check_and_run_cpu_turn(current_room)
|
||||||
else:
|
else:
|
||||||
# Turn ended, check for CPU
|
# Turn ended - let client animation complete before CPU turn
|
||||||
|
# (player discard swoop animation is ~500ms: 350ms swoop + 150ms settle)
|
||||||
|
logger.debug(f"Player discarded, waiting 0.5s before CPU turn")
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
logger.debug(f"Post-discard delay complete, checking for CPU turn")
|
||||||
await check_and_run_cpu_turn(current_room)
|
await check_and_run_cpu_turn(current_room)
|
||||||
|
|
||||||
elif msg_type == "cancel_draw":
|
elif msg_type == "cancel_draw":
|
||||||
@ -954,9 +1001,11 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Clean up the room
|
# Clean up the room
|
||||||
|
room_code = current_room.code
|
||||||
for cpu in list(current_room.get_cpu_players()):
|
for cpu in list(current_room.get_cpu_players()):
|
||||||
current_room.remove_player(cpu.id)
|
current_room.remove_player(cpu.id)
|
||||||
room_manager.remove_room(current_room.code)
|
cleanup_room_profiles(room_code)
|
||||||
|
room_manager.remove_room(room_code)
|
||||||
current_room = None
|
current_room = None
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
@ -972,12 +1021,12 @@ async def _process_stats_safe(room: Room):
|
|||||||
notifications while stats are being processed.
|
notifications while stats are being processed.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Build mapping - for non-CPU players, the player_id is their user_id
|
# Build mapping - use auth_user_id for authenticated players
|
||||||
# (assigned during authentication or as a session UUID)
|
# Only authenticated players get their stats tracked
|
||||||
player_user_ids = {}
|
player_user_ids = {}
|
||||||
for player_id, room_player in room.players.items():
|
for player_id, room_player in room.players.items():
|
||||||
if not room_player.is_cpu:
|
if not room_player.is_cpu and room_player.auth_user_id:
|
||||||
player_user_ids[player_id] = player_id
|
player_user_ids[player_id] = room_player.auth_user_id
|
||||||
|
|
||||||
# Find winner
|
# Find winner
|
||||||
winner_id = None
|
winner_id = None
|
||||||
@ -1095,6 +1144,7 @@ async def check_and_run_cpu_turn(room: Room):
|
|||||||
|
|
||||||
async def handle_player_leave(room: Room, player_id: str):
|
async def handle_player_leave(room: Room, player_id: str):
|
||||||
"""Handle a player leaving a room."""
|
"""Handle a player leaving a room."""
|
||||||
|
room_code = room.code
|
||||||
room_player = room.remove_player(player_id)
|
room_player = room.remove_player(player_id)
|
||||||
|
|
||||||
# If no human players left, clean up the room entirely
|
# If no human players left, clean up the room entirely
|
||||||
@ -1102,7 +1152,9 @@ async def handle_player_leave(room: Room, player_id: str):
|
|||||||
# Remove all remaining CPU players to release their profiles
|
# Remove all remaining CPU players to release their profiles
|
||||||
for cpu in list(room.get_cpu_players()):
|
for cpu in list(room.get_cpu_players()):
|
||||||
room.remove_player(cpu.id)
|
room.remove_player(cpu.id)
|
||||||
room_manager.remove_room(room.code)
|
# Clean up any remaining profile tracking for this room
|
||||||
|
cleanup_room_profiles(room_code)
|
||||||
|
room_manager.remove_room(room_code)
|
||||||
elif room_player:
|
elif room_player:
|
||||||
await room.broadcast({
|
await room.broadcast({
|
||||||
"type": "player_left",
|
"type": "player_left",
|
||||||
|
|||||||
@ -19,7 +19,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
|
||||||
from ai import assign_profile, assign_specific_profile, get_profile, release_profile
|
from ai import assign_profile, assign_specific_profile, get_profile, release_profile, cleanup_room_profiles
|
||||||
from game import Game, Player
|
from game import Game, Player
|
||||||
|
|
||||||
|
|
||||||
@ -33,11 +33,12 @@ class RoomPlayer:
|
|||||||
in-game state like cards and scores.
|
in-game state like cards and scores.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
id: Unique player identifier.
|
id: Unique player identifier (connection_id for multi-tab support).
|
||||||
name: Display name.
|
name: Display name.
|
||||||
websocket: WebSocket connection (None for CPU players).
|
websocket: WebSocket connection (None for CPU players).
|
||||||
is_host: Whether this player controls game settings.
|
is_host: Whether this player controls game settings.
|
||||||
is_cpu: Whether this is an AI-controlled player.
|
is_cpu: Whether this is an AI-controlled player.
|
||||||
|
auth_user_id: Authenticated user ID for stats/limits (None for guests).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
@ -45,6 +46,7 @@ class RoomPlayer:
|
|||||||
websocket: Optional[WebSocket] = None
|
websocket: Optional[WebSocket] = None
|
||||||
is_host: bool = False
|
is_host: bool = False
|
||||||
is_cpu: bool = False
|
is_cpu: bool = False
|
||||||
|
auth_user_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -73,6 +75,7 @@ class Room:
|
|||||||
player_id: str,
|
player_id: str,
|
||||||
name: str,
|
name: str,
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
|
auth_user_id: Optional[str] = None,
|
||||||
) -> RoomPlayer:
|
) -> RoomPlayer:
|
||||||
"""
|
"""
|
||||||
Add a human player to the room.
|
Add a human player to the room.
|
||||||
@ -80,9 +83,10 @@ class Room:
|
|||||||
The first player to join becomes the host.
|
The first player to join becomes the host.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
player_id: Unique identifier for the player.
|
player_id: Unique identifier for the player (connection_id).
|
||||||
name: Display name.
|
name: Display name.
|
||||||
websocket: The player's WebSocket connection.
|
websocket: The player's WebSocket connection.
|
||||||
|
auth_user_id: Authenticated user ID for stats/limits (None for guests).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The created RoomPlayer object.
|
The created RoomPlayer object.
|
||||||
@ -93,6 +97,7 @@ class Room:
|
|||||||
name=name,
|
name=name,
|
||||||
websocket=websocket,
|
websocket=websocket,
|
||||||
is_host=is_host,
|
is_host=is_host,
|
||||||
|
auth_user_id=auth_user_id,
|
||||||
)
|
)
|
||||||
self.players[player_id] = room_player
|
self.players[player_id] = room_player
|
||||||
|
|
||||||
@ -117,9 +122,9 @@ class Room:
|
|||||||
The created RoomPlayer, or None if profile unavailable.
|
The created RoomPlayer, or None if profile unavailable.
|
||||||
"""
|
"""
|
||||||
if profile_name:
|
if profile_name:
|
||||||
profile = assign_specific_profile(cpu_id, profile_name)
|
profile = assign_specific_profile(cpu_id, profile_name, self.code)
|
||||||
else:
|
else:
|
||||||
profile = assign_profile(cpu_id)
|
profile = assign_profile(cpu_id, self.code)
|
||||||
|
|
||||||
if not profile:
|
if not profile:
|
||||||
return None
|
return None
|
||||||
@ -157,9 +162,9 @@ class Room:
|
|||||||
room_player = self.players.pop(player_id)
|
room_player = self.players.pop(player_id)
|
||||||
self.game.remove_player(player_id)
|
self.game.remove_player(player_id)
|
||||||
|
|
||||||
# Release CPU profile back to the pool
|
# Release CPU profile back to the room's pool
|
||||||
if room_player.is_cpu:
|
if room_player.is_cpu:
|
||||||
release_profile(room_player.name)
|
release_profile(room_player.name, self.code)
|
||||||
|
|
||||||
# Assign new host if needed
|
# Assign new host if needed
|
||||||
if room_player.is_host and self.players:
|
if room_player.is_host and self.players:
|
||||||
|
|||||||
5
tests/e2e/.gitignore
vendored
Normal file
5
tests/e2e/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
255
tests/e2e/bot/actions.ts
Normal file
255
tests/e2e/bot/actions.ts
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* Game action executors with proper animation timing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Page } from '@playwright/test';
|
||||||
|
import { SELECTORS } from '../utils/selectors';
|
||||||
|
import { TIMING, waitForAnimations } from '../utils/timing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a game action
|
||||||
|
*/
|
||||||
|
export interface ActionResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes game actions on the page
|
||||||
|
*/
|
||||||
|
export class Actions {
|
||||||
|
constructor(private page: Page) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw a card from the deck
|
||||||
|
*/
|
||||||
|
async drawFromDeck(): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
// Wait for any ongoing animations first
|
||||||
|
await waitForAnimations(this.page);
|
||||||
|
|
||||||
|
const deck = this.page.locator(SELECTORS.game.deck);
|
||||||
|
await deck.waitFor({ state: 'visible', timeout: 5000 });
|
||||||
|
|
||||||
|
// Wait for deck to become clickable (may take a moment after turn starts)
|
||||||
|
let isClickable = false;
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
isClickable = await deck.evaluate(el => el.classList.contains('clickable'));
|
||||||
|
if (isClickable) break;
|
||||||
|
await this.page.waitForTimeout(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isClickable) {
|
||||||
|
return { success: false, error: 'Deck is not clickable' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use force:true because deck-area has a pulsing animation that makes it "unstable"
|
||||||
|
await deck.click({ force: true, timeout: 5000 });
|
||||||
|
await this.page.waitForTimeout(TIMING.drawComplete);
|
||||||
|
await waitForAnimations(this.page);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw a card from the discard pile
|
||||||
|
*/
|
||||||
|
async drawFromDiscard(): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
// Wait for any ongoing animations first
|
||||||
|
await waitForAnimations(this.page);
|
||||||
|
|
||||||
|
const discard = this.page.locator(SELECTORS.game.discard);
|
||||||
|
await discard.waitFor({ state: 'visible', timeout: 5000 });
|
||||||
|
// Use force:true because deck-area has a pulsing animation
|
||||||
|
await discard.click({ force: true, timeout: 5000 });
|
||||||
|
await this.page.waitForTimeout(TIMING.drawComplete);
|
||||||
|
await waitForAnimations(this.page);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swap drawn card with a card at position
|
||||||
|
*/
|
||||||
|
async swapCard(position: number): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const cardSelector = SELECTORS.cards.playerCard(position);
|
||||||
|
const card = this.page.locator(cardSelector);
|
||||||
|
await card.waitFor({ state: 'visible', timeout: 5000 });
|
||||||
|
// Use force:true to handle any CSS animations
|
||||||
|
await card.click({ force: true, timeout: 5000 });
|
||||||
|
await this.page.waitForTimeout(TIMING.swapComplete);
|
||||||
|
await waitForAnimations(this.page);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discard the drawn card
|
||||||
|
*/
|
||||||
|
async discardDrawn(): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const discardBtn = this.page.locator(SELECTORS.game.discardBtn);
|
||||||
|
await discardBtn.click();
|
||||||
|
await this.page.waitForTimeout(TIMING.pauseAfterDiscard);
|
||||||
|
await waitForAnimations(this.page);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flip a card at position
|
||||||
|
*/
|
||||||
|
async flipCard(position: number): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
// Wait for animations before clicking
|
||||||
|
await waitForAnimations(this.page);
|
||||||
|
|
||||||
|
const cardSelector = SELECTORS.cards.playerCard(position);
|
||||||
|
const card = this.page.locator(cardSelector);
|
||||||
|
await card.waitFor({ state: 'visible', timeout: 5000 });
|
||||||
|
// Use force:true to handle any CSS animations
|
||||||
|
await card.click({ force: true, timeout: 5000 });
|
||||||
|
await this.page.waitForTimeout(TIMING.flipComplete);
|
||||||
|
await waitForAnimations(this.page);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip the optional flip (endgame mode)
|
||||||
|
*/
|
||||||
|
async skipFlip(): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const skipBtn = this.page.locator(SELECTORS.game.skipFlipBtn);
|
||||||
|
await skipBtn.click();
|
||||||
|
await this.page.waitForTimeout(TIMING.turnTransition);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Knock early (flip all remaining cards)
|
||||||
|
*/
|
||||||
|
async knockEarly(): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const knockBtn = this.page.locator(SELECTORS.game.knockEarlyBtn);
|
||||||
|
await knockBtn.click();
|
||||||
|
await this.page.waitForTimeout(TIMING.swapComplete);
|
||||||
|
await waitForAnimations(this.page);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for turn to start
|
||||||
|
*/
|
||||||
|
async waitForMyTurn(timeout: number = 30000): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.page.waitForFunction(
|
||||||
|
(sel) => {
|
||||||
|
const deckArea = document.querySelector(sel);
|
||||||
|
return deckArea?.classList.contains('your-turn-to-draw');
|
||||||
|
},
|
||||||
|
SELECTORS.game.deckArea,
|
||||||
|
{ timeout }
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for game phase change
|
||||||
|
*/
|
||||||
|
async waitForPhase(
|
||||||
|
expectedPhases: string[],
|
||||||
|
timeout: number = 30000
|
||||||
|
): Promise<boolean> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
// Check for round over
|
||||||
|
const nextRoundBtn = this.page.locator(SELECTORS.game.nextRoundBtn);
|
||||||
|
if (await nextRoundBtn.isVisible().catch(() => false)) {
|
||||||
|
if (expectedPhases.includes('round_over')) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for game over
|
||||||
|
const newGameBtn = this.page.locator(SELECTORS.game.newGameBtn);
|
||||||
|
if (await newGameBtn.isVisible().catch(() => false)) {
|
||||||
|
if (expectedPhases.includes('game_over')) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for final turn
|
||||||
|
const finalTurnBadge = this.page.locator(SELECTORS.game.finalTurnBadge);
|
||||||
|
if (await finalTurnBadge.isVisible().catch(() => false)) {
|
||||||
|
if (expectedPhases.includes('final_turn')) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for my turn (playing phase)
|
||||||
|
const deckArea = this.page.locator(SELECTORS.game.deckArea);
|
||||||
|
const isMyTurn = await deckArea.evaluate(el =>
|
||||||
|
el.classList.contains('your-turn-to-draw')
|
||||||
|
).catch(() => false);
|
||||||
|
if (isMyTurn && expectedPhases.includes('playing')) return true;
|
||||||
|
|
||||||
|
await this.page.waitForTimeout(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the "Next Hole" button to start next round
|
||||||
|
*/
|
||||||
|
async nextRound(): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const btn = this.page.locator(SELECTORS.game.nextRoundBtn);
|
||||||
|
await btn.waitFor({ state: 'visible', timeout: 5000 });
|
||||||
|
await btn.click();
|
||||||
|
await this.page.waitForTimeout(TIMING.roundOverDelay);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the "New Game" button to return to waiting room
|
||||||
|
*/
|
||||||
|
async newGame(): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const btn = this.page.locator(SELECTORS.game.newGameBtn);
|
||||||
|
await btn.waitFor({ state: 'visible', timeout: 5000 });
|
||||||
|
await btn.click();
|
||||||
|
await this.page.waitForTimeout(TIMING.turnTransition);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for animations to complete
|
||||||
|
*/
|
||||||
|
async waitForAnimationComplete(timeout: number = 5000): Promise<void> {
|
||||||
|
await waitForAnimations(this.page, timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
334
tests/e2e/bot/ai-brain.ts
Normal file
334
tests/e2e/bot/ai-brain.ts
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
/**
|
||||||
|
* AI decision-making logic for the test bot
|
||||||
|
* Simplified port of server/ai.py for client-side decision making
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CardState, PlayerState } from './state-parser';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card value mapping (standard rules)
|
||||||
|
*/
|
||||||
|
const CARD_VALUES: Record<string, number> = {
|
||||||
|
'★': -2, // Joker
|
||||||
|
'2': -2,
|
||||||
|
'A': 1,
|
||||||
|
'K': 0,
|
||||||
|
'3': 3,
|
||||||
|
'4': 4,
|
||||||
|
'5': 5,
|
||||||
|
'6': 6,
|
||||||
|
'7': 7,
|
||||||
|
'8': 8,
|
||||||
|
'9': 9,
|
||||||
|
'10': 10,
|
||||||
|
'J': 10,
|
||||||
|
'Q': 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game options that affect card values
|
||||||
|
*/
|
||||||
|
export interface GameOptions {
|
||||||
|
superKings?: boolean; // K = -2 instead of 0
|
||||||
|
tenPenny?: boolean; // 10 = 1 instead of 10
|
||||||
|
oneEyedJacks?: boolean; // J♥/J♠ = 0
|
||||||
|
eagleEye?: boolean; // Jokers +2 unpaired, -4 paired
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the point value of a card
|
||||||
|
*/
|
||||||
|
export function getCardValue(card: { rank: string | null; suit?: string | null }, options: GameOptions = {}): number {
|
||||||
|
if (!card.rank) return 5; // Unknown card estimated at average
|
||||||
|
|
||||||
|
let value = CARD_VALUES[card.rank] ?? 5;
|
||||||
|
|
||||||
|
// Super Kings rule
|
||||||
|
if (options.superKings && card.rank === 'K') {
|
||||||
|
value = -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ten Penny rule
|
||||||
|
if (options.tenPenny && card.rank === '10') {
|
||||||
|
value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-Eyed Jacks rule
|
||||||
|
if (options.oneEyedJacks && card.rank === 'J') {
|
||||||
|
if (card.suit === 'hearts' || card.suit === 'spades') {
|
||||||
|
value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eagle Eye rule (Jokers are +2 when unpaired)
|
||||||
|
// Note: We can't know pairing status from just one card, so this is informational
|
||||||
|
if (options.eagleEye && card.rank === '★') {
|
||||||
|
value = 2; // Default to unpaired value
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get column partner position (cards that can form pairs)
|
||||||
|
* Column pairs: (0,3), (1,4), (2,5)
|
||||||
|
*/
|
||||||
|
export function getColumnPartner(position: number): number {
|
||||||
|
return position < 3 ? position + 3 : position - 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Brain - makes decisions for the test bot
|
||||||
|
*/
|
||||||
|
export class AIBrain {
|
||||||
|
constructor(private options: GameOptions = {}) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose 2 cards for initial flip
|
||||||
|
* Prefer different columns for better pair information
|
||||||
|
*/
|
||||||
|
chooseInitialFlips(cards: CardState[]): number[] {
|
||||||
|
const faceDown = cards.filter(c => !c.faceUp);
|
||||||
|
if (faceDown.length === 0) return [];
|
||||||
|
if (faceDown.length === 1) return [faceDown[0].position];
|
||||||
|
|
||||||
|
// Good initial flip patterns (different columns)
|
||||||
|
const patterns = [
|
||||||
|
[0, 4], [2, 4], [3, 1], [5, 1],
|
||||||
|
[0, 5], [2, 3],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Find a valid pattern
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const valid = pattern.every(p =>
|
||||||
|
faceDown.some(c => c.position === p)
|
||||||
|
);
|
||||||
|
if (valid) return pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: pick any two face-down cards in different columns
|
||||||
|
const result: number[] = [];
|
||||||
|
const usedColumns = new Set<number>();
|
||||||
|
|
||||||
|
for (const card of faceDown) {
|
||||||
|
const col = card.position % 3;
|
||||||
|
if (!usedColumns.has(col)) {
|
||||||
|
result.push(card.position);
|
||||||
|
usedColumns.add(col);
|
||||||
|
if (result.length === 2) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we couldn't get different columns, just take first two
|
||||||
|
if (result.length < 2) {
|
||||||
|
for (const card of faceDown) {
|
||||||
|
if (!result.includes(card.position)) {
|
||||||
|
result.push(card.position);
|
||||||
|
if (result.length === 2) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether to take from discard pile
|
||||||
|
*/
|
||||||
|
shouldTakeDiscard(
|
||||||
|
discardCard: { rank: string; suit: string } | null,
|
||||||
|
myCards: CardState[]
|
||||||
|
): boolean {
|
||||||
|
if (!discardCard) return false;
|
||||||
|
|
||||||
|
const value = getCardValue(discardCard, this.options);
|
||||||
|
|
||||||
|
// Always take Jokers and Kings (excellent cards)
|
||||||
|
if (discardCard.rank === '★' || discardCard.rank === 'K') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always take negative/low value cards
|
||||||
|
if (value <= 2) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if discard can form a pair with a visible card
|
||||||
|
for (const card of myCards) {
|
||||||
|
if (card.faceUp && card.rank === discardCard.rank) {
|
||||||
|
const partnerPos = getColumnPartner(card.position);
|
||||||
|
const partnerCard = myCards.find(c => c.position === partnerPos);
|
||||||
|
|
||||||
|
// Only pair if partner is face-down (unknown) - pairing negative cards is wasteful
|
||||||
|
if (partnerCard && !partnerCard.faceUp && value > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take medium cards if we have visible bad cards to replace
|
||||||
|
if (value <= 5) {
|
||||||
|
for (const card of myCards) {
|
||||||
|
if (card.faceUp && card.rank) {
|
||||||
|
const cardValue = getCardValue(card, this.options);
|
||||||
|
if (cardValue > value + 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: draw from deck
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose position to swap drawn card, or null to discard
|
||||||
|
*/
|
||||||
|
chooseSwapPosition(
|
||||||
|
drawnCard: { rank: string; suit?: string | null },
|
||||||
|
myCards: CardState[],
|
||||||
|
mustSwap: boolean = false // True if drawn from discard
|
||||||
|
): number | null {
|
||||||
|
const drawnValue = getCardValue(drawnCard, this.options);
|
||||||
|
|
||||||
|
// Calculate score for each position
|
||||||
|
const scores: { pos: number; score: number }[] = [];
|
||||||
|
|
||||||
|
for (let pos = 0; pos < 6; pos++) {
|
||||||
|
const card = myCards.find(c => c.position === pos);
|
||||||
|
if (!card) continue;
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
const partnerPos = getColumnPartner(pos);
|
||||||
|
const partnerCard = myCards.find(c => c.position === partnerPos);
|
||||||
|
|
||||||
|
// Check for pair creation
|
||||||
|
if (partnerCard?.faceUp && partnerCard.rank === drawnCard.rank) {
|
||||||
|
const partnerValue = getCardValue(partnerCard, this.options);
|
||||||
|
|
||||||
|
if (drawnValue >= 0) {
|
||||||
|
// Good pair! Both cards become 0
|
||||||
|
score += drawnValue + partnerValue;
|
||||||
|
} else {
|
||||||
|
// Pairing negative cards is wasteful (unless special rules)
|
||||||
|
score -= Math.abs(drawnValue) * 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point improvement
|
||||||
|
if (card.faceUp && card.rank) {
|
||||||
|
const currentValue = getCardValue(card, this.options);
|
||||||
|
score += currentValue - drawnValue;
|
||||||
|
} else {
|
||||||
|
// Face-down card - expected value ~4.5
|
||||||
|
const expectedHidden = 4.5;
|
||||||
|
score += (expectedHidden - drawnValue) * 0.7; // Discount for uncertainty
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus for revealing hidden cards with good drawn cards
|
||||||
|
if (!card.faceUp && drawnValue <= 3) {
|
||||||
|
score += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
scores.push({ pos, score });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score descending
|
||||||
|
scores.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
// If best score is positive, swap there
|
||||||
|
if (scores.length > 0 && scores[0].score > 0) {
|
||||||
|
return scores[0].pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must swap if drawn from discard
|
||||||
|
if (mustSwap && scores.length > 0) {
|
||||||
|
// Find a face-down position if possible
|
||||||
|
const faceDownScores = scores.filter(s => {
|
||||||
|
const card = myCards.find(c => c.position === s.pos);
|
||||||
|
return card && !card.faceUp;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (faceDownScores.length > 0) {
|
||||||
|
return faceDownScores[0].pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise take the best score even if negative
|
||||||
|
return scores[0].pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard the drawn card
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose which card to flip after discarding
|
||||||
|
*/
|
||||||
|
chooseFlipPosition(myCards: CardState[]): number {
|
||||||
|
const faceDown = myCards.filter(c => !c.faceUp);
|
||||||
|
if (faceDown.length === 0) return 0;
|
||||||
|
|
||||||
|
// Prefer flipping cards where the partner is visible (pair info)
|
||||||
|
for (const card of faceDown) {
|
||||||
|
const partnerPos = getColumnPartner(card.position);
|
||||||
|
const partner = myCards.find(c => c.position === partnerPos);
|
||||||
|
if (partner?.faceUp) {
|
||||||
|
return card.position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random face-down card
|
||||||
|
return faceDown[Math.floor(Math.random() * faceDown.length)].position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether to skip optional flip (endgame mode)
|
||||||
|
*/
|
||||||
|
shouldSkipFlip(myCards: CardState[]): boolean {
|
||||||
|
const faceDown = myCards.filter(c => !c.faceUp);
|
||||||
|
|
||||||
|
// Always flip if we have many hidden cards
|
||||||
|
if (faceDown.length >= 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small chance to skip with 1-2 hidden cards
|
||||||
|
return faceDown.length <= 2 && Math.random() < 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate estimated hand score
|
||||||
|
*/
|
||||||
|
estimateScore(cards: CardState[]): number {
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// Group cards by column for pair detection
|
||||||
|
const columns: (CardState | undefined)[][] = [
|
||||||
|
[cards.find(c => c.position === 0), cards.find(c => c.position === 3)],
|
||||||
|
[cards.find(c => c.position === 1), cards.find(c => c.position === 4)],
|
||||||
|
[cards.find(c => c.position === 2), cards.find(c => c.position === 5)],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [top, bottom] of columns) {
|
||||||
|
if (top?.faceUp && bottom?.faceUp) {
|
||||||
|
if (top.rank === bottom.rank) {
|
||||||
|
// Pair - contributes 0
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
score += getCardValue(top, this.options);
|
||||||
|
score += getCardValue(bottom, this.options);
|
||||||
|
} else if (top?.faceUp) {
|
||||||
|
score += getCardValue(top, this.options);
|
||||||
|
score += 4.5; // Estimate for hidden bottom
|
||||||
|
} else if (bottom?.faceUp) {
|
||||||
|
score += 4.5; // Estimate for hidden top
|
||||||
|
score += getCardValue(bottom, this.options);
|
||||||
|
} else {
|
||||||
|
score += 9; // Both hidden, estimate 4.5 each
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(score);
|
||||||
|
}
|
||||||
|
}
|
||||||
599
tests/e2e/bot/golf-bot.ts
Normal file
599
tests/e2e/bot/golf-bot.ts
Normal file
@ -0,0 +1,599 @@
|
|||||||
|
/**
|
||||||
|
* GolfBot - Main orchestrator for the test bot
|
||||||
|
* Controls browser and coordinates game actions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Page, expect } from '@playwright/test';
|
||||||
|
import { StateParser, GamePhase, ParsedGameState } from './state-parser';
|
||||||
|
import { AIBrain, GameOptions } from './ai-brain';
|
||||||
|
import { Actions, ActionResult } from './actions';
|
||||||
|
import { SELECTORS } from '../utils/selectors';
|
||||||
|
import { TIMING, waitForAnimations } from '../utils/timing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for starting a game
|
||||||
|
*/
|
||||||
|
export interface StartGameOptions {
|
||||||
|
holes?: number;
|
||||||
|
decks?: number;
|
||||||
|
initialFlips?: number;
|
||||||
|
flipMode?: 'never' | 'always' | 'endgame';
|
||||||
|
knockPenalty?: boolean;
|
||||||
|
jokerMode?: 'none' | 'standard' | 'lucky-swing' | 'eagle-eye';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a turn
|
||||||
|
*/
|
||||||
|
export interface TurnResult {
|
||||||
|
success: boolean;
|
||||||
|
action: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GolfBot - automated game player for testing
|
||||||
|
*/
|
||||||
|
export class GolfBot {
|
||||||
|
private stateParser: StateParser;
|
||||||
|
private actions: Actions;
|
||||||
|
private brain: AIBrain;
|
||||||
|
private screenshots: { label: string; buffer: Buffer }[] = [];
|
||||||
|
private consoleErrors: string[] = [];
|
||||||
|
private turnCount = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private page: Page,
|
||||||
|
aiOptions: GameOptions = {}
|
||||||
|
) {
|
||||||
|
this.stateParser = new StateParser(page);
|
||||||
|
this.actions = new Actions(page);
|
||||||
|
this.brain = new AIBrain(aiOptions);
|
||||||
|
|
||||||
|
// Capture console errors
|
||||||
|
page.on('console', msg => {
|
||||||
|
if (msg.type() === 'error') {
|
||||||
|
this.consoleErrors.push(msg.text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
page.on('pageerror', err => {
|
||||||
|
this.consoleErrors.push(err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the game
|
||||||
|
*/
|
||||||
|
async goto(url?: string): Promise<void> {
|
||||||
|
await this.page.goto(url || '/');
|
||||||
|
await this.page.waitForLoadState('networkidle');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new game room
|
||||||
|
*/
|
||||||
|
async createGame(playerName: string): Promise<string> {
|
||||||
|
// Enter name
|
||||||
|
const nameInput = this.page.locator(SELECTORS.lobby.playerNameInput);
|
||||||
|
await nameInput.fill(playerName);
|
||||||
|
|
||||||
|
// Click create room
|
||||||
|
const createBtn = this.page.locator(SELECTORS.lobby.createRoomBtn);
|
||||||
|
await createBtn.click();
|
||||||
|
|
||||||
|
// Wait for waiting room
|
||||||
|
await this.page.waitForSelector(SELECTORS.screens.waiting, {
|
||||||
|
state: 'visible',
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get room code
|
||||||
|
const roomCodeEl = this.page.locator(SELECTORS.waiting.roomCode);
|
||||||
|
const roomCode = await roomCodeEl.textContent() || '';
|
||||||
|
|
||||||
|
return roomCode.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join an existing game room
|
||||||
|
*/
|
||||||
|
async joinGame(roomCode: string, playerName: string): Promise<void> {
|
||||||
|
// Enter name
|
||||||
|
const nameInput = this.page.locator(SELECTORS.lobby.playerNameInput);
|
||||||
|
await nameInput.fill(playerName);
|
||||||
|
|
||||||
|
// Enter room code
|
||||||
|
const codeInput = this.page.locator(SELECTORS.lobby.roomCodeInput);
|
||||||
|
await codeInput.fill(roomCode);
|
||||||
|
|
||||||
|
// Click join
|
||||||
|
const joinBtn = this.page.locator(SELECTORS.lobby.joinRoomBtn);
|
||||||
|
await joinBtn.click();
|
||||||
|
|
||||||
|
// Wait for waiting room
|
||||||
|
await this.page.waitForSelector(SELECTORS.screens.waiting, {
|
||||||
|
state: 'visible',
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a CPU player
|
||||||
|
*/
|
||||||
|
async addCPU(profileName?: string): Promise<void> {
|
||||||
|
// Click add CPU button
|
||||||
|
const addBtn = this.page.locator(SELECTORS.waiting.addCpuBtn);
|
||||||
|
await addBtn.click();
|
||||||
|
|
||||||
|
// Wait for modal
|
||||||
|
await this.page.waitForSelector(SELECTORS.waiting.cpuModal, {
|
||||||
|
state: 'visible',
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select profile if specified
|
||||||
|
if (profileName) {
|
||||||
|
const profileCard = this.page.locator(
|
||||||
|
`${SELECTORS.waiting.cpuProfilesGrid} .profile-card:has-text("${profileName}")`
|
||||||
|
);
|
||||||
|
await profileCard.click();
|
||||||
|
} else {
|
||||||
|
// Select first available profile
|
||||||
|
const firstProfile = this.page.locator(
|
||||||
|
`${SELECTORS.waiting.cpuProfilesGrid} .profile-card:not(.unavailable)`
|
||||||
|
).first();
|
||||||
|
await firstProfile.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click add button
|
||||||
|
const addSelectedBtn = this.page.locator(SELECTORS.waiting.addSelectedCpusBtn);
|
||||||
|
await addSelectedBtn.click();
|
||||||
|
|
||||||
|
// Wait for modal to close
|
||||||
|
await this.page.waitForSelector(SELECTORS.waiting.cpuModal, {
|
||||||
|
state: 'hidden',
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the game
|
||||||
|
*/
|
||||||
|
async startGame(options: StartGameOptions = {}): Promise<void> {
|
||||||
|
// Set game options if host
|
||||||
|
const hostSettings = this.page.locator(SELECTORS.waiting.hostSettings);
|
||||||
|
|
||||||
|
if (await hostSettings.isVisible()) {
|
||||||
|
if (options.holes) {
|
||||||
|
await this.page.selectOption(SELECTORS.waiting.numRounds, String(options.holes));
|
||||||
|
}
|
||||||
|
if (options.decks) {
|
||||||
|
await this.page.selectOption(SELECTORS.waiting.numDecks, String(options.decks));
|
||||||
|
}
|
||||||
|
if (options.initialFlips !== undefined) {
|
||||||
|
await this.page.selectOption(SELECTORS.waiting.initialFlips, String(options.initialFlips));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advanced options require opening the details section first
|
||||||
|
if (options.flipMode) {
|
||||||
|
const advancedSection = this.page.locator('.advanced-options-section');
|
||||||
|
if (await advancedSection.isVisible()) {
|
||||||
|
// Check if it's already open
|
||||||
|
const isOpen = await advancedSection.evaluate(el => el.hasAttribute('open'));
|
||||||
|
if (!isOpen) {
|
||||||
|
await advancedSection.locator('summary').click();
|
||||||
|
await this.page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.page.selectOption(SELECTORS.waiting.flipMode, options.flipMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click start game
|
||||||
|
const startBtn = this.page.locator(SELECTORS.waiting.startGameBtn);
|
||||||
|
await startBtn.click();
|
||||||
|
|
||||||
|
// Wait for game screen
|
||||||
|
await this.page.waitForSelector(SELECTORS.screens.game, {
|
||||||
|
state: 'visible',
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForAnimations(this.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current game phase
|
||||||
|
*/
|
||||||
|
async getGamePhase(): Promise<GamePhase> {
|
||||||
|
return this.stateParser.getPhase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full game state
|
||||||
|
*/
|
||||||
|
async getGameState(): Promise<ParsedGameState> {
|
||||||
|
return this.stateParser.getState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if it's bot's turn
|
||||||
|
*/
|
||||||
|
async isMyTurn(): Promise<boolean> {
|
||||||
|
return this.stateParser.isMyTurn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for bot's turn
|
||||||
|
*/
|
||||||
|
async waitForMyTurn(timeout: number = 30000): Promise<boolean> {
|
||||||
|
return this.actions.waitForMyTurn(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for any animation to complete
|
||||||
|
*/
|
||||||
|
async waitForAnimation(): Promise<void> {
|
||||||
|
await waitForAnimations(this.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a complete turn
|
||||||
|
*/
|
||||||
|
async playTurn(): Promise<TurnResult> {
|
||||||
|
this.turnCount++;
|
||||||
|
const state = await this.getGameState();
|
||||||
|
|
||||||
|
// Handle initial flip phase
|
||||||
|
if (state.phase === 'initial_flip') {
|
||||||
|
return this.handleInitialFlip(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle waiting for flip after discard
|
||||||
|
if (state.phase === 'waiting_for_flip') {
|
||||||
|
return this.handleWaitingForFlip(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular turn
|
||||||
|
if (!state.heldCard.visible) {
|
||||||
|
// Need to draw
|
||||||
|
return this.handleDraw(state);
|
||||||
|
} else {
|
||||||
|
// Have a card, need to swap or discard
|
||||||
|
return this.handleSwapOrDiscard(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle initial flip phase
|
||||||
|
*/
|
||||||
|
private async handleInitialFlip(state: ParsedGameState): Promise<TurnResult> {
|
||||||
|
const myCards = state.myPlayer?.cards || [];
|
||||||
|
const faceDownPositions = myCards.filter(c => !c.faceUp).map(c => c.position);
|
||||||
|
|
||||||
|
if (faceDownPositions.length === 0) {
|
||||||
|
return { success: true, action: 'initial_flip_complete' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose cards to flip
|
||||||
|
const toFlip = this.brain.chooseInitialFlips(myCards);
|
||||||
|
|
||||||
|
for (const pos of toFlip) {
|
||||||
|
if (faceDownPositions.includes(pos)) {
|
||||||
|
const result = await this.actions.flipCard(pos);
|
||||||
|
if (!result.success) {
|
||||||
|
return { success: false, action: 'initial_flip', error: result.error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
action: 'initial_flip',
|
||||||
|
details: { positions: toFlip },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle draw phase
|
||||||
|
*/
|
||||||
|
private async handleDraw(state: ParsedGameState): Promise<TurnResult> {
|
||||||
|
const myCards = state.myPlayer?.cards || [];
|
||||||
|
const discardTop = state.discard.topCard;
|
||||||
|
|
||||||
|
// Decide: discard or deck
|
||||||
|
const takeDiscard = this.brain.shouldTakeDiscard(discardTop, myCards);
|
||||||
|
|
||||||
|
let result: ActionResult;
|
||||||
|
let source: string;
|
||||||
|
|
||||||
|
if (takeDiscard && state.discard.clickable) {
|
||||||
|
result = await this.actions.drawFromDiscard();
|
||||||
|
source = 'discard';
|
||||||
|
} else {
|
||||||
|
result = await this.actions.drawFromDeck();
|
||||||
|
source = 'deck';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return { success: false, action: 'draw', error: result.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for held card to be visible
|
||||||
|
await this.page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Now handle swap or discard
|
||||||
|
const newState = await this.getGameState();
|
||||||
|
|
||||||
|
// If held card still not visible, wait a bit more and retry
|
||||||
|
if (!newState.heldCard.visible) {
|
||||||
|
await this.page.waitForTimeout(500);
|
||||||
|
const retryState = await this.getGameState();
|
||||||
|
return this.handleSwapOrDiscard(retryState, source === 'discard');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.handleSwapOrDiscard(newState, source === 'discard');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle swap or discard decision
|
||||||
|
*/
|
||||||
|
private async handleSwapOrDiscard(
|
||||||
|
state: ParsedGameState,
|
||||||
|
mustSwap: boolean = false
|
||||||
|
): Promise<TurnResult> {
|
||||||
|
const myCards = state.myPlayer?.cards || [];
|
||||||
|
const heldCard = state.heldCard.card;
|
||||||
|
|
||||||
|
if (!heldCard) {
|
||||||
|
return { success: false, action: 'swap_or_discard', error: 'No held card' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide: swap position or discard
|
||||||
|
const swapPos = this.brain.chooseSwapPosition(heldCard, myCards, mustSwap);
|
||||||
|
|
||||||
|
if (swapPos !== null) {
|
||||||
|
// Swap
|
||||||
|
const result = await this.actions.swapCard(swapPos);
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
action: 'swap',
|
||||||
|
details: { position: swapPos, card: heldCard },
|
||||||
|
error: result.error,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Discard
|
||||||
|
const result = await this.actions.discardDrawn();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return { success: false, action: 'discard', error: result.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we need to flip
|
||||||
|
await this.page.waitForTimeout(200);
|
||||||
|
const afterState = await this.getGameState();
|
||||||
|
|
||||||
|
if (afterState.phase === 'waiting_for_flip') {
|
||||||
|
return this.handleWaitingForFlip(afterState);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
action: 'discard',
|
||||||
|
details: { card: heldCard },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle waiting for flip after discard
|
||||||
|
*/
|
||||||
|
private async handleWaitingForFlip(state: ParsedGameState): Promise<TurnResult> {
|
||||||
|
const myCards = state.myPlayer?.cards || [];
|
||||||
|
|
||||||
|
// Check if flip is optional
|
||||||
|
if (state.canSkipFlip) {
|
||||||
|
if (this.brain.shouldSkipFlip(myCards)) {
|
||||||
|
const result = await this.actions.skipFlip();
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
action: 'skip_flip',
|
||||||
|
error: result.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose a card to flip
|
||||||
|
const pos = this.brain.chooseFlipPosition(myCards);
|
||||||
|
const result = await this.actions.flipCard(pos);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
action: 'flip',
|
||||||
|
details: { position: pos },
|
||||||
|
error: result.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a screenshot with label
|
||||||
|
*/
|
||||||
|
async takeScreenshot(label: string): Promise<Buffer> {
|
||||||
|
const buffer = await this.page.screenshot();
|
||||||
|
this.screenshots.push({ label, buffer });
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all collected screenshots
|
||||||
|
*/
|
||||||
|
getScreenshots(): { label: string; buffer: Buffer }[] {
|
||||||
|
return this.screenshots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get console errors collected
|
||||||
|
*/
|
||||||
|
getConsoleErrors(): string[] {
|
||||||
|
return this.consoleErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear console errors
|
||||||
|
*/
|
||||||
|
clearConsoleErrors(): void {
|
||||||
|
this.consoleErrors = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the UI appears frozen (animation stuck)
|
||||||
|
*/
|
||||||
|
async isFrozen(timeout: number = 3000): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await waitForAnimations(this.page, timeout);
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get turn count
|
||||||
|
*/
|
||||||
|
getTurnCount(): number {
|
||||||
|
return this.turnCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play through initial flip phase completely
|
||||||
|
*/
|
||||||
|
async completeInitialFlips(): Promise<void> {
|
||||||
|
let phase = await this.getGamePhase();
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 10;
|
||||||
|
|
||||||
|
while (phase === 'initial_flip' && attempts < maxAttempts) {
|
||||||
|
if (await this.isMyTurn()) {
|
||||||
|
await this.playTurn();
|
||||||
|
}
|
||||||
|
await this.page.waitForTimeout(500);
|
||||||
|
phase = await this.getGamePhase();
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play through entire round
|
||||||
|
*/
|
||||||
|
async playRound(maxTurns: number = 100): Promise<{ success: boolean; turns: number }> {
|
||||||
|
let turns = 0;
|
||||||
|
|
||||||
|
while (turns < maxTurns) {
|
||||||
|
const phase = await this.getGamePhase();
|
||||||
|
|
||||||
|
if (phase === 'round_over' || phase === 'game_over') {
|
||||||
|
return { success: true, turns };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await this.isMyTurn()) {
|
||||||
|
const result = await this.playTurn();
|
||||||
|
if (!result.success) {
|
||||||
|
console.warn(`Turn ${turns} failed:`, result.error);
|
||||||
|
}
|
||||||
|
turns++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.page.waitForTimeout(200);
|
||||||
|
|
||||||
|
// Check for frozen state
|
||||||
|
if (await this.isFrozen()) {
|
||||||
|
return { success: false, turns };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, turns };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play through entire game (all rounds)
|
||||||
|
*/
|
||||||
|
async playGame(maxRounds: number = 18): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
rounds: number;
|
||||||
|
totalTurns: number;
|
||||||
|
}> {
|
||||||
|
let rounds = 0;
|
||||||
|
let totalTurns = 0;
|
||||||
|
|
||||||
|
while (rounds < maxRounds) {
|
||||||
|
const phase = await this.getGamePhase();
|
||||||
|
|
||||||
|
if (phase === 'game_over') {
|
||||||
|
return { success: true, rounds, totalTurns };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete initial flips first
|
||||||
|
await this.completeInitialFlips();
|
||||||
|
|
||||||
|
// Play the round
|
||||||
|
const roundResult = await this.playRound();
|
||||||
|
totalTurns += roundResult.turns;
|
||||||
|
rounds++;
|
||||||
|
|
||||||
|
if (!roundResult.success) {
|
||||||
|
return { success: false, rounds, totalTurns };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for game over
|
||||||
|
let newPhase = await this.getGamePhase();
|
||||||
|
if (newPhase === 'game_over') {
|
||||||
|
return { success: true, rounds, totalTurns };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this was the final round
|
||||||
|
const state = await this.getGameState();
|
||||||
|
const isLastRound = state.currentRound >= state.totalRounds;
|
||||||
|
|
||||||
|
// If last round just ended, wait for game_over or trigger it
|
||||||
|
if (newPhase === 'round_over' && isLastRound) {
|
||||||
|
// Wait a few seconds for auto-transition or countdown
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await this.page.waitForTimeout(1000);
|
||||||
|
newPhase = await this.getGamePhase();
|
||||||
|
if (newPhase === 'game_over') {
|
||||||
|
return { success: true, rounds, totalTurns };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game might require clicking Next Hole to show Final Results
|
||||||
|
// Try clicking the button to trigger the transition
|
||||||
|
const nextResult = await this.actions.nextRound();
|
||||||
|
|
||||||
|
// Wait for Final Results modal to appear
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await this.page.waitForTimeout(1000);
|
||||||
|
newPhase = await this.getGamePhase();
|
||||||
|
if (newPhase === 'game_over') {
|
||||||
|
return { success: true, rounds, totalTurns };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start next round if available
|
||||||
|
if (newPhase === 'round_over') {
|
||||||
|
await this.page.waitForTimeout(1000);
|
||||||
|
const nextResult = await this.actions.nextRound();
|
||||||
|
if (!nextResult.success) {
|
||||||
|
// Maybe we're not the host, wait for host to start
|
||||||
|
await this.page.waitForTimeout(5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, rounds, totalTurns };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
tests/e2e/bot/index.ts
Normal file
10
tests/e2e/bot/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export { GolfBot, StartGameOptions, TurnResult } from './golf-bot';
|
||||||
|
export { AIBrain, GameOptions, getCardValue, getColumnPartner } from './ai-brain';
|
||||||
|
export { Actions, ActionResult } from './actions';
|
||||||
|
export {
|
||||||
|
StateParser,
|
||||||
|
CardState,
|
||||||
|
PlayerState,
|
||||||
|
ParsedGameState,
|
||||||
|
GamePhase,
|
||||||
|
} from './state-parser';
|
||||||
524
tests/e2e/bot/state-parser.ts
Normal file
524
tests/e2e/bot/state-parser.ts
Normal file
@ -0,0 +1,524 @@
|
|||||||
|
import { Page, Locator } from '@playwright/test';
|
||||||
|
import { SELECTORS } from '../utils/selectors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a card's state as extracted from the DOM
|
||||||
|
*/
|
||||||
|
export interface CardState {
|
||||||
|
position: number;
|
||||||
|
faceUp: boolean;
|
||||||
|
rank: string | null;
|
||||||
|
suit: string | null;
|
||||||
|
clickable: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a player's state as extracted from the DOM
|
||||||
|
*/
|
||||||
|
export interface PlayerState {
|
||||||
|
name: string;
|
||||||
|
cards: CardState[];
|
||||||
|
isCurrentTurn: boolean;
|
||||||
|
score: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the overall game state as extracted from the DOM
|
||||||
|
*/
|
||||||
|
export interface ParsedGameState {
|
||||||
|
phase: GamePhase;
|
||||||
|
currentRound: number;
|
||||||
|
totalRounds: number;
|
||||||
|
statusMessage: string;
|
||||||
|
isFinalTurn: boolean;
|
||||||
|
myPlayer: PlayerState | null;
|
||||||
|
opponents: PlayerState[];
|
||||||
|
deck: {
|
||||||
|
clickable: boolean;
|
||||||
|
};
|
||||||
|
discard: {
|
||||||
|
hasCard: boolean;
|
||||||
|
clickable: boolean;
|
||||||
|
pickedUp: boolean;
|
||||||
|
topCard: { rank: string; suit: string } | null;
|
||||||
|
};
|
||||||
|
heldCard: {
|
||||||
|
visible: boolean;
|
||||||
|
card: { rank: string; suit: string } | null;
|
||||||
|
};
|
||||||
|
canDiscard: boolean;
|
||||||
|
canSkipFlip: boolean;
|
||||||
|
canKnockEarly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GamePhase =
|
||||||
|
| 'lobby'
|
||||||
|
| 'waiting'
|
||||||
|
| 'initial_flip'
|
||||||
|
| 'playing'
|
||||||
|
| 'waiting_for_flip'
|
||||||
|
| 'final_turn'
|
||||||
|
| 'round_over'
|
||||||
|
| 'game_over';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses game state from the DOM
|
||||||
|
* This allows visual validation - the DOM should reflect the internal game state
|
||||||
|
*/
|
||||||
|
export class StateParser {
|
||||||
|
constructor(private page: Page) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current screen/phase
|
||||||
|
*/
|
||||||
|
async getPhase(): Promise<GamePhase> {
|
||||||
|
// Check which screen is active
|
||||||
|
const lobbyVisible = await this.isVisible(SELECTORS.screens.lobby);
|
||||||
|
if (lobbyVisible) return 'lobby';
|
||||||
|
|
||||||
|
const waitingVisible = await this.isVisible(SELECTORS.screens.waiting);
|
||||||
|
if (waitingVisible) return 'waiting';
|
||||||
|
|
||||||
|
const gameVisible = await this.isVisible(SELECTORS.screens.game);
|
||||||
|
if (!gameVisible) return 'lobby';
|
||||||
|
|
||||||
|
// We're in the game screen - determine game phase
|
||||||
|
const statusText = await this.getStatusMessage();
|
||||||
|
const gameButtons = await this.isVisible(SELECTORS.game.gameButtons);
|
||||||
|
|
||||||
|
// Check for game over - Final Results modal or "New Game" button visible
|
||||||
|
const finalResultsModal = this.page.locator('#final-results-modal');
|
||||||
|
if (await finalResultsModal.isVisible().catch(() => false)) {
|
||||||
|
return 'game_over';
|
||||||
|
}
|
||||||
|
const newGameBtn = this.page.locator(SELECTORS.game.newGameBtn);
|
||||||
|
if (await newGameBtn.isVisible().catch(() => false)) {
|
||||||
|
return 'game_over';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for round over (Next Hole button visible)
|
||||||
|
const nextRoundBtn = this.page.locator(SELECTORS.game.nextRoundBtn);
|
||||||
|
if (await nextRoundBtn.isVisible().catch(() => false)) {
|
||||||
|
// Check if this is the last round - if so, might be transitioning to game_over
|
||||||
|
const currentRound = await this.getCurrentRound();
|
||||||
|
const totalRounds = await this.getTotalRounds();
|
||||||
|
|
||||||
|
// If on last round and all cards revealed, this is effectively game_over
|
||||||
|
if (currentRound >= totalRounds) {
|
||||||
|
// Check the button text - if it doesn't mention "Next", might be game over
|
||||||
|
const btnText = await nextRoundBtn.textContent().catch(() => '');
|
||||||
|
if (btnText && !btnText.toLowerCase().includes('next')) {
|
||||||
|
return 'game_over';
|
||||||
|
}
|
||||||
|
// Still round_over but will transition to game_over soon
|
||||||
|
}
|
||||||
|
return 'round_over';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for final turn badge
|
||||||
|
const finalTurnBadge = this.page.locator(SELECTORS.game.finalTurnBadge);
|
||||||
|
if (await finalTurnBadge.isVisible().catch(() => false)) {
|
||||||
|
return 'final_turn';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if waiting for initial flip
|
||||||
|
if (statusText.toLowerCase().includes('flip') &&
|
||||||
|
statusText.toLowerCase().includes('card')) {
|
||||||
|
// Could be initial flip or flip after discard
|
||||||
|
const skipFlipBtn = this.page.locator(SELECTORS.game.skipFlipBtn);
|
||||||
|
if (await skipFlipBtn.isVisible().catch(() => false)) {
|
||||||
|
return 'waiting_for_flip';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're in initial flip phase (multiple cards to flip)
|
||||||
|
const myCards = await this.getMyCards();
|
||||||
|
const faceUpCount = myCards.filter(c => c.faceUp).length;
|
||||||
|
if (faceUpCount < 2) {
|
||||||
|
return 'initial_flip';
|
||||||
|
}
|
||||||
|
return 'waiting_for_flip';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'playing';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full parsed game state
|
||||||
|
*/
|
||||||
|
async getState(): Promise<ParsedGameState> {
|
||||||
|
const phase = await this.getPhase();
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
currentRound: await this.getCurrentRound(),
|
||||||
|
totalRounds: await this.getTotalRounds(),
|
||||||
|
statusMessage: await this.getStatusMessage(),
|
||||||
|
isFinalTurn: await this.isFinalTurn(),
|
||||||
|
myPlayer: await this.getMyPlayer(),
|
||||||
|
opponents: await this.getOpponents(),
|
||||||
|
deck: {
|
||||||
|
clickable: await this.isDeckClickable(),
|
||||||
|
},
|
||||||
|
discard: {
|
||||||
|
hasCard: await this.discardHasCard(),
|
||||||
|
clickable: await this.isDiscardClickable(),
|
||||||
|
pickedUp: await this.isDiscardPickedUp(),
|
||||||
|
topCard: await this.getDiscardTop(),
|
||||||
|
},
|
||||||
|
heldCard: {
|
||||||
|
visible: await this.isHeldCardVisible(),
|
||||||
|
card: await this.getHeldCard(),
|
||||||
|
},
|
||||||
|
canDiscard: await this.isVisible(SELECTORS.game.discardBtn),
|
||||||
|
canSkipFlip: await this.isVisible(SELECTORS.game.skipFlipBtn),
|
||||||
|
canKnockEarly: await this.isVisible(SELECTORS.game.knockEarlyBtn),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current round number
|
||||||
|
*/
|
||||||
|
async getCurrentRound(): Promise<number> {
|
||||||
|
const text = await this.getText(SELECTORS.game.currentRound);
|
||||||
|
return parseInt(text) || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total rounds
|
||||||
|
*/
|
||||||
|
async getTotalRounds(): Promise<number> {
|
||||||
|
const text = await this.getText(SELECTORS.game.totalRounds);
|
||||||
|
return parseInt(text) || 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status message text
|
||||||
|
*/
|
||||||
|
async getStatusMessage(): Promise<string> {
|
||||||
|
return this.getText(SELECTORS.game.statusMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if final turn badge is visible
|
||||||
|
*/
|
||||||
|
async isFinalTurn(): Promise<boolean> {
|
||||||
|
return this.isVisible(SELECTORS.game.finalTurnBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get local player's state
|
||||||
|
*/
|
||||||
|
async getMyPlayer(): Promise<PlayerState | null> {
|
||||||
|
const playerArea = this.page.locator(SELECTORS.game.playerArea).first();
|
||||||
|
if (!await playerArea.isVisible().catch(() => false)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameEl = playerArea.locator('.player-name');
|
||||||
|
const name = await nameEl.textContent().catch(() => 'You') || 'You';
|
||||||
|
|
||||||
|
const scoreEl = playerArea.locator(SELECTORS.game.yourScore);
|
||||||
|
const scoreText = await scoreEl.textContent().catch(() => '0') || '0';
|
||||||
|
const score = parseInt(scoreText) || 0;
|
||||||
|
|
||||||
|
const cards = await this.getMyCards();
|
||||||
|
const isCurrentTurn = await this.isMyTurn();
|
||||||
|
|
||||||
|
return { name, cards, isCurrentTurn, score };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cards for local player
|
||||||
|
*/
|
||||||
|
async getMyCards(): Promise<CardState[]> {
|
||||||
|
const cards: CardState[] = [];
|
||||||
|
const cardContainer = this.page.locator(SELECTORS.game.playerCards);
|
||||||
|
|
||||||
|
const cardEls = cardContainer.locator('.card, .card-slot .card');
|
||||||
|
const count = await cardEls.count();
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.min(count, 6); i++) {
|
||||||
|
const cardEl = cardEls.nth(i);
|
||||||
|
cards.push(await this.parseCard(cardEl, i));
|
||||||
|
}
|
||||||
|
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get opponent players' states
|
||||||
|
*/
|
||||||
|
async getOpponents(): Promise<PlayerState[]> {
|
||||||
|
const opponents: PlayerState[] = [];
|
||||||
|
const opponentAreas = this.page.locator('.opponent-area');
|
||||||
|
const count = await opponentAreas.count();
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const area = opponentAreas.nth(i);
|
||||||
|
const nameEl = area.locator('.opponent-name');
|
||||||
|
const name = await nameEl.textContent().catch(() => `Opponent ${i + 1}`) || `Opponent ${i + 1}`;
|
||||||
|
|
||||||
|
const scoreEl = area.locator('.opponent-showing');
|
||||||
|
const scoreText = await scoreEl.textContent().catch(() => null);
|
||||||
|
const score = scoreText ? parseInt(scoreText) : null;
|
||||||
|
|
||||||
|
const isCurrentTurn = await area.evaluate(el =>
|
||||||
|
el.classList.contains('current-turn')
|
||||||
|
);
|
||||||
|
|
||||||
|
const cards: CardState[] = [];
|
||||||
|
const cardEls = area.locator('.card-grid .card');
|
||||||
|
const cardCount = await cardEls.count();
|
||||||
|
|
||||||
|
for (let j = 0; j < Math.min(cardCount, 6); j++) {
|
||||||
|
cards.push(await this.parseCard(cardEls.nth(j), j));
|
||||||
|
}
|
||||||
|
|
||||||
|
opponents.push({ name, cards, isCurrentTurn, score });
|
||||||
|
}
|
||||||
|
|
||||||
|
return opponents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single card element
|
||||||
|
*/
|
||||||
|
private async parseCard(cardEl: Locator, position: number): Promise<CardState> {
|
||||||
|
const classList = await cardEl.evaluate(el => Array.from(el.classList));
|
||||||
|
|
||||||
|
// Face-down cards have 'card-back' class, face-up have 'card-front' class
|
||||||
|
const faceUp = classList.includes('card-front');
|
||||||
|
const clickable = classList.includes('clickable');
|
||||||
|
const selected = classList.includes('selected');
|
||||||
|
|
||||||
|
let rank: string | null = null;
|
||||||
|
let suit: string | null = null;
|
||||||
|
|
||||||
|
if (faceUp) {
|
||||||
|
const content = await cardEl.textContent().catch(() => '') || '';
|
||||||
|
|
||||||
|
// Check for joker
|
||||||
|
if (classList.includes('joker') || content.toLowerCase().includes('joker')) {
|
||||||
|
rank = '★';
|
||||||
|
// Determine suit from icon
|
||||||
|
if (content.includes('🐉')) {
|
||||||
|
suit = 'hearts';
|
||||||
|
} else if (content.includes('👹')) {
|
||||||
|
suit = 'spades';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Parse rank and suit from text
|
||||||
|
const lines = content.split('\n').map(l => l.trim()).filter(l => l);
|
||||||
|
if (lines.length >= 2) {
|
||||||
|
rank = lines[0];
|
||||||
|
suit = this.parseSuitSymbol(lines[1]);
|
||||||
|
} else if (lines.length === 1) {
|
||||||
|
// Try to extract rank from combined text
|
||||||
|
const text = lines[0];
|
||||||
|
const rankMatch = text.match(/^([AKQJ]|10|[2-9])/);
|
||||||
|
if (rankMatch) {
|
||||||
|
rank = rankMatch[1];
|
||||||
|
const suitPart = text.slice(rank.length);
|
||||||
|
suit = this.parseSuitSymbol(suitPart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { position, faceUp, rank, suit, clickable, selected };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse suit symbol to suit name
|
||||||
|
*/
|
||||||
|
private parseSuitSymbol(symbol: string): string | null {
|
||||||
|
const cleaned = symbol.trim();
|
||||||
|
if (cleaned.includes('♥') || cleaned.includes('hearts')) return 'hearts';
|
||||||
|
if (cleaned.includes('♦') || cleaned.includes('diamonds')) return 'diamonds';
|
||||||
|
if (cleaned.includes('♣') || cleaned.includes('clubs')) return 'clubs';
|
||||||
|
if (cleaned.includes('♠') || cleaned.includes('spades')) return 'spades';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if it's the local player's turn
|
||||||
|
*/
|
||||||
|
async isMyTurn(): Promise<boolean> {
|
||||||
|
// Check if deck area has your-turn-to-draw class
|
||||||
|
const deckArea = this.page.locator(SELECTORS.game.deckArea);
|
||||||
|
const hasClass = await deckArea.evaluate(el =>
|
||||||
|
el.classList.contains('your-turn-to-draw')
|
||||||
|
).catch(() => false);
|
||||||
|
|
||||||
|
if (hasClass) return true;
|
||||||
|
|
||||||
|
// Check status message
|
||||||
|
const status = await this.getStatusMessage();
|
||||||
|
const statusLower = status.toLowerCase();
|
||||||
|
|
||||||
|
// Various indicators that it's our turn
|
||||||
|
if (statusLower.includes('your turn')) return true;
|
||||||
|
if (statusLower.includes('select') && statusLower.includes('card')) return true; // Initial flip
|
||||||
|
if (statusLower.includes('flip a card')) return true;
|
||||||
|
if (statusLower.includes('choose a card')) return true;
|
||||||
|
|
||||||
|
// Check if our cards are clickable (another indicator)
|
||||||
|
const clickableCards = await this.getClickablePositions();
|
||||||
|
if (clickableCards.length > 0) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if deck is clickable
|
||||||
|
*/
|
||||||
|
async isDeckClickable(): Promise<boolean> {
|
||||||
|
const deck = this.page.locator(SELECTORS.game.deck);
|
||||||
|
return deck.evaluate(el => el.classList.contains('clickable')).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if discard pile has a card
|
||||||
|
*/
|
||||||
|
async discardHasCard(): Promise<boolean> {
|
||||||
|
const discard = this.page.locator(SELECTORS.game.discard);
|
||||||
|
return discard.evaluate(el =>
|
||||||
|
el.classList.contains('has-card') || el.classList.contains('card-front')
|
||||||
|
).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if discard is clickable
|
||||||
|
*/
|
||||||
|
async isDiscardClickable(): Promise<boolean> {
|
||||||
|
const discard = this.page.locator(SELECTORS.game.discard);
|
||||||
|
return discard.evaluate(el =>
|
||||||
|
el.classList.contains('clickable') && !el.classList.contains('disabled')
|
||||||
|
).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if discard card is picked up (floating)
|
||||||
|
*/
|
||||||
|
async isDiscardPickedUp(): Promise<boolean> {
|
||||||
|
const discard = this.page.locator(SELECTORS.game.discard);
|
||||||
|
return discard.evaluate(el => el.classList.contains('picked-up')).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the top card of the discard pile
|
||||||
|
*/
|
||||||
|
async getDiscardTop(): Promise<{ rank: string; suit: string } | null> {
|
||||||
|
const hasCard = await this.discardHasCard();
|
||||||
|
if (!hasCard) return null;
|
||||||
|
|
||||||
|
const content = await this.page.locator(SELECTORS.game.discardContent).textContent()
|
||||||
|
.catch(() => null);
|
||||||
|
if (!content) return null;
|
||||||
|
|
||||||
|
return this.parseCardContent(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if held card is visible
|
||||||
|
*/
|
||||||
|
async isHeldCardVisible(): Promise<boolean> {
|
||||||
|
const floating = this.page.locator(SELECTORS.game.heldCardFloating);
|
||||||
|
return floating.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get held card details
|
||||||
|
*/
|
||||||
|
async getHeldCard(): Promise<{ rank: string; suit: string } | null> {
|
||||||
|
const visible = await this.isHeldCardVisible();
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const content = await this.page.locator(SELECTORS.game.heldCardFloatingContent)
|
||||||
|
.textContent().catch(() => null);
|
||||||
|
if (!content) return null;
|
||||||
|
|
||||||
|
return this.parseCardContent(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse card content text (from held card, discard, etc.)
|
||||||
|
*/
|
||||||
|
private parseCardContent(content: string): { rank: string; suit: string } | null {
|
||||||
|
// Handle jokers
|
||||||
|
if (content.toLowerCase().includes('joker')) {
|
||||||
|
const suit = content.includes('🐉') ? 'hearts' : 'spades';
|
||||||
|
return { rank: '★', suit };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse rank and suit
|
||||||
|
// Content may be "7\n♥" (with newline) or "7♥" (combined)
|
||||||
|
const lines = content.split('\n').map(l => l.trim()).filter(l => l);
|
||||||
|
|
||||||
|
if (lines.length >= 2) {
|
||||||
|
// Two separate lines
|
||||||
|
return {
|
||||||
|
rank: lines[0],
|
||||||
|
suit: this.parseSuitSymbol(lines[1]) || 'unknown',
|
||||||
|
};
|
||||||
|
} else if (lines.length === 1) {
|
||||||
|
const text = lines[0];
|
||||||
|
// Try to extract rank (A, K, Q, J, 10, or 2-9)
|
||||||
|
const rankMatch = text.match(/^(10|[AKQJ2-9])/);
|
||||||
|
if (rankMatch) {
|
||||||
|
const rank = rankMatch[1];
|
||||||
|
const suitPart = text.slice(rank.length);
|
||||||
|
const suit = this.parseSuitSymbol(suitPart);
|
||||||
|
if (suit) {
|
||||||
|
return { rank, suit };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count face-up cards for local player
|
||||||
|
*/
|
||||||
|
async countFaceUpCards(): Promise<number> {
|
||||||
|
const cards = await this.getMyCards();
|
||||||
|
return cards.filter(c => c.faceUp).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count face-down cards for local player
|
||||||
|
*/
|
||||||
|
async countFaceDownCards(): Promise<number> {
|
||||||
|
const cards = await this.getMyCards();
|
||||||
|
return cards.filter(c => !c.faceUp).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get positions of clickable cards
|
||||||
|
*/
|
||||||
|
async getClickablePositions(): Promise<number[]> {
|
||||||
|
const cards = await this.getMyCards();
|
||||||
|
return cards.filter(c => c.clickable).map(c => c.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get positions of face-down cards
|
||||||
|
*/
|
||||||
|
async getFaceDownPositions(): Promise<number[]> {
|
||||||
|
const cards = await this.getMyCards();
|
||||||
|
return cards.filter(c => !c.faceUp).map(c => c.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
private async isVisible(selector: string): Promise<boolean> {
|
||||||
|
const el = this.page.locator(selector);
|
||||||
|
return el.isVisible().catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getText(selector: string): Promise<string> {
|
||||||
|
const el = this.page.locator(selector);
|
||||||
|
return (await el.textContent().catch(() => '')) || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
231
tests/e2e/health/animation-tracker.ts
Normal file
231
tests/e2e/health/animation-tracker.ts
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* Animation tracker - monitors animation completion and timing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Page } from '@playwright/test';
|
||||||
|
import { TIMING } from '../utils/timing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animation event
|
||||||
|
*/
|
||||||
|
export interface AnimationEvent {
|
||||||
|
type: 'start' | 'complete' | 'stall';
|
||||||
|
animationType?: string;
|
||||||
|
duration?: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AnimationTracker - tracks animation states
|
||||||
|
*/
|
||||||
|
export class AnimationTracker {
|
||||||
|
private events: AnimationEvent[] = [];
|
||||||
|
private animationStartTime: number | null = null;
|
||||||
|
|
||||||
|
constructor(private page: Page) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record animation start
|
||||||
|
*/
|
||||||
|
recordStart(type?: string): void {
|
||||||
|
this.animationStartTime = Date.now();
|
||||||
|
this.events.push({
|
||||||
|
type: 'start',
|
||||||
|
animationType: type,
|
||||||
|
timestamp: this.animationStartTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record animation complete
|
||||||
|
*/
|
||||||
|
recordComplete(type?: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const duration = this.animationStartTime
|
||||||
|
? now - this.animationStartTime
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
this.events.push({
|
||||||
|
type: 'complete',
|
||||||
|
animationType: type,
|
||||||
|
duration,
|
||||||
|
timestamp: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.animationStartTime = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record animation stall
|
||||||
|
*/
|
||||||
|
recordStall(type?: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const duration = this.animationStartTime
|
||||||
|
? now - this.animationStartTime
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
this.events.push({
|
||||||
|
type: 'stall',
|
||||||
|
animationType: type,
|
||||||
|
duration,
|
||||||
|
timestamp: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if animation queue is animating
|
||||||
|
*/
|
||||||
|
async isAnimating(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await this.page.evaluate(() => {
|
||||||
|
const game = (window as any).game;
|
||||||
|
return game?.animationQueue?.isAnimating() ?? false;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get animation queue length
|
||||||
|
*/
|
||||||
|
async getQueueLength(): Promise<number> {
|
||||||
|
try {
|
||||||
|
return await this.page.evaluate(() => {
|
||||||
|
const game = (window as any).game;
|
||||||
|
return game?.animationQueue?.queue?.length ?? 0;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for animation to complete with tracking
|
||||||
|
*/
|
||||||
|
async waitForAnimation(
|
||||||
|
type: string,
|
||||||
|
timeoutMs: number = 5000
|
||||||
|
): Promise<{ completed: boolean; duration: number }> {
|
||||||
|
this.recordStart(type);
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const game = (window as any).game;
|
||||||
|
if (!game?.animationQueue) return true;
|
||||||
|
return !game.animationQueue.isAnimating();
|
||||||
|
},
|
||||||
|
{ timeout: timeoutMs }
|
||||||
|
);
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.recordComplete(type);
|
||||||
|
return { completed: true, duration };
|
||||||
|
} catch {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.recordStall(type);
|
||||||
|
return { completed: false, duration };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for specific animation type by watching DOM changes
|
||||||
|
*/
|
||||||
|
async waitForFlipAnimation(timeoutMs: number = 2000): Promise<boolean> {
|
||||||
|
return this.waitForAnimationClass('flipping', timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForSwapAnimation(timeoutMs: number = 3000): Promise<boolean> {
|
||||||
|
return this.waitForAnimationClass('swap-animation', timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for animation class to appear and disappear
|
||||||
|
*/
|
||||||
|
private async waitForAnimationClass(
|
||||||
|
className: string,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Wait for class to appear
|
||||||
|
await this.page.waitForSelector(`.${className}`, {
|
||||||
|
state: 'attached',
|
||||||
|
timeout: timeoutMs / 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for class to disappear (animation complete)
|
||||||
|
await this.page.waitForSelector(`.${className}`, {
|
||||||
|
state: 'detached',
|
||||||
|
timeout: timeoutMs / 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get animation events
|
||||||
|
*/
|
||||||
|
getEvents(): AnimationEvent[] {
|
||||||
|
return [...this.events];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stall events
|
||||||
|
*/
|
||||||
|
getStalls(): AnimationEvent[] {
|
||||||
|
return this.events.filter(e => e.type === 'stall');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get average animation duration by type
|
||||||
|
*/
|
||||||
|
getAverageDuration(type?: string): number | null {
|
||||||
|
const completed = this.events.filter(e =>
|
||||||
|
e.type === 'complete' &&
|
||||||
|
e.duration !== undefined &&
|
||||||
|
(!type || e.animationType === type)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (completed.length === 0) return null;
|
||||||
|
|
||||||
|
const total = completed.reduce((sum, e) => sum + (e.duration || 0), 0);
|
||||||
|
return total / completed.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if animations are within expected timing
|
||||||
|
*/
|
||||||
|
validateTiming(
|
||||||
|
type: string,
|
||||||
|
expectedMs: number,
|
||||||
|
tolerancePercent: number = 50
|
||||||
|
): { valid: boolean; actual: number | null } {
|
||||||
|
const avgDuration = this.getAverageDuration(type);
|
||||||
|
|
||||||
|
if (avgDuration === null) {
|
||||||
|
return { valid: true, actual: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tolerance = expectedMs * (tolerancePercent / 100);
|
||||||
|
const minOk = expectedMs - tolerance;
|
||||||
|
const maxOk = expectedMs + tolerance;
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: avgDuration >= minOk && avgDuration <= maxOk,
|
||||||
|
actual: avgDuration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear tracked events
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.events = [];
|
||||||
|
this.animationStartTime = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
209
tests/e2e/health/freeze-detector.ts
Normal file
209
tests/e2e/health/freeze-detector.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* Freeze detector - monitors for UI responsiveness issues
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Page } from '@playwright/test';
|
||||||
|
import { SELECTORS } from '../utils/selectors';
|
||||||
|
import { TIMING } from '../utils/timing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check result
|
||||||
|
*/
|
||||||
|
export interface HealthCheck {
|
||||||
|
healthy: boolean;
|
||||||
|
issues: HealthIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthIssue {
|
||||||
|
type: 'animation_stall' | 'websocket_closed' | 'console_error' | 'unresponsive';
|
||||||
|
message: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FreezeDetector - monitors UI health
|
||||||
|
*/
|
||||||
|
export class FreezeDetector {
|
||||||
|
private issues: HealthIssue[] = [];
|
||||||
|
private consoleErrors: string[] = [];
|
||||||
|
private wsState: number | null = null;
|
||||||
|
|
||||||
|
constructor(private page: Page) {
|
||||||
|
// Monitor console errors
|
||||||
|
page.on('console', msg => {
|
||||||
|
if (msg.type() === 'error') {
|
||||||
|
const text = msg.text();
|
||||||
|
this.consoleErrors.push(text);
|
||||||
|
this.addIssue('console_error', text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
page.on('pageerror', err => {
|
||||||
|
this.consoleErrors.push(err.message);
|
||||||
|
this.addIssue('console_error', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a health issue
|
||||||
|
*/
|
||||||
|
private addIssue(type: HealthIssue['type'], message: string): void {
|
||||||
|
this.issues.push({
|
||||||
|
type,
|
||||||
|
message,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear recorded issues
|
||||||
|
*/
|
||||||
|
clearIssues(): void {
|
||||||
|
this.issues = [];
|
||||||
|
this.consoleErrors = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all recorded issues
|
||||||
|
*/
|
||||||
|
getIssues(): HealthIssue[] {
|
||||||
|
return [...this.issues];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent issues (within timeframe)
|
||||||
|
*/
|
||||||
|
getRecentIssues(withinMs: number = 10000): HealthIssue[] {
|
||||||
|
const cutoff = Date.now() - withinMs;
|
||||||
|
return this.issues.filter(i => i.timestamp > cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for animation stall
|
||||||
|
*/
|
||||||
|
async checkAnimationStall(timeoutMs: number = 5000): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const game = (window as any).game;
|
||||||
|
if (!game?.animationQueue) return true;
|
||||||
|
return !game.animationQueue.isAnimating();
|
||||||
|
},
|
||||||
|
{ timeout: timeoutMs }
|
||||||
|
);
|
||||||
|
return false; // No stall
|
||||||
|
} catch {
|
||||||
|
this.addIssue('animation_stall', `Animation did not complete within ${timeoutMs}ms`);
|
||||||
|
return true; // Stalled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check WebSocket health
|
||||||
|
*/
|
||||||
|
async checkWebSocket(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const state = await this.page.evaluate(() => {
|
||||||
|
const game = (window as any).game;
|
||||||
|
return game?.ws?.readyState;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.wsState = state;
|
||||||
|
|
||||||
|
// WebSocket.OPEN = 1
|
||||||
|
if (state !== 1) {
|
||||||
|
const stateNames: Record<number, string> = {
|
||||||
|
0: 'CONNECTING',
|
||||||
|
1: 'OPEN',
|
||||||
|
2: 'CLOSING',
|
||||||
|
3: 'CLOSED',
|
||||||
|
};
|
||||||
|
this.addIssue('websocket_closed', `WebSocket is ${stateNames[state] || 'UNKNOWN'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.addIssue('websocket_closed', `Failed to check WebSocket: ${error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if element is responsive to clicks
|
||||||
|
*/
|
||||||
|
async checkClickResponsiveness(
|
||||||
|
selector: string,
|
||||||
|
timeoutMs: number = 2000
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const el = this.page.locator(selector);
|
||||||
|
if (!await el.isVisible()) {
|
||||||
|
return true; // Element not visible is not necessarily an issue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if element is clickable
|
||||||
|
await el.click({ timeout: timeoutMs, trial: true });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
this.addIssue('unresponsive', `Element ${selector} not responsive`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run full health check
|
||||||
|
*/
|
||||||
|
async runHealthCheck(): Promise<HealthCheck> {
|
||||||
|
const animationOk = !(await this.checkAnimationStall());
|
||||||
|
const wsOk = await this.checkWebSocket();
|
||||||
|
|
||||||
|
const healthy = animationOk && wsOk && this.consoleErrors.length === 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
healthy,
|
||||||
|
issues: this.getRecentIssues(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monitor game loop for issues
|
||||||
|
* Returns when an issue is detected or timeout
|
||||||
|
*/
|
||||||
|
async monitorUntilIssue(
|
||||||
|
timeoutMs: number = 60000,
|
||||||
|
checkIntervalMs: number = 500
|
||||||
|
): Promise<HealthIssue | null> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeoutMs) {
|
||||||
|
// Check animation
|
||||||
|
const animStall = await this.checkAnimationStall(3000);
|
||||||
|
if (animStall) {
|
||||||
|
return this.issues[this.issues.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check WebSocket
|
||||||
|
const wsOk = await this.checkWebSocket();
|
||||||
|
if (!wsOk) {
|
||||||
|
return this.issues[this.issues.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for new console errors
|
||||||
|
if (this.consoleErrors.length > 0) {
|
||||||
|
return this.issues[this.issues.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.page.waitForTimeout(checkIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get console errors
|
||||||
|
*/
|
||||||
|
getConsoleErrors(): string[] {
|
||||||
|
return [...this.consoleErrors];
|
||||||
|
}
|
||||||
|
}
|
||||||
2
tests/e2e/health/index.ts
Normal file
2
tests/e2e/health/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { FreezeDetector, HealthCheck, HealthIssue } from './freeze-detector';
|
||||||
|
export { AnimationTracker, AnimationEvent } from './animation-tracker';
|
||||||
111
tests/e2e/package-lock.json
generated
Normal file
111
tests/e2e/package-lock.json
generated
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
{
|
||||||
|
"name": "golf-e2e-tests",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "golf-e2e-tests",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.40.0",
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"typescript": "^5.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.58.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz",
|
||||||
|
"integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.58.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "20.19.30",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz",
|
||||||
|
"integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.58.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
|
||||||
|
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.58.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.58.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
|
||||||
|
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
tests/e2e/package.json
Normal file
22
tests/e2e/package.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "golf-e2e-tests",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "End-to-end tests for Golf Card Game",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:headed": "playwright test --headed",
|
||||||
|
"test:debug": "playwright test --debug",
|
||||||
|
"test:ui": "playwright test --ui",
|
||||||
|
"test:full-game": "playwright test specs/full-game.spec.ts",
|
||||||
|
"test:visual": "playwright test specs/visual.spec.ts",
|
||||||
|
"test:stress": "playwright test specs/stress.spec.ts",
|
||||||
|
"test:report": "playwright show-report",
|
||||||
|
"install:browsers": "playwright install chromium"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.40.0",
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"typescript": "^5.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
36
tests/e2e/playwright.config.ts
Normal file
36
tests/e2e/playwright.config.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './specs',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: [
|
||||||
|
['html', { outputFolder: 'playwright-report' }],
|
||||||
|
['list'],
|
||||||
|
],
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.TEST_URL || 'http://localhost:8000',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'retain-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mobile',
|
||||||
|
use: { ...devices['iPhone 13'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tablet',
|
||||||
|
use: { ...devices['iPad Pro 11'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Note: The server must be running before tests
|
||||||
|
// Start it with: cd ../.. && python server/main.py
|
||||||
|
// webServer config removed because it requires PostgreSQL which may not be available
|
||||||
|
});
|
||||||
253
tests/e2e/specs/full-game.spec.ts
Normal file
253
tests/e2e/specs/full-game.spec.ts
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* Full game playthrough tests
|
||||||
|
* Tests complete game sessions with the bot
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { GolfBot } from '../bot/golf-bot';
|
||||||
|
import { FreezeDetector } from '../health/freeze-detector';
|
||||||
|
import { ScreenshotValidator } from '../visual/screenshot-validator';
|
||||||
|
|
||||||
|
test.describe('Full Game Playthrough', () => {
|
||||||
|
test('bot completes 3-hole game against CPU', async ({ page }) => {
|
||||||
|
test.setTimeout(180000); // 3 minutes for 3-hole game
|
||||||
|
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const freezeDetector = new FreezeDetector(page);
|
||||||
|
const validator = new ScreenshotValidator(page);
|
||||||
|
|
||||||
|
// Navigate to game
|
||||||
|
await bot.goto();
|
||||||
|
|
||||||
|
// Create game and add CPU
|
||||||
|
const roomCode = await bot.createGame('TestBot');
|
||||||
|
expect(roomCode).toHaveLength(4);
|
||||||
|
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
|
||||||
|
// Take screenshot of waiting room
|
||||||
|
await validator.capture('waiting-room');
|
||||||
|
|
||||||
|
// Start game with 3 holes
|
||||||
|
await bot.startGame({ holes: 3 });
|
||||||
|
|
||||||
|
// Verify game started
|
||||||
|
const phase = await bot.getGamePhase();
|
||||||
|
expect(['initial_flip', 'playing']).toContain(phase);
|
||||||
|
|
||||||
|
// Take screenshot of game start
|
||||||
|
await validator.capture('game-start', phase);
|
||||||
|
|
||||||
|
// Play through the entire game
|
||||||
|
const result = await bot.playGame(3);
|
||||||
|
|
||||||
|
// Take final screenshot
|
||||||
|
await validator.capture('game-over', 'game_over');
|
||||||
|
|
||||||
|
// Verify game completed
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.rounds).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Check for errors
|
||||||
|
const errors = bot.getConsoleErrors();
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
|
||||||
|
// Verify no freezes occurred
|
||||||
|
const health = await freezeDetector.runHealthCheck();
|
||||||
|
expect(health.healthy).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bot completes 9-hole game against CPU', async ({ page }) => {
|
||||||
|
test.setTimeout(900000); // 15 minutes for 9-hole game
|
||||||
|
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const freezeDetector = new FreezeDetector(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
|
||||||
|
const roomCode = await bot.createGame('TestBot');
|
||||||
|
await bot.addCPU('Marcus');
|
||||||
|
|
||||||
|
await bot.startGame({ holes: 9 });
|
||||||
|
|
||||||
|
// Play full game
|
||||||
|
const result = await bot.playGame(9);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.rounds).toBe(9);
|
||||||
|
|
||||||
|
// Verify game ended properly
|
||||||
|
const finalPhase = await bot.getGamePhase();
|
||||||
|
expect(finalPhase).toBe('game_over');
|
||||||
|
|
||||||
|
// Check health
|
||||||
|
const health = await freezeDetector.runHealthCheck();
|
||||||
|
expect(health.healthy).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bot handles initial flip phase correctly', async ({ page }) => {
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const validator = new ScreenshotValidator(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('TestBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.startGame({ holes: 1, initialFlips: 2 });
|
||||||
|
|
||||||
|
// Wait for initial flip phase
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Take screenshot before flips
|
||||||
|
await validator.capture('before-initial-flip');
|
||||||
|
|
||||||
|
// Complete initial flips
|
||||||
|
await bot.completeInitialFlips();
|
||||||
|
|
||||||
|
// Take screenshot after flips
|
||||||
|
await validator.capture('after-initial-flip');
|
||||||
|
|
||||||
|
// Verify 2 cards are face-up
|
||||||
|
const state = await bot.getGameState();
|
||||||
|
const faceUpCount = state.myPlayer?.cards.filter(c => c.faceUp).length || 0;
|
||||||
|
expect(faceUpCount).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bot recovers from rapid turn changes', async ({ page }) => {
|
||||||
|
test.setTimeout(90000); // 90 seconds
|
||||||
|
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('TestBot');
|
||||||
|
|
||||||
|
// Add multiple fast CPUs
|
||||||
|
await bot.addCPU('Maya'); // Aggressive
|
||||||
|
await bot.addCPU('Sage'); // Sneaky finisher
|
||||||
|
|
||||||
|
await bot.startGame({ holes: 1 });
|
||||||
|
|
||||||
|
// Play with health monitoring
|
||||||
|
let frozenCount = 0;
|
||||||
|
let turnCount = 0;
|
||||||
|
|
||||||
|
while (await bot.getGamePhase() !== 'round_over' && turnCount < 50) {
|
||||||
|
if (await bot.isMyTurn()) {
|
||||||
|
const result = await bot.playTurn();
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
turnCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for freeze
|
||||||
|
if (await bot.isFrozen(2000)) {
|
||||||
|
frozenCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not have frozen
|
||||||
|
expect(frozenCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('game handles all players finishing', async ({ page }) => {
|
||||||
|
test.setTimeout(90000); // 90 seconds for single round
|
||||||
|
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const validator = new ScreenshotValidator(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('TestBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.startGame({ holes: 1 });
|
||||||
|
|
||||||
|
// Play until round over
|
||||||
|
const roundResult = await bot.playRound(100);
|
||||||
|
expect(roundResult.success).toBe(true);
|
||||||
|
|
||||||
|
// Take screenshot of round end
|
||||||
|
await validator.capture('round-end');
|
||||||
|
|
||||||
|
// Verify all player cards are revealed
|
||||||
|
const state = await bot.getGameState();
|
||||||
|
const allRevealed = state.myPlayer?.cards.every(c => c.faceUp) ?? false;
|
||||||
|
expect(allRevealed).toBe(true);
|
||||||
|
|
||||||
|
// Verify scoreboard is visible
|
||||||
|
const scoreboardVisible = await validator.expectVisible('#game-buttons');
|
||||||
|
expect(scoreboardVisible.passed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Game Settings', () => {
|
||||||
|
test('Speed Golf mode (flip on discard)', async ({ page }) => {
|
||||||
|
test.setTimeout(90000); // 90 seconds
|
||||||
|
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('TestBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
|
||||||
|
// Start with Speed Golf (always flip)
|
||||||
|
await bot.startGame({
|
||||||
|
holes: 1,
|
||||||
|
flipMode: 'always',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Play through
|
||||||
|
const result = await bot.playRound(50);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
// No errors should occur
|
||||||
|
expect(bot.getConsoleErrors()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Endgame mode (optional flip)', async ({ page }) => {
|
||||||
|
test.setTimeout(90000); // 90 seconds
|
||||||
|
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('TestBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
|
||||||
|
// Start with Endgame mode
|
||||||
|
await bot.startGame({
|
||||||
|
holes: 1,
|
||||||
|
flipMode: 'endgame',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Play through
|
||||||
|
const result = await bot.playRound(50);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
expect(bot.getConsoleErrors()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Multiple decks with many players', async ({ page }) => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('TestBot');
|
||||||
|
|
||||||
|
// Add 4 CPUs (5 total players)
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.addCPU('Marcus');
|
||||||
|
await bot.addCPU('Maya');
|
||||||
|
await bot.addCPU('Kenji');
|
||||||
|
|
||||||
|
// Start with 2 decks
|
||||||
|
await bot.startGame({
|
||||||
|
holes: 1,
|
||||||
|
decks: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Play through
|
||||||
|
const result = await bot.playRound(100);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
expect(bot.getConsoleErrors()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
401
tests/e2e/specs/stress.spec.ts
Normal file
401
tests/e2e/specs/stress.spec.ts
Normal file
@ -0,0 +1,401 @@
|
|||||||
|
/**
|
||||||
|
* Stress tests
|
||||||
|
* Tests for race conditions, memory leaks, and edge cases
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { GolfBot } from '../bot/golf-bot';
|
||||||
|
import { FreezeDetector } from '../health/freeze-detector';
|
||||||
|
import { AnimationTracker } from '../health/animation-tracker';
|
||||||
|
|
||||||
|
test.describe('Stress Tests', () => {
|
||||||
|
test('rapid action sequence (race condition detection)', async ({ page }) => {
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const freezeDetector = new FreezeDetector(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('StressBot');
|
||||||
|
await bot.addCPU('Maya'); // Aggressive, fast player
|
||||||
|
await bot.startGame({ holes: 1 });
|
||||||
|
|
||||||
|
await bot.completeInitialFlips();
|
||||||
|
|
||||||
|
let actionCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
// Rapid turns with minimal delays
|
||||||
|
while (await bot.getGamePhase() !== 'round_over' && actionCount < 100) {
|
||||||
|
if (await bot.isMyTurn()) {
|
||||||
|
// Reduce normal waits
|
||||||
|
const state = await bot.getGameState();
|
||||||
|
|
||||||
|
if (!state.heldCard.visible) {
|
||||||
|
// Quick draw
|
||||||
|
const deck = page.locator('#deck');
|
||||||
|
await deck.click({ timeout: 1000 }).catch(() => { errorCount++; });
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
} else {
|
||||||
|
// Quick swap or discard
|
||||||
|
const faceDown = state.myPlayer?.cards.find(c => !c.faceUp);
|
||||||
|
if (faceDown) {
|
||||||
|
const card = page.locator(`#player-cards .card:nth-child(${faceDown.position + 1})`);
|
||||||
|
await card.click({ timeout: 1000 }).catch(() => { errorCount++; });
|
||||||
|
} else {
|
||||||
|
const discardBtn = page.locator('#discard-btn');
|
||||||
|
await discardBtn.click({ timeout: 1000 }).catch(() => { errorCount++; });
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
actionCount++;
|
||||||
|
} else {
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for freezes
|
||||||
|
if (await bot.isFrozen(2000)) {
|
||||||
|
console.warn(`Freeze detected at action ${actionCount}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no critical errors
|
||||||
|
const health = await freezeDetector.runHealthCheck();
|
||||||
|
expect(health.issues.filter(i => i.type === 'websocket_closed')).toHaveLength(0);
|
||||||
|
|
||||||
|
// Some click errors are acceptable (timing issues), but not too many
|
||||||
|
expect(errorCount).toBeLessThan(10);
|
||||||
|
|
||||||
|
console.log(`Completed ${actionCount} rapid actions with ${errorCount} minor errors`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple games in succession (memory leak detection)', async ({ page }) => {
|
||||||
|
test.setTimeout(300000); // 5 minutes
|
||||||
|
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const gamesCompleted: number[] = [];
|
||||||
|
|
||||||
|
// Get initial memory if available
|
||||||
|
const getMemory = async () => {
|
||||||
|
try {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
if ('memory' in performance) {
|
||||||
|
return (performance as any).memory.usedJSHeapSize;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialMemory = await getMemory();
|
||||||
|
console.log(`Initial memory: ${initialMemory ? Math.round(initialMemory / 1024 / 1024) + 'MB' : 'N/A'}`);
|
||||||
|
|
||||||
|
// Play 10 quick games
|
||||||
|
for (let game = 0; game < 10; game++) {
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame(`MemBot${game}`);
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.startGame({ holes: 1 });
|
||||||
|
|
||||||
|
const result = await bot.playRound(50);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
gamesCompleted.push(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check memory every few games
|
||||||
|
if (game % 3 === 2) {
|
||||||
|
const currentMemory = await getMemory();
|
||||||
|
if (currentMemory) {
|
||||||
|
console.log(`Game ${game + 1}: memory = ${Math.round(currentMemory / 1024 / 1024)}MB`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any accumulated errors
|
||||||
|
bot.clearConsoleErrors();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should complete most games
|
||||||
|
expect(gamesCompleted.length).toBeGreaterThanOrEqual(8);
|
||||||
|
|
||||||
|
// Final memory check
|
||||||
|
const finalMemory = await getMemory();
|
||||||
|
if (initialMemory && finalMemory) {
|
||||||
|
const memoryGrowth = finalMemory - initialMemory;
|
||||||
|
console.log(`Memory growth: ${Math.round(memoryGrowth / 1024 / 1024)}MB`);
|
||||||
|
|
||||||
|
// Memory shouldn't grow excessively (allow 50MB growth for 10 games)
|
||||||
|
expect(memoryGrowth).toBeLessThan(50 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('6-player game with 5 CPUs (max players)', async ({ page }) => {
|
||||||
|
test.setTimeout(180000); // 3 minutes
|
||||||
|
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const freezeDetector = new FreezeDetector(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('TestBot');
|
||||||
|
|
||||||
|
// Add 5 CPU players (max typical setup)
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.addCPU('Maya');
|
||||||
|
await bot.addCPU('Priya');
|
||||||
|
await bot.addCPU('Marcus');
|
||||||
|
await bot.addCPU('Kenji');
|
||||||
|
|
||||||
|
// Start with 2 decks (recommended for 6 players)
|
||||||
|
await bot.startGame({
|
||||||
|
holes: 3,
|
||||||
|
decks: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Play through all rounds
|
||||||
|
const result = await bot.playGame(3);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.rounds).toBe(3);
|
||||||
|
|
||||||
|
// Check for issues
|
||||||
|
const health = await freezeDetector.runHealthCheck();
|
||||||
|
expect(health.healthy).toBe(true);
|
||||||
|
|
||||||
|
console.log(`6-player game completed in ${result.totalTurns} turns`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('animation queue under load', async ({ page }) => {
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const animTracker = new AnimationTracker(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('AnimBot');
|
||||||
|
await bot.addCPU('Maya'); // Fast player
|
||||||
|
await bot.addCPU('Sage'); // Sneaky finisher
|
||||||
|
await bot.startGame({ holes: 1 });
|
||||||
|
|
||||||
|
await bot.completeInitialFlips();
|
||||||
|
|
||||||
|
let animationCount = 0;
|
||||||
|
let stallCount = 0;
|
||||||
|
|
||||||
|
while (await bot.getGamePhase() !== 'round_over' && animationCount < 50) {
|
||||||
|
if (await bot.isMyTurn()) {
|
||||||
|
// Track animation timing
|
||||||
|
animTracker.recordStart('turn');
|
||||||
|
|
||||||
|
await bot.playTurn();
|
||||||
|
|
||||||
|
const result = await animTracker.waitForAnimation('turn', 5000);
|
||||||
|
if (!result.completed) {
|
||||||
|
stallCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
animationCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check animation timing is reasonable
|
||||||
|
const avgDuration = animTracker.getAverageDuration('turn');
|
||||||
|
console.log(`Average turn animation: ${avgDuration?.toFixed(0) || 'N/A'}ms`);
|
||||||
|
|
||||||
|
// Stalls should be rare
|
||||||
|
expect(stallCount).toBeLessThan(3);
|
||||||
|
|
||||||
|
// Check stall events
|
||||||
|
const stalls = animTracker.getStalls();
|
||||||
|
if (stalls.length > 0) {
|
||||||
|
console.log(`Animation stalls:`, stalls);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('websocket reconnection handling', async ({ page }) => {
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const freezeDetector = new FreezeDetector(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('ReconnectBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.startGame({ holes: 1 });
|
||||||
|
|
||||||
|
await bot.completeInitialFlips();
|
||||||
|
|
||||||
|
// Play a few turns
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await bot.waitForMyTurn(10000);
|
||||||
|
if (await bot.isMyTurn()) {
|
||||||
|
await bot.playTurn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check WebSocket is healthy
|
||||||
|
const wsHealthy = await freezeDetector.checkWebSocket();
|
||||||
|
expect(wsHealthy).toBe(true);
|
||||||
|
|
||||||
|
// Note: Actually closing/reopening websocket would require
|
||||||
|
// server cooperation or network manipulation
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent clicks during animation', async ({ page }) => {
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('ClickBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.startGame({ holes: 1 });
|
||||||
|
|
||||||
|
await bot.completeInitialFlips();
|
||||||
|
await bot.waitForMyTurn(10000);
|
||||||
|
|
||||||
|
// Draw a card
|
||||||
|
const deck = page.locator('#deck');
|
||||||
|
await deck.click();
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
// Try rapid clicks on multiple elements while animation might be running
|
||||||
|
const clickPromises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const card = page.locator(`#player-cards .card:nth-child(${i + 1})`);
|
||||||
|
clickPromises.push(
|
||||||
|
card.click({ timeout: 500 }).catch(() => {})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all clicks to complete or timeout
|
||||||
|
await Promise.all(clickPromises);
|
||||||
|
|
||||||
|
// Wait for any animations
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Game should still be in a valid state
|
||||||
|
const phase = await bot.getGamePhase();
|
||||||
|
expect(['playing', 'waiting_for_flip', 'round_over']).toContain(phase);
|
||||||
|
|
||||||
|
// No console errors
|
||||||
|
const errors = bot.getConsoleErrors();
|
||||||
|
expect(errors.filter(e => e.includes('undefined') || e.includes('null'))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Edge Cases', () => {
|
||||||
|
test('all cards revealed simultaneously', async ({ page }) => {
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('EdgeBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
|
||||||
|
// Start with Speed Golf (flip on discard) to reveal cards faster
|
||||||
|
await bot.startGame({
|
||||||
|
holes: 1,
|
||||||
|
flipMode: 'always',
|
||||||
|
initialFlips: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Play until we trigger round end
|
||||||
|
const result = await bot.playRound(100);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
// Verify game handled the transition
|
||||||
|
const phase = await bot.getGamePhase();
|
||||||
|
expect(phase).toBe('round_over');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deck reshuffle scenario', async ({ page }) => {
|
||||||
|
test.setTimeout(180000); // 3 minutes for longer game
|
||||||
|
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('ShuffleBot');
|
||||||
|
|
||||||
|
// Add many players to deplete deck faster
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.addCPU('Maya');
|
||||||
|
await bot.addCPU('Marcus');
|
||||||
|
await bot.addCPU('Kenji');
|
||||||
|
|
||||||
|
// Use only 1 deck to force reshuffle
|
||||||
|
await bot.startGame({
|
||||||
|
holes: 1,
|
||||||
|
decks: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Play through - deck should reshuffle during game
|
||||||
|
const result = await bot.playRound(200);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty discard pile handling', async ({ page }) => {
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('EmptyBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.startGame({ holes: 1 });
|
||||||
|
|
||||||
|
// At game start, discard might be empty briefly
|
||||||
|
const initialState = await bot.getGameState();
|
||||||
|
|
||||||
|
// Game should still function
|
||||||
|
await bot.completeInitialFlips();
|
||||||
|
await bot.waitForMyTurn(10000);
|
||||||
|
|
||||||
|
// Should be able to draw from deck even if discard is empty
|
||||||
|
if (await bot.isMyTurn()) {
|
||||||
|
const state = await bot.getGameState();
|
||||||
|
if (!state.discard.hasCard) {
|
||||||
|
// Draw from deck should work
|
||||||
|
const deck = page.locator('#deck');
|
||||||
|
await deck.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Should have a held card now
|
||||||
|
const afterState = await bot.getGameState();
|
||||||
|
expect(afterState.heldCard.visible).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('final turn badge timing', async ({ page }) => {
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('BadgeBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.startGame({ holes: 1 });
|
||||||
|
|
||||||
|
// Monitor for final turn badge
|
||||||
|
let sawFinalTurnBadge = false;
|
||||||
|
let turnsAfterBadge = 0;
|
||||||
|
|
||||||
|
while (await bot.getGamePhase() !== 'round_over') {
|
||||||
|
const state = await bot.getGameState();
|
||||||
|
|
||||||
|
if (state.isFinalTurn) {
|
||||||
|
sawFinalTurnBadge = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sawFinalTurnBadge && await bot.isMyTurn()) {
|
||||||
|
turnsAfterBadge++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await bot.isMyTurn()) {
|
||||||
|
await bot.playTurn();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If final turn happened, we should have had at most 1 turn after badge appeared
|
||||||
|
// (this depends on whether we're the one who triggered final turn)
|
||||||
|
if (sawFinalTurnBadge) {
|
||||||
|
expect(turnsAfterBadge).toBeLessThanOrEqual(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
348
tests/e2e/specs/visual.spec.ts
Normal file
348
tests/e2e/specs/visual.spec.ts
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* Visual regression tests
|
||||||
|
* Validates visual correctness at key game moments
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, devices } from '@playwright/test';
|
||||||
|
import { GolfBot } from '../bot/golf-bot';
|
||||||
|
import { ScreenshotValidator } from '../visual/screenshot-validator';
|
||||||
|
import {
|
||||||
|
validateGameStart,
|
||||||
|
validateAfterInitialFlip,
|
||||||
|
validateDrawPhase,
|
||||||
|
validateAfterDraw,
|
||||||
|
validateRoundOver,
|
||||||
|
validateFinalTurn,
|
||||||
|
validateResponsiveLayout,
|
||||||
|
} from '../visual/visual-rules';
|
||||||
|
|
||||||
|
test.describe('Visual Validation', () => {
|
||||||
|
test('game start visual state', async ({ page }) => {
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const validator = new ScreenshotValidator(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('TestBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.startGame({ holes: 1 });
|
||||||
|
|
||||||
|
// Wait for game to fully render
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Capture game start
|
||||||
|
await validator.capture('game-start-visual');
|
||||||
|
|
||||||
|
// Validate visual state
|
||||||
|
const result = await validateGameStart(validator);
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
if (!result.passed) {
|
||||||
|
console.log('Failures:', result.failures);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initial flip visual state', async ({ page }) => {
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const validator = new ScreenshotValidator(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('TestBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.startGame({ holes: 1, initialFlips: 2 });
|
||||||
|
|
||||||
|
// Complete initial flips
|
||||||
|
await bot.completeInitialFlips();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Capture after flips
|
||||||
|
await validator.capture('after-initial-flip-visual');
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
const result = await validateAfterInitialFlip(validator, 2);
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
if (!result.passed) {
|
||||||
|
console.log('Failures:', result.failures);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('draw phase visual state', async ({ page }) => {
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const validator = new ScreenshotValidator(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('TestBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.startGame({ holes: 1 });
|
||||||
|
|
||||||
|
// Complete initial phase and wait for our turn
|
||||||
|
await bot.completeInitialFlips();
|
||||||
|
|
||||||
|
// Wait for our turn
|
||||||
|
await bot.waitForMyTurn(10000);
|
||||||
|
|
||||||
|
// Capture draw phase
|
||||||
|
await validator.capture('draw-phase-visual');
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
const result = await validateDrawPhase(validator);
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
if (!result.passed) {
|
||||||
|
console.log('Failures:', result.failures);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('held card visual state', async ({ page }) => {
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const validator = new ScreenshotValidator(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('TestBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.startGame({ holes: 1 });
|
||||||
|
|
||||||
|
await bot.completeInitialFlips();
|
||||||
|
await bot.waitForMyTurn(10000);
|
||||||
|
|
||||||
|
// Draw a card
|
||||||
|
const deck = page.locator('#deck');
|
||||||
|
await deck.click();
|
||||||
|
|
||||||
|
// Wait for draw animation
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Capture held card state
|
||||||
|
await validator.capture('held-card-visual');
|
||||||
|
|
||||||
|
// Validate held card is visible
|
||||||
|
const heldResult = await validator.expectHeldCardVisible();
|
||||||
|
expect(heldResult.passed).toBe(true);
|
||||||
|
|
||||||
|
// Validate cards are clickable
|
||||||
|
const clickableResult = await validator.expectCount(
|
||||||
|
'#player-cards .card.clickable',
|
||||||
|
6
|
||||||
|
);
|
||||||
|
expect(clickableResult.passed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round over visual state', async ({ page }) => {
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const validator = new ScreenshotValidator(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('TestBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.startGame({ holes: 1 });
|
||||||
|
|
||||||
|
// Play until round over
|
||||||
|
await bot.playRound(50);
|
||||||
|
|
||||||
|
// Wait for animations
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Capture round over
|
||||||
|
await validator.capture('round-over-visual');
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
const result = await validateRoundOver(validator);
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
if (!result.passed) {
|
||||||
|
console.log('Failures:', result.failures);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('card flip animation renders correctly', async ({ page }) => {
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const validator = new ScreenshotValidator(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('TestBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.startGame({ holes: 1, initialFlips: 0 }); // No initial flips
|
||||||
|
|
||||||
|
// Wait for our turn
|
||||||
|
await bot.waitForMyTurn(10000);
|
||||||
|
|
||||||
|
// Capture before flip
|
||||||
|
await validator.capture('before-flip');
|
||||||
|
|
||||||
|
// Get a face-down card position
|
||||||
|
const state = await bot.getGameState();
|
||||||
|
const faceDownPos = state.myPlayer?.cards.find(c => !c.faceUp)?.position ?? 0;
|
||||||
|
|
||||||
|
// Draw and swap to trigger flip
|
||||||
|
const deck = page.locator('#deck');
|
||||||
|
await deck.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Click the face-down card to swap
|
||||||
|
const card = page.locator(`#player-cards .card:nth-child(${faceDownPos + 1})`);
|
||||||
|
await card.click();
|
||||||
|
|
||||||
|
// Wait for animation to complete
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// Capture after flip
|
||||||
|
await validator.capture('after-flip');
|
||||||
|
|
||||||
|
// Verify card is now face-up
|
||||||
|
const afterState = await bot.getGameState();
|
||||||
|
const cardAfter = afterState.myPlayer?.cards.find(c => c.position === faceDownPos);
|
||||||
|
expect(cardAfter?.faceUp).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opponent highlighting on their turn', async ({ page }) => {
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const validator = new ScreenshotValidator(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('TestBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.startGame({ holes: 1 });
|
||||||
|
|
||||||
|
await bot.completeInitialFlips();
|
||||||
|
|
||||||
|
// Play our turn and then wait for opponent's turn
|
||||||
|
if (await bot.isMyTurn()) {
|
||||||
|
await bot.playTurn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for opponent turn indicator
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Check if it's opponent's turn
|
||||||
|
const state = await bot.getGameState();
|
||||||
|
const opponentPlaying = state.opponents.some(o => o.isCurrentTurn);
|
||||||
|
|
||||||
|
if (opponentPlaying) {
|
||||||
|
// Capture opponent turn
|
||||||
|
await validator.capture('opponent-turn-visual');
|
||||||
|
|
||||||
|
// Find which opponent has current turn
|
||||||
|
const currentOpponentIndex = state.opponents.findIndex(o => o.isCurrentTurn);
|
||||||
|
if (currentOpponentIndex >= 0) {
|
||||||
|
const result = await validator.expectOpponentCurrentTurn(currentOpponentIndex);
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discard pile updates correctly', async ({ page }) => {
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const validator = new ScreenshotValidator(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('TestBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.startGame({ holes: 1 });
|
||||||
|
|
||||||
|
await bot.completeInitialFlips();
|
||||||
|
await bot.waitForMyTurn(10000);
|
||||||
|
|
||||||
|
// Get initial discard state
|
||||||
|
const beforeState = await bot.getGameState();
|
||||||
|
const beforeDiscard = beforeState.discard.topCard;
|
||||||
|
|
||||||
|
// Draw from deck and discard
|
||||||
|
const deck = page.locator('#deck');
|
||||||
|
await deck.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Get the held card
|
||||||
|
const heldCard = (await bot.getGameState()).heldCard.card;
|
||||||
|
|
||||||
|
// Discard the drawn card
|
||||||
|
const discardBtn = page.locator('#discard-btn');
|
||||||
|
await discardBtn.click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
|
||||||
|
// Capture after discard
|
||||||
|
await validator.capture('after-discard-visual');
|
||||||
|
|
||||||
|
// Verify discard pile has the card we discarded
|
||||||
|
const afterState = await bot.getGameState();
|
||||||
|
expect(afterState.discard.hasCard).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Responsive Layout', () => {
|
||||||
|
test('mobile layout (375px)', async ({ browser }) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
...devices['iPhone 13'],
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const validator = new ScreenshotValidator(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('TestBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.startGame({ holes: 1 });
|
||||||
|
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Capture mobile layout
|
||||||
|
await validator.capture('mobile-375-layout');
|
||||||
|
|
||||||
|
// Validate responsive elements
|
||||||
|
const result = await validateResponsiveLayout(validator, 375);
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
if (!result.passed) {
|
||||||
|
console.log('Mobile failures:', result.failures);
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tablet layout (768px)', async ({ browser }) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 768, height: 1024 },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const validator = new ScreenshotValidator(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('TestBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.startGame({ holes: 1 });
|
||||||
|
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Capture tablet layout
|
||||||
|
await validator.capture('tablet-768-layout');
|
||||||
|
|
||||||
|
// Validate responsive elements
|
||||||
|
const result = await validateResponsiveLayout(validator, 768);
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop layout (1920px)', async ({ browser }) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
const bot = new GolfBot(page);
|
||||||
|
const validator = new ScreenshotValidator(page);
|
||||||
|
|
||||||
|
await bot.goto();
|
||||||
|
await bot.createGame('TestBot');
|
||||||
|
await bot.addCPU('Sofia');
|
||||||
|
await bot.startGame({ holes: 1 });
|
||||||
|
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Capture desktop layout
|
||||||
|
await validator.capture('desktop-1920-layout');
|
||||||
|
|
||||||
|
// Validate responsive elements
|
||||||
|
const result = await validateResponsiveLayout(validator, 1920);
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
32
tests/e2e/tsconfig.json
Normal file
32
tests/e2e/tsconfig.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": false,
|
||||||
|
"declarationMap": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": ".",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@bot/*": ["bot/*"],
|
||||||
|
"@health/*": ["health/*"],
|
||||||
|
"@visual/*": ["visual/*"],
|
||||||
|
"@utils/*": ["utils/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"playwright-report"
|
||||||
|
]
|
||||||
|
}
|
||||||
12
tests/e2e/utils/index.ts
Normal file
12
tests/e2e/utils/index.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export {
|
||||||
|
TIMING,
|
||||||
|
waitForAnimations,
|
||||||
|
waitForWebSocket,
|
||||||
|
safeWait,
|
||||||
|
} from './timing';
|
||||||
|
export {
|
||||||
|
SELECTORS,
|
||||||
|
playerCardSelector,
|
||||||
|
clickableCardSelector,
|
||||||
|
opponentCardSelector,
|
||||||
|
} from './selectors';
|
||||||
157
tests/e2e/utils/selectors.ts
Normal file
157
tests/e2e/utils/selectors.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* DOM selector constants for the Golf game
|
||||||
|
* Extracted from client/index.html and client/app.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SELECTORS = {
|
||||||
|
// Screens
|
||||||
|
screens: {
|
||||||
|
lobby: '#lobby-screen',
|
||||||
|
waiting: '#waiting-screen',
|
||||||
|
game: '#game-screen',
|
||||||
|
rules: '#rules-screen',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Lobby elements
|
||||||
|
lobby: {
|
||||||
|
playerNameInput: '#player-name',
|
||||||
|
roomCodeInput: '#room-code',
|
||||||
|
createRoomBtn: '#create-room-btn',
|
||||||
|
joinRoomBtn: '#join-room-btn',
|
||||||
|
error: '#lobby-error',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Waiting room elements
|
||||||
|
waiting: {
|
||||||
|
roomCode: '#display-room-code',
|
||||||
|
copyCodeBtn: '#copy-room-code',
|
||||||
|
shareBtn: '#share-room-link',
|
||||||
|
playersList: '#players-list',
|
||||||
|
hostSettings: '#host-settings',
|
||||||
|
startGameBtn: '#start-game-btn',
|
||||||
|
leaveRoomBtn: '#leave-room-btn',
|
||||||
|
addCpuBtn: '#add-cpu-btn',
|
||||||
|
removeCpuBtn: '#remove-cpu-btn',
|
||||||
|
cpuModal: '#cpu-select-modal',
|
||||||
|
cpuProfilesGrid: '#cpu-profiles-grid',
|
||||||
|
cancelCpuBtn: '#cancel-cpu-btn',
|
||||||
|
addSelectedCpusBtn: '#add-selected-cpus-btn',
|
||||||
|
// Settings
|
||||||
|
numDecks: '#num-decks',
|
||||||
|
numRounds: '#num-rounds',
|
||||||
|
initialFlips: '#initial-flips',
|
||||||
|
flipMode: '#flip-mode',
|
||||||
|
knockPenalty: '#knock-penalty',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Game screen elements
|
||||||
|
game: {
|
||||||
|
// Header
|
||||||
|
currentRound: '#current-round',
|
||||||
|
totalRounds: '#total-rounds',
|
||||||
|
statusMessage: '#status-message',
|
||||||
|
finalTurnBadge: '#final-turn-badge',
|
||||||
|
muteBtn: '#mute-btn',
|
||||||
|
leaveGameBtn: '#leave-game-btn',
|
||||||
|
activeRulesBar: '#active-rules-bar',
|
||||||
|
|
||||||
|
// Table
|
||||||
|
opponentsRow: '#opponents-row',
|
||||||
|
playerArea: '.player-area',
|
||||||
|
playerCards: '#player-cards',
|
||||||
|
playerHeader: '#player-header',
|
||||||
|
yourScore: '#your-score',
|
||||||
|
|
||||||
|
// Deck and discard
|
||||||
|
deckArea: '.deck-area',
|
||||||
|
deck: '#deck',
|
||||||
|
discard: '#discard',
|
||||||
|
discardContent: '#discard-content',
|
||||||
|
discardBtn: '#discard-btn',
|
||||||
|
skipFlipBtn: '#skip-flip-btn',
|
||||||
|
knockEarlyBtn: '#knock-early-btn',
|
||||||
|
|
||||||
|
// Held card
|
||||||
|
heldCardSlot: '#held-card-slot',
|
||||||
|
heldCardDisplay: '#held-card-display',
|
||||||
|
heldCardFloating: '#held-card-floating',
|
||||||
|
heldCardFloatingContent: '#held-card-floating-content',
|
||||||
|
|
||||||
|
// Scoreboard
|
||||||
|
scoreboard: '#scoreboard',
|
||||||
|
scoreTable: '#score-table tbody',
|
||||||
|
standingsList: '#standings-list',
|
||||||
|
nextRoundBtn: '#next-round-btn',
|
||||||
|
newGameBtn: '#new-game-btn',
|
||||||
|
gameButtons: '#game-buttons',
|
||||||
|
|
||||||
|
// Card layer for animations
|
||||||
|
cardLayer: '#card-layer',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Card-related selectors
|
||||||
|
cards: {
|
||||||
|
// Player's own cards (0-5)
|
||||||
|
playerCard: (index: number) => `#player-cards .card:nth-child(${index + 1})`,
|
||||||
|
playerCardSlot: (index: number) => `#player-cards .card-slot:nth-child(${index + 1})`,
|
||||||
|
|
||||||
|
// Opponent cards
|
||||||
|
opponentArea: (index: number) => `.opponent-area:nth-child(${index + 1})`,
|
||||||
|
opponentCard: (oppIndex: number, cardIndex: number) =>
|
||||||
|
`.opponent-area:nth-child(${oppIndex + 1}) .card-grid .card:nth-child(${cardIndex + 1})`,
|
||||||
|
|
||||||
|
// Card states
|
||||||
|
faceUp: '.card-front',
|
||||||
|
faceDown: '.card-back',
|
||||||
|
clickable: '.clickable',
|
||||||
|
selected: '.selected',
|
||||||
|
},
|
||||||
|
|
||||||
|
// CSS classes for state detection
|
||||||
|
classes: {
|
||||||
|
active: 'active',
|
||||||
|
hidden: 'hidden',
|
||||||
|
clickable: 'clickable',
|
||||||
|
selected: 'selected',
|
||||||
|
faceUp: 'card-front',
|
||||||
|
faceDown: 'card-back',
|
||||||
|
red: 'red',
|
||||||
|
black: 'black',
|
||||||
|
joker: 'joker',
|
||||||
|
currentTurn: 'current-turn',
|
||||||
|
roundWinner: 'round-winner',
|
||||||
|
yourTurnToDraw: 'your-turn-to-draw',
|
||||||
|
hasCard: 'has-card',
|
||||||
|
pickedUp: 'picked-up',
|
||||||
|
disabled: 'disabled',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Animation-related
|
||||||
|
animations: {
|
||||||
|
swapAnimation: '#swap-animation',
|
||||||
|
swapCardFromHand: '#swap-card-from-hand',
|
||||||
|
animCard: '.anim-card',
|
||||||
|
realCard: '.real-card',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a selector for a card in the player's grid
|
||||||
|
*/
|
||||||
|
export function playerCardSelector(position: number): string {
|
||||||
|
return SELECTORS.cards.playerCard(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a selector for a clickable card
|
||||||
|
*/
|
||||||
|
export function clickableCardSelector(position: number): string {
|
||||||
|
return `${SELECTORS.cards.playerCard(position)}.${SELECTORS.classes.clickable}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a selector for an opponent's card
|
||||||
|
*/
|
||||||
|
export function opponentCardSelector(opponentIndex: number, cardPosition: number): string {
|
||||||
|
return SELECTORS.cards.opponentCard(opponentIndex, cardPosition);
|
||||||
|
}
|
||||||
71
tests/e2e/utils/timing.ts
Normal file
71
tests/e2e/utils/timing.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Animation timing constants from animation-queue.js
|
||||||
|
* Used to wait for animations to complete before asserting state
|
||||||
|
*/
|
||||||
|
export const TIMING = {
|
||||||
|
// Core animation durations (from CSS/animation-queue.js)
|
||||||
|
flipDuration: 540,
|
||||||
|
moveDuration: 270,
|
||||||
|
pauseAfterFlip: 144,
|
||||||
|
pauseAfterDiscard: 550,
|
||||||
|
pauseBeforeNewCard: 150,
|
||||||
|
pauseAfterSwapComplete: 400,
|
||||||
|
pauseBetweenAnimations: 90,
|
||||||
|
|
||||||
|
// Derived waits for test actions
|
||||||
|
get flipComplete() {
|
||||||
|
return this.flipDuration + this.pauseAfterFlip + 100;
|
||||||
|
},
|
||||||
|
get swapComplete() {
|
||||||
|
return this.flipDuration + this.pauseAfterFlip + this.moveDuration +
|
||||||
|
this.pauseAfterDiscard + this.pauseBeforeNewCard +
|
||||||
|
this.moveDuration + this.pauseAfterSwapComplete + 200;
|
||||||
|
},
|
||||||
|
get drawComplete() {
|
||||||
|
return this.moveDuration + this.pauseBeforeNewCard + 100;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Safety margins for network/processing
|
||||||
|
networkBuffer: 200,
|
||||||
|
safetyMargin: 300,
|
||||||
|
|
||||||
|
// Longer waits
|
||||||
|
turnTransition: 500,
|
||||||
|
cpuThinkingMin: 400,
|
||||||
|
cpuThinkingMax: 1200,
|
||||||
|
roundOverDelay: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for animation queue to drain
|
||||||
|
*/
|
||||||
|
export async function waitForAnimations(page: import('@playwright/test').Page, timeout = 5000): Promise<void> {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const game = (window as any).game;
|
||||||
|
if (!game?.animationQueue) return true;
|
||||||
|
return !game.animationQueue.isAnimating();
|
||||||
|
},
|
||||||
|
{ timeout }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for WebSocket to be ready
|
||||||
|
*/
|
||||||
|
export async function waitForWebSocket(page: import('@playwright/test').Page, timeout = 5000): Promise<void> {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const game = (window as any).game;
|
||||||
|
return game?.ws?.readyState === WebSocket.OPEN;
|
||||||
|
},
|
||||||
|
{ timeout }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait a fixed time plus safety margin
|
||||||
|
*/
|
||||||
|
export function safeWait(duration: number): number {
|
||||||
|
return duration + TIMING.safetyMargin;
|
||||||
|
}
|
||||||
15
tests/e2e/visual/index.ts
Normal file
15
tests/e2e/visual/index.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export {
|
||||||
|
ScreenshotValidator,
|
||||||
|
VisualExpectation,
|
||||||
|
CaptureResult,
|
||||||
|
} from './screenshot-validator';
|
||||||
|
export {
|
||||||
|
validateGameStart,
|
||||||
|
validateAfterInitialFlip,
|
||||||
|
validateDrawPhase,
|
||||||
|
validateAfterDraw,
|
||||||
|
validateRoundOver,
|
||||||
|
validateFinalTurn,
|
||||||
|
validateOpponentTurn,
|
||||||
|
validateResponsiveLayout,
|
||||||
|
} from './visual-rules';
|
||||||
342
tests/e2e/visual/screenshot-validator.ts
Normal file
342
tests/e2e/visual/screenshot-validator.ts
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
/**
|
||||||
|
* Screenshot validator - captures screenshots and validates visual states
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Page, expect } from '@playwright/test';
|
||||||
|
import { SELECTORS } from '../utils/selectors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual expectation result
|
||||||
|
*/
|
||||||
|
export interface VisualExpectation {
|
||||||
|
passed: boolean;
|
||||||
|
selector: string;
|
||||||
|
expected: string;
|
||||||
|
actual?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screenshot capture result
|
||||||
|
*/
|
||||||
|
export interface CaptureResult {
|
||||||
|
label: string;
|
||||||
|
buffer: Buffer;
|
||||||
|
timestamp: number;
|
||||||
|
phase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScreenshotValidator - semantic visual validation
|
||||||
|
*/
|
||||||
|
export class ScreenshotValidator {
|
||||||
|
private captures: CaptureResult[] = [];
|
||||||
|
|
||||||
|
constructor(private page: Page) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a screenshot with metadata
|
||||||
|
*/
|
||||||
|
async capture(label: string, phase?: string): Promise<CaptureResult> {
|
||||||
|
const buffer = await this.page.screenshot({ fullPage: true });
|
||||||
|
const result: CaptureResult = {
|
||||||
|
label,
|
||||||
|
buffer,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
phase,
|
||||||
|
};
|
||||||
|
this.captures.push(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture element-specific screenshot
|
||||||
|
*/
|
||||||
|
async captureElement(
|
||||||
|
selector: string,
|
||||||
|
label: string
|
||||||
|
): Promise<CaptureResult | null> {
|
||||||
|
try {
|
||||||
|
const element = this.page.locator(selector);
|
||||||
|
const buffer = await element.screenshot();
|
||||||
|
const result: CaptureResult = {
|
||||||
|
label,
|
||||||
|
buffer,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
this.captures.push(result);
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all captures
|
||||||
|
*/
|
||||||
|
getCaptures(): CaptureResult[] {
|
||||||
|
return [...this.captures];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear captures
|
||||||
|
*/
|
||||||
|
clearCaptures(): void {
|
||||||
|
this.captures = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== Semantic Validators ==============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect element to be visible
|
||||||
|
*/
|
||||||
|
async expectVisible(selector: string): Promise<VisualExpectation> {
|
||||||
|
try {
|
||||||
|
const el = this.page.locator(selector);
|
||||||
|
await expect(el).toBeVisible({ timeout: 2000 });
|
||||||
|
return { passed: true, selector, expected: 'visible' };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
selector,
|
||||||
|
expected: 'visible',
|
||||||
|
actual: 'not visible',
|
||||||
|
error: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect element to be hidden
|
||||||
|
*/
|
||||||
|
async expectNotVisible(selector: string): Promise<VisualExpectation> {
|
||||||
|
try {
|
||||||
|
const el = this.page.locator(selector);
|
||||||
|
await expect(el).toBeHidden({ timeout: 2000 });
|
||||||
|
return { passed: true, selector, expected: 'hidden' };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
selector,
|
||||||
|
expected: 'hidden',
|
||||||
|
actual: 'visible',
|
||||||
|
error: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect element to have specific CSS class
|
||||||
|
*/
|
||||||
|
async expectHasClass(
|
||||||
|
selector: string,
|
||||||
|
className: string
|
||||||
|
): Promise<VisualExpectation> {
|
||||||
|
try {
|
||||||
|
const el = this.page.locator(selector);
|
||||||
|
const hasClass = await el.evaluate(
|
||||||
|
(node, cls) => node.classList.contains(cls),
|
||||||
|
className
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed: hasClass,
|
||||||
|
selector,
|
||||||
|
expected: `has class "${className}"`,
|
||||||
|
actual: hasClass ? `has class "${className}"` : `missing class "${className}"`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
selector,
|
||||||
|
expected: `has class "${className}"`,
|
||||||
|
error: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect element to NOT have specific CSS class
|
||||||
|
*/
|
||||||
|
async expectNoClass(
|
||||||
|
selector: string,
|
||||||
|
className: string
|
||||||
|
): Promise<VisualExpectation> {
|
||||||
|
try {
|
||||||
|
const el = this.page.locator(selector);
|
||||||
|
const hasClass = await el.evaluate(
|
||||||
|
(node, cls) => node.classList.contains(cls),
|
||||||
|
className
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed: !hasClass,
|
||||||
|
selector,
|
||||||
|
expected: `no class "${className}"`,
|
||||||
|
actual: hasClass ? `has class "${className}"` : `no class "${className}"`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
selector,
|
||||||
|
expected: `no class "${className}"`,
|
||||||
|
error: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect text content to match
|
||||||
|
*/
|
||||||
|
async expectText(
|
||||||
|
selector: string,
|
||||||
|
expected: string | RegExp
|
||||||
|
): Promise<VisualExpectation> {
|
||||||
|
try {
|
||||||
|
const el = this.page.locator(selector);
|
||||||
|
const text = await el.textContent() || '';
|
||||||
|
|
||||||
|
const matches = expected instanceof RegExp
|
||||||
|
? expected.test(text)
|
||||||
|
: text.includes(expected);
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed: matches,
|
||||||
|
selector,
|
||||||
|
expected: String(expected),
|
||||||
|
actual: text,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
selector,
|
||||||
|
expected: String(expected),
|
||||||
|
error: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect specific number of elements
|
||||||
|
*/
|
||||||
|
async expectCount(
|
||||||
|
selector: string,
|
||||||
|
count: number
|
||||||
|
): Promise<VisualExpectation> {
|
||||||
|
try {
|
||||||
|
const els = this.page.locator(selector);
|
||||||
|
const actual = await els.count();
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed: actual === count,
|
||||||
|
selector,
|
||||||
|
expected: `count=${count}`,
|
||||||
|
actual: `count=${actual}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
selector,
|
||||||
|
expected: `count=${count}`,
|
||||||
|
error: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect card at position to be face-up
|
||||||
|
*/
|
||||||
|
async expectCardFaceUp(position: number): Promise<VisualExpectation> {
|
||||||
|
const selector = SELECTORS.cards.playerCard(position);
|
||||||
|
return this.expectHasClass(selector, 'card-front');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect card at position to be face-down
|
||||||
|
*/
|
||||||
|
async expectCardFaceDown(position: number): Promise<VisualExpectation> {
|
||||||
|
const selector = SELECTORS.cards.playerCard(position);
|
||||||
|
return this.expectHasClass(selector, 'card-back');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect card at position to be clickable
|
||||||
|
*/
|
||||||
|
async expectCardClickable(position: number): Promise<VisualExpectation> {
|
||||||
|
const selector = SELECTORS.cards.playerCard(position);
|
||||||
|
return this.expectHasClass(selector, 'clickable');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect deck to be clickable
|
||||||
|
*/
|
||||||
|
async expectDeckClickable(): Promise<VisualExpectation> {
|
||||||
|
return this.expectHasClass(SELECTORS.game.deck, 'clickable');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect discard pile to have a card
|
||||||
|
*/
|
||||||
|
async expectDiscardHasCard(): Promise<VisualExpectation> {
|
||||||
|
return this.expectHasClass(SELECTORS.game.discard, 'has-card');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect final turn badge visible
|
||||||
|
*/
|
||||||
|
async expectFinalTurnBadge(): Promise<VisualExpectation> {
|
||||||
|
return this.expectVisible(SELECTORS.game.finalTurnBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect held card floating visible
|
||||||
|
*/
|
||||||
|
async expectHeldCardVisible(): Promise<VisualExpectation> {
|
||||||
|
return this.expectVisible(SELECTORS.game.heldCardFloating);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect held card floating hidden
|
||||||
|
*/
|
||||||
|
async expectHeldCardHidden(): Promise<VisualExpectation> {
|
||||||
|
return this.expectNotVisible(SELECTORS.game.heldCardFloating);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect opponent to have current-turn class
|
||||||
|
*/
|
||||||
|
async expectOpponentCurrentTurn(opponentIndex: number): Promise<VisualExpectation> {
|
||||||
|
const selector = SELECTORS.cards.opponentArea(opponentIndex);
|
||||||
|
return this.expectHasClass(selector, 'current-turn');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect status message to contain text
|
||||||
|
*/
|
||||||
|
async expectStatusMessage(text: string | RegExp): Promise<VisualExpectation> {
|
||||||
|
return this.expectText(SELECTORS.game.statusMessage, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a batch of visual checks
|
||||||
|
*/
|
||||||
|
async runChecks(
|
||||||
|
checks: Array<() => Promise<VisualExpectation>>
|
||||||
|
): Promise<{ passed: number; failed: number; results: VisualExpectation[] }> {
|
||||||
|
const results: VisualExpectation[] = [];
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const check of checks) {
|
||||||
|
const result = await check();
|
||||||
|
results.push(result);
|
||||||
|
if (result.passed) {
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { passed, failed, results };
|
||||||
|
}
|
||||||
|
}
|
||||||
232
tests/e2e/visual/visual-rules.ts
Normal file
232
tests/e2e/visual/visual-rules.ts
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* Visual rules - expected visual states for different game phases
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ScreenshotValidator } from './screenshot-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expected visual states for game start
|
||||||
|
*/
|
||||||
|
export async function validateGameStart(
|
||||||
|
validator: ScreenshotValidator
|
||||||
|
): Promise<{ passed: boolean; failures: string[] }> {
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
// All cards should be visible (face up or down)
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const result = await validator.expectCount(
|
||||||
|
`#player-cards .card:nth-child(${i + 1})`,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
if (!result.passed) {
|
||||||
|
failures.push(`Card ${i} not present`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status message should indicate game phase
|
||||||
|
const statusResult = await validator.expectVisible('#status-message');
|
||||||
|
if (!statusResult.passed) {
|
||||||
|
failures.push('Status message not visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deck should be visible
|
||||||
|
const deckResult = await validator.expectVisible('#deck');
|
||||||
|
if (!deckResult.passed) {
|
||||||
|
failures.push('Deck not visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard should be visible
|
||||||
|
const discardResult = await validator.expectVisible('#discard');
|
||||||
|
if (!discardResult.passed) {
|
||||||
|
failures.push('Discard not visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { passed: failures.length === 0, failures };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expected visual states after initial flip
|
||||||
|
*/
|
||||||
|
export async function validateAfterInitialFlip(
|
||||||
|
validator: ScreenshotValidator,
|
||||||
|
expectedFaceUp: number = 2
|
||||||
|
): Promise<{ passed: boolean; failures: string[] }> {
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
// Count face-up cards
|
||||||
|
const faceUpResult = await validator.expectCount(
|
||||||
|
'#player-cards .card.card-front',
|
||||||
|
expectedFaceUp
|
||||||
|
);
|
||||||
|
if (!faceUpResult.passed) {
|
||||||
|
failures.push(`Expected ${expectedFaceUp} face-up cards, got ${faceUpResult.actual}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count face-down cards
|
||||||
|
const faceDownResult = await validator.expectCount(
|
||||||
|
'#player-cards .card.card-back',
|
||||||
|
6 - expectedFaceUp
|
||||||
|
);
|
||||||
|
if (!faceDownResult.passed) {
|
||||||
|
failures.push(`Expected ${6 - expectedFaceUp} face-down cards, got ${faceDownResult.actual}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { passed: failures.length === 0, failures };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expected visual states during player's turn (draw phase)
|
||||||
|
*/
|
||||||
|
export async function validateDrawPhase(
|
||||||
|
validator: ScreenshotValidator
|
||||||
|
): Promise<{ passed: boolean; failures: string[] }> {
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
// Deck should be clickable
|
||||||
|
const deckResult = await validator.expectDeckClickable();
|
||||||
|
if (!deckResult.passed) {
|
||||||
|
failures.push('Deck should be clickable');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Held card should NOT be visible yet
|
||||||
|
const heldResult = await validator.expectHeldCardHidden();
|
||||||
|
if (!heldResult.passed) {
|
||||||
|
failures.push('Held card should not be visible before draw');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard button should be hidden
|
||||||
|
const discardBtnResult = await validator.expectNotVisible('#discard-btn');
|
||||||
|
if (!discardBtnResult.passed) {
|
||||||
|
failures.push('Discard button should be hidden before draw');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { passed: failures.length === 0, failures };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expected visual states after drawing a card
|
||||||
|
*/
|
||||||
|
export async function validateAfterDraw(
|
||||||
|
validator: ScreenshotValidator
|
||||||
|
): Promise<{ passed: boolean; failures: string[] }> {
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
// Held card should be visible (floating)
|
||||||
|
const heldResult = await validator.expectHeldCardVisible();
|
||||||
|
if (!heldResult.passed) {
|
||||||
|
failures.push('Held card should be visible after draw');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player cards should be clickable
|
||||||
|
const clickableResult = await validator.expectCount(
|
||||||
|
'#player-cards .card.clickable',
|
||||||
|
6
|
||||||
|
);
|
||||||
|
if (!clickableResult.passed) {
|
||||||
|
failures.push('All player cards should be clickable');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { passed: failures.length === 0, failures };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expected visual states for round over
|
||||||
|
*/
|
||||||
|
export async function validateRoundOver(
|
||||||
|
validator: ScreenshotValidator
|
||||||
|
): Promise<{ passed: boolean; failures: string[] }> {
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
// All player cards should be face-up
|
||||||
|
const faceUpResult = await validator.expectCount(
|
||||||
|
'#player-cards .card.card-front',
|
||||||
|
6
|
||||||
|
);
|
||||||
|
if (!faceUpResult.passed) {
|
||||||
|
failures.push('All cards should be face-up at round end');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next round button OR new game button should be visible
|
||||||
|
const nextRoundResult = await validator.expectVisible('#next-round-btn');
|
||||||
|
const newGameResult = await validator.expectVisible('#new-game-btn');
|
||||||
|
|
||||||
|
if (!nextRoundResult.passed && !newGameResult.passed) {
|
||||||
|
failures.push('Neither next round nor new game button visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game buttons container should be visible
|
||||||
|
const gameButtonsResult = await validator.expectVisible('#game-buttons');
|
||||||
|
if (!gameButtonsResult.passed) {
|
||||||
|
failures.push('Game buttons should be visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { passed: failures.length === 0, failures };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expected visual states for final turn
|
||||||
|
*/
|
||||||
|
export async function validateFinalTurn(
|
||||||
|
validator: ScreenshotValidator
|
||||||
|
): Promise<{ passed: boolean; failures: string[] }> {
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
// Final turn badge should be visible
|
||||||
|
const badgeResult = await validator.expectFinalTurnBadge();
|
||||||
|
if (!badgeResult.passed) {
|
||||||
|
failures.push('Final turn badge should be visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { passed: failures.length === 0, failures };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expected visual states during opponent's turn
|
||||||
|
*/
|
||||||
|
export async function validateOpponentTurn(
|
||||||
|
validator: ScreenshotValidator,
|
||||||
|
opponentIndex: number
|
||||||
|
): Promise<{ passed: boolean; failures: string[] }> {
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
// Opponent should have current-turn highlight
|
||||||
|
const turnResult = await validator.expectOpponentCurrentTurn(opponentIndex);
|
||||||
|
if (!turnResult.passed) {
|
||||||
|
failures.push(`Opponent ${opponentIndex} should have current-turn class`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deck should NOT be clickable (not our turn)
|
||||||
|
const deckResult = await validator.expectNoClass('#deck', 'clickable');
|
||||||
|
if (!deckResult.passed) {
|
||||||
|
failures.push('Deck should not be clickable during opponent turn');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { passed: failures.length === 0, failures };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate responsive layout at specific width
|
||||||
|
*/
|
||||||
|
export async function validateResponsiveLayout(
|
||||||
|
validator: ScreenshotValidator,
|
||||||
|
width: number
|
||||||
|
): Promise<{ passed: boolean; failures: string[] }> {
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
// Core elements should still be visible
|
||||||
|
const elements = [
|
||||||
|
'#deck',
|
||||||
|
'#discard',
|
||||||
|
'#player-cards',
|
||||||
|
'#status-message',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of elements) {
|
||||||
|
const result = await validator.expectVisible(selector);
|
||||||
|
if (!result.passed) {
|
||||||
|
failures.push(`${selector} not visible at ${width}px width`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { passed: failures.length === 0, failures };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user