14 - Game over

Aug 1, 2021 tdd rust srl | Prev | Next | Code

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

So far this game is not very balanced, isn’t it? The enemies are getting slapped around until they die. Let’s see how we can make them hit back.

A bit of housekeeping

Before tackling player damage, some housekeeping happened:

Lastly, a few words on why I am OK with testing the enemy damage code for one direction only.
Short answer, I am confident that it works for all directions because the combat log test verifies all directions, and the damage code lives in the same loop:

impl Game {
    // ...
    pub fn tick<T: Input>(&mut self, input: &T) {
        // For all directions, if colliding with coords:
        self.combat_resolver.handle_combat_with(coords, *direction);
    }
So I know that everything combat related happens in CombatResolver::handle_combat_with, for all directions.
I mean, in theory, I could punk myself and run the damage code for only one direction, and the combat log code for all directions, thus making the feature tests pass and the game not work as expected.
But why would I do that? As the sole programmer I know the code well enough to not expect such a thing.
To summarize: Have enough confidence, no further tests needed.

Damaging the player

How do we test player damage in a feature test? Well, let’s say the game just terminates when the player dies. That would be a very user-visible feature.
To facilitate this, the game needs to tell the world about the player having died, with the world being the (still untested) game loop. So, let’s have a function Game::player_died that returns true if the player, well, has died:

#[test]
fn game_over_after_player_death() {
    let mut game = TestableGame::from_strings(vec![". @ E ."]);
    enemy_hits_for(60, &mut game);
    game.input.simulate_move(Right);
    game.tick();
    assert!(!game.game.player_died());
    game.tick();
    assert!(game.game.player_died());
}

fn enemy_hits_for(damage: u32, game: &mut TestableGame) {
    game.configure_combat(|combat_engine| {
        combat_engine.say_is_hit(ENEMY, PLAYER);
        combat_engine.say_damage(ENEMY, damage);
    });
}

This assumes that the player has 100 hitpoints. The enemy always hits for 60 damage, which means the player should be dead after the second tick, and Game::player_died should return true at this point. (Look, we don’t even have to render the game here. Funny, isn’t it?)

After stubbing out Game::player_died, this fails as expected.

To pass the test, we would like to do something like this:

impl Game {
    // ...
    pub fn tick<T: Input>(&mut self, input: &T) {
        for direction in MoveDirection::iter() {
            if input.wants_to_move(*direction) {
                let result = self.dungeon_ref.borrow_mut().move_player(*direction);
                if let Some((coords, Enemy)) = result {
                    self.player_died = self.combat_resolver.handle_combat_with(
                                                        coords, *direction);
                }
            }
        }
    }

    pub fn player_died(&self) -> bool {
        self.player_died
    }
}

This requires a bit of plumbing inside the Dungeon struct, and CombatResolver::handle_combat_with gets a return value and a new deeply nested return statement:

impl CombatResolver {    
    pub fn handle_combat_with(&mut self, coords: DungeonCoords, 
                              direction: MoveDirection) -> bool {
        // ...
        if self.combat_engine.is_hit(coords, player_pos) {
            let damage = self.combat_engine.roll_damage(coords);
            self.add_combat_event(CombatEventHit::new(Enemy, Player, damage));
            let remaining_hp = self.dungeon_ref.borrow_mut().damage_player(damage);
            if remaining_hp <= 0 {
                self.add_combat_event(CombatEventDeath::new(Player));
                return true;
            }
        // ...
    }
    // ...
}

And finally, in a rare trip to main.rs, we can use the new API like this:

    while !input.quit_game() && !game.player_died() {
        game.render(&mut renderer);
        renderer.flush(&mut term);
        input.on_key(term.read_key()?);
        game.tick(&input);
        term.clear_last_lines(GAME_HEIGHT - 1)?;
    }
    if game.player_died() {
        game.render(&mut renderer);
        renderer.flush(&mut term);
        println!("\nYou died. Thanks for playing!");
    }

All very raw and subject to future cleanup, but here it is.

But, how can we win the game? Easy. Just kill all the enemies. Which we will tackle after a short intermission featuring the “user interface”.