From 850b8d6abf4158b4cd99ea0b6d82b44b128b9e11 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 14 Feb 2026 11:16:45 -0500 Subject: [PATCH] Standard-rules-only leaderboard with client unranked indicators Only standard-rules games now count toward leaderboard stats. Games with any house rule variant are marked "Unranked" in the active rules bar, and a notice appears in the lobby when house rules are selected. Also fixes game_logger duplicate options dicts (now uses dataclasses.asdict, capturing all options including previously missing ones) and refactors duplicated achievement-checking logic into shared helpers. Co-Authored-By: Claude Opus 4.6 --- client/app.js | 36 +++++++- client/index.html | 1 + client/style.css | 22 +++++ server/game.py | 15 ++++ server/main.py | 1 + server/services/game_logger.py | 48 +++-------- server/services/stats_service.py | 136 ++++++++++++++----------------- 7 files changed, 142 insertions(+), 117 deletions(-) diff --git a/client/app.js b/client/app.js index c86cd89..1188704 100644 --- a/client/app.js +++ b/client/app.js @@ -417,6 +417,7 @@ class GolfGame { this.oneEyedJacksCheckbox = document.getElementById('one-eyed-jacks'); this.knockEarlyCheckbox = document.getElementById('knock-early'); this.wolfpackComboNote = document.getElementById('wolfpack-combo-note'); + this.unrankedNotice = document.getElementById('unranked-notice'); this.startGameBtn = document.getElementById('start-game-btn'); this.leaveRoomBtn = document.getElementById('leave-room-btn'); this.addCpuBtn = document.getElementById('add-cpu-btn'); @@ -545,6 +546,34 @@ class GolfGame { this.wolfpackCheckbox.addEventListener('change', updateWolfpackCombo); this.fourOfAKindCheckbox.addEventListener('change', updateWolfpackCombo); + // Show/hide unranked notice when house rules change + const houseRuleInputs = [ + this.flipModeSelect, this.knockPenaltyCheckbox, + this.superKingsCheckbox, this.tenPennyCheckbox, + this.knockBonusCheckbox, this.underdogBonusCheckbox, + this.tiedShameCheckbox, this.blackjackCheckbox, + this.wolfpackCheckbox, this.flipAsActionCheckbox, + this.fourOfAKindCheckbox, this.negativePairsCheckbox, + this.oneEyedJacksCheckbox, this.knockEarlyCheckbox, + ]; + const jokerRadios = document.querySelectorAll('input[name="joker-mode"]'); + const updateUnrankedNotice = () => { + const hasHouseRules = ( + (this.flipModeSelect?.value && this.flipModeSelect.value !== 'never') || + this.knockPenaltyCheckbox?.checked || + (document.querySelector('input[name="joker-mode"]:checked')?.value !== 'none') || + this.superKingsCheckbox?.checked || this.tenPennyCheckbox?.checked || + this.knockBonusCheckbox?.checked || this.underdogBonusCheckbox?.checked || + this.tiedShameCheckbox?.checked || this.blackjackCheckbox?.checked || + this.wolfpackCheckbox?.checked || this.flipAsActionCheckbox?.checked || + this.fourOfAKindCheckbox?.checked || this.negativePairsCheckbox?.checked || + this.oneEyedJacksCheckbox?.checked || this.knockEarlyCheckbox?.checked + ); + this.unrankedNotice?.classList.toggle('hidden', !hasHouseRules); + }; + houseRuleInputs.forEach(el => el?.addEventListener('change', updateUnrankedNotice)); + jokerRadios.forEach(el => el.addEventListener('change', updateUnrankedNotice)); + // Toggle scoreboard collapse on mobile const scoreboardTitle = this.scoreboard.querySelector('h4'); if (scoreboardTitle) { @@ -2637,17 +2666,20 @@ class GolfGame { return `${rule}`; }; + const unrankedTag = this.gameState.is_standard_rules === false + ? 'Unranked' : ''; + if (rules.length === 0) { this.activeRulesList.innerHTML = 'Standard'; } else if (rules.length <= 2) { - this.activeRulesList.innerHTML = rules.map(renderTag).join(''); + this.activeRulesList.innerHTML = unrankedTag + rules.map(renderTag).join(''); } else { const displayed = rules.slice(0, 2); const hidden = rules.slice(2); const moreCount = hidden.length; const tooltip = hidden.join(', '); - this.activeRulesList.innerHTML = displayed.map(renderTag).join('') + + this.activeRulesList.innerHTML = unrankedTag + displayed.map(renderTag).join('') + `+${moreCount} more`; } this.activeRulesBar.classList.remove('hidden'); diff --git a/client/index.html b/client/index.html index 728f4b0..40f50f9 100644 --- a/client/index.html +++ b/client/index.html @@ -259,6 +259,7 @@ + diff --git a/client/style.css b/client/style.css index 6b2f01d..332fddd 100644 --- a/client/style.css +++ b/client/style.css @@ -837,6 +837,28 @@ input::placeholder { color: rgba(255, 255, 255, 0.9); } +.active-rules-bar .rule-tag.unranked { + background: rgba(220, 80, 80, 0.3); + color: #f08080; + border: 1px solid rgba(220, 80, 80, 0.4); +} + +/* Unranked notice in waiting room */ +.unranked-notice { + background: rgba(220, 80, 80, 0.15); + border: 1px solid rgba(220, 80, 80, 0.3); + color: #f0a0a0; + font-size: 0.8rem; + padding: 6px 12px; + border-radius: 6px; + margin: 8px 0; + text-align: center; +} + +.unranked-notice.hidden { + display: none; +} + /* Card Styles */ .card { width: clamp(65px, 5.5vw, 100px); diff --git a/server/game.py b/server/game.py index c7aedbc..d548222 100644 --- a/server/game.py +++ b/server/game.py @@ -512,6 +512,20 @@ class GameOptions: deck_colors: list[str] = field(default_factory=lambda: ["red", "blue", "gold"]) """Colors for card backs from different decks (in order by deck_id).""" + def is_standard_rules(self) -> bool: + """Check if all rules are standard (no house rules active).""" + return not any([ + self.flip_mode != "never", + self.initial_flips != 2, + self.knock_penalty, + self.use_jokers, + self.lucky_swing, self.super_kings, self.ten_penny, + self.knock_bonus, self.underdog_bonus, self.tied_shame, + self.blackjack, self.wolfpack, self.eagle_eye, + self.flip_as_action, self.four_of_a_kind, + self.negative_pairs_keep_value, self.one_eyed_jacks, self.knock_early, + ]) + _ALLOWED_COLORS = { "red", "blue", "gold", "teal", "purple", "orange", "yellow", "green", "pink", "cyan", "brown", "slate", @@ -1638,4 +1652,5 @@ class Game: "one_eyed_jacks": self.options.one_eyed_jacks, }, "deck_colors": self.options.deck_colors, + "is_standard_rules": self.options.is_standard_rules(), } diff --git a/server/main.py b/server/main.py index 0548490..9b0cde3 100644 --- a/server/main.py +++ b/server/main.py @@ -531,6 +531,7 @@ async def _process_stats_safe(room: Room): winner_id=winner_id, num_rounds=room.game.num_rounds, player_user_ids=player_user_ids, + game_options=room.game.options, ) logger.debug(f"Stats processed for room {room.code}") except Exception as e: diff --git a/server/services/game_logger.py b/server/services/game_logger.py index 6cc9439..4eba668 100644 --- a/server/services/game_logger.py +++ b/server/services/game_logger.py @@ -17,6 +17,7 @@ Usage: logger.log_move(game_id, player, is_cpu=False, action="swap", ...) """ +from dataclasses import asdict from typing import Optional, TYPE_CHECKING import asyncio import uuid @@ -46,6 +47,13 @@ class GameLogger: """ self.event_store = event_store + @staticmethod + def _options_to_dict(options: "GameOptions") -> dict: + """Convert GameOptions to dict for storage, excluding non-rule fields.""" + d = asdict(options) + d.pop("deck_colors", None) + return d + # ------------------------------------------------------------------------- # Game Lifecycle # ------------------------------------------------------------------------- @@ -71,30 +79,12 @@ class GameLogger: """ game_id = str(uuid.uuid4()) - options_dict = { - "flip_mode": options.flip_mode, - "initial_flips": options.initial_flips, - "knock_penalty": options.knock_penalty, - "use_jokers": options.use_jokers, - "lucky_swing": options.lucky_swing, - "super_kings": options.super_kings, - "ten_penny": options.ten_penny, - "knock_bonus": options.knock_bonus, - "underdog_bonus": options.underdog_bonus, - "tied_shame": options.tied_shame, - "blackjack": options.blackjack, - "eagle_eye": options.eagle_eye, - "negative_pairs_keep_value": getattr(options, "negative_pairs_keep_value", False), - "four_of_a_kind": getattr(options, "four_of_a_kind", False), - "wolfpack": getattr(options, "wolfpack", False), - } - try: await self.event_store.create_game( game_id=game_id, room_code=room_code, host_id="system", - options=options_dict, + options=self._options_to_dict(options), ) log.debug(f"Logged game start: {game_id} room={room_code}") except Exception as e: @@ -133,30 +123,12 @@ class GameLogger: options: "GameOptions", ) -> None: """Helper to log game start with pre-generated ID.""" - options_dict = { - "flip_mode": options.flip_mode, - "initial_flips": options.initial_flips, - "knock_penalty": options.knock_penalty, - "use_jokers": options.use_jokers, - "lucky_swing": options.lucky_swing, - "super_kings": options.super_kings, - "ten_penny": options.ten_penny, - "knock_bonus": options.knock_bonus, - "underdog_bonus": options.underdog_bonus, - "tied_shame": options.tied_shame, - "blackjack": options.blackjack, - "eagle_eye": options.eagle_eye, - "negative_pairs_keep_value": getattr(options, "negative_pairs_keep_value", False), - "four_of_a_kind": getattr(options, "four_of_a_kind", False), - "wolfpack": getattr(options, "wolfpack", False), - } - try: await self.event_store.create_game( game_id=game_id, room_code=room_code, host_id="system", - options=options_dict, + options=self._options_to_dict(options), ) log.debug(f"Logged game start: {game_id} room={room_code}") except Exception as e: diff --git a/server/services/stats_service.py b/server/services/stats_service.py index c96267a..a0be4ed 100644 --- a/server/services/stats_service.py +++ b/server/services/stats_service.py @@ -14,6 +14,7 @@ import asyncpg from stores.event_store import EventStore from models.events import EventType +from game import GameOptions logger = logging.getLogger(__name__) @@ -584,6 +585,47 @@ class StatsService: return data if data["num_rounds"] > 0 else None + @staticmethod + def _check_win_milestones(stats_row, earned_ids: set) -> List[str]: + """Check win/streak achievement milestones. Shared by event and legacy paths.""" + new = [] + wins = stats_row["games_won"] + for threshold, achievement_id in [(1, "first_win"), (10, "win_10"), (50, "win_50"), (100, "win_100")]: + if wins >= threshold and achievement_id not in earned_ids: + new.append(achievement_id) + streak = stats_row["current_win_streak"] + for threshold, achievement_id in [(5, "streak_5"), (10, "streak_10")]: + if streak >= threshold and achievement_id not in earned_ids: + new.append(achievement_id) + return new + + @staticmethod + async def _get_earned_ids(conn: asyncpg.Connection, user_id: str) -> set: + """Get set of already-earned achievement IDs for a user.""" + earned = await conn.fetch( + "SELECT achievement_id FROM user_achievements WHERE user_id = $1", + user_id, + ) + return {e["achievement_id"] for e in earned} + + @staticmethod + async def _award_achievements( + conn: asyncpg.Connection, + user_id: str, + achievement_ids: List[str], + game_id: Optional[str] = None, + ) -> None: + """Insert achievement records for a user.""" + for achievement_id in achievement_ids: + try: + await conn.execute(""" + INSERT INTO user_achievements (user_id, achievement_id, game_id) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING + """, user_id, achievement_id, game_id) + except Exception as e: + logger.error(f"Failed to award achievement {achievement_id}: {e}") + async def _check_achievements( self, conn: asyncpg.Connection, @@ -605,8 +647,6 @@ class StatsService: Returns: List of newly awarded achievement IDs. """ - new_achievements = [] - # Get current stats (after update) stats = await conn.fetchrow(""" SELECT games_won, knockouts, best_win_streak, current_win_streak, perfect_rounds, wolfpacks @@ -617,35 +657,15 @@ class StatsService: if not stats: return [] - # Get already earned achievements - earned = await conn.fetch(""" - SELECT achievement_id FROM user_achievements WHERE user_id = $1 - """, user_id) - earned_ids = {e["achievement_id"] for e in earned} + earned_ids = await self._get_earned_ids(conn, user_id) - # Check win milestones - wins = stats["games_won"] - if wins >= 1 and "first_win" not in earned_ids: - new_achievements.append("first_win") - if wins >= 10 and "win_10" not in earned_ids: - new_achievements.append("win_10") - if wins >= 50 and "win_50" not in earned_ids: - new_achievements.append("win_50") - if wins >= 100 and "win_100" not in earned_ids: - new_achievements.append("win_100") + # Win/streak milestones (shared logic) + new_achievements = self._check_win_milestones(stats, earned_ids) - # Check streak achievements - streak = stats["current_win_streak"] - if streak >= 5 and "streak_5" not in earned_ids: - new_achievements.append("streak_5") - if streak >= 10 and "streak_10" not in earned_ids: - new_achievements.append("streak_10") - - # Check knockout achievements + # Game-specific achievements (event path only) if stats["knockouts"] >= 10 and "knockout_10" not in earned_ids: new_achievements.append("knockout_10") - # Check round-specific achievements from this game best_round = player_data.get("best_round") if best_round is not None: if best_round <= 0 and "perfect_round" not in earned_ids: @@ -653,21 +673,10 @@ class StatsService: if best_round < 0 and "negative_round" not in earned_ids: new_achievements.append("negative_round") - # Check wolfpack if player_data.get("wolfpacks", 0) > 0 and "wolfpack" not in earned_ids: new_achievements.append("wolfpack") - # Award new achievements - for achievement_id in new_achievements: - try: - await conn.execute(""" - INSERT INTO user_achievements (user_id, achievement_id, game_id) - VALUES ($1, $2, $3) - ON CONFLICT DO NOTHING - """, user_id, achievement_id, game_id) - except Exception as e: - logger.error(f"Failed to award achievement {achievement_id}: {e}") - + await self._award_achievements(conn, user_id, new_achievements, game_id) return new_achievements # ------------------------------------------------------------------------- @@ -680,18 +689,21 @@ class StatsService: winner_id: Optional[str], num_rounds: int, player_user_ids: dict[str, str] = None, + game_options: Optional[GameOptions] = None, ) -> List[str]: """ Process game stats directly from game state (for legacy games). This is used when games don't have event sourcing. Stats are updated - based on final game state. + based on final game state. Only standard-rules games count toward + leaderboard stats. Args: players: List of game.Player objects with final scores. winner_id: Player ID of the winner. num_rounds: Total rounds played. player_user_ids: Optional mapping of player_id to user_id (for authenticated players). + game_options: Optional game options to check for standard rules. Returns: List of newly awarded achievement IDs. @@ -699,6 +711,11 @@ class StatsService: if not players: return [] + # Only track stats for standard-rules games + if game_options and not game_options.is_standard_rules(): + logger.debug("Skipping stats for non-standard rules game") + return [] + # Count human players for has_human_opponents calculation # For legacy games, we assume all players are human unless otherwise indicated human_count = len(players) @@ -800,9 +817,6 @@ class StatsService: Only checks win-based achievements since we don't have round-level data. """ - new_achievements = [] - - # Get current stats stats = await conn.fetchrow(""" SELECT games_won, current_win_streak FROM player_stats WHERE user_id = $1 @@ -811,41 +825,9 @@ class StatsService: if not stats: return [] - # Get already earned achievements - earned = await conn.fetch(""" - SELECT achievement_id FROM user_achievements WHERE user_id = $1 - """, user_id) - earned_ids = {e["achievement_id"] for e in earned} - - # Check win milestones - wins = stats["games_won"] - if wins >= 1 and "first_win" not in earned_ids: - new_achievements.append("first_win") - if wins >= 10 and "win_10" not in earned_ids: - new_achievements.append("win_10") - if wins >= 50 and "win_50" not in earned_ids: - new_achievements.append("win_50") - if wins >= 100 and "win_100" not in earned_ids: - new_achievements.append("win_100") - - # Check streak achievements - streak = stats["current_win_streak"] - if streak >= 5 and "streak_5" not in earned_ids: - new_achievements.append("streak_5") - if streak >= 10 and "streak_10" not in earned_ids: - new_achievements.append("streak_10") - - # Award new achievements - for achievement_id in new_achievements: - try: - await conn.execute(""" - INSERT INTO user_achievements (user_id, achievement_id) - VALUES ($1, $2) - ON CONFLICT DO NOTHING - """, user_id, achievement_id) - except Exception as e: - logger.error(f"Failed to award achievement {achievement_id}: {e}") - + earned_ids = await self._get_earned_ids(conn, user_id) + new_achievements = self._check_win_milestones(stats, earned_ids) + await self._award_achievements(conn, user_id, new_achievements) return new_achievements # -------------------------------------------------------------------------