Port FireRed wild encounter grace period

Gen3 (FireRed) had no cooldown between wild battles, so every step on an
encounter tile rolled at the area's full rate -- Route 1 (~21%) landed a
battle on roughly one step in five. Gen2 already had this via
World:wildCooldownStep(); game3 never did.

Port pret/pokefirered wild_encounter.c:

- GetMapBaseEncounterCooldown -> Encounters.mapBaseCooldown: steps of
  immunity derived from the area's own rate (rate >= 80 -> none, < 10 ->
  8, else 8 - rate/10).
- HandleWildEncounterCooldown -> Encounters.handleCooldown: a soft floor,
  not a hard gate -- once the minimum elapses a 5%/step leak lets a battle
  through anyway. Includes the White/Black Flute, Cleanse Tag, Stench and
  Illuminate modifiers in pret's application order (Cleanse Tag before the
  ability mod, which changes the result).
- ResetEncounterRateModifiers -> Encounters.resetRateModifiers, wired to
  the two places pret resets: map load (Map.load, including seamless
  connection crossings) and battle start (BattleBridge.startWild). The
  latter is the bug-#1229 class -- scripted battles and fishing re-arm the
  grace period even though no step rolled.

Measured on Route 1 over 4000 steps: 843 encounters before, 397 after;
first encounter never lands before step 7.

Not ported (makes encounters more likely, so it does not affect the
reported symptom): the encounterRateBuff anti-frustration ramp and the
Mach/Acro bike rate modifier.

