Files
gen1recomp/tests/drivers/route.lua
T

6817 lines
285 KiB
Lua

-- 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
-- ---------------------------------------------------------------------
-- 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 "<map>#<cellId>".
--
-- 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 "<from>><to>".
--
-- 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
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
coroutine.yield()
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
coroutine.yield()
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
-- 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
for cell in pairs(extra) do blocked[cell] = true 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")
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
local function fightBattle(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
-- 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 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()
if cur == hungBattle 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)
else
hungBattle = cur
end
end
else
hungBattle = nil
end
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
if inBattle() 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
-- 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
-- one surf mount per goto: walking ashore dismounts, so an unreachable
-- target beyond a pond can otherwise mount -> cross -> dismount -> fail
-- -> mount again forever (Celadon's pond, target (39,8))
local surfMounts = 0
-- ...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 {}
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 == 0 and mountSurfToward
and mountSurfToward(tx, ty) then
surfMounts = surfMounts + 1
goto edgeStep
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 == 0 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
-- only a warp ON the map border needs the held direction; an
-- interior door fires on its own and has no "outward" to push
local outward = (s.x >= m.widthCells - 1 and "right")
or (s.x <= 0 and "left")
or (s.y >= m.heightCells - 1 and "down")
or (s.y <= 0 and "up")
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.
if lastTarget and cell == lastFrom then
reverts[lastTarget] = (reverts[lastTarget] or 0) + 1
if reverts[lastTarget] >= 2 and not refused[lastTarget] then
local W = ow().map.widthCells
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
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 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 == 0 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)
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
ops.pickup = ops.talk
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.
function driveLearnMenu()
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()
local t = top()
if t == menu then
-- the forget list: pick the first move that is neither an HM field
-- move (the engine refuses, and they are the run's geography) nor
-- the move being learned
local UNFORGETTABLE = { SURF = true, CUT = true, STRENGTH = true,
FLY = true, FLASH = true, DIG = true }
local row
for ri, mv in ipairs((menu.mon and menu.mon.moves) or {}) do
local id = tostring(mv.id or mv):upper()
if not UNFORGETTABLE[id] and id ~= newId then row = ri break end
end
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
cursorTo("index", 1) -- YES, delete an older move
press("a")
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 (Bag.CAPACITY is 20
-- slots; 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 free = 20 - 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
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 {})
if newKinds > 0 and 20 - 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 -> <item> -> USE -> <party slot>. 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
local 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)
-- 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
-- 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
mashUntilIdle(400)
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
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
local outward = (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
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.
local 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
add(regionNode(id, sr), regionNode(src, dr),
{ warp = { x = w.x, y = w.y }, cost = 3, 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 then
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
add(regionNode(id, sr), regionNode(conn.map, dr),
{ dir = COMPASS_DIR[dir], cells = cells, cost = 1 })
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
g[id][#g[id] + 1] = { to = d, warp = w, cost = elev }
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)
g[id][#g[id] + 1] = { to = c.map, dir = COMPASS_DIR[dir],
cells = cells, cost = #cells > 0 and 1 or 8 }
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
g[id][#g[id] + 1] = { to = src, warp = w, dynamic = true, cost = 3 }
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 "<from>|map|<edgeId>"
-- (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()
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
-- 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 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
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 }
end
end
end
return seen, W
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",
}
-- 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, which useItemOn just mashes A
-- through -- so the TM is consumed and nothing is learned. That is how
-- DIG silently failed on a level-30 WARTORTLE, and DIG is the route's
-- ride back to Cerulean, so the run then had nowhere to go.
--
-- `index` past the last move means "give up"; rows 1..4 forget that move.
-- We drop slot 1, which for the route's targets is the starter's weakest
-- filler rather than anything it fights with.
-- Drive the full-moveset flow.
--
-- MoveLearnMenu is NOT on top when it appears. Screens.push puts it on
-- the stack and its :enter() immediately pushes a TextBox ("... is trying
-- to learn ... Delete an older move?") which in turn pushes a ChoiceBox
-- (src/ui/MoveLearnMenu.lua:34-50). So `top()` is a TextBox, the old
-- `top().newMoveId` test never matched, and the forget flow never ran --
-- the TM was consumed and nothing was learned. That is why a level-30
-- WARTORTLE, whose tmhm list DOES contain DIG and BUBBLEBEAM (verified
-- against pokered's base_stats/wartortle.asm -- our data is right), kept
-- reporting "did NOT learn it", which took DIG away from the run.
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
local menu = learnMenu()
if not menu then break end
local t = top()
if t == menu then
-- Forget the first move that is NEITHER an HM field move NOR the
-- move being taught. "Slot 1" was blind: after SURF replaced the
-- lead's first move, the next teach (EARTHQUAKE) put the cursor
-- on SURF, the engine refused ("HM moves can't be forgotten")
-- and the menu wedged with the TM half-spent. HM moves are also
-- the run's GEOGRAPHY -- losing SURF strands the Cinnabar leg.
local UNFORGETTABLE = { SURF = true, CUT = true, STRENGTH = true,
FLY = true, FLASH = true, DIG = true }
local row = 1
for ri, mv in ipairs((menu.mon and menu.mon.moves) or {}) do
local id = tostring(mv.id or mv):upper()
if not UNFORGETTABLE[id] and id ~= key:upper() then
row = ri
break
end
end
if not cursorTo("index", row) then break end
press("a")
U.wait(10)
elseif isChoice() then
cursorTo("index", 1) -- YES, delete a move
press("a")
U.wait(8)
else
press("a") -- text box
U.wait(6)
end
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()
local slot = slotKnowing(move)
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
-- 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
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] 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()
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 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 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]
if cuttableCell(m, nx, ny) 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
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
return ok and not cuttableCell(ow().map, best[1], best[2])
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 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
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
if not slotKnowing("SURF") then
say("mountSurf: nobody knows SURF")
return false
end
if not (G.save.inventory or {}).SOULBADGE then
say("mountSurf: no SOULBADGE")
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()
-- nearest reachable land cell with water beside it
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 - 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
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 opens from the map's own onEnter (data/scripts/story3.lua
-- `elevator`), so it is already up by the time we get here -- there is
-- nothing to interact with. 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).
--
-- The floor menu opens from the elevator's own onEnter, so it is already up
-- when we arrive. If it is NOT up -- we bounced in on a stray warp and the
-- menu was dismissed, or we are on the default 1F-exit oscillation -- step
-- back onto the car's warp to re-open it before giving up.
local function rideElevator(where, wantMap)
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 waitFor(isList, 20) then
-- Re-open the floor menu: the car's exit warp re-enters the elevator,
-- firing onEnter again. This is what breaks the un-ridden bounce.
local car = findWarpTo("ELEVATOR") or findWarpTo("")
if car then walkOntoWarp(car.x, car.y) end
from = ow().map.id
end
if not waitFor(isList, 60) 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 only then walks us out onto the floor.
for _ = 1, 600 do
if ow().map.id ~= from then break end
if idle() then U.wait(4) else mashUntilIdle() end
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
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
-- ---------------------------------------------------------------------
-- 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
-- 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
-- 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
-- <TOWN>_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)
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]
-- 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
-- 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
-- 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
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
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
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))
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))
return true
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 = runRoute(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.
U.wait(5)
press("start")
U.wait(10)
press("a")
U.wait(10)
local title = top()
local newRow
for i, it in ipairs(title and title.items or {}) do
if it.label == "NEW GAME" then newRow = i break 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()
local result = runRoute()
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