Add final results modal, active rules display, and UI improvements

- Add big final results modal at game end with rankings and share button
- Add active rules bar showing enabled variants during gameplay
- Increase spacing between player cards and opponents row
- Add Wolfpack bonus rule (2 pairs of Jacks = -5 pts)
- Change joker options to radio buttons (None/Standard/Lucky Swing/Eagle-Eye)
- Update Eagle-Eye jokers: +2 pts unpaired, -4 pts paired
- Add card flip animation on discard pile
- Redesign waiting room layout with side-by-side columns
- Style card backs with red Bee-style diamond crosshatch pattern
- Compact standings panel to show top 4 per category
- Various CSS polish and responsive improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee 2026-01-24 23:53:07 -05:00
parent f4275c7a7d
commit 39b78a2ba6
10 changed files with 1283 additions and 438 deletions

View File

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

View File

@ -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 => `<span class="rule-tag">${rule}</span>`)
.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 = `
<h4>${displayName}${player.all_face_up ? ' ✓' : ''}</h4>
<h4>${displayName}${player.all_face_up ? ' ✓' : ''}<span class="opponent-showing">${showingScore}</span></h4>
<div class="card-grid">
${player.cards.map(card => this.renderCard(card, false, false)).join('')}
</div>
@ -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 `<div class="rank-row ${pointsRank === 0 ? 'leader' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${name}</span><span class="rank-val">${p.total_score}pt</span></div>`;
}).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 `<div class="rank-row ${holesRank === 0 && p.rounds_won > 0 ? 'leader' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${name}</span><span class="rank-val">${p.rounds_won}W</span></div>`;
}).join('');
const displayName = player.name.length > 12
? player.name.substring(0, 11) + '…'
: player.name;
div.innerHTML = `
<span class="standing-pos">${index + 1}.</span>
<span class="standing-name">${displayName}</span>
<span class="standing-score">${player.total_score} pts</span>
`;
this.standingsList.appendChild(div);
});
this.standingsList.innerHTML = `
<div class="standings-section">
<div class="standings-title">By Score</div>
${pointsHtml}
</div>
<div class="standings-section">
<div class="standings-title">By Holes</div>
${holesHtml}
</div>
`;
}
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 `<div class="rank-row ${holesRank === 0 && p.rounds_won > 0 ? 'leader' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${name}</span><span class="rank-val">${p.rounds_won}W</span></div>`;
}).join('');
const doubleVictoryHtml = isDoubleVictory
? `<div class="double-victory">DOUBLE VICTORY! ${pointsLeader.name}</div>`
: '';
// 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 = `
<h3>${title}</h3>
${doubleVictoryHtml}
<div class="rankings-columns">
<div class="ranking-section">
<h4>Points (Low Wins)</h4>
@ -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 `<div class="final-rank-row ${pointsRank === 0 ? 'winner' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${p.name}</span><span class="rank-val">${p.total} pts</span></div>`;
}).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 `<div class="final-rank-row ${holesRank === 0 && p.rounds_won > 0 ? 'winner' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${p.name}</span><span class="rank-val">${p.rounds_won} wins</span></div>`;
}).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 = `
<div class="final-results-content">
<h2>🏌 Final Results</h2>
${isDoubleVictory ? `<div class="double-victory-banner">🏆 DOUBLE VICTORY: ${pointsLeader.name} 🏆</div>` : ''}
<div class="final-rankings">
<div class="final-ranking-section">
<h3>By Points (Low Wins)</h3>
${pointsHtml}
</div>
<div class="final-ranking-section">
<h3>By Holes Won</h3>
${holesHtml}
</div>
</div>
<div class="final-actions">
<button class="btn btn-primary" id="share-results-btn">📋 Copy Results</button>
<button class="btn btn-secondary" id="close-results-btn">New Game</button>
</div>
</div>
`;
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

View File

