From 7d6566fa51c75dc6e3b393de735f657751b7c88b Mon Sep 17 00:00:00 2001 From: "DESKTOP-8SRFDDM\\cam95" Date: Mon, 24 Aug 2026 21:06:07 -0500 Subject: [PATCH] engine: catch.party_full custody for a full-party catch (RFC 0018) A capture the party cannot hold silently falls through to the box; the new partyFullDestination seam lets a mode claim custody at that moment instead, and pokemon.caught reports destination "mod" so the mode can find the mon again. Guarded call site, file-local vanilla, docs and a public-API modkit case. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01N1DVpYXGabigHqMwviDoKV --- docs/modding.md | 23 +++++ docs/rfcs/0018-catch-party-full.md | 113 ++++++++++++++++++++++++ src/battle/BattleState.lua | 44 ++++++--- tests/modkit/cases/catch_party_full.lua | 98 ++++++++++++++++++++ 4 files changed, 265 insertions(+), 13 deletions(-) create mode 100644 docs/rfcs/0018-catch-party-full.md create mode 100644 tests/modkit/cases/catch_party_full.lua diff --git a/docs/modding.md b/docs/modding.md index 28a38c1d..3d1329bf 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -451,6 +451,29 @@ Menu choices and moves use the same engine methods as the native controls; their mutable logic. Tutorial, link, forced, stale, and covered battle states refuse core intents. Use `mod.input` for ordinary text advance. +## Party-full custody at a catch + +When a capture lands on a full party, the cart deposits the mon in storage +without a question. `catch.party_full` (RFC 0018) lets a mode stand in front +of `SendNewMonToBox` and take custody instead: + +```lua +mod.hooks:wrap("catch.party_full", function(next, ctx) + -- ctx = { battle = , mon = , name = , + -- game = } + if myMode.active then + takeCustody(ctx.mon) -- the mon is the mod's problem now + return true -- nothing is deposited + end + return next(ctx) -- false: deposit, as today +end) +``` + +A truthy return skips the box entirely -- the mon is neither in the party nor +in any box, and `pokemon.caught` reports `destination = "mod"` so the mode can +find its own custody again. Anything falsy deposits as always, "But every BOX +is full!" included. + ## Rendering pipelines Most registries hand the engine *content*. `render_pipelines` hands it diff --git a/docs/rfcs/0018-catch-party-full.md b/docs/rfcs/0018-catch-party-full.md new file mode 100644 index 00000000..6658c434 --- /dev/null +++ b/docs/rfcs/0018-catch-party-full.md @@ -0,0 +1,113 @@ +# RFC 0018: `catch.party_full` — custody of a catch the party cannot hold + +## Status + +Proposed. + +## Motivation + +When a capture lands and the party already holds six Pokémon, the cart does +not ask the player anything: `AddPartyMon` fails and the mon goes to +`SendNewMonToBox` without a stop. The engine mirrors that — +`BattleState:storeCaughtMon` falls through to `Boxes.deposit` — and a game +mode has no way to stand in front of it. + +That silence is wrong for any mode where the box is not a thing. A battle +royale locks the Pokémon Center PC for the whole match (the box is a second +health bar: deposit the healthy ones, fight with one, withdraw fresh ones), +so a seventh catch is deposited into storage the player cannot reach — the +mon is gone and everyone saw the fanfare. A Nuzlocke that counts a boxed mon +as lost, a randomizer that wants to hand out a replacement instead, a +challenge run that makes the player release someone for the catch — all want +the decision, and today there is no seam to catch it at. + +The immediate consumer is a battle-royale mode, where the fix is the game's +own rule: at 6/6 you choose who makes room, and whoever leaves hits the +ground as a ball. Neither the decision nor the spill is specific to it. + +## The decision it extends + +This extends the **additive, guarded seam convention** Route B in +`CONTRIBUTING-mods.md` documents, and is gated by the parity guarantee +`tests/engine/gate_meta_coverage.lua` enforces. It sits next to +`catch.nickname` (RFC 0015) on the same capture path: that hook answers +*what the mon is called* once its home is decided; this one decides the home +when the party cannot hold it. + +There is no in-repo D-number registry to amend. + +## Exact API delta + +### New hook: `catch.party_full` + +```lua +mod.hooks:wrap("catch.party_full", function(next, ctx) + -- ctx = { battle = , mon = , name = , + -- game = } + if myMode.active then + takeCustody(ctx.mon) -- the mon is the mod's problem now + return true -- nothing is deposited + end + return next(ctx) -- false: deposit, as today +end) +``` + +Call site: `BattleState:partyFullDestination(mon)`, called from the capture +path at the moment `AddPartyMon` has failed and `SendNewMonToBox` would run. +The vanilla link returns `false`. A truthy return skips the box entirely — +the mon is neither in the party nor in any box, and `pokemon.caught` reports +`destination = "mod"` so the mode can find its own custody again. Anything +falsy deposits as always, "But every BOX is full!" included. + +The method returns `"box"` or `"mod"`, and is a *method* on purpose: the +compatibility seam for engines that predate this RFC needs a name to ask for +(see below), and `battleStyle`/`offerNickname` set the precedent. + +### One event value, already emitted + +`pokemon.caught`'s `destination` gains a third value, `"mod"`, next to +`"party"` and `"box"`. Nothing that reads the event today matches on it. + +### No other surface changes + +The call site is guarded by `Runtime.wantsHook`, and the vanilla link is a +file-local, so a build with nothing wrapped runs the branch exactly as +before and allocates nothing it did not allocate before. + +## Migration + +Nothing changes for existing mods. A mode that was fighting the box after +the fact — withdrawing the deposit it could not prevent, or eating the loss — +should wrap `catch.party_full` and take the mon at the moment of the catch. + +## Verification + +- `tests/modkit/cases/catch_party_full.lua` — through the public mod API: + with no mod a 6/6 catch lands in the box with the transfer text; a wrapped + mod that claims custody leaves the mon out of both party and boxes; a + fall-through deposits; the hook's ctx carries the battle, the mon, the + display name and the game. +- `tests/engine/gate_hooks.lua` — the name is in the live catalog and passes + the no-mod parity gate (vanilla called exactly once, result unchanged, + nothing allocated). +- `tests/engine/gate_meta_coverage.lua` — the name is covered by the unit + corpus. + +## Backward compatibility + +Additive. No existing hook, event, registry or manifest field changes shape. +`storeCaughtMon`'s box branch keeps its text, its nickname offer and its +`pokemon.caught` emit; the only reader-visible difference with no subscriber +is one extra method on `BattleState`. + +## Compatibility seam for older engines + +The call site is mid-function, so a mod on a stock engine cannot see it — +the same problem `world.talk` has. The battle-royale mod's shim covers the +gap by wrapping `Boxes.deposit` (the one call the old branch makes that a +patch can reach): during a match it raises `catch.party_full` first, and a +claim refuses the deposit, so nothing reaches a box even there. The mod then +takes custody from the `pokemon.caught` emit, which carries the mon. What +the shim cannot repair is the text: the old branch answers a refused deposit +with "But every BOX is full!" before the mode's own prompt opens — the wrong +reason for the right decision, and the argument for the seam. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 300cc345..835d6da7 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5086,6 +5086,22 @@ end -- "New POKéDEX data will be added" + the dex entry page, then -- AddPartyMon or SendNewMonToBox (both call AskName), then the PC -- transfer text when the party was full. +-- Where a caught mon goes when the party has no room for it (RFC 0018): +-- "box", as AddPartyMon falling through to SendNewMonToBox always did, or +-- "mod" when the catch.party_full hook claims it -- a game mode that has +-- done away with storage hands the decision to the player instead of +-- laundering the catch through a PC it has locked. A method rather than +-- an inline read, so a mod or a compatibility shim can tell a seam engine +-- from a stock one by name. +function BattleState:partyFullDestination(mon) + if not Runtime.wantsHook("catch.party_full") then return "box" end + local claimed = Runtime.call("catch.party_full", function() return false end, + { battle = self, mon = mon, name = self.enemy and self.enemy.name, + game = self.game }) + if claimed then return "mod" end + return "box" +end + function BattleState:storeCaughtMon() -- ItemUseBall reloads the caught mon via LoadEnemyMonData -- (item_effects.asm:472-501), regenerating its move list from the @@ -5126,19 +5142,21 @@ function BattleState:storeCaughtMon() if Party.add(game.save.party, self.enemy.mon) then askCaughtNickname() else - destination = "box" - local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon) - if boxNum then - askCaughtNickname() - -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL - local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL - self:sayNext(self:romText( - metBill and "_ItemUseBallText07" or "_ItemUseBallText08", - metBill and "%s was\ntransferred to\nBILL's PC!" - or "%s was\ntransferred to\nsomeone's PC!", - self.enemy.name)) - else - self:sayNext(Strings("But every BOX\nis full!")) + destination = self:partyFullDestination(self.enemy.mon) + if destination == "box" then + local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon) + if boxNum then + askCaughtNickname() + -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL + local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL + self:sayNext(self:romText( + metBill and "_ItemUseBallText07" or "_ItemUseBallText08", + metBill and "%s was\ntransferred to\nBILL's PC!" + or "%s was\ntransferred to\nsomeone's PC!", + self.enemy.name)) + else + self:sayNext(Strings("But every BOX\nis full!")) + end end end Runtime.emit("pokemon.caught", { diff --git a/tests/modkit/cases/catch_party_full.lua b/tests/modkit/cases/catch_party_full.lua new file mode 100644 index 00000000..5dbdd918 --- /dev/null +++ b/tests/modkit/cases/catch_party_full.lua @@ -0,0 +1,98 @@ +-- A sandboxed mod can take custody of a catch the party cannot hold +-- (catch.party_full): the mon goes to the mod instead of a PC box, which is +-- the difference between "choose who to release" and a catch that vanishes +-- into storage the mode has locked away. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.modkit") +local BattleState = require("src.battle.BattleState") +local Boxes = require("src.pokemon.Boxes") + +local FIXTURE = { + ["mods/catch_probe/manifest.json"] = [[{ + "id": "catch_probe", + "name": "Catch Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/catch_probe/main.lua"] = [[ + local mod = ... + mod.exports.answer = nil + mod.exports.ctx = nil + mod.hooks:wrap("catch.party_full", function(next, ctx) + mod.exports.ctx = ctx + if mod.exports.answer == nil then return next(ctx) end + return mod.exports.answer + end) + ]], +} + +local function fullBattle(species) + local data = { pokemon = {}, text = {} } + local party = {} + for _ = 1, 6 do party[#party + 1] = { species = "RATTATA", moves = {} } end + local save = { + party = party, + player = { name = "RED" }, + options = { battleStyle = "shift" }, + flags = {}, + } + return setmetatable({ + game = { save = save, stack = { push = function() end }, data = data }, + data = data, + queue = {}, nextInsert = 0, + enemy = { mon = { species = species or "PIDGEY", level = 5, moves = {} }, + name = species or "PIDGEY" }, + }, { __index = BattleState }) +end + +local function boxTotal(save) + local n = 0 + for _, box in ipairs(Boxes.ensure(save)) do n = n + #box end + return n +end + +-- ------- no mod: the cart's silence, reproduced + +local vanilla = T.sdk.loadNone({}) +local battle = fullBattle() +battle:storeCaughtMon() +T.eq(battle.result, "caught", "no mod: the catch still lands") +T.eq(#battle.game.save.party, 6, "no mod: the party is untouched") +T.eq(boxTotal(battle.game.save), 1, "no mod: the mon was deposited") +T.eq(battle.queue[#battle.queue].text, "PIDGEY was\ntransferred to\nsomeone's PC!", + "no mod: with the transfer text") +vanilla.release() + +-- ------- a mod claims custody + +local run = T.sdk.loadMods({ "mods/catch_probe" }, { fs = T.sdk.memfs(FIXTURE) }) +T.eq(#run.errors, 0, "the catch probe loads clean (" .. tostring(run.errors[1]) .. ")") +local probe = run.loader.exports.catch_probe + +probe.answer = true +local claimed = fullBattle() +claimed:storeCaughtMon() +T.eq(claimed.result, "caught", "claimed: the catch still lands") +T.eq(#claimed.game.save.party, 6, "claimed: the party is untouched") +T.eq(boxTotal(claimed.game.save), 0, "claimed: nothing reached a box") +T.eq(probe.ctx and probe.ctx.name, "PIDGEY", "the hook was handed the display name") +T.check(probe.ctx and probe.ctx.battle == claimed, "and the battle") +T.check(probe.ctx and probe.ctx.mon == claimed.enemy.mon, "and the caught mon") +T.check(probe.ctx and probe.ctx.game == claimed.game, "and the game") + +probe.answer = false +local declined = fullBattle() +declined:storeCaughtMon() +T.eq(boxTotal(declined.game.save), 1, "declined: the box path runs as always") + +probe.answer = nil +local fell = fullBattle() +fell:storeCaughtMon() +T.eq(boxTotal(fell.game.save), 1, "falling through deposits, as today") + +run.release() +T.finish("catch party full")