Let’s do some more with the TileArbiter, including plugging it in to actual game play.

My experience yesterday with the TileArbiter convinces me that it’s going to be a much better way to manage encounters than the current dispatching back and forth. Double dispatch works nicely when you have a binary operator that needs to know the types of both arguments, or when you have a few different objects who might talk with you, with slightly different expectations. But for the purposes of mediating contact between two entities, it seems to be spreading tiny bits of code all over, and I’m finding it very difficult to keep the flow in mind.

I conclude that for a smarter man, the double dispatch solution might be a good one, but that I need something simpler, and the TileArbiter seems to do the job for me. And, if it doesn’t, we’ll refactor again. No biggie, we learn, change the code, carry on.

Opportunities

I’ve been thinking about encounters, battles, and the like. Our little game doesn’t offer much opportunity for user-controlled battles, or at least I don’t see how we’d do it, but we can make things interesting. Here are a few ideas:

  • The first entity to try to enter another’s cell has “initiative”. That means they get to strike first in a battle. However,
  • There could be entities that are friendly unless you attack them.
  • There could be invisible entities that get initiative if you step into their tile, because you can’t see them. (We’d probably want to provide some way to find out where they are. A spell?)
  • There could be Chest Mimics, that look almost like chests but are in fact a voracious monster that attacks you when you enter their tile.

And, of course, any kind of protracted battle should be predicated on the attributes of the player and monster, in terms of strength, speed, health, armor, weapons … some array of attributes that are used in running the battle. In early days, we’ll not do anything very elaborate, but we should probably at least sketch out something, to be sure the door is open to doing it.

Oh, and there should be doors. Keys should be necessary. Maybe there are even different kinds of keys for different purposes. And, we need to place blocking objects like chests so that they don’t block hallways. A blocked hallway could make it impossible to finish a level.

And … we need levels. Should it be possible to return to a level once we’ve left it? That could lead to some interesting save and restore concerns. But if we saved the random number seed at the right point, we could generate at least the same room layout.

So there’s lots to do. Today, I want to plug TileArbiter into the actual game play, and then make sure that all the existing interactions still work.

If I were a good person, I’d test those interactions. But that assumes facts not in evidence.

Let’s get started.

Using TileArbiter

At present, TileArbiter (TA) looks like this:

function TileArbiter:init(resident, mover)
    self.resident = resident
    self.mover = mover
end

function TileArbiter:moveTo()
    local entry = self:tableEntry(self.resident,self.mover)
    local action = entry.action
    if action then action(self.mover,self.resident) end
    local result = entry.moveTo(self)
    return result
end

function TileArbiter:acceptMove()
    return self.resident:getTile()
end

function TileArbiter:refuseMove()
    return self.mover:getTile()
end

function TileArbiter:table()
    local t = {}
    t[Chest] = {}
    t[Key] = {}
    t[Chest][Monster] = {moveTo=TileArbiter.refuseMove}
    t[Chest][Player] = {moveTo=TileArbiter.refuseMove}
    t[Key][Monster] = {moveTo=TileArbiter.acceptMove}
    t[Key][Player] = {moveTo=TileArbiter.acceptMove, action=Player.startActionWithKey}
    return t
end

function TileArbiter:tableEntry(resident,mover)
    local rclass = getmetatable(resident)
    local mclass = getmetatable(mover)
    local entry = self:table()[rclass][mclass]
    assert(entry, "missing entry")
    return entry
end

Basically we have a table of interactions, indexed by resident entity and moving entity, the latter being the entity trying to enter the tile where the resident, um, presently resides.

The table records include only a couple of fields, moveTo and action. I expect we’ll need more, and that these may not be quite right. The moveTo operation is implicitly sent to the TA itself, and will either accept or refuse the move, returning the new tile, or the original mover’s tile, respectively.

The action field is a message to be sent to the moving entity, with a parameter of the resident. This is enough to set up a conversation between them, which is used for purposes of taking keys, battles, and so on.

Again, I expect these will need elaboration and changing as we learn how to use the TA object.

TA is not used in game play at all yet. We have some tests, such as this one:

        _:test("TileArbiter: player can step on key and receives it", function()
            local runner = GameRunner()
            local room = Room(1,1,20,20, runner)
            local pt = Tile:room(11,10,runner)
            local player = Player(pt,runner)
            runner.player = player
            local kt = Tile:room(10,10, runner)
            local key = Key(kt,runner)
            _:expect(player.keys).is(0)
            _:expect(kt.contents).has(key)
            arb = TileArbiter(key,player)
            local moveTo = arb:moveTo()
            _:expect(moveTo).is(kt)
            _:expect(player.keys).is(1)
            _:expect(kt.contents).hasnt(key)
        end)

