The Repo on GitHub

It’s Tuesday, so tonight is Friday Geeks Night Out. I want to demo something new. Can I get it ready this morning? Surprisingly, yes I can!

I believe that the current bots could be made to gather different things into separate piles very easily. They currently pick up only blocks with nothing on at least one side as they face the block: ‘B_x’ or ‘xB. And they only drop it only an empty space if the scent of a block in the area is greater than some threshold, and if there is another block on at least one side of the empty space ‘x_B’ or ‘B_x’. so it seems to me that if blocks had a few different scents, and we only checked for exceeding the threshold using the scent of the block we’re holding … they might sort blocks into piles of the same scent. We’d display then in the game with different colors, and as the game runs, they might create separate piles of each color. That would be quite amazing, given how little the bot know.

I propose to do that this morning, so that I can demonstrate it tonight. How might we do this?

My thoughts so far have led me to think that the scent result that the World returns to the bot should become a structured object with various scents and a value for each one. That’s not too difficult a change, but it would involve changes to both sides, and adjusting everything so that scent changes from an integer to a dictionary kind of thing.

But just now, a few lines above, I had a different idea that just may save me. What if we redefine scent such that, since you have this particular block in your mandibles, your scent-scenting antennae are swamped with that scent, and so you can really only smell more or less of that scent, so that all that we record in the scent return is the magnitude of that scent in the area.

With that change to the World, our existing bots might just begin to sort without even realizing it.

That’s too good not to try. Let’s look at how we get the scent:

class World:
    def set_bot_scent(self, bot):
        bot.scent = self.map.scent_at(bot.location)

class Map:
    def scent_at(self, location):
        total_scent = 0
        for dx in (-2, -1, 0, 1, 2):
            for dy in (-2, -1, 0, 1, 2):
                current = location + Direction(dx, dy)
                scent = self.relative_scent(location, current)
                total_scent += scent
        return total_scent

    def relative_scent(self, location, current):
        found = self.at_xy(current.x, current.y)
        scent = 0
        if found and found.name == 'B':
            scent = 4 - location.distance(current)
            if scent < 0:
                scent = 0
        return scent

So, let’s suppose that we provide a second parameter to scent_at, the particular odor the bot is sensitive to, and we pass it down to relative scent, where it can compare to see if the thing it finds has that particular odor.

So a world-side block needs to have an odor. I think that, for now, we’ll provide that on the world side, but we will probably wish to make it a parameter on creation at some point. For now, we’re trying to find the simplest set of changes that will get this new capability operating.

Note
That’s not entirely because I really want to get finished to show the gang. It’s really a big part of everything we do, finding the shortest sensible path to getting a sensible part of the story to work. GeePaw Hill calls the thought process “pathing”, heedless of the fact that that’s not even a word. Like Humpty Dumpty, he and I and you can make words mean whatever we intend them to mean. There is some peril in that but it has its moments. But I digress …

So our WorldEntity needs to have an odor. Odor has negative connotations for me. Let’s call it … aroma.

class WorldEntity:
    next_id = 100

    def __init__(self, kind: EntityKind, x: int, y: int, direction: Direction):
        self._dict = dict()
        WorldEntity.next_id += 1
        self._dict['eid'] = self.next_id
        self._dict['kind'] = kind
        self.location = Location(x, y)
        self.direction = direction
        self.holding = None
        self.scent = 0
        self.vision = []

    @classmethod
    def bot(cls, x, y, direction):
        return cls(EntityKind.BOT, x, y, direction)

    @classmethod
    def block(cls, x, y):
        return cls(EntityKind.BLOCK, x, y, Direction.EAST)

I note that we have a required direction even for blocks. That’s kind of a leftover from our conversion to WorldEntity, a choice made to make the changes easier. We might want to improve that and if we choose to, the changes should be localized to right here. We’re on a mission here, so we’ll let that ride. Let’s init aroma to zero and set it for blocks in the creation method.

We need accessors:

    @property
    def aroma(self):
        return self._dict['aroma']

    @aroma.setter
    def aroma(self, aroma):
        self._dict['aroma'] = aroma