tests/engine/wild_encounter_cooldown.lua covers the cooldown golden table,
the counter/leak mechanics, all five modifiers, both re-arm paths and the
end-to-end step count.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Shane McGovern
2026-09-19 09:46:02 +01:00
parent d6d8ee2329
commit b84c2c1511
4 changed files with 396 additions and 3 deletions
+7
View File
@@ -392,6 +392,13 @@ end
function BattleBridge.startWild(mod, game, encounter, opts)
opts = opts or {}
opts.wild = true
-- pret battle_setup.c resets the encounter cooldown when a battle starts, so
-- the grace period re-arms after every wild battle -- including ones nothing
-- stepped into (scripted battles, fishing).
local okE, Encounters = pcall(require, "src.core.game3.encounters")
if okE and Encounters and Encounters.resetRateModifiers then
Encounters.resetRateModifiers()
end
return BattleBridge.start(mod, game, encounter, opts)
end
+153 -3
View File
@@ -10,6 +10,7 @@ local Encounters = {}
Encounters._tables = {} -- mapId or "group:num" → { land = { rate, slots }, ... }
Encounters._pendingWild = nil
Encounters._prevGrass = false -- pret first-step-into-grass gate
Encounters._stepsSinceLastEncounter = 0 -- pret sWildEncounterData.stepsSinceLastEncounter
Encounters._logged = false
Encounters._loaded = false
@@ -18,6 +19,21 @@ local LAND_WEIGHTS = { 20, 20, 10, 10, 10, 10, 5, 5, 4, 4, 1, 1 }
local WATER_WEIGHTS = { 60, 30, 5, 4, 1 }
local MAX_ENCOUNTER_RATE = 1600 -- pret wild_encounter.c (FireRed)
-- Ids the encounter-rate modifiers below key off (pret constants/abilities.h,
-- constants/items.h, constants/flags.h).
local ABILITY_STENCH = 1
local ABILITY_ILLUMINATE = 35
local ITEM_CLEANSE_TAG = 190
local FLAG_SYS_WHITE_FLUTE_ACTIVE = 0x803
local FLAG_SYS_BLACK_FLUTE_ACTIVE = 0x804
-- pret GetMapBaseEncounterCooldown returns 0xFF when the map has no encounter
-- data for that tile type, which aborts the check instead of granting a grace
-- period (the roll would fail anyway).
local COOLDOWN_NONE = 0xFF
local COOLDOWN_BASE_LEAK = 5 -- pret: encRate = 5 * 256
local COOLDOWN_SCALE = 256 -- pret keeps minSteps/encRate scaled so the modifiers stay fractional
local function log(msg)
print("[game3/encounters] " .. tostring(msg))
end
@@ -186,6 +202,130 @@ local function table_for(mapId)
return Encounters._tables[tostring(mapId)]
end
--- The area `terrain` rolls on, resolved the same way rollLand/rollWater do.
--- The cooldown needs the rate before the roll happens, and must not consume
--- RNG to get it.
local function area_for(mapId, terrain)
local t = table_for(mapId)
if terrain == "water" then
return normalize_area(t and t.water, 15)
end
return normalize_area(t and t.land) or normalize_area(t and t.grass)
end
-- ---------------------------------------------------------------------------
-- Wild encounter grace period (pret wild_encounter.c).
--
-- FireRed is the only generation with a step cooldown between wild battles:
-- HandleWildEncounterCooldown refuses the roll for a map-dependent number of
-- steps after the last encounter, then lets a small percentage per step
-- through so the wait is soft rather than a hard floor.
-- ---------------------------------------------------------------------------
--- pret GetMapBaseEncounterCooldown: how many steps after a battle are immune,
--- derived from the area's own encounter rate. Rates at 80+ get no grace period
--- at all; below that the wait grows as the rate drops.
function Encounters.mapBaseCooldown(terrain, rate)
if terrain ~= "land" and terrain ~= "water" then return COOLDOWN_NONE end
if rate == nil then return COOLDOWN_NONE end
rate = tonumber(rate) or 0
if rate >= 80 then return 0 end
if rate < 10 then return 8 end
return 8 - math.floor(rate / 10)
end
--- pret GetLeadMonIndex: the lead party slot, eggs excluded.
local function lead_mon()
local ok, Runtime = pcall(require, "src.core.game3.runtime")
local session = ok and Runtime and Runtime.getSession and Runtime.getSession()
local party = session and session.party
if type(party) ~= "table" then return nil end
for i = 1, #party do
local mon = party[i]
if type(mon) == "table" and not mon.isEgg and not mon.egg then return mon end
end
return nil
end
--- pret GetFluteEncounterRateModType: 1 = White Flute, 2 = Black Flute.
local function flute_mod_type()
local okS, Space = pcall(require, "src.core.game3.scripting.space")
if not okS or not Space or not Space.store then return 0 end
local okF, Flags = pcall(require, "src.core.game3.scripting.flags")
if not okF or not Flags or not Flags.getFlag then return 0 end
if Flags.getFlag(Space.store, nil, FLAG_SYS_WHITE_FLUTE_ACTIVE) then return 1 end
if Flags.getFlag(Space.store, nil, FLAG_SYS_BLACK_FLUTE_ACTIVE) then return 2 end
return 0
end
--- pret IsLeadMonHoldingCleanseTag.
local function lead_holds_cleanse_tag()
local mon = lead_mon()
if not mon then return false end
return (tonumber(mon.item or mon.heldItem) or 0) == ITEM_CLEANSE_TAG
end
--- pret GetAbilityEncounterRateModType: Stench 1 (rarer), Illuminate 2 (commoner).
local function ability_mod_type()
local mon = lead_mon()
if not mon then return 0 end
local ability = tonumber(mon.abilityId or mon.ability) or 0
if ability == ABILITY_STENCH then return 1 end
if ability == ABILITY_ILLUMINATE then return 2 end
return 0
end
--- The fully modified (minSteps, leak) pair pret computes inside
--- HandleWildEncounterCooldown. nil means "no encounter data here".
function Encounters.cooldownMinSteps(terrain, rate)
local minSteps = Encounters.mapBaseCooldown(terrain, rate)
if minSteps == COOLDOWN_NONE then return nil end
minSteps = minSteps * COOLDOWN_SCALE
local leak = COOLDOWN_BASE_LEAK * COOLDOWN_SCALE
local flute = flute_mod_type()
if flute == 1 then
minSteps = minSteps - math.floor(minSteps / 2)
leak = leak + math.floor(leak / 2)
elseif flute == 2 then
minSteps = minSteps * 2
leak = math.floor(leak / 2)
end
if lead_holds_cleanse_tag() then
minSteps = minSteps + math.floor(minSteps / 3)
leak = leak - math.floor(leak / 3)
end
local ability = ability_mod_type()
if ability == 1 then
minSteps = minSteps * 2
leak = math.floor(leak / 2)
elseif ability == 2 then
minSteps = math.floor(minSteps / 2)
leak = leak * 2
end
return math.floor(minSteps / COOLDOWN_SCALE), math.floor(leak / COOLDOWN_SCALE)
end
--- pret HandleWildEncounterCooldown. TRUE means this step may roll for an
--- encounter. Runs on every step onto an encounter tile -- including the steps
--- the dice roll would have denied, which is what advances the counter.
function Encounters.handleCooldown(terrain, rate)
local minSteps, leak = Encounters.cooldownMinSteps(terrain, rate)
if minSteps == nil then return false end
if Encounters._stepsSinceLastEncounter >= minSteps then return true end
Encounters._stepsSinceLastEncounter = Encounters._stepsSinceLastEncounter + 1
return (Rng.Random() % 100) < leak
end
--- pret ResetEncounterRateModifiers, reached from RestartWildEncounterImmunitySteps
--- on map load (overworld.c) and on battle start (battle_setup.c). Resetting when
--- the battle starts is what re-arms the grace period, including for wild battles
--- nothing stepped into (scripts, fishing).
function Encounters.resetRateModifiers()
Encounters._stepsSinceLastEncounter = 0
end
local function roll_area(mapId, areaKey, weights, enterFromOther, fallbackRate)
local t = table_for(mapId)
local area = normalize_area(t and t[areaKey], fallbackRate)
@@ -226,10 +366,18 @@ local function vanilla_step(mapId, terrain, opts)
if enterFromOther == nil then
enterFromOther = not Encounters._prevGrass
end
-- pret TryStandardWildEncounter consults the cooldown before the rate test.
local area = area_for(mapId, terrain)
if not Encounters.handleCooldown(terrain, area and area.rate) then return nil end
local enc
if terrain == "water" then
return Encounters.rollWater(mapId, enterFromOther)
enc = Encounters.rollWater(mapId, enterFromOther)
else
enc = Encounters.rollLand(mapId, nil, enterFromOther)
end
return Encounters.rollLand(mapId, nil, enterFromOther)
-- pret sets stepsSinceLastEncounter = 0 once an encounter actually starts.
if enc then Encounters.resetRateModifiers() end
return enc
end
local function mod_encounter(enc)
@@ -277,7 +425,9 @@ function Encounters.onStep(mapId, terrain, opts)
if enc and wantsSpecies then
enc = ModRuntime.call("encounter.species", same_encounter, enc, ctx)
end
return engine_encounter(enc)
enc = engine_encounter(enc)
if enc then Encounters.resetRateModifiers() end
return enc
end
function Encounters.noteGrass(onGrass)
+9
View File
@@ -264,6 +264,15 @@ function Map.load(mod, game, mapId, opts)
if not MapIds.isGame3Map(mapId) then
return nil, "not a game3 map"
end
-- pret RestartWildEncounterImmunitySteps on LoadMap / LoadMapFromWarp: every
-- map entry restarts the wild encounter grace period. Unconditional, so the
-- seamless connection crossing between two routes resets it too.
do
local okE, Encounters = pcall(require, "src.core.game3.encounters")
if okE and Encounters and Encounters.resetRateModifiers then
Encounters.resetRateModifiers()
end
end
local Ghosts = require("src.core.game3.ghosts")
local fromMapId = Map._announced
if Map.current and Map.current ~= mapId then
+227
View File
@@ -0,0 +1,227 @@
-- FireRed wild encounter grace period (pret wild_encounter.c).
--
-- FireRed is the only generation with a step cooldown between wild battles:
-- HandleWildEncounterCooldown refuses the roll for a map-dependent number of
-- steps after the last encounter, then lets 5%/step through so the wait is
-- soft rather than a hard floor. Without it a Route 1 tile (rate 21) rolls
-- 21% per step and the game reads as "a wild battle nearly every step".
--
-- Every expected value below is the literal pret constant, so a drift in the
-- port shows up here rather than as a frequency change in-game.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local Rng = require("src.core.game3.rng")
local Encounters = require("src.core.game3.encounters")
local LAND_SLOT = { { species = 16, minLevel = 3, maxLevel = 5 } }
Encounters._tables = {
ROUTE_1 = { land = { rate = 21, slots = LAND_SLOT } },
RARE = { land = { rate = 5, slots = LAND_SLOT } },
COMMON = { land = { rate = 80, slots = LAND_SLOT } },
POND = { water = { rate = 21, slots = LAND_SLOT } },
}
Encounters._loaded = true
local function reset()
Encounters.resetRateModifiers()
end
-- ---------------------------------------------------------------- base table
-- pret GetMapBaseEncounterCooldown: 0xFF when there is no encounter data,
-- 0 at rate >= 80, 8 at rate < 10, else 8 - rate/10.
local GOLDEN_BASE = {
[0] = 8, [5] = 8, [9] = 8, [10] = 7, [19] = 7, [21] = 6, [25] = 6,
[40] = 4, [79] = 1, [80] = 0, [100] = 0,
}
for rate, want in pairs(GOLDEN_BASE) do
eq(Encounters.mapBaseCooldown("land", rate), want, "mapBaseCooldown(land, " .. rate .. ")")
eq(Encounters.mapBaseCooldown("water", rate), want, "mapBaseCooldown(water, " .. rate .. ")")
end
eq(Encounters.mapBaseCooldown("land", nil), 0xFF, "no landMonsInfo -> 0xFF")
eq(Encounters.mapBaseCooldown("water", nil), 0xFF, "no waterMonsInfo -> 0xFF")
eq(Encounters.mapBaseCooldown("none", 21), 0xFF, "TILE_ENCOUNTER_NONE -> 0xFF")
-- ---------------------------------------------------------------- counter
print("[test] cooldown counter")
reset()
eq(Encounters._stepsSinceLastEncounter, 0, "reset zeroes the counter")
-- Route 1 is a 6-step cooldown: the first six steps must be denied even with
-- the rate test favouring an encounter.
Rng.SeedRng(0xC0DE)
local allowedEarly = 0
for i = 1, 6 do
if Encounters.handleCooldown("land", 21) then allowedEarly = allowedEarly + 1 end
eq(Encounters._stepsSinceLastEncounter, i, "denied step " .. i .. " advances the counter")
end
check(allowedEarly == 0, "pinned seed: no leak across the 6-step cooldown")
-- Once the counter has reached minSteps the step is allowed, and the check
-- stops touching the RNG or the counter (pret returns TRUE immediately).
local before = Rng.getState().value
check(Encounters.handleCooldown("land", 21), "counter at minSteps allows the roll")
eq(Rng.getState().value, before, "allowed step consumes no RNG")
eq(Encounters._stepsSinceLastEncounter, 6, "allowed step leaves the counter alone")
-- A map with no encounter data aborts before the counter moves at all.
reset()
check(not Encounters.handleCooldown("land", nil), "no encounter data denies the step")
eq(Encounters._stepsSinceLastEncounter, 0, "no encounter data leaves the counter alone")
-- ---------------------------------------------------------------- leak
print("[test] 5%/step leak")
Rng.SeedRng(0x1234)
local leaked = 0
local STEPS = 20000
for _ = 1, STEPS do
reset()
if Encounters.handleCooldown("land", 21) then leaked = leaked + 1 end
end
local pct = leaked / STEPS * 100
check(pct > 4.5 and pct < 5.5, ("leak is 5%%/step (%.2f%%)"):format(pct))
-- ---------------------------------------------------------------- modifiers
print("[test] rate modifiers (pret HandleWildEncounterCooldown)")
local Space = require("src.core.game3.scripting.space")
local Flags = require("src.core.game3.scripting.flags")
local Runtime = require("src.core.game3.runtime")
local prevStore, prevSession = Space.store, Runtime.session
Space.store = Flags.newStore()
local function mods()
return Encounters.cooldownMinSteps("land", 21)
end
local function setMods(flute, party)
Flags.setFlag(Space.store, nil, 0x803, flute == "white")
Flags.setFlag(Space.store, nil, 0x804, flute == "black")
Runtime.session = party and { party = party } or nil
end
setMods(nil, nil)
local baseSteps, baseLeak = mods()
eq(baseSteps, 6, "base minSteps")
eq(baseLeak, 5, "base leak")
setMods("white", nil)
local ws, wl = mods()
eq(ws, 3, "White Flute halves minSteps")
eq(wl, 7, "White Flute raises the leak")
setMods("black", nil)
local bs, bl = mods()
eq(bs, 12, "Black Flute doubles minSteps")
eq(bl, 2, "Black Flute halves the leak")
setMods(nil, { { species = 1, level = 5, item = 190 } })
local cs, cl = mods()
eq(cs, 8, "Cleanse Tag raises minSteps by a third")
eq(cl, 3, "Cleanse Tag lowers the leak")
setMods(nil, { { species = 1, level = 5, abilityId = 1 } })
local ss, sl = mods()
eq(ss, 12, "Stench doubles minSteps")
eq(sl, 2, "Stench halves the leak")
setMods(nil, { { species = 1, level = 5, abilityId = 35 } })
local is, il = mods()
eq(is, 3, "Illuminate halves minSteps")
eq(il, 10, "Illuminate doubles the leak")
-- pret GetLeadMonIndex skips eggs, so an egg lead applies nothing.
setMods(nil, { { species = 1, level = 5, isEgg = true, abilityId = 35 } })
eq(mods(), 6, "egg lead applies no ability modifier")
Space.store, Runtime.session = prevStore, prevSession
-- ---------------------------------------------------------------- end to end
print("[test] step rolls (Route 1, rate 21)")
-- Steady state in tall grass: enterFromOther is only set on the first step in,
-- so every later step is a bare rate test. That is the 21%/step baseline.
local function roll(steps, useCooldown)
Rng.SeedRng(0xC0DE)
Rng.SeedWildEncounterRng(0xBEEF)
reset()
local n, firstAt = 0, nil
for i = 1, steps do
local enc
if useCooldown then
enc = Encounters.onStep("ROUTE_1", "land", { enterFromOther = false })
else
enc = Encounters.rollLand("ROUTE_1", nil, false)
end
if enc then
n = n + 1
firstAt = firstAt or i
reset()
end
end
return n, firstAt
end
local withCooldown = roll(4000, true)
local withoutCooldown = roll(4000, false)
eq(withCooldown, 397, "4000 steps with the cooldown -> 397 encounters")
eq(withoutCooldown, 843, "4000 steps without it -> 843 encounters")
check(withCooldown < withoutCooldown / 2,
("cooldown more than halves the encounter count (%d vs %d)"):format(withCooldown, withoutCooldown))
-- The first encounter after a reset cannot land before the cooldown expires.
Rng.SeedRng(0xC0DE)
Rng.SeedWildEncounterRng(0xBEEF)
reset()
local pattern = {}
for i = 1, 10 do
pattern[i] = Encounters.onStep("ROUTE_1", "land", { enterFromOther = false }) and "E" or "-"
end
eq(table.concat(pattern), "------E---", "first encounter lands on step 7 of a 6-step cooldown")
-- A rate-80 area gets no grace period, exactly as pret.
reset()
check(Encounters.cooldownMinSteps("land", 80) == 0, "rate 80 has no cooldown")
Rng.SeedRng(1)
check(Encounters.handleCooldown("land", 80), "rate 80 allows the first step")
-- Water uses its own area's rate.
reset()
eq(Encounters.cooldownMinSteps("water", 21), 6, "water area cooldown")
-- ---------------------------------------------------------------- re-arm
print("[test] the cooldown re-arms when a battle starts")
-- Regression for the Gen 2 bug #1229 class: a wild battle that no step rolled
-- (script, fishing) still has to restart the immunity steps, or the next step
-- in grass is a fresh encounter.
local BattleBridge = require("src.core.game3.battle_bridge")
Encounters._stepsSinceLastEncounter = 5
pcall(BattleBridge.startWild, nil, nil, { species = 16, level = 3 }, {})
eq(Encounters._stepsSinceLastEncounter, 0, "startWild restarts the immunity steps")
-- pret also restarts them on every map load (overworld.c LoadMap /
-- LoadMapFromWarp), which is what makes walking out of a route and back in
-- give a fresh grace period -- including the seamless connection crossing
-- between two routes.
print("[test] the cooldown re-arms on map load")
local Map = require("src.core.game3.map")
Encounters._stepsSinceLastEncounter = 5
pcall(Map.load, nil, nil, "FR_ROUTE1", {})
eq(Encounters._stepsSinceLastEncounter, 0, "map load restarts the immunity steps")
Encounters._stepsSinceLastEncounter = 5
pcall(Map.load, nil, nil, "FR_ROUTE1", { seamless = true })
eq(Encounters._stepsSinceLastEncounter, 0, "seamless connection load restarts them too")
Encounters._stepsSinceLastEncounter = 5
pcall(Map.load, nil, nil, "PALLET_TOWN", {})
eq(Encounters._stepsSinceLastEncounter, 5, "a non-game3 map id is not a load")
T.finish("wild_encounter_cooldown")