Python 146 - Targeting
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!