diff --git a/docs/modding.md b/docs/modding.md index 55f8ee82..7c464abe 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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 = , name = , 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 diff --git a/docs/rfcs/0015-battle-rule-hooks.md b/docs/rfcs/0015-battle-rule-hooks.md new file mode 100644 index 00000000..ca482c92 --- /dev/null +++ b/docs/rfcs/0015-battle-rule-hooks.md @@ -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 = , name = , 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. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 0130903c..2889573c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -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() diff --git a/tests/modkit/cases/battle_rule_hooks.lua b/tests/modkit/cases/battle_rule_hooks.lua new file mode 100644 index 00000000..16a91f4d --- /dev/null +++ b/tests/modkit/cases/battle_rule_hooks.lua @@ -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")