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:
18
server/ai.py
18
server/ai.py
@@ -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
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 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(),
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user