Entity Type
The WorldEntity only supports Bots so far. It needs to support Blocks, and whatever other items our world might one day contain. We need a type designation. We run into just a bit of trouble. Long article, nothing to see here.
I think we’d like to avoid the term type
, as Python has a built-in function of that name. How about kind
? And we’ll qualify it as EntityKind. And it will be an Enum, which seems to me to be the way to do such things. I think this will be a public sort of thing: a client will see visions of entities, and needs to know what we tell it about their kind. We won’t always have perfect vision, so one possibility is surely UNKNOWN
.
I think we can just type this in. Tests will surely emerge.
But let’s think a bit further out. We have a story on the board about our bots sorting things by “scent”. I can see at least two possibilities:
- There can be entities of a given kind, say Block, with different scents, say red, green and blue;
- There can only be a single entity scent, and our story should be about sorting different kinds of things.
OK, that much thinking says to me that I can ignore the question of scent while dealing with kind. Perhaps that was obvious, but when a thought comes to mind, I prefer to give it a moment, unless it’s one of those dark ones.
To the Code Cave, CodeMan!
from enum import Enum
class EntityKind(Enum):
BOT = 1
BLOCK = 2
That should do the job for now. Next, let’s assign the kind, and see about passing it across the wire.
class World:
def add_bot(self, x, y, direction = Direction.EAST):
id = self.add_world_bot(x, y, direction)
returned_bot = Bot(x, y, direction)
returned_bot.id = id
return returned_bot
def add_world_bot(self, x, y, direction = Direction.EAST):
bot = WorldEntity(x, y, direction)
self.add(bot)
return bot.id
class WorldEntity:
def __init__(self, x=None, y=None, direction=None):
self._dict = dict()
if x is not None:
self.location = Location(x, y)
self.direction = direction
self.holding = None
self.scent = 0
self.vision = []
WorldEntity has a raft of accessors, one for each of the things stored in the dictionary. Why? Because I don’t want to access it with dictionary style, and I don’t even want to know, generally speaking, that it’s represented as a dictionary.
Now clearly, when we create a WorldEntity, the most important thing about it will be its kind, and it seems to me that we should have class methods to construct our different kinds of entity and that now is the time to do it. I think we can do this by “wishful thinking”, changing the code to be as we wish it were, and relying on our tests to make sure we connect it all back up. So …
class World:
def add_world_bot(self, x, y, direction = Direction.EAST):
bot = WorldEntity.bot(x, y, direction)
self.add(bot)
return bot.id
21 tests break. Perfect.
class WorldEntity:
@classmethod
def bot(cls, x=None, y=None, direction=None):
instance = cls(x, y, direction)
return instance
All tests green. Commit: add bot
class method and WorldKind enum. Should have said EntityKind. Sorry, not going to amend, last time I did that the world ended.
Now, of course we want the kind passed in, so we’ll change the signature. We’ll leave x, y and direction, though I could imagine a world where setting those was up to the individual class method. Fact is, our Blocks won’t have need of a lot of that information. We might, just might, deal with that with separate classes and one interface, but not just now.
class WorldEntity:
def __init__(self, kind, x=None, y=None, direction=None):
self._dict = dict()
if x is not None:
self.location = Location(x, y)
self.direction = direction
self.holding = None
self.scent = 0
self.vision = []
@classmethod
def bot(cls, x=None, y=None, direction=None):
instance = cls(EntityKind.BOT, x, y, direction)
return instance
Two tests are still failing, perhaps due to what the Change Signature refactoring did. And we need to do something about those defaulted members. We’ll discuss that in a moment. Need to go after those tests while they’re hot.
Ha! Easy. Needed to import EntityKind for the WorldEntity tests. No problem. Commit: include kind in constructor.
Now, the x, y and direction caused an odd thing to happen. The original code:
instance = cls(x, y, direction)
compiled, because x was taken as kind, y as x, direction as y and None was supplied for direction. Let’s make those parameters required.
class WorldEntity:
def __init__(self, kind, x, y, direction):
self._dict = dict()
if x is not None:
self.location = Location(x, y)
self.direction = direction
self.holding = None
self.scent = 0
self.vision = []
@classmethod
def bot(cls, x, y, direction):
instance = cls(EntityKind.BOT, x, y, direction)
return instance
Two tests fail, presumably because they aren’t defining enough stuff. Yes, that’s all it was, again in the WorldEntity tests:
def test_create(self):
WorldEntity(EntityKind.BOT, 0, 0, Direction.EAST)
def test_set_and_fetch(self):
entity = WorldEntity(EntityKind.BOT, 0, 0, Direction.EAST)
...
I’m going to put type hints on this class. Why? Because I got that odd thing where it accepted too few parameters, and I think that had I provided type hints, it would have given me a compile error.
I’m glad that I set out to do that. I noticed that I didn’t set kind yet. Let’s add a test.
def test_create(self):
entity = WorldEntity(EntityKind.BOT, 0, 0, Direction.EAST)
assert entity.kind == EntityKind.BOT
This won’t even satisfy PyCharm without the method …
@property
def kind(self):
return self._dict['kind']
Fails for lack of kind in the dictionary. Now we could create a setter, but we really don’t want people setting that member. Let’s do this:
class WorldEntity:
def __init__(self, kind, x, y, direction):
self._dict = dict()
self._dict['kind'] = kind
if x is not None:
self.location = Location(x, y)
self.direction = direction
self.holding = None
self.scent = 0
self.vision = []
It’s worth noting that that is exactly what I wanted … and that PyCharm suggested the line as soon as I hit enter after the self._dict = dict()
line. This is one fine program!
Commit: added kind
read accessor and value in dictionary.
Reflection
OK, settle down here and let’s see what we’re at. The object id
comes to mind. I’m not dead certain even where we set it now, but it seems pretty clear that this class is the right place.
Pretty clearly we’ll have an add_block
method Real Soon Now, and that may lead to some consolidation of code. That might be the next thing we do, in fact. I have a mild inclination toward saving dictionary space for the blocks, but seriously, there won’t be that many. Premature optimization, most likely.
This code:
class WorldEntity:
def __init__(self, kind, x, y, direction):
self._dict = dict()
self._dict['kind'] = kind
if x is not None:
self.location = Location(x, y)
self.direction = direction
self.holding = None
self.scent = 0
self.vision = []
Historically, i.e. a few minutes ago, x could be None
. It can’t be None
now. Change:
class WorldEntity:
def __init__(self, kind, x, y, direction):
self._dict = dict()
self._dict['kind'] = kind
self.location = Location(x, y)
self.direction = direction
self.holding = None
self.scent = 0
self.vision = []
Commit: remove check for missing x.
@classmethod
def bot(cls, x, y, direction):
instance = cls(EntityKind.BOT, x, y, direction)
return instance
I wrote that with the temp because I was thinking that I might init via setters, but that did not eventuate, nor did it occur nor even happen, so:
@classmethod
def bot(cls, x, y, direction):
return cls(EntityKind.BOT, x, y, direction)
Option+Command+N, Enter. Love me some PyCharm.
Commit: inline temp.
OK, I think this is decent. Now let’s TDD in add_block and then use it.
No, wait, let’s do the id first. How is that happening?
- Note
- Doing id first doesn’t work out. Rollback coming.
class World:
def add_bot(self, x, y, direction = Direction.EAST):
id = self.add_world_bot(x, y, direction)
returned_bot = Bot(x, y, direction)
returned_bot.id = id
return returned_bot
def add_world_bot(self, x, y, direction = Direction.EAST):
bot = WorldEntity.bot(x, y, direction)
self.add(bot)
return bot.id
def add(self, entity):
World.next_id += 1
entity.id = World.next_id
self.map.place(entity)
I know that the intention here is just to keep incrementing the World.next_id
for each new entity. Now that there is an actual class for it, let’s do it in WorldEntiry. And we’ll remove the setter for id
to ensure that people don’t go around messing with it.
class WorldEntity:
next_id = 100
def __init__(self, kind, x, y, direction):
self._dict = dict()
WorldEntity.next_id += 1
self._dict['id'] = WorldEntity.next_id
self._dict['kind'] = kind
self.location = Location(x, y)
self.direction = direction
self.holding = None
self.scent = 0
self.vision = []
And in World:
class World:
def add_bot(self, x, y, direction = Direction.EAST):
id = self.add_world_bot(x, y, direction)
returned_bot = Bot(x, y, direction)
returned_bot.id = id
return returned_bot
def add_world_bot(self, x, y, direction = Direction.EAST):
bot = WorldEntity.bot(x, y, direction)
self.add(bot)
return bot.id
def add(self, entity): # used to set id.
self.map.place(entity)
One test failing. It’s a vision test and it’s seriously wrong now:
def test_three_blocks_near(self):
from block import Block
from bot import Bot
world = World(10, 10)
world.add(Block(4, 4))
world.add(Block(6, 6))
world.add(Block(4, 5))
world.add(Bot(5, 5))
vision = world.map.vision_at(Location(5, 5))
assert ('R', 5, 5) in vision
assert ('B', 4, 5) in vision
assert ('B', 6, 6) in vision
assert ('B', 4, 4) in vision
The vision shows ‘R’ for everything, which we can fix, but there is another issue>. I have made a mistake. I should not have addressed moving id before making the bot a WorldEntity. OK, fine, be that way.
- Roll Back
- As foreshadowed above.
What happened? It came to me in looking at that test that without the id coming from World, the bots would all get a None id, and that won’t work. So let’s do a test for adding bots, as I originally intended.
def test_create_block(self):
entity = WorldEntity.block(0, 0, Direction.EAST)
That fails, of course.
@classmethod
def block(cls, x, y, direction):
return cls(EntityKind.BLOCK, x, y, direction)
Passing. Commit: block
class method.
Now the methods on World.
class World:
def add_block(self, x, y, direction = Direction.EAST):
id = self.add_world_block(x, y, direction)
returned_bot = Bot(x, y, direction)
returned_bot.id = id
return returned_bot
def add_world_block(self, x, y, direction = Direction.EAST):
bot = WorldEntity.block(x, y, direction)
self.add(bot)
return bot.id
We’re creating a lot of duplication here. We’ll clean it up. Right now we want to get the blocks working. I’m not entirely sure that they will.
Let’s fix up that vision test.
def test_three_blocks_near(self):
world = World(10, 10)
world.add_block(4, 4)
world.add_block(6, 6)
world.add_block(4, 5)
world.add_bot(5, 5)
vision = world.map.vision_at(Location(5, 5))
assert ('R', 5, 5) in vision
assert ('B', 4, 5) in vision
assert ('B', 6, 6) in vision
assert ('B', 4, 4) in vision
Used to say add(Block(etc))
. Changed to call add_block. It appears that vision has only ‘R’ values in it now. I am not surprised:
class WorldEntity:
@property
def name(self):
return 'R'
This needs to be a bit more robust:
@property
def name(self):
return 'R' if self.kind is EntityKind.BOT else 'B'
And the test runs. I am not ready to commit, however, until I’ve changed all the relevant add(Block)
to add_block
.
- Note II
- Now, in post, I wonder if we really needed to roll back. It’s not entirely clear. I suspect we’d have encountered the
id
issue sooner or later but having seen it, rolling back made sense at the time.
I’ve been ticking through the tests, making the add_block
change. Very tedious. The tests generally run, though I have had to make what I thought was a minor change, converting
assert world_bot.has(block)
to
assert world_bot.has_block()
I was thinking that was close enough, that it is unlikely that we’ll get the wrong block. But now this test does not run as converted:
def test_drop_block_on_open_cell(self):
world = World(10, 10)
client_bot = world.add_bot(5, 5)
bot_id = client_bot.id
world_bot = world.map.at_id(bot_id)
block = world.add_block(1, 9)
world_bot.receive(block)
connection = DirectConnection(world)
assert len(world.map.contents.keys()) == 2
connection.drop(client_bot, block)
assert len(world.map.contents.keys()) == 2
assert not world_bot.has(block)
assert not world.is_empty(Location(6, 5))
We’d best check what we return from add_block
, then receive
, and then, finally, drop
.
OK, well this seems clearly wrong:
class World:
def add_block(self, x, y, direction = Direction.EAST):
id = self.add_world_block(x, y, direction)
returned_bot = Bot(x, y, direction)
returned_bot.id = id
return returned_bot
Why are we creating a client-side object here? And what about those names? Too much copy-pasta.
Ah. Our tests actually expect a client-side result.OK, but let’s rename for clarity.
def add_block(self, x, y, direction = Direction.EAST):
id = self.add_world_block(x, y, direction)
returned_client_block = Block(x, y)
returned_client_block.id = id
return returned_client_block
def add_bot(self, x, y, direction = Direction.EAST):
id = self.add_world_bot(x, y, direction)
returned_client_bot = Bot(x, y, direction)
returned_client_bot.id = id
return returned_client_bot
OK we are green, but not exactly good. Commit: part way thru changing over to add_block
.
I think we can pause here.
Summary
We added the EntityKind readily, and presently have both a bot kind and block kind, with just one test that needs work. Work proceeded rather smoothly, although with more tedium than I’d prefer, and we’re left with some issues:
-
Deal with the add_bot and add_block methods returning client objects. Separate tests into server and client side? How can we test our Bots without a World to run against?
- Change where
id
is calculated - Fix the drop test
Of these, I’m most concerned about the way the tests rely on a client object coming back from the add operations. I think what we might do is to provide special constructors for the tests, so that the basic add_bot and add_block can behave in a more worldly fashion instead of this odd ix of world and client.
There was a moment, when that one test wouldn’t convert readily, when I thought I might have to roll back the other tests, but after a moment’s panic it seemed clear that everything is working and therefore everything is OK. It’s just that we have one test that needs to be made to work … and then a change to special test-oriented construction of some kind, to be figured out.
Once more, we progress toward a better separation of client and server, but we are also a step closer to dealing with the grouping issue. My guess right now is that we’ll add different kinds of objects, each with its own scent, and we’ll mix the scents by kind, rather than have multiple possible scents per object. The scent idea is already weird enough.
As is yours truly. See you next time!