9 - Preparing for combat

Jul 4, 2021 tdd rust srl | Prev | Next | Code

This post is part of the series Making a Roguelike - A test-driven approach.

The simplest thing that could possibly work

The simplest form of combat in a typical dungeon crawler is melee combat. So naturally we go for that first. Let’s assume the following scenario:

. . E . .
. E @ E .
. . E . .

Surrounded by enemies, when the player tries to go in any direction, this is what we want to happen:

How are we going to write a feature test for that? Clearly, there is some randomness involved. An initial combat system could go like this:

For the dice rolls, we define a fixed set of outcomes in the test, and make assertions based on those rolls.
A plan is forming. For now, let’s have only one enemy, which is to the right of the player:

#[test]
fn combat_on_collision() {
    let dice_roller = ControlledDiceRoller::new();
    dice_roller.next_roll_is(17, 20);
    dice_roller.next_roll_is(3, 8);
    dice_roller.next_roll_is(16, 20);
    dice_roller.next_roll_is(6, 8);

    let mut game = TestableGame::from_strings(vec![". @ E ."], dice_roller);

    game.input.simulate_move_right();
    game.tick();

    game.renderer.assert_combat_log(vec![
        "Player hits Enemy for 6 damage!",
        "Enemy hits Player for 2 damage!"
    ])
}

The ControlledDiceRoller is used to control the outcome of the coming rolls: A twenty-sided die (a D20) produces a 17, then a D8 produces a 3, and so on. The game gets an instance of the dice roller in the form of a trait object, and it uses the trait’s API to produce dice rolls.
And then let’s say we have something like a combat log that the game prints out, and we use that to get an idea of what happened: Player hits for 6 damage, enemy hits for 2 damage, in this particular case.

But how are the dice rolls connected to the combat outcome? Information about attack and defense bonuses is missing. We would have to add that somehow. And then the reader of the test would have to know exactly how the combat system works, in order to make sense of the assertion. This also requires that the combat system is already in place.
That seems like a lot of work. And who knows if the combat system is any good. Surely there will be critical hits and damage modifiers in the future? This test should be protected from these changes. It only wants to assert that “if the player bumps into an enemy, there is going to be a fight”. How the outcome of this fight is determined is another matter, and it should not be the subject of this test.

(Another option is the property testing route, asserting that a certain percentage of rolls are hits, and damage is in some kind of range, and so on. I mean, I like it, but that sounds like a test that belongs in continuous integration, written well after we know how the combat system is supposed to work.)

Let’s call it CombatEngine

What we really want is a test that drives the design that we care about at this stage, and no more. The details of the combat system can wait. Let’s redline that (game and code) design deferral dial!

#[test]
fn combat_on_collision() {
    let combat_engine = ControlledCombatEngine::new();
    combat_engine.say_is_hit((1, 0), (2, 0));
    combat_engine.say_is_hit((2, 0), (1, 0));
    combat_engine.say_damage((1, 0), 6);
    combat_engine.say_damage((2, 0), 2);

    let mut game = TestableGame::from_strings(vec![". @ E ."]);
    game.game.override_combat_engine(combat_engine);

    game.input.simulate_move_right();
    game.tick();
    game.renderer.assert_combat_log(vec![
        "Player hits Enemy for 6 damage!",
        "Enemy hits Player for 2 damage!"
    ])
}
We are going to pretend that a struct called ControlledCombatEngine exists. It enables us to control the combat outcome, with the player at (1, 0) and the sole enemy on (2, 0):

In other words, we are only depending on the “API” of the combat system, and not on the implementation. We don’t even care if there are any dice rolls involved! The hits, misses and damages are controlled directly, without assuming any kind of underlying system.

For all this to work, there is going to be a trait CombatEngine, which will have different implementations for test and production.

So let’s stub out all the required dependencies:

A new trait CombatEngine:

pub trait CombatEngine {}

pub struct NullCombatEngine {}

impl CombatEngine for NullCombatEngine {}

Game::override_combat_engine:

 1pub struct Game {
 2    dungeon: Dungeon,
 3    combat_engine: Box<dyn CombatEngine>,
 4}
 5
 6impl Game {
 7    pub fn generate_with<T: DungeonGenerator>(generator: &T) -> Self {
 8        Self {
 9            combat_engine: Box::from(NullCombatEngine {}),
10            dungeon: generator.generate(),
11        }
12    }
13
14    pub fn override_combat_engine<T: 'static + CombatEngine>(&mut self, engine: T) {
15        self.combat_engine = Box::from(engine);
16    }
17    // ...
18}

An empty ControlledCombatEngine:

pub struct ControlledCombatEngine {}

impl ControlledCombatEngine {
    pub fn new() -> Self { Self {} }

    pub fn say_is_hit(&mut self, _attacker: DungeonCoords, _attackee: DungeonCoords) {}

    pub fn say_damage(&mut self, _victim: DungeonCoords, _amount: u32) {}
}

impl CombatEngine for ControlledCombatEngine {}

RenderingSpy::assert_combat_log:

impl RenderingSpy {
    // ...
    pub fn assert_combat_log(&self, _expected: Vec<&str>) { 
        todo!() 
    }
}

Now the test compiles, but of course the assertion is not yet implemented:

not yet implemented
thread '_tests::feature::melee_combat_test::combat_on_collision' panicked at 'not yet implemented', src/_tests/helpers/rendering_spy.rs:41:9
stack backtrace:

We can’t even assert yet! We better make that work.

Logging combat

The combat log gives us a peek into what is happening when a fight occurs. Clearly, the game has to have a way to add something to it. So let’s add a function to the Renderer trait:

pub trait Renderer {
    // ...
    fn append_combat_log(&mut self, text: &str);
}

The implementation in RenderingSpy is not interesting, but the tests might be:

#[test]
fn assert_combat_log_succeeds_if_combat_log_matches() {
    let mut renderer = RenderingSpy::new(1, 1);
    renderer.append_combat_log("combat log line 1");
    renderer.append_combat_log("combat log line 2");

    renderer.assert_combat_log(vec![
        "combat log line 1",
        "combat log line 2",
    ]);
}

#[test]
#[should_panic(expected="assertion failed")]
fn assert_combat_log_fails_if_combat_log_does_not_match() {
    let mut renderer = RenderingSpy::new(1, 1);
    renderer.append_combat_log("combat log line 1");
    renderer.append_combat_log("combat log line 2");

    renderer.assert_combat_log(vec![
        "combat log line 2",
        "combat log line 1",
    ]);
}

That #[should_panic(...)] bit will make the second test fail if the test does not panic, i.e. if assert_combat_log did not fail. It should fail though, because it expects the wrong combat log.

I should mention here that between writing the feature test and adding the combat log API, there was a bit of refactoring, mainly extracting common code from TerminalRenderer and RenderingSpy. The whole thing is only mildly interesting, so I will skip over it. Maybe it will be a bonus feature on the DVD.
For reference, here are the relevant revisions regarding that refactoring and the combat log addition in RenderingSpy:

And after all that is done, given the trivial implementation of RenderingSpy::append_combat_log, the test fails for the right reason:

thread '_tests::feature::melee_combat_test::combat_on_collision' panicked at 'assertion failed: `(left == right)`
  left: `""`,
 right: `"Player hits Enemy for 6 damage!\nEnemy hits Player for 2 damage!"`', src/_tests/helpers/rendering_spy.rs:29:9

If a fight had taken place, at least the combat log would have been written, right?

Unfortunately, we are running out of space. Apologies for running a bit long today. The story of making that test pass will have to wait until next time!