Moves in the game presently call legalNeighbor, which calls validateMoveTo:

function Tile:legalNeighbor(anEntity, aStepVector)
    local newTile = self:getNeighbor(aStepVector)
    return self:validateMoveTo(anEntity,newTile)
end

function Tile:validateMoveTo(anEntity, newTile)
    if newTile:isRoom() then
        local tile = newTile:attemptedEntranceBy(anEntity, self)
        self:moveEntrant(anEntity,tile)
        return tile
    else
        return self
    end
end

That method calls attemptedEntranceBy on the new tile, passing in the moving entity:

function Tile:attemptedEntranceBy(enteringEntity, oldRoom)
    local accepted = false
    local residents = 0
    for k,residentEntity in pairs(self.contents) do
        residents = residents + 1
        accepted = accepted or residentEntity:acceptEntrance(enteringEntity)
    end
    if residents == 0 or accepted then
        return self
    else
        return oldRoom
    end
end

That method goes through lots of rigmarole, checking all the residents to get their opinion, and then returning either the origin tile or the new tile as dictated.

Here’s where we should plug in the TA. I can imagine ways to plug it in incrementally, checking in attempted entrance to see if the case is handled yet by TA. If we thought this transition would take a long time, we could do this “feature flagging” kind of thing, so as to release the TA incrementally. Here, that’s overkill. I’m sure we can make it work entirely today, if I’d just get around to it.

But it won’t do to just plug it in and hope. Let’s ensure that we have enough tests of the existing thing, all of which should continue to run after TA is plugged in. We do have a few decent tests:

        
        _:test("player can't enter chest tile", function()
            local runner = GameRunner()
            local chestTile = Tile:room(10,10,runner)
            local chest = Chest(chestTile, runner)
            local playerTile = Tile:room(11,10,runner)
            local player = Player(playerTile,runner)
            _:expect(chest:isOpen()).is(false)
            local chosenTile = playerTile:validateMoveTo(player, chestTile)
            _:expect(chosenTile).is(playerTile)
            _:expect(chest:isOpen()).is(true)
        end)
        
        _:test("monster can enter player tile", function()
            local runner = GameRunner()
            local monsterTile = Tile:room(10,10,runner)
            local monster = Monster(monsterTile, runner)
            local playerTile = Tile:room(11,10,runner)
            local player = Player(playerTile,runner)
            runner.player = player -- needed because of monster decisions
            local chosenTile = monsterTile:validateMoveTo(monster,playerTile)
            _:expect(chosenTile).is(playerTile)
        end)
        
        _:test("monster can't enter chest tile", function()
            local runner = GameRunner()
            local playerTile = Tile:room(15,15,runner)
            local player = Player(playerTile,runner)
            runner.player = player -- needed because of monster decisions
            local chestTile = Tile:room(10,10,runner)
            local chest = Chest(chestTile, runner)
            local monsterTile = Tile:room(11,10,runner)
            local monster = Monster(monsterTile,runner)
            local chosenTile = monsterTile:validateMoveTo(monster, chestTile)
            _:expect(chosenTile).is(monsterTile)
        end)

Those all use validateMoveTo, so if we work beneath that method, as we intend to, these tests should continue to work. I’m going to call that close enough. Let’s see about plugging this thing in, at least, and run the tests.

Plugging it in. Really.

I think TA goes in here:

function Tile:attemptedEntranceBy(enteringEntity, oldRoom)
    local accepted = false
    local residents = 0
    for k,residentEntity in pairs(self.contents) do
        residents = residents + 1
        accepted = accepted or residentEntity:acceptEntrance(enteringEntity)
    end
    if residents == 0 or accepted then
        return self
    else
        return oldRoom
    end
end

It’ll replace that acceptEntrance call on the residents. I think it goes like this:

function Tile:attemptedEntranceBy(enteringEntity, oldRoom)
    local ta
    local acceptedTile
    local accepted = false
    local residents = 0
    for k,residentEntity in pairs(self.contents) do
        residents = residents + 1
        ta = TileArbiter(residentEntity,enteringEntity)
        acceptedTile = ta:moveTo()
        accepted = accepted or acceptedTile==self
    end
    if residents == 0 or accepted then
        return self
    else
        return oldRoom
    end
