Merge pull request #1825 from BountyHunterKanden/encounter-table-preview

Add API seam for mods to read/give information about altered encounter tables
This commit is contained in:
bryanthaboi
2026-09-01 13:50:11 -04:00
committed by GitHub
7 changed files with 586 additions and 5 deletions
+16 -4
View File
@@ -511,10 +511,15 @@ gains a field instead of the name gaining a prefix.
`world.block_replaced`, `world.boulder_moved`, `world.tod_changed`,
`world.object_toggled`, `flag.changed`; hooks `warp.destination`,
`movement.collision`, `movement.speed`, `encounter.roll`,
`encounter.species`, `encounter.fishing`, `world.tod`, `map.palette`,
`fieldmove.eligibility`. `flag.changed` carries the numeric `wEventFlags`
id under Gen 1's `name` key, which is the one payload difference the
numeric flag space forces.
`encounter.species`, `encounter.fishing`, `encounter.table`, `world.tod`,
`map.palette`, `fieldmove.eligibility`. `flag.changed` carries the numeric
`wEventFlags` id under Gen 1's `name` key, which is the one payload
difference the numeric flag space forces. `encounter.table` is raised only
from `mod.world:effectiveEncounters(mapId, terrain, opts)`, a read-only
query with no RNG and no live World required, not from the roll path
itself; `opts.daytime` previews a specific Gen 2 time of day
(`"MORN"`/`"DAY"`/`"NITE"`/`"DARK"`), defaulting to the save's real current
time when omitted.
- *Menus (`src/ui/gen2/`):* `ui.start_menu.items`, `ui.title_menu.items`,
`ui.options.rows`, `ui.party.submenu`, `ui.party.grid_navigation`,
`ui.naming.grid`, `ui.pc.items`, `ui.list_menu`, `transition.style`.
@@ -834,6 +839,13 @@ the same as "the hook sees everything":
Those three read row shapes that are not `{ species, level }` slot lists, so
a mod that reskins encounters misses headbutt trees, rock smash and the
roamers.
- `effectiveEncounters` (backing `encounter.table`) has the same roamer gap
for the same reason: it composes the static grass/water table with
`Roamers.Swarm.tables` (a swarm's persistent per-map substitution IS
reflected), but not with `Roamers.checkEncounter` (a roaming legendary's
dynamic, per-step override is not). A route overlay built on this query
should treat its answer as "the map's own encounters," not "guaranteed to
be what the next step produces."
- `src/ui/gen2/BattleState.lua` builds a flat `opts` for `Catching.attempt`
with no `data` in it, so a mod-registered ball is readable through
`Catching.recordFor` but is not yet resolved at the real throw site.
+28
View File
@@ -564,6 +564,34 @@ in any box, and `pokemon.caught` reports `destination = "mod"` so the mode can
find its own custody again. Anything falsy deposits as always, "But every BOX
is full!" included.
## Effective wild-encounter distribution
`encounter.roll` and `encounter.species` transform one wild-encounter draw;
neither gives a mod a way to ask what the distribution looks like without
rolling it. `encounter.table` and `mod.world:effectiveEncounters` are the read
side of that pair (RFC 0019): a hook that transforms a whole distribution
instead of one draw, and a query that runs it with no RNG and no side effects.
```lua
mod.hooks:wrap("encounter.table", function(next, dist, ctx)
-- dist = { [speciesName] = weight, ... }; ctx.mapId, ctx.terrain match
-- encounter.roll/encounter.species; ctx.preview = true; ctx.rng is absent
if myMode.bias(ctx.mapId) then dist = myMode.reweight(dist) end
return next(dist, ctx)
end)
local info = mod.world:effectiveEncounters("ROUTE_29", "grass")
-- info = { chance = 0.6, dist = { RATTATA = 51, PIDGEY = 51, ... } }
```
`terrain` is `"grass"`, `"water"`, or (Gen 1 only) `"indoor"`, the same values
`encounter.roll`'s own `ctx.terrain` already uses. On Gen 2, an optional third
argument, `{ daytime = "NITE" }`, previews a specific time of day instead of
the save's actual current one; grass genuinely has three different
distributions per map there. `effectiveEncounters` reflects an active Gen 2
swarm but not a roaming legendary, which overrides a single step at roll time
rather than the table itself.
## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it
+190
View File
@@ -0,0 +1,190 @@
# 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`
```lua
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)`
```lua
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 }`:
- `chance` is the vanilla probability that a step produces any encounter at
all: `grass.rate / 256` on Gen 1 (or the def's own `.buckets`-scaled
equivalent), the model-specific constant on Gen 2. It is not run through
`encounter.table` in 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.fishing`
already handles rod encounters as its own hook, untouched here. Worth its
own preview hook later if a mod actually needs one.
- `dist` is 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`/`encounterBuckets` differences; Gen 2: consecutive
`GRASS_SLOT_CHANCES`/`WATER_SLOT_CHANCES` differences), not normalized to a
probability. They are comparable within one `dist`, not across two
different calls with a different base `chance`.
- `terrain` is `"grass"`, `"water"`, or, Gen 1 only, `"indoor"`, matching the
real values `ctx.terrain` already carries on `encounter.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 same `grass` table 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:
`effectiveEncounters` against 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 wrapped `encounter.table`
biases the returned `dist` without ever calling `ctx.rng` (asserted
absent, not just unused); a wrapper that returns nothing leaves `dist`
exactly as it was, proving the type-check guard actually holds (confirmed
by briefly removing the guard and watching this same case crash with a
nil `dist`, before restoring it); an unknown map and a zero-rate map both
answer `{ chance = 0, dist = {} }` rather than erroring; an invalid
`terrain` or `opts.daytime` string returns `nil, 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 real `opts.daytime` values each return the correct
one of the three slot lists, and the omitted case resolves the save's
actual current time via `Clock.hour`/`Palettes.clockDaytime`, pinned
deterministically in the test with `Clock.setTime` rather than depending
on the host clock.
- `tests/engine/gate_hooks.lua`, the live-catalog parity gate: `encounter.table`
gets its no-mod-installed pass automatically once the literal
`Runtime.call("encounter.table", ...)` string exists in source, no
hand-written code needed for that part.
- `tests/engine/gate_gen2_mod_api.lua`: `encounter.table` has call sites in
both a `gen2`-pathed file and a non-`gen2` file (mirroring `encounter.roll`/
`encounter.species`), so it needs an explicit entry in that gate's
`GEN2_HOOKS` list, or the gate fails with instructions saying so.
- `tests/engine/gate_meta_coverage.lua`: covered by the new case file above;
no `DEBT` entry needed.
- `docs/mod-api-gen2-compat.md`: a new line next to the existing
`encounter.roll`/`encounter.species`/`encounter.fishing` entry, documenting
`encounter.table` and `effectiveEncounters` the 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.
+54
View File
@@ -399,6 +399,60 @@ function WorldAPI:getFlag(name)
return save and save.flags and save.flags[name]
end
local ENCOUNTER_TERRAIN = { grass = true, water = true, indoor = true }
-- The effective wild-encounter distribution for a map/terrain, composed with
-- any encounter.table wrapper, without touching the RNG. Unlike a real roll
-- (encounter.roll/encounter.species), this needs no live overworld: mapId is
-- an explicit argument, so a caller can ask about any map from a menu, not
-- only the one the player is standing on.
--
-- "indoor" (caves) has no table of its own -- it reads the same grass table
-- Encounter.roll already gives every cave floor; that is an existing engine
-- behavior, not something new here.
--
-- Weights are the raw per-slot values the vanilla table already encodes
-- (consecutive .buckets/encounterBuckets differences), not normalized to a
-- probability, and are only comparable within one dist. chance is the
-- separate vanilla probability that a step produces any encounter at all;
-- it does not run through encounter.table -- biasing whether an encounter
-- happens at all, as opposed to which species, is a different question this
-- RFC scopes out the same way it scopes out fishing.
function WorldAPI:effectiveEncounters(mapId, terrain, opts)
if not ENCOUNTER_TERRAIN[terrain] then
return nil, "invalid terrain: " .. tostring(terrain)
end
local data = self.game and self.game.data
local encDef = data and data.encounters and data.encounters[mapId]
local key = (terrain == "indoor") and "grass" or terrain
local slotDef = encDef and encDef[key]
local chance = (slotDef and tonumber(slotDef.rate) or 0) / 256
local dist = {}
if slotDef and chance > 0 and slotDef.slots then
local weights = slotDef.buckets
or FieldDefaults.constant(data, "encounterBuckets")
local prev = 0
for i, threshold in ipairs(weights) do
local slot = slotDef.slots[i]
if slot and slot.species then
dist[slot.species] = (dist[slot.species] or 0) + (threshold - prev)
end
prev = threshold
end
end
if Runtime.wantsHook("encounter.table") then
-- A wrapper that forgets to return anything makes Runtime.call itself
-- return nothing (Hooks:call unpacks an empty pcall result), which would
-- otherwise turn dist into nil here. Keep the pre-hook dist instead of
-- handing a caller a nil they will pairs() over and crash on.
local transformed = Runtime.call("encounter.table",
function(d) return d end, dist,
{ mapId = mapId, terrain = terrain, preview = true })
if type(transformed) == "table" then dist = transformed end
end
return { chance = chance, dist = dist }
end
-- active map only: this mutates the runtime Map and rebuilds the renderer.
-- A layout change that must survive a reload belongs in a maps patch.
function WorldAPI:replaceBlock(bx, by, block)
+90
View File
@@ -278,6 +278,96 @@ function WorldAPI:useFieldAction(id, opts)
return nil, "field action unavailable"
end
local ENCOUNTER_TERRAIN = { grass = true, water = true }
local DAYTIMES = { MORN = true, DAY = true, NITE = true, DARK = true }
-- Same contract as the Gen 1 arm's effectiveEncounters: the effective wild
-- encounter distribution for a map/terrain, composed with any
-- encounter.table wrapper, with no RNG and no live World required.
--
-- Grass is genuinely three different distributions per map, one per time of
-- day (Gold has no Gen 1 equivalent of this). opts.daytime previews a
-- specific one ("MORN"/"DAY"/"NITE", or "DARK" which reads as NITE, the same
-- fallback Encounter.grassSlot uses); omitted, this resolves the map's own
-- actual current time the same way a real roll would, via Clock/Palettes
-- rather than needing a live World instance.
--
-- The base table is run through Roamers.Swarm.tables first, the same
-- substitution a real roll draws from, so an active swarm is reflected here
-- too. An active ROAMING legendary is not: Roamers.checkEncounter overrides
-- a single step at roll time, and this is a static-table query -- the same
-- gap docs/mod-api-gen2-compat.md already documents for encounter.roll/
-- encounter.species.
function WorldAPI:effectiveEncounters(mapId, terrain, opts)
if not ENCOUNTER_TERRAIN[terrain] then
return nil, "invalid terrain: " .. tostring(terrain)
end
local game = self.game
local data = game and game.data
local encounters = data and data.encounters
local save = game and game.save
local tables = encounters
if encounters and save then
tables = require("src.core.gen2.Roamers").Swarm.tables(save, encounters, mapId)
end
local Encounter = require("src.battle.gen2.Encounter")
local dist = {}
local chance
if terrain == "water" then
local entry = tables and tables.water and tables.water[mapId]
chance = (entry and tonumber(entry.rate) or 0) / 256
if entry and chance > 0 and entry.slots then
local prev = 0
for i, cumulative in ipairs(Encounter.WATER_SLOT_CHANCES) do
local slot = entry.slots[i]
if slot and slot.species then
dist[slot.species] = (dist[slot.species] or 0) + (cumulative - prev)
end
prev = cumulative
end
end
else
local entry = tables and tables.grass and tables.grass[mapId]
local daytime = opts and opts.daytime
if daytime and not DAYTIMES[daytime] then
return nil, "invalid daytime: " .. tostring(daytime)
end
if not daytime then
local Clock = require("src.core.gen2.Clock")
local Palettes = require("src.world.gen2.Palettes")
daytime = save and Palettes.clockDaytime(Clock.hour(save)) or "DAY"
end
local key = (daytime == "DARK") and "NITE" or daytime
local rate = entry and entry.rates and (entry.rates[key] or entry.rates.DAY)
chance = (tonumber(rate) or 0) / 256
local slots = entry and entry.slots and entry.slots[key]
if slots and chance > 0 then
local prev = 0
for i, cumulative in ipairs(Encounter.GRASS_SLOT_CHANCES) do
local slot = slots[i]
if slot and slot.species then
dist[slot.species] = (dist[slot.species] or 0) + (cumulative - prev)
end
prev = cumulative
end
end
end
if Runtime.wantsHook("encounter.table") then
-- A wrapper that forgets to return anything makes Runtime.call itself
-- return nothing (Hooks:call unpacks an empty pcall result), which would
-- otherwise turn dist into nil here. Keep the pre-hook dist instead of
-- handing a caller a nil they will pairs() over and crash on.
local transformed = Runtime.call("encounter.table",
function(d) return d end, dist,
{ mapId = mapId, terrain = terrain, preview = true })
if type(transformed) == "table" then dist = transformed end
end
return { chance = chance, dist = dist }
end
-- The same read-only minimap contract as Gen 1, with Gold's object/event
-- visibility rules supplying the semantic markers.
function WorldAPI:mapOverview()
+1 -1
View File
@@ -399,7 +399,7 @@ local GEN2_HOOKS = {
-- overworld
"warp.destination", "movement.collision", "movement.speed",
"encounter.roll", "encounter.species", "encounter.fishing",
"world.tod", "map.palette", "fieldmove.eligibility",
"encounter.table", "world.tod", "map.palette", "fieldmove.eligibility",
-- menus and the battle intro
"ui.start_menu.items", "ui.title_menu.items", "ui.options.rows",
"ui.party.submenu", "ui.party.grid_navigation", "ui.naming.grid",
+207
View File
@@ -0,0 +1,207 @@
-- encounter.table lets a mod read the effective wild-encounter distribution
-- for a map/terrain, composed with any live encounter.table wrapper, with no
-- RNG and no side effects -- the read side encounter.roll/encounter.species
-- never had. Exercised through mod.world:effectiveEncounters on hand-built
-- fixture data for both generations; the roll hooks themselves are untouched.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.modkit")
local WorldAPI = require("src.world.WorldAPI")
local WorldAPI2 = require("src.world.gen2.WorldAPI")
local Clock = require("src.core.gen2.Clock")
local FIXTURE = {
["mods/encounter_probe/manifest.json"] = [[{
"id": "encounter_probe",
"name": "Encounter Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/encounter_probe/main.lua"] = [[
local mod = ...
mod.exports.calls = 0
mod.hooks:wrap("encounter.table", function(next, dist, ctx)
mod.exports.calls = mod.exports.calls + 1
mod.exports.ctx = ctx
mod.exports.sawRng = ctx.rng ~= nil
dist = next(dist, ctx)
local biased = {}
for species, weight in pairs(dist) do biased[species] = weight end
biased.MEW = 999
return biased
end)
]],
}
-- ------- Gen 1
local gen1Game = {
data = {
encounters = {
PALLET_TOWN = {
grass = { rate = 156, buckets = { 100, 200, 256 },
slots = { { level = 3, species = "PIDGEY" },
{ level = 3, species = "RATTATA" },
{ level = 4, species = "PIDGEY" } } },
water = { rate = 20, buckets = { 200, 256 },
slots = { { level = 10, species = "POLIWAG" },
{ level = 15, species = "TENTACOOL" } } },
},
DEAD_END = {
grass = { rate = 0, buckets = { 256 },
slots = { { level = 5, species = "NOTHING" } } },
},
},
},
}
local gen1 = WorldAPI.new(gen1Game, "test")
-- ------- no mod: vanilla weights, exactly
local vanilla = T.sdk.loadNone({})
local grass = gen1:effectiveEncounters("PALLET_TOWN", "grass")
T.check(grass ~= nil, "PALLET_TOWN grass returns a result")
T.eq(grass.chance, 156 / 256, "chance matches the vanilla rate")
T.eq(grass.dist.PIDGEY, 156, "PIDGEY sums both its slots (100 + 56)")
T.eq(grass.dist.RATTATA, 100, "RATTATA keeps its own slot's weight")
local indoor = gen1:effectiveEncounters("PALLET_TOWN", "indoor")
T.eq(indoor.chance, grass.chance, "indoor reuses grass's chance")
T.eq(indoor.dist.PIDGEY, grass.dist.PIDGEY, "and grass's distribution, exactly")
local water = gen1:effectiveEncounters("PALLET_TOWN", "water")
T.eq(water.chance, 20 / 256, "water has its own, separate chance")
T.eq(water.dist.POLIWAG, 200, "and its own distribution")
T.eq(water.dist.TENTACOOL, 56, "second water slot")
local dead = gen1:effectiveEncounters("DEAD_END", "grass")
T.eq(dead.chance, 0, "a zero-rate table reports zero chance")
T.eq(next(dead.dist), nil, "and an empty distribution, not an error")
local unknown = gen1:effectiveEncounters("NOWHERE", "grass")
T.eq(unknown.chance, 0, "an unknown map answers zero rather than nil")
T.eq(next(unknown.dist), nil, "with an empty distribution")
local badTerrain, err = gen1:effectiveEncounters("PALLET_TOWN", "lava")
T.eq(badTerrain, nil, "an invalid terrain returns nil")
T.check(err ~= nil, "and a reason")
vanilla.release()
-- ------- Gen 1, wrapped
local run = T.sdk.loadMods({ "mods/encounter_probe" },
{ fs = T.sdk.memfs(FIXTURE) })
T.eq(#run.errors, 0,
"the encounter probe loads clean (" .. tostring(run.errors[1]) .. ")")
local probe = run.loader.exports.encounter_probe
local wrapped = gen1:effectiveEncounters("PALLET_TOWN", "grass")
T.eq(wrapped.dist.MEW, 999, "the wrapper's bias comes through")
T.eq(wrapped.dist.PIDGEY, 156, "and the vanilla species are still there")
T.eq(probe.calls, 1, "the hook ran once")
T.eq(probe.ctx.mapId, "PALLET_TOWN", "ctx carries mapId")
T.eq(probe.ctx.terrain, "grass", "and terrain")
T.eq(probe.ctx.preview, true, "and preview = true")
T.eq(probe.sawRng, false, "ctx.rng is absent on a preview call")
run.release()
-- ------- a wrapper that forgets to return anything must not corrupt dist
local CARELESS_FIXTURE = {
["mods/careless_probe/manifest.json"] = [[{
"id": "careless_probe",
"name": "Careless Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/careless_probe/main.lua"] = [[
local mod = ...
mod.hooks:wrap("encounter.table", function(next, dist, ctx)
-- deliberately no return
end)
]],
}
local carelessRun = T.sdk.loadMods({ "mods/careless_probe" },
{ fs = T.sdk.memfs(CARELESS_FIXTURE) })
T.eq(#carelessRun.errors, 0, "the careless probe loads clean")
local safe = gen1:effectiveEncounters("PALLET_TOWN", "grass")
T.check(safe ~= nil, "a non-returning hook does not blow up the call")
T.eq(safe.dist.PIDGEY, 156, "and the pre-hook dist survives intact")
T.eq(safe.dist.RATTATA, 100, "same vanilla weights as with no mod at all")
carelessRun.release()
-- ------- Gen 2 (grass is 3 distributions per map, one per time of day;
-- water has no time split)
local gen2Game = {
data = {
encounters = {
grass = {
NEW_BARK_TOWN = {
rates = { MORN = 51, DAY = 51, NITE = 51 },
slots = {
MORN = { { level = 3, species = "HOOTHOOT" } },
DAY = { { level = 3, species = "PIDGEY" },
{ level = 4, species = "PIDGEY" } },
NITE = { { level = 3, species = "HOOTHOOT" } },
},
},
},
water = {
NEW_BARK_TOWN = { rate = 30,
slots = { { level = 10, species = "POLIWAG" } } },
},
},
},
save = {},
}
local gen2 = WorldAPI2.new(gen2Game, "test")
local vanilla2 = T.sdk.loadNone({})
-- GRASS_SLOT_CHANCES = {30,60,80,90,95,99,100}; a 2-slot fixture only fills
-- the first two cumulative steps (30, then 60-30=30), the rest go unclaimed.
local day = gen2:effectiveEncounters("NEW_BARK_TOWN", "grass",
{ daytime = "DAY" })
T.eq(day.chance, 51 / 256, "Gen 2 grass chance, DAY")
T.eq(day.dist.PIDGEY, 60, "both DAY slots sum (30 + 30)")
local morn = gen2:effectiveEncounters("NEW_BARK_TOWN", "grass",
{ daytime = "MORN" })
T.eq(morn.dist.PIDGEY, nil, "PIDGEY is a DAY-only species here")
T.eq(morn.dist.HOOTHOOT, 30, "MORN's own single slot")
local dark = gen2:effectiveEncounters("NEW_BARK_TOWN", "grass",
{ daytime = "DARK" })
T.eq(dark.dist.HOOTHOOT, 30, "DARK reads as NITE (same single slot as MORN)")
local waterGen2 = gen2:effectiveEncounters("NEW_BARK_TOWN", "water")
T.eq(waterGen2.chance, 30 / 256, "Gen 2 water chance")
T.eq(waterGen2.dist.POLIWAG, 60, "water's own single slot")
local badDaytime, dErr = gen2:effectiveEncounters("NEW_BARK_TOWN", "grass",
{ daytime = "TEATIME" })
T.eq(badDaytime, nil, "an invalid daytime returns nil")
T.check(dErr ~= nil, "and a reason")
local noIndoor, iErr = gen2:effectiveEncounters("NEW_BARK_TOWN", "indoor")
T.eq(noIndoor, nil, "Gen 2 has no indoor/cave quirk to reuse grass through")
T.check(iErr ~= nil, "so indoor is just an invalid terrain here")
Clock.setTime(gen2Game.save, 14, 0)
local now = gen2:effectiveEncounters("NEW_BARK_TOWN", "grass")
T.eq(now.dist.PIDGEY, day.dist.PIDGEY,
"omitted daytime resolves the save's actual current time (14:00 = DAY)")
vanilla2.release()
T.finish("encounter table")