A TADS3/adv3 module providing several basic data types/data structures

One of the things I’ve been struggling with is the fact that dependency management is kinda kludgy in TADS3, so there’s some balance between “this would be cleaner if it was in its own module” versus “putting it in its own module means dealing with ugly hacks like header guard-based checks throwing errors and constantly tweaking makefiles”.

Anyway, a couple minor updates:

  • Graph.testVertices(fn)

    Returns boolean true if the test function fn returns boolean true for each vertex.

  • Rectangle.offset()

    Fixed a sign bug that occured when corners were on opposite sides of the origin.

  • RTree.insert()

    Fixed a bug in node splitting that would sometimes incorrectly expand the bounding box to include the origin. Resolving this improves performance substantially in cases where it occurred (basically parts of the tree could effectively collapsed into a linked list that needed to be checked sequentially because bounding boxes could end up overlapping in slightly nonsensical ways). But not enough for me to change my earlier comments about QuadTree performing better for most “IF game” use cases.

All of this out of additional procgen map stuff I’m still working on. Which leads me to ask: is there anyone else out there working on something where they’d want procgen map generation (specifically in IF, more specifically in TADS3)? If so, what’s the actual design use case? I’m at the point where I’ve implemented most of the nuts and bolts I care about for the WIP, but I’m still going back and forth on what level of detail “should be” in individual per-game instances and how much “generic use case” logic should be built into the generators themselves.

Like right now my model is that the default generator generates nothing, and in a game you create instances of the relevant classes to write bespoke methods to declare, for example, if you’re picking between three options how you want the choice to be made (with no default behavior). If that makes sense.

3 Likes

A few more updates.

LineSegment

A new class implementing, as the name suggests, line segments.

A line segment is defined by two XY instances, which are the arguments for the class constructor.

Methods

  • contains(xy)

    Returns boolean true if the given XY instance lies on the calling line segment.

  • equals(ls)

    Returns boolean true if the given LineSegment instance has the same endpoints as the calling instance.

  • coincident(ls)

    Returns boolean true if the given LineSegment shares exactly one endpoint with the calling instance.

  • intersects(ls, ignoreEndpoints?)

    Returns boolean true if the given LineSegment intersects the calling instance. If the second argument is boolean true then intersections that occur only at one endpoint don’t count.

Matrix

A couple new methods:

  • multiply(m)

    Multiplies the calling Matrix instance by the argument instance, returning a new Matrix instance containing the product.

  • clone()

    Returns a copy of the calling matrix.

Graph

Updated adjacencyMatrix() to optionally take a list of vertex IDs as its argument. By default the adjacency matrix is constructed using a vertex list in ASCIIbetical order by vertex ID. Called with an argument it will be used for the vertex order instead.

XY

  • isAdjacentToAll(lst)

    Like isAdjacent(), but accepts a list of XY instances and returns boolean true if each element of the list is adjacent (in the Moore neighborhood of) the calling XY instance.

All of these are bits of code being backported out of the procgen map code I’m working on.

Still on the fence about pulling the subgraph isomorphism stuff out too. On the one hand it’s more general than just the procgen stuff I’m using it for, but on the other hand it’s larger than everything in the base Graph implementation (including Vertex and Edge and the Dijkstra pathfinding stuff) put together. And although it’s generic graph theory stuff, I’m a little skeptical that there’s that much need for a subgraph isomorphism solver in general IF programming. Witness: TADS3 having managed to reach 2025 without a prior implementation (as far as I know).

3 Likes

A couple more minor updates.

TSProf

This is a class that uses the TS class to aid in the implementation of rudimentary performance profilers.

Properties

  • label = 'tsProf'

    Label for output. Only used in the report() method.

Methods

  • start(id)

    Starts a timer associated with the ID id.

  • stop(id)

    Stops the timer associated with the ID id, if one exists.

  • total(id)

    Returns the cumulative time elapsed for the timer id.

  • report()

    Prints a list of all the timer IDs and their elapsed times.

The entire class is wrapped in a preprocessor conditional for _DEBUG, so it’ll only be compiled in for debugging builds.

The way I’m using this is:

  • Create an instance, in this example __pgProf (for the procgen stuff I’m working on)
  • Assign a unique-ish ID for each object, in this case syslogID
  • Include a bunch of #define statements in a preprocessor conditional that checks for a compile-time flag. If set, the defines are:
#define pgProfStart(v) __pgProf.start(syslogID + '.' + v + '()')
#define pgProfStop(v) __pgProf.stop(syslogID + '.' + v + '()')
#define pgProfTotal(v) __pgProf.total(syslogID + '.' + v + '()')
#define pgProfReport() __pgProf.report()

