Chapter 27 · ~56 min read

A Bot That Plays

Is the game too hard, or too easy? And is every class worth playing? One run can't say, and neither can twenty: it takes hundreds, played the same way each time, by a player who never tires and never learns. This chapter builds that player, a bot: a program that plays the game from the inside, through the same functions the keys call. It plays with no window, at about a second a run.

The bot plays hundreds of runs, and reports how they went: how many it won, the floors where it died, and what killed it most. Its reports find bugs, first in the bot itself. Then they answer a question about the game: whether the Mage is a weak class, or one the bot was playing badly. The next image shows a bot's report on 600 runs, 200 as each class.

The end of Chapter 27
Figure 27.1 — The end of Chapter 27

The bot wins 13% of its runs, about one in eight, and reaches floor 5.9 on average. That's a modest player, and a useful one. It plays the same way every time, so once it plays well, a change in its numbers is a change in the game.

Project folder: Chapter 27 in the book's repository holds this chapter's finished project, the finished game. The chapter carries on in your own project from Chapter 26, or from the book's Chapter 26 folder.

In this chapter, we will:

  • Run the game without a window, from the command line, with --bot
  • Write a bot that plays a run from the inside, as a careful but simple player would
  • Add up its runs in a report: the share won, the floors reached, the deaths on each floor, and the commonest causes
  • Have the bot say why it does each thing, and use that to find and fix its own bugs
  • Work out how many hits each class needs to kill each monster
  • Balance the game by experiment, and follow the book's own tuning, from a game no run could win to the one you've built

There are four checkpoints along the way. Let's start with the plan.

Planning the Chapter

This chapter adds four files of code, and changes five:

File Status What it's for
src/Bot.h, src/Bot.cpp New The bot: a player that's a program, and the runs it plays
src/BotReport.h, src/BotReport.cpp New The runs, added up
src/Files.h, src/Files.cpp Changed A folder of the bot's own
src/Game.h, src/Game.cpp Changed The bot as the game's friend, and the table of hits to kill
src/main.cpp Changed --bot and --hits on the command line, and a hidden window for the bot
The other files Unchanged

The next image shows how the files fit together at the end of the chapter. The bot holds a Game, and plays it through its own functions, and the report adds up the bot's runs. Only main knows about either, since it's main that reads the command line.

The files at the end of Chapter 27
Figure 27.2 — The files at the end of Chapter 27

Most of the work is in the bot, about 790 lines between its header and its code. Its report has about 125 more. The game itself changes little: Game.cpp grows by about 140 lines for the table of hits to kill, and main.cpp by about 40.

How the Bot Plays

A bot could play as a person does, pressing keys and reading the screen. Ours takes a shortcut: it's allowed into the Game's private parts. So it calls the functions the keys call, and reads what the screen would show from the Game's members. It's careful to read only what a player could see: monsters in view, items seen, and the floor as far as it's been explored.

Design note: Rog-O-Matic. Bots are nearly as old as roguelikes. Rog-O-Matic was written at Carnegie Mellon University in the early 1980s by Michael Mauldin, Guy Jacobson, Andrew Appel and Leonard Hamey. It played Rogue the way a person does: it read the characters Rogue sent to the terminal, worked out the dungeon from them, and sent keys back. Matched against fifteen of the university's best Rogue players, over two months, it had the best median score of them all.

Ours has it easier, since it lives inside the game. But it keeps to the same rule as Rog-O-Matic: it knows only what a player could know.

Each of the bot's turns is one decision. It works down a list of things to try, in order, and the first that finds something to do takes the turn. The next image shows the list.

How the bot decides
Figure 27.3 — How the bot decides

The order is the bot's whole character. Answering the game comes first, since the game is waiting for it. Drinking a healing potion comes before fighting, since a dead bot can't fight. Wandering comes last, for when there's nothing else to do.

A Player That's a Program

The first part runs the game without a window, and writes the bot, which plays a run to its end, and prints a line about it.

Playing Without a Window

The bot's runs start from the command line: AmuletsOfSDL --bot 20 plays 20 runs as each class, from seed 1. And AmuletsOfSDL --bot 20 500 does the same from seed 500. So main reads the command line first, before SDL starts, since the bot needs a few things done differently. Open main.cpp, and add a small function to its unnamed namespace, below showDataErrors, with a blank line in between:

// A number on the command line, such as a seed
unsigned int numberFrom(const char* text)
{
    return static_cast<unsigned int>(SDL_strtoul(text, nullptr, 10));
}

The function SDL_strtoul reads the number, as the seed has always been read. Then the cast makes it an unsigned int, as the seed is.

The seed was read below the window, too late for the bot. In main, delete everything from // A seed on the command line replays that dungeon down to seed = static_cast<unsigned int>(SDL_strtoul(argv[1], nullptr, 10));, and the closing brace below it, and the blank line above it.

The command line is read again, with the bot's two words, as soon as the data has loaded. Add this above if (!SDL_Init(SDL_INIT_VIDEO)), with a blank line in between:

// A seed on the command line replays that dungeon, as in
// "AmuletsOfSDL 12345". Without one, the seed is chosen at random.
// "AmuletsOfSDL --bot 20" has the bot play 20 runs as each class, from
// seed 1, or from a seed given after the number, as "--bot 20 500"
unsigned int seed = 0;
int botRuns = 0;
if (argc > 2 && SDL_strcmp(argv[1], "--bot") == 0)
{
    botRuns = static_cast<int>(numberFrom(argv[2]));
    seed = argc > 3 ? numberFrom(argv[3]) : 1;
}
else if (argc > 1)
{
    seed = numberFrom(argv[1]);
}

The first word after the program's name is argv[1]. And SDL_strcmp compares two strings, giving 0 when they're the same. With --bot, the number after it is how many runs. The one after that, if there is one, is the first seed. Anything else is a seed, as before.

The bot plays in silence, since its runs go by far too fast to hear. Find these lines:

// Sound is welcome, but the game plays on without it
if (!SDL_InitSubSystem(SDL_INIT_AUDIO))

Change them to these:

// Sound is welcome, but the game plays on without it. The bot plays
// without it always
if (botRuns == 0 && !SDL_InitSubSystem(SDL_INIT_AUDIO))

Without audio, the sound bank can't open a device, and says so, as Chapter 25 made it. Every sound it's asked for is silence.

A Game needs a renderer for its glyph cache and its views, even if it never draws a thing. And a renderer needs a window. So the bot gets a window, but a hidden one: SDL makes it, and never shows it. Find these lines:

// The window can be resized. On a high-density screen, such as a Mac's
// Retina display, it also gets to use every pixel the screen has
SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
if (!SDL_CreateWindowAndRenderer("Amulets of SDL", windowW, windowH,
                                 SDL_WINDOW_RESIZABLE |
                                 SDL_WINDOW_HIGH_PIXEL_DENSITY,
                                 &window, &renderer))

Change them to these:

// The window can be resized. On a high-density screen, such as a Mac's
// Retina display, it also gets to use every pixel the screen has. For
// the bot, which needs one to make a game but draws nothing, it's
// hidden
SDL_WindowFlags flags = SDL_WINDOW_RESIZABLE |
                        SDL_WINDOW_HIGH_PIXEL_DENSITY;
if (botRuns > 0)
{
    flags |= SDL_WINDOW_HIDDEN;
}
SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
if (!SDL_CreateWindowAndRenderer("Amulets of SDL", windowW, windowH,
                                 flags, &window, &renderer))

An SDL_WindowFlags holds the window's flags, as bits, and |= adds SDL_WINDOW_HIDDEN to them for the bot.

Last, the bot plays in place of the game. Find this line, in the block that holds the glyph cache:

if (glyphs.isLoaded())

Change it to these:

if (glyphs.isLoaded() && botRuns > 0)
{
    // The bot plays in a folder of its own, so its runs never
    // touch the player's save, morgue files or high scores
    useUserFolder("AmuletsOfSDL-bot");
    playBotRuns(renderer, glyphs, data, sounds, botRuns, seed);
}
else if (glyphs.isLoaded())

The bot gets the same data, glyph cache and sound bank a Game would, since each of its runs is a Game. Its functions are in a header of its own, Bot.h, coming soon. Add it to main.cpp's includes, below #include <vector>:

#include "Bot.h"

The game won't build again until Bot.h and Bot.cpp exist, later in this part.

A Folder of Its Own

A run that ends deletes the save, writes a morgue file and enters its score, as Chapter 26 made it. If the bot played in the player's own folder, it would delete a run the player had saved to finish later, and fill the high scores with its own runs. So the bot plays in a folder of its own, beside the player's. Open Files.h, and add this below userPath's declaration, with a blank line in between:

// From now on, the files the game writes go in a folder of this name
// instead, beside the usual one. The bot plays in a folder of its own, so
// its runs never touch a player's save, morgue files or high scores
void useUserFolder(const std::string& name);

The folder's name has been a constant, and now it can change. In Files.cpp, find these lines:

// The names SDL_GetPrefPath files the game's own folder under: the
// organization that makes the game, and the game
const char* const ORGANIZATION = "GameCodeSchool";
const char* const GAME = "AmuletsOfSDL";

Change them to these:

// The names SDL_GetPrefPath files the game's own folder under: the
// organization that makes the game, and the game, unless the bot has
// asked for a folder of its own
const char* const ORGANIZATION = "GameCodeSchool";
std::string gameFolder = "AmuletsOfSDL";

In userPath, change SDL_GetPrefPath(ORGANIZATION, GAME) to SDL_GetPrefPath(ORGANIZATION, gameFolder.c_str()). Then add useUserFolder below userPath:

void useUserFolder(const std::string& name)
{
    gameFolder = name;
}

The bot's folder, AmuletsOfSDL-bot, sits beside AmuletsOfSDL in %APPDATA%\GameCodeSchool. SDL makes it the first time it's asked for it.

The Game's Friend

The bot plays through the Game's own functions, playerStep, pickUp, useItem and the rest. It reads the members too, player_ and level_ among them. All of them are private. A class can name another class as its friend, and a friend may use the class's private members as freely as the class itself can.

Open Game.h. The comment above the class still says a run ends with the player's death, which Chapter 26 made untrue. Find these lines, above class Game:

// One run of the game, from a new dungeon to the player's death: the
// dungeon and everyone in it, the rules that play out each turn, and the
// screen that shows it all

Change them to these:

// One run of the game, from a new dungeon to its end, by death or by
// victory: the dungeon and everyone in it, the rules that play out each
// turn, and the screen that shows it all

Then add this at the top of the class, above public:, leaving a blank line after it:

// The bot plays from the inside, through the functions the keys call,
// and looks at everything there is to see, as a player at the screen
// could. So the Bot is Game's friend: it can use its private parts
friend class Bot;

Friendship is a strong grant, and a class gives it sparingly. The bot can now change anything in a Game, and must be trusted not to. It's the simplest honest way for a bot to play, though, since it uses exactly what the keys use. Nothing has to be added to the game for it.

The Bot's Class

Now the bot. In Solution Explorer, right-click src, choose Add > New Item, and add a Header File (.h) called Bot.h. Type its includes:

#pragma once
#include <SDL3/SDL.h>
#include <optional>   // std::optional, for where a monster was last seen
#include <set>        // std::set, for the items the bot has given up on
#include <string>     // std::string, for a run's results
#include "Common.h"
#include "Data.h"
#include "Game.h"
#include "GlyphCache.h"
#include "SoundBank.h"

The bot needs the Game it plays, and the things a Game is made with: the data, the glyph cache and the sound bank. Add the class's comment, and its public part, below them, with a blank line in between:

// A player that's a program. It plays a run the way someone might who has
// played a few times: it fights what comes to it, drinks when it's badly
// hurt, picks everything up and wears the best of it, explores each floor
// with the game's own explore, and goes down. It plays through the
// functions the keys call, and nothing is drawn, so a run takes a second
// or two
class Bot
{
public:
    explicit Bot(Game& game);

    // Plays a run as a class, to its end, or until MAX_TURNS have passed
    void play(const ClassDef& def);

    // The run's results, as one line: the class, the seed, how it ended,
    // the floor and the turn it ended on, and what killed the player
    std::string results() const;

The decisions come next, each part of one in a function of its own. Add the class's private part below results's declaration, with a blank line in between:

private:
    // One decision, which mostly takes a turn
    void act();

