diff --git a/client/app.js b/client/app.js index 9919bf2..c178a08 100644 --- a/client/app.js +++ b/client/app.js @@ -139,7 +139,6 @@ class GolfGame { this.currentRoundSpan = document.getElementById('current-round'); this.totalRoundsSpan = document.getElementById('total-rounds'); this.turnInfo = document.getElementById('turn-info'); - this.leaderInfo = document.getElementById('leader-info'); this.yourScore = document.getElementById('your-score'); this.muteBtn = document.getElementById('mute-btn'); this.opponentsRow = document.getElementById('opponents-row'); @@ -154,6 +153,7 @@ class GolfGame { this.toast = document.getElementById('toast'); this.scoreboard = document.getElementById('scoreboard'); this.scoreTable = document.getElementById('score-table').querySelector('tbody'); + this.standingsList = document.getElementById('standings-list'); this.gameButtons = document.getElementById('game-buttons'); this.nextRoundBtn = document.getElementById('next-round-btn'); this.newGameBtn = document.getElementById('new-game-btn'); @@ -739,6 +739,43 @@ class GolfGame { return suit === 'hearts' || suit === 'diamonds'; } + calculateShowingScore(cards) { + if (!cards || cards.length !== 6) return 0; + + // Use card values from server (includes house rules) or defaults + const cardValues = this.gameState?.card_values || { + 'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, + '8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2 + }; + + const getCardValue = (card) => { + if (!card.face_up) return 0; + return cardValues[card.rank] ?? 0; + }; + + // Check for column pairs (cards in same column cancel out if matching) + let total = 0; + for (let col = 0; col < 3; col++) { + const topCard = cards[col]; + const bottomCard = cards[col + 3]; + + const topUp = topCard.face_up; + const bottomUp = bottomCard.face_up; + + // If both face up and matching rank, they cancel (score 0) + if (topUp && bottomUp && topCard.rank === bottomCard.rank) { + // Matching pair = 0 points for both + continue; + } + + // Otherwise add individual values + total += getCardValue(topCard); + total += getCardValue(bottomCard); + } + + return total; + } + getSuitSymbol(suit) { const symbols = { hearts: '♥', @@ -777,19 +814,12 @@ class GolfGame { } } - // Update leader info (by total score) - const sortedByTotal = [...this.gameState.players].sort((a, b) => a.total_score - b.total_score); - const leader = sortedByTotal[0]; - if (leader && this.gameState.current_round > 1) { - this.leaderInfo.textContent = `Leader: ${leader.name} (${leader.total_score})`; - } else { - this.leaderInfo.textContent = ""; - } - - // Update your score + // Update your score (points currently showing on your cards) const me = this.gameState.players.find(p => p.id === this.playerId); if (me) { - this.yourScore.textContent = me.round_score ?? 0; + // Calculate visible score from face-up cards + const showingScore = this.calculateShowingScore(me.cards); + this.yourScore.textContent = showingScore; } // Update discard pile @@ -833,7 +863,7 @@ class GolfGame { div.classList.add('current-turn'); } - const displayName = player.name.length > 8 ? player.name.substring(0, 7) + '…' : player.name; + const displayName = player.name.length > 12 ? player.name.substring(0, 11) + '…' : player.name; div.innerHTML = `

${displayName}${player.all_face_up ? ' ✓' : ''}

@@ -895,6 +925,10 @@ class GolfGame { updateScorePanel() { if (!this.gameState) return; + // Update standings (left panel) + this.updateStandings(); + + // Update score table (right panel) this.scoreTable.innerHTML = ''; this.gameState.players.forEach(player => { @@ -906,8 +940,8 @@ class GolfGame { } // Truncate long names - const displayName = player.name.length > 10 - ? player.name.substring(0, 9) + '…' + const displayName = player.name.length > 12 + ? player.name.substring(0, 11) + '…' : player.name; const roundScore = player.score !== null ? player.score : '-'; @@ -923,6 +957,39 @@ class GolfGame { }); } + updateStandings() { + if (!this.gameState || !this.standingsList) return; + + // Sort players by total score (lowest is best in golf) + const sorted = [...this.gameState.players].sort((a, b) => a.total_score - b.total_score); + + this.standingsList.innerHTML = ''; + + sorted.forEach((player, index) => { + const div = document.createElement('div'); + div.className = 'standing-row'; + + if (index === 0 && player.total_score < sorted[sorted.length - 1]?.total_score) { + div.classList.add('leader'); + } + if (player.id === this.playerId) { + div.classList.add('you'); + } + + const displayName = player.name.length > 12 + ? player.name.substring(0, 11) + '…' + : player.name; + + div.innerHTML = ` + ${index + 1}. + ${displayName} + ${player.total_score} pts + `; + + this.standingsList.appendChild(div); + }); + } + renderCard(card, clickable, selected) { let classes = 'card'; let content = ''; @@ -959,8 +1026,8 @@ class GolfGame { const roundsWon = score.rounds_won || 0; // Truncate long names - const displayName = score.name.length > 10 - ? score.name.substring(0, 9) + '…' + const displayName = score.name.length > 12 + ? score.name.substring(0, 11) + '…' : score.name; if (total === minScore) { @@ -1023,7 +1090,7 @@ class GolfGame { prevPoints = p.total; } const medal = pointsRank === 0 ? '🥇' : pointsRank === 1 ? '🥈' : pointsRank === 2 ? '🥉' : `${pointsRank + 1}.`; - const name = p.name.length > 8 ? p.name.substring(0, 7) + '…' : p.name; + const name = p.name.length > 12 ? p.name.substring(0, 11) + '…' : p.name; return `
${medal}${name}${p.total}pt
`; }).join(''); @@ -1038,7 +1105,7 @@ class GolfGame { // No medal for 0 wins const medal = p.rounds_won === 0 ? '-' : holesRank === 0 ? '🥇' : holesRank === 1 ? '🥈' : holesRank === 2 ? '🥉' : `${holesRank + 1}.`; - const name = p.name.length > 8 ? p.name.substring(0, 7) + '…' : p.name; + const name = p.name.length > 12 ? p.name.substring(0, 11) + '…' : p.name; return `
${medal}${name}${p.rounds_won}W
`; }).join(''); diff --git a/client/index.html b/client/index.html index e6f87f1..dfa8300 100644 --- a/client/index.html +++ b/client/index.html @@ -15,7 +15,7 @@
- +
@@ -192,8 +192,7 @@
Hole 1/9
Your turn
-
Leader: -
-
You: 0 pts
+
Showing: 0
@@ -227,7 +226,14 @@
-
+ +
+

Standings

+
+
+ + +

Scores

diff --git a/client/style.css b/client/style.css index 8fdfb9b..8f2573e 100644 --- a/client/style.css +++ b/client/style.css @@ -280,29 +280,35 @@ input::placeholder { /* Game Screen */ .game-header { - display: flex; - justify-content: center; + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; align-items: center; - gap: clamp(15px, 3vw, 40px); - padding: 6px 15px; - background: rgba(0,0,0,0.2); - border-radius: 6px; - font-size: 0.85rem; - width: calc(100vw - 250px); - margin-left: 10px; + padding: 10px 25px; + background: rgba(0,0,0,0.35); + border-radius: 0; + font-size: 0.9rem; + width: 100vw; + margin-left: calc(-50vw + 50%); + box-sizing: border-box; +} + +.game-header .round-info { + justify-self: start; + font-weight: 600; } .game-header .turn-info { + justify-self: center; font-weight: 600; color: #f4a460; } -.game-header .leader-info { - opacity: 0.9; +.game-header .score-info { + justify-self: center; } -.game-header .score-info { - opacity: 0.9; +.game-header .mute-btn { + justify-self: end; } .mute-btn { @@ -323,13 +329,13 @@ input::placeholder { /* Card Styles */ .card { - width: clamp(60px, 6vw, 80px); - height: clamp(84px, 8.4vw, 112px); + width: clamp(65px, 5.5vw, 100px); + height: clamp(91px, 7.7vw, 140px); border-radius: 6px; display: flex; align-items: center; justify-content: center; - font-size: clamp(1.2rem, 1.4vw, 1.7rem); + font-size: clamp(2rem, 2.5vw, 3.2rem); font-weight: bold; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; @@ -386,8 +392,8 @@ input::placeholder { /* Card Grid */ .card-grid { display: grid; - grid-template-columns: repeat(3, clamp(60px, 6vw, 80px)); - gap: clamp(6px, 0.8vw, 12px); + grid-template-columns: repeat(3, clamp(65px, 5.5vw, 100px)); + gap: clamp(8px, 0.8vw, 14px); justify-content: center; } @@ -415,11 +421,10 @@ input::placeholder { flex-wrap: nowrap; justify-content: center; align-items: flex-end; - gap: clamp(10px, 2vw, 30px); - min-height: clamp(100px, 12vw, 150px); - padding: 0 10px; - width: calc(100vw - 240px); - margin-left: 10px; + gap: clamp(12px, 1.8vw, 35px); + min-height: clamp(120px, 14vw, 200px); + padding: 0 20px; + width: 100%; } /* Arch layout - middle items higher, edges lower with rotation for "around the table" feel */ @@ -566,15 +571,15 @@ input::placeholder { /* Opponent Areas */ .opponent-area { background: rgba(0,0,0,0.2); - border-radius: 6px; - padding: clamp(3px, 0.4vw, 6px) clamp(5px, 0.6vw, 10px) clamp(5px, 0.6vw, 10px); + border-radius: 8px; + padding: clamp(4px, 0.5vw, 10px) clamp(6px, 0.7vw, 14px) clamp(6px, 0.7vw, 14px); text-align: center; } .opponent-area h4 { - font-size: clamp(0.65rem, 0.75vw, 0.85rem); - margin: 0 0 4px 0; - padding: clamp(2px, 0.3vw, 4px) clamp(6px, 0.8vw, 10px); + font-size: clamp(0.7rem, 0.8vw, 1rem); + margin: 0 0 6px 0; + padding: clamp(3px, 0.35vw, 6px) clamp(8px, 0.9vw, 14px); background: rgba(244, 164, 96, 0.5); border-radius: 4px; white-space: nowrap; @@ -584,15 +589,15 @@ input::placeholder { .opponent-area .card-grid { display: grid; - grid-template-columns: repeat(3, clamp(40px, 4.5vw, 60px)); - gap: clamp(3px, 0.4vw, 6px); + grid-template-columns: repeat(3, clamp(45px, 4vw, 75px)); + gap: clamp(4px, 0.4vw, 8px); } .opponent-area .card { - width: clamp(40px, 4.5vw, 60px); - height: clamp(56px, 6.3vw, 84px); - font-size: clamp(0.75rem, 0.9vw, 1.1rem); - border-radius: 4px; + width: clamp(45px, 4vw, 75px); + height: clamp(63px, 5.6vw, 105px); + font-size: clamp(1.3rem, 1.5vw, 2.2rem); + border-radius: 5px; } .opponent-area.current-turn { @@ -647,13 +652,14 @@ input::placeholder { #game-screen.active { display: flex; flex-direction: column; - align-items: flex-start; + align-items: center; position: relative; + padding: 10px; } .game-layout { display: flex; - justify-content: flex-start; + justify-content: center; width: 100%; align-items: flex-start; } @@ -664,86 +670,136 @@ input::placeholder { flex-direction: column; align-items: center; gap: 8px; - width: calc(100vw - 230px); + width: 100%; } -/* Scoreboard Panel - positioned as overlay with cool styling */ -.scoreboard-panel { +/* Side Panels - positioned in bottom corners */ +.side-panel { position: fixed; - top: 15px; - right: 15px; - background: linear-gradient(145deg, rgba(20, 60, 40, 0.95) 0%, rgba(10, 35, 25, 0.98) 100%); - border-radius: 12px; - padding: 12px; - width: 210px; - flex-shrink: 0; - overflow: hidden; + bottom: 20px; + background: linear-gradient(145deg, rgba(15, 50, 35, 0.92) 0%, rgba(8, 30, 20, 0.95) 100%); + border-radius: 14px; + padding: 16px 18px; + width: 235px; z-index: 100; - backdrop-filter: blur(8px); - border: 1px solid rgba(244, 164, 96, 0.3); + backdrop-filter: blur(10px); + border: 1px solid rgba(244, 164, 96, 0.25); box-shadow: - 0 4px 20px rgba(0, 0, 0, 0.4), - 0 0 30px rgba(244, 164, 96, 0.1), - inset 0 1px 0 rgba(255, 255, 255, 0.1); + 0 4px 24px rgba(0, 0, 0, 0.5), + 0 0 40px rgba(244, 164, 96, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.08); } -.scoreboard-panel > h4 { +.side-panel.left-panel { + left: 20px; +} + +.side-panel.right-panel { + right: 20px; +} + +.side-panel > h4 { font-size: 0.9rem; text-align: center; - margin-bottom: 10px; + margin-bottom: 12px; color: #f4a460; text-transform: uppercase; - letter-spacing: 0.15em; + letter-spacing: 0.2em; font-weight: 700; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); + border-bottom: 1px solid rgba(244, 164, 96, 0.2); + padding-bottom: 10px; } -.scoreboard-panel table { +/* Standings list */ +.standings-list { + font-size: 0.95rem; +} + +.standings-list .standing-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 8px; + border-radius: 5px; + margin-bottom: 4px; +} + +.standings-list .standing-row.leader { + background: rgba(244, 164, 96, 0.2); + border-left: 3px solid #f4a460; +} + +.standings-list .standing-row.you { + background: rgba(255, 255, 255, 0.1); +} + +.standings-list .standing-pos { + font-weight: 700; + color: #f4a460; + width: 20px; +} + +.standings-list .standing-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.standings-list .standing-score { + font-weight: 600; + opacity: 0.9; +} + +/* Score table */ +.side-panel table { width: 100%; border-collapse: collapse; - font-size: 0.8rem; + font-size: 0.9rem; } -.scoreboard-panel th, -.scoreboard-panel td { - padding: 6px 5px; +.side-panel th, +.side-panel td { + padding: 7px 5px; text-align: center; - border-bottom: 1px solid rgba(255,255,255,0.1); + border-bottom: 1px solid rgba(255,255,255,0.08); } -.scoreboard-panel th { +.side-panel th { font-weight: 600; - background: rgba(0,0,0,0.3); - font-size: 0.7rem; + background: rgba(0,0,0,0.25); + font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; - color: rgba(255, 255, 255, 0.7); + color: rgba(255, 255, 255, 0.6); } -.scoreboard-panel td:first-child { +.side-panel td:first-child { text-align: left; font-weight: 500; } -.scoreboard-panel tr.winner { - background: linear-gradient(90deg, rgba(244, 164, 96, 0.4) 0%, rgba(244, 164, 96, 0.2) 100%); +.side-panel tr.winner { + background: linear-gradient(90deg, rgba(244, 164, 96, 0.35) 0%, rgba(244, 164, 96, 0.15) 100%); } -.scoreboard-panel tr.current-player { - background: rgba(244, 164, 96, 0.2); +.side-panel tr.current-player { + background: rgba(244, 164, 96, 0.15); box-shadow: inset 3px 0 0 #f4a460; } .game-buttons { - margin-top: 8px; + margin-top: 10px; display: flex; flex-direction: column; - gap: 5px; + gap: 6px; } .game-buttons .btn { - font-size: 0.75rem; - padding: 6px 10px; + font-size: 0.7rem; + padding: 8px 10px; + width: 100%; } /* Rankings Announcement */ diff --git a/server/ai.py b/server/ai.py index 46cca44..3a1c5e8 100644 --- a/server/ai.py +++ b/server/ai.py @@ -6,20 +6,16 @@ from dataclasses import dataclass from typing import Optional from enum import Enum -from game import Card, Player, Game, GamePhase, GameOptions, RANK_VALUES, Rank +from game import Card, Player, Game, GamePhase, GameOptions, RANK_VALUES, Rank, get_card_value +# Alias for backwards compatibility - use the centralized function from game.py def get_ai_card_value(card: Card, options: GameOptions) -> int: - """Get card value with house rules applied for AI decisions.""" - if card.rank == Rank.JOKER: - return -5 if options.lucky_swing else -2 - if card.rank == Rank.KING and options.super_kings: - return -2 - if card.rank == Rank.SEVEN and options.lucky_sevens: - return 0 - if card.rank == Rank.TEN and options.ten_penny: - return 0 - return card.value() + """Get card value with house rules applied for AI decisions. + + This is an alias for game.get_card_value() for backwards compatibility. + """ + return get_card_value(card, options) def can_make_pair(card1: Card, card2: Card, options: GameOptions) -> bool: diff --git a/server/constants.py b/server/constants.py new file mode 100644 index 0000000..8501b79 --- /dev/null +++ b/server/constants.py @@ -0,0 +1,53 @@ +# Card values - Single source of truth for all card scoring +# Per RULES.md: A=1, 2=-2, 3-10=face, J/Q=10, K=0, Joker=-2 +DEFAULT_CARD_VALUES = { + 'A': 1, + '2': -2, + '3': 3, + '4': 4, + '5': 5, + '6': 6, + '7': 7, + '8': 8, + '9': 9, + '10': 10, + 'J': 10, + 'Q': 10, + 'K': 0, + '★': -2, # Joker (standard) +} + +# House rule modifications (per RULES.md House Rules section) +SUPER_KINGS_VALUE = -2 # K worth -2 instead of 0 +LUCKY_SEVENS_VALUE = 0 # 7 worth 0 instead of 7 +TEN_PENNY_VALUE = 1 # 10 worth 1 instead of 10 +LUCKY_SWING_JOKER_VALUE = -5 # Joker worth -5 in Lucky Swing mode + + +def get_card_value_for_rank(rank_str: str, options: dict | None = None) -> int: + """ + Get point value for a card rank string, with house rules applied. + + This is the single source of truth for card value calculations. + Use this for string-based rank lookups (e.g., from JSON/logs). + + Args: + rank_str: Card rank as string ('A', '2', ..., 'K', '★') + options: Optional dict with house rule flags (lucky_swing, super_kings, etc.) + + Returns: + Point value for the card + """ + value = DEFAULT_CARD_VALUES.get(rank_str, 0) + + if options: + if rank_str == '★' and options.get('lucky_swing'): + value = LUCKY_SWING_JOKER_VALUE + elif rank_str == 'K' and options.get('super_kings'): + value = SUPER_KINGS_VALUE + elif rank_str == '7' and options.get('lucky_sevens'): + value = LUCKY_SEVENS_VALUE + elif rank_str == '10' and options.get('ten_penny'): + value = TEN_PENNY_VALUE + + return value diff --git a/server/game.py b/server/game.py index e0b7214..19edbc6 100644 --- a/server/game.py +++ b/server/game.py @@ -5,6 +5,14 @@ from dataclasses import dataclass, field from typing import Optional from enum import Enum +from constants import ( + DEFAULT_CARD_VALUES, + SUPER_KINGS_VALUE, + LUCKY_SEVENS_VALUE, + TEN_PENNY_VALUE, + LUCKY_SWING_JOKER_VALUE, +) + class Suit(Enum): HEARTS = "hearts" @@ -30,22 +38,34 @@ class Rank(Enum): JOKER = "★" -RANK_VALUES = { - Rank.ACE: 1, - Rank.TWO: -2, - Rank.THREE: 3, - Rank.FOUR: 4, - Rank.FIVE: 5, - Rank.SIX: 6, - Rank.SEVEN: 7, - Rank.EIGHT: 8, - Rank.NINE: 9, - Rank.TEN: 10, - Rank.JACK: 10, - Rank.QUEEN: 10, - Rank.KING: 0, - Rank.JOKER: -2, -} +# Derive RANK_VALUES from DEFAULT_CARD_VALUES (single source of truth in constants.py) +RANK_VALUES = {rank: DEFAULT_CARD_VALUES[rank.value] for rank in Rank} + + +def get_card_value(card: "Card", options: Optional["GameOptions"] = None) -> int: + """ + Get point value for a card, with house rules applied. + + This is the single source of truth for Card object value calculations. + Use this instead of card.value() when house rules need to be considered. + + Args: + card: Card object to evaluate + options: Optional GameOptions with house rule flags + + Returns: + Point value for the card + """ + if options: + if card.rank == 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: + return SUPER_KINGS_VALUE + if card.rank == Rank.SEVEN and options.lucky_sevens: + return LUCKY_SEVENS_VALUE + if card.rank == Rank.TEN and options.ten_penny: + return TEN_PENNY_VALUE + return RANK_VALUES[card.rank] @dataclass @@ -128,19 +148,6 @@ class Player: if len(self.cards) != 6: return 0 - def get_card_value(card: Card) -> int: - """Get card value with house rules applied.""" - if options: - if card.rank == Rank.JOKER: - return -5 if options.lucky_swing else -2 - if card.rank == Rank.KING and options.super_kings: - return -2 - if card.rank == Rank.SEVEN and options.lucky_sevens: - return 0 - if card.rank == Rank.TEN and options.ten_penny: - return 1 - return card.value() - def cards_match(card1: Card, card2: Card) -> bool: """Check if two cards match for pairing (with Queens Wild support).""" if card1.rank == card2.rank: @@ -190,9 +197,9 @@ class Player: continue else: if top_idx not in four_of_kind_positions: - total += get_card_value(top_card) + total += get_card_value(top_card, options) if bottom_idx not in four_of_kind_positions: - total += get_card_value(bottom_card) + total += get_card_value(bottom_card, options) self.score = total return total @@ -257,6 +264,22 @@ class Game: def flip_on_discard(self) -> bool: return self.options.flip_on_discard + def get_card_values(self) -> dict: + """Get current card values with house rules applied.""" + values = DEFAULT_CARD_VALUES.copy() + + # Apply house rule modifications + if self.options.super_kings: + values['K'] = SUPER_KINGS_VALUE + if self.options.lucky_sevens: + values['7'] = LUCKY_SEVENS_VALUE + if self.options.ten_penny: + values['10'] = TEN_PENNY_VALUE + if self.options.lucky_swing: + values['★'] = LUCKY_SWING_JOKER_VALUE + + return values + def add_player(self, player: Player) -> bool: if len(self.players) >= 6: return False @@ -606,4 +629,5 @@ class Game: ), "initial_flips": self.options.initial_flips, "flip_on_discard": self.flip_on_discard, + "card_values": self.get_card_values(), } diff --git a/server/game_analyzer.py b/server/game_analyzer.py index 8ce5139..a375a60 100644 --- a/server/game_analyzer.py +++ b/server/game_analyzer.py @@ -14,6 +14,7 @@ from typing import Optional from enum import Enum from game import Rank, RANK_VALUES, GameOptions +from constants import get_card_value_for_rank # ============================================================================= @@ -21,25 +22,12 @@ from game import Rank, RANK_VALUES, GameOptions # ============================================================================= def get_card_value(rank: str, options: Optional[dict] = None) -> int: - """Get point value for a card rank string.""" - rank_map = { - 'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, - '8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2 - } - value = rank_map.get(rank, 0) + """Get point value for a card rank string. - # Apply house rules if provided - if options: - if rank == '★' and options.get('lucky_swing'): - value = -5 - if rank == 'K' and options.get('super_kings'): - value = -2 - if rank == '7' and options.get('lucky_sevens'): - value = 0 - if rank == '10' and options.get('ten_penny'): - value = 1 - - return value + This is a wrapper around constants.get_card_value_for_rank() for + backwards compatibility with existing analyzer code. + """ + return get_card_value_for_rank(rank, options) def rank_quality(rank: str, options: Optional[dict] = None) -> str: