The Big Book of Small EK9 Programs

Adapted from Al Sweigart's The Big Book of Small Python Projects: 81 Easy Practice Programs (No Starch Press, 2021; copyright © 2021 Al Sweigart), which he makes freely available under a Creative Commons Attribution-NonCommercial-ShareAlike licence at inventwithpython.com/bigbookpython. Changes were made: each program was ported from Python to EK9 and remodelled in EK9's idioms.

Project 63 — The Royal Game of Ur

This is project 63 of the book, ported to EK9. Its chapter is still being written; until then, this page shows the port itself: a run of it, the whole program, and how it keeps to the original.

A sample run

The program is random, so the run fixes it by setting BIGBOOK_SEED=1. The player's answers are piped in:

home
home
home
quit

Run it with:

BIGBOOK_SEED=1 ek9 royal-game-of-ur.ek9 < input.txt

It prints:

The Royal Game of Ur, by Al Sweigart al@inventwithpython.com

X  home:*******  goal:
| h:. | g:. | f:. | e:. |     | t:. | s:. |
| i:. | j:. | k:. | l:. | m:. | n:. | o:. | p:. |
| d:. | c:. | b:. | a:. |     | r:. | q:. |
O  home:*******  goal:
It is X's turn.
Flips: T-T-T-H (1)
Select move 1 spaces: home quit

X  home:******  goal:
| h:. | g:. | f:. | e:X |     | t:. | s:. |
| i:. | j:. | k:. | l:. | m:. | n:. | o:. | p:. |
| d:. | c:. | b:. | a:. |     | r:. | q:. |
O  home:*******  goal:
It is O's turn.
Flips: T-T-T-T (0)
No heads - you lose a turn.

X  home:******  goal:
| h:. | g:. | f:. | e:X |     | t:. | s:. |
| i:. | j:. | k:. | l:. | m:. | n:. | o:. | p:. |
| d:. | c:. | b:. | a:. |     | r:. | q:. |
O  home:*******  goal:
It is X's turn.
Flips: T-H-T-T (1)
Select move 1 spaces: e quit
Select move 1 spaces: e quit
Select move 1 spaces: e quit
Thanks for playing!

The program

