Merge pull request #1798 from campavao/rfc-0015-battle-rule-hooks

engine: battle.style and catch.nickname rule hooks
This commit is contained in:
bryanthaboi
2026-08-25 15:55:07 -04:00
committed by GitHub
4 changed files with 328 additions and 7 deletions
+36
View File
@@ -505,6 +505,42 @@ 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.
## Battle rule hooks
Two decisions the OPTION screen and the cart make for the player, which a game
mode can make instead (RFC 0015). Neither writes the player's saved
preference, so a mode can hold a rule for as long as it is active and hand the
player's own setting back untouched.
`battle.style` wraps the SHIFT/SET read at the moment the foe's Pokémon faints
and the engine would offer a free switch:
```lua
mod.hooks:wrap("battle.style", function(next, battle)
if myMode.active then return "set" end -- no "will you change POKéMON?"
return next(battle) -- the OPTION row, as today
end)
```
Return `"set"` or `"shift"`; anything else reads as the vanilla answer.
`catch.nickname` wraps the `AskName` prompt after a capture (party or box),
the same question `pokemon.before_give`'s `gift.nickname` already answers for
script gifts:
```lua
mod.hooks:wrap("catch.nickname", function(next, mon, ctx)
-- ctx = { battle = <BattleState>, name = <display name>, game = <Game> }
if myMode.active then return false end -- keep the species name
if myNames then return myNames[mon.species] end -- a string names it, no prompt
return next(mon, ctx) -- true: ask, as today
end)
```
`false` keeps the species name with no prompt. A string is the nickname with
no prompt, clipped to the naming grid's ten characters (an empty string names
nothing). Anything else queues the prompt.
## Party-full custody at a catch
When a capture lands on a full party, the cart deposits the mon in storage
+110
View File
@@ -0,0 +1,110 @@
# RFC 0015: Battle rule hooks — `battle.style` and `catch.nickname`
## Status
Proposed.
## Motivation
Two decisions in a Red/Blue/Yellow battle are made for the player by the
OPTION screen and by the cart, and a game mode has no way to make them
instead:
**Whether a faint offers a free switch.** `EnemySendOutFirstMon` reads the
battle-style bit: SHIFT asks "will you change POKéMON?" when the foe's Pokémon
faints, SET does not. The engine reads `save.options.battleStyle` inline at
that moment. A mode that wants SET — a tournament, a Nuzlocke, a battle royale
where party-as-health is the whole design — can only get it by writing the
player's saved preference, which leaks into their real playthrough the first
time anything calls `Game:writeOptions` (the speed hotkey does, on every
press).
**Whether a catch asks for a nickname.** `AddPartyMon` and `SendNewMonToBox`
both run `AskName` after a capture. A mode with a disposable team, a
randomizer that names what it hands out, a speedrun practice mod — all want to
answer that prompt themselves, and the only way today is to drive the yes/no
box from outside. The script-gift path already has this seam: a mod that sets
`gift.nickname` on `pokemon.before_give` skips `AskName`. The catch path does
not.
The immediate consumer is a battle-royale mode; neither hook 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. `catch.nickname` mirrors a
contract that already exists for gifts (`pokemon.before_give`'s
`gift.nickname`), so the two ways a Pokémon joins the party answer the same
question the same way.
There is no in-repo D-number registry to amend.
## Exact API delta
### New hook: `battle.style`
```lua
mod.hooks:wrap("battle.style", function(next, battle)
if myMode.active then return "set" end
return next(battle) -- the OPTION row, as today
end)
```
Call site: `BattleState:battleStyle()`, called from the enemy send-out path at
the moment the SHIFT prompt would be offered. The vanilla link reads
`save.options.battleStyle` (lower-cased, default `"shift"`) exactly as the
inline read did. A return of `"set"` or `"shift"` is used; anything else reads
as the vanilla answer, so a hook that returns nothing by mistake cannot change
the rule. The player's saved preference is never written.
### New hook: `catch.nickname`
```lua
mod.hooks:wrap("catch.nickname", function(next, mon, ctx)
-- ctx = { battle = <BattleState>, name = <display name>, game = <Game> }
if myMode.active then return false end -- keep the species name
if randomizer then return pickName(mon) end -- a string names it
return next(mon, ctx) -- true: ask, as today
end)
```
Call site: `BattleState:offerNickname(mon, displayName)`, called from the
capture path where `AskName` ran, before the prompt is queued. The vanilla
link returns `true`. `false` skips the prompt and keeps the species name; a
string skips the prompt and is the nickname, clipped to the naming grid's ten
characters (an empty string names nothing, like the grid's own empty entry);
anything else queues the prompt as today. The method returns whether a prompt
was queued.
### No other surface changes
Both call sites are guarded by `Runtime.wantsHook`, and both vanilla links are
file-local functions, so a build with nothing wrapped runs the branch exactly
as before and allocates nothing it did not allocate before. `askNicknameUI` is
unchanged and still public.
## Migration
Nothing changes for existing mods. A mod that was writing
`save.options.battleStyle` to force a style should wrap `battle.style` instead
and stop writing the option.
## Verification
- `tests/modkit/cases/battle_rule_hooks.lua` — through the public mod API: the
no-mod answers match the OPTION row and the cart's AskName; a wrapped mod
forces either style without the row being written; `false`, a string, a
too-long string, an empty string, and a fall-through each do what this RFC
says at the catch prompt.
- `tests/engine/gate_hooks.lua` — both names are in the live catalog and pass
the no-mod parity gate (vanilla called exactly once, result unchanged,
nothing allocated).
- `tests/engine/gate_meta_coverage.lua` — both names are covered by the unit
corpus.
## Backward compatibility
Additive. No existing hook, event, registry or manifest field changes shape.
The two new methods on `BattleState` are called only from the paths that
previously inlined their logic; the results with no subscriber are identical.
+53 -7
View File
@@ -4531,8 +4531,7 @@ function BattleState:enemyMonFainted()
-- remaining HP); SET / single-mon / fainted active skip the prompt.
local nextMon = self.enemyParty[self.enemyIndex]
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
:lower()
local style = self:battleStyle()
local partyCount = #self:playerPartyView()
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
@@ -5100,6 +5099,57 @@ function BattleState:ballMissMessage(shakes)
return t._ItemUseBallText04 or self:romText("_ItemUseBallText04", "Shoot! It was so\nclose too!")
end
-- ------- battle rules a mode may own
--
-- Two decisions the OPTION screen and the cart make for the player that a
-- game mode may want to make instead: whether a faint offers a free switch,
-- and whether a catch asks for a nickname. Each is a hook around the vanilla
-- answer, so a mode can force it without touching the player's saved
-- preference and without the player being able to change it mid-match.
--
-- The vanilla links are file-locals so an empty chain allocates no closure.
local function styleFromOptions(battle)
return tostring(((battle.game.save or {}).options or {}).battleStyle or "shift")
:lower()
end
local function alwaysAsk() return true end
-- "shift" or "set" for this battle. battle.style wraps the OPTION row: a
-- mod returns "set" or "shift"; anything else reads as the vanilla answer.
function BattleState:battleStyle()
if not Runtime.wantsHook("battle.style") then return styleFromOptions(self) end
local style = Runtime.call("battle.style", styleFromOptions, self)
if style == "set" then return "set" end
if style == "shift" then return "shift" end
return styleFromOptions(self)
end
-- AskName for a catch (AddPartyMon / SendNewMonToBox). Vanilla queues the
-- yes/no prompt. catch.nickname may answer for the player: false keeps the
-- species name and shows nothing; a string is the nickname, shown nothing;
-- anything else asks as usual. Returns whether a prompt was queued.
--
-- The same verdict a script gift already takes from pokemon.before_give's
-- gift.nickname, for the other way a Pokemon joins the party.
function BattleState:offerNickname(mon, displayName)
if Runtime.wantsHook("catch.nickname") then
local verdict = Runtime.call("catch.nickname", alwaysAsk, mon,
{ battle = self, name = displayName, game = self.game })
if verdict == false then return false end
if type(verdict) == "string" then
-- the naming grid's own limit, so a mod cannot hand the party a name
-- the summary screen has no room to draw
verdict = verdict:sub(1, 10)
if #verdict > 0 then mon.nickname = verdict end
return false
end
end
self:uiNext(function() return self:askNicknameUI(mon, displayName) end)
return true
end
-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank,
-- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party
-- AddPartyMon and SendNewMonToBox (#172).
@@ -5180,11 +5230,7 @@ function BattleState:storeCaughtMon()
end)
end
local function askCaughtNickname()
local caught = self.enemy.mon
local enemyName = self.enemy.name
self:uiNext(function()
return self:askNicknameUI(caught, enemyName)
end)
self:offerNickname(self.enemy.mon, self.enemy.name)
end
if Party.add(game.save.party, self.enemy.mon) then
askCaughtNickname()
+129
View File
@@ -0,0 +1,129 @@
-- A sandboxed mod can decide two battle rules the OPTION screen and the cart
-- otherwise decide for the player -- whether a faint offers a free switch
-- (battle.style) and whether a catch asks for a nickname (catch.nickname) --
-- using only public mod surfaces, and neither hook touches the player's
-- saved preference.
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 TextBox = require("src.render.TextBox")
local FIXTURE = {
["mods/rules_probe/manifest.json"] = [[{
"id": "rules_probe",
"name": "Rules Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/rules_probe/main.lua"] = [[
local mod = ...
mod.exports.style = "set"
mod.exports.nickname = false
mod.hooks:wrap("battle.style", function(next, battle)
mod.exports.styleCalls = (mod.exports.styleCalls or 0) + 1
mod.exports.styleBattle = battle
if mod.exports.style ~= nil then return mod.exports.style end
return next(battle)
end)
mod.hooks:wrap("catch.nickname", function(next, mon, ctx)
mod.exports.nameCalls = (mod.exports.nameCalls or 0) + 1
mod.exports.nameCtx = ctx
if mod.exports.nickname ~= nil then return mod.exports.nickname end
return next(mon, ctx)
end)
]],
}
local function fixtureBattle(style)
local data = { pokemon = {}, text = {} }
return setmetatable({
game = { save = { options = { battleStyle = style } },
stack = { push = function() end }, data = data },
data = data,
queue = {}, nextInsert = 0,
}, { __index = BattleState })
end
-- does the queued UI, if any, put the yes/no prompt on screen?
local function promptQueued(battle)
for _, item in ipairs(battle.queue) do
if item.ui then
local state = item.ui()
if getmetatable(state) == TextBox and state.choice then return true end
end
end
return false
end
-- ------- no mod: the OPTION row and the cart's AskName decide
local vanilla = T.sdk.loadNone({})
T.eq(fixtureBattle("shift"):battleStyle(), "shift", "no mod: SHIFT row reads shift")
T.eq(fixtureBattle("set"):battleStyle(), "set", "no mod: SET row reads set")
T.eq(fixtureBattle("SET"):battleStyle(), "set", "no mod: the row is case-insensitive")
T.eq(fixtureBattle(nil):battleStyle(), "shift", "no mod: a missing row is the cart default")
local plain = fixtureBattle("shift")
local mon = { species = "RATTATA" }
T.eq(plain:offerNickname(mon, "RATTATA"), true, "no mod: a catch queues the prompt")
T.eq(promptQueued(plain), true, "no mod: and it is the yes/no box")
T.eq(mon.nickname, nil, "no mod: nothing is named behind the player's back")
vanilla.release()
-- ------- a mod answers both
local run = T.sdk.loadMods({ "mods/rules_probe" }, { fs = T.sdk.memfs(FIXTURE) })
T.eq(#run.errors, 0, "the rules probe loads clean (" .. tostring(run.errors[1]) .. ")")
local probe = run.loader.exports.rules_probe
local forced = fixtureBattle("shift")
T.eq(forced:battleStyle(), "set", "\"set\" wins over a SHIFT row")
T.eq(forced.game.save.options.battleStyle, "shift", "without writing the row")
T.eq(probe.styleCalls, 1, "the hook ran once")
T.check(probe.styleBattle == forced, "and was handed the battle")
probe.style = "shift"
T.eq(fixtureBattle("set"):battleStyle(), "shift", "\"shift\" wins over a SET row")
probe.style = "banana"
T.eq(fixtureBattle("set"):battleStyle(), "set",
"an answer that is neither reads as the row")
probe.style = nil
T.eq(fixtureBattle("set"):battleStyle(), "set", "falling through reads the row")
local skipped = fixtureBattle("shift")
local kept = { species = "RATTATA" }
T.eq(skipped:offerNickname(kept, "RATTATA"), false, "false: no prompt is queued")
T.eq(#skipped.queue, 0, "nothing at all is queued")
T.eq(kept.nickname, nil, "and the species name is kept")
T.eq(probe.nameCalls, 1, "the hook ran once")
T.check(probe.nameCtx and probe.nameCtx.battle == skipped, "with the battle in ctx")
T.eq(probe.nameCtx and probe.nameCtx.name, "RATTATA", "and the display name")
probe.nickname = "SPIKE"
local named = { species = "RATTATA" }
T.eq(fixtureBattle("shift"):offerNickname(named, "RATTATA"), false,
"a string: no prompt either")
T.eq(named.nickname, "SPIKE", "and it is the nickname")
probe.nickname = "TOOLONGFORTHEGRID"
local clipped = { species = "RATTATA" }
fixtureBattle("shift"):offerNickname(clipped, "RATTATA")
T.eq(clipped.nickname, "TOOLONGFOR", "a long string is clipped to the grid's ten")
probe.nickname = ""
local blank = { species = "RATTATA" }
T.eq(fixtureBattle("shift"):offerNickname(blank, "RATTATA"), false,
"an empty string still declines the prompt")
T.eq(blank.nickname, nil, "and names nothing, like the grid's own empty entry")
probe.nickname = nil
local asked = fixtureBattle("shift")
T.eq(asked:offerNickname({ species = "RATTATA" }, "RATTATA"), true,
"falling through asks as usual")
T.eq(promptQueued(asked), true, "with the real prompt")
run.release()
T.finish("battle rule hooks")