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/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/core/Game.lua b/src/core/Game.lua index 71ae4d68..24e32a09 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..9e119a5d 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 and Runtime.wants("link.battle_ended") 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 1f02377d..a00a0a13 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1969,6 +1969,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,7 +2002,12 @@ 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", vanillaTalk, 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 diff --git a/tests/modkit/cases/link_battle_ended.lua b/tests/modkit/cases/link_battle_ended.lua new file mode 100644 index 00000000..c27916fe --- /dev/null +++ b/tests/modkit/cases/link_battle_ended.lua @@ -0,0 +1,103 @@ +-- 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 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") +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")