Python Asteroids on GitHub

I am oddly without a plan this morning, even my tentative sort of plan. Be prepared to read my sort of thinking. I hit a few bumps but not major ones.

Yesterday, we changed the Fleets and Interactor so that every object in the mix now interacts with every other. That is arguably inefficient but not really that bad. How bad is it? How many objects might there be …

I suppose in principle you could have 44 asteroids, six missiles, one ship, one saucer, and seven fragments on the screen at once, plus a few others, maybe 65 or 70 at most. So we would do 70*69/2 interactions, 2415 interactions. That’s a lot, but most of them come down to one or two message sends. It might be fun to measure to see how long it would take. Maybe we’ll do that someday. A more reasonable number is likely to around 20 objects, for a much more reasonable couple of hundred interactions.

But I digress. We’re going to do it this way until we can’t, because someone here likes this design idea.

What about scoring? We’re only part way there on that. The flyers create Score objects and give them to Fleets, but Fleets doesn’t store them, it just accumulates the score and the game later reads it out.

Let’s see if we can test-drive the full Score object and its partner ScoreKeeper.

Score[Keeper]

I create this test:


class TestScore:

    def test_keeper(self):
        keeper = ScoreKeeper()

PyCharm offers to create a class for me, in this module:

class ScoreKeeper:
    pass

Maybe I’ll develop it right here for convenience. I think PyCharm might even move it for me later. We’ll find out. Worst case, cut and paste.

    def test_keeper(self):
        keeper = ScoreKeeper()
        assert keeper.score == 0

Curiously, ScoreKeeper does not know this. We’ll teach it:

class ScoreKeeper:
    def __init__(self):
        self.score = 0

This is going great so far.

Let’s press on, creating a Score and interacting.

    def test_keeper(self):
        keeper = ScoreKeeper()
        assert keeper.score == 0
        score = Score(20)
        keeper.interact_with_score(score)
        assert keeper.score == 20

Moving right along here, aren’t we?

class ScoreKeeper:
    def __init__(self):
        self.score = 0

    def interact_with_score(self, score):
        self.score += score.score

I think that method is supposed to receive a fleets parameter tho.

    def interact_with_score(self, score, fleets):
        self.score += score.score

Now the test needs to provide a Fleets. Sure, why not?

    def test_keeper_accumulates_score(self):
        fleets = Fleets()
        keeper = ScoreKeeper()
        assert keeper.score == 0
        score = Score(20)
        keeper.interact_with_score(score, fleets)
        assert keeper.score == 20
        score = Score(50)
        keeper.interact_with_score(score, fleets)
        assert keeper.score == 70

So far so good. I think we can commit this. Commit: initial ScoreKeeper accumulates Score.

Now what we really need is for the Score to be handed to the Fleets, to be tucked away and then to be removed when the interactions take place. So we really should exercise the whole group, Fleets, Interactor, Score, and ScoreKeeper. I’d like to be further along before I do that.

Let’s test-drive Fleets a bit:

    def test_score_saved_in_fleets(self):
        fleets = Fleets()
        score = Score(20)
        fleets.add_score(score)
        assert score in fleets.all_objects

This fails. No surprise. Now in Fleets, I think we need a new collection, others.

class Fleets:
    def __init__(self, asteroids=None, missiles=None, saucers=None, saucer_missiles=None, ships=None):
        asteroids = asteroids if asteroids is not None else []
        missiles = missiles if missiles is not None else []
        saucers = saucers if saucers is not None else []
        saucer_missiles = saucer_missiles if saucer_missiles is not None else []
        ships = ships if ships is not None else []
        self.fleets = (
            AsteroidFleet(asteroids),
            MissileFleet(missiles, u.MISSILE_LIMIT),
            SaucerFleet(saucers),
            MissileFleet(saucer_missiles, u.SAUCER_MISSILE_LIMIT),
            ShipFleet(ships),
            ExplosionFleet(),
            Fleet([]))
        self.thumper = Thumper(self.beat1, self.beat2)
        self.score = 0

    @property
    def others(self):
        return self.fleets[6]

    def add_score(self, score):
        self.others.append(score)
        self.score += score.score

OK, now is this test still failing? Yes, because Score does not implement all the required messages for interaction. We need to add it to that other test, and to add two new methods that everyone needs.

Slightly irritating but I think it’s best to just do it.

    def test_double_dispatch_readiness(self):
        classes = [Asteroid, Missile, Saucer, Ship, Fragment, Score, ScoreKeeper]
        errors = []
        for klass in classes:
            attrs = dir(klass)
            methods = ["interact_with",
                       "interact_with_asteroid",
                       "interact_with_missile",
                       "interact_with_saucer",
                       "interact_with_ship",
                       "interact_with_score",
                       "interact_with_scorekeeper",
                       "are_we_colliding"]
            for method in methods:
                if method not in attrs:
                    errors.append((klass.__name__, method))
        assert not errors

Grr but won’t take long, from 0856 … 0904. Eight minutes that I’ll never get back. Tests are green, let’s get back to work:

    def test_score_removed_on_interaction(self):
        fleets = Fleets()
        keeper = ScoreKeeper()
        fleets.add_scorekeeper(keeper)
        score = Score(20)
        fleets.add_score(score)
        interactor = Interactor(fleets)
        interactor.perform_interactions()
        assert keeper.score == 20
        assert score not in fleets.all_objects

We don’t have add_scorekeeper yet:

class Fleets:
    def add_scorekeeper(self, scorekeeper):
        self.others.append(scorekeeper)

What does the test say? It says the score is 20 but the Score is still in the mix. No surprise because this:

class Score:
    def interact_with_scorekeeper(self, scorekeeper, fleets):
        pass

Should be this:

    def interact_with_scorekeeper(self, scorekeeper, fleets):
        fleets.remove_score(self)

And Fleets should do this:

class Fleets:
    def remove_score(self, score):
        self.others.remove(score)

And ScoreKeeper should do this:

class ScoreKeeper:
    def interact_with(self, other, fleets):
        other.interact_with_scorekeeper(self, fleets)

And the test is green. I think we can commit: Score and ScoreKeeper interact properly if added to mix. Scoring still done in Fleets for now.

But the build is broken!!

Score has no method draw, nor has ScoreKeeper. Ow! I wish I had tested the game.

I add draw to the double dispatch test and it highlights Score and ScoreKeepeer, no surprise there. Game tells me they also need tick.

I add tick to the test. Fix the classes. Test again. Green.

Commit: add draw and tick to Score and ScoreKeeper, and to protocol test.

Reflection

OK, what really happened here? What really happened is that all the flyers have a rather wide protocol that they have to implement:

    def test_flyer_protocol(self):
        classes = [Asteroid, Missile, Saucer, Ship, Fragment, Score, ScoreKeeper]
        errors = []
        for klass in classes:
            attrs = dir(klass)
            methods = ["interact_with",
                       "interact_with_asteroid",
                       "interact_with_missile",
                       "interact_with_saucer",
                       "interact_with_ship",
                       "interact_with_score",
                       "interact_with_scorekeeper",
                       "are_we_colliding",
                       "tick",
                       "draw"]
            for method in methods:
                if method not in attrs:
                    errors.append((klass.__name__, method))
        assert not errors

I renamed the test to be more accurate, as it is now checking the full protocol (I think) rather than just the double-dispatch methods. But this isn’t good enough unless we keep it up to date.

I think we need to put an abstract class on top of the flyers, so as to give PyCharm a chance to help us out. I’ll put that on the list. For now, I think the test is strong enough to keep me out of trouble, at least until we generate a new Flyer.

Digression?

In the Kotlin decentralized version, Hill invented an amazing delegation scheme that provided default functions without inheritance, because his mother was frightened by inheritance while he was in the womb and it marked him for life, and that feature saved us, in the Kotlin code from what we’re experiencing here, a proliferation of interact_with_xxx methods, one for each possible class, most of which are ignored, in all the objects that can be in the mix.

Between you and me, I’d be inclined to resolve this by implementing null methods in a superclass, but I do admit that that’s a bit nasty. We’ll see what we do but we’re up to ten required methods now and we’re not done yet.

There are things we could do, or we could just let it be what it is. For now, we’ll let it be what it is.

Shall we stop now?

We might be able to go one more step pretty easily. Could we implement score on Fleets to refer to the ScoreKeeper? Yes, but we should really sort out how we’re going to get the ScoreKeeper in there to begin with.

We’ll stop now, because we’re in a good place now.

PyCharm Move!

Recall that I developed ScoreKeeper class in the same file as its tests. that is often a convenient way to test-drive a new class. I asked PyCharm to move the class and it prompted me for a file name and moved the whole class to the new location and fixed up all the imports. Very nice.

Summary

This really went fairly nicely, but the fact remains that I had a broken version at HEAD for about eight minutes, which is eight minutes too long. The issue was that the flyers all need to understand draw and tick and there was no test for that. I think adding an abstract class to serve as an interface definition may be the thing to do before we do any more new flyers.

Even so, in about 90 minutes we have test-driven Score and ScoreKeeper’s interactions, and can be rather sure that when we do put them into the mix, they’ll work as we need. We may install them next time, or maybe we’ll work on the abstract superclass. I never know what I’m going to do.

For now, a decent morning, if not a perfect one. New capability on the road to the decentralized version. I am satisfied but not preening.

See you next time!