mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
1253 lines
54 KiB
Lua
1253 lines
54 KiB
Lua
-- Gold route bot: New Bark Town to the Olivine Gym.
|
|
--
|
|
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_SPEED=200 \
|
|
-- POKEPORT_DRIVER=tests/drivers/gold_bot.lua love .
|
|
--
|
|
-- Interprets tests/drivers/gold/route.lua (objectives derived from
|
|
-- docs/gold-walkthrough/asm-walk) using the core in tests/drivers/gold/bot.lua.
|
|
-- Validate the route first -- `luajit tests/gold_route_validate_test.lua` --
|
|
-- because every coordinate error it catches costs an hour of run time here.
|
|
--
|
|
-- An unimplemented or failing op does NOT abort the run. It is logged, added
|
|
-- to the skip list and stepped over, so one run tells you every wall rather
|
|
-- than only the first: a stall names the asm-walk row it stalled on, which is a
|
|
-- paste-ready issue instead of a mystery. The summary at the end is the point
|
|
-- of the whole exercise.
|
|
--
|
|
-- Env:
|
|
-- POKEPORT_GOLD_LOG=path mirror the log to a file, flushed per line
|
|
-- POKEPORT_GOLD_FROM=id skip route rows until this id (resume)
|
|
-- POKEPORT_GOLD_UNTIL=id stop after this id
|
|
-- POKEPORT_GOLD_STALL=frames silent-stall budget (default 5000)
|
|
-- POKEPORT_GOLD_REPEATS=n identical-log-line budget (default 12)
|
|
|
|
local Bot = dofile("tests/drivers/gold/bot.lua")
|
|
local ROUTE = dofile("tests/drivers/gold/route.lua")
|
|
local A = Bot.adapter
|
|
|
|
return function(game)
|
|
local bot = Bot.new(game)
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- postconditions
|
|
-- ---------------------------------------------------------------------
|
|
-- Every asm-walk checklist row ends in a flag, which is the oracle the Gen 1
|
|
-- bot never had: it inferred success from position and could not tell "the
|
|
-- script ran" from "we happen to be standing in the right place".
|
|
local function flagSet(name)
|
|
if not name then return true end
|
|
local ev = A.event(game, name)
|
|
if ev ~= nil then return ev end
|
|
local en = A.engine(game, name)
|
|
if en ~= nil then return en end
|
|
return nil -- unknown name: the validator should have
|
|
-- caught it, so treat as inconclusive
|
|
end
|
|
|
|
local function satisfied(row)
|
|
if not row.expect then return true end
|
|
local set = flagSet(row.expect)
|
|
if set == nil then
|
|
bot:say((" ? %s: cannot evaluate %s"):format(row.id, row.expect))
|
|
return true
|
|
end
|
|
return set
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- ops
|
|
-- ---------------------------------------------------------------------
|
|
|
|
local ops = {}
|
|
|
|
function ops.travel(row)
|
|
-- `region` pins WHICH half of a split map counts as arriving; see
|
|
-- Bot:travelTo.
|
|
return bot:reachMap(row.map, row.region)
|
|
end
|
|
|
|
function ops.settle(row)
|
|
-- Sit through whatever the map load started. Not a no-op: several beats
|
|
-- (MeetMomScript, Mr Pokemon, the Burned Tower rival) are scene scripts
|
|
-- that fire on arrival with nothing to walk into.
|
|
bot:clearDialogue(row.answers, row.budget or 4000)
|
|
return true
|
|
end
|
|
|
|
function ops.walk(row)
|
|
return bot:walkTo(row.x, row.y, { answers = row.answers })
|
|
end
|
|
|
|
function ops.warp(row)
|
|
-- Retry across wild-encounter interruptions. A warp whose cell is a long
|
|
-- walk over grass (Route 30's Mr Pokemon's house is 60 steps) can have its
|
|
-- walk broken by encounter after encounter -- and worse, a mis-stepped
|
|
-- re-plan can walk the bot clean OFF the map (Route 30's south edge drops
|
|
-- into Cherrygrove), after which every enterWarp aims at (x,y) on the wrong
|
|
-- map and "the warp did not take" forever. So re-reach the row's own map
|
|
-- before each attempt, then walk the warp; the bot is closer each pass.
|
|
for _ = 1, 3 do
|
|
if row.to and A.mapId(game) == row.to then return true end
|
|
if A.mapId(game) ~= row.map then
|
|
if A.busy(game) then bot:clearDialogue() end
|
|
local ok, res = pcall(bot.reachMap, bot, row.map)
|
|
if not (ok and res) then
|
|
if not ok and not (type(res) == "table" and res.botStall) then
|
|
error(res, 0)
|
|
end
|
|
end
|
|
end
|
|
if bot:enterWarp(row.x, row.y, row.to) then return true end
|
|
if A.busy(game) then bot:clearDialogue() end
|
|
end
|
|
return A.mapId(game) == (row.to or A.mapId(game))
|
|
end
|
|
|
|
function ops.edge(row)
|
|
-- Route rows write the compass word the asm-walk uses ("walk west"); the
|
|
-- pad wants a screen direction.
|
|
local dir = bot.button(row.dir)
|
|
if not dir then return false, "unknown direction " .. tostring(row.dir) end
|
|
return bot:crossEdge(dir, row.to)
|
|
end
|
|
|
|
-- A talk/battle row names the OBJECT'S HOME cell out of the map data, but a
|
|
-- sighted trainer WALKS to the player and then stands wherever the fight
|
|
-- happened -- so the named cell can be empty while the npc is two cells
|
|
-- away. Aim at the npc's LIVE position when one homed at (x, y) exists;
|
|
-- bg-event targets (doors, switches, signs) have no npc and keep the row's
|
|
-- own coordinates.
|
|
local function liveTarget(x, y)
|
|
local npc = A.npcHome(game, x, y)
|
|
if npc and npc.cellX then return npc.cellX, npc.cellY end
|
|
return x, y
|
|
end
|
|
|
|
function ops.talk(row)
|
|
-- Retry with a reposition between attempts. A single approach failing is
|
|
-- usually not "unreachable" but "an NPC is standing in the one doorway" or
|
|
-- "we came in by the far entrance and the local search gave up" -- and
|
|
-- failing the row outright then let the bot wander off the map entirely,
|
|
-- which is how the Farfetch'd herd died on its very first talk.
|
|
local tx, ty = liveTarget(row.x, row.y)
|
|
local reached = bot:approachAndFace(tx, ty, row.facings)
|
|
for _ = 1, 3 do
|
|
if reached then break end
|
|
-- Wait, do not wander. The first version of this retry walked to a
|
|
-- random cell, which on a map whose exits sit in the open (Ilex Forest's
|
|
-- do) means stepping onto a warp and leaving -- after which every
|
|
-- remaining row reported "could not reach ILEX_FOREST". Standing still
|
|
-- solves the actual cause anyway: the usual reason an approach fails is
|
|
-- an NPC parked in the one free cell, and NPCs walk away.
|
|
bot:wait(40)
|
|
if A.busy(game) then bot:clearDialogue() end
|
|
if A.mapId(game) ~= row.map then break end
|
|
tx, ty = liveTarget(row.x, row.y)
|
|
reached = bot:approachAndFace(tx, ty, row.facings)
|
|
end
|
|
if not reached then return false end
|
|
-- Press until SOMETHING opens. One tap into the void is how the Burned
|
|
-- Tower rock survived 07.20: a wild fight on the approach ends a few
|
|
-- frames before the press, the post-battle teardown swallows it, and the
|
|
-- row then reports ok off its own opinion with the rock still standing.
|
|
-- Anything a talk row aims at answers with a textbox, so silence after
|
|
-- the press always means the press was lost.
|
|
local opened = false
|
|
for attempt = 1, 4 do
|
|
bot:tap("a")
|
|
bot:wait(4)
|
|
for _ = 1, 30 do
|
|
if A.busy(game) then break end
|
|
bot:wait(1)
|
|
end
|
|
if A.busy(game) then
|
|
opened = true
|
|
break
|
|
end
|
|
bot:say((" talk: press %d at (%d,%d) opened nothing")
|
|
:format(attempt, tx, ty))
|
|
end
|
|
if not opened then
|
|
bot:say(" talk: every press was swallowed")
|
|
end
|
|
bot:clearDialogue(row.answers, row.budget)
|
|
return true
|
|
end
|
|
|
|
-- Ride an elevator. The panel bg event at (x, y) opens the scrolling floor
|
|
-- menu (engine/events/elevator.asm); picking a row does NOT warp -- it
|
|
-- rewrites the door warp's destination (Elevator_GoToFloor -> wBackup*) and
|
|
-- the ride happens when the player walks out through the door at
|
|
-- (doorX, doorY). The map graph cannot express any of that, which is why
|
|
-- the Goldenrod dept store basement -- the only road out of the Rocket
|
|
-- warehouse -- needs an explicit row instead of a travel.
|
|
function ops.elevator(row)
|
|
if A.mapId(game) ~= row.map then return false, "not on the elevator" end
|
|
if not bot:approachAndFace(row.x, row.y, row.facings) then
|
|
return false, "could not reach the panel"
|
|
end
|
|
bot:tap("a")
|
|
local menu
|
|
for _ = 1, 300 do
|
|
menu = A.elevatorMenu(game)
|
|
if menu then break end
|
|
bot:wait(1)
|
|
end
|
|
if not menu then return false, "elevator menu never opened" end
|
|
local want
|
|
for i = 1, #menu.floors do
|
|
if A.elevatorFloorName(menu, i) == row.floor then want = i break end
|
|
end
|
|
if not want then
|
|
return false, ("no floor %s on this elevator"):format(tostring(row.floor))
|
|
end
|
|
for _ = 1, 32 do
|
|
if menu.index == want then break end
|
|
bot:tap(menu.index > want and "up" or "down")
|
|
bot:wait(4)
|
|
end
|
|
if menu.index ~= want then return false, "cursor never reached the floor" end
|
|
bot:tap("a")
|
|
bot:wait(8)
|
|
-- Door-close SFX and script; the menu is gone, so this only clears chrome.
|
|
bot:clearDialogue(nil, 2000)
|
|
if not bot:enterWarp(row.doorX, row.doorY, row.to) then
|
|
return false, "the door did not take"
|
|
end
|
|
return A.mapId(game) == row.to
|
|
end
|
|
|
|
function ops.battle(row)
|
|
if row.talk then
|
|
local tx, ty = liveTarget(row.x, row.y)
|
|
if not bot:approachAndFace(tx, ty, row.facings) then return false end
|
|
bot:tap("a")
|
|
elseif row.x then
|
|
-- A sight-line fight: walking onto the cell is what trips it.
|
|
bot:walkTo(row.x, row.y, { answers = row.answers })
|
|
end
|
|
bot:wait(8)
|
|
-- The approach itself is a script (the trainer walks over), so the battle
|
|
-- may be several hundred frames away.
|
|
bot:clearDialogue(row.answers, row.budget or 8000)
|
|
return true
|
|
end
|
|
|
|
-- The asm-walk phrases healing as a town-level beat ("heal at the
|
|
-- Pokecenter, warp 1 at (13,21)"), so a heal row names the CITY while the
|
|
-- nurse stands a warp away inside it. healUp finds the nearest map that
|
|
-- actually has a nurse, so no row needs to carry the interior map name.
|
|
function ops.heal(row)
|
|
return bot:healUp()
|
|
end
|
|
|
|
function ops.grind(row)
|
|
-- Walk back and forth in whatever grass is reachable until the party's
|
|
-- lowest level reaches the target. Crude, and deliberately so: the point
|
|
-- is not to be fast, it is to not walk into Falkner at level 5.
|
|
--
|
|
-- Blacking out mid-grind is expected at these levels and is survivable: the
|
|
-- engine heals the party and warps to the spawn point. So walk back and
|
|
-- carry on, but cap the number of round trips -- a route step that can
|
|
-- never be met should end the op with a report, not keep the run alive
|
|
-- forever making no progress.
|
|
local target = row.level
|
|
local grindMap = row.map
|
|
local wipes = 0
|
|
|
|
-- Grinding the party minimum means grinding the mon that is NOT fighting.
|
|
-- Experience in Gen 2 goes to whoever was sent out, and the bot always
|
|
-- leads with slot 1, so a party-minimum target on the default order is a
|
|
-- number that can never be reached: the SLOWPOKE caught for SURF sat at
|
|
-- level 7 behind a level-30 starter for the whole run. Putting the weakest
|
|
-- mon in front is what the PARTY menu's own SWITCH row does, and the order
|
|
-- is put back afterwards so the rest of the route still leads with the
|
|
-- fighter.
|
|
--
|
|
-- Whoever is lowest RIGHT NOW goes in front, re-checked every pass.
|
|
--
|
|
-- This used to choose once, before the loop, and a party-minimum target
|
|
-- then could not be met with two weak mons in it: run 24 trained the
|
|
-- SLOWPOKE from 5 to 10 while the TOGEPI sat at 5 behind it, so `minLevel`
|
|
-- never moved and the row died on its futility ceiling at "level 5/20".
|
|
-- Re-picking rotates them, which is what a player switching at the PARTY
|
|
-- menu does anyway.
|
|
--
|
|
-- The original order is snapshotted whole rather than tracked as a single
|
|
-- swap index, because after a few rotations there is no single swap to undo
|
|
-- -- and the rest of the route needs slot 1 to be the fighter again.
|
|
local originalOrder
|
|
if row.lead == false then
|
|
originalOrder = {}
|
|
for i, mon in ipairs(A.party(game)) do originalOrder[i] = mon end
|
|
end
|
|
local function leadWithWeakest()
|
|
if not originalOrder then return end
|
|
local party = A.party(game)
|
|
local weakest
|
|
for i, mon in ipairs(party) do
|
|
if not mon.isEgg and (mon.hp or 0) > 0
|
|
and (not weakest or (mon.level or 0)
|
|
< (party[weakest].level or 0)) then
|
|
weakest = i
|
|
end
|
|
end
|
|
if weakest and weakest > 1 then
|
|
party[1], party[weakest] = party[weakest], party[1]
|
|
bot:say((" grind: leading with %s (level %d) so it earns the levels")
|
|
:format(tostring(party[1].species), party[1].level or 0))
|
|
end
|
|
end
|
|
leadWithWeakest()
|
|
-- Grinding the party minimum means the mon in front cannot win a fight, so
|
|
-- the bot switches the fighter in on turn one and lets the weakling collect
|
|
-- its participation share. See A.openBattleParty.
|
|
bot.switchTrain = (row.lead == false) or nil
|
|
local function finish(ok, why)
|
|
bot.switchTrain = nil
|
|
if originalOrder then
|
|
local party = A.party(game)
|
|
for i, mon in ipairs(originalOrder) do party[i] = mon end
|
|
end
|
|
return ok, why
|
|
end
|
|
-- `lead = true` grinds the front mon only; the default is the party
|
|
-- minimum, which is what the opening Route 29 row wants.
|
|
local levelOf = row.lead and A.leadLevel or A.minLevel
|
|
-- A frame ceiling as well as a pass ceiling. A grind re-travels to its map
|
|
-- after every wipe, and if that journey is the one that loops -- Radio
|
|
-- Tower 2F, where an NPC stands in the only corridor -- the pass counter
|
|
-- never advances and the row eats the entire run's wall clock. Run 14 died
|
|
-- this way at row 208 with the League untouched.
|
|
local grindStart = bot:frames()
|
|
local grindBudget = tonumber(os.getenv("POKEPORT_GOLD_GRIND_BUDGET"))
|
|
or 700000
|
|
-- Futility ceiling, separate from the frame budget.
|
|
--
|
|
-- Keyed on EXPERIENCE, not level, because those are two different
|
|
-- questions. A grind can be earning steadily and still not cross a level
|
|
-- for a long time -- ROUTE_34's wilds against a level-28 QUILAVA are worth
|
|
-- very little each -- and killing that grind loses levels the route is
|
|
-- counting on. What is worth killing is a grind earning *nothing at all*:
|
|
-- run 24's TOGEPI fainted before it could act every single battle, and a
|
|
-- fainted participant is awarded nothing, so "level 5/20" was unreachable
|
|
-- by construction and the 700k frame budget was going to spend forty
|
|
-- minutes proving it.
|
|
--
|
|
-- Summed over the whole party, so it stays true for the party-minimum form
|
|
-- where the mon in front changes from pass to pass.
|
|
local function partyExp()
|
|
local total = 0
|
|
for _, mon in ipairs(A.party(game)) do
|
|
if not mon.isEgg then total = total + (mon.experience or 0) end
|
|
end
|
|
return total
|
|
end
|
|
local stuckExp, stuckSince = partyExp(), 0
|
|
-- Pass ceiling. Raised from 900 after Cluster E: a lead one level short of
|
|
-- target (09.g at 37/38, 07.g similarly) was still earning every pass, so
|
|
-- the futility guard never fired, the frame budget was still hundreds of
|
|
-- thousands away, and the loop fell off the end with no `why` -- which the
|
|
-- runner printed as the opaque "action reported failure". Grass pacing
|
|
-- (below) is what actually makes the levels land; the higher ceiling is
|
|
-- the backstop that still names the cause when they do not.
|
|
local passBudget = tonumber(os.getenv("POKEPORT_GOLD_GRIND_PASSES")) or 2000
|
|
local function returnToGrind(why)
|
|
bot:forgetSurf()
|
|
bot.borders = {}
|
|
if A.busy(game) then bot:clearDialogue() end
|
|
if bot:travelTo(grindMap) then return true end
|
|
-- One retry. The failed trip leaves priced exits and a half-finished
|
|
-- position; standing still clears NPC timing, and a second plan is what
|
|
-- gets the bot out of the Route-36 National-Park-gate loop that killed
|
|
-- every 05.g recovery before the whiteout-spawn fix.
|
|
bot:say((" grind: %s, retrying travel"):format(why))
|
|
bot:wait(30)
|
|
if A.busy(game) then bot:clearDialogue() end
|
|
return bot:travelTo(grindMap)
|
|
end
|
|
for pass = 1, passBudget do
|
|
if levelOf(game) >= target then return finish(true) end
|
|
local exp = partyExp()
|
|
if exp > stuckExp then
|
|
stuckExp, stuckSince = exp, pass
|
|
elseif pass - stuckSince > 120 then
|
|
return finish(false, ("no experience earned in %d passes (level %d/%d)"
|
|
.. " -- nothing here can be beaten"):format(
|
|
pass - stuckSince, levelOf(game), target))
|
|
end
|
|
if bot:frames() - grindStart > grindBudget then
|
|
return finish(false, ("gave up after %d frames (level %d/%d)")
|
|
:format(bot:frames() - grindStart, levelOf(game), target))
|
|
end
|
|
if A.busy(game) then bot:clearDialogue() end
|
|
-- Rotate: the mon that was lowest last pass may not be lowest now.
|
|
leadWithWeakest()
|
|
|
|
-- Heal before the party is dry. A wipe is a free heal on the cart, but
|
|
-- the walk home is what ended 05.g (and used to return to Cherrygrove for
|
|
-- the whole game). Attacking PP is the signal that matters: LEER /
|
|
-- SMOKESCREEN keep total PP looking fine while EMBER is empty, and the
|
|
-- fighter then STRUGGLES itself into a whiteout.
|
|
if A.damagingPpFraction(game) < 0.2 or A.leadHpFraction(game) < 0.25 then
|
|
bot:say((" grind: healing mid-session (hp %.2f attacking-pp %.2f)")
|
|
:format(A.leadHpFraction(game), A.damagingPpFraction(game)))
|
|
if not bot:healUp() then
|
|
return finish(false, "could not heal mid-grind")
|
|
end
|
|
if A.mapId(game) ~= grindMap and not returnToGrind("post-heal") then
|
|
return finish(false, "could not get back to " .. grindMap
|
|
.. " after mid-grind heal")
|
|
end
|
|
end
|
|
|
|
if A.mapId(game) ~= grindMap then
|
|
wipes = wipes + 1
|
|
if wipes > (row.wipeBudget or 4) then
|
|
return finish(false, ("blacked out %d times grinding %s (level %d/%d)")
|
|
:format(wipes, grindMap, levelOf(game), target))
|
|
end
|
|
bot:say((" grind: back to %s after a wipe (%d)"):format(grindMap, wipes))
|
|
if not returnToGrind("wipe recovery") then
|
|
return finish(false, "could not get back to " .. grindMap)
|
|
end
|
|
end
|
|
|
|
local map = A.map(game)
|
|
if not map then
|
|
return finish(false, "no map loaded while grinding " .. grindMap)
|
|
end
|
|
-- Pace encounter tiles, not random corridor. The catch op already learned
|
|
-- this: wandering the whole map mostly walks road, burns frames without
|
|
-- rolls, and steps onto connection edges that the wipe counter then
|
|
-- treats as blackouts.
|
|
--
|
|
-- Cave / dungeon maps skip the grass array entirely
|
|
-- (FieldMoves.canEncounterWildMon): every non-ice walkable tile rolls, so
|
|
-- VICTORY_ROAD reports zero `isEncounterCell` hits and still has a full
|
|
-- wild table. Treat those floors the same way the engine does.
|
|
local walked = false
|
|
local spots = {}
|
|
local env = map.def and map.def.environment
|
|
local caveFloor = (env == "CAVE" or env == "DUNGEON")
|
|
for cy = 0, map.heightCells - 1 do
|
|
for cx = 0, map.widthCells - 1 do
|
|
local hit
|
|
if caveFloor then
|
|
hit = A.walkable(game, map, cx, cy) and not A.isIce(map, cx, cy)
|
|
else
|
|
-- Water is in CheckGrassCollision's array; a grind paces LAND
|
|
-- grass, and aiming at a pond bounces off the shore (or surfs
|
|
-- off the grind entirely once the party can).
|
|
hit = A.isEncounterCell(map, cx, cy)
|
|
and not A.isWater(map, cx, cy)
|
|
end
|
|
if hit then spots[#spots + 1] = { cx, cy } end
|
|
end
|
|
end
|
|
if #spots > 0 then
|
|
for _ = 1, 12 do
|
|
local pick = spots[math.random(1, #spots)]
|
|
if bot:planPath(pick[1], pick[2]) then
|
|
bot:walkTo(pick[1], pick[2], { attempts = 3 })
|
|
walked = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
if not walked then
|
|
for _ = 1, 10 do
|
|
local x = math.random(0, math.max(0, map.widthCells - 1))
|
|
local y = math.random(0, math.max(0, map.heightCells - 1))
|
|
if bot:planPath(x, y) then
|
|
bot:walkTo(x, y, { attempts = 3 })
|
|
break
|
|
end
|
|
end
|
|
end
|
|
if pass % 20 == 0 then
|
|
bot:say((" grind: level %d/%d on %s")
|
|
:format(levelOf(game), target, tostring(A.mapId(game))))
|
|
end
|
|
end
|
|
return finish(false, ("gave up after %d passes (level %d/%d)")
|
|
:format(passBudget, levelOf(game), target))
|
|
end
|
|
|
|
-- Teach an HM move to a party mon that can legally learn it.
|
|
--
|
|
-- Driven through the model rather than the four screens the player would
|
|
-- walk (PACK -> the HM -> the party list -> confirm). That is a deliberate
|
|
-- driver shortcut of the same kind tests/drivers/gold_walk_smoke.lua takes
|
|
-- when it calls world:setMap: the subject under test is the ROUTE, and menu
|
|
-- navigation is neither what a route row means nor what it is checking.
|
|
-- Compatibility is still honoured -- the mon must have the move in its
|
|
-- tmhm list -- so this cannot teach SURF to something that could never
|
|
-- learn it and quietly invalidate the run.
|
|
function ops.teach(row)
|
|
local Mon = require("src.battle.gen2.Mon")
|
|
local move = row.move
|
|
local pokemon = game.data and game.data.pokemon
|
|
for _, mon in ipairs(A.party(game)) do
|
|
for _, known in ipairs(mon.moves or {}) do
|
|
if known.id == move then
|
|
bot:say((" teach: %s already knows %s"):format(
|
|
tostring(mon.species), move))
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
-- Slot 1 LAST.
|
|
--
|
|
-- This used to walk the party in order, so every HM went to whoever was in
|
|
-- front -- which is always the starter, because the bot always leads with
|
|
-- the fighter. By the League the lead's four slots were CUT, STRENGTH and
|
|
-- a level-10 EMBER, and a level-73 Typhlosion was swinging a Normal HM at
|
|
-- Bruno. HMs belong on the mule; the mon that has to win fights keeps its
|
|
-- attacking moves. The lead is still the fallback, because a route that
|
|
-- cannot teach CUT cannot leave Ilex Forest.
|
|
local order = {}
|
|
local party = A.party(game)
|
|
for i = 2, #party do order[#order + 1] = party[i] end
|
|
if party[1] then order[#order + 1] = party[1] end
|
|
|
|
for _, mon in ipairs(order) do
|
|
local def = pokemon and pokemon[mon.species]
|
|
local compatible = false
|
|
for _, id in ipairs((def and def.tmhm) or {}) do
|
|
if id == move then compatible = true break end
|
|
end
|
|
if compatible and not mon.isEgg then
|
|
local ok, reason, entry = Mon.learnMove(mon, move, game.data)
|
|
if ok then
|
|
bot:say((" teach: %s learned %s"):format(tostring(mon.species), move))
|
|
return true
|
|
end
|
|
if reason == "full" and entry then
|
|
-- No forget screen exists in the port yet, so replace the weakest
|
|
-- move rather than stall: a bot that cannot teach CUT cannot leave
|
|
-- Ilex Forest at all.
|
|
--
|
|
-- Never an HM move, though -- the cart flatly refuses to overwrite
|
|
-- one, and the run needs them simultaneously: the mule carries SURF,
|
|
-- WHIRLPOOL and WATERFALL together for Route 27, and this exact
|
|
-- line once paid WHIRLPOOL for WATERFALL and re-walled Tohjo Falls.
|
|
local HM_MOVES = { CUT = true, FLY = true, SURF = true,
|
|
STRENGTH = true, FLASH = true,
|
|
WHIRLPOOL = true, WATERFALL = true }
|
|
local defs = game.data.moves
|
|
local worst, worstPower
|
|
for i, known in ipairs(mon.moves) do
|
|
if not HM_MOVES[known.id] then
|
|
local power = (defs and defs[known.id] and defs[known.id].power) or 0
|
|
if not worst or power < worstPower then
|
|
worst, worstPower = i, power
|
|
end
|
|
end
|
|
end
|
|
if worst then
|
|
bot:say((" teach: %s forgets %s for %s"):format(
|
|
tostring(mon.species), tostring(mon.moves[worst].id), move))
|
|
mon.moves[worst] = entry
|
|
return true
|
|
end
|
|
-- All four slots are HMs: this mon is out of room, try the next.
|
|
bot:say((" teach: %s has no forgettable slot for %s"):format(
|
|
tostring(mon.species), move))
|
|
end
|
|
end
|
|
end
|
|
return nil, ("no party mon can learn %s"):format(tostring(move))
|
|
end
|
|
|
|
-- Use a field move against a cell (CUT a tree, ROCK_SMASH a boulder, SURF
|
|
-- off a beach). Face the target first: World:fieldContext reads the tile the
|
|
-- player is facing, so the facing IS the argument.
|
|
function ops.field(row)
|
|
local move = row.move
|
|
local world = A.world(game)
|
|
if not world then return false end
|
|
-- Bot:stepDir launches SURF on its own whenever a planned step enters
|
|
-- water, so by the time an explicit SURF row runs the player is often
|
|
-- already afloat -- and useFieldMove then refuses with "You're already
|
|
-- SURFING." Already surfing IS this row's postcondition.
|
|
if move == "SURF" and A.surfing(game) then
|
|
bot:say(" field: already surfing")
|
|
return true
|
|
end
|
|
local user = world:partyMoveUser(move)
|
|
if not user then
|
|
return nil, ("no party mon knows %s"):format(tostring(move))
|
|
end
|
|
if not bot:approachAndFace(row.x, row.y) then
|
|
return false, ("could not face (%d,%d)"):format(row.x, row.y)
|
|
end
|
|
local result = world:useFieldMove(move, user)
|
|
if not (result and result.ok) then
|
|
return false, ("%s refused here (%s)"):format(move,
|
|
tostring(result and result.text))
|
|
end
|
|
-- The move runs as a queued script on the next frame the world owns.
|
|
bot:wait(8)
|
|
bot:clearDialogue({ "yes" }, 3000)
|
|
return true
|
|
end
|
|
|
|
-- Buy from a Mart.
|
|
--
|
|
-- Driven through the save rather than the four screens a player walks, the
|
|
-- same shortcut ops.teach and A.throwBall take, and honest for the same
|
|
-- reason: the money is checked and spent at the item's own price, so this can
|
|
-- only buy what the player could have bought standing at the counter.
|
|
--
|
|
-- It exists because of one hard dependency. The Cyndaquil line cannot learn
|
|
-- SURF, so the route catches a SLOWPOKE for it -- and Elm's aide hands over
|
|
-- exactly five POKE BALLs in the whole game to that point. Five throws at a
|
|
-- wild SLOWPOKE is a coin flip, and losing it costs SURF, which costs
|
|
-- Cianwood, the Storm Badge and every section after.
|
|
function ops.buy(row)
|
|
local save = game.save
|
|
local defs = game.data and game.data.items
|
|
local id = row.item
|
|
local want = row.count or 1
|
|
if not (save and defs and defs[id]) then
|
|
return nil, "unknown item " .. tostring(id)
|
|
end
|
|
local price = defs[id].price or 0
|
|
save.inventory = save.inventory or {}
|
|
local bought = 0
|
|
for _ = 1, want do
|
|
if (save.player.money or 0) < price then break end
|
|
save.player.money = save.player.money - price
|
|
save.inventory[id] = (save.inventory[id] or 0) + 1
|
|
bought = bought + 1
|
|
end
|
|
bot:say((" buy: %d x %s for %d, %d left")
|
|
:format(bought, id, bought * price, save.player.money or 0))
|
|
return bought > 0
|
|
end
|
|
|
|
-- Shove a STRENGTH boulder.
|
|
--
|
|
-- Two separate things, and conflating them is why the route carried this as
|
|
-- a `manual` row. STRENGTH must first be turned ON for this map load
|
|
-- (World:runStrength sets strengthActive, and ResetBikeFlags clears it on
|
|
-- every map change), which is a field move used against the boulder. Only
|
|
-- then does walking INTO the boulder push it, one cell per step, the way
|
|
-- MovementFunction_Strength does on the cart.
|
|
--
|
|
-- `count` is how many cells to shove it; `dir` the direction to shove. The
|
|
-- bot stands on the far side and walks forward, so the cell it must reach is
|
|
-- one step BEHIND the boulder.
|
|
function ops.push(row)
|
|
local world = A.world(game)
|
|
if not world then return false end
|
|
local move = "STRENGTH"
|
|
local user = world:partyMoveUser(move)
|
|
if not user then return nil, "no party mon knows STRENGTH" end
|
|
|
|
local d = ({ up = { 0, -1 }, down = { 0, 1 },
|
|
left = { -1, 0 }, right = { 1, 0 } })[bot.button(row.dir)]
|
|
if not d then return false, "unknown push direction " .. tostring(row.dir) end
|
|
-- Stand on the opposite side, facing the boulder.
|
|
local standX, standY = row.x - d[1], row.y - d[2]
|
|
if not bot:walkTo(standX, standY) then
|
|
return false, ("could not stand at (%d,%d) to push"):format(standX, standY)
|
|
end
|
|
bot:face(bot.button(row.dir))
|
|
|
|
if not world.strengthActive then
|
|
local result = world:useFieldMove(move, user)
|
|
if not (result and result.ok) then
|
|
-- "BOULDERS_MOVE" means it was already on, which is a success here.
|
|
if not (world.strengthActive) then
|
|
return false, ("STRENGTH refused (%s)")
|
|
:format(tostring(result and result.text))
|
|
end
|
|
end
|
|
bot:wait(8)
|
|
bot:clearDialogue({ "yes" }, 4000)
|
|
end
|
|
|
|
bot:say((" push: strength %s, boulder at (%d,%d), pushing %s from (%d,%d)")
|
|
:format(world.strengthActive and "on" or "OFF", row.x, row.y,
|
|
tostring(row.dir), standX, standY))
|
|
|
|
-- Walking into the boulder is the push, and the player does NOT move: the
|
|
-- cart treats a strength boulder like a solid NPC (.CheckNPC's "2"), so the
|
|
-- rock slides and you stay where you were. Two consequences the first
|
|
-- version got wrong:
|
|
--
|
|
-- * Bot:stepDir reports "did not move" for a SUCCESSFUL push, and notes a
|
|
-- wall at the boulder's old cell for its trouble.
|
|
-- * The next push has to wait for the rock to finish sliding --
|
|
-- World:tryPushBoulder refuses while `npc.moving` -- so back-to-back
|
|
-- presses do nothing at all. That was the whole failure: the log said
|
|
-- "strength on, boulder ahead" and the lane never opened.
|
|
for i = 1, (row.count or 1) do
|
|
bot:face(bot.button(row.dir))
|
|
local bx, by = A.pos(game)
|
|
bot:stepDir(bot.button(row.dir))
|
|
-- Let the boulder land before asking again.
|
|
for _ = 1, 60 do
|
|
local npc = A.npcAt(game, bx + d[1], by + d[2])
|
|
if not (npc and npc.moving) then break end
|
|
bot:wait(1)
|
|
end
|
|
bot:wait(4)
|
|
local ax, ay = A.pos(game)
|
|
local blocked = A.npcAt(game, ax + d[1], ay + d[2]) ~= nil
|
|
bot:say((" push %d: player (%d,%d)->(%d,%d), boulder cell %s")
|
|
:format(i, bx, by, ax, ay, blocked and "still filled" or "clear"))
|
|
-- A push that worked left a wall memory on a cell that is now free.
|
|
bot:clearWall(A.mapId(game), bx + d[1], by + d[2])
|
|
if A.busy(game) then bot:clearDialogue(nil, 2000) end
|
|
bot:progress()
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- Hold a fixed sequence of directions.
|
|
--
|
|
-- The one place objectives cannot be expressed as "be at this cell": an ICE
|
|
-- floor, where a press slides until something stops you, so the reachable
|
|
-- set is a function of the whole route rather than of adjacency and the
|
|
-- bot's tile-by-tile planner cannot describe it at all. The asm-walk writes
|
|
-- the Mahogany and Blackthorn gym puzzles as exactly this -- a list of
|
|
-- directions -- so the route carries the list.
|
|
function ops.press(row)
|
|
for _, raw in ipairs(row.dirs or {}) do
|
|
local dir = bot.button(raw)
|
|
if not dir then return false, "unknown direction " .. tostring(raw) end
|
|
A.hold(game, dir)
|
|
bot:wait(6)
|
|
A.releaseDirs(game)
|
|
-- Let the slide finish: an ice tile keeps the player moving for several
|
|
-- cells after the press ends.
|
|
for _ = 1, 120 do
|
|
if not A.moving(game) then break end
|
|
bot:wait(1)
|
|
end
|
|
bot:wait(2)
|
|
if A.busy(game) then bot:clearDialogue(row.answers, 4000) end
|
|
bot:progress()
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- Catch one of `species` on this map.
|
|
--
|
|
-- Originally just the SLOWPOKE for SURF (Cyndaquil cannot learn it). The
|
|
-- route now also catches a POLIWAG for WHIRLPOOL/WATERFALL -- SLOWPOKE's
|
|
-- tmhm list has SURF and STRENGTH but neither of those two -- and a bird
|
|
-- for FLY. `row.water` restricts the hunt to COLL_WATER cells so a map
|
|
-- that also has grass does not burn the ball budget on land encounters.
|
|
function ops.catch(row)
|
|
local wanted = {}
|
|
for _, id in ipairs(row.species or {}) do wanted[id] = true end
|
|
|
|
local function have()
|
|
for _, mon in ipairs(A.party(game)) do
|
|
if wanted[mon.species] then return mon end
|
|
end
|
|
return nil
|
|
end
|
|
if have() then
|
|
bot:say(" catch: already have one")
|
|
return true
|
|
end
|
|
if A.partySize(game) >= 6 then return nil, "party is full" end
|
|
|
|
local ball = row.ball or "POKE_BALL"
|
|
-- A badge / teach may have just landed (FOGBADGE is the row above 07.30);
|
|
-- drop the canSurf cache before asking, so a stale "no" from before Morty
|
|
-- does not refuse a water hunt that is now legal.
|
|
bot:forgetSurf()
|
|
-- Water hunts need SURF + FOGBADGE before a water cell is even pathable.
|
|
if row.water and not bot:canSurf() then
|
|
return nil, "water catch needs SURF and FOGBADGE"
|
|
end
|
|
bot.catchWanted, bot.catchBall = wanted, ball
|
|
local ok, err = pcall(function()
|
|
for pass = 1, 300 do
|
|
if have() then return end
|
|
if not A.hasItem(game, ball) then
|
|
error({ botStall = true, why = "out of " .. ball }, 0)
|
|
end
|
|
if A.busy(game) then
|
|
-- Answer NO to the nickname prompt a catch ends on: the naming
|
|
-- keyboard has no cancel, so opening it strands the run.
|
|
bot:clearDialogue({ "no", "no" }, 2000)
|
|
end
|
|
if A.mapId(game) ~= row.map then
|
|
if not bot:travelTo(row.map) then return end
|
|
end
|
|
local map = A.map(game)
|
|
if not map then return end
|
|
-- Wander to a cell we can actually reach, and re-roll rather than
|
|
-- aiming at one behind a wall: an unreachable target makes walkTo bail
|
|
-- immediately, so the bot stands still and never rolls an encounter.
|
|
-- Aim at cells that can actually roll an encounter. Collected once
|
|
-- per pass and shuffled by index, so the hunt paces the grass instead
|
|
-- of the corridor between it.
|
|
local spots = {}
|
|
for cy = 0, map.heightCells - 1 do
|
|
for cx = 0, map.widthCells - 1 do
|
|
-- COLL_WATER is in CheckGrassCollision's array, so a land hunt's
|
|
-- filter has to EXCLUDE water, not merely not-require it: the
|
|
-- Slowpoke Well's pond qualified as an "encounter cell", the
|
|
-- shuffle aimed at it, and a party with no SURF bounced off the
|
|
-- shore twelve times until the repeat guard killed the row.
|
|
local water = A.isWater(map, cx, cy)
|
|
if A.isEncounterCell(map, cx, cy)
|
|
and ((row.water and water) or (not row.water and not water)) then
|
|
spots[#spots + 1] = { cx, cy }
|
|
end
|
|
end
|
|
end
|
|
if #spots == 0 then
|
|
if row.water then
|
|
error({ botStall = true,
|
|
why = "no reachable water encounter cells on " .. row.map }, 0)
|
|
end
|
|
for _ = 1, 10 do
|
|
local x = math.random(0, map.widthCells - 1)
|
|
local y = math.random(0, map.heightCells - 1)
|
|
if bot:planPath(x, y) then bot:walkTo(x, y, { attempts = 3 }) break end
|
|
end
|
|
else
|
|
for _ = 1, 8 do
|
|
local pick = spots[math.random(1, #spots)]
|
|
if bot:planPath(pick[1], pick[2]) then
|
|
bot:walkTo(pick[1], pick[2], { attempts = 3 })
|
|
break
|
|
end
|
|
end
|
|
end
|
|
if pass % 20 == 0 then
|
|
local left = (game.save and game.save.inventory
|
|
and game.save.inventory[ball]) or 0
|
|
bot:say((" catch: still hunting (%d %s left, party %d)")
|
|
:format(left, ball, A.partySize(game)))
|
|
end
|
|
end
|
|
end)
|
|
bot.catchWanted, bot.catchBall = nil, nil
|
|
if A.busy(game) then bot:clearDialogue({ "no", "no" }, 2000) end
|
|
|
|
local mon = have()
|
|
if mon then
|
|
bot:say((" catch: got a %s"):format(tostring(mon.species)))
|
|
return true
|
|
end
|
|
if not ok and type(err) == "table" and err.why then return nil, err.why end
|
|
return false, "did not catch " .. table.concat(row.species or {}, "/")
|
|
end
|
|
|
|
-- Walk about on this map until something happens.
|
|
--
|
|
-- Some beats are not reached by going anywhere: they are delivered. Elm's
|
|
-- SPECIALCALL_ASSISTANT is the one the route cannot do without -- Falkner's
|
|
-- badge script arms it, the call only lands while the player is OUTSIDE and
|
|
-- moving, and the call is what puts his aide in the Violet Pokecenter with
|
|
-- the Togepi Egg. `settle` cannot do this: there is nothing on screen to
|
|
-- clear, the trigger is the walking itself.
|
|
function ops.wander(row)
|
|
-- `expectClear` is the mirror of `expect`: an object_event's event flag
|
|
-- HIDES the object when it is SET, so "the aide has appeared" is a flag
|
|
-- going to false. Only this op needs it, so it is not part of the generic
|
|
-- postcondition machinery.
|
|
local function arrived()
|
|
if row.expectClear then return flagSet(row.expectClear) == false end
|
|
if row.expect then return satisfied(row) end
|
|
return false
|
|
end
|
|
local target = row.expect or row.expectClear
|
|
for pass = 1, (row.budget or 40) do
|
|
if target and arrived() then return true end
|
|
if A.busy(game) then bot:clearDialogue(row.answers, 4000) end
|
|
if A.mapId(game) ~= row.map then
|
|
if not bot:travelTo(row.map) then return false, "left " .. row.map end
|
|
end
|
|
local map = A.map(game)
|
|
if not map then return false end
|
|
local x = math.random(0, math.max(0, map.widthCells - 1))
|
|
local y = math.random(0, math.max(0, map.heightCells - 1))
|
|
if bot:planPath(x, y) then bot:walkTo(x, y, { attempts = 3 }) end
|
|
end
|
|
if A.busy(game) then bot:clearDialogue(row.answers, 4000) end
|
|
return target == nil or arrived()
|
|
end
|
|
|
|
function ops.check(row)
|
|
return satisfied(row)
|
|
end
|
|
|
|
function ops.manual(row)
|
|
return nil, row.why
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- the run
|
|
-- ---------------------------------------------------------------------
|
|
|
|
local from = os.getenv("POKEPORT_GOLD_FROM")
|
|
local until_ = os.getenv("POKEPORT_GOLD_UNTIL")
|
|
-- Checkpointing. POKEPORT_GOLD_CKPT=1 writes a save at the first row of
|
|
-- every section; POKEPORT_GOLD_RESUME=NN loads section NN's checkpoint and
|
|
-- starts the route there. Unlike POKEPORT_GOLD_FROM (which skips ROWS and
|
|
-- leaves the player in the bedroom), this restores the game state, so a
|
|
-- late-section bug is a 30-second test instead of a 10-minute replay.
|
|
local checkpointing = os.getenv("POKEPORT_GOLD_CKPT") == "1"
|
|
local resume = os.getenv("POKEPORT_GOLD_RESUME")
|
|
|
|
local function sectionOf(id)
|
|
return tostring(id):match("^(%d+)%.") or tostring(id)
|
|
end
|
|
|
|
-- Wait for the world before touching anything: Game2 builds it during
|
|
-- load, and a driver that starts pressing buttons first is pressing them
|
|
-- at a nil map.
|
|
for _ = 1, 3000 do
|
|
if A.ready(game) then break end
|
|
bot:wait(1)
|
|
end
|
|
if not A.ready(game) then
|
|
print("[gold] the world never came up; is POKEPORT_GAME=gold set?")
|
|
return
|
|
end
|
|
|
|
if resume then
|
|
local ok, err = A.loadCheckpoint(game, resume)
|
|
if not ok then
|
|
print(("[gold] cannot resume from section %s: %s")
|
|
:format(resume, tostring(err)))
|
|
return
|
|
end
|
|
-- continueGame rebuilds the world from scratch, so wait for it the same
|
|
-- way the cold boot above does.
|
|
for _ = 1, 3000 do
|
|
if A.ready(game) then break end
|
|
bot:wait(1)
|
|
end
|
|
if not A.ready(game) then
|
|
print("[gold] resumed save never brought the world up")
|
|
return
|
|
end
|
|
bot:say(("resumed section %s on %s at %d,%d")
|
|
:format(resume, tostring(A.mapId(game)), select(1, A.pos(game)),
|
|
select(2, A.pos(game))))
|
|
end
|
|
|
|
bot:say(("start on %s at %d,%d")
|
|
:format(tostring(A.mapId(game)), select(1, A.pos(game)),
|
|
select(2, A.pos(game))))
|
|
|
|
local started = from == nil and resume == nil
|
|
local completed, failed, skipped = {}, {}, {}
|
|
local aborted = nil
|
|
local lastSection = nil
|
|
|
|
-- Row id -> position, so a failed row can send the runner BACKWARDS.
|
|
--
|
|
-- `retryFrom` is how the route loops a fight it cannot yet win. Losing in
|
|
-- Gen 2 costs money and nothing else: every point of experience earned on
|
|
-- the way to the wipe is kept. So a party that loses to Bruno still banks
|
|
-- the levels it took off Hitmontop, and the next attempt starts stronger
|
|
-- than the last one. Repeat that and the fight is eventually winnable
|
|
-- without any route change at all -- which is the only grind available at
|
|
-- this point, since Johto's wilds cap around level 30 and the Elite Four's
|
|
-- own mons are in the forties.
|
|
--
|
|
-- The rewind target is a row, not a map, because the whole gauntlet has to
|
|
-- restart: the Indigo Plateau Pokecenter's MAPCALLBACK_NEWMAP clears every
|
|
-- EVENT_BEAT_ELITE_4_*, so after a wipe Will is standing there again.
|
|
local indexOf = {}
|
|
for i, r in ipairs(ROUTE) do indexOf[r.id] = i end
|
|
local attempts = {}
|
|
-- Frame stamp of when each row's current lap began, so a lap that costs
|
|
-- nothing can be told from one that actually ran.
|
|
local lapStartedAt = {}
|
|
local retryCap = tonumber(os.getenv("POKEPORT_GOLD_RETRY")) or 12
|
|
|
|
local function clearFailure(id)
|
|
for i = #failed, 1, -1 do
|
|
if failed[i].id == id then table.remove(failed, i) end
|
|
end
|
|
end
|
|
|
|
local index = 0
|
|
while index < #ROUTE do
|
|
index = index + 1
|
|
local row = ROUTE[index]
|
|
if not started and row.id == from then started = true end
|
|
if not started and resume and sectionOf(row.id) == resume then
|
|
started = true
|
|
end
|
|
if started then
|
|
-- One checkpoint per section, taken on the way IN so it captures the
|
|
-- state the section starts from rather than the state it left behind.
|
|
if checkpointing and sectionOf(row.id) ~= lastSection then
|
|
lastSection = sectionOf(row.id)
|
|
local ok, err = A.writeCheckpoint(game, lastSection)
|
|
bot:say(("checkpoint %s: %s")
|
|
:format(lastSection, ok and "written" or ("FAILED " .. tostring(err))))
|
|
end
|
|
bot:progress()
|
|
-- Teaching SURF, earning FOGBADGE or losing the surfer all change which
|
|
-- cells are passable and which borders can be stood on, and both answers
|
|
-- are cached for speed. A route row is the natural place to forget them:
|
|
-- it is the only point where the party or the badges can have changed.
|
|
bot:forgetSurf()
|
|
bot.borders = {}
|
|
-- Heal between rows when the party is in no state to fight.
|
|
--
|
|
-- Bot:maybeHeal has existed since the first version and was never called,
|
|
-- which did not matter while a lost battle still ran the winner's script
|
|
-- -- the bot fainted its way through Whitney and four Elite Four rooms and
|
|
-- the run looked fine. With that fixed (tests/gen2_battle_loss_test.lua)
|
|
-- a wipe costs the whole objective, so arriving healthy is the difference
|
|
-- between earning a badge and re-walking half of Johto.
|
|
--
|
|
-- Never inside the League: its rooms seal behind you, and the Indigo
|
|
-- Plateau Pokecenter's MAPCALLBACK_NEWMAP clears every
|
|
-- EVENT_BEAT_ELITE_4_*, so a heal after Will restarts the gauntlet.
|
|
--
|
|
-- `noHeal` is for puzzles that a MAPCALLBACK_NEWMAP undoes. The
|
|
-- Goldenrod underground switch byte resets on every entry to the
|
|
-- underground / warehouse; a heal between Switch3 and Switch2 left the
|
|
-- room, zeroed the byte, and the warehouse door never opened.
|
|
if not row.noHeal and not tostring(row.id):match("^18%.") then
|
|
-- Guarded like tryReach below: healUp travels, travel can stall, and
|
|
-- a stall raised out here escaped the runner and killed a whole run
|
|
-- as `driver error: table: 0x...` with neither a row nor a reason
|
|
-- attached (the Radio Tower 2F corridor wedge). A failed
|
|
-- between-rows heal is not fatal; the next row plans fresh.
|
|
local healed, err = pcall(bot.maybeHeal, bot)
|
|
if not healed then
|
|
if type(err) == "table" and err.botStall then
|
|
bot:say((" heal stalled: %s"):format(tostring(err.why)))
|
|
bot:progress()
|
|
else
|
|
error(err, 0)
|
|
end
|
|
end
|
|
end
|
|
bot:say(("== [%d/%d] %s %s %s")
|
|
:format(index, #ROUTE, row.id, row.op, tostring(row.map)))
|
|
|
|
-- Already done? A resumed run, or a script that set the flag as a side
|
|
-- effect of an earlier row, should not redo the work.
|
|
if row.expect and satisfied(row) then
|
|
bot:say((" already satisfied (%s)"):format(row.expect))
|
|
completed[row.id] = true
|
|
clearFailure(row.id)
|
|
else
|
|
-- Be on the right map first. Every op except `travel` itself assumes
|
|
-- it; this is the single line that turns the asm-walk's per-map
|
|
-- checklist into something that does not need hop-by-hop directions.
|
|
-- Guarded, for the same reason the op below is.
|
|
--
|
|
-- Bot:reachMap raises a `{ botStall = true }` table when it gives up,
|
|
-- and these two calls sit OUTSIDE the pcall that catches it -- so a
|
|
-- stall while travelling to a row's map escaped the runner entirely and
|
|
-- killed the whole run with `driver error: table: 0x...`, which names
|
|
-- neither the row nor the reason. Run 26 died that way at row 196 after
|
|
-- eleven tries to cross MAHOGANY_TOWN's east edge, ~450k frames in,
|
|
-- taking sections 07-12 with it. A stall here means exactly what a
|
|
-- failed travel means: this row could not reach its map.
|
|
local function tryReach(map)
|
|
local ok, res = pcall(bot.reachMap, bot, map, row.region)
|
|
if ok then return res end
|
|
if type(res) == "table" and res.botStall then
|
|
bot:say((" travel to %s stalled: %s"):format(
|
|
map, tostring(res.why)))
|
|
return false
|
|
end
|
|
error(res, 0)
|
|
end
|
|
|
|
local onMap = true
|
|
if row.op ~= "travel" and A.mapId(game) ~= row.map then
|
|
onMap = tryReach(row.map)
|
|
if not onMap then
|
|
-- One retry. travelTo leaves behind a trip's worth of exit bans and
|
|
-- a half-finished position, and a second attempt plans fresh from
|
|
-- wherever it stopped -- which is what gets the bot back INTO
|
|
-- Goldenrod Gym after Whitney's script pushes it out, so the badge
|
|
-- talk that follows the fight can actually happen.
|
|
bot:say((" travel to %s failed, retrying once"):format(row.map))
|
|
if A.busy(game) then bot:clearDialogue() end
|
|
onMap = tryReach(row.map)
|
|
end
|
|
end
|
|
|
|
local ok, why
|
|
if not onMap then
|
|
ok, why = false, "could not reach " .. tostring(row.map)
|
|
else
|
|
local run = ops[row.op]
|
|
if not run then
|
|
ok, why = nil, "unknown op " .. tostring(row.op)
|
|
else
|
|
local guarded, res, err = pcall(run, row)
|
|
if not guarded then
|
|
-- A stall inside an op is information about THAT op, not the end
|
|
-- of the run: re-raise anything else.
|
|
if type(res) == "table" and res.botStall then
|
|
ok, why = false, "stalled: " .. tostring(res.why)
|
|
-- ...unless it is fatal. A battle the engine cannot finish
|
|
-- leaves the battle on the stack, so every later row would
|
|
-- re-enter it and stall the same way, filling the report with
|
|
-- 80 copies of one problem. Stop and say what happened.
|
|
if res.fatal then
|
|
bot:say("ABORTING RUN: " .. tostring(res.why))
|
|
failed[#failed + 1] = { id = row.id, why = res.why,
|
|
map = tostring(A.mapId(game)) }
|
|
aborted = res.why
|
|
break
|
|
end
|
|
else
|
|
error(res, 0)
|
|
end
|
|
else
|
|
ok, why = res, err
|
|
end
|
|
end
|
|
end
|
|
|
|
-- `expect` is the ORACLE; the op's own opinion is advisory.
|
|
--
|
|
-- This used to read `ok and satisfied(row)`, which short-circuits: an
|
|
-- op that returned false meant the flag was never even read, and the
|
|
-- failure was then reported with the default string "postcondition
|
|
-- <flag> not set" -- wording that asserts a check which did not happen.
|
|
--
|
|
-- Row 18.18 is the case that matters. Beating Lance opens the door and
|
|
-- the bot walks straight through it, so the `walk` op ends with "left
|
|
-- LANCES_ROOM for HALL_OF_FAME" and reports failure -- after winning.
|
|
-- Run 20 beat the Champion ten times ("CHAMPION LANCE was defeated!",
|
|
-- the prize money, the door) and threw all ten away, then rewound out
|
|
-- of the Hall of Fame to refight the gauntlet.
|
|
--
|
|
-- If a row named a flag and that flag is set, the row is done, whatever
|
|
-- the op made of its own walk.
|
|
local passed
|
|
if row.expect then passed = satisfied(row) else passed = ok end
|
|
if ok == nil then
|
|
bot:say((" SKIP %s: %s"):format(row.id, tostring(why)))
|
|
skipped[#skipped + 1] = { id = row.id, why = why }
|
|
elseif passed then
|
|
bot:say(" ok")
|
|
completed[row.id] = true
|
|
clearFailure(row.id)
|
|
else
|
|
local reason = why
|
|
or (row.expect and ("postcondition " .. row.expect .. " not set"))
|
|
or "action reported failure"
|
|
local limit = row.retryLimit or retryCap
|
|
if row.optional then
|
|
bot:say((" optional miss %s: %s"):format(row.id, reason))
|
|
skipped[#skipped + 1] = { id = row.id, why = reason, soft = true }
|
|
elseif row.retryFrom and indexOf[row.retryFrom]
|
|
and (attempts[row.id] or 0) < limit
|
|
and bot:frames() > (lapStartedAt[row.id] or -1) then
|
|
-- The guard on frames: a lap that took ZERO frames did not retry
|
|
-- anything, it spun. Cianwood is the case -- a boulder puzzle left
|
|
-- in a sealed state answers "approach: nowhere to stand"
|
|
-- immediately, so run 25 burned all four of Chuck's laps inside a
|
|
-- single frame without the player ever moving. If a whole lap
|
|
-- costs nothing, the state that caused the failure has not changed
|
|
-- and never will; stop and report instead of spending the budget.
|
|
-- Not a failure yet: a lap. Rewind and run the stretch again with
|
|
-- whatever levels this attempt earned before it died.
|
|
attempts[row.id] = (attempts[row.id] or 0) + 1
|
|
lapStartedAt[row.id] = bot:frames()
|
|
bot:say((" RETRY %s (lap %d/%d): %s -- rewinding to %s")
|
|
:format(row.id, attempts[row.id], limit, reason, row.retryFrom))
|
|
if A.busy(game) then bot:clearDialogue() end
|
|
-- Bank the lap.
|
|
--
|
|
-- The whole point of a retry lap is that the levels it earned are
|
|
-- kept, and a wall-clock timeout that threw them away would undo
|
|
-- exactly the thing the loop exists to accumulate: the Elite Four
|
|
-- laps take the lead up ~4 levels each, so a run killed at lap
|
|
-- eight restarts eleven levels weaker than it died. A wipe leaves
|
|
-- the player healed and stood in a Pokecenter, which is the
|
|
-- cleanest state in the loop to snapshot.
|
|
local section = sectionOf(row.id)
|
|
local wrote, err = A.writeCheckpoint(game, section)
|
|
bot:say((" lap checkpoint %s: %s"):format(
|
|
section, wrote and "written" or ("skipped " .. tostring(err))))
|
|
index = indexOf[row.retryFrom] - 1
|
|
else
|
|
bot:say((" FAIL %s: %s"):format(row.id, reason))
|
|
failed[#failed + 1] = { id = row.id, why = reason,
|
|
map = tostring(A.mapId(game)) }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if aborted then break end
|
|
if until_ and row.id == until_ then break end
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- report
|
|
-- ---------------------------------------------------------------------
|
|
print("")
|
|
print("================ gold bot summary ================")
|
|
-- Counted over the SET of ids, not the number of passes: a retry lap runs
|
|
-- Will's room again and that is one row completed, not two.
|
|
local done = 0
|
|
for _, r in ipairs(ROUTE) do
|
|
if completed[r.id] then done = done + 1 end
|
|
end
|
|
print(("route rows completed : %d/%d"):format(done, #ROUTE))
|
|
print(("badges : %d"):format(A.badges(game)))
|
|
print(("final map : %s"):format(tostring(A.mapId(game))))
|
|
print(("party : %d mon, lowest level %d")
|
|
:format(A.partySize(game), A.minLevel(game)))
|
|
print(("frames : %d"):format(bot:frames()))
|
|
print(("teleport shortcuts : %d"):format(bot.teleports or 0))
|
|
if #skipped > 0 then
|
|
print(("\nnot implemented / optional misses (%d):"):format(#skipped))
|
|
for _, s in ipairs(skipped) do
|
|
print((" %-8s %s"):format(s.id, tostring(s.why)))
|
|
end
|
|
end
|
|
if #failed > 0 then
|
|
print(("\nFAILED (%d) -- each names an asm-walk checklist row:"):format(#failed))
|
|
for _, f in ipairs(failed) do
|
|
print((" %-8s on %-24s %s"):format(f.id, f.map, tostring(f.why)))
|
|
end
|
|
end
|
|
if aborted then
|
|
print(("\nRUN ABORTED: %s"):format(tostring(aborted)))
|
|
end
|
|
-- The result line names the furthest thing actually PROVEN, in flags.
|
|
--
|
|
-- It used to report the Mineral Badge either way, which was the milestone
|
|
-- when the bot could not get past Olivine and was long stale by the time the
|
|
-- Champion fell -- run 22 beat Lance and still printed "reached the Mineral
|
|
-- Badge". Deliberately NOT keyed on `final map`: the Hall of Fame is
|
|
-- reachable by the harness teleport, and EVENT_BEAT_ELITE_FOUR can be left
|
|
-- set in a resumed checkpoint, so neither is evidence. Lance's own flag is.
|
|
local champion = A.event(game, "EVENT_BEAT_CHAMPION_LANCE")
|
|
local badges = A.badges(game)
|
|
print("")
|
|
if champion then
|
|
print("RESULT: CHAMPION LANCE DEFEATED -- the game is beaten")
|
|
if (bot.teleports or 0) > 0 then
|
|
print((" (%d teleport shortcut(s) were used somewhere in this run;"
|
|
.. " check they were not on the way to the League)")
|
|
:format(bot.teleports))
|
|
end
|
|
elseif badges >= 8 then
|
|
print("RESULT: all eight badges, Champion not beaten")
|
|
else
|
|
print(("RESULT: %d/8 badges, Champion not beaten"):format(badges))
|
|
end
|
|
print("==================================================")
|
|
end
|