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 37 — Hungry Robots

This is project 37 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:

S
S
QUIT

Run it with:

BIGBOOK_SEED=1 ek9 hungry-robots.ek9 < input.txt

It prints:

Hungry Robots, by Al Sweigart al@inventwithpython.com
░░░░░░░░░░░░░░░░░░░░
░         R  ░░    ░
░      ░          @░
░░ ░           ░R ░░
░ ░░      ░       ░░
░  ░      R   ░    ░
░░ ░   ░  R      X ░
░░         ░     ░ ░
░       X ░      R ░
░░░░░░░░░░░░░░░░░░░░
(T)eleports remaining: 2
   (Q) (W) ( )
   (A) (S) ( )
Enter move or QUIT: (Z) ( ) ( )
░░░░░░░░░░░░░░░░░░░░
░            ░░    ░
░      ░   R     R@░
░░ ░           ░  ░░
░ ░░      ░R      ░░
░  ░       R  ░    ░
░░ ░   ░         X ░
░░         ░     ░R░
░       X ░        ░
░░░░░░░░░░░░░░░░░░░░
(T)eleports remaining: 2
   (Q) (W) ( )
   ( ) (S) ( )
Enter move or QUIT: (Z) ( ) ( )
░░░░░░░░░░░░░░░░░░░░
░            ░░    ░
░      ░    R     @░
░░ ░        R  ░  ░░
░ ░░      ░ R     ░░
░  ░          ░    ░
░░ ░   ░         XR░
░░         ░     ░ ░
░       X ░        ░
░░░░░░░░░░░░░░░░░░░░
You have been caught by a robot!

The program

