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 1 — Bagels

Bagels is a code-breaking game. The computer secretly picks a three-digit number with no digit repeated, and the player has ten guesses to find it. After every guess the computer hands back a set of clues: "Fermi" means a digit is correct and in the right place, "Pico" means a digit is correct but in the wrong place, and "Bagels" means nothing in the guess was right at all. The clues are printed in a fixed order, so they never give away which digit earned which — only how many of each. Once the secret is found, or the guesses run out, the game offers another round.

Adapted from project 1 of Al Sweigart's The Big Book of Small Python Projects (No Starch Press, 2021), under CC BY-NC-SA 4.0.

A sample run

The secret number is random, so the golden run fixes it by setting BIGBOOK_SEED=1 (from seed.txt) before piping in the player's guesses from input.txt:

123
456
638
no
BIGBOOK_SEED=1 ek9 bagels.ek9 < input.txt

Because the guesses are piped in rather than typed at a terminal, they are never echoed back — only the clues the program itself prints appear below. With seed 1 the secret turns out to be 638, found on the third guess:

Bagels, a deductive logic game.

I am thinking of a 3-digit number with no repeated digits.
Try to guess what it is. Here are some clues:
When I say:     That means:
  Pico          One digit is correct but in the wrong position.
  Fermi         One digit is correct and in the right position.
  Bagels        No digit is correct.
I have thought up a number.
You have 10 guesses to get it.
Guess #1:
Pico
Guess #2:
Pico
Guess #3:
You got it!
Do you want to play again? (yes or no)
Thanks for playing!

The same game in French and Chinese

The game's words are kept apart from the game, so playing it in another language is a matter of choosing which words to use. BIGBOOK_LANG=fr chooses the French ones; the player's guesses are the same, and only their last answer changes, to "non":

BIGBOOK_SEED=1 BIGBOOK_LANG=fr ek9 bagels.ek9 < input.fr.txt
Bagels, un jeu de logique déductive.

Je pense à un nombre de 3 chiffres, sans chiffre répété.
Essayez de le deviner. Voici quelques indices :
Quand je dis :  Cela signifie :
  Pico          Un chiffre est correct mais mal placé.
  Fermi         Un chiffre est correct et bien placé.
  Bagels        Aucun chiffre n'est correct.
J'ai choisi un nombre.
Vous avez 10 essais pour le trouver.
Essai n° 1 :
Pico
Essai n° 2 :
Pico
Essai n° 3 :
Vous l'avez trouvé !
Voulez-vous rejouer ? (oui ou non)
Merci d'avoir joué !

And BIGBOOK_LANG=zh, answering "否":

百吉饼(Bagels),一个推理游戏。

我想好了一个没有重复数字的 3 位数。
请猜猜它是多少。以下是提示:
我说:          意思是:
  Pico          有一个数字正确,但位置不对。
  Fermi         有一个数字正确,位置也对。
  Bagels        没有一个数字正确。
我已经想好了一个数。
你有 10 次机会猜中它。
第 1 次猜测:
Pico
第 2 次猜测:
Pico
第 3 次猜测:
猜对了!
还想再玩一次吗?(是 或 否)
谢谢游玩!

A language the game has no words for (BIGBOOK_LANG=de) plays in English.

The program