    // The parts of a decision. Each returns true if it did something
    bool answer();
    bool drinkIfHurt();
    bool goForLastAmulet();
    bool fight(const Monster* foe);
    bool chase(const Monster* foe);
    bool castByHeartAt(const Monster& foe);
    bool shootAt(const Monster& foe);
    bool pickUpHere();
    bool whileSafe();
    bool goToItem();
    bool explore();
    bool goDown();
    void wander();

The parts are in the order the bot tries them, the order of Figure 27.3. Add the helpers below them, with a blank line in between, then the bot's members, and the function that plays the runs:

    // Helpers
    void arrive();
    void wearTheBest();
    const Monster* nearestMonster() const;
    bool walkTo(Point goal);
    void step(Point to);
    bool use(ItemEffect effect);
    bool useUnknown(ItemClass itemClass);
    void press(SDL_Keycode key);

    Game& game_;
    std::optional<Point> lastSeen_;   // where the monster it chased was
    std::set<int> skipped_;           // cells of items it has given up on
    bool explored_ = false;           // nothing left to explore here
    bool lost_ = false;               // no way on that it knows of
    int idle_ = 0;                    // decisions in a row with no turn
};

// Has the bot play runs seeds, from firstSeed, as each class in turn, and
// prints a line of results for each run
void playBotRuns(SDL_Renderer* renderer, GlyphCache& glyphs,
                 const GameData& data, SoundBank& sounds, int runs,
                 unsigned int firstSeed);

In the preceding code, a std::set, as in Chapter 16, keeps values in order, each once. It can also say whether it holds one. The bot keeps the cells of items it has given up on there, as numbers: an item it couldn't pick up, since its pack was full, or one it couldn't reach. The function playBotRuns isn't a member, since it isn't one bot's job: it makes a Game and a Bot for each run.

A Run, Decision by Decision

Add a C++ File (.cpp) called Bot.cpp. Type its includes, and the numbers that shape the bot:

#include "Bot.h"
#include <algorithm>   // std::find, for a monster on a line of fire
#include <cstdio>      // std::printf, for the results
#include <vector>      // std::vector, for paths and lines of fire
#include "AStar.h"
#include "DijkstraMap.h"

namespace
{
    // A run still going after this many turns has gone wrong: the bot is
    // stuck. Most wins take 4,000 to 9,000, the rests included
    constexpr int MAX_TURNS = 30000;

    // The bot drinks below this share of its health, in percent
    constexpr int DRINK_BELOW = 40;

    // A monster further off than this, in steps, isn't worth chasing
    constexpr int CHASE_STEPS = 8;

    // Decisions in a row that take no turn before the bot gives up on what
    // it's trying, and waits a turn
    constexpr int MOST_IDLE = 10;

In the preceding code, MAX_TURNS is the bot's limit. Most wins take 4,000 to 9,000 turns, rests and all. A run still going after 30,000 has almost certainly gone wrong, so it ends as stuck. Add two small helpers below them, with a blank line in between, and the namespace's closing brace:

    int stepsApart(Point a, Point b)
    {
        return SDL_max(SDL_abs(a.x - b.x), SDL_abs(a.y - b.y));
    }

    // A cell as a number, for keeping in a set
    int cellKey(Point cell)
    {
        return cell.y * MAP_W + cell.x;
    }
}

The function stepsApart counts steps as the game does, a diagonal step as one. And cellKey makes a cell a single number, its row times the map's width, plus its column. That's all a set of cells needs.

Below the namespace comes the constructor, and the start of play, which plays a run. Add them, with a blank line in between:

Bot::Bot(Game& game)
    : game_(game)
{
}

void Bot::play(const ClassDef& def)
{
    game_.startAs(def);
    while (game_.state_ != GameState::Dead &&
           game_.state_ != GameState::Won && game_.turn_ < MAX_TURNS)
    {
        int turn = game_.turn_;
        int depth = game_.level_.depth;
        act();

        // Nothing is drawn, so the turn's flashes and sounds are let go
        game_.effects_.finish();

        if (game_.level_.depth != depth)
        {
            arrive();
        }

In the preceding code, play starts the run as the class, as the class menu would. Then it decides, a decision at a time, until the run is won, lost or stuck. Nothing is drawn, so the flashes, missiles and sounds that a turn asks for are let go at once with finish, as a key lets them go. A new floor means the bot's memory of the last one is no use, and arrive forgets it.

Most decisions take a turn. One that doesn't, such as giving up on an item it can't reach, leaves the bot to decide again. Add the rest of play below it:

        if (game_.turn_ != turn)
        {
            idle_ = 0;
        }
        else if (++idle_ > MOST_IDLE)
        {
            // Whatever the bot was trying, it can't: it gives up on the
            // item under it, if any, and waits a turn
            skipped_.insert(cellKey(game_.player_.pos));
            game_.playerStep({ 0, 0 });
            idle_ = 0;
        }
    }
}

Ten decisions in a row that take no turn mean the bot is trying something it can't do. It gives up on whatever lies where it stands. Then it waits a turn with playerStep({ 0, 0 }), a step of nowhere, which is how the period key waits.

When the run is over, results says how it went, in one line. Add it below play:

std::string Bot::results() const
{
    const Player& player = game_.player_;
    const char* ending = game_.state_ == GameState::Won    ? "won"
                         : game_.state_ == GameState::Dead ? "died"
                                                           : "stuck";
    char line[160];
    SDL_snprintf(line, sizeof(line), "%-8s %10u  %-5s floor %d, turn %5d  %s",
                 player.classDef->name.c_str(), game_.seed_, ending,
                 game_.level_.depth, game_.turn_, player.killedBy.c_str());
    return line;
}

A call to SDL_snprintf writes the line, as in Chapter 26. The numbers between % and a letter set a width, so the runs' lines make columns. A %-8s is a string, at least eight characters wide, with a minus that lines it up on the left. And %10u is an unsigned int, at least ten wide, lined up on the right. The conditional operator, used twice in a row, chooses among three: "won", "died", or "stuck".

Now the decision itself. Add act below results:

// A decision: the first of these that does something is the turn's
void Bot::act()
{
    if (answer() || drinkIfHurt() || goForLastAmulet())
    {
        return;
    }
    const Monster* foe = nearestMonster();
    if (fight(foe) || chase(foe) || pickUpHere())
    {
        return;
    }
    if (foe == nullptr && whileSafe())
    {
        return;
    }
    if (goToItem() || explore() || goDown())
    {
        return;
    }
    wander();
}

In the preceding code, || stops at the first part that returns true, so the parts after it aren't asked. The nearest monster is found once, for the parts that need it, and the bot rests and tries unknown potions only when no monster is in view.

Answers, Potions and the Last Amulet

The game sometimes waits for an answer: a yes or no, or a choice from the pack, or a place to aim. Add answer below act:

// The game is asking something, or waiting for a choice
bool Bot::answer()
{
    Game& g = game_;
    if (g.state_ == GameState::Confirming)
    {
        // Going down without the floor's Amulet: by then, the bot has
        // looked everywhere it can. A known trap: only when it's lost
        bool descending = g.confirmPurpose_ == ConfirmPurpose::Descend;
        press(descending || lost_ ? SDLK_Y : SDLK_N);
        return true;
    }
    if (g.state_ == GameState::Inventory || g.state_ == GameState::Targeting)
    {
        press(SDLK_ESCAPE);
        return true;
    }
    return false;
}

The bot answers as handleKey expects, with press, which comes later. It says yes to leaving a floor's Amulet behind, since the game asks only when the bot has explored all it can. And it says yes to stepping onto a known trap only when it's lost. A menu, or aiming that's left open, it closes with Esc.

Below 40% of its health, or with as much poison in it as health, the bot drinks. Add drinkIfHurt below answer:

// Badly hurt, or about to die of poison: a healing potion it knows, or
// else one it doesn't, on the chance that it heals
bool Bot::drinkIfHurt()
{
    const Player& player = game_.player_;
    bool low = player.hp * 100 < player.maxHp * DRINK_BELOW;
    bool poisonKills = player.poisoned >= player.hp;
    if (!low && !poisonKills)
    {
        return false;
    }
    return use(ItemEffect::HealFully) || use(ItemEffect::Heal) ||
           useUnknown(ItemClass::Potion);
}

The health is compared with 40% by multiplying, as the game's own tests do, so no fraction is lost. A potion it knows comes first, and greater healing before healing. With neither, any potion it doesn't know is better than nothing.

With two Amulets, the third wins the run at once, so nothing else matters once it's in sight. Add goForLastAmulet below drinkIfHurt:

// The third Amulet wins the run at once, so with two, the bot makes for
// the third when it's seen, whatever's in the way
bool Bot::goForLastAmulet()
{
    Game& g = game_;
    if (g.player_.amulets != AMULETS - 1)
    {
        return false;
    }
    for (const FloorItem& lying : g.level_.items)
    {
        if (lying.item.def->itemClass != ItemClass::Amulet || !lying.seen)
        {
            continue;
        }
        if (lying.pos == g.player_.pos)
        {
            g.pickUp();
            return true;
        }
        return walkTo(lying.pos);
    }
    return false;
}

In the preceding code, only an Amulet the bot has seen counts, since a player doesn't know what lies out of sight.

Fighting

A monster beside the bot is attacked, by stepping into it, as a player attacks. Further off, it casts, or shoots, if it can. Add fight below goForLastAmulet:

// A monster beside the bot is attacked. Further off, the Mage casts
// lightning by heart at it when it can, and a bow shoots it
bool Bot::fight(const Monster* foe)
{
    if (foe == nullptr)
    {
        return false;
    }
    if (stepsApart(game_.player_.pos, foe->pos) == 1)
    {
        step(foe->pos);
        lastSeen_.reset();
        return true;
    }
    return castByHeartAt(*foe) || shootAt(*foe);
}

The bot walks to a monster in view but too far to hit, unless it's running away or more than eight steps off. Add the start of chase below fight:

// After a monster in view, unless it's running or far off; and with none
// in view, on to where the one it was after was last seen, as a player
// would go, rather than forget it the moment it's out of sight
bool Bot::chase(const Monster* foe)
{
    Point here = game_.player_.pos;
    if (foe != nullptr)
    {
        // Where the monster is, the bot reads before the step. The step
        // plays out a turn, which may kill the monster, and take it out of
        // the floor's monsters, or drop the bot through a trapdoor to a new
        // floor: either way, foe would point at nothing
        Point at = foe->pos;
        bool running = foe->state == MonsterState::Fleeing;
        if (running || stepsApart(here, at) > CHASE_STEPS || !walkTo(at))
        {
            lastSeen_.reset();
            return false;
        }
        lastSeen_ = at;
        return true;
    }

In the preceding code, the bot reads where the monster is, and whether it's running, before it takes a step. The order matters.

A step plays out a turn, and the turn may kill the monster, which the game then takes out of level_.monsters. Or it may drop the bot through a trapdoor, to a new floor with new monsters. Either way, foe would then point at nothing, or at another monster. Reading it would be undefined behavior: the program might carry on, or read nonsense, or crash. The next image shows how.

A pointer into a vector, after an erase
Figure 27.4 — A pointer into a vector, after an erase

A pointer into a vector stays valid only as long as the vector keeps its elements where they are. Erasing one moves every element after it down a place. The pointer goes on pointing at the same spot, which now holds something else, or nothing. Add the rest of chase below it:

    if (lastSeen_ && here != *lastSeen_ && walkTo(*lastSeen_))
    {
        return true;
    }
    lastSeen_.reset();
    return false;
}

The bot, with no monster in view, goes on to where the one it was after was last seen, as a player would, rather than forget it the moment it's out of sight.

The Mage's lightning by heart is cast as a player casts it, with z, and aimed with the cursor. Add castByHeartAt below chase:

