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.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) {
@@ -1758,6 +1787,7 @@ class GolfGame {
await this.delay(T.initialPause || 300); await this.delay(T.initialPause || 300);
const cardValues = this.gameState?.card_values || this.getDefaultCardValues(); const cardValues = this.gameState?.card_values || this.getDefaultCardValues();
const scoringRules = this.gameState?.scoring_rules || {};
// Order: knocker first, then others // Order: knocker first, then others
const ordered = [...players].sort((a, b) => { const ordered = [...players].sort((a, b) => {
@@ -1767,41 +1797,38 @@ class GolfGame {
}); });
for (const player of ordered) { for (const player of ordered) {
const cards = this.getCardElements(player.id, 0, 1, 2, 3, 4, 5); const cardEls = this.getCardElements(player.id, 0, 1, 2, 3, 4, 5);
if (cards.length < 6) continue; 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 // Highlight player area
this.highlightPlayerArea(player.id, true); this.highlightPlayerArea(player.id, true);
let total = 0; const colIndices = [[0, 3], [1, 4], [2, 5]];
const columns = [[0, 3], [1, 4], [2, 5]];
for (const [topIdx, bottomIdx] of columns) { for (let c = 0; c < 3; c++) {
const topData = player.cards[topIdx]; const [topIdx, bottomIdx] = colIndices[c];
const bottomData = player.cards[bottomIdx]; const col = result.columns[c];
const topCard = cards[topIdx]; const topCard = cardEls[topIdx];
const bottomCard = cards[bottomIdx]; const bottomCard = cardEls[bottomIdx];
const isPair = topData?.rank && bottomData?.rank && topData.rank === bottomData.rank;
if (isPair) { if (col.isPair) {
// Just show pair cancel — no individual card values
topCard?.classList.add('tallying'); topCard?.classList.add('tallying');
bottomCard?.classList.add('tallying'); bottomCard?.classList.add('tallying');
this.showPairCancel(topCard, bottomCard); this.showPairCancel(topCard, bottomCard, col.pairValue);
await this.delay(T.pairCelebration || 400); await this.delay(T.pairCelebration || 400);
} else { } else {
// Show individual card values // Show individual card values
topCard?.classList.add('tallying'); topCard?.classList.add('tallying');
const topValue = cardValues[topData?.rank] ?? 0; const topOverlay = this.showCardValue(topCard, col.topValue, col.topValue < 0);
const topOverlay = this.showCardValue(topCard, topValue, topValue < 0);
await this.delay(T.cardHighlight || 200); await this.delay(T.cardHighlight || 200);
bottomCard?.classList.add('tallying'); bottomCard?.classList.add('tallying');
const bottomValue = cardValues[bottomData?.rank] ?? 0; const bottomOverlay = this.showCardValue(bottomCard, col.bottomValue, col.bottomValue < 0);
const bottomOverlay = this.showCardValue(bottomCard, bottomValue, bottomValue < 0);
await this.delay(T.cardHighlight || 200); await this.delay(T.cardHighlight || 200);
total += topValue + bottomValue;
this.hideCardValue(topOverlay); this.hideCardValue(topOverlay);
this.hideCardValue(bottomOverlay); this.hideCardValue(bottomOverlay);
} }
@@ -1811,6 +1838,15 @@ class GolfGame {
await this.delay(T.columnPause || 150); 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); this.highlightPlayerArea(player.id, false);
await this.delay(T.playerPause || 500); await this.delay(T.playerPause || 500);
} }
@@ -1843,16 +1879,18 @@ class GolfGame {
setTimeout(() => overlay.remove(), 200); setTimeout(() => overlay.remove(), 200);
} }
showPairCancel(card1, card2) { showPairCancel(card1, card2, pairValue = 0) {
if (!card1 || !card2) return; if (!card1 || !card2) return;
const rect1 = card1.getBoundingClientRect(); const rect1 = card1.getBoundingClientRect();
const rect2 = card2.getBoundingClientRect(); const rect2 = card2.getBoundingClientRect();
const centerX = (rect1.left + rect1.right + rect2.left + rect2.right) / 4; const centerX = (rect1.left + rect1.right + rect2.left + rect2.right) / 4;
const centerY = (rect1.top + rect1.bottom + rect2.top + rect2.bottom) / 4; const centerY = (rect1.top + rect1.bottom + rect2.top + rect2.bottom) / 4;
const sign = pairValue > 0 ? '+' : '';
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'pair-cancel-overlay'; 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.left = `${centerX}px`;
overlay.style.top = `${centerY}px`; overlay.style.top = `${centerY}px`;
document.body.appendChild(overlay); document.body.appendChild(overlay);
@@ -1869,6 +1907,25 @@ class GolfGame {
}, 600); }, 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() { getDefaultCardValues() {
return { return {
'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '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>`; 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');
@@ -3130,41 +3190,99 @@ class GolfGame {
return suit === 'hearts' || suit === 'diamonds'; 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 || { * Calculate structured scoring results for a 6-card hand.
'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, * Single source of truth for client-side scoring logic.
'8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2 *
}; * @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.)
const getCardValue = (card) => { * @param {Object} scoringRules - house rule flags from server (eagle_eye, negative_pairs_keep_value, etc.)
if (!card.face_up) return 0; * @param {boolean} onlyFaceUp - if true, only count face-up cards (for live score badge)
return cardValues[card.rank] ?? 0; * @returns {{ columns: Array<{isPair, pairValue, topValue, bottomValue}>, bonuses: Array<{type, value}>, total: number }}
}; */
calculateColumnScores(cards, cardValues, scoringRules, onlyFaceUp = false) {
// Check for column pairs (cards in same column cancel out if matching) const rules = scoringRules || {};
const columns = [];
let total = 0; let total = 0;
let jackPairs = 0;
const pairedRanks = [];
for (let col = 0; col < 3; col++) { for (let col = 0; col < 3; col++) {
const topCard = cards[col]; const topCard = cards[col];
const bottomCard = cards[col + 3]; const bottomCard = cards[col + 3];
const topUp = topCard.face_up; const topUp = topCard.face_up;
const bottomUp = bottomCard.face_up; const bottomUp = bottomCard.face_up;
// If both face up and matching rank, they cancel (score 0) const topValue = (topUp || !onlyFaceUp) ? this.getCardPointValue(topCard, cardValues, rules) : 0;
if (topUp && bottomUp && topCard.rank === bottomCard.rank) { const bottomValue = (bottomUp || !onlyFaceUp) ? this.getCardPointValue(bottomCard, cardValues, rules) : 0;
// Matching pair = 0 points for both
continue;
}
// Otherwise add individual values const bothVisible = onlyFaceUp ? (topUp && bottomUp) : true;
total += getCardValue(topCard); const isPair = bothVisible && topCard.rank && bottomCard.rank && topCard.rank === bottomCard.rank;
total += getCardValue(bottomCard);
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) { getSuitSymbol(suit) {

View File

@@ -448,6 +448,10 @@ class CardAnimations {
const deckColor = this.getDeckColor(); const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(rect, true, deckColor); 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); this.setCardContent(animCard, cardData);
// Apply rotation to match arch layout // Apply rotation to match arch layout
@@ -603,6 +607,10 @@ class CardAnimations {
const deckColor = this.getDeckColor(); const deckColor = this.getDeckColor();
const animCard = this.createAnimCard(rect, true, deckColor); 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); this.setCardContent(animCard, discardCard);
if (rotation) { if (rotation) {

View File

@@ -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>
@@ -547,12 +548,12 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div class="house-rule"> <div class="house-rule">
<h4>Super Kings</h4> <h4>Super Kings</h4>
<p>Kings are worth <strong>-2 points</strong> instead of 0.</p> <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>
<div class="house-rule"> <div class="house-rule">
<h4>Ten Penny</h4> <h4>Ten Penny</h4>
<p>10s are worth <strong>1 point</strong> instead of 10.</p> <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>
</div> </div>
@@ -561,12 +562,12 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div class="house-rule"> <div class="house-rule">
<h4>Standard Jokers</h4> <h4>Standard Jokers</h4>
<p>2 Jokers per deck, each worth <strong>-2 points</strong>.</p> <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>
<div class="house-rule"> <div class="house-rule">
<h4>Lucky Swing</h4> <h4>Lucky Swing</h4>
<p>Only <strong>1 Joker</strong> in the entire deck, worth <strong>-5 points</strong>.</p> <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>
<div class="house-rule"> <div class="house-rule">
<h4>Eagle Eye</h4> <h4>Eagle Eye</h4>
@@ -580,12 +581,12 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div class="house-rule"> <div class="house-rule">
<h4>Knock Penalty</h4> <h4>Knock Penalty</h4>
<p><strong>+10 points</strong> if you go out but don't have the lowest score.</p> <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>
<div class="house-rule"> <div class="house-rule">
<h4>Knock Bonus</h4> <h4>Knock Bonus</h4>
<p><strong>-5 points</strong> for going out first (regardless of who wins).</p> <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> </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> <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> </div>
@@ -595,27 +596,27 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
<div class="house-rule"> <div class="house-rule">
<h4>Underdog Bonus</h4> <h4>Underdog Bonus</h4>
<p>Round winner gets <strong>-3 points</strong> extra.</p> <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>
<div class="house-rule"> <div class="house-rule">
<h4>Tied Shame</h4> <h4>Tied Shame</h4>
<p>If you tie another player's score, <strong>both get +5 penalty</strong>.</p> <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>
<div class="house-rule"> <div class="house-rule">
<h4>Blackjack</h4> <h4>Blackjack</h4>
<p>Score of exactly <strong>21 becomes 0</strong>.</p> <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>
<div class="house-rule"> <div class="house-rule">
<h4>Wolfpack</h4> <h4>Wolfpack</h4>
<p>Having <strong>all 4 Jacks</strong> (2 pairs) gives <strong>-20 bonus</strong>.</p> <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> </div>
<div class="rules-mode"> <div class="rules-mode">
<h3>New Variants</h3> <h3>Game Variants</h3>
<div class="house-rule"> <div class="house-rule">
<h4>Flip as Action</h4> <h4>Flip as Action</h4>
<p>Use your turn to flip one of your face-down cards without drawing. Ends your turn immediately.</p> <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"> <div class="house-rule">
<h4>Four of a Kind</h4> <h4>Four of a Kind</h4>
<p>Having 4 cards of the same rank across two columns scores <strong>-20 bonus</strong>.</p> <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>
<div class="house-rule"> <div class="house-rule">
<h4>Negative Pairs Keep Value</h4> <h4>Negative Pairs Keep Value</h4>

View File

@@ -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);
@@ -4522,6 +4544,10 @@ body.screen-shake {
25% { transform: translate(-50%, -50%) scale(1.1); opacity: 1; } 25% { transform: translate(-50%, -50%) scale(1.1); opacity: 1; }
100% { transform: translate(-50%, -60%) scale(1); opacity: 0; } 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 --- */ /* --- V3_10: Column Pair Indicator --- */
.card.paired { .card.paired {

View File

@@ -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",
@@ -1630,5 +1644,13 @@ class Game:
"finisher_id": self.finisher_id, "finisher_id": self.finisher_id,
"card_values": self.get_card_values(), "card_values": self.get_card_values(),
"active_rules": active_rules, "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, "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, 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:

View File

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

View File

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