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.oneEyedJacksCheckbox = document.getElementById('one-eyed-jacks');
|
||||||
this.knockEarlyCheckbox = document.getElementById('knock-early');
|
this.knockEarlyCheckbox = document.getElementById('knock-early');
|
||||||
this.wolfpackComboNote = document.getElementById('wolfpack-combo-note');
|
this.wolfpackComboNote = document.getElementById('wolfpack-combo-note');
|
||||||
|
this.unrankedNotice = document.getElementById('unranked-notice');
|
||||||
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');
|
||||||
@ -545,6 +546,34 @@ class GolfGame {
|
|||||||
this.wolfpackCheckbox.addEventListener('change', updateWolfpackCombo);
|
this.wolfpackCheckbox.addEventListener('change', updateWolfpackCombo);
|
||||||
this.fourOfAKindCheckbox.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
|
// Toggle scoreboard collapse on mobile
|
||||||
const scoreboardTitle = this.scoreboard.querySelector('h4');
|
const scoreboardTitle = this.scoreboard.querySelector('h4');
|
||||||
if (scoreboardTitle) {
|
if (scoreboardTitle) {
|
||||||
@ -2637,17 +2666,20 @@ class GolfGame {
|
|||||||
return `<span class="rule-tag" data-rule="${key}">${rule}</span>`;
|
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) {
|
if (rules.length === 0) {
|
||||||
this.activeRulesList.innerHTML = '<span class="rule-tag standard">Standard</span>';
|
this.activeRulesList.innerHTML = '<span class="rule-tag standard">Standard</span>';
|
||||||
} else if (rules.length <= 2) {
|
} else if (rules.length <= 2) {
|
||||||
this.activeRulesList.innerHTML = rules.map(renderTag).join('');
|
this.activeRulesList.innerHTML = unrankedTag + rules.map(renderTag).join('');
|
||||||
} else {
|
} else {
|
||||||
const displayed = rules.slice(0, 2);
|
const displayed = rules.slice(0, 2);
|
||||||
const hidden = rules.slice(2);
|
const hidden = rules.slice(2);
|
||||||
const moreCount = hidden.length;
|
const moreCount = hidden.length;
|
||||||
const tooltip = hidden.join(', ');
|
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>`;
|
`<span class="rule-tag rule-more" title="${tooltip}">+${moreCount} more</span>`;
|
||||||
}
|
}
|
||||||
this.activeRulesBar.classList.remove('hidden');
|
this.activeRulesBar.classList.remove('hidden');
|
||||||
|
|||||||
@ -259,6 +259,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</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>
|
<button id="start-game-btn" class="btn btn-primary">Start Game</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -837,6 +837,28 @@ input::placeholder {
|
|||||||
color: rgba(255, 255, 255, 0.9);
|
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 Styles */
|
||||||
.card {
|
.card {
|
||||||
width: clamp(65px, 5.5vw, 100px);
|
width: clamp(65px, 5.5vw, 100px);
|
||||||
|
|||||||
@ -512,6 +512,20 @@ class GameOptions:
|
|||||||
deck_colors: list[str] = field(default_factory=lambda: ["red", "blue", "gold"])
|
deck_colors: list[str] = field(default_factory=lambda: ["red", "blue", "gold"])
|
||||||
"""Colors for card backs from different decks (in order by deck_id)."""
|
"""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 = {
|
_ALLOWED_COLORS = {
|
||||||
"red", "blue", "gold", "teal", "purple", "orange", "yellow",
|
"red", "blue", "gold", "teal", "purple", "orange", "yellow",
|
||||||
"green", "pink", "cyan", "brown", "slate",
|
"green", "pink", "cyan", "brown", "slate",
|
||||||
@ -1638,4 +1652,5 @@ class Game:
|
|||||||
"one_eyed_jacks": self.options.one_eyed_jacks,
|
"one_eyed_jacks": self.options.one_eyed_jacks,
|
||||||
},
|
},
|
||||||
"deck_colors": self.options.deck_colors,
|
"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,
|
winner_id=winner_id,
|
||||||
num_rounds=room.game.num_rounds,
|
num_rounds=room.game.num_rounds,
|
||||||
player_user_ids=player_user_ids,
|
player_user_ids=player_user_ids,
|
||||||
|
game_options=room.game.options,
|
||||||
)
|
)
|
||||||
logger.debug(f"Stats processed for room {room.code}")
|
logger.debug(f"Stats processed for room {room.code}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -17,6 +17,7 @@ Usage:
|
|||||||
logger.log_move(game_id, player, is_cpu=False, action="swap", ...)
|
logger.log_move(game_id, player, is_cpu=False, action="swap", ...)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
import asyncio
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
@ -46,6 +47,13 @@ class GameLogger:
|
|||||||
"""
|
"""
|
||||||
self.event_store = event_store
|
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
|
# Game Lifecycle
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@ -71,30 +79,12 @@ class GameLogger:
|
|||||||
"""
|
"""
|
||||||
game_id = str(uuid.uuid4())
|
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:
|
try:
|
||||||
await self.event_store.create_game(
|
await self.event_store.create_game(
|
||||||
game_id=game_id,
|
game_id=game_id,
|
||||||
room_code=room_code,
|
room_code=room_code,
|
||||||
host_id="system",
|
host_id="system",
|
||||||
options=options_dict,
|
options=self._options_to_dict(options),
|
||||||
)
|
)
|
||||||
log.debug(f"Logged game start: {game_id} room={room_code}")
|
log.debug(f"Logged game start: {game_id} room={room_code}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -133,30 +123,12 @@ class GameLogger:
|
|||||||
options: "GameOptions",
|
options: "GameOptions",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Helper to log game start with pre-generated ID."""
|
"""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:
|
try:
|
||||||
await self.event_store.create_game(
|
await self.event_store.create_game(
|
||||||
game_id=game_id,
|
game_id=game_id,
|
||||||
room_code=room_code,
|
room_code=room_code,
|
||||||
host_id="system",
|
host_id="system",
|
||||||
options=options_dict,
|
options=self._options_to_dict(options),
|
||||||
)
|
)
|
||||||
log.debug(f"Logged game start: {game_id} room={room_code}")
|
log.debug(f"Logged game start: {game_id} room={room_code}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import asyncpg
|
|||||||
|
|
||||||
from stores.event_store import EventStore
|
from stores.event_store import EventStore
|
||||||
from models.events import EventType
|
from models.events import EventType
|
||||||
|
from game import GameOptions
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -584,6 +585,47 @@ class StatsService:
|
|||||||
|
|
||||||
return data if data["num_rounds"] > 0 else None
|
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(
|
async def _check_achievements(
|
||||||
self,
|
self,
|
||||||
conn: asyncpg.Connection,
|
conn: asyncpg.Connection,
|
||||||
@ -605,8 +647,6 @@ class StatsService:
|
|||||||
Returns:
|
Returns:
|
||||||
List of newly awarded achievement IDs.
|
List of newly awarded achievement IDs.
|
||||||
"""
|
"""
|
||||||
new_achievements = []
|
|
||||||
|
|
||||||
# Get current stats (after update)
|
# Get current stats (after update)
|
||||||
stats = await conn.fetchrow("""
|
stats = await conn.fetchrow("""
|
||||||
SELECT games_won, knockouts, best_win_streak, current_win_streak, perfect_rounds, wolfpacks
|
SELECT games_won, knockouts, best_win_streak, current_win_streak, perfect_rounds, wolfpacks
|
||||||
@ -617,35 +657,15 @@ class StatsService:
|
|||||||
if not stats:
|
if not stats:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Get already earned achievements
|
earned_ids = await self._get_earned_ids(conn, user_id)
|
||||||
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
|
# Win/streak milestones (shared logic)
|
||||||
wins = stats["games_won"]
|
new_achievements = self._check_win_milestones(stats, earned_ids)
|
||||||
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
|
# Game-specific achievements (event path only)
|
||||||
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
|
|
||||||
if stats["knockouts"] >= 10 and "knockout_10" not in earned_ids:
|
if stats["knockouts"] >= 10 and "knockout_10" not in earned_ids:
|
||||||
new_achievements.append("knockout_10")
|
new_achievements.append("knockout_10")
|
||||||
|
|
||||||
# Check round-specific achievements from this game
|
|
||||||
best_round = player_data.get("best_round")
|
best_round = player_data.get("best_round")
|
||||||
if best_round is not None:
|
if best_round is not None:
|
||||||
if best_round <= 0 and "perfect_round" not in earned_ids:
|
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:
|
if best_round < 0 and "negative_round" not in earned_ids:
|
||||||
new_achievements.append("negative_round")
|
new_achievements.append("negative_round")
|
||||||
|
|
||||||
# Check wolfpack
|
|
||||||
if player_data.get("wolfpacks", 0) > 0 and "wolfpack" not in earned_ids:
|
if player_data.get("wolfpacks", 0) > 0 and "wolfpack" not in earned_ids:
|
||||||
new_achievements.append("wolfpack")
|
new_achievements.append("wolfpack")
|
||||||
|
|
||||||
# Award new achievements
|
await self._award_achievements(conn, user_id, new_achievements, game_id)
|
||||||
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}")
|
|
||||||
|
|
||||||
return new_achievements
|
return new_achievements
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@ -680,18 +689,21 @@ class StatsService:
|
|||||||
winner_id: Optional[str],
|
winner_id: Optional[str],
|
||||||
num_rounds: int,
|
num_rounds: int,
|
||||||
player_user_ids: dict[str, str] = None,
|
player_user_ids: dict[str, str] = None,
|
||||||
|
game_options: Optional[GameOptions] = None,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Process game stats directly from game state (for legacy games).
|
Process game stats directly from game state (for legacy games).
|
||||||
|
|
||||||
This is used when games don't have event sourcing. Stats are updated
|
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:
|
Args:
|
||||||
players: List of game.Player objects with final scores.
|
players: List of game.Player objects with final scores.
|
||||||
winner_id: Player ID of the winner.
|
winner_id: Player ID of the winner.
|
||||||
num_rounds: Total rounds played.
|
num_rounds: Total rounds played.
|
||||||
player_user_ids: Optional mapping of player_id to user_id (for authenticated players).
|
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:
|
Returns:
|
||||||
List of newly awarded achievement IDs.
|
List of newly awarded achievement IDs.
|
||||||
@ -699,6 +711,11 @@ class StatsService:
|
|||||||
if not players:
|
if not players:
|
||||||
return []
|
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
|
# Count human players for has_human_opponents calculation
|
||||||
# For legacy games, we assume all players are human unless otherwise indicated
|
# For legacy games, we assume all players are human unless otherwise indicated
|
||||||
human_count = len(players)
|
human_count = len(players)
|
||||||
@ -800,9 +817,6 @@ class StatsService:
|
|||||||
|
|
||||||
Only checks win-based achievements since we don't have round-level data.
|
Only checks win-based achievements since we don't have round-level data.
|
||||||
"""
|
"""
|
||||||
new_achievements = []
|
|
||||||
|
|
||||||
# Get current stats
|
|
||||||
stats = await conn.fetchrow("""
|
stats = await conn.fetchrow("""
|
||||||
SELECT games_won, current_win_streak FROM player_stats
|
SELECT games_won, current_win_streak FROM player_stats
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
@ -811,41 +825,9 @@ class StatsService:
|
|||||||
if not stats:
|
if not stats:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Get already earned achievements
|
earned_ids = await self._get_earned_ids(conn, user_id)
|
||||||
earned = await conn.fetch("""
|
new_achievements = self._check_win_milestones(stats, earned_ids)
|
||||||
SELECT achievement_id FROM user_achievements WHERE user_id = $1
|
await self._award_achievements(conn, user_id, new_achievements)
|
||||||
""", 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}")
|
|
||||||
|
|
||||||
return new_achievements
|
return new_achievements
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user