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:
parent
f4275c7a7d
commit
39b78a2ba6
@ -74,7 +74,6 @@ When a player reveals all 6 cards, others get one final turn. Lowest score wins.
|
|||||||
|
|
||||||
### Point Modifiers
|
### Point Modifiers
|
||||||
- `super_kings` - Kings worth -2 (instead of 0)
|
- `super_kings` - Kings worth -2 (instead of 0)
|
||||||
- `lucky_sevens` - 7s worth 0 (instead of 7)
|
|
||||||
- `ten_penny` - 10s worth 1 (instead of 10)
|
- `ten_penny` - 10s worth 1 (instead of 10)
|
||||||
- `lucky_swing` - Single Joker worth -5
|
- `lucky_swing` - Single Joker worth -5
|
||||||
- `eagle_eye` - Paired Jokers score -8
|
- `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
|
- `tied_shame` - +5 penalty for tied scores
|
||||||
- `blackjack` - Score of exactly 21 becomes 0
|
- `blackjack` - Score of exactly 21 becomes 0
|
||||||
|
|
||||||
### Gameplay Twists
|
### Gameplay Options
|
||||||
- `flip_on_discard` - Must flip a card when discarding from deck
|
- `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
|
- `use_jokers` - Add Jokers to deck
|
||||||
|
- `eagle_eye` - Paired Jokers score -8 instead of canceling
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
349
client/app.js
349
client/app.js
@ -62,6 +62,15 @@ class GolfGame {
|
|||||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
|
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
|
||||||
oscillator.start(ctx.currentTime);
|
oscillator.start(ctx.currentTime);
|
||||||
oscillator.stop(ctx.currentTime + 0.2);
|
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') {
|
} else if (type === 'shuffle') {
|
||||||
// Multiple quick sounds to simulate shuffling
|
// Multiple quick sounds to simulate shuffling
|
||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
@ -102,6 +111,7 @@ class GolfGame {
|
|||||||
|
|
||||||
// Waiting room elements
|
// Waiting room elements
|
||||||
this.displayRoomCode = document.getElementById('display-room-code');
|
this.displayRoomCode = document.getElementById('display-room-code');
|
||||||
|
this.copyRoomCodeBtn = document.getElementById('copy-room-code');
|
||||||
this.playersList = document.getElementById('players-list');
|
this.playersList = document.getElementById('players-list');
|
||||||
this.hostSettings = document.getElementById('host-settings');
|
this.hostSettings = document.getElementById('host-settings');
|
||||||
this.waitingMessage = document.getElementById('waiting-message');
|
this.waitingMessage = document.getElementById('waiting-message');
|
||||||
@ -111,21 +121,15 @@ class GolfGame {
|
|||||||
this.initialFlipsSelect = document.getElementById('initial-flips');
|
this.initialFlipsSelect = document.getElementById('initial-flips');
|
||||||
this.flipOnDiscardCheckbox = document.getElementById('flip-on-discard');
|
this.flipOnDiscardCheckbox = document.getElementById('flip-on-discard');
|
||||||
this.knockPenaltyCheckbox = document.getElementById('knock-penalty');
|
this.knockPenaltyCheckbox = document.getElementById('knock-penalty');
|
||||||
this.jokerModeSelect = document.getElementById('joker-mode');
|
|
||||||
// House Rules - Point Modifiers
|
// House Rules - Point Modifiers
|
||||||
this.superKingsCheckbox = document.getElementById('super-kings');
|
this.superKingsCheckbox = document.getElementById('super-kings');
|
||||||
this.luckySevensCheckbox = document.getElementById('lucky-sevens');
|
|
||||||
this.tenPennyCheckbox = document.getElementById('ten-penny');
|
this.tenPennyCheckbox = document.getElementById('ten-penny');
|
||||||
// House Rules - Bonuses/Penalties
|
// House Rules - Bonuses/Penalties
|
||||||
this.knockBonusCheckbox = document.getElementById('knock-bonus');
|
this.knockBonusCheckbox = document.getElementById('knock-bonus');
|
||||||
this.underdogBonusCheckbox = document.getElementById('underdog-bonus');
|
this.underdogBonusCheckbox = document.getElementById('underdog-bonus');
|
||||||
this.tiedShameCheckbox = document.getElementById('tied-shame');
|
this.tiedShameCheckbox = document.getElementById('tied-shame');
|
||||||
this.blackjackCheckbox = document.getElementById('blackjack');
|
this.blackjackCheckbox = document.getElementById('blackjack');
|
||||||
// House Rules - Gameplay Twists
|
this.wolfpackCheckbox = document.getElementById('wolfpack');
|
||||||
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.startGameBtn = document.getElementById('start-game-btn');
|
this.startGameBtn = document.getElementById('start-game-btn');
|
||||||
this.leaveRoomBtn = document.getElementById('leave-room-btn');
|
this.leaveRoomBtn = document.getElementById('leave-room-btn');
|
||||||
this.addCpuBtn = document.getElementById('add-cpu-btn');
|
this.addCpuBtn = document.getElementById('add-cpu-btn');
|
||||||
@ -157,6 +161,9 @@ class GolfGame {
|
|||||||
this.gameButtons = document.getElementById('game-buttons');
|
this.gameButtons = document.getElementById('game-buttons');
|
||||||
this.nextRoundBtn = document.getElementById('next-round-btn');
|
this.nextRoundBtn = document.getElementById('next-round-btn');
|
||||||
this.newGameBtn = document.getElementById('new-game-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() {
|
bindEvents() {
|
||||||
@ -174,6 +181,13 @@ class GolfGame {
|
|||||||
this.cancelCpuBtn.addEventListener('click', () => { this.playSound('click'); this.hideCpuSelect(); });
|
this.cancelCpuBtn.addEventListener('click', () => { this.playSound('click'); this.hideCpuSelect(); });
|
||||||
this.addSelectedCpusBtn.addEventListener('click', () => { this.playSound('success'); this.addSelectedCpus(); });
|
this.addSelectedCpusBtn.addEventListener('click', () => { this.playSound('success'); this.addSelectedCpus(); });
|
||||||
this.muteBtn.addEventListener('click', () => this.toggleSound());
|
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
|
// Enter key handlers
|
||||||
this.playerNameInput.addEventListener('keypress', (e) => {
|
this.playerNameInput.addEventListener('keypress', (e) => {
|
||||||
@ -188,20 +202,6 @@ class GolfGame {
|
|||||||
e.target.value = e.target.value.toUpperCase();
|
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
|
// Update deck recommendation when deck selection changes
|
||||||
this.numDecksSelect.addEventListener('change', () => {
|
this.numDecksSelect.addEventListener('change', () => {
|
||||||
const playerCount = this.currentPlayers ? this.currentPlayers.length : 0;
|
const playerCount = this.currentPlayers ? this.currentPlayers.length : 0;
|
||||||
@ -322,6 +322,15 @@ class GolfGame {
|
|||||||
this.showScoreboard(data.final_scores, true, data.rankings);
|
this.showScoreboard(data.final_scores, true, data.rankings);
|
||||||
break;
|
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':
|
case 'error':
|
||||||
this.showError(data.message);
|
this.showError(data.message);
|
||||||
break;
|
break;
|
||||||
@ -358,6 +367,26 @@ class GolfGame {
|
|||||||
this.showLobby();
|
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() {
|
startGame() {
|
||||||
const decks = parseInt(this.numDecksSelect.value);
|
const decks = parseInt(this.numDecksSelect.value);
|
||||||
const rounds = parseInt(this.numRoundsSelect.value);
|
const rounds = parseInt(this.numRoundsSelect.value);
|
||||||
@ -367,14 +396,14 @@ class GolfGame {
|
|||||||
const flip_on_discard = this.flipOnDiscardCheckbox.checked;
|
const flip_on_discard = this.flipOnDiscardCheckbox.checked;
|
||||||
const knock_penalty = this.knockPenaltyCheckbox.checked;
|
const knock_penalty = this.knockPenaltyCheckbox.checked;
|
||||||
|
|
||||||
// Joker mode
|
// Joker mode (radio buttons)
|
||||||
const joker_mode = this.jokerModeSelect.value;
|
const joker_mode = document.querySelector('input[name="joker-mode"]:checked').value;
|
||||||
const use_jokers = joker_mode !== 'none';
|
const use_jokers = joker_mode !== 'none';
|
||||||
const lucky_swing = joker_mode === 'lucky-swing';
|
const lucky_swing = joker_mode === 'lucky-swing';
|
||||||
|
const eagle_eye = joker_mode === 'eagle-eye';
|
||||||
|
|
||||||
// House Rules - Point Modifiers
|
// House Rules - Point Modifiers
|
||||||
const super_kings = this.superKingsCheckbox.checked;
|
const super_kings = this.superKingsCheckbox.checked;
|
||||||
const lucky_sevens = this.luckySevensCheckbox.checked;
|
|
||||||
const ten_penny = this.tenPennyCheckbox.checked;
|
const ten_penny = this.tenPennyCheckbox.checked;
|
||||||
|
|
||||||
// House Rules - Bonuses/Penalties
|
// House Rules - Bonuses/Penalties
|
||||||
@ -382,11 +411,7 @@ class GolfGame {
|
|||||||
const underdog_bonus = this.underdogBonusCheckbox.checked;
|
const underdog_bonus = this.underdogBonusCheckbox.checked;
|
||||||
const tied_shame = this.tiedShameCheckbox.checked;
|
const tied_shame = this.tiedShameCheckbox.checked;
|
||||||
const blackjack = this.blackjackCheckbox.checked;
|
const blackjack = this.blackjackCheckbox.checked;
|
||||||
|
const wolfpack = this.wolfpackCheckbox.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;
|
|
||||||
|
|
||||||
this.send({
|
this.send({
|
||||||
type: 'start_game',
|
type: 'start_game',
|
||||||
@ -398,15 +423,13 @@ class GolfGame {
|
|||||||
use_jokers,
|
use_jokers,
|
||||||
lucky_swing,
|
lucky_swing,
|
||||||
super_kings,
|
super_kings,
|
||||||
lucky_sevens,
|
|
||||||
ten_penny,
|
ten_penny,
|
||||||
knock_bonus,
|
knock_bonus,
|
||||||
underdog_bonus,
|
underdog_bonus,
|
||||||
tied_shame,
|
tied_shame,
|
||||||
blackjack,
|
blackjack,
|
||||||
queens_wild,
|
eagle_eye,
|
||||||
four_of_a_kind,
|
wolfpack
|
||||||
eagle_eye
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -549,7 +572,7 @@ class GolfGame {
|
|||||||
if (this.gameState.waiting_for_initial_flip) {
|
if (this.gameState.waiting_for_initial_flip) {
|
||||||
if (card.face_up) return;
|
if (card.face_up) return;
|
||||||
|
|
||||||
this.playSound('card');
|
this.playSound('flip');
|
||||||
const requiredFlips = this.gameState.initial_flips || 2;
|
const requiredFlips = this.gameState.initial_flips || 2;
|
||||||
|
|
||||||
if (this.selectedCards.includes(position)) {
|
if (this.selectedCards.includes(position)) {
|
||||||
@ -594,6 +617,22 @@ class GolfGame {
|
|||||||
this.leaveRoom();
|
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
|
// UI Helpers
|
||||||
showScreen(screen) {
|
showScreen(screen) {
|
||||||
this.lobbyScreen.classList.remove('active');
|
this.lobbyScreen.classList.remove('active');
|
||||||
@ -630,6 +669,28 @@ class GolfGame {
|
|||||||
this.drawnCard = null;
|
this.drawnCard = null;
|
||||||
this.selectedCards = [];
|
this.selectedCards = [];
|
||||||
this.waitingForFlip = false;
|
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) {
|
showError(message) {
|
||||||
@ -825,6 +886,15 @@ class GolfGame {
|
|||||||
// Update discard pile
|
// Update discard pile
|
||||||
if (this.gameState.discard_top) {
|
if (this.gameState.discard_top) {
|
||||||
const discardCard = 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.add('has-card', 'card-front');
|
||||||
this.discard.classList.remove('card-back', 'red', 'black', 'joker');
|
this.discard.classList.remove('card-back', 'red', 'black', 'joker');
|
||||||
|
|
||||||
@ -839,6 +909,7 @@ class GolfGame {
|
|||||||
} else {
|
} else {
|
||||||
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker');
|
this.discard.classList.remove('has-card', 'card-front', 'red', 'black', 'joker');
|
||||||
this.discardContent.innerHTML = '';
|
this.discardContent.innerHTML = '';
|
||||||
|
this.lastDiscardKey = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update deck/discard clickability and visual state
|
// 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 displayName = player.name.length > 12 ? player.name.substring(0, 11) + '…' : player.name;
|
||||||
|
const showingScore = this.calculateShowingScore(player.cards);
|
||||||
|
|
||||||
div.innerHTML = `
|
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">
|
<div class="card-grid">
|
||||||
${player.cards.map(card => this.renderCard(card, false, false)).join('')}
|
${player.cards.map(card => this.renderCard(card, false, false)).join('')}
|
||||||
</div>
|
</div>
|
||||||
@ -960,34 +1032,48 @@ class GolfGame {
|
|||||||
updateStandings() {
|
updateStandings() {
|
||||||
if (!this.gameState || !this.standingsList) return;
|
if (!this.gameState || !this.standingsList) return;
|
||||||
|
|
||||||
// Sort players by total score (lowest is best in golf)
|
// Sort by total points (lowest wins) - top 4
|
||||||
const sorted = [...this.gameState.players].sort((a, b) => a.total_score - b.total_score);
|
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 = '';
|
// Build points ranking
|
||||||
|
let pointsRank = 0;
|
||||||
sorted.forEach((player, index) => {
|
let prevPoints = null;
|
||||||
const div = document.createElement('div');
|
const pointsHtml = byPoints.map((p, i) => {
|
||||||
div.className = 'standing-row';
|
if (p.total_score !== prevPoints) {
|
||||||
|
pointsRank = i;
|
||||||
if (index === 0 && player.total_score < sorted[sorted.length - 1]?.total_score) {
|
prevPoints = p.total_score;
|
||||||
div.classList.add('leader');
|
|
||||||
}
|
}
|
||||||
if (player.id === this.playerId) {
|
const medal = pointsRank === 0 ? '🥇' : pointsRank === 1 ? '🥈' : pointsRank === 2 ? '🥉' : '4.';
|
||||||
div.classList.add('you');
|
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
|
this.standingsList.innerHTML = `
|
||||||
? player.name.substring(0, 11) + '…'
|
<div class="standings-section">
|
||||||
: player.name;
|
<div class="standings-title">By Score</div>
|
||||||
|
${pointsHtml}
|
||||||
div.innerHTML = `
|
</div>
|
||||||
<span class="standing-pos">${index + 1}.</span>
|
<div class="standings-section">
|
||||||
<span class="standing-name">${displayName}</span>
|
<div class="standings-title">By Holes</div>
|
||||||
<span class="standing-score">${player.total_score} pts</span>
|
${holesHtml}
|
||||||
`;
|
</div>
|
||||||
|
`;
|
||||||
this.standingsList.appendChild(div);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderCard(card, clickable, selected) {
|
renderCard(card, clickable, selected) {
|
||||||
@ -1043,16 +1129,20 @@ class GolfGame {
|
|||||||
this.scoreTable.appendChild(tr);
|
this.scoreTable.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show rankings announcement
|
// Show rankings announcement only for final results
|
||||||
this.showRankingsAnnouncement(rankings, isFinal);
|
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
|
// Show game buttons
|
||||||
this.gameButtons.classList.remove('hidden');
|
this.gameButtons.classList.remove('hidden');
|
||||||
|
|
||||||
if (isFinal) {
|
if (this.isHost) {
|
||||||
this.nextRoundBtn.classList.add('hidden');
|
|
||||||
this.newGameBtn.classList.remove('hidden');
|
|
||||||
} else if (this.isHost) {
|
|
||||||
this.nextRoundBtn.classList.remove('hidden');
|
this.nextRoundBtn.classList.remove('hidden');
|
||||||
this.newGameBtn.classList.add('hidden');
|
this.newGameBtn.classList.add('hidden');
|
||||||
} else {
|
} else {
|
||||||
@ -1065,6 +1155,8 @@ class GolfGame {
|
|||||||
// Remove existing announcement if any
|
// Remove existing announcement if any
|
||||||
const existing = document.getElementById('rankings-announcement');
|
const existing = document.getElementById('rankings-announcement');
|
||||||
if (existing) existing.remove();
|
if (existing) existing.remove();
|
||||||
|
const existingVictory = document.getElementById('double-victory-banner');
|
||||||
|
if (existingVictory) existingVictory.remove();
|
||||||
|
|
||||||
if (!rankings) return;
|
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>`;
|
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('');
|
}).join('');
|
||||||
|
|
||||||
const doubleVictoryHtml = isDoubleVictory
|
// If double victory, show banner above the left panel (standings)
|
||||||
? `<div class="double-victory">DOUBLE VICTORY! ${pointsLeader.name}</div>`
|
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 = `
|
announcement.innerHTML = `
|
||||||
<h3>${title}</h3>
|
<h3>${title}</h3>
|
||||||
${doubleVictoryHtml}
|
|
||||||
<div class="rankings-columns">
|
<div class="rankings-columns">
|
||||||
<div class="ranking-section">
|
<div class="ranking-section">
|
||||||
<h4>Points (Low Wins)</h4>
|
<h4>Points (Low Wins)</h4>
|
||||||
@ -1131,6 +1230,118 @@ class GolfGame {
|
|||||||
// Insert before the scoreboard
|
// Insert before the scoreboard
|
||||||
this.scoreboard.insertBefore(announcement, this.scoreboard.firstChild);
|
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
|
// Initialize game when page loads
|
||||||
|
|||||||
@ -38,151 +38,164 @@
|
|||||||
|
|
||||||
<!-- Waiting Room Screen -->
|
<!-- Waiting Room Screen -->
|
||||||
<div id="waiting-screen" class="screen">
|
<div id="waiting-screen" class="screen">
|
||||||
<h2>Room: <span id="display-room-code"></span></h2>
|
<div class="room-code-banner">
|
||||||
|
<span class="room-code-label">ROOM CODE</span>
|
||||||
<div class="players-list">
|
<span class="room-code-value" id="display-room-code"></span>
|
||||||
<h3>Players</h3>
|
<button class="room-code-copy" id="copy-room-code" title="Copy to clipboard">📋</button>
|
||||||
<ul id="players-list"></ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="host-settings" class="settings hidden">
|
<div class="waiting-layout">
|
||||||
<h3>Game Settings</h3>
|
<div class="waiting-left-col">
|
||||||
<div class="form-group">
|
<div class="players-list">
|
||||||
<label>CPU Players</label>
|
<h3>Players</h3>
|
||||||
<div class="cpu-controls">
|
<ul id="players-list"></ul>
|
||||||
<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>
|
</div>
|
||||||
|
<button id="leave-room-btn" class="btn btn-danger">Leave Room</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details class="house-rules-section">
|
<div id="host-settings" class="settings hidden">
|
||||||
<summary>House Rules</summary>
|
<h3>Game Settings</h3>
|
||||||
|
<div class="basic-settings-row">
|
||||||
<div class="house-rules-category">
|
<div class="form-group">
|
||||||
<h4>Jokers</h4>
|
<label>CPU Players</label>
|
||||||
<div class="form-group compact">
|
<div class="cpu-controls">
|
||||||
<select id="joker-mode">
|
<button id="remove-cpu-btn" class="btn btn-small btn-danger">(-) Delete</button>
|
||||||
<option value="none">No Jokers</option>
|
<button id="add-cpu-btn" class="btn btn-small btn-success">(+) Add</button>
|
||||||
<option value="standard">Standard (2 per deck, -2 each)</option>
|
</div>
|
||||||
<option value="lucky-swing">Lucky Swing (1 joker in all decks, -5 pts)</option>
|
</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>
|
</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>
|
||||||
</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">
|
<div class="advanced-options-grid">
|
||||||
<h4>Point Modifiers</h4>
|
<!-- Left Column: Variants & Jokers -->
|
||||||
<div class="checkbox-group">
|
<div class="options-column">
|
||||||
<label class="checkbox-label">
|
<div class="options-category">
|
||||||
<input type="checkbox" id="super-kings">
|
<h4>Variants</h4>
|
||||||
<span>Super Kings</span>
|
<div class="checkbox-group">
|
||||||
<span class="rule-desc">Kings worth -2 instead of 0</span>
|
<label class="checkbox-label">
|
||||||
</label>
|
<input type="checkbox" id="flip-on-discard">
|
||||||
<label class="checkbox-label">
|
<span>Flip on Discard</span>
|
||||||
<input type="checkbox" id="lucky-sevens">
|
<span class="rule-desc">Flip card when discarding from deck</span>
|
||||||
<span>Lucky Sevens</span>
|
</label>
|
||||||
<span class="rule-desc">7s worth 0 instead of 7</span>
|
<label class="checkbox-label">
|
||||||
</label>
|
<input type="checkbox" id="knock-penalty">
|
||||||
<label class="checkbox-label">
|
<span>Knock Penalty</span>
|
||||||
<input type="checkbox" id="ten-penny">
|
<span class="rule-desc">+10 if you go out but don't have lowest</span>
|
||||||
<span>Ten Penny</span>
|
</label>
|
||||||
<span class="rule-desc">10s worth 1 (like Ace)</span>
|
</div>
|
||||||
</label>
|
</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>
|
|
||||||
|
|
||||||
<div class="house-rules-category">
|
<!-- Right Column: Bonuses & Gameplay -->
|
||||||
<h4>Bonuses & Penalties</h4>
|
<div class="options-column">
|
||||||
<div class="checkbox-group">
|
<div class="options-category">
|
||||||
<label class="checkbox-label">
|
<h4>Bonuses & Penalties</h4>
|
||||||
<input type="checkbox" id="knock-bonus">
|
<div class="checkbox-group">
|
||||||
<span>Knock Out Bonus</span>
|
<label class="checkbox-label">
|
||||||
<span class="rule-desc">-5 for going out first</span>
|
<input type="checkbox" id="knock-bonus">
|
||||||
</label>
|
<span>Knock Out Bonus</span>
|
||||||
<label class="checkbox-label">
|
<span class="rule-desc">-5 for going out first</span>
|
||||||
<input type="checkbox" id="underdog-bonus">
|
</label>
|
||||||
<span>Underdog Bonus</span>
|
<label class="checkbox-label">
|
||||||
<span class="rule-desc">-3 for lowest score each hole</span>
|
<input type="checkbox" id="underdog-bonus">
|
||||||
</label>
|
<span>Underdog Bonus</span>
|
||||||
<label class="checkbox-label">
|
<span class="rule-desc">-3 for lowest score each hole</span>
|
||||||
<input type="checkbox" id="tied-shame">
|
</label>
|
||||||
<span>Tied Shame</span>
|
<label class="checkbox-label">
|
||||||
<span class="rule-desc">+5 if you tie with someone</span>
|
<input type="checkbox" id="tied-shame">
|
||||||
</label>
|
<span>Tied Shame</span>
|
||||||
<label class="checkbox-label">
|
<span class="rule-desc">+5 if you tie with someone</span>
|
||||||
<input type="checkbox" id="blackjack">
|
</label>
|
||||||
<span>Blackjack</span>
|
<label class="checkbox-label inline">
|
||||||
<span class="rule-desc">Exact 21 becomes 0</span>
|
<input type="checkbox" id="blackjack">
|
||||||
</label>
|
<span>Blackjack</span>
|
||||||
</div>
|
<span class="rule-desc">21 pts = 0 pts</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Game Screen -->
|
<!-- 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="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="turn-info" id="turn-info">Your turn</div>
|
||||||
<div class="score-info">Showing: <span id="your-score">0</span></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>
|
||||||
|
|
||||||
<div class="game-table">
|
<div class="game-table">
|
||||||
@ -203,7 +224,7 @@
|
|||||||
<div class="table-center">
|
<div class="table-center">
|
||||||
<div class="deck-area">
|
<div class="deck-area">
|
||||||
<div id="deck" class="card card-back">
|
<div id="deck" class="card card-back">
|
||||||
<span>DECK</span>
|
<span>?</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="discard" class="card">
|
<div id="discard" class="card">
|
||||||
<span id="discard-content"></span>
|
<span id="discard-content"></span>
|
||||||
@ -228,7 +249,7 @@
|
|||||||
|
|
||||||
<!-- Left panel: Standings -->
|
<!-- Left panel: Standings -->
|
||||||
<div id="standings-panel" class="side-panel left-panel">
|
<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 id="standings-list" class="standings-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
701
client/style.css
701
client/style.css
@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
background-color: #1a472a;
|
/* Dark emerald pool table felt */
|
||||||
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");
|
background: linear-gradient(180deg, #0a4528 0%, #0d5030 50%, #0a4528 100%);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
@ -61,11 +61,153 @@ body {
|
|||||||
|
|
||||||
/* Waiting Screen */
|
/* Waiting Screen */
|
||||||
#waiting-screen {
|
#waiting-screen {
|
||||||
max-width: 500px;
|
max-width: 1000px;
|
||||||
margin: 0 auto;
|
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;
|
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 {
|
h1 {
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
@ -153,6 +295,11 @@ input::placeholder {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: #27ae60;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-small {
|
.btn-small {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@ -270,6 +417,41 @@ input::placeholder {
|
|||||||
cursor: pointer;
|
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 */
|
||||||
.settings {
|
.settings {
|
||||||
background: rgba(0,0,0,0.2);
|
background: rgba(0,0,0,0.2);
|
||||||
@ -307,8 +489,16 @@ input::placeholder {
|
|||||||
justify-self: center;
|
justify-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-header .mute-btn {
|
.game-header .header-buttons {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#leave-game-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mute-btn {
|
.mute-btn {
|
||||||
@ -327,6 +517,39 @@ input::placeholder {
|
|||||||
background: rgba(255,255,255,0.1);
|
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 Styles */
|
||||||
.card {
|
.card {
|
||||||
width: clamp(65px, 5.5vw, 100px);
|
width: clamp(65px, 5.5vw, 100px);
|
||||||
@ -349,10 +572,19 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-back {
|
.card-back {
|
||||||
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
|
/* Bee-style diamond grid pattern - red with white crosshatch */
|
||||||
border: 3px solid #1a252f;
|
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;
|
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 {
|
.card-front {
|
||||||
@ -402,7 +634,7 @@ input::placeholder {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 25px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -510,10 +742,16 @@ input::placeholder {
|
|||||||
|
|
||||||
.deck-area {
|
.deck-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 15px;
|
||||||
align-items: center;
|
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 {
|
#discard {
|
||||||
background: rgba(255,255,255,0.1);
|
background: rgba(255,255,255,0.1);
|
||||||
border: 2px dashed rgba(255,255,255,0.3);
|
border: 2px dashed rgba(255,255,255,0.3);
|
||||||
@ -537,6 +775,29 @@ input::placeholder {
|
|||||||
box-shadow: none;
|
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 {
|
#drawn-card-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -547,6 +808,11 @@ input::placeholder {
|
|||||||
border-radius: 8px;
|
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 {
|
#drawn-card-area .btn {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@ -585,6 +851,19 @@ input::placeholder {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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 {
|
.opponent-area .card-grid {
|
||||||
@ -678,9 +957,9 @@ input::placeholder {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
background: linear-gradient(145deg, rgba(15, 50, 35, 0.92) 0%, rgba(8, 30, 20, 0.95) 100%);
|
background: linear-gradient(145deg, rgba(15, 50, 35, 0.92) 0%, rgba(8, 30, 20, 0.95) 100%);
|
||||||
border-radius: 14px;
|
border-radius: 16px;
|
||||||
padding: 16px 18px;
|
padding: 18px 20px;
|
||||||
width: 235px;
|
width: 263px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
border: 1px solid rgba(244, 164, 96, 0.25);
|
border: 1px solid rgba(244, 164, 96, 0.25);
|
||||||
@ -699,69 +978,81 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.side-panel > h4 {
|
.side-panel > h4 {
|
||||||
font-size: 0.9rem;
|
font-size: 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 14px;
|
||||||
color: #f4a460;
|
color: #f4a460;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.2em;
|
letter-spacing: 0.2em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||||
border-bottom: 1px solid rgba(244, 164, 96, 0.2);
|
border-bottom: 1px solid rgba(244, 164, 96, 0.2);
|
||||||
padding-bottom: 10px;
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Standings list */
|
/* Standings list - two sections, top 4 each */
|
||||||
.standings-list {
|
.standings-section {
|
||||||
font-size: 0.95rem;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.standings-list .standing-row {
|
.standings-section:last-child {
|
||||||
display: flex;
|
margin-bottom: 0;
|
||||||
justify-content: space-between;
|
}
|
||||||
|
|
||||||
|
.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;
|
align-items: center;
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.standings-list .standing-row.leader {
|
.standings-list .rank-pos {
|
||||||
background: rgba(244, 164, 96, 0.2);
|
text-align: center;
|
||||||
border-left: 3px solid #f4a460;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.standings-list .standing-row.you {
|
.standings-list .rank-name {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.standings-list .standing-pos {
|
|
||||||
font-weight: 700;
|
|
||||||
color: #f4a460;
|
|
||||||
width: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.standings-list .standing-name {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.standings-list .standing-score {
|
.standings-list .rank-val {
|
||||||
font-weight: 600;
|
text-align: right;
|
||||||
opacity: 0.9;
|
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 */
|
/* Score table */
|
||||||
.side-panel table {
|
.side-panel table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 0.9rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-panel th,
|
.side-panel th,
|
||||||
.side-panel td {
|
.side-panel td {
|
||||||
padding: 7px 5px;
|
padding: 8px 6px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||||
}
|
}
|
||||||
@ -769,7 +1060,7 @@ input::placeholder {
|
|||||||
.side-panel th {
|
.side-panel th {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: rgba(0,0,0,0.25);
|
background: rgba(0,0,0,0.25);
|
||||||
font-size: 0.75rem;
|
font-size: 0.85rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
@ -790,62 +1081,62 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.game-buttons {
|
.game-buttons {
|
||||||
margin-top: 10px;
|
margin-top: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-buttons .btn {
|
.game-buttons .btn {
|
||||||
font-size: 0.7rem;
|
font-size: 0.8rem;
|
||||||
padding: 8px 10px;
|
padding: 10px 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rankings Announcement */
|
/* Rankings Announcement */
|
||||||
.rankings-announcement {
|
.rankings-announcement {
|
||||||
background: linear-gradient(135deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.25) 100%);
|
background: linear-gradient(135deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.25) 100%);
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
padding: 8px;
|
padding: 10px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 12px;
|
||||||
border: 1px solid rgba(244, 164, 96, 0.3);
|
border: 1px solid rgba(244, 164, 96, 0.3);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rankings-announcement h3 {
|
.rankings-announcement h3 {
|
||||||
font-size: 0.85rem;
|
font-size: 0.95rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 10px 0;
|
||||||
color: #f4a460;
|
color: #f4a460;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rankings-announcement h4 {
|
.rankings-announcement h4 {
|
||||||
font-size: 0.7rem;
|
font-size: 0.8rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0 0 5px 0;
|
margin: 0 0 6px 0;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rankings-columns {
|
.rankings-columns {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ranking-section {
|
.ranking-section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: rgba(0,0,0,0.2);
|
background: rgba(0,0,0,0.2);
|
||||||
border-radius: 5px;
|
border-radius: 6px;
|
||||||
padding: 5px;
|
padding: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rank-row {
|
.rank-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 0.7rem;
|
font-size: 0.8rem;
|
||||||
padding: 2px 0;
|
padding: 3px 0;
|
||||||
gap: 2px;
|
gap: 3px;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -855,7 +1146,7 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rank-pos {
|
.rank-pos {
|
||||||
width: 16px;
|
width: 18px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@ -869,7 +1160,7 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rank-val {
|
.rank-val {
|
||||||
font-size: 0.6rem;
|
font-size: 0.7rem;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@ -881,11 +1172,11 @@ input::placeholder {
|
|||||||
background: linear-gradient(135deg, #ffd700 0%, #f4a460 50%, #ffd700 100%);
|
background: linear-gradient(135deg, #ffd700 0%, #f4a460 50%, #ffd700 100%);
|
||||||
color: #1a472a;
|
color: #1a472a;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 8px;
|
padding: 10px;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.9rem;
|
font-size: 1rem;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 10px;
|
||||||
animation: victoryPulse 1s ease-in-out infinite alternate;
|
animation: victoryPulse 1s ease-in-out infinite alternate;
|
||||||
text-shadow: 0 1px 0 rgba(255,255,255,0.3);
|
text-shadow: 0 1px 0 rgba(255,255,255,0.3);
|
||||||
}
|
}
|
||||||
@ -1198,15 +1489,15 @@ input::placeholder {
|
|||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* House Rules Section */
|
/* Advanced Options Section */
|
||||||
.house-rules-section {
|
.advanced-options-section {
|
||||||
background: rgba(0, 0, 0, 0.15);
|
background: rgba(0, 0, 0, 0.15);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin: 15px 0;
|
margin: 15px 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.house-rules-section summary {
|
.advanced-options-section summary {
|
||||||
padding: 12px 15px;
|
padding: 12px 15px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@ -1218,69 +1509,103 @@ input::placeholder {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.house-rules-section summary::-webkit-details-marker {
|
.advanced-options-section summary::-webkit-details-marker {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.house-rules-section summary::before {
|
.advanced-options-section summary::before {
|
||||||
content: "▸";
|
content: "▸";
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.house-rules-section[open] summary::before {
|
.advanced-options-section[open] summary::before {
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.house-rules-section summary:hover {
|
.advanced-options-section summary:hover {
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.house-rules-category {
|
/* Two-column grid for options */
|
||||||
padding: 12px 15px;
|
.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);
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.house-rules-category h4 {
|
.options-category:first-child {
|
||||||
font-size: 0.85rem;
|
border-top: none;
|
||||||
margin-bottom: 10px;
|
}
|
||||||
|
|
||||||
|
.options-category h4 {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
color: #f4a460;
|
color: #f4a460;
|
||||||
}
|
}
|
||||||
|
|
||||||
.house-rules-category .checkbox-group {
|
.options-category .checkbox-group {
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.house-rules-category .checkbox-label {
|
.options-category .checkbox-label {
|
||||||
font-size: 0.85rem;
|
font-size: 0.95rem;
|
||||||
padding: 5px 0;
|
font-weight: 500;
|
||||||
|
padding: 3px 0;
|
||||||
flex-wrap: wrap;
|
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 description */
|
||||||
.rule-desc {
|
.rule-desc {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 0.7rem;
|
font-size: 0.8rem;
|
||||||
opacity: 0.7;
|
opacity: 0.6;
|
||||||
margin-left: 22px;
|
margin-left: 30px;
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact form group for house rules */
|
/* Compact form group for options */
|
||||||
.house-rules-category .form-group.compact {
|
.options-category .form-group.compact {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.house-rules-category .form-group.compact select {
|
.options-category .form-group.compact select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 0.8rem;
|
font-size: 0.75rem;
|
||||||
padding: 6px 8px;
|
padding: 5px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Eagle Eye option under joker dropdown */
|
/* Eagle Eye option under joker dropdown */
|
||||||
.eagle-eye-option {
|
.eagle-eye-option {
|
||||||
margin-top: 8px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Disabled checkbox styling */
|
/* Disabled checkbox styling */
|
||||||
@ -1292,3 +1617,191 @@ input::placeholder {
|
|||||||
.checkbox-label input:disabled {
|
.checkbox-label input:disabled {
|
||||||
cursor: not-allowed;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -116,7 +116,6 @@ Our implementation supports these optional rule variations:
|
|||||||
|--------|--------|
|
|--------|--------|
|
||||||
| `lucky_swing` | Single Joker worth **-5** (instead of two -2 Jokers) |
|
| `lucky_swing` | Single Joker worth **-5** (instead of two -2 Jokers) |
|
||||||
| `super_kings` | Kings worth **-2** (instead of 0) |
|
| `super_kings` | Kings worth **-2** (instead of 0) |
|
||||||
| `lucky_sevens` | 7s worth **0** (instead of 7) |
|
|
||||||
| `ten_penny` | 10s worth **1** (instead of 10) |
|
| `ten_penny` | 10s worth **1** (instead of 10) |
|
||||||
|
|
||||||
## Bonuses & Penalties
|
## Bonuses & Penalties
|
||||||
@ -128,13 +127,11 @@ Our implementation supports these optional rule variations:
|
|||||||
| `tied_shame` | Tying another player's score = **+5** penalty to both |
|
| `tied_shame` | Tying another player's score = **+5** penalty to both |
|
||||||
| `blackjack` | Exact score of 21 becomes **0** |
|
| `blackjack` | Exact score of 21 becomes **0** |
|
||||||
|
|
||||||
## Gameplay Twists
|
## Special Rules
|
||||||
|
|
||||||
| Option | Effect |
|
| Option | Effect |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| `queens_wild` | Queens match any rank for column pairing |
|
| `eagle_eye` | Jokers worth **+2 unpaired**, **-4 paired** (spot the pair!) |
|
||||||
| `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) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
212
server/ai.py
212
server/ai.py
@ -18,14 +18,9 @@ def get_ai_card_value(card: Card, options: GameOptions) -> int:
|
|||||||
return get_card_value(card, options)
|
return get_card_value(card, options)
|
||||||
|
|
||||||
|
|
||||||
def can_make_pair(card1: Card, card2: Card, options: GameOptions) -> bool:
|
def can_make_pair(card1: Card, card2: Card) -> bool:
|
||||||
"""Check if two cards can form a pair (with Queens Wild support)."""
|
"""Check if two cards can form a pair."""
|
||||||
if card1.rank == card2.rank:
|
return 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 estimate_opponent_min_score(player: Player, game: Game) -> int:
|
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
|
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:
|
def count_rank_in_hand(player: Player, rank: Rank) -> int:
|
||||||
"""Count how many cards of a given rank the player has visible."""
|
"""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)
|
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:
|
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.
|
"""Check if player has a visible card worse than the given value.
|
||||||
|
|
||||||
@ -255,29 +358,10 @@ class GolfAI:
|
|||||||
if discard_card.rank == Rank.KING:
|
if discard_card.rank == Rank.KING:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Auto-take 7s when lucky_sevens enabled (they're worth 0)
|
# Auto-take 10s when ten_penny enabled (they're worth 1)
|
||||||
if discard_card.rank == Rank.SEVEN and options.lucky_sevens:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Auto-take 10s when ten_penny enabled (they're worth 0)
|
|
||||||
if discard_card.rank == Rank.TEN and options.ten_penny:
|
if discard_card.rank == Rank.TEN and options.ten_penny:
|
||||||
return True
|
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)
|
# 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
|
# Pairing negative cards is bad - you lose the negative benefit
|
||||||
if discard_value > 0:
|
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:
|
if card.face_up and card.rank == discard_card.rank and not pair_card.face_up:
|
||||||
return True
|
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)
|
# 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
|
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
|
# Check if we have cards worse than the discard
|
||||||
worst_visible = -999
|
worst_visible = -999
|
||||||
for card in player.cards:
|
for card in player.cards:
|
||||||
@ -338,15 +437,6 @@ class GolfAI:
|
|||||||
if not player.cards[pair_pos].face_up:
|
if not player.cards[pair_pos].face_up:
|
||||||
return pair_pos
|
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
|
# Check for column pair opportunity first
|
||||||
# But DON'T pair negative value cards (2s, Jokers) - keeping them unpaired is better!
|
# But DON'T pair negative value cards (2s, Jokers) - keeping them unpaired is better!
|
||||||
# Exception: Eagle Eye makes pairing Jokers GOOD (doubled negative)
|
# 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:
|
if pair_card.face_up and pair_card.rank == drawn_card.rank and not card.face_up:
|
||||||
return i
|
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)
|
# Find best swap among face-up cards that are BAD (positive value)
|
||||||
# Don't swap good cards (Kings, 2s, etc.) just for marginal gains -
|
# 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
|
# we want to keep good cards and put new good cards into face-down positions
|
||||||
@ -409,27 +492,44 @@ class GolfAI:
|
|||||||
return i
|
return i
|
||||||
|
|
||||||
# Consider swapping with face-down cards for very good cards (negative or zero value)
|
# 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
|
is_excellent = (drawn_value <= 0 or
|
||||||
drawn_card.rank == Rank.ACE 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))
|
(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:
|
if is_excellent:
|
||||||
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
|
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
|
||||||
if face_down:
|
if face_down:
|
||||||
# Pair hunters might hold out hoping for matches
|
# 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 None
|
||||||
return random.choice(face_down)
|
return random.choice(face_down)
|
||||||
|
|
||||||
# For medium cards, swap threshold based on profile
|
# 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]
|
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
|
||||||
if face_down:
|
if face_down:
|
||||||
# Pair hunters hold high cards hoping for matches
|
# Pair hunters hold high cards hoping for matches
|
||||||
if profile.pair_hope > 0.5 and drawn_value >= 6:
|
# BUT: check if pairing is actually viable
|
||||||
if random.random() < profile.pair_hope:
|
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 None
|
||||||
return random.choice(face_down)
|
return random.choice(face_down)
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,6 @@ from enum import Enum
|
|||||||
from constants import (
|
from constants import (
|
||||||
DEFAULT_CARD_VALUES,
|
DEFAULT_CARD_VALUES,
|
||||||
SUPER_KINGS_VALUE,
|
SUPER_KINGS_VALUE,
|
||||||
LUCKY_SEVENS_VALUE,
|
|
||||||
TEN_PENNY_VALUE,
|
TEN_PENNY_VALUE,
|
||||||
LUCKY_SWING_JOKER_VALUE,
|
LUCKY_SWING_JOKER_VALUE,
|
||||||
)
|
)
|
||||||
@ -58,11 +57,11 @@ def get_card_value(card: "Card", options: Optional["GameOptions"] = None) -> int
|
|||||||
"""
|
"""
|
||||||
if options:
|
if options:
|
||||||
if card.rank == Rank.JOKER:
|
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]
|
return LUCKY_SWING_JOKER_VALUE if options.lucky_swing else RANK_VALUES[Rank.JOKER]
|
||||||
if card.rank == Rank.KING and options.super_kings:
|
if card.rank == Rank.KING and options.super_kings:
|
||||||
return SUPER_KINGS_VALUE
|
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:
|
if card.rank == Rank.TEN and options.ten_penny:
|
||||||
return TEN_PENNY_VALUE
|
return TEN_PENNY_VALUE
|
||||||
return RANK_VALUES[card.rank]
|
return RANK_VALUES[card.rank]
|
||||||
@ -148,58 +147,39 @@ class Player:
|
|||||||
if len(self.cards) != 6:
|
if len(self.cards) != 6:
|
||||||
return 0
|
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
|
total = 0
|
||||||
|
jack_pairs = 0 # Track paired Jacks for Wolfpack bonus
|
||||||
|
|
||||||
# Cards are arranged in 2 rows x 3 columns
|
# Cards are arranged in 2 rows x 3 columns
|
||||||
# Position mapping: [0, 1, 2] (top row)
|
# Position mapping: [0, 1, 2] (top row)
|
||||||
# [3, 4, 5] (bottom row)
|
# [3, 4, 5] (bottom row)
|
||||||
# Columns: (0,3), (1,4), (2,5)
|
# 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):
|
for col in range(3):
|
||||||
top_idx = col
|
top_idx = col
|
||||||
bottom_idx = col + 3
|
bottom_idx = col + 3
|
||||||
top_card = self.cards[top_idx]
|
top_card = self.cards[top_idx]
|
||||||
bottom_card = self.cards[bottom_idx]
|
bottom_card = self.cards[bottom_idx]
|
||||||
|
|
||||||
# Skip if part of four of a kind
|
# Check if column pair matches (same rank)
|
||||||
if top_idx in four_of_kind_positions and bottom_idx in four_of_kind_positions:
|
if top_card.rank == bottom_card.rank:
|
||||||
continue
|
# Track Jack pairs for Wolfpack
|
||||||
|
if top_card.rank == Rank.JACK:
|
||||||
# Check if column pair matches (same rank or Queens Wild)
|
jack_pairs += 1
|
||||||
if cards_match(top_card, bottom_card):
|
# Eagle Eye: paired jokers score -4 (reward for spotting the pair)
|
||||||
# Eagle Eye: paired jokers score -8 (2³) instead of canceling
|
|
||||||
if (options and options.eagle_eye and
|
if (options and options.eagle_eye and
|
||||||
top_card.rank == Rank.JOKER and bottom_card.rank == Rank.JOKER):
|
top_card.rank == Rank.JOKER and bottom_card.rank == Rank.JOKER):
|
||||||
total -= 8
|
total -= 4
|
||||||
continue
|
continue
|
||||||
# Normal matching pair scores 0
|
# Normal matching pair scores 0
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
if top_idx not in four_of_kind_positions:
|
total += get_card_value(top_card, options)
|
||||||
total += get_card_value(top_card, options)
|
total += get_card_value(bottom_card, options)
|
||||||
if bottom_idx not in four_of_kind_positions:
|
|
||||||
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
|
self.score = total
|
||||||
return total
|
return total
|
||||||
@ -228,7 +208,6 @@ class GameOptions:
|
|||||||
# House Rules - Point Modifiers
|
# House Rules - Point Modifiers
|
||||||
lucky_swing: bool = False # Single joker worth -5 instead of two -2 jokers
|
lucky_swing: bool = False # Single joker worth -5 instead of two -2 jokers
|
||||||
super_kings: bool = False # Kings worth -2 instead of 0
|
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
|
ten_penny: bool = False # 10s worth 1 (like Ace) instead of 10
|
||||||
|
|
||||||
# House Rules - Bonuses/Penalties
|
# House Rules - Bonuses/Penalties
|
||||||
@ -236,10 +215,9 @@ class GameOptions:
|
|||||||
underdog_bonus: bool = False # Lowest score player gets -3 each hole
|
underdog_bonus: bool = False # Lowest score player gets -3 each hole
|
||||||
tied_shame: bool = False # Tie with someone's score = +5 penalty to both
|
tied_shame: bool = False # Tie with someone's score = +5 penalty to both
|
||||||
blackjack: bool = False # Hole score of exactly 21 becomes 0
|
blackjack: bool = False # Hole score of exactly 21 becomes 0
|
||||||
|
wolfpack: bool = False # 2 pairs of Jacks = -5 bonus
|
||||||
|
|
||||||
# House Rules - Gameplay Twists
|
# House Rules - Special
|
||||||
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
|
|
||||||
eagle_eye: bool = False # Paired jokers double instead of cancel (-4 or -10)
|
eagle_eye: bool = False # Paired jokers double instead of cancel (-4 or -10)
|
||||||
|
|
||||||
|
|
||||||
@ -271,12 +249,12 @@ class Game:
|
|||||||
# Apply house rule modifications
|
# Apply house rule modifications
|
||||||
if self.options.super_kings:
|
if self.options.super_kings:
|
||||||
values['K'] = SUPER_KINGS_VALUE
|
values['K'] = SUPER_KINGS_VALUE
|
||||||
if self.options.lucky_sevens:
|
|
||||||
values['7'] = LUCKY_SEVENS_VALUE
|
|
||||||
if self.options.ten_penny:
|
if self.options.ten_penny:
|
||||||
values['10'] = TEN_PENNY_VALUE
|
values['10'] = TEN_PENNY_VALUE
|
||||||
if self.options.lucky_swing:
|
if self.options.lucky_swing:
|
||||||
values['★'] = LUCKY_SWING_JOKER_VALUE
|
values['★'] = LUCKY_SWING_JOKER_VALUE
|
||||||
|
elif self.options.eagle_eye:
|
||||||
|
values['★'] = 2 # Eagle-eyed: +2 unpaired, -4 paired
|
||||||
|
|
||||||
return values
|
return values
|
||||||
|
|
||||||
@ -613,6 +591,34 @@ class Game:
|
|||||||
|
|
||||||
discard_top = self.discard_top()
|
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 {
|
return {
|
||||||
"phase": self.phase.value,
|
"phase": self.phase.value,
|
||||||
"players": players_data,
|
"players": players_data,
|
||||||
@ -630,4 +636,5 @@ class Game:
|
|||||||
"initial_flips": self.options.initial_flips,
|
"initial_flips": self.options.initial_flips,
|
||||||
"flip_on_discard": self.flip_on_discard,
|
"flip_on_discard": self.flip_on_discard,
|
||||||
"card_values": self.get_card_values(),
|
"card_values": self.get_card_values(),
|
||||||
|
"active_rules": active_rules,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -184,17 +184,14 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
# House Rules - Point Modifiers
|
# House Rules - Point Modifiers
|
||||||
lucky_swing=data.get("lucky_swing", False),
|
lucky_swing=data.get("lucky_swing", False),
|
||||||
super_kings=data.get("super_kings", False),
|
super_kings=data.get("super_kings", False),
|
||||||
lucky_sevens=data.get("lucky_sevens", False),
|
|
||||||
ten_penny=data.get("ten_penny", False),
|
ten_penny=data.get("ten_penny", False),
|
||||||
# House Rules - Bonuses/Penalties
|
# House Rules - Bonuses/Penalties
|
||||||
knock_bonus=data.get("knock_bonus", False),
|
knock_bonus=data.get("knock_bonus", False),
|
||||||
underdog_bonus=data.get("underdog_bonus", False),
|
underdog_bonus=data.get("underdog_bonus", False),
|
||||||
tied_shame=data.get("tied_shame", False),
|
tied_shame=data.get("tied_shame", False),
|
||||||
blackjack=data.get("blackjack", 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),
|
eagle_eye=data.get("eagle_eye", False),
|
||||||
|
wolfpack=data.get("wolfpack", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate settings
|
# Validate settings
|
||||||
@ -331,6 +328,37 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
await handle_player_leave(current_room, player_id)
|
await handle_player_leave(current_room, player_id)
|
||||||
current_room = None
|
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:
|
except WebSocketDisconnect:
|
||||||
if current_room:
|
if current_room:
|
||||||
await handle_player_leave(current_room, player_id)
|
await handle_player_leave(current_room, player_id)
|
||||||
|
|||||||
@ -235,11 +235,6 @@ def get_test_configs() -> list[tuple[str, GameOptions]]:
|
|||||||
super_kings=True,
|
super_kings=True,
|
||||||
)))
|
)))
|
||||||
|
|
||||||
configs.append(("lucky_sevens", GameOptions(
|
|
||||||
initial_flips=2,
|
|
||||||
lucky_sevens=True,
|
|
||||||
)))
|
|
||||||
|
|
||||||
configs.append(("ten_penny", GameOptions(
|
configs.append(("ten_penny", GameOptions(
|
||||||
initial_flips=2,
|
initial_flips=2,
|
||||||
ten_penny=True,
|
ten_penny=True,
|
||||||
@ -267,17 +262,7 @@ def get_test_configs() -> list[tuple[str, GameOptions]]:
|
|||||||
blackjack=True,
|
blackjack=True,
|
||||||
)))
|
)))
|
||||||
|
|
||||||
# === Gameplay Twists ===
|
# === Special Rules ===
|
||||||
|
|
||||||
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,
|
|
||||||
)))
|
|
||||||
|
|
||||||
configs.append(("eagle_eye", GameOptions(
|
configs.append(("eagle_eye", GameOptions(
|
||||||
initial_flips=2,
|
initial_flips=2,
|
||||||
@ -292,7 +277,6 @@ def get_test_configs() -> list[tuple[str, GameOptions]]:
|
|||||||
use_jokers=True,
|
use_jokers=True,
|
||||||
lucky_swing=True,
|
lucky_swing=True,
|
||||||
super_kings=True,
|
super_kings=True,
|
||||||
lucky_sevens=True,
|
|
||||||
ten_penny=True,
|
ten_penny=True,
|
||||||
)))
|
)))
|
||||||
|
|
||||||
@ -311,8 +295,6 @@ def get_test_configs() -> list[tuple[str, GameOptions]]:
|
|||||||
configs.append(("WILD CARDS", GameOptions(
|
configs.append(("WILD CARDS", GameOptions(
|
||||||
initial_flips=2,
|
initial_flips=2,
|
||||||
use_jokers=True,
|
use_jokers=True,
|
||||||
queens_wild=True,
|
|
||||||
four_of_a_kind=True,
|
|
||||||
eagle_eye=True,
|
eagle_eye=True,
|
||||||
)))
|
)))
|
||||||
|
|
||||||
@ -329,14 +311,11 @@ def get_test_configs() -> list[tuple[str, GameOptions]]:
|
|||||||
use_jokers=True,
|
use_jokers=True,
|
||||||
lucky_swing=True,
|
lucky_swing=True,
|
||||||
super_kings=True,
|
super_kings=True,
|
||||||
lucky_sevens=True,
|
|
||||||
ten_penny=True,
|
ten_penny=True,
|
||||||
knock_bonus=True,
|
knock_bonus=True,
|
||||||
underdog_bonus=True,
|
underdog_bonus=True,
|
||||||
tied_shame=True,
|
tied_shame=True,
|
||||||
blackjack=True,
|
blackjack=True,
|
||||||
queens_wild=True,
|
|
||||||
four_of_a_kind=True,
|
|
||||||
eagle_eye=True,
|
eagle_eye=True,
|
||||||
)))
|
)))
|
||||||
|
|
||||||
@ -457,15 +436,6 @@ def print_expected_effects(results: list[RuleTestResult]):
|
|||||||
status = "✓" if diff < 0 else "✗"
|
status = "✓" if diff < 0 else "✗"
|
||||||
checks.append((r.name, expected, f"{actual} ({diff:+.1f})", status))
|
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)
|
# ten_penny should lower scores (10s worth 1 instead of 10)
|
||||||
r = find("ten_penny")
|
r = find("ten_penny")
|
||||||
if r and r.scores:
|
if r and r.scores:
|
||||||
|
|||||||
@ -145,7 +145,7 @@ class TestMayaBugFix:
|
|||||||
When forced to swap (drew from discard), the AI should use
|
When forced to swap (drew from discard), the AI should use
|
||||||
get_ai_card_value() to find the worst card, not raw value().
|
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 = create_test_game()
|
||||||
game.options = GameOptions(super_kings=True) # Kings now worth -2
|
game.options = GameOptions(super_kings=True) # Kings now worth -2
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user