Python 121 - Hyperspace Completion
I did a bit last night. Let’s see if we can finish the HG this morning. I bet we can, but then, I read ahead in this article.
It is now 0700 Wednesday morning, and I’ll report on the changes I made last night during FGNOaZE1, and proceed from there.
I renamed and extended the existing test:
def test_next_entry_requires_recharge_and_lift_button(self):
impossible = Vector2(-5, -9)
ship = Ship(impossible)
hg = HyperspaceGenerator(ship)
hg.recharge()
hg.press_button()
assert self.did_enter_hyperspace(impossible, ship)
ship.move_to(impossible)
hg.press_button()
assert self.did_not_enter_hyperspace(impossible, ship)
hg.recharge()
hg.press_button()
assert self.did_not_enter_hyperspace(impossible, ship)
hg.lift_button()
hg.press_button()
assert self.did_enter_hyperspace(impossible, ship)
@staticmethod
def did_enter_hyperspace(impossible, ship):
return ship.position != impossible
@staticmethod
def did_not_enter_hyperspace(impossible, ship):
return ship.position == impossible
The test shows that both recharge and lift are needed before the ship can enter hyperspace. And I wrote little methods to cover the actions, which seems better than allowing either the tests or the Ship to tweak the object’s innards directly. I wrote little helper methods so that the test itself is a bit more clear about what happens.
Here’s the current class, passing all tests.
class HyperspaceGenerator:
def __init__(self, ship):
self._charged = False
self._button_down = False
self._ship = ship
def press_button(self):
if self._charged and not self._button_down:
self.hyperspace_jump()
self._button_down = True
def hyperspace_jump(self):
self._charged = False
x = random.randrange(u.SCREEN_SIZE)
y = random.randrange(u.SCREEN_SIZE)
self._ship.move_to(Vector2(x, y))
self._ship._angle = random.randrange(360)
dx = random.randrange(u.SHIP_HYPERSPACE_MAX_VELOCITY)
dy = random.randrange(u.SHIP_HYPERSPACE_MAX_VELOCITY)
self._ship.accelerate_to(Vector2(dx, dy))
def recharge(self):
self._charged = True
def lift_button(self):
self._button_down = False
We are green. Commit: HG now checks both recharge and button lift before attempting hyperspace.
Cheating Death
I think that all that remains inside HG is the check for a hyperspace failure, which is a dice roll against a value including the number of asteroids present. In Ship, it looks like this:
def enter_hyperspace_if_possible(self, fleets):
if self._hyperspace_key_ready and self._hyperspace_recharged:
self._hyperspace_key_ready = False
roll = random.randrange(0, 63)
if self.hyperspace_failure(roll):
self.explode(fleets)
else:
self.hyperspace_transfer()
def hyperspace_failure(self, roll):
return roll > 44 + self._asteroid_tally
The fewer asteroids there are, the worse your chances. If you go to hyperspace with just one asteroid present, you have to roll less than 44 out of 63 (hmm, that’s a 62 range, we’ll fix that) to survive, so your chances are good. But if there were 19 asteroids on the screen, you’d have to beat 63, so you are guaranteed no failure. Those figures were scraped from the original Asteroids assembly code. Spared no expense.
We need the asteroid tally, and the Ship has that. What it has, actually, is the tally from the last cycle, so it is in principle off by one or two at most, but only by the number of asteroids created or destroyed in that exact cycle, which is almost always none. Close enough for our universe.
Therefore … in our next test we’ll be testing the hyperspace failure feature. What would be nice would be able to pass in the number of asteroids, and the dice roll, right into press_button
. And why not? We need both, and we can default the roll such that the generator rolls the dice if we don’t provide one.
Let’s make that the new rule, and change the existing tests to pass in safe values.
I just pass 99 as the tally in all the tests, for example:
hg.lift_button()
hg.press_button(99)
assert self.did_enter_hyperspace(impossible, ship)
Nothing works, until I do this:
def press_button(self, asteroid_tally):
self._asteroid_tally = asteroid_tally
if self._charged and not self._button_down:
self.hyperspace_jump()
self._button_down = True
And we are green. Commit: HG accepts asteroid tally as parameter to press_button.
Now to test the failure. I think I’ll test drive a new hypespace_failure
function, just like the one in Ship, rather than try to do something arcane to detect from the outside that it happened. We may wind up with a gap in testing, but we’ll try not to.
No … let’s see if we can come up with a decent round trip test first. We just have to detect an Explosion, how hard could it be?
Thinking about how it’ll work, recall that the HG knows its ship. It actually controls the ship during the hyperspace jump:
def hyperspace_jump(self):
self._charged = False
x = random.randrange(u.SCREEN_SIZE)
y = random.randrange(u.SCREEN_SIZE)
self._ship.move_to(Vector2(x, y))
self._ship._angle = random.randrange(360)
dx = random.randrange(u.SHIP_HYPERSPACE_MAX_VELOCITY)
dy = random.randrange(u.SHIP_HYPERSPACE_MAX_VELOCITY)
self._ship.accelerate_to(Vector2(dx, dy))
We freely grant that HG’s notion of control is very random. Frankly, you were lucky to survive at all. This thing is for emergencies, a last-ditch attempt to save your ship.
So when the HG fails, it will send the ship the explode message that it already has:
def explode(self, fleets):
player.play("bang_large", self._location)
fleets.remove_flyer(self)
fleets.add_flyer(Explosion.from_ship(self.position))
Ah. We see that the HG will need fleets
. No problem, we’ll surely be on some ship operation that has it. But it’s another member we’ll need to pass in.
We can’t pass it on creation: we don’t have it. It’ll have to be yet another parameter to press_button. That means we’ll pass fleets, tally, and possibly roll, which we’ll really only use in the tests. I think our usual order is fleets last. Here, we’ll put it second and see how we feel about it.
Guessing at the future, we may default that argument as well, only using that feature in testing. We’ll see. Now a test.
def test_failure(self):
fleets = Fleets()
impossible = Vector2(-5, -9)
ship = Ship(impossible)
fi = FI(fleets)
fleets.add_flyer(ship)
hg = HyperspaceGenerator(ship)
hg.recharge()
hg.press_button(0, 43) # roll < 44 + tally
assert fi.explosions
This won’t compile, because press doesn’t expect the parameter, and fi doesn’t know explosions
yet. I’ll fix that first.
class FleetInspector:
@property
def explosions(self):
return self.select(lambda a: isinstance(a, Explosion))
And now the parameter:
def press_button(self, asteroid_tally, fleets=None):
fleets = fleets if fleets else Fleets()
self._asteroid_tally = asteroid_tally
if self._charged and not self._button_down:
self.hyperspace_jump()
self._button_down = True
Now we just have our one test failing, not finding an explosion.
No, that isn’t right. I’m not passing the fleets yet, what I’ve passed so far is the tally and dice roll. Change the signature of press_button
to be more nearly correct.
def press_button(self, asteroid_tally, dice_roll=0, fleets=None):
fleets = fleets if fleets else Fleets()
self._asteroid_tally = asteroid_tally
if self._charged and not self._button_down:
self.hyperspace_jump()
self._button_down = True
Pass fleets in the test:
def test_failure(self):
fleets = Fleets()
impossible = Vector2(-5, -9)
ship = Ship(impossible)
fi = FI(fleets)
fleets.add_flyer(ship)
hg = HyperspaceGenerator(ship)
hg.recharge()
hg.press_button(0, 43, fleets) # roll < 44 + tally
assert fi.explosions
Time to make it work. We’re starting with this, just like above:
def press_button(self, asteroid_tally, dice_roll=0, fleets=None):
fleets = fleets if fleets else Fleets()
self._asteroid_tally = asteroid_tally
if self._charged and not self._button_down:
self.hyperspace_jump()
self._button_down = True
Let’s do this by intention. Here’s what I coded, and I thought it was pretty good:
def press_button(self, asteroid_tally, dice_roll=0, fleets=None):
fleets = fleets if fleets else Fleets()
self._asteroid_tally = asteroid_tally
if self._charged and not self._button_down:
self.jump_or_explode(asteroid_tally, dice_roll, fleets)
self._button_down = True
def jump_or_explode(self, asteroid_tally, dice_roll, fleets):
if self.hyperspace_failed(asteroid_tally, dice_roll):
self._ship.explode(fleets)
else:
self.hyperspace_jump()
@staticmethod
def hyperspace_failed(asteroid_tally, dice_roll):
return dice_roll > 44 + asteroid_tally
The test does not agree.
def test_failure(self):
fleets = Fleets()
impossible = Vector2(-5, -9)
ship = Ship(impossible)
fi = FI(fleets)
fleets.add_flyer(ship)
hg = HyperspaceGenerator(ship)
hg.recharge()
hg.press_button(0, 43, fleets) # roll < 44 + tally
assert fi.explosions
It jumped when (I think) it shouldn’t have.
I’ll add a check for the No, wait I have the condition wrong.
@staticmethod
def hyperspace_failed(asteroid_tally, dice_roll):
return dice_roll < 44 + asteroid_tally
Right, except now TWO tests fail. I think the default argument should be 99 not zero.
Changing that, my new tests passes. There are still two failing. Why???
First of all > is correct in the failure code. Put that back. That gets me to just one failure:
def test_failure(self):
fleets = Fleets()
impossible = Vector2(-5, -9)
ship = Ship(impossible)
fi = FI(fleets)
fleets.add_flyer(ship)
hg = HyperspaceGenerator(ship)
hg.recharge()
hg.press_button(0, 43, fleets) # roll < 44 + tally
assert fi.explosions
The test is wrong. High rolls are bad. And the comment is wrong. Fix the test.
def test_failure(self):
fleets = Fleets()
impossible = Vector2(-5, -9)
ship = Ship(impossible)
fi = FI(fleets)
fleets.add_flyer(ship)
hg = HyperspaceGenerator(ship)
hg.recharge()
hg.press_button(0, 45, fleets) # fail = roll > 44 + tally
assert fi.explosions
Those conditionals, man, they’re hard. Test is green. I think we’re solid but we could use more tests. At least one more:
def test_success(self):
fleets = Fleets()
impossible = Vector2(-5, -9)
ship = Ship(impossible)
fi = FI(fleets)
fleets.add_flyer(ship)
hg = HyperspaceGenerator(ship)
hg.recharge()
hg.press_button(0, 44, fleets) # fail = roll > 44 + tally
assert not fi.explosions
I think this is pretty solid. It’s not installed in Game, so we can still commit with relative impunity: HG includes failure, passes all tests. Ready to test in game.
Now let’s put it into ship. Should be straightforward.
No. I got ahead of myself. Quite a bit, actually. We need to provide for recharging, which isn’t implemented at all.
We intend that to happen in tick
. We’ll use a timer. We need a test.
def test_recharge_timer(self):
fleets = Fleets()
impossible = Vector2(-5, -9)
ship = Ship(impossible)
fi = FI(fleets)
fleets.add_flyer(ship)
hg = HyperspaceGenerator(ship)
hg.press_button(0, 44, fleets) # fail = roll > 44 + tally
assert self.did_not_enter_hyperspace(impossible, ship)
hg.tick(u.SHIP_HYPERSPACE_RECHARGE_TIME)
hg.lift_button()
hg.press_button(0, 44, fleets) # fail = roll > 44 + tally
assert self.did_enter_hyperspace(impossible, ship)
We need tick
and a timer.
def __init__(self, ship):
self._asteroid_tally = 0
self._button_down = False
self._charged = False
self._ship = ship
self._timer = Timer(u.SHIP_HYPERSPACE_RECHARGE_TIME)
def tick(self, delta_time):
if not self._charged:
self._timer.tick(delta_time, self.recharge)
The test runs. Commit: HG now uses tick to recharge. Belay previous premature optimism.
Now we’re pretty close. Before I realized I needed the tick
, I was looking at how to plug this thing into ship, and it’s either going to be easy or horrid. Let’s just try it and see if we can find the easy way. Surely we can create it without trouble.
Ha. As soon as I do this:
class Ship(Flyer):
def __init__(self, position):
self.radius = 25
self._location = MovableLocation(position, Vector2(0, 0))
self._hyperspace_generator = HyperspaceGenerator(self)
All the tests start to fail. The reason is a recursive import, which Python cannot deal with. I need to change HG not to use Fleets. OK, hammer it.
class HyperspaceGenerator:
def press_button(self, asteroid_tally, dice_roll=0, fleets=None):
# fleets = fleets if fleets else Fleets() REMOVED
self._asteroid_tally = asteroid_tally
if self._charged and not self._button_down:
self.jump_or_explode(asteroid_tally, dice_roll, fleets)
self._button_down = True
We are green. Commit: Ship creates its HuyperspaceGenerator.
There’s a lot in Ship. I’ll be glad when this works.
Now to call it, we change this:
...
if keys[pygame.K_SPACE]:
self.enter_hyperspace_if_possible(fleets)
else:
self._hyperspace_key_ready = True
To this:
if keys[pygame.K_SPACE]:
roll = random.randrange(0, 63)
self._hyperspace_generator.press_button(self._asteroid_tally, roll, fleets)
else:
self._hyperspace_generator.lift_button()
And we’d better change the tick
from this:
def tick(self, delta_time, fleets):
if not self._hyperspace_recharged:
self._hyperspace_timer.tick(delta_time, self.recharge)
To this:
def tick(self, delta_time, fleets):
self._hyperspace_generator.tick(delta_time)
We should probably be passing fleets here, for consistency, but right now HG will be happy with this. I have a test failing, not one of today’s, but an old one. It’s obsolete, checking ship flags. I think the recharging is covered. I want to test in the game to see what happens.
It seems to be working perfectly. I’d love to have a wah-wah sound for when it fails. Maybe later. I remove that redundant test, and I expect there will be a couple more.
I remove another one and this one is left:
def test_hyperspace_failure(self):
"""hyperspace fails when random(0 through 62) > asteroid count plus 44"""
ship = Ship(u.CENTER)
self.check_no_fail(ship, 0, 0)
self.check_fail(ship, 45)
self.check_fail(ship, 62)
self.check_no_fail(ship, 62, 18)
@staticmethod
def check_no_fail(ship, roll, asteroids):
ship._asteroid_tally = asteroids
assert not ship.hyperspace_failure(roll)
@staticmethod
def check_fail(ship, roll):
assert ship.hyperspace_failure(roll)
This should be able to be repurposed for testing the HG, and it’s worth doing. Also note that comment: apparently 0-62 is correct, not what I thought above.
I’ll pull that test over to the HG tests and rewire it.
def test_hyperspace_failure(self):
"""hyperspace fails when random(0 through 62) > asteroid count plus 44"""
hg = HyperspaceGenerator(None)
self.check_no_fail(hg, 0, 0)
self.check_fail(hg, 45, 0) # 45 > 44 you lose
self.check_fail(hg, 62, 17) # 62 > 44+ 17 you lose
self.check_no_fail(hg, 62, 18) # 62 !> 44+18 = 62
@staticmethod
def check_no_fail(hg, roll, asteroids):
assert not hg.hyperspace_failed(asteroids, roll)
@staticmethod
def check_fail(hg, roll, asteroids):
assert hg.hyperspace_failed(asteroids, roll)
That’s passing. Green. Commit: Ship converted to use HyperspaceGenerator object for hyperspace transfers.
Summary
This got a bit long. I’ve split the article in two, and it’s still long. Sorry. You probably skipped through anyway. And the most interesting thing in this whole article is that almost nothing interesting happened.
We ticked through a series of increasingly difficult tests, making the HG deal with new situations. A few times we went back and changed old calls. That “could” have been avoided with enough up-front guessing, or as some folx like to call it “design”, but I am comfortable “discovering” things that I darn well actually already know.
I think that I benefit from pushing down on my speculative design, that is, design that is not yet called for by a failing test, or a visible need in the production code, or (it could happen) a discovered defect. Like some folx, I tend to speculate too much, do more design and sometimes more coding than is appropriate to the situation. The idea is to do just enough. If I have to fail on “just enough”, I prefer to fail with “just barely too little”, because I get simpler code on that side of the line.
Your experience may be different, but if I gave advice2, I’d advise you to experiment on both sides of the “just enough” line, to see what happens.
I think that HG is more understandable on its own, and Ship is certainly a bit easier to grasp without the flags and methods it used to have. I’ll spare you yet another listing of the Generator, you can check it out on GitHub if you’re really interested. I think it is just about right, though I do have one more thing in mind. But that’s for another day.
This design change increases overall cohesion, by removing a responsibility from Ship, making it a bit simpler, and isolating the responsibility in a single object, which only deals with hyperspace. Rather satisfactory in my opinion.
A good afternoon, evening, and morning’s work.
See you next time!