diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index ceb2950b..d5a48d2b 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`. @@ -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. diff --git a/docs/modding.md b/docs/modding.md index 700ba7fd..9be6abee 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..654843cc --- /dev/null +++ b/docs/rfcs/0019-encounter-table-preview.md @@ -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. diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 9dd78e1b..571f30c6 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -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) diff --git a/src/world/gen2/WorldAPI.lua b/src/world/gen2/WorldAPI.lua index 5b4b5de2..2a5ceac7 100644 --- a/src/world/gen2/WorldAPI.lua +++ b/src/world/gen2/WorldAPI.lua @@ -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() diff --git a/tests/engine/gate_gen2_mod_api.lua b/tests/engine/gate_gen2_mod_api.lua index 099ec992..481f906a 100644 --- a/tests/engine/gate_gen2_mod_api.lua +++ b/tests/engine/gate_gen2_mod_api.lua @@ -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", 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")