Musing on Technical Debt. A very interesting series of tiny commits leading to an important change: my invariant.

My understanding of technical debt as Ward originally defined it is that it is the difference between the design we have, and the better design we now have in our head. Ward was not thinking of crufty code, poorly-written code, untested code. He was comparing the design that exists with the design we now see.

This morning as that cat tried to get me to get up and feed her, I was thinking about the Tile. At the time I built the basics of the Tile, I didn’t fully realize how central it would become, though I think I did see that it would be an important component of the system. And I was thinking about two of the Tile’s main methods, addDeferredContents and removeContents. Yesterday I recognized an invariant that we need in the system, namely that an object in the dungeon can be in one and only one tile. Yesterday’s work was aimed at ensuring that could happen.

We wound up with this method:

function Tile:moveEntity(thing)
    local from = thing:getTile()
    if from then from:removeContents(thing) end
    self:addDeferredContents(thing)
    thing:setTile(self)
end

When I wrote that method yesterday, it was at first a class method that took two tiles, from and to. I realized that we were always using the tile the thing was on as the from tile, so I changed the method to be sent to the to tile and to fetch the from from the thing.

As the last thing I did with that method yesterday, I moved it into the mostly alphabetic group of instance methods. This morning, as I grabbed that method to display it, I noticed this one right below it:

function Tile:moveEntrant(anEntity, newTile)
    self:removeContents(anEntity)
    newTile:addDeferredContents(anEntity)
end

There seems to be some overlap between these two methods. What happened? Which way is the “right” way? Should one of these be removed? Should one call the other?

I really thought this article would go in a different direction. It’s fascinating how often a thought-design gets overturned in the face of the actual code. Let’s look for senders of moveEntrant.

There is exactly one:

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

The flow down to that method looks like this:

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