And to init:

    def __init__(self, kind: EntityKind, x: int, y: int, direction: Direction):
        self._dict = dict()
        WorldEntity.next_id += 1
        self._dict['eid'] = self.next_id
        self._dict['kind'] = kind
        self.aroma = 0
        self.location = Location(x, y)
        self.direction = direction
        self.holding = None
        self.scent = 0
        self.vision = []

Green. Commit: adding aroma to WorldEntity.

Now in the block constructor, let’s set it to a random value. Let’s have four values. 0 thru 3 should be OK, though I am tempted to do something more fancy.

From this:

    @classmethod
    def block(cls, x, y):
        return cls(EntityKind.BLOCK, x, y, Direction.EAST)

Extract temp variable:

    @classmethod
    def block(cls, x, y):
        blk = cls(EntityKind.BLOCK, x, y, Direction.EAST)
        return blk

We could commit but let’s not be silly. Now set the value.

    @classmethod
    def block(cls, x, y):
        blk = cls(EntityKind.BLOCK, x, y, Direction.EAST)
        blk.aroma = random.randint(0, 4)
        return blk

This is fine but I’d really like to have some tests about now. We actually have a decent number of tests for scent now, and I expect that without a bit of care, they’ll all break, since they are all assuming that all Blocks have the same aroma. Let’s add a new parameter to the block method, the desired aroma. If it’s not provided, we’ll do the random one.

    @classmethod
    def block(cls, x, y, aroma=None):
        blk = cls(EntityKind.BLOCK, x, y, Direction.EAST)
        blk.aroma = random.randint(0, 4) if aroma is None else aroma
        return blk

Now I think that all the scent tests need a look. I think they are testing using client-side Blocks. Many tests still do, and this is not a good thing. They are, I change them all and now they look like this:

    def test_scent_all_around(self):
        map = Map(10, 10)
        for x in range(11):
            for y in range(11):
                block = WorldEntity.block(x, y)
                map.place(block)
        scent = map.scent_at(Location (5, 5))
        assert scent ==  40

I know they’re going to break when we start checking the aroma, because they all need to set a specific aroma, but let’s let that happen: it will be encouraging, telling us that we know what’s going on.

Now we can go back to the scent stuff and pass in an aroma. (Aroma really should be a class or an enum, shouldn’t it, not just a naked integer?)

class World:
    def set_bot_scent(self, bot):
        aroma_to_seek = 0
        if bot.holding:
            aroma_to_seek = bot.holding.aroma
        bot.scent = self.map.scent_at(bot.location)

This works, which is a bit of good news. I was concerned that the fetch of aroma might cause trouble, but it seems good so far.

Pass it down. We need to change the signature of these two methods in Map:

class Map:
    def scent_at(self, location):
        total_scent = 0
        for dx in (-2, -1, 0, 1, 2):
            for dy in (-2, -1, 0, 1, 2):
                current = location + Direction(dx, dy)
                scent = self.relative_scent(location, current)
                total_scent += scent
        return total_scent

    def relative_scent(self, location, current):
        found = self.at_xy(current.x, current.y)
        scent = 0
        if found and found.name == 'B':
            scent = 4 - location.distance(current)
            if scent < 0:
                scent = 0
        return scent

The first one is called several times from our scent tests, and the second is called at least once. We’ll trust PyCharm Change Signature to do the job. And we’ll also check its work.

    def scent_at(self, location, desired_aroma):
        total_scent = 0
        for dx in (-2, -1, 0, 1, 2):
            for dy in (-2, -1, 0, 1, 2):
                current = location + Direction(dx, dy)
                scent = self.relative_scent(location, current, desired_aroma)
                total_scent += scent
        return total_scent

    def relative_scent(self, location, current, desired_aroma):
        found = self.at_xy(current.x, current.y)
        scent = 0
        if found and found.name == 'B':
            scent = 4 - location.distance(current)
            if scent < 0:
                scent = 0
        return scent

So far so good. I’ll check the callers visually. The scent_at is always called with zero, as expected from the Change Signature. We change world:

class World:
    def set_bot_scent(self, bot):
        aroma_to_seek = 0
        if bot.holding:
            aroma_to_seek = bot.holding.aroma
        bot.scent = self.map.scent_at(bot.location, aroma_to_seek)

We’re still not checking the aroma of course … so let’s add the necessary code. Or, should we commit? I think we should. Commit: passing aroma to scent logic, unused at bottom.

