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 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken 2026-02-14 11:16:45 -05:00
parent e1cca98b8b
commit 850b8d6abf
7 changed files with 142 additions and 117 deletions

View File

@ -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 `<span class="rule-tag" data-rule="${key}">${rule}</span>`;
};
const unrankedTag = this.gameState.is_standard_rules === false
? '<span class="rule-tag unranked">Unranked</span>' : '';
if (rules.length === 0) {
this.activeRulesList.innerHTML = '<span class="rule-tag standard">Standard</span>';
} 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('') +
`<span class="rule-tag rule-more" title="${tooltip}">+${moreCount} more</span>`;
}
this.activeRulesBar.classList.remove('hidden');

View File

@ -259,6 +259,7 @@
</div>
</details>
<div id="unranked-notice" class="unranked-notice hidden">Games with house rules are unranked and won't affect leaderboard stats.</div>
<button id="start-game-btn" class="btn btn-primary">Start Game</button>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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