CLOSES #2843, CLOSES #2844, CLOSES #2845, CLOSES #2846

This commit is contained in:
bryanthaboi
2026-10-10 14:06:59 -04:00
parent daf8673954
commit e81362907a
15 changed files with 695 additions and 20 deletions
+7 -4
View File
@@ -309,11 +309,14 @@ M.PALLET_TOWN = {
game.stack:push(TextBox.new(game,
t._PalletTownOakHeyWaitDontGoOutText or "OAK: Hey! Wait!\nDon't go out!",
nil, { auto = { delay = 10, overlap = 10, onOverlap = function()
-- .HeyWaitDontGoOutText turns the player to face down (toward
-- the approaching Oak) before the exclamation bubble
function()
ow.emote = nil
oakAppearsAndWalks()
end, { auto = { delay = 10, overlap = 60, onOverlap = function()
-- scripts/PalletTown.asm:186
ow.player.facing = "down"
ow.emote = { npc = ow.player, frames = 50, onDone = oakAppearsAndWalks }
-- scripts/PalletTown.asm:185
ow.emote = { npc = ow.player, frames = 60 }
end } }))
return true
end,
+1 -3
View File
@@ -214,9 +214,7 @@ local function applySavedAudioMode()
end)
if not ok or type(savedOptions) ~= "table" then savedOptions = {} end
local mode = savedOptions.audioMode or "both"
if love and love.audio and love.audio.setMixWithSystem then
pcall(love.audio.setMixWithSystem, mode ~= "game_only")
end
require("src.audio.AudioMix").set(mode ~= "game_only")
end
local Game, EditorApp, Importer, TouchEditor, Studio, Prelaunch
+23
View File
@@ -0,0 +1,23 @@
local AudioMix = {}
local sent
function AudioMix.set(mix)
mix = mix and true or false
if sent == mix then return false end
if not (love and love.audio and love.audio.setMixWithSystem) then
return false
end
if pcall(love.audio.setMixWithSystem, mix) then sent = mix end
return true
end
function AudioMix.current()
return sent
end
function AudioMix._resetForTest()
sent = nil
end
return AudioMix
+6 -4
View File
@@ -573,9 +573,7 @@ end
function Music.setAudioMode(mode)
currentAudioMode = mode or "both"
if love and love.audio and love.audio.setMixWithSystem then
pcall(love.audio.setMixWithSystem, currentAudioMode ~= "game_only")
end
require("src.audio.AudioMix").set(currentAudioMode ~= "game_only")
Music.setVolumeLevel(storedVolumeLevel)
end
@@ -634,7 +632,11 @@ function Music.onDeviceReset()
local src = require("src.core.ChipAudio").currentSource()
if not src then return end
state.source = src
applyVolume(src)
if state.fade then
fadeSetLevel(state.fade, state.fade.level)
else
applyVolume(src)
end
applyFilter(src)
return
end
+1 -5
View File
@@ -370,9 +370,7 @@ function Audio.applyEngineOptions(opts)
if type(opts) ~= "table" then return end
local mode = opts.audioMode or "both"
Audio._audioMode = mode
if love and love.audio and love.audio.setMixWithSystem then
pcall(love.audio.setMixWithSystem, mode ~= "game_only")
end
require("src.audio.AudioMix").set(mode ~= "game_only")
if mode == "external_only" then
Audio._bgmVolume = 0
else
@@ -467,7 +465,6 @@ function Audio.playSong(id, opts)
}
Audio._mapSong = Audio._mapSong or id
-- A new song owns the bus — cancel stale fades and fanfares (oak exit fade was killing lab BGM).
Audio._fadeOut = nil
Audio._fadeIn = nil
Audio._fanfareActive = false
@@ -1743,7 +1740,6 @@ function Audio.onFocusGained()
end
end
--- Device reset: QueueableSource may be dead — rebuild then refill.
function Audio.rebuildPlayback()
Audio._suspended = false
if not (love and love.audio and love.audio.newQueueableSource) then return false end
+12
View File
@@ -204,9 +204,21 @@ local function makeFollower(game, ow, x, y, facing)
if idle and idle.phase then return idle.phase % 2 end
return NPC.walkPhase(self)
end
npc.draw = function(self, camX, camY)
if PikachuFollower.underPlayer(ow, self) then return end
return NPC.draw(self, camX, camY)
end
return npc
end
-- engine/pikachu/pikachu_follow.asm:1030
function PikachuFollower.underPlayer(ow, npc)
local p = ow and ow.player
if not (p and npc) or npc.moving then return false end
return npc.cellX == (p.targetX or p.cellX)
and npc.cellY == (p.targetY or p.cellY)
end
local function findFollower(ow)
for i, npc in ipairs(ow.npcs or {}) do
if npc.pikachuFollower then return npc, i end
+15 -2
View File
@@ -7067,7 +7067,8 @@ function World:startBattle(opts, onDone)
-- whatever stood there.
if whiteout then
self:healParty()
if not BugContest.isActive(game.save) then
local inContest = BugContest.isActive(game.save)
if not inContest then
CallAsm.run(self, "HalveMoney")
CallAsm.run(self, "GetWhiteoutSpawn")
end
@@ -7078,6 +7079,12 @@ function World:startBattle(opts, onDone)
Runtime.emit("world.blacked_out",
{ save = game.save, healTarget = self:healPoint() })
end
if inContest then
self:restoreMapMusic()
-- engine/events/whiteout.asm:24-25
self:bugContestResults()
return
end
-- engine/events/whiteout.asm:19-20
self:runMapSetup(MAPSETUP.WARP, function()
self:warpToSpawn()
@@ -11078,9 +11085,15 @@ end
function World:whiteOut()
self:showText(
Strings("You have no more\nPOKéMON that can\011fight!"), function()
self:healParty()
-- engine/events/whiteout.asm:15-16
if BugContest.isActive(self.game and self.game.save) then
-- engine/events/whiteout.asm:24-25
self:bugContestResults()
return
end
CallAsm.run(self, "HalveMoney")
CallAsm.run(self, "GetWhiteoutSpawn")
self:healParty()
-- Guarded because healPoint walks the spawn table to answer: with nobody
-- listening the blackout must not pay for a lookup warpToSpawn is about to
-- make again anyway.
+69
View File
@@ -0,0 +1,69 @@
-- scripts/PalletTown.asm:177-188
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local TextBox = require("src.render.TextBox")
local ok = true
local function check(label, cond)
if not cond then ok = false end
U.log(cond and "PASS" or "FAIL", label)
end
local function done()
love.event.quit(ok and 0 or 1)
end
local f = game.save.flags
f.EVENT_FOLLOWED_OAK_INTO_LAB = nil
f.EVENT_GOT_STARTER = nil
U.teleport(game, "PALLET_TOWN", 10, 3, "up")
local ow = game.stack:top()
local box
for _ = 1, 120 do
table.insert(game.input.pressQueue, "up")
game.input.state.up = true
U.wait(1)
local top = game.stack:top()
if getmetatable(top) == TextBox then box = top break end
end
game.input.state.up = false
check("hey wait text box opened", box ~= nil)
if not box then return done() end
local bubbleStart
for _ = 1, 600 do
if ow.emote and ow.emote.npc == ow.player then bubbleStart = true break end
U.wait(1)
end
check("bubble appears while text box is up", bubbleStart ~= nil and game.stack:top() == box)
if not bubbleStart then return done() end
U.wait(30)
check("text box still up mid-bubble", game.stack:top() == box)
U.still(game, DIR .. "/2843_01_bubble_with_textbox.png")
local boxGone, bubbleGone
for _ = 1, 200 do
U.wait(1)
if not boxGone and game.stack:top() ~= box then boxGone = U.frame() end
if not bubbleGone and not (ow.emote and ow.emote.npc == ow.player) then bubbleGone = U.frame() end
if boxGone and bubbleGone then break end
end
local held = box.autoTimer and box.auto and (box.autoTimer - box.auto.delay)
U.log("bubble frames under box", tostring(held))
check("text box and bubble close on the same frame",
boxGone ~= nil and boxGone == bubbleGone)
check("bubble lasts 60 frames under the box", held == 60)
U.still(game, DIR .. "/2843_02_box_and_bubble_gone.png")
local oakShown = false
for _ = 1, 60 do
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == "PALLETTOWN_OAK" then oakShown = true end
end
if oakShown then break end
U.wait(1)
end
check("oak appears after the bubble", oakShown)
done()
end
@@ -0,0 +1,120 @@
-- engine/menus/main_menu.asm:106-112, home/fade_audio.asm:12-45
return function(game)
local U = dofile("tests/drivers/util.lua")
local SaveData = require("src.core.SaveData")
local Music = require("src.core.Music")
local ChipAudio = require("src.core.ChipAudio")
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots/2844"
local fails = 0
local function check(label, ok, detail)
print((ok and "PASS " or "FAIL ") .. label
.. (detail ~= nil and (" " .. tostring(detail)) or ""))
if not ok then fails = fails + 1 end
return ok
end
local function finish()
print(fails == 0 and "PASS continue_fade_no_jump_bug2844"
or "FAIL continue_fade_no_jump_bug2844")
love.event.quit(fails == 0 and 0 or 1)
while true do coroutine.yield() end
end
local function waitFor(pred, limit)
for _ = 1, limit or 1800 do
if pred() then return true end
U.wait(1)
end
return false
end
local mixCalls = 0
local realMix = love.audio.setMixWithSystem
love.audio.setMixWithSystem = function(mix)
mixCalls = mixCalls + 1
love.event.push("audioreset")
if realMix then return realMix(mix) end
return true
end
if not check("new game reached the overworld", U.newGame(game)) then
return finish()
end
local ow = game.overworld
game.save.player.map = ow.map.id
game.save.player.x, game.save.player.y = ow.player.cellX, ow.player.cellY
check("save written", SaveData.save(game.save) ~= false)
local mapSong = game.data.audio.mapSongs[ow.map.id]
game:returnToTitle()
local title = game.stack:top()
for _ = 1, 120 do
title = game.stack:top()
if title and title.screenId == "TitleState" and title.phase == "loop" then
break
end
U.tap(game, "start")
U.wait(10)
end
check("title reached its loop", title and title.phase == "loop")
local titleSong
check("title music playing", waitFor(function()
titleSong = Music.current()
return titleSong ~= nil and titleSong ~= mapSong
and ChipAudio.currentSource() ~= nil
end, 600), titleSong)
U.tap(game, "start")
check("main menu open", waitFor(function()
return title.menuOpen and game.stack:top() ~= title
end, 600))
U.wait(4)
local menu = game.stack:top()
U.tap(game, "a")
check("CONTINUE info window open", waitFor(function()
local top = game.stack:top()
return top ~= menu and top and top.titleUiBox and top.titleUiBox[1] == 4
end, 300))
U.wait(4)
local titleSource = ChipAudio.currentSource()
mixCalls = 0
U.tap(game, "a")
local fadeFrames, swapped, rose, lastVol = 0, false, false, nil
local switchFrame, shot = nil, false
for f = 1, 300 do
local top = game.stack:top()
if top and top.screenId == "QuarantineReport" then
table.insert(game.input.pressQueue, "a")
end
local cur = Music.current()
local level, _, vol
if Music.fadeLevel then level, _, vol = Music.fadeLevel() end
if level and cur == titleSong then
fadeFrames = fadeFrames + 1
if ChipAudio.currentSource() ~= titleSource then swapped = true end
local live = ChipAudio.currentSource()
local ok, v = pcall(function() return live:getVolume() end)
v = ok and v or vol
if lastVol and v and v > lastVol + 1e-6 then rose = true end
lastVol = v or lastVol
if fadeFrames == 30 and not shot then
shot = true
U.still(game, SHOT_DIR .. "/2844_01_overworld_title_fading.png")
end
end
if not switchFrame and cur == mapSong then switchFrame = f end
if switchFrame and f > switchFrame + 5 then break end
U.wait(1)
end
check("CONTINUE sends no setMixWithSystem (no audioreset)", mixCalls == 0,
mixCalls)
check("title fade ran", fadeFrames >= 80, fadeFrames)
check("title keeps its queued stream through the fade (no position jump)",
not swapped)
check("source volume never rises during the fade", not rose)
check("map song takes over after the fade", switchFrame ~= nil, switchFrame)
love.audio.setMixWithSystem = realMix
return finish()
end
@@ -0,0 +1,82 @@
-- ../pokecrystal/engine/events/whiteout.asm:9-25
-- ../pokecrystal/engine/events/std_scripts.asm:308-315
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local BugContest = require("src.core.gen2.BugContest")
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gen2-contest-whiteout-2845"
return function(game)
os.execute('mkdir -p "' .. SHOT_DIR .. '" 2>/dev/null')
local fails = 0
local function ok(cond, line)
if not cond then fails = fails + 1 end
print("[2845] " .. (cond and "PASS " or "FAIL ") .. line)
return cond
end
local function finish()
print("[2845] " .. (fails == 0 and "all claims passed" or (fails .. " claims failed")))
love.event.quit(fails == 0 and 0 or 1)
while true do U.wait(60) end
end
local function mapId(world)
return world.map and world.map.def and world.map.def.id
end
U.wait(45)
local world, save = game.world, game.save
if not ok(world and world.map and world.vm and save, "the world booted") then finish() end
local lead = Mon.new(game.data, "SENTRET", 3)
lead.hp = 1
save.party = { lead }
save.player = save.player or {}
save.player.money = 3000
BugContest.start(save)
world:warpToMapId("NATIONAL_PARK", 10, 20, "down")
U.wait(45)
ok(mapId(world) == "NATIONAL_PARK", "standing in the park mid-contest")
ok(BugContest.isActive(save), "the contest is running")
local wild = Mon.new(game.data, "SCYTHER", 50)
wild.moves = { { id = "QUICK_ATTACK", pp = 30, maxPp = 30 } }
ok(world:startBattle({ wild = wild, contest = true }), "a contest battle started")
local screen
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then screen = top break end
U.wait(1)
end
if not ok(screen ~= nil and screen.contest, "the contest battle screen came up") then finish() end
for _ = 1, 3000 do
if game.stack:top() ~= screen then break end
U.tap(game, "a")
U.wait(3)
end
ok(screen.battle and screen.battle.outcome == "lose", "the battle ended in a loss")
local atGate = false
for _ = 1, 600 do
if mapId(world) == "ROUTE_36_NATIONAL_PARK_GATE" then atGate = true break end
U.wait(2)
end
ok(atGate, "the wipe warped to the national park gate, not a Pokecenter (map "
.. tostring(mapId(world)) .. ")")
U.wait(30)
U.still(game, SHOT_DIR .. "/2845_01_gate_after_wipe.png")
local deadline = love.timer.getTime() + 15
while world.vm:running() and love.timer.getTime() < deadline do
U.tap(game, "a")
U.wait(3)
end
ok(not world.vm:running(), "the judging script ran to completion")
ok(not BugContest.isActive(save), "the contest is over")
ok(mapId(world) == "ROUTE_36_NATIONAL_PARK_GATE", "still at the gate after judging")
ok(save.player.money == 3000, "the wallet was not halved")
ok((save.party[1] and save.party[1].hp or 0) > 0, "the party was healed")
U.wait(10)
U.still(game, SHOT_DIR .. "/2845_02_judged_at_gate.png")
finish()
end
@@ -0,0 +1,82 @@
-- engine/pikachu/pikachu_follow.asm:1030
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local GameVersion = require("src.core.GameVersion")
local NPC = require("src.world.NPC")
local PF = require("src.world.PikachuFollower")
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local failures = 0
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
if not ok then failures = failures + 1 end
return ok
end
check("running as Yellow", GameVersion.isYellow())
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
game.save.pikachuInBall = false
game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) }
game.save.onBike = false
local pikaDraws = 0
local realDraw = NPC.draw
NPC.draw = function(self, ...)
if self.pikachuFollower then pikaDraws = pikaDraws + 1 end
return realDraw(self, ...)
end
local function plain(map, x, y)
return map:inBounds(x, y) and map:isWalkableCell(x, y)
and not map:isGrassCell(x, y)
end
U.teleport(game, "ROUTE_1", 10, 20, "down")
U.wait(5)
local ow = game.overworld
local sx, sy
for y = 4, 30 do
for x = 2, 16 do
if not sx and plain(ow.map, x, y) and plain(ow.map, x, y + 1)
and plain(ow.map, x, y + 2) and plain(ow.map, x - 1, y)
and plain(ow.map, x + 1, y) then
sx, sy = x, y
end
end
end
check("found an open Route 1 cell", sx ~= nil)
sx, sy = sx or 10, sy or 20
U.teleport(game, "ROUTE_1", sx, sy, "down")
U.wait(30)
ow = game.overworld
local npc = PF.current(ow)
local p = ow.player
check("follower spawned on the Route 1 map load", npc ~= nil)
check("spawn state 0 parks Pikachu on the player's cell",
npc and npc.cellX == p.cellX and npc.cellY == p.cellY)
pikaDraws = 0
local shotOk = U.still(game, SHOT_DIR .. "/2846_01_route1_before_step.png")
check("Pikachu is not drawn before the first step (draws=" .. pikaDraws .. ")",
shotOk and pikaDraws == 0)
U.hold(game, "down", 20)
U.wait(20)
npc = PF.current(ow)
p = ow.player
check("after one step Pikachu trails one cell behind",
npc and p and npc.cellX == p.cellX and npc.cellY == p.cellY - 1)
pikaDraws = 0
shotOk = U.still(game, SHOT_DIR .. "/2846_02_route1_after_step.png")
check("Pikachu is drawn after the first step (draws=" .. pikaDraws .. ")",
shotOk and pikaDraws > 0)
NPC.draw = realDraw
U.log(failures == 0 and "PASS all" or ("FAIL " .. failures .. " check(s)"))
love.event.quit(failures == 0 and 0 or 1)
end
+7 -2
View File
@@ -36,10 +36,15 @@ Music.applyOptions({ audioMode = "both", musicVol = 7 })
eq(Music.audioMode(), "both", "Music mode set to 'both'")
eq(mixWithSystemCalls[#mixWithSystemCalls], true, "audioMode 'both' enables mixWithSystem")
mixWithSystemCalls = {}
Music.applyOptions({ audioMode = "both", musicVol = 7 })
eq(#mixWithSystemCalls, 0, "re-applying unchanged options sends no setMixWithSystem (#2844)")
mixWithSystemCalls = {}
Music.applyOptions({ audioMode = "external_only", musicVol = 7 })
eq(Music.audioMode(), "external_only", "Music mode set to 'external_only'")
eq(mixWithSystemCalls[#mixWithSystemCalls], true, "audioMode 'external_only' enables mixWithSystem")
eq(#mixWithSystemCalls, 0, "'both' -> 'external_only' keeps mixWithSystem on without a resend")
eq(require("src.audio.AudioMix").current(), true, "audioMode 'external_only' enables mixWithSystem")
mixWithSystemCalls = {}
Music.applyOptions({ audioMode = "game_only", musicVol = 7 })
@@ -60,7 +65,7 @@ Game3Audio.applyEngineOptions({ audioMode = "external_only", musicVol = 7, sfxVo
eq(Game3Audio._audioMode, "external_only", "Game3 audioMode set to 'external_only'")
eq(Game3Audio._bgmVolume, 0, "Game3 'external_only' mutes in-game BGM")
check(Game3Audio._sfxVolume > 0, "Game3 'external_only' keeps SFX active")
eq(mixWithSystemCalls[#mixWithSystemCalls], true, "Game3 'external_only' enables mixWithSystem")
eq(require("src.audio.AudioMix").current(), true, "Game3 'external_only' enables mixWithSystem")
mixWithSystemCalls = {}
Game3Audio.applyEngineOptions({ audioMode = "game_only", musicVol = 7, sfxVol = 7 })
@@ -0,0 +1,91 @@
-- home/fade_audio.asm:12-35
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local eq = T.eq
love = require("tests.love_stub")
local Source = {}
Source.__index = Source
function Source:play() self.playing = true end
function Source:stop() self.playing = false end
function Source:pause() self.playing = false end
function Source:isPlaying() return self.playing end
function Source:setLooping() end
function Source:setVolume(v) self.volume = v end
function Source:setPitch() end
function Source:setFilter() end
local current
local resets = 0
local fakeChip = {
playMusic = function()
current = setmetatable({ playing = true }, Source)
return current
end,
currentSource = function() return current end,
rebuildPlayback = function()
current = setmetatable({ playing = true }, Source)
resets = resets + 1
return true
end,
stopMusic = function() current = nil end,
update = function() end,
ensureMusicPlaying = function() end,
holdMusic = function() end,
awaitingFirstBuffer = function() return false end,
applyOptions = function() return false end,
setStereo = function() end,
}
package.loaded["src.core.ChipAudio"] = fakeChip
local Music = require("src.core.Music")
local mixCalls = 0
love.audio = {
newSource = function() return setmetatable({}, Source) end,
setMixWithSystem = function()
mixCalls = mixCalls + 1
fakeChip.rebuildPlayback()
Music.onDeviceReset()
return true
end,
}
local data = { audio = {
songs = {
Music_TitleScreen = { chip = true },
Music_PalletTown = { chip = true },
},
mapSongs = { PALLET_TOWN = "Music_PalletTown" },
} }
local opts = { audioMode = "both", musicVol = 7, musicFilter = 0 }
Music.applyOptions(opts)
Music.play(data, "Music_TitleScreen")
local title = current
eq(Music.current(), "Music_TitleScreen", "title theme playing")
mixCalls, resets = 0, 0
Music.applyOptions(opts)
eq(mixCalls, 0, "CONTINUE re-applying the same options sends no setMixWithSystem")
eq(resets, 0, "so the playing title stream is not rebuilt mid-song")
check(current == title, "the title keeps its queued stream")
local full = current.volume
Music.playMap(data, "PALLET_TOWN", false, false, Music.MAP_FADE)
eq(Music.current(), "Music_TitleScreen", "title fades before the map theme")
for _ = 1, 3 * (Music.MAP_FADE + 1) do Music.update(data) end
local faded = current.volume
check(faded < full, "fade has lowered the title volume")
fakeChip.rebuildPlayback()
Music.onDeviceReset()
check(math.abs(current.volume - faded) < 1e-9,
"a device reset mid-fade keeps the faded level")
T.finish("continue_fade_audio_reset_bug2844")
+119
View File
@@ -0,0 +1,119 @@
-- engine/events/whiteout.asm:9-25, engine/events/std_scripts.asm:308-315
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 contest whiteout")
local check, eq = S.check, S.eq
local World = require("src.world.gen2.World")
local Screens = require("src.ui.Screens")
local Events = require("src.world.gen2.Events")
local Save = require("src.core.gen2.Save")
local BugContest = require("src.core.gen2.BugContest")
local DATA = {
pokemon = {
CATERPIE = { id = "CATERPIE", name = "CATERPIE", index = 10, baseExp = 53,
growthRate = "MEDIUM_FAST", stats = { hp = 45, attack = 30,
defense = 35, speed = 45, specialAttack = 20, specialDefense = 20 },
types = { "BUG" } },
RATTATA = { id = "RATTATA", name = "RATTATA", index = 19, baseExp = 57,
growthRate = "MEDIUM_FAST", stats = { hp = 30, attack = 56,
defense = 35, speed = 72, specialAttack = 25, specialDefense = 35 },
types = { "NORMAL" } },
},
moves = {},
}
local function mon(species, level, hp)
return { species = species, name = species, nickname = species,
level = level, hp = hp, maxHp = 30,
stats = { hp = 30, attack = 10, defense = 10, speed = 10,
specialAttack = 10, specialDefense = 10 },
moves = {}, experience = 0, statExp = {}, dvs = {} }
end
local function contestSave()
local save = Save.normalize({ party = { mon("RATTATA", 10, 0) },
player = { name = "GOLD", money = 3000 } })
BugContest.start(save)
return save
end
local function contestWorld(save)
local captured, log = {}, {}
Screens.invalidate()
local game = {
data = {
pokemon = DATA.pokemon, moves = DATA.moves,
screens = {
Gen2BattleState = function(_, opts)
captured.opts = opts
return { screenId = "Gen2BattleState" }
end,
},
},
save = save,
stack = { push = function() end, pop = function() end },
}
local world = World.new(game)
game.world = world
world.map = { def = { id = "NATIONAL_PARK" } }
world.maps = { NATIONAL_PARK = world.map.def }
world.events = Events.new()
world.playBattleMusic = function() end
world.battleMusicContext = function() return nil end
world.pushBattleTransition = function() return nil end
world.restoreMapMusic = function() end
world.roamMonsAfterBattle = function() end
world.healParty = function() log[#log + 1] = "heal" end
world.runMapSetup = function(_, _, fn) log[#log + 1] = "mapsetup" fn() end
world.warpToSpawn = function() log[#log + 1] = "spawn" end
world.bugContestResults = function() log[#log + 1] = "results" return true end
world.bugContestOver = function() log[#log + 1] = "over" return true end
world.showText = function(_, _, onDone) if onDone then onDone() end end
return world, captured, log
end
local function has(log, entry)
for _, e in ipairs(log) do if e == entry then return true end end
return false
end
do
local save = contestSave()
local world, captured, log = contestWorld(save)
world:startBattle({ wild = mon("CATERPIE", 7, 20), contest = true })
check(captured.opts ~= nil, "a contest encounter pushes the battle screen")
captured.opts.onDone("lose")
check(has(log, "heal"), "the party is healed")
check(has(log, "results"),
"a contest wipe runs BugContestResultsWarpScript")
check(not has(log, "spawn"), "and does not warp to the last Pokecenter")
check(not has(log, "over"), "and does not also run the out-of-balls script")
eq(save.player.money, 3000, "nor halve the wallet")
end
do
local save = Save.normalize({ party = { mon("RATTATA", 10, 0) },
player = { name = "GOLD", money = 3000 } })
local world, captured, log = contestWorld(save)
world:startBattle({ wild = mon("CATERPIE", 7, 20) })
captured.opts.onDone("lose")
check(has(log, "spawn"), "outside the contest a wipe still warps home")
check(not has(log, "results"), "and never runs the contest results")
eq(save.player.money, 1500, "and halves the wallet")
end
do
local save = contestSave()
local world, _, log = contestWorld(save)
world:whiteOut()
check(has(log, "results"),
"a poison wipe in the park also goes to the contest results")
check(not has(log, "spawn"), "rather than to the last Pokecenter")
eq(save.player.money, 3000, "and keeps the wallet whole")
end
Screens.invalidate()
return S.finish()
@@ -0,0 +1,60 @@
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 Yellow Pikachu under player")
local check, eq = S.check, S.eq
local Data = require("src.core.Data")
Data:load()
local NPC = require("src.world.NPC")
local Follower = require("src.world.PikachuFollower")
local spriteDef = Data.sprites and (Data.sprites.SPRITE_PIKACHU or Data.sprites.SPRITE_RED)
if not spriteDef then
for _, def in pairs(Data.sprites or {}) do spriteDef = def break end
end
if not spriteDef then
print("[skip] parity Yellow Pikachu under player: no sprite table")
S.finish()
return
end
local game = { data = { sprites = { SPRITE_PIKACHU = spriteDef }, field = {} },
save = { party = {} } }
local walkable = function() return true end
local ow = {
map = { id = "ROUTE_1", inBounds = walkable, isWalkableCell = walkable },
player = { cellX = 5, cellY = 9, facing = "down" },
npcs = {}, entities = {},
}
local restore = Follower.setShouldSpawn(function() return true end)
local draws = 0
local realDraw = NPC.draw
NPC.draw = function() draws = draws + 1 end
Follower.onMapEntered(game, ow, nil, true)
local pika = Follower.current(ow)
check(pika, "a map load spawns the follower")
eq(pika and pika.cellX, 5, "spawn state 0 puts Pikachu on the player's column")
eq(pika and pika.cellY, 9, "spawn state 0 puts Pikachu on the player's row")
check(pika and Follower.underPlayer(ow, pika), "the follower counts as under the player")
pika:draw(0, 0)
eq(draws, 0, "Pikachu on the player's cell draws nothing before the first step")
ow.player.targetX, ow.player.targetY = 5, 10
pika:draw(0, 0)
eq(draws, 1, "once the player commits a step off the cell Pikachu is drawn")
ow.player.cellX, ow.player.cellY = 5, 10
ow.player.targetX, ow.player.targetY = nil, nil
pika:draw(0, 0)
eq(draws, 2, "and stays drawn one cell behind")
Follower.onMapEntered(game, ow, { pikachuSpawn = 1 }, true)
pika = Follower.current(ow)
check(pika and not Follower.underPlayer(ow, pika), "a warp spawn beside the player is not hidden")
pika:draw(0, 0)
eq(draws, 3, "and draws")
NPC.draw = realDraw
Follower.setShouldSpawn(restore)
S.finish()