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
- Faithful to the book: two players race 7 tokens from home to goal along their own tracks; each turn flips 4 coins and moves one token as many squares as there are heads; landing on a rosette (h/t/l/d/r) gives another turn; on the shared middle row a token knocks an opponent's back to its home, except on the protected centre rosette l; a token needs the exact count to reach the goal; the first to bring all 7 to the goal wins. No heads, or no valid move, loses the turn.
- Divergences: this port draws its own board layout; the "Press Enter" pauses are omitted.
- Idioms:
- Players are a
Playerenumeration (X,O), and$playergives the letter shown. - The game state is an
UrBoardclass: aDict of (String, Player)for the squares andDict of (Player, Integer)counts of the tokens at home and at the goal. An empty square has no entry, sooccupantOfreturns an unsetPlayerand.exists only in the drawing. - The square labels and the two tracks are the book's game data, kept as strings: a square
is found with
track.indexOf(label), and index 0 of every track is home, so a token entering the board moves from there like any other. console.nextMovereturns an unsetStringwhen the player quits or the input ends, so the game loop guards withif chosen <- console.nextMove(moves, heads).- The number of heads is a stream,
cat flips | map with headValue | collect as Integer, soheadsInisas pure. - The tokens at home are drawn with
rightPadded(count, '*')on an empty string.
- Players are a