Last active
March 20, 2024 21:42
-
-
Save arseniiv/9d7826d82d1b2ee4f52ad407c9ff367a to your computer and use it in GitHub Desktop.
Comparing simple strategies of playing one-suit black jack with individual decks for each player
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| from __future__ import annotations | |
| from collections import defaultdict | |
| from dataclasses import dataclass | |
| from fractions import Fraction | |
| from typing import Callable, Final, Iterable, Iterator | |
| @dataclass(frozen=True, slots=True) | |
| class Prob[TDom, TProb: (float, Fraction)]: | |
| """Probability monad.""" | |
| dist: tuple[tuple[TDom, TProb | int], ...] | |
| def __init__(self, dist: Iterable[tuple[TDom, TProb | int]]) -> None: | |
| dist = tuple((val, prob) for val, prob in dist if prob) | |
| if not dist: | |
| raise ValueError('distribution has zero probability') | |
| object.__setattr__(self, 'dist', dist) | |
| def __iter__(self) -> Iterator[tuple[TDom, TProb | int]]: | |
| return iter(self.dist) | |
| def values(self) -> Iterator[TDom]: | |
| for items in self.dist: | |
| yield items[0] | |
| def total(self) -> TProb | int: | |
| return sum(items[1] for items in self.dist) | |
| def __truediv__(self, other: TProb) -> Prob[TDom, TProb]: | |
| return Prob((val, prob / other) for val, prob in self.dist) | |
| def map[TDom2](self, fun: Callable[[TDom], TDom2]) -> Prob[TDom2, TProb]: | |
| res: defaultdict[TDom2, TProb | int] | |
| res = defaultdict(lambda: 0) | |
| for val, prob in self.dist: | |
| res[fun(val)] += prob | |
| return Prob(res.items()) | |
| @staticmethod | |
| def pure(val: TDom, type_: type[TProb]) -> Prob[TDom, TProb]: | |
| return Prob(((val, type_(1)),)) | |
| @staticmethod | |
| def uniform(values: Iterable[TDom], type_: type[TProb]) -> Prob[TDom, TProb]: | |
| values = tuple(values) | |
| prob = 1 / type_(len(values)) | |
| res: defaultdict[TDom, TProb | int] | |
| res = defaultdict(lambda: 0) | |
| for val in values: | |
| res[val] += prob | |
| return Prob(res.items()) | |
| def bind[TDom2](self, monadic_fun: Callable[[TDom], Iterable[tuple[TDom2, TProb | int]]]) -> Prob[TDom2, TProb]: | |
| res: defaultdict[TDom2, TProb | int] | |
| res = defaultdict(lambda: 0) | |
| for val, prob in self.dist: | |
| for val2, prob2 in monadic_fun(val): | |
| res[val2] += prob * prob2 | |
| return Prob(res.items()) | |
| def sorted(self) -> Prob[TDom, TProb]: | |
| return Prob(tuple(sorted(self.dist))) | |
| def probability(self, predicate: Callable[[TDom], bool]) -> TProb | int: | |
| return sum(prob for val, prob in self.dist if predicate(val)) | |
| type Hand = tuple[int, frozenset[int]] # points total and cards | |
| DECK: Final = frozenset(range(1, 13 + 1)) | |
| EMPTY_HAND: Final[Hand] = (int(), frozenset()) | |
| def linear_points(points: int) -> int: | |
| """Allows determining who won by just comparing (larger result wins).""" | |
| # … < 23 < 22 < 0 < 1 < … < 20 < 21 | |
| return points if points <= 21 else -points | |
| def stands_at(points: int) -> Prob[int, Fraction]: | |
| """The distribution of scores for a player who doesn’t grab cards after getting this many points.""" | |
| def enough(dist: Prob[Hand, Fraction]) -> bool: | |
| return all(val[0] >= points for val in dist.values()) | |
| def add_to_hand(hand: Hand, card: int) -> Hand: | |
| total = hand[0] + (card if card != 1 else 11) | |
| if card == 1 and total > 21: | |
| total -= 10 # ace taken to be 1 point instead of 11 | |
| return total, hand[1] | {card} | |
| def hit(hand: Hand) -> Prob[Hand, Fraction]: | |
| """Add another card, if appropriate, probabilistically.""" | |
| if hand[0] >= points: | |
| return Prob.pure(hand, Fraction) | |
| draw = DECK - hand[1] | |
| return Prob.uniform((add_to_hand(hand, card) for card in draw), Fraction) | |
| dist = Prob.pure(EMPTY_HAND, Fraction) | |
| while not enough(dist): | |
| dist = dist.bind(hit) | |
| return dist.map(lambda hand: hand[0]) | |
| def main() -> None: | |
| PLAYERS: Final = tuple(stands_at(n).sorted() for n in range(21 + 1)) | |
| for n1, player1 in enumerate(PLAYERS): | |
| print(f'Player who stands at {n1}:') | |
| dist = player1.dist | |
| print(', '.join(f'{val}: {prob:.0%}' for val, prob in dist)) | |
| for n2, player2 in enumerate(PLAYERS[n1:], start=n1): | |
| score = player1.bind( | |
| lambda pts1: player2.map( | |
| lambda pts2: linear_points(pts1) - linear_points(pts2))) | |
| wins = score.probability(lambda x: x > 0) | |
| draws = score.probability(lambda x: x == 0) | |
| loses = 1 - wins - draws | |
| if wins > loses: | |
| print(f'Wins against {n2} in {wins} ≈ {wins:.3%}') | |
| elif wins < loses: | |
| print(f'Loses to {n2} in {loses} ≈ {loses:.3%}') | |
| else: | |
| print(f'Wins/loses equally with {n2} in {wins} ≈ {wins:.3%}') | |
| if draws: | |
| print(f'Draws with {n2} in {draws} ≈ {draws:.3%}') | |
| print() | |
| if __name__ == '__main__': | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment