From 13ab5b9017ce211d7704a42655c82ba77d624008 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 14 Feb 2026 09:56:59 -0500 Subject: [PATCH] Tune knock-early thresholds and fix failing test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/ai.py | 570 ++++++++++++++----------- server/game.py | 190 ++++++--- server/models/game_state.py | 2 +- server/test_ai_decisions.py | 683 ++++++++++++++++++++++++++++++ server/test_house_rules.py | 4 +- server/tests/test_event_replay.py | 30 +- 6 files changed, 1135 insertions(+), 344 deletions(-) create mode 100644 server/test_ai_decisions.py diff --git a/server/ai.py b/server/ai.py index c7e9587..22d5a12 100644 --- a/server/ai.py +++ b/server/ai.py @@ -747,42 +747,21 @@ class GolfAI: return random.choice(options) @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) - - # 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]) + def _check_auto_take( + discard_card: Card, + discard_value: int, + player: Player, + options: GameOptions, + profile: CPUProfile + ) -> Optional[bool]: + """Check auto-take rules for the discard card. + 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) if discard_card.rank == Rank.JOKER: - # Eagle Eye: If we have a visible Joker, take to pair them (doubled negative!) if options.eagle_eye: for card in player.cards: 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: rank_count = sum(1 for c in player.cards if c.face_up and c.rank == discard_card.rank) 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)") return True # 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: for i, card in enumerate(player.cards): pair_pos = (i + 3) % 6 if i < 3 else i - 3 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: ai_log(f" >> TAKE: can pair with visible {card.rank.value} at pos {i}") return True - # Take low cards (using house rule adjusted values) - # Threshold adjusts by game phase - early game be picky, late game less so + return None # No auto-take triggered + + @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) 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)") 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. - 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 pressure = get_end_game_pressure(player, game) # 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: - # 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 = min(pressure_threshold, 7) # Cap at 7 if discard_value <= pressure_threshold: - # Only take if we have hidden cards that could be worse if count_hidden(player) > 0: - # CRITICAL: Verify there's actually a good swap position - if has_good_swap_option(): + if GolfAI._has_good_swap_option(discard_card, discard_value, player, options, game, profile): ai_log(f" >> TAKE: pressure={pressure:.2f}, threshold={pressure_threshold}") return True else: @@ -881,11 +897,8 @@ class GolfAI: worst_visible = max(worst_visible, get_ai_card_value(card, options)) 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): - # CRITICAL: Verify there's actually a good swap position - if has_good_swap_option(): + if GolfAI._has_good_swap_option(discard_card, discard_value, player, options, game, profile): ai_log(f" >> TAKE: have worse visible card ({worst_visible})") return True else: @@ -894,6 +907,222 @@ class GolfAI: ai_log(f" >> PASS: drawing from deck instead") 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 def calculate_swap_score( pos: int, @@ -917,213 +1146,22 @@ class GolfAI: - aggression: higher = more willing to go out, take risks - 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 - # Personality-based weight modifiers - # pair_hope: 0.0-1.0, affects how much we value pairing vs spreading - 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/1b. Pair creation + spread bonus + score += GolfAI._pair_improvement(pos, drawn_card, drawn_value, player, options, profile) - # 1. PAIR BONUS - Creating a pair - # pair_hope affects how much we value this - if partner_card.face_up and partner_card.rank == drawn_card.rank: - partner_value = get_ai_card_value(partner_card, options) + # 2. Point gain from replacement + score += GolfAI._point_gain(pos, drawn_card, drawn_value, player, options, profile) - if drawn_value >= 0: - # Good pair! Both cards cancel to 0 - 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})") + # 3-4d. Reveal bonus, future pair potential, four-of-a-kind, wolfpack, comeback + score += GolfAI._reveal_and_bonus_score(pos, drawn_card, drawn_value, player, options, game, profile) # 5. GO-OUT SAFETY - Penalty for going out with bad score face_down_positions = hidden_positions(player) if len(face_down_positions) == 1 and pos == face_down_positions[0]: 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)) if projected_score > max_acceptable: score -= 100 @@ -1701,12 +1739,26 @@ class GolfAI: expected_hidden_total = len(face_down) * EXPECTED_HIDDEN_VALUE projected_score = visible_score + expected_hidden_total - # More aggressive players accept higher risk - max_acceptable = 8 + int(profile.aggression * 10) # Range: 8 to 18 + # Tighter threshold: range 5 to 9 based on aggression + 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: - # Add some randomness based on aggression - knock_chance = profile.aggression * 0.4 # Max 40% for most aggressive + # Scale knock chance by how good the projected score is + 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: ai_log(f" Knock early: taking the gamble! (projected {projected_score:.1f})") return True diff --git a/server/game.py b/server/game.py index 5ce1f1a..0a5867d 100644 --- a/server/game.py +++ b/server/game.py @@ -21,7 +21,7 @@ Card Layout: import random import uuid from collections import Counter -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from enum import Enum from typing import Optional, Callable, Any @@ -130,11 +130,13 @@ class Card: suit: The card's suit (hearts, diamonds, clubs, spades). rank: The card's rank (A, 2-10, J, Q, K, or Joker). 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 rank: Rank face_up: bool = False + deck_id: int = 0 def to_dict(self, reveal: bool = False) -> dict: """ @@ -154,24 +156,27 @@ class Card: "suit": self.suit.value, "rank": self.rank.value, "face_up": self.face_up, + "deck_id": self.deck_id, } def to_client_dict(self) -> dict: """ 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: - 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: return { "suit": self.suit.value, "rank": self.rank.value, "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: """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) # Build deck(s) with standard cards - for _ in range(num_decks): + for deck_idx in range(num_decks): for suit in Suit: for rank in Rank: 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 if use_jokers and not lucky_swing: - self.cards.append(Card(Suit.HEARTS, Rank.JOKER)) - self.cards.append(Card(Suit.SPADES, Rank.JOKER)) + self.cards.append(Card(Suit.HEARTS, Rank.JOKER, deck_id=deck_idx)) + self.cards.append(Card(Suit.SPADES, Rank.JOKER, deck_id=deck_idx)) # Lucky Swing: Single joker total, worth -5 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() @@ -256,6 +261,12 @@ class Deck: """Return the number of cards left in the deck.""" 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: """ Add cards to the deck and shuffle. @@ -498,6 +509,44 @@ class GameOptions: knock_early: bool = False """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 class Game: @@ -543,6 +592,7 @@ class Game: players_with_final_turn: set = field(default_factory=set) initial_flips_done: set = field(default_factory=set) options: GameOptions = field(default_factory=GameOptions) + dealer_idx: int = 0 # Event sourcing support game_id: str = field(default_factory=lambda: str(uuid.uuid4())) @@ -711,6 +761,9 @@ class Game: for i, player in enumerate(self.players): if player.id == player_id: 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) return removed return None @@ -772,26 +825,49 @@ class Game: def _options_to_dict(self) -> dict: """Convert GameOptions to dictionary for event storage.""" - return { - "flip_mode": self.options.flip_mode, - "initial_flips": self.options.initial_flips, - "knock_penalty": self.options.knock_penalty, - "use_jokers": self.options.use_jokers, - "lucky_swing": self.options.lucky_swing, - "super_kings": self.options.super_kings, - "ten_penny": self.options.ten_penny, - "knock_bonus": self.options.knock_bonus, - "underdog_bonus": self.options.underdog_bonus, - "tied_shame": self.options.tied_shame, - "blackjack": self.options.blackjack, - "eagle_eye": self.options.eagle_eye, - "wolfpack": self.options.wolfpack, - "flip_as_action": self.options.flip_as_action, - "four_of_a_kind": self.options.four_of_a_kind, - "negative_pairs_keep_value": self.options.negative_pairs_keep_value, - "one_eyed_jacks": self.options.one_eyed_jacks, - "knock_early": self.options.knock_early, - } + return asdict(self.options) + + # Boolean rules that map directly to display names + _RULE_DISPLAY = [ + ("knock_penalty", "Knock Penalty"), + ("lucky_swing", "Lucky Swing"), + ("eagle_eye", "Eagle-Eye"), + ("super_kings", "Super Kings"), + ("ten_penny", "Ten Penny"), + ("knock_bonus", "Knock Bonus"), + ("underdog_bonus", "Underdog"), + ("tied_shame", "Tied Shame"), + ("blackjack", "Blackjack"), + ("wolfpack", "Wolfpack"), + ("flip_as_action", "Flip as Action"), + ("four_of_a_kind", "Four of a Kind"), + ("negative_pairs_keep_value", "Negative Pairs Keep Value"), + ("one_eyed_jacks", "One-Eyed Jacks"), + ("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: """ @@ -838,7 +914,12 @@ class Game: "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 self._emit( @@ -847,6 +928,7 @@ class Game: deck_seed=self.deck.seed, dealt_cards=dealt_cards, first_discard=first_discard_dict, + current_player_idx=self.current_player_index, ) # Skip initial flip phase if 0 flips required @@ -1518,56 +1600,22 @@ class Game: discard_top = self.discard_top() - # Build active rules list for display - 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") + active_rules = self._get_active_rules() return { "phase": self.phase.value, "players": players_data, "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, "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, "total_rounds": self.num_rounds, "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, "waiting_for_initial_flip": ( self.phase == GamePhase.INITIAL_FLIP and @@ -1579,6 +1627,8 @@ class Game: "flip_is_optional": self.flip_is_optional, "flip_as_action": self.options.flip_as_action, "knock_early": self.options.knock_early, + "finisher_id": self.finisher_id, "card_values": self.get_card_values(), "active_rules": active_rules, + "deck_colors": self.options.deck_colors, } diff --git a/server/models/game_state.py b/server/models/game_state.py index 48441fb..b7322df 100644 --- a/server/models/game_state.py +++ b/server/models/game_state.py @@ -237,7 +237,7 @@ class RebuiltGameState: self.initial_flips_done = set() self.drawn_card = None self.drawn_from_discard = False - self.current_player_idx = 0 + self.current_player_idx = event.data.get("current_player_idx", 0) self.discard_pile = [] # Deal cards to players (all face-down) diff --git a/server/test_ai_decisions.py b/server/test_ai_decisions.py new file mode 100644 index 0000000..2d85a18 --- /dev/null +++ b/server/test_ai_decisions.py @@ -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 diff --git a/server/test_house_rules.py b/server/test_house_rules.py index 67ae9b4..a4f20c0 100644 --- a/server/test_house_rules.py +++ b/server/test_house_rules.py @@ -138,7 +138,7 @@ def run_game_with_options(options: GameOptions, num_players: int = 4) -> tuple[l 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.""" all_scores = [] @@ -516,7 +516,7 @@ def main(): for i, (name, options) in enumerate(configs): 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) # Quick status diff --git a/server/tests/test_event_replay.py b/server/tests/test_event_replay.py index 2f79e37..b18e120 100644 --- a/server/tests/test_event_replay.py +++ b/server/tests/test_event_replay.py @@ -125,8 +125,9 @@ class TestEventEmission: game, collector = create_test_game(num_players=2) game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0)) + current = game.current_player() initial_count = len(collector.events) - card = game.draw_card("p1", "deck") + card = game.draw_card(current.id, "deck") assert card is not None new_events = collector.events[initial_count:] @@ -134,7 +135,7 @@ class TestEventEmission: assert len(draw_events) == 1 event = draw_events[0] - assert event.player_id == "p1" + assert event.player_id == current.id assert event.data["source"] == "deck" assert event.data["card"]["rank"] == card.rank.value @@ -142,10 +143,12 @@ class TestEventEmission: """Swapping a card should emit card_swapped event.""" game, collector = create_test_game(num_players=2) 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) - old_card = game.swap_card("p1", 0) + old_card = game.swap_card(current.id, 0) assert old_card is not None new_events = collector.events[initial_count:] @@ -153,24 +156,26 @@ class TestEventEmission: assert len(swap_events) == 1 event = swap_events[0] - assert event.player_id == "p1" + assert event.player_id == current.id assert event.data["position"] == 0 def test_discard_card_event(self): """Discarding drawn card should emit card_discarded event.""" game, collector = create_test_game(num_players=2) 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) - game.discard_drawn("p1") + game.discard_drawn(current.id) new_events = collector.events[initial_count:] discard_events = [e for e in new_events if e.event_type == EventType.CARD_DISCARDED] assert len(discard_events) == 1 event = discard_events[0] - assert event.player_id == "p1" + assert event.player_id == current.id 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)) # Do a swap - drawn = game.draw_card("p1", "deck") - old_card = game.get_player("p1").cards[0] - game.swap_card("p1", 0) + current = game.current_player() + drawn = game.draw_card(current.id, "deck") + old_card = game.get_player(current.id).cards[0] + game.swap_card(current.id, 0) # Rebuild and verify 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 assert rebuilt_player.cards[0].rank == drawn.rank.value