deleting stale tests

This commit is contained in:
bryanthaboi
2026-08-06 07:11:16 -04:00
parent cd37aa175c
commit 6f75a64c46
13 changed files with 19 additions and 1931 deletions
+19 -10
View File
@@ -14,6 +14,15 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
-- Scoped suite, not the module-level counters: run_tests.lua dofiles this
-- file in its own process, and T.finish ends in os.exit, which took the
-- parent runner down with it. The run still exited 0, so it read as a pass
-- while every alphabetically later parity file and the three tiers chained
-- after them silently never ran. S.finish raises instead, which is what the
-- rest of the parity files do. modkit does not re-export suite, so it comes
-- off the shared harness it wraps.
local S = T.harness.suite("parity faint cry bug709")
local Data = T.fixtures.fresh()
local Font = require("src.render.Font")
Font.load(Data)
@@ -70,10 +79,10 @@ do
battle.playVictoryMusic = function() end
battle:onFaint(battle.player)
pump(battle, 1)
T.eq(cries[1], "FIXMON_A", "the player mon's faint plays its species cry")
T.eq(#cries, 1, "no other cry on the player faint")
S.eq(cries[1], "FIXMON_A", "the player mon's faint plays its species cry")
S.eq(#cries, 1, "no other cry on the player faint")
for _, name in ipairs(sfx) do
T.check(name ~= "Faint_Fall",
S.check(name ~= "Faint_Fall",
"the player faint never plays Faint_Fall (#709)")
end
end
@@ -87,18 +96,18 @@ do
battle.playVictoryMusic = function() end
battle:onFaint(battle.enemy)
pump(battle, 2)
T.eq(#cries, 0, "the enemy faint plays no species cry")
S.eq(#cries, 0, "the enemy faint plays no species cry")
local fall, thud = false, false
for i, name in ipairs(sfx) do
if name == "Faint_Fall" then
T.check(not fall, "Faint_Fall plays once")
S.check(not fall, "Faint_Fall plays once")
fall = true
T.check(not thud, "Faint_Fall precedes Faint_Thud")
S.check(not thud, "Faint_Fall precedes Faint_Thud")
elseif name == "Faint_Thud" then
thud = true
end
end
T.check(fall and thud, "trainer enemy faint plays Faint_Fall and Faint_Thud")
S.check(fall and thud, "trainer enemy faint plays Faint_Fall and Faint_Thud")
end
-- enemy faint, wild battle: no faint sfx at all (victory music only)
@@ -110,11 +119,11 @@ do
battle.playVictoryMusic = function() end
battle:onFaint(battle.enemy)
pump(battle)
T.eq(#cries, 0, "the wild enemy faint plays no species cry")
S.eq(#cries, 0, "the wild enemy faint plays no species cry")
for _, name in ipairs(sfx) do
T.check(name ~= "Faint_Fall" and name ~= "Faint_Thud",
S.check(name ~= "Faint_Fall" and name ~= "Faint_Thud",
"the wild enemy faint plays no faint sfx (.wild_win)")
end
end
T.finish("parity faint cry bug709")
S.finish()
-91
View File
@@ -1,91 +0,0 @@
-- Parity: the Fly overworld animation (#702).
--
-- Oracle: engine/overworld/player_animations.asm. Departure
-- (_LeaveMapAnim .flyAnimation) flaps the bird in place for 8 x Delay3,
-- plays SFX_FLY, flies FlyAnimationScreenCoords1 up and off to the right
-- (12 pairs, 3 frames each), waits 40 frames, then exits over the
-- top-left along FlyAnimationScreenCoords2 (11 pairs). Arrival
-- (EnterMapAnim .flyAnimation) plays SFX_FLY again and swoops in along
-- FlyAnimationEnterScreenCoords (12 pairs), and only then does
-- LoadPlayerSpriteGraphics bring the player back.
--
-- Self-contained: `luajit tests/parity_fly_anim.lua`; also globbed by
-- tests/run_tests.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.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity fly anim (#702)")
local check, eq = S.check, S.eq
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
-- record SFX without touching the audio backend
local plays = {}
local Sound = require("src.core.Sound")
local realPlay = Sound.play
Sound.play = function(_, key) plays[#plays + 1] = key end
local function popAll() while Game.stack:top() do Game.stack:pop() end end
local function frame()
Input.pressed = {}
StateStack:update(1 / 60)
end
local function frames(n) for _ = 1, n do frame() end end
Game.stack:push(OW, "ROUTE_17", 4, 10, "down")
local ow = Game.stack:top()
ow:flyTo("PALLET_TOWN")
check(ow.flyAnim ~= nil, "the bird lead-in starts on FLY")
eq(ow.flyAnim and ow.flyAnim.phase, "flap", "the bird flaps in place first")
eq(ow.player.inputLocked, true, "input is locked for the flight")
eq(#plays, 0, "no SFX during the in-place flap")
frames(23)
eq(ow.flyAnim and ow.flyAnim.phase, "flap", "still flapping 23 frames in")
frame()
eq(ow.flyAnim and ow.flyAnim.phase, "path1",
"the up-right path starts after 8 x Delay3")
eq(plays[#plays], "Fly", "SFX_FLY plays as the bird takes off")
frames(36)
eq(ow.flyAnim and ow.flyAnim.phase, "hold",
"the bird parks off screen after the 12-pair path")
frames(40)
eq(ow.flyAnim and ow.flyAnim.phase, "path2",
"the top-left exit follows the 40-frame beat")
frames(33)
check(ow.flyAnim == nil, "the departure ends after the 11-pair exit")
-- the warp transition runs its fade out/in; the map switches inside it
local guard = 0
while ow.map.id == "ROUTE_17" and guard < 400 do
guard = guard + 1
frame()
end
eq(ow.map.id, "PALLET_TOWN", "the warp lands in Pallet Town")
check(ow.flyArrive ~= nil, "the landing swoop starts on arrival")
eq(plays[#plays], "Fly", "SFX_FLY plays again for the landing")
eq(ow.player.inputLocked, true, "input stays locked for the swoop")
frames(35)
check(ow.flyArrive ~= nil, "the swoop is still flying 35 frames in")
frame()
check(ow.flyArrive == nil, "the swoop ends after the 12-pair path")
eq(ow.player.inputLocked, false, "and hands input back")
Sound.play = realPlay
S.finish()
-185
View File
@@ -1,185 +0,0 @@
-- Parity test, gift atomicity: a mon handed over by give_pokemon and the
-- event that closes its offer must land in the same script step, so a
-- script torn down between the two cannot hand the gift out twice (#426).
--
-- asm sources:
-- pokeyellow scripts/Route24.asm (Route24CooltrainerM4Text: CheckEvent
-- EVENT_54F -> YesNoChoice -> GivePokemon -> `jp nc, TextScriptEnd`
-- (party + box full leaves the event clear so the offer repeats) ->
-- PrintText Route24Text_515e3 -> SetEvent EVENT_54F)
-- pokeyellow scripts/CeruleanMelaniesHouse.asm (same shape plus predef
-- HideObject TOGGLE_CERULEAN_BULBASAUR, then SetEvent
-- EVENT_GOT_BULBASAUR_IN_CERULEAN)
-- pokeyellow scripts/VermilionCity_2.asm (CheckEvent / SetEvent
-- EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY)
-- scripts/CeladonMansionRoofHouse.asm (Eevee ball: GivePokemon with no
-- confirm, HideObject on success)
-- On hardware the event write trails the received text because no step in
-- between can abort. The port yields there (AskName, NamingScreen, the
-- text box) and wraps every row in the script.command mod hook, so the
-- write is hoisted ahead of the text: the event is only read at script
-- entry, and the failed-give path still leaves it clear.
--
-- Self-contained: run via `luajit tests/parity_gift_atomicity.lua`; also
-- dofile'd by tests/run_tests.lua's aggregator.
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 gift atomicity")
local check, eq = S.check, S.eq
local Commands = require("src.script.Commands")
local Events = require("src.mods.Events")
local Flags = require("src.script.Flags")
local Game = require("src.core.Game")
local Hooks = require("src.mods.Hooks")
local Input = require("src.core.Input")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner")
local StateStack = require("src.core.StateStack")
Game.data = Data
Game.input = Input; Input:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
require("src.render.Font").load(Data)
local gifts = require("data.scripts.yellow_gifts")
local eevee = require("data.scripts.celadon_eevee")
-- === 1) row-order audit: on every gift site the carry guard follows
-- give_pokemon immediately and the bookkeeping (event, and the
-- HideObject that clears a ball or a pen mon) comes before any
-- received text ===
local function audit(label, rows)
local give
for i, row in ipairs(rows) do
if row[1] == "give_pokemon" then give = i break end
end
if not give then
check(false, label .. ": has a give_pokemon row")
return
end
eq(rows[give + 1] and rows[give + 1][1], "jump_if_false",
label .. ": carry guard sits right after give_pokemon")
local flag, text, hide
for i = give + 2, #rows do
local name = rows[i][1]
if name == "set_flag" and not flag then flag = i end
if name == "hide_object" and not hide then hide = i end
if (name == "show_text" or name == "ask") and not text then text = i end
if name == "jump" and rows[i][2] ~= nil and text then break end
end
eq(flag, give + 2, label .. ": event write is the first row past the guard")
check(text and flag < text,
label .. ": event write precedes the received text")
if hide then
check(hide < text, label .. ": HideObject precedes the received text")
end
end
-- the two function-form scripts build their rows per talk; run them with
-- the gift branch's preconditions and keep what they hand the runner
local function capture(fn, save)
local rows
local ow = { runner = { run = function(_, r) rows = r end } }
fn({ save = save }, ow, { def = {}, facePlayer = function() end },
function() end)
return rows or {}
end
audit("Route 24 Damian",
gifts.ROUTE_24.talk.TEXT_ROUTE24_COOLTRAINER_M4)
audit("Melanie's BULBASAUR",
capture(gifts.CERULEAN_MELANIES_HOUSE.talk
.TEXT_CERULEANMELANIESHOUSE_MELANIE,
{ flags = {}, pikachuHappiness = 200 }))
audit("Officer Jenny's SQUIRTLE",
capture(gifts.VERMILION_CITY.talk.TEXT_VERMILIONCITY_OFFICER_JENNY,
{ flags = {}, inventory = { THUNDERBADGE = 1 } }))
audit("Celadon EEVEE ball",
eevee.talk.TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL)
-- === harness: run a row list headless, A-mashing through the yes/no,
-- the nickname prompt and every text box, recording show_text ids
-- (Yellow's gift text is not in a Red cache, so show_text takes
-- its literal-id fallback: the ids are still what we assert on) ===
local shown = {}
local origShow = Commands.show_text
Commands.show_text = function(ctx, textId, subs)
shown[#shown + 1] = textId
return origShow(ctx, textId, subs)
end
local function runRows(rows)
shown = {}
StateStack:init()
local ow = { map = { id = "ROUTE_24", def = { label = "ROUTE_24" } },
npcs = {}, entities = {} }
local r = ScriptRunner.new(Game, ow)
r:run(rows, { npc = { def = {}, facePlayer = function() end },
overworld = ow })
local guard = 0
while r:isRunning() and guard < 3000 do
guard = guard + 1
Input.pressed = { a = true }
StateStack:update(1 / 60)
r:update()
end
Input.pressed = {}
return not r:isRunning()
end
local DAMIAN = gifts.ROUTE_24.talk.TEXT_ROUTE24_COOLTRAINER_M4
-- === 2) plain accept: one CHARMANDER, EVENT_54F set, and the next talk
-- is Damian's after-text only ===
Game.save = SaveData.newGame()
check(runRows(DAMIAN), "Damian gift script completes")
eq(#Game.save.party, 1, "CHARMANDER joins the party")
eq(Game.save.party[1].species, "CHARMANDER", "gift species is CHARMANDER")
check(Flags.get(Game.save, "EVENT_54F"), "accepting sets EVENT_54F")
check(runRows(DAMIAN), "post-gift talk completes")
eq(table.concat(shown, ","), "_Route24DamianText4",
"a closed offer shows only the after-text")
eq(#Game.save.party, 1, "no second CHARMANDER")
-- === 3) the regression itself: every row runs inside the script.command
-- hook, and a mod that mishandles the row after the give (the
-- reporter was running a third-party UI mod) tears the coroutine
-- down mid-gift -- here by sending the pc at a label that is not
-- there. The mon is already in the party, so EVENT_54F has to be
-- set by then or the next talk re-runs the whole offer ===
local savedEvents, savedHooks, savedErrors =
Runtime.events, Runtime.hooks, Runtime.errors
local hooks = Hooks.new()
Runtime.install(Events.new(), hooks, {})
local remove = hooks:wrap("script.command", function(nextFn, _, name, args)
if name == "show_text" and args[1] == "_Route24DamianText2" then
return "no_such_label"
end
return nextFn()
end, 0, "t")
Game.save = SaveData.newGame()
local origError = Logger.error -- the tear-down logs; the test expects it
Logger.error = function() end
runRows(DAMIAN)
Logger.error = origError
eq(#Game.save.party, 1, "the killed script still handed the CHARMANDER over")
check(Flags.get(Game.save, "EVENT_54F"),
"EVENT_54F survives a tear-down after the give")
remove()
Runtime.install(savedEvents, savedHooks, savedErrors)
check(runRows(DAMIAN), "talk after the tear-down completes")
eq(table.concat(shown, ","), "_Route24DamianText4",
"the interrupted gift is not offered again")
eq(#Game.save.party, 1, "still exactly one CHARMANDER")
S.finish()
-188
View File
@@ -1,188 +0,0 @@
-- 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()
-154
View File
@@ -1,154 +0,0 @@
-- Parity test: A/START are never handled mid-step (#286).
-- Self-contained: run via `luajit tests/parity_midstep_buttons.lua`; also
-- dofile'd by tests/run_tests.lua's aggregator.
--
-- Oracle: home/overworld.asm OverworldLoop reads wWalkCounter and, when it
-- is nonzero ("the player sprite has not yet completed the walking
-- animation"), jumps straight to .moveAhead -- JoypadOverworld, and with
-- it the START check, the A check, and every direction initiation, only
-- ever runs while the player stands on a tile.
--
-- The port ran handleInput() every frame regardless of player.moving, so a
-- mid-step A/START press pushed its TextBox/StartMenu right there and
-- froze Red between tiles, mid-animation (#286: running up to Nurse Joy
-- and mashing A stops him half off the tile).
--
-- Second oracle, engine/joypad.asm _Joypad: hJoyPressed is
-- (hJoyLast ^ hJoyInput) & hJoyInput, and hJoyLast only advances on an
-- explicit `call Joypad`. vblank's per-frame ReadJoypad writes hJoyInput
-- alone, and the mid-step path never calls Joypad, so hJoyLast is FROZEN
-- for the whole animation. A button pressed mid-step and still held when
-- the step lands therefore reads as a fresh press at the next poll; one
-- released before the step lands is genuinely lost. The port used to drop
-- both, which on the Cycling Road roll made START a coin flip (#525).
--
-- The invariant: while a step is in progress, A and START change nothing
-- (no TextBox, no StartMenu, the step completes). On the landing frame a
-- still-held A or START is acted on, a released one is not.
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 midstep buttons")
local check, eq = S.check, S.eq
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack
StateStack:init()
-- PALLET_TOWN (6,9) facing down: open grass, several free tiles south
Game.save = SaveData.newGame()
Game.stack:push(OW, "PALLET_TOWN", 6, 9, "down")
local ow = Game.stack:top()
local function step(pressedBtn)
-- the real driver: Game:step promotes pressQueue edges via Input:step()
-- (which also expires them) before stack:update
if pressedBtn then table.insert(Input.pressQueue, pressedBtn) end
Input:step()
ow:update(1 / 60)
end
-- A synthetic pressQueue inject has no source entry, so Input:step sets
-- state[btn] = true and nothing ever clears it (src/core/Input.lua) -- the
-- harness models a HELD button. Most cases below want a tap, so release it
-- explicitly; the held cases are called out where they matter.
local function tap(btn)
step(btn)
Input.state[btn] = false
end
-- start a step south (held direction, like hJoyHeld)
Input.state.down = true
step()
Input.state.down = false
check(ow.player.moving, "held direction starts a step")
local startY = ow.player.cellY
-- spy on interact(): a mid-step A press must not even reach it
local interactCalls = 0
local baseInteract = ow.interact
ow.interact = function(self, ...)
interactCalls = interactCalls + 1
return baseInteract(self, ...)
end
-- mid-step A press: nothing may happen (the original acts on nothing here)
tap("a")
eq(interactCalls, 0, "mid-step A never reaches interact()")
check(Game.stack:top() == ow, "mid-step A pushes no TextBox")
check(ow.player.moving, "mid-step A does not interrupt the step")
-- mid-step START press: no start menu either
tap("start")
check(Game.stack:top() == ow, "mid-step START opens no menu")
check(ow.player.moving, "mid-step START does not interrupt the step")
-- run the step out: the player lands on the next tile, unfrozen
local guard = 0
while ow.player.moving and guard < 60 do step(); guard = guard + 1 end
eq(ow.player.cellY, startY + 1, "the step completes onto the next tile")
-- the issue's actual repro ("press A quickly/early" running up to Nurse
-- Joy): start another step and press A on its FINAL mid-step frame, then
-- RELEASE it before the step lands. hJoyLast is frozen through the
-- animation, so the next poll sees the button already up and computes no
-- edge (engine/joypad.asm) -- this press really is lost.
Input.state.down = true
step()
Input.state.down = false
check(ow.player.moving, "second step starts")
guard = 0
while ow.player.moving and guard < 60 do
guard = guard + 1
if guard == (ow.player.stepFramesCur or 16) - 1 then
tap("a") -- the last frame before landing, released immediately
else
step()
end
end
check(not ow.player.moving, "the second step completes")
step() -- the landing frame, where a still-held button would be polled
eq(interactCalls, 0, "a mid-step A released before landing is still lost")
check(Game.stack:top() == ow, "the released last-frame A pushes no TextBox")
-- ...but a mid-step A that is STILL HELD when the step lands is delivered
-- on the landing frame, because hJoyLast never advanced (#525). Nothing
-- happens mid-step either way: the poll is deferred, not the action.
Input.state.down = true
step()
Input.state.down = false
check(ow.player.moving, "third step starts")
step("a") -- pressed mid-step and left held
eq(interactCalls, 0, "the held A still does nothing mid-step")
check(ow.player.moving, "the held A does not interrupt the step")
guard = 0
while ow.player.moving and guard < 60 do step(); guard = guard + 1 end
eq(interactCalls, 0, "still nothing while the step runs out")
step() -- landing frame
eq(interactCalls, 1, "a held mid-step A is polled on the landing frame")
Input.state.a = false
-- standing on the tile again, START and A work as always
interactCalls = 0
tap("start")
check(Game.stack:top() ~= ow, "START opens the start menu on a tile")
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "PALLET_TOWN", 6, 9, "down")
ow = Game.stack:top()
interactCalls = 0 -- OW is a singleton: the spy survives the re-push
step("a")
eq(interactCalls, 1, "A on a tile runs interact() (the gate is movement-only)")
S.finish()
-129
View File
@@ -1,129 +0,0 @@
-- Parity / regression for #73: Gen 1 fight-menu SELECT reorders moves.
--
-- Select marks a slot, move the cursor, Select (or A) swaps. Defaults:
-- Tab / either Shift / gamepad Back. Self-contained; also picked up by
-- tests/run_tests.lua's parity_* glob.
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.RATTATA) then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local Input = require("src.core.Input")
local S = require("tests.harness").suite("parity move swap")
local check, eq = S.check, S.eq
local function freshGame()
local mon = Pokemon.new(Data, "NIDORAN_M", 8)
mon.moves = {
{ id = "TACKLE", pp = 35 },
{ id = "LEER", pp = 30 },
{ id = "HORN_ATTACK", pp = 25 },
{ id = "POISON_STING", pp = 35 },
}
return {
data = Data,
input = Input,
save = {
party = { mon },
player = { name = "RED" },
inventory = {},
options = {},
pokedex = { seen = {}, owned = {} },
flags = {},
money = 0,
},
stack = { push = function() end, pop = function() end, top = function() end },
}
end
local function tapKey(battle, key)
Input:keypressed(key)
Input:step()
battle:update(0)
Input:keyreleased(key)
end
local function tapPad(battle, button)
Input:gamepadpressed(nil, button)
Input:step()
battle:update(0)
Input:gamepadreleased(nil, button)
end
-- Default Select sources all edge the logical select button.
do
Input:init()
for _, key in ipairs({ "tab", "rshift", "lshift" }) do
Input:reset()
Input:keypressed(key)
Input:step()
check(Input:wasPressed("select"), key .. " maps to select")
end
Input:reset()
Input:gamepadpressed(nil, "back")
Input:step()
check(Input:wasPressed("select"), "gamepad back maps to select")
end
-- Fight menu: Select, move, Select swaps slots 1 and 2.
do
Input:init()
local game = freshGame()
local battle = BattleState.newWild(game, "PIDGEY", 5)
battle.phase = "moveSelect"
battle.moveIndex = 1
battle.moveSwapIndex = nil
local a = battle.player.curMoves[1].id
local b = battle.player.curMoves[2].id
tapKey(battle, "tab")
eq(battle.moveSwapIndex, 1, "first Select marks the current slot")
tapKey(battle, "down")
eq(battle.moveIndex, 2, "cursor moved to slot 2")
tapKey(battle, "tab")
check(battle.moveSwapIndex == nil, "second Select clears the mark")
eq(battle.player.curMoves[1].id, b, "slot 1 holds the former slot 2 move")
eq(battle.player.curMoves[2].id, a, "slot 2 holds the former slot 1 move")
eq(battle.player.mon.moves[1].id, b, "party moves table stays in sync")
end
-- Same reorder via gamepad Back (SDL "back" = controller Select/View).
do
Input:init()
local game = freshGame()
local battle = BattleState.newWild(game, "PIDGEY", 5)
battle.phase = "moveSelect"
battle.moveIndex = 1
battle.moveSwapIndex = nil
local a = battle.player.curMoves[1].id
local b = battle.player.curMoves[2].id
tapPad(battle, "back")
tapPad(battle, "dpdown")
tapPad(battle, "back")
eq(battle.player.curMoves[1].id, b, "pad Select swaps slot 1")
eq(battle.player.curMoves[2].id, a, "pad Select swaps slot 2")
end
-- A confirms a pending swap (bag-style), without starting the turn.
do
Input:init()
local game = freshGame()
local battle = BattleState.newWild(game, "PIDGEY", 5)
battle.phase = "moveSelect"
battle.moveIndex = 1
battle.moveSwapIndex = nil
local a = battle.player.curMoves[1].id
local b = battle.player.curMoves[2].id
tapKey(battle, "tab")
tapKey(battle, "down")
tapKey(battle, "z") -- A
eq(battle.phase, "moveSelect", "A completes a pending swap without attacking")
eq(battle.player.curMoves[1].id, b, "A-confirm swapped slot 1")
eq(battle.player.curMoves[2].id, a, "A-confirm swapped slot 2")
end
S.finish()
-206
View File
@@ -1,206 +0,0 @@
-- Parity test: the SHIFT free switch hands the WHOLE exp share to the mon
-- coming in (#275). EnemySendOutFirstMon zeroes wPartyGainExpFlags and
-- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon, which sets
-- only the incoming mon's bit (engine/battle/core.asm:1436-1443, 2424-2433);
-- GiveExperiencePoints divides by the set bits (experience.asm:295-300), so a
-- leftover flag halves the payout. The reset was never ported.
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.RATTATA) then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local Experience = require("src.battle.Experience")
local Screens = require("src.ui.Screens")
local S = require("tests.harness").suite("parity shift exp share")
local check, eq = S.check, S.eq
-- Minimal game stub: what BattleState.newTrainer / enemyMonFainted touch.
-- battleStyle is per-scenario, so the caller sets it.
local function freshGame(style)
return {
data = Data,
save = {
party = {
Pokemon.new(Data, "BULBASAUR", 50),
Pokemon.new(Data, "SQUIRTLE", 40),
},
player = { name = "RED" },
inventory = {},
options = { battleStyle = style },
pokedex = { seen = {}, owned = {} },
flags = {},
money = 0,
},
stack = { push = function() end, pop = function() end, top = function() end },
}
end
-- Drain the queue, running act rows and answering the SHIFT prompt. `yes`
-- picks YES (the free switch) or NO; `pick` is the party mon the battle
-- PartyMenu would hand back. Text rows are collected in order so the exp
-- line can be read the way the player reads it.
local function pump(b, yes, pick, seen)
local origPush = Screens.push
Screens.push = function(_, id, opts)
if id == "PartyMenu" and opts and opts.onSwitch and pick then
opts.onSwitch(pick)
end
end
local ok, err = pcall(function()
local n = 0
while #b.queue > 0 and n < 500 do
n = n + 1
local item = table.remove(b.queue, 1)
if item.fn then
b.nextInsert = 0
item.fn()
elseif item.text then
seen[#seen + 1] = item.text
if item.choice and item.text:find("change POKéMON", 1, true) then
item.choice(yes)
end
end
end
end)
Screens.push = origPush
return ok, err
end
-- the number _ExpPointsText prints (wExpAmountGained), out of the port's
-- "%s gained\n%d EXP. Points!" row
local function expLine(seen)
for _, t in ipairs(seen) do
local n = t:match("gained\n(%d+) EXP%. Points!")
if n then return tonumber(n), t end
end
return nil
end
-- OPP_YOUNGSTER 1 is RATTATA 11 / EKANS 11 in both versions: two slots, so
-- there is a second mon to KO after the switch.
local YOUNGSTER, ROSTER = "OPP_YOUNGSTER", 1
-- Set up the fight at the moment the first enemy mon drops, with the lead the
-- only participant (as markParticipant left it), so the caller only has to pump.
local function atFirstKO(style)
local Game = freshGame(style)
local b = BattleState.newTrainer(Game, YOUNGSTER, ROSTER)
b.enemyParty[1].hp = 0
b.enemyIndex = 1
b.enemy.mon = b.enemyParty[1]
b.participants = { [Game.save.party[1]] = true }
b:enemyMonFainted()
return Game, b
end
-- KO whatever is out now and read back the exp line for it.
local function koAndRead(b)
local before = {}
for i, mon in ipairs(b.game.save.party) do before[i] = mon.exp end
local seen = {}
b.enemy.mon.hp = 0
-- updateQueue zeroes this before every act row it runs; calling
-- enemyMonFainted straight from the test has to do the same, or the *Next
-- inserters index past the end of the drained queue and leave a hole
b.nextInsert = 0
b:enemyMonFainted()
local ok, err = pump(b, false, nil, seen)
local delta = {}
for i, mon in ipairs(b.game.save.party) do delta[i] = mon.exp - before[i] end
return ok, err, seen, delta
end
do
local Game, b = atFirstKO("shift")
eq(#b.enemyParty, 2, "OPP_YOUNGSTER roster " .. ROSTER .. " has two mons")
local lead, reserve = Game.save.party[1], Game.save.party[2]
-- KO one: the SHIFT prompt, answered YES with the reserve picked.
local seen = {}
local ok, err = pump(b, true, reserve, seen)
check(ok, "the SHIFT switch pumped without error: " .. tostring(err))
check(b.player.mon == reserve, "the free switch put the reserve on the field")
check(b.enemy.mon.hp > 0, "the foe's second mon is out")
-- The participant set is the mechanism; the exp number below is the symptom.
check(b.participants[reserve] == true, "the switch-in is a participant")
check(b.participants[lead] == nil,
"the mon that was out when the foe fainted is no longer one (#275)")
-- KO two: the reserve fights alone, so it must be paid as a single
-- participant.
local ok2, err2, seen2, delta = koAndRead(b)
check(ok2, "the second KO pumped without error: " .. tostring(err2))
local foeDef = Data.pokemon[b.enemyParty[2].species]
local solo = Experience.gainFor(foeDef, b.enemyParty[2].level, true, 1, nil,
Data.constants)
local halved = Experience.gainFor(foeDef, b.enemyParty[2].level, true, 2, nil,
Data.constants)
check(solo > halved,
"the two divisors are distinguishable for this foe (" ..
solo .. " vs " .. halved .. ")")
local shown, line = expLine(seen2)
check(shown ~= nil, "the KO printed an EXP. Points! line")
eq(shown, solo, "the switch-in is paid a whole share, not a split one (#275)")
check(shown ~= halved,
"the printed number is not the two-way split (" .. tostring(line) .. ")")
eq(delta[2], solo, "the reserve's exp rose by exactly that share")
eq(delta[1], 0, "the mon left behind is paid nothing for a KO it missed")
local lines = 0
for _, t in ipairs(seen2) do
if t:find("EXP%. Points!") then lines = lines + 1 end
end
eq(lines, 1, "exactly one mon is announced as gaining exp")
end
-- Control: SET style has no free switch, so the lead fights both mons and is
-- paid a whole share for each. Pin it here: the SHIFT switch-in above must
-- earn the same number.
do
local Game, b = atFirstKO("set")
local seen = {}
local ok = pump(b, false, nil, seen)
check(ok, "SET style pumped without error")
check(b.player.mon == Game.save.party[1], "SET style never offered a switch")
local ok2, _, seen2, delta = koAndRead(b)
check(ok2, "the SET second KO pumped without error")
local foeDef = Data.pokemon[b.enemyParty[2].species]
local solo = Experience.gainFor(foeDef, b.enemyParty[2].level, true, 1, nil,
Data.constants)
local shown = expLine(seen2)
eq(shown, solo, "SET style pays the lead a whole share")
eq(delta[1], solo, "and the lead's exp rises by it")
end
-- The path the reset must NOT touch: the party-menu SwitchPlayerMon
-- (core.asm:2424-2433, from PartyMenuOrRockOrRun) sets the incoming mon's bit
-- without zeroing the flag bytes, which is the exp-share trick every player
-- uses: send a weak mon in, switch it straight out, it still splits the KO.
do
local Game = freshGame("shift")
local b = BattleState.newTrainer(Game, YOUNGSTER, ROSTER)
local lead, reserve = Game.save.party[1], Game.save.party[2]
b.participants = { [lead] = true }
b:resolveSwitch(reserve)
local n = 0
while #b.queue > 0 and n < 200 do
n = n + 1
local item = table.remove(b.queue, 1)
if item.fn then b.nextInsert = 0; item.fn() end
end
check(b.player.mon == reserve, "the voluntary switch went through")
check(b.participants[reserve] == true, "the mon coming in participates")
check(b.participants[lead] == true,
"a VOLUNTARY switch keeps the outgoing mon flagged (the exp share)")
end
S.finish()
-117
View File
@@ -1,117 +0,0 @@
-- Parity: Oak's lab starter-ball Pokédex preview (#110).
-- pret StarterDex (engine/events/starter_dex.asm) temporarily sets the
-- owned bits so ShowPokedexData prints the full entry before the player
-- has caught anything. Also: English R/B prints only the kind string
-- (no " POKéMON" suffix -- that clipped "LIZARD" to "LIZARD POKé").
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 starter dex")
local check, eq = S.check, S.eq
local Font = require("src.render.Font")
Font.load(Data)
local DexEntryMenu = require("src.ui.DexEntryMenu")
local SaveData = require("src.core.SaveData")
local mapScripts = require("data.scripts.init")
local function fakeGame()
return {
data = Data,
save = SaveData.newGame(),
input = { wasPressed = function() return false end },
stack = { pop = function() end },
}
end
local function drawCapture(menu)
local drawn = {}
local saved = Font.draw
Font.draw = function(text, x, y)
drawn[#drawn + 1] = { text = tostring(text), x = x, y = y }
return Font.width(text)
end
menu:draw()
Font.draw = saved
return drawn
end
local function findText(drawn, needle)
for _, d in ipairs(drawn) do
if d.text == needle or d.text:find(needle, 1, true) then return d end
end
return nil
end
-- === 1) unowned entry without forceOwned stays "Data unknown." ===
do
local game = fakeGame()
game.save.pokedex = { seen = {}, owned = {} }
local menu = DexEntryMenu.new(game, "CHARMANDER")
local drawn = drawCapture(menu)
check(findText(drawn, "Data unknown."),
"unowned Charmander shows Data unknown without forceOwned")
check(not findText(drawn, "Obviously prefers"),
"unowned Charmander hides description without forceOwned")
check(not findText(drawn, "HT "),
"unowned Charmander hides height without forceOwned")
end
-- === 2) forceOwned shows full entry without mutating save ===
do
local game = fakeGame()
game.save.pokedex = { seen = {}, owned = {} }
local menu = DexEntryMenu.new(game, { species = "CHARMANDER", forceOwned = true })
check(menu.forceOwned, "forceOwned flag sticks on the menu")
local drawn = drawCapture(menu)
check(findText(drawn, "Obviously prefers"),
"forceOwned Charmander shows dex description")
check(findText(drawn, "HT "),
"forceOwned Charmander shows height")
check(not findText(drawn, "Data unknown."),
"forceOwned Charmander does not show Data unknown")
check(not game.save.pokedex.owned.CHARMANDER,
"forceOwned preview does not mark Charmander owned")
end
-- === 3) kind is the bare English string (no POKéMON suffix) ===
do
local game = fakeGame()
game.save.pokedex = { seen = {}, owned = { CHARMANDER = true } }
local menu = DexEntryMenu.new(game, "CHARMANDER")
local drawn = drawCapture(menu)
local kind = findText(drawn, "LIZARD")
check(kind and kind.text == "LIZARD",
"kind draws as LIZARD only (English R/B PlaceString)")
check(not findText(drawn, "POKéMON"),
"kind line does not append POKéMON")
check(kind.x + Font.width(kind.text) <= 160,
"LIZARD kind fits on-screen (no clip)")
end
-- === 4) Oak's lab starter scripts request forceOwned ===
do
local balls = {
"TEXT_OAKSLAB_CHARMANDER_POKE_BALL",
"TEXT_OAKSLAB_SQUIRTLE_POKE_BALL",
"TEXT_OAKSLAB_BULBASAUR_POKE_BALL",
}
for _, textId in ipairs(balls) do
local script = mapScripts.talkScript("OAKS_LAB", textId)
local found
for _, row in ipairs(script) do
if row[1] == "push_screen" and row[2] == "DexEntryMenu" then
found = row[3]
break
end
end
check(type(found) == "table" and found.forceOwned == true
and type(found.species) == "string",
textId .. " pushes DexEntryMenu with forceOwned")
end
end
S.finish()
-187
View File
@@ -1,187 +0,0 @@
-- Parity test: getting on SURF ends the bike (#846).
--
-- pokered keeps walking / biking / surfing in ONE state byte,
-- wWalkBikeSurfState. ItemUseSurfboard (engine/items/item_effects.asm)
-- copies the old state aside, refuses when it is already 2, and on a
-- successful mount does `ld a, 2 / ld [wWalkBikeSurfState], a ; change
-- player state to surfing` followed by PlayDefaultMusic -- the bike state
-- is overwritten, so no bike can survive a surf: not its 8-frame step
-- cadence, not its theme. The port splits that byte into two independent
-- flags (Game.save.onBike and player.surfing) and nothing used to clear
-- the first when the second went up, so a player who surfed off the bike
-- paddled at bike speed with Music_BikeRiding still playing.
--
-- The mirror direction is explicit in the same asm file: ItemUseBicycle
-- opens `ld a, [wWalkBikeSurfState] / cp 2 ; is the player surfing? /
-- jp z, ItemUseNotTime`, so the bag cannot re-raise the bike on water.
--
-- Self-contained; run via `luajit tests/parity_surf_clears_bike_bug846.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.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity surf clears bike")
local check, eq = S.check, S.eq
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local ItemEffects = require("src.inventory.ItemEffects")
local Music = require("src.core.Music")
local Pokemon = require("src.pokemon.Pokemon")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
Game.overworld = OW
-- tests/parity_bills_pc.lua swaps the OverworldController chunk's TextBox
-- upvalue for a stub and never puts it back, and run_tests.lua runs every
-- parity suite from one file: point it back at the real module so trySurf
-- pushes a real box here (same guard as parity_field_move_layering.lua).
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
setUpvalue(OW.trySurf, "TextBox", require("src.render.TextBox"))
local function frame(btns)
Input.pressed = {}
for _, b in ipairs(btns or {}) do Input.pressed[b] = true; Input.state[b] = true end
StateStack:update(1 / 60)
for _, b in ipairs(btns or {}) do Input.state[b] = false end
end
local function popAll() while Game.stack:top() do Game.stack:pop() end end
local function pushOW(mapId, x, y, facing)
popAll()
Game.stack:push(OW, mapId, x, y, facing)
return Game.stack:top()
end
local function mkMon(species, ...)
local m = Pokemon.new(Data, species, 20)
m.moves = {}
for _, id in ipairs({ ... }) do m.moves[#m.moves + 1] = { id = id, pp = 15 } end
return m
end
-- Music.play no-ops on the headless audio stub, so the song the bike/surf
-- override actually resolves to is only observable by intercepting it
-- (the Music.playMap stub pattern in parity_seam_walk_anim.lua)
local playedSong
local realPlay = Music.play
Music.play = function(_, song) playedSong = song end
local bikeSong = Music.special(Data, "bike")
local surfSong = Music.special(Data, "surf")
check(bikeSong ~= nil and surfSong ~= nil and bikeSong ~= surfSong,
"the bike and surf themes are two distinct songs")
-- =====================================================================
-- control: on land, onBike really does buy the halved step cadence, so
-- the assertion after the mount below is not vacuous
-- =====================================================================
local ow = pushOW("PALLET_TOWN", 5, 6, "up")
local p = ow.player
eq(p.stepFrames, 16, "a walking step is 16 frames")
eq(p.bikeStepFrames, 8, "the bicycle doubles walking speed (8 frames)")
Game.save.onBike = true
p.turnTimer = 0
p.stepFramesCur = nil
eq(p:tryMove("up", ow.map, ow.entities), "moved", "riding north out of the spawn cell")
eq(p.stepFramesCur, p.bikeStepFrames, "onBike hands out the bike cadence on land")
-- =====================================================================
-- the mount: bike state is gone the moment the got-on text closes, and
-- the first step onto the water is a WALK-length step, not a bike one
-- =====================================================================
ow = pushOW("PALLET_TOWN", 4, 13, "down")
p = ow.player
p.surfing = false
Game.save.onBike = true
Game.save.party = { mkMon("SQUIRTLE", "SURF") }
-- HM03's badge gate (FieldDefaults hmBadges): without it partyKnows("SURF")
-- refuses and trySurf never prints anything
Game.save.inventory.SOULBADGE = true
check(ow:partyKnows("SURF") ~= nil, "the party can use SURF here")
check(ow.map:isWaterCell(4, 14), "Pallet's south shore faces water at (4,14)")
-- what is playing when the mount starts: the bike override over the
-- outdoor Pallet theme
Music.playMap(Data, "PALLET_TOWN", true, false)
eq(playedSong, bikeSong, "the bike theme plays while riding through Pallet")
p.stepFramesCur = nil
ow:trySurf(4, 14, nil)
local box = Game.stack:top()
check(box ~= nil and box.pages ~= nil, "SURF prints _SurfingGotOnText")
local guard = 0
while Game.stack:top() == box and guard < 400 do
guard = guard + 1
frame({ "a" })
end
check(Game.stack:top() ~= box, "the got-on text closes")
eq(p.surfing, true, "the mount raises the surf state")
eq(Game.save.onBike, false,
"ItemUseSurfboard writes surfing OVER the bike state, so the bike ends (#846)")
eq(playedSong, surfSong,
"PlayDefaultMusic after the mount picks the surf theme, not the bike theme")
-- the blink carries the mount forward onto the water; let that scripted
-- step land (it is queued through scriptMove, which drives the entity
-- directly and never touches Player:tryMove)
guard = 0
while (Game.stack:top() ~= ow or p.moving or #ow.scriptMoves > 0) and guard < 240 do
guard = guard + 1
frame({})
end
eq(Game.stack:top(), ow, "the mount ends back on the map")
eq(p.cellY, 14, "the mount steps forward onto the water")
-- the symptom in #846: the first paddled step the player takes. It runs
-- through Player:tryMove, which reads Game.save.onBike for its step
-- length -- a stale bike flag paddles at 8 frames per cell.
check(ow.map:isWaterCell(4, 15), "the next cell south is water too")
p.turnTimer = 0
p.stepFramesCur = nil
eq(p:tryMove("down", ow.map, ow.entities), "moved", "paddling south from (4,14)")
eq(p.stepFramesCur, p.stepFrames,
"the paddled step uses the walk cadence, the exact symptom in #846")
check(p.stepFramesCur ~= p.bikeStepFrames, "no bike cadence survives onto the water")
-- =====================================================================
-- the mirror hole: the bag cannot put the bike back on under a surfer
-- (ItemUseBicycle's `cp 2` -> jp z, ItemUseNotTime), or the bug returns
-- by another route
-- =====================================================================
local save = SaveData.newGame()
local surfingOw = { player = { surfing = true } }
local result, msgs = ItemEffects.use(Data, save, "BICYCLE", nil, false, nil, surfingOw)
eq(result, "failed", "the BICYCLE is refused while surfing")
check(result ~= "bicycle", "a surfing BICYCLE never reaches the mount path")
check(msgs and msgs[1] and msgs[1]:find("isn't the", 1, true) ~= nil,
"the surfing BICYCLE refusal uses the OAK 'not the time' text")
local landOw = { player = { surfing = false } }
eq((ItemEffects.use(Data, save, "BICYCLE", nil, false, nil, landOw)), "bicycle",
"the BICYCLE still mounts normally on land")
Music.play = realPlay
popAll()
S.finish()
-49
View File
@@ -1,49 +0,0 @@
-- Parity: a player send-out zeroes both battle cursors (#737). SendOutMon
-- (engine/battle/core.asm:1733-1735) clears wBattleAndStartSavedMenuItem and,
-- with the same hli/hl pair, wPlayerMoveListIndex behind it (wram.asm:242-244),
-- so the menu reopens on FIGHT and the move list on the first slot.
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 then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("parity switch cursor reset")
local eq = S.eq
local pressed = {}
local save = SaveData.newGame()
save.party = {
Pokemon.new(Data, "BULBASAUR", 10),
Pokemon.new(Data, "PIDGEY", 10),
}
local game = {
data = Data,
save = save,
input = {
wasPressed = function(_, key) return pressed[key] == true end,
isDown = function(_, key) return pressed[key] == true end,
},
stack = { push = function() end, pop = function() end, top = function() end },
}
local battle = BattleState.newWild(game, "RATTATA", 3)
battle.phase = "menu"
battle.menuIndex = 4
battle.moveIndex = 3
battle:resolveSwitch(save.party[2])
for i = 1, 4000 do
if battle.phase == "menu" then break end
pressed.a = (i % 4 == 0)
battle:update(1 / 60)
pressed.a = nil
end
eq(battle.moveIndex, 1, "the move cursor is back on the first slot")
eq(battle.menuIndex, 1, "the battle menu is back on FIGHT")
S.finish()
-198
View File
@@ -1,198 +0,0 @@
-- Parity: the beaten trainer's own loss line prints ON the battle screen,
-- between the pic scrolling back in and the prize money (#282).
-- TrainerBattleVictory (engine/battle/core.asm:915-949) runs TrainerDefeatedText,
-- ScrollTrainerPicAfterBattle, PrintEndBattleText, then MoneyForWinningText.
-- The scroll (scroll_draw_trainer_pic.asm:1-31) rewrites tilemap columns only,
-- so the pokeball row ClearSprites emptied does not come back with the pic.
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 trainer victory text")
local check, eq = S.check, S.eq
local Data = require("src.core.Data")
if not Data.maps then Data:load() end
local Font = require("src.render.Font")
Font.load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local Sound = require("src.core.Sound")
local Music = require("src.core.Music")
Sound.playCry = function() end
Sound.play = function() end
Sound.playMove = function() end
Sound.playMoveCry = function() end
Sound.stopLoop = function() end
Music.playBattle = function() end
Music.play = function() end
local press = {}
local function makeGame(party)
local save = SaveData.newGame()
save.party = party
local stack = { states = {} }
function stack:push(state) self.states[#self.states + 1] = state end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
-- isDown as well as wasPressed: battle text collapses PrintLetterDelay
-- while A or B is held, and the typing path reads it every frame
return { data = Data, save = save, stack = stack,
input = { wasPressed = function(_, b) return press[b] == true end,
isDown = function(_, b) return press[b] == true end } }
end
-- A held: updateQueue only reads the button once a page is typed out, so an
-- early press is ignored and the queue drains at a player's pace.
local function step(battle)
press.a = true
battle:update(1 / 60)
press.a = false
end
-- Fight a YOUNGSTER, wipe its party, and record every message row in the order
-- it reached the screen plus what the foe's pic slot was doing at the time.
local function fightAndWin(endBattleText)
local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 60) })
local battle = BattleState.newTrainer(game, "OPP_YOUNGSTER", 1)
battle.endBattleText = endBattleText
local result, resultAt
battle.onFinish = function(r) result = r end
battle:enter()
for _ = 1, 500 do
step(battle)
if battle.phase == "menu" then break end
end
-- the KO itself, through the real faint path (onFaint queues the slide,
-- the faint text and the enemyMonFainted act)
for _, mon in ipairs(battle.enemyParty) do mon.hp = 0 end
battle.enemy.mon.hp = 0
battle.phase = "messages"
battle.nextInsert = 0
battle:onFaint(battle.enemy)
local pages, foeOffAt, foeShownAt = {}, {}, {}
local ballRowsSeen, frame, foeMax, foeSteps = 0, 0, 0, 0
local realRow = battle.drawBallRow
battle.drawBallRow = function() ballRowsSeen = ballRowsSeen + 1 end
local lastOff = battle:picOffset("foe")
for f = 1, 2000 do
frame = f
step(battle)
local cur = battle.current
local text = cur and cur.text
if text and pages[#pages] ~= text then
pages[#pages + 1] = text
foeOffAt[text] = battle:picOffset("foe")
foeShownAt[text] = battle.showEnemyTrainer and true or false
end
local off = battle:picOffset("foe")
if off > foeMax then foeMax = off end
-- count only the inward frames; the jump from 0 to 64 is the program
-- being armed off-screen, not a step of the scroll
if off < lastOff then foeSteps = foeSteps + 1 end
lastOff = off
-- drawHUDs is the only place a ball row can come from; sample it while
-- the beaten trainer is back on screen
if battle.showEnemyTrainer then pcall(battle.drawHUDs, battle, 0) end
if result then resultAt = f break end
end
battle.drawBallRow = realRow
return {
battle = battle, pages = pages, result = result, resultAt = resultAt,
foeOffAt = foeOffAt, foeShownAt = foeShownAt, ballRowsSeen = ballRowsSeen,
frames = frame, foeMax = foeMax, foeSteps = foeSteps,
}
end
local function indexOf(pages, fragment)
for i, p in ipairs(pages) do
if p:find(fragment, 1, true) then return i end
end
return nil
end
-- ------------------------------------------------- the full victory order
local LOSS = "What a total\nwaste of time!"
local run = fightAndWin(LOSS)
eq(run.result, "win", "the battle resolves as a win")
local defeated = indexOf(run.pages, "defeated")
local loss = indexOf(run.pages, "waste of time")
local money = indexOf(run.pages, "for winning")
check(defeated ~= nil, "TrainerDefeatedText prints (\"RED defeated YOUNGSTER!\")")
check(loss ~= nil,
"the trainer's own EndBattleText prints INSIDE the battle (#282)")
check(money ~= nil, "MoneyForWinningText prints")
check(defeated and loss and defeated < loss,
"the defeat line comes before the trainer's loss line")
check(loss and money and loss < money,
"PrintEndBattleText comes before MoneyForWinningText (core.asm:942-949)")
check(money == #run.pages,
"the prize money is the LAST thing on the battle screen")
-- the pic is back, at rest, with no ball row beside it
if loss then
local text = run.pages[loss]
eq(run.foeShownAt[text], true,
"the beaten trainer's pic is on screen for his loss line")
eq(run.foeOffAt[text], 16,
"the pic has come to rest two tiles right of the battle slot "
.. "(_ScrollTrainerPicAfterBattle ends at hlcoord 14,0)")
end
eq(run.ballRowsSeen, 0,
"no pokeball row comes back with the pic (ClearSprites emptied that OAM; "
.. "_ScrollTrainerPicAfterBattle only rewrites tilemap columns)")
eq(run.battle.introBalls, nil, "the DrawAllPokeballs window stays closed")
-- The pic is on screen well before the LAST page: the plain act() this used to
-- ride appended to the end of the queue, so the trainer flashed up one row
-- before finish() popped the battle.
if money then
eq(run.foeShownAt[run.pages[money]], true,
"the trainer's pic is already back for the money line, not flashed up "
.. "one row before the battle pops (#282)")
end
-- and it really scrolls rather than popping into place: 64px off the right
-- edge, then 2px a frame down to the resting 16
eq(run.foeMax, 64, "the scroll-in starts 8 tiles off the right edge")
eq(run.foeSteps, 24,
"it takes 24 frames to walk in (six 4-frame columns, "
.. "scroll_draw_trainer_pic.asm:1-31)")
-- ------------------------------------------------------ ordering vs onFinish
-- finish() pops the battle, so anything the overworld pushes afterwards is a
-- second screen cut. Every trainer-victory row must be consumed before it.
check(run.resultAt ~= nil and run.resultAt >= run.frames,
"onFinish fires only once the whole sequence has drained")
-- ------------------------------------------------------------- \f pages
-- Five EndBattleTexts carry a `para` (e.g. _Route9Youngster1EndBattleText).
-- BattleState:startMessage only splits \n and \v, so an unsplit \f would
-- render as a garbage glyph instead of starting a new page.
local para = fightAndWin("Oh well.\fI give up!")
check(indexOf(para.pages, "Oh well.") ~= nil,
"a \\f EndBattleText prints its first page")
check(indexOf(para.pages, "I give up!") ~= nil,
"a \\f EndBattleText prints its second page")
local p1, p2 = indexOf(para.pages, "Oh well."), indexOf(para.pages, "I give up!")
check(p1 and p2 and p2 == p1 + 1, "the two pages are consecutive rows")
for _, page in ipairs(para.pages) do
check(page:find("\f", 1, true) == nil,
"no page still carries a raw \\f: " .. (page:gsub("\n", " / ")))
end
-- --------------------------------------------------- scripted battles
-- Commands.start_battle never sets endBattleText; those scripts print their
-- own follow-up, so the sequence must simply skip the row.
local none = fightAndWin(nil)
eq(none.result, "win", "a battle with no EndBattleText still resolves")
local d2, m2 = indexOf(none.pages, "defeated"), indexOf(none.pages, "for winning")
check(d2 and m2 and d2 < m2,
"defeat text then money, with nothing between them")
eq(m2, #none.pages, "the prize money is still last")
S.finish()
-127
View File
@@ -1,127 +0,0 @@
-- Regression (#535): after handing over the GOLD TEETH and receiving
-- HM04, every later talk to the Warden must still say something.
--
-- data/scripts/story.lua's TEXT_WARDENSHOUSE_WARDEN pointed the
-- EVENT_GOT_HM04 branch (row 3, jump_if_true) at the same silent-end jump
-- the give-then-thank fallthrough uses (row 13), so ScriptRunner's pc ran
-- straight past the end of the row list with zero show_text calls -- the
-- Warden went mute on every visit after the trade. pokered's .got_item
-- branch (scripts/WardensHouse.asm) instead prints .HM04ExplanationText
-- (text/WardensHouse.asm: "HM04 teaches STRENGTH ... SECRET HOUSE in
-- SAFARI ZONE") on every subsequent talk.
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 wardens house (#535)")
local check, eq = S.check, S.eq
local Commands = require("src.script.Commands")
local Flags = require("src.script.Flags")
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner")
local StateStack = require("src.core.StateStack")
Game.data = Data
Game.input = Input; Input:init()
Game.stack = StateStack; StateStack:init()
require("src.render.Font").load(Data)
local story = require("data.scripts.story")
local script = story.WARDENS_HOUSE.talk.TEXT_WARDENSHOUSE_WARDEN
-- instrument show_text the way parity_gift_atomicity.lua does, to record
-- exactly which text ids actually printed
local shown = {}
-- forward EVERY argument: the 4th is extraOpts, which is how Commands.ask
-- hands down its `choice` callback. A wrapper that stops at `subs` silently
-- turns every ask in the script back into a plain show_text -- no YES/NO box,
-- and ctx.lastCheck left holding whatever the previous check_* put there.
local origShow = Commands.show_text
Commands.show_text = function(ctx, textId, ...)
shown[#shown + 1] = textId
return origShow(ctx, textId, ...)
end
-- `button` drives the whole conversation: both A and B page a text box, and
-- on the YES/NO box A takes the cursor's default (YES) while B snaps to NO
-- and answers false (ChoiceBox:update, .choseSecondMenuItem). So holding A
-- runs the yes branch and holding B runs the no branch, with no reaching
-- into the choice box from the test.
local function runScript(button)
shown = {}
StateStack:init()
local ow = { map = { id = "WARDENS_HOUSE", def = { label = "WardensHouse" } },
npcs = {}, entities = {} }
local r = ScriptRunner.new(Game, ow)
r:run(script, { npc = { def = {}, facePlayer = function() end },
overworld = ow })
local guard = 0
while r:isRunning() and guard < 3000 do
guard = guard + 1
Input.pressed = { [button or "a"] = true }
StateStack:update(1 / 60)
r:update()
end
Input.pressed = {}
return not r:isRunning()
end
-- === 1) first talk, holding the GOLD TEETH: gives HM04, sets the flag ===
Game.save = SaveData.newGame()
Game.save.inventory.GOLD_TEETH = 1
check(runScript(), "give-the-teeth talk completes")
eq(table.concat(shown, ","),
"_WardensHouseWardenGaveTheGoldTeethText,_WardensHouseWardenThanksText,"
.. "_WardensHouseWardenReceivedHM04Text",
"handing over the teeth shows the give/thanks/received sequence, nothing after")
check(Flags.get(Game.save, "EVENT_GOT_HM04"), "EVENT_GOT_HM04 is set")
check(Flags.get(Game.save, "EVENT_GAVE_GOLD_TEETH"), "EVENT_GAVE_GOLD_TEETH is set")
check(Game.save.inventory.HM_STRENGTH ~= nil, "HM04 (Strength) lands in the bag")
check(not Game.save.inventory.GOLD_TEETH, "the GOLD TEETH is taken")
-- === 2) the regression itself: every later talk, once EVENT_GOT_HM04 is
-- set, must print the explanation text instead of nothing ===
check(runScript(), "post-gift talk completes")
eq(table.concat(shown, ","), "_WardensHouseWardenHM04ExplanationText",
"every subsequent talk now prints the HM04/Safari Zone explanation (#535)")
-- run it again to confirm this is not a one-shot: it repeats every visit
check(runScript(), "a third talk completes")
eq(table.concat(shown, ","), "_WardensHouseWardenHM04ExplanationText",
"the explanation text repeats on every later talk, not just the first")
-- === 3) no GOLD TEETH yet: the gibberish question, then a YES/NO, then the
-- warden's answer -- Gibberish2 on yes, Gibberish3 on no (#645).
-- The port used to stop dead after the question. ===
Game.save = SaveData.newGame()
check(runScript("a"), "empty-handed talk completes on yes")
eq(table.concat(shown, ","),
"_WardensHouseWardenGibberish1Text,_WardensHouseWardenGibberish2Text",
"answering YES gets the warden's reply, not silence (#645)")
check(not Flags.get(Game.save, "EVENT_GOT_HM04"), "no HM04 yet")
Game.save = SaveData.newGame()
check(runScript("b"), "empty-handed talk completes on no")
eq(table.concat(shown, ","),
"_WardensHouseWardenGibberish1Text,_WardensHouseWardenGibberish3Text",
"and answering NO gets the other reply (#645)")
-- the question is asked, not just printed: `ask` is what puts the YES/NO box
-- up, so a future edit that downgrades it back to show_text fails here
local askRow
for _, row in ipairs(script) do
if row[2] == "_WardensHouseWardenGibberish1Text" then askRow = row[1] end
end
eq(askRow, "ask", "the gibberish line is asked with a YES/NO, not just shown")
-- neither answer touches the teeth trade
check(not Flags.get(Game.save, "EVENT_GAVE_GOLD_TEETH"),
"and neither answer hands over teeth the player does not have")
Commands.show_text = origShow
S.finish()
-290
View File
@@ -1,290 +0,0 @@
-- Parity (#617): Yellow's Viridian old man is the OLD_MAN2 at (18,9),
-- not the Red/Blue OLD_MAN at (17,5), and his dialog has no yes/no
-- choice -- the apology speech runs the RATTATA demo battle straight
-- away, the post-battle line is the losing-my-touch text, and he walks
-- off and hides.
--
-- Oracle: pokeyellow scripts/OaksLab.asm (OaksLabOakGivesPokedexScript:
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN_2),
-- scripts/ViridianCity.asm (ViridianCityCheckWaitingOldMan,
-- ViridianCityOldMan2Text, ...InitialCatchTrainingScript,
-- ...PostInitialCatchTraining) and scripts/ViridianCity_2.asm
-- (ViridianCityPrintOldManText). The Red/Blue "Are you in a hurry?"
-- script was running against Yellow's text: YES printed the TimeIsMoney
-- alias (_ViridianCityOldManLosingMyTouchText) and NO ran the demo --
-- every talk, forever.
--
-- Self-contained: `luajit tests/parity_yellow_old_man.lua`; also globbed
-- by tests/run_tests.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.maps and Data.maps.PALLET_TOWN) then Data:load() end
local GameVersion = require("src.core.GameVersion")
local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner")
local TextBox = require("src.render.TextBox")
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local S = require("tests.harness").suite("parity Yellow old man (#617)")
local check, eq = S.check, S.eq
local oldVersion = GameVersion.get()
local MAP = "VIRIDIAN_CITY"
local SLEEPER = "VIRIDIANCITY_OLD_MAN_SLEEPY"
local WALKER = "VIRIDIANCITY_OLD_MAN"
local OLD_MAN2 = "VIRIDIANCITY_OLD_MAN2"
local DONE_FLAG = "EVENT_COMPLETED_CATCH_TRAINING"
-- The Yellow wiring must be attached before anything else caches the
-- map-script registry: data.scripts.init branches on GameVersion at
-- load, so flip it first (this file owns its own process when run
-- standalone). Under tests/run_tests.lua the registry is already
-- cached with the Red wiring, so attach the Yellow modules directly
-- afterwards -- attachBase merges per TEXT constant and replaces hooks,
-- which is a no-op on a fresh process and the fix on a shared one.
GameVersion.set("yellow")
local mapScripts = require("data.scripts.init")
local MapScripts = require("src.script.MapScripts")
MapScripts.attachBase(MAP,
require("data.scripts.yellow_viridian_old_man").VIRIDIAN_CITY)
MapScripts.attachBase("OAKS_LAB",
require("data.scripts.oaks_lab_yellow"))
local oldManMod = require("data.scripts.yellow_viridian_old_man")
-- ------------------------------------------------------- the demo species
-- The catch demo is a RATTATA in Yellow (SetupBattle sets wCurOpponent
-- = RATTATA) but the Yellow manifest inherited Red's WEEDLE; the runtime
-- override in Data:applyVersionedFieldData repairs old caches. Kept
-- active until the end of this file so the demo-battle assertions below
-- run against the Yellow value; restored before S.finish() like
-- parity_yellow_trades does for its trades table.
local originalOldManBattle = Data.field.oldManBattle
or { species = "WEEDLE", level = 5 } -- the fixture carries no oldManBattle
local originalTrades = Data.field.trades
eq(originalOldManBattle.species, "WEEDLE",
"Red/Blue's old man still demos a Weedle")
GameVersion.set("yellow")
Data:applyVersionedFieldData()
eq(Data.field.oldManBattle.species, "RATTATA",
"Yellow's old man demos a Rattata (#617)")
local manifestFile = assert(io.open("tools/rom_manifest_yellow.json", "r"))
local yellowManifest = manifestFile:read("*a")
manifestFile:close()
check(yellowManifest:find('"species": "RATTATA"', 1, true) ~= nil,
"the Yellow manifest stamps RATTATA for fresh imports")
local redManifestFile = assert(io.open("tools/rom_manifest.json", "r"))
local redManifest = redManifestFile:read("*a")
redManifestFile:close()
check(redManifest:find('"species": "WEEDLE"', 1, true) ~= nil,
"and the Red/Blue manifest keeps WEEDLE")
-- ------------------------------------------------------- the Pokedex swap
-- OaksLabOakGivesPokedexScript shows TOGGLE_OLD_MAN_2 (the tutorial old
-- man standing on the sleeper's cell), never the Red/Blue walker
local oaksRows = mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")
check(type(oaksRows) == "table",
"the Yellow OaksLab Oak talk resolves to rows")
local sawSleepHide, sawOldMan2Show, sawOldManShow = false, false, false
for _, row in ipairs(oaksRows or {}) do
if row[1] == "hide_object" and row[3] == SLEEPER then sawSleepHide = true end
if row[1] == "show_object" and row[3] == OLD_MAN2 then sawOldMan2Show = true end
if row[1] == "show_object" and row[3] == WALKER then sawOldManShow = true end
end
check(sawSleepHide, "the Pokédex hand-over hides the lying old man")
check(sawOldMan2Show, "it shows OLD_MAN2 on the sleeper's cell")
check(not sawOldManShow, "it never shows the Red/Blue OLD_MAN (#617)")
-- both Yellow gamblers default hidden (toggle OFF), like pokeyellow
-- data/maps/toggleable_objects.asm. OLD_MAN2 only exists in a Yellow
-- import -- a Red-imported checkout carries just OLD_MAN -- so the
-- dataset checks tolerate its absence and the Yellow manifest carries
-- the OLD_MAN2 default instead.
local walkerDef, oldMan2Def
if Data.maps[MAP] then
for _, o in ipairs(Data.maps[MAP].objects or {}) do
if o.name == WALKER then walkerDef = o end
if o.name == OLD_MAN2 then oldMan2Def = o end
end
end
check(walkerDef == nil or walkerDef.hidden == true,
"VIRIDIANCITY_OLD_MAN defaults hidden in Yellow")
check(oldMan2Def == nil or oldMan2Def.hidden == true,
"VIRIDIANCITY_OLD_MAN2 defaults hidden in Yellow")
local om2Name = yellowManifest:find('"name": "VIRIDIANCITY_OLD_MAN2"', 1, true)
local om2Hidden = om2Name and yellowManifest:sub(
math.max(1, om2Name - 40), om2Name):find('"hidden": true', 1, true)
check(om2Hidden ~= nil,
"the Yellow manifest ships OLD_MAN2 with the toggle OFF")
-- ------------------------------------------------------- script registry
local talk = mapScripts.talkScript(MAP, "TEXT_VIRIDIANCITY_OLD_MAN2")
check(type(talk) == "function",
"TEXT_VIRIDIANCITY_OLD_MAN2 resolves to the Yellow handler")
check(type(mapScripts.talkScript(MAP, "TEXT_VIRIDIANCITY_OLD_MAN")) == "table",
"the Red/Blue OLD_MAN talk is still registered (unreachable in Yellow)")
local hooks = mapScripts.get(MAP)
check(hooks and type(hooks.onEnter) == "function",
"VIRIDIAN_CITY.onEnter is the Yellow swap")
check(hooks and type(hooks.onStep) == "function",
"VIRIDIAN_CITY.onStep chains the gym lock and sleeper gate")
check(oldManMod.VIRIDIAN_CITY and oldManMod.VIRIDIAN_CITY.talk
and oldManMod.VIRIDIAN_CITY.talk.TEXT_VIRIDIANCITY_OLD_MAN2 == talk,
"the handler is the module's own, not a leftover merge")
-- ------------------------------------------------------- completed branch
do
local pushed = {}
local game = {
data = Data,
save = SaveData.newGame(),
stack = { push = function(_, s) pushed[#pushed + 1] = s end },
}
game.save.flags.EVENT_COMPLETED_CATCH_TRAINING = true
local done = false
talk(game, nil, {}, function() done = true end)
eq(#pushed, 1, "a second talk only prints one box")
eq(getmetatable(pushed[1]), TextBox, "the losing-my-touch line, in a box")
pushed[1].onDone()
check(done, "closing it hands input back")
end
-- ------------------------------- the initial tutorial, end to end
-- Needs real species in the dataset (the fixture carries only FIX_*);
-- the engine's old-man demo machinery itself is parity_J's territory.
if Data.pokemon.RATTATA and Data.pokemon.PIKACHU then
do
require("src.render.Font").load(Data)
local pushed = {}
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "PIKACHU", 12) }
local moves = {}
local man = { def = { index = 8, name = OLD_MAN2 } }
local ow = {
map = { id = MAP, def = { label = "ViridianCity" } },
npcs = { man }, entities = { man },
player = { cellX = 19, cellY = 9, facing = "left" },
scriptMove = function(_, _, dir, _, cb) moves[#moves + 1] = dir; cb() end,
npcByIndex = function(_, i) if i == 8 then return man end end,
}
local game = {
data = Data,
save = save,
stack = { push = function(_, s) pushed[#pushed + 1] = s end },
}
local runner = ScriptRunner.new(game, ow)
ow.runner = runner
local done = false
talk(game, ow, man, function() done = true end)
eq(#pushed, 1, "the initial talk opens the apology speech")
eq(getmetatable(pushed[1]), TextBox, "in a text box")
pushed[1].onDone() -- A: the apology closes, the demo battle starts
eq(#pushed, 2, "the demo battle starts with no choice in between")
local battle = pushed[2]
check(battle and battle.demo, "it is the old-man demo battle")
eq(battle and battle.enemy and battle.enemy.mon.species, "RATTATA",
"the demo is a RATTATA in Yellow (#617)")
check(battle and battle.demoFails,
"the initial training throw breaks out, never catches (#636)")
eq(save.flags[DONE_FLAG], nil, "the flag is still clear mid-demo")
battle.onFinish() -- the battle ends, the post-battle text prints
eq(save.flags[DONE_FLAG], true, "EVENT_COMPLETED_CATCH_TRAINING is set")
eq(#pushed, 3, "the losing-my-touch line follows the demo")
pushed[3].onDone() -- A: the old man walks off
eq(#moves, 6, "with the player on (19,9) he walks down 6 tiles")
check(moves[1] == "down" and moves[6] == "down",
"all six steps are the ViridianCityOldManMovementData2 walk")
eq(save.objectToggles[MAP] and save.objectToggles[MAP][OLD_MAN2], false,
"TOGGLE_OLD_MAN_2 hides once the walk finishes")
check(done, "and the talk hands input back")
end
-- ---------------------------------- side talk: player not on (19,9) cell
do
local pushed = {}
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "PIKACHU", 12) }
local moves = {}
local man = { def = { index = 8, name = OLD_MAN2 } }
local pika = { def = { index = 99, name = "PIKACHU_FOLLOWER" },
pikachuFollower = true }
local ow = {
map = { id = MAP, def = { label = "ViridianCity" } },
npcs = { man, pika }, entities = { man, pika },
player = { cellX = 18, cellY = 8, facing = "down" },
scriptMove = function(_, _, dir, _, cb) moves[#moves + 1] = dir; cb() end,
npcByIndex = function(_, i) if i == 8 then return man elseif i == 99 then return pika end end,
}
local game = {
data = Data,
save = save,
stack = { push = function(_, s) pushed[#pushed + 1] = s end },
}
local runner = ScriptRunner.new(game, ow)
ow.runner = runner
talk(game, ow, man, function() end)
pushed[1].onDone()
pushed[2].onFinish()
pushed[3].onDone()
eq(moves[1], "right", "Pikachu steps aside first (ViridianCityMovePikachu)")
eq(moves[2], "right", "then the old man turns right one tile")
eq(#moves, 2, "and no more")
end
else
check(true, "fixture dataset: demo-battle flow skipped (no RATTATA)")
end
-- --------------------------------------------------------- the (19,9) step
do
local pushed = {}
local save = SaveData.newGame()
local man = { def = { index = 8, name = OLD_MAN2 } }
local ow = {
map = { id = MAP, def = { label = "ViridianCity" } },
npcs = { man }, entities = { man },
player = { cellX = 19, cellY = 9, facing = "down" },
scriptMove = function(_, _, _, _, cb) cb() end,
npcByIndex = function() end,
}
local game = {
data = Data,
save = save,
stack = { push = function(_, s) pushed[#pushed + 1] = s end },
}
local runner = ScriptRunner.new(game, ow)
ow.runner = runner
check(not hooks.onStep(game, ow, 5, 5),
"off the trigger cell the step passes through")
check(hooks.onStep(game, ow, 19, 9),
"pre-Pokedex the sleeper gate owns (19,9)")
eq(#pushed, 1, "with the sleepy text box")
check(save.flags[DONE_FLAG] ~= true, "the tutorial is not running")
save.flags.EVENT_GOT_POKEDEX = true
check(hooks.onStep(game, ow, 19, 9),
"with the Pokedex, (19,9) starts the tutorial")
eq(man.facing, "right", "the old man faces the player")
eq(ow.player.facing, "left", "and the player turns to face him")
eq(#pushed, 2, "the apology box is up")
check(save.flags[DONE_FLAG] ~= true,
"no flag until the demo battle actually runs")
save.flags.EVENT_COMPLETED_CATCH_TRAINING = true
check(not hooks.onStep(game, ow, 19, 9),
"once the tutorial is done the cell is quiet again")
end
Data.field.trades = originalTrades
Data.field.oldManBattle = originalOldManBattle
GameVersion.set(oldVersion)
S.finish()