Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
850b8d6abf | ||
|
|
e1cca98b8b | ||
|
|
df61d88ec6 |
206
client/app.js
206
client/app.js
@@ -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;
|
||||
|
||||
// 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;
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise add individual values
|
||||
total += getCardValue(topCard);
|
||||
total += getCardValue(bottomCard);
|
||||
// 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 total;
|
||||
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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -531,6 +531,7 @@ async def _process_stats_safe(room: Room):
|
||||
winner_id=winner_id,
|
||||
num_rounds=room.game.num_rounds,
|
||||
player_user_ids=player_user_ids,
|
||||
game_options=room.game.options,
|
||||
)
|
||||
logger.debug(f"Stats processed for room {room.code}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -17,6 +17,7 @@ Usage:
|
||||
logger.log_move(game_id, player, is_cpu=False, action="swap", ...)
|
||||
"""
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
import asyncio
|
||||
import uuid
|
||||
@@ -46,6 +47,13 @@ class GameLogger:
|
||||
"""
|
||||
self.event_store = event_store
|
||||
|
||||
@staticmethod
|
||||
def _options_to_dict(options: "GameOptions") -> dict:
|
||||
"""Convert GameOptions to dict for storage, excluding non-rule fields."""
|
||||
d = asdict(options)
|
||||
d.pop("deck_colors", None)
|
||||
return d
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Game Lifecycle
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -71,30 +79,12 @@ class GameLogger:
|
||||
"""
|
||||
game_id = str(uuid.uuid4())
|
||||
|
||||
options_dict = {
|
||||
"flip_mode": options.flip_mode,
|
||||
"initial_flips": options.initial_flips,
|
||||
"knock_penalty": options.knock_penalty,
|
||||
"use_jokers": options.use_jokers,
|
||||
"lucky_swing": options.lucky_swing,
|
||||
"super_kings": options.super_kings,
|
||||
"ten_penny": options.ten_penny,
|
||||
"knock_bonus": options.knock_bonus,
|
||||
"underdog_bonus": options.underdog_bonus,
|
||||
"tied_shame": options.tied_shame,
|
||||
"blackjack": options.blackjack,
|
||||
"eagle_eye": options.eagle_eye,
|
||||
"negative_pairs_keep_value": getattr(options, "negative_pairs_keep_value", False),
|
||||
"four_of_a_kind": getattr(options, "four_of_a_kind", False),
|
||||
"wolfpack": getattr(options, "wolfpack", False),
|
||||
}
|
||||
|
||||
try:
|
||||
await self.event_store.create_game(
|
||||
game_id=game_id,
|
||||
room_code=room_code,
|
||||
host_id="system",
|
||||
options=options_dict,
|
||||
options=self._options_to_dict(options),
|
||||
)
|
||||
log.debug(f"Logged game start: {game_id} room={room_code}")
|
||||
except Exception as e:
|
||||
@@ -133,30 +123,12 @@ class GameLogger:
|
||||
options: "GameOptions",
|
||||
) -> None:
|
||||
"""Helper to log game start with pre-generated ID."""
|
||||
options_dict = {
|
||||
"flip_mode": options.flip_mode,
|
||||
"initial_flips": options.initial_flips,
|
||||
"knock_penalty": options.knock_penalty,
|
||||
"use_jokers": options.use_jokers,
|
||||
"lucky_swing": options.lucky_swing,
|
||||
"super_kings": options.super_kings,
|
||||
"ten_penny": options.ten_penny,
|
||||
"knock_bonus": options.knock_bonus,
|
||||
"underdog_bonus": options.underdog_bonus,
|
||||
"tied_shame": options.tied_shame,
|
||||
"blackjack": options.blackjack,
|
||||
"eagle_eye": options.eagle_eye,
|
||||
"negative_pairs_keep_value": getattr(options, "negative_pairs_keep_value", False),
|
||||
"four_of_a_kind": getattr(options, "four_of_a_kind", False),
|
||||
"wolfpack": getattr(options, "wolfpack", False),
|
||||
}
|
||||
|
||||
try:
|
||||
await self.event_store.create_game(
|
||||
game_id=game_id,
|
||||
room_code=room_code,
|
||||
host_id="system",
|
||||
options=options_dict,
|
||||
options=self._options_to_dict(options),
|
||||
)
|
||||
log.debug(f"Logged game start: {game_id} room={room_code}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -14,6 +14,7 @@ import asyncpg
|
||||
|
||||
from stores.event_store import EventStore
|
||||
from models.events import EventType
|
||||
from game import GameOptions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -584,6 +585,47 @@ class StatsService:
|
||||
|
||||
return data if data["num_rounds"] > 0 else None
|
||||
|
||||
@staticmethod
|
||||
def _check_win_milestones(stats_row, earned_ids: set) -> List[str]:
|
||||
"""Check win/streak achievement milestones. Shared by event and legacy paths."""
|
||||
new = []
|
||||
wins = stats_row["games_won"]
|
||||
for threshold, achievement_id in [(1, "first_win"), (10, "win_10"), (50, "win_50"), (100, "win_100")]:
|
||||
if wins >= threshold and achievement_id not in earned_ids:
|
||||
new.append(achievement_id)
|
||||
streak = stats_row["current_win_streak"]
|
||||
for threshold, achievement_id in [(5, "streak_5"), (10, "streak_10")]:
|
||||
if streak >= threshold and achievement_id not in earned_ids:
|
||||
new.append(achievement_id)
|
||||
return new
|
||||
|
||||
@staticmethod
|
||||
async def _get_earned_ids(conn: asyncpg.Connection, user_id: str) -> set:
|
||||
"""Get set of already-earned achievement IDs for a user."""
|
||||
earned = await conn.fetch(
|
||||
"SELECT achievement_id FROM user_achievements WHERE user_id = $1",
|
||||
user_id,
|
||||
)
|
||||
return {e["achievement_id"] for e in earned}
|
||||
|
||||
@staticmethod
|
||||
async def _award_achievements(
|
||||
conn: asyncpg.Connection,
|
||||
user_id: str,
|
||||
achievement_ids: List[str],
|
||||
game_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Insert achievement records for a user."""
|
||||
for achievement_id in achievement_ids:
|
||||
try:
|
||||
await conn.execute("""
|
||||
INSERT INTO user_achievements (user_id, achievement_id, game_id)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING
|
||||
""", user_id, achievement_id, game_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to award achievement {achievement_id}: {e}")
|
||||
|
||||
async def _check_achievements(
|
||||
self,
|
||||
conn: asyncpg.Connection,
|
||||
@@ -605,8 +647,6 @@ class StatsService:
|
||||
Returns:
|
||||
List of newly awarded achievement IDs.
|
||||
"""
|
||||
new_achievements = []
|
||||
|
||||
# Get current stats (after update)
|
||||
stats = await conn.fetchrow("""
|
||||
SELECT games_won, knockouts, best_win_streak, current_win_streak, perfect_rounds, wolfpacks
|
||||
@@ -617,35 +657,15 @@ class StatsService:
|
||||
if not stats:
|
||||
return []
|
||||
|
||||
# Get already earned achievements
|
||||
earned = await conn.fetch("""
|
||||
SELECT achievement_id FROM user_achievements WHERE user_id = $1
|
||||
""", user_id)
|
||||
earned_ids = {e["achievement_id"] for e in earned}
|
||||
earned_ids = await self._get_earned_ids(conn, user_id)
|
||||
|
||||
# Check win milestones
|
||||
wins = stats["games_won"]
|
||||
if wins >= 1 and "first_win" not in earned_ids:
|
||||
new_achievements.append("first_win")
|
||||
if wins >= 10 and "win_10" not in earned_ids:
|
||||
new_achievements.append("win_10")
|
||||
if wins >= 50 and "win_50" not in earned_ids:
|
||||
new_achievements.append("win_50")
|
||||
if wins >= 100 and "win_100" not in earned_ids:
|
||||
new_achievements.append("win_100")
|
||||
# Win/streak milestones (shared logic)
|
||||
new_achievements = self._check_win_milestones(stats, earned_ids)
|
||||
|
||||
# Check streak achievements
|
||||
streak = stats["current_win_streak"]
|
||||
if streak >= 5 and "streak_5" not in earned_ids:
|
||||
new_achievements.append("streak_5")
|
||||
if streak >= 10 and "streak_10" not in earned_ids:
|
||||
new_achievements.append("streak_10")
|
||||
|
||||
# Check knockout achievements
|
||||
# Game-specific achievements (event path only)
|
||||
if stats["knockouts"] >= 10 and "knockout_10" not in earned_ids:
|
||||
new_achievements.append("knockout_10")
|
||||
|
||||
# Check round-specific achievements from this game
|
||||
best_round = player_data.get("best_round")
|
||||
if best_round is not None:
|
||||
if best_round <= 0 and "perfect_round" not in earned_ids:
|
||||
@@ -653,21 +673,10 @@ class StatsService:
|
||||
if best_round < 0 and "negative_round" not in earned_ids:
|
||||
new_achievements.append("negative_round")
|
||||
|
||||
# Check wolfpack
|
||||
if player_data.get("wolfpacks", 0) > 0 and "wolfpack" not in earned_ids:
|
||||
new_achievements.append("wolfpack")
|
||||
|
||||
# Award new achievements
|
||||
for achievement_id in new_achievements:
|
||||
try:
|
||||
await conn.execute("""
|
||||
INSERT INTO user_achievements (user_id, achievement_id, game_id)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING
|
||||
""", user_id, achievement_id, game_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to award achievement {achievement_id}: {e}")
|
||||
|
||||
await self._award_achievements(conn, user_id, new_achievements, game_id)
|
||||
return new_achievements
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -680,18 +689,21 @@ class StatsService:
|
||||
winner_id: Optional[str],
|
||||
num_rounds: int,
|
||||
player_user_ids: dict[str, str] = None,
|
||||
game_options: Optional[GameOptions] = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Process game stats directly from game state (for legacy games).
|
||||
|
||||
This is used when games don't have event sourcing. Stats are updated
|
||||
based on final game state.
|
||||
based on final game state. Only standard-rules games count toward
|
||||
leaderboard stats.
|
||||
|
||||
Args:
|
||||
players: List of game.Player objects with final scores.
|
||||
winner_id: Player ID of the winner.
|
||||
num_rounds: Total rounds played.
|
||||
player_user_ids: Optional mapping of player_id to user_id (for authenticated players).
|
||||
game_options: Optional game options to check for standard rules.
|
||||
|
||||
Returns:
|
||||
List of newly awarded achievement IDs.
|
||||
@@ -699,6 +711,11 @@ class StatsService:
|
||||
if not players:
|
||||
return []
|
||||
|
||||
# Only track stats for standard-rules games
|
||||
if game_options and not game_options.is_standard_rules():
|
||||
logger.debug("Skipping stats for non-standard rules game")
|
||||
return []
|
||||
|
||||
# Count human players for has_human_opponents calculation
|
||||
# For legacy games, we assume all players are human unless otherwise indicated
|
||||
human_count = len(players)
|
||||
@@ -800,9 +817,6 @@ class StatsService:
|
||||
|
||||
Only checks win-based achievements since we don't have round-level data.
|
||||
"""
|
||||
new_achievements = []
|
||||
|
||||
# Get current stats
|
||||
stats = await conn.fetchrow("""
|
||||
SELECT games_won, current_win_streak FROM player_stats
|
||||
WHERE user_id = $1
|
||||
@@ -811,41 +825,9 @@ class StatsService:
|
||||
if not stats:
|
||||
return []
|
||||
|
||||
# Get already earned achievements
|
||||
earned = await conn.fetch("""
|
||||
SELECT achievement_id FROM user_achievements WHERE user_id = $1
|
||||
""", user_id)
|
||||
earned_ids = {e["achievement_id"] for e in earned}
|
||||
|
||||
# Check win milestones
|
||||
wins = stats["games_won"]
|
||||
if wins >= 1 and "first_win" not in earned_ids:
|
||||
new_achievements.append("first_win")
|
||||
if wins >= 10 and "win_10" not in earned_ids:
|
||||
new_achievements.append("win_10")
|
||||
if wins >= 50 and "win_50" not in earned_ids:
|
||||
new_achievements.append("win_50")
|
||||
if wins >= 100 and "win_100" not in earned_ids:
|
||||
new_achievements.append("win_100")
|
||||
|
||||
# Check streak achievements
|
||||
streak = stats["current_win_streak"]
|
||||
if streak >= 5 and "streak_5" not in earned_ids:
|
||||
new_achievements.append("streak_5")
|
||||
if streak >= 10 and "streak_10" not in earned_ids:
|
||||
new_achievements.append("streak_10")
|
||||
|
||||
# Award new achievements
|
||||
for achievement_id in new_achievements:
|
||||
try:
|
||||
await conn.execute("""
|
||||
INSERT INTO user_achievements (user_id, achievement_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
""", user_id, achievement_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to award achievement {achievement_id}: {e}")
|
||||
|
||||
earned_ids = await self._get_earned_ids(conn, user_id)
|
||||
new_achievements = self._check_win_milestones(stats, earned_ids)
|
||||
await self._award_achievements(conn, user_id, new_achievements)
|
||||
return new_achievements
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user