diff --git a/README.md b/README.md index 6cb9469..ebe6e2a 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,6 @@ When a player reveals all 6 cards, others get one final turn. Lowest score wins. ### Point Modifiers - `super_kings` - Kings worth -2 (instead of 0) -- `lucky_sevens` - 7s worth 0 (instead of 7) - `ten_penny` - 10s worth 1 (instead of 10) - `lucky_swing` - Single Joker worth -5 - `eagle_eye` - Paired Jokers score -8 @@ -86,11 +85,10 @@ When a player reveals all 6 cards, others get one final turn. Lowest score wins. - `tied_shame` - +5 penalty for tied scores - `blackjack` - Score of exactly 21 becomes 0 -### Gameplay Twists +### Gameplay Options - `flip_on_discard` - Must flip a card when discarding from deck -- `queens_wild` - Queens match any rank for pairing -- `four_of_a_kind` - 4 of same rank in grid = all score 0 - `use_jokers` - Add Jokers to deck +- `eagle_eye` - Paired Jokers score -8 instead of canceling ## Development diff --git a/client/app.js b/client/app.js index c178a08..523d1ff 100644 --- a/client/app.js +++ b/client/app.js @@ -62,6 +62,15 @@ class GolfGame { gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2); oscillator.start(ctx.currentTime); oscillator.stop(ctx.currentTime + 0.2); + } else if (type === 'flip') { + // Sharp quick click for card flips + oscillator.type = 'square'; + oscillator.frequency.setValueAtTime(1800, ctx.currentTime); + oscillator.frequency.exponentialRampToValueAtTime(600, ctx.currentTime + 0.02); + gainNode.gain.setValueAtTime(0.12, ctx.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.025); + oscillator.start(ctx.currentTime); + oscillator.stop(ctx.currentTime + 0.025); } else if (type === 'shuffle') { // Multiple quick sounds to simulate shuffling for (let i = 0; i < 8; i++) { @@ -102,6 +111,7 @@ class GolfGame { // Waiting room elements this.displayRoomCode = document.getElementById('display-room-code'); + this.copyRoomCodeBtn = document.getElementById('copy-room-code'); this.playersList = document.getElementById('players-list'); this.hostSettings = document.getElementById('host-settings'); this.waitingMessage = document.getElementById('waiting-message'); @@ -111,21 +121,15 @@ class GolfGame { this.initialFlipsSelect = document.getElementById('initial-flips'); this.flipOnDiscardCheckbox = document.getElementById('flip-on-discard'); this.knockPenaltyCheckbox = document.getElementById('knock-penalty'); - this.jokerModeSelect = document.getElementById('joker-mode'); // House Rules - Point Modifiers this.superKingsCheckbox = document.getElementById('super-kings'); - this.luckySevensCheckbox = document.getElementById('lucky-sevens'); this.tenPennyCheckbox = document.getElementById('ten-penny'); // House Rules - Bonuses/Penalties this.knockBonusCheckbox = document.getElementById('knock-bonus'); this.underdogBonusCheckbox = document.getElementById('underdog-bonus'); this.tiedShameCheckbox = document.getElementById('tied-shame'); this.blackjackCheckbox = document.getElementById('blackjack'); - // House Rules - Gameplay Twists - this.queensWildCheckbox = document.getElementById('queens-wild'); - this.fourOfAKindCheckbox = document.getElementById('four-of-a-kind'); - this.eagleEyeCheckbox = document.getElementById('eagle-eye'); - this.eagleEyeLabel = document.getElementById('eagle-eye-label'); + this.wolfpackCheckbox = document.getElementById('wolfpack'); this.startGameBtn = document.getElementById('start-game-btn'); this.leaveRoomBtn = document.getElementById('leave-room-btn'); this.addCpuBtn = document.getElementById('add-cpu-btn'); @@ -157,6 +161,9 @@ class GolfGame { this.gameButtons = document.getElementById('game-buttons'); this.nextRoundBtn = document.getElementById('next-round-btn'); this.newGameBtn = document.getElementById('new-game-btn'); + this.leaveGameBtn = document.getElementById('leave-game-btn'); + this.activeRulesBar = document.getElementById('active-rules-bar'); + this.activeRulesList = document.getElementById('active-rules-list'); } bindEvents() { @@ -174,6 +181,13 @@ class GolfGame { this.cancelCpuBtn.addEventListener('click', () => { this.playSound('click'); this.hideCpuSelect(); }); this.addSelectedCpusBtn.addEventListener('click', () => { this.playSound('success'); this.addSelectedCpus(); }); this.muteBtn.addEventListener('click', () => this.toggleSound()); + this.leaveGameBtn.addEventListener('click', () => { this.playSound('click'); this.leaveGame(); }); + + // Copy room code to clipboard + this.copyRoomCodeBtn.addEventListener('click', () => { + this.playSound('click'); + this.copyRoomCode(); + }); // Enter key handlers this.playerNameInput.addEventListener('keypress', (e) => { @@ -188,20 +202,6 @@ class GolfGame { e.target.value = e.target.value.toUpperCase(); }); - // Eagle Eye only works with Standard Jokers (need 2 to pair them) - const updateEagleEyeVisibility = () => { - const isStandardJokers = this.jokerModeSelect.value === 'standard'; - if (isStandardJokers) { - this.eagleEyeLabel.classList.remove('hidden'); - } else { - this.eagleEyeLabel.classList.add('hidden'); - this.eagleEyeCheckbox.checked = false; - } - }; - this.jokerModeSelect.addEventListener('change', updateEagleEyeVisibility); - // Check initial state - updateEagleEyeVisibility(); - // Update deck recommendation when deck selection changes this.numDecksSelect.addEventListener('change', () => { const playerCount = this.currentPlayers ? this.currentPlayers.length : 0; @@ -322,6 +322,15 @@ class GolfGame { this.showScoreboard(data.final_scores, true, data.rankings); break; + case 'game_ended': + // Host ended the game or player was kicked + this.ws.close(); + this.showLobby(); + if (data.reason) { + this.showError(data.reason); + } + break; + case 'error': this.showError(data.message); break; @@ -358,6 +367,26 @@ class GolfGame { this.showLobby(); } + copyRoomCode() { + if (!this.roomCode) return; + + navigator.clipboard.writeText(this.roomCode).then(() => { + // Show brief visual feedback + const originalText = this.copyRoomCodeBtn.textContent; + this.copyRoomCodeBtn.textContent = 'āœ“'; + setTimeout(() => { + this.copyRoomCodeBtn.textContent = originalText; + }, 1500); + }).catch(err => { + console.error('Failed to copy room code:', err); + // Fallback: select the text for manual copy + const range = document.createRange(); + range.selectNode(this.displayRoomCode); + window.getSelection().removeAllRanges(); + window.getSelection().addRange(range); + }); + } + startGame() { const decks = parseInt(this.numDecksSelect.value); const rounds = parseInt(this.numRoundsSelect.value); @@ -367,14 +396,14 @@ class GolfGame { const flip_on_discard = this.flipOnDiscardCheckbox.checked; const knock_penalty = this.knockPenaltyCheckbox.checked; - // Joker mode - const joker_mode = this.jokerModeSelect.value; + // Joker mode (radio buttons) + const joker_mode = document.querySelector('input[name="joker-mode"]:checked').value; const use_jokers = joker_mode !== 'none'; const lucky_swing = joker_mode === 'lucky-swing'; + const eagle_eye = joker_mode === 'eagle-eye'; // House Rules - Point Modifiers const super_kings = this.superKingsCheckbox.checked; - const lucky_sevens = this.luckySevensCheckbox.checked; const ten_penny = this.tenPennyCheckbox.checked; // House Rules - Bonuses/Penalties @@ -382,11 +411,7 @@ class GolfGame { const underdog_bonus = this.underdogBonusCheckbox.checked; const tied_shame = this.tiedShameCheckbox.checked; const blackjack = this.blackjackCheckbox.checked; - - // House Rules - Gameplay Twists - const queens_wild = this.queensWildCheckbox.checked; - const four_of_a_kind = this.fourOfAKindCheckbox.checked; - const eagle_eye = this.eagleEyeCheckbox.checked; + const wolfpack = this.wolfpackCheckbox.checked; this.send({ type: 'start_game', @@ -398,15 +423,13 @@ class GolfGame { use_jokers, lucky_swing, super_kings, - lucky_sevens, ten_penny, knock_bonus, underdog_bonus, tied_shame, blackjack, - queens_wild, - four_of_a_kind, - eagle_eye + eagle_eye, + wolfpack }); } @@ -549,7 +572,7 @@ class GolfGame { if (this.gameState.waiting_for_initial_flip) { if (card.face_up) return; - this.playSound('card'); + this.playSound('flip'); const requiredFlips = this.gameState.initial_flips || 2; if (this.selectedCards.includes(position)) { @@ -594,6 +617,22 @@ class GolfGame { this.leaveRoom(); } + leaveGame() { + if (this.isHost) { + // Host ending game affects everyone + if (confirm('End game for all players?')) { + this.send({ type: 'end_game' }); + } + } else { + // Regular player just leaves + if (confirm('Leave this game?')) { + this.send({ type: 'leave_game' }); + this.ws.close(); + this.showLobby(); + } + } + } + // UI Helpers showScreen(screen) { this.lobbyScreen.classList.remove('active'); @@ -630,6 +669,28 @@ class GolfGame { this.drawnCard = null; this.selectedCards = []; this.waitingForFlip = false; + // Update leave button text based on role + this.leaveGameBtn.textContent = this.isHost ? 'End Game' : 'Leave'; + // Update active rules bar + this.updateActiveRulesBar(); + } + + updateActiveRulesBar() { + if (!this.gameState || !this.gameState.active_rules) { + this.activeRulesBar.classList.add('hidden'); + return; + } + + const rules = this.gameState.active_rules; + if (rules.length === 0) { + this.activeRulesBar.classList.add('hidden'); + return; + } + + this.activeRulesList.innerHTML = rules + .map(rule => `${rule}`) + .join(''); + this.activeRulesBar.classList.remove('hidden'); } showError(message) { @@ -825,6 +886,15 @@ class GolfGame { // Update discard pile if (this.gameState.discard_top) { const discardCard = this.gameState.discard_top; + const cardKey = `${discardCard.rank}-${discardCard.suit}`; + + // Animate if discard changed + if (this.lastDiscardKey && this.lastDiscardKey !== cardKey) { + this.discard.classList.add('card-flip-in'); + setTimeout(() => this.discard.classList.remove('card-flip-in'), 400); + } + this.lastDiscardKey = cardKey; + this.discard.classList.add('has-card', 'card-front'); this.discard.classList.remove('card-back', 'red', 'black', 'joker'); @@ -839,6 +909,7 @@ class GolfGame { } else { this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker'); this.discardContent.innerHTML = ''; + this.lastDiscardKey = null; } // Update deck/discard clickability and visual state @@ -864,9 +935,10 @@ class GolfGame { } const displayName = player.name.length > 12 ? player.name.substring(0, 11) + '…' : player.name; + const showingScore = this.calculateShowingScore(player.cards); div.innerHTML = ` -

