ok the auto driver works but needs work

This commit is contained in:
bryanthaboi
2026-07-21 13:19:53 -04:00
parent 9468ffccdf
commit 60b3cebdce
6 changed files with 419 additions and 36 deletions
+8 -1
View File
@@ -12,8 +12,15 @@ function FixedStep:init(callback)
self.callback = callback self.callback = callback
end end
-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15
-- steps), which silently throttled the high fast-forward levels: at 100X
-- a 60fps frame wants ~100 steps. Game:update raises this to fit the
-- current speed target; a stall still cannot snowball past one frame's
-- intended budget.
FixedStep.maxAccum = MAX_ACCUM
function FixedStep:update(dt) function FixedStep:update(dt)
self.accum = math.min(self.accum + dt, MAX_ACCUM) self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM)
while self.accum >= self.STEP do while self.accum >= self.STEP do
self.accum = self.accum - self.STEP self.accum = self.accum - self.STEP
self.callback(self.STEP) self.callback(self.STEP)
+5 -1
View File
@@ -170,7 +170,11 @@ function Game:update(dt)
-- deferred A and edge pulses land in Input's press queue for this step. -- deferred A and edge pulses land in Input's press queue for this step.
TouchInput:update(dt) TouchInput:update(dt)
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua). -- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
FixedStep:update(dt * self:logicSpeed()) -- Give the accumulator room for one full frame at the current speed,
-- or the anti-spiral clamp quietly caps every level above ~15X.
local speed = self:logicSpeed()
FixedStep.maxAccum = math.max(0.25, speed * FixedStep.STEP * 1.5)
FixedStep:update(dt * speed)
-- Audio runs off real time at a fixed 60Hz regardless of game speed or -- Audio runs off real time at a fixed 60Hz regardless of game speed or
-- display refresh, so fades and chip synthesis keep their intended tempo -- display refresh, so fades and chip synthesis keep their intended tempo
-- whether we are at 1X, 10X, or running with vsync disabled. -- whether we are at 1X, 10X, or running with vsync disabled.
+1 -1
View File
@@ -18,7 +18,7 @@ local GameSpeed = {}
-- attempt is long enough that the iteration loop, not the engine, is the -- attempt is long enough that the iteration loop, not the engine, is the
-- bottleneck. Vsync caps how much a real frame can do, so past 10X the -- bottleneck. Vsync caps how much a real frame can do, so past 10X the
-- multiplier is increasingly a ceiling rather than a rate. -- multiplier is increasingly a ceiling rather than a rate.
GameSpeed.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75 } GameSpeed.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75, 100,500 }
GameSpeed.DEFAULT = 1 GameSpeed.DEFAULT = 1
function GameSpeed.levelLabel(v) function GameSpeed.levelLabel(v)
+40
View File
@@ -29,6 +29,46 @@ local function hashSet(list, into)
return into return into
end end
-- Passability of a cell of an UNLOADED map def -- the connected neighbor
-- during an edge crossing. pokered's collision check reads the neighbor
-- strip's tile bytes, so a step off the map edge onto a solid tile of
-- the connected map bumps exactly like an in-map wall; the port needs
-- the same read without building the whole Map. Same math as cellTile
-- on the raw blocks, honoring the surf rule (water/shore passable only
-- while surfing, same fallbacks as Map.new).
function Map.defPassable(def, tilesetDef, cx, cy, surfing)
if not (def and tilesetDef and tilesetDef.blocks and tilesetDef.walkable) then
return true -- no data to judge with: keep the old permissive behavior
end
local tx, ty = cx * 2, cy * 2 + 1
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
local id
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
id = def.borderBlock
else
id = def.blocks[by * def.width + bx + 1]
end
local block = tilesetDef.blocks[(id or 0) + 1]
if not block then return false end
local tile = block[(ty % 4) * 4 + (tx % 4) + 1]
for _, t in ipairs(tilesetDef.walkable) do
if t == tile then return true end
end
if surfing then
for _, t in ipairs(tilesetDef.waterTiles or WATER_TILES) do
if t == tile then return true end
end
local shore = tilesetDef.shoreTiles
if shore == nil and not NO_SHORE_TILESETS[def.tileset] then
shore = SHORE_TILES
end
for _, t in ipairs(shore or {}) do
if t == tile then return true end
end
end
return false
end
function Map.new(def, tilesetDef) function Map.new(def, tilesetDef)
local self = setmetatable({}, Map) local self = setmetatable({}, Map)
self.def = def self.def = def
+12 -3
View File
@@ -883,8 +883,7 @@ function OverworldState:checkEdgeExit(dir)
local conn = self.map:connection(COMPASS[dir]) local conn = self.map:connection(COMPASS[dir])
if conn then if conn then
self:crossConnection(dir, conn) return self:crossConnection(dir, conn)
return true
end end
return false return false
end end
@@ -898,7 +897,7 @@ function OverworldState:crossConnection(dir, conn)
local dest = Game.data.maps[conn.map] local dest = Game.data.maps[conn.map]
if not dest then if not dest then
Logger.warn("connection to unknown map %s", tostring(conn.map)) Logger.warn("connection to unknown map %s", tostring(conn.map))
return return false
end end
local p = self.player local p = self.player
local x, y = p.cellX, p.cellY local x, y = p.cellX, p.cellY
@@ -914,6 +913,15 @@ function OverworldState:crossConnection(dir, conn)
end end
x = math.max(0, math.min(destW - 1, x)) x = math.max(0, math.min(destW - 1, x))
y = math.max(0, math.min(destH - 1, y)) y = math.max(0, math.min(destH - 1, y))
-- pokered's collision check reads the NEIGHBOR strip's tile bytes, so
-- stepping off the edge onto a solid tile of the connected map bumps
-- exactly like an in-map wall. Without this read, Pallet's south
-- shore (land at x2-3) walked straight onto ROUTE_21 (3,0) -- a
-- collision tile -- stranding the player on a cell no walk can leave.
if not Map.defPassable(dest, Game.data.tilesets[dest.tileset], x, y,
p.surfing) then
return false
end
self:setMap(conn.map, x, y, p.facing, { seamless = true }) self:setMap(conn.map, x, y, p.facing, { seamless = true })
-- place the player one cell before the seam (their old world spot, -- place the player one cell before the seam (their old world spot,
-- which the neighbor strip renders identically) and start the step -- which the neighbor strip renders identically) and start the step
@@ -931,6 +939,7 @@ function OverworldState:crossConnection(dir, conn)
p.stepFramesCur = Game.save.onBike p.stepFramesCur = Game.save.onBike
and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8) and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8)
or (FieldDefaults.world(Game.data, "stepFrames") or 16) or (FieldDefaults.world(Game.data, "stepFrames") or 16)
return true
end end
-- ------------------------------------------------------------------------- -- -------------------------------------------------------------------------
+353 -30
View File
@@ -1104,6 +1104,9 @@ local mountSurfToward
-- travelTo); declared here for the same reason as cutToward. -- travelTo); declared here for the same reason as cutToward.
local arriveByWarp local arriveByWarp
-- pickupBlockingBall (defined below, next to reachableSet) is a GLOBAL,
-- like cutToward: the main chunk is at LuaJIT's 200-local ceiling.
-- Cross to another region of the same map through a gate. Assigned below -- Cross to another region of the same map through a gate. Assigned below
-- (needs walkOntoWarp); declared here because ops.goto_ calls it. -- (needs walkOntoWarp); declared here because ops.goto_ calls it.
local reEnterThroughGate local reEnterThroughGate
@@ -1141,10 +1144,14 @@ end
function ops.goto_(s, _where, isLast) function ops.goto_(s, _where, isLast)
local o = ow() local o = ow()
local map = o.map local map = o.map
-- one surf mount per goto: walking ashore dismounts, so an unreachable -- surf mounts per goto, BUDGETED: walking ashore dismounts, so an
-- target beyond a pond can otherwise mount -> cross -> dismount -> fail -- unreachable target beyond a pond can mount -> cross -> dismount ->
-- -> mount again forever (Celadon's pond, target (39,8)) -- fail -> mount forever (Celadon's pond, target (39,8)) -- but a hard
local surfMounts = 0 -- cap of ONE stranded the bot on Route 20's mid-island strip, where a
-- legitimate crossing is surf -> beach -> surf again. Three mounts
-- covers every real island-hop; a still-unreachable target then fails
-- the goto as before.
local surfMounts, SURF_BUDGET = 0, 3
-- ...and a small cut budget per goto: a pocket behind two trees opens -- ...and a small cut budget per goto: a pocket behind two trees opens
-- over two passes, but a target that stays unreachable must not -- over two passes, but a target that stays unreachable must not
-- chain-cut every ornamental bush on the map (Saffron/Celadon cut-spam) -- chain-cut every ornamental bush on the map (Saffron/Celadon cut-spam)
@@ -1171,6 +1178,8 @@ function ops.goto_(s, _where, isLast)
local tx = math.max(0, math.min(s.x, map.widthCells - 1)) local tx = math.max(0, math.min(s.x, map.widthCells - 1))
local ty = math.max(0, math.min(s.y, map.heightCells - 1)) local ty = math.max(0, math.min(s.y, map.heightCells - 1))
local refusedEdge = knownWalls(map.id) or {} local refusedEdge = knownWalls(map.id) or {}
-- one border-cell retarget per goto (see the fenced-lane rung below)
local edgeRetargeted = false
for edgeTry = 1, 2 do for edgeTry = 1, 2 do
for _ = 1, 300 do for _ = 1, 300 do
if ow().map.id ~= startMap then return true end if ow().map.id ~= startMap then return true end
@@ -1206,11 +1215,43 @@ function ops.goto_(s, _where, isLast)
end end
-- ...or the border cell is on WATER (Pallet's south edge): -- ...or the border cell is on WATER (Pallet's south edge):
-- mount SURF at the shore and re-plan over the sea. -- mount SURF at the shore and re-plan over the sea.
if surfMounts == 0 and mountSurfToward if surfMounts < SURF_BUDGET and mountSurfToward
and mountSurfToward(tx, ty) then and mountSurfToward(tx, ty) then
surfMounts = surfMounts + 1 surfMounts = surfMounts + 1
goto edgeStep goto edgeStep
end end
-- ...or THIS border column is fenced off while another of
-- the same edge is open. Viridian's south border is three
-- fenced lanes and only x20-21 connects to the city; the
-- planner's seam cell (15,35) is statically walkable-paired
-- but dynamically sealed, so the exit spun cutting
-- ornamental bushes and surfing the pond. Retarget ONCE to
-- the nearest border cell that is BOTH reachable right now
-- and has a walkable far side.
if not edgeRetargeted then
local compass = (dir == "up" and "north")
or (dir == "down" and "south")
or (dir == "left" and "west") or "east"
local conn = map.def.connections and map.def.connections[compass]
if conn and seamCells and reachableSet then
local seen, WW = reachableSet()
local bestAlt, bestD
for _, c in ipairs(seamCells(map.def, compass, conn)) do
if seen[c[2] * WW + c[1]] and (c[1] ~= tx or c[2] ~= ty) then
local d2 = math.abs(c[1] - tx) + math.abs(c[2] - ty)
if not bestD or d2 < bestD then bestAlt, bestD = c, d2 end
end
end
if bestAlt then
say(("edge exit %s on %s: border cell (%d,%d) is walled "
.. "off; crossing at (%d,%d) instead"):format(dir,
tostring(map.id), tx, ty, bestAlt[1], bestAlt[2]))
tx, ty = bestAlt[1], bestAlt[2]
edgeRetargeted = true
goto edgeStep
end
end
end
end end
break -- already there, or genuinely no path break -- already there, or genuinely no path
end end
@@ -1250,7 +1291,7 @@ function ops.goto_(s, _where, isLast)
-- The push stalled. Once per goto: if this edge has a WATER crossing -- The push stalled. Once per goto: if this edge has a WATER crossing
-- and the party can surf, mount and retry the whole exit over the -- and the party can surf, mount and retry the whole exit over the
-- sea column instead of bumping the shore (Cinnabar -> Route 21). -- sea column instead of bumping the shore (Cinnabar -> Route 21).
if edgeTry == 1 and surfMounts == 0 and not ow().player.surfing if edgeTry == 1 and surfMounts < SURF_BUDGET and not ow().player.surfing
and slotKnowing("SURF") then and slotKnowing("SURF") then
local wc = borderWaterCell(map, dir, tx, ty) local wc = borderWaterCell(map, dir, tx, ty)
if wc and mountSurfToward and mountSurfToward(wc[1], wc[2]) then if wc and mountSurfToward and mountSurfToward(wc[1], wc[2]) then
@@ -1353,12 +1394,21 @@ function ops.goto_(s, _where, isLast)
for _ = 1, 8 do for _ = 1, 8 do
if ow().map.id ~= from then return true end if ow().map.id ~= from then return true end
local m = ow().map local m = ow().map
-- only a warp ON the map border needs the held direction; an -- A warp needing a held press: mirror the engine's
-- interior door fires on its own and has no "outward" to push -- Warp.extraCheck -- facing the map edge, OR a warp-carpet
local outward = (s.x >= m.widthCells - 1 and "right") -- door tile in front (Route 16's gate mats: plain path
or (s.x <= 0 and "left") -- tiles whose warp fires only pressing INTO the building).
or (s.y >= m.heightCells - 1 and "down") -- A true interior door fires on its own; no direction
or (s.y <= 0 and "up") -- passes extraCheck there and we stop pushing.
local WarpMod = require("src.world.Warp")
local carpets = G.data.field and G.data.field.warpCarpets
local outward
for _, dd in ipairs(DIRS) do
if WarpMod.extraCheck(m, carpets, s.x, s.y, dd[3]) then
outward = dd[3]
break
end
end
if not outward then break end if not outward then break end
pushed = true pushed = true
faceDir(outward) faceDir(outward)
@@ -1464,6 +1514,15 @@ function ops.goto_(s, _where, isLast)
blocked = 0 blocked = 0
goto keepGoing goto keepGoing
end end
-- ...or an item ball is the wall (Pokémon Tower 6F's
-- RARE_CANDY sits in the floor's only crossing corridor).
-- Tried BEFORE arriveByWarp: taking the ball keeps the walk on
-- this floor, while arriveByWarp warps clear off it hunting
-- the far end.
if pickupBlockingBall and pickupBlockingBall(s.x, s.y) then
blocked = 0
goto keepGoing
end
-- ...or the target is in a part of this map no walk connects. -- ...or the target is in a part of this map no walk connects.
-- --
-- Cave floors are not one connected room. MT_MOON_B1F is four -- Cave floors are not one connected room. MT_MOON_B1F is four
@@ -1533,7 +1592,7 @@ function ops.goto_(s, _where, isLast)
-- an NPC-blocked target in a pond town does not trigger a -- an NPC-blocked target in a pond town does not trigger a
-- pointless swim, and ONCE per goto -- coming ashore dismounts, -- pointless swim, and ONCE per goto -- coming ashore dismounts,
-- so a still-unreachable target re-mounts forever otherwise. -- so a still-unreachable target re-mounts forever otherwise.
if surfMounts == 0 and mountSurfToward if surfMounts < SURF_BUDGET and mountSurfToward
and mountSurfToward(s.x, s.y) then and mountSurfToward(s.x, s.y) then
surfMounts = surfMounts + 1 surfMounts = surfMounts + 1
blocked = 0 blocked = 0
@@ -2439,6 +2498,8 @@ local function walkOntoWarp(wx, wy)
local toggledSwitch = false local toggledSwitch = false
-- one Victory Road switch solve per call (see solveVictoryRoadSwitches) -- one Victory Road switch solve per call (see solveVictoryRoadSwitches)
local solvedVR = false local solvedVR = false
-- one blocking-item-ball pickup per call (see pickupBlockingBall)
local tookBall = false
for _ = 1, 300 do for _ = 1, 300 do
if ow().map.id ~= from then return true end if ow().map.id ~= from then return true end
if inBattle() then fightBattle() if inBattle() then fightBattle()
@@ -2456,11 +2517,28 @@ local function walkOntoWarp(wx, wy)
-- "leave by this edge" idiom -- which is Red shoving downward into -- "leave by this edge" idiom -- which is Red shoving downward into
-- the wall below the door instead of walking out of it. -- the wall below the door instead of walking out of it.
local m = ow().map local m = ow().map
local outward = (wy >= m.heightCells - 1 and "down") -- Which press can actually FIRE this warp? Mirror the engine:
or (wy <= 0 and "up") -- Warp.extraCheck says whether pressing `dir` from this cell can
or (wx <= 0 and "left") -- warp -- facing the map edge, OR a warp-carpet door tile in
or (wx >= m.widthCells - 1 and "right") -- front. Route 16's gate mats are the latter: plain path tiles
or "down" -- whose warp fires only pressing INTO the building ($4B door to
-- the LEFT), and the old edge-based guess pushed "down" forever
-- ("the step onto (24,10) keeps getting shoved back").
local WarpMod = require("src.world.Warp")
local carpets = G.data.field and G.data.field.warpCarpets
local outward
for _, d in ipairs(DIRS) do
if WarpMod.extraCheck(m, carpets, wx, wy, d[3]) then
outward = d[3]
break
end
end
outward = outward
or (wy >= m.heightCells - 1 and "down")
or (wy <= 0 and "up")
or (wx <= 0 and "left")
or (wx >= m.widthCells - 1 and "right")
or "down"
faceDir(outward) faceDir(outward)
walk(outward) walk(outward)
-- If pushing outward does nothing, the warp we are standing on is -- If pushing outward does nothing, the warp we are standing on is
@@ -2543,6 +2621,12 @@ local function walkOntoWarp(wx, wy)
solvedVR = true solvedVR = true
solveVictoryRoadSwitches() solveVictoryRoadSwitches()
bfsNil = 0 bfsNil = 0
-- ...or an item ball on the only corridor (Pokémon Tower 6F's
-- RARE_CANDY): take it and re-path.
elseif not tookBall and pickupBlockingBall
and pickupBlockingBall(wx, wy) then
tookBall = true
bfsNil = 0
else else
bfsNil = (bfsNil or 0) + 1 bfsNil = (bfsNil or 0) + 1
if bfsNil >= 8 then if bfsNil >= 8 then
@@ -2638,7 +2722,10 @@ end
-- pairing is exact and a seam with no walkable pair can never be crossed -- pairing is exact and a seam with no walkable pair can never be crossed
-- however hard we push at it. This is what keeps the search from planning -- however hard we push at it. This is what keeps the search from planning
-- Vermilion -> Saffron -> Cerulean through a walled city. -- Vermilion -> Saffron -> Cerulean through a walled city.
local function seamCells(def, dir, conn) -- GLOBAL (not local): ops.goto_'s edge-exit retarget below needs it and
-- is defined earlier in the file; the main chunk is also at LuaJIT's
-- 200-local ceiling.
function seamCells(def, dir, conn)
local dest = G.data.maps and G.data.maps[conn.map] local dest = G.data.maps and G.data.maps[conn.map]
if not dest then return {} end if not dest then return {} end
local aW, aH = def.width * 2, def.height * 2 local aW, aH = def.width * 2, def.height * 2
@@ -2770,14 +2857,16 @@ local function regionGraph()
local dw = maps[src].warps and maps[src].warps[w.destWarp] local dw = maps[src].warps and maps[src].warps[w.destWarp]
local dr = dw and cellRegionOf(src, dw.x, dw.y) local dr = dw and cellRegionOf(src, dw.x, dw.y)
if sr and dr then if sr and dr then
local c = (id:find("^SEAFOAM") or src:find("^SEAFOAM")) and 43 or 3
add(regionNode(id, sr), regionNode(src, dr), add(regionNode(id, sr), regionNode(src, dr),
{ warp = { x = w.x, y = w.y }, cost = 3, dynamic = true }) { warp = { x = w.x, y = w.y }, cost = c, dynamic = true })
end end
end end
elseif type(d) == "string" and maps[d] then elseif type(d) == "string" and maps[d] then
local dw = maps[d].warps and maps[d].warps[w.destWarp] local dw = maps[d].warps and maps[d].warps[w.destWarp]
local dr = dw and cellRegionOf(d, dw.x, dw.y) local dr = dw and cellRegionOf(d, dw.x, dw.y)
if sr and dr then if sr and dr and not (d:find("^SEAFOAM") and not id:find("^SEAFOAM")) then
-- entering Seafoam pruned, same reason as the map graph
local c = (id:find("ELEVATOR") or d:find("ELEVATOR")) and 50 or 1 local c = (id:find("ELEVATOR") or d:find("ELEVATOR")) and 50 or 1
add(regionNode(id, sr), regionNode(d, dr), add(regionNode(id, sr), regionNode(d, dr),
{ warp = { x = w.x, y = w.y }, cost = c }) { warp = { x = w.x, y = w.y }, cost = c })
@@ -2802,8 +2891,13 @@ local function regionGraph()
local k = tostring(sr) .. ">" .. tostring(dr) local k = tostring(sr) .. ">" .. tostring(dr)
if sr and dr and not seen[k] then if sr and dr and not seen[k] then
seen[k] = true seen[k] = true
add(regionNode(id, sr), regionNode(conn.map, dr), -- ROUTE_20 <-> CINNABAR: pruned, same reason as the map
{ dir = COMPASS_DIR[dir], cells = cells, cost = 1 }) -- graph (the east and west seas never connect)
if not ((id == "ROUTE_20" and conn.map == "CINNABAR_ISLAND")
or (id == "CINNABAR_ISLAND" and conn.map == "ROUTE_20")) then
add(regionNode(id, sr), regionNode(conn.map, dr),
{ dir = COMPASS_DIR[dir], cells = cells, cost = 1 })
end
end end
end end
end end
@@ -2945,7 +3039,17 @@ local function mapGraph()
-- rather than deleted so a floor only the elevator can reach (Silph's -- rather than deleted so a floor only the elevator can reach (Silph's
-- top) is still on the graph. -- top) is still on the graph.
local elev = (id:find("ELEVATOR") or d:find("ELEVATOR")) and 50 or 1 local elev = (id:find("ELEVATOR") or d:find("ELEVATOR")) and 50 or 1
g[id][#g[id] + 1] = { to = d, warp = w, cost = elev } -- ENTERING Seafoam is pruned outright: 1F's two door pockets
-- only connect through the BASEMENT floors, which hop execution
-- cannot thread, so every plan through the islands ping-ponged
-- the Route 20 doors -- pricing alone lost to a wall of banned
-- alternatives after hours of wandering. The route never uses
-- Seafoam (Cinnabar is reached by sea from Pallet). Exits (the
-- LAST_MAP door edges) are kept so a bot stranded inside can
-- always leave.
if not (d:find("^SEAFOAM") and not id:find("^SEAFOAM")) then
g[id][#g[id] + 1] = { to = d, warp = w, cost = elev }
end
into[d] = into[d] or {} into[d] = into[d] or {}
into[d][id] = true into[d][id] = true
end end
@@ -2958,8 +3062,23 @@ local function mapGraph()
-- gate script can open one later, and a wrongly deleted edge is -- gate script can open one later, and a wrongly deleted edge is
-- an unreachable map, while a wrongly costly one is a detour. -- an unreachable map, while a wrongly costly one is a detour.
local cells = seamCells(def, dir, c) local cells = seamCells(def, dir, c)
g[id][#g[id] + 1] = { to = c.map, dir = COMPASS_DIR[dir], -- ROUTE_20's east and west seas NEVER connect on the map: a
cells = cells, cost = #cells > 0 and 1 or 8 } -- walled channel splits them at Seafoam, and the only crossing
-- is through the islands' basement, which hop execution cannot
-- thread. The map graph sees one ROUTE_20 node, so from the
-- east side the CINNABAR seam looked one hop away, and one
-- travelTo(PALLET_TOWN) spent HOURS banning its way across
-- Kanto until this impossible seam was the cheapest edge left,
-- then camped it. PRUNED, not priced -- this is a hard physical
-- fact, not a maybe-a-tree-opens-it seam, and both sides stay
-- reachable without it (CINNABAR via ROUTE_21, ROUTE_20 via
-- ROUTE_19). The executable Cinnabar path is Pallet ->
-- ROUTE_21 south, the HOF run's own.
if not ((id == "ROUTE_20" and c.map == "CINNABAR_ISLAND")
or (id == "CINNABAR_ISLAND" and c.map == "ROUTE_20")) then
g[id][#g[id] + 1] = { to = c.map, dir = COMPASS_DIR[dir],
cells = cells, cost = #cells > 0 and 1 or 8 }
end
end end
end end
end end
@@ -2972,7 +3091,8 @@ local function mapGraph()
for _, w in ipairs(def.warps or {}) do for _, w in ipairs(def.warps or {}) do
if w.destMap == "LAST_MAP" then if w.destMap == "LAST_MAP" then
for src in pairs(into[id] or {}) do for src in pairs(into[id] or {}) do
g[id][#g[id] + 1] = { to = src, warp = w, dynamic = true, cost = 3 } local c = (id:find("^SEAFOAM") or src:find("^SEAFOAM")) and 43 or 3
g[id][#g[id] + 1] = { to = src, warp = w, dynamic = true, cost = c }
end end
end end
end end
@@ -3436,12 +3556,74 @@ function reachableSet()
and not pairBlockedStep(m, c[1], c[2], nx, ny, p.surfing) then and not pairBlockedStep(m, c[1], c[2], nx, ny, p.surfing) then
seen[id] = true seen[id] = true
q[#q + 1] = { nx, ny } q[#q + 1] = { nx, ny }
elseif nx >= 0 and ny >= 0 and nx < W and ny < H
and not passableCell(m, nx, ny) then
-- ledge hops, so this set matches what bfsNextKey can walk
-- (see cutToward for the ROUTE_9 case this fixed)
local lx, ly = ledgeLanding(m, c[1], c[2], d[3], d[1], d[2])
if lx then
local lid = ly * W + lx
if not seen[lid] and not blocked[lid] then
seen[lid] = true
q[#q + 1] = { lx, ly }
end
end
end end
end end
end end
return seen, W return seen, W
end end
-- Pick up an item ball that is walling off the walk to (tx, ty).
--
-- An item ball is an NPC and therefore a BFS wall -- and Pokémon Tower
-- 6F's RARE_CANDY at (6,8) stands in the ONLY corridor between the
-- floor's east and west halves. Vanilla players grab it in passing (the
-- ball despawns, opening the way) and the route's own pickup steps do
-- the same -- but a resume desync that skips those steps leaves the
-- floor severed for good: "goto (6,14) unreachable on POKEMON_TOWER_6F"
-- with a fresh save's balls all standing. So: when the target is
-- outside the reachable set and a ball sits on its frontier, stand
-- beside it, take it, and let the caller re-plan. Same one-per-call
-- contract as cutToward.
pickingBall = false -- global: the main chunk is at the 200-local ceiling
function pickupBlockingBall(tx, ty)
if pickingBall then return false end
local m = ow().map
local seen, W = reachableSet()
if seen[ty * W + tx] then return false end -- not a blocking-ball problem
for _, npc in ipairs(ow().npcs) do
if tostring(npc.def and npc.def.sprite or "") == "SPRITE_POKE_BALL" then
local bx, by = npc.cellX, npc.cellY
for _, d in ipairs(DIRS) do
local ax, ay = bx + d[1], by + d[2]
if ax >= 0 and ay >= 0 and ax < W and ay < m.heightCells
and seen[ay * W + ax] then
pickingBall = true
local took = false
ops.goto_({ x = ax, y = ay })
local p = ow().player
if ow().map.id == m.id and p.cellX == ax and p.cellY == ay then
faceDir((by < ay and "up") or (by > ay and "down")
or (bx < ax and "left") or "right")
press("a")
U.wait(20)
mashUntilIdle()
took = not ow():npcAtCell(bx, by)
end
pickingBall = false
if took then
say(("picked up the item ball at (%d,%d) that was walling off "
.. "(%d,%d)"):format(bx, by, tx, ty))
return true
end
end
end
end
end
return false
end
-- Reach another region of THIS map by going through a gate and out its far -- Reach another region of THIS map by going through a gate and out its far
-- door. -- door.
-- --
@@ -3945,6 +4127,8 @@ end
-- CASCADEBADGE (src/ui/PartyMenu.lua:342). -- CASCADEBADGE (src/ui/PartyMenu.lua:342).
function ops.fieldMove(s, where) function ops.fieldMove(s, where)
local move = tostring(s.move or ""):upper() local move = tostring(s.move or ""):upper()
-- the tree cell CUT will face, for the post-use verification below
local cutCellX, cutCellY
local slot = slotKnowing(move) local slot = slotKnowing(move)
if not slot then if not slot then
-- DIG and FLY are TRANSPORT, and a transport step we cannot perform is -- DIG and FLY are TRANSPORT, and a transport step we cannot perform is
@@ -3981,6 +4165,21 @@ function ops.fieldMove(s, where)
say(("fieldMove %s: nobody in the party knows it"):format(move)) say(("fieldMove %s: nobody in the party knows it"):format(move))
return false return false
end end
-- The knower must also be CONSCIOUS: the party submenu hides field
-- moves on a fainted mon (vanilla -- partyKnows requires hp > 0), so
-- the menu walk below finds only STATS/SWITCH and fails. Route 23's
-- mandatory surf strip is where this bit: WARTORTLE arrived dead from
-- the Viridian Gym attrition, "no SURF entry" x3, the water was never
-- crossed, and the whole league skip-cascaded with all eight badges
-- in the bag. Heal, fail this attempt loudly, and let the caller's
-- retry run the step again with a live user.
local knower = (G.save.party or {})[slot]
if knower and (knower.hp or 0) <= 0 then
say(("fieldMove %s: its only user has fainted -- visiting a centre "
.. "before retrying"):format(move))
visitPokeCenter(where)
return false
end
-- CUT acts on the tile the player is FACING (OverworldState:tryCut), and -- CUT acts on the tile the player is FACING (OverworldState:tryCut), and
-- a `goto` leaves us facing whatever direction we last stepped. Without -- a `goto` leaves us facing whatever direction we last stepped. Without
-- turning to the tree first the move fires into empty ground, the game -- turning to the tree first the move fires into empty ground, the game
@@ -3991,6 +4190,19 @@ function ops.fieldMove(s, where)
local m = ow().map local m = ow().map
local function cuttable(cx, cy) local function cuttable(cx, cy)
if not m:inBounds(cx, cy) or m:isWalkableCell(cx, cy) then return false end if not m:inBounds(cx, cy) or m:isWalkableCell(cx, cy) then return false end
-- The same FACE gate as the engine (tryCut): a tree BLOCK is 2x2
-- cells and only the tree-tile half is cuttable -- facing the
-- fence/sign half answers "Nothing to CUT". Without this the scan
-- below picked whichever unwalkable half it met first: Route 9's
-- tree at (5,8) has the block's sign half at (4,9), DOWN is
-- scanned before RIGHT, and the run cut "the tree" twenty times
-- from (4,8) without ever opening the way east.
local ts = m.def and m.def.tileset
local tile = m:cellTile(cx, cy)
if not ((ts == "OVERWORLD" and tile == 0x3d)
or (ts == "GYM" and tile == 0x50)) then
return false
end
local block = m:blockAt(math.floor(cx / 2), math.floor(cy / 2)) local block = m:blockAt(math.floor(cx / 2), math.floor(cy / 2))
for _, sw in ipairs(G.data.field.cutTreeSwaps or {}) do for _, sw in ipairs(G.data.field.cutTreeSwaps or {}) do
if sw.before == block then return true end if sw.before == block then return true end
@@ -4001,7 +4213,11 @@ function ops.fieldMove(s, where)
local facing local facing
for _, d in ipairs({ { 0, 1, "down" }, { 0, -1, "up" }, for _, d in ipairs({ { 0, 1, "down" }, { 0, -1, "up" },
{ -1, 0, "left" }, { 1, 0, "right" } }) do { -1, 0, "left" }, { 1, 0, "right" } }) do
if cuttable(p.cellX + d[1], p.cellY + d[2]) then facing = d[3] break end if cuttable(p.cellX + d[1], p.cellY + d[2]) then
facing = d[3]
cutCellX, cutCellY = p.cellX + d[1], p.cellY + d[2]
break
end
end end
if not facing then if not facing then
note("fieldMove: no tree adjacent", where) note("fieldMove: no tree adjacent", where)
@@ -4102,6 +4318,19 @@ function ops.fieldMove(s, where)
press("a") press("a")
U.wait(12) U.wait(12)
mashUntilIdle() mashUntilIdle()
-- CUT is verifiable: the faced tree cell must be walkable afterwards.
-- "used CUT" used to print regardless, so an engine refusal ("Nothing
-- to CUT") looked like success and the caller re-cut the same tree
-- for its whole budget.
if move == "CUT" and cutCellX then
local now = ow().map
if now:inBounds(cutCellX, cutCellY)
and not now:isWalkableCell(cutCellX, cutCellY) then
say(("fieldMove CUT: the tree at (%d,%d) is still standing -- the "
.. "cut did not land"):format(cutCellX, cutCellY))
return false
end
end
say(("used %s on %s"):format(move, tostring(where))) say(("used %s on %s"):format(move, tostring(where)))
-- DIG and FLY are the route's long-distance travel, and neither lands -- DIG and FLY are the route's long-distance travel, and neither lands
@@ -4182,6 +4411,22 @@ function cutToward(tx, ty)
and not blockedBy[id] and m:isWalkableCell(nx, ny) then and not blockedBy[id] and m:isWalkableCell(nx, ny) then
seen[id] = true seen[id] = true
queue[#queue + 1] = { nx, ny } queue[#queue + 1] = { nx, ny }
elseif nx >= 0 and ny >= 0 and nx < W and ny < H
and not m:isWalkableCell(nx, ny) then
-- ledge hops reach cells this flood otherwise misses -- and
-- bfsNextKey WALKS them, so leaving them out makes the two
-- disagree: from ROUTE_9 (46,9) the regrown west tree's face is
-- only ledge-reachable, cutToward found "no tree", and
-- travelTo(CINNABAR_ISLAND) died -- taking Blaine, the 7-badge
-- Viridian Gym and the whole league gate with it.
local lx, ly = ledgeLanding(m, c[1], c[2], d[3], d[1], d[2])
if lx then
local lid = ly * W + lx
if not seen[lid] and not blockedBy[lid] then
seen[lid] = true
queue[#queue + 1] = { lx, ly }
end
end
end end
end end
end end
@@ -4863,14 +5108,21 @@ function mountSurfToward(tx, ty)
-- water tilesets only (water_tilesets.asm); a cave with no water would -- water tilesets only (water_tilesets.asm); a cave with no water would
-- scan every cell for nothing -- scan every cell for nothing
local seen, W = reachableSet() local seen, W = reachableSet()
-- nearest reachable land cell with water beside it -- The shore to mount from is the reachable land-beside-water cell
-- nearest the TARGET, with the player's own distance only breaking
-- ties. Nearest-to-player was a trap on Route 20's mid-island strip:
-- a rock wall splits the strip's shores, and the bot re-mounted on
-- the west shore every time, surfed back over ground it had already
-- crossed, dismounted in the same spot and burned the whole mount
-- budget without ever entering the water that leads east.
local best, bestD local best, bestD
for cellId in pairs(seen) do for cellId in pairs(seen) do
local cx, cy = cellId % W, math.floor(cellId / W) local cx, cy = cellId % W, math.floor(cellId / W)
for _, d in ipairs(DIRS) do for _, d in ipairs(DIRS) do
local wx, wy = cx + d[1], cy + d[2] local wx, wy = cx + d[1], cy + d[2]
if m:inBounds(wx, wy) and m:isWaterCell(wx, wy) then if m:inBounds(wx, wy) and m:isWaterCell(wx, wy) then
local dist = math.abs(cx - p.cellX) + math.abs(cy - p.cellY) local dist = (math.abs(cx - tx) + math.abs(cy - ty)) * 4
+ math.abs(cx - p.cellX) + math.abs(cy - p.cellY)
if not bestD or dist < bestD then best, bestD = { cx, cy }, dist end if not bestD or dist < bestD then best, bestD = { cx, cy }, dist end
break break
end end
@@ -6024,6 +6276,26 @@ local function runRoute(startIndex)
-- already at the heal point, so this is what the death gets attributed to -- already at the heal point, so this is what the death gets attributed to
local lastRanMap local lastRanMap
local diedOn = {} -- segment index -> deaths this attempt local diedOn = {} -- segment index -> deaths this attempt
-- Badge go-back (see the league-gate check in the loop): badges whose
-- gym segment this attempt has already rewound to, so a gym that
-- cannot be won does not loop the rewind forever.
local badgeRescued = {}
local LEAGUE_GATE_MAPS = {
ROUTE_22_GATE = true, ROUTE_23 = true, VICTORY_ROAD_1F = true,
VICTORY_ROAD_2F = true, VICTORY_ROAD_3F = true,
INDIGO_PLATEAU = true, INDIGO_PLATEAU_LOBBY = true,
}
-- route order; badge names are inventory keys, gyms are segment maps
local BADGE_GYM_SEGMENTS = {
{ "BOULDERBADGE", "PEWTER_GYM" },
{ "CASCADEBADGE", "CERULEAN_GYM" },
{ "THUNDERBADGE", "VERMILION_GYM" },
{ "RAINBOWBADGE", "CELADON_GYM" },
{ "SOULBADGE", "FUCHSIA_GYM" },
{ "MARSHBADGE", "SAFFRON_GYM" },
{ "VOLCANOBADGE", "CINNABAR_GYM" },
{ "EARTHBADGE", "VIRIDIAN_GYM" },
}
-- segment index -> deaths in a row where training gained no levels. -- segment index -> deaths in a row where training gained no levels.
-- Reported, not acted on: no death count writes off a segment any more. -- Reported, not acted on: no death count writes off a segment any more.
-- A long barren streak means the grind cannot reach a training ground -- A long barren streak means the grind cannot reach a training ground
@@ -6291,6 +6563,54 @@ local function runRoute(startIndex)
end end
end end
-- Rolling checkpoint every 10 segments, no external toucher needed.
-- A run once reached segment 182 with all eight badges and then
-- "completed" by skipping the sealed league -- and NOTHING was on
-- disk: no toucher was running, no abandon fired, and route
-- completion never saved. Hours of progress, gone at exit. Never
-- again: the driver banks its own progress as it goes.
if i % 10 == 0 then
saveCheckpoint(i, "rolling (every 10 segments)")
end
-- GO BACK FOR A MISSING BADGE before the league gate can wall us.
--
-- Route 22/23's guards badge-check in order and shove the player
-- back (vanilla), and the route only ever moves FORWARD -- so a run
-- that lost a gym segment to a travel failure (Blaine's whole
-- island skipped over a failed sea trip, once) used to reach the
-- gate with seven badges and thrash against the shove until the
-- attempt died. The badges are ordinary inventory; when a league
-- segment comes up short, rewind the index to the missing badge's
-- own gym segment and let the ordinary mismatch->travelTo machinery
-- carry us there. Earliest missing badge first (Cinnabar's segment
-- sits before Viridian's, and Viridian Gym itself only unlocks at
-- seven badges). Once per badge per attempt, so a gym that
-- genuinely cannot be won does not loop the rewind forever.
if LEAGUE_GATE_MAPS[seg.map] then
local missing
for _, bg in ipairs(BADGE_GYM_SEGMENTS) do
if not (G.save.inventory or {})[bg[1]] and not badgeRescued[bg[1]] then
missing = bg
break
end
end
if missing then
local back
for j = i - 1, 1, -1 do
if ROUTE[j].map == missing[2] then back = j break end
end
if back then
badgeRescued[missing[1]] = true
say(("the league gate is ahead but %s is missing -- rewinding "
.. "to segment %d/%d (%s) to earn it")
:format(missing[1], back, #ROUTE, missing[2]))
i = back - 1
goto nextSegment
end
end
end
-- Standing in an elevator? Ride out to the floor we need, before the -- Standing in an elevator? Ride out to the floor we need, before the
-- map-wait can react to a bounce. -- map-wait can react to a bounce.
-- --
@@ -6694,6 +7014,9 @@ local function runRoute(startIndex)
say("---- route complete ----") say("---- route complete ----")
say(("segments: %d failures: %d"):format(#ROUTE, failures)) say(("segments: %d failures: %d"):format(#ROUTE, failures))
-- bank the final state whatever happened: a "complete" run that
-- skip-cascaded its tail used to exit with nothing on disk
saveCheckpoint(#ROUTE, "route complete")
return true return true
end end