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:
parent
e1cca98b8b
commit
850b8d6abf
@ -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');
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user