Merge pull request #1434 from sanjinpepic/upstream-fixes

This commit is contained in:
bryanthaboi
2026-08-16 20:34:55 -04:00
committed by GitHub
18 changed files with 496 additions and 15 deletions
@@ -131,4 +131,25 @@ T.same(Checkpoint.inspect(game), {
canCapture = true, canRestore = true, kind = "overworld",
}, "settled overworld remains supported")
-- drainHold gates capture (see the refused() case above) exactly because it
-- marks an HP bar mid-animation. Once stepHPDrain settles the bar it must
-- let go of that gate too, or the very first drain of a battle leaves the
-- checkpoint contract refused for everything after it.
do
local game3, _, battle3 = makeGame()
battle3.enemy.mon.hp = battle3.enemy.mon.hp - 5
local frames = 0
while battle3:stepHPDrain() and frames < 10000 do
frames = frames + 1
end
T.eq(battle3.enemy.shownHP, battle3.enemy.mon.hp,
"the HP bar settles on the new total")
T.eq(battle3.enemy.drainHold, nil,
"drainHold releases the checkpoint gate once the bar finishes draining")
local capability = Checkpoint.inspect(game3)
T.check(capability.canCapture == true,
"a checkpoint is capturable again after the drain settles: "
.. tostring(capability.reason))
end
T.finish()
+26
View File
@@ -468,6 +468,32 @@ do
T.eq(forced.shiny, true, "opts.shiny still wins over shiny.roll")
end)
-- Mon.syncIdentity (wired into refreshStats, which SummaryMenu.new calls
-- on every menu open) used to recompute mon.shiny from DVs unconditionally,
-- so opening the summary screen on a forced shiny -- one whose DVs do not
-- happen to match the natural pattern -- un-shinied it the moment the menu
-- opened. shiny is monotonic once true: a natural roll or a forced one
-- both stay shiny through any later refresh, the way opts.shiny already
-- wins at construction.
do
local forced = Mon.new(DATA, "SEEDMON", 5, { dvs = plainDvs, shiny = true })
T.eq(forced.shiny, true, "still shiny straight out of Mon.new")
Mon.syncIdentity(forced, DATA)
T.eq(forced.shiny, true, "syncIdentity does not clobber a forced shiny")
Mon.refreshStats(forced, DATA)
T.eq(forced.shiny, true,
"refreshStats (SummaryMenu.new's call) does not either")
-- the natural cases are unaffected: DVs that read shiny stay shiny,
-- DVs that do not stay plain
local natural = Mon.new(DATA, "SEEDMON", 5, { dvs = shinyDvs })
Mon.syncIdentity(natural, DATA)
T.eq(natural.shiny, true, "a naturally shiny mon still reads shiny")
local plain = Mon.new(DATA, "SEEDMON", 5, { dvs = plainDvs })
Mon.syncIdentity(plain, DATA)
T.eq(plain.shiny, false, "a plain mon is not promoted to shiny")
end
local genderCtx
withHook("gender.roll", function(nextFn, ctx)
genderCtx = ctx
+31
View File
@@ -0,0 +1,31 @@
-- ItemEffects.use crashed instead of refusing when a species record carried
-- no tmhm list at all (ipairs(nil)), which is a different situation from a
-- species whose list simply does not name the move being taught -- that
-- case already refuses cleanly with MonCannotLearnMachineMoveText. A mod
-- species missing the field entirely took the whole game down on the very
-- first TM/HM use rather than reaching that refusal.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness").suite("item effects tmhm nil")
local Fixtures = require("tests.modkit.fixtures")
local ItemEffects = require("src.inventory.ItemEffects")
local Pokemon = require("src.pokemon.Pokemon")
local Data = Fixtures.fresh()
Data.pokemon.FIXMON_A.tmhm = nil
local mon = Pokemon.new(Data, "FIXMON_A", 10)
local save = { player = { name = "RED" } }
local ok, result, payload = pcall(ItemEffects.use, Data, save, "FIX_TM", mon)
T.check(ok, "using a TM on a species with no tmhm list does not crash: "
.. tostring(result))
if ok then
T.eq(result, "failed", "the species refuses the move instead of crashing into it")
T.check(type(payload) == "table" and payload[1] ~= nil,
"a refusal message is still returned")
end
T.finish()
+101
View File
@@ -0,0 +1,101 @@
-- Public mod-API coverage for the "item.use" hook (src/ui/BagMenu.lua).
--
-- Before this hook existed, every result ItemEffects.use returned fell
-- through to one unconditional call with nothing wrapped around it: a mod
-- could not suppress a message, delay it behind a screen of its own, or
-- replace what a specific item id does after the bag decides to use it.
-- This exercises the seam end to end through the public mod API -- a real
-- BagMenu list, a real USE selection -- rather than calling the hook
-- machinery directly.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Bag = require("src.inventory.Bag")
-- Real TextBoxes want a Font atlas; this only cares that useOn reaches the
-- no-effect fallthrough, so the same stand-in tests/parity_rare_candy_menu.lua
-- uses for a ROM-backed run works here too.
local realTextBox = package.loaded["src.render.TextBox"]
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
}
package.loaded["src.ui.BagMenu"] = nil
local BagMenu = require("src.ui.BagMenu")
local FIXTURE = {
["mods/item_hook_probe/manifest.json"] = [[{
"id": "item_hook_probe",
"name": "Item Hook Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/item_hook_probe/main.lua"] = [[
local mod = ...
mod.hooks:wrap("item.use",
function(vanilla, game, battle, id, target, list, moveIndex, picker)
mod.exports.calls = (mod.exports.calls or 0) + 1
mod.exports.id = id
mod.exports.battle = battle
mod.exports.target = target
return vanilla(game, battle, id, target, list, moveIndex, picker)
end)
]],
}
local function newStack()
local stack = { states = {} }
function stack:push(s) self.states[#self.states + 1] = s end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return stack
end
local run = T.sdk.loadMods({ "mods/item_hook_probe" }, {
fs = T.sdk.memfs(FIXTURE),
})
T.eq(#run.errors, 0,
"the probe mod loads clean (" .. tostring(run.errors[1]) .. ")")
local game = {
data = run.data,
stack = newStack(),
save = {
player = { name = "RED" }, inventory = {}, money = 0,
options = { battleStyle = "set", battleAnim = "on" },
pokedex = { seen = {}, owned = {} }, flags = {},
},
}
Bag.add(game.save, "FIX_POTION", 1)
local list = BagMenu.new(game, {})
game.stack:push(list)
local row
for i, r in ipairs(list.items) do
if r.value == "FIX_POTION" then row = i end
end
T.check(row ~= nil, "the fixture item is in the bag")
list.index = row
list.onChoose(list.items[row], list)
-- out of battle the bag offers USE / TOSS first (start_sub_menus.asm)
local sub = game.stack:top()
T.check(sub ~= nil and sub.items and sub.items[1] and sub.items[1].onSelect,
"the USE/TOSS submenu opened")
sub.items[1].onSelect()
local out = run.loader.exports.item_hook_probe or {}
T.eq(out.calls, 1, "the hook fires exactly once for a bag item use")
T.eq(out.id, "FIX_POTION", "the hook sees the item id")
T.eq(out.battle, nil, "the hook sees the field-use battle argument (nil)")
local top = game.stack:top()
T.check(type(top) == "table" and top.textBox == true,
"vanilla still ran: the no-effect message box landed on the stack")
run.release()
package.loaded["src.render.TextBox"] = realTextBox
package.loaded["src.ui.BagMenu"] = nil
T.finish()
@@ -0,0 +1,89 @@
-- A map object's `pokemon` field (the static wild encounter kind --
-- OverworldController.lua's `d.pokemon`, handed straight to
-- BattleState.newWild with no existence check of its own) used to go
-- completely unchecked: R.maps.objects was f.opt(f.list(f.any)), so a
-- typo'd species sat in a loaded mod and only surfaced as a crash the
-- moment a player stepped up to that object. Every other kind sharing the
-- objects array (NPCs, signs-as-objects, warps) has fields this schema
-- still does not know about, which is what f.partial is for: it types only
-- `pokemon` and leaves the rest of an object's shape alone.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local function manifest(id)
return ([[{
"id": "%s", "name": "%s", "version": "1.0.0",
"entry": "main.lua", "api": 2
}]]):format(id, id)
end
-- ------- a bad species id is caught as a load error, not left to crash
local BAD = {
["mods/bad_static_encounter/manifest.json"] = manifest("bad_static_encounter"),
["mods/bad_static_encounter/main.lua"] = [[
local mod = ...
mod.content.maps:patch("FIX_ROUTE", {
objects = {
{ pokemon = "NOT_A_SPECIES", level = 30, text = "Gyaoo!" },
},
})
]],
}
do
local run = T.sdk.loadMods({ "mods/bad_static_encounter" },
{ fs = T.sdk.memfs(BAD) })
local dangling = {}
for _, message in ipairs(run.errors) do
if message:match("unresolved reference") then
dangling[#dangling + 1] = message
end
end
T.eq(#dangling, 1,
"a bad static-encounter species is reported once ("
.. table.concat(dangling, "; ") .. ")")
T.check(dangling[1] and dangling[1]:match("maps%.FIX_ROUTE%.objects")
and dangling[1]:match("pokemon"),
"the report names the map, the objects field and the pokemon registry: "
.. tostring(dangling[1]))
run.release()
end
-- ------- a real species resolves, and an NPC-shaped object beside it (no
-- pokemon field at all, and fields this schema never named -- sprite,
-- movement, range) is untouched
local GOOD = {
["mods/good_static_encounter/manifest.json"] = manifest("good_static_encounter"),
["mods/good_static_encounter/main.lua"] = [[
local mod = ...
mod.content.maps:patch("FIX_ROUTE", {
objects = {
{ index = 1, name = "FIXROUTE_TRAINER", sprite = "SPRITE_FIX_NPC",
movement = "STAY", range = "NONE", text = "TEXT_FIXROUTE_TRAINER",
x = 5, y = 9 },
{ pokemon = "FIXMON_A", level = 30, text = "Gyaoo!" },
},
})
]],
}
do
local run = T.sdk.loadMods({ "mods/good_static_encounter" },
{ fs = T.sdk.memfs(GOOD) })
T.eq(#run.errors, 0,
"a real species and an untyped NPC object both load clean ("
.. tostring(run.errors[1]) .. ")")
local objects = run.data.maps.FIX_ROUTE.objects
T.eq(#objects, 2, "both objects landed on the map")
T.eq(objects[1].sprite, "SPRITE_FIX_NPC",
"the NPC object's untyped fields passed through unexamined")
T.eq(objects[2].pokemon, "FIXMON_A",
"the static encounter's species field passed through too")
run.release()
end
T.finish()
+48
View File
@@ -117,6 +117,27 @@ local DATA = {
fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_NOUSE" },
OLD_ROD = { id = "OLD_ROD", pocket = "KEY", name = "OLD ROD",
fieldMenu = "ITEMMENU_CURRENT", battleMenu = "ITEMMENU_NOUSE" },
-- a mod's own battle-pack item: its action lives only in
-- gen2ItemEffects below, which ItemEffects.RECORDS (the module's
-- built-in table) has never heard of (#8)
MOD_ITEM = item("MOD_ITEM"),
},
gen2ItemEffects = {
-- a status cure rather than an HP heal: HP is exposed to the wild
-- mon's own reply once the item spends the turn, which would make a
-- direct before/after HP check depend on incidental battle math this
-- fix has nothing to do with. Status is not.
MOD_ITEM = {
action = "status", field = true, needsTarget = true,
use = function(ctx)
local mon = ctx.mon
if mon.status ~= "poison" then
return { used = false, text = "It won't have\nany effect." }
end
mon.status = nil
return { used = true, text = "MOD ITEM used!" }
end,
},
},
}
@@ -236,6 +257,33 @@ do
eq(save.inventory.ANTIDOTE, 1, "with the ANTIDOTE untouched")
end
-- ---- a mod's own battle-pack item (#8) -------------------------------------
-- BattleState:useItem asked ItemEffects.partyAction for the item's family
-- with no `data` argument, the same omission Game2:usePartyItem had for the
-- field pack, so a mod item's action -- present only in the merged
-- gen2ItemEffects table -- resolved to nil and the pack fell straight to
-- "That isn't going to help here." instead of opening the party list.
do
local sick = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect })
sick.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
sick.status = "poison"
local screen, _, _, save, pushed = newScreen({
player = sick, party = { sick }, inventory = { MOD_ITEM = 1 },
})
check(runToMenu(screen), "reached the menu")
screen:useItem("MOD_ITEM")
eq(screen.phase, "submenu",
"a mod's own gen2ItemEffects record opens UseItem_SelectMon")
local picker = pushed[#pushed]
eq(getmetatable(picker), PartyMenu, "and the pick is the party screen")
if picker and picker.onChoose then
picker.onChoose(1, sick)
eq(sick.status, nil, "the mod item's own use() ran through the real screens")
eq(save.inventory.MOD_ITEM, nil, "and the mod item was spent")
end
end
-- ---- IsItemUsedOnConfusedMon: the battle-only arm --------------------------
do
local screen, battle, player, save, pushed = newScreen({
+36
View File
@@ -102,6 +102,24 @@ local DATA = {
TM01 = { id = "TM01", name = "TM01", pocket = "TM_HM", index = 191,
fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_NOUSE",
teaches = "SWIFT" },
-- a mod's own field item, whose action lives only in gen2ItemEffects
-- below -- ItemEffects.RECORDS (the module's built-in table) has never
-- heard of it, so resolving it at all requires the merged dataset (#8)
MOD_ITEM = { id = "MOD_ITEM", name = "MOD ITEM", pocket = "ITEM",
index = 250, fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_PARTY" },
},
gen2ItemEffects = {
MOD_ITEM = {
action = "heal", field = true, needsTarget = true,
use = function(ctx)
local mon = ctx.mon
if mon.hp >= mon.maxHp then
return { used = false, text = "It won't have\nany effect." }
end
mon.hp = math.min(mon.maxHp, mon.hp + 5)
return { used = true, text = "MOD ITEM used!" }
end,
},
},
gen2MenuGfx = {},
gen2Icons = {
@@ -461,6 +479,24 @@ do
eq(host.save.inventory.HP_UP, nil, "and the HP UP was spent")
end
do
-- #8 regression: Game2:usePartyItem asked ItemEffects.partyAction for the
-- item's family with no `data` argument, so it could only ever see
-- RECORDS -- the module's own built-ins. A mod's field item, whose
-- action exists only in the merged gen2ItemEffects table, resolved to a
-- nil action and fell straight through to the "isn't going to help here"
-- refusal instead of opening the party list at all.
local mon = fixtureMon(12, { hp = 10 })
local host = newHost({ MOD_ITEM = 1 }, { mon })
host:useFieldItem("MOD_ITEM")
local party = host.stack:top()
check(party ~= nil and party.prompt ~= nil,
"a mod's own gen2ItemEffects record opens the party list")
drive(host, function() return host.stack:top() ~= party end)
eq(mon.hp, 15, "the mod item's own use() ran through the real menu")
eq(host.save.inventory.MOD_ITEM, nil, "and the mod item was spent")
end
do
local mon = fixtureMon(12, { statExp = {
hp = 25600, attack = 0, defense = 0, speed = 0, special = 0 } })
+7
View File
@@ -44,6 +44,11 @@ local PROBE = [[
out.requireDebug = attempt(require, "debug")
out.requirePackage = attempt(require, "package")
out.requireFfi = attempt(require, "ffi")
-- jit.util exposes bytecode/constant introspection over any function this
-- chunk can reach -- the same class of escape the debug library is denied
-- for -- so it must fail the same way require("debug") does rather than
-- walking straight through under the bare "jit" global's cover.
out.requireJitUtil = attempt(require, "jit.util")
out.requireSocket = attempt(require, "socket")
out.requireSemver = select(2, pcall(require, "src.mods.Semver"))
@@ -180,6 +185,8 @@ T.eq(out.getfenv, nil, "getfenv is still absent, so a mod cannot read the real _
T.eq(out.debug, nil, "the debug library is still absent")
T.check(out.loveThread ~= false, "love.thread is still refused: it opens a full Lua state")
T.check(out.requireFfi ~= false, "require(\"ffi\") is still refused: it is arbitrary C")
T.check(out.requireJitUtil ~= false,
"require(\"jit.util\") is still refused: it is bytecode/constant introspection")
T.check(out.requireDebug ~= false, "require(\"debug\") is still refused")
T.check(out.requirePackage ~= false, "require(\"package\") is still refused")
T.eq(out.popen, nil, "io.popen refuses rather than spawning a process")
+17
View File
@@ -324,6 +324,23 @@ do
check(bound.gen2Tilesets == bound.tilesets, "bindGoldData aliases gen2Tilesets")
check(Gen.tilesets({ gen2Tilesets = { TILESET_GYM = true } }).TILESET_GYM,
"Gen.tilesets prefers gen2Tilesets")
-- bindGoldData bound gen2Palettes/gen2Icons/gen2Pokedex/gen2Landmarks/
-- gen2Roofs/gen2Sprites through loadGen but never gen2Constants, so any
-- mod reading mod.content.constants:get(...) under a save-editor Gold
-- bootstrap saw an empty table where it expected the cart's ordered name
-- lists. loadGen falls back to require("data.generated.constants") when
-- the ROM cache has nothing active, which is what a checkout with no
-- ROM imported hits too -- stub that module the same way to prove the
-- wiring without needing a real Gold extraction.
package.loaded["data.generated.constants"] = { badges = { "ZEPHYR" } }
local withConstants = Gen.bindGoldData({})
package.loaded["data.generated.constants"] = nil
check(withConstants.gen2Constants ~= nil,
"bindGoldData populates gen2Constants")
check(withConstants.gen2Constants and withConstants.gen2Constants.badges
and withConstants.gen2Constants.badges[1] == "ZEPHYR",
"gen2Constants carries the extractor's own name lists")
end
do