-- Full-route bot driver. -- -- POKEPORT_DRIVER=tests/drivers/route.lua love . -- xvfb-run -a env POKEPORT_DRIVER=tests/drivers/route.lua love . (headless) -- -- Interprets tests/drivers/bot_route.lua -- the converted PokeBotBad any% -- route, see tools/botconv/ -- against the live Game object. The route is -- geography only: we walk where the speedrun walks but fight everything -- the slow reliable way, so none of its frame-perfect tactics carry over. -- -- Unimplemented ops do not abort the run. They log and continue, and the -- summary at the end lists every op that was skipped and where. A run that -- stalls tells you which handler to write next; a run that finishes is an -- end-to-end assertion that warps, scripts, battles and menus all work. local U = require("tests.drivers.util") local ROUTE = require("tests.drivers.bot_route") local G -- the Game object, set on entry local skipped = {} local log = {} -- Set when the party blacks out. Polling HP for this is unreliable: the -- revive can land in the same frame the battle screen pops, so a run can -- black out without the driver ever observing a zeroed party -- which is -- exactly how one attempt sailed on to skip 145 segments and then ran the -- ENDGAME Viridian Gym visit at level 12, because it happened to be -- standing in Viridian City and the map matched. -- -- OverworldController announces it instead, immediately before reviving -- (`Runtime.emit("world.blacked_out", ...)`), so hook the announcement. -- Wrapping emit rather than subscribing keeps this working headlessly: -- the mod event bus is a null object until a loader installs one. local blackedOut = false do local Runtime = require("src.mods.Runtime") local baseEmit = Runtime.emit Runtime.emit = function(name, payload) if name == "world.blacked_out" then blackedOut = true end return baseEmit(name, payload) end end local function note(op, where) local key = op .. " @ " .. where skipped[key] = (skipped[key] or 0) + 1 end -- A long run is normally stopped by killing the process, and block-buffered -- stdout discards everything since the last 4K flush -- which is most of -- what we want to read. Go unbuffered, and additionally mirror to a file -- flushed per line (POKEPORT_ROUTE_LOG) so output survives a hard kill. pcall(function() io.stdout:setvbuf("no") end) local logFile do local path = os.getenv("POKEPORT_ROUTE_LOG") if path then logFile = io.open(path, "w") end end -- --------------------------------------------------------------------- -- stuck-loop watchdog -- --------------------------------------------------------------------- -- "Stuck" from the outside looks like the log printing the same thing over -- and over: every retry loop in this file narrates each attempt through -- say(), so a run that repeats the exact same line-cycle STUCK_REPEATS -- times without reaching a NEW segment is trying the same thing and -- getting the same result. Detect that inside say() itself, throw a -- sentinel table, and let runRouteGuarded (bottom of file) turn it into a -- checkpoint plus a written report instead of an endless loop. A backstop -- in U.wait catches the opposite shape: a loop that spins silently, -- logging nothing at all. -- One table, not a spray of locals: the main chunk was already brushing -- LuaJIT's 200-local limit before the watchdog existed. local WD = { -- POKEPORT_ROUTE_WATCHDOG=0 disables the 10-repeat stuck detector and -- its silent-stall backstop entirely: the run then only ever stops on a -- real death/lost-abandon or the outer time cap, and slow-but-working -- stretches (the Victory Road grind, long Elite Four battles) are never -- cut short by a false positive. enabled = os.getenv("POKEPORT_ROUTE_WATCHDOG") ~= "0", repeats = tonumber(os.getenv("POKEPORT_ROUTE_STUCK_REPEATS")) or 10, -- Cycle/window sizing: the ROUTE_17 travelTo loop narrated a ~16-line -- cycle, sailing under a 12-line period cap and putting only ~6 copies -- of any one line in a 100-line window. Both bounds sized to catch it: -- 10 repeats of a 20-line cycle span 200 lines, within keep (240). maxPeriod = 20, -- longest line-cycle the detector will match window = 200, -- scattered-repeat window (see WD.recordLine) stallFrames = 150000, -- silent logic-frames before the backstop fires keep = 240, history = {}, armed = false, lastActivity = 0, reportPath = os.getenv("POKEPORT_ROUTE_STUCK_REPORT") or "/tmp/route_stuck_report.txt", -- trees whose felling provably brought us no closer to a target this -- run, keyed "#," (see cutToward); they respawn on every -- map re-entry, so without this the cut loop never converges futileCuts = {}, -- surf mounts repeated from the same shore toward the same target -- (see mountSurfToward); a disconnected pond otherwise spins forever futileMounts = {}, -- items a later teach step needs; filled in beside TEACH_ITEMS (which -- is declared after pickupTossJunk, so the set rides on WD instead) keepItems = {}, -- mansion statue toggles per sealed target (see toggleMansionSwitch) futileSwitches = {}, -- where the run is, kept fresh by runRoute for the stuck report progress = { segment = 0, maxSegment = 0, stepIndex = nil, stepCount = nil, action = nil }, } -- Returns a hit table when the tail of the history is one cycle of up to -- maxPeriod lines repeated `repeats` times back to back, or when this -- exact line has shown up `repeats` times inside the last `window` lines -- (the sloppier loops interleave their repeats with lines that vary, so -- pure periodicity alone would miss them). function WD.recordLine(line) local h = WD.history h[#h + 1] = line if #h > WD.keep then table.remove(h, 1) end WD.lastActivity = U.frame() local n = #h for p = 1, WD.maxPeriod do local need = p * WD.repeats if n >= need then local periodic = true for j = n - need + p + 1, n do if h[j] ~= h[j - p] then periodic = false break end end if periodic then local cycle = {} for j = n - p + 1, n do cycle[#cycle + 1] = h[j] end return { period = p, count = WD.repeats, cycle = cycle, line = line } end end end local seen = 0 for j = math.max(1, n - WD.window + 1), n do if h[j] == line then seen = seen + 1 end end if seen >= WD.repeats then return { period = 0, count = seen, cycle = { line }, line = line } end return nil end -- Silent-stall backstop. Every loop in the driver waits through U.wait -- (U.tap included), so a run that stops logging but keeps spinning still -- passes through here every frame it burns. do local baseWait = U.wait -- Brake-coast: a perfect fence-lean can deadlock -- the bike pinned on -- one cell while the planner waits for a state change that never comes -- (observed twice on ROUTE_17; a manual "down" press un-wedged it both -- times). So after too many braked frames on the SAME cell, stop -- leaning for a moment and let the hill move us a cell, which is -- exactly that manual nudge, automated. local brakeCell, brakeRun, coastLeft = nil, 0, 0 U.wait = function(n) for _ = 1, (n or 1) do -- Cycling Road brake (see slopeBrakeDir, defined later; resolved as -- a global at call time): every idle overworld frame the driver -- waits through must lean on the fence or the bike rolls south. -- Only when the overworld is the live state and no tap is queued, -- so menu cursors and interactions never see a phantom held key. local brake if G and G.stack and G.overworld and G.stack:top() == G.overworld and #(G.input.pressQueue or {}) == 0 and slopeBrakeDir then brake = slopeBrakeDir() end if brake then local p = G.overworld.player local cell = (p.cellY or 0) * 4096 + (p.cellX or 0) if cell == brakeCell then brakeRun = brakeRun + 1 else brakeCell, brakeRun = cell, 0 end if coastLeft > 0 then coastLeft = coastLeft - 1 brake = nil -- hands off: let the bike roll elseif brakeRun > 600 then brakeRun, coastLeft = 0, 20 brake = nil end else brakeCell, brakeRun, coastLeft = nil, 0, 0 end if brake then G.input.state[brake] = true end baseWait(1) if brake then G.input.state[brake] = false end end if WD.armed and (U.frame() - WD.lastActivity) > WD.stallFrames then WD.armed = false error({ routeStuck = { period = 0, count = 1, cycle = {}, silent = true, line = ("no log output for %d frames"):format(WD.stallFrames) } }, 0) end end end -- --------------------------------------------------------------------- -- danger memory -- --------------------------------------------------------------------- -- Where the party has died before, counted per map and persisted, so the -- bot gets more careful about a place every time it loses there instead of -- walking into the same fight at the same HP forever. It survives the -- process, so each run starts knowing what killed the last one. -- -- Keyed by map rather than by segment index: segment numbers shift -- whenever the route is regenerated, but "Viridian Forest keeps killing -- us" stays true across conversions. local MEMORY_PATH = os.getenv("POKEPORT_ROUTE_MEMORY") or "/tmp/pokeport_route_memory.lua" local deaths, walls, seams = {}, {}, {} local function loadMemory() local chunk = loadfile(MEMORY_PATH) if not chunk then return end local ok, t = pcall(chunk) if not (ok and type(t) == "table") then return end -- earlier files were a flat map -> death-count table deaths = type(t.deaths) == "table" and t.deaths or t walls = type(t.walls) == "table" and t.walls or {} seams = type(t.seams) == "table" and t.seams or {} end local function writeCounts(fh, tbl) local keys = {} for k in pairs(tbl) do keys[#keys + 1] = k end table.sort(keys) for _, k in ipairs(keys) do fh:write((" [%q] = %d,\n"):format(k, tbl[k])) end end local function saveMemory() local fh = io.open(MEMORY_PATH, "w") if not fh then return end fh:write("-- written by tests/drivers/route.lua; safe to delete to forget\n") fh:write("return {\n deaths = {\n") writeCounts(fh, deaths) fh:write(" },\n walls = {\n") writeCounts(fh, walls) fh:write(" },\n seams = {\n") writeCounts(fh, seams) fh:write(" },\n}\n") fh:close() end local function dangerAt(mapId) return deaths[mapId or ""] or 0 end local function recordDeath(mapId) if not mapId then return end deaths[mapId] = (deaths[mapId] or 0) + 1 saveMemory() end -- Cells the engine refused to walk into, keyed "#". -- -- BFS plans over map:isWalkableCell, a static tile test that does not know -- about ledges (one-way), directional blocks, counters or the goal-cell -- exemption below -- so it can hand back a step tryMove rejects. Because -- BFS is deterministic, re-planning then returns the SAME step and the -- player bumps the identical wall until the retry counter gives up. This -- is that missing feedback: a refusal is remembered and planned around. local WALL_CONFIRM = 2 -- refusals before future runs plan around it local function wallKey(mapId, cell) return tostring(mapId) .. "#" .. cell end local function noteWall(mapId, cell) local k = wallKey(mapId, cell) walls[k] = (walls[k] or 0) + 1 saveMemory() return walls[k] end -- Walking onto a cell proves it passable after all, so forget it. Keeps a -- temporary blocker (an NPC mid-stroll, a tree before we have Cut) from -- being blacklisted for good. local function clearWall(mapId, cell) local k = wallKey(mapId, cell) if walls[k] then walls[k] = nil; saveMemory() end end -- Map-graph hops that did not work, keyed ">". -- -- The same feedback idea as `walls`, one level up. travelTo plans over map -- adjacency, which is geography and says nothing about whether the seam is -- actually passable YET: Route 6's north connection to Saffron is real map -- data, but the city is walled and gated until the guards are dealt with. -- -- A PRICE, never a ban, and that distinction is the whole lesson here. This -- started out as a hard block after two failures, which quietly severed the -- map graph: crossing a seam is flaky (an NPC parked on the border cell is -- enough), so VERMILION_CITY>ROUTE_6 -- the only way north out of Vermilion, -- and one that had already worked earlier in the same session -- reached two -- failures and became impossible. The run degenerated to skipping from -- segment 1. A seam that is merely expensive gets tried again when it is the -- only way through; a banned one strands the run for good. -- -- Cleared outright on a crossing that works, so a seam that opens later (a -- gate script, a Cut tree) costs nothing thereafter. local SEAM_PENALTY = 4 -- added to the hop cost per recorded failure local SEAM_PENALTY_MAX = 24 local function seamKey(from, to) return tostring(from) .. ">" .. tostring(to) end -- A travel edge's identity for the per-trip ban set. -- -- Per WARP, not per seam. A gate exposes many physical warps for the SAME -- (from, to) pair -- ROUTE_16_GATE_1F has eight LAST_MAP warps all leading -- back to ROUTE_16, four to each half -- so banning by seamKey when one of -- them fails kills all eight, including the east door that is the only way -- across. Keying the ban by the warp's own cell keeps the others live. -- Connections have a single seam, so they fall back to seamKey. local function edgeId(from, e) if e.warp then return ("%s>%s@%d,%d"):format(tostring(from), tostring(e.to), e.warp.x, e.warp.y) end return seamKey(from, e.to) end local function noteSeam(from, to) local k = seamKey(from, to) seams[k] = (seams[k] or 0) + 1 saveMemory() return seams[k] end local function clearSeam(from, to) local k = seamKey(from, to) if seams[k] then seams[k] = nil; saveMemory() end end local function seamCost(from, to) return math.min((seams[seamKey(from, to)] or 0) * SEAM_PENALTY, SEAM_PENALTY_MAX) end -- cells this map has refused often enough to route around from the start local function knownWalls(mapId) local prefix = tostring(mapId) .. "#" local set for k, n in pairs(walls) do if n >= WALL_CONFIRM and k:sub(1, #prefix) == prefix then set = set or {} set[tonumber(k:sub(#prefix + 1))] = true end end return set end local function say(...) local parts = { ... } for i = 1, #parts do parts[i] = tostring(parts[i]) end local line = "[route] " .. table.concat(parts, " ") print(line) if logFile then logFile:write(line, "\n"); logFile:flush() end -- Never let a BATTLE trip the stuck detector. A fight in progress narrates -- "battle timed out" / "trainer battle slow" as it grinds a foe down, and -- treating those repeats as a wedge rewound the run straight out of the -- Elite Four. The E4 is won by attrition -- XP is kept through a blackout, -- so every attempt levels the team -- so a fight must be allowed to run to -- its own end (a win, or a faint toward a blackout that retries at the -- lobby), never rewound. (inBattle() is declared below; inline its test.) local bt = G and G.stack and G.stack.top and G.stack:top() local inABattle = bt ~= nil and bt.kind ~= nil if WD.armed and not inABattle then local hit = WD.recordLine(line) if hit then -- disarm before throwing so the unwind's own say() calls (the -- report, the checkpoint) cannot re-trigger the detector WD.armed = false error({ routeStuck = hit }, 0) end end end -- --------------------------------------------------------------------- -- state predicates (mirrors tests/autopilot.lua's busy()/idle()) -- --------------------------------------------------------------------- local function ow() return G.overworld end -- Anything that makes the overworld ignore normal player input: a pushed -- UI state, a script/cutscene in flight, or a timed hold. A plain -- stack-size check reads "idle" the instant a text box closes even mid -- cutscene, which would let us resume walking during Oak's escort. local function busy() local o = ow() return G.stack:top() ~= o or (o.runner and o.runner:isRunning()) or #(o.scriptMoves or {}) > 0 or o.engaging or o.emote ~= nil or o.healAnim ~= nil end local function idle() return not busy() and not ow().transitioning end local function inBattle() local top = G.stack:top() return top ~= nil and top.kind ~= nil end local function press(btn) U.tap(G, btn) end -- Movement needs the direction HELD, not tapped: OverworldController's -- handleInput walks on input:isDown(dir) (OverworldController.lua:734) and -- calls tryMove every frame the key is down. A one-frame tap only turns -- the player in place, which reads as walking on the spot into a wall. -- Edge exits, ledge hops and boulder pushes additionally require that we -- already face the direction before the step (lines 735-739), so holding -- through the turn is what makes those fire at all. -- -- Returns true if we changed cell (or map), false if we were blocked. local function walk(dir, maxFrames) local o = ow() local sx, sy, smap = o.player.cellX, o.player.cellY, o.map.id local moved = false -- Frames spent already facing `dir` without the step starting. The engine -- refuses a blocked move every frame the key is held, so without this the -- loop replays the bump animation for the whole of maxFrames -- and the -- caller then retries up to 8 times. That is what "Red walking into the -- same wall ten times and then recovering" looks like on screen; it is a -- held-input problem, not a pathfinding one. Bail as soon as the engine -- has clearly said no and let the caller re-plan. local refusedFrames = 0 for _ = 1, maxFrames or 40 do table.insert(G.input.pressQueue, dir) G.input.state[dir] = true coroutine.yield() local p = ow().player if ow().map.id ~= smap then moved = true break end if (p.cellX ~= sx or p.cellY ~= sy) and not p.moving then moved = true break end -- The step has COMMITTED (tween running toward a new cell): release -- the key NOW and ride out the tween without it. Holding through the -- landing frame attempts a further move the instant the tween ends, -- and on an edge-collision warp cell that held press IS the warp -- trigger (Warp.onCollision + extraCheck): walking DOWN onto Victory -- Road 1F's entrance mat at (9,17) -- a waypoint the route walks -- across horizontally, but our warp-averse BFS approaches from above -- -- warped straight out to ROUTE_23, which is the "left -- VICTORY_ROAD_1F partway through segment 183" exit. cellX commits -- only at tween END (Player.lua), so committing is read off targetX. if p.moving and p.targetX and (p.targetX ~= sx or p.targetY ~= sy) then G.input.state[dir] = false for _ = 1, 60 do if ow().map.id ~= smap then break end if not ow().player.moving then break end -- Cycling Road: lean on the fence through the landing frame, or -- the empty input there rolls the bike a cell south (and a held -- `dir` instead would step a cell too far) local brake = slopeBrakeDir() if brake then G.input.state[brake] = true end coroutine.yield() if brake then G.input.state[brake] = false end end local q = ow().player moved = ow().map.id ~= smap or q.cellX ~= sx or q.cellY ~= sy break end if busy() then -- A LEDGE HOP is performed with scriptMove, so it reads as "busy" -- exactly like a cutscene does -- and breaking here reported the hop -- as "did not move", which made goto_ blacklist the ledge as a wall. -- Wait out our own hop and judge by the cell we land on. Anything else -- busy (a battle, a real script) still belongs to the caller. local hopping = (ow().player.hopFrames or 0) > 0 if hopping and not inBattle() then for _ = 1, 90 do if not busy() then break end coroutine.yield() end local q = ow().player if ow().map.id ~= smap or q.cellX ~= sx or q.cellY ~= sy then moved = true end end break end -- the first frames legitimately go on turning to face `dir` if p.facing == dir and not p.moving then refusedFrames = refusedFrames + 1 if refusedFrames >= 6 then break end else refusedFrames = 0 end end G.input.state[dir] = false do -- the settle frame: inert everywhere except a slope map, where empty -- input here is exactly the forced-roll condition (see slopeBrakeDir) local brake = slopeBrakeDir() if brake then G.input.state[brake] = true end coroutine.yield() if brake then G.input.state[brake] = false end end return moved end -- --------------------------------------------------------------------- -- BFS over the current map (walls + stationary NPCs), from autopilot.lua -- --------------------------------------------------------------------- local DIRS = { { 0, -1, "up" }, { 0, 1, "down" }, { -1, 0, "left" }, { 1, 0, "right" } } -- direction key -> step delta, for turning a refused step back into a cell local DELTA = {} for _, d in ipairs(DIRS) do DELTA[d[3]] = { d[1], d[2] } end -- `extra` is a set of cell ids to treat as walls (see knownWalls / the -- refusal feedback in ops.goto_). -- Where a ledge hop from (x, y) in `dir` would land, or nil if there is no -- hop to be had. -- -- Mirrors OverworldState:checkLedgeHop (OverworldController.lua:845): the -- standing tile, the ledge tile in front and the input direction all have to -- match a row of field.ledges, and the landing cell two along must be free. -- -- BFS needs this because it plans over `isWalkableCell`, and a ledge tile is -- deliberately NOT walkable -- you may only cross it by hopping. Without it -- Route 4 is impassable: the route steps from (79,8) to (79,10), which is a -- hop over the ledge at (79,9), and the bot reported "goto (79,10) -- unreachable" and then failed the edge exit into Cerulean, losing the run to -- nine skipped segments every single time. Every later route with a ledge -- would have gone the same way. local function ledgeLanding(map, x, y, dir, dx, dy) local ledges = G.data.field and G.data.field.ledges if not ledges then return nil end local fx, fy = x + dx, y + dy if not map:inBounds(fx, fy) then return nil end local tileset = map.def.tileset local standing, front = map:cellTile(x, y), map:cellTile(fx, fy) for _, ledge in ipairs(ledges) do if (ledge.tileset or "OVERWORLD") == tileset and ledge.facing == dir and ledge.input == dir and ledge.standingTile == standing and ledge.ledgeTile == front then local lx, ly = fx + dx, fy + dy if map:inBounds(lx, ly) and map:isWalkableCell(lx, ly) then return lx, ly end end end return nil end -- Walkable for the player AS THEY CURRENTLY ARE. isWalkableCell is a -- land-only test, so once SURF is up every remaining over-water waypoint -- would read unreachable -- and ROUTE_21 is a ninety-row water corridor, -- so the trip to Cinnabar dies on the first plan without this. While -- surfing, water counts too; land stays passable because walking onto the -- shore is exactly how you dismount. local function passableCell(map, x, y) if map:isWalkableCell(x, y) then return true end local p = ow().player return p.surfing == true and map:isWaterCell(x, y) end -- --------------------------------------------------------------------- -- Cycling Road (slope maps) -- --------------------------------------------------------------------- -- On a slope map the engine rolls an idle bike one cell south on ANY -- frame with no direction held (OverworldController.lua:900-911, porting -- home/overworld.asm's forced PAD_DOWN on ROUTE_17). Held input -- -- refused or not -- is processed first and suppresses the roll, so the -- only way to stand still there is the human rider's trick: lean on the -- fence. slopeBrakeDir() picks a side direction that is blocked from the -- cell we are on (or about to land on); walk() and the U.wait wrapper -- hold it through every frame they would otherwise leave empty. Without -- it each step's settle frame rolled the bike a cell south, goto_ read -- the roll-back as a scripted shove, blacklisted the one-cell lane at -- (18,122), and the run wedged pedalling in the corner. -- (Globals, not locals: the main chunk is at LuaJIT's 200-local ceiling.) function slopeMapNow() if not (G and G.save and G.save.onBike) then return false end local o = ow() if not (o and o.map) then return false end local fmap = G.data and G.data.field and G.data.field.forcedMovement for _, m in ipairs((fmap and fmap.slopeMaps) or {}) do if m == o.map.id then return true end end return false end function slopeBrakeDir() if not slopeMapNow() then return nil end local o = ow() local p = o.player local px, py = p.cellX, p.cellY -- mid-tween the landing frame is the one that counts: brake against -- the cell we are about to occupy if p.moving and p.targetX then px, py = p.targetX, p.targetY end if not passableCell(o.map, px - 1, py) then return "left" end if not passableCell(o.map, px + 1, py) then return "right" end return nil end -- Tile-pair (elevation) collisions -- pokered's TilePairCollisionsLand / -- Water, the same data src/world/Collision.lua enforces per step. CAVERN -- and FOREST use these to fence off elevation shelves, so two adjacent -- WALKABLE cells can still be uncrossable: Victory Road 1F's (5,9) -> -- (5,8) is one, and a BFS that only checked walkability re-proposed that -- refused step for walkOntoWarp's whole budget -- "warp@1,1 did not -- move" x300 while travelTo ping-ponged ROUTE_23 <-> 1F. (Boulders are -- exempt: checkBoulderPush never consults tile pairs, matching -- push_boulder.asm, so only PLAYER movement models this.) local function pairBlockedStep(map, sx, sy, tx, ty, surfing) local tp = G.data.field and G.data.field.tilePairs local list = tp and (surfing and tp.water or tp.land) if not list or #list == 0 then return false end local ts = map.def.tileset local a, b = map:cellTile(sx, sy), map:cellTile(tx, ty) for _, p in ipairs(list) do if p.tileset == ts and ((p.a == a and p.b == b) or (p.a == b and p.b == a)) then return true end end return false end local function bfsNextKey(tx, ty, extra) local o = ow() local map, p = o.map, o.player if p.cellX == tx and p.cellY == ty then return nil end local w = map.widthCells local function id(x, y) return y * w + x end local blocked = {} if extra then -- The caller's refusal set must never contain the DESTINATION. -- walkOntoWarp's revert-blacklist once refused a warp's own cell, -- after which every re-plan said "no path" with the target ONE step -- away and nobody in the way (Silph 1F (16,11) -> (16,10)). -- Intermediate cells only; NPC blocks below still apply to the goal. local goal = id(tx, ty) for cell in pairs(extra) do if cell ~= goal then blocked[cell] = true end end end -- NPCs are walls -- but only the ones that belong to THIS map. -- -- A warp swaps map.id before the entity list is rebuilt, so a plan made -- in that window blocks cells using the PREVIOUS map's NPCs. Their -- coordinates are usually nonsense here, and `id(x, y)` folds x and y -- together, so an NPC at forest (16,43) lands on some innocent cell of an -- 8-wide gate and walls it off. That is exactly what stranded a run in -- VIRIDIAN_FOREST_NORTH_GATE: "goto (4,1) unreachable ... npcs: -- SPRITE_YOUNGSTER@(16,43) SPRITE_YOUNGSTER@(30,33)" -- forest -- coordinates listed against a gate the size of a room, every following -- segment skipping, and the attempt lost. local h = map.heightCells for _, npc in ipairs(o.npcs) do local inMap = npc.cellX >= 0 and npc.cellY >= 0 and npc.cellX < w and npc.cellY < h if inMap then blocked[id(npc.cellX, npc.cellY)] = true if npc.targetX and npc.targetX >= 0 and npc.targetY >= 0 and npc.targetX < w and npc.targetY < h then blocked[id(npc.targetX, npc.targetY)] = true end end end local prev = { [id(p.cellX, p.cellY)] = -1 } local queue, head = { id(p.cellX, p.cellY) }, 1 while head <= #queue do local cur = queue[head]; head = head + 1 local cx, cy = cur % w, math.floor(cur / w) if cx == tx and cy == ty then break end for _, d in ipairs(DIRS) do local nx, ny = cx + d[1], cy + d[2] local nid = id(nx, ny) -- Never path onto the Celadon Mart elevator entrance. There the -- elevator is optional (stairs reach every floor) and its mat is a -- bounce-trap the driver keeps stepping onto during floor navigation. -- Scoped to CELADON_MART so Silph's elevator -- a different map the -- route genuinely rides -- is untouched, and to elevator-destination -- warps so ordinary doors and stairs are untouched. The target cell -- is always allowed (never block where we are trying to go). -- Never path THROUGH any warp cell -- stepping on one warps -- (bug 10's warp parity), so a door mat on the walk line detours -- the whole goto into a building. Saffron's segment 172 kept -- diving into COPYCAT'S HOUSE exactly this way. The target cell is -- always allowed: warps we MEAN to take are goto'd directly. local elevTrap = false if not (nx == tx and ny == ty) then elevTrap = (map.warpAtCell and map:warpAtCell(nx, ny)) ~= nil end if nx >= 0 and ny >= 0 and nx < w and ny < map.heightCells and not prev[nid] and not blocked[nid] and not elevTrap and (passableCell(map, nx, ny) or (nx == tx and ny == ty)) and not pairBlockedStep(map, cx, cy, nx, ny, p.surfing) then prev[nid] = cur queue[#queue + 1] = nid else -- Not walkable -- but it may be a ledge we can hop, which lands two -- cells along and is one-way. Treated as an ordinary edge from the -- cell we are standing on; walk() already holds the direction, which -- is what makes the engine's hop fire. local lx, ly = ledgeLanding(map, cx, cy, d[3], d[1], d[2]) if lx then local lid = id(lx, ly) if not prev[lid] and not blocked[lid] then prev[lid] = cur queue[#queue + 1] = lid end end end end end local goal = id(tx, ty) if not prev[goal] then return nil end local cur = goal while prev[cur] ~= id(p.cellX, p.cellY) do cur = prev[cur] if cur == nil or cur == -1 then return nil end end local cx, cy = cur % w, math.floor(cur / w) for _, d in ipairs(DIRS) do if p.cellX + d[1] == cx and p.cellY + d[2] == cy then return d[3] end end -- The first move may be a ledge hop, which lands TWO cells along rather -- than one, so the adjacency test above finds nothing. Same direction, and -- walk() holds it exactly the same way -- the engine does the jumping. for _, d in ipairs(DIRS) do local lx, ly = ledgeLanding(map, p.cellX, p.cellY, d[3], d[1], d[2]) if lx == cx and ly == cy then return d[3] end end end -- --------------------------------------------------------------------- -- battle -- --------------------------------------------------------------------- -- A deterministic mid-roll stand-in for love.math.random. Damage.lua only -- ever calls rng(min, max) (Damage.lua:83,105,253), so probing a move with -- this consumes no game RNG and cannot desync the battle -- which calling -- battle.rng directly would. -- Wait for a screen to appear WITHOUT pressing anything. -- -- pressUntil mashes A, which is safe on a text box and disastrous on a -- bag or a party list: A there means "use this item" / "send out this mon", -- so mashing to wait for the screen acts on whatever the cursor happens to -- be sitting on. local function waitFor(pred, tries) for _ = 1, tries or 40 do if pred() then return true end U.wait(3) end return pred() end -- Set while we are driving a *voluntary* switch (training, below), so -- sendOutHealthy -- which owns the forced post-faint menu -- does not grab -- the same PartyMenu and send out slot 1 instead. local switchingForTraining = false -- The battle's replacement PartyMenu, if it is what we are looking at. -- -- It is pushed on top of the BattleState (BattleState:openReplacementMenu), -- and it carries no `.kind`, so inBattle() reads false while it is up and -- fightBattle's loop exits underneath it. `.onSwitch` is what makes it this -- menu rather than the overworld party screen. local function replacementMenu() local t = G.stack:top() if t and t.onSwitch ~= nil and t.index ~= nil and t.items == nil then return t end end -- Send out the first healthy mon. -- -- Pressing A blind does NOT work: the cursor starts on slot 1, which is the -- mon that just fainted, and PartyMenu answers "There's no will to fight!" -- and reopens (BattleState.lua:2606-2609). The first run that ever had a -- second Pokémon to switch to spent the rest of its life in that menu, -- logging "mashUntilIdle timed out" on repeat. local function sendOutHealthy() if switchingForTraining then return false end if not replacementMenu() then return false end local slot for i, mon in ipairs(G.save.party or {}) do if (mon.hp or 0) > 0 then slot = i break end end if not slot then return false end -- nothing left; the blackout owns this for _ = 1, 12 do local t = replacementMenu() if not t or t.index == slot then break end press(t.index > slot and "up" or "down") U.wait(3) end if replacementMenu() then press("a") U.wait(8) end return true end -- --------------------------------------------------------------------- -- training the second Pokémon -- --------------------------------------------------------------------- -- -- Gen 1 gives exp only to mons that were actually SENT OUT -- (BattleState:markParticipant / the participant split in enemyMonFainted), -- so the Nidoran we go out of our way to catch on Route 22 earns nothing at -- all while it sits in slot 2. The route would then reach Mt. Moon and put a -- Moon Stone into a level 4 Nidoking, wasting every TM aimed at it later -- (thrash, thunderbolt, horn drill, rock slide, ice beam). -- -- Sending it out and switching straight back earns it a full share without it -- ever having to survive a fight. The cost is the enemy's free move on each -- switch, so it only runs while the trainee is healthy enough to take one and -- only on the first menu of a battle, never mid-fight. local TRAINEE_MIN_HP = 0.6 -- How far above the trainee a wild foe may be before switching the -- trainee in is a faint rather than a share of the exp. The switch costs -- it one free enemy attack, so the gap is a survivability bound, not a -- preference: past it the trainee dies, leaves the rotation (traineeSlot -- wants hp > 0) and never trains again. Eight covers the levels the route -- actually intends -- a NIDORAN caught at 3-4 on ROUTE_22 training through -- Mt. Moon's level 8-12 wilds -- without feeding it to Route 9. local TRAINEE_MAX_GAP = 8 -- The party slot we are levelling: the healthy member furthest behind the -- lead. nil when there is nobody worth training. local function traineeSlot() local roster = G.save.party or {} local lead = roster[1] if not lead or #roster < 2 then return nil end local best, bestLevel for i = 2, #roster do local mon = roster[i] local max = mon.stats and mon.stats.hp or 0 if (mon.hp or 0) > 0 and max > 0 and (mon.hp / max) >= TRAINEE_MIN_HP and (mon.level or 0) < (lead.level or 0) and (not bestLevel or (mon.level or 0) < bestLevel) then best, bestLevel = i, mon.level or 0 end end return best end local function midRng(a, b) return math.floor((a + b) / 2) end -- Rank the active mon's moves by expected damage against the current -- opponent using the engine's own damage math, so type effectiveness, -- STAB and the ruleset all come from the game rather than a ported table. -- The move that takes the enemy DOWN without killing it, for catching. -- -- Gen 1's catch formula scales with missing HP, so throwing at full health -- is close to the worst odds available. It is fine for the route's NIDORAN -- (catch rate 235), which is why the original threw immediately, but ODDISH -- is catch rate 45 -- six balls went into one at full HP and it still broke -- out every time. -- -- Returns the move doing the most damage that still leaves the target -- alive, with a margin, or nil when every move would KO (a level-20 lead -- against a level-13 wild mon frequently has no safe option at all) -- in -- which case the caller throws at whatever HP it has rather than killing -- the thing it came for. local CATCH_HP_MARGIN = 0.10 -- of max HP, kept in reserve under any hit local function weakenMoveIndex(battle) local enemy = battle.enemy and battle.enemy.mon if not enemy then return nil end local maxHP = math.max(1, enemy.stats and enemy.stats.hp or 1) local floorHP = math.max(1, math.floor(maxHP * CATCH_HP_MARGIN)) local headroom = (enemy.hp or 0) - floorHP if headroom <= 0 then return nil end -- already as low as we dare local bestIdx, bestDmg = nil, -1 for i, mv in ipairs(battle.player.curMoves) do if mv.pp > 0 and battle.player.disabledSlot ~= i then local def = battle:moveDef(mv) if def then local probe = setmetatable({ id = mv.id }, { __index = def }) local ok, dmg, info = pcall(function() return battle:computeDamage(battle.player, battle.enemy, probe, { rng = midRng }) end) -- Only moves that compute a real number are candidates: a status -- move does no damage and would spin the turn without progress. if ok and type(dmg) == "number" and dmg > 0 and not (info and info.typeMult == 0) then -- midRng is the MID roll; the real hit can roll higher and crit, -- so leave the margin rather than aiming at exactly 1 HP. if dmg <= headroom and dmg > bestDmg then bestIdx, bestDmg = i, dmg end end end end end return bestIdx end local function bestMoveIndex(battle) local moves = battle.player.curMoves local bestIdx, bestDmg = nil, -1 for i, mv in ipairs(moves) do if mv.pp > 0 and battle.player.disabledSlot ~= i then local def = battle:moveDef(mv) if def then -- guarantee .id/.highCrit reach Damage.critRoll even if the merged -- record does not carry them local probe = setmetatable({ id = mv.id }, { __index = def }) local ok, dmg, info = pcall(function() return battle:computeDamage(battle.player, battle.enemy, probe, { rng = midRng }) end) if ok and type(dmg) == "number" then if info and info.typeMult == 0 then dmg = -1 end -- immune if dmg > bestDmg then bestIdx, bestDmg = i, dmg end elseif bestIdx == nil then bestIdx, bestDmg = i, 0 -- status move / uncomputable: still usable end end end end return bestIdx or 1 end -- Move the 2x2 action cursor to `want` (1=fight 2=pkmn 3=item 4=run). -- Layout is index = row*2 + col + 1 (BattleState.lua:1040-1047); left and -- right both toggle the column, up and down both toggle the row. local function battleMenuTo(battle, want) for _ = 1, 4 do if battle.menuIndex == want then return true end local col = (battle.menuIndex - 1) % 2 local wantCol = (want - 1) % 2 if col ~= wantCol then press("left") else press("up") end U.wait(2) end return battle.menuIndex == want end -- Switch to party `slot` from the battle action menu (PKMN is index 2). -- -- Flags the PartyMenu it opens as ours, so sendOutHealthy leaves it alone -- -- otherwise the forced-replacement handler would see a party menu and send -- out slot 1, which is the mon we are trying to switch away from. local function switchTo(battle, slot) switchingForTraining = true local ok = false repeat if not battleMenuTo(battle, 2) then break end press("a") if not waitFor(replacementMenu, 30) then break end for _ = 1, 12 do local t = replacementMenu() if not t or t.index == slot then break end press(t.index > slot and "up" or "down") U.wait(3) end local t = replacementMenu() if not t or t.index ~= slot then break end press("a") -- select mon -> SWITCH / STATS / CANCEL U.wait(8) t = replacementMenu() if not (t and t.submenu) then break end -- SWITCH is first (SwitchStatsCancelText) press("a") U.wait(10) ok = true until true switchingForTraining = false if not ok then -- back out of whatever we left open for _ = 1, 6 do if not replacementMenu() then break end press("b") U.wait(4) end end return ok end -- Below this fraction of max HP we would rather leave than trade turns. -- -- Two thresholds, because a flat one is wrong in both directions. Fleeing -- forfeits the XP, so a cautious bot arrives everywhere underlevelled, -- takes proportionally more damage and flees even more -- but dropping the -- threshold outright is worse: a lone level-5 starter on Route 1 has no -- centre and no items, and fighting on simply kills it (tried it, the run -- died at segment 6 instead of 22). -- -- So: fight further down only when something can actually put the HP back. local LOW_HP_EXPOSED = 0.35 -- nothing can restore HP; protect the run local LOW_HP_COVERED = 0.2 -- a nurse or a potion is within reach -- assigned below, once the healing helpers it needs exist local canRestoreHP = function() return false end -- Forward-declared: defined with the travel helpers, needed by stepUpTo. local reachableSet -- Forward-declared: defined with the party helpers; crossSeam and the -- surf-mount logic need it before its definition point. local slotKnowing -- Forward-declared: assigned after MANUAL.playPokeFlute exists. Wakes a -- SNORLAX that is blocking the current map when the POKE FLUTE is aboard. local wakeBlockingSnorlax -- true while deliberately farming encounters: fleeing earns no XP, which is -- the whole point of grinding, so the flee guard stands down local grinding = false -- Drink a potion during a battle. Defined further down, once the bag -- helpers (HP_ITEMS, heldCount, party) it needs exist; fightBattle -- calls it and comes first. local IN_BATTLE_HEAL_AT = 0.35 local healInBattle -- Throw the POKE_DOLL from the battle ITEM menu. Defined near -- healInBattle (same forward-declaration dance); fightBattle needs it for -- the static ghost MAROWAK, where fleeing leaves the blocker standing. local throwPokeDoll -- Drive a MoveLearnMenu HM-safely wherever one appears. LEVEL-UP learns -- open the same menu ops.teach handles, but they surface mid-battle and -- in mashUntilIdle -- whose blind A lands on row 1, which after Silph is -- SURF, and the engine's HM refusal loops forever. Defined after the -- cursor helpers; forward-declared for fightBattle/mashUntilIdle. local driveLearnMenu local function lowOnHP(battle) if grinding then return false end -- training: take the XP, not the exit local mon = battle.player and battle.player.mon local max = mon and mon.stats and mon.stats.hp if not max or max <= 0 then return false end -- Stay in the fight only where we can both afford to and have not been -- killed before: a map with a death on record gets the cautious bar even -- when the bag is full, because the record says our estimate was wrong -- here at least once. local covered = canRestoreHP() and dangerAt(ow().map.id) == 0 return mon.hp / max < (covered and LOW_HP_COVERED or LOW_HP_EXPOSED) end -- the battle state seen hung on the last fightBattle timeout, for the -- engine-hang bailout at the bottom of fightBattle local hungBattle -- Body on WD (the chunk is at LuaJIT's 200-local ceiling); the local -- wrapper below adds the re-entrancy accounting. function WD.fightBattleBody(limit) limit = limit or 20000 local frames = 0 -- TryRunningFromBattle can fail on a speed check, and each failure costs -- the turn, so cap the attempts and fight on rather than be cornered -- burning turns we cannot afford. local runAttempts = 0 -- Set by the critical-switch branch below to the bench slot to bring in. local battleSwitchSlot -- Emergency bench-switches this battle. ONE: it is a last resort to break -- an unhealable stall (Agatha's Gengar), not a tactic. Unbounded, it -- looped -- "switching in slot 2" x10 in a Mt. Moon trainer fight tripped -- the watchdog when the switched-in mon was itself too weak to change the -- outcome. Capped so a fight that cannot be swung this way falls through -- to attacking (and, if it must, a clean blackout the recovery retries). local switchesLeft = 1 -- Potions drunk this battle. Bounded so a fight we are losing anyway -- cannot drain the bag one turn at a time. local healsLeft = 3 -- Switch the trainee in for its exp share, then straight back out. Only -- while grinding is off (grindALevel wants the trainee to actually fight) -- and only when there is somebody behind the lead worth levelling. -- -- WILD battles only. Sharing splits the exp, so training through the -- trainer fights left the lead three levels down by Pewter and it died to -- Brock seven times in one run. Wild encounters are frequent and cheap; -- the fights that decide the run are not the place to hand away half the -- exp and two free enemy moves. local trainingPhase = "done" if not grinding and G.stack:top() and G.stack:top().kind == "wild" and not G.stack:top().ghost and not G.stack:top().safari and traineeSlot() then trainingPhase = "in" end -- The replacement menu sits ON TOP of the battle and has no `.kind`, so -- inBattle() alone would end the loop the moment our lead faints and hand -- a party menu to mashUntilIdle, which only knows how to press A. while (inBattle() or replacementMenu()) and frames < limit do if sendOutHealthy() then frames = frames + 10 goto continue end local battle = G.stack:top() local phase = battle.phase -- Watch for the wipe here rather than after the battle: blacking out -- revives the party at the heal point, so a check made once the screen -- has popped always sees a healthy party and misses it entirely. if not blackedOut then local roster = G.save.party or {} local alive = false for _, mon in ipairs(roster) do if (mon.hp or 0) > 0 then alive = true break end end if #roster > 0 and not alive then blackedOut = true end end if phase == "menu" then if battle.safari then -- Safari battles have no FIGHT: the grid is BALL/BAIT/ROCK/RUN -- and the route catches nothing in the zone -- it is a corridor -- to the GOLD TEETH and HM03. RUN always succeeds in the Safari -- Zone, and every other choice spends a ball or a turn. if battleMenuTo(battle, 4) then press("a") end elseif battle.player.mon.hp <= 0 then -- our lead is down but the replacement menu has not opened yet; -- A brings it up, and sendOutHealthy above drives it once it does press("a") elseif battle.ghost then -- Pokémon Tower without the SILPH_SCOPE: FIGHT answers "too -- scared to move!", balls are dodged, and the ghost never goes -- down -- the battle cannot be won, only left. tryRun's -- IsGhostBattle branch always escapes, so RUN is guaranteed. -- -- Except the static MAROWAK at (10,16): fleeing that one leaves -- it blocking the stairs and the trigger re-fires forever. The -- POKE_DOLL ends it for good (the wBattleResult trick, see -- data/scripts/story3.lua) -- and no WILD tower encounter is a -- MAROWAK, so the species is a safe discriminator. local foe = battle.enemy and battle.enemy.mon if foe and tostring(foe.species) == "MAROWAK" and ((G.save.inventory or {}).POKE_DOLL or 0) > 0 and throwPokeDoll and throwPokeDoll(battle) then frames = frames + 10 elseif battleMenuTo(battle, 4) then press("a") end -- First menu of the battle: send the trainee out purely to be -- marked a participant, then hand the fight straight back. -- -- Only when it can survive being there. Switching in costs the -- trainee one free enemy attack, and against a foe far above it -- that is simply a faint -- after which traineeSlot's `hp > 0` -- drops it from the rotation for good, until some nurse happens to -- revive it. That is how NIDORAN_M sat at level 4 for an entire -- run while the lead reached 33: it was fed to the first big wild -- mon it met and never trained again. Being a level behind is the -- point of a trainee; being thirty behind means the exp share is -- worth less than the body. local slot = traineeSlot() local foe = battle.enemy and battle.enemy.mon local mon = slot and (G.save.party or {})[slot] local gap = (foe and mon) and ((foe.level or 0) - (mon.level or 0)) or 0 if slot and gap > TRAINEE_MAX_GAP then slot = nil -- too dangerous to be worth the share end trainingPhase = (slot and switchTo(battle, slot)) and "out" or "done" elseif trainingPhase == "out" then -- Leave the trainee IN to actually fight when the encounter is weak -- enough to be safe. A share of the exp levels it far too slowly -- -- Nidoran was still trailing badly by Cerulean -- and the route -- needs a real Nidoking, not a passenger. Taking the kill is worth -- several shares, so the rule is "fight anything at or below your -- own level while healthy, hand back anything above it". local me = battle.player and battle.player.mon local foe = battle.enemy and battle.enemy.mon local maxHP = me and me.stats and me.stats.hp or 0 local safe = me and foe and maxHP > 0 and (foe.level or 99) <= (me.level or 0) + 1 and (me.hp or 0) / maxHP >= 0.5 local lead = (G.save.party or {})[1] if not safe and lead and (lead.hp or 0) > 0 then switchTo(battle, 1) end trainingPhase = "done" elseif battle.kind ~= "wild" and healsLeft > 0 and (battle.player.mon.hp or 0) / math.max(1, battle.player.mon.stats.hp or 1) < IN_BATTLE_HEAL_AT and healInBattle(battle, "battle") then -- A trainer cannot be fled (tryRun refuses), so the only way to -- survive a gauntlet is to drink. Capped per battle so a losing -- fight empties the turn counter rather than the whole bag. healsLeft = healsLeft - 1 elseif battle.kind ~= "wild" and switchesLeft > 0 and (battle.player.mon.hp or 0) / math.max(1, battle.player.mon.stats.hp or 1) < 0.18 and (function() -- the lead is critical AND unhealable (no items or the -- cap is spent). Agatha's Gengar is a Hypnosis/PP -- staller: our lead sat at 2/202 HP asleep, out of -- FULL_RESTOREs, trading nothing, while NIDOKING and -- ODDISH stood healthy on the bench -- an infinite -- stall that timed out forever and never resolved. -- Switching in a live body forces the fight to a real -- outcome: it acts, or it faints toward a clean -- blackout the recovery machinery can retry. Only when -- a genuinely healthier mon exists. local active = battle.player.mon local roster = G.save.party or {} for si, m in ipairs(roster) do if m ~= active and (m.hp or 0) > 0 and m.stats and (m.hp / math.max(1, m.stats.hp)) > 0.6 then battleSwitchSlot = si return true end end return false end)() then say(("lead is critical and out of heals -- switching in slot %d") :format(battleSwitchSlot)) switchesLeft = switchesLeft - 1 switchTo(battle, battleSwitchSlot) elseif battle.kind == "wild" and runAttempts < 4 and lowOnHP(battle) then -- RUN is index 4 of the 2x2 grid; trainers refuse (tryRun), so -- this only ever fires on wild encounters runAttempts = runAttempts + 1 if battleMenuTo(battle, 4) then press("a") end elseif battleMenuTo(battle, 1) then press("a") end elseif phase == "moveSelect" then local want = bestMoveIndex(battle) for _ = 1, 8 do if battle.moveIndex == want then break end press(battle.moveIndex > want and "up" or "down") U.wait(2) frames = frames + 2 end press("a") else -- intro / messages / anything else: advance -- but never mash A -- blind through a level-up MoveLearnMenu (the cursor sits on SURF) if not (driveLearnMenu and driveLearnMenu()) then press("a") end end U.wait(3) frames = frames + 4 ::continue:: end if frames >= limit then say("battle timed out") -- An engine hang: the battle stops responding to ANY input (seen -- live vs Agatha -- GOLBAT sent out, an empty text frame, A/B dead, -- while a clean A-mash of the same fight never reproduces it). -- Every caller retries fightBattle, so a hung battle livelocks the -- whole run: ops.goto_ alone re-enters it up to 600 times. On the -- SECOND consecutive timeout of the SAME battle state, force the -- state off the stack and let the overworld machinery re-engage -- from scratch -- an unbeaten trainer is simply re-fought with -- fresh RNG, a wild encounter is gone. Crude, but the alternative -- is a dead run, and the route is built to recover from anything -- the map throws at it. if inBattle() then local cur = G.stack:top() -- NEVER force-close a TRAINER battle. Popping an unbeaten trainer -- leaves their defeat flag unset and their exit door sealed with no -- recovery -- that is exactly how Agatha walled off the Champion and -- the Hall of Fame at a completed 197/197 route. A trainer's only -- honest outcomes are win or blackout (both handled), so hand the -- fight back to the caller to re-enter with a fresh frame budget and -- grind it down. Force-close stays only for WILD/ghost hangs, where -- the encounter simply vanishes with no lasting state. local trainer = cur.kind ~= "wild" and not cur.ghost and not cur.safari if cur == hungBattle and not trainer then local foe = cur.enemy and cur.enemy.mon say(("battle engine hang (phase=%s kind=%s foe=%s) -- forcing the " .. "battle closed"):format(tostring(cur.phase), tostring(cur.kind), tostring(foe and foe.species))) hungBattle = nil G.stack:pop() U.wait(20) elseif trainer then -- Nudge a possibly-stuck prompt and let the caller re-enter; -- keep the battle on the stack no matter what. local foe = cur.enemy and cur.enemy.mon say(("trainer battle slow (phase=%s foe=%s) -- re-entering, not " .. "closing"):format(tostring(cur.phase), tostring(foe and foe.species))) press("a") U.wait(6) else hungBattle = cur end end else hungBattle = nil end end -- Re-entrancy accounting around the body. healInBattle and throwPokeDoll -- run INSIDE fightBattle and call mashUntilIdle, whose battle safety net -- used to re-enter fightBattle recursively -- fresh frame budget, fresh -- heal allowance, and the shared hungBattle bookkeeping cross-firing -- between nesting levels until it force-popped a battle that was still -- being fought. That is the "battle engine hang" that left Agatha -- undefeated with her door sealed; the engine itself was fine. local function fightBattle(limit) WD.fighting = (WD.fighting or 0) + 1 local ok, res = pcall(WD.fightBattleBody, limit) WD.fighting = WD.fighting - 1 if not ok then error(res, 0) end return res end -- --------------------------------------------------------------------- -- shared waits -- --------------------------------------------------------------------- -- Mash A until the overworld is idle again, fighting any battle that opens. local function mashUntilIdle(limit) limit = limit or 6000 local frames = 0 while frames < limit do -- Never press A blind at a replacement menu: the cursor is on the mon -- that just fainted, so A re-picks it and the menu reopens forever. if replacementMenu() then sendOutHealthy() end -- ...and never RE-ENTER a fight this mash is already running inside -- of (healInBattle/throwPokeDoll come through here mid-battle); the -- plain A press below drains the turn's text just fine. if inBattle() and (WD.fighting or 0) == 0 then fightBattle() end if idle() then return true end if driveLearnMenu and driveLearnMenu() then frames = frames + 10 else press("a") U.wait(6) frames = frames + 7 end end -- A alone cannot close everything: a wedged START/party/submenu chain -- cycles forever under A (seen as 25 consecutive timeouts during the -- Saffron bush-cutting spree). Unwind with B before reporting failure -- -- B is safe everywhere mashUntilIdle is allowed (shops are B-only and -- never come through here). for _ = 1, 12 do if idle() then return true end press("b") U.wait(6) end say("mashUntilIdle timed out") return false end -- Turn without stepping: hold just long enough for the turn to register, -- then release before tryMove commits to a move. local function faceDir(dir) if not dir then return end for _ = 1, 4 do if ow().player.facing == dir then return end table.insert(G.input.pressQueue, dir) G.input.state[dir] = true coroutine.yield() G.input.state[dir] = false U.wait(3) end end -- Map lookups. Defined up here because both the shop and the Poké Center -- helpers need them, and the shop comes first. local function findWarpTo(pattern) for _, w in ipairs(ow().map.def.warps or {}) do if type(w.destMap) == "string" and w.destMap:find(pattern) then return w end end end local function findObject(pattern) for _, o in ipairs(ow().map.def.objects or {}) do if type(o.name) == "string" and o.name:find(pattern) then return o end end end -- --------------------------------------------------------------------- -- ops -- --------------------------------------------------------------------- local ops = {} -- Assigned further down, once the bag/nurse helpers they need exist. -- Declared here so ops.battle can top up BEFORE picking a fight. local autoHeal, riskThreshold -- The map the NEXT segment expects, set by the segment loop. DIG and FLY -- land wherever the save's heal point says rather than where the route -- assumed, so the ops that use them need to know what they were aiming at. local nextMapWanted local travelDumped = false -- Cut a tree that is walling us off from (tx, ty). Assigned below, once -- ops.fieldMove exists to do the cutting; declared here because ops.goto_ -- calls it and comes first. local cutToward -- Open a Silph Co card-key door that is walling us off from (tx, ty). -- Assigned next to cutToward; declared here for the same reason. local openDoorToward -- Press a reachable Pokémon Mansion statue switch when the switch walls -- are what seals off (tx, ty). Assigned next to openDoorToward. local toggleMansionSwitch -- Bag-driven field item use / step-out-of-a-building helpers. Defined -- with the item helpers far below; declared here because ops.fieldMove's -- DIG fallback escape-ropes out of the Mansion. local useFieldItem, leaveByDoor -- Shove a STRENGTH boulder out of the walk to (tx, ty). Assigned next to -- toggleMansionSwitch; declared here because ops.goto_'s ladder calls it. local pushBoulderToward -- Victory Road boulder-switch solver + its switch table; declared here -- because walkOntoWarp's no-path ladder calls them. local VR_SWITCHES, solveVictoryRoadSwitches -- Mount SURF when land runs out. Defined after ops.fieldMove; used by -- ops.goto_'s unreachable ladder so the Pallet -> Route 21 -> Cinnabar -- water corridor is travellable (BFS is already water-aware WHILE -- surfing -- the mount was the missing action). local mountSurfToward -- Reach a warp cell by ARRIVING through it. Assigned below (it needs -- travelTo); declared here for the same reason as cutToward. local arriveByWarp -- pickupBlockingBall (defined below, next to reachableSet) is a GLOBAL, -- like cutToward: the main chunk is at LuaJIT's 200-local ceiling. -- Cross to another region of the same map through a gate. Assigned below -- (needs walkOntoWarp); declared here because ops.goto_ calls it. local reEnterThroughGate -- The border cell of `dir`'s edge that is WATER, nearest to (nearX, -- nearY). Some seams are only crossable by sea: Cinnabar's north border -- is land at x 10-13 with ocean beyond, so pushing off it just bumps the -- shore -- the crossing is the water strip at x 1-3. local function borderWaterCell(m, dir, nearX, nearY) local best, bestD if dir == "up" or dir == "down" then local y = dir == "up" and 0 or m.heightCells - 1 for x = 0, m.widthCells - 1 do if m:isWaterCell(x, y) then local d = math.abs(x - nearX) if not bestD or d < bestD then best, bestD = { x, y }, d end end end else local x = dir == "left" and 0 or m.widthCells - 1 for y = 0, m.heightCells - 1 do if m:isWaterCell(x, y) then local d = math.abs(y - nearY) if not bestD or d < bestD then best, bestD = { x, y }, d end end end end return best end -- Walk to (x, y). Coordinates outside the map are the route's idiom for -- "leave through this edge into the connecting map", so we walk into the -- edge and let the connection carry us rather than pathfinding to a cell -- that does not exist. function ops.goto_(s, _where, isLast) local o = ow() local map = o.map -- surf mounts per goto, BUDGETED: walking ashore dismounts, so an -- unreachable target beyond a pond can mount -> cross -> dismount -> -- fail -> mount forever (Celadon's pond, target (39,8)) -- but a hard -- cap of ONE stranded the bot on Route 20's mid-island strip, where a -- legitimate crossing is surf -> beach -> surf again. Three mounts -- covers every real island-hop; a still-unreachable target then fails -- the goto as before. local surfMounts, SURF_BUDGET = 0, 3 -- ...and a small cut budget per goto: a pocket behind two trees opens -- over two passes, but a target that stays unreachable must not -- chain-cut every ornamental bush on the map (Saffron/Celadon cut-spam) local cuts, CUT_BUDGET = 0, 3 -- one mansion-switch toggle per goto (see toggleMansionSwitch) local switchToggles = 0 -- boulder shoves per goto: a push chain along a row is several calls -- (shove, re-plan, shove), so this is roomier than the cut budget local boulderPushes = 0 local outside = s.x < 0 or s.y < 0 or s.x >= map.widthCells or s.y >= map.heightCells if outside then local dir = (s.y < 0 and "up") or (s.y >= map.heightCells and "down") or (s.x < 0 and "left") or "right" local startMap = map.id -- Get to the border cell the route is pointing through FIRST. -- -- Holding the direction from wherever we happen to be standing only -- works if the way out is already in front of us. With a wall in -- between it walks into that wall once per iteration -- 200 identical -- bumps before giving up, which is exactly what "Red walking into the -- same wall over and over and then recovering" looks like on screen. local tx = math.max(0, math.min(s.x, map.widthCells - 1)) local ty = math.max(0, math.min(s.y, map.heightCells - 1)) local refusedEdge = knownWalls(map.id) or {} -- one border-cell retarget per goto (see the fenced-lane rung below) local edgeRetargeted = false for edgeTry = 1, 2 do for _ = 1, 300 do if ow().map.id ~= startMap then return true end local p = ow().player if p.cellX == tx and p.cellY == ty and not p.moving then break end if inBattle() then fightBattle() elseif busy() then mashUntilIdle() else local key = bfsNextKey(tx, ty, refusedEdge) if not key then -- No path to the border cell -- and on the way OUT of a map that -- is very often a Cut tree that has grown back. Gen 1 restores -- them on every map re-entry, so a tree the route cut on the way -- in is solid again on the way back: ROUTE_16 cuts one at segment -- 101, and returning east to CELADON_CITY then reported -- "ROUTE_16 -> CELADON_CITY did not cross" four times over, with -- the bot trapped in the western pocket and travelTo concluding -- "no way there from ROUTE_16". -- -- cutToward already handles this for ordinary waypoints; the -- edge-exit branch is a separate path and never called it. local p2 = ow().player if p2.cellX ~= tx or p2.cellY ~= ty then if cuts < CUT_BUDGET and cutToward(tx, ty) then cuts = cuts + 1 goto edgeStep -- re-plan now that the way is open end -- The border can also be walled off by a gate rather than a -- tree: ROUTE_16's east seam sits beyond the gate building, so -- leaving toward CELADON_CITY means going through it first. if reEnterThroughGate(tx, ty) then goto edgeStep end -- ...or the border cell is on WATER (Pallet's south edge): -- mount SURF at the shore and re-plan over the sea. if surfMounts < SURF_BUDGET and mountSurfToward and mountSurfToward(tx, ty) then surfMounts = surfMounts + 1 goto edgeStep end -- ...or a sleeping SNORLAX is the wall. ROUTE_12's lies at -- (10,62), squarely in the seam band shared with ROUTE_11 and -- the corridor toward LAVENDER_TOWN, so the whole east-loop -- walk home dead-ended on it while cutToward shaved unrelated -- bushes ("used CUT on ROUTE_12" x10). The waypoint branch -- and walkOntoWarp both have this rung; the border-exit -- branch was the one path without it. if wakeBlockingSnorlax and wakeBlockingSnorlax("edge exit") then goto edgeStep end -- ...or THIS border column is fenced off while another of -- the same edge is open. Viridian's south border is three -- fenced lanes and only x20-21 connects to the city; the -- planner's seam cell (15,35) is statically walkable-paired -- but dynamically sealed, so the exit spun cutting -- ornamental bushes and surfing the pond. Retarget ONCE to -- the nearest border cell that is BOTH reachable right now -- and has a walkable far side. if not edgeRetargeted then local compass = (dir == "up" and "north") or (dir == "down" and "south") or (dir == "left" and "west") or "east" local conn = map.def.connections and map.def.connections[compass] if conn and seamCells and reachableSet then local seen, WW = reachableSet() local bestAlt, bestD for _, c in ipairs(seamCells(map.def, compass, conn)) do if seen[c[2] * WW + c[1]] and (c[1] ~= tx or c[2] ~= ty) then local d2 = math.abs(c[1] - tx) + math.abs(c[2] - ty) if not bestD or d2 < bestD then bestAlt, bestD = c, d2 end end end if bestAlt then say(("edge exit %s on %s: border cell (%d,%d) is walled " .. "off; crossing at (%d,%d) instead"):format(dir, tostring(map.id), tx, ty, bestAlt[1], bestAlt[2])) tx, ty = bestAlt[1], bestAlt[2] edgeRetargeted = true goto edgeStep end end end end break -- already there, or genuinely no path end if not walk(key) then local dv = DELTA[key] if dv then local cell = (p.cellY + dv[2]) * map.widthCells + (p.cellX + dv[1]) refusedEdge[cell] = true noteWall(map.id, cell) end end end ::edgeStep:: end -- Now push across the seam, but stop as soon as it is clearly not -- happening rather than hammering the border for a fixed 200 frames. local stalls = 0 for _ = 1, 80 do if ow().map.id ~= startMap then return true end if inBattle() then fightBattle() elseif busy() then mashUntilIdle() else local p = ow().player local bx, by = p.cellX, p.cellY faceDir(dir) walk(dir) local q = ow().player if q.cellX == bx and q.cellY == by then stalls = stalls + 1 if stalls >= 6 then break end else stalls = 0 end end end -- The push stalled. Once per goto: if this edge has a WATER crossing -- and the party can surf, mount and retry the whole exit over the -- sea column instead of bumping the shore (Cinnabar -> Route 21). if edgeTry == 1 and surfMounts < SURF_BUDGET and not ow().player.surfing and slotKnowing("SURF") then local wc = borderWaterCell(map, dir, tx, ty) if wc and mountSurfToward and mountSurfToward(wc[1], wc[2]) then surfMounts = surfMounts + 1 tx, ty = wc[1], wc[2] say(("edge exit %s on %s: retrying over the water at (%d,%d)") :format(dir, tostring(startMap), tx, ty)) else break end else break end end -- edgeTry say(("edge exit failed on %s: %s from (%d,%d) toward (%d,%d)") :format(tostring(startMap), dir, ow().player.cellX, ow().player.cellY, s.x, s.y)) return false end local blocked = 0 -- Cells the engine has refused this trip, seeded with the ones earlier -- runs already learned about on this map, so we plan around a known bad -- tile instead of rediscovering it by walking into it. local refused = knownWalls(map.id) or {} -- How often we have stood on each cell while pathing. A gate script that -- shoves the player back (the locked Viridian Gym at (32,8), Pewter's -- youngster, the Route 22 sign) defeats the `blocked` counter below: the -- step genuinely succeeds, so walk() returns true and blocked resets -- -- then the script walks us straight back and we oscillate for the full -- 600 iterations. Bail once a cell has come round too many times. local visits, oscillating = {}, 12 -- Which cell each shove came from, so the plan can route AROUND the -- trigger instead of merely giving up. A shove is a step that SUCCEEDS -- and is then scripted back (Cinnabar's locked gym door at (18,4): -- standing there without the SECRET_KEY prints "The door is locked..." -- and walks you back down, and BFS -- deterministic, ties broken "up" -- first -- re-proposed that exact step forever from (18,5)). Two -- reverts on the same step target make it a refused cell for this trip; -- per-call only, because the same cell is legitimately walkable later -- (with the key in the bag the gate does not fire). local reverts, lastFrom, lastTarget = {}, nil, nil -- Whether the last successful step landed exactly ON the waypoint. A -- SAME-MAP teleporter (Saffron Gym's warp pads) fires the moment that -- step lands and moves us across the map, so the map-change guard never -- returns and the loop hunts a waypoint it has already satisfied from -- the far pocket -- which is how segment 173 wandered the gym, fought -- two side trainers, and left without ever reaching Sabrina. A jump of -- more than two cells distinguishes a pad from a gate-script shove -- (which walks us back exactly one). local steppedOnTarget = false for _ = 1, 600 do -- Stop the instant a warp changed the map under us. -- -- A waypoint can sit past a warp the route walks THROUGH rather than -- onto: VERMILION_DOCK's boarding warp is at (14,2) and segment 62 is -- `goto (14,0)` then `goto (14,3)`, so walking down to (14,3) steps onto -- (14,2) and boards the S.S. Anne mid-walk. Without this guard the goto -- kept chasing (14,3) -- now a cell ON THE SHIP -- and pathed from the -- arrival tile (27,0) straight into (26,0), the ship's OWN warp back to -- the dock, bouncing off. Every following segment then ran on the wrong -- map: the whole ship skipped, HM01 CUT was never collected, and the -- run died 15 segments later at the uncuttable Route 9 tree. The -- edge-exit branch already returns on a map change; the in-bounds loop -- must too, and the segment loop re-syncs to wherever the warp left us. if ow().map.id ~= map.id then return true end local p = ow().player if steppedOnTarget then local jump = math.abs(p.cellX - s.x) + math.abs(p.cellY - s.y) if jump > 2 then say(("goto (%d,%d): stepped on it and a pad moved us to (%d,%d) -- " .. "waypoint done"):format(s.x, s.y, p.cellX, p.cellY)) return true end end if p.cellX == s.x and p.cellY == s.y then -- Arrived. If the tween into the cell is still running, wait it out -- rather than falling through to BFS -- bfsNextKey returns nil when -- start == goal, which the branch below would misread as "no path" -- and report as unreachable while we are standing on the target. if not p.moving then -- Landing on a warp cell that does not take us anywhere. -- -- Warp.onArrive only fires for a tile in the tileset's warpTiles; -- everything else needs the direction still HELD while facing the -- map edge (Warp.onCollision + extraCheck), and walk() releases the -- key the moment the cell changes. Either way the segment then ends -- on the right tile, on the wrong map, and every following segment -- skips -- Mt. Moon B1F's exit at (27,3) is exactly this. -- -- ONLY on a segment's last step. A segment's FIRST waypoint is -- routinely the warp we just arrived through -- Red's house -- segment 2 opens on (7,1), the stairs it just came down -- and -- shoving there rides them straight back up. That cost a whole -- attempt at segment 2 the first time this went in. local warped = isLast and map:warpAtCell(s.x, s.y) if warped then local from = ow().map.id local pushed = false for _ = 1, 8 do if ow().map.id ~= from then return true end local m = ow().map -- A warp needing a held press: mirror the engine's -- Warp.extraCheck -- facing the map edge, OR a warp-carpet -- door tile in front (Route 16's gate mats: plain path -- tiles whose warp fires only pressing INTO the building). -- A true interior door fires on its own; no direction -- passes extraCheck there and we stop pushing. local WarpMod = require("src.world.Warp") local carpets = G.data.field and G.data.field.warpCarpets local outward for _, dd in ipairs(DIRS) do if WarpMod.extraCheck(m, carpets, s.x, s.y, dd[3]) then outward = dd[3] break end end if not outward then break end pushed = true faceDir(outward) walk(outward, 8) end -- Only worth saying when we actually tried and it still refused. -- Interior doors reach here every time (they fire a moment later, -- during the next segment's map-wait), and warning about those -- just cries wolf -- two showed up in the first ten segments. if pushed and ow().map.id == from then local dest = (warped.def and warped.def.destMap) or warped.destMap say(("goto (%d,%d): stood on a border warp to %s on %s and it " .. "did not fire"):format(s.x, s.y, tostring(dest), from)) end end return true end U.wait(2) elseif inBattle() then fightBattle() elseif busy() then mashUntilIdle() else -- Count only the iterations where we actually choose a step from this -- cell -- not frames spent in a battle or waiting out a tween, which -- legitimately sit on one cell for a long time. local cell = p.cellY * ow().map.widthCells + p.cellX -- Back where the last successful step started: that step was shoved. -- EXCEPT on Cycling Road, where the hill (not a gate script) rolls -- the bike a cell south on any empty-input frame: blacklisting the -- cell above for that severed the one-cell lane at ROUTE_17 -- (18,122) and wedged the run in the corner. A roll-back is exactly -- "we are one row BELOW the cell we had reached"; keep retrying it, -- the slopeBrakeDir holds should stop the next one. if lastTarget and cell == lastFrom then local W = ow().map.widthCells local rolledBack = slopeMapNow() and lastFrom == lastTarget + W if not rolledBack then reverts[lastTarget] = (reverts[lastTarget] or 0) + 1 if reverts[lastTarget] >= 2 and not refused[lastTarget] then say(("goto (%d,%d): the step onto (%d,%d) keeps getting shoved " .. "back -- planning around it") :format(s.x, s.y, lastTarget % W, math.floor(lastTarget / W))) refused[lastTarget] = true visits = {} -- the new plan deserves a fresh oscillation budget end end end visits[cell] = (visits[cell] or 0) + 1 if visits[cell] > oscillating then say(("goto (%d,%d) gave up on %s: pathed away from (%d,%d) %d times " .. "-- a gate script keeps pushing us back") :format(s.x, s.y, ow().map.id, p.cellX, p.cellY, visits[cell])) return false end local key = bfsNextKey(s.x, s.y, refused) if not key then -- an NPC can park on the target or the only path to it; give it a -- moment to wander off before giving up on this waypoint U.wait(30) -- That wait yields, so a script move or a tween can land us on the -- target meanwhile -- and bfsNextKey returns nil for "already -- there" just as it does for "no path". Re-check arrival first or -- we report the waypoint unreachable while standing on it (and -- `p` is live, so the message would print from == target). local arrived = p.cellX == s.x and p.cellY == s.y if not arrived and not bfsNextKey(s.x, s.y, refused) then -- BFS treats NPCs as walls, so an unreachable waypoint almost -- always means somebody is standing where the route expects -- empty floor -- usually an event NPC that should have been -- hidden by a script. Name them, so the log says who. local who = {} for _, npc in ipairs(ow().npcs) do who[#who + 1] = ("%s@(%d,%d)"):format( tostring(npc.def and npc.def.id or npc.def and npc.def.sprite or "?"), npc.cellX, npc.cellY) end -- ...or a Cut tree has grown back across the way. -- -- Gen 1 restores cut trees whenever the map is re-entered, and we -- re-enter constantly: every Poké Center visit is a warp out and -- back. Vermilion is the case that forced this. Its gym sits in a -- pocket (x 6-15, y 20-23) whose ONLY opening is the tree at -- (15,18) -- verified against the block data, block 53, which is -- a cutTreeSwaps entry. So beating Surge, healing at the centre -- and then running segment 71 found `goto (12,20)` unreachable, -- every following segment skipped, and a won gym was thrown away. -- -- Budgeted: on maps whose ornamental bushes match cutTreeSwaps -- (Saffron, Celadon), a target that is walled off for some OTHER -- reason otherwise chain-cuts every reachable bush before giving -- up, and map re-entry regrows them all for the next pass. if cuts < CUT_BUDGET and cutToward(s.x, s.y) then cuts = cuts + 1 blocked = 0 goto keepGoing end -- ...or a Silph Co card-key door. The closed tile is not -- walkable and only an A-press while facing it opens it -- (OverworldState:tryCardKeyDoor), so to BFS every locked door -- is a permanent wall -- which is how segment 148's walk to -- Giovanni read "unreachable" and the Silph ending got skipped. if openDoorToward(s.x, s.y) then blocked = 0 goto keepGoing end -- ...or a Pokémon Mansion gate in the wrong switch state. ONE -- toggle per goto: if the flipped state is still wrong, the -- next call flips it back rather than this one oscillating. if switchToggles == 0 and toggleMansionSwitch(s.x, s.y) then switchToggles = switchToggles + 1 blocked = 0 goto keepGoing end -- ...or an item ball is the wall (Pokémon Tower 6F's -- RARE_CANDY sits in the floor's only crossing corridor). -- Tried BEFORE arriveByWarp: taking the ball keeps the walk on -- this floor, while arriveByWarp warps clear off it hunting -- the far end. if pickupBlockingBall and pickupBlockingBall(s.x, s.y) then blocked = 0 goto keepGoing end -- ...or the target is in a part of this map no walk connects. -- -- Cave floors are not one connected room. MT_MOON_B1F is four -- separate pockets stitched together through B2F and 1F, so -- standing on warp 5 at (21,17) there is genuinely NO path to -- warp 7 at (23,3) -- the log said so with "npcs: none". travelTo -- cannot help either: both cells are the same MAP, so it thinks -- we have already arrived. The way to such a cell is to come out -- of it, which is what this does. if arriveByWarp(s.x, s.y) then return true end -- ...or the way across is a gate whose warps all say LAST_MAP, -- which no plan can see through (ROUTE_16's two halves). if reEnterThroughGate(s.x, s.y) then blocked = 0 goto keepGoing end -- ...or a sleeping SNORLAX is the wall and the POKE FLUTE is -- aboard: wake it rather than report unreachable (the route's -- own flute segment can get skipped in a desync). if wakeBlockingSnorlax and wakeBlockingSnorlax("goto") then blocked = 0 goto keepGoing end -- ...or a STRENGTH boulder is the wall. PokeBotBad's Victory -- Road waypoints path straight through boulders, shoving them -- a cell per step -- so both "the target is under a boulder" -- and "a boulder sits on the only path" land here. One shove, -- then re-plan; budgeted like the cuts. if boulderPushes < 8 and pushBoulderToward and pushBoulderToward(s.x, s.y) then boulderPushes = boulderPushes + 1 blocked = 0 goto keepGoing end -- ...or the waypoint IS an NPC's own cell. PokeBotBad's -- collision model does not wall off NPCs, so its waypoints -- sometimes name the tile a clerk is standing on -- Celadon -- Mart 2F's goto (6,3) is CELADONMART2F_CLERK2's cell, and the -- failed step cost the run its POKE_DOLL. Our BFS is right to -- refuse; "beside them, facing them" serves every use the -- route makes of such a waypoint (a talk/shop step follows). for _, npc in ipairs(ow().npcs) do if npc.cellX == s.x and npc.cellY == s.y then for _, d in ipairs({ { 0, 1, "up" }, { 0, -1, "down" }, { -1, 0, "right" }, { 1, 0, "left" } }) do local ax, ay = s.x + d[1], s.y + d[2] if map:inBounds(ax, ay) and map:isWalkableCell(ax, ay) and not ow():npcAtCell(ax, ay) and ops.goto_({ x = ax, y = ay }) then local p2 = ow().player if p2.cellX == ax and p2.cellY == ay then faceDir(d[3]) say(("goto (%d,%d) is %s's own cell; standing beside " .. "at (%d,%d)"):format(s.x, s.y, tostring(npc.def and (npc.def.id or npc.def.sprite) or "npc"), ax, ay)) return true end end end break end end -- ...or the land simply ends and the rest of the way is WATER. -- Mount SURF at the nearest shore and re-plan; bfsNextKey -- treats water as passable while surfing. Last in the ladder so -- an NPC-blocked target in a pond town does not trigger a -- pointless swim, and ONCE per goto -- coming ashore dismounts, -- so a still-unreachable target re-mounts forever otherwise. if surfMounts < SURF_BUDGET and mountSurfToward and mountSurfToward(s.x, s.y) then surfMounts = surfMounts + 1 blocked = 0 goto keepGoing end say(("goto (%d,%d) unreachable on %s; from (%d,%d); npcs: %s") :format(s.x, s.y, ow().map.id, p.cellX, p.cellY, #who > 0 and table.concat(who, " ") or "none")) return false end elseif walk(key) then blocked = 0 -- remember the step for the shove detector above, target computed -- from where we stood BEFORE the walk local dv = DELTA[key] if dv then local W = ow().map.widthCells lastFrom = cell lastTarget = (math.floor(cell / W) + dv[2]) * W + (cell % W + dv[1]) steppedOnTarget = lastTarget == s.y * W + s.x end -- proved passable: forget any refusal an earlier run recorded here local now = ow() if now.map.id == map.id then clearWall(map.id, now.player.cellY * now.map.widthCells + now.player.cellX) end else -- BFS said this was walkable but the step did not land: a warp -- fired, an entity moved in, or the collision data disagrees -- (ledges are one-way, counters and doors are not plain floor). -- -- Retrying alone is useless -- BFS is deterministic, so it hands -- back this exact step again and we bump the same wall until the -- counter runs out. Remember the cell it refused and re-plan; the -- next bfsNextKey call treats it as a wall and routes around. blocked = blocked + 1 local dv = DELTA[key] if dv then local bx, by = p.cellX + dv[1], p.cellY + dv[2] local cell = by * map.widthCells + bx refused[cell] = true local seen = noteWall(map.id, cell) if blocked == 1 then say(("goto (%d,%d): %s refused the step into (%d,%d); routing " .. "around it (refused %dx across runs)") :format(s.x, s.y, ow().map.id, bx, by, seen)) end end if blocked >= 8 then say(("goto (%d,%d) stuck at (%d,%d) on %s") :format(s.x, s.y, p.cellX, p.cellY, ow().map.id)) return false end U.wait(4) end end ::keepGoing:: end say(("goto (%d,%d) timed out"):format(s.x, s.y)) return false end -- Interact with whatever is adjacent, and report whether anything opened. -- -- Most converted steps carry no direction: PokeBotBad's strategy functions -- knew the geometry, so the route only records a dir for its generic -- {s="talk",dir=...} verb. Walking a waypoint leaves us facing the way we -- travelled, which is rarely the way the target sits -- arriving at Oak's -- ball row from the west leaves us facing right while the ball is north. -- So when no direction is given, try each facing until the press takes. local function interact(face) local dirs = face and { face } or { ow().player.facing, "up", "down", "left", "right" } for _, d in ipairs(dirs) do faceDir(d) press("a") U.wait(10) if busy() or inBattle() then return true end end return false end function ops.battle(s, where) -- Top up BEFORE picking the fight rather than after losing it. This is -- the whole difference between a trainer costing us a potion and a -- trainer costing us the run, and it is where the danger memory pays -- off: on a map that has killed us the bar is much higher. autoHeal(where, riskThreshold(where)) local opened = interact(s.face) if inBattle() then fightBattle() end mashUntilIdle() return opened end function ops.talk(s, where) -- A talk aimed at an item ball is a pickup in disguise: the route data -- writes plain `talk` for some balls -- Safari's GOLD_TEETH among them, -- and losing THAT silently left no trace until Victory Road's boulders -- found nobody knowing STRENGTH. Route those through ops.pickup's -- verified, retrying path. Only balls whose def carries `.item` count: -- Oak's starter balls are SPRITE_POKE_BALL too, but they are script -- objects with no item field, and shoving THAT cutscene through the -- pickup retry would wreck the intro. `__asPickup` breaks the mutual -- recursion (ops.pickup drives the actual interaction via ops.talk). if not s.__asPickup then local p = ow().player for _, npc in ipairs(ow().npcs) do if npc.def and npc.def.item and math.abs(npc.cellX - p.cellX) + math.abs(npc.cellY - p.cellY) <= 2 then return ops.pickup(setmetatable({ __asPickup = true }, { __index = s }), where or ow().map.id) end end end local opened = interact(s.face) -- a talk can start a fight (a trainer, or the ball that triggers the -- rival), so never assume the overworld is what we come back to if inBattle() then fightBattle() end mashUntilIdle() return opened end -- A pickup is a talk aimed at an item ball -- but unlike a talk it has a -- verifiable outcome: something enters the bag. Silph's CARD_KEY was lost -- exactly here: the goto before this step had failed ("goto (20,16) -- unreachable on SILPH_CO_5F"), the interact mashed empty dialog from the -- wrong cell, and the run only found out ten locked doors later as a wall -- of "no path". Verify the gain; when nothing arrived, walk to the -- nearest still-visible item ball and try once more, loudly either way. function ops.pickup(s, where) local function bagTotal() local t = 0 for _, n in pairs(G.save.inventory or {}) do t = t + (tonumber(n) or 1) end return t end local startMap = ow().map.id local before = bagTotal() local opened = ops.talk(s) if bagTotal() > before then return opened end if ow().map.id ~= startMap then -- something during the talk moved us off the map (a warp, a rope); -- retrying HERE would hunt balls on the wrong floor. Let the step -- loop's partway-check re-sync instead. say(("pickup on %s FAILED: left the map mid-step (now on %s)") :format(where, tostring(ow().map.id))) return false end -- A FULL bag refuses the ball outright ("no more room") -- the Saffron -- restock packs all 20 slots right before Silph's CARD_KEY. Free one, -- then retry at the ball below. if #(G.save.bagOrder or {}) >= 20 and pickupTossJunk then pickupTossJunk(where) before = bagTotal() -- the toss shrank the bag; success is a gain from HERE if ow().map.id ~= startMap then say(("pickup on %s FAILED: the toss moved us off the map (now on %s)") :format(where, tostring(ow().map.id))) return false end end local p = ow().player local ball, bestD for _, npc in ipairs(ow().npcs) do -- live entities carry their identity on npc.def (see -- wakeBlockingSnorlax); only defs with `.item` are real bag pickups -- (Oak's starter balls are SPRITE_POKE_BALL script objects without it) if npc.def and npc.def.item then local d = math.abs(npc.cellX - p.cellX) + math.abs(npc.cellY - p.cellY) if not bestD or d < bestD then ball, bestD = npc, d end end end if ball then -- stand beside the ball and face back toward it (offset -> face pairs) for _, d in ipairs({ { 1, 0, "left" }, { -1, 0, "right" }, { 0, 1, "up" }, { 0, -1, "down" } }) do local nx, ny = ball.cellX + d[1], ball.cellY + d[2] if passableCell(ow().map, nx, ny) and ops.goto_({ x = nx, y = ny }, where, false) and ow().player.cellX == nx and ow().player.cellY == ny then interact(d[3]) if inBattle() then fightBattle() end mashUntilIdle() break end end end if bagTotal() > before then say(("pickup on %s: first try gained nothing; retry at the ball " .. "(%d,%d) worked"):format(where, ball.cellX, ball.cellY)) return true end say(("pickup on %s FAILED: nothing entered the bag (from (%d,%d)%s)") :format(where, p.cellX, p.cellY, ball and (", ball seen at (" .. ball.cellX .. "," .. ball.cellY .. ")") or ", no ball in sight")) return false end function ops.bike() -- toggling the bike needs the bag; without a handler we simply walk, -- which costs time but never blocks progress return true end -- The route names its catch targets in its own vocabulary; these are the -- species the encounter tables actually use. `flier` is PokeBotBad's word -- for "whatever around here can be taught FLY". local CATCH_SPECIES = { nidoran = { "NIDORAN_M", "NIDORAN_F" }, oddish = { "ODDISH" }, paras = { "PARAS" }, flier = { "SPEAROW", "PIDGEY" }, } -- Set by allowCatch, read by the catch handlers that follow it. The route -- always pairs the two (`allowCatch nidoran` then `manual catchNidoran`), -- so this is only a fallback for the handler's own species list. local catchTarget function ops.allowCatch(s) catchTarget = s.mon and CATCH_SPECIES[s.mon] or nil return true end -- --------------------------------------------------------------------- -- shops -- --------------------------------------------------------------------- -- Which screen is on top. ShopMenu is a Menu of BUY/SELL/QUIT whose rows -- carry .onSelect; the buy list is a ListMenu whose rows carry .value (the -- item id); QuantityBox owns .qty; ChoiceBox owns .index with no rows. local function top() return G.stack:top() end local function rows() local t = top() return t and t.items end local function isMenu() local r = rows() return r ~= nil and r[1] ~= nil and r[1].onSelect ~= nil end local function isList() local r = rows() return r ~= nil and r[1] ~= nil and r[1].value ~= nil end local function isQty() local t = top() return t ~= nil and t.qty ~= nil end local function isChoice() local t = top() return t ~= nil and t.index ~= nil and t.items == nil end -- press `btn` until pred() holds local function pressUntil(pred, btn, tries) for _ = 1, tries or 60 do if pred() then return true end press(btn) U.wait(4) end return pred() end -- move a vertical cursor to `want`, reading `field` off the top screen local function cursorTo(field, want, tries) for _ = 1, tries or 40 do local t = top() if not t or t[field] == want then return t ~= nil end press(t[field] > want and "up" or "down") U.wait(3) end local t = top() return t ~= nil and t[field] == want end -- PokeBotBad's shop lists are exact counts tuned to a speedrun's money and -- damage breakpoints. We only need to stay alive and be able to catch -- HM-safe MoveLearnMenu handling, shared by fightBattle, mashUntilIdle -- and ops.teach's flow. Returns true if it did something (a learn menu is -- somewhere on the stack), false when there is nothing learn-related up. -- `preferForget` is the route step's `replace` hint: forget that move when -- it is present and safe, fall back to the first-safe-row heuristic. function driveLearnMenu(preferForget) local menu for _, st in ipairs(G.stack.states or {}) do if st.newMoveId and st.mon and st.index then menu = st break end end if not menu then return false end local newId = tostring(menu.newMoveId or ""):upper() -- The learn flow raises TWO YES/NO prompts wanting OPPOSITE answers: -- "Delete an older move?" wants YES (on to the forget list), "Abandon -- learning X?" wants NO (back to the delete prompt). Blindly answering -- YES confirmed the abandon whenever stray B presses (backOut) had -- flipped the flow onto the second prompt -- the run lost BUBBLEBEAM, -- DIG and CUT to exactly that. The ChoiceBox itself is text-free; the -- prompt is the TextBox directly beneath it on the stack, so read that. -- (Nested here rather than file-local: the main chunk is at LuaJIT's -- 200-local ceiling.) local function choicePromptIsAbandon() local states = G.stack.states or {} local under = states[#states - 1] if under and under.pages then for _, page in ipairs(under.pages) do for _, l in ipairs(page) do if tostring(l):find("Abandon") then return true end end end end return false end -- Decide the goal up front, because the choice prompts need to know it: -- forget `row`, or abandon the new move when nothing is safe to forget. -- HM field moves are excluded (the engine refuses, and they are the -- run's geography), as is the move being learned. local UNFORGETTABLE = { SURF = true, CUT = true, STRENGTH = true, FLY = true, FLASH = true, DIG = true } local moves = (menu.mon and menu.mon.moves) or {} local prefer = preferForget and tostring(preferForget):upper() or nil local row if prefer then for ri, mv in ipairs(moves) do local id = tostring(mv.id or mv):upper() if id == prefer and not UNFORGETTABLE[id] and id ~= newId then row = ri break end end end if not row then for ri, mv in ipairs(moves) do local id = tostring(mv.id or mv):upper() if not UNFORGETTABLE[id] and id ~= newId then row = ri break end end end local t = top() if t == menu then if not row then press("b") -- nothing safe to forget: give up on the new move U.wait(8) return true end if cursorTo("index", row) then press("a") end U.wait(10) return true end if isChoice() then -- Answer for the goal: heading for the forget list means YES on -- "Delete an older move?" and NO on "Abandon learning?"; when nothing -- is safe to forget the answers flip, because abandoning IS the goal. local wantForget = row ~= nil local yes = (choicePromptIsAbandon() ~= wantForget) if yes then cursorTo("index", 1) press("a") else press("b") end U.wait(8) return true end press("a") -- the surrounding text boxes U.wait(6) return true end -- things, so each stop buys a plain stock instead. local SHOP_STOCK = { -- Viridian Mart stocks POKE_BALL, ANTIDOTE, PARLYZ_HEAL, BURN_HEAL and -- no POTION at all (vanilla) -- so there is no HP healing for sale until -- Pewter, and the only HP items before Viridian Forest are the two -- hidden POTIONs at VIRIDIAN_CITY (14,4) and VIRIDIAN_FOREST (1,18). -- The status heals are worth the money: ops.heal(status) can spend them, -- and a slept lead loses more turns than a dented one. viridianBalls = { { "POKE_BALL", 10 }, { "ANTIDOTE", 2 }, { "PARLYZ_HEAL", 2 } }, -- ESCAPE_ROPE first, and it is not optional: segment 48 leaves Bill's -- house with `useItem escape_rope` and has no walk to the door, so -- without one in the bag the bot takes the S.S. ANNE TICKET and then -- stands in Bill's living room until the skip guard kills the attempt. -- Pewter is the only stop before Bill that sells them (Cerulean Mart -- stocks REPEL in that slot), so it gets first call on the money. pewter = { { "ESCAPE_ROPE", 1 }, { "POTION", 8 } }, -- SUPER_POTION, not POTION: VermilionMartClerkText stocks -- POKE_BALL, SUPER_POTION, ICE_HEAL, AWAKENING, PARLYZ_HEAL, REPEL -- (data/items/marts.asm:17) and carries no plain POTION at all. Asking -- for one bought nothing -- "shop: no POTION @ VERMILION_MART" -- so the -- bot left Vermilion with zero healing and spent the next stretch of the -- route dying: nine wipes in Surge's gym, five more around Cerulean, all -- of them fought at whatever HP the last nurse left. Healing comes first -- here; the balls are for later catches and can wait. vermilion = { { "SUPER_POTION", 8 }, { "POKE_BALL", 5 } }, -- Celadon Mart 2F (segment 92) is the best-stocked shop the route visits -- and the last one before the long Rocket Hideout / Pokémon Tower / Silph -- stretch, so it is where the run buys its staying power. -- -- SUPER_REPEL is dropped on purpose: fewer wild encounters means less -- exp, and being underlevelled is what actually kills this bot. REVIVE is -- worth the 1500 -- a faint mid-dungeon otherwise means a walk back from -- the heal point through everything we just cleared. -- `reserve` keeps ¥800 back for the roof's vending machines four segments -- later (a FRESH_WATER is ¥200, and one drink opens every Saffron gate). -- REVIVE is dropped: at ¥1500 it alone emptied the wallet, and a revive -- is worth far less to this run than reaching Lavender at all. repels = { reserve = 800, { "SUPER_POTION", 4 } }, -- Celadon Mart 5F stocks X_ACCURACY / GUARD_SPEC / DIRE_HIT / the X -- stat items and the five vitamins -- no healing at all. It previously -- asked for FULL_RESTORE here, which 5F has never sold (that is the -- Indigo Plateau lobby), so the step bought nothing every run. The X -- items are exactly the speedrun tech we drop, so there is nothing here -- worth stopping for; kept as an empty list so the step is a no-op -- rather than a logged failure. buffs = {}, pokeDoll = { { "POKE_DOLL", 1 } }, -- TM07 and the vending machine are speedrun tech (Horn Drill, drinks for -- the Saffron guards); the guards are handled by the giveWater handler tm07 = {}, vending = {}, water = {}, -- Saffron Mart (GREAT_BALL, HYPER_POTION, MAX_REPEL, ESCAPE_ROPE, -- FULL_HEAL, REVIVE -- marts.asm). Not a route stop; the segment loop -- shops here when the heal-point anchor finds the bag out of HP items, -- because Silph's 7F rival sits right above and a checkpoint-resumed -- bag arrives empty -- five wipes' worth of empty. saffronRestock = { { "HYPER_POTION", 3 }, { "MAX_REPEL", 3 }, { "FULL_HEAL", 2 } }, -- Cinnabar Mart (ULTRA_BALL, GREAT_BALL, HYPER_POTION, MAX_REPEL, -- ESCAPE_ROPE, FULL_HEAL, REVIVE). The Mansion sits next door and its -- B1F is chained wild fights: arriving with an empty bag drained the -- lead to 2/182 and the blackout threw the arc away. The repels feed -- the route's own useItem super_repel steps (segments 155/162). cinnabarRestock = { { "HYPER_POTION", 4 }, { "MAX_REPEL", 3 }, { "FULL_HEAL", 2 } }, -- The Indigo lobby mart (ULTRA_BALL, GREAT_BALL, FULL_RESTORE, -- MAX_POTION, FULL_HEAL, REVIVE, MAX_REPEL). Not a route stop -- the -- speedrun arrives provisioned -- but our bot arrives with whatever -- survived Victory Road, and the Elite Four is five fights with no -- nurse between them. The segment loop shops here on its own when the -- lobby segment runs (see the INDIGO_PLATEAU_LOBBY anchor). No REVIVE: -- nothing in the driver knows how to use one. indigo = { { "FULL_RESTORE", 6 }, { "FULL_HEAL", 3 } }, } local DEFAULT_STOCK = { { "POTION", 10 } } -- Drive a QuantityBox to `want`. -- -- It does NOT follow the list convention cursorTo implements. On a list, -- up moves toward index 1; on a QuantityBox, up *increases* the count and -- down decreases it, both wrapping around 1..max (QuantityBox.lua:29-33). -- Handing it cursorTo therefore walked the wrong way and then oscillated -- between 1 and max forever, so buyItem gave up and bought nothing -- the -- exception being a target exactly one wrap below max, which is why the -- occasional stop appeared to work and the failure looked intermittent. local function qtyTo(want, tries) for _ = 1, tries or 120 do local t = top() if not t or not t.qty then return false end if t.qty == want then return true end press(t.qty < want and "up" or "down") U.wait(3) end local t = top() return t ~= nil and t.qty == want end -- Items we can always part with when the 20-slot bag is full. Money or -- dead weight only: the NUGGET exists to be sold, TM_BIDE / TM_TOXIC / -- TM_MEGA_DRAIN are never taught by the route, and status heals are -- covered by FULL_HEALs. Nothing here gates progress. local SELLABLE_JUNK = { "NUGGET", "TM_BIDE", "TM_TOXIC", "TM_MEGA_DRAIN", "ANTIDOTE", "BURN_HEAL", "ICE_HEAL", "AWAKENING" } -- Sell the full stack of `id` from the shop's BUY/SELL/QUIT menu. Returns -- with the menu back on top. local function sellItem(id) if not isMenu() then return false end if not cursorTo("index", 2) then return false end -- SELL press("a") U.wait(10) if not pressUntil(isList, "a", 30) then press("b") U.wait(6) return false end local idx for i, row in ipairs(rows()) do if row.value == id then idx = i break end end local sold = false if idx and cursorTo("index", idx) then press("a") U.wait(6) if isQty() then qtyTo(top().max or 1) -- the whole stack press("a") U.wait(6) end if isChoice() then cursorTo("index", 1) -- YES press("a") U.wait(10) end pressUntil(isList, "a", 20) sold = ((G.save.inventory or {})[id] or 0) == 0 end -- back out of the sell list to the BUY/SELL menu for _ = 1, 10 do if isMenu() then break end press("b") U.wait(6) end return sold end -- Free up bag slots so `needed` NEW item kinds fit (20 slots without a -- capacity mod; a stack of an item already held costs nothing). This is why -- every restock had been reporting "HYPER_POTION x0": the buy list -- opened, the quantity was set, the engine said "no room" -- and the run -- walked into the Mansion with FULL_HEALs but not one HP restore. local function freeBagSlots(needed, where) local used = #(G.save.bagOrder or {}) local capacity = require("src.inventory.Bag").capacity(G.data) local free = capacity - used for _, id in ipairs(SELLABLE_JUNK) do if free >= needed then break end if ((G.save.inventory or {})[id] or 0) > 0 then if sellItem(id) then free = free + 1 say(("shop: sold the %s to free a bag slot (%d free)") :format(id, free)) end end end if free < needed then -- same dead-weight rule as pickupTossJunk: TMs no teach step wants -- are sellable too. The Indigo lobby restock hit this: the junk list -- ran dry, FULL_RESTOREs could not land, and the E4 attrition war -- was fought on FULL_HEALs alone. Snapshot first -- selling reorders -- the bag under the iterator. local order = {} for _, id in ipairs(G.save.bagOrder or {}) do order[#order + 1] = id end for _, id in ipairs(order) do if free >= needed then break end if tostring(id):find("^TM_") and not WD.keepItems[id] then if sellItem(id) then free = free + 1 say(("shop: sold the %s to free a bag slot (%d free)") :format(id, free)) end end end end if free < needed then say(("shop %s: bag still short %d slot(s) after selling junk") :format(tostring(where), needed - free)) end return free end -- Buy `qty` of `id` from an open buy list. Leaves the list open. local function buyItem(id, qty, where) if not isList() then return false end local idx for i, row in ipairs(rows()) do if row.value == id then idx = i break end end if not idx then note("shop: no " .. id, where) say(("shop: %s is not on this buy list"):format(id)) return false end if not cursorTo("index", idx) then say(("shop: could not reach the %s row"):format(id)) return false end press("a") U.wait(6) if not isQty() then note("shop: no quantity box for " .. id, where) say(("shop: no quantity box opened for %s"):format(id)) return false end -- QuantityBox caps at .max (what we can afford), so never ask for more local want = math.min(qty, top().max or qty) if not qtyTo(want) then note("shop: could not set the quantity for " .. id, where) say(("shop: stuck setting %s quantity to %d on %s"):format(id, want, tostring(where))) return false end press("a") U.wait(6) if isChoice() then if not cursorTo("index", 1) then return false end -- YES press("a") U.wait(10) end -- back to the list (the purchase text may need clearing first) pressUntil(isList, "a", 20) -- Trust the BAG, not the menu flow: with all 20 slots used the engine -- answers "You have no room for this!" through exactly the same -- screens, and this function reported success while buying nothing -- -- which is how every restock came back "HYPER_POTION x0" with no -- failure line and the Mansion was fought without a single HP item. if ((G.save.inventory or {})[id] or 0) < 1 then say(("shop: %s purchase did not reach the bag (no room?)"):format(id)) return false end return true end -- Back all the way out of the shop with B. -- -- Never mash A to close one: on the buy list A IS a purchase, so the -- generic mashUntilIdle() turns any wrong turn in here into an endless -- shopping spree that empties the wallet and then sits on the "not enough -- money" footer forever. B is the only safe key once a shop screen is up. local function closeShop() for _ = 1, 40 do if not (isList() or isQty() or isChoice() or isMenu()) then return true end press("b") U.wait(6) end return false end -- Walk to a cell beside (ox, oy) and turn to face it. -- -- The route's own waypoint is wherever PokeBotBad happened to stand, which -- is not always next to the thing it then talks to. Viridian Mart is the -- case that matters: the shop step walks to (2,5), but VIRIDIANMART_CLERK -- is at (0,5) -- two tiles west, so interact() probed four empty cells and -- the shop never opened. That failed quietly into `note`, so every stop on -- the route had been buying nothing at all: no POKE_BALLs for the Nidoran -- catch, and none of Pewter's eight POTIONs either, which is a large part -- of why the party kept arriving at Brock with nothing to heal with. local function stepUpTo(ox, oy) local m = ow().map local startMap = m.id -- Candidates in preference order: beside the object, then TWO cells -- away across a counter (mart clerks stand behind one -- Map's -- counterTiles are talked across, same as the nurse's desk). -- -- Filtered by what we can ACTUALLY WALK TO, not just what is walkable. -- Celadon Mart 4F is why: the clerk's own aisle (0-8,7) is sealed -- behind the counter, so its cells are walkable-but-unreachable -- and -- handing one to ops.goto_ fired its unreachable-recovery ladder -- (arriveByWarp), which warped the bot clear off the floor hunting a -- way in and left it "arrived" at the same coordinates on 1F, talking -- to a wall. The shop then failed, no POKE_DOLL was bought, and the -- ghost MAROWAK sealed the tower. local reach, W = reachableSet() for _, d in ipairs({ { 1, 0, "left" }, { -1, 0, "right" }, { 0, 1, "up" }, { 0, -1, "down" }, { 0, 2, "up" }, { 0, -2, "down" }, { 2, 0, "left" }, { -2, 0, "right" } }) do local cx, cy = ox + d[1], oy + d[2] if m:inBounds(cx, cy) and m:isWalkableCell(cx, cy) and not ow():npcAtCell(cx, cy) and reach[cy * W + cx] then local p = ow().player if not (p.cellX == cx and p.cellY == cy) then ops.goto_({ x = cx, y = cy }) p = ow().player end if ow().map.id ~= startMap then say(("stepUpTo (%d,%d): a warp moved us to %s -- giving up") :format(ox, oy, tostring(ow().map.id))) return false end if p.cellX == cx and p.cellY == cy then faceDir(d[3]) return true end end end return false end function ops.shop(s, where) local stock = SHOP_STOCK[s.list] or DEFAULT_STOCK if #stock == 0 then return true end -- Stand next to the clerk first; the route's waypoint may not be -- adjacent to them (see stepUpTo). local clerk = findObject("CLERK") or findObject("CASHIER") do local p = ow().player say(("shop %s: on %s at (%d,%d); clerk %s"):format( tostring(s.list), tostring(ow().map.id), p.cellX, p.cellY, clerk and ("@(%d,%d)"):format(clerk.x, clerk.y) or "NOT FOUND")) end if clerk then local reached = stepUpTo(clerk.x, clerk.y) local p = ow().player say(("shop %s: stepUpTo %s; now on %s at (%d,%d) facing %s"):format( tostring(s.list), reached and "ok" or "FAILED", tostring(ow().map.id), p.cellX, p.cellY, tostring(p.facing))) end -- Shops failed silently into `note` for a long time, and a note only -- surfaces in the end-of-run summary -- long after the empty bag has -- already cost a catch or a gym. Name the stage that failed, live. local function stageFailed(stage) local t = top() local kind = (isList() and "list") or (isMenu() and "menu") or (isQty() and "qty") or (isChoice() and "choice") or (t and "other") or "nothing" say(("shop %s on %s: FAILED at %s (top=%s, rows=%d)"):format( tostring(s.list), tostring(where), stage, kind, rows() and #rows() or 0)) end if not interact() then note("shop: no clerk", where) stageFailed("reaching the clerk") return false end if not pressUntil(isMenu, "a", 60) then note("shop: menu never opened", where) stageFailed("BUY/SELL/QUIT menu") closeShop() mashUntilIdle() return false end -- Make room BEFORE buying: count the list entries that would need a NEW -- bag slot and sell junk until they fit (see freeBagSlots). do local newKinds = 0 for _, want in ipairs(stock) do if ((G.save.inventory or {})[want[1]] or 0) == 0 then newKinds = newKinds + 1 end end local used = #(G.save.bagOrder or {}) local capacity = require("src.inventory.Bag").capacity(G.data) if newKinds > 0 and capacity - used < newKinds then freeBagSlots(newKinds, where) end end if not cursorTo("index", 1) then stageFailed("cursor to BUY") closeShop() return false end press("a") U.wait(10) if not pressUntil(isList, "a", 30) then note("shop: buy list never opened", where) stageFailed("buy list") closeShop() mashUntilIdle() return false end -- Stop buying once the reserve is reached. -- -- Some later step needs money more than this shop does, and a shop with -- no brake spends it all. Celadon Mart 2F is exactly that: it comes at -- segment 92 and the roof's vending machines at 96, so stocking up on -- SUPER_POTIONs left ¥68 and the vending machine could not sell a -- ¥200 FRESH_WATER -- "3 vending purchases, 0 drink(s) in the bag". That -- drink is not a comfort item: it opens all four Saffron gates, and with -- Saffron shut the run cannot reach Lavender for the POKE_FLUTE, so the -- potions bought instead cost the back half of the game. for _, want in ipairs(stock) do if (G.save.money or 0) <= (stock.reserve or 0) then say(("shop %s on %s: stopping at ¥%d to keep the ¥%d reserve") :format(tostring(s.list), tostring(where), G.save.money or 0, stock.reserve or 0)) break end buyItem(want[1], want[2], where) end local closed = closeShop() if not closed then note("shop: would not close", where) end mashUntilIdle() -- safe now: every shop screen is off the stack -- Report what we actually walked out with. A shop that silently buys -- nothing is the failure mode this op has already had once, and it only -- shows up much later as an empty bag at the point of need. local got = {} for _, want in ipairs(stock) do got[#got + 1] = ("%s x%d"):format(want[1], (G.save.inventory or {})[want[1]] or 0) end say(("shop %s on %s: %s (¥%d left)"):format( tostring(s.list), tostring(where), table.concat(got, ", "), G.save.money or 0)) return closed end -- --------------------------------------------------------------------- -- healing -- --------------------------------------------------------------------- -- The route's `heal` steps are bag heals, not Poké Center visits: only 3 -- of the 197 segments are centers, and PokeBotBad tops up from items as it -- walks. A step can carry `status` (cure a status), `full` (spend a -- FULL_RESTORE) or `hp = N` (only bother once N HP is missing). -- -- We drive the real menus instead of calling ItemEffects.use directly. -- Calling the API would heal just as well and be far less fiddly, but the -- whole point of the run is to assert that warps, scripts, battles AND -- menus work end to end -- a bag that stopped opening would go unnoticed. -- weakest first, so a heal spends the cheapest thing that will do local HP_ITEMS = { "POTION", "SUPER_POTION", "HYPER_POTION", "MAX_POTION", "FULL_RESTORE" } local STATUS_ITEMS = { "ANTIDOTE", "PARLYZ_HEAL", "AWAKENING", "BURN_HEAL", "ICE_HEAL", "FULL_HEAL" } local function party() return G.save.party or {} end local function heldCount(id) return (G.save.inventory or {})[id] end -- the living mon that has lost the most HP, and how much it is missing local function mostHurt() local slot, worst for i, mon in ipairs(party()) do local max = mon.stats and mon.stats.hp if max and mon.hp and mon.hp > 0 then local missing = max - mon.hp if missing > 0 and (not worst or missing > worst) then slot, worst = i, missing end end end return slot, worst or 0 end local function firstStatused() for i, mon in ipairs(party()) do if mon.hp and mon.hp > 0 and mon.status and mon.status ~= "" then return i end end end -- B out of whatever menus are open, back to the plain overworld local function backOut() for _ = 1, 25 do if top() == ow() then return true end press("b") U.wait(5) end return top() == ow() end -- The USE/TOSS submenu is pushed with a raw stack:push, so unlike every -- other screen here it carries no .screenId -- identify it by its rows. local function isUseToss() local r = rows() return r ~= nil and r[1] ~= nil and r[1].label == "USE" end -- START -> ITEM -> -> USE -> . Returns true if used. local function useItemOn(itemId, slot, where) press("start") U.wait(8) local menu = top() if not (menu and menu.screenId == "StartMenu") then note("heal: start menu never opened", where) backOut() return false end -- the ITEM row shifts with the POKéDEX/LINK/MODS rows (and the list runs -- through the ui.start_menu.items mod hook), so never hardcode its index local itemRow for i, it in ipairs(menu.items or {}) do if it.label == "ITEM" then itemRow = i break end end if not itemRow or not cursorTo("index", itemRow) then note("heal: no ITEM row", where) backOut() return false end press("a") U.wait(10) local bag = top() if not (bag and bag.screenId == "BagMenu") then note("heal: bag never opened", where) backOut() return false end local bagRow for i, r in ipairs(bag.items or {}) do if r.value == itemId then bagRow = i break end end if not bagRow or not cursorTo("index", bagRow) then note("heal: " .. itemId .. " not in bag", where) backOut() return false end press("a") U.wait(8) -- outside battle a healing item always offers USE/TOSS first; USE is row 1 if isUseToss() then if not cursorTo("index", 1) then backOut() return false end press("a") U.wait(8) end -- TMs and HMs interpose "Booted up a TM!" / "It contained X!" text -- between USE and the target picker (BagMenu.lua:341-350, ItemUseTMHM); -- healing items go straight to the party menu. Advance that text with A -- until the picker appears. Bailing out here was the REAL teach killer: -- top() was the boot-up TextBox, this reported "party menu never -- opened", and backOut()'s B presses cancelled the teach entirely -- -- which is how the run reached Route 9's tree with nobody knowing CUT. local pm for _ = 1, 20 do pm = top() if pm and pm.screenId == "PartyMenu" then break end press("a") U.wait(6) end pm = top() if not (pm and pm.screenId == "PartyMenu") then note("heal: party menu never opened", where) backOut() return false end if not cursorTo("index", slot) then backOut() return false end press("a") U.wait(10) -- A TM/HM on a full moveset just pushed MoveLearnMenu (BagMenu.lua:158). -- Hands OFF: the close-out below mashes A then B, and on a YES/NO box B -- always answers NO (ChoiceBox.lua:36-41) -- so the mash ping-ponged -- "Delete an older move?" / "Abandon learning?" and declined the very -- move the item was spent on. That is how CUT was never learned and -- Route 9's tree walled off the run at segment 76. ops.teach's -- learn-menu driver knows the right answers; leave the stack exactly as -- it stands for it. for _, st in ipairs(G.stack.states or {}) do if st.newMoveId and st.mon and st.index then return true end end -- clear the "HP was restored!" box, then unwind the bag + start menu pressUntil(function() return not (top() and top().screenId == "PartyMenu") end, "a", 15) backOut() return true end -- Toss a junk stack where no shop exists. freeBagSlots sells, which needs -- a clerk; a FULL bag at an item ball ("no more room") happens -- mid-dungeon -- Silph 5F's CARD_KEY was refused exactly this way after -- the Saffron restock packed the bag to 20 slots. Same START->ITEM walk -- as useItemOn, but taking the TOSS row. Global, not local: ops.pickup is -- defined earlier in the file and resolves this at call time. function pickupTossJunk(where) local victim, count for _, id in ipairs(SELLABLE_JUNK) do local n = (G.save.inventory or {})[id] or 0 if n > 0 then victim, count = id, n break end end if not victim then -- The static junk list ran dry: the pickup retries now collect the -- bonus balls older runs walked past, so the bag fills with TMs the -- route has no teach step for. Those are dead weight -- toss the -- first one that no teach will ever want (WD.keepItems; HMs are not -- TM_-prefixed and stay untouched). for _, id in ipairs(G.save.bagOrder or {}) do if tostring(id):find("^TM_") and not WD.keepItems[id] then victim, count = id, (G.save.inventory or {})[id] or 1 break end end end if not victim then say("pickup: bag is full and no junk left to toss") return false end press("start") U.wait(8) local menu = top() if not (menu and menu.screenId == "StartMenu") then backOut() return false end local itemRow for i, it in ipairs(menu.items or {}) do if it.label == "ITEM" then itemRow = i break end end if not itemRow or not cursorTo("index", itemRow) then backOut() return false end press("a") U.wait(10) local bag = top() if not (bag and bag.screenId == "BagMenu") then backOut() return false end local bagRow for i, r in ipairs(bag.items or {}) do if r.value == victim then bagRow = i break end end if not bagRow or not cursorTo("index", bagRow) then backOut() return false end press("a") U.wait(8) if not isUseToss() or not cursorTo("index", 2) then backOut() return false end press("a") U.wait(8) if isQty() then qtyTo(count) -- the whole stack, or the slot stays occupied press("a") U.wait(8) end -- "Is it OK to toss X?" -- YES is the first row; then the fanfare text. -- NEVER mashUntilIdle here: with the bag still open, its A presses -- select whatever row the cursor sits on -- one run USED an ESCAPE_ROPE -- that way and teleported the bot out of Silph mid-pickup. Advance only -- while a text box is up, then B out. pressUntil(function() return not isChoice() end, "a", 10) for _ = 1, 10 do local t = top() if not (t and t.pages) then break end press("a") U.wait(6) end backOut() local left = (G.save.inventory or {})[victim] or 0 say(("pickup: tossed %s x%d to free a bag slot (%s)") :format(victim, count, left == 0 and "freed" or "slot NOT freed")) return left == 0 end -- Drink a potion DURING a battle. -- -- fightBattle only ever had one answer to a hurt lead: flee. That works on -- a wild encounter and is impossible against a trainer, so a gauntlet map -- simply ground the lead down and killed it -- ROUTE_9 is one long chain of -- trainers and it wiped the run fifteen times in a single attempt while -- eight SUPER_POTIONs sat unused in the bag. The lead was level 33 against -- level-20 trainers; it was not outmatched, it was just never healed. -- -- Losing the turn is the right trade here. A faint costs the whole battle, -- and behind our lead sits a level-4 NIDORAN -- when the lead goes down the -- run is over, so the turn is cheap by comparison. -- -- The bag in battle is the ITEM slot of the action grid (index 3), the same -- door throwBall uses; the potion then asks for a target through a -- pickOnly PartyMenu (BagMenu:useItem -> Screens.push "PartyMenu"). function healInBattle(battle, where) local mon = battle.player and battle.player.mon if not mon or not mon.stats then return false end local gap = (mon.stats.hp or 0) - (mon.hp or 0) if gap <= 0 then return false end -- smallest item that covers the gap, so a MAX_POTION is not spent on a -- scratch (same order ops.heal uses) local pick for _, id in ipairs(HP_ITEMS) do if (heldCount(id) or 0) > 0 then local amount = (id == "POTION" and 20) or (id == "SUPER_POTION" and 50) or (id == "HYPER_POTION" and 200) or 999 if not pick then pick = id end if amount >= gap then pick = id break end end end if not pick then return false end local beforeHP = mon.hp or 0 local beforeCount = heldCount(pick) or 0 if not battleMenuTo(battle, 3) then return false end press("a") if not waitFor(isList, 40) then backOut() return false end local idx for i, row in ipairs(rows() or {}) do if row.value == pick then idx = i break end end if not idx or not cursorTo("index", idx) then backOut() return false end press("a") U.wait(10) -- the party picker: heal the mon that is actually out, which is slot 1's -- position in the list only when nothing has been switched, so find it local pm = top() if pm and pm.onSwitch ~= nil and pm.index ~= nil then local slot = 1 for i, m in ipairs(party()) do if m == mon then slot = i break end end if not cursorTo("index", slot) then backOut() return false end press("a") U.wait(12) end -- Sample the result BEFORE handing the turn back. -- -- The first version logged HP after mashUntilIdle, which runs the rest of -- the turn including the enemy's attack -- so every heal printed "(0/79)" -- and there was no way to tell a potion that never applied from one that -- worked before we were killed anyway. Watch for the HP to actually rise, -- and check the bag, so the log says which it was. local rose = waitFor(function() return (mon.hp or 0) > beforeHP end, 30) local healedTo = mon.hp or 0 local spent = (heldCount(pick) or 0) < beforeCount -- Drain the heal's text back to a battle prompt WITHOUT mashUntilIdle: -- idle() means the OVERWORLD is idle, which is never true mid-battle, so -- mashUntilIdle burned its whole 400-frame budget on every heal. Against -- Agatha that starved the fight of turns until it hit the 20000-frame -- timeout and got force-closed -- sealing her door and the Hall of Fame -- with it. Advance only until the battle takes input again (or ends). for _ = 1, 150 do if not (inBattle() or replacementMenu()) then break end local b = G.stack:top() if b and (b.phase == "menu" or b.phase == "moveSelect") then break end if not (driveLearnMenu and driveLearnMenu()) then press("a") end U.wait(4) end if rose then say(("healed %s with %s mid-battle: %d -> %d/%d"):format( tostring(mon.species), pick, beforeHP, healedTo, mon.stats.hp or 0)) else say(("mid-battle %s did NOT heal %s (%d/%d, item %s) -- now %d/%d"):format( pick, tostring(mon.species), beforeHP, mon.stats.hp or 0, spent and "was consumed" or "still in the bag", mon.hp or 0, mon.stats.hp or 0)) end return rose end -- Battle menu -> ITEM -> POKE_DOLL. Used by fightBattle against the -- static ghost MAROWAK; ItemEffects ends the battle as an escape and -- BagMenu marks battle.pokeDollEscape for the 6F script's win check. function throwPokeDoll(battle) if not battleMenuTo(battle, 3) then return false end press("a") if not waitFor(isList, 40) then backOut() return false end local idx for i, row in ipairs(rows() or {}) do if row.value == "POKE_DOLL" then idx = i break end end if not idx or not cursorTo("index", idx) then backOut() return false end press("a") U.wait(12) say("threw the POKE DOLL") mashUntilIdle(400) return true end function ops.heal(s, where) -- status cures first: a slept/paralysed lead loses more turns than a -- dented one, and the route only asks for them when it matters if s.status then local slot = firstStatused() if not slot then return true end -- nothing to cure; not a failure for _, id in ipairs(STATUS_ITEMS) do if heldCount(id) then return useItemOn(id, slot, where) end end note("heal: no status item", where) return true end local slot, missing = mostHurt() if not slot then return true end -- party is topped up already -- `hp = N` is the route's "only bother once N HP is down" threshold if s.hp and missing < s.hp then return true end local order = HP_ITEMS if s.full then order = { "FULL_RESTORE", "MAX_POTION", "HYPER_POTION" } end -- prefer the smallest item that covers the gap, so early POTIONs are not -- wasted on 2 HP and a FULL_RESTORE is not wasted on a scratch local pick for _, id in ipairs(order) do if heldCount(id) then pick = pick or id -- fallback: the weakest thing we actually own local amount = (id == "POTION" and 20) or (id == "SUPER_POTION" and 50) or (id == "HYPER_POTION" and 200) or 999 if amount >= missing then pick = id break end end end if not pick then note("heal: no HP item", where) return true end return useItemOn(pick, slot, where) end -- --------------------------------------------------------------------- -- Poké Centers -- --------------------------------------------------------------------- -- Viridian Mart sells POKE_BALL/ANTIDOTE/PARLYZ_HEAL/BURN_HEAL and no -- POTION (vanilla), and the first POTION on sale is in Pewter -- on the -- far side of Viridian Forest. For the whole first act the nurse is the -- only way to restore HP, so healing cannot be just another route verb: -- it has to be able to leave the route, walk to a counter, and come back -- to the tile it left from. -- Walk onto a warp tile, stopping the instant the map changes. -- -- ops.goto_ must NOT be used for this: it re-reads ow() every iteration, -- so once the warp fires it carries on pathfinding toward the *old* map's -- coordinates on the new map -- which for a door at (23,25) landing in a -- 12x8 Poké Center is off the edge, and the out-of-bounds branch then -- spends 200 frames trying to leave by the nearest border. local function walkOntoWarp(wx, wy) local from = ow().map.id local stuckOnWarp = 0 local bfsNil = 0 local cuts = 0 -- Shove learning, same as ops.goto_: a coordinate-trigger script that -- walks the player back (Cinnabar's locked gym door at (18,4)) makes a -- deterministic BFS re-propose the same shoved step for the whole loop -- ("warp@6,3 did not move" x300, seen live from (18,5)). Refuse the -- cell after two reverts and plan around; per-call, since the trigger -- stops firing once its condition is met. local refused, reverts, lastFrom, lastTarget = {}, {}, nil, nil -- one mansion-switch toggle per call (see toggleMansionSwitch) local toggledSwitch = false -- one Victory Road switch solve per call (see solveVictoryRoadSwitches) local solvedVR = false -- one blocking-item-ball pickup per call (see pickupBlockingBall) local tookBall = false for _ = 1, 300 do if ow().map.id ~= from then return true end if inBattle() then fightBattle() elseif busy() then mashUntilIdle() else local p = ow().player if p.cellX == wx and p.cellY == wy then -- Standing on the mat is not enough for a building exit: it fires -- on the step that LEAVES the doorway, so push toward the nearest -- edge (down for a door on the bottom row) instead of waiting. -- -- Without this we sit on the mat until the loop expires and the -- rest of the route then runs indoors, where the city's waypoints -- are out of bounds on a 12x8 Poké Center and read as the route's -- "leave by this edge" idiom -- which is Red shoving downward into -- the wall below the door instead of walking out of it. local m = ow().map -- Which press can actually FIRE this warp? Mirror the engine: -- Warp.extraCheck says whether pressing `dir` from this cell can -- warp -- facing the map edge, OR a warp-carpet door tile in -- front. Route 16's gate mats are the latter: plain path tiles -- whose warp fires only pressing INTO the building ($4B door to -- the LEFT), and the old edge-based guess pushed "down" forever -- ("the step onto (24,10) keeps getting shoved back"). local WarpMod = require("src.world.Warp") local carpets = G.data.field and G.data.field.warpCarpets local outward for _, d in ipairs(DIRS) do if WarpMod.extraCheck(m, carpets, wx, wy, d[3]) then outward = d[3] break end end outward = outward or (wy >= m.heightCells - 1 and "down") or (wy <= 0 and "up") or (wx <= 0 and "left") or (wx >= m.widthCells - 1 and "right") or "down" faceDir(outward) walk(outward) -- If pushing outward does nothing, the warp we are standing on is -- INERT -- we arrived onto it, and the engine keeps a warp inert -- until you step off (OverworldController warpEntryCell). That is -- correct for not bouncing, but here we WANT to leave the same way, -- which is exactly the Route 16 gate: the plan routes back out the -- door we came in, and walkOntoWarp shoved a dead warp forever -- ("warp@0,8 did not move" x90). Step off to an adjacent cell and -- back on to re-arm it, then the next loop takes it normally. if ow().map.id == from and (p.cellX == wx and p.cellY == wy) then stuckOnWarp = (stuckOnWarp or 0) + 1 if stuckOnWarp >= 3 then for _, d in ipairs(DIRS) do local ax, ay = wx + d[1], wy + d[2] if m:inBounds(ax, ay) and m:isWalkableCell(ax, ay) and not m:warpAtCell(ax, ay) then walk(d[3]) -- step OFF the warp (re-arms it) if ow().player.cellX == ax and ow().player.cellY == ay then stuckOnWarp = 0 break end end end end else stuckOnWarp = 0 end else local m = ow().map local cell = p.cellY * m.widthCells + p.cellX if lastTarget and cell == lastFrom then reverts[lastTarget] = (reverts[lastTarget] or 0) + 1 if reverts[lastTarget] >= 2 and not refused[lastTarget] then say(("walkOntoWarp (%d,%d): the step onto (%d,%d) keeps getting " .. "shoved back -- planning around it") :format(wx, wy, lastTarget % m.widthCells, math.floor(lastTarget / m.widthCells))) refused[lastTarget] = true end end local key = bfsNextKey(wx, wy, refused) if not key then -- The warp may be walled off by a Cut tree -- the Vermilion Gym -- door at (12,19) sits behind one -- so walkOntoWarp could never -- reach it and travelTo(VERMILION_GYM) spun "did not move" -- forever. Cut a blocking tree and retry; only give up if there -- is none (a genuinely unreachable warp). -- -- And do NOT give up on the first nil. bfsNextKey walls off -- every NPC cell -- including the TARGET -- and NPCs wander: a -- villager standing on (or stepping toward) the door mat makes -- it "unreachable" for exactly as long as their pause lasts. -- Giving up instantly is how every Poké Center anchor at -- Lavender failed ("could not walk onto the door at (3,5)") -- while the heal point silently stayed a town behind, and tower -- deaths then replayed the whole Celadon->Lavender walk. Wait -- them out; only a path that stays missing is real. if cuts < 3 and cutToward and cutToward(wx, wy) then cuts = cuts + 1 bfsNil = 0 -- way opened; loop re-paths -- ...or by a Silph Co card-key door: the stairs the map planner -- routes through sit behind locked doors on most Silph floors, -- so travelTo(SILPH_CO_11F) could never re-climb the building -- after a death broke the route's own segment sequence. elseif openDoorToward and openDoorToward(wx, wy) then bfsNil = 0 -- ...or by a Pokémon Mansion gate in the wrong switch state -- (segment 162 stranded the bot at B1F (4,11) exactly here). elseif not toggledSwitch and toggleMansionSwitch and toggleMansionSwitch(wx, wy) then toggledSwitch = true bfsNil = 0 -- ...or a Victory Road barrier whose boulder switch is not -- pressed. travelTo aims at the 1F ladder warp@1,1 straight -- through the closed barrier block; solve the floor's switches -- and re-path. Once per call. elseif not solvedVR and solveVictoryRoadSwitches and VR_SWITCHES and VR_SWITCHES[ow().map.id] then solvedVR = true solveVictoryRoadSwitches() bfsNil = 0 -- ...or an item ball on the only corridor (Pokémon Tower 6F's -- RARE_CANDY): take it and re-path. elseif not tookBall and pickupBlockingBall and pickupBlockingBall(wx, wy) then tookBall = true bfsNil = 0 else bfsNil = (bfsNil or 0) + 1 if bfsNil >= 8 then -- One actionable blocker before giving up: a sleeping -- SNORLAX with the POKE FLUTE aboard. The route's own -- flute segment can get skipped in a desync, after which -- every trip past Route 16's junction dead-ends here -- ("no path ... SPRITE_SNORLAX@(26,10)") forever. if wakeBlockingSnorlax and wakeBlockingSnorlax("walkOntoWarp") then bfsNil = 0 else local p2 = ow().player local who = {} for _, npc in ipairs(ow().npcs) do who[#who + 1] = ("%s@(%d,%d)"):format( tostring(npc.def and (npc.def.id or npc.def.sprite) or "?"), npc.cellX, npc.cellY) end say(("walkOntoWarp (%d,%d) on %s: no path from (%d,%d); npcs: %s") :format(wx, wy, tostring(ow().map.id), p2.cellX, p2.cellY, #who > 0 and table.concat(who, " ") or "none")) return false end end U.wait(20) -- let wanderers step aside / transitions settle end elseif walk(key) then local dv = DELTA[key] if dv then local W = m.widthCells lastFrom = cell lastTarget = (math.floor(cell / W) + dv[2]) * W + (cell % W + dv[1]) end else U.wait(4) end end end end return ow().map.id ~= from end -- --------------------------------------------------------------------- -- cross-map travel -- --------------------------------------------------------------------- -- -- The route travels by HEAL POINT: segment 72 leaves the Pokémon Fan Club -- with `fieldMove dig`, and DIG warps to wLastBlackoutMap. A speedrun never -- heals at Vermilion, so the route can assume that lands in Cerulean; our -- survival systems visit any nurse the party is hurt in front of, so it -- does not. Those two facts cannot both stand, and suppressing the nurse -- is the wrong half to give up -- so travel to a named map instead of -- trusting where a warp happened to drop us. -- -- Plans over map ADJACENCY -- connections and warps together -- because -- Vermilion -> Cerulean is ROUTE_6 -> UNDERGROUND_PATH_ROUTE_6 -> -- UNDERGROUND_PATH_NORTH_SOUTH -> UNDERGROUND_PATH_ROUTE_5 -> ROUTE_5 -> -- CERULEAN_CITY, whose middle is warps and whose ends are connections. A -- connection-only search cannot find it at all. local COMPASS_DIR = { north = "up", south = "down", west = "left", east = "right" } -- Walkability of an arbitrary map's cell, without loading it. -- -- Mirrors Map:cellTile + Map:isWalkableCell (src/world/Map.lua) -- the -- bottom-left 8x8 tile of the cell, against the tileset's walkable list. -- We need this for maps we are NOT standing on, and MapLoader builds only -- the current one, so the lookup is reproduced here rather than shared. local function defWalkable(def, cx, cy) local ts = G.data.tilesets and G.data.tilesets[def.tileset] if not ts or not ts.blocks or not ts.walkable then return false end local tx, ty = cx * 2, cy * 2 + 1 local bx, by = math.floor(tx / 4), math.floor(ty / 4) local id if bx < 0 or by < 0 or bx >= def.width or by >= def.height then id = def.borderBlock else id = def.blocks[by * def.width + bx + 1] end local block = ts.blocks[(id or 0) + 1] if not block then return false end local tile = block[(ty % 4) * 4 + (tx % 4) + 1] for _, w in ipairs(ts.walkable) do if w == tile then return true end end return false end -- The border cells of `def` on `dir` whose opposite number across the seam -- is walkable too, nearest first once the caller sorts them. -- -- crossConnection places you at destX = curX - offset*2 (clamped), so the -- pairing is exact and a seam with no walkable pair can never be crossed -- however hard we push at it. This is what keeps the search from planning -- Vermilion -> Saffron -> Cerulean through a walled city. -- GLOBAL (not local): ops.goto_'s edge-exit retarget below needs it and -- is defined earlier in the file; the main chunk is also at LuaJIT's -- 200-local ceiling. function seamCells(def, dir, conn) local dest = G.data.maps and G.data.maps[conn.map] if not dest then return {} end local aW, aH = def.width * 2, def.height * 2 local bW, bH = dest.width * 2, dest.height * 2 local out = {} if dir == "north" or dir == "south" then local ay = (dir == "north") and 0 or aH - 1 local by = (dir == "north") and bH - 1 or 0 for x = 0, aW - 1 do local dx = x - conn.offset * 2 if dx >= 0 and dx < bW and defWalkable(def, x, ay) and defWalkable(dest, dx, by) then out[#out + 1] = { x, ay } end end else local ax = (dir == "west") and 0 or aW - 1 local bx = (dir == "west") and bW - 1 or 0 for y = 0, aH - 1 do local dy = y - conn.offset * 2 if dy >= 0 and dy < bH and defWalkable(def, ax, y) and defWalkable(dest, bx, dy) then out[#out + 1] = { ax, y } end end end return out end -- The collision tile of an arbitrary map's cell (for ledge matching), -- mirroring Map:cellTile without loading the map. local function defCellTile(def, cx, cy) local ts = G.data.tilesets and G.data.tilesets[def.tileset] if not ts or not ts.blocks then return nil end local tx, ty = cx * 2, cy * 2 + 1 local bx, by = math.floor(tx / 4), math.floor(ty / 4) local id if bx < 0 or by < 0 or bx >= def.width or by >= def.height then id = def.borderBlock else id = def.blocks[by * def.width + bx + 1] end local block = ts.blocks[(id or 0) + 1] return block and block[(ty % 4) * 4 + (tx % 4) + 1] end -- --------------------------------------------------------------------- -- region-aware map graph -- --------------------------------------------------------------------- -- -- A plain map graph treats each map as ONE node, which is wrong for maps a -- gate building or a ledge splits into parts no single walk connects. -- ROUTE_16 is three such regions, SAFFRON's four routes each cross through -- a gate, ROUTE_8/11/12 are each cut in two. Planning over map nodes there -- makes travelTo try a connection it cannot reach from its current side, -- fail, and re-plan the long way -- which walked a Saffron trip back to -- ROUTE_9, half the map away. -- -- So nodes are (map, REGION): a connected component of the map's walkable -- cells. Warps, connections and ledges connect specific regions, so a plan -- can only cross where the current side actually reaches. -- mapId -> { reg = {cellId -> regionIndex}, wc, n }. Flood-filled once. local REGION_CACHE = {} local function mapRegions(mapId) if REGION_CACHE[mapId] then return REGION_CACHE[mapId] end local def = G.data.maps[mapId] local wc, hc = def.width * 2, def.height * 2 local reg, n = {}, 0 for y = 0, hc - 1 do for x = 0, wc - 1 do local id = y * wc + x if defWalkable(def, x, y) and not reg[id] then n = n + 1 reg[id] = n local stack = { { x, y } } while #stack > 0 do local c = stack[#stack]; stack[#stack] = nil for _, d in ipairs(DIRS) do local nx, ny = c[1] + d[1], c[2] + d[2] local nid = ny * wc + nx if nx >= 0 and ny >= 0 and nx < wc and ny < hc and defWalkable(def, nx, ny) and not reg[nid] then reg[nid] = n stack[#stack + 1] = { nx, ny } end end end end end end local r = { reg = reg, wc = wc, n = n } REGION_CACHE[mapId] = r return r end local function cellRegionOf(mapId, x, y) local r = mapRegions(mapId) return r.reg[y * r.wc + x] end local function regionNode(mapId, reg) return mapId .. "#" .. tostring(reg) end -- The region graph: node -> list of edges, each carrying how to EXECUTE the -- crossing (a warp cell, a connection dir+seam, or a ledge hop). local REGION_GRAPH local function regionGraph() if REGION_GRAPH then return REGION_GRAPH end local maps = G.data.maps local g, into = {}, {} local function add(a, b, e) e.to = b; g[a] = g[a] or {}; g[a][#g[a] + 1] = e end -- who warps into each map, for LAST_MAP resolution for id, def in pairs(maps) do for _, w in ipairs(def.warps or {}) do if type(w.destMap) == "string" and w.destMap ~= "LAST_MAP" and maps[w.destMap] then into[w.destMap] = into[w.destMap] or {} into[w.destMap][id] = true end end end for id, def in pairs(maps) do -- warps (concrete destination) for _, w in ipairs(def.warps or {}) do local d = w.destMap local sr = cellRegionOf(id, w.x, w.y) if d == "LAST_MAP" then -- resolve against each map that feeds this one, at destWarp's cell for src in pairs(into[id] or {}) do local dw = maps[src].warps and maps[src].warps[w.destWarp] local dr = dw and cellRegionOf(src, dw.x, dw.y) if sr and dr then local c = (id:find("^SEAFOAM") or src:find("^SEAFOAM")) and 43 or 3 add(regionNode(id, sr), regionNode(src, dr), { warp = { x = w.x, y = w.y }, cost = c, dynamic = true }) end end elseif type(d) == "string" and maps[d] then local dw = maps[d].warps and maps[d].warps[w.destWarp] local dr = dw and cellRegionOf(d, dw.x, dw.y) if sr and dr and not (d:find("^SEAFOAM") and not id:find("^SEAFOAM")) then -- entering Seafoam pruned, same reason as the map graph local c = (id:find("ELEVATOR") or d:find("ELEVATOR")) and 50 or 1 add(regionNode(id, sr), regionNode(d, dr), { warp = { x = w.x, y = w.y }, cost = c }) end end end -- connections (seam pairs, grouped by region pair) for dir, conn in pairs(def.connections or {}) do if maps[conn.map] and COMPASS_DIR[dir] then local cells = seamCells(def, dir, conn) local dest = maps[conn.map] local seen = {} for _, c in ipairs(cells) do local sr = cellRegionOf(id, c[1], c[2]) -- the paired cell on the far side local bx, by if dir == "north" then bx, by = c[1] - conn.offset * 2, dest.height * 2 - 1 elseif dir == "south" then bx, by = c[1] - conn.offset * 2, 0 elseif dir == "west" then bx, by = dest.width * 2 - 1, c[2] - conn.offset * 2 else bx, by = 0, c[2] - conn.offset * 2 end local dr = cellRegionOf(conn.map, bx, by) local k = tostring(sr) .. ">" .. tostring(dr) if sr and dr and not seen[k] then seen[k] = true -- ROUTE_20 <-> CINNABAR: pruned, same reason as the map -- graph (the east and west seas never connect) if not ((id == "ROUTE_20" and conn.map == "CINNABAR_ISLAND") or (id == "CINNABAR_ISLAND" and conn.map == "ROUTE_20")) then add(regionNode(id, sr), regionNode(conn.map, dr), { dir = COMPASS_DIR[dir], cells = cells, cost = 1 }) end end end -- A seam with NO walkable pair is a pure-water border. The map -- graph keeps those as expensive-but-real edges (see its -- comment); this builder silently dropped them, which made -- CINNABAR_ISLAND a structural island up here -- every -- travelTo(CINNABAR_*) then fell to the region-blind flat -- planner for the WHOLE trip, and its proposals stranded runs -- on ROUTE_22/15/18 once banned. Which shore region actually -- touches the water is execution's problem (mountSurfToward): -- connect every region pair and price the uncertainty. if not next(seen) and not ((id == "ROUTE_20" and conn.map == "CINNABAR_ISLAND") or (id == "CINNABAR_ISLAND" and conn.map == "ROUTE_20")) then local srcR = mapRegions(id) local dstR = mapRegions(conn.map) for sr = 1, srcR.n do for dr = 1, dstR.n do add(regionNode(id, sr), regionNode(conn.map, dr), { dir = COMPASS_DIR[dir], cells = cells, cost = 8 }) end end end end end -- ledge hops (one-way region connectors) local wc, hc = def.width * 2, def.height * 2 for y = 0, hc - 1 do for x = 0, wc - 1 do if defWalkable(def, x, y) then for _, d in ipairs(DIRS) do local fx, fy = x + d[1], y + d[2] if fx >= 0 and fy >= 0 and fx < wc and fy < hc then local st, ft = defCellTile(def, x, y), defCellTile(def, fx, fy) for _, ledge in ipairs(G.data.field.ledges or {}) do if (ledge.tileset or "OVERWORLD") == def.tileset and ledge.facing == d[3] and ledge.input == d[3] and ledge.standingTile == st and ledge.ledgeTile == ft then local lx, ly = fx + d[1], fy + d[2] if lx >= 0 and ly >= 0 and lx < wc and ly < hc and defWalkable(def, lx, ly) then local sr, dr = cellRegionOf(id, x, y), cellRegionOf(id, lx, ly) if sr and dr and sr ~= dr then add(regionNode(id, sr), regionNode(id, dr), { ledge = { x = x, y = y, dir = d[3] }, cost = 1 }) end end end end end end end end end -- Cut trees bridge regions. A cuttable tree is a wall in the static -- tiles, so the flood fill splits the map at it -- but CUT opens it, -- and the route relies on that: ROUTE_16 cuts a tree at ~(34,8) that -- joins the Celadon-side strip to the east-top, the only walking link -- between Route 16's two halves. Without this the fly-house pocket is a -- dead end and the run dies there. Connect the regions on either side -- of each cut tree, both ways, executed by walking onto the far cell -- (ops.goto_ cuts the tree in the way). local swaps = G.data.field.cutTreeSwaps or {} local isCut = {} for _, sw in ipairs(swaps) do isCut[sw.before] = true end for y = 0, hc - 1 do for x = 0, wc - 1 do local block = defWalkable(def, x, y) and nil or (function() local bx, by = math.floor(x / 2), math.floor(y / 2) if bx < 0 or by < 0 or bx >= def.width or by >= def.height then return def.borderBlock end return def.blocks[by * def.width + bx + 1] end)() if block and isCut[block] then -- walkable orthogonal neighbours of the tree cell local nbr = {} for _, d in ipairs(DIRS) do local nx, ny = x + d[1], y + d[2] if nx >= 0 and ny >= 0 and nx < wc and ny < hc and defWalkable(def, nx, ny) then local r = cellRegionOf(id, nx, ny) if r then nbr[#nbr + 1] = { x = nx, y = ny, r = r } end end end for a = 1, #nbr do for b = 1, #nbr do if a ~= b and nbr[a].r ~= nbr[b].r then add(regionNode(id, nbr[a].r), regionNode(id, nbr[b].r), { cut = { x = nbr[b].x, y = nbr[b].y }, cost = 2 }) end end end end end end end REGION_GRAPH = g return g end -- Cheapest region-path from the current position to any region of `dest`. -- Returns a list of edges (each with warp/dir/ledge execution info). local function planRegionRoute(fromNode, dest, banned) local g = regionGraph() local dist, prev = { [fromNode] = 0 }, {} local settled = {} local goal while true do local cur, best for k, dv in pairs(dist) do if not settled[k] and (best == nil or dv < best) then cur, best = k, dv end end if not cur then break end if cur:match("^(.-)#") == dest then goal = cur; break end settled[cur] = true for _, e in ipairs(g[cur] or {}) do local bk = cur .. "|" .. (e.warp and (e.warp.x .. "," .. e.warp.y) or e.ledge and ("L" .. e.ledge.x .. "," .. e.ledge.y) or e.cut and ("T" .. e.cut.x .. "," .. e.cut.y) or ("C" .. tostring(e.dir))) .. "|" .. e.to if not (banned and banned[bk]) then local nd = best + e.cost + seamCost(cur:match("^(.-)#"), e.to:match("^(.-)#")) if dist[e.to] == nil or nd < dist[e.to] then dist[e.to] = nd prev[e.to] = { node = cur, edge = e, bk = bk } end end end end if not goal then return nil end local path, cur = {}, goal while prev[cur] do local p = prev[cur] table.insert(path, 1, { edge = p.edge, bk = p.bk }) cur = p.node end return path end -- map id -> list of outgoing edges. Built once; the map data never changes. local MAP_GRAPH local function mapGraph() if MAP_GRAPH then return MAP_GRAPH end local maps = G.data.maps or {} local g, into = {}, {} for id, def in pairs(maps) do g[id] = g[id] or {} for _, w in ipairs(def.warps or {}) do local d = w.destMap if type(d) == "string" and d ~= "LAST_MAP" and maps[d] then -- Elevators are costly, so travelTo prefers stairs. Walking into an -- elevator is a bounce-trap for a plain warp cross: the car opens a -- floor MENU and its default exit dumps you on the reciprocal warp, -- oscillating ELEVATOR <-> floor. rideElevator drives the menu when -- we are actually in the car, but travelTo must route AROUND the -- elevator wherever stairs exist (all of Celadon Mart). Priced high -- rather than deleted so a floor only the elevator can reach (Silph's -- top) is still on the graph. local elev = (id:find("ELEVATOR") or d:find("ELEVATOR")) and 50 or 1 -- ENTERING Seafoam is pruned outright: 1F's two door pockets -- only connect through the BASEMENT floors, which hop execution -- cannot thread, so every plan through the islands ping-ponged -- the Route 20 doors -- pricing alone lost to a wall of banned -- alternatives after hours of wandering. The route never uses -- Seafoam (Cinnabar is reached by sea from Pallet). Exits (the -- LAST_MAP door edges) are kept so a bot stranded inside can -- always leave. if not (d:find("^SEAFOAM") and not id:find("^SEAFOAM")) then g[id][#g[id] + 1] = { to = d, warp = w, cost = elev } end into[d] = into[d] or {} into[d][id] = true end end for dir, c in pairs(def.connections or {}) do if maps[c.map] and COMPASS_DIR[dir] then -- A seam with no walkable pair is map data that describes two -- adjacent maps you cannot actually walk between. Keep the edge -- but make it expensive rather than deleting it: a Cut tree or a -- gate script can open one later, and a wrongly deleted edge is -- an unreachable map, while a wrongly costly one is a detour. local cells = seamCells(def, dir, c) -- ROUTE_20's east and west seas NEVER connect on the map: a -- walled channel splits them at Seafoam, and the only crossing -- is through the islands' basement, which hop execution cannot -- thread. The map graph sees one ROUTE_20 node, so from the -- east side the CINNABAR seam looked one hop away, and one -- travelTo(PALLET_TOWN) spent HOURS banning its way across -- Kanto until this impossible seam was the cheapest edge left, -- then camped it. PRUNED, not priced -- this is a hard physical -- fact, not a maybe-a-tree-opens-it seam, and both sides stay -- reachable without it (CINNABAR via ROUTE_21, ROUTE_20 via -- ROUTE_19). The executable Cinnabar path is Pallet -> -- ROUTE_21 south, the HOF run's own. if not ((id == "ROUTE_20" and c.map == "CINNABAR_ISLAND") or (id == "CINNABAR_ISLAND" and c.map == "ROUTE_20")) then g[id][#g[id] + 1] = { to = c.map, dir = COMPASS_DIR[dir], cells = cells, cost = #cells > 0 and 1 or 8 } end end end end -- A LAST_MAP warp goes back wherever we came IN from, which is not known -- until we are standing there. For planning, the candidates are every map -- that warps into this one; we re-plan after each hop, so guessing wrong -- costs one replan rather than a wrong route. Priced above a concrete -- edge so a known-destination hop always wins a tie. for id, def in pairs(maps) do for _, w in ipairs(def.warps or {}) do if w.destMap == "LAST_MAP" then for src in pairs(into[id] or {}) do local c = (id:find("^SEAFOAM") or src:find("^SEAFOAM")) and 43 or 3 g[id][#g[id] + 1] = { to = src, warp = w, dynamic = true, cost = c } end end end end MAP_GRAPH = g return g end -- Cheapest hop list from `from` to `to`, or nil. `banned` is a set of -- seamKeys this trip has already proved useless. local function planMapRoute(from, to, banned) local g = mapGraph() if from == to then return {} end local dist, prev = { [from] = 0 }, {} -- Dijkstra over a handful of hundreds of nodes: a linear scan for the -- nearest unsettled node is far cheaper than the heap it would replace. local settled = {} while true do local cur, best for id, d in pairs(dist) do if not settled[id] and (best == nil or d < best) then cur, best = id, d end end if not cur or cur == to then break end settled[cur] = true for _, e in ipairs(g[cur] or {}) do local k = edgeId(cur, e) -- `banned` is per-TRIP and per-WARP (see edgeId): within one travelTo -- a specific warp that just looped us back must not be re-planned, but -- its siblings across the same seam stay available. Across trips the -- memory only makes a seam dearer -- see seamCost. -- -- The caller stores map-planner bans under "|map|" -- (namespaced away from the region planner's keys); checking only -- the bare edgeId here meant NO map-plan ban ever matched, and a -- warp that could not be reached was re-proposed for the whole -- 90-hop budget -- the Mansion 1F "warp@4,27 did not move" loop. if not (banned and (banned[k] or banned[cur .. "|map|" .. k])) then local nd = best + e.cost + seamCost(cur, e.to) if dist[e.to] == nil or nd < dist[e.to] then dist[e.to] = nd prev[e.to] = { map = cur, edge = e } end end end end if not prev[to] then return nil end local path, cur = {}, to while prev[cur] do table.insert(path, 1, prev[cur].edge) cur = prev[cur].map end return path end -- Walk off the map edge in `dir`, taking the connection. Returns true if -- the map changed. local function crossSeam(edge) local m = ow().map local p = ow().player local from = m.id local W, H = m.widthCells, m.heightCells -- Prefer a border cell whose far side is walkable; fall back to any -- walkable border cell when the seam data gave us none, so a connection -- we mispriced can still be tried. local cands = {} for _, c in ipairs(edge.cells or {}) do cands[#cands + 1] = { c[1], c[2] } end if #cands == 0 then if edge.dir == "up" or edge.dir == "down" then local by = (edge.dir == "up") and 0 or H - 1 for x = 0, W - 1 do if m:isWalkableCell(x, by) then cands[#cands + 1] = { x, by } end end else local bx = (edge.dir == "left") and 0 or W - 1 for y = 0, H - 1 do if m:isWalkableCell(bx, y) then cands[#cands + 1] = { bx, y } end end end end -- A seam whose border is ALL SEA (Pallet -> Route 21, the only way to -- Cinnabar) has no walkable candidates at all. With SURF aboard the -- water cells are candidates too: ops.goto_'s unreachable ladder mounts -- at the shore (mountSurfToward) and BFS is water-aware once up. if #cands == 0 and slotKnowing("SURF") and (G.save.inventory or {}).SOULBADGE then if edge.dir == "up" or edge.dir == "down" then local by = (edge.dir == "up") and 0 or H - 1 for x = 0, W - 1 do if m:isWaterCell(x, by) then cands[#cands + 1] = { x, by } end end else local bx = (edge.dir == "left") and 0 or W - 1 for y = 0, H - 1 do if m:isWaterCell(bx, y) then cands[#cands + 1] = { bx, y } end end end end table.sort(cands, function(a, b) return math.abs(a[1] - p.cellX) + math.abs(a[2] - p.cellY) < math.abs(b[1] - p.cellX) + math.abs(b[2] - p.cellY) end) -- Out-of-bounds coordinates are ops.goto_'s "leave through this edge" -- idiom: it clamps back to the border cell, paths there, then holds the -- direction across the seam. -- -- Several candidates, because a single one makes crossing flaky: BFS -- treats an NPC as a wall, and Vermilion's seam cells sit among six -- wandering sprites, so the same crossing succeeded and failed within one -- session depending on where they had drifted. Trying more of the seam -- costs a little walking and removes most of that. for k = 1, math.min(#cands, 8) do local cx, cy = cands[k][1], cands[k][2] local ox = (edge.dir == "left" and -1) or (edge.dir == "right" and W) or cx local oy = (edge.dir == "up" and -1) or (edge.dir == "down" and H) or cy ops.goto_({ x = ox, y = oy }) if ow().map.id ~= from then return true end end return false end -- How many hops a trip may take. The longest legitimate one in the route -- (Vermilion -> Cerulean underground) is six; the cap only exists so a -- pair of seams that each bounce us back cannot loop forever. -- Generous: a gate can cost several wrong-door tries before the right one -- (Route 16 has eight warps to sift), and each is one hop. The cap only -- exists so a genuinely impossible trip cannot spin forever. local MAX_TRAVEL_HOPS = 90 -- Distinct maps a single travelTo may touch before it is declared lost. -- The fly-replacement town-to-town trips are long -- ROUTE_16 -> LAVENDER -- runs Celadon, Route 7, Saffron, Route 8, and the two gates that split -- Route 16 and Route 11 each add a couple of sub-maps to sift. The old cap -- of 12 cut those off and stranded the fly, then it wandered. It can be -- this loose safely now that GRINDING no longer travels (grindALevel does a -- single crossSeam), which was the only case that wandered map-wide. local MAX_TRAVEL_MAPS = 30 -- Times one cell may be looped back onto before travelTo gives up entirely. local MAX_TRAVEL_REVISITS = 8 -- Times a connection may fail to cross before travelTo bans it for the trip. -- One miss is common (an NPC on the seam, an odd DIG landing); a persistent -- miss means it is genuinely shut from here. local CONNECTION_CROSS_TRIES = 3 -- Execute one graph hop -- a warp, a connection cross, or a ledge hop -- -- the same way for a region edge or a map edge. -- Wait until the player is standing on a real, in-bounds cell. A seam -- crossing has a window where map.id has flipped but the position has not -- been remapped yet -- travelTo read "SAFFRON_CITY:-1:22" and -- "ROUTE_7:20:14" there, poisoning its visited/ban sets and leaving BFS -- planning from an out-of-bounds origin, which is what "cross left did -- not move x90" after Silph actually was. local function settleCell() for _ = 1, 90 do local o = ow() local p = o.player if inBattle() then fightBattle() elseif busy() then mashUntilIdle() elseif p.cellX >= 0 and p.cellY >= 0 and p.cellX < o.map.widthCells and p.cellY < o.map.heightCells and not p.moving then return true else U.wait(4) end end return false end local function doHop(edge) if edge.warp then walkOntoWarp(edge.warp.x, edge.warp.y) elseif edge.ledge then -- one-way ledge: stand on the cell and hold the direction; the engine -- does the two-cell jump (walk() waits out its hop frames) ops.goto_({ x = edge.ledge.x, y = edge.ledge.y }) faceDir(edge.ledge.dir) walk(edge.ledge.dir) elseif edge.cut then -- cross a cut tree: walk onto the far-side cell. ops.goto_ cuts a tree -- walling off the target on its own (cutToward), then walks through. ops.goto_({ x = edge.cut.x, y = edge.cut.y }) else crossSeam({ dir = edge.dir, cells = edge.cells }) end end -- Walk the map graph to `dest`, independent of the heal point. -- -- Plans over the REGION graph (a gate-split map is several nodes), so it can -- only propose a crossing the current side actually reaches -- no more -- trying a Saffron exit from the wrong region and wandering off. Falls back -- to the plain map graph when the player's cell has no region (an odd warp -- tile), so the older behaviour is still there as a net. local function travelTo(dest, where) if not (G.data.maps and G.data.maps[dest]) then say(("travelTo: no such map %s"):format(tostring(dest))) return false end local banned = {} local start = ow().map.id local function posKey() local p = ow().player return ("%s:%d:%d"):format(ow().map.id, p.cellX, p.cellY) end local visited = { [posKey()] = true } local revisits = {} -- cell -> times looped back onto it -- Safety leash: even with region planning, a genuinely unreachable dest -- (waiting on Surf, a badge) must not spin forever. local mapsSeen, mapCount = { [start] = true }, 1 -- The region planner routes gate-split maps correctly (Saffron, Route 11) -- but a few gates -- Route 16's two-level LAST_MAP maze -- have edge-warp -- crossings walkOntoWarp cannot execute, where the older map planner's -- crossSeam / reEnterThroughGate could. So after this many region hops in -- a row fail to move, drop to the map planner for the rest of the trip: -- region planning where it helps, the proven map planner as the floor, so -- this never leaves the run worse than the map-only baseline. local REGION_FAIL_LIMIT = 6 local regionFails, useMap = 0, false local secondWind = false local gateRescued = {} -- edges already given their one gate rescue local ropedOut = false -- one ESCAPE_ROPE bail per trip (see below) for _ = 1, MAX_TRAVEL_HOPS do local here = ow().map.id if not mapsSeen[here] then mapsSeen[here] = true mapCount = mapCount + 1 if mapCount > MAX_TRAVEL_MAPS then say(("travelTo %s: wandered across %d maps without arriving (on %s) " .. "-- giving up"):format(tostring(dest), mapCount, here)) note("travelTo: wandered too far to " .. tostring(dest), where) return false end end if here == dest then if here ~= start then say(("travelled %s -> %s"):format(tostring(start), tostring(dest))) end return true end -- Region-aware plan first (unless we have fallen back); map-level plan -- as the fallback. local p = ow().player local reg = not useMap and cellRegionOf(here, p.cellX, p.cellY) local hop, banKey, fromRegion if reg then local rp = planRegionRoute(regionNode(here, reg), dest, banned) if rp and #rp > 0 then hop, banKey, fromRegion = rp[1].edge, rp[1].bk, true end end if not hop then local mp = planMapRoute(here, dest, banned) if mp and #mp > 0 then hop = mp[1] banKey = here .. "|map|" .. edgeId(here, mp[1]) end end if not hop then -- Dead-ended inside a dungeon with an ESCAPE ROPE aboard: rope to -- the heal point and re-plan from there, exactly what a lost player -- does. The Mansion's switch-gate deadlock (1F stairs room vs B1F -- return path wanting opposite states) is the case; the engine -- refuses the rope anywhere it is illegal, so this cannot fire in -- the overworld. if not ropedOut and (heldCount("ESCAPE_ROPE") or 0) > 0 and useFieldItem("ESCAPE_ROPE", where) then ropedOut = true local landed = ow().map.id say(("travelTo %s: no way there from %s -- escape-roped to %s") :format(tostring(dest), tostring(here), tostring(landed))) if tostring(landed):find("POKECENTER") then leaveByDoor(landed) end banned = {} else say(("travelTo %s: no way there from %s"):format(tostring(dest), tostring(here))) note("travelTo: no route to " .. tostring(dest), where) return false end end -- roped out above with no hop chosen: re-enter the loop and plan -- from wherever the rope landed us if not hop then goto nextTravelHop end local hopDesc = hop.warp and ("warp@" .. hop.warp.x .. "," .. hop.warp.y) or hop.ledge and ("ledge@" .. hop.ledge.x .. "," .. hop.ledge.y) or hop.cut and ("cut@" .. hop.cut.x .. "," .. hop.cut.y) or ("cross " .. tostring(hop.dir)) if os.getenv("POKEPORT_TRAVEL_DEBUG") then local wr = hop.warp and cellRegionOf(here, hop.warp.x, hop.warp.y) say(("[travel] at %s (%d,%d) reg=%s useMap=%s -> %s (warpReg=%s)") :format(here, p.cellX, p.cellY, tostring(reg), tostring(useMap), hopDesc, tostring(wr))) if reg and not travelDumped then travelDumped = true local nodeKey = regionNode(here, reg) local g = regionGraph() local seen, q, h = { [nodeKey] = true }, { nodeKey }, 1 local reachMaps = {} while h <= #q do local cur = q[h]; h = h + 1 reachMaps[cur:match("^(.-)#")] = true for _, e in ipairs(g[cur] or {}) do if not seen[e.to] then seen[e.to] = true; q[#q + 1] = e.to end end end say((" [reach] from %s: CELADON=%s LAVENDER=%s POKEMON_TOWER_6F=%s " .. "SAFFRON=%s"):format(nodeKey, tostring(reachMaps.CELADON_CITY ~= nil), tostring(reachMaps.LAVENDER_TOWN ~= nil), tostring(reachMaps.POKEMON_TOWER_6F ~= nil), tostring(reachMaps.SAFFRON_CITY ~= nil))) end end settleCell() -- A warp the flat planner proposes can sit in a DIFFERENT connected -- region of this map than we do: ROCK_TUNNEL_1F's north alcove -- (15,0) is walled off from the (15,3) landing pocket, and the flat -- graph prices all four LAST_MAP warps identically -- so the trip -- burned its whole budget knocking on structurally unreachable doors -- forever. The region planner scopes edges correctly; give the flat -- fallback the same eyes at execution time and ban the hop on the -- spot instead of paying walkOntoWarp's full retry budget to learn it. if hop.dir and (not hop.cells or #hop.cells == 0) and not (slotKnowing("SURF") and (G.save.inventory or {}).SOULBADGE) then -- a pure-water seam before the party can surf: ban silently -- the -- planner has land alternatives, and executing it would only spin -- the mount machinery for nothing banned[banKey] = true goto nextTravelHop end -- The four Saffron gates are one guard with one thirst: without a -- drink (or the flag his script sets on taking one) every crossing -- is a scripted shove-back, and retrying it read as a stuck loop -- -- one run lost all five attempts to the ROUTE_6_GATE shove. Ban -- silently until the pass condition is real. do local SAFFRON_GATES = { ROUTE_5_GATE = true, ROUTE_6_GATE = true, ROUTE_7_GATE = true, ROUTE_8_GATE = true } -- ANY hop whose far side is Saffron: the city has no drink-free -- entrance at all, and the ROUTE_6 border connection (a plain -- seam in the map data, not a gate-map hop) funneled every -- blackout-recovery walk straight into the guard's shove. -- Region hops carry "MAP#region" names -- strip the suffix or the -- match silently misses (it did, for five straight attempts). local dst = tostring(hop.to or ""):match("^([^#]+)") or "" local entering = SAFFRON_GATES[dst] or dst == "SAFFRON_CITY" if entering then local pass = (G.save.flags or {}).EVENT_GAVE_GUARDS_DRINK if not pass then for _, d in ipairs({ "FRESH_WATER", "SODA_POP", "LEMONADE" }) do if (heldCount(d) or 0) > 0 then pass = true break end end end if not pass then banned[banKey] = true goto nextTravelHop end end end -- Region sanity is only meaningful when we are physically ON the map -- the plan reads (`here`); mid-desync the player can stand elsewhere, -- and computing their cell against the wrong map's region table -- produced garbage bans that severed real paths (ROUTE_2's seams, -- one pinballing attempt 2). if hop.warp and ow().map.id == here then local pp = ow().player local pr = cellRegionOf(here, pp.cellX, pp.cellY) local wr = cellRegionOf(here, hop.warp.x, hop.warp.y) if pr and wr and pr ~= wr then banned[banKey] = true say(("travelTo %s: warp@%d,%d on %s is in another region -- banning") :format(tostring(dest), hop.warp.x, hop.warp.y, here)) goto nextTravelHop end elseif hop.dir and hop.cells and hop.cells[1] and ow().map.id == here then -- Same blindness, connection flavour: ROUTE_10's south seam only -- touches its Lavender-side region, and proposing "cross down" -- from the Cerulean side sent goto_ hacking at trees and diving -- into the tunnel pocket forever. If no walkable seam cell shares -- the player's region, the crossing is not reachable on foot from -- here -- ban it and let the planner find the through-the-tunnel -- chain instead. local pp = ow().player local pr = cellRegionOf(here, pp.cellX, pp.cellY) if pr then local touches = false for _, c in ipairs(hop.cells) do if cellRegionOf(here, c[1], c[2]) == pr then touches = true break end end if not touches then banned[banKey] = true say(("travelTo %s: the %s seam on %s is in another region -- " .. "banning"):format(tostring(dest), tostring(hop.dir), here)) goto nextTravelHop end end end local before = posKey() doHop(hop) settleCell() local pk = posKey() if pk == before then -- did not move at all: this hop is shut from here, ban and re-plan banned[banKey] = true -- ...and REMEMBER it (a price, never a ban -- see seamCost). The -- notes were designed and documented up top but never wired in, so -- a seam that failed every lap of a trip stayed free on the next -- re-plan: the ROUTE_12 east loop retried its impossible west -- crossing at full price forever. Connections only -- warps already -- have per-warp ban granularity via edgeId. if hop.dir and not hop.warp and hop.to then noteSeam(here, hop.to) end -- A connection can be unreachable from THIS half of a gate-split -- map. The map planner sees both halves as one node, so from Route -- 7's east half it proposed the WEST seam into Celadon ninety times -- running -- the seam only touches the west half, and the gate in -- the middle is the only way across. Step through the gate toward -- a walkable cell on the seam's border, then let the re-plan try -- again from the far side. local gx, gy if hop.cells and hop.cells[1] then gx, gy = hop.cells[1][1], hop.cells[1][2] elseif hop.dir then -- no walkable seam pair recorded: scan this edge for any -- walkable border cell to aim the gate crossing at local m2 = ow().map local W2, H2 = m2.widthCells, m2.heightCells if hop.dir == "left" or hop.dir == "right" then local bx = hop.dir == "left" and 0 or W2 - 1 for y2 = 0, H2 - 1 do if m2:isWalkableCell(bx, y2) then gx, gy = bx, y2 break end end else local by = hop.dir == "up" and 0 or H2 - 1 for x2 = 0, W2 - 1 do if m2:isWalkableCell(x2, by) then gx, gy = x2, by break end end end end -- ONE gate rescue per edge. reEnterThroughGate can report success -- for a walk that changed nothing (no gate on the map), and -- un-banning the edge every time re-proposed the same dead hop -- forever -- Cinnabar's "cross up" was retried for the full 90-hop -- budget while the sea sat uncrossed to the west. if gx and not gateRescued[banKey] and reEnterThroughGate(gx, gy) then gateRescued[banKey] = true banned[banKey] = nil say(("travelTo %s: crossed a gate on %s toward the seam; retrying") :format(tostring(dest), here)) end if fromRegion then regionFails = regionFails + 1 if regionFails >= REGION_FAIL_LIMIT and not useMap then useMap = true banned = {} -- the map planner has its own edge keys; start clean say(("travelTo %s: region hops keep failing on %s -- switching to " .. "the map planner"):format(tostring(dest), here)) end end say(("travelTo %s: %s (%s) did not move; re-planning") :format(tostring(dest), here, hopDesc)) elseif visited[pk] then -- looped back onto a cell we already stood on: ban the hop that did it banned[banKey] = true -- a crossing that only ever leads back where we came from earns the -- same seam price as one that refuses outright (see above) if hop.dir and not hop.warp and hop.to then noteSeam(here, hop.to) end -- A loop counts against the region planner too, or a connection that -- crossSeam keeps landing on the same border cell (Pewter -> Route 3 -- from an awkward spot a grind left us in) spins forever without ever -- reaching the "did not move" branch that triggers the fallback. if fromRegion then regionFails = regionFails + 1 if regionFails >= REGION_FAIL_LIMIT and not useMap then useMap = true banned = {} say(("travelTo %s: region hops keep looping on %s -- switching to " .. "the map planner"):format(tostring(dest), here)) end end -- Hard anti-livelock: the same cell revisited far too many times means -- neither planner is getting anywhere. One SECOND WIND first: the -- fallback to the map planner is trip-wide, and on a cut-tree-split -- map (Route 2's Diglett pocket, on the only land route to Pallet) -- it is blind to the region bridges the region planner knows -- so a -- few flaky early hops used to condemn the whole trip to a planner -- that could never finish it. Reset to region planning once, with -- clean slates, before actually giving up. revisits[pk] = (revisits[pk] or 0) + 1 if revisits[pk] > MAX_TRAVEL_REVISITS then if not secondWind then secondWind = true useMap = false regionFails = 0 banned = {} visited = {} revisits = {} say(("travelTo %s: second wind -- back to region planning from %s") :format(tostring(dest), tostring(ow().map.id))) else say(("travelTo %s: stuck revisiting %s -- giving up") :format(tostring(dest), pk)) note("travelTo: livelocked reaching " .. tostring(dest), where) return false end end say(("travelTo %s: looped back to %s; trying another way") :format(tostring(dest), pk)) else -- genuine progress: a region hop that worked resets the fail streak if fromRegion then regionFails = 0 end -- a connection that carried us across is forgiven its past flakes if hop.dir and not hop.warp and hop.to and ow().map.id == hop.to then clearSeam(here, hop.to) end visited[pk] = true end visited._last = pk ::nextTravelHop:: end say(("travelTo %s: gave up after %d hops, on %s") :format(tostring(dest), MAX_TRAVEL_HOPS, ow().map.id)) note("travelTo: too many hops to " .. tostring(dest), where) return ow().map.id == dest end -- Reach a warp cell by ARRIVING through it rather than walking to it. -- -- A dungeon floor is not one connected room, and the route crosses between -- its pockets by going up or down and back. MT_MOON_B1F is the case that -- forced this: it is four pockets joined only through B2F and 1F, so from -- warp 5 at (21,17) there is no path at all to warp 7 at (23,3) -- the -- unreachable log named it exactly, "npcs: none", because nothing was in -- the way; there is simply no floor between them. -- -- Warps are two-ended and the data says which end is which, so this is -- exact rather than a search: MT_MOON_B2F's warp 4 at (5,7) declares -- destWarp 7 into MT_MOON_B1F, and taking it lands us ON (23,3). Find the -- far end, travel to that map, step through, and we arrive at the target -- instead of failing to walk to it. -- Cells we can currently walk to, NPCs counted as walls exactly as -- bfsNextKey sees them. Shared by the "walled off" recoveries below. function reachableSet() local m, p = ow().map, ow().player local W, H = m.widthCells, m.heightCells local blocked = {} -- only this map's NPCs; see bfsNextKey for why a foreign one is poison for _, npc in ipairs(ow().npcs) do if npc.cellX >= 0 and npc.cellY >= 0 and npc.cellX < W and npc.cellY < H then blocked[npc.cellY * W + npc.cellX] = true end end local seen = { [p.cellY * W + p.cellX] = true } local q, head = { { p.cellX, p.cellY } }, 1 while head <= #q do local c = q[head]; head = head + 1 for _, d in ipairs(DIRS) do local nx, ny = c[1] + d[1], c[2] + d[2] local id = ny * W + nx if nx >= 0 and ny >= 0 and nx < W and ny < H and not seen[id] and not blocked[id] and passableCell(m, nx, ny) and not pairBlockedStep(m, c[1], c[2], nx, ny, p.surfing) then seen[id] = true q[#q + 1] = { nx, ny } elseif nx >= 0 and ny >= 0 and nx < W and ny < H and not passableCell(m, nx, ny) then -- ledge hops, so this set matches what bfsNextKey can walk -- (see cutToward for the ROUTE_9 case this fixed) local lx, ly = ledgeLanding(m, c[1], c[2], d[3], d[1], d[2]) if lx then local lid = ly * W + lx if not seen[lid] and not blocked[lid] then seen[lid] = true q[#q + 1] = { lx, ly } end end end end end return seen, W end -- Pick up an item ball that is walling off the walk to (tx, ty). -- -- An item ball is an NPC and therefore a BFS wall -- and Pokémon Tower -- 6F's RARE_CANDY at (6,8) stands in the ONLY corridor between the -- floor's east and west halves. Vanilla players grab it in passing (the -- ball despawns, opening the way) and the route's own pickup steps do -- the same -- but a resume desync that skips those steps leaves the -- floor severed for good: "goto (6,14) unreachable on POKEMON_TOWER_6F" -- with a fresh save's balls all standing. So: when the target is -- outside the reachable set and a ball sits on its frontier, stand -- beside it, take it, and let the caller re-plan. Same one-per-call -- contract as cutToward. pickingBall = false -- global: the main chunk is at the 200-local ceiling function pickupBlockingBall(tx, ty) if pickingBall then return false end local m = ow().map local seen, W = reachableSet() if seen[ty * W + tx] then return false end -- not a blocking-ball problem for _, npc in ipairs(ow().npcs) do if tostring(npc.def and npc.def.sprite or "") == "SPRITE_POKE_BALL" then local bx, by = npc.cellX, npc.cellY for _, d in ipairs(DIRS) do local ax, ay = bx + d[1], by + d[2] if ax >= 0 and ay >= 0 and ax < W and ay < m.heightCells and seen[ay * W + ax] then pickingBall = true local took = false ops.goto_({ x = ax, y = ay }) local p = ow().player if ow().map.id == m.id and p.cellX == ax and p.cellY == ay then faceDir((by < ay and "up") or (by > ay and "down") or (bx < ax and "left") or "right") press("a") U.wait(20) mashUntilIdle() took = not ow():npcAtCell(bx, by) end pickingBall = false if took then say(("picked up the item ball at (%d,%d) that was walling off " .. "(%d,%d)"):format(bx, by, tx, ty)) return true end end end end end return false end -- Reach another region of THIS map by going through a gate and out its far -- door. -- -- The case arriveByWarp cannot take. ROUTE_16 is split in two by the gate -- building at x 18-23: west (x<=17) holds the FLY house, east (x>=24) leads -- to CELADON_CITY, and row 10 is the only crossing -- -- (17,10) -> ROUTE_16_GATE_1F -> (24,10). Both sides are the SAME map, so -- travelTo thinks we have arrived; and every one of the gate's warps is -- LAST_MAP with no destWarp, so arriveByWarp has no far end to aim at. -- The result was a bot sealed in the western pocket reporting -- "ROUTE_16 -> CELADON_CITY did not cross" until it ran out of rewinds. -- -- So: step into a gate we CAN reach, then come back out through a door we -- did not use, and see whether the target is reachable now. local goingThroughGate = false function reEnterThroughGate(tx, ty) if goingThroughGate then return false end local hereId = ow().map.id local seen, W = reachableSet() if seen[ty * W + tx] then return false end -- not a region problem -- Doors we can walk to that lead INTO a named sub-map, nearest first. -- -- The destMap filter is a safety rail, and it was learned the hard way. -- Without it this entered ANY reachable warp -- including a building's -- own LAST_MAP exit -- so an ordinary unreachable waypoint (a Vermilion -- Gym trash can the puzzle had walled off for the moment) sent the bot -- WALKING OUT OF THE GYM and back in, over and over, until the process -- hung and was killed. A real gate crossing goes through a named map -- (ROUTE_16 -> ROUTE_16_GATE_1F); a LAST_MAP door just dumps us outside -- the current area, which is never the way to another region OF THIS map. local doors = {} for i, w in ipairs(ow().map.def.warps or {}) do if seen[w.y * W + w.x] and type(w.destMap) == "string" and w.destMap ~= "LAST_MAP" and G.data.maps[w.destMap] then doors[#doors + 1] = { idx = i, warp = w } end end local p = ow().player table.sort(doors, function(a, b) return math.abs(a.warp.x - p.cellX) + math.abs(a.warp.y - p.cellY) < math.abs(b.warp.x - p.cellX) + math.abs(b.warp.y - p.cellY) end) goingThroughGate = true local ok = false for k = 1, math.min(#doors, 3) do local door = doors[k] if not walkOntoWarp(door.warp.x, door.warp.y) then break end local inner = ow().map.id -- try the inner map's other exits back to where we came from local tried = 0 for _, w2 in ipairs(ow().map.def.warps or {}) do local back = w2.destMap if (back == hereId or back == "LAST_MAP") and not (w2.x == ow().player.cellX and w2.y == ow().player.cellY) then tried = tried + 1 -- 8, not 4: ROUTE_16_GATE_1F has EIGHT LAST_MAP doors back to -- ROUTE_16 -- four per half, north half listed first -- so a cap -- of 4 tried only the side we came in from and the crossing -- always "failed" while the south doors sat untried. if tried > 8 then break end if walkOntoWarp(w2.x, w2.y) and ow().map.id == hereId then local seen2, W2 = reachableSet() if seen2[ty * W2 + tx] then say(("crossed %s via %s and came out the far side; (%d,%d) is " .. "reachable now"):format(hereId, tostring(inner), tx, ty)) ok = true break end -- wrong side: go back in and try another door if not walkOntoWarp(door.warp.x, door.warp.y) then break end end end end if ok then break end -- make sure we are back on the map we started from before trying again if ow().map.id ~= hereId then local out = findWarpTo(hereId) or findWarpTo("LAST_MAP") or findWarpTo("") if out then walkOntoWarp(out.x, out.y) end end end goingThroughGate = false return ok end local arrivingByWarp = false function arriveByWarp(tx, ty) if arrivingByWarp then return false end local m = ow().map local hereId = m.id local w = m.warpAtCell and m:warpAtCell(tx, ty) if not w then return false end local idx = w.index -- every (map, warp) pair whose far end is this exact warp local cands = {} for mapId, def in pairs(G.data.maps or {}) do for _, other in ipairs(def.warps or {}) do if other.destMap == hereId and other.destWarp == idx then cands[#cands + 1] = { map = mapId, warp = other } end end end if #cands == 0 then return false end arrivingByWarp = true local ok = false for k = 1, math.min(#cands, 3) do local c = cands[k] say(("goto (%d,%d) has no floor leading to it on %s; arriving through " .. "%s's warp at (%d,%d) instead") :format(tx, ty, hereId, c.map, c.warp.x, c.warp.y)) if travelTo(c.map, hereId) then walkOntoWarp(c.warp.x, c.warp.y) local p = ow().player if ow().map.id == hereId and p.cellX == tx and p.cellY == ty then ok = true break end end end arrivingByWarp = false if ok then say(("arrived at (%d,%d) on %s"):format(tx, ty, hereId)) end return ok end -- Talk to the nurse and accept, mirroring tests/drivers/heal_test.lua: -- welcome text -> ChoiceBox -> YES -> machine animation -> farewell. local function talkToNurse(where) local nurse = findObject("_NURSE") if not nurse then note("center: no nurse", where) say(("center on %s: no nurse object on %s"):format( tostring(where), tostring(ow().map.id))) return false end -- her counter sits between you and her, so she is talked to from two -- tiles below facing up (heal_test stands at (3,3) for a nurse at (3,1)) if not ops.goto_({ x = nurse.x, y = nurse.y + 2 }) then note("center: cannot reach the counter", where) say(("center on %s: could not reach the counter at (%d,%d)"):format( tostring(where), nurse.x, nurse.y + 2)) return false end faceDir("up") press("a") U.wait(10) -- accept the prompt; ChoiceBox defaults to YES at index 1. Mashing A is -- safe in here -- unlike a shop, no screen in a Poké Center spends money. for _ = 1, 60 do if isChoice() then cursorTo("index", 1) press("a") break end if idle() then break end press("a") U.wait(5) end -- busy() already covers ow.healAnim, so idle() waits out the machine for _ = 1, 200 do if idle() then break end U.wait(6) end return mashUntilIdle() end local function totalHP() local t = 0 for _, mon in ipairs(party()) do t = t + (mon.hp or 0) end return t end -- Leave the route, heal, and come back to the tile we left from. -- Returns true only if HP actually went up: talkToNurse ends in -- mashUntilIdle, which reports success for *reaching the overworld again*, -- not for healing. Trusting that is what made the bot walk into the centre, -- out again and straight back in on every following segment. -- `forHealPoint` visits for the RESPAWN, not the HP. -- -- talkToNurse writes wLastBlackoutMap (SetLastBlackoutMap), and that is -- worth a detour on its own: without it the heal point stays wherever it -- last happened to be, and a gym loss respawns a town behind. Judging the -- visit by whether HP rose is exactly wrong here -- we go in healthy on -- purpose -- so a heal-point visit is never counted as a failure and never -- triggers the "nurse healed nothing" backoff. local function visitPokeCenter(where, forHealPoint) local fromMap = ow().map.id local door = findWarpTo("POKECENTER") if not door then return false end -- no centre on this map local before = totalHP() if not walkOntoWarp(door.x, door.y) then note("center: could not get in", where) say(("center on %s: could not walk onto the door at (%d,%d)"):format( tostring(where), door.x, door.y)) return false end talkToNurse(where) local healed = totalHP() > before -- The whole point of a forHealPoint visit is the nurse WRITING the -- respawn (SetLastBlackoutMap). Silence here is what hid the Route 10 -- anchor failing on every pass: the log said "re-anchoring at ROUTE_10" -- while wipes kept waking in CERULEAN, because the talk never happened -- and nothing said so. local centreMap = ow().map.id if forHealPoint and not (G.save.lastHeal and G.save.lastHeal.map == centreMap) then say(("center on %s: nurse did NOT take the heal point (still %s)") :format(tostring(where), tostring(G.save.lastHeal and G.save.lastHeal.map))) end if forHealPoint then healed = true end if not healed then note("center: nurse restored nothing", where) end -- the centre's exit warps both lead to LAST_MAP local out = findWarpTo("LAST_MAP") or findWarpTo("") if out then walkOntoWarp(out.x, out.y) end if ow().map.id ~= fromMap then note("center: did not get back to " .. fromMap, where) end -- Deliberately NOT walking back to where we stood before healing. -- -- Every route step is an absolute goto, so the segment re-paths from -- wherever we are and the backtrack buys nothing. It costs plenty: the -- saved tile is wherever the previous segment happened to stop, and when -- that is the edge of the map -- Viridian's southern row, if we had just -- walked up from Route 1 -- "returning" marches back down to the seam and -- slips into the next map, desyncing the run for good. return healed end -- Whether a fight is worth staying in: true if the bag holds something that -- restores HP, or the map we are standing on has a centre to walk back to. -- Read mid-battle by lowOnHP, which is why it is only assigned here. canRestoreHP = function() for _, id in ipairs(HP_ITEMS) do if heldCount(id) then return true end end return findWarpTo("POKECENTER") ~= nil end -- Top up between segments, independently of the route's own heal steps. -- -- PokeBotBad asks for a heal exactly where a speedrun needs one, and it -- assumes levels we do not reach. Left to the route's heal steps alone the -- party grinds down over a couple of maps and blacks out -- which is how -- the first runs ended, every time in Viridian Forest. -- Two thresholds again, for the same reason the flee guard has two: a -- potion is scarce, a nurse is free. Spend an item only once a mon is -- clearly hurt, but never walk out of a town with a centre less than full -- -- the stretch that keeps killing us (Viridian Forest into Route 2) has -- no centre anywhere along it, so whatever we leave Viridian with is all -- we get. local AUTO_HEAL_AT = 0.5 -- worth spending an item on local CENTER_TOP_UP_AT = 0.95 -- worth walking to a free nurse for -- segments to wait before trying another nurse, after one healed nothing local centerCooldown = 0 -- The town whose Poké Center last wrote the heal point. A blackout sends us -- to wLastBlackoutMap, so when this falls behind the route the respawn is -- somewhere we finished with, and every death replays the walk back. local healAnchor -- worst HP fraction in the party, and the slot holding it local function worstFraction() local slot = mostHurt() if not slot then return 1, nil end local mon = party()[slot] local max = mon.stats and mon.stats.hp or 0 if max <= 0 then return 1, nil end return mon.hp / max, slot end -- How healthy we insist on being before walking into a map's fights. -- -- This is the "how likely is the next trainer to kill me" judgement, made -- from what we can actually see up front (we do not know the enemy until -- the battle opens): how much of the party is still standing, and how -- often this map has killed us before. function riskThreshold(mapId) local usable = 0 for _, mon in ipairs(party()) do if (mon.hp or 0) > 0 then usable = usable + 1 end end local need = AUTO_HEAL_AT -- a one-mon party has no second chance: a single bad matchup ends the -- run outright, so demand real headroom rather than half a bar if usable <= 1 then need = math.max(need, 0.8) end -- and every death here buys more caution, up to "arrive essentially full" local d = dangerAt(mapId) if d > 0 then need = math.min(0.95, need + 0.15 * math.min(d, 3)) end return need end -- `allowCenter` gates the Poké Center detour, and only the segment-boundary -- call passes it. -- -- A centre visit WARPS us -- it is not a walk we can undo -- and a map can be -- split into regions a walk cannot cross. Cerulean is exactly that: a fence -- divides it and the only ways through are two pass-through houses. Healing -- part-way through the segment that crosses to the east half teleported the -- bot back to the centre in the west half, where every remaining waypoint was -- unreachable and the attempt died in a run of skips. Mid-segment healing is -- items only; the nurse is a between-segments decision. function autoHeal(where, need, allowCenter) if not idle() then return end need = need or AUTO_HEAL_AT local frac = worstFraction() -- the nurse is free and heals the whole party, so top up on the way past -- rather than waiting for anything to get dangerous if allowCenter and frac < math.max(need, CENTER_TOP_UP_AT) and centerCooldown <= 0 and findWarpTo("POKECENTER") then if visitPokeCenter(where) then return end -- The walk there put no HP back. Retrying it next segment is how the -- bot ends up marching in and out of the same door forever, so stand -- down for a while and fall through to items instead. centerCooldown = 12 say("nurse healed nothing on " .. tostring(where) .. "; not walking back for 12 segments") end if frac >= need then return end -- several passes: one potion rarely tops up a badly hurt mon, and a -- multi-mon party can have more than one below the line for _ = 1, 6 do local f, slot = worstFraction() if not slot or f >= need then return end local before = party()[slot].hp ops.heal({}, where) -- ops.heal reports "no HP item" by noting and returning true, so stop -- on anything that did not actually raise HP rather than spinning local after = party()[slot] and party()[slot].hp or 0 if after <= before then return end end end -- Menu-driven ops still to implement. Each is a real menu interaction -- (bag, party, PC) rather than something the route can express, so they -- are stubbed loudly instead of guessed at. -- The route names moves in its own vocabulary; these are the items that -- teach them. Anything missing falls through to the stub and is logged. local TEACH_ITEMS = { cut = "HM_CUT", fly = "HM_FLY", surf = "HM_SURF", strength = "HM_STRENGTH", flash = "HM_FLASH", -- NB: no `thrash`. Gen 1 has no Thrash TM -- the route's five `teach -- thrash` steps on Route 24 are accepting a LEVEL-UP learn prompt, which -- mashUntilIdle already answers with A. Left unmapped on purpose. dig = "TM_DIG", bubblebeam = "TM_BUBBLEBEAM", earthquake = "TM_EARTHQUAKE", thunderbolt = "TM_THUNDERBOLT", horn_drill = "TM_HORN_DRILL", rock_slide = "TM_ROCK_SLIDE", ice_beam = "TM_ICE_BEAM", mega_punch = "TM_MEGA_PUNCH", mega_kick = "TM_MEGA_KICK", } -- pickupTossJunk consults this when the bag is full: anything a teach -- step will want is protected from the toss for _, item in pairs(TEACH_ITEMS) do WD.keepItems[item] = true end -- Party slot of the first species in `prefs` that we actually own. The route -- lists alternatives ({"oddish","paras"}) because which one it caught varies. local function slotForSpecies(prefs) if type(prefs) == "string" then prefs = { prefs } end local roster = party() for _, want in ipairs(prefs or {}) do local target = tostring(want):upper() for i, mon in ipairs(roster) do if tostring(mon.species):upper() == target then return i end end end return nil end function slotKnowing(moveId) for i, mon in ipairs(party()) do for _, mv in ipairs(mon.moves or {}) do if tostring(mv.id):upper() == moveId then return i end end end end -- Teach an HM/TM. Same bag walk as a healing item: the party menu at the end -- picks who learns it. function ops.teach(s, where) local key = tostring(s.move or ""):lower() local item = TEACH_ITEMS[key] if not item then note("teach:" .. key, where) return true end -- Idempotent: a blackout replays the segment, and a TM is consumed on -- use -- so the second pass found "no TM_EARTHQUAKE in the bag" and -- failed a step whose work was already done (the move was on the lead -- from the first pass). Somebody already knowing the move IS success. if slotKnowing(key:upper()) then say(("teach %s: already known -- skipping"):format(key)) return true end if (heldCount(item) or 0) <= 0 then note("teach: no " .. item, where) say(("teach %s: no %s in the bag"):format(key, item)) return false end -- Who can actually learn it. The route names a species (its TMs are aimed -- at the Nidoking it assumes we evolved), and when that mon is not in the -- party the old fallback was "slot 1" -- which handed THUNDERBOLT to a -- WARTORTLE, whose tmhm list does not contain it, so the TM was spent for -- nothing and the log blamed the teach. Compatibility is data we already -- have; consult it rather than guessing. local function canLearn(mon) local sp = mon and G.data.pokemon[mon.species] for _, m in ipairs(sp and sp.tmhm or {}) do if m == key:upper() then return true end end return false end local slot = slotForSpecies(s.mon) if slot and not canLearn(party()[slot]) then slot = nil end if not slot then for i, mon in ipairs(party()) do if canLearn(mon) then slot = i break end end end if not slot then note("teach: nobody can learn " .. key, where) say(("teach %s: nobody in the party can learn it"):format(key)) return false end local who = party()[slot] say(("teaching %s to %s (slot %d)"):format(key, tostring(who and who.species or "?"), slot)) local ok = useItemOn(item, slot, where) -- A full moveset opens MoveLearnMenu. useItemOn used to mash A/B right -- through it (B answers NO on the YES/NO boxes), abandoning the move the -- item was spent on -- DIG silently failed on a level-30 WARTORTLE that -- way, and losing CUT the same way walled the run off at Route 9's tree. -- useItemOn now returns with the learn flow untouched the moment it -- appears (see the guard there), and driveLearnMenu answers the two -- prompts by intent ("Delete an older move?" YES, "Abandon learning?" -- NO), picks a forgettable non-HM row, and prefers the route step's own -- `replace` hint when it names a safe move. -- -- MoveLearnMenu is NOT on top when it appears: its :enter() immediately -- pushes the TryingToLearn TextBox, whose last page raises the YES/NO -- ChoiceBox -- which is why the state scan below searches the whole -- stack rather than testing top().newMoveId. local function learnMenu() for _, st in ipairs(G.stack.states or {}) do if st.newMoveId and st.mon and st.index then return st end end end if waitFor(learnMenu, 30) then for _ = 1, 60 do if not learnMenu() then break end driveLearnMenu(s.replace) end mashUntilIdle() say(("%s forgot a move to learn %s"):format( tostring(who and who.species or "?"), key)) end -- The learn lands a frame or two after the menus close, so checking -- immediately reported "did NOT learn it" for a TM that had in fact -- worked -- DIG said it failed and then got used two segments later. -- Give it a moment before calling it. local learned = waitFor(function() return slotKnowing(key:upper()) ~= nil end, 20) if learned then say(("%s learned %s"):format(tostring(who and who.species or "?"), key)) return true end say(("teach %s: %s did NOT learn it"):format(key, tostring(who and who.species or "?"))) return false end -- Use a field move from the party menu (start_sub_menus.asm). CUT/SURF/ -- STRENGTH/FLY all live there, gated on the matching badge -- CUT needs -- CASCADEBADGE (src/ui/PartyMenu.lua:342). function ops.fieldMove(s, where) local move = tostring(s.move or ""):upper() -- the tree cell CUT will face, for the post-use verification below local cutCellX, cutCellY local slot = slotKnowing(move) if not slot then -- The HM/TM may be sitting IN THE BAG with its teach step lost to a -- desync (Victory Road's boulders once arrived with HM_STRENGTH -- unowned; had it been owned-but-untaught this is the save). Teaching -- here costs one menu trip; ops.teach no-ops cleanly when absent. local item = TEACH_ITEMS[tostring(s.move or ""):lower()] if item and (heldCount(item) or 0) > 0 then say(("fieldMove %s: nobody knows it but %s is in the bag -- " .. "teaching now"):format(move, item)) if ops.teach({ move = tostring(s.move or ""):lower() }, where) then slot = slotKnowing(move) end end end if not slot then -- DIG and FLY are TRANSPORT, and a transport step we cannot perform is -- a walk we have not taken yet rather than a dead end. Segment 72 is -- the case: `fieldMove dig` out of the Pokémon Fan Club is the whole of -- the route's Vermilion -> Cerulean trip, and if TM_DIG never made it -- into the bag this returned false, segments 73-81 all skipped against -- CERULEAN_CITY, and the rewind looped over Vermilion forever -- with a -- won gym behind it. Walking there is slower and always available. if (move == "DIG" or move == "FLY") and nextMapWanted and ow().map.id ~= nextMapWanted then -- DIG out of a dungeon has an item equivalent we may be carrying: -- the ESCAPE ROPE warps to the heal point exactly as DIG does. The -- Mansion is why this matters: segment 162 ends `fieldMove dig` -- and WALKING out instead runs into the switch-gate deadlock (the -- 1F stairs room and the B1F return path want opposite states, so -- the cross-floor statue press ping-pongs forever). The engine -- refuses the rope on maps where it is illegal, so a failed use -- falls through to the walk unharmed. if move == "DIG" and (heldCount("ESCAPE_ROPE") or 0) > 0 then if useFieldItem("ESCAPE_ROPE", where) then local landed = ow().map.id say(("fieldMove DIG: nobody knows it -- escape-roped to %s") :format(tostring(landed))) if tostring(landed):find("POKECENTER") then leaveByDoor(landed) end if ow().map.id == nextMapWanted then return true end end end say(("fieldMove %s: nobody knows it -- walking to %s instead") :format(move, tostring(nextMapWanted))) if travelTo(nextMapWanted, where) then return true end end note("fieldMove: nobody knows " .. move, where) say(("fieldMove %s: nobody in the party knows it"):format(move)) return false end -- Field moves work from fainted users in Gen 1 (PartyMenu + partyKnows -- no longer gate on hp > 0). No Poké Center detour needed here. -- CUT acts on the tile the player is FACING (OverworldState:tryCut), and -- a `goto` leaves us facing whatever direction we last stepped. Without -- turning to the tree first the move fires into empty ground, the game -- says "There isn't anything", and the rewind loop retries it forever. -- So find the tree ourselves: a cuttable block is one listed in -- field.cutTreeSwaps, on a cell that is not walkable. if move == "CUT" then local m = ow().map local function cuttable(cx, cy) if not m:inBounds(cx, cy) or m:isWalkableCell(cx, cy) then return false end -- The same FACE gate as the engine (tryCut): a tree BLOCK is 2x2 -- cells and only the tree-tile half is cuttable -- facing the -- fence/sign half answers "Nothing to CUT". Without this the scan -- below picked whichever unwalkable half it met first: Route 9's -- tree at (5,8) has the block's sign half at (4,9), DOWN is -- scanned before RIGHT, and the run cut "the tree" twenty times -- from (4,8) without ever opening the way east. local ts = m.def and m.def.tileset local tile = m:cellTile(cx, cy) if not ((ts == "OVERWORLD" and tile == 0x3d) or (ts == "GYM" and tile == 0x50)) then return false end local block = m:blockAt(math.floor(cx / 2), math.floor(cy / 2)) for _, sw in ipairs(G.data.field.cutTreeSwaps or {}) do if sw.before == block then return true end end return false end local p = ow().player local facing for _, d in ipairs({ { 0, 1, "down" }, { 0, -1, "up" }, { -1, 0, "left" }, { 1, 0, "right" } }) do if cuttable(p.cellX + d[1], p.cellY + d[2]) then facing = d[3] cutCellX, cutCellY = p.cellX + d[1], p.cellY + d[2] break end end if not facing then note("fieldMove: no tree adjacent", where) say(("fieldMove CUT: no cuttable tree next to (%d,%d) on %s") :format(p.cellX, p.cellY, tostring(where))) return false end faceDir(facing) end -- SURF mounts only when the FACING tile is water (ItemUseSurfboard -- -- else _NoSurfingHereText and the submenu loops), and a goto leaves us -- facing wherever we last stepped. The route names the direction when -- it knows it; otherwise face whichever neighbour is water. if move == "SURF" then local m, p = ow().map, ow().player local facing = s.face if not facing then for _, d in ipairs({ { 0, 1, "down" }, { 0, -1, "up" }, { -1, 0, "left" }, { 1, 0, "right" } }) do if m:inBounds(p.cellX + d[1], p.cellY + d[2]) and m:isWaterCell(p.cellX + d[1], p.cellY + d[2]) then facing = d[3] break end end end if not facing then note("fieldMove: no water adjacent", where) say(("fieldMove SURF: no water next to (%d,%d) on %s") :format(p.cellX, p.cellY, tostring(where))) return false end faceDir(facing) end -- A `teach` immediately precedes this in the route, and its bag/party -- menus are still unwinding when we get here. Opening START on top of -- that lands us somewhere else entirely, and the failure was silent -- -- Vermilion taught CUT and then walked into the tree because this step -- did nothing at all, so the gym segment skipped. mashUntilIdle() press("start") U.wait(8) local menu = top() local row for i, it in ipairs(menu and menu.items or {}) do if it.label == "POKéMON" then row = i break end end if not row or not cursorTo("index", row) then note("fieldMove: no POKéMON row", where) say(("fieldMove %s: the START menu never opened"):format(move)) backOut() return false end press("a") U.wait(10) if not cursorTo("index", slot) then note("fieldMove: no slot " .. slot, where) say(("fieldMove %s: could not select party slot %d"):format(move, slot)) backOut() return false end press("a") U.wait(10) -- The per-mon submenu (STATS / SWITCH / field moves) is NOT a separate -- stack entry: PartyMenu keeps it on itself as .submenu / .subItems, with -- its own cursor in .subIndex (src/ui/PartyMenu.lua:327,379). Reading -- top().items here scans the party list instead and finds no CUT, which -- looked exactly like the badge gate refusing -- it was not; the save had -- CASCADEBADGE all along. local pm = top() if not (pm and pm.submenu and pm.subItems) then note("fieldMove: no submenu", where) say(("fieldMove %s: the party submenu never opened"):format(move)) backOut() return false end local mrow for i, it in ipairs(pm.subItems) do if it.label == move then mrow = i break end end if not mrow then local have = {} for _, it in ipairs(pm.subItems) do have[#have + 1] = it.label end note("fieldMove: no " .. move .. " entry", where) say(("fieldMove %s: submenu offered %s"):format(move, table.concat(have, "/"))) backOut() return false end -- .subIndex, not .index -- the party cursor is a different field if not cursorTo("subIndex", mrow) then note("fieldMove: submenu cursor stuck", where) say(("fieldMove %s: could not move the submenu cursor to row %d") :format(move, mrow)) backOut() return false end press("a") U.wait(12) mashUntilIdle() -- CUT is verifiable: the faced tree cell must be walkable afterwards. -- "used CUT" used to print regardless, so an engine refusal ("Nothing -- to CUT") looked like success and the caller re-cut the same tree -- for its whole budget. if move == "CUT" and cutCellX then local now = ow().map if now:inBounds(cutCellX, cutCellY) and not now:isWalkableCell(cutCellX, cutCellY) then say(("fieldMove CUT: the tree at (%d,%d) is still standing -- the " .. "cut did not land"):format(cutCellX, cutCellY)) return false end end say(("used %s on %s"):format(move, tostring(where))) -- DIG and FLY are the route's long-distance travel, and neither lands -- where the route thinks. DIG warps to wLastBlackoutMap: the route takes -- segment 72's `fieldMove dig` out of the Pokémon Fan Club expecting -- Cerulean, because a speedrun never heals at Vermilion -- but ours does, -- so the warp puts us straight back in Vermilion and segments 73-75 can -- never run. Walk the rest of the way rather than trusting the warp. if (move == "DIG" or move == "FLY") and nextMapWanted then -- Give the warp a moment to land before reading where we are. for _ = 1, 120 do if idle() and not inBattle() then break end if inBattle() then fightBattle() else mashUntilIdle() end end local here = ow().map.id if here ~= nextMapWanted then say(("%s landed on %s but the route wants %s -- travelling") :format(move, tostring(here), tostring(nextMapWanted))) travelTo(nextMapWanted, where) end end return true end -- Is this cell a cuttable tree? (A cutTreeSwaps block on an unwalkable -- cell -- the same test ops.fieldMove uses to find one to face.) local function cuttableCell(m, cx, cy) if not m:inBounds(cx, cy) or m:isWalkableCell(cx, cy) then return false end -- Mirror the engine's UsedCut gate (OverworldState:tryCut): CUT only -- works on the OVERWORLD tree tile and the GYM plant tile. Block ids -- are per-tileset, so matching them alone made every look-alike block -- on other tilesets read as a tree -- the Saffron/Celadon bush-spam, -- and a Route 23 (PLATEAU) "tree" whose swap crashed the renderer. local ts = m.def and m.def.tileset local tile = m:cellTile(cx, cy) if not ((ts == "OVERWORLD" and tile == 0x3d) or (ts == "GYM" and tile == 0x50)) then return false end local block = m:blockAt(math.floor(cx / 2), math.floor(cy / 2)) for _, sw in ipairs(G.data.field.cutTreeSwaps or {}) do if sw.before == block then return true end end return false end -- Reachability guard, so cutToward cannot recurse through ops.goto_. local cutting = false -- Cut a tree that is walling us off from (tx, ty). -- -- Finds the trees on the frontier of where we can currently walk, picks the -- one nearest the target, stands next to it and cuts. One tree per call -- -- the caller re-plans afterwards, so a pocket behind two trees opens over -- two passes rather than needing a search over combinations. function cutToward(tx, ty) if cutting then return false end if not slotKnowing("CUT") then return false end local m, p = ow().map, ow().player local W, H = m.widthCells, m.heightCells -- where we can get to right now, NPCs included as walls exactly as -- bfsNextKey sees them local blockedBy = {} -- only this map's NPCs; see bfsNextKey for _, npc in ipairs(ow().npcs) do if npc.cellX >= 0 and npc.cellY >= 0 and npc.cellX < W and npc.cellY < H then blockedBy[npc.cellY * W + npc.cellX] = true end end local function reach() local pp = ow().player local seen = { [pp.cellY * W + pp.cellX] = true } local queue, head = { { pp.cellX, pp.cellY } }, 1 while head <= #queue do local c = queue[head]; head = head + 1 for _, d in ipairs(DIRS) do local nx, ny = c[1] + d[1], c[2] + d[2] local id = ny * W + nx if nx >= 0 and ny >= 0 and nx < W and ny < H and not seen[id] and not blockedBy[id] and m:isWalkableCell(nx, ny) then seen[id] = true queue[#queue + 1] = { nx, ny } elseif nx >= 0 and ny >= 0 and nx < W and ny < H and not m:isWalkableCell(nx, ny) then -- ledge hops reach cells this flood otherwise misses -- and -- bfsNextKey WALKS them, so leaving them out makes the two -- disagree: from ROUTE_9 (46,9) the regrown west tree's face is -- only ledge-reachable, cutToward found "no tree", and -- travelTo(CINNABAR_ISLAND) died -- taking Blaine, the 7-badge -- Viridian Gym and the whole league gate with it. local lx, ly = ledgeLanding(m, c[1], c[2], d[3], d[1], d[2]) if lx then local lid = ly * W + lx if not seen[lid] and not blockedBy[lid] then seen[lid] = true queue[#queue + 1] = { lx, ly } end end end end end return seen end -- nearest reachable cell's Manhattan distance to the target: the honest -- measure of whether a cut helped at all local function bestDist(set) local bd for id in pairs(set) do local cx, cy = id % W, math.floor(id / W) local d = math.abs(cx - tx) + math.abs(cy - ty) if not bd or d < bd then bd = d end end return bd or math.huge end local seen = reach() if seen[ty * W + tx] then return false end -- not a tree problem local best, bestD, stand for id in pairs(seen) do local cx, cy = id % W, math.floor(id / W) for _, d in ipairs(DIRS) do local nx, ny = cx + d[1], cy + d[2] -- skip trees this run has already proven useless for ANY target -- twice over; they respawn on every map re-entry, so without the -- memory the loop is: cut, fail, leave, return, cut... (the -- "used CUT on ROUTE_12/ROUTE_10" x10 wedges) if cuttableCell(m, nx, ny) and (WD.futileCuts[m.id .. "#" .. nx .. "," .. ny] or 0) < 2 then local dist = math.abs(nx - tx) + math.abs(ny - ty) if not bestD or dist < bestD then best, bestD, stand = { nx, ny }, dist, { cx, cy } end end end end if not best then return false end local beforeDist = bestDist(seen) say(("goto (%d,%d) is walled off; cutting the tree at (%d,%d) from (%d,%d)") :format(tx, ty, best[1], best[2], stand[1], stand[2])) cutting = true ops.goto_({ x = stand[1], y = stand[2] }) local ok = ops.fieldMove({ move = "cut" }, ow().map.id) cutting = false if not (ok and not cuttableCell(ow().map, best[1], best[2])) then return false end -- The tree fell -- but did it HELP? A tree picked by straight-line -- distance can be a pure red herring whose corridor goes somewhere -- else entirely. Only count the cut as progress when the reachable -- set actually got closer to the target; otherwise remember the tree -- as futile so the next lap stops offering it. if ow().map.id == m.id then local after = reach() if after[ty * W + tx] or bestDist(after) < beforeDist then return true end local k = m.id .. "#" .. best[1] .. "," .. best[2] WD.futileCuts[k] = (WD.futileCuts[k] or 0) + 1 say(("the tree at (%d,%d) fell but (%d,%d) is no closer%s") :format(best[1], best[2], tx, ty, WD.futileCuts[k] >= 2 and " -- writing that tree off" or "")) return false end return true end -- Open a Silph Co card-key door that is walling us off from (tx, ty). -- -- The locked-door tile is not walkable and only opens on an A-press while -- FACING it (OverworldState:tryCardKeyDoor, engine/events/card_key.asm), -- so to BFS every closed door is a permanent wall. PokeBotBad opens them -- by bonking A on its way through, so the route has no explicit -- open-the-door steps -- without this rung, segment 148's walk to -- Giovanni reported "goto (2,15) unreachable" and the whole Silph ending -- was silently skipped (which is why the save had CARD_KEY, the beaten -- 11F trainers, and no EVENT_BEAT_SILPH_CO_GIOVANNI). -- -- Same contract as cutToward: one door per call, NPCs are walls, the -- caller re-plans afterwards. Door positions come from -- field.cardKeyDoors.doors (data/events/card_key.asm), so this is a -- lookup, not a tile scan; a door whose tile has already swapped to the -- open block is skipped. local openingDoor = false function openDoorToward(tx, ty) if openingDoor then return false end local ck = G.data.field.cardKeyDoors local m = ow().map local doors = ck and ck.doors and ck.doors[m.id] if not doors then return false end if not G.save.inventory.CARD_KEY then -- Loud, not silent: this bail is the moment "the pickup failed on 5F" -- becomes "the tower is a maze of walls", and it repeating is exactly -- what the stuck detector should get to see and stop on. say(("card-key door on %s is in the way but CARD_KEY is not in the bag") :format(tostring(m.id))) return false end local W, H = m.widthCells, m.heightCells local p = ow().player local blockedBy = {} for _, npc in ipairs(ow().npcs) do if npc.cellX >= 0 and npc.cellY >= 0 and npc.cellX < W and npc.cellY < H then blockedBy[npc.cellY * W + npc.cellX] = true end end local seen = { [p.cellY * W + p.cellX] = true } local queue, head = { { p.cellX, p.cellY } }, 1 while head <= #queue do local c = queue[head]; head = head + 1 for _, d in ipairs(DIRS) do local nx, ny = c[1] + d[1], c[2] + d[2] local id = ny * W + nx if nx >= 0 and ny >= 0 and nx < W and ny < H and not seen[id] and not blockedBy[id] and m:isWalkableCell(nx, ny) then seen[id] = true queue[#queue + 1] = { nx, ny } end end end if seen[ty * W + tx] then return false end -- not a door problem local function closedDoor(dx, dy) local tile = m:cellTile(dx, dy) if m.id == "SILPH_CO_11F" then return tile == ck.silphCo11F.doorTile end for _, t in ipairs(ck.doorTiles) do if tile == t then return true end end return false end local best, bestD, stand, standFace for _, d in ipairs(doors) do if closedDoor(d.x, d.y) then for _, dv in ipairs(DIRS) do local ax, ay = d.x + dv[1], d.y + dv[2] if seen[ay * W + ax] then local dist = math.abs(d.x - tx) + math.abs(d.y - ty) if not bestD or dist < bestD then -- face the OPPOSITE of the offset that took us to the stand -- cell: standing below the door means facing up local face = (dv[3] == "up" and "down") or (dv[3] == "down" and "up") or (dv[3] == "left" and "right") or "left" best, bestD, stand, standFace = d, dist, { ax, ay }, face end end end end end if not best then return false end say(("goto (%d,%d) is behind a card-key door; opening the one at " .. "(%d,%d) from (%d,%d)"):format(tx, ty, best.x, best.y, stand[1], stand[2])) openingDoor = true ops.goto_({ x = stand[1], y = stand[2] }) local ok = false local p2 = ow().player if ow().map.id == m.id and p2.cellX == stand[1] and p2.cellY == stand[2] then faceDir(standFace) press("a") U.wait(10) mashUntilIdle() -- "Bingo! The CARD KEY opened the door!" ok = not closedDoor(best.x, best.y) end openingDoor = false if not ok then say(("card-key door at (%d,%d) on %s did not open") :format(best.x, best.y, m.id)) end return ok end -- Press a Pokémon Mansion statue switch when its walls seal off (tx, ty). -- -- One shared toggle, EVENT_MANSION_SWITCH_ON, opens/closes gate blocks on -- all four floors (data/scripts/story6.lua MANSION_BLOCKS, from pokered's -- Mansion*CheckReplaceSwitchDoorBlocks). The route flips it via its own -- talk steps, but a missed talk or a re-planned walk leaves the floor in -- the wrong state and every plan across a closed gate reads "no path" -- -- segment 162 sealed the bot at B1F (4,11) with the exit stairs behind a -- gate, and travelTo burned every rewind against it. -- -- Statue cells from story6.lua's mansionFloor registrations; the player -- stands on the cell BELOW and faces up (all mansion switches face up). -- The press is TextBox -> ChoiceBox(YES) -> TextBox, which one A plus -- mashUntilIdle drives. One toggle per call: the caller re-plans, and if -- the flipped state is still wrong the NEXT call flips it back -- states -- alternate until the target's side is open. local MANSION_SWITCHES = { POKEMON_MANSION_1F = { { 2, 5 } }, POKEMON_MANSION_2F = { { 2, 11 } }, POKEMON_MANSION_3F = { { 10, 5 } }, POKEMON_MANSION_B1F = { { 20, 3 }, { 18, 25 } }, } local togglingSwitch = false -- Reachable set from where we stand, NPCs as walls (cutToward's fill). local function mansionReach() local m = ow().map local W, H = m.widthCells, m.heightCells local p = ow().player local blockedBy = {} for _, npc in ipairs(ow().npcs) do if npc.cellX >= 0 and npc.cellY >= 0 and npc.cellX < W and npc.cellY < H then blockedBy[npc.cellY * W + npc.cellX] = true end end local seen = { [p.cellY * W + p.cellX] = true } local queue, head = { { p.cellX, p.cellY } }, 1 while head <= #queue do local c = queue[head]; head = head + 1 for _, d in ipairs(DIRS) do local nx, ny = c[1] + d[1], c[2] + d[2] local id = ny * W + nx if nx >= 0 and ny >= 0 and nx < W and ny < H and not seen[id] and not blockedBy[id] and m:isWalkableCell(nx, ny) then seen[id] = true queue[#queue + 1] = { nx, ny } end end end return seen, W end -- Press any statue on the CURRENT floor that we can reach. All mansion -- switches are faced upward from the cell below. local function pressStatueHere() local m = ow().map local switches = MANSION_SWITCHES[m.id] if not switches then return false end local seen, W = mansionReach() for _, c in ipairs(switches) do local sx, sy = c[1], c[2] + 1 if seen[sy * W + sx] then ops.goto_({ x = sx, y = sy }) local p = ow().player if ow().map.id == m.id and p.cellX == sx and p.cellY == sy then faceDir("up") press("a") U.wait(10) mashUntilIdle() -- "Press it?" -> YES -> "Who wouldn't?" return true end end end return false end -- Push a STRENGTH boulder that blocks the walk to (tx, ty). -- -- PokeBotBad moves boulders by WALKING INTO them -- its Victory Road -- waypoints path straight "through" a boulder, shoving it a cell at a -- time -- but our BFS treats every NPC as a wall, so the route's pushes -- never happened: segment 183's boulder sat at (5,15) untouched, the 1F -- switch was never pressed, and 184-186 skipped against the closed -- barrier forever. -- -- One shove per call, the caller re-plans (the same contract as -- cutToward). The boulder chosen is the one nearest the straight line to -- the target; the push direction is the one whose landing cell is free -- and moves the boulder toward/past the target side, preferring the axis -- we approach on. STRENGTH is armed on demand (it clears on every map -- load -- push_boulder.asm). local pushingBoulder = false function pushBoulderToward(tx, ty) if pushingBoulder then return false end if not slotKnowing("STRENGTH") then return false end local m = ow().map local p = ow().player -- The boulder that matters is the one ON the waypoint (the route walks -- straight through boulders, so its gotos name the boulder's own cell -- while shoving it along) or, failing that, the one nearest the -- TARGET. Nearest-to-player picked Victory Road 1F's far corner -- boulder and shoved it around while the one the route was actually -- pushing sat untouched. local boulder, bestD for _, npc in ipairs(ow().npcs) do if tostring(npc.def and npc.def.sprite or ""):find("BOULDER") then if npc.cellX == tx and npc.cellY == ty then boulder, bestD = npc, 0 break end local d = math.abs(npc.cellX - tx) + math.abs(npc.cellY - ty) if not bestD or d < bestD then boulder, bestD = npc, d end end end if not boulder or bestD > 8 then return false end local bx, by = boulder.cellX, boulder.cellY pushingBoulder = true local ok = false if not ow().strengthActive then ops.fieldMove({ move = "strength" }, ow().map.id) end -- candidate push directions: stand on the opposite side, landing free local cands = {} for _, d in ipairs(DIRS) do local sx, sy = bx - d[1], by - d[2] -- stand cell local lx, ly = bx + d[1], by + d[2] -- landing cell if m:inBounds(sx, sy) and m:isWalkableCell(sx, sy) and not ow():npcAtCell(sx, sy) and m:inBounds(lx, ly) and m:isWalkableCell(lx, ly) and not ow():npcAtCell(lx, ly) then -- score: prefer the direction that carries the boulder toward the -- target (or onto it), and the stand cell being where we already are local score = 0 local dNow = math.abs(bx - tx) + math.abs(by - ty) local dAfter = math.abs(lx - tx) + math.abs(ly - ty) if dAfter < dNow then score = score + 2 end if p.cellX == sx and p.cellY == sy then score = score + 1 end cands[#cands + 1] = { d = d, sx = sx, sy = sy, score = score } end end table.sort(cands, function(a, b) return a.score > b.score end) for _, c in ipairs(cands) do local reached = p.cellX == c.sx and p.cellY == c.sy if not reached then ops.goto_({ x = c.sx, y = c.sy }) local q = ow().player reached = ow().map.id == m.id and q.cellX == c.sx and q.cellY == c.sy end if reached then say(("pushing the boulder at (%d,%d) %s (toward (%d,%d))") :format(bx, by, c.d[3], tx, ty)) for _ = 1, 8 do faceDir(c.d[3]) walk(c.d[3], 12) if not ow():npcAtCell(bx, by) then ok = true break end U.wait(4) end break end end pushingBoulder = false return ok end -- --------------------------------------------------------------------- -- Victory Road boulder switches, solved DIRECTLY. -- -- The route moves boulders by walking through them (PokeBotBad's -- collision model lets it), and reproducing that shove-by-shove through -- the goto ladder proved fragile: one wrong first push and the whole -- choreography is dead, the switch never presses, and the floor's -- barrier block stays down while segments 184+ skip against it. -- -- These puzzles are static, so plan them outright: a single-boulder -- sokoban BFS over (boulder cell, player region) that returns the shove -- list onto the switch cell, executed one stand-face-push at a time. -- Switch cells and barrier flags from data/scripts/story.lua -- (scripts/VictoryRoad1F/2F/3F.asm); the 3F hole at (23,15) drops a -- boulder down beside 2F's second switch, which is where its boulder -- comes from. -- --------------------------------------------------------------------- VR_SWITCHES = { VICTORY_ROAD_1F = { { flag = "EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH", x = 17, y = 13 }, }, VICTORY_ROAD_2F = { { flag = "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1", x = 1, y = 16 }, { flag = "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2", x = 9, y = 16 }, }, VICTORY_ROAD_3F = { { flag = "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1", x = 3, y = 5 }, -- the hole: press only while 2F's dropped boulder has not appeared { hole = true, x = 23, y = 15 }, }, } -- Shove plan for one boulder onto (tx, ty): BFS over boulder positions, -- with the player's reachable region (boulder + other NPCs as walls) -- deciding which sides can be pushed from. Returns { {sx,sy,dir}, ... }. local function planShoves(m, boulder, tx, ty) local W, H = m.widthCells, m.heightCells local walls = {} for _, npc in ipairs(ow().npcs) do if npc ~= boulder and npc.cellX >= 0 and npc.cellY >= 0 and npc.cellX < W and npc.cellY < H then walls[npc.cellY * W + npc.cellX] = true end end local function open(x, y, bx, by) if x < 0 or y < 0 or x >= W or y >= H then return false end if x == bx and y == by then return false end local id = y * W + x return not walls[id] and m:isWalkableCell(x, y) end -- reachable-region representative for the player with the boulder at -- (bx,by): the smallest cell id in the flood fill from (px,py) local function regionRep(px, py, bx, by) local seen = { [py * W + px] = true } local q, h, rep = { { px, py } }, 1, py * W + px while h <= #q do local c = q[h]; h = h + 1 for _, d in ipairs(DIRS) do local nx, ny = c[1] + d[1], c[2] + d[2] local id = ny * W + nx -- the player's steps respect tile-pair (elevation) collisions; -- the BOULDER's landing does not (checkBoulderPush never checks -- pairs), so only this flood fill filters on them if not seen[id] and open(nx, ny, bx, by) and not pairBlockedStep(m, c[1], c[2], nx, ny, false) then seen[id] = true if id < rep then rep = id end q[#q + 1] = { nx, ny } end end end return rep, seen end local p = ow().player local startRep, startSeen = regionRep(p.cellX, p.cellY, boulder.cellX, boulder.cellY) local start = { bx = boulder.cellX, by = boulder.cellY, rep = startRep, seen = startSeen, path = {} } local visited = { [start.bx .. "," .. start.by .. "," .. start.rep] = true } local queue, head = { start }, 1 while head <= #queue do local n = queue[head]; head = head + 1 if n.bx == tx and n.by == ty then return n.path end if #n.path < 60 then for _, d in ipairs(DIRS) do local sx, sy = n.bx - d[1], n.by - d[2] local lx, ly = n.bx + d[1], n.by + d[2] if open(lx, ly, n.bx, n.by) and n.seen[sy * W + sx] then local rep2, seen2 = regionRep(n.bx, n.by, lx, ly) local key = lx .. "," .. ly .. "," .. rep2 if not visited[key] then visited[key] = true local path = {} for i, s in ipairs(n.path) do path[i] = s end path[#path + 1] = { sx = sx, sy = sy, dir = d[3] } queue[#queue + 1] = { bx = lx, by = ly, rep = rep2, seen = seen2, path = path } end end end end end return nil end -- The solver's stand-cell walks must NEVER leave the floor. ops.goto_'s -- unreachable ladder (arriveByWarp, reEnterThroughGate, the surf mount) -- deliberately warps/walks off the map and can report the goto "done" -- from elsewhere -- which is how three consecutive runs went from -- "18 shove(s) ... (attempt 1)" to standing on ROUTE_23 with no -- shove-failure line and no attempt-2 line: a stand cell read -- unreachable for a moment (the boulder itself, a wanderer, a stale -- refused cell) and the ladder marched the bot out the entrance. The -- floor and every path are fully known at plan time, so walk with plain -- BFS and FAIL FAST; a false return sends the solver back to re-plan -- from live state, which is cheap. bfsNextKey never paths through a -- warp cell, so this walker cannot step out of the cave by accident. local function solverWalkTo(tx, ty) local m = ow().map local refused = {} local stuck = 0 for _ = 1, 300 do if ow().map.id ~= m.id then return false end local p = ow().player if p.cellX == tx and p.cellY == ty then if not p.moving then return true end U.wait(2) elseif inBattle() then fightBattle() elseif busy() then mashUntilIdle() else local key = bfsNextKey(tx, ty, refused) if not key then -- a wanderer parked on the path (or the target) may step off; -- give it a moment, then hand the failure to the solver's retry stuck = stuck + 1 if stuck >= 4 then return false end U.wait(30) elseif walk(key) then stuck = 0 else -- the engine refused the step: plan around that cell, never -- escalate local dv = DELTA[key] if dv then refused[(p.cellY + dv[2]) * m.widthCells + (p.cellX + dv[1])] = true end stuck = stuck + 1 if stuck >= 8 then return false end end end end return false end -- Solve every unpressed switch on the current Victory Road floor. local solvingBoulders = false function solveVictoryRoadSwitches() if solvingBoulders then return end local m = ow().map local entries = VR_SWITCHES[m.id] if not entries then return end solvingBoulders = true -- the solver's own gotos must not trigger the ladder's ad-hoc shover pushingBoulder = true for _, sw in ipairs(entries) do local needed if sw.hole then local t = (G.save.objectToggles or {}).VICTORY_ROAD_2F needed = not (t and t.VICTORYROAD2F_BOULDER) else needed = not G.save.flags[sw.flag] end if needed then -- A trainer battle or a wanderer can interrupt the execution and -- leave everything mid-pose (the first live run fought two -- COOLTRAINERs during shove 1 and drifted out the entrance), so -- plan-and-execute is retried from LIVE state a few times; the -- plan is cheap and the switch flag tells us when we are done. for attempt = 1, 3 do if ow().map.id ~= m.id then break end -- try each boulder; the puzzles have exactly one that works local plan for _, npc in ipairs(ow().npcs) do if tostring(npc.def and npc.def.sprite or ""):find("BOULDER") then plan = planShoves(m, npc, sw.x, sw.y) if plan then break end end end if not plan then say(("victory road: no boulder can reach (%d,%d) on %s") :format(sw.x, sw.y, m.id)) break end say(("victory road: %d shove(s) to put the boulder on (%d,%d)" .. " (attempt %d)"):format(#plan, sw.x, sw.y, attempt)) if not ow().strengthActive then ops.fieldMove({ move = "strength" }, m.id) end local clean = true for i, shove in ipairs(plan) do local done = false solverWalkTo(shove.sx, shove.sy) local q = ow().player if ow().map.id == m.id and q.cellX == shove.sx and q.cellY == shove.sy then local dv = DELTA[shove.dir] local bx, by = shove.sx + dv[1], shove.sy + dv[2] local lx, ly = bx + dv[1], by + dv[2] faceDir(shove.dir) -- Press INTO the boulder one frame at a time and STOP the -- instant it starts moving. A held key chain-pushes -- the -- first bump arms (BIT_TRIED_PUSH_BOULDER), the next frame -- pushes, and if we keep holding, the player follows the -- boulder in and shoves it again -- which scatters it cells -- past where the plan wants it. (The arm survives released -- frames; it only resets when a move attempt finds no -- boulder in front.) for _ = 1, 120 do table.insert(G.input.pressQueue, shove.dir) G.input.state[shove.dir] = true coroutine.yield() G.input.state[shove.dir] = false if inBattle() then fightBattle() faceDir(shove.dir) end if not ow():npcAtCell(bx, by) then break end end -- let the slide + dust finish, and confirm it landed where -- the plan expects (not chained past it) for _ = 1, 90 do if not busy() and ow():npcAtCell(lx, ly) then break end U.wait(2) end done = ow():npcAtCell(lx, ly) ~= nil and not ow():npcAtCell(bx, by) end if not done then say(("victory road: shove %d/%d (%s from (%d,%d)) did not move " .. "the boulder -- re-planning"):format(i, #plan, shove.dir, shove.sx, shove.sy)) clean = false break end end -- drifted out of the cave mid-solve: come back before retrying if ow().map.id ~= m.id then local back for _, w in ipairs(ow().map.def.warps or {}) do if tostring(w.destMap or "") == m.id then back = w break end end if back then walkOntoWarp(back.x, back.y) end if ow().map.id ~= m.id then break end end local pressed if sw.hole then local t = (G.save.objectToggles or {}).VICTORY_ROAD_2F pressed = t and t.VICTORYROAD2F_BOULDER else pressed = G.save.flags[sw.flag] end if pressed then say(("victory road: switch at (%d,%d) pressed"):format(sw.x, sw.y)) break end if clean then -- executed fully yet no flag: geometry bug, stop -- and say so, -- because a silent return here reads as "the solver never came -- back" (three runs were mis-diagnosed exactly that way) say(("victory road: all shoves landed yet (%d,%d) never set its " .. "flag -- geometry bug, giving up"):format(sw.x, sw.y)) break end end end end pushingBoulder = false solvingBoulders = false end function toggleMansionSwitch(tx, ty) if togglingSwitch then return false end local m = ow().map if not MANSION_SWITCHES[m.id] then return false end local seen, W = mansionReach() if seen[ty * W + tx] then return false end -- not a switch problem -- Futility cap, same lesson as cutToward: the 1F stairs room and the -- B1F return path want OPPOSITE gate states, so chasing one target -- through the switches ping-pongs forever ("no reachable switch on -- 1F; pressing one on B1F instead" x10). Three toggles without the -- target opening IS the deadlock -- fail fast so travelTo can conclude -- "no way there" and take the escape rope out. local fk = m.id .. "#" .. tx .. "," .. ty WD.futileSwitches[fk] = (WD.futileSwitches[fk] or 0) + 1 if WD.futileSwitches[fk] > 3 then return false end togglingSwitch = true say(("goto (%d,%d) is sealed by the mansion gates; looking for a switch") :format(tx, ty)) local ok = pressStatueHere() if not ok then -- No statue on this side of the gates. The 1F basement-stairs room is -- the case: its only statue sits OUTSIDE the room, but the B1F stairs -- are inside it -- go down, press one of B1F's two statues, and come -- back up. (A vanilla player stuck there does exactly this.) for _, w in ipairs(m.def.warps or {}) do local dm = tostring(w.destMap or "") if MANSION_SWITCHES[dm] and seen[w.y * W + w.x] then local fromMap = m.id say(("no reachable switch on %s; pressing one on %s instead") :format(fromMap, dm)) if walkOntoWarp(w.x, w.y) and MANSION_SWITCHES[ow().map.id] and ow().map.id ~= fromMap then ok = pressStatueHere() -- back through the stairs we came down local back for _, w2 in ipairs(ow().map.def.warps or {}) do if tostring(w2.destMap or "") == fromMap then back = w2 break end end if back then walkOntoWarp(back.x, back.y) end ok = ok and ow().map.id == fromMap end break end end end togglingSwitch = false return ok end -- Push a Strength boulder (Victory Road's four, segments 183-186). -- -- Two things here are easy to get wrong, both from push_boulder.asm -- (mirrored in OverworldState:checkBoulderPush): -- -- * The FIRST walk into a boulder only ARMS the push -- (BIT_TRIED_PUSH_BOULDER); the second one moves it. A single held walk -- looks like a wall and reports failure. -- * BIT_STRENGTH_ACTIVE lives in wStatusFlags1 and is cleared on EVERY map -- load, so it is re-armed per map rather than once per run. function ops.push(s, where) local dir = tostring(s.face or "") if dir == "" then note("push: no direction", where) return false end if not ow().strengthActive then if not ops.fieldMove({ move = "strength" }, where) then note("push: could not activate STRENGTH", where) say("push: STRENGTH would not activate") return false end end faceDir(dir) local d = DELTA[dir] if not d then return false end local p = ow().player local bx, by = p.cellX + d[1], p.cellY + d[2] local boulder = ow():npcAtCell(bx, by) if not boulder then note("push: no boulder to push", where) say(("push %s: nothing pushable at (%d,%d) on %s") :format(dir, bx, by, ow().map.id)) return false end for _ = 1, 8 do faceDir(dir) walk(dir, 12) if not ow():npcAtCell(bx, by) then say(("pushed the boulder %s on %s"):format(dir, ow().map.id)) return true end U.wait(4) end note("push: boulder would not move", where) say(("push %s: the boulder at (%d,%d) did not move"):format(dir, bx, by)) return false end -- Mount SURF at the nearest shore cell, so an over-water target becomes -- plannable. Only fires when the party can actually surf (SURF known -- -- taught at Silph 10F -- and SOULBADGE held); returns true once -- player.surfing is up, after which bfsNextKey's passableCell opens the -- water and the caller's re-plan does the rest. The Pallet -> Route 21 -> -- Cinnabar corridor is why this exists: travelTo could plan the seams but -- nothing ever stepped OFF the beach. local mountingSurf = false function mountSurfToward(tx, ty) if mountingSurf then return false end local p = ow().player if p.surfing then return false end -- already up; not the problem -- note(), NOT say(): pre-Surf the goto rescue chain reaches this rung -- constantly (more since cutToward got honest), and ten identical say -- lines inside one segment read as a stuck loop to the watchdog -- one -- run lost all five attempts to that false positive on ordinary -- Vermilion gotos. if not slotKnowing("SURF") then note("mountSurf: SURF unknown", ow().map.id) return false end if not (G.save.inventory or {}).SOULBADGE then note("mountSurf: no SOULBADGE", ow().map.id) return false end mountingSurf = true local m = ow().map -- water tilesets only (water_tilesets.asm); a cave with no water would -- scan every cell for nothing local seen, W = reachableSet() -- The shore to mount from is the reachable land-beside-water cell -- nearest the TARGET, with the player's own distance only breaking -- ties. Nearest-to-player was a trap on Route 20's mid-island strip: -- a rock wall splits the strip's shores, and the bot re-mounted on -- the west shore every time, surfed back over ground it had already -- crossed, dismounted in the same spot and burned the whole mount -- budget without ever entering the water that leads east. local best, bestD for cellId in pairs(seen) do local cx, cy = cellId % W, math.floor(cellId / W) for _, d in ipairs(DIRS) do local wx, wy = cx + d[1], cy + d[2] if m:inBounds(wx, wy) and m:isWaterCell(wx, wy) then local dist = (math.abs(cx - tx) + math.abs(cy - ty)) * 4 + math.abs(cx - p.cellX) + math.abs(cy - p.cellY) if not bestD or dist < bestD then best, bestD = { cx, cy }, dist end break end end end if not best then mountingSurf = false return false end -- The same shore toward the same target, over and over, is the dead -- pond trap: Vermilion's harbor pool is the closest-to-target water -- the flood can see, but it connects to nothing, so the run re-mounted -- there until the watchdog killed it ("used SURF on VERMILION_CITY" -- x10). A couple of repeats are honest re-plans; more means this water -- goes nowhere useful -- refuse and fail fast so the caller can ban -- the hop instead. local mk = m.id .. "#" .. best[1] .. "," .. best[2] .. ">" .. tx .. "," .. ty WD.futileMounts[mk] = (WD.futileMounts[mk] or 0) + 1 if WD.futileMounts[mk] > 3 then say(("mountSurf: refusing the same shore (%d,%d) toward (%d,%d) again") :format(best[1], best[2], tx, ty)) mountingSurf = false return false end if not ops.goto_({ x = best[1], y = best[2] }) then mountingSurf = false return false end say(("land ran out on the way to (%d,%d) -- surfing from (%d,%d)") :format(tx, ty, best[1], best[2])) ops.fieldMove({ move = "surf" }, ow().map.id) mountingSurf = false return ow().player.surfing == true end -- FLY between towns -- by walking, because we can never actually Fly. -- -- HM02 is picked up at segment 104 (ROUTE_16_FLY_HOUSE) and taught at 105, -- but the route means it for a bird we never catch: our party is the -- starter plus a NIDORAN line, and WARTORTLE's tmhm list has no FLY. So -- `teach fly` legitimately fails and the move is never available. -- -- Leaving this a stub was quietly fatal rather than merely slow. Segment -- 105 flies to LAVENDER_TOWN, so the stub left us standing on ROUTE_16 and -- segments 106-114 -- the ENTIRE Pokémon Tower arc, which is where Mr. Fuji -- hands over the POKE_FLUTE -- all skipped. The run then walked into the -- Route 16 SNORLAX with no flute, could not pass, and thrashed until it ran -- out of rewinds. One stubbed op, sixty segments of consequences. -- -- The step names its destination (dest=lavender, map=4), and `map` is a -- Fly-menu index rather than a map id, so the next segment's map is the -- reliable target. local FLY_DESTS = { pallet = "PALLET_TOWN", viridian = "VIRIDIAN_CITY", pewter = "PEWTER_CITY", cerulean = "CERULEAN_CITY", lavender = "LAVENDER_TOWN", vermilion = "VERMILION_CITY", celadon = "CELADON_CITY", fuchsia = "FUCHSIA_CITY", cinnabar = "CINNABAR_ISLAND", saffron = "SAFFRON_CITY", } function ops.fly(s, where) local dest = FLY_DESTS[tostring(s.dest or ""):lower()] or nextMapWanted if not dest then note("fly: no destination", where) return false end if ow().map.id == dest then return true end say(("fly to %s: walking there instead"):format(dest)) -- CINNABAR is an ISLAND: every approach is by sea. Left to itself the -- planner hunts a land bridge across the northern half of the map and -- micro-loops in Saffron's houses. Stage through FUCHSIA -- a proven -- land trip -- and from there the shortest plan is the Route 19/20 -- water corridor, which crossSeam can now surf. if dest == "CINNABAR_ISLAND" and ow().map.id ~= "FUCHSIA_CITY" and not tostring(ow().map.id):find("ROUTE_19") and not tostring(ow().map.id):find("ROUTE_20") then say("staging through FUCHSIA_CITY for the sea route") travelTo("FUCHSIA_CITY", where) end if travelTo(dest, where) then return true end note("fly: could not reach " .. dest, where) return false end for _, name in ipairs({ "swapItem", "swapMove" }) do ops[name] = function(s, where) note(name, where) return true end end -- --------------------------------------------------------------------- -- catching -- --------------------------------------------------------------------- -- Whether the run is over: nothing left standing. Blacking out revives the -- party at the heal point, so this is checked while the battle that did it -- is still resolving, before the revive lands. (Defined here rather than -- with the runner below because catchWild watches for it too.) local function wiped() local party = G.save and G.save.party or {} if #party == 0 then return false end for _, mon in ipairs(party) do if (mon.hp or 0) > 0 then return false end end return true end -- -- The route is built around a second Pokémon. Segment 16 buys 10 POKE_BALLs -- for exactly this, segment 18 catches a Nidoran on Route 22, and Mt. Moon -- evolves it into the Nidoking that every later TM is aimed at (thrash, -- thunderbolt, horn drill, rock slide, ice beam). Skipping the catch left a -- lone starter carrying a route balanced for two -- no switch, no revive -- -- which is what kept killing us in Viridian Forest. local BALL_ORDER = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL" } local function ballInBag() for _, id in ipairs(BALL_ORDER) do if (heldCount(id) or 0) > 0 then return id end end end -- Throw one ball at the current wild encounter. -- -- Drives the real menus (battle menu -> ITEM -> the ball) the way ops.heal -- does, rather than calling BattleState:throwBall, so a broken bag fails the -- run instead of being quietly bypassed. In battle the bag has no USE/TOSS -- submenu (BagMenu.lua:359), so selecting a ball throws it outright. local function throwBall(battle, ball, where) local function failed(stage) local t = top() say(("catch: throw failed at %s on %s (top=%s, rows=%d, phase=%s)"):format( stage, tostring(where), (isList() and "list") or (isMenu() and "menu") or (t and "other") or "nothing", rows() and #rows() or 0, tostring(battle.phase))) end -- ITEM is index 3 of the 2x2 action grid (fight/pkmn/item/run) if not battleMenuTo(battle, 3) then failed("the ITEM slot") return false end press("a") if not waitFor(isList, 40) then failed("opening the bag") return false end local idx for i, row in ipairs(rows()) do if row.value == ball then idx = i break end end if not idx then failed("finding " .. ball .. " in the bag") return false end if not cursorTo("index", idx) then failed("the bag cursor") return false end -- in battle the bag has no USE/TOSS submenu, so A throws it outright press("a") U.wait(10) return true end -- How long to pace for a target before accepting we are not getting one. -- Route 22 grass is a 25/256 encounter rate with Nidoran on 4 of 10 slots, -- so this is many times the expected wait; it exists to end the segment, -- not to be reached. local CATCH_MAX_STEPS = 300 -- Hard ceiling on outer-loop passes regardless of which branch they take, -- so a wedged battle cannot pin the run (see the loop below). local CATCH_MAX_ITERATIONS = 1200 -- Only walking counts toward CATCH_MAX_STEPS, so a wait that never resolves -- would spin forever. This bounds it. local MAX_CATCH_SPINS = 400 -- Pace for wild encounters and spend balls on anything in `targets`. -- -- Non-targets are fought rather than fled: we are underlevelled precisely -- because the bot flees, and this is grass we are standing in anyway. -- -- The catch WEAKENS first (weakenMoveIndex), then throws. This used to -- throw at full HP on the reasoning that NIDORAN's catch rate is 235 and we -- hold ten balls, so softening it only risked killing the target. That -- holds for NIDORAN and not for the second catch: ODDISH is catch rate 45, -- and a full-HP run at it spent six balls without ever sticking a shake. -- Gen 1's formula scales with missing HP, so a safe hit is worth far more -- than an extra ball -- and weakenMoveIndex only offers a move that leaves -- the target alive with a margin, falling back to throwing when no move is -- safe, so the original worry is handled rather than ignored. local function catchWild(targets, where) targets = targets or catchTarget if not targets or #targets == 0 then note("catch: no target species", where) return true end local want = {} for _, s in ipairs(targets) do want[s] = true end local startSize = #party() local steps, thrown, spins, weakened = 0, 0, 0, 0 local seen = {} -- species -> encounters, so a failed catch says what showed up local ball = ballInBag() -- No balls means no catch, and pacing the grass for 300 steps to discover -- that only feeds the encounters we cannot do anything with. Say so and -- move on -- an empty bag here points at the shop stop, not at this step. if not ball then note("catch: no balls in the bag", where) say(("catching %s on %s: NO BALLS -- check the shop stop that should " .. "have stocked them"):format(table.concat(targets, "/"), tostring(where))) return true end say(("catching %s on %s (party %d, %s x%d)"):format( table.concat(targets, "/"), tostring(where), startSize, ball, heldCount(ball) or 0)) -- Bound the LOOP, not just the walking. -- -- `steps` is the only bound the outer loop had, and it is incremented in -- exactly one branch -- the one that paces the grass. A battle that never -- resolves therefore re-enters the inBattle branch forever with `steps` -- frozen, and CATCH_MAX_STEPS can never trip. That is not theoretical: a -- level-13 ODDISH on ROUTE_6 pinned the run for 10,229 identical -- iterations of "found ODDISH after 21 steps -- throwing", never throwing -- (no failure logged), never catching, never running out of balls. -- Whatever wedges the battle, the driver must not livelock on it. local iterations = 0 while steps < CATCH_MAX_STEPS do iterations = iterations + 1 if iterations > CATCH_MAX_ITERATIONS then -- Name WHICH sub-state is holding the queue, not just the phase. -- -- BattleState:updateQueue returns true (queue held) for four separate -- reasons -- waitingUI, waitFrames, draining, animPlaying -- and the -- phase alone cannot tell them apart. The first pass at this only -- reported phase=messages, which narrowed it to "the queue is stuck" -- and no further; afterQueue and the drain have since been ruled out -- (tests/parity_ball_miss.lua covers the phase contract). Print the -- lot so the next occurrence names the culprit instead of needing -- another run to guess again. local b = G.stack:top() say(("catch: livelocked on %s after %d iterations (steps=%d, thrown=%d, " .. "inBattle=%s, phase=%s, afterQueue=%s, queue=%d, waitingUI=%s, " .. "waitFrames=%s, draining=%s, animPlaying=%s) -- abandoning") :format(tostring(where), iterations, steps, thrown, tostring(inBattle()), tostring(b and b.phase), tostring(b and b.afterQueue), b and b.queue and #b.queue or -1, tostring(b and b.waitingUI), tostring(b and b.waitFrames), tostring(b and b.draining), tostring(b and b.animPlaying))) note("catch: livelocked", where) if inBattle() then fightBattle() end mashUntilIdle() return true end if blackedOut or wiped() then return false end if inBattle() then spins = 0 -- Let the enemy side finish being built before deciding what this is. -- Reading it the instant the stack top becomes a battle can return nil -- and drop our target into the "just fight it" branch. waitFor(function() local b = G.stack:top() return not inBattle() or (b.enemy and b.enemy.mon) ~= nil end, 20) local battle = G.stack:top() local enemy = inBattle() and battle.enemy and battle.enemy.mon or nil local species = enemy and enemy.species if battle.kind == "wild" and species and want[species] then -- Our target. Keep throwing until it is ours, the balls run out or -- the battle ends some other way (it broke out and KO'd us). say(("found %s (level %d) after %d steps -- throwing"):format( species, enemy.level or 0, steps)) local frames = 0 while inBattle() and #party() == startSize and frames < 4000 do local b = G.stack:top() if b.phase == "menu" then -- our own mon fainted mid-catch: hand back to the fight loop, -- which owns the replacement menu and the blackout watch if b.player.mon.hp <= 0 then break end local ball = ballInBag() if not ball then note("catch: out of balls", where) break end -- Soften it up first. Gen 1's catch chance scales with missing -- HP, so full-HP throws are the worst odds on offer -- fine for -- a NIDORAN at catch rate 235, hopeless for an ODDISH at 45, -- which ate six balls without a single shake that stuck. -- weakenMoveIndex only ever offers a move that leaves the -- target alive with a margin; when nothing is safe it returns -- nil and we throw rather than risk killing what we came for. local weaken = weakenMoveIndex(b) if weaken then if battleMenuTo(b, 1) then press("a") U.wait(6) for _ = 1, 8 do if b.moveIndex == weaken then break end press(b.moveIndex > weaken and "up" or "down") U.wait(2) end press("a") U.wait(6) weakened = weakened + 1 end elseif not throwBall(b, ball, where) then note("catch: could not throw " .. ball, where) break else thrown = thrown + 1 end else press("a") U.wait(3) end frames = frames + 4 end if #party() > startSize then local got = party()[#party()] say(("caught %s (level %d) after %d ball%s and %d weakening hit%s " .. "-- party is now %d") :format(tostring(got and got.species or species), got and got.level or 0, thrown, thrown == 1 and "" or "s", weakened, weakened == 1 and "" or "s", #party())) return true end if inBattle() then fightBattle() end else seen[species or "?"] = (seen[species or "?"] or 0) + 1 fightBattle() -- not our target, but the XP still counts end autoHeal(where, riskThreshold(where)) elseif busy() then -- Deliberately NOT mashUntilIdle: it fights whatever battle it finds -- (mashUntilIdle -> fightBattle). An encounter opens *during* the walk -- below, so the very next thing this loop did was hand our target to -- that wait, which killed it before the branch above ever saw it -- -- the bot stood in the right grass, met the right Pokémon and never -- threw a ball. Advance text only and let the battle branch decide. press("a") U.wait(6) spins = spins + 1 if spins > MAX_CATCH_SPINS then say(("catch: stuck waiting for the overworld on %s; giving up") :format(tostring(where))) return true end else -- encounters only fire on steps, so pace the grass we were left in spins = 0 walk(steps % 2 == 0 and "up" or "down") steps = steps + 1 end end local tally = {} for sp, n in pairs(seen) do tally[#tally + 1] = ("%s x%d"):format(sp, n) end table.sort(tally) note(("catch: no %s after %d steps (%d balls thrown)") :format(table.concat(targets, "/"), steps, thrown), where) say(("catch: gave up on %s after %d steps, %d balls thrown, %d weakening " .. "hits; met %s"):format( table.concat(targets, "/"), steps, thrown, weakened, #tally > 0 and table.concat(tally, ", ") or "nothing")) return true end -- Named handlers for the route's `manual` steps. Anything without one is -- still stubbed loudly rather than guessed at. local MANUAL = {} -- Vermilion Gym's trash can lock puzzle (engine/events/hidden_events/ -- vermilion_gym_trash.asm). Two switches hide in the 15 cans; both open -- the door block at (2,2) that walls Surge off. Until then the route's -- gotos into the north half are simply unreachable and the gym segment -- skips -- the bot walked in, wandered and walked back out. -- -- We cannot read where the switches are, and we should not: the first is -- re-rolled on every Vermilion City load AND on every wrong second guess, -- so there is nothing stable to aim at. Just check cans until the door -- opens. The vanilla odds are poor and the re-roll makes it memoryless, -- hence the generous budget. local function solveTrashcans(where) local cans = G.data.field.hiddenExtras and G.data.field.hiddenExtras.trashCans and G.data.field.hiddenExtras.trashCans.cans if not cans or #cans == 0 then note("trashcans: no can data", where) return false end local function solved() return G.save.flags.EVENT_2ND_LOCK_OPENED == true end if solved() then return true end local tries = 0 for _ = 1, 12 do -- passes over the grid for _, can in ipairs(cans) do if solved() then say(("trashcans: both locks open after %d checks"):format(tries)) return true end -- stand next to the can and face it; stepUpTo handles the approach if stepUpTo(can.x, can.y) then press("a") U.wait(8) mashUntilIdle() tries = tries + 1 end end end note("trashcans: unsolved", where) say(("trashcans: %d checks and the door is still shut"):format(tries)) return false end function MANUAL.trashcans(where) return solveTrashcans(where) end -- Evolve with a stone (Mt. Moon's MOON_STONE on the route's NIDORINO). -- -- The whole back half of the route's move plan is aimed at a NIDOKING -- -- thrash, thunderbolt, rock_slide, ice_beam all target it -- so leaving -- this a stub did not merely skip a step, it silently invalidated every -- later `teach`: with no NIDOKING in the party they fell through to slot 1 -- and were spent on a WARTORTLE that cannot learn them. -- -- Data checked against pokered (data/pokemon/evos_moves.asm:1897, -- EVOLVE_ITEM MOON_STONE -> NIDOKING); ours matches, and the engine already -- returns { evolveTo = ... } from ItemEffects and runs Evolution.evolve in -- BagMenu. Only the driver was missing. local function evolveWithStone(species, item, where) local slot = slotForSpecies(species) if not slot then note("evolve: no " .. tostring(species[1] or species), where) say(("evolve: no %s in the party"):format(tostring(species[1] or species))) return false end if (heldCount(item) or 0) <= 0 then note("evolve: no " .. item, where) say(("evolve: no %s in the bag"):format(item)) return false end local before = party()[slot] and party()[slot].species useItemOn(item, slot, where) -- the evolution scene is a long one; wait it out rather than stepping on it for _ = 1, 200 do if idle() then break end mashUntilIdle() end local after = party()[slot] and party()[slot].species if after ~= before then say(("%s evolved into %s"):format(tostring(before), tostring(after))) return true end say(("evolve: %s did not evolve"):format(tostring(before))) return false end function MANUAL.evolveNidoking(where) return evolveWithStone({ "NIDORINO", "NIDORAN_M" }, "MOON_STONE", where) end -- NIDORAN_M -> NIDORINO is EVOLVE_LEVEL 16 (evos_moves.asm), so it happens -- on its own as the trainee levels; there is nothing to drive here. Kept as -- a real handler rather than a stub so the end-of-run summary stops listing -- it as skipped work. function MANUAL.evolveNidorino() return true end -- Ride an elevator to the floor the next segment wants. -- -- Both elevators the route uses (CELADON_MART_ELEVATOR at segment 98, -- SILPH_CO_ELEVATOR at 140) gate real geography: the mart's upper floors -- and most of Silph are only reachable through them. -- -- The floor menu belongs to the car's panel bg_event, not to map entry -- (#395): walk to the panel and press A. Rows are the short floor tokens -- pokered prints ("5F", "B2F"), which is exactly the tail of the -- destination map id, so the next segment names the button to press. -- Ride the elevator to `wantMap` (defaults to the next segment's floor). local function rideElevator(where, wantMap) -- Stand beside the car's panel bg_event and press A to open the floor -- menu (data/maps/objects/CeladonMartElevator.asm `bg_event 3, 0`). local function pressPanel() local m = ow().map local sign = (m.def.signs or {})[1] if not sign then return false end -- offset from the panel cell, then the direction that faces it back local SIDES = { { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" } } for _, s in ipairs(SIDES) do local sx, sy = sign.x + s[1], sign.y + s[2] if m:inBounds(sx, sy) and m:isWalkableCell(sx, sy) then ops.goto_({ x = sx, y = sy }) local p = ow().player if ow().map.id == m.id and p.cellX == sx and p.cellY == sy then faceDir(s[3]) press("a") if waitFor(isList, 60) then return true end end end end return false end local want = tostring(wantMap or nextMapWanted or "") local token = want:match("_([^_]+)$") if not token then note("elevator: no destination floor", where) say(("elevator on %s: no floor named to ride to"):format(tostring(where))) return false end local from = ow().map.id if not isList() and not pressPanel() then note("elevator: no floor menu", where) say(("elevator on %s: the WHICH FLOOR? menu never opened"):format(from)) return false end local idx for i, row in ipairs(rows() or {}) do if tostring(row.label) == token then idx = i break end end if not idx then -- The exact floor is not on this elevator -- the Celadon roof, say, is -- reached by stairs from 5F, not by the car. Ride to the highest floor -- offered instead (nearest the roof) and let the caller walk the last -- stairs. Better than failing and bouncing. local best, bestFloor for i, row in ipairs(rows() or {}) do local n = tonumber(tostring(row.label):match("%d+")) if n and (not bestFloor or n > bestFloor) then best, bestFloor = i, n end end if best then say(("elevator on %s: no %s floor; riding to the top (%s) instead") :format(from, token, tostring(rows()[best].label))) idx = best else local have = {} for _, row in ipairs(rows() or {}) do have[#have + 1] = tostring(row.label) end note("elevator: no floor " .. token, where) say(("elevator on %s: wanted %s, offered %s") :format(from, token, table.concat(have, "/"))) backOut() return false end end if not cursorTo("index", idx) then backOut() return false end press("a") -- ShakeElevator runs the whole ride in place -- music stop, 100 scroll -- bounces, the PA chime -- and then hands control back inside the car: -- the rewritten exit warp is what we walk out onto (#395). mashUntilIdle() local car = findWarpTo(want) or ow().map.def.warps[1] if car then walkOntoWarp(car.x, car.y) end local landed = ow().map.id if landed == want then say(("rode the elevator to %s"):format(token)) return true end note("elevator: landed on " .. tostring(landed), where) say(("elevator: wanted %s, ended on %s"):format(want, tostring(landed))) return false end MANUAL.deptElevator = rideElevator MANUAL.silphElevator = rideElevator -- Escape the Pokémon Tower 6F MAROWAK with a POKé DOLL. -- -- This is not a shortcut we could skip -- it is the route's only way past -- 6F. The route never visits the Game Corner or ROCKET_HIDEOUT (checked: -- no segment on either map), so it never holds the SILPH_SCOPE, and -- Map.ghostBattles marks every POKEMON_TOWER floor `unlessItem = -- "SILPH_SCOPE"`. Without the scope the ghost cannot be damaged at all, so -- fighting it is an infinite battle; the POKE_DOLL bought at segment 94 -- (CELADON_MART_4F) is what gets us to 7F and Mr. Fuji -- and therefore to -- the POKE_FLUTE and the Snorlax beyond it. -- -- ItemUsePokeDoll works on wild battles only, which the ghost is. function MANUAL.pokeDoll(where) -- The battle usually opens DURING the preceding goto, where fightBattle -- already throws the doll (the ghost branch). Arriving here with the -- event set means that worked; there is nothing left to do. if (G.save.flags or {}).EVENT_BEAT_GHOST_MAROWAK then say("MAROWAK already departed (doll thrown mid-goto)") return true end if (heldCount("POKE_DOLL") or 0) <= 0 then -- The Celadon 4F stop can fail (it did -- a clerk behind a counter -- the old stepUpTo could not reach), and without the doll the -- MAROWAK is unbeatable and the tower a dead end. The mart is a -- short walk from Lavender, so go buy one rather than write the -- attempt off; the segment loop travels back to the tower on its -- own when the manual step ends on the wrong map. say("pokeDoll: none in the bag -- walking to CELADON_MART_4F to buy one") local back = ow().map.id if travelTo("CELADON_MART_4F", where) then ops.shop({ list = "pokeDoll" }, "CELADON_MART_4F") travelTo(back, where) end end if (heldCount("POKE_DOLL") or 0) <= 0 then note("pokeDoll: none in the bag", where) say("pokeDoll: no POKE_DOLL (the Celadon 4F stop should have bought one)") return false end -- the ghost engages as we approach; give it a moment to open for _ = 1, 120 do if inBattle() then break end if busy() then mashUntilIdle() else U.wait(4) end end if not inBattle() then note("pokeDoll: no battle to escape", where) return false end local battle = G.stack:top() for _ = 1, 40 do if not inBattle() then break end if battle.phase == "menu" then if not battleMenuTo(battle, 3) then break end press("a") if not waitFor(isList, 40) then backOut() break end local idx for i, row in ipairs(rows() or {}) do if row.value == "POKE_DOLL" then idx = i break end end if not idx or not cursorTo("index", idx) then backOut() break end press("a") U.wait(12) mashUntilIdle(400) break end press("a") U.wait(4) end if inBattle() then note("pokeDoll: still in the battle", where) say("pokeDoll: the doll did not end the MAROWAK battle") return false end say("escaped the MAROWAK ghost with a POKé DOLL") return true end -- Buy drinks from the Celadon Mart roof vending machines. -- -- This is the key that opens the middle of the map. All four Saffron gates -- turn you back until the guards have been given a drink, and Saffron sits -- between Celadon and Lavender -- so with no drink in the bag the route -- cannot reach Lavender for the POKE_FLUTE, and travelTo reports "no route -- to MR_FUJIS_HOUSE" however hard it searches. One FRESH_WATER opens every -- gate permanently. -- -- The machines are SIGNS (10,1) (11,1) (12,2), not clerks, so ops.shop -- cannot drive them -- they open a plain ListMenu (data/scripts/story4.lua -- vendingMachine) which stays up between purchases, showing a "popped out!" -- box over itself each time. -- -- Buys several: one goes to the guards, and the roof's thirsty girl trades -- the others for TMs. Nothing later depends on those TMs, so a short bag or -- a thin wallet is not a failure. local VENDING_WANT = 3 function MANUAL.giveWater(where) local sign for _, s in ipairs(ow().map.def.signs or {}) do if tostring(s.text or s.id or ""):find("VENDING_MACHINE") then sign = s break end end if not sign then note("giveWater: no vending machine here", where) return false end -- a sign is read from the cell below it, facing up if not ops.goto_({ x = sign.x, y = sign.y + 1 }) then note("giveWater: cannot reach the vending machine", where) return false end faceDir("up") press("a") U.wait(10) if not waitFor(isList, 40) then note("giveWater: the vending machine did not open", where) say("giveWater: no vending menu appeared") backOut() return false end local bought = 0 for _ = 1, VENDING_WANT do if not isList() then break end cursorTo("index", 1) -- FRESH_WATER, the cheapest at 200 press("a") U.wait(12) -- the purchase (or "Not enough money") prints over the list; clear it for _ = 1, 20 do if isList() then break end press("a") U.wait(4) end bought = bought + 1 end backOut() mashUntilIdle() local held = 0 for _, d in ipairs({ "FRESH_WATER", "SODA_POP", "LEMONADE" }) do held = held + (heldCount(d) or 0) end say(("giveWater: %d vending purchases, %d drink(s) in the bag") :format(bought, held)) if held <= 0 then note("giveWater: no drink bought", where) return false end return true end function MANUAL.catchNidoran(where) return catchWild(CATCH_SPECIES.nidoran, where) end -- Bill's whole favour, hand-driven (BillsHouse.asm). The route data has -- only a bare `manual talkToBill` here, and with it unimplemented the -- S.S. TICKET arrived only when the segment's blind final talk happened -- to line up -- three attempts in one run wedged at the Vermilion -- gangplank for want of it. The chain: the monster on the floor (answer -- YES), the cell-separator PC at (1,4) (hidden event, fires facing UP -- from (1,5)), then Bill at (4,4) hands the ticket over. function MANUAL.talkToBill(where) local function ticket() return (heldCount("S_S_TICKET") or 0) > 0 end if ticket() then return true end for _ = 1, 3 do local flags = G.save.flags or {} if not flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR then -- the monster at (6,5); stand left of it, face right if ops.goto_({ x = 5, y = 5 }, where, false) then interact("right") mashUntilIdle() end end flags = G.save.flags or {} if flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then if ops.goto_({ x = 1, y = 5 }, where, false) then interact("up") mashUntilIdle() end end -- Bill stands at (4,4) once separated; the ticket text is his if ops.goto_({ x = 4, y = 5 }, where, false) then interact("up") mashUntilIdle() end if ticket() then say("Bill is himself again -- S.S. TICKET in the bag") return true end end say("talkToBill: no S.S. TICKET after the full favour chain") return false end function MANUAL.catchOddish(where) return catchWild(CATCH_SPECIES.oddish, where) end -- Use a field item that takes no target (the ESCAPE ROPE). -- -- Same bag walk as useItemOn, but there is no party menu at the end: the -- item just fires and warps us to the last heal point. function useFieldItem(itemId, where) press("start") U.wait(8) local menu = top() if not (menu and menu.screenId == "StartMenu") then note("useItem: start menu never opened", where) backOut() return false end local itemRow for i, it in ipairs(menu.items or {}) do if it.label == "ITEM" then itemRow = i break end end if not itemRow or not cursorTo("index", itemRow) then note("useItem: no ITEM row", where) backOut() return false end press("a") U.wait(10) local bag = top() if not (bag and bag.screenId == "BagMenu") then note("useItem: bag never opened", where) backOut() return false end local bagRow for i, r in ipairs(bag.items or {}) do if r.value == itemId then bagRow = i break end end if not bagRow or not cursorTo("index", bagRow) then note("useItem: no " .. itemId .. " in the bag", where) backOut() return false end press("a") U.wait(8) if isUseToss() then -- USE is row 1 if not cursorTo("index", 1) then backOut() return false end press("a") U.wait(10) end -- the rope warps us out; let the map change land local from = ow().map.id for _ = 1, 150 do if ow().map.id ~= from then break end if busy() then press("a") end U.wait(6) end if ow().map.id == from then backOut() return false end return true end -- Walk out through the building's exit warp. The fallback for a missing -- ESCAPE ROPE: it does not land us where the route expects, but standing -- still inside a house guarantees the attempt dies, and walking out at -- least gives the segment loop a chance to re-sync. function leaveByDoor(where) local out = findWarpTo("LAST_MAP") or findWarpTo("") if out and walkOntoWarp(out.x, out.y) then say(("left %s by the door (no ESCAPE ROPE)"):format(tostring(where))) return true end return false end function ops.useItem(s, where) local item = s.item and tostring(s.item):upper() if s.move then -- A TM teach routed through useItem upstream (TM26 EARTHQUAKE, five -- times at different checkpoints). The TM exists once, so the step is -- made idempotent: skip when somebody already knows the move, else -- hand it to the teach op, whose compatibility fallback picks the -- lead -- the mon that actually fights for us. local key = tostring(s.move):upper() if slotKnowing(key) then return true end return ops.teach({ move = s.move, mon = s.mon }, where) end if item == "REPEL" or item == "SUPER_REPEL" or item == "MAX_REPEL" then -- The route repels through every dungeon it crosses, and the engine -- honours it (save.repelSteps). We fight most encounters anyway, but -- inside the Mansion / Victory Road the attrition is what kills runs: -- B1F drained BLASTOISE to 2/182 across chained wild fights with an -- empty bag and the blackout threw the whole arc away. Use whichever -- repel is actually aboard, strongest first. for _, id in ipairs({ "MAX_REPEL", "SUPER_REPEL", "REPEL" }) do if (heldCount(id) or 0) > 0 then if useFieldItem(id, where) then say(("used %s on %s"):format(id, ow().map.id)) return true end end end note("useItem: no repel aboard", where) return true -- never blocks the route end if item ~= "ESCAPE_ROPE" then -- the other useItem steps are speedrun stat items and X ATTACKs note("useItem", where) return true end if (heldCount("ESCAPE_ROPE") or 0) > 0 and useFieldItem("ESCAPE_ROPE", where) then -- The rope warps to the heal point, which puts us INSIDE the town's -- Poké Center -- but the route's next segment is the town outside it. -- Without stepping out the bot just stands at the nurse while every -- remaining segment skips: "got the ticket, used the rope, then stood -- there". local landed = ow().map.id say(("escape rope out of %s -> %s"):format(tostring(where), tostring(landed))) if tostring(landed):find("POKECENTER") then leaveByDoor(landed) end return true end note("useItem: escape rope failed", where) return leaveByDoor(where) end -- Wake the Route 16 / Route 12 SNORLAX. -- -- Defined down here because it needs useFieldItem. The flute must be USED -- from the bag while standing next to it -- talking to Snorlax with the -- flute in the bag does nothing (ItemEffects' adjacentSleepingSnorlax, and -- data/scripts/story.lua's snorlaxWake). Waking it starts a static battle, -- so the fight is driven before handing back. function MANUAL.playPokeFlute(where) local snorlax for _, npc in ipairs(ow().npcs) do local id = tostring((npc.def and (npc.def.id or npc.def.sprite)) or "") if id:find("SNORLAX") then snorlax = npc break end end if not snorlax then note("playPokeFlute: no SNORLAX here", where) say(("playPokeFlute: nothing asleep on %s"):format(ow().map.id)) return false end if (heldCount("POKE_FLUTE") or 0) <= 0 then note("playPokeFlute: no POKE_FLUTE", where) say("playPokeFlute: the flute is not in the bag (Mr. Fuji not yet reached?)") return false end if not stepUpTo(snorlax.cellX, snorlax.cellY) then note("playPokeFlute: cannot reach SNORLAX", where) return false end local p = ow().player local dx, dy = snorlax.cellX - p.cellX, snorlax.cellY - p.cellY faceDir((dx > 0 and "right") or (dx < 0 and "left") or (dy > 0 and "down") or "up") useFieldItem("POKE_FLUTE", where) -- waking it is a static battle, not a warp for _ = 1, 200 do if inBattle() then fightBattle() elseif idle() then break else mashUntilIdle() end end local still = false for _, npc in ipairs(ow().npcs) do local id = tostring((npc.def and (npc.def.id or npc.def.sprite)) or "") if id:find("SNORLAX") then still = true break end end if still then note("playPokeFlute: SNORLAX still there", where) return false end say("SNORLAX woke up and the way is clear") return true end -- The movement layer's answer to a SNORLAX in the way (walkOntoWarp and -- ops.goto_ both call this before giving up): with the POKE FLUTE aboard -- the blocker is an errand, not a wall. The route's own `playPokeFlute` -- segment is easy to lose to a desync, and without this every later trip -- past Route 16's junction dead-ends on "SPRITE_SNORLAX@(26,10)" forever. function wakeBlockingSnorlax(whereFor) if (heldCount("POKE_FLUTE") or 0) <= 0 then return false end local found = false for _, npc in ipairs(ow().npcs) do local id = tostring((npc.def and (npc.def.id or npc.def.sprite)) or "") if id:find("SNORLAX") then found = true break end end if not found then return false end say(("a sleeping SNORLAX blocks %s and the POKE FLUTE is aboard -- waking it") :format(tostring(whereFor))) return MANUAL.playPokeFlute(whereFor) end function ops.manual(s, where) local fn = MANUAL[s.name] if not fn then note("manual:" .. s.name, where) return true end return fn(where) end -- --------------------------------------------------------------------- -- runner -- --------------------------------------------------------------------- -- One pass over the route. Returns false if the party wiped, so the caller -- can restart from a fresh game the way PokeBotBad's Strategies.reset does -- -- a blackout dumps us at the heal point with the route pointing -- somewhere else entirely, and there is no sane way to re-sync mid-run. -- A cutscene rarely carries us past more than a couple of segments; a long -- unbroken run of skips means the attempt is lost, not merely ahead. local MAX_CONSECUTIVE_SKIPS = 8 -- How far back a lost attempt will rewind before giving up. Each rewind -- goes strictly further back than the last. local MAX_REWINDS = 4 -- How many times a lost attempt will WALK to the segment's map before -- falling back to rewinding. Tried first because it resumes the route -- where it left off rather than replaying, but it is a long walk, so a -- run that keeps needing one is lost for some other reason. local MAX_TRAVEL_RESCUES = 3 -- How many maps a deferred grind will try before being dropped. local MAX_PENDING_GRINDS = 3 -- Grind wild encounters where we stand until the lead gains a level. -- -- Used after a second death on the same segment: arriving healthy was not -- enough, so the answer is levels rather than another attempt at the same -- HP. We do NOT flee here (that is the point) and we top up between -- encounters, so this doubles as the "heal until ready" half. local recoverFromBlackout -- Can a wild encounter fire on this cell? -- -- Mirrors OverworldController's own test (the `grass / surfing / indoor` -- branch around line 2307): grass with an encounter table, or any tile on an -- indoor map that has one and is not the excluded tileset. local function encounterCell(map, x, y) if not (G.data.encounters and G.data.encounters[map.id]) then return false end if map:isGrassCell(x, y) then return true end local indoor = G.data.field and G.data.field.indoorEncounters return indoor ~= nil and (map.def.index or -1) >= indoor.firstIndoorMap and map.def.tileset ~= indoor.excludedTileset end -- Walk to the nearest cell where an encounter can actually fire. -- -- Without this, grindALevel paced up and down wherever the blackout left us -- -- which is a POKé CENTER, indoors, with no encounter table at all. It -- looked like the bot "wigging out" on the spot: 200 steps of walking into -- the counter, no encounters possible, then "grind gave up". local function gotoEncounterCell(map) local p = ow().player if encounterCell(map, p.cellX, p.cellY) then return true end local best, bestD for y = 0, (map.heightCells or 0) - 1 do for x = 0, (map.widthCells or 0) - 1 do if encounterCell(map, x, y) and map:isWalkableCell(x, y) then local d = math.abs(x - p.cellX) + math.abs(y - p.cellY) if not bestD or d < bestD then best, bestD = { x, y }, d end end end end if not best then return false end ops.goto_({ x = best[1], y = best[2] }) p = ow().player return encounterCell(ow().map, p.cellX, p.cellY) end local function grindALevel(where) local lead = party()[1] if not lead or not lead.level then return false end -- Grinding only works where wild mons live. After a blackout we are -- standing in a Poké Center, so check before pacing rather than after. if not gotoEncounterCell(ow().map) then -- Nothing to fight here -- but a town is usually one step from a route -- that is all grass. Cerulean is the case that forced this: the rival -- north of town kept killing us, the city has no encounters at all, and -- so "train before trying again" could never do anything and the run -- was written off as hopeless while a full training ground sat one map -- away. Cross into a connected map that has wild mons and grind there; -- the segment loop re-syncs us afterwards. -- -- A SINGLE seam hop to an adjacent grassy map -- never travelTo. -- -- travelTo plans multi-hop and, when the direct seam is shut, hunts an -- alternate route: asked to reach ROUTE_3 for grinding from Pewter with -- that seam briefly unwalkable, it walked the bot clear across the map -- to ROUTE_21 and abandoned the run. Grinding only ever wants the grass -- next door, so cross exactly one connection with crossSeam (which uses -- the edge-exit idiom the old hand-rolled version got wrong -- it aimed -- at in-bounds cells and never reached the border) and give up if that -- one hop does not land somewhere with encounters. local from = ow().map local fromId = from.id local moved = false for dir, conn in pairs(from.def.connections or {}) do local dest = conn.map or conn.destMap if dest and G.data.encounters and G.data.encounters[dest] then say(("grind: %s has no wild mons; crossing %s into %s"):format( tostring(fromId), dir, tostring(dest))) crossSeam({ to = dest, dir = COMPASS_DIR[dir], cells = seamCells(from.def, dir, conn) }) if ow().map.id ~= fromId and G.data.encounters[ow().map.id] then moved = true break end end end if not (moved and gotoEncounterCell(ow().map)) then say(("grind: no wild encounters reachable from %s; skipping") :format(tostring(from.id))) return false end end local target = lead.level + 1 say(("grinding on %s: %s is level %d, want %d") :format(tostring(where), tostring(lead.species or "lead"), lead.level, target)) local sinceBattle = 0 local stuckPaces = 0 for _ = 1, 900 do if blackedOut then return false end local now = party()[1] if not now or (now.level or 0) >= target then say(("grind done: level %d"):format(now and now.level or 0)) return true end if inBattle() then grinding = true -- suppress the flee guard: fleeing earns no XP fightBattle() grinding = false sinceBattle = 0 autoHeal(where, 0.7) elseif busy() then mashUntilIdle() else -- Encounters only fire on steps taken ON an encounter tile, so pacing -- is only useful while we are still standing on one -- two steps in a -- straight line can walk off the grass strip entirely. local p = ow().player if not encounterCell(ow().map, p.cellX, p.cellY) then if not gotoEncounterCell(ow().map) then say("grind gave up: walked off the grass and cannot get back") return false end end -- Pace along whichever axis actually moves. The old strictly -- vertical pace zeroed out on a one-cell-tall grass strip -- both -- steps hit walls, no step means no encounter roll, and 200 idle -- iterations later the grind reported "no encounters here" while -- standing in perfectly good grass (Route 2's northern patch). local before = ow().player local bx, by = before.cellX, before.cellY if not walk(sinceBattle % 2 == 0 and "up" or "down") then walk(sinceBattle % 2 == 0 and "left" or "right") end local after = ow().player if after.cellX ~= bx or after.cellY ~= by then stuckPaces = 0 else stuckPaces = (stuckPaces or 0) + 1 if stuckPaces > 24 then say("grind gave up: boxed in, nothing to pace on") return false end end sinceBattle = sinceBattle + 1 if sinceBattle > 200 then say("grind gave up: no encounters here") return false end end end return false end -- Levels to ask for in one recovery. local MAX_GRIND_LEVELS = 3 -- Deaths between grind sessions. A death never ends the run -- we go back -- and try again -- but every Nth one buys levels first. Retrying is cheap -- and the RNG wins plenty of close fights on its own; a grind is minutes of -- pacing, so it is paid periodically rather than per death. local DEATHS_PER_GRIND = 10 -- Gyms are walls, not variance: fixed roster, always entered healed, and -- nothing to spend before Pewter. Grind sooner there. See recoverFromBlackout. local DEATHS_PER_GRIND_GYM = 3 -- How far back a death is allowed to rewind us before we walk back to the -- segment instead. Past this the replay costs more than the trip. local MAX_RESUME_REWIND = 12 -- Grind up to `n` levels, topping up in between. Returns levels actually -- gained, which is the number that matters: a death is only worth retrying -- if we came back stronger than we went in. local function grindLevels(n, where) local lead = party()[1] local before = lead and lead.level or 0 for round = 1, n do if blackedOut then break end if not grindALevel(where) then break end -- items only between rounds; a nurse trip warps us off the grass and -- grindALevel would just have to walk back to it autoHeal(where, 0.9) if round < n then say(("training on: %d of %d levels"):format(round, n)) end end local now = party()[1] return math.max(0, (now and now.level or 0) - before) end -- --------------------------------------------------------------------- -- stuck report -- --------------------------------------------------------------------- function WD.describeStep(s) if type(s) ~= "table" then return tostring(s) end local keys = {} for k in pairs(s) do if k ~= "op" then keys[#keys + 1] = tostring(k) end end table.sort(keys) local parts = { tostring(s.op) } for _, k in ipairs(keys) do parts[#parts + 1] = k .. "=" .. tostring(s[k]) end return table.concat(parts, " ") end -- One human-readable block answering: how far did the run get, what was it -- doing when it wedged, and what did it want next. Said into the log AND -- written to STUCK_REPORT_PATH, so a killed window still leaves the answer -- on disk. function WD.reportStuck(hit) local wasArmed = WD.armed WD.armed = false -- the report echoes the repeated lines; never recurse local progress = WD.progress local out = {} local function add(fmt, ...) out[#out + 1] = select("#", ...) > 0 and fmt:format(...) or fmt end local seg = ROUTE[progress.segment] local far = ROUTE[progress.maxSegment] add("==== STUCK REPORT ====") add("got to: segment %d/%d (%s)", progress.maxSegment, #ROUTE, far and far.map or "?") add("stuck at: segment %d/%d (%s), step %s/%s: %s", progress.segment, #ROUTE, seg and seg.map or "?", tostring(progress.stepIndex or "?"), tostring(progress.stepCount or "?"), tostring(progress.action or "?")) local o = ow() if o and o.map and o.player then local state = inBattle() and "in battle" or (busy() and "busy (menu/script/cutscene)" or "idle") add("standing: %s (%s,%s) -- %s", tostring(o.map.id), tostring(o.player.cellX), tostring(o.player.cellY), state) end local nxt = ROUTE[progress.segment + 1] if nxt then add("wanted next: segment %d/%d (%s), starting with: %s", progress.segment + 1, #ROUTE, nxt.map, WD.describeStep(nxt.steps and nxt.steps[1] or "?")) else add("wanted next: nothing -- this was the last segment") end local roster = {} for _, mon in ipairs(party()) do roster[#roster + 1] = ("%s L%s %s/%s"):format(tostring(mon.species), tostring(mon.level), tostring(mon.hp), tostring(mon.stats and mon.stats.hp)) end add("party: %s", #roster > 0 and table.concat(roster, ", ") or "(none)") local badges = {} for _, b in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", "RAINBOWBADGE", "SOULBADGE", "MARSHBADGE", "VOLCANOBADGE", "EARTHBADGE" }) do if heldCount(b) then badges[#badges + 1] = b end end add("badges: %s", #badges > 0 and table.concat(badges, ", ") or "(none)") if hit.silent then add("detector: silent stall -- %s", tostring(hit.line)) elseif (hit.period or 0) > 0 then add("detector: this %d-line cycle repeated %d times in a row:", hit.period, hit.count) for _, l in ipairs(hit.cycle or {}) do add(" | %s", l) end else add("detector: %s (seen %dx in the last %d log lines)", tostring(hit.line), hit.count or 1, WD.window) end local h = WD.history local tail = math.min(#h, 30) add("last %d log lines:", tail) for j = #h - tail + 1, #h do add(" | %s", h[j]) end add("==== END STUCK REPORT ====") for _, l in ipairs(out) do say(l) end local fh = io.open(WD.reportPath, "w") if fh then fh:write(table.concat(out, "\n"), "\n") fh:close() say("stuck report written to " .. WD.reportPath) end WD.armed = wasArmed end -- --------------------------------------------------------------------- -- checkpoints -- --------------------------------------------------------------------- -- -- Every blocker so far has been late in the route, and re-reaching it costs -- a full replay from Pallet Town. So when the run gets stuck, write the -- game's own save plus the segment we died on; POKEPORT_ROUTE_RESUME=1 then -- picks up there instead of starting over. Fix, resume, see the fix -- in -- seconds rather than a quarter of an hour. -- -- POKEPORT_ROUTE_STOP_ON_STUCK=1 save and stop rather than restarting -- POKEPORT_ROUTE_RESUME=1 load the checkpoint instead of a new game -- POKEPORT_ROUTE_CHECKPOINT=path where it lives local CHECKPOINT_PATH = os.getenv("POKEPORT_ROUTE_CHECKPOINT") or "/tmp/pokeport_route_checkpoint.lua" local STOP_ON_STUCK = os.getenv("POKEPORT_ROUTE_STOP_ON_STUCK") == "1" local function saveCheckpoint(i, reason) -- Game:writeSave(), NOT G:save -- `Game.save` is the save DATA table, so -- calling it as a method fails with "attempt to call method 'save'". local ok, err = pcall(function() G:writeSave() end) if not ok then say(("checkpoint: the game would not save (%s)"):format(tostring(err))) return false end local fh = io.open(CHECKPOINT_PATH, "w") if not fh then say("checkpoint: could not write " .. CHECKPOINT_PATH) return false end fh:write("-- written by tests/drivers/route.lua; safe to delete\n") fh:write(("return { segment = %d, reason = %q, map = %q }\n") :format(i, tostring(reason), tostring(ow().map.id))) fh:close() say(("checkpoint: saved at segment %d/%d on %s (%s) -- resume with " .. "POKEPORT_ROUTE_RESUME=1"):format(i, #ROUTE, tostring(ow().map.id), tostring(reason))) return true end local function loadCheckpoint() local chunk = loadfile(CHECKPOINT_PATH) if not chunk then return nil end local ok, data = pcall(chunk) if not ok or type(data) ~= "table" or type(data.segment) ~= "number" then return nil end return data end local function runRoute(startIndex) local failures, skips = 0, 0 -- one walk-back attempt per segment index before a skip is allowed local travelSkipTriedFor = nil -- the last segment we actually ran: a blackout only shows up once we are -- already at the heal point, so this is what the death gets attributed to local lastRanMap local diedOn = {} -- segment index -> deaths this attempt -- Badge go-back (see the league-gate check in the loop): badges whose -- gym segment this attempt has already rewound to, so a gym that -- cannot be won does not loop the rewind forever. local badgeRescued = {} -- one STRENGTH go-back per attempt, same shape as badgeRescued local strengthRescued = false -- one pre-league grind per attempt. The any% party reaches the Elite -- Four as a lone strong starter (BLASTOISE ~L62) over a bench too weak -- to trade turns -- which beat Agatha's staller but could not survive -- the five-room gauntlet with no nurse between rooms. Level the lead on -- Victory Road's wilds first so it can actually solo it. local leagueGrinded = false local LEAGUE_GRIND_TARGET = 72 local LEAGUE_GATE_MAPS = { ROUTE_22_GATE = true, ROUTE_23 = true, VICTORY_ROAD_1F = true, VICTORY_ROAD_2F = true, VICTORY_ROAD_3F = true, INDIGO_PLATEAU = true, INDIGO_PLATEAU_LOBBY = true, } -- route order; badge names are inventory keys, gyms are segment maps local BADGE_GYM_SEGMENTS = { { "BOULDERBADGE", "PEWTER_GYM" }, { "CASCADEBADGE", "CERULEAN_GYM" }, { "THUNDERBADGE", "VERMILION_GYM" }, { "RAINBOWBADGE", "CELADON_GYM" }, { "SOULBADGE", "FUCHSIA_GYM" }, { "MARSHBADGE", "SAFFRON_GYM" }, { "VOLCANOBADGE", "CINNABAR_GYM" }, { "EARTHBADGE", "VIRIDIAN_GYM" }, } -- segment index -> deaths in a row where training gained no levels. -- Reported, not acted on: no death count writes off a segment any more. -- A long barren streak means the grind cannot reach a training ground -- from where we keep waking up, which is a bug to go and read, not a -- reason to stop the run. local barren = {} -- Rewinding: each one must go strictly further back than the last, or a -- pair of segments could bounce us between them forever. local rewinds, rewindFloor = 0, #ROUTE + 1 -- Cross-map rescues spent getting un-lost. Capped for the same reason -- rewinds are: travelling to the segment's map fixes being in the wrong -- PLACE, and if we keep ending up lost anyway the problem is something -- else and retrying the walk forever just burns the attempt quietly. local travels = 0 -- Lost stretches survived by jumping forward instead of dying. The -- ROUTE_12 -> ROUTE_8 pocket wedged all five attempts of one run the -- same way: travelTo could not thread it, so the attempt abandoned at -- 85/197 every time. A forward jump lets the route's own re-sync and -- the badge/item go-backs carry on past a stretch travelTo cannot -- solve, the way the watchdog's soft-recovery does for loops. local lostJumps = 0 local MAX_LOST_JUMPS = 6 -- Grinds owed but not yet spendable (see recoverFromBlackout): the death -- happens in a gym, the wake-up is in a Poké Center, and the grass is -- somewhere between the two. local pendingGrind = 0 blackedOut = false -- each attempt starts from a fresh game -- Recover from a blackout instead of throwing the run away. -- -- We respawn at the last heal point, fully healed, so the only thing -- missing is a way back. Rather than pathfind cross-map, hand the route -- itself the job: rewind to the most recent segment that starts on the -- map we woke up in, and let the normal waypoints walk us forward again. -- That is usually a handful of segments, not a restart. local function resumeIndexFor(i, mapId) for j = i, 1, -1 do if ROUTE[j].map == mapId then return j end end end -- Returns the segment index to resume from, or nil to abandon the attempt. function recoverFromBlackout(i, wokeOn, diedMap) blackedOut = false diedOn[i] = (diedOn[i] or 0) + 1 -- Wait out the respawn BEFORE deciding anything from the map. -- -- world.blacked_out fires when the battle decides, not when the warp -- to the heal point lands, so a caller that reads ow().map.id right -- away hands us the DEATH map as the wake map. Seen live: "wiped on -- CELADON_GYM -- woke on CELADON_GYM, resuming from segment 164", -- after which the gym segment's waypoints ran on whatever map the -- respawn then delivered and the bot spent minutes cutting trees in -- circles around Celadon. The respawn always ends inside a Poké -- Center (or the Pallet bedroom on a fresh save), so wait for the -- map to actually change / a centre to appear, clearing the blackout -- text as it comes. for _ = 1, 200 do local m = tostring(ow().map.id) if m:find("POKECENTER") or (m ~= tostring(diedMap) and idle()) then break end if busy() then mashUntilIdle() else U.wait(10) end end wokeOn = ow().map.id -- Losing the lab rival costs nothing in the original: the battle counts -- either way (oaks_lab.lua sets EVENT_BATTLED_RIVAL_IN_OAKS_LAB on a -- loss too), we keep the starter, and the story carries on. So the lab -- has no work left for us and the honest resume is to step OVER it. -- Rewinding onto it the way the generic path would walks back in to -- re-run `talk` and a `battle` op that can no longer fire; restarting -- -- what this used to do -- threw the run away over a near-even level-5 -- mirror match we would only lose again just as often. if diedMap == "OAKS_LAB" and ROUTE[i + 1] and ROUTE[i + 1].map == wokeOn then say(("wiped to the lab rival -- the battle still counted, resuming from " .. "segment %d/%d"):format(i + 1, #ROUTE)) return i + 1 end -- Get out of the Poké Center before anything else. -- -- A blackout leaves us INSIDE one, where no encounter can ever fire, so -- training attempted from here reports "no wild encounters reachable on -- _POKECENTER" and gains nothing -- every time, for every death. -- Cerulean burned three deaths that way before declaring itself -- hopeless. Step out first; the grind and the resume both want the town. if tostring(wokeOn):find("POKECENTER") then local out = findWarpTo("LAST_MAP") or findWarpTo("") if out and walkOntoWarp(out.x, out.y) then wokeOn = ow().map.id end end -- Second death on the same segment: arriving healthy clearly is not -- enough, so come back STRONGER rather than merely repeating the attempt. -- -- Dying is a signal that we are underlevelled, and the answer to -- underlevelled is levels: grind, heal, grind again, top up once more and -- walk back in. A segment is NEVER abandoned for killing us, however -- many times -- the run just keeps going back. -- -- The grind runs every DEATHS_PER_GRIND deaths rather than on every one. -- Retrying is cheap and the RNG alone wins plenty of fights that were -- close; a grind session is minutes of walking in circles, so paying it -- per death made a bad segment eat the whole run in training rather -- than in attempts. -- Gyms grind sooner than the rest of the route. -- -- A route death is often just variance -- a bad crit, a trainer met at -- the wrong HP -- and walking back is cheap, so ten tries before paying -- for a grind is a fair trade there. A gym is not that: the roster is -- fixed, we arrive healed every time, and there is no item to spend -- before Pewter (Viridian Mart sells no POTION at all and the Pewter -- shop is segment 28, AFTER Brock at 26). So a gym that beat us will -- beat us again in the same way, and levels are the only variable we -- control. Brock took seven deaths in a row at ten-per-grind, each one -- a free walk back to the same loss. -- Silph Co. counts as a gym for grind cadence: the 7F rival is a -- fixed roster fought at full heal, exactly the "wall, not variance" -- shape -- and the wake town (Saffron) has no wild mons, so the -- ten-death route cadence meant SEVEN potion-stocked wipes before -- the first level was bought. local segMapName = tostring(ROUTE[i] and ROUTE[i].map) local gymHere = segMapName:find("GYM") ~= nil or segMapName:find("SILPH") ~= nil local everyN = gymHere and DEATHS_PER_GRIND_GYM or DEATHS_PER_GRIND if diedOn[i] % everyN == 0 then say(("segment %d/%d has killed us %dx this attempt -- training before " .. "another try"):format(i, #ROUTE, diedOn[i])) -- ask for more the more it has beaten us, capped so one bad segment -- cannot eat the whole run local want = math.min(diedOn[i] - 1, MAX_GRIND_LEVELS) local gained = grindLevels(want, wokeOn) -- Grinding MOVES us. gotoEncounterCell walks to the nearest cell that -- can actually spawn something, and when the wake-up map has no wild -- mons at all that means crossing into a neighbour -- "PEWTER_CITY -- has no wild mons; crossing south into ROUTE_2". Everything below -- decides where to resume from `wokeOn`, so leaving it stale resumes -- onto a segment for the town while we are standing on the route: -- "resuming from segment 29/197" followed immediately by "skipping -- segment 29: expects PEWTER_CITY, on ROUTE_2", and then every -- segment through Mt. Moon skipping in one burst. wokeOn = ow().map.id if blackedOut then -- died while training: we are back at the heal point, which is where -- we were going to resume from anyway. Clear it and carry on. blackedOut = false say("died while training") end if gained > 0 then barren[i] = 0 say(("training done: %d level%s gained, healing before another try") :format(gained, gained == 1 and "" or "s")) -- the last top-up before walking back in; the nurse is free and we -- are standing next to one autoHeal(wokeOn, 1.0, true) else -- We wake in a Poké Center, where no encounter can fire, so this -- often cannot grind HERE at all. Carry the intent and let the main -- loop spend it on the first map with wild mons -- the grass the -- route walks us back through. barren[i] = (barren[i] or 0) + 1 pendingGrind = math.min((pendingGrind or 0) + 1, MAX_PENDING_GRINDS) say(("training gained nothing (%d in a row); deferred to the next map " .. "with wild encounters"):format(barren[i])) -- Deliberately NOT a give-up. A segment is never written off for -- killing us, however often: the run keeps trying, and a grind -- session every DEATHS_PER_GRIND deaths is what changes the odds -- between attempts. A grind that gains nothing is worth logging, -- not worth ending a run over -- the training ground is often just -- somewhere this recovery could not reach yet. end end -- We respawn INSIDE a Poké Center, and the route walks *past* centres -- far more often than through them -- only 3 of 197 segments are one. -- So the map we wake up on is usually absent from the route entirely. -- Step outside and resume from the city instead of giving up: this is -- what made a death in Pewter Gym restart the whole run even though we -- had already beaten Brock. -- Step outside BEFORE choosing where to resume. -- -- We always wake inside a Poké Center, and when the route happens to have -- a segment for that town's centre, resumeIndexFor matches it and we -- resume onto a step whose waypoints only make sense on the way IN. The -- segment then walks straight back out, logs "left ... partway through; -- re-syncing", and we die at the same place again: Cerulean looped -- 40 -> die at 46 -> 40 with the failure count climbing 13, 16, 20, 24. -- Resuming from the town is always the useful answer. if tostring(wokeOn):find("POKECENTER") then local out = findWarpTo("LAST_MAP") or findWarpTo("") if out and walkOntoWarp(out.x, out.y) then wokeOn = ow().map.id end end local j = resumeIndexFor(i, wokeOn) if not j then local out = findWarpTo("LAST_MAP") or findWarpTo("") if out and walkOntoWarp(out.x, out.y) then wokeOn = ow().map.id j = resumeIndexFor(i, wokeOn) end end -- Resuming a LONG way back is worse than walking back. -- -- resumeIndexFor picks the most recent segment starting on the map we -- are standing on, which is fine when that is a step or two behind and -- ruinous when it is not. Dying on ROUTE_9 (segment 76) and grinding -- beforehand left us on ROUTE_4, whose most recent segment is 38 -- so -- one death discarded thirty-eight segments, and everything from Mt. -- Moon forward had to be replayed to get back to the fight that killed -- us. Walking back to where we died costs minutes; replaying costs the -- run. Only accept a rewind that is genuinely nearby. if j and (i - j) > MAX_RESUME_REWIND then say(("wiped on %s -- the nearest segment on %s is %d/%d, %d back; " .. "walking to %s instead"):format(tostring(diedMap), tostring(wokeOn), j, #ROUTE, i - j, ROUTE[i].map)) if travelTo(ROUTE[i].map, wokeOn) then return i end say("the walk back failed; falling back to the rewind") end -- Nothing in the route starts where we woke up. Walk back to the map -- the segment we died on wants and carry on from there -- the run is -- never restarted or abandoned over a death, so this has to produce an -- answer. Vermilion is the case that needs it: dying on ROUTE_6 wakes -- us in VERMILION_POKECENTER, which no segment starts on. if not j and travelTo(ROUTE[i].map, wokeOn) then say(("wiped on %s, woke on %s with no segment there -- travelled back " .. "to %s"):format(tostring(diedMap), tostring(wokeOn), ROUTE[i].map)) return i end if not j then -- Even the walk failed. Retry the segment where we stand rather than -- throwing the attempt away; the loop's own map-wait and the -- lost-segment rescue get another go at re-syncing us. say(("wiped on %s, woke on %s -- no segment starts there and the walk " .. "back failed; retrying segment %d/%d anyway") :format(tostring(diedMap), tostring(wokeOn), i, #ROUTE)) return i end say(("wiped on %s (now %dx) -- woke on %s, resuming from segment %d/%d " .. "instead of restarting") :format(tostring(diedMap), dangerAt(diedMap), tostring(wokeOn), j, #ROUTE)) return j end -- Every way an attempt can be written off funnels through here, so -- POKEPORT_ROUTE_STOP_ON_STUCK covers deaths as well as lost-in-the-route -- skips: checkpoint, then stop instead of replaying from Pallet Town. -- Returns nil to stop the whole run, false to restart the attempt. local function abandon(at, reason) -- same report the watchdog writes, so every stop explains itself WD.reportStuck({ period = 0, count = 1, cycle = {}, line = "abandoned: " .. tostring(reason) }) saveCheckpoint(at, reason) if STOP_ON_STUCK then return nil end return false end local i = (startIndex and startIndex > 1) and (startIndex - 1) or 0 while i < #ROUTE do i = i + 1 local seg = ROUTE[i] WD.progress.segment = i -- ops.fieldMove and the elevator handler read where the NEXT segment -- wants us; set it up here so both can see it from the loop's top. nextMapWanted = ROUTE[i + 1] and ROUTE[i + 1].map or nil -- On-demand checkpoint: `touch /tmp/pokeport_route_savenow` and the -- run banks its progress (game save + segment) at the next segment -- boundary. Exists because a driver fix mid-run used to mean killing -- the process -- and with it every level the Silph attrition war had -- bought since the last stuck-checkpoint. do local marker = io.open("/tmp/pokeport_route_savenow", "r") if marker then marker:close() os.remove("/tmp/pokeport_route_savenow") saveCheckpoint(i, "requested via savenow marker") end end -- Rolling checkpoint every 10 segments, no external toucher needed. -- A run once reached segment 182 with all eight badges and then -- "completed" by skipping the sealed league -- and NOTHING was on -- disk: no toucher was running, no abandon fired, and route -- completion never saved. Hours of progress, gone at exit. Never -- again: the driver banks its own progress as it goes. if i % 10 == 0 then saveCheckpoint(i, "rolling (every 10 segments)") end -- GO BACK FOR A MISSING BADGE before the league gate can wall us. -- -- Route 22/23's guards badge-check in order and shove the player -- back (vanilla), and the route only ever moves FORWARD -- so a run -- that lost a gym segment to a travel failure (Blaine's whole -- island skipped over a failed sea trip, once) used to reach the -- gate with seven badges and thrash against the shove until the -- attempt died. The badges are ordinary inventory; when a league -- segment comes up short, rewind the index to the missing badge's -- own gym segment and let the ordinary mismatch->travelTo machinery -- carry us there. Earliest missing badge first (Cinnabar's segment -- sits before Viridian's, and Viridian Gym itself only unlocks at -- seven badges). Once per badge per attempt, so a gym that -- genuinely cannot be won does not loop the rewind forever. if LEAGUE_GATE_MAPS[seg.map] then -- STRENGTH gate, mirroring the badge go-back below: Victory Road is -- boulders before it is anything else, and one run arrived with all -- eight badges and no STRENGTH -- the Safari gate misfire silently -- dropped GOLD_TEETH, so the Warden never paid out and nothing -- noticed for fifty segments. Rewind to the link that is actually -- missing, once per attempt. (HM-in-bag-but-untaught needs no -- rewind: ops.fieldMove now teaches from the bag on the spot.) if not strengthRescued and not slotKnowing("STRENGTH") and not (G.save.inventory or {}).HM_STRENGTH then local wantMap = (G.save.inventory or {}).GOLD_TEETH and "WARDENS_HOUSE" or "SAFARI_ZONE_WEST" local back -- EARLIEST visit: SAFARI_ZONE_WEST's later twin is the dig-out -- segment, not the one that stands at the GOLD_TEETH ball for j = 1, i - 1 do if ROUTE[j].map == wantMap then back = j break end end if back then strengthRescued = true say(("the league gate is ahead but STRENGTH is missing -- " .. "rewinding to segment %d/%d (%s) for %s") :format(back, #ROUTE, wantMap, wantMap == "WARDENS_HOUSE" and "HM_STRENGTH" or "the GOLD_TEETH")) i = back - 1 goto nextSegment end end local missing for _, bg in ipairs(BADGE_GYM_SEGMENTS) do if not (G.save.inventory or {})[bg[1]] and not badgeRescued[bg[1]] then missing = bg break end end if missing then local back for j = i - 1, 1, -1 do if ROUTE[j].map == missing[2] then back = j break end end if back then badgeRescued[missing[1]] = true say(("the league gate is ahead but %s is missing -- rewinding " .. "to segment %d/%d (%s) to earn it") :format(missing[1], back, #ROUTE, missing[2])) i = back - 1 goto nextSegment end end end -- Standing in an elevator? Ride out to the floor we need, before the -- map-wait can react to a bounce. -- -- An elevator is entered two ways, and both end here: on purpose (the -- route's segment 98), or by accident -- a floor's elevator-door warp -- at (1,1) catching a goto mid-climb. Either way, an un-ridden car -- oscillates on its default 1F exit (ELEVATOR <-> 1F forever), which is -- what wedged the run at Celadon. Checking the PHYSICAL map, not -- seg.map, catches the accidental case the seg.map-only check missed. -- Ride to seg.map's own floor when we were mid-climb, or to the next -- segment's floor when this segment IS the elevator. if tostring(ow().map.id):find("ELEVATOR") then local floor = tostring(seg.map):find("ELEVATOR") and nextMapWanted or seg.map rideElevator("elevator", floor) if tostring(seg.map):find("ELEVATOR") then goto nextSegment end end -- Once the essential Celadon Mart shopping is done, LEAVE. -- -- The only things the mart is climbed for are the POKE_DOLL (4F, the -- Marowak escape) and a drink (roof, the Saffron gates). After those, -- the route's remaining mart segments -- 5F again, the elevator, 1F -- -- are pure descent, and they are a warp bounce-trap: the elevator drops -- you onto the reciprocal warp and oscillates ELEVATOR <-> 1F forever. -- So when both essentials are in the bag and we are still in the mart, -- walk out to CELADON_CITY and jump to the segment that resumes there, -- skipping the whole fragile descent. local function hasDrink() for _, d in ipairs({ "FRESH_WATER", "SODA_POP", "LEMONADE" }) do if (heldCount(d) or 0) > 0 then return true end end return false end if tostring(seg.map):find("CELADON_MART") and (heldCount("POKE_DOLL") or 0) > 0 and hasDrink() then for j = i, math.min(i + 8, #ROUTE) do if ROUTE[j].map == "CELADON_CITY" then say(("Celadon Mart done (POKE_DOLL + drink) -- leaving to " .. "CELADON_CITY, resuming segment %d/%d"):format(j, #ROUTE)) if travelTo("CELADON_CITY", seg.map) then i = j - 1 end goto nextSegment end end end -- The route rides the Celadon elevator only to descend to 1F, and every -- Celadon floor is joined by stairs, so the elevator is optional there -- and a liability (see the bounce-trap note in mapGraph). If this -- segment IS the elevator and we are not in it, skip it -- the next -- segment's floor is reached by the stairs-based travelTo below. if tostring(seg.map):find("CELADON_MART_ELEVATOR") then goto nextSegment end -- Multi-floor interior: climb by stairs via travelTo, not the route's -- per-floor gotos. Celadon Mart is six floors joined by up/down stairs -- (and an elevator), and the route walks each floor by hand -- a path -- that keeps stepping onto the wrong staircase or the elevator door and -- desyncing, which is what skipped the ROOF and its Fresh Water (the -- key to every Saffron gate). travelTo targets each specific stair warp -- and climbs cleanly; the segment's own steps then handle the in-floor -- shopping once we are on the right floor. if ow().map.id ~= seg.map and tostring(seg.map):find("CELADON_MART") then travelTo(seg.map, seg.map) end -- Wait for the map to actually be the one this segment describes. -- -- PokeBotBad advances its path list on map CHANGE (action/walk.lua:91) -- rather than by counting steps, and that difference matters: a warp -- or a cutscene can still be in flight when the previous segment's -- last waypoint lands. Oak's escort is the worst case -- it walks the -- player from Pallet Town into the lab over many frames, so running -- the lab segment immediately fires the starter pick and the rival -- battle out in Pallet Town, where they silently do nothing. The -- rival then never leaves, and blocks the route on the return visit. if ow().map.id ~= seg.map then for _ = 1, 240 do if ow().map.id == seg.map then break end if inBattle() then fightBattle() elseif busy() then mashUntilIdle() else U.wait(5) end end end local here = ow().map.id if here ~= seg.map then WD.progress.stepIndex, WD.progress.stepCount = nil, nil WD.progress.action = ("re-sync: expected %s, standing on %s") :format(seg.map, here) -- Never run a segment's steps on the wrong map: the waypoints are -- meaningless there and the interactions land on whatever happens -- to be adjacent. Skip it and let a later segment re-sync. -- A blackout is the one mismatch that will never re-sync: it drops us -- at the heal point while the route points somewhere else, so every -- remaining segment skips in one burst and the attempt reports a -- full-length pass over nothing. End it here instead. if blackedOut then -- `here` is the heal point, not where we died -- blame the last -- segment we actually ran, or the memory learns "Poké Center is -- dangerous" and nothing useful. recordDeath(lastRanMap) local resumed = recoverFromBlackout(i, here, lastRanMap) if not resumed then return abandon(i, "died too many times") end i = resumed - 1 -- the loop's own i = i + 1 lands us on it goto nextSegment end -- We are on a sub-map the route has finished with, and nothing walks -- us out of it. Any map with a LAST_MAP warp is one we entered from -- somewhere else, so that warp is the way back to a map the route -- still has segments for. -- -- Poké Centers are the case that made this obvious (a blackout or an -- ESCAPE ROPE drops us in one), but they are not the only one: -- VERMILION_DOCK strands us the same way. Its segment 68 is the single -- step `goto (14,2)` -- the gangplank tile we are already standing on -- when we disembark -- while the exit is warp 1 at (14,0), which the -- route never mentions. The bot stood on the arrival tile and skipped -- every remaining segment. local wayOut = findWarpTo("LAST_MAP") if wayOut then local from = here if walkOntoWarp(wayOut.x, wayOut.y) then here = ow().map.id say(("stepped out of %s onto %s"):format(tostring(from), tostring(here))) if here == seg.map then skips = 0 goto runSegment end end end -- WALK to the segment before skipping it -- once per segment index. -- -- Skipping exists for segments a cutscene already carried us past. -- But the route revisits maps, so a run of skips re-enters at the -- first segment matching wherever we STAND -- which can be a LATER -- twin of the same map, silently dropping everything between. -- ROUTE_16_GATE_1F appears at both ~102 and 120: standing there -- after a stall skipped 112-119 in one burst, and with them the -- POKE_DOLL, the MAROWAK, and Mr. Fuji's POKE_FLUTE -- after which -- the SNORLAX is unpassable and the run is dead anyway. A travel -- that works costs a walk; a skip that is wrong costs the game. if travelSkipTriedFor ~= i then travelSkipTriedFor = i say(("segment %d/%d expects %s -- travelling there before skipping") :format(i, #ROUTE, seg.map)) if travelTo(seg.map, here) and ow().map.id == seg.map then skips = 0 goto runSegment end here = ow().map.id -- a failed trip still moves us end say(("skipping segment %d/%d: expects %s, on %s") :format(i, #ROUTE, seg.map, here)) failures = failures + 1 skips = skips + 1 -- Skipping is meant to step over a segment a cutscene carried us -- past. A long unbroken run of them means we are lost, and carrying -- on is actively dangerous: the route revisits maps, so segment 175 -- (the ENDGAME Viridian Gym) matches "VIRIDIAN_CITY" just as well as -- segment 12 does. One desynced attempt did exactly that and spent -- its remaining time walking a level-12 party into a gym locked -- until seven badges. if skips > MAX_CONSECUTIVE_SKIPS then -- Rewind FURTHER before writing the attempt off. -- -- Resuming lands us on a segment that merely *matches* the map we -- woke on, which is not the same as one that LEADS anywhere: after -- a Cerulean death the resume sat on CERULEAN_CITY while segments -- 46+ wanted ROUTE_24, BILLS_HOUSE, VERMILION... and every one -- skipped. The route already contains the walk we need -- it is -- just earlier in the list. So step back to an earlier segment on -- the map we are actually standing on and let the route drive us -- forward again, rather than giving up on a run that is merely -- pointed at the wrong entry. local j for k = math.min(rewindFloor, i) - 1, 1, -1 do if ROUTE[k].map == here then j = k break end end if j and rewinds < MAX_REWINDS then rewinds = rewinds + 1 rewindFloor = j skips = 0 say(("lost on %s -- rewinding to segment %d/%d (rewind %d/%d) " .. "instead of abandoning"):format(here, j, #ROUTE, rewinds, MAX_REWINDS)) i = j - 1 goto nextSegment end -- Last resort, AFTER the rewind has run out of ideas. -- -- Ordering matters and was learned the hard way: this used to run -- BEFORE the rewind, on the theory that walking to the segment's -- map is more honest than replaying. It is -- but it is also a long -- walk that MOVES us, and moving invalidates the rewind targets -- behind it. A Mt. Moon attempt that the rewind alone had always -- recovered instead travelled to MT_MOON_1F, failed to find the -- B1F staircase, rewound from there and walked back out to ROUTE_4 -- worse off than it started. The rewind is cheap and proven; this -- is neither, so it only gets the cases the rewind cannot take. if travels < MAX_TRAVEL_RESCUES and travelTo(seg.map, here) then travels = travels + 1 skips = 0 say(("travelled to %s -- resuming segment %d/%d (rescue %d/%d)") :format(seg.map, i, #ROUTE, travels, MAX_TRAVEL_RESCUES)) i = i - 1 goto nextSegment end -- A failed travelTo still moves us -- it abandons the trip wherever -- the last hop landed -- so report where we actually are. here = ow().map.id -- Before writing the whole attempt off: JUMP FORWARD to the next -- segment whose map we can actually reach from here, and carry -- on. A stretch travelTo cannot thread (the ROUTE_12 west pocket) -- should cost a few skipped segments, not the run -- the badge -- and STRENGTH go-backs refetch anything load-bearing that the -- jump steps over. if lostJumps < MAX_LOST_JUMPS then local jumpTo for j = i + 1, #ROUTE do if ROUTE[j].map == here then jumpTo = j break end end -- nothing on this map ahead: hand it to reachableSet via a -- forward scan for a segment travelTo can still get to if not jumpTo then for j = i + 1, math.min(i + 20, #ROUTE) do if ROUTE[j].map ~= seg.map then jumpTo = j break end end end if jumpTo then lostJumps = lostJumps + 1 skips = 0 say(("lost on %s -- jumping forward to segment %d/%d (%s), " .. "recovery %d/%d"):format(here, jumpTo, #ROUTE, ROUTE[jumpTo].map, lostJumps, MAX_LOST_JUMPS)) -- Advancing the index alone left us PHYSICALLY marooned in the -- ROUTE_12 (0,62) pocket, so the next travelTo re-trapped from -- the identical dead spot -- the jump just cycled. DIG warps -- to the last Poké Center, a real hub the route can navigate -- from; point it at the jump target's map so the built-in -- post-DIG travelTo heads the right way. if slotKnowing("DIG") then nextMapWanted = ROUTE[jumpTo].map ops.fieldMove({ move = "dig" }, here) end i = jumpTo - 1 goto nextSegment end end say(("lost: %d segments skipped in a row, abandoning attempt at %d/%d") :format(skips, i, #ROUTE)) return abandon(i, ("lost on %s, expected %s"):format(here, seg.map)) end goto nextSegment end ::runSegment:: skips = 0 if i > WD.progress.maxSegment then WD.progress.maxSegment = i -- a new segment reached on its own map IS progress: forget the old -- chatter so earlier retries cannot trip the loop detector later WD.history = {} end lastRanMap = seg.map -- Spend a deferred grind as soon as we are somewhere it can work. if pendingGrind > 0 and G.data.encounters and G.data.encounters[seg.map] then if grindALevel(seg.map) then pendingGrind = 0 else pendingGrind = pendingGrind - 1 -- do not keep retrying forever end end -- About to walk into a gym: stop at the town's Poké Center first. -- -- Two payoffs, and the second is the bigger one. It heals to full for -- free, and the nurse writes the heal point (SetLastBlackoutMap), so a -- loss respawns us next door instead of wherever we last healed. Without -- this the bot arrived at Brock healthy, never touched Pewter's centre, -- and its respawn was still VIRIDIAN -- so every Brock death cost a full -- recrossing of Viridian Forest. It died to Brock on the first attempt in -- three runs straight, and paid that walk each time. -- RESTORED: the pre-gym Poké Center stop (allowCenter = true). -- -- It was removed because the nurse writes the heal point and the route -- TRAVELS by heal point: segment 72 leaves the Pokémon Fan Club with -- `fieldMove dig`, DIG warps to the last Poké Center town, and the -- route assumes that is Cerulean -- so topping up at Vermilion before -- Surge silently retargeted the trip and stranded the run. The note -- left here said the fix was "NOT to put this back as it was -- it is -- to make the return trip independent of the heal point". -- -- That is now done: ops.fieldMove re-targets DIG and FLY through -- travelTo when the landing map is not what the next segment wants, so -- where we last healed no longer steers the route. The precondition is -- met, so the stop comes back. -- -- It is worth real time. Without it the heal point stays wherever it -- happened to be -- a ROUTE_3 death after Pewter woke us in VIRIDIAN -- and cost a full re-crossing of Viridian Forest -- and gym losses -- respawn a town behind instead of next door. -- nextMapWanted is already set at the loop top (ops.fieldMove and the -- elevator handler both read it); nextSeg here is just for the gym test. local nextSeg = ROUTE[i + 1] if nextSeg and tostring(nextSeg.map):find("GYM") then -- Unconditional, NOT via autoHeal's threshold. -- -- autoHeal only walks to a nurse when HP is under the bar, and we -- arrive at gyms healthy -- so routing this through it did nothing at -- all: PEWTER_GYM still killed us five times with VIRIDIAN as the -- respawn, paying a re-crossing of Viridian Forest each time. The -- heal is the smaller half of this stop; the heal POINT is the point. visitPokeCenter(seg.map, true) healAnchor = seg.map autoHeal(seg.map, 0.95, true) end -- Keep the respawn near where we actually are. -- -- A blackout warps to wLastBlackoutMap, and the nurse is the only thing -- that moves it. Left alone it lags badly: with VERMILION as the last -- centre, a death in ROCK_TUNNEL_B1F woke us there and resumed eight -- segments back, replaying the Fan Club DIG and the whole underground -- walk to Cerulean -- twice in a row, which is most of what a stalled -- run is actually spending its time on. -- -- Once per town, and only when we are ACTUALLY STANDING on that town. -- -- The guard is `ow().map.id == seg.map`, and it is load-bearing, not -- belt-and-braces. findWarpTo reads the CURRENT map, but the fire -- condition keyed on seg.map -- so when a warp had bounced us onto a -- different map than the segment expected, this dove into whatever -- centre the map we happened to be standing on had. That is exactly how -- it broke the S.S. Anne: boarding put us on the ship, an edge-warp -- bounced us back to VERMILION_CITY, and this then marched into -- Vermilion's Poké Center while the segment wanted SS_ANNE_1F, skipping -- the entire ship -- and with it HM01 CUT, which the run needs 15 -- segments later to clear the Route 9 tree. -- -- Re-anchoring is a convenience (cheaper deaths); it must never perturb -- a segment that is mid-warp. On our own map with a centre, and not -- already anchored here. if ow().map.id == seg.map and healAnchor ~= seg.map and findWarpTo("POKECENTER") then local was = healAnchor healAnchor = seg.map say(("re-anchoring the heal point at %s (was %s)") :format(seg.map, tostring(was))) visitPokeCenter(seg.map, true) end -- The Indigo lobby is the one nurse with no POKECENTER warp -- we are -- already standing in the building she works in. Anchor the respawn -- here before the Elite Four: a loss otherwise wakes us a region away -- (travelTo cannot re-cross Victory Road's boulder puzzles), and the -- lobby segment is what a blackout resumes from, so the walk back to -- Lorelei's door is the route's own steps. Restock while we are here: -- five fights, no nurse between them, and the bag holds whatever -- survived Victory Road. if seg.map == "INDIGO_PLATEAU_LOBBY" and ow().map.id == seg.map then if healAnchor ~= seg.map then healAnchor = seg.map say("re-anchoring the heal point at the Indigo lobby nurse") talkToNurse(seg.map) end ops.shop({ list = "indigo" }, seg.map) end -- Saffron is the Silph staging town, and a checkpoint-resumed bag -- arrives EMPTY -- the shop segments live behind the checkpoint, so -- the 7F rival was fought potion-less five wipes in a row. When the -- bag has no HP item, buy some next door before climbing the tower -- of trainers. -- Victory Road floors: put the boulders on their switches FIRST, -- before the segment's own steps walk into (or shove apart) the -- puzzle. Idempotent -- each switch is guarded by its event flag. if VR_SWITCHES[seg.map] and ow().map.id == seg.map then solveVictoryRoadSwitches() end -- Guarantee the POKE_FLUTE. It gates two Snorlax (ROUTE_16, ROUTE_12), -- and its handoff is a two-step scripted chain the travel/skip machinery -- can bypass: talk Fuji at the tower top (rescue -> warps you home) then -- talk him again at his house (gives the flute, but ONLY if rescued - -- story.lua:439). A skipped tower talk means Fuji never appears at home, -- the house talk gives nothing, and Route 16 seals the run seven -- segments later. Do it here, keyed on the flags, not the fragile -- waypoints. Same family as the CARD_KEY and GOLD_TEETH misses. local fujiFlags = G.save.flags or {} if not fujiFlags.EVENT_GOT_POKE_FLUTE then if ow().map.id == "POKEMON_TOWER_7F" and not fujiFlags.EVENT_RESCUED_MR_FUJI then local fuji = findObject("MR_FUJI") if fuji and stepUpTo(fuji.x, fuji.y) then interact() mashUntilIdle() say("rescued Mr. Fuji at the tower top") end elseif ow().map.id == "MR_FUJIS_HOUSE" and fujiFlags.EVENT_RESCUED_MR_FUJI then local fuji = findObject("MR_FUJI") if fuji and stepUpTo(fuji.x, fuji.y) then interact() mashUntilIdle() if (G.save.flags or {}).EVENT_GOT_POKE_FLUTE then say("collected the POKE_FLUTE from Mr. Fuji") end end end end -- Level the lead for the Elite Four while standing on Victory Road's -- wilds -- the last grass before the sealed five-room gauntlet. Once -- per attempt, and only if the lead is actually short of the target, -- so a strong resume does not pace for nothing. if not leagueGrinded and tostring(seg.map):find("VICTORY_ROAD") and ow().map.id == seg.map and G.data.encounters and G.data.encounters[seg.map] then leagueGrinded = true local lead = party()[1] local cur = lead and lead.level or 0 if cur > 0 and cur < LEAGUE_GRIND_TARGET then say(("pre-league grind: lead is L%d, training toward L%d on %s") :format(cur, LEAGUE_GRIND_TARGET, seg.map)) local gained = grindLevels(LEAGUE_GRIND_TARGET - cur, seg.map) say(("pre-league grind: gained %d level(s), lead now L%d") :format(gained, (party()[1] or {}).level or cur)) autoHeal(seg.map, 0.95, true) end end local RESTOCK_TOWNS = { SAFFRON_CITY = "saffronRestock", CINNABAR_ISLAND = "cinnabarRestock" } local restockList = RESTOCK_TOWNS[seg.map] if restockList and ow().map.id == seg.map then local hpItems = 0 for _, id in ipairs(HP_ITEMS) do hpItems = hpItems + (heldCount(id) or 0) end if hpItems < 2 then local mart = findWarpTo("MART") if mart and walkOntoWarp(mart.x, mart.y) then say(("bag is out of HP items -- restocking (%s)"):format(restockList)) ops.shop({ list = restockList }, seg.map) local out = findWarpTo("LAST_MAP") or findWarpTo("") if out then walkOntoWarp(out.x, out.y) end end end end if centerCooldown > 0 then centerCooldown = centerCooldown - 1 end -- the one place a nurse detour is safe: between segments, where the -- next segment re-paths from wherever the warp left us autoHeal(seg.map, riskThreshold(seg.map), true) -- (Elevator handling now lives at the top of the loop, keyed on the -- PHYSICAL map, so it catches both the intended arrival and an -- accidental entry mid-climb. See there.) for si, s in ipairs(seg.steps) do if blackedOut or wiped() then recordDeath(seg.map) -- wait out the warp to the heal point before deciding where we are for _ = 1, 240 do if idle() and not inBattle() then break end if inBattle() then fightBattle() else mashUntilIdle() end end local resumed = recoverFromBlackout(i, ow().map.id, seg.map) if not resumed then return abandon(i, "died too many times") end i = resumed - 1 goto nextSegment end -- A warp, a ledge hop or a gate shove can move us off this segment's -- map partway through it. Its remaining waypoints are meaningless -- here: out of bounds they look like the route's "leave by this edge" -- idiom, so we spend the rest of the segment shoving Red into a -- border -- e.g. Viridian's (20,35) run while still on ROUTE_1, which -- pushes right into Route 1's east wall over and over. Stop and let -- the map-wait at the top of the loop re-sync us. if ow().map.id ~= seg.map then say(("left %s partway through segment %d/%d (now on %s); re-syncing") :format(seg.map, i, #ROUTE, ow().map.id)) failures = failures + 1 break end -- Re-check health before EVERY step, not just at the segment -- boundary. Route trainers engage on sight as we walk past them -- -- they never come through ops.battle -- so a segment that began -- healthy can still march a nearly-dead lead into a fight it cannot -- flee (trainers refuse). Route 3 after Brock is exactly this: the -- gauntlet IS the map, and the old per-segment check only looked -- once, before the first of them. autoHeal(seg.map, riskThreshold(seg.map)) WD.progress.stepIndex, WD.progress.stepCount = si, #seg.steps WD.progress.action = WD.describeStep(s) local fn = ops[s.op == "goto" and "goto_" or s.op] if not fn then note("UNHANDLED:" .. s.op, seg.map) else -- ops receive whether this is the segment's final step; goto_ uses -- it to decide a warp is an intended exit rather than an arrival tile local ok = fn(s, seg.map, si == #seg.steps) if ok == false then failures = failures + 1 end end end if i % 10 == 0 then say(("segment %d/%d map=%s failures=%d"):format(i, #ROUTE, ow().map.id, failures)) end ::nextSegment:: end say("---- route complete ----") say(("segments: %d failures: %d"):format(#ROUTE, failures)) -- bank the final state whatever happened: a "complete" run that -- skip-cascaded its tail used to exit with nothing on disk saveCheckpoint(#ROUTE, "route complete") return true end -- Run one attempt with the stuck watchdog armed. The watchdog throws its -- sentinel out of whatever loop was repeating itself; translate that into -- the same checkpoint-and-stop shape abandon() produces, plus the written -- report of where the run was and what it wanted next. local function runRouteGuarded(startIndex) local resumeAt = startIndex local recoveries = 0 local stuckAt = {} -- segment -> times wedged there (progressive rewind) while true do WD.history = {} WD.lastActivity = U.frame() WD.armed = WD.enabled local ok, result = pcall(runRoute, resumeAt) WD.armed = false if ok then return result end if not (type(result) == "table" and result.routeStuck) then error(result, 0) -- a real bug, not a stuck loop; let the harness print it end local hit = result.routeStuck WD.reportStuck(hit) saveCheckpoint(math.max(WD.progress.segment, 1), "stuck loop: " .. tostring(hit.line)) if STOP_ON_STUCK then return nil end -- A 10-repeat stuck loop is NOT a stop. Per the run's rule: try -- something else, then rewind and continue. The run only ever ends on -- a real death-abandon, route completion, or the outer time cap. recoveries = recoveries + 1 -- (1) Try something else: DIG out to the last Poké Center. Some wedges -- have no in-place escape -- the ROUTE_12 (0,62) pocket is walled by a -- Snorlax, water, and an out-of-region seam -- and a rewind alone -- would just walk back into them. A hub the route can navigate from -- breaks that. Outdoors + DIG known only; the engine refuses it -- indoors, where a rewind is the right tool anyway. for _, d in ipairs({ "up", "down", "left", "right", "a", "b", "start", "select" }) do G.input.state[d] = false -- sentinel can unwind mid-menu, keys held end for _ = 1, 10 do if idle() and not inBattle() then break end if inBattle() then fightBattle() else mashUntilIdle() end end if slotKnowing and slotKnowing("DIG") and G.overworld and G.stack:top() == G.overworld then local before = ow().map.id nextMapWanted = nil pcall(function() ops.fieldMove({ move = "dig" }, before) end) if ow().map.id ~= before then say(("watchdog: DIG warped %s -> %s (trying something else)") :format(tostring(before), tostring(ow().map.id))) end end -- (2) Rewind ~10 segments and continue. If the SAME stretch keeps -- wedging, back up further each time (10, 20, 30...) so two segments -- cannot ping-pong forever -- eventually the rewind reaches ground the -- route can cross, or the whole route replays from the start. local at = math.max(WD.progress.segment, 1) stuckAt[at] = (stuckAt[at] or 0) + 1 local back = 10 * stuckAt[at] resumeAt = math.max(1, at - back) say(("watchdog: stuck at segment %d (x%d) -- rewinding %d to segment %d " .. "and continuing"):format(at, stuckAt[at], back, resumeAt)) -- Last-resort backstop only: after very many recoveries the progressive -- rewind has replayed from the start repeatedly and still cannot pass. -- Restart the ATTEMPT (fresh game, next of the five) rather than stop -- the run -- this is not the 10-repeat halt, it is genuine give-up. if recoveries > 25 then say("watchdog: 25 recoveries without lasting progress -- restarting " .. "the attempt") return false end end end local function report() local keys = {} for k in pairs(skipped) do keys[#keys + 1] = k end table.sort(keys) if #keys > 0 then say("skipped ops:") for _, k in ipairs(keys) do say((" %-40s x%d"):format(k, skipped[k])) end end end return function(game) G = game loadMemory() do local known = {} for map, n in pairs(deaths) do known[#known + 1] = ("%s x%d"):format(map, n) end table.sort(known) local wallCount = 0 for _, n in pairs(walls) do if n >= WALL_CONFIRM then wallCount = wallCount + 1 end end if #known > 0 or wallCount > 0 then say(("memory (%s): deaths %s; %d cells to route around") :format(MEMORY_PATH, #known > 0 and table.concat(known, ", ") or "none", wallCount)) else say("memory: empty -- first run, everything looks safe") end end local attempts = tonumber(os.getenv("POKEPORT_ROUTE_ATTEMPTS")) or 5 -- Resume from a checkpoint instead of replaying from Pallet Town. Only -- the first attempt resumes; if it fails again, the retries are honest -- runs from the start. local resume if os.getenv("POKEPORT_ROUTE_RESUME") == "1" then resume = loadCheckpoint() if resume then say(("resuming from checkpoint: segment %d/%d on %s (%s)") :format(resume.segment, #ROUTE, tostring(resume.map), tostring(resume.reason))) else say("POKEPORT_ROUTE_RESUME=1 but no usable checkpoint; starting fresh") end end for attempt = 1, attempts do say(("==== attempt %d/%d ===="):format(attempt, attempts)) if attempt == 1 and resume then local SaveData = require("src.core.SaveData") local loaded, recovered = SaveData.load() if loaded then U.wait(5) G:restoreSave(loaded, recovered) U.wait(30) mashUntilIdle() local stopped = runRouteGuarded(resume.segment) if stopped == nil then say("stopped at a checkpoint") break end if stopped then say("run finished") break end say("checkpoint attempt failed; starting a clean run") resume = nil goto nextAttempt end say("checkpoint save would not load; starting fresh") resume = nil end if attempt > 1 then -- power-cycle back to the title and start over, the way a speedrun -- bot resets a dead run (Strategies.reset). Cheaper and far more -- predictable than trying to re-sync the route from a heal point. G:returnToTitle() U.wait(60) end -- Start a genuinely NEW game. -- -- U.newGame taps A on the title menu, which is only NEW GAME when no -- save exists -- with one, CONTINUE is first and the tap loads it. The -- checkpoint feature writes saves, so it silently turned every "clean" -- run into a resume: a fresh run reported being lost at segment 9 while -- standing in VERMILION_CITY, hundreds of segments from where it should -- have been. Pick the row by label instead of trusting its position. local fresh = false for _ = 1, 3 do U.wait(5) -- NEVER press A blindly here. With a save on disk the menu is -- already up after the movie skip, CONTINUE is its first row, and -- a stray A selects it -- every retry then "lands on ROUTE_16" and -- gets rejected, which burned four whole attempts in one run. -- Press START only until the menu's items are visible, back out of -- any submenu a stray press opened, and touch A only once the -- cursor is on the NEW GAME row. local newRow for _ = 1, 120 do local t = top() local items = t and t.items if items then for i, it in ipairs(items) do if it.label == "NEW GAME" then newRow = i break end end if newRow then break end press("b") -- ContinueInfo or another submenu; back out U.wait(4) else press("start") -- still the movie or the bare title screen U.wait(6) end end if newRow then if cursorTo("index", newRow) then press("a") U.wait(10) end -- mash through Oak's speech and the naming presets for _ = 1, 400 do press("a") U.wait(2) if G.overworld and G.stack:top() == G.overworld then break end end U.wait(10) else say("title menu had no NEW GAME row; falling back to U.newGame") U.newGame(game) end mashUntilIdle() -- A fresh game always wakes in Red's bedroom. Anywhere else means -- CONTINUE hijacked a blind tap -- one attempt toured the endgame -- from ROUTE_23 with the route pointed at segment 1 that way -- -- so go back to the title and try again rather than run on it. if G.overworld and ow().map.id == "REDS_HOUSE_2F" then fresh = true break end say(("new game landed on %s -- back to the title to try again") :format(tostring(G.overworld and ow().map.id))) G:returnToTitle() U.wait(60) end if not fresh then say("could not start a fresh game; abandoning this attempt") goto nextAttempt end local result = runRouteGuarded() if result == nil then say("stopped at a checkpoint") break end if result then say("run finished") break end say("restarting after a wipe") ::nextAttempt:: end report() end