…and if not, they are…

#define pgProfStart(v)
#define pgProfStop(v)
#define pgProfTotal(v) 0
#define pgProfReport()

Then in methods I’m trying to profile I call pgProfStart('someMethodName') at the start and pgProfStop('someMethodName') before any return.

Then at any point calling pgProfReport() will output a list of all the methods I’ve done this for and their cumulative execution time, labelled with the object/class name and method name, something like:

=====pgProf START=====
PermuteBt.accept(): .005
GraphGrid.placeNeighbors(): .014
GraphGrid.insertNext(): .002
=====pgProf END=====

Compiling without the preprocessor flag turns all the macros into NOPs, so they’re not even as expensive at runtime as a call to a method that immediately returns.

Dunno how many other TADS3 authors are doing things…messy…enough to require this kind of profiling, but if you are, here it is.

3 Likes

Another utility class/type.

BT

The BT class and its support classes provide a simple, fairly generic recursive backtracker. It’s intended to allow you to rewrite tail recursion logic to use an iterative, resumable solver instead.

Methods

  • pop()

    Pops the top frame off the stack and returns it.

  • push(f)

    Pushes the frame f onto the top of the stack.

  • reject(f)

    Rejection method. This should return boolean true if the frame f and any frames derived from it should be rejected.

    Subclasses will want to implement their own logic here. The stock method only fails on invalid frames.

    Returning true from this method means “stop trying this solution, it will never work”. It does NOT just mean “this frame is invalid” (although an invalid frame is one of the possible failure modes)

  • accept(f)

    Acceptance method. This should return boolean true if the frame f is a valid solution to the problem being solved.

    Subclasses will want to implement their own logic. The stock method just checks to see if a value is assigned to each variable being solved for.

    Returning true from this method means “stop processing, you’ve found the answer”.

  • next(f)

    Returns the next frame following frame f.

    By default this will just be the next permutation of the variable/value assignments.

  • step(f)

    Advances processing a single step starting from frame f.

    Returns boolean true if the next step yielded a solution, placing the solution itself at the top of the stack; boolean nil if the next step was rejected (that is, resulted in a permanent failure for the branch being explored); and the next frame if the step resulted in neither a solution nor a failure.

  • run(f)

    Step through frames starting with frame f, continuing until either a frame is accepted (that is, a solution is found) or rejected (that is, no further solutions are available from the starting frame).

BTStack

Stack for the recursive backtracker. This generally won’t need to be interacted with directly unless you’re wrting a subclass of BT with a bunch of problem-specific logic.

Methods

  • clear()

    Clears the stack.

  • push(f)

    Pushes the frame f onto the top of the stack

  • pop()

    Pops the top frame off the stack and returns it.

  • validateFrame(f)

    Returns boolean true if f is a valid frame for this stack.

BTFrame

Stack frame for the recursive backtracker.

Properties

  • vList = nil

    List of variables we’re trying to assign values to.

  • pList = nil

    List of possible values to assign to the variables.

  • result = nil

    List of assignments so far.

    NOTE: The indices in result correspond to indicies in vList. That is, the ith value of result is a possible assignment for the ith variable in vList. The values in result correspond to indices in pList. That is, they aren’t the values being assigned they’re the index of a value in pList.

Methods

  • clone()

    Returns a copy of this frame.

  • next()

    Returns the frame that follows this one. The stock class proceeds by simple permutation.

Explanation

This all might sound a little arcane if you’re not already familiar with the concepts, so here’s a simple, concrete example.

Let’s say we have three people, Alice, Bob, and Carol, and we’re having a very odd dinner party in which each person is given exactly one vegetable and no two people receive the same vegetable. The vegetables we have are asparagus, broccoli, carrots, and daikon.

We have the additional constraint that, for reasons not worth getting into here, Bob refuses to accept any vegetable whose name starts with B. So he can’t be assigned broccoli.

We could just assign permutations via tail recursion: we write a function that assigns the next available vegetable to the next person without a vegetable, and then call the function again recursively until we have assignments for each person.

The problem wth this method is what if we want to do the same thing tomorrow and don’t want to ever repeat the same assignments (until we run out of possible assignments)? We could solve this by keeping track of all past menu assignments, but then that gets out of hand in a hurry and we still end up having to iterate through the entire process every day (so the nth day will be faster than the n + 1th day, and so on…because each successive day we have to recurse deeper to find an unused solution).

Instead of doing that we can use a recursive backtracker. It uses just the most recent state to figure out how to proceed, and is guaranteed to cover every valid permuations.

