Cassino Capstone#

In our latest micro-training, “Good→Better→Best Python,” we discussed numerous in-depth examples of object-oriented programming in Python, various applications, and general guidance on what features of object-oriented programming you should use and when you might code yourself into a corner.

This was our first micro-training session to include an additional “Capstone Project” session; an additional ticket tier that offers a three-hour, interactive and hands-on session in which a small group of attendees take the resulting code written in the lab sessions and extended it into a full-fledged web app suitable for showing to current or prospective employers and colleagues.

For this capstone project, we implemented the game of Cassino not only in Python, but in a React application on a Python server (Starlette). This past week, we had our kick-off meeting to get the ball rolling on this project.

As a group, we made our way through some React code in order to provide a framework to play our game. We discussed the differences between websockets and REST APIs and decided that we might need to take the application in either direction for maximum flexibility. In a production setting, we may not have made the same choice, but this is more of a learning project anyway. Setting up the initial React application was tricky, as none of us are React experts (this is a Python capstone, after all), but, thankfully, we had support from one of our attendees who helped us navigate the application complexity (big shout out to Jef!).

After we had a baseline application running, it was time to make improvements to our backend, where we would actually contain the logic for playing our game of Cassino. We worked together to decide which entities to model and how we wanted to structure our API. While we didn’t complete the entire game in this session, we crafted a great starting point for our attendees to use to reach the finish line.

Our attendees are setting aside time to collaborate further on this project so that they can publicly use and share the finished product as a résumé and skill-building activity!

Game Engine Starter Code#

I wanted to share the initial modeling of our game of Cassino with you all so you can see the approaches we took. We started with a preference for immutable game states with a fairly heavy use of object-orientation. All that’s left is tie these pieces together to formulate a game of Cassino!

from collections import deque
from random import Random
from enum import Enum
from itertools import product
from functools import cached_property, reduce
from dataclasses import dataclass, replace
from collections import namedtuple
from operator import or_
from typing import Union
from sys import exit; import sys; sys.breakpointhook = exit; del sys, exit

Suit = Enum('Suit', '''
    Diamond Club
    Heart Spade
''')
Rank = Enum('Rank', '''
    Two Three Four Five Six
    Seven Eight Nine Ten
    Jack Queen King Ace
''')

class Card(namedtuple('Card', 'rank suit')):
    SUITS = {
        Suit.Diamond:  '\N{black diamond suit}',
        Suit.Club:     '\N{black club suit}',
        Suit.Heart:    '\N{black heart suit}',
        Suit.Spade:    '\N{black spade suit}',
    }
    RANKS = {
        Rank.Two:    '2', Rank.Three:  '3', Rank.Four:   '4',
        Rank.Five:   '5', Rank.Six:    '6', Rank.Seven:  '7',
        Rank.Eight:  '8', Rank.Nine:   '9', Rank.Ten:    '10',
        Rank.Jack:   'J', Rank.Queen:  'Q', Rank.King:   'K',
        Rank.Ace:    'A',
    }
    VALUES = {
        Rank.Ace:    1, Rank.Two:    2, Rank.Three:  3,
        Rank.Four:   4, Rank.Five:   5, Rank.Six:    6,
        Rank.Seven:  7, Rank.Eight:  8, Rank.Nine:   9,
        Rank.Ten:    10,
    }

    @cached_property
    def value(self):
        return self.VALUES.get(self.rank)

    @cached_property
    def symbol(self):
        return f'{self.RANKS[self.rank]}{self.SUITS[self.suit]}'

STANDARD_DECK = [Card(r, s) for r, s in product(Rank, Suit)]

# trail: discard
# combine: build
# pair: capture

@dataclass(frozen=True)
class Player:
    name   : str
    points : int = 0
    @classmethod
    def from_name(cls, name):
        return cls(name=name)

    def __hash__(self):
        return hash(self.name)

@dataclass(frozen=True)
class Unit:
    cards : frozenset[Card]
    value : Union[int, None] = None

    @classmethod
    def from_card(cls, card):
        return cls(cards=frozenset({card}), value=card.value)

    def render(self):
        if len(self.cards):
            return '【{}】'.format(' '.join(c.symbol for c in self.cards))
        return ' '.join(c.symbol for c in self.cards)

    def __or__(self, other):
        if self.value is None or other.value is None:
            raise ValueError('cannot combine')
        if (self.value + other.value) > max(Card.VALUES.values()):
            raise ValueError('cannot combine')
        return Unit(cards=frozenset({*self.cards, *other.cards}), value=self.value+other.value)

    def __hash__(self):
        return hash(self.cards)

@dataclass(frozen=True)
class State:
    deck    : deque[Card]
    table   : frozenset[Unit]
    players : frozenset[Player]
    hands   : dict[Player, frozenset[Card]]
    capture : dict[Player, frozenset[Card]]

    @classmethod
    def from_players(cls, deck, *players):
        return cls(
            deck=deck,
            table=frozenset(),
            players=frozenset(players),
            hands={pl: frozenset() for pl in players},
            capture={pl: frozenset() for pl in players},
        )

    def with_deal(self):
        deck = [*self.deck]
        table = {*self.table}
        hands = {pl: {*h} for pl, h in self.hands.items()}
        for _ in range(2):
            for h in hands.values():
                h.update(deck.pop() for _ in range(2))
            table.update(Unit.from_card(deck.pop()) for _ in range(2))
        return replace(self, deck=deck, table=frozenset(table), hands={k: frozenset(v) for k, v in hands.items()})

    def with_discard(self, player, card):
        deck = [*self.deck]
        table = {*self.table}
        hand = {*self.hands[player]}

        hand.remove(card)
        table.add(Unit.from_card(card))
        hand.add(deck.pop())

        return replace(self, deck=deck, table=frozenset(table), hands={**self.hands, pl: frozenset(hand)})

    def with_build(self, player, card, *targets):
        deck = [*self.deck]
        table = {*self.table}
        hand = {*self.hands[player]}

        hand.remove(card)
        table.difference_update(targets)
        table.add(reduce(or_, {*targets, Unit.from_card(card)}))
        hand.add(deck.pop())

        return replace(self, deck=deck, table=frozenset(table), hands={**self.hands, pl: frozenset(hand)})

    def with_capture(self, player, card, *targets):
        deck = [*self.deck]
        table = {*self.table}
        hand = {*self.hands[player]}

        if not all(card.value == t.value for t in targets):
            raise ValueError(f'cannot capture {targets} with {card}')

        hand.remove(card)
        table.difference_update(targets)
        hand.add(deck.pop())

        return replace(self, deck=deck, table=frozenset(table), hands={**self.hands, pl: frozenset(hand)})

    def render(self):
        return [
            f'Table: {" ".join(u.render() for u in self.table)}',
            *(
                f'{pl.name:<10} {" ".join(c.symbol for c in h)}'
                for pl, h in sorted(self.hands.items(), key=lambda pl_h: pl_h[0].name)
            ),
        ]

If you joined us for our workshops and labs these past couple weeks on object orientation, then this code might look a little familiar!

Would you have modeled this game any differently or taken a different approach? We would love to hear your feedback in our Discord channel! You can join the conversation using this link.

Wrap-Up#

This capstone project has been great fun so far, and we have a group of extremely talented individuals collaborating on it. I can’t wait to see what they put together for this project. Talk to you all again next week!