feat(mod-api): add composable map occupancy seam

This commit is contained in:
Bo Layer
2026-08-23 03:17:27 -06:00
parent e88f2ef060
commit 0147e3d87b
4 changed files with 237 additions and 7 deletions
+26 -7
View File
@@ -2,6 +2,8 @@
-- ghost, elevators, the Game Corner coins/prizes, the SS Anne departure -- ghost, elevators, the Game Corner coins/prizes, the SS Anne departure
-- and the Hall of Fame record. Each cites its pokered source. -- and the Hall of Fame record. Each cites its pokered source.
local Runtime = require("src.mods.Runtime")
local M = {} local M = {}
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
@@ -909,17 +911,34 @@ M.VERMILION_DOCK = {
local f = game.save.flags local f = game.save.flags
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
-- the ship is long gone: erase her right away, and anyone who -- the ship is long gone: erase her right away, and anyone who
-- still lands here is sent back out past the guard -- still lands here is sent back out past the guard unless a mod
-- explicitly permits this occupied map state. This hook surrounds
-- only the ejection decision; the map's complete onEnter chain has
-- already run and the departed ship remains erased.
for _, b in ipairs(DOCK_SHIP_BLOCKS) do for _, b in ipairs(DOCK_SHIP_BLOCKS) do
ow.map:setBlock(b.bx, b.by, b.water) ow.map:setBlock(b.bx, b.by, b.water)
end end
ow.map.renderer:rebuild() ow.map.renderer:rebuild()
local TextBox = require("src.render.TextBox") local occupancyAllowed = false
game.stack:push(TextBox.new(game, if Runtime.wantsHook("map.occupancy_allowed") then
game.data.text._VermilionCitySailor1ShipSetSailText local player = ow.player or {}
or "The ship set sail.", function() occupancyAllowed = Runtime.call("map.occupancy_allowed",
ow:startWarpTo("VERMILION_CITY", 18, 29, "up") function() return false end, game, {
end)) mapId = "VERMILION_DOCK",
reason = "ss_anne_departed",
gameVersion = game.save and game.save.version,
x = player.cellX,
y = player.cellY,
}) == true
end
if not occupancyAllowed then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._VermilionCitySailor1ShipSetSailText
or "The ship set sail.", function()
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
end))
end
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship -- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
-- triggers the departure (wDestinationWarpID == 1 in pokered) -- -- triggers the departure (wDestinationWarpID == 1 in pokered) --
+50
View File
@@ -228,6 +228,56 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
read-only snapshots; mods choose which layers to render. Red and Gold expose read-only snapshots; mods choose which layers to render. Red and Gold expose
the same contract while applying their own object and event visibility rules. the same contract while applying their own object and event visibility rules.
### Active Gen 1 block checks
Red, Blue, and Yellow expose
`mod.world:activeBlockAt(mapId, blockX, blockY)`. It returns the numeric block
ID at one zero-based block coordinate only when `mapId` is the active map.
The value is a scalar snapshot: changing it cannot change the map. This lets a
mod compare a small runtime map signature before it applies a lawful authored
replacement, without reading the mutable map or ROM cache through engine
internals.
The method fails closed. Before an overworld exists it returns
`nil, "no overworld"`; for a different active map it returns
`nil, "map is not active"`; non-numeric, non-finite, or fractional coordinates
return `nil, "invalid block coordinates"`; and negative or out-of-range
coordinates return `nil, "block coordinates out of bounds"`. An unavailable
or malformed active block returns `nil, "block unavailable"`. The caller must
require every expected cell to match before changing presentation. This method
is Gen 1-only; Gold callers receive no parity promise for it.
### Conditional map occupancy
`map.occupancy_allowed` is a narrow Gen 1 hook around a map script's vanilla
decision to eject the player from an otherwise valid loaded map. Its first
call site is the post-departure `VERMILION_DOCK` branch. The ship has already
been erased when the hook runs, and the hook does not replace or suppress any
base or peer map handler.
The wrapper receives `(next, game, context)`. The dock context is a copied
`{ mapId = "VERMILION_DOCK", reason = "ss_anne_departed", gameVersion, x, y }`
record. Vanilla returns `false`. Return exactly `true` to allow the player to
remain; every other value denies occupancy and preserves the normal message
and warp. A composable wrapper calls downstream first and only adds its own
permission:
```lua
mod.hooks:wrap("map.occupancy_allowed", function(next, game, ctx)
local allowed = next(game, ctx)
local mine = ctx.mapId == "VERMILION_DOCK"
and ctx.reason == "ss_anne_departed"
and myPublicEligibilityCheck(game)
return allowed == true or mine == true
end)
```
With no wrapper, the hook allocates no context and vanilla behavior is byte-for-
byte unchanged. A throwing wrapper is isolated by the normal hook bus. A nil,
string, number, table, or other malformed final answer fails closed. Disabling
or uninstalling the permitting mod therefore restores vanilla ejection without
changing the S.S. Anne story flag or restoring the ship.
## Party ordering ## Party ordering
Companion UIs and alternate party screens can call Companion UIs and alternate party screens can call
@@ -0,0 +1,131 @@
# RFC 0013: Conditional map occupancy and active-block reads
## Status
Proposed.
## Motivation
The Gen 1 Vermilion Dock script correctly ejects a player who enters after the
S.S. Anne has departed. A content mod can add a city-side route back to that
empty harbor, but it cannot preserve the dock visit: replacing the complete
dock handler would discard vanilla behavior and peer handlers, while an added
handler cannot cancel the base handler's ejection.
A mod that changes one active map block also needs to prove that it is looking
at the expected Red, Blue, or Yellow layout before it acts. `mapOverview()` is
intentionally presentation-oriented and does not expose block identity.
Requiring internal `Map` state or generated ROM data would cross the public mod
boundary and make a wrong-version edit difficult to fail closed.
## Decision and plan extended
This extends Route B in `CONTRIBUTING-mods.md`: new behavior is additive,
ordinary hook composition remains the authority, an empty hook chain is a
provable no-op, and mods receive copied or scalar data rather than mutable
engine state. It supports the approved Mew-under-the-truck implementation plan
without adding any Mew-specific rule, asset, flag, or content to the engine.
## Exact API delta
### `map.occupancy_allowed`
The post-departure `VERMILION_DOCK` script calls this hook after it replaces the
ship blocks with water and immediately before it would display the departure
message and warp the player to Vermilion City.
A wrapper has this shape:
```lua
function(next, game, context) -> boolean
```
The context is a new table with these fields:
| Field | Meaning |
|---|---|
| `mapId` | `"VERMILION_DOCK"` at this call site |
| `reason` | Stable reason key `"ss_anne_departed"` |
| `gameVersion` | Active save version (`red`, `blue`, or `yellow`) when present |
| `x`, `y` | Current player cell coordinates when present |
Vanilla returns `false`. The player remains only when the final chain result is
exactly `true`. Absent, throwing, or malformed wrappers therefore preserve
ejection. A wrapper composes by calling `next(game, context)` and returning
true when either downstream or its own narrow rule permits occupancy. The hook
does not replace `MapScripts` registration, merging, or dispatch, and does not
change the departure flag or reconstruct the ship.
The call is guarded by `Runtime.wantsHook`, so an empty chain allocates no
context and follows the prior branch exactly.
### `WorldAPI:activeBlockAt`
Gen 1's public `mod.world` facade adds:
```lua
activeBlockAt(mapId, blockX, blockY) -> blockId
| nil, reason
```
`mapId` must equal the active map ID. Coordinates are finite, integral,
zero-based block coordinates. A successful result is a numeric scalar copied
from the active runtime map. The method never returns the map's mutable block
array and never writes game or save state.
Failure reasons are stable:
| Condition | Reason |
|---|---|
| No active overworld map | `no overworld` |
| `mapId` differs from the active map | `map is not active` |
| Coordinate has the wrong type, is non-finite, or is fractional | `invalid block coordinates` |
| Coordinate is negative or outside the active map | `block coordinates out of bounds` |
| Active block data is absent or malformed | `block unavailable` |
Requiring the expected map ID and rejecting all ambiguous input lets a mod
compare every cell in its version-specific signature before it calls an
existing mutation API. Red, Blue, and Yellow each use their own loaded map
data. Gold does not gain this method in this RFC.
## Migration and compatibility
Existing mods change nothing. No hook, event, registry, manifest field, save
field, map handler, or WorldAPI method is removed or renamed. With no hook
subscriber the departed-dock behavior is unchanged. Existing callers cannot
invoke the new WorldAPI method accidentally.
The occupancy answer is not persisted by the engine. Disabling or uninstalling
a mod removes its wrapper through normal owner cleanup, so a later dock entry
uses vanilla ejection. The engine writes no new save state and neither API
returns or serializes ROM or save data.
## Verification requirements
The parity gate must prove:
- vanilla departed-dock message and warp remain with no subscriber;
- one permission wrapper can allow occupancy without removing the ship-erasure
work or any map handler;
- multiple cooperative wrappers preserve downstream permission;
- absent, throwing, nil, false, and malformed hook answers fail closed;
- Red, Blue, and Yellow contexts keep their version identity separate;
- disabling or removing the owner restores vanilla behavior without a save
migration;
- `activeBlockAt` accepts only the active map and valid in-range integral
coordinates, returns a scalar, and never mutates map or save state; and
- the test fixtures and resulting changes contain no ROM or save payload.
The hook must be driven through a real public `hooks:wrap` chain. The block API
must be exercised through a public `WorldAPI` instance. Tests must not replace
the production map handler with a test-only implementation.
## Docs with the change
`docs/modding.md` documents both contracts, their failure behavior, the
cooperative wrapper pattern, and the Gen 1-only block method. No registry or
schema changes occur, so generated registry documentation is unchanged.
## Deprecation etiquette
Nothing is removed, superseded, or deprecated.
+30
View File
@@ -117,6 +117,36 @@ function WorldAPI:current()
facing = p and p.facing } facing = p and p.facing }
end end
local function validBlockCoordinate(value)
return type(value) == "number" and value == value
and value ~= math.huge and value ~= -math.huge
and value == math.floor(value)
end
-- Read one block from the active Gen 1 map without exposing the mutable block
-- array. Requiring the expected map id makes a stale signature fail closed
-- if a warp or reload moved the player before the caller completed its check.
-- Block coordinates are zero-based, matching replaceBlock.
function WorldAPI:activeBlockAt(mapId, bx, by)
local ow = self:overworld()
if not ow or not ow.map then return nil, NO_OVERWORLD end
local map = ow.map
if map.id ~= mapId then return nil, "map is not active" end
if not validBlockCoordinate(bx) or not validBlockCoordinate(by) then
return nil, "invalid block coordinates"
end
local def = map.def
if not def or type(def.width) ~= "number" or type(def.height) ~= "number"
or bx < 0 or by < 0 or bx >= def.width or by >= def.height then
return nil, "block coordinates out of bounds"
end
local blockId = map:blockAt(bx, by)
if not validBlockCoordinate(blockId) or blockId < 0 then
return nil, "block unavailable"
end
return blockId
end
-- Companion UIs may offer party ordering while the player is in free roam. -- Companion UIs may offer party ordering while the player is in free roam.
-- The same guard that makes opening a menu safe keeps scripts, transitions, -- The same guard that makes opening a menu safe keeps scripts, transitions,
-- movement and screens above the overworld from observing a mid-action swap. -- movement and screens above the overworld from observing a mid-action swap.