seminars.fb

Programming Fundamentals → “OO Design”

Seminar (Fri, Dec 18, 2020; 11 PM PST)

Theme: Programming Fundamentals

Topic: OO Design

Keywords: objects, classes, design

Presenter James Powell james@dutc.io
Date Friday, December 18, 2020
Time 11:00 PM PST
print("Let's go!")

Game: Rock, Paper, Scissors

Rules

The game “Rock, Paper, Scissors” is played as follows:

Task: write a function to evaluate the rules of the game.

# NOTE: for naming & design purposes, you may assume the players are directional
#       i.e., `a` is the Player
#             `b` is the Challenger
#       e.g., `rules` could return "player wins" or "player loses"
#              or it could "player wins" vs "challenger wins"
# QUESTION: how do you represent ties?
def rules(a, b):
    ''' return who wins, given shapes played by two players a and b '''
    pass

Task: write a framework that can evaluate a strategy and play the game for 10,000 rounds given a pairing of strategies.

from random import choice

def random_strategy():
    ''' randomly select a shape '''
    return choice(['rps'])

# other sample strategies…

# QUESTION: how do we track "history" here?
def beat_previous_play():
    ''' select the shape that would beat the opponent's previous play '''
    pass

def most_common_play(n=3):
    ''' select the most common shape from the opponent's previous N plays '''
    pass

games = [(random_strategy(), random_strategy()) for _ in range(10_000)]
results = [rules(a, b) for a, b in games]

Game: Queen’s Gambit

Data

We have two datafiles in “Portable Game Notation” representing a series of chess games played in recent years.

We will use a third-party toolkit python-chess to load these and generate simpler files for our consumption.

NOTE: you do not need to install python-chess for this exercise!

from chess import WHITE, BLACK, PAWN, ROOK, KNIGHT, BISHOP, QUEEN, KING
from chess.pgn import read_game
from itertools import product
from collections import namedtuple
from enum import Enum
from pathlib import Path

class Colors(Enum):
    Black = BLACK
    White = WHITE

class Pieces(Enum):
    Pawn   = PAWN
    Rook   = ROOK
    Knight = KNIGHT
    Bishop = BISHOP
    Queen  = QUEEN
    King   = KING

if __name__ == '__main__':
    for input_filename in (Path(x) for x in {'carlsen.pgn', 'croatian.pgn'}):
        output_filename = input_filename.with_suffix('.csv')

        games = []
        with open(input_filename) as f:
            while (g := read_game(f)):
                games.append(g)

        class Row(namedtuple('RowBase', 'game move color piece positions')):
            def to_csv(self):
                return f'{self.game}, {self.move}, {self.color.name}, {self.piece.name}, {", ".join(f"{x}" for x in self.positions)}\n'

        rows = []
        for game_no, g in enumerate(games):
            b = g.board()
            for move_no, m in enumerate(g.mainline_moves()):
                b.push(m)
                for col, pc in product(Colors, Pieces):
                    if b.pieces(pc.value, col.value):
                        r = Row(game_no, move_no, col, pc, [*b.pieces(pc.value, col.value)])
                        rows.append(r)

        with open(output_filename, 'w') as f:
            for r in rows:
                if r.positions:
                    f.write(r.to_csv())

Actual Data

These files contain information about these games in the following CSV format (with no header):

Play

Read in the above data using pandas, numpy, or xarray (or some combination thereof) and determine the following things:

from pandas import read_csv
df = read_csv('carlsen.csv',
              header=None,
              names='game move side piece pos0 pos1 pos2 pos3 pos4 pos5 pos6 pos7'.split())

print(df.sample(3))

Game: Blackjack

General Rules

Here are the general rules for a real game of Blackjack:

Simplified Rules (for Problems Below)

Disclaimer

The solution codes provided in module are intended to demonstrate the use of Python language features that we have covered at this point our journey together.

There may be other language or library features that we will cover at a later stage in this course that may be able to further refine these solutions.

Problem I

Part (i):

# TASK: using `random.randrange`, write a programme that loops
#       until the sum of the cards in your hand is ≥ 21
#       - use a `while`-loop
#       - use a single variable `hand_total` to represent the total
#         value of your hand
#       - use *simplified* rules (where Ace is strictly worth 11)
#       - at the end, print out whether you won or went bust
from random import randrange
hand = 0
# HELPER: the `while` loop in Python loops until some predicate is no longer true

