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