// Lightning by heart, the Mage's gift, whenever it's ready, at the foe.
// It costs nothing, so the bot doesn't save it
bool Bot::castByHeartAt(const Monster& foe)
{
    Game& g = game_;
    bool knows = g.player_.classDef->has(Gift::LIGHTNING_BY_HEART);
    if (!knows || g.player_.spellTurns > 0)
    {
        return false;
    }
    g.castByHeart();
    g.lookCursor_ = foe.pos;
    std::vector<Point> path = g.lineOfFire();
    if (std::find(path.begin(), path.end(), foe.pos) == path.end())
    {
        press(SDLK_ESCAPE);   // out of reach: letting it go costs nothing
        return false;
    }
    g.fireAtCursor();
    return true;
}

In the preceding code, castByHeart is the function z calls, which starts the aiming. The bot puts the cursor on the monster, and asks for the line of fire, as the screen shows it to a player. If the monster isn't on the line, a wall is in the way, or it's out of range. Either way, the bot lets the spell go with Esc, which costs nothing. Add shootAt below it:

// An arrow, with a bow ready, arrows in the quiver, and a clear line
bool Bot::shootAt(const Monster& foe)
{
    Game& g = game_;
    if (!g.player_.bow || !g.player_.quiver)
    {
        return false;
    }
    g.fire();
    g.lookCursor_ = foe.pos;
    std::vector<Point> path = g.lineOfFire();
    if (path.empty() || path.back() != foe.pos)
    {
        press(SDLK_ESCAPE);
        return false;
    }
    g.fireAtCursor();
    return true;
}

An arrow must end its line on the monster: one that stops short, at a wall or another monster, isn't worth an arrow.

Items, and Quiet Moments

Whatever lies where the bot stands, it picks up, and then it wears the best of what it has. That makes this the one decision that can take two turns. Add pickUpHere below shootAt:

// Whatever lies here is picked up, unless the pack was full for it
// before. An Amulet always fits
bool Bot::pickUpHere()
{
    Game& g = game_;
    const FloorItem* here = g.floorItemAt(g.player_.pos);
    if (here == nullptr)
    {
        return false;
    }
    bool amulet = here->item.def->itemClass == ItemClass::Amulet;
    if (!amulet && skipped_.contains(cellKey(g.player_.pos)))
    {
        return false;
    }
    int turn = g.turn_;
    g.pickUp();
    if (g.turn_ == turn)
    {
        skipped_.insert(cellKey(g.player_.pos));   // the pack is full
        return false;
    }
    // Picking up takes a turn, which can end the run, if a monster strikes
    // or the third Amulet wins it. Wearing is only for a run still going
    if (g.state_ == GameState::Playing)
    {
        wearTheBest();
    }
    return true;
}

In the preceding code, a pickup that took no turn failed. The only way one fails is a full pack, so the bot gives up on that item. An Amulet always fits, since it doesn't go in the pack. The set's contains says whether it holds a value, as in Chapter 12. A pickup that took its turn may have ended the run, by a monster's blow or with the third Amulet, so the bot wears the best only if the run is still under way.

With no monster in view, there's time. Add whileSafe below pickUpHere:

// With no monster in view: a potion of strength it knows is drunk at
// once; it rests to full health, a turn at a time, which costs nothing
// but turns; then it finds out what its unknown potions and scrolls are
// by trying them, as players do
bool Bot::whileSafe()
{
    Game& g = game_;
    if (use(ItemEffect::Strengthen))
    {
        return true;
    }
    if (g.player_.hp < g.player_.maxHp)
    {
        g.playerStep({ 0, 0 });
        return true;
    }
    return useUnknown(ItemClass::Potion) || useUnknown(ItemClass::Scroll);
}

Resting is a wait, a turn at a time. It costs nothing, since the game has no hunger. Then the bot tries its unknown potions and scrolls, which is how a player finds out what they are.

The bot walks to the nearest item it has seen. Nearest by the way there, though, not as the crow flies. An item across a wall can be close as the crow flies, and far to walk. Going by the crow, an earlier bot turned back and forth between two items forever. Add the start of goToItem below whileSafe:

// To the nearest item it has seen and not given up on: nearest by the way
// there, not as the crow flies, so that a step toward it leaves it the
// nearest, and the bot doesn't turn back and forth between two
bool Bot::goToItem()
{
    Game& g = game_;
    DijkstraMap fromBot;
    fromBot.compute(g.knownMap(), { g.player_.pos });
    const FloorItem* best = nullptr;
    int bestDistance = DijkstraMap::UNREACHED;
    for (const FloorItem& lying : g.level_.items)
    {
        if (!lying.seen || skipped_.contains(cellKey(lying.pos)))
        {
            continue;
        }
        int distance = fromBot.at(lying.pos);
        if (distance < bestDistance)
        {
            best = &lying;
            bestDistance = distance;
        }
    }

In the preceding code, a Dijkstra map from the bot, as in Chapter 10, gives the walking distance to every cell, on the floor as the bot knows it. Add the rest below it:

    if (best == nullptr)
    {
        return false;
    }
    if (!walkTo(best->pos))
    {
        skipped_.insert(cellKey(best->pos));
        return false;
    }
    return true;
}

The bot gives up on an item it can't find a way to.

Exploring, and Going Down

With nothing to fight and nothing to pick up, the bot explores, with Chapter 23's explore, a step at a time. Add explore below goToItem:

// A step of the game's own explore, until there's nothing left
bool Bot::explore()
{
    if (explored_)
    {
        return false;
    }
    int turn = game_.turn_;
    explored_ = game_.exploreStep();
    return game_.turn_ != turn;
}

A call to exploreStep returns true when there's nothing left to explore. The bot remembers it, and stops asking until a new floor. Add goDown below it:

// Down the stairs, or on toward them
bool Bot::goDown()
{
    Game& g = game_;
    Point here = g.player_.pos;
    if (g.level_.map.at(here.x, here.y) == Terrain::StairsDown)
    {
        g.descend();
        return true;
    }
    std::optional<Point> stairs = g.knownStairs();
    return stairs && walkTo(*stairs);
}

Last of all, with no way on that it knows of, the bot is lost. Add wander below goDown:

// Lost: no stairs it knows of, and nothing left to explore that it can
// reach, as when a scroll of teleportation has put it in a corner behind
// a secret door. It wanders, and searches, as a lost player would, and
// steps onto known traps if it has to. Then it explores again, in case a
// way has opened
void Bot::wander()
{
    lost_ = true;
    explored_ = false;
    if (SDL_rand(2) == 0)
    {
        game_.search();
    }
    else
    {
        game_.playerStep(DIRECTIONS[SDL_rand(8)]);
    }
}

Calls to SDL_rand choose between a search and a step in one of the eight directions, from DIRECTIONS, the game's own list. The game's dice aren't used, so the bot's choices don't change the dungeon's. And with SDL_srand, which comes later, the bot's own dice start from the seed too. So a run plays out the same every time. Add arrive below it:

// A new floor: what the bot knew of the last one is no use
void Bot::arrive()
{
    lastSeen_.reset();
    skipped_.clear();
    explored_ = false;
    lost_ = false;
}

Everything the bot remembers of the floor is cleared: where it last saw a monster, the items it gave up on, and whether it has explored the floor, or is lost on it.

The Helpers

After every pickup, the bot looks through its pack for something better. Add the start of wearTheBest below arrive:

// Wields a weapon that hits harder, wears better armor, readies a bow
// with none ready, and fills the quiver
void Bot::wearTheBest()
{
    Game& g = game_;
    const Player& player = g.player_;
    const std::vector<Item>& items = player.inventory.items();
    for (size_t i = 0; i < items.size(); ++i)
    {
        const ItemDef& def = *items[i].def;
        bool better = false;
        if (def.itemClass == ItemClass::Weapon)
        {
            better = def.minDamage + def.maxDamage >
                     player.minDamage() + player.maxDamage();
        }
        else if (def.itemClass == ItemClass::Armor)
        {
            better = def.defense >
                     (player.armor ? player.armor->def->defense : 0);
        }

In the preceding code, a weapon is better if its least and most damage add up to more, which compares their averages. Add the rest below it:

        else if (def.itemClass == ItemClass::Bow)
        {
            better = !player.bow ||
                     def.maxDamage > player.bow->def->maxDamage;
        }
        else if (def.itemClass == ItemClass::Missile)
        {
            better = !def.thrown;   // arrows, for the quiver
        }
        if (better)
        {
            g.useItem(static_cast<int>(i));
            return;
        }
    }
}

Arrows go in the quiver, and darts stay in the pack. Using an item is what i and a letter do. So useItem wields a weapon, wears armor, readies a bow and fills the quiver, as each needs. It takes a turn, so the bot does one thing at a time, and returns.

The nearest monster is the nearest one in view: the bot doesn't know about the others, any more than a player would. Add nearestMonster below wearTheBest:

// The nearest monster in view, or nullptr
const Monster* Bot::nearestMonster() const
{
    const Monster* best = nullptr;
    int bestSteps = MAP_W;
    for (const Monster& monster : game_.level_.monsters)
    {
        bool seen = game_.level_.map.isVisible(monster.pos.x, monster.pos.y);
        int steps = stepsApart(game_.player_.pos, monster.pos);
        if (seen && monster.isAlive() && steps < bestSteps)
        {
            best = &monster;
            bestSteps = steps;
        }
    }
    return best;
}

The bot walks with Chapter 23's A*, on the floor as it knows it, and steps as a key would. Add walkTo and step below nearestMonster:

// A step along the shortest way the bot knows to a cell, around the traps
// it knows of. Returns false if it knows no way there
bool Bot::walkTo(Point goal)
{
    std::vector<Point> path = AStar::findPath(game_.knownMap(),
                                              game_.player_.pos, goal);
    if (path.empty())
    {
        return false;
    }
    step(path.front());
    return true;
}

// A step toward a cell next to the bot, or into a monster there
void Bot::step(Point to)
{
    Point from = game_.player_.pos;
    game_.playerStep({ SDL_clamp(to.x - from.x, -1, 1),
                       SDL_clamp(to.y - from.y, -1, 1) });
}

In the preceding code, step turns a cell next to the bot into one of the steps a key gives, -1, 0 or 1 across and down, with SDL_clamp. Stepping into a monster attacks it, so step is how the bot fights, too.

Using a potion or a scroll is a matter of finding it in the pack. Add use below step:

// Uses the first potion or scroll in the pack known to have an effect
bool Bot::use(ItemEffect effect)
{
    Game& g = game_;
    const std::vector<Item>& items = g.player_.inventory.items();
    for (size_t i = 0; i < items.size(); ++i)
    {
        const ItemDef& def = *items[i].def;
        if (def.effect == effect && g.identification_.isKnown(def))
        {
            g.useItem(static_cast<int>(i));
            return true;
        }
    }
    return false;
}

Only a potion or scroll whose kind the bot knows will do, as a player counts only on a potion they recognize. The ones it doesn't know, it tries when it's safe, or when it's badly hurt and knows no healing potion. Add useUnknown below use:

// Uses the first potion, or scroll, whose kind isn't known yet
bool Bot::useUnknown(ItemClass itemClass)
{
    Game& g = game_;
    const std::vector<Item>& items = g.player_.inventory.items();
    for (size_t i = 0; i < items.size(); ++i)
    {
        const ItemDef& def = *items[i].def;
        if (def.itemClass == itemClass && !g.identification_.isKnown(def))
        {
            g.useItem(static_cast<int>(i));
            return true;
        }
    }
    return false;
}

The game's questions are answered with keys. Add press below them:

// A key, as if pressed, for the game's questions and menus
void Bot::press(SDL_Keycode key)
{
    SDL_KeyboardEvent event{};
    event.key = key;
    game_.handleKey(event);
}

The empty braces, {}, make a keyboard event with every field 0. Only the key is set, which is all handleKey reads.

Playing the Runs

Last, playBotRuns plays the runs: for each seed, a run as each class. Each run is in a Game of its own, as the class menu would start it. Add it at the end of Bot.cpp, with a blank line in between:

void playBotRuns(SDL_Renderer* renderer, GlyphCache& glyphs,
                 const GameData& data, SoundBank& sounds, int runs,
                 unsigned int firstSeed)
{
    for (int i = 0; i < runs; ++i)
    {
        unsigned int seed = firstSeed + i;
        for (const ClassDef& def : data.classes())
        {
            // SDL's own dice, which the bot wanders with, start from the
            // seed too, so a run plays out the same every time
            SDL_srand(seed);
            Game game(renderer, glyphs, data, sounds, seed, false, false);
            Bot bot(game);
            bot.play(def);
            std::printf("%s\n", bot.results().c_str());
            std::fflush(stdout);
        }
    }
}

