mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
189 lines
7.0 KiB
Lua
189 lines
7.0 KiB
Lua
-- Parity test: a ball thrown at the POKEMON_TOWER_6F RESTLESS SOUL is
|
|
-- always dodged, scope or no scope.
|
|
--
|
|
-- ItemUseBall reaches the $10 "can't be caught" anim data by TWO
|
|
-- independent routes (engine/items/item_effects.asm):
|
|
--
|
|
-- :149-153 callfar IsGhostBattle / ld b, $10 / jp z, .setAnimData
|
|
-- :166-175 .notOldManBattle -- wCurMap == POKEMON_TOWER_6F and
|
|
-- wEnemyMonSpecies2 == RESTLESS_SOUL -> the same $10
|
|
--
|
|
-- The port only had the first, as the scope-less disguise flag
|
|
-- self.ghost. Once the SILPH_SCOPE revealed the MAROWAK the battle was
|
|
-- an ordinary wild one, so throwBall ran the capture roll and a MASTER
|
|
-- BALL caught it outright. That result is "caught", not "win" or the
|
|
-- POKE DOLL escape, so PokemonTower6F's script never set
|
|
-- EVENT_BEAT_GHOST_MAROWAK and the (10,16) trigger re-fired forever
|
|
-- (#444). The map+species half sits BEFORE .loop, hence before the
|
|
-- MASTER_BALL shortcut, so even a Master Ball is dodged.
|
|
--
|
|
-- Run-away parity is the other side of this: only IsGhostBattle grants
|
|
-- the free escape (engine/battle/core.asm TryRunningFromBattle), so a
|
|
-- revealed MAROWAK keeps normal flee rolls and self.ghost stays the sole
|
|
-- gate there.
|
|
--
|
|
-- Self-contained; run via `luajit tests/parity_marowak_ball.lua`.
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
if not _G.love then _G.love = require("tests.love_stub") end
|
|
local S = require("tests.harness").suite("parity marowak ball")
|
|
local check, eq = S.check, S.eq
|
|
|
|
local BattleState = require("src.battle.BattleState")
|
|
|
|
-- ---- 1. the 6F script arms noCatch with and without the scope -----------
|
|
do
|
|
local realTextBox = package.loaded["src.render.TextBox"]
|
|
local realBattleState = package.loaded["src.battle.BattleState"]
|
|
package.loaded["src.render.TextBox"] = {
|
|
new = function(_, text, done) return { text = text, done = done } end,
|
|
}
|
|
local made = {}
|
|
package.loaded["src.battle.BattleState"] = {
|
|
newWild = function(_, species, level)
|
|
local b = { species = species, level = level, ghost = false }
|
|
b.makeGhost = function(self) self.ghost = true end
|
|
-- the scope's branch (#492): disguised on entry, but IsGhostBattle
|
|
-- false, which is exactly the state the dodge below has to survive
|
|
b.makeUnveiledGhost = function(self) self.scopeReveal = true end
|
|
made[#made + 1] = b
|
|
return b
|
|
end,
|
|
}
|
|
|
|
local tower = dofile("data/scripts/story3.lua").POKEMON_TOWER_6F
|
|
local function trigger(inventory)
|
|
local pushed = {}
|
|
local game = {
|
|
save = { inventory = inventory, flags = {} },
|
|
data = { text = {} },
|
|
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
|
|
}
|
|
local ow = {
|
|
player = {},
|
|
scriptMove = function() end,
|
|
afterBattle = function() end,
|
|
}
|
|
check(tower.onStep(game, ow, 10, 16), "the trigger fires on (10,16)")
|
|
pushed[1].done()
|
|
return made[#made]
|
|
end
|
|
|
|
local noScope = trigger({})
|
|
check(noScope.ghost, "without the scope the battle is still disguised")
|
|
check(noScope.noCatch, "and noCatch is set")
|
|
|
|
local withScope = trigger({ SILPH_SCOPE = 1 })
|
|
check(not withScope.ghost, "with the scope IsGhostBattle is false")
|
|
check(withScope.scopeReveal, "and the unveil plays instead (#492)")
|
|
check(withScope.noCatch,
|
|
"but noCatch survives it -- balls are dodged either way")
|
|
|
|
package.loaded["src.render.TextBox"] = realTextBox
|
|
package.loaded["src.battle.BattleState"] = realBattleState
|
|
end
|
|
|
|
-- ---- 2. throwBall takes the dodge branch on noCatch alone ---------------
|
|
local realSound = package.loaded["src.core.Sound"]
|
|
package.loaded["src.core.Sound"] = { play = function() end }
|
|
|
|
-- A real BattleState minus the pieces the decision does not touch: the
|
|
-- capture roll and the ball chain record that they were reached, which is
|
|
-- exactly the bug (a MASTER BALL catching the revealed MAROWAK).
|
|
local function throw(flags, ball)
|
|
local self = setmetatable({
|
|
kind = "wild",
|
|
ghost = flags.ghost or false,
|
|
noCatch = flags.noCatch or false,
|
|
queue = {},
|
|
rolled = false,
|
|
chained = false,
|
|
enemyMoved = false,
|
|
turnEnded = false,
|
|
data = { items = { MASTER_BALL = { name = "MASTER BALL" },
|
|
POKE_BALL = { name = "POKé BALL" } },
|
|
text = {} },
|
|
game = { save = { player = { name = "RED" } } },
|
|
player = {},
|
|
enemy = {},
|
|
}, BattleState)
|
|
self.ballDef = function() return nil end
|
|
self.catchAttempt = function(s) s.rolled = true return false, 3 end
|
|
self.ballChain = function(s) s.chained = true end
|
|
self.enemyAction = function() return {} end
|
|
self.executeAction = function(s) s.enemyMoved = true end
|
|
self.endOfTurn = function(s) s.turnEnded = true end
|
|
self:throwBall(ball)
|
|
-- the whole outcome lives in the act() closure throwBall queues, and
|
|
-- that closure queues more rows, so drain like updateQueue does: run
|
|
-- each fn row once, with nextInsert pointing at it.
|
|
local ran = {}
|
|
local more = true
|
|
while more do
|
|
more = false
|
|
for i, row in ipairs(self.queue) do
|
|
if row.fn and not ran[row] then
|
|
ran[row] = true
|
|
self.nextInsert = i
|
|
row.fn()
|
|
more = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
local texts = {}
|
|
for _, row in ipairs(self.queue) do
|
|
if row.text then texts[#texts + 1] = tostring(row.text) end
|
|
end
|
|
self.texts = table.concat(texts, "|")
|
|
return self
|
|
end
|
|
|
|
local function assertDodge(b, label)
|
|
check(not b.rolled, label .. ": no capture roll")
|
|
check(not b.chained, label .. ": no wobble chain")
|
|
check(b.texts:find("It dodged the", 1, true) ~= nil,
|
|
label .. ": ItemUseBallText00 line 1")
|
|
check(b.texts:find("can't be caught", 1, true) ~= nil,
|
|
label .. ": ItemUseBallText00 line 2")
|
|
check(b.enemyMoved, label .. ": the turn is spent, the foe moves")
|
|
check(b.turnEnded, label .. ": and the turn ends")
|
|
end
|
|
|
|
assertDodge(throw({ ghost = true }, "POKE_BALL"), "IsGhostBattle exit")
|
|
assertDodge(throw({ noCatch = true }, "POKE_BALL"), ".notOldManBattle exit")
|
|
-- the regression itself: revealed by the scope, so ghost is false
|
|
assertDodge(throw({ noCatch = true }, "MASTER_BALL"), "MASTER BALL")
|
|
|
|
do
|
|
local plain = throw({}, "MASTER_BALL")
|
|
check(plain.rolled,
|
|
"an ordinary wild mon still rolls -- the guard is not global")
|
|
end
|
|
|
|
-- The dodged toss keeps the arc the thrown ball picked (TossBallAnimation
|
|
-- reads wCurItem), so the Master Ball flicker is not lost.
|
|
do
|
|
local b = throw({ noCatch = true }, "MASTER_BALL")
|
|
local anim
|
|
for _, row in ipairs(b.queue) do
|
|
if row.anim then anim = row.anim break end
|
|
end
|
|
eq("ULTRATOSS_ANIM", anim, "a dodged MASTER BALL still tosses as ULTRATOSS")
|
|
end
|
|
|
|
package.loaded["src.core.Sound"] = realSound
|
|
|
|
-- ---- 3. noCatch grants no free escape ----------------------------------
|
|
do
|
|
local function roll(flags)
|
|
local b = { ghost = flags.ghost or false, noCatch = flags.noCatch or false,
|
|
runAttempts = 1, rng = function() return 255 end }
|
|
return BattleState.runRollVanilla(b, 10, 100)
|
|
end
|
|
check(roll({ ghost = true }), "IsGhostBattle still always escapes")
|
|
check(not roll({ noCatch = true }),
|
|
"a revealed MAROWAK takes the normal flee roll")
|
|
end
|
|
|
|
S.finish()
|