mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
8297 lines
354 KiB
Lua
8297 lines
354 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
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- stuck-loop watchdog
|
|
-- ---------------------------------------------------------------------
|
|
-- "Stuck" from the outside looks like the log printing the same thing over
|
|
-- and over: every retry loop in this file narrates each attempt through
|
|
-- say(), so a run that repeats the exact same line-cycle STUCK_REPEATS
|
|
-- times without reaching a NEW segment is trying the same thing and
|
|
-- getting the same result. Detect that inside say() itself, throw a
|
|
-- sentinel table, and let runRouteGuarded (bottom of file) turn it into a
|
|
-- checkpoint plus a written report instead of an endless loop. A backstop
|
|
-- in U.wait catches the opposite shape: a loop that spins silently,
|
|
-- logging nothing at all.
|
|
-- One table, not a spray of locals: the main chunk was already brushing
|
|
-- LuaJIT's 200-local limit before the watchdog existed.
|
|
local WD = {
|
|
-- POKEPORT_ROUTE_WATCHDOG=0 disables the 10-repeat stuck detector and
|
|
-- its silent-stall backstop entirely: the run then only ever stops on a
|
|
-- real death/lost-abandon or the outer time cap, and slow-but-working
|
|
-- stretches (the Victory Road grind, long Elite Four battles) are never
|
|
-- cut short by a false positive.
|
|
enabled = os.getenv("POKEPORT_ROUTE_WATCHDOG") ~= "0",
|
|
repeats = tonumber(os.getenv("POKEPORT_ROUTE_STUCK_REPEATS")) or 10,
|
|
-- Cycle/window sizing: the ROUTE_17 travelTo loop narrated a ~16-line
|
|
-- cycle, sailing under a 12-line period cap and putting only ~6 copies
|
|
-- of any one line in a 100-line window. Both bounds sized to catch it:
|
|
-- 10 repeats of a 20-line cycle span 200 lines, within keep (240).
|
|
maxPeriod = 20, -- longest line-cycle the detector will match
|
|
window = 200, -- scattered-repeat window (see WD.recordLine)
|
|
stallFrames = 150000, -- silent logic-frames before the backstop fires
|
|
keep = 240,
|
|
history = {},
|
|
armed = false,
|
|
lastActivity = 0,
|
|
reportPath = os.getenv("POKEPORT_ROUTE_STUCK_REPORT")
|
|
or "/tmp/route_stuck_report.txt",
|
|
-- trees whose felling provably brought us no closer to a target this
|
|
-- run, keyed "<map>#<x>,<y>" (see cutToward); they respawn on every
|
|
-- map re-entry, so without this the cut loop never converges
|
|
futileCuts = {},
|
|
-- surf mounts repeated from the same shore toward the same target
|
|
-- (see mountSurfToward); a disconnected pond otherwise spins forever
|
|
futileMounts = {},
|
|
-- items a later teach step needs; filled in beside TEACH_ITEMS (which
|
|
-- is declared after pickupTossJunk, so the set rides on WD instead)
|
|
keepItems = {},
|
|
-- mansion statue toggles per sealed target (see toggleMansionSwitch)
|
|
futileSwitches = {},
|
|
-- where the run is, kept fresh by runRoute for the stuck report
|
|
progress = { segment = 0, maxSegment = 0,
|
|
stepIndex = nil, stepCount = nil, action = nil },
|
|
}
|
|
|
|
-- Returns a hit table when the tail of the history is one cycle of up to
|
|
-- maxPeriod lines repeated `repeats` times back to back, or when this
|
|
-- exact line has shown up `repeats` times inside the last `window` lines
|
|
-- (the sloppier loops interleave their repeats with lines that vary, so
|
|
-- pure periodicity alone would miss them).
|
|
function WD.recordLine(line)
|
|
local h = WD.history
|
|
h[#h + 1] = line
|
|
if #h > WD.keep then table.remove(h, 1) end
|
|
WD.lastActivity = U.frame()
|
|
local n = #h
|
|
for p = 1, WD.maxPeriod do
|
|
local need = p * WD.repeats
|
|
if n >= need then
|
|
local periodic = true
|
|
for j = n - need + p + 1, n do
|
|
if h[j] ~= h[j - p] then periodic = false break end
|
|
end
|
|
if periodic then
|
|
local cycle = {}
|
|
for j = n - p + 1, n do cycle[#cycle + 1] = h[j] end
|
|
return { period = p, count = WD.repeats, cycle = cycle, line = line }
|
|
end
|
|
end
|
|
end
|
|
local seen = 0
|
|
for j = math.max(1, n - WD.window + 1), n do
|
|
if h[j] == line then seen = seen + 1 end
|
|
end
|
|
if seen >= WD.repeats then
|
|
return { period = 0, count = seen, cycle = { line }, line = line }
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Silent-stall backstop. Every loop in the driver waits through U.wait
|
|
-- (U.tap included), so a run that stops logging but keeps spinning still
|
|
-- passes through here every frame it burns.
|
|
do
|
|
local baseWait = U.wait
|
|
-- Brake-coast: a perfect fence-lean can deadlock -- the bike pinned on
|
|
-- one cell while the planner waits for a state change that never comes
|
|
-- (observed twice on ROUTE_17; a manual "down" press un-wedged it both
|
|
-- times). So after too many braked frames on the SAME cell, stop
|
|
-- leaning for a moment and let the hill move us a cell, which is
|
|
-- exactly that manual nudge, automated.
|
|
local brakeCell, brakeRun, coastLeft = nil, 0, 0
|
|
U.wait = function(n)
|
|
for _ = 1, (n or 1) do
|
|
-- Cycling Road brake (see slopeBrakeDir, defined later; resolved as
|
|
-- a global at call time): every idle overworld frame the driver
|
|
-- waits through must lean on the fence or the bike rolls south.
|
|
-- Only when the overworld is the live state and no tap is queued,
|
|
-- so menu cursors and interactions never see a phantom held key.
|
|
local brake
|
|
if G and G.stack and G.overworld and G.stack:top() == G.overworld
|
|
and #(G.input.pressQueue or {}) == 0 and slopeBrakeDir then
|
|
brake = slopeBrakeDir()
|
|
end
|
|
if brake then
|
|
local p = G.overworld.player
|
|
local cell = (p.cellY or 0) * 4096 + (p.cellX or 0)
|
|
if cell == brakeCell then
|
|
brakeRun = brakeRun + 1
|
|
else
|
|
brakeCell, brakeRun = cell, 0
|
|
end
|
|
if coastLeft > 0 then
|
|
coastLeft = coastLeft - 1
|
|
brake = nil -- hands off: let the bike roll
|
|
elseif brakeRun > 600 then
|
|
brakeRun, coastLeft = 0, 20
|
|
brake = nil
|
|
end
|
|
else
|
|
brakeCell, brakeRun, coastLeft = nil, 0, 0
|
|
end
|
|
if brake then G.input.state[brake] = true end
|
|
baseWait(1)
|
|
if brake then G.input.state[brake] = false end
|
|
end
|
|
if WD.armed and (U.frame() - WD.lastActivity) > WD.stallFrames then
|
|
WD.armed = false
|
|
error({ routeStuck = { period = 0, count = 1, cycle = {}, silent = true,
|
|
line = ("no log output for %d frames"):format(WD.stallFrames) } }, 0)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- danger memory
|
|
-- ---------------------------------------------------------------------
|
|
-- Where the party has died before, counted per map and persisted, so the
|
|
-- bot gets more careful about a place every time it loses there instead of
|
|
-- walking into the same fight at the same HP forever. It survives the
|
|
-- process, so each run starts knowing what killed the last one.
|
|
--
|
|
-- Keyed by map rather than by segment index: segment numbers shift
|
|
-- whenever the route is regenerated, but "Viridian Forest keeps killing
|
|
-- us" stays true across conversions.
|
|
local MEMORY_PATH = os.getenv("POKEPORT_ROUTE_MEMORY")
|
|
or "/tmp/pokeport_route_memory.lua"
|
|
local deaths, walls, seams = {}, {}, {}
|
|
|
|
local function loadMemory()
|
|
local chunk = loadfile(MEMORY_PATH)
|
|
if not chunk then return end
|
|
local ok, t = pcall(chunk)
|
|
if not (ok and type(t) == "table") then return end
|
|
-- earlier files were a flat map -> death-count table
|
|
deaths = type(t.deaths) == "table" and t.deaths or t
|
|
walls = type(t.walls) == "table" and t.walls or {}
|
|
seams = type(t.seams) == "table" and t.seams or {}
|
|
end
|
|
|
|
local function writeCounts(fh, tbl)
|
|
local keys = {}
|
|
for k in pairs(tbl) do keys[#keys + 1] = k end
|
|
table.sort(keys)
|
|
for _, k in ipairs(keys) do fh:write((" [%q] = %d,\n"):format(k, tbl[k])) end
|
|
end
|
|
|
|
local function saveMemory()
|
|
local fh = io.open(MEMORY_PATH, "w")
|
|
if not fh then return end
|
|
fh:write("-- written by tests/drivers/route.lua; safe to delete to forget\n")
|
|
fh:write("return {\n deaths = {\n")
|
|
writeCounts(fh, deaths)
|
|
fh:write(" },\n walls = {\n")
|
|
writeCounts(fh, walls)
|
|
fh:write(" },\n seams = {\n")
|
|
writeCounts(fh, seams)
|
|
fh:write(" },\n}\n")
|
|
fh:close()
|
|
end
|
|
|
|
local function dangerAt(mapId) return deaths[mapId or ""] or 0 end
|
|
|
|
local function recordDeath(mapId)
|
|
if not mapId then return end
|
|
deaths[mapId] = (deaths[mapId] or 0) + 1
|
|
saveMemory()
|
|
end
|
|
|
|
-- Cells the engine refused to walk into, keyed "<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
|
|
-- Never let a BATTLE trip the stuck detector. A fight in progress narrates
|
|
-- "battle timed out" / "trainer battle slow" as it grinds a foe down, and
|
|
-- treating those repeats as a wedge rewound the run straight out of the
|
|
-- Elite Four. The E4 is won by attrition -- XP is kept through a blackout,
|
|
-- so every attempt levels the team -- so a fight must be allowed to run to
|
|
-- its own end (a win, or a faint toward a blackout that retries at the
|
|
-- lobby), never rewound. (inBattle() is declared below; inline its test.)
|
|
local bt = G and G.stack and G.stack.top and G.stack:top()
|
|
local inABattle = bt ~= nil and bt.kind ~= nil
|
|
if WD.armed and not inABattle then
|
|
local hit = WD.recordLine(line)
|
|
if hit then
|
|
-- disarm before throwing so the unwind's own say() calls (the
|
|
-- report, the checkpoint) cannot re-trigger the detector
|
|
WD.armed = false
|
|
error({ routeStuck = hit }, 0)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- state predicates (mirrors tests/autopilot.lua's busy()/idle())
|
|
-- ---------------------------------------------------------------------
|
|
|
|
local function ow() return G.overworld end
|
|
|
|
-- Anything that makes the overworld ignore normal player input: a pushed
|
|
-- UI state, a script/cutscene in flight, or a timed hold. A plain
|
|
-- stack-size check reads "idle" the instant a text box closes even mid
|
|
-- cutscene, which would let us resume walking during Oak's escort.
|
|
local function busy()
|
|
local o = ow()
|
|
return G.stack:top() ~= o
|
|
or (o.runner and o.runner:isRunning())
|
|
or #(o.scriptMoves or {}) > 0
|
|
or o.engaging
|
|
or o.emote ~= nil
|
|
or o.healAnim ~= nil
|
|
end
|
|
|
|
local function idle() return not busy() and not ow().transitioning end
|
|
|
|
local function inBattle()
|
|
local top = G.stack:top()
|
|
return top ~= nil and top.kind ~= nil
|
|
end
|
|
|
|
local function press(btn) U.tap(G, btn) end
|
|
|
|
-- Movement needs the direction HELD, not tapped: OverworldController's
|
|
-- handleInput walks on input:isDown(dir) (OverworldController.lua:734) and
|
|
-- calls tryMove every frame the key is down. A one-frame tap only turns
|
|
-- the player in place, which reads as walking on the spot into a wall.
|
|
-- Edge exits, ledge hops and boulder pushes additionally require that we
|
|
-- already face the direction before the step (lines 735-739), so holding
|
|
-- through the turn is what makes those fire at all.
|
|
--
|
|
-- Returns true if we changed cell (or map), false if we were blocked.
|
|
local function walk(dir, maxFrames)
|
|
local o = ow()
|
|
local sx, sy, smap = o.player.cellX, o.player.cellY, o.map.id
|
|
local moved = false
|
|
-- Frames spent already facing `dir` without the step starting. The engine
|
|
-- refuses a blocked move every frame the key is held, so without this the
|
|
-- loop replays the bump animation for the whole of maxFrames -- and the
|
|
-- caller then retries up to 8 times. That is what "Red walking into the
|
|
-- same wall ten times and then recovering" looks like on screen; it is a
|
|
-- held-input problem, not a pathfinding one. Bail as soon as the engine
|
|
-- has clearly said no and let the caller re-plan.
|
|
local refusedFrames = 0
|
|
for _ = 1, maxFrames or 40 do
|
|
table.insert(G.input.pressQueue, dir)
|
|
G.input.state[dir] = true
|
|
coroutine.yield()
|
|
local p = ow().player
|
|
if ow().map.id ~= smap then moved = true break end
|
|
if (p.cellX ~= sx or p.cellY ~= sy) and not p.moving then moved = true break end
|
|
-- The step has COMMITTED (tween running toward a new cell): release
|
|
-- the key NOW and ride out the tween without it. Holding through the
|
|
-- landing frame attempts a further move the instant the tween ends,
|
|
-- and on an edge-collision warp cell that held press IS the warp
|
|
-- trigger (Warp.onCollision + extraCheck): walking DOWN onto Victory
|
|
-- Road 1F's entrance mat at (9,17) -- a waypoint the route walks
|
|
-- across horizontally, but our warp-averse BFS approaches from above
|
|
-- -- warped straight out to ROUTE_23, which is the "left
|
|
-- VICTORY_ROAD_1F partway through segment 183" exit. cellX commits
|
|
-- only at tween END (Player.lua), so committing is read off targetX.
|
|
if p.moving and p.targetX and (p.targetX ~= sx or p.targetY ~= sy) then
|
|
G.input.state[dir] = false
|
|
for _ = 1, 60 do
|
|
if ow().map.id ~= smap then break end
|
|
if not ow().player.moving then break end
|
|
-- Cycling Road: lean on the fence through the landing frame, or
|
|
-- the empty input there rolls the bike a cell south (and a held
|
|
-- `dir` instead would step a cell too far)
|
|
local brake = slopeBrakeDir()
|
|
if brake then G.input.state[brake] = true end
|
|
coroutine.yield()
|
|
if brake then G.input.state[brake] = false end
|
|
end
|
|
local q = ow().player
|
|
moved = ow().map.id ~= smap or q.cellX ~= sx or q.cellY ~= sy
|
|
break
|
|
end
|
|
if busy() then
|
|
-- A LEDGE HOP is performed with scriptMove, so it reads as "busy"
|
|
-- exactly like a cutscene does -- and breaking here reported the hop
|
|
-- as "did not move", which made goto_ blacklist the ledge as a wall.
|
|
-- Wait out our own hop and judge by the cell we land on. Anything else
|
|
-- busy (a battle, a real script) still belongs to the caller.
|
|
local hopping = (ow().player.hopFrames or 0) > 0
|
|
if hopping and not inBattle() then
|
|
for _ = 1, 90 do
|
|
if not busy() then break end
|
|
coroutine.yield()
|
|
end
|
|
local q = ow().player
|
|
if ow().map.id ~= smap or q.cellX ~= sx or q.cellY ~= sy then
|
|
moved = true
|
|
end
|
|
end
|
|
break
|
|
end
|
|
-- the first frames legitimately go on turning to face `dir`
|
|
if p.facing == dir and not p.moving then
|
|
refusedFrames = refusedFrames + 1
|
|
if refusedFrames >= 6 then break end
|
|
else
|
|
refusedFrames = 0
|
|
end
|
|
end
|
|
G.input.state[dir] = false
|
|
do
|
|
-- the settle frame: inert everywhere except a slope map, where empty
|
|
-- input here is exactly the forced-roll condition (see slopeBrakeDir)
|
|
local brake = slopeBrakeDir()
|
|
if brake then G.input.state[brake] = true end
|
|
coroutine.yield()
|
|
if brake then G.input.state[brake] = false end
|
|
end
|
|
return moved
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- BFS over the current map (walls + stationary NPCs), from autopilot.lua
|
|
-- ---------------------------------------------------------------------
|
|
|
|
local DIRS = { { 0, -1, "up" }, { 0, 1, "down" }, { -1, 0, "left" }, { 1, 0, "right" } }
|
|
|
|
-- direction key -> step delta, for turning a refused step back into a cell
|
|
local DELTA = {}
|
|
for _, d in ipairs(DIRS) do DELTA[d[3]] = { d[1], d[2] } end
|
|
|
|
-- `extra` is a set of cell ids to treat as walls (see knownWalls / the
|
|
-- refusal feedback in ops.goto_).
|
|
-- Where a ledge hop from (x, y) in `dir` would land, or nil if there is no
|
|
-- hop to be had.
|
|
--
|
|
-- Mirrors OverworldState:checkLedgeHop (OverworldController.lua:845): the
|
|
-- standing tile, the ledge tile in front and the input direction all have to
|
|
-- match a row of field.ledges, and the landing cell two along must be free.
|
|
--
|
|
-- BFS needs this because it plans over `isWalkableCell`, and a ledge tile is
|
|
-- deliberately NOT walkable -- you may only cross it by hopping. Without it
|
|
-- Route 4 is impassable: the route steps from (79,8) to (79,10), which is a
|
|
-- hop over the ledge at (79,9), and the bot reported "goto (79,10)
|
|
-- unreachable" and then failed the edge exit into Cerulean, losing the run to
|
|
-- nine skipped segments every single time. Every later route with a ledge
|
|
-- would have gone the same way.
|
|
local function ledgeLanding(map, x, y, dir, dx, dy)
|
|
local ledges = G.data.field and G.data.field.ledges
|
|
if not ledges then return nil end
|
|
local fx, fy = x + dx, y + dy
|
|
if not map:inBounds(fx, fy) then return nil end
|
|
local tileset = map.def.tileset
|
|
local standing, front = map:cellTile(x, y), map:cellTile(fx, fy)
|
|
for _, ledge in ipairs(ledges) do
|
|
if (ledge.tileset or "OVERWORLD") == tileset
|
|
and ledge.facing == dir and ledge.input == dir
|
|
and ledge.standingTile == standing and ledge.ledgeTile == front then
|
|
local lx, ly = fx + dx, fy + dy
|
|
if map:inBounds(lx, ly) and map:isWalkableCell(lx, ly) then
|
|
return lx, ly
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Walkable for the player AS THEY CURRENTLY ARE. isWalkableCell is a
|
|
-- land-only test, so once SURF is up every remaining over-water waypoint
|
|
-- would read unreachable -- and ROUTE_21 is a ninety-row water corridor,
|
|
-- so the trip to Cinnabar dies on the first plan without this. While
|
|
-- surfing, water counts too; land stays passable because walking onto the
|
|
-- shore is exactly how you dismount.
|
|
local function passableCell(map, x, y)
|
|
if map:isWalkableCell(x, y) then return true end
|
|
local p = ow().player
|
|
return p.surfing == true and map:isWaterCell(x, y)
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Cycling Road (slope maps)
|
|
-- ---------------------------------------------------------------------
|
|
-- On a slope map the engine rolls an idle bike one cell south on ANY
|
|
-- frame with no direction held (OverworldController.lua:900-911, porting
|
|
-- home/overworld.asm's forced PAD_DOWN on ROUTE_17). Held input --
|
|
-- refused or not -- is processed first and suppresses the roll, so the
|
|
-- only way to stand still there is the human rider's trick: lean on the
|
|
-- fence. slopeBrakeDir() picks a side direction that is blocked from the
|
|
-- cell we are on (or about to land on); walk() and the U.wait wrapper
|
|
-- hold it through every frame they would otherwise leave empty. Without
|
|
-- it each step's settle frame rolled the bike a cell south, goto_ read
|
|
-- the roll-back as a scripted shove, blacklisted the one-cell lane at
|
|
-- (18,122), and the run wedged pedalling in the corner.
|
|
-- (Globals, not locals: the main chunk is at LuaJIT's 200-local ceiling.)
|
|
function slopeMapNow()
|
|
if not (G and G.save and G.save.onBike) then return false end
|
|
local o = ow()
|
|
if not (o and o.map) then return false end
|
|
local fmap = G.data and G.data.field and G.data.field.forcedMovement
|
|
for _, m in ipairs((fmap and fmap.slopeMaps) or {}) do
|
|
if m == o.map.id then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
function slopeBrakeDir()
|
|
if not slopeMapNow() then return nil end
|
|
local o = ow()
|
|
local p = o.player
|
|
local px, py = p.cellX, p.cellY
|
|
-- mid-tween the landing frame is the one that counts: brake against
|
|
-- the cell we are about to occupy
|
|
if p.moving and p.targetX then px, py = p.targetX, p.targetY end
|
|
if not passableCell(o.map, px - 1, py) then return "left" end
|
|
if not passableCell(o.map, px + 1, py) then return "right" end
|
|
return nil
|
|
end
|
|
|
|
-- Tile-pair (elevation) collisions -- pokered's TilePairCollisionsLand /
|
|
-- Water, the same data src/world/Collision.lua enforces per step. CAVERN
|
|
-- and FOREST use these to fence off elevation shelves, so two adjacent
|
|
-- WALKABLE cells can still be uncrossable: Victory Road 1F's (5,9) ->
|
|
-- (5,8) is one, and a BFS that only checked walkability re-proposed that
|
|
-- refused step for walkOntoWarp's whole budget -- "warp@1,1 did not
|
|
-- move" x300 while travelTo ping-ponged ROUTE_23 <-> 1F. (Boulders are
|
|
-- exempt: checkBoulderPush never consults tile pairs, matching
|
|
-- push_boulder.asm, so only PLAYER movement models this.)
|
|
local function pairBlockedStep(map, sx, sy, tx, ty, surfing)
|
|
local tp = G.data.field and G.data.field.tilePairs
|
|
local list = tp and (surfing and tp.water or tp.land)
|
|
if not list or #list == 0 then return false end
|
|
local ts = map.def.tileset
|
|
local a, b = map:cellTile(sx, sy), map:cellTile(tx, ty)
|
|
for _, p in ipairs(list) do
|
|
if p.tileset == ts
|
|
and ((p.a == a and p.b == b) or (p.a == b and p.b == a)) then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function bfsNextKey(tx, ty, extra)
|
|
local o = ow()
|
|
local map, p = o.map, o.player
|
|
if p.cellX == tx and p.cellY == ty then return nil end
|
|
local w = map.widthCells
|
|
local function id(x, y) return y * w + x end
|
|
local blocked = {}
|
|
if extra then
|
|
-- The caller's refusal set must never contain the DESTINATION.
|
|
-- walkOntoWarp's revert-blacklist once refused a warp's own cell,
|
|
-- after which every re-plan said "no path" with the target ONE step
|
|
-- away and nobody in the way (Silph 1F (16,11) -> (16,10)).
|
|
-- Intermediate cells only; NPC blocks below still apply to the goal.
|
|
local goal = id(tx, ty)
|
|
for cell in pairs(extra) do
|
|
if cell ~= goal then blocked[cell] = true end
|
|
end
|
|
end
|
|
-- NPCs are walls -- but only the ones that belong to THIS map.
|
|
--
|
|
-- A warp swaps map.id before the entity list is rebuilt, so a plan made
|
|
-- in that window blocks cells using the PREVIOUS map's NPCs. Their
|
|
-- coordinates are usually nonsense here, and `id(x, y)` folds x and y
|
|
-- together, so an NPC at forest (16,43) lands on some innocent cell of an
|
|
-- 8-wide gate and walls it off. That is exactly what stranded a run in
|
|
-- VIRIDIAN_FOREST_NORTH_GATE: "goto (4,1) unreachable ... npcs:
|
|
-- SPRITE_YOUNGSTER@(16,43) SPRITE_YOUNGSTER@(30,33)" -- forest
|
|
-- coordinates listed against a gate the size of a room, every following
|
|
-- segment skipping, and the attempt lost.
|
|
local h = map.heightCells
|
|
for _, npc in ipairs(o.npcs) do
|
|
local inMap = npc.cellX >= 0 and npc.cellY >= 0
|
|
and npc.cellX < w and npc.cellY < h
|
|
if inMap then
|
|
blocked[id(npc.cellX, npc.cellY)] = true
|
|
if npc.targetX and npc.targetX >= 0 and npc.targetY >= 0
|
|
and npc.targetX < w and npc.targetY < h then
|
|
blocked[id(npc.targetX, npc.targetY)] = true
|
|
end
|
|
end
|
|
end
|
|
local prev = { [id(p.cellX, p.cellY)] = -1 }
|
|
local queue, head = { id(p.cellX, p.cellY) }, 1
|
|
while head <= #queue do
|
|
local cur = queue[head]; head = head + 1
|
|
local cx, cy = cur % w, math.floor(cur / w)
|
|
if cx == tx and cy == ty then break end
|
|
for _, d in ipairs(DIRS) do
|
|
local nx, ny = cx + d[1], cy + d[2]
|
|
local nid = id(nx, ny)
|
|
-- Never path onto the Celadon Mart elevator entrance. There the
|
|
-- elevator is optional (stairs reach every floor) and its mat is a
|
|
-- bounce-trap the driver keeps stepping onto during floor navigation.
|
|
-- Scoped to CELADON_MART so Silph's elevator -- a different map the
|
|
-- route genuinely rides -- is untouched, and to elevator-destination
|
|
-- warps so ordinary doors and stairs are untouched. The target cell
|
|
-- is always allowed (never block where we are trying to go).
|
|
-- Never path THROUGH any warp cell -- stepping on one warps
|
|
-- (bug 10's warp parity), so a door mat on the walk line detours
|
|
-- the whole goto into a building. Saffron's segment 172 kept
|
|
-- diving into COPYCAT'S HOUSE exactly this way. The target cell is
|
|
-- always allowed: warps we MEAN to take are goto'd directly.
|
|
local elevTrap = false
|
|
if not (nx == tx and ny == ty) then
|
|
elevTrap = (map.warpAtCell and map:warpAtCell(nx, ny)) ~= nil
|
|
end
|
|
if nx >= 0 and ny >= 0 and nx < w and ny < map.heightCells
|
|
and not prev[nid] and not blocked[nid] and not elevTrap
|
|
and (passableCell(map, nx, ny) or (nx == tx and ny == ty))
|
|
and not pairBlockedStep(map, cx, cy, nx, ny, p.surfing) then
|
|
prev[nid] = cur
|
|
queue[#queue + 1] = nid
|
|
else
|
|
-- Not walkable -- but it may be a ledge we can hop, which lands two
|
|
-- cells along and is one-way. Treated as an ordinary edge from the
|
|
-- cell we are standing on; walk() already holds the direction, which
|
|
-- is what makes the engine's hop fire.
|
|
local lx, ly = ledgeLanding(map, cx, cy, d[3], d[1], d[2])
|
|
if lx then
|
|
local lid = id(lx, ly)
|
|
if not prev[lid] and not blocked[lid] then
|
|
prev[lid] = cur
|
|
queue[#queue + 1] = lid
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
local goal = id(tx, ty)
|
|
if not prev[goal] then return nil end
|
|
local cur = goal
|
|
while prev[cur] ~= id(p.cellX, p.cellY) do
|
|
cur = prev[cur]
|
|
if cur == nil or cur == -1 then return nil end
|
|
end
|
|
local cx, cy = cur % w, math.floor(cur / w)
|
|
for _, d in ipairs(DIRS) do
|
|
if p.cellX + d[1] == cx and p.cellY + d[2] == cy then return d[3] end
|
|
end
|
|
-- The first move may be a ledge hop, which lands TWO cells along rather
|
|
-- than one, so the adjacency test above finds nothing. Same direction, and
|
|
-- walk() holds it exactly the same way -- the engine does the jumping.
|
|
for _, d in ipairs(DIRS) do
|
|
local lx, ly = ledgeLanding(map, p.cellX, p.cellY, d[3], d[1], d[2])
|
|
if lx == cx and ly == cy then return d[3] end
|
|
end
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- battle
|
|
-- ---------------------------------------------------------------------
|
|
|
|
-- A deterministic mid-roll stand-in for love.math.random. Damage.lua only
|
|
-- ever calls rng(min, max) (Damage.lua:83,105,253), so probing a move with
|
|
-- this consumes no game RNG and cannot desync the battle -- which calling
|
|
-- battle.rng directly would.
|
|
-- Wait for a screen to appear WITHOUT pressing anything.
|
|
--
|
|
-- pressUntil mashes A, which is safe on a text box and disastrous on a
|
|
-- bag or a party list: A there means "use this item" / "send out this mon",
|
|
-- so mashing to wait for the screen acts on whatever the cursor happens to
|
|
-- be sitting on.
|
|
local function waitFor(pred, tries)
|
|
for _ = 1, tries or 40 do
|
|
if pred() then return true end
|
|
U.wait(3)
|
|
end
|
|
return pred()
|
|
end
|
|
|
|
-- Set while we are driving a *voluntary* switch (training, below), so
|
|
-- sendOutHealthy -- which owns the forced post-faint menu -- does not grab
|
|
-- the same PartyMenu and send out slot 1 instead.
|
|
local switchingForTraining = false
|
|
|
|
-- The battle's replacement PartyMenu, if it is what we are looking at.
|
|
--
|
|
-- It is pushed on top of the BattleState (BattleState:openReplacementMenu),
|
|
-- and it carries no `.kind`, so inBattle() reads false while it is up and
|
|
-- fightBattle's loop exits underneath it. `.onSwitch` is what makes it this
|
|
-- menu rather than the overworld party screen.
|
|
local function replacementMenu()
|
|
local t = G.stack:top()
|
|
if t and t.onSwitch ~= nil and t.index ~= nil and t.items == nil then
|
|
return t
|
|
end
|
|
end
|
|
|
|
-- Send out the first healthy mon.
|
|
--
|
|
-- Pressing A blind does NOT work: the cursor starts on slot 1, which is the
|
|
-- mon that just fainted, and PartyMenu answers "There's no will to fight!"
|
|
-- and reopens (BattleState.lua:2606-2609). The first run that ever had a
|
|
-- second Pokémon to switch to spent the rest of its life in that menu,
|
|
-- logging "mashUntilIdle timed out" on repeat.
|
|
local function sendOutHealthy()
|
|
if switchingForTraining then return false end
|
|
if not replacementMenu() then return false end
|
|
local slot
|
|
for i, mon in ipairs(G.save.party or {}) do
|
|
if (mon.hp or 0) > 0 then slot = i break end
|
|
end
|
|
if not slot then return false end -- nothing left; the blackout owns this
|
|
for _ = 1, 12 do
|
|
local t = replacementMenu()
|
|
if not t or t.index == slot then break end
|
|
press(t.index > slot and "up" or "down")
|
|
U.wait(3)
|
|
end
|
|
if replacementMenu() then
|
|
press("a")
|
|
U.wait(8)
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- training the second Pokémon
|
|
-- ---------------------------------------------------------------------
|
|
--
|
|
-- Gen 1 gives exp only to mons that were actually SENT OUT
|
|
-- (BattleState:markParticipant / the participant split in enemyMonFainted),
|
|
-- so the Nidoran we go out of our way to catch on Route 22 earns nothing at
|
|
-- all while it sits in slot 2. The route would then reach Mt. Moon and put a
|
|
-- Moon Stone into a level 4 Nidoking, wasting every TM aimed at it later
|
|
-- (thrash, thunderbolt, horn drill, rock slide, ice beam).
|
|
--
|
|
-- Sending it out and switching straight back earns it a full share without it
|
|
-- ever having to survive a fight. The cost is the enemy's free move on each
|
|
-- switch, so it only runs while the trainee is healthy enough to take one and
|
|
-- only on the first menu of a battle, never mid-fight.
|
|
local TRAINEE_MIN_HP = 0.6
|
|
|
|
-- How far above the trainee a wild foe may be before switching the
|
|
-- trainee in is a faint rather than a share of the exp. The switch costs
|
|
-- it one free enemy attack, so the gap is a survivability bound, not a
|
|
-- preference: past it the trainee dies, leaves the rotation (traineeSlot
|
|
-- wants hp > 0) and never trains again. Eight covers the levels the route
|
|
-- actually intends -- a NIDORAN caught at 3-4 on ROUTE_22 training through
|
|
-- Mt. Moon's level 8-12 wilds -- without feeding it to Route 9.
|
|
local TRAINEE_MAX_GAP = 8
|
|
|
|
-- The party slot we are levelling: the healthy member furthest behind the
|
|
-- lead. nil when there is nobody worth training.
|
|
local function traineeSlot()
|
|
local roster = G.save.party or {}
|
|
local lead = roster[1]
|
|
if not lead or #roster < 2 then return nil end
|
|
local best, bestLevel
|
|
for i = 2, #roster do
|
|
local mon = roster[i]
|
|
local max = mon.stats and mon.stats.hp or 0
|
|
if (mon.hp or 0) > 0 and max > 0
|
|
and (mon.hp / max) >= TRAINEE_MIN_HP
|
|
and (mon.level or 0) < (lead.level or 0)
|
|
and (not bestLevel or (mon.level or 0) < bestLevel) then
|
|
best, bestLevel = i, mon.level or 0
|
|
end
|
|
end
|
|
return best
|
|
end
|
|
|
|
local function midRng(a, b) return math.floor((a + b) / 2) end
|
|
|
|
-- Rank the active mon's moves by expected damage against the current
|
|
-- opponent using the engine's own damage math, so type effectiveness,
|
|
-- STAB and the ruleset all come from the game rather than a ported table.
|
|
-- The move that takes the enemy DOWN without killing it, for catching.
|
|
--
|
|
-- Gen 1's catch formula scales with missing HP, so throwing at full health
|
|
-- is close to the worst odds available. It is fine for the route's NIDORAN
|
|
-- (catch rate 235), which is why the original threw immediately, but ODDISH
|
|
-- is catch rate 45 -- six balls went into one at full HP and it still broke
|
|
-- out every time.
|
|
--
|
|
-- Returns the move doing the most damage that still leaves the target
|
|
-- alive, with a margin, or nil when every move would KO (a level-20 lead
|
|
-- against a level-13 wild mon frequently has no safe option at all) -- in
|
|
-- which case the caller throws at whatever HP it has rather than killing
|
|
-- the thing it came for.
|
|
local CATCH_HP_MARGIN = 0.10 -- of max HP, kept in reserve under any hit
|
|
|
|
local function weakenMoveIndex(battle)
|
|
local enemy = battle.enemy and battle.enemy.mon
|
|
if not enemy then return nil end
|
|
local maxHP = math.max(1, enemy.stats and enemy.stats.hp or 1)
|
|
local floorHP = math.max(1, math.floor(maxHP * CATCH_HP_MARGIN))
|
|
local headroom = (enemy.hp or 0) - floorHP
|
|
if headroom <= 0 then return nil end -- already as low as we dare
|
|
local bestIdx, bestDmg = nil, -1
|
|
for i, mv in ipairs(battle.player.curMoves) do
|
|
if mv.pp > 0 and battle.player.disabledSlot ~= i then
|
|
local def = battle:moveDef(mv)
|
|
if def then
|
|
local probe = setmetatable({ id = mv.id }, { __index = def })
|
|
local ok, dmg, info = pcall(function()
|
|
return battle:computeDamage(battle.player, battle.enemy, probe,
|
|
{ rng = midRng })
|
|
end)
|
|
-- Only moves that compute a real number are candidates: a status
|
|
-- move does no damage and would spin the turn without progress.
|
|
if ok and type(dmg) == "number" and dmg > 0
|
|
and not (info and info.typeMult == 0) then
|
|
-- midRng is the MID roll; the real hit can roll higher and crit,
|
|
-- so leave the margin rather than aiming at exactly 1 HP.
|
|
if dmg <= headroom and dmg > bestDmg then bestIdx, bestDmg = i, dmg end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return bestIdx
|
|
end
|
|
|
|
local function bestMoveIndex(battle)
|
|
local moves = battle.player.curMoves
|
|
local bestIdx, bestDmg = nil, -1
|
|
for i, mv in ipairs(moves) do
|
|
if mv.pp > 0 and battle.player.disabledSlot ~= i then
|
|
local def = battle:moveDef(mv)
|
|
if def then
|
|
-- guarantee .id/.highCrit reach Damage.critRoll even if the merged
|
|
-- record does not carry them
|
|
local probe = setmetatable({ id = mv.id }, { __index = def })
|
|
local ok, dmg, info = pcall(function()
|
|
return battle:computeDamage(battle.player, battle.enemy, probe,
|
|
{ rng = midRng })
|
|
end)
|
|
if ok and type(dmg) == "number" then
|
|
if info and info.typeMult == 0 then dmg = -1 end -- immune
|
|
if dmg > bestDmg then bestIdx, bestDmg = i, dmg end
|
|
elseif bestIdx == nil then
|
|
bestIdx, bestDmg = i, 0 -- status move / uncomputable: still usable
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return bestIdx or 1
|
|
end
|
|
|
|
-- Move the 2x2 action cursor to `want` (1=fight 2=pkmn 3=item 4=run).
|
|
-- Layout is index = row*2 + col + 1 (BattleState.lua:1040-1047); left and
|
|
-- right both toggle the column, up and down both toggle the row.
|
|
local function battleMenuTo(battle, want)
|
|
for _ = 1, 4 do
|
|
if battle.menuIndex == want then return true end
|
|
local col = (battle.menuIndex - 1) % 2
|
|
local wantCol = (want - 1) % 2
|
|
if col ~= wantCol then press("left") else press("up") end
|
|
U.wait(2)
|
|
end
|
|
return battle.menuIndex == want
|
|
end
|
|
|
|
-- Switch to party `slot` from the battle action menu (PKMN is index 2).
|
|
--
|
|
-- Flags the PartyMenu it opens as ours, so sendOutHealthy leaves it alone --
|
|
-- otherwise the forced-replacement handler would see a party menu and send
|
|
-- out slot 1, which is the mon we are trying to switch away from.
|
|
local function switchTo(battle, slot)
|
|
switchingForTraining = true
|
|
local ok = false
|
|
repeat
|
|
if not battleMenuTo(battle, 2) then break end
|
|
press("a")
|
|
if not waitFor(replacementMenu, 30) then break end
|
|
for _ = 1, 12 do
|
|
local t = replacementMenu()
|
|
if not t or t.index == slot then break end
|
|
press(t.index > slot and "up" or "down")
|
|
U.wait(3)
|
|
end
|
|
local t = replacementMenu()
|
|
if not t or t.index ~= slot then break end
|
|
press("a") -- select mon -> SWITCH / STATS / CANCEL
|
|
U.wait(8)
|
|
t = replacementMenu()
|
|
if not (t and t.submenu) then break end
|
|
-- SWITCH is first (SwitchStatsCancelText)
|
|
press("a")
|
|
U.wait(10)
|
|
ok = true
|
|
until true
|
|
switchingForTraining = false
|
|
if not ok then -- back out of whatever we left open
|
|
for _ = 1, 6 do
|
|
if not replacementMenu() then break end
|
|
press("b")
|
|
U.wait(4)
|
|
end
|
|
end
|
|
return ok
|
|
end
|
|
|
|
-- Below this fraction of max HP we would rather leave than trade turns.
|
|
--
|
|
-- Two thresholds, because a flat one is wrong in both directions. Fleeing
|
|
-- forfeits the XP, so a cautious bot arrives everywhere underlevelled,
|
|
-- takes proportionally more damage and flees even more -- but dropping the
|
|
-- threshold outright is worse: a lone level-5 starter on Route 1 has no
|
|
-- centre and no items, and fighting on simply kills it (tried it, the run
|
|
-- died at segment 6 instead of 22).
|
|
--
|
|
-- So: fight further down only when something can actually put the HP back.
|
|
local LOW_HP_EXPOSED = 0.35 -- nothing can restore HP; protect the run
|
|
local LOW_HP_COVERED = 0.2 -- a nurse or a potion is within reach
|
|
|
|
-- assigned below, once the healing helpers it needs exist
|
|
local canRestoreHP = function() return false end
|
|
|
|
-- Forward-declared: defined with the travel helpers, needed by stepUpTo.
|
|
local reachableSet
|
|
|
|
-- Forward-declared: defined with the party helpers; crossSeam and the
|
|
-- surf-mount logic need it before its definition point.
|
|
local slotKnowing
|
|
|
|
-- Forward-declared: assigned after MANUAL.playPokeFlute exists. Wakes a
|
|
-- SNORLAX that is blocking the current map when the POKE FLUTE is aboard.
|
|
local wakeBlockingSnorlax
|
|
|
|
-- true while deliberately farming encounters: fleeing earns no XP, which is
|
|
-- the whole point of grinding, so the flee guard stands down
|
|
local grinding = false
|
|
|
|
-- Drink a potion during a battle. Defined further down, once the bag
|
|
-- helpers (HP_ITEMS, heldCount, party) it needs exist; fightBattle
|
|
-- calls it and comes first.
|
|
local IN_BATTLE_HEAL_AT = 0.35
|
|
local healInBattle
|
|
|
|
-- Throw the POKE_DOLL from the battle ITEM menu. Defined near
|
|
-- healInBattle (same forward-declaration dance); fightBattle needs it for
|
|
-- the static ghost MAROWAK, where fleeing leaves the blocker standing.
|
|
local throwPokeDoll
|
|
|
|
-- Drive a MoveLearnMenu HM-safely wherever one appears. LEVEL-UP learns
|
|
-- open the same menu ops.teach handles, but they surface mid-battle and
|
|
-- in mashUntilIdle -- whose blind A lands on row 1, which after Silph is
|
|
-- SURF, and the engine's HM refusal loops forever. Defined after the
|
|
-- cursor helpers; forward-declared for fightBattle/mashUntilIdle.
|
|
local driveLearnMenu
|
|
|
|
local function lowOnHP(battle)
|
|
if grinding then return false end -- training: take the XP, not the exit
|
|
local mon = battle.player and battle.player.mon
|
|
local max = mon and mon.stats and mon.stats.hp
|
|
if not max or max <= 0 then return false end
|
|
-- Stay in the fight only where we can both afford to and have not been
|
|
-- killed before: a map with a death on record gets the cautious bar even
|
|
-- when the bag is full, because the record says our estimate was wrong
|
|
-- here at least once.
|
|
local covered = canRestoreHP() and dangerAt(ow().map.id) == 0
|
|
return mon.hp / max < (covered and LOW_HP_COVERED or LOW_HP_EXPOSED)
|
|
end
|
|
|
|
-- the battle state seen hung on the last fightBattle timeout, for the
|
|
-- engine-hang bailout at the bottom of fightBattle
|
|
local hungBattle
|
|
|
|
-- Body on WD (the chunk is at LuaJIT's 200-local ceiling); the local
|
|
-- wrapper below adds the re-entrancy accounting.
|
|
function WD.fightBattleBody(limit)
|
|
limit = limit or 20000
|
|
local frames = 0
|
|
-- TryRunningFromBattle can fail on a speed check, and each failure costs
|
|
-- the turn, so cap the attempts and fight on rather than be cornered
|
|
-- burning turns we cannot afford.
|
|
local runAttempts = 0
|
|
-- Set by the critical-switch branch below to the bench slot to bring in.
|
|
local battleSwitchSlot
|
|
-- Emergency bench-switches this battle. ONE: it is a last resort to break
|
|
-- an unhealable stall (Agatha's Gengar), not a tactic. Unbounded, it
|
|
-- looped -- "switching in slot 2" x10 in a Mt. Moon trainer fight tripped
|
|
-- the watchdog when the switched-in mon was itself too weak to change the
|
|
-- outcome. Capped so a fight that cannot be swung this way falls through
|
|
-- to attacking (and, if it must, a clean blackout the recovery retries).
|
|
local switchesLeft = 1
|
|
-- Potions drunk this battle. Bounded so a fight we are losing anyway
|
|
-- cannot drain the bag one turn at a time.
|
|
local healsLeft = 3
|
|
-- Switch the trainee in for its exp share, then straight back out. Only
|
|
-- while grinding is off (grindALevel wants the trainee to actually fight)
|
|
-- and only when there is somebody behind the lead worth levelling.
|
|
--
|
|
-- WILD battles only. Sharing splits the exp, so training through the
|
|
-- trainer fights left the lead three levels down by Pewter and it died to
|
|
-- Brock seven times in one run. Wild encounters are frequent and cheap;
|
|
-- the fights that decide the run are not the place to hand away half the
|
|
-- exp and two free enemy moves.
|
|
local trainingPhase = "done"
|
|
if not grinding and G.stack:top() and G.stack:top().kind == "wild"
|
|
and not G.stack:top().ghost and not G.stack:top().safari
|
|
and traineeSlot() then
|
|
trainingPhase = "in"
|
|
end
|
|
-- The replacement menu sits ON TOP of the battle and has no `.kind`, so
|
|
-- inBattle() alone would end the loop the moment our lead faints and hand
|
|
-- a party menu to mashUntilIdle, which only knows how to press A.
|
|
while (inBattle() or replacementMenu()) and frames < limit do
|
|
if sendOutHealthy() then
|
|
frames = frames + 10
|
|
goto continue
|
|
end
|
|
local battle = G.stack:top()
|
|
local phase = battle.phase
|
|
-- Watch for the wipe here rather than after the battle: blacking out
|
|
-- revives the party at the heal point, so a check made once the screen
|
|
-- has popped always sees a healthy party and misses it entirely.
|
|
if not blackedOut then
|
|
local roster = G.save.party or {}
|
|
local alive = false
|
|
for _, mon in ipairs(roster) do
|
|
if (mon.hp or 0) > 0 then alive = true break end
|
|
end
|
|
if #roster > 0 and not alive then blackedOut = true end
|
|
end
|
|
if phase == "menu" then
|
|
if battle.safari then
|
|
-- Safari battles have no FIGHT: the grid is BALL/BAIT/ROCK/RUN
|
|
-- and the route catches nothing in the zone -- it is a corridor
|
|
-- to the GOLD TEETH and HM03. RUN always succeeds in the Safari
|
|
-- Zone, and every other choice spends a ball or a turn.
|
|
if battleMenuTo(battle, 4) then press("a") end
|
|
elseif battle.player.mon.hp <= 0 then
|
|
-- our lead is down but the replacement menu has not opened yet;
|
|
-- A brings it up, and sendOutHealthy above drives it once it does
|
|
press("a")
|
|
elseif battle.ghost then
|
|
-- Pokémon Tower without the SILPH_SCOPE: FIGHT answers "too
|
|
-- scared to move!", balls are dodged, and the ghost never goes
|
|
-- down -- the battle cannot be won, only left. tryRun's
|
|
-- IsGhostBattle branch always escapes, so RUN is guaranteed.
|
|
--
|
|
-- Except the static MAROWAK at (10,16): fleeing that one leaves
|
|
-- it blocking the stairs and the trigger re-fires forever. The
|
|
-- POKE_DOLL ends it for good (the wBattleResult trick, see
|
|
-- data/scripts/story3.lua) -- and no WILD tower encounter is a
|
|
-- MAROWAK, so the species is a safe discriminator.
|
|
local foe = battle.enemy and battle.enemy.mon
|
|
if foe and tostring(foe.species) == "MAROWAK"
|
|
and ((G.save.inventory or {}).POKE_DOLL or 0) > 0
|
|
and throwPokeDoll and throwPokeDoll(battle) then
|
|
frames = frames + 10
|
|
elseif battleMenuTo(battle, 4) then
|
|
press("a")
|
|
end
|
|
-- First menu of the battle: send the trainee out purely to be
|
|
-- marked a participant, then hand the fight straight back.
|
|
--
|
|
-- Only when it can survive being there. Switching in costs the
|
|
-- trainee one free enemy attack, and against a foe far above it
|
|
-- that is simply a faint -- after which traineeSlot's `hp > 0`
|
|
-- drops it from the rotation for good, until some nurse happens to
|
|
-- revive it. That is how NIDORAN_M sat at level 4 for an entire
|
|
-- run while the lead reached 33: it was fed to the first big wild
|
|
-- mon it met and never trained again. Being a level behind is the
|
|
-- point of a trainee; being thirty behind means the exp share is
|
|
-- worth less than the body.
|
|
local slot = traineeSlot()
|
|
local foe = battle.enemy and battle.enemy.mon
|
|
local mon = slot and (G.save.party or {})[slot]
|
|
local gap = (foe and mon) and ((foe.level or 0) - (mon.level or 0)) or 0
|
|
if slot and gap > TRAINEE_MAX_GAP then
|
|
slot = nil -- too dangerous to be worth the share
|
|
end
|
|
trainingPhase = (slot and switchTo(battle, slot)) and "out" or "done"
|
|
elseif trainingPhase == "out" then
|
|
-- Leave the trainee IN to actually fight when the encounter is weak
|
|
-- enough to be safe. A share of the exp levels it far too slowly --
|
|
-- Nidoran was still trailing badly by Cerulean -- and the route
|
|
-- needs a real Nidoking, not a passenger. Taking the kill is worth
|
|
-- several shares, so the rule is "fight anything at or below your
|
|
-- own level while healthy, hand back anything above it".
|
|
local me = battle.player and battle.player.mon
|
|
local foe = battle.enemy and battle.enemy.mon
|
|
local maxHP = me and me.stats and me.stats.hp or 0
|
|
local safe = me and foe and maxHP > 0
|
|
and (foe.level or 99) <= (me.level or 0) + 1
|
|
and (me.hp or 0) / maxHP >= 0.5
|
|
local lead = (G.save.party or {})[1]
|
|
if not safe and lead and (lead.hp or 0) > 0 then
|
|
switchTo(battle, 1)
|
|
end
|
|
trainingPhase = "done"
|
|
elseif battle.kind ~= "wild" and healsLeft > 0
|
|
and (battle.player.mon.hp or 0)
|
|
/ math.max(1, battle.player.mon.stats.hp or 1) < IN_BATTLE_HEAL_AT
|
|
and healInBattle(battle, "battle") then
|
|
-- A trainer cannot be fled (tryRun refuses), so the only way to
|
|
-- survive a gauntlet is to drink. Capped per battle so a losing
|
|
-- fight empties the turn counter rather than the whole bag.
|
|
healsLeft = healsLeft - 1
|
|
elseif battle.kind ~= "wild" and switchesLeft > 0
|
|
and (battle.player.mon.hp or 0)
|
|
/ math.max(1, battle.player.mon.stats.hp or 1) < 0.18
|
|
and (function()
|
|
-- the lead is critical AND unhealable (no items or the
|
|
-- cap is spent). Agatha's Gengar is a Hypnosis/PP
|
|
-- staller: our lead sat at 2/202 HP asleep, out of
|
|
-- FULL_RESTOREs, trading nothing, while NIDOKING and
|
|
-- ODDISH stood healthy on the bench -- an infinite
|
|
-- stall that timed out forever and never resolved.
|
|
-- Switching in a live body forces the fight to a real
|
|
-- outcome: it acts, or it faints toward a clean
|
|
-- blackout the recovery machinery can retry. Only when
|
|
-- a genuinely healthier mon exists.
|
|
local active = battle.player.mon
|
|
local roster = G.save.party or {}
|
|
for si, m in ipairs(roster) do
|
|
if m ~= active and (m.hp or 0) > 0
|
|
and m.stats and (m.hp / math.max(1, m.stats.hp)) > 0.6
|
|
then
|
|
battleSwitchSlot = si
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end)() then
|
|
say(("lead is critical and out of heals -- switching in slot %d")
|
|
:format(battleSwitchSlot))
|
|
switchesLeft = switchesLeft - 1
|
|
switchTo(battle, battleSwitchSlot)
|
|
elseif battle.kind == "wild" and runAttempts < 4 and lowOnHP(battle) then
|
|
-- RUN is index 4 of the 2x2 grid; trainers refuse (tryRun), so
|
|
-- this only ever fires on wild encounters
|
|
runAttempts = runAttempts + 1
|
|
if battleMenuTo(battle, 4) then press("a") end
|
|
elseif battleMenuTo(battle, 1) then
|
|
press("a")
|
|
end
|
|
elseif phase == "moveSelect" then
|
|
local want = bestMoveIndex(battle)
|
|
for _ = 1, 8 do
|
|
if battle.moveIndex == want then break end
|
|
press(battle.moveIndex > want and "up" or "down")
|
|
U.wait(2)
|
|
frames = frames + 2
|
|
end
|
|
press("a")
|
|
else
|
|
-- intro / messages / anything else: advance -- but never mash A
|
|
-- blind through a level-up MoveLearnMenu (the cursor sits on SURF)
|
|
if not (driveLearnMenu and driveLearnMenu()) then
|
|
press("a")
|
|
end
|
|
end
|
|
U.wait(3)
|
|
frames = frames + 4
|
|
::continue::
|
|
end
|
|
if frames >= limit then
|
|
say("battle timed out")
|
|
-- An engine hang: the battle stops responding to ANY input (seen
|
|
-- live vs Agatha -- GOLBAT sent out, an empty text frame, A/B dead,
|
|
-- while a clean A-mash of the same fight never reproduces it).
|
|
-- Every caller retries fightBattle, so a hung battle livelocks the
|
|
-- whole run: ops.goto_ alone re-enters it up to 600 times. On the
|
|
-- SECOND consecutive timeout of the SAME battle state, force the
|
|
-- state off the stack and let the overworld machinery re-engage
|
|
-- from scratch -- an unbeaten trainer is simply re-fought with
|
|
-- fresh RNG, a wild encounter is gone. Crude, but the alternative
|
|
-- is a dead run, and the route is built to recover from anything
|
|
-- the map throws at it.
|
|
if inBattle() then
|
|
local cur = G.stack:top()
|
|
-- NEVER force-close a TRAINER battle. Popping an unbeaten trainer
|
|
-- leaves their defeat flag unset and their exit door sealed with no
|
|
-- recovery -- that is exactly how Agatha walled off the Champion and
|
|
-- the Hall of Fame at a completed 197/197 route. A trainer's only
|
|
-- honest outcomes are win or blackout (both handled), so hand the
|
|
-- fight back to the caller to re-enter with a fresh frame budget and
|
|
-- grind it down. Force-close stays only for WILD/ghost hangs, where
|
|
-- the encounter simply vanishes with no lasting state.
|
|
local trainer = cur.kind ~= "wild" and not cur.ghost and not cur.safari
|
|
if cur == hungBattle and not trainer then
|
|
local foe = cur.enemy and cur.enemy.mon
|
|
say(("battle engine hang (phase=%s kind=%s foe=%s) -- forcing the "
|
|
.. "battle closed"):format(tostring(cur.phase),
|
|
tostring(cur.kind), tostring(foe and foe.species)))
|
|
hungBattle = nil
|
|
G.stack:pop()
|
|
U.wait(20)
|
|
elseif trainer then
|
|
-- Nudge a possibly-stuck prompt and let the caller re-enter;
|
|
-- keep the battle on the stack no matter what.
|
|
local foe = cur.enemy and cur.enemy.mon
|
|
say(("trainer battle slow (phase=%s foe=%s) -- re-entering, not "
|
|
.. "closing"):format(tostring(cur.phase),
|
|
tostring(foe and foe.species)))
|
|
press("a")
|
|
U.wait(6)
|
|
else
|
|
hungBattle = cur
|
|
end
|
|
end
|
|
else
|
|
hungBattle = nil
|
|
end
|
|
end
|
|
|
|
-- Re-entrancy accounting around the body. healInBattle and throwPokeDoll
|
|
-- run INSIDE fightBattle and call mashUntilIdle, whose battle safety net
|
|
-- used to re-enter fightBattle recursively -- fresh frame budget, fresh
|
|
-- heal allowance, and the shared hungBattle bookkeeping cross-firing
|
|
-- between nesting levels until it force-popped a battle that was still
|
|
-- being fought. That is the "battle engine hang" that left Agatha
|
|
-- undefeated with her door sealed; the engine itself was fine.
|
|
local function fightBattle(limit)
|
|
WD.fighting = (WD.fighting or 0) + 1
|
|
local ok, res = pcall(WD.fightBattleBody, limit)
|
|
WD.fighting = WD.fighting - 1
|
|
if not ok then error(res, 0) end
|
|
return res
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- shared waits
|
|
-- ---------------------------------------------------------------------
|
|
|
|
-- Mash A until the overworld is idle again, fighting any battle that opens.
|
|
local function mashUntilIdle(limit)
|
|
limit = limit or 6000
|
|
local frames = 0
|
|
while frames < limit do
|
|
-- Never press A blind at a replacement menu: the cursor is on the mon
|
|
-- that just fainted, so A re-picks it and the menu reopens forever.
|
|
if replacementMenu() then sendOutHealthy() end
|
|
-- ...and never RE-ENTER a fight this mash is already running inside
|
|
-- of (healInBattle/throwPokeDoll come through here mid-battle); the
|
|
-- plain A press below drains the turn's text just fine.
|
|
if inBattle() and (WD.fighting or 0) == 0 then fightBattle() end
|
|
if idle() then return true end
|
|
if driveLearnMenu and driveLearnMenu() then
|
|
frames = frames + 10
|
|
else
|
|
press("a")
|
|
U.wait(6)
|
|
frames = frames + 7
|
|
end
|
|
end
|
|
-- A alone cannot close everything: a wedged START/party/submenu chain
|
|
-- cycles forever under A (seen as 25 consecutive timeouts during the
|
|
-- Saffron bush-cutting spree). Unwind with B before reporting failure --
|
|
-- B is safe everywhere mashUntilIdle is allowed (shops are B-only and
|
|
-- never come through here).
|
|
for _ = 1, 12 do
|
|
if idle() then return true end
|
|
press("b")
|
|
U.wait(6)
|
|
end
|
|
say("mashUntilIdle timed out")
|
|
return false
|
|
end
|
|
|
|
-- Turn without stepping: hold just long enough for the turn to register,
|
|
-- then release before tryMove commits to a move.
|
|
local function faceDir(dir)
|
|
if not dir then return end
|
|
for _ = 1, 4 do
|
|
if ow().player.facing == dir then return end
|
|
table.insert(G.input.pressQueue, dir)
|
|
G.input.state[dir] = true
|
|
coroutine.yield()
|
|
G.input.state[dir] = false
|
|
U.wait(3)
|
|
end
|
|
end
|
|
|
|
-- Map lookups. Defined up here because both the shop and the Poké Center
|
|
-- helpers need them, and the shop comes first.
|
|
local function findWarpTo(pattern)
|
|
for _, w in ipairs(ow().map.def.warps or {}) do
|
|
if type(w.destMap) == "string" and w.destMap:find(pattern) then return w end
|
|
end
|
|
end
|
|
|
|
local function findObject(pattern)
|
|
for _, o in ipairs(ow().map.def.objects or {}) do
|
|
if type(o.name) == "string" and o.name:find(pattern) then return o end
|
|
end
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- ops
|
|
-- ---------------------------------------------------------------------
|
|
|
|
local ops = {}
|
|
|
|
-- Assigned further down, once the bag/nurse helpers they need exist.
|
|
-- Declared here so ops.battle can top up BEFORE picking a fight.
|
|
local autoHeal, riskThreshold
|
|
|
|
-- The map the NEXT segment expects, set by the segment loop. DIG and FLY
|
|
-- land wherever the save's heal point says rather than where the route
|
|
-- assumed, so the ops that use them need to know what they were aiming at.
|
|
local nextMapWanted
|
|
local travelDumped = false
|
|
|
|
-- Cut a tree that is walling us off from (tx, ty). Assigned below, once
|
|
-- ops.fieldMove exists to do the cutting; declared here because ops.goto_
|
|
-- calls it and comes first.
|
|
local cutToward
|
|
-- Open a Silph Co card-key door that is walling us off from (tx, ty).
|
|
-- Assigned next to cutToward; declared here for the same reason.
|
|
local openDoorToward
|
|
-- Press a reachable Pokémon Mansion statue switch when the switch walls
|
|
-- are what seals off (tx, ty). Assigned next to openDoorToward.
|
|
local toggleMansionSwitch
|
|
-- Bag-driven field item use / step-out-of-a-building helpers. Defined
|
|
-- with the item helpers far below; declared here because ops.fieldMove's
|
|
-- DIG fallback escape-ropes out of the Mansion.
|
|
local useFieldItem, leaveByDoor
|
|
-- Shove a STRENGTH boulder out of the walk to (tx, ty). Assigned next to
|
|
-- toggleMansionSwitch; declared here because ops.goto_'s ladder calls it.
|
|
local pushBoulderToward
|
|
-- Victory Road boulder-switch solver + its switch table; declared here
|
|
-- because walkOntoWarp's no-path ladder calls them.
|
|
local VR_SWITCHES, solveVictoryRoadSwitches
|
|
-- Mount SURF when land runs out. Defined after ops.fieldMove; used by
|
|
-- ops.goto_'s unreachable ladder so the Pallet -> Route 21 -> Cinnabar
|
|
-- water corridor is travellable (BFS is already water-aware WHILE
|
|
-- surfing -- the mount was the missing action).
|
|
local mountSurfToward
|
|
|
|
-- Reach a warp cell by ARRIVING through it. Assigned below (it needs
|
|
-- travelTo); declared here for the same reason as cutToward.
|
|
local arriveByWarp
|
|
|
|
-- pickupBlockingBall (defined below, next to reachableSet) is a GLOBAL,
|
|
-- like cutToward: the main chunk is at LuaJIT's 200-local ceiling.
|
|
|
|
-- Cross to another region of the same map through a gate. Assigned below
|
|
-- (needs walkOntoWarp); declared here because ops.goto_ calls it.
|
|
local reEnterThroughGate
|
|
|
|
-- The border cell of `dir`'s edge that is WATER, nearest to (nearX,
|
|
-- nearY). Some seams are only crossable by sea: Cinnabar's north border
|
|
-- is land at x 10-13 with ocean beyond, so pushing off it just bumps the
|
|
-- shore -- the crossing is the water strip at x 1-3.
|
|
local function borderWaterCell(m, dir, nearX, nearY)
|
|
local best, bestD
|
|
if dir == "up" or dir == "down" then
|
|
local y = dir == "up" and 0 or m.heightCells - 1
|
|
for x = 0, m.widthCells - 1 do
|
|
if m:isWaterCell(x, y) then
|
|
local d = math.abs(x - nearX)
|
|
if not bestD or d < bestD then best, bestD = { x, y }, d end
|
|
end
|
|
end
|
|
else
|
|
local x = dir == "left" and 0 or m.widthCells - 1
|
|
for y = 0, m.heightCells - 1 do
|
|
if m:isWaterCell(x, y) then
|
|
local d = math.abs(y - nearY)
|
|
if not bestD or d < bestD then best, bestD = { x, y }, d end
|
|
end
|
|
end
|
|
end
|
|
return best
|
|
end
|
|
|
|
-- Walk to (x, y). Coordinates outside the map are the route's idiom for
|
|
-- "leave through this edge into the connecting map", so we walk into the
|
|
-- edge and let the connection carry us rather than pathfinding to a cell
|
|
-- that does not exist.
|
|
function ops.goto_(s, _where, isLast)
|
|
local o = ow()
|
|
local map = o.map
|
|
-- surf mounts per goto, BUDGETED: walking ashore dismounts, so an
|
|
-- unreachable target beyond a pond can mount -> cross -> dismount ->
|
|
-- fail -> mount forever (Celadon's pond, target (39,8)) -- but a hard
|
|
-- cap of ONE stranded the bot on Route 20's mid-island strip, where a
|
|
-- legitimate crossing is surf -> beach -> surf again. Three mounts
|
|
-- covers every real island-hop; a still-unreachable target then fails
|
|
-- the goto as before.
|
|
local surfMounts, SURF_BUDGET = 0, 3
|
|
-- ...and a small cut budget per goto: a pocket behind two trees opens
|
|
-- over two passes, but a target that stays unreachable must not
|
|
-- chain-cut every ornamental bush on the map (Saffron/Celadon cut-spam)
|
|
local cuts, CUT_BUDGET = 0, 3
|
|
-- one mansion-switch toggle per goto (see toggleMansionSwitch)
|
|
local switchToggles = 0
|
|
-- boulder shoves per goto: a push chain along a row is several calls
|
|
-- (shove, re-plan, shove), so this is roomier than the cut budget
|
|
local boulderPushes = 0
|
|
local outside = s.x < 0 or s.y < 0
|
|
or s.x >= map.widthCells or s.y >= map.heightCells
|
|
if outside then
|
|
local dir = (s.y < 0 and "up") or (s.y >= map.heightCells and "down")
|
|
or (s.x < 0 and "left") or "right"
|
|
local startMap = map.id
|
|
|
|
-- Get to the border cell the route is pointing through FIRST.
|
|
--
|
|
-- Holding the direction from wherever we happen to be standing only
|
|
-- works if the way out is already in front of us. With a wall in
|
|
-- between it walks into that wall once per iteration -- 200 identical
|
|
-- bumps before giving up, which is exactly what "Red walking into the
|
|
-- same wall over and over and then recovering" looks like on screen.
|
|
local tx = math.max(0, math.min(s.x, map.widthCells - 1))
|
|
local ty = math.max(0, math.min(s.y, map.heightCells - 1))
|
|
local refusedEdge = knownWalls(map.id) or {}
|
|
-- one border-cell retarget per goto (see the fenced-lane rung below)
|
|
local edgeRetargeted = false
|
|
for edgeTry = 1, 2 do
|
|
for _ = 1, 300 do
|
|
if ow().map.id ~= startMap then return true end
|
|
local p = ow().player
|
|
if p.cellX == tx and p.cellY == ty and not p.moving then break end
|
|
if inBattle() then fightBattle()
|
|
elseif busy() then mashUntilIdle()
|
|
else
|
|
local key = bfsNextKey(tx, ty, refusedEdge)
|
|
if not key then
|
|
-- No path to the border cell -- and on the way OUT of a map that
|
|
-- is very often a Cut tree that has grown back. Gen 1 restores
|
|
-- them on every map re-entry, so a tree the route cut on the way
|
|
-- in is solid again on the way back: ROUTE_16 cuts one at segment
|
|
-- 101, and returning east to CELADON_CITY then reported
|
|
-- "ROUTE_16 -> CELADON_CITY did not cross" four times over, with
|
|
-- the bot trapped in the western pocket and travelTo concluding
|
|
-- "no way there from ROUTE_16".
|
|
--
|
|
-- cutToward already handles this for ordinary waypoints; the
|
|
-- edge-exit branch is a separate path and never called it.
|
|
local p2 = ow().player
|
|
if p2.cellX ~= tx or p2.cellY ~= ty then
|
|
if cuts < CUT_BUDGET and cutToward(tx, ty) then
|
|
cuts = cuts + 1
|
|
goto edgeStep -- re-plan now that the way is open
|
|
end
|
|
-- The border can also be walled off by a gate rather than a
|
|
-- tree: ROUTE_16's east seam sits beyond the gate building, so
|
|
-- leaving toward CELADON_CITY means going through it first.
|
|
if reEnterThroughGate(tx, ty) then
|
|
goto edgeStep
|
|
end
|
|
-- ...or the border cell is on WATER (Pallet's south edge):
|
|
-- mount SURF at the shore and re-plan over the sea.
|
|
if surfMounts < SURF_BUDGET and mountSurfToward
|
|
and mountSurfToward(tx, ty) then
|
|
surfMounts = surfMounts + 1
|
|
goto edgeStep
|
|
end
|
|
-- ...or a sleeping SNORLAX is the wall. ROUTE_12's lies at
|
|
-- (10,62), squarely in the seam band shared with ROUTE_11 and
|
|
-- the corridor toward LAVENDER_TOWN, so the whole east-loop
|
|
-- walk home dead-ended on it while cutToward shaved unrelated
|
|
-- bushes ("used CUT on ROUTE_12" x10). The waypoint branch
|
|
-- and walkOntoWarp both have this rung; the border-exit
|
|
-- branch was the one path without it.
|
|
if wakeBlockingSnorlax and wakeBlockingSnorlax("edge exit") then
|
|
goto edgeStep
|
|
end
|
|
-- ...or THIS border column is fenced off while another of
|
|
-- the same edge is open. Viridian's south border is three
|
|
-- fenced lanes and only x20-21 connects to the city; the
|
|
-- planner's seam cell (15,35) is statically walkable-paired
|
|
-- but dynamically sealed, so the exit spun cutting
|
|
-- ornamental bushes and surfing the pond. Retarget ONCE to
|
|
-- the nearest border cell that is BOTH reachable right now
|
|
-- and has a walkable far side.
|
|
if not edgeRetargeted then
|
|
local compass = (dir == "up" and "north")
|
|
or (dir == "down" and "south")
|
|
or (dir == "left" and "west") or "east"
|
|
local conn = map.def.connections and map.def.connections[compass]
|
|
if conn and seamCells and reachableSet then
|
|
local seen, WW = reachableSet()
|
|
local bestAlt, bestD
|
|
for _, c in ipairs(seamCells(map.def, compass, conn)) do
|
|
if seen[c[2] * WW + c[1]] and (c[1] ~= tx or c[2] ~= ty) then
|
|
local d2 = math.abs(c[1] - tx) + math.abs(c[2] - ty)
|
|
if not bestD or d2 < bestD then bestAlt, bestD = c, d2 end
|
|
end
|
|
end
|
|
if bestAlt then
|
|
say(("edge exit %s on %s: border cell (%d,%d) is walled "
|
|
.. "off; crossing at (%d,%d) instead"):format(dir,
|
|
tostring(map.id), tx, ty, bestAlt[1], bestAlt[2]))
|
|
tx, ty = bestAlt[1], bestAlt[2]
|
|
edgeRetargeted = true
|
|
goto edgeStep
|
|
end
|
|
end
|
|
end
|
|
end
|
|
break -- already there, or genuinely no path
|
|
end
|
|
if not walk(key) then
|
|
local dv = DELTA[key]
|
|
if dv then
|
|
local cell = (p.cellY + dv[2]) * map.widthCells + (p.cellX + dv[1])
|
|
refusedEdge[cell] = true
|
|
noteWall(map.id, cell)
|
|
end
|
|
end
|
|
end
|
|
::edgeStep::
|
|
end
|
|
|
|
-- Now push across the seam, but stop as soon as it is clearly not
|
|
-- happening rather than hammering the border for a fixed 200 frames.
|
|
local stalls = 0
|
|
for _ = 1, 80 do
|
|
if ow().map.id ~= startMap then return true end
|
|
if inBattle() then fightBattle()
|
|
elseif busy() then mashUntilIdle()
|
|
else
|
|
local p = ow().player
|
|
local bx, by = p.cellX, p.cellY
|
|
faceDir(dir)
|
|
walk(dir)
|
|
local q = ow().player
|
|
if q.cellX == bx and q.cellY == by then
|
|
stalls = stalls + 1
|
|
if stalls >= 6 then break end
|
|
else
|
|
stalls = 0
|
|
end
|
|
end
|
|
end
|
|
-- The push stalled. Once per goto: if this edge has a WATER crossing
|
|
-- and the party can surf, mount and retry the whole exit over the
|
|
-- sea column instead of bumping the shore (Cinnabar -> Route 21).
|
|
if edgeTry == 1 and surfMounts < SURF_BUDGET and not ow().player.surfing
|
|
and slotKnowing("SURF") then
|
|
local wc = borderWaterCell(map, dir, tx, ty)
|
|
if wc and mountSurfToward and mountSurfToward(wc[1], wc[2]) then
|
|
surfMounts = surfMounts + 1
|
|
tx, ty = wc[1], wc[2]
|
|
say(("edge exit %s on %s: retrying over the water at (%d,%d)")
|
|
:format(dir, tostring(startMap), tx, ty))
|
|
else
|
|
break
|
|
end
|
|
else
|
|
break
|
|
end
|
|
end -- edgeTry
|
|
say(("edge exit failed on %s: %s from (%d,%d) toward (%d,%d)")
|
|
:format(tostring(startMap), dir,
|
|
ow().player.cellX, ow().player.cellY, s.x, s.y))
|
|
return false
|
|
end
|
|
|
|
local blocked = 0
|
|
-- Cells the engine has refused this trip, seeded with the ones earlier
|
|
-- runs already learned about on this map, so we plan around a known bad
|
|
-- tile instead of rediscovering it by walking into it.
|
|
local refused = knownWalls(map.id) or {}
|
|
-- How often we have stood on each cell while pathing. A gate script that
|
|
-- shoves the player back (the locked Viridian Gym at (32,8), Pewter's
|
|
-- youngster, the Route 22 sign) defeats the `blocked` counter below: the
|
|
-- step genuinely succeeds, so walk() returns true and blocked resets --
|
|
-- then the script walks us straight back and we oscillate for the full
|
|
-- 600 iterations. Bail once a cell has come round too many times.
|
|
local visits, oscillating = {}, 12
|
|
-- Which cell each shove came from, so the plan can route AROUND the
|
|
-- trigger instead of merely giving up. A shove is a step that SUCCEEDS
|
|
-- and is then scripted back (Cinnabar's locked gym door at (18,4):
|
|
-- standing there without the SECRET_KEY prints "The door is locked..."
|
|
-- and walks you back down, and BFS -- deterministic, ties broken "up"
|
|
-- first -- re-proposed that exact step forever from (18,5)). Two
|
|
-- reverts on the same step target make it a refused cell for this trip;
|
|
-- per-call only, because the same cell is legitimately walkable later
|
|
-- (with the key in the bag the gate does not fire).
|
|
local reverts, lastFrom, lastTarget = {}, nil, nil
|
|
-- Whether the last successful step landed exactly ON the waypoint. A
|
|
-- SAME-MAP teleporter (Saffron Gym's warp pads) fires the moment that
|
|
-- step lands and moves us across the map, so the map-change guard never
|
|
-- returns and the loop hunts a waypoint it has already satisfied from
|
|
-- the far pocket -- which is how segment 173 wandered the gym, fought
|
|
-- two side trainers, and left without ever reaching Sabrina. A jump of
|
|
-- more than two cells distinguishes a pad from a gate-script shove
|
|
-- (which walks us back exactly one).
|
|
local steppedOnTarget = false
|
|
for _ = 1, 600 do
|
|
-- Stop the instant a warp changed the map under us.
|
|
--
|
|
-- A waypoint can sit past a warp the route walks THROUGH rather than
|
|
-- onto: VERMILION_DOCK's boarding warp is at (14,2) and segment 62 is
|
|
-- `goto (14,0)` then `goto (14,3)`, so walking down to (14,3) steps onto
|
|
-- (14,2) and boards the S.S. Anne mid-walk. Without this guard the goto
|
|
-- kept chasing (14,3) -- now a cell ON THE SHIP -- and pathed from the
|
|
-- arrival tile (27,0) straight into (26,0), the ship's OWN warp back to
|
|
-- the dock, bouncing off. Every following segment then ran on the wrong
|
|
-- map: the whole ship skipped, HM01 CUT was never collected, and the
|
|
-- run died 15 segments later at the uncuttable Route 9 tree. The
|
|
-- edge-exit branch already returns on a map change; the in-bounds loop
|
|
-- must too, and the segment loop re-syncs to wherever the warp left us.
|
|
if ow().map.id ~= map.id then return true end
|
|
local p = ow().player
|
|
if steppedOnTarget then
|
|
local jump = math.abs(p.cellX - s.x) + math.abs(p.cellY - s.y)
|
|
if jump > 2 then
|
|
say(("goto (%d,%d): stepped on it and a pad moved us to (%d,%d) -- "
|
|
.. "waypoint done"):format(s.x, s.y, p.cellX, p.cellY))
|
|
return true
|
|
end
|
|
end
|
|
if p.cellX == s.x and p.cellY == s.y then
|
|
-- Arrived. If the tween into the cell is still running, wait it out
|
|
-- rather than falling through to BFS -- bfsNextKey returns nil when
|
|
-- start == goal, which the branch below would misread as "no path"
|
|
-- and report as unreachable while we are standing on the target.
|
|
if not p.moving then
|
|
-- Landing on a warp cell that does not take us anywhere.
|
|
--
|
|
-- Warp.onArrive only fires for a tile in the tileset's warpTiles;
|
|
-- everything else needs the direction still HELD while facing the
|
|
-- map edge (Warp.onCollision + extraCheck), and walk() releases the
|
|
-- key the moment the cell changes. Either way the segment then ends
|
|
-- on the right tile, on the wrong map, and every following segment
|
|
-- skips -- Mt. Moon B1F's exit at (27,3) is exactly this.
|
|
--
|
|
-- ONLY on a segment's last step. A segment's FIRST waypoint is
|
|
-- routinely the warp we just arrived through -- Red's house
|
|
-- segment 2 opens on (7,1), the stairs it just came down -- and
|
|
-- shoving there rides them straight back up. That cost a whole
|
|
-- attempt at segment 2 the first time this went in.
|
|
local warped = isLast and map:warpAtCell(s.x, s.y)
|
|
if warped then
|
|
local from = ow().map.id
|
|
local pushed = false
|
|
for _ = 1, 8 do
|
|
if ow().map.id ~= from then return true end
|
|
local m = ow().map
|
|
-- A warp needing a held press: mirror the engine's
|
|
-- Warp.extraCheck -- facing the map edge, OR a warp-carpet
|
|
-- door tile in front (Route 16's gate mats: plain path
|
|
-- tiles whose warp fires only pressing INTO the building).
|
|
-- A true interior door fires on its own; no direction
|
|
-- passes extraCheck there and we stop pushing.
|
|
local WarpMod = require("src.world.Warp")
|
|
local carpets = G.data.field and G.data.field.warpCarpets
|
|
local outward
|
|
for _, dd in ipairs(DIRS) do
|
|
if WarpMod.extraCheck(m, carpets, s.x, s.y, dd[3]) then
|
|
outward = dd[3]
|
|
break
|
|
end
|
|
end
|
|
if not outward then break end
|
|
pushed = true
|
|
faceDir(outward)
|
|
walk(outward, 8)
|
|
end
|
|
-- Only worth saying when we actually tried and it still refused.
|
|
-- Interior doors reach here every time (they fire a moment later,
|
|
-- during the next segment's map-wait), and warning about those
|
|
-- just cries wolf -- two showed up in the first ten segments.
|
|
if pushed and ow().map.id == from then
|
|
local dest = (warped.def and warped.def.destMap) or warped.destMap
|
|
say(("goto (%d,%d): stood on a border warp to %s on %s and it "
|
|
.. "did not fire"):format(s.x, s.y, tostring(dest), from))
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
U.wait(2)
|
|
elseif inBattle() then
|
|
fightBattle()
|
|
elseif busy() then
|
|
mashUntilIdle()
|
|
else
|
|
-- Count only the iterations where we actually choose a step from this
|
|
-- cell -- not frames spent in a battle or waiting out a tween, which
|
|
-- legitimately sit on one cell for a long time.
|
|
local cell = p.cellY * ow().map.widthCells + p.cellX
|
|
-- Back where the last successful step started: that step was shoved.
|
|
-- EXCEPT on Cycling Road, where the hill (not a gate script) rolls
|
|
-- the bike a cell south on any empty-input frame: blacklisting the
|
|
-- cell above for that severed the one-cell lane at ROUTE_17
|
|
-- (18,122) and wedged the run in the corner. A roll-back is exactly
|
|
-- "we are one row BELOW the cell we had reached"; keep retrying it,
|
|
-- the slopeBrakeDir holds should stop the next one.
|
|
if lastTarget and cell == lastFrom then
|
|
local W = ow().map.widthCells
|
|
local rolledBack = slopeMapNow() and lastFrom == lastTarget + W
|
|
if not rolledBack then
|
|
reverts[lastTarget] = (reverts[lastTarget] or 0) + 1
|
|
if reverts[lastTarget] >= 2 and not refused[lastTarget] then
|
|
say(("goto (%d,%d): the step onto (%d,%d) keeps getting shoved "
|
|
.. "back -- planning around it")
|
|
:format(s.x, s.y, lastTarget % W, math.floor(lastTarget / W)))
|
|
refused[lastTarget] = true
|
|
visits = {} -- the new plan deserves a fresh oscillation budget
|
|
end
|
|
end
|
|
end
|
|
visits[cell] = (visits[cell] or 0) + 1
|
|
if visits[cell] > oscillating then
|
|
say(("goto (%d,%d) gave up on %s: pathed away from (%d,%d) %d times "
|
|
.. "-- a gate script keeps pushing us back")
|
|
:format(s.x, s.y, ow().map.id, p.cellX, p.cellY, visits[cell]))
|
|
return false
|
|
end
|
|
local key = bfsNextKey(s.x, s.y, refused)
|
|
if not key then
|
|
-- an NPC can park on the target or the only path to it; give it a
|
|
-- moment to wander off before giving up on this waypoint
|
|
U.wait(30)
|
|
-- That wait yields, so a script move or a tween can land us on the
|
|
-- target meanwhile -- and bfsNextKey returns nil for "already
|
|
-- there" just as it does for "no path". Re-check arrival first or
|
|
-- we report the waypoint unreachable while standing on it (and
|
|
-- `p` is live, so the message would print from == target).
|
|
local arrived = p.cellX == s.x and p.cellY == s.y
|
|
if not arrived and not bfsNextKey(s.x, s.y, refused) then
|
|
-- BFS treats NPCs as walls, so an unreachable waypoint almost
|
|
-- always means somebody is standing where the route expects
|
|
-- empty floor -- usually an event NPC that should have been
|
|
-- hidden by a script. Name them, so the log says who.
|
|
local who = {}
|
|
for _, npc in ipairs(ow().npcs) do
|
|
who[#who + 1] = ("%s@(%d,%d)"):format(
|
|
tostring(npc.def and npc.def.id or npc.def and npc.def.sprite or "?"),
|
|
npc.cellX, npc.cellY)
|
|
end
|
|
-- ...or a Cut tree has grown back across the way.
|
|
--
|
|
-- Gen 1 restores cut trees whenever the map is re-entered, and we
|
|
-- re-enter constantly: every Poké Center visit is a warp out and
|
|
-- back. Vermilion is the case that forced this. Its gym sits in a
|
|
-- pocket (x 6-15, y 20-23) whose ONLY opening is the tree at
|
|
-- (15,18) -- verified against the block data, block 53, which is
|
|
-- a cutTreeSwaps entry. So beating Surge, healing at the centre
|
|
-- and then running segment 71 found `goto (12,20)` unreachable,
|
|
-- every following segment skipped, and a won gym was thrown away.
|
|
--
|
|
-- Budgeted: on maps whose ornamental bushes match cutTreeSwaps
|
|
-- (Saffron, Celadon), a target that is walled off for some OTHER
|
|
-- reason otherwise chain-cuts every reachable bush before giving
|
|
-- up, and map re-entry regrows them all for the next pass.
|
|
if cuts < CUT_BUDGET and cutToward(s.x, s.y) then
|
|
cuts = cuts + 1
|
|
blocked = 0
|
|
goto keepGoing
|
|
end
|
|
-- ...or a Silph Co card-key door. The closed tile is not
|
|
-- walkable and only an A-press while facing it opens it
|
|
-- (OverworldState:tryCardKeyDoor), so to BFS every locked door
|
|
-- is a permanent wall -- which is how segment 148's walk to
|
|
-- Giovanni read "unreachable" and the Silph ending got skipped.
|
|
if openDoorToward(s.x, s.y) then
|
|
blocked = 0
|
|
goto keepGoing
|
|
end
|
|
-- ...or a Pokémon Mansion gate in the wrong switch state. ONE
|
|
-- toggle per goto: if the flipped state is still wrong, the
|
|
-- next call flips it back rather than this one oscillating.
|
|
if switchToggles == 0 and toggleMansionSwitch(s.x, s.y) then
|
|
switchToggles = switchToggles + 1
|
|
blocked = 0
|
|
goto keepGoing
|
|
end
|
|
-- ...or an item ball is the wall (Pokémon Tower 6F's
|
|
-- RARE_CANDY sits in the floor's only crossing corridor).
|
|
-- Tried BEFORE arriveByWarp: taking the ball keeps the walk on
|
|
-- this floor, while arriveByWarp warps clear off it hunting
|
|
-- the far end.
|
|
if pickupBlockingBall and pickupBlockingBall(s.x, s.y) then
|
|
blocked = 0
|
|
goto keepGoing
|
|
end
|
|
-- ...or the target is in a part of this map no walk connects.
|
|
--
|
|
-- Cave floors are not one connected room. MT_MOON_B1F is four
|
|
-- separate pockets stitched together through B2F and 1F, so
|
|
-- standing on warp 5 at (21,17) there is genuinely NO path to
|
|
-- warp 7 at (23,3) -- the log said so with "npcs: none". travelTo
|
|
-- cannot help either: both cells are the same MAP, so it thinks
|
|
-- we have already arrived. The way to such a cell is to come out
|
|
-- of it, which is what this does.
|
|
if arriveByWarp(s.x, s.y) then return true end
|
|
-- ...or the way across is a gate whose warps all say LAST_MAP,
|
|
-- which no plan can see through (ROUTE_16's two halves).
|
|
if reEnterThroughGate(s.x, s.y) then
|
|
blocked = 0
|
|
goto keepGoing
|
|
end
|
|
-- ...or a sleeping SNORLAX is the wall and the POKE FLUTE is
|
|
-- aboard: wake it rather than report unreachable (the route's
|
|
-- own flute segment can get skipped in a desync).
|
|
if wakeBlockingSnorlax and wakeBlockingSnorlax("goto") then
|
|
blocked = 0
|
|
goto keepGoing
|
|
end
|
|
-- ...or a STRENGTH boulder is the wall. PokeBotBad's Victory
|
|
-- Road waypoints path straight through boulders, shoving them
|
|
-- a cell per step -- so both "the target is under a boulder"
|
|
-- and "a boulder sits on the only path" land here. One shove,
|
|
-- then re-plan; budgeted like the cuts.
|
|
if boulderPushes < 8 and pushBoulderToward
|
|
and pushBoulderToward(s.x, s.y) then
|
|
boulderPushes = boulderPushes + 1
|
|
blocked = 0
|
|
goto keepGoing
|
|
end
|
|
-- ...or the waypoint IS an NPC's own cell. PokeBotBad's
|
|
-- collision model does not wall off NPCs, so its waypoints
|
|
-- sometimes name the tile a clerk is standing on -- Celadon
|
|
-- Mart 2F's goto (6,3) is CELADONMART2F_CLERK2's cell, and the
|
|
-- failed step cost the run its POKE_DOLL. Our BFS is right to
|
|
-- refuse; "beside them, facing them" serves every use the
|
|
-- route makes of such a waypoint (a talk/shop step follows).
|
|
for _, npc in ipairs(ow().npcs) do
|
|
if npc.cellX == s.x and npc.cellY == s.y then
|
|
for _, d in ipairs({ { 0, 1, "up" }, { 0, -1, "down" },
|
|
{ -1, 0, "right" }, { 1, 0, "left" } }) do
|
|
local ax, ay = s.x + d[1], s.y + d[2]
|
|
if map:inBounds(ax, ay) and map:isWalkableCell(ax, ay)
|
|
and not ow():npcAtCell(ax, ay)
|
|
and ops.goto_({ x = ax, y = ay }) then
|
|
local p2 = ow().player
|
|
if p2.cellX == ax and p2.cellY == ay then
|
|
faceDir(d[3])
|
|
say(("goto (%d,%d) is %s's own cell; standing beside "
|
|
.. "at (%d,%d)"):format(s.x, s.y,
|
|
tostring(npc.def and (npc.def.id or npc.def.sprite)
|
|
or "npc"), ax, ay))
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
break
|
|
end
|
|
end
|
|
-- ...or the land simply ends and the rest of the way is WATER.
|
|
-- Mount SURF at the nearest shore and re-plan; bfsNextKey
|
|
-- treats water as passable while surfing. Last in the ladder so
|
|
-- an NPC-blocked target in a pond town does not trigger a
|
|
-- pointless swim, and ONCE per goto -- coming ashore dismounts,
|
|
-- so a still-unreachable target re-mounts forever otherwise.
|
|
if surfMounts < SURF_BUDGET and mountSurfToward
|
|
and mountSurfToward(s.x, s.y) then
|
|
surfMounts = surfMounts + 1
|
|
blocked = 0
|
|
goto keepGoing
|
|
end
|
|
say(("goto (%d,%d) unreachable on %s; from (%d,%d); npcs: %s")
|
|
:format(s.x, s.y, ow().map.id, p.cellX, p.cellY,
|
|
#who > 0 and table.concat(who, " ") or "none"))
|
|
return false
|
|
end
|
|
elseif walk(key) then
|
|
blocked = 0
|
|
-- remember the step for the shove detector above, target computed
|
|
-- from where we stood BEFORE the walk
|
|
local dv = DELTA[key]
|
|
if dv then
|
|
local W = ow().map.widthCells
|
|
lastFrom = cell
|
|
lastTarget = (math.floor(cell / W) + dv[2]) * W + (cell % W + dv[1])
|
|
steppedOnTarget = lastTarget == s.y * W + s.x
|
|
end
|
|
-- proved passable: forget any refusal an earlier run recorded here
|
|
local now = ow()
|
|
if now.map.id == map.id then
|
|
clearWall(map.id, now.player.cellY * now.map.widthCells + now.player.cellX)
|
|
end
|
|
else
|
|
-- BFS said this was walkable but the step did not land: a warp
|
|
-- fired, an entity moved in, or the collision data disagrees
|
|
-- (ledges are one-way, counters and doors are not plain floor).
|
|
--
|
|
-- Retrying alone is useless -- BFS is deterministic, so it hands
|
|
-- back this exact step again and we bump the same wall until the
|
|
-- counter runs out. Remember the cell it refused and re-plan; the
|
|
-- next bfsNextKey call treats it as a wall and routes around.
|
|
blocked = blocked + 1
|
|
local dv = DELTA[key]
|
|
if dv then
|
|
local bx, by = p.cellX + dv[1], p.cellY + dv[2]
|
|
local cell = by * map.widthCells + bx
|
|
refused[cell] = true
|
|
local seen = noteWall(map.id, cell)
|
|
if blocked == 1 then
|
|
say(("goto (%d,%d): %s refused the step into (%d,%d); routing "
|
|
.. "around it (refused %dx across runs)")
|
|
:format(s.x, s.y, ow().map.id, bx, by, seen))
|
|
end
|
|
end
|
|
if blocked >= 8 then
|
|
say(("goto (%d,%d) stuck at (%d,%d) on %s")
|
|
:format(s.x, s.y, p.cellX, p.cellY, ow().map.id))
|
|
return false
|
|
end
|
|
U.wait(4)
|
|
end
|
|
end
|
|
::keepGoing::
|
|
end
|
|
say(("goto (%d,%d) timed out"):format(s.x, s.y))
|
|
return false
|
|
end
|
|
|
|
-- Interact with whatever is adjacent, and report whether anything opened.
|
|
--
|
|
-- Most converted steps carry no direction: PokeBotBad's strategy functions
|
|
-- knew the geometry, so the route only records a dir for its generic
|
|
-- {s="talk",dir=...} verb. Walking a waypoint leaves us facing the way we
|
|
-- travelled, which is rarely the way the target sits -- arriving at Oak's
|
|
-- ball row from the west leaves us facing right while the ball is north.
|
|
-- So when no direction is given, try each facing until the press takes.
|
|
local function interact(face)
|
|
local dirs = face and { face }
|
|
or { ow().player.facing, "up", "down", "left", "right" }
|
|
for _, d in ipairs(dirs) do
|
|
faceDir(d)
|
|
press("a")
|
|
U.wait(10)
|
|
if busy() or inBattle() then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
function ops.battle(s, where)
|
|
-- Top up BEFORE picking the fight rather than after losing it. This is
|
|
-- the whole difference between a trainer costing us a potion and a
|
|
-- trainer costing us the run, and it is where the danger memory pays
|
|
-- off: on a map that has killed us the bar is much higher.
|
|
autoHeal(where, riskThreshold(where))
|
|
local opened = interact(s.face)
|
|
if inBattle() then fightBattle() end
|
|
mashUntilIdle()
|
|
return opened
|
|
end
|
|
|
|
function ops.talk(s, where)
|
|
-- A talk aimed at an item ball is a pickup in disguise: the route data
|
|
-- writes plain `talk` for some balls -- Safari's GOLD_TEETH among them,
|
|
-- and losing THAT silently left no trace until Victory Road's boulders
|
|
-- found nobody knowing STRENGTH. Route those through ops.pickup's
|
|
-- verified, retrying path. Only balls whose def carries `.item` count:
|
|
-- Oak's starter balls are SPRITE_POKE_BALL too, but they are script
|
|
-- objects with no item field, and shoving THAT cutscene through the
|
|
-- pickup retry would wreck the intro. `__asPickup` breaks the mutual
|
|
-- recursion (ops.pickup drives the actual interaction via ops.talk).
|
|
if not s.__asPickup then
|
|
local p = ow().player
|
|
for _, npc in ipairs(ow().npcs) do
|
|
if npc.def and npc.def.item
|
|
and math.abs(npc.cellX - p.cellX)
|
|
+ math.abs(npc.cellY - p.cellY) <= 2 then
|
|
return ops.pickup(setmetatable({ __asPickup = true }, { __index = s }),
|
|
where or ow().map.id)
|
|
end
|
|
end
|
|
end
|
|
local opened = interact(s.face)
|
|
-- a talk can start a fight (a trainer, or the ball that triggers the
|
|
-- rival), so never assume the overworld is what we come back to
|
|
if inBattle() then fightBattle() end
|
|
mashUntilIdle()
|
|
return opened
|
|
end
|
|
|
|
-- A pickup is a talk aimed at an item ball -- but unlike a talk it has a
|
|
-- verifiable outcome: something enters the bag. Silph's CARD_KEY was lost
|
|
-- exactly here: the goto before this step had failed ("goto (20,16)
|
|
-- unreachable on SILPH_CO_5F"), the interact mashed empty dialog from the
|
|
-- wrong cell, and the run only found out ten locked doors later as a wall
|
|
-- of "no path". Verify the gain; when nothing arrived, walk to the
|
|
-- nearest still-visible item ball and try once more, loudly either way.
|
|
function ops.pickup(s, where)
|
|
local function bagTotal()
|
|
local t = 0
|
|
for _, n in pairs(G.save.inventory or {}) do t = t + (tonumber(n) or 1) end
|
|
return t
|
|
end
|
|
local startMap = ow().map.id
|
|
local before = bagTotal()
|
|
local opened = ops.talk(s)
|
|
if bagTotal() > before then return opened end
|
|
if ow().map.id ~= startMap then
|
|
-- something during the talk moved us off the map (a warp, a rope);
|
|
-- retrying HERE would hunt balls on the wrong floor. Let the step
|
|
-- loop's partway-check re-sync instead.
|
|
say(("pickup on %s FAILED: left the map mid-step (now on %s)")
|
|
:format(where, tostring(ow().map.id)))
|
|
return false
|
|
end
|
|
-- A FULL bag refuses the ball outright ("no more room") -- the Saffron
|
|
-- restock packs all 20 slots right before Silph's CARD_KEY. Free one,
|
|
-- then retry at the ball below.
|
|
if #(G.save.bagOrder or {}) >= 20 and pickupTossJunk then
|
|
pickupTossJunk(where)
|
|
before = bagTotal() -- the toss shrank the bag; success is a gain from HERE
|
|
if ow().map.id ~= startMap then
|
|
say(("pickup on %s FAILED: the toss moved us off the map (now on %s)")
|
|
:format(where, tostring(ow().map.id)))
|
|
return false
|
|
end
|
|
end
|
|
local p = ow().player
|
|
local ball, bestD
|
|
for _, npc in ipairs(ow().npcs) do
|
|
-- live entities carry their identity on npc.def (see
|
|
-- wakeBlockingSnorlax); only defs with `.item` are real bag pickups
|
|
-- (Oak's starter balls are SPRITE_POKE_BALL script objects without it)
|
|
if npc.def and npc.def.item then
|
|
local d = math.abs(npc.cellX - p.cellX) + math.abs(npc.cellY - p.cellY)
|
|
if not bestD or d < bestD then ball, bestD = npc, d end
|
|
end
|
|
end
|
|
if ball then
|
|
-- stand beside the ball and face back toward it (offset -> face pairs)
|
|
for _, d in ipairs({ { 1, 0, "left" }, { -1, 0, "right" },
|
|
{ 0, 1, "up" }, { 0, -1, "down" } }) do
|
|
local nx, ny = ball.cellX + d[1], ball.cellY + d[2]
|
|
if passableCell(ow().map, nx, ny)
|
|
and ops.goto_({ x = nx, y = ny }, where, false)
|
|
and ow().player.cellX == nx and ow().player.cellY == ny then
|
|
interact(d[3])
|
|
if inBattle() then fightBattle() end
|
|
mashUntilIdle()
|
|
break
|
|
end
|
|
end
|
|
end
|
|
if bagTotal() > before then
|
|
say(("pickup on %s: first try gained nothing; retry at the ball "
|
|
.. "(%d,%d) worked"):format(where, ball.cellX, ball.cellY))
|
|
return true
|
|
end
|
|
say(("pickup on %s FAILED: nothing entered the bag (from (%d,%d)%s)")
|
|
:format(where, p.cellX, p.cellY,
|
|
ball and (", ball seen at (" .. ball.cellX .. ","
|
|
.. ball.cellY .. ")")
|
|
or ", no ball in sight"))
|
|
return false
|
|
end
|
|
|
|
function ops.bike()
|
|
-- toggling the bike needs the bag; without a handler we simply walk,
|
|
-- which costs time but never blocks progress
|
|
return true
|
|
end
|
|
|
|
-- The route names its catch targets in its own vocabulary; these are the
|
|
-- species the encounter tables actually use. `flier` is PokeBotBad's word
|
|
-- for "whatever around here can be taught FLY".
|
|
local CATCH_SPECIES = {
|
|
nidoran = { "NIDORAN_M", "NIDORAN_F" },
|
|
oddish = { "ODDISH" },
|
|
paras = { "PARAS" },
|
|
flier = { "SPEAROW", "PIDGEY" },
|
|
}
|
|
|
|
-- Set by allowCatch, read by the catch handlers that follow it. The route
|
|
-- always pairs the two (`allowCatch nidoran` then `manual catchNidoran`),
|
|
-- so this is only a fallback for the handler's own species list.
|
|
local catchTarget
|
|
|
|
function ops.allowCatch(s)
|
|
catchTarget = s.mon and CATCH_SPECIES[s.mon] or nil
|
|
return true
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- shops
|
|
-- ---------------------------------------------------------------------
|
|
-- Which screen is on top. ShopMenu is a Menu of BUY/SELL/QUIT whose rows
|
|
-- carry .onSelect; the buy list is a ListMenu whose rows carry .value (the
|
|
-- item id); QuantityBox owns .qty; ChoiceBox owns .index with no rows.
|
|
local function top() return G.stack:top() end
|
|
local function rows() local t = top() return t and t.items end
|
|
local function isMenu()
|
|
local r = rows() return r ~= nil and r[1] ~= nil and r[1].onSelect ~= nil
|
|
end
|
|
local function isList()
|
|
local r = rows() return r ~= nil and r[1] ~= nil and r[1].value ~= nil
|
|
end
|
|
local function isQty() local t = top() return t ~= nil and t.qty ~= nil end
|
|
local function isChoice()
|
|
local t = top() return t ~= nil and t.index ~= nil and t.items == nil
|
|
end
|
|
|
|
-- press `btn` until pred() holds
|
|
local function pressUntil(pred, btn, tries)
|
|
for _ = 1, tries or 60 do
|
|
if pred() then return true end
|
|
press(btn)
|
|
U.wait(4)
|
|
end
|
|
return pred()
|
|
end
|
|
|
|
-- move a vertical cursor to `want`, reading `field` off the top screen
|
|
local function cursorTo(field, want, tries)
|
|
for _ = 1, tries or 40 do
|
|
local t = top()
|
|
if not t or t[field] == want then return t ~= nil end
|
|
press(t[field] > want and "up" or "down")
|
|
U.wait(3)
|
|
end
|
|
local t = top()
|
|
return t ~= nil and t[field] == want
|
|
end
|
|
|
|
-- PokeBotBad's shop lists are exact counts tuned to a speedrun's money and
|
|
-- damage breakpoints. We only need to stay alive and be able to catch
|
|
-- HM-safe MoveLearnMenu handling, shared by fightBattle, mashUntilIdle
|
|
-- and ops.teach's flow. Returns true if it did something (a learn menu is
|
|
-- somewhere on the stack), false when there is nothing learn-related up.
|
|
-- `preferForget` is the route step's `replace` hint: forget that move when
|
|
-- it is present and safe, fall back to the first-safe-row heuristic.
|
|
function driveLearnMenu(preferForget)
|
|
local menu
|
|
for _, st in ipairs(G.stack.states or {}) do
|
|
if st.newMoveId and st.mon and st.index then menu = st break end
|
|
end
|
|
if not menu then return false end
|
|
local newId = tostring(menu.newMoveId or ""):upper()
|
|
-- The learn flow raises TWO YES/NO prompts wanting OPPOSITE answers:
|
|
-- "Delete an older move?" wants YES (on to the forget list), "Abandon
|
|
-- learning X?" wants NO (back to the delete prompt). Blindly answering
|
|
-- YES confirmed the abandon whenever stray B presses (backOut) had
|
|
-- flipped the flow onto the second prompt -- the run lost BUBBLEBEAM,
|
|
-- DIG and CUT to exactly that. The ChoiceBox itself is text-free; the
|
|
-- prompt is the TextBox directly beneath it on the stack, so read that.
|
|
-- (Nested here rather than file-local: the main chunk is at LuaJIT's
|
|
-- 200-local ceiling.)
|
|
local function choicePromptIsAbandon()
|
|
local states = G.stack.states or {}
|
|
local under = states[#states - 1]
|
|
if under and under.pages then
|
|
for _, page in ipairs(under.pages) do
|
|
for _, l in ipairs(page) do
|
|
if tostring(l):find("Abandon") then return true end
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
-- Decide the goal up front, because the choice prompts need to know it:
|
|
-- forget `row`, or abandon the new move when nothing is safe to forget.
|
|
-- HM field moves are excluded (the engine refuses, and they are the
|
|
-- run's geography), as is the move being learned.
|
|
local UNFORGETTABLE = { SURF = true, CUT = true, STRENGTH = true,
|
|
FLY = true, FLASH = true, DIG = true }
|
|
local moves = (menu.mon and menu.mon.moves) or {}
|
|
local prefer = preferForget and tostring(preferForget):upper() or nil
|
|
local row
|
|
if prefer then
|
|
for ri, mv in ipairs(moves) do
|
|
local id = tostring(mv.id or mv):upper()
|
|
if id == prefer and not UNFORGETTABLE[id] and id ~= newId then
|
|
row = ri
|
|
break
|
|
end
|
|
end
|
|
end
|
|
if not row then
|
|
for ri, mv in ipairs(moves) do
|
|
local id = tostring(mv.id or mv):upper()
|
|
if not UNFORGETTABLE[id] and id ~= newId then row = ri break end
|
|
end
|
|
end
|
|
local t = top()
|
|
if t == menu then
|
|
if not row then
|
|
press("b") -- nothing safe to forget: give up on the new move
|
|
U.wait(8)
|
|
return true
|
|
end
|
|
if cursorTo("index", row) then press("a") end
|
|
U.wait(10)
|
|
return true
|
|
end
|
|
if isChoice() then
|
|
-- Answer for the goal: heading for the forget list means YES on
|
|
-- "Delete an older move?" and NO on "Abandon learning?"; when nothing
|
|
-- is safe to forget the answers flip, because abandoning IS the goal.
|
|
local wantForget = row ~= nil
|
|
local yes = (choicePromptIsAbandon() ~= wantForget)
|
|
if yes then
|
|
cursorTo("index", 1)
|
|
press("a")
|
|
else
|
|
press("b")
|
|
end
|
|
U.wait(8)
|
|
return true
|
|
end
|
|
press("a") -- the surrounding text boxes
|
|
U.wait(6)
|
|
return true
|
|
end
|
|
|
|
-- things, so each stop buys a plain stock instead.
|
|
local SHOP_STOCK = {
|
|
-- Viridian Mart stocks POKE_BALL, ANTIDOTE, PARLYZ_HEAL, BURN_HEAL and
|
|
-- no POTION at all (vanilla) -- so there is no HP healing for sale until
|
|
-- Pewter, and the only HP items before Viridian Forest are the two
|
|
-- hidden POTIONs at VIRIDIAN_CITY (14,4) and VIRIDIAN_FOREST (1,18).
|
|
-- The status heals are worth the money: ops.heal(status) can spend them,
|
|
-- and a slept lead loses more turns than a dented one.
|
|
viridianBalls = { { "POKE_BALL", 10 }, { "ANTIDOTE", 2 }, { "PARLYZ_HEAL", 2 } },
|
|
-- ESCAPE_ROPE first, and it is not optional: segment 48 leaves Bill's
|
|
-- house with `useItem escape_rope` and has no walk to the door, so
|
|
-- without one in the bag the bot takes the S.S. ANNE TICKET and then
|
|
-- stands in Bill's living room until the skip guard kills the attempt.
|
|
-- Pewter is the only stop before Bill that sells them (Cerulean Mart
|
|
-- stocks REPEL in that slot), so it gets first call on the money.
|
|
pewter = { { "ESCAPE_ROPE", 1 }, { "POTION", 8 } },
|
|
-- SUPER_POTION, not POTION: VermilionMartClerkText stocks
|
|
-- POKE_BALL, SUPER_POTION, ICE_HEAL, AWAKENING, PARLYZ_HEAL, REPEL
|
|
-- (data/items/marts.asm:17) and carries no plain POTION at all. Asking
|
|
-- for one bought nothing -- "shop: no POTION @ VERMILION_MART" -- so the
|
|
-- bot left Vermilion with zero healing and spent the next stretch of the
|
|
-- route dying: nine wipes in Surge's gym, five more around Cerulean, all
|
|
-- of them fought at whatever HP the last nurse left. Healing comes first
|
|
-- here; the balls are for later catches and can wait.
|
|
vermilion = { { "SUPER_POTION", 8 }, { "POKE_BALL", 5 } },
|
|
-- Celadon Mart 2F (segment 92) is the best-stocked shop the route visits
|
|
-- and the last one before the long Rocket Hideout / Pokémon Tower / Silph
|
|
-- stretch, so it is where the run buys its staying power.
|
|
--
|
|
-- SUPER_REPEL is dropped on purpose: fewer wild encounters means less
|
|
-- exp, and being underlevelled is what actually kills this bot. REVIVE is
|
|
-- worth the 1500 -- a faint mid-dungeon otherwise means a walk back from
|
|
-- the heal point through everything we just cleared.
|
|
-- `reserve` keeps ¥800 back for the roof's vending machines four segments
|
|
-- later (a FRESH_WATER is ¥200, and one drink opens every Saffron gate).
|
|
-- REVIVE is dropped: at ¥1500 it alone emptied the wallet, and a revive
|
|
-- is worth far less to this run than reaching Lavender at all.
|
|
repels = { reserve = 800, { "SUPER_POTION", 4 } },
|
|
-- Celadon Mart 5F stocks X_ACCURACY / GUARD_SPEC / DIRE_HIT / the X
|
|
-- stat items and the five vitamins -- no healing at all. It previously
|
|
-- asked for FULL_RESTORE here, which 5F has never sold (that is the
|
|
-- Indigo Plateau lobby), so the step bought nothing every run. The X
|
|
-- items are exactly the speedrun tech we drop, so there is nothing here
|
|
-- worth stopping for; kept as an empty list so the step is a no-op
|
|
-- rather than a logged failure.
|
|
buffs = {},
|
|
pokeDoll = { { "POKE_DOLL", 1 } },
|
|
-- TM07 and the vending machine are speedrun tech (Horn Drill, drinks for
|
|
-- the Saffron guards); the guards are handled by the giveWater handler
|
|
tm07 = {}, vending = {}, water = {},
|
|
-- Saffron Mart (GREAT_BALL, HYPER_POTION, MAX_REPEL, ESCAPE_ROPE,
|
|
-- FULL_HEAL, REVIVE -- marts.asm). Not a route stop; the segment loop
|
|
-- shops here when the heal-point anchor finds the bag out of HP items,
|
|
-- because Silph's 7F rival sits right above and a checkpoint-resumed
|
|
-- bag arrives empty -- five wipes' worth of empty.
|
|
saffronRestock = { { "HYPER_POTION", 3 }, { "MAX_REPEL", 3 },
|
|
{ "FULL_HEAL", 2 } },
|
|
-- Cinnabar Mart (ULTRA_BALL, GREAT_BALL, HYPER_POTION, MAX_REPEL,
|
|
-- ESCAPE_ROPE, FULL_HEAL, REVIVE). The Mansion sits next door and its
|
|
-- B1F is chained wild fights: arriving with an empty bag drained the
|
|
-- lead to 2/182 and the blackout threw the arc away. The repels feed
|
|
-- the route's own useItem super_repel steps (segments 155/162).
|
|
cinnabarRestock = { { "HYPER_POTION", 4 }, { "MAX_REPEL", 3 },
|
|
{ "FULL_HEAL", 2 } },
|
|
-- The Indigo lobby mart (ULTRA_BALL, GREAT_BALL, FULL_RESTORE,
|
|
-- MAX_POTION, FULL_HEAL, REVIVE, MAX_REPEL). Not a route stop -- the
|
|
-- speedrun arrives provisioned -- but our bot arrives with whatever
|
|
-- survived Victory Road, and the Elite Four is five fights with no
|
|
-- nurse between them. The segment loop shops here on its own when the
|
|
-- lobby segment runs (see the INDIGO_PLATEAU_LOBBY anchor). No REVIVE:
|
|
-- nothing in the driver knows how to use one.
|
|
indigo = { { "FULL_RESTORE", 6 }, { "FULL_HEAL", 3 } },
|
|
}
|
|
local DEFAULT_STOCK = { { "POTION", 10 } }
|
|
|
|
-- Drive a QuantityBox to `want`.
|
|
--
|
|
-- It does NOT follow the list convention cursorTo implements. On a list,
|
|
-- up moves toward index 1; on a QuantityBox, up *increases* the count and
|
|
-- down decreases it, both wrapping around 1..max (QuantityBox.lua:29-33).
|
|
-- Handing it cursorTo therefore walked the wrong way and then oscillated
|
|
-- between 1 and max forever, so buyItem gave up and bought nothing -- the
|
|
-- exception being a target exactly one wrap below max, which is why the
|
|
-- occasional stop appeared to work and the failure looked intermittent.
|
|
local function qtyTo(want, tries)
|
|
for _ = 1, tries or 120 do
|
|
local t = top()
|
|
if not t or not t.qty then return false end
|
|
if t.qty == want then return true end
|
|
press(t.qty < want and "up" or "down")
|
|
U.wait(3)
|
|
end
|
|
local t = top()
|
|
return t ~= nil and t.qty == want
|
|
end
|
|
|
|
-- Items we can always part with when the 20-slot bag is full. Money or
|
|
-- dead weight only: the NUGGET exists to be sold, TM_BIDE / TM_TOXIC /
|
|
-- TM_MEGA_DRAIN are never taught by the route, and status heals are
|
|
-- covered by FULL_HEALs. Nothing here gates progress.
|
|
local SELLABLE_JUNK = { "NUGGET", "TM_BIDE", "TM_TOXIC", "TM_MEGA_DRAIN",
|
|
"ANTIDOTE", "BURN_HEAL", "ICE_HEAL", "AWAKENING" }
|
|
|
|
-- Sell the full stack of `id` from the shop's BUY/SELL/QUIT menu. Returns
|
|
-- with the menu back on top.
|
|
local function sellItem(id)
|
|
if not isMenu() then return false end
|
|
if not cursorTo("index", 2) then return false end -- SELL
|
|
press("a")
|
|
U.wait(10)
|
|
if not pressUntil(isList, "a", 30) then
|
|
press("b")
|
|
U.wait(6)
|
|
return false
|
|
end
|
|
local idx
|
|
for i, row in ipairs(rows()) do
|
|
if row.value == id then idx = i break end
|
|
end
|
|
local sold = false
|
|
if idx and cursorTo("index", idx) then
|
|
press("a")
|
|
U.wait(6)
|
|
if isQty() then
|
|
qtyTo(top().max or 1) -- the whole stack
|
|
press("a")
|
|
U.wait(6)
|
|
end
|
|
if isChoice() then
|
|
cursorTo("index", 1) -- YES
|
|
press("a")
|
|
U.wait(10)
|
|
end
|
|
pressUntil(isList, "a", 20)
|
|
sold = ((G.save.inventory or {})[id] or 0) == 0
|
|
end
|
|
-- back out of the sell list to the BUY/SELL menu
|
|
for _ = 1, 10 do
|
|
if isMenu() then break end
|
|
press("b")
|
|
U.wait(6)
|
|
end
|
|
return sold
|
|
end
|
|
|
|
-- Free up bag slots so `needed` NEW item kinds fit (20 slots without a
|
|
-- capacity mod; a stack of an item already held costs nothing). This is why
|
|
-- every restock had been reporting "HYPER_POTION x0": the buy list
|
|
-- opened, the quantity was set, the engine said "no room" -- and the run
|
|
-- walked into the Mansion with FULL_HEALs but not one HP restore.
|
|
local function freeBagSlots(needed, where)
|
|
local used = #(G.save.bagOrder or {})
|
|
local capacity = require("src.inventory.Bag").capacity(G.data)
|
|
local free = capacity - used
|
|
for _, id in ipairs(SELLABLE_JUNK) do
|
|
if free >= needed then break end
|
|
if ((G.save.inventory or {})[id] or 0) > 0 then
|
|
if sellItem(id) then
|
|
free = free + 1
|
|
say(("shop: sold the %s to free a bag slot (%d free)")
|
|
:format(id, free))
|
|
end
|
|
end
|
|
end
|
|
if free < needed then
|
|
-- same dead-weight rule as pickupTossJunk: TMs no teach step wants
|
|
-- are sellable too. The Indigo lobby restock hit this: the junk list
|
|
-- ran dry, FULL_RESTOREs could not land, and the E4 attrition war
|
|
-- was fought on FULL_HEALs alone. Snapshot first -- selling reorders
|
|
-- the bag under the iterator.
|
|
local order = {}
|
|
for _, id in ipairs(G.save.bagOrder or {}) do order[#order + 1] = id end
|
|
for _, id in ipairs(order) do
|
|
if free >= needed then break end
|
|
if tostring(id):find("^TM_") and not WD.keepItems[id] then
|
|
if sellItem(id) then
|
|
free = free + 1
|
|
say(("shop: sold the %s to free a bag slot (%d free)")
|
|
:format(id, free))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if free < needed then
|
|
say(("shop %s: bag still short %d slot(s) after selling junk")
|
|
:format(tostring(where), needed - free))
|
|
end
|
|
return free
|
|
end
|
|
|
|
-- Buy `qty` of `id` from an open buy list. Leaves the list open.
|
|
local function buyItem(id, qty, where)
|
|
if not isList() then return false end
|
|
local idx
|
|
for i, row in ipairs(rows()) do
|
|
if row.value == id then idx = i break end
|
|
end
|
|
if not idx then
|
|
note("shop: no " .. id, where)
|
|
say(("shop: %s is not on this buy list"):format(id))
|
|
return false
|
|
end
|
|
if not cursorTo("index", idx) then
|
|
say(("shop: could not reach the %s row"):format(id))
|
|
return false
|
|
end
|
|
press("a")
|
|
U.wait(6)
|
|
if not isQty() then
|
|
note("shop: no quantity box for " .. id, where)
|
|
say(("shop: no quantity box opened for %s"):format(id))
|
|
return false
|
|
end
|
|
-- QuantityBox caps at .max (what we can afford), so never ask for more
|
|
local want = math.min(qty, top().max or qty)
|
|
if not qtyTo(want) then
|
|
note("shop: could not set the quantity for " .. id, where)
|
|
say(("shop: stuck setting %s quantity to %d on %s"):format(id, want,
|
|
tostring(where)))
|
|
return false
|
|
end
|
|
press("a")
|
|
U.wait(6)
|
|
if isChoice() then
|
|
if not cursorTo("index", 1) then return false end -- YES
|
|
press("a")
|
|
U.wait(10)
|
|
end
|
|
-- back to the list (the purchase text may need clearing first)
|
|
pressUntil(isList, "a", 20)
|
|
-- Trust the BAG, not the menu flow: with all 20 slots used the engine
|
|
-- answers "You have no room for this!" through exactly the same
|
|
-- screens, and this function reported success while buying nothing --
|
|
-- which is how every restock came back "HYPER_POTION x0" with no
|
|
-- failure line and the Mansion was fought without a single HP item.
|
|
if ((G.save.inventory or {})[id] or 0) < 1 then
|
|
say(("shop: %s purchase did not reach the bag (no room?)"):format(id))
|
|
return false
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- Back all the way out of the shop with B.
|
|
--
|
|
-- Never mash A to close one: on the buy list A IS a purchase, so the
|
|
-- generic mashUntilIdle() turns any wrong turn in here into an endless
|
|
-- shopping spree that empties the wallet and then sits on the "not enough
|
|
-- money" footer forever. B is the only safe key once a shop screen is up.
|
|
local function closeShop()
|
|
for _ = 1, 40 do
|
|
if not (isList() or isQty() or isChoice() or isMenu()) then return true end
|
|
press("b")
|
|
U.wait(6)
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Walk to a cell beside (ox, oy) and turn to face it.
|
|
--
|
|
-- The route's own waypoint is wherever PokeBotBad happened to stand, which
|
|
-- is not always next to the thing it then talks to. Viridian Mart is the
|
|
-- case that matters: the shop step walks to (2,5), but VIRIDIANMART_CLERK
|
|
-- is at (0,5) -- two tiles west, so interact() probed four empty cells and
|
|
-- the shop never opened. That failed quietly into `note`, so every stop on
|
|
-- the route had been buying nothing at all: no POKE_BALLs for the Nidoran
|
|
-- catch, and none of Pewter's eight POTIONs either, which is a large part
|
|
-- of why the party kept arriving at Brock with nothing to heal with.
|
|
local function stepUpTo(ox, oy)
|
|
local m = ow().map
|
|
local startMap = m.id
|
|
-- Candidates in preference order: beside the object, then TWO cells
|
|
-- away across a counter (mart clerks stand behind one -- Map's
|
|
-- counterTiles are talked across, same as the nurse's desk).
|
|
--
|
|
-- Filtered by what we can ACTUALLY WALK TO, not just what is walkable.
|
|
-- Celadon Mart 4F is why: the clerk's own aisle (0-8,7) is sealed
|
|
-- behind the counter, so its cells are walkable-but-unreachable -- and
|
|
-- handing one to ops.goto_ fired its unreachable-recovery ladder
|
|
-- (arriveByWarp), which warped the bot clear off the floor hunting a
|
|
-- way in and left it "arrived" at the same coordinates on 1F, talking
|
|
-- to a wall. The shop then failed, no POKE_DOLL was bought, and the
|
|
-- ghost MAROWAK sealed the tower.
|
|
local reach, W = reachableSet()
|
|
for _, d in ipairs({ { 1, 0, "left" }, { -1, 0, "right" },
|
|
{ 0, 1, "up" }, { 0, -1, "down" },
|
|
{ 0, 2, "up" }, { 0, -2, "down" },
|
|
{ 2, 0, "left" }, { -2, 0, "right" } }) do
|
|
local cx, cy = ox + d[1], oy + d[2]
|
|
if m:inBounds(cx, cy) and m:isWalkableCell(cx, cy)
|
|
and not ow():npcAtCell(cx, cy)
|
|
and reach[cy * W + cx] then
|
|
local p = ow().player
|
|
if not (p.cellX == cx and p.cellY == cy) then
|
|
ops.goto_({ x = cx, y = cy })
|
|
p = ow().player
|
|
end
|
|
if ow().map.id ~= startMap then
|
|
say(("stepUpTo (%d,%d): a warp moved us to %s -- giving up")
|
|
:format(ox, oy, tostring(ow().map.id)))
|
|
return false
|
|
end
|
|
if p.cellX == cx and p.cellY == cy then
|
|
faceDir(d[3])
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
function ops.shop(s, where)
|
|
local stock = SHOP_STOCK[s.list] or DEFAULT_STOCK
|
|
if #stock == 0 then return true end
|
|
-- Stand next to the clerk first; the route's waypoint may not be
|
|
-- adjacent to them (see stepUpTo).
|
|
local clerk = findObject("CLERK") or findObject("CASHIER")
|
|
do
|
|
local p = ow().player
|
|
say(("shop %s: on %s at (%d,%d); clerk %s"):format(
|
|
tostring(s.list), tostring(ow().map.id), p.cellX, p.cellY,
|
|
clerk and ("@(%d,%d)"):format(clerk.x, clerk.y) or "NOT FOUND"))
|
|
end
|
|
if clerk then
|
|
local reached = stepUpTo(clerk.x, clerk.y)
|
|
local p = ow().player
|
|
say(("shop %s: stepUpTo %s; now on %s at (%d,%d) facing %s"):format(
|
|
tostring(s.list), reached and "ok" or "FAILED",
|
|
tostring(ow().map.id), p.cellX, p.cellY, tostring(p.facing)))
|
|
end
|
|
-- Shops failed silently into `note` for a long time, and a note only
|
|
-- surfaces in the end-of-run summary -- long after the empty bag has
|
|
-- already cost a catch or a gym. Name the stage that failed, live.
|
|
local function stageFailed(stage)
|
|
local t = top()
|
|
local kind = (isList() and "list") or (isMenu() and "menu")
|
|
or (isQty() and "qty") or (isChoice() and "choice")
|
|
or (t and "other") or "nothing"
|
|
say(("shop %s on %s: FAILED at %s (top=%s, rows=%d)"):format(
|
|
tostring(s.list), tostring(where), stage, kind,
|
|
rows() and #rows() or 0))
|
|
end
|
|
|
|
if not interact() then
|
|
note("shop: no clerk", where)
|
|
stageFailed("reaching the clerk")
|
|
return false
|
|
end
|
|
if not pressUntil(isMenu, "a", 60) then
|
|
note("shop: menu never opened", where)
|
|
stageFailed("BUY/SELL/QUIT menu")
|
|
closeShop()
|
|
mashUntilIdle()
|
|
return false
|
|
end
|
|
-- Make room BEFORE buying: count the list entries that would need a NEW
|
|
-- bag slot and sell junk until they fit (see freeBagSlots).
|
|
do
|
|
local newKinds = 0
|
|
for _, want in ipairs(stock) do
|
|
if ((G.save.inventory or {})[want[1]] or 0) == 0 then
|
|
newKinds = newKinds + 1
|
|
end
|
|
end
|
|
local used = #(G.save.bagOrder or {})
|
|
local capacity = require("src.inventory.Bag").capacity(G.data)
|
|
if newKinds > 0 and capacity - used < newKinds then
|
|
freeBagSlots(newKinds, where)
|
|
end
|
|
end
|
|
if not cursorTo("index", 1) then
|
|
stageFailed("cursor to BUY")
|
|
closeShop()
|
|
return false
|
|
end
|
|
press("a")
|
|
U.wait(10)
|
|
if not pressUntil(isList, "a", 30) then
|
|
note("shop: buy list never opened", where)
|
|
stageFailed("buy list")
|
|
closeShop()
|
|
mashUntilIdle()
|
|
return false
|
|
end
|
|
-- Stop buying once the reserve is reached.
|
|
--
|
|
-- Some later step needs money more than this shop does, and a shop with
|
|
-- no brake spends it all. Celadon Mart 2F is exactly that: it comes at
|
|
-- segment 92 and the roof's vending machines at 96, so stocking up on
|
|
-- SUPER_POTIONs left ¥68 and the vending machine could not sell a
|
|
-- ¥200 FRESH_WATER -- "3 vending purchases, 0 drink(s) in the bag". That
|
|
-- drink is not a comfort item: it opens all four Saffron gates, and with
|
|
-- Saffron shut the run cannot reach Lavender for the POKE_FLUTE, so the
|
|
-- potions bought instead cost the back half of the game.
|
|
for _, want in ipairs(stock) do
|
|
if (G.save.money or 0) <= (stock.reserve or 0) then
|
|
say(("shop %s on %s: stopping at ¥%d to keep the ¥%d reserve")
|
|
:format(tostring(s.list), tostring(where), G.save.money or 0,
|
|
stock.reserve or 0))
|
|
break
|
|
end
|
|
buyItem(want[1], want[2], where)
|
|
end
|
|
local closed = closeShop()
|
|
if not closed then note("shop: would not close", where) end
|
|
mashUntilIdle() -- safe now: every shop screen is off the stack
|
|
-- Report what we actually walked out with. A shop that silently buys
|
|
-- nothing is the failure mode this op has already had once, and it only
|
|
-- shows up much later as an empty bag at the point of need.
|
|
local got = {}
|
|
for _, want in ipairs(stock) do
|
|
got[#got + 1] = ("%s x%d"):format(want[1], (G.save.inventory or {})[want[1]] or 0)
|
|
end
|
|
say(("shop %s on %s: %s (¥%d left)"):format(
|
|
tostring(s.list), tostring(where), table.concat(got, ", "),
|
|
G.save.money or 0))
|
|
return closed
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- healing
|
|
-- ---------------------------------------------------------------------
|
|
-- The route's `heal` steps are bag heals, not Poké Center visits: only 3
|
|
-- of the 197 segments are centers, and PokeBotBad tops up from items as it
|
|
-- walks. A step can carry `status` (cure a status), `full` (spend a
|
|
-- FULL_RESTORE) or `hp = N` (only bother once N HP is missing).
|
|
--
|
|
-- We drive the real menus instead of calling ItemEffects.use directly.
|
|
-- Calling the API would heal just as well and be far less fiddly, but the
|
|
-- whole point of the run is to assert that warps, scripts, battles AND
|
|
-- menus work end to end -- a bag that stopped opening would go unnoticed.
|
|
|
|
-- weakest first, so a heal spends the cheapest thing that will do
|
|
local HP_ITEMS = { "POTION", "SUPER_POTION", "HYPER_POTION", "MAX_POTION",
|
|
"FULL_RESTORE" }
|
|
local STATUS_ITEMS = { "ANTIDOTE", "PARLYZ_HEAL", "AWAKENING", "BURN_HEAL",
|
|
"ICE_HEAL", "FULL_HEAL" }
|
|
|
|
local function party() return G.save.party or {} end
|
|
local function heldCount(id) return (G.save.inventory or {})[id] end
|
|
|
|
-- the living mon that has lost the most HP, and how much it is missing
|
|
local function mostHurt()
|
|
local slot, worst
|
|
for i, mon in ipairs(party()) do
|
|
local max = mon.stats and mon.stats.hp
|
|
if max and mon.hp and mon.hp > 0 then
|
|
local missing = max - mon.hp
|
|
if missing > 0 and (not worst or missing > worst) then slot, worst = i, missing end
|
|
end
|
|
end
|
|
return slot, worst or 0
|
|
end
|
|
|
|
local function firstStatused()
|
|
for i, mon in ipairs(party()) do
|
|
if mon.hp and mon.hp > 0 and mon.status and mon.status ~= "" then return i end
|
|
end
|
|
end
|
|
|
|
-- B out of whatever menus are open, back to the plain overworld
|
|
local function backOut()
|
|
for _ = 1, 25 do
|
|
if top() == ow() then return true end
|
|
press("b")
|
|
U.wait(5)
|
|
end
|
|
return top() == ow()
|
|
end
|
|
|
|
-- The USE/TOSS submenu is pushed with a raw stack:push, so unlike every
|
|
-- other screen here it carries no .screenId -- identify it by its rows.
|
|
local function isUseToss()
|
|
local r = rows()
|
|
return r ~= nil and r[1] ~= nil and r[1].label == "USE"
|
|
end
|
|
|
|
-- START -> ITEM -> <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
|
|
|
|
-- TMs and HMs interpose "Booted up a TM!" / "It contained X!" text
|
|
-- between USE and the target picker (BagMenu.lua:341-350, ItemUseTMHM);
|
|
-- healing items go straight to the party menu. Advance that text with A
|
|
-- until the picker appears. Bailing out here was the REAL teach killer:
|
|
-- top() was the boot-up TextBox, this reported "party menu never
|
|
-- opened", and backOut()'s B presses cancelled the teach entirely --
|
|
-- which is how the run reached Route 9's tree with nobody knowing CUT.
|
|
local pm
|
|
for _ = 1, 20 do
|
|
pm = top()
|
|
if pm and pm.screenId == "PartyMenu" then break end
|
|
press("a")
|
|
U.wait(6)
|
|
end
|
|
pm = top()
|
|
if not (pm and pm.screenId == "PartyMenu") then
|
|
note("heal: party menu never opened", where)
|
|
backOut()
|
|
return false
|
|
end
|
|
if not cursorTo("index", slot) then backOut() return false end
|
|
press("a")
|
|
U.wait(10)
|
|
-- A TM/HM on a full moveset just pushed MoveLearnMenu (BagMenu.lua:158).
|
|
-- Hands OFF: the close-out below mashes A then B, and on a YES/NO box B
|
|
-- always answers NO (ChoiceBox.lua:36-41) -- so the mash ping-ponged
|
|
-- "Delete an older move?" / "Abandon learning?" and declined the very
|
|
-- move the item was spent on. That is how CUT was never learned and
|
|
-- Route 9's tree walled off the run at segment 76. ops.teach's
|
|
-- learn-menu driver knows the right answers; leave the stack exactly as
|
|
-- it stands for it.
|
|
for _, st in ipairs(G.stack.states or {}) do
|
|
if st.newMoveId and st.mon and st.index then return true end
|
|
end
|
|
-- clear the "HP was restored!" box, then unwind the bag + start menu
|
|
pressUntil(function() return not (top() and top().screenId == "PartyMenu") end, "a", 15)
|
|
backOut()
|
|
return true
|
|
end
|
|
|
|
-- Toss a junk stack where no shop exists. freeBagSlots sells, which needs
|
|
-- a clerk; a FULL bag at an item ball ("no more room") happens
|
|
-- mid-dungeon -- Silph 5F's CARD_KEY was refused exactly this way after
|
|
-- the Saffron restock packed the bag to 20 slots. Same START->ITEM walk
|
|
-- as useItemOn, but taking the TOSS row. Global, not local: ops.pickup is
|
|
-- defined earlier in the file and resolves this at call time.
|
|
function pickupTossJunk(where)
|
|
local victim, count
|
|
for _, id in ipairs(SELLABLE_JUNK) do
|
|
local n = (G.save.inventory or {})[id] or 0
|
|
if n > 0 then victim, count = id, n break end
|
|
end
|
|
if not victim then
|
|
-- The static junk list ran dry: the pickup retries now collect the
|
|
-- bonus balls older runs walked past, so the bag fills with TMs the
|
|
-- route has no teach step for. Those are dead weight -- toss the
|
|
-- first one that no teach will ever want (WD.keepItems; HMs are not
|
|
-- TM_-prefixed and stay untouched).
|
|
for _, id in ipairs(G.save.bagOrder or {}) do
|
|
if tostring(id):find("^TM_") and not WD.keepItems[id] then
|
|
victim, count = id, (G.save.inventory or {})[id] or 1
|
|
break
|
|
end
|
|
end
|
|
end
|
|
if not victim then
|
|
say("pickup: bag is full and no junk left to toss")
|
|
return false
|
|
end
|
|
press("start")
|
|
U.wait(8)
|
|
local menu = top()
|
|
if not (menu and menu.screenId == "StartMenu") then backOut() return false end
|
|
local itemRow
|
|
for i, it in ipairs(menu.items or {}) do
|
|
if it.label == "ITEM" then itemRow = i break end
|
|
end
|
|
if not itemRow or not cursorTo("index", itemRow) then backOut() return false end
|
|
press("a")
|
|
U.wait(10)
|
|
local bag = top()
|
|
if not (bag and bag.screenId == "BagMenu") then backOut() return false end
|
|
local bagRow
|
|
for i, r in ipairs(bag.items or {}) do
|
|
if r.value == victim then bagRow = i break end
|
|
end
|
|
if not bagRow or not cursorTo("index", bagRow) then backOut() return false end
|
|
press("a")
|
|
U.wait(8)
|
|
if not isUseToss() or not cursorTo("index", 2) then backOut() return false end
|
|
press("a")
|
|
U.wait(8)
|
|
if isQty() then
|
|
qtyTo(count) -- the whole stack, or the slot stays occupied
|
|
press("a")
|
|
U.wait(8)
|
|
end
|
|
-- "Is it OK to toss X?" -- YES is the first row; then the fanfare text.
|
|
-- NEVER mashUntilIdle here: with the bag still open, its A presses
|
|
-- select whatever row the cursor sits on -- one run USED an ESCAPE_ROPE
|
|
-- that way and teleported the bot out of Silph mid-pickup. Advance only
|
|
-- while a text box is up, then B out.
|
|
pressUntil(function() return not isChoice() end, "a", 10)
|
|
for _ = 1, 10 do
|
|
local t = top()
|
|
if not (t and t.pages) then break end
|
|
press("a")
|
|
U.wait(6)
|
|
end
|
|
backOut()
|
|
local left = (G.save.inventory or {})[victim] or 0
|
|
say(("pickup: tossed %s x%d to free a bag slot (%s)")
|
|
:format(victim, count, left == 0 and "freed" or "slot NOT freed"))
|
|
return left == 0
|
|
end
|
|
|
|
-- Drink a potion DURING a battle.
|
|
--
|
|
-- fightBattle only ever had one answer to a hurt lead: flee. That works on
|
|
-- a wild encounter and is impossible against a trainer, so a gauntlet map
|
|
-- simply ground the lead down and killed it -- ROUTE_9 is one long chain of
|
|
-- trainers and it wiped the run fifteen times in a single attempt while
|
|
-- eight SUPER_POTIONs sat unused in the bag. The lead was level 33 against
|
|
-- level-20 trainers; it was not outmatched, it was just never healed.
|
|
--
|
|
-- Losing the turn is the right trade here. A faint costs the whole battle,
|
|
-- and behind our lead sits a level-4 NIDORAN -- when the lead goes down the
|
|
-- run is over, so the turn is cheap by comparison.
|
|
--
|
|
-- The bag in battle is the ITEM slot of the action grid (index 3), the same
|
|
-- door throwBall uses; the potion then asks for a target through a
|
|
-- pickOnly PartyMenu (BagMenu:useItem -> Screens.push "PartyMenu").
|
|
function healInBattle(battle, where)
|
|
local mon = battle.player and battle.player.mon
|
|
if not mon or not mon.stats then return false end
|
|
local gap = (mon.stats.hp or 0) - (mon.hp or 0)
|
|
if gap <= 0 then return false end
|
|
-- smallest item that covers the gap, so a MAX_POTION is not spent on a
|
|
-- scratch (same order ops.heal uses)
|
|
local pick
|
|
for _, id in ipairs(HP_ITEMS) do
|
|
if (heldCount(id) or 0) > 0 then
|
|
local amount = (id == "POTION" and 20) or (id == "SUPER_POTION" and 50)
|
|
or (id == "HYPER_POTION" and 200) or 999
|
|
if not pick then pick = id end
|
|
if amount >= gap then pick = id break end
|
|
end
|
|
end
|
|
if not pick then return false end
|
|
local beforeHP = mon.hp or 0
|
|
local beforeCount = heldCount(pick) or 0
|
|
if not battleMenuTo(battle, 3) then return false end
|
|
press("a")
|
|
if not waitFor(isList, 40) then backOut() return false end
|
|
local idx
|
|
for i, row in ipairs(rows() or {}) do
|
|
if row.value == pick then idx = i break end
|
|
end
|
|
if not idx or not cursorTo("index", idx) then backOut() return false end
|
|
press("a")
|
|
U.wait(10)
|
|
-- the party picker: heal the mon that is actually out, which is slot 1's
|
|
-- position in the list only when nothing has been switched, so find it
|
|
local pm = top()
|
|
if pm and pm.onSwitch ~= nil and pm.index ~= nil then
|
|
local slot = 1
|
|
for i, m in ipairs(party()) do
|
|
if m == mon then slot = i break end
|
|
end
|
|
if not cursorTo("index", slot) then backOut() return false end
|
|
press("a")
|
|
U.wait(12)
|
|
end
|
|
-- Sample the result BEFORE handing the turn back.
|
|
--
|
|
-- The first version logged HP after mashUntilIdle, which runs the rest of
|
|
-- the turn including the enemy's attack -- so every heal printed "(0/79)"
|
|
-- and there was no way to tell a potion that never applied from one that
|
|
-- worked before we were killed anyway. Watch for the HP to actually rise,
|
|
-- and check the bag, so the log says which it was.
|
|
local rose = waitFor(function() return (mon.hp or 0) > beforeHP end, 30)
|
|
local healedTo = mon.hp or 0
|
|
local spent = (heldCount(pick) or 0) < beforeCount
|
|
-- Drain the heal's text back to a battle prompt WITHOUT mashUntilIdle:
|
|
-- idle() means the OVERWORLD is idle, which is never true mid-battle, so
|
|
-- mashUntilIdle burned its whole 400-frame budget on every heal. Against
|
|
-- Agatha that starved the fight of turns until it hit the 20000-frame
|
|
-- timeout and got force-closed -- sealing her door and the Hall of Fame
|
|
-- with it. Advance only until the battle takes input again (or ends).
|
|
for _ = 1, 150 do
|
|
if not (inBattle() or replacementMenu()) then break end
|
|
local b = G.stack:top()
|
|
if b and (b.phase == "menu" or b.phase == "moveSelect") then break end
|
|
if not (driveLearnMenu and driveLearnMenu()) then press("a") end
|
|
U.wait(4)
|
|
end
|
|
if rose then
|
|
say(("healed %s with %s mid-battle: %d -> %d/%d"):format(
|
|
tostring(mon.species), pick, beforeHP, healedTo, mon.stats.hp or 0))
|
|
else
|
|
say(("mid-battle %s did NOT heal %s (%d/%d, item %s) -- now %d/%d"):format(
|
|
pick, tostring(mon.species), beforeHP, mon.stats.hp or 0,
|
|
spent and "was consumed" or "still in the bag",
|
|
mon.hp or 0, mon.stats.hp or 0))
|
|
end
|
|
return rose
|
|
end
|
|
|
|
-- Battle menu -> ITEM -> POKE_DOLL. Used by fightBattle against the
|
|
-- static ghost MAROWAK; ItemEffects ends the battle as an escape and
|
|
-- BagMenu marks battle.pokeDollEscape for the 6F script's win check.
|
|
function throwPokeDoll(battle)
|
|
if not battleMenuTo(battle, 3) then return false end
|
|
press("a")
|
|
if not waitFor(isList, 40) then backOut() return false end
|
|
local idx
|
|
for i, row in ipairs(rows() or {}) do
|
|
if row.value == "POKE_DOLL" then idx = i break end
|
|
end
|
|
if not idx or not cursorTo("index", idx) then backOut() return false end
|
|
press("a")
|
|
U.wait(12)
|
|
say("threw the POKE DOLL")
|
|
mashUntilIdle(400)
|
|
return true
|
|
end
|
|
|
|
function ops.heal(s, where)
|
|
-- status cures first: a slept/paralysed lead loses more turns than a
|
|
-- dented one, and the route only asks for them when it matters
|
|
if s.status then
|
|
local slot = firstStatused()
|
|
if not slot then return true end -- nothing to cure; not a failure
|
|
for _, id in ipairs(STATUS_ITEMS) do
|
|
if heldCount(id) then return useItemOn(id, slot, where) end
|
|
end
|
|
note("heal: no status item", where)
|
|
return true
|
|
end
|
|
|
|
local slot, missing = mostHurt()
|
|
if not slot then return true end -- party is topped up already
|
|
-- `hp = N` is the route's "only bother once N HP is down" threshold
|
|
if s.hp and missing < s.hp then return true end
|
|
|
|
local order = HP_ITEMS
|
|
if s.full then order = { "FULL_RESTORE", "MAX_POTION", "HYPER_POTION" } end
|
|
-- prefer the smallest item that covers the gap, so early POTIONs are not
|
|
-- wasted on 2 HP and a FULL_RESTORE is not wasted on a scratch
|
|
local pick
|
|
for _, id in ipairs(order) do
|
|
if heldCount(id) then
|
|
pick = pick or id -- fallback: the weakest thing we actually own
|
|
local amount = (id == "POTION" and 20) or (id == "SUPER_POTION" and 50)
|
|
or (id == "HYPER_POTION" and 200) or 999
|
|
if amount >= missing then pick = id break end
|
|
end
|
|
end
|
|
if not pick then
|
|
note("heal: no HP item", where)
|
|
return true
|
|
end
|
|
return useItemOn(pick, slot, where)
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Poké Centers
|
|
-- ---------------------------------------------------------------------
|
|
-- Viridian Mart sells POKE_BALL/ANTIDOTE/PARLYZ_HEAL/BURN_HEAL and no
|
|
-- POTION (vanilla), and the first POTION on sale is in Pewter -- on the
|
|
-- far side of Viridian Forest. For the whole first act the nurse is the
|
|
-- only way to restore HP, so healing cannot be just another route verb:
|
|
-- it has to be able to leave the route, walk to a counter, and come back
|
|
-- to the tile it left from.
|
|
|
|
-- Walk onto a warp tile, stopping the instant the map changes.
|
|
--
|
|
-- ops.goto_ must NOT be used for this: it re-reads ow() every iteration,
|
|
-- so once the warp fires it carries on pathfinding toward the *old* map's
|
|
-- coordinates on the new map -- which for a door at (23,25) landing in a
|
|
-- 12x8 Poké Center is off the edge, and the out-of-bounds branch then
|
|
-- spends 200 frames trying to leave by the nearest border.
|
|
local function walkOntoWarp(wx, wy)
|
|
local from = ow().map.id
|
|
local stuckOnWarp = 0
|
|
local bfsNil = 0
|
|
local cuts = 0
|
|
-- Shove learning, same as ops.goto_: a coordinate-trigger script that
|
|
-- walks the player back (Cinnabar's locked gym door at (18,4)) makes a
|
|
-- deterministic BFS re-propose the same shoved step for the whole loop
|
|
-- ("warp@6,3 did not move" x300, seen live from (18,5)). Refuse the
|
|
-- cell after two reverts and plan around; per-call, since the trigger
|
|
-- stops firing once its condition is met.
|
|
local refused, reverts, lastFrom, lastTarget = {}, {}, nil, nil
|
|
-- one mansion-switch toggle per call (see toggleMansionSwitch)
|
|
local toggledSwitch = false
|
|
-- one Victory Road switch solve per call (see solveVictoryRoadSwitches)
|
|
local solvedVR = false
|
|
-- one blocking-item-ball pickup per call (see pickupBlockingBall)
|
|
local tookBall = false
|
|
for _ = 1, 300 do
|
|
if ow().map.id ~= from then return true end
|
|
if inBattle() then fightBattle()
|
|
elseif busy() then mashUntilIdle()
|
|
else
|
|
local p = ow().player
|
|
if p.cellX == wx and p.cellY == wy then
|
|
-- Standing on the mat is not enough for a building exit: it fires
|
|
-- on the step that LEAVES the doorway, so push toward the nearest
|
|
-- edge (down for a door on the bottom row) instead of waiting.
|
|
--
|
|
-- Without this we sit on the mat until the loop expires and the
|
|
-- rest of the route then runs indoors, where the city's waypoints
|
|
-- are out of bounds on a 12x8 Poké Center and read as the route's
|
|
-- "leave by this edge" idiom -- which is Red shoving downward into
|
|
-- the wall below the door instead of walking out of it.
|
|
local m = ow().map
|
|
-- Which press can actually FIRE this warp? Mirror the engine:
|
|
-- Warp.extraCheck says whether pressing `dir` from this cell can
|
|
-- warp -- facing the map edge, OR a warp-carpet door tile in
|
|
-- front. Route 16's gate mats are the latter: plain path tiles
|
|
-- whose warp fires only pressing INTO the building ($4B door to
|
|
-- the LEFT), and the old edge-based guess pushed "down" forever
|
|
-- ("the step onto (24,10) keeps getting shoved back").
|
|
local WarpMod = require("src.world.Warp")
|
|
local carpets = G.data.field and G.data.field.warpCarpets
|
|
local outward
|
|
for _, d in ipairs(DIRS) do
|
|
if WarpMod.extraCheck(m, carpets, wx, wy, d[3]) then
|
|
outward = d[3]
|
|
break
|
|
end
|
|
end
|
|
outward = outward
|
|
or (wy >= m.heightCells - 1 and "down")
|
|
or (wy <= 0 and "up")
|
|
or (wx <= 0 and "left")
|
|
or (wx >= m.widthCells - 1 and "right")
|
|
or "down"
|
|
faceDir(outward)
|
|
walk(outward)
|
|
-- If pushing outward does nothing, the warp we are standing on is
|
|
-- INERT -- we arrived onto it, and the engine keeps a warp inert
|
|
-- until you step off (OverworldController warpEntryCell). That is
|
|
-- correct for not bouncing, but here we WANT to leave the same way,
|
|
-- which is exactly the Route 16 gate: the plan routes back out the
|
|
-- door we came in, and walkOntoWarp shoved a dead warp forever
|
|
-- ("warp@0,8 did not move" x90). Step off to an adjacent cell and
|
|
-- back on to re-arm it, then the next loop takes it normally.
|
|
if ow().map.id == from and (p.cellX == wx and p.cellY == wy) then
|
|
stuckOnWarp = (stuckOnWarp or 0) + 1
|
|
if stuckOnWarp >= 3 then
|
|
for _, d in ipairs(DIRS) do
|
|
local ax, ay = wx + d[1], wy + d[2]
|
|
if m:inBounds(ax, ay) and m:isWalkableCell(ax, ay)
|
|
and not m:warpAtCell(ax, ay) then
|
|
walk(d[3]) -- step OFF the warp (re-arms it)
|
|
if ow().player.cellX == ax and ow().player.cellY == ay then
|
|
stuckOnWarp = 0
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
else
|
|
stuckOnWarp = 0
|
|
end
|
|
else
|
|
local m = ow().map
|
|
local cell = p.cellY * m.widthCells + p.cellX
|
|
if lastTarget and cell == lastFrom then
|
|
reverts[lastTarget] = (reverts[lastTarget] or 0) + 1
|
|
if reverts[lastTarget] >= 2 and not refused[lastTarget] then
|
|
say(("walkOntoWarp (%d,%d): the step onto (%d,%d) keeps getting "
|
|
.. "shoved back -- planning around it")
|
|
:format(wx, wy, lastTarget % m.widthCells,
|
|
math.floor(lastTarget / m.widthCells)))
|
|
refused[lastTarget] = true
|
|
end
|
|
end
|
|
local key = bfsNextKey(wx, wy, refused)
|
|
if not key then
|
|
-- The warp may be walled off by a Cut tree -- the Vermilion Gym
|
|
-- door at (12,19) sits behind one -- so walkOntoWarp could never
|
|
-- reach it and travelTo(VERMILION_GYM) spun "did not move"
|
|
-- forever. Cut a blocking tree and retry; only give up if there
|
|
-- is none (a genuinely unreachable warp).
|
|
--
|
|
-- And do NOT give up on the first nil. bfsNextKey walls off
|
|
-- every NPC cell -- including the TARGET -- and NPCs wander: a
|
|
-- villager standing on (or stepping toward) the door mat makes
|
|
-- it "unreachable" for exactly as long as their pause lasts.
|
|
-- Giving up instantly is how every Poké Center anchor at
|
|
-- Lavender failed ("could not walk onto the door at (3,5)")
|
|
-- while the heal point silently stayed a town behind, and tower
|
|
-- deaths then replayed the whole Celadon->Lavender walk. Wait
|
|
-- them out; only a path that stays missing is real.
|
|
if cuts < 3 and cutToward and cutToward(wx, wy) then
|
|
cuts = cuts + 1
|
|
bfsNil = 0 -- way opened; loop re-paths
|
|
-- ...or by a Silph Co card-key door: the stairs the map planner
|
|
-- routes through sit behind locked doors on most Silph floors,
|
|
-- so travelTo(SILPH_CO_11F) could never re-climb the building
|
|
-- after a death broke the route's own segment sequence.
|
|
elseif openDoorToward and openDoorToward(wx, wy) then
|
|
bfsNil = 0
|
|
-- ...or by a Pokémon Mansion gate in the wrong switch state
|
|
-- (segment 162 stranded the bot at B1F (4,11) exactly here).
|
|
elseif not toggledSwitch and toggleMansionSwitch
|
|
and toggleMansionSwitch(wx, wy) then
|
|
toggledSwitch = true
|
|
bfsNil = 0
|
|
-- ...or a Victory Road barrier whose boulder switch is not
|
|
-- pressed. travelTo aims at the 1F ladder warp@1,1 straight
|
|
-- through the closed barrier block; solve the floor's switches
|
|
-- and re-path. Once per call.
|
|
elseif not solvedVR and solveVictoryRoadSwitches
|
|
and VR_SWITCHES and VR_SWITCHES[ow().map.id] then
|
|
solvedVR = true
|
|
solveVictoryRoadSwitches()
|
|
bfsNil = 0
|
|
-- ...or an item ball on the only corridor (Pokémon Tower 6F's
|
|
-- RARE_CANDY): take it and re-path.
|
|
elseif not tookBall and pickupBlockingBall
|
|
and pickupBlockingBall(wx, wy) then
|
|
tookBall = true
|
|
bfsNil = 0
|
|
else
|
|
bfsNil = (bfsNil or 0) + 1
|
|
if bfsNil >= 8 then
|
|
-- One actionable blocker before giving up: a sleeping
|
|
-- SNORLAX with the POKE FLUTE aboard. The route's own
|
|
-- flute segment can get skipped in a desync, after which
|
|
-- every trip past Route 16's junction dead-ends here
|
|
-- ("no path ... SPRITE_SNORLAX@(26,10)") forever.
|
|
if wakeBlockingSnorlax and wakeBlockingSnorlax("walkOntoWarp") then
|
|
bfsNil = 0
|
|
else
|
|
local p2 = ow().player
|
|
local who = {}
|
|
for _, npc in ipairs(ow().npcs) do
|
|
who[#who + 1] = ("%s@(%d,%d)"):format(
|
|
tostring(npc.def and (npc.def.id or npc.def.sprite) or "?"),
|
|
npc.cellX, npc.cellY)
|
|
end
|
|
say(("walkOntoWarp (%d,%d) on %s: no path from (%d,%d); npcs: %s")
|
|
:format(wx, wy, tostring(ow().map.id), p2.cellX, p2.cellY,
|
|
#who > 0 and table.concat(who, " ") or "none"))
|
|
return false
|
|
end
|
|
end
|
|
U.wait(20) -- let wanderers step aside / transitions settle
|
|
end
|
|
elseif walk(key) then
|
|
local dv = DELTA[key]
|
|
if dv then
|
|
local W = m.widthCells
|
|
lastFrom = cell
|
|
lastTarget = (math.floor(cell / W) + dv[2]) * W + (cell % W + dv[1])
|
|
end
|
|
else
|
|
U.wait(4)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return ow().map.id ~= from
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- cross-map travel
|
|
-- ---------------------------------------------------------------------
|
|
--
|
|
-- The route travels by HEAL POINT: segment 72 leaves the Pokémon Fan Club
|
|
-- with `fieldMove dig`, and DIG warps to wLastBlackoutMap. A speedrun never
|
|
-- heals at Vermilion, so the route can assume that lands in Cerulean; our
|
|
-- survival systems visit any nurse the party is hurt in front of, so it
|
|
-- does not. Those two facts cannot both stand, and suppressing the nurse
|
|
-- is the wrong half to give up -- so travel to a named map instead of
|
|
-- trusting where a warp happened to drop us.
|
|
--
|
|
-- Plans over map ADJACENCY -- connections and warps together -- because
|
|
-- Vermilion -> Cerulean is ROUTE_6 -> UNDERGROUND_PATH_ROUTE_6 ->
|
|
-- UNDERGROUND_PATH_NORTH_SOUTH -> UNDERGROUND_PATH_ROUTE_5 -> ROUTE_5 ->
|
|
-- CERULEAN_CITY, whose middle is warps and whose ends are connections. A
|
|
-- connection-only search cannot find it at all.
|
|
|
|
local COMPASS_DIR = { north = "up", south = "down", west = "left", east = "right" }
|
|
|
|
-- Walkability of an arbitrary map's cell, without loading it.
|
|
--
|
|
-- Mirrors Map:cellTile + Map:isWalkableCell (src/world/Map.lua) -- the
|
|
-- bottom-left 8x8 tile of the cell, against the tileset's walkable list.
|
|
-- We need this for maps we are NOT standing on, and MapLoader builds only
|
|
-- the current one, so the lookup is reproduced here rather than shared.
|
|
local function defWalkable(def, cx, cy)
|
|
local ts = G.data.tilesets and G.data.tilesets[def.tileset]
|
|
if not ts or not ts.blocks or not ts.walkable then return false end
|
|
local tx, ty = cx * 2, cy * 2 + 1
|
|
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
|
|
local id
|
|
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
|
|
id = def.borderBlock
|
|
else
|
|
id = def.blocks[by * def.width + bx + 1]
|
|
end
|
|
local block = ts.blocks[(id or 0) + 1]
|
|
if not block then return false end
|
|
local tile = block[(ty % 4) * 4 + (tx % 4) + 1]
|
|
for _, w in ipairs(ts.walkable) do
|
|
if w == tile then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- The border cells of `def` on `dir` whose opposite number across the seam
|
|
-- is walkable too, nearest first once the caller sorts them.
|
|
--
|
|
-- crossConnection places you at destX = curX - offset*2 (clamped), so the
|
|
-- pairing is exact and a seam with no walkable pair can never be crossed
|
|
-- however hard we push at it. This is what keeps the search from planning
|
|
-- Vermilion -> Saffron -> Cerulean through a walled city.
|
|
-- GLOBAL (not local): ops.goto_'s edge-exit retarget below needs it and
|
|
-- is defined earlier in the file; the main chunk is also at LuaJIT's
|
|
-- 200-local ceiling.
|
|
function seamCells(def, dir, conn)
|
|
local dest = G.data.maps and G.data.maps[conn.map]
|
|
if not dest then return {} end
|
|
local aW, aH = def.width * 2, def.height * 2
|
|
local bW, bH = dest.width * 2, dest.height * 2
|
|
local out = {}
|
|
if dir == "north" or dir == "south" then
|
|
local ay = (dir == "north") and 0 or aH - 1
|
|
local by = (dir == "north") and bH - 1 or 0
|
|
for x = 0, aW - 1 do
|
|
local dx = x - conn.offset * 2
|
|
if dx >= 0 and dx < bW
|
|
and defWalkable(def, x, ay) and defWalkable(dest, dx, by) then
|
|
out[#out + 1] = { x, ay }
|
|
end
|
|
end
|
|
else
|
|
local ax = (dir == "west") and 0 or aW - 1
|
|
local bx = (dir == "west") and bW - 1 or 0
|
|
for y = 0, aH - 1 do
|
|
local dy = y - conn.offset * 2
|
|
if dy >= 0 and dy < bH
|
|
and defWalkable(def, ax, y) and defWalkable(dest, bx, dy) then
|
|
out[#out + 1] = { ax, y }
|
|
end
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- The collision tile of an arbitrary map's cell (for ledge matching),
|
|
-- mirroring Map:cellTile without loading the map.
|
|
local function defCellTile(def, cx, cy)
|
|
local ts = G.data.tilesets and G.data.tilesets[def.tileset]
|
|
if not ts or not ts.blocks then return nil end
|
|
local tx, ty = cx * 2, cy * 2 + 1
|
|
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
|
|
local id
|
|
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
|
|
id = def.borderBlock
|
|
else
|
|
id = def.blocks[by * def.width + bx + 1]
|
|
end
|
|
local block = ts.blocks[(id or 0) + 1]
|
|
return block and block[(ty % 4) * 4 + (tx % 4) + 1]
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- region-aware map graph
|
|
-- ---------------------------------------------------------------------
|
|
--
|
|
-- A plain map graph treats each map as ONE node, which is wrong for maps a
|
|
-- gate building or a ledge splits into parts no single walk connects.
|
|
-- ROUTE_16 is three such regions, SAFFRON's four routes each cross through
|
|
-- a gate, ROUTE_8/11/12 are each cut in two. Planning over map nodes there
|
|
-- makes travelTo try a connection it cannot reach from its current side,
|
|
-- fail, and re-plan the long way -- which walked a Saffron trip back to
|
|
-- ROUTE_9, half the map away.
|
|
--
|
|
-- So nodes are (map, REGION): a connected component of the map's walkable
|
|
-- cells. Warps, connections and ledges connect specific regions, so a plan
|
|
-- can only cross where the current side actually reaches.
|
|
|
|
-- mapId -> { reg = {cellId -> regionIndex}, wc, n }. Flood-filled once.
|
|
local REGION_CACHE = {}
|
|
local function mapRegions(mapId)
|
|
if REGION_CACHE[mapId] then return REGION_CACHE[mapId] end
|
|
local def = G.data.maps[mapId]
|
|
local wc, hc = def.width * 2, def.height * 2
|
|
local reg, n = {}, 0
|
|
for y = 0, hc - 1 do
|
|
for x = 0, wc - 1 do
|
|
local id = y * wc + x
|
|
if defWalkable(def, x, y) and not reg[id] then
|
|
n = n + 1
|
|
reg[id] = n
|
|
local stack = { { x, y } }
|
|
while #stack > 0 do
|
|
local c = stack[#stack]; stack[#stack] = nil
|
|
for _, d in ipairs(DIRS) do
|
|
local nx, ny = c[1] + d[1], c[2] + d[2]
|
|
local nid = ny * wc + nx
|
|
if nx >= 0 and ny >= 0 and nx < wc and ny < hc
|
|
and defWalkable(def, nx, ny) and not reg[nid] then
|
|
reg[nid] = n
|
|
stack[#stack + 1] = { nx, ny }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
local r = { reg = reg, wc = wc, n = n }
|
|
REGION_CACHE[mapId] = r
|
|
return r
|
|
end
|
|
|
|
local function cellRegionOf(mapId, x, y)
|
|
local r = mapRegions(mapId)
|
|
return r.reg[y * r.wc + x]
|
|
end
|
|
|
|
local function regionNode(mapId, reg) return mapId .. "#" .. tostring(reg) end
|
|
|
|
-- The region graph: node -> list of edges, each carrying how to EXECUTE the
|
|
-- crossing (a warp cell, a connection dir+seam, or a ledge hop).
|
|
local REGION_GRAPH
|
|
local function regionGraph()
|
|
if REGION_GRAPH then return REGION_GRAPH end
|
|
local maps = G.data.maps
|
|
local g, into = {}, {}
|
|
local function add(a, b, e) e.to = b; g[a] = g[a] or {}; g[a][#g[a] + 1] = e end
|
|
-- who warps into each map, for LAST_MAP resolution
|
|
for id, def in pairs(maps) do
|
|
for _, w in ipairs(def.warps or {}) do
|
|
if type(w.destMap) == "string" and w.destMap ~= "LAST_MAP" and maps[w.destMap] then
|
|
into[w.destMap] = into[w.destMap] or {}
|
|
into[w.destMap][id] = true
|
|
end
|
|
end
|
|
end
|
|
for id, def in pairs(maps) do
|
|
-- warps (concrete destination)
|
|
for _, w in ipairs(def.warps or {}) do
|
|
local d = w.destMap
|
|
local sr = cellRegionOf(id, w.x, w.y)
|
|
if d == "LAST_MAP" then
|
|
-- resolve against each map that feeds this one, at destWarp's cell
|
|
for src in pairs(into[id] or {}) do
|
|
local dw = maps[src].warps and maps[src].warps[w.destWarp]
|
|
local dr = dw and cellRegionOf(src, dw.x, dw.y)
|
|
if sr and dr then
|
|
local c = (id:find("^SEAFOAM") or src:find("^SEAFOAM")) and 43 or 3
|
|
add(regionNode(id, sr), regionNode(src, dr),
|
|
{ warp = { x = w.x, y = w.y }, cost = c, dynamic = true })
|
|
end
|
|
end
|
|
elseif type(d) == "string" and maps[d] then
|
|
local dw = maps[d].warps and maps[d].warps[w.destWarp]
|
|
local dr = dw and cellRegionOf(d, dw.x, dw.y)
|
|
if sr and dr and not (d:find("^SEAFOAM") and not id:find("^SEAFOAM")) then
|
|
-- entering Seafoam pruned, same reason as the map graph
|
|
local c = (id:find("ELEVATOR") or d:find("ELEVATOR")) and 50 or 1
|
|
add(regionNode(id, sr), regionNode(d, dr),
|
|
{ warp = { x = w.x, y = w.y }, cost = c })
|
|
end
|
|
end
|
|
end
|
|
-- connections (seam pairs, grouped by region pair)
|
|
for dir, conn in pairs(def.connections or {}) do
|
|
if maps[conn.map] and COMPASS_DIR[dir] then
|
|
local cells = seamCells(def, dir, conn)
|
|
local dest = maps[conn.map]
|
|
local seen = {}
|
|
for _, c in ipairs(cells) do
|
|
local sr = cellRegionOf(id, c[1], c[2])
|
|
-- the paired cell on the far side
|
|
local bx, by
|
|
if dir == "north" then bx, by = c[1] - conn.offset * 2, dest.height * 2 - 1
|
|
elseif dir == "south" then bx, by = c[1] - conn.offset * 2, 0
|
|
elseif dir == "west" then bx, by = dest.width * 2 - 1, c[2] - conn.offset * 2
|
|
else bx, by = 0, c[2] - conn.offset * 2 end
|
|
local dr = cellRegionOf(conn.map, bx, by)
|
|
local k = tostring(sr) .. ">" .. tostring(dr)
|
|
if sr and dr and not seen[k] then
|
|
seen[k] = true
|
|
-- ROUTE_20 <-> CINNABAR: pruned, same reason as the map
|
|
-- graph (the east and west seas never connect)
|
|
if not ((id == "ROUTE_20" and conn.map == "CINNABAR_ISLAND")
|
|
or (id == "CINNABAR_ISLAND" and conn.map == "ROUTE_20")) then
|
|
add(regionNode(id, sr), regionNode(conn.map, dr),
|
|
{ dir = COMPASS_DIR[dir], cells = cells, cost = 1 })
|
|
end
|
|
end
|
|
end
|
|
-- A seam with NO walkable pair is a pure-water border. The map
|
|
-- graph keeps those as expensive-but-real edges (see its
|
|
-- comment); this builder silently dropped them, which made
|
|
-- CINNABAR_ISLAND a structural island up here -- every
|
|
-- travelTo(CINNABAR_*) then fell to the region-blind flat
|
|
-- planner for the WHOLE trip, and its proposals stranded runs
|
|
-- on ROUTE_22/15/18 once banned. Which shore region actually
|
|
-- touches the water is execution's problem (mountSurfToward):
|
|
-- connect every region pair and price the uncertainty.
|
|
if not next(seen)
|
|
and not ((id == "ROUTE_20" and conn.map == "CINNABAR_ISLAND")
|
|
or (id == "CINNABAR_ISLAND" and conn.map == "ROUTE_20")) then
|
|
local srcR = mapRegions(id)
|
|
local dstR = mapRegions(conn.map)
|
|
for sr = 1, srcR.n do
|
|
for dr = 1, dstR.n do
|
|
add(regionNode(id, sr), regionNode(conn.map, dr),
|
|
{ dir = COMPASS_DIR[dir], cells = cells, cost = 8 })
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
-- ledge hops (one-way region connectors)
|
|
local wc, hc = def.width * 2, def.height * 2
|
|
for y = 0, hc - 1 do
|
|
for x = 0, wc - 1 do
|
|
if defWalkable(def, x, y) then
|
|
for _, d in ipairs(DIRS) do
|
|
local fx, fy = x + d[1], y + d[2]
|
|
if fx >= 0 and fy >= 0 and fx < wc and fy < hc then
|
|
local st, ft = defCellTile(def, x, y), defCellTile(def, fx, fy)
|
|
for _, ledge in ipairs(G.data.field.ledges or {}) do
|
|
if (ledge.tileset or "OVERWORLD") == def.tileset
|
|
and ledge.facing == d[3] and ledge.input == d[3]
|
|
and ledge.standingTile == st and ledge.ledgeTile == ft then
|
|
local lx, ly = fx + d[1], fy + d[2]
|
|
if lx >= 0 and ly >= 0 and lx < wc and ly < hc
|
|
and defWalkable(def, lx, ly) then
|
|
local sr, dr = cellRegionOf(id, x, y), cellRegionOf(id, lx, ly)
|
|
if sr and dr and sr ~= dr then
|
|
add(regionNode(id, sr), regionNode(id, dr),
|
|
{ ledge = { x = x, y = y, dir = d[3] }, cost = 1 })
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
-- Cut trees bridge regions. A cuttable tree is a wall in the static
|
|
-- tiles, so the flood fill splits the map at it -- but CUT opens it,
|
|
-- and the route relies on that: ROUTE_16 cuts a tree at ~(34,8) that
|
|
-- joins the Celadon-side strip to the east-top, the only walking link
|
|
-- between Route 16's two halves. Without this the fly-house pocket is a
|
|
-- dead end and the run dies there. Connect the regions on either side
|
|
-- of each cut tree, both ways, executed by walking onto the far cell
|
|
-- (ops.goto_ cuts the tree in the way).
|
|
local swaps = G.data.field.cutTreeSwaps or {}
|
|
local isCut = {}
|
|
for _, sw in ipairs(swaps) do isCut[sw.before] = true end
|
|
for y = 0, hc - 1 do
|
|
for x = 0, wc - 1 do
|
|
local block = defWalkable(def, x, y) and nil
|
|
or (function()
|
|
local bx, by = math.floor(x / 2), math.floor(y / 2)
|
|
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
|
|
return def.borderBlock
|
|
end
|
|
return def.blocks[by * def.width + bx + 1]
|
|
end)()
|
|
if block and isCut[block] then
|
|
-- walkable orthogonal neighbours of the tree cell
|
|
local nbr = {}
|
|
for _, d in ipairs(DIRS) do
|
|
local nx, ny = x + d[1], y + d[2]
|
|
if nx >= 0 and ny >= 0 and nx < wc and ny < hc
|
|
and defWalkable(def, nx, ny) then
|
|
local r = cellRegionOf(id, nx, ny)
|
|
if r then nbr[#nbr + 1] = { x = nx, y = ny, r = r } end
|
|
end
|
|
end
|
|
for a = 1, #nbr do
|
|
for b = 1, #nbr do
|
|
if a ~= b and nbr[a].r ~= nbr[b].r then
|
|
add(regionNode(id, nbr[a].r), regionNode(id, nbr[b].r),
|
|
{ cut = { x = nbr[b].x, y = nbr[b].y }, cost = 2 })
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
REGION_GRAPH = g
|
|
return g
|
|
end
|
|
|
|
-- Cheapest region-path from the current position to any region of `dest`.
|
|
-- Returns a list of edges (each with warp/dir/ledge execution info).
|
|
local function planRegionRoute(fromNode, dest, banned)
|
|
local g = regionGraph()
|
|
local dist, prev = { [fromNode] = 0 }, {}
|
|
local settled = {}
|
|
local goal
|
|
while true do
|
|
local cur, best
|
|
for k, dv in pairs(dist) do
|
|
if not settled[k] and (best == nil or dv < best) then cur, best = k, dv end
|
|
end
|
|
if not cur then break end
|
|
if cur:match("^(.-)#") == dest then goal = cur; break end
|
|
settled[cur] = true
|
|
for _, e in ipairs(g[cur] or {}) do
|
|
local bk = cur .. "|" .. (e.warp and (e.warp.x .. "," .. e.warp.y)
|
|
or e.ledge and ("L" .. e.ledge.x .. "," .. e.ledge.y)
|
|
or e.cut and ("T" .. e.cut.x .. "," .. e.cut.y)
|
|
or ("C" .. tostring(e.dir))) .. "|" .. e.to
|
|
if not (banned and banned[bk]) then
|
|
local nd = best + e.cost + seamCost(cur:match("^(.-)#"), e.to:match("^(.-)#"))
|
|
if dist[e.to] == nil or nd < dist[e.to] then
|
|
dist[e.to] = nd
|
|
prev[e.to] = { node = cur, edge = e, bk = bk }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if not goal then return nil end
|
|
local path, cur = {}, goal
|
|
while prev[cur] do
|
|
local p = prev[cur]
|
|
table.insert(path, 1, { edge = p.edge, bk = p.bk })
|
|
cur = p.node
|
|
end
|
|
return path
|
|
end
|
|
|
|
-- map id -> list of outgoing edges. Built once; the map data never changes.
|
|
local MAP_GRAPH
|
|
|
|
local function mapGraph()
|
|
if MAP_GRAPH then return MAP_GRAPH end
|
|
local maps = G.data.maps or {}
|
|
local g, into = {}, {}
|
|
for id, def in pairs(maps) do
|
|
g[id] = g[id] or {}
|
|
for _, w in ipairs(def.warps or {}) do
|
|
local d = w.destMap
|
|
if type(d) == "string" and d ~= "LAST_MAP" and maps[d] then
|
|
-- Elevators are costly, so travelTo prefers stairs. Walking into an
|
|
-- elevator is a bounce-trap for a plain warp cross: the car opens a
|
|
-- floor MENU and its default exit dumps you on the reciprocal warp,
|
|
-- oscillating ELEVATOR <-> floor. rideElevator drives the menu when
|
|
-- we are actually in the car, but travelTo must route AROUND the
|
|
-- elevator wherever stairs exist (all of Celadon Mart). Priced high
|
|
-- rather than deleted so a floor only the elevator can reach (Silph's
|
|
-- top) is still on the graph.
|
|
local elev = (id:find("ELEVATOR") or d:find("ELEVATOR")) and 50 or 1
|
|
-- ENTERING Seafoam is pruned outright: 1F's two door pockets
|
|
-- only connect through the BASEMENT floors, which hop execution
|
|
-- cannot thread, so every plan through the islands ping-ponged
|
|
-- the Route 20 doors -- pricing alone lost to a wall of banned
|
|
-- alternatives after hours of wandering. The route never uses
|
|
-- Seafoam (Cinnabar is reached by sea from Pallet). Exits (the
|
|
-- LAST_MAP door edges) are kept so a bot stranded inside can
|
|
-- always leave.
|
|
if not (d:find("^SEAFOAM") and not id:find("^SEAFOAM")) then
|
|
g[id][#g[id] + 1] = { to = d, warp = w, cost = elev }
|
|
end
|
|
into[d] = into[d] or {}
|
|
into[d][id] = true
|
|
end
|
|
end
|
|
for dir, c in pairs(def.connections or {}) do
|
|
if maps[c.map] and COMPASS_DIR[dir] then
|
|
-- A seam with no walkable pair is map data that describes two
|
|
-- adjacent maps you cannot actually walk between. Keep the edge
|
|
-- but make it expensive rather than deleting it: a Cut tree or a
|
|
-- gate script can open one later, and a wrongly deleted edge is
|
|
-- an unreachable map, while a wrongly costly one is a detour.
|
|
local cells = seamCells(def, dir, c)
|
|
-- ROUTE_20's east and west seas NEVER connect on the map: a
|
|
-- walled channel splits them at Seafoam, and the only crossing
|
|
-- is through the islands' basement, which hop execution cannot
|
|
-- thread. The map graph sees one ROUTE_20 node, so from the
|
|
-- east side the CINNABAR seam looked one hop away, and one
|
|
-- travelTo(PALLET_TOWN) spent HOURS banning its way across
|
|
-- Kanto until this impossible seam was the cheapest edge left,
|
|
-- then camped it. PRUNED, not priced -- this is a hard physical
|
|
-- fact, not a maybe-a-tree-opens-it seam, and both sides stay
|
|
-- reachable without it (CINNABAR via ROUTE_21, ROUTE_20 via
|
|
-- ROUTE_19). The executable Cinnabar path is Pallet ->
|
|
-- ROUTE_21 south, the HOF run's own.
|
|
if not ((id == "ROUTE_20" and c.map == "CINNABAR_ISLAND")
|
|
or (id == "CINNABAR_ISLAND" and c.map == "ROUTE_20")) then
|
|
g[id][#g[id] + 1] = { to = c.map, dir = COMPASS_DIR[dir],
|
|
cells = cells, cost = #cells > 0 and 1 or 8 }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
-- A LAST_MAP warp goes back wherever we came IN from, which is not known
|
|
-- until we are standing there. For planning, the candidates are every map
|
|
-- that warps into this one; we re-plan after each hop, so guessing wrong
|
|
-- costs one replan rather than a wrong route. Priced above a concrete
|
|
-- edge so a known-destination hop always wins a tie.
|
|
for id, def in pairs(maps) do
|
|
for _, w in ipairs(def.warps or {}) do
|
|
if w.destMap == "LAST_MAP" then
|
|
for src in pairs(into[id] or {}) do
|
|
local c = (id:find("^SEAFOAM") or src:find("^SEAFOAM")) and 43 or 3
|
|
g[id][#g[id] + 1] = { to = src, warp = w, dynamic = true, cost = c }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
MAP_GRAPH = g
|
|
return g
|
|
end
|
|
|
|
-- Cheapest hop list from `from` to `to`, or nil. `banned` is a set of
|
|
-- seamKeys this trip has already proved useless.
|
|
local function planMapRoute(from, to, banned)
|
|
local g = mapGraph()
|
|
if from == to then return {} end
|
|
local dist, prev = { [from] = 0 }, {}
|
|
-- Dijkstra over a handful of hundreds of nodes: a linear scan for the
|
|
-- nearest unsettled node is far cheaper than the heap it would replace.
|
|
local settled = {}
|
|
while true do
|
|
local cur, best
|
|
for id, d in pairs(dist) do
|
|
if not settled[id] and (best == nil or d < best) then cur, best = id, d end
|
|
end
|
|
if not cur or cur == to then break end
|
|
settled[cur] = true
|
|
for _, e in ipairs(g[cur] or {}) do
|
|
local k = edgeId(cur, e)
|
|
-- `banned` is per-TRIP and per-WARP (see edgeId): within one travelTo
|
|
-- a specific warp that just looped us back must not be re-planned, but
|
|
-- its siblings across the same seam stay available. Across trips the
|
|
-- memory only makes a seam dearer -- see seamCost.
|
|
--
|
|
-- The caller stores map-planner bans under "<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()
|
|
-- A warp the flat planner proposes can sit in a DIFFERENT connected
|
|
-- region of this map than we do: ROCK_TUNNEL_1F's north alcove
|
|
-- (15,0) is walled off from the (15,3) landing pocket, and the flat
|
|
-- graph prices all four LAST_MAP warps identically -- so the trip
|
|
-- burned its whole budget knocking on structurally unreachable doors
|
|
-- forever. The region planner scopes edges correctly; give the flat
|
|
-- fallback the same eyes at execution time and ban the hop on the
|
|
-- spot instead of paying walkOntoWarp's full retry budget to learn it.
|
|
if hop.dir and (not hop.cells or #hop.cells == 0)
|
|
and not (slotKnowing("SURF") and (G.save.inventory or {}).SOULBADGE) then
|
|
-- a pure-water seam before the party can surf: ban silently -- the
|
|
-- planner has land alternatives, and executing it would only spin
|
|
-- the mount machinery for nothing
|
|
banned[banKey] = true
|
|
goto nextTravelHop
|
|
end
|
|
-- The four Saffron gates are one guard with one thirst: without a
|
|
-- drink (or the flag his script sets on taking one) every crossing
|
|
-- is a scripted shove-back, and retrying it read as a stuck loop --
|
|
-- one run lost all five attempts to the ROUTE_6_GATE shove. Ban
|
|
-- silently until the pass condition is real.
|
|
do
|
|
local SAFFRON_GATES = { ROUTE_5_GATE = true, ROUTE_6_GATE = true,
|
|
ROUTE_7_GATE = true, ROUTE_8_GATE = true }
|
|
-- ANY hop whose far side is Saffron: the city has no drink-free
|
|
-- entrance at all, and the ROUTE_6 border connection (a plain
|
|
-- seam in the map data, not a gate-map hop) funneled every
|
|
-- blackout-recovery walk straight into the guard's shove.
|
|
-- Region hops carry "MAP#region" names -- strip the suffix or the
|
|
-- match silently misses (it did, for five straight attempts).
|
|
local dst = tostring(hop.to or ""):match("^([^#]+)") or ""
|
|
local entering = SAFFRON_GATES[dst] or dst == "SAFFRON_CITY"
|
|
if entering then
|
|
local pass = (G.save.flags or {}).EVENT_GAVE_GUARDS_DRINK
|
|
if not pass then
|
|
for _, d in ipairs({ "FRESH_WATER", "SODA_POP", "LEMONADE" }) do
|
|
if (heldCount(d) or 0) > 0 then pass = true break end
|
|
end
|
|
end
|
|
if not pass then
|
|
banned[banKey] = true
|
|
goto nextTravelHop
|
|
end
|
|
end
|
|
end
|
|
-- Region sanity is only meaningful when we are physically ON the map
|
|
-- the plan reads (`here`); mid-desync the player can stand elsewhere,
|
|
-- and computing their cell against the wrong map's region table
|
|
-- produced garbage bans that severed real paths (ROUTE_2's seams,
|
|
-- one pinballing attempt 2).
|
|
if hop.warp and ow().map.id == here then
|
|
local pp = ow().player
|
|
local pr = cellRegionOf(here, pp.cellX, pp.cellY)
|
|
local wr = cellRegionOf(here, hop.warp.x, hop.warp.y)
|
|
if pr and wr and pr ~= wr then
|
|
banned[banKey] = true
|
|
say(("travelTo %s: warp@%d,%d on %s is in another region -- banning")
|
|
:format(tostring(dest), hop.warp.x, hop.warp.y, here))
|
|
goto nextTravelHop
|
|
end
|
|
elseif hop.dir and hop.cells and hop.cells[1]
|
|
and ow().map.id == here then
|
|
-- Same blindness, connection flavour: ROUTE_10's south seam only
|
|
-- touches its Lavender-side region, and proposing "cross down"
|
|
-- from the Cerulean side sent goto_ hacking at trees and diving
|
|
-- into the tunnel pocket forever. If no walkable seam cell shares
|
|
-- the player's region, the crossing is not reachable on foot from
|
|
-- here -- ban it and let the planner find the through-the-tunnel
|
|
-- chain instead.
|
|
local pp = ow().player
|
|
local pr = cellRegionOf(here, pp.cellX, pp.cellY)
|
|
if pr then
|
|
local touches = false
|
|
for _, c in ipairs(hop.cells) do
|
|
if cellRegionOf(here, c[1], c[2]) == pr then touches = true break end
|
|
end
|
|
if not touches then
|
|
banned[banKey] = true
|
|
say(("travelTo %s: the %s seam on %s is in another region -- "
|
|
.. "banning"):format(tostring(dest), tostring(hop.dir), here))
|
|
goto nextTravelHop
|
|
end
|
|
end
|
|
end
|
|
local before = posKey()
|
|
doHop(hop)
|
|
settleCell()
|
|
local pk = posKey()
|
|
if pk == before then
|
|
-- did not move at all: this hop is shut from here, ban and re-plan
|
|
banned[banKey] = true
|
|
-- ...and REMEMBER it (a price, never a ban -- see seamCost). The
|
|
-- notes were designed and documented up top but never wired in, so
|
|
-- a seam that failed every lap of a trip stayed free on the next
|
|
-- re-plan: the ROUTE_12 east loop retried its impossible west
|
|
-- crossing at full price forever. Connections only -- warps already
|
|
-- have per-warp ban granularity via edgeId.
|
|
if hop.dir and not hop.warp and hop.to then noteSeam(here, hop.to) end
|
|
-- A connection can be unreachable from THIS half of a gate-split
|
|
-- map. The map planner sees both halves as one node, so from Route
|
|
-- 7's east half it proposed the WEST seam into Celadon ninety times
|
|
-- running -- the seam only touches the west half, and the gate in
|
|
-- the middle is the only way across. Step through the gate toward
|
|
-- a walkable cell on the seam's border, then let the re-plan try
|
|
-- again from the far side.
|
|
local gx, gy
|
|
if hop.cells and hop.cells[1] then
|
|
gx, gy = hop.cells[1][1], hop.cells[1][2]
|
|
elseif hop.dir then
|
|
-- no walkable seam pair recorded: scan this edge for any
|
|
-- walkable border cell to aim the gate crossing at
|
|
local m2 = ow().map
|
|
local W2, H2 = m2.widthCells, m2.heightCells
|
|
if hop.dir == "left" or hop.dir == "right" then
|
|
local bx = hop.dir == "left" and 0 or W2 - 1
|
|
for y2 = 0, H2 - 1 do
|
|
if m2:isWalkableCell(bx, y2) then gx, gy = bx, y2 break end
|
|
end
|
|
else
|
|
local by = hop.dir == "up" and 0 or H2 - 1
|
|
for x2 = 0, W2 - 1 do
|
|
if m2:isWalkableCell(x2, by) then gx, gy = x2, by break end
|
|
end
|
|
end
|
|
end
|
|
-- ONE gate rescue per edge. reEnterThroughGate can report success
|
|
-- for a walk that changed nothing (no gate on the map), and
|
|
-- un-banning the edge every time re-proposed the same dead hop
|
|
-- forever -- Cinnabar's "cross up" was retried for the full 90-hop
|
|
-- budget while the sea sat uncrossed to the west.
|
|
if gx and not gateRescued[banKey] and reEnterThroughGate(gx, gy) then
|
|
gateRescued[banKey] = true
|
|
banned[banKey] = nil
|
|
say(("travelTo %s: crossed a gate on %s toward the seam; retrying")
|
|
:format(tostring(dest), here))
|
|
end
|
|
if fromRegion then
|
|
regionFails = regionFails + 1
|
|
if regionFails >= REGION_FAIL_LIMIT and not useMap then
|
|
useMap = true
|
|
banned = {} -- the map planner has its own edge keys; start clean
|
|
say(("travelTo %s: region hops keep failing on %s -- switching to "
|
|
.. "the map planner"):format(tostring(dest), here))
|
|
end
|
|
end
|
|
say(("travelTo %s: %s (%s) did not move; re-planning")
|
|
:format(tostring(dest), here, hopDesc))
|
|
elseif visited[pk] then
|
|
-- looped back onto a cell we already stood on: ban the hop that did it
|
|
banned[banKey] = true
|
|
-- a crossing that only ever leads back where we came from earns the
|
|
-- same seam price as one that refuses outright (see above)
|
|
if hop.dir and not hop.warp and hop.to then noteSeam(here, hop.to) end
|
|
-- A loop counts against the region planner too, or a connection that
|
|
-- crossSeam keeps landing on the same border cell (Pewter -> Route 3
|
|
-- from an awkward spot a grind left us in) spins forever without ever
|
|
-- reaching the "did not move" branch that triggers the fallback.
|
|
if fromRegion then
|
|
regionFails = regionFails + 1
|
|
if regionFails >= REGION_FAIL_LIMIT and not useMap then
|
|
useMap = true
|
|
banned = {}
|
|
say(("travelTo %s: region hops keep looping on %s -- switching to "
|
|
.. "the map planner"):format(tostring(dest), here))
|
|
end
|
|
end
|
|
-- Hard anti-livelock: the same cell revisited far too many times means
|
|
-- neither planner is getting anywhere. One SECOND WIND first: the
|
|
-- fallback to the map planner is trip-wide, and on a cut-tree-split
|
|
-- map (Route 2's Diglett pocket, on the only land route to Pallet)
|
|
-- it is blind to the region bridges the region planner knows -- so a
|
|
-- few flaky early hops used to condemn the whole trip to a planner
|
|
-- that could never finish it. Reset to region planning once, with
|
|
-- clean slates, before actually giving up.
|
|
revisits[pk] = (revisits[pk] or 0) + 1
|
|
if revisits[pk] > MAX_TRAVEL_REVISITS then
|
|
if not secondWind then
|
|
secondWind = true
|
|
useMap = false
|
|
regionFails = 0
|
|
banned = {}
|
|
visited = {}
|
|
revisits = {}
|
|
say(("travelTo %s: second wind -- back to region planning from %s")
|
|
:format(tostring(dest), tostring(ow().map.id)))
|
|
else
|
|
say(("travelTo %s: stuck revisiting %s -- giving up")
|
|
:format(tostring(dest), pk))
|
|
note("travelTo: livelocked reaching " .. tostring(dest), where)
|
|
return false
|
|
end
|
|
end
|
|
say(("travelTo %s: looped back to %s; trying another way")
|
|
:format(tostring(dest), pk))
|
|
else
|
|
-- genuine progress: a region hop that worked resets the fail streak
|
|
if fromRegion then regionFails = 0 end
|
|
-- a connection that carried us across is forgiven its past flakes
|
|
if hop.dir and not hop.warp and hop.to and ow().map.id == hop.to then
|
|
clearSeam(here, hop.to)
|
|
end
|
|
visited[pk] = true
|
|
end
|
|
visited._last = pk
|
|
::nextTravelHop::
|
|
end
|
|
say(("travelTo %s: gave up after %d hops, on %s")
|
|
:format(tostring(dest), MAX_TRAVEL_HOPS, ow().map.id))
|
|
note("travelTo: too many hops to " .. tostring(dest), where)
|
|
return ow().map.id == dest
|
|
end
|
|
|
|
-- Reach a warp cell by ARRIVING through it rather than walking to it.
|
|
--
|
|
-- A dungeon floor is not one connected room, and the route crosses between
|
|
-- its pockets by going up or down and back. MT_MOON_B1F is the case that
|
|
-- forced this: it is four pockets joined only through B2F and 1F, so from
|
|
-- warp 5 at (21,17) there is no path at all to warp 7 at (23,3) -- the
|
|
-- unreachable log named it exactly, "npcs: none", because nothing was in
|
|
-- the way; there is simply no floor between them.
|
|
--
|
|
-- Warps are two-ended and the data says which end is which, so this is
|
|
-- exact rather than a search: MT_MOON_B2F's warp 4 at (5,7) declares
|
|
-- destWarp 7 into MT_MOON_B1F, and taking it lands us ON (23,3). Find the
|
|
-- far end, travel to that map, step through, and we arrive at the target
|
|
-- instead of failing to walk to it.
|
|
-- Cells we can currently walk to, NPCs counted as walls exactly as
|
|
-- bfsNextKey sees them. Shared by the "walled off" recoveries below.
|
|
function reachableSet()
|
|
local m, p = ow().map, ow().player
|
|
local W, H = m.widthCells, m.heightCells
|
|
local blocked = {}
|
|
-- only this map's NPCs; see bfsNextKey for why a foreign one is poison
|
|
for _, npc in ipairs(ow().npcs) do
|
|
if npc.cellX >= 0 and npc.cellY >= 0 and npc.cellX < W and npc.cellY < H then
|
|
blocked[npc.cellY * W + npc.cellX] = true
|
|
end
|
|
end
|
|
local seen = { [p.cellY * W + p.cellX] = true }
|
|
local q, head = { { p.cellX, p.cellY } }, 1
|
|
while head <= #q do
|
|
local c = q[head]; head = head + 1
|
|
for _, d in ipairs(DIRS) do
|
|
local nx, ny = c[1] + d[1], c[2] + d[2]
|
|
local id = ny * W + nx
|
|
if nx >= 0 and ny >= 0 and nx < W and ny < H and not seen[id]
|
|
and not blocked[id] and passableCell(m, nx, ny)
|
|
and not pairBlockedStep(m, c[1], c[2], nx, ny, p.surfing) then
|
|
seen[id] = true
|
|
q[#q + 1] = { nx, ny }
|
|
elseif nx >= 0 and ny >= 0 and nx < W and ny < H
|
|
and not passableCell(m, nx, ny) then
|
|
-- ledge hops, so this set matches what bfsNextKey can walk
|
|
-- (see cutToward for the ROUTE_9 case this fixed)
|
|
local lx, ly = ledgeLanding(m, c[1], c[2], d[3], d[1], d[2])
|
|
if lx then
|
|
local lid = ly * W + lx
|
|
if not seen[lid] and not blocked[lid] then
|
|
seen[lid] = true
|
|
q[#q + 1] = { lx, ly }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return seen, W
|
|
end
|
|
|
|
-- Pick up an item ball that is walling off the walk to (tx, ty).
|
|
--
|
|
-- An item ball is an NPC and therefore a BFS wall -- and Pokémon Tower
|
|
-- 6F's RARE_CANDY at (6,8) stands in the ONLY corridor between the
|
|
-- floor's east and west halves. Vanilla players grab it in passing (the
|
|
-- ball despawns, opening the way) and the route's own pickup steps do
|
|
-- the same -- but a resume desync that skips those steps leaves the
|
|
-- floor severed for good: "goto (6,14) unreachable on POKEMON_TOWER_6F"
|
|
-- with a fresh save's balls all standing. So: when the target is
|
|
-- outside the reachable set and a ball sits on its frontier, stand
|
|
-- beside it, take it, and let the caller re-plan. Same one-per-call
|
|
-- contract as cutToward.
|
|
pickingBall = false -- global: the main chunk is at the 200-local ceiling
|
|
function pickupBlockingBall(tx, ty)
|
|
if pickingBall then return false end
|
|
local m = ow().map
|
|
local seen, W = reachableSet()
|
|
if seen[ty * W + tx] then return false end -- not a blocking-ball problem
|
|
for _, npc in ipairs(ow().npcs) do
|
|
if tostring(npc.def and npc.def.sprite or "") == "SPRITE_POKE_BALL" then
|
|
local bx, by = npc.cellX, npc.cellY
|
|
for _, d in ipairs(DIRS) do
|
|
local ax, ay = bx + d[1], by + d[2]
|
|
if ax >= 0 and ay >= 0 and ax < W and ay < m.heightCells
|
|
and seen[ay * W + ax] then
|
|
pickingBall = true
|
|
local took = false
|
|
ops.goto_({ x = ax, y = ay })
|
|
local p = ow().player
|
|
if ow().map.id == m.id and p.cellX == ax and p.cellY == ay then
|
|
faceDir((by < ay and "up") or (by > ay and "down")
|
|
or (bx < ax and "left") or "right")
|
|
press("a")
|
|
U.wait(20)
|
|
mashUntilIdle()
|
|
took = not ow():npcAtCell(bx, by)
|
|
end
|
|
pickingBall = false
|
|
if took then
|
|
say(("picked up the item ball at (%d,%d) that was walling off "
|
|
.. "(%d,%d)"):format(bx, by, tx, ty))
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Reach another region of THIS map by going through a gate and out its far
|
|
-- door.
|
|
--
|
|
-- The case arriveByWarp cannot take. ROUTE_16 is split in two by the gate
|
|
-- building at x 18-23: west (x<=17) holds the FLY house, east (x>=24) leads
|
|
-- to CELADON_CITY, and row 10 is the only crossing --
|
|
-- (17,10) -> ROUTE_16_GATE_1F -> (24,10). Both sides are the SAME map, so
|
|
-- travelTo thinks we have arrived; and every one of the gate's warps is
|
|
-- LAST_MAP with no destWarp, so arriveByWarp has no far end to aim at.
|
|
-- The result was a bot sealed in the western pocket reporting
|
|
-- "ROUTE_16 -> CELADON_CITY did not cross" until it ran out of rewinds.
|
|
--
|
|
-- So: step into a gate we CAN reach, then come back out through a door we
|
|
-- did not use, and see whether the target is reachable now.
|
|
local goingThroughGate = false
|
|
|
|
function reEnterThroughGate(tx, ty)
|
|
if goingThroughGate then return false end
|
|
local hereId = ow().map.id
|
|
local seen, W = reachableSet()
|
|
if seen[ty * W + tx] then return false end -- not a region problem
|
|
-- Doors we can walk to that lead INTO a named sub-map, nearest first.
|
|
--
|
|
-- The destMap filter is a safety rail, and it was learned the hard way.
|
|
-- Without it this entered ANY reachable warp -- including a building's
|
|
-- own LAST_MAP exit -- so an ordinary unreachable waypoint (a Vermilion
|
|
-- Gym trash can the puzzle had walled off for the moment) sent the bot
|
|
-- WALKING OUT OF THE GYM and back in, over and over, until the process
|
|
-- hung and was killed. A real gate crossing goes through a named map
|
|
-- (ROUTE_16 -> ROUTE_16_GATE_1F); a LAST_MAP door just dumps us outside
|
|
-- the current area, which is never the way to another region OF THIS map.
|
|
local doors = {}
|
|
for i, w in ipairs(ow().map.def.warps or {}) do
|
|
if seen[w.y * W + w.x] and type(w.destMap) == "string"
|
|
and w.destMap ~= "LAST_MAP" and G.data.maps[w.destMap] then
|
|
doors[#doors + 1] = { idx = i, warp = w }
|
|
end
|
|
end
|
|
local p = ow().player
|
|
table.sort(doors, function(a, b)
|
|
return math.abs(a.warp.x - p.cellX) + math.abs(a.warp.y - p.cellY)
|
|
< math.abs(b.warp.x - p.cellX) + math.abs(b.warp.y - p.cellY)
|
|
end)
|
|
goingThroughGate = true
|
|
local ok = false
|
|
for k = 1, math.min(#doors, 3) do
|
|
local door = doors[k]
|
|
if not walkOntoWarp(door.warp.x, door.warp.y) then break end
|
|
local inner = ow().map.id
|
|
-- try the inner map's other exits back to where we came from
|
|
local tried = 0
|
|
for _, w2 in ipairs(ow().map.def.warps or {}) do
|
|
local back = w2.destMap
|
|
if (back == hereId or back == "LAST_MAP")
|
|
and not (w2.x == ow().player.cellX and w2.y == ow().player.cellY) then
|
|
tried = tried + 1
|
|
-- 8, not 4: ROUTE_16_GATE_1F has EIGHT LAST_MAP doors back to
|
|
-- ROUTE_16 -- four per half, north half listed first -- so a cap
|
|
-- of 4 tried only the side we came in from and the crossing
|
|
-- always "failed" while the south doors sat untried.
|
|
if tried > 8 then break end
|
|
if walkOntoWarp(w2.x, w2.y) and ow().map.id == hereId then
|
|
local seen2, W2 = reachableSet()
|
|
if seen2[ty * W2 + tx] then
|
|
say(("crossed %s via %s and came out the far side; (%d,%d) is "
|
|
.. "reachable now"):format(hereId, tostring(inner), tx, ty))
|
|
ok = true
|
|
break
|
|
end
|
|
-- wrong side: go back in and try another door
|
|
if not walkOntoWarp(door.warp.x, door.warp.y) then break end
|
|
end
|
|
end
|
|
end
|
|
if ok then break end
|
|
-- make sure we are back on the map we started from before trying again
|
|
if ow().map.id ~= hereId then
|
|
local out = findWarpTo(hereId) or findWarpTo("LAST_MAP") or findWarpTo("")
|
|
if out then walkOntoWarp(out.x, out.y) end
|
|
end
|
|
end
|
|
goingThroughGate = false
|
|
return ok
|
|
end
|
|
|
|
local arrivingByWarp = false
|
|
|
|
function arriveByWarp(tx, ty)
|
|
if arrivingByWarp then return false end
|
|
local m = ow().map
|
|
local hereId = m.id
|
|
local w = m.warpAtCell and m:warpAtCell(tx, ty)
|
|
if not w then return false end
|
|
local idx = w.index
|
|
-- every (map, warp) pair whose far end is this exact warp
|
|
local cands = {}
|
|
for mapId, def in pairs(G.data.maps or {}) do
|
|
for _, other in ipairs(def.warps or {}) do
|
|
if other.destMap == hereId and other.destWarp == idx then
|
|
cands[#cands + 1] = { map = mapId, warp = other }
|
|
end
|
|
end
|
|
end
|
|
if #cands == 0 then return false end
|
|
arrivingByWarp = true
|
|
local ok = false
|
|
for k = 1, math.min(#cands, 3) do
|
|
local c = cands[k]
|
|
say(("goto (%d,%d) has no floor leading to it on %s; arriving through "
|
|
.. "%s's warp at (%d,%d) instead")
|
|
:format(tx, ty, hereId, c.map, c.warp.x, c.warp.y))
|
|
if travelTo(c.map, hereId) then
|
|
walkOntoWarp(c.warp.x, c.warp.y)
|
|
local p = ow().player
|
|
if ow().map.id == hereId and p.cellX == tx and p.cellY == ty then
|
|
ok = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
arrivingByWarp = false
|
|
if ok then say(("arrived at (%d,%d) on %s"):format(tx, ty, hereId)) end
|
|
return ok
|
|
end
|
|
|
|
-- Talk to the nurse and accept, mirroring tests/drivers/heal_test.lua:
|
|
-- welcome text -> ChoiceBox -> YES -> machine animation -> farewell.
|
|
local function talkToNurse(where)
|
|
local nurse = findObject("_NURSE")
|
|
if not nurse then
|
|
note("center: no nurse", where)
|
|
say(("center on %s: no nurse object on %s"):format(
|
|
tostring(where), tostring(ow().map.id)))
|
|
return false
|
|
end
|
|
-- her counter sits between you and her, so she is talked to from two
|
|
-- tiles below facing up (heal_test stands at (3,3) for a nurse at (3,1))
|
|
if not ops.goto_({ x = nurse.x, y = nurse.y + 2 }) then
|
|
note("center: cannot reach the counter", where)
|
|
say(("center on %s: could not reach the counter at (%d,%d)"):format(
|
|
tostring(where), nurse.x, nurse.y + 2))
|
|
return false
|
|
end
|
|
faceDir("up")
|
|
press("a")
|
|
U.wait(10)
|
|
-- accept the prompt; ChoiceBox defaults to YES at index 1. Mashing A is
|
|
-- safe in here -- unlike a shop, no screen in a Poké Center spends money.
|
|
for _ = 1, 60 do
|
|
if isChoice() then
|
|
cursorTo("index", 1)
|
|
press("a")
|
|
break
|
|
end
|
|
if idle() then break end
|
|
press("a")
|
|
U.wait(5)
|
|
end
|
|
-- busy() already covers ow.healAnim, so idle() waits out the machine
|
|
for _ = 1, 200 do
|
|
if idle() then break end
|
|
U.wait(6)
|
|
end
|
|
return mashUntilIdle()
|
|
end
|
|
|
|
local function totalHP()
|
|
local t = 0
|
|
for _, mon in ipairs(party()) do t = t + (mon.hp or 0) end
|
|
return t
|
|
end
|
|
|
|
-- Leave the route, heal, and come back to the tile we left from.
|
|
-- Returns true only if HP actually went up: talkToNurse ends in
|
|
-- mashUntilIdle, which reports success for *reaching the overworld again*,
|
|
-- not for healing. Trusting that is what made the bot walk into the centre,
|
|
-- out again and straight back in on every following segment.
|
|
-- `forHealPoint` visits for the RESPAWN, not the HP.
|
|
--
|
|
-- talkToNurse writes wLastBlackoutMap (SetLastBlackoutMap), and that is
|
|
-- worth a detour on its own: without it the heal point stays wherever it
|
|
-- last happened to be, and a gym loss respawns a town behind. Judging the
|
|
-- visit by whether HP rose is exactly wrong here -- we go in healthy on
|
|
-- purpose -- so a heal-point visit is never counted as a failure and never
|
|
-- triggers the "nurse healed nothing" backoff.
|
|
local function visitPokeCenter(where, forHealPoint)
|
|
local fromMap = ow().map.id
|
|
local door = findWarpTo("POKECENTER")
|
|
if not door then return false end -- no centre on this map
|
|
local before = totalHP()
|
|
if not walkOntoWarp(door.x, door.y) then
|
|
note("center: could not get in", where)
|
|
say(("center on %s: could not walk onto the door at (%d,%d)"):format(
|
|
tostring(where), door.x, door.y))
|
|
return false
|
|
end
|
|
talkToNurse(where)
|
|
local healed = totalHP() > before
|
|
-- The whole point of a forHealPoint visit is the nurse WRITING the
|
|
-- respawn (SetLastBlackoutMap). Silence here is what hid the Route 10
|
|
-- anchor failing on every pass: the log said "re-anchoring at ROUTE_10"
|
|
-- while wipes kept waking in CERULEAN, because the talk never happened
|
|
-- and nothing said so.
|
|
local centreMap = ow().map.id
|
|
if forHealPoint and not (G.save.lastHeal and G.save.lastHeal.map == centreMap) then
|
|
say(("center on %s: nurse did NOT take the heal point (still %s)")
|
|
:format(tostring(where),
|
|
tostring(G.save.lastHeal and G.save.lastHeal.map)))
|
|
end
|
|
if forHealPoint then healed = true end
|
|
if not healed then note("center: nurse restored nothing", where) end
|
|
-- the centre's exit warps both lead to LAST_MAP
|
|
local out = findWarpTo("LAST_MAP") or findWarpTo("")
|
|
if out then walkOntoWarp(out.x, out.y) end
|
|
if ow().map.id ~= fromMap then
|
|
note("center: did not get back to " .. fromMap, where)
|
|
end
|
|
-- Deliberately NOT walking back to where we stood before healing.
|
|
--
|
|
-- Every route step is an absolute goto, so the segment re-paths from
|
|
-- wherever we are and the backtrack buys nothing. It costs plenty: the
|
|
-- saved tile is wherever the previous segment happened to stop, and when
|
|
-- that is the edge of the map -- Viridian's southern row, if we had just
|
|
-- walked up from Route 1 -- "returning" marches back down to the seam and
|
|
-- slips into the next map, desyncing the run for good.
|
|
return healed
|
|
end
|
|
|
|
-- Whether a fight is worth staying in: true if the bag holds something that
|
|
-- restores HP, or the map we are standing on has a centre to walk back to.
|
|
-- Read mid-battle by lowOnHP, which is why it is only assigned here.
|
|
canRestoreHP = function()
|
|
for _, id in ipairs(HP_ITEMS) do
|
|
if heldCount(id) then return true end
|
|
end
|
|
return findWarpTo("POKECENTER") ~= nil
|
|
end
|
|
|
|
-- Top up between segments, independently of the route's own heal steps.
|
|
--
|
|
-- PokeBotBad asks for a heal exactly where a speedrun needs one, and it
|
|
-- assumes levels we do not reach. Left to the route's heal steps alone the
|
|
-- party grinds down over a couple of maps and blacks out -- which is how
|
|
-- the first runs ended, every time in Viridian Forest.
|
|
-- Two thresholds again, for the same reason the flee guard has two: a
|
|
-- potion is scarce, a nurse is free. Spend an item only once a mon is
|
|
-- clearly hurt, but never walk out of a town with a centre less than full
|
|
-- -- the stretch that keeps killing us (Viridian Forest into Route 2) has
|
|
-- no centre anywhere along it, so whatever we leave Viridian with is all
|
|
-- we get.
|
|
local AUTO_HEAL_AT = 0.5 -- worth spending an item on
|
|
local CENTER_TOP_UP_AT = 0.95 -- worth walking to a free nurse for
|
|
|
|
-- segments to wait before trying another nurse, after one healed nothing
|
|
local centerCooldown = 0
|
|
|
|
-- The town whose Poké Center last wrote the heal point. A blackout sends us
|
|
-- to wLastBlackoutMap, so when this falls behind the route the respawn is
|
|
-- somewhere we finished with, and every death replays the walk back.
|
|
local healAnchor
|
|
|
|
-- worst HP fraction in the party, and the slot holding it
|
|
local function worstFraction()
|
|
local slot = mostHurt()
|
|
if not slot then return 1, nil end
|
|
local mon = party()[slot]
|
|
local max = mon.stats and mon.stats.hp or 0
|
|
if max <= 0 then return 1, nil end
|
|
return mon.hp / max, slot
|
|
end
|
|
|
|
-- How healthy we insist on being before walking into a map's fights.
|
|
--
|
|
-- This is the "how likely is the next trainer to kill me" judgement, made
|
|
-- from what we can actually see up front (we do not know the enemy until
|
|
-- the battle opens): how much of the party is still standing, and how
|
|
-- often this map has killed us before.
|
|
function riskThreshold(mapId)
|
|
local usable = 0
|
|
for _, mon in ipairs(party()) do
|
|
if (mon.hp or 0) > 0 then usable = usable + 1 end
|
|
end
|
|
local need = AUTO_HEAL_AT
|
|
-- a one-mon party has no second chance: a single bad matchup ends the
|
|
-- run outright, so demand real headroom rather than half a bar
|
|
if usable <= 1 then need = math.max(need, 0.8) end
|
|
-- and every death here buys more caution, up to "arrive essentially full"
|
|
local d = dangerAt(mapId)
|
|
if d > 0 then need = math.min(0.95, need + 0.15 * math.min(d, 3)) end
|
|
return need
|
|
end
|
|
|
|
-- `allowCenter` gates the Poké Center detour, and only the segment-boundary
|
|
-- call passes it.
|
|
--
|
|
-- A centre visit WARPS us -- it is not a walk we can undo -- and a map can be
|
|
-- split into regions a walk cannot cross. Cerulean is exactly that: a fence
|
|
-- divides it and the only ways through are two pass-through houses. Healing
|
|
-- part-way through the segment that crosses to the east half teleported the
|
|
-- bot back to the centre in the west half, where every remaining waypoint was
|
|
-- unreachable and the attempt died in a run of skips. Mid-segment healing is
|
|
-- items only; the nurse is a between-segments decision.
|
|
function autoHeal(where, need, allowCenter)
|
|
if not idle() then return end
|
|
need = need or AUTO_HEAL_AT
|
|
local frac = worstFraction()
|
|
|
|
-- the nurse is free and heals the whole party, so top up on the way past
|
|
-- rather than waiting for anything to get dangerous
|
|
if allowCenter and frac < math.max(need, CENTER_TOP_UP_AT) and centerCooldown <= 0
|
|
and findWarpTo("POKECENTER") then
|
|
if visitPokeCenter(where) then return end
|
|
-- The walk there put no HP back. Retrying it next segment is how the
|
|
-- bot ends up marching in and out of the same door forever, so stand
|
|
-- down for a while and fall through to items instead.
|
|
centerCooldown = 12
|
|
say("nurse healed nothing on " .. tostring(where)
|
|
.. "; not walking back for 12 segments")
|
|
end
|
|
|
|
if frac >= need then return end
|
|
-- several passes: one potion rarely tops up a badly hurt mon, and a
|
|
-- multi-mon party can have more than one below the line
|
|
for _ = 1, 6 do
|
|
local f, slot = worstFraction()
|
|
if not slot or f >= need then return end
|
|
local before = party()[slot].hp
|
|
ops.heal({}, where)
|
|
-- ops.heal reports "no HP item" by noting and returning true, so stop
|
|
-- on anything that did not actually raise HP rather than spinning
|
|
local after = party()[slot] and party()[slot].hp or 0
|
|
if after <= before then return end
|
|
end
|
|
end
|
|
|
|
-- Menu-driven ops still to implement. Each is a real menu interaction
|
|
-- (bag, party, PC) rather than something the route can express, so they
|
|
-- are stubbed loudly instead of guessed at.
|
|
-- The route names moves in its own vocabulary; these are the items that
|
|
-- teach them. Anything missing falls through to the stub and is logged.
|
|
local TEACH_ITEMS = {
|
|
cut = "HM_CUT", fly = "HM_FLY", surf = "HM_SURF",
|
|
strength = "HM_STRENGTH", flash = "HM_FLASH",
|
|
-- NB: no `thrash`. Gen 1 has no Thrash TM -- the route's five `teach
|
|
-- thrash` steps on Route 24 are accepting a LEVEL-UP learn prompt, which
|
|
-- mashUntilIdle already answers with A. Left unmapped on purpose.
|
|
dig = "TM_DIG", bubblebeam = "TM_BUBBLEBEAM", earthquake = "TM_EARTHQUAKE",
|
|
thunderbolt = "TM_THUNDERBOLT", horn_drill = "TM_HORN_DRILL",
|
|
rock_slide = "TM_ROCK_SLIDE", ice_beam = "TM_ICE_BEAM",
|
|
mega_punch = "TM_MEGA_PUNCH", mega_kick = "TM_MEGA_KICK",
|
|
}
|
|
-- pickupTossJunk consults this when the bag is full: anything a teach
|
|
-- step will want is protected from the toss
|
|
for _, item in pairs(TEACH_ITEMS) do WD.keepItems[item] = true end
|
|
|
|
-- Party slot of the first species in `prefs` that we actually own. The route
|
|
-- lists alternatives ({"oddish","paras"}) because which one it caught varies.
|
|
local function slotForSpecies(prefs)
|
|
if type(prefs) == "string" then prefs = { prefs } end
|
|
local roster = party()
|
|
for _, want in ipairs(prefs or {}) do
|
|
local target = tostring(want):upper()
|
|
for i, mon in ipairs(roster) do
|
|
if tostring(mon.species):upper() == target then return i end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function slotKnowing(moveId)
|
|
for i, mon in ipairs(party()) do
|
|
for _, mv in ipairs(mon.moves or {}) do
|
|
if tostring(mv.id):upper() == moveId then return i end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Teach an HM/TM. Same bag walk as a healing item: the party menu at the end
|
|
-- picks who learns it.
|
|
function ops.teach(s, where)
|
|
local key = tostring(s.move or ""):lower()
|
|
local item = TEACH_ITEMS[key]
|
|
if not item then note("teach:" .. key, where) return true end
|
|
-- Idempotent: a blackout replays the segment, and a TM is consumed on
|
|
-- use -- so the second pass found "no TM_EARTHQUAKE in the bag" and
|
|
-- failed a step whose work was already done (the move was on the lead
|
|
-- from the first pass). Somebody already knowing the move IS success.
|
|
if slotKnowing(key:upper()) then
|
|
say(("teach %s: already known -- skipping"):format(key))
|
|
return true
|
|
end
|
|
if (heldCount(item) or 0) <= 0 then
|
|
note("teach: no " .. item, where)
|
|
say(("teach %s: no %s in the bag"):format(key, item))
|
|
return false
|
|
end
|
|
-- Who can actually learn it. The route names a species (its TMs are aimed
|
|
-- at the Nidoking it assumes we evolved), and when that mon is not in the
|
|
-- party the old fallback was "slot 1" -- which handed THUNDERBOLT to a
|
|
-- WARTORTLE, whose tmhm list does not contain it, so the TM was spent for
|
|
-- nothing and the log blamed the teach. Compatibility is data we already
|
|
-- have; consult it rather than guessing.
|
|
local function canLearn(mon)
|
|
local sp = mon and G.data.pokemon[mon.species]
|
|
for _, m in ipairs(sp and sp.tmhm or {}) do
|
|
if m == key:upper() then return true end
|
|
end
|
|
return false
|
|
end
|
|
local slot = slotForSpecies(s.mon)
|
|
if slot and not canLearn(party()[slot]) then slot = nil end
|
|
if not slot then
|
|
for i, mon in ipairs(party()) do
|
|
if canLearn(mon) then slot = i break end
|
|
end
|
|
end
|
|
if not slot then
|
|
note("teach: nobody can learn " .. key, where)
|
|
say(("teach %s: nobody in the party can learn it"):format(key))
|
|
return false
|
|
end
|
|
local who = party()[slot]
|
|
say(("teaching %s to %s (slot %d)"):format(key,
|
|
tostring(who and who.species or "?"), slot))
|
|
local ok = useItemOn(item, slot, where)
|
|
-- A full moveset opens MoveLearnMenu. useItemOn used to mash A/B right
|
|
-- through it (B answers NO on the YES/NO boxes), abandoning the move the
|
|
-- item was spent on -- DIG silently failed on a level-30 WARTORTLE that
|
|
-- way, and losing CUT the same way walled the run off at Route 9's tree.
|
|
-- useItemOn now returns with the learn flow untouched the moment it
|
|
-- appears (see the guard there), and driveLearnMenu answers the two
|
|
-- prompts by intent ("Delete an older move?" YES, "Abandon learning?"
|
|
-- NO), picks a forgettable non-HM row, and prefers the route step's own
|
|
-- `replace` hint when it names a safe move.
|
|
--
|
|
-- MoveLearnMenu is NOT on top when it appears: its :enter() immediately
|
|
-- pushes the TryingToLearn TextBox, whose last page raises the YES/NO
|
|
-- ChoiceBox -- which is why the state scan below searches the whole
|
|
-- stack rather than testing top().newMoveId.
|
|
local function learnMenu()
|
|
for _, st in ipairs(G.stack.states or {}) do
|
|
if st.newMoveId and st.mon and st.index then return st end
|
|
end
|
|
end
|
|
if waitFor(learnMenu, 30) then
|
|
for _ = 1, 60 do
|
|
if not learnMenu() then break end
|
|
driveLearnMenu(s.replace)
|
|
end
|
|
mashUntilIdle()
|
|
say(("%s forgot a move to learn %s"):format(
|
|
tostring(who and who.species or "?"), key))
|
|
end
|
|
-- The learn lands a frame or two after the menus close, so checking
|
|
-- immediately reported "did NOT learn it" for a TM that had in fact
|
|
-- worked -- DIG said it failed and then got used two segments later.
|
|
-- Give it a moment before calling it.
|
|
local learned = waitFor(function() return slotKnowing(key:upper()) ~= nil end, 20)
|
|
if learned then
|
|
say(("%s learned %s"):format(tostring(who and who.species or "?"), key))
|
|
return true
|
|
end
|
|
say(("teach %s: %s did NOT learn it"):format(key,
|
|
tostring(who and who.species or "?")))
|
|
return false
|
|
end
|
|
|
|
-- Use a field move from the party menu (start_sub_menus.asm). CUT/SURF/
|
|
-- STRENGTH/FLY all live there, gated on the matching badge -- CUT needs
|
|
-- CASCADEBADGE (src/ui/PartyMenu.lua:342).
|
|
function ops.fieldMove(s, where)
|
|
local move = tostring(s.move or ""):upper()
|
|
-- the tree cell CUT will face, for the post-use verification below
|
|
local cutCellX, cutCellY
|
|
local slot = slotKnowing(move)
|
|
if not slot then
|
|
-- The HM/TM may be sitting IN THE BAG with its teach step lost to a
|
|
-- desync (Victory Road's boulders once arrived with HM_STRENGTH
|
|
-- unowned; had it been owned-but-untaught this is the save). Teaching
|
|
-- here costs one menu trip; ops.teach no-ops cleanly when absent.
|
|
local item = TEACH_ITEMS[tostring(s.move or ""):lower()]
|
|
if item and (heldCount(item) or 0) > 0 then
|
|
say(("fieldMove %s: nobody knows it but %s is in the bag -- "
|
|
.. "teaching now"):format(move, item))
|
|
if ops.teach({ move = tostring(s.move or ""):lower() }, where) then
|
|
slot = slotKnowing(move)
|
|
end
|
|
end
|
|
end
|
|
if not slot then
|
|
-- DIG and FLY are TRANSPORT, and a transport step we cannot perform is
|
|
-- a walk we have not taken yet rather than a dead end. Segment 72 is
|
|
-- the case: `fieldMove dig` out of the Pokémon Fan Club is the whole of
|
|
-- the route's Vermilion -> Cerulean trip, and if TM_DIG never made it
|
|
-- into the bag this returned false, segments 73-81 all skipped against
|
|
-- CERULEAN_CITY, and the rewind looped over Vermilion forever -- with a
|
|
-- won gym behind it. Walking there is slower and always available.
|
|
if (move == "DIG" or move == "FLY") and nextMapWanted
|
|
and ow().map.id ~= nextMapWanted then
|
|
-- DIG out of a dungeon has an item equivalent we may be carrying:
|
|
-- the ESCAPE ROPE warps to the heal point exactly as DIG does. The
|
|
-- Mansion is why this matters: segment 162 ends `fieldMove dig`
|
|
-- and WALKING out instead runs into the switch-gate deadlock (the
|
|
-- 1F stairs room and the B1F return path want opposite states, so
|
|
-- the cross-floor statue press ping-pongs forever). The engine
|
|
-- refuses the rope on maps where it is illegal, so a failed use
|
|
-- falls through to the walk unharmed.
|
|
if move == "DIG" and (heldCount("ESCAPE_ROPE") or 0) > 0 then
|
|
if useFieldItem("ESCAPE_ROPE", where) then
|
|
local landed = ow().map.id
|
|
say(("fieldMove DIG: nobody knows it -- escape-roped to %s")
|
|
:format(tostring(landed)))
|
|
if tostring(landed):find("POKECENTER") then leaveByDoor(landed) end
|
|
if ow().map.id == nextMapWanted then return true end
|
|
end
|
|
end
|
|
say(("fieldMove %s: nobody knows it -- walking to %s instead")
|
|
:format(move, tostring(nextMapWanted)))
|
|
if travelTo(nextMapWanted, where) then return true end
|
|
end
|
|
note("fieldMove: nobody knows " .. move, where)
|
|
say(("fieldMove %s: nobody in the party knows it"):format(move))
|
|
return false
|
|
end
|
|
-- Field moves work from fainted users in Gen 1 (PartyMenu + partyKnows
|
|
-- no longer gate on hp > 0). No Poké Center detour needed here.
|
|
-- CUT acts on the tile the player is FACING (OverworldState:tryCut), and
|
|
-- a `goto` leaves us facing whatever direction we last stepped. Without
|
|
-- turning to the tree first the move fires into empty ground, the game
|
|
-- says "There isn't anything", and the rewind loop retries it forever.
|
|
-- So find the tree ourselves: a cuttable block is one listed in
|
|
-- field.cutTreeSwaps, on a cell that is not walkable.
|
|
if move == "CUT" then
|
|
local m = ow().map
|
|
local function cuttable(cx, cy)
|
|
if not m:inBounds(cx, cy) or m:isWalkableCell(cx, cy) then return false end
|
|
-- The same FACE gate as the engine (tryCut): a tree BLOCK is 2x2
|
|
-- cells and only the tree-tile half is cuttable -- facing the
|
|
-- fence/sign half answers "Nothing to CUT". Without this the scan
|
|
-- below picked whichever unwalkable half it met first: Route 9's
|
|
-- tree at (5,8) has the block's sign half at (4,9), DOWN is
|
|
-- scanned before RIGHT, and the run cut "the tree" twenty times
|
|
-- from (4,8) without ever opening the way east.
|
|
local ts = m.def and m.def.tileset
|
|
local tile = m:cellTile(cx, cy)
|
|
if not ((ts == "OVERWORLD" and tile == 0x3d)
|
|
or (ts == "GYM" and tile == 0x50)) then
|
|
return false
|
|
end
|
|
local block = m:blockAt(math.floor(cx / 2), math.floor(cy / 2))
|
|
for _, sw in ipairs(G.data.field.cutTreeSwaps or {}) do
|
|
if sw.before == block then return true end
|
|
end
|
|
return false
|
|
end
|
|
local p = ow().player
|
|
local facing
|
|
for _, d in ipairs({ { 0, 1, "down" }, { 0, -1, "up" },
|
|
{ -1, 0, "left" }, { 1, 0, "right" } }) do
|
|
if cuttable(p.cellX + d[1], p.cellY + d[2]) then
|
|
facing = d[3]
|
|
cutCellX, cutCellY = p.cellX + d[1], p.cellY + d[2]
|
|
break
|
|
end
|
|
end
|
|
if not facing then
|
|
note("fieldMove: no tree adjacent", where)
|
|
say(("fieldMove CUT: no cuttable tree next to (%d,%d) on %s")
|
|
:format(p.cellX, p.cellY, tostring(where)))
|
|
return false
|
|
end
|
|
faceDir(facing)
|
|
end
|
|
-- SURF mounts only when the FACING tile is water (ItemUseSurfboard --
|
|
-- else _NoSurfingHereText and the submenu loops), and a goto leaves us
|
|
-- facing wherever we last stepped. The route names the direction when
|
|
-- it knows it; otherwise face whichever neighbour is water.
|
|
if move == "SURF" then
|
|
local m, p = ow().map, ow().player
|
|
local facing = s.face
|
|
if not facing then
|
|
for _, d in ipairs({ { 0, 1, "down" }, { 0, -1, "up" },
|
|
{ -1, 0, "left" }, { 1, 0, "right" } }) do
|
|
if m:inBounds(p.cellX + d[1], p.cellY + d[2])
|
|
and m:isWaterCell(p.cellX + d[1], p.cellY + d[2]) then
|
|
facing = d[3] break
|
|
end
|
|
end
|
|
end
|
|
if not facing then
|
|
note("fieldMove: no water adjacent", where)
|
|
say(("fieldMove SURF: no water next to (%d,%d) on %s")
|
|
:format(p.cellX, p.cellY, tostring(where)))
|
|
return false
|
|
end
|
|
faceDir(facing)
|
|
end
|
|
|
|
-- A `teach` immediately precedes this in the route, and its bag/party
|
|
-- menus are still unwinding when we get here. Opening START on top of
|
|
-- that lands us somewhere else entirely, and the failure was silent --
|
|
-- Vermilion taught CUT and then walked into the tree because this step
|
|
-- did nothing at all, so the gym segment skipped.
|
|
mashUntilIdle()
|
|
press("start")
|
|
U.wait(8)
|
|
local menu = top()
|
|
local row
|
|
for i, it in ipairs(menu and menu.items or {}) do
|
|
if it.label == "POKéMON" then row = i break end
|
|
end
|
|
if not row or not cursorTo("index", row) then
|
|
note("fieldMove: no POKéMON row", where)
|
|
say(("fieldMove %s: the START menu never opened"):format(move))
|
|
backOut()
|
|
return false
|
|
end
|
|
press("a")
|
|
U.wait(10)
|
|
if not cursorTo("index", slot) then
|
|
note("fieldMove: no slot " .. slot, where)
|
|
say(("fieldMove %s: could not select party slot %d"):format(move, slot))
|
|
backOut()
|
|
return false
|
|
end
|
|
press("a")
|
|
U.wait(10)
|
|
-- The per-mon submenu (STATS / SWITCH / field moves) is NOT a separate
|
|
-- stack entry: PartyMenu keeps it on itself as .submenu / .subItems, with
|
|
-- its own cursor in .subIndex (src/ui/PartyMenu.lua:327,379). Reading
|
|
-- top().items here scans the party list instead and finds no CUT, which
|
|
-- looked exactly like the badge gate refusing -- it was not; the save had
|
|
-- CASCADEBADGE all along.
|
|
local pm = top()
|
|
if not (pm and pm.submenu and pm.subItems) then
|
|
note("fieldMove: no submenu", where)
|
|
say(("fieldMove %s: the party submenu never opened"):format(move))
|
|
backOut()
|
|
return false
|
|
end
|
|
local mrow
|
|
for i, it in ipairs(pm.subItems) do
|
|
if it.label == move then mrow = i break end
|
|
end
|
|
if not mrow then
|
|
local have = {}
|
|
for _, it in ipairs(pm.subItems) do have[#have + 1] = it.label end
|
|
note("fieldMove: no " .. move .. " entry", where)
|
|
say(("fieldMove %s: submenu offered %s"):format(move,
|
|
table.concat(have, "/")))
|
|
backOut()
|
|
return false
|
|
end
|
|
-- .subIndex, not .index -- the party cursor is a different field
|
|
if not cursorTo("subIndex", mrow) then
|
|
note("fieldMove: submenu cursor stuck", where)
|
|
say(("fieldMove %s: could not move the submenu cursor to row %d")
|
|
:format(move, mrow))
|
|
backOut()
|
|
return false
|
|
end
|
|
press("a")
|
|
U.wait(12)
|
|
mashUntilIdle()
|
|
-- CUT is verifiable: the faced tree cell must be walkable afterwards.
|
|
-- "used CUT" used to print regardless, so an engine refusal ("Nothing
|
|
-- to CUT") looked like success and the caller re-cut the same tree
|
|
-- for its whole budget.
|
|
if move == "CUT" and cutCellX then
|
|
local now = ow().map
|
|
if now:inBounds(cutCellX, cutCellY)
|
|
and not now:isWalkableCell(cutCellX, cutCellY) then
|
|
say(("fieldMove CUT: the tree at (%d,%d) is still standing -- the "
|
|
.. "cut did not land"):format(cutCellX, cutCellY))
|
|
return false
|
|
end
|
|
end
|
|
say(("used %s on %s"):format(move, tostring(where)))
|
|
|
|
-- DIG and FLY are the route's long-distance travel, and neither lands
|
|
-- where the route thinks. DIG warps to wLastBlackoutMap: the route takes
|
|
-- segment 72's `fieldMove dig` out of the Pokémon Fan Club expecting
|
|
-- Cerulean, because a speedrun never heals at Vermilion -- but ours does,
|
|
-- so the warp puts us straight back in Vermilion and segments 73-75 can
|
|
-- never run. Walk the rest of the way rather than trusting the warp.
|
|
if (move == "DIG" or move == "FLY") and nextMapWanted then
|
|
-- Give the warp a moment to land before reading where we are.
|
|
for _ = 1, 120 do
|
|
if idle() and not inBattle() then break end
|
|
if inBattle() then fightBattle() else mashUntilIdle() end
|
|
end
|
|
local here = ow().map.id
|
|
if here ~= nextMapWanted then
|
|
say(("%s landed on %s but the route wants %s -- travelling")
|
|
:format(move, tostring(here), tostring(nextMapWanted)))
|
|
travelTo(nextMapWanted, where)
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- Is this cell a cuttable tree? (A cutTreeSwaps block on an unwalkable
|
|
-- cell -- the same test ops.fieldMove uses to find one to face.)
|
|
local function cuttableCell(m, cx, cy)
|
|
if not m:inBounds(cx, cy) or m:isWalkableCell(cx, cy) then return false end
|
|
-- Mirror the engine's UsedCut gate (OverworldState:tryCut): CUT only
|
|
-- works on the OVERWORLD tree tile and the GYM plant tile. Block ids
|
|
-- are per-tileset, so matching them alone made every look-alike block
|
|
-- on other tilesets read as a tree -- the Saffron/Celadon bush-spam,
|
|
-- and a Route 23 (PLATEAU) "tree" whose swap crashed the renderer.
|
|
local ts = m.def and m.def.tileset
|
|
local tile = m:cellTile(cx, cy)
|
|
if not ((ts == "OVERWORLD" and tile == 0x3d)
|
|
or (ts == "GYM" and tile == 0x50)) then
|
|
return false
|
|
end
|
|
local block = m:blockAt(math.floor(cx / 2), math.floor(cy / 2))
|
|
for _, sw in ipairs(G.data.field.cutTreeSwaps or {}) do
|
|
if sw.before == block then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Reachability guard, so cutToward cannot recurse through ops.goto_.
|
|
local cutting = false
|
|
|
|
-- Cut a tree that is walling us off from (tx, ty).
|
|
--
|
|
-- Finds the trees on the frontier of where we can currently walk, picks the
|
|
-- one nearest the target, stands next to it and cuts. One tree per call --
|
|
-- the caller re-plans afterwards, so a pocket behind two trees opens over
|
|
-- two passes rather than needing a search over combinations.
|
|
function cutToward(tx, ty)
|
|
if cutting then return false end
|
|
if not slotKnowing("CUT") then return false end
|
|
local m, p = ow().map, ow().player
|
|
local W, H = m.widthCells, m.heightCells
|
|
-- where we can get to right now, NPCs included as walls exactly as
|
|
-- bfsNextKey sees them
|
|
local blockedBy = {}
|
|
-- only this map's NPCs; see bfsNextKey
|
|
for _, npc in ipairs(ow().npcs) do
|
|
if npc.cellX >= 0 and npc.cellY >= 0 and npc.cellX < W and npc.cellY < H then
|
|
blockedBy[npc.cellY * W + npc.cellX] = true
|
|
end
|
|
end
|
|
local function reach()
|
|
local pp = ow().player
|
|
local seen = { [pp.cellY * W + pp.cellX] = true }
|
|
local queue, head = { { pp.cellX, pp.cellY } }, 1
|
|
while head <= #queue do
|
|
local c = queue[head]; head = head + 1
|
|
for _, d in ipairs(DIRS) do
|
|
local nx, ny = c[1] + d[1], c[2] + d[2]
|
|
local id = ny * W + nx
|
|
if nx >= 0 and ny >= 0 and nx < W and ny < H and not seen[id]
|
|
and not blockedBy[id] and m:isWalkableCell(nx, ny) then
|
|
seen[id] = true
|
|
queue[#queue + 1] = { nx, ny }
|
|
elseif nx >= 0 and ny >= 0 and nx < W and ny < H
|
|
and not m:isWalkableCell(nx, ny) then
|
|
-- ledge hops reach cells this flood otherwise misses -- and
|
|
-- bfsNextKey WALKS them, so leaving them out makes the two
|
|
-- disagree: from ROUTE_9 (46,9) the regrown west tree's face is
|
|
-- only ledge-reachable, cutToward found "no tree", and
|
|
-- travelTo(CINNABAR_ISLAND) died -- taking Blaine, the 7-badge
|
|
-- Viridian Gym and the whole league gate with it.
|
|
local lx, ly = ledgeLanding(m, c[1], c[2], d[3], d[1], d[2])
|
|
if lx then
|
|
local lid = ly * W + lx
|
|
if not seen[lid] and not blockedBy[lid] then
|
|
seen[lid] = true
|
|
queue[#queue + 1] = { lx, ly }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return seen
|
|
end
|
|
-- nearest reachable cell's Manhattan distance to the target: the honest
|
|
-- measure of whether a cut helped at all
|
|
local function bestDist(set)
|
|
local bd
|
|
for id in pairs(set) do
|
|
local cx, cy = id % W, math.floor(id / W)
|
|
local d = math.abs(cx - tx) + math.abs(cy - ty)
|
|
if not bd or d < bd then bd = d end
|
|
end
|
|
return bd or math.huge
|
|
end
|
|
local seen = reach()
|
|
if seen[ty * W + tx] then return false end -- not a tree problem
|
|
local best, bestD, stand
|
|
for id in pairs(seen) do
|
|
local cx, cy = id % W, math.floor(id / W)
|
|
for _, d in ipairs(DIRS) do
|
|
local nx, ny = cx + d[1], cy + d[2]
|
|
-- skip trees this run has already proven useless for ANY target
|
|
-- twice over; they respawn on every map re-entry, so without the
|
|
-- memory the loop is: cut, fail, leave, return, cut... (the
|
|
-- "used CUT on ROUTE_12/ROUTE_10" x10 wedges)
|
|
if cuttableCell(m, nx, ny)
|
|
and (WD.futileCuts[m.id .. "#" .. nx .. "," .. ny] or 0) < 2 then
|
|
local dist = math.abs(nx - tx) + math.abs(ny - ty)
|
|
if not bestD or dist < bestD then
|
|
best, bestD, stand = { nx, ny }, dist, { cx, cy }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if not best then return false end
|
|
local beforeDist = bestDist(seen)
|
|
say(("goto (%d,%d) is walled off; cutting the tree at (%d,%d) from (%d,%d)")
|
|
:format(tx, ty, best[1], best[2], stand[1], stand[2]))
|
|
cutting = true
|
|
ops.goto_({ x = stand[1], y = stand[2] })
|
|
local ok = ops.fieldMove({ move = "cut" }, ow().map.id)
|
|
cutting = false
|
|
if not (ok and not cuttableCell(ow().map, best[1], best[2])) then
|
|
return false
|
|
end
|
|
-- The tree fell -- but did it HELP? A tree picked by straight-line
|
|
-- distance can be a pure red herring whose corridor goes somewhere
|
|
-- else entirely. Only count the cut as progress when the reachable
|
|
-- set actually got closer to the target; otherwise remember the tree
|
|
-- as futile so the next lap stops offering it.
|
|
if ow().map.id == m.id then
|
|
local after = reach()
|
|
if after[ty * W + tx] or bestDist(after) < beforeDist then return true end
|
|
local k = m.id .. "#" .. best[1] .. "," .. best[2]
|
|
WD.futileCuts[k] = (WD.futileCuts[k] or 0) + 1
|
|
say(("the tree at (%d,%d) fell but (%d,%d) is no closer%s")
|
|
:format(best[1], best[2], tx, ty,
|
|
WD.futileCuts[k] >= 2 and " -- writing that tree off" or ""))
|
|
return false
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- Open a Silph Co card-key door that is walling us off from (tx, ty).
|
|
--
|
|
-- The locked-door tile is not walkable and only opens on an A-press while
|
|
-- FACING it (OverworldState:tryCardKeyDoor, engine/events/card_key.asm),
|
|
-- so to BFS every closed door is a permanent wall. PokeBotBad opens them
|
|
-- by bonking A on its way through, so the route has no explicit
|
|
-- open-the-door steps -- without this rung, segment 148's walk to
|
|
-- Giovanni reported "goto (2,15) unreachable" and the whole Silph ending
|
|
-- was silently skipped (which is why the save had CARD_KEY, the beaten
|
|
-- 11F trainers, and no EVENT_BEAT_SILPH_CO_GIOVANNI).
|
|
--
|
|
-- Same contract as cutToward: one door per call, NPCs are walls, the
|
|
-- caller re-plans afterwards. Door positions come from
|
|
-- field.cardKeyDoors.doors (data/events/card_key.asm), so this is a
|
|
-- lookup, not a tile scan; a door whose tile has already swapped to the
|
|
-- open block is skipped.
|
|
local openingDoor = false
|
|
function openDoorToward(tx, ty)
|
|
if openingDoor then return false end
|
|
local ck = G.data.field.cardKeyDoors
|
|
local m = ow().map
|
|
local doors = ck and ck.doors and ck.doors[m.id]
|
|
if not doors then return false end
|
|
if not G.save.inventory.CARD_KEY then
|
|
-- Loud, not silent: this bail is the moment "the pickup failed on 5F"
|
|
-- becomes "the tower is a maze of walls", and it repeating is exactly
|
|
-- what the stuck detector should get to see and stop on.
|
|
say(("card-key door on %s is in the way but CARD_KEY is not in the bag")
|
|
:format(tostring(m.id)))
|
|
return false
|
|
end
|
|
local W, H = m.widthCells, m.heightCells
|
|
local p = ow().player
|
|
local blockedBy = {}
|
|
for _, npc in ipairs(ow().npcs) do
|
|
if npc.cellX >= 0 and npc.cellY >= 0 and npc.cellX < W and npc.cellY < H then
|
|
blockedBy[npc.cellY * W + npc.cellX] = true
|
|
end
|
|
end
|
|
local seen = { [p.cellY * W + p.cellX] = true }
|
|
local queue, head = { { p.cellX, p.cellY } }, 1
|
|
while head <= #queue do
|
|
local c = queue[head]; head = head + 1
|
|
for _, d in ipairs(DIRS) do
|
|
local nx, ny = c[1] + d[1], c[2] + d[2]
|
|
local id = ny * W + nx
|
|
if nx >= 0 and ny >= 0 and nx < W and ny < H and not seen[id]
|
|
and not blockedBy[id] and m:isWalkableCell(nx, ny) then
|
|
seen[id] = true
|
|
queue[#queue + 1] = { nx, ny }
|
|
end
|
|
end
|
|
end
|
|
if seen[ty * W + tx] then return false end -- not a door problem
|
|
local function closedDoor(dx, dy)
|
|
local tile = m:cellTile(dx, dy)
|
|
if m.id == "SILPH_CO_11F" then
|
|
return tile == ck.silphCo11F.doorTile
|
|
end
|
|
for _, t in ipairs(ck.doorTiles) do
|
|
if tile == t then return true end
|
|
end
|
|
return false
|
|
end
|
|
local best, bestD, stand, standFace
|
|
for _, d in ipairs(doors) do
|
|
if closedDoor(d.x, d.y) then
|
|
for _, dv in ipairs(DIRS) do
|
|
local ax, ay = d.x + dv[1], d.y + dv[2]
|
|
if seen[ay * W + ax] then
|
|
local dist = math.abs(d.x - tx) + math.abs(d.y - ty)
|
|
if not bestD or dist < bestD then
|
|
-- face the OPPOSITE of the offset that took us to the stand
|
|
-- cell: standing below the door means facing up
|
|
local face = (dv[3] == "up" and "down") or (dv[3] == "down" and "up")
|
|
or (dv[3] == "left" and "right") or "left"
|
|
best, bestD, stand, standFace = d, dist, { ax, ay }, face
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if not best then return false end
|
|
say(("goto (%d,%d) is behind a card-key door; opening the one at "
|
|
.. "(%d,%d) from (%d,%d)"):format(tx, ty, best.x, best.y,
|
|
stand[1], stand[2]))
|
|
openingDoor = true
|
|
ops.goto_({ x = stand[1], y = stand[2] })
|
|
local ok = false
|
|
local p2 = ow().player
|
|
if ow().map.id == m.id and p2.cellX == stand[1] and p2.cellY == stand[2] then
|
|
faceDir(standFace)
|
|
press("a")
|
|
U.wait(10)
|
|
mashUntilIdle() -- "Bingo! The CARD KEY opened the door!"
|
|
ok = not closedDoor(best.x, best.y)
|
|
end
|
|
openingDoor = false
|
|
if not ok then
|
|
say(("card-key door at (%d,%d) on %s did not open")
|
|
:format(best.x, best.y, m.id))
|
|
end
|
|
return ok
|
|
end
|
|
|
|
-- Press a Pokémon Mansion statue switch when its walls seal off (tx, ty).
|
|
--
|
|
-- One shared toggle, EVENT_MANSION_SWITCH_ON, opens/closes gate blocks on
|
|
-- all four floors (data/scripts/story6.lua MANSION_BLOCKS, from pokered's
|
|
-- Mansion*CheckReplaceSwitchDoorBlocks). The route flips it via its own
|
|
-- talk steps, but a missed talk or a re-planned walk leaves the floor in
|
|
-- the wrong state and every plan across a closed gate reads "no path" --
|
|
-- segment 162 sealed the bot at B1F (4,11) with the exit stairs behind a
|
|
-- gate, and travelTo burned every rewind against it.
|
|
--
|
|
-- Statue cells from story6.lua's mansionFloor registrations; the player
|
|
-- stands on the cell BELOW and faces up (all mansion switches face up).
|
|
-- The press is TextBox -> ChoiceBox(YES) -> TextBox, which one A plus
|
|
-- mashUntilIdle drives. One toggle per call: the caller re-plans, and if
|
|
-- the flipped state is still wrong the NEXT call flips it back -- states
|
|
-- alternate until the target's side is open.
|
|
local MANSION_SWITCHES = {
|
|
POKEMON_MANSION_1F = { { 2, 5 } },
|
|
POKEMON_MANSION_2F = { { 2, 11 } },
|
|
POKEMON_MANSION_3F = { { 10, 5 } },
|
|
POKEMON_MANSION_B1F = { { 20, 3 }, { 18, 25 } },
|
|
}
|
|
local togglingSwitch = false
|
|
|
|
-- Reachable set from where we stand, NPCs as walls (cutToward's fill).
|
|
local function mansionReach()
|
|
local m = ow().map
|
|
local W, H = m.widthCells, m.heightCells
|
|
local p = ow().player
|
|
local blockedBy = {}
|
|
for _, npc in ipairs(ow().npcs) do
|
|
if npc.cellX >= 0 and npc.cellY >= 0 and npc.cellX < W and npc.cellY < H then
|
|
blockedBy[npc.cellY * W + npc.cellX] = true
|
|
end
|
|
end
|
|
local seen = { [p.cellY * W + p.cellX] = true }
|
|
local queue, head = { { p.cellX, p.cellY } }, 1
|
|
while head <= #queue do
|
|
local c = queue[head]; head = head + 1
|
|
for _, d in ipairs(DIRS) do
|
|
local nx, ny = c[1] + d[1], c[2] + d[2]
|
|
local id = ny * W + nx
|
|
if nx >= 0 and ny >= 0 and nx < W and ny < H and not seen[id]
|
|
and not blockedBy[id] and m:isWalkableCell(nx, ny) then
|
|
seen[id] = true
|
|
queue[#queue + 1] = { nx, ny }
|
|
end
|
|
end
|
|
end
|
|
return seen, W
|
|
end
|
|
|
|
-- Press any statue on the CURRENT floor that we can reach. All mansion
|
|
-- switches are faced upward from the cell below.
|
|
local function pressStatueHere()
|
|
local m = ow().map
|
|
local switches = MANSION_SWITCHES[m.id]
|
|
if not switches then return false end
|
|
local seen, W = mansionReach()
|
|
for _, c in ipairs(switches) do
|
|
local sx, sy = c[1], c[2] + 1
|
|
if seen[sy * W + sx] then
|
|
ops.goto_({ x = sx, y = sy })
|
|
local p = ow().player
|
|
if ow().map.id == m.id and p.cellX == sx and p.cellY == sy then
|
|
faceDir("up")
|
|
press("a")
|
|
U.wait(10)
|
|
mashUntilIdle() -- "Press it?" -> YES -> "Who wouldn't?"
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Push a STRENGTH boulder that blocks the walk to (tx, ty).
|
|
--
|
|
-- PokeBotBad moves boulders by WALKING INTO them -- its Victory Road
|
|
-- waypoints path straight "through" a boulder, shoving it a cell at a
|
|
-- time -- but our BFS treats every NPC as a wall, so the route's pushes
|
|
-- never happened: segment 183's boulder sat at (5,15) untouched, the 1F
|
|
-- switch was never pressed, and 184-186 skipped against the closed
|
|
-- barrier forever.
|
|
--
|
|
-- One shove per call, the caller re-plans (the same contract as
|
|
-- cutToward). The boulder chosen is the one nearest the straight line to
|
|
-- the target; the push direction is the one whose landing cell is free
|
|
-- and moves the boulder toward/past the target side, preferring the axis
|
|
-- we approach on. STRENGTH is armed on demand (it clears on every map
|
|
-- load -- push_boulder.asm).
|
|
local pushingBoulder = false
|
|
function pushBoulderToward(tx, ty)
|
|
if pushingBoulder then return false end
|
|
if not slotKnowing("STRENGTH") then return false end
|
|
local m = ow().map
|
|
local p = ow().player
|
|
-- The boulder that matters is the one ON the waypoint (the route walks
|
|
-- straight through boulders, so its gotos name the boulder's own cell
|
|
-- while shoving it along) or, failing that, the one nearest the
|
|
-- TARGET. Nearest-to-player picked Victory Road 1F's far corner
|
|
-- boulder and shoved it around while the one the route was actually
|
|
-- pushing sat untouched.
|
|
local boulder, bestD
|
|
for _, npc in ipairs(ow().npcs) do
|
|
if tostring(npc.def and npc.def.sprite or ""):find("BOULDER") then
|
|
if npc.cellX == tx and npc.cellY == ty then
|
|
boulder, bestD = npc, 0
|
|
break
|
|
end
|
|
local d = math.abs(npc.cellX - tx) + math.abs(npc.cellY - ty)
|
|
if not bestD or d < bestD then boulder, bestD = npc, d end
|
|
end
|
|
end
|
|
if not boulder or bestD > 8 then return false end
|
|
local bx, by = boulder.cellX, boulder.cellY
|
|
pushingBoulder = true
|
|
local ok = false
|
|
if not ow().strengthActive then
|
|
ops.fieldMove({ move = "strength" }, ow().map.id)
|
|
end
|
|
-- candidate push directions: stand on the opposite side, landing free
|
|
local cands = {}
|
|
for _, d in ipairs(DIRS) do
|
|
local sx, sy = bx - d[1], by - d[2] -- stand cell
|
|
local lx, ly = bx + d[1], by + d[2] -- landing cell
|
|
if m:inBounds(sx, sy) and m:isWalkableCell(sx, sy)
|
|
and not ow():npcAtCell(sx, sy)
|
|
and m:inBounds(lx, ly) and m:isWalkableCell(lx, ly)
|
|
and not ow():npcAtCell(lx, ly) then
|
|
-- score: prefer the direction that carries the boulder toward the
|
|
-- target (or onto it), and the stand cell being where we already are
|
|
local score = 0
|
|
local dNow = math.abs(bx - tx) + math.abs(by - ty)
|
|
local dAfter = math.abs(lx - tx) + math.abs(ly - ty)
|
|
if dAfter < dNow then score = score + 2 end
|
|
if p.cellX == sx and p.cellY == sy then score = score + 1 end
|
|
cands[#cands + 1] = { d = d, sx = sx, sy = sy, score = score }
|
|
end
|
|
end
|
|
table.sort(cands, function(a, b) return a.score > b.score end)
|
|
for _, c in ipairs(cands) do
|
|
local reached = p.cellX == c.sx and p.cellY == c.sy
|
|
if not reached then
|
|
ops.goto_({ x = c.sx, y = c.sy })
|
|
local q = ow().player
|
|
reached = ow().map.id == m.id and q.cellX == c.sx and q.cellY == c.sy
|
|
end
|
|
if reached then
|
|
say(("pushing the boulder at (%d,%d) %s (toward (%d,%d))")
|
|
:format(bx, by, c.d[3], tx, ty))
|
|
for _ = 1, 8 do
|
|
faceDir(c.d[3])
|
|
walk(c.d[3], 12)
|
|
if not ow():npcAtCell(bx, by) then ok = true break end
|
|
U.wait(4)
|
|
end
|
|
break
|
|
end
|
|
end
|
|
pushingBoulder = false
|
|
return ok
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Victory Road boulder switches, solved DIRECTLY.
|
|
--
|
|
-- The route moves boulders by walking through them (PokeBotBad's
|
|
-- collision model lets it), and reproducing that shove-by-shove through
|
|
-- the goto ladder proved fragile: one wrong first push and the whole
|
|
-- choreography is dead, the switch never presses, and the floor's
|
|
-- barrier block stays down while segments 184+ skip against it.
|
|
--
|
|
-- These puzzles are static, so plan them outright: a single-boulder
|
|
-- sokoban BFS over (boulder cell, player region) that returns the shove
|
|
-- list onto the switch cell, executed one stand-face-push at a time.
|
|
-- Switch cells and barrier flags from data/scripts/story.lua
|
|
-- (scripts/VictoryRoad1F/2F/3F.asm); the 3F hole at (23,15) drops a
|
|
-- boulder down beside 2F's second switch, which is where its boulder
|
|
-- comes from.
|
|
-- ---------------------------------------------------------------------
|
|
|
|
VR_SWITCHES = {
|
|
VICTORY_ROAD_1F = {
|
|
{ flag = "EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH", x = 17, y = 13 },
|
|
},
|
|
VICTORY_ROAD_2F = {
|
|
{ flag = "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1", x = 1, y = 16 },
|
|
{ flag = "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2", x = 9, y = 16 },
|
|
},
|
|
VICTORY_ROAD_3F = {
|
|
{ flag = "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1", x = 3, y = 5 },
|
|
-- the hole: press only while 2F's dropped boulder has not appeared
|
|
{ hole = true, x = 23, y = 15 },
|
|
},
|
|
}
|
|
|
|
-- Shove plan for one boulder onto (tx, ty): BFS over boulder positions,
|
|
-- with the player's reachable region (boulder + other NPCs as walls)
|
|
-- deciding which sides can be pushed from. Returns { {sx,sy,dir}, ... }.
|
|
local function planShoves(m, boulder, tx, ty)
|
|
local W, H = m.widthCells, m.heightCells
|
|
local walls = {}
|
|
for _, npc in ipairs(ow().npcs) do
|
|
if npc ~= boulder and npc.cellX >= 0 and npc.cellY >= 0
|
|
and npc.cellX < W and npc.cellY < H then
|
|
walls[npc.cellY * W + npc.cellX] = true
|
|
end
|
|
end
|
|
local function open(x, y, bx, by)
|
|
if x < 0 or y < 0 or x >= W or y >= H then return false end
|
|
if x == bx and y == by then return false end
|
|
local id = y * W + x
|
|
return not walls[id] and m:isWalkableCell(x, y)
|
|
end
|
|
-- reachable-region representative for the player with the boulder at
|
|
-- (bx,by): the smallest cell id in the flood fill from (px,py)
|
|
local function regionRep(px, py, bx, by)
|
|
local seen = { [py * W + px] = true }
|
|
local q, h, rep = { { px, py } }, 1, py * W + px
|
|
while h <= #q do
|
|
local c = q[h]; h = h + 1
|
|
for _, d in ipairs(DIRS) do
|
|
local nx, ny = c[1] + d[1], c[2] + d[2]
|
|
local id = ny * W + nx
|
|
-- the player's steps respect tile-pair (elevation) collisions;
|
|
-- the BOULDER's landing does not (checkBoulderPush never checks
|
|
-- pairs), so only this flood fill filters on them
|
|
if not seen[id] and open(nx, ny, bx, by)
|
|
and not pairBlockedStep(m, c[1], c[2], nx, ny, false) then
|
|
seen[id] = true
|
|
if id < rep then rep = id end
|
|
q[#q + 1] = { nx, ny }
|
|
end
|
|
end
|
|
end
|
|
return rep, seen
|
|
end
|
|
local p = ow().player
|
|
local startRep, startSeen = regionRep(p.cellX, p.cellY, boulder.cellX, boulder.cellY)
|
|
local start = { bx = boulder.cellX, by = boulder.cellY, rep = startRep,
|
|
seen = startSeen, path = {} }
|
|
local visited = { [start.bx .. "," .. start.by .. "," .. start.rep] = true }
|
|
local queue, head = { start }, 1
|
|
while head <= #queue do
|
|
local n = queue[head]; head = head + 1
|
|
if n.bx == tx and n.by == ty then return n.path end
|
|
if #n.path < 60 then
|
|
for _, d in ipairs(DIRS) do
|
|
local sx, sy = n.bx - d[1], n.by - d[2]
|
|
local lx, ly = n.bx + d[1], n.by + d[2]
|
|
if open(lx, ly, n.bx, n.by) and n.seen[sy * W + sx] then
|
|
local rep2, seen2 = regionRep(n.bx, n.by, lx, ly)
|
|
local key = lx .. "," .. ly .. "," .. rep2
|
|
if not visited[key] then
|
|
visited[key] = true
|
|
local path = {}
|
|
for i, s in ipairs(n.path) do path[i] = s end
|
|
path[#path + 1] = { sx = sx, sy = sy, dir = d[3] }
|
|
queue[#queue + 1] = { bx = lx, by = ly, rep = rep2,
|
|
seen = seen2, path = path }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- The solver's stand-cell walks must NEVER leave the floor. ops.goto_'s
|
|
-- unreachable ladder (arriveByWarp, reEnterThroughGate, the surf mount)
|
|
-- deliberately warps/walks off the map and can report the goto "done"
|
|
-- from elsewhere -- which is how three consecutive runs went from
|
|
-- "18 shove(s) ... (attempt 1)" to standing on ROUTE_23 with no
|
|
-- shove-failure line and no attempt-2 line: a stand cell read
|
|
-- unreachable for a moment (the boulder itself, a wanderer, a stale
|
|
-- refused cell) and the ladder marched the bot out the entrance. The
|
|
-- floor and every path are fully known at plan time, so walk with plain
|
|
-- BFS and FAIL FAST; a false return sends the solver back to re-plan
|
|
-- from live state, which is cheap. bfsNextKey never paths through a
|
|
-- warp cell, so this walker cannot step out of the cave by accident.
|
|
local function solverWalkTo(tx, ty)
|
|
local m = ow().map
|
|
local refused = {}
|
|
local stuck = 0
|
|
for _ = 1, 300 do
|
|
if ow().map.id ~= m.id then return false end
|
|
local p = ow().player
|
|
if p.cellX == tx and p.cellY == ty then
|
|
if not p.moving then return true end
|
|
U.wait(2)
|
|
elseif inBattle() then
|
|
fightBattle()
|
|
elseif busy() then
|
|
mashUntilIdle()
|
|
else
|
|
local key = bfsNextKey(tx, ty, refused)
|
|
if not key then
|
|
-- a wanderer parked on the path (or the target) may step off;
|
|
-- give it a moment, then hand the failure to the solver's retry
|
|
stuck = stuck + 1
|
|
if stuck >= 4 then return false end
|
|
U.wait(30)
|
|
elseif walk(key) then
|
|
stuck = 0
|
|
else
|
|
-- the engine refused the step: plan around that cell, never
|
|
-- escalate
|
|
local dv = DELTA[key]
|
|
if dv then
|
|
refused[(p.cellY + dv[2]) * m.widthCells + (p.cellX + dv[1])] = true
|
|
end
|
|
stuck = stuck + 1
|
|
if stuck >= 8 then return false end
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Solve every unpressed switch on the current Victory Road floor.
|
|
local solvingBoulders = false
|
|
function solveVictoryRoadSwitches()
|
|
if solvingBoulders then return end
|
|
local m = ow().map
|
|
local entries = VR_SWITCHES[m.id]
|
|
if not entries then return end
|
|
solvingBoulders = true
|
|
-- the solver's own gotos must not trigger the ladder's ad-hoc shover
|
|
pushingBoulder = true
|
|
for _, sw in ipairs(entries) do
|
|
local needed
|
|
if sw.hole then
|
|
local t = (G.save.objectToggles or {}).VICTORY_ROAD_2F
|
|
needed = not (t and t.VICTORYROAD2F_BOULDER)
|
|
else
|
|
needed = not G.save.flags[sw.flag]
|
|
end
|
|
if needed then
|
|
-- A trainer battle or a wanderer can interrupt the execution and
|
|
-- leave everything mid-pose (the first live run fought two
|
|
-- COOLTRAINERs during shove 1 and drifted out the entrance), so
|
|
-- plan-and-execute is retried from LIVE state a few times; the
|
|
-- plan is cheap and the switch flag tells us when we are done.
|
|
for attempt = 1, 3 do
|
|
if ow().map.id ~= m.id then break end
|
|
-- try each boulder; the puzzles have exactly one that works
|
|
local plan
|
|
for _, npc in ipairs(ow().npcs) do
|
|
if tostring(npc.def and npc.def.sprite or ""):find("BOULDER") then
|
|
plan = planShoves(m, npc, sw.x, sw.y)
|
|
if plan then break end
|
|
end
|
|
end
|
|
if not plan then
|
|
say(("victory road: no boulder can reach (%d,%d) on %s")
|
|
:format(sw.x, sw.y, m.id))
|
|
break
|
|
end
|
|
say(("victory road: %d shove(s) to put the boulder on (%d,%d)"
|
|
.. " (attempt %d)"):format(#plan, sw.x, sw.y, attempt))
|
|
if not ow().strengthActive then
|
|
ops.fieldMove({ move = "strength" }, m.id)
|
|
end
|
|
local clean = true
|
|
for i, shove in ipairs(plan) do
|
|
local done = false
|
|
solverWalkTo(shove.sx, shove.sy)
|
|
local q = ow().player
|
|
if ow().map.id == m.id and q.cellX == shove.sx and q.cellY == shove.sy then
|
|
local dv = DELTA[shove.dir]
|
|
local bx, by = shove.sx + dv[1], shove.sy + dv[2]
|
|
local lx, ly = bx + dv[1], by + dv[2]
|
|
faceDir(shove.dir)
|
|
-- Press INTO the boulder one frame at a time and STOP the
|
|
-- instant it starts moving. A held key chain-pushes -- the
|
|
-- first bump arms (BIT_TRIED_PUSH_BOULDER), the next frame
|
|
-- pushes, and if we keep holding, the player follows the
|
|
-- boulder in and shoves it again -- which scatters it cells
|
|
-- past where the plan wants it. (The arm survives released
|
|
-- frames; it only resets when a move attempt finds no
|
|
-- boulder in front.)
|
|
for _ = 1, 120 do
|
|
table.insert(G.input.pressQueue, shove.dir)
|
|
G.input.state[shove.dir] = true
|
|
coroutine.yield()
|
|
G.input.state[shove.dir] = false
|
|
if inBattle() then fightBattle() faceDir(shove.dir) end
|
|
if not ow():npcAtCell(bx, by) then break end
|
|
end
|
|
-- let the slide + dust finish, and confirm it landed where
|
|
-- the plan expects (not chained past it)
|
|
for _ = 1, 90 do
|
|
if not busy() and ow():npcAtCell(lx, ly) then break end
|
|
U.wait(2)
|
|
end
|
|
done = ow():npcAtCell(lx, ly) ~= nil
|
|
and not ow():npcAtCell(bx, by)
|
|
end
|
|
if not done then
|
|
say(("victory road: shove %d/%d (%s from (%d,%d)) did not move "
|
|
.. "the boulder -- re-planning"):format(i, #plan, shove.dir,
|
|
shove.sx, shove.sy))
|
|
clean = false
|
|
break
|
|
end
|
|
end
|
|
-- drifted out of the cave mid-solve: come back before retrying
|
|
if ow().map.id ~= m.id then
|
|
local back
|
|
for _, w in ipairs(ow().map.def.warps or {}) do
|
|
if tostring(w.destMap or "") == m.id then back = w break end
|
|
end
|
|
if back then walkOntoWarp(back.x, back.y) end
|
|
if ow().map.id ~= m.id then break end
|
|
end
|
|
local pressed
|
|
if sw.hole then
|
|
local t = (G.save.objectToggles or {}).VICTORY_ROAD_2F
|
|
pressed = t and t.VICTORYROAD2F_BOULDER
|
|
else
|
|
pressed = G.save.flags[sw.flag]
|
|
end
|
|
if pressed then
|
|
say(("victory road: switch at (%d,%d) pressed"):format(sw.x, sw.y))
|
|
break
|
|
end
|
|
if clean then
|
|
-- executed fully yet no flag: geometry bug, stop -- and say so,
|
|
-- because a silent return here reads as "the solver never came
|
|
-- back" (three runs were mis-diagnosed exactly that way)
|
|
say(("victory road: all shoves landed yet (%d,%d) never set its "
|
|
.. "flag -- geometry bug, giving up"):format(sw.x, sw.y))
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
pushingBoulder = false
|
|
solvingBoulders = false
|
|
end
|
|
|
|
function toggleMansionSwitch(tx, ty)
|
|
if togglingSwitch then return false end
|
|
local m = ow().map
|
|
if not MANSION_SWITCHES[m.id] then return false end
|
|
local seen, W = mansionReach()
|
|
if seen[ty * W + tx] then return false end -- not a switch problem
|
|
-- Futility cap, same lesson as cutToward: the 1F stairs room and the
|
|
-- B1F return path want OPPOSITE gate states, so chasing one target
|
|
-- through the switches ping-pongs forever ("no reachable switch on
|
|
-- 1F; pressing one on B1F instead" x10). Three toggles without the
|
|
-- target opening IS the deadlock -- fail fast so travelTo can conclude
|
|
-- "no way there" and take the escape rope out.
|
|
local fk = m.id .. "#" .. tx .. "," .. ty
|
|
WD.futileSwitches[fk] = (WD.futileSwitches[fk] or 0) + 1
|
|
if WD.futileSwitches[fk] > 3 then return false end
|
|
togglingSwitch = true
|
|
say(("goto (%d,%d) is sealed by the mansion gates; looking for a switch")
|
|
:format(tx, ty))
|
|
local ok = pressStatueHere()
|
|
if not ok then
|
|
-- No statue on this side of the gates. The 1F basement-stairs room is
|
|
-- the case: its only statue sits OUTSIDE the room, but the B1F stairs
|
|
-- are inside it -- go down, press one of B1F's two statues, and come
|
|
-- back up. (A vanilla player stuck there does exactly this.)
|
|
for _, w in ipairs(m.def.warps or {}) do
|
|
local dm = tostring(w.destMap or "")
|
|
if MANSION_SWITCHES[dm] and seen[w.y * W + w.x] then
|
|
local fromMap = m.id
|
|
say(("no reachable switch on %s; pressing one on %s instead")
|
|
:format(fromMap, dm))
|
|
if walkOntoWarp(w.x, w.y) and MANSION_SWITCHES[ow().map.id]
|
|
and ow().map.id ~= fromMap then
|
|
ok = pressStatueHere()
|
|
-- back through the stairs we came down
|
|
local back
|
|
for _, w2 in ipairs(ow().map.def.warps or {}) do
|
|
if tostring(w2.destMap or "") == fromMap then back = w2 break end
|
|
end
|
|
if back then walkOntoWarp(back.x, back.y) end
|
|
ok = ok and ow().map.id == fromMap
|
|
end
|
|
break
|
|
end
|
|
end
|
|
end
|
|
togglingSwitch = false
|
|
return ok
|
|
end
|
|
|
|
-- Push a Strength boulder (Victory Road's four, segments 183-186).
|
|
--
|
|
-- Two things here are easy to get wrong, both from push_boulder.asm
|
|
-- (mirrored in OverworldState:checkBoulderPush):
|
|
--
|
|
-- * The FIRST walk into a boulder only ARMS the push
|
|
-- (BIT_TRIED_PUSH_BOULDER); the second one moves it. A single held walk
|
|
-- looks like a wall and reports failure.
|
|
-- * BIT_STRENGTH_ACTIVE lives in wStatusFlags1 and is cleared on EVERY map
|
|
-- load, so it is re-armed per map rather than once per run.
|
|
function ops.push(s, where)
|
|
local dir = tostring(s.face or "")
|
|
if dir == "" then
|
|
note("push: no direction", where)
|
|
return false
|
|
end
|
|
if not ow().strengthActive then
|
|
if not ops.fieldMove({ move = "strength" }, where) then
|
|
note("push: could not activate STRENGTH", where)
|
|
say("push: STRENGTH would not activate")
|
|
return false
|
|
end
|
|
end
|
|
faceDir(dir)
|
|
local d = DELTA[dir]
|
|
if not d then return false end
|
|
local p = ow().player
|
|
local bx, by = p.cellX + d[1], p.cellY + d[2]
|
|
local boulder = ow():npcAtCell(bx, by)
|
|
if not boulder then
|
|
note("push: no boulder to push", where)
|
|
say(("push %s: nothing pushable at (%d,%d) on %s")
|
|
:format(dir, bx, by, ow().map.id))
|
|
return false
|
|
end
|
|
for _ = 1, 8 do
|
|
faceDir(dir)
|
|
walk(dir, 12)
|
|
if not ow():npcAtCell(bx, by) then
|
|
say(("pushed the boulder %s on %s"):format(dir, ow().map.id))
|
|
return true
|
|
end
|
|
U.wait(4)
|
|
end
|
|
note("push: boulder would not move", where)
|
|
say(("push %s: the boulder at (%d,%d) did not move"):format(dir, bx, by))
|
|
return false
|
|
end
|
|
|
|
-- Mount SURF at the nearest shore cell, so an over-water target becomes
|
|
-- plannable. Only fires when the party can actually surf (SURF known --
|
|
-- taught at Silph 10F -- and SOULBADGE held); returns true once
|
|
-- player.surfing is up, after which bfsNextKey's passableCell opens the
|
|
-- water and the caller's re-plan does the rest. The Pallet -> Route 21 ->
|
|
-- Cinnabar corridor is why this exists: travelTo could plan the seams but
|
|
-- nothing ever stepped OFF the beach.
|
|
local mountingSurf = false
|
|
function mountSurfToward(tx, ty)
|
|
if mountingSurf then return false end
|
|
local p = ow().player
|
|
if p.surfing then return false end -- already up; not the problem
|
|
-- note(), NOT say(): pre-Surf the goto rescue chain reaches this rung
|
|
-- constantly (more since cutToward got honest), and ten identical say
|
|
-- lines inside one segment read as a stuck loop to the watchdog -- one
|
|
-- run lost all five attempts to that false positive on ordinary
|
|
-- Vermilion gotos.
|
|
if not slotKnowing("SURF") then
|
|
note("mountSurf: SURF unknown", ow().map.id)
|
|
return false
|
|
end
|
|
if not (G.save.inventory or {}).SOULBADGE then
|
|
note("mountSurf: no SOULBADGE", ow().map.id)
|
|
return false
|
|
end
|
|
mountingSurf = true
|
|
local m = ow().map
|
|
-- water tilesets only (water_tilesets.asm); a cave with no water would
|
|
-- scan every cell for nothing
|
|
local seen, W = reachableSet()
|
|
-- The shore to mount from is the reachable land-beside-water cell
|
|
-- nearest the TARGET, with the player's own distance only breaking
|
|
-- ties. Nearest-to-player was a trap on Route 20's mid-island strip:
|
|
-- a rock wall splits the strip's shores, and the bot re-mounted on
|
|
-- the west shore every time, surfed back over ground it had already
|
|
-- crossed, dismounted in the same spot and burned the whole mount
|
|
-- budget without ever entering the water that leads east.
|
|
local best, bestD
|
|
for cellId in pairs(seen) do
|
|
local cx, cy = cellId % W, math.floor(cellId / W)
|
|
for _, d in ipairs(DIRS) do
|
|
local wx, wy = cx + d[1], cy + d[2]
|
|
if m:inBounds(wx, wy) and m:isWaterCell(wx, wy) then
|
|
local dist = (math.abs(cx - tx) + math.abs(cy - ty)) * 4
|
|
+ math.abs(cx - p.cellX) + math.abs(cy - p.cellY)
|
|
if not bestD or dist < bestD then best, bestD = { cx, cy }, dist end
|
|
break
|
|
end
|
|
end
|
|
end
|
|
if not best then mountingSurf = false return false end
|
|
-- The same shore toward the same target, over and over, is the dead
|
|
-- pond trap: Vermilion's harbor pool is the closest-to-target water
|
|
-- the flood can see, but it connects to nothing, so the run re-mounted
|
|
-- there until the watchdog killed it ("used SURF on VERMILION_CITY"
|
|
-- x10). A couple of repeats are honest re-plans; more means this water
|
|
-- goes nowhere useful -- refuse and fail fast so the caller can ban
|
|
-- the hop instead.
|
|
local mk = m.id .. "#" .. best[1] .. "," .. best[2] .. ">" .. tx .. "," .. ty
|
|
WD.futileMounts[mk] = (WD.futileMounts[mk] or 0) + 1
|
|
if WD.futileMounts[mk] > 3 then
|
|
say(("mountSurf: refusing the same shore (%d,%d) toward (%d,%d) again")
|
|
:format(best[1], best[2], tx, ty))
|
|
mountingSurf = false
|
|
return false
|
|
end
|
|
if not ops.goto_({ x = best[1], y = best[2] }) then
|
|
mountingSurf = false
|
|
return false
|
|
end
|
|
say(("land ran out on the way to (%d,%d) -- surfing from (%d,%d)")
|
|
:format(tx, ty, best[1], best[2]))
|
|
ops.fieldMove({ move = "surf" }, ow().map.id)
|
|
mountingSurf = false
|
|
return ow().player.surfing == true
|
|
end
|
|
|
|
-- FLY between towns -- by walking, because we can never actually Fly.
|
|
--
|
|
-- HM02 is picked up at segment 104 (ROUTE_16_FLY_HOUSE) and taught at 105,
|
|
-- but the route means it for a bird we never catch: our party is the
|
|
-- starter plus a NIDORAN line, and WARTORTLE's tmhm list has no FLY. So
|
|
-- `teach fly` legitimately fails and the move is never available.
|
|
--
|
|
-- Leaving this a stub was quietly fatal rather than merely slow. Segment
|
|
-- 105 flies to LAVENDER_TOWN, so the stub left us standing on ROUTE_16 and
|
|
-- segments 106-114 -- the ENTIRE Pokémon Tower arc, which is where Mr. Fuji
|
|
-- hands over the POKE_FLUTE -- all skipped. The run then walked into the
|
|
-- Route 16 SNORLAX with no flute, could not pass, and thrashed until it ran
|
|
-- out of rewinds. One stubbed op, sixty segments of consequences.
|
|
--
|
|
-- The step names its destination (dest=lavender, map=4), and `map` is a
|
|
-- Fly-menu index rather than a map id, so the next segment's map is the
|
|
-- reliable target.
|
|
local FLY_DESTS = {
|
|
pallet = "PALLET_TOWN", viridian = "VIRIDIAN_CITY", pewter = "PEWTER_CITY",
|
|
cerulean = "CERULEAN_CITY", lavender = "LAVENDER_TOWN",
|
|
vermilion = "VERMILION_CITY", celadon = "CELADON_CITY",
|
|
fuchsia = "FUCHSIA_CITY", cinnabar = "CINNABAR_ISLAND",
|
|
saffron = "SAFFRON_CITY",
|
|
}
|
|
|
|
function ops.fly(s, where)
|
|
local dest = FLY_DESTS[tostring(s.dest or ""):lower()] or nextMapWanted
|
|
if not dest then
|
|
note("fly: no destination", where)
|
|
return false
|
|
end
|
|
if ow().map.id == dest then return true end
|
|
say(("fly to %s: walking there instead"):format(dest))
|
|
-- CINNABAR is an ISLAND: every approach is by sea. Left to itself the
|
|
-- planner hunts a land bridge across the northern half of the map and
|
|
-- micro-loops in Saffron's houses. Stage through FUCHSIA -- a proven
|
|
-- land trip -- and from there the shortest plan is the Route 19/20
|
|
-- water corridor, which crossSeam can now surf.
|
|
if dest == "CINNABAR_ISLAND"
|
|
and ow().map.id ~= "FUCHSIA_CITY"
|
|
and not tostring(ow().map.id):find("ROUTE_19")
|
|
and not tostring(ow().map.id):find("ROUTE_20") then
|
|
say("staging through FUCHSIA_CITY for the sea route")
|
|
travelTo("FUCHSIA_CITY", where)
|
|
end
|
|
if travelTo(dest, where) then return true end
|
|
note("fly: could not reach " .. dest, where)
|
|
return false
|
|
end
|
|
|
|
for _, name in ipairs({ "swapItem",
|
|
"swapMove" }) do
|
|
ops[name] = function(s, where)
|
|
note(name, where)
|
|
return true
|
|
end
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- catching
|
|
-- ---------------------------------------------------------------------
|
|
|
|
-- Whether the run is over: nothing left standing. Blacking out revives the
|
|
-- party at the heal point, so this is checked while the battle that did it
|
|
-- is still resolving, before the revive lands. (Defined here rather than
|
|
-- with the runner below because catchWild watches for it too.)
|
|
local function wiped()
|
|
local party = G.save and G.save.party or {}
|
|
if #party == 0 then return false end
|
|
for _, mon in ipairs(party) do
|
|
if (mon.hp or 0) > 0 then return false end
|
|
end
|
|
return true
|
|
end
|
|
--
|
|
-- The route is built around a second Pokémon. Segment 16 buys 10 POKE_BALLs
|
|
-- for exactly this, segment 18 catches a Nidoran on Route 22, and Mt. Moon
|
|
-- evolves it into the Nidoking that every later TM is aimed at (thrash,
|
|
-- thunderbolt, horn drill, rock slide, ice beam). Skipping the catch left a
|
|
-- lone starter carrying a route balanced for two -- no switch, no revive --
|
|
-- which is what kept killing us in Viridian Forest.
|
|
|
|
local BALL_ORDER = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL" }
|
|
|
|
local function ballInBag()
|
|
for _, id in ipairs(BALL_ORDER) do
|
|
if (heldCount(id) or 0) > 0 then return id end
|
|
end
|
|
end
|
|
|
|
-- Throw one ball at the current wild encounter.
|
|
--
|
|
-- Drives the real menus (battle menu -> ITEM -> the ball) the way ops.heal
|
|
-- does, rather than calling BattleState:throwBall, so a broken bag fails the
|
|
-- run instead of being quietly bypassed. In battle the bag has no USE/TOSS
|
|
-- submenu (BagMenu.lua:359), so selecting a ball throws it outright.
|
|
local function throwBall(battle, ball, where)
|
|
local function failed(stage)
|
|
local t = top()
|
|
say(("catch: throw failed at %s on %s (top=%s, rows=%d, phase=%s)"):format(
|
|
stage, tostring(where),
|
|
(isList() and "list") or (isMenu() and "menu") or (t and "other")
|
|
or "nothing",
|
|
rows() and #rows() or 0, tostring(battle.phase)))
|
|
end
|
|
-- ITEM is index 3 of the 2x2 action grid (fight/pkmn/item/run)
|
|
if not battleMenuTo(battle, 3) then failed("the ITEM slot") return false end
|
|
press("a")
|
|
if not waitFor(isList, 40) then failed("opening the bag") return false end
|
|
local idx
|
|
for i, row in ipairs(rows()) do
|
|
if row.value == ball then idx = i break end
|
|
end
|
|
if not idx then failed("finding " .. ball .. " in the bag") return false end
|
|
if not cursorTo("index", idx) then failed("the bag cursor") return false end
|
|
-- in battle the bag has no USE/TOSS submenu, so A throws it outright
|
|
press("a")
|
|
U.wait(10)
|
|
return true
|
|
end
|
|
|
|
-- How long to pace for a target before accepting we are not getting one.
|
|
-- Route 22 grass is a 25/256 encounter rate with Nidoran on 4 of 10 slots,
|
|
-- so this is many times the expected wait; it exists to end the segment,
|
|
-- not to be reached.
|
|
local CATCH_MAX_STEPS = 300
|
|
-- Hard ceiling on outer-loop passes regardless of which branch they take,
|
|
-- so a wedged battle cannot pin the run (see the loop below).
|
|
local CATCH_MAX_ITERATIONS = 1200
|
|
|
|
-- Only walking counts toward CATCH_MAX_STEPS, so a wait that never resolves
|
|
-- would spin forever. This bounds it.
|
|
local MAX_CATCH_SPINS = 400
|
|
|
|
-- Pace for wild encounters and spend balls on anything in `targets`.
|
|
--
|
|
-- Non-targets are fought rather than fled: we are underlevelled precisely
|
|
-- because the bot flees, and this is grass we are standing in anyway.
|
|
--
|
|
-- The catch WEAKENS first (weakenMoveIndex), then throws. This used to
|
|
-- throw at full HP on the reasoning that NIDORAN's catch rate is 235 and we
|
|
-- hold ten balls, so softening it only risked killing the target. That
|
|
-- holds for NIDORAN and not for the second catch: ODDISH is catch rate 45,
|
|
-- and a full-HP run at it spent six balls without ever sticking a shake.
|
|
-- Gen 1's formula scales with missing HP, so a safe hit is worth far more
|
|
-- than an extra ball -- and weakenMoveIndex only offers a move that leaves
|
|
-- the target alive with a margin, falling back to throwing when no move is
|
|
-- safe, so the original worry is handled rather than ignored.
|
|
local function catchWild(targets, where)
|
|
targets = targets or catchTarget
|
|
if not targets or #targets == 0 then
|
|
note("catch: no target species", where)
|
|
return true
|
|
end
|
|
local want = {}
|
|
for _, s in ipairs(targets) do want[s] = true end
|
|
local startSize = #party()
|
|
local steps, thrown, spins, weakened = 0, 0, 0, 0
|
|
local seen = {} -- species -> encounters, so a failed catch says what showed up
|
|
local ball = ballInBag()
|
|
-- No balls means no catch, and pacing the grass for 300 steps to discover
|
|
-- that only feeds the encounters we cannot do anything with. Say so and
|
|
-- move on -- an empty bag here points at the shop stop, not at this step.
|
|
if not ball then
|
|
note("catch: no balls in the bag", where)
|
|
say(("catching %s on %s: NO BALLS -- check the shop stop that should "
|
|
.. "have stocked them"):format(table.concat(targets, "/"), tostring(where)))
|
|
return true
|
|
end
|
|
say(("catching %s on %s (party %d, %s x%d)"):format(
|
|
table.concat(targets, "/"), tostring(where), startSize,
|
|
ball, heldCount(ball) or 0))
|
|
|
|
-- Bound the LOOP, not just the walking.
|
|
--
|
|
-- `steps` is the only bound the outer loop had, and it is incremented in
|
|
-- exactly one branch -- the one that paces the grass. A battle that never
|
|
-- resolves therefore re-enters the inBattle branch forever with `steps`
|
|
-- frozen, and CATCH_MAX_STEPS can never trip. That is not theoretical: a
|
|
-- level-13 ODDISH on ROUTE_6 pinned the run for 10,229 identical
|
|
-- iterations of "found ODDISH after 21 steps -- throwing", never throwing
|
|
-- (no failure logged), never catching, never running out of balls.
|
|
-- Whatever wedges the battle, the driver must not livelock on it.
|
|
local iterations = 0
|
|
while steps < CATCH_MAX_STEPS do
|
|
iterations = iterations + 1
|
|
if iterations > CATCH_MAX_ITERATIONS then
|
|
-- Name WHICH sub-state is holding the queue, not just the phase.
|
|
--
|
|
-- BattleState:updateQueue returns true (queue held) for four separate
|
|
-- reasons -- waitingUI, waitFrames, draining, animPlaying -- and the
|
|
-- phase alone cannot tell them apart. The first pass at this only
|
|
-- reported phase=messages, which narrowed it to "the queue is stuck"
|
|
-- and no further; afterQueue and the drain have since been ruled out
|
|
-- (tests/parity_ball_miss.lua covers the phase contract). Print the
|
|
-- lot so the next occurrence names the culprit instead of needing
|
|
-- another run to guess again.
|
|
local b = G.stack:top()
|
|
say(("catch: livelocked on %s after %d iterations (steps=%d, thrown=%d, "
|
|
.. "inBattle=%s, phase=%s, afterQueue=%s, queue=%d, waitingUI=%s, "
|
|
.. "waitFrames=%s, draining=%s, animPlaying=%s) -- abandoning")
|
|
:format(tostring(where), iterations, steps, thrown,
|
|
tostring(inBattle()), tostring(b and b.phase),
|
|
tostring(b and b.afterQueue), b and b.queue and #b.queue or -1,
|
|
tostring(b and b.waitingUI), tostring(b and b.waitFrames),
|
|
tostring(b and b.draining), tostring(b and b.animPlaying)))
|
|
note("catch: livelocked", where)
|
|
if inBattle() then fightBattle() end
|
|
mashUntilIdle()
|
|
return true
|
|
end
|
|
if blackedOut or wiped() then return false end
|
|
if inBattle() then
|
|
spins = 0
|
|
-- Let the enemy side finish being built before deciding what this is.
|
|
-- Reading it the instant the stack top becomes a battle can return nil
|
|
-- and drop our target into the "just fight it" branch.
|
|
waitFor(function()
|
|
local b = G.stack:top()
|
|
return not inBattle() or (b.enemy and b.enemy.mon) ~= nil
|
|
end, 20)
|
|
local battle = G.stack:top()
|
|
local enemy = inBattle() and battle.enemy and battle.enemy.mon or nil
|
|
local species = enemy and enemy.species
|
|
if battle.kind == "wild" and species and want[species] then
|
|
-- Our target. Keep throwing until it is ours, the balls run out or
|
|
-- the battle ends some other way (it broke out and KO'd us).
|
|
say(("found %s (level %d) after %d steps -- throwing"):format(
|
|
species, enemy.level or 0, steps))
|
|
local frames = 0
|
|
while inBattle() and #party() == startSize and frames < 4000 do
|
|
local b = G.stack:top()
|
|
if b.phase == "menu" then
|
|
-- our own mon fainted mid-catch: hand back to the fight loop,
|
|
-- which owns the replacement menu and the blackout watch
|
|
if b.player.mon.hp <= 0 then break end
|
|
local ball = ballInBag()
|
|
if not ball then
|
|
note("catch: out of balls", where)
|
|
break
|
|
end
|
|
-- Soften it up first. Gen 1's catch chance scales with missing
|
|
-- HP, so full-HP throws are the worst odds on offer -- fine for
|
|
-- a NIDORAN at catch rate 235, hopeless for an ODDISH at 45,
|
|
-- which ate six balls without a single shake that stuck.
|
|
-- weakenMoveIndex only ever offers a move that leaves the
|
|
-- target alive with a margin; when nothing is safe it returns
|
|
-- nil and we throw rather than risk killing what we came for.
|
|
local weaken = weakenMoveIndex(b)
|
|
if weaken then
|
|
if battleMenuTo(b, 1) then
|
|
press("a")
|
|
U.wait(6)
|
|
for _ = 1, 8 do
|
|
if b.moveIndex == weaken then break end
|
|
press(b.moveIndex > weaken and "up" or "down")
|
|
U.wait(2)
|
|
end
|
|
press("a")
|
|
U.wait(6)
|
|
weakened = weakened + 1
|
|
end
|
|
elseif not throwBall(b, ball, where) then
|
|
note("catch: could not throw " .. ball, where)
|
|
break
|
|
else
|
|
thrown = thrown + 1
|
|
end
|
|
else
|
|
press("a")
|
|
U.wait(3)
|
|
end
|
|
frames = frames + 4
|
|
end
|
|
if #party() > startSize then
|
|
local got = party()[#party()]
|
|
say(("caught %s (level %d) after %d ball%s and %d weakening hit%s "
|
|
.. "-- party is now %d")
|
|
:format(tostring(got and got.species or species),
|
|
got and got.level or 0, thrown,
|
|
thrown == 1 and "" or "s", weakened,
|
|
weakened == 1 and "" or "s", #party()))
|
|
return true
|
|
end
|
|
if inBattle() then fightBattle() end
|
|
else
|
|
seen[species or "?"] = (seen[species or "?"] or 0) + 1
|
|
fightBattle() -- not our target, but the XP still counts
|
|
end
|
|
autoHeal(where, riskThreshold(where))
|
|
elseif busy() then
|
|
-- Deliberately NOT mashUntilIdle: it fights whatever battle it finds
|
|
-- (mashUntilIdle -> fightBattle). An encounter opens *during* the walk
|
|
-- below, so the very next thing this loop did was hand our target to
|
|
-- that wait, which killed it before the branch above ever saw it --
|
|
-- the bot stood in the right grass, met the right Pokémon and never
|
|
-- threw a ball. Advance text only and let the battle branch decide.
|
|
press("a")
|
|
U.wait(6)
|
|
spins = spins + 1
|
|
if spins > MAX_CATCH_SPINS then
|
|
say(("catch: stuck waiting for the overworld on %s; giving up")
|
|
:format(tostring(where)))
|
|
return true
|
|
end
|
|
else
|
|
-- encounters only fire on steps, so pace the grass we were left in
|
|
spins = 0
|
|
walk(steps % 2 == 0 and "up" or "down")
|
|
steps = steps + 1
|
|
end
|
|
end
|
|
local tally = {}
|
|
for sp, n in pairs(seen) do tally[#tally + 1] = ("%s x%d"):format(sp, n) end
|
|
table.sort(tally)
|
|
note(("catch: no %s after %d steps (%d balls thrown)")
|
|
:format(table.concat(targets, "/"), steps, thrown), where)
|
|
say(("catch: gave up on %s after %d steps, %d balls thrown, %d weakening "
|
|
.. "hits; met %s"):format(
|
|
table.concat(targets, "/"), steps, thrown, weakened,
|
|
#tally > 0 and table.concat(tally, ", ") or "nothing"))
|
|
return true
|
|
end
|
|
|
|
-- Named handlers for the route's `manual` steps. Anything without one is
|
|
-- still stubbed loudly rather than guessed at.
|
|
local MANUAL = {}
|
|
|
|
-- Vermilion Gym's trash can lock puzzle (engine/events/hidden_events/
|
|
-- vermilion_gym_trash.asm). Two switches hide in the 15 cans; both open
|
|
-- the door block at (2,2) that walls Surge off. Until then the route's
|
|
-- gotos into the north half are simply unreachable and the gym segment
|
|
-- skips -- the bot walked in, wandered and walked back out.
|
|
--
|
|
-- We cannot read where the switches are, and we should not: the first is
|
|
-- re-rolled on every Vermilion City load AND on every wrong second guess,
|
|
-- so there is nothing stable to aim at. Just check cans until the door
|
|
-- opens. The vanilla odds are poor and the re-roll makes it memoryless,
|
|
-- hence the generous budget.
|
|
local function solveTrashcans(where)
|
|
local cans = G.data.field.hiddenExtras
|
|
and G.data.field.hiddenExtras.trashCans
|
|
and G.data.field.hiddenExtras.trashCans.cans
|
|
if not cans or #cans == 0 then
|
|
note("trashcans: no can data", where)
|
|
return false
|
|
end
|
|
local function solved() return G.save.flags.EVENT_2ND_LOCK_OPENED == true end
|
|
if solved() then return true end
|
|
local tries = 0
|
|
for _ = 1, 12 do -- passes over the grid
|
|
for _, can in ipairs(cans) do
|
|
if solved() then
|
|
say(("trashcans: both locks open after %d checks"):format(tries))
|
|
return true
|
|
end
|
|
-- stand next to the can and face it; stepUpTo handles the approach
|
|
if stepUpTo(can.x, can.y) then
|
|
press("a")
|
|
U.wait(8)
|
|
mashUntilIdle()
|
|
tries = tries + 1
|
|
end
|
|
end
|
|
end
|
|
note("trashcans: unsolved", where)
|
|
say(("trashcans: %d checks and the door is still shut"):format(tries))
|
|
return false
|
|
end
|
|
|
|
function MANUAL.trashcans(where) return solveTrashcans(where) end
|
|
|
|
-- Evolve with a stone (Mt. Moon's MOON_STONE on the route's NIDORINO).
|
|
--
|
|
-- The whole back half of the route's move plan is aimed at a NIDOKING --
|
|
-- thrash, thunderbolt, rock_slide, ice_beam all target it -- so leaving
|
|
-- this a stub did not merely skip a step, it silently invalidated every
|
|
-- later `teach`: with no NIDOKING in the party they fell through to slot 1
|
|
-- and were spent on a WARTORTLE that cannot learn them.
|
|
--
|
|
-- Data checked against pokered (data/pokemon/evos_moves.asm:1897,
|
|
-- EVOLVE_ITEM MOON_STONE -> NIDOKING); ours matches, and the engine already
|
|
-- returns { evolveTo = ... } from ItemEffects and runs Evolution.evolve in
|
|
-- BagMenu. Only the driver was missing.
|
|
local function evolveWithStone(species, item, where)
|
|
local slot = slotForSpecies(species)
|
|
if not slot then
|
|
note("evolve: no " .. tostring(species[1] or species), where)
|
|
say(("evolve: no %s in the party"):format(tostring(species[1] or species)))
|
|
return false
|
|
end
|
|
if (heldCount(item) or 0) <= 0 then
|
|
note("evolve: no " .. item, where)
|
|
say(("evolve: no %s in the bag"):format(item))
|
|
return false
|
|
end
|
|
local before = party()[slot] and party()[slot].species
|
|
useItemOn(item, slot, where)
|
|
-- the evolution scene is a long one; wait it out rather than stepping on it
|
|
for _ = 1, 200 do
|
|
if idle() then break end
|
|
mashUntilIdle()
|
|
end
|
|
local after = party()[slot] and party()[slot].species
|
|
if after ~= before then
|
|
say(("%s evolved into %s"):format(tostring(before), tostring(after)))
|
|
return true
|
|
end
|
|
say(("evolve: %s did not evolve"):format(tostring(before)))
|
|
return false
|
|
end
|
|
|
|
function MANUAL.evolveNidoking(where)
|
|
return evolveWithStone({ "NIDORINO", "NIDORAN_M" }, "MOON_STONE", where)
|
|
end
|
|
|
|
-- NIDORAN_M -> NIDORINO is EVOLVE_LEVEL 16 (evos_moves.asm), so it happens
|
|
-- on its own as the trainee levels; there is nothing to drive here. Kept as
|
|
-- a real handler rather than a stub so the end-of-run summary stops listing
|
|
-- it as skipped work.
|
|
function MANUAL.evolveNidorino() return true end
|
|
|
|
-- Ride an elevator to the floor the next segment wants.
|
|
--
|
|
-- Both elevators the route uses (CELADON_MART_ELEVATOR at segment 98,
|
|
-- SILPH_CO_ELEVATOR at 140) gate real geography: the mart's upper floors
|
|
-- and most of Silph are only reachable through them.
|
|
--
|
|
-- The floor menu belongs to the car's panel bg_event, not to map entry
|
|
-- (#395): walk to the panel and press A. Rows are the short floor tokens
|
|
-- pokered prints ("5F", "B2F"), which is exactly the tail of the
|
|
-- destination map id, so the next segment names the button to press.
|
|
-- Ride the elevator to `wantMap` (defaults to the next segment's floor).
|
|
local function rideElevator(where, wantMap)
|
|
-- Stand beside the car's panel bg_event and press A to open the floor
|
|
-- menu (data/maps/objects/CeladonMartElevator.asm `bg_event 3, 0`).
|
|
local function pressPanel()
|
|
local m = ow().map
|
|
local sign = (m.def.signs or {})[1]
|
|
if not sign then return false end
|
|
-- offset from the panel cell, then the direction that faces it back
|
|
local SIDES = { { 0, 1, "up" }, { 0, -1, "down" },
|
|
{ 1, 0, "left" }, { -1, 0, "right" } }
|
|
for _, s in ipairs(SIDES) do
|
|
local sx, sy = sign.x + s[1], sign.y + s[2]
|
|
if m:inBounds(sx, sy) and m:isWalkableCell(sx, sy) then
|
|
ops.goto_({ x = sx, y = sy })
|
|
local p = ow().player
|
|
if ow().map.id == m.id and p.cellX == sx and p.cellY == sy then
|
|
faceDir(s[3])
|
|
press("a")
|
|
if waitFor(isList, 60) then return true end
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
local want = tostring(wantMap or nextMapWanted or "")
|
|
local token = want:match("_([^_]+)$")
|
|
if not token then
|
|
note("elevator: no destination floor", where)
|
|
say(("elevator on %s: no floor named to ride to"):format(tostring(where)))
|
|
return false
|
|
end
|
|
local from = ow().map.id
|
|
if not isList() and not pressPanel() then
|
|
note("elevator: no floor menu", where)
|
|
say(("elevator on %s: the WHICH FLOOR? menu never opened"):format(from))
|
|
return false
|
|
end
|
|
local idx
|
|
for i, row in ipairs(rows() or {}) do
|
|
if tostring(row.label) == token then idx = i break end
|
|
end
|
|
if not idx then
|
|
-- The exact floor is not on this elevator -- the Celadon roof, say, is
|
|
-- reached by stairs from 5F, not by the car. Ride to the highest floor
|
|
-- offered instead (nearest the roof) and let the caller walk the last
|
|
-- stairs. Better than failing and bouncing.
|
|
local best, bestFloor
|
|
for i, row in ipairs(rows() or {}) do
|
|
local n = tonumber(tostring(row.label):match("%d+"))
|
|
if n and (not bestFloor or n > bestFloor) then best, bestFloor = i, n end
|
|
end
|
|
if best then
|
|
say(("elevator on %s: no %s floor; riding to the top (%s) instead")
|
|
:format(from, token, tostring(rows()[best].label)))
|
|
idx = best
|
|
else
|
|
local have = {}
|
|
for _, row in ipairs(rows() or {}) do have[#have + 1] = tostring(row.label) end
|
|
note("elevator: no floor " .. token, where)
|
|
say(("elevator on %s: wanted %s, offered %s")
|
|
:format(from, token, table.concat(have, "/")))
|
|
backOut()
|
|
return false
|
|
end
|
|
end
|
|
if not cursorTo("index", idx) then backOut() return false end
|
|
press("a")
|
|
-- ShakeElevator runs the whole ride in place -- music stop, 100 scroll
|
|
-- bounces, the PA chime -- and then hands control back inside the car:
|
|
-- the rewritten exit warp is what we walk out onto (#395).
|
|
mashUntilIdle()
|
|
local car = findWarpTo(want) or ow().map.def.warps[1]
|
|
if car then walkOntoWarp(car.x, car.y) end
|
|
local landed = ow().map.id
|
|
if landed == want then
|
|
say(("rode the elevator to %s"):format(token))
|
|
return true
|
|
end
|
|
note("elevator: landed on " .. tostring(landed), where)
|
|
say(("elevator: wanted %s, ended on %s"):format(want, tostring(landed)))
|
|
return false
|
|
end
|
|
|
|
MANUAL.deptElevator = rideElevator
|
|
MANUAL.silphElevator = rideElevator
|
|
|
|
-- Escape the Pokémon Tower 6F MAROWAK with a POKé DOLL.
|
|
--
|
|
-- This is not a shortcut we could skip -- it is the route's only way past
|
|
-- 6F. The route never visits the Game Corner or ROCKET_HIDEOUT (checked:
|
|
-- no segment on either map), so it never holds the SILPH_SCOPE, and
|
|
-- Map.ghostBattles marks every POKEMON_TOWER floor `unlessItem =
|
|
-- "SILPH_SCOPE"`. Without the scope the ghost cannot be damaged at all, so
|
|
-- fighting it is an infinite battle; the POKE_DOLL bought at segment 94
|
|
-- (CELADON_MART_4F) is what gets us to 7F and Mr. Fuji -- and therefore to
|
|
-- the POKE_FLUTE and the Snorlax beyond it.
|
|
--
|
|
-- ItemUsePokeDoll works on wild battles only, which the ghost is.
|
|
function MANUAL.pokeDoll(where)
|
|
-- The battle usually opens DURING the preceding goto, where fightBattle
|
|
-- already throws the doll (the ghost branch). Arriving here with the
|
|
-- event set means that worked; there is nothing left to do.
|
|
if (G.save.flags or {}).EVENT_BEAT_GHOST_MAROWAK then
|
|
say("MAROWAK already departed (doll thrown mid-goto)")
|
|
return true
|
|
end
|
|
if (heldCount("POKE_DOLL") or 0) <= 0 then
|
|
-- The Celadon 4F stop can fail (it did -- a clerk behind a counter
|
|
-- the old stepUpTo could not reach), and without the doll the
|
|
-- MAROWAK is unbeatable and the tower a dead end. The mart is a
|
|
-- short walk from Lavender, so go buy one rather than write the
|
|
-- attempt off; the segment loop travels back to the tower on its
|
|
-- own when the manual step ends on the wrong map.
|
|
say("pokeDoll: none in the bag -- walking to CELADON_MART_4F to buy one")
|
|
local back = ow().map.id
|
|
if travelTo("CELADON_MART_4F", where) then
|
|
ops.shop({ list = "pokeDoll" }, "CELADON_MART_4F")
|
|
travelTo(back, where)
|
|
end
|
|
end
|
|
if (heldCount("POKE_DOLL") or 0) <= 0 then
|
|
note("pokeDoll: none in the bag", where)
|
|
say("pokeDoll: no POKE_DOLL (the Celadon 4F stop should have bought one)")
|
|
return false
|
|
end
|
|
-- the ghost engages as we approach; give it a moment to open
|
|
for _ = 1, 120 do
|
|
if inBattle() then break end
|
|
if busy() then mashUntilIdle() else U.wait(4) end
|
|
end
|
|
if not inBattle() then
|
|
note("pokeDoll: no battle to escape", where)
|
|
return false
|
|
end
|
|
local battle = G.stack:top()
|
|
for _ = 1, 40 do
|
|
if not inBattle() then break end
|
|
if battle.phase == "menu" then
|
|
if not battleMenuTo(battle, 3) then break end
|
|
press("a")
|
|
if not waitFor(isList, 40) then backOut() break end
|
|
local idx
|
|
for i, row in ipairs(rows() or {}) do
|
|
if row.value == "POKE_DOLL" then idx = i break end
|
|
end
|
|
if not idx or not cursorTo("index", idx) then backOut() break end
|
|
press("a")
|
|
U.wait(12)
|
|
mashUntilIdle(400)
|
|
break
|
|
end
|
|
press("a")
|
|
U.wait(4)
|
|
end
|
|
if inBattle() then
|
|
note("pokeDoll: still in the battle", where)
|
|
say("pokeDoll: the doll did not end the MAROWAK battle")
|
|
return false
|
|
end
|
|
say("escaped the MAROWAK ghost with a POKé DOLL")
|
|
return true
|
|
end
|
|
|
|
-- Buy drinks from the Celadon Mart roof vending machines.
|
|
--
|
|
-- This is the key that opens the middle of the map. All four Saffron gates
|
|
-- turn you back until the guards have been given a drink, and Saffron sits
|
|
-- between Celadon and Lavender -- so with no drink in the bag the route
|
|
-- cannot reach Lavender for the POKE_FLUTE, and travelTo reports "no route
|
|
-- to MR_FUJIS_HOUSE" however hard it searches. One FRESH_WATER opens every
|
|
-- gate permanently.
|
|
--
|
|
-- The machines are SIGNS (10,1) (11,1) (12,2), not clerks, so ops.shop
|
|
-- cannot drive them -- they open a plain ListMenu (data/scripts/story4.lua
|
|
-- vendingMachine) which stays up between purchases, showing a "popped out!"
|
|
-- box over itself each time.
|
|
--
|
|
-- Buys several: one goes to the guards, and the roof's thirsty girl trades
|
|
-- the others for TMs. Nothing later depends on those TMs, so a short bag or
|
|
-- a thin wallet is not a failure.
|
|
local VENDING_WANT = 3
|
|
|
|
function MANUAL.giveWater(where)
|
|
local sign
|
|
for _, s in ipairs(ow().map.def.signs or {}) do
|
|
if tostring(s.text or s.id or ""):find("VENDING_MACHINE") then
|
|
sign = s
|
|
break
|
|
end
|
|
end
|
|
if not sign then
|
|
note("giveWater: no vending machine here", where)
|
|
return false
|
|
end
|
|
-- a sign is read from the cell below it, facing up
|
|
if not ops.goto_({ x = sign.x, y = sign.y + 1 }) then
|
|
note("giveWater: cannot reach the vending machine", where)
|
|
return false
|
|
end
|
|
faceDir("up")
|
|
press("a")
|
|
U.wait(10)
|
|
if not waitFor(isList, 40) then
|
|
note("giveWater: the vending machine did not open", where)
|
|
say("giveWater: no vending menu appeared")
|
|
backOut()
|
|
return false
|
|
end
|
|
local bought = 0
|
|
for _ = 1, VENDING_WANT do
|
|
if not isList() then break end
|
|
cursorTo("index", 1) -- FRESH_WATER, the cheapest at 200
|
|
press("a")
|
|
U.wait(12)
|
|
-- the purchase (or "Not enough money") prints over the list; clear it
|
|
for _ = 1, 20 do
|
|
if isList() then break end
|
|
press("a")
|
|
U.wait(4)
|
|
end
|
|
bought = bought + 1
|
|
end
|
|
backOut()
|
|
mashUntilIdle()
|
|
local held = 0
|
|
for _, d in ipairs({ "FRESH_WATER", "SODA_POP", "LEMONADE" }) do
|
|
held = held + (heldCount(d) or 0)
|
|
end
|
|
say(("giveWater: %d vending purchases, %d drink(s) in the bag")
|
|
:format(bought, held))
|
|
if held <= 0 then
|
|
note("giveWater: no drink bought", where)
|
|
return false
|
|
end
|
|
return true
|
|
end
|
|
|
|
function MANUAL.catchNidoran(where) return catchWild(CATCH_SPECIES.nidoran, where) end
|
|
|
|
-- Bill's whole favour, hand-driven (BillsHouse.asm). The route data has
|
|
-- only a bare `manual talkToBill` here, and with it unimplemented the
|
|
-- S.S. TICKET arrived only when the segment's blind final talk happened
|
|
-- to line up -- three attempts in one run wedged at the Vermilion
|
|
-- gangplank for want of it. The chain: the monster on the floor (answer
|
|
-- YES), the cell-separator PC at (1,4) (hidden event, fires facing UP
|
|
-- from (1,5)), then Bill at (4,4) hands the ticket over.
|
|
function MANUAL.talkToBill(where)
|
|
local function ticket() return (heldCount("S_S_TICKET") or 0) > 0 end
|
|
if ticket() then return true end
|
|
for _ = 1, 3 do
|
|
local flags = G.save.flags or {}
|
|
if not flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR then
|
|
-- the monster at (6,5); stand left of it, face right
|
|
if ops.goto_({ x = 5, y = 5 }, where, false) then
|
|
interact("right")
|
|
mashUntilIdle()
|
|
end
|
|
end
|
|
flags = G.save.flags or {}
|
|
if flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR
|
|
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
|
|
if ops.goto_({ x = 1, y = 5 }, where, false) then
|
|
interact("up")
|
|
mashUntilIdle()
|
|
end
|
|
end
|
|
-- Bill stands at (4,4) once separated; the ticket text is his
|
|
if ops.goto_({ x = 4, y = 5 }, where, false) then
|
|
interact("up")
|
|
mashUntilIdle()
|
|
end
|
|
if ticket() then
|
|
say("Bill is himself again -- S.S. TICKET in the bag")
|
|
return true
|
|
end
|
|
end
|
|
say("talkToBill: no S.S. TICKET after the full favour chain")
|
|
return false
|
|
end
|
|
function MANUAL.catchOddish(where) return catchWild(CATCH_SPECIES.oddish, where) end
|
|
|
|
-- Use a field item that takes no target (the ESCAPE ROPE).
|
|
--
|
|
-- Same bag walk as useItemOn, but there is no party menu at the end: the
|
|
-- item just fires and warps us to the last heal point.
|
|
function useFieldItem(itemId, where)
|
|
press("start")
|
|
U.wait(8)
|
|
local menu = top()
|
|
if not (menu and menu.screenId == "StartMenu") then
|
|
note("useItem: start menu never opened", where)
|
|
backOut()
|
|
return false
|
|
end
|
|
local itemRow
|
|
for i, it in ipairs(menu.items or {}) do
|
|
if it.label == "ITEM" then itemRow = i break end
|
|
end
|
|
if not itemRow or not cursorTo("index", itemRow) then
|
|
note("useItem: no ITEM row", where)
|
|
backOut()
|
|
return false
|
|
end
|
|
press("a")
|
|
U.wait(10)
|
|
local bag = top()
|
|
if not (bag and bag.screenId == "BagMenu") then
|
|
note("useItem: bag never opened", where)
|
|
backOut()
|
|
return false
|
|
end
|
|
local bagRow
|
|
for i, r in ipairs(bag.items or {}) do
|
|
if r.value == itemId then bagRow = i break end
|
|
end
|
|
if not bagRow or not cursorTo("index", bagRow) then
|
|
note("useItem: no " .. itemId .. " in the bag", where)
|
|
backOut()
|
|
return false
|
|
end
|
|
press("a")
|
|
U.wait(8)
|
|
if isUseToss() then -- USE is row 1
|
|
if not cursorTo("index", 1) then backOut() return false end
|
|
press("a")
|
|
U.wait(10)
|
|
end
|
|
-- the rope warps us out; let the map change land
|
|
local from = ow().map.id
|
|
for _ = 1, 150 do
|
|
if ow().map.id ~= from then break end
|
|
if busy() then press("a") end
|
|
U.wait(6)
|
|
end
|
|
if ow().map.id == from then
|
|
backOut()
|
|
return false
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- Walk out through the building's exit warp. The fallback for a missing
|
|
-- ESCAPE ROPE: it does not land us where the route expects, but standing
|
|
-- still inside a house guarantees the attempt dies, and walking out at
|
|
-- least gives the segment loop a chance to re-sync.
|
|
function leaveByDoor(where)
|
|
local out = findWarpTo("LAST_MAP") or findWarpTo("")
|
|
if out and walkOntoWarp(out.x, out.y) then
|
|
say(("left %s by the door (no ESCAPE ROPE)"):format(tostring(where)))
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
function ops.useItem(s, where)
|
|
local item = s.item and tostring(s.item):upper()
|
|
if s.move then
|
|
-- A TM teach routed through useItem upstream (TM26 EARTHQUAKE, five
|
|
-- times at different checkpoints). The TM exists once, so the step is
|
|
-- made idempotent: skip when somebody already knows the move, else
|
|
-- hand it to the teach op, whose compatibility fallback picks the
|
|
-- lead -- the mon that actually fights for us.
|
|
local key = tostring(s.move):upper()
|
|
if slotKnowing(key) then return true end
|
|
return ops.teach({ move = s.move, mon = s.mon }, where)
|
|
end
|
|
if item == "REPEL" or item == "SUPER_REPEL" or item == "MAX_REPEL" then
|
|
-- The route repels through every dungeon it crosses, and the engine
|
|
-- honours it (save.repelSteps). We fight most encounters anyway, but
|
|
-- inside the Mansion / Victory Road the attrition is what kills runs:
|
|
-- B1F drained BLASTOISE to 2/182 across chained wild fights with an
|
|
-- empty bag and the blackout threw the whole arc away. Use whichever
|
|
-- repel is actually aboard, strongest first.
|
|
for _, id in ipairs({ "MAX_REPEL", "SUPER_REPEL", "REPEL" }) do
|
|
if (heldCount(id) or 0) > 0 then
|
|
if useFieldItem(id, where) then
|
|
say(("used %s on %s"):format(id, ow().map.id))
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
note("useItem: no repel aboard", where)
|
|
return true -- never blocks the route
|
|
end
|
|
if item ~= "ESCAPE_ROPE" then
|
|
-- the other useItem steps are speedrun stat items and X ATTACKs
|
|
note("useItem", where)
|
|
return true
|
|
end
|
|
if (heldCount("ESCAPE_ROPE") or 0) > 0 and useFieldItem("ESCAPE_ROPE", where) then
|
|
-- The rope warps to the heal point, which puts us INSIDE the town's
|
|
-- Poké Center -- but the route's next segment is the town outside it.
|
|
-- Without stepping out the bot just stands at the nurse while every
|
|
-- remaining segment skips: "got the ticket, used the rope, then stood
|
|
-- there".
|
|
local landed = ow().map.id
|
|
say(("escape rope out of %s -> %s"):format(tostring(where), tostring(landed)))
|
|
if tostring(landed):find("POKECENTER") then leaveByDoor(landed) end
|
|
return true
|
|
end
|
|
note("useItem: escape rope failed", where)
|
|
return leaveByDoor(where)
|
|
end
|
|
|
|
-- Wake the Route 16 / Route 12 SNORLAX.
|
|
--
|
|
-- Defined down here because it needs useFieldItem. The flute must be USED
|
|
-- from the bag while standing next to it -- talking to Snorlax with the
|
|
-- flute in the bag does nothing (ItemEffects' adjacentSleepingSnorlax, and
|
|
-- data/scripts/story.lua's snorlaxWake). Waking it starts a static battle,
|
|
-- so the fight is driven before handing back.
|
|
function MANUAL.playPokeFlute(where)
|
|
local snorlax
|
|
for _, npc in ipairs(ow().npcs) do
|
|
local id = tostring((npc.def and (npc.def.id or npc.def.sprite)) or "")
|
|
if id:find("SNORLAX") then snorlax = npc break end
|
|
end
|
|
if not snorlax then
|
|
note("playPokeFlute: no SNORLAX here", where)
|
|
say(("playPokeFlute: nothing asleep on %s"):format(ow().map.id))
|
|
return false
|
|
end
|
|
if (heldCount("POKE_FLUTE") or 0) <= 0 then
|
|
note("playPokeFlute: no POKE_FLUTE", where)
|
|
say("playPokeFlute: the flute is not in the bag (Mr. Fuji not yet reached?)")
|
|
return false
|
|
end
|
|
if not stepUpTo(snorlax.cellX, snorlax.cellY) then
|
|
note("playPokeFlute: cannot reach SNORLAX", where)
|
|
return false
|
|
end
|
|
local p = ow().player
|
|
local dx, dy = snorlax.cellX - p.cellX, snorlax.cellY - p.cellY
|
|
faceDir((dx > 0 and "right") or (dx < 0 and "left")
|
|
or (dy > 0 and "down") or "up")
|
|
useFieldItem("POKE_FLUTE", where)
|
|
-- waking it is a static battle, not a warp
|
|
for _ = 1, 200 do
|
|
if inBattle() then fightBattle()
|
|
elseif idle() then break
|
|
else mashUntilIdle() end
|
|
end
|
|
local still = false
|
|
for _, npc in ipairs(ow().npcs) do
|
|
local id = tostring((npc.def and (npc.def.id or npc.def.sprite)) or "")
|
|
if id:find("SNORLAX") then still = true break end
|
|
end
|
|
if still then
|
|
note("playPokeFlute: SNORLAX still there", where)
|
|
return false
|
|
end
|
|
say("SNORLAX woke up and the way is clear")
|
|
return true
|
|
end
|
|
|
|
-- The movement layer's answer to a SNORLAX in the way (walkOntoWarp and
|
|
-- ops.goto_ both call this before giving up): with the POKE FLUTE aboard
|
|
-- the blocker is an errand, not a wall. The route's own `playPokeFlute`
|
|
-- segment is easy to lose to a desync, and without this every later trip
|
|
-- past Route 16's junction dead-ends on "SPRITE_SNORLAX@(26,10)" forever.
|
|
function wakeBlockingSnorlax(whereFor)
|
|
if (heldCount("POKE_FLUTE") or 0) <= 0 then return false end
|
|
local found = false
|
|
for _, npc in ipairs(ow().npcs) do
|
|
local id = tostring((npc.def and (npc.def.id or npc.def.sprite)) or "")
|
|
if id:find("SNORLAX") then found = true break end
|
|
end
|
|
if not found then return false end
|
|
say(("a sleeping SNORLAX blocks %s and the POKE FLUTE is aboard -- waking it")
|
|
:format(tostring(whereFor)))
|
|
return MANUAL.playPokeFlute(whereFor)
|
|
end
|
|
|
|
function ops.manual(s, where)
|
|
local fn = MANUAL[s.name]
|
|
if not fn then
|
|
note("manual:" .. s.name, where)
|
|
return true
|
|
end
|
|
return fn(where)
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- runner
|
|
-- ---------------------------------------------------------------------
|
|
|
|
-- One pass over the route. Returns false if the party wiped, so the caller
|
|
-- can restart from a fresh game the way PokeBotBad's Strategies.reset does
|
|
-- -- a blackout dumps us at the heal point with the route pointing
|
|
-- somewhere else entirely, and there is no sane way to re-sync mid-run.
|
|
-- A cutscene rarely carries us past more than a couple of segments; a long
|
|
-- unbroken run of skips means the attempt is lost, not merely ahead.
|
|
local MAX_CONSECUTIVE_SKIPS = 8
|
|
|
|
-- How far back a lost attempt will rewind before giving up. Each rewind
|
|
-- goes strictly further back than the last.
|
|
local MAX_REWINDS = 4
|
|
|
|
-- How many times a lost attempt will WALK to the segment's map before
|
|
-- falling back to rewinding. Tried first because it resumes the route
|
|
-- where it left off rather than replaying, but it is a long walk, so a
|
|
-- run that keeps needing one is lost for some other reason.
|
|
local MAX_TRAVEL_RESCUES = 3
|
|
|
|
-- How many maps a deferred grind will try before being dropped.
|
|
local MAX_PENDING_GRINDS = 3
|
|
|
|
-- Grind wild encounters where we stand until the lead gains a level.
|
|
--
|
|
-- Used after a second death on the same segment: arriving healthy was not
|
|
-- enough, so the answer is levels rather than another attempt at the same
|
|
-- HP. We do NOT flee here (that is the point) and we top up between
|
|
-- encounters, so this doubles as the "heal until ready" half.
|
|
local recoverFromBlackout
|
|
|
|
-- Can a wild encounter fire on this cell?
|
|
--
|
|
-- Mirrors OverworldController's own test (the `grass / surfing / indoor`
|
|
-- branch around line 2307): grass with an encounter table, or any tile on an
|
|
-- indoor map that has one and is not the excluded tileset.
|
|
local function encounterCell(map, x, y)
|
|
if not (G.data.encounters and G.data.encounters[map.id]) then return false end
|
|
if map:isGrassCell(x, y) then return true end
|
|
local indoor = G.data.field and G.data.field.indoorEncounters
|
|
return indoor ~= nil
|
|
and (map.def.index or -1) >= indoor.firstIndoorMap
|
|
and map.def.tileset ~= indoor.excludedTileset
|
|
end
|
|
|
|
-- Walk to the nearest cell where an encounter can actually fire.
|
|
--
|
|
-- Without this, grindALevel paced up and down wherever the blackout left us
|
|
-- -- which is a POKé CENTER, indoors, with no encounter table at all. It
|
|
-- looked like the bot "wigging out" on the spot: 200 steps of walking into
|
|
-- the counter, no encounters possible, then "grind gave up".
|
|
local function gotoEncounterCell(map)
|
|
local p = ow().player
|
|
if encounterCell(map, p.cellX, p.cellY) then return true end
|
|
local best, bestD
|
|
for y = 0, (map.heightCells or 0) - 1 do
|
|
for x = 0, (map.widthCells or 0) - 1 do
|
|
if encounterCell(map, x, y) and map:isWalkableCell(x, y) then
|
|
local d = math.abs(x - p.cellX) + math.abs(y - p.cellY)
|
|
if not bestD or d < bestD then best, bestD = { x, y }, d end
|
|
end
|
|
end
|
|
end
|
|
if not best then return false end
|
|
ops.goto_({ x = best[1], y = best[2] })
|
|
p = ow().player
|
|
return encounterCell(ow().map, p.cellX, p.cellY)
|
|
end
|
|
|
|
local function grindALevel(where)
|
|
local lead = party()[1]
|
|
if not lead or not lead.level then return false end
|
|
-- Grinding only works where wild mons live. After a blackout we are
|
|
-- standing in a Poké Center, so check before pacing rather than after.
|
|
if not gotoEncounterCell(ow().map) then
|
|
-- Nothing to fight here -- but a town is usually one step from a route
|
|
-- that is all grass. Cerulean is the case that forced this: the rival
|
|
-- north of town kept killing us, the city has no encounters at all, and
|
|
-- so "train before trying again" could never do anything and the run
|
|
-- was written off as hopeless while a full training ground sat one map
|
|
-- away. Cross into a connected map that has wild mons and grind there;
|
|
-- the segment loop re-syncs us afterwards.
|
|
--
|
|
-- A SINGLE seam hop to an adjacent grassy map -- never travelTo.
|
|
--
|
|
-- travelTo plans multi-hop and, when the direct seam is shut, hunts an
|
|
-- alternate route: asked to reach ROUTE_3 for grinding from Pewter with
|
|
-- that seam briefly unwalkable, it walked the bot clear across the map
|
|
-- to ROUTE_21 and abandoned the run. Grinding only ever wants the grass
|
|
-- next door, so cross exactly one connection with crossSeam (which uses
|
|
-- the edge-exit idiom the old hand-rolled version got wrong -- it aimed
|
|
-- at in-bounds cells and never reached the border) and give up if that
|
|
-- one hop does not land somewhere with encounters.
|
|
local from = ow().map
|
|
local fromId = from.id
|
|
local moved = false
|
|
for dir, conn in pairs(from.def.connections or {}) do
|
|
local dest = conn.map or conn.destMap
|
|
if dest and G.data.encounters and G.data.encounters[dest] then
|
|
say(("grind: %s has no wild mons; crossing %s into %s"):format(
|
|
tostring(fromId), dir, tostring(dest)))
|
|
crossSeam({ to = dest, dir = COMPASS_DIR[dir],
|
|
cells = seamCells(from.def, dir, conn) })
|
|
if ow().map.id ~= fromId and G.data.encounters[ow().map.id] then
|
|
moved = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
if not (moved and gotoEncounterCell(ow().map)) then
|
|
say(("grind: no wild encounters reachable from %s; skipping")
|
|
:format(tostring(from.id)))
|
|
return false
|
|
end
|
|
end
|
|
local target = lead.level + 1
|
|
say(("grinding on %s: %s is level %d, want %d")
|
|
:format(tostring(where), tostring(lead.species or "lead"), lead.level, target))
|
|
local sinceBattle = 0
|
|
local stuckPaces = 0
|
|
for _ = 1, 900 do
|
|
if blackedOut then return false end
|
|
local now = party()[1]
|
|
if not now or (now.level or 0) >= target then
|
|
say(("grind done: level %d"):format(now and now.level or 0))
|
|
return true
|
|
end
|
|
if inBattle() then
|
|
grinding = true -- suppress the flee guard: fleeing earns no XP
|
|
fightBattle()
|
|
grinding = false
|
|
sinceBattle = 0
|
|
autoHeal(where, 0.7)
|
|
elseif busy() then
|
|
mashUntilIdle()
|
|
else
|
|
-- Encounters only fire on steps taken ON an encounter tile, so pacing
|
|
-- is only useful while we are still standing on one -- two steps in a
|
|
-- straight line can walk off the grass strip entirely.
|
|
local p = ow().player
|
|
if not encounterCell(ow().map, p.cellX, p.cellY) then
|
|
if not gotoEncounterCell(ow().map) then
|
|
say("grind gave up: walked off the grass and cannot get back")
|
|
return false
|
|
end
|
|
end
|
|
-- Pace along whichever axis actually moves. The old strictly
|
|
-- vertical pace zeroed out on a one-cell-tall grass strip -- both
|
|
-- steps hit walls, no step means no encounter roll, and 200 idle
|
|
-- iterations later the grind reported "no encounters here" while
|
|
-- standing in perfectly good grass (Route 2's northern patch).
|
|
local before = ow().player
|
|
local bx, by = before.cellX, before.cellY
|
|
if not walk(sinceBattle % 2 == 0 and "up" or "down") then
|
|
walk(sinceBattle % 2 == 0 and "left" or "right")
|
|
end
|
|
local after = ow().player
|
|
if after.cellX ~= bx or after.cellY ~= by then
|
|
stuckPaces = 0
|
|
else
|
|
stuckPaces = (stuckPaces or 0) + 1
|
|
if stuckPaces > 24 then
|
|
say("grind gave up: boxed in, nothing to pace on")
|
|
return false
|
|
end
|
|
end
|
|
sinceBattle = sinceBattle + 1
|
|
if sinceBattle > 200 then
|
|
say("grind gave up: no encounters here")
|
|
return false
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Levels to ask for in one recovery.
|
|
local MAX_GRIND_LEVELS = 3
|
|
|
|
-- Deaths between grind sessions. A death never ends the run -- we go back
|
|
-- and try again -- but every Nth one buys levels first. Retrying is cheap
|
|
-- and the RNG wins plenty of close fights on its own; a grind is minutes of
|
|
-- pacing, so it is paid periodically rather than per death.
|
|
local DEATHS_PER_GRIND = 10
|
|
-- Gyms are walls, not variance: fixed roster, always entered healed, and
|
|
-- nothing to spend before Pewter. Grind sooner there. See recoverFromBlackout.
|
|
local DEATHS_PER_GRIND_GYM = 3
|
|
|
|
-- How far back a death is allowed to rewind us before we walk back to the
|
|
-- segment instead. Past this the replay costs more than the trip.
|
|
local MAX_RESUME_REWIND = 12
|
|
|
|
-- Grind up to `n` levels, topping up in between. Returns levels actually
|
|
-- gained, which is the number that matters: a death is only worth retrying
|
|
-- if we came back stronger than we went in.
|
|
local function grindLevels(n, where)
|
|
local lead = party()[1]
|
|
local before = lead and lead.level or 0
|
|
for round = 1, n do
|
|
if blackedOut then break end
|
|
if not grindALevel(where) then break end
|
|
-- items only between rounds; a nurse trip warps us off the grass and
|
|
-- grindALevel would just have to walk back to it
|
|
autoHeal(where, 0.9)
|
|
if round < n then
|
|
say(("training on: %d of %d levels"):format(round, n))
|
|
end
|
|
end
|
|
local now = party()[1]
|
|
return math.max(0, (now and now.level or 0) - before)
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- stuck report
|
|
-- ---------------------------------------------------------------------
|
|
|
|
function WD.describeStep(s)
|
|
if type(s) ~= "table" then return tostring(s) end
|
|
local keys = {}
|
|
for k in pairs(s) do if k ~= "op" then keys[#keys + 1] = tostring(k) end end
|
|
table.sort(keys)
|
|
local parts = { tostring(s.op) }
|
|
for _, k in ipairs(keys) do
|
|
parts[#parts + 1] = k .. "=" .. tostring(s[k])
|
|
end
|
|
return table.concat(parts, " ")
|
|
end
|
|
|
|
-- One human-readable block answering: how far did the run get, what was it
|
|
-- doing when it wedged, and what did it want next. Said into the log AND
|
|
-- written to STUCK_REPORT_PATH, so a killed window still leaves the answer
|
|
-- on disk.
|
|
function WD.reportStuck(hit)
|
|
local wasArmed = WD.armed
|
|
WD.armed = false -- the report echoes the repeated lines; never recurse
|
|
local progress = WD.progress
|
|
local out = {}
|
|
local function add(fmt, ...)
|
|
out[#out + 1] = select("#", ...) > 0 and fmt:format(...) or fmt
|
|
end
|
|
local seg = ROUTE[progress.segment]
|
|
local far = ROUTE[progress.maxSegment]
|
|
add("==== STUCK REPORT ====")
|
|
add("got to: segment %d/%d (%s)", progress.maxSegment, #ROUTE,
|
|
far and far.map or "?")
|
|
add("stuck at: segment %d/%d (%s), step %s/%s: %s",
|
|
progress.segment, #ROUTE, seg and seg.map or "?",
|
|
tostring(progress.stepIndex or "?"), tostring(progress.stepCount or "?"),
|
|
tostring(progress.action or "?"))
|
|
local o = ow()
|
|
if o and o.map and o.player then
|
|
local state = inBattle() and "in battle"
|
|
or (busy() and "busy (menu/script/cutscene)" or "idle")
|
|
add("standing: %s (%s,%s) -- %s", tostring(o.map.id),
|
|
tostring(o.player.cellX), tostring(o.player.cellY), state)
|
|
end
|
|
local nxt = ROUTE[progress.segment + 1]
|
|
if nxt then
|
|
add("wanted next: segment %d/%d (%s), starting with: %s",
|
|
progress.segment + 1, #ROUTE, nxt.map,
|
|
WD.describeStep(nxt.steps and nxt.steps[1] or "?"))
|
|
else
|
|
add("wanted next: nothing -- this was the last segment")
|
|
end
|
|
local roster = {}
|
|
for _, mon in ipairs(party()) do
|
|
roster[#roster + 1] = ("%s L%s %s/%s"):format(tostring(mon.species),
|
|
tostring(mon.level), tostring(mon.hp),
|
|
tostring(mon.stats and mon.stats.hp))
|
|
end
|
|
add("party: %s", #roster > 0 and table.concat(roster, ", ") or "(none)")
|
|
local badges = {}
|
|
for _, b in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE",
|
|
"RAINBOWBADGE", "SOULBADGE", "MARSHBADGE",
|
|
"VOLCANOBADGE", "EARTHBADGE" }) do
|
|
if heldCount(b) then badges[#badges + 1] = b end
|
|
end
|
|
add("badges: %s", #badges > 0 and table.concat(badges, ", ") or "(none)")
|
|
if hit.silent then
|
|
add("detector: silent stall -- %s", tostring(hit.line))
|
|
elseif (hit.period or 0) > 0 then
|
|
add("detector: this %d-line cycle repeated %d times in a row:",
|
|
hit.period, hit.count)
|
|
for _, l in ipairs(hit.cycle or {}) do add(" | %s", l) end
|
|
else
|
|
add("detector: %s (seen %dx in the last %d log lines)",
|
|
tostring(hit.line), hit.count or 1, WD.window)
|
|
end
|
|
local h = WD.history
|
|
local tail = math.min(#h, 30)
|
|
add("last %d log lines:", tail)
|
|
for j = #h - tail + 1, #h do add(" | %s", h[j]) end
|
|
add("==== END STUCK REPORT ====")
|
|
for _, l in ipairs(out) do say(l) end
|
|
local fh = io.open(WD.reportPath, "w")
|
|
if fh then
|
|
fh:write(table.concat(out, "\n"), "\n")
|
|
fh:close()
|
|
say("stuck report written to " .. WD.reportPath)
|
|
end
|
|
WD.armed = wasArmed
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- checkpoints
|
|
-- ---------------------------------------------------------------------
|
|
--
|
|
-- Every blocker so far has been late in the route, and re-reaching it costs
|
|
-- a full replay from Pallet Town. So when the run gets stuck, write the
|
|
-- game's own save plus the segment we died on; POKEPORT_ROUTE_RESUME=1 then
|
|
-- picks up there instead of starting over. Fix, resume, see the fix -- in
|
|
-- seconds rather than a quarter of an hour.
|
|
--
|
|
-- POKEPORT_ROUTE_STOP_ON_STUCK=1 save and stop rather than restarting
|
|
-- POKEPORT_ROUTE_RESUME=1 load the checkpoint instead of a new game
|
|
-- POKEPORT_ROUTE_CHECKPOINT=path where it lives
|
|
local CHECKPOINT_PATH = os.getenv("POKEPORT_ROUTE_CHECKPOINT")
|
|
or "/tmp/pokeport_route_checkpoint.lua"
|
|
local STOP_ON_STUCK = os.getenv("POKEPORT_ROUTE_STOP_ON_STUCK") == "1"
|
|
|
|
local function saveCheckpoint(i, reason)
|
|
-- Game:writeSave(), NOT G:save -- `Game.save` is the save DATA table, so
|
|
-- calling it as a method fails with "attempt to call method 'save'".
|
|
local ok, err = pcall(function() G:writeSave() end)
|
|
if not ok then
|
|
say(("checkpoint: the game would not save (%s)"):format(tostring(err)))
|
|
return false
|
|
end
|
|
local fh = io.open(CHECKPOINT_PATH, "w")
|
|
if not fh then
|
|
say("checkpoint: could not write " .. CHECKPOINT_PATH)
|
|
return false
|
|
end
|
|
fh:write("-- written by tests/drivers/route.lua; safe to delete\n")
|
|
fh:write(("return { segment = %d, reason = %q, map = %q }\n")
|
|
:format(i, tostring(reason), tostring(ow().map.id)))
|
|
fh:close()
|
|
say(("checkpoint: saved at segment %d/%d on %s (%s) -- resume with "
|
|
.. "POKEPORT_ROUTE_RESUME=1"):format(i, #ROUTE, tostring(ow().map.id),
|
|
tostring(reason)))
|
|
return true
|
|
end
|
|
|
|
local function loadCheckpoint()
|
|
local chunk = loadfile(CHECKPOINT_PATH)
|
|
if not chunk then return nil end
|
|
local ok, data = pcall(chunk)
|
|
if not ok or type(data) ~= "table" or type(data.segment) ~= "number" then
|
|
return nil
|
|
end
|
|
return data
|
|
end
|
|
|
|
local function runRoute(startIndex)
|
|
local failures, skips = 0, 0
|
|
-- one walk-back attempt per segment index before a skip is allowed
|
|
local travelSkipTriedFor = nil
|
|
-- the last segment we actually ran: a blackout only shows up once we are
|
|
-- already at the heal point, so this is what the death gets attributed to
|
|
local lastRanMap
|
|
local diedOn = {} -- segment index -> deaths this attempt
|
|
-- Badge go-back (see the league-gate check in the loop): badges whose
|
|
-- gym segment this attempt has already rewound to, so a gym that
|
|
-- cannot be won does not loop the rewind forever.
|
|
local badgeRescued = {}
|
|
-- one STRENGTH go-back per attempt, same shape as badgeRescued
|
|
local strengthRescued = false
|
|
-- one pre-league grind per attempt. The any% party reaches the Elite
|
|
-- Four as a lone strong starter (BLASTOISE ~L62) over a bench too weak
|
|
-- to trade turns -- which beat Agatha's staller but could not survive
|
|
-- the five-room gauntlet with no nurse between rooms. Level the lead on
|
|
-- Victory Road's wilds first so it can actually solo it.
|
|
local leagueGrinded = false
|
|
local LEAGUE_GRIND_TARGET = 72
|
|
local LEAGUE_GATE_MAPS = {
|
|
ROUTE_22_GATE = true, ROUTE_23 = true, VICTORY_ROAD_1F = true,
|
|
VICTORY_ROAD_2F = true, VICTORY_ROAD_3F = true,
|
|
INDIGO_PLATEAU = true, INDIGO_PLATEAU_LOBBY = true,
|
|
}
|
|
-- route order; badge names are inventory keys, gyms are segment maps
|
|
local BADGE_GYM_SEGMENTS = {
|
|
{ "BOULDERBADGE", "PEWTER_GYM" },
|
|
{ "CASCADEBADGE", "CERULEAN_GYM" },
|
|
{ "THUNDERBADGE", "VERMILION_GYM" },
|
|
{ "RAINBOWBADGE", "CELADON_GYM" },
|
|
{ "SOULBADGE", "FUCHSIA_GYM" },
|
|
{ "MARSHBADGE", "SAFFRON_GYM" },
|
|
{ "VOLCANOBADGE", "CINNABAR_GYM" },
|
|
{ "EARTHBADGE", "VIRIDIAN_GYM" },
|
|
}
|
|
-- segment index -> deaths in a row where training gained no levels.
|
|
-- Reported, not acted on: no death count writes off a segment any more.
|
|
-- A long barren streak means the grind cannot reach a training ground
|
|
-- from where we keep waking up, which is a bug to go and read, not a
|
|
-- reason to stop the run.
|
|
local barren = {}
|
|
-- Rewinding: each one must go strictly further back than the last, or a
|
|
-- pair of segments could bounce us between them forever.
|
|
local rewinds, rewindFloor = 0, #ROUTE + 1
|
|
-- Cross-map rescues spent getting un-lost. Capped for the same reason
|
|
-- rewinds are: travelling to the segment's map fixes being in the wrong
|
|
-- PLACE, and if we keep ending up lost anyway the problem is something
|
|
-- else and retrying the walk forever just burns the attempt quietly.
|
|
local travels = 0
|
|
-- Lost stretches survived by jumping forward instead of dying. The
|
|
-- ROUTE_12 -> ROUTE_8 pocket wedged all five attempts of one run the
|
|
-- same way: travelTo could not thread it, so the attempt abandoned at
|
|
-- 85/197 every time. A forward jump lets the route's own re-sync and
|
|
-- the badge/item go-backs carry on past a stretch travelTo cannot
|
|
-- solve, the way the watchdog's soft-recovery does for loops.
|
|
local lostJumps = 0
|
|
local MAX_LOST_JUMPS = 6
|
|
-- Grinds owed but not yet spendable (see recoverFromBlackout): the death
|
|
-- happens in a gym, the wake-up is in a Poké Center, and the grass is
|
|
-- somewhere between the two.
|
|
local pendingGrind = 0
|
|
blackedOut = false -- each attempt starts from a fresh game
|
|
|
|
-- Recover from a blackout instead of throwing the run away.
|
|
--
|
|
-- We respawn at the last heal point, fully healed, so the only thing
|
|
-- missing is a way back. Rather than pathfind cross-map, hand the route
|
|
-- itself the job: rewind to the most recent segment that starts on the
|
|
-- map we woke up in, and let the normal waypoints walk us forward again.
|
|
-- That is usually a handful of segments, not a restart.
|
|
local function resumeIndexFor(i, mapId)
|
|
for j = i, 1, -1 do
|
|
if ROUTE[j].map == mapId then return j end
|
|
end
|
|
end
|
|
|
|
-- Returns the segment index to resume from, or nil to abandon the attempt.
|
|
function recoverFromBlackout(i, wokeOn, diedMap)
|
|
blackedOut = false
|
|
diedOn[i] = (diedOn[i] or 0) + 1
|
|
|
|
-- Wait out the respawn BEFORE deciding anything from the map.
|
|
--
|
|
-- world.blacked_out fires when the battle decides, not when the warp
|
|
-- to the heal point lands, so a caller that reads ow().map.id right
|
|
-- away hands us the DEATH map as the wake map. Seen live: "wiped on
|
|
-- CELADON_GYM -- woke on CELADON_GYM, resuming from segment 164",
|
|
-- after which the gym segment's waypoints ran on whatever map the
|
|
-- respawn then delivered and the bot spent minutes cutting trees in
|
|
-- circles around Celadon. The respawn always ends inside a Poké
|
|
-- Center (or the Pallet bedroom on a fresh save), so wait for the
|
|
-- map to actually change / a centre to appear, clearing the blackout
|
|
-- text as it comes.
|
|
for _ = 1, 200 do
|
|
local m = tostring(ow().map.id)
|
|
if m:find("POKECENTER") or (m ~= tostring(diedMap) and idle()) then
|
|
break
|
|
end
|
|
if busy() then mashUntilIdle() else U.wait(10) end
|
|
end
|
|
wokeOn = ow().map.id
|
|
|
|
-- Losing the lab rival costs nothing in the original: the battle counts
|
|
-- either way (oaks_lab.lua sets EVENT_BATTLED_RIVAL_IN_OAKS_LAB on a
|
|
-- loss too), we keep the starter, and the story carries on. So the lab
|
|
-- has no work left for us and the honest resume is to step OVER it.
|
|
-- Rewinding onto it the way the generic path would walks back in to
|
|
-- re-run `talk` and a `battle` op that can no longer fire; restarting --
|
|
-- what this used to do -- threw the run away over a near-even level-5
|
|
-- mirror match we would only lose again just as often.
|
|
if diedMap == "OAKS_LAB" and ROUTE[i + 1] and ROUTE[i + 1].map == wokeOn then
|
|
say(("wiped to the lab rival -- the battle still counted, resuming from "
|
|
.. "segment %d/%d"):format(i + 1, #ROUTE))
|
|
return i + 1
|
|
end
|
|
|
|
-- Get out of the Poké Center before anything else.
|
|
--
|
|
-- A blackout leaves us INSIDE one, where no encounter can ever fire, so
|
|
-- training attempted from here reports "no wild encounters reachable on
|
|
-- <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)
|
|
-- same report the watchdog writes, so every stop explains itself
|
|
WD.reportStuck({ period = 0, count = 1, cycle = {},
|
|
line = "abandoned: " .. tostring(reason) })
|
|
saveCheckpoint(at, reason)
|
|
if STOP_ON_STUCK then return nil end
|
|
return false
|
|
end
|
|
|
|
local i = (startIndex and startIndex > 1) and (startIndex - 1) or 0
|
|
while i < #ROUTE do
|
|
i = i + 1
|
|
local seg = ROUTE[i]
|
|
WD.progress.segment = i
|
|
-- ops.fieldMove and the elevator handler read where the NEXT segment
|
|
-- wants us; set it up here so both can see it from the loop's top.
|
|
nextMapWanted = ROUTE[i + 1] and ROUTE[i + 1].map or nil
|
|
|
|
-- On-demand checkpoint: `touch /tmp/pokeport_route_savenow` and the
|
|
-- run banks its progress (game save + segment) at the next segment
|
|
-- boundary. Exists because a driver fix mid-run used to mean killing
|
|
-- the process -- and with it every level the Silph attrition war had
|
|
-- bought since the last stuck-checkpoint.
|
|
do
|
|
local marker = io.open("/tmp/pokeport_route_savenow", "r")
|
|
if marker then
|
|
marker:close()
|
|
os.remove("/tmp/pokeport_route_savenow")
|
|
saveCheckpoint(i, "requested via savenow marker")
|
|
end
|
|
end
|
|
|
|
-- Rolling checkpoint every 10 segments, no external toucher needed.
|
|
-- A run once reached segment 182 with all eight badges and then
|
|
-- "completed" by skipping the sealed league -- and NOTHING was on
|
|
-- disk: no toucher was running, no abandon fired, and route
|
|
-- completion never saved. Hours of progress, gone at exit. Never
|
|
-- again: the driver banks its own progress as it goes.
|
|
if i % 10 == 0 then
|
|
saveCheckpoint(i, "rolling (every 10 segments)")
|
|
end
|
|
|
|
-- GO BACK FOR A MISSING BADGE before the league gate can wall us.
|
|
--
|
|
-- Route 22/23's guards badge-check in order and shove the player
|
|
-- back (vanilla), and the route only ever moves FORWARD -- so a run
|
|
-- that lost a gym segment to a travel failure (Blaine's whole
|
|
-- island skipped over a failed sea trip, once) used to reach the
|
|
-- gate with seven badges and thrash against the shove until the
|
|
-- attempt died. The badges are ordinary inventory; when a league
|
|
-- segment comes up short, rewind the index to the missing badge's
|
|
-- own gym segment and let the ordinary mismatch->travelTo machinery
|
|
-- carry us there. Earliest missing badge first (Cinnabar's segment
|
|
-- sits before Viridian's, and Viridian Gym itself only unlocks at
|
|
-- seven badges). Once per badge per attempt, so a gym that
|
|
-- genuinely cannot be won does not loop the rewind forever.
|
|
if LEAGUE_GATE_MAPS[seg.map] then
|
|
-- STRENGTH gate, mirroring the badge go-back below: Victory Road is
|
|
-- boulders before it is anything else, and one run arrived with all
|
|
-- eight badges and no STRENGTH -- the Safari gate misfire silently
|
|
-- dropped GOLD_TEETH, so the Warden never paid out and nothing
|
|
-- noticed for fifty segments. Rewind to the link that is actually
|
|
-- missing, once per attempt. (HM-in-bag-but-untaught needs no
|
|
-- rewind: ops.fieldMove now teaches from the bag on the spot.)
|
|
if not strengthRescued and not slotKnowing("STRENGTH")
|
|
and not (G.save.inventory or {}).HM_STRENGTH then
|
|
local wantMap = (G.save.inventory or {}).GOLD_TEETH
|
|
and "WARDENS_HOUSE" or "SAFARI_ZONE_WEST"
|
|
local back
|
|
-- EARLIEST visit: SAFARI_ZONE_WEST's later twin is the dig-out
|
|
-- segment, not the one that stands at the GOLD_TEETH ball
|
|
for j = 1, i - 1 do
|
|
if ROUTE[j].map == wantMap then back = j break end
|
|
end
|
|
if back then
|
|
strengthRescued = true
|
|
say(("the league gate is ahead but STRENGTH is missing -- "
|
|
.. "rewinding to segment %d/%d (%s) for %s")
|
|
:format(back, #ROUTE, wantMap,
|
|
wantMap == "WARDENS_HOUSE" and "HM_STRENGTH"
|
|
or "the GOLD_TEETH"))
|
|
i = back - 1
|
|
goto nextSegment
|
|
end
|
|
end
|
|
local missing
|
|
for _, bg in ipairs(BADGE_GYM_SEGMENTS) do
|
|
if not (G.save.inventory or {})[bg[1]] and not badgeRescued[bg[1]] then
|
|
missing = bg
|
|
break
|
|
end
|
|
end
|
|
if missing then
|
|
local back
|
|
for j = i - 1, 1, -1 do
|
|
if ROUTE[j].map == missing[2] then back = j break end
|
|
end
|
|
if back then
|
|
badgeRescued[missing[1]] = true
|
|
say(("the league gate is ahead but %s is missing -- rewinding "
|
|
.. "to segment %d/%d (%s) to earn it")
|
|
:format(missing[1], back, #ROUTE, missing[2]))
|
|
i = back - 1
|
|
goto nextSegment
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Standing in an elevator? Ride out to the floor we need, before the
|
|
-- map-wait can react to a bounce.
|
|
--
|
|
-- An elevator is entered two ways, and both end here: on purpose (the
|
|
-- route's segment 98), or by accident -- a floor's elevator-door warp
|
|
-- at (1,1) catching a goto mid-climb. Either way, an un-ridden car
|
|
-- oscillates on its default 1F exit (ELEVATOR <-> 1F forever), which is
|
|
-- what wedged the run at Celadon. Checking the PHYSICAL map, not
|
|
-- seg.map, catches the accidental case the seg.map-only check missed.
|
|
-- Ride to seg.map's own floor when we were mid-climb, or to the next
|
|
-- segment's floor when this segment IS the elevator.
|
|
if tostring(ow().map.id):find("ELEVATOR") then
|
|
local floor = tostring(seg.map):find("ELEVATOR") and nextMapWanted or seg.map
|
|
rideElevator("elevator", floor)
|
|
if tostring(seg.map):find("ELEVATOR") then goto nextSegment end
|
|
end
|
|
|
|
-- Once the essential Celadon Mart shopping is done, LEAVE.
|
|
--
|
|
-- The only things the mart is climbed for are the POKE_DOLL (4F, the
|
|
-- Marowak escape) and a drink (roof, the Saffron gates). After those,
|
|
-- the route's remaining mart segments -- 5F again, the elevator, 1F --
|
|
-- are pure descent, and they are a warp bounce-trap: the elevator drops
|
|
-- you onto the reciprocal warp and oscillates ELEVATOR <-> 1F forever.
|
|
-- So when both essentials are in the bag and we are still in the mart,
|
|
-- walk out to CELADON_CITY and jump to the segment that resumes there,
|
|
-- skipping the whole fragile descent.
|
|
local function hasDrink()
|
|
for _, d in ipairs({ "FRESH_WATER", "SODA_POP", "LEMONADE" }) do
|
|
if (heldCount(d) or 0) > 0 then return true end
|
|
end
|
|
return false
|
|
end
|
|
if tostring(seg.map):find("CELADON_MART")
|
|
and (heldCount("POKE_DOLL") or 0) > 0 and hasDrink() then
|
|
for j = i, math.min(i + 8, #ROUTE) do
|
|
if ROUTE[j].map == "CELADON_CITY" then
|
|
say(("Celadon Mart done (POKE_DOLL + drink) -- leaving to "
|
|
.. "CELADON_CITY, resuming segment %d/%d"):format(j, #ROUTE))
|
|
if travelTo("CELADON_CITY", seg.map) then i = j - 1 end
|
|
goto nextSegment
|
|
end
|
|
end
|
|
end
|
|
|
|
-- The route rides the Celadon elevator only to descend to 1F, and every
|
|
-- Celadon floor is joined by stairs, so the elevator is optional there
|
|
-- and a liability (see the bounce-trap note in mapGraph). If this
|
|
-- segment IS the elevator and we are not in it, skip it -- the next
|
|
-- segment's floor is reached by the stairs-based travelTo below.
|
|
if tostring(seg.map):find("CELADON_MART_ELEVATOR") then
|
|
goto nextSegment
|
|
end
|
|
|
|
-- Multi-floor interior: climb by stairs via travelTo, not the route's
|
|
-- per-floor gotos. Celadon Mart is six floors joined by up/down stairs
|
|
-- (and an elevator), and the route walks each floor by hand -- a path
|
|
-- that keeps stepping onto the wrong staircase or the elevator door and
|
|
-- desyncing, which is what skipped the ROOF and its Fresh Water (the
|
|
-- key to every Saffron gate). travelTo targets each specific stair warp
|
|
-- and climbs cleanly; the segment's own steps then handle the in-floor
|
|
-- shopping once we are on the right floor.
|
|
if ow().map.id ~= seg.map and tostring(seg.map):find("CELADON_MART") then
|
|
travelTo(seg.map, seg.map)
|
|
end
|
|
|
|
-- Wait for the map to actually be the one this segment describes.
|
|
--
|
|
-- PokeBotBad advances its path list on map CHANGE (action/walk.lua:91)
|
|
-- rather than by counting steps, and that difference matters: a warp
|
|
-- or a cutscene can still be in flight when the previous segment's
|
|
-- last waypoint lands. Oak's escort is the worst case -- it walks the
|
|
-- player from Pallet Town into the lab over many frames, so running
|
|
-- the lab segment immediately fires the starter pick and the rival
|
|
-- battle out in Pallet Town, where they silently do nothing. The
|
|
-- rival then never leaves, and blocks the route on the return visit.
|
|
if ow().map.id ~= seg.map then
|
|
for _ = 1, 240 do
|
|
if ow().map.id == seg.map then break end
|
|
if inBattle() then fightBattle()
|
|
elseif busy() then mashUntilIdle()
|
|
else U.wait(5) end
|
|
end
|
|
end
|
|
local here = ow().map.id
|
|
if here ~= seg.map then
|
|
WD.progress.stepIndex, WD.progress.stepCount = nil, nil
|
|
WD.progress.action = ("re-sync: expected %s, standing on %s")
|
|
:format(seg.map, here)
|
|
-- Never run a segment's steps on the wrong map: the waypoints are
|
|
-- meaningless there and the interactions land on whatever happens
|
|
-- to be adjacent. Skip it and let a later segment re-sync.
|
|
-- A blackout is the one mismatch that will never re-sync: it drops us
|
|
-- at the heal point while the route points somewhere else, so every
|
|
-- remaining segment skips in one burst and the attempt reports a
|
|
-- full-length pass over nothing. End it here instead.
|
|
if blackedOut then
|
|
-- `here` is the heal point, not where we died -- blame the last
|
|
-- segment we actually ran, or the memory learns "Poké Center is
|
|
-- dangerous" and nothing useful.
|
|
recordDeath(lastRanMap)
|
|
local resumed = recoverFromBlackout(i, here, lastRanMap)
|
|
if not resumed then return abandon(i, "died too many times") end
|
|
i = resumed - 1 -- the loop's own i = i + 1 lands us on it
|
|
goto nextSegment
|
|
end
|
|
-- We are on a sub-map the route has finished with, and nothing walks
|
|
-- us out of it. Any map with a LAST_MAP warp is one we entered from
|
|
-- somewhere else, so that warp is the way back to a map the route
|
|
-- still has segments for.
|
|
--
|
|
-- Poké Centers are the case that made this obvious (a blackout or an
|
|
-- ESCAPE ROPE drops us in one), but they are not the only one:
|
|
-- VERMILION_DOCK strands us the same way. Its segment 68 is the single
|
|
-- step `goto (14,2)` -- the gangplank tile we are already standing on
|
|
-- when we disembark -- while the exit is warp 1 at (14,0), which the
|
|
-- route never mentions. The bot stood on the arrival tile and skipped
|
|
-- every remaining segment.
|
|
local wayOut = findWarpTo("LAST_MAP")
|
|
if wayOut then
|
|
local from = here
|
|
if walkOntoWarp(wayOut.x, wayOut.y) then
|
|
here = ow().map.id
|
|
say(("stepped out of %s onto %s"):format(tostring(from), tostring(here)))
|
|
if here == seg.map then
|
|
skips = 0
|
|
goto runSegment
|
|
end
|
|
end
|
|
end
|
|
-- WALK to the segment before skipping it -- once per segment index.
|
|
--
|
|
-- Skipping exists for segments a cutscene already carried us past.
|
|
-- But the route revisits maps, so a run of skips re-enters at the
|
|
-- first segment matching wherever we STAND -- which can be a LATER
|
|
-- twin of the same map, silently dropping everything between.
|
|
-- ROUTE_16_GATE_1F appears at both ~102 and 120: standing there
|
|
-- after a stall skipped 112-119 in one burst, and with them the
|
|
-- POKE_DOLL, the MAROWAK, and Mr. Fuji's POKE_FLUTE -- after which
|
|
-- the SNORLAX is unpassable and the run is dead anyway. A travel
|
|
-- that works costs a walk; a skip that is wrong costs the game.
|
|
if travelSkipTriedFor ~= i then
|
|
travelSkipTriedFor = i
|
|
say(("segment %d/%d expects %s -- travelling there before skipping")
|
|
:format(i, #ROUTE, seg.map))
|
|
if travelTo(seg.map, here) and ow().map.id == seg.map then
|
|
skips = 0
|
|
goto runSegment
|
|
end
|
|
here = ow().map.id -- a failed trip still moves us
|
|
end
|
|
say(("skipping segment %d/%d: expects %s, on %s")
|
|
:format(i, #ROUTE, seg.map, here))
|
|
failures = failures + 1
|
|
skips = skips + 1
|
|
-- Skipping is meant to step over a segment a cutscene carried us
|
|
-- past. A long unbroken run of them means we are lost, and carrying
|
|
-- on is actively dangerous: the route revisits maps, so segment 175
|
|
-- (the ENDGAME Viridian Gym) matches "VIRIDIAN_CITY" just as well as
|
|
-- segment 12 does. One desynced attempt did exactly that and spent
|
|
-- its remaining time walking a level-12 party into a gym locked
|
|
-- until seven badges.
|
|
if skips > MAX_CONSECUTIVE_SKIPS then
|
|
-- Rewind FURTHER before writing the attempt off.
|
|
--
|
|
-- Resuming lands us on a segment that merely *matches* the map we
|
|
-- woke on, which is not the same as one that LEADS anywhere: after
|
|
-- a Cerulean death the resume sat on CERULEAN_CITY while segments
|
|
-- 46+ wanted ROUTE_24, BILLS_HOUSE, VERMILION... and every one
|
|
-- skipped. The route already contains the walk we need -- it is
|
|
-- just earlier in the list. So step back to an earlier segment on
|
|
-- the map we are actually standing on and let the route drive us
|
|
-- forward again, rather than giving up on a run that is merely
|
|
-- pointed at the wrong entry.
|
|
local j
|
|
for k = math.min(rewindFloor, i) - 1, 1, -1 do
|
|
if ROUTE[k].map == here then j = k break end
|
|
end
|
|
if j and rewinds < MAX_REWINDS then
|
|
rewinds = rewinds + 1
|
|
rewindFloor = j
|
|
skips = 0
|
|
say(("lost on %s -- rewinding to segment %d/%d (rewind %d/%d) "
|
|
.. "instead of abandoning"):format(here, j, #ROUTE, rewinds,
|
|
MAX_REWINDS))
|
|
i = j - 1
|
|
goto nextSegment
|
|
end
|
|
-- Last resort, AFTER the rewind has run out of ideas.
|
|
--
|
|
-- Ordering matters and was learned the hard way: this used to run
|
|
-- BEFORE the rewind, on the theory that walking to the segment's
|
|
-- map is more honest than replaying. It is -- but it is also a long
|
|
-- walk that MOVES us, and moving invalidates the rewind targets
|
|
-- behind it. A Mt. Moon attempt that the rewind alone had always
|
|
-- recovered instead travelled to MT_MOON_1F, failed to find the
|
|
-- B1F staircase, rewound from there and walked back out to ROUTE_4
|
|
-- worse off than it started. The rewind is cheap and proven; this
|
|
-- is neither, so it only gets the cases the rewind cannot take.
|
|
if travels < MAX_TRAVEL_RESCUES and travelTo(seg.map, here) then
|
|
travels = travels + 1
|
|
skips = 0
|
|
say(("travelled to %s -- resuming segment %d/%d (rescue %d/%d)")
|
|
:format(seg.map, i, #ROUTE, travels, MAX_TRAVEL_RESCUES))
|
|
i = i - 1
|
|
goto nextSegment
|
|
end
|
|
-- A failed travelTo still moves us -- it abandons the trip wherever
|
|
-- the last hop landed -- so report where we actually are.
|
|
here = ow().map.id
|
|
-- Before writing the whole attempt off: JUMP FORWARD to the next
|
|
-- segment whose map we can actually reach from here, and carry
|
|
-- on. A stretch travelTo cannot thread (the ROUTE_12 west pocket)
|
|
-- should cost a few skipped segments, not the run -- the badge
|
|
-- and STRENGTH go-backs refetch anything load-bearing that the
|
|
-- jump steps over.
|
|
if lostJumps < MAX_LOST_JUMPS then
|
|
local jumpTo
|
|
for j = i + 1, #ROUTE do
|
|
if ROUTE[j].map == here then jumpTo = j break end
|
|
end
|
|
-- nothing on this map ahead: hand it to reachableSet via a
|
|
-- forward scan for a segment travelTo can still get to
|
|
if not jumpTo then
|
|
for j = i + 1, math.min(i + 20, #ROUTE) do
|
|
if ROUTE[j].map ~= seg.map then jumpTo = j break end
|
|
end
|
|
end
|
|
if jumpTo then
|
|
lostJumps = lostJumps + 1
|
|
skips = 0
|
|
say(("lost on %s -- jumping forward to segment %d/%d (%s), "
|
|
.. "recovery %d/%d"):format(here, jumpTo, #ROUTE,
|
|
ROUTE[jumpTo].map, lostJumps, MAX_LOST_JUMPS))
|
|
-- Advancing the index alone left us PHYSICALLY marooned in the
|
|
-- ROUTE_12 (0,62) pocket, so the next travelTo re-trapped from
|
|
-- the identical dead spot -- the jump just cycled. DIG warps
|
|
-- to the last Poké Center, a real hub the route can navigate
|
|
-- from; point it at the jump target's map so the built-in
|
|
-- post-DIG travelTo heads the right way.
|
|
if slotKnowing("DIG") then
|
|
nextMapWanted = ROUTE[jumpTo].map
|
|
ops.fieldMove({ move = "dig" }, here)
|
|
end
|
|
i = jumpTo - 1
|
|
goto nextSegment
|
|
end
|
|
end
|
|
say(("lost: %d segments skipped in a row, abandoning attempt at %d/%d")
|
|
:format(skips, i, #ROUTE))
|
|
return abandon(i, ("lost on %s, expected %s"):format(here, seg.map))
|
|
end
|
|
goto nextSegment
|
|
end
|
|
::runSegment::
|
|
skips = 0
|
|
if i > WD.progress.maxSegment then
|
|
WD.progress.maxSegment = i
|
|
-- a new segment reached on its own map IS progress: forget the old
|
|
-- chatter so earlier retries cannot trip the loop detector later
|
|
WD.history = {}
|
|
end
|
|
lastRanMap = seg.map
|
|
-- Spend a deferred grind as soon as we are somewhere it can work.
|
|
if pendingGrind > 0 and G.data.encounters and G.data.encounters[seg.map] then
|
|
if grindALevel(seg.map) then
|
|
pendingGrind = 0
|
|
else
|
|
pendingGrind = pendingGrind - 1 -- do not keep retrying forever
|
|
end
|
|
end
|
|
-- About to walk into a gym: stop at the town's Poké Center first.
|
|
--
|
|
-- Two payoffs, and the second is the bigger one. It heals to full for
|
|
-- free, and the nurse writes the heal point (SetLastBlackoutMap), so a
|
|
-- loss respawns us next door instead of wherever we last healed. Without
|
|
-- this the bot arrived at Brock healthy, never touched Pewter's centre,
|
|
-- and its respawn was still VIRIDIAN -- so every Brock death cost a full
|
|
-- recrossing of Viridian Forest. It died to Brock on the first attempt in
|
|
-- three runs straight, and paid that walk each time.
|
|
-- RESTORED: the pre-gym Poké Center stop (allowCenter = true).
|
|
--
|
|
-- It was removed because the nurse writes the heal point and the route
|
|
-- TRAVELS by heal point: segment 72 leaves the Pokémon Fan Club with
|
|
-- `fieldMove dig`, DIG warps to the last Poké Center town, and the
|
|
-- route assumes that is Cerulean -- so topping up at Vermilion before
|
|
-- Surge silently retargeted the trip and stranded the run. The note
|
|
-- left here said the fix was "NOT to put this back as it was -- it is
|
|
-- to make the return trip independent of the heal point".
|
|
--
|
|
-- That is now done: ops.fieldMove re-targets DIG and FLY through
|
|
-- travelTo when the landing map is not what the next segment wants, so
|
|
-- where we last healed no longer steers the route. The precondition is
|
|
-- met, so the stop comes back.
|
|
--
|
|
-- It is worth real time. Without it the heal point stays wherever it
|
|
-- happened to be -- a ROUTE_3 death after Pewter woke us in VIRIDIAN
|
|
-- and cost a full re-crossing of Viridian Forest -- and gym losses
|
|
-- respawn a town behind instead of next door.
|
|
-- nextMapWanted is already set at the loop top (ops.fieldMove and the
|
|
-- elevator handler both read it); nextSeg here is just for the gym test.
|
|
local nextSeg = ROUTE[i + 1]
|
|
if nextSeg and tostring(nextSeg.map):find("GYM") then
|
|
-- Unconditional, NOT via autoHeal's threshold.
|
|
--
|
|
-- autoHeal only walks to a nurse when HP is under the bar, and we
|
|
-- arrive at gyms healthy -- so routing this through it did nothing at
|
|
-- all: PEWTER_GYM still killed us five times with VIRIDIAN as the
|
|
-- respawn, paying a re-crossing of Viridian Forest each time. The
|
|
-- heal is the smaller half of this stop; the heal POINT is the point.
|
|
visitPokeCenter(seg.map, true)
|
|
healAnchor = seg.map
|
|
autoHeal(seg.map, 0.95, true)
|
|
end
|
|
-- Keep the respawn near where we actually are.
|
|
--
|
|
-- A blackout warps to wLastBlackoutMap, and the nurse is the only thing
|
|
-- that moves it. Left alone it lags badly: with VERMILION as the last
|
|
-- centre, a death in ROCK_TUNNEL_B1F woke us there and resumed eight
|
|
-- segments back, replaying the Fan Club DIG and the whole underground
|
|
-- walk to Cerulean -- twice in a row, which is most of what a stalled
|
|
-- run is actually spending its time on.
|
|
--
|
|
-- Once per town, and only when we are ACTUALLY STANDING on that town.
|
|
--
|
|
-- The guard is `ow().map.id == seg.map`, and it is load-bearing, not
|
|
-- belt-and-braces. findWarpTo reads the CURRENT map, but the fire
|
|
-- condition keyed on seg.map -- so when a warp had bounced us onto a
|
|
-- different map than the segment expected, this dove into whatever
|
|
-- centre the map we happened to be standing on had. That is exactly how
|
|
-- it broke the S.S. Anne: boarding put us on the ship, an edge-warp
|
|
-- bounced us back to VERMILION_CITY, and this then marched into
|
|
-- Vermilion's Poké Center while the segment wanted SS_ANNE_1F, skipping
|
|
-- the entire ship -- and with it HM01 CUT, which the run needs 15
|
|
-- segments later to clear the Route 9 tree.
|
|
--
|
|
-- Re-anchoring is a convenience (cheaper deaths); it must never perturb
|
|
-- a segment that is mid-warp. On our own map with a centre, and not
|
|
-- already anchored here.
|
|
if ow().map.id == seg.map and healAnchor ~= seg.map
|
|
and findWarpTo("POKECENTER") then
|
|
local was = healAnchor
|
|
healAnchor = seg.map
|
|
say(("re-anchoring the heal point at %s (was %s)")
|
|
:format(seg.map, tostring(was)))
|
|
visitPokeCenter(seg.map, true)
|
|
end
|
|
-- The Indigo lobby is the one nurse with no POKECENTER warp -- we are
|
|
-- already standing in the building she works in. Anchor the respawn
|
|
-- here before the Elite Four: a loss otherwise wakes us a region away
|
|
-- (travelTo cannot re-cross Victory Road's boulder puzzles), and the
|
|
-- lobby segment is what a blackout resumes from, so the walk back to
|
|
-- Lorelei's door is the route's own steps. Restock while we are here:
|
|
-- five fights, no nurse between them, and the bag holds whatever
|
|
-- survived Victory Road.
|
|
if seg.map == "INDIGO_PLATEAU_LOBBY" and ow().map.id == seg.map then
|
|
if healAnchor ~= seg.map then
|
|
healAnchor = seg.map
|
|
say("re-anchoring the heal point at the Indigo lobby nurse")
|
|
talkToNurse(seg.map)
|
|
end
|
|
ops.shop({ list = "indigo" }, seg.map)
|
|
end
|
|
-- Saffron is the Silph staging town, and a checkpoint-resumed bag
|
|
-- arrives EMPTY -- the shop segments live behind the checkpoint, so
|
|
-- the 7F rival was fought potion-less five wipes in a row. When the
|
|
-- bag has no HP item, buy some next door before climbing the tower
|
|
-- of trainers.
|
|
-- Victory Road floors: put the boulders on their switches FIRST,
|
|
-- before the segment's own steps walk into (or shove apart) the
|
|
-- puzzle. Idempotent -- each switch is guarded by its event flag.
|
|
if VR_SWITCHES[seg.map] and ow().map.id == seg.map then
|
|
solveVictoryRoadSwitches()
|
|
end
|
|
-- Guarantee the POKE_FLUTE. It gates two Snorlax (ROUTE_16, ROUTE_12),
|
|
-- and its handoff is a two-step scripted chain the travel/skip machinery
|
|
-- can bypass: talk Fuji at the tower top (rescue -> warps you home) then
|
|
-- talk him again at his house (gives the flute, but ONLY if rescued -
|
|
-- story.lua:439). A skipped tower talk means Fuji never appears at home,
|
|
-- the house talk gives nothing, and Route 16 seals the run seven
|
|
-- segments later. Do it here, keyed on the flags, not the fragile
|
|
-- waypoints. Same family as the CARD_KEY and GOLD_TEETH misses.
|
|
local fujiFlags = G.save.flags or {}
|
|
if not fujiFlags.EVENT_GOT_POKE_FLUTE then
|
|
if ow().map.id == "POKEMON_TOWER_7F"
|
|
and not fujiFlags.EVENT_RESCUED_MR_FUJI then
|
|
local fuji = findObject("MR_FUJI")
|
|
if fuji and stepUpTo(fuji.x, fuji.y) then
|
|
interact()
|
|
mashUntilIdle()
|
|
say("rescued Mr. Fuji at the tower top")
|
|
end
|
|
elseif ow().map.id == "MR_FUJIS_HOUSE"
|
|
and fujiFlags.EVENT_RESCUED_MR_FUJI then
|
|
local fuji = findObject("MR_FUJI")
|
|
if fuji and stepUpTo(fuji.x, fuji.y) then
|
|
interact()
|
|
mashUntilIdle()
|
|
if (G.save.flags or {}).EVENT_GOT_POKE_FLUTE then
|
|
say("collected the POKE_FLUTE from Mr. Fuji")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
-- Level the lead for the Elite Four while standing on Victory Road's
|
|
-- wilds -- the last grass before the sealed five-room gauntlet. Once
|
|
-- per attempt, and only if the lead is actually short of the target,
|
|
-- so a strong resume does not pace for nothing.
|
|
if not leagueGrinded and tostring(seg.map):find("VICTORY_ROAD")
|
|
and ow().map.id == seg.map and G.data.encounters
|
|
and G.data.encounters[seg.map] then
|
|
leagueGrinded = true
|
|
local lead = party()[1]
|
|
local cur = lead and lead.level or 0
|
|
if cur > 0 and cur < LEAGUE_GRIND_TARGET then
|
|
say(("pre-league grind: lead is L%d, training toward L%d on %s")
|
|
:format(cur, LEAGUE_GRIND_TARGET, seg.map))
|
|
local gained = grindLevels(LEAGUE_GRIND_TARGET - cur, seg.map)
|
|
say(("pre-league grind: gained %d level(s), lead now L%d")
|
|
:format(gained, (party()[1] or {}).level or cur))
|
|
autoHeal(seg.map, 0.95, true)
|
|
end
|
|
end
|
|
local RESTOCK_TOWNS = { SAFFRON_CITY = "saffronRestock",
|
|
CINNABAR_ISLAND = "cinnabarRestock" }
|
|
local restockList = RESTOCK_TOWNS[seg.map]
|
|
if restockList and ow().map.id == seg.map then
|
|
local hpItems = 0
|
|
for _, id in ipairs(HP_ITEMS) do hpItems = hpItems + (heldCount(id) or 0) end
|
|
if hpItems < 2 then
|
|
local mart = findWarpTo("MART")
|
|
if mart and walkOntoWarp(mart.x, mart.y) then
|
|
say(("bag is out of HP items -- restocking (%s)"):format(restockList))
|
|
ops.shop({ list = restockList }, seg.map)
|
|
local out = findWarpTo("LAST_MAP") or findWarpTo("")
|
|
if out then walkOntoWarp(out.x, out.y) end
|
|
end
|
|
end
|
|
end
|
|
if centerCooldown > 0 then centerCooldown = centerCooldown - 1 end
|
|
-- the one place a nurse detour is safe: between segments, where the
|
|
-- next segment re-paths from wherever the warp left us
|
|
autoHeal(seg.map, riskThreshold(seg.map), true)
|
|
|
|
-- (Elevator handling now lives at the top of the loop, keyed on the
|
|
-- PHYSICAL map, so it catches both the intended arrival and an
|
|
-- accidental entry mid-climb. See there.)
|
|
for si, s in ipairs(seg.steps) do
|
|
if blackedOut or wiped() then
|
|
recordDeath(seg.map)
|
|
-- wait out the warp to the heal point before deciding where we are
|
|
for _ = 1, 240 do
|
|
if idle() and not inBattle() then break end
|
|
if inBattle() then fightBattle() else mashUntilIdle() end
|
|
end
|
|
local resumed = recoverFromBlackout(i, ow().map.id, seg.map)
|
|
if not resumed then return abandon(i, "died too many times") end
|
|
i = resumed - 1
|
|
goto nextSegment
|
|
end
|
|
-- A warp, a ledge hop or a gate shove can move us off this segment's
|
|
-- map partway through it. Its remaining waypoints are meaningless
|
|
-- here: out of bounds they look like the route's "leave by this edge"
|
|
-- idiom, so we spend the rest of the segment shoving Red into a
|
|
-- border -- e.g. Viridian's (20,35) run while still on ROUTE_1, which
|
|
-- pushes right into Route 1's east wall over and over. Stop and let
|
|
-- the map-wait at the top of the loop re-sync us.
|
|
if ow().map.id ~= seg.map then
|
|
say(("left %s partway through segment %d/%d (now on %s); re-syncing")
|
|
:format(seg.map, i, #ROUTE, ow().map.id))
|
|
failures = failures + 1
|
|
break
|
|
end
|
|
-- Re-check health before EVERY step, not just at the segment
|
|
-- boundary. Route trainers engage on sight as we walk past them --
|
|
-- they never come through ops.battle -- so a segment that began
|
|
-- healthy can still march a nearly-dead lead into a fight it cannot
|
|
-- flee (trainers refuse). Route 3 after Brock is exactly this: the
|
|
-- gauntlet IS the map, and the old per-segment check only looked
|
|
-- once, before the first of them.
|
|
autoHeal(seg.map, riskThreshold(seg.map))
|
|
WD.progress.stepIndex, WD.progress.stepCount = si, #seg.steps
|
|
WD.progress.action = WD.describeStep(s)
|
|
local fn = ops[s.op == "goto" and "goto_" or s.op]
|
|
if not fn then
|
|
note("UNHANDLED:" .. s.op, seg.map)
|
|
else
|
|
-- ops receive whether this is the segment's final step; goto_ uses
|
|
-- it to decide a warp is an intended exit rather than an arrival tile
|
|
local ok = fn(s, seg.map, si == #seg.steps)
|
|
if ok == false then failures = failures + 1 end
|
|
end
|
|
end
|
|
if i % 10 == 0 then
|
|
say(("segment %d/%d map=%s failures=%d"):format(i, #ROUTE, ow().map.id, failures))
|
|
end
|
|
::nextSegment::
|
|
end
|
|
|
|
say("---- route complete ----")
|
|
say(("segments: %d failures: %d"):format(#ROUTE, failures))
|
|
-- bank the final state whatever happened: a "complete" run that
|
|
-- skip-cascaded its tail used to exit with nothing on disk
|
|
saveCheckpoint(#ROUTE, "route complete")
|
|
return true
|
|
end
|
|
|
|
-- Run one attempt with the stuck watchdog armed. The watchdog throws its
|
|
-- sentinel out of whatever loop was repeating itself; translate that into
|
|
-- the same checkpoint-and-stop shape abandon() produces, plus the written
|
|
-- report of where the run was and what it wanted next.
|
|
local function runRouteGuarded(startIndex)
|
|
local resumeAt = startIndex
|
|
local recoveries = 0
|
|
local stuckAt = {} -- segment -> times wedged there (progressive rewind)
|
|
while true do
|
|
WD.history = {}
|
|
WD.lastActivity = U.frame()
|
|
WD.armed = WD.enabled
|
|
local ok, result = pcall(runRoute, resumeAt)
|
|
WD.armed = false
|
|
if ok then return result end
|
|
if not (type(result) == "table" and result.routeStuck) then
|
|
error(result, 0) -- a real bug, not a stuck loop; let the harness print it
|
|
end
|
|
local hit = result.routeStuck
|
|
WD.reportStuck(hit)
|
|
saveCheckpoint(math.max(WD.progress.segment, 1),
|
|
"stuck loop: " .. tostring(hit.line))
|
|
if STOP_ON_STUCK then return nil end
|
|
-- A 10-repeat stuck loop is NOT a stop. Per the run's rule: try
|
|
-- something else, then rewind and continue. The run only ever ends on
|
|
-- a real death-abandon, route completion, or the outer time cap.
|
|
recoveries = recoveries + 1
|
|
|
|
-- (1) Try something else: DIG out to the last Poké Center. Some wedges
|
|
-- have no in-place escape -- the ROUTE_12 (0,62) pocket is walled by a
|
|
-- Snorlax, water, and an out-of-region seam -- and a rewind alone
|
|
-- would just walk back into them. A hub the route can navigate from
|
|
-- breaks that. Outdoors + DIG known only; the engine refuses it
|
|
-- indoors, where a rewind is the right tool anyway.
|
|
for _, d in ipairs({ "up", "down", "left", "right",
|
|
"a", "b", "start", "select" }) do
|
|
G.input.state[d] = false -- sentinel can unwind mid-menu, keys held
|
|
end
|
|
for _ = 1, 10 do
|
|
if idle() and not inBattle() then break end
|
|
if inBattle() then fightBattle() else mashUntilIdle() end
|
|
end
|
|
if slotKnowing and slotKnowing("DIG") and G.overworld
|
|
and G.stack:top() == G.overworld then
|
|
local before = ow().map.id
|
|
nextMapWanted = nil
|
|
pcall(function() ops.fieldMove({ move = "dig" }, before) end)
|
|
if ow().map.id ~= before then
|
|
say(("watchdog: DIG warped %s -> %s (trying something else)")
|
|
:format(tostring(before), tostring(ow().map.id)))
|
|
end
|
|
end
|
|
|
|
-- (2) Rewind ~10 segments and continue. If the SAME stretch keeps
|
|
-- wedging, back up further each time (10, 20, 30...) so two segments
|
|
-- cannot ping-pong forever -- eventually the rewind reaches ground the
|
|
-- route can cross, or the whole route replays from the start.
|
|
local at = math.max(WD.progress.segment, 1)
|
|
stuckAt[at] = (stuckAt[at] or 0) + 1
|
|
local back = 10 * stuckAt[at]
|
|
resumeAt = math.max(1, at - back)
|
|
say(("watchdog: stuck at segment %d (x%d) -- rewinding %d to segment %d "
|
|
.. "and continuing"):format(at, stuckAt[at], back, resumeAt))
|
|
|
|
-- Last-resort backstop only: after very many recoveries the progressive
|
|
-- rewind has replayed from the start repeatedly and still cannot pass.
|
|
-- Restart the ATTEMPT (fresh game, next of the five) rather than stop
|
|
-- the run -- this is not the 10-repeat halt, it is genuine give-up.
|
|
if recoveries > 25 then
|
|
say("watchdog: 25 recoveries without lasting progress -- restarting "
|
|
.. "the attempt")
|
|
return false
|
|
end
|
|
end
|
|
end
|
|
|
|
local function report()
|
|
local keys = {}
|
|
for k in pairs(skipped) do keys[#keys + 1] = k end
|
|
table.sort(keys)
|
|
if #keys > 0 then
|
|
say("skipped ops:")
|
|
for _, k in ipairs(keys) do say((" %-40s x%d"):format(k, skipped[k])) end
|
|
end
|
|
end
|
|
|
|
return function(game)
|
|
G = game
|
|
loadMemory()
|
|
do
|
|
local known = {}
|
|
for map, n in pairs(deaths) do known[#known + 1] = ("%s x%d"):format(map, n) end
|
|
table.sort(known)
|
|
local wallCount = 0
|
|
for _, n in pairs(walls) do
|
|
if n >= WALL_CONFIRM then wallCount = wallCount + 1 end
|
|
end
|
|
if #known > 0 or wallCount > 0 then
|
|
say(("memory (%s): deaths %s; %d cells to route around")
|
|
:format(MEMORY_PATH,
|
|
#known > 0 and table.concat(known, ", ") or "none", wallCount))
|
|
else
|
|
say("memory: empty -- first run, everything looks safe")
|
|
end
|
|
end
|
|
local attempts = tonumber(os.getenv("POKEPORT_ROUTE_ATTEMPTS")) or 5
|
|
-- Resume from a checkpoint instead of replaying from Pallet Town. Only
|
|
-- the first attempt resumes; if it fails again, the retries are honest
|
|
-- runs from the start.
|
|
local resume
|
|
if os.getenv("POKEPORT_ROUTE_RESUME") == "1" then
|
|
resume = loadCheckpoint()
|
|
if resume then
|
|
say(("resuming from checkpoint: segment %d/%d on %s (%s)")
|
|
:format(resume.segment, #ROUTE, tostring(resume.map),
|
|
tostring(resume.reason)))
|
|
else
|
|
say("POKEPORT_ROUTE_RESUME=1 but no usable checkpoint; starting fresh")
|
|
end
|
|
end
|
|
for attempt = 1, attempts do
|
|
say(("==== attempt %d/%d ===="):format(attempt, attempts))
|
|
if attempt == 1 and resume then
|
|
local SaveData = require("src.core.SaveData")
|
|
local loaded, recovered = SaveData.load()
|
|
if loaded then
|
|
U.wait(5)
|
|
G:restoreSave(loaded, recovered)
|
|
U.wait(30)
|
|
mashUntilIdle()
|
|
local stopped = runRouteGuarded(resume.segment)
|
|
if stopped == nil then say("stopped at a checkpoint") break end
|
|
if stopped then say("run finished") break end
|
|
say("checkpoint attempt failed; starting a clean run")
|
|
resume = nil
|
|
goto nextAttempt
|
|
end
|
|
say("checkpoint save would not load; starting fresh")
|
|
resume = nil
|
|
end
|
|
if attempt > 1 then
|
|
-- power-cycle back to the title and start over, the way a speedrun
|
|
-- bot resets a dead run (Strategies.reset). Cheaper and far more
|
|
-- predictable than trying to re-sync the route from a heal point.
|
|
G:returnToTitle()
|
|
U.wait(60)
|
|
end
|
|
-- Start a genuinely NEW game.
|
|
--
|
|
-- U.newGame taps A on the title menu, which is only NEW GAME when no
|
|
-- save exists -- with one, CONTINUE is first and the tap loads it. The
|
|
-- checkpoint feature writes saves, so it silently turned every "clean"
|
|
-- run into a resume: a fresh run reported being lost at segment 9 while
|
|
-- standing in VERMILION_CITY, hundreds of segments from where it should
|
|
-- have been. Pick the row by label instead of trusting its position.
|
|
local fresh = false
|
|
for _ = 1, 3 do
|
|
U.wait(5)
|
|
-- NEVER press A blindly here. With a save on disk the menu is
|
|
-- already up after the movie skip, CONTINUE is its first row, and
|
|
-- a stray A selects it -- every retry then "lands on ROUTE_16" and
|
|
-- gets rejected, which burned four whole attempts in one run.
|
|
-- Press START only until the menu's items are visible, back out of
|
|
-- any submenu a stray press opened, and touch A only once the
|
|
-- cursor is on the NEW GAME row.
|
|
local newRow
|
|
for _ = 1, 120 do
|
|
local t = top()
|
|
local items = t and t.items
|
|
if items then
|
|
for i, it in ipairs(items) do
|
|
if it.label == "NEW GAME" then newRow = i break end
|
|
end
|
|
if newRow then break end
|
|
press("b") -- ContinueInfo or another submenu; back out
|
|
U.wait(4)
|
|
else
|
|
press("start") -- still the movie or the bare title screen
|
|
U.wait(6)
|
|
end
|
|
end
|
|
if newRow then
|
|
if cursorTo("index", newRow) then
|
|
press("a")
|
|
U.wait(10)
|
|
end
|
|
-- mash through Oak's speech and the naming presets
|
|
for _ = 1, 400 do
|
|
press("a")
|
|
U.wait(2)
|
|
if G.overworld and G.stack:top() == G.overworld then break end
|
|
end
|
|
U.wait(10)
|
|
else
|
|
say("title menu had no NEW GAME row; falling back to U.newGame")
|
|
U.newGame(game)
|
|
end
|
|
mashUntilIdle()
|
|
-- A fresh game always wakes in Red's bedroom. Anywhere else means
|
|
-- CONTINUE hijacked a blind tap -- one attempt toured the endgame
|
|
-- from ROUTE_23 with the route pointed at segment 1 that way --
|
|
-- so go back to the title and try again rather than run on it.
|
|
if G.overworld and ow().map.id == "REDS_HOUSE_2F" then
|
|
fresh = true
|
|
break
|
|
end
|
|
say(("new game landed on %s -- back to the title to try again")
|
|
:format(tostring(G.overworld and ow().map.id)))
|
|
G:returnToTitle()
|
|
U.wait(60)
|
|
end
|
|
if not fresh then
|
|
say("could not start a fresh game; abandoning this attempt")
|
|
goto nextAttempt
|
|
end
|
|
local result = runRouteGuarded()
|
|
if result == nil then say("stopped at a checkpoint") break end
|
|
if result then
|
|
say("run finished")
|
|
break
|
|
end
|
|
say("restarting after a wipe")
|
|
::nextAttempt::
|
|
end
|
|
report()
|
|
end
|