@ -38,151 +38,164 @@
<!-- Waiting Room Screen -->
<div id="waiting-screen" class="screen">
<h2>Room: <span id="display-room-code"></span></h2>
<div class="players-list">
<h3>Players</h3>
<ul id="players-list"></ul>
<div class="room-code-banner">
<span class="room-code-label">ROOM CODE</span>
<span class="room-code-value" id="display-room-code"></span>
<button class="room-code-copy" id="copy-room-code" title="Copy to clipboard">📋</button>
</div>
<div id="host-settings" class="settings hidden">
<h3>Game Settings</h3>
<div class="form-group">
<label>CPU Players</label>
<div class="cpu-controls">
<button id="remove-cpu-btn" class="btn btn-small btn-secondary">- CPU</button>
<button id="add-cpu-btn" class="btn btn-small btn-primary">+ CPU</button>
</div>
</div>
<div class="form-group">
<label for="num-decks">Number of Decks</label>
<select id="num-decks">
<option value="1">1 Deck (2-4 players)</option>
<option value="2">2 Decks (4-6 players)</option>
<option value="3">3 Decks (5-6 players)</option>
</select>
<p id="deck-recommendation" class="recommendation hidden">Strongly recommended: 2+ decks for 4+ players to avoid running out of cards</p>
</div>
<div class="form-group">
<label for="num-rounds">Number of Holes</label>
<select id="num-rounds">
<option value="9" selected>9 Holes (Front Nine)</option>
<option value="18">18 Holes (Full Round)</option>
<option value="3">3 Holes (Quick Game)</option>
<option value="1">1 Hole</option>
</select>
</div>
<div class="form-group">
<label for="initial-flips">Starting Cards Revealed</label>
<select id="initial-flips">
<option value="2" selected>2 cards (Standard)</option>
<option value="1">1 card</option>
<option value="0">None (Blind start)</option>
</select>
</div>
<div class="form-group">
<label>Variants</label>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="flip-on-discard">
<span>Flip card when discarding from deck</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="knock-penalty">
<span>+10 penalty if you go out but don't have lowest</span>
</label>
<div class="waiting-layout">
<div class="waiting-left-col">
<div class="players-list">
<h3>Players</h3>
<ul id="players-list"></ul>
</div>
<button id="leave-room-btn" class="btn btn-danger">Leave Room</button>
</div>
<details class="house-rules-section">
<summary>House Rules</summary>
<div class="house-rules-category">
<h4>Jokers</h4>
<div class="form-group compact">
<select id="joker-mode">
<option value="none">No Jokers</option>
<option value="standard">Standard (2 per deck, -2 each)</option>
<option value="lucky-swing">Lucky Swing (1 joker in all decks, -5 pts)</option>
<div id="host-settings" class="settings hidden">
<h3>Game Settings</h3>
<div class="basic-settings-row">
<div class="form-group">
<label>CPU Players</label>
<div class="cpu-controls">
<button id="remove-cpu-btn" class="btn btn-small btn-danger">(-) Delete</button>
<button id="add-cpu-btn" class="btn btn-small btn-success">(+) Add</button>
</div>
</div>
<div class="form-group">
<label for="num-decks">Decks</label>
<select id="num-decks">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
</div>
<div class="form-group">
<label for="num-rounds">Holes</label>
<select id="num-rounds">
<option value="9" selected>9</option>
<option value="18">18</option>
<option value="3">3</option>
<option value="1">1</option>
</select>
</div>
<div class="form-group">
<label for="initial-flips">Cards Revealed</label>
<select id="initial-flips">
<option value="2" selected>2 cards</option>
<option value="1">1 card</option>
<option value="0">None</option>
</select>
<label class="checkbox-label eagle-eye-option hidden" id="eagle-eye-label">
<input type="checkbox" id="eagle-eye">
<span>Eagle Eye</span>
<span class="rule-desc">Paired jokers score -8</span>
</label>
</div>
</div>
<p id="deck-recommendation" class="recommendation hidden">Recommended: 2+ decks for 4+ players</p>
<details class="advanced-options-section">
<summary>Advanced Options</summary>
<div class="house-rules-category">
<h4>Point Modifiers</h4>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="super-kings">
<span>Super Kings</span>
<span class="rule-desc">Kings worth -2 instead of 0</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="lucky-sevens">
<span>Lucky Sevens</span>
<span class="rule-desc">7s worth 0 instead of 7</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="ten-penny">
<span>Ten Penny</span>
<span class="rule-desc">10s worth 1 (like Ace)</span>
</label>
<div class="advanced-options-grid">
<!-- Left Column: Variants & Jokers -->
<div class="options-column">
<div class="options-category">
<h4>Variants</h4>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="flip-on-discard">
<span>Flip on Discard</span>
<span class="rule-desc">Flip card when discarding from deck</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="knock-penalty">
<span>Knock Penalty</span>
<span class="rule-desc">+10 if you go out but don't have lowest</span>
</label>
</div>
</div>
<div class="options-category">
<h4>Jokers</h4>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="joker-mode" value="none" checked>
<span>None</span>
</label>
<label class="radio-label">
<input type="radio" name="joker-mode" value="standard">
<span>Standard</span>
<span class="rule-desc">2 per deck, -2 pts / 0 paired</span>
</label>
<label class="radio-label">
<input type="radio" name="joker-mode" value="lucky-swing">
<span>Lucky Swing</span>
<span class="rule-desc">1-2-3 decks - 1 Joker, -5 pt</span>
</label>
<label class="radio-label">
<input type="radio" name="joker-mode" value="eagle-eye">
<span>Eagle-Eyed</span>
<span class="rule-desc">★ = +2 pts, -4 pts paired</span>
</label>
</div>
</div>
<div class="options-category">
<h4>Point Modifiers</h4>
<div class="checkbox-group">
<label class="checkbox-label inline">
<input type="checkbox" id="super-kings">
<span>Super Kings</span>
<span class="rule-desc">K = -2 pts</span>
</label>
<label class="checkbox-label inline">
<input type="checkbox" id="ten-penny">
<span>Ten Penny</span>
<span class="rule-desc">10 = 1 pt</span>
</label>
</div>
</div>
</div>
</div>
<div class="house-rules-category">
<h4>Bonuses & Penalties</h4>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="knock-bonus">
<span>Knock Out Bonus</span>
<span class="rule-desc">-5 for going out first</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="underdog-bonus">
<span>Underdog Bonus</span>
<span class="rule-desc">-3 for lowest score each hole</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="tied-shame">
<span>Tied Shame</span>
<span class="rule-desc">+5 if you tie with someone</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="blackjack">
<span>Blackjack</span>
<span class="rule-desc">Exact 21 becomes 0</span>
</label>
</div>
</div>
<!-- Right Column: Bonuses & Gameplay -->
<div class="options-column">
<div class="options-category">
<h4>Bonuses & Penalties</h4>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="knock-bonus">
<span>Knock Out Bonus</span>
<span class="rule-desc">-5 for going out first</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="underdog-bonus">
<span>Underdog Bonus</span>
<span class="rule-desc">-3 for lowest score each hole</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="tied-shame">
<span>Tied Shame</span>
<span class="rule-desc">+5 if you tie with someone</span>
</label>
<label class="checkbox-label inline">
<input type="checkbox" id="blackjack">
<span>Blackjack</span>
<span class="rule-desc">21 pts = 0 pts</span>
</label>
<label class="checkbox-label inline">
<input type="checkbox" id="wolfpack">
<span>Wolfpack</span>
<span class="rule-desc">2 pairs of Jacks = -5 pts</span>
</label>
</div>
</div>
<div class="house-rules-category">
<h4>Gameplay Twists</h4>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="queens-wild">
<span>Queens Wild</span>
<span class="rule-desc">Queens pair with any rank</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="four-of-a-kind">
<span>Four of a Kind</span>
<span class="rule-desc">4 matching cards all score 0</span>
</label>
</div>
</div>
</details>
<button id="start-game-btn" class="btn btn-primary">Start Game</button>
<button id="start-game-btn" class="btn btn-primary">Start Game</button>
</div>
<p id="waiting-message" class="info">Waiting for host to start the game...</p>
</div>
<p id="waiting-message" class="info">Waiting for host to start the game...</p>
<button id="leave-room-btn" class="btn btn-danger">Leave Room</button>
</div>
<!-- Game Screen -->
@ -193,7 +206,15 @@
<div class="round-info">Hole <span id="current-round">1</span>/<span id="total-rounds">9</span></div>
<div class="turn-info" id="turn-info">Your turn</div>
<div class="score-info">Showing: <span id="your-score">0</span></div>
<button id="mute-btn" class="mute-btn" title="Toggle sound">🔊</button>
<div class="header-buttons">
<button id="leave-game-btn" class="btn btn-small btn-danger">Leave</button>
<button id="mute-btn" class="mute-btn" title="Toggle sound">🔊</button>
</div>
</div>
<div id="active-rules-bar" class="active-rules-bar hidden">
<span class="rules-label">Rules:</span>
<span id="active-rules-list" class="rules-list"></span>
</div>
<div class="game-table">
@ -203,7 +224,7 @@
<div class="table-center">
<div class="deck-area">
<div id="deck" class="card card-back">
<span>DECK</span>
<span>?</span>
</div>
<div id="discard" class="card">
<span id="discard-content"></span>
@ -228,7 +249,7 @@
<!-- Left panel: Standings -->
<div id="standings-panel" class="side-panel left-panel">
<h4>Standings</h4>
<h4>Current Standings</h4>
<div id="standings-list" class="standings-list"></div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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