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