end

It’s inconvenient that moveTo returns the tile, and we’ll want to change this method to accommodate that, but for now I rather expect this to work. Or, to put it another way, I want to know why it doesn’t.

Two tests fail:

12: player can't enter chest tile  -- Actual: false, Expected: true
13: monster can enter player tile -- TileArbiter:41: attempt to index a nil value (field '?')

Better read those to see what’s up.

        _:test("player can't enter chest tile", function()
            local runner = GameRunner()
            local chestTile = Tile:room(10,10,runner)
            local chest = Chest(chestTile, runner)
            local playerTile = Tile:room(11,10,runner)
            local player = Player(playerTile,runner)
            _:expect(chest:isOpen()).is(false)
            local chosenTile = playerTile:validateMoveTo(player, chestTile)
            _:expect(chosenTile).is(playerTile)
            _:expect(chest:isOpen()).is(true)
        end)
        
        _:test("monster can enter player tile", function()
            local runner = GameRunner()
            local monsterTile = Tile:room(10,10,runner)
            local monster = Monster(monsterTile, runner)
            local playerTile = Tile:room(11,10,runner)
            local player = Player(playerTile,runner)
            runner.player = player -- needed because of monster decisions
            local chosenTile = monsterTile:validateMoveTo(monster,playerTile)
            _:expect(chosenTile).is(playerTile)
        end)

The error in the first test is that the chest didn’t open. The other two expects in the first test were OK. Let’s go after that first. Did any of our TA tests check that? Not really. Let’s write one against TA rather than the larger validate path.

        _:test("TileArbiter: player cannot step on chest, chest opens", function()
            local runner = GameRunner()
            local room = Room(1,1,20,20, runner)
            local pt = Tile:room(11,10,runner)
            local player = Player(pt,runner)
            runner.player = player
            local ct = Tile:room(10,10, runner)
            local chest = Chest(ct,runner)
            local arb = TileArbiter(chest, player)
            local moveTo = arb:moveTo()
            _:expect(moveTo).is(pt)
            _:expect(chest:isOpen(), "chest should be open").is(true)
        end)

This better fail.

17: TileArbiter: player cannot step on chest, chest opens chest should be open -- Actual: false, Expected: true

Let’s see what goes on in TA. Perhaps we have set up no action or something like that.

function TileArbiter:table()
    local t = {}
    t[Chest] = {}
    t[Key] = {}
    t[Chest][Monster] = {moveTo=TileArbiter.refuseMove}
    t[Chest][Player] = {moveTo=TileArbiter.refuseMove}
    t[Key][Monster] = {moveTo=TileArbiter.acceptMove}
    t[Key][Player] = {moveTo=TileArbiter.acceptMove, action=Player.startActionWithKey}
    return t
end

Right. We need to start action. Player has this:

function Player:startActionWithChest(aChest)
    aChest:open()
end

And we need to call it:

function TileArbiter:table()
    local t = {}
    t[Chest] = {}
    t[Key] = {}
    t[Chest][Monster] = {moveTo=TileArbiter.refuseMove}
    t[Chest][Player] = {moveTo=TileArbiter.refuseMove, action=Player.startActionWithChest}
    t[Key][Monster] = {moveTo=TileArbiter.acceptMove}
    t[Key][Player] = {moveTo=TileArbiter.acceptMove, action=Player.startActionWithKey}
    return t
end

New test passes, and we’re down to one failing:

13: monster can enter player tile -- TileArbiter:41: attempt to index a nil value (field '?')

That’s here:

function TileArbiter:tableEntry(resident,mover)
    local rclass = getmetatable(resident)
    local mclass = getmetatable(mover)
    local entry = self:table()[rclass][mclass] -- <-- 41
    assert(entry, "missing entry")
    return entry
end

That’s a missing entry at the top of the table, resulting in trying to index a nil. Let’s improve and instrument that method:

function TileArbiter:tableEntry(resident,mover)
    local rclass = getmetatable(resident)
    local mclass = getmetatable(mover)
    local innerTable = self:table()[rclass]
    if not innerTable then
        print(resident, "no TA table entry")
        assert(innerTable, "missing rclass")
    end
    local entry = self:table()[rclass][mclass]
    if not entry then
        print("TA Missing mclass", resident,mover)
        assert(entry, "missing mclass")
    end
    return entry
end
Player (11,10)	no TA table entry

What test is this?

13: monster can enter player tile -- TileArbiter:44: missing rclass

