3
\$\begingroup\$

I am writing a text-based freecell game in python 3. I am looking for a way to generate hints. A hint should simply be all the possible moves that you can make, excluding moving a card to a free cell (because you can always do that). It should also handle moving stacks of cards (stacks are lists of cards where each card is of one higher rank and opposite color than the one below it) to other columns. Unfortunately, because there are so many cases to handle that are very similar but slightly different, (moving cards from the tableau to foundation, free cell to foundation, free cell to tableau, tableau to tableau) I feel like my code is being repeated a lot, and I find myself copying and pasting a few lines of codes from time to time. Here is my current code:

class SimpleHint:
    """A simple class for generating the possible moves you could make. Note that it does not consider moves that
    involve moving a card *into* a free cell (though it does show moves of free cells coming out)."""
    def __init__(self, board):
        self.board = board

    def get_board_hints(self):
        """Get hints just from the tableau, moving cards from one pile to another"""
        free_cells_num = sum(map(lambda x: x is None, self.board.freecells))
        # We have to check every combination of stacks with every other position on the board
        # Note: stack = cards of increasing rank and alternating color which can be moved
        for ind1, pile1 in enumerate(self.board.piles):
            # This is the maximum possible length a stack could be
            max_stack_len = min(free_cells_num+1, len(pile1))
            card_ind1 = CardIndex(self.board, ind1, CardIndex.TYPE_MAIN)
            for bottom_stack in range(max_stack_len):
                card1 = pile1[-(bottom_stack+1)]
                # If the current card is not one higher in rank and of opposite color, then the stack is broken
                if bottom_stack > 0 and (pile1[-bottom_stack].rank + 1 != card1.rank
                                    or pile1[-bottom_stack].color == card1.color):
                    break
                # Now, we check every possible column to put it in
                for ind2, pile2 in enumerate(self.board.piles):
                    if pile1 is pile2:
                        continue

                    card_ind2 = CardIndex(self.board, ind2, CardIndex.TYPE_MAIN)
                    if card_ind2.can_add_card(card1):
                        # We know the stack starting at card1 can be placed at card_ind2
                        # Now we just have to compile a list of moves that would correctly move the stack
                        available_cells = list(filter(lambda x: self.board.freecells[x] is None, range(NUM_FREECELLS)))
                        indices = []
                        for c in range(bottom_stack):
                            free_cell = CardIndex(self.board, available_cells[c], CardIndex.TYPE_FREECELL)
                            indices.append((card_ind1, free_cell))
                        indices.append((card_ind1, card_ind2))
                        for c in range(bottom_stack):
                            free_cell = CardIndex(self.board, available_cells[bottom_stack - c - 1], CardIndex.TYPE_FREECELL)
                            indices.append((free_cell, card_ind2))
                        yield MovePoss(indices)

    def get_other_hints(self):
        """Gets hints from moving from a free cell to the tableau, the tableau to a foundation,
        or a free cell to a foundation"""
        for free_ind in range(NUM_FREECELLS):
            card_ind1 = CardIndex(self.board, free_ind, CardIndex.TYPE_FREECELL)
            card1 = card_ind1.peek_card()
            if card1 is None:
                continue
            for foundation_ind in range(NUM_FOUNDATIONS):
                card_ind2 = CardIndex(self.board, foundation_ind, CardIndex.TYPE_FOUNDATION)
                if card_ind2.can_add_card(card1):
                    yield MovePoss([(card_ind1, card_ind2)])
                    if card1.rank == 0:
                        break
        for ind, pile in enumerate(self.board.piles):
            if len(pile) == 0:
                continue
            card_ind1 = CardIndex(self.board, ind, CardIndex.TYPE_MAIN)
            card1 = card_ind1.peek_card()
            for free_ind in range(NUM_FREECELLS):
                card_ind2 = CardIndex(self.board, free_ind, CardIndex.TYPE_FREECELL)
                if card_ind1.can_add_card(card_ind2.peek_card()):
                    yield MovePoss([(card_ind2, card_ind1)])
            for foundation_ind in range(NUM_FOUNDATIONS):
                card_ind2 = CardIndex(self.board, foundation_ind, CardIndex.TYPE_FOUNDATION)
                if card_ind2.can_add_card(card1):
                    yield MovePoss([(card_ind1, card_ind2)])
                    # If the card is an ace, it can go in any foundation, but we save space by only displaying it once
                    if card1.rank == 0:
                        break

    def get_hints(self):
        for hint in self.get_other_hints():
            yield hint
        for hint in self.get_board_hints():
            yield hint