function Monster:basicRandomMove(tiles)
    local tile = self.tile:validateMoveTo(self,tiles[math.random(1,#tiles)])
    self:basicMoveToTile(tile)
end

function Monster:basicMoveTowardPlayer(dungeon)
    local tiles = dungeon:availableTilesCloserToPlayer(self.tile)
    self.tile = self.tile:validateMoveTo(self,tiles[math.random(1,#tiles)])
end

That last one can be folded into a call to basicRandomMove. That simplifies things. Test and commit: folded basicMoveTowardPlayer to call basicRandomMove.

Some readers may be thinking “how does calling someone who does a thing ‘simplify’ anything over just calling the thing? It adds yet another layer of calling. That’s not simpler!”

But it is. We’ve taken an idea “validate a random one of these tiles”, which occurred in two places, down to one. And we’re reduced the connections into the validate method.

That’s why I like that change. YMMV, and in your program you get to do it your way.

But I’m still on the track of something, and it’s involved in this legal and validate stuff.

Here’s a trick. I’ve burned a couple of brain cells doing and committing a quick refactoring of that one call to validate. So my brain isn’t entirely fresh on the users of validate and legal, and the smart money says that I should refresh my cache.

There are now just two callers of validateMoveTo, aside from tests.

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

function Monster:basicRandomMove(tiles)
    local tile = self.tile:validateMoveTo(self,tiles[math.random(1,#tiles)])
    self:basicMoveToTile(tile)
end

Lets move up the legal chain:

function Player:moveBy(aStep)
    self.runner:clearTiles()
    local tile = self.tile:legalNeighbor(self,aStep)
    tile:moveEntity(self)
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)]
    local tile = self.tile:legalNeighbor(self,move)
    self:basicMoveToTile(tile)
end

When we look at the validate method:

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

There are two things going on here. (That’s a bit of a code smell, but we do need both things.)

First, we try to enter the cell, which has an important side effect of triggering events, via the Tile Arbiter, which is what opens chests and initiates combat and so on. The attemptedEntrance method also returns a tile, either the new one, if we’re allowed in, or the current tile, if we’re not. Either way, we return the tile we’re supposed to land on, and we return that, again, if we are the call from legalNeighbor, and we finally move wherever we’re told:

function Player:moveBy(aStep)
    self.runner:clearTiles()
    local tile = self.tile:legalNeighbor(self,aStep)
    tile:moveEntity(self)
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)]
    local tile = self.tile:legalNeighbor(self,move)
    self:basicMoveToTile(tile)
end

But we have this:

function Monster:basicMoveToTile(tile)
    tile:moveEntity(self)
end

If we inline that method we can remove it:

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)]
    local tile = self.tile:legalNeighbor(self,move)
    self:basicMoveToTile(tile)
end

That becomes:

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)]
    local tile = self.tile:legalNeighbor(self,move)
    tile:moveEntity(self)
end

Similarly:

function Monster:basicRandomMove(tiles)
    local tile = self.tile:validateMoveTo(self,tiles[math.random(1,#tiles)])
    self:basicMoveToTile(tile)
end

Becomes:

function Monster:basicRandomMove(tiles)
    local tile = self.tile:validateMoveTo(self,tiles[math.random(1,#tiles)])
    tile:moveEntity(self)
end

There’s one more call:

function PathMonsterStrategy:execute(dungeon)
    local newTile = self.map:nextAfter(self.monster:getTile())
    if newTile then
        self.monster:basicMoveToTile(newTile)
        self.tile = self.monster:getTile()
    else
        self.monster:pathComplete()
    end
end

We inline:

function PathMonsterStrategy:execute(dungeon)
    local newTile = self.map:nextAfter(self.monster:getTile())
    if newTile then
        newTile:moveEntity(self)
        self.tile = self.monster:getTile()
    else
        self.monster:pathComplete()
    end
end

But:

function Tile:moveEntity(thing)
    local from = thing:getTile()
    if from then from:removeContents(thing) end
    self:addDeferredContents(thing)
    thing:setTile(self)
end

That sets the tile. Therefore:

function PathMonsterStrategy:execute(dungeon)
    local newTile = self.map:nextAfter(self.monster:getTile())
    if newTile then
        newTile:moveEntity(self)
    else
        self.monster:pathComplete()
    end
end

There are now no calls to the basicMoveToTile. Remove it.

Commit: refactor to remove basicMoveToTile.

Now, I want to see what other methods are setting tile directly.

Here’s one:

function Monster:init(tile, runner, mtEntry)
    if not MT then self:initMonsterTable() end
    self.tile = tile
    self.tile:addDeferredContents(self)
    self.runner = runner
...

We can, and should, do this:

function Monster:init(tile, runner, mtEntry)
    if not MT then self:initMonsterTable() end
    tile:moveEntity(self)
    self.runner = runner
...

A small but notable simplification, fanning in a bit more.

function Monster:moveTo(aTile)
    self.tile = aTile
end

Who’s calling this? A search makes me think that it is never called. Remove it and test. Everything seems fine. Nice.

function WayDown:init(tile,runner)
    self.runner = runner
    self.tile = tile
    self.tile:addDeferredContents(self)
end

That becomes:

function WayDown:init(tile,runner)
    self.runner = runner
    tile:moveEntity(self)
end

I think this may fail for lack of the protocol expected, as it’s the first object that isn’t a subclass of Entity that I’ve tried to move. We need to implement getTile and setTile if they’re not there.

function WayDown:setTile(tile)
    self.tile = tile
end

When I rez a PathFinder and let it move, I get this crash:

Tile:332: attempt to call a nil value (method 'getTile')
stack traceback:
	Tile:332: in method 'moveEntity'
	MonsterStrategy:92: in method 'execute'
	Monster:280: in method 'executeMoveStrategy'
	Monster:231: in method 'chooseMove'
	Monsters:75: in method 'move'
	GameRunner:371: in method 'playerTurnComplete'
	Player:253: in method 'turnComplete'
	Player:181: in method 'keyPress'
	GameRunner:321: in method 'keyPress'
	Main:38: in function 'keyboard'

That won’t be the current change. That’ll be in the pathfinder somewhere. What’s MonsterStrategy:92?

function PathMonsterStrategy:execute(dungeon)
    local newTile = self.map:nextAfter(self.monster:getTile())
    if newTile then
        newTile:moveEntity(self)
    else
        self.monster:pathComplete()
    end
end

Ah. That was a change or two back, and clearly I never did a decent test. We can’t move the strategy, we need to move the monster:

function PathMonsterStrategy:execute(dungeon)
    local newTile = self.map:nextAfter(self.monster:getTile())
    if newTile then
        newTile:moveEntity(self.monster)
    else
        self.monster:pathComplete()
    end
end

Now back to my testing of the pathfinder and waydown …

Works now. Commit: fixed problem in pathfinder strategy; WayDown uses moveEntity.

Remarks

We’re following our nose here, but sniffing out a particular scent, people who set a tile directly. I’m searching for .tile =. It seems that most of those should use our moveEntity (which should get renamed). Let’s rename it now, to moveObject.

Commit: rename moveEntity to moveObject

Back to tile-setters.

function Tile:initDetails()
    self.contents = DeferredTable()
    self.seen = false
    self:clearVisible()
    self:setTile(nil)
end

My general practice is that if a class has an accessor, the class should use the accessor. However, I am not perfect about finding and replacing direct access. I did the one above in the spirit of the general practice. I make a firm purpose of amendment.

The Tile object has a member variable called tile. That should be renamed to sprite. Do it.

The previous clever move in tile was mistaken. Should have been:

function Tile:initDetails()
    self.contents = DeferredTable()
    self.seen = false
    self:clearVisible()
    self.sprite = nil
end

So much for “general practice”. What we had there was an interesting defect. Throughout the whole system, the word tile means an instance of Tile. Except inside Tile, where it meant sprite. I cleverly confused myself. All better now.

It takes me a few more tries to find all the tile references in Tile, but I find them. (Codea search is weak, and so am I.) Commit: rename Tile.tile to Tile.sprite.

Back to searching for .tile =

Here’s an interesting one:

function HangoutMonsterStrategy:init(monster, tile)
    self.monster = monster
    self.tile = tile
    self.min = 3
    self.max = 6
end

The hangout strategy is to remain between 3 and 6 tiles away from the target tile. We have called that tile tile. Not a great name. Let’s rename it to targetTile.

function HangoutMonsterStrategy:init(monster, targetTile)
    self.monster = monster
    self.targetTile = targetTile
    self.min = 3
    self.max = 6
end

function HangoutMonsterStrategy:execute(dungeon)
    local method = self:selectMove(self.monster:manhattanDistanceFromTile(self.targetTile))
    self.monster[method](self.monster, dungeon, self.targetTile)
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

Test and commit: rename tile to targetTile in HangoutMonsterStrategy.

Continuing … but note that I could stop at any point, each one a bit further along toward goodness.

Ah, a new candidate:

function Loot:init(tile, kind, min, max)
    self.tile = tile
    self.kind = kind
    self.icon = self:getIcon(self.kind)
    self.min = min
    self.max = max
    if tile then tile:addDeferredContents(self) end
end

This will need a setTile, and we need to take note of the fact that we may not have a tile to move to. I think that’s fine, though I rather feel like I should explicitly set it to nil.

function Loot:init(tile, kind, min, max)
    self.tile = nil
    if tile then tile:moveObject(self) end
    self.kind = kind
    self.icon = self:getIcon(self.kind)
    self.min = min
    self.max = max
end

Test. A Loot test fails:

1: Create Health Loot -- Loot:11: attempt to call a nil value (method 'moveObject')

That test isn’t bearing any weight. I remove it entirely. Remind me to speak about that later.

Another test fails, Tiled Game. Bummer, someone was using the FakePlayer from that tab. Revert. Discover it’s a reference to FakePlayer in Loot. Remove that. Spike test fails. Revert again. Someone wants FakeTile. Revert. Remove the test, keep the fake objects.

Test. Tests are green. Rename tab to FakePlayerAndTile. Test again, commit: removed testLoot, retain FakePlayer and FakeTile

Back to the search. The revert lost the changes here:

function Loot:init(tile, kind, min, max)
    self.tile = tile
    self.kind = kind
    self.icon = self:getIcon(self.kind)
    self.min = min
    self.max = max
    if tile then tile:addDeferredContents(self) end
end
function Loot:init(tile, kind, min, max)
    self.tile = nil
    self.kind = kind
    self.icon = self:getIcon(self.kind)
    self.min = min
    self.max = max
    if tile then tile:moveObject(self) end
end

And a set method as usual:

function Loot:setTile(tile)
    self.tile = tile
end

Test. Commit: convert Loot to use moveObject.

I am tempted to break, but I’m having a sort of rhythmic fun doing these replacements.

function Key:init(tile, runner)
    self.tile = tile
    self.tile:addDeferredContents(self)
    self.runner = runner
end

Add a setter and then:

function Key:init(tile, runner)
    self.runner = runner
    tile:moveObject(self)
end

Test. Commit: convert Key to use moveObject.

function Chest:init(tile, runner)
    self.tile = tile
    self.tile:addDeferredContents(self)
    self.runner = runner
    self.closedPic = asset.builtin.Planet_Cute.Chest_Closed
    self.openPic = asset.builtin.Planet_Cute.Chest_Open
    self.pic = self.closedPic
end

Ditto. Test, commit: convert Chest to use moveObject.

function Spikes:init(tile, tweenDelay)
    self.delay = tweenDelay or tween.delay
    self.tile = tile
    self.tile:addDeferredContents(self)
    self.damageTable = { down={lo=1,hi=1}, up={lo=4,hi=7}}
    self.assetTable = { down=asset.trap_down, up=asset.trap_up }
    self.verbs = {down="jab ", up="impale " } 
    self:up()
    self:toggleUpDown()
end

Again, same drill.

function Spikes:init(tile, tweenDelay)
    self.delay = tweenDelay or tween.delay
    tile:moveObject(self)
    self.damageTable = { down={lo=1,hi=1}, up={lo=4,hi=7}}
    self.assetTable = { down=asset.trap_down, up=asset.trap_up }
    self.verbs = {down="jab ", up="impale " } 
    self:up()
    self:toggleUpDown()
end

Test. Test fails. FakeTile needed improvement.

Commit: convert spikes to moveObject.

function Decor:init(tile,runner,kind)
    self.sprite = kind and DecorSprites[kind] or Decor:randomSprite()
    self.tile = tile
    self.tile:addDeferredContents(self)
    self.runner = runner
    self.scaleX = ScaleX[math.random(1,2)]
end

Same drill. Test, commit: Decor converted to moveObject.

That’s the last one. There are no direct setters of tile member variables in the system, other than inside a setTile method. Where are all the senders of setTile?

There are a series of setTile methods used in dungeon creation:

function GameRunner:createTiles()
    self.tiles = {}
    for x = 1,self.tileCountX+1 do
        self.tiles[x] = {}
        for y = 1,self.tileCountY+1 do
            local tile = Tile:edge(x,y, self)
            self:setTile(tile)
        end
    end
end

function GameRunner:setTile(aTile)
    self:getDungeon():setTile(aTile)
end

Inline and cache that:

function GameRunner:createTiles()
    self.tiles = {}
    local dungeon = self:getDungeon()
    for x = 1,self.tileCountX+1 do
        self.tiles[x] = {}
        for y = 1,self.tileCountY+1 do
            local tile = Tile:edge(x,y, self)
            dungeon:setTile(tile)
        end
    end
end

But the method is poorly named. It should be defineTile.

function GameRunner:createTiles()
    self.tiles = {}
    local dungeon = self:getDungeon()
    for x = 1,self.tileCountX+1 do
        self.tiles[x] = {}
        for y = 1,self.tileCountY+1 do
            local tile = Tile:edge(x,y, self)
            dungeon:defineTile(tile)
        end
    end
end

function Dungeon:defineTile(aTile)
    assert(not TileLock, "attempt to set tile while locked")
    local pos = aTile:pos()
    self.tiles[pos.x][pos.y] = aTile
end

function Dungeon:setHallwayTile(x,y)
    local t = self:privateGetTileXY(x,y)
    if not t:isRoom() then
        self:defineTile(Tile:room(x,y,t.runner))
    end
end

Test. Tests fail. Don’t look. Revert.

Revert Without Looking???

Yes. I was just flying along. Clearly did something wrong. Don’t care what it was. Revert, slow down, do again. Do better.

function GameRunner:createTiles()
    self.tiles = {}
    for x = 1,self.tileCountX+1 do
        self.tiles[x] = {}
        for y = 1,self.tileCountY+1 do
            local tile = Tile:edge(x,y, self)
            self:setTile(tile)
        end
    end
end

Rename this method to defineTile in this class only.

Ah, interesting. Room:paint calls setTile.

function Room:paint(roomNumber)
    for x = self.x1,self.x2 do
        for y = self.y1,self.y2 do
            self.runner:defineTile(self:correctTile(x,y, roomNumber))
        end
    end
end

That was the cause of the trouble, I suspect, and because I was flying along, I didn’t test soon enough to spot it.

We’re green. Commit: rename GameRunner:setTile to defineTile.

Now we can do the rename in dungeon, I think. Yes, green. Commit: rename Dungeon:setTile to defineTile.

Here’s an interesting call to setTile:

function Player:init(tile, runner)
    self.runner = runner
    self:setTile(tile)
    self:initAttributes(self:retainedAttributes())
    self:initSounds()
    self:initVerbs()
end

Can’t we use moveObject here? I think we can and should. Test, commit: use moveObject in Player:init.

At this point, the only sender of setTile in the whole system (other than tests) is this one::

function Tile:moveObject(thing)
    local from = thing:getTile()
    if from then from:removeContents(thing) end
    self:addDeferredContents(thing)
    thing:setTile(self)
end

This method enforces the invariant that only one tile may contain a given thing. We have improved the design to enforce an important invariant, and in so doing, we’ve removed quite a few duplicated calls to add and remove functions.

We do have some new duplication, however, and one other small issue. The duplication is among Decor, Loot, and so on, the inanimate objects that can be in a tile. They each have their own getTile and setTile, all the same.

And we have this:

function Monster:setTile(tile)
    self.tile = tile
end

function Player:setTile(tile)
    self.tile = tile
    self.tile:illuminate()
    self.tile:addDeferredContents(self)
end

The Player has some special stuff in its setTile, t illuminate the dungeon. And that add? That’s not necessary: we know we just did it because the only caller does it. Remove. Test. Commit: remove extra call to addDeferredContents from Player:setTile.

I’d really like to move that illuminate so that all the setTile methods are pure setters. All but this one are.

Can we move it higher up in the player?

We want to illuminate if the player moves. Where’s that clearTiles call?

function Player:moveBy(aStep)
    self.runner:clearTiles()
    local tile = self.tile:legalNeighbor(self,aStep)
    tile:moveObject(self)
end

We can move the illuminate here, but there will be an issue if we do. For example, if we move the player to room 1, we’ll hit the moveObject and the setTile, but where will the illumination change come from?

This is a bit tricky, and it has been a long morning, 0800 to 1120, so let’s wrap it up here and leave a note for later. The note says:

Change Player:setTile to pure setter. Move illumination. (Cache in “Illuminator”?)

Good enough, let’s have a Summary.

Summary

I wanted to speak about removing tests. Historically, my advice has always been to keep them. They’re mostly harmless and might find something. Other, possibly wiser souls recommend removing them if they haven’t found a problem for a long time. Other folks remove them after using them to build an object. That last idea seems weak to me, because when that object changes, the tests may become valuable again.

I think it’s always risky to remove a test, but they do get in the way, and if they require a lot of changing, they may not be carrying their weight.

I didn’t think deeply about removing the test that I removed. I looked at it, decided it wasn’t all that helpful, and nuked it. Interestingly, its Fake objects turned out to be useful elsewhere. So I kept those.

We’ll see whether we get in trouble without that test. My expectation is that we won’t.

Technical Debt

This article started out talking about technical debt, the difference between the design we have and the design we see. Today we moved to a design which I believe is unquestionably better: we have a single method that maintains the old and new tile collections, such that the rule that a given object can only be on one tile is automatically maintained and enforced. This is a very good thing.

I would argue that looking back from here, we reduced technical debt, but I would have to admit that I didn’t have a clear picture of the design I was moving toward. I did know that I wanted everyone going through that method, but I had no clear picture of the impact on the overall code.

Instead, we just sought out each place where we weren’t using that method, and changed it to use it. We just incrementally improved a bunch of similar things until there were no things left to improve.

Rather nifty. And probably a new record as well: 16 commits in less than four hours. Most of them were extremely atomic. I like the way it felt, with one exception.

The changes were mostly rote, but not entirely so. So when I hit one that wasn’t quite rote, I was moving rather swiftly and messed up the change a time or two. I suspect that’s an issue with tiny commits: one can get to running too fast and suddenly you’re doing that windmilling thing to avoid falling down and sliding on your bare chest on an alley paved with sharp cinders. Or was that just me?

So a steady pace, not too fast, lots of commits. It was good.

But still no poison.

See you next time!


D2.zip