A Tiny Spike
I want to try an idea that I have about the client-server aspect of our little program. … wanders off mumbling …
Copied from the project Slack:
As for bringing our code more in line with C/S, my own answer is, I think, a bit odd and therefore, I think, interesting.
As things stand, the Bot sends messages to the world saying to do things: move me, scan for me, take, drop. And the world, sends messages to the Bot when it wants the bot to know things: here is your location, here is this thing you took. Neither side is like result = self.world.move(), it’s just self.world.move() and if you look at your location, it will have changed.
I think that is not anything like the standard client setup, where everyone everywhere knows that what is going to happen is a request is going to be sent, like response=requests.get(url), and so everyone thinks in terms of sucking information out of the response contents. As we presently have it, the two objects Bot and World both act a lot like Agents, as if they were running on separate threads and could send messages to each other.
That’s nice, by my lights, though I am prepared to be talked down from that position. Of course, they are not currently in separate threads, and in Python really couldn’t be unless we want to be weird, so there are constraints on when you can look for a result. (I think scan is the odd one out just now, but that’s OK, we will fix it when we actually use it.)
So what I propose to try today is to build a Bot-side world façade kind of thing that buffers what is sent to it, [sends it to another façade that] then unwinds it into a series of calls on World. And the reverse.
I’ll create a new test file and work in it.
class TestCSSpike:
def test_hookup(self):
assert 2 + 1 == 4
Test fails, so I’m sure my tests are being run. I’ve seen tests get lost, like tears in the rain.
So, to inform you and settle my mind, I in mind that the finished client-server picture will look something like this:
World <-> [Entity Façade] <-> [Magical Network Stuff] <-> [World Façade] <-> Bot
During one Bot cycle, the World Façade (I’m going to get tired of typing that ‘ç’ real soon now) will receive the messages that our Bots send to the world and will buffer them up in some form so that when all the Bots have made their requests, the WF will issue a request to the server. The server’s response will consist of some JSON or something, which the WF will unpack and send the various messages to the correct Bots, before running the Bot cycle again.
And, if that works, then clearly the same thing can work in the other direction, leaving both sides with the impression that they are sending messages demanding service and that they get messages sent back.
But Ron? Is that really better?
Honestly, I do not really know if it’s better. The way the Bots and World work now is by sending back and forth and I like it. Whether my colleagues on the project also like it, I do not yet know: we haven’t really talked about the C/S thing much at all. Anyway I am here to spike this idea.
What we clearly cannot do (readily) is have each Bot call behave as if it can immediately receive a result. We can’t realistically code like this:
self.new_location = World.move(self)
Why not? Because that would either require that every single Bot action be a client request (why would that be so bad though?) or would require that we invent some scheme where multiple and various parts of our code somehow get suspended until later. I don’t begin to know how to do that.
Anyway, I’m here today to work on packing up requests and unpacking them.
I’ll try to sketch a test:
def test_simple_scenario(self):
target = FakeBot()
wf = WorldFacade(target)
wf.take()
wf.move()
wf.unwind()
assert target.inventory == "entity"
assert target.location == (10, 10)
I freely grant that I’m not sure quite what I want. I’ll try things and see where they lead. Here, unwind
sends the messages to the target, but with results:
class WorldFacade:
def __init__(self, target):
self.target = target
self.actions = []
def take(self):
self.actions.append("take")
def move(self):
self.actions.append("move")
def unwind(self):
for action in self.actions:
match action:
case "take":
self.target.take('entity')
case "move":
self.target.move((10, 10))
And, trivially:
class FakeBot:
def __init__(self):
self.inventory = None
self.location = None
def take(self, item):
self.inventory = item
def move(self, location):
self.location = location
Now that’s not quite what we would see in the real situation. Unwind would send those messages over to World, with an associated entity id, and World would send different messages back, with results. More like this:
def unwind(self):
"""
The network stuff is in here. We would send these messages to World,
and it would send others back, through its own Facade thing.
"""
for action in self.actions:
match action:
case "take":
self.target.receive('entity')
case "move":
self.target.set_location((10, 10))
class FakeBot:
def __init__(self):
self.inventory = None
self.location = None
def receive(self, item):
self.inventory = item
def set_location(self, location):
self.location = location
That’s a bit more like what I have in mind … at least the sketch scenario shows that take
’s response is to send receive
and move
’s is to send set_location
.
So the WF object implements methods like move
and take
, saves them up somehow, and then can in principle package those things up and JSON them over to a peer in the server, who will unpack them and send them to the World just as if the Bot was right there.
I very much need at least one other brain applied to this thinking. Why? Well, because I’m thinking that the World, at least probably wouldn’t mind returning a value. Let’s review World a bit.
class World:
def take_forward(self, bot: Bot):
take_location = self.bots_next_location(bot)
if take_location == bot.location:
return
entity = self.map.entity_at(take_location.x, take_location.y)
if self.can_take_entity(entity):
self.map.remove(entity.id)
bot.receive(entity)
That’s the method that corresponds to take
in my test. What would it look like if it were written to do a return?
class World:
def take_forward(self, bot: Bot):
take_location = self.bots_next_location(bot)
if take_location == bot.location:
return None
entity = self.map.entity_at(take_location.x, take_location.y)
if self.can_take_entity(entity):
self.map.remove(entity.id)
return entity
That would be OK and it might make things much easier for the facade on this side, which could send messages and immediately add to the result. Maybe six a dozen of one, half of another, I’m not sure.
How about step
, which handles a move
?
class World:
def step(self, bot):
location = self.bots_next_location(bot)
self.map.attempt_move(bot.id, location)
bot.vision = self.map.create_vision(bot.location)
That leads me to:
class Map:
def attempt_move(self, id, location: Location):
entity = self.contents[id]
if self.location_is_valid(location):
entity.location = location
THat amounts to a set_location
in the scenario. Here again, we could return readily enough, if it were sensible.
I think the more critical issue is to come up with ways that the client side can be written so as not to force us to interact over the Internet every time a bot looks around, but that, instead, allows a fairly simple way to write clients who do batch all the actions of all their entities for each “turn cycle”.
I guess, in a sense, it may not matter. If there is a way to send all the Bots’ requests in a batch, clearly it is possible to send a batch size of one bot taking one action. Whether that results in terrible slowness for a client that does that is up to the client. The World server will probably need to do something to balance the impact of all the active clients, perhaps as simple as a FIFO queue or perhaps something that tries to give every client the same number of accesses over time.
Anyway, I have one tiny test and have had some exchanges with my colleagues, and that is enough for now. I think I need to think more about my own idea, and to learn more about realistic alternatives.
… wanders off mumbling …