-- Offline reachability over the extracted Gold cache. -- -- luajit tools/goldwalk/mapgraph.lua path CHERRYGROVE_CITY ILEX_FOREST -- luajit tools/goldwalk/mapgraph.lua map ROUTE_32 -- luajit tools/goldwalk/mapgraph.lua reach ROUTE_32 18 6 -- luajit tools/goldwalk/mapgraph.lua exits ROUTE_32 -- luajit tools/goldwalk/mapgraph.lua audit (every map's exits) -- luajit tools/goldwalk/mapgraph.lua graph > tests/drivers/gold/map_regions.lua -- -- The route bot's planner works from the live world, which means every question -- about the map graph used to cost a run. This answers the same questions from -- the cache in milliseconds: which exits of a map are reachable from which -- others, whether a connection's border is actually walkable, and what the real -- shortest path between two maps is. `path` is the ground truth the bot's -- travel is measured against; `reach` is what says whether a warp cell can be -- walked to from where the player lands. -- -- Surf is modelled as an option (`--surf`) rather than a fact, because whether -- water counts as passable is exactly the difference between a route that needs -- HM03 and one that does not. package.path = "./?.lua;./?/init.lua;" .. package.path local Permissions = require("src.world.gen2.Permissions") local CACHE = os.getenv("GOLD_CACHE") or ((os.getenv("HOME") or "") .. "/Library/Application Support/LOVE/gold-dev/gold") local maps = assert(loadfile(CACHE .. "/data/generated/maps.lua"))() local tilesets = assert(loadfile(CACHE .. "/data/generated/tilesets.lua"))() local SURF = false for _, a in ipairs(arg) do if a == "--surf" then SURF = true end end -- --------------------------------------------------------------------------- -- geometry -- --------------------------------------------------------------------------- local function tilesetOf(def) -- Extracted tilesets are keyed both ways depending on the table; try id then -- name so this does not depend on which the extractor happened to write. return tilesets[def.tilesetId] or tilesets[(def.tilesetId or 0) + 1] or tilesets[def.tileset] end local function cellCollision(def, cx, cy) local w, h = def.width, def.height local bx, by = math.floor(cx / 2), math.floor(cy / 2) local id if bx < 0 or by < 0 or bx >= w or by >= h then id = def.borderBlock or 0 else id = def.blocks[by * w + bx + 1] or 0 end if id == 0 then return 0xff end local ts = tilesetOf(def) local quad = ts and ts.collision and ts.collision[id + 1] if not quad then return 0xff end return quad[(cy % 2) * 2 + (cx % 2) + 1] or 0xff end local function inBounds(def, cx, cy) return cx >= 0 and cy >= 0 and cx < def.width * 2 and cy < def.height * 2 end -- Warp cells are holes in the floor, not floor. -- -- Stepping onto one leaves the map, so a flood fill that walks THROUGH warps -- draws a map that cannot be walked. Route 33 is the case that proves it: its -- northern strip and its southern strip touch at exactly one cell, (11,9), and -- that cell is the Union Cave entrance. Treat it as floor and Route 33 is one -- connected map, its west border reaches Azalea Town proper, and the planner -- confidently routes a walk that ends in a cave. Treat it as a hole and the -- truth appears: you reach southern Route 33, and therefore Azalea, only by -- coming out of Union Cave. -- ...but only when the tile itself is a warp. CheckWarpTile reads the -- COLLISION, so a `warp_event` sitting on plain floor never fires -- and -- Ecruteak Gym is full of those: several of its thirty floor-hole coordinates -- are ordinary floor, and they are the safe path between the pits. Treating -- the coordinate as the hole cut that gym in two and hid Morty. local function warpIndexAt(def, cx, cy) for i, w in ipairs(def.warps or {}) do if w.x == cx and w.y == cy then local coll = cellCollision(def, cx, cy) if Permissions.isWarpCollision(coll) or Permissions.carpetDirection(coll) ~= nil then return i end return nil end end return nil end local function floorAt(def, cx, cy) if not inBounds(def, cx, cy) then return false end local coll = cellCollision(def, cx, cy) if Permissions.isWalkable(coll) then return true end if SURF and Permissions.isWater(coll) then return true end return false end local function passable(def, cx, cy) if not floorAt(def, cx, cy) then return false end return warpIndexAt(def, cx, cy) == nil end -- Movement is DIRECTED, and the fill has to be too. -- -- Two cart mechanics make cells one-way (see Permissions.stepPermitted / -- ledgeFacings): Gold's side-wall arms -- standing on an UP_WALL you may not -- move up, and nobody may step DOWN onto one -- and ledge hops, where a -- refused step off a HOP tile jumps TWO cells in its own directions and never -- comes back. Burned Tower B1F is the map that forced this: its fall-landing -- pockets drain into the main floor only through hop-down ledges, so an -- undirected fill called them sealed and the planner teleported out. -- -- A "region" is therefore a strongly connected component -- the cells that can -- all reach EACH OTHER -- and a region's usable exits are computed over its -- forward closure (everything it can reach, one-way drains included). local DELTA4 = { { 0, -1, "up" }, { 0, 1, "down" }, { -1, 0, "left" }, { 1, 0, "right" } } local function collOf(def) return function(x, y) return cellCollision(def, x, y) end end -- Successors of (x, y): ordinary steps plus ledge hops, exactly the engine's -- order (a hop fires only when the single step is refused). local function stepsFrom(def, x, y) local out = {} local co = collOf(def) local hop = Permissions.ledgeFacings(cellCollision(def, x, y)) for _, d in ipairs(DELTA4) do local nx, ny, dir = x + d[1], y + d[2], d[3] if Permissions.stepPermitted(co, x, y, dir) and passable(def, nx, ny) then out[#out + 1] = { nx, ny } elseif hop and hop[dir] then local hx, hy = x + d[1] * 2, y + d[2] * 2 if passable(def, hx, hy) then out[#out + 1] = { hx, hy } end end end return out end -- Can (x, y) step or hop onto (tx, ty)? Used by the backward fill. local function stepsOnto(def, x, y, tx, ty) if not passable(def, x, y) then return false end for _, s in ipairs(stepsFrom(def, x, y)) do if s[1] == tx and s[2] == ty then return true end end return false end -- Forward-reachable set from (sx,sy) (the honest "what can I reach from -- here"), keyed y*4096+x. local function region(def, sx, sy) local key = function(x, y) return y * 4096 + x end local seen = { [key(sx, sy)] = true } local queue, head = { { sx, sy } }, 1 while head <= #queue do local c = queue[head]; head = head + 1 for _, s in ipairs(stepsFrom(def, c[1], c[2])) do local k = key(s[1], s[2]) if not seen[k] then seen[k] = true queue[#queue + 1] = s end end end return seen end -- Everything that can REACH (sx,sy): the same fill over reversed edges. A -- predecessor is an adjacent cell stepping in, or a cell two out hopping in. local function regionBackward(def, sx, sy) local key = function(x, y) return y * 4096 + x end local seen = { [key(sx, sy)] = true } local queue, head = { { sx, sy } }, 1 while head <= #queue do local c = queue[head]; head = head + 1 for _, d in ipairs(DELTA4) do for _, dist in ipairs({ 1, 2 }) do local px, py = c[1] + d[1] * dist, c[2] + d[2] * dist local k = py * 4096 + px if not seen[k] and stepsOnto(def, px, py, c[1], c[2]) then seen[k] = true queue[#queue + 1] = { px, py } end end end end return seen end -- The strongly connected component of (sx,sy): forward ∩ backward. local function scc(def, sx, sy) local fwd = region(def, sx, sy) local bwd = regionBackward(def, sx, sy) local out = {} for k in pairs(fwd) do if bwd[k] then out[k] = true end end return out end local DIR_DELTA = { north = { 0, -1 }, south = { 0, 1 }, west = { -1, 0 }, east = { 1, 0 } } -- Border cells of `def` on the given side that a player could stand on. local function borderCells(def, dir) local w, h = def.width * 2, def.height * 2 local out = {} if dir == "north" then for x = 0, w - 1 do if passable(def, x, 0) then out[#out + 1] = { x, 0 } end end elseif dir == "south" then for x = 0, w - 1 do if passable(def, x, h - 1) then out[#out + 1] = { x, h - 1 } end end elseif dir == "west" then for y = 0, h - 1 do if passable(def, 0, y) then out[#out + 1] = { 0, y } end end else for y = 0, h - 1 do if passable(def, w - 1, y) then out[#out + 1] = { w - 1, y } end end end return out end -- --------------------------------------------------------------------------- -- the graph -- --------------------------------------------------------------------------- -- A node is (map, region): two warps on the same map that cannot walk to each -- other are genuinely different places, which is the fact a map-only graph -- misses and the bot's planner pays for at run time. -- A warp you can LEAVE through, as opposed to one you can only arrive on. -- -- Half the warp_events in a multi-floor interior sit on plain floor: they are -- the landing spot of a ladder on the other side, and CheckWarpTile will never -- fire on them because the collision is not a warp. Offering them as exits is -- how the Olivine lighthouse trapped the bot -- 3F's seven-cell pocket has -- three warps in it, two of them (8 and 9, both `coll=00`) arrival-only, and -- the planner kept choosing those instead of the one real ladder at (9,5). local function firesAsExit(def, w) local coll = cellCollision(def, w.x, w.y) return Permissions.isWarpCollision(coll) or Permissions.carpetDirection(coll) ~= nil end local function exitsOf(id) local def = maps[id] if not def then return {} end local out = {} for i, w in ipairs(def.warps or {}) do if w.destMap and maps[w.destMap] and firesAsExit(def, w) then out[#out + 1] = { kind = "warp", index = i, x = w.x, y = w.y, to = w.destMap, destWarp = w.destWarp } end end for dir, conn in pairs(def.connections or {}) do if conn.mapId and maps[conn.mapId] then out[#out + 1] = { kind = "edge", dir = dir, to = conn.mapId, offset = conn.offset or 0 } end end return out end -- Where does an exit deposit the player on the destination map? local function landingOf(id, exit) local def = maps[id] local destDef = maps[exit.to] if not destDef then return nil end if exit.kind == "warp" then local w = (destDef.warps or {})[exit.destWarp] if w then return w.x, w.y end -- destWarp out of range happens on a few one-way warps; fall back to the -- first warp so the graph still has an anchor. local first = (destDef.warps or {})[1] return first and first.x, first and first.y end local dw, dh = destDef.width * 2, destDef.height * 2 local d = DIR_DELTA[exit.dir] if not d then return nil end -- The connection strip lands you on the far border, offset in blocks. if exit.dir == "north" then return nil, dh - 1 end if exit.dir == "south" then return nil, 0 end if exit.dir == "west" then return dw - 1, nil end return 0, nil end -- Can a player standing in region `reg` on `id` USE this exit? -- -- A warp's own cell is a hole, so it is never inside a region: what matters is -- whether the region touches it. A connection is usable if the region reaches -- any cell of that border. local function exitReachable(id, exit, reg) local def = maps[id] if exit.kind == "warp" then if reg[exit.y * 4096 + exit.x] then return true end for _, d in ipairs({ { 0, -1 }, { 0, 1 }, { -1, 0 }, { 1, 0 } }) do if reg[(exit.y + d[2]) * 4096 + (exit.x + d[1])] then return true end end return false end for _, c in ipairs(borderCells(def, exit.dir)) do if reg[c[2] * 4096 + c[1]] then return true end end return false end -- Every distinct standing region of a map, keyed by a representative cell. -- -- `cells` is the strongly connected component (mutual reachability -- what -- "standing in this region" means), `forward` its full forward closure (what -- a player standing there can get to, one-way drains included). Exits are -- judged over `forward`; landings are matched against `cells`. local function regionsOf(id) local def = maps[id] local seen, out = {}, {} local w, h = def.width * 2, def.height * 2 for y = 0, h - 1 do for x = 0, w - 1 do local k = y * 4096 + x if not seen[k] and passable(def, x, y) then local comp = scc(def, x, y) for rk in pairs(comp) do seen[rk] = true end out[#out + 1] = { x = x, y = y, cells = comp, forward = region(def, x, y) } end end end return out end -- --------------------------------------------------------------------------- -- commands -- --------------------------------------------------------------------------- -- The region graph, in memory. `graph` prints it and `path` searches it, so -- the tool's answer and the bot's plan cannot drift apart. local function buildGraph() local ids = {} for id in pairs(maps) do ids[#ids + 1] = id end table.sort(ids) local regions = {} for _, id in ipairs(ids) do regions[id] = regionsOf(id) end local function regionsAt(id, x, y) local out, seen = {}, {} local function add(cx, cy) for index, r in ipairs(regions[id] or {}) do if r.cells[cy * 4096 + cx] and not seen[index] then seen[index] = true out[#out + 1] = index end end end add(x, y) if #out == 0 then for _, d in ipairs({ { 0, -1 }, { 0, 1 }, { -1, 0 }, { 1, 0 } }) do add(x + d[1], y + d[2]) end end return out end local graph = {} for _, id in ipairs(ids) do local list = {} for _, r in ipairs(regions[id]) do local size = 0 for _ in pairs(r.cells) do size = size + 1 end local links = {} for _, e in ipairs(exitsOf(id)) do if exitReachable(id, e, r.forward) then local landings = {} if e.kind == "warp" then local lx, ly = landingOf(id, e) if lx and ly then landings = regionsAt(e.to, lx, ly) end else local destDef = maps[e.to] local destW, destH = destDef.width * 2, destDef.height * 2 local offset = (e.offset or 0) * 2 local seen = {} for _, c in ipairs(borderCells(maps[id], e.dir)) do if r.forward[c[2] * 4096 + c[1]] then local lx, ly if e.dir == "north" then lx, ly = c[1] - offset, destH - 1 elseif e.dir == "south" then lx, ly = c[1] - offset, 0 elseif e.dir == "west" then lx, ly = destW - 1, c[2] - offset else lx, ly = 0, c[2] - offset end lx = math.max(0, math.min(destW - 1, lx)) ly = math.max(0, math.min(destH - 1, ly)) for _, k in ipairs(regionsAt(e.to, lx, ly)) do if not seen[k] then seen[k] = true landings[#landings + 1] = k end end end end end for _, k in ipairs(landings) do if e.kind == "warp" then links[#links + 1] = { k = "w", i = e.index, x = e.x, y = e.y, to = e.to, r = k } else links[#links + 1] = { k = "e", d = e.dir, to = e.to, r = k } end end end end list[#list + 1] = { x = r.x, y = r.y, size = size, exits = links } end if #list > 0 then graph[id] = list end end return graph, ids end local cmd = arg[1] local function requireMap(id) if not maps[id] then io.stderr:write(("no such map: %s\n"):format(tostring(id))) os.exit(1) end return maps[id] end if cmd == "map" then local id = arg[2] local def = requireMap(id) print(("%s %dx%d blocks (%dx%d cells) tileset %s") :format(id, def.width, def.height, def.width * 2, def.height * 2, tostring(def.tileset))) print("connections:") for dir, conn in pairs(def.connections or {}) do local cells = borderCells(def, dir) print((" %-6s -> %-28s offset %-4d %d walkable border cells") :format(dir, tostring(conn.mapId), conn.offset or 0, #cells)) end print("warps:") for i, w in ipairs(def.warps or {}) do print((" %2d (%3d,%3d) -> %-28s warp %s") :format(i, w.x, w.y, tostring(w.destMap), tostring(w.destWarp))) end local regs = regionsOf(id) print(("regions: %d"):format(#regs)) for i, r in ipairs(regs) do local n = 0 for _ in pairs(r.cells) do n = n + 1 end local names = {} for _, e in ipairs(exitsOf(id)) do if exitReachable(id, e, r.forward) then names[#names + 1] = (e.kind == "warp" and ("w%d->%s"):format(e.index, e.to) or ("%s->%s"):format(e.dir, e.to)) end end print((" region %d from (%d,%d): %4d cells, exits: %s") :format(i, r.x, r.y, n, #names > 0 and table.concat(names, " ") or "(none)")) end elseif cmd == "reach" then local id, x, y = arg[2], tonumber(arg[3]), tonumber(arg[4]) local def = requireMap(id) local reg = region(def, x, y) local n = 0 for _ in pairs(reg) do n = n + 1 end print(("from (%d,%d) on %s: %d reachable cells"):format(x, y, id, n)) for _, e in ipairs(exitsOf(id)) do local ok = exitReachable(id, e, reg) print((" %-4s %-30s %s") :format(ok and "ok" or "NO", e.kind == "warp" and ("warp %d (%d,%d) -> %s"):format(e.index, e.x, e.y, e.to) or ("%s edge -> %s"):format(e.dir, e.to), "")) end elseif cmd == "exits" then local id = arg[2] requireMap(id) for _, e in ipairs(exitsOf(id)) do if e.kind == "warp" then print(("warp %2d (%3d,%3d) -> %s"):format(e.index, e.x, e.y, e.to)) else print(("%-6s edge -> %s (offset %d, %d walkable border cells)") :format(e.dir, e.to, e.offset, #borderCells(maps[id], e.dir))) end end elseif cmd == "path" then -- Shortest walkable path over (map, region) nodes. This is what the bot's -- travel SHOULD find; a discrepancy is a planner bug, not a map bug. local from, to = arg[2], arg[3] requireMap(from); requireMap(to) local startDef = maps[from] local startCell if arg[4] and arg[5] then startCell = { tonumber(arg[4]), tonumber(arg[5]) } else local w = (startDef.warps or {})[1] startCell = w and { w.x, w.y } or { 0, 0 } end -- Node identity: map id + a canonical cell of the region we are standing in. local regionCache = {} local function regionAt(id, x, y) local key = ("%s#%d,%d"):format(id, x, y) if regionCache[key] then return regionCache[key] end local reg = region(maps[id], x, y) regionCache[key] = reg return reg end local function canon(id, reg) local best for k in pairs(reg) do if not best or k < best then best = k end end return ("%s@%s"):format(id, tostring(best)) end local startReg = regionAt(from, startCell[1], startCell[2]) local startKey = canon(from, startReg) local dist = { [startKey] = 0 } local prev = {} local queue = { { id = from, reg = startReg, key = startKey, x = startCell[1], y = startCell[2] } } local head = 1 local found while head <= #queue do local node = queue[head]; head = head + 1 if node.id == to then found = node break end for _, e in ipairs(exitsOf(node.id)) do if exitReachable(node.id, e, node.reg) then -- Where the exit puts us. A warp names one cell. A CONNECTION is a -- strip, not a cell, and which cell you land on is decided by where -- along the border you crossed -- so every region that touches the far -- border is a landing this exit can produce. Modelling it as a single -- cell was wrong in a way that mattered: Azalea Town's east border is -- touched by two regions, one of them a 27-cell pocket with no exit but -- the way back, and picking that one made the whole of Azalea look -- unreachable from Route 33. local landings = {} local lx, ly = landingOf(node.id, e) if lx and ly then landings[#landings + 1] = { lx, ly } else local opposite = ({ north = "south", south = "north", west = "east", east = "west" })[e.dir] for _, c in ipairs(borderCells(maps[e.to], opposite)) do landings[#landings + 1] = c end end for _, c in ipairs(landings) do -- floorAt, not passable: a warp's landing is usually ON the far -- warp's own tile (a door mat), and `passable` calls every warp -- tile a hole -- which silently dropped every door landing and made -- `path` report NO ROUTE into any interior. The region fill from a -- warp cell expands through its standable neighbours, so seeding on -- the mat is fine. if floorAt(maps[e.to], c[1], c[2]) then local reg = regionAt(e.to, c[1], c[2]) local key = canon(e.to, reg) if dist[key] == nil then dist[key] = dist[node.key] + 1 prev[key] = { node = node, exit = e } queue[#queue + 1] = { id = e.to, reg = reg, key = key, x = c[1], y = c[2] } end end end end end end if not found then print(("NO WALKABLE ROUTE %s -> %s%s") :format(from, to, SURF and " (even with surf)" or " (try --surf)")) os.exit(2) end local chain = {} local node = found while prev[node.key] do local step = prev[node.key] table.insert(chain, 1, { from = step.node.id, exit = step.exit }) node = step.node end print(("%s -> %s : %d hops%s") :format(from, to, #chain, SURF and " (surf allowed)" or "")) for i, hop in ipairs(chain) do local e = hop.exit print((" %2d %-30s %s"):format(i, hop.from, e.kind == "warp" and ("warp %d (%d,%d) -> %s"):format(e.index, e.x, e.y, e.to) or ("%s edge -> %s"):format(e.dir, e.to))) end elseif cmd == "graph" then -- Emit the region graph the bot plans over. -- -- luajit tools/goldwalk/mapgraph.lua graph > tests/drivers/gold/map_regions.lua -- -- A node is (map, region): a map's walkable cells split by whatever the -- player cannot walk across, including its own warp tiles. That split is the -- thing a map-id graph cannot say and the bot kept paying for -- "Route 33 -- connects west to Azalea Town" is true of one half of Route 33 and false of -- the other, and "Azalea Town connects east to Route 33" is true of the town -- and of a 27-cell dead end that only looks like the town from outside. local graph, ids = buildGraph() local out = {} out[#out + 1] = "-- GENERATED by tools/goldwalk/mapgraph.lua graph." out[#out + 1] = "-- Region-aware map graph for the Gold route bot: see that" out[#out + 1] = "-- tool's `graph` command for what a region is and why the" out[#out + 1] = "-- bot cannot plan without one. Regenerate after any change" out[#out + 1] = "-- to the extractor's map output." out[#out + 1] = "return {" for _, id in ipairs(ids) do local regs = graph[id] if regs then out[#out + 1] = (" [%q] = {"):format(id) for _, r in ipairs(regs) do local links = {} for _, e in ipairs(r.exits) do if e.k == "w" then links[#links + 1] = ("{k=\"w\",i=%d,x=%d,y=%d,to=%q,r=%d}") :format(e.i, e.x, e.y, e.to, e.r) else links[#links + 1] = ("{k=\"e\",d=%q,to=%q,r=%d}") :format(e.d, e.to, e.r) end end out[#out + 1] = (" { x = %d, y = %d, size = %d, exits = { %s } },") :format(r.x, r.y, r.size, table.concat(links, ", ")) end out[#out + 1] = " }," end end out[#out + 1] = "}" print(table.concat(out, "\n")) elseif cmd == "audit" then -- Every connection whose border has no walkable cell on one side: those are -- the edges the bot will try, fail, and price -- and each one is either a map -- the player is meant to reach through a gate instead, or an extractor bug. local bad = 0 local ids = {} for id in pairs(maps) do ids[#ids + 1] = id end table.sort(ids) for _, id in ipairs(ids) do local def = maps[id] for dir, conn in pairs(def.connections or {}) do local cells = borderCells(def, dir) if #cells == 0 then bad = bad + 1 print(("%-30s %-6s -> %-30s NO walkable border cell") :format(id, dir, tostring(conn.mapId))) end end end print(("%d connections have an unwalkable near border"):format(bad)) else print("usage: mapgraph.lua {map|reach|exits|path|audit} [args] [--surf]") end