From b84c2c15116620166da37a0032a3cd22fb0bd65b Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Sat, 19 Sep 2026 09:46:02 +0100 Subject: [PATCH] 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> --- src/core/game3/battle_bridge.lua | 7 + src/core/game3/encounters.lua | 156 +++++++++++++++- src/core/game3/map.lua | 9 + tests/engine/wild_encounter_cooldown.lua | 227 +++++++++++++++++++++++ 4 files changed, 396 insertions(+), 3 deletions(-) create mode 100644 tests/engine/wild_encounter_cooldown.lua diff --git a/src/core/game3/battle_bridge.lua b/src/core/game3/battle_bridge.lua index 6acd7a98..000da2c6 100644 --- a/src/core/game3/battle_bridge.lua +++ b/src/core/game3/battle_bridge.lua @@ -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 diff --git a/src/core/game3/encounters.lua b/src/core/game3/encounters.lua index 6be918d3..08887a57 100644 --- a/src/core/game3/encounters.lua +++ b/src/core/game3/encounters.lua @@ -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) diff --git a/src/core/game3/map.lua b/src/core/game3/map.lua index 5a83b924..57320d25 100644 --- a/src/core/game3/map.lua +++ b/src/core/game3/map.lua @@ -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 diff --git a/tests/engine/wild_encounter_cooldown.lua b/tests/engine/wild_encounter_cooldown.lua new file mode 100644 index 00000000..17ecf9c7 --- /dev/null +++ b/tests/engine/wild_encounter_cooldown.lua @@ -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")