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 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-02-23 19:07:57 -05:00
parent 06b15f002d
commit 8d5b2ee655
4 changed files with 94 additions and 24 deletions

View File

@@ -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);
}
}

View File

@@ -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 });
}

View File

@@ -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