From 8d5b2ee655ed4f265609b39e9aa1ee5d86e069fe Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 23 Feb 2026 19:07:57 -0500 Subject: [PATCH] Fix AI knock decisions and improve round-end animations Fix dumb AI knocks (e.g. Maya knocking on 13 points) by adding opponent threat checks and a hard cap of 10 to should_knock_early(). Remove dead should_go_out_early() call whose return value was never used. Retune knock chance tiers to be more conservative at higher projected scores. On the client side, fix round-end reveal sequencing so the last player's swap/discard animation plays before the reveal sequence starts, and prevent re-renders from clobbering swap animations during reveals. Also make the turn-pulse shake configurable via timing-config and target only cards. Co-Authored-By: Claude Opus 4.6 --- client/app.js | 62 ++++++++++++++++++++++++++++++++------- client/card-animations.js | 18 ++++++++---- client/timing-config.js | 8 +++++ server/ai.py | 30 ++++++++++++++----- 4 files changed, 94 insertions(+), 24 deletions(-) diff --git a/client/app.js b/client/app.js index 528377c..a2a0e61 100644 --- a/client/app.js +++ b/client/app.js @@ -822,11 +822,29 @@ class GolfGame { newState.phase === 'round_over'; if (roundJustEnded && oldState) { - // Save pre-reveal state for the reveal animation - this.preRevealState = JSON.parse(JSON.stringify(oldState)); - this.postRevealState = newState; - // Update state but DON'T render yet - reveal animation will handle it + // Update state first so animations can read new card data this.gameState = newState; + + // Fire animations for the last turn (swap/discard) before deferring + try { + this.triggerAnimationsForStateChange(oldState, newState); + } catch (e) { + console.error('Animation error on round end:', e); + } + + // Build preRevealState from oldState, but mark swap position as + // already handled so reveal animation doesn't double-flip it + const preReveal = JSON.parse(JSON.stringify(oldState)); + if (this.opponentSwapAnimation) { + const { playerId, position } = this.opponentSwapAnimation; + const player = preReveal.players.find(p => p.id === playerId); + if (player?.cards[position]) { + player.cards[position].face_up = true; + } + } + + this.preRevealState = preReveal; + this.postRevealState = newState; break; } @@ -1431,8 +1449,9 @@ class GolfGame { this.swapAnimationCardEl = handCardEl; this.swapAnimationHandCardEl = handCardEl; - // Hide originals during animation + // Hide originals and UI during animation handCardEl.classList.add('swap-out'); + this.discardBtn.classList.add('hidden'); if (this.heldCardFloating) { this.heldCardFloating.style.visibility = 'hidden'; } @@ -2087,6 +2106,22 @@ class GolfGame { const knockerId = newState.finisher_id; const revealOrder = this.getRevealOrder(newState.players, knockerId); + // Wait for the last player's animation (swap/discard/draw) to finish + // so the final play is visible before the reveal sequence starts + const maxWait = 3000; + const start = Date.now(); + while (Date.now() - start < maxWait) { + if (!this.isDrawAnimating && !this.opponentSwapAnimation && + !this.opponentDiscardAnimating && !this.localDiscardAnimating && + !this.swapAnimationInProgress) { + break; + } + await this.delay(100); + } + + // Extra pause so the final play registers before reveals start + await this.delay(T.lastPlayPause || 2500); + // Initial pause this.setStatus('Revealing cards...', 'reveal'); await this.delay(T.initialPause || 500); @@ -2425,7 +2460,7 @@ class GolfGame { this.opponentSwapAnimation = null; this.opponentDiscardAnimating = false; this.isDrawAnimating = true; - console.log('[DEBUG] Opponent draw from deck - setting isDrawAnimating=true'); + console.log('[DEBUG] Opponent draw from deck - setting isDrawAnimating=true, drawnCard:', drawnCard ? `${drawnCard.rank} of ${drawnCard.suit}` : 'NULL', 'discardTop:', newDiscard ? `${newDiscard.rank} of ${newDiscard.suit}` : 'EMPTY'); window.drawAnimations.animateDrawDeck(drawnCard, () => { console.log('[DEBUG] Opponent draw from deck complete - clearing isDrawAnimating'); this.isDrawAnimating = false; @@ -2506,6 +2541,7 @@ class GolfGame { const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards); if (swappedPosition >= 0 && wasOtherPlayer) { + console.log('[DEBUG] Swap detected:', { playerId: previousPlayerId, position: swappedPosition, wasFaceUp, newDiscard: newDiscard?.rank }); // Opponent swapped - animate from the actual position that changed this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp); // Show CPU swap announcement @@ -2818,22 +2854,28 @@ class GolfGame { rotation: sourceRotation, wasHandFaceDown: !wasFaceUp, onComplete: () => { - sourceCardEl.classList.remove('swap-out'); + if (sourceCardEl) sourceCardEl.classList.remove('swap-out'); this.opponentSwapAnimation = null; this.opponentDiscardAnimating = false; console.log('[DEBUG] Swap animation complete - clearing opponentSwapAnimation and opponentDiscardAnimating'); - this.renderGame(); + // Don't re-render during reveal animation - it handles its own rendering + if (!this.revealAnimationInProgress) { + this.renderGame(); + } } } ); } else { // Fallback setTimeout(() => { - sourceCardEl.classList.remove('swap-out'); + if (sourceCardEl) sourceCardEl.classList.remove('swap-out'); this.opponentSwapAnimation = null; this.opponentDiscardAnimating = false; console.log('[DEBUG] Swap animation fallback complete - clearing flags'); - this.renderGame(); + // Don't re-render during reveal animation - it handles its own rendering + if (!this.revealAnimationInProgress) { + this.renderGame(); + } }, 500); } } diff --git a/client/card-animations.js b/client/card-animations.js index e019be5..cbb29a1 100644 --- a/client/card-animations.js +++ b/client/card-animations.js @@ -220,6 +220,7 @@ class CardAnimations { } _animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) { + console.log('[DEBUG] _animateDrawDeckCard called with cardData:', cardData ? `${cardData.rank} of ${cardData.suit}` : 'NULL'); const deckColor = this.getDeckColor(); const animCard = this.createAnimCard(deckRect, true, deckColor); animCard.dataset.animating = 'true'; // Mark as actively animating @@ -228,6 +229,9 @@ class CardAnimations { if (cardData) { this.setCardContent(animCard, cardData); + // Debug: verify what was actually set on the front face + const front = animCard.querySelector('.draw-anim-front'); + console.log('[DEBUG] Draw anim card front content:', front?.innerHTML); } this.playSound('draw-deck'); @@ -759,26 +763,28 @@ class CardAnimations { const id = 'turnPulse'; this.stopTurnPulse(element); - // Quick shake animation + // Quick shake animation - target cards only, not labels + const T = window.TIMING?.turnPulse || {}; + const cards = element.querySelectorAll(':scope > .pile-wrapper > .card, :scope > .pile-wrapper > .discard-stack'); const doShake = () => { if (!this.activeAnimations.has(id)) return; anime({ - targets: element, + targets: cards.length ? cards : element, translateX: [0, -6, 6, -4, 3, 0], - duration: 300, + duration: T.duration || 300, easing: 'easeInOutQuad' }); }; - // Delay first shake by 5 seconds, then repeat every 2 seconds + // Delay first shake, then repeat at interval const timeout = setTimeout(() => { if (!this.activeAnimations.has(id)) return; doShake(); - const interval = setInterval(doShake, 2000); + const interval = setInterval(doShake, T.interval || 3000); const entry = this.activeAnimations.get(id); if (entry) entry.interval = interval; - }, 5000); + }, T.initialDelay || 5000); this.activeAnimations.set(id, { timeout }); } diff --git a/client/timing-config.js b/client/timing-config.js index d443b6d..1868740 100644 --- a/client/timing-config.js +++ b/client/timing-config.js @@ -77,6 +77,7 @@ const TIMING = { // V3_03: Round end reveal timing reveal: { + lastPlayPause: 2500, // Pause after last play animation before reveals voluntaryWindow: 2000, // Time for players to flip their own cards initialPause: 250, // Pause before auto-reveals start cardStagger: 50, // Between cards in same hand @@ -128,6 +129,13 @@ const TIMING = { pulseDelay: 200, // Delay before card appears (pulse visible first) }, + // Turn pulse (deck shake) + turnPulse: { + initialDelay: 5000, // Delay before first shake + interval: 3000, // Time between shakes + duration: 300, // Shake animation duration + }, + // V3_17: Knock notification knock: { statusDuration: 2500, // How long the knock status message persists diff --git a/server/ai.py b/server/ai.py index b80f086..d4ceac9 100644 --- a/server/ai.py +++ b/server/ai.py @@ -1739,9 +1739,23 @@ class GolfAI: expected_hidden_total = len(face_down) * EXPECTED_HIDDEN_VALUE projected_score = visible_score + expected_hidden_total + # Hard cap: never knock with projected score > 10 + if projected_score > 10: + ai_log(f" Knock rejected: projected score {projected_score:.1f} > 10 hard cap") + return False + # Tighter threshold: range 5 to 9 based on aggression max_acceptable = 5 + int(profile.aggression * 4) + # Check opponent threat - don't knock if an opponent likely beats us + opponent_min = estimate_opponent_min_score(player, game, optimistic=False) + if opponent_min < projected_score: + # Opponent is likely beating us - penalize threshold + threat_margin = projected_score - opponent_min + max_acceptable -= int(threat_margin * 0.75) + ai_log(f" Knock threat penalty: opponent est {opponent_min}, " + f"margin {threat_margin:.1f}, threshold now {max_acceptable}") + # Exception: if all opponents are showing terrible scores, relax threshold all_opponents_bad = all( sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up) >= 25 @@ -1752,12 +1766,14 @@ class GolfAI: if projected_score <= max_acceptable: # Scale knock chance by how good the projected score is - if projected_score <= 5: - knock_chance = profile.aggression * 0.3 # Max 30% - elif projected_score <= 7: + if projected_score <= 4: + knock_chance = profile.aggression * 0.35 # Max 35% + elif projected_score <= 6: knock_chance = profile.aggression * 0.15 # Max 15% - else: - knock_chance = profile.aggression * 0.05 # Max 5% (very rare) + elif projected_score <= 8: + knock_chance = profile.aggression * 0.06 # Max 6% + else: # 9-10 + knock_chance = profile.aggression * 0.02 # Max 2% (very rare) if random.random() < knock_chance: ai_log(f" Knock early: taking the gamble! (projected {projected_score:.1f})") @@ -1966,10 +1982,8 @@ async def process_cpu_turn( await asyncio.sleep(thinking_time) ai_log(f"{cpu_player.name} done thinking, making decision") - # Check if we should try to go out early - GolfAI.should_go_out_early(cpu_player, game, profile) - # Check if we should knock early (flip all remaining cards at once) + # (Opponent threat logic consolidated into should_knock_early) if GolfAI.should_knock_early(game, cpu_player, profile): if game.knock_early(cpu_player.id): _log_cpu_action(logger, game_id, cpu_player, game,