In the preceding code, SDL_srand starts SDL's own dice from the seed, before each run, so the bot wanders the same way each time it plays that seed as that class. The Game is made with the seed, so its dungeon is the seed's. And false, false start it in the glyph view, and not at the title. Then std::printf prints the run's line to the console window. And std::fflush(stdout) makes it appear at once, rather than when the program's output fills up a buffer.

Checkpoint: The bot is best run in Release, which builds the game with the compiler's optimizations: a run takes about a second, rather than about fifteen in Debug. In the toolbar, change Debug to Release. Then right-click the project, choose Properties, set Configuration to All Configurations, choose Debugging, type --bot 5 into Command Arguments, and click OK. Press Ctrl+F5, which starts the game without the debugger. The first build in Release compiles every file again, which takes a minute or two.

No window opens. The console window starts with No sound: Audio subsystem is not initialized. That's the sound bank saying it has no device, since the bot never starts audio. Then come fifteen lines, a run as each class for seeds 1 to 5:

No sound: Audio subsystem is not initialized
Fighter           1  died  floor 6, turn  3675  poison
Rogue             1  stuck floor 4, turn 30000
Mage              1  died  floor 4, turn  2044  a grizzly bear
Fighter           2  died  floor 4, turn  1813  poison
Rogue             2  died  floor 5, turn  2089  a skeleton orc
Mage              2  died  floor 4, turn  1623  a cave orc
Fighter           3  died  floor 5, turn  2101  a cave orc
Rogue             3  died  floor 5, turn  2355  an archer's arrow
Mage              3  died  floor 4, turn  1562  a wolf
Fighter           4  died  floor 7, turn  5423  a dusk-elf mage's lightning
Rogue             4  died  floor 4, turn  1701  a cave orc
Mage              4  died  floor 4, turn  1451  a grizzly bear
Fighter           5  died  floor 8, turn  4359  a forest troll
Rogue             5  died  floor 7, turn  4001  a forest troll
Mage              5  died  floor 4, turn  2100  a cave orc

No run is won, which is no surprise from five seeds. The Rogue's run on seed 1 is stuck: it was still going after 30,000 turns, so its line took a few seconds longer than the rest. Something kept it from the stairs, and the line can't say what. The next part has the bot tell us.

The Bot Reports

A line a run is fine for fifteen runs, and no use for six hundred. This part has the bot say why it does each thing, so that a run that goes wrong can be read. Then it adds up the runs in a report.

Saying Why

Each part of a decision will say what it did, in a few words. And the bot keeps its last twelve decisions, with where it was. The decisions are kept in a std::deque, a double-ended queue, which can add and remove at either end. So the oldest can go from the front as each new one goes on the back. Open Bot.h, and add its header below #include <SDL3/SDL.h>:

#include <deque>      // std::deque, for the bot's last few decisions

Add the words, and the last decisions, below idle_:

std::string why_;                 // the decision, in a few words
std::deque<std::string> decisions_;   // the last LAST_DECISIONS

The parts will say what they did in why_. Replace the comment above bool answer(); with this:

// The parts of a decision. Each returns true if it did something, and
// says what in why_

Then declare the function that keeps a decision: add the line void remember(int turn, Point from); below void arrive();.

In Bot.cpp, add how many decisions it keeps below MOST_IDLE, with a blank line in between:

// How many of its last decisions the bot keeps, to show what it was
// trying if it gets stuck
constexpr size_t LAST_DECISIONS = 12;

A cell is written as (x,y). Add a helper for that below cellKey:

std::string cellText(Point cell)
{
    return "(" + std::to_string(cell.x) + "," + std::to_string(cell.y) +
           ")";
}

In play, each decision starts with no words, from where the bot stands, and ends remembered. Add these lines directly above act();:

Point from = game_.player_.pos;
why_.clear();

Then, in play, add the line remember(turn, from); below act();.

Add remember below arrive:

// Keeps the last LAST_DECISIONS decisions, as in "turn 1520, floor 4,
// (23,17): explores"
void Bot::remember(int turn, Point from)
{
    decisions_.push_back("turn " + std::to_string(turn) + ", floor " +
                         std::to_string(game_.level_.depth) + ", " +
                         cellText(from) + ": " + why_);
    if (decisions_.size() > LAST_DECISIONS)
    {
        decisions_.pop_front();
    }
}

A decision that pushes the deque past twelve pushes the oldest off its front, with pop_front.

Now each part says what it did, starting with the answers. In answer, find this line:

press(descending || lost_ ? SDLK_Y : SDLK_N);

Change it to these:

bool yes = descending || lost_;
why_ = yes ? "says yes to the game's question"
           : "says no to the game's question";
press(yes ? SDLK_Y : SDLK_N);

Then add this line directly above press(SDLK_ESCAPE);, in the if for the pack and the aiming:

why_ = "puts the pack away, or stops aiming";

In drinkIfHurt, add this line directly above its last return:

why_ = "drinks, badly hurt";

In goForLastAmulet, add this line directly above if (lying.pos == g.player_.pos):

why_ = "makes for the last Amulet";

In fight, add this line directly above step(foe->pos);:

why_ = "attacks the " + foe->def->name;

In chase, the words name the monster. So they must be written before the step, as everything the bot reads of the monster must. Find the comment inside chase:

// Where the monster is, the bot reads before the step. The step
// plays out a turn, which may kill the monster, and take it out of
// the floor's monsters, or drop the bot through a trapdoor to a new
// floor: either way, foe would point at nothing

Change it to these:

// What the bot needs to know of the monster, it reads before the
// step. The step plays out a turn, which may kill the monster, and
// take it out of the floor's monsters, or drop the bot through a
// trapdoor to a new floor: either way, foe would point at nothing

Then, in chase, add the line why_ = "chases the " + foe->def->name + " at " + cellText(at); below bool running = foe->state == MonsterState::Fleeing;.

And add this line directly below if (lastSeen_ && here != *lastSeen_ && walkTo(*lastSeen_)) and its opening brace:

why_ = "goes where it last saw a monster, " + cellText(*lastSeen_);

In castByHeartAt, add this line directly above g.fireAtCursor();:

why_ = "casts lightning at the " + foe.def->name;

In shootAt, add this line directly above its g.fireAtCursor();:

why_ = "shoots at the " + foe.def->name;

In pickUpHere, add this line directly above wearTheBest();:

why_ = "picks up what's here";

In whileSafe, add this line directly below if (use(ItemEffect::Strengthen)) and its opening brace:

why_ = "drinks a potion of strength";

Add this one directly above g.playerStep({ 0, 0 });:

why_ = "rests";

And this one directly above its last return:

why_ = "tries something it doesn't know";

In goToItem, the words name the item's cell, but only after the step. The step may have picked the item up, or dropped the bot through a trapdoor. So the cell is read first, as in chase. Find these lines:

if (!walkTo(best->pos))
{
    skipped_.insert(cellKey(best->pos));
    return false;
}
return true;

Change them to these:

Point goal = best->pos;   // read before the step, as in chase
if (!walkTo(goal))
{
    skipped_.insert(cellKey(goal));
    return false;
}
why_ = "goes to an item at " + cellText(goal);
return true;

In explore, add the line why_ = "explores"; below explored_ = game_.exploreStep();.

In goDown, add this line directly above g.descend();:

why_ = "goes down the stairs";

Then, in goDown, add the line why_ = "walks to the stairs"; below std::optional<Point> stairs = g.knownStairs();.

In wander, add this line directly above game_.search();:

why_ = "is lost, and searches";

And this one directly above game_.playerStep(DIRECTIONS[SDL_rand(8)]);:

why_ = "is lost, and wanders";

The words go in before the part acts, but they're only kept for the part that took the turn. A part that sets why_ and then finds it can't act leaves the next part to set it again.

A Run's Results

A run's results become a struct, rather than a line, so the report can add them up. Add it to Bot.h, above the class's comment:

// How a run ended: the class, the seed, how it ended (won, died, or stuck,
// still going after MAX_TURNS), the floor and the turn it ended on, what
// killed the player, if anything did, and, for a stuck run, the bot's last
// decisions, to show what it was trying
struct RunResult
{
    std::string className;
    unsigned int seed = 0;
    std::string ending;
    int floor = 1;
    int turn = 0;
    std::string killedBy;
    std::vector<std::string> lastDecisions;
};

The struct holds the last decisions in a vector, so Bot.h needs <vector>. Add it below #include <string>:

#include <vector>     // std::vector, for the last decisions, handed over

The bot hands over the struct in place of the line. Find these lines:

// The run's results, as one line: the class, the seed, how it ended,
// the floor and the turn it ended on, and what killed the player
std::string results() const;

Change them to these:

// How the run went
RunResult result() const;

The line is made from the struct now, by a function that isn't a member, since it needs only the struct. Find the comment above playBotRuns's declaration:

// Has the bot play runs seeds, from firstSeed, as each class in turn, and
// prints a line of results for each run

Change it to these:

// One line about a run, and for a stuck run, its last decisions
std::string describe(const RunResult& run);

// Has the bot play runs seeds, from firstSeed, as each class in turn. It
// prints a line for each run, and at the end, a report of them all

In Bot.cpp, replace the whole of results with this:

RunResult Bot::result() const
{
    RunResult run;
    run.className = game_.player_.classDef->name;
    run.seed = game_.seed_;
    run.ending = game_.state_ == GameState::Won    ? "won"
                 : game_.state_ == GameState::Dead ? "died"
                                                   : "stuck";
    run.floor = game_.level_.depth;
    run.turn = game_.turn_;
    run.killedBy = game_.player_.killedBy;
    if (run.ending == "stuck")
    {
        run.lastDecisions.assign(decisions_.begin(), decisions_.end());
    }
    return run;
}

A stuck run carries its last decisions. They're copied from the deque into the vector by assign, from the deque's beginning to its end. Add describe above playBotRuns:

std::string describe(const RunResult& run)
{
    char line[160];
    SDL_snprintf(line, sizeof(line), "%-8s %10u  %-5s floor %d, turn %5d  %s",
                 run.className.c_str(), run.seed, run.ending.c_str(),
                 run.floor, run.turn, run.killedBy.c_str());
    std::string text = line;
    for (const std::string& decision : run.lastDecisions)
    {
        text += "\n      " + decision;
    }
    return text;
}

The line is the one results made. Each of a stuck run's last decisions goes on a line of its own below it, indented.

The Report

The report adds the runs up as they come, for each class and for them all: the runs, the wins, the stuck runs, the floors reached, and the deaths on each floor, and the causes of death. Add a Header File (.h) called BotReport.h, and type its includes, and the class's public part:

#pragma once
#include <map>      // std::map, for the classes and the causes of death
#include <string>   // std::string, for their names
#include <vector>   // std::vector, for the classes, in order
#include "Bot.h"
#include "Common.h"

// The bot's runs, added up: for each class, how many it played, won and
// got stuck in, the floors it reached, and the floor of each death; and
// what killed it most
class BotReport
{
public:
    // Adds a run to the report
    void add(const RunResult& run);

    // Prints the report: a row for each class, and one for them all, then
    // the causes of death, the most common first
    void print() const;

Add its private part below print's declaration, with a blank line in between:

private:
    // What's added up, for a class or for them all
    struct Tally
    {
        int runs = 0;
        int won = 0;
        int stuck = 0;
        int floors = 0;                    // the floors reached, added up
        int diedOn[LAST_FLOOR + 1] = {};   // the deaths on each floor
    };

    void printRow(const std::string& name, const Tally& tally) const;

    std::vector<std::string> classOrder_;   // as they first played
    std::map<std::string, Tally> tallies_;
    Tally all_;
    std::map<std::string, int> causes_;
};

The struct Tally is what's added up, for one class, or for all. Its diedOn is an array with a count for each floor, and = {} makes every count 0. It's one longer than the floors, so a floor's deaths are at its own number, diedOn[depth]. That leaves the first count, diedOn[0], never used.