${displayName}${player.all_face_up ? ' āœ“' : ''}

+

${displayName}${player.all_face_up ? ' āœ“' : ''}${showingScore}

${player.cards.map(card => this.renderCard(card, false, false)).join('')}
@@ -960,34 +1032,48 @@ class GolfGame { updateStandings() { if (!this.gameState || !this.standingsList) return; - // Sort players by total score (lowest is best in golf) - const sorted = [...this.gameState.players].sort((a, b) => a.total_score - b.total_score); + // Sort by total points (lowest wins) - top 4 + const byPoints = [...this.gameState.players].sort((a, b) => a.total_score - b.total_score).slice(0, 4); + // Sort by holes won (most wins) - top 4 + const byHoles = [...this.gameState.players].sort((a, b) => b.rounds_won - a.rounds_won).slice(0, 4); - this.standingsList.innerHTML = ''; - - sorted.forEach((player, index) => { - const div = document.createElement('div'); - div.className = 'standing-row'; - - if (index === 0 && player.total_score < sorted[sorted.length - 1]?.total_score) { - div.classList.add('leader'); + // Build points ranking + let pointsRank = 0; + let prevPoints = null; + const pointsHtml = byPoints.map((p, i) => { + if (p.total_score !== prevPoints) { + pointsRank = i; + prevPoints = p.total_score; } - if (player.id === this.playerId) { - div.classList.add('you'); + const medal = pointsRank === 0 ? 'šŸ„‡' : pointsRank === 1 ? '🄈' : pointsRank === 2 ? 'šŸ„‰' : '4.'; + const name = p.name.length > 8 ? p.name.substring(0, 7) + '…' : p.name; + return `
${medal}${name}${p.total_score}pt
`; + }).join(''); + + // Build holes won ranking + let holesRank = 0; + let prevHoles = null; + const holesHtml = byHoles.map((p, i) => { + if (p.rounds_won !== prevHoles) { + holesRank = i; + prevHoles = p.rounds_won; } + const medal = p.rounds_won === 0 ? '-' : + holesRank === 0 ? 'šŸ„‡' : holesRank === 1 ? '🄈' : holesRank === 2 ? 'šŸ„‰' : '4.'; + const name = p.name.length > 8 ? p.name.substring(0, 7) + '…' : p.name; + return `
${medal}${name}${p.rounds_won}W
`; + }).join(''); - const displayName = player.name.length > 12 - ? player.name.substring(0, 11) + '…' - : player.name; - - div.innerHTML = ` - ${index + 1}. - ${displayName} - ${player.total_score} pts - `; - - this.standingsList.appendChild(div); - }); + this.standingsList.innerHTML = ` +
+
By Score
+ ${pointsHtml} +
+
+
By Holes
+ ${holesHtml} +
+ `; } renderCard(card, clickable, selected) { @@ -1043,16 +1129,20 @@ class GolfGame { this.scoreTable.appendChild(tr); }); - // Show rankings announcement - this.showRankingsAnnouncement(rankings, isFinal); + // Show rankings announcement only for final results + const existingAnnouncement = document.getElementById('rankings-announcement'); + if (existingAnnouncement) existingAnnouncement.remove(); + + if (isFinal) { + // Show big final results modal instead of side panel stuff + this.showFinalResultsModal(rankings, scores); + return; + } // Show game buttons this.gameButtons.classList.remove('hidden'); - if (isFinal) { - this.nextRoundBtn.classList.add('hidden'); - this.newGameBtn.classList.remove('hidden'); - } else if (this.isHost) { + if (this.isHost) { this.nextRoundBtn.classList.remove('hidden'); this.newGameBtn.classList.add('hidden'); } else { @@ -1065,6 +1155,8 @@ class GolfGame { // Remove existing announcement if any const existing = document.getElementById('rankings-announcement'); if (existing) existing.remove(); + const existingVictory = document.getElementById('double-victory-banner'); + if (existingVictory) existingVictory.remove(); if (!rankings) return; @@ -1109,13 +1201,20 @@ class GolfGame { return `
${medal}${name}${p.rounds_won}W
`; }).join(''); - const doubleVictoryHtml = isDoubleVictory - ? `
DOUBLE VICTORY! ${pointsLeader.name}
` - : ''; + // If double victory, show banner above the left panel (standings) + if (isDoubleVictory) { + const victoryBanner = document.createElement('div'); + victoryBanner.id = 'double-victory-banner'; + victoryBanner.className = 'double-victory'; + victoryBanner.textContent = `DOUBLE VICTORY! ${pointsLeader.name}`; + const standingsPanel = document.getElementById('standings-panel'); + if (standingsPanel) { + standingsPanel.insertBefore(victoryBanner, standingsPanel.firstChild); + } + } announcement.innerHTML = `

${title}

- ${doubleVictoryHtml}

Points (Low Wins)

@@ -1131,6 +1230,118 @@ class GolfGame { // Insert before the scoreboard this.scoreboard.insertBefore(announcement, this.scoreboard.firstChild); } + + showFinalResultsModal(rankings, scores) { + // Hide side panels + const standingsPanel = document.getElementById('standings-panel'); + const scoreboard = document.getElementById('scoreboard'); + if (standingsPanel) standingsPanel.classList.add('hidden'); + if (scoreboard) scoreboard.classList.add('hidden'); + + // Remove existing modal if any + const existing = document.getElementById('final-results-modal'); + if (existing) existing.remove(); + + // Determine winners + const pointsLeader = rankings.by_points[0]; + const holesLeader = rankings.by_holes_won[0]; + const isDoubleVictory = pointsLeader && holesLeader && + pointsLeader.name === holesLeader.name && + holesLeader.rounds_won > 0; + + // Build points ranking + let pointsRank = 0; + let prevPoints = null; + const pointsHtml = rankings.by_points.map((p, i) => { + if (p.total !== prevPoints) { + pointsRank = i; + prevPoints = p.total; + } + const medal = pointsRank === 0 ? 'šŸ„‡' : pointsRank === 1 ? '🄈' : pointsRank === 2 ? 'šŸ„‰' : `${pointsRank + 1}.`; + return `
${medal}${p.name}${p.total} pts
`; + }).join(''); + + // Build holes ranking + let holesRank = 0; + let prevHoles = null; + const holesHtml = rankings.by_holes_won.map((p, i) => { + if (p.rounds_won !== prevHoles) { + holesRank = i; + prevHoles = p.rounds_won; + } + const medal = p.rounds_won === 0 ? '-' : + holesRank === 0 ? 'šŸ„‡' : holesRank === 1 ? '🄈' : holesRank === 2 ? 'šŸ„‰' : `${holesRank + 1}.`; + return `
${medal}${p.name}${p.rounds_won} wins
`; + }).join(''); + + // Build share text + const shareText = this.buildShareText(rankings, isDoubleVictory); + + // Create modal + const modal = document.createElement('div'); + modal.id = 'final-results-modal'; + modal.className = 'final-results-modal'; + modal.innerHTML = ` +
+

šŸŒļø Final Results

+ ${isDoubleVictory ? `
šŸ† DOUBLE VICTORY: ${pointsLeader.name} šŸ†
` : ''} +
+
+

By Points (Low Wins)

+ ${pointsHtml} +
+
+

By Holes Won

+ ${holesHtml} +
+
+
+ + +
+
+ `; + + document.body.appendChild(modal); + + // Bind button events + document.getElementById('share-results-btn').addEventListener('click', () => { + navigator.clipboard.writeText(shareText).then(() => { + const btn = document.getElementById('share-results-btn'); + btn.textContent = 'āœ“ Copied!'; + setTimeout(() => btn.textContent = 'šŸ“‹ Copy Results', 2000); + }); + }); + + document.getElementById('close-results-btn').addEventListener('click', () => { + modal.remove(); + this.leaveRoom(); + }); + } + + buildShareText(rankings, isDoubleVictory) { + let text = 'šŸŒļø Golf Card Game Results\n'; + text += '═══════════════════════\n\n'; + + if (isDoubleVictory) { + text += `šŸ† DOUBLE VICTORY: ${rankings.by_points[0].name}!\n\n`; + } + + text += 'šŸ“Š By Points (Low Wins):\n'; + rankings.by_points.forEach((p, i) => { + const medal = i === 0 ? 'šŸ„‡' : i === 1 ? '🄈' : i === 2 ? 'šŸ„‰' : `${i + 1}.`; + text += `${medal} ${p.name}: ${p.total} pts\n`; + }); + + text += '\n⛳ By Holes Won:\n'; + rankings.by_holes_won.forEach((p, i) => { + const medal = p.rounds_won === 0 ? '-' : i === 0 ? 'šŸ„‡' : i === 1 ? '🄈' : i === 2 ? 'šŸ„‰' : `${i + 1}.`; + text += `${medal} ${p.name}: ${p.rounds_won} wins\n`; + }); + + text += '\nPlayed at golf.game'; + return text; + } } // Initialize game when page loads diff --git a/client/index.html b/client/index.html index dfa8300..4642200 100644 --- a/client/index.html +++ b/client/index.html @@ -38,151 +38,164 @@
-

Room:

- -
-

Players

-
    +
    + ROOM CODE + +
    - + +
    @@ -203,7 +224,7 @@
    - DECK + ?
    @@ -228,7 +249,7 @@
    -

    Standings

    +

    Current Standings

    diff --git a/client/style.css b/client/style.css index 8f2573e..626bea5 100644 --- a/client/style.css +++ b/client/style.css @@ -6,8 +6,8 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; - background-color: #1a472a; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60' viewBox='0 0 60 60'%3E%3Cg fill='%23224d32' fill-opacity='0.4'%3E%3Cpath d='M15 5c0-1.1.9-2 2-2h6c1.1 0 2 .9 2 2v8c0 1.1-.9 2-2 2h-6c-1.1 0-2-.9-2-2V5zm0 40c0-1.1.9-2 2-2h6c1.1 0 2 .9 2 2v8c0 1.1-.9 2-2 2h-6c-1.1 0-2-.9-2-2v-8z'/%3E%3Cpath d='M35 25c0-1.1.9-2 2-2h6c1.1 0 2 .9 2 2v8c0 1.1-.9 2-2 2h-6c-1.1 0-2-.9-2-2v-8z'/%3E%3Ccircle cx='10' cy='30' r='3'/%3E%3Ccircle cx='50' cy='10' r='3'/%3E%3Ccircle cx='50' cy='50' r='3'/%3E%3Cpath d='M30 18l3 5h-6l3-5zm0 24l3 5h-6l3-5z'/%3E%3C/g%3E%3C/svg%3E"); + /* Dark emerald pool table felt */ + background: linear-gradient(180deg, #0a4528 0%, #0d5030 50%, #0a4528 100%); min-height: 100vh; color: #fff; } @@ -61,11 +61,153 @@ body { /* Waiting Screen */ #waiting-screen { - max-width: 500px; + max-width: 1000px; margin: 0 auto; + padding: 20px 40px; + position: relative; +} + +/* Desktop: side-by-side layout */ +.waiting-layout { + display: grid; + grid-template-columns: 220px 1fr; + gap: 25px; + align-items: start; +} + +.waiting-left-col { + display: flex; + flex-direction: column; + gap: 15px; +} + +.waiting-left-col .players-list { + background: rgba(0,0,0,0.2); + border-radius: 10px; + padding: 15px; +} + +.waiting-left-col .players-list h3 { + margin: 0 0 10px 0; + font-size: 1rem; +} + +#waiting-screen .settings { + background: rgba(0,0,0,0.2); + border-radius: 10px; padding: 20px; } +#waiting-screen .settings h3 { + margin: 0 0 15px 0; +} + +/* Basic settings in a row */ +.basic-settings-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 15px; + align-items: end; +} + +.basic-settings-row .form-group { + margin-bottom: 0; +} + +.basic-settings-row .form-group label { + font-size: 0.8rem; + margin-bottom: 4px; + display: block; +} + +.basic-settings-row select { + width: 100%; + padding: 8px 4px; +} + +.basic-settings-row .cpu-controls { + display: flex; + gap: 5px; +} + +.basic-settings-row .cpu-controls .btn { + flex: 1; + padding: 8px 0; +} + +#waiting-message { + grid-column: 1 / -1; + text-align: center; + margin-top: 15px; +} + +/* Mobile: stack vertically */ +@media (max-width: 700px) { + .waiting-layout { + grid-template-columns: 1fr; + } + + .basic-settings-row { + grid-template-columns: 1fr 1fr; + } +} + +/* Room Code Banner */ +.room-code-banner { + position: fixed; + top: 0; + right: 0; + z-index: 100; + background: linear-gradient(135deg, rgba(244, 164, 96, 0.9) 0%, rgba(230, 140, 70, 0.95) 100%); + padding: 10px 15px; + border-radius: 0 0 0 12px; + display: flex; + align-items: center; + gap: 10px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); +} + +.room-code-label { + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: rgba(26, 71, 42, 0.8); +} + +.room-code-value { + font-size: 1.5rem; + font-weight: 800; + font-family: 'Courier New', monospace; + letter-spacing: 0.2em; + color: #1a472a; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.3); +} + +.room-code-copy { + background: rgba(26, 71, 42, 0.2); + border: none; + border-radius: 6px; + padding: 6px 8px; + cursor: pointer; + font-size: 1rem; + transition: all 0.2s; +} + +.room-code-copy:hover { + background: rgba(26, 71, 42, 0.3); + transform: scale(1.1); +} + +.room-code-copy:active { + transform: scale(0.95); +} + +.room-code-copy.copied { + background: rgba(26, 71, 42, 0.4); +} + h1 { font-size: 3rem; @@ -153,6 +295,11 @@ input::placeholder { color: #fff; } +.btn-success { + background: #27ae60; + color: #fff; +} + .btn-small { padding: 8px 16px; font-size: 0.9rem; @@ -270,6 +417,41 @@ input::placeholder { cursor: pointer; } +.radio-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.radio-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 0.95rem; + font-weight: 500; +} + +.radio-label input[type="radio"] { + width: 18px; + height: 18px; + cursor: pointer; + flex-shrink: 0; +} + +.radio-label .rule-desc { + width: auto; + margin-left: 0; + opacity: 0.6; + font-weight: 400; + font-size: 0.8rem; + white-space: nowrap; +} + +.radio-label .rule-desc::before { + content: "— "; +} + /* Settings */ .settings { background: rgba(0,0,0,0.2); @@ -307,8 +489,16 @@ input::placeholder { justify-self: center; } -.game-header .mute-btn { +.game-header .header-buttons { justify-self: end; + display: flex; + align-items: center; + gap: 10px; +} + +#leave-game-btn { + padding: 6px 12px; + font-size: 0.8rem; } .mute-btn { @@ -327,6 +517,39 @@ input::placeholder { background: rgba(255,255,255,0.1); } +/* Active Rules Bar */ +.active-rules-bar { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 6px 20px; + background: rgba(0, 0, 0, 0.25); + font-size: 0.8rem; + flex-wrap: wrap; +} + +.active-rules-bar .rules-label { + color: rgba(255, 255, 255, 0.5); + font-weight: 500; +} + +.active-rules-bar .rules-list { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: center; +} + +.active-rules-bar .rule-tag { + background: rgba(244, 164, 96, 0.25); + color: #f4a460; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; +} + /* Card Styles */ .card { width: clamp(65px, 5.5vw, 100px); @@ -349,10 +572,19 @@ input::placeholder { } .card-back { - background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%); - border: 3px solid #1a252f; + /* Bee-style diamond grid pattern - red with white crosshatch */ + background-color: #c41e3a; + background-image: + linear-gradient(45deg, rgba(255,255,255,0.25) 25%, transparent 25%), + linear-gradient(-45deg, rgba(255,255,255,0.25) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(255,255,255,0.25) 75%), + linear-gradient(-45deg, transparent 75%, rgba(255,255,255,0.25) 75%); + background-size: 8px 8px; + background-position: 0 0, 0 4px, 4px -4px, -4px 0; + border: 3px solid #8b1528; color: #fff; - font-size: 0.8rem; + font-size: clamp(1.8rem, 2.5vw, 3rem); + box-shadow: 0 4px 12px rgba(0,0,0,0.3); } .card-front { @@ -402,7 +634,7 @@ input::placeholder { display: flex; flex-direction: column; align-items: center; - gap: 5px; + gap: 25px; width: 100%; } @@ -510,10 +742,16 @@ input::placeholder { .deck-area { display: flex; - gap: 12px; + gap: 15px; align-items: center; } +.deck-area .card { + width: clamp(80px, 7vw, 120px); + height: clamp(112px, 9.8vw, 168px); + font-size: clamp(2.4rem, 3.2vw, 4rem); +} + #discard { background: rgba(255,255,255,0.1); border: 2px dashed rgba(255,255,255,0.3); @@ -537,6 +775,29 @@ input::placeholder { box-shadow: none; } +/* Card flip animation for discard pile */ +.card-flip-in { + animation: cardFlipIn 0.4s ease-out; +} + +@keyframes cardFlipIn { + 0% { + transform: scale(1.3) rotateY(90deg); + opacity: 0.5; + box-shadow: 0 0 30px rgba(244, 164, 96, 0.8); + } + 50% { + transform: scale(1.15) rotateY(0deg); + opacity: 1; + box-shadow: 0 0 25px rgba(244, 164, 96, 0.6); + } + 100% { + transform: scale(1) rotateY(0deg); + opacity: 1; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + } +} + #drawn-card-area { display: flex; flex-direction: column; @@ -547,6 +808,11 @@ input::placeholder { border-radius: 8px; } +#drawn-card-area .card { + width: clamp(80px, 7vw, 120px); + height: clamp(112px, 9.8vw, 168px); + font-size: clamp(2.4rem, 3.2vw, 4rem); +} #drawn-card-area .btn { white-space: nowrap; @@ -585,6 +851,19 @@ input::placeholder { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + display: flex; + justify-content: space-between; + align-items: center; +} + +.opponent-showing { + font-weight: 700; + color: rgba(255, 255, 255, 0.9); + background: rgba(0, 0, 0, 0.25); + padding: 1px 6px; + border-radius: 3px; + font-size: 0.9em; + margin-left: 8px; } .opponent-area .card-grid { @@ -678,9 +957,9 @@ input::placeholder { position: fixed; bottom: 20px; background: linear-gradient(145deg, rgba(15, 50, 35, 0.92) 0%, rgba(8, 30, 20, 0.95) 100%); - border-radius: 14px; - padding: 16px 18px; - width: 235px; + border-radius: 16px; + padding: 18px 20px; + width: 263px; z-index: 100; backdrop-filter: blur(10px); border: 1px solid rgba(244, 164, 96, 0.25); @@ -699,69 +978,81 @@ input::placeholder { } .side-panel > h4 { - font-size: 0.9rem; + font-size: 1rem; text-align: center; - margin-bottom: 12px; + margin-bottom: 14px; color: #f4a460; text-transform: uppercase; letter-spacing: 0.2em; font-weight: 700; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); border-bottom: 1px solid rgba(244, 164, 96, 0.2); - padding-bottom: 10px; + padding-bottom: 12px; } -/* Standings list */ -.standings-list { - font-size: 0.95rem; +/* Standings list - two sections, top 4 each */ +.standings-section { + margin-bottom: 10px; } -.standings-list .standing-row { - display: flex; - justify-content: space-between; +.standings-section:last-child { + margin-bottom: 0; +} + +.standings-title { + font-size: 0.7rem; + color: rgba(255,255,255,0.5); + text-transform: uppercase; + letter-spacing: 0.1em; + padding-bottom: 3px; + margin-bottom: 3px; + border-bottom: 1px solid rgba(255,255,255,0.1); +} + +.standings-list .rank-row { + display: grid; + grid-template-columns: 22px 1fr 36px; + gap: 4px; + font-size: 0.8rem; + padding: 2px 0; align-items: center; - padding: 6px 8px; - border-radius: 5px; - margin-bottom: 4px; } -.standings-list .standing-row.leader { - background: rgba(244, 164, 96, 0.2); - border-left: 3px solid #f4a460; +.standings-list .rank-pos { + text-align: center; + font-size: 0.75rem; } -.standings-list .standing-row.you { - background: rgba(255, 255, 255, 0.1); -} - -.standings-list .standing-pos { - font-weight: 700; - color: #f4a460; - width: 20px; -} - -.standings-list .standing-name { - flex: 1; +.standings-list .rank-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.standings-list .standing-score { - font-weight: 600; - opacity: 0.9; +.standings-list .rank-val { + text-align: right; + font-size: 0.75rem; + color: rgba(255,255,255,0.7); +} + +.standings-list .rank-row.leader { + color: #f4a460; +} + +.standings-list .rank-row.leader .rank-val { + color: #f4a460; } /* Score table */ .side-panel table { width: 100%; border-collapse: collapse; - font-size: 0.9rem; + font-size: 1rem; } .side-panel th, .side-panel td { - padding: 7px 5px; + padding: 8px 6px; text-align: center; border-bottom: 1px solid rgba(255,255,255,0.08); } @@ -769,7 +1060,7 @@ input::placeholder { .side-panel th { font-weight: 600; background: rgba(0,0,0,0.25); - font-size: 0.75rem; + font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; color: rgba(255, 255, 255, 0.6); @@ -790,62 +1081,62 @@ input::placeholder { } .game-buttons { - margin-top: 10px; + margin-top: 12px; display: flex; flex-direction: column; - gap: 6px; + gap: 8px; } .game-buttons .btn { - font-size: 0.7rem; - padding: 8px 10px; + font-size: 0.8rem; + padding: 10px 12px; width: 100%; } /* Rankings Announcement */ .rankings-announcement { background: linear-gradient(135deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.25) 100%); - border-radius: 8px; - padding: 8px; - margin-bottom: 10px; + border-radius: 10px; + padding: 10px; + margin-bottom: 12px; border: 1px solid rgba(244, 164, 96, 0.3); overflow: hidden; } .rankings-announcement h3 { - font-size: 0.85rem; + font-size: 0.95rem; text-align: center; - margin: 0 0 8px 0; + margin: 0 0 10px 0; color: #f4a460; } .rankings-announcement h4 { - font-size: 0.7rem; + font-size: 0.8rem; text-align: center; - margin: 0 0 5px 0; + margin: 0 0 6px 0; opacity: 0.8; } .rankings-columns { display: flex; - gap: 6px; + gap: 8px; } .ranking-section { flex: 1; min-width: 0; background: rgba(0,0,0,0.2); - border-radius: 5px; - padding: 5px; + border-radius: 6px; + padding: 6px; overflow: hidden; } .rank-row { display: flex; align-items: center; - font-size: 0.7rem; - padding: 2px 0; - gap: 2px; + font-size: 0.8rem; + padding: 3px 0; + gap: 3px; flex-wrap: nowrap; } @@ -855,7 +1146,7 @@ input::placeholder { } .rank-pos { - width: 16px; + width: 18px; text-align: center; flex-shrink: 0; } @@ -869,7 +1160,7 @@ input::placeholder { } .rank-val { - font-size: 0.6rem; + font-size: 0.7rem; opacity: 0.9; flex-shrink: 0; white-space: nowrap; @@ -881,11 +1172,11 @@ input::placeholder { background: linear-gradient(135deg, #ffd700 0%, #f4a460 50%, #ffd700 100%); color: #1a472a; text-align: center; - padding: 8px; - border-radius: 6px; + padding: 10px; + border-radius: 8px; font-weight: 700; - font-size: 0.9rem; - margin-bottom: 8px; + font-size: 1rem; + margin-bottom: 10px; animation: victoryPulse 1s ease-in-out infinite alternate; text-shadow: 0 1px 0 rgba(255,255,255,0.3); } @@ -1198,15 +1489,15 @@ input::placeholder { min-width: 100px; } -/* House Rules Section */ -.house-rules-section { +/* Advanced Options Section */ +.advanced-options-section { background: rgba(0, 0, 0, 0.15); border-radius: 8px; margin: 15px 0; overflow: hidden; } -.house-rules-section summary { +.advanced-options-section summary { padding: 12px 15px; cursor: pointer; font-weight: 600; @@ -1218,69 +1509,103 @@ input::placeholder { gap: 8px; } -.house-rules-section summary::-webkit-details-marker { +.advanced-options-section summary::-webkit-details-marker { display: none; } -.house-rules-section summary::before { +.advanced-options-section summary::before { content: "ā–ø"; font-size: 0.8rem; transition: transform 0.2s; } -.house-rules-section[open] summary::before { +.advanced-options-section[open] summary::before { transform: rotate(90deg); } -.house-rules-section summary:hover { +.advanced-options-section summary:hover { background: rgba(0, 0, 0, 0.3); } -.house-rules-category { - padding: 12px 15px; +/* Two-column grid for options */ +.advanced-options-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; +} + +.options-column { + padding: 8px 12px; +} + +.options-column:first-child { + border-right: 1px solid rgba(255, 255, 255, 0.1); +} + +.options-category { + padding: 8px 0; border-top: 1px solid rgba(255, 255, 255, 0.1); } -.house-rules-category h4 { - font-size: 0.85rem; - margin-bottom: 10px; +.options-category:first-child { + border-top: none; +} + +.options-category h4 { + font-size: 0.8rem; + margin-bottom: 8px; opacity: 0.9; color: #f4a460; } -.house-rules-category .checkbox-group { - gap: 6px; +.options-category .checkbox-group { + gap: 4px; } -.house-rules-category .checkbox-label { - font-size: 0.85rem; - padding: 5px 0; +.options-category .checkbox-label { + font-size: 0.95rem; + font-weight: 500; + padding: 3px 0; flex-wrap: wrap; } +/* Inline checkbox labels - description on same line */ +.checkbox-label.inline { + flex-wrap: nowrap; +} + +.checkbox-label.inline .rule-desc { + width: auto; + margin-left: 0; +} + +.checkbox-label.inline .rule-desc::before { + content: "— "; +} + /* Rule description */ .rule-desc { width: 100%; - font-size: 0.7rem; - opacity: 0.7; - margin-left: 22px; + font-size: 0.8rem; + opacity: 0.6; + margin-left: 30px; margin-top: 1px; } -/* Compact form group for house rules */ -.house-rules-category .form-group.compact { +/* Compact form group for options */ +.options-category .form-group.compact { margin: 0; } -.house-rules-category .form-group.compact select { +.options-category .form-group.compact select { width: 100%; - font-size: 0.8rem; - padding: 6px 8px; + font-size: 0.75rem; + padding: 5px 6px; } /* Eagle Eye option under joker dropdown */ .eagle-eye-option { - margin-top: 8px; + margin-top: 6px; } /* Disabled checkbox styling */ @@ -1292,3 +1617,191 @@ input::placeholder { .checkbox-label input:disabled { cursor: not-allowed; } + +/* Mobile: stack columns */ +@media (max-width: 500px) { + .advanced-options-grid { + grid-template-columns: 1fr; + } + + .options-column:first-child { + border-right: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } +} + +/* Final Results Modal */ +.final-results-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 300; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.final-results-content { + background: linear-gradient(145deg, #1a472a 0%, #0d3320 100%); + border-radius: 20px; + padding: 30px 40px; + max-width: 550px; + width: 90%; + text-align: center; + box-shadow: + 0 20px 60px rgba(0, 0, 0, 0.6), + 0 0 80px rgba(244, 164, 96, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.1); + border: 2px solid rgba(244, 164, 96, 0.3); + animation: modalSlideIn 0.4s ease; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: scale(0.9) translateY(-20px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.final-results-content h2 { + font-size: 2rem; + margin-bottom: 20px; + color: #f4a460; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.double-victory-banner { + background: linear-gradient(135deg, #ffd700 0%, #f4a460 50%, #ffd700 100%); + color: #1a472a; + padding: 12px 20px; + border-radius: 10px; + font-size: 1.2rem; + font-weight: 700; + margin-bottom: 20px; + animation: victoryPulse 1s ease-in-out infinite alternate; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.4); +} + +.final-rankings { + display: flex; + gap: 20px; + margin-bottom: 25px; +} + +.final-ranking-section { + flex: 1; + background: rgba(0, 0, 0, 0.25); + border-radius: 12px; + padding: 15px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.final-ranking-section h3 { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: rgba(255, 255, 255, 0.6); + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.final-rank-row { + display: flex; + align-items: center; + padding: 8px 10px; + margin-bottom: 4px; + border-radius: 6px; + font-size: 1rem; + transition: background 0.2s; +} + +.final-rank-row:nth-child(2) { + background: linear-gradient(90deg, rgba(244, 164, 96, 0.3) 0%, rgba(244, 164, 96, 0.1) 100%); + font-weight: 600; + color: #f4a460; +} + +.final-rank-row:nth-child(3) { + background: rgba(192, 192, 192, 0.15); +} + +.final-rank-row:nth-child(4) { + background: rgba(205, 127, 50, 0.12); +} + +.final-rank-row .rank-pos { + width: 28px; + font-weight: 700; + font-size: 1.1rem; +} + +.final-rank-row .rank-name { + flex: 1; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.final-rank-row .rank-val { + font-weight: 600; + font-size: 0.95rem; + opacity: 0.9; +} + +.final-actions { + display: flex; + gap: 12px; + justify-content: center; + flex-wrap: wrap; +} + +.final-actions .btn { + min-width: 140px; + padding: 14px 24px; + font-size: 1rem; +} + +.final-actions .btn-primary { + box-shadow: 0 4px 15px rgba(244, 164, 96, 0.4); +} + +/* Mobile adjustments for final results modal */ +@media (max-width: 500px) { + .final-results-content { + padding: 20px 25px; + } + + .final-results-content h2 { + font-size: 1.5rem; + } + + .final-rankings { + flex-direction: column; + gap: 15px; + } + + .final-rank-row { + font-size: 0.9rem; + padding: 6px 8px; + } + + .final-actions .btn { + min-width: 120px; + padding: 12px 20px; + } +} diff --git a/server/RULES.md b/server/RULES.md index 65294ae..cd1d612 100644 --- a/server/RULES.md +++ b/server/RULES.md @@ -116,7 +116,6 @@ Our implementation supports these optional rule variations: |--------|--------| | `lucky_swing` | Single Joker worth **-5** (instead of two -2 Jokers) | | `super_kings` | Kings worth **-2** (instead of 0) | -| `lucky_sevens` | 7s worth **0** (instead of 7) | | `ten_penny` | 10s worth **1** (instead of 10) | ## Bonuses & Penalties @@ -128,13 +127,11 @@ Our implementation supports these optional rule variations: | `tied_shame` | Tying another player's score = **+5** penalty to both | | `blackjack` | Exact score of 21 becomes **0** | -## Gameplay Twists +## Special Rules | Option | Effect | |--------|--------| -| `queens_wild` | Queens match any rank for column pairing | -| `four_of_a_kind` | 4 cards of same rank in grid = all 4 score 0 | -| `eagle_eye` | Paired Jokers score **-8** (instead of canceling to 0) | +| `eagle_eye` | Jokers worth **+2 unpaired**, **-4 paired** (spot the pair!) | --- diff --git a/server/ai.py b/server/ai.py index 3a1c5e8..e8860e9 100644 --- a/server/ai.py +++ b/server/ai.py @@ -18,14 +18,9 @@ def get_ai_card_value(card: Card, options: GameOptions) -> int: return get_card_value(card, options) -def can_make_pair(card1: Card, card2: Card, options: GameOptions) -> bool: - """Check if two cards can form a pair (with Queens Wild support).""" - if card1.rank == card2.rank: - return True - if options.queens_wild: - if card1.rank == Rank.QUEEN or card2.rank == Rank.QUEEN: - return True - return False +def can_make_pair(card1: Card, card2: Card) -> bool: + """Check if two cards can form a pair.""" + return card1.rank == card2.rank def estimate_opponent_min_score(player: Player, game: Game) -> int: @@ -41,11 +36,119 @@ def estimate_opponent_min_score(player: Player, game: Game) -> int: return min_est +def get_end_game_pressure(player: Player, game: Game) -> float: + """ + Calculate pressure level based on how close opponents are to going out. + Returns 0.0-1.0 where higher means more pressure to improve hand NOW. + + Pressure increases when: + - Opponents have few hidden cards (close to going out) + - We have many hidden cards (stuck with unknown values) + """ + my_hidden = sum(1 for c in player.cards if not c.face_up) + + # Find the opponent closest to going out + min_opponent_hidden = 6 + for p in game.players: + if p.id == player.id: + continue + opponent_hidden = sum(1 for c in p.cards if not c.face_up) + min_opponent_hidden = min(min_opponent_hidden, opponent_hidden) + + # No pressure if opponents have lots of hidden cards + if min_opponent_hidden >= 4: + return 0.0 + + # Pressure scales based on how close opponent is to finishing + # 3 hidden = mild pressure (0.4), 2 hidden = medium (0.7), 1 hidden = high (0.9), 0 = max (1.0) + base_pressure = {0: 1.0, 1: 0.9, 2: 0.7, 3: 0.4}.get(min_opponent_hidden, 0.0) + + # Increase pressure further if WE have many hidden cards (more unknowns to worry about) + hidden_risk_bonus = (my_hidden - 2) * 0.05 # +0.05 per hidden card above 2 + hidden_risk_bonus = max(0, hidden_risk_bonus) + + return min(1.0, base_pressure + hidden_risk_bonus) + + def count_rank_in_hand(player: Player, rank: Rank) -> int: """Count how many cards of a given rank the player has visible.""" return sum(1 for c in player.cards if c.face_up and c.rank == rank) +def count_visible_cards_by_rank(game: Game) -> dict[Rank, int]: + """ + Count all visible cards of each rank across the entire table. + Includes: all face-up player cards + top of discard pile. + + Note: Buried discard cards are NOT counted because they reshuffle + back into the deck when it empties. + """ + counts: dict[Rank, int] = {rank: 0 for rank in Rank} + + # Count all face-up cards in all players' hands + for player in game.players: + for card in player.cards: + if card.face_up: + counts[card.rank] += 1 + + # Count top of discard pile (the only visible discard) + discard_top = game.discard_top() + if discard_top: + counts[discard_top.rank] += 1 + + return counts + + +def get_pair_viability(rank: Rank, game: Game, exclude_discard_top: bool = False) -> float: + """ + Calculate how viable it is to pair a card of this rank. + Returns 0.0-1.0 where higher means better odds of finding a pair. + + In a standard deck: 4 of each rank (2 Jokers). + If you can see N cards of that rank, only (4-N) remain. + + Args: + rank: The rank we want to pair + exclude_discard_top: If True, don't count discard top (useful when + evaluating taking that card - it won't be visible after) + """ + counts = count_visible_cards_by_rank(game) + visible = counts.get(rank, 0) + + # Adjust if we're evaluating the discard top card itself + if exclude_discard_top: + discard_top = game.discard_top() + if discard_top and discard_top.rank == rank: + visible = max(0, visible - 1) + + # Cards in deck for this rank + max_copies = 2 if rank == Rank.JOKER else 4 + remaining = max(0, max_copies - visible) + + # Viability scales with remaining copies + # 4 remaining = 1.0, 3 = 0.75, 2 = 0.5, 1 = 0.25, 0 = 0.0 + return remaining / max_copies + + +def get_game_phase(game: Game) -> str: + """ + Determine current game phase based on average hidden cards. + Returns: 'early', 'mid', or 'late' + """ + total_hidden = sum( + sum(1 for c in p.cards if not c.face_up) + for p in game.players + ) + avg_hidden = total_hidden / len(game.players) if game.players else 6 + + if avg_hidden >= 4.5: + return 'early' + elif avg_hidden >= 2.5: + return 'mid' + else: + return 'late' + + def has_worse_visible_card(player: Player, card_value: int, options: GameOptions) -> bool: """Check if player has a visible card worse than the given value. @@ -255,29 +358,10 @@ class GolfAI: if discard_card.rank == Rank.KING: return True - # Auto-take 7s when lucky_sevens enabled (they're worth 0) - if discard_card.rank == Rank.SEVEN and options.lucky_sevens: - return True - - # Auto-take 10s when ten_penny enabled (they're worth 0) + # Auto-take 10s when ten_penny enabled (they're worth 1) if discard_card.rank == Rank.TEN and options.ten_penny: return True - # Queens Wild: Queen can complete ANY pair - if options.queens_wild and discard_card.rank == Rank.QUEEN: - for i, card in enumerate(player.cards): - if card.face_up: - pair_pos = (i + 3) % 6 if i < 3 else i - 3 - if not player.cards[pair_pos].face_up: - # We have an incomplete column - Queen could pair it - return True - - # Four of a Kind: If we have 2+ of this rank, consider taking - if options.four_of_a_kind: - rank_count = count_rank_in_hand(player, discard_card.rank) - if rank_count >= 2: - return True - # Take card if it could make a column pair (but NOT for negative value cards) # Pairing negative cards is bad - you lose the negative benefit if discard_value > 0: @@ -289,15 +373,30 @@ class GolfAI: if card.face_up and card.rank == discard_card.rank and not pair_card.face_up: return True - # Queens Wild: check if we can pair with Queen - if options.queens_wild: - if card.face_up and can_make_pair(card, discard_card, options) and not pair_card.face_up: - return True - # Take low cards (using house rule adjusted values) - if discard_value <= 2: + # Threshold adjusts by game phase - early game be picky, late game less so + phase = get_game_phase(game) + base_threshold = {'early': 2, 'mid': 3, 'late': 4}.get(phase, 2) + + if discard_value <= base_threshold: return True + # Calculate end-game pressure from opponents close to going out + pressure = get_end_game_pressure(player, game) + + # Under pressure, expand what we consider "worth taking" + # When opponents are close to going out, take decent cards to avoid + # getting stuck with unknown bad cards when the round ends + if pressure > 0.2: + # Scale threshold: at pressure 0.2 take 4s, at 0.5+ take 6s + pressure_threshold = 3 + int(pressure * 6) # 4 to 9 based on pressure + pressure_threshold = min(pressure_threshold, 7) # Cap at 7 + if discard_value <= pressure_threshold: + # Only take if we have hidden cards that could be worse + my_hidden = sum(1 for c in player.cards if not c.face_up) + if my_hidden > 0: + return True + # Check if we have cards worse than the discard worst_visible = -999 for card in player.cards: @@ -338,15 +437,6 @@ class GolfAI: if not player.cards[pair_pos].face_up: return pair_pos - # Four of a Kind: If we have 3 of this rank and draw the 4th, prioritize keeping - if options.four_of_a_kind: - rank_count = count_rank_in_hand(player, drawn_card.rank) - if rank_count >= 3: - # We'd have 4 - swap into any face-down spot - face_down = [i for i, c in enumerate(player.cards) if not c.face_up] - if face_down: - return random.choice(face_down) - # Check for column pair opportunity first # But DON'T pair negative value cards (2s, Jokers) - keeping them unpaired is better! # Exception: Eagle Eye makes pairing Jokers GOOD (doubled negative) @@ -366,13 +456,6 @@ class GolfAI: if pair_card.face_up and pair_card.rank == drawn_card.rank and not card.face_up: return i - # Queens Wild: Queen can pair with anything - if options.queens_wild: - if card.face_up and can_make_pair(card, drawn_card, options) and not pair_card.face_up: - return pair_pos - if pair_card.face_up and can_make_pair(pair_card, drawn_card, options) and not card.face_up: - return i - # Find best swap among face-up cards that are BAD (positive value) # Don't swap good cards (Kings, 2s, etc.) just for marginal gains - # we want to keep good cards and put new good cards into face-down positions @@ -409,27 +492,44 @@ class GolfAI: return i # Consider swapping with face-down cards for very good cards (negative or zero value) - # 7s (lucky_sevens) and 10s (ten_penny) become "excellent" cards worth keeping + # 10s (ten_penny) become "excellent" cards worth keeping is_excellent = (drawn_value <= 0 or drawn_card.rank == Rank.ACE or - (options.lucky_sevens and drawn_card.rank == Rank.SEVEN) or (options.ten_penny and drawn_card.rank == Rank.TEN)) + # Calculate pair viability and game phase for smarter decisions + pair_viability = get_pair_viability(drawn_card.rank, game) + phase = get_game_phase(game) + pressure = get_end_game_pressure(player, game) + if is_excellent: face_down = [i for i, c in enumerate(player.cards) if not c.face_up] if face_down: # Pair hunters might hold out hoping for matches - if profile.pair_hope > 0.6 and random.random() < profile.pair_hope: + # BUT: reduce hope if pair is unlikely or late game pressure + effective_hope = profile.pair_hope * pair_viability + if phase == 'late' or pressure > 0.5: + effective_hope *= 0.3 # Much less willing to gamble late game + if effective_hope > 0.6 and random.random() < effective_hope: return None return random.choice(face_down) # For medium cards, swap threshold based on profile - if drawn_value <= profile.swap_threshold: + # Late game: be more willing to swap in medium cards + effective_threshold = profile.swap_threshold + if phase == 'late' or pressure > 0.5: + effective_threshold += 2 # Accept higher value cards under pressure + + if drawn_value <= effective_threshold: face_down = [i for i, c in enumerate(player.cards) if not c.face_up] if face_down: # Pair hunters hold high cards hoping for matches - if profile.pair_hope > 0.5 and drawn_value >= 6: - if random.random() < profile.pair_hope: + # BUT: check if pairing is actually viable + effective_hope = profile.pair_hope * pair_viability + if phase == 'late' or pressure > 0.5: + effective_hope *= 0.3 # Don't gamble late game + if effective_hope > 0.5 and drawn_value >= 6: + if random.random() < effective_hope: return None return random.choice(face_down) diff --git a/server/game.py b/server/game.py index 19edbc6..d5df571 100644 --- a/server/game.py +++ b/server/game.py @@ -8,7 +8,6 @@ from enum import Enum from constants import ( DEFAULT_CARD_VALUES, SUPER_KINGS_VALUE, - LUCKY_SEVENS_VALUE, TEN_PENNY_VALUE, LUCKY_SWING_JOKER_VALUE, ) @@ -58,11 +57,11 @@ def get_card_value(card: "Card", options: Optional["GameOptions"] = None) -> int """ if options: if card.rank == Rank.JOKER: + if options.eagle_eye: + return 2 # Eagle-eyed: jokers worth +2 unpaired, -4 when paired (handled in calculate_score) return LUCKY_SWING_JOKER_VALUE if options.lucky_swing else RANK_VALUES[Rank.JOKER] if card.rank == Rank.KING and options.super_kings: return SUPER_KINGS_VALUE - if card.rank == Rank.SEVEN and options.lucky_sevens: - return LUCKY_SEVENS_VALUE if card.rank == Rank.TEN and options.ten_penny: return TEN_PENNY_VALUE return RANK_VALUES[card.rank] @@ -148,58 +147,39 @@ class Player: if len(self.cards) != 6: return 0 - def cards_match(card1: Card, card2: Card) -> bool: - """Check if two cards match for pairing (with Queens Wild support).""" - if card1.rank == card2.rank: - return True - if options and options.queens_wild: - if card1.rank == Rank.QUEEN or card2.rank == Rank.QUEEN: - return True - return False - total = 0 + jack_pairs = 0 # Track paired Jacks for Wolfpack bonus + # Cards are arranged in 2 rows x 3 columns # Position mapping: [0, 1, 2] (top row) # [3, 4, 5] (bottom row) # Columns: (0,3), (1,4), (2,5) - # Check for Four of a Kind first (4 cards same rank = all score 0) - four_of_kind_positions: set[int] = set() - if options and options.four_of_a_kind: - from collections import Counter - rank_positions: dict[Rank, list[int]] = {} - for i, card in enumerate(self.cards): - if card.rank not in rank_positions: - rank_positions[card.rank] = [] - rank_positions[card.rank].append(i) - for rank, positions in rank_positions.items(): - if len(positions) >= 4: - four_of_kind_positions.update(positions) - for col in range(3): top_idx = col bottom_idx = col + 3 top_card = self.cards[top_idx] bottom_card = self.cards[bottom_idx] - # Skip if part of four of a kind - if top_idx in four_of_kind_positions and bottom_idx in four_of_kind_positions: - continue - - # Check if column pair matches (same rank or Queens Wild) - if cards_match(top_card, bottom_card): - # Eagle Eye: paired jokers score -8 (2³) instead of canceling + # Check if column pair matches (same rank) + if top_card.rank == bottom_card.rank: + # Track Jack pairs for Wolfpack + if top_card.rank == Rank.JACK: + jack_pairs += 1 + # Eagle Eye: paired jokers score -4 (reward for spotting the pair) if (options and options.eagle_eye and top_card.rank == Rank.JOKER and bottom_card.rank == Rank.JOKER): - total -= 8 + total -= 4 continue # Normal matching pair scores 0 continue else: - if top_idx not in four_of_kind_positions: - total += get_card_value(top_card, options) - if bottom_idx not in four_of_kind_positions: - total += get_card_value(bottom_card, options) + total += get_card_value(top_card, options) + total += get_card_value(bottom_card, options) + + # Wolfpack bonus: 2 pairs of Jacks = -5 pts + if options and options.wolfpack and jack_pairs >= 2: + total -= 5 self.score = total return total @@ -228,7 +208,6 @@ class GameOptions: # House Rules - Point Modifiers lucky_swing: bool = False # Single joker worth -5 instead of two -2 jokers super_kings: bool = False # Kings worth -2 instead of 0 - lucky_sevens: bool = False # 7s worth 0 instead of 7 ten_penny: bool = False # 10s worth 1 (like Ace) instead of 10 # House Rules - Bonuses/Penalties @@ -236,10 +215,9 @@ class GameOptions: underdog_bonus: bool = False # Lowest score player gets -3 each hole tied_shame: bool = False # Tie with someone's score = +5 penalty to both blackjack: bool = False # Hole score of exactly 21 becomes 0 + wolfpack: bool = False # 2 pairs of Jacks = -5 bonus - # House Rules - Gameplay Twists - queens_wild: bool = False # Queens count as any rank for pairing - four_of_a_kind: bool = False # 4 cards of same rank in grid = all 4 score 0 + # House Rules - Special eagle_eye: bool = False # Paired jokers double instead of cancel (-4 or -10) @@ -271,12 +249,12 @@ class Game: # Apply house rule modifications if self.options.super_kings: values['K'] = SUPER_KINGS_VALUE - if self.options.lucky_sevens: - values['7'] = LUCKY_SEVENS_VALUE if self.options.ten_penny: values['10'] = TEN_PENNY_VALUE if self.options.lucky_swing: values['ā˜…'] = LUCKY_SWING_JOKER_VALUE + elif self.options.eagle_eye: + values['ā˜…'] = 2 # Eagle-eyed: +2 unpaired, -4 paired return values @@ -613,6 +591,34 @@ class Game: discard_top = self.discard_top() + # Build active rules list for display + active_rules = [] + if self.options: + if self.options.flip_on_discard: + active_rules.append("Flip on Discard") + if self.options.knock_penalty: + active_rules.append("Knock Penalty") + if self.options.use_jokers and not self.options.lucky_swing and not self.options.eagle_eye: + active_rules.append("Jokers") + if self.options.lucky_swing: + active_rules.append("Lucky Swing") + if self.options.eagle_eye: + active_rules.append("Eagle-Eye") + if self.options.super_kings: + active_rules.append("Super Kings") + if self.options.ten_penny: + active_rules.append("Ten Penny") + if self.options.knock_bonus: + active_rules.append("Knock Bonus") + if self.options.underdog_bonus: + active_rules.append("Underdog") + if self.options.tied_shame: + active_rules.append("Tied Shame") + if self.options.blackjack: + active_rules.append("Blackjack") + if self.options.wolfpack: + active_rules.append("Wolfpack") + return { "phase": self.phase.value, "players": players_data, @@ -630,4 +636,5 @@ class Game: "initial_flips": self.options.initial_flips, "flip_on_discard": self.flip_on_discard, "card_values": self.get_card_values(), + "active_rules": active_rules, } diff --git a/server/main.py b/server/main.py index f6c2279..f41d21a 100644 --- a/server/main.py +++ b/server/main.py @@ -184,17 +184,14 @@ async def websocket_endpoint(websocket: WebSocket): # House Rules - Point Modifiers lucky_swing=data.get("lucky_swing", False), super_kings=data.get("super_kings", False), - lucky_sevens=data.get("lucky_sevens", False), ten_penny=data.get("ten_penny", False), # House Rules - Bonuses/Penalties knock_bonus=data.get("knock_bonus", False), underdog_bonus=data.get("underdog_bonus", False), tied_shame=data.get("tied_shame", False), blackjack=data.get("blackjack", False), - # House Rules - Gameplay Twists - queens_wild=data.get("queens_wild", False), - four_of_a_kind=data.get("four_of_a_kind", False), eagle_eye=data.get("eagle_eye", False), + wolfpack=data.get("wolfpack", False), ) # Validate settings @@ -331,6 +328,37 @@ async def websocket_endpoint(websocket: WebSocket): await handle_player_leave(current_room, player_id) current_room = None + elif msg_type == "leave_game": + # Player leaves during an active game + if current_room: + await handle_player_leave(current_room, player_id) + current_room = None + + elif msg_type == "end_game": + # Host ends the game for everyone + if not current_room: + continue + + room_player = current_room.get_player(player_id) + if not room_player or not room_player.is_host: + await websocket.send_json({ + "type": "error", + "message": "Only the host can end the game", + }) + continue + + # Notify all players that the game has ended + await current_room.broadcast({ + "type": "game_ended", + "reason": "Host ended the game", + }) + + # Clean up the room + for cpu in list(current_room.get_cpu_players()): + current_room.remove_player(cpu.id) + room_manager.remove_room(current_room.code) + current_room = None + except WebSocketDisconnect: if current_room: await handle_player_leave(current_room, player_id) diff --git a/server/test_house_rules.py b/server/test_house_rules.py index d353caf..526bd9d 100644 --- a/server/test_house_rules.py +++ b/server/test_house_rules.py @@ -235,11 +235,6 @@ def get_test_configs() -> list[tuple[str, GameOptions]]: super_kings=True, ))) - configs.append(("lucky_sevens", GameOptions( - initial_flips=2, - lucky_sevens=True, - ))) - configs.append(("ten_penny", GameOptions( initial_flips=2, ten_penny=True, @@ -267,17 +262,7 @@ def get_test_configs() -> list[tuple[str, GameOptions]]: blackjack=True, ))) - # === Gameplay Twists === - - configs.append(("queens_wild", GameOptions( - initial_flips=2, - queens_wild=True, - ))) - - configs.append(("four_of_a_kind", GameOptions( - initial_flips=2, - four_of_a_kind=True, - ))) + # === Special Rules === configs.append(("eagle_eye", GameOptions( initial_flips=2, @@ -292,7 +277,6 @@ def get_test_configs() -> list[tuple[str, GameOptions]]: use_jokers=True, lucky_swing=True, super_kings=True, - lucky_sevens=True, ten_penny=True, ))) @@ -311,8 +295,6 @@ def get_test_configs() -> list[tuple[str, GameOptions]]: configs.append(("WILD CARDS", GameOptions( initial_flips=2, use_jokers=True, - queens_wild=True, - four_of_a_kind=True, eagle_eye=True, ))) @@ -329,14 +311,11 @@ def get_test_configs() -> list[tuple[str, GameOptions]]: use_jokers=True, lucky_swing=True, super_kings=True, - lucky_sevens=True, ten_penny=True, knock_bonus=True, underdog_bonus=True, tied_shame=True, blackjack=True, - queens_wild=True, - four_of_a_kind=True, eagle_eye=True, ))) @@ -457,15 +436,6 @@ def print_expected_effects(results: list[RuleTestResult]): status = "āœ“" if diff < 0 else "āœ—" checks.append((r.name, expected, f"{actual} ({diff:+.1f})", status)) - # lucky_sevens should lower scores (7s worth 0 instead of 7) - r = find("lucky_sevens") - if r and r.scores: - diff = r.mean_score - baseline.mean_score - expected = "LOWER scores" - actual = "lower" if diff < -1 else "higher" if diff > 1 else "similar" - status = "āœ“" if diff < 0 else "āœ—" - checks.append((r.name, expected, f"{actual} ({diff:+.1f})", status)) - # ten_penny should lower scores (10s worth 1 instead of 10) r = find("ten_penny") if r and r.scores: diff --git a/server/test_maya_bug.py b/server/test_maya_bug.py index 9b9b3f6..31aab3f 100644 --- a/server/test_maya_bug.py +++ b/server/test_maya_bug.py @@ -145,7 +145,7 @@ class TestMayaBugFix: When forced to swap (drew from discard), the AI should use get_ai_card_value() to find the worst card, not raw value(). - This matters for house rules like super_kings, lucky_sevens, etc. + This matters for house rules like super_kings, ten_penny, etc. """ game = create_test_game() game.options = GameOptions(super_kings=True) # Kings now worth -2