3 Commits

Author SHA1 Message Date
adlee-was-taken
850b8d6abf 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>
2026-02-14 11:16:45 -05:00
adlee-was-taken
e1cca98b8b Fix client scoring to respect house rules for column pairs
Client-side scoring (points badge and score tally animation) ignored
house rules that modify pair behavior. Extract shared
calculateColumnScores() helper that mirrors server logic for
eagle_eye, negative_pairs_keep_value, wolfpack, four_of_a_kind,
and one_eyed_jacks rules. Server now sends scoring_rules flags
in game state.

Also fix opponent flip animation card font-size matching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:34:40 -05:00
adlee-was-taken
df61d88ec6 Revise rules page strategic impact descriptions for accuracy
Rename "New Variants" to "Game Variants", fix descriptions that
contradicted game mechanics (impossible card scenarios, misleading
value assessments), and clarify Underdog Bonus catch-up intent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:18:26 -05:00
8 changed files with 303 additions and 173 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) {
@@ -1758,6 +1787,7 @@ class GolfGame {
await this.delay(T.initialPause || 300);
const cardValues = this.gameState?.card_values || this.getDefaultCardValues();
const scoringRules = this.gameState?.scoring_rules || {};
// Order: knocker first, then others
const ordered = [...players].sort((a, b) => {
@@ -1767,41 +1797,38 @@ class GolfGame {
});
for (const player of ordered) {
const cards = this.getCardElements(player.id, 0, 1, 2, 3, 4, 5);
if (cards.length < 6) continue;
const cardEls = this.getCardElements(player.id, 0, 1, 2, 3, 4, 5);
if (cardEls.length < 6) continue;
// Use shared scoring logic (all cards revealed at round end)
const result = this.calculateColumnScores(player.cards, cardValues, scoringRules, false);
// Highlight player area
this.highlightPlayerArea(player.id, true);
let total = 0;
const columns = [[0, 3], [1, 4], [2, 5]];
const colIndices = [[0, 3], [1, 4], [2, 5]];
for (const [topIdx, bottomIdx] of columns) {
const topData = player.cards[topIdx];
const bottomData = player.cards[bottomIdx];
const topCard = cards[topIdx];
const bottomCard = cards[bottomIdx];
const isPair = topData?.rank && bottomData?.rank && topData.rank === bottomData.rank;
for (let c = 0; c < 3; c++) {
const [topIdx, bottomIdx] = colIndices[c];
const col = result.columns[c];
const topCard = cardEls[topIdx];
const bottomCard = cardEls[bottomIdx];
if (isPair) {
// Just show pair cancel — no individual card values
if (col.isPair) {
topCard?.classList.add('tallying');
bottomCard?.classList.add('tallying');
this.showPairCancel(topCard, bottomCard);
this.showPairCancel(topCard, bottomCard, col.pairValue);
await this.delay(T.pairCelebration || 400);
} else {
// Show individual card values
topCard?.classList.add('tallying');
const topValue = cardValues[topData?.rank] ?? 0;
const topOverlay = this.showCardValue(topCard, topValue, topValue < 0);
const topOverlay = this.showCardValue(topCard, col.topValue, col.topValue < 0);
await this.delay(T.cardHighlight || 200);
bottomCard?.classList.add('tallying');
const bottomValue = cardValues[bottomData?.rank] ?? 0;
const bottomOverlay = this.showCardValue(bottomCard, bottomValue, bottomValue < 0);
const bottomOverlay = this.showCardValue(bottomCard, col.bottomValue, col.bottomValue < 0);
await this.delay(T.cardHighlight || 200);
total += topValue + bottomValue;
this.hideCardValue(topOverlay);
this.hideCardValue(bottomOverlay);
}
@@ -1811,6 +1838,15 @@ class GolfGame {
await this.delay(T.columnPause || 150);
}
// Show bonuses (wolfpack, four-of-a-kind)
if (result.bonuses.length > 0) {
for (const bonus of result.bonuses) {
const label = bonus.type === 'wolfpack' ? 'WOLFPACK!' : 'FOUR OF A KIND!';
this.showBonusOverlay(player.id, label, bonus.value);
await this.delay(T.pairCelebration || 400);
}
}
this.highlightPlayerArea(player.id, false);
await this.delay(T.playerPause || 500);
}
@@ -1843,16 +1879,18 @@ class GolfGame {
setTimeout(() => overlay.remove(), 200);
}
showPairCancel(card1, card2) {
showPairCancel(card1, card2, pairValue = 0) {
if (!card1 || !card2) return;
const rect1 = card1.getBoundingClientRect();
const rect2 = card2.getBoundingClientRect();
const centerX = (rect1.left + rect1.right + rect2.left + rect2.right) / 4;
const centerY = (rect1.top + rect1.bottom + rect2.top + rect2.bottom) / 4;
const sign = pairValue > 0 ? '+' : '';
const overlay = document.createElement('div');
overlay.className = 'pair-cancel-overlay';
overlay.textContent = 'PAIR! +0';
if (pairValue < 0) overlay.classList.add('negative');
overlay.textContent = `PAIR! ${sign}${pairValue}`;
overlay.style.left = `${centerX}px`;
overlay.style.top = `${centerY}px`;
document.body.appendChild(overlay);
@@ -1869,6 +1907,25 @@ class GolfGame {
}, 600);
}
showBonusOverlay(playerId, label, value) {
const area = playerId === this.playerId
? this.playerArea
: this.opponentsRow.querySelector(`.opponent-area[data-player-id="${playerId}"]`);
if (!area) return;
const rect = area.getBoundingClientRect();
const overlay = document.createElement('div');
overlay.className = 'pair-cancel-overlay negative';
overlay.textContent = `${label} ${value}`;
overlay.style.left = `${rect.left + rect.width / 2}px`;
overlay.style.top = `${rect.top + rect.height / 2}px`;
document.body.appendChild(overlay);
this.playSound('pair');
setTimeout(() => overlay.remove(), 600);
}
getDefaultCardValues() {
return {
'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7,
@@ -2609,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');
@@ -3130,41 +3190,99 @@ class GolfGame {
return suit === 'hearts' || suit === 'diamonds';
}
calculateShowingScore(cards) {
if (!cards || cards.length !== 6) return 0;
/**
* Get the point value for a single card, respecting house rules.
* Handles one_eyed_jacks (J♥/J♠ = 0) which can't be in the card_values map.
*/
getCardPointValue(card, cardValues, scoringRules) {
if (!card.rank) return 0;
if (scoringRules?.one_eyed_jacks && card.rank === 'J' &&
(card.suit === 'hearts' || card.suit === 'spades')) {
return 0;
}
return cardValues[card.rank] ?? 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)
/**
* Calculate structured scoring results for a 6-card hand.
* Single source of truth for client-side scoring logic.
*
* @param {Array} cards - 6-element array of card data objects ({rank, suit, face_up})
* @param {Object} cardValues - rank→point map from server (includes lucky_swing, super_kings, etc.)
* @param {Object} scoringRules - house rule flags from server (eagle_eye, negative_pairs_keep_value, etc.)
* @param {boolean} onlyFaceUp - if true, only count face-up cards (for live score badge)
* @returns {{ columns: Array<{isPair, pairValue, topValue, bottomValue}>, bonuses: Array<{type, value}>, total: number }}
*/
calculateColumnScores(cards, cardValues, scoringRules, onlyFaceUp = false) {
const rules = scoringRules || {};
const columns = [];
let total = 0;
let jackPairs = 0;
const pairedRanks = [];
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;
}
const topValue = (topUp || !onlyFaceUp) ? this.getCardPointValue(topCard, cardValues, rules) : 0;
const bottomValue = (bottomUp || !onlyFaceUp) ? this.getCardPointValue(bottomCard, cardValues, rules) : 0;
// Otherwise add individual values
total += getCardValue(topCard);
total += getCardValue(bottomCard);
const bothVisible = onlyFaceUp ? (topUp && bottomUp) : true;
const isPair = bothVisible && topCard.rank && bottomCard.rank && topCard.rank === bottomCard.rank;
if (isPair) {
pairedRanks.push(topCard.rank);
if (topCard.rank === 'J') jackPairs++;
let pairValue = 0;
// Eagle Eye: paired jokers score -4
if (rules.eagle_eye && topCard.rank === '★') {
pairValue = -4;
}
// Negative Pairs Keep Value: negative-value pairs keep their score
else if (rules.negative_pairs_keep_value && (topValue < 0 || bottomValue < 0)) {
pairValue = topValue + bottomValue;
}
// Normal pair: 0
total += pairValue;
columns.push({ isPair: true, pairValue, topValue, bottomValue });
} else {
total += topValue + bottomValue;
columns.push({ isPair: false, pairValue: 0, topValue, bottomValue });
}
}
return total;
// Bonuses
const bonuses = [];
if (rules.wolfpack && jackPairs >= 2) {
bonuses.push({ type: 'wolfpack', value: -20 });
total += -20;
}
if (rules.four_of_a_kind) {
const rankCounts = {};
for (const r of pairedRanks) {
rankCounts[r] = (rankCounts[r] || 0) + 1;
}
for (const [rank, count] of Object.entries(rankCounts)) {
if (count >= 2) {
bonuses.push({ type: 'four_of_a_kind', value: -20, rank });
total += -20;
}
}
}
return { columns, bonuses, total };
}
calculateShowingScore(cards) {
if (!cards || cards.length !== 6) return 0;
const cardValues = this.gameState?.card_values || this.getDefaultCardValues();
const scoringRules = this.gameState?.scoring_rules || {};
return this.calculateColumnScores(cards, cardValues, scoringRules, true).total;
}
getSuitSymbol(suit) {

View File

@@ -448,6 +448,10 @@ class CardAnimations {
const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(rect, true, deckColor);
// Match source card's font-size (opponent cards are smaller than default)
const srcFontSize = getComputedStyle(cardElement).fontSize;
const front = animCard.querySelector('.draw-anim-front');
if (front) front.style.fontSize = srcFontSize;
this.setCardContent(animCard, cardData);
// Apply rotation to match arch layout
@@ -603,6 +607,10 @@ class CardAnimations {
const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(rect, true, deckColor);
// Match source card's font-size (opponent cards are smaller than default)
const srcFontSize = getComputedStyle(sourceCardElement).fontSize;
const front = animCard.querySelector('.draw-anim-front');
if (front) front.style.fontSize = srcFontSize;
this.setCardContent(animCard, discardCard);
if (rotation) {

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>
@@ -547,12 +548,12 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div class="house-rule">
<h4>Super Kings</h4>
<p>Kings are worth <strong>-2 points</strong> instead of 0.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Kings become valuable to keep unpaired, not just pairing fodder. Creates interesting decisions - do you pair Kings for 0, or keep them separate for -4 total?</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Pairing Kings now has a real cost — two Kings in separate columns score -4 total, but paired they score 0. Makes you think twice before completing a King pair.</p>
</div>
<div class="house-rule">
<h4>Ten Penny</h4>
<p>10s are worth <strong>1 point</strong> instead of 10.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Removes the "10 disaster" - drawing a 10 is no longer a crisis. Queens and Jacks become the only truly bad cards. Makes the game more forgiving.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Drawing a 10 is no longer a crisis Queens and Jacks become the only truly dangerous cards. Reduces the penalty spread between mid-range and high cards.</p>
</div>
</div>
@@ -561,12 +562,12 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div class="house-rule">
<h4>Standard Jokers</h4>
<p>2 Jokers per deck, each worth <strong>-2 points</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Jokers are great to find but pairing them is wasteful (0 points instead of -4). Best kept in different columns. Adds 2 premium cards to hunt for.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Jokers are premium finds, but pairing them wastes their value (0 points instead of -4). Best placed in different columns.</p>
</div>
<div class="house-rule">
<h4>Lucky Swing</h4>
<p>Only <strong>1 Joker</strong> in the entire deck, worth <strong>-5 points</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> High variance. Whoever finds this rare card gets a significant advantage. Increases the luck factor - sometimes you get it, sometimes your opponent does.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> With only one Joker in the deck, finding it is a major swing. Raises the stakes on every draw from the deck.</p>
</div>
<div class="house-rule">
<h4>Eagle Eye</h4>
@@ -580,12 +581,12 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div class="house-rule">
<h4>Knock Penalty</h4>
<p><strong>+10 points</strong> if you go out but don't have the lowest score.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Discourages reckless rushing. You need to be confident you're winning before going out. Rewards patience and reading your opponents' likely scores. Can backfire spectacularly if you misjudge.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> You need to be confident you have the lowest score before going out. Rewards patience and reading your opponents' likely hands.</p>
</div>
<div class="house-rule">
<h4>Knock Bonus</h4>
<p><strong>-5 points</strong> for going out first (regardless of who wins).</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Encourages racing to finish, even with a mediocre hand. The 5-point bonus might make up for a slightly worse score. Speeds up gameplay.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Rewards racing to finish. The 5-point bonus can offset a slightly worse hand, creating a tension between improving your score and ending the round quickly.</p>
</div>
<p class="combo-note"><em>Combining Knock Penalty + Knock Bonus creates high-stakes "going out" decisions: -5 if you win, +10 if you lose!</em></p>
</div>
@@ -595,27 +596,27 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div class="house-rule">
<h4>Underdog Bonus</h4>
<p>Round winner gets <strong>-3 points</strong> extra.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Amplifies winning - the best player each round pulls further ahead. Can lead to snowballing leads over multiple holes. Rewards consistency.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Gives trailing players a way to close the gap — win a round and claw back 3 extra points. Over multiple holes, a player who's behind can mount a comeback by stringing together strong rounds.</p>
</div>
<div class="house-rule">
<h4>Tied Shame</h4>
<p>If you tie another player's score, <strong>both get +5 penalty</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Punishes playing it safe. If you suspect a tie, you need to take risks to differentiate your score. Creates interesting late-round decisions.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Punishes playing it safe. If you suspect a tie, you need to take risks to break it — a last-turn swap you'd normally skip becomes worth considering.</p>
</div>
<div class="house-rule">
<h4>Blackjack</h4>
<p>Score of exactly <strong>21 becomes 0</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> A "hail mary" comeback. If you're stuck at 21, you're suddenly in great shape. Mostly luck, but adds exciting moments when it happens.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Turns a bad round into a great one. If your score lands on exactly 21, you walk away with 0 instead. Worth keeping in mind before making that last swap.</p>
</div>
<div class="house-rule">
<h4>Wolfpack</h4>
<p>Having <strong>all 4 Jacks</strong> (2 pairs) gives <strong>-20 bonus</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Extremely rare but now a significant reward! Turns a potential disaster (40 points of Jacks) into a triumph. The huge bonus makes it worth celebrating when achieved, though still not worth actively pursuing.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Turns a potential disaster (40 points of Jacks) into a triumph. If you already have a pair of Jacks in one column and a third Jack appears, the -20 bonus makes it worth grabbing and hunting for the fourth.</p>
</div>
</div>
<div class="rules-mode">
<h3>New Variants</h3>
<h3>Game Variants</h3>
<div class="house-rule">
<h4>Flip as Action</h4>
<p>Use your turn to flip one of your face-down cards without drawing. Ends your turn immediately.</p>
@@ -624,7 +625,7 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div class="house-rule">
<h4>Four of a Kind</h4>
<p>Having 4 cards of the same rank across two columns scores <strong>-20 bonus</strong>.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Rewards collecting matching cards beyond just column pairs. Changes whether you should take a third or fourth copy of a rank. If you already have two pairs of 8s, that's -20 extra! Stacks with Wolfpack: four Jacks = -40 total.</p>
<p class="strategic-impact"><strong>Strategic impact:</strong> Rewards collecting matching cards beyond column pairs. Once you have a pair in one column, grabbing a third or fourth of that rank for another column becomes worthwhile. Stacks with Wolfpack: four Jacks = -40 total.</p>
</div>
<div class="house-rule">
<h4>Negative Pairs Keep Value</h4>

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);
@@ -4522,6 +4544,10 @@ body.screen-shake {
25% { transform: translate(-50%, -50%) scale(1.1); opacity: 1; }
100% { transform: translate(-50%, -60%) scale(1); opacity: 0; }
}
.pair-cancel-overlay.negative {
color: #81d4fa;
border-color: rgba(100, 181, 246, 0.4);
}
/* --- V3_10: Column Pair Indicator --- */
.card.paired {

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",
@@ -1630,5 +1644,13 @@ class Game:
"finisher_id": self.finisher_id,
"card_values": self.get_card_values(),
"active_rules": active_rules,
"scoring_rules": {
"negative_pairs_keep_value": self.options.negative_pairs_keep_value,
"eagle_eye": self.options.eagle_eye,
"wolfpack": self.options.wolfpack,
"four_of_a_kind": self.options.four_of_a_kind,
"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
# -------------------------------------------------------------------------