Dungeon 106
We have to set a trap in a hallway. Let’s make that more efficient to do. Also, we don’t do it in the more efficient way after all. Also invariants. Also: Ron defines ‘should’.
Before I start on moving the spikes into a hallway, let’s review the notes on my yellow sticky notes about this program. The idea is to show a summary of what I think about, and, perhaps, a useful way of keeping track of ideas. XXX marks items I’ve lined out as no longer relevant.
- Use __newindex to detect wrong variable names
- XXX Should monster moves turn off player movement? Nesting?
- XXX Floater tests
- DONE Don’t block halls with chests
- DONE Don’t start monsters too close to player
- DONE Don’t put things too close to player
- Codea’s a pain when there are lots of tests. Use separate projects for tests??
- Poison
- Don’t die (good advice for most of us)
- DONEish Levels
- Leveling up
- Traps
- Puzzles
- Doors and Keys
- Decor e.g. skeletons
- Gold and a Store?
- Health improves after injury
- Poison -2 / interval?
- Starting message by level
- Magic “1” in getRandomMonsters, make param
- CombatRound appendText vs display is confusing
- Make CombatRound easier to use in e.g. Spikes
I do have some arguably useful points to make about these yellow sticky notes.
Stories and Documents
Some would-be “Agile” folks would call most of these “stories” and would require that they be written down, in much more detail than we have here. Now of course, there’s just one of me here, although we do have a few personalities, so just writing things on sticky notes makes sense. But at what scale do we need more, and in what time frame do we need more detail?
On the C3 project, there were 14 or more team members, and our “product owner” kept all the stories for a corporate payroll on index cards in a lunch box. When a story’s time had come, we’d flesh that one out with discussion and tests. Only rarely would there ever be a document, usually because some other department sent us information or requirements in document form.
A team working together on a product doesn’t need a lot of written communication. They may need historical information, and they may have specific given requirements for documentation, which may or may not make sense. If we have to do it, we have to do it. If we don’t have to do it, maybe wwe shouldn’t do it. If we don’t need it yet, maybe we shouldn’t do it now.
I’m not here to say what you should or shouldn’t do. I’m here to observe that in the more than a half-century I’ve been doing software development, I’ve seen more useless documentation than I have seen useful. And, yes, there were times when we said “if only this had been documented”. I can’t think of a single time when we said that, where some other similar project would have documented that kind of information. The things we want to know are usually so detailed that no one in their right mind would write them down on “paper”. We could, and in my view should, make the code clear as to what’s going on and why.
Enough. Let’s place those spikes.
Placing Spikes
Our mission is to place spike traps in narrow hallways. This is made difficult by our current design. Recall that the bulk of the game’s logic takes place in an array of Tiles, which can be Edge, Wall, or Room. (Room might well have been called “Floor”, but it wasn’t.)
When we create the dungeon, it’s all Edge. Then we randomly place Rooms, setting their central area to type Room, and their border to type Wall. We set each room tile to the room number of the room, simply because we want to avoid room 1 for object placement. (That magic “1” spoken of up above.)
Then we cut hallways from room n to room n-1, going randomly first vertically or horizontally, then horizontally or vertically, laying down Tiles set to type Room with no room number.
After all that is done, we have a moderately complex process of computing the best-looking tiles for the walls, which includes setting any Edge tiles adjacent to Room tiles to Walls. That has the effect of putting walls around the hallways.
Then we plunk things down. That’s always done in essentially the same way: we pick a random tile anywhere in the space, and if it is a Room tile, and it has other Room tiles around it, we plunk down the item.
That’s not terribly efficient: there are generally far more non-Room tiles than Room tiles. What that means in actual effect is that it probably takes us a half-dozen probes to find a place to put something. Since that probe just computes two random integers and inspects a table location, that inefficient process takes so little time that we don’t notice it.
However. For our spike placement, we need a hallway tile, and not just any old hallway tile. We need one that has hallway tiles on each of two opposing sides, and that does not have hallway tiles on the other sides. Since hallways can run right beside each other, we need to avoid that case.
(An alternative would be that if we can detect that we’re in a wide hallway, we’d put Spikes all the way across. That would be just fine and we should keep it in mind.)
Here’s the “more efficient way.”
Now, we could just do our random probes and detect hallway tiles. It’s not difficult to code. When I started this article, however, my plan was to change the random searching code to know how to always find Room tiles, and I was thinking I’d keep track of hallway tiles separately.
By the time I got to the paragraph above, however, I changed my mind. Yes, saving more info might be useful. But let’s just go ahead and find isolated hallway tiles.
Brute Force May Sometimes Be An Answer
We’ll talk more about this after this works. For now let’s do the thing.
GameRunner, when it creates a level, calls this function:
function GameRunner:placeSpikes()
local r1 = self.rooms[1]
local rcx,rcy = r1:center()
local tile = self:getTile(vec2(rcx-2,rcy+2))
Spikes(tile)
end
I’m reminded that we have to place the WayDown better as well. I write on a sticky note: Place WayDown.
The code above just places the Spikes above and to the left of the center of the first room. The princess rezzes in the center. We do have some random placement code to look at, for example this:
function GameRunner:createThings(aClass, n)
local things = {}
for i = 1,n or 1 do
local tile = self:randomRoomTile(1)
table.insert(things, aClass(tile,self))
end
return things
end
We use a function randomRoomTile
, which looks like this:
function GameRunner:randomRoomTile(roomToAvoid)
return self:getDungeon():randomRoomTile(roomToAvoid)
end
function Dungeon:randomRoomTile(roomToAvoid)
while true do
local pos = vec2(math.random(1, self.tileCountX), math.random(1,self.tileCountY))
local tile = self:getTile(pos)
if tile:getRoomNumber() ~= roomToAvoid and tile:isOpenRoom() then return tile end
end
end
We could shape a method getRandomHallwayTile
after that one. We do have tests on Dungeon, so maybe we can test at least part of that function. I’m rather reluctant, however, because of the need to create a large space of tiles to work in.
Let’s try this without a test and see where we can find … ah, just thinking about it gives me an idea.
Suppose we had a function like this:
function isOpenHallwayTile(tile, up, down, left, right)
The first parameter is the candidate tile, and the other four are the tiles above, below, left and right of it. This function just answers whether the tile is OK. That, we can TDD!
_:test("is open hallway tile", function()
local tile, up, down, left, right
tile = Tile:room(100,100)
up = Tile:wall(100,101)
down = Tile:wall(100,99)
left = Tile:wall(99,100)
right = Tile:wall(101,100)
_:expect(tile:isOpenHallway()).is(false)
end)
If this fails looking for isOpenHallway, we’re in good shape.
3: is open hallway tile -- Tile:14: Attempt to create room tile when locked
OK, not a problem. How do we unlock these babies?
_:before(function()
local gm = GameRunner()
gm:createLevel(12)
dungeon = gm:getDungeon()
_TileLock = TileLock
TileLock = false
end)
_:after(function()
TileLock = _TileLock
end)
Now then:
3: is open hallway tile -- TestDungeon:42: attempt to call a nil value (method 'isOpenHallway')
As expected. Nice. Now:
_:test("is open hallway tile", function()
local tile, up, down, left, right
tile = Tile:room(100,100)
up = Tile:wall(100,101)
down = Tile:wall(100,99)
left = Tile:wall(99,100)
right = Tile:wall(101,100)
_:expect(tile:isOpenHallway(up,down,left,right)).is(false)
end)
And …
function Tile:isOpenHallway(up,down,left,right)
return false
end
“Fake it till you make it”. Test should run green. Yes. Now:
up = Tile:room(100,101)
down = Tile:room(100,99)
_:expect(tile:isOpenHallway(up,down,left,right), "up down OK").is(true)
I expect that to fail of course.
3: is open hallway tile up down OK -- Actual: false, Expected: true
Let’s code:
function Tile:isOpenHallway(up,down,left,right)
return up:isRoom() and down:isRoom()
end
This is of course insufficient. Shall I extend this torture all the way? Or shall I just code this up now that we see the pattern? Let’s extend the test a bit at least:
left = Tile:room(99,100)
right = Tile:room(101,100)
_:expect(tile:isOpenHallway(up,down,left,right), "up down left right not OK").is(false)
3: is open hallway tile up down left right not OK -- Actual: true, Expected: false
And:
function Tile:isOpenHallway(up,down,left,right)
return up:isRoom() and down:isRoom() and not left:isRoom() and not right:isRoom()
end
Green. I could check just one or the other but the code is clear. Let’s do the other direction:
_:test("is open hallway tile left right", function()
local tile, up, down, left, right
tile = Tile:room(100,100)
up = Tile:wall(100,101)
down = Tile:wall(100,99)
left = Tile:room(99,100)
right = Tile:room(101,100)
_:expect(tile:isOpenHallway(up,down,left,right)).is(true)
up = Tile:room(100,101)
down = Tile:room(100,99)
_:expect(tile:isOpenHallway(up,down,left,right), "up down left right not OK").is(false)
end)
4: is open hallway tile left right -- Actual: false, Expected: true
And:
function Tile:isOpenHallway(up,down,left,right)
return up:isRoom() and down:isRoom() and not left:isRoom() and not right:isRoom()
or left:isRoom() and right:isRoom() and not up:isRoom() and not down:isRoom()
end
I expect this to work.
And it does. Now I feel that we’ve done our duty. Let’s code the routine that uses this, similar but not the same as this one:
function Dungeon:randomRoomTile(roomToAvoid)
while true do
local pos = vec2(math.random(1, self.tileCountX), math.random(1,self.tileCountY))
local tile = self:getTile(pos)
if tile:getRoomNumber() ~= roomToAvoid and tile:isOpenRoom() then return tile end
end
end
function Dungeon:randomHallwayTile()
while true do
local pos = vec2(math.random(1, self.tileCountX), math.random(1,self.tileCountY))
local tile = self:getTile(pos)
local up = self:getTile(pos+vec2(0,1))
local down = self:getTile(pos-vec2(0,1))
local left = self:getTile(pos-vec2(1,0))
local right = self:getTile(pos+vec2(1,0))
if tile:isOpenHallway(up,down,left,right) then return tile end
end
end
This makes sense to me. Let’s use it.
function GameRunner:placeSpikes()
local tile = self:getDungeon():randomHallwayTile()
Spikes(tile)
end
This runs OK, but I’ll never find the spikes. Let’s place lots of them:
function GameRunner:placeSpikes(count)
for i = 1,count or 20 do
local tile = self:getDungeon():randomHallwayTile()
Spikes(tile)
end
end
Now for a run. Immediately, I find a bug.
We’re not checking to see if the tile in question is a room tile. :)
If I were a good person, I’d enhance my tests. OK, OK, here goes.
_:test("is open hallway tile up down", function()
local tile, up, down, left, right
tile = Tile:wall(100,100)
up = Tile:wall(100,101)
down = Tile:wall(100,99)
left = Tile:wall(99,100)
right = Tile:wall(101,100)
_:expect(tile:isOpenHallway(up,down,left,right)).is(false)
up = Tile:room(100,101)
down = Tile:room(100,99)
_:expect(tile:isOpenHallway(up,down,left,right), "central isn't room").is(false)
tile = Tile:room(100,100)
_:expect(tile:isOpenHallway(up,down,left,right), "up down OK").is(true)
left = Tile:room(99,100)
right = Tile:room(101,100)
_:expect(tile:isOpenHallway(up,down,left,right), "up down left right not OK").is(false)
end)
3: is open hallway tile up down central isn't room -- Actual: true, Expected: false
function Tile:isOpenHallway(up,down,left,right)
if not self:isRoom() then return false end
return up:isRoom() and down:isRoom() and not left:isRoom() and not right:isRoom()
or left:isRoom() and right:isRoom() and not up:isRoom() and not down:isRoom()
end
OK? Happy now? Let’s run again.
Yikes, something weird has happened. I found spikes in the hallway OK but when I stepped on them:
TestSpikes:63: attempt to call a nil value (method 'addToCrawl')
stack traceback:
TestSpikes:63: in method 'actionWith'
Player:225: in local 'action'
TileArbiter:27: in method 'moveTo'
Tile:87: in method 'attemptedEntranceBy'
Tile:331: in function <Tile:329>
(...tail calls...)
Player:172: in method 'moveBy'
Player:128: in method 'executeKey'
Player:166: in method 'keyPress'
GameRunner:257: in method 'keyPress'
Main:34: in function 'keyboard'
Did I just cause that now? Or what? The code is this:
function Spikes:actionWith(player)
local co = CombatRound(self,player)
co:appendText("Spikes impale "..player:name().."!")
local damage = math.random(self:damageLo(), self:damageHi())
co:applyDamage(damage)
self.tile.runner:addToCrawl(co:getCommandList())
end
The tile I’m in appears not to have a runner.
This raises an issue that is troubling at a higher level. We’ve chosen the style of passing a runner to any object that may need it. This seems better than having a global variable, although in practice, there’s really only ever going to be one runner. I know darn well I could grab the global here, because in fact there is on, named Runner
. But no. Let’s find the problem.
It appears that I have created a tile with a Dungeon in it, not a GameRunner. I’ll put a trap in tile creation and see how this has come about.
function Tile:init(x,y,kind, runner, roomNumber)
assert(runner:__tostring() ~= "GameRunner", "Spurious GameRunner in Tile")
self.position = vec2(x,y)
self.kind = kind
self.runner = runner
self.roomNumber = roomNumber
self:initDetails()
end
Of course this is going to break some tests.
Tile:55: Spurious GameRunner in Tile
stack traceback:
[C]: in function 'assert'
Tile:55: in field 'init'
... false
end
setmetatable(c, mt)
return c
end:24: in function <... false
end
setmetatable(c, mt)
return c
end:20>
(...tail calls...)
GameRunner:29: in method 'createTiles'
GameRunner:94: in method 'createLevel'
TestDungeon:13: in field '_before'
CodeaUnit:44: in method 'test'
TestDungeon:23: in local 'allTests'
CodeaUnit:16: in method 'describe'
TestDungeon:9: in function 'testDungeon'
[string "testDungeon()"]:1: in main chunk
CodeaUnit:139: in field 'execute'
Tests:364: in function 'runCodeaUnitTests'
Main:10: in function 'setup'
Arrgh. Need more info.
Flinging asserts around, I get this:
Tile:57: Spurious GameRunner in Tile
stack traceback:
[C]: in function 'assert'
Tile:57: in field 'init'
... false
end
setmetatable(c, mt)
return c
end:24: in function <... false
end
setmetatable(c, mt)
return c
end:20>
(...tail calls...)
TestDungeon:125: in method 'setHallwayTile'
TestDungeon:138: in method 'verticalCorridor'
Room:87: in method 'vhCorridor'
Room:43: in method 'connect'
GameRunner:63: in method 'connectRooms'
GameRunner:97: in method 'createLevel'
TestDungeon:15: in field '_before'
CodeaUnit:44: in method 'test'
TestDungeon:26: in local 'allTests'
CodeaUnit:16: in method 'describe'
TestDungeon:9: in function 'testDungeon'
[string "testDungeon()"]:1: in main chunk
CodeaUnit:139: in field 'execute'
Tests:364: in function 'runCodeaUnitTests'
Main:10: in function 'setup'
Let’s check that method:
function Dungeon:setHallwayTile(x,y)
local t = self:privateGetTileXY(x,y)
if not t:isRoom() then
self:setTile(Tile:room(x,y,self))
end
end
Right. There we go, setting the tile’s runner to self
, a Dungeon. That will never do. Worse, though, is that the Dungeon doesn’t know the runner. So we’re going to need to pass it down and down and down …
function Dungeon:setHallwayTile(x,y, runner)
local t = self:privateGetTileXY(x,y)
if not t:isRoom() then
self:setTile(Tile:room(x,y,runner))
end
end
function Dungeon:horizontalCorridor(fromX, toX, y, runner)
fromX,toX = math.min(fromX,toX), math.max(fromX,toX)
for x = fromX,toX do
self:setHallwayTile(x,y, runner)
end
end
function Dungeon:verticalCorridor(fromY, toY, x, runner)
fromY,toY = math.min(fromY,toY), math.max(fromY,toY)
for y = fromY, toY do
self:setHallwayTile(x,y, runner)
end
end
function Room:hvCorridor(dungeon, aRoom)
local startX,startY = self:center()
local endX,endY = aRoom:center()
dungeon:horizontalCorridor(startX,endX,startY, self.runner)
dungeon:verticalCorridor(startY,endY,endX, self.runner)
end
function Room:vhCorridor(dungeon, aRoom)
local startX,startY = self:center()
local endX,endY = aRoom:center()
dungeon:verticalCorridor(startY,endY,startX, self.runner)
dungeon:horizontalCorridor(startX,endX,endY, self.runner)
end
I expect this to do the trick. My expectations are dashed:
Tile:57: Spurious GameRunner in Tile
stack traceback:
[C]: in function 'assert'
Tile:57: in field 'init'
... false
end
setmetatable(c, mt)
return c
end:24: in function <... false
end
setmetatable(c, mt)
return c
end:20>
(...tail calls...)
Tile:221: in method 'getSurroundingInfo'
Tile:107: in method 'convertEdgeToWall'
GameRunner:71: in method 'convertEdgesToWalls'
GameRunner:98: in method 'createLevel'
TestDungeon:15: in field '_before'
CodeaUnit:44: in method 'test'
TestDungeon:26: in local 'allTests'
CodeaUnit:16: in method 'describe'
TestDungeon:9: in function 'testDungeon'
[string "testDungeon()"]:1: in main chunk
CodeaUnit:139: in field 'execute'
Tests:364: in function 'runCodeaUnitTests'
Main:10: in function 'setup'
That’s here:
function Tile:getSurroundingInfo()
local byte = 0
local ck = { vec2(1,1),vec2(0,1),vec2(-1,1), vec2(1,0),vec2(-1,0), vec2(1,-1),vec2(0,-1),vec2(-1,-1) }
for i,p in ipairs(ck) do
byte = byte<<1
local pos = self:pos() + p
local tile = self.runner:getTile(pos)
if tile:isRoom() then
byte = byte|1
end
end
return byte
end
function GameRunner:getTile(aPosition)
return self:getDungeon():getTile(aPosition)
end
function Dungeon:getTile(pos)
return self:privateGetTileXY(pos.x, pos.y)
end
function Dungeon:privateGetTileXY(x,y)
if x<=0 or x>self.tileCountX or y<=0 or y>self.tileCountY then
return Tile:edge(x,y, self)
end
return self.tiles[x][y]
end
Yes, here again we need the runner. Our move of capability over to Dungeon has worked, but is violating some agreements about tiles having runners. Our choice is to create the Dungeon knowing the runner, and use it, or to pass it all down down down. Having found two places needing that, I’m going to go for the former.
function Dungeon:init(tiles, runner)
self.tiles = tiles
self.runner = runner
self.tileCountX = #self.tiles - 1
self.tileCountY = #self.tiles[1] - 1
end
function GameRunner:getDungeon()
return Dungeon(self.tiles, self)
end
And fix the get:
function Dungeon:privateGetTileXY(x,y)
if x<=0 or x>self.tileCountX or y<=0 or y>self.tileCountY then
return Tile:edge(x,y, self.runner)
end
return self.tiles[x][y]
end
And back out those other changes:
function Dungeon:setHallwayTile(x,y)
local t = self:privateGetTileXY(x,y)
if not t:isRoom() then
self:setTile(Tile:room(x,y,t.runner))
end
end
Here, I reused the tile’s existing runner. Seemed more direct.
Elsewhere I just removed the passing of runner and the expectation of receiving it.
However, I’ve broken something else.
GameRunner:231: bad argument #1 to 'for iterator' (table expected, got nil)
stack traceback:
[C]: in function 'next'
GameRunner:231: in method 'hasMonsterNearPlayer'
MusicPlayer:76: in method 'checkForDanger'
That’s here:
function GameRunner:hasMonsterNearPlayer(range)
for k,m in pairs(self.monsters) do
if m:isAlive() and m:distanceFromPlayer() <= range then return true end
end
return false
end
How do we manage to have no monsters? I believe that is likely a tween running in the game runner that has no monsters. As a hack, I do this:
function GameRunner:hasMonsterNearPlayer(range)
for k,m in pairs(self.monsters or {}) do
if m:isAlive() and m:distanceFromPlayer() <= range then return true end
end
return false
end
That will provide an empty collection if there are no monsters. Another way to fix it would be to init the monsters member to {} in init
.
I think we are somewhat solid at the moment. Let me remove some debug prints and we’ll have a little retro.
Two tests are failing, both this way:
3: monsters correctly initialized level 2 -- Tile:57: Spurious GameRunner in Tile
This is in testMonsterLevels, and the fix is this:
_:before(function()
_runner = Runner
end)
_:after(function()
Runner = _runner
end)
_:test("monsters correctly initialized", function()
local gm = GameRunner()
Runner = gm
gm:createLevel(12)
local mt = gm.monsters
_:expect(#mt).is(6)
for i,m in ipairs(mt) do
_:expect(m.level).is(1)
end
end)
_:test("monsters correctly initialized level 2", function()
local gm = GameRunner()
Runner=gm
gm:createLevel(12)
gm:createLevel(12)
local mt = gm.monsters
_:expect(#mt).is(6)
for i,m in ipairs(mt) do
_:expect(m.level).is(2)
end
end)
There were others. After due consideration, I think that ease of writing tests is more important than this check, and I remove it but leave it in a comment for consideration later:
function Tile:init(x,y,kind, runner, roomNumber)
--[[
if runner ~= Runner then
print(runner)
assert(false, "Spurious GameRunner in Tile")
end
]]---
self.position = vec2(x,y)
self.kind = kind
self.runner = runner
self.roomNumber = roomNumber
self:initDetails()
end
All is well, although an odd thing happened. I stepped on a spike and got damaged twice. I believe there were two spikes in the same location, because other spikes didn’t do the same thing.
We don’t limit things being placed to empty cells, but in general I think we should.
In any case, we seem to have accomplished our goal of placing spikes in hallways. We may want some new stories about placing things on top of each other and next to each other. And let’s cut the number of spikes down.
self:placeSpikes(5)
We can commit this: 5 spikes, in hallways. various Dungeon fixes.
We need a retro, and a break. Maybe a retro counts as a break.
Retro
I originally had in mind what I called a “more efficient way” to find hallway tiles. I was going to keep track of them as I created them in the horizontal and vertical hall-carvers. Then when looking for hallway tiles, I could search randomly in those cached tiles.
Then, as I was going along explaining the problem and what I thought I’d do about it, and right after I was thinking that probably less than ten percent of tiles are hallway tiles, I decided to go ahead and search randomly among all the tiles to find our hallway ones.
That code was easier to write, and while it’s far less efficient than the scheme I had in mind, it works fine. The dungeon still generates in effectively zero time, even if we’re placing 20 spikes.
A performance measurement having shown …
Back in the early days of XP, when we thought about writing some special code to be more efficient, Kent Beck advised us never to write special efficient code unless a performance measurement showed us that it was needed. Two decades later, that’s still good advice. The “more efficient” code would have added a new structure to the system, added code to create it, possibly code to update it, and then code to use it. The code there instead looks roughly like
To find a hallway tile, select a random tile, return it if it’s a hallway tile, otherwise try again.
That code requires fewer characters than it just took me to describe it in English. Simpler, easier, fast enough. A decent job, decently done.
Leftover trouble
But toward the end, things went a little wonky, didn’t they? I think the only real issue was that Dungeon could create tiles that didn’t have the GameRunner as their runner member. That is usually harmless, because Tiles only rarely talk back to the runner.
They call back to runner to get neighbors … but Dungeon actually does that operation, so those calls “just worked”. But when we went to create new tiles, not so good.
Essentially we broke what we call an “invariant”, a rule that is never to be broken, in this case “A Tile has a pointer to the living GameRunner”. We’ve never made that invariant (or any other) particularly explicit. There’s a style of development with focus on invariants, but while I’ve used the technique in the past, I don’t generally do so now. As we saw when tests failed, putting a check for an invariant in late in the day can cause problems. It caused enough hassle that I decided to remove the check.
Here in the cold light of late morning, I think that’s a mistake. I should do the check. So I’ll put it back and fix the broken tests. Grr, I hate when I decide to do the right thing.
function Tile:init(x,y,kind, runner, roomNumber)
if runner ~= Runner then
print(runner)
assert(false, "Invariant Failed: Tile must receive Runner")
end
self.position = vec2(x,y)
self.kind = kind
self.runner = runner
self.roomNumber = roomNumber
self:initDetails()
end
Fixing the tests just took a moment. I just had to paste “,Runner” into about ten calls to make tiles, in the TestDungeon tab.
So doing the right thing wasn’t so bad after all.
Looking at the actual defect, which was that I had pasted a couple of self
s where there should have been something else, what might we have done better?
- I could have been smarter, but that ship has sailed.
- I could have had some kind of test that verified the quality of the dungeon, and it could have checked to see that everyone has a runner. No one would think of that.
- I could have expressed the invariant in the tile creation. That’s something one might actually think about.
Be that as it may, those things didn’t happen, and we had a defect that was, I believe, harmless until Spikes landed in hallways. And, unfortunately, that defect was not covered by a test, and was only discovered in manual testing.
Can we think of a test where we can make the case that we “should” have implemented it? I’m really not much about “should”, but I do recognize sometimes that I’ve failed to do something that I’d have been better off doing, or, more frequently, I’ve done something that I’d have been better off not doing.
So I don’t want to beat anyone up when I say “should”. To me, that’s shorthand for something like
- Should have done X
- My life might be a bit better if I more frequently did X in these situations.
Offhand, I’m not thinking of a test that fits. I do think expressing the invariant would have been a useful thing to have done, which is why I’ve done it now. And since we are probably going to be refactoring toward Dungeon a bit more, it may still pay off.
So … what have we learned? Maybe we’ve learned to consider invariants. But I’m a bit sad to say that I’m not feeling that I’ve learned anything useful here, in the sense that I’m not seeing how I’ll change my ways in the future.
That doesn’t mean that I won’t, because I’ll probably be a bit more sensitive to things like this, but I don’t have a thing I can write down and stick to my mirror.
Ah well, it happens sometimes.
I did just get an idea and wrote it on a sticky:
- Make WayDown require a key
I have three tiles with what appear to be chained doors, in three different colors. Here’s one of them, and its matching key:
We could modify the WayDown to display one of those doors, randomly, until you try to enter it with the correct colored key in your inventory. When you do that, it changes to a stairway, and if you then enter, you go to a new level.
That might be amusing.
That reminds me, I noticed that monsters won’t cross the spikes. Should I leave it that way? It makes for an interesting and possibly painful way to avoid monsters. I think we’ll leave it for now.
I’m calling this enough for the day. We have placed spikes in random hallways, and despite a stumble left over from yesterday, tested and developed it rather nicely. That’s a win.
See you next time!