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:
parent
06b15f002d
commit
8d5b2ee655
@ -822,11 +822,29 @@ class GolfGame {
|
|||||||
newState.phase === 'round_over';
|
newState.phase === 'round_over';
|
||||||
|
|
||||||
if (roundJustEnded && oldState) {
|
if (roundJustEnded && oldState) {
|
||||||
// Save pre-reveal state for the reveal animation
|
// Update state first so animations can read new card data
|
||||||
this.preRevealState = JSON.parse(JSON.stringify(oldState));
|
|
||||||
this.postRevealState = newState;
|
|
||||||
// Update state but DON'T render yet - reveal animation will handle it
|
|
||||||
this.gameState = newState;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1431,8 +1449,9 @@ class GolfGame {
|
|||||||
this.swapAnimationCardEl = handCardEl;
|
this.swapAnimationCardEl = handCardEl;
|
||||||
this.swapAnimationHandCardEl = handCardEl;
|
this.swapAnimationHandCardEl = handCardEl;
|
||||||
|
|
||||||
// Hide originals during animation
|
// Hide originals and UI during animation
|
||||||
handCardEl.classList.add('swap-out');
|
handCardEl.classList.add('swap-out');
|
||||||
|
this.discardBtn.classList.add('hidden');
|
||||||
if (this.heldCardFloating) {
|
if (this.heldCardFloating) {
|
||||||
this.heldCardFloating.style.visibility = 'hidden';
|
this.heldCardFloating.style.visibility = 'hidden';
|
||||||
}
|
}
|
||||||
@ -2087,6 +2106,22 @@ class GolfGame {
|
|||||||
const knockerId = newState.finisher_id;
|
const knockerId = newState.finisher_id;
|
||||||
const revealOrder = this.getRevealOrder(newState.players, knockerId);
|
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
|
// Initial pause
|
||||||
this.setStatus('Revealing cards...', 'reveal');
|
this.setStatus('Revealing cards...', 'reveal');
|
||||||
await this.delay(T.initialPause || 500);
|
await this.delay(T.initialPause || 500);
|
||||||
@ -2425,7 +2460,7 @@ class GolfGame {
|
|||||||
this.opponentSwapAnimation = null;
|
this.opponentSwapAnimation = null;
|
||||||
this.opponentDiscardAnimating = false;
|
this.opponentDiscardAnimating = false;
|
||||||
this.isDrawAnimating = true;
|
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, () => {
|
window.drawAnimations.animateDrawDeck(drawnCard, () => {
|
||||||
console.log('[DEBUG] Opponent draw from deck complete - clearing isDrawAnimating');
|
console.log('[DEBUG] Opponent draw from deck complete - clearing isDrawAnimating');
|
||||||
this.isDrawAnimating = false;
|
this.isDrawAnimating = false;
|
||||||
@ -2506,6 +2541,7 @@ class GolfGame {
|
|||||||
const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards);
|
const cardsIdentical = wasOtherPlayer && JSON.stringify(oldPlayer.cards) === JSON.stringify(newPlayer.cards);
|
||||||
|
|
||||||
if (swappedPosition >= 0 && wasOtherPlayer) {
|
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
|
// Opponent swapped - animate from the actual position that changed
|
||||||
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
|
this.fireSwapAnimation(previousPlayerId, newDiscard, swappedPosition, wasFaceUp);
|
||||||
// Show CPU swap announcement
|
// Show CPU swap announcement
|
||||||
@ -2818,22 +2854,28 @@ class GolfGame {
|
|||||||
rotation: sourceRotation,
|
rotation: sourceRotation,
|
||||||
wasHandFaceDown: !wasFaceUp,
|
wasHandFaceDown: !wasFaceUp,
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
sourceCardEl.classList.remove('swap-out');
|
if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
|
||||||
this.opponentSwapAnimation = null;
|
this.opponentSwapAnimation = null;
|
||||||
this.opponentDiscardAnimating = false;
|
this.opponentDiscardAnimating = false;
|
||||||
console.log('[DEBUG] Swap animation complete - clearing opponentSwapAnimation and opponentDiscardAnimating');
|
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 {
|
} else {
|
||||||
// Fallback
|
// Fallback
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
sourceCardEl.classList.remove('swap-out');
|
if (sourceCardEl) sourceCardEl.classList.remove('swap-out');
|
||||||
this.opponentSwapAnimation = null;
|
this.opponentSwapAnimation = null;
|
||||||
this.opponentDiscardAnimating = false;
|
this.opponentDiscardAnimating = false;
|
||||||
console.log('[DEBUG] Swap animation fallback complete - clearing flags');
|
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);
|
}, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -220,6 +220,7 @@ class CardAnimations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
|
_animateDrawDeckCard(cardData, deckRect, holdingRect, onComplete) {
|
||||||
|
console.log('[DEBUG] _animateDrawDeckCard called with cardData:', cardData ? `${cardData.rank} of ${cardData.suit}` : 'NULL');
|
||||||
const deckColor = this.getDeckColor();
|
const deckColor = this.getDeckColor();
|
||||||
const animCard = this.createAnimCard(deckRect, true, deckColor);
|
const animCard = this.createAnimCard(deckRect, true, deckColor);
|
||||||
animCard.dataset.animating = 'true'; // Mark as actively animating
|
animCard.dataset.animating = 'true'; // Mark as actively animating
|
||||||
@ -228,6 +229,9 @@ class CardAnimations {
|
|||||||
|
|
||||||
if (cardData) {
|
if (cardData) {
|
||||||
this.setCardContent(animCard, 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');
|
this.playSound('draw-deck');
|
||||||
@ -759,26 +763,28 @@ class CardAnimations {
|
|||||||
const id = 'turnPulse';
|
const id = 'turnPulse';
|
||||||
this.stopTurnPulse(element);
|
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 = () => {
|
const doShake = () => {
|
||||||
if (!this.activeAnimations.has(id)) return;
|
if (!this.activeAnimations.has(id)) return;
|
||||||
|
|
||||||
anime({
|
anime({
|
||||||
targets: element,
|
targets: cards.length ? cards : element,
|
||||||
translateX: [0, -6, 6, -4, 3, 0],
|
translateX: [0, -6, 6, -4, 3, 0],
|
||||||
duration: 300,
|
duration: T.duration || 300,
|
||||||
easing: 'easeInOutQuad'
|
easing: 'easeInOutQuad'
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delay first shake by 5 seconds, then repeat every 2 seconds
|
// Delay first shake, then repeat at interval
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (!this.activeAnimations.has(id)) return;
|
if (!this.activeAnimations.has(id)) return;
|
||||||
doShake();
|
doShake();
|
||||||
const interval = setInterval(doShake, 2000);
|
const interval = setInterval(doShake, T.interval || 3000);
|
||||||
const entry = this.activeAnimations.get(id);
|
const entry = this.activeAnimations.get(id);
|
||||||
if (entry) entry.interval = interval;
|
if (entry) entry.interval = interval;
|
||||||
}, 5000);
|
}, T.initialDelay || 5000);
|
||||||
this.activeAnimations.set(id, { timeout });
|
this.activeAnimations.set(id, { timeout });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -77,6 +77,7 @@ const TIMING = {
|
|||||||
|
|
||||||
// V3_03: Round end reveal timing
|
// V3_03: Round end reveal timing
|
||||||
reveal: {
|
reveal: {
|
||||||
|
lastPlayPause: 2500, // Pause after last play animation before reveals
|
||||||
voluntaryWindow: 2000, // Time for players to flip their own cards
|
voluntaryWindow: 2000, // Time for players to flip their own cards
|
||||||
initialPause: 250, // Pause before auto-reveals start
|
initialPause: 250, // Pause before auto-reveals start
|
||||||
cardStagger: 50, // Between cards in same hand
|
cardStagger: 50, // Between cards in same hand
|
||||||
@ -128,6 +129,13 @@ const TIMING = {
|
|||||||
pulseDelay: 200, // Delay before card appears (pulse visible first)
|
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
|
// V3_17: Knock notification
|
||||||
knock: {
|
knock: {
|
||||||
statusDuration: 2500, // How long the knock status message persists
|
statusDuration: 2500, // How long the knock status message persists
|
||||||
|
|||||||
30
server/ai.py
30
server/ai.py
@ -1739,9 +1739,23 @@ class GolfAI:
|
|||||||
expected_hidden_total = len(face_down) * EXPECTED_HIDDEN_VALUE
|
expected_hidden_total = len(face_down) * EXPECTED_HIDDEN_VALUE
|
||||||
projected_score = visible_score + expected_hidden_total
|
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
|
# Tighter threshold: range 5 to 9 based on aggression
|
||||||
max_acceptable = 5 + int(profile.aggression * 4)
|
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
|
# Exception: if all opponents are showing terrible scores, relax threshold
|
||||||
all_opponents_bad = all(
|
all_opponents_bad = all(
|
||||||
sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up) >= 25
|
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:
|
if projected_score <= max_acceptable:
|
||||||
# Scale knock chance by how good the projected score is
|
# Scale knock chance by how good the projected score is
|
||||||
if projected_score <= 5:
|
if projected_score <= 4:
|
||||||
knock_chance = profile.aggression * 0.3 # Max 30%
|
knock_chance = profile.aggression * 0.35 # Max 35%
|
||||||
elif projected_score <= 7:
|
elif projected_score <= 6:
|
||||||
knock_chance = profile.aggression * 0.15 # Max 15%
|
knock_chance = profile.aggression * 0.15 # Max 15%
|
||||||
else:
|
elif projected_score <= 8:
|
||||||
knock_chance = profile.aggression * 0.05 # Max 5% (very rare)
|
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:
|
if random.random() < knock_chance:
|
||||||
ai_log(f" Knock early: taking the gamble! (projected {projected_score:.1f})")
|
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)
|
await asyncio.sleep(thinking_time)
|
||||||
ai_log(f"{cpu_player.name} done thinking, making decision")
|
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)
|
# 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 GolfAI.should_knock_early(game, cpu_player, profile):
|
||||||
if game.knock_early(cpu_player.id):
|
if game.knock_early(cpu_player.id):
|
||||||
_log_cpu_action(logger, game_id, cpu_player, game,
|
_log_cpu_action(logger, game_id, cpu_player, game,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user