There are a few custom classes here. CardIndex is a class that keeps a reference to the location of a card (there may or may not be any actual cards there). In addition, board.piles[n], represents the nth column and contains a list of Cards, each of which have a suit, rank, and color. This is the full code in the file:

import random
CARD_TEXT_LEN = 10
NUM_FREECELLS = 4
NUM_FOUNDATIONS = 4

help_text = (
"""This is freecell, a card game. The objective is to fill up the foundations. This is accomplished by stacking the main
board by alternating color (white/black) and in descending order (King through Ace). You can also use the free cells to
hold cards which are blocking columns and bring them back later.

Your input should be two numbers separated by a space like this:

    >>> Choose some cards to move: 1 7

This would move the last card from the 1st column to the 7th column (everything is 1-indexed).
To move a card to a free cell, use the suffix c:

    >>> Choose some cards to move: 1 4c

would move the card from the 1st column to free cell 4.
Similarly, to move a card to a foundation, use the suffix f.
You can use semicolons to send multiple statements. For example, to move the bottom three cards
from column 1 to column two, you could do

    >>> Choose some cards to move: 1 1c; 1 2c; 1 2; 2c 2; 1c 2

You can also type sh or "simple hint" to get a hint""")


class Card:
    """A card part of a standard deck of 52 cards. Has a suit and rank."""
    def __init__(self, rank, suit):
        self.rank = rank
        # For suit ordering: 0=white heart, 1=black clubs, 2=white diamonds, 3=black spades
        self.suit = suit
        self.color = self.suit % 2 == 0

    def to_str(self, force_to=CARD_TEXT_LEN):
        # Note that because rank is 0-indexed but when displaying, it should be 1-indexed, it is necessary
        # to add one. Thus, 12 actually represents King (not Queen) and 0 represents Ace.
        suitname = ["♡", "♣", "♢", "♠"][self.suit]
        if self.rank >= 10:
            rankname = ["J", "Q", "K"][self.rank - 10]
        elif self.rank == 0:
            rankname = "A"
        else:
            rankname = str(self.rank + 1)
        spaces = " " * (force_to - len(suitname) - len(rankname) - 1) if force_to != -1 else ""
        return rankname + " " + suitname + spaces


class CardIndex:
    """This class is for referencing a particular card in either the main board,
    the free cell area, or the foundations."""

    TYPE_MAIN = 0
    TYPE_FREECELL = 1
    TYPE_FOUNDATION = 2

    def __init__(self, board, index, type):
        self.board = board
        self.index = index
        self.type = type

    def peek_card(self):
        """Returns the card's value without modifying anything. May return None if there is no card where the index
        is currently referencing."""
        if self.type == self.TYPE_MAIN:
            pile = self.board.piles[self.index]
            return None if len(pile) == 0 else pile[-1]
        elif self.type == self.TYPE_FREECELL:
            return self.board.freecells[self.index]
        elif self.type == self.TYPE_FOUNDATION:
            return self.board.foundations[self.index]

    def pop_card(self):
        """Returns the card and removes it from its previous location."""
        if self.type == self.TYPE_MAIN:
            return self.board.piles[self.index].pop()
        elif self.type == self.TYPE_FREECELL:
            card = self.board.freecells[self.index]
            self.board.freecells[self.index] = None
            return card
        elif self.type == self.TYPE_FOUNDATION:
            raise ValueError("Cannot remove cards from foundations")

    def can_add_card(self, card):
        """Determines if it is possible to move 'card' into the location described by this index."""
        if card is None:
            return False
        prev_card = self.peek_card()
        if self.type == self.TYPE_MAIN:
            return prev_card is None or (prev_card.rank - 1 == card.rank and prev_card.color != card.color)
        elif self.type == self.TYPE_FREECELL:
            return prev_card is None
        elif self.type == self.TYPE_FOUNDATION:
            # If the card is an ace or one rank higher and same suit as previous card
            return (prev_card is None and card.rank == 0) \
                   or (prev_card is not None and prev_card.rank + 1 == card.rank and prev_card.suit == card.suit)

    def add_card(self, card):
        """Adds the card 'card' into this index. This assumes that checks have already been done to make sure
        that this is legal. No further checks are done here."""
        if self.type == self.TYPE_MAIN:
            self.board.piles[self.index].append(card)
        elif self.type == self.TYPE_FREECELL:
            self.board.freecells[self.index] = card
        elif self.type == self.TYPE_FOUNDATION:
            self.board.foundations[self.index] = card

    def __str__(self):
        suffix = ["", "c", "f"][self.type]
        return str(self.index + 1) + suffix