Yes, well, player is resident. We have no entries for player. There need to be some. Let’s do them.

function TileArbiter:table()
    local t = {}
    t[Chest] = {}
    t[Chest][Monster] = {moveTo=TileArbiter.refuseMove}
    t[Chest][Player] = {moveTo=TileArbiter.refuseMove, action=Player.startActionWithChest}
    t[Key] = {}
    t[Key][Monster] = {moveTo=TileArbiter.acceptMove}
    t[Key][Player] = {moveTo=TileArbiter.acceptMove, action=Player.startActionWithKey}
    t[Player] = {}
    t[Player][Monster] = {moveTo=TileArbiter.acceptMove, action=Monster.startActionWithPlayer}
    return t
end

The tests all pass. However, we get this crash:

Monster (64,21)	no TA table entry

TileArbiter:46: missing rclass
stack traceback:
	[C]: in function 'assert'
	TileArbiter:46: in method 'tableEntry'
	TileArbiter:12: in method 'moveTo'
	Tile:55: in method 'attemptedEntranceBy'
	Tile:236: in function <Tile:234>
	(...tail calls...)
	Monster:83: in method 'moveTowardAvatar'
	Monster:38: in field 'callback'
	...in pairs(tweens) do
    c = c + 1
...

I try an improvement to the print in the TA table lookup and get this message:

Monster (44,12)	Monster (44,12)	no TA rclass table entry

We have a monster-monster collision, it looks like. And sure enough we don’t have any entries for monsters as residents.

It is probably time to deal with a default rather than explode when things are not in the table. This will be risky, of course, since forgetting an entry could lead to defects, but if we choose a decent default it shouldn’t be as fatal as it is now.

Let’s make the rule be that if we don’t recognize the inputs, the move is refused and that’s all.

function TileArbiter:tableEntry(resident,mover)
    local rclass = getmetatable(resident)
    local mclass = getmetatable(mover)
    local innerTable = self:table()[rclass] or {}
    local entry = self:table()[rclass][mclass] or self:defaultEntry()
    return entry
end

function TileArbiter:defaultEntry()
    return {moveTo=TileArbiter.refuseMove}
end

Let’s run with this for a bit and see what happens. Well, this bad thing happened when I tried to step on a monster:

TileArbiter:44: attempt to index a nil value (field '?')
stack traceback:
	TileArbiter:44: in method 'tableEntry'
	TileArbiter:12: in method 'moveTo'
	Tile:55: in method 'attemptedEntranceBy'
	Tile:236: in function <Tile:234>
	(...tail calls...)
	Player:78: in method 'moveBy'
	Player:72: in method 'keyPress'
	GameRunner:156: in method 'keyPress'
	Main:68: in function 'keyboard'

Oh. I cleverly did not use the table i carefully initialized to empty:

function TileArbiter:tableEntry(resident,mover)
    local rclass = getmetatable(resident)
    local mclass = getmetatable(mover)
    local innerTable = self:table()[rclass] or {}
    local entry = innerTable[rclass][mclass] or self:defaultEntry()
    return entry
end

Again:

TileArbiter:44: attempt to index a nil value (field '?')
stack traceback:
	TileArbiter:44: in method 'tableEntry'
	TileArbiter:12: in method 'moveTo'
	Tile:55: in method 'attemptedEntranceBy'
	Tile:236: in function <Tile:234>
	(...tail calls...)
	Monster:81: in method 'moveTowardAvatar'
...

I suspect I need to read more carefully. How dull can I be? One more try:

function TileArbiter:tableEntry(resident,mover)
    local rclass = getmetatable(resident)
    local mclass = getmetatable(mover)
    local innerTable = self:table()[rclass] or {}
    local entry = innerTable[mclass] or self:defaultEntry()
    return entry
end

Things don’t crash, but princess can’t kill monster. That’s monster resident, player moving … I’m sure there’s an entry for that … but there isn’t. Let’s make one:

    t[Monster]={}
    t[Monster][Player] = {moveTo=TileArbiter.acceptMove, action=Player.startActionWithMonster}

That does it, and everything appears to work just as before. Commit: TileArbiter arbitrates all encounters.

But we’re not done here. We need to clean up some of the cruft inside TileArbiter, and we should look for residual functions like the acceptEntrance ones. Done, removed those. What else? A few like the empty startActionWithKey in Monster.

I notice that there is startActionWithMonster in Monster. They can encounter each other and this is supposed to happen:

function Monster:startActionWithMonster(aMonster)
    aMonster:rest()
end