Now, let’s plug in the check and see what happens.

class Map:
    def relative_scent(self, location, current, desired_aroma):
        found = self.at_xy(current.x, current.y)
        scent = 0
        if found and found.name == 'B' and found.aroma == desired_aroma:
            scent = 4 - location.distance(current)
            if scent < 0:
                scent = 0
        return scent

Sure enough, a few tests break. I hope they’re the ones I expected.

As anticipated, they all needed to have an aroma specified in the test, like this:

    def test_scent_right_here(self):
        map = Map(10, 10)
        loc = Location (5, 5)
        block = WorldEntity.block(loc.x, loc.y, 1)
        map.place(block)
        scent = map.scent_at(loc, 1)
        assert scent == 4

We are green. Commit: aroma is being checked in scent logic.

Now, if this works, the game should be sorting. We need to make it show different colors for different aromas. I may have to relearn pygame.

class Game:
    def draw_world(self):
        for entity in self.world.map:
            x = entity.x
            y = entity.y
            if x is not None and y is not None and x >= 0 and y >= 0:
                name = entity.name
                scale_x = x * self.scale
                scale_y = y * self.scale
                color = WHITE
                if name == 'R':
                    if entity.holding:
                        color = RED
                    else:
                        color = GREEN
                self.text((scale_x + 1, scale_y + 1), name, 16, color, BLACK)

OK, what we need, among other things, is some new colors. I think we need four total colors, so maybe just one more.

So I have adjusted the game to put in blocks of random color, and at first I thought it wasn’t working. What happened was that after a while, all the bots were running around laden and never dropping anything. What had happened, I believe, is that they had picked up all the blocks of a given color, so there was nowhere that they could ever drop their block.

I “solved” the issue by creating a lot more blocks, so that they are never able to pick up all the blocks of any given color all at the same time.

And here’s my result after running for a while:

grouped by color

I call that success! Commit: can sort by aroma.

Let’s quickly review what we’ve done here.

class World:
    def add_block(self, x, y, aroma=0):
        entity = WorldEntity.block(x, y, aroma)
        returned_client_entity = Block(x, y)
        return self.add_and_return_client_entity(entity, returned_client_entity)

    def set_bot_scent(self, bot):
        aroma_to_seek = 0
        if bot.holding:
            aroma_to_seek = bot.holding.aroma
        bot.scent = self.map.scent_at(bot.location, aroma_to_seek)

class Map:
    def scent_at(self, location, desired_aroma):
        total_scent = 0
        for dx in (-2, -1, 0, 1, 2):
            for dy in (-2, -1, 0, 1, 2):
                current = location + Direction(dx, dy)
                scent = self.relative_scent(location, current, desired_aroma)
                total_scent += scent
        return total_scent

    def relative_scent(self, location, current, desired_aroma):
        found = self.at_xy(current.x, current.y)
        scent = 0
        if found and found.name == 'B' and found.aroma == desired_aroma:
            scent = 4 - location.distance(current)
            if scent < 0:
                scent = 0
        return scent

class WorldEntity:
    def __init__(self, kind: EntityKind, x: int, y: int, direction: Direction):
        self._dict = dict()
        WorldEntity.next_id += 1
        self._dict['eid'] = self.next_id
        self._dict['kind'] = kind
        self.aroma = 0
        self.location = Location(x, y)
        self.direction = direction
        self.holding = None
        self.scent = 0
        self.vision = []

    @classmethod
    def block(cls, x, y, aroma=None):
        blk = cls(EntityKind.BLOCK, x, y, Direction.EAST)
        blk.aroma = random.randint(0, 4) if aroma is None else aroma
        return blk

    @property
    def aroma(self):
        return self._dict['aroma']

    @aroma.setter
    def aroma(self, aroma):
        self._dict['aroma'] = aroma

I think there are things needing improvement here, in particular, the constructors might benefit from a bit of revision, but we’re kind of emulating multiple classes in WorldEntity, so they’re going to be a bit odd until they settle down. Possibly we want to change the default aroma. Possibly it shouldn’t really be randomized inside the class method, but outside if you want random.

Really not much to complain about. I am pleased, and the sort is amazing.

See you next time!