#!ek9
defines module bigbook.bagels

  defines constant

    NumDigits <- 3
    MaxGuesses <- 10

  defines type

    //The two clues a digit can earn, in the order they are shown.
    Clue
      Fermi
      Pico

    List of Clue

  defines class

    //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 player sees or types 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())

      showNewNumber()
        stdout.println(say.newNumber())

      //Asks until the answer is a guess; unset only if the input ends first.
      nextGuess()
        -> guessNumber as Integer
        <- rtn as String: String()
        while not rtn? and not input.ended()
          stdout.println(say.askGuess(guessNumber))
          if entry <- input.next()
            if isGuess(entry)
              rtn: entry

      showClues()
        ->
          solved as Boolean
          clues as List of Clue

        stdout.println(solved <- say.gotIt() : clueLine(clues))

      showRanOut()
        -> secret as String
        stdout.println(say.ranOut(secret))

      wantsAnotherGame()
        <- rtn as Boolean: false
        stdout.println(say.playAgain())
        if answer <- input.next()
          rtn: answer.lowerCase().startsWith(say.yes())

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

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

      private clueLine()
        -> clues as List of Clue
        <- rtn as String: say.bagels()
        if not clues.empty()
          words <- List() of String
          for clue in clues
            words += clue == Clue.Fermi <- say.fermi() : say.pico()
          rtn: cat words | join with spaced | collect as String

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

  defines function

    //THE GAME: its rules, with no input or output.

    //Three different random digits: a digit already drawn is drawn again.
    secretNumber()
      -> rng as Random
      <- rtn as String: ""
      while length rtn < NumDigits
        digit <- $rng.next(0, 9)
        if rtn not contains digit
          rtn += digit

    isGuess() as pure
      -> entry as String
      <- rtn as Boolean: length entry == NumDigits and entry matches /^[0-9]+$/

    //Unset when the digit at this position earns no clue at all.
    clueAt() as pure
      ->
        guess as String
        secret as String
        position as Integer
      <- rtn as Clue: Clue()

      digit <- guess.subString(position, position + 1)
      if digit == secret.subString(position, position + 1)
        rtn: Clue.Fermi
      else if secret contains digit
        rtn: Clue.Pico

    //Sorted, so the clues never say which digit earned which.
    cluesFor()
      ->
        guess as String
        secret as String
      <- rtn as List of Clue: List()

      found <- List() of Clue
      for position in 0 ... NumDigits - 1
        if clue <- clueAt(guess, secret, position)
          found += clue
      rtn: cat found | sort | collect as List of Clue

  defines function

    spaced() as pure
      ->
        left as String
        right as String
      <- rtn as String: `${left} ${right}`

    //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 player sees, one block per language.
  defines text for "en"

    Say

      introduction()
        `Bagels, a deductive logic game.

I am thinking of a ${NumDigits}-digit number with no repeated digits.
Try to guess what it is. Here are some clues:
When I say:     That means:
  Pico          One digit is correct but in the wrong position.
  Fermi         One digit is correct and in the right position.
  Bagels        No digit is correct.`

      newNumber()
        `I have thought up a number.
You have ${MaxGuesses} guesses to get it.`

      askGuess()
        -> guessNumber as Integer
        `Guess #${guessNumber}:`

      fermi()
        "Fermi"

      pico()
        "Pico"

      bagels()
        "Bagels"

      gotIt()
        "You got it!"

      ranOut()
        -> secret as String
        `You ran out of guesses.
The answer was ${secret}.`

      playAgain()
        "Do you want to play again? (yes or no)"

      yes()
        "y"

      goodbye()
        "Thanks for playing!"

  defines text for "fr"

    Say

      introduction()
        `Bagels, un jeu de logique déductive.

Je pense à un nombre de ${NumDigits} chiffres, sans chiffre répété.
Essayez de le deviner. Voici quelques indices :
Quand je dis :  Cela signifie :
  Pico          Un chiffre est correct mais mal placé.
  Fermi         Un chiffre est correct et bien placé.
  Bagels        Aucun chiffre n'est correct.`

      newNumber()
        `J'ai choisi un nombre.
Vous avez ${MaxGuesses} essais pour le trouver.`

      askGuess()
        -> guessNumber as Integer
        `Essai n° ${guessNumber} :`

      fermi()
        "Fermi"

      pico()
        "Pico"

      bagels()
        "Bagels"

      gotIt()
        "Vous l'avez trouvé !"

      ranOut()
        -> secret as String
        `Vous n'avez plus d'essais.
La réponse était ${secret}.`

      playAgain()
        "Voulez-vous rejouer ? (oui ou non)"

      yes()
        "o"

      goodbye()
        "Merci d'avoir joué !"

  defines text for "zh"

    Say

      introduction()
        `百吉饼(Bagels),一个推理游戏。

我想好了一个没有重复数字的 ${NumDigits} 位数。
请猜猜它是多少。以下是提示:
我说:          意思是:
  Pico          有一个数字正确,但位置不对。
  Fermi         有一个数字正确,位置也对。
  Bagels        没有一个数字正确。`

      newNumber()
        `我已经想好了一个数。
你有 ${MaxGuesses} 次机会猜中它。`

      askGuess()
        -> guessNumber as Integer
        `第 ${guessNumber} 次猜测:`

      fermi()
        "Fermi"

      pico()
        "Pico"

      bagels()
        "Bagels"

      gotIt()
        "猜对了!"

      ranOut()
        -> secret as String
        `你的机会用完了。
答案是 ${secret}。`

      playAgain()
        "还想再玩一次吗?(是 或 否)"

      yes()
        "是"

      goodbye()
        "谢谢游玩!"

  defines program

    //Bagels (Big Book project 1). Guess a 3-digit number with no repeated digits; the clues say
    //how close each guess is. The game decides; the Console asks and tells, in the player's words.
    //The golden replays a seeded game (seed.txt).
    Bagels()
      //A language with no words of its own is unset: ?: falls back to English.
      console <- Console(Say(language()) ?: Say("en"))
      rng <- randomSource()

      console.showIntroduction()
      running <- true
      while running and not console.ended()
        secret <- secretNumber(rng)
        console.showNewNumber()
        guessNumber <- 1
        solved <- false
        while guessNumber <= MaxGuesses and not solved and not console.ended()
          if guess <- console.nextGuess(guessNumber)
            solved: guess == secret
            console.showClues(solved, cluesFor(guess, secret))
            guessNumber += 1
        if not solved and guessNumber > MaxGuesses
          console.showRanOut(secret)
        running: console.wantsAnotherGame()
      console.showGoodbye()

How the port works

The original is one main() that decides, asks, tells and words everything in the same lines. The port keeps those apart, in four parts you can read and change on their own:

This is a little more code than the Python, and it is the point of the port: each part has one job, so a new language is a new block of words, a new rule touches only the game, and the game can be tested without a player.