Hello, loves!

By Jove, I think he’s got it! We’ll find out. Also: design documentation. Results: very favorable.

I took some time last night to think about our content objects and created this design document:

!table from yesterday with scribbled notes all over it

I’m sure that’s entirely clear, but just in case, we’ll be finding out what it means over the next few sessions, if in fact it means anything.

Key notions that doing that scribbling brought to mind:

  • Pass desired subscriptions to the content constructor as data, event name, caller id, and a callback.
  • Do all the subscriptions in the Content __init__ or in a separate initialize method built into Content class, looping over the provided subscriptions.
  • Provide a changed method in Content, publishing the standard message that signals the view to update. (This may turn out to be more tricky than it seems, but I’m sure we can do it.)
  • Express whether to allow entry as a function, probably a lambda. Possibly default to something reasonable, possibly to state == len(resources). (Won’t work for drawable: it needs False.) Wait and see.
  • Possibly each type of content provides its own expected members beyond the default few, using a dataclass or __slots__. Requires access to self = the Content instance.

I think I’d say that the basic idea here is that content customization consists of getting the right callbacks executed. The current scheme requires that we write code to do the subscriptions, which really just sets up the callbacks, but requires each type of content to write its own procedures. Instead, we’ll specify the events we want and their callbacks as data, so that the only code we have to provide is the callback functions themselves.

I believe that this will get us close to a single Content class that can do anything we need.

We’ll find out by doing it, as we did with the previous attempt, which was reverted yesterday. I cleverly saved the tests for that CombinedContent object. What we’ll do this morning is strip out the code of CombinedObject and build up a new one that passes the current tests and such new ones as we may devise.

Let’s get to it. I’ll mark all the tests to skip, enable them as we go.

    def test_first(self):
        content = CombinedContent(name='item', 
                                  resources=self.resources, 
                                  scale=0.75)
        assert content.name == 'item'
        assert content.resources == self.resources
        assert content.scale == 0.75
        assert content.interact_with_player(None) == True

I think we’ll begin by having all the members we need in every instance. It’s just not that heavy a burden: there are at most tens of Content, possible a hundred at a time. Nothing.

We want interact with player to be a method as shown here. Let’s default it to True, since this test craves that result.

class CombinedContent:
    def __init__(self, name, resources, scale):
        self.name = name
        self.resources = resources
        self.scale = scale
        self.state = 0
        self.interact_with_player = lambda interactor: True

I went wild and put in state as well. Green. Commit: working on new CombinedContent.

I recast the next test to be more to my liking:

    def test_cannot_enter(self):
        interaction = lambda interactor: False
        content = CombinedContent(name='item',
                                  resources=self.resources,
                                  scale=0.5,
                                  interaction = interaction)
        assert content.interact_with_player(None) == False

And to make it go:

class CombinedContent:
    def __init__(self, *, name, resources, scale,
                 interaction=lambda interactor: True):
        self.name = name
        self.resources = resources
        self.scale = scale
        self.state = 0
        self.interact_with_player = interaction

I’ve changed the init to require named arguments (the *). We’re going to have quite a few and this will give us a chance of getting it right as we go forward.

Green again. Commit again.

Our next four tests are working with the bound method idea that the prior class used. Those tests are in the repo and I’ll remove them from the current test file. The next test is now:

    @pytest.mark.skip('until ready')
    def test_run(self):
        touched = False
        def run_test(self, dungeon):
            nonlocal touched
            touched = True
        content = CombinedContent(name='item', resources=self.resources, scale=0.5)
        content.run_method = run_test
        content.run('fake')
        assert touched is True

Ah! This one is checking that we can override the run method. That’s not our scheme now. I think we need to scrap that one and begin writing some new tests for the simple cases. We do have some tests for the spikes, but we’re not ready for those. Erase the one above and start working out tests for our various types of content, Drawable, Receivable, and so on.

If this scheme works, the run method in Content will be standard, no overrides. (I am concerned about some of the dynamic behavior of content like Animated. The point of this exercise is to find out if this scheme can handle all these things, but I want to build up from easy to difficult.)

Drawable allows entry and has no behavior. We should be able to use it now. Only way to be sure is to plug it into main. I discover that we need a run method. Makes sense, the way the old stuff works.

