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:
adlee-was-taken 2026-02-14 09:56:59 -05:00
parent 9bb9d1e397
commit 13ab5b9017
6 changed files with 1135 additions and 344 deletions

View File

@ -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,34 +796,30 @@ 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
phase = get_game_phase(game)
base_threshold = {'early': 2, 'mid': 3, 'late': 4}.get(phase, 2)
if discard_value <= base_threshold: @staticmethod
ai_log(f" >> TAKE: low card (value {discard_value} <= {base_threshold} threshold for {phase} game)") def _has_good_swap_option(
return True discard_card: Card,
discard_value: int,
# For marginal cards (not auto-take), preview swap scores before committing. player: Player,
# Taking from discard FORCES a swap - don't take if no good swap exists. options: GameOptions,
def has_good_swap_option() -> bool: game: Game,
profile: CPUProfile
) -> bool:
"""Preview swap scores to check if any position is worth swapping into.""" """Preview swap scores to check if any position is worth swapping into."""
for pos in range(6): for pos in range(6):
score = GolfAI.calculate_swap_score( score = GolfAI.calculate_swap_score(
@ -854,21 +829,62 @@ class GolfAI:
return True return True
return False 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)
base_threshold = {'early': 2, 'mid': 3, 'late': 4}.get(phase, 2)
if discard_value <= base_threshold:
ai_log(f" >> TAKE: low card (value {discard_value} <= {base_threshold} threshold for {phase} game)")
return True
# For marginal cards, preview swap scores before committing.
# Taking from discard FORCES a swap - don't take if no good swap exists.
# 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

View File

@ -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,
} }

View File

@ -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
View 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

View File

@ -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

View File

@ -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