Route B in CONTRIBUTING-mods.md asks an event/hook change for five things. This adds the two that were missing and fixes what the third turned up. link.battle_ended built its payload unconditionally. Route B is explicit that a new event must not allocate when nothing wants it, and every other emit in the engine already guards -- Runtime.wants now gates this one too, so an unsubscribed build runs the branch exactly as it did. world.talk was handing Runtime.call a closure built fresh on every A press, purely to have a fallthrough to pass. It is a file-local now, so an unhooked press allocates nothing it did not allocate before. The RFC covers motivation, the API delta with call sites, migration (nothing changes for existing mods), and verification. The backward-compatibility statement is in it: every item is a new name or a new optional argument, and example_mew_starter -- api 1, category = "GAMEPLAY", whole-species copy -- still loads, which run_modkit proves on every run. No registry or schema field is added, so gen_registry_docs has nothing to emit for this change. Running it does show pre-existing drift in docs/modding/reference/registries.md (timeFishGroups, an objects refinement) from earlier Schemas.lua edits that were never regenerated; that is not this branch's to carry, so it is left alone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7.0 KiB
RFC 0014: Mod-driven world actors and adopted link sessions
Status
Proposed.
Motivation
A mod can already spawn a runtime object with mod.world:spawnNpc and can
already start a link battle. It cannot make either of them behave like
something the mod itself owns.
Three walls, each of which stops a mode rather than inconveniencing it:
An actor cannot move on its own schedule. The only public way to animate a
spawned object is Handle:scriptMove, which queues onto
OverworldState.scriptMoves. A non-empty scriptMoves is how the overworld
knows a cutscene is running -- handleInput gates on it -- so anything
animated that way freezes the player's controls for as long as it walks. That
is correct for Oak marching into his lab and wrong for an ambient walker or a
networked player's ghost, which move continuously and must not lock anyone out.
A spawned object cannot answer the A press. Talking is resolved from the
map's text tables by TEXT_* id. A runtime object has no id, so the vanilla
path has nothing to say for it, and the mod that created it has no way to say
anything either.
A link battle cannot ride a connection the mod already has. LinkState
owns pairing, so a mode that already has a socket to its peer must either open
a second connection for the battle or reimplement lockstep. And when the
battle ends, cable rules leave the real party untouched, so the damage exists
only in the battle's own copies -- which are gone by the time the state
unwinds. A mode built on link battles cannot learn what the fight cost.
The immediate consumer is an overworld multiplayer mode, but nothing here is specific to it: the first two are wanted by any mod with an actor that moves itself, and the third by any mode that runs battles over its own transport -- a tournament ladder, a draft, a gauntlet.
The decision it extends
This adds nothing to the compatibility surface's shape; it extends the
additive, guarded seam convention that Route B in CONTRIBUTING-mods.md
documents, and is gated by the parity guarantee tests/engine/gate_meta_coverage.lua
enforces ("21-testing-and-ci: a parity gate for every extension point"; M14).
There is no in-repo D-number registry to amend; the consuming design lives outside this repository, as it did for RFC 0011.
Exact API delta
New hook: world.talk
mod.hooks:wrap("world.talk", function(next, ow, target)
-- ow = the OverworldState raising it
-- target = the object on the faced cell
if mine(target) then
say(target)
return -- the mod answered; the text path is skipped
end
return next(ow, target) -- anything else falls through unchanged
end)
Call site: OverworldState:interact, on the branch that has already resolved
an object on the faced cell (including across a counter) and confirmed it is
not mid-step and not the Pikachu follower. It runs before the map's text
tables are consulted. With no subscriber, Runtime.call invokes the vanilla
fallthrough, which is a file-local function rather than a per-press closure,
so an unhooked A press allocates nothing it did not allocate before.
New event: link.battle_ended
mod.events:on("link.battle_ended", function(ev)
-- ev = {
-- result = "win" | "lose" | "draw" | "ended",
-- myParty = lockstep copy of our party,
-- theirParty = lockstep copy of theirs,
-- peerName = string,
-- role = "host" | "guest",
-- }
end)
Call site: LinkState:update, in the battleRunning stage, once the battle
state has been popped and before exitWith unwinds the link stack. Guarded by
Runtime.wants("link.battle_ended"), so with no subscriber no payload table
is allocated and the branch runs exactly as before.
The party copies are the point of the event. They are the lockstep records the battle actually fought with, which is where the damage lives under cable rules.
New WorldAPI handle methods
handle:stepNow(dir) --> true when the step started
handle:canStep(dir) --> would stepNow land somewhere legal?
handle:placeAt(x, y, dir) --> snap, no animation, clears a step in flight
handle:isMoving() --> true while a step is animating
handle:setPassable(flag) --> may the player walk through this object?
stepNow sets the same per-tile state scriptMove does, without the queue and
therefore without the input lockout. It does not consult collision: the
intended caller is replaying a move already decided elsewhere (validated on a
peer, or authored), and re-judging it locally would let two copies of the same
actor disagree about where it is. canStep is the separate opinion for callers
that want it. setPassable sets the flag Collision.occupied already honours
for Yellow's companion Pikachu; a passable object still draws and still talks.
Two supporting changes
Game:startNewGame(opts) -- the title screen's NEW GAME closure, made callable,
with opts.intro = false to land directly in the world. A mode that issues its
own starting state has no use for the intro cutscene.
CodeEntry.new(shape) -- accepts an optional { length =, charset = }, so the
slot-scrub widget that enters a link code can also carry a room code or a
host:port address. Called with no argument it is byte-for-byte the previous
widget.
Migration and compatibility
Existing mods change nothing. Every item above is a new name or a new optional argument; no existing name, payload, signature, or default changes.
The v1 surface is unaffected: content.X:register/override/get, events:on,
hooks:wrap, mod.log, mod:read, the manifest v1 fields and
pokemon.before_give all behave as before. mods/example_mew_starter -- api 1,
category = "GAMEPLAY", whole-species copy -- loads unchanged, which
tests/run_modkit.lua proves on every run.
With no subscriber to either seam, Red, Blue, Yellow, Gold and Silver behave
exactly as they did: the A press reaches talkTo, the link battle unwinds
without building an event payload, and no handle method is reachable unless a
mod calls it.
Verification
tests/modkit/cases/world_talk.luadrives the realOverworldState:interactpath. It asserts the unhooked build first (the A press reachestalkTo), then that a mod owning the object suppresses the text path by not callingnext, that an object the mod ignores still falls through, and that an object mid-step raises no hook at all.tests/modkit/cases/link_battle_ended.luadrives the realLinkState:updatepath. It asserts that with nothing subscribed the event is not wanted (so no payload is built) and the battle still unwinds, then that a subscriber receives the result, the role, the peer name and both party copies including the damage the real party never took, from both sides of the cable.tests/engine/gate_hooks.luaandtests/engine/gate_events.luawalk the live catalog, so both seams are parity-gated structurally;gate_meta_coveragepasses 208/208 with both names covered and no DEBT entry added.
Deprecation etiquette
Nothing is removed, renamed, superseded or deprecated.