Dungeon 296
What have I learned in this Dungeon Crawl? What would I do differently? I’ve decided to find out.
I’m psyched for a new idea this morning, and have deferred my morning ablutions to get to the keyboard. I promise to ablute before anyone has to get too close to me.
Ward Cunningham defined technical debt as the difference between the design we have, and the design we now understand that we could have, unless I misunderstood him. Be that as it may, while I improve and refactor the D2 design, I often wonder what I’d do if I were to do it over.
In real life, we rarely get a chance to do a product over, although my experience suggests that we often wish we could, when the going gets just too tough in the product we have. My experience also suggests that we should never accept such a chance, at least not while the old product exists, because we can never catch up with it1.
But I do wonder. Given all that I’ve done, and all that I’ve learned, all the mistakes that I’ve made and discovered, all the ones that I may not have discovered, what would I do if given the opportunity to write a program like this again.
I was thinking about that last night, and I produced a weird drawing of my thoughts, and some text. Here’s the drawing:
I actually dictated the text into Notes, because I was sitting in our TV room and there’s no keyboard on the iPad in that room. The dictation worked nearly well. Here’s the result, only lightly edited:
So there are these things things might be player monster treasure anything that’s in the dungeon. Things have coordinates coordinates have no deep meaning they might be XY they might be XYZ. Are each thing can respond to any number of events. Events have names and parameters. There’s a Clock that sends a tick event on summer cycle. The buttons that control the player simply send a move-by event containing a vector in the direction that the player wants to move. 00 things in the dungeon see that event and if the coordinate resulting is their coordinate, they can respond with an indicator saying whether they will accept the move, And whether they wish to undertake action with the creature moving in on them. Moving things such as monsters, may choose to move on a clock event. They will have some kind of pluggable strategy to choose the move they want to make. Like the player they will trigger a move by event and other things will respond as to whether or not they can make the move. A chest might decide to open when it is moved upon by the player, While the treasure might decide to give itself to the player when it is moved upon. My intuition is that the scheme will allow a full dungeon game to be played in it without any consideration of geometry, tiles, or entities of any kind. Then clearly enough, graphics can be supplied to draw tiles, treasures, things, anything that may be there. This seems so simple I have to try it.
I’ll leave that fairly messy, like my thoughts. In what follows, I’ll try to be more clear, but recognize that you’re watching the creation of a new program based on a couple of hours of thought (and about a year and a half of experience with the previous program (and six decades of general programming experience)).
D3 - The New Program
Of course we’ll want CodeaUnit, so I build a new Codea project accordingly. I’m calling it D3ngeon. I’ll also set up WorkingCopy so that I can version the program with Git.
I’ve decided to save the original Tests tab, trimmed down, to use as a template for new test suites that I’ll write as we go. That will reduce the pain of setting up rtests for a new class, and help keep me honest.
Now let’s get started.
- Events
- The fundamental theory of D3 is that it works by triggering events. The core idea came from Beth, who, when a few of us were blue-skying, suggested the idea. I think that ze thought it might not work; I think we all did. But I’ve decided to run with hir idea and my current thinking is that it’ll work, with the single possible concern of efficiency. But iPads are fast. We’ll see.
-
To avoid infinite recursion, the Events will need to be kept in a queue, to be named Q, with the rule being that a new Event is pulled from the queue only after the current Event has been processed by everything.
-
I anticipate that there may be a Clock Event that fires on some regular interval, to be used as needed to do animations or whatever may need regular updating. I will try to resist this anticipation until it’s actually needed.
- Thing
- Did I say every thing? Yes. The dungeon has Things in it. Things might be the player, the monsters, the chests, the treasures. Every thing receives every Event. (The default behavior of a Thing, given an Event, will be to do nothing.)
- Coordinate
- Each Thing will have a Coordinate. (I’m beginning to think that I might call this object Place.) We will think of this Coordinate as being the coordinates of the Tile or whatever piece of the world currently holds the Thing. I intend for Coordinate to be entirely abstract when seen from above.
- Step
- A Thing that moves can take a Step. A Step is a proposed move in any “direction”. A Coordinate can produce a set of Coordinates that are one Step away from it. (And perhaps other numbers of Steps.) A Coordinate plus a Step is another Coordinate. (Yes, a Step is like a vector.)
- Moving
- When a Thing wants to move, it will choose a Step that it would like to make. Somewhere—I don’t see with that much clarity from here—that Step will be converted to a Coordinate, and an Event will announce that this Thing wants to move to that Coordinate. Things already on that Coordinate can do two things. They can indicate that they refuse the entry or are indifferent to it. And they can, of course, enqueue another Event. I anticipate that when the Player tries to move onto a Monster, the Monster will refuse entry and initiate combat. I emphasize “anticipate”. I’m not at all in a position to say just how that might happen. We’re just sketching here.
That’s about all the design I’ve got. I’m not sure quite how we’re going to produce all this, in what order. The idea will be to get something credibly working, at least under test, this morning.
Let’s get down to cases. I’m really interested to find out how we test something that is running solely on Events.
First Test
I think we’ll start with the operation of the queue. It’s central. In so doing, we’ll need some Things and some Events. It’s not easy to see how to do this wit single-class micro-tests, so I won’t. I’ll do a tab called oh Scenarios and code up a little test.
-- Scenarios
-- RJ 20200502
function testScenarios()
_:describe("Scenarios", function()
_:before(function()
end)
_:after(function()
end)
_:test("Create Things", function()
end)
end)
end
Not much here. Fill in some work:
local response
local Sender = class()
local Receiver = class()
function testScenarios()
_:describe("Scenarios", function()
_:before(function()
end)
_:after(function()
end)
_:test("Create Things", function()
local sender = Sender()
local receiver = Receiver()
sender:act()
_:expect(response).is("I saw that!")
end)
end)
end
This is enough to produce this error, as I intended.
1: Create Things -- Scenarios:21:
attempt to call a nil value (method 'act')
Test name isn’t very good. Let’s do some more work.
_:test("pub-sub works", function()
local sender = Sender()
local receiver = Receiver()
sender:act()
_:expect(response).is("I saw that!")
end)
function Sender:act()
Q:publish("hello", self)
end
This will drive out Q:
1: pub-sub works -- Scenarios:8:
attempt to index a nil value (global 'Q')
OK, let’s do that as a new class. I’ll build it right here for now, maybe move it to a new tab when it grows up.
local Queue = class()
local Q
function testScenarios()
_:describe("Scenarios", function()
_:before(function()
Q = Queue()
end)
_:after(function()
end)
_:test("pub-sub works", function()
local sender = Sender()
local receiver = Receiver()
sender:act()
_:expect(response).is("I saw that!")
end)
I reckon we’ll fail now on publish. I am surprised to get this error:
1: pub-sub works -- Scenarios:8:
attempt to index a nil value (global 'Q')
Oh. I have to put Queue and Q ahead of the other classes. Lua is picky about those live calls to class(). That gets me what I expect:
1: pub-sub works -- Scenarios:11:
attempt to call a nil value (method 'publish')
OK, we’d best implement publish and see the test fail.
function Queue:publish(event,...)
end
This should fail the test, finding a nil response.
1: pub-sub works -- Actual: nil, Expected: I saw that!
OK, let’s do some work. The not interesting part is making an actual queue of events. The somewhat more interesting part will be finding all the Things that need to be subscribed. For now … I have an idea. One thing at a time though.
I get this far and encounter an issue:
function Queue:init()
self.events = {}
end
function Queue:publish(event, sender,...)
local entry = { event, sender, ... }
table.insert(self.events, entry)
end
Note that all I’m doing in publish
is to pack up the input to publish
and put it into the events
table. Maybe we need an object there, maybe we don’t. But I want the rules to be that publish
cannot recur. If we’re in the midst of a publish, publishes done during that time must enqueue, not immediately start a recursive publish.
I think I’ll defer that concern and write a test for it. For now, what I need is this:
function Queue:publish(event, sender,...)
local entry = { event, sender, ... }
table.insert(self.events, entry)
local toSend = table.remove(self.events,1)
for i,thing in ipairs(self.things) do
thing[toSend[1]]()
end
end
This is awkward, but I think that if we had any things, we’d call them. I’ll let this test drive out some more code.
1: pub-sub works -- attempt to index a nil value
I’m not sure what this is, suspect it’s that self.things
isn’t a table. With this change:
function Queue:init()
self.events = {}
self.things = {}
end
We’re back to our former error:
1: pub-sub works -- Actual: nil, Expected: I saw that!
Let’s add our Receiver to things:
_:test("pub-sub works", function()
local sender = Sender()
local receiver = Receiver()
Q:subscribe(receiver) -- <===
sender:act()
_:expect(response).is("I saw that!")
end)
That will error missing subscribe:
1: pub-sub works -- Scenarios:46:
attempt to call a nil value (method 'subscribe')
And …
function Queue:subscribe(receiver)
table.insert(self.things, receiver)
end
Now I expect that we’ll get a message saying that our receiver does not understand “hello”. That message may not be too clear, showing up as an attempt to call a nil. We’ll see:
1: pub-sub works -- Scenarios:16:
attempt to call a nil value (field '?')
Let’s improve publish a bit:
function Queue:publish(event, sender,...)
local entry = { event, sender, ... }
table.insert(self.events, entry)
local toSend = table.remove(self.events,1)
for i,thing in ipairs(self.things) do
local method = thing[toSend]
assert(method, "Receiver does not support method '"..method..".")
thing[method]()
end
end
Now I expect that error. I’m wrong. The check isn’t quite right. Try this:
function Queue:publish(event, sender,...)
local entry = { event, sender, ... }
table.insert(self.events, entry)
local toSend = table.remove(self.events,1)
for i,thing in ipairs(self.things) do
local methodName = toSend[1]
local method = thing[methodName]
assert(method, "Receiver does not support method '"..methodName..".")
thing[method]()
end
end
It’s becoming clear that we so need an object to hold an Event. Test. This time I get what I was aiming for:
1: pub-sub works -- Scenarios:18: Receiver does not support method 'hello.
Forgot the closing tick. Fix that and give Receiver a hello
method:
function Receiver:hello()
response = "I saw that!"
end
I expect my test to run. And it would if I had done the calling part right.
function Queue:publish(event, sender,...)
local entry = { event, sender, ... }
table.insert(self.events, entry)
local toSend = table.remove(self.events,1)
for i,thing in ipairs(self.things) do
local methodName = toSend[1]
local method = thing[methodName]
assert(method, "Receiver does not support method '"..methodName.."'.")
method(thing)
end
end
Test runs. Commit: Initial pub-sub test works.
Time to reflect.
Reflection
OK, we have a couple of things wired up through publish-subscribe. What we do not have is:
- enqueue publishes until existing publish is complete
- provide some easy automatic way to track subscribers.
As for the first case, I envision that the way the program will work is that it will sit idle (except perhaps for a clock tick TBD) until the user hits a movement key or button, which will publish an attempted move in some form yet to be described, which will be published to the monsters and such, some of whom may publish other messages, and down finally to some “Thing” that will tell the monsters that it is their turn to move.
As I write that, I foresee a problem. Imagine two monsters adjacent to the same Coordinate. Suppose, for reasons of their own, each monster wants to move to that coordinate. In turn, each one will publish a proposed move and the other things in the Dungeon will see the proposal. None of them has that coordinate, so no one will object. Is it possible that we’ll move two monsters to the same Coordinate? I guess it’ll depend on how and when the move is done … the tangled webs we weave when we try to do anything with events. We’ll see.
What I’d like to have happen is for every Thing to automatically add itself upon creation to the list of Things. (We’d also like it to remove itself upon its inevitable and tragic death, if that can happen.) Now, it is easy enough to put a line in the Thing:init
to do Q:addReceiver(self)
. But in Lua, if we write a subclass, it does not necessarily run the superclass’s init. We’ll have to be careful about that, however we do it. Of course it’ll be pretty obvious if a given Thing isn’t in the list, since it will never do anything.
To begin with, let’s deal with the queueing of publishes. I’ll try to write a test for that.
Pub-Sub Sequencing
I write this much of the next test:
_:test("pub is complete before next pub", function()
local sender = Sender()
local publisher = Publisher()
Q:subscribe(sender)
Q:subscribe(publisher)
sender:act()
end)
This makes me recognize something that I haven’t thought through. Since I envision all things being subscribed to the Q, the sender is going to get called on its own publications. That’s scary. We don’t want a recursion.
I’m going to start my hierarchy now, and we’ll just play along and see what happens. I think it’ll sort out.
This test will fail for want of Publisher:
2: pub is complete before next pub -- Scenarios:64:
attempt to call a nil value (global 'Publisher')
I’m going to send the same hello message, so might as well add that to the test after implementing Publisher:
local Publisher = class()
function Publisher:hello()
Q:publishe("greetings")
end
~~~lua
_:test("pub is complete before next pub", function()
local sender = Sender()
local publisher = Publisher()
Q:subscribe(sender)
Q:subscribe(publisher)
sender:act()
end)
Someone’s going to barf due to not knowing “greetings”, I reckon. I could be wrong. And I am:
2: pub is complete before next pub -- Scenarios:20: Receiver does not support method 'hello'.
function Sender:hello()
end
And now …
2: pub is complete before next pub -- Scenarios:49:
attempt to call a nil value (method 'publishe')
Nice spelling. I should use the modern, not the medieval spelling:
function Publisher:hello()
Q:publish("greetings")
end
2: pub is complete before next pub -- Scenarios:20: Receiver does not support method 'greetings'.
Of course neither does. Give them both one.
function Sender:greetings()
end
function Publisher:greetings()
-- ignored
end
OK, now I think this test runs. Of course it has no assertions yet. But it does run, which at least means that I’ve avoided recursion so far. Now to test the timing.
What we want to ensure is that sender
does not receive the “greeting” message until after its publish has completed. (It occurs to me that I really don’t know how to make this work. But I think I can test it like this. First, in Sender:greeting()
, I’ll save a value in Sender:
function Sender:greetings()
self.called = true
end
Then in Sender:act()
, we’ll set an expectation:
function Sender:act()
self.called = false
Q:publish("hello", self)
_:expect(self.called).is(false)
end
I think this should fail.
2: pub is complete before next pub -- Actual: true, Expected: false
Sweet, we have a test. Now how the heck are we going to make this work? Let’s reflect on how it fails.
We publish “hello”. We begin the loop over all receivers, sending them “hello”. One of them responds with a publish of “greetings”. That re-enters the publish
method, recursively, triggering a loop over all the receivers, sending them “greetings”, right in the middle of the loop over receivers waiting to hear “hello”.
I think this test might not fail if i reverse the order of receivers in the test. Let me try that. No. Fortunately it still fails, because they are ALL called while we are still in the act
method. That’s good.
I see this problem as having two parts. First, if we are in the process of publishing a message, we don’t want to start publishing another: we just want to put it in the queue. Second, after doing the publish, we should then check the queue to see if it has an elements, and if it does, do another publish.
So it seems like we need to set an internal state saying that we’re publishing. Let’s first refactor the current publish
method:
function Queue:publish(event, sender,...)
local entry = { event, sender, ... }
table.insert(self.events, entry)
local toSend = table.remove(self.events,1)
for i,thing in ipairs(self.things) do
local methodName = toSend[1]
local method = thing[methodName]
assert(method, "Receiver does not support method '"..methodName.."'.")
method(thing)
end
end
Thus:
function Queue:publish(event, sender,...)
self:enqueue(event, sender, ...)
self:dequeue()
end
function Queue:enqueue(event, sender, ...)
local entry = { event, sender, ... }
table.insert(self.events, entry)
end
function Queue:dequeue()
local toSend = table.remove(self.events,1)
for i,thing in ipairs(self.things) do
local methodName = toSend[1]
local method = thing[methodName]
assert(method, "Receiver does not support method '"..methodName.."'.")
method(thing)
end
end
I’m finding this tricky. Would be even without some local interruptions from cats and wives. Let’s try something simple, setting a flag and clearing it in dequeue
.
As soon as I write this, I see that it won’t work:
function Queue:publish(event, sender,...)
self:enqueue(event, sender, ...)
if not self.publishing then
self:dequeue()
end
end
function Queue:dequeue()
self.publishing = true
local toSend = table.remove(self.events,1)
for i,thing in ipairs(self.things) do
local methodName = toSend[1]
local method = thing[methodName]
assert(method, "Receiver does not support method '"..methodName.."'.")
method(thing)
end
self.publishing = false
end
Now when we do our first publish, the second call will get enqueued, but I think it’ll never get dequeued. We can test this. First run the test, see if it passes now. I think it does. Yes. But extend it:
_:test("pub is complete before next pub", function()
local sender = Sender()
local publisher = Publisher()
Q:subscribe(publisher)
Q:subscribe(sender)
sender:act()
_:expect(Q:length()).is(0)
end)
And
function Queue:length()
return #self.events
end
I expect this to fail with 1 not zero.
2: pub is complete before next pub -- Actual: 1, Expected: 0
We didn’t spill the queue after we were done.
Would it suffice to dequeue twice in publish
? No, I think we need to dequeue one thing at a time until the queue is empty.
What about this:
function Queue:dequeue()
if self.publishing then return end
while #self.events > 0 do
self.publishing = true
local toSend = table.remove(self.events,1)
for i,thing in ipairs(self.things) do
local methodName = toSend[1]
local method = thing[methodName]
assert(method, "Receiver does not support method '"..methodName.."'.")
method(thing)
end
self.publishing = false
end
end
If we’re publishing, we exit. Otherwise, while there are events, we set publishing, loop over the things for the next event, then check again.
I think I could move the flag further out. But the test doesn’t run:
2: pub is complete before next pub -- Actual: true, Expected: false
_:test("pub is complete before next pub", function()
local sender = Sender()
local publisher = Publisher()
Q:subscribe(publisher)
Q:subscribe(sender)
sender:act()
_:expect(Q:length()).is(0)
end)
function Sender:act()
self.called = false
Q:publish("hello", self)
_:expect(self.called).is(false)
end
I can make the test more clear thus:
function Sender:act()
self.called = false
Q:publish("hello", self)
_:expect(self.called, "called too soon").is(false)
end
Test.
2: pub is complete before next pub called too soon -- Actual: true, Expected: false
However, this might actually be OK. As implemented, we won’t return to the act
call until the queue is empty. But I think we are sure that everyone has had a chance to respond to “hello” before anyone sees “greetings”.
Let’s recast this test. Everyone is going to see both “hello” and “greetings”. What we want is for everyone to see “hello” before they see “greetings”. So let’s have them report when they see the messages.
_:test("pub is complete before next pub", function()
results = {}
local sender = Sender()
local publisher = Publisher()
Q:subscribe(publisher)
Q:subscribe(sender)
local expect = {"publisher hello", "sender hello", "publisher greetings", "sender greetings"}
sender:act()
_:expect(Q:length()).is(0)
for i,message in ipairs(expect) do
_:expect(results[i]).is(message)
end
end)
In the various hello
and greetings
methods, I have:
function Sender:greetings()
table.insert(results,"sender greetings")
end
function Sender:hello()
table.insert(results,"sender hello")
end
function Publisher:greetings()
table.insert(results,"publisher greetings")
end
function Publisher:hello()
table.insert(results,"publisher hello")
Q:publish("greetings")
end
The sequence of calls is as I require, all the hellos and then all the greetings
. I think we’re good.
I also think we can move the flag setting outward in dequeue
:
function Queue:dequeue()
if self.publishing then return end
self.publishing = true
while #self.events > 0 do
local toSend = table.remove(self.events,1)
for i,thing in ipairs(self.things) do
local methodName = toSend[1]
local method = thing[methodName]
assert(method, "Receiver does not support method '"..methodName.."'.")
method(thing)
end
end
self.publishing = false
end
Test again. Pass. Commit: A given Event is fully published before publishing any subsequent publish call.
OK. Reflect. This has been rather a long session already. I started about 0900 and it is 1150 now.
Reflection II
I believe at about an 0.8 level that this implementation of Queue is good enough, that it will always empty the queue, and that it will fully process a given event before starting any events enqueued during that processing.
It does make recursive calls to dequeue
, but they immediately return if we are processing. So I think we’re good.
We have some visible issues:
- Clearly we need something more structured than the table we’re putting in the queue;
- We still have to remember to subscribe each instance.
- Every instance must implement a method for anything that can be published.
We’ll address the queue item in a moment, but let’s talk about the latter two. My plan is that we have a convention:
An abstract class
Thing
will serve as the superclass for all things. When you decide to publish a new event, you must implement an empty method for that event, in Thing. Include your parameter list for documentation. Then, of course, implement a concrete method in any class that actually responds to that event.
Let’s sort out the event a bit. We’ll imagine that the event can have any calling sequence that it wants. The publish
method will expect only the message, which is required to be the name of a method in each subscribed Thing, and whatever other parameters the publisher sends.
I propose to make this work:
function Queue:publish(methodName,...)
local event = Event(methodName, ...)
self:enqueue(evend)
self:dequeue()
end
function Queue:enqueue(event)
table.insert(self.events, event)
end
function Queue:dequeue()
if self.publishing then return end
self.publishing = true
while #self.events > 0 do
local event = table.remove(self.events,1)
local methodName = event:methodName()
local args = event:args()
for i,thing in ipairs(self.things) do
local method = thing[methodName]
assert(method, "Receiver does not support method '"..methodName.."'.")
method(thing, table.unpack(args))
end
end
self.publishing = false
end
I’ve posited an Event class that contains methodName
and args
, where args
is a table that we’ll unpack to reproduce the calling arguments.
I should have written a test for this and I’ll do so now, before going any further. I knew what I wanted to do, so I typed it in sans test. Bad Ron, no biscuit.
_:test("pub-sub passes arguments", function()
local obj = Object()
Q:subscribe(obj)
obj:act()
end)
local Object = class()
function Object:act()
Q:publish("action", 1,2,3)
end
function Object:action(a1,a2, a3)
_:expect(a1).is(1)
_:expect(a2).is(2)
_:expect(a3).is(3)
end
I’ve probably forgotten something. Oh, right, the Event object. Test should find that. All the tests complain:
1: pub-sub works -- Scenarios:17:
attempt to call a nil value (global 'Event')
Let’s be having that class.
local Event = class()
function Event:init(methodName, ...)
self._methodName = methodName
self._args = {...}
end
function Event:args()
return self._args
end
function Event:methodName()
return self._methodName
end
Now test. Weird. All my tests fail except the new one.
1: pub-sub works -- Actual: nil, Expected: I saw that!
2: pub is complete before next pub -- Actual: nil, Expected: publisher hello
2: pub is complete before next pub -- Actual: nil, Expected: sender hello
2: pub is complete before next pub -- Actual: nil, Expected: publisher greetings
2: pub is complete before next pub -- Actual: nil, Expected: sender greetings
I’d better review my code, I don’t see what I’ve done wrong.
function Queue:publish(methodName,...)
local event = Event(methodName, ...)
self:enqueue(evend)
self:dequeue()
end
Possibly using the conventional spelling of event
would be helpful.
function Queue:publish(methodName,...)
local event = Event(methodName, ...)
self:enqueue(event)
self:dequeue()
end
Test. All the tests run, which is what I rather expected. Commit: Queue uses internal Event object to pass events in and out of the queue.
Code Review
I want to show you all the code in the current tab, “Scenarios”, because I’ve done something unusual.
-- Scenarios
-- RJ 20200502
local response
local results
local Event = class()
function Event:init(methodName, ...)
self._methodName = methodName
self._args = {...}
end
function Event:args()
return self._args
end
function Event:methodName()
return self._methodName
end
local Queue = class()
function Queue:init()
self.events = {}
self.things = {}
self.publishing = false
end
function Queue:publish(methodName,...)
local event = Event(methodName, ...)
self:enqueue(event)
self:dequeue()
end
function Queue:enqueue(event)
table.insert(self.events, event)
end
function Queue:dequeue()
if self.publishing then return end
self.publishing = true
while #self.events > 0 do
local event = table.remove(self.events,1)
local methodName = event:methodName()
local args = event:args()
for i,thing in ipairs(self.things) do
local method = thing[methodName]
assert(method, "Receiver does not support method '"..methodName.."'.")
method(thing, table.unpack(args))
end
end
self.publishing = false
end
function Queue:length()
return #self.events
end
function Queue:subscribe(receiver)
table.insert(self.things, receiver)
end
local Q
local Sender = class()
function Sender:act()
self.called = false
Q:publish("hello", self)
_:expect(self.called, "called too soon").is(false)
end
function Sender:greetings()
table.insert(results,"sender greetings")
end
function Sender:hello()
table.insert(results,"sender hello")
end
local Receiver = class()
function Receiver:hello()
response = "I saw that!"
end
local Publisher = class()
function Publisher:greetings()
table.insert(results,"publisher greetings")
end
function Publisher:hello()
table.insert(results,"publisher hello")
Q:publish("greetings")
end
local Object = class()
function Object:act()
Q:publish("action", 1,2,3)
end
function Object:action(a1,a2, a3)
_:expect(a1).is(1)
_:expect(a2).is(2)
_:expect(a3).is(3)
end
function testScenarios()
_:describe("Scenarios", function()
_:before(function()
Q = Queue()
end)
_:after(function()
end)
_:test("pub-sub works", function()
results = {}
local sender = Sender()
local receiver = Receiver()
Q:subscribe(receiver)
sender:act()
_:expect(response).is("I saw that!")
end)
_:test("pub is complete before next pub", function()
results = {}
local sender = Sender()
local publisher = Publisher()
Q:subscribe(publisher)
Q:subscribe(sender)
local expect = {"publisher hello", "sender hello", "publisher greetings", "sender greetings"}
sender:act()
_:expect(Q:length()).is(0)
for i,message in ipairs(expect) do
_:expect(results[i]).is(message)
end
end)
_:test("pub-sub passes arguments", function()
local obj = Object()
Q:subscribe(obj)
obj:act()
end)
end)
end
The unusual thing is that all the classes here are local. this tab exports no globals to the rest of the system. Unfortunately, we do need at least one thing out of here, a Queue instance to use in the game. I suppose the easiest thing to do will be to make Queue global. An alternative would be a global function, something like this:
function GetQueue()
return Queue()
end
That would make everything about the Queue private. It doesn’t make much difference: we have to know to say Q:publish(), and that’s all we have to know.
I suppose we could even lazy-init an instance and just implement a publish function:
function Publish(methodName,...)
return Q:publish(methodName,...)
end
That does remind me of a feature we will almost certainly need, which is for the results to come back from a publish. I have in mind that when a Thing tries to move, it will publish its intention and respondents will demur or express no interest. So we need to get back a “NO” or “OK” kind of response.
Hm. That’s tricky, because as we’ve done the Queue, I think that all the calls for message N are made before any calls for message N+1, but we’ve made no provision for what will be in the return from the publish call. I think we’d have to stack the returns in the while # > 0
loop, and then unstack them in publish.
Yikes! I’m not sure if that can even work. It’s hard to think about, at least here chez Ron. No, as written, it can’t work. The sequence goes like this:
- Sender publishes “hello”;
- Q sends “hello” to R1;
- R1 receives “hello” message;
- R1 publishes “greetings”;
- Q enqueues “greetings”;
- Q calls dequeue;
- Dequeue returns immediately;
- Q returns to R1
- Q dequeues “hello” to other Rs
- Q finally done with “hello”;
- looping, Q dequeues “greetings” …
- Q sends “greetings” to everyone …;
- Q finally done with “greetings”
- Q returns to Sender.
This is deeply weird. The only things I can think of to make it more weird are to do it with coroutines, or let it be recursive. I suppose it might be done in a more orderly fashion with a fast timer that repeatedly tries to dequeue.
I’m not going to borrow trouble from the future. What I will do, however, is prioritize working on move, which I probably would have done anyway, to see how we’ll handle the entry refusal issue.
One possibility comes to mind, which I’ll write down here by way of memory. Suppose we publish iWantToMoveTo(someCoord)
and suppose that if any receiver of that message doesn’t like the idea, he calls us on a method youCant
. And suppose the code in, say, Player is:
...
self.target = calculateSomeCoord()
Q:publish("iWantToMoveTo", self.target)
self.coord = self.target
...
function Player:youCant()
self.target = self.coord
end
In words: if we can’t, we set our target (back) to our current coord, and then we move ourselves to where we already are.
I think that might even work if the would-be refusers were to publish youCant
. I think we know that we don’t see our return until all our subscribers have had a go at us and all their published methods are consumed.
We can fairly readily write a test for that.
Anyway it is now 1250, so I am nearly four hours in. It’s time to sum up.
Summary
I think we have a nice implementation of a pub-sub queue, and the tests running so far, plus the thinking we’ve done here, give me a good feeling about this idea. We don’t have anything like a game running yet, but cut me a break, we’re four hours in on the first day.
I have a good feeling about this. I’m going to give it a few more days to see what happens.
And then what?
I see a few possibilities:
- It might not work. I think this is unlikely, but sometimes experiments don’t work. OK, Beth was right about this not necessarily being a super idea.
- It does seem to work. We use this new understanding to refactor D2 toward a better design along these lines.
- We write a new program, D3ngeon, perhaps even doing all the things that D2 can do, just to see what happens.
- Do some entirely new thing.
Now my theory is that option 2 is generally best. I have had nothing but bad results with rewriting existing programs: one is always in a tail chase, or time runs out before the new thing is sufficient to the need. So my theory is that even if this idea is brilliant, the right thing to do is to see what it tells us about D2, and to improve D2 in the light of this new idea.
But that’s not going to be much fun, is it? At least I don’t think it will be.
At this writing, I rather want to start on 3, expecting to get far enough to draw conclusions, and then move on to something new.
On the other hand … with a bit of effort, this effort could become a series that’s more tutorial than the existing 295 articles, more along the lines of “Here’s how to write a Dungeon program in Codea”.
Right now? I just don’t know. I just know that I want to push this idea further and see what happens.
I hope you’ll follow along. And don’t forget to write or tweet at me.
-
“I could be wrong, but I’m not.” – Eagles ↩