Files
gen1recomp/tests/modkit/cases/battle_rule_hooks.lua
T
DESKTOP-8SRFDDM\cam95 2ca07cfd09 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
2026-08-25 09:20:35 -05:00

130 lines
5.0 KiB
Lua

-- 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")