To use the BT class to solve this problem we start out by defining our lists somewhere where we can refer to them:

veg = static [ 'asparagus', 'broccoli', 'carrots', 'daikon' ]
people = static [ 'Alice', 'Bob', 'Carol' ]

Now we declare an instance of BT and give it an accept() method that provides our novel constraint involving Bob’s preferences.

demoBacktracker: BT
        accept(frm) {
                if(!inherited(frm))
                        return(nil);

                return(frm.result[2] != 2);
        }
;

What’s going on here? The inherited() check just verifies that every variable has an assignment. That is, we’re only interested (here) in solutions in which each person is assigned a vegetable.

The frm.result[2] == 2 implements Bob’s dietary restrictions. The way to read this is that indices in the frame’s result vector (result) correspond to indices in the variable vector (BTFrame.vList). In our case the second element of vList will be Bob, so result[2] is “what is assigned to Bob”. The values in the result vector are indices in the value list. And in this case the second value is “broccoli”.

If we wanted to implment an actual alphabetic check (that is, if there were multiple vegetables that start with B and we want to exclude them all) we could perform the test on frm.pList[frm.result[2]].

To invoke the solver we create a BTFrame instance representing the problem we want it to solve:

f = new BTFrame(people, veg, nil);

That is, we want to come up with assignments for everyone in people using values from veg, and our current state (the third arg) is nil…we haven’t done any work yet.

We can then call the backtracker instance we created: with demoBacktracker.run(f). This will run until it finds a solution or runs out of things to try, returning true on success and nil on failure.

On success, the top of the backtracker’s stack will contain a stack frame containing a solution to the problem.

We can iterate over all of them with something like:

                while(f != nil) {
                        if(demoBacktracker.run(f) != nil) {
                                f = demoBacktracker.pop();

                                printFrame(f);

                                f = demoBacktracker.next(f);
                        }
                }

