engine: battle.style and catch.nickname rule hooks (RFC 0015)

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
This commit is contained in:
DESKTOP-8SRFDDM\cam95
2026-08-24 21:06:05 -05:00
parent bbe0f0d9ae
commit 2ca07cfd09
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.