Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21985b7e9b | ||
|
|
56305424ff | ||
|
|
0bfe9d5f9f | ||
|
|
a0bb28d5eb | ||
|
|
55006d6ff4 | ||
|
|
adcc59b6fc | ||
|
|
7e0c006f5e | ||
|
|
02f9b3c44d | ||
|
|
9f75cdb0dc | ||
|
|
519d08a2a6 | ||
|
|
9419cb562e | ||
|
|
17c8e574ab | ||
|
|
94edb685a7 | ||
|
|
6b7d6c459e | ||
|
|
1de282afc2 | ||
|
|
9b0a8295eb | ||
|
|
28a0f90374 | ||
|
|
0df451aa99 | ||
|
|
8d7b024525 | ||
|
|
9c08b4735a | ||
|
|
49916e6a6c | ||
|
|
e0641de449 | ||
|
|
e2a90c0f34 | ||
|
|
86f5222746 | ||
|
|
60997e8ad4 | ||
|
|
3e133b17c0 | ||
|
|
9866fb8e92 | ||
|
|
4a5cfb68f1 | ||
|
|
ebb00f613c | ||
|
|
98aa0823ed | ||
|
|
4a3d62e26e | ||
|
|
d958258066 | ||
|
|
26bc151458 | ||
|
|
0d5c0c613d | ||
|
|
e9692de6c6 | ||
|
|
3414bfad1a | ||
|
|
ecad259db2 | ||
|
|
932e9ca4ef | ||
|
|
10825e8b82 | ||
|
|
53abde53ac | ||
|
|
d7ba3154a1 | ||
|
|
197595fc4d | ||
|
|
e38d8c1561 | ||
|
|
afb4869b21 | ||
|
|
c6769f9257 | ||
|
|
8657a0501f | ||
|
|
730ba9c462 | ||
|
|
1ba80606a7 | ||
|
|
3261e6ee26 | ||
|
|
de3495635b | ||
|
|
4c23f2b4a9 | ||
|
|
7b071afdfb | ||
|
|
c7fb85d281 | ||
|
|
118912dd13 | ||
|
|
0e594a5e28 | ||
|
|
a6ec72d72c | ||
|
|
e2f353d4ab | ||
|
|
e601eb04c9 | ||
|
|
6c771810f7 | ||
|
|
dbad7037d1 | ||
|
|
21362ba125 | ||
|
|
2dcdaf2b49 | ||
|
|
1fa13bbe3b | ||
|
|
a76fd8da32 | ||
|
|
634d101f2c | ||
|
|
28c9882b17 |
108
client/app.js
108
client/app.js
@@ -950,7 +950,7 @@ class GolfGame {
|
||||
// Host ended the game or player was kicked
|
||||
this._intentionalClose = true;
|
||||
if (this.ws) this.ws.close();
|
||||
this.showScreen('lobby');
|
||||
this.showLobby();
|
||||
if (data.reason) {
|
||||
this.showError(data.reason);
|
||||
}
|
||||
@@ -975,7 +975,7 @@ class GolfGame {
|
||||
|
||||
case 'queue_left':
|
||||
this.stopMatchmakingTimer();
|
||||
this.showScreen('lobby');
|
||||
this.showLobby();
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
@@ -995,7 +995,7 @@ class GolfGame {
|
||||
cancelMatchmaking() {
|
||||
this.send({ type: 'queue_leave' });
|
||||
this.stopMatchmakingTimer();
|
||||
this.showScreen('lobby');
|
||||
this.showLobby();
|
||||
}
|
||||
|
||||
startMatchmakingTimer() {
|
||||
@@ -1573,8 +1573,10 @@ class GolfGame {
|
||||
this.heldCardFloating.classList.add('hidden');
|
||||
|
||||
if (this.pendingGameState) {
|
||||
const oldState = this.gameState;
|
||||
this.gameState = this.pendingGameState;
|
||||
this.pendingGameState = null;
|
||||
this.checkForNewPairs(oldState, this.gameState);
|
||||
this.renderGame();
|
||||
}
|
||||
}
|
||||
@@ -1789,6 +1791,10 @@ class GolfGame {
|
||||
document.body.appendChild(modal);
|
||||
this.setStatus('Hole complete');
|
||||
|
||||
// Hide bottom bar so it doesn't overlay the modal
|
||||
const bottomBar = document.getElementById('mobile-bottom-bar');
|
||||
if (bottomBar) bottomBar.classList.add('hidden');
|
||||
|
||||
// Bind next button
|
||||
const nextBtn = document.getElementById('ss-next-btn');
|
||||
nextBtn.addEventListener('click', () => {
|
||||
@@ -1918,6 +1924,10 @@ class GolfGame {
|
||||
this.clearScoresheetCountdown();
|
||||
const modal = document.getElementById('scoresheet-modal');
|
||||
if (modal) modal.remove();
|
||||
|
||||
// Restore bottom bar
|
||||
const bottomBar = document.getElementById('mobile-bottom-bar');
|
||||
if (bottomBar) bottomBar.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// --- V3_02: Dealing Animation ---
|
||||
@@ -2599,6 +2609,7 @@ class GolfGame {
|
||||
}
|
||||
|
||||
firePairCelebration(playerId, pos1, pos2) {
|
||||
this.playSound('pair');
|
||||
const elements = this.getCardElements(playerId, pos1, pos2);
|
||||
if (elements.length < 2) return;
|
||||
|
||||
@@ -2796,16 +2807,7 @@ class GolfGame {
|
||||
|
||||
// Use unified swap animation
|
||||
if (window.cardAnimations) {
|
||||
// For opponent swaps, size the held card to match the opponent card
|
||||
// rather than the deck size (default holding rect uses deck dimensions,
|
||||
// which looks oversized next to small opponent cards on mobile)
|
||||
const holdingRect = window.cardAnimations.getHoldingRect();
|
||||
const heldRect = holdingRect ? {
|
||||
left: holdingRect.left,
|
||||
top: holdingRect.top,
|
||||
width: sourceRect.width,
|
||||
height: sourceRect.height
|
||||
} : null;
|
||||
const heldRect = window.cardAnimations.getHoldingRect();
|
||||
|
||||
window.cardAnimations.animateUnifiedSwap(
|
||||
discardCard, // handCardData - card going to discard
|
||||
@@ -3055,6 +3057,14 @@ class GolfGame {
|
||||
}
|
||||
|
||||
showLobby() {
|
||||
if (window.cardAnimations) {
|
||||
window.cardAnimations.cancelAll();
|
||||
}
|
||||
this.dealAnimationInProgress = false;
|
||||
this.isDrawAnimating = false;
|
||||
this.localDiscardAnimating = false;
|
||||
this.opponentDiscardAnimating = false;
|
||||
this.opponentSwapAnimation = false;
|
||||
this.showScreen(this.lobbyScreen);
|
||||
this.lobbyError.textContent = '';
|
||||
this.roomCode = null;
|
||||
@@ -3127,6 +3137,24 @@ class GolfGame {
|
||||
`<span class="rule-tag rule-more" title="${tooltip}">+${moreCount} more</span>`;
|
||||
}
|
||||
this.activeRulesBar.classList.remove('hidden');
|
||||
|
||||
// Update mobile rules indicator
|
||||
const mobileRulesBtn = document.getElementById('mobile-rules-btn');
|
||||
const mobileRulesIcon = document.getElementById('mobile-rules-icon');
|
||||
const mobileRulesContent = document.getElementById('mobile-rules-content');
|
||||
if (mobileRulesBtn && mobileRulesIcon && mobileRulesContent) {
|
||||
const isHouseRules = rules.length > 0;
|
||||
mobileRulesIcon.textContent = isHouseRules ? '!' : 'RULES';
|
||||
mobileRulesBtn.classList.toggle('house-rules', isHouseRules);
|
||||
|
||||
if (!isHouseRules) {
|
||||
mobileRulesContent.innerHTML = '<div class="mobile-rules-content-list"><span class="rule-tag standard">Standard Rules</span></div>';
|
||||
} else {
|
||||
const tagHtml = (unrankedTag ? '<span class="rule-tag unranked">Unranked</span>' : '') +
|
||||
rules.map(renderTag).join('');
|
||||
mobileRulesContent.innerHTML = `<div class="mobile-rules-content-list">${tagHtml}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// V3_14: Map display names to rule keys
|
||||
@@ -3438,15 +3466,6 @@ class GolfGame {
|
||||
// Toggle game area class for border pulse
|
||||
this.gameScreen.classList.add('final-turn-active');
|
||||
|
||||
// Calculate remaining turns
|
||||
const remaining = this.countRemainingTurns();
|
||||
|
||||
// Update badge content
|
||||
const remainingEl = this.finalTurnBadge.querySelector('.final-turn-remaining');
|
||||
if (remainingEl) {
|
||||
remainingEl.textContent = remaining === 1 ? '1 turn left' : `${remaining} turns left`;
|
||||
}
|
||||
|
||||
// Show badge
|
||||
this.finalTurnBadge.classList.remove('hidden');
|
||||
|
||||
@@ -3542,7 +3561,9 @@ class GolfGame {
|
||||
const cardHeight = deckRect.height;
|
||||
|
||||
// Position card centered, overlapping both piles (lower than before)
|
||||
const overlapOffset = cardHeight * 0.35; // More overlap = lower position
|
||||
// On mobile portrait, place held card fully above the deck/discard area
|
||||
const isMobilePortrait = document.body.classList.contains('mobile-portrait');
|
||||
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
|
||||
const cardLeft = centerX - cardWidth / 2;
|
||||
const cardTop = deckRect.top - overlapOffset;
|
||||
this.heldCardFloating.style.left = `${cardLeft}px`;
|
||||
@@ -3554,11 +3575,21 @@ class GolfGame {
|
||||
this.heldCardFloating.style.fontSize = `${cardWidth * 0.35}px`;
|
||||
}
|
||||
|
||||
// Position discard button attached to right side of held card
|
||||
const buttonLeft = cardLeft + cardWidth; // Right edge of card (no gap)
|
||||
const buttonTop = cardTop + cardHeight * 0.3; // Vertically centered on card
|
||||
this.discardBtn.style.left = `${buttonLeft}px`;
|
||||
this.discardBtn.style.top = `${buttonTop}px`;
|
||||
// Position discard button
|
||||
if (isMobilePortrait) {
|
||||
// Below the held card, centered
|
||||
const btnRect = this.discardBtn.getBoundingClientRect();
|
||||
const buttonLeft = cardLeft + (cardWidth - (btnRect.width || 70)) / 2;
|
||||
const buttonTop = cardTop + cardHeight + 4;
|
||||
this.discardBtn.style.left = `${buttonLeft}px`;
|
||||
this.discardBtn.style.top = `${buttonTop}px`;
|
||||
} else {
|
||||
// Right side of held card (desktop)
|
||||
const buttonLeft = cardLeft + cardWidth;
|
||||
const buttonTop = cardTop + cardHeight * 0.3;
|
||||
this.discardBtn.style.left = `${buttonLeft}px`;
|
||||
this.discardBtn.style.top = `${buttonTop}px`;
|
||||
}
|
||||
|
||||
if (card.rank === '★') {
|
||||
this.heldCardFloating.classList.add('joker');
|
||||
@@ -3604,7 +3635,8 @@ class GolfGame {
|
||||
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
|
||||
const cardWidth = deckRect.width;
|
||||
const cardHeight = deckRect.height;
|
||||
const overlapOffset = cardHeight * 0.35;
|
||||
const isMobilePortrait = document.body.classList.contains('mobile-portrait');
|
||||
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
|
||||
const cardLeft = centerX - cardWidth / 2;
|
||||
const cardTop = deckRect.top - overlapOffset;
|
||||
|
||||
@@ -3776,14 +3808,19 @@ class GolfGame {
|
||||
if (mobileTotal) mobileTotal.textContent = this.gameState.total_rounds;
|
||||
|
||||
// Show/hide final turn badge with enhanced urgency
|
||||
// Note: markKnocker() is deferred until after opponent areas are rebuilt below
|
||||
const isFinalTurn = this.gameState.phase === 'final_turn';
|
||||
if (isFinalTurn) {
|
||||
this.updateFinalTurnDisplay();
|
||||
this.gameScreen.classList.add('final-turn-active');
|
||||
this.finalTurnBadge.classList.remove('hidden');
|
||||
if (!this.finalTurnAnnounced) {
|
||||
this.playSound('alert');
|
||||
this.finalTurnAnnounced = true;
|
||||
}
|
||||
} else {
|
||||
this.finalTurnBadge.classList.add('hidden');
|
||||
this.gameScreen.classList.remove('final-turn-active');
|
||||
this.finalTurnAnnounced = false;
|
||||
this.clearKnockerMark();
|
||||
}
|
||||
|
||||
// Toggle not-my-turn class to disable hover effects when it's not player's turn
|
||||
@@ -3805,7 +3842,7 @@ class GolfGame {
|
||||
: this.gameState.current_player_id;
|
||||
const displayedPlayer = this.gameState.players.find(p => p.id === displayedPlayerId);
|
||||
if (displayedPlayer && displayedPlayerId !== this.playerId) {
|
||||
this.setStatus(`${displayedPlayer.name}'s turn`);
|
||||
this.setStatus(`${displayedPlayer.name}'s turn`, 'opponent-turn');
|
||||
}
|
||||
|
||||
// Update player header (name + score like opponents)
|
||||
@@ -4149,6 +4186,13 @@ class GolfGame {
|
||||
// Update scoreboard panel
|
||||
this.updateScorePanel();
|
||||
|
||||
// Mark knocker AFTER opponent areas are rebuilt (otherwise innerHTML='' wipes it)
|
||||
if (this.gameState.phase === 'final_turn') {
|
||||
this.markKnocker(this.gameState.finisher_id);
|
||||
} else {
|
||||
this.clearKnockerMark();
|
||||
}
|
||||
|
||||
// Initialize anime.js hover listeners on newly created cards
|
||||
if (window.cardAnimations) {
|
||||
window.cardAnimations.initHoverListeners(this.playerCards);
|
||||
|
||||
@@ -46,7 +46,8 @@ class CardAnimations {
|
||||
const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4;
|
||||
const cardWidth = deckRect.width;
|
||||
const cardHeight = deckRect.height;
|
||||
const overlapOffset = cardHeight * 0.35;
|
||||
const isMobilePortrait = document.body.classList.contains('mobile-portrait');
|
||||
const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35);
|
||||
|
||||
return {
|
||||
left: centerX - cardWidth / 2,
|
||||
@@ -155,12 +156,20 @@ class CardAnimations {
|
||||
}
|
||||
this.activeAnimations.clear();
|
||||
|
||||
// Remove all animation card elements (including those marked as animating)
|
||||
document.querySelectorAll('.draw-anim-card').forEach(el => {
|
||||
// Remove all animation overlay elements
|
||||
document.querySelectorAll('.draw-anim-card, .traveling-card, .deal-anim-container').forEach(el => {
|
||||
delete el.dataset.animating;
|
||||
el.remove();
|
||||
});
|
||||
|
||||
// Restore visibility on any cards hidden during animations
|
||||
document.querySelectorAll('.card[style*="opacity: 0"], .card[style*="opacity:0"]').forEach(el => {
|
||||
el.style.opacity = '';
|
||||
});
|
||||
document.querySelectorAll('.card[style*="visibility: hidden"], .card[style*="visibility:hidden"]').forEach(el => {
|
||||
el.style.visibility = '';
|
||||
});
|
||||
|
||||
// Restore discard pile visibility if it was hidden during animation
|
||||
const discardPile = document.getElementById('discard');
|
||||
if (discardPile && discardPile.style.opacity === '0') {
|
||||
@@ -756,22 +765,28 @@ class CardAnimations {
|
||||
|
||||
anime({
|
||||
targets: element,
|
||||
translateX: [0, -8, 8, -6, 4, 0],
|
||||
duration: 400,
|
||||
translateX: [0, -6, 6, -4, 3, 0],
|
||||
duration: 300,
|
||||
easing: 'easeInOutQuad'
|
||||
});
|
||||
};
|
||||
|
||||
// Do initial shake, then repeat every 3 seconds
|
||||
doShake();
|
||||
const interval = setInterval(doShake, 3000);
|
||||
this.activeAnimations.set(id, { interval });
|
||||
// Delay first shake by 5 seconds, then repeat every 2 seconds
|
||||
const timeout = setTimeout(() => {
|
||||
if (!this.activeAnimations.has(id)) return;
|
||||
doShake();
|
||||
const interval = setInterval(doShake, 2000);
|
||||
const entry = this.activeAnimations.get(id);
|
||||
if (entry) entry.interval = interval;
|
||||
}, 5000);
|
||||
this.activeAnimations.set(id, { timeout });
|
||||
}
|
||||
|
||||
stopTurnPulse(element) {
|
||||
const id = 'turnPulse';
|
||||
const existing = this.activeAnimations.get(id);
|
||||
if (existing) {
|
||||
if (existing.timeout) clearTimeout(existing.timeout);
|
||||
if (existing.interval) clearInterval(existing.interval);
|
||||
if (existing.pause) existing.pause();
|
||||
this.activeAnimations.delete(id);
|
||||
@@ -1515,6 +1530,7 @@ class CardAnimations {
|
||||
|
||||
// Create container for animation cards
|
||||
const container = document.createElement('div');
|
||||
container.className = 'deal-anim-container';
|
||||
container.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1000;';
|
||||
document.body.appendChild(container);
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
<h1><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-swing">🏌️</span><span class="kicked-ball">⚪</span> <span class="golf-title">Golf</span></h1>
|
||||
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
|
||||
|
||||
<div class="alpha-banner">Alpha — Things may break. Stats may be wiped.</div>
|
||||
|
||||
<!-- Auth prompt for unauthenticated users -->
|
||||
<div id="auth-prompt" class="auth-prompt">
|
||||
<p>Log in or sign up to play.</p>
|
||||
@@ -303,7 +305,6 @@
|
||||
<div id="final-turn-badge" class="final-turn-badge hidden">
|
||||
<span class="final-turn-icon">⚡</span>
|
||||
<span class="final-turn-text">FINAL TURN</span>
|
||||
<span class="final-turn-remaining"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-col header-col-right">
|
||||
@@ -327,18 +328,24 @@
|
||||
</div>
|
||||
<span class="held-label">Holding</span>
|
||||
</div>
|
||||
<div id="deck" class="card card-back"></div>
|
||||
<div class="discard-stack">
|
||||
<div id="discard" class="card">
|
||||
<span id="discard-content"></span>
|
||||
<div class="pile-wrapper">
|
||||
<span class="pile-label">DRAW</span>
|
||||
<div id="deck" class="card card-back"></div>
|
||||
</div>
|
||||
<div class="pile-wrapper">
|
||||
<span class="pile-label">DISCARD</span>
|
||||
<div class="discard-stack">
|
||||
<div id="discard" class="card">
|
||||
<span id="discard-content"></span>
|
||||
</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="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>
|
||||
</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="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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,16 +404,23 @@
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Mobile bottom bar (hidden on desktop) -->
|
||||
<div id="mobile-bottom-bar">
|
||||
<div class="mobile-round-info">Hole <span id="mobile-current-round">1</span>/<span id="mobile-total-rounds">9</span></div>
|
||||
<button class="mobile-bar-btn" data-drawer="standings-panel">Standings</button>
|
||||
<button class="mobile-bar-btn" data-drawer="scoreboard">Scores</button>
|
||||
<button class="mobile-bar-btn mobile-rules-btn" id="mobile-rules-btn" data-drawer="rules-drawer"><span id="mobile-rules-icon">RULES</span></button>
|
||||
<button class="mobile-bar-btn" data-drawer="standings-panel">Scorecard</button>
|
||||
<button id="mobile-leave-btn" class="mobile-bar-btn mobile-leave-btn">End Game</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile rules drawer -->
|
||||
<div id="rules-drawer" class="side-panel rules-drawer-panel">
|
||||
<h4>Active Rules</h4>
|
||||
<div id="mobile-rules-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- Drawer backdrop for mobile -->
|
||||
<div id="drawer-backdrop" class="drawer-backdrop"></div>
|
||||
</div>
|
||||
@@ -414,9 +428,8 @@
|
||||
<!-- Rules Screen -->
|
||||
<div id="rules-screen" class="screen">
|
||||
<div class="rules-container">
|
||||
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
|
||||
|
||||
<div class="rules-header">
|
||||
<button id="rules-back-btn" class="btn rules-back-btn">« Back</button>
|
||||
<h1><span class="golfer-logo">🏌️</span> <span class="golf-title">Golf Rules</span></h1>
|
||||
<p class="rules-subtitle">6-Card Golf Card Game - Complete Guide</p>
|
||||
</div>
|
||||
@@ -732,9 +745,8 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
||||
<!-- Leaderboard Screen -->
|
||||
<div id="leaderboard-screen" class="screen">
|
||||
<div class="leaderboard-container">
|
||||
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">« Back</button>
|
||||
|
||||
<div class="leaderboard-header">
|
||||
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">« Back</button>
|
||||
<h1>Leaderboard</h1>
|
||||
<p class="leaderboard-subtitle">Top players ranked by performance</p>
|
||||
</div>
|
||||
|
||||
292
client/style.css
292
client/style.css
@@ -445,7 +445,21 @@ h1 {
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 40px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.alpha-banner {
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 200, 100, 0.9);
|
||||
background: rgba(244, 164, 96, 0.1);
|
||||
border: 1px solid rgba(244, 164, 96, 0.25);
|
||||
border-radius: 20px;
|
||||
padding: 5px 16px;
|
||||
margin: 0 auto 30px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@@ -722,8 +736,8 @@ input::placeholder {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
background: rgba(0,0,0,0.35);
|
||||
padding: 6px 12px;
|
||||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.25) 0%, transparent 100%);
|
||||
font-size: 0.9rem;
|
||||
width: 100vw;
|
||||
margin-left: calc(-50vw + 50%);
|
||||
@@ -1110,6 +1124,20 @@ input::placeholder {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.pile-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pile-label {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
/* Gentle pulse when it's your turn to draw - handled by anime.js */
|
||||
/* The .your-turn-to-draw class triggers anime.js startTurnPulse() */
|
||||
|
||||
@@ -1640,7 +1668,7 @@ input::placeholder {
|
||||
}
|
||||
|
||||
.status-message.your-turn {
|
||||
background: linear-gradient(135deg, #b5d484 0%, #9ab973 100%);
|
||||
background: linear-gradient(135deg, #c8e6a0 0%, #8fbf5a 100%);
|
||||
color: #2d3436;
|
||||
}
|
||||
|
||||
@@ -1716,7 +1744,7 @@ input::placeholder {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.game-layout {
|
||||
@@ -2784,26 +2812,63 @@ input::placeholder {
|
||||
/* Mobile adjustments for final results modal */
|
||||
@media (max-width: 500px) {
|
||||
.final-results-content {
|
||||
padding: 20px 25px;
|
||||
padding: 15px 12px;
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.final-results-content h2 {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.double-victory-banner {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.final-rankings {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.final-ranking-section {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.final-ranking-section h3 {
|
||||
font-size: 0.7rem;
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.final-rank-row {
|
||||
font-size: 0.8rem;
|
||||
padding: 4px 5px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.final-rank-row .rank-pos {
|
||||
width: 22px;
|
||||
font-size: 0.9rem;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.final-rank-row .rank-val {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.final-actions {
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.final-actions .btn {
|
||||
min-width: 120px;
|
||||
padding: 12px 20px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2840,12 +2905,15 @@ input::placeholder {
|
||||
|
||||
/* Rules back button */
|
||||
.rules-back-btn {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: auto;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.8rem;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.rules-back-btn:hover {
|
||||
@@ -2861,6 +2929,7 @@ input::placeholder {
|
||||
|
||||
/* Rules header */
|
||||
.rules-header {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin-bottom: 25px;
|
||||
padding-bottom: 20px;
|
||||
@@ -3275,7 +3344,11 @@ input::placeholder {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide global auth-bar when game screen is active */
|
||||
/* Hide global auth-bar and remove top padding when game screen is active */
|
||||
#app:has(#game-screen.active) {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
#app:has(#game-screen.active) > .auth-bar {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -3497,11 +3570,15 @@ input::placeholder {
|
||||
border-radius: 12px;
|
||||
padding: 20px 25px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.leaderboard-header {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid rgba(244, 164, 96, 0.3);
|
||||
}
|
||||
|
||||
.leaderboard-header h1 {
|
||||
@@ -3518,12 +3595,15 @@ input::placeholder {
|
||||
|
||||
/* Leaderboard back button */
|
||||
.leaderboard-back-btn {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: auto;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.8rem;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.leaderboard-back-btn:hover {
|
||||
@@ -4292,6 +4372,15 @@ input::placeholder {
|
||||
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.player-area .dealer-chip {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
font-size: 18px;
|
||||
border-width: 2px;
|
||||
bottom: -22px;
|
||||
left: -22px;
|
||||
}
|
||||
|
||||
/* --- V3_03: Round End Reveal --- */
|
||||
.reveal-prompt {
|
||||
position: fixed;
|
||||
@@ -4373,6 +4462,13 @@ input::placeholder {
|
||||
.opponent-area.is-knocker {
|
||||
border: 2px solid #ff6b35;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 12px 3px rgba(255, 107, 53, 0.4);
|
||||
animation: knocker-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes knocker-glow {
|
||||
0%, 100% { box-shadow: 0 0 12px 3px rgba(255, 107, 53, 0.4); }
|
||||
50% { box-shadow: 0 0 20px 6px rgba(255, 107, 53, 0.6); }
|
||||
}
|
||||
|
||||
.knocker-badge {
|
||||
@@ -4926,6 +5022,7 @@ body.mobile-portrait #game-screen.active {
|
||||
overflow: hidden;
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -4952,14 +5049,15 @@ body.mobile-portrait .game-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
padding-top: calc(4px + env(safe-area-inset-top, 0px));
|
||||
padding: 6px 8px;
|
||||
padding-top: calc(6px + env(safe-area-inset-top, 0px));
|
||||
font-size: 0.75rem;
|
||||
min-height: 32px;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.25) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
body.mobile-portrait .header-col-left {
|
||||
@@ -4986,6 +5084,12 @@ body.mobile-portrait .game-header #leave-game-btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.mobile-portrait .rules-container,
|
||||
body.mobile-portrait .leaderboard-container,
|
||||
body.mobile-portrait #matchmaking-screen {
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
body.mobile-portrait .status-message {
|
||||
font-size: 1.02rem;
|
||||
white-space: nowrap;
|
||||
@@ -5005,7 +5109,7 @@ body.mobile-portrait #leave-game-btn {
|
||||
}
|
||||
|
||||
body.mobile-portrait .mute-btn {
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.95rem;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
@@ -5023,8 +5127,9 @@ body.mobile-portrait .game-table {
|
||||
justify-content: flex-start;
|
||||
gap: 0 !important;
|
||||
flex: 1 1 0%;
|
||||
overflow: hidden;
|
||||
padding: 0 4px;
|
||||
overflow-x: clip;
|
||||
overflow-y: hidden;
|
||||
padding: 0 10px;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
@@ -5035,10 +5140,10 @@ body.mobile-portrait .opponents-row {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: 4px 10px;
|
||||
gap: 9px 10px;
|
||||
min-height: 0 !important;
|
||||
padding: 2px 4px 6px;
|
||||
overflow: hidden;
|
||||
padding: 2px 4px 12px;
|
||||
overflow: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -5047,7 +5152,7 @@ body.mobile-portrait .player-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: space-evenly;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
flex: 1 1 0%;
|
||||
@@ -5103,6 +5208,11 @@ body.mobile-portrait .opponent-area .card {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
body.mobile-portrait .opponent-area .knocker-badge {
|
||||
top: auto;
|
||||
bottom: -10px;
|
||||
}
|
||||
|
||||
body.mobile-portrait .opponent-showing {
|
||||
font-size: 0.85rem;
|
||||
padding: 0px 3px;
|
||||
@@ -5111,9 +5221,8 @@ body.mobile-portrait .opponent-showing {
|
||||
|
||||
/* --- Mobile: Deck/Discard area centered --- */
|
||||
body.mobile-portrait .table-center {
|
||||
padding: 5px 10px;
|
||||
padding: 20px 10px 5px;
|
||||
border-radius: 8px;
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
body.mobile-portrait .deck-area {
|
||||
@@ -5121,7 +5230,7 @@ body.mobile-portrait .deck-area {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
body.mobile-portrait .deck-area > .card,
|
||||
body.mobile-portrait .deck-area .card,
|
||||
body.mobile-portrait #deck,
|
||||
body.mobile-portrait #discard {
|
||||
width: 64px !important;
|
||||
@@ -5144,7 +5253,8 @@ body.mobile-portrait #discard-btn {
|
||||
position: fixed;
|
||||
writing-mode: horizontal-tb;
|
||||
text-orientation: initial;
|
||||
padding: 8px 16px;
|
||||
width: auto;
|
||||
padding: 6px 18px;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -5168,8 +5278,8 @@ body.mobile-portrait .player-area .dealer-chip {
|
||||
height: 22px;
|
||||
font-size: 11px;
|
||||
border-width: 2px;
|
||||
bottom: auto;
|
||||
top: -8px;
|
||||
top: auto;
|
||||
bottom: -8px;
|
||||
left: -8px;
|
||||
}
|
||||
|
||||
@@ -5291,8 +5401,9 @@ body.mobile-portrait .game-buttons {
|
||||
/* --- Mobile: Bottom bar --- */
|
||||
body.mobile-portrait #mobile-bottom-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
gap: 8px;
|
||||
background: none;
|
||||
flex-shrink: 0;
|
||||
@@ -5301,7 +5412,7 @@ body.mobile-portrait #mobile-bottom-bar {
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
/* Hole indicator — pinned left with pill background */
|
||||
/* Hole indicator — pinned left */
|
||||
body.mobile-portrait #mobile-bottom-bar .mobile-round-info {
|
||||
margin-right: auto;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
@@ -5354,6 +5465,59 @@ body.mobile-portrait #mobile-bottom-bar .mobile-bar-btn.active {
|
||||
box-shadow: 0 2px 12px rgba(244, 164, 96, 0.4);
|
||||
}
|
||||
|
||||
/* --- Mobile: Rules indicator button --- */
|
||||
body.mobile-portrait #mobile-bottom-bar .mobile-rules-btn {
|
||||
padding: 4px 9px;
|
||||
font-size: 0.77rem;
|
||||
font-weight: 700;
|
||||
min-width: unset;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
body.mobile-portrait #mobile-bottom-bar .mobile-rules-btn.house-rules {
|
||||
background: rgba(244, 164, 96, 0.25);
|
||||
border-color: rgba(244, 164, 96, 0.4);
|
||||
color: #f4a460;
|
||||
}
|
||||
|
||||
/* --- Mobile: Rules drawer content --- */
|
||||
#rules-drawer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-portrait #rules-drawer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
body.mobile-portrait .rules-drawer-panel .mobile-rules-content-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
body.mobile-portrait .rules-drawer-panel .rule-tag {
|
||||
background: rgba(244, 164, 96, 0.3);
|
||||
color: #f4a460;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
body.mobile-portrait .rules-drawer-panel .rule-tag.standard {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
body.mobile-portrait .rules-drawer-panel .rule-tag.unranked {
|
||||
background: rgba(220, 80, 80, 0.3);
|
||||
color: #f08080;
|
||||
border: 1px solid rgba(220, 80, 80, 0.4);
|
||||
}
|
||||
|
||||
/* --- Mobile: Non-game screens --- */
|
||||
body.mobile-portrait #lobby-screen {
|
||||
padding: 55px 12px 15px;
|
||||
@@ -5367,6 +5531,66 @@ body.mobile-portrait #waiting-screen {
|
||||
max-height: 100dvh;
|
||||
}
|
||||
|
||||
/* --- Mobile: Compact scoresheet modal --- */
|
||||
body.mobile-portrait .scoresheet-content {
|
||||
padding: 14px 16px;
|
||||
max-height: 90vh;
|
||||
max-height: var(--app-height, 90vh);
|
||||
}
|
||||
|
||||
body.mobile-portrait .ss-header {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
body.mobile-portrait .ss-players {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
body.mobile-portrait .ss-player-row {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
body.mobile-portrait .ss-player-header {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
body.mobile-portrait .ss-player-name {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
body.mobile-portrait .ss-mini-card {
|
||||
width: 30px;
|
||||
height: 22px;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
body.mobile-portrait .ss-columns {
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
body.mobile-portrait .ss-column {
|
||||
gap: 2px;
|
||||
padding: 3px 4px;
|
||||
}
|
||||
|
||||
body.mobile-portrait .ss-col-score {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
body.mobile-portrait .ss-scores {
|
||||
font-size: 0.7rem;
|
||||
gap: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
body.mobile-portrait .ss-next-btn {
|
||||
margin-top: 10px;
|
||||
padding: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* --- Mobile: Very short screens (e.g. iPhone SE) --- */
|
||||
@media (max-height: 600px) {
|
||||
body.mobile-portrait .opponents-row {
|
||||
@@ -5389,7 +5613,7 @@ body.mobile-portrait #waiting-screen {
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
body.mobile-portrait .deck-area > .card,
|
||||
body.mobile-portrait .deck-area .card,
|
||||
body.mobile-portrait #deck,
|
||||
body.mobile-portrait #discard {
|
||||
width: 60px !important;
|
||||
|
||||
@@ -61,6 +61,15 @@ services:
|
||||
- "traefik.http.routers.golf.entrypoints=websecure"
|
||||
- "traefik.http.routers.golf.tls=true"
|
||||
- "traefik.http.routers.golf.tls.certresolver=letsencrypt"
|
||||
# www -> bare domain redirect
|
||||
- "traefik.http.routers.golf-www.rule=Host(`www.${DOMAIN:-golf.example.com}`)"
|
||||
- "traefik.http.routers.golf-www.entrypoints=websecure"
|
||||
- "traefik.http.routers.golf-www.tls=true"
|
||||
- "traefik.http.routers.golf-www.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.golf-www.middlewares=www-redirect"
|
||||
- "traefik.http.middlewares.www-redirect.redirectregex.regex=^https://www\\.(.+)"
|
||||
- "traefik.http.middlewares.www-redirect.redirectregex.replacement=https://$${1}"
|
||||
- "traefik.http.middlewares.www-redirect.redirectregex.permanent=true"
|
||||
- "traefik.http.services.golf.loadbalancer.server.port=8000"
|
||||
# WebSocket sticky sessions
|
||||
- "traefik.http.services.golf.loadbalancer.sticky.cookie=true"
|
||||
|
||||
@@ -1934,7 +1934,11 @@ def _log_cpu_action(logger, game_id: Optional[str], cpu_player: Player, game: Ga
|
||||
async def process_cpu_turn(
|
||||
game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None
|
||||
) -> None:
|
||||
"""Process a complete turn for a CPU player."""
|
||||
"""Process a complete turn for a CPU player.
|
||||
|
||||
May raise asyncio.CancelledError if the game is ended mid-turn.
|
||||
The caller (check_and_run_cpu_turn) handles cancellation.
|
||||
"""
|
||||
import asyncio
|
||||
from services.game_logger import get_logger
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_gam
|
||||
"game_state": game_state,
|
||||
})
|
||||
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
@@ -240,7 +240,7 @@ async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_g
|
||||
async with ctx.current_room.game_lock:
|
||||
if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions):
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -297,7 +297,7 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat
|
||||
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
await asyncio.sleep(1.0)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
@@ -329,12 +329,12 @@ async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_s
|
||||
})
|
||||
else:
|
||||
await asyncio.sleep(0.5)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
else:
|
||||
logger.debug("Player discarded, waiting 0.5s before CPU turn")
|
||||
await asyncio.sleep(0.5)
|
||||
logger.debug("Post-discard delay complete, checking for CPU turn")
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
|
||||
@@ -364,7 +364,7 @@ async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game
|
||||
)
|
||||
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
@@ -380,7 +380,7 @@ async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game
|
||||
)
|
||||
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
@@ -400,7 +400,7 @@ async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast
|
||||
)
|
||||
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
@@ -418,7 +418,7 @@ async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_ga
|
||||
)
|
||||
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
|
||||
|
||||
async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
|
||||
@@ -443,7 +443,7 @@ async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_gam
|
||||
"game_state": game_state,
|
||||
})
|
||||
|
||||
await check_and_run_cpu_turn(ctx.current_room)
|
||||
check_and_run_cpu_turn(ctx.current_room)
|
||||
else:
|
||||
await broadcast_game_state(ctx.current_room)
|
||||
|
||||
@@ -473,6 +473,15 @@ async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, c
|
||||
await ctx.websocket.send_json({"type": "error", "message": "Only the host can end the game"})
|
||||
return
|
||||
|
||||
# Cancel any running CPU turn task so the game ends immediately
|
||||
if ctx.current_room.cpu_turn_task:
|
||||
ctx.current_room.cpu_turn_task.cancel()
|
||||
try:
|
||||
await ctx.current_room.cpu_turn_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
ctx.current_room.cpu_turn_task = None
|
||||
|
||||
await ctx.current_room.broadcast({
|
||||
"type": "game_ended",
|
||||
"reason": "Host ended the game",
|
||||
|
||||
@@ -705,8 +705,13 @@ async def broadcast_game_state(room: Room):
|
||||
})
|
||||
|
||||
|
||||
async def check_and_run_cpu_turn(room: Room):
|
||||
"""Check if current player is CPU and run their turn."""
|
||||
def check_and_run_cpu_turn(room: Room):
|
||||
"""Check if current player is CPU and start their turn as a background task.
|
||||
|
||||
The CPU turn chain runs as a fire-and-forget asyncio.Task stored on
|
||||
room.cpu_turn_task. This allows the WebSocket message loop to remain
|
||||
responsive so that end_game/leave messages can cancel the task immediately.
|
||||
"""
|
||||
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
|
||||
return
|
||||
|
||||
@@ -718,21 +723,54 @@ async def check_and_run_cpu_turn(room: Room):
|
||||
if not room_player or not room_player.is_cpu:
|
||||
return
|
||||
|
||||
# Brief pause before CPU starts - animations are faster now
|
||||
await asyncio.sleep(0.25)
|
||||
task = asyncio.create_task(_run_cpu_chain(room))
|
||||
room.cpu_turn_task = task
|
||||
|
||||
# Run CPU turn
|
||||
async def broadcast_cb():
|
||||
await broadcast_game_state(room)
|
||||
def _on_done(t: asyncio.Task):
|
||||
# Clear the reference when the task finishes (success, cancel, or error)
|
||||
if room.cpu_turn_task is t:
|
||||
room.cpu_turn_task = None
|
||||
if not t.cancelled() and t.exception():
|
||||
logger.error(f"CPU turn task error in room {room.code}: {t.exception()}")
|
||||
|
||||
await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id)
|
||||
task.add_done_callback(_on_done)
|
||||
|
||||
# Check if next player is also CPU (chain CPU turns)
|
||||
await check_and_run_cpu_turn(room)
|
||||
|
||||
async def _run_cpu_chain(room: Room):
|
||||
"""Run consecutive CPU turns until a human player's turn or game ends."""
|
||||
while True:
|
||||
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
|
||||
return
|
||||
|
||||
current = room.game.current_player()
|
||||
if not current:
|
||||
return
|
||||
|
||||
room_player = room.get_player(current.id)
|
||||
if not room_player or not room_player.is_cpu:
|
||||
return
|
||||
|
||||
# Brief pause before CPU starts - animations are faster now
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
# Run CPU turn
|
||||
async def broadcast_cb():
|
||||
await broadcast_game_state(room)
|
||||
|
||||
await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id)
|
||||
|
||||
|
||||
async def handle_player_leave(room: Room, player_id: str):
|
||||
"""Handle a player leaving a room."""
|
||||
# Cancel any running CPU turn task before cleanup
|
||||
if room.cpu_turn_task:
|
||||
room.cpu_turn_task.cancel()
|
||||
try:
|
||||
await room.cpu_turn_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
room.cpu_turn_task = None
|
||||
|
||||
room_code = room.code
|
||||
room_player = room.remove_player(player_id)
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ class Room:
|
||||
settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1})
|
||||
game_log_id: Optional[str] = None
|
||||
game_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
cpu_turn_task: Optional[asyncio.Task] = None
|
||||
|
||||
def add_player(
|
||||
self,
|
||||
|
||||
Reference in New Issue
Block a user