From 3710a68e37f4321227db7514198e4bb9ae37e8e8 Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 6 Jul 2026 23:33:35 +0200 Subject: [PATCH] RL: encoding observacii, akcne masky a Round prostredie Zaklad RL vrstvy (rl/DESIGN.md): egocentricky rotovana observacia (ruka, videne karty, tipy, kopka, dedukovane voidy -- 233 dim), masky legalnych tipov/kariet zrkadliace pravidla enginu a RoundEnv (jedno kolo = jedna self-play epizoda). Fuzz-testy vynucuju zhodu masiek s enginom. Co-Authored-By: Claude Fable 5 --- rl/DESIGN.md | 181 +++++++++++++++++++++++++ rl/__init__.py | 0 rl/encoding.py | 168 +++++++++++++++++++++++ rl/env.py | 70 ++++++++++ tests/test_encoding.py | 296 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 715 insertions(+) create mode 100644 rl/DESIGN.md create mode 100644 rl/__init__.py create mode 100644 rl/encoding.py create mode 100644 rl/env.py create mode 100644 tests/test_encoding.py diff --git a/rl/DESIGN.md b/rl/DESIGN.md new file mode 100644 index 0000000..6129563 --- /dev/null +++ b/rl/DESIGN.md @@ -0,0 +1,181 @@ +# RL bot pre bridzik — navrh (2026-07-01) + +Ciel: naucit sa principy self-play reinforcement learningu (v duchu AlphaGo Zero) +na praktickom priklade — natrenovat sietovy policy pre hranie bridziku. Zamerne +zjednodusene oproti AlphaGo Zero: bez MCTS (skryta informacia neumoznuje priamy +prehladavaci strom), cisty self-play policy gradient (PPO/REINFORCE) nad `bridzik.py` +enginom. + +## Kluc: `Round` je nezavisla epizoda + +Bodovanie (`Round.get_points_summary`) je cisto lokalne pre jedno kolo — nezavisi +od predoslych ani nasledujucich kol, len od tipu a poctu kopiek v danom kole. +Netreba teda simulovat cely `Bridzik`/`Series` state machine na trening — staci +instanciovat `Round(round_number, first_player, shuffler)` priamo, opakovane, +s roznymi `round_number` (0-7, teda 8 az 1 karta v ruke). Kazdy `Round` je +samostatna self-play epizoda. + +Vyhody: +- jednoduchsi self-play loop (ziadne series/game bookkeeping) +- vela nezavislych epizod, jednoduchá paralelizacia + +**Curriculum vs. uniformne samplovanie (bod 4).** Povodny napad "zacat na +`round_number=7`" je zavadzajuci: pri 1 karte je tip masked na {0,1} a jedina +karta je vynutena — nula card-play rozhodnuti, ziadne ucenie, len smoke-test. +Realne ucenie je v kolach `round_number` 0-3 (6-8 kariet). Preto: +- `round_number` je aj tak v observacii, takze **default = uniformne samplovat + `round_number` 0-7** a nechat siet zdielat vahy naprieč velkostami ruk. +- Ak curriculum, tak **od tazkych (viac kariet) k lahsim**, nie naopak; alebo + aspon uniformne s miernym zvyhodnenim tazsich kol. +- `round_number=7` drzat len ako sanity/smoke test pipeline, nie ako trening. + +## 1. State encoding (observation) + +Spolocne pre guess aj play fazu, budovane z `Round` objektu pre daneho hraca: + +- **vlastna ruka** — 32-dim multi-hot (4 farby x 8 hodnot) +- **round_number** — one-hot (8) alebo normalizovane cislo (urcuje velkost ruk) +- **tipy vsetkych 4 hracov** — 4x (flag "uz tipoval" + normalizovana hodnota) +- **vlastny tip** (po tipnuti) — kriticke pre play fazu (viem, ci este potrebujem + vyhrat kopku, alebo sa jej mam vyhybat) +- **kolko kopiek uz kazdy hrac vyhral v tomto kole** — 4 scalars, odvodene + z dokoncenych `Stash` objektov v `self.stashes` +- **aktualna kopka v procese** — 4 sloty (karta alebo prazdne) + `first_player` + aktualnej kopky +- **uz odohrane/videne karty v tomto kole** — 32-dim multi-hot (bod 1). KRITICKE: + bez toho observacia NIE JE Markovovska. Pri viac kartach (round_number 0-2) + su dve rovnake ruky s rovnakou aktualnou kopkou, ale roznou historiou uz + odohranych kariet, rozne stavy s roznym optimalnym tahom (vies, ci este visi + eso/cerven). Bez tejto zlozky sa siet nemoze naucit card-counting a strop hry + ostane nizky. Kodovat karty odohrane v predoslych dokoncenych `Stash`-och + (mimo tvojej ruky a mimo aktualnej rozohranej kopky). +- **(volitelne, neskor — bod 8) znama neúčasť supperov vo farbe (voids)** — + ked supper neprizna vynasanu farbu, prezradi void → per-hrac x per-farba + flag. Silna informacia, ale nechat na neskorsie rozsirenie. + +**Egocentricka rotacia (bod 2) — povinny invariant.** Aby parameter sharing +medzi 4 sedadlami fungoval, VSETKY 4-hracske vektory (tipy, pocty vyhranych +kopiek, sloty aktualnej kopky, `first_player`) musia byt rotovane tak, ze +"ja" = index 0 a ostatni relativne (+1, +2, +3 v smere hry). Toto zapisat do +`encoding.py` ako tvrdy invariant + unit test — je to najpravdepodobnejsie +miesto tichej chyby, ktora pokazi ucenie. + +Zamerne vynechane: priebezne skore/standings naprieč hrou — kedze odmena je +per-round nezavisla, optimalne rozhodnutie v danom kole na standings nezavisi. + +**Velkost observacie (bod 5).** Povodny odhad ~80-100 floatov je podstrelený. +Ak sa 4 sloty aktualnej kopky koduju one-hot (4x32=128) + ruka 32 + videne +karty 32 + tipy/pocty/round_number/first_player, realny `input_dim` je skor +~200. Nie je to problem, len podla toho nastavit vstupnu vrstvu siete. + +## 2. Akcny priestor + maskovanie + +- **Guess**: 9 kategorii (0-8), maskovane na `0..(8-round_number)`; pre 4. + (posledneho) tipujuceho naviac zamaskovat hodnotu, ktora by sposobila + `BridzikException` (sucet tipov = pocet kopiek) — vypocitatelne vopred + z `self.guesses`. Zakazana hodnota = `(8-round_number) - sum(3 tipov)`; + ak vyjde mimo `0..(8-round_number)`, je uz aj tak nelegalna a nemaskuje sa + nic navyse (osetrit rozsah). +- **Play card**: 32 kategorii (rovnaka indexacia farba+hodnota ako hand-encoding), + maskovane na karty, ktore hrac realne ma A splnaju follow-suit pravidlo + (rovnaka logika ako v `Round.play_card`: farba prvej karty v kopke, inak + povinna cervena ak ju hrac ma). + +## 3. Sieť + +Zdielany "trup" (2 hidden layers, ~128-256 neuronov, ReLU) nad observation +vektorom (~80-100 floatov), s troma vystupmi: + +- guess head (9 logitov) +- play head (32 logitov) +- value head (1 scalar) — odhad ocakavanej odmeny do konca kola (baseline + pre actor-critic) + +`input_dim` nastavit podla realnej velkosti observacie (~200, viz bod 5 +v sekcii 1), nie podla povodneho ~80-100. + +Fazovy flag v observacii + maskovanie urcuje, ktora hlava je pouzitelna +v danom kroku (guess a play fazy sa nikdy neprelinaju). + +## 4. Odmena a trening + +- Odmena = 0 pocas kola; na konci kola kazdy hrac dostane + `points_summary[player]` (0 alebo `10+guess`) ako terminalnu odmenu za + VSETKY svoje rozhodnutia v danom kole (guess + vsetky `play_card` tahy). + Sparse terminal reward, ziadne discountovanie netreba — `gamma=1` (bod 9), + kolo ma max 9 rozhodnuti na hraca: round 0 = 1 tip + 8 kariet. +- Algoritmus: self-play PPO (prip. najprv jednoduchsie REINFORCE + baseline), + jedna zdielana siet hra vsetkych 4 hracov v kazdom `Round` (parameter + sharing, rovnaky princip ako AlphaGo Zero). +- **Normalizacia odmeny (bod 7).** `10+guess` je v rozsahu 10-18 a lisi sa + per kolo; pri miesanych `round_number` to zvysuje varianciu policy gradientu. + Standardizovat advantage per batch (odcitat priemer, delit std) — bezna + PPO praktika, tu je nutnejsia kvoli rozne velkym odmenam. +- Paralelizacia: `Round` instancie su nezavisle bez shared state, self-play + generovanie sa da paralelizovat cez multiprocessing naprieč jadrami CPU + (pripadne batchovanim viacerych epizod naraz cez sietovy forward). + +**Caveat: hra nie je zero-sum (bod 3).** Je to 4-hracska general-sum hra — +viacero hracov moze naraz trafit tip a vsetci skoruju, zaroven sa o kopky +sutazi (`sum(kopky) = pocet kopiek`). Self-play so zdielanymi vahami preto +NEMA konvergencne zaruky ako AlphaGo Zero (2-hracska zero-sum); skonverguje +k *nejakemu* equilibriu, nie nutne k optimu, a moze oscilovat. Na ucebny +ciel to staci, ale: (a) nepredavat si to ako "AlphaZero-grade optimalitu", +(b) sledovat progres proti FIXNYM baseline-om (sekcia 5), nie len podla +self-play reward, ktory sa hybe s protihracom. + +## 5. Vyhodnotenie + +- priemerne body/kolo oproti baseline (nahodny legalny hrac, Monte Carlo + heuristicky tipper — pozri sekciu nizsie) +- presnost tipu (% kôl, kde sa tip presne trafil) — interpretovatelnejsia + metrika nez surove body + +## Alternativa/doplnok pre tipovaciu fazu: Monte Carlo namiesto siete + +Kedze tipovanie je v podstate odhad pravdepodobnosti pri neznamom rozdeleni +zvysnych kariet, da sa riesit aj bez siete: + +1. **Naivna MC simulacia** — vygenerovat vela nahodnych rozdeleni zvysnych + kariet medzi ostatnych 3 hracov, odsimulovat kolo s jednoduchou heuristickou + hracou strategiou, spocitat rozdelenie poctu vlastnych kopiek. POZOR: kedze + bodujeme len presnu zhodu, spravny cieľ je **mod** rozdelenia, nie priemer. + POZOR 2 (bod 6 — "discard pile"): `deal_starting_cards` zahodí prvych + `4*round_number` kariet (`round_cards[4*round_number:]`), takze v kole NIE + su rozdane vsetky karty. MC teda z `32 - vlastna_ruka` kariet rozdá kazdemu + z 3 supperov len `(8-round_number)` kariet a **zvysok necha v neznamej kope + mimo hru** — nerozdavat vsetko medzi supperov, inak nadhodnotis, kolko + vysokych kariet/cervene supperi drzia. +2. **Silnejsia verzia** — rovnaky MC rollout, ale simulovat zvysok kola + s uz natrenovanou card-play sietou namiesto naivnej heuristiky (analogia + MCTS + value network v AlphaZero namiesto ciste nahodnych rolloutov). + +Toto sa da pouzit ako rychly heuristicky baseline bez trenovania siete na +tipovanie vobec, alebo ako silnejsi hybrid s uz existujucou play sietou. + +## Poradie implementacie + +1. `rl/encoding.py` — cistě funkcie observation + mask (nad `Round` objektom), + testovatelne izolovane. Uz tu zapracovat bod 1 (videne karty) a bod 2 + (egocentricka rotacia). Unit/property testy: maska NIKDY nepovoli tah, ktory + `Round.play_card`/`add_player_guess` odmietne (fuzz-test proti enginu); + rotacia je konzistentna pre vsetky 4 sedadla. +2. `rl/env.py` — step/reset wrapper okolo jedneho `Round` (nie celeho `Bridzik`) +3. **baseline hraci + evaluacny harness UZ TU** (nahodny legalny hrac, MC + heuristicky tipper podla sekcie vyssie) — nech je metrika k dispozicii od + prvej trenovacej epochy a da sa sledovat progres (bod 3: proti fixnym + baseline-om, nie len self-play reward). +4. sieť (PyTorch, trup + 3 hlavy) — `input_dim` podla realnej velkosti obs (~200) +5. self-play generator (paralelne `Round` epizody) +6. PPO update krok (advantage standardizovat per batch — bod 7) +7. (neskor, volitelne) rozsirit na cely `Series`/`Bridzik` self-play, ak by + sa ukazalo, ze cross-round dynamika (rotacia first_player a pod.) predsa + len nieco mení — podla bodu 1 to nie je ocakavane + +## Technologie + +- **PyTorch** — samostatny `requirements-rl.txt`, oddeleny od zakladneho + `requirements.txt` projektu +- vlastna mensia implementacia PPO (v duchu CleanRL) namiesto Stable-Baselines3/ + RLlib — nas pripad (multi-agent self-play so zdielanymi vahami, maskovanie + akcii) sa bije s ich single-agent Gym abstrakciou viac, nez by pomohla diff --git a/rl/__init__.py b/rl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rl/encoding.py b/rl/encoding.py new file mode 100644 index 0000000..52d44d9 --- /dev/null +++ b/rl/encoding.py @@ -0,0 +1,168 @@ +"""Observation a action-mask encoding nad `Round` objektom z `bridzik.py`. + +Ciste funkcie bez externych zavislosti (ziadny numpy/torch) -- vystupy su +obycajne zoznamy floatov/boolov, konverzia na tenzory je vecou volajuceho. + +Tvrdy invariant (viz rl/DESIGN.md): VSETKY 4-hracske zlozky observacie su +egocentricky rotovane -- "ja" (parameter `player`) je vzdy index 0, ostatni +hraci +1/+2/+3 v smere hry. Bez tejto rotacie parameter sharing medzi +sedadlami nefunguje. + +Masky zrkadlia pravidla enginu: maska NIKDY nesmie povolit akciu, ktoru by +`Round.add_player_guess` / `Round.play_card` odmietli, a naopak. Tuto zhodu +vynucuje fuzz-test v tests/test_encoding.py. +""" + +from bridzik import Card, Card_colors, Card_values, ROUNDS_PER_SERIES + +# Kanonicke poradie farieb a hodnot = poradie deklaracie v enum-och. +COLORS = list(Card_colors) # HEARTS, LEAVES, ACORNS, BELLS +VALUES = list(Card_values) # C7, C8, C9, C10, LOWER, UPPER, KING, ACE + +N_CARDS = 32 +N_GUESS_ACTIONS = 9 # tipy 0..8 +N_PLAY_ACTIONS = N_CARDS # jedna akcia = jedna karta + +_COLOR_INDEX = {color: i for i, color in enumerate(COLORS)} +_VALUE_INDEX = {value: i for i, value in enumerate(VALUES)} + +# Layout observacie -- offsety su sucast verejneho kontraktu (testy aj siet +# sa na ne odkazuju menom, nie magickym cislom). +OFF_HAND = 0 # 32 multi-hot: vlastna ruka +OFF_SEEN = OFF_HAND + N_CARDS # 32 multi-hot: karty z dokoncenych kopiek +OFF_ROUND = OFF_SEEN + N_CARDS # 8 one-hot: round_number +OFF_PHASE = OFF_ROUND + ROUNDS_PER_SERIES # 1 flag: 1.0 = tipovacia faza +OFF_GUESSES = OFF_PHASE + 1 # 4 x (flag "uz tipoval", tip/8), rel. poradie +OFF_TRICKS = OFF_GUESSES + 8 # 4 x (vyhrane kopky / 8), rel. poradie +OFF_STASH = OFF_TRICKS + 4 # 4 x 32 one-hot: aktualna kopka, rel. poradie +OFF_STASH_LEADER = OFF_STASH + 4 * N_CARDS # 4 one-hot: rel. first_player kopky +OFF_VOIDS = OFF_STASH_LEADER + 4 # 4 hraci x 4 farby: dedukovane voidy, rel. poradie +OBS_DIM = OFF_VOIDS + 16 # = 233 + + +def card_index(card: Card) -> int: + """Index karty 0..31: farba (blok po 8) + hodnota.""" + return _COLOR_INDEX[card.color] * 8 + _VALUE_INDEX[card.value] + + +def index_card(index: int) -> Card: + """Inverzia card_index.""" + return Card(COLORS[index // 8], VALUES[index % 8]) + + +def relative_seat(seat: int, player: int) -> int: + """Egocentricka rotacia: `player` -> 0, dalsi v smere hry -> 1, 2, 3.""" + return (seat - player) % 4 + + +def deduce_voids(rnd) -> dict: + """Isto-dedukovane chybajuce farby hracov z priebehu kola. + + Follow-suit pravidlo prezradza: kto nepriznal vynasanu farbu, uz ju nema; + kto pri tom nezahral ani cerven (tromf), nema ani tu. Vynasajuci hrac + neprezradza nic. Vracia dict seat -> set(Card_colors). Kedze karty pocas + kola len ubudaju, raz dedukovany void plati do konca kola. + """ + voids = {seat: set() for seat in range(4)} + for stash in rnd.stashes: + first = stash.get_first_card() + if first is None: + continue + for seat, card in stash.get_cards().items(): + if seat == stash.first_player: + continue + if card.color != first.color: + voids[seat].add(first.color) + if card.color != Card_colors['HEARTS']: + voids[seat].add(Card_colors['HEARTS']) + return voids + + +def encode_observation(rnd, player: int) -> list: + """Observacia kola z pohladu hraca `player` (OBS_DIM floatov v [0, 1]). + + Funguje v oboch fazach (tipovanie aj hra) aj na terminalnom stave; + necita get_active_player(), takze sa da zavolat pre lubovolneho hraca + kedykolvek. + """ + obs = [0.0] * OBS_DIM + + for card in rnd.player_cards[player]: + obs[OFF_HAND + card_index(card)] = 1.0 + + # Dokoncene kopky -> "videne karty"; jedina pripadna nedokoncena kopka + # (posledna) je aktualna rozohrana a koduje sa do slotov nizsie. + current_stash = None + for stash in rnd.stashes: + if stash.is_completed(): + for card in stash.get_cards().values(): + obs[OFF_SEEN + card_index(card)] = 1.0 + else: + current_stash = stash + + obs[OFF_ROUND + rnd.round_number] = 1.0 + obs[OFF_PHASE] = 0.0 if rnd.is_guessing_completed() else 1.0 + + for seat, guess in rnd.guesses.items(): + rel = relative_seat(seat, player) + obs[OFF_GUESSES + 2 * rel] = 1.0 + obs[OFF_GUESSES + 2 * rel + 1] = guess / 8 + + tricks = rnd.get_stashes_winner_summary() + for seat in range(4): + obs[OFF_TRICKS + relative_seat(seat, player)] = tricks[seat] / 8 + + if current_stash is not None: + for seat, card in current_stash.get_cards().items(): + rel = relative_seat(seat, player) + obs[OFF_STASH + rel * N_CARDS + card_index(card)] = 1.0 + obs[OFF_STASH_LEADER + relative_seat(current_stash.first_player, player)] = 1.0 + + for seat, banned in deduce_voids(rnd).items(): + rel = relative_seat(seat, player) + for color in banned: + obs[OFF_VOIDS + rel * 4 + _COLOR_INDEX[color]] = 1.0 + + return obs + + +def guess_mask(rnd) -> list: + """Maska legalnych tipov (N_GUESS_ACTIONS boolov) pre aktivneho tipujuceho. + + Zrkadli Round.add_player_guess: tip 0..(8 - round_number); poslednemu + (stvrtemu) tipujucemu je navyse zakazana hodnota, pri ktorej by sucet + tipov vysiel presne na pocet kopiek v kole. + """ + max_guess = 8 - rnd.round_number + mask = [g <= max_guess for g in range(N_GUESS_ACTIONS)] + if len(rnd.guesses) == 3: + forbidden = max_guess - sum(rnd.guesses.values()) + if 0 <= forbidden <= max_guess: + mask[forbidden] = False + return mask + + +def legal_cards(hand: list, first_card) -> list: + """Karty z `hand`, ktore smie hrac zahrat do kopky vynasanej `first_card`. + + Zrkadli follow-suit logiku Round.play_card: povinna farba prvej karty + kopky; ak ju hrac nema, povinna cervena (tromf); ak nema ani tu, + lubovolna karta. Pri vynasani (`first_card is None`) lubovolna karta. + """ + if first_card is None: + return list(hand) + same_color = [c for c in hand if c.color == first_card.color] + if same_color: + return same_color + hearts = [c for c in hand if c.color == Card_colors['HEARTS']] + return hearts if hearts else list(hand) + + +def play_mask(rnd, player: int) -> list: + """Maska legalnych kariet (N_PLAY_ACTIONS boolov) pre hraca `player`.""" + stash = rnd.get_last_stash() + first_card = stash.get_first_card() if stash is not None else None + mask = [False] * N_PLAY_ACTIONS + for card in legal_cards(rnd.player_cards[player], first_card): + mask[card_index(card)] = True + return mask diff --git a/rl/env.py b/rl/env.py new file mode 100644 index 0000000..174afab --- /dev/null +++ b/rl/env.py @@ -0,0 +1,70 @@ +"""Self-play prostredie nad jednym `Round`-om (viz rl/DESIGN.md). + +Jedno kolo = jedna epizoda. Prostredie je multi-agentne a tahove: v kazdom +kroku je na tahu prave jeden hrac (`Decision.player`), akciu zan doda +volajuci (zdielana siet, heuristika, ...). Odmena je sparse a terminalna -- +`Round.get_points_summary()` pre vsetkych 4 hracov naraz na konci kola. + +Akcie: v tipovacej faze index tipu 0..8, v hracej faze index karty 0..31 +(kanonicke cislovanie z rl/encoding.py). Legalne akcie urcuje +`Decision.mask`; nelegalna akcia prebuble ako BridzikException z enginu. +""" + +from random import Random +from typing import NamedTuple + +from bridzik import Round, ROUNDS_PER_SERIES +from rl.encoding import encode_observation, guess_mask, index_card, play_mask + +PHASE_GUESS = 'guess' +PHASE_PLAY = 'play' + + +class Decision(NamedTuple): + """Jeden rozhodovaci bod: kto je na tahu, v akej faze, co vidi a co smie.""" + player: int + phase: str + obs: list + mask: list + + +class RoundEnv: + def __init__(self, rng: Random = None): + self.rng = rng if rng is not None else Random() + self.round = None + + def reset(self, round_number: int = None, first_player: int = None) -> Decision: + """Zacne novu epizodu; nezadane parametre sa sampluju uniformne.""" + if round_number is None: + round_number = self.rng.randrange(ROUNDS_PER_SERIES) + if first_player is None: + first_player = self.rng.randrange(4) + self.round = Round(round_number, first_player, shuffler=self.rng.shuffle) + return self._decision() + + def step(self, action: int): + """Vykona akciu hraca na tahu. + + Vracia (decision, rewards, done): pocas kola (Decision, None, False), + na konci kola (None, [body 4 hracov], True). + """ + if self.round is None or self.round.is_completed(): + raise RuntimeError('Epizoda nebezi -- najprv zavolaj reset().') + player = self.round.get_active_player() + if not self.round.is_guessing_completed(): + self.round.add_player_guess(player, action) + else: + self.round.play_card(player, index_card(action)) + if self.round.is_completed(): + return None, self.round.get_points_summary(), True + return self._decision(), None, False + + def _decision(self) -> Decision: + player = self.round.get_active_player() + if not self.round.is_guessing_completed(): + return Decision(player, PHASE_GUESS, + encode_observation(self.round, player), + guess_mask(self.round)) + return Decision(player, PHASE_PLAY, + encode_observation(self.round, player), + play_mask(self.round, player)) diff --git a/tests/test_encoding.py b/tests/test_encoding.py new file mode 100644 index 0000000..93709f4 --- /dev/null +++ b/tests/test_encoding.py @@ -0,0 +1,296 @@ +import copy +import random +import unittest + +from bridzik import cards, Card, Card_colors, Card_values, BridzikException, Round +from rl.encoding import ( + COLORS, VALUES, N_CARDS, N_GUESS_ACTIONS, N_PLAY_ACTIONS, + OFF_HAND, OFF_SEEN, OFF_ROUND, OFF_PHASE, OFF_GUESSES, OFF_TRICKS, + OFF_STASH, OFF_STASH_LEADER, OFF_VOIDS, OBS_DIM, + card_index, deduce_voids, index_card, relative_seat, encode_observation, + guess_mask, play_mask, +) + + +class CardIndexCase(unittest.TestCase): + def test_roundtrip_and_uniqueness(self): + indexes = set() + for card in cards: + idx = card_index(card) + self.assertIn(idx, range(N_CARDS)) + self.assertEqual(index_card(idx), card) + indexes.add(idx) + self.assertEqual(len(indexes), N_CARDS) + + def test_layout(self): + # farba = blok po 8, hodnota = pozicia v bloku + self.assertEqual(card_index(Card(Card_colors['HEARTS'], Card_values['C7'])), 0) + self.assertEqual(card_index(Card(Card_colors['HEARTS'], Card_values['ACE'])), 7) + self.assertEqual(card_index(Card(COLORS[3], Card_values['ACE'])), 31) + + +class RotationCase(unittest.TestCase): + def test_relative_seat(self): + for player in range(4): + self.assertEqual(relative_seat(player, player), 0) + # smer hry = rastuce cislo sedadla mod 4 + self.assertEqual(relative_seat((player + 1) % 4, player), 1) + self.assertEqual(relative_seat((player + 3) % 4, player), 3) + + def test_guesses_rotated_for_all_seats(self): + r = Round(0, 2) + guesses = {2: 5, 3: 0, 0: 1, 1: 1} + for seat in [2, 3, 0, 1]: + r.add_player_guess(seat, guesses[seat]) + for player in range(4): + obs = encode_observation(r, player) + for seat in range(4): + rel = relative_seat(seat, player) + self.assertEqual(obs[OFF_GUESSES + 2 * rel], 1.0) + self.assertEqual(obs[OFF_GUESSES + 2 * rel + 1], guesses[seat] / 8) + + def test_partial_guesses_flags(self): + r = Round(3, 1) + r.add_player_guess(1, 2) + for player in range(4): + obs = encode_observation(r, player) + rel = relative_seat(1, player) + self.assertEqual(obs[OFF_GUESSES + 2 * rel], 1.0) + self.assertEqual(obs[OFF_GUESSES + 2 * rel + 1], 2 / 8) + for seat in [0, 2, 3]: + rel = relative_seat(seat, player) + self.assertEqual(obs[OFF_GUESSES + 2 * rel], 0.0) + self.assertEqual(obs[OFF_GUESSES + 2 * rel + 1], 0.0) + + +class ObservationCase(unittest.TestCase): + @staticmethod + def _deterministic_round(): + # rovnaka konstrukcia ako v test_engine.RoundCase.test_play_card + shuffler = lambda l: None + c0 = [Card(Card_colors['BELLS'], Card_values['UPPER']), + Card(Card_colors['HEARTS'], Card_values['UPPER'])] + c1 = [Card(Card_colors['BELLS'], Card_values['C7']), + Card(Card_colors['HEARTS'], Card_values['C10'])] + c2 = [Card(Card_colors['BELLS'], Card_values['ACE']), + Card(Card_colors['BELLS'], Card_values['C8'])] + c3 = [Card(Card_colors['LEAVES'], Card_values['C7']), + Card(Card_colors['BELLS'], Card_values['LOWER'])] + c = ['dummy'] * 24 + c0 + c1 + c2 + c3 + r = Round(6, 1, c, shuffler) + return r, [c0, c1, c2, c3] + + def test_hand_multi_hot(self): + r, hands = self._deterministic_round() + for player in range(4): + obs = encode_observation(r, player) + hand_indexes = {card_index(c) for c in hands[player]} + for i in range(N_CARDS): + self.assertEqual(obs[OFF_HAND + i], 1.0 if i in hand_indexes else 0.0) + + def test_round_number_and_phase(self): + r, _ = self._deterministic_round() + obs = encode_observation(r, 0) + for i in range(8): + self.assertEqual(obs[OFF_ROUND + i], 1.0 if i == 6 else 0.0) + self.assertEqual(obs[OFF_PHASE], 1.0) # tipovacia faza + + for seat, guess in [(1, 0), (2, 0), (3, 1), (0, 2)]: + r.add_player_guess(seat, guess) + obs = encode_observation(r, 0) + self.assertEqual(obs[OFF_PHASE], 0.0) # hracia faza + + def test_current_stash_slots_and_seen(self): + r, hands = self._deterministic_round() + for seat, guess in [(1, 0), (2, 0), (3, 1), (0, 2)]: + r.add_player_guess(seat, guess) + + # rozohrana kopka: hraju 0 a 1 + r.play_card(0, hands[0][0]) + r.play_card(1, hands[1][0]) + for player in range(4): + obs = encode_observation(r, player) + slot0 = relative_seat(0, player) + slot1 = relative_seat(1, player) + self.assertEqual(obs[OFF_STASH + slot0 * N_CARDS + card_index(hands[0][0])], 1.0) + self.assertEqual(obs[OFF_STASH + slot1 * N_CARDS + card_index(hands[1][0])], 1.0) + self.assertEqual(sum(obs[OFF_STASH:OFF_STASH + 4 * N_CARDS]), 2.0) + # leader kopky je hrac 0 (najvyssi tip) + self.assertEqual(obs[OFF_STASH_LEADER + relative_seat(0, player)], 1.0) + # nic este nie je "videne" -- prva kopka nie je dokoncena + self.assertEqual(sum(obs[OFF_SEEN:OFF_SEEN + N_CARDS]), 0.0) + + # dokoncena kopka -> karty sa presunu do SEEN, sloty sa vyprazdnia + r.play_card(2, hands[2][0]) + r.play_card(3, hands[3][1]) + obs = encode_observation(r, 0) + played = [hands[0][0], hands[1][0], hands[2][0], hands[3][1]] + for card in played: + self.assertEqual(obs[OFF_SEEN + card_index(card)], 1.0) + self.assertEqual(sum(obs[OFF_SEEN:OFF_SEEN + N_CARDS]), 4.0) + self.assertEqual(sum(obs[OFF_STASH:OFF_STASH + 4 * N_CARDS]), 0.0) + # novu kopku vynasa vitaz (hrac 2, BELLS ACE) + self.assertEqual(obs[OFF_STASH_LEADER + relative_seat(2, 0)], 1.0) + # pocty vyhranych kopiek rotovane + for player in range(4): + obs = encode_observation(r, player) + self.assertEqual(obs[OFF_TRICKS + relative_seat(2, player)], 1 / 8) + + def test_terminal_state_encodable(self): + r = Round(7, 0) + for seat, guess in [(0, 0), (1, 0), (2, 0), (3, 0)]: + try: + r.add_player_guess(seat, guess) + except BridzikException: + r.add_player_guess(seat, 1) + while not r.is_completed(): + player = r.get_active_player() + mask = play_mask(r, player) + r.play_card(player, index_card(mask.index(True))) + obs = encode_observation(r, 0) + self.assertEqual(len(obs), OBS_DIM) + self.assertEqual(sum(obs[OFF_SEEN:OFF_SEEN + N_CARDS]), 4.0) + + +class VoidsInObservationCase(unittest.TestCase): + def test_voids_encoded_and_rotated(self): + # hrac 0 vynasa zelen; 2 tromfne (void zelen), 3 hodi gulu (void + # zelen aj cerven) -- viz deduce_voids + 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) + r.add_player_guess(1, 0) + r.add_player_guess(2, 0) + r.add_player_guess(3, 1) + + obs = encode_observation(r, 0) + self.assertEqual(sum(obs[OFF_VOIDS:OFF_VOIDS + 16]), 0.0) + + for seat, card in [(0, hand0[0]), (1, hand1[0]), + (2, hand2[0]), (3, hand3[0])]: + r.play_card(seat, card) + + leaves_i = COLORS.index(Card_colors['LEAVES']) + hearts_i = COLORS.index(Card_colors['HEARTS']) + for player in range(4): + obs = encode_observation(r, player) + block = lambda seat: obs[OFF_VOIDS + relative_seat(seat, player) * 4: + OFF_VOIDS + relative_seat(seat, player) * 4 + 4] + self.assertEqual(sum(block(0)), 0.0) # vynasajuci neprezradza nic + self.assertEqual(sum(block(1)), 0.0) # priznal farbu + self.assertEqual(block(2)[leaves_i], 1.0) + self.assertEqual(sum(block(2)), 1.0) + self.assertEqual(block(3)[leaves_i], 1.0) + self.assertEqual(block(3)[hearts_i], 1.0) + self.assertEqual(sum(block(3)), 2.0) + + +class GuessMaskCase(unittest.TestCase): + def test_range_by_round_number(self): + for round_number in range(8): + r = Round(round_number, 0) + mask = guess_mask(r) + for g in range(N_GUESS_ACTIONS): + self.assertEqual(mask[g], g <= 8 - round_number) + + def test_last_guesser_forbidden_value(self): + r = Round(0, 0) + r.add_player_guess(0, 2) + r.add_player_guess(1, 1) + r.add_player_guess(2, 3) + mask = guess_mask(r) + self.assertFalse(mask[2]) # 2+1+3+2 == 8 kopiek -> zakazane + for g in [0, 1, 3, 4, 5, 6, 7, 8]: + self.assertTrue(mask[g]) + + def test_forbidden_value_out_of_range(self): + # sucet tipov > pocet kopiek -> zakazana hodnota by bola zaporna, + # ziadne dodatocne maskovanie + r = Round(0, 0) + r.add_player_guess(0, 8) + r.add_player_guess(1, 5) + r.add_player_guess(2, 0) + mask = guess_mask(r) + self.assertEqual(mask, [True] * 9) + + +class MaskEngineConsistencyCase(unittest.TestCase): + """Fuzz: maska presne zrkadli engine -- povolena akcia NIKDY nezlyha, + zakazana akcia VZDY vyhodi BridzikException.""" + + def _check_guess_mask(self, rnd, player, mask): + for g in range(N_GUESS_ACTIONS): + if mask[g]: + copy.deepcopy(rnd).add_player_guess(player, g) + else: + with self.assertRaises(BridzikException): + rnd.add_player_guess(player, g) + + def _check_play_mask(self, rnd, player, mask): + self.assertIn(True, mask) # aktivny hrac ma vzdy legalny tah + for i in range(N_PLAY_ACTIONS): + if mask[i]: + copy.deepcopy(rnd).play_card(player, index_card(i)) + else: + with self.assertRaises(BridzikException): + rnd.play_card(player, index_card(i)) + + def _check_observation(self, rnd, player): + obs = encode_observation(rnd, player) + self.assertEqual(len(obs), OBS_DIM) + for v in obs: + self.assertGreaterEqual(v, 0.0) + self.assertLessEqual(v, 1.0) + hand = {card_index(c) for c in rnd.player_cards[player]} + for i in range(N_CARDS): + self.assertEqual(obs[OFF_HAND + i], 1.0 if i in hand else 0.0) + if i in hand: # ruka a videne karty su disjunktne + self.assertEqual(obs[OFF_SEEN + i], 0.0) + # zakodovany void nikdy neprotireci realnej ruke hraca + for seat in range(4): + rel = relative_seat(seat, player) + held = {c.color for c in rnd.player_cards[seat]} + for ci, color in enumerate(COLORS): + if obs[OFF_VOIDS + rel * 4 + ci] == 1.0: + self.assertNotIn(color, held) + + def _fuzz_round(self, rng, round_number, first_player): + rnd = Round(round_number, first_player) + for _ in range(4): + player = rnd.get_active_player() + self._check_observation(rnd, player) + mask = guess_mask(rnd) + self._check_guess_mask(rnd, player, mask) + rnd.add_player_guess( + player, rng.choice([g for g in range(N_GUESS_ACTIONS) if mask[g]]) + ) + while not rnd.is_completed(): + player = rnd.get_active_player() + self._check_observation(rnd, player) + mask = play_mask(rnd, player) + self._check_play_mask(rnd, player, mask) + rnd.play_card( + player, index_card(rng.choice([i for i in range(N_PLAY_ACTIONS) if mask[i]])) + ) + # kolo dohrane do konca cisto cez masky -> bodovanie funguje + self.assertEqual(len(rnd.get_points_summary()), 4) + + def test_fuzz_all_round_numbers_and_seats(self): + rng = random.Random(1337) + for round_number in range(8): + for first_player in range(4): + for _ in range(3): + self._fuzz_round(rng, round_number, first_player) + + +if __name__ == '__main__': + unittest.main(verbosity=2)