mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
372 lines
16 KiB
Lua
372 lines
16 KiB
Lua
-- Parity test, Workstream J.
|
|
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 J")
|
|
local check, eq = S.check, S.eq
|
|
|
|
-- === assertions per your spec test plan ===
|
|
|
|
local Game = require("src.core.Game")
|
|
Game.data = Data
|
|
Game.save = require("src.core.SaveData").newGame()
|
|
local Font = require("src.render.Font")
|
|
if not pcall(Font.encode, "A") then Font.load(Data) end
|
|
local Pokemon = require("src.pokemon.Pokemon")
|
|
local BattleState = require("src.battle.BattleState")
|
|
local MoveEffects = require("src.battle.MoveEffects")
|
|
|
|
local function freshBattle()
|
|
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
|
|
local tb = BattleState.newWild(Game, "PIDGEY", 10)
|
|
return tb
|
|
end
|
|
|
|
-- scripted rng: pops the given values, then always returns the max roll
|
|
local function mkseq(vals)
|
|
local i = 0
|
|
return function(a, b)
|
|
i = i + 1
|
|
return vals[i] ~= nil and vals[i] or b
|
|
end
|
|
end
|
|
|
|
-- (1) TRANSFORM: user.sprite morphs into the target species pic AND the
|
|
-- target's stat stages are copied (not cleared). transform.asm:31-53
|
|
-- (AnimationTransformMon) + :57-132 (copies wEnemyMonStatMods).
|
|
do
|
|
local tb = freshBattle()
|
|
local preSprite = tb.player.sprite
|
|
tb.enemy.stages.attack = 2
|
|
tb.enemy.stages.speed = -1
|
|
MoveEffects.primary.TRANSFORM_EFFECT(tb, tb.player, tb.enemy)
|
|
check(tb.player.sprite ~= preSprite, "transform swaps the user's sprite")
|
|
eq(tb.player.sprite.path, Data.pokemon.PIDGEY.spriteBack,
|
|
"player-side transform uses the target species BACK pic")
|
|
eq(tb.player.stages.attack, 2, "transform copies target's +attack stage")
|
|
eq(tb.player.stages.speed, -1, "transform copies target's -speed stage")
|
|
-- deep copy: later changes to the target must not bleed into the user
|
|
tb.enemy.stages.attack = 5
|
|
eq(tb.player.stages.attack, 2, "transform stages are a deep copy")
|
|
-- enemy-side transform uses the FRONT pic
|
|
local tb2 = freshBattle()
|
|
MoveEffects.primary.TRANSFORM_EFFECT(tb2, tb2.enemy, tb2.player)
|
|
eq(tb2.enemy.sprite.path, Data.pokemon.BULBASAUR.spriteFront,
|
|
"enemy-side transform uses the target species FRONT pic")
|
|
end
|
|
|
|
-- (2) TRAPPING: continueTrapping replays the trapping move's per-turn
|
|
-- animation (core.asm:3554-3566 -> GetPlayerAnimationType).
|
|
do
|
|
local tb = freshBattle()
|
|
-- performMove WRAP rng: accuracy, crit, damage-random, trapping counter
|
|
tb.rng = mkseq({ 0, 255, 255, 0 })
|
|
tb:performMove(tb.player, tb.enemy, { id = "WRAP", pp = 10 })
|
|
eq(tb.player.trapMove, "WRAP", "the trapping move id is stored on the user")
|
|
-- keep the victim alive so the continuation stays clean
|
|
tb.enemy.mon.stats.hp = 999
|
|
tb.enemy.mon.hp = 999
|
|
tb.queue = {}
|
|
tb.nextInsert = 0
|
|
tb:continueTrapping(tb.player, tb.enemy)
|
|
local animRow
|
|
for _, row in ipairs(tb.queue) do
|
|
if row.anim == "WRAP" then animRow = row end
|
|
end
|
|
check(animRow ~= nil, "continueTrapping queues an anim row for the trap move")
|
|
check(animRow and animRow.attackerIsPlayer == true,
|
|
"the queued trap anim row is attributed to the attacker")
|
|
end
|
|
|
|
-- (3) MIMIC runs MID-move (MimicEffect, effects.asm:1203-1273): the
|
|
-- move executes, MoveHitTest runs, and only on a hit does the player's
|
|
-- copy menu open (.letPlayerChooseMove) -- the enemy's Mimic and link
|
|
-- battles roll a random slot (.getRandomMove). The copied move
|
|
-- overwrites the used slot's ID in place (the PP byte is untouched,
|
|
-- :1261-1266) and the id snaps back when the battler leaves play.
|
|
local function queueScan(tb)
|
|
local found = { texts = {} }
|
|
for _, row in ipairs(tb.queue) do
|
|
if row.mimicSelect then found.chooser = row.mimicSelect end
|
|
if row.anim == "MIMIC" then found.anim = true end
|
|
if row.text then table.insert(found.texts, row.text) end
|
|
end
|
|
return found
|
|
end
|
|
local function hasText(found, pat)
|
|
for _, t in ipairs(found.texts) do if t:find(pat) then return true end end
|
|
return false
|
|
end
|
|
do
|
|
-- player Mimic HIT: the chooser row is queued only after the hit test
|
|
local tb = freshBattle()
|
|
tb.kind = "wild"
|
|
tb.player.curMoves[1] = { id = "MIMIC", pp = 10 } -- aliases mon.moves
|
|
tb.enemy.curMoves = { { id = "GUST", pp = 35 }, { id = "SAND_ATTACK", pp = 15 } }
|
|
tb.rng = function(a, b) return a end -- accuracy roll 0: hit
|
|
tb.queue, tb.nextInsert = {}, 0
|
|
tb:performMove(tb.player, tb.enemy, tb.player.curMoves[1])
|
|
local found = queueScan(tb)
|
|
check(found.chooser ~= nil, "player Mimic hit queues the mid-move chooser row")
|
|
check(not found.anim, "no Mimic animation before the copy is picked")
|
|
eq(tb.player.curMoves[1].id, "MIMIC", "the slot is untouched until the pick")
|
|
-- apply the pick like the mimicSelect A-handler does
|
|
tb.nextInsert = 0
|
|
tb:applyMimic(found.chooser.user, found.chooser.target, found.chooser.moveInst, 2)
|
|
eq(tb.player.curMoves[1].id, "SAND_ATTACK",
|
|
"the pick overwrites Mimic's slot id (slot 2 copied)")
|
|
eq(tb.player.curMoves[1].pp, 9,
|
|
"the copy inherits Mimic's remaining PP (only the move id byte is written)")
|
|
eq(tb.player.mon.moves[1].id, "SAND_ATTACK",
|
|
"the battle slot aliases the party slot (DecrementPP hits both)")
|
|
check(tb.player.curMoves[1].mimic == true, "the copied slot is flagged mimic")
|
|
found = queueScan(tb)
|
|
check(found.anim, "the Mimic animation plays after the copy")
|
|
check(hasText(found, "learned"), "the 'learned MOVE!' text follows")
|
|
-- the id snaps back when the battler leaves play; spent PP stays spent
|
|
tb:restoreMimicked(tb.player)
|
|
eq(tb.player.mon.moves[1].id, "MIMIC", "leaving play restores the party move id")
|
|
eq(tb.player.mon.moves[1].pp, 9, "spent PP stays spent after the restore")
|
|
|
|
-- player Mimic MISS: "But, it failed!", no chooser, no animation
|
|
local tbm = freshBattle()
|
|
tbm.kind = "wild"
|
|
tbm.player.curMoves[1] = { id = "MIMIC", pp = 10 }
|
|
tbm.enemy.curMoves = { { id = "GUST", pp = 35 } }
|
|
tbm.rng = function(a, b) return b end -- accuracy roll 255: miss
|
|
tbm.queue, tbm.nextInsert = {}, 0
|
|
tbm:performMove(tbm.player, tbm.enemy, tbm.player.curMoves[1])
|
|
local fm = queueScan(tbm)
|
|
check(fm.chooser == nil, "a missed Mimic never opens the chooser")
|
|
check(not fm.anim, "a missed Mimic plays no animation")
|
|
check(hasText(fm, "But, it failed!"),
|
|
"a missed Mimic prints PrintButItFailedText_")
|
|
eq(tbm.player.curMoves[1].id, "MIMIC", "a missed Mimic copies nothing")
|
|
|
|
-- a mid-Fly/Dig target also fails (.mimicMissed via INVULNERABLE)
|
|
local tbi = freshBattle()
|
|
tbi.kind = "wild"
|
|
tbi.player.curMoves[1] = { id = "MIMIC", pp = 10 }
|
|
tbi.enemy.curMoves = { { id = "GUST", pp = 35 } }
|
|
tbi.enemy.invulnerable = true
|
|
tbi.rng = function(a, b) return a end
|
|
tbi.queue, tbi.nextInsert = {}, 0
|
|
tbi:performMove(tbi.player, tbi.enemy, tbi.player.curMoves[1])
|
|
local fi = queueScan(tbi)
|
|
check(fi.chooser == nil and hasText(fi, "But, it failed!"),
|
|
"Mimic fails outright against a mid-Fly/Dig target")
|
|
|
|
-- enemy Mimic: random slot of the player, no menu (.getRandomMove)
|
|
local tbe = freshBattle()
|
|
tbe.kind = "wild"
|
|
tbe.player.curMoves = { { id = "GUST", pp = 35 }, { id = "SAND_ATTACK", pp = 15 } }
|
|
tbe.enemy.curMoves[1] = { id = "MIMIC", pp = 10 }
|
|
tbe.rng = mkseq({ 0, 2 }) -- accuracy hit, then random slot 2
|
|
tbe.queue, tbe.nextInsert = {}, 0
|
|
tbe:performMove(tbe.enemy, tbe.player, tbe.enemy.curMoves[1])
|
|
local fe = queueScan(tbe)
|
|
check(fe.chooser == nil, "enemy Mimic never opens a chooser")
|
|
eq(tbe.enemy.curMoves[1].id, "SAND_ATTACK",
|
|
"enemy Mimic copies a random player move immediately")
|
|
eq(tbe.enemy.curMoves[1].pp, 9, "enemy Mimic also keeps the slot's PP")
|
|
check(fe.anim and hasText(fe, "learned"),
|
|
"enemy Mimic still plays the animation and learned text")
|
|
|
|
-- link battle: the player's Mimic rolls random too (no chooser)
|
|
local tbl = freshBattle()
|
|
tbl.kind = "link"
|
|
tbl.player.curMoves[1] = { id = "MIMIC", pp = 10 }
|
|
tbl.enemy.curMoves = { { id = "GUST", pp = 35 }, { id = "SAND_ATTACK", pp = 15 } }
|
|
tbl.rng = mkseq({ 0, 1 }) -- accuracy hit, random slot 1
|
|
tbl.queue, tbl.nextInsert = {}, 0
|
|
tbl:performMove(tbl.player, tbl.enemy, tbl.player.curMoves[1])
|
|
local fl = queueScan(tbl)
|
|
check(fl.chooser == nil, "link Mimic skips the interactive chooser")
|
|
eq(tbl.player.curMoves[1].id, "GUST", "link Mimic rolls random (slot 1)")
|
|
tbl:restoreMimicked(tbl.player)
|
|
|
|
-- the test-injection hook still short-circuits the player's menu
|
|
local tbh = freshBattle()
|
|
tbh.kind = "wild"
|
|
tbh.player.curMoves[1] = { id = "MIMIC", pp = 10 }
|
|
tbh.enemy.curMoves = { { id = "GUST", pp = 35 }, { id = "SAND_ATTACK", pp = 15 } }
|
|
tbh.mimicChoice = function(self, target) return 2 end
|
|
tbh.rng = function(a, b) return a end
|
|
tbh.queue, tbh.nextInsert = {}, 0
|
|
tbh:performMove(tbh.player, tbh.enemy, tbh.player.curMoves[1])
|
|
eq(tbh.player.curMoves[1].id, "SAND_ATTACK",
|
|
"the mimicChoice hook applies the pick without a chooser row")
|
|
end
|
|
|
|
-- (3b) MIMIC chooser wiring through the queue: the mimicSelect row
|
|
-- flips the phase when it reaches the queue head, and the A-press
|
|
-- applies the highlighted slot (B never cancels: MoveSelectionMenu's
|
|
-- mimic menu watches only UP/DOWN/A, core.asm:2553-2557).
|
|
do
|
|
local pressed = {}
|
|
local tb = freshBattle()
|
|
tb.game = { input = { wasPressed = function(_, k) return pressed[k] or false end },
|
|
stack = { top = function() return tb end },
|
|
save = Game.save }
|
|
tb.kind = "wild"
|
|
tb.player.curMoves[1] = { id = "MIMIC", pp = 10 }
|
|
tb.enemy.curMoves = { { id = "GUST", pp = 35 }, { id = "SAND_ATTACK", pp = 15 } }
|
|
tb.rng = function(a, b) return a end
|
|
tb.queue, tb.nextInsert = {}, 0
|
|
tb.phase = "messages"
|
|
tb.afterQueue = "menu"
|
|
tb:performMove(tb.player, tb.enemy, tb.player.curMoves[1])
|
|
-- drain the queue past the announcement + 50-frame beat
|
|
for _ = 1, 400 do
|
|
if tb.phase ~= "messages" then break end
|
|
pressed.a = true
|
|
tb:updateQueue()
|
|
if tb.current then tb.current = nil end -- fast-forward text rows
|
|
end
|
|
pressed.a = false
|
|
eq(tb.phase, "mimicSelect", "the queued chooser row enters the mimicSelect phase")
|
|
eq(#tb.mimicMoves, 2, "the chooser lists the enemy's moves")
|
|
eq(tb.mimicIndex, 1, "the cursor starts on the first move")
|
|
-- B does nothing (no cancel path in the mimic menu)
|
|
pressed.b = true
|
|
tb:update(1 / 60)
|
|
pressed.b = false
|
|
eq(tb.phase, "mimicSelect", "B does not leave the mimic menu")
|
|
-- DOWN then A copies slot 2
|
|
pressed.down = true
|
|
tb:update(1 / 60)
|
|
pressed.down = false
|
|
eq(tb.mimicIndex, 2, "DOWN moves the chooser cursor")
|
|
pressed.a = true
|
|
tb:update(1 / 60)
|
|
pressed.a = false
|
|
eq(tb.phase, "messages", "A returns to the message queue")
|
|
eq(tb.player.curMoves[1].id, "SAND_ATTACK", "A applies the highlighted slot")
|
|
tb:restoreMimicked(tb.player)
|
|
end
|
|
|
|
-- (4) BIDE (regression): Gen 1 never rolls accuracy on release, so the
|
|
-- stored energy unleashes as bideDamage*2 even when an accuracy roll
|
|
-- would have missed (effects.asm:764-789 + core.asm:3481-3529).
|
|
do
|
|
local tb = freshBattle()
|
|
tb.enemy.mon.stats.hp = 200
|
|
tb.enemy.mon.hp = 200
|
|
local rngCalls = 0
|
|
tb.rng = function(a, b) rngCalls = rngCalls + 1; return b end -- 255 -> would miss
|
|
tb.player.bideTurns = 1
|
|
tb.player.bideDamage = 50
|
|
tb:continueBide(tb.player, tb.enemy)
|
|
eq(tb.player.bideTurns, nil, "Bide releases on schedule")
|
|
eq(tb.enemy.mon.hp, 100, "Bide release deals bideDamage*2 (2*50)")
|
|
eq(rngCalls, 0, "Bide release consumes no rng for an accuracy check")
|
|
end
|
|
|
|
-- (5) OLD MAN catch tutorial (DisplayBattleMenu's old-man branch,
|
|
-- core.asm:2018-2050 + BagWasSelected:2193-2210 + ItemUseBall's
|
|
-- BATTLE_TYPE_OLD_MAN forks): no input is read at the battle menu --
|
|
-- the cursor hovers FIGHT for 80 frames, hops to ITEM for 50, and the
|
|
-- item menu (one POKé BALL x50) is forced. The item list is scripted
|
|
-- too (DisplayListMenuID's old-man branch, home/list_menu.asm:65-80):
|
|
-- input is never read, the '▶' hovers POKé BALL for 80 frames, then the
|
|
-- auto A-press leaves the hollow '▷' on the row and the ball is used.
|
|
-- The old man NEVER attacks; the throw skips the catch calc entirely
|
|
-- ($43 = always caught) and the Weedle is not kept.
|
|
do
|
|
local pressed = {}
|
|
local stack = { states = {} }
|
|
function stack:push(s) table.insert(self.states, s) end
|
|
function stack:pop() return table.remove(self.states) end
|
|
function stack:top() return self.states[#self.states] end
|
|
local fg = {
|
|
data = Data,
|
|
save = require("src.core.SaveData").newGame(),
|
|
input = { wasPressed = function(_, k) return pressed[k] or false end },
|
|
stack = stack,
|
|
}
|
|
fg.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
|
|
local seen = {}
|
|
local demo = BattleState.newWild(fg, "WEEDLE", 5)
|
|
demo:makeOldManDemo()
|
|
local finished = false
|
|
demo.onFinish = function() finished = true end
|
|
local origStart = demo.startMessage
|
|
demo.startMessage = function(self, item)
|
|
table.insert(seen, item.text)
|
|
origStart(self, item)
|
|
end
|
|
stack:push(demo)
|
|
demo:enter()
|
|
check(demo.playerBackPic == nil or tostring(demo.playerBackPic.path or ""):find("oldman") ~= nil
|
|
or demo.demo, "the demo uses the old man back pic slot") -- headless: pic may be nil
|
|
-- intro text auto-advances on A
|
|
for _ = 1, 300 do
|
|
if demo.phase == "menu" then break end
|
|
pressed.a = true
|
|
demo:update(1 / 60)
|
|
end
|
|
pressed.a = false
|
|
eq(demo.phase, "menu", "the demo reaches the battle menu")
|
|
-- the scripted cursor ignores input and holds the menu for 130 frames
|
|
local frames = 0
|
|
for _ = 1, 200 do
|
|
if stack:top() ~= demo then break end
|
|
pressed.a = true -- must be ignored: the old man script reads no input
|
|
if demo.phase == "menu" then frames = frames + 1 end
|
|
demo:update(1 / 60)
|
|
end
|
|
pressed.a = false
|
|
check(stack:top() ~= demo, "the ITEM menu is forced without input")
|
|
eq(frames, 131, "FIGHT hover (80) + ITEM hover (50) frames before the bag")
|
|
local bag = stack:top()
|
|
eq(bag.items and #bag.items, 1, "the old man's bag lists exactly one item")
|
|
eq(bag.items[1].label, "POKé BALL", "the item is a POKé BALL")
|
|
eq(bag.items[1].right, "x50", "with quantity x50 (OldManItemList)")
|
|
-- the list script (home/list_menu.asm:65-80): input is never read --
|
|
-- B can't back out -- and the '▶' hovers POKé BALL for 80 frames
|
|
local enemyHP = demo.enemy.mon.hp
|
|
pressed.b = true
|
|
local hover = 0
|
|
for _ = 1, 80 do
|
|
if stack:top() ~= bag then break end
|
|
hover = hover + 1
|
|
bag:update(1 / 60)
|
|
end
|
|
pressed.b = false
|
|
check(stack:top() == bag, "the old man's bag reads no input (B can't back out)")
|
|
eq(hover, 80, "the '▶' hovers POKé BALL for 80 frames")
|
|
check(not bag.hollowIndex, "the cursor stays the filled '▶' through the hover")
|
|
-- frame 81: the auto A-press leaves the hollow '▷' on the chosen row
|
|
-- (PlaceUnfilledArrowMenuCursor) while ItemUseBall spins up
|
|
bag:update(1 / 60)
|
|
eq(bag.hollowIndex, 1, "the auto-A leaves the hollow '▷' on the POKé BALL row")
|
|
check(stack:top() == bag, "the hollow '▷' is visible while the list is open")
|
|
-- the throw follows without any input: OLD MAN throws, it ALWAYS
|
|
-- catches, nothing is kept
|
|
for _ = 1, 20 do
|
|
if stack:top() ~= bag then break end
|
|
bag:update(1 / 60)
|
|
end
|
|
check(stack:top() ~= bag, "the ball is thrown without input")
|
|
for _ = 1, 2000 do
|
|
if finished then break end
|
|
pressed.a = true
|
|
demo:update(1 / 60)
|
|
end
|
|
pressed.a = false
|
|
check(finished, "the throw ends the demo battle")
|
|
local function sawText(pat)
|
|
for _, t in ipairs(seen) do if t and t:find(pat, 1, true) then return true end end
|
|
return false
|
|
end
|
|
check(sawText("OLD MAN used\nPOKé BALL!"), "the throw is credited to OLD MAN")
|
|
check(sawText("All right!\nWEEDLE was\ncaught!"), "the ball always catches (_ItemUseBallText05)")
|
|
eq(demo.enemy.mon.hp, enemyHP, "the old man never attacks (Weedle at full HP)")
|
|
eq(#fg.save.party, 1, "the caught Weedle is NOT added to the party")
|
|
check(not (fg.save.pokedex and fg.save.pokedex.owned and fg.save.pokedex.owned.WEEDLE),
|
|
"the caught Weedle is NOT added to the dex")
|
|
end
|
|
|
|
S.finish()
|