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:
Aaron D. Lee
2026-01-24 20:26:17 -05:00
parent 94da51e46b
commit f4275c7a7d
7 changed files with 348 additions and 158 deletions

View File

@@ -6,20 +6,16 @@ from dataclasses import dataclass
from typing import Optional
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:
"""Get card value with house rules applied for AI decisions."""
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 0
return card.value()
"""Get card value with house rules applied for AI decisions.
This is an alias for game.get_card_value() for backwards compatibility.
"""
return get_card_value(card, options)
def can_make_pair(card1: Card, card2: Card, options: GameOptions) -> bool:

53
server/constants.py Normal file
View 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

View File

@@ -5,6 +5,14 @@ from dataclasses import dataclass, field
from typing import Optional
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):
HEARTS = "hearts"
@@ -30,22 +38,34 @@ class Rank(Enum):
JOKER = ""
RANK_VALUES = {
Rank.ACE: 1,
Rank.TWO: -2,
Rank.THREE: 3,
Rank.FOUR: 4,
Rank.FIVE: 5,
Rank.SIX: 6,
Rank.SEVEN: 7,
Rank.EIGHT: 8,
Rank.NINE: 9,
Rank.TEN: 10,
Rank.JACK: 10,
Rank.QUEEN: 10,
Rank.KING: 0,
Rank.JOKER: -2,
}
# Derive RANK_VALUES from DEFAULT_CARD_VALUES (single source of truth in constants.py)
RANK_VALUES = {rank: DEFAULT_CARD_VALUES[rank.value] for rank in Rank}
def get_card_value(card: "Card", options: Optional["GameOptions"] = None) -> int:
"""
Get point value for a card, with house rules applied.
This is the single source of truth for Card object value calculations.
Use this instead of card.value() when house rules need to be considered.
Args:
card: Card object to evaluate
options: Optional GameOptions with house rule flags
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
@@ -128,19 +148,6 @@ class Player:
if len(self.cards) != 6:
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:
"""Check if two cards match for pairing (with Queens Wild support)."""
if card1.rank == card2.rank:
@@ -190,9 +197,9 @@ class Player:
continue
else:
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:
total += get_card_value(bottom_card)
total += get_card_value(bottom_card, options)
self.score = total
return total
@@ -257,6 +264,22 @@ class Game:
def flip_on_discard(self) -> bool:
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:
if len(self.players) >= 6:
return False
@@ -606,4 +629,5 @@ class Game:
),
"initial_flips": self.options.initial_flips,
"flip_on_discard": self.flip_on_discard,
"card_values": self.get_card_values(),
}

View File

@@ -14,6 +14,7 @@ from typing import Optional
from enum import Enum
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:
"""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)
"""Get point value for a card rank string.
# Apply house rules if provided
if options:
if rank == '' and options.get('lucky_swing'):
value = -5
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
This is a wrapper around constants.get_card_value_for_rank() for
backwards compatibility with existing analyzer code.
"""
return get_card_value_for_rank(rank, options)
def rank_quality(rank: str, options: Optional[dict] = None) -> str: