Programming Assignment 1a - Solitaire, Part 2

Object-Oriented Programming with Java, Spring 2008


This note describes the design of a Eagle's-Wing playing program. Familarity with the Eagle's Wing player API may be helpful when trying to understand this design.

The program description straightforwardly leads to an initial design:

main(String args[]) 
  final int 
    gameCount = processArguments(args)
    gamesWon = playGames(gameCount)
  System.out.println("Won: " + gamesWon + " lost: " + gameCount - gamesWon)

There's only one required command-line argument: the number of games to play. There are not interesting design question for command-line arguments, although it might be useful to consider what might happen if there were more than one command-line argument.

The game-playing requirement is also straightforward:

playGames(int gameCount)

  int won = 0

  while --gameCount > 0
    if playGame()
      won++

  return won

The design of playGame() is more interesting: how do you play a game of Eagle's Wing? However, it turns out that the crazy-monkey style of play leads to a simple design. Crazy-monkey play involves making any move possible without considering whether the move is good or bad. Keep this up until there are no more moves and then see if the game's a win or a loss. Crazy-monkey play is simple to design because there's no strategy.

Crazy-monkey play needs three things:

  1. A way to generate moves.

  2. A way to recognize when there are no more moves.

  3. A way to recognize a winning game.

A bit of thought shows that generating moves consists of creating pairs of card piles; each pair represents a move between the first pile to the second (or vice versa). Eagle's Wing makes this simple because there's only one move available between any two card piles, assuming any move is possible at all. The Eagle's Wing API makes the card piles available as an enumeration; generating pairs is a small matter of two for loops over the card-pile enumerations. Fortunately, the static method CardPiles.pairIterate() generates all possible card-pile pairs, calling a method with each pair as a parameter.

Recognizing when a game has no more moves could be tricky, but the Eagle's Wing API provides the predicate movesPossible() to do the job. Similarly, recognizing when a game is won is a bit easier, but it doesn't matter because the API provides gameWon().

With these three API features the game player falls into place:

boolean playGame()

  EaglesWing game = EaglesWing.newGame()

  CardPiles.PairFun pairFun = new CardPiles.PairFun() {
    int f(CardPiles fromPile, CardPiles toPile) {
      game.move(fromPile, toPile)
      return 0
      }
    }

  do CardPiles.pairIterate(pairFun)
  while game.movesPossible()

  return game.gameWon()

And that's it for the design of a program to play Eagle's Wing. It's a simple design, but a surprisingly effective one capable of winning 46% of the games it plays:

$ java -ea player.Main -s 10 -p random 10000
Won:  4641 lost: 5359

$

A Biased-Monkey Design

Crazy-monkey play has the great virtue of being simple, but it pays for that virtue by being a less-than-accomplished player. How much simplicity has to be sacrificed to get a respectable level of play?

One obvious problem with crazy-monkey play is it doesn't distinguish among moves; it just makes every possible move blindly as it comes up. Given that the objective of Eagle's Wing is to move all cards to the foundation piles, it might make sense to bias play so that moves to foundation piles occur before other moves are made.

int biasedMonkeyPlay()

  EaglesWing game = EaglesWing.newGame()

  do
    foundationMoves(game)
    nonfoundationMoves(game)
  while game.movesPossible()

  return game.gameWon()

foundationMoves() is implemented with a simple filter on the move pairs:

void foundationMoves(EaglesWing game)

  CardPiles.pairIterate(new CardPiles.PairFun() {
    int f(CardPiles fromPile, CardPiles toPile) {
      if (pileType(toPile) == foundationPile)
        game.move(fromPile, toPile)
      return 0
      }
    })

Likewise for nonfoundationMoves():

void nonfoundationMoves(EaglesWing game)

  CardPiles.pairIterate(new CardPiles.PairFun() {
    int f(CardPiles fromPile, CardPiles toPile) {
      if (pileType(toPile) != foundationPile)
        game.move(fromPile, toPile)
      return 0
      }
    })

nonfoundationMoves() only needs to look at the to-pile types because there are no legal moves using a foundation pile as the from pile. Observant readers will have noticed that nonfoundationMoves() and foundationMoves() are almost identical, differing only in the pile-type test. Good software design demands that the common parts of both methods be extracted to a single method parametrized by the difference; See the appendix for more details.

Unfortunately, biased-monkey play isn't much better than crazy-monkey play:

$ java -ea player.Main -s 10 -p biased 10000
Won:  5311 lost: 4689

$

Biased play improves the win percentage to around 53% from 46%.

Stronger Bias

A little bit of thought suggests that perhaps the biases aren't strong enough. For example, foundationMoves() considers each acceptable pile-pair only once per call. This strategy ignores the fact that a successful move may uncover more cards that could be moved to a foundation pile. For example, a successful move from the stock pile to a foundation pile uncovers a new stock card, which also might be successfully moved to a foundation pile. As it stands, however, foundationMoves() doesn't make the second move and leaves the new stock card for the non-foundation moves, where it could be moved to a wing pile or to the waste pile. A similar problem exists for the trunk and waste piles.

The solution to this problem is to increase the bias for foundation moves by repeatedly checking for foundation moves until there aren't any more:

void strongFoundationMoves(final EaglesWing game)

  class StrongFoundationFun
  extends CardPiles.PileFun {

    boolean checkAgain;

    public int f(CardPiles fromPile, CardPiles toPile) {
      if pileType(toPile) == foundationPile
        if game.move(fromPile, toPile) == EaglesWingStatus.MOVE_SUCCESSFUL
          checkAgain = true
      }
    }

  StrongFoundationFun strongFoundationFun = new StrongFoundationFun();

  do
    strongFoundationFun.checkAgain = false
    CardPiles.pairIterate(strongFoundationFun)
  while strongFoundationFun.checkAgain

Similarly, the nonfoundationMoves() bias against foundation moves may be too strong: moves from the trunk, stock, or waste piles could uncover cards that could be moved to a foundation pile, but may not be because they're moved elsewhere later in the call to nonfoundationMoves(). The weak foundation bias can be strengthened by stopping non-foundation moves after the first successful one; foundationMoves() then gets a crack at any new cards uncovered.

void weakNonfoundationMoves(EaglesWing game)

  CardPiles.pairIterate(new CardPiles.PairFun() {
    int f(CardPiles fromPile, CardPiles toPile) {
      if pileType(toPile) != foundationPile &&
        game.move(fromPile, toPile) == EaglesWingStatus.MOVE_SUCCESSFUL
        return 2
      return 0
      }
    })

Together they allow strong-biased play:

int strongBiasedPlay()

  EaglesWing game = EaglesWing.newGame()

  do
    strongFoundationMoves(game)
    weakNonfoundationMoves(game)
  while game.movesPossible()

  return game.gameWon()

Strong-biased play is a bit better, improving to 58% from 53%:

$ java -ea player.Main -s 10 -p strongBiased 10000
Won:  5863 lost: 4137

$

However, it seems we're past the knee of the cost-benefit curve and that any further sigificant improvements will require significant work.

Appendix: The Unbearable Heaviness of Java

The move methods developed for biased-monkey play

void foundationMoves(EaglesWing game)

  CardPiles.pairIterate(new CardPiles.PairFun() {
    int f(CardPiles fromPile, CardPiles toPile) {
      if pileType(toPile) == foundationPile
        game.move(fromPile, toPile)
      return 0
      }
    })

void nonfoundationMoves(EaglesWing game)

  CardPiles.pairIterate(new CardPiles.PairFun() {
    int f(CardPiles fromPile, CardPiles toPile) {
      if (pileType(toPile) != foundationPile)
        game.move(fromPile, toPile)
      return 0
      }
    })

are almost identical, differing only in the pile-type test. Good design demands that the common parts of these methods be extracted to a single method parametrized by the difference. Lisp-like languages, in this case Scheme, are good at writing software using procedural abstraction:

(define (biased-moves game acceptable-move)

  (CardPiles.pairIterate
     (lambda (from-pile to-pile)
       (if (acceptable-move from-pile to-pile)
         (game.move from-pile to-pile)))))

The lambda form creates an anonymous function, which is being passed into the pair iterator. The anonymous function calls another function, acceptable-move, to determine if the given pair represents an acceptable move. The function acceptable-move is passed in as a parameter.

Now foundationMoves() and nonfoundationMoves() can be defined simply in terms of biased-moves:

(define (foundation-moves game)
  (biased-moves game 
     (lambda (from-pile to-pile) (eq? (pile-type to-pile) foundation-pile))))

(define (foundation-moves game)
  (biased-moves game 
     (lambda (from-pile to-pile) (not (eq? (pile-type to-pile) foundation-pile))))

The second argument in each call to biased-moves is an anonymous function that determines if a given pair of card piles is an acceptable move. In the first case the move is acceptable of the to-pile is in the foundation; in the second case the move is acceptable if the to-pile is not in the foundation (this could be abstracted further, but the given code is straightforward enough as it is).

The Java implementation is similar, but requires more machinery. biased-moves can be defined as a child of CardPiles.PairFun:

class BiasedFun
implements CardPiles.PairFun {

  private final MoveBias moveBias;
  private final EaglesWing game;

  BiasedFun(EaglesWing g, MoveBias mb) {
    game = g;
    moveBoas = mb;
    }

  public int f(CardPiles fromPile, CardPiles toPile) {
    if (moveBias(fromPile, toPile)
      game.move(fromPile, toPile);
    return 0;
    }

Making BiasedFun a child of CardPiles.PairFun allows BiasedFun instances to be treated as CardPiles.PairFun instances via subtype polymorphism.

MoveBias is an interface defining the bias function to apply to a pile pair:

interface MoveBias {
  public boolean moveBias(CardPile fromPile, CardPile toPile);
  }

Now foundationMoves() and nonfoundationMoves() can be defined in terms of biased-moves:

void foundationMoves(EaglesWing game)
 
  final MoveBias foundationBias = new MoveBias() {
    public boolean 
    moveBias(CardPile fromPile, CardPile toPile) {
      return pileType(toPile) == foundationPile
      }
    }

  CardPairs.pairIterate(new BiasedFun(game, foundationBias));


void nonfoundationMoves(EaglesWing game)
 
  CardPairs.pairIterate(new BiasedFun(game, 
    new MoveBias() {
      public boolean 
      moveBias(CardPile fromPile, CardPile toPile) {
	return pileType(toPile) != foundationPile
	}
      })

In foundationMoves() the bias-function instance was stored in a local variable (foundationBias) that was passed on as a parameter to BiasedFun(); in nonfoundationMoves() the bias-function instance was created directly in the BiasedFun() argument list.

To be completely fair, it should be pointed out that Java is providing type-safety while the Scheme implementation isn't. Scheme allows any values to be passed into biased-fun and CardPiles.pairIterate as function values and it isn't until the values are used (that is, it isn't until run-time) that the error is discovered. The Java implementation, on the other hand, is strongly (in this case) and statically typed: any attempt to use anything other than MoveBias and CardPiles.PairMove values is detected at compile time. The real question is: Does Java have to make strong and static type safety so conceptually and mechanically expensive?


This page last modified on 15 March 2008.