mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
deleting stale tests
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user