x = 10
while x > 0:
    print(f'{x = }')
    x -= 1

print('-' * 20)

x = 10
while True: # do-while
    print(f'{x = }')
    x -= 1
    if x < 0:
        break
# HELPER: `random.randrange` returns a number between [start, stop)—i.e., from
#          some starting number `start` up to (but not including) some stopping
#         number `stop`
#         e.g., the below gives you numbers on the interval [1, 10)
from random import randrange
for _ in range(3):
    print(f'{randrange(1, 10) = }') # NOTE: you will never see the number `10`

Part (ii):

# TASK: repeat the above simulation 100 times and compute the percentage of 
#       trial runs in which you go bust

Solution I

Part (i):

from random import randrange

hand = 0

while hand <= 21:
    card = randrange(1, 11 + 1) 
    hand += card
    print(f'Got card worth: {card}')

if hand == 21:
    print(f'Blackjack!')
elif hand > 21:
    print(f'Bust! @ {hand}')
else:
    print(f'Under 21! @ {hand}')

Part (ii):

from random import randrange

num_bust = 0
NUM_TRIALS = 150
for _ in range(NUM_TRIALS):
    hand = 0
    while hand < 21:
        card = randrange(1, 11 + 1) 
        hand += card

    if hand > 21:
        num_bust += 1

print(f'You went bust {num_bust / NUM_TRIALS * 100}% of the time')

Problem II

# TASK: model a deck of 52 cards; use `random.shuffle` to shuffle the deck,
#       dealing cards to yourself until your card value is ≥ 21
#       - use *simplified* rules (where Ace is strictly worth 11)
from random import shuffle

suits = ...
faces = ...
deck = [...]
shuffle(deck)

Solution II

from random import shuffle

suits  = 'C D H S'.split()
faces  = '2 3 4 5 6 7 8 9 10 J Q K A'.split()
values = [2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 10, 10, 11]

deck = []
for s in suits:
    for f in faces:
        card = s, f
        deck.append(card)
shuffle(deck)

hand = []
while True:
    card = deck.pop()
    hand.append(card)
    total_value = 0
    for _, f in hand:
        value = values[faces.index(f)]
        total_value += value
    if total_value >= 21:
        break
print(f'{hand = }')

total_value = 0
for _, f in hand:
    value = values[faces.index(f)]
    total_value += value
if total_value == 21:
    print(f'Blackjack! {hand = }')
elif total_value > 21:
    print(f'Bust! @ {total_value} {hand = }')
else:
    print(f'Under… @ {total_value} {hand = }')

Problem III

# TASK: given your deck of cards, run your simplified game of Blackjack 100
#       times, and compute the probability of going bust

Solution III

from random import shuffle

suits  = 'C D H S'.split()
faces  = '2 3 4 5 6 7 8 9 10 J Q K A'.split()
values = [2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 10, 10, 11]

TOTAL_GAMES = 100
games = []
for _ in range(TOTAL_GAMES):
    deck = []
    for s in suits:
        for f in faces:
            card = s, f
            deck.append(card)
    shuffle(deck)

    hand = []
    while True:
        card = deck.pop()
        hand.append(card)
        total_value = 0
        for _, f in hand:
            value = values[faces.index(f)]
            total_value += value
        if total_value >= 21:
            break
    games.append(hand)
#  print(f'{games = }')

results = []
for hand in games:
    total_value = 0
    for _, f in hand:
        value = values[faces.index(f)]
        total_value += value
    if total_value > 21:
        res = hand, 'bust'
    elif total_value == 21:
        res = hand, 'blackjack'
    elif total_value < 21:
        res = hand, 'miss'
    results.append(res)
#  print(f'{results = }')

num_bust = 0
for _, res in results:
    if res == 'bust':
        num_bust += 1
#  print(f'{num_bust = }')
print(f'{num_bust / len(results) * 100 = :.2f}%')

Additional Problems

# TASK: Repeat Problem III above, but allow Ace to be worth EITHER 1 or 11.
# TASK: Repeat Problem III above, but follow dealer's rules, allow the player
#       to stop after ≥17.
# TASK: Repeat Problem III above, but implement multiple strategies:
#       - strategy (i): stop at 21
#       - strategy (ii): stop after ≥17
#       - strategy (iii): stop if the probability of going bust is >50%
#                         (*without* keeping track of the cards that have been dealt)
#       - strategy (iv): stop if the probability of going bust is >50%
#                         (*with* keeping track of the cards that have been dealt)
#       Compute the probability of going bust for each strategy.
from collections import namedtuple
from enum import Enum
from dataclasses import dataclass
from itertools import product, zip_longest
from random import shuffle
from enum import Enum, auto
from collections import namedtuple, Counter, defaultdict

