We are working toward making dungeon description easier. I think of it as creating a little language. Today, adding randomness, if my luck holds. It holds, and we belay the whole plan.

There will be a number of kinds of rooms. We have cave, diamond, and round already, and we’ll surely want rectangular and perhaps hyperbolic paraboloid. Perhaps not. When we build a dungeon, we use random numbers one way or another in the room’s parameters. For example:

def connect_rooms(dungeon: Dungeon, maker: RoomMaker, space: CellSpace):
    while not dungeon.is_fully_connected:
        origin = space.random_available_cell()
        if origin is None:
            break
        size = random.randint(100, 200)
        room = maker.experimental(number_of_cells=size, origin=origin)
        dungeon.add_room(room)
        if dungeon.is_fully_connected:
            break
        if len(room.cells) <= 100:
            dungeon.remove_room(room)
            print(f'too small {len(room.cells)} cells')
    print(f'{dungeon.is_fully_connected=}\n{len(dungeon.rooms)=}')

We’ll quite often use space.random_available_cell() to place a room, but we’ll surely also want to provide a specific room location. We’ll want the rooms to take on random sizes. My guess is that we’ll want to be able to provide a run-time executable value for any parameter of any room kind that we ever have.

We need an example. And, if we’re going to have an example, let’s make it a test. I’ll take a cut at it, hold on a moment.

    def test_callable_size(self):
        space = CellSpace(20,20)
        origin = space.at(10,10)
        maker = RoomMaker()
        diamond = maker.diamond(
            number_of_cells=lambda: 5,
            origin=origin)
        assert len(diamond.cells) == 5

That’s not much of a function there but it is a function. Won’t compile, because PyCharm knows number_of_cells is supposed to be Int. And won’t run, because somehow it created a Room with 400 cells in it. The latter mystery is easy to solve: the DiamondRoomCollector keeps grabbing cells until it runs out, trying to make an integer equal a function:

class DiamondCellCollector:
    def build(self, *, number_of_cells: int, origin: Cell):
        cells:list[Cell] = []
        count = 0
        for cell in origin.generate(lambda c: c.is_available):
            cells.append(cell)
            cell.room = self
            count += 1
            if count == number_of_cells:
                break
        return cells

This is all fine but I realize that don’t like the test. I want the lambda to be in the dictionary that defines the room, not in some call. Change the test:

    def test_callable_size(self):
        space = CellSpace(20,20)
        cell = space.at(10,10)
        diamond = {"shape":'diamond',
                   'number_of_cells': lambda: 5,
                   'origin': cell}
        maker = RoomMaker()
        room = maker.build_one(diamond)
        assert len(room.cells) == 5

Still failing, of course, but now let’s look at build_one:

    def build_one(self, args):
        shape = args.get('shape')
        if shape is None:
            return Room([], None, 'build_one called with no shape')
        args.pop('shape')
        method = getattr(self, shape, None)
        if method is None:
            return Room([], None, f'unknown shape "{shape}"')
        return method(**args)

My cunning plan here is to convert any callable arguments into their values right here, which lets everyone else go about their business as usual.

Like this:

    def build_one(self, args):
        shape = args.get('shape')
        if shape is None:
            return Room([], None, 'build_one called with no shape')
        args.pop('shape')
        fixed_args = self.fixup(args)
        method = getattr(self, shape, None)
        if method is None:
            return Room([], None, f'unknown shape "{shape}"')
        return method(**fixed_args)

    def fixup(self, args):
        fixed = dict()
        for k, v in args.items():
            try:
                fixed[k] = v()
            except:
                fixed[k] = v
        return fixed

In fixup, we create a new dictionary. For each key we provide a value that is either the result of calling the provided value, or the value itself. So if you send in literals, they pass through and if you send in functions, they are called.

We are green. Commit …

Oops better not. Let’s do better:

    def build_one(self, args):
        fixed_args = self.fixup(args)
        shape = fixed_args.get('shape')
        if shape is None:
            return Room([], None, 'build_one called with no shape')
        fixed_args.pop('shape')
        method = getattr(self, shape, None)
        if method is None:
            return Room([], None, f'unknown shape "{shape}"')
        return method(**fixed_args)

I realized that we couldn’t make “shape” callable. Now we can. No reason to think we would … except that a bunch of random shapes … hmm. Nice. We’ll try that shortly.

I think we can improve fixup:

    def fixup(self, args):
        return {k: self.fix(v) for k, v in args.items()}

    def fix(self, v):
        try:
            return v()
        except:
            return v

Green, commit: build dictionaries accept lambdas for any parameter.

Thinking about random shapes, clearly we can do it, but since not all rooms take the same parameters, it might not work as well as one would hope. But it’ll do what we asked it to.

Let’s set up an example in main. Here’s part of main:

def main():
    space = CellSpace(64, 56)
    # random.seed(234)
    dungeon = Dungeon()
    maker = RoomMaker()
    make_diamond_in_round_room(dungeon, maker, space)
    number_of_rooms = random.randint(2,2)
    for _ in range(number_of_rooms):
        make_a_diamond_room(dungeon, maker, space)
        make_a_round_room(dungeon, maker, space)
    # for room_1, room_2 in zip(dungeon.rooms, dungeon.rooms[1:]):
    #     dungeon.find_path_between_rooms(space, room_1, room_2)
    connect_rooms(dungeon, maker, space)
    view = DungeonView(dungeon)
    view.main_loop()


def make_a_round_room(dungeon: Dungeon, maker: RoomMaker, space: CellSpace):
    origin = space.random_available_cell()
    rad = random.randint(5, 8)
    room = maker.round(radius=rad, origin=origin)
    dungeon.add_room(room)

We want, in theory at least, to do this with our new dictionaries. Let’s change make_a_round_room to use that scheme. Here’s the result, and the corresponding method for diamond rooms:

def make_a_round_room(dungeon: Dungeon, maker: RoomMaker, space: CellSpace):
    round = {'shape':'round',
             'origin': lambda: space.random_available_cell(),
             'radius': lambda: random.randint(5,8)}
    room = maker.build_one(round)
    dungeon.add_room(room)


def make_a_diamond_room(dungeon: Dungeon, maker: RoomMaker, space: CellSpace):
    origin = space.random_available_cell()
    room = maker.diamond(number_of_cells=random.choice([25, 61]), origin=origin)
    dungeon.add_room(room)

It’s not easy to see the new scheme as better, is it? Let’s revisit the original idea and see how this is panning out. The core idea, quoted from the first “Little Language” article, is:

  • Move toward a scheme where map layouts are more like data than procedure. Base them on keyword-value pairs, probably in dictionaries. (In principle, could be strings, JSON, etc.)

We certainly have the ability now to define a room, including random values, as “data”, in the form of a dictionary. It will be easy, when we get around to it, to extend RoomMaker to have a build from a list of dictionaries. It is probably possible, perhaps even easy, to include looping over a list. Perhaps something as simple as a method with ‘shape’ as ‘list’ and ‘count’ as a function or constant, which recursively calls build. Sounds like fun.

But the thing is, it’s just as easy, in fact probably a bit easier, to just write the loop that you want, calling the methods you want with the parameters you want.

Belay This Plan

I think we’ll set this idea aside, at least for now. We’ll either find creating our dungeons too tedious, and figure out some easier scheme, perhaps based on this idea or a better one, or we’ll find that things are just fine as they are.

Summary

So. Bad idea, or at least not a good enough one. Do we feel badly, creep away with our tail between our legs? We do not. We felt a need, had an idea, tried it, decided it didn’t offer enough value. It took us less than a day to find out, and we learned some interesting techniques along the way. If everything we try works, we’re probably not letting our ideas range far enough to reach all the good stuff. Anyway, that’s my story and I’m sticking to it.

What have we learned? Well, to name a few:

  • It’s a lot more typing to type a dictionary than a method call, with all the quotes and colons and stuff. I had not felt that going in, perhaps because Lua dictionaries are a bit nicer.

  • We got a bit of experience with dispatching on shape, which is a good thing to have at a reasonable lever in the bag of tricks.

  • We unwound our calls at the last minute in build_one, with that fix trick. My guess is that that one is just a little bit deeper in the bag than we should usually go, but practice is good.

  • We came up with some nice tests along the way, in areas where we had previously not seen how to write reasonable- and simple-enough tests.

Future

We do have some work we could do on the dungeon map, notably including making paths that are less baroque than our current scheme provides, where we build random rooms until everything connects.

Most of that, paths aside, seems like more of the same to me, so less interesting. We’ll need to decide what, if anything, we’re actually going to do with this thing. On the one hand, I can enjoy and draw lessons from any code, but it’s more fun when we can pretend there is a point, a larger objective. Is this a game? Or what?

We’ll see what we come up with. See you next time!