Migration and Backward compatibility were two separate sections saying the same thing about encounter.roll/encounter.species being untouched, on top of a third mention in "The decision it extends" -- RFC 0014 and 0015 both fold this into one "Migration and compatibility" section, so this does the same and drops the redundant third restatement. Also fixes the chance bullet's "the same reasoning that scoped fishing out of this RFC" -- that reasoning was never actually stated anywhere in the doc, just referenced as if it had been. Reworded to stand on its own: encounter.fishing is a real, separate, existing hook this RFC doesn't touch, which is reason enough on its own.
10 KiB
RFC 0019: Encounter table preview (encounter.table)
Status
Proposed.
Motivation
encounter.roll and encounter.species let a mod transform one wild
encounter draw: RNG in, one {species, level} out. Neither gives a mod a way
to ask what the effective distribution looks like without actually rolling.
A mod that wants to display encounters, a route overlay or a "what's here"
panel, has two options today, and both are wrong. It can read the raw
Data.encounters[mapId].grass/.water table directly, which misses anything
a live encounter.roll wrapper is doing: a weather bias, a swarm, a
time-of-day shift. Or it can sample the real roll hundreds of times to
estimate the distribution, which is expensive, fires the real RNG, and still
only approximates the answer.
The immediate consumer is a route-info overlay mod that wants to show what's catchable on the current route, composed with whatever another installed mod is doing to the odds. Nothing here is specific to it; any reader mod wanting the same answer hits the same wall.
The decision it extends
This extends the additive, guarded seam convention Route B in
CONTRIBUTING-mods.md documents, alongside the existing encounter.roll/
encounter.species pair it sits next to. It does not change either of those
hooks' contracts: a real roll is untouched by this RFC, still exactly
Runtime.call("encounter.roll", ...) then Runtime.call("encounter.species", ...), no new call added to that path. encounter.table is a separate,
preview-only chain that only the new query method below ever calls.
There is no in-repo D-number registry to amend.
Exact API delta
New hook: encounter.table
mod.hooks:wrap("encounter.table", function(next, dist, ctx)
-- dist = { [speciesName] = weight, ... }, already includes every
-- vanilla slot for this map/terrain (and time of day, on Gen 2)
-- ctx.mapId, ctx.terrain: same fields encounter.roll/species already carry
-- ctx.preview = true always; ctx.rng is absent (see below)
if isWindy(ctx.mapId) then
dist = bias(dist, "FLYING")
end
return next(dist, ctx)
end)
Raised only from the new query method, never from a real roll. dist is the
value each link transforms and returns, the same shape as the map it
received. A wrapper that throws is caught and skipped by Hooks:call itself,
same as any other hook. A wrapper that returns nothing is a different failure
mode Hooks:call does not guard: an empty return makes the whole
Runtime.call return nothing, which would silently hand the query method a
nil dist instead of a table. The query method checks the result is a table
before accepting it and keeps the pre-hook dist otherwise, so a careless
wrapper degrades to "did nothing" rather than crashing whatever reads the
result.
ctx.rng is absent on a preview call. encounter.roll/encounter.species
both carry a working ctx.rng; a preview call has nothing to roll, so the
field is missing rather than stubbed. A wrapper shared between
encounter.roll and encounter.table that unconditionally calls
ctx.rng(...) errors loudly on the preview path instead of silently drawing
real randomness, which is the failure mode we want: a mod author who shares
logic between the two finds out immediately, at test time, not from a bug
report about phantom RNG draws during a menu render.
New query method: mod.world:effectiveEncounters(mapId, terrain, opts)
local info = mod.world:effectiveEncounters("ROUTE_29", "grass")
-- info = { chance = 0.6, dist = { RATTATA = 51, PIDGEY = 51, ... } } or nil
info, err = mod.world:effectiveEncounters(mapId, terrain, { daytime = "NITE" })
Returns nil, reason only when the call itself does not make sense: an
unrecognized terrain string, or (Gen 2) an opts.daytime that is not one of
"MORN"/"DAY"/"NITE"/"DARK". A map or terrain that genuinely has
nothing there, an unknown map id, a landlocked map asked about "water", a
zero-rate table, still answers the question asked: { chance = 0, dist = {} }.
Telling those two failure modes apart would need a live map registry, and Gen
1 and Gen 2 expose that differently (data.maps vs. the live World.maps),
so this stays a caller-facing distinction rather than an engine-plumbing one:
"nothing here" and "you asked about a place that doesn't exist" both read the
same, which is honest about what the query can and cannot tell you apart.
On a hit, returns { chance, dist }:
chanceis the vanilla probability that a step produces any encounter at all:grass.rate / 256on Gen 1 (or the def's own.buckets-scaled equivalent), the model-specific constant on Gen 2. It is not run throughencounter.tablein this RFC. Biasing whether an encounter happens at all, as opposed to which species it is, is a separate question, one this RFC doesn't answer for grass/water and doesn't need to:encounter.fishingalready handles rod encounters as its own hook, untouched here. Worth its own preview hook later if a mod actually needs one.distis a flat species-to-weight map, one entry per distinct species (slots repeating the same species are summed, since a caller asking "how likely is CATERPIE" doesn't care which slot it's in). Weights are the raw per-slot values the vanilla table already encodes (Gen 1: consecutive.buckets/encounterBucketsdifferences; Gen 2: consecutiveGRASS_SLOT_CHANCES/WATER_SLOT_CHANCESdifferences), not normalized to a probability. They are comparable within onedist, not across two different calls with a different basechance.terrainis"grass","water", or, Gen 1 only,"indoor", matching the real valuesctx.terrainalready carries onencounter.roll/encounter.species(the issue that opened this proposal said "surf"; the engine's own ctx has always said "water", and this RFC follows the engine)."indoor"reads the samegrasstable caves already resolve through today; that's an existing engine behavior, not new here, and is worth a caller knowing about rather than discovering it by comparing two supposedly different distributions that turn out identical.opts.daytime(Gen 2 only, optional) previews a specific time of day ("MORN","DAY","NITE"); omitted, it uses the map's actual current time, the same value a real roll would use right now. Gen 2 grass genuinely has three different distributions per map; a caller that wants "what does the player see this instant" gets it by default, and a caller that wants to preview a different time asks for it explicitly.
A Gen 2 swarm is reflected; a roamer is not, and those are two different
things in src/core/gen2/Roamers.lua. A swarm is a persistent, per-map
substitution of the whole table (Roamers.Swarm.tables), the same one a real
roll already draws from, so effectiveEncounters runs the base table through
it before building dist. A roaming legendary is a dynamic, per-step
override (Roamers.checkEncounter, called immediately before rollEncounter
on both the wild-step and sweet-scent paths) that replaces the map's
encounter outright the moment it fires, and this RFC does not try to predict
that. effectiveEncounters reports the swarm-aware static table composed
with encounter.table wrappers; a route overlay built on it should treat the
answer as "the map's own encounters," not "guaranteed to be what the next
step produces."
Migration and compatibility
Nothing changes for existing mods. encounter.roll and encounter.species
keep their exact current signatures, call sites, and behavior; no existing
mod using either needs to change anything. A mod that already estimates a
distribution by sampling encounter.roll hundreds of times can switch to one
effectiveEncounters call, at its own pace. encounter.table is additive
only: no existing hook, event, registry, or manifest field changes shape.
Verification
tests/modkit/cases/encounter_table.lua, through the public mod API:effectiveEncountersagainst a fixture map with no wrapper installed matches the vanilla table's own weights exactly, including summing a species that occupies more than one slot; a wrappedencounter.tablebiases the returneddistwithout ever callingctx.rng(asserted absent, not just unused); a wrapper that returns nothing leavesdistexactly as it was, proving the type-check guard actually holds (confirmed by briefly removing the guard and watching this same case crash with a nildist, before restoring it); an unknown map and a zero-rate map both answer{ chance = 0, dist = {} }rather than erroring; an invalidterrainoropts.daytimestring returnsnil, reason, and so does Gen 2's"indoor", which does not exist there the way Gen 1's cave quirk does; Gen 2's three realopts.daytimevalues each return the correct one of the three slot lists, and the omitted case resolves the save's actual current time viaClock.hour/Palettes.clockDaytime, pinned deterministically in the test withClock.setTimerather than depending on the host clock.tests/engine/gate_hooks.lua, the live-catalog parity gate:encounter.tablegets its no-mod-installed pass automatically once the literalRuntime.call("encounter.table", ...)string exists in source, no hand-written code needed for that part.tests/engine/gate_gen2_mod_api.lua:encounter.tablehas call sites in both agen2-pathed file and a non-gen2file (mirroringencounter.roll/encounter.species), so it needs an explicit entry in that gate'sGEN2_HOOKSlist, or the gate fails with instructions saying so.tests/engine/gate_meta_coverage.lua: covered by the new case file above; noDEBTentry needed.docs/mod-api-gen2-compat.md: a new line next to the existingencounter.roll/encounter.species/encounter.fishingentry, documentingencounter.tableandeffectiveEncountersthe same way, plus a line in the partial-coverage section noting the same swarm-yes/roamer-no split those hooks already have.docs/modding.md: a new "Effective wild-encounter distribution" section, matching the RFC-cited-in-prose convention the two most recent hook additions (RFC 0014, RFC 0015) already established there.
Deprecation etiquette
Nothing is removed, renamed, superseded, or deprecated.