mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
285 lines
12 KiB
Lua
285 lines
12 KiB
Lua
-- Parity / regression: a box mon has no stat block, so the engine derives one
|
|
-- on demand (#233 STATS in the box, #304 withdraw). macros/ram.asm's
|
|
-- box_struct is a prefix of party_struct that stops before MON_STATS;
|
|
-- status_screen.asm:64-77 recomputes on BOX_DATA before it draws, and
|
|
-- add_mon.asm's _MoveMon runs CalcStats on the way back to the party. The
|
|
-- formula itself is home/move_mon.asm CalcStats, ported in Stats.lua.
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
if not _G.love then _G.love = require("tests.love_stub") end
|
|
|
|
local Data = require("src.core.Data")
|
|
if not (Data.pokemon and Data.pokemon.ODDISH) then Data:load() end
|
|
local S = require("tests.harness").suite("parity box mon stats")
|
|
local check, eq = S.check, S.eq
|
|
|
|
local Stats = require("src.pokemon.Stats")
|
|
local SaveData = require("src.core.SaveData")
|
|
local Boxes = require("src.pokemon.Boxes")
|
|
local HudTiles = require("src.render.HudTiles")
|
|
|
|
-- A mon shaped like GenSave.decodeMon's box output: no `stats` key at all.
|
|
-- Gen 1 has no HP DV byte, it is the low bit of each of the other four
|
|
-- (home/pokemon.asm GetMonHeader), so 13/11/7/5 being odd makes the HP DV
|
|
-- 15. The .sav round trip below only compares because of that.
|
|
local DVS = { hp = 15, attack = 13, defense = 11, speed = 7, special = 5 }
|
|
local STATEXP = { hp = 1200, attack = 900, defense = 400, speed = 0, special = 250 }
|
|
|
|
local function boxShapedMon(species, level, hp)
|
|
return {
|
|
species = species, level = level, exp = 1000,
|
|
dvs = { hp = DVS.hp, attack = DVS.attack, defense = DVS.defense,
|
|
speed = DVS.speed, special = DVS.special },
|
|
statExp = { hp = STATEXP.hp, attack = STATEXP.attack,
|
|
defense = STATEXP.defense, speed = STATEXP.speed,
|
|
special = STATEXP.special },
|
|
hp = hp, status = nil, otId = 12345, catchRate = 45,
|
|
moves = { { id = "ABSORB", pp = 20 } },
|
|
typeBytes = { 22, 3 },
|
|
}
|
|
end
|
|
|
|
-- ================================================================== 1
|
|
-- Stats.ensure gives the same numbers as Stats.calc and never leaves a
|
|
-- current HP above the maximum it derived.
|
|
do
|
|
local def = Data.pokemon.ODDISH
|
|
local want = Stats.calc(def, 13, DVS, STATEXP)
|
|
|
|
local mon = boxShapedMon("ODDISH", 13, 999) -- a PKHeX-tampered current HP
|
|
eq(mon.stats, nil, "premise: a box-shaped mon starts with no stat block")
|
|
Stats.ensure(def, mon)
|
|
check(type(mon.stats) == "table", "ensure gives the box mon a stat block")
|
|
for _, key in ipairs(Stats.ORDER) do
|
|
eq(mon.stats[key], want[key], "ensure's " .. key .. " matches CalcStats")
|
|
end
|
|
eq(mon.hp, want.hp, "a tampered 999 HP clamps to the recomputed maximum")
|
|
|
|
-- a stored HP under the maximum is what box_struct really holds; keep it
|
|
local hurt = boxShapedMon("ODDISH", 13, 7)
|
|
Stats.ensure(def, hurt)
|
|
eq(hurt.hp, 7, "a stored current HP below the max is kept as stored")
|
|
eq(hurt.stats.hp, want.hp, "and the maximum is the derived one")
|
|
|
|
-- a fainted box mon stays fainted (the REVIVE case in the party menu)
|
|
local fainted = boxShapedMon("ODDISH", 13, 0)
|
|
Stats.ensure(def, fainted)
|
|
eq(fainted.hp, 0, "a fainted box mon stays at 0 HP")
|
|
|
|
-- idempotent, so a vanilla save round-trips with the numbers it was saved
|
|
-- with rather than recomputed ones
|
|
local party = boxShapedMon("ODDISH", 13, 7)
|
|
party.stats = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 }
|
|
local before = party.stats
|
|
Stats.ensure(def, party)
|
|
check(party.stats == before, "ensure leaves an existing stat block alone")
|
|
eq(party.stats.hp, 1, "and does not clamp HP against a block it did not build")
|
|
eq(party.hp, 7, "nor rewrite the stored HP")
|
|
|
|
-- a species the merged data does not know must not raise here: validate's
|
|
-- own quarantine pass owns that mon, and ensure runs before it
|
|
local unknown = boxShapedMon("NOT_A_SPECIES", 13, 7)
|
|
Stats.ensure(nil, unknown)
|
|
eq(unknown.stats, nil, "an unknown species leaves stats nil instead of raising")
|
|
end
|
|
|
|
-- ================================================================== 2
|
|
-- The real .sav path: encode -> decode is what an imported battery save does
|
|
-- to a box mon (SaveFileIO -> SaveConvert.importSav -> GenSave.decode).
|
|
do
|
|
local GenSave = require("src.save_convert.GenSave")
|
|
GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")())
|
|
|
|
local save = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" })
|
|
Boxes.ensure(save)
|
|
-- the same mon in the party and in box 1, so the two structs can be
|
|
-- compared against each other after the round trip
|
|
save.party = { boxShapedMon("ODDISH", 13, 20) }
|
|
Stats.ensure(Data.pokemon.ODDISH, save.party[1])
|
|
save.boxes[1] = { boxShapedMon("ODDISH", 13, 20) }
|
|
Stats.ensure(Data.pokemon.ODDISH, save.boxes[1][1])
|
|
|
|
local bytes = GenSave.encode(save, Data, nil)
|
|
eq(#bytes, GenSave.SAVE_SIZE, "the fixture save encodes to a 32768-byte .sav")
|
|
|
|
local imported = GenSave.decode(bytes, Data)
|
|
check(type(imported.party[1]) == "table", "the .sav decodes a party mon")
|
|
check(type((imported.boxes[1] or {})[1]) == "table", "and a box 1 mon")
|
|
local pmon, bmon = imported.party[1], imported.boxes[1][1]
|
|
check(type(pmon.stats) == "table",
|
|
"premise: party_struct carries stats, so the party mon has them")
|
|
eq(bmon.stats, nil,
|
|
"premise: box_struct does not, so the imported box mon has none (#233)")
|
|
|
|
-- Game:restoreSave runs this on every load, which is the only thing that
|
|
-- reaches a save already sitting on disk
|
|
SaveData.validate(imported, Data)
|
|
-- guarded: without the repair every line below raises the reported crash
|
|
-- itself, which would hide the rest of the suite
|
|
if check(type(bmon.stats) == "table", "validate repairs the imported box mon") then
|
|
local want = Stats.calc(Data.pokemon[bmon.species], bmon.level, bmon.dvs,
|
|
bmon.statExp)
|
|
for _, key in ipairs(Stats.ORDER) do
|
|
eq(bmon.stats[key], want[key], "repaired box " .. key .. " matches CalcStats")
|
|
end
|
|
eq(bmon.stats.hp, pmon.stats.hp,
|
|
"the box copy ends up with the same max HP the party copy stored")
|
|
check(bmon.hp <= bmon.stats.hp, "current HP can never sit above the maximum")
|
|
-- the exact expression src/render/HudTiles.lua drawHPBar died on
|
|
check(type(bmon.stats.hp) == "number",
|
|
"mon.stats.hp is a number (the nil index)")
|
|
end
|
|
|
|
-- the call SummaryMenu:draw and PartyMenu:draw both make (#233 / #304)
|
|
local okDraw, err = pcall(HudTiles.drawHPBar, Data, 11, 3, bmon, 1)
|
|
check(okDraw, "drawHPBar over a repaired box mon does not raise: " .. tostring(err))
|
|
|
|
-- box_struct has no stat fields, so the repair must not move a byte of the
|
|
-- exported .sav
|
|
local reexported = GenSave.encode(imported, Data)
|
|
eq(#reexported, GenSave.SAVE_SIZE, "the repaired save re-exports to 32768 bytes")
|
|
local firstDiff
|
|
for i = 1, #bytes do
|
|
if bytes:byte(i) ~= reexported:byte(i) then firstDiff = i break end
|
|
end
|
|
eq(firstDiff, nil,
|
|
"the stat repair leaves the .sav byte-identical on export (box_struct "
|
|
.. "has no stat fields)")
|
|
end
|
|
|
|
-- ================================================================== 3
|
|
-- Every list a stats-less mon can sit in: party, all twelve boxes, and the
|
|
-- daycare (status_screen.asm treats DAYCARE_DATA like BOX_DATA).
|
|
do
|
|
local save = SaveData.newGame()
|
|
Boxes.ensure(save)
|
|
save.party = { boxShapedMon("PIDGEY", 9, 5) }
|
|
save.boxes[1] = { boxShapedMon("RATTATA", 4, 3) }
|
|
save.boxes[7] = { boxShapedMon("ZUBAT", 22, 40) }
|
|
save.daycare = { mon = boxShapedMon("ODDISH", 13, 6) }
|
|
|
|
SaveData.validate(save, Data)
|
|
check(type(save.party[1].stats) == "table", "validate repairs a party slot")
|
|
check(type(save.boxes[1][1].stats) == "table", "validate repairs box 1")
|
|
check(type(save.boxes[7][1].stats) == "table", "validate repairs box 7")
|
|
check(type(save.daycare.mon.stats) == "table", "validate repairs the daycare mon")
|
|
|
|
-- the level clamp runs first, so the derived stats use a sane level
|
|
local wild = boxShapedMon("PIDGEY", 250, 5)
|
|
local save2 = SaveData.newGame()
|
|
Boxes.ensure(save2)
|
|
save2.boxes[1] = { wild }
|
|
SaveData.validate(save2, Data)
|
|
eq(wild.level, 100, "an out-of-range level clamps to 100")
|
|
eq(wild.stats and wild.stats.hp,
|
|
Stats.calc(Data.pokemon.PIDGEY, 100, wild.dvs, wild.statExp).hp,
|
|
"and the derived stats use the clamped level, not the stored one")
|
|
end
|
|
|
|
-- ================================================================== 4
|
|
-- #233's own site: SummaryMenu.new ports status_screen.asm's
|
|
-- .DontRecalculate branch, and Bill's PC hands it the box mon directly.
|
|
do
|
|
local realSound = package.loaded["src.core.Sound"]
|
|
package.loaded["src.core.Sound"] = { play = function() end,
|
|
playCry = function() end }
|
|
local SummaryMenu = require("src.ui.SummaryMenu")
|
|
local mon = boxShapedMon("ODDISH", 13, 20)
|
|
local game = { data = Data, save = SaveData.newGame() }
|
|
local ok, screen = pcall(SummaryMenu.new, game, mon)
|
|
check(ok, "opening STATS on a stats-less box mon does not raise: "
|
|
.. tostring(screen))
|
|
check(type(mon.stats) == "table",
|
|
"SummaryMenu.new recalculates the block before it draws (#233)")
|
|
if ok and screen then
|
|
local okDraw, err = pcall(function()
|
|
HudTiles.drawHPBar(Data, 11, 3, screen.mon, 1)
|
|
end)
|
|
check(okDraw, "and its HP bar draws: " .. tostring(err))
|
|
end
|
|
package.loaded["src.core.Sound"] = realSound
|
|
end
|
|
|
|
-- ================================================================== 5
|
|
-- #304's own site: the box -> party transfer, which is the only thing that
|
|
-- covers a mon a mod drops into a box mid-session. withdraw is a
|
|
-- file-local, reached through upvalues as tests/parity_bills_pc.lua does.
|
|
do
|
|
local BoxMenu = require("src.ui.BoxMenu")
|
|
|
|
local function getUpvalue(fn, name)
|
|
local i = 1
|
|
while true do
|
|
local n, v = debug.getupvalue(fn, i)
|
|
if not n then return nil end
|
|
if n == name then return v end
|
|
i = i + 1
|
|
end
|
|
end
|
|
local function setUpvalue(fn, name, val)
|
|
local i = 1
|
|
while true do
|
|
local n = debug.getupvalue(fn, i)
|
|
if not n then return false end
|
|
if n == name then debug.setupvalue(fn, i, val); return true end
|
|
i = i + 1
|
|
end
|
|
end
|
|
|
|
local withdraw = getUpvalue(BoxMenu.new, "withdraw")
|
|
check(type(withdraw) == "function", "found BoxMenu's withdraw upvalue")
|
|
|
|
if type(withdraw) == "function" then
|
|
-- capture the list menu's opts instead of building a real one
|
|
local captured
|
|
check(setUpvalue(withdraw, "ListMenu", {
|
|
new = function(_, _, items, opts)
|
|
captured = { items = items, opts = opts }
|
|
return { kind = "list", items = items, close = function() end }
|
|
end,
|
|
}), "stubbed ListMenu on withdraw")
|
|
-- the WITHDRAW row is what fires onAction; run it straight
|
|
check(setUpvalue(withdraw, "monSubmenu",
|
|
function(_, _, _, onAction) onAction() end),
|
|
"stubbed monSubmenu on withdraw")
|
|
check(setUpvalue(withdraw, "afterTransfer", function() end),
|
|
"stubbed afterTransfer on withdraw")
|
|
|
|
local realSound = package.loaded["src.core.Sound"]
|
|
package.loaded["src.core.Sound"] = { play = function() end,
|
|
playCry = function() end }
|
|
|
|
local save = SaveData.newGame()
|
|
Boxes.ensure(save)
|
|
save.party = {}
|
|
save.boxes[1] = { boxShapedMon("ODDISH", 13, 20) }
|
|
local mon = save.boxes[1][1]
|
|
local game = { data = Data, save = save,
|
|
stack = { push = function() end, pop = function() end } }
|
|
|
|
withdraw(game)
|
|
check(captured ~= nil, "withdraw opened its box list")
|
|
if captured then
|
|
eq(#captured.items, 1, "the list has the one box mon in it")
|
|
captured.opts.onChoose(captured.items[1],
|
|
{ index = 1, close = function() end })
|
|
eq(#save.boxes[1], 0, "the mon left the box")
|
|
eq(#save.party, 1, "and landed in the party")
|
|
eq(save.party[1], mon, "it is the same mon table")
|
|
check(type(mon.stats) == "table",
|
|
"withdraw ran CalcStats on the way out (#304)")
|
|
local want = Stats.calc(Data.pokemon.ODDISH, 13, mon.dvs, mon.statExp)
|
|
eq(mon.stats and mon.stats.hp, want.hp,
|
|
"the withdrawn mon's max HP is the derived one")
|
|
local okDraw, err = pcall(HudTiles.drawHPBar, Data, 5, 5, mon, 2)
|
|
check(okDraw, "the party row's HP bar draws for it: " .. tostring(err))
|
|
end
|
|
|
|
package.loaded["src.core.Sound"] = realSound
|
|
-- run_tests.lua dofiles the parity suites in one interpreter, so a later
|
|
-- suite would inherit these stubs
|
|
package.loaded["src.ui.BoxMenu"] = nil
|
|
end
|
|
end
|
|
|
|
S.finish()
|