From dd0f0e08921e035cecf762787ecc115ffb52c511 Mon Sep 17 00:00:00 2001 From: "DESKTOP-8SRFDDM\\cam95" Date: Sat, 22 Aug 2026 21:21:02 -0500 Subject: [PATCH 1/3] engine: seams for mod-driven world actors and link sessions Five small additions. None of them mention the mod that prompted them. WorldAPI handles gain stepNow, canStep, placeAt, isMoving and setPassable. scriptMove queues onto OverworldState.scriptMoves, and a non-empty scriptMoves is how the overworld knows a cutscene is running, so it gates handleInput -- an actor animated that way freezes the player's controls for as long as it walks. Right for Oak marching to his lab, wrong for anything moving on its own schedule. stepNow sets the same per-tile state without the queue. It skips the collision check on purpose: the caller is replaying a move that was decided somewhere else, and re-judging it here would let the two copies disagree about where the actor is. canStep is there for callers that do want the map's opinion. OverworldController raises world.talk around the NPC talk path. An object from spawnNpc has no TEXT_* id, so the vanilla path has nothing to say for one; a mod that owns the object wraps this and does not call next. LinkState.newFromSession adopts a transport that is already paired, so a mode can tunnel a battle through a connection it already has rather than opening a second one. The hello and fingerprint exchange still runs. When the battle ends, link.battle_ended carries the result and both lockstep party copies -- cable rules leave the real party alone, so that is the only place the damage exists. Game:startNewGame(opts) is the title's NEW GAME closure made callable, with opts.intro=false to land straight in the world. CodeEntry.new takes an optional {length=, charset=} so the same widget can enter an address or a room code. Co-Authored-By: Claude Fable 5 --- src/core/Game.lua | 47 +++++++++++++-------- src/link/CodeEntry.lua | 69 ++++++++++++++++++++++++------- src/link/LinkState.lua | 54 +++++++++++++++++++++++- src/world/OverworldController.lua | 8 +++- src/world/WorldAPI.lua | 67 ++++++++++++++++++++++++++++++ 5 files changed, 211 insertions(+), 34 deletions(-) diff --git a/src/core/Game.lua b/src/core/Game.lua index b1b730b9..bbe9c9eb 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -147,29 +147,42 @@ function Game:bootConfig() return boot end +-- NEW GAME, as a call: a fresh skeleton (through save.new_game, so a mod +-- can reshape it), the overworld at the skeleton's spawn, and the intro +-- screen on top. The title menu's NEW GAME row is this; a mod that starts a +-- game on its own terms -- a match, a challenge mode -- calls it directly. +-- +-- opts.intro = false skips the newGame screen (Oak's speech) so the player +-- lands straight in the world; the skeleton then has to carry a name and a +-- party, which is the caller's job via save.new_game. +function Game:startNewGame(opts) + local OverworldState = require("src.world.OverworldController") + while self.stack:top() do self.stack:pop() end + -- New Game keeps the standalone options.lua preferences + self.sessionStartedAt = os.time() + self.save = SaveData.newGame(self:bootConfig()) + -- no bucket carry-over: mod state from an abandoned session must + -- not leak into a fresh slot; mods seed via save.created instead + self:adoptSave(self.save) + ModRuntime.emit("save.created", { save = self.save }) + self:applyOptions(self.save.options) + self.stack:push(OverworldState, self.save.player.map, + self.save.player.x, self.save.player.y, + self.save.player.facing, + { via = "boot", freshBoot = true }) + if not (opts and opts.intro == false) then + Screens.push(self, bootScreens(self).newGame or "OakSpeech", + function() end) + end +end + -- the title screen with its NEW GAME / CONTINUE wiring; used at boot -- and by the START-menu QUIT confirmation function Game:makeTitleState() local OverworldState = require("src.world.OverworldController") local factory = Screens.get(self, bootScreens(self).title or "TitleState") local title = factory.new(self, { - onNewGame = function() - while self.stack:top() do self.stack:pop() end - -- New Game keeps the standalone options.lua preferences - self.sessionStartedAt = os.time() - self.save = SaveData.newGame(self:bootConfig()) - -- no bucket carry-over: mod state from an abandoned session must - -- not leak into a fresh slot; mods seed via save.created instead - self:adoptSave(self.save) - ModRuntime.emit("save.created", { save = self.save }) - self:applyOptions(self.save.options) - self.stack:push(OverworldState, self.save.player.map, - self.save.player.x, self.save.player.y, - self.save.player.facing, - { via = "boot", freshBoot = true }) - Screens.push(self, bootScreens(self).newGame or "OakSpeech", - function() end) - end, + onNewGame = function() self:startNewGame() end, onContinue = function() local loaded, recovered = SaveData.load() if loaded then diff --git a/src/link/CodeEntry.lua b/src/link/CodeEntry.lua index f0335662..23c74241 100644 --- a/src/link/CodeEntry.lua +++ b/src/link/CodeEntry.lua @@ -1,28 +1,63 @@ --- Shared 6-slot room-code entry widget: the digit-scrub interaction --- LinkState's own `ipDigits`/`addrPos` already uses for IP entry, over the --- Crockford-32-style alphabet pokeserver room/tournament codes are drawn --- from (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read --- aloud or handwritten never has to be checked twice). +-- Shared slot-scrub entry widget: the digit-scrub interaction LinkState's +-- own `ipDigits`/`addrPos` already uses for IP entry, over the Crockford-32 +-- style alphabet pokeserver room/tournament codes are drawn from +-- (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read aloud or +-- handwritten never has to be checked twice). +-- +-- The Gen 1 naming grid cannot stand in for this: it has no digits at all +-- (data/text/alphabets.asm is letters and punctuation), so a room code or +-- an address typed there would be unenterable. That is what this exists +-- for. +-- +-- new() takes an optional {length=, charset=} so the same interaction can +-- carry something other than a room code -- a dotted IP over "0123456789.", +-- say. Both default to the room-code shape, so existing callers are +-- unaffected and CodeEntry.LENGTH / CodeEntry.CHARSET still describe them. local CodeEntry = {} CodeEntry.CHARSET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" CodeEntry.LENGTH = 6 -function CodeEntry.new() +-- state carries its own length/charset so a caller holding two widgets of +-- different shapes cannot have one read the other's alphabet +function CodeEntry.new(opts) + local charset = (opts and opts.charset) or CodeEntry.CHARSET + local length = (opts and opts.length) or CodeEntry.LENGTH local chars = {} - for i = 1, CodeEntry.LENGTH do chars[i] = 1 end -- index into CHARSET, 1-based - return { chars = chars, pos = 1 } + for i = 1, length do chars[i] = 1 end -- index into charset, 1-based + return { chars = chars, pos = 1, charset = charset, length = length } end -local N = #CodeEntry.CHARSET +-- Seed the slots from an existing string: prefilling the LAN address means +-- the player scrubs the last octet instead of all twelve digits. Anything +-- not in the charset lands on slot 1's character. +-- Slots past the end of the seed -- and any character the charset does not +-- carry -- land on the charset's blank where it has one, so seeding a +-- 15-slot address widget with "192.168.1.40" reads back as that address and +-- not as "192.168.1.40000". A charset with no blank (the room code's) has +-- nowhere to put one, so those fall back to the first character as before. +function CodeEntry.fromText(text, opts) + local state = CodeEntry.new(opts) + local blank = state.charset:find(" ", 1, true) or 1 + for i = 1, state.length do + local ch = tostring(text or ""):sub(i, i) + state.chars[i] = (ch ~= "" and state.charset:find(ch, 1, true)) or blank + end + return state +end + +local function charsetOf(state) return state.charset or CodeEntry.CHARSET end +local function lengthOf(state) return state.length or CodeEntry.LENGTH end function CodeEntry.up(state) - state.chars[state.pos] = state.chars[state.pos] % N + 1 + local n = #charsetOf(state) + state.chars[state.pos] = state.chars[state.pos] % n + 1 end function CodeEntry.down(state) - state.chars[state.pos] = (state.chars[state.pos] - 2) % N + 1 + local n = #charsetOf(state) + state.chars[state.pos] = (state.chars[state.pos] - 2) % n + 1 end function CodeEntry.left(state) @@ -30,14 +65,18 @@ function CodeEntry.left(state) end function CodeEntry.right(state) - state.pos = math.min(CodeEntry.LENGTH, state.pos + 1) + state.pos = math.min(lengthOf(state), state.pos + 1) +end + +-- the character in slot i, which is what a draw loop wants +function CodeEntry.charAt(state, i) + local charset = charsetOf(state) + return charset:sub(state.chars[i], state.chars[i]) end function CodeEntry.text(state) local out = {} - for i = 1, CodeEntry.LENGTH do - out[i] = CodeEntry.CHARSET:sub(state.chars[i], state.chars[i]) - end + for i = 1, lengthOf(state) do out[i] = CodeEntry.charAt(state, i) end return table.concat(out) end diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index c2464beb..44ae1841 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -112,6 +112,32 @@ function LinkState.newJoinOnline(game, code) return self end +-- Adopt a transport that is ALREADY paired and skip the connect UI: an +-- overworld multiplayer session handing one pair of players off to a battle +-- or a trade. The caller has settled which mode and which side hosts, so +-- all that is left is the hello exchange every link session runs before it +-- commits -- the fingerprint/mod compatibility check still gets its say, +-- exactly as it would have on the LAN or ONLINE path. +-- +-- `transport` is anything Session accepts (update/poll/send/close plus the +-- .paired/.closed/.error fields), which is what lets a mod route a battle +-- over a channel of its own. Ownership transfers with it: exitWith closes +-- the session, so the caller's transport is done once this state unwinds. +-- +-- opts.forceLevel the level rule, normally chosen on the battleOptions +-- screen; an adopted session has no menu to pick it on +function LinkState.newFromSession(game, transport, mode, isHost, opts) + local self = LinkState.new(game) + self.net = Session.new(transport, { role = isHost and "host" or "guest", + kind = "link" }) + self.adopted = true + self.adoptedMode, self.adoptedHost = mode, isHost and true or false + self.forceLevel = opts and opts.forceLevel or nil + self.stage = "adopted" + self:sendHello(isHost and mode or nil) + return self +end + function LinkState:exitWith(message, reason) DiscordPresence.setJoinCode(nil) self.game.linkSession = nil -- back to the player's own GAME SPEED @@ -203,8 +229,9 @@ function LinkState:decideCompat(mode, isHost) mods = peer and peer.mods, fingerprint = peer and peer.fingerprint }, }) if self.verdict == "full" or self.verdict == "vanilla_peer" then - if isHost and mode == "battle" then + if isHost and mode == "battle" and not self.adopted then -- host picks the level rule now, before the parties are exchanged + -- (an adopted session had it passed in; see newFromSession) self.stage = "battleOptions" else self:startMode(mode, isHost) @@ -243,6 +270,16 @@ function LinkState:update(dt) end end + -- handed over from an already-paired session (LinkState.newFromSession): + -- both hellos are in flight, and the first one to land settles compat and + -- drops us straight into the agreed mode + if self.stage == "adopted" then + if self:pollHello() then + self:decideCompat(self.adoptedMode, self.adoptedHost) + end + return + end + if self.stage == "menu" then if input:wasPressed("down") then self.index = self.index % 3 + 1 @@ -497,11 +534,26 @@ function LinkState:update(dt) return end self.game.stack:push(battle) + self.battle = battle self.stage = "battleRunning" end elseif self.stage == "battleRunning" then if self.game.stack:top() == self then + -- the lockstep copies carry the damage the real party never takes + -- (cable rules), so a mode that wants it -- a tournament ladder, a + -- battle royale -- reads it from here before the state unwinds + local battle = self.battle + if battle then + Runtime.emit("link.battle_ended", { + result = battle.result or "ended", + myParty = battle.playerParty, + theirParty = battle.enemyParty, + peerName = self.peerName, + role = self.isHost and "host" or "guest", + }) + end + self.battle = nil self:exitWith(nil) -- battle finished end end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index e0391b3f..47dda17e 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1993,7 +1993,13 @@ function OverworldState:interact() -- talk() lands the follower on its cell first. require("src.world.PikachuFollower").talk(Game, self, npc) elseif not npc.moving then - self:talkTo(npc) + -- world.talk: the A press on an object, before the map's text tables + -- get it. A runtime object a mod spawned (WorldAPI:spawnNpc) carries + -- no TEXT_* id, so the vanilla path has nothing to say for it; a mod + -- that owns the object wraps this and simply does not call next(). + -- Everything else falls straight through to talkTo as before. + Runtime.call("world.talk", function(ow, target) ow:talkTo(target) end, + self, npc) end interacted(self, fx, fy, "npc", npc) return diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 69b9eea2..d4f3d800 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -5,6 +5,7 @@ -- quiet no-op, never a crash. Reaching into OverworldState internals -- stays unsupported; anything a mod legitimately needs belongs here. +local Collision = require("src.world.Collision") local Logger = require("src.core.Logger") local FieldDefaults = require("src.world.FieldDefaults") local Map = require("src.world.Map") @@ -407,6 +408,72 @@ function Handle:position() return self.npc.cellX, self.npc.cellY end +-- Walk one tile starting NOW, outside the scripted-movement queue. +-- +-- scriptMove queues onto OverworldState.scriptMoves, and a non-empty +-- scriptMoves is how the overworld knows a cutscene is running -- it gates +-- handleInput (OverworldController "local scripted = ... #self.scriptMoves > +-- 0"), so an actor animated that way freezes the player's controls for as +-- long as it walks. That is right for Oak marching to his lab and wrong for +-- an actor that moves on its own schedule: a networked player's ghost, an +-- ambient walker. This is the same per-tile state scriptMove sets, minus +-- the queue and therefore minus the lockout. +-- +-- Collision is deliberately not checked. The caller is replaying a move +-- that was already decided somewhere else (validated on the peer's machine, +-- or authored), and re-judging it here would let the two copies disagree +-- about where the actor is. Use canStep first if you want the check. +function Handle:stepNow(dir) + local npc = self.npc + if not Collision.DELTA[dir] then return nil, "bad direction: " .. tostring(dir) end + if npc.moving then return nil, "already moving" end + npc.facing = dir + npc.targetX, npc.targetY = Collision.target(npc.cellX, npc.cellY, dir) + npc.moving = true + npc.progress = 0 + return true +end + +-- Would stepNow land somewhere legal? Exposed separately so a caller that +-- does want the map's opinion can ask for it without giving up the "replay +-- verbatim" default above. +function Handle:canStep(dir) + local ow = self.ow + if not (ow and ow.map) then return false end + return Collision.canMove(ow.map, ow.entities, self.npc, dir) and true or false +end + +-- Snap to a cell with no animation: a warp arrival, or a resync that has +-- drifted too far to walk off. Clears any step in flight so the entity +-- cannot land on its old target a frame later. +function Handle:placeAt(x, y, facing) + local npc = self.npc + npc.moving = false + npc.marching = false + npc.targetX, npc.targetY = nil, nil + npc.progress = 0 + npc.cellX, npc.cellY = x, y + npc.px, npc.py = x * 16, y * 16 + if facing then npc.facing = facing end + return true +end + +-- True while a step is still animating, so a driver can pace itself rather +-- than stomping a move in flight. +function Handle:isMoving() + return self.npc.moving and true or false +end + +-- Whether the player may walk through this object (Collision.occupied skips +-- passable entities -- Yellow's companion Pikachu is the engine's own user +-- of the flag). It still draws and can still be talked to; it just stops +-- being an obstacle, which is what a dynamic actor wants when standing in a +-- doorway would otherwise wall someone in. +function Handle:setPassable(passable) + self.npc.passable = passable and true or false + return true +end + function WorldAPI:npc(mapId, indexOrName) local ow = self:overworld() if not ow then return nil, NO_OVERWORLD end From 01c5eb21899dada06e040b53faf1e179d0549366 Mon Sep 17 00:00:00 2001 From: "DESKTOP-8SRFDDM\\cam95" Date: Sun, 23 Aug 2026 10:19:06 -0500 Subject: [PATCH 2/3] tests, docs: cover world.talk and link.battle_ended gate_meta_coverage asks every extension point for a unit test through the public mod API, a no-mod parity test, and docs. The seams commit shipped the call sites and owed the rest. Both cases drive the real engine path, and both assert the unhooked build first, so they fail if the seam is deleted and if it changes vanilla behaviour. world_talk stands an object on the faced cell: with no mod the A press reaches talkTo, with a mod that owns the object it does not, and an object the mod ignores still falls through. link_battle_ended parks a session at the end of a battle and checks the event carries the result, the role, and both party copies. The parity side needed nothing. gate_hooks and gate_events walk the live catalog, so both seams were covered structurally as soon as the call sites existed. Co-Authored-By: Claude Fable 5 --- docs/modding.md | 74 +++++++++++++++++ tests/modkit/cases/link_battle_ended.lua | 99 ++++++++++++++++++++++ tests/modkit/cases/world_talk.lua | 100 +++++++++++++++++++++++ 3 files changed, 273 insertions(+) create mode 100644 tests/modkit/cases/link_battle_ended.lua create mode 100644 tests/modkit/cases/world_talk.lua diff --git a/docs/modding.md b/docs/modding.md index 7c724404..1a2251b0 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -265,6 +265,80 @@ Red exposes FLY separately because it requires a destination picker: and `mod.world:flyTo(mapId)` accepts only a visited destination from the native Fly town list. Gold does not expose these two methods yet. +## Self-driven world actors + +`mod.world:spawnNpc()` returns a handle whose `scriptMove` queues onto the +overworld's scripted-movement list. A non-empty list is how the overworld +knows a cutscene is running, so it gates player input for as long as the +actor walks -- right for Oak marching to his lab, wrong for an actor that +moves on its own schedule (a networked player's ghost, an ambient walker). +Five handle methods drive one without that lockout: + +```lua +local ghost = mod.world:spawnNpc({ map = "ROUTE_1", x = 5, y = 7, + sprite = "SPRITE_RED" }) +if ghost:canStep("up") then ghost:stepNow("up") end -- one tile, now +if not ghost:isMoving() then ghost:placeAt(9, 3, "down") end -- snap, no walk +ghost:setPassable(true) -- walk-through +``` + +`stepNow(dir)` sets the same per-tile state `scriptMove` does, minus the +queue. It deliberately does **not** check collision: a caller replaying a +move that was already decided elsewhere (validated on a peer's machine, or +authored) would let the two copies disagree about where the actor is if this +re-judged it. Ask `canStep(dir)` first when you do want the map's opinion. +`placeAt(x, y, facing)` snaps with no animation and clears any step in +flight, for a warp arrival or a resync too far gone to walk off. +`isMoving()` lets a driver pace itself instead of stomping a move already +running. `setPassable(flag)` is the flag `Collision.occupied` skips (the +engine's own user is Yellow's companion Pikachu); a passable object still +draws and can still be talked to. + +An object spawned this way carries no `TEXT_*` id, so the vanilla talk path +has nothing to say for it. The **`world.talk`** hook is the A press on an +object, raised before the map's text tables get it: + +```lua +mod.hooks:wrap("world.talk", function(next, ow, target) + if mine(target) then + say(target) -- the mod answers for an object it owns + return -- ...by not calling next() + end + return next(ow, target) -- everything else falls through unchanged +end) +``` + +With no subscriber the A press reaches `talkTo` exactly as before. An object +mid-step raises no hook, matching the vanilla gate. + +## Adopting an already-paired link session + +`LinkState.newFromSession(game, transport, mode, isHost, opts)` starts a link +session on a transport that is *already* paired, skipping the address/code +entry UI while keeping the hello and fingerprint compatibility exchange +intact. `transport` is anything `Session` accepts, which is what lets a mode +tunnel a battle through its own connection rather than opening a second one. + +When the battle finishes, **`link.battle_ended`** reports the outcome: + +```lua +mod.events:on("link.battle_ended", function(ev) + -- ev = { result, myParty, theirParty, peerName, role } +end) +``` + +The party copies are the point. Cable rules leave the real party untouched, +so a mode built on link battles -- a tournament ladder, a battle royale -- +has no other way to learn what the fight cost, and by the time the state +unwinds the battle object is gone. `role` is `"host"` or `"guest"`. + +Two smaller pieces support the same shape of mode. `Game:startNewGame(opts)` +is the title screen's NEW GAME closure made callable, with `opts.intro = +false` to land straight in the world -- a mode that hands out its own starting +state has no use for Oak's speech. `CodeEntry.new` takes an optional +`{ length = , charset = }`, so the slot-scrub widget that enters a link code +can also carry a room code or an address. + ## Read-only battle snapshots `mod.battle:snapshot()` returns `nil` outside a battle and a copied battle diff --git a/tests/modkit/cases/link_battle_ended.lua b/tests/modkit/cases/link_battle_ended.lua new file mode 100644 index 00000000..93c5ff9c --- /dev/null +++ b/tests/modkit/cases/link_battle_ended.lua @@ -0,0 +1,99 @@ +-- A sandboxed mod can read the outcome of a link battle -- who won, and the +-- lockstep party copies -- through the public event surface. +-- +-- The copies are the point. Cable rules leave the real party untouched, so +-- a mode built on link battles (a tournament ladder, a battle royale) has no +-- other way to learn what the fight cost; by the time the state unwinds the +-- battle object is gone. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.modkit") +local LinkState = require("src.link.LinkState") + +local FIXTURE = { + ["mods/outcome_probe/manifest.json"] = [[{ + "id": "outcome_probe", + "name": "Outcome Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/outcome_probe/main.lua"] = [[ + local mod = ... + mod.exports.seen = 0 + mod.events:on("link.battle_ended", function(ev) + mod.exports.seen = mod.exports.seen + 1 + mod.exports.result = ev.result + mod.exports.role = ev.role + mod.exports.peerName = ev.peerName + mod.exports.myLead = ev.myParty and ev.myParty[1] + mod.exports.theirLead = ev.theirParty and ev.theirParty[1] + end) + ]], +} + +-- a link session parked at the end of a battle: no transport, so update() +-- goes straight to the stage that reports the outcome +local function finishedSession(result, isHost) + local ls + ls = setmetatable({ + stage = "battleRunning", + isHost = isHost, + peerName = "BLUE", + net = nil, + battle = { + result = result, + playerParty = { { species = "RATTATA", hp = 3 } }, + enemyParty = { { species = "PIDGEY", hp = 0 } }, + }, + game = { input = {}, stack = { top = function() return ls end } }, + }, { __index = LinkState }) + -- the real exit unwinds the whole link stack; the event is what is under + -- test, so record the exit rather than run it + ls.exitWith = function() ls.exited = true end + return ls +end + +-- ------- no mod: the battle still ends, nothing observes it + +local vanilla = T.sdk.loadNone({}) +local quiet = finishedSession("win", true) +quiet:update(0) +T.check(quiet.exited, "with no mod loaded the finished battle still unwinds") +T.eq(quiet.battle, nil, "and lets go of the battle") +vanilla.release() + +-- ------- a mod reads the outcome and both party copies + +local run = T.sdk.loadMods({ "mods/outcome_probe" }, { fs = T.sdk.memfs(FIXTURE) }) +T.eq(#run.errors, 0, + "the public outcome probe loads clean (" .. tostring(run.errors[1]) .. ")") + +local session = finishedSession("win", true) +session:update(0) +local out = run.loader.exports.outcome_probe or {} +T.eq(out.seen, 1, "a finished link battle raises the event once") +T.eq(out.result, "win", "the outcome is reported") +T.eq(out.role, "host", "so is which side of the cable we were") +T.eq(out.peerName, "BLUE", "and who we played") +T.eq(out.myLead and out.myLead.species, "RATTATA", + "the lockstep copy of our party comes with it") +T.eq(out.myLead and out.myLead.hp, 3, + "carrying the damage the real party never took") +T.eq(out.theirLead and out.theirLead.species, "PIDGEY", + "and the copy of theirs") +T.check(session.exited, "the state unwinds afterwards, as it always did") +T.eq(session.battle, nil, "and the battle is released") + +-- the guest side says so +local guest = finishedSession("lose", false) +guest:update(0) +T.eq(run.loader.exports.outcome_probe.seen, 2, "the guest reports too") +T.eq(run.loader.exports.outcome_probe.role, "guest", "as the guest") +T.eq(run.loader.exports.outcome_probe.result, "lose", "with its own outcome") + +run.release() + +T.finish("link_battle_ended") diff --git a/tests/modkit/cases/world_talk.lua b/tests/modkit/cases/world_talk.lua new file mode 100644 index 00000000..7ae3c73f --- /dev/null +++ b/tests/modkit/cases/world_talk.lua @@ -0,0 +1,100 @@ +-- A sandboxed mod can claim the A press on an object it owns, using only +-- public mod surfaces, and an unhooked build still talks to it as before. +-- +-- The seam exists because a runtime object (WorldAPI:spawnNpc) carries no +-- TEXT_* id: the vanilla talk path has nothing to say for one, so a mod that +-- spawned it has to be able to answer instead. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.modkit") +local OverworldState = require("src.world.OverworldController") + +local FIXTURE = { + ["mods/talk_probe/manifest.json"] = [[{ + "id": "talk_probe", + "name": "Talk Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/talk_probe/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("world.talk", function(next, ow, target) + if target and target.claimedByMod then + mod.exports.claimed = target.id + return -- deliberately not calling next(): the mod answers instead + end + return next(ow, target) + end) + ]], +} + +-- enough of an overworld to reach the A press: a player facing one cell, an +-- object standing on it, and a map with no counter to talk across +local function fixtureOverworld(npc) + local ow + ow = setmetatable({ + npcs = { npc }, + player = { + facing = "up", + facingCell = function() return 4, 5 end, + }, + map = { + id = "FIX_ROUTE", + isCounterCell = function() return false end, + }, + talked = {}, + }, { __index = OverworldState }) + -- the vanilla destination, recorded rather than run: talkTo walks into the + -- map's text tables, which a fixture map does not have + ow.talkTo = function(_, target) ow.talked[#ow.talked + 1] = target.id end + return ow +end + +local function objectAt(id, claimed) + return { id = id, cellX = 4, cellY = 5, targetX = 4, targetY = 5, + moving = false, claimedByMod = claimed or nil, def = {} } +end + +-- ------- no mod: the A press lands in the vanilla talk path + +local vanilla = T.sdk.loadNone({}) +local plain = fixtureOverworld(objectAt("SIGNPOST_MAN")) +plain:interact() +T.eq(#plain.talked, 1, "with no mod loaded the A press reaches talkTo") +T.eq(plain.talked[1], "SIGNPOST_MAN", "and it is handed the object it faced") +vanilla.release() + +-- ------- a mod that owns the object answers for it + +local run = T.sdk.loadMods({ "mods/talk_probe" }, { fs = T.sdk.memfs(FIXTURE) }) +T.eq(#run.errors, 0, + "the public talk probe loads clean (" .. tostring(run.errors[1]) .. ")") + +local owned = fixtureOverworld(objectAt("GHOST_PLAYER", true)) +owned:interact() +local out = run.loader.exports.talk_probe or {} +T.eq(out.claimed, "GHOST_PLAYER", "a public hook sees the object it owns") +T.eq(#owned.talked, 0, + "and a hook that does not call next keeps the vanilla text path out of it") + +-- ...while everything the mod does not own falls straight through +local other = fixtureOverworld(objectAt("NURSE_JOY")) +other:interact() +T.eq(#other.talked, 1, "an object the mod does not claim still reaches talkTo") +T.eq(other.talked[1], "NURSE_JOY", "unchanged") + +-- an object mid-step is not talkable in either build (walk_npc.asm), so the +-- hook must not fire for one either +local walking = fixtureOverworld(objectAt("WALKER", true)) +walking.npcs[1].moving = true +walking:interact() +T.eq(run.loader.exports.talk_probe.claimed, "GHOST_PLAYER", + "an object mid-step raises no talk hook") +T.eq(#walking.talked, 0, "and reaches no talk path at all") + +run.release() + +T.finish("world_talk") From b550db3abb6a3683b81999a84bbbc622211c4b7d Mon Sep 17 00:00:00 2001 From: "DESKTOP-8SRFDDM\\cam95" Date: Sun, 23 Aug 2026 10:35:12 -0500 Subject: [PATCH 3/3] rfc: guard the new seams and write RFC 0014 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 --- ...driven-actors-and-adopted-link-sessions.md | 158 ++++++++++++++++++ src/link/LinkState.lua | 2 +- src/world/OverworldController.lua | 7 +- tests/modkit/cases/link_battle_ended.lua | 4 + 4 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 docs/rfcs/0014-mod-driven-actors-and-adopted-link-sessions.md diff --git a/docs/rfcs/0014-mod-driven-actors-and-adopted-link-sessions.md b/docs/rfcs/0014-mod-driven-actors-and-adopted-link-sessions.md new file mode 100644 index 00000000..4294bcbe --- /dev/null +++ b/docs/rfcs/0014-mod-driven-actors-and-adopted-link-sessions.md @@ -0,0 +1,158 @@ +# 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` + +```lua +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` + +```lua +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 + +```lua +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.lua` drives the real `OverworldState:interact` + path. It asserts the unhooked build first (the A press reaches `talkTo`), + then that a mod owning the object suppresses the text path by not calling + `next`, 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.lua` drives the real `LinkState:update` + path. 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.lua` and `tests/engine/gate_events.lua` walk the live + catalog, so both seams are parity-gated structurally; `gate_meta_coverage` + passes 208/208 with both names covered and no DEBT entry added. + +## Deprecation etiquette + +Nothing is removed, renamed, superseded or deprecated. diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index 44ae1841..9e119a5d 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -544,7 +544,7 @@ function LinkState:update(dt) -- (cable rules), so a mode that wants it -- a tournament ladder, a -- battle royale -- reads it from here before the state unwinds local battle = self.battle - if battle then + if battle and Runtime.wants("link.battle_ended") then Runtime.emit("link.battle_ended", { result = battle.result or "ended", myParty = battle.playerParty, diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 47dda17e..4696f9fa 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1964,6 +1964,10 @@ function OverworldState:pushableAtCell(cx, cy) return nil end +-- world.talk's fallthrough, hoisted so the A press does not build a closure +-- on every press just to have one to hand a hook nobody may have wrapped +local function vanillaTalk(ow, target) ow:talkTo(target) end + -- what the A press resolved to, for world.interacted's listeners local function interacted(self, fx, fy, kind, target) Runtime.emit("world.interacted", { mapId = self.map.id, x = fx, y = fy, @@ -1998,8 +2002,7 @@ function OverworldState:interact() -- no TEXT_* id, so the vanilla path has nothing to say for it; a mod -- that owns the object wraps this and simply does not call next(). -- Everything else falls straight through to talkTo as before. - Runtime.call("world.talk", function(ow, target) ow:talkTo(target) end, - self, npc) + Runtime.call("world.talk", vanillaTalk, self, npc) end interacted(self, fx, fy, "npc", npc) return diff --git a/tests/modkit/cases/link_battle_ended.lua b/tests/modkit/cases/link_battle_ended.lua index 93c5ff9c..c27916fe 100644 --- a/tests/modkit/cases/link_battle_ended.lua +++ b/tests/modkit/cases/link_battle_ended.lua @@ -58,7 +58,11 @@ end -- ------- no mod: the battle still ends, nothing observes it +local Runtime = require("src.mods.Runtime") + local vanilla = T.sdk.loadNone({}) +T.check(not Runtime.wants("link.battle_ended"), + "with nothing subscribed the event is not wanted, so no payload is built") local quiet = finishedSession("win", true) quiet:update(0) T.check(quiet.exited, "with no mod loaded the finished battle still unwinds")