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>
This commit is contained in:
parent
df61d88ec6
commit
e1cca98b8b
170
client/app.js
170
client/app.js
@ -1758,6 +1758,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 +1768,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 +1809,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 +1850,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 +1878,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,
|
||||
@ -3130,41 +3158,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) {
|
||||
|
||||
@ -4522,6 +4522,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 {
|
||||
|
||||
@ -1630,5 +1630,12 @@ 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,
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user