The classes are kept in a std::map by name, and their names in a vector too, in the order they first played. So the report lists them in the class menu's order, rather than alphabetically.

Add a C++ File (.cpp) called BotReport.cpp, and type its includes, and the start of add, which notes each class the first time it plays:

#include "BotReport.h"
#include <algorithm>   // std::sort, for the causes of death
#include <cstdio>      // std::printf, for the report
#include <utility>     // std::pair, for a cause and its count

namespace
{
    // How many causes of death the report lists
    constexpr int CAUSES_SHOWN = 8;
}

void BotReport::add(const RunResult& run)
{
    if (!tallies_.contains(run.className))
    {
        classOrder_.push_back(run.className);
    }

Add the rest of add below it, with a blank line in between:

    // The run counts for its class, and for them all
    for (Tally* tally : { &tallies_[run.className], &all_ })
    {
        ++tally->runs;
        tally->floors += run.floor;
        if (run.ending == "won")
        {
            ++tally->won;
        }
        else if (run.ending == "stuck")
        {
            ++tally->stuck;
        }
        else
        {
            ++tally->diedOn[run.floor];
        }
    }
    if (run.ending == "died")
    {
        ++causes_[run.killedBy];
    }
}

In the preceding code, the braces in the for make a list of two pointers, one to the run's class's tally and one to the tally of all. So the same lines count the run for both. A map's [] makes the class's tally the first time it's asked for, as Chapter 26's kills did.

Now print, which prints a row for each class and one for all, and then the causes of death, the most common first. Add it below add:

void BotReport::print() const
{
    std::printf("\nThe bot's %d runs\n\n", all_.runs);
    std::printf("%-8s %5s %5s %6s %6s   Deaths on floors 1 to %d\n",
                "Class", "Runs", "Won", "Stuck", "Floor", LAST_FLOOR);
    for (const std::string& name : classOrder_)
    {
        printRow(name, tallies_.at(name));
    }
    printRow("All", all_);

    // The causes of death, the most common first
    std::vector<std::pair<int, std::string>> causes;
    for (const auto& [cause, count] : causes_)
    {
        causes.push_back({ count, cause });
    }
    std::sort(causes.rbegin(), causes.rend());
    std::printf("\nWhat killed it most:\n");
    for (int i = 0; i < CAUSES_SHOWN && i < static_cast<int>(causes.size());
         ++i)
    {
        std::printf("%6d  %s\n", causes[i].first, causes[i].second.c_str());
    }
}

In the preceding code, the causes are copied from the map into a vector of pairs, each its count and its cause, since a map can only be in the order of its keys. Sorting pairs sorts them by their first value, the count, and then, for equal counts, by the second. And rbegin and rend are the vector's reverse iterators, which run from its end to its beginning. So std::sort puts them in order from the back, and the biggest counts come first.

Last, printRow, a row of the report. Add it below print:

// A row of the report: the class, its runs, the share it won, the runs it
// got stuck in, the floor it reached on average, and its deaths on each
// floor
void BotReport::printRow(const std::string& name, const Tally& tally) const
{
    if (tally.runs == 0)
    {
        return;
    }
    int wonPercent = (100 * tally.won + tally.runs / 2) / tally.runs;
    double floor = static_cast<double>(tally.floors) / tally.runs;
    std::printf("%-8s %5d %4d%% %6d %6.1f  ", name.c_str(), tally.runs,
                wonPercent, tally.stuck, floor);
    for (int depth = 1; depth <= LAST_FLOOR; ++depth)
    {
        std::printf(" %3d", tally.diedOn[depth]);
    }
    std::printf("\n");
}

Adding half the runs before dividing rounds the share won to the nearest whole percent. The average floor needs a fraction, so floors is made a double before the division, or it would be a whole number, rounded down. In printf, %% prints a percent sign, since a lone % starts a format.

The report is made, filled and printed in playBotRuns. In Bot.cpp, add the line #include "BotReport.h" below #include "AStar.h", for the report's header.

Then replace the whole of playBotRuns with this:

void playBotRuns(SDL_Renderer* renderer, GlyphCache& glyphs,
                 const GameData& data, SoundBank& sounds, int runs,
                 unsigned int firstSeed)
{
    BotReport report;
    for (int i = 0; i < runs; ++i)
    {
        unsigned int seed = firstSeed + i;
        for (const ClassDef& def : data.classes())
        {
            // SDL's own dice, which the bot wanders with, start from the
            // seed too, so a run plays out the same every time
            SDL_srand(seed);
            Game game(renderer, glyphs, data, sounds, seed, false, false);
            Bot bot(game);
            bot.play(def);
            RunResult run = bot.result();
            std::printf("%s\n", describe(run).c_str());
            std::fflush(stdout);
            report.add(run);
        }
    }
    report.print();
}

Checkpoint: Press Ctrl+F5, with --bot 5 still in Command Arguments. The fifteen runs end as they did, since nothing about how the bot plays has changed. So the Rogue's on seed 1 is stuck again. But now its line is followed by its last twelve decisions:

Rogue             1  stuck floor 4, turn 30000
      turn 29988, floor 4, (31,21): chases the grizzly bear at (39,24)
      turn 29989, floor 4, (32,22): goes to an item at (31,21)
      turn 29990, floor 4, (31,21): chases the grizzly bear at (39,24)
      turn 29991, floor 4, (32,22): goes to an item at (31,21)
      turn 29992, floor 4, (31,21): chases the grizzly bear at (39,24)
      turn 29993, floor 4, (32,22): goes to an item at (31,21)
      turn 29994, floor 4, (31,21): chases the grizzly bear at (39,24)
      turn 29995, floor 4, (32,22): goes to an item at (31,21)
      turn 29996, floor 4, (31,21): chases the grizzly bear at (39,24)
      turn 29997, floor 4, (32,22): goes to an item at (31,21)
      turn 29998, floor 4, (31,21): chases the grizzly bear at (39,24)
      turn 29999, floor 4, (32,22): goes to an item at (31,21)

A step toward a grizzly bear, and a step back toward an item, over and over. Below the runs, the report adds up all fifteen: none won, one stuck, and floor 5.0 on average.

Five seeds are too few to say much about the game, so now a bigger sample. Change Command Arguments to --bot 200 1000, for 200 seeds from seed 1000, 600 runs in all, and press Ctrl+F5. It takes about ten minutes. The report at the end is the one the rest of the chapter works from:

The bot's 600 runs

Class     Runs   Won  Stuck  Floor   Deaths on floors 1 to 9
Fighter    200   10%      9    6.3     1   1   1  23  36  40  38  13  18
Rogue      200    8%      2    5.7    11   9   5  26  32  41  40  13   5
Mage       200    2%      0    3.5    40  41  33  27  20  19   3   9   5
All        600    7%     11    5.2    52  51  39  76  88 100  81  35  28

What killed it most:
   115  a cave orc
    74  a wolf
    53  a dusk-elf mage's lightning
    50  a forest troll
    39  a skeleton orc
    37  poison
    36  an archer's arrow
    34  an ogre

The bot wins 7% of its runs, and the Mage only 2%. Two in five of the Mage's runs end on floor 1 or 2. Eleven runs are stuck, and each has its trace above the report, below its line.

What the Traces Found

A bot's first job is to play well enough that its numbers mean something, and a stuck run is the bot's bug, not the game's. This part reads the stuck runs' traces, fixes what they show, and teaches the bot to use its attack scrolls.

Torn Between Two

The Rogue's trace on seed 1 is the place to start: a step toward a grizzly bear, a step back toward an item, and again. Five of the eleven stuck runs in the report went the same way, each with a monster and an item of its own. The bot's two cells explain it. The next image shows them.

Torn between two monsters
Figure 27.5 — Torn between two monsters

From the first cell, the bot sees a grizzly bear, asleep, eight steps off, close enough to chase, and steps toward it. One step on, the bear is out of sight, behind a wall. The nearest monster in view is a shrieker, nine steps off, too far to chase. So chase gives up, and gives up on the bear too, since giving up resets lastSeen_.

With nothing to chase, the bot makes for the nearest item, some arrows lying on the first cell. From there it sees the bear again. It never picks the arrows up, either, since chasing comes first.

The fix is that a monster the bot won't chase doesn't make it forget the one it was after. In Bot.cpp, replace the comment above chase with this:

// After a monster in view, unless it's running or far off; and with none
// worth chasing in view, on to where the one it was after was last seen,
// as a player would go, rather than forget it the moment it's out of
// sight. (A monster it won't chase doesn't make it forget the one it
// will: the trace of a stuck run showed the bot torn between a grizzly
// bear, seen from one cell, and a shrieker too far off to chase, seen
// from the next)

Then, in chase, find these lines:

if (foe != nullptr)
{

Change them to these:

bool worthIt = foe != nullptr &&
               foe->state != MonsterState::Fleeing &&
               stepsApart(here, foe->pos) <= CHASE_STEPS;
if (worthIt)
{

The new worthIt asks what the old test asked, the other way around: is there a monster, not running, and near enough? Further down, the old test goes. Find these lines:

bool running = foe->state == MonsterState::Fleeing;
why_ = "chases the " + foe->def->name + " at " + cellText(at);
if (running || stepsApart(here, at) > CHASE_STEPS || !walkTo(at))
{
    lastSeen_.reset();
    return false;
}
lastSeen_ = at;
return true;

Change them to these:

why_ = "chases the " + foe->def->name + " at " + cellText(at);
if (walkTo(at))
{
    lastSeen_ = at;
    return true;
}

Only a monster worth chasing changes what the bot is after. Otherwise the bot goes on to where it last saw the one it was after, the bear, and finds it.

A Monster That Keeps Its Distance

With only that fix, all five torn runs went on to their ends. Two more of the report's stuck runs, the Fighter's on seeds 1093 and 1169, were caught in a longer loop, each after an archer. The fix changed how the first one played, and it went on to its end too. The second was still stuck, and its trace ends like this:

Fighter        1169  stuck floor 3, turn 30000
      turn 29988, floor 3, (19,27): explores
      turn 29989, floor 3, (19,28): explores
      turn 29990, floor 3, (19,29): chases the archer at (19,26)
      turn 29991, floor 3, (19,28): goes where it last saw a monster, (19,26)
      turn 29992, floor 3, (19,27): goes where it last saw a monster, (19,26)
      turn 29993, floor 3, (19,26): explores
      turn 29994, floor 3, (19,27): explores
      turn 29995, floor 3, (19,28): explores
      turn 29996, floor 3, (19,29): chases the archer at (19,26)
      turn 29997, floor 3, (19,28): goes where it last saw a monster, (19,26)
      turn 29998, floor 3, (19,27): goes where it last saw a monster, (19,26)
      turn 29999, floor 3, (19,26): explores

An archer keeps its distance, as a dusk-elf mage does. It stays three to five steps from the player, and backs away from one who comes closer.

From (19,29), the bot sees the archer, three steps off, and steps toward it. The archer backs away, out of sight. So the bot goes on to where it last saw the archer, finds nothing there, and goes back to exploring, the way it came. Back at (19,29), it sees the archer again, three steps off: thousands of turns of chasing, and never a blow.

So a chase now has a limit. After forty steps without a blow, the bot lets monsters be for a hundred turns, and gets on with something else. In Bot.cpp, add the two numbers below LAST_DECISIONS, with a blank line in between:

// Steps of chasing with no blow struck before the bot gives up on a
// monster, and the turns it then lets monsters be (countChaseStep)
constexpr int CHASE_LIMIT = 40;
constexpr int CHASE_REST = 100;

In Bot.h, add the chase's count, and the turn it may chase again, below idle_:

int chaseSteps_ = 0;              // chasing, since its last blow
int chaseAgainOn_ = 0;            // the turn it will chase again

Then declare the function that counts a step of chasing: add the line void countChaseStep(); below void arrive();.

A bot that's resting from chasing chases nothing, neither a monster in view nor one it last saw. In chase, find these lines:

bool worthIt = foe != nullptr &&
               foe->state != MonsterState::Fleeing &&
               stepsApart(here, foe->pos) <= CHASE_STEPS;
if (worthIt)
{

Change them to these:

bool rested = game_.turn_ >= chaseAgainOn_;
bool worthIt = foe != nullptr && rested &&
               foe->state != MonsterState::Fleeing &&
               stepsApart(here, foe->pos) <= CHASE_STEPS;
if (worthIt)
{

Every step of a chase is counted, after a monster in view or on to where one was last seen. Find these lines, further down:

    why_ = "chases the " + foe->def->name + " at " + cellText(at);
    if (walkTo(at))
    {
        lastSeen_ = at;
        return true;
    }
}
if (lastSeen_ && here != *lastSeen_ && walkTo(*lastSeen_))
{
    why_ = "goes where it last saw a monster, " + cellText(*lastSeen_);

Change them to these:

    why_ = "chases the " + foe->def->name + " at " + cellText(at);
    if (walkTo(at))
    {
        lastSeen_ = at;
        countChaseStep();
        return true;
    }
}
if (rested && lastSeen_ && here != *lastSeen_ && walkTo(*lastSeen_))
{
    why_ = "goes where it last saw a monster, " + cellText(*lastSeen_);
    countChaseStep();

Now add countChaseStep below chase:

// A step of chasing with no blow struck. After CHASE_LIMIT of them, the
// monster is one that keeps its distance, as archers do, and the bot lets
// it be for CHASE_REST turns. (Found in the trace of a stuck run: the bot
// following an archer that kept its distance, back and forth,
// forever)
void Bot::countChaseStep()
{
    if (++chaseSteps_ >= CHASE_LIMIT)
    {
        chaseAgainOn_ = game_.turn_ + CHASE_REST;
        chaseSteps_ = 0;
        lastSeen_.reset();
    }
}

A blow ends the count, since a chase that lands a blow is working. In fight, add the line chaseSteps_ = 0; below lastSeen_.reset();. And a new floor ends it too. In arrive, add the line chaseSteps_ = 0; below lost_ = false;.

With the limit, a chase after a monster that keeps its distance ends after forty steps, and the bot gets on with the rest of the floor. That's the last change to chase, which has grown a piece at a time. If yours misbehaves, the book's Chapter 27 folder has the finished Bot.cpp to compare it with.

Lost Behind a Secret Door

The other four stuck runs in the report were lost, in ways no fix here helps. The next image shows the Fighter's, on seed 1117.

Lost in a pocket behind a secret door
Figure 27.6 — Lost in a pocket behind a secret door

On floor 3, the Fighter read a scroll it didn't know, and it was a scroll of teleportation. It landed in two rooms, joined by a door. Their only way out is a secret door, at the top of a corridor from one of them, into a room it had explored from the other side. Its wandering, a random step or a search at a time, never found the door in more than 28,000 turns.

On seed 1017, the Rogue read a scroll of teleportation on floor 2, and landed in a pocket of its own. The Fighter, on seed 1143, stepped onto a teleport trap on floor 2, into another. It found the door at last, some 25,000 turns later, and was on floor 9 when its turns ran out. A player would search along the walls, a few steps apart, which is an exercise for you at the end of the chapter.

The Fighter's run on seed 1002 is lost in another way. On floor 2, a teleport trap sent it across the floor, away from the stairs. It knows where they are, but both ways back to them cross a trap it knows of, and the bot walks around the traps it knows. Wandering, it would step onto one, and say yes when the game asks, but in more than 29,000 turns it never happened to.

Attack Scrolls

One last change isn't a fix. The bot reads a scroll of lightning, or of fireball, only to find out what it is. A scroll it knows, it never reads at all. Now it reads one when a fight is going badly: when it's below half its health, or more than one monster is in view. In Bot.h, add the line bool readAttackAt(const Monster& foe); below bool shootAt(const Monster& foe);, for the function that reads one.

It needs to know how many monsters are in view, so add the line int monstersInView() const; below const Monster* nearestMonster() const; too.

In Bot.cpp, fight reads a scroll when its other ways can't hit. Replace the comment above fight with this:

// A monster beside the bot is attacked. Further off, the Mage casts
// lightning by heart at it when it can, a bow shoots it, and a scroll is
// read at it if the fight is going badly

Then, in fight, find this line:

return castByHeartAt(*foe) || shootAt(*foe);

Change it to these:

if (castByHeartAt(*foe) || shootAt(*foe) || readAttackAt(*foe))
{
    chaseSteps_ = 0;
    return true;
}
return false;

A cast, a shot or a scroll at the monster is as good as a blow, and ends the chase's count. Add readAttackAt below fight:

// A scroll of lightning, or of fireball, that the bot knows, at the foe,
// when the fight is going badly: the bot below half its health, or more
// than one monster in view. The line of fire is checked first, as for the
// spell and the bow, but before the scroll is read, since a read scroll
// let go is wasted. Lightning needs the foe on the line. A fireball needs
// the foe to be the first thing the line meets, three steps or more away,
// so its burst doesn't catch the bot
bool Bot::readAttackAt(const Monster& foe)
{
    Game& g = game_;
    const Player& player = g.player_;
    bool badly = player.hp * 2 < player.maxHp || monstersInView() > 1;
    if (!badly)
    {
        return false;
    }
    Point at = foe.pos;
    std::string name = foe.def->name;
    g.lookCursor_ = at;
    g.targetPurpose_ = TargetPurpose::Lightning;
    std::vector<Point> path = g.lineOfFire();
    bool onLine = std::find(path.begin(), path.end(), at) != path.end();
    g.targetPurpose_ = TargetPurpose::Fireball;
    path = g.lineOfFire();
    bool first = !path.empty() && path.back() == at &&
                 stepsApart(player.pos, at) >= 3;
    if (!(onLine && use(ItemEffect::Lightning)) &&
        !(first && use(ItemEffect::Fireball)))
    {
        return false;
    }
    why_ = "reads an attack scroll at the " + name;
    g.lookCursor_ = at;
    g.fireAtCursor();
    return true;
}

In the preceding code, the bot looks before it reads. A scroll is gone once it's read, even if the aiming is then let go with Esc, so the bot checks the line of fire first, as the screen would show it while aiming. It puts the cursor, lookCursor_, on the monster, and sets the aiming's purpose, targetPurpose_, as a scroll of lightning would. Then lineOfFire gives the cells the lightning would pass through, as it does while the player aims.

Lightning goes through monsters, so onLine asks only whether the foe is on that line, with std::find, as in castByHeartAt. A fireball stops at the first monster its line meets, and bursts there. So with the purpose set as a fireball's, first asks whether the line ends on the foe, at three steps or more, since the burst reaches two steps from where it lands. Aimed past a nearer monster, a fireball would burst on that one, perhaps right beside the bot.

In the last if, each use is called only when the test before its && is true. So a scroll is read only when its line will hit, and lightning is tried first. Reading the scroll doesn't take the turn: it starts the aiming, as it would for a player, with the cursor on the nearest monster. So the bot puts the cursor back on its foe, and fires with fireAtCursor, which takes the turn. Add monstersInView below it:

// How many monsters are in view
int Bot::monstersInView() const
{
    int count = 0;
    for (const Monster& monster : game_.level_.monsters)
    {
        bool seen = game_.level_.map.isVisible(monster.pos.x, monster.pos.y);
        if (seen && monster.isAlive())
        {
            ++count;
        }
    }
    return count;
}

Checkpoint: Press Ctrl+F5, with --bot 5 in Command Arguments for a quick look first. The Rogue's run on seed 1 isn't stuck any more: it dies on floor 5, to a shadow hound. Then change them back to --bot 200 1000, press Ctrl+F5, and in ten minutes or so, the report:

The bot's 600 runs

Class     Runs   Won  Stuck  Floor   Deaths on floors 1 to 9
Fighter    200   16%      1    6.7     1   1   1  14  29  51  37  17  17
Rogue      200   12%      1    5.8    11  10   3  25  38  33  33  14   8
Mage       200    3%      2    3.9    33  29  30  34  25  21   8   9   3
All        600   10%      4    5.5    45  40  34  73  92 105  78  40  28

What killed it most:
   116  a cave orc
    81  a wolf
    49  a skeleton orc
    47  a forest troll
    45  a dusk-elf mage's lightning
    33  poison
    31  an ogre
    27  an archer's arrow

The bot wins 10% of its runs, up from 7%, and reaches floor 5.5 on average, up from 5.2. Four runs are stuck, and all four are lost: the Fighter's on seed 1002 and the Rogue's on seed 1017, as before, and two of the Mage's, on seeds 1023 and 1070. The Mage's two went differently now that the bot plays better, and each stepped onto a teleport trap, one on floor 3 and the other on floor 2, into a pocket behind a secret door. The Fighter's runs on seeds 1117 and 1143 went differently too, and on to their ends.

But the Mage still wins only 3% of its runs, against the Fighter's 16%. The last part finds out why.

Balancing by Experiment

With the bot playing well enough that its numbers can be trusted, they can say something about the game itself. This part adds a table that works out, from the data files, how many hits each class needs to kill each kind of monster. Then it uses the table, and the bot, to answer the question the last report raises: is the Mage a weak class?

Hits to Kill

The Mage wins 3% of its runs, against the Fighter's 16% and the Rogue's 12%. One way to see why is to work out what each class can do to each monster, without playing at all. For each attack a class starts with, how many hits does it take, on average, to kill each kind of monster? Everything needed is in the data files: the monsters' health and defense, the classes' kits and strength, and each weapon's damage. So the game can print the table itself, with AmuletsOfSDL --hits.

The table needs no run, so its function needs no Game. A static member function belongs to the class rather than to any one object. It's called as Game::printHitsToKill(data), with no Game made. And it can't use player_ or level_, since there's no Game for them to belong to.

It's a member so it can sit in Game.cpp, beside the rules it copies, and use their constants. Open Game.h, and add its declaration below usesTiles's, with a blank line in between:

// Prints how many blows, shots or spells each class needs, on average,
// to kill each kind of monster, worked out from the data files and the
// game's rules. It needs no run, so it's static
static void printHitsToKill(const GameData& data);

In Game.cpp, add the header for printf below #include <algorithm>:

#include <cstdio>     // std::printf, for the table of hits to kill

The table has a column for each attack: each class's first weapon, its bow if it starts with one, and its lightning by heart, if it has the gift. Add the start of printHitsToKill below blowDamage:

// The table of hits to kill. For each class, a column for its weapon (or
// its fists), its bow, if it starts with one, and its lightning by heart,
// if it has the gift; for each kind of monster, the hits each takes to
// kill it, on average, found by fighting it FIGHTS times, with SDL's own
// dice. A blow is a roll of the weapon's damage, plus strength, less the
// monster's defense, but at least 1; an arrow, the same with no strength,
// and every arrow counted as a hit; lightning, a roll of the scroll's
// damage, half again with scroll power, and no defense taken off. A
// monster that regenerates heals REGENERATION after each hit that leaves
// it alive, and "never" means the hits can't outpace it
void Game::printHitsToKill(const GameData& data)
{
    // An attack: the class, its name, its damage from least to most, the
    // strength added to it, and how it treats defense
    struct Column
    {
        std::string className;
        std::string attack;
        int least = 0;
        int most = 0;
        int bonus = 0;
        bool halfAgain = false;        // scroll power
        bool ignoresDefense = false;   // magic
    };
    std::vector<Column> columns;

In the preceding code, Column is a struct declared inside the function, since nothing else needs it. Add the columns, a class at a time, below it:

for (const ClassDef& def : data.classes())
{
    bool weapon = false;
    bool bow = false;
    for (const Item& item : def.kit)
    {
        const ItemDef& kind = *item.def;
        if (kind.itemClass == ItemClass::Weapon && !weapon)
        {
            columns.push_back({ def.name, kind.name, kind.minDamage,
                                kind.maxDamage, def.strength });
            weapon = true;
        }
        else if (kind.itemClass == ItemClass::Bow && !bow)
        {
            columns.push_back({ def.name, kind.name, kind.minDamage,
                                kind.maxDamage, 0 });
            bow = true;
        }
    }

In the preceding code, the braces in push_back make a Column from values in its members' order, and the members left out keep their defaults. Add the rest of the loop below it, for a class with no weapon, and for lightning:

    if (!weapon)
    {
        columns.push_back({ def.name, "fists", Player::FIST_MIN_DAMAGE,
                            Player::FIST_MAX_DAMAGE, def.strength });
    }
    if (def.has(Gift::LIGHTNING_BY_HEART))
    {
        columns.push_back({ def.name, "lightning", LIGHTNING_MIN,
                            LIGHTNING_MAX, 0,
                            def.has(Gift::SCROLL_POWER), true });
    }
}

The lightning takes the scroll's damage, LIGHTNING_MIN to LIGHTNING_MAX, half again for a class with scroll power. It ignores defense, as magic does.

How many hits does a monster take? Its health, divided by an average hit, is a quick answer, and a wrong one for a monster that dies in one hit or two. A giant white rat has 4 health, and the Fighter's least blow does 4, so one blow always kills it. But 4 divided by an average blow of 7 is 0.6.

So the table finds out the way the bot does, by trying: it fights each monster 10,000 times with each attack, with dice, and counts the hits. Add the fight below the loop, with a blank line in between:

// Each attack fights each kind of monster FIGHTS times, and a fight
// that has gone on for MOST_HITS hits is one it can't win
constexpr int FIGHTS = 10000;
constexpr int MOST_HITS = 1000;

// One fight: a hit at a time, each a roll of the attack's damage, each
// roll as likely as the others, until the monster is dead. Returns
// the hits it took
auto fight = [](const Column& column, const MonsterDef& monster,
                int heals)
{
    int hp = monster.hp;
    int hits = 0;
    while (hp > 0 && hits < MOST_HITS)
    {
        int roll = column.least +
                   SDL_rand(column.most - column.least + 1);
        int damage = column.halfAgain ? roll + roll / 2 : roll;
        if (!column.ignoresDefense)
        {
            damage = SDL_max(1, damage + column.bonus - monster.defense);
        }

In the preceding code, fight is a lambda that takes three things and returns the hits a fight took. It needs nothing from around it, so its brackets are empty: FIGHTS and MOST_HITS are constants, which a lambda may use without capturing them. Each hit is a roll of the attack's damage, with SDL_rand, and then the same sums the game does for a hit. Add the rest of the fight below it:

        hp -= damage;
        ++hits;
        if (hp > 0)
        {
            hp = SDL_min(monster.hp, hp + heals);
        }
    }
    return hits;
};

// SDL's dice start from the same place each time, and so the table
// comes out the same
SDL_srand(1);

Any monster that regenerates heals after each hit that leaves it alive, as it heals each turn in the game. That's a hit every turn, as a weapon or a bow can strike. Lightning by heart comes only every twelve turns, though, so against a monster that heals, its column is kinder than a real fight. A fight that's still going after 1,000 hits is one the attack can't win. And SDL_srand(1) starts SDL's dice in the same place each time, so the table comes out the same each time it's printed.

Now the table itself: a row for each kind of monster, its floors, its health and its defense, and then each attack's hits. Add the headings and the start of each row below it, with a blank line in between:

std::printf("Hits to kill, on average\n\n");
std::printf("%-18s %6s %4s %4s", "", "floors", "hp", "def");
for (const Column& column : columns)
{
    std::printf(" %12s", column.className.c_str());
}
std::printf("\n%-35s", "");
for (const Column& column : columns)
{
    std::printf(" %12s", column.attack.c_str());
}
std::printf("\n");
for (const MonsterDef& monster : data.monsters())
{
    std::string floors = std::to_string(monster.minFloor) + "-" +
                         std::to_string(monster.maxFloor);
    std::printf("%-18s %6s %4d %4d", monster.name.c_str(),
                floors.c_str(), monster.hp, monster.defense);
    int heals = monster.has(Trait::REGENERATES) ? REGENERATION : 0;

Then add each attack's fights, and the end of the function, below it:

        for (const Column& column : columns)
        {
            int hits = 0;
            bool never = false;
            for (int i = 0; i < FIGHTS && !never; ++i)
            {
                int these = fight(column, monster, heals);
                hits += these;
                never = these >= MOST_HITS;
            }
            if (never)
            {
                std::printf(" %12s", "never");
            }
            else
            {
                std::printf(" %12.1f", static_cast<double>(hits) / FIGHTS);
            }
        }
        std::printf("\n");
    }
}

In the preceding code, the hits of all the fights are added up, and divided by how many there were, as a double, for the average. A fight that hits MOST_HITS ends the column's fights at once, with "never".

The table is asked for on the command line, and needs no window. So main prints it and stops, before SDL starts. In main.cpp, add this below the if that shows the data's mistakes, with a blank line in between:

// "AmuletsOfSDL --hits" prints the table of hits to kill, worked out
// from the data files, and stops: it needs no window
if (argc > 1 && SDL_strcmp(argv[1], "--hits") == 0)
{
    Game::printHitsToKill(data);
    return 0;
}

The return 0 ends the program at once, before SDL starts, since the table needs nothing from it.

The Mage, Played Well

The next image shows the table.

Hits to kill, on average
Figure 27.7 — Hits to kill, on average

The table answers the question, and it isn't the Mage's fault. Its lightning kills almost anything in one cast or two, a cave orc in 1.4 and the dragon itself in 3.6, since magic ignores armor. The dagger is another matter: 16 blows for a cave orc, and 84 for a forest troll, which heals almost as fast as the dagger hurts it. So the Mage is strong, if it casts, and the bot's Mage doesn't. It attacks a monster beside it by stepping into it, with its dagger, though its lightning is ready.

The bot should cast first, near or far, whenever it can. In Bot.cpp, replace the comment above fight with this:

// The Mage casts lightning by heart at a monster, near or far, whenever
// it can: it hits far harder than the Mage's dagger. (The bot used to
// stab with the dagger at a monster beside it, and the Mage won far
// more often once it cast first.) A monster beside the bot is
// otherwise attacked; further off, a bow shoots it, and a scroll is read
// at it if the fight is going badly

Then, in fight, find this line:

if (stepsApart(game_.player_.pos, foe->pos) == 1)

Change it to these:

if (castByHeartAt(*foe))
{
    chaseSteps_ = 0;
    return true;
}
if (stepsApart(game_.player_.pos, foe->pos) == 1)

The lightning has had its chance by the time the bot shoots or reads, so it comes out of the last test. In fight, change castByHeartAt(*foe) || shootAt(*foe) || readAttackAt(*foe) to shootAt(*foe) || readAttackAt(*foe). That's the last change to fight, which, like chase, has grown a piece at a time. If yours misbehaves, compare it with the one in the finished Bot.cpp, in the book's Chapter 27 folder.

One Class at a Time

An experiment about one class needs only that class's runs, which take a third of the time. The command line AmuletsOfSDL --bot 200 1000 mage plays 200 runs as the Mage alone, from seed 1000. And mage, the class's id as classes.txt names it, chooses the class. In Bot.h, find the declaration of playBotRuns, with its comment:

// Has the bot play runs seeds, from firstSeed, as each class in turn. It
// prints a line for each run, and at the end, a report of them all
void playBotRuns(SDL_Renderer* renderer, GlyphCache& glyphs,
                 const GameData& data, SoundBank& sounds, int runs,
                 unsigned int firstSeed);

Change it to these:

// Has the bot play runs seeds, from firstSeed, as each class in turn, or
// as one class only, if its id is given. It prints a line for each run,
// and at the end, a report of them all
void playBotRuns(SDL_Renderer* renderer, GlyphCache& glyphs,
                 const GameData& data, SoundBank& sounds, int runs,
                 unsigned int firstSeed, const std::string& onlyClass);

In Bot.cpp, playBotRuns takes the class too: in playBotRuns, change unsigned int firstSeed) to unsigned int firstSeed, const std::string& onlyClass). Then add this at the top of the loop over the classes, in playBotRuns, above the comment about SDL's dice, leaving a blank line after it:

if (!onlyClass.empty() && def.id != onlyClass)
{
    continue;
}

The class comes on the command line after the seed, and main reads it there. In main.cpp, find these lines:

// A seed on the command line replays that dungeon, as in
// "AmuletsOfSDL 12345". Without one, the seed is chosen at random.
// "AmuletsOfSDL --bot 20" has the bot play 20 runs as each class, from
// seed 1, or from a seed given after the number, as "--bot 20 500"
unsigned int seed = 0;
int botRuns = 0;
if (argc > 2 && SDL_strcmp(argv[1], "--bot") == 0)
{
    botRuns = static_cast<int>(numberFrom(argv[2]));
    seed = argc > 3 ? numberFrom(argv[3]) : 1;

Change them to these:

// A seed on the command line replays that dungeon, as in
// "AmuletsOfSDL 12345". Without one, the seed is chosen at random.
// "AmuletsOfSDL --bot 20" has the bot play 20 runs as each class, from
// seed 1, or from a seed given after the number, as "--bot 20 500",
// and as one class only, if its id comes after that: "--bot 20 1 mage"
unsigned int seed = 0;
int botRuns = 0;
std::string botClass;
if (argc > 2 && SDL_strcmp(argv[1], "--bot") == 0)
{
    botRuns = static_cast<int>(numberFrom(argv[2]));
    seed = argc > 3 ? numberFrom(argv[3]) : 1;
    botClass = argc > 4 ? argv[4] : "";

And pass it on. Find these lines:

playBotRuns(renderer, glyphs, data, sounds, botRuns, seed);

Change them to these:

playBotRuns(renderer, glyphs, data, sounds, botRuns, seed,
            botClass);

Checkpoint: Change Command Arguments to --hits, and press Ctrl+F5. The table comes at once, with no runs played, and its numbers are Figure 27.7's. Then change them to --bot 200 1000 mage, and press Ctrl+F5 again.

The Mage's runs alone take about three minutes. Now the Mage wins 11% of them, more than three times as many as before, and reaches floor 5.2 on average, rather than 3.9. They're the same runs the Mage plays among the others, since a run depends only on its seed and its class.

Last, --bot 200 1000, for all three classes, and the chapter's final report, the one in Figure 27.1:

The bot's 600 runs

Class     Runs   Won  Stuck  Floor   Deaths on floors 1 to 9
Fighter    200   16%      1    6.7     1   1   1  14  29  51  37  17  17
Rogue      200   12%      1    5.8    11  10   3  25  38  33  33  14   8
Mage       200   11%      1    5.2    15  16  19  36  28  23  16  17   7
All        600   13%      3    5.9    27  27  23  75  95 107  86  48  32

What killed it most:
    99  a cave orc
    74  a wolf
    47  a dusk-elf mage's lightning
    45  a forest troll
    42  a skeleton orc
    38  a shadow hound
    33  poison
    33  an archer's arrow

The bot wins 13% of its runs, and the Mage 11%, close behind the Rogue's 12%. The Mage was never a weak class: the bot was playing it badly, and the fix was in the bot. That's worth remembering whenever a bot's numbers seem to say that a game needs changing.

The Book's Own Tuning

The game you've built was balanced this way, by a bigger bot: a test tool that plays much as yours does, with a few more tricks, such as saving its attack scrolls for a fight worth one. It played the finished game, as it stood before any tuning. Its reports, and the changes they led to, are the book's worked example of what a bot is for.

Design note: Angband's Borg. Angband has a bot of its own, the Borg, an automatic Angband player. The Borg was first written for about version 2.8.0 as a program apart from the game. It was taken into the game around version 3.3, taken out again in 4.0, when the game changed too much around it, and brought back around 4.2.3. Its documentation says it's mostly for fun, since it's entertaining to watch, and that it has been used to test the game and find bugs.

The test tool did the same work for this game, in a single night of runs.

Its first runs found more wrong with the bot than with the game. It forgot a monster the moment the monster went out of sight, turned back and forth between two items, and stood lost in corners. Your bot has the fixes: lastSeen_, the walking distance in goToItem, and the search in wander.

Once it played sensibly, its first 600 runs of the finished game won nothing at all, and reached floor 4.6 on average. Its report listed every Amulet left behind, and why: 148 of them, in 144 runs. A trapdoor had dropped the player past 68, a secret door hid 43, and 37 were on floors read with a scroll of magic mapping. The mapping made explore call a floor done before its Amulet was ever seen.

Since a run that leaves an Amulet behind can't be won, the game changed, not the bot: no trapdoor on an Amulet's floor, no secret door that cuts an Amulet off, and a scroll of magic mapping that shows the floor's items. Going down without the floor's Amulet now asks first, too, which is the question answer says yes to. The next 600 runs won 1.2%, every one of them the Fighter's.

The causes of death came next. A forest troll healed 2 a turn, and only the Fighter could hurt it faster than that. So trolls now heal 1, REGENERATION, and the runs won 1.9%.

The deep band, the monsters of floors 7 to 9, had too much health and did too much damage. Once it had less of both, the runs won 14.8%. But the Fighter won 32.8% of its runs, the Rogue 11.0%, and the Mage 0.7%.

The classes' numbers and kits, in Chapter 22, were changed until no class won more than twice as often as another. The Mage was the hard one. Its magic was its scrolls, and once they were read, it was a frail fighter with a dagger. No change to its health or its kit lifted it above 1.5% of its runs won. What did was magic that comes back, lightning by heart, ready again every 12 turns.

The last cliff was floor 7. The deep band arrives there in a cave, where every monster can see the player from afar. More than half of the Fighters who reached floor 7 died on it. A cave now holds three quarters of the monsters a floor of rooms would, CAVE_MONSTERS_PERCENT, and about a third did.

Nearly fifty tries went into the tuning, each of 600 to 1,800 runs. Its last 1,756 runs won 14.6% of the time: the Fighter 19.1%, the Rogue 11.1% and the Mage 13.7%, on floor 6.0 on average. The bigger bot wins more often than yours, since it plays better. That's the point of the whole chapter in one more number: a bot's report measures the bot and the game together, so the bot has to be right before the game can be judged by it.

Playing the Game

To play the game yourself again, empty Command Arguments. Release plays exactly as Debug does, only faster, so either will do. The game is finished, and yours to play. You can do much better than the bot, which never runs from a fight it can't win, and treats a dragon much as it treats a rat. As the book tuned it, the aim was a game that a careful player wins about one run in three, once they know it.

The bot is a tool for any change you make, too. Play the same 600 runs before and after a change, and compare the reports. A change that seems small in one run can turn out large over hundreds, and one that seems large can turn out to change nothing at all.

Understanding the Code

The command line is read in main, before SDL starts, with numberFrom for its numbers. With --hits, the game prints the table of hits to kill, and stops. And with --bot, it plays the bot's runs in place of the game: how many, then the first seed, and then a class's id, the last two optional.

For the bot, the window is hidden, since a Game still needs a renderer. And the audio subsystem isn't started, so the sound bank stays silent. Then useUserFolder moves everything the game writes into AmuletsOfSDL-bot, so the bot's runs never touch the player's save, morgue files or high scores.

The runs are played by playBotRuns, each seed as each class, or as the one class asked for. Each run is in a Game of its own, with SDL's dice started from the seed by SDL_srand, so a run replays exactly.

The Bot is the Game's friend, and plays through the functions the keys call. Its play decides until the run is won or lost, or until MAX_TURNS, when it's stuck. After each decision, it lets the turn's effects go with finish, and forgets the last floor with arrive. And after MOST_IDLE decisions in a row that take no turn, it gives up on what's underfoot, and waits.

A decision is act: the first of answer, drinkIfHurt, goForLastAmulet, fight, chase, pickUpHere, whileSafe, goToItem, explore and goDown that does something takes the turn, and with none of them, wander. The bot reads only what a player could see: the monsters in view, the items seen and the floor as explored. It reads what it needs of a monster or an item before it steps, since the step can take it away. Each part says what it did in why_, and remember keeps the last LAST_DECISIONS in a std::deque. A stuck run's RunResult carries them, and describe prints them.

The report, BotReport, adds up each RunResult, in a Tally for each class and one for them all. Its print shows the runs, the share won, the stuck runs, the average floor and the deaths on each floor, and then the commonest causes of death, sorted with reverse iterators. The traces led to two fixes in chase: a monster that isn't worthIt doesn't make the bot forget the one it's after, and countChaseStep gives up on a chase after CHASE_LIMIT steps with no blow, for CHASE_REST turns. An attack scroll is read by readAttackAt when a fight goes badly, and only once the line of fire shows that it will hit. And fight casts the Mage's lightning by heart first, near or far.

The table comes from Game::printHitsToKill, a static member function, so it needs no Game. For each class's attacks, it fights each kind of monster FIGHTS times, by the game's own rules, with SDL's dice started from 1. Then it prints the average hits, or "never" for a fight that MOST_HITS couldn't win.

Experimenting

Try each of these, and put the first two back afterward, since they change the game. In Release, the table comes at once, and the runs take about three minutes for one class, or ten for all three:

  • The Mage without its gift. In classes.txt, take lightning_by_heart off the end of the Mage's gifts. Run with --hits, and the Mage's lightning column is gone. A few of the other numbers move by a tenth, since SDL's dice fall differently without it. Then run with --bot 200 1000 mage: the Mage wins none of its 200 runs, rather than 11%, and reaches floor 2.7 on average.
  • Trolls that heal faster. In Game.cpp, set REGENERATION to 2, as the forest troll's healing first was. Run with --hits, and the troll's row changes: the Mage's dagger never kills it, and the Rogue's bow needs 29.6 arrows, rather than 13.2. Only the lightning's number stays the same, 2.0, since the table's bolts come a turn apart, and two that close kill a troll either way. Cast by heart, they're twelve turns apart, and a troll that heals 2 a turn is whole again by the second, so the spell alone can't kill one.
  • A bot that waits to drink. In Bot.cpp, set DRINK_BELOW to 20, so the bot drinks only below a fifth of its health. Run with --bot 200 1000, and the bot wins 16% of its runs, rather than 13%, and every class gets further on average.

The first shows how much of the Mage is its one gift, and the second why the trolls were tamed. The third is a surprise: the bot plays better by drinking later. At 20%, it drinks about one potion fewer a run, and has more of them left for the fights that need them.

So 20 is the better setting, and you're welcome to keep it. The bot's report then measures a better bot, and the same game. That's the chapter's lesson once more: a bot's numbers measure the bot as well as the game.

Common Errors and Fixes

If the game didn't build, or doesn't behave as described, the problem is probably one of these.

C2039: 'deque': is not a member of 'std', in Bot.h, followed on the same line by C7568, C2062 and C2238, and then C2065: 'decisions_': undeclared identifier, in Bot.cpp, several times. The header for std::deque is missing, so the member can't be declared, and every use of it is an error too. Add #include <deque> below #include <SDL3/SDL.h>, in Bot.h.

C2248: 'Game::startAs': cannot access private member declared in class 'Game', in Bot.cpp, and dozens more like it, for state_, turn_, level_ and the rest. The Game doesn't name the Bot as its friend, so its private members are closed to it. Add friend class Bot; at the top of the class, as in The Game's Friend.

C2352: 'Game::printHitsToKill': a call of a non-static member function requires an object, in main.cpp. The declaration in Game.h is missing its static, so the function needs a Game to be called on, and main has none. Add static at the start of the declaration.

C2660: 'playBotRuns': function does not take 7 arguments, in main.cpp. The declaration in Bot.h still has six parameters, without onlyClass. So main's call, with the class, matches nothing it knows of. Change the declaration as in One Class at a Time.

C2724: 'Game::printHitsToKill': 'static' should not be used on member functions defined at file scope, in Game.cpp. A member function is static in its declaration, in the class, and only there: the definition mustn't say so again. Take static off the start of the definition.

C3861: 'playBotRuns': identifier not found, in main.cpp. The file doesn't include Bot.h, so it has never heard of the function. Add #include "Bot.h" below #include <vector>.

The game opens its window, at the title screen, and the bot never plays. The Command Arguments were typed for one configuration, and you're running the other, which has its own. Set Configuration to All Configurations in the project's properties, and type them again.

Each run takes a quarter of a minute, and 600 of them would take hours. The game is built in Debug, which leaves the code as written, so the debugger can follow it. Debug also checks a great deal as it runs. Change Debug to Release in the toolbar, and a run takes about a second.

A run you had saved is gone, and the high scores are full of runs you never played. The bot played in your own folder, and as each of its runs ended, it removed the save, wrote a morgue file and entered its score. The line useUserFolder("AmuletsOfSDL-bot"); is missing, above the call to playBotRuns, in main.cpp. Add it back. The saved run is gone for good, though, and the bot's scores stay in the table until better runs push them out.

FAQ

Q) Why is the bot the game's friend, rather than a program that presses keys and reads the screen?

A) Reading the screen means turning pixels, or a terminal's characters, back into monsters and items. That's a big program of its own, and a slow one. A friend reads the same things straight from the Game, and calls the functions the keys call. So the bot plays the real game, by its real rules, at full speed. The price is trust: a friend can change anything, so the bot must only ever do what a player could do, and know what a player could know.

Q) Why does each run start SDL's dice from the seed?

A) So that a run can be played again. The game's own dice start from the seed, and the bot's choices, where to wander and when to search, come from SDL's. With both started from the seed, a seed and a class replay a run exactly. So a stuck run can be played again, as many times as it takes to understand it.

