mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user