Client-Server Prep
Not a high school, no, just getting ready for client/server in our World. Much design thinking, just a bit of code, a small step in what seems like a decent direction.
Here’s one version of yesterday’s list of things one might do:
- Devise an object representing a batch of commands;
- Build a structure on the client side calling for a batch, probably a Cohort object;
- Devise a socket-oriented message format, simpler than the one in the tutorial;
- Use that format, JSONing and deJSONing it, between the Bot/Cohort side and the World side;
- Maybe, just maybe, do a localhost C/S connection just to prove, I don’t know, something.
The Shape of the Problem
As mentioned back then, we don’t have to do things in that order. This morning I’m thinking of the surface of something roughly shaped like a ball. A region in space. Maybe not even convex. Maybe even with holes in it, like a donut. Probably fuzzy around the surface, but in there somewhere is the solution to the problem (or problems) were trying to solve.
Any problem that we are capable of solving, I think, must be capable of being broken down into sub-problems. We’re just not made such that we can solve a big problem all at once, even if coming up with a solution does require some major insight. We will have to go through solving all the little problems that make up the big one.
So as we look more carefully at our problem space, we see lot of smaller problems floating around in there. In principle, we can take any one, solve it, and make the whole situation simpler, rinse, repeat until done. In practice, the sub-problems may or may not really be part of the big problem’s solution. In particular, our overall solution may not require that particular sub-solution at all, or may require it in a much different form.
Nonetheless, our job as developers comes down, sooner or later, to picking a sub-problem to solve, solving it, and continuing that process until we’re done … except that in a living product, we’re never done. Was it DaVinci who said that software is never done, only abandoned? I don’t recall.
Anyway, our job is to look at a writhing mass of problem and pull out a wiggling bit of it and solve it. Sometimes that little solution is just right; often it is nearly good enough and we modify it to be just right; sometimes it’s not needed after all, and we thank it for its service and remove it from the repo.
My current rough idea for the client-server bit is that we’ll have a layer just above the socket that assembles and returns a complete object, a “command list”, consisting of commands, what else, where each one has an identifier of an entity and a list of operations, where each operation is a list with an action and zero or more parameters. We’ve already seen these action things, ‘step’, ‘turn “EAST”’, and so on.
We have those actions now, but they’re handled in a fashion that I think we need to change:
class Bot:
def do_something(self, connection):
actions = []
actions += self.update_for_state_machine()
self.state = self.state.update(self._knowledge)
actions += self.state.action(self._knowledge)
if random.random() < self.direction_change_chance:
actions += self.change_direction()
actions += ['step']
self.perform_actions(actions, connection)
def perform_actions(self, actions, connection):
for action in actions:
match action:
case 'take':
connection.take(self)
case 'drop':
connection.drop(self, self.holding)
case 'step':
self._old_location = self.location
connection.step(self)
case 'NORTH' | 'EAST' | 'SOUTH' | 'WEST':
connection.set_direction(self, action)
case _:
assert 0, f'no case {action}'
We create a list of actions, but then we call the connection once for each action. The connection treats them all separately:
class DirectConnection:
def set_direction(self, bot, direction_string):
self.world.command('turn', bot.id, direction_string)
self.update_client(bot)
def step(self, bot):
self.world.command('step', bot.id)
self.update_client(bot)
...
And so on. What we want, according to my theories, is for the connection to send across and get a result for, not one command, not even one bot’s list of commands, but all the commands for all the bots. We can always make a shorter call if we choose to, but because of network latency, we’ve made the decision to send a batch of commands, a command list, rather than single commands. A single action request is still possible: it’ll just be a list with one command in it, consisting of a single action.
- Concern
- I have a moment of concern about tests. If there are a lot of tests that focus on single actions, they might need changing, and that would be tedious and irritating. A quick look around makes me think that our separation of client and server concerns has eliminated most, if not all, such tests. If we haven’t, well, it’s probably time to find them and do the right thing, whatever we deem that to be.
In the course of digging into the above concern, I find two suites of tests that are kind of interesting. First one is this:
class TestWorldInputFactory:
def test_building(self):
world = World(5,5)
bot_id = world.add_bot(5, 5)
block_id = world.add_block(7,8)
world_input = InputBuilder(world) \
.request(bot_id) \
.action('take') \
.action('turn','SOUTH') \
.action('step') \
.action('step') \
.action('drop', block_id) \
.request(world.add_bot(7, 7)) \
.action('step') \
.action('step') \
.result()
assert len(world_input._requests) == 2
I vaguely recall doing this (and the other suite, which we’ll probably look at below, depending how this goes).
The idea here was a neat little syntax for defining a bot’s program: we start with her ID and then give her a series of actions. We can even start another request for another bot. The operational code is this:
class InputBuilder:
def __init__(self, world):
from test_world_batch import WorldInput
self._world = world
self._world_input = WorldInput()
self._current_request = None
def request(self, identifier):
from test_world_batch import EntityRequest
self._current_request = EntityRequest(identifier, self._world)
self._world_input.add_request(self._current_request)
return self
def action(self, action_word, parameter=None):
from test_world_batch import EntityAction
operation = EntityAction(action_word, parameter)
self._current_request.add_action(operation)
return self
def result(self):
return self._world_input
We see that this object is building a WorldInput
object, which is defined in the other test file that I discovered.
- Note
- I’m writing this as if I had just discovered these tests and test objects in legacy code that I inherited, as if I have no recollection of them. That lack of recollection is closer to true than I’d care to admit. Since I did these objects in a couple of days in mid-October, a lot has gone on, including code cleanup and a lot of client-server learning.
-
The exercise with InputBuilder and WorldInput was an experiment to see if we could create a useful factory kind of object for building up command lists. The good news is that the example above shows that we can do that. The bad news is that the code as written seems to be focused on building a command list on the world side. I now suspect that the World will be quite happy just to receive a list of commands and to tick through it. It’s more on the client side that building up a list of commands will be needed.
-
Still, it’s a neat idea. Let’s see how it might work in the light of today’s problem: client sends batch of commands to server and receives batch of answers back.
Plucking at one area of the big cloud of problem, we think about the Cohort, the collection of Bot objects that a client manages. (Other client builders can do it some other way if they wish. This is our Python client. But they’ll have the same problem.) Our Cohort object amounts to little more than a collection of Bot instances. When it is time to send commands to the world, the Cohort will get a message, do_something
or the equivalent, and Cohort will send do_something
to each Bot, and will expect to get a list of commands out of the deal.
So let’s imagine a WorldInput object that is essentially the nested list we spoke up up at the beginning. And the Cohort’s job is to build that object and pas it to the Connection, which will transmit it in JSON to the server. So the Cohort can create an InputBuilder like the one above, and pass it to each Bot where we now pass the connection.
Let’s review that Bot code again: we might actually be in good shape:
class Bot:
def do_something(self, builder):
actions = []
actions += self.update_for_state_machine()
self.state = self.state.update(self._knowledge)
actions += self.state.action(self._knowledge)
if random.random() < self.direction_change_chance:
actions += self.change_direction()
actions += ['step']
self.perform_actions(actions, builder)
def perform_actions(self, actions, builder):
for action in actions:
match action:
case 'take':
builder.take(self)
case 'drop':
builder.drop(self, self.holding)
case 'step':
self._old_location = self.location
builder.step(self)
case 'NORTH' | 'EAST' | 'SOUTH' | 'WEST':
builder.set_direction(self, action)
case _:
assert 0, f'no case {action}'
I’ve taken the liberty of changing the parameter name above to builder
just to help us focus.
That wouldn’t be quite right, but it would be close. If we wanted it to check validity as it does in the match
statement, we might see something like this:
def perform_actions(self, actions, builder):
for action in actions:
match action:
case 'take':
builder.action('take')
case 'drop':
builder.action('drop', self.holding)
case 'step':
self._old_location = self.location
builder.action('step')
case 'NORTH' | 'EAST' | 'SOUTH' | 'WEST':
builder.action('set_direction', action)
case _:
assert 0, f'no case {action}'
I have two simultaneous feelings going on about this. On the one hand, I feel strongly that something like the above will allow us to build up the lists we need. And on the other hand, I don’t as yet see small steps that will get us there.
As things stand now, the (Direct) Connection sends individual actions to the World, each one with a Bot ID and whatever other parameters may be needed.
I’m trying to imagine a series of small steps. What if we had a new method on DirectConnection just for now, that accepts a list of actions, like the one we have when we call perform_actions
, and that method just calls the individual DirectConnection methods that we call now from Bot?
Presently, all the DC operations accept a Bot as a parameter and all of them just use its id. We might want to change all those to pass the id directly. That could be a lot of changes. Judging from the hints in PyCharm, there are only three or four usages per method. Let’s just change it to expect the id and fix what breaks.
Ah, not so fast mein Herr, the methods in DC refer to the bot as well as its id, such as:
class DirectConnection:
def step(self, bot):
self.world.command('step', bot.id)
self.update_client(bot)
So we have identified a new sub-problem: when information comes back from the server, it will refer to our Bot instances by the ID which it assigned when they were created. Our client-side code will have to be able to fetch our Bot instance using its ID, so as to give it the updated Knowledge.
- Reaction
- Too many notes. Everything I’ve plucked at so far has too many connections to other things. I’m not seeing—as yet—a small step to take toward a solution.
OK. Another possibility: suppose we build the server-side code that accepts the list of lists that we want for a command batch. By their nature, those lists can be used to deal with a single step of a single bot if we so wish, so we should be able to mate up the lists to the DC on its side.
- Related:
-
If DC knew we were starting a new command sequence, it could initialize its own little map from ID to bot. In fact, a bit of garbage collection aside, it need not even init the list: if a new bot happens to get the same ID during testing, DC would overwrite the old one with the new when the new one arrives.
-
But no. We really don’t want DC to know the Bots. Just not the job of a connection to know that much domain stuff. the connection needs some Cohort-like object to talk to, that it is given when it’s created.
- The Light Begins to Dawn
-
Wait! I think I see a possibility. Here’s all there is to DC:
class DirectConnection:
def __init__(self, world):
self.world = world
def add_bot(self, x, y, direction=Direction.EAST):
bot_id = self.world.add_bot(x, y, direction)
result_dict = self.world.fetch(bot_id)
client_bot = Bot(x, y)
client_bot._knowledge.update(result_dict)
return client_bot
def set_direction(self, client_bot, direction_string):
self.world.command('turn', client_bot.id, direction_string)
self.update_client(client_bot)
def update_client(self, client_bot):
result_dict = self.world.fetch(client_bot.id)
client_bot._knowledge.update(result_dict)
def step(self, client_bot):
self.world.command('step', client_bot.id)
self.update_client(client_bot)
def take(self, client_bot):
self.world.command('take', client_bot.id)
self.update_client(client_bot)
def drop(self, client_bot, block_id):
self.world.command('drop', client_bot.id, block_id)
self.update_client(client_bot)
Each of the actions takes a parameter currently called client_bot
. Suppose that, instead, we pass in a Cohort, and a client_bot_id. And suppose that the update_client
is changed to send update_client
to the Cohort, including the ID along with the dictionary. (Is the ID already in the dictionary? If not, should it be? I think it is.) So it only needs to send the dictionary back to the Cohort, which can update the bot.
OK, we can almost do this. We’ll do one command at a time. Step seems easy.
I kind of wish I could have two versions of the method, one with the new Cohort and one not. There are ways to do that in Python but none that appeal to me just now. Let’s create a new method in DC and then call it:
class DirectConnection:
def new_step(self, cohort, client_bot_id):
self.world.command('step', client_bot_id)
result_dict = self.world.fetch(client_bot_id)
cohort.update(result_dict)
Now can I create a trivial object that allows the step action to call this new method? This has to change:
class Bot:
...
case 'step':
self._old_location = self.location
connection.step(self)
...
Let’s try this:
case 'step':
self._old_location = self.location
cohort = Cohort(self)
connection.new_step(cohort, self.id)
We have no Cohort, so the tests just went red. No surprise there. But this much should work:
class Cohort:
def __init__(self, bot):
self._bot = bot
def update(self, result_dict):
self._bot._knowledge.update(result_dict)
With that in place, new_step
works. Commit this? I think yes: it works. Kind of weird but we’re about to fix that. Commit: temporary method new_step.
Now remove step and replace with new_step, to find the tests needing updating:
def test_step(self):
world = World(20, 20)
connection = DirectConnection(world)
bot = connection.add_bot(10, 10)
connection.step(bot)
assert bot.location == Location(11, 10)
assert world.map.at_xy(11, 10).id == bot.id
def test_step_north(self):
world = World(10, 10)
connection = DirectConnection(world)
client_bot = connection.add_bot(5, 5, Direction.NORTH)
location = client_bot.location
connection.step(client_bot)
assert client_bot.location == location + Direction.NORTH
I’ll make the change but it raises a concern. This should get us to green:
def test_step(self):
world = World(20, 20)
connection = DirectConnection(world)
bot = connection.add_bot(10, 10)
connection.step(Cohort(bot), bot.id)
assert bot.location == Location(11, 10)
assert world.map.at_xy(11, 10).id == bot.id
def test_step_north(self):
world = World(10, 10)
connection = DirectConnection(world)
client_bot = connection.add_bot(5, 5, Direction.NORTH)
location = client_bot.location
connection.step(Cohort(client_bot), client_bot.id)
assert client_bot.location == location + Direction.NORTH
Green, new_step
is step
and the old one is gone. Commit: step now uses a Cohort.
The concern is this: the Cohort class is rather limited, to say the least. We will ultimately need a cohort that at least includes lots of Bot instances, and that responds to do_something
and collects responses from all the Bots it holds. Will we be able to evolve from here to there? Perhaps, perhaps not. If not, we can rename this class to indicate its limited purpose and use it only in tests of single commands. SingleBotCohort or something like that.
I think we can set that concern aside. It is still a potential problem, but it doesn’t seem to be a deal breaker.
It’s time to stop. I’m two hours in, and that’s enough programming and writing for the morning. Let’s sum up.
Summary
We just did a tiny bit of code, one mini object and a small change to one operational method, plus a few related changes. But it’s rather significant.
Why is it significant?
It’s significant because, after thinking all around the problem, we cam up with a very small step that moves us in at least roughly the right direction, creating a trivial class that stands roughly where we want it to stand, and supporting at least part of what we need it to do. We put it in without breaking many tests, and those only for moments.
It seems to me that it was a near perfect example of small steps.
Remind me what we actually did?
The one method in DirectConnection, step
, has been changed so that it no longer knows the Bot instances, but instead is passed a new object, a Cohort, that can accept knowledge and apply it. In principle, the Cohort decodes the knowledge and uses its contained ID to access the correct Bot. In practice right now, it just has one Bot and it passes the knowledge on. But the Cohort can be extended to know many Bots and to accept a list of knowledge instances to distribute. I feel confident that we can sort that out.
As things stand, the DirectConnection is passive: we call do_something
only from tests (and from the Game). I think that when all is said and done, when a connection can be written to from the client side, we’ll want to trigger do_something
. I’m not certain of that, and for now, I think it doesn’t matter. We’ll find out if and when we get closer to a separate server from the client. We’ll probably do that, if the masses clamor for it.
This pleases me.
What we’ve done this morning is hardly more than a Spike, except that I deem it to be righteous enough to be retained as a basis for further work. It is quite clear to me that we can do the same cohort trick to the other actions. It is less clear but I am rather confident that we can arrange to have the Cohort do the sending to DC, which may turn out to be about what we want. I deem this a very weak bridge across the chasm, but I think it connects up just about as we’ll want it to be … or that we’ll be able to move the ends as we need to.
As such, I think it is a small step in roughly the right direction, and that is all we ever set out to do.
We’ll do more, next time. See you then!