class Board:
    def __init__(self, board_copy=None):
        if board_copy is None:
            cards = []
            for rank in range(13):
                for suit in range(4):
                    cards.append(Card(rank, suit))
            random.shuffle(cards)
            # Columns of cards
            self.piles = []
            # Add 4 piles of 7 cards
            for i in range(0, 28, 7):
                self.piles.append(cards[i:i+7])
            # Add 4 piles of 6 cards
            for i in range(28, 52, 6):
                self.piles.append(cards[i:i+6])
            self.freecells = [None] * NUM_FREECELLS
            self.foundations = [None] * NUM_FOUNDATIONS
        else:
            self.piles = board_copy.piles[:]
            self.freecells = board_copy.freecells[:]
            self.foundations = board_copy.foundations[:]

    def move_card(self, origin, dest):
        if not dest.can_add_card(origin.peek_card()):
            raise ValueError("Cannot move cards")
        dest.add_card(origin.pop_card())

    def __str__(self):
        def concatenate_list_cards(card_list):
            return (" "*4).join(map(lambda x: str(x) if x is None else x.to_str(force_to=-1), card_list))
        free_cell_str = concatenate_list_cards(self.freecells)
        foundations_str = concatenate_list_cards(self.foundations)
        max_len = max(map(len, self.piles))
        transposed = [[" " * CARD_TEXT_LEN] * len(self.piles) for _ in range(max_len)]
        for pile_num, pile in enumerate(self.piles):
            for index, card in enumerate(pile):
                transposed[index][pile_num] = card.to_str()
        main_board = "\n".join(map("".join, transposed))
        return "Free cells: " + free_cell_str + " "*20 + "Foundations: " + foundations_str + "\n\n" + main_board


class MovePoss:
    """Move possibility"""
    def __init__(self, indices):
        self.indices = indices

    def perform_on_board(self):
        for index in self.indices:
            index[1].add_card(index[0])

    def __str__(self):
        return "; ".join(map(lambda x: str(x[0])+" "+str(x[1]), self.indices))


