The tutorial · Step 9

Bots, replays and a frame

Play this step Its whole project

The game plays and looks just as it did in the last step. So why is this a step of its own? Because of how you'll keep it working. As a game grows, a change here breaks something over there, and no one can replay every level by hand after every change. So you teach the game to test itself.

Everything in this step is possible because of step 3. The rules are deterministic, so a game can be tested by playing it, and the same play always gives the same result.

FileWhat changes
crates/game/src/tools.rsNew: the game's own tools
crates/game/src/stages.rstools() returns them
crates/game/src/lib.rspub mod tools;, only for the tools build
crates/game/src/rules.rsCounts its turns, for bots
crates/game/files/lines/1.ron to 5.ronNew: a winning line for each level
crates/game/tests/tools.rsNew: tests through the tools
crates/game/tests/frames/level1.txtNew: a frame kept as a file

The game's own tools

cargo shuyi has run your game since step 1, using BasicTools, the engine's ready-made tools. Those can't know about your rules: they have no idea what a move is. So the game now writes its own, Tools, which adds what's special to this game, and calls BasicTools for the rest.

Shuyi: GameTools is the trait for that. It's the bridge between your game and the engine's tools, whether a person runs them from a terminal or a coding assistant calls them. Your Tools is in this step's tools.rs. Most of its functions just pass the call on to self.basic(). This page shows the parts that aren't that.

In lib.rs, the module is only part of a build with the tools in it, so it never ends up in the game you ship:

#[cfg(feature = "tools")]
pub mod tools;

Rust: #[cfg(feature = "tools")] means "only include this if the tools feature is on". A feature is an optional part of a crate, listed in its Cargo.toml. That's what --features tools was doing in the shuyi alias, in your .cargo/config.toml, all along.

And stages::tools returns it:

/// The game's tools: the engine's, and its scene, bot and replay on top.
#[cfg(feature = "tools")]
pub fn tools() -> crate::tools::Tools {
    crate::tools::Tools::new(crate::game())
}

The scene: what a program can read

A person looks at the screen. A program needs the same facts as data:

/// The board's scene in `h`: its map, and what a program asks of it.
pub fn scene(h: &Harness<Data>) -> Result<Scene, String> {
    let board = h
        .screen::<Board>()
        .ok_or_else(|| format!("no level is open (the top screen is `{}`)", h.top_screen()))?;
    let map = board.scene(Board::view_tiles());
    let mut scene = Scene::new("board", "grid map", map.to_text(&Terrain));
    scene.data = json!({
        "level": board.level,
        "moves": board.history.len(),
        "won": board.puzzle.outcome().is_some(),
    });
    Ok(scene)
}

Try asking:

cargo shuyi scene --json --script "Enter Enter Right Right"

That plays those keys with no window, and answers with the map as text, plus your moves and won. json! writes JSON, the most common format for programs to exchange data. Here is how a test uses it:

#[test]
fn the_scene_says_the_moves_and_whether_it_is_won() {
    let tools = game::stages::tools();
    let mut input = RunInput::default();
    input.script = keys("Enter Enter Right");
    let answer = scene(&tools, &input);
    let data = &answer.result.as_ref().unwrap().scene.as_ref().unwrap().data;
    assert_eq!(data["moves"], 1, "{data}");
    assert_eq!(data["won"], false, "{data}");
    input.script = keys("Enter Enter Right Right");
    let answer = scene(&tools, &input);
    let data = &answer.result.as_ref().unwrap().scene.as_ref().unwrap().data;
    assert_eq!(data["moves"], 2, "{data}");
    assert_eq!(data["won"], true, "{data}");
}

A bot

Game idea: a bot is a player made of code. It looks at the state, and picks the next command. Bots test a game from outside it, playing whole games in the blink of an eye. In a game with opponents, they can also be the opponents.

