Refactor card values to single source of truth, fix ten_penny bug
- Add constants.py as the single source of truth for card values - Derive RANK_VALUES from DEFAULT_CARD_VALUES instead of duplicating - Add centralized get_card_value() function in game.py for Card objects - Add get_card_value_for_rank() in constants.py for string-based lookups - Fix bug: AI ten_penny returned 0 instead of 1 per RULES.md - Update ai.py and game_analyzer.py to use centralized functions - UI improvements for client Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
94da51e46b
commit
f4275c7a7d
105
client/app.js
105
client/app.js
@ -139,7 +139,6 @@ class GolfGame {
|
|||||||
this.currentRoundSpan = document.getElementById('current-round');
|
this.currentRoundSpan = document.getElementById('current-round');
|
||||||
this.totalRoundsSpan = document.getElementById('total-rounds');
|
this.totalRoundsSpan = document.getElementById('total-rounds');
|
||||||
this.turnInfo = document.getElementById('turn-info');
|
this.turnInfo = document.getElementById('turn-info');
|
||||||
this.leaderInfo = document.getElementById('leader-info');
|
|
||||||
this.yourScore = document.getElementById('your-score');
|
this.yourScore = document.getElementById('your-score');
|
||||||
this.muteBtn = document.getElementById('mute-btn');
|
this.muteBtn = document.getElementById('mute-btn');
|
||||||
this.opponentsRow = document.getElementById('opponents-row');
|
this.opponentsRow = document.getElementById('opponents-row');
|
||||||
@ -154,6 +153,7 @@ class GolfGame {
|
|||||||
this.toast = document.getElementById('toast');
|
this.toast = document.getElementById('toast');
|
||||||
this.scoreboard = document.getElementById('scoreboard');
|
this.scoreboard = document.getElementById('scoreboard');
|
||||||
this.scoreTable = document.getElementById('score-table').querySelector('tbody');
|
this.scoreTable = document.getElementById('score-table').querySelector('tbody');
|
||||||
|
this.standingsList = document.getElementById('standings-list');
|
||||||
this.gameButtons = document.getElementById('game-buttons');
|
this.gameButtons = document.getElementById('game-buttons');
|
||||||
this.nextRoundBtn = document.getElementById('next-round-btn');
|
this.nextRoundBtn = document.getElementById('next-round-btn');
|
||||||
this.newGameBtn = document.getElementById('new-game-btn');
|
this.newGameBtn = document.getElementById('new-game-btn');
|
||||||
@ -739,6 +739,43 @@ class GolfGame {
|
|||||||
return suit === 'hearts' || suit === 'diamonds';
|
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;
|
||||||
|
return cardValues[card.rank] ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for column pairs (cards in same column cancel out if matching)
|
||||||
|
let total = 0;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise add individual values
|
||||||
|
total += getCardValue(topCard);
|
||||||
|
total += getCardValue(bottomCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
getSuitSymbol(suit) {
|
getSuitSymbol(suit) {
|
||||||
const symbols = {
|
const symbols = {
|
||||||
hearts: '♥',
|
hearts: '♥',
|
||||||
@ -777,19 +814,12 @@ class GolfGame {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update leader info (by total score)
|
// Update your score (points currently showing on your cards)
|
||||||
const sortedByTotal = [...this.gameState.players].sort((a, b) => a.total_score - b.total_score);
|
|
||||||
const leader = sortedByTotal[0];
|
|
||||||
if (leader && this.gameState.current_round > 1) {
|
|
||||||
this.leaderInfo.textContent = `Leader: ${leader.name} (${leader.total_score})`;
|
|
||||||
} else {
|
|
||||||
this.leaderInfo.textContent = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update your score
|
|
||||||
const me = this.gameState.players.find(p => p.id === this.playerId);
|
const me = this.gameState.players.find(p => p.id === this.playerId);
|
||||||
if (me) {
|
if (me) {
|
||||||
this.yourScore.textContent = me.round_score ?? 0;
|
// Calculate visible score from face-up cards
|
||||||
|
const showingScore = this.calculateShowingScore(me.cards);
|
||||||
|
this.yourScore.textContent = showingScore;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update discard pile
|
// Update discard pile
|
||||||
@ -833,7 +863,7 @@ class GolfGame {
|
|||||||
div.classList.add('current-turn');
|
div.classList.add('current-turn');
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayName = player.name.length > 8 ? player.name.substring(0, 7) + '…' : player.name;
|
const displayName = player.name.length > 12 ? player.name.substring(0, 11) + '…' : player.name;
|
||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<h4>${displayName}${player.all_face_up ? ' ✓' : ''}</h4>
|
<h4>${displayName}${player.all_face_up ? ' ✓' : ''}</h4>
|
||||||
@ -895,6 +925,10 @@ class GolfGame {
|
|||||||
updateScorePanel() {
|
updateScorePanel() {
|
||||||
if (!this.gameState) return;
|
if (!this.gameState) return;
|
||||||
|
|
||||||
|
// Update standings (left panel)
|
||||||
|
this.updateStandings();
|
||||||
|
|
||||||
|
// Update score table (right panel)
|
||||||
this.scoreTable.innerHTML = '';
|
this.scoreTable.innerHTML = '';
|
||||||
|
|
||||||
this.gameState.players.forEach(player => {
|
this.gameState.players.forEach(player => {
|
||||||
@ -906,8 +940,8 @@ class GolfGame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Truncate long names
|
// Truncate long names
|
||||||
const displayName = player.name.length > 10
|
const displayName = player.name.length > 12
|
||||||
? player.name.substring(0, 9) + '…'
|
? player.name.substring(0, 11) + '…'
|
||||||
: player.name;
|
: player.name;
|
||||||
|
|
||||||
const roundScore = player.score !== null ? player.score : '-';
|
const roundScore = player.score !== null ? player.score : '-';
|
||||||
@ -923,6 +957,39 @@ class GolfGame {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateStandings() {
|
||||||
|
if (!this.gameState || !this.standingsList) return;
|
||||||
|
|
||||||
|
// Sort players by total score (lowest is best in golf)
|
||||||
|
const sorted = [...this.gameState.players].sort((a, b) => a.total_score - b.total_score);
|
||||||
|
|
||||||
|
this.standingsList.innerHTML = '';
|
||||||
|
|
||||||
|
sorted.forEach((player, index) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'standing-row';
|
||||||
|
|
||||||
|
if (index === 0 && player.total_score < sorted[sorted.length - 1]?.total_score) {
|
||||||
|
div.classList.add('leader');
|
||||||
|
}
|
||||||
|
if (player.id === this.playerId) {
|
||||||
|
div.classList.add('you');
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = player.name.length > 12
|
||||||
|
? player.name.substring(0, 11) + '…'
|
||||||
|
: player.name;
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<span class="standing-pos">${index + 1}.</span>
|
||||||
|
<span class="standing-name">${displayName}</span>
|
||||||
|
<span class="standing-score">${player.total_score} pts</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.standingsList.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
renderCard(card, clickable, selected) {
|
renderCard(card, clickable, selected) {
|
||||||
let classes = 'card';
|
let classes = 'card';
|
||||||
let content = '';
|
let content = '';
|
||||||
@ -959,8 +1026,8 @@ class GolfGame {
|
|||||||
const roundsWon = score.rounds_won || 0;
|
const roundsWon = score.rounds_won || 0;
|
||||||
|
|
||||||
// Truncate long names
|
// Truncate long names
|
||||||
const displayName = score.name.length > 10
|
const displayName = score.name.length > 12
|
||||||
? score.name.substring(0, 9) + '…'
|
? score.name.substring(0, 11) + '…'
|
||||||
: score.name;
|
: score.name;
|
||||||
|
|
||||||
if (total === minScore) {
|
if (total === minScore) {
|
||||||
@ -1023,7 +1090,7 @@ class GolfGame {
|
|||||||
prevPoints = p.total;
|
prevPoints = p.total;
|
||||||
}
|
}
|
||||||
const medal = pointsRank === 0 ? '🥇' : pointsRank === 1 ? '🥈' : pointsRank === 2 ? '🥉' : `${pointsRank + 1}.`;
|
const medal = pointsRank === 0 ? '🥇' : pointsRank === 1 ? '🥈' : pointsRank === 2 ? '🥉' : `${pointsRank + 1}.`;
|
||||||
const name = p.name.length > 8 ? p.name.substring(0, 7) + '…' : p.name;
|
const name = p.name.length > 12 ? p.name.substring(0, 11) + '…' : p.name;
|
||||||
return `<div class="rank-row ${pointsRank === 0 ? 'leader' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${name}</span><span class="rank-val">${p.total}pt</span></div>`;
|
return `<div class="rank-row ${pointsRank === 0 ? 'leader' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${name}</span><span class="rank-val">${p.total}pt</span></div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@ -1038,7 +1105,7 @@ class GolfGame {
|
|||||||
// No medal for 0 wins
|
// No medal for 0 wins
|
||||||
const medal = p.rounds_won === 0 ? '-' :
|
const medal = p.rounds_won === 0 ? '-' :
|
||||||
holesRank === 0 ? '🥇' : holesRank === 1 ? '🥈' : holesRank === 2 ? '🥉' : `${holesRank + 1}.`;
|
holesRank === 0 ? '🥇' : holesRank === 1 ? '🥈' : holesRank === 2 ? '🥉' : `${holesRank + 1}.`;
|
||||||
const name = p.name.length > 8 ? p.name.substring(0, 7) + '…' : p.name;
|
const name = p.name.length > 12 ? p.name.substring(0, 11) + '…' : p.name;
|
||||||
return `<div class="rank-row ${holesRank === 0 && p.rounds_won > 0 ? 'leader' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${name}</span><span class="rank-val">${p.rounds_won}W</span></div>`;
|
return `<div class="rank-row ${holesRank === 0 && p.rounds_won > 0 ? 'leader' : ''}"><span class="rank-pos">${medal}</span><span class="rank-name">${name}</span><span class="rank-val">${p.rounds_won}W</span></div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="player-name">Your Name</label>
|
<label for="player-name">Your Name</label>
|
||||||
<input type="text" id="player-name" placeholder="Enter your name" maxlength="20">
|
<input type="text" id="player-name" placeholder="Enter your name" maxlength="12">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
@ -192,8 +192,7 @@
|
|||||||
<div class="game-header">
|
<div class="game-header">
|
||||||
<div class="round-info">Hole <span id="current-round">1</span>/<span id="total-rounds">9</span></div>
|
<div class="round-info">Hole <span id="current-round">1</span>/<span id="total-rounds">9</span></div>
|
||||||
<div class="turn-info" id="turn-info">Your turn</div>
|
<div class="turn-info" id="turn-info">Your turn</div>
|
||||||
<div class="leader-info" id="leader-info">Leader: -</div>
|
<div class="score-info">Showing: <span id="your-score">0</span></div>
|
||||||
<div class="score-info">You: <span id="your-score">0</span> pts</div>
|
|
||||||
<button id="mute-btn" class="mute-btn" title="Toggle sound">🔊</button>
|
<button id="mute-btn" class="mute-btn" title="Toggle sound">🔊</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -227,7 +226,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="scoreboard" class="scoreboard-panel">
|
<!-- Left panel: Standings -->
|
||||||
|
<div id="standings-panel" class="side-panel left-panel">
|
||||||
|
<h4>Standings</h4>
|
||||||
|
<div id="standings-list" class="standings-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right panel: Scores -->
|
||||||
|
<div id="scoreboard" class="side-panel right-panel">
|
||||||
<h4>Scores</h4>
|
<h4>Scores</h4>
|
||||||
<table id="score-table">
|
<table id="score-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
206
client/style.css
206
client/style.css
@ -280,29 +280,35 @@ input::placeholder {
|
|||||||
|
|
||||||
/* Game Screen */
|
/* Game Screen */
|
||||||
.game-header {
|
.game-header {
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: center;
|
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: clamp(15px, 3vw, 40px);
|
padding: 10px 25px;
|
||||||
padding: 6px 15px;
|
background: rgba(0,0,0,0.35);
|
||||||
background: rgba(0,0,0,0.2);
|
border-radius: 0;
|
||||||
border-radius: 6px;
|
font-size: 0.9rem;
|
||||||
font-size: 0.85rem;
|
width: 100vw;
|
||||||
width: calc(100vw - 250px);
|
margin-left: calc(-50vw + 50%);
|
||||||
margin-left: 10px;
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-header .round-info {
|
||||||
|
justify-self: start;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-header .turn-info {
|
.game-header .turn-info {
|
||||||
|
justify-self: center;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f4a460;
|
color: #f4a460;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-header .leader-info {
|
.game-header .score-info {
|
||||||
opacity: 0.9;
|
justify-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-header .score-info {
|
.game-header .mute-btn {
|
||||||
opacity: 0.9;
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mute-btn {
|
.mute-btn {
|
||||||
@ -323,13 +329,13 @@ input::placeholder {
|
|||||||
|
|
||||||
/* Card Styles */
|
/* Card Styles */
|
||||||
.card {
|
.card {
|
||||||
width: clamp(60px, 6vw, 80px);
|
width: clamp(65px, 5.5vw, 100px);
|
||||||
height: clamp(84px, 8.4vw, 112px);
|
height: clamp(91px, 7.7vw, 140px);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: clamp(1.2rem, 1.4vw, 1.7rem);
|
font-size: clamp(2rem, 2.5vw, 3.2rem);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
@ -386,8 +392,8 @@ input::placeholder {
|
|||||||
/* Card Grid */
|
/* Card Grid */
|
||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, clamp(60px, 6vw, 80px));
|
grid-template-columns: repeat(3, clamp(65px, 5.5vw, 100px));
|
||||||
gap: clamp(6px, 0.8vw, 12px);
|
gap: clamp(8px, 0.8vw, 14px);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -415,11 +421,10 @@ input::placeholder {
|
|||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: clamp(10px, 2vw, 30px);
|
gap: clamp(12px, 1.8vw, 35px);
|
||||||
min-height: clamp(100px, 12vw, 150px);
|
min-height: clamp(120px, 14vw, 200px);
|
||||||
padding: 0 10px;
|
padding: 0 20px;
|
||||||
width: calc(100vw - 240px);
|
width: 100%;
|
||||||
margin-left: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Arch layout - middle items higher, edges lower with rotation for "around the table" feel */
|
/* Arch layout - middle items higher, edges lower with rotation for "around the table" feel */
|
||||||
@ -566,15 +571,15 @@ input::placeholder {
|
|||||||
/* Opponent Areas */
|
/* Opponent Areas */
|
||||||
.opponent-area {
|
.opponent-area {
|
||||||
background: rgba(0,0,0,0.2);
|
background: rgba(0,0,0,0.2);
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
padding: clamp(3px, 0.4vw, 6px) clamp(5px, 0.6vw, 10px) clamp(5px, 0.6vw, 10px);
|
padding: clamp(4px, 0.5vw, 10px) clamp(6px, 0.7vw, 14px) clamp(6px, 0.7vw, 14px);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opponent-area h4 {
|
.opponent-area h4 {
|
||||||
font-size: clamp(0.65rem, 0.75vw, 0.85rem);
|
font-size: clamp(0.7rem, 0.8vw, 1rem);
|
||||||
margin: 0 0 4px 0;
|
margin: 0 0 6px 0;
|
||||||
padding: clamp(2px, 0.3vw, 4px) clamp(6px, 0.8vw, 10px);
|
padding: clamp(3px, 0.35vw, 6px) clamp(8px, 0.9vw, 14px);
|
||||||
background: rgba(244, 164, 96, 0.5);
|
background: rgba(244, 164, 96, 0.5);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@ -584,15 +589,15 @@ input::placeholder {
|
|||||||
|
|
||||||
.opponent-area .card-grid {
|
.opponent-area .card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, clamp(40px, 4.5vw, 60px));
|
grid-template-columns: repeat(3, clamp(45px, 4vw, 75px));
|
||||||
gap: clamp(3px, 0.4vw, 6px);
|
gap: clamp(4px, 0.4vw, 8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.opponent-area .card {
|
.opponent-area .card {
|
||||||
width: clamp(40px, 4.5vw, 60px);
|
width: clamp(45px, 4vw, 75px);
|
||||||
height: clamp(56px, 6.3vw, 84px);
|
height: clamp(63px, 5.6vw, 105px);
|
||||||
font-size: clamp(0.75rem, 0.9vw, 1.1rem);
|
font-size: clamp(1.3rem, 1.5vw, 2.2rem);
|
||||||
border-radius: 4px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opponent-area.current-turn {
|
.opponent-area.current-turn {
|
||||||
@ -647,13 +652,14 @@ input::placeholder {
|
|||||||
#game-screen.active {
|
#game-screen.active {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-layout {
|
.game-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
@ -664,86 +670,136 @@ input::placeholder {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
width: calc(100vw - 230px);
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scoreboard Panel - positioned as overlay with cool styling */
|
/* Side Panels - positioned in bottom corners */
|
||||||
.scoreboard-panel {
|
.side-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 15px;
|
bottom: 20px;
|
||||||
right: 15px;
|
background: linear-gradient(145deg, rgba(15, 50, 35, 0.92) 0%, rgba(8, 30, 20, 0.95) 100%);
|
||||||
background: linear-gradient(145deg, rgba(20, 60, 40, 0.95) 0%, rgba(10, 35, 25, 0.98) 100%);
|
border-radius: 14px;
|
||||||
border-radius: 12px;
|
padding: 16px 18px;
|
||||||
padding: 12px;
|
width: 235px;
|
||||||
width: 210px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(10px);
|
||||||
border: 1px solid rgba(244, 164, 96, 0.3);
|
border: 1px solid rgba(244, 164, 96, 0.25);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 4px 20px rgba(0, 0, 0, 0.4),
|
0 4px 24px rgba(0, 0, 0, 0.5),
|
||||||
0 0 30px rgba(244, 164, 96, 0.1),
|
0 0 40px rgba(244, 164, 96, 0.08),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoreboard-panel > h4 {
|
.side-panel.left-panel {
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel.right-panel {
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel > h4 {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 12px;
|
||||||
color: #f4a460;
|
color: #f4a460;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.2em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||||
|
border-bottom: 1px solid rgba(244, 164, 96, 0.2);
|
||||||
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoreboard-panel table {
|
/* Standings list */
|
||||||
|
.standings-list {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standings-list .standing-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standings-list .standing-row.leader {
|
||||||
|
background: rgba(244, 164, 96, 0.2);
|
||||||
|
border-left: 3px solid #f4a460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standings-list .standing-row.you {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.standings-list .standing-pos {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f4a460;
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standings-list .standing-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standings-list .standing-score {
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Score table */
|
||||||
|
.side-panel table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 0.8rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoreboard-panel th,
|
.side-panel th,
|
||||||
.scoreboard-panel td {
|
.side-panel td {
|
||||||
padding: 6px 5px;
|
padding: 7px 5px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoreboard-panel th {
|
.side-panel th {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: rgba(0,0,0,0.3);
|
background: rgba(0,0,0,0.25);
|
||||||
font-size: 0.7rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoreboard-panel td:first-child {
|
.side-panel td:first-child {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoreboard-panel tr.winner {
|
.side-panel tr.winner {
|
||||||
background: linear-gradient(90deg, rgba(244, 164, 96, 0.4) 0%, rgba(244, 164, 96, 0.2) 100%);
|
background: linear-gradient(90deg, rgba(244, 164, 96, 0.35) 0%, rgba(244, 164, 96, 0.15) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoreboard-panel tr.current-player {
|
.side-panel tr.current-player {
|
||||||
background: rgba(244, 164, 96, 0.2);
|
background: rgba(244, 164, 96, 0.15);
|
||||||
box-shadow: inset 3px 0 0 #f4a460;
|
box-shadow: inset 3px 0 0 #f4a460;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-buttons {
|
.game-buttons {
|
||||||
margin-top: 8px;
|
margin-top: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 5px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-buttons .btn {
|
.game-buttons .btn {
|
||||||
font-size: 0.75rem;
|
font-size: 0.7rem;
|
||||||
padding: 6px 10px;
|
padding: 8px 10px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rankings Announcement */
|
/* Rankings Announcement */
|
||||||
|
|||||||
18
server/ai.py
18
server/ai.py
@ -6,20 +6,16 @@ from dataclasses import dataclass
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from game import Card, Player, Game, GamePhase, GameOptions, RANK_VALUES, Rank
|
from game import Card, Player, Game, GamePhase, GameOptions, RANK_VALUES, Rank, get_card_value
|
||||||
|
|
||||||
|
|
||||||
|
# Alias for backwards compatibility - use the centralized function from game.py
|
||||||
def get_ai_card_value(card: Card, options: GameOptions) -> int:
|
def get_ai_card_value(card: Card, options: GameOptions) -> int:
|
||||||
"""Get card value with house rules applied for AI decisions."""
|
"""Get card value with house rules applied for AI decisions.
|
||||||
if card.rank == Rank.JOKER:
|
|
||||||
return -5 if options.lucky_swing else -2
|
This is an alias for game.get_card_value() for backwards compatibility.
|
||||||
if card.rank == Rank.KING and options.super_kings:
|
"""
|
||||||
return -2
|
return get_card_value(card, options)
|
||||||
if card.rank == Rank.SEVEN and options.lucky_sevens:
|
|
||||||
return 0
|
|
||||||
if card.rank == Rank.TEN and options.ten_penny:
|
|
||||||
return 0
|
|
||||||
return card.value()
|
|
||||||
|
|
||||||
|
|
||||||
def can_make_pair(card1: Card, card2: Card, options: GameOptions) -> bool:
|
def can_make_pair(card1: Card, card2: Card, options: GameOptions) -> bool:
|
||||||
|
|||||||
53
server/constants.py
Normal file
53
server/constants.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# Card values - Single source of truth for all card scoring
|
||||||
|
# Per RULES.md: A=1, 2=-2, 3-10=face, J/Q=10, K=0, Joker=-2
|
||||||
|
DEFAULT_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, # Joker (standard)
|
||||||
|
}
|
||||||
|
|
||||||
|
# House rule modifications (per RULES.md House Rules section)
|
||||||
|
SUPER_KINGS_VALUE = -2 # K worth -2 instead of 0
|
||||||
|
LUCKY_SEVENS_VALUE = 0 # 7 worth 0 instead of 7
|
||||||
|
TEN_PENNY_VALUE = 1 # 10 worth 1 instead of 10
|
||||||
|
LUCKY_SWING_JOKER_VALUE = -5 # Joker worth -5 in Lucky Swing mode
|
||||||
|
|
||||||
|
|
||||||
|
def get_card_value_for_rank(rank_str: str, options: dict | None = None) -> int:
|
||||||
|
"""
|
||||||
|
Get point value for a card rank string, with house rules applied.
|
||||||
|
|
||||||
|
This is the single source of truth for card value calculations.
|
||||||
|
Use this for string-based rank lookups (e.g., from JSON/logs).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rank_str: Card rank as string ('A', '2', ..., 'K', '★')
|
||||||
|
options: Optional dict with house rule flags (lucky_swing, super_kings, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Point value for the card
|
||||||
|
"""
|
||||||
|
value = DEFAULT_CARD_VALUES.get(rank_str, 0)
|
||||||
|
|
||||||
|
if options:
|
||||||
|
if rank_str == '★' and options.get('lucky_swing'):
|
||||||
|
value = LUCKY_SWING_JOKER_VALUE
|
||||||
|
elif rank_str == 'K' and options.get('super_kings'):
|
||||||
|
value = SUPER_KINGS_VALUE
|
||||||
|
elif rank_str == '7' and options.get('lucky_sevens'):
|
||||||
|
value = LUCKY_SEVENS_VALUE
|
||||||
|
elif rank_str == '10' and options.get('ten_penny'):
|
||||||
|
value = TEN_PENNY_VALUE
|
||||||
|
|
||||||
|
return value
|
||||||
@ -5,6 +5,14 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
from constants import (
|
||||||
|
DEFAULT_CARD_VALUES,
|
||||||
|
SUPER_KINGS_VALUE,
|
||||||
|
LUCKY_SEVENS_VALUE,
|
||||||
|
TEN_PENNY_VALUE,
|
||||||
|
LUCKY_SWING_JOKER_VALUE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Suit(Enum):
|
class Suit(Enum):
|
||||||
HEARTS = "hearts"
|
HEARTS = "hearts"
|
||||||
@ -30,22 +38,34 @@ class Rank(Enum):
|
|||||||
JOKER = "★"
|
JOKER = "★"
|
||||||
|
|
||||||
|
|
||||||
RANK_VALUES = {
|
# Derive RANK_VALUES from DEFAULT_CARD_VALUES (single source of truth in constants.py)
|
||||||
Rank.ACE: 1,
|
RANK_VALUES = {rank: DEFAULT_CARD_VALUES[rank.value] for rank in Rank}
|
||||||
Rank.TWO: -2,
|
|
||||||
Rank.THREE: 3,
|
|
||||||
Rank.FOUR: 4,
|
def get_card_value(card: "Card", options: Optional["GameOptions"] = None) -> int:
|
||||||
Rank.FIVE: 5,
|
"""
|
||||||
Rank.SIX: 6,
|
Get point value for a card, with house rules applied.
|
||||||
Rank.SEVEN: 7,
|
|
||||||
Rank.EIGHT: 8,
|
This is the single source of truth for Card object value calculations.
|
||||||
Rank.NINE: 9,
|
Use this instead of card.value() when house rules need to be considered.
|
||||||
Rank.TEN: 10,
|
|
||||||
Rank.JACK: 10,
|
Args:
|
||||||
Rank.QUEEN: 10,
|
card: Card object to evaluate
|
||||||
Rank.KING: 0,
|
options: Optional GameOptions with house rule flags
|
||||||
Rank.JOKER: -2,
|
|
||||||
}
|
Returns:
|
||||||
|
Point value for the card
|
||||||
|
"""
|
||||||
|
if options:
|
||||||
|
if card.rank == Rank.JOKER:
|
||||||
|
return LUCKY_SWING_JOKER_VALUE if options.lucky_swing else RANK_VALUES[Rank.JOKER]
|
||||||
|
if card.rank == Rank.KING and options.super_kings:
|
||||||
|
return SUPER_KINGS_VALUE
|
||||||
|
if card.rank == Rank.SEVEN and options.lucky_sevens:
|
||||||
|
return LUCKY_SEVENS_VALUE
|
||||||
|
if card.rank == Rank.TEN and options.ten_penny:
|
||||||
|
return TEN_PENNY_VALUE
|
||||||
|
return RANK_VALUES[card.rank]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -128,19 +148,6 @@ class Player:
|
|||||||
if len(self.cards) != 6:
|
if len(self.cards) != 6:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def get_card_value(card: Card) -> int:
|
|
||||||
"""Get card value with house rules applied."""
|
|
||||||
if options:
|
|
||||||
if card.rank == Rank.JOKER:
|
|
||||||
return -5 if options.lucky_swing else -2
|
|
||||||
if card.rank == Rank.KING and options.super_kings:
|
|
||||||
return -2
|
|
||||||
if card.rank == Rank.SEVEN and options.lucky_sevens:
|
|
||||||
return 0
|
|
||||||
if card.rank == Rank.TEN and options.ten_penny:
|
|
||||||
return 1
|
|
||||||
return card.value()
|
|
||||||
|
|
||||||
def cards_match(card1: Card, card2: Card) -> bool:
|
def cards_match(card1: Card, card2: Card) -> bool:
|
||||||
"""Check if two cards match for pairing (with Queens Wild support)."""
|
"""Check if two cards match for pairing (with Queens Wild support)."""
|
||||||
if card1.rank == card2.rank:
|
if card1.rank == card2.rank:
|
||||||
@ -190,9 +197,9 @@ class Player:
|
|||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
if top_idx not in four_of_kind_positions:
|
if top_idx not in four_of_kind_positions:
|
||||||
total += get_card_value(top_card)
|
total += get_card_value(top_card, options)
|
||||||
if bottom_idx not in four_of_kind_positions:
|
if bottom_idx not in four_of_kind_positions:
|
||||||
total += get_card_value(bottom_card)
|
total += get_card_value(bottom_card, options)
|
||||||
|
|
||||||
self.score = total
|
self.score = total
|
||||||
return total
|
return total
|
||||||
@ -257,6 +264,22 @@ class Game:
|
|||||||
def flip_on_discard(self) -> bool:
|
def flip_on_discard(self) -> bool:
|
||||||
return self.options.flip_on_discard
|
return self.options.flip_on_discard
|
||||||
|
|
||||||
|
def get_card_values(self) -> dict:
|
||||||
|
"""Get current card values with house rules applied."""
|
||||||
|
values = DEFAULT_CARD_VALUES.copy()
|
||||||
|
|
||||||
|
# Apply house rule modifications
|
||||||
|
if self.options.super_kings:
|
||||||
|
values['K'] = SUPER_KINGS_VALUE
|
||||||
|
if self.options.lucky_sevens:
|
||||||
|
values['7'] = LUCKY_SEVENS_VALUE
|
||||||
|
if self.options.ten_penny:
|
||||||
|
values['10'] = TEN_PENNY_VALUE
|
||||||
|
if self.options.lucky_swing:
|
||||||
|
values['★'] = LUCKY_SWING_JOKER_VALUE
|
||||||
|
|
||||||
|
return values
|
||||||
|
|
||||||
def add_player(self, player: Player) -> bool:
|
def add_player(self, player: Player) -> bool:
|
||||||
if len(self.players) >= 6:
|
if len(self.players) >= 6:
|
||||||
return False
|
return False
|
||||||
@ -606,4 +629,5 @@ class Game:
|
|||||||
),
|
),
|
||||||
"initial_flips": self.options.initial_flips,
|
"initial_flips": self.options.initial_flips,
|
||||||
"flip_on_discard": self.flip_on_discard,
|
"flip_on_discard": self.flip_on_discard,
|
||||||
|
"card_values": self.get_card_values(),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ from typing import Optional
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from game import Rank, RANK_VALUES, GameOptions
|
from game import Rank, RANK_VALUES, GameOptions
|
||||||
|
from constants import get_card_value_for_rank
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@ -21,25 +22,12 @@ from game import Rank, RANK_VALUES, GameOptions
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
def get_card_value(rank: str, options: Optional[dict] = None) -> int:
|
def get_card_value(rank: str, options: Optional[dict] = None) -> int:
|
||||||
"""Get point value for a card rank string."""
|
"""Get point value for a card rank string.
|
||||||
rank_map = {
|
|
||||||
'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
|
|
||||||
}
|
|
||||||
value = rank_map.get(rank, 0)
|
|
||||||
|
|
||||||
# Apply house rules if provided
|
This is a wrapper around constants.get_card_value_for_rank() for
|
||||||
if options:
|
backwards compatibility with existing analyzer code.
|
||||||
if rank == '★' and options.get('lucky_swing'):
|
"""
|
||||||
value = -5
|
return get_card_value_for_rank(rank, options)
|
||||||
if rank == 'K' and options.get('super_kings'):
|
|
||||||
value = -2
|
|
||||||
if rank == '7' and options.get('lucky_sevens'):
|
|
||||||
value = 0
|
|
||||||
if rank == '10' and options.get('ten_penny'):
|
|
||||||
value = 1
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def rank_quality(rank: str, options: Optional[dict] = None) -> str:
|
def rank_quality(rank: str, options: Optional[dict] = None) -> str:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user