mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
Merge pull request #1799 from campavao/rfc-0018-catch-party-full
engine: catch.party_full — custody of a catch the party cannot hold
This commit is contained in:
@@ -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 = <BattleState>, mon = <Pokemon>, name = <display name>,
|
||||
-- game = <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
|
||||
|
||||
@@ -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 = <BattleState>, mon = <Pokemon>, name = <display name>,
|
||||
-- game = <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.
|
||||
+31
-13
@@ -5133,6 +5133,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
|
||||
@@ -5173,19 +5189,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", {
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user