mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 11:11:10 +02:00
Merge pull request #1434 from sanjinpepic/upstream-fixes
This commit is contained in:
@@ -1103,6 +1103,15 @@ function BattleState:stepHPDrain()
|
||||
if not b.shownPx then b.shownPx = targetPx end
|
||||
if (b.drainHold or 0) > 0 then
|
||||
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
|
||||
elseif b.shownPx ~= targetPx then
|
||||
-- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 /
|
||||
|
||||
@@ -112,7 +112,13 @@ function Mon.syncIdentity(mon, data)
|
||||
if mon.dvs then
|
||||
mon.gender = Mon.gender(def, mon.dvs,
|
||||
{ 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 })
|
||||
if mon.species == Unown.SPECIES then
|
||||
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
|
||||
|
||||
+4
-1
@@ -683,7 +683,10 @@ end
|
||||
-- .SelectMon / PPRestoreItem_Cancel carry path: nothing spent.
|
||||
function Game2:usePartyItem(itemId)
|
||||
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
|
||||
local party = (self.save and self.save.party) or {}
|
||||
if #party == 0 then
|
||||
|
||||
@@ -515,7 +515,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if not target then return "failed", { noEffect(data) } end
|
||||
local speciesDef = data.pokemon[target.species]
|
||||
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
|
||||
end
|
||||
if not ok then
|
||||
|
||||
@@ -28,8 +28,14 @@ local DENIED = {
|
||||
}
|
||||
|
||||
-- Same idea one level up: love.filesystem is reachable by name, and
|
||||
-- love.thread starts a Lua state this sandbox has no say over.
|
||||
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true }
|
||||
-- love.thread starts a Lua state this sandbox has no say over. jit.util
|
||||
-- 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.
|
||||
local NETWORK = { socket = true, enet = true, http = true, https = true,
|
||||
|
||||
+62
-7
@@ -91,6 +91,28 @@ function f.rec(fields, opts)
|
||||
desc = "{" .. table.concat(parts, ", ") .. "}" }
|
||||
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)
|
||||
local parts = {}
|
||||
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
|
||||
return
|
||||
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
|
||||
for _, alt in ipairs(t.alts) do
|
||||
local scratch = {}
|
||||
@@ -299,7 +338,7 @@ collectRefs = function(t, value, path, out)
|
||||
for k, v in pairs(value) do
|
||||
collectRefs(t.value, v, path .. "." .. tostring(k), out)
|
||||
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
|
||||
collectRefs(ft, value[key], path .. "." .. tostring(key), out)
|
||||
end
|
||||
@@ -377,11 +416,19 @@ function Schemas.crossValidate(loader, data)
|
||||
and loader.content[ref.registry]
|
||||
-- A registry with no home in this generation has no id space to
|
||||
-- check against: its base view resolves to nothing, so EVERY
|
||||
-- reference into it would read as dangling. Gold's species carry a
|
||||
-- growthRate and an evolution method like Red's do; the ids are
|
||||
-- fine, it is the Gen 1 `growth_rates` / `evolution_methods`
|
||||
-- namespaces that are not there to confirm them. Skipped for the
|
||||
-- same reason an undeclared registry is: unknown, not wrong.
|
||||
-- reference into it would read as dangling. `transitions` is the
|
||||
-- standing example -- Gold draws its own battle intro and never
|
||||
-- reads the merged table, so a mod's transition id there is
|
||||
-- unconfirmable, not wrong. `growth_rates` and `evolution_methods`
|
||||
-- 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
|
||||
refRegistry = nil
|
||||
end
|
||||
@@ -887,7 +934,15 @@ R.maps = {
|
||||
destMap = f.str, destWarp = f.int(0),
|
||||
destGroup = 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)),
|
||||
connections = f.opt(f.map(f.enum{ "north", "south", "east", "west" }, f.any)),
|
||||
},
|
||||
|
||||
+16
-1
@@ -4,6 +4,7 @@
|
||||
|
||||
local ItemEffects = require("src.inventory.ItemEffects")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local BagMenu = {}
|
||||
@@ -46,7 +47,16 @@ end
|
||||
-- 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
|
||||
-- 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,
|
||||
battle, moveIndex, game.overworld)
|
||||
local function closePicker()
|
||||
@@ -375,6 +385,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
showMessages(game, payload, closePicker) -- failed
|
||||
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)
|
||||
-- pick a target from the party
|
||||
-- the ETHERs and PP UP open the move menu after picking a mon
|
||||
|
||||
@@ -3024,8 +3024,10 @@ function BattleState:useItem(itemId)
|
||||
-- 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
|
||||
-- drinks, the status cures and their berries, REVIVE / MAX REVIVE, and
|
||||
-- the ETHER / ELIXER family.
|
||||
local action = ItemEffects.partyAction(itemId)
|
||||
-- the ETHER / ELIXER family. Without the merged dataset this can only
|
||||
-- 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
|
||||
return self:useOnPartyMon(itemId, action)
|
||||
end
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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({
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -95,6 +95,12 @@ function Gen.bindGoldData(data)
|
||||
end
|
||||
|
||||
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.gen2Pokedex = data.gen2Pokedex or loadGen("pokedex")
|
||||
data.gen2Landmarks = data.gen2Landmarks or loadGen("landmarks")
|
||||
|
||||
Reference in New Issue
Block a user