mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 12:15:31 +02:00
Merge pull request #1541 from bryanthaboi/dev
tuesday afternoon squashing
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local sources = {}
|
||||
|
||||
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(v) self.looping = v end
|
||||
function Source:setVolume(v) self.volume = v end
|
||||
function Source:setPitch(v) self.pitch = v end
|
||||
function Source:setFilter() self.filters = (self.filters or 0) + 1 end
|
||||
function Source:getDuration() return 1 end
|
||||
function Source:getFreeBufferCount() return self.free end
|
||||
function Source:queue() self.free = math.max(0, self.free - 1) end
|
||||
|
||||
local ChipSynth = require("src.core.ChipSynth")
|
||||
local BUFFERS = ChipSynth.MUSIC_BUFFER_COUNT
|
||||
|
||||
local function track(src)
|
||||
sources[#sources + 1] = src
|
||||
return src
|
||||
end
|
||||
|
||||
love.audio = {
|
||||
newSource = function(what, mode)
|
||||
return track(setmetatable({ file = what, mode = mode, free = 0 }, Source))
|
||||
end,
|
||||
newQueueableSource = function()
|
||||
return track(setmetatable({ queueable = true, free = BUFFERS }, Source))
|
||||
end,
|
||||
}
|
||||
|
||||
local channels = {}
|
||||
local lastGen, lastEpoch = 0, 0
|
||||
|
||||
local Channel = {}
|
||||
Channel.__index = Channel
|
||||
function Channel:push(msg)
|
||||
if type(msg) == "table" and msg.cmd == "play" then
|
||||
lastGen, lastEpoch = msg.gen, msg.stereoEpoch or 0
|
||||
end
|
||||
self.queue[#self.queue + 1] = msg
|
||||
end
|
||||
function Channel:pop() return table.remove(self.queue, 1) end
|
||||
function Channel:clear() self.queue = {} end
|
||||
function Channel:getCount() return #self.queue end
|
||||
|
||||
local function channel(name)
|
||||
channels[name] = channels[name] or setmetatable({ queue = {} }, Channel)
|
||||
return channels[name]
|
||||
end
|
||||
|
||||
local threadStub = {
|
||||
newThread = function()
|
||||
return {
|
||||
start = function() end,
|
||||
getError = function() return nil end,
|
||||
wait = function() end,
|
||||
}
|
||||
end,
|
||||
getChannel = channel,
|
||||
}
|
||||
|
||||
local function deliverBuffer(epoch)
|
||||
channel("chipaudio_out"):push({
|
||||
gen = lastGen, sd = true, stereoEpoch = epoch or lastEpoch })
|
||||
end
|
||||
|
||||
local ChipAsm = require("src.audio.ChipAsm")
|
||||
|
||||
local function chipSong(octave)
|
||||
return ChipAsm.song{
|
||||
channels = { { hw = 1, program = {
|
||||
{ notetype = { speed = 12, volume = 12, fade = 0 } },
|
||||
{ octave = octave },
|
||||
{ note = "C", len = 8 },
|
||||
{ loop = { count = 0, to = 1 } },
|
||||
} } },
|
||||
}
|
||||
end
|
||||
|
||||
local function fixtureData()
|
||||
return {
|
||||
audio = {
|
||||
songs = {
|
||||
Music_PalletTown = chipSong(4),
|
||||
Music_Streamed = { file = "assets/song.ogg",
|
||||
loopFile = "assets/song_loop.ogg" },
|
||||
},
|
||||
sfx = { Press_AB = "assets/beep.wav" },
|
||||
cries = {},
|
||||
mapSongs = { PALLET_TOWN = "Music_PalletTown" },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
local Music = require("src.core.Music")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
local function freshChipAudio(threaded)
|
||||
love.thread = threaded and threadStub or nil
|
||||
package.loaded["src.core.ChipAudio"] = nil
|
||||
return require("src.core.ChipAudio")
|
||||
end
|
||||
|
||||
local function lastSource() return sources[#sources] end
|
||||
|
||||
local function clearSources()
|
||||
for i = #sources, 1, -1 do sources[i] = nil end
|
||||
end
|
||||
|
||||
local function audioSuspend()
|
||||
require("src.core.ChipAudio").setSuspended(true)
|
||||
end
|
||||
|
||||
local function audioReset()
|
||||
local ChipAudio = require("src.core.ChipAudio")
|
||||
ChipAudio.setSuspended(false)
|
||||
ChipAudio.rebuildPlayback()
|
||||
Music.onDeviceReset()
|
||||
Sound.onDeviceReset()
|
||||
end
|
||||
|
||||
local ChipAudio = freshChipAudio(true)
|
||||
local data = fixtureData()
|
||||
Sound.invalidate()
|
||||
Music.reload()
|
||||
clearSources()
|
||||
channel("chipaudio_out"):clear()
|
||||
|
||||
Music.playMap(data, "PALLET_TOWN", false, false)
|
||||
local src = lastSource()
|
||||
check(src and src.queueable, "the map theme streams through ChipAudio")
|
||||
src.playing = false
|
||||
|
||||
audioSuspend()
|
||||
deliverBuffer()
|
||||
ChipAudio.update()
|
||||
eq(src.free, BUFFERS, "a suspended update queues nothing onto the dead source")
|
||||
check(not src.playing, "a suspended update does not start playback")
|
||||
eq(channel("chipaudio_out"):getCount(), 1,
|
||||
"the worker buffer waits in the channel instead of being dropped")
|
||||
|
||||
ChipAudio.setSuspended(true)
|
||||
ChipAudio.setSuspended(false)
|
||||
ChipAudio.setSuspended(false)
|
||||
check(not ChipAudio.isSuspended(), "the suspend flag is idempotent both ways")
|
||||
|
||||
ChipAudio.update()
|
||||
eq(src.free, BUFFERS - 1, "the resumed update queues the waiting buffer")
|
||||
check(src.playing, "the resumed update starts playback")
|
||||
|
||||
src.playing = false
|
||||
audioSuspend()
|
||||
ChipAudio.ensureMusicPlaying()
|
||||
check(not src.playing, "ensureMusicPlaying stays silent while suspended")
|
||||
ChipAudio.setSuspended(false)
|
||||
ChipAudio.ensureMusicPlaying()
|
||||
check(src.playing, "ensureMusicPlaying recovers again once output is back")
|
||||
|
||||
Music.setVolumeLevel(7)
|
||||
Music.setFilterLevel(0)
|
||||
local before = #sources
|
||||
audioSuspend()
|
||||
audioReset()
|
||||
eq(#sources, before + 1, "the reset built exactly one replacement source")
|
||||
local fresh = ChipAudio.currentSource()
|
||||
check(fresh ~= nil and fresh ~= src, "rebuildPlayback swapped in a fresh source")
|
||||
check(not src.playing, "the source from the dead device was stopped")
|
||||
eq(fresh.free, BUFFERS, "the replacement source starts empty")
|
||||
|
||||
deliverBuffer()
|
||||
ChipAudio.update()
|
||||
eq(fresh.free, BUFFERS - 1,
|
||||
"worker PCM tagged with the pre-reset gen and stereo epoch still queues")
|
||||
check(fresh.playing, "playback resumes on the first requeued buffer")
|
||||
|
||||
deliverBuffer(lastEpoch + 1)
|
||||
ChipAudio.update()
|
||||
eq(fresh.free, BUFFERS - 1,
|
||||
"a buffer from another stereo epoch is still dropped after the reset")
|
||||
|
||||
check(fresh.volume ~= nil, "Music re-applied volume to the replacement source")
|
||||
check((fresh.filters or 0) > 0, "Music re-applied the filter to it as well")
|
||||
Music.setVolumeLevel(0)
|
||||
eq(fresh.volume, 0, "the replacement source is what Music now drives")
|
||||
check(src.volume ~= 0, "the dead source is no longer Music's source")
|
||||
Music.setVolumeLevel(7)
|
||||
|
||||
local first = Sound.play(data, "Press_AB")
|
||||
check(first ~= nil and first.playing, "the sfx played")
|
||||
Sound.onDeviceReset()
|
||||
local second = Sound.play(data, "Press_AB")
|
||||
check(second ~= nil and second ~= first,
|
||||
"the sfx cache is empty after onDeviceReset; the next play re-renders")
|
||||
|
||||
Music.stop()
|
||||
before = #sources
|
||||
audioReset()
|
||||
audioReset()
|
||||
eq(#sources, before, "an audioreset with no music playing builds nothing")
|
||||
check(ChipAudio.currentSource() == nil, "and leaves no music behind")
|
||||
|
||||
Music.play(data, "Music_Streamed")
|
||||
local intro, loop = sources[#sources - 1], sources[#sources]
|
||||
check(intro ~= nil and intro.playing, "the streamed intro is sounding")
|
||||
check(loop ~= nil and not loop.playing, "its loop body waits its turn")
|
||||
before = #sources
|
||||
audioSuspend()
|
||||
audioReset()
|
||||
eq(#sources, before + 2, "the reset rebuilt both file sources")
|
||||
local newIntro, newLoop = sources[#sources - 1], sources[#sources]
|
||||
check(newIntro ~= intro and newIntro.playing,
|
||||
"the streamed song plays again from the top")
|
||||
check(newLoop ~= loop and not newLoop.playing,
|
||||
"its rebuilt loop body still waits its turn")
|
||||
check(not intro.playing, "the streamed source from the dead device was stopped")
|
||||
eq(Music.current(), "Music_Streamed", "and the song label is unchanged")
|
||||
|
||||
ChipAudio = freshChipAudio(false)
|
||||
Music.reload()
|
||||
clearSources()
|
||||
Music.playMap(data, "PALLET_TOWN", false, false)
|
||||
local syncSrc = lastSource()
|
||||
check(syncSrc and syncSrc.queueable and syncSrc.free < BUFFERS,
|
||||
"the sync path fills its queue on the frame the song starts")
|
||||
local filled = syncSrc.free
|
||||
audioSuspend()
|
||||
ChipAudio.update()
|
||||
eq(syncSrc.free, filled, "a suspended update fills nothing on the sync path")
|
||||
|
||||
audioReset()
|
||||
local syncFresh = ChipAudio.currentSource()
|
||||
check(syncFresh ~= syncSrc, "the sync path swaps in a fresh source too")
|
||||
check(syncFresh.free < BUFFERS and syncFresh.playing,
|
||||
"and refills and starts it right away")
|
||||
check(not syncSrc.playing, "the sync source from the dead device was stopped")
|
||||
|
||||
local function source(path)
|
||||
local f = io.open(path, "rb")
|
||||
check(f ~= nil, path .. " is readable")
|
||||
local text = f and f:read("*a") or ""
|
||||
if f then f:close() end
|
||||
return text
|
||||
end
|
||||
|
||||
local mainSrc = source("main.lua")
|
||||
local suspendHook =
|
||||
mainSrc:match("\nfunction love%.handlers%.audiosuspend%(%).-\nend\n")
|
||||
check(suspendHook ~= nil, "main.lua defines love.handlers.audiosuspend")
|
||||
suspendHook = suspendHook or ""
|
||||
check(suspendHook:find("setSuspended, true", 1, true) ~= nil,
|
||||
"the suspend handler gates ChipAudio")
|
||||
|
||||
local resetHook =
|
||||
mainSrc:match("\nfunction love%.handlers%.audioreset%(%).-\nend\n")
|
||||
check(resetHook ~= nil, "main.lua defines love.handlers.audioreset")
|
||||
resetHook = resetHook or ""
|
||||
local unGate = resetHook:find("setSuspended, false", 1, true)
|
||||
local rebuild = resetHook:find("rebuildPlayback", 1, true)
|
||||
local music = resetHook:find("Music.onDeviceReset", 1, true)
|
||||
local sound = resetHook:find("Sound.onDeviceReset", 1, true)
|
||||
check(unGate and rebuild and unGate < rebuild,
|
||||
"the reset handler lifts the gate before rebuilding playback")
|
||||
check(rebuild and music and rebuild < music,
|
||||
"Music re-binds after ChipAudio swapped the source in")
|
||||
check(music and sound and music < sound, "and the sfx cache is flushed last")
|
||||
check(resetHook:find('package.loaded["src.core.ChipAudio"]', 1, true) ~= nil,
|
||||
"the handlers cost a session that never touched audio nothing")
|
||||
|
||||
local workerSrc = source("src/core/chip_worker.lua")
|
||||
check(workerSrc:find("outCh:getCount() < LOOKAHEAD", 1, true) ~= nil,
|
||||
"the chip worker bounds its look-ahead instead of free-running")
|
||||
|
||||
T.finish("audio device reset")
|
||||
@@ -126,6 +126,9 @@ end
|
||||
|
||||
-- ---- and the draw picks the right table -----------------------------------
|
||||
do
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local savedColors = PaletteFX.mode
|
||||
PaletteFX.setMode("redpp")
|
||||
local G = love.graphics
|
||||
local realDraw = G.draw
|
||||
local blits
|
||||
@@ -147,6 +150,7 @@ do
|
||||
"with the doll's own sprite deciding which, the way SetFacingBigDoll does")
|
||||
|
||||
G.draw = realDraw
|
||||
PaletteFX.setMode(savedColors)
|
||||
end
|
||||
|
||||
-- ---- WillObjectIntersectBigObject -----------------------------------------
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
-- Gen 2 time-dependent fishing encounters (TimeFishGroups).
|
||||
--
|
||||
-- luajit tests/gen2_fishing_time_test.lua
|
||||
--
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local S = require("tests.harness").suite("gen2 fishing time")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local World = require("src.world.gen2.World")
|
||||
local Encounter = require("src.battle.gen2.Encounter")
|
||||
local Permissions = require("src.world.gen2.Permissions")
|
||||
|
||||
local COLL_FLOOR, COLL_WATER = 0x00, 0x29
|
||||
local MAP_W, MAP_H = 10, 10
|
||||
|
||||
local function mon(name, level)
|
||||
return { species = name, id = name, name = name, baseStats = { hp = 50 }, level = level or 10 }
|
||||
end
|
||||
|
||||
local function fakeData()
|
||||
return {
|
||||
pokemon = {
|
||||
MAGIKARP = mon("MAGIKARP"),
|
||||
KRABBY = mon("KRABBY"),
|
||||
KINGLER = mon("KINGLER"),
|
||||
CORSOLA = mon("CORSOLA"),
|
||||
STARYU = mon("STARYU"),
|
||||
SHELLDER = mon("SHELLDER"),
|
||||
CHINCHOU = mon("CHINCHOU"),
|
||||
LANTURN = mon("LANTURN"),
|
||||
TENTACRUEL = mon("TENTACRUEL"),
|
||||
},
|
||||
items = {
|
||||
OLD_ROD = { keyItem = true },
|
||||
GOOD_ROD = { keyItem = true },
|
||||
SUPER_ROD = { keyItem = true },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
local function fishWorld(mapId, fishGroup, daytime)
|
||||
local map = {
|
||||
id = mapId,
|
||||
def = { id = mapId, width = MAP_W, height = MAP_H,
|
||||
environment = "ROUTE", fishGroup = fishGroup },
|
||||
cellCollision = function(self, cx, cy)
|
||||
return (cx == 5 and cy == 4) and COLL_WATER or COLL_FLOOR
|
||||
end,
|
||||
}
|
||||
local game = {
|
||||
save = {
|
||||
timeOfDay = daytime or "DAY",
|
||||
dailyFlags = {},
|
||||
player = { cellX = 5, cellY = 5, facing = "up" },
|
||||
},
|
||||
data = fakeData(),
|
||||
}
|
||||
local world = World.new(game)
|
||||
world.map = map
|
||||
world.maps = { [mapId] = map.def }
|
||||
world.player = game.save.player
|
||||
world.playerState = 0
|
||||
world.tod = daytime or "DAY"
|
||||
world.daytime = daytime or "DAY"
|
||||
return world, game
|
||||
end
|
||||
|
||||
-- Test Shore group with TimeFishGroups (Corsola / Staryu)
|
||||
local SHORE_ENCOUNTERS = {
|
||||
fishGroups = {
|
||||
FISHGROUP_SHORE = {
|
||||
id = "FISHGROUP_SHORE",
|
||||
chance = 255, -- always bite
|
||||
old = {
|
||||
{ chance = 179, species = "MAGIKARP", level = 10 },
|
||||
{ chance = 217, species = "MAGIKARP", level = 10 },
|
||||
{ chance = 255, species = "KRABBY", level = 10 },
|
||||
},
|
||||
good = {
|
||||
{ chance = 89, species = "MAGIKARP", level = 20 },
|
||||
{ chance = 178, species = "KRABBY", level = 20 },
|
||||
{ chance = 230, species = "KRABBY", level = 20 },
|
||||
{ chance = 255, species = "CORSOLA", level = 20, timeGroup = 0,
|
||||
day = { species = "CORSOLA", level = 20 },
|
||||
nite = { species = "STARYU", level = 20 } },
|
||||
},
|
||||
super = {
|
||||
{ chance = 102, species = "KRABBY", level = 40 },
|
||||
{ chance = 178, species = "CORSOLA", level = 40, timeGroup = 1,
|
||||
day = { species = "CORSOLA", level = 40 },
|
||||
nite = { species = "STARYU", level = 40 } },
|
||||
{ chance = 230, species = "KRABBY", level = 40 },
|
||||
{ chance = 255, species = "KINGLER", level = 40 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- Test Ocean group with TimeFishGroups (Shellder)
|
||||
local OCEAN_ENCOUNTERS = {
|
||||
fishGroups = {
|
||||
FISHGROUP_OCEAN = {
|
||||
id = "FISHGROUP_OCEAN",
|
||||
chance = 255,
|
||||
old = {
|
||||
{ chance = 179, species = "MAGIKARP", level = 10 },
|
||||
{ chance = 217, species = "MAGIKARP", level = 10 },
|
||||
{ chance = 255, species = "TENTACOOL", level = 10 },
|
||||
},
|
||||
good = {
|
||||
{ chance = 89, species = "MAGIKARP", level = 20 },
|
||||
{ chance = 178, species = "TENTACOOL", level = 20 },
|
||||
{ chance = 230, species = "CHINCHOU", level = 20 },
|
||||
{ chance = 255, species = "SHELLDER", level = 20, timeGroup = 2,
|
||||
day = { species = "SHELLDER", level = 20 },
|
||||
nite = { species = "SHELLDER", level = 20 } },
|
||||
},
|
||||
super = {
|
||||
{ chance = 102, species = "CHINCHOU", level = 40 },
|
||||
{ chance = 178, species = "SHELLDER", level = 40, timeGroup = 3,
|
||||
day = { species = "SHELLDER", level = 40 },
|
||||
nite = { species = "SHELLDER", level = 40 } },
|
||||
{ chance = 230, species = "TENTACRUEL", level = 40 },
|
||||
{ chance = 255, species = "LANTURN", level = 40 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- ---- Direct Encounter.fishSlot tests ---------------------------------------
|
||||
do
|
||||
-- Shore Good Rod at value 240 (slot 4) during DAY -> Corsola lv20
|
||||
local rollDay = Encounter.fishSlot(SHORE_ENCOUNTERS, "ROUTE_34", "GOOD_ROD",
|
||||
function(_n) return 240 end, { ROUTE_34 = { fishGroup = "FISHGROUP_SHORE" } }, nil, "DAY")
|
||||
check(rollDay ~= nil, "Shore Good Rod bites during DAY")
|
||||
eq(rollDay.species, "CORSOLA", "Shore Good Rod slot 4 is CORSOLA during DAY")
|
||||
eq(rollDay.level, 20, "Shore Good Rod slot 4 level is 20")
|
||||
|
||||
-- Shore Good Rod at value 240 (slot 4) during NITE -> Staryu lv20
|
||||
local rollNite = Encounter.fishSlot(SHORE_ENCOUNTERS, "ROUTE_34", "GOOD_ROD",
|
||||
function(_n) return 240 end, { ROUTE_34 = { fishGroup = "FISHGROUP_SHORE" } }, nil, "NITE")
|
||||
check(rollNite ~= nil, "Shore Good Rod bites during NITE")
|
||||
eq(rollNite.species, "STARYU", "Shore Good Rod slot 4 is STARYU during NITE")
|
||||
eq(rollNite.level, 20, "Shore Good Rod slot 4 level is 20")
|
||||
|
||||
-- Shore Super Rod at value 150 (slot 2) during DAY -> Corsola lv40
|
||||
local rollSuperDay = Encounter.fishSlot(SHORE_ENCOUNTERS, "ROUTE_34", "SUPER_ROD",
|
||||
function(_n) return 150 end, { ROUTE_34 = { fishGroup = "FISHGROUP_SHORE" } }, nil, "DAY")
|
||||
check(rollSuperDay ~= nil, "Shore Super Rod bites during DAY")
|
||||
eq(rollSuperDay.species, "CORSOLA", "Shore Super Rod slot 2 is CORSOLA during DAY")
|
||||
eq(rollSuperDay.level, 40, "Shore Super Rod slot 2 level is 40")
|
||||
|
||||
-- Shore Super Rod at value 150 (slot 2) during NITE -> Staryu lv40
|
||||
local rollSuperNite = Encounter.fishSlot(SHORE_ENCOUNTERS, "ROUTE_34", "SUPER_ROD",
|
||||
function(_n) return 150 end, { ROUTE_34 = { fishGroup = "FISHGROUP_SHORE" } }, nil, "NITE")
|
||||
check(rollSuperNite ~= nil, "Shore Super Rod bites during NITE")
|
||||
eq(rollSuperNite.species, "STARYU", "Shore Super Rod slot 2 is STARYU during NITE")
|
||||
eq(rollSuperNite.level, 40, "Shore Super Rod slot 2 level is 40")
|
||||
|
||||
-- Ocean Super Rod at value 150 (slot 2) during DAY/NITE -> Shellder lv40
|
||||
local rollOceanSuper = Encounter.fishSlot(OCEAN_ENCOUNTERS, "ROUTE_41", "SUPER_ROD",
|
||||
function(_n) return 150 end, { ROUTE_41 = { fishGroup = "FISHGROUP_OCEAN" } }, nil, "DAY")
|
||||
check(rollOceanSuper ~= nil, "Ocean Super Rod bites")
|
||||
eq(rollOceanSuper.species, "SHELLDER", "Ocean Super Rod slot 2 is SHELLDER")
|
||||
eq(rollOceanSuper.level, 40, "Ocean Super Rod slot 2 level is 40")
|
||||
end
|
||||
|
||||
-- ---- World:rollFishing integration tests -----------------------------------
|
||||
do
|
||||
-- World with Shore group in DAY time
|
||||
local worldDay, _ = fishWorld("ROUTE_34", "FISHGROUP_SHORE", "DAY")
|
||||
worldDay.encounters = SHORE_ENCOUNTERS
|
||||
|
||||
-- Mock math.random / love.math.random to land on slot 2 of super rod (value ~150)
|
||||
love.math.random = function(n) return 151 end
|
||||
local outcome, wild = worldDay:rollFishing("SUPER_ROD")
|
||||
eq(outcome, "battle", "Super Rod triggers battle on Corsola slot")
|
||||
check(wild ~= nil, "Wild mon is created")
|
||||
eq(wild.species, "CORSOLA", "Wild mon is CORSOLA during DAY")
|
||||
eq(wild.level, 40, "Wild mon level is 40")
|
||||
|
||||
-- World with Shore group in NITE time
|
||||
local worldNite, _ = fishWorld("ROUTE_34", "FISHGROUP_SHORE", "NITE")
|
||||
worldNite.encounters = SHORE_ENCOUNTERS
|
||||
local outcomeN, wildN = worldNite:rollFishing("SUPER_ROD")
|
||||
eq(outcomeN, "battle", "Super Rod triggers battle on Staryu slot")
|
||||
check(wildN ~= nil, "Wild mon is created")
|
||||
eq(wildN.species, "STARYU", "Wild mon is STARYU during NITE")
|
||||
eq(wildN.level, 40, "Wild mon level is 40")
|
||||
|
||||
-- World with Ocean group -> Shellder
|
||||
local worldOcean, _ = fishWorld("ROUTE_41", "FISHGROUP_OCEAN", "DAY")
|
||||
worldOcean.encounters = OCEAN_ENCOUNTERS
|
||||
local outcomeO, wildO = worldOcean:rollFishing("SUPER_ROD")
|
||||
eq(outcomeO, "battle", "Super Rod triggers battle on Shellder slot")
|
||||
check(wildO ~= nil, "Wild mon is created")
|
||||
eq(wildO.species, "SHELLDER", "Wild mon is SHELLDER")
|
||||
eq(wildO.level, 40, "Wild mon level is 40")
|
||||
end
|
||||
|
||||
-- ---- Real Gold cache integration tests (if present) ------------------------
|
||||
do
|
||||
local cache = os.getenv("GOLD_CACHE")
|
||||
if not cache then
|
||||
local home = os.getenv("HOME") or ""
|
||||
cache = home .. "/.local/share/love/pokemon-love2d/gold"
|
||||
end
|
||||
local encChunk = loadfile(cache .. "/data/generated/encounters.lua")
|
||||
if not encChunk then
|
||||
check(true, "no gold cache: time fish groups (SKIP)")
|
||||
else
|
||||
local enc = encChunk() or {}
|
||||
local groups = enc.fishGroups or {}
|
||||
local shore = groups.FISHGROUP_SHORE
|
||||
check(shore ~= nil, "cache carries FISHGROUP_SHORE")
|
||||
if shore and shore.super then
|
||||
local slot2 = shore.super[2]
|
||||
check(slot2 ~= nil, "shore super rod has slot 2")
|
||||
if slot2 then
|
||||
eq(slot2.timeGroup, 1, "slot 2 timeGroup is 1")
|
||||
check(slot2.day ~= nil, "slot 2 has day entry")
|
||||
check(slot2.nite ~= nil, "slot 2 has nite entry")
|
||||
if slot2.day then eq(slot2.day.species, "CORSOLA", "day is CORSOLA") end
|
||||
if slot2.nite then eq(slot2.nite.species, "STARYU", "nite is STARYU") end
|
||||
end
|
||||
end
|
||||
|
||||
local ocean = groups.FISHGROUP_OCEAN
|
||||
check(ocean ~= nil, "cache carries FISHGROUP_OCEAN")
|
||||
if ocean and ocean.super then
|
||||
local slot2 = ocean.super[2]
|
||||
check(slot2 ~= nil, "ocean super rod has slot 2")
|
||||
if slot2 then
|
||||
eq(slot2.timeGroup, 3, "slot 2 timeGroup is 3")
|
||||
check(slot2.day ~= nil, "slot 2 has day entry")
|
||||
if slot2.day then eq(slot2.day.species, "SHELLDER", "day is SHELLDER") end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- ---- Animation & Bobber State Machine tests --------------------------------
|
||||
do
|
||||
local world, _ = fishWorld("ROUTE_34", "FISHGROUP_SHORE", "DAY")
|
||||
world.encounters = SHORE_ENCOUNTERS
|
||||
world.player.cellX = 5
|
||||
world.player.cellY = 5
|
||||
world.player.facing = "left"
|
||||
|
||||
local wildMon = { species = "CORSOLA", level = 40 }
|
||||
world:beginFishing("battle", wildMon)
|
||||
|
||||
check(world.fishing ~= nil, "Fishing state is initialized")
|
||||
eq(world.fishing.phase, "cast", "Initial phase is cast")
|
||||
eq(world.fishing.timer, 40, "Cast timer is 40 frames")
|
||||
check(world.fishing.bobber ~= nil, "Bobber is created")
|
||||
eq(world.fishing.bobber.cellX, 4, "Bobber target X is 4 (one cell left)")
|
||||
eq(world.fishing.bobber.cellY, 5, "Bobber target Y is 5")
|
||||
eq(world.fishing.bobber.px, 64, "Bobber px is 64")
|
||||
eq(world.fishing.bobber.py, 80, "Bobber py is 80")
|
||||
eq(world.player.fishing, true, "Player has fishing flag")
|
||||
|
||||
-- Step through cast phase (40 frames + transition tick)
|
||||
for f = 1, 40 + 1 do
|
||||
world:updateFishing()
|
||||
end
|
||||
eq(world.fishing.phase, "bite", "Transitions to bite phase on battle outcome")
|
||||
eq(world.fishing.timer, 40, "Bite timer is 40 frames")
|
||||
|
||||
-- Step through bite phase and check 1px alternating offset
|
||||
local seenOdd, seenEven = false, false
|
||||
for f = 1, 40 do
|
||||
world:updateFishing()
|
||||
if world.player.spriteYOffset == 1 then seenOdd = true end
|
||||
if world.player.spriteYOffset == 0 then seenEven = true end
|
||||
end
|
||||
check(seenOdd and seenEven, "Player sprite Y offset alternates during bite phase")
|
||||
-- Transition on next tick triggers text/battle and clears fishing state
|
||||
world:updateFishing()
|
||||
eq(world.fishing, nil, "Fishing state is cleared after bite completion")
|
||||
eq(world.player.fishing, nil, "Player fishing flag is cleared after bite completion")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -364,12 +364,6 @@ local r, g, b = shaded.data:getPixel(0, 0)
|
||||
check(r == 0 and g == 0 and b == 1,
|
||||
"a 4-shade pic is palette-quantized onto its shade bucket")
|
||||
|
||||
local full = battle:speciesSprite("FULLCOLOR", false)
|
||||
r, g, b = full.data:getPixel(0, 0)
|
||||
check(math.abs(r - 0.4) < 1e-6 and math.abs(g - 0.7) < 1e-6
|
||||
and math.abs(b - 0.9) < 1e-6,
|
||||
"a trueColor pic keeps a pixel no 4-shade palette contains")
|
||||
|
||||
-- trainers.trueColor is the same opt-out on a class portrait
|
||||
BattleState.invalidate()
|
||||
local trainerPicData = {
|
||||
@@ -386,6 +380,14 @@ local shadedTrainer = BattleState.trainerSprite(trainerPicData,
|
||||
r, g, b = shadedTrainer.data:getPixel(0, 0)
|
||||
check(r == 0 and g == 0 and b == 1,
|
||||
"a 4-shade trainer pic is palette-quantized onto its shade bucket")
|
||||
|
||||
local savedColors = PaletteFX.mode
|
||||
PaletteFX.setMode("redpp")
|
||||
local full = battle:speciesSprite("FULLCOLOR", false)
|
||||
r, g, b = full.data:getPixel(0, 0)
|
||||
check(math.abs(r - 0.4) < 1e-6 and math.abs(g - 0.7) < 1e-6
|
||||
and math.abs(b - 0.9) < 1e-6,
|
||||
"a trueColor pic keeps a pixel no 4-shade palette contains")
|
||||
local fullTrainer = BattleState.trainerSprite(trainerPicData,
|
||||
trainerPicData.trainers.FULLCOLOR)
|
||||
r, g, b = fullTrainer.data:getPixel(0, 0)
|
||||
@@ -395,6 +397,7 @@ check(math.abs(r - 0.4) < 1e-6 and math.abs(g - 0.7) < 1e-6
|
||||
check(BattleState.trainerTrueColor(trainerPicData,
|
||||
trainerPicData.trainers.REUSED) == true,
|
||||
"a basePic reuse inherits the base portrait's trueColor flag")
|
||||
PaletteFX.setMode(savedColors)
|
||||
|
||||
-- ------- trueColor: the colors == false zone sentinel
|
||||
|
||||
@@ -434,6 +437,8 @@ check(bareDraw.shader == false,
|
||||
-- covered and endFrame splices it in. Driven through the real draw path
|
||||
-- rather than by handing endFrame a hand-built zone.
|
||||
|
||||
savedColors = PaletteFX.mode
|
||||
PaletteFX.setMode("redpp")
|
||||
local GRAYS = PaletteFX.GRAYS
|
||||
local function canvasDraws(canvas)
|
||||
local drawn = {}
|
||||
@@ -625,6 +630,7 @@ check(#PaletteFX.trueColorRects("world") == 0,
|
||||
"the same tileset without the flag reports nothing")
|
||||
Renderer:endWorldPass()
|
||||
Renderer:endFrame({ PaletteFX.whole(GRAYS) }, fullWorldZones())
|
||||
PaletteFX.setMode(savedColors)
|
||||
|
||||
-- ------- font pages and charmap ordering
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
if not _G.love then
|
||||
_G.love = {
|
||||
audio = {
|
||||
newSource = function() return { stop = function() end, play = function() end, setVolume = function() end } end
|
||||
}
|
||||
}
|
||||
end
|
||||
local SurfingMinigame = require("src.ui.SurfingMinigame")
|
||||
|
||||
local function assert_eq(got, want, msg)
|
||||
if got ~= want then
|
||||
error(string.format("%s: got %s, want %s", msg or "assertion failed", tostring(got), tostring(want)))
|
||||
end
|
||||
end
|
||||
|
||||
local function assert_true(cond, msg)
|
||||
if not cond then
|
||||
error(string.format("%s: expected true", msg or "assertion failed"))
|
||||
end
|
||||
end
|
||||
|
||||
-- Mock game environment
|
||||
local mockInput = {
|
||||
keysDown = {},
|
||||
keysPressed = {},
|
||||
isDown = function(self, k) return not not self.keysDown[k] end,
|
||||
wasPressed = function(self, k) return not not self.keysPressed[k] end,
|
||||
}
|
||||
|
||||
local mockGame = {
|
||||
save = { surfingHighScore = 1000 },
|
||||
input = mockInput,
|
||||
stack = {
|
||||
items = {},
|
||||
pop = function(self) table.remove(self.items) end,
|
||||
push = function(self, item) table.insert(self.items, item) end,
|
||||
},
|
||||
data = { audio = { sfx = {} } },
|
||||
}
|
||||
|
||||
print("Running SurfingMinigame unit tests...")
|
||||
|
||||
-- Test 1: Initialization
|
||||
local mg = SurfingMinigame.new(mockGame)
|
||||
assert_eq(mg.hp, 6000, "Initial HP must be 6000 (60.00s)")
|
||||
assert_eq(mg.speed, 0.25, "Initial speed must be 0.25")
|
||||
assert_eq(mg.distance, 0, "Initial distance must be 0")
|
||||
assert_eq(mg.routine, 0, "Initial routine must be ROUTINE_START_GAME (0)")
|
||||
assert_eq(mg.pikaState, 0, "Initial Pikachu state must be PIKA_STATE_RIDING (0)")
|
||||
print("✓ Initial state test passed")
|
||||
|
||||
-- Test 2: Start banner transition to RunGame
|
||||
for _ = 1, 40 do
|
||||
mg:update()
|
||||
end
|
||||
assert_eq(mg.routine, 1, "Routine should advance to ROUTINE_RUN_GAME (1)")
|
||||
print("✓ Start banner transition test passed")
|
||||
|
||||
-- Test 3: Automatic acceleration and HP countdown
|
||||
local initialSpeed = mg.speed
|
||||
local initialHp = mg.hp
|
||||
mg:update()
|
||||
assert_true(mg.speed > initialSpeed, "Pikachu should automatically accelerate while riding")
|
||||
assert_eq(mg.hp, initialHp - 1, "HP should decrease by 1 each frame")
|
||||
print("✓ Auto acceleration and HP countdown test passed")
|
||||
|
||||
-- Test 4: Landing Evaluation Matrix
|
||||
mg.frameSet = 5
|
||||
assert_eq(mg:evaluateLanding(), "rough", "Angle 5 on open water should be rough landing")
|
||||
mg.frameSet = 6
|
||||
assert_eq(mg:evaluateLanding(), "hard", "Angle 6 on open water should be hard landing")
|
||||
mg.frameSet = 4
|
||||
assert_eq(mg:evaluateLanding(), "clean", "Angle 4 (flat) on open water should be clean landing")
|
||||
mg.frameSet = 1
|
||||
assert_eq(mg:evaluateLanding(), "wipeout", "Angle 1 on open water should be wipeout")
|
||||
for f = 8, 14 do
|
||||
mg.frameSet = f
|
||||
assert_eq(mg:evaluateLanding(), "wipeout", "Upside-down frame " .. f .. " must be wipeout")
|
||||
end
|
||||
print("✓ Landing evaluation matrix test passed (including upside-down frames 8..14)")
|
||||
|
||||
-- Test 5: Stunt Scoring
|
||||
mg.radnessMeter = 1
|
||||
mg.trickFlags = 1
|
||||
mg.radness = 0
|
||||
mg:calculateStuntPoints()
|
||||
assert_eq(mg.radness, 50, "Single flip should award +50 radness points")
|
||||
|
||||
mg.radnessMeter = 2
|
||||
mg.trickFlags = 1
|
||||
mg.radness = 0
|
||||
mg:calculateStuntPoints()
|
||||
assert_eq(mg.radness, 150, "Double flip (same direction) should award +150 points")
|
||||
|
||||
mg.radnessMeter = 3
|
||||
mg.trickFlags = 1
|
||||
mg.radness = 0
|
||||
mg:calculateStuntPoints()
|
||||
assert_eq(mg.radness, 350, "Triple flip (same direction) should award +350 points")
|
||||
|
||||
mg.radnessMeter = 2
|
||||
mg.trickFlags = 3
|
||||
mg.radness = 0
|
||||
mg:calculateStuntPoints()
|
||||
assert_eq(mg.radness, 180, "Double flip (mixed) should award +180 points")
|
||||
|
||||
mg.radnessMeter = 3
|
||||
mg.trickFlags = 3
|
||||
mg.radness = 0
|
||||
mg:calculateStuntPoints()
|
||||
assert_eq(mg.radness, 500, "Triple flip (mixed) should award +500 points")
|
||||
print("✓ Stunt scoring calculation test passed")
|
||||
|
||||
-- Test 6: Non-fatal wipeout crash recovery
|
||||
mg.pikaState = 3 -- PIKA_STATE_CRASHED
|
||||
mg.crashTimer = 96
|
||||
mg.speed = 0.25
|
||||
for _ = 1, 95 do
|
||||
mg:update()
|
||||
assert_eq(mg.pikaState, 3, "Pikachu should remain in crashed state during timer")
|
||||
end
|
||||
mg:update()
|
||||
assert_eq(mg.pikaState, 0, "Pikachu should recover and return to PIKA_STATE_RIDING after 96 frames")
|
||||
print("✓ Wipeout crash recovery test passed")
|
||||
|
||||
-- Test 7: Results tally countdown sequence
|
||||
mg.routine = 7 -- ROUTINE_WRITE_TOTAL
|
||||
mg.hp = 100
|
||||
mg.radness = 200
|
||||
mg.totalScore = 0
|
||||
mg.routineTimer = 1
|
||||
mg:update()
|
||||
assert_eq(mg.routine, 8, "Routine should advance to ROUTINE_ADD_HP_TOTAL (8)")
|
||||
|
||||
while mg.routine == 8 do
|
||||
mg:update()
|
||||
end
|
||||
assert_eq(mg.hp, 0, "HP should be tallied down to 0")
|
||||
assert_eq(mg.totalScore, 100, "Total score should include 100 from HP")
|
||||
assert_eq(mg.routine, 9, "Routine should advance to ROUTINE_ADD_RAD_TOTAL (9)")
|
||||
|
||||
while mg.routine == 9 do
|
||||
mg:update()
|
||||
end
|
||||
assert_eq(mg.radness, 0, "Radness should be tallied down to 0")
|
||||
assert_eq(mg.totalScore, 300, "Total score should be 300 (100 HP + 200 Radness)")
|
||||
assert_eq(mg.routine, 10, "Routine should advance to ROUTINE_WAIT_LAST (10)")
|
||||
print("✓ Results tally countdown test passed")
|
||||
|
||||
print("All SurfingMinigame unit tests passed successfully!")
|
||||
Reference in New Issue
Block a user