Two decisions the OPTION screen and the cart make for the player, made hookable so a game mode can make them instead: whether a faint offers the SHIFT free switch, and whether a catch asks for a nickname. Guarded call sites, file-local vanilla links, docs and a public-API modkit case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1DVpYXGabigHqMwviDoKV
4.6 KiB
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
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
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.