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
174
client/app.js
174
client/app.js
@ -1758,6 +1758,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 +1768,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 +1809,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 +1850,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 +1878,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,
|
||||||
@ -3130,41 +3158,99 @@ class GolfGame {
|
|||||||
return suit === 'hearts' || suit === 'diamonds';
|
return suit === 'hearts' || suit === 'diamonds';
|
||||||
}
|
}
|
||||||
|
|
||||||
calculateShowingScore(cards) {
|
/**
|
||||||
if (!cards || cards.length !== 6) return 0;
|
* Get the point value for a single card, respecting house rules.
|
||||||
|
* Handles one_eyed_jacks (J♥/J♠ = 0) which can't be in the card_values map.
|
||||||
|
*/
|
||||||
|
getCardPointValue(card, cardValues, scoringRules) {
|
||||||
|
if (!card.rank) return 0;
|
||||||
|
if (scoringRules?.one_eyed_jacks && card.rank === 'J' &&
|
||||||
|
(card.suit === 'hearts' || card.suit === 'spades')) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return cardValues[card.rank] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Use card values from server (includes house rules) or defaults
|
/**
|
||||||
const cardValues = this.gameState?.card_values || {
|
* Calculate structured scoring results for a 6-card hand.
|
||||||
'A': 1, '2': -2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7,
|
* Single source of truth for client-side scoring logic.
|
||||||
'8': 8, '9': 9, '10': 10, 'J': 10, 'Q': 10, 'K': 0, '★': -2
|
*
|
||||||
};
|
* @param {Array} cards - 6-element array of card data objects ({rank, suit, face_up})
|
||||||
|
* @param {Object} cardValues - rank→point map from server (includes lucky_swing, super_kings, etc.)
|
||||||
const getCardValue = (card) => {
|
* @param {Object} scoringRules - house rule flags from server (eagle_eye, negative_pairs_keep_value, etc.)
|
||||||
if (!card.face_up) return 0;
|
* @param {boolean} onlyFaceUp - if true, only count face-up cards (for live score badge)
|
||||||
return cardValues[card.rank] ?? 0;
|
* @returns {{ columns: Array<{isPair, pairValue, topValue, bottomValue}>, bonuses: Array<{type, value}>, total: number }}
|
||||||
};
|
*/
|
||||||
|
calculateColumnScores(cards, cardValues, scoringRules, onlyFaceUp = false) {
|
||||||
// Check for column pairs (cards in same column cancel out if matching)
|
const rules = scoringRules || {};
|
||||||
|
const columns = [];
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
let jackPairs = 0;
|
||||||
|
const pairedRanks = [];
|
||||||
|
|
||||||
for (let col = 0; col < 3; col++) {
|
for (let col = 0; col < 3; col++) {
|
||||||
const topCard = cards[col];
|
const topCard = cards[col];
|
||||||
const bottomCard = cards[col + 3];
|
const bottomCard = cards[col + 3];
|
||||||
|
|
||||||
const topUp = topCard.face_up;
|
const topUp = topCard.face_up;
|
||||||
const bottomUp = bottomCard.face_up;
|
const bottomUp = bottomCard.face_up;
|
||||||
|
|
||||||
// If both face up and matching rank, they cancel (score 0)
|
const topValue = (topUp || !onlyFaceUp) ? this.getCardPointValue(topCard, cardValues, rules) : 0;
|
||||||
if (topUp && bottomUp && topCard.rank === bottomCard.rank) {
|
const bottomValue = (bottomUp || !onlyFaceUp) ? this.getCardPointValue(bottomCard, cardValues, rules) : 0;
|
||||||
// Matching pair = 0 points for both
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise add individual values
|
const bothVisible = onlyFaceUp ? (topUp && bottomUp) : true;
|
||||||
total += getCardValue(topCard);
|
const isPair = bothVisible && topCard.rank && bottomCard.rank && topCard.rank === bottomCard.rank;
|
||||||
total += getCardValue(bottomCard);
|
|
||||||
|
if (isPair) {
|
||||||
|
pairedRanks.push(topCard.rank);
|
||||||
|
if (topCard.rank === 'J') jackPairs++;
|
||||||
|
|
||||||
|
let pairValue = 0;
|
||||||
|
|
||||||
|
// Eagle Eye: paired jokers score -4
|
||||||
|
if (rules.eagle_eye && topCard.rank === '★') {
|
||||||
|
pairValue = -4;
|
||||||
|
}
|
||||||
|
// Negative Pairs Keep Value: negative-value pairs keep their score
|
||||||
|
else if (rules.negative_pairs_keep_value && (topValue < 0 || bottomValue < 0)) {
|
||||||
|
pairValue = topValue + bottomValue;
|
||||||
|
}
|
||||||
|
// Normal pair: 0
|
||||||
|
|
||||||
|
total += pairValue;
|
||||||
|
columns.push({ isPair: true, pairValue, topValue, bottomValue });
|
||||||
|
} else {
|
||||||
|
total += topValue + bottomValue;
|
||||||
|
columns.push({ isPair: false, pairValue: 0, topValue, bottomValue });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return total;
|
// Bonuses
|
||||||
|
const bonuses = [];
|
||||||
|
if (rules.wolfpack && jackPairs >= 2) {
|
||||||
|
bonuses.push({ type: 'wolfpack', value: -20 });
|
||||||
|
total += -20;
|
||||||
|
}
|
||||||
|
if (rules.four_of_a_kind) {
|
||||||
|
const rankCounts = {};
|
||||||
|
for (const r of pairedRanks) {
|
||||||
|
rankCounts[r] = (rankCounts[r] || 0) + 1;
|
||||||
|
}
|
||||||
|
for (const [rank, count] of Object.entries(rankCounts)) {
|
||||||
|
if (count >= 2) {
|
||||||
|
bonuses.push({ type: 'four_of_a_kind', value: -20, rank });
|
||||||
|
total += -20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { columns, bonuses, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateShowingScore(cards) {
|
||||||
|
if (!cards || cards.length !== 6) return 0;
|
||||||
|
const cardValues = this.gameState?.card_values || this.getDefaultCardValues();
|
||||||
|
const scoringRules = this.gameState?.scoring_rules || {};
|
||||||
|
return this.calculateColumnScores(cards, cardValues, scoringRules, true).total;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSuitSymbol(suit) {
|
getSuitSymbol(suit) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -4522,6 +4522,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 {
|
||||||
|
|||||||
@ -1630,5 +1630,12 @@ 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,
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user