…where we’ve declared a printFrame() function elsewhere to display each result:

        printFrame(f) {
                local ar, i;

                ar = new Vector(f.result.length);
                for(i = 1; i <= f.result.length; i++)
                        ar.append('<<f.vList[i]>> has
                                <<f.pList[f.result[i]]>>');

                "<<stringLister.makeSimpleList(ar)>>.\n ";
        }

Running this gives us:

Alice has asparagus, Bob has carrots, and Carol has broccoli.
Alice has asparagus, Bob has carrots, and Carol has daikon.
Alice has asparagus, Bob has daikon, and Carol has broccoli.
Alice has asparagus, Bob has daikon, and Carol has carrots.
Alice has broccoli, Bob has asparagus, and Carol has carrots.
Alice has broccoli, Bob has asparagus, and Carol has daikon.
Alice has broccoli, Bob has carrots, and Carol has asparagus.
Alice has broccoli, Bob has carrots, and Carol has daikon.
Alice has broccoli, Bob has daikon, and Carol has asparagus.
Alice has broccoli, Bob has daikon, and Carol has carrots.
Alice has carrots, Bob has asparagus, and Carol has broccoli.
Alice has carrots, Bob has asparagus, and Carol has daikon.
Alice has carrots, Bob has daikon, and Carol has asparagus.
Alice has carrots, Bob has daikon, and Carol has broccoli.
Alice has daikon, Bob has asparagus, and Carol has broccoli.
Alice has daikon, Bob has asparagus, and Carol has carrots.
Alice has daikon, Bob has carrots, and Carol has asparagus.
Alice has daikon, Bob has carrots, and Carol has broccoli.

…which is all the possible assignments that satisfy our requirements.

If we’re just doing this…just enumerating all the possibilities…we could “just” compute the permutations. The thing that the recursive backtracker gives us is the ability to compute one solution and the at some later time if we want to continue the process where we left off, all we have to have is the last frame (which is just the two lists and the result vector), and we never have to iterate over any possibilities we’ve previously considered.

Here’s the complete code for the example problem with additional comments, now also demo/src/btTest.t in the dataTypes repo.

#charset "us-ascii"
//
// btTest.t
// Version 1.0
// Copyright 2022 Diegesis & Mimesis
//
// Test of the recursive backtracker.
//
// It can be compiled via the included makefile with
//
//      # t3make -f btTest.t3m
//
// ...or the equivalent, depending on what TADS development environment
// you're using.
//
// This "game" is distributed under the MIT License, see LICENSE.txt
// for details.
//
#include <adv3.h>
#include <en_us.h>

#include "dataTypes.h"

// Backtracker instance.  This is where we implement our instance-specific
// logic.
demoBacktracker: BT
        accept(frm) {
                // Generic test, which just makes sure there's an assignment
                // for each variable.
                if(!inherited(frm))
                        return(nil);

                // Bob rejects vegetables starting with B.
                // The *indices* in the result vector as the same as the
                // indices in the variable vector (vList).  So result[2]
                // means "the value assigned to the second variable",
                // and in this case the second variable is Bob.
                // The *values* of the result vector are indices in
                // the value vector (pList).  So result[2] = 2 means
                // we're a) checking the value assigned to Bob (that's
                // the lefthand side), and b) if the value is the second
                // one in the value vector, in this case "broccoli".
                // So this rule means that we're rejecting a result only
                // if it involves givving broccoli to Bob.
                return(frm.result[2] != 2);
        }
;

versionInfo: GameID;
gameMain: GameMainDef
        veg = static [ 'asparagus', 'broccoli', 'carrots', 'daikon' ]
        people = static [ 'Alice', 'Bob', 'Carol' ]

        newGame() {
                local f;

                // Create the initial query:  list of variables to
                // assign, list of possible assignments, and progress so
                // far.
                f = new BTFrame(people, veg, nil);

                // Contine as long as we have another frame to process.
                while(f != nil) {
                        // The return value of BT.run() is true if the
                        // backtracker found a solution, nil otherwise.
                        if(demoBacktracker.run(f) != nil) {
                                // If the return value from .run() was
                                // true, the solution it found will be
                                // the top of the stack.  Here we lift
                                // it off the stack.
                                f = demoBacktracker.pop();

                                // Print the contents of the frame.
                                printFrame(f);

                                // Pick the next frame in the sequence
                                // and continue.
                                f = demoBacktracker.next(f);
                        }
                }
        }

        // Print a single stack frame.
        printFrame(f) {
                local ar, i;

                // Make an array containing the assignments.
                // Note that the result vector, here f.result,
                // contains the *indices*, in f.pList, of the
                // assignments, NOT the values themselves.
                ar = new Vector(f.result.length);
                for(i = 1; i <= f.result.length; i++)
                        ar.append('<<f.vList[i]>> has
                                <<f.pList[f.result[i]]>>');

                // Have the string lister format the array.
                "<<stringLister.makeSimpleList(ar)>>.\n ";
        }
;
4 Likes

A few additions to the Graph class.

Graph

Methods

  • clone()

    Returns a shallow copy of the calling graph.

    This creates a new Graph instance containing the same vertices and edges as the calling graph. It does not copy any data defined on the vertices or edges.

  • difference(g)

    Returns a new Graph instance containg the difference of the calling graph and the argument graph. That is, a graph containing the vertices and edges that are in the calling graph that are not in the argument graph.

  • equals(g)

    Returns boolean true if the argument graph equals the calling graph. That is, they both contain the same vertices and edges.

  • intersection(g)

    Returns a new Graph instance containing the intersection of the calling graph and the argument graph. That is, a graph containing the vertices and edges that are in both graphs.

  • union(g)

    Returns a new Graph instance containing the union of the calling graph and the argument graph. That is, all vertices and edges that are in either of the two graphs.

In the above methods, comparisons are made using vertex and edge IDs rather than instances.

This is probably more abstract than anything most people will need for most games, but I’ve been using a lot of graph theory stuff in my procgen code and it vaguely seems like the kind of thing that wants to live in the Graph class.

2 Likes

More graph theory stuff.

Graph

  • isRegular()

    Returns boolean true if the degree of all vertices in the graph is the same.

  • maximumDegree()

    Returns the degree of the vertex with the most edges.

  • minimumDegree()

    Returns the degree of the vertex with the fewest edges.

Also added a simple implementation of disjoint-set/union-find operations:

DisjointSetForest

A simple container class for a forest containing multiple DisjointSet objects. Purely for convenience.

Methods

  • add(v)

    Adds a DisjointSet instance to the forest.

  • forEachSet(fn)

    Iterates over each DisjointSet instance in the forest, calling the argument function fn on each.

DisjointSet

An abstract disjoint-set data structure. This is used for efficient lookups of whether two given nodes (e.g. vertices in a graph) are connected.

The expected usage is that the parent and rank will be automagically computed by the module when an instance is used in find() and union() calls.

Properties

  • id = nil

    Arbitrary ID for the element. Should only be set by the constructor.

  • parent = nil

    Parent of the element. Should not be set directly.

  • rank = 0

    Rank of the element. Should not be set directly.

Methods

  • add(v)

    Initialize the DisjointSet instance, setting its parent to be itself and its rank to be zero.

    This is called automagically by the constructor, and generally shouldn’t be manually invoked.

  • find(v)

    Returns the DisjointSet instance that is the top-level parent of the argument, which can be either a DisjointSet instance or the ID of a declared DisjointSet instance.

  • getDisjointSet(id)

    Returns the DisjointSet instance with the given ID.

  • union(v0, v1)

    Joins the two argument disjoint sets. The arguments can be either DisjointSet instances or IDs.

Example usage:

     // Create a forest to hold the disjoint sets
     local f = new DisjointSetForest();

     // Add elements "foo", "bar", and "baz" to the forest
     makeDisjointSet('foo', f);
     makeDisjointSet('bar', f);
     makeDisjointSet('baz', f);
     makeDisjointSet('quux', f);

     // Join "foo" and "bar"
     disjointSetUnion('foo', 'bar');
     // Join "foo" and "baz"
     disjointSetUnion('foo', 'baz');
     // Join "baz" and "quux"
     disjointSetUnion('baz', 'quux');

     // Sets p to be the top-level parent of "quux", which will be
     // the DisjointSet instance "bar".
     local p = disjointSetFind('quux');

I already have some code that’ll probably go on DisjointSetForest that converts a Graph into disjoint-set data and then uses Kruskal’s algorithm to compute a minimum spanning tree, but I haven’t cleaned it up yet.

Just trying to get back in the habit of doing regular updates/releases instead of going down recursive refactoring bunny trails while working on this kind of stuff.

All of this is coming out of procgen puzzle generation stuff I’m working on, where I want to be able to give a puzzle specification that grows “automatically” via the procgen logic, from the initial game state (where the player has unlocked nothing) to the endgame state (where everything is potentially unlocked) without having to hard-code breakpoints for each transition in the internal game state.

As a side-note, I’ll add that I’m back to using RTree for the spatial indexing for procgen map layout; up above I commented that I’d switch to QuadTree after doing performance testing. I thought it was surprising that QuadTree was performing better than RTree, and it turns out that it must’ve been an artifact of the test method or something, because in “real” game maps RTree has been performing substantially better.

5 Likes

New methods and a tweak to the usage of the stuff I just posted.

First, the new methods:

DisjointSetForest

Methods

  • graphToForest(g)

    Uses the argument Graph instance to configure the forest, adding nodes for each Vertex in the graph and doing a union() for each edge.

    NOTE: This does not clear the forest beforehand. So if the the contents of the graph are all you want in the forest you’ll have to make sure the forest is empty yourself.

  • kruskal(g, fn?)

    Returns a new Graph instance containing a minimum spanning tree of the argument g (which must be a Graph instance) computed using Kruskal’s algorithm.

    The optional second argument is an edge sorting function. If given, the function must accept two Edge instances as arguments and return an integer as per normal TADS3 sort() semantics. If no function is given the edge lengths will be used to sort edges.

    NOTE: This method modifies the forest. New nodes will be added for each vertex in the graph, and union()s will be performed for each edge. This also means that any disjoint-sets already in the forest when kruskal() is invoked will be part of the computed spanning tree. If these things are not desired, a new DisjointSetForest instance should be created before calling this method.

Moved methods

In addition to the above, almost all of the methods previously on DisjointSet are now on DisjointSetForest, and the global macros for doing disjoint-set operations have been removed. This is basically just to force the “namespace” of node IDs to be local to each individual forest.

This is basically just cleanup. The code started out as part of the procgen stuff I’m working on, which has a way of generating globally-unique IDs, so ID collisions weren’t really something I had to worry about. But breaking the disjoint-set logic out to be a standalone thing makes IDs having to be globally unique an obvious weakness. So I re-wrote it this way.

Anyway, the new basic semantics are identical to the example in my previous post, but the exact usage has changes a little:

     // Create a forest to hold the disjoint sets
     local f = new DisjointSetForest();

     // Add elements "foo", "bar", and "baz" to the forest
     f.makeSet('foo');
     f.makeSet('bar');
     f.makeSet('baz');
     f.makeSet('quux');

     // Join "foo" and "bar"
     f.union('foo', 'bar');
     // Join "foo" and "baz"
     f.union('foo', 'baz');
     // Join "baz" and "quux"
     f.union('baz', 'quux');

     // Sets p to be the top-level parent of "quux", which will be
     // a DisjointSet instance.
     local p = f.find('quux');

This is all just a) methods moving from DisjointSet to DisjointSetForest, and b) calling the method on an instance instead of using a macro to call it statically on the class.

1 Like