That tells the encountered monster to rest for a few seconds, to give the monsters a better chance of separating. Let’s add a monster-monster encounter to the TA table, and while we’re at it, have them never get to the same tile.

    t[Monster][Monster] = {moveTo=TileArbiter.refuseMove, action=Player.startActionWithMonster}

Quick test … this is odd:

dead monster

See that dead monster up above the princess? I swear I didn’t touch him. What could have killed him? Hm well, why did I put Player in that line above. That’s wrong, but I don’t see how it caused that problem. Fix it, try again.

    t[Monster][Monster] = {moveTo=TileArbiter.refuseMove, action=Monster.startActionWithMonster}

More game play … seems OK now. Maybe there were two monsters there, and one tried to move on another, and used the player method which would kill. And it would in fact work, since the method doesn’t use self.

Looking forward, I suspect we may want two actions, one for each participant in the TA, but we’ll wait until a real need arises. I am not one to put in unneeded complexity in the name of “we’re gonna need it”.

A quick scan for any more leftover cruft from the old encounters … nothing to speak of.

That TA table is created on every instance. Let’s create it exactly once. That’s pervasive enough that I’ll show the whole class:

-- TileArbiter
-- RJ 20201222

TileArbiter = class()

TA = TileArbiter

TA_table = nil

function TileArbiter:init(resident, mover)
    self.resident = resident
    self.mover = mover
    self:createTable()
end

function TileArbiter:acceptMove()
    return self.resident:getTile()
end

function TileArbiter:defaultEntry()
    return {moveTo=TileArbiter.refuseMove}
end

function TileArbiter:moveTo()
    local entry = self:tableEntry(self.resident,self.mover)
    local action = entry.action
    if action then action(self.mover,self.resident) end
    local result = entry.moveTo(self)
    return result
end

function TileArbiter:refuseMove()
    return self.mover:getTile()
end

function TileArbiter:createTable()
    if TA_table then return end
    local t = {}
    t[Chest] = {}
    t[Chest][Monster] = {moveTo=TileArbiter.refuseMove}
    t[Chest][Player] = {moveTo=TileArbiter.refuseMove, action=Player.startActionWithChest}
    t[Key] = {}
    t[Key][Monster] = {moveTo=TileArbiter.acceptMove}
    t[Key][Player] = {moveTo=TileArbiter.acceptMove, action=Player.startActionWithKey}
    t[Player] = {}
    t[Player][Monster] = {moveTo=TileArbiter.acceptMove, action=Monster.startActionWithPlayer}
    t[Monster]={}
    t[Monster][Monster] = {moveTo=TileArbiter.refuseMove, action=Monster.startActionWithMonster}
    t[Monster][Player] = {moveTo=TileArbiter.acceptMove, action=Player.startActionWithMonster}
    TA_table = t
end

function TileArbiter:tableEntry(resident,mover)
    local rclass = getmetatable(resident)
    local mclass = getmetatable(mover)
    local innerTable = TA_table[rclass] or {}
    local entry = innerTable[mclass] or self:defaultEntry()
    return entry
end

I think we’re good to go. Let’s commit: cleaning up code after installing TA. And let’s sum up.

Summing Up

The TA table has worked very nicely. I’ve left it in a somewhat dangerous state, in that it returns a default that just refuses a move but doesn’t otherwise diagnose or log any information. That should be harmless in game play, but it’s not as robust as we might like.

And we’ve seen that the table entries are subject to error, like the one that I pasted with ‘Player’ where I should have put Monster. And of course a common error will surely be to leave interactions out entirely.

It wouldn’t be hard to build a little cross-checker that prints all the undefined combinations, and that might be a good idea. Maybe we could make it a test, even. Yes, that would be wise. We’ll surely make the mistake of missing entries when we create new entities. One interesting question will be how we remember to put the names of new classes into that test.

Maybe there’s some clever way to figure out which classes are entities. Naming them things like MonsterEntity would do it. But nasty to read. Anyway thats for another day.

So far, the TA idea is bearing weight nicely. I’m certainly far less confused as I work with it, and the most mysterious defect took but a moment to find, the substitution of Player for ‘Monster’.

Looking forward, I think we’ll want a start on attributes for the player-monster battles, and some controls that can be used on devices that don’t have keyboards. I am quite sure that this thing will look odd on the phone. I can’t even really play it on my other iPad that has no keyboard. For now, with zero customers, it doesn’t seem very important.

If you’re reading these things, let me know, please. See you next time!

D2.zip