A bot needs to know who moves, for games with several players. The puzzle has one, so its side is (), a value with nothing in it. It also needs to know the turn, so the puzzle now counts them. In rules.rs, add turn: u32 to the state, start it at 1, and add 1 in apply. (u32 is a whole number that can't go below zero.) Then:

/// Who moves, for the bots: the puzzle has one player, so its side is `()`.
impl Sides for Puzzle {
    type Side = ();

    fn side_to_move(&self) {}

    fn turn(&self) -> u32 {
        self.turn
    }
}

This bot isn't clever. It plays a line kept in a file: one in files/lines/ for each level, such as files/lines/2.ron:

// A line that wins level 2, one step a move.
[Right, Right, Down, Left, Left, Down, Right, Right]

Take the five from this step's copy. Those are the winning moves for each level, written by hand. Here's the bot that plays one:

/// A bot that plays a line kept in the game's files: each level's is in
/// `files/lines/`.
#[derive(Debug, Clone)]
pub struct LineBot {
    /// The steps left, last first.
    steps: Vec<Dir>,
}

impl LineBot {
    /// The bot that plays level `level`'s line, counted from 0.
    pub fn new(bundle: &Bundle, level: usize) -> Result<Self, String> {
        // check-text: not player text, a file name
        let name = format!("lines/{}.ron", level + 1);
        let text = bundle.file(&name).ok_or(format!("no {name}"))?;
        let mut steps: Vec<Dir> = ron::from_str(text).map_err(|e| format!("{name}: {e}"))?;
        steps.reverse();
        Ok(Self { steps })
    }
}

impl Bot<Puzzle> for LineBot {
    fn choose(&mut self, puzzle: &Puzzle) -> Command {
        // A line played out with no win steps wherever it can: the play's
        // turn limit ends it.
        let legal = || puzzle.legal_commands().first().copied();
        self.steps
            .pop()
            .map(Command::Step)
            .or_else(legal)
            .unwrap_or(Command::Step(Dir::Up))
    }
}

Rust: Vec<Dir> read straight from the file by ron::from_str, thanks to serde. steps.reverse() puts the last step first, because .pop() takes from the end of a list, and that's quick. .or_else(legal) says "or, if that gave None, try this". So when the line is used up, the bot makes any legal step, and the turn limit will end the play.

Why does this matter? Here's the test:

#[test]
fn every_levels_line_wins_it() {
    let bundle = game::game().bundle();
    for (n, file) in level::read_all(&bundle).unwrap().iter().enumerate() {
        let mut puzzle = Puzzle::new(file).unwrap();
        let mut bot = LineBot::new(&bundle, n).unwrap();
        let report = play(&mut puzzle, &mut [((), &mut bot)], 500).unwrap();
        assert!(report.outcome.is_some(), "level {}", n + 1);
    }
}

#[test]
fn the_playtest_tool_wins_level_one() {
    let mut input = PlaytestInput::default();
    input.tries = Some(3);
    let answer = playtest(&game::stages::tools(), &input);
    assert!(answer.ok, "{answer:?}");
    let line = &answer.result.as_ref().unwrap()[0].entrants[0];
    assert_eq!(line.outcomes.get("Won"), Some(&3), "{line:?}");
}

It plays each level's line, and checks it wins. So every level is proven winnable, every time you run your tests. Edit a level so it can't be won, and this test fails, and says which one. For a puzzle game, that's the most valuable test there is.

To run the bot by hand:

cargo shuyi playtest --tries 20

The tools know how to do that because of playtest_plan, in tools.rs: one bot, line, with no checks to judge it, each try played through the engine's play and kept as a record you can replay. Look it up in this step's copy.

A replay

A record is a level and its commands, and nothing more. Since the rules are deterministic, that's the whole play:

/// A record of a level: which one and the commands sent to its rules, and
/// what made it. The game's record format for `replay`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LevelRecord {
    /// The level, counted from 0.
    pub level: usize,
    /// The commands, in order.
    pub commands: Vec<Command>,
    /// What made it ([`crate::made`]); a record without one still reads.
    #[serde(default)]
    pub made: Option<Made>,
}

Shuyi: the replay tool plays a record twice and compares a hash of the state at every step. A hash is a short number worked out from all of a value, so if any of it changes, the number will too. So anything that isn't deterministic shows up at once, at the very step where it went wrong. If someone reports a bug, a record of what they did will reproduce it exactly.

#[test]
fn a_record_replays_with_a_hash_at_every_step() {
    let dir = std::env::temp_dir().join(format!("box-pusher-{}", std::process::id()));
    std::fs::create_dir_all(&dir).unwrap();
    let record = LevelRecord {
        level: 0,
        commands: vec![Command::Step(Dir::Right), Command::Step(Dir::Right)],
        made: Some(game::made()),
    };
    std::fs::write(dir.join("one.ron"), ron::to_string(&record).unwrap()).unwrap();
    let answer = replay(&game::stages::tools(), &dir, &ReplayInput::new("one.ron"));
    std::fs::remove_dir_all(&dir).unwrap();
    assert!(answer.ok, "{answer:?}");
}

made says which build made a record, since a record from another build may not play alike, and the tool refuses to compare those.

A frame

Most tests read the state. So how do you test a look? Here's the first level:

The first level with no moves made, the frame the test keeps as text.

And here's the same frame as text. h.snapshot() gives you this, its characters first, and then a map of each cell's colors:

 First push                            Moves: 0 
                                                
                                                
                                                
                                                
                                                
                                                
                                                
                                                
                ▓▓▓▓▓▓▓▓▓▓▓▓▓▓                  
                ▓▓          ▓▓                  
                ▓▓  @ []  <>▓▓                  
                ▓▓          ▓▓                  
                ▓▓▓▓▓▓▓▓▓▓▓▓▓▓                  
                                                
                                                
                                                
                                                
                                                
                                                
                                                
                                                
                                                
 arrows walk · u undo · Backspace levels        
--- colours ---
abbbbbbbbbbaaaaaaaaaaaaaaaaaaaaaaaaaaaacccccccca
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaddddddddddddddaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaddeeeeeeeeeeddaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaddeefeggeebbddaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaddeeeeeeeeeeddaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaddddddddddddddaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
ahhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhaaaaaaaa
--- legend ---
a = fg:panel bg:panel
b = fg:highlight bg:panel
c = fg:text bg:panel
d = fg:border bg:wall_bg
e = fg:floor_fg bg:panel
f = fg:bright bg:panel
g = fg:box bg:panel
h = fg:text_dim bg:panel

Notice that the colors are named by their role (fg:box), not by a number. So this test doesn't fail if you make the box blue, as that's a change of look on purpose, but it does if the box stops being drawn with its box color. The test compares this with the file you keep:

/// Compares the first level's frame with the file kept beside this test;
/// `UPDATE_FRAMES=1` writes it, to read before keeping it.
#[test]
fn the_first_level_looks_as_it_did() {
    let mut h = game::game().harness();
    h.keys("Enter Enter");
    let frame = h.snapshot();
    let file = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/frames/level1.txt");
    if std::env::var_os("UPDATE_FRAMES").is_some() {
        std::fs::write(&file, &frame).unwrap();
    }
    let kept = std::fs::read_to_string(&file).unwrap().replace('\r', "");
    assert_eq!(frame, kept);
}

To make the file the first time, or after you've changed a look on purpose, set UPDATE_FRAMES, which is what the test checks for:

UPDATE_FRAMES=1 cargo test --workspace the_first_level_looks_as_it_did

On Windows, in PowerShell, the same thing takes two lines:

$env:UPDATE_FRAMES = "1"
cargo test --workspace the_first_level_looks_as_it_did

(In PowerShell, close that terminal afterwards, or that variable stays set.) Read the new file before you keep it. It'll be what the test holds the game to from now on.

Keep these tests few. A frame test fails on every change of look, while a test of the state only fails if something's really wrong. So test what happened through the state, as you've been doing, and keep a frame or two for the screens you care about most.

See them catch something

Change '@' to 'P' in Pieces, as you tried back in step 2, and run cargo test --workspace. the_first_level_looks_as_it_did fails, and shows you the difference. Put it back. Now change one line of files/lines/1.ron, from [Right, Right] to [Right], and run the tests: every_levels_line_wins_it fails, naming level 1. Put that back too.

That is the feeling you want. You can change anything, because if it breaks, you'll know within a few seconds.

What you learned

Chapter 8 has more on testing, and chapter 10 on the tools. Next: hand it out.