From f38b99caa6fce684103aa2d8768aad76f0a9031f Mon Sep 17 00:00:00 2001 From: BountyHunterKanden Date: Tue, 25 Aug 2026 21:25:15 -0500 Subject: [PATCH 1/2] engine: encounter.table -- preview the effective wild-encounter distribution (RFC 0019) Lets a mod ask what a map's wild-encounter odds look like right now, composed with any live encounter.table wrapper, without rolling the RNG. encounter.roll/encounter.species can only transform one draw; this is the missing read side. Guarded query method (mod.world:effectiveEncounters), no changes to the real roll path, docs and a public-API modkit case. --- docs/mod-api-gen2-compat.md | 20 ++- docs/modding.md | 28 +++ docs/rfcs/0019-encounter-table-preview.md | 194 ++++++++++++++++++++ src/world/WorldAPI.lua | 54 ++++++ src/world/gen2/WorldAPI.lua | 90 ++++++++++ tests/engine/gate_gen2_mod_api.lua | 2 +- tests/modkit/cases/encounter_table.lua | 207 ++++++++++++++++++++++ 7 files changed, 590 insertions(+), 5 deletions(-) create mode 100644 docs/rfcs/0019-encounter-table-preview.md create mode 100644 tests/modkit/cases/encounter_table.lua diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index ddc00b15..d61a2b4b 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -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`. @@ -821,6 +826,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. diff --git a/docs/modding.md b/docs/modding.md index 7c464abe..3bbf9df6 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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 diff --git a/docs/rfcs/0019-encounter-table-preview.md b/docs/rfcs/0019-encounter-table-preview.md new file mode 100644 index 00000000..8c834290 --- /dev/null +++ b/docs/rfcs/0019-encounter-table-preview.md @@ -0,0 +1,194 @@ +# 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 different question, and the same + reasoning that scoped fishing out of this RFC ("grass/surf is what needs it + now") applies just as well to a future hook for `chance`. +- `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 + +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. + +## 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. + +## Backward compatibility + +Additive only. No existing hook, event, registry, or manifest field changes +shape. `encounter.roll`, `encounter.species`, and every existing `WorldAPI` +method keep their exact current contracts. + +## Deprecation etiquette + +Nothing is removed, renamed, superseded, or deprecated. diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 7964dcc7..4c42b407 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -398,6 +398,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) diff --git a/src/world/gen2/WorldAPI.lua b/src/world/gen2/WorldAPI.lua index 52bdcfe6..e8788d6e 100644 --- a/src/world/gen2/WorldAPI.lua +++ b/src/world/gen2/WorldAPI.lua @@ -205,6 +205,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() diff --git a/tests/engine/gate_gen2_mod_api.lua b/tests/engine/gate_gen2_mod_api.lua index ad632b2c..d4094774 100644 --- a/tests/engine/gate_gen2_mod_api.lua +++ b/tests/engine/gate_gen2_mod_api.lua @@ -396,7 +396,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", diff --git a/tests/modkit/cases/encounter_table.lua b/tests/modkit/cases/encounter_table.lua new file mode 100644 index 00000000..6c6eee7b --- /dev/null +++ b/tests/modkit/cases/encounter_table.lua @@ -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") From 36431f4c2420ff984065dd85476c4ea76c29bb0a Mon Sep 17 00:00:00 2001 From: BountyHunterKanden Date: Wed, 26 Aug 2026 20:31:31 -0500 Subject: [PATCH 2/2] rfc: tighten 0019, merge migration/compat sections 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. --- docs/rfcs/0019-encounter-table-preview.md | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/docs/rfcs/0019-encounter-table-preview.md b/docs/rfcs/0019-encounter-table-preview.md index 8c834290..654843cc 100644 --- a/docs/rfcs/0019-encounter-table-preview.md +++ b/docs/rfcs/0019-encounter-table-preview.md @@ -98,9 +98,10 @@ On a hit, returns `{ chance, dist }`: 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 different question, and the same - reasoning that scoped fishing out of this RFC ("grass/surf is what needs it - now") applies just as well to a future hook for `chance`. + 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 @@ -137,13 +138,14 @@ 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 +## 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. +`effectiveEncounters` call, at its own pace. `encounter.table` is additive +only: no existing hook, event, registry, or manifest field changes shape. ## Verification @@ -183,12 +185,6 @@ distribution by sampling `encounter.roll` hundreds of times can switch to one matching the RFC-cited-in-prose convention the two most recent hook additions (RFC 0014, RFC 0015) already established there. -## Backward compatibility - -Additive only. No existing hook, event, registry, or manifest field changes -shape. `encounter.roll`, `encounter.species`, and every existing `WorldAPI` -method keep their exact current contracts. - ## Deprecation etiquette Nothing is removed, renamed, superseded, or deprecated.