mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
212 lines
8.2 KiB
Lua
212 lines
8.2 KiB
Lua
-- Parity test, Vermilion Gym trash can puzzle.
|
|
-- Self-contained: run via `luajit tests/parity_trashcans.lua`; also
|
|
-- dofile'd by tests/run_tests.lua's aggregator.
|
|
--
|
|
-- Covers engine/events/hidden_events/vermilion_gym_trash.asm
|
|
-- (GymTrashScript + the GymTrashCans table, bug included) and
|
|
-- scripts/VermilionCity.asm (VermilionCity_Script
|
|
-- .setFirstLockTrashCanIndex):
|
|
-- * the first-lock can is rolled on EVERY Vermilion City map load
|
|
-- (Random & $0e -> a random even can), not lazily in the gym
|
|
-- * the second-lock can comes from the GymTrashCans row for the
|
|
-- first can: `mask AND random-byte` minus 1 indexes the candidate
|
|
-- bytes, so a zero AND underflows ($ff) into the bank's zero
|
|
-- padding and lands the switch in can 0 regardless (the documented
|
|
-- bug); mask 2 can only reach candidate 2, mask 4 only candidate 4
|
|
-- * a wrong second can resets EVENT_1ST_LOCK_OPENED and immediately
|
|
-- re-rolls the first can
|
|
-- * opening the second lock prints only VermilionGymTrashSuccessText3
|
|
-- (SuccessText2 is unused in pokered) and opens the door block
|
|
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.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
|
local S = require("tests.harness").suite("parity trashcans")
|
|
local check, eq = S.check, S.eq
|
|
|
|
local OW = require("src.world.OverworldController")
|
|
local SaveData = require("src.core.SaveData")
|
|
local story = require("data.scripts.story")
|
|
|
|
-- trashCanSwitch closes over the module-locals `Game` and `TextBox`
|
|
-- (set during real boot); rewire them through the debug library, the
|
|
-- same trick as parity_D.lua.
|
|
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 shown = {}
|
|
local textBoxStub = {
|
|
new = function(_, text, onDone)
|
|
shown[#shown + 1] = text
|
|
if onDone then onDone() end
|
|
return { text = text }
|
|
end,
|
|
}
|
|
local realTextBox = getUpvalue(OW.trashCanSwitch, "TextBox")
|
|
local fakeGame = { data = Data, save = SaveData.newGame(), stack = { push = function() end } }
|
|
check(setUpvalue(OW.trashCanSwitch, "TextBox", textBoxStub), "TextBox upvalue rewired")
|
|
check(setUpvalue(OW.trashCanSwitch, "Game", fakeGame), "Game upvalue rewired")
|
|
|
|
-- scripted love.math.random: feed(...) queues the next return values
|
|
local realRandom = love.math.random
|
|
local queue = {}
|
|
local function feed(...) queue = { ... } end
|
|
love.math.random = function(a, b)
|
|
if #queue > 0 then return table.remove(queue, 1) end
|
|
return realRandom(a, b)
|
|
end
|
|
|
|
local replaced = {}
|
|
local fakeSelf = setmetatable({
|
|
map = { id = "VERMILION_GYM" },
|
|
replaceBlock = function(_, x, y, b) replaced[#replaced + 1] = { x = x, y = y, b = b } end,
|
|
}, { __index = OW })
|
|
|
|
local function fresh()
|
|
fakeGame.save = SaveData.newGame()
|
|
shown = {}
|
|
replaced = {}
|
|
end
|
|
|
|
local t = Data.text
|
|
|
|
-- === Vermilion City map load rolls (and re-rolls) the first-lock can ===
|
|
do
|
|
fresh()
|
|
check(story.VERMILION_CITY.onEnter ~= nil, "VERMILION_CITY.onEnter registered")
|
|
feed(3) -- Random & $0e ported as random(0,7)*2
|
|
story.VERMILION_CITY.onEnter(fakeGame, {})
|
|
eq(fakeGame.save.trashPuzzle.first, 6, "map load rolls the first-lock can (even index)")
|
|
feed(0)
|
|
story.VERMILION_CITY.onEnter(fakeGame, {})
|
|
eq(fakeGame.save.trashPuzzle.first, 0, "every map load re-rolls, not just the first")
|
|
-- the roll is unconditional: mid-second-stage state survives, only
|
|
-- the (unread) first index changes
|
|
fakeGame.save.flags.EVENT_1ST_LOCK_OPENED = true
|
|
fakeGame.save.trashPuzzle.second = 3
|
|
feed(5)
|
|
story.VERMILION_CITY.onEnter(fakeGame, {})
|
|
eq(fakeGame.save.trashPuzzle.first, 10, "re-roll happens even after the 1st lock opened")
|
|
check(fakeGame.save.flags.EVENT_1ST_LOCK_OPENED, "map load leaves EVENT_1ST_LOCK_OPENED alone")
|
|
eq(fakeGame.save.trashPuzzle.second, 3, "map load leaves the second-lock can alone")
|
|
end
|
|
|
|
-- === wrong first can: plain trash text, nothing opens ===
|
|
do
|
|
fresh()
|
|
fakeGame.save.trashPuzzle = { first = 0 }
|
|
fakeSelf:trashCanSwitch(2)
|
|
check(not fakeGame.save.flags.EVENT_1ST_LOCK_OPENED, "wrong first can opens nothing")
|
|
eq(shown[#shown], t._VermilionGymTrashText, "wrong first can shows VermilionGymTrashText")
|
|
end
|
|
|
|
-- === right first can: EVENT_1ST_LOCK_OPENED + GymTrashCans second roll ===
|
|
-- can 0's row is `mask 2, candidates 1,3`: AND result 2 -> byte offset 1
|
|
-- -> candidate 2 (=can 3); candidate 1 is unreachable with mask 2
|
|
do
|
|
fresh()
|
|
fakeGame.save.trashPuzzle = { first = 0 }
|
|
feed(2) -- random byte: band(2, mask 2) = 2
|
|
fakeSelf:trashCanSwitch(0)
|
|
check(fakeGame.save.flags.EVENT_1ST_LOCK_OPENED == true, "right can sets EVENT_1ST_LOCK_OPENED")
|
|
eq(fakeGame.save.trashPuzzle.second, 3, "mask 2 reaches only the 2nd candidate (can 3)")
|
|
eq(shown[#shown], t._VermilionGymTrashSuccessText1, "first lock shows SuccessText1")
|
|
end
|
|
|
|
-- zero AND result: `dec a` underflows and the read lands in zero
|
|
-- padding -> the second switch is can 0 regardless of adjacency
|
|
do
|
|
fresh()
|
|
fakeGame.save.trashPuzzle = { first = 0 }
|
|
feed(13) -- band(13, mask 2) = 0
|
|
fakeSelf:trashCanSwitch(0)
|
|
eq(fakeGame.save.trashPuzzle.second, 0, "zero AND puts the second switch in can 0 (the bug)")
|
|
end
|
|
|
|
-- can 4's row is `mask 4, candidates 1,3,5,7`: only AND result 4
|
|
-- (-> 4th candidate, can 7) or the can-0 bug are possible
|
|
do
|
|
fresh()
|
|
fakeGame.save.trashPuzzle = { first = 4 }
|
|
feed(4) -- band(4, mask 4) = 4
|
|
fakeSelf:trashCanSwitch(4)
|
|
eq(fakeGame.save.trashPuzzle.second, 7, "mask 4 reaches only the 4th candidate (can 7)")
|
|
fresh()
|
|
fakeGame.save.trashPuzzle = { first = 4 }
|
|
feed(3) -- band(3, mask 4) = 0
|
|
fakeSelf:trashCanSwitch(4)
|
|
eq(fakeGame.save.trashPuzzle.second, 0, "mask 4 with a zero AND falls into can 0")
|
|
end
|
|
|
|
-- can 6's row is `mask 3, candidates 3,7,9`: results 1-3 map onto all
|
|
-- three candidates
|
|
do
|
|
for _, case in ipairs({ { 1, 3 }, { 2, 7 }, { 3, 9 }, { 4, 0 } }) do
|
|
fresh()
|
|
fakeGame.save.trashPuzzle = { first = 6 }
|
|
feed(case[1]) -- band(case[1], mask 3): 4 -> 0 (bug), else itself
|
|
fakeSelf:trashCanSwitch(6)
|
|
eq(fakeGame.save.trashPuzzle.second, case[2],
|
|
("mask 3, AND=%d -> second can %d"):format(case[1] % 4, case[2]))
|
|
end
|
|
end
|
|
|
|
-- === wrong second can: relock + IMMEDIATE first-can re-roll ===
|
|
do
|
|
fresh()
|
|
fakeGame.save.flags.EVENT_1ST_LOCK_OPENED = true
|
|
fakeGame.save.trashPuzzle = { first = 0, second = 3 }
|
|
feed(5) -- the fail path's Random & $e -> can 10
|
|
fakeSelf:trashCanSwitch(1)
|
|
check(not fakeGame.save.flags.EVENT_1ST_LOCK_OPENED, "fail resets EVENT_1ST_LOCK_OPENED")
|
|
eq(fakeGame.save.trashPuzzle.first, 10, "fail immediately re-rolls the first can")
|
|
eq(fakeGame.save.trashPuzzle.second, nil, "fail clears the second-lock can")
|
|
eq(shown[#shown], t._VermilionGymTrashFailText, "fail shows VermilionGymTrashFailText")
|
|
end
|
|
|
|
-- === right second can: puzzle done, door block opens, only Text3 ===
|
|
do
|
|
fresh()
|
|
fakeGame.save.flags.EVENT_1ST_LOCK_OPENED = true
|
|
fakeGame.save.trashPuzzle = { first = 0, second = 3 }
|
|
fakeSelf:trashCanSwitch(3)
|
|
check(fakeGame.save.flags.EVENT_2ND_LOCK_OPENED == true, "second lock sets EVENT_2ND_LOCK_OPENED")
|
|
eq(shown[#shown], t._VermilionGymTrashSuccessText3,
|
|
"only SuccessText3 prints (SuccessText2 is unused in pokered)")
|
|
check(replaced[1] and replaced[1].x == 2 and replaced[1].y == 2 and replaced[1].b == 5,
|
|
"door block (2,2) replaced with the clear floor block")
|
|
-- solved puzzle: every can is plain trash from now on
|
|
fakeSelf:trashCanSwitch(0)
|
|
eq(shown[#shown], t._VermilionGymTrashText, "solved puzzle shows plain trash text")
|
|
end
|
|
|
|
-- === legacy saves: pre-rewrite `opened1` migrates to the event flag ===
|
|
do
|
|
fresh()
|
|
fakeGame.save.trashPuzzle = { first = 0, opened1 = true, second = 3 }
|
|
fakeSelf:trashCanSwitch(3)
|
|
check(fakeGame.save.flags.EVENT_2ND_LOCK_OPENED == true,
|
|
"legacy opened1 save still completes the puzzle")
|
|
end
|
|
|
|
love.math.random = realRandom
|
|
setUpvalue(OW.trashCanSwitch, "TextBox", realTextBox)
|
|
|
|
S.finish()
|