mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-26 15:31:15 +02:00
2ca07cfd09
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
130 lines
5.0 KiB
Lua
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")
|