Python Asteroids on GitHub

The small saucer should always target the ship, while the large one targets randomly. Let’s see how we might manage this. The Gunner does know the saucer …

Let’s take a look at Gunner, to see how the decisions are made at present:

class Gunner:
    def fire(self, delta_time, saucer, ship_or_none: Ship | None, fleets):
        if ship_or_none:
            ship_position = ship_or_none.position
        else:
            ship_position = self.random_position()
        self._timer.tick(delta_time, self.fire_missile, saucer, ship_position, fleets)

    def fire_missile(self, saucer, ship_position, fleets):
        if saucer.missile_tally < u.SAUCER_MISSILE_LIMIT:
            self.select_missile(fleets, saucer, ship_position)

    def select_missile(self, fleets, saucer, ship_position):
        if random.random() <= u.SAUCER_TARGETING_FRACTION:
            velocity_adjustment = Vector2(0, 0)
        else:
            velocity_adjustment = saucer.velocity
        self.create_targeted_missile(saucer.position, ship_position, velocity_adjustment, fleets)

    def create_targeted_missile(self, from_position, to_position, velocity_adjustment, fleets):
        best_aiming_point = self.best_aiming_point(from_position, to_position, u.SCREEN_SIZE)
        angle = self.angle_to_hit(best_aiming_point, from_position)
        missile = self.missile_at_angle(from_position, angle, velocity_adjustment)
        fleets.append(missile)

If there is a ship we have its position. If not, we have a random position. If random.random() is less than or equal to the targeting fraction, we set the velocity adjustment to zero, which makes the missile go where it is aimed, i.e. not applying the saucer’s velocity to it, which would make it track differently.

Therefore, all we need to do is change this one if:

    def select_missile(self, fleets, saucer, ship_position):
        if saucer._size == 1 or random.random() <= u.SAUCER_TARGETING_FRACTION:
            velocity_adjustment = Vector2(0, 0)
        else:
            velocity_adjustment = saucer.velocity
        self.create_targeted_missile(saucer.position, ship_position, velocity_adjustment, fleets)

I thought that might be more difficult, but I think that’s it. I’ll test it by setting saucer size to 1 always. Testing in game, the so and so aims at me every time. Got me in the back with a wrap-around shot.

I honestly don’t see a decent test for this. Python doesn’t like me accessing the private member _size. Let’s put a meaningful property into Saucer.

class Saucer(Flyer):
    @property
    def always_target(self):
        return self._size == 1

class Gunner:
    def select_missile(self, fleets, saucer, ship_position):
        if saucer.always_target or random.random() <= u.SAUCER_TARGETING_FRACTION:
            velocity_adjustment = Vector2(0, 0)
        else:
            velocity_adjustment = saucer.velocity
        self.create_targeted_missile(saucer.position, ship_position, velocity_adjustment, fleets)

One more in-game test to be sure that takes. Yes. Commit: Small saucer always targets. Can’t think of decent test.

Now to actually test that, we’d want to be able to control the random number, and set up a large saucer and small one and see what they did to the velocity adjustment. Shall we do that?

    def fire_missile(self, saucer, ship_position, fleets):
        if saucer.missile_tally < u.SAUCER_MISSILE_LIMIT:
            self.select_missile(random.random(), fleets, saucer, ship_position)

    def select_missile(self, chance_of_targeting, fleets, saucer, ship_position):
        if saucer.always_target or chance_of_targeting <= u.SAUCER_TARGETING_FRACTION:
            velocity_adjustment = Vector2(0, 0)
        else:
            velocity_adjustment = saucer.velocity
        self.create_targeted_missile(saucer.position, ship_position, velocity_adjustment, fleets)

Changing the signature, I push the random.random() up into fire_missile. Now we can write a test.

    def test_large_saucer_does_not_target(self):
        pos = Vector2(100, 100)
        fleets = Fleets()
        fi = FI(fleets)
        large_saucer = Saucer(2)
        large_saucer._location.position = Vector2(100, 50)
        large_gunner = large_saucer._gunner
        large_gunner.select_missile(1, fleets, large_saucer, pos)
        missiles = fi.missiles
        assert missiles
        missile = missiles[0]
        velocity = missile.velocity_testing_only
        assert velocity.x != 0 or velocity.y != pytest.approx(166.666, .001)  
        # not straight up

    def test_small_saucer_does_target(self):
        pos = Vector2(100, 100)
        fleets = Fleets()
        fi = FI(fleets)
        small_saucer = Saucer(1)
        small_saucer._location.position = Vector2(100, 50)
        small_gunner = small_saucer._gunner
        small_gunner.select_missile(1, fleets, small_saucer, pos)
        missiles = fi.missiles
        assert missiles
        missile = missiles[0]
        velocity = missile.velocity_testing_only
        assert velocity.x == 0
        assert velocity.y == pytest.approx(166.666, 0.001)  
        # straight up

Setting the random to 1 in the calls to select_missile ensures that the random won’t trigger a targeting. So the large saucer should never fire straight up (which would be targeted) and the small saucer should. I suppose the first test could in principle fail, but I don’t see a better way to test it unless I inject yet another random. Maybe I’ll get a better idea but this at least expresses what should happen.

Commit: added tests for random and targeted firing.

Summary

Sometimes this happens. A few characters to make it work, and a couple of lines to make it right, and 30 lines to test it. The use of random numbers often makes testing difficult. Perhaps if we arrange the code so that we compute both randoms separately would make it easier to set up these tests, but they’d still have a lot of setup and not much testing.

I don’t even see a good application for a test double / mock object here. Possibly a different factoring of the logic would make for easier testing, but I am quite confident that the feature works.

Some days, the bear nibbles on you. But it didn’t get a solid bite.

See you next time!