Dungeon 123
Top o’ the morning’ to ya, and I think we’ll be starting on a set-piece today. Unless I change my mind. Also, some ranting about Definition of Ready.
Yes, I am half-Irish, on my mother’s side. That said, most of the green clothing I have looks more black than green. I have a good reason for that: I’m a Winter, and black is my color.
I was thinking this morning, while the cat sat on me, about what to do with the new path-finding capability. I’d already thought of there being monsters that would lead you to things, such as the WayDown. So now, I’ve got a “big” story in mind:
Near the WayDown, position a few guardian monsters, perhaps from the next level. Those monsters never leave the area of the WayDown. Perhaps they will battle. Maybe they just block the player from the WayDown. (If so, how does she get past them?)
The WayDown requires a key. Once the player finds a key, one guardian monster occasionally appears near the player. This monster never comes closer than (say) 5, nor moves further away than (say) 10. Its main purpose is to lead the player to the WayDown room and the final confrontation with the guardians.
The monster needs enough intelligence to carry out this duty without doing anything really strange like trampling the player. Details will need to be worked out.
Over the next while, we’ll be working toward this story. At this moment, I’m not sure what order we’ll proceed in, nor quite how any of this will be done. That’s just fine: why should I know exactly how to do some ill-defined thing I just thought of an hour ago?
Definition of Ready
I must digress. A common “idea” in Scrum is the “Definition of Ready”, an agreement between Product Owner and developers as to when a story is well-enough described to be ready to be brought to Sprint Planning. Frequent elements of this “definition” are that acceptance tests must be defined or ready, and that the Product Owner has answered, or can answer, any questions the team may have about the story.
I think this notion is weak. First of all, it turns out to be used quite often as a weapon for the developers to resist doing things. Anything they don’t want to do, they pull out the DoR and start playing Story Lawyer with the PO. This is counter-productive. We’re supposed to collaborate, not battle.
Second, the DoR practice tends to nail down details too soon, and to generate rigid stories that are too large. In order to be “ready”, the Product Owner is induced to go into great detail about the story, and then, whether all the details are good or not, we’re pretty much stuck with it.
So you’d be correct to think that no reasonable Definition of Ready would accept the story above. That’s probably because there is no such thing as a reasonable Definition of Ready.
What do we do instead? We do what we’re going to do right here: we collaborate as a group to figure out what’s needed and how to do it.
But I digress …
Set Piece
Given what we have, we can envision a room with the WayDown in it, and a few monsters roaming around the room. They’ll need a new kind of MonsterStrategy. It doesn’t sound too complicated, and is probably similar to the Calm strategy, except that instead of milling around near the player they mill around near the WayDown.
We can certainly envision another monster who can appear near the player and then lead to the WayDown room, and there should be no problem causing that monster to change strategy to become a regular guardian, or perhaps just enter the WayDown to go on break.
If we want the guardians to block the player, that would be another strategy, basically “move between the WayDown and the player”, although we’ll have to figure out details.
This will be our first “set piece”, unless along the way we come up with something interesting.
I guess the obvious place to start, since we’ve just been working on it, is a monster that walks from somewhere to the WayDown. We can just randomly produce that monster, or produce it near the player, and let it go.
Another reason for working on this is that our current test feature for the pathfinding needs to come out, and in building it, we’ve had to write some unfortunate code.
So let’s start there.
Clean Up the Pathfinding Interface
When the player touches the “Flee” button, we lay down Health Loots along the path from the player to the WayDown. We do that like this:
function Player:flee()
local dungeon = self.runner:getDungeon()
local map = Map(dungeon)
local me = self:getTile():pos()
local wd = self.runner.wayDown.tile:pos()
map:start(wd.x,wd.y)
map:run()
local path = map:cell(me.x,me.y):path()
for i,c in ipairs(path) do
self:markTile(c)
end
end
function Player:markTile(mapCell)
local pos = mapCell:posVec()
local dung = self.runner:getDungeon()
local tile = dung:getTile(pos)
print("markTile", pos,dung,tile)
tile:addDeferredContents(Loot(tile, "Health", 1,1))
end
This code doesn’t tell a very coherent story. Let’s try to express it a bit more clearly, first in English.
Our purpose is to get a path from somewhere (the player location) to somewhere else (the WayDown location). To do that, we:
- Create a Map of the current dungeon.
- Map the dungeon with paths to the WayDown.
- Pull out the path to the player location.
- (Mark the path’s tiles.)
Note that what Map:run
does is to link every cell in the Map back to the point we call “start”. (We should probably call it “stop” or “target”. After run
has completed, every MapCell has a link to the MapCell you should move to if you want to get to the target. We just pull out one such path by following those links.
So what we really want is for someone to give us a path, an array of Tiles. It’s possible that we should settle for an array of steps. Without looking, I think we’d prefer tiles.
As I think about this further, maybe we don’t need to create the path in the usual case at all. Maybe we’d really like to retain the whole Map, with the links all in there.
Think about it. If we have a map to the WayDown, any time a monster wants to move toward the WayDown, it could just access the map at the point that corresponds to its current location, and the Map could give back the coordinates, or the Tile, to move to. The monster could try to move to that Tile. If it succeeds, repeat. If it fails for some reason, it could move randomly and try again next time.
Yes, I like that. We’ll retain the path ability: we might want to mark something. But let’s assume that having created a Map to somewhere, we can hold on to it and use it repeatedly. We can always refresh it if we need to.
So let’s recast the Flee code in those terms. That is, let’s create the map, changing the protocol from start
to target
, and using the Map to give us the tile to populate.
Adjusting the Protocol
I think I’d like the map to think in terms of Tiles, because that’s what the user code uses.
My first cut is this:
function Player:flee()
local dungeon = self.runner:getDungeon()
local map = Map(dungeon)
map:target(self.runner.wayDown.tile)
map:run()
local next = self:getTile()
while next do
next = map:nextAfter(next)
if next then self:markTile(next) end
end
end
I’ve posited a method target
that accepts a tile, and a method nextAfter
that also accepts a tile.
I’ll need to build these in Map and MapCell. Let’s try to TDD these. This is enough to start:
_:test("target accepts tile", function()
local dungeonClass = FakeMapDungeon
local tiles = FakeMapTile:create(50,50)
local dungeon = dungeonClass(tiles)
local map = Map(dungeon)
local t = FakeMapTile(10,10)
map:target(t)
map:run()
end)
I’m going to change the start
internally to something else, perhaps targetXY
, since it now accepts and x and y.
function Map:target(tile)
self:targetXY(unpack(tile:pos()))
end
function Map:targetXY(x,y)
self.q = {}
local c = self:cell(x,y)
c.distance = 0
c.parent = nil
self:addQueue(c)
end
This will require me to change all the start
references in the tests. Consider it done.
I also have to implement this:
function FakeMapTile:pos()
return vec2(x,y)
end
Can’t call unpack. Lua 5.4 has forced us to quality if. I think we can send it, however.
function Map:target(tile)
self:targetXY(tile:pos():unpack())
end
Now I get this:
8: target accepts tile -- PathFinder:158: attempt to index a nil value (field '?')
That’s here:
function Map:cell(x,y)
return self.cells[x][y]
end
The bug is this:
function FakeMapTile:pos()
return vec2(self.x,self.y)
end
Forgot the selfs.
Now the new test runs. Of course it doesn’t assert anything yet.
map:run()
t = FakeMapTile(11,10)
local g = map:nextTile(t)
local pos = g:pos()
_:expect(pos).is(vec2(10,10))
I figure the adjacent cell should point to the target.
8: target accepts tile -- PathFinder:127: attempt to call a nil value (method 'nextTile')
As expected.
function Map:nextTile(tile)
local cell = self:getCellFromTile(tile)
local prev = cell.parent
if not prev then return nil end
return self.dungeon:getTile(vec2(prev.x,prev.y))
end
function Map:getCellFromTile(tile)
local pos = tile:pos()
return self:getCellFromPosition(pos)
end
function Map:getCellFromPosition(pos)
return self:cell(pos.x,pos.y)
end
And I have to extend the FakeMapDungeon:
function FakeMapDungeon:getTile(pos)
return self.tiles[pos.x][pos.y]
end
The test runs.
If things are as they should be, then the Flee button should also work.
Well, it would if I had used the same method name:
function Map:nextAfter(tile)
local cell = self:getCellFromTile(tile)
local prev = cell.parent
if not prev then return nil end
return self.dungeon:getTile(vec2(prev.x,prev.y))
end
And if I had fixed MarkTile to expect a tile.
function Player:markTile(tile)
tile:addDeferredContents(Loot(tile, "Health", 1,1))
end
And now the path appears again. Now let’s look again at how we set up the Map and improve it more.
function Player:flee()
local dungeon = self.runner:getDungeon()
local map = Map(dungeon)
map:target(self.runner.wayDown.tile)
map:run()
local next = self:getTile()
while next do
next = map:nextAfter(next)
if next then self:markTile(next) end
end
end
It seems that one never wants anything but a Map to a given target. So let’s create what we want. And since the GameRunner is the owner of the dungeon, we should ask it to give us the map.
And since it knows where the WayDown is, let’s just say this:
function Player:flee()
local map = self.runner:mapToWayDown()
local next = self:getTile()
while next do
next = map:nextAfter(next)
if next then self:markTile(next) end
end
end
function GameRunner:mapToWayDown()
local map = Map(self:getDungeon())
map:target(self.wayDown.tile)
map:run()
return map
end
Simple enough. But why do we bother to say run
? There’s nothing else to do. Change Map.
function Map:target(tile)
self:targetXY(tile:pos():unpack())
self:run()
end
Now we have this:
function GameRunner:mapToWayDown()
local map = Map(self:getDungeon())
map:target(self.wayDown.tile)
return map
end
Why not just pass the target when we create?
function GameRunner:mapToWayDown()
return Map(self:getDungeon(), self.wayDown.tile)
end
We can do this.
function Map:init(dungeon, tile)
self.dungeon = dungeon
self:initCells()
self.visited = false
if tile then self:target(tile) end
end
This is nice. From the outside, we speak in terms of tiles, which are the core object in the dungeon. Inside, we deal with the conversions between our MapCells and tiles. And we have one-stop shopping to get the map we want.
We’ll follow this pattern to provide other paths as needed, such as paths to treasures or paths to allow monsters to seek out the player or the like.
Nice. Commit: improve interface to Map for pathfinding.
What Shall We Do Now?
Let’s do a new monster strategy, one that keeps the monster within a few tiles of the WayDown, or other defined tile.
Let’s review our strategy code.
function CalmMonsterStrategy:execute(dungeon)
local method = self:selectMove(self.monster:manhattanDistanceFromPlayer())
self.monster[method](self.monster, dungeon)
end
function CalmMonsterStrategy:selectMove(range)
if range > 10 then
return "basicMoveRandomly"
elseif range >= 4 then
return "basicMoveTowardPlayer"
elseif range == 3 then
return "basicMaintainRangeToPlayer"
elseif range == 2 then
return "basicMoveAwayFromPlayer"
elseif range == 1 then
return "basicMoveTowardPlayer"
else
return "basicMoveAwayFromPlayer"
end
end
The monsters know how to do those basic moves:
function Monster:basicMaintainRangeToPlayer(dungeon)
local tiles = dungeon:availableTilesSameDistanceFromPlayer(self.tile)
self.tile = self.tile:validateMoveTo(self,tiles[math.random(1,#tiles)])
end
function Monster:basicMoveAwayFromPlayer(dungeon)
local tiles = dungeon:availableTilesFurtherFromPlayer(self.tile)
self.tile = self.tile:validateMoveTo(self,tiles[math.random(1,#tiles)])
end
function Monster:basicMoveRandomly(ignoredDungeon)
local moves = {vec2(-1,0), vec2(0,1), vec2(0,-1), vec2(1,0)}
local move = moves[math.random(1,4)]
self.tile = self.tile:legalNeighbor(self,move)
end
function Monster:basicMoveTowardPlayer(dungeon)
local tiles = dungeon:availableTilesCloserToPlayer(self.tile)
self.tile = self.tile:validateMoveTo(self,tiles[math.random(1,#tiles)])
end
We can foresee that we’ll want one or two more moves here, toward or away from a tile. No sense writing them until we need them.
So what’s the strategy? Let’s move no nearer than 3 to the WayDown, and no further away than 6. We’ll go for a bit of generalization and use a general tile, not specifically the WayDown.
This is arguably a poor idea. It might be better to do the simpler thing and then generalize, but I think in this case we’re fine.
I basically just typed this in:
-- Hanging Out Monster
HangoutMonsterStrategy = class()
function HangoutMonsterStrategy:init(monster, tile)
self.monster = monster
self.tile = tile
self.min = 3
self.max = 6
end
function HangoutMonsterStrategy:execute(dungeon)
local method = self:selectMove(self.monster:manhattanDistanceFromTile(tile))
self.monster[method](self.monster, dungeon, tile)
end
function HangoutMonsterStrategy:selectMove(range)
if range < min then
return "basicMoveAwayFromTile"
elseif self.min <= range and range <= self.max then
return "basicMoveRandomly"
else
return "basicMoveTowardTile"
end
end
I suppose I should at least test this selection code, I usually get it wrong.
_:test("Hangout strategy tests", function()
local method
local strat = HangoutMonsterStrategy(nil,nil)
method = strat:selectMove(7)
_:expect(method).is("basicMoveTowardTile")
method = strat:selectMove(6)
_:expect(method).is("basicMoveRandomly")
method = strat:selectMove(5)
_:expect(method).is("basicMoveRandomly")
method = strat:selectMove(4)
_:expect(method).is("basicMoveRandomly")
method = strat:selectMove(3)
_:expect(method).is("basicMoveRandomly")
method = strat:selectMove(2)
_:expect(method).is("basicMoveAwayFromTile")
end)
There you go, and the test runs.
Now, however, we need the two new movers.
We’ll begin by parroting this one:
function Monster:basicMoveAwayFromPlayer(dungeon)
local tiles = dungeon:availableTilesFurtherFromPlayer(self.tile)
self.tile = self.tile:validateMoveTo(self,tiles[math.random(1,#tiles)])
end
Let’s see what Dungeon
knows. All the various distance methods come down to this one:
function Dungeon:availableTilesAtDistanceFromPlayer(aTile, desiredDistance)
local pos = aTile:pos()
local result = {}
for i, delta in ipairs(self:neighborOffsets()) do
local t = self:getTile(pos + delta)
local d = self:manhattanToPlayer(t)
if d == desiredDistance and (t:isRoom() or t:isEdge()) then
table.insert(result, t)
end
end
if #result == 0 then
result = {aTile}
end
return result
end
We’d like to extend this to allow provision of a comparison tile other than the player.
Let’s refactor:
function Dungeon:availableTilesAtDistanceFromPlayer(startingTile, desiredDistance)
local pos = startingTile:pos()
local playerTile = self:playerTile()
local result = {}
for i, delta in ipairs(self:neighborOffsets()) do
local t = self:getTile(pos + delta)
local d = self:manhattanBetween(t, playerTile)
if d == desiredDistance and (t:isRoom() or t:isEdge()) then
table.insert(result, t)
end
end
if #result == 0 then
result = {aTile}
end
return result
end
function Dungeon:manhattanBetween(tile1, tile2)
return tile1:manhattanDistance(tile2)
end
function Dungeon:manhattanToPlayer(tile)
return self:manhattanBetween(tile, self:playerTile())
end
This should be equivalent, and all seems in order. Now we can extract a method. First, reorder this:
function Dungeon:availableTilesAtDistanceFromPlayer(startingTile, desiredDistance)
local playerTile = self:playerTile()
local pos = startingTile:pos()
local result = {}
for i, delta in ipairs(self:neighborOffsets()) do
local t = self:getTile(pos + delta)
local d = self:manhattanBetween(t, playerTile)
if d == desiredDistance and (t:isRoom() or t:isEdge()) then
table.insert(result, t)
end
end
if #result == 0 then
result = {aTile}
end
return result
end
Now rename to keep my head on straight:
function Dungeon:availableTilesAtDistanceFromPlayer(startingTile, desiredDistance)
local targetTile = self:playerTile()
local pos = startingTile:pos()
local result = {}
for i, delta in ipairs(self:neighborOffsets()) do
local t = self:getTile(pos + delta)
local d = self:manhattanBetween(t, targetTile)
if d == desiredDistance and (t:isRoom() or t:isEdge()) then
table.insert(result, t)
end
end
if #result == 0 then
result = {aTile}
end
return result
end
I renamed playerTile
to targetTile
. Now extract method:
function Dungeon:availableTilesAtDistanceFromPlayer(startingTile, desiredDistance)
local targetTile = self:playerTile()
return self:availableTilesAtDistanceFromTile(startingTile, targetTile, desiredDistance)
end
function Dungeon:availableTilesAtDistanceFromTile(startingTile, targetTile, desiredDistance)
local pos = startingTile:pos()
local result = {}
for i, delta in ipairs(self:neighborOffsets()) do
local t = self:getTile(pos + delta)
local d = self:manhattanBetween(t, targetTile)
if d == desiredDistance and (t:isRoom() or t:isEdge()) then
table.insert(result, t)
end
end
if #result == 0 then
result = {aTile}
end
return result
end
This should be a pure refactoring if I haven’t messed up. It appears that I have not messed up.
Now we can implement our desired new methods from the HangoutMonsterStrategy:
function HangoutMonsterStrategy:execute(dungeon)
local method = self:selectMove(self.monster:manhattanDistanceFromTile(tile))
self.monster[method](self.monster, dungeon, self.tile)
end
function HangoutMonsterStrategy:selectMove(range)
if range < self.min then
return "basicMoveAwayFromTile"
elseif self.min <= range and range <= self.max then
return "basicMoveRandomly"
else
return "basicMoveTowardTile"
end
end
(I saw and fixed the missing self
on tile in the execute
above. I sure to make that error frequently.)
We’ll be passing in the dungeon and the tile to these new methods:
function Monster:basicMoveAwayFromTile(dungeon, tile)
local tiles = dungeon:availableTilesFurtherFromTile(tile)
self.tile = self.tile:validateMoveTo(self,tiles[math.random(1,#tiles)])
end
function Monster:basicMoveTowardTile(dungeon, tile)
local tiles = dungeon:availableTilesCloserToTile(tile)
self.tile = self.tile:validateMoveTo(self,tiles[math.random(1,#tiles)])
end
Now these new methods aren’t in Dungeon yet, so:
function Dungeon:availableTilesCloserToTile(tile1, tile2)
local desiredDistance = self:manhattanBetween(tile1,tile2) - 1
return self:availableTilesAtDistanceFromTile(tile1,tile2, desiredDistance)
end
function Dungeon:availableTilesFurtherFromTile(tile1, tile2)
local desiredDistance = self:manhattanBetween(tile1,tile2) + 1
return self:availableTilesAtDistanceFromTile(tile1,tile2, desiredDistance)
end
Now we have no actual tests for monsters running this strategy, so let’s create one near the WayDown.
Assessment
Lifting my head up for a moment from deep in the keyboard, this has been going on a while, and we’re not really done yet. Of course all the tests run and the game works, but this new strategy still isn’t in use.
I want to get to an actual in-game monster running this strategy asap. I could do a new one near the WayDown, or put one somewhere else.
Let’s create a monster with our new strategy, in Room 1, centered.
Grrr
After too long a time, I’ve failed to set up a test monster. They’re too tricky to set up in an ad-hoc fashion. I’d like to revert, but sadly it has been a long time since the last commit.
I’ve burned an hour since the “What Shall We Do Now”, and there’s a crash in the game.
Nothing for it but to debug for ages, or revert. One quick look to see if I can debug. The crash is very intermittent:
Monster:141: bad argument #1 to 'random' (interval is empty)
stack traceback:
[C]: in function 'math.random'
Monster:141: in field '?'
MonsterStrategy:15: in method 'execute'
Monster:234: in method 'executeMoveStrategy'
Monster:185: in method 'chooseMove'
Monsters:61: in method 'move'
GameRunner:339: in method 'playerTurnComplete'
Player:238: in method 'turnComplete'
Player:177: in method 'keyPress'
GameRunner:289: in method 'keyPress'
Main:33: in function 'keyboard'
The picture was this:
I had just tried to move right, toward the slime in the open area, when the program crashed. I don’t know which monster crashed, but since the one to the left had been following me, I suspect it’s the one on the right. I don’t know whether he’s calm or nasty.
However, I wish I had looked at the screen more carefully because we’re here:
function CalmMonsterStrategy:execute(dungeon)
local method = self:selectMove(self.monster:manhattanDistanceFromPlayer())
print("calm ", method)
self.monster[method](self.monster, dungeon)
end
Another try suggests that the method is this:
calm basicMaintainRangeToPlayer
Ah. In this case, with the monster right there at the edge, there is no move he can make that maintains range. Let’s examine that code.
function Monster:basicMaintainRangeToPlayer(dungeon)
local tiles = dungeon:availableTilesSameDistanceFromPlayer(self.tile)
self.tile = self.tile:validateMoveTo(self,tiles[math.random(1,#tiles)])
end
Ha!
function Dungeon:availableTilesSameDistanceFromPlayer(aTile)
local desiredDistance = self:manhattanToPlayer(aTile)
return self:availableTilesAtDistanceFromPlayer(aTile, desiredDistance)
end
function Dungeon:availableTilesAtDistanceFromPlayer(startingTile, desiredDistance)
local targetTile = self:playerTile()
return self:availableTilesAtDistanceFromTile(startingTile, targetTile, desiredDistance)
end
function Dungeon:availableTilesAtDistanceFromTile(startingTile, targetTile, desiredDistance)
local pos = startingTile:pos()
local result = {}
for i, delta in ipairs(self:neighborOffsets()) do
local t = self:getTile(pos + delta)
local d = self:manhattanBetween(t, targetTile)
if d == desiredDistance and (t:isRoom() or t:isEdge()) then
table.insert(result, t)
end
end
if #result == 0 then
result = {aTile}
end
return result
end
Note that result = {aTile}
. Refactoring defect. there is no aTile
. That should be startingTile
.
function Dungeon:availableTilesAtDistanceFromTile(startingTile, targetTile, desiredDistance)
local pos = startingTile:pos()
local result = {}
for i, delta in ipairs(self:neighborOffsets()) do
local t = self:getTile(pos + delta)
local d = self:manhattanBetween(t, targetTile)
if d == desiredDistance and (t:isRoom() or t:isEdge()) then
table.insert(result, t)
end
end
if #result == 0 then
result = {startingTile}
end
return result
end
I’m quite sure that will fix the problem, but there’s no easy test. Maybe I can try to trick a monster into the right hallway position.
Fairly comprehensive testing convinces me that the problem is fixed. It’s quite late, let’s sum up.
Summary
Today went well until it didn’t. I performed a refactoring that seemed correct but I had failed to rename one of the internal parameter references, which fetched Lua’s default nil in a low-probability case, resulting in a rare but certain crash.
There is a trick, fairly deep in the bag, that would let us modify Lua’s global table so as not to allow references to undefined terms. I’ve never tried it, but it might well have detected this error and given us a better diagnostic. I’ll at least make a note of the idea.
Beyond that, we’re going to encounter a small number of defects due to failed edits or simple typographical errors.
Now it is quite arguable that there should be a test for this case, since there is very clearly some custom code to handle it:
if #result == 0 then
result = {startingTile}
end
I’m not sure that we even have tests directly aimed at these availableTiles...
methods. Well, it turns out that we have one weak test:
_:test("Dungeon helps monster moves", function()
local tiles, x1,x2
local dungeon = Runner:getDungeon()
dungeon:setTestPlayerAtPosition(vec2(30,30))
local monsterTile = dungeon:getTile(vec2(34,34))
_:expect(dungeon:manhattanToPlayer(monsterTile)).is(8)
tiles = dungeon:availableTilesCloserToPlayer(monsterTile)
x1,x2 = dungeon:getTile(vec2(33,34)), dungeon:getTile(vec2(34,33))
_:expect(tiles).has(x1)
_:expect(tiles).has(x2)
tiles = dungeon:availableTilesFurtherFromPlayer(monsterTile)
x1,x2 = dungeon:getTile(vec2(35,34)), dungeon:getTile(vec2(34,35))
_:expect(tiles).has(x1)
_:expect(tiles).has(x2)
tiles = dungeon:availableTilesSameDistanceFromPlayer(monsterTile)
x1,x2 = dungeon:getTile(vec2(33,35)), dungeon:getTile(vec2(35,33))
_:expect(tiles).has(x1)
_:expect(tiles).has(x2)
end)
This doesn’t even check the empty case. Since we’ve refactored those methods to get at some inter-tile ones, we really need more tests for these.
I’ve made a note of that and I think we’ll start there tomorrow.
I also learned that it’s not as easy as it might be to set up a special purpose monster. Monsters are now created by the Monsters
class. Creating one in GameRunner seems not to be quite the thing. We’ll work on that soon as well, perhaps also tomorrow.
For now … we made some decent progress on improving out Map pathfinding, and we have what seems to be a HangoutMonsterStrategy ready to go but untested.
A decent day, but not a great one. We’ll try to do better tomorrow.
Commit: HangoutMonsterStrategy and refactoring.
Rather a large commit, hit 6 files and 90 lines added or changed. That’s surely larger than I’d like to do. Should try to bear down on smaller more frequent commits.
See you next time!