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
+9
View File
@@ -1103,6 +1103,15 @@ function BattleState:stepHPDrain()
if not b.shownPx then b.shownPx = targetPx end if not b.shownPx then b.shownPx = targetPx end
if (b.drainHold or 0) > 0 then if (b.drainHold or 0) > 0 then
b.drainHold = b.drainHold - 1 b.drainHold = b.drainHold - 1
-- Once the count runs out with nothing left pending (bar and
-- number already on the final total), the drain is over, not just
-- between steps: leave the field at 0 and BattleSafety.inspect
-- reads it as still mid-animation for the rest of the battle,
-- since drainHold ~= nil is its settled-presentation gate.
if b.drainHold <= 0 and b.shownPx == targetPx and b.shownHP == goal
and not b.draining then
b.drainHold = nil
end
busy = true busy = true
elseif b.shownPx ~= targetPx then elseif b.shownPx ~= targetPx then
-- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 / -- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 /
+7 -1
View File
@@ -112,7 +112,13 @@ function Mon.syncIdentity(mon, data)
if mon.dvs then if mon.dvs then
mon.gender = Mon.gender(def, mon.dvs, mon.gender = Mon.gender(def, mon.dvs,
{ species = mon.species, level = mon.level }) { species = mon.species, level = mon.level })
mon.shiny = Mon.isShiny(mon.dvs, -- shiny is monotonic once true, the same as opts.shiny winning over
-- shiny.roll at Mon.new: a forced shiny (the scripted-shiny path, DVs
-- that do not themselves read as shiny) must not un-shiny the moment
-- this runs again, and it runs on every SummaryMenu open via
-- refreshStats. A mon not already shiny still promotes normally if
-- its DVs justify it, e.g. after an edit.
mon.shiny = mon.shiny or Mon.isShiny(mon.dvs,
{ species = mon.species, def = def, level = mon.level }) { species = mon.species, def = def, level = mon.level })
if mon.species == Unown.SPECIES then if mon.species == Unown.SPECIES then
mon.unownLetter = Unown.letterFromDVs(mon.dvs) mon.unownLetter = Unown.letterFromDVs(mon.dvs)
+4 -1
View File
@@ -683,7 +683,10 @@ end
-- .SelectMon / PPRestoreItem_Cancel carry path: nothing spent. -- .SelectMon / PPRestoreItem_Cancel carry path: nothing spent.
function Game2:usePartyItem(itemId) function Game2:usePartyItem(itemId)
local ItemEffects = require("src.core.gen2.ItemEffects") local ItemEffects = require("src.core.gen2.ItemEffects")
local action = ItemEffects.partyAction(itemId) -- without the merged dataset this can only ever see RECORDS, the
-- module's own built-ins, so a mod's field item resolves to no action
-- at all and never gets past the .Oak refusal below
local action = ItemEffects.partyAction(itemId, self.data)
if not action then return end if not action then return end
local party = (self.save and self.save.party) or {} local party = (self.save and self.save.party) or {}
if #party == 0 then if #party == 0 then
+4 -1
View File
@@ -515,7 +515,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if not target then return "failed", { noEffect(data) } end if not target then return "failed", { noEffect(data) } end
local speciesDef = data.pokemon[target.species] local speciesDef = data.pokemon[target.species]
local ok = false local ok = false
for _, m in ipairs(speciesDef.tmhm) do -- a species record with no tmhm list at all is "teaches nothing", the
-- same as one whose list just does not name this move -- not a reason
-- to crash instead of refusing normally
for _, m in ipairs(speciesDef.tmhm or {}) do
if m == itemDef.machine.move then ok = true break end if m == itemDef.machine.move then ok = true break end
end end
if not ok then if not ok then
+8 -2
View File
@@ -28,8 +28,14 @@ local DENIED = {
} }
-- Same idea one level up: love.filesystem is reachable by name, and -- Same idea one level up: love.filesystem is reachable by name, and
-- love.thread starts a Lua state this sandbox has no say over. -- love.thread starts a Lua state this sandbox has no say over. jit.util
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true } -- is the LuaJIT-specific equal of the debug library above -- funcbc,
-- funck and friends read the bytecode and constants of any function a
-- chunk can reach, which is enough to walk back to upvalues (the real
-- _G, love, io) the rest of this file exists to keep out of reach. The
-- bare `jit` table stays -- env.jit above hands it over directly for
-- jit.on/off/flush -- so only the submodule require is denied.
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true, ["jit"] = true }
-- The wire, which is what the network permission governs. -- The wire, which is what the network permission governs.
local NETWORK = { socket = true, enet = true, http = true, https = true, local NETWORK = { socket = true, enet = true, http = true, https = true,
+62 -7
View File
@@ -91,6 +91,28 @@ function f.rec(fields, opts)
desc = "{" .. table.concat(parts, ", ") .. "}" } desc = "{" .. table.concat(parts, ", ") .. "}" }
end end
-- An open record: the listed fields are typed (including f.id
-- cross-references) and everything else on the value passes through
-- unexamined, unlike f.rec's nested shapes, which reject any key they do
-- not name. Map objects are why this exists -- NPCs, signs, items, warps
-- and static encounters all share one array, and only a full union of
-- every kind's shape could describe it as f.rec; that is a lot of surface
-- to keep in sync with the loader for fields nothing here needs to check.
-- f.partial types just the field that actually names another registry and
-- leaves every kind-specific field around it alone.
function f.partial(fields)
local names = {}
for name in pairs(fields) do names[#names + 1] = name end
table.sort(names)
local parts = {}
for _, name in ipairs(names) do
local ft = fields[name]
parts[#parts + 1] = name .. (ft.kind == "opt" and "?" or "")
end
return { kind = "partial", fields = fields,
desc = "{" .. table.concat(parts, ", ") .. ", ...}" }
end
function f.union(alts) function f.union(alts)
local parts = {} local parts = {}
for _, alt in ipairs(alts) do parts[#parts + 1] = alt.desc end for _, alt in ipairs(alts) do parts[#parts + 1] = alt.desc end
@@ -197,6 +219,23 @@ checkValue = function(t, value, path, patchMode, errors, top)
end end
return return
end end
if kind == "partial" then
-- the open counterpart of "rec": listed fields are checked exactly
-- like a rec's, and any key not listed is left alone rather than
-- flagged, so a heterogeneous blob (map objects) can have one field
-- typed without every other shape sharing the array being rejected
if type(value) ~= "table" then return fail(errors, path, t.desc, value) end
for key, ft in pairs(t.fields) do
local sub = value[key]
if sub ~= nil then
checkValue(ft, sub, path .. "." .. tostring(key), patchMode, errors)
elseif ft.kind ~= "opt" and not patchMode then
errors[#errors + 1] = ("%s.%s: missing required field (%s)")
:format(path, key, ft.desc)
end
end
return
end
if kind == "union" then if kind == "union" then
for _, alt in ipairs(t.alts) do for _, alt in ipairs(t.alts) do
local scratch = {} local scratch = {}
@@ -299,7 +338,7 @@ collectRefs = function(t, value, path, out)
for k, v in pairs(value) do for k, v in pairs(value) do
collectRefs(t.value, v, path .. "." .. tostring(k), out) collectRefs(t.value, v, path .. "." .. tostring(k), out)
end end
elseif kind == "rec" and type(value) == "table" then elseif (kind == "rec" or kind == "partial") and type(value) == "table" then
for key, ft in pairs(t.fields) do for key, ft in pairs(t.fields) do
collectRefs(ft, value[key], path .. "." .. tostring(key), out) collectRefs(ft, value[key], path .. "." .. tostring(key), out)
end end
@@ -377,11 +416,19 @@ function Schemas.crossValidate(loader, data)
and loader.content[ref.registry] and loader.content[ref.registry]
-- A registry with no home in this generation has no id space to -- A registry with no home in this generation has no id space to
-- check against: its base view resolves to nothing, so EVERY -- check against: its base view resolves to nothing, so EVERY
-- reference into it would read as dangling. Gold's species carry a -- reference into it would read as dangling. `transitions` is the
-- growthRate and an evolution method like Red's do; the ids are -- standing example -- Gold draws its own battle intro and never
-- fine, it is the Gen 1 `growth_rates` / `evolution_methods` -- reads the merged table, so a mod's transition id there is
-- namespaces that are not there to confirm them. Skipped for the -- unconfirmable, not wrong. `growth_rates` and `evolution_methods`
-- same reason an undeclared registry is: unknown, not wrong. -- used to sit in that category too, back when Gold had no id space
-- for either. Both are routed now: `growth_rates` keeps its Gen 1
-- path and is seeded from data.pokemon.growthRates (the extractor's
-- Gold curves, src/battle/gen2/Mon.lua), and `evolution_methods`
-- routes to gen2EvolutionMethods (src/core/gen2/Evolution.lua's
-- literal EVOLVE_* ids, present with or without a ROM import). A
-- Gold species' growthRate or evolution method is checked against
-- real ids exactly like a Red one's, so a genuine typo is still
-- caught here rather than waved through as "unknown, not wrong."
if refRegistry and Schemas.gatedFor(ref.registry, loader.generation) then if refRegistry and Schemas.gatedFor(ref.registry, loader.generation) then
refRegistry = nil refRegistry = nil
end end
@@ -887,7 +934,15 @@ R.maps = {
destMap = f.str, destWarp = f.int(0), destMap = f.str, destWarp = f.int(0),
destGroup = f.opt(f.int(0)), destGroup = f.opt(f.int(0)),
destMapNum = f.opt(f.int(0)) })), destMapNum = f.opt(f.int(0)) })),
objects = f.opt(f.list(f.any)), -- NPCs, signs, items, warps and static wild encounters all share this
-- one array, with no field the loader could use to tell them apart
-- ahead of time -- an f.rec strict enough to describe every kind would
-- reject the others. f.partial types only `pokemon` (the static
-- encounter's species, OverworldController.lua's `d.pokemon` ->
-- BattleState.newWild) so a bad id is a load-time error, the same as
-- an encounter slot's species, instead of the crash newWild has no
-- guard against. Every other object field passes through untouched.
objects = f.opt(f.list(f.partial{ pokemon = f.opt(f.id("pokemon")) })),
signs = f.opt(f.list(f.any)), signs = f.opt(f.list(f.any)),
connections = f.opt(f.map(f.enum{ "north", "south", "east", "west" }, f.any)), connections = f.opt(f.map(f.enum{ "north", "south", "east", "west" }, f.any)),
}, },
+16 -1
View File
@@ -4,6 +4,7 @@
local ItemEffects = require("src.inventory.ItemEffects") local ItemEffects = require("src.inventory.ItemEffects")
local ListMenu = require("src.ui.ListMenu") local ListMenu = require("src.ui.ListMenu")
local Runtime = require("src.mods.Runtime")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local BagMenu = {} local BagMenu = {}
@@ -46,7 +47,16 @@ end
-- the stack, so every exit that prints has to close it afterwards. For -- the stack, so every exit that prints has to close it afterwards. For
-- every other item the picker popped itself first and closePicker's identity -- every other item the picker popped itself first and closePicker's identity
-- check makes it a no-op (#252). -- check makes it a no-op (#252).
local function useOn(game, battle, id, target, list, moveIndex, picker) --
-- Every result string used to fall through to this one unconditional
-- function with no seam around it: a mod could not suppress a message,
-- delay it behind a screen of its own, or replace the outcome for one item
-- id. The "item.use" hook wraps the whole dispatch (not a name per
-- result -- a mod deciding what a Poké Doll or a stone does needs the
-- SAME reach a vanilla `if result == ...` branch has, not a narrower one),
-- the way "battle.overlay" and "ui.party.submenu" already wrap a
-- screen's own default behavior elsewhere in src/ui.
local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
local result, payload, extra = ItemEffects.use(game.data, game.save, id, target, local result, payload, extra = ItemEffects.use(game.data, game.save, id, target,
battle, moveIndex, game.overworld) battle, moveIndex, game.overworld)
local function closePicker() local function closePicker()
@@ -375,6 +385,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
showMessages(game, payload, closePicker) -- failed showMessages(game, payload, closePicker) -- failed
end end
local function useOn(game, battle, id, target, list, moveIndex, picker)
return Runtime.call("item.use", vanillaUseOn,
game, battle, id, target, list, moveIndex, picker)
end
local function pickTargetAndUse(game, battle, id, list) local function pickTargetAndUse(game, battle, id, list)
-- pick a target from the party -- pick a target from the party
-- the ETHERs and PP UP open the move menu after picking a mon -- the ETHERs and PP UP open the move menu after picking a mon
+4 -2
View File
@@ -3024,8 +3024,10 @@ function BattleState:useItem(itemId)
-- Everything else the pack can spend on a party mon runs the same -- Everything else the pack can spend on a party mon runs the same
-- item_effects.asm routine the field pack runs: the potion line and the -- item_effects.asm routine the field pack runs: the potion line and the
-- drinks, the status cures and their berries, REVIVE / MAX REVIVE, and -- drinks, the status cures and their berries, REVIVE / MAX REVIVE, and
-- the ETHER / ELIXER family. -- the ETHER / ELIXER family. Without the merged dataset this can only
local action = ItemEffects.partyAction(itemId) -- ever see RECORDS, the module's own built-ins, the same gap
-- Game2:usePartyItem had for the field pack.
local action = ItemEffects.partyAction(itemId, self.game and self.game.data)
if action then if action then
return self:useOnPartyMon(itemId, action) return self:useOnPartyMon(itemId, action)
end end
@@ -131,4 +131,25 @@ T.same(Checkpoint.inspect(game), {
canCapture = true, canRestore = true, kind = "overworld", canCapture = true, canRestore = true, kind = "overworld",
}, "settled overworld remains supported") }, "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() T.finish()
+26
View File
@@ -468,6 +468,32 @@ do
T.eq(forced.shiny, true, "opts.shiny still wins over shiny.roll") T.eq(forced.shiny, true, "opts.shiny still wins over shiny.roll")
end) 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 local genderCtx
withHook("gender.roll", function(nextFn, ctx) withHook("gender.roll", function(nextFn, ctx)
genderCtx = 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" }, fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_NOUSE" },
OLD_ROD = { id = "OLD_ROD", pocket = "KEY", name = "OLD ROD", OLD_ROD = { id = "OLD_ROD", pocket = "KEY", name = "OLD ROD",
fieldMenu = "ITEMMENU_CURRENT", battleMenu = "ITEMMENU_NOUSE" }, 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") eq(save.inventory.ANTIDOTE, 1, "with the ANTIDOTE untouched")
end 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 -------------------------- -- ---- IsItemUsedOnConfusedMon: the battle-only arm --------------------------
do do
local screen, battle, player, save, pushed = newScreen({ 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, TM01 = { id = "TM01", name = "TM01", pocket = "TM_HM", index = 191,
fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_NOUSE", fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_NOUSE",
teaches = "SWIFT" }, 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 = {}, gen2MenuGfx = {},
gen2Icons = { gen2Icons = {
@@ -461,6 +479,24 @@ do
eq(host.save.inventory.HP_UP, nil, "and the HP UP was spent") eq(host.save.inventory.HP_UP, nil, "and the HP UP was spent")
end 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 do
local mon = fixtureMon(12, { statExp = { local mon = fixtureMon(12, { statExp = {
hp = 25600, attack = 0, defense = 0, speed = 0, special = 0 } }) 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.requireDebug = attempt(require, "debug")
out.requirePackage = attempt(require, "package") out.requirePackage = attempt(require, "package")
out.requireFfi = attempt(require, "ffi") 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.requireSocket = attempt(require, "socket")
out.requireSemver = select(2, pcall(require, "src.mods.Semver")) 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.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.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.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.requireDebug ~= false, "require(\"debug\") is still refused")
T.check(out.requirePackage ~= false, "require(\"package\") 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") 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(bound.gen2Tilesets == bound.tilesets, "bindGoldData aliases gen2Tilesets")
check(Gen.tilesets({ gen2Tilesets = { TILESET_GYM = true } }).TILESET_GYM, check(Gen.tilesets({ gen2Tilesets = { TILESET_GYM = true } }).TILESET_GYM,
"Gen.tilesets prefers gen2Tilesets") "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 end
do do
+6
View File
@@ -95,6 +95,12 @@ function Gen.bindGoldData(data)
end end
data.gen2Palettes = data.gen2Palettes or loadGen("palettes") data.gen2Palettes = data.gen2Palettes or loadGen("palettes")
-- Namespaced AND differently shaped in Schemas.GEN2 (the cart's ordered
-- name lists, not Gen 1's rule table), same as palettes/icons below --
-- omitting it left mod.content.constants:get(...) reading an empty table
-- under a Gold save-editor boot, which is what misreads "generation" and
-- rejects every record a mod shapes off it.
data.gen2Constants = data.gen2Constants or loadGen("constants")
data.gen2Icons = data.gen2Icons or loadGen("icons") data.gen2Icons = data.gen2Icons or loadGen("icons")
data.gen2Pokedex = data.gen2Pokedex or loadGen("pokedex") data.gen2Pokedex = data.gen2Pokedex or loadGen("pokedex")
data.gen2Landmarks = data.gen2Landmarks or loadGen("landmarks") data.gen2Landmarks = data.gen2Landmarks or loadGen("landmarks")