2
\$\begingroup\$

I want to test some different endgame scenarios for one-handed Solitaire where the removal of the cards if the suit or value match is optional.

I've written a simple generator which yields at the decision point on every opportunity where it could clear cards and accepts a boolean on whether it will clear those cards.

Any feedback on usability of the generators is appreciated.

I want to be able to attempt different algorithms for end states (<10 cards) and get some data on how optional choices can improve win odds, while also being able to look at the discards, cards on stack and unseen cards to be able to determine if the game is even winnable or if it's optimal to simply remove as many cards as possible.

Given how I need access to the deck, stack and discard should I rearrange these in any way? As is the winnable boolean would have to be a property of OneHandedSolitaire as it is the only area that knows of all 3 lists of value.

"""One handed solitaire with a rule change wherein removing cards is optional.

This choice in the majority of cases won't be an optimal play except for in end game scenarios where an actual victory
is still possible and based on the suits or values available the remaining number of cards can influence decisions.
"""
import copy
import itertools
import operator
import random
from collections import namedtuple, Counter

SUITS = ['♠', '♥', '♦', '♣']
VALUES = list(map(str, range(2, 11))) + ['J', 'Q', 'K', 'A']
Card = namedtuple('Card', ['suit', 'value'])
UNSHUFFLED_DECK = list((Card(*c_v) for c_v in itertools.product(SUITS, VALUES)))


class Deck:
    def __init__(self):
        self.deck = copy.copy(UNSHUFFLED_DECK)

    def shuffle(self):
        random.shuffle(self.deck)

    def draw(self):
        """Return a card; raises exception on an empty deck"""
        try:
            return self.deck.pop()
        except IndexError:
            raise RuntimeError("Deck is empty")


class FourStack:
    """Stack for comparison of cards between the top and fourth from top cards; also saves discarded cards from stack"""
    def __init__(self):
        self.cards = []
        self.discards = []

    def add_card(self, card):
        self.cards.append(card)

    @property
    def suit(self) -> bool:
        """Compare the suit of the stack"""
        try:
            if self.cards[-1].suit == self.cards[-4].suit:
                return True
        # If there are <4 cards in the stack cannot match
        except IndexError:
            pass
        return False

    def remove_middle(self):
        for _ in range(2):
            self.discards.append(self.cards.pop(-2))

    @property
    def value(self) -> bool:
        """Compare the value of the stack"""
        try:
            if self.cards[-1].value == self.cards[-4].value:
                return True
        # If there are <4 cards in the stack cannot match
        except IndexError:
            pass
        return False

    def remove_all(self):
        for _ in range(4):
            self.discards.append(self.cards.pop(-1))


class OneHandedSolitaire:
    def __init__(self):
        self.new_game()

    def new_game(self):
        self._setup()
        self._start()

    def _setup(self):
        self.deck = Deck()
        self.deck.shuffle()
        self.stack = FourStack()

    def _start(self):
        """Start the game"""
        for _ in range(4):
            self.flip()

    def flip(self):
        """Flip a card over and put it onto the stack"""
        self.stack.add_card(self.deck.draw())

    def compare(self):
        """Compare the top card and the 4th from the top and provide options

        These are the functional rules of one handed solitaire. Matching values remove all four cards on top of the
        stack, matching suits remove the middle two cards from the top four.

        returns a method which will clear cards or None
        """
        if self.stack.value:
            return self.stack.remove_all
        if self.stack.suit:
            return self.stack.remove_middle

    def choice_gen(self):
        """Yield the next decision point of the game"""
        while True:
            choice = self.compare()
            if choice:
                yield choice
            try:
                self.flip()
            except RuntimeError:
                return

    def play_gen(self):
        """Play a game of one handed solitaire.

        With this generator send True to clear and False to not clear the cards. You can inspect either the yielded
        method to see how many and which cards will be removed.
        """
        for choice in self.choice_gen():
            clear = yield
            if clear:
                choice()
        score = len(self.stack.cards)
        return score

    def play_clear(self):
        """Play a game where the user always removes every card they can and return the score."""
        gen = self.play_gen()
        gen.send(None)
        try:
            while True:
                gen.send(True)
        except StopIteration as e:
            score = e.value
            return score

    def winnable(self) -> bool:
        """Determining absolutely if the game is winnable is logically and computationally difficult.

        This being True means we can't eliminate victory, but doesn't guarantee a victory is possible.
        """
        # Empty deck remaining cards
        # Edge case: technically there could be a valid series of ignored choices that could clear the stack
        if not self.deck.deck and self.stack.cards:
            return False

        remaining_cards = self.deck.deck + self.stack.cards

        if len(remaining_cards) == 2:
            return False

        # no matches remaining
        matches = itertools.groupby(remaining_cards, key=operator.itemgetter('value'))
        for _, cards in matches:
            if len(cards) <= 1:
                break
        # nobreak
        else:
            return False

        # matches remain, but they are all within 3 cards of each other in ordering

        return True


def play_clear_until_win():
    ohs = OneHandedSolitaire()
    games = 1
    score = True
    while score:
        score = ohs.play_clear()
        print(f"Score was {score} after {games} games")
        ohs.new_game()
        games += 1


def play(n):
    c = Counter()
    ohs = OneHandedSolitaire()
    for _ in range(n):
        score = ohs.play_clear()
        c.update([score])
        ohs.new_game()
    return c


if __name__ == "__main__":
    runs = play(300000)
    print(f"Counter data: {runs}")
    print(f"Most common score: '{runs.most_common()}'")
    win_prct = runs[0] / (sum(runs.values()))
    print(f"Winning odds: {win_prct}")

Thanks for reading!

\$\endgroup\$
  • 2
    \$\begingroup\$ VALUES seems wrong to me — it has two aces but no tens. \$\endgroup\$ – Gareth Rees Sep 17 '18 at 8:19
  • \$\begingroup\$ @GarethRees Thanks, you're completely right! It won't affect the odds, but it's a good change for legibility! \$\endgroup\$ – Brian Sep 17 '18 at 13:52

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.