This morning, I think we’ll look at some code. As a start, I have a question about Room. Brief LLM-AI digression.

The Room class has a member variable origin:

class Room:
    def __init__(self, cells, origin, name = ''):
        self.cells:list[Cell] = cells
        self.origin = origin
        self.name = name
        self.color = "grey22"
        count = 0
        for cell in self.cells:
            count = count + 1
            cell.room = self

Why does it have that instance variable? What use do we make of it? Can we get rid of it somehow?

The only use of origin is in test that essentially makes sure that we saved it:

    def test_build(self):
        random.seed(234)
        Cell.create_space(64, 56)
        size = 100
        origin = Cell.at(32, 28)
        room = RoomMaker().cave(number_of_cells=size, origin=origin)
        assert len(set(room.cells)) == 100
        assert room.origin == Cell.at(32, 28)
        self.verify_contiguous(room)

Now we note that RoomMaker does need the origin, because it builds rooms starting at that provided cell, but since there is no use of it in Room, we don’t need it there. Remove the setting line in Room, and Change Signature.

class Room:
    def __init__(self, cells, name=''):
        self.cells:list[Cell] = cells
        self.name = name
        self.color = "grey22"
        count = 0
        for cell in self.cells:
            count = count + 1
            cell.room = self

That one test fails, of course. Remove that check, put in a couple of others just for grins.

    def test_build(self):
        random.seed(234)
        Cell.create_space(64, 56)
        size = 100
        origin = Cell.at(32, 28)
        room = RoomMaker().cave(number_of_cells=size, origin=origin)
        assert len(room.cells) == 100
        assert origin.room == room
        assert origin in room
        self.verify_contiguous(room)

Green. Commit: remove unneeded origin from Room.

Room only has three other real methods, though it has some dunder methods to allow for iteration and a reasonable print. The real methods are:

    def return_cells(self):
        for cell in self.cells:
            cell.room = None

    def reclaim_cells_from(self, other):
        for cell in self.cells:
            other.forget(cell)
            cell.room = self

    def forget(self, cell):
        self.cells.remove(cell)

We use all of those methods when we create our round room with a diamond room inside:

def make_diamond_in_round_room(dungeon: Dungeon, maker: RoomMaker):
    origin = Cell.at(32, 28)
    diamond = maker.diamond(number_of_cells=13, origin=origin)
    diamond.return_cells()
    round = maker.round(radius=5, origin=origin)
    diamond.reclaim_cells_from(round)
    dungeon.add_room(diamond)
    dungeon.add_room(round)

To make a nested room, we create the inner room, return its cells (retaining the cell collection in the inner room), then create the outer room, and then reclaim the inner room’s cells from it, requiring it to forget them.

The Dungeon class also uses return_cells, when we do remove_room. We really only do that in main, as part of trying to connect the rooms by adding lots of large wandering rooms.

In any case, we do use those methods and they seem pretty straightforward, so we’ll let them be.

Reflection

There is a lot of ad hoc code here, various experiments that we have done as we have built up our dungeon making ability. We have no real “production” system in place that organizes these various functions and methods into some higher-level more orderly process. We just have a lot of possibly useful, definitely interesting functions in main and methods in RoomMaker, that make various kinds of rooms.

And we’ll need at least one more such thing, because so far, we have no way to make a rectangular room, which, let’s face it, is kind of the canonical minimum standard dungeon room. We haven’t made them because we know how to make them and we have not known how to draw paths and connect rooms and such, so those capabilities have been higher priority. Sooner or later, someone will ask for a rectangular room and we’ll make one. It’ll just take a few minutes, I expect.

What Next?

We have the following files and classes in our ‘src’ tree:

main.py
params.py
class Cell
    class CellSpace
    class PathHelper
class Dungeon
class DungeonView
class Room
class RoomMaker
class RoomView
class Suite

I show CellSpace and PathHelper subordinate to Cell, because they are only used in Cell and are presently co-resident in file ‘cell.py’.

The View classes do the drawing for us and may become more capable if we expand the current code to be more like a game. Suite is no more than a collection of one or more rooms that have a border in common, with the ability to find a pair of points, one in each or two suites, such that there is no pair of points closer together than the pair we find. We use those points to find a path between the suites:

    def find_path_cells(self, suite) -> list[Cell]:
        source, target = self.find_closest_pair(suite)
        cells = source.find_path(target, [source.room, target.room])
        on_path = cells[1:-1]
        return on_path

That method returns the cells that are on the path but not in the starting rooms: the outside part of the path.

Reflection

There are issues here. We have multiple ways of finding paths now, with an adjustable randomness factor that is not available here. We will need to sort that capability into a more orderly form. And our current pathfinding code can, in principle, include many cells from the source or target room, because find_path isn’t guaranteed to start on an edge cell. It could build a path that goes right across both rooms. We should probably make all paths include only cells that are not in the source or target rooms, or any of the rooms that they are allowed to traverse.

There will be a similar notion to find_path deeper in the game, one that we might call find_route, which could be used by a monster to find its way to the player. That capability would use only existing room or path cells, where what we are doing with find_path is actually allocating new cells to make up a new path in the dungeon.

I’ve made a note of both of those concerns.

Evolving

Here’s a kind of meta-reflection: we are evolving the code here, but we are also evolving our understanding of what the code might be, can be, should be. This is pretty much the exact opposite of the theoretical approach to software design, which is to specify it first, then design it, then code it. I have never, in my six and a half decades of experience, encountered a program that could have been correctly specified at the beginning. I have never seen a design done before coding that held up once the coding began.

Some people would argue that that means we should try harder to do good specs, try harder to do perfect designs. I don’t think so. When we see the thing we’re making, we see what we like about it and what we don’t like. If we’re stuck with a spec, or stuck with a design, we’re stuck, period. So we know with certainty that the spec will change. It follows that the design will change. We cannot get them right at the beginning.

This is the essence of the approach formerly known as “Agile”, before it was diluted and subverted and turned Dark: We start from a minimal spec, a minimal design, and we evolve what we want, how it should be done, and doing it together.

This is not easy. It requires developers who can work across the board from spec to design to code, it requires management or customers who can guide an evolving project, and it requires technical capability that includes solid testing, flexible design, and very strong refactoring capability.

It is not easy. But it can possibly work: the spec then design then code scheme cannot possibly work.

Digression: LLM-AI

I leave it to you to reflect on how LLM-AI style development is supposed to work and whether and where it might work and where it is likely to fail. My first approximation is that it will fail on anything that is interesting and has to work.

But What Next?

Right, sorry. It seems to me that what we have is actually working rather well. I am tempted to add in a simple Player object, represented by a mark on the screen, and let it move around based on keyboard action, navigating the dungeon. That might lead us to the notion of doors and such, or even treasures and monsters. What we have would begin to look like a game.

I think we’ll do that.

As for the code, as things stand, Cell plus CellSpace plus PathHelper amount to almost 250 lines of code, while Suite is 32, Room is 32, Dungeon 67, and even main is only 97. Cell alone is about 165 lines. It seems to me that this is out of balance, a sign that Something Should Be Done.

I do think that the Cell and its associates are pretty solid: it’s just that the size being so different makes me suspect there are classes waiting to be found. We’ll keep this concern in mind and try to notice possibilities or difficulties as they come up. For now, it’s working as intended, so we’ll move on.

So next time, we’ll put a little dude in the dungeon and see what that takes. I suspect doing that will impact not just the View classes but also Dungeon, which currently is not much more than a collection of Rooms. Yes, it knows suites and paths, but I’m not even very sure about those notions and how they fit in. We’ll change them and move them as needed. We are in the business of changing code.

See you next time!