class SimpleHint:
    """A simple class for generating the possible moves you could make. Note that it does not consider moves that
    involve moving a card *into* a free cell (though it does show moves of free cells coming out)."""
    def __init__(self, board):
        self.board = board

    def get_board_hints(self):
        """Get hints just from the tableau, moving cards from one pile to another"""
        free_cells_num = sum(map(lambda x: x is None, self.board.freecells))
        # We have to check every combination of stacks with every other position on the board
        # Note: stack = cards of increasing rank and alternating color which can be moved
        for ind1, pile1 in enumerate(self.board.piles):
            # This is the maximum possible length a stack could be
            max_stack_len = min(free_cells_num+1, len(pile1))
            card_ind1 = CardIndex(self.board, ind1, CardIndex.TYPE_MAIN)
            for bottom_stack in range(max_stack_len):
                card1 = pile1[-(bottom_stack+1)]
                # If the current card is not one higher in rank and of opposite color, then the stack is broken
                if bottom_stack > 0 and (pile1[-bottom_stack].rank + 1 != card1.rank
                                    or pile1[-bottom_stack].color == card1.color):
                    break
                # Now, we check every possible column to put it in
                for ind2, pile2 in enumerate(self.board.piles):
                    if pile1 is pile2:
                        continue

                    card_ind2 = CardIndex(self.board, ind2, CardIndex.TYPE_MAIN)
                    if card_ind2.can_add_card(card1):
                        # We know the stack starting at card1 can be placed at card_ind2
                        # Now we just have to compile a list of moves that would correctly move the stack
                        available_cells = list(filter(lambda x: self.board.freecells[x] is None, range(NUM_FREECELLS)))
                        indices = []
                        for c in range(bottom_stack):
                            free_cell = CardIndex(self.board, available_cells[c], CardIndex.TYPE_FREECELL)
                            indices.append((card_ind1, free_cell))
                        indices.append((card_ind1, card_ind2))
                        for c in range(bottom_stack):
                            free_cell = CardIndex(self.board, available_cells[bottom_stack - c - 1], CardIndex.TYPE_FREECELL)
                            indices.append((free_cell, card_ind2))
                        yield MovePoss(indices)

    def get_other_hints(self):
        """Gets hints from moving from a free cell to the tableau, the tableau to a foundation,
        or a free cell to a foundation"""
        for free_ind in range(NUM_FREECELLS):
            card_ind1 = CardIndex(self.board, free_ind, CardIndex.TYPE_FREECELL)
            card1 = card_ind1.peek_card()
            if card1 is None:
                continue
            for foundation_ind in range(NUM_FOUNDATIONS):
                card_ind2 = CardIndex(self.board, foundation_ind, CardIndex.TYPE_FOUNDATION)
                if card_ind2.can_add_card(card1):
                    yield MovePoss([(card_ind1, card_ind2)])
                    if card1.rank == 0:
                        break
        for ind, pile in enumerate(self.board.piles):
            if len(pile) == 0:
                continue
            card_ind1 = CardIndex(self.board, ind, CardIndex.TYPE_MAIN)
            card1 = card_ind1.peek_card()
            for free_ind in range(NUM_FREECELLS):
                card_ind2 = CardIndex(self.board, free_ind, CardIndex.TYPE_FREECELL)
                if card_ind1.can_add_card(card_ind2.peek_card()):
                    yield MovePoss([(card_ind2, card_ind1)])
            for foundation_ind in range(NUM_FOUNDATIONS):
                card_ind2 = CardIndex(self.board, foundation_ind, CardIndex.TYPE_FOUNDATION)
                if card_ind2.can_add_card(card1):
                    yield MovePoss([(card_ind1, card_ind2)])
                    # If the card is an ace, it can go in any foundation, but we save space by only displaying it once
                    if card1.rank == 0:
                        break

    def get_hints(self):
        for hint in self.get_other_hints():
            yield hint
        for hint in self.get_board_hints():
            yield hint

def run_input_on_board(board, inp):
    """Processes some text input. Returns -1 if quit; otherwise, returns 1 if the board does need to be refreshed (e.g.
    help text was displayed or move was invalid) and 0 if the board needs to be refreshed."""
    if inp in ["q", "quit"]:
        return -1
    if inp in ["h", "help"]:
        print(help_text)
        return 1
    if inp in ["sh", "simple hint"]:
        hints = SimpleHint(board).get_hints()
        hints_available = False
        for hint in hints:
            print("Try typing", hint)
            hints_available = True
        if not hints_available:
            print("No hints available")
        return 1

    def parse_card(card_str):
        if card_str.endswith("c"):
            index = int(card_str.rstrip("c"))
            type = CardIndex.TYPE_FREECELL
        elif card_str.endswith("f"):
            index = int(card_str.rstrip("f"))
            type = CardIndex.TYPE_FOUNDATION
        else:
            index = int(card_str)
            type = CardIndex.TYPE_MAIN
        return CardIndex(board, index - 1, type)
    try:
        parts = list(map(parse_card, inp.split(" ")))
        board.move_card(parts[0], parts[1])
    except ValueError as e:
        print("That is not a valid move. Type h or help for help. (Error message: {})".format(e))
        return 1
    return 0


def main():
    b = Board()
    changed = True
    while True:
        if changed:
            print(b)
        changed = False
        moves = input("Choose some cards to move: ").split(";")
        for move in moves:
            ret = run_input_on_board(b, move.strip())
            if ret == 1:
                break  # There was an error so interrupt the sequence of moves
            elif ret == -1:
                return
            else:
                changed = True

if __name__ == "__main__":
    main()

I am mainly looking for ways to shorten the SimpleHint class. Commentary on the rest of the code would certainly be helpful as well, however. In particular, the CardIndex class may need to be refactored to make SimpleHint shorter.

\$\endgroup\$

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.