Saturday, October 29, 2016

Solitaire in a Bash script

I like to write Bash scripts. It stems from my time as a Unix and Linux systems administrator, years ago. I used to automated everything. So I got very good at writing shell scripts. Even today when managing a personal server, I write Bash scripts to automate various jobs so I don't have to keep logging into the server all the time. For example, I have a job that parses an RSS news feed with Bash.

I admit that my Bash scripts aren't always for automation. Some of my scripts are just for fun. Like the Bash script to fill out my March Madness basketball brackets. It can be an interesting diversion to write a Bash script to do something innovative.

And lately, I've started writing another such Bash script. Let me tell you about it.

We all know the classic Klondike Solitaire card game. There have been countless computer implementations of Solitaire on every platform. We even had a simple Solitaire game on our old Apple IIe computer in the 1980s. If you run Linux, you may be familiar with AisleRiot, which supports multiple card solitaire games, including the classic Klondike Solitaire. More recently, Google now provides a browser-based version of Klondike Solitaire; just search for the term "solitaire" and you'll get an option to "Click to play" the web version.

I wanted to write my own version of Klondike Solitaire as a Bash script. Sure, I could grab another shell script implementation of Solitaire called Shellitaire but I liked the challenge of writing my own.

And I did. Or rather, I mostly did. I have run out of free time to work on it. So I'm sharing it here in case others want to build on it. I have implemented most of the game, except for the card selection. You might think that's the toughest part, but I don't think so; I'll explain at the end.

So, how do you write a solitaire card game in a shell script?

I found it was easiest to leverage the strength of shell scripts: files. I started with all 52 cards in a single "deck" file, shuffled it, then "drew" cards from the deck into piles on the tableau.

Let's start with the basics. Creating 52 cards, comprised of thirteen cards of four different suits, is straightforward:

for s in c d h s ; do
  for n in $(seq 1 13) ; do
    echo "$s$n"
  done
done

You can direct the output to a file, and you have a file containing an ordered representation of all 52 cards. On a Linux system, you can use GNU shuf(1) to randomize ("shuffle") an input file, which can also be a pipe. So to define a shuffled deck of 52 cards, you do this:

deck=/tmp/sol/deck

for s in c d h s ; do
  for n in $(seq 1 13) ; do
    echo "$s$n"
  done
done | shuf > $deck

Drawing cards from the deck requires a little more work, but not much more. I wrote a simple function popn() that takes ("pops") the first n lines of a file and returns those lines, and shortens the file at the same time. Usually, this will be one at a time, but we'll need the flexibility later.

On top of that function, I wrote another simple function drawcard() that draws a single card from the deck and assigns it to the end of a "drawn cards" pile. This function also needs a little extra logic to deal with an empty deck; if the deck is empty, we return the drawn cards to the deck and start over. This is why it's important to pop from the start of the deck and append drawn cards to the end of the "drawn cards" pile; you can easily reset the deck using the drawn cards:

function popn() {
  # pop the first n($2) items from a file($1) and print it
  head -$2 $1
  cp $1 /tmp/tempfile
  awk "NR>$2 {print}" /tmp/tempfile > $1
}

function drawcard() {
  if [ $(cat $deck | wc -l) -eq 0 ] ; then
    cp $draw $deck
    >$draw
  fi

  popn $deck 1 >> $draw
}

In Klondike Solitaire, the play area is a tableau of seven piles of cards, where the first n-1 cards are piled "face down" and the last card is placed "face up." So for the first column, there are no "face down" cards, and only one "face up" card. On the seventh column, you start with six "face down" cards, and only one "face up" card. The player must move cards on these seven piles, eventually transferring the cards to four separate "foundation" piles, where each pile is dedicated to a separate suit: clubs, diamonds, hearts, and spades.

Creating the play area requires the use of Bash arrays. I rarely use arrays in Bash, but they are a very useful feature. Bash supports both indexed arrays (0, 1, 2, …) and associative arrays (where the index is a string value). You define a variable as an indexed array using the declare -a directive, and as an associative array with the declare -A directive.

With this, it's simple enough to define the tableau card piles as separate files, then use the popn() function to deal cards from the deck into these files.

declare -a tabdraw
for n in $(seq 1 7) ; do tabdraw[$n]="/tmp/sol/tab.draw.$n" ; done

declare -a pile
for n in $(seq 1 7) ; do tab[$n]="/tmp/sol/tab.$n" ; done

declare -A found
for s in c d h s ; do found[$s]="/tmp/sol/found.$s" ; done

# deal cards from deck into tableau {curly brackets needed on array refs}

for n in $(seq 1 7) ; do popn $deck 1 >> ${tab[$n]} ; done

for n in $(seq 1 7) ; do popn $deck $((n - 1)) >> ${tabdraw[$n]} ; done

And that's the guts of a Solitaire game in Bash! When assembled, my Bash script looked like this:

#!/bin/bash

# create work directory

tmpdir="/tmp/sol.tmp.$RANDOM"
[ ! -d $tmpdir ] && mkdir $tmpdir

# create variables

deck="$tmpdir/deck"
draw="$tmpdir/draw"
tmpf="$tmpdir/tmpf"

declare -a tabdraw
for n in $(seq 1 7) ; do tabdraw[$n]="$tmpdir/tab.draw.$n" ; done

declare -a pile
for n in $(seq 1 7) ; do tab[$n]="$tmpdir/tab.$n" ; done

declare -A found
for s in c d h s ; do found[$s]="$tmpdir/found.$s" ; done

# create functions

function popn() {
  # pop the first n($2) items from a file($1) and print it
  head -$2 $1
  cp $1 $tmpf
  awk "NR>$2 {print}" $tmpf > $1
}

function drawcard() {
  if [ $(cat $deck | wc -l) -eq 0 ] ; then
    cp $draw $deck
    >$draw
  fi

  popn $deck 1 >> $draw
}

function showcards() {
  # show the cards again - repaint the screen

  clear

  for n in $(seq 1 7) ; do cat ${tabdraw[$n]} | wc -l > $tmpdir/wc.$n ; done

  paste $tmpdir/wc.[1-7]
  paste ${tab[*]}

  echo '___  ___  ___  ___'

  paste ${found[*]}

  echo '___'

  cat $deck | wc -l
  tail -1 $draw
}

# build and shuffle initial deck

for s in c d h s ; do
  echo "${s}0" > ${found[$s]}

  for n in $(seq 1 13) ; do
    echo "$s$n"
  done
done | shuf > $deck

# deal cards from deck into tableau {curly brackets needed on array refs}

for n in $(seq 1 7) ; do popn $deck 1 >> ${tab[$n]} ; done

for n in $(seq 1 7) ; do popn $deck $((n - 1)) >> ${tabdraw[$n]} ; done

# loop until quit ('q')

input='n'
while [ "$input" != "q" ] ; do
  case "$input" in
  'n')
    # draw next card
    drawcard
    ;;
  *)
    # parse into card1,card2
    # is this a valid request?
    # is this a valid move?
    ;;
  esac

  showcards

  echo -n '?> '
  read input
done

# cleanup

rm -rf $tmpdir

I have left unfinished the logic to move cards around on the tableau. This might seem like the most difficult part, but not really. Since every pile on the tableau is a file, it's easy enough to seek a requested card in each of the "face up" piles, then extract all lines (cards) from that "face up" pile and append them onto another "face up" pile. You'd need a little extra logic in there to move Kings to an empty space on the tableau, but that's not very difficult.

I envisioned splitting the logic into three parts:

1. Verifying that this is a valid request
For example, the user is not allowed to move a black card onto a black card. Also, the user can only move cards in descending order on the tableau, and ascending order on the foundation. That's easy logic.
2. Confirming that the cards are there to move
This should be a fairly straightforward process using fgrep(1) to locate the requested cards on the tableau or in the foundation.
3. Moving the cards
I believe this should be easy, if you remember that cards are only entries in files. You can easily write a function that outputs all lines starting with card A, and appends them to the end of another file. At the same time, the function can truncate the first file starting at card A.

When you start the script, you should see output similar to this:

0       1       2       3       4       5       6
s12     c11     s2      h7      s11     h11     d3
___     ___     ___     ___
c0      d0      h0      s0
___
23
c13
?> 

The output is intentionally functional, and uses paste(1) to merge the output from several tableau files. What you're seeing:
  • The first line shows the number of cards remaining in each of the tableau "face down" piles.
  • The second line shows the "face up" cards for each pile on the tableau. This sample output indicates: 12 (Queen) of Spades, 11 (Jack) of Clubs, 2 of Spades, 7 of Hearts, 11 (Jack) of Spades, 11 (Jack) of Hearts, and 3 of Diamonds.
  • The third line shows the empty foundation piles. I initialized these as the "zero" cards so the logic to transfer cards to the foundation could remain simple; you only move cards of the same suit in ascending order.
  • The fourth line shows the number of cards remaining to be drawn on the deck.
  • The fifth line shows the "face up" card from the deck. In this case, it is the 13 (King) of Clubs.
  • The last line shows a prompt (?>) for the user to enter a command.

This Bash script was a lot of fun to write, but I don't have time to finish it. The script took an afternoon to write, and an hour to tweak. Even so, I think this is a solid start to play Solitaire in a Bash script.

Feel free to finish this script. Please assume Creative Commons Attribution. So if you use it somewhere else, such as to write an article and publish it, you should credit me as the original author.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.