Tune knock-early thresholds and fix failing test suite
Tighten should_knock_early() so AI no longer knocks with projected scores of 12-14. New range: max_acceptable 5-9 (was 8-18), with scaled knock_chance by score quality and an exception when all opponents show 25+ visible points. Fix 5 pre-existing test failures: - test_event_replay: use game.current_player() instead of hardcoding "p1", since dealer logic makes p2 go first - game.py: include current_player_idx in round_started event so state replay knows the correct starting player - test_house_rules: rename test_rule_config → run_rule_config so pytest doesn't collect it as a test fixture Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9bb9d1e397
commit
13ab5b9017
570
server/ai.py
570
server/ai.py
@ -747,42 +747,21 @@ class GolfAI:
|
|||||||
return random.choice(options)
|
return random.choice(options)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def should_take_discard(discard_card: Optional[Card], player: Player,
|
def _check_auto_take(
|
||||||
profile: CPUProfile, game: Game) -> bool:
|
discard_card: Card,
|
||||||
"""Decide whether to take from discard pile or deck."""
|
discard_value: int,
|
||||||
if not discard_card:
|
player: Player,
|
||||||
return False
|
options: GameOptions,
|
||||||
|
profile: CPUProfile
|
||||||
options = game.options
|
) -> Optional[bool]:
|
||||||
discard_value = get_ai_card_value(discard_card, options)
|
"""Check auto-take rules for the discard card.
|
||||||
|
|
||||||
ai_log(f"--- {profile.name} considering discard: {discard_card.rank.value}{discard_card.suit.value} (value={discard_value}) ---")
|
|
||||||
|
|
||||||
# SAFEGUARD: If we have only 1 face-down card, taking from discard
|
|
||||||
# forces us to swap and go out. Check if that would be acceptable.
|
|
||||||
face_down = hidden_positions(player)
|
|
||||||
if len(face_down) == 1:
|
|
||||||
projected_score = project_score(player, face_down[0], discard_card, options)
|
|
||||||
|
|
||||||
# Don't take if score would be terrible
|
|
||||||
max_acceptable = 18 if profile.aggression > 0.6 else (16 if profile.aggression > 0.3 else 14)
|
|
||||||
ai_log(f" Go-out check: projected={projected_score}, max_acceptable={max_acceptable}")
|
|
||||||
if projected_score > max_acceptable:
|
|
||||||
# Exception: still take if it's an excellent card (Joker, 2, King, Ace)
|
|
||||||
# and we have a visible bad card to replace instead
|
|
||||||
if discard_value >= 0 and discard_card.rank not in (Rank.ACE, Rank.TWO, Rank.KING, Rank.JOKER):
|
|
||||||
ai_log(f" >> REJECT: would force go-out with {projected_score} pts")
|
|
||||||
return False # Don't take - would force bad go-out
|
|
||||||
|
|
||||||
# Unpredictable players occasionally make random choice
|
|
||||||
# BUT only for reasonable cards (value <= 5) - never randomly take bad cards
|
|
||||||
if random.random() < profile.unpredictability:
|
|
||||||
if discard_value <= 5:
|
|
||||||
return random.choice([True, False])
|
|
||||||
|
|
||||||
|
Returns True (take), or None (no auto-take triggered, continue evaluation).
|
||||||
|
Covers: Jokers, Kings, one-eyed Jacks, wolfpack Jacks, ten_penny 10s,
|
||||||
|
four-of-a-kind pursuit, and pair potential.
|
||||||
|
"""
|
||||||
# Always take Jokers and Kings (even better with house rules)
|
# Always take Jokers and Kings (even better with house rules)
|
||||||
if discard_card.rank == Rank.JOKER:
|
if discard_card.rank == Rank.JOKER:
|
||||||
# Eagle Eye: If we have a visible Joker, take to pair them (doubled negative!)
|
|
||||||
if options.eagle_eye:
|
if options.eagle_eye:
|
||||||
for card in player.cards:
|
for card in player.cards:
|
||||||
if card.face_up and card.rank == Rank.JOKER:
|
if card.face_up and card.rank == Rank.JOKER:
|
||||||
@ -817,24 +796,75 @@ class GolfAI:
|
|||||||
if options.four_of_a_kind and profile.aggression > 0.5:
|
if options.four_of_a_kind and profile.aggression > 0.5:
|
||||||
rank_count = sum(1 for c in player.cards if c.face_up and c.rank == discard_card.rank)
|
rank_count = sum(1 for c in player.cards if c.face_up and c.rank == discard_card.rank)
|
||||||
if rank_count >= 2:
|
if rank_count >= 2:
|
||||||
# Already have 2+ of this rank, take to pursue four-of-a-kind!
|
|
||||||
ai_log(f" >> TAKE: {discard_card.rank.value} for four-of-a-kind ({rank_count} visible)")
|
ai_log(f" >> TAKE: {discard_card.rank.value} for four-of-a-kind ({rank_count} visible)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Take card if it could make a column pair (but NOT for negative value cards)
|
# Take card if it could make a column pair (but NOT for negative value cards)
|
||||||
# Pairing negative cards is bad - you lose the negative benefit
|
|
||||||
if discard_value > 0:
|
if discard_value > 0:
|
||||||
for i, card in enumerate(player.cards):
|
for i, card in enumerate(player.cards):
|
||||||
pair_pos = (i + 3) % 6 if i < 3 else i - 3
|
pair_pos = (i + 3) % 6 if i < 3 else i - 3
|
||||||
pair_card = player.cards[pair_pos]
|
pair_card = player.cards[pair_pos]
|
||||||
|
|
||||||
# Direct rank match
|
|
||||||
if card.face_up and card.rank == discard_card.rank and not pair_card.face_up:
|
if card.face_up and card.rank == discard_card.rank and not pair_card.face_up:
|
||||||
ai_log(f" >> TAKE: can pair with visible {card.rank.value} at pos {i}")
|
ai_log(f" >> TAKE: can pair with visible {card.rank.value} at pos {i}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Take low cards (using house rule adjusted values)
|
return None # No auto-take triggered
|
||||||
# Threshold adjusts by game phase - early game be picky, late game less so
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_good_swap_option(
|
||||||
|
discard_card: Card,
|
||||||
|
discard_value: int,
|
||||||
|
player: Player,
|
||||||
|
options: GameOptions,
|
||||||
|
game: Game,
|
||||||
|
profile: CPUProfile
|
||||||
|
) -> bool:
|
||||||
|
"""Preview swap scores to check if any position is worth swapping into."""
|
||||||
|
for pos in range(6):
|
||||||
|
score = GolfAI.calculate_swap_score(
|
||||||
|
pos, discard_card, discard_value, player, options, game, profile
|
||||||
|
)
|
||||||
|
if score > 0:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def should_take_discard(discard_card: Optional[Card], player: Player,
|
||||||
|
profile: CPUProfile, game: Game) -> bool:
|
||||||
|
"""Decide whether to take from discard pile or deck."""
|
||||||
|
if not discard_card:
|
||||||
|
return False
|
||||||
|
|
||||||
|
options = game.options
|
||||||
|
discard_value = get_ai_card_value(discard_card, options)
|
||||||
|
|
||||||
|
ai_log(f"--- {profile.name} considering discard: {discard_card.rank.value}{discard_card.suit.value} (value={discard_value}) ---")
|
||||||
|
|
||||||
|
# SAFEGUARD: If we have only 1 face-down card, taking from discard
|
||||||
|
# forces us to swap and go out. Check if that would be acceptable.
|
||||||
|
face_down = hidden_positions(player)
|
||||||
|
if len(face_down) == 1:
|
||||||
|
projected_score = project_score(player, face_down[0], discard_card, options)
|
||||||
|
|
||||||
|
max_acceptable = 18 if profile.aggression > 0.6 else (16 if profile.aggression > 0.3 else 14)
|
||||||
|
ai_log(f" Go-out check: projected={projected_score}, max_acceptable={max_acceptable}")
|
||||||
|
if projected_score > max_acceptable:
|
||||||
|
if discard_value >= 0 and discard_card.rank not in (Rank.ACE, Rank.TWO, Rank.KING, Rank.JOKER):
|
||||||
|
ai_log(f" >> REJECT: would force go-out with {projected_score} pts")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Unpredictable players occasionally make random choice
|
||||||
|
if random.random() < profile.unpredictability:
|
||||||
|
if discard_value <= 5:
|
||||||
|
return random.choice([True, False])
|
||||||
|
|
||||||
|
# Auto-take rules (Jokers, Kings, one-eyed Jacks, wolfpack, etc.)
|
||||||
|
auto_take = GolfAI._check_auto_take(discard_card, discard_value, player, options, profile)
|
||||||
|
if auto_take is not None:
|
||||||
|
return auto_take
|
||||||
|
|
||||||
|
# Take low cards (threshold adjusts by game phase)
|
||||||
phase = get_game_phase(game)
|
phase = get_game_phase(game)
|
||||||
base_threshold = {'early': 2, 'mid': 3, 'late': 4}.get(phase, 2)
|
base_threshold = {'early': 2, 'mid': 3, 'late': 4}.get(phase, 2)
|
||||||
|
|
||||||
@ -842,33 +872,19 @@ class GolfAI:
|
|||||||
ai_log(f" >> TAKE: low card (value {discard_value} <= {base_threshold} threshold for {phase} game)")
|
ai_log(f" >> TAKE: low card (value {discard_value} <= {base_threshold} threshold for {phase} game)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# For marginal cards (not auto-take), preview swap scores before committing.
|
# For marginal cards, preview swap scores before committing.
|
||||||
# Taking from discard FORCES a swap - don't take if no good swap exists.
|
# Taking from discard FORCES a swap - don't take if no good swap exists.
|
||||||
def has_good_swap_option() -> bool:
|
|
||||||
"""Preview swap scores to check if any position is worth swapping into."""
|
|
||||||
for pos in range(6):
|
|
||||||
score = GolfAI.calculate_swap_score(
|
|
||||||
pos, discard_card, discard_value, player, options, game, profile
|
|
||||||
)
|
|
||||||
if score > 0:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Calculate end-game pressure from opponents close to going out
|
# Calculate end-game pressure from opponents close to going out
|
||||||
pressure = get_end_game_pressure(player, game)
|
pressure = get_end_game_pressure(player, game)
|
||||||
|
|
||||||
# Under pressure, expand what we consider "worth taking"
|
# Under pressure, expand what we consider "worth taking"
|
||||||
# When opponents are close to going out, take decent cards to avoid
|
|
||||||
# getting stuck with unknown bad cards when the round ends
|
|
||||||
if pressure > 0.2:
|
if pressure > 0.2:
|
||||||
# Scale threshold: at pressure 0.2 take 4s, at 0.5+ take 6s
|
|
||||||
pressure_threshold = 3 + int(pressure * 6) # 4 to 9 based on pressure
|
pressure_threshold = 3 + int(pressure * 6) # 4 to 9 based on pressure
|
||||||
pressure_threshold = min(pressure_threshold, 7) # Cap at 7
|
pressure_threshold = min(pressure_threshold, 7) # Cap at 7
|
||||||
if discard_value <= pressure_threshold:
|
if discard_value <= pressure_threshold:
|
||||||
# Only take if we have hidden cards that could be worse
|
|
||||||
if count_hidden(player) > 0:
|
if count_hidden(player) > 0:
|
||||||
# CRITICAL: Verify there's actually a good swap position
|
if GolfAI._has_good_swap_option(discard_card, discard_value, player, options, game, profile):
|
||||||
if has_good_swap_option():
|
|
||||||
ai_log(f" >> TAKE: pressure={pressure:.2f}, threshold={pressure_threshold}")
|
ai_log(f" >> TAKE: pressure={pressure:.2f}, threshold={pressure_threshold}")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
@ -881,11 +897,8 @@ class GolfAI:
|
|||||||
worst_visible = max(worst_visible, get_ai_card_value(card, options))
|
worst_visible = max(worst_visible, get_ai_card_value(card, options))
|
||||||
|
|
||||||
if worst_visible > discard_value + 1:
|
if worst_visible > discard_value + 1:
|
||||||
# Sanity check: only take if we actually have something worse to replace
|
|
||||||
# This prevents taking a bad card when all visible cards are better
|
|
||||||
if has_worse_visible_card(player, discard_value, options):
|
if has_worse_visible_card(player, discard_value, options):
|
||||||
# CRITICAL: Verify there's actually a good swap position
|
if GolfAI._has_good_swap_option(discard_card, discard_value, player, options, game, profile):
|
||||||
if has_good_swap_option():
|
|
||||||
ai_log(f" >> TAKE: have worse visible card ({worst_visible})")
|
ai_log(f" >> TAKE: have worse visible card ({worst_visible})")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
@ -894,6 +907,222 @@ class GolfAI:
|
|||||||
ai_log(f" >> PASS: drawing from deck instead")
|
ai_log(f" >> PASS: drawing from deck instead")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pair_improvement(
|
||||||
|
pos: int,
|
||||||
|
drawn_card: Card,
|
||||||
|
drawn_value: int,
|
||||||
|
player: Player,
|
||||||
|
options: GameOptions,
|
||||||
|
profile: CPUProfile
|
||||||
|
) -> float:
|
||||||
|
"""Calculate pair bonus and spread bonus score components.
|
||||||
|
|
||||||
|
Section 1: Pair creation scoring (positive/negative/eagle_eye/negative_pairs_keep_value)
|
||||||
|
Section 1b: Spread bonus for non-pairing excellent cards
|
||||||
|
"""
|
||||||
|
partner_pos = get_column_partner_position(pos)
|
||||||
|
partner_card = player.cards[partner_pos]
|
||||||
|
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
# Personality-based weight modifiers
|
||||||
|
pair_weight = 1.0 + profile.pair_hope # Range: 1.0 to 2.0
|
||||||
|
spread_weight = 2.0 - profile.pair_hope # Range: 1.0 to 2.0 (inverse)
|
||||||
|
|
||||||
|
# 1. PAIR BONUS - Creating a pair
|
||||||
|
if partner_card.face_up and partner_card.rank == drawn_card.rank:
|
||||||
|
partner_value = get_ai_card_value(partner_card, options)
|
||||||
|
|
||||||
|
if drawn_value >= 0:
|
||||||
|
# Good pair! Both cards cancel to 0
|
||||||
|
pair_bonus = drawn_value + partner_value
|
||||||
|
score += pair_bonus * pair_weight
|
||||||
|
else:
|
||||||
|
# Pairing negative cards
|
||||||
|
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
|
||||||
|
score += 8 * pair_weight # Eagle Eye Joker pairs = -4
|
||||||
|
elif options.negative_pairs_keep_value:
|
||||||
|
pair_benefit = abs(drawn_value + partner_value)
|
||||||
|
score += pair_benefit * pair_weight
|
||||||
|
ai_log(f" Negative pair keep value bonus: +{pair_benefit * pair_weight:.1f}")
|
||||||
|
else:
|
||||||
|
# Standard rules: penalty for wasting negative cards
|
||||||
|
penalty = abs(drawn_value) * 2 * (2.0 - profile.pair_hope)
|
||||||
|
score -= penalty
|
||||||
|
ai_log(f" Negative pair penalty at pos {pos}: -{penalty:.1f} (score now={score:.2f})")
|
||||||
|
|
||||||
|
# 1b. SPREAD BONUS - Not pairing good cards (spreading them out)
|
||||||
|
if not partner_card.face_up or partner_card.rank != drawn_card.rank:
|
||||||
|
if drawn_value <= 1: # Excellent cards (K, 2, A, Joker)
|
||||||
|
score += spread_weight * 0.5
|
||||||
|
|
||||||
|
return score
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _point_gain(
|
||||||
|
pos: int,
|
||||||
|
drawn_card: Card,
|
||||||
|
drawn_value: int,
|
||||||
|
player: Player,
|
||||||
|
options: GameOptions,
|
||||||
|
profile: CPUProfile
|
||||||
|
) -> float:
|
||||||
|
"""Calculate point gain score component from replacing a card.
|
||||||
|
|
||||||
|
Handles face-up replacement (breaking pair, creating pair, normal swap)
|
||||||
|
and hidden card expected-value calculation with discount.
|
||||||
|
"""
|
||||||
|
current_card = player.cards[pos]
|
||||||
|
partner_pos = get_column_partner_position(pos)
|
||||||
|
partner_card = player.cards[partner_pos]
|
||||||
|
|
||||||
|
if current_card.face_up:
|
||||||
|
current_value = get_ai_card_value(current_card, options)
|
||||||
|
|
||||||
|
# CRITICAL: Check if current card is part of an existing column pair
|
||||||
|
if partner_card.face_up and partner_card.rank == current_card.rank:
|
||||||
|
partner_value = get_ai_card_value(partner_card, options)
|
||||||
|
|
||||||
|
if options.eagle_eye and current_card.rank == Rank.JOKER:
|
||||||
|
old_column_value = -4
|
||||||
|
new_column_value = drawn_value + 2
|
||||||
|
point_gain = old_column_value - new_column_value
|
||||||
|
ai_log(f" Breaking Eagle Eye joker pair at pos {pos}: column {old_column_value} -> {new_column_value}, gain={point_gain}")
|
||||||
|
elif options.negative_pairs_keep_value and (current_value < 0 or partner_value < 0):
|
||||||
|
old_column_value = current_value + partner_value
|
||||||
|
new_column_value = drawn_value + partner_value
|
||||||
|
point_gain = old_column_value - new_column_value
|
||||||
|
ai_log(f" Breaking negative-keep pair at pos {pos}: column {old_column_value} -> {new_column_value}, gain={point_gain}")
|
||||||
|
else:
|
||||||
|
old_column_value = 0
|
||||||
|
new_column_value = drawn_value + partner_value
|
||||||
|
point_gain = old_column_value - new_column_value
|
||||||
|
ai_log(f" Breaking standard pair at pos {pos}: column 0 -> {new_column_value}, gain={point_gain}")
|
||||||
|
elif partner_card.face_up and partner_card.rank == drawn_card.rank:
|
||||||
|
# CREATING a new pair (drawn matches partner, but current doesn't)
|
||||||
|
partner_value = get_ai_card_value(partner_card, options)
|
||||||
|
old_column_value = current_value + partner_value
|
||||||
|
if drawn_value < 0 and not options.negative_pairs_keep_value:
|
||||||
|
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
|
||||||
|
new_column_value = -4
|
||||||
|
else:
|
||||||
|
new_column_value = 0
|
||||||
|
elif options.negative_pairs_keep_value and (drawn_value < 0 or partner_value < 0):
|
||||||
|
new_column_value = drawn_value + partner_value
|
||||||
|
else:
|
||||||
|
new_column_value = 0
|
||||||
|
point_gain = old_column_value - new_column_value
|
||||||
|
ai_log(f" Creating pair at pos {pos}: column {old_column_value} -> {new_column_value}, gain={point_gain}")
|
||||||
|
else:
|
||||||
|
point_gain = current_value - drawn_value
|
||||||
|
|
||||||
|
return float(point_gain)
|
||||||
|
else:
|
||||||
|
# Hidden card - expected value ~4.5
|
||||||
|
creates_negative_pair = (
|
||||||
|
partner_card.face_up and
|
||||||
|
partner_card.rank == drawn_card.rank and
|
||||||
|
drawn_value < 0 and
|
||||||
|
not options.negative_pairs_keep_value and
|
||||||
|
not (options.eagle_eye and drawn_card.rank == Rank.JOKER)
|
||||||
|
)
|
||||||
|
if not creates_negative_pair:
|
||||||
|
expected_hidden = EXPECTED_HIDDEN_VALUE
|
||||||
|
point_gain = expected_hidden - drawn_value
|
||||||
|
discount = 0.5 + (profile.swap_threshold / 16) # Range: 0.5 to 1.0
|
||||||
|
return point_gain * discount
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _reveal_and_bonus_score(
|
||||||
|
pos: int,
|
||||||
|
drawn_card: Card,
|
||||||
|
drawn_value: int,
|
||||||
|
player: Player,
|
||||||
|
options: GameOptions,
|
||||||
|
game: Game,
|
||||||
|
profile: CPUProfile
|
||||||
|
) -> float:
|
||||||
|
"""Calculate reveal bonus and strategic bonus score components.
|
||||||
|
|
||||||
|
Sections 3-4d: reveal bonus, future pair potential, four-of-a-kind pursuit,
|
||||||
|
wolfpack pursuit, and comeback aggression.
|
||||||
|
"""
|
||||||
|
current_card = player.cards[pos]
|
||||||
|
partner_pos = get_column_partner_position(pos)
|
||||||
|
partner_card = player.cards[partner_pos]
|
||||||
|
|
||||||
|
score = 0.0
|
||||||
|
pair_weight = 1.0 + profile.pair_hope
|
||||||
|
|
||||||
|
# 3. REVEAL BONUS - Value of revealing hidden cards
|
||||||
|
if not current_card.face_up:
|
||||||
|
reveal_bonus = min(count_hidden(player), 4)
|
||||||
|
aggression_multiplier = 0.8 + profile.aggression * 0.4 # Range: 0.8 to 1.2
|
||||||
|
|
||||||
|
if drawn_value <= 0:
|
||||||
|
score += reveal_bonus * 1.2 * aggression_multiplier
|
||||||
|
elif drawn_value == 1:
|
||||||
|
score += reveal_bonus * 1.0 * aggression_multiplier
|
||||||
|
elif drawn_value <= 4:
|
||||||
|
score += reveal_bonus * 0.6 * aggression_multiplier
|
||||||
|
elif drawn_value <= 6:
|
||||||
|
score += reveal_bonus * 0.3 * aggression_multiplier
|
||||||
|
|
||||||
|
# 4. FUTURE PAIR POTENTIAL
|
||||||
|
if not current_card.face_up and not partner_card.face_up:
|
||||||
|
pair_viability = get_pair_viability(drawn_card.rank, game)
|
||||||
|
score += pair_viability * pair_weight * 0.5
|
||||||
|
|
||||||
|
# 4b. FOUR OF A KIND PURSUIT
|
||||||
|
if options.four_of_a_kind:
|
||||||
|
rank_count = sum(
|
||||||
|
1 for i, c in enumerate(player.cards)
|
||||||
|
if c.face_up and c.rank == drawn_card.rank and i != pos
|
||||||
|
)
|
||||||
|
if rank_count >= 2:
|
||||||
|
four_kind_bonus = rank_count * 4
|
||||||
|
standings_pressure = get_standings_pressure(player, game)
|
||||||
|
if standings_pressure > 0.3:
|
||||||
|
four_kind_bonus *= (1 + standings_pressure * 0.5)
|
||||||
|
score += four_kind_bonus
|
||||||
|
ai_log(f" Four-of-a-kind pursuit bonus: +{four_kind_bonus:.1f}")
|
||||||
|
|
||||||
|
# 4c. WOLFPACK PURSUIT
|
||||||
|
if options.wolfpack and profile.aggression > 0.5:
|
||||||
|
jack_pair_count = 0
|
||||||
|
for col in range(3):
|
||||||
|
top, bot = player.cards[col], player.cards[col + 3]
|
||||||
|
if top.face_up and bot.face_up and top.rank == Rank.JACK and bot.rank == Rank.JACK:
|
||||||
|
jack_pair_count += 1
|
||||||
|
|
||||||
|
visible_jacks = sum(1 for c in player.cards if c.face_up and c.rank == Rank.JACK)
|
||||||
|
|
||||||
|
if drawn_card.rank == Rank.JACK:
|
||||||
|
if jack_pair_count == 1:
|
||||||
|
if partner_card.face_up and partner_card.rank == Rank.JACK:
|
||||||
|
wolfpack_bonus = 15 * profile.aggression
|
||||||
|
score += wolfpack_bonus
|
||||||
|
ai_log(f" Wolfpack pursuit: completing 2nd Jack pair! +{wolfpack_bonus:.1f}")
|
||||||
|
elif not partner_card.face_up:
|
||||||
|
wolfpack_bonus = 2 * profile.aggression
|
||||||
|
score += wolfpack_bonus
|
||||||
|
ai_log(f" Wolfpack pursuit (speculative): +{wolfpack_bonus:.1f}")
|
||||||
|
elif visible_jacks >= 1 and partner_card.face_up and partner_card.rank == Rank.JACK:
|
||||||
|
wolfpack_bonus = 8 * profile.aggression
|
||||||
|
score += wolfpack_bonus
|
||||||
|
ai_log(f" Wolfpack pursuit: first Jack pair +{wolfpack_bonus:.1f}")
|
||||||
|
|
||||||
|
# 4d. COMEBACK AGGRESSION
|
||||||
|
standings_pressure = get_standings_pressure(player, game)
|
||||||
|
if standings_pressure > 0.3 and not current_card.face_up and drawn_value < HIGH_CARD_THRESHOLD:
|
||||||
|
comeback_bonus = standings_pressure * 3 * profile.aggression
|
||||||
|
score += comeback_bonus
|
||||||
|
ai_log(f" Comeback aggression bonus: +{comeback_bonus:.1f} (pressure={standings_pressure:.2f})")
|
||||||
|
|
||||||
|
return score
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def calculate_swap_score(
|
def calculate_swap_score(
|
||||||
pos: int,
|
pos: int,
|
||||||
@ -917,213 +1146,22 @@ class GolfAI:
|
|||||||
- aggression: higher = more willing to go out, take risks
|
- aggression: higher = more willing to go out, take risks
|
||||||
- swap_threshold: affects how picky about card values
|
- swap_threshold: affects how picky about card values
|
||||||
"""
|
"""
|
||||||
current_card = player.cards[pos]
|
|
||||||
partner_pos = get_column_partner_position(pos)
|
|
||||||
partner_card = player.cards[partner_pos]
|
|
||||||
|
|
||||||
score = 0.0
|
score = 0.0
|
||||||
|
|
||||||
# Personality-based weight modifiers
|
# 1/1b. Pair creation + spread bonus
|
||||||
# pair_hope: 0.0-1.0, affects how much we value pairing vs spreading
|
score += GolfAI._pair_improvement(pos, drawn_card, drawn_value, player, options, profile)
|
||||||
pair_weight = 1.0 + profile.pair_hope # Range: 1.0 to 2.0
|
|
||||||
spread_weight = 2.0 - profile.pair_hope # Range: 1.0 to 2.0 (inverse)
|
|
||||||
|
|
||||||
# 1. PAIR BONUS - Creating a pair
|
# 2. Point gain from replacement
|
||||||
# pair_hope affects how much we value this
|
score += GolfAI._point_gain(pos, drawn_card, drawn_value, player, options, profile)
|
||||||
if partner_card.face_up and partner_card.rank == drawn_card.rank:
|
|
||||||
partner_value = get_ai_card_value(partner_card, options)
|
|
||||||
|
|
||||||
if drawn_value >= 0:
|
# 3-4d. Reveal bonus, future pair potential, four-of-a-kind, wolfpack, comeback
|
||||||
# Good pair! Both cards cancel to 0
|
score += GolfAI._reveal_and_bonus_score(pos, drawn_card, drawn_value, player, options, game, profile)
|
||||||
pair_bonus = drawn_value + partner_value
|
|
||||||
score += pair_bonus * pair_weight # Pair hunters value this more
|
|
||||||
else:
|
|
||||||
# Pairing negative cards
|
|
||||||
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
|
|
||||||
score += 8 * pair_weight # Eagle Eye Joker pairs = -4
|
|
||||||
elif options.negative_pairs_keep_value:
|
|
||||||
# Negative Pairs Keep Value: pairing 2s/Jokers is NOW good!
|
|
||||||
# Pair of 2s = -4, pair of Jokers = -4 (instead of 0)
|
|
||||||
pair_benefit = abs(drawn_value + partner_value)
|
|
||||||
score += pair_benefit * pair_weight
|
|
||||||
ai_log(f" Negative pair keep value bonus: +{pair_benefit * pair_weight:.1f}")
|
|
||||||
else:
|
|
||||||
# Standard rules: penalty for wasting negative cards
|
|
||||||
penalty = abs(drawn_value) * 2 * (2.0 - profile.pair_hope)
|
|
||||||
score -= penalty
|
|
||||||
ai_log(f" Negative pair penalty at pos {pos}: -{penalty:.1f} (score now={score:.2f})")
|
|
||||||
|
|
||||||
# 1b. SPREAD BONUS - Not pairing good cards (spreading them out)
|
|
||||||
# Players with low pair_hope prefer spreading aces/2s across columns
|
|
||||||
if not partner_card.face_up or partner_card.rank != drawn_card.rank:
|
|
||||||
if drawn_value <= 1: # Excellent cards (K, 2, A, Joker)
|
|
||||||
# Small bonus for spreading - scales with spread preference
|
|
||||||
score += spread_weight * 0.5
|
|
||||||
|
|
||||||
# 2. POINT GAIN - Direct value improvement
|
|
||||||
if current_card.face_up:
|
|
||||||
current_value = get_ai_card_value(current_card, options)
|
|
||||||
|
|
||||||
# CRITICAL: Check if current card is part of an existing column pair
|
|
||||||
# If so, breaking the pair is usually terrible - the paired column is worth 0,
|
|
||||||
# but after breaking it becomes (drawn_value + orphaned_partner_value)
|
|
||||||
if partner_card.face_up and partner_card.rank == current_card.rank:
|
|
||||||
partner_value = get_ai_card_value(partner_card, options)
|
|
||||||
|
|
||||||
# Determine the current column value (what the pair contributes)
|
|
||||||
if options.eagle_eye and current_card.rank == Rank.JOKER:
|
|
||||||
# Eagle Eye: paired jokers contribute -4 total
|
|
||||||
old_column_value = -4
|
|
||||||
# After swap: orphan joker becomes +2 (unpaired eagle_eye value)
|
|
||||||
new_column_value = drawn_value + 2
|
|
||||||
point_gain = old_column_value - new_column_value
|
|
||||||
ai_log(f" Breaking Eagle Eye joker pair at pos {pos}: column {old_column_value} -> {new_column_value}, gain={point_gain}")
|
|
||||||
elif options.negative_pairs_keep_value and (current_value < 0 or partner_value < 0):
|
|
||||||
# Negative pairs keep value: column is worth sum of both values
|
|
||||||
old_column_value = current_value + partner_value
|
|
||||||
new_column_value = drawn_value + partner_value
|
|
||||||
point_gain = old_column_value - new_column_value
|
|
||||||
ai_log(f" Breaking negative-keep pair at pos {pos}: column {old_column_value} -> {new_column_value}, gain={point_gain}")
|
|
||||||
else:
|
|
||||||
# Standard pair - column is worth 0
|
|
||||||
# After swap: column becomes drawn_value + partner_value
|
|
||||||
old_column_value = 0
|
|
||||||
new_column_value = drawn_value + partner_value
|
|
||||||
point_gain = old_column_value - new_column_value
|
|
||||||
ai_log(f" Breaking standard pair at pos {pos}: column 0 -> {new_column_value}, gain={point_gain}")
|
|
||||||
elif partner_card.face_up and partner_card.rank == drawn_card.rank:
|
|
||||||
# CREATING a new pair (drawn matches partner, but current doesn't)
|
|
||||||
# Calculate column change properly
|
|
||||||
old_column_value = current_value + partner_value
|
|
||||||
# Determine new column value based on rules
|
|
||||||
if drawn_value < 0 and not options.negative_pairs_keep_value:
|
|
||||||
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
|
|
||||||
new_column_value = -4
|
|
||||||
else:
|
|
||||||
new_column_value = 0 # Negative pair under standard rules
|
|
||||||
elif options.negative_pairs_keep_value and (drawn_value < 0 or partner_value < 0):
|
|
||||||
new_column_value = drawn_value + partner_value
|
|
||||||
else:
|
|
||||||
new_column_value = 0 # Standard positive pair
|
|
||||||
point_gain = old_column_value - new_column_value
|
|
||||||
ai_log(f" Creating pair at pos {pos}: column {old_column_value} -> {new_column_value}, gain={point_gain}")
|
|
||||||
else:
|
|
||||||
# No existing pair, not creating pair - normal calculation
|
|
||||||
point_gain = current_value - drawn_value
|
|
||||||
|
|
||||||
score += point_gain
|
|
||||||
else:
|
|
||||||
# Hidden card - expected value ~4.5
|
|
||||||
# BUT: Don't add this bonus if we're creating a negative pair
|
|
||||||
# (the pair penalty already accounts for the bad outcome)
|
|
||||||
creates_negative_pair = (
|
|
||||||
partner_card.face_up and
|
|
||||||
partner_card.rank == drawn_card.rank and
|
|
||||||
drawn_value < 0 and
|
|
||||||
not options.negative_pairs_keep_value and
|
|
||||||
not (options.eagle_eye and drawn_card.rank == Rank.JOKER)
|
|
||||||
)
|
|
||||||
if not creates_negative_pair:
|
|
||||||
expected_hidden = EXPECTED_HIDDEN_VALUE
|
|
||||||
point_gain = expected_hidden - drawn_value
|
|
||||||
# Conservative players (low swap_threshold) discount uncertain gains more
|
|
||||||
discount = 0.5 + (profile.swap_threshold / 16) # Range: 0.5 to 1.0
|
|
||||||
score += point_gain * discount
|
|
||||||
|
|
||||||
# 3. REVEAL BONUS - Value of revealing hidden cards
|
|
||||||
# More aggressive players want to reveal faster to go out
|
|
||||||
if not current_card.face_up:
|
|
||||||
reveal_bonus = min(count_hidden(player), 4)
|
|
||||||
|
|
||||||
# Aggressive players get bigger reveal bonus (want to go out faster)
|
|
||||||
aggression_multiplier = 0.8 + profile.aggression * 0.4 # Range: 0.8 to 1.2
|
|
||||||
|
|
||||||
# Scale by card quality
|
|
||||||
if drawn_value <= 0: # Excellent
|
|
||||||
score += reveal_bonus * 1.2 * aggression_multiplier
|
|
||||||
elif drawn_value == 1: # Great
|
|
||||||
score += reveal_bonus * 1.0 * aggression_multiplier
|
|
||||||
elif drawn_value <= 4: # Good
|
|
||||||
score += reveal_bonus * 0.6 * aggression_multiplier
|
|
||||||
elif drawn_value <= 6: # Medium
|
|
||||||
score += reveal_bonus * 0.3 * aggression_multiplier
|
|
||||||
# Bad cards: no reveal bonus
|
|
||||||
|
|
||||||
# 4. FUTURE PAIR POTENTIAL
|
|
||||||
# Pair hunters value positions where both cards are hidden
|
|
||||||
if not current_card.face_up and not partner_card.face_up:
|
|
||||||
pair_viability = get_pair_viability(drawn_card.rank, game)
|
|
||||||
score += pair_viability * pair_weight * 0.5
|
|
||||||
|
|
||||||
# 4b. FOUR OF A KIND PURSUIT
|
|
||||||
# When four_of_a_kind rule is enabled, boost score for collecting 3rd/4th card
|
|
||||||
if options.four_of_a_kind:
|
|
||||||
# Count how many of this rank player already has visible (excluding current position)
|
|
||||||
rank_count = sum(
|
|
||||||
1 for i, c in enumerate(player.cards)
|
|
||||||
if c.face_up and c.rank == drawn_card.rank and i != pos
|
|
||||||
)
|
|
||||||
if rank_count >= 2:
|
|
||||||
# Already have 2+ of this rank, getting more is great for 4-of-a-kind
|
|
||||||
four_kind_bonus = rank_count * 4 # 8 for 2 cards, 12 for 3 cards
|
|
||||||
# Boost when behind in standings
|
|
||||||
standings_pressure = get_standings_pressure(player, game)
|
|
||||||
if standings_pressure > 0.3:
|
|
||||||
four_kind_bonus *= (1 + standings_pressure * 0.5) # Up to 50% boost
|
|
||||||
score += four_kind_bonus
|
|
||||||
ai_log(f" Four-of-a-kind pursuit bonus: +{four_kind_bonus:.1f}")
|
|
||||||
|
|
||||||
# 4c. WOLFPACK PURSUIT - Aggressive players chase Jack pairs for -20 bonus
|
|
||||||
if options.wolfpack and profile.aggression > 0.5:
|
|
||||||
# Count Jack pairs already formed
|
|
||||||
jack_pair_count = 0
|
|
||||||
for col in range(3):
|
|
||||||
top, bot = player.cards[col], player.cards[col + 3]
|
|
||||||
if top.face_up and bot.face_up and top.rank == Rank.JACK and bot.rank == Rank.JACK:
|
|
||||||
jack_pair_count += 1
|
|
||||||
|
|
||||||
# Count visible Jacks that could form pairs
|
|
||||||
visible_jacks = sum(1 for c in player.cards if c.face_up and c.rank == Rank.JACK)
|
|
||||||
|
|
||||||
if drawn_card.rank == Rank.JACK:
|
|
||||||
# Drawing a Jack - evaluate wolfpack potential
|
|
||||||
if jack_pair_count == 1:
|
|
||||||
# Already have one pair! Second pair gives -20 bonus
|
|
||||||
if partner_card.face_up and partner_card.rank == Rank.JACK:
|
|
||||||
# Completing second Jack pair!
|
|
||||||
wolfpack_bonus = 15 * profile.aggression
|
|
||||||
score += wolfpack_bonus
|
|
||||||
ai_log(f" Wolfpack pursuit: completing 2nd Jack pair! +{wolfpack_bonus:.1f}")
|
|
||||||
elif not partner_card.face_up:
|
|
||||||
# Partner unknown - speculative wolfpack pursuit
|
|
||||||
# Probability of unknown card being Jack is very low (~3%)
|
|
||||||
# Expected value of swapping Jack into unknown is negative
|
|
||||||
# Only give small bonus - not enough to override negative point_gain
|
|
||||||
wolfpack_bonus = 2 * profile.aggression
|
|
||||||
score += wolfpack_bonus
|
|
||||||
ai_log(f" Wolfpack pursuit (speculative): +{wolfpack_bonus:.1f}")
|
|
||||||
elif visible_jacks >= 1 and partner_card.face_up and partner_card.rank == Rank.JACK:
|
|
||||||
# Completing first Jack pair while having other Jacks
|
|
||||||
wolfpack_bonus = 8 * profile.aggression
|
|
||||||
score += wolfpack_bonus
|
|
||||||
ai_log(f" Wolfpack pursuit: first Jack pair +{wolfpack_bonus:.1f}")
|
|
||||||
|
|
||||||
# 4d. COMEBACK AGGRESSION - Boost reveal bonus when behind in late game
|
|
||||||
# Only for cards that aren't objectively bad (value < 8)
|
|
||||||
# Don't incentivize locking in 8, 9, 10, J, Q just to "go out faster"
|
|
||||||
standings_pressure = get_standings_pressure(player, game)
|
|
||||||
if standings_pressure > 0.3 and not current_card.face_up and drawn_value < HIGH_CARD_THRESHOLD:
|
|
||||||
# Behind in standings - boost incentive to reveal and play faster
|
|
||||||
comeback_bonus = standings_pressure * 3 * profile.aggression
|
|
||||||
score += comeback_bonus
|
|
||||||
ai_log(f" Comeback aggression bonus: +{comeback_bonus:.1f} (pressure={standings_pressure:.2f})")
|
|
||||||
|
|
||||||
# 5. GO-OUT SAFETY - Penalty for going out with bad score
|
# 5. GO-OUT SAFETY - Penalty for going out with bad score
|
||||||
face_down_positions = hidden_positions(player)
|
face_down_positions = hidden_positions(player)
|
||||||
if len(face_down_positions) == 1 and pos == face_down_positions[0]:
|
if len(face_down_positions) == 1 and pos == face_down_positions[0]:
|
||||||
projected_score = project_score(player, pos, drawn_card, options)
|
projected_score = project_score(player, pos, drawn_card, options)
|
||||||
|
|
||||||
# Aggressive players accept higher scores when going out
|
|
||||||
max_acceptable = GO_OUT_SCORE_BASE + int(profile.aggression * (GO_OUT_SCORE_MAX - GO_OUT_SCORE_BASE))
|
max_acceptable = GO_OUT_SCORE_BASE + int(profile.aggression * (GO_OUT_SCORE_MAX - GO_OUT_SCORE_BASE))
|
||||||
if projected_score > max_acceptable:
|
if projected_score > max_acceptable:
|
||||||
score -= 100
|
score -= 100
|
||||||
@ -1701,12 +1739,26 @@ class GolfAI:
|
|||||||
expected_hidden_total = len(face_down) * EXPECTED_HIDDEN_VALUE
|
expected_hidden_total = len(face_down) * EXPECTED_HIDDEN_VALUE
|
||||||
projected_score = visible_score + expected_hidden_total
|
projected_score = visible_score + expected_hidden_total
|
||||||
|
|
||||||
# More aggressive players accept higher risk
|
# Tighter threshold: range 5 to 9 based on aggression
|
||||||
max_acceptable = 8 + int(profile.aggression * 10) # Range: 8 to 18
|
max_acceptable = 5 + int(profile.aggression * 4)
|
||||||
|
|
||||||
|
# Exception: if all opponents are showing terrible scores, relax threshold
|
||||||
|
all_opponents_bad = all(
|
||||||
|
sum(get_ai_card_value(c, game.options) for c in p.cards if c.face_up) >= 25
|
||||||
|
for p in game.players if p.id != player.id
|
||||||
|
)
|
||||||
|
if all_opponents_bad:
|
||||||
|
max_acceptable += 5 # Willing to knock at higher score when winning big
|
||||||
|
|
||||||
if projected_score <= max_acceptable:
|
if projected_score <= max_acceptable:
|
||||||
# Add some randomness based on aggression
|
# Scale knock chance by how good the projected score is
|
||||||
knock_chance = profile.aggression * 0.4 # Max 40% for most aggressive
|
if projected_score <= 5:
|
||||||
|
knock_chance = profile.aggression * 0.3 # Max 30%
|
||||||
|
elif projected_score <= 7:
|
||||||
|
knock_chance = profile.aggression * 0.15 # Max 15%
|
||||||
|
else:
|
||||||
|
knock_chance = profile.aggression * 0.05 # Max 5% (very rare)
|
||||||
|
|
||||||
if random.random() < knock_chance:
|
if random.random() < knock_chance:
|
||||||
ai_log(f" Knock early: taking the gamble! (projected {projected_score:.1f})")
|
ai_log(f" Knock early: taking the gamble! (projected {projected_score:.1f})")
|
||||||
return True
|
return True
|
||||||
|
|||||||
190
server/game.py
190
server/game.py
@ -21,7 +21,7 @@ Card Layout:
|
|||||||
import random
|
import random
|
||||||
import uuid
|
import uuid
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional, Callable, Any
|
from typing import Optional, Callable, Any
|
||||||
@ -130,11 +130,13 @@ class Card:
|
|||||||
suit: The card's suit (hearts, diamonds, clubs, spades).
|
suit: The card's suit (hearts, diamonds, clubs, spades).
|
||||||
rank: The card's rank (A, 2-10, J, Q, K, or Joker).
|
rank: The card's rank (A, 2-10, J, Q, K, or Joker).
|
||||||
face_up: Whether the card is visible to all players.
|
face_up: Whether the card is visible to all players.
|
||||||
|
deck_id: Which deck this card came from (0-indexed, for multi-deck games).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
suit: Suit
|
suit: Suit
|
||||||
rank: Rank
|
rank: Rank
|
||||||
face_up: bool = False
|
face_up: bool = False
|
||||||
|
deck_id: int = 0
|
||||||
|
|
||||||
def to_dict(self, reveal: bool = False) -> dict:
|
def to_dict(self, reveal: bool = False) -> dict:
|
||||||
"""
|
"""
|
||||||
@ -154,24 +156,27 @@ class Card:
|
|||||||
"suit": self.suit.value,
|
"suit": self.suit.value,
|
||||||
"rank": self.rank.value,
|
"rank": self.rank.value,
|
||||||
"face_up": self.face_up,
|
"face_up": self.face_up,
|
||||||
|
"deck_id": self.deck_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
def to_client_dict(self) -> dict:
|
def to_client_dict(self) -> dict:
|
||||||
"""
|
"""
|
||||||
Convert card to dictionary for client display.
|
Convert card to dictionary for client display.
|
||||||
|
|
||||||
Hides card details if face-down to prevent cheating.
|
Hides card details if face-down to prevent cheating, but always
|
||||||
|
includes deck_id so the client can show the correct back color.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with card info, or just {face_up: False} if hidden.
|
Dict with card info, or just {face_up: False, deck_id} if hidden.
|
||||||
"""
|
"""
|
||||||
if self.face_up:
|
if self.face_up:
|
||||||
return {
|
return {
|
||||||
"suit": self.suit.value,
|
"suit": self.suit.value,
|
||||||
"rank": self.rank.value,
|
"rank": self.rank.value,
|
||||||
"face_up": True,
|
"face_up": True,
|
||||||
|
"deck_id": self.deck_id,
|
||||||
}
|
}
|
||||||
return {"face_up": False}
|
return {"face_up": False, "deck_id": self.deck_id}
|
||||||
|
|
||||||
def value(self) -> int:
|
def value(self) -> int:
|
||||||
"""Get base point value (without house rule modifications)."""
|
"""Get base point value (without house rule modifications)."""
|
||||||
@ -210,20 +215,20 @@ class Deck:
|
|||||||
self.seed: int = seed if seed is not None else random.randint(0, 2**31 - 1)
|
self.seed: int = seed if seed is not None else random.randint(0, 2**31 - 1)
|
||||||
|
|
||||||
# Build deck(s) with standard cards
|
# Build deck(s) with standard cards
|
||||||
for _ in range(num_decks):
|
for deck_idx in range(num_decks):
|
||||||
for suit in Suit:
|
for suit in Suit:
|
||||||
for rank in Rank:
|
for rank in Rank:
|
||||||
if rank != Rank.JOKER:
|
if rank != Rank.JOKER:
|
||||||
self.cards.append(Card(suit, rank))
|
self.cards.append(Card(suit, rank, deck_id=deck_idx))
|
||||||
|
|
||||||
# Standard jokers: 2 per deck, worth -2 each
|
# Standard jokers: 2 per deck, worth -2 each
|
||||||
if use_jokers and not lucky_swing:
|
if use_jokers and not lucky_swing:
|
||||||
self.cards.append(Card(Suit.HEARTS, Rank.JOKER))
|
self.cards.append(Card(Suit.HEARTS, Rank.JOKER, deck_id=deck_idx))
|
||||||
self.cards.append(Card(Suit.SPADES, Rank.JOKER))
|
self.cards.append(Card(Suit.SPADES, Rank.JOKER, deck_id=deck_idx))
|
||||||
|
|
||||||
# Lucky Swing: Single joker total, worth -5
|
# Lucky Swing: Single joker total, worth -5
|
||||||
if use_jokers and lucky_swing:
|
if use_jokers and lucky_swing:
|
||||||
self.cards.append(Card(Suit.HEARTS, Rank.JOKER))
|
self.cards.append(Card(Suit.HEARTS, Rank.JOKER, deck_id=0))
|
||||||
|
|
||||||
self.shuffle()
|
self.shuffle()
|
||||||
|
|
||||||
@ -256,6 +261,12 @@ class Deck:
|
|||||||
"""Return the number of cards left in the deck."""
|
"""Return the number of cards left in the deck."""
|
||||||
return len(self.cards)
|
return len(self.cards)
|
||||||
|
|
||||||
|
def top_card_deck_id(self) -> Optional[int]:
|
||||||
|
"""Return the deck_id of the top card (for showing correct back color)."""
|
||||||
|
if self.cards:
|
||||||
|
return self.cards[-1].deck_id
|
||||||
|
return None
|
||||||
|
|
||||||
def add_cards(self, cards: list[Card]) -> None:
|
def add_cards(self, cards: list[Card]) -> None:
|
||||||
"""
|
"""
|
||||||
Add cards to the deck and shuffle.
|
Add cards to the deck and shuffle.
|
||||||
@ -498,6 +509,44 @@ class GameOptions:
|
|||||||
knock_early: bool = False
|
knock_early: bool = False
|
||||||
"""Allow going out early by flipping all remaining cards (max 2 face-down)."""
|
"""Allow going out early by flipping all remaining cards (max 2 face-down)."""
|
||||||
|
|
||||||
|
deck_colors: list[str] = field(default_factory=lambda: ["red", "blue", "gold"])
|
||||||
|
"""Colors for card backs from different decks (in order by deck_id)."""
|
||||||
|
|
||||||
|
_ALLOWED_COLORS = {
|
||||||
|
"red", "blue", "gold", "teal", "purple", "orange", "yellow",
|
||||||
|
"green", "pink", "cyan", "brown", "slate",
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_client_data(cls, data: dict) -> "GameOptions":
|
||||||
|
"""Build GameOptions from client WebSocket message data."""
|
||||||
|
raw_deck_colors = data.get("deck_colors", ["red", "blue", "gold"])
|
||||||
|
deck_colors = [c for c in raw_deck_colors if c in cls._ALLOWED_COLORS]
|
||||||
|
if not deck_colors:
|
||||||
|
deck_colors = ["red", "blue", "gold"]
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
flip_mode=data.get("flip_mode", "never"),
|
||||||
|
initial_flips=max(0, min(2, data.get("initial_flips", 2))),
|
||||||
|
knock_penalty=data.get("knock_penalty", False),
|
||||||
|
use_jokers=data.get("use_jokers", False),
|
||||||
|
lucky_swing=data.get("lucky_swing", False),
|
||||||
|
super_kings=data.get("super_kings", False),
|
||||||
|
ten_penny=data.get("ten_penny", False),
|
||||||
|
knock_bonus=data.get("knock_bonus", False),
|
||||||
|
underdog_bonus=data.get("underdog_bonus", False),
|
||||||
|
tied_shame=data.get("tied_shame", False),
|
||||||
|
blackjack=data.get("blackjack", False),
|
||||||
|
eagle_eye=data.get("eagle_eye", False),
|
||||||
|
wolfpack=data.get("wolfpack", False),
|
||||||
|
flip_as_action=data.get("flip_as_action", False),
|
||||||
|
four_of_a_kind=data.get("four_of_a_kind", False),
|
||||||
|
negative_pairs_keep_value=data.get("negative_pairs_keep_value", False),
|
||||||
|
one_eyed_jacks=data.get("one_eyed_jacks", False),
|
||||||
|
knock_early=data.get("knock_early", False),
|
||||||
|
deck_colors=deck_colors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Game:
|
class Game:
|
||||||
@ -543,6 +592,7 @@ class Game:
|
|||||||
players_with_final_turn: set = field(default_factory=set)
|
players_with_final_turn: set = field(default_factory=set)
|
||||||
initial_flips_done: set = field(default_factory=set)
|
initial_flips_done: set = field(default_factory=set)
|
||||||
options: GameOptions = field(default_factory=GameOptions)
|
options: GameOptions = field(default_factory=GameOptions)
|
||||||
|
dealer_idx: int = 0
|
||||||
|
|
||||||
# Event sourcing support
|
# Event sourcing support
|
||||||
game_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
game_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
@ -711,6 +761,9 @@ class Game:
|
|||||||
for i, player in enumerate(self.players):
|
for i, player in enumerate(self.players):
|
||||||
if player.id == player_id:
|
if player.id == player_id:
|
||||||
removed = self.players.pop(i)
|
removed = self.players.pop(i)
|
||||||
|
# Adjust dealer_idx if needed after removal
|
||||||
|
if self.players and self.dealer_idx >= len(self.players):
|
||||||
|
self.dealer_idx = 0
|
||||||
self._emit("player_left", player_id=player_id, reason=reason)
|
self._emit("player_left", player_id=player_id, reason=reason)
|
||||||
return removed
|
return removed
|
||||||
return None
|
return None
|
||||||
@ -772,26 +825,49 @@ class Game:
|
|||||||
|
|
||||||
def _options_to_dict(self) -> dict:
|
def _options_to_dict(self) -> dict:
|
||||||
"""Convert GameOptions to dictionary for event storage."""
|
"""Convert GameOptions to dictionary for event storage."""
|
||||||
return {
|
return asdict(self.options)
|
||||||
"flip_mode": self.options.flip_mode,
|
|
||||||
"initial_flips": self.options.initial_flips,
|
# Boolean rules that map directly to display names
|
||||||
"knock_penalty": self.options.knock_penalty,
|
_RULE_DISPLAY = [
|
||||||
"use_jokers": self.options.use_jokers,
|
("knock_penalty", "Knock Penalty"),
|
||||||
"lucky_swing": self.options.lucky_swing,
|
("lucky_swing", "Lucky Swing"),
|
||||||
"super_kings": self.options.super_kings,
|
("eagle_eye", "Eagle-Eye"),
|
||||||
"ten_penny": self.options.ten_penny,
|
("super_kings", "Super Kings"),
|
||||||
"knock_bonus": self.options.knock_bonus,
|
("ten_penny", "Ten Penny"),
|
||||||
"underdog_bonus": self.options.underdog_bonus,
|
("knock_bonus", "Knock Bonus"),
|
||||||
"tied_shame": self.options.tied_shame,
|
("underdog_bonus", "Underdog"),
|
||||||
"blackjack": self.options.blackjack,
|
("tied_shame", "Tied Shame"),
|
||||||
"eagle_eye": self.options.eagle_eye,
|
("blackjack", "Blackjack"),
|
||||||
"wolfpack": self.options.wolfpack,
|
("wolfpack", "Wolfpack"),
|
||||||
"flip_as_action": self.options.flip_as_action,
|
("flip_as_action", "Flip as Action"),
|
||||||
"four_of_a_kind": self.options.four_of_a_kind,
|
("four_of_a_kind", "Four of a Kind"),
|
||||||
"negative_pairs_keep_value": self.options.negative_pairs_keep_value,
|
("negative_pairs_keep_value", "Negative Pairs Keep Value"),
|
||||||
"one_eyed_jacks": self.options.one_eyed_jacks,
|
("one_eyed_jacks", "One-Eyed Jacks"),
|
||||||
"knock_early": self.options.knock_early,
|
("knock_early", "Early Knock"),
|
||||||
}
|
]
|
||||||
|
|
||||||
|
def _get_active_rules(self) -> list[str]:
|
||||||
|
"""Build list of active house rule display names."""
|
||||||
|
rules = []
|
||||||
|
if not self.options:
|
||||||
|
return rules
|
||||||
|
|
||||||
|
# Special: flip mode
|
||||||
|
if self.options.flip_mode == FlipMode.ALWAYS.value:
|
||||||
|
rules.append("Speed Golf")
|
||||||
|
elif self.options.flip_mode == FlipMode.ENDGAME.value:
|
||||||
|
rules.append("Endgame Flip")
|
||||||
|
|
||||||
|
# Special: jokers (only if not overridden by lucky_swing/eagle_eye)
|
||||||
|
if self.options.use_jokers and not self.options.lucky_swing and not self.options.eagle_eye:
|
||||||
|
rules.append("Jokers")
|
||||||
|
|
||||||
|
# Boolean rules
|
||||||
|
for attr, display_name in self._RULE_DISPLAY:
|
||||||
|
if getattr(self.options, attr):
|
||||||
|
rules.append(display_name)
|
||||||
|
|
||||||
|
return rules
|
||||||
|
|
||||||
def start_round(self) -> None:
|
def start_round(self) -> None:
|
||||||
"""
|
"""
|
||||||
@ -838,7 +914,12 @@ class Game:
|
|||||||
"suit": first_discard.suit.value,
|
"suit": first_discard.suit.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
self.current_player_index = 0
|
# Rotate dealer clockwise each round (first round: host deals)
|
||||||
|
if self.current_round > 1:
|
||||||
|
self.dealer_idx = (self.dealer_idx + 1) % len(self.players)
|
||||||
|
|
||||||
|
# First player is to the left of dealer (next in order)
|
||||||
|
self.current_player_index = (self.dealer_idx + 1) % len(self.players)
|
||||||
|
|
||||||
# Emit round_started event with deck seed and all dealt cards
|
# Emit round_started event with deck seed and all dealt cards
|
||||||
self._emit(
|
self._emit(
|
||||||
@ -847,6 +928,7 @@ class Game:
|
|||||||
deck_seed=self.deck.seed,
|
deck_seed=self.deck.seed,
|
||||||
dealt_cards=dealt_cards,
|
dealt_cards=dealt_cards,
|
||||||
first_discard=first_discard_dict,
|
first_discard=first_discard_dict,
|
||||||
|
current_player_idx=self.current_player_index,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Skip initial flip phase if 0 flips required
|
# Skip initial flip phase if 0 flips required
|
||||||
@ -1518,56 +1600,22 @@ class Game:
|
|||||||
|
|
||||||
discard_top = self.discard_top()
|
discard_top = self.discard_top()
|
||||||
|
|
||||||
# Build active rules list for display
|
active_rules = self._get_active_rules()
|
||||||
active_rules = []
|
|
||||||
if self.options:
|
|
||||||
if self.options.flip_mode == FlipMode.ALWAYS.value:
|
|
||||||
active_rules.append("Speed Golf")
|
|
||||||
elif self.options.flip_mode == FlipMode.ENDGAME.value:
|
|
||||||
active_rules.append("Endgame Flip")
|
|
||||||
if self.options.knock_penalty:
|
|
||||||
active_rules.append("Knock Penalty")
|
|
||||||
if self.options.use_jokers and not self.options.lucky_swing and not self.options.eagle_eye:
|
|
||||||
active_rules.append("Jokers")
|
|
||||||
if self.options.lucky_swing:
|
|
||||||
active_rules.append("Lucky Swing")
|
|
||||||
if self.options.eagle_eye:
|
|
||||||
active_rules.append("Eagle-Eye")
|
|
||||||
if self.options.super_kings:
|
|
||||||
active_rules.append("Super Kings")
|
|
||||||
if self.options.ten_penny:
|
|
||||||
active_rules.append("Ten Penny")
|
|
||||||
if self.options.knock_bonus:
|
|
||||||
active_rules.append("Knock Bonus")
|
|
||||||
if self.options.underdog_bonus:
|
|
||||||
active_rules.append("Underdog")
|
|
||||||
if self.options.tied_shame:
|
|
||||||
active_rules.append("Tied Shame")
|
|
||||||
if self.options.blackjack:
|
|
||||||
active_rules.append("Blackjack")
|
|
||||||
if self.options.wolfpack:
|
|
||||||
active_rules.append("Wolfpack")
|
|
||||||
# New house rules
|
|
||||||
if self.options.flip_as_action:
|
|
||||||
active_rules.append("Flip as Action")
|
|
||||||
if self.options.four_of_a_kind:
|
|
||||||
active_rules.append("Four of a Kind")
|
|
||||||
if self.options.negative_pairs_keep_value:
|
|
||||||
active_rules.append("Negative Pairs Keep Value")
|
|
||||||
if self.options.one_eyed_jacks:
|
|
||||||
active_rules.append("One-Eyed Jacks")
|
|
||||||
if self.options.knock_early:
|
|
||||||
active_rules.append("Early Knock")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"phase": self.phase.value,
|
"phase": self.phase.value,
|
||||||
"players": players_data,
|
"players": players_data,
|
||||||
"current_player_id": current.id if current else None,
|
"current_player_id": current.id if current else None,
|
||||||
|
"dealer_id": self.players[self.dealer_idx].id if self.players else None,
|
||||||
|
"dealer_idx": self.dealer_idx,
|
||||||
"discard_top": discard_top.to_dict(reveal=True) if discard_top else None,
|
"discard_top": discard_top.to_dict(reveal=True) if discard_top else None,
|
||||||
"deck_remaining": self.deck.cards_remaining() if self.deck else 0,
|
"deck_remaining": self.deck.cards_remaining() if self.deck else 0,
|
||||||
|
"deck_top_deck_id": self.deck.top_card_deck_id() if self.deck else None,
|
||||||
"current_round": self.current_round,
|
"current_round": self.current_round,
|
||||||
"total_rounds": self.num_rounds,
|
"total_rounds": self.num_rounds,
|
||||||
"has_drawn_card": self.drawn_card is not None,
|
"has_drawn_card": self.drawn_card is not None,
|
||||||
|
"drawn_card": self.drawn_card.to_dict(reveal=True) if self.drawn_card else None,
|
||||||
|
"drawn_player_id": current.id if current and self.drawn_card else None,
|
||||||
"can_discard": self.can_discard_drawn() if self.drawn_card else True,
|
"can_discard": self.can_discard_drawn() if self.drawn_card else True,
|
||||||
"waiting_for_initial_flip": (
|
"waiting_for_initial_flip": (
|
||||||
self.phase == GamePhase.INITIAL_FLIP and
|
self.phase == GamePhase.INITIAL_FLIP and
|
||||||
@ -1579,6 +1627,8 @@ class Game:
|
|||||||
"flip_is_optional": self.flip_is_optional,
|
"flip_is_optional": self.flip_is_optional,
|
||||||
"flip_as_action": self.options.flip_as_action,
|
"flip_as_action": self.options.flip_as_action,
|
||||||
"knock_early": self.options.knock_early,
|
"knock_early": self.options.knock_early,
|
||||||
|
"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,
|
||||||
|
"deck_colors": self.options.deck_colors,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -237,7 +237,7 @@ class RebuiltGameState:
|
|||||||
self.initial_flips_done = set()
|
self.initial_flips_done = set()
|
||||||
self.drawn_card = None
|
self.drawn_card = None
|
||||||
self.drawn_from_discard = False
|
self.drawn_from_discard = False
|
||||||
self.current_player_idx = 0
|
self.current_player_idx = event.data.get("current_player_idx", 0)
|
||||||
self.discard_pile = []
|
self.discard_pile = []
|
||||||
|
|
||||||
# Deal cards to players (all face-down)
|
# Deal cards to players (all face-down)
|
||||||
|
|||||||
683
server/test_ai_decisions.py
Normal file
683
server/test_ai_decisions.py
Normal file
@ -0,0 +1,683 @@
|
|||||||
|
"""
|
||||||
|
Test suite for AI decision sub-functions extracted from ai.py.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- _pair_improvement(): pair bonus / negative pair / spread bonus
|
||||||
|
- _point_gain(): face-up replacement, hidden card discount
|
||||||
|
- _reveal_and_bonus_score(): reveal scaling, comeback bonus
|
||||||
|
- _check_auto_take(): joker/king/one-eyed-jack/wolfpack auto-takes
|
||||||
|
- _has_good_swap_option(): good/bad swap previews
|
||||||
|
- calculate_swap_score(): go-out safety penalty
|
||||||
|
- should_take_discard(): integration of sub-decisions
|
||||||
|
- should_knock_early(): knock timing decisions
|
||||||
|
|
||||||
|
Run with: pytest test_ai_decisions.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from game import (
|
||||||
|
Card, Deck, Player, Game, GamePhase, GameOptions,
|
||||||
|
Suit, Rank, RANK_VALUES
|
||||||
|
)
|
||||||
|
from ai import GolfAI, CPUProfile, get_ai_card_value
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Helpers (shared with test_v3_features.py pattern)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def make_game(num_players=2, options=None, rounds=1):
|
||||||
|
"""Create a game with N players, dealt and in PLAYING phase."""
|
||||||
|
opts = options or GameOptions()
|
||||||
|
game = Game(num_rounds=rounds, options=opts)
|
||||||
|
for i in range(num_players):
|
||||||
|
game.add_player(Player(id=f"p{i}", name=f"Player {i}"))
|
||||||
|
game.start_round()
|
||||||
|
if game.phase == GamePhase.INITIAL_FLIP:
|
||||||
|
for p in game.players:
|
||||||
|
game.flip_initial_cards(p.id, [0, 1])
|
||||||
|
return game
|
||||||
|
|
||||||
|
|
||||||
|
def set_hand(player, ranks, face_up=True):
|
||||||
|
"""Set player hand to specific ranks (all hearts, all face-up by default)."""
|
||||||
|
player.cards = [
|
||||||
|
Card(Suit.HEARTS, rank, face_up=face_up) for rank in ranks
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def flip_all_but(player, keep_down=0):
|
||||||
|
"""Flip all cards face-up except `keep_down` cards (from the end)."""
|
||||||
|
for i, card in enumerate(player.cards):
|
||||||
|
card.face_up = i < len(player.cards) - keep_down
|
||||||
|
|
||||||
|
|
||||||
|
def make_profile(**overrides):
|
||||||
|
"""Create a CPUProfile with sensible defaults, overridable."""
|
||||||
|
defaults = dict(
|
||||||
|
name="TestBot",
|
||||||
|
style="balanced",
|
||||||
|
pair_hope=0.5,
|
||||||
|
aggression=0.5,
|
||||||
|
swap_threshold=4,
|
||||||
|
unpredictability=0.0, # Deterministic by default for tests
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return CPUProfile(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# _pair_improvement
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestPairImprovement:
|
||||||
|
"""Test pair bonus and spread bonus scoring."""
|
||||||
|
|
||||||
|
def test_positive_pair_bonus(self):
|
||||||
|
"""Pairing two positive cards should yield a positive score."""
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
# Position 0 has a 7, partner (pos 3) has a 7 face-up
|
||||||
|
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.SEVEN, Rank.FOUR, Rank.SIX])
|
||||||
|
profile = make_profile(pair_hope=0.5)
|
||||||
|
drawn_card = Card(Suit.DIAMONDS, Rank.SEVEN)
|
||||||
|
drawn_value = get_ai_card_value(drawn_card, game.options)
|
||||||
|
|
||||||
|
score = GolfAI._pair_improvement(
|
||||||
|
0, drawn_card, drawn_value, player, game.options, profile
|
||||||
|
)
|
||||||
|
# Pairing two 7s: bonus = (7+7) * pair_weight(1.5) = 21
|
||||||
|
assert score > 0
|
||||||
|
|
||||||
|
def test_negative_pair_penalty_standard(self):
|
||||||
|
"""Under standard rules, pairing negative cards is penalized."""
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
set_hand(player, [Rank.TWO, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.TWO, Rank.FOUR, Rank.SIX])
|
||||||
|
profile = make_profile(pair_hope=0.5)
|
||||||
|
drawn_card = Card(Suit.DIAMONDS, Rank.TWO)
|
||||||
|
drawn_value = get_ai_card_value(drawn_card, game.options)
|
||||||
|
|
||||||
|
score = GolfAI._pair_improvement(
|
||||||
|
0, drawn_card, drawn_value, player, game.options, profile
|
||||||
|
)
|
||||||
|
# Penalty for wasting negative cards under standard rules
|
||||||
|
assert score < 0
|
||||||
|
|
||||||
|
def test_eagle_eye_joker_pair_bonus(self):
|
||||||
|
"""Eagle Eye Joker pairs should get a large bonus."""
|
||||||
|
opts = GameOptions(eagle_eye=True)
|
||||||
|
game = make_game(options=opts)
|
||||||
|
player = game.players[0]
|
||||||
|
set_hand(player, [Rank.JOKER, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.JOKER, Rank.FOUR, Rank.SIX])
|
||||||
|
profile = make_profile(pair_hope=0.5)
|
||||||
|
drawn_card = Card(Suit.DIAMONDS, Rank.JOKER)
|
||||||
|
drawn_value = get_ai_card_value(drawn_card, opts)
|
||||||
|
|
||||||
|
score = GolfAI._pair_improvement(
|
||||||
|
0, drawn_card, drawn_value, player, opts, profile
|
||||||
|
)
|
||||||
|
# Eagle Eye Joker pairs = 8 * pair_weight
|
||||||
|
assert score > 0
|
||||||
|
|
||||||
|
def test_negative_pairs_keep_value(self):
|
||||||
|
"""With negative_pairs_keep_value, pairing 2s should be good."""
|
||||||
|
opts = GameOptions(negative_pairs_keep_value=True)
|
||||||
|
game = make_game(options=opts)
|
||||||
|
player = game.players[0]
|
||||||
|
set_hand(player, [Rank.TWO, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.TWO, Rank.FOUR, Rank.SIX])
|
||||||
|
profile = make_profile(pair_hope=0.5)
|
||||||
|
drawn_card = Card(Suit.DIAMONDS, Rank.TWO)
|
||||||
|
drawn_value = get_ai_card_value(drawn_card, opts)
|
||||||
|
|
||||||
|
score = GolfAI._pair_improvement(
|
||||||
|
0, drawn_card, drawn_value, player, opts, profile
|
||||||
|
)
|
||||||
|
assert score > 0
|
||||||
|
|
||||||
|
def test_spread_bonus_for_excellent_card(self):
|
||||||
|
"""Spreading an Ace across columns should get a spread bonus."""
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.EIGHT, Rank.FOUR, Rank.SIX])
|
||||||
|
profile = make_profile(pair_hope=0.0) # Spreader, not pair hunter
|
||||||
|
drawn_card = Card(Suit.DIAMONDS, Rank.ACE)
|
||||||
|
drawn_value = get_ai_card_value(drawn_card, game.options)
|
||||||
|
|
||||||
|
score = GolfAI._pair_improvement(
|
||||||
|
0, drawn_card, drawn_value, player, game.options, profile
|
||||||
|
)
|
||||||
|
# spread_weight = 2.0, bonus = 2.0 * 0.5 = 1.0
|
||||||
|
assert score == pytest.approx(1.0)
|
||||||
|
|
||||||
|
def test_no_spread_bonus_for_bad_card(self):
|
||||||
|
"""No spread bonus for high-value cards."""
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.EIGHT, Rank.FOUR, Rank.SIX])
|
||||||
|
profile = make_profile(pair_hope=0.0)
|
||||||
|
drawn_card = Card(Suit.DIAMONDS, Rank.NINE)
|
||||||
|
drawn_value = get_ai_card_value(drawn_card, game.options)
|
||||||
|
|
||||||
|
score = GolfAI._pair_improvement(
|
||||||
|
0, drawn_card, drawn_value, player, game.options, profile
|
||||||
|
)
|
||||||
|
assert score == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# _point_gain
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestPointGain:
|
||||||
|
"""Test point gain from replacing cards."""
|
||||||
|
|
||||||
|
def test_replace_high_with_low(self):
|
||||||
|
"""Replacing a face-up 10 with a 3 should give positive point gain."""
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
set_hand(player, [Rank.TEN, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.EIGHT, Rank.FOUR, Rank.SIX])
|
||||||
|
profile = make_profile()
|
||||||
|
drawn_card = Card(Suit.DIAMONDS, Rank.THREE)
|
||||||
|
drawn_value = get_ai_card_value(drawn_card, game.options)
|
||||||
|
|
||||||
|
gain = GolfAI._point_gain(
|
||||||
|
0, drawn_card, drawn_value, player, game.options, profile
|
||||||
|
)
|
||||||
|
# 10 - 3 = 7
|
||||||
|
assert gain == pytest.approx(7.0)
|
||||||
|
|
||||||
|
def test_breaking_pair_is_bad(self):
|
||||||
|
"""Breaking an existing pair should produce a negative point gain."""
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
# Column 0: positions 0 and 3 are both 5s (paired)
|
||||||
|
set_hand(player, [Rank.FIVE, Rank.THREE, Rank.EIGHT,
|
||||||
|
Rank.FIVE, Rank.FOUR, Rank.SIX])
|
||||||
|
profile = make_profile()
|
||||||
|
drawn_card = Card(Suit.DIAMONDS, Rank.FOUR)
|
||||||
|
drawn_value = get_ai_card_value(drawn_card, game.options)
|
||||||
|
|
||||||
|
gain = GolfAI._point_gain(
|
||||||
|
0, drawn_card, drawn_value, player, game.options, profile
|
||||||
|
)
|
||||||
|
# Breaking pair: old_column=0, new_column=4+5=9, gain=0-9=-9
|
||||||
|
assert gain < 0
|
||||||
|
|
||||||
|
def test_creating_pair(self):
|
||||||
|
"""Creating a new pair should produce a positive point gain."""
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
# Position 0 has 9, partner (pos 3) has 5. Draw a 5 to pair with pos 3.
|
||||||
|
set_hand(player, [Rank.NINE, Rank.THREE, Rank.EIGHT,
|
||||||
|
Rank.FIVE, Rank.FOUR, Rank.SIX])
|
||||||
|
profile = make_profile()
|
||||||
|
drawn_card = Card(Suit.DIAMONDS, Rank.FIVE)
|
||||||
|
drawn_value = get_ai_card_value(drawn_card, game.options)
|
||||||
|
|
||||||
|
gain = GolfAI._point_gain(
|
||||||
|
0, drawn_card, drawn_value, player, game.options, profile
|
||||||
|
)
|
||||||
|
# Creating pair: old_column=9+5=14, new_column=0, gain=14
|
||||||
|
assert gain > 0
|
||||||
|
|
||||||
|
def test_hidden_card_discount(self):
|
||||||
|
"""Hidden card replacement should use expected value with discount."""
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
set_hand(player, [Rank.TEN, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.EIGHT, Rank.FOUR, Rank.SIX])
|
||||||
|
player.cards[0].face_up = False # Position 0 is hidden
|
||||||
|
profile = make_profile(swap_threshold=4)
|
||||||
|
drawn_card = Card(Suit.DIAMONDS, Rank.ACE)
|
||||||
|
drawn_value = get_ai_card_value(drawn_card, game.options)
|
||||||
|
|
||||||
|
gain = GolfAI._point_gain(
|
||||||
|
0, drawn_card, drawn_value, player, game.options, profile
|
||||||
|
)
|
||||||
|
# expected_hidden=4.5, drawn_value=1, gain=(4.5-1)*discount
|
||||||
|
# discount = 0.5 + (4/16) = 0.75
|
||||||
|
assert gain == pytest.approx((4.5 - 1) * 0.75)
|
||||||
|
|
||||||
|
def test_hidden_card_negative_pair_no_bonus(self):
|
||||||
|
"""No point gain bonus when creating a negative pair on hidden card."""
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
set_hand(player, [Rank.TWO, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.TWO, Rank.FOUR, Rank.SIX])
|
||||||
|
player.cards[0].face_up = False # Position 0 hidden
|
||||||
|
# Partner (pos 3) is face-up TWO
|
||||||
|
profile = make_profile()
|
||||||
|
drawn_card = Card(Suit.DIAMONDS, Rank.TWO)
|
||||||
|
drawn_value = get_ai_card_value(drawn_card, game.options)
|
||||||
|
|
||||||
|
gain = GolfAI._point_gain(
|
||||||
|
0, drawn_card, drawn_value, player, game.options, profile
|
||||||
|
)
|
||||||
|
# Creates negative pair → returns 0.0
|
||||||
|
assert gain == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# _reveal_and_bonus_score
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestRevealAndBonusScore:
|
||||||
|
"""Test reveal bonus, comeback bonus, and strategic bonuses."""
|
||||||
|
|
||||||
|
def test_reveal_bonus_scales_by_quality(self):
|
||||||
|
"""Better cards get bigger reveal bonuses."""
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
set_hand(player, [Rank.TEN, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.EIGHT, Rank.FOUR, Rank.SIX])
|
||||||
|
player.cards[0].face_up = False
|
||||||
|
player.cards[3].face_up = False
|
||||||
|
profile = make_profile(aggression=0.5)
|
||||||
|
|
||||||
|
# Excellent card (value 0)
|
||||||
|
king = Card(Suit.DIAMONDS, Rank.KING)
|
||||||
|
king_score = GolfAI._reveal_and_bonus_score(
|
||||||
|
0, king, 0, player, game.options, game, profile
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bad card (value 8)
|
||||||
|
eight = Card(Suit.DIAMONDS, Rank.EIGHT)
|
||||||
|
eight_score = GolfAI._reveal_and_bonus_score(
|
||||||
|
0, eight, 8, player, game.options, game, profile
|
||||||
|
)
|
||||||
|
|
||||||
|
assert king_score > eight_score
|
||||||
|
|
||||||
|
def test_comeback_bonus_when_behind(self):
|
||||||
|
"""Player behind in standings should get a comeback bonus."""
|
||||||
|
opts = GameOptions()
|
||||||
|
game = make_game(options=opts, rounds=5)
|
||||||
|
player = game.players[0]
|
||||||
|
player.total_score = 40 # Behind
|
||||||
|
game.players[1].total_score = 10 # Leader
|
||||||
|
game.current_round = 4 # Late game
|
||||||
|
|
||||||
|
set_hand(player, [Rank.TEN, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.EIGHT, Rank.FOUR, Rank.SIX])
|
||||||
|
player.cards[0].face_up = False
|
||||||
|
profile = make_profile(aggression=0.8)
|
||||||
|
|
||||||
|
drawn_card = Card(Suit.DIAMONDS, Rank.THREE)
|
||||||
|
drawn_value = get_ai_card_value(drawn_card, game.options)
|
||||||
|
|
||||||
|
score = GolfAI._reveal_and_bonus_score(
|
||||||
|
0, drawn_card, drawn_value, player, game.options, game, profile
|
||||||
|
)
|
||||||
|
# Should include comeback bonus (standings_pressure > 0.3, hidden card, value < 8)
|
||||||
|
assert score > 0
|
||||||
|
|
||||||
|
def test_no_comeback_for_high_card(self):
|
||||||
|
"""No comeback bonus for cards with value >= 8."""
|
||||||
|
opts = GameOptions()
|
||||||
|
game = make_game(options=opts, rounds=5)
|
||||||
|
player = game.players[0]
|
||||||
|
player.total_score = 40
|
||||||
|
game.players[1].total_score = 10
|
||||||
|
game.current_round = 4
|
||||||
|
|
||||||
|
set_hand(player, [Rank.TEN, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.EIGHT, Rank.FOUR, Rank.SIX])
|
||||||
|
player.cards[0].face_up = False
|
||||||
|
profile = make_profile(aggression=0.8)
|
||||||
|
|
||||||
|
# Draw a Queen (value 10 >= 8)
|
||||||
|
drawn_card = Card(Suit.DIAMONDS, Rank.QUEEN)
|
||||||
|
drawn_value = get_ai_card_value(drawn_card, game.options)
|
||||||
|
|
||||||
|
score_with_queen = GolfAI._reveal_and_bonus_score(
|
||||||
|
0, drawn_card, drawn_value, player, game.options, game, profile
|
||||||
|
)
|
||||||
|
# Bad cards get no reveal bonus and no comeback bonus
|
||||||
|
# So score should be 0 or very small (only future pair potential)
|
||||||
|
assert score_with_queen < 2.0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# _check_auto_take
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestCheckAutoTake:
|
||||||
|
"""Test auto-take rules for discard pile decisions."""
|
||||||
|
|
||||||
|
def test_always_take_joker(self):
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.EIGHT, Rank.FOUR, Rank.SIX])
|
||||||
|
profile = make_profile()
|
||||||
|
joker = Card(Suit.HEARTS, Rank.JOKER)
|
||||||
|
value = get_ai_card_value(joker, game.options)
|
||||||
|
|
||||||
|
result = GolfAI._check_auto_take(joker, value, player, game.options, profile)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_always_take_king(self):
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.EIGHT, Rank.FOUR, Rank.SIX])
|
||||||
|
profile = make_profile()
|
||||||
|
king = Card(Suit.HEARTS, Rank.KING)
|
||||||
|
value = get_ai_card_value(king, game.options)
|
||||||
|
|
||||||
|
result = GolfAI._check_auto_take(king, value, player, game.options, profile)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_one_eyed_jack_auto_take(self):
|
||||||
|
opts = GameOptions(one_eyed_jacks=True)
|
||||||
|
game = make_game(options=opts)
|
||||||
|
player = game.players[0]
|
||||||
|
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.EIGHT, Rank.FOUR, Rank.SIX])
|
||||||
|
profile = make_profile()
|
||||||
|
|
||||||
|
# J♥ is one-eyed
|
||||||
|
jack_hearts = Card(Suit.HEARTS, Rank.JACK)
|
||||||
|
value = get_ai_card_value(jack_hearts, opts)
|
||||||
|
result = GolfAI._check_auto_take(jack_hearts, value, player, opts, profile)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
# J♦ is NOT one-eyed
|
||||||
|
jack_diamonds = Card(Suit.DIAMONDS, Rank.JACK)
|
||||||
|
value = get_ai_card_value(jack_diamonds, opts)
|
||||||
|
result = GolfAI._check_auto_take(jack_diamonds, value, player, opts, profile)
|
||||||
|
assert result is None # No auto-take
|
||||||
|
|
||||||
|
def test_wolfpack_jack_pursuit(self):
|
||||||
|
opts = GameOptions(wolfpack=True)
|
||||||
|
game = make_game(options=opts)
|
||||||
|
player = game.players[0]
|
||||||
|
# Player has 2 visible Jacks
|
||||||
|
set_hand(player, [Rank.JACK, Rank.JACK, Rank.FIVE,
|
||||||
|
Rank.EIGHT, Rank.FOUR, Rank.SIX])
|
||||||
|
profile = make_profile(aggression=0.8)
|
||||||
|
|
||||||
|
jack = Card(Suit.DIAMONDS, Rank.JACK)
|
||||||
|
value = get_ai_card_value(jack, opts)
|
||||||
|
result = GolfAI._check_auto_take(jack, value, player, opts, profile)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_wolfpack_jack_not_aggressive_enough(self):
|
||||||
|
opts = GameOptions(wolfpack=True)
|
||||||
|
game = make_game(options=opts)
|
||||||
|
player = game.players[0]
|
||||||
|
set_hand(player, [Rank.JACK, Rank.JACK, Rank.FIVE,
|
||||||
|
Rank.EIGHT, Rank.FOUR, Rank.SIX])
|
||||||
|
profile = make_profile(aggression=0.3) # Too passive
|
||||||
|
|
||||||
|
jack = Card(Suit.DIAMONDS, Rank.JACK)
|
||||||
|
value = get_ai_card_value(jack, opts)
|
||||||
|
result = GolfAI._check_auto_take(jack, value, player, opts, profile)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_ten_penny_auto_take(self):
|
||||||
|
opts = GameOptions(ten_penny=True)
|
||||||
|
game = make_game(options=opts)
|
||||||
|
player = game.players[0]
|
||||||
|
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.EIGHT, Rank.FOUR, Rank.SIX])
|
||||||
|
profile = make_profile()
|
||||||
|
|
||||||
|
ten = Card(Suit.HEARTS, Rank.TEN)
|
||||||
|
value = get_ai_card_value(ten, opts)
|
||||||
|
result = GolfAI._check_auto_take(ten, value, player, opts, profile)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_pair_potential_auto_take(self):
|
||||||
|
"""Take card that can pair with a visible card."""
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
# Position 0 has a 7 face-up, partner (pos 3) is face-down
|
||||||
|
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.EIGHT, Rank.FOUR, Rank.SIX])
|
||||||
|
player.cards[3].face_up = False
|
||||||
|
profile = make_profile()
|
||||||
|
|
||||||
|
seven = Card(Suit.DIAMONDS, Rank.SEVEN)
|
||||||
|
value = get_ai_card_value(seven, game.options)
|
||||||
|
result = GolfAI._check_auto_take(seven, value, player, game.options, profile)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_no_auto_take_for_mediocre_card(self):
|
||||||
|
"""A random 8 should not be auto-taken."""
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.FOUR, Rank.FOUR, Rank.SIX])
|
||||||
|
profile = make_profile()
|
||||||
|
|
||||||
|
eight = Card(Suit.HEARTS, Rank.EIGHT)
|
||||||
|
value = get_ai_card_value(eight, game.options)
|
||||||
|
result = GolfAI._check_auto_take(eight, value, player, game.options, profile)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# _has_good_swap_option
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestHasGoodSwapOption:
|
||||||
|
|
||||||
|
def test_good_swap_available(self):
|
||||||
|
"""With high cards in hand and a low card to swap, should return True."""
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
set_hand(player, [Rank.TEN, Rank.QUEEN, Rank.NINE,
|
||||||
|
Rank.EIGHT, Rank.JACK, Rank.SEVEN])
|
||||||
|
profile = make_profile()
|
||||||
|
drawn_card = Card(Suit.DIAMONDS, Rank.ACE)
|
||||||
|
drawn_value = get_ai_card_value(drawn_card, game.options)
|
||||||
|
|
||||||
|
result = GolfAI._has_good_swap_option(
|
||||||
|
drawn_card, drawn_value, player, game.options, game, profile
|
||||||
|
)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_no_good_swap(self):
|
||||||
|
"""With all low cards in hand and a high card, should return False."""
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
set_hand(player, [Rank.ACE, Rank.TWO, Rank.KING,
|
||||||
|
Rank.ACE, Rank.TWO, Rank.KING])
|
||||||
|
profile = make_profile()
|
||||||
|
drawn_card = Card(Suit.DIAMONDS, Rank.QUEEN)
|
||||||
|
drawn_value = get_ai_card_value(drawn_card, game.options)
|
||||||
|
|
||||||
|
result = GolfAI._has_good_swap_option(
|
||||||
|
drawn_card, drawn_value, player, game.options, game, profile
|
||||||
|
)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# calculate_swap_score (integration: go-out safety)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestCalculateSwapScore:
|
||||||
|
|
||||||
|
def test_go_out_safety_penalty(self):
|
||||||
|
"""Going out with a bad score should apply a -100 penalty."""
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
# All face-up except position 5, hand is all high cards
|
||||||
|
set_hand(player, [Rank.TEN, Rank.QUEEN, Rank.NINE,
|
||||||
|
Rank.EIGHT, Rank.JACK, Rank.SEVEN])
|
||||||
|
player.cards[5].face_up = False # Only pos 5 is hidden
|
||||||
|
profile = make_profile(aggression=0.0) # Conservative
|
||||||
|
|
||||||
|
# Draw a Queen (bad card) - swapping into the only hidden pos would go out
|
||||||
|
drawn_card = Card(Suit.DIAMONDS, Rank.QUEEN)
|
||||||
|
drawn_value = get_ai_card_value(drawn_card, game.options)
|
||||||
|
|
||||||
|
score = GolfAI.calculate_swap_score(
|
||||||
|
5, drawn_card, drawn_value, player, game.options, game, profile
|
||||||
|
)
|
||||||
|
# Should be heavily penalized (projected score would be terrible)
|
||||||
|
assert score < -50
|
||||||
|
|
||||||
|
def test_components_sum_correctly(self):
|
||||||
|
"""Verify calculate_swap_score equals sum of sub-functions plus go-out check."""
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
set_hand(player, [Rank.TEN, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.EIGHT, Rank.FOUR, Rank.SIX])
|
||||||
|
profile = make_profile()
|
||||||
|
|
||||||
|
drawn_card = Card(Suit.DIAMONDS, Rank.ACE)
|
||||||
|
drawn_value = get_ai_card_value(drawn_card, game.options)
|
||||||
|
|
||||||
|
total = GolfAI.calculate_swap_score(
|
||||||
|
0, drawn_card, drawn_value, player, game.options, game, profile
|
||||||
|
)
|
||||||
|
pair = GolfAI._pair_improvement(
|
||||||
|
0, drawn_card, drawn_value, player, game.options, profile
|
||||||
|
)
|
||||||
|
point = GolfAI._point_gain(
|
||||||
|
0, drawn_card, drawn_value, player, game.options, profile
|
||||||
|
)
|
||||||
|
bonus = GolfAI._reveal_and_bonus_score(
|
||||||
|
0, drawn_card, drawn_value, player, game.options, game, profile
|
||||||
|
)
|
||||||
|
|
||||||
|
# No go-out safety penalty here (not the last hidden card)
|
||||||
|
assert total == pytest.approx(pair + point + bonus)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# should_take_discard (integration)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestShouldTakeDiscard:
|
||||||
|
|
||||||
|
def test_take_joker(self):
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
set_hand(player, [Rank.SEVEN, Rank.THREE, Rank.FIVE,
|
||||||
|
Rank.EIGHT, Rank.FOUR, Rank.SIX])
|
||||||
|
profile = make_profile()
|
||||||
|
joker = Card(Suit.HEARTS, Rank.JOKER)
|
||||||
|
|
||||||
|
result = GolfAI.should_take_discard(joker, player, profile, game)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_pass_on_none(self):
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
profile = make_profile()
|
||||||
|
|
||||||
|
result = GolfAI.should_take_discard(None, player, profile, game)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_go_out_safeguard_rejects_bad_card(self):
|
||||||
|
"""With 1 hidden card and bad projected score, should reject mediocre discard."""
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
# All face-up except position 5, hand is all high cards
|
||||||
|
set_hand(player, [Rank.TEN, Rank.QUEEN, Rank.NINE,
|
||||||
|
Rank.EIGHT, Rank.JACK, Rank.SEVEN])
|
||||||
|
player.cards[5].face_up = False
|
||||||
|
profile = make_profile(aggression=0.0)
|
||||||
|
|
||||||
|
# A 6 is mediocre - go-out check should reject since projected score is terrible
|
||||||
|
six = Card(Suit.HEARTS, Rank.SIX)
|
||||||
|
result = GolfAI.should_take_discard(six, player, profile, game)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# should_knock_early
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestShouldKnockEarly:
|
||||||
|
|
||||||
|
def test_requires_knock_early_option(self):
|
||||||
|
game = make_game()
|
||||||
|
player = game.players[0]
|
||||||
|
flip_all_but(player, keep_down=1)
|
||||||
|
profile = make_profile(aggression=1.0)
|
||||||
|
|
||||||
|
result = GolfAI.should_knock_early(game, player, profile)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_no_knock_with_many_hidden(self):
|
||||||
|
"""Should not knock with more than 2 face-down cards."""
|
||||||
|
opts = GameOptions(knock_early=True)
|
||||||
|
game = make_game(options=opts)
|
||||||
|
player = game.players[0]
|
||||||
|
flip_all_but(player, keep_down=3)
|
||||||
|
profile = make_profile(aggression=1.0)
|
||||||
|
|
||||||
|
result = GolfAI.should_knock_early(game, player, profile)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_no_knock_all_face_up(self):
|
||||||
|
"""Should not knock with 0 face-down cards."""
|
||||||
|
opts = GameOptions(knock_early=True)
|
||||||
|
game = make_game(options=opts)
|
||||||
|
player = game.players[0]
|
||||||
|
for card in player.cards:
|
||||||
|
card.face_up = True
|
||||||
|
profile = make_profile(aggression=1.0)
|
||||||
|
|
||||||
|
result = GolfAI.should_knock_early(game, player, profile)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_low_aggression_unlikely_to_knock(self):
|
||||||
|
"""Conservative players should almost never knock early."""
|
||||||
|
opts = GameOptions(knock_early=True, initial_flips=0)
|
||||||
|
game = make_game(options=opts)
|
||||||
|
player = game.players[0]
|
||||||
|
# Good hand but passive player
|
||||||
|
set_hand(player, [Rank.ACE, Rank.TWO, Rank.KING,
|
||||||
|
Rank.ACE, Rank.TWO, Rank.THREE])
|
||||||
|
player.cards[5].face_up = False # 1 hidden
|
||||||
|
profile = make_profile(aggression=0.0)
|
||||||
|
|
||||||
|
# With aggression=0.0, knock_chance = 0.0 → never knocks
|
||||||
|
result = GolfAI.should_knock_early(game, player, profile)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_high_projected_score_never_knocks(self):
|
||||||
|
"""Projected score >9 with normal opponents should always reject."""
|
||||||
|
opts = GameOptions(knock_early=True, initial_flips=0)
|
||||||
|
game = make_game(options=opts)
|
||||||
|
player = game.players[0]
|
||||||
|
# Mediocre hand: visible 8+7+6 = 21, plus 1 hidden (~4.5) → ~25.5
|
||||||
|
set_hand(player, [Rank.EIGHT, Rank.SEVEN, Rank.SIX,
|
||||||
|
Rank.FOUR, Rank.FIVE, Rank.NINE])
|
||||||
|
player.cards[5].face_up = False # 1 hidden
|
||||||
|
profile = make_profile(aggression=1.0)
|
||||||
|
|
||||||
|
# max_acceptable = 5 + 4 = 9, projected ~25.5 >> 9
|
||||||
|
for _ in range(50):
|
||||||
|
result = GolfAI.should_knock_early(game, player, profile)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_low_aggression_mediocre_hand_never_knocks(self):
|
||||||
|
"""Low aggression with a middling hand should never knock."""
|
||||||
|
opts = GameOptions(knock_early=True, initial_flips=0)
|
||||||
|
game = make_game(options=opts)
|
||||||
|
player = game.players[0]
|
||||||
|
# Decent but not great: visible 1+2+5 = 8, plus 1 hidden (~4.5) → ~12.5
|
||||||
|
set_hand(player, [Rank.ACE, Rank.TWO, Rank.FIVE,
|
||||||
|
Rank.THREE, Rank.FOUR, Rank.SIX])
|
||||||
|
player.cards[5].face_up = False # 1 hidden
|
||||||
|
profile = make_profile(aggression=0.2)
|
||||||
|
|
||||||
|
# max_acceptable = 5 + 0 = 5, projected ~12.5 >> 5
|
||||||
|
for _ in range(50):
|
||||||
|
result = GolfAI.should_knock_early(game, player, profile)
|
||||||
|
assert result is False
|
||||||
@ -138,7 +138,7 @@ def run_game_with_options(options: GameOptions, num_players: int = 4) -> tuple[l
|
|||||||
return [], 0, f"Exception: {str(e)}"
|
return [], 0, f"Exception: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
def test_rule_config(name: str, options: GameOptions, num_games: int = 50) -> RuleTestResult:
|
def run_rule_config(name: str, options: GameOptions, num_games: int = 50) -> RuleTestResult:
|
||||||
"""Test a specific rule configuration."""
|
"""Test a specific rule configuration."""
|
||||||
|
|
||||||
all_scores = []
|
all_scores = []
|
||||||
@ -516,7 +516,7 @@ def main():
|
|||||||
|
|
||||||
for i, (name, options) in enumerate(configs):
|
for i, (name, options) in enumerate(configs):
|
||||||
print(f"[{i+1}/{len(configs)}] Testing: {name}...")
|
print(f"[{i+1}/{len(configs)}] Testing: {name}...")
|
||||||
result = test_rule_config(name, options, num_games)
|
result = run_rule_config(name, options, num_games)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
|
|
||||||
# Quick status
|
# Quick status
|
||||||
|
|||||||
@ -125,8 +125,9 @@ class TestEventEmission:
|
|||||||
game, collector = create_test_game(num_players=2)
|
game, collector = create_test_game(num_players=2)
|
||||||
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
|
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
|
||||||
|
|
||||||
|
current = game.current_player()
|
||||||
initial_count = len(collector.events)
|
initial_count = len(collector.events)
|
||||||
card = game.draw_card("p1", "deck")
|
card = game.draw_card(current.id, "deck")
|
||||||
|
|
||||||
assert card is not None
|
assert card is not None
|
||||||
new_events = collector.events[initial_count:]
|
new_events = collector.events[initial_count:]
|
||||||
@ -134,7 +135,7 @@ class TestEventEmission:
|
|||||||
|
|
||||||
assert len(draw_events) == 1
|
assert len(draw_events) == 1
|
||||||
event = draw_events[0]
|
event = draw_events[0]
|
||||||
assert event.player_id == "p1"
|
assert event.player_id == current.id
|
||||||
assert event.data["source"] == "deck"
|
assert event.data["source"] == "deck"
|
||||||
assert event.data["card"]["rank"] == card.rank.value
|
assert event.data["card"]["rank"] == card.rank.value
|
||||||
|
|
||||||
@ -142,10 +143,12 @@ class TestEventEmission:
|
|||||||
"""Swapping a card should emit card_swapped event."""
|
"""Swapping a card should emit card_swapped event."""
|
||||||
game, collector = create_test_game(num_players=2)
|
game, collector = create_test_game(num_players=2)
|
||||||
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
|
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
|
||||||
game.draw_card("p1", "deck")
|
|
||||||
|
current = game.current_player()
|
||||||
|
game.draw_card(current.id, "deck")
|
||||||
|
|
||||||
initial_count = len(collector.events)
|
initial_count = len(collector.events)
|
||||||
old_card = game.swap_card("p1", 0)
|
old_card = game.swap_card(current.id, 0)
|
||||||
|
|
||||||
assert old_card is not None
|
assert old_card is not None
|
||||||
new_events = collector.events[initial_count:]
|
new_events = collector.events[initial_count:]
|
||||||
@ -153,24 +156,26 @@ class TestEventEmission:
|
|||||||
|
|
||||||
assert len(swap_events) == 1
|
assert len(swap_events) == 1
|
||||||
event = swap_events[0]
|
event = swap_events[0]
|
||||||
assert event.player_id == "p1"
|
assert event.player_id == current.id
|
||||||
assert event.data["position"] == 0
|
assert event.data["position"] == 0
|
||||||
|
|
||||||
def test_discard_card_event(self):
|
def test_discard_card_event(self):
|
||||||
"""Discarding drawn card should emit card_discarded event."""
|
"""Discarding drawn card should emit card_discarded event."""
|
||||||
game, collector = create_test_game(num_players=2)
|
game, collector = create_test_game(num_players=2)
|
||||||
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
|
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
|
||||||
drawn = game.draw_card("p1", "deck")
|
|
||||||
|
current = game.current_player()
|
||||||
|
drawn = game.draw_card(current.id, "deck")
|
||||||
|
|
||||||
initial_count = len(collector.events)
|
initial_count = len(collector.events)
|
||||||
game.discard_drawn("p1")
|
game.discard_drawn(current.id)
|
||||||
|
|
||||||
new_events = collector.events[initial_count:]
|
new_events = collector.events[initial_count:]
|
||||||
discard_events = [e for e in new_events if e.event_type == EventType.CARD_DISCARDED]
|
discard_events = [e for e in new_events if e.event_type == EventType.CARD_DISCARDED]
|
||||||
|
|
||||||
assert len(discard_events) == 1
|
assert len(discard_events) == 1
|
||||||
event = discard_events[0]
|
event = discard_events[0]
|
||||||
assert event.player_id == "p1"
|
assert event.player_id == current.id
|
||||||
assert event.data["card"]["rank"] == drawn.rank.value
|
assert event.data["card"]["rank"] == drawn.rank.value
|
||||||
|
|
||||||
|
|
||||||
@ -383,13 +388,14 @@ class TestFullGameReplay:
|
|||||||
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
|
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
|
||||||
|
|
||||||
# Do a swap
|
# Do a swap
|
||||||
drawn = game.draw_card("p1", "deck")
|
current = game.current_player()
|
||||||
old_card = game.get_player("p1").cards[0]
|
drawn = game.draw_card(current.id, "deck")
|
||||||
game.swap_card("p1", 0)
|
old_card = game.get_player(current.id).cards[0]
|
||||||
|
game.swap_card(current.id, 0)
|
||||||
|
|
||||||
# Rebuild and verify
|
# Rebuild and verify
|
||||||
state = rebuild_state(collector.events)
|
state = rebuild_state(collector.events)
|
||||||
rebuilt_player = state.get_player("p1")
|
rebuilt_player = state.get_player(current.id)
|
||||||
|
|
||||||
# The swapped card should be in the hand
|
# The swapped card should be in the hand
|
||||||
assert rebuilt_player.cards[0].rank == drawn.rank.value
|
assert rebuilt_player.cards[0].rank == drawn.rank.value
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user