class Values(Enum):
    Two   = 2
    Three = auto()
    Four  = auto()
    Five  = auto()
    Six   = auto()
    Seven = auto()
    Eight = auto()
    Nine  = auto()
    Ten   = auto()
    Jack  = 10
    Queen = 10
    King  = 10
    Ace   = 11
    def __add__(self, other):
        if isinstance(other, Card):
            other = other.value
        return self.value + other
    def __str__(self):
        return dict(zip(Values, '2 3 4 5 6 7 8 9 10 J Q K A'.split()))[self]

class Suits(Enum):
    Diamonds = 0
    Clubs    = auto()
    Hearts   = auto()
    Spades   = auto()
    def __str__(self):
        return '\N{black diamond suit} \N{black club suit} \N{black heart suit} \N{black spade suit}'.split()[self.value]

class Card(namedtuple('CardBase', 'value suit')):
    def __add__(self, other):
        if isinstance(other, Card):
            other = other.value
        return self.value + other
    __radd__ = __add__
    def __str__(self):
        return f'{self.value}{self.suit}'

class Hand(namedtuple('Hand', 'cards')):
    @classmethod
    def from_cards(cls, *cards):
        return cls([*cards])
    def __str__(self):
        return ' '.join(f'{c}' for c in self.cards)
    @property
    def total(self):
        return sum(self.cards)

Strategy = namedtuple('Strategy', 'func name author')
STRATEGIES = {}
def strategy(name, author):
    def dec(func):
        STRATEGIES[func] = Strategy(func, name, author)
        return func
    return dec

@strategy(
    name = 'Hit Until 21',
    author = 'James',
)
def hit_until_21(hand, deck):
    while True:
        if hand.total >= 21:
            break
        hand.cards.append(deck.pop())
        yield

@strategy(
    name = 'Hit Until 17',
    author = 'Jiyuan',
)
def hit_until_17(hand, deck):
    while True:
        if hand.total >= 17:
            break
        hand.cards.append(deck.pop())
        yield

@strategy(
    name = 'Hit Until Prob of Bust > 50%',
    author = 'Mahmut',
)
def hit_until_prob(hand, deck):
    while True:
        prob = sum(Hand.from_cards(*hand.cards, c).total > 21 for c in deck) / len(deck)
        if prob > .50:
            break
        hand.cards.append(deck.pop())
        yield

if __name__ == '__main__':
    wins = defaultdict(Counter)
    for m_strat, jy_strat in product(STRATEGIES.values(), STRATEGIES.values()):
        for _ in range(1_000):
            deck = [Card(v, s) for v, s in product(Values, Suits)]
            shuffle(deck)

            m_hand  = Hand.from_cards(deck.pop(), deck.pop())
            jy_hand = Hand.from_cards(deck.pop(), deck.pop())
            for _ in zip_longest(m_strat.func(m_hand, deck), jy_strat.func(jy_hand, deck)):
                if m_hand.total == 21 or jy_hand.total > 21:
                    wins[m_strat, jy_strat]['ms'] += 1
                    break
                elif m_hand.total > 21 or jy_hand.total == 21:
                    wins[m_strat, jy_strat]['jy'] += 1
                    break
            else:
                if m_hand.total > jy_hand.total:
                    wins[m_strat, jy_strat]['ms'] += 1
                else:
                    wins[m_strat, jy_strat]['jy'] += 1

    for (a, b), cnt in wins.items():
        print(f'ms: {a.name}\njy: {b.name}')
        for play, count in cnt.most_common():
            print(f'  {play}: {count}')
        print()
print(f'{m_hand       = !s}')
print(f'{m_hand.total = !s}')
hit_until_21(m_hand, deck)
print(f'{m_hand       = !s}')
print(f'{m_hand.total = !s}')

print('-' * 10) 

print(f'{jy_hand       = !s}')
print(f'{jy_hand.total = !s}')
hit_until_17(jy_hand, deck)
print(f'{jy_hand       = !s}')
print(f'{jy_hand.total = !s}')