From 0147e3d87b28807a809f834ee53f7ec1a787449b Mon Sep 17 00:00:00 2001 From: Bo Layer Date: Sun, 23 Aug 2026 03:17:27 -0600 Subject: [PATCH] feat(mod-api): add composable map occupancy seam --- data/scripts/story3.lua | 33 ++++- docs/modding.md | 50 +++++++ .../0013-map-occupancy-and-active-block.md | 131 ++++++++++++++++++ src/world/WorldAPI.lua | 30 ++++ 4 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 docs/rfcs/0013-map-occupancy-and-active-block.md diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index d2d4dbd5..787b0d99 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -2,6 +2,8 @@ -- ghost, elevators, the Game Corner coins/prizes, the SS Anne departure -- and the Hall of Fame record. Each cites its pokered source. +local Runtime = require("src.mods.Runtime") + local M = {} -- ------------------------------------------------------------------- @@ -909,17 +911,34 @@ M.VERMILION_DOCK = { local f = game.save.flags if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then -- 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 ow.map:setBlock(b.bx, b.by, b.water) end ow.map.renderer:rebuild() - 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)) + local occupancyAllowed = false + if Runtime.wantsHook("map.occupancy_allowed") then + local player = ow.player or {} + occupancyAllowed = Runtime.call("map.occupancy_allowed", + function() return false end, game, { + 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 -- VermilionDockSSAnneLeavesScript: only stepping OFF the ship -- triggers the departure (wDestinationWarpID == 1 in pokered) -- diff --git a/docs/modding.md b/docs/modding.md index 7c724404..73c8d853 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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 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 Companion UIs and alternate party screens can call diff --git a/docs/rfcs/0013-map-occupancy-and-active-block.md b/docs/rfcs/0013-map-occupancy-and-active-block.md new file mode 100644 index 00000000..1066ddc3 --- /dev/null +++ b/docs/rfcs/0013-map-occupancy-and-active-block.md @@ -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. diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 69b9eea2..a7abd4f3 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -117,6 +117,36 @@ function WorldAPI:current() facing = p and p.facing } 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. -- The same guard that makes opening a menu safe keeps scripts, transitions, -- movement and screens above the overworld from observing a mid-action swap.