#!ek9
defines module bigbook.ur

  defines constant

    //The book's game data: each player's route as square labels, from home (H) to goal (G).
    //The two routes share the middle row, i to p.
    XTrack <- "HefghijklmnopstG"
    OTrack <- "HabcdijklmnopqrG"
    Goal <- "G"

    //The board as drawn, row by row; a space is the gap where the board narrows.
    TopRow <- "hgfe ts"
    MiddleRow <- "ijklmnop"
    BottomRow <- "dcba rq"
    Gap <- " "

    Rosettes <- "htldr"
    ProtectedRosette <- "l"

    TokensPerPlayer <- 7
    Coins <- 4

    //The game's own name for bringing a token onto the board; the Console words it.
    FromHome <- "home"

    //The board's own marks, data like the square labels: a token, an empty square, the edge
    //of a square, the gap where the board narrows, and what sits between two coin faces.
    TokenMark <- '*'
    EmptyMark <- "."
    Edge <- "|"
    GapCell <- "     |"
    FaceSeparator <- "-"

  defines type

    //A player is X or O, never any other string; '$' gives the letter the board shows.
    Player
      X
      O

    //The two faces a coin can land on.
    Coin
      Heads
      Tails

    List of Coin

  defines class

    //THE GAME's state: which player's token is on each square, and how many tokens each
    //player still has at home and has brought to the goal. An empty square is simply one with
    //no entry, so the board needs no "empty" marker; "." appears only when it is drawn.
    UrBoard
      squares as Dict of (String, Player): Dict() of (String, Player)
      atHome as Dict of (Player, Integer): {Player.X: TokensPerPlayer, Player.O: TokensPerPlayer}
      atGoal as Dict of (Player, Integer): {Player.X: 0, Player.O: 0}

      homeCount() as pure
        -> player as Player
        <- rtn as Integer: atHome.getOrDefault(player, 0)

      goalCount() as pure
        -> player as Player
        <- rtn as Integer: atGoal.getOrDefault(player, 0)

      hasWon() as pure
        -> player as Player
        <- rtn as Boolean: goalCount(player) == TokensPerPlayer

      //UNSET when the square is empty: tri-state in place of a sentinel.
      occupantOf() as pure
        -> label as String
        <- rtn as Player: squares.getOrDefault(label, Player())

      //A token may land on an empty square, or knock off an opponent's token anywhere except
      //the protected rosette in the middle of the shared row.
      canLandOn() as pure
        ->
          label as String
          player as Player
        <- rtn as Boolean: true

        if occupant <- occupantOf(label)
          rtn: occupant <> player and label <> ProtectedRosette

      validMoves()
        ->
          player as Player
          flip as Integer
        <- rtn as List of String: List() of String

        track <- trackOf(player)
        if homeCount(player) > 0 and not occupantOf(labelAt(track, flip))?
          rtn += FromHome
        //Index 0 is home and the last index is the goal; the squares lie between them. An empty
        //square's occupant is unset, and comparing an unset value is never true, so empty
        //squares need no separate test.
        for index in 1 ... (length track) - 2
          square <- labelAt(track, index)
          target <- index + flip
          if occupantOf(square) == player and target < length track
            landing <- labelAt(track, target)
            if landing == Goal or canLandOn(landing, player)
              rtn += square

      //Make a move already validated by validMoves; returns the label of the landing square.
      moveToken()
        ->
          player as Player
          chosen as String
          flip as Integer
        <- rtn as String: String()

        track <- trackOf(player)
        if chosen == FromHome
          atHome += DictEntry(player, homeCount(player) - 1)
        else
          squares -= chosen
        //Home is index 0 of every track, so a token entering the board moves from there.
        startIndex <- chosen == FromHome <- 0 : track.indexOf(chosen)
        rtn: labelAt(track, startIndex + flip)
        if rtn == Goal
          atGoal += DictEntry(player, goalCount(player) + 1)
        else
          if knocked <- occupantOf(rtn)
            atHome += DictEntry(knocked, homeCount(knocked) + 1)
          squares += DictEntry(rtn, player)

      default operator ?

    //THE input idiom of this book, copied unchanged from lib/LineInput.ek9 (the reasons are
    //there): read with next(), and every input loop also stops when ended() is true.
    LineInput
      stdin as Stdin: Stdin()
      inputEnded as Boolean: false

      next()
        <- rtn as String: stdin.next()
        if not rtn?
          inputEnded: true

      ended() as pure
        <- rtn as Boolean: Boolean(inputEnded)

      override operator ? as pure
        <- rtn as Boolean: true

    //THE INPUT AND OUTPUT: everything the players see or type goes through here, worded by Say.
    Console
      stdout as Stdout: Stdout()
      input as LineInput: LineInput()
      say as Say: Say("en")

      Console()
        -> words as Say
        say: words

      showIntroduction()
        stdout.println(say.introduction())

      //The board is drawn, not worded: computed from the game's squares, a row at a time, with
      //each player's tokens at home and at the goal above and below it.
      showBoard()
        -> board as UrBoard
        stdout.println("")
        stdout.println(reserveLine(board, Player.X))
        stdout.println(rowLine(board, TopRow))
        stdout.println(rowLine(board, MiddleRow))
        stdout.println(rowLine(board, BottomRow))
        stdout.println(reserveLine(board, Player.O))

      showTurn()
        -> player as Player
        stdout.println(say.turn(player))

      showFlips()
        ->
          flips as List of Coin
          heads as Integer

        faces <- ""
        for coin in flips
          if not faces.empty()
            faces += FaceSeparator
          faces += coin == Coin.Heads <- say.headsFace() : say.tailsFace()
        stdout.println(say.flips(faces, heads))

      showNoHeads()
        stdout.println(say.noHeads())

      showNoMoves()
        stdout.println(say.noMoves())

      //Asks until the reply is one of the moves offered; unset when the player quits or the
      //input ends.
      nextMove()
        ->
          moves as List of String
          heads as Integer
        <- rtn as String: String()

        options <- ""
        for move in moves
          options += `${moveWord(move)} `
        options += say.quit()
        quitting <- false
        while not rtn? and not quitting and not input.ended()
          stdout.println(say.askMove(heads, options))
          if reply <- input.next().lowerCase().trim()
            move <- moveOf(reply)
            if reply == say.quit()
              quitting: true
            else if moves contains move
              rtn: move

      showWinner()
        -> player as Player
        stdout.println(say.won(player))

      showRosette()
        -> player as Player
        stdout.println(say.rosette(player))

      showGoodbye()
        stdout.println(say.goodbye())

      ended() as pure
        <- rtn as Boolean: input.ended()

      //The move from home is offered, and typed, in the player's words.
      private moveWord()
        -> move as String
        <- rtn as String: move == FromHome <- say.home() : move

      private moveOf()
        -> reply as String
        <- rtn as String: reply == say.home() <- FromHome : reply

      private reserveLine()
        ->
          board as UrBoard
          player as Player
        <- rtn as String: say.reserve(player, marks(board.homeCount(player)), marks(board.goalCount(player)))

      private rowLine()
        ->
          board as UrBoard
          labels as String
        <- rtn as String: Edge

        for ch in labels
          label as String: ch
          rtn += label == Gap <- GapCell : ` ${label}:${markOf(board, label)} ${Edge}`

      //An empty square's occupant is unset, so the '.' exists only here, at the display edge.
      private markOf()
        ->
          board as UrBoard
          label as String
        <- rtn as String: EmptyMark

        if occupant <- board.occupantOf(label)
          rtn: $occupant

      //A run of token marks; a method cannot be called on a string literal, so the padding
      //starts from rtn.
      private marks()
        -> count as Integer
        <- rtn as String: ""
        rtn: rtn.rightPadded(count, TokenMark)

      override operator ? as pure
        <- rtn as Boolean: true

  defines function

    //THE GAME: its rules, with no input or output (and the UrBoard above).

    opponentOf() as pure
      -> player as Player
      <- rtn as Player: player == Player.X <- Player.O : Player.X

    trackOf() as pure
      -> player as Player
      <- rtn as String: player == Player.X <- XTrack : OTrack

    labelAt() as pure
      ->
        track as String
        index as Integer
      <- rtn as String: track.subString(index, index + 1)

    isRosette() as pure
      -> label as String
      <- rtn as Boolean: Rosettes contains label

    //Flip the four coins, in order.
    flipCoins()
      -> rng as Random
      <- rtn as List of Coin: List() of Coin
      for flip in 1 ... Coins
        rtn += rng.next(0, 1) == 1 <- Coin.Heads : Coin.Tails

    headValue() as pure
      -> coin as Coin
      <- rtn as Integer: coin == Coin.Heads <- 1 : 0

    //The number of heads is how far a token moves.
    headsIn() as pure
      -> flips as List of Coin
      <- rtn as Integer: cat flips | map with headValue | collect as Integer

  defines function

    //THE FLOW, one turn of the original's main(): the coins, then a move. Returns the player who
    //goes next - the same player after a rosette - or unset when the player quits or the input
    //ends. The program checks for a winner.
    takeTurn()
      ->
        board as UrBoard
        player as Player
        rng as Random
        console as Console
      <- rtn as Player: opponentOf(player)

      console.showBoard(board)
      console.showTurn(player)
      flips <- flipCoins(rng)
      heads <- headsIn(flips)
      console.showFlips(flips, heads)
      moves <- board.validMoves(player, heads)
      if heads == 0
        console.showNoHeads()
      else if moves.empty()
        console.showNoMoves()
      else if chosen <- console.nextMove(moves, heads)
        landed <- board.moveToken(player, chosen, heads)
        if isRosette(landed)
          console.showRosette(player)
          rtn: player
      else
        rtn: Player()

  defines function

    //THE source of randomness, copied unchanged from lib/RandomSource.ek9: SystemRandom(), or
    //SeededRandom(seed) when BIGBOOK_SEED holds a number (run-and-diff.sh sets it from seed.txt).
    randomSource()
      <- rtn as Random: SeededRandom(Integer(EnvVars().get("BIGBOOK_SEED"))) ?: SystemRandom()

    //THE language of the words, copied unchanged from lib/Language.ek9: BIGBOOK_LANG when it
    //is set (fr, zh, ...), otherwise en.
    language()
      <- rtn as String: EnvVars().get("BIGBOOK_LANG") ?: "en"

  //THE WORDS: every word the players see or type. The board, its square labels and marks are
  //not here: the Console draws them from the game's squares.
  defines text for "en"

    Say

      introduction()
        "The Royal Game of Ur, by Al Sweigart al@inventwithpython.com"

      reserve()
        ->
          player as Player
          homeMarks as String
          goalMarks as String
        `${player}  home:${homeMarks}  goal:${goalMarks}`

      turn()
        -> player as Player
        `It is ${player}'s turn.`

      headsFace()
        "H"

      tailsFace()
        "T"

      flips()
        ->
          faces as String
          heads as Integer
        `Flips: ${faces} (${heads})`

      noHeads()
        "No heads - you lose a turn."

      noMoves()
        "No possible moves - you lose a turn."

      askMove()
        ->
          heads as Integer
          options as String
        `Select move ${heads} spaces: ${options}`

      home()
        "home"

      quit()
        "quit"

      won()
        -> player as Player
        `${player} has won the game!`

      rosette()
        -> player as Player
        `${player} landed on a rosette and goes again.`

      goodbye()
        "Thanks for playing!"

  defines program

    //The Royal Game of Ur (Big Book project 63). A 5,000-year-old race game: flip four coins and
    //move a token that many squares towards the goal. The game decides; the Console asks and
    //tells, in the players' words. The golden replays a seeded game (seed.txt).
    RoyalGameOfUr()
      //A language with no words of its own is unset: ?: falls back to English.
      console <- Console(Say(language()) ?: Say("en"))
      rng <- randomSource()

      console.showIntroduction()
      board <- UrBoard()
      turn <- Player.X
      running <- true
      while running and not console.ended()
        if nextPlayer <- takeTurn(board: board, player: turn, rng: rng, console: console)
          if board.hasWon(turn)
            console.showBoard(board)
            console.showWinner(turn)
            running: false
          else
            turn: nextPlayer
        else
          console.showGoodbye()
          running: false

About this port