mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
817 lines
31 KiB
Lua
817 lines
31 KiB
Lua
-- Field item use from the Gen 2 PACK: pack.asm UseItem's .Party arm
|
|
-- (src/core/gen2/ItemEffects.lua + Game2:usePartyItem), the
|
|
-- ESCAPE ROPE / DIG / TELEPORT escape family (World:useEscapeRope,
|
|
-- FieldMoves.digFromMenu / teleportFromMenu over EscapeRopeOrDig,
|
|
-- engine/events/overworld.asm), the SQUIRTBOTTLE's queued tree script
|
|
-- (engine/events/squirtbottle.asm _Squirtbottle), and the .Oak refusal for
|
|
-- field-NOUSE items. The cache-backed sections skip without a gold cache,
|
|
-- the same guard tests/gen2_pokecenter_stairs_test.lua uses.
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
-- The UI modules require love-side helpers at load time; stub what they
|
|
-- touch. Nothing here draws.
|
|
love = love or {}
|
|
love.graphics = love.graphics or {
|
|
getColor = function() return 1, 1, 1, 1 end,
|
|
setColor = function() end,
|
|
rectangle = function() end,
|
|
print = function() end,
|
|
printf = function() end,
|
|
draw = function() end,
|
|
newQuad = function() return {} end,
|
|
newImage = function() return nil end,
|
|
getShader = function() return nil end,
|
|
setShader = function() end,
|
|
newShader = function() error("no shaders in this harness") end,
|
|
getDimensions = function() return 160, 144 end,
|
|
push = function() end, pop = function() end,
|
|
translate = function() end, scale = function() end,
|
|
circle = function() end, clear = function() end,
|
|
}
|
|
love.math = love.math or {
|
|
random = function(a, b)
|
|
if b then return a end
|
|
return a and 1 or 0.5
|
|
end,
|
|
}
|
|
love.filesystem = love.filesystem or {
|
|
load = function() return nil end,
|
|
getInfo = function() return nil end,
|
|
read = function() return nil end,
|
|
write = function() return true end,
|
|
remove = function() return true end,
|
|
}
|
|
love.timer = love.timer or { getTime = function() return 0 end }
|
|
|
|
require("src.core.Logger").warn = function() end
|
|
|
|
local S = require("tests.harness").suite("gen2 field items")
|
|
local check, eq = S.check, S.eq
|
|
|
|
local Game2 = require("src.core.Game2")
|
|
local ItemEffects = require("src.core.gen2.ItemEffects")
|
|
local FieldMoves = require("src.world.gen2.FieldMoves")
|
|
local Mon = require("src.battle.gen2.Mon")
|
|
local PackMenu = require("src.ui.gen2.PackMenu")
|
|
local Strings = require("src.core.Strings")
|
|
|
|
-- ------------------------------------------------------------------ fixtures
|
|
|
|
local DATA = {
|
|
audio = { sfx = {}, sfxOrder = {} },
|
|
tokens = require("src.render.TextBox").TOKENS,
|
|
pokemon = {
|
|
growthRates = {
|
|
GROWTH_MEDIUM_SLOW = {
|
|
numerator = 6, denominator = 5, squared = -15, linear = 100,
|
|
constant = 140,
|
|
},
|
|
},
|
|
CYNDAQUIL = {
|
|
id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155, index = 155,
|
|
growthRate = "GROWTH_MEDIUM_SLOW",
|
|
types = { "FIRE", "FIRE" },
|
|
baseStats = {
|
|
hp = 39, attack = 52, defense = 43, speed = 65,
|
|
specialAttack = 60, specialDefense = 50,
|
|
},
|
|
-- EMBER at 12 makes a candy from 11 offer exactly one move.
|
|
levelMoves = {
|
|
{ level = 1, move = "TACKLE" },
|
|
{ level = 12, move = "EMBER" },
|
|
},
|
|
tmhm = { "SWIFT" },
|
|
},
|
|
},
|
|
moves = {
|
|
TACKLE = { id = "TACKLE", name = "TACKLE", pp = 35 },
|
|
EMBER = { id = "EMBER", name = "EMBER", pp = 25 },
|
|
SURF = { id = "SURF", name = "SURF", pp = 15 },
|
|
SMOKESCREEN = { id = "SMOKESCREEN", name = "SMOKESCREEN", pp = 20 },
|
|
SWIFT = { id = "SWIFT", name = "SWIFT", pp = 20 },
|
|
},
|
|
items = {
|
|
POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 18,
|
|
fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_PARTY" },
|
|
ETHER = { id = "ETHER", name = "ETHER", pocket = "ITEM", index = 69,
|
|
fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_PARTY" },
|
|
X_ATTACK = { id = "X_ATTACK", name = "X ATTACK", pocket = "ITEM",
|
|
index = 49, fieldMenu = "ITEMMENU_NOUSE", battleMenu = "ITEMMENU_CLOSE" },
|
|
POKE_BALL = { id = "POKE_BALL", name = "POKE BALL", pocket = "BALL",
|
|
index = 5, fieldMenu = "ITEMMENU_NOUSE", battleMenu = "ITEMMENU_CLOSE" },
|
|
TM01 = { id = "TM01", name = "TM01", pocket = "TM_HM", index = 191,
|
|
fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_NOUSE",
|
|
teaches = "SWIFT" },
|
|
},
|
|
gen2MenuGfx = {},
|
|
gen2Icons = {
|
|
species = { CYNDAQUIL = "ICON_FOX" },
|
|
icons = { ICON_FOX = { id = "ICON_FOX", image = "x/fox.png" } },
|
|
},
|
|
}
|
|
|
|
local function newInput()
|
|
local input = { pressed = {}, down = {} }
|
|
function input:press(...)
|
|
for _, button in ipairs({ ... }) do self.pressed[button] = true end
|
|
end
|
|
function input:wasPressed(button)
|
|
if self.pressed[button] then
|
|
self.pressed[button] = nil
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
function input:isDown() return false end
|
|
return input
|
|
end
|
|
|
|
local function newStack()
|
|
return {
|
|
_items = {},
|
|
push = function(self, s) self._items[#self._items + 1] = s end,
|
|
pop = function(self) return table.remove(self._items) end,
|
|
top = function(self) return self._items[#self._items] end,
|
|
clear = function(self)
|
|
while #self._items > 0 do self:pop() end
|
|
end,
|
|
}
|
|
end
|
|
|
|
local function fixtureMon(level, fields)
|
|
local built = Mon.new(DATA, "CYNDAQUIL", level or 12, {
|
|
dvs = { attack = 15, defense = 15, speed = 15, special = 15 },
|
|
moves = {
|
|
{ id = "TACKLE", pp = 30, maxPp = 35 },
|
|
{ id = "SURF", pp = 15, maxPp = 15 },
|
|
},
|
|
})
|
|
for key, value in pairs(fields or {}) do built[key] = value end
|
|
return built
|
|
end
|
|
|
|
-- Drives whatever is on top of the stack (party menu, move list, text box)
|
|
-- with an A press per frame until `predicate` answers true.
|
|
local function drive(game, predicate, frames)
|
|
for _ = 1, frames or 600 do
|
|
if predicate() then return true end
|
|
local top = game.stack:top()
|
|
if not top then return predicate() end
|
|
game.input:press("a")
|
|
if top.update then top:update(1 / 60) end
|
|
end
|
|
return predicate()
|
|
end
|
|
|
|
-- --------------------------------------------- ItemEffects: HP restoration
|
|
|
|
do
|
|
local mon = fixtureMon(12, { hp = 10 })
|
|
local result = ItemEffects.useOnMon("POTION", mon, DATA)
|
|
eq(result.used, true, "a POTION on a hurt mon is spent")
|
|
eq(mon.hp, 30, "and heals HealingHPAmounts' 20")
|
|
eq(result.text, "CYNDAQUIL\nrecovered 20 HP!", "with the cart's line")
|
|
|
|
local capped = fixtureMon(12, { hp = mon.maxHp - 5 })
|
|
local capResult = ItemEffects.useOnMon("HYPER_POTION", capped, DATA)
|
|
eq(capResult.used, true, "a heal near full HP still lands")
|
|
eq(capped.hp, capped.maxHp, "capped at max HP")
|
|
eq(capResult.text, "CYNDAQUIL\nrecovered 5 HP!", "printing the real delta")
|
|
|
|
local full = fixtureMon(12)
|
|
eq(ItemEffects.useOnMon("POTION", full, DATA).used, false,
|
|
"a full-HP target refuses (IsMonAtFullHealth)")
|
|
local fainted = fixtureMon(12, { hp = 0 })
|
|
local faintResult = ItemEffects.useOnMon("POTION", fainted, DATA)
|
|
eq(faintResult.used, false, "a fainted target refuses (IsMonFainted)")
|
|
eq(faintResult.text, ItemEffects.TEXT_NO_EFFECT,
|
|
"with ItemWontHaveEffectText")
|
|
end
|
|
|
|
-- ------------------------------------------------ ItemEffects: status heals
|
|
|
|
do
|
|
local psn = fixtureMon(12, { status = "psn" })
|
|
local result = ItemEffects.useOnMon("ANTIDOTE", psn, DATA)
|
|
eq(result.used, true, "ANTIDOTE cures poison")
|
|
eq(psn.status, nil, "the status byte clears")
|
|
eq(result.text, "CYNDAQUIL's\ncured of poison.", "with the party text")
|
|
|
|
local brn = fixtureMon(12, { status = "brn" })
|
|
eq(ItemEffects.useOnMon("ANTIDOTE", brn, DATA).used, false,
|
|
"ANTIDOTE refuses a burn (the mask does not intersect)")
|
|
eq(brn.status, "brn", "and leaves it standing")
|
|
|
|
local slp = fixtureMon(12, { status = "sleep" })
|
|
local healAll = ItemEffects.useOnMon("FULL_HEAL", slp, DATA)
|
|
eq(healAll.used, true, "FULL_HEAL's $ff mask takes any status")
|
|
eq(healAll.text, "CYNDAQUIL's\nhealth returned.", "as HEAL_ALL")
|
|
|
|
local clean = fixtureMon(12)
|
|
eq(ItemEffects.useOnMon("FULL_HEAL", clean, DATA).used, false,
|
|
"a clean mon refuses a status heal")
|
|
|
|
-- FullRestoreEffect: full HP falls through to FullyHealStatus.
|
|
local par = fixtureMon(12, { status = "par" })
|
|
local restore = ItemEffects.useOnMon("FULL_RESTORE", par, DATA)
|
|
eq(restore.used, true, "FULL RESTORE at full HP still cures the status")
|
|
eq(par.status, nil, "clearing it")
|
|
local hurt = fixtureMon(12, { hp = 3, status = "psn" })
|
|
local both = ItemEffects.useOnMon("FULL_RESTORE", hurt, DATA)
|
|
eq(both.used, true, "FULL RESTORE below full HP heals")
|
|
eq(hurt.hp, hurt.maxHp, "to full")
|
|
eq(hurt.status, nil, "and clears the status alongside (.FullRestore)")
|
|
end
|
|
|
|
-- ---------------------------------------------------- ItemEffects: revives
|
|
|
|
do
|
|
local down = fixtureMon(12, { hp = 0, status = "psn" })
|
|
local result = ItemEffects.useOnMon("REVIVE", down, DATA)
|
|
eq(result.used, true, "REVIVE stands a fainted mon up")
|
|
eq(down.hp, math.floor(down.maxHp / 2), "at half max HP (ReviveHalfHP)")
|
|
eq(down.status, nil, "with the status wiped")
|
|
eq(result.text, "CYNDAQUIL\nis revitalized.", "and the cart's line")
|
|
|
|
local down2 = fixtureMon(12, { hp = 0 })
|
|
ItemEffects.useOnMon("MAX_REVIVE", down2, DATA)
|
|
eq(down2.hp, down2.maxHp, "MAX REVIVE restores full HP")
|
|
|
|
local up = fixtureMon(12)
|
|
eq(ItemEffects.useOnMon("REVIVE", up, DATA).used, false,
|
|
"a standing mon refuses a REVIVE")
|
|
|
|
local egg = fixtureMon(12, { isEgg = true, hp = 0 })
|
|
local eggResult = ItemEffects.useOnMon("REVIVE", egg, DATA)
|
|
eq(eggResult.used, false, "an EGG refuses (UseItem_SelectMon's cp EGG)")
|
|
eq(eggResult.text, ItemEffects.TEXT_CANT_USE_ON_EGG, "with the EGG line")
|
|
end
|
|
|
|
-- ------------------------------------------------- ItemEffects: RARE CANDY
|
|
|
|
do
|
|
local mon = fixtureMon(11, { hp = 20 })
|
|
local before = { max = mon.maxHp, exp = mon.experience }
|
|
local result = ItemEffects.useOnMon("RARE_CANDY", mon, DATA)
|
|
eq(result.used, true, "a RARE CANDY below MAX_LEVEL is spent")
|
|
eq(mon.level, 12, "one level up")
|
|
eq(mon.experience, Mon.experienceForLevel(
|
|
DATA.pokemon.growthRates.GROWTH_MEDIUM_SLOW, 12),
|
|
"experience SET to CalcExpAtLevel's threshold")
|
|
check(mon.maxHp > before.max, "stats recomputed at the new level")
|
|
eq(mon.hp, 20 + (mon.maxHp - before.max),
|
|
"current HP gains exactly the max-HP delta")
|
|
eq(result.text, "CYNDAQUIL grew to\nlevel 12!", "with the grew-to line")
|
|
eq(#result.learned, 1, "LearnLevelMoves offers the level-12 move")
|
|
eq(result.learned[1], "EMBER", "which is EMBER")
|
|
|
|
local capped = fixtureMon(11, { level = Mon.MAX_LEVEL })
|
|
eq(ItemEffects.useOnMon("RARE_CANDY", capped, DATA).used, false,
|
|
"MAX_LEVEL refuses (cp MAX_LEVEL / jp nc, NoEffectMessage)")
|
|
end
|
|
|
|
-- ------------------------------------------------- ItemEffects: PP family
|
|
|
|
do
|
|
local mon = fixtureMon(12)
|
|
eq(ItemEffects.usePpItem("ETHER", mon, 1).used, true,
|
|
"an ETHER on a drained slot is spent")
|
|
eq(mon.moves[1].pp, 35, "restoring 10 capped at max (30 + 10 > 35)")
|
|
eq(ItemEffects.usePpItem("ETHER", mon, 2).used, false,
|
|
"a slot already at max refuses (.dont_restore)")
|
|
|
|
local berry = fixtureMon(12)
|
|
berry.moves[1].pp = 10
|
|
ItemEffects.usePpItem("MYSTERYBERRY", berry, 1)
|
|
eq(berry.moves[1].pp, 15, "MYSTERYBERRY restores 5")
|
|
|
|
local deep = fixtureMon(12)
|
|
deep.moves[1].pp = 0
|
|
ItemEffects.usePpItem("MAX_ETHER", deep, 1)
|
|
eq(deep.moves[1].pp, 35, "MAX ETHER restores the whole slot")
|
|
|
|
local elixer = fixtureMon(12)
|
|
elixer.moves[1].pp = 5
|
|
elixer.moves[2].pp = 3
|
|
local all = ItemEffects.usePpItem("ELIXER", elixer)
|
|
eq(all.used, true, "an ELIXER needs no slot")
|
|
eq(elixer.moves[1].pp, 15, "restoring 10 to every move")
|
|
eq(elixer.moves[2].pp, 13, "each capped by its own max")
|
|
eq(all.text, ItemEffects.TEXT_PP_RESTORED, "with PPRestoredText")
|
|
|
|
local fullMon = fixtureMon(12)
|
|
fullMon.moves[1].pp = 35
|
|
eq(ItemEffects.usePpItem("MAX_ELIXER", fullMon).used, false,
|
|
"a party mon with every slot full refuses the ELIXER family")
|
|
end
|
|
|
|
-- ------------------------------------------------ Game2: the .Party wiring
|
|
-- The REAL flow: usePartyItem pushes the real Gen2PartyMenu, the pick runs
|
|
-- the effect, the consumption happens, and the message rides a real TextBox
|
|
-- whose dismissal leaves the state UNDER it alone (the say() teardown
|
|
-- contract).
|
|
|
|
local function newHost(inventory, party)
|
|
local host = setmetatable({
|
|
data = DATA,
|
|
save = {
|
|
player = { name = "GOLD" },
|
|
party = party or { fixtureMon(12, { hp = 10 }) },
|
|
inventory = inventory or {},
|
|
options = {},
|
|
},
|
|
options = {},
|
|
input = newInput(),
|
|
stack = newStack(),
|
|
}, { __index = Game2 })
|
|
return host
|
|
end
|
|
|
|
do
|
|
local host = newHost({ POTION = 2 })
|
|
local packSentinel = { isPack = true }
|
|
host.stack:push(packSentinel)
|
|
host:useFieldItem("POTION")
|
|
local party = host.stack:top()
|
|
check(party ~= nil and party.prompt ~= nil, "USE pushes the party list")
|
|
eq(party.prompt, "Use on which <PK><MN>?",
|
|
"under UseOnWhichPKMNString")
|
|
-- Pick the first mon: the real PartyMenu update loop takes the A press.
|
|
drive(host, function()
|
|
return host.stack:top() ~= party
|
|
end)
|
|
local box = host.stack:top()
|
|
check(box ~= nil and box.pages ~= nil, "the pick lands a TextBox")
|
|
eq(host.save.party[1].hp, 30, "the POTION healed through the real menu")
|
|
eq(host.save.inventory.POTION, 1, "and one POTION left the pack")
|
|
drive(host, function() return host.stack:top() == packSentinel end)
|
|
eq(host.stack:top(), packSentinel,
|
|
"dismissing the message returns to the pack, not past it")
|
|
end
|
|
|
|
do
|
|
-- The refusal path spends nothing.
|
|
local host = newHost({ POTION = 2 }, { fixtureMon(12) })
|
|
host:useFieldItem("POTION")
|
|
local party = host.stack:top()
|
|
drive(host, function() return host.stack:top() ~= party end)
|
|
eq(host.save.inventory.POTION, 2, "a refused heal costs nothing")
|
|
local box = host.stack:top()
|
|
check(box ~= nil and box.pages ~= nil, "and prints the no-effect line")
|
|
end
|
|
|
|
do
|
|
-- ETHER: party pick, then the move list, then the restore.
|
|
local mon = fixtureMon(12)
|
|
local host = newHost({ ETHER = 1 }, { mon })
|
|
host:useFieldItem("ETHER")
|
|
local party = host.stack:top()
|
|
check(party ~= nil, "ETHER opens the party list")
|
|
drive(host, function() return host.stack:top() ~= party end)
|
|
local mover = host.stack:top()
|
|
check(mover ~= nil and mover.list ~= nil,
|
|
"the pick opens the move list (Gen2MoveDeleter's shared screen)")
|
|
drive(host, function() return host.stack:top() ~= mover end)
|
|
eq(mon.moves[1].pp, 35, "slot one restored through the real screens")
|
|
eq(host.save.inventory.ETHER, nil, "and the ETHER was spent")
|
|
end
|
|
|
|
do
|
|
-- RARE CANDY: level line, then the learnset prompt for the level-12 move.
|
|
local mon = fixtureMon(11, { hp = 20 })
|
|
mon.moves = { { id = "TACKLE", pp = 30, maxPp = 35 } }
|
|
local host = newHost({ RARE_CANDY = 1 }, { mon })
|
|
host:useFieldItem("RARE_CANDY")
|
|
local party = host.stack:top()
|
|
drive(host, function() return host.stack:top() ~= party end)
|
|
eq(mon.level, 12, "the candy leveled through the real party menu")
|
|
drive(host, function() return host.stack:top() == nil end, 2000)
|
|
eq(#mon.moves, 2, "the level-12 move was learned on the way out")
|
|
eq(mon.moves[2].id, "EMBER", "and it is EMBER")
|
|
eq(host.save.inventory.RARE_CANDY, nil, "with the candy spent")
|
|
end
|
|
|
|
-- ------------------------------------------------ TeachTMHM's LearnMove loop
|
|
-- engine/items/tmhm.asm TeachTMHM -> predef LearnMove (engine/pokemon/learn.asm):
|
|
-- a full moveset runs ForgetMove's ask / pick / "Stop learning" loop, and only
|
|
-- b = 1 pays HAPPINESS_LEARNMOVE and ConsumeTM.
|
|
|
|
local function fourMoveMon()
|
|
local mon = fixtureMon(12)
|
|
mon.moves = {
|
|
{ id = "TACKLE", pp = 30, maxPp = 35 },
|
|
{ id = "SURF", pp = 15, maxPp = 15 },
|
|
{ id = "SMOKESCREEN", pp = 20, maxPp = 20 },
|
|
{ id = "EMBER", pp = 25, maxPp = 25 },
|
|
}
|
|
return mon
|
|
end
|
|
|
|
-- One frame on whatever is on top, with the button this case answers with.
|
|
local function step(game, button)
|
|
local top = game.stack:top()
|
|
if not top then return nil end
|
|
game.input:press(button)
|
|
if top.update then top:update(1 / 60) end
|
|
return top
|
|
end
|
|
|
|
local function firstLine(state)
|
|
return state and state.pages and state.pages[1] and state.pages[1][1]
|
|
end
|
|
|
|
do
|
|
-- YES, then slot one: the TM lands and is spent.
|
|
local mon = fourMoveMon()
|
|
local host = newHost({ TM01 = 1 }, { mon })
|
|
local packSentinel = { isPack = true }
|
|
host.stack:push(packSentinel)
|
|
host:useFieldItem("TM01")
|
|
local party = host.stack:top()
|
|
check(party ~= nil and party.prompt ~= nil, "a TM opens the party list")
|
|
drive(host, function() return host.stack:top() ~= party end)
|
|
check(firstLine(host.stack:top()) == "CYNDAQUIL is",
|
|
"a full moveset asks which move to forget, not a refusal")
|
|
drive(host, function() return host.stack:top() == packSentinel end, 3000)
|
|
eq(host.stack:top(), packSentinel, "the flow returns to the pack")
|
|
eq(mon.moves[1].id, "SWIFT", "the picked slot took the TM's move")
|
|
eq(#mon.moves, 4, "and the set is still four long")
|
|
eq(host.save.inventory.TM01, nil, "with the TM consumed (ConsumeTM)")
|
|
end
|
|
|
|
do
|
|
-- NO, then NO to "Stop learning": `jp c, .loop` reopens the question.
|
|
local mon = fourMoveMon()
|
|
local host = newHost({ TM01 = 1 }, { mon })
|
|
host:useFieldItem("TM01")
|
|
local party = host.stack:top()
|
|
drive(host, function() return host.stack:top() ~= party end)
|
|
local asks, seen = 0, {}
|
|
for _ = 1, 900 do
|
|
local top = host.stack:top()
|
|
if not top or asks >= 2 then break end
|
|
if not seen[top] and firstLine(top) == "CYNDAQUIL is" then
|
|
seen[top] = true
|
|
asks = asks + 1
|
|
end
|
|
step(host, "b")
|
|
end
|
|
eq(asks, 2, "refusing to stop learning reopens the delete question")
|
|
eq(mon.moves[1].id, "TACKLE", "with the moveset untouched")
|
|
eq(host.save.inventory.TM01, 1, "and the TM still in the pack")
|
|
end
|
|
|
|
do
|
|
-- NO, then YES to "Stop learning": DidNotLearnMoveText, b = 0, nothing spent.
|
|
local mon = fourMoveMon()
|
|
local host = newHost({ TM01 = 1 }, { mon })
|
|
host:useFieldItem("TM01")
|
|
local party = host.stack:top()
|
|
drive(host, function() return host.stack:top() ~= party end)
|
|
local button = "b"
|
|
for _ = 1, 900 do
|
|
local top = host.stack:top()
|
|
if not top then break end
|
|
if firstLine(top) == "Stop learning" then button = "a" end
|
|
step(host, button)
|
|
end
|
|
eq(host.stack:top(), nil, "giving up closes the whole LearnMove flow")
|
|
eq(mon.moves[1].id, "TACKLE", "the moveset is untouched")
|
|
eq(#mon.moves, 4, "still four moves")
|
|
eq(host.save.inventory.TM01, 1, "and a refusal spends nothing")
|
|
end
|
|
|
|
do
|
|
-- RARE CANDY into a full moveset: RareCandyEffect ends in LearnLevelMoves
|
|
-- (engine/items/item_effects.asm) whose .learn arm is `predef LearnMove`
|
|
-- (engine/pokemon/evolve.asm), so the level-12 move opens ForgetMove's ask.
|
|
local mon = fixtureMon(11, { hp = 20 })
|
|
mon.moves = {
|
|
{ id = "TACKLE", pp = 30, maxPp = 35 },
|
|
{ id = "SURF", pp = 15, maxPp = 15 },
|
|
{ id = "SMOKESCREEN", pp = 20, maxPp = 20 },
|
|
{ id = "SWIFT", pp = 20, maxPp = 20 },
|
|
}
|
|
local host = newHost({ RARE_CANDY = 1 }, { mon })
|
|
host:useFieldItem("RARE_CANDY")
|
|
local party = host.stack:top()
|
|
drive(host, function() return host.stack:top() ~= party end)
|
|
local asked = false
|
|
for _ = 1, 900 do
|
|
local top = host.stack:top()
|
|
if not top then break end
|
|
if firstLine(top) == "CYNDAQUIL is" then
|
|
asked = true
|
|
break
|
|
end
|
|
step(host, "a")
|
|
end
|
|
check(asked, "a candy's level-up move asks which move to forget")
|
|
eq(mon.level, 12, "with the level already banked")
|
|
-- YES, then slot one.
|
|
drive(host, function() return host.stack:top() == nil end, 2000)
|
|
eq(mon.moves[1].id, "EMBER", "the picked slot took the level-up move")
|
|
eq(#mon.moves, 4, "and the set is still four long")
|
|
eq(host.save.inventory.RARE_CANDY, nil, "with the candy spent")
|
|
end
|
|
|
|
-- --------------------------------------------- PackMenu: the .Oak refusal
|
|
|
|
do
|
|
local fieldWorld = { useFieldItem = function() return nil end }
|
|
local game = { input = newInput(), save = {
|
|
player = { name = "GOLD" },
|
|
inventory = { X_ATTACK = 1 },
|
|
options = {},
|
|
}, data = DATA, stack = newStack() }
|
|
local chosen
|
|
local pack = PackMenu.new(game, {
|
|
save = game.save, items = DATA.items, world = fieldWorld,
|
|
onChoose = function(id) chosen = id end,
|
|
})
|
|
pack:useSelected()
|
|
check(pack.message ~= nil, "a field-NOUSE item prints in the pack")
|
|
eq(pack.message[1], "OAK: {PLAYER}!", "OakThisIsntTheTimeText")
|
|
eq(chosen, nil, "and never reaches onChoose")
|
|
|
|
-- The DUDE's tutorial pack has a stub world and must still hand its
|
|
-- field-NOUSE POKE BALL to the throw.
|
|
local dudeGame = { input = newInput(), save = {
|
|
player = { name = "GOLD" },
|
|
inventory = { POKE_BALL = 1 },
|
|
options = {},
|
|
}, data = DATA, stack = newStack() }
|
|
local thrown
|
|
local dudePack = PackMenu.new(dudeGame, {
|
|
save = dudeGame.save, items = DATA.items, world = {}, pocket = "BALL",
|
|
onChoose = function(id) thrown = id end,
|
|
})
|
|
dudePack:useSelected()
|
|
eq(thrown, "POKE_BALL", "the tutorial pack still throws")
|
|
eq(dudePack.message, nil, "with no Oak line in the way")
|
|
end
|
|
|
|
-- ------------------------------------------------ say(): the teardown rule
|
|
|
|
do
|
|
local host = newHost({})
|
|
local under = { name = "under" }
|
|
host.stack:push(under)
|
|
host:say(Strings(
|
|
"An item in your\nPACK may be\fregistered for use\non SELECT Button."))
|
|
local box = host.stack:top()
|
|
check(box ~= under, "say() pushes the box")
|
|
eq(#box.pages, 2, "MayRegisterItemText is two pages")
|
|
eq(box.pages[1][1], "An item in your", "page one, line one")
|
|
eq(box.pages[1][2], "PACK may be", "page one ends mid-sentence, as cart")
|
|
drive(host, function() return host.stack:top() == under end, 3000)
|
|
eq(host.stack:top(), under,
|
|
"dismissing pops the box alone: the state under it survives")
|
|
eq(#host.stack._items, 1, "and exactly one state remains")
|
|
end
|
|
|
|
-- ------------------------------------------------- FieldMoves: DIG / TELEPORT
|
|
|
|
do
|
|
local dig = FieldMoves.digFromMenu({
|
|
environment = "CAVE", canEscapeRope = true,
|
|
})
|
|
eq(dig.ok, true, "DIG works in a CAVE with a banked triple")
|
|
eq(dig.action, "dig", "queuing the dig action")
|
|
eq(FieldMoves.digFromMenu({
|
|
environment = "ROUTE", canEscapeRope = true,
|
|
}).ok, false, "DIG refuses outdoors (.CheckCanDig)")
|
|
eq(FieldMoves.digFromMenu({
|
|
environment = "CAVE", canEscapeRope = false,
|
|
}).ok, false, "and with a dead triple (.fail)")
|
|
|
|
eq(FieldMoves.teleportFromMenu({ environment = "TOWN" }).ok, true,
|
|
"TELEPORT works in a TOWN")
|
|
eq(FieldMoves.teleportFromMenu({ environment = "ROUTE" }).ok, true,
|
|
"and on a ROUTE (CheckOutdoorMap)")
|
|
eq(FieldMoves.teleportFromMenu({ environment = "CAVE" }).ok, false,
|
|
"and refuses underground")
|
|
eq(FieldMoves.FROM_MENU.DIG, FieldMoves.digFromMenu,
|
|
"DIG is wired into the submenu table")
|
|
eq(FieldMoves.FROM_MENU.TELEPORT, FieldMoves.teleportFromMenu,
|
|
"and so is TELEPORT")
|
|
end
|
|
|
|
-- -------------------------------------------- cache-backed World sections
|
|
|
|
local cache = os.getenv("GOLD_CACHE")
|
|
if not cache then
|
|
local home = os.getenv("HOME") or ""
|
|
cache = home .. "/Library/Application Support/LOVE/gold-dev/gold"
|
|
end
|
|
local probe = io.open(cache .. "/data/generated/maps.lua", "r")
|
|
if not probe then
|
|
check(true, "gold cache absent (SKIP)")
|
|
S.finish()
|
|
return
|
|
end
|
|
probe:close()
|
|
|
|
local function loadLua(rel) return assert(loadfile(cache .. "/" .. rel))() end
|
|
local maps = loadLua("data/generated/maps.lua")
|
|
local tilesets = loadLua("data/generated/tilesets.lua")
|
|
local scripts = loadLua("data/generated/scripts.lua")
|
|
|
|
local World = require("src.world.gen2.World")
|
|
local Map = require("src.world.gen2.Map")
|
|
|
|
-- A world over the real defs, the tests/gen2_pokecenter_stairs_test.lua way:
|
|
-- setMap records instead of baking an image; everything else is shipped code.
|
|
local function world(mapId, x, y, save)
|
|
local game = {
|
|
data = { audio = { sfxOrder = {} }, tokens = DATA.tokens,
|
|
items = { ESCAPE_ROPE = { id = "ESCAPE_ROPE", index = 19 } } },
|
|
save = save or { player = { name = "GOLD" }, options = {} },
|
|
input = newInput(),
|
|
stack = newStack(),
|
|
}
|
|
local w = World.new(game)
|
|
w.maps, w.tilesets, w.scripts = maps, tilesets, scripts
|
|
w.map = Map.new(maps[mapId], tilesets[maps[mapId].tileset])
|
|
w.player = { cellX = x, cellY = y, facing = "down", moving = false }
|
|
w.loaded = nil
|
|
w.setMap = function(self, id, cx, cy, facing)
|
|
self.loaded = { id = id, x = cx, y = cy, facing = facing }
|
|
return true
|
|
end
|
|
return w, game
|
|
end
|
|
|
|
local function pump(w)
|
|
for _ = 1, 64 do
|
|
if not w.mapSetup then return end
|
|
w:updateMapSetup()
|
|
end
|
|
end
|
|
|
|
-- ESCAPE ROPE in a CAVE with the banked triple: consumed, queued, and the
|
|
-- queued action warps to the banked warp's own tile.
|
|
do
|
|
local save = { player = { name = "GOLD" }, options = {},
|
|
inventory = { ESCAPE_ROPE = 2 } }
|
|
local w, game = world("UNION_CAVE_B2F", 5, 3, save)
|
|
w.backupWarp = { map = "CHERRYGROVE_POKECENTER_1F", warp = 3 }
|
|
local outcome = w:useEscapeRope("ESCAPE_ROPE")
|
|
eq(outcome, "escape_rope", "the rope succeeds in a CAVE")
|
|
eq(save.inventory.ESCAPE_ROPE, 1,
|
|
"UseDisposableItem takes one on success only")
|
|
check(w.queuedFieldMove ~= nil, "the script is QUEUED, not run in the pack")
|
|
check(w:runQueuedFieldMove(), "and the drain runs it")
|
|
local box = game.stack:top()
|
|
check(box ~= nil and box.pages ~= nil, "the used-rope line is up")
|
|
eq(box.pages[1][1], "GOLD used an", "addressed to the player")
|
|
drive(game, function() return game.stack:top() == nil end, 3000)
|
|
pump(w)
|
|
eq(w.loaded and w.loaded.id, "CHERRYGROVE_POKECENTER_1F",
|
|
"the warp pays out to the banked map")
|
|
local dest = maps.CHERRYGROVE_POKECENTER_1F.warps[3]
|
|
eq(w.loaded.x, dest.x, "on the banked warp's own x")
|
|
eq(w.loaded.y, dest.y, "and y")
|
|
eq(w.playerState, FieldMoves.PLAYER_NORMAL,
|
|
"with VAR_MOVEMENT back to PLAYER_NORMAL")
|
|
end
|
|
|
|
-- The refusals: outdoors (TIN_TOWER_ROOF is environment ROUTE), and a CAVE
|
|
-- whose triple is dead. Neither takes the item.
|
|
do
|
|
local save = { player = { name = "GOLD" }, options = {},
|
|
inventory = { ESCAPE_ROPE = 1 } }
|
|
local w = world("TIN_TOWER_ROOF", 9, 5, save)
|
|
eq(w:useEscapeRope("ESCAPE_ROPE"), "nowhere",
|
|
"TIN TOWER ROOF refuses: its environment is ROUTE")
|
|
eq(save.inventory.ESCAPE_ROPE, 1, "at no cost")
|
|
|
|
local w2 = world("UNION_CAVE_B2F", 5, 3, save)
|
|
w2.backupWarp = nil
|
|
eq(w2:useEscapeRope("ESCAPE_ROPE"), "nowhere",
|
|
"a CAVE with no banked triple refuses (.fail on the zeroed triple)")
|
|
eq(save.inventory.ESCAPE_ROPE, 1, "still at no cost")
|
|
|
|
local w3 = world("WHIRL_ISLAND_LUGIA_CHAMBER", 5, 3, save)
|
|
w3.backupWarp = { map = "CHERRYGROVE_POKECENTER_1F", warp = 3 }
|
|
eq(w3:useEscapeRope("ESCAPE_ROPE"), "escape_rope",
|
|
"the Lugia chamber (environment CAVE) accepts")
|
|
end
|
|
|
|
-- DIG from the party submenu shares the whole path through the REAL
|
|
-- fieldContext.
|
|
do
|
|
local save = { player = { name = "GOLD" }, options = {} }
|
|
local w = world("UNION_CAVE_B2F", 5, 3, save)
|
|
w.backupWarp = { map = "CHERRYGROVE_POKECENTER_1F", warp = 3 }
|
|
local result = w:useFieldMove("DIG", { nickname = "SANDSHREW" })
|
|
eq(result and result.ok, true, "DIG succeeds where the rope does")
|
|
check(w.queuedFieldMove ~= nil, "and queues the same escape action")
|
|
eq(w.queuedFieldMove.action, "dig", "as dig")
|
|
|
|
local w2 = world("TIN_TOWER_ROOF", 9, 5, save)
|
|
local refused = w2:useFieldMove("DIG", { nickname = "SANDSHREW" })
|
|
eq(refused and refused.ok, false, "and refuses outdoors")
|
|
end
|
|
|
|
-- TELEPORT queues the whiteout-spawn return from an outdoor map.
|
|
do
|
|
local save = { player = { name = "GOLD" }, options = {} }
|
|
local w = world("ROUTE_36", 10, 10, save)
|
|
local result = w:useFieldMove("TELEPORT", { nickname = "ABRA" })
|
|
eq(result and result.ok, true, "TELEPORT works on a ROUTE")
|
|
eq(w.queuedFieldMove.action, "teleport", "queuing the teleport action")
|
|
local w2 = world("UNION_CAVE_B2F", 5, 3, save)
|
|
local refused = w2:useFieldMove("TELEPORT", { nickname = "ABRA" })
|
|
eq(refused and refused.ok, false, "and refuses in a cave")
|
|
end
|
|
|
|
-- SQUIRTBOTTLE: facing the Route 36 Sudowoodo, the queued script IS the
|
|
-- extracted WateredWeirdTreeScript slice -- battle and all; anywhere else
|
|
-- the queued script is the "nothing happened" line.
|
|
do
|
|
local save = { player = { name = "GOLD" }, options = {},
|
|
inventory = { SQUIRTBOTTLE = 1 } }
|
|
-- The tree is object index 3 of the extracted ROUTE_36: movement 23
|
|
-- (SPRITEMOVEDATA_SUDOWOODO) at (35, 9).
|
|
local treeDef
|
|
for _, obj in ipairs(maps.ROUTE_36.objects or {}) do
|
|
if obj.movement == 23 then treeDef = obj end
|
|
end
|
|
check(treeDef ~= nil, "the extracted ROUTE_36 carries the Sudowoodo object")
|
|
eq(treeDef and treeDef.scriptKey, "4b:61aa",
|
|
"whose talk script is the SudowoodoScript body")
|
|
|
|
local w = world("ROUTE_36", treeDef.x, treeDef.y - 1, save)
|
|
w.player.facing = "down"
|
|
w.npcs = { { cellX = treeDef.x, cellY = treeDef.y, def = treeDef } }
|
|
eq(w:useSquirtbottle(), "squirtbottle", "facing the tree succeeds")
|
|
local rows = w.queuedScript
|
|
check(rows ~= nil, "_Squirtbottle QUEUES the script")
|
|
eq(rows[1] and rows[1].op, "opentext",
|
|
"starting at WateredWeirdTreeScript's own first row")
|
|
local sawBattle, sawWild
|
|
for _, cmd in ipairs(rows) do
|
|
if cmd.op == "startbattle" then sawBattle = true end
|
|
if cmd.op == "loadwildmon" and cmd.species == 185 and cmd.level == 20 then
|
|
sawWild = true
|
|
end
|
|
end
|
|
check(sawWild, "the slice carries loadwildmon SUDOWOODO, 20")
|
|
check(sawBattle, "and the startbattle")
|
|
eq(save.inventory.SQUIRTBOTTLE, 1, "a key item is never consumed")
|
|
|
|
-- Wrong facing: the same tile relationship but looking away.
|
|
local away = world("ROUTE_36", treeDef.x, treeDef.y - 1, save)
|
|
away.player.facing = "up"
|
|
away.npcs = w.npcs
|
|
eq(away:useSquirtbottle(), "squirtbottle",
|
|
"wItemEffectSucceeded is 1 unconditionally: the pack still quits")
|
|
local nothing = away.queuedScript
|
|
local isNothing
|
|
for _, cmd in ipairs(nothing or {}) do
|
|
if cmd.op == "rawtext" then isNothing = true end
|
|
end
|
|
check(isNothing, "but the queued script is the nothing-happened line")
|
|
end
|
|
|
|
-- ---- specialsound picks its cue off the item's POCKET ----------------------
|
|
--
|
|
-- Script_specialsound (engine/overworld/scripting.asm:476) farcalls
|
|
-- CheckItemPocket over wCurItem and loads SFX_GET_TM ($9b) for the TM/HM pocket,
|
|
-- SFX_ITEM ($01) for everything else. It is the sound inside GiveItemScript, so
|
|
-- every `verbosegiveitem` rings it -- SproutTower3F's SageLiScript does
|
|
-- `verbosegiveitem HM_FLASH` and must ring the TM jingle. The port used to
|
|
-- throw the item argument away and play SFX_ITEM unconditionally.
|
|
do
|
|
local World = require("src.world.gen2.World")
|
|
local rung = {}
|
|
local sfxData = {
|
|
-- audio/sfx_pointers.asm order, only as far as the two cues need.
|
|
audio = { sfx = {}, sfxOrder = {} },
|
|
items = {
|
|
POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 18 },
|
|
HM_FLASH = { id = "HM_FLASH", name = "HM05", pocket = "TM_HM",
|
|
index = 247 },
|
|
},
|
|
}
|
|
for i = 0, 0x9b do sfxData.audio.sfxOrder[i + 1] = "Sfx_Unused" end
|
|
sfxData.audio.sfxOrder[2] = "Sfx_Item"
|
|
sfxData.audio.sfxOrder[0x9b + 1] = "Sfx_GetTm"
|
|
local w = World.new({ data = sfxData, save = { player = {}, inventory = {} } })
|
|
w.playSfx = function(_, id) rung[#rung + 1] = id end
|
|
|
|
w:specialSound(247)
|
|
eq(rung[1], 0x9b, "an HM rings SFX_GET_TM, not the ordinary item jingle")
|
|
w:specialSound(18)
|
|
eq(rung[2], 1, "and a POTION still rings SFX_ITEM")
|
|
-- Script_specialsound has no operand of its own: it reads wCurItem, and a
|
|
-- slot the cache cannot name falls to the `cp TM_HM / jr z` default.
|
|
w:specialSound(nil)
|
|
eq(rung[3], 1, "with no item known, the fall-through is SFX_ITEM")
|
|
w:specialSound(0xfe)
|
|
eq(rung[4], 1, "and so is an item index nothing in the cache names")
|
|
end
|
|
|
|
S.finish()
|