mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
deca93582c
Completes the pret/pokefirered parity for wild encounter rate gating,
on top of the cooldown port in b84c2c15.
Ports DoWildEncounterRateTest as Encounters.encounterRate(): *16, bike
*80/100, banked failure rate +buff*16/200, flute, Cleanse Tag *2/3,
ability (Stench /2, Illuminate *2), then the 1600 clamp last -- pret's
exact order, which the modifier-order tests pin down.
Ports AddToWildEncounterRateBuff: a failed rate test (or a rate test
that passes but picks no slot) banks the area rate; an active Repel
zeroes the bank. Without this, encounters were still flat-rate rather
than ramping after near-misses.
Also ports the Mach/Acro bike *80/100 modifier.
End-to-end goldens on Route 1 (rate 21, 4000 steps) shift 397->401
with the cooldown and 843->858 without. That matches pret's steady
state analytically: a 6-step cooldown followed by p~0.112 as the bank
ramps gives a cycle of (1-0.94^6)/0.112+6 ~ 10.3 steps, ~10%/step.
The observed 401/4000 = 10.03% confirms it.
tests/game3_rng_test.lua now resets rate modifiers per replay, since
the banked rate makes sequences history-dependent; a map load resets
in-game.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
331 lines
13 KiB
Lua
331 lines
13 KiB
Lua
-- 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".
|
|
--
|
|
-- DoWildEncounterRateTest carries the rest of the same function family: the
|
|
-- bike and banked-failure rate modifiers, plus the flute / Cleanse Tag /
|
|
-- ability modifiers the cooldown also honours.
|
|
--
|
|
-- 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")
|
|
|
|
-- ------------------------------------------------- rate test modifiers
|
|
|
|
-- pret DoWildEncounterRateTest applies the same modifiers to the roll's
|
|
-- threshold, in 1/1600ths, so they have to be pinned separately from the
|
|
-- cooldown's minSteps.
|
|
print("[test] rate test modifiers (pret DoWildEncounterRateTest)")
|
|
local function rateOf(r) return Encounters.encounterRate(r) end
|
|
|
|
setMods(nil, nil)
|
|
eq(rateOf(21), 336, "rate 21 -> 21*16")
|
|
eq(rateOf(0), 0, "rate 0 -> no threshold")
|
|
eq(rateOf(100), 1600, "rate 100 saturates at MAX_ENCOUNTER_RATE")
|
|
|
|
setMods("white", nil)
|
|
eq(rateOf(21), 504, "White Flute raises the threshold by half")
|
|
setMods("black", nil)
|
|
eq(rateOf(21), 168, "Black Flute halves the threshold")
|
|
|
|
setMods(nil, { { species = 1, level = 5, item = 190 } })
|
|
eq(rateOf(21), 224, "Cleanse Tag cuts the threshold to two thirds")
|
|
|
|
setMods(nil, { { species = 1, level = 5, abilityId = 1 } })
|
|
eq(rateOf(21), 168, "Stench halves the threshold")
|
|
setMods(nil, { { species = 1, level = 5, abilityId = 35 } })
|
|
eq(rateOf(21), 672, "Illuminate doubles the threshold")
|
|
|
|
-- pret clamps after the ability mod, so Illuminate on a saturated rate stays
|
|
-- at MAX_ENCOUNTER_RATE instead of overflowing.
|
|
setMods(nil, { { species = 1, level = 5, abilityId = 35 } })
|
|
eq(rateOf(100), 1600, "the clamp runs after the ability modifier")
|
|
|
|
-- Modifier order is observable through the integer divisions: pret applies
|
|
-- flute -> Cleanse Tag -> ability, and reordering changes the result.
|
|
setMods("white", { { species = 1, level = 5, item = 190 } })
|
|
eq(rateOf(5), 80, "flute before Cleanse Tag (reversed order gives 79)")
|
|
setMods(nil, { { species = 1, level = 5, item = 190, abilityId = 35 } })
|
|
eq(rateOf(7), 148, "Cleanse Tag before ability (reversed order gives 149)")
|
|
|
|
Space.store, Runtime.session = prevStore, prevSession
|
|
|
|
-- ------------------------------------------------- rate buff / bike
|
|
|
|
-- pret AddToWildEncounterRateBuff banks the rate of a failed roll, and the
|
|
-- Mach/Acro bike scales the threshold down by 20%.
|
|
print("[test] banked failure rate + bike")
|
|
local function rollLand()
|
|
return Encounters.rollLand("ROUTE_1", nil, false)
|
|
end
|
|
|
|
Rng.SeedRng(0xC0DE)
|
|
Rng.SeedWildEncounterRng(1)
|
|
reset()
|
|
eq(Encounters._encounterRateBuff, 0, "buff starts empty")
|
|
eq(rollLand(), nil, "seed 1: the roll fails")
|
|
eq(Encounters._encounterRateBuff, 21, "a failed roll banks the area rate")
|
|
eq(Encounters.encounterRate(21), 337, "the banked rate lifts the threshold")
|
|
|
|
-- A cooldown denial returns before the rate test, so it banks nothing.
|
|
reset()
|
|
Encounters.handleCooldown("land", 21)
|
|
eq(Encounters._encounterRateBuff, 0, "a cooldown denial banks nothing")
|
|
|
|
-- So does the first-step-into-grass behaviour gate (pret returns before
|
|
-- DoWildEncounterRateTest there too).
|
|
Rng.SeedRng(2)
|
|
Rng.SeedWildEncounterRng(0xBEEF)
|
|
reset()
|
|
eq(Encounters.rollLand("ROUTE_1", nil, true), nil, "the behaviour gate denies")
|
|
eq(Encounters._encounterRateBuff, 0, "the behaviour gate banks nothing")
|
|
|
|
-- Starting a battle zeroes it again (pret sets encounterRateBuff = 0).
|
|
Rng.SeedRng(0xC0DE)
|
|
Rng.SeedWildEncounterRng(2)
|
|
reset()
|
|
check(rollLand() ~= nil, "seed 2: the roll lands")
|
|
eq(Encounters._encounterRateBuff, 0, "a landed encounter clears the buff")
|
|
|
|
-- A Repel zeroes the bank instead of growing it.
|
|
reset()
|
|
Rng.SeedWildEncounterRng(1)
|
|
Runtime.session = { repelSteps = 100 }
|
|
eq(rollLand(), nil, "the roll still fails under a Repel")
|
|
eq(Encounters._encounterRateBuff, 0, "an active Repel clears the bank")
|
|
Runtime.session = prevSession
|
|
|
|
-- The bank is what makes a long dry spell slowly likelier, so a big bank has
|
|
-- to move the threshold materially (pret: buff * 16 / 200).
|
|
reset()
|
|
Encounters._encounterRateBuff = 2500
|
|
eq(Encounters.encounterRate(21), 536, "banked 2500 adds 200 to the threshold")
|
|
reset()
|
|
|
|
-- pret TestPlayerAvatarFlags(PLAYER_AVATAR_FLAG_MACH_BIKE | ACRO_BIKE).
|
|
local Player = require("src.core.game3.player")
|
|
local prevBiking = Player.biking
|
|
Player.biking = true
|
|
eq(Encounters.encounterRate(21), 268, "the bike cuts the threshold to 80%")
|
|
Player.biking = false
|
|
eq(Encounters.encounterRate(21), 336, "on foot the threshold is unscaled")
|
|
Player.biking = prevBiking
|
|
|
|
-- ---------------------------------------------------------------- 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, 401, "4000 steps with the cooldown -> 401 encounters")
|
|
eq(withoutCooldown, 858, "4000 steps without it -> 858 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")
|