mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
Merge pull request #1798 from campavao/rfc-0015-battle-rule-hooks
engine: battle.style and catch.nickname rule hooks
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user