Q) Can I run several copies at once, to get more runs sooner?

A) Yes, each with seeds of its own. Build in Release, open the project's folder in File Explorer, type cmd into its address bar, and press Enter. In the window that opens, type AmuletsOfSDL --bot 100 1000, and in a second one, AmuletsOfSDL --bot 100 1100. Each plays its own runs and prints its own report, and adding the two up is up to you. The reports are safe, but the bot's folder is shared, and its high scores aren't.

Each run reads the table of high scores to add its own, and writes it back through the same temporary file, scores.txt.tmp. So two runs that end at once can lose a score, or spoil each other's writing. The reports don't depend on the folder, but trust its table only after one copy at a time.

Even one bot's runs can end in the same second, and a morgue file is named to the second. So a later run's morgue file can replace an earlier one's, and the folder ends up with fewer of them than there were runs.

Q) Why not have the bot learn to play, rather than write its rules by hand?

A) A bot can learn, by playing many thousands of runs and keeping what works, and some have learned to play roguelikes well. A test bot has a different job, though. It must be the same player every time, so that a change in its numbers comes from the game. And its mistakes must be easy to read, so they can be fixed, as this chapter's traces were. A few dozen lines of rules do both.

AI Exercise (Optional)

About one run in 200 still ends stuck. Two of the three in the last report are lost in a pocket behind a secret door. If you'd like the bot to find its way out, with an AI's help, open your AI chatbot of choice, and try a prompt like this:

"I'm writing a roguelike in C++ with SDL 3, and a bot that plays it, a class Bot that's a friend of the class Game. When the bot is lost, with no stairs it knows of and nothing left to explore that it can reach, its wander() function searches or takes a random step, at random. A bot teleported into a pocket of rooms whose only way out is a secret door rarely finds it, since a random walk seldom reaches the right wall. The Game has knownMap(), which returns the floor as the player knows it, with unexplored cells, secret doors and known traps all as walls; search(), which searches the cells around the player, and takes a turn; and playerStep(Point step). The Map has at(x, y), which returns a Terrain, and isExplored(x, y). I'd like the lost bot to walk along the walls of the area it can reach, searching every few steps, until a way on opens, and then explore again. It must use only what a player could know, so it mustn't look at where the secret doors really are. Show me every change, with each curly brace on its own line, and explain it."

Notice what the preceding prompt does. It rules out the easy answer, which would find the secret door on the real map and walk to it, since a bot that knows what no player could would make its own numbers lie.

When the answer comes back, check that it never reads a secret door's place from the real map. And check that it keeps track of the walls it has searched, or it may search the same corner forever. Then play the 600 runs again, from seed 1000, and see how many stay stuck.

Summary

The game has a player of its own now: a bot, the Game's friend, which plays through the functions the keys call and sees only what a player could. It works down a list of things to try, says why it did each one, and adds its runs up in a report. Its traces found its own bugs, and its report and the table of hits to kill showed that the Mage was never a weak class, only one the bot played badly. The book's own tuning went the same way, run by run, from a game no run could win to the one you've built: nine floors, three Amulets and three classes, in a dungeon that's new every time. What you build next is up to you, and whatever it is, a bot can play it.