mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
2429 lines
105 KiB
Lua
2429 lines
105 KiB
Lua
-- Route-bot core for the Gold port.
|
|
--
|
|
-- Interprets tests/drivers/gold/route.lua -- objectives derived from
|
|
-- docs/gold-walkthrough/asm-walk -- against a live game, through the adapter in
|
|
-- tests/drivers/gold/adapter.lua. Nothing here names a Gen 2 module; swap the
|
|
-- adapter and this drives Gen 1.
|
|
--
|
|
-- The shape differs from the Gen 1 bot on purpose. tests/drivers/route.lua
|
|
-- interprets PokeBotBad's tile-by-tile waypoints, so its route says "walk to
|
|
-- (8,30), then (8,24)". The asm-walk has no walking paths at all -- it has
|
|
-- OBJECTIVES ("warp 1 at (6,3)", "talk to ELMSLAB_ELM at (5,2)") plus the
|
|
-- EVENT_* each one sets. So the route here is a list of goals and ALL the
|
|
-- navigation is the bot's problem: local BFS inside a map, and a second BFS
|
|
-- over the map graph to reach the map an objective names. That is more work
|
|
-- here and far less work per route row, and it means a map whose geometry the
|
|
-- port gets slightly wrong is routed around rather than fatal.
|
|
--
|
|
-- Three feedback memories exist because a planner that cannot learn will retry
|
|
-- the same failing step until the watchdog kills it. All three are lessons
|
|
-- the Gen 1 bot paid for first (see tests/drivers/route.lua's header):
|
|
--
|
|
-- walls BFS plans over a static tile test that knows nothing about ledges,
|
|
-- directional blocks or an NPC parked in a doorway. A step the
|
|
-- engine refuses is remembered so the next plan avoids it, and
|
|
-- forgotten the moment we do stand there -- otherwise a wandering
|
|
-- NPC blacklists a corridor permanently.
|
|
-- seams a map-graph edge that did not work gets PRICED, never banned.
|
|
-- Banning severed the Gen 1 map graph outright: crossing a seam is
|
|
-- flaky, so the only road north out of a town could reach two
|
|
-- failures and become impossible. An expensive seam is still taken
|
|
-- when it is the only way through.
|
|
-- deaths per-map blackout counts, so the bot gets more careful about a place
|
|
-- that keeps killing it instead of walking back in at 3 HP.
|
|
|
|
local Bot = {}
|
|
Bot.__index = Bot
|
|
|
|
local A = dofile("tests/drivers/gold/adapter.lua")
|
|
Bot.adapter = A
|
|
|
|
-- The region-aware map graph, generated from the extracted cache by
|
|
-- tools/goldwalk/mapgraph.lua. See Bot:planTravel for what it buys.
|
|
local REGIONS = dofile("tests/drivers/gold/map_regions.lua")
|
|
|
|
-- Declared up here rather than beside the pathfinder because Bot:turnAway --
|
|
-- the escape hatch for an NPC whose conversation keeps restarting -- walks it
|
|
-- too, and a `local` further down the file is not in scope there: DELTA
|
|
-- resolved to the global nil and `pairs(nil)` crashed the run out of
|
|
-- clearDialogue, which is a fatal error in the one place that exists to
|
|
-- recover from a stuck conversation.
|
|
local DELTA = {
|
|
up = { 0, -1 },
|
|
down = { 0, 1 },
|
|
left = { -1, 0 },
|
|
right = { 1, 0 },
|
|
}
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- frame plumbing
|
|
-- ---------------------------------------------------------------------------
|
|
-- Everything in this file runs inside main.lua's driver coroutine: one yield is
|
|
-- one logic step. POKEPORT_SPEED only changes how many of those main.lua runs
|
|
-- per rendered frame, so nothing here needs to know the multiplier.
|
|
|
|
local frames = 0
|
|
|
|
function Bot:wait(n)
|
|
for _ = 1, (n or 1) do
|
|
frames = frames + 1
|
|
self.silentFrames = self.silentFrames + 1
|
|
-- Silent-stall backstop. Every loop in this file waits through here, so a
|
|
-- run that stops logging but keeps spinning still passes through this
|
|
-- counter on the frames it burns. The talkative case is caught by the
|
|
-- repeat detector in :say().
|
|
if self.silentFrames > self.stallFrames then
|
|
error({ botStall = true,
|
|
why = ("no progress for %d frames"):format(self.silentFrames) })
|
|
end
|
|
coroutine.yield()
|
|
end
|
|
end
|
|
|
|
function Bot:frames() return frames end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- logging + stuck detection
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
function Bot:say(...)
|
|
local parts = {}
|
|
for i = 1, select("#", ...) do
|
|
parts[#parts + 1] = tostring((select(i, ...)))
|
|
end
|
|
local line = table.concat(parts, " ")
|
|
print(("[gold %6d] %s"):format(frames, line))
|
|
if self.logFile then
|
|
self.logFile:write(("%6d %s\n"):format(frames, line))
|
|
self.logFile:flush() -- a long run is normally ended by killing the
|
|
-- process, and a block-buffered tail loses
|
|
-- exactly the part worth reading
|
|
end
|
|
self:recordLine(line)
|
|
end
|
|
|
|
-- "Stuck" from the outside looks like the log repeating itself. Every retry
|
|
-- loop below narrates each attempt, so the same line cycling N times without a
|
|
-- new objective means we are trying the same thing and getting the same
|
|
-- result. Detected here rather than per-loop so no loop can forget to.
|
|
function Bot:recordLine(line)
|
|
local h = self.history
|
|
h[#h + 1] = line
|
|
if #h > 200 then table.remove(h, 1) end
|
|
local repeats = 0
|
|
for i = #h, 1, -1 do
|
|
if h[i] == line then
|
|
repeats = repeats + 1
|
|
if repeats >= self.stuckRepeats then
|
|
error({ botStall = true,
|
|
why = ("repeated %q %d times"):format(line, repeats) })
|
|
end
|
|
end
|
|
if #h - i > 40 then break end -- only look at the recent window: the same
|
|
-- line an hour ago is not a loop
|
|
end
|
|
end
|
|
|
|
-- Called whenever something genuinely advanced, which is what makes both
|
|
-- detectors safe to be aggressive.
|
|
function Bot:progress()
|
|
self.silentFrames = 0
|
|
self.history = {}
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- memories
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
local function wallKey(mapId, x, y)
|
|
return ("%s#%d,%d"):format(tostring(mapId), x, y)
|
|
end
|
|
|
|
function Bot:noteWall(mapId, x, y)
|
|
self.walls[wallKey(mapId, x, y)] = (self.walls[wallKey(mapId, x, y)] or 0) + 1
|
|
end
|
|
|
|
-- Standing on a cell proves it passable after all. Keeps a temporary blocker
|
|
-- (an NPC mid-stroll, a tree before we have CUT) out of the permanent record.
|
|
function Bot:clearWall(mapId, x, y)
|
|
self.walls[wallKey(mapId, x, y)] = nil
|
|
end
|
|
|
|
function Bot:wallCost(mapId, x, y)
|
|
local n = self.walls[wallKey(mapId, x, y)] or 0
|
|
return n * 40 -- a price, not a ban: a refused cell that is the only
|
|
-- way through is still taken, just last
|
|
end
|
|
|
|
local function seamKey(from, to) return tostring(from) .. ">" .. tostring(to) end
|
|
|
|
function Bot:noteSeam(from, to)
|
|
local k = seamKey(from, to)
|
|
self.seams[k] = (self.seams[k] or 0) + 1
|
|
end
|
|
|
|
function Bot:clearSeam(from, to)
|
|
self.seams[seamKey(from, to)] = nil
|
|
end
|
|
|
|
function Bot:seamCost(from, to)
|
|
return (self.seams[seamKey(from, to)] or 0) * 8
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- input helpers
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
function Bot:tap(button, hold)
|
|
A.press(self.g, button)
|
|
self:wait(hold or 2)
|
|
A.release(self.g, button)
|
|
self:wait(2)
|
|
end
|
|
|
|
-- Wait out anything that has taken control (script, text box, cutscene move),
|
|
-- mashing A so text advances. `answers` is a queue consumed by choice boxes:
|
|
-- "yes" presses A, "no" presses B. Everything unanswered defaults to YES,
|
|
-- which is what ChoiceBox starts its cursor on.
|
|
function Bot:clearDialogue(answers, budget)
|
|
local queue = {}
|
|
for i, v in ipairs(answers or {}) do queue[i] = v end
|
|
local pressed = 0
|
|
local restarts, wasBusy = 0, true
|
|
for _ = 1, (budget or 4000) do
|
|
local busy = A.busy(self.g)
|
|
|
|
-- Re-trigger guard. Our own A press is what closes the last page, and if
|
|
-- the player is still facing the NPC the very next press runs
|
|
-- World:interact and starts the SAME conversation again -- so a bot stood
|
|
-- in front of a chatty NPC talks to them forever. (Seen on every run:
|
|
-- "See for yourself. He's w..." in Ilex Forest, for seconds at a time.)
|
|
-- Count how often the box comes back after closing; once it is clearly a
|
|
-- loop, turn away so an A press cannot reach them, and stop.
|
|
if busy and not wasBusy then
|
|
restarts = restarts + 1
|
|
if restarts >= 2 then
|
|
self:say("dialogue: conversation keeps restarting -- turning away")
|
|
self:turnAway()
|
|
return true
|
|
end
|
|
end
|
|
wasBusy = busy
|
|
|
|
if not busy then
|
|
if pressed > 0 then self:progress() end
|
|
return true
|
|
end
|
|
if A.inBattle(self.g) then
|
|
self:fightBattle()
|
|
elseif A.namingScreenUp(self.g) then
|
|
-- Take the default name rather than typing one letter per A press.
|
|
self:say("dialogue: naming screen -- accepting the species name")
|
|
A.dismissNaming(self.g)
|
|
self:progress()
|
|
elseif A.partyMenuUp(self.g) then
|
|
-- A forced switch: pick a mon that can still fight, or back out.
|
|
if A.chooseHealthyPartyMon(self.g) then
|
|
self:say("battle: sending out the next healthy mon")
|
|
self:tap("a")
|
|
else
|
|
self:tap("b")
|
|
end
|
|
self:progress()
|
|
else
|
|
local answer = table.remove(queue, 1)
|
|
self:tap(answer == "no" and "b" or "a")
|
|
pressed = pressed + 1
|
|
-- Advancing a cutscene IS progress. Without this the silent-stall
|
|
-- backstop counted a long scene as a hang and killed the run inside it --
|
|
-- Sprout Tower's rival encounter runs well past the 5000-frame budget.
|
|
-- Nothing is lost by trusting the loop's own `budget` instead: it is
|
|
-- bounded, and it reports rather than spins.
|
|
self:progress()
|
|
end
|
|
end
|
|
self:say(("dialogue: still busy after %d presses -- %s")
|
|
:format(pressed, A.busyReason(self.g)))
|
|
return false
|
|
end
|
|
|
|
-- Face a direction with nothing talkable in it, so a stray A press cannot
|
|
-- reopen the conversation we just escaped. Prefers a direction whose facing
|
|
-- cell holds no NPC; falls back to any.
|
|
function Bot:turnAway()
|
|
local map = A.map(self.g)
|
|
local x, y = A.pos(self.g)
|
|
if not (map and x) then return end
|
|
for dir, d in pairs(DELTA) do
|
|
if not A.npcAt(self.g, x + d[1], y + d[2]) and dir ~= A.facing(self.g) then
|
|
self:face(dir)
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- battles
|
|
-- ---------------------------------------------------------------------------
|
|
-- Deliberately dumb: FIGHT and the first move, every turn. The route walks
|
|
-- where the walkthrough walks and levels normally, so it does not need the
|
|
-- speedrun's frame-perfect tactics -- and a wrong-but-simple battle policy
|
|
-- fails visibly (the party faints) rather than subtly.
|
|
|
|
function Bot:fightBattle()
|
|
local start = frames
|
|
local mapBefore = A.mapId(self.g)
|
|
-- One switch per battle: after it the fighter is out and the weakling has
|
|
-- already banked its participation.
|
|
self.switchedThisBattle = false
|
|
-- One status cure per battle (see A.statusCure): re-inflicted paralysis is
|
|
-- not worth chasing with the Champion's FULL RESTOREs.
|
|
self.curedStatusThisBattle = false
|
|
self:say("battle: fighting")
|
|
local overruns = 0
|
|
local lastMessage, lastPhase
|
|
while A.inBattle(self.g) do
|
|
local phase = A.battlePhase(self.g)
|
|
|
|
-- Narrate the battle. A wedged fight is otherwise a black box: the run
|
|
-- reports "no end" and nothing about the cause. Logging the message box
|
|
-- and the phase as they change turns that into the engine's own words --
|
|
-- which is how the catch tutorial and the out-of-PP stall were both
|
|
-- identified. Only on CHANGE, so a 3000-frame fight is a handful of lines
|
|
-- and the repeat detector in :say() is not tripped by ordinary combat.
|
|
local message = A.battleMessage(self.g)
|
|
if message ~= lastMessage or phase ~= lastPhase then
|
|
lastMessage, lastPhase = message, phase
|
|
if message then
|
|
self:say(("battle: [%s] %s"):format(tostring(phase), tostring(message)))
|
|
end
|
|
end
|
|
|
|
if phase == "choose-forget" then
|
|
-- A level-up move on a full set (AskLearnMove). The masher's blind A
|
|
-- would drop slot 1 -- often an HM or the good move. Point the cursor at
|
|
-- the weakest non-HM move first, or keep the four if the newcomer is no
|
|
-- upgrade. The whole endgame turns on this: a starter that never learns
|
|
-- a move past its level-1 attack cannot out-damage Clair or the Elite
|
|
-- Four, so it laps forever. Handled and looped WITHOUT falling through to
|
|
-- the unconditional A below -- a stray A on this phase drops slot 1.
|
|
local button = A.resolveForgetMenu(self.g)
|
|
if button then
|
|
self:say(("battle: forget menu -- %s")
|
|
:format(button == "a" and "dropping weakest move" or "keeping moveset"))
|
|
self:tap(button)
|
|
else
|
|
self:tap("a") -- still on the "wants to learn" prompt line
|
|
end
|
|
self:progress()
|
|
elseif phase == "menu" then
|
|
-- Pin the cursor before every confirm. Without this a failed escape
|
|
-- leaves the menu parked on RUN and the masher spends the rest of the
|
|
-- fight re-attempting a run it has already been refused.
|
|
--
|
|
-- The catch tutorial is the exception and it wedged a whole run: it has
|
|
-- no player mon, so FIGHT drops into an empty move list that A cannot
|
|
-- submit and B is the only way out of. PACK is its real path.
|
|
-- Catching rides inside the ordinary battle handler, because a wild
|
|
-- encounter can start anywhere -- mid-walk, mid-travel -- and every one
|
|
-- of those paths already funnels through here. (It briefly lived in
|
|
-- tryRun by accident, which shares the same `local phase` line: the bot
|
|
-- then only ever threw a ball when a battle OVERRAN, so 88 encounters
|
|
-- produced zero throws.)
|
|
if self.catchWanted and A.isWildBattle(self.g)
|
|
and self.catchWanted[A.enemySpecies(self.g)]
|
|
and A.hasItem(self.g, self.catchBall) then
|
|
-- Throw at full health and never attack a species we came to catch: by
|
|
-- the time the bot wants a SLOWPOKE its starter is ~L15 and one EMBER
|
|
-- kills a L6 target outright, so "soften first" threw nothing at all.
|
|
self:say(("battle: throwing a %s at %s (hp %.2f)")
|
|
:format(self.catchBall, tostring(A.enemySpecies(self.g)),
|
|
A.enemyHpFraction(self.g)))
|
|
A.throwBall(self.g, self.catchBall)
|
|
self:progress()
|
|
self:wait(2)
|
|
elseif self.switchTrain and not self.switchedThisBattle
|
|
and not A.battleIsTutorial(self.g)
|
|
and A.leadIsWeakest(self.g) then
|
|
-- Switch training: the weakling has been SENT OUT, which is all it
|
|
-- needs to be a participant. Hand the fight to whoever can win it
|
|
-- before the weakling is hit; the incoming mon takes this turn's
|
|
-- attack, so the participant survives and collects its share.
|
|
self.switchedThisBattle = true
|
|
if A.openBattleParty(self.g) then
|
|
self:wait(2)
|
|
if A.chooseStrongestPartyMon(self.g) then
|
|
self:say("battle: switch-training -- handing over to the fighter")
|
|
self:tap("a")
|
|
else
|
|
self:tap("b")
|
|
end
|
|
self:progress()
|
|
self:wait(2)
|
|
end
|
|
elseif A.statusCure(self.g) and not self.curedStatusThisBattle then
|
|
-- Cure a turn-costing status (paralysis is the one that matters) with
|
|
-- a FULL_RESTORE. Once per battle: re-inflicted paralysis is a lost
|
|
-- cause to keep curing, and the FULL_RESTOREs are the Champion's. This
|
|
-- is the whole difference in the Clair fight -- a paralyzed Fire lead
|
|
-- loses the KINGDRA attrition war; a cured one outspeeds and wins.
|
|
local cure = A.statusCure(self.g)
|
|
self.curedStatusThisBattle = true
|
|
self:say(("battle: curing %s with a %s")
|
|
:format(tostring(A.leadStatus(self.g)), cure))
|
|
A.useItem(self.g, cure)
|
|
self:progress()
|
|
self:wait(2)
|
|
elseif A.leadHpFraction(self.g) < 0.6
|
|
and A.bestHeal(self.g, A.leadHpFraction(self.g))
|
|
and not A.battleIsTutorial(self.g) then
|
|
-- Drink something before the mon that is carrying the run faints.
|
|
-- 0.6, not 0.35: the Elite Four hit hard enough to cross a third of a
|
|
-- health bar in one turn, so a 35% trigger is a trigger that fires
|
|
-- after the mon is already dead.
|
|
-- The policy had no items in it at all, so every leader from Whitney
|
|
-- on had to be out-levelled instead -- hundreds of thousands of frames
|
|
-- of grinding to buy what a SUPER POTION buys in one turn.
|
|
--
|
|
-- WHICH item is A.bestHeal's problem, and it is rationed: the trigger
|
|
-- stays generous, but between 0.35 and 0.6 it hands back the weakest
|
|
-- thing that still heals a quarter of the bar, keeping the FULL
|
|
-- RESTOREs for the room where the run actually ends.
|
|
local item = A.bestHeal(self.g, A.leadHpFraction(self.g))
|
|
self:say(("battle: using a %s (hp %.2f)")
|
|
:format(item, A.leadHpFraction(self.g)))
|
|
A.useItem(self.g, item)
|
|
self:progress()
|
|
self:wait(2)
|
|
else
|
|
A.setBattleMenu(self.g, A.battleIsTutorial(self.g)
|
|
and A.BATTLE_MENU_PACK or A.BATTLE_MENU_FIGHT)
|
|
end
|
|
elseif phase == "moves" then
|
|
-- Pick a move that can actually be used, hardest-hitting first. Slot 1
|
|
-- is not a safe default: at 0 PP the turn silently never happens, and
|
|
-- with no Struggle in the port that is a battle which can never end.
|
|
if not A.pickBattleMove(self.g) then
|
|
if A.playerMoveCount(self.g) == 0 then
|
|
self:tap("b") -- tutorial-style empty list: back out
|
|
end
|
|
-- Otherwise every move is dry, and that is now the engine's business,
|
|
-- not the bot's: Battle:playerAttack substitutes STRUGGLE for a mon
|
|
-- with nothing left to spend, exactly as the cart does. So confirm
|
|
-- whatever the cursor is on and let the turn happen.
|
|
--
|
|
-- The bot used to try to ESCAPE here and abort the run when a trainer
|
|
-- refused -- which was right while the port had no Struggle and is
|
|
-- wrong now: escaping a wild fight it could win by struggling is how
|
|
-- a run ends up circling a route forever, never leveling and never
|
|
-- fainting.
|
|
end
|
|
end
|
|
-- The forget branch already pressed its own button and must NOT get the
|
|
-- masher's A -- a stray A on that phase is a blind slot-1 drop.
|
|
if phase ~= "choose-forget" then
|
|
self:tap("a")
|
|
end
|
|
-- A battle IS progress. Without this the silent-stall backstop in :wait()
|
|
-- counted the whole fight as "nothing happening" and killed the run
|
|
-- mid-fight -- and because the next route row then found the battle still
|
|
-- up, every following row stalled the same way. The battle's own budget
|
|
-- below is what catches a genuinely wedged fight.
|
|
self:progress()
|
|
if frames - start > self.battleFrames then
|
|
overruns = overruns + 1
|
|
start = frames
|
|
self:say(("battle: %d frames with no end (phase=%s, lead hp=%.2f, party=%d)")
|
|
:format(overruns * self.battleFrames, tostring(A.battlePhase(self.g)),
|
|
A.leadHpFraction(self.g), A.partySize(self.g)))
|
|
-- A battle that will not end is usually one neither side can win. The
|
|
-- masher always picks the first move, so a starter holding TACKLE that
|
|
-- meets a GASTLY is in a stalemate: Normal cannot touch Ghost, and the
|
|
-- Ghost's own status moves do no damage back -- the log shows lead HP
|
|
-- frozen for tens of thousands of frames. Running is the correct answer
|
|
-- and costs nothing on a wild fight; on a trainer it simply fails and we
|
|
-- go back to mashing.
|
|
-- Never try to escape a battle we are deliberately spending balls on:
|
|
-- the overrun handler was cutting catch attempts short, which is why five
|
|
-- throws at one SLOWPOKE ended with the bot fighting it instead.
|
|
if self.catchWanted and A.isWildBattle(self.g)
|
|
and self.catchWanted[A.enemySpecies(self.g)] then
|
|
self:say("battle: overrunning, but this is a catch -- keeping at it")
|
|
elseif not self:tryRun() then
|
|
self:say("battle: could not run (trainer?), continuing")
|
|
end
|
|
if overruns >= 3 then
|
|
self:say("battle: giving up on this fight")
|
|
return false
|
|
end
|
|
end
|
|
end
|
|
self:progress()
|
|
-- A wipe is not an error: the engine revives the party and warps to the
|
|
-- spawn point, so a blackout is really a slow free heal. It only becomes a
|
|
-- problem when it keeps happening in the same place, which is what the
|
|
-- per-map count is for -- and what tells a human WHERE the route is too hard
|
|
-- rather than just that it stalled.
|
|
if not A.partyHealthy(self.g) then
|
|
local where = mapBefore or "?"
|
|
self.deaths[where] = (self.deaths[where] or 0) + 1
|
|
self:say(("battle: party wiped on %s (%d here)")
|
|
:format(tostring(where), self.deaths[where]))
|
|
end
|
|
self:say("battle: done")
|
|
end
|
|
|
|
function Bot:deathsAt(mapId)
|
|
return self.deaths[mapId or ""] or 0
|
|
end
|
|
|
|
-- Escape a wild battle. Returns true once the battle is gone, false if it is
|
|
-- still up after a fair number of attempts -- which is what a trainer battle
|
|
-- looks like, since RUN is refused there.
|
|
function Bot:tryRun()
|
|
for _ = 1, 80 do
|
|
if not A.inBattle(self.g) then
|
|
self:progress()
|
|
return true
|
|
end
|
|
if A.partyMenuUp(self.g) then
|
|
if A.chooseHealthyPartyMon(self.g) then self:tap("a") else self:tap("b") end
|
|
self:progress()
|
|
end
|
|
|
|
local phase = A.battlePhase(self.g)
|
|
|
|
-- Catching rides inside the ordinary battle handler rather than a separate
|
|
-- one, because a wild encounter can start anywhere -- mid-walk, mid-travel
|
|
-- -- and every one of those paths already funnels into here. `catchWanted`
|
|
-- is set for the duration of a `catch` route row.
|
|
if phase == "menu" then
|
|
A.setBattleMenu(self.g, A.BATTLE_MENU_RUN)
|
|
self:tap("a")
|
|
elseif phase == "moves" then
|
|
self:tap("b") -- back out of the move list to the main menu
|
|
else
|
|
self:tap("a") -- a message is up; advance it
|
|
end
|
|
end
|
|
return not A.inBattle(self.g)
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- local pathfinding
|
|
-- ---------------------------------------------------------------------------
|
|
-- Dijkstra rather than plain BFS, because the wall memory is a COST and not a
|
|
-- wall: a cell the engine refused twice should be avoided if there is any other
|
|
-- way round and still used when there is not.
|
|
|
|
-- src/world/gen2/Player.lua: a walk is 16 frames, a turn-in-place 4.
|
|
local TURN_FRAMES = 4
|
|
|
|
-- Map connections are keyed by COMPASS word ("west"); the pad is named by
|
|
-- SCREEN direction ("left"). Normalising at the boundary is deliberate: the
|
|
-- first run held a button called "west", which no input source has ever heard
|
|
-- of, so the bot walked at the east edge of New Bark Town waiting for Route 29
|
|
-- to appear. Everything below this line speaks pad directions only.
|
|
local BUTTON_DIR = {
|
|
up = "up", down = "down", left = "left", right = "right",
|
|
north = "up", south = "down", west = "left", east = "right",
|
|
}
|
|
|
|
function Bot.button(dir) return BUTTON_DIR[dir] end
|
|
|
|
-- Can we get onto the water at all right now? Cached per attempt because
|
|
-- partyMoveUser walks the party and re-checks the badge every call, and the
|
|
-- planner asks about thousands of cells.
|
|
function Bot:canSurf()
|
|
if self.surfKnown == nil then
|
|
self.surfKnown = A.surfUser(self.g) ~= nil
|
|
end
|
|
return self.surfKnown
|
|
end
|
|
|
|
function Bot:forgetSurf() self.surfKnown = nil end
|
|
|
|
function Bot:passable(map, x, y, goalX, goalY)
|
|
if x == goalX and y == goalY then
|
|
-- The goal is exempt from the walkability test. Half the objectives in
|
|
-- the route are cells the player can never STAND on -- a door tile, an
|
|
-- item ball, an NPC -- and the caller either warps off it or only needs to
|
|
-- be adjacent. Reachability is what the search decides; what happens on
|
|
-- arrival is the action's business.
|
|
return map:inBounds(x, y)
|
|
end
|
|
if not A.walkable(self.g, map, x, y) then
|
|
-- Water is passable to a party that can SURF, even while we are still on
|
|
-- foot: the step onto it is what starts the surf, exactly as it does for a
|
|
-- player pressing A at the shore. Without this the planner drew the coast
|
|
-- as a wall, so Cianwood, Route 27, the Whirl Islands and the Dragon's Den
|
|
-- were all "unreachable" and only a teleport got the route past them.
|
|
if not (self:canSurf() and A.isWater(map, x, y)) then return false end
|
|
end
|
|
if A.npcAt(self.g, x, y) then return false end
|
|
-- A warp TILE is not a cell you pass through, it is a cell you LEAVE THE MAP
|
|
-- on. Planning a route across one produces a path that is a lie: the walk
|
|
-- ends somewhere else entirely, several steps before it meant to. That is
|
|
-- how the Route 46 gate trapped the bot -- it lands you on the warp back, so
|
|
-- every plan out of Route 46 stepped over the neighbouring warp tile and
|
|
-- returned to the gate, twice a second, until the loop guard fired. A warp
|
|
-- may still be a DESTINATION, which is the exemption above.
|
|
--
|
|
-- The test is the TILE, not the warp_event coordinate: CheckWarpTile reads
|
|
-- the collision, so a warp_event on plain floor never fires. Ecruteak Gym is
|
|
-- the map that proves it -- several of its thirty hole coordinates are
|
|
-- ordinary floor, and they are the safe path between the pits, so refusing
|
|
-- all of them cut the gym in half and put Morty out of reach.
|
|
if A.isWarpTile(map, x, y) then return false end
|
|
return true
|
|
end
|
|
|
|
-- Where a single press of `dir` from (x, y) comes to rest.
|
|
--
|
|
-- Ice (COLL_ICE) keeps the player moving in the same direction until they land
|
|
-- on a non-ice cell or bump something -- CheckStandingOnIce / .CheckForced in
|
|
-- the engine. The planner's graph is therefore over REST positions, not over
|
|
-- every walkable cell: a press is one edge, and its endpoint is wherever the
|
|
-- slide stops. Off ice this is ordinary adjacency (one cell).
|
|
function Bot:slideRest(map, x, y, dir, goalX, goalY)
|
|
local d = DELTA[dir]
|
|
if not d then return nil end
|
|
local cx, cy = x + d[1], y + d[2]
|
|
-- GetMovementPermissions can veto the leave (standing on a side-wall tile)
|
|
-- or the entry (Gold's neighbour arm: no stepping DOWN onto an UP_WALL).
|
|
-- A refused step off a LEDGE tile is not a dead end but a two-cell hop
|
|
-- (.TryJump): Burned Tower B1F's landing pockets drain only this way.
|
|
if not A.stepPermitted(map, x, y, dir)
|
|
or not self:passable(map, cx, cy, goalX, goalY) then
|
|
local hop = A.ledgeFacings(map, x, y)
|
|
if hop and hop[dir] then
|
|
local hx, hy = x + d[1] * 2, y + d[2] * 2
|
|
if self:passable(map, hx, hy, goalX, goalY) then
|
|
return hx, hy, 2
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
local steps = 1
|
|
while A.isIce(map, cx, cy) do
|
|
-- The slide re-runs the same permission test per cell, which is what
|
|
-- rests it on the last ice cell above an UP_WALL strip -- the rest
|
|
-- position Ice Path 1F's HM07 pocket is entered from.
|
|
if not A.stepPermitted(map, cx, cy, dir) then break end
|
|
local nx, ny = cx + d[1], cy + d[2]
|
|
if not self:passable(map, nx, ny, goalX, goalY) then break end
|
|
cx, cy, steps = nx, ny, steps + 1
|
|
end
|
|
return cx, cy, steps
|
|
end
|
|
|
|
-- Cheapest path from the player to (goalX, goalY) as a list of press
|
|
-- directions, or nil. Cost is cells travelled plus the wall memory's price.
|
|
-- On ice each press may cover several cells and only REST positions are nodes,
|
|
-- so a cell the player would slide past is not a place the path can ask to
|
|
-- stand -- which is what made every Ice Path boulder push report
|
|
-- "could not stand at (x,y) to push" when the planner still thought ice was
|
|
-- ordinary floor.
|
|
function Bot:planPath(goalX, goalY)
|
|
local sx, sy = A.pos(self.g)
|
|
if not sx then return nil end
|
|
return self:planPathFrom(sx, sy, goalX, goalY)
|
|
end
|
|
|
|
-- The same search from an arbitrary start. enterWarp's step-off needs it:
|
|
-- next to a one-way door the two candidate cells are on DIFFERENT SIDES of
|
|
-- the map (Blackthorn's Ice Path exit has the cliff corridor above and the
|
|
-- hop into town below), and only a path check can say which one still
|
|
-- reaches the target.
|
|
function Bot:planPathFrom(sx, sy, goalX, goalY)
|
|
local map = A.map(self.g)
|
|
if not map then return nil end
|
|
local mapId = map.id
|
|
if not sx then return nil end
|
|
if sx == goalX and sy == goalY then return {} end
|
|
|
|
local function key(x, y) return y * 4096 + x end
|
|
local dist = { [key(sx, sy)] = 0 }
|
|
local prev = {}
|
|
-- A binary heap is overkill for maps this size (the biggest Johto map is
|
|
-- well under 4k cells) and a linear scan keeps the code readable.
|
|
local open = { { x = sx, y = sy, d = 0 } }
|
|
local seen = {}
|
|
|
|
while #open > 0 do
|
|
local bi, best = 1, open[1]
|
|
for i = 2, #open do
|
|
if open[i].d < best.d then bi, best = i, open[i] end
|
|
end
|
|
table.remove(open, bi)
|
|
local bk = key(best.x, best.y)
|
|
if not seen[bk] then
|
|
seen[bk] = true
|
|
if best.x == goalX and best.y == goalY then
|
|
local path, cx, cy = {}, goalX, goalY
|
|
while not (cx == sx and cy == sy) do
|
|
local step = prev[key(cx, cy)]
|
|
if not step then return nil end
|
|
table.insert(path, 1, step.dir)
|
|
cx, cy = step.x, step.y
|
|
end
|
|
return path
|
|
end
|
|
for dir, _ in pairs(DELTA) do
|
|
local nx, ny, steps = self:slideRest(map, best.x, best.y, dir,
|
|
goalX, goalY)
|
|
if nx then
|
|
-- Water is passable but not free: getting on it costs a field move
|
|
-- and a prompt, so a dry route of similar length should win.
|
|
local nd = best.d + steps + self:wallCost(mapId, nx, ny)
|
|
+ ((not A.surfing(self.g) and A.isWater(map, nx, ny))
|
|
and 3 or 0)
|
|
local nk = key(nx, ny)
|
|
if nd < (dist[nk] or math.huge) then
|
|
dist[nk] = nd
|
|
prev[nk] = { x = best.x, y = best.y, dir = dir }
|
|
open[#open + 1] = { x = nx, y = ny, d = nd }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Turn in place to face `dir`, without stepping.
|
|
--
|
|
-- Pressing a direction you are not already facing TURNS and does not step
|
|
-- (Player:tryMove:52 -- the turnArmed arm returns "turned" before the
|
|
-- collision check). A bot that treats that first press as a step reads the
|
|
-- turn as a refusal and learns a wall that is not there, so turning is its own
|
|
-- operation. turnArmed only re-arms on a frame with no direction held
|
|
-- (World:step:6427), which is why the release matters.
|
|
function Bot:face(dir)
|
|
if A.facing(self.g) == dir then return end
|
|
A.hold(self.g, dir)
|
|
self:wait(2)
|
|
A.releaseDirs(self.g)
|
|
self:wait(TURN_FRAMES + 4) -- let turnTimer expire and turnArmed re-arm
|
|
end
|
|
|
|
-- Exactly one cell in `dir`.
|
|
--
|
|
-- The subtlety that cost the first run: World:step starts the NEXT step in the
|
|
-- same frame the previous one lands (it falls through p:update() straight into
|
|
-- movePlayer with heldDir still set), so a held direction walks a whole
|
|
-- corridor until something blocks it. The first version of this held until
|
|
-- the cell changed and only then released -- which meant every "step" ran to
|
|
-- the far wall, and walkTo ping-ponged between the two ends of Mom's kitchen
|
|
-- forever. So: hold only long enough for movePlayer to fire once, release,
|
|
-- and let the in-flight step finish on its own.
|
|
--
|
|
-- The engine's own verdict ("moved" / "blocked" / "edge") never reaches a
|
|
-- driver, so the observable equivalent is whether the cell changed. A step
|
|
-- that did not move is what feeds wall memory.
|
|
function Bot:stepDir(dir)
|
|
local mapId = A.mapId(self.g)
|
|
|
|
self:face(dir)
|
|
if A.busy(self.g) or A.mapId(self.g) ~= mapId then
|
|
-- Something took the world between planning and stepping. Reporting it as
|
|
-- "moved" is right -- the caller must re-plan either way -- but it used to
|
|
-- be SILENT, and a walk whose every step bailed here looked in the log like
|
|
-- a walk that never happened: no refusals, no movement, 330 frames a try.
|
|
if A.mapId(self.g) == mapId then
|
|
self:say(("step %s deferred: %s"):format(dir, A.busyReason(self.g)))
|
|
end
|
|
return true
|
|
end
|
|
|
|
local x0, y0 = A.pos(self.g)
|
|
|
|
-- Stepping from the shore onto water is not a step, it is a field move.
|
|
-- World:useFieldMove reads the tile the player is FACING, so the face above
|
|
-- is already the argument; all that is left is to ask, answer the "Want to
|
|
-- SURF?" prompt, and let the queued script put us afloat. The step itself is
|
|
-- then the ordinary one below.
|
|
local map0 = A.map(self.g)
|
|
local d0 = DELTA[dir]
|
|
if map0 and not A.surfing(self.g) and self:canSurf()
|
|
and A.isWater(map0, x0 + d0[1], y0 + d0[2]) then
|
|
self:say(("surf: entering the water at (%d,%d)")
|
|
:format(x0 + d0[1], y0 + d0[2]))
|
|
if A.startSurf(self.g) then
|
|
self:wait(8)
|
|
self:clearDialogue({ "yes" }, 3000)
|
|
self:progress()
|
|
-- The surf script walks the player onto the water itself, so the cell has
|
|
-- already changed and there is nothing left to step.
|
|
local sx, sy = A.pos(self.g)
|
|
if sx ~= x0 or sy ~= y0 then return true end
|
|
end
|
|
end
|
|
|
|
-- A whirlpool bars the step the way the shore bars a walk, and the way
|
|
-- through is HM06 (GLACIERBADGE gated): Script_UsedWhirlpool swaps the
|
|
-- facing block for plain water, after which the ordinary step below just
|
|
-- works. Route 27's crossing and the Dragon Fang pocket are both behind
|
|
-- one of these.
|
|
if map0 and A.surfing(self.g)
|
|
and A.isWhirlpool(map0, x0 + d0[1], y0 + d0[2])
|
|
and A.whirlpoolUser(self.g) then
|
|
self:say(("whirlpool: clearing (%d,%d)"):format(x0 + d0[1], y0 + d0[2]))
|
|
self:face(dir)
|
|
if A.useWhirlpool(self.g) then
|
|
self:wait(8)
|
|
self:clearDialogue({ "yes" }, 3000)
|
|
self:progress()
|
|
end
|
|
end
|
|
|
|
-- A waterfall is climbed by the field move, not by steps:
|
|
-- Script_UsedWaterfall forces UP one cell at a time until the player is off
|
|
-- the falls, so by the time it returns the position has jumped several
|
|
-- cells and the current plan is stale. Report moved and let the caller
|
|
-- re-plan from the top. (Coming DOWN rides the current automatically --
|
|
-- .CheckTile -- so only the upward press needs the move.)
|
|
if map0 and dir == "up" and A.surfing(self.g)
|
|
and A.isWaterfall(map0, x0, y0 - 1)
|
|
and A.waterfallUser(self.g) then
|
|
self:say(("waterfall: climbing from (%d,%d)"):format(x0, y0))
|
|
self:face(dir)
|
|
if A.useWaterfall(self.g) then
|
|
self:wait(8)
|
|
self:clearDialogue({ "yes" }, 6000)
|
|
for _ = 1, 900 do
|
|
if not A.moving(self.g) and not A.busy(self.g) then break end
|
|
self:wait(1)
|
|
end
|
|
local cx, cy = A.pos(self.g)
|
|
if cx ~= x0 or cy ~= y0 then
|
|
self:progress()
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
|
|
A.hold(self.g, dir)
|
|
self:wait(2) -- one fixed step under the hold is one
|
|
-- movePlayer call; a second is a no-op
|
|
-- because the player is already moving
|
|
A.releaseDirs(self.g)
|
|
|
|
-- Let the press finish. Off ice that is one STEP_FRAMES landing. On ice
|
|
-- CheckForced keeps starting the next cell after release, so "not moving"
|
|
-- only becomes true at the REST position -- allow enough frames for a long
|
|
-- Ice Path corridor (a cell is 16 frames; 40 cells is well under this).
|
|
for _ = 1, 700 do
|
|
if A.busy(self.g) or A.mapId(self.g) ~= mapId then break end
|
|
if not A.moving(self.g) then break end
|
|
self:wait(1)
|
|
end
|
|
self:wait(1)
|
|
|
|
local x1, y1 = A.pos(self.g)
|
|
local d = DELTA[dir]
|
|
local wantX, wantY = x0 + d[1], y0 + d[2]
|
|
local moved = A.mapId(self.g) ~= mapId or x1 ~= x0 or y1 ~= y0
|
|
if A.mapId(self.g) ~= mapId then
|
|
self:progress()
|
|
return true
|
|
end
|
|
if not moved then
|
|
-- The engine refused the first cell. That is a real wall.
|
|
self:noteWall(mapId, wantX, wantY)
|
|
return false
|
|
end
|
|
-- A press that moved is a success even when it did not stop on the adjacent
|
|
-- cell: ice slides, and planPath already asked for the REST position. The
|
|
-- old one-cell check priced every ice corridor as a wall and made the Ice
|
|
-- Path unsolvable on foot.
|
|
--
|
|
-- The remaining case is a script that PUT us somewhere else after a normal
|
|
-- step -- Route 32's Miracle Seed man at (18,8) follows the player, walks
|
|
-- them two cells back north, and re-runs on every pass. Pricing the cell
|
|
-- we were shoved off makes the parallel column at x=19 win next time.
|
|
-- A ledge hop is the other press that legitimately overshoots the adjacent
|
|
-- cell: .TryJump lands two cells out, and calling that "a script moved us"
|
|
-- priced the wall past every ledge and buried the hop under wall memory.
|
|
local hop = map0 and A.ledgeFacings(map0, x0, y0)
|
|
if hop and hop[dir] and x1 == x0 + d[1] * 2 and y1 == y0 + d[2] * 2 then
|
|
self:clearWall(mapId, x1, y1)
|
|
self:progress()
|
|
return true
|
|
end
|
|
if not (x1 == wantX and y1 == wantY) and not A.isIce(A.map(self.g), wantX, wantY)
|
|
and not A.isIce(A.map(self.g), x0, y0) then
|
|
self:noteWall(mapId, wantX, wantY)
|
|
self:say(("step %s from (%d,%d) ended at (%d,%d), not (%d,%d) -- a script "
|
|
.. "moved us"):format(dir, x0, y0, x1, y1, wantX, wantY))
|
|
else
|
|
self:clearWall(mapId, x1, y1)
|
|
end
|
|
self:progress()
|
|
return true
|
|
end
|
|
|
|
-- Walk to a cell on the current map. Re-plans after every step: NPCs move,
|
|
-- scripts fire, and a plan made six steps ago is a guess about a world that
|
|
-- has since changed.
|
|
function Bot:walkTo(goalX, goalY, opts)
|
|
opts = opts or {}
|
|
local map0 = A.mapId(self.g)
|
|
local lastX, lastY, stuckAttempts = nil, nil, 0
|
|
for attempt = 1, (opts.attempts or 60) do
|
|
if A.busy(self.g) then
|
|
-- Sit out whatever took over -- but remember where we were standing when
|
|
-- it started, because a script that MOVES the player is the one kind of
|
|
-- obstacle the tile test can never see.
|
|
--
|
|
-- Route 32's Miracle Seed man is the case that mattered. His coord event
|
|
-- at (18,8) follows the player, walks them two cells back north, and is
|
|
-- guarded by an item flag rather than a scene, so it re-runs on every
|
|
-- pass -- faithfully, since on the cart the way past is the parallel
|
|
-- column at x=19. The bot could not see any of that: the step onto
|
|
-- (18,8) succeeded, so the cell looked fine, and the shove happened later
|
|
-- while dialogue was being cleared. It re-planned the same path forever,
|
|
-- which left the only road to Azalea, Ilex Forest and Goldenrod
|
|
-- impassable and made every run reach that half of Johto by teleport.
|
|
--
|
|
-- Pricing the cell we were shoved OFF is the general form of the fix, and
|
|
-- it prices only the specific tile that did it. Three notes rather than
|
|
-- one because arriving on the cell clears the memory, and the arrival is
|
|
-- exactly what triggers the shove.
|
|
local bx, by = A.pos(self.g)
|
|
local bmap = A.mapId(self.g)
|
|
local bmapObj = A.map(self.g)
|
|
local leftWarp = bmapObj and bx and A.isWarpTile(bmapObj, bx, by)
|
|
self:say(("walk: waiting on %s"):format(A.busyReason(self.g)))
|
|
self:clearDialogue(opts.answers)
|
|
local ax, ay = A.pos(self.g)
|
|
if bx and ax and bmap == A.mapId(self.g) and (ax ~= bx or ay ~= by) then
|
|
-- Same-map warps (GOLDENROD_UNDERGROUND's basement door at (18,6) ->
|
|
-- (21,31), and the pair back) change the cell but not the map id.
|
|
-- Treating that as a Miracle-Seed-style shove priced the door as a
|
|
-- wall, so every later hop onto it "did not take" and the switch-room
|
|
-- region-1 travel fell back to TELEPORT.
|
|
if leftWarp then
|
|
self:say(("walk: same-map warp moved us off (%d,%d) to (%d,%d)")
|
|
:format(bx, by, ax, ay))
|
|
if opts.stopOnMapChange then return true end
|
|
else
|
|
self:say(("walk: a script moved us off (%d,%d) to (%d,%d) -- avoiding it")
|
|
:format(bx, by, ax, ay))
|
|
for _ = 1, 3 do self:noteWall(bmap, bx, by) end
|
|
end
|
|
end
|
|
end
|
|
-- Leaving the map ends this walk either way. It is a SUCCESS when the
|
|
-- caller was walking into a warp or a seam, and a failure otherwise -- and
|
|
-- "otherwise" is usually a blackout, which teleports the player to their
|
|
-- spawn point mid-route. Without this the planner kept solving for Route
|
|
-- 29 coordinates while stood in the bedroom and logged "no path" forever.
|
|
if A.mapId(self.g) ~= map0 then
|
|
if opts.stopOnMapChange then return true end
|
|
self:say(("walk abandoned: left %s for %s")
|
|
:format(tostring(map0), tostring(A.mapId(self.g))))
|
|
return false
|
|
end
|
|
local x, y = A.pos(self.g)
|
|
if not x then return false end
|
|
if x == goalX and y == goalY then return true end
|
|
local path = self:planPath(goalX, goalY)
|
|
if not path then
|
|
-- Boxed in by somebody who is about to walk away.
|
|
--
|
|
-- NPCs are priced as walls, which is right for planning and wrong as a
|
|
-- verdict: they MOVE. A door tile makes it fatal, because a door is set
|
|
-- into a building and has exactly one cell in front of it -- so one
|
|
-- wandering NPC standing there leaves the player with no path at all.
|
|
-- Mahogany Gym is the case: the bot stepped out onto warp 3 at (6,13),
|
|
-- an NPC was on (6,14), and every route row for the next 400k frames
|
|
-- failed. The run had seven badges at the time.
|
|
--
|
|
-- So before believing "no path", check whether the only thing in the way
|
|
-- is a person, and if so stand still and ask again. This is the same
|
|
-- reasoning ops.talk already uses on a failed approach.
|
|
local map = A.map(self.g)
|
|
local blockedByNpc = false
|
|
if map then
|
|
for _, d in pairs(DELTA) do
|
|
local nx, ny = x + d[1], y + d[2]
|
|
if A.npcAt(self.g, nx, ny) and A.walkable(self.g, map, nx, ny) then
|
|
blockedByNpc = true
|
|
end
|
|
end
|
|
end
|
|
if blockedByNpc and (opts.npcWaits or 0) < 4 then
|
|
self:say(("walkTo (%d,%d): boxed in at (%d,%d) by an NPC -- waiting")
|
|
:format(goalX, goalY, x, y))
|
|
self:wait(60)
|
|
if A.busy(self.g) then self:clearDialogue(opts.answers) end
|
|
local retry = {}
|
|
for k, v in pairs(opts) do retry[k] = v end
|
|
retry.npcWaits = (opts.npcWaits or 0) + 1
|
|
return self:walkTo(goalX, goalY, retry)
|
|
end
|
|
self:say(("walkTo (%d,%d): no path from (%d,%d) on %s")
|
|
:format(goalX, goalY, x, y, tostring(A.mapId(self.g))))
|
|
return false
|
|
end
|
|
if #path == 0 then return true end
|
|
|
|
-- Give up early when an attempt ends exactly where it began. Re-planning
|
|
-- is only worth doing if something changed, and a plan that cannot take its
|
|
-- first step will produce the same plan forever: Route 32's Pokecenter is
|
|
-- 98 cells away past a one-way drop, and the bot spent ~800k frames --
|
|
-- most of a run -- re-deriving that same 98-step path from the same cell.
|
|
-- Failing in a few hundred frames instead is what makes the whole route
|
|
-- finish, and it is logged rather than silently capped.
|
|
if x == lastX and y == lastY then
|
|
stuckAttempts = stuckAttempts + 1
|
|
if stuckAttempts >= 3 then
|
|
self:say(("walkTo (%d,%d): no progress from (%d,%d) in %d attempts")
|
|
:format(goalX, goalY, x, y, stuckAttempts))
|
|
return false
|
|
end
|
|
else
|
|
stuckAttempts = 0
|
|
end
|
|
lastX, lastY = x, y
|
|
|
|
self:say(("walk (%d,%d)->(%d,%d) %d steps [try %d] %s")
|
|
:format(x, y, goalX, goalY, #path, attempt,
|
|
table.concat(path, "", 1, math.min(8, #path)):gsub("up", "^")
|
|
:gsub("down", "v"):gsub("left", "<"):gsub("right", ">")))
|
|
-- Walk the plan, but abandon it the moment the world disagrees: a warp, a
|
|
-- trip-wire script or a trainer's sight line all invalidate it.
|
|
for _, dir in ipairs(path) do
|
|
if A.busy(self.g) or A.mapId(self.g) ~= map0 then break end
|
|
if not self:stepDir(dir) then
|
|
-- Name the cell the engine refused. Wall memory alone is silent, so a
|
|
-- walk that cannot start looked identical in the log to one that had no
|
|
-- path at all -- and the two have completely different causes (a tile
|
|
-- the static test thinks is walkable versus a genuine dead end).
|
|
local bx, by = A.pos(self.g)
|
|
local d = DELTA[dir]
|
|
self:say(("step %s refused: (%d,%d) -> (%d,%d) on %s%s")
|
|
:format(dir, bx, by, bx + d[1], by + d[2], tostring(map0),
|
|
A.npcAt(self.g, bx + d[1], by + d[2]) and " [npc]" or ""))
|
|
break
|
|
end
|
|
local cx, cy = A.pos(self.g)
|
|
if cx == goalX and cy == goalY then break end
|
|
end
|
|
if opts.stopOnMapChange and A.mapId(self.g) ~= map0 then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Stand next to (x,y) and face it. Used by every talk / item-ball / hidden
|
|
-- item objective, none of which can stand on their target.
|
|
function Bot:approachAndFace(x, y, allowed)
|
|
local map = A.map(self.g)
|
|
if not map then return false end
|
|
-- `allowed` restricts which way we may be FACING when we press A. Normally
|
|
-- irrelevant, but the Ilex Forest Farfetch'd chase branches on it: each
|
|
-- FarfetchdPositionN script scalls FarfetchdCryAndCheckFacing, and facing the
|
|
-- wrong way sends the bird BACKWARDS round the loop instead of onwards, so a
|
|
-- bot that approaches from whichever side is nearest can herd forever.
|
|
local allow
|
|
if allowed then
|
|
allow = {}
|
|
for _, dir in ipairs(allowed) do allow[dir] = true end
|
|
end
|
|
|
|
local best
|
|
local function consider(sx, sy, dir)
|
|
if allow and not allow[dir] then return end
|
|
-- Same water rule as Bot:passable / borderStandable: a stand cell on the
|
|
-- lake is legal once the party can SURF, even while we are still on the
|
|
-- shore. A.walkable alone refuses water until FieldMoves.isSurfing is
|
|
-- already true, which made every water NPC (the Red Gyarados at
|
|
-- LAKE_OF_RAGE 18,22 is the one that matters) report "nowhere to stand"
|
|
-- from land -- so 10.33 never started the fight, Lance never appeared,
|
|
-- and the Mahogany Mart staircase scene never armed.
|
|
if not A.walkable(self.g, map, sx, sy)
|
|
and not (self:canSurf() and A.isWater(map, sx, sy)) then
|
|
return
|
|
end
|
|
if A.npcAt(self.g, sx, sy) then return end
|
|
local path = self:planPath(sx, sy)
|
|
if path and (not best or #path < best.len) then
|
|
best = { x = sx, y = sy, dir = dir, len = #path }
|
|
end
|
|
end
|
|
|
|
for dir, d in pairs(DELTA) do
|
|
-- Directly alongside: stand here, face `dir` to look at (x,y).
|
|
consider(x - d[1], y - d[2], dir)
|
|
-- ...or two cells back across a COUNTER. A Pokecenter nurse and a Mart
|
|
-- clerk have no walkable neighbour at all: the tile in front of them is the
|
|
-- counter, and the cart reaches them by doubling an A press's range over
|
|
-- one (CheckFacingObject). Without this the bot decided a Pokecenter had
|
|
-- "nowhere to stand" and could never heal.
|
|
local mid = { x - d[1], y - d[2] }
|
|
if map:inBounds(mid[1], mid[2])
|
|
and A.isCounter(map, mid[1], mid[2]) then
|
|
consider(x - d[1] * 2, y - d[2] * 2, dir)
|
|
end
|
|
end
|
|
if not best then
|
|
local px, py = A.pos(self.g)
|
|
self:say(("approach (%d,%d): nowhere to stand (at %s %d,%d surf=%s size=%d)")
|
|
:format(x, y, tostring(A.mapId(self.g)), px or -1, py or -1,
|
|
tostring(self:canSurf()), self:regionSize(256)))
|
|
return false
|
|
end
|
|
if not self:walkTo(best.x, best.y) then return false end
|
|
self:face(best.dir)
|
|
return true
|
|
end
|
|
|
|
-- Stand on a warp cell and actually go through it.
|
|
--
|
|
-- Doors, staircases, caves and panels warp on arrival, so walking on is the
|
|
-- whole job. A CARPET does not: World:checkCarpetWhileStanding wants the
|
|
-- player stopped on the tile with that carpet's own direction held, and until
|
|
-- it gets that it will happily let the bot stand in the doorway all day. The
|
|
-- tile itself says which kind it is, so ask it rather than guess -- the first
|
|
-- run guessed "hold whatever we are facing", which walked straight back off
|
|
-- the mat and ping-ponged either side of the front door.
|
|
function Bot:enterWarp(x, y, dest)
|
|
local from = A.mapId(self.g)
|
|
local startX, startY = A.pos(self.g)
|
|
|
|
-- Get off the warp we arrived on before aiming at a different one.
|
|
--
|
|
-- A ladder drops you ONTO a warp tile, and the arrival cooldown holds it
|
|
-- until you step away. Walking straight to another warp from there means
|
|
-- the first step is off one warp and, in a tight room, often onto a second
|
|
-- -- which fires, and puts us back where we started. The Olivine
|
|
-- lighthouse's 3F pocket is seven cells with three warps in it, and the bot
|
|
-- spent every attempt bouncing between 3F and 4F: 21 hops, nothing gained.
|
|
-- Standing on plain floor first costs one step and makes the walk ordinary.
|
|
do
|
|
local map = A.map(self.g)
|
|
local px, py = A.pos(self.g)
|
|
if map and px and A.isWarpTile(map, px, py)
|
|
and not (px == x and py == y) then
|
|
-- Prefer the side of the door the TARGET is on. Next to a one-way
|
|
-- passage the candidates are not interchangeable: coming out of the Ice
|
|
-- Path onto Blackthorn's cliff, UP is the corridor that cannot reach
|
|
-- town and DOWN is the ledge hop that can, and stepping off the wrong
|
|
-- way turned a correct region plan into "warp not reachable from here".
|
|
local fallback
|
|
local chosen
|
|
for dir, d in pairs(DELTA) do
|
|
local nx, ny = px + d[1], py + d[2]
|
|
if map:inBounds(nx, ny) and A.walkable(self.g, map, nx, ny)
|
|
and not A.isWarpTile(map, nx, ny)
|
|
and not A.npcAt(self.g, nx, ny) then
|
|
fallback = fallback or dir
|
|
if self:planPathFrom(nx, ny, x, y) then chosen = dir break end
|
|
end
|
|
end
|
|
local dir = chosen or fallback
|
|
if dir then
|
|
self:say(("warp: stepping off the arrival tile (%d,%d) first")
|
|
:format(px, py))
|
|
self:stepDir(dir)
|
|
end
|
|
if A.mapId(self.g) ~= from then return false end
|
|
end
|
|
end
|
|
-- Unreachable from here is a fact, not a failure to keep retrying: inside a
|
|
-- tower it means this ladder is in another region of the floor. But an NPC
|
|
-- parked in a one-wide corridor is a fact that can change -- an engaged
|
|
-- trainer stands wherever the fight happened, and on Radio Tower 2F that
|
|
-- was the only lane to the stairs -- so wait a few beats before giving up,
|
|
-- the same way walkTo waits when it is boxed in.
|
|
local plannable = self:planPath(x, y)
|
|
for _ = 1, 4 do
|
|
if plannable then break end
|
|
self:wait(60)
|
|
plannable = self:planPath(x, y)
|
|
end
|
|
if not plannable then
|
|
self:say(("warp (%d,%d) on %s is not reachable from here")
|
|
:format(x, y, tostring(from)))
|
|
return false
|
|
end
|
|
self:walkTo(x, y, { stopOnMapChange = true })
|
|
if A.mapId(self.g) ~= from then return A.mapId(self.g) == (dest or A.mapId(self.g)) end
|
|
do
|
|
-- walkTo returns on a same-map warp via stopOnMapChange; catch it here
|
|
-- before the "already standing on it" re-arm walks us back through the
|
|
-- door the other way.
|
|
local nx, ny = A.pos(self.g)
|
|
if nx and (nx ~= x or ny ~= y) and (dest == nil or dest == from)
|
|
and (nx ~= startX or ny ~= startY) then
|
|
self:say(("warp (%d,%d): same-map warp landed at (%d,%d)")
|
|
:format(x, y, nx, ny))
|
|
self:clearDialogue()
|
|
return true
|
|
end
|
|
end
|
|
|
|
self:wait(12) -- an immediate warp takes on arrival
|
|
if A.mapId(self.g) ~= from then
|
|
self:clearDialogue()
|
|
return dest == nil or A.mapId(self.g) == dest
|
|
end
|
|
|
|
-- Standing ON the tile already and not warping means the arrival cooldown is
|
|
-- holding it: World:clearWarpCooldownIfLeft only releases once the player has
|
|
-- stepped OFF the warp they arrived on, which is what stops a door bouncing
|
|
-- you straight back where you came from. A bot that walks "to" a cell it is
|
|
-- already on moves zero steps, so it waits on a warp that will never fire --
|
|
-- Ilex Forest's north door did exactly this, forever. Step off and back on.
|
|
local px, py = A.pos(self.g)
|
|
if px == x and py == y then
|
|
self:say(("warp (%d,%d): already standing on it, stepping off to re-arm")
|
|
:format(x, y))
|
|
for _, dir in ipairs(A.DIRS) do
|
|
if self:stepDir(dir) then break end
|
|
end
|
|
if A.mapId(self.g) ~= from then
|
|
self:clearDialogue()
|
|
return dest == nil or A.mapId(self.g) == dest
|
|
end
|
|
self:walkTo(x, y, { stopOnMapChange = true })
|
|
self:wait(12)
|
|
if A.mapId(self.g) ~= from then
|
|
self:clearDialogue()
|
|
return dest == nil or A.mapId(self.g) == dest
|
|
end
|
|
end
|
|
|
|
local map = A.map(self.g)
|
|
local want = map and A.carpetDir(map, x, y)
|
|
-- The tile's own answer first, then the compass, because a mis-decoded
|
|
-- collision should cost a few frames rather than the whole route. Built by
|
|
-- append rather than as a literal: `{ want, "down", ... }` with a nil `want`
|
|
-- is an array whose first element is nil, and ipairs then walks NONE of it --
|
|
-- which quietly disabled every retry on any tile that is not a carpet.
|
|
local tries = {}
|
|
if want then tries[#tries + 1] = want end
|
|
for _, dir in ipairs({ "down", "up", "left", "right" }) do
|
|
tries[#tries + 1] = dir
|
|
end
|
|
for _, dir in ipairs(tries) do
|
|
if dir then
|
|
-- Must be standing still ON the cell: a step in progress fails
|
|
-- checkCarpetWhileStanding's first test.
|
|
local cx, cy = A.pos(self.g)
|
|
if cx ~= x or cy ~= y then
|
|
self:walkTo(x, y, { stopOnMapChange = true })
|
|
if A.mapId(self.g) ~= from then break end
|
|
end
|
|
A.hold(self.g, dir)
|
|
self:wait(TURN_FRAMES + 8) -- turn first if needed, then the hold
|
|
-- is seen while stationary
|
|
A.releaseDirs(self.g)
|
|
self:wait(10)
|
|
if A.mapId(self.g) ~= from then break end
|
|
end
|
|
end
|
|
|
|
self:clearDialogue()
|
|
local now = A.mapId(self.g)
|
|
if now ~= from then
|
|
return dest == nil or now == dest
|
|
end
|
|
-- Same-map warp: the basement door on GOLDENROD_UNDERGROUND is a warp
|
|
-- whose destination is the same map id at a different cell ((18,6) <->
|
|
-- (21,31)). The map-id test above cannot see it, and calling that a miss
|
|
-- made every region-1 travel into the switch room TELEPORT after the key
|
|
-- was used -- the hop had already landed in region 5, then enterWarp
|
|
-- reported failure and the planner tried the door again from the wrong half.
|
|
local nx, ny = A.pos(self.g)
|
|
if nx and (nx ~= x or ny ~= y)
|
|
and (dest == nil or dest == from)
|
|
and (nx ~= startX or ny ~= startY or startX == x) then
|
|
self:say(("warp (%d,%d): same-map warp landed at (%d,%d)")
|
|
:format(x, y, nx, ny))
|
|
return true
|
|
end
|
|
self:say(("warp (%d,%d) on %s did not take"):format(x, y, tostring(from)))
|
|
return false
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- the map graph
|
|
-- ---------------------------------------------------------------------------
|
|
-- Warps and connections form a directed graph over map ids. The route only
|
|
-- names the map an objective lives on, so this is what turns "be on
|
|
-- CIANWOOD_CITY" into the twelve hops that actually get there.
|
|
|
|
function Bot:mapDefs()
|
|
local w = A.world(self.g)
|
|
return (w and w.maps) or {}
|
|
end
|
|
|
|
-- Does this map's own border on `dir` have a single cell we could stand on?
|
|
--
|
|
-- Twenty-one of Johto and Kanto's map connections do not: New Bark Town's east
|
|
-- edge onto Route 27, Route 41's west edge onto Cianwood, Azalea Town's west
|
|
-- edge onto Route 34 and eighteen more. Some are water (crossable once we can
|
|
-- SURF, which is why the test asks the CURRENT movement mode rather than a
|
|
-- fixed one) and some are simply scenery -- the real way through is a gate
|
|
-- building next to them. Either way the connection exists in the map data, so
|
|
-- the planner kept choosing it, crossEdge kept spending a thousand frames
|
|
-- pushing at a wall, and the seam price only made it the second choice rather
|
|
-- than no choice. Asking the map costs one row scan and settles it.
|
|
--
|
|
-- tools/goldwalk/mapgraph.lua's `audit` lists them all from the cache.
|
|
function Bot:borderStandable(mapId, dir)
|
|
local key = ("%s#%s#%s"):format(tostring(mapId), tostring(dir),
|
|
A.surfing(self.g) and "surf" or "foot")
|
|
local cached = self.borders[key]
|
|
if cached ~= nil then return cached end
|
|
local map = A.map(self.g)
|
|
if not (map and map.id == mapId) then return true end -- not here; cannot
|
|
-- ask, so allow
|
|
local w, h = map.widthCells, map.heightCells
|
|
local ok = false
|
|
local function look(x, y)
|
|
if ok then return end
|
|
if A.walkable(self.g, map, x, y)
|
|
or (self:canSurf() and A.isWater(map, x, y)) then
|
|
ok = true
|
|
end
|
|
end
|
|
if dir == "up" then
|
|
for x = 0, w - 1 do look(x, 0) end
|
|
elseif dir == "down" then
|
|
for x = 0, w - 1 do look(x, h - 1) end
|
|
elseif dir == "left" then
|
|
for y = 0, h - 1 do look(0, y) end
|
|
else
|
|
for y = 0, h - 1 do look(w - 1, y) end
|
|
end
|
|
self.borders[key] = ok
|
|
return ok
|
|
end
|
|
|
|
-- Every way off `mapId`: each warp cell, and each connected edge.
|
|
function Bot:exitsOf(mapId)
|
|
local defs = self:mapDefs()
|
|
local def = defs[mapId]
|
|
if not def then return {} end
|
|
local out = {}
|
|
-- On the map we are STANDING on we can ask the tile whether a warp would
|
|
-- actually fire, and drop the ones that cannot. Half the warp_events in a
|
|
-- multi-floor interior sit on plain floor: they are the landing spot of a
|
|
-- ladder on the far side, and CheckWarpTile never fires on them. The
|
|
-- Olivine lighthouse's 3F pocket has two of those and one real ladder, and
|
|
-- the planner kept picking the phantoms. A remote map's collision is not
|
|
-- available here -- that is what map_regions.lua is generated for.
|
|
local liveMap = A.map(self.g)
|
|
local canAsk = liveMap ~= nil and liveMap.id == mapId
|
|
for i, warp in ipairs(def.warps or {}) do
|
|
if warp.destMap and defs[warp.destMap]
|
|
and not (canAsk and not A.isWarpTile(liveMap, warp.x, warp.y)) then
|
|
out[#out + 1] = { kind = "warp", to = warp.destMap,
|
|
x = warp.x, y = warp.y, index = i }
|
|
end
|
|
end
|
|
for dir, conn in pairs(def.connections or {}) do
|
|
local to = conn.mapId
|
|
local button = BUTTON_DIR[dir]
|
|
if to and defs[to] and button then
|
|
out[#out + 1] = { kind = "edge", to = to, dir = button }
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- A single exit's identity, so one bad ladder can be priced without
|
|
-- condemning every route between the same two maps.
|
|
local function exitKey(mapId, exit)
|
|
return ("%s#%s"):format(tostring(mapId),
|
|
exit.kind == "warp" and ("w" .. exit.index) or ("e" .. exit.dir))
|
|
end
|
|
|
|
-- Is this map a cul-de-sac -- a house, a shop, a speech room -- whose every
|
|
-- exit leads to the same one map?
|
|
--
|
|
-- A map like that can never be an intermediate step on a route between two
|
|
-- OTHER maps, but the planner had no way to know that, and the cost of not
|
|
-- knowing was the single worst behaviour in the log: after a genuine seam
|
|
-- failed, the per-trip exit bans left the real roads out of a town banned,
|
|
-- the cheapest remaining "path" ran through a building, and the bot toured
|
|
-- every house in Cherrygrove and Violet -- 61 hops, 50k frames -- before the
|
|
-- loop guard gave up and a teleport covered for it. Excluding cul-de-sacs
|
|
-- from EXPANSION (never from being the target) removes the whole class.
|
|
function Bot:isCulDeSac(mapId)
|
|
local cached = self.culDeSac[mapId]
|
|
if cached ~= nil then return cached end
|
|
local only, count = nil, 0
|
|
for _, exit in ipairs(self:exitsOf(mapId)) do
|
|
if exit.to ~= mapId then
|
|
if only == nil then only = exit.to end
|
|
if exit.to ~= only then
|
|
self.culDeSac[mapId] = false
|
|
return false
|
|
end
|
|
count = count + 1
|
|
end
|
|
end
|
|
-- No exits at all is not a cul-de-sac, it is a bug in the extract; treat it
|
|
-- as impassable either way.
|
|
local verdict = count > 0
|
|
self.culDeSac[mapId] = verdict
|
|
return verdict
|
|
end
|
|
|
|
function Bot:noteBadExit(mapId, exit)
|
|
local k = exitKey(mapId, exit)
|
|
self.badExits[k] = (self.badExits[k] or 0) + 1
|
|
end
|
|
|
|
function Bot:exitCost(mapId, exit)
|
|
-- 40 rather than 25: a failed exit costs a thousand frames to discover, and
|
|
-- at 25 a two-hop alternative never beat re-trying the same broken ladder --
|
|
-- the log has Route 35's south edge chosen, failed and re-chosen three times
|
|
-- in a row before the price finally added up.
|
|
return (self.badExits[exitKey(mapId, exit)] or 0) * 40
|
|
end
|
|
|
|
-- Cheapest sequence of hops from the current map to `target`.
|
|
--
|
|
-- Two things here are not the obvious map-to-map BFS, and Sprout Tower is why.
|
|
-- Its floors are several DISCONNECTED regions joined only through other floors:
|
|
-- arriving on 2F by the west ladder leaves you walled off from the ladder up to
|
|
-- 3F, which is reachable only from 2F's other entrance. A graph whose nodes
|
|
-- are maps cannot see that, so it kept choosing an exit it could not walk to
|
|
-- and the planner span. The Olivine Lighthouse -- six floors of exactly this
|
|
-- shape, and the actual destination of this route -- would have done the same.
|
|
--
|
|
-- 1. Exits of the CURRENT map are filtered by whether their cell is
|
|
-- reachable from where the player is standing right now. That is the
|
|
-- region information the map-node graph lacks, and it is free: we can
|
|
-- only ever be standing in one region.
|
|
-- 2. The current map is NOT marked visited, so a route may legitimately
|
|
-- leave and come back -- 2F -> 1F -> 2F is how you change region, and a
|
|
-- plain BFS would refuse to consider it.
|
|
--
|
|
-- Per-exit pricing then makes repeated attempts try a DIFFERENT ladder rather
|
|
-- than the same one, so the search converges instead of oscillating.
|
|
-- How much this trip has already seen of a map.
|
|
--
|
|
-- This replaced a per-trip exit BAN, and the replacement is the fix for the
|
|
-- worst navigation failure in the log. The ban's intent was right -- stop the
|
|
-- bot bouncing between a gate's two warps forever -- but its unit was wrong: it
|
|
-- banned exits that had WORKED, and a long trip that backtracks after a failed
|
|
-- seam legitimately re-uses the road it came in on. After six maps most of the
|
|
-- real roads out of a town were banned, the cheapest surviving "route" ran
|
|
-- through the buildings, and the bot toured every house in Cherrygrove and
|
|
-- Violet before the loop guard gave up.
|
|
--
|
|
-- Pricing the MAP instead says the true thing: coming back somewhere we have
|
|
-- already been is usually wrong and occasionally necessary. Twelve is more
|
|
-- than any detour worth taking around a working road and less than the cost of
|
|
-- the tour.
|
|
function Bot:visitCost(visits, mapId)
|
|
-- Four, not twelve. A revisit has to cost LESS than the detour that avoids
|
|
-- it or the planner buys the detour: stood in Azalea Town's east pocket with
|
|
-- Ilex Forest two warps away through the town, a 12-per-visit price made
|
|
-- "back into Azalea" (1 hop + 12) look worse than "north through Route 32,
|
|
-- Goldenrod and Route 34" (7 hops), and the bot walked half of Johto rather
|
|
-- than step back through a door it had just used. Repeat visits still add
|
|
-- up, which is all the ping-pong guard needs.
|
|
return ((visits and visits[mapId]) or 0) * 4
|
|
end
|
|
|
|
-- Which region of the current map are we standing in?
|
|
--
|
|
-- Flood fill from the player with warp cells treated as holes -- the same rule
|
|
-- the generator used -- then see which of the map's recorded representative
|
|
-- cells the fill contains. Standing ON a warp belongs to no region, so the
|
|
-- fill starts from the neighbours instead and may legitimately answer with
|
|
-- several: that is what coming out of Union Cave onto Route 33's (11,9) does,
|
|
-- and it is the whole reason the southern half of that route is reachable at
|
|
-- all.
|
|
function Bot:currentRegions()
|
|
local map = A.map(self.g)
|
|
local mapId = A.mapId(self.g)
|
|
local list = REGIONS[mapId or ""]
|
|
local px, py = A.pos(self.g)
|
|
if not (map and list and px) then return nil end
|
|
|
|
-- The same warp test the generator used, or the fill draws a different map
|
|
-- from the one the graph was built on and matches no region at all -- at
|
|
-- which case planTravelRegions silently returns nil and the whole
|
|
-- region-aware planner is off.
|
|
local starts = {}
|
|
if not A.isWarpTile(map, px, py) then
|
|
starts[#starts + 1] = { px, py }
|
|
else
|
|
for _, d in pairs(DELTA) do
|
|
local nx, ny = px + d[1], py + d[2]
|
|
if map:inBounds(nx, ny) and A.walkable(self.g, map, nx, ny)
|
|
and not A.isWarpTile(map, nx, ny) then
|
|
starts[#starts + 1] = { nx, ny }
|
|
end
|
|
end
|
|
end
|
|
if #starts == 0 then return nil end
|
|
|
|
-- The generator's regions are strongly connected components over DIRECTED
|
|
-- movement (ledge hops and Gold's one-way walls -- see mapgraph.lua), so
|
|
-- membership is mutual reachability: the fill runs once forward and once
|
|
-- over reversed edges, and a representative cell has to appear in both.
|
|
-- A forward-only fill would claim every region the player can DRAIN into
|
|
-- (hop down into and never climb back out of), and planTravelRegions would
|
|
-- then seed the search from regions the player is not standing in.
|
|
local function cellOk(x, y)
|
|
return map:inBounds(x, y) and A.walkable(self.g, map, x, y)
|
|
and not A.isWarpTile(map, x, y)
|
|
end
|
|
-- Successor cells of (x, y): permitted steps, else ledge hops.
|
|
local function stepsFrom(x, y)
|
|
local out = {}
|
|
local hop = A.ledgeFacings(map, x, y)
|
|
for dir, d in pairs(DELTA) do
|
|
local nx, ny = x + d[1], y + d[2]
|
|
if A.stepPermitted(map, x, y, dir) and cellOk(nx, ny) then
|
|
out[#out + 1] = { nx, ny }
|
|
elseif hop and hop[dir] then
|
|
local hx, hy = x + d[1] * 2, y + d[2] * 2
|
|
if cellOk(hx, hy) then out[#out + 1] = { hx, hy } end
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
local function stepsOnto(x, y, tx, ty)
|
|
if not cellOk(x, y) then return false end
|
|
for _, s in ipairs(stepsFrom(x, y)) do
|
|
if s[1] == tx and s[2] == ty then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function fill(forward)
|
|
local seen = {}
|
|
local queue, head = {}, 1
|
|
for _, s in ipairs(starts) do
|
|
seen[s[2] * 4096 + s[1]] = true
|
|
queue[#queue + 1] = s
|
|
end
|
|
while head <= #queue do
|
|
local c = queue[head]; head = head + 1
|
|
if forward then
|
|
for _, s in ipairs(stepsFrom(c[1], c[2])) do
|
|
local k = s[2] * 4096 + s[1]
|
|
if not seen[k] then
|
|
seen[k] = true
|
|
queue[#queue + 1] = { s[1], s[2] }
|
|
end
|
|
end
|
|
else
|
|
for _, d in pairs(DELTA) do
|
|
for dist = 1, 2 do
|
|
local px2, py2 = c[1] + d[1] * dist, c[2] + d[2] * dist
|
|
local k = py2 * 4096 + px2
|
|
if not seen[k] and stepsOnto(px2, py2, c[1], c[2]) then
|
|
seen[k] = true
|
|
queue[#queue + 1] = { px2, py2 }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return seen
|
|
end
|
|
|
|
local fwd = fill(true)
|
|
local bwd = fill(false)
|
|
local out = {}
|
|
for index, r in ipairs(list) do
|
|
local k = r.y * 4096 + r.x
|
|
if fwd[k] and bwd[k] then out[#out + 1] = index end
|
|
end
|
|
return (#out > 0) and out or nil
|
|
end
|
|
|
|
-- Cheapest sequence of hops from where we stand to `target`, over the REGION
|
|
-- graph.
|
|
--
|
|
-- The map-id graph this replaced could not express the one fact that decides
|
|
-- most of Johto's routing: a map is not a place. Route 33's north strip and
|
|
-- its south strip touch only at the Union Cave entrance, so "Route 33 connects
|
|
-- west to Azalea Town" is true of the south half and false of the north half;
|
|
-- Azalea Town's east border is shared by the town and by a 27-cell dead end.
|
|
-- Planning over map ids, the bot walked from Route 32 down Route 33, crossed
|
|
-- west, landed in the dead end, and then -- because the graph insisted it was
|
|
-- standing in Azalea Town -- either toured Johto looking for a way out or gave
|
|
-- up and teleported. Over regions the same query answers "through Union
|
|
-- Cave", which is what the walkthrough says.
|
|
--
|
|
-- The static graph is a prior, not gospel: CUT trees, boulders and Strength
|
|
-- can merge regions at run time, and it knows nothing of NPCs. So a plan that
|
|
-- fails still falls back to the map-id search below, and the live prices
|
|
-- (seams, bad exits, visits) apply to both.
|
|
function Bot:planTravelRegions(target, visits, wantRegion)
|
|
local from = A.mapId(self.g)
|
|
if not (from and REGIONS[from] and REGIONS[target]) then return nil end
|
|
local here = self:currentRegions()
|
|
if not here then
|
|
-- Falling back to the map-id planner is a real loss of accuracy on any map
|
|
-- with more than one region, so say when it happens rather than degrade
|
|
-- silently: the lighthouse bounce looked like a planner bug and was really
|
|
-- this.
|
|
local px, py = A.pos(self.g)
|
|
self:say(("plan: no region match on %s at (%s,%s) -- using the map graph")
|
|
:format(tostring(from), tostring(px), tostring(py)))
|
|
return nil
|
|
end
|
|
-- Already there is only "already there" if the REGION matches too.
|
|
--
|
|
-- This returned an empty hop list whenever the map ids matched, which made a
|
|
-- region-targeted travel a no-op the moment the bot was standing anywhere on
|
|
-- the right map: row 11.15r reported "ok" in zero frames from the wrong half
|
|
-- of TEAM_ROCKET_BASE_B3F, and every row behind it went back to failing. When
|
|
-- the region is wrong the answer is a real route -- out of this region and
|
|
-- back in by the other door -- so fall through and plan one.
|
|
if from == target then
|
|
if not wantRegion then return {} end
|
|
for _, r in ipairs(here) do
|
|
if r == wantRegion then return {} end
|
|
end
|
|
end
|
|
|
|
local function key(map, r) return ("%s#%d"):format(map, r) end
|
|
local dist, open = {}, {}
|
|
for _, r in ipairs(here) do
|
|
dist[key(from, r)] = 0
|
|
open[#open + 1] = { map = from, region = r, d = 0, parent = nil }
|
|
end
|
|
|
|
local seen = {}
|
|
while #open > 0 do
|
|
local bi, best = 1, open[1]
|
|
for i = 2, #open do
|
|
if open[i].d < best.d then bi, best = i, open[i] end
|
|
end
|
|
table.remove(open, bi)
|
|
local bk = key(best.map, best.region)
|
|
if not seen[bk] then
|
|
seen[bk] = true
|
|
if best.map == target
|
|
and (not wantRegion or best.region == wantRegion) then
|
|
local hops, node = {}, best
|
|
while node and node.hop do
|
|
table.insert(hops, 1, node.hop)
|
|
node = node.parent
|
|
end
|
|
return hops
|
|
end
|
|
local regs = REGIONS[best.map]
|
|
local node = regs and regs[best.region]
|
|
-- Do NOT apply map-level isCulDeSac here. GOLDENROD_UNDERGROUND's every
|
|
-- external warp lands on SWITCH_ROOM_ENTRANCES, so the map-id test calls
|
|
-- it a cul-de-sac -- but its regions are the only bridge between that
|
|
-- neighbour's disconnected halves (basement warp 6 -> switch region 1;
|
|
-- salon warps 1/2 -> switch regions 10/9). Filtering it made every
|
|
-- region-targeted travel into the switch room fall back to the map-id
|
|
-- planner and arrive in the wrong half, with Switch1/2/3 then reporting
|
|
-- "nowhere to stand".
|
|
if node then
|
|
for _, link in ipairs(node.exits) do
|
|
local exit = (link.k == "w")
|
|
and { kind = "warp", index = link.i, x = link.x, y = link.y,
|
|
to = link.to }
|
|
or { kind = "edge", dir = BUTTON_DIR[link.d], to = link.to }
|
|
if exit.kind ~= "edge" or exit.dir then
|
|
local nd = best.d + 1 + self:visitCost(visits, link.to)
|
|
+ self:seamCost(best.map, link.to)
|
|
+ self:exitCost(best.map, exit)
|
|
local nk = key(link.to, link.r)
|
|
if nd < (dist[nk] or math.huge) then
|
|
dist[nk] = nd
|
|
open[#open + 1] = { map = link.to, region = link.r, d = nd,
|
|
parent = best,
|
|
hop = { from = best.map, exit = exit } }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function Bot:planTravel(target, avoid, visits)
|
|
local from = A.mapId(self.g)
|
|
if not from then return nil end
|
|
if from == target then return {} end
|
|
|
|
-- Nodes carry their own parent chain rather than a prev[mapId] table.
|
|
-- That table was the bug that made this whole fix look broken: once the
|
|
-- start map may appear in the MIDDLE of a route (2F -> 1F -> 2F), walking
|
|
-- the chain back "until we reach the start" stops at that middle occurrence
|
|
-- and hands back only the tail -- which is precisely the unreachable hop the
|
|
-- detour existed to avoid. Following node parents cannot truncate.
|
|
local dist = {}
|
|
local open = {}
|
|
|
|
-- Seed with the exits we can actually reach from where we are standing.
|
|
local px, py = A.pos(self.g)
|
|
local reachable = 0
|
|
for _, exit in ipairs(self:exitsOf(from)) do
|
|
local ok = true
|
|
if exit.kind == "warp" then
|
|
-- Only WARP exits are reachability-checked here. Doing the same for edge
|
|
-- exits is tempting and was tried: it costs up to a dozen full searches
|
|
-- per edge per plan, and travelTo re-plans after every hop, so the run
|
|
-- went from 280k frames to 1.36M and got further from Goldenrod, not
|
|
-- closer. crossEdge probes the border itself, which is the cheap place
|
|
-- for that question.
|
|
ok = self:planPath(exit.x, exit.y) ~= nil
|
|
-- Never step straight back through the warp we just arrived on. A gate
|
|
-- drops the player onto the tile that leads back, so "walk to that tile"
|
|
-- is a no-op followed by an immediate return -- the bot ping-ponged
|
|
-- between Route 46 and its gate this way, twice a second, forever.
|
|
-- Backtracking is still allowed, just not from the doormat.
|
|
if avoid and exit.to == avoid and exit.x == px and exit.y == py then
|
|
ok = false
|
|
end
|
|
elseif not self:borderStandable(from, exit.dir) then
|
|
-- A connection whose near border we could not stand on anywhere. Not a
|
|
-- flaky seam to be priced: there is nothing to walk off.
|
|
ok = false
|
|
end
|
|
if ok then
|
|
reachable = reachable + 1
|
|
local d = 1 + self:visitCost(visits, exit.to)
|
|
+ self:seamCost(from, exit.to)
|
|
+ self:exitCost(from, exit)
|
|
-- Deliberately NOT penalising "the map we just came from" in general.
|
|
-- Tried it (+50) and it was a bad trade: Johto's routes are a chain, so
|
|
-- almost every real path revisits its predecessor, and the planner
|
|
-- answered by taking enormous detours -- the bot ended up back on Route
|
|
-- 29 wiping 88 times instead of walking to Violet City. The narrow
|
|
-- standing-on-the-doormat exclusion above is enough to stop the tight
|
|
-- bounce, and travelTo's visits guard catches the looser ones.
|
|
if d < (dist[exit.to] or math.huge) then
|
|
dist[exit.to] = d
|
|
open[#open + 1] = { id = exit.to, d = d, parent = nil,
|
|
hop = { from = from, exit = exit } }
|
|
end
|
|
end
|
|
end
|
|
if reachable == 0 then
|
|
-- Walled in. Nothing to plan; the caller reports it rather than spinning.
|
|
return nil
|
|
end
|
|
|
|
local seen = {}
|
|
while #open > 0 do
|
|
local bi, best = 1, open[1]
|
|
for i = 2, #open do
|
|
if open[i].d < best.d then bi, best = i, open[i] end
|
|
end
|
|
table.remove(open, bi)
|
|
if not seen[best.id] then
|
|
seen[best.id] = true
|
|
if best.id == target then
|
|
local hops, node = {}, best
|
|
while node do
|
|
table.insert(hops, 1, node.hop)
|
|
node = node.parent
|
|
end
|
|
return hops
|
|
end
|
|
-- Never route THROUGH a cul-de-sac; it may only ever be a destination.
|
|
if not (self:isCulDeSac(best.id) and best.id ~= target) then
|
|
for _, exit in ipairs(self:exitsOf(best.id)) do
|
|
-- A seam is priced by how often it has failed, so a flaky border cell
|
|
-- costs more than a clean one but never becomes impassable.
|
|
local nd = best.d + 1 + self:visitCost(visits, exit.to)
|
|
+ self:seamCost(best.id, exit.to)
|
|
+ self:exitCost(best.id, exit)
|
|
if nd < (dist[exit.to] or math.huge) then
|
|
dist[exit.to] = nd
|
|
open[#open + 1] = { id = exit.to, d = nd, parent = best,
|
|
hop = { from = best.id, exit = exit } }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Take one hop: walk onto the warp cell, or walk off the connected edge.
|
|
function Bot:takeHop(hop)
|
|
local from = A.mapId(self.g)
|
|
local exit = hop.exit
|
|
if exit.kind == "warp" then
|
|
self:say(("hop %s -> %s via warp %d (%d,%d)")
|
|
:format(from, exit.to, exit.index, exit.x, exit.y))
|
|
self:enterWarp(exit.x, exit.y, exit.to)
|
|
else
|
|
self:say(("hop %s -> %s via %s edge"):format(from, exit.to, exit.dir))
|
|
self:crossEdge(exit.dir, exit.to)
|
|
end
|
|
self:clearDialogue()
|
|
local arrived = A.mapId(self.g)
|
|
if arrived == exit.to then
|
|
self:clearSeam(from, exit.to)
|
|
self:progress()
|
|
return true
|
|
end
|
|
self:noteSeam(from, exit.to)
|
|
self:say(("hop failed: wanted %s, on %s"):format(exit.to, tostring(arrived)))
|
|
return arrived ~= from -- landing somewhere ELSE is still movement, and
|
|
-- the caller re-plans from wherever we are
|
|
end
|
|
|
|
-- How many cells can the player actually reach from where they stand?
|
|
--
|
|
-- Several maps have a pocket that touches a border: Azalea Town's east side is
|
|
-- shared by the town proper (251 cells) and a 27-cell dead end whose only exit
|
|
-- is back the way you came. Cross at the wrong y, or teleport onto the wrong
|
|
-- warp, and every plan afterwards is drawn from inside a cupboard. Counting
|
|
-- the reachable cells is how the bot notices it is in one.
|
|
function Bot:regionSize(limit)
|
|
local map = A.map(self.g)
|
|
local sx, sy = A.pos(self.g)
|
|
if not (map and sx) then return 0 end
|
|
local seen = { [sy * 4096 + sx] = true }
|
|
local queue, head, n = { { sx, sy } }, 1, 1
|
|
while head <= #queue do
|
|
local c = queue[head]; head = head + 1
|
|
for _, d in pairs(DELTA) do
|
|
local nx, ny = c[1] + d[1], c[2] + d[2]
|
|
local k = ny * 4096 + nx
|
|
if not seen[k] and map:inBounds(nx, ny)
|
|
and A.walkable(self.g, map, nx, ny) then
|
|
seen[k] = true
|
|
n = n + 1
|
|
if limit and n >= limit then return n end
|
|
queue[#queue + 1] = { nx, ny }
|
|
end
|
|
end
|
|
end
|
|
return n
|
|
end
|
|
|
|
-- The reachable cell nearest the given edge, which is what we aim at before
|
|
-- pushing into the connection.
|
|
--
|
|
-- `skip` walks the candidate list further along. A connection is a STRIP: the
|
|
-- y you cross at decides the y you land on, and on a map with a border pocket
|
|
-- that decides which side of a wall you arrive in. Always aiming at the
|
|
-- nearest cell meant a failed crossing was retried identically forever, so the
|
|
-- caller counts its attempts and asks for a different cell each time.
|
|
function Bot:edgeTarget(map, rawDir, skip)
|
|
local dir = BUTTON_DIR[rawDir]
|
|
if not dir then return nil end
|
|
local w, h = map.widthCells, map.heightCells
|
|
local px, py = A.pos(self.g)
|
|
if not px then return nil end
|
|
-- Candidates along the border, nearest first -- and then REACHABLE-checked.
|
|
--
|
|
-- "Nearest passable" is not good enough and Route 34 is why: its top row has
|
|
-- passable cells the player cannot actually get to (the river cuts them off),
|
|
-- so the bot aimed at (13,0), found no path, and never crossed into Goldenrod
|
|
-- at all. A border cell is only a useful target if we can walk to it.
|
|
local candidates = {}
|
|
local function consider(x, y)
|
|
if not self:passable(map, x, y) then return end
|
|
candidates[#candidates + 1] =
|
|
{ x = x, y = y, d = math.abs(x - px) + math.abs(y - py) }
|
|
end
|
|
if dir == "up" then
|
|
for x = 0, w - 1 do consider(x, 0) end
|
|
elseif dir == "down" then
|
|
for x = 0, w - 1 do consider(x, h - 1) end
|
|
elseif dir == "left" then
|
|
for y = 0, h - 1 do consider(0, y) end
|
|
else
|
|
for y = 0, h - 1 do consider(w - 1, y) end
|
|
end
|
|
table.sort(candidates, function(a, b) return a.d < b.d end)
|
|
|
|
-- Each check is a full search, so the scan is bounded -- but the bound has to
|
|
-- be big enough to leave the region we are IN. The nearest-16 cap was too
|
|
-- small on tall maps: coming down Blackthorn onto Route 45's top strip
|
|
-- (region 3), the sixteen west-border cells nearest (15,0) are all up in the
|
|
-- top-left, which is region 1/2 and unreachable from region 3 -- region 3's
|
|
-- own Route 46 border cells are a third of the way down, past the cap, so
|
|
-- the scan found nothing reachable and fell back to (0,0), a region-1 cell it
|
|
-- could never walk to. Scan far enough to clear a region (this is a
|
|
-- per-crossing fallback, not the per-hop plan the handoff warns is too slow),
|
|
-- and stop early once a handful are in hand.
|
|
local reachable = {}
|
|
local checked = 0
|
|
for _, cell in ipairs(candidates) do
|
|
if self:planPath(cell.x, cell.y) then reachable[#reachable + 1] = cell end
|
|
checked = checked + 1
|
|
if #reachable >= 6 or checked >= 64 then break end
|
|
end
|
|
if #reachable > 0 then
|
|
-- Alternate near end / far end as `skip` grows rather than stepping one
|
|
-- cell along. Border cells that land in the same pocket are contiguous,
|
|
-- so "the next one over" is almost always the same mistake again; the
|
|
-- opposite end of the border is a different region.
|
|
skip = skip or 0
|
|
local index
|
|
if skip % 2 == 0 then
|
|
index = math.min(#reachable, math.floor(skip / 2) + 1)
|
|
else
|
|
index = math.max(1, #reachable - math.floor((skip - 1) / 2))
|
|
end
|
|
return reachable[index]
|
|
end
|
|
-- Nothing provably reachable: still hand back the nearest border cell. The
|
|
-- local search does not model one-way ledge hops, so "no path" is weaker than
|
|
-- "cannot get there", and crossEdge's push-and-slide can still find the seam.
|
|
if #candidates > 0 then
|
|
self:say(("edgeTarget %s: none of the nearest %d border cells provably "
|
|
.. "reachable, trying the closest anyway"):format(dir, checked))
|
|
end
|
|
return candidates[1]
|
|
end
|
|
|
|
-- Walk off the edge of the map in `dir` (a pad direction) onto the connected
|
|
-- map. There is no single cell to aim at -- any cell on that border works, and
|
|
-- which ones are reachable depends on where we came in -- so aim at the nearest
|
|
-- reachable border cell, then push. Pushing repeatedly matters: the landing
|
|
-- strip is offset, so the first border cell we reach is not always one the
|
|
-- connection actually covers, and walking ALONG the edge finds one that is.
|
|
function Bot:crossEdge(dir, dest)
|
|
local from = A.mapId(self.g)
|
|
local map = A.map(self.g)
|
|
if not map then return false end
|
|
|
|
-- Cross at a different point each time this particular border has FAILED,
|
|
-- so a crossing that lands in a dead-end pocket is not repeated identically.
|
|
-- The counter used to bump on every call, including successes: the first
|
|
-- Route 43 -> Lake of Rage cross (the good x) then made the return visit
|
|
-- after 10.g pick skip=1, which is the west end of the strip, and that
|
|
-- lands in a 47-cell pocket with no path to the Red Gyarados even with
|
|
-- Surf. Only failures (and escapePocket's explicit bump) advance it.
|
|
local tryKey = ("%s#%s"):format(tostring(from), tostring(dir))
|
|
local skip = self.edgeTries[tryKey] or 0
|
|
|
|
local target = self:edgeTarget(map, dir, skip)
|
|
local aimed = target ~= nil
|
|
if target then
|
|
local cx, cy = A.pos(self.g)
|
|
self:say(("edge %s off %s: crossing at (%d,%d) from (%s,%s) [try %d]")
|
|
:format(dir, tostring(from), target.x, target.y, tostring(cx),
|
|
tostring(cy), skip))
|
|
self:walkTo(target.x, target.y, { stopOnMapChange = true })
|
|
if A.mapId(self.g) ~= from then
|
|
self:clearDialogue()
|
|
return dest == nil or A.mapId(self.g) == dest
|
|
end
|
|
end
|
|
|
|
-- Push into the seam, and if it does not take, slide along the edge and try
|
|
-- again. `along` is the axis perpendicular to the crossing.
|
|
local along = (dir == "up" or dir == "down") and { "left", "right" }
|
|
or { "up", "down" }
|
|
-- Pushing blindly is only worth much when we got near the border under our
|
|
-- own steam. When no border cell was even reachable -- the player is in a
|
|
-- pocket, or eighty rows away -- twenty-four pushes is five thousand frames
|
|
-- spent proving what the failed walk already said.
|
|
for attempt = 1, (aimed and 24 or 6) do
|
|
if A.mapId(self.g) ~= from then break end
|
|
A.hold(self.g, dir)
|
|
self:wait(TURN_FRAMES + 14)
|
|
A.releaseDirs(self.g)
|
|
self:wait(6)
|
|
if A.mapId(self.g) ~= from then break end
|
|
if A.busy(self.g) then self:clearDialogue() end
|
|
-- Alternate which way we slide so a blocked corner does not trap us at one
|
|
-- end of the border, but never slide OFF the map: standing in a corner, a
|
|
-- slide along one border is a step across the other one. That is how
|
|
-- "cross west into Violet City" came back having arrived on Route 30 --
|
|
-- the player was on Route 31's bottom row, and the first southward slide
|
|
-- took the south connection instead. A crossing that lands on the wrong
|
|
-- map is worse than one that fails: the caller prices the seam it asked
|
|
-- for, which was never the one that fired.
|
|
local slide = along[(attempt % 2) + 1]
|
|
local ax, ay = A.pos(self.g)
|
|
local map2 = A.map(self.g)
|
|
local d = DELTA[slide]
|
|
if not (ax and map2 and map2:inBounds(ax + d[1], ay + d[2])) then
|
|
slide = along[((attempt + 1) % 2) + 1]
|
|
d = DELTA[slide]
|
|
end
|
|
if ax and map2 and map2:inBounds(ax + d[1], ay + d[2]) then
|
|
self:stepDir(slide)
|
|
end
|
|
end
|
|
|
|
self:clearDialogue()
|
|
local now = A.mapId(self.g)
|
|
if now == from then
|
|
self.edgeTries[tryKey] = skip + 1
|
|
self:say(("edge %s off %s did not cross"):format(dir, tostring(from)))
|
|
return false
|
|
end
|
|
return dest == nil or now == dest
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- healing
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
-- A Pokecenter nurse is a map's only SPRITE_NURSE object, so the extracted
|
|
-- objects already say where every heal point in Johto is; nothing needs a cell
|
|
-- hardcoded per town.
|
|
local function nurseOn(def)
|
|
for _, obj in ipairs((def and def.objects) or {}) do
|
|
if obj.sprite == "SPRITE_NURSE" then return obj end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Nearest map (by hop count) whose def satisfies `predicate`.
|
|
function Bot:findNearest(predicate)
|
|
local defs = self:mapDefs()
|
|
local from = A.mapId(self.g)
|
|
if not from then return nil end
|
|
local seen, queue, head = { [from] = true }, { from }, 1
|
|
while head <= #queue do
|
|
local id = queue[head]
|
|
head = head + 1
|
|
if predicate(defs[id], id) then return id end
|
|
for _, exit in ipairs(self:exitsOf(id)) do
|
|
if not seen[exit.to] then
|
|
seen[exit.to] = true
|
|
queue[#queue + 1] = exit.to
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Full heal at a Pokecenter: restores HP *and PP*, which is the part that
|
|
-- matters most given the port has no Struggle (see A.partyPpFraction).
|
|
function Bot:healUp(preferMap)
|
|
local defs = self:mapDefs()
|
|
-- Candidates in graph-BFS order, and TRY EACH ONE. The BFS is region-blind,
|
|
-- so the nearest Pokecenter by map hops can be walled off from where we
|
|
-- actually stand -- west of the Sudowoodo tree, Route 36 cannot reach
|
|
-- Violet's -- and failing the whole heal on that one miss is what ended
|
|
-- 05.g with "could not heal mid-grind" while Goldenrod's center sat two
|
|
-- maps the other way.
|
|
local candidates = {}
|
|
if preferMap and nurseOn(defs[preferMap]) then
|
|
candidates[#candidates + 1] = preferMap
|
|
end
|
|
local from = A.mapId(self.g)
|
|
if from then
|
|
local seen, queue, head = { [from] = true }, { from }, 1
|
|
while head <= #queue and #candidates < 4 do
|
|
local id = queue[head]
|
|
head = head + 1
|
|
if nurseOn(defs[id]) and id ~= preferMap then
|
|
candidates[#candidates + 1] = id
|
|
end
|
|
for _, exit in ipairs(self:exitsOf(id)) do
|
|
if not seen[exit.to] then
|
|
seen[exit.to] = true
|
|
queue[#queue + 1] = exit.to
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if #candidates == 0 then
|
|
self:say("heal: no Pokecenter reachable from here")
|
|
return false
|
|
end
|
|
for _, target in ipairs(candidates) do
|
|
if A.mapId(self.g) == target or self:travelTo(target) then
|
|
local nurse = nurseOn(defs[target])
|
|
if nurse and self:approachAndFace(nurse.x, nurse.y) then
|
|
self:tap("a")
|
|
self:clearDialogue({ "yes" }, 3000)
|
|
self:say(("heal: done on %s (hp %.2f, pp %.2f)")
|
|
:format(target, A.leadHpFraction(self.g), A.partyPpFraction(self.g)))
|
|
return true
|
|
end
|
|
self:say("heal: could not reach the nurse on " .. target)
|
|
else
|
|
self:say("heal: could not reach " .. target .. " -- trying the next")
|
|
end
|
|
end
|
|
self:say("heal: every candidate Pokecenter was unreachable")
|
|
return false
|
|
end
|
|
|
|
-- Heal when the party is in no state to keep fighting. Called between route
|
|
-- rows rather than inside them, so it can never interrupt a scripted beat
|
|
-- half-way through.
|
|
function Bot:maybeHeal()
|
|
local hp = A.leadHpFraction(self.g)
|
|
local pp = A.damagingPpFraction(self.g)
|
|
if hp >= 0.35 and pp >= 0.35 then return false end
|
|
if A.busy(self.g) or A.inBattle(self.g) then return false end
|
|
self:say(("heal: hp %.2f attacking-pp %.2f -- going to a Pokecenter")
|
|
:format(hp, pp))
|
|
return self:healUp()
|
|
end
|
|
|
|
-- Are we walled into a corner of this map whose only exit leads back to
|
|
-- `cameFrom`? Cheap enough to ask once per hop: it is one flood fill plus a
|
|
-- reachability test per exit, and it is the difference between noticing a bad
|
|
-- crossing immediately and touring Johto to find out.
|
|
function Bot:inPocket(cameFrom)
|
|
local here = A.mapId(self.g)
|
|
if not here then return false end
|
|
local exits = self:exitsOf(here)
|
|
if #exits == 0 then return false end
|
|
local wayOut = false
|
|
for _, exit in ipairs(exits) do
|
|
if exit.to ~= cameFrom then
|
|
if exit.kind == "edge" then
|
|
if self:borderStandable(here, exit.dir) then wayOut = true break end
|
|
elseif self:planPath(exit.x, exit.y) then
|
|
wayOut = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
return not wayOut
|
|
end
|
|
|
|
-- We crossed into a pocket. Go back and cross again somewhere else.
|
|
--
|
|
-- `from` is the map we came from and `exit` the crossing that put us here.
|
|
-- Each attempt bumps that crossing's counter, which is what makes edgeTarget
|
|
-- pick a border cell at the other end of the seam instead of the same one.
|
|
function Bot:escapePocket(from, exit)
|
|
local pocketMap = A.mapId(self.g)
|
|
local key = ("%s#%s"):format(tostring(from),
|
|
exit.kind == "edge" and exit.dir or ("w" .. tostring(exit.index)))
|
|
for attempt = 1, 3 do
|
|
self.edgeTries[key] = (self.edgeTries[key] or 0) + 1
|
|
-- Back out through whichever way we can reach; on a pocket that is the way
|
|
-- we came in.
|
|
local out = nil
|
|
for _, e in ipairs(self:exitsOf(pocketMap)) do
|
|
if e.to == from then out = e break end
|
|
end
|
|
if not out then return false end
|
|
if not self:takeHop({ exit = out }) then return false end
|
|
if A.mapId(self.g) ~= from then return false end
|
|
|
|
if exit.kind == "edge" then
|
|
self:crossEdge(exit.dir, exit.to)
|
|
else
|
|
self:enterWarp(exit.x, exit.y, exit.to)
|
|
end
|
|
if A.mapId(self.g) ~= pocketMap then return false end
|
|
if not self:inPocket(from) then
|
|
self:say(("travel: re-crossed into %s clear of the pocket (try %d)")
|
|
:format(tostring(pocketMap), attempt))
|
|
return true
|
|
end
|
|
end
|
|
self:say(("travel: every crossing into %s lands in the pocket")
|
|
:format(tostring(pocketMap)))
|
|
return false
|
|
end
|
|
|
|
-- Get to `target`, re-planning after every hop.
|
|
-- `wantRegion` is a region INDEX from tests/drivers/gold/map_regions.lua,
|
|
-- for the maps where arriving is not the same as arriving somewhere useful.
|
|
--
|
|
-- TEAM_ROCKET_BASE_B3F is the case that forced it. The rival trigger at (8,10)
|
|
-- sits in region 1, which is reachable only through warps 1 and 3 on the far
|
|
-- LEFT of the floor; the way in from B1F lands you in region 3 on the right,
|
|
-- and the two never touch. "travel TEAM_ROCKET_BASE_B3F" was therefore
|
|
-- satisfied by the wrong half of the map, and every row after it -- the rival,
|
|
-- Giovanni's door, Executive 4, HAIL GIOVANNI -- failed on a floor the bot was
|
|
-- standing on. The real route is a loop through both floors, which the region
|
|
-- graph can find as soon as it is told which end to aim for.
|
|
function Bot:travelTo(target, wantRegion)
|
|
if not self:mapDefs()[target] then
|
|
self:say("travel: unknown map " .. tostring(target))
|
|
return false
|
|
end
|
|
-- Loop breaker. A multi-floor interior (Sprout Tower, the lighthouse) has
|
|
-- several ladders between the same pair of floors, so a hop can succeed --
|
|
-- land exactly on the map it promised -- and still leave us further from the
|
|
-- goal, at which point the next plan sends us straight back and the two
|
|
-- floors trade the player back and forth forever. Counting map VISITS
|
|
-- catches that, where the seam price cannot: nothing here ever failed.
|
|
local visits = {}
|
|
local cameFrom = nil
|
|
local relaxed = false
|
|
-- A frame budget, not just a hop budget.
|
|
--
|
|
-- The hop counter (80) and the per-map visit guard both bound how many
|
|
-- DECISIONS a trip makes, and neither bounds how long one takes: a single
|
|
-- travel to the Ilex Forest gate once spent 3.5 MILLION frames -- most of a
|
|
-- run -- inside eighty legal-looking hops, each of which walked half of
|
|
-- Azalea and pushed at a border twenty-four times. The watchdogs in :wait()
|
|
-- and :say() cannot see it either, because every hop reports progress.
|
|
--
|
|
-- 150k frames is far more than any real journey in Johto (the longest honest
|
|
-- one measured is about 60k) and far less than a wasted run.
|
|
local startedAt = frames
|
|
local frameBudget = tonumber(os.getenv("POKEPORT_GOLD_TRAVEL_BUDGET")) or 150000
|
|
|
|
-- Starting inside a pocket is the same problem as arriving in one, minus the
|
|
-- hop that tells you which crossing put you there. The way out is forced
|
|
-- and leads to exactly one map, so the crossing to vary is the one BACK from
|
|
-- that map -- bump it before the trip rather than after, or the return leg
|
|
-- re-enters the same corner and the trip is a loop by construction.
|
|
if A.mapId(self.g) ~= target and self:inPocket(nil) then
|
|
local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" }
|
|
for _, exit in ipairs(self:exitsOf(A.mapId(self.g))) do
|
|
if exit.kind == "edge" and OPPOSITE[exit.dir] then
|
|
local key = ("%s#%s"):format(exit.to, OPPOSITE[exit.dir])
|
|
self.edgeTries[key] = (self.edgeTries[key] or 0) + 1
|
|
self:say(("travel: starting in a dead-end pocket of %s -- will re-enter "
|
|
.. "from %s at a different point")
|
|
:format(tostring(A.mapId(self.g)), exit.to))
|
|
end
|
|
end
|
|
end
|
|
|
|
local function arrived()
|
|
if A.mapId(self.g) ~= target then return false end
|
|
if not wantRegion then return true end
|
|
for _, r in ipairs(self:currentRegions() or {}) do
|
|
if r == wantRegion then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
for _ = 1, 80 do
|
|
local here = A.mapId(self.g)
|
|
if arrived() then return true end
|
|
if frames - startedAt > frameBudget then
|
|
self:say(("travel: %d frames trying to reach %s from %s, giving up")
|
|
:format(frames - startedAt, target, tostring(here)))
|
|
return false
|
|
end
|
|
if A.busy(self.g) then self:clearDialogue() end
|
|
|
|
-- Revisiting a map is now legitimate -- changing region inside a tower
|
|
-- means leaving and coming back -- so the loop guard has to allow several
|
|
-- passes before it calls it a loop.
|
|
visits[here] = (visits[here] or 0) + 1
|
|
if visits[here] > 8 then
|
|
self:say(("travel: looping through %s on the way to %s, giving up")
|
|
:format(tostring(here), target))
|
|
return false
|
|
end
|
|
|
|
-- Region graph first, map-id graph as the fallback: the static graph is
|
|
-- right about geometry and blind to anything the run has changed (a CUT
|
|
-- tree down, a boulder moved, a badge earned).
|
|
local hops = self:planTravelRegions(target, visits, wantRegion)
|
|
or self:planTravel(target, cameFrom, visits)
|
|
if not hops and not relaxed then
|
|
-- The prices have painted us into a corner; drop them once and retry, so
|
|
-- a route that genuinely needs to walk back through somewhere is still
|
|
-- possible.
|
|
relaxed = true
|
|
hops = self:planTravel(target, cameFrom, nil)
|
|
end
|
|
if not hops then
|
|
self:say(("travel: no route %s -> %s"):format(tostring(here), target))
|
|
return false
|
|
end
|
|
-- Empty hops means "already there" only when the REGION matches too.
|
|
-- planTravel (map-id fallback) returns {} whenever from == target, so a
|
|
-- region-targeted travel that landed on the wrong half of RADIO_TOWER_5F
|
|
-- -- or any other split map whose static region path is blocked -- used
|
|
-- to report success at (0,0) while the boss sat in region 2.
|
|
if #hops == 0 then return arrived() end
|
|
local exit = hops[1].exit
|
|
cameFrom = here
|
|
if not self:takeHop(hops[1]) then
|
|
-- Price both the pair and the specific exit. The pair price keeps a
|
|
-- flaky seam usable; the exit price is what makes the next attempt pick a
|
|
-- DIFFERENT ladder, which is the only way out of a floor whose regions
|
|
-- do not connect.
|
|
self:noteSeam(here, exit.to)
|
|
self:noteBadExit(here, exit)
|
|
-- Burn a frame on the way past. A hop that fails before it moves --
|
|
-- "warp (15,0) is not reachable from here", decided by a search and no
|
|
-- input at all -- costs ZERO frames, so a plan that keeps choosing it
|
|
-- spins the loop without the silent-stall watchdog ever seeing a frame
|
|
-- go by. Radio Tower 2F did exactly that: the same three lines, forever,
|
|
-- at frame 857930. The repeat detector in :say() catches the talkative
|
|
-- version; this makes sure the quiet one is caught too.
|
|
self:wait(2)
|
|
elseif self:inPocket(here)
|
|
and not self:isCulDeSac(A.mapId(self.g)) then
|
|
-- The hop landed exactly where it promised and still went wrong: we are
|
|
-- in a walled-off corner whose only way out is back. Azalea Town's east
|
|
-- side is shared by the town (251 cells) and a 27-cell dead end, and
|
|
-- Lake of Rage's west south shore is a 47-cell pocket with no path to
|
|
-- the Red Gyarados even with Surf. Crossing from Route 43 at the wrong
|
|
-- x puts you in that pocket, after which every approach on the lake
|
|
-- reports "nowhere to stand".
|
|
--
|
|
-- Used to only fire when the pocket was an INTERMEDIATE map
|
|
-- (`mapId ~= target`). That missed the destination case: travelTo
|
|
-- called the Lake pocket "arrived" and 10.33 never started the fight.
|
|
-- Cul-de-sacs (houses, shops) are excluded -- being pocketed in one is
|
|
-- the same as having arrived.
|
|
--
|
|
-- A connection is a strip, so the answer is to cross at a different
|
|
-- point. escapePocket bumps the edge counter, which is what makes the
|
|
-- retry pick another border cell instead of the same one.
|
|
-- Deliberately NOT priced as a bad exit, and deliberately retried HERE
|
|
-- rather than by handing control back to the planner. It is the right
|
|
-- exit crossed at the wrong point; the planner, which has no idea a map
|
|
-- can have two disconnected halves, only sees a map it has now visited
|
|
-- twice and answers by taking a seven-hop detour round Johto -- which is
|
|
-- the failure this whole branch exists to prevent. So: step back out,
|
|
-- cross again somewhere else, and only give up after a few tries.
|
|
self:say(("travel: %s is a dead-end pocket from here, re-crossing")
|
|
:format(tostring(A.mapId(self.g))))
|
|
self:escapePocket(here, exit)
|
|
end
|
|
end
|
|
-- Must honour wantRegion. Returning "on the right map" here was how a
|
|
-- region-targeted travel into GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES
|
|
-- reported success from region 10 after the map-id fallback hopped warp 1:
|
|
-- eighty retries later the loop ended on the right map in the wrong half.
|
|
return arrived()
|
|
end
|
|
|
|
-- Last-resort arrival: walk if we can, otherwise put the player there.
|
|
--
|
|
-- The map graph has one known hole -- Goldenrod is only reachable through
|
|
-- Route 35's south gate, whose warp is not reachable from the border the
|
|
-- player arrives on, so the planner bounces -- and that hole gates PLAINBADGE,
|
|
-- which gates STRENGTH, which gates everything after it. Rather than let one
|
|
-- seam block six sections, fall through to a teleport and SAY SO, so a summary
|
|
-- can be read honestly: rows reached this way tested the beat, not the route.
|
|
function Bot:reachMap(target, wantRegion)
|
|
if A.mapId(self.g) == target then
|
|
if wantRegion then
|
|
for _, r in ipairs(self:currentRegions() or {}) do
|
|
if r == wantRegion then return true end
|
|
end
|
|
-- Wrong half of a split map; fall through and travel properly.
|
|
elseif self:isCulDeSac(target) or self:regionSize(64) >= 64 then
|
|
return true
|
|
else
|
|
-- On the right map but in a tiny disconnected pocket (Lake of Rage
|
|
-- west shore after a bad strip crossing). Leave through whatever exit
|
|
-- we can reach, then travelTo back in at a different point.
|
|
self:say(("reachMap: on %s in a %d-cell pocket, leaving to re-enter")
|
|
:format(target, self:regionSize()))
|
|
for _, exit in ipairs(self:exitsOf(target)) do
|
|
if self:takeHop({ exit = exit }) and A.mapId(self.g) ~= target then
|
|
break
|
|
end
|
|
end
|
|
if A.mapId(self.g) == target then return false end
|
|
end
|
|
end
|
|
-- A stall inside travelTo must not skip the fallback below it.
|
|
--
|
|
-- travelTo raises `{ botStall = true }` when the silent-stall backstop fires,
|
|
-- and that unwound straight past this function -- so the one situation the
|
|
-- warp-landing fallback exists for, a player who cannot walk anywhere, was
|
|
-- also the one situation where it never got to run. Run 26 died exactly
|
|
-- there: boxed onto MAHOGANY_GYM's door tile by an NPC, it spent 400k frames
|
|
-- failing every remaining row rather than taking the shortcut, and lost a
|
|
-- seven-badge save. A stall is a reason to fall back, not a reason to stop.
|
|
local ok, res = pcall(self.travelTo, self, target, wantRegion)
|
|
if ok and res then return true end
|
|
if not ok then
|
|
if not (type(res) == "table" and res.botStall) then error(res, 0) end
|
|
self:say(("travel to %s stalled (%s) -- trying a warp landing instead")
|
|
:format(target, tostring(res.why)))
|
|
end
|
|
local def = self:mapDefs()[target]
|
|
local warps = (def and def.warps) or {}
|
|
if #warps == 0 then return false end
|
|
|
|
-- Try each warp in turn and keep the first landing with room to move.
|
|
--
|
|
-- Warp 1 used to be taken unconditionally, and on Ilex Forest warp 1 is
|
|
-- (1,5) -- inside a walled pocket. Every row after that shortcut then
|
|
-- reported "nowhere to stand": the Farfetch'd herd, HM01 CUT and, through
|
|
-- CUT, Whitney, the Squirtbottle, Sudowoodo, ROCK SMASH, the Burned Tower
|
|
-- and Morty. One bad landing cost six sections, so the landing is now
|
|
-- checked rather than assumed.
|
|
--
|
|
-- When the caller named a region, prefer a landing that currentRegions
|
|
-- says is that region -- the largest pocket on a split map is not always
|
|
-- the useful half (and on SWITCH_ROOM_ENTRANCES region 1 happens to be
|
|
-- largest, but region 10 is the second-largest and is where a blind
|
|
-- "first warp with room" used to drop the bot).
|
|
local best, bestSize, bestWanted
|
|
for index, landing in ipairs(warps) do
|
|
if A.teleport(self.g, target, landing.x, landing.y) then
|
|
self:wait(4)
|
|
local size = self:regionSize(64)
|
|
local inWanted = false
|
|
if wantRegion then
|
|
for _, r in ipairs(self:currentRegions() or {}) do
|
|
if r == wantRegion then inWanted = true break end
|
|
end
|
|
end
|
|
if inWanted and not bestWanted then
|
|
best, bestSize, bestWanted = landing, size, true
|
|
elseif not bestWanted and (not bestSize or size > bestSize) then
|
|
best, bestSize = landing, size
|
|
end
|
|
-- 24 rather than "as big as possible": a gate hut is 36 cells and a
|
|
-- Pokecenter smaller still, so demanding a large region would reject
|
|
-- every landing on a small map and scan warps for nothing. What is being
|
|
-- ruled out is a walled-off corner, and those are single figures.
|
|
if (not wantRegion and size >= 24) or (bestWanted and size >= 24) then
|
|
break
|
|
end
|
|
self:say(("TELEPORT: warp %d of %s lands in a %d-cell pocket, trying the "
|
|
.. "next"):format(index, target, size))
|
|
end
|
|
if index >= 6 then break end -- a big interior has plenty; do not scan
|
|
-- every ladder of a six-floor tower
|
|
end
|
|
if not best then return false end
|
|
self:say(("TELEPORT: could not walk to %s, placing the player at (%d,%d) "
|
|
.. "[harness shortcut, not navigation]")
|
|
:format(target, best.x, best.y))
|
|
if not A.teleport(self.g, target, best.x, best.y) then return false end
|
|
self.teleports = (self.teleports or 0) + 1
|
|
self:wait(30)
|
|
self:clearDialogue()
|
|
self:progress()
|
|
if not wantRegion then return A.mapId(self.g) == target end
|
|
for _, r in ipairs(self:currentRegions() or {}) do
|
|
if r == wantRegion then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- construction
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
function Bot.new(game, opts)
|
|
opts = opts or {}
|
|
local self = setmetatable({}, Bot)
|
|
self.g = game
|
|
self.walls, self.seams, self.deaths, self.badExits = {}, {}, {}, {}
|
|
self.culDeSac, self.borders, self.edgeTries = {}, {}, {}
|
|
self.history = {}
|
|
self.silentFrames = 0
|
|
self.stallFrames = tonumber(os.getenv("POKEPORT_GOLD_STALL")) or 5000
|
|
self.stuckRepeats = tonumber(os.getenv("POKEPORT_GOLD_REPEATS")) or 12
|
|
self.battleFrames = 3000
|
|
self.skipped = {}
|
|
local logPath = os.getenv("POKEPORT_GOLD_LOG")
|
|
if logPath then self.logFile = io.open(logPath, "w") end
|
|
return self
|
|
end
|
|
|
|
return Bot
|