#!ek9
defines module bigbook.hungryrobots

  defines constant

    Width <- 20
    Height <- 10
    RobotCount <- 5
    TeleportCount <- 2
    DeadRobotCount <- 2
    WallCount <- 20

    //The board's own marks, data like the move keys: they are drawn, not worded.
    WallMark <- "░"
    DeadRobotMark <- "X"
    PlayerMark <- "@"
    RobotMark <- "R"
    Blank <- " "
    //The keypad's first two rows are drawn three spaces in, as this port has always drawn them.
    KeypadIndent <- "   "

  defines type

    //What the board itself holds. Robots and the player move every turn, so they are not
    //cells: they are positions drawn over the board.
    Cell
      Empty
      Wall
      DeadRobot

    //Every move the player can make, in the order the keypad lays them out. Parsing a reply
    //into this type is the validation: anything else is an unset Move. QUIT is not a move: it
    //is a word the player types, so the Console recognises it.
    Move
      NorthWest
      North
      NorthEast
      West
      Stay
      East
      SouthWest
      South
      SouthEast
      Teleport

  defines record

    //A board position, compared by value, so it can be a Dict key and be found in a List.
    //The same shape as lib/Coord.ek9.
    Coord
      x as Integer: Integer()
      y as Integer: Integer()

      default Coord() as pure

      //Integer(px) copies the argument: a stored key must not change when the caller's
      //Integer does.
      Coord() as pure
        ->
          px as Integer
          py as Integer

        x :=? Integer(px)
        y :=? Integer(py)

      default operator ?
      default operator <=>
      default operator #?

  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())

      //The board is drawn, not worded: computed from the game's cells, robots and player.
      showBoard()
        ->
          board as Dict of (Coord, Cell)
          robots as List of Coord
          player as Coord

        for y in 0 ... Height - 1
          line <- ""
          for x in 0 ... Width - 1
            line += glyphAt(board: board, robots: robots, player: player, at: Coord(x, y))
          stdout.println(line)

      //Asks until the game allows the move; unset when the player QUITs or the input ends.
      nextMove()
        ->
          open as List of Move
          teleports as Integer
        <- rtn as Move: Move()

        quitting <- false
        while not rtn? and not quitting and not input.ended()
          showKeypad(open, teleports)
          if line <- input.next()
            reply <- line.upperCase().trim()
            if reply == say.quit()
              quitting: true
            else if move <- moveOf(reply)
              if isAllowed(open, move, teleports)
                rtn: move

      showSurvived()
        stdout.println(say.survived())

      showCaught()
        stdout.println(say.caught())

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

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

      //The keys are the game's data; the keypad shows a key only for a direction the player
      //can move in. The prompt shares the keypad's bottom row.
      private showKeypad()
        ->
          open as List of Move
          teleports as Integer

        topRow <- keypadRow(open: open, left: Move.NorthWest, middle: Move.North, right: Move.NorthEast)
        bottomRow <- keypadRow(open: open, left: Move.SouthWest, middle: Move.South, right: Move.SouthEast)
        //Stay is always offered, so the middle row shows its key whatever is open.
        middleRow <- `(${offered(open, Move.West)}) (${keyOf(Move.Stay)}) (${offered(open, Move.East)})`
        stdout.println(say.teleportsLeft(teleports))
        stdout.println(`${KeypadIndent}${topRow}`)
        stdout.println(`${KeypadIndent}${middleRow}`)
        stdout.println(`${say.askMove()} ${bottomRow}`)

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

  defines function

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

    //Off the board reads as a wall, so no move or robot step needs a bounds check.
    cellAt() as pure
      ->
        board as Dict of (Coord, Cell)
        at as Coord
      <- rtn as Cell: board.getOrDefault(at, Cell.Wall)

    isOpen() as pure
      ->
        board as Dict of (Coord, Cell)
        robots as List of Coord
        at as Coord
      <- rtn as Boolean: cellAt(board, at) == Cell.Empty and not (robots contains at)

    //The key the player types for a move: the game's own data.
    keyOf() as pure
      -> move as Move
      <- rtn as String: String()
      switch move
        case Move.NorthWest
          rtn: "Q"
        case Move.North
          rtn: "W"
        case Move.NorthEast
          rtn: "E"
        case Move.West
          rtn: "A"
        case Move.Stay
          rtn: "S"
        case Move.East
          rtn: "D"
        case Move.SouthWest
          rtn: "Z"
        case Move.South
          rtn: "X"
        case Move.SouthEast
          rtn: "C"
        case Move.Teleport
          rtn: "T"
        default
          rtn: String()

    //The reverse of keyOf, derived from it, so the two cannot disagree.
    moveOf() as pure
      -> key as String
      <- rtn as Move: Move()
      for move in Move
        if keyOf(move) == key
          rtn: move

    //How far a move shifts the player along one axis: -1, 0 or +1.
    columnShift() as pure
      -> move as Move
      <- rtn as Integer: 0
      if move == Move.NorthWest or move == Move.West or move == Move.SouthWest
        rtn: -1
      else if move == Move.NorthEast or move == Move.East or move == Move.SouthEast
        rtn: 1

    rowShift() as pure
      -> move as Move
      <- rtn as Integer: 0
      if move == Move.NorthWest or move == Move.North or move == Move.NorthEast
        rtn: -1
      else if move == Move.SouthWest or move == Move.South or move == Move.SouthEast
        rtn: 1

    step() as pure
      ->
        from as Coord
        move as Move
      <- rtn as Coord: Coord(from.x + columnShift(move), from.y + rowShift(move))

    compassMoves() as pure
      <- rtn as List of Move: [
        Move.NorthWest, Move.North, Move.NorthEast,
        Move.West, Move.East,
        Move.SouthWest, Move.South, Move.SouthEast
      ]

    //The directions the player can move in from where they stand.
    openMoves()
      ->
        board as Dict of (Coord, Cell)
        robots as List of Coord
        player as Coord
      <- rtn as List of Move: List() of Move

      for direction in compassMoves()
        if isOpen(board, robots, step(player, direction))
          rtn += direction

    //Stay is always allowed, Teleport while any are left, a direction only if open.
    isAllowed() as pure
      ->
        open as List of Move
        move as Move
        teleports as Integer
      <- rtn as Boolean: open contains move or move == Move.Stay

      if move == Move.Teleport
        rtn: teleports > 0

    //One step towards a target along one axis: -1, 0 or +1.
    towards() as pure
      ->
        from as Integer
        target as Integer
      <- rtn as Integer: from < target <- 1 : from > target <- -1 : 0

    //A robot heads straight for the player. Blocked by a wall, it tries the horizontal part of
    //the move, then the vertical part, and otherwise stays where it is.
    robotStep() as pure
      ->
        board as Dict of (Coord, Cell)
        robot as Coord
        player as Coord
      <- rtn as Coord: robot

      across <- Coord(robot.x + towards(robot.x, player.x), robot.y)
      upOrDown <- Coord(robot.x, robot.y + towards(robot.y, player.y))
      direct <- Coord(across.x, upOrDown.y)
      if cellAt(board, direct) <> Cell.Wall
        rtn: direct
      else if cellAt(board, across) == Cell.Empty
        rtn: across
      else if cellAt(board, upOrDown) == Cell.Empty
        rtn: upOrDown

    randomEmptySpace()
      ->
        board as Dict of (Coord, Cell)
        robots as List of Coord
        rng as Random
      <- rtn as Coord: Coord()

      while not rtn?
        x <- rng.next(1, Width - 2)
        y <- rng.next(1, Height - 2)
        candidate <- Coord(x, y)
        if isOpen(board, robots, candidate)
          rtn: candidate

    newBoard()
      -> rng as Random
      <- rtn as Dict of (Coord, Cell): Dict() of (Coord, Cell)
      for x in 0 ... Width - 1
        for y in 0 ... Height - 1
          edge <- x == 0 or x == Width - 1 or y == 0 or y == Height - 1
          rtn += DictEntry(Coord(x, y), edge <- Cell.Wall : Cell.Empty)
      noRobots <- List() of Coord
      for wall in 1 ... WallCount
        rtn += DictEntry(randomEmptySpace(rtn, noRobots, rng), Cell.Wall)
      for wreck in 1 ... DeadRobotCount
        rtn += DictEntry(randomEmptySpace(rtn, noRobots, rng), Cell.DeadRobot)

    addRobots()
      ->
        board as Dict of (Coord, Cell)
        rng as Random
      <- rtn as List of Coord: List() of Coord

      for robot in 1 ... RobotCount
        rtn += randomEmptySpace(board, rtn, rng)

    //Returns the robots still running, and marks each new crash site on the board in place.
    //A robot that is on, or steps onto, a dead robot is destroyed; two robots that step onto
    //the same space are both destroyed and leave a dead robot there.
    moveRobots()
      ->
        board as Dict of (Coord, Cell)
        robots as List of Coord
        player as Coord
      <- rtn as List of Coord: List() of Coord

      for robot in robots
        target <- robotStep(board, robot, player)
        crashed <- cellAt(board, robot) == Cell.DeadRobot or cellAt(board, target) == Cell.DeadRobot
        if not crashed
          if rtn contains target
            board += DictEntry(target, Cell.DeadRobot)
            rtn -= target
          else
            rtn += target

  defines function

    //The Console's drawing: computed output, with no words in it.

    glyphOf() as pure
      -> cell as Cell
      <- rtn as String?
      //The compiler asks for every Cell and a default (for an unset Cell), so rtn needs no initial value.
      switch cell
        case Cell.Wall
          rtn: WallMark
        case Cell.DeadRobot
          rtn: DeadRobotMark
        case Cell.Empty
          rtn: Blank
        default
          rtn: Blank

    //Walls and dead robots are drawn over everything, as in the original.
    glyphAt() as pure
      ->
        board as Dict of (Coord, Cell)
        robots as List of Coord
        player as Coord
        at as Coord
      <- rtn as String: glyphOf(cellAt(board, at))

      if cellAt(board, at) == Cell.Empty
        rtn: at == player <- PlayerMark : robots contains at <- RobotMark : rtn

    //A key shown on the keypad, or a blank for a direction the player cannot move in.
    offered() as pure
      ->
        open as List of Move
        move as Move
      <- rtn as String: open contains move <- keyOf(move) : Blank

    keypadRow() as pure
      ->
        open as List of Move
        left as Move
        middle as Move
        right as Move
      <- rtn as String: `(${offered(open, left)}) (${offered(open, middle)}) (${offered(open, 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 or types. The board and the keypad are not here: the
  //Console draws them from the game's cells and keys.
  defines text for "en"

    Say

      introduction()
        "Hungry Robots, by Al Sweigart al@inventwithpython.com"

      teleportsLeft()
        -> teleports as Integer
        `(T)eleports remaining: ${teleports}`

      askMove()
        "Enter move or QUIT:"

      quit()
        "QUIT"

      goodbye()
        "Thanks for playing!"

      survived()
        "All the robots have crashed. You survived. Good job!"

      caught()
        "You have been caught by a robot!"

  defines program

    //Hungry Robots (Big Book project 37). Escape the robots by tricking them into crashing into each
    //other or into dead robots. The game decides; the Console asks and tells, in the player's
    //words. The golden replays a seeded game (seed.txt).
    HungryRobots()
      //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 <- newBoard(rng)
      robots <- addRobots(board, rng)
      player <- randomEmptySpace(board, robots, rng)
      //A copy, because -= changes the Integer in place and must not change the constant.
      teleports <- Integer(TeleportCount)

      running <- true
      while running and not console.ended()
        console.showBoard(board, robots, player)
        if robots.empty()
          console.showSurvived()
          running: false
        else if move <- console.nextMove(openMoves(board, robots, player), teleports)
          if move == Move.Teleport
            teleports -= 1
            player: randomEmptySpace(board, robots, rng)
          else
            player: step(player, move)
          robots: moveRobots(board, robots, player)
          if robots contains player
            console.showBoard(board, robots, player)
            console.showCaught()
            running: false
        else
          console.showGoodbye()
          running: false

About this port