Zannick / logic-graph

Tools for video game logic representation and analysis, particularly routing and beatability checks for speedruns and randomizers.
MIT License
3 stars 0 forks source link

General graph simplification #71

Closed Zannick closed 1 year ago

Zannick commented 1 year ago

In general there's not much purpose to having every node we may have defined in the logic, particularly when some nodes serve only for movement time calculations. In AV2, the original dream was to have the solver check every place Indra could deploy the drone and have the drone still be able to attract the portal. This is a bit too complex; we'd have to import the coordinates to the solver or add generated logic. If we remove that need and just create ad-hoc rules as necessary (e.g. Indra has to be in this area to move the portal over here), then we don't need to keep all the spots around. But they are useful for the movement calculations (which are done in python).

What we could do is programmatically build a condensed graph, where we select the nodes we care about from an area (which will be any with a Location, Action, or an Exit to another area), and for each such node, determine paths to each other node (that isn't through another one). In AV2's Lake Amagi > Main Area, for example, we'd remove around 20 nodes and leave around 15.

What would be a little tricky about this are in-area exits; we'd have to AND the requirements together if a path went through multiple such exits, and if we have multiple parallel exits, we could see a combinatorial explosion of exits. Plus the codebase is not equipped to natively understand the requirements. Although the Rust compiler can do a great job simplifying everything if we generate these exits through the python Compiler, python will much slower at the shortest path algorithms. On the other hand, we'd need to modify the can_access interface a little if we perform the condensing in Rust, since as it is currently, each condensed edge would need a reference to all the actual Exit objects it covers.

Zannick commented 1 year ago

Here's the general algorithm idea:

For each area:

  1. Mark spots of interest: any spot with a Location, Action, Exit to another Area, or keep: true.
  2. For each spot, build a tree of movements to other spots in the area (basically Dijkstra's). Each edge in this tree should have:
    1. a base movement time, if it has one
    2. a list of pairs of (movement set, movement time), where movement time is smaller than any subset of the movement set (including the base movement).
    3. a list of pairs of (requirement, movement time), corresponding to Exits that move within the same Area, where movement time is smaller than the base movement. If an Exit's destination is already a node in the tree that isn't the child of the Exit's source, a) the time must be less than the base movement time from the source to the destination, and b) this will result in a node having multiple parents.
  3. For every spot of interest in this tree that is not a descendant of a spot of interest besides the root, generate the powerset of movement requirements in the list of edges (excepting the base movement), and for each entry in that powerset, record as a Condensed Edge the combination of requirements and the total sum of the minimum corresponding times for each edge in the tree. This requirement to exclude descendant spots of interest limits the potential combinatorial explosion of edges; we need to track all area edges in the tree anyway to be sure we cover the real shortest path.

    For example, if an exit requires an item Ledge_Grab and there is no base movement for the connection, and that edge is in the path to the destination node, then one Condensed Edge will have a requirement of Ledge_Grab and use the base movement for any other edges in the path.

Using AV2's Amagi > Main Area as an example, in the yaml right now it has:

This is more structure in the graph, but in comparing to the number of iterations needed to get through it. Before condensing:

Also, if we choose to build these condensed edges in Rust instead of generating code for them, then we could actually drop even more spots based on locations we know we can skip... probably.

Zannick commented 1 year ago

Now, arguably, we could go to the logical extreme here and apply this graph reduction to the graph as a whole, rather than area by area. This would essentially be precalculating the shortest paths between all the points of interest. The downside would be that we can't perform this full reduction for a map randomizer until after we've determined the game is beatable. For that, though, greedy search should be fast enough, and if not, we can make an access-graph-style beatability checker.

Zannick commented 1 year ago
  1. [...]

    1. [...] If an Exit's destination is already a node in the tree that isn't the child of the Exit's source, a) the time must be less than the base movement time from the source to the destination, and b) this will result in a node having multiple parents.

I guess this is true for movements as well. That kind of complicates it further. We can avoid having to check about multiple parents if we just do a depth-first-search on the DAG. We might generate some useless edges, though, if we're not checking mixes of possibilities, but the goal is reducing the number of spots anyway, not the number of edges.

Zannick commented 1 year ago

I tagged the wrong issue with the main implementation commit... 😅

The current issue with the BFS approach is correctly choosing when to stop. We need every possible combination of requirements for travelling between nodes A and B represented in its edges, where the requirements are modelled using the movement state (i.e. the movements' requirements) and the exit ids. We can always check whenever we find an edge between A and B whether its requirements are a superset of any we've already found (in which case if it's not faster then we can discard it), but the priority queue still has to terminate to know that we've finished. And so, we need to stop adding new items to the queue by some metric:

  1. limiting each potential edge (base movement, restricted movement, and exit) to once each in the whole DAG rooted at A makes it finish quickly but drops a lot of necessary edges (e.g. A -> C can find a path AB1-BC1 but then will exclude AB2-BC1).
  2. limiting each potential edge to once per path seems like the right idea but results in very snake-y routes with base movement and as many exits as possible, a long runtime, and an explosion of memory usage.

I imagine 2) would be vastly improved by having a max distance per pair of nodes. Either we have that from the base movement + always-exits path, or we have some other minimum requirements... we could probably check for a requirements subset again. But in iterating on the queue, we haven't picked a destination until we reach it, so we can't look at our path and say "oh, there's already a better route to B with less requirements" unless we can say "oh, there's already a better route to every other node with less requirements".

Changing the exit ids to access ids might help a little with the deduplication but doesn't solve the underlying problem... a BFS needs to properly identify when it reaches a state we've already reached. Perhaps we have to record the requirements of each path as we retrieve them (i.e. a hashset of (dest, reqs)), rather than track the individual edges for usage, and as we extend the path we have to check against those recordings?

Zannick commented 1 year ago

Finally working, and our results are:

Condensed into 435 total sources, 1631 total edges, 249 interesting spots (from 466), 677 interesting edges