class CombinedContent:
    def run(self, dungeon):
        pass

Drawable can be replaced with CombinedContent. We have to convert to naming the arguments, of course. Good idea anyway.

OK, let’s make receivable work. Better write a test for this one.

    def test_receivable(self):
        def interaction(interactor):
            interactor.receive_content(self)
            interactor.announce(f'You have received {self.name}!')
            return True

        content = CombinedContent(name='receivable',
                                  resources=self.resources,
                                  scale=0.5,
                                  interaction=interaction)
        assert content.interact_with_player(self) == True
        assert self.received == content
        assert self.message == 'You have received receivable!'

We define the interaction as shown. The test class has methods for receive_content and announce that should let those asserts run … except that self.name is not defined here. We need that code to run in the context of the item.

I think I mentioned above that I expected difficulty with access to self. Here it is.

I wonder whether we can bind the interaction to the content item, turning it into a method and make it work.

This may get weird.

Ha! It doesn’t get all that weird. The work we did on MethodType binding serves us here. In CombinedContent:

class CombinedContent:
    def __init__(self, *, name, resources, scale,
                 interaction=lambda self, interactor: True):
        self.name = name
        self.resources = resources
        self.scale = scale
        self.state = 0
        self.interact_with_player = types.MethodType(interaction, self)

We bind the provided lambda to our instance, making it a bound method of this instance. The new format for the interaction lambda is lambda self, interactor: etc, as seen in the default. I change the existing tests that provide their own lambdas and all the tests run.

Commit. This tells me that we can probably replace ReceivableContent with CombinedContent in main. Try one.

That in fact works. Commit to save main.

In the previous version of CombinedContent we had special code to allow the user to pass either a lambda, which needs to be bound, or a method, which does not. Thus far, we’re requiring a lambda. If we were to pass in an already bound method, we’d get an error. I’ve made a note to think about that and we’ll probably put that same change in again.

OK. Drawable and Receivable behavior are simple, as they do not subscribe to any events nor publish any. The remaining concrete classes, DungeonControl, Animated, and Appearing, are a bit more tricky. Of these, I think that DungeonControl is the simplest, as it does not subscribe to any events, but it does interact and publish as it does so.

I think we’d best test this one into existence. It seems easy enough.

This partial test passes:

    def test_control(self):
        def lever(self, interactor):
            return False
        resources = ['a','b', 'c', 'd']
        content = CombinedContent(name='control',
                                  resources=resources,
                                  scale=0.75,
                                  interaction=lever,
                                  )
        assert content.state == 0

Now it needs to change state and publish on each interaction:

    def test_control(self):
        def lever(self, interactor):
            return False
        resources = ['a','b', 'c', 'd']
        content = CombinedContent(name='control',
                                  resources=resources,
                                  scale=0.75,
                                  interaction=lever,
                                  )
        assert content.state == 0
        content.interact_with_player(self)
        assert content.state == 1

Fails, of course.

    def test_control(self):
        def lever(self, interactor):
            self.state = (self.state+1)%len(self.resources)
            return False

That’ll actually work all the way around, I’m sure. And I’m sure it’s not allowing entry but we should check.

Now let’s check the publication and implement it:

    def test_control(self):
        def lever(self, interactor):
            self.state = (self.state+1)%len(self.resources)
            interactor.publish('state_number', self.name, self.state)
            return False
        resources = ['a','b', 'c', 'd']
        content = CombinedContent(name='control',
                                  resources=resources,
                                  scale=0.75,
                                  interaction=lever,
                                  )
        assert content.state == 0
        entry =content.interact_with_player(self)
        assert entry == False
        assert content.state == 1
        assert self.published == ('state_number', 'control')

That’s passing. The CombinedContent is now handling Drawable, Receivable, and DungeonControl cases. I’ll plug it in for the control in a moment, but first let’s look at the class that’s doing all this work:

class CombinedContent:
    def __init__(self, *, name, resources, scale,
                 interaction=lambda self, interactor: True):
        self.name = name
        self.resources = resources
        self.scale = scale
        self.state = 0
        self.interact_with_player = types.MethodType(interaction, self)

    def run(self, dungeon):
        pass

That’s all there is, and run will be removed. And, so far, all the objects have had to do is to provide a single function, interaction.

I think we’ll test the DungeonControl in main now. Let’s do that with a test first, so that we can move over the lever class method. I guess what I’ll do it just change the existing test:

    def test_control(self):
        content = CombinedContent.lever('control')
        assert content.state == 0
        entry =content.interact_with_player(self)
        assert entry == False
        assert content.state == 1
        assert self.published == ('state_number', 'control')

That passes, but the lever does not twitch when Dot tries to step on it.

I think we need to trigger an update to change the picture. And I have a theory about that. But did the code run and why didn’t the spikes change?

I’m tired. I resort to a bit of debugging with some prints.

Before I even do the print I see the problem, I called the lever control because I pasted the code from the test and its name has to be spikes as it is talking to the spikes.

Ah, again. I was getting a runtime error, which Arcade swallows. The state_number message requires two parameters. Let’s beef up the test.

    def test_control(self):
        content = CombinedContent.lever('control')
        assert content.state == 0
        entry =content.interact_with_player(self)
        assert entry == False
        assert content.state == 1
        event, caller, kwargs = self.published
        assert (event, caller) == ('state_number', 'control')
        assert kwargs['content'] is content
        assert kwargs['state'] == 1

And here is the CombinedContent class, a final time for the morning, including the lever class method:

class CombinedContent:
    @classmethod
    def lever(cls, name):
        def lever(self, interactor):
            self.state = (self.state+1)%len(self.resources)
            interactor.publish('state_number', self.name, content=self, state=self.state)
            return False
        resource1 = '/Users/ron/Desktop/DungeonTiles/png/objects/lever/1.png'
        resource2 = '/Users/ron/Desktop/DungeonTiles/png/objects/lever/2.png'
        resource3 = '/Users/ron/Desktop/DungeonTiles/png/objects/lever/3.png'
        resource4 = '/Users/ron/Desktop/DungeonTiles/png/objects/lever/4.png'
        resources = [resource1, resource2, resource3, resource4]
        scale = 0.75
        return cls(name=name,
                  resources=resources,
                  scale=0.75,
                  interaction=lever,
                  )

    def __init__(self, *, name, resources, scale,
                 interaction=lambda self, interactor: True):
        self.name = name
        self.resources = resources
        self.scale = scale
        self.state = 0
        self.interact_with_player = types.MethodType(interaction, self)

    def run(self, dungeon):
        pass

We definitely need some kind of checking on publish to ensure that people give us the names and values we want: I had ‘state_number’ where I needed ‘state’ and forgot ‘control’ altogether. I’d also like to find a way to make Arcade not swallow runtime errors. I’ve made a note to research that.

Let’s sum up.

Summary

This went swimmingly, as folks used to say in the olden days. The tests are really helping, although we still need to view things on screen to feel confident. Maybe some additional tests could be devised.

The only issues encountered were readily dealt with and the biggest problem was that the publish in this last step was just wrong. Looking at an example would have helped. Having publish detect the error would have helped as well.

The biggest technical issue was the need to wrap the interaction function to appear to be a method on CombinedContent, so that it can access the various instance variables. We learned how to do that a day or so ago, and while it is a bit deep in the bag, it is all inside the CombinedContent class and we mortals can just take it as given that self will work in our interaction functions. We may need to make that wrapping more capable, but so long as we stick with local functions in our constructors, it’ll all be just fine.

It might be wise to standardize on having a class method for each type of object, even the simple ones, just for consistency and to keep all the examples where we can find them. We’ll see if that makes sense, but it probably will.

As we go forward, we’ll do Animated, which will require us to provide a mapping between control states and the Animated object states. Recall that the spikes receive four states from the lever, and that they cycle on 0 and 3, stop in the up position on 1, and stop in the down position on 2. We’ll also have to sort out the spikes dealing damage inconvenience to Dot. (We’re going with a primum non nocere style in this game.)

I think this is going well. In the previous schemes we had long constructors and lots of code in content classes or the combined one: around 100 lines total. With the new scheme, our constructors will be no longer, possibly shorter, and there will be very little code in the final Content class, which will have no subclasses and do all its work in initializing, with all the functionality residing in short callback functions.

I feel good about this one. But it’s early days, I could change my mind. But I probably won’t.

See you next time!