11 - Punching in all directions

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

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

The next part of the combat story has some twists and turns, so let me just briefly touch on the main beats:

Three questions remained last time:

Hit and miss, revisited

Let’s handle the first two cases in one go. Three more tests appear:

#[test]
fn collision_with_non_enemy_does_nothing() {
    let combat_engine = ControlledCombatEngine::new();
    let mut game = TestableGame::from_strings(vec![". @ # ."]);
    game.game.override_combat_engine(combat_engine);

    game.input.simulate_move_right();
    game.tick();
    game.render();
    game.renderer.assert_combat_log(vec![]);
}

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

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

    game.input.simulate_move_right();
    game.tick();
    game.render();
    game.renderer.assert_combat_log(vec![
        "Player hits Enemy for 5 damage!",
        "Enemy misses Player!"
    ])
}

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

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

    game.input.simulate_move_right();
    game.tick();
    game.render();
    game.renderer.assert_combat_log(vec![
        "Player misses Enemy!",
        "Enemy hits Player for 5 damage!",
    ])
}

And all this only for moving to the right! Clearly, this will not scale. We will deal with that later. For now, we can make it pass with this Game::tick:

 1pub fn tick<T: Input>(&mut self, input: &T) {
 2    // ...
 3    if input.move_right() {
 4        if let Some((coords, Enemy)) = self.dungeon.move_player_right() {
 5            let player_pos = self.dungeon.get_player_position();
 6            if self.combat_engine.is_hit(player_pos, coords) {
 7                let player_damage = self.combat_engine.roll_damage(player_pos);
 8                self.combat_events.push(CombatEvent::hit(Player, Enemy, player_damage));
 9            } else {
10                self.combat_events.push(CombatEvent::miss(Player, Enemy));
11            }
12
13            if self.combat_engine.is_hit(coords, player_pos) {
14                let enemy_damage = self.combat_engine.roll_damage(coords);
15                self.combat_events.push(CombatEvent::hit(Enemy, Player, enemy_damage));
16            } else {
17                self.combat_events.push(CombatEvent::miss(Enemy, Player));
18            }
19        }
20    }
21    // ...
22}

This will pass the three tests. We only initiate combat when colliding with an enemy, and the combat engine is consulted for the player and for the enemy, recording combat events accordingly.

A small explosion of test cases

But what if the enemy is in a different direction? This explodes the test cases a bit: Now everything is multiplied by four.
Copy and paste is not really an option. Instead, the input system gets a little overhaul. The Input trait changes to this:

pub enum MoveDirection {
    Left,
    Right,
    Up,
    Down
}

pub trait Input {
    fn quit_game(&self) -> bool;
    fn wants_to_move(&self, direction: MoveDirection) -> bool;
}

Gone are all the functions for specific directions - too clunky. Now scaling up to all directions should be easier, both in tests and production.
All downstream API’s are adapted as well, resulting in a succinct Game::tick:

pub fn tick<T: Input>(&mut self, input: &T) {
    for direction in MoveDirection::iter() {
        let direction = *direction;
        if input.wants_to_move(direction) {
            if let Some((coords, Enemy)) = self.dungeon.move_player(direction) {
                self.record_combat_with(coords)
            }
        }
    }
}

… with record_combat_with just being an extraction of the previously inlined lump of code.

Testing all directions

The tests should reflect all possibilities as well. On the top level, they now look like this:

const PLAYER: DungeonCoords = (1, 1);

const ENEMY_RIGHT: DungeonCoords = (2, 1);
const ENEMY_LEFT: DungeonCoords = (0, 1);
const ENEMY_ABOVE: DungeonCoords = (1, 0);
const ENEMY_BELOW: DungeonCoords = (1, 2);

const DUNGEON: [&str; 3] = [
    ". E .",
    "E @ E",
    ". E ."
];

#[test]
fn combat_with_enemy_to_the_right() {
    verify_combat_scenarios_when_moving(Right, ENEMY_RIGHT);
}

#[test]
fn combat_with_enemy_to_the_left() {
    verify_combat_scenarios_when_moving(Left, ENEMY_LEFT);
}

#[test]
fn combat_with_enemy_above() {
    verify_combat_scenarios_when_moving(Up, ENEMY_ABOVE);
}

#[test]
fn combat_with_enemy_below() {
    verify_combat_scenarios_when_moving(Down, ENEMY_BELOW);
}

#[test]
fn collision_with_non_enemy_does_nothing() {
    let mut game = TestableGame::from_strings(vec![
        ". # .",
        "# @ #",
        ". # ."
    ]);

    for direction in MoveDirection::iter() {
        game.input.simulate_move(*direction);
        game.tick();
        game.render();
        game.renderer.assert_combat_log(vec![]);
    }
}

// ...

This reads nicely, I think. The helper function verify_combat_scenarios_when_moving sets up the four possible scenarios (hit/hit, miss/miss, miss/hit, hit/miss) and then for each asserts that the combat log reflects the expected outcome.
The whole thing is a bit of an eyeful:

The tests kind of look like they have been written after writing the code. Which is true, in a way. They did grow out of tests that initially drove the code, however. Test-driven tests, then? My head hurts all of a sudden.

Anwyay, next time, finally, all this will be integrated into production, and I am almost certain there will be another gif. Stay tuned!