From e1733f4943be5afb6ae24587d71f7dff9486b682 Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 6 Jul 2026 23:33:45 +0200 Subject: [PATCH] RL: baseline boti a evaluacny harness RandomPlayer, HeuristicPlayer (MC tipper nad rozdaniami neznamych kariet + tipom riadena hracia heuristika) a McPlayer (MC ohodnotenie kazdeho kandidatskeho tahu nad rozdaniami konzistentnymi s dedukovanymi voidmi). Evaluacia s rotaciou sedadiel: py -m rl.evaluate. Co-Authored-By: Claude Fable 5 --- rl/evaluate.py | 89 +++++++++++ rl/players.py | 264 ++++++++++++++++++++++++++++++++ tests/test_rl_players.py | 316 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 669 insertions(+) create mode 100644 rl/evaluate.py create mode 100644 rl/players.py create mode 100644 tests/test_rl_players.py diff --git a/rl/evaluate.py b/rl/evaluate.py new file mode 100644 index 0000000..1ef4982 --- /dev/null +++ b/rl/evaluate.py @@ -0,0 +1,89 @@ +"""Evaluacny harness: odohra N kol medzi 4 hracmi a spocita metriky. + +Metriky per hrac (viz rl/DESIGN.md, sekcia 5): priemerne body na kolo +a presnost tipu (% kol s presne trafenym tipom). Sedadla sa medzi kolami +rotuju, aby ziadny hrac nebol systematicky zvyhodneny poradim tipovania. + +Spustenie ako skript porovna baseline botov: + py -m rl.evaluate --rounds 500 --seed 7 +""" + +import argparse +from random import Random + +from bridzik import ROUNDS_PER_SERIES +from rl.env import PHASE_GUESS, RoundEnv + + +def play_round(players: list, env: RoundEnv, round_number: int = None, + first_player: int = None) -> list: + """Odohra jedno kolo; `players[seat]` rozhoduje za sedadlo `seat`. + + Vrati body 4 sedadiel (`Round.get_points_summary()`). + """ + decision = env.reset(round_number, first_player) + while True: + seat = decision.player + if decision.phase == PHASE_GUESS: + action = players[seat].guess(env.round, seat) + else: + action = players[seat].play(env.round, seat) + decision, rewards, done = env.step(action) + if done: + return rewards + + +def evaluate(players: list, n_rounds: int, rng: Random = None, + round_numbers: list = None) -> list: + """Odohra `n_rounds` kol s rotaciou sedadiel; vrati stats per hrac. + + Vystup: zoznam dictov v poradi `players` -- + {'avg_points': float, 'hit_rate': float, 'rounds': int}. + """ + rng = rng if rng is not None else Random() + env = RoundEnv(rng) + points = [0] * 4 + hits = [0] * 4 + for i in range(n_rounds): + round_number = rng.choice(round_numbers) if round_numbers \ + else rng.randrange(ROUNDS_PER_SERIES) + # rotacia: sedadlo s obsadzuje players[(s + i) % 4] + seating = [players[(s + i) % 4] for s in range(4)] + rewards = play_round(seating, env, round_number) + for seat in range(4): + player_index = (seat + i) % 4 + points[player_index] += rewards[seat] + hits[player_index] += rewards[seat] > 0 + return [{'avg_points': points[p] / n_rounds, + 'hit_rate': hits[p] / n_rounds, + 'rounds': n_rounds} for p in range(len(players))] + + +def main(): + from rl.players import HeuristicPlayer, RandomPlayer + + parser = argparse.ArgumentParser(description='Evaluacia baseline botov') + parser.add_argument('--rounds', type=int, default=500) + parser.add_argument('--seed', type=int, default=7) + parser.add_argument('--mc-samples', type=int, default=100) + args = parser.parse_args() + + rng = Random(args.seed) + lineups = [ + ('4x random', [RandomPlayer(rng) for _ in range(4)]), + ('1x heuristika + 3x random', + [HeuristicPlayer(rng, n_samples=args.mc_samples)] + + [RandomPlayer(rng) for _ in range(3)]), + ('4x heuristika', + [HeuristicPlayer(rng, n_samples=args.mc_samples) for _ in range(4)]), + ] + for label, players in lineups: + stats = evaluate(players, args.rounds, rng) + print(f'\n{label} ({args.rounds} kol):') + for i, s in enumerate(stats): + print(f' hrac {i}: {s["avg_points"]:6.2f} bodov/kolo, ' + f'tip trafeny {100 * s["hit_rate"]:5.1f} %') + + +if __name__ == '__main__': + main() diff --git a/rl/players.py b/rl/players.py new file mode 100644 index 0000000..30ab3b0 --- /dev/null +++ b/rl/players.py @@ -0,0 +1,264 @@ +"""Baseline hraci pre evaluaciu a neskorsi warm-start siete (viz rl/DESIGN.md). + +Spolocne rozhranie: `guess(rnd, seat) -> int` (tip 0..8) a +`play(rnd, seat) -> int` (index karty 0..31). Hrac vidi len to, co by videl +pri stole -- vlastnu ruku, tipy, dokoncene kopky a rozohranu kopku; do cudzich +ruk nesiaha. +""" + +from collections import Counter +from random import Random + +from bridzik import cards, Card_colors, Stash +from rl.encoding import ( + N_GUESS_ACTIONS, N_PLAY_ACTIONS, + card_index, deduce_voids, guess_mask, legal_cards, play_mask, +) + + +class RandomPlayer: + """Uniformne nahodny legalny tah -- najslabsi mozny baseline.""" + + def __init__(self, rng: Random = None): + self.rng = rng if rng is not None else Random() + + def guess(self, rnd, seat: int) -> int: + mask = guess_mask(rnd) + return self.rng.choice([g for g in range(N_GUESS_ACTIONS) if mask[g]]) + + def play(self, rnd, seat: int) -> int: + mask = play_mask(rnd, seat) + return self.rng.choice([i for i in range(N_PLAY_ACTIONS) if mask[i]]) + + +def _strength(card) -> tuple: + """Absolutna sila karty: kazda cervena (tromf) bije kazdu necervenu.""" + return (card.color == Card_colors['HEARTS'], card.value.value) + + +def _current_best(stash): + """Zatial vitazna karta rozohranej kopky (None ak sa este nevynieslo).""" + first = stash.get_first_card() if stash is not None else None + if first is None: + return None + best = first + for card in stash.get_cards().values(): + if _beats(card, best): + best = card + return best + + +def _beats(card, best) -> bool: + """Ci `card` prebije `best` (karta drziaca kopku; jej farba je smerodajna).""" + if card.color == best.color: + return card.value > best.value + return card.color == Card_colors['HEARTS'] + + +def simulate_tricks(hands: dict, leader: int, rng: Random) -> list: + """Dohra kopky s nahodnou legalnou strategiou; vrati pocty vyhier hracov. + + `hands` je dict seat -> zoznam kariet (rovnako velke ruky); zoznamy sa + spotrebuju. Vitaza kopky urcuje enginovy Stash.get_winner() -- pravidla + sa tu neduplikuju. + """ + tricks = [0] * 4 + for _ in range(len(hands[leader])): + stash = Stash(leader) + for i in range(4): + seat = (leader + i) % 4 + card = rng.choice(legal_cards(hands[seat], stash.get_first_card())) + hands[seat].remove(card) + stash.add_card(seat, card) + leader = stash.get_winner() + tricks[leader] += 1 + return tricks + + +def deal_consistent(unknown: list, hand_sizes: dict, voids: dict, + rng: Random, max_tries: int = 20) -> dict: + """Nahodne rozdanie neznamych kariet superom respektujuce voidy. + + Greedy priradenie po zamiesani (najviac obmedzeni hraci prvi); ak sa + konzistentne rozdanie nepodari za `max_tries`, padne na rozdanie bez + voidov (zriedkave, radsej mierne skreslena vzorka nez ziadna). + Zvysok kariet ostava v odlozenej kope mimo hry. + """ + seats = sorted(hand_sizes, key=lambda s: len(voids.get(s, ())), reverse=True) + pool = list(unknown) + for _ in range(max_tries): + rng.shuffle(pool) + remaining = list(pool) + hands = {} + for seat in seats: + hand, rest, banned = [], [], voids.get(seat, set()) + for card in remaining: + if len(hand) < hand_sizes[seat] and card.color not in banned: + hand.append(card) + else: + rest.append(card) + if len(hand) < hand_sizes[seat]: + break + hands[seat] = hand + remaining = rest + else: + return hands + rng.shuffle(pool) + idx = 0 + hands = {} + for seat in seats: + hands[seat] = pool[idx:idx + hand_sizes[seat]] + idx += hand_sizes[seat] + return hands + + +def mc_guess_distribution(rnd, seat: int, n_samples: int, rng: Random) -> Counter: + """Monte Carlo odhad rozdelenia poctu vlastnych kopiek v kole. + + Nezname karty sa v kazdej vzorke nahodne rozdelia ostatnym trom hracom + -- kazdemu len (8 - round_number) kariet, zvysok ostava v odlozenej kope + mimo hry (pozri DESIGN.md, pasca "discard pile"). Leader prvej kopky je + v case tipovania neznamy (najvyssi tip), sampluje sa uniformne. + """ + hand = rnd.player_cards[seat] + hand_size = 8 - rnd.round_number + unknown = [c for c in cards if c not in hand] + counts = Counter() + for _ in range(n_samples): + rng.shuffle(unknown) + sim_hands = {seat: list(hand)} + others = [s for s in range(4) if s != seat] + for i, other in enumerate(others): + sim_hands[other] = unknown[i * hand_size:(i + 1) * hand_size] + tricks = simulate_tricks(sim_hands, rng.randrange(4), rng) + counts[tricks[seat]] += 1 + return counts + + +def finish_round(hands: dict, current_cards: dict, first_player: int, + me: int, my_card, tricks: list, rng: Random) -> list: + """Dohra kolo od mojho tahu: dokonci rozohranu kopku (ja hram `my_card`, + dalsi nahodne legalne) a zvysne kopky dohra nahodnou legalnou strategiou. + `tricks` su uz vyhrane kopky (mutuje sa kopia volajuceho); vrati final.""" + stash = Stash(first_player) + for seat, card in current_cards.items(): + stash.add_card(seat, card) + stash.add_card(me, my_card) + while not stash.is_completed(): + seat = stash.get_active_player() + card = rng.choice(legal_cards(hands[seat], stash.get_first_card())) + hands[seat].remove(card) + stash.add_card(seat, card) + leader = stash.get_winner() + tricks[leader] += 1 + while hands[leader]: + stash = Stash(leader) + for i in range(4): + seat = (leader + i) % 4 + card = rng.choice(legal_cards(hands[seat], stash.get_first_card())) + hands[seat].remove(card) + stash.add_card(seat, card) + leader = stash.get_winner() + tricks[leader] += 1 + return tricks + + +class HeuristicPlayer: + """MC tipper + jednoducha hracia heuristika riadena vlastnym tipom.""" + + def __init__(self, rng: Random = None, n_samples: int = 100): + self.rng = rng if rng is not None else Random() + self.n_samples = n_samples + + def guess(self, rnd, seat: int) -> int: + counts = mc_guess_distribution(rnd, seat, self.n_samples, self.rng) + mask = guess_mask(rnd) + # najcastejsi LEGALNY pocet kopiek (mod rozdelenia, nie priemer -- + # boduje sa len presna zhoda); pri nule vzoriek pre legalny tip + # rozhodne blizkost k celkovemu modu + mode = counts.most_common(1)[0][0] + legal = [g for g in range(N_GUESS_ACTIONS) if mask[g]] + return max(legal, key=lambda g: (counts[g], -abs(g - mode))) + + def play(self, rnd, seat: int) -> int: + hand = rnd.player_cards[seat] + stash = rnd.get_last_stash() + allowed = legal_cards(hand, stash.get_first_card() if stash else None) + need = rnd.guesses[seat] - rnd.get_stashes_winner_summary()[seat] + best = _current_best(stash) + + if best is None: + # vynasam: chcem kopku -> najsilnejsia karta; nechcem -> najslabsia + chosen = max(allowed, key=_strength) if need > 0 else min(allowed, key=_strength) + else: + winning = [c for c in allowed if _beats(c, best)] + if need > 0 and winning: + # ber kopku co najlacnejsie + chosen = min(winning, key=_strength) + elif need <= 0 and len(winning) < len(allowed): + # kopku nechcem: zbav sa najsilnejsej neberucej karty + chosen = max((c for c in allowed if not _beats(c, best)), key=_strength) + elif need <= 0: + # vsetko berie -> ber co najlacnejsie (setri silne karty netreba, + # ale nizka karta drzi sancu, ze ma este niekto prebije) + chosen = min(allowed, key=_strength) + else: + # kopku chcem, ale nic neberie -> odhod najslabsiu + chosen = min(allowed, key=_strength) + return card_index(chosen) + + +class McPlayer(HeuristicPlayer): + """Heuristika s MC hracou fazou: kazdy kandidatsky tah sa ohodnoti + simulaciami zvysku kola nad rozdaniami neznamych kariet konzistentnymi + s dedukovanymi voidmi (`use_voids=False` = ablacia bez dedukcie). + Tipovanie ostava MC tipper z HeuristicPlayer (pred prvou kartou niet + z coho voidy dedukovat).""" + + def __init__(self, rng: Random = None, n_samples: int = 100, + play_samples: int = 24, use_voids: bool = True): + super().__init__(rng, n_samples) + self.play_samples = play_samples + self.use_voids = use_voids + + def play(self, rnd, seat: int) -> int: + hand = rnd.player_cards[seat] + stash = rnd.get_last_stash() + first_card = stash.get_first_card() if stash else None + candidates = legal_cards(hand, first_card) + if len(candidates) == 1: + return card_index(candidates[0]) + + target = rnd.guesses[seat] + base_tricks = rnd.get_stashes_winner_summary() + voids = deduce_voids(rnd) if self.use_voids else {} + seen = set() + played_count = Counter() + for st in rnd.stashes: + for other, card in st.get_cards().items(): + seen.add(card) + played_count[other] += 1 + unknown = [c for c in cards if c not in seen and c not in hand] + hand_size0 = 8 - rnd.round_number + hand_sizes = {s: hand_size0 - played_count[s] for s in range(4) if s != seat} + current_cards = stash.get_cards() + + # spolocne rozdanie pre vsetkych kandidatov (common random numbers -- + # porovnavame tahy na tych istych svetoch, mensia variancia) + scores = {card_index(c): 0 for c in candidates} + for _ in range(self.play_samples): + world = deal_consistent(unknown, hand_sizes, voids, self.rng) + for candidate in candidates: + sim_hands = {s: list(h) for s, h in world.items()} + sim_hands[seat] = [c for c in hand if c != candidate] + tricks = finish_round(sim_hands, dict(current_cards), + stash.first_player, seat, candidate, + list(base_tricks), self.rng) + if tricks[seat] == target: + scores[card_index(candidate)] += 1 + + best_index = max(scores, key=scores.get) + if scores[best_index] == 0: + # tip uz je (takmer) nedosiahnutelny -> aspon rozumny pravidlovy tah + return super().play(rnd, seat) + return best_index diff --git a/tests/test_rl_players.py b/tests/test_rl_players.py new file mode 100644 index 0000000..0cd928d --- /dev/null +++ b/tests/test_rl_players.py @@ -0,0 +1,316 @@ +import unittest +from random import Random + +from bridzik import cards, Card, Card_colors, Card_values, Round +from rl.encoding import card_index, index_card, legal_cards +from rl.env import Decision, PHASE_GUESS, PHASE_PLAY, RoundEnv +from rl.evaluate import evaluate, play_round +from rl.players import ( + HeuristicPlayer, McPlayer, RandomPlayer, + _beats, _current_best, deal_consistent, deduce_voids, + mc_guess_distribution, simulate_tricks, +) + + +class RoundEnvCase(unittest.TestCase): + def test_episode_structure(self): + env = RoundEnv(Random(42)) + decision = env.reset(round_number=6, first_player=1) + rng = Random(0) + + # prve 4 rozhodnutia su tipy, v poradi od first_player + expected_guessers = [1, 2, 3, 0] + for expected in expected_guessers: + self.assertIsInstance(decision, Decision) + self.assertEqual(decision.phase, PHASE_GUESS) + self.assertEqual(decision.player, expected) + action = rng.choice([g for g in range(9) if decision.mask[g]]) + decision, rewards, done = env.step(action) + self.assertIsNone(rewards) + self.assertFalse(done) + + # potom hracie rozhodnutia az po terminal: 2 karty x 4 hraci + steps = 0 + while True: + self.assertEqual(decision.phase, PHASE_PLAY) + self.assertEqual(decision.player, env.round.get_active_player()) + action = rng.choice([i for i in range(32) if decision.mask[i]]) + decision, rewards, done = env.step(action) + steps += 1 + if done: + break + self.assertEqual(steps, 8) + self.assertIsNone(decision) + self.assertEqual(rewards, env.round.get_points_summary()) + self.assertEqual(len(rewards), 4) + + # po done sa step neda volat, reset zacne novu epizodu + self.assertRaises(RuntimeError, env.step, 0) + self.assertIsInstance(env.reset(), Decision) + + def test_reset_samples_round_and_seat(self): + env = RoundEnv(Random(7)) + seen_rounds, seen_seats = set(), set() + for _ in range(100): + env.reset() + seen_rounds.add(env.round.round_number) + seen_seats.add(env.round.first_player) + self.assertEqual(seen_rounds, set(range(8))) + self.assertEqual(seen_seats, set(range(4))) + + def test_deterministic_with_seed(self): + rewards = [] + for _ in range(2): + env = RoundEnv(Random(123)) + players = [RandomPlayer(Random(5)) for _ in range(4)] + rewards.append(play_round(players, env, round_number=0)) + self.assertEqual(rewards[0], rewards[1]) + + +class SimulationHelpersCase(unittest.TestCase): + def test_beats(self): + heart_7 = Card(Card_colors['HEARTS'], Card_values['C7']) + heart_8 = Card(Card_colors['HEARTS'], Card_values['C8']) + leaves_ace = Card(Card_colors['LEAVES'], Card_values['ACE']) + leaves_king = Card(Card_colors['LEAVES'], Card_values['KING']) + bells_ace = Card(Card_colors['BELLS'], Card_values['ACE']) + + self.assertTrue(_beats(leaves_ace, leaves_king)) # vyssia vo farbe + self.assertFalse(_beats(leaves_king, leaves_ace)) + self.assertTrue(_beats(heart_7, leaves_ace)) # tromf bije farbu + self.assertFalse(_beats(bells_ace, leaves_king)) # cudzia farba neberie + self.assertTrue(_beats(heart_8, heart_7)) # tromfy medzi sebou + self.assertFalse(_beats(leaves_ace, heart_7)) # farba nebije tromf + + def test_current_best_tracks_stash(self): + from bridzik import Stash + leaves_7 = Card(Card_colors['LEAVES'], Card_values['C7']) + leaves_ace = Card(Card_colors['LEAVES'], Card_values['ACE']) + heart_7 = Card(Card_colors['HEARTS'], Card_values['C7']) + + self.assertIsNone(_current_best(None)) + s = Stash(0) + self.assertIsNone(_current_best(s)) + s.add_card(0, leaves_7) + self.assertEqual(_current_best(s), leaves_7) + s.add_card(1, leaves_ace) + self.assertEqual(_current_best(s), leaves_ace) + s.add_card(2, heart_7) + self.assertEqual(_current_best(s), heart_7) + + def test_simulate_tricks_consumes_hands(self): + rng = Random(3) + deck = list(cards) + rng.shuffle(deck) + hands = {seat: deck[seat * 8:(seat + 1) * 8] for seat in range(4)} + tricks = simulate_tricks(hands, leader=2, rng=rng) + self.assertEqual(sum(tricks), 8) + for seat in range(4): + self.assertEqual(hands[seat], []) + + +class HeuristicPlayerCase(unittest.TestCase): + @staticmethod + def _round_with_hand(player0_hand): + # deterministicke rozdanie: player0_hand ide hracovi 0, zvysok dalej; + # deal_starting_cards najprv zahodi 4*round_number kariet, preto + # treba ruku umiestnit az ZA odkladaciu kopu + round_number = 8 - len(player0_hand) + rest = [c for c in cards if c not in player0_hand] + skip = 4 * round_number + deck = rest[:skip] + list(player0_hand) + rest[skip:] + return Round(round_number, 0, deck, shuffler=lambda l: None) + + def test_mc_guess_all_hearts_is_certain(self): + # 8 cerveni = tromfy beru kazdu kopku bez ohladu na rozdanie a hru + all_hearts = [Card(Card_colors['HEARTS'], v) for v in Card_values] + r = self._round_with_hand(all_hearts) + counts = mc_guess_distribution(r, 0, n_samples=30, rng=Random(1)) + self.assertEqual(counts, {8: 30}) + self.assertEqual(HeuristicPlayer(Random(1), n_samples=30).guess(r, 0), 8) + + def test_mc_guess_weak_hand_low(self): + # dve najnizsie necervene karty -> tip 0 s prehladom + weak = [Card(Card_colors['LEAVES'], Card_values['C7']), + Card(Card_colors['BELLS'], Card_values['C7'])] + r = self._round_with_hand(weak) + self.assertEqual(HeuristicPlayer(Random(2), n_samples=60).guess(r, 0), 0) + + def test_mc_guess_respects_mask(self): + # posledny tipujuci: zakazana hodnota nesmie byt vratena, ani ked + # je modom rozdelenia + all_hearts = [Card(Card_colors['HEARTS'], v) for v in Card_values] + r = self._round_with_hand(all_hearts) + r.add_player_guess(0, 0) + r.add_player_guess(1, 0) + r.add_player_guess(2, 0) + # zakazany tip pre hraca 3 je 8; jeho ruka je nahodna, ale nech by + # simulacia vratila cokolvek, vysledok musi byt legalny + guess = HeuristicPlayer(Random(3), n_samples=20).guess(r, 3) + self.assertNotEqual(guess, 8) + self.assertIn(guess, range(8)) + + def test_play_takes_trick_when_needed(self): + hand = [Card(Card_colors['LEAVES'], Card_values['ACE']), + Card(Card_colors['LEAVES'], Card_values['C7']), + Card(Card_colors['BELLS'], Card_values['C7'])] + r = self._round_with_hand(hand) + r.add_player_guess(0, 3) # najvyssi tip -> hrac 0 vynasa + r.add_player_guess(1, 0) + r.add_player_guess(2, 0) + r.add_player_guess(3, 1) + # hrac 0 potrebuje kopky -> vynasa najsilnejsiu kartu (LEAVES ACE) + action = HeuristicPlayer(Random(4)).play(r, 0) + self.assertEqual(index_card(action), hand[0]) + + def test_play_ducks_when_satisfied(self): + hand = [Card(Card_colors['LEAVES'], Card_values['ACE']), + Card(Card_colors['LEAVES'], Card_values['C7']), + Card(Card_colors['BELLS'], Card_values['C7'])] + r = self._round_with_hand(hand) + r.add_player_guess(0, 0) # hrac 0 nechce ziadnu kopku + r.add_player_guess(1, 2) # najvyssi tip -> vynasa hrac 1 + r.add_player_guess(2, 0) + r.add_player_guess(3, 0) + first_card = legal_cards(r.player_cards[1], None)[0] + r.play_card(1, first_card) + action = HeuristicPlayer(Random(5)).play(r, 2) + # legalnost staci overit enginom; strategiu netestujeme natvrdo, + # lebo zavisi od nahodnej ruky hraca 2 + r.play_card(2, index_card(action)) + + def test_play_duck_scenario_deterministic(self): + # hrac 0 tipol 0, ma na ruke LEAVES ACE aj C7; kopku vedie LEAVES C8 + # -> musi priznat farbu a spravne je podliezt (C7), nie zobrat esom + hand0 = [Card(Card_colors['LEAVES'], Card_values['ACE']), + Card(Card_colors['LEAVES'], Card_values['C7'])] + hand1 = [Card(Card_colors['LEAVES'], Card_values['C8']), + Card(Card_colors['LEAVES'], Card_values['C9'])] + rest = [c for c in cards if c not in hand0 + hand1] + deck = rest[:24] + hand0 + hand1 + rest[24:] # 24 = odkladacia kopa + r = Round(6, 0, deck, shuffler=lambda l: None) + r.add_player_guess(0, 0) + r.add_player_guess(1, 2) # vynasa hrac 1 + r.add_player_guess(2, 0) + r.add_player_guess(3, 1) # 0+2+0+0 by bol zakazany sucet (2 kopky) + r.play_card(1, hand1[0]) + action = HeuristicPlayer(Random(6)).play(r, 0) + self.assertEqual(index_card(action), hand0[1]) + + +class VoidDeductionCase(unittest.TestCase): + def test_deduce_voids_from_stash(self): + # hrac 0 vynasa zelen; 1 prizna farbu (nic), 2 tromfne cervenou + # (void zelen), 3 hodi gulu (void zelen AJ cerven) + hand0 = [Card(Card_colors['LEAVES'], Card_values['C7']), + Card(Card_colors['LEAVES'], Card_values['C8'])] + hand1 = [Card(Card_colors['LEAVES'], Card_values['C9']), + Card(Card_colors['LEAVES'], Card_values['C10'])] + hand2 = [Card(Card_colors['HEARTS'], Card_values['C7']), + Card(Card_colors['ACORNS'], Card_values['C7'])] + hand3 = [Card(Card_colors['BELLS'], Card_values['C7']), + Card(Card_colors['BELLS'], Card_values['C8'])] + rest = [c for c in cards if c not in hand0 + hand1 + hand2 + hand3] + deck = rest[:24] + hand0 + hand1 + hand2 + hand3 + r = Round(6, 0, deck, shuffler=lambda l: None) + r.add_player_guess(0, 2) # najvyssi tip -> vynasa 0 + r.add_player_guess(1, 0) + r.add_player_guess(2, 0) + r.add_player_guess(3, 1) + + self.assertEqual(deduce_voids(r), {0: set(), 1: set(), 2: set(), 3: set()}) + r.play_card(0, hand0[0]) + r.play_card(1, hand1[0]) # priznal farbu -> nic + r.play_card(2, hand2[0]) # cerven -> void zelen + r.play_card(3, hand3[0]) # gula -> void zelen aj cerven + voids = deduce_voids(r) + self.assertEqual(voids[0], set()) # vynasajuci neprezradza nic + self.assertEqual(voids[1], set()) + self.assertEqual(voids[2], {Card_colors['LEAVES']}) + self.assertEqual(voids[3], {Card_colors['LEAVES'], Card_colors['HEARTS']}) + + def test_deduced_voids_never_contradict_hands(self): + # fuzz: dedukovany void NIKDY neprotireci realnej ruke hraca + rng = Random(21) + for _ in range(30): + r = Round(rng.randrange(4), rng.randrange(4)) + players = [RandomPlayer(Random(rng.random())) for _ in range(4)] + for _ in range(4): + seat = r.get_active_player() + r.add_player_guess(seat, players[seat].guess(r, seat)) + while not r.is_completed(): + seat = r.get_active_player() + r.play_card(seat, index_card(players[seat].play(r, seat))) + for other, banned in deduce_voids(r).items(): + held = {c.color for c in r.player_cards[other]} + self.assertFalse(held & banned, + f'void {banned} vs ruka {held}') + + def test_deal_consistent_respects_voids(self): + rng = Random(22) + unknown = [c for c in cards][:20] + voids = {1: {Card_colors['HEARTS']}, 2: set(), + 3: {Card_colors['LEAVES'], Card_colors['BELLS']}} + for _ in range(20): + hands = deal_consistent(unknown, {1: 4, 2: 4, 3: 4}, voids, rng) + self.assertTrue(all(len(h) == 4 for h in hands.values())) + for seat, banned in voids.items(): + self.assertFalse({c.color for c in hands[seat]} & banned) + + +class McPlayerCase(unittest.TestCase): + def test_plays_legal_full_rounds(self): + rng = Random(23) + env = RoundEnv(rng) + players = [McPlayer(Random(24), n_samples=20, play_samples=8), + McPlayer(Random(25), n_samples=20, play_samples=8, + use_voids=False)] \ + + [RandomPlayer(Random(s)) for s in (26, 27)] + for round_number in range(8): + rewards = play_round(players, env, round_number) + self.assertEqual(len(rewards), 4) + + def test_duck_scenario(self): + # tip 0, kopku vedie sused LEAVES C8 a ja som HNED na tahu (MC hrac + # stavia kopku poctivo, takze na rozdiel od pravidlovej heuristiky + # vyzaduje konzistentne poradie): mam ACE aj C7 -> podlezt sedmickou + hand0 = [Card(Card_colors['LEAVES'], Card_values['ACE']), + Card(Card_colors['LEAVES'], Card_values['C7'])] + hand3 = [Card(Card_colors['LEAVES'], Card_values['C8']), + Card(Card_colors['LEAVES'], Card_values['C9'])] + rest = [c for c in cards if c not in hand0 + hand3] + deck = rest[:24] + hand0 + rest[24:26] + rest[26:28] + hand3 + r = Round(6, 0, deck, shuffler=lambda l: None) + r.add_player_guess(0, 0) + r.add_player_guess(1, 0) + r.add_player_guess(2, 0) + r.add_player_guess(3, 1) # najvyssi tip -> vynasa hrac 3, po nom ja + r.play_card(3, hand3[0]) + self.assertEqual(r.get_active_player(), 0) + action = McPlayer(Random(28), play_samples=30).play(r, 0) + self.assertEqual(index_card(action), hand0[1]) + + +class EvaluateCase(unittest.TestCase): + def test_full_random_matchup_runs(self): + rng = Random(11) + stats = evaluate([RandomPlayer(rng) for _ in range(4)], 40, rng) + for s in stats: + self.assertEqual(s['rounds'], 40) + self.assertGreaterEqual(s['avg_points'], 0) + self.assertLessEqual(s['hit_rate'], 1) + + def test_heuristic_beats_random(self): + rng = Random(13) + players = [HeuristicPlayer(rng, n_samples=40)] \ + + [RandomPlayer(rng) for _ in range(3)] + stats = evaluate(players, 120, rng) + heuristic, randoms = stats[0], stats[1:] + best_random = max(s['avg_points'] for s in randoms) + self.assertGreater(heuristic['avg_points'], best_random) + self.assertGreater(heuristic['hit_rate'], + max(s['hit_rate'] for s in randoms)) + + +if __name__ == '__main__': + unittest.main(verbosity=2)