Python Asteroids on GitHub

OK, let’s see if this baby will fit where we intended it to fit.

The moving fingers having writ the ShipProvider classes, let’s see if they can move on to using them in ShipMaker. Let’s review the terrain. The central code is this:

class ShipMaker(Flyer):
    def create_ship(self, fleets):
        if not self._safe_to_emerge:
            return False
        if self.ships_remain():
            self.rez_available_ship(fleets)
        else:
            fleets.append(GameOver())
            self._game_over = True
        return True

    def rez_available_ship(self, fleets):
        self.switch_to_other_player()
        if self.ships_remaining(self._current_player) == 0:
            self.switch_to_other_player()
        self.rez_ship_for_player(self._current_player, fleets)

    def rez_ship_for_player(self, player_number, fleets):
        self._ships_remaining[player_number] -= 1
        fleets.append(Ship(u.CENTER))
        fleets.append(Signal(player_number))

    def switch_to_other_player(self):
        self._current_player = (self._current_player + 1) % len(self._ships_remaining)

We also have related code:

class ShipMaker(Flyer):
    def ships_remaining(self, player_number):
        return self._ships_remaining[player_number]

    def testing_set_ships_remaining(self, ships):
        self._ships_remaining[0] = ships

    def add_ship(self, player_identifier):
        self._ships_remaining[player_identifier] += 1
        player.play("extra_ship")

Let’s just go for it. I don’t see anything truly simple to do here.

Foreshadowing
This works out OK, but smaller steps would have been nicer.

Change the init:

    def __init__(self, number_of_players):
        self._ships_remaining = []
        for _ in range(0, number_of_players):
            self._ships_remaining.append(u.SHIPS_PER_QUARTER)
        self._current_player = number_of_players - 1  # trust me
        self._timer = Timer(u.SHIP_EMERGENCE_TIME)
        self._game_over = False
        self._need_ship = True
        self._safe_to_emerge = False

That becomes this:

    def __init__(self, number_of_players):
        if number_of_players == 1:
            self._provider = SinglePlayerShipProvider(u.SHIPS_PER_QUARTER)
        else:
            self._provider = TwoPlayerShipProvider(u.SHIPS_PER_QUARTER)
        self._current_player = number_of_players - 1  # trust me
        self._timer = Timer(u.SHIP_EMERGENCE_TIME)
        self._game_over = False
        self._need_ship = True
        self._safe_to_emerge = False

That change, removing the ships_remaining member, squiggles all the places that need changing:

    def ships_remaining(self, player_number):
        return self._provider.ships_available(player_number)

We see our first mis-fit: should rename those two members to have the same name. Not right now, though.

Foreshadowing
We finally do it, way down in the final bits. Easy enough of course.
    def testing_set_ships_remaining(self, ships):
        self._ships_remaining[0] = ships

I had forgotten that entirely. Better do that one. Let’s add a test for it, and make it private in both places:

    def test_tests_can_set(self):
        provider = SinglePlayerShipProvider(4)
        provider.testing_set_ships_remaining(2)
        assert provider.ships_available(0) == 2

And:

class SinglePlayerShipProvider:
    def testing_set_ships_remaining(self, count):
        self._ships = count

PyCharm wanted me to add the method to the TwoPlayer version as well, so I did. But I’m sitting on ten broken tests so need to get done here. Or roll back and think of a simpler path. Let’s continue on: if it goes as well as I expect, we’ll be done and if not we’ll have the learning for the next try.

class ShipProvider(Flyer):
    def add_ship(self, player_identifier):
        self._provider.add_ship(player_identifier)
        player.play("extra_ship")

Still just finding the squiggles and making the obvious changes.

For the actual creation, I think I want to make the change here:

    def create_ship(self, fleets):
        if not self._safe_to_emerge:
            return False
        to_append = self._provider.provide()
        if to_append:
            for flyer in to_append:
                fleets.append(flyer)
        else:
            fleets.append(GameOver())
            self._game_over = True
        return True

Broken tests are down to two. I think I would like to remove these methods that are not used in ShipMaker, but they may be tested:

    def rez_available_ship(self, fleets):
        self.switch_to_other_player()
        if self.ships_remaining(self._current_player) == 0:
            self.switch_to_other_player()
        self.rez_ship_for_player(self._current_player, fleets)

    def rez_ship_for_player(self, player_number, fleets):
        self._ships_remaining[player_number] -= 1
        fleets.append(Ship(u.CENTER))
        fleets.append(Signal(player_number))

    def switch_to_other_player(self):
        self._current_player = (self._current_player + 1) % len(self._ships_remaining)

    def ships_remain(self):
        return sum(self._ships_remaining) > 0

Let me just remove them and follow my nose. I am not pleased with how any changes this is requiring. We might still roll back.

Foreshadowing
We do not. Everything unwinds nicely.

Still only two tests failing. They are, first this:

    def test_two_player_free_ship_goes_to_right_ship(self):
        fleets = Fleets()
        fleets.append(keeper := ScoreKeeper())
        fleets.append(maker := ShipMaker(2))
        maker.rez_available_ship(fleets)
        maker.testing_set_ships_remaining(0)
        keeper.interact_with_signal(Signal(0), fleets)
        free = u.FREE_SHIP_SCORE
        keeper.interact_with_shipmaker(maker, fleets)
        assert keeper.score == 0
        keeper.interact_with_score(Score(100), fleets)
        assert maker.ships_remaining(0) == 0
        assert keeper.score == 100
        keeper.interact_with_score(Score(free), fleets)
        assert keeper.score == 100 + free
        assert maker.ships_remaining(0) == 1
        assert maker.ships_remaining(1) == u.SHIPS_PER_QUARTER

Do we really need this test? I doubt it but I’d like to keep it. Let’s see what the other test is:

    def test_unequal_ship_count(self):
        fleets = Fleets()
        fi = FI(fleets)
        maker = ShipMaker(2)
        maker._ships_remaining = [1, 3]

        self.make_ship_for_player(0, fi, fleets, maker)
        self.make_ship_for_player(1, fi, fleets, maker)
        self.make_ship_for_player(1, fi, fleets, maker)
        self.make_ship_for_player(1, fi, fleets, maker)

        assert not maker.ships_remain()

    @staticmethod
    def make_ship_for_player(player, fi, fleets, maker):
        assert maker.ships_remain()
        maker.rez_available_ship(fleets)
        signal = fi.signals[0]
        assert signal.signal == player
        fleets.remove(signal)

That’s unhappy because we’re calling ships_remain. We can skip those calls and use the rest.

What about rez_available_ship? I think we could extract from this method and make a usable rez method:

    def create_ship(self, fleets):
        if not self._safe_to_emerge:
            return False
        to_append = self._provider.provide()
        if to_append:
            for flyer in to_append:
                fleets.append(flyer)
        else:
            fleets.append(GameOver())
            self._game_over = True
        return True

It won’t be quite the same but I think it might be good enough if we do this:

    def create_ship(self, fleets):
        if not self._safe_to_emerge:
            return False
        self.rez_available_ship(fleets)
        return True

    def rez_available_ship(self, fleets):
        to_append = self._provider.provide()
        if to_append:
            for flyer in to_append:
                fleets.append(flyer)
        else:
            fleets.append(GameOver())
            self._game_over = True

One of the two passes. Both would have made me happier. Still borked:

    def test_unequal_ship_count(self):
        fleets = Fleets()
        fi = FI(fleets)
        maker = ShipMaker(2)
        maker._ships_remaining = [1, 3]

        self.make_ship_for_player(0, fi, fleets, maker)
        self.make_ship_for_player(1, fi, fleets, maker)
        self.make_ship_for_player(1, fi, fleets, maker)
        self.make_ship_for_player(1, fi, fleets, maker)

        assert not maker.ships_remain()

    @staticmethod
    def make_ship_for_player(player, fi, fleets, maker):
        assert maker.ships_remain()
        maker.rez_available_ship(fleets)
        signal = fi.signals[0]
        assert signal.signal == player
        fleets.remove(signal)

Ah. I can’t set the ship count quite like that. Let’s change the setup:

    def test_unequal_ship_count(self):
        fleets = Fleets()
        fi = FI(fleets)
        maker = ShipMaker(2)
        maker._provider._ships = [1, 3]

        self.make_ship_for_player(0, fi, fleets, maker)
        self.make_ship_for_player(1, fi, fleets, maker)
        self.make_ship_for_player(1, fi, fleets, maker)
        self.make_ship_for_player(1, fi, fleets, maker)

And we are green. I almost like this. Let me try the game just for extra confidence. Works as advertised. Let’s commit, reflect, and clean up. Commit: ShipMaker uses new ShipProvider objects to, well, provide ships.

Reflection

That went nearly as well as one could have hoped. Basically I just plugged in the new objects and modified all the references to the former member variable _ships_remaining. Most of the tests started running immediately: I never even looked to see what they were.

The two that continued to break were using that somewhat internal method, rez_available_ship. We reproduced a good-enough version of that and used it. That fixed one test and the other uses a very tiny hammer to fudge the number of available ships for testing:

        maker._provider._ships = [1, 3]

Not one private access, but two! Fantastic. Let’s improve this. We do have this method on ShipMaker:

    def testing_set_ships_remaining(self, ships):
        self._provider.testing_set_ships_remaining(ships)

Let’s extend that to accept a list and pass it on down. First change the above test to use our new method:

    def test_unequal_ship_count(self):
        fleets = Fleets()
        fi = FI(fleets)
        maker = ShipMaker(2)
        maker._provider._ships = [1, 3]
        maker.testing_set_ships_remaining([1, 3])

        self.make_ship_for_player(0, fi, fleets, maker)
        self.make_ship_for_player(1, fi, fleets, maker)
        self.make_ship_for_player(1, fi, fleets, maker)
        self.make_ship_for_player(1, fi, fleets, maker)

Then find usages and change them to pass a list:

    def set_up_free_ship_test():
        fleets = Fleets()
        fleets.append(keeper := ScoreKeeper())
        fleets.append(maker := ShipMaker(1))
        maker.testing_set_ships_remaining([0])
        return fleets, maker, keeper

    def test_two_player_free_ship_goes_to_right_ship(self):
        fleets = Fleets()
        fleets.append(keeper := ScoreKeeper())
        fleets.append(maker := ShipMaker(2))
        maker.rez_available_ship(fleets)
        maker.testing_set_ships_remaining([0])
        keeper.interact_with_signal(Signal(0), fleets)
        free = u.FREE_SHIP_SCORE
        keeper.interact_with_shipmaker(maker, fleets)
        assert keeper.score == 0
        keeper.interact_with_score(Score(100), fleets)
        assert maker.ships_remaining(0) == 0
        assert keeper.score == 100
        keeper.interact_with_score(Score(free), fleets)
        assert keeper.score == 100 + free
        assert maker.ships_remaining(0) == 1
        assert maker.ships_remaining(1) == u.SHIPS_PER_QUARTER

Change the method to expect a list and pass it on:

class ShipMaker(Flyer):
    def testing_set_ships_remaining(self, counts):
        self._provider.testing_set_ships_remaining(counts)

class SinglePlayerShipProvider:
    def testing_set_ships_remaining(self, counts):
        self._ships = counts[0]

class TwoPlayerShipProvider:
    def testing_set_ships_remaining(self, counts):
        self._ships = counts

I am surprised that this doesn’t make the tests run. This one needed two parameters:

    def test_two_player_free_ship_goes_to_right_ship(self):
        fleets = Fleets()
        fleets.append(keeper := ScoreKeeper())
        fleets.append(maker := ShipMaker(2))
        maker.rez_available_ship(fleets)
        maker.testing_set_ships_remaining([0, 4])
        keeper.interact_with_signal(Signal(0), fleets)
        free = u.FREE_SHIP_SCORE
        keeper.interact_with_shipmaker(maker, fleets)
        assert keeper.score == 0
        keeper.interact_with_score(Score(100), fleets)
        assert maker.ships_remaining(0) == 0
        assert keeper.score == 100
        keeper.interact_with_score(Score(free), fleets)
        assert keeper.score == 100 + free
        assert maker.ships_remaining(0) == 1
        assert maker.ships_remaining(1) == u.SHIPS_PER_QUARTER

I think that test is clearly useless now, but we shouldn’t delete tests at this critical time. The other failure is this, where I had just missed changing the set to use a list:

    def test_tests_can_set(self):
        provider = SinglePlayerShipProvider(4)
        provider.testing_set_ships_remaining([2])
        assert provider.ships_available(0) == 2

Green. Commit: Tests no longer quite so invasive. Back to reflection:

Reflection (2)

With that change in place, we still have that testing method. One hates to leave testing methods in the production code. We could get rid of it by monkey-patching it in, or by providing a very invasive test method like the one we just fixed. Or we could bless the ability to set the ship counts, use it in the Providers, and then use it cruelly in the tests. But even to do that, we’d generally have to work through the ShipMaker, so we’d have to change it as well.

OK, what about using add_ship somehow? What about implementing remove_ship?

What about getting over it? I think we’ll get over it.

Code Review

What can we say about the code? I think ShipMaker is clearly better, with its _provider doing all the lifting. What about the Providers themselves?

Well, first, there is no abstract class, and there should be.

class ShipProvider(ABC):
    @abstractmethod
    def add_ship(self, _player: int):
        pass

    @abstractmethod
    def provide(self) -> list[Ship|Signal]:
        pass

    @abstractmethod
    def ships_available(self, _player:int) -> int:
        pass

    @abstractmethod
    def testing_set_ships_remaining(self, counts: list[int]):
        pass

I went entire pig here and specified type hints. Can’t hurt, might help.

Green. Commit: Added abstract class ShipProvider.

I think there are some methods in TwoPlayer that could be marked private:

    def provide(self):
        self._switch_players()
        if self._ships[self._current_player]:
            return self._ship_for_player(self._current_player)
        else:
            self._switch_players()
            if self._ships[self._current_player]:
                return self._ship_for_player(self._current_player)
            else:
                return []

    def _ship_for_player(self, player):
        self._ships[player] -= 1
        return [Ship(u.CENTER), Signal(player)]

    def _switch_players(self):
        self._current_player = (self._current_player + 1) % 2

Again, can’t hurt, might help. I think I’d do well to mark things that way more often.

What about those u.PLAYER_ZERO and u.PLAYER_ONE values? Are they used everywhere they should be?

Not really. What should we really do with them, especially in TwoPlayerShipProvider?

Suppose that those values are just tokens of some kind. Then this is a bit suspect:

class TwoPlayerShipProvider:
    def __init__(self, number_of_ships):
        self._current_player = 1
        self._ships = [number_of_ships, number_of_ships]

Should _ships be named _ship_counts, and should it perhaps be a dictionary from the player token to count?

Let’s try that, see if we like it:

class TwoPlayerShipProvider:
    def __init__(self, number_of_ships):
        self._current_player = u.PLAYER_ONE
        self._ships = {u.PLAYER_ZERO: number_of_ships, u.PLAYER_ONE: number_of_ships}

We can’t quite tolerate this now:

    def _switch_players(self):
        self._current_player = (self._current_player + 1) % 2

We don’t know, or are at least trying to forget that the player tokens aren’t numbers, they’re just tokens. How about this:

    def _switch_players(self):
        self._current_player = u.PLAYER_ONE if self._current_player == u.PLAYER_ZERO else u.PLAYER_ZERO

As an old—and I really mean it—C programmer, I like the ternary. YMMV.

Let’s do a bit of renaming:

class TwoPlayerShipProvider:
    def __init__(self, number_of_ships):
        self._current_player_token = u.PLAYER_ONE
        self._ships = {u.PLAYER_ZERO: number_of_ships, u.PLAYER_ONE: number_of_ships}

    def add_ship(self, player_token):
        self._ships[player_token] += 1

    def ships_available(self, player_token):
        return self._ships[player_token]

    def provide(self):
        self._switch_players()
        if self._ships[self._current_player_token]:
            return self._ship_for_player(self._current_player_token)
        else:
            self._switch_players()
            if self._ships[self._current_player_token]:
                return self._ship_for_player(self._current_player_token)
            else:
                return []

    def _ship_for_player(self, player_token):
        self._ships[player_token] -= 1
        return [Ship(u.CENTER), Signal(player_token)]

    def _switch_players(self):
        self._current_player_token = u.PLAYER_ONE if self._current_player_token == u.PLAYER_ZERO else u.PLAYER_ZERO

    def testing_set_ships_remaining(self, counts):
        self._ships[u.PLAYER_ZERO] = counts[0]
        self._ships[u.PLAYER_ONE] = counts[1]

I think that’s better. Why? Because now we are (nearly?) unconcerned about the type of u.PLAYER_ZERO and u.PLAYER_ONE. They could be strings or UUIDs or something sensible.

We don’t need to do the same trick in SinglePlayerShipProvider but what we could do is rename it to PlayerZeroShipProvider to make it clear that that’s what it is.

Possibly Bad Idea

Hm, that makes me wonder … would it make sense for TwoPlayerShipProvider to use two SinglePlayerShipProviders, one for each player token?

It seems to me that it certainly could use SinglePlayer. But should it?1 I honestly do not know. Might want to try it and see if it is somehow better to use the existing abstraction rather than the new one. Not today, Satan, not today.

We are green as, um, things that are green. Commit: ShipProviders are independent of type of u.PLAYER_ZERO and u.PLAYER_ONE. Within Reason.

I think we’ll call this a wrap.

Summary

The new ShipProvider classes installed quite easily. Tests broke while the changes were underway, then generally healed, except for two that were using internal ShipMaker methods that no longer existed. Those tests were readily converted to use the new scheme.

We then provided an abstract class to act as an interface for the two concrete Provider classes, and then refactored a bit so that u.PLAYER_ZERO and u.PLAYER_ONE are no longer known to be integers inside Providers, but are treated as literal tokens.

This method seems to need renaming somewhere:

class ShipMaker(Flyer):
    def ships_remaining(self, player_number):
        return self._provider.ships_available(player_number)

I’ll just do that right now. Commit: rename method.

I suspect that there are tests with literal zero or one that are not agnostic about the type of those u variables. I’ll make a note to look for them and adjust them, and will save you the boredom unless it isn’t boring after all.

Overall, my new objects went into place nicely, though I had to make all the changes before everything worked. That went easily, as you can see above, but there were around a half-dozen things that needed to change. Possibly there was a better way. Possibly we could have extracted a combined object first, and then plugged our new thing into it. Possibly we could have refactored from what we had all the way to what we have now, or possibly even to something better.

That’s even more speculative than creating the two little objects was. Those objects came with some very nice tests, so I’m perfectly happy with the result. But I freely grant that the code was red longer than we’d have liked. Ideally, we make one small change and get back to green. We fell short of that ideal. And even so, we have a fine result.

Could it be better? Of course. There’s always room for better.

See you next time!



  1. “You programmers were so preoccupied with whether you could, you didn’t stop to think if you should.” – Dr Ian Malcolm, private communication2 

  2. “Did too, nanner nanner.” – Ron Jeffries, replying to Malcolm