squassshheee

CLOSES #128
CLOSES #114
CLOSES #113
CLOSES #111
CLOSES #110
CLOSES #109
CLOSES #107
CLOSES #106
CLOSES #105
CLOSES #90
CLOSES #103
This commit is contained in:
bryanthaboi
2026-07-23 16:11:27 -04:00
parent bfa68e6f7d
commit d2f6735b0a
35 changed files with 1248 additions and 171 deletions
+7
View File
@@ -64,6 +64,13 @@ return function(game)
check(warped, "YES warps to REDS_HOUSE_2F")
check(ow.player.cellX == 3 and ow.player.cellY == 6,
"landed on the bedroom spawn (3,6)")
check(ow.lastOutdoor and ow.lastOutdoor.id == "PALLET_TOWN",
"PC home warp retargets lastOutdoor to Pallet (#103)")
local lead = game.save.party and game.save.party[1]
if lead and lead.stats then
check(lead.hp == lead.stats.hp and lead.status == nil,
"PC home warp heals the party")
end
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
end
+2 -15
View File
@@ -4165,21 +4165,8 @@ function ops.fieldMove(s, where)
say(("fieldMove %s: nobody in the party knows it"):format(move))
return false
end
-- The knower must also be CONSCIOUS: the party submenu hides field
-- moves on a fainted mon (vanilla -- partyKnows requires hp > 0), so
-- the menu walk below finds only STATS/SWITCH and fails. Route 23's
-- mandatory surf strip is where this bit: WARTORTLE arrived dead from
-- the Viridian Gym attrition, "no SURF entry" x3, the water was never
-- crossed, and the whole league skip-cascaded with all eight badges
-- in the bag. Heal, fail this attempt loudly, and let the caller's
-- retry run the step again with a live user.
local knower = (G.save.party or {})[slot]
if knower and (knower.hp or 0) <= 0 then
say(("fieldMove %s: its only user has fainted -- visiting a centre "
.. "before retrying"):format(move))
visitPokeCenter(where)
return false
end
-- Field moves work from fainted users in Gen 1 (PartyMenu + partyKnows
-- no longer gate on hp > 0). No Poké Center detour needed here.
-- CUT acts on the tile the player is FACING (OverworldState:tryCut), and
-- a `goto` leaves us facing whatever direction we last stepped. Without
-- turning to the tree first the move fires into empty ground, the game
+6 -1
View File
@@ -127,7 +127,12 @@ stub.sound = {
stub.keyboard = { isDown = function() return false end }
stub.mouse = { getPosition = function() return 0, 0 end }
stub.mouse = {
getPosition = function() return 0, 0 end,
isCursorSupported = function() return false end,
getSystemCursor = function(name) return name end,
setCursor = function() end,
}
stub.timer = { getTime = function() return 0 end }
+5
View File
@@ -320,6 +320,9 @@ check(vanillaSave.player.map == "REDS_HOUSE_2F" and vanillaSave.player.x == 3
"the seeded boot config reproduces the NewGameWarp bedroom spawn")
check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE"
and vanillaSave.money == 3000, "the seeded boot config reproduces the Red start")
-- issue #109: vanilla New Game puts 1 Potion in the player's item PC
check(vanillaSave.pcItems and vanillaSave.pcItems.POTION == 1,
"new game seeds 1 Potion in the player's PC")
-- the heal point is deliberately NOT the spawn: wLastBlackoutMap is
-- zero-filled at new game and PALLET_TOWN is map 0
check(vanillaSave.lastHeal.map == "PALLET_TOWN" and vanillaSave.lastHeal.x == 5
@@ -329,6 +332,8 @@ local bareSave = SaveData.newGame()
check(bareSave.player.map == "REDS_HOUSE_2F" and bareSave.money == 3000
and bareSave.lastHeal.map == "PALLET_TOWN",
"newGame without a boot config is unchanged")
check(bareSave.pcItems and bareSave.pcItems.POTION == 1,
"newGame without a boot config still seeds the PC Potion")
local bootData, bootLoader = withMod("total_conversion", [[
return function(mod)
+27
View File
@@ -618,6 +618,33 @@ check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE,
"RED++ still has ROUTE (aliased from VIRIDIAN)")
check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON,
"RED++ passes zone colors through like GBC")
-- issue #128: RED++'s gbc pack is Red-derived; Blue must keep ROM LOGO1
-- (and the Blue-only SLOTS* rows) so the title ribbon is blue, not red
do
local GameVersion = require("src.core.GameVersion")
local prevVer = GameVersion.get()
local blueLogo1 = {
{ 255, 239, 255 }, { 247, 247, 140 }, { 173, 0, 33 }, { 115, 156, 239 },
}
local blueSlots2 = {
{ 255, 239, 255 }, { 255, 255, 140 }, { 132, 156, 239 }, { 25, 16, 16 },
}
local rom = { palettes = { palettes = {
LOGO1 = blueLogo1, SLOTS2 = blueSlots2, ROUTE = { { 1, 2, 3 }, { 0, 0, 0 },
{ 0, 0, 0 }, { 0, 0, 0 } },
} } }
GameVersion.set("blue")
check(PaletteFX.pal(rom, "LOGO1") == blueLogo1,
"Blue + RED++ prefers ROM LOGO1 over the Red gbc pack")
check(PaletteFX.pal(rom, "SLOTS2") == blueSlots2,
"Blue + RED++ prefers ROM SLOTS2 over the Red gbc pack")
check(PaletteFX.pal(rom, "ROUTE") == gbc.palettes.ROUTE,
"Blue + RED++ still uses the gbc pack for shared names")
GameVersion.set("red")
check(PaletteFX.pal(rom, "LOGO1") == gbc.palettes.LOGO1,
"Red + RED++ keeps the gbc-pack LOGO1 (title stays red)")
GameVersion.set(prevVer)
end
PaletteFX.setMode(prevMode)
-- ------- the transitions registry
+26
View File
@@ -536,6 +536,32 @@ do
"newGame starts an empty modData")
check(plain.meta.format == Version.saveFormat and #plain.meta.mods == 0,
"newGame stamps a vanilla meta")
check(plain.pcItems and plain.pcItems.POTION == 1,
"unhooked newGame seeds 1 Potion in pcItems (issue #109)")
end
-- issue #109: loading a pre-fix save that never had pcItems must not
-- invent a free Potion (player may already have withdrawn/tossed it, or
-- the empty PC is intentional). Seeding is New Game only.
do
local files = {}
love.filesystem = memfs(files)
local legacy = {
meta = { format = Version.saveFormat, mods = {} },
player = { map = "PALLET_TOWN", x = 5, y = 6, facing = "down",
name = "RED", rival = "BLUE", id = 1 },
flags = {}, inventory = {}, party = {}, box = {}, money = 3000,
defeatedTrainers = {}, pokedex = { seen = {}, owned = {} },
lastHeal = { map = "PALLET_TOWN", x = 5, y = 6 },
options = SaveData.defaultOptions(),
}
check(legacy.pcItems == nil, "fixture omits pcItems on purpose")
check(SaveData.save(legacy), "legacy save without pcItems writes")
local loaded = SaveData.load()
check(loaded ~= nil, "legacy save loads")
check(loaded.pcItems == nil or loaded.pcItems.POTION == nil,
"load does not invent a PC Potion for existing saves")
love.filesystem = realFS
end
-- a throwing mod migration is skipped, never fatal: an uncaught error here
+32
View File
@@ -475,6 +475,38 @@ check(title.version and title.version.path
== "assets/generated/title/red_version.png",
"the shipped version ribbon loads")
-- issue #128: title SGB zones must resolve Blue's LOGO1 (blue ribbon),
-- not Red's, when the ROM pack carries Blue SuperPalettes
do
local GameVersion = require("src.core.GameVersion")
local PaletteFX = require("src.render.PaletteFX")
local prevVer, prevMode = GameVersion.get(), PaletteFX.mode
local blueLogo1 = {
{ 255, 239, 255 }, { 247, 247, 140 }, { 173, 0, 33 }, { 115, 156, 239 },
}
local mewmon = {
{ 255, 239, 255 }, { 247, 181, 140 }, { 132, 115, 156 }, { 25, 16, 16 },
}
local logo2 = {
{ 255, 239, 255 }, { 247, 247, 140 }, { 148, 148, 197 }, { 58, 58, 132 },
}
local game = { data = { palettes = { palettes = {
LOGO1 = blueLogo1, LOGO2 = logo2, MEWMON = mewmon,
} } } }
PaletteFX.setMode("redpp")
GameVersion.set("blue")
local zones = TitleState.sgbPalettes(title, game)
check(zones and zones[2] and zones[2].colors == blueLogo1,
"Blue title ribbon zone uses ROM LOGO1 under RED++")
GameVersion.set("red")
zones = TitleState.sgbPalettes(title, game)
local gbcLogo1 = PaletteFX.gbcPack().palettes.LOGO1
check(zones and zones[2] and zones[2].colors == gbcLogo1,
"Red title under RED++ keeps gbc-pack LOGO1 even if ROM has Blue's")
GameVersion.set(prevVer)
PaletteFX.setMode(prevMode)
end
local OakSpeech = require("src.ui.OakSpeech")
local ogame = { data = {
field = { oakSpeech = { music = "X_Song", demoSpecies = "PIKACHU" } },
+13 -2
View File
@@ -281,11 +281,21 @@ end
-- the digit only), plus the LIFT_KEY gate.
-- ===================================================================
do
-- without the key: text-only, no floor menu
local gated = openElevator("ROCKET_HIDEOUT_ELEVATOR", {})
local hideoutSnap = snapshotWarps("ROCKET_HIDEOUT_ELEVATOR")
-- ROM car defaults to B1F; entering from another floor without the key
-- must still seed a walk-out back to that floor (#90 / #105).
eq(hideoutSnap[1].destMap, "ROCKET_HIDEOUT_B1F",
"Rocket Hideout ROM car warps still default to B1F")
-- without the key: text-only, no floor menu, but exit is seeded
local gated, _, _, gatedOw =
openElevator("ROCKET_HIDEOUT_ELEVATOR", {}, "ROCKET_HIDEOUT_B2F")
check(gated ~= nil, "Rocket Hideout without LIFT_KEY still pushes something")
check(gated ~= nil and getmetatable(gated) ~= ListMenu,
"Rocket Hideout without LIFT_KEY does not open the floor menu")
eq(gatedOw.map.def.warps[1].destMap, "ROCKET_HIDEOUT_B2F",
"Rocket Hideout without LIFT_KEY seeds exit warps to the entry floor")
restoreWarps("ROCKET_HIDEOUT_ELEVATOR", hideoutSnap)
-- with the key: full B1F/B2F/B4F menu, same as the other elevators
local menu, warpCalls, stack, ow = openElevator("ROCKET_HIDEOUT_ELEVATOR", { LIFT_KEY = 1 })
@@ -324,6 +334,7 @@ do
eq(warpCalls[1].y, chosen.value.y, "Rocket Hideout walk-out lands on the chosen floor y")
end
end
restoreWarps("ROCKET_HIDEOUT_ELEVATOR", hideoutSnap)
end
Sound.play = origSoundPlay
+18
View File
@@ -69,6 +69,12 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")),
eq(Game.save.inventory.OAKS_PARCEL, nil, "parcel delivered")
check(Flags.get(Game.save, "EVENT_OAK_GOT_PARCEL"), "delivery flag set")
check(Flags.get(Game.save, "EVENT_GOT_POKEDEX"), "Pokedex flag set")
-- OaksLab.asm OakGivesPokedex: HideObject TOGGLE_POKEDEX_1/2 (#106)
local labToggles = Game.save.objectToggles and Game.save.objectToggles.OAKS_LAB
check(labToggles and labToggles.OAKSLAB_POKEDEX1 == false,
"POKEDEX1 hidden after receiving Pokédex")
check(labToggles and labToggles.OAKSLAB_POKEDEX2 == false,
"POKEDEX2 hidden after receiving Pokédex")
eq(Game.save.inventory.POKE_BALL, nil, "still no POKe BALLs at the pokedex beat")
-- talking to Oak again before beating the Route 22 rival should fall
@@ -89,4 +95,16 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")),
"Oak talk (post-grant) script completes")
eq(Game.save.inventory.POKE_BALL, 5, "POKe Ball count unchanged on a second talk")
-- === 5) onEnter re-hides table Pokédex for pre-#106 saves ===
Game.save.objectToggles = { OAKS_LAB = {
OAKSLAB_POKEDEX1 = true, OAKSLAB_POKEDEX2 = true,
} }
local oaksLab = mapScripts.get("OAKS_LAB")
check(oaksLab and type(oaksLab.onEnter) == "function", "OAKS_LAB onEnter present")
oaksLab.onEnter(Game, nil)
local repaired = Game.save.objectToggles.OAKS_LAB
check(repaired.OAKSLAB_POKEDEX1 == false
and repaired.OAKSLAB_POKEDEX2 == false,
"onEnter hides both Pokédex table sprites when EVENT_GOT_POKEDEX is set")
S.finish()
+49
View File
@@ -408,6 +408,55 @@ check(strFlash ~= page2 and strFlash ~= ow and strFlash.pages == nil,
for _ = 1, 10 do frame({}) end
eq(Game.stack:top(), ow, "back on the map after the blink")
-- ===========================================================================
-- #107: fainted Pokémon can still use field moves (party submenu + partyKnows)
-- ===========================================================================
local fainted = mkMon("PIDGEOTTO", "FLY", "CUT", "STRENGTH", "SURF")
fainted.hp = 0
Game.save.party = { fainted }
Game.save.inventory = {
THUNDERBADGE = true, CASCADEBADGE = true,
RAINBOWBADGE = true, SOULBADGE = true,
}
ow = pushOW("PALLET_TOWN", 4, 13, "down")
check(ow:partyKnows("SURF") == fainted,
"partyKnows finds SURF on a fainted mon")
check(ow:partyKnows("CUT") == fainted,
"partyKnows finds CUT on a fainted mon")
local pmFaint = PartyMenu.new(Game)
Game.stack:push(pmFaint)
frame({ "a" })
local actsFaint = submenuActions(pmFaint)
-- FLY needs OVERWORLD tileset (Pallet); CUT/SURF/STRENGTH need badges only
check(actsFaint.fly and actsFaint.cut and actsFaint.surf and actsFaint.strength,
"fainted mon still lists FLY/CUT/SURF/STRENGTH in the party submenu")
popToOW()
-- STRENGTH activation from a fainted user (name text + strengthActive)
ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right")
clearCaptured()
local pmFaintStr = PartyMenu.new(Game)
-- Seafoam is not OVERWORLD, so FLY is omitted: STATS, SWITCH, CUT, STRENGTH, SURF
selectSubItem(pmFaintStr, 4)
eq(Game.overworld.strengthActive, true,
"fainted mon can activate STRENGTH from the party menu")
check(sawText("used") and sawText("STRENGTH"),
"fainted STRENGTH still prints _UsedStrengthText")
drainText()
-- SURF mount from a fainted user when facing water
Game.save.party = { fainted }
Game.save.inventory.SOULBADGE = true
ow = pushOW("PALLET_TOWN", 4, 13, "down")
ow.player.surfing = false
eq(ow:useSurfFieldMove(), "ok", "useSurfFieldMove ok with only a fainted SURF mon")
clearCaptured()
-- submenu order: STATS, SWITCH, FLY, CUT, STRENGTH, SURF (move order on mon)
local pmFaintSurf = PartyMenu.new(Game)
selectSubItem(pmFaintSurf, 6)
eq(ow.player.surfing, true, "fainted mon can SURF from the party menu")
check(not onStack(pmFaintSurf), "party menu closes after fainted SURF")
-- restore the spied constructor so later dofile'd suites are unaffected
TextBox.new = realTextBoxNew
popAll()
+37 -3
View File
@@ -88,8 +88,12 @@ local SaveData = require("src.core.SaveData")
local stack2 = newStack()
local game2 = { data = Data, input = fakeInput, stack = stack2,
save = SaveData.newGame() }
game2.save.party = { { species = "PIKACHU", level = 81 } }
-- battered party: record_hall_of_fame must heal before the autosave (#103)
game2.save.party = { { species = "PIKACHU", level = 81, hp = 1,
stats = { hp = 100 }, status = "PAR",
moves = { { id = "THUNDERBOLT", pp = 0 } } } }
game2.save.player.map = "HALL_OF_FAME"
game2.save.lastOutdoor = { id = "INDIGO_PLATEAU", x = 9, y = 5 }
local wrote = false
function game2:writeSave() wrote = true; SaveData.save(self.save) end
@@ -129,9 +133,39 @@ local saved = savedRaw and SaveData.decode(savedRaw) or nil
check(saved ~= nil, "save.lua written and decodable")
eq(saved and saved.lastHeal and saved.lastHeal.map, "PALLET_TOWN",
"wLastBlackoutMap := PALLET_TOWN before the save")
eq(saved and saved.player and saved.player.map, "HALL_OF_FAME",
"save keeps the player in the HALL_OF_FAME room")
eq(saved and saved.player and saved.player.map, "REDS_HOUSE_2F",
"CONTINUE lands in the bedroom (post-game home, #103)")
eq(saved and saved.player and saved.player.x, 3, "bedroom spawn x")
eq(saved and saved.player and saved.player.y, 6, "bedroom spawn y")
eq(saved and saved.lastOutdoor and saved.lastOutdoor.id, "PALLET_TOWN",
"LAST_MAP exits aim at Pallet Town, not Indigo")
check(saved and saved.postGameHomeOk, "postGameHomeOk marks the relocate done")
eq(saved and #(saved.hallOfFame or {}), 1, "hall of fame team persisted")
local healed = saved and saved.party and saved.party[1]
local thunderPp = Data.moves and Data.moves.THUNDERBOLT and Data.moves.THUNDERBOLT.pp
check(healed and healed.hp == healed.stats.hp and healed.status == nil
and healed.moves[1].pp == thunderPp,
"party is fully healed in the post-credits save")
-- house exit mats resolve to Pallet's door, not Indigo
local Warp = require("src.world.Warp")
local houseExit = { destMap = "LAST_MAP", destWarp = 1, x = 2, y = 7 }
local destMap, dx, dy = Warp.destination(Data, houseExit, saved.lastOutdoor)
eq(destMap, "PALLET_TOWN", "Red's house LAST_MAP -> PALLET_TOWN")
eq(dx, 5, "Pallet door x")
eq(dy, 5, "Pallet door y")
-- stuck-save rescue: HoF + hallOfFame + no postGameHomeOk -> bedroom
local stuck = SaveData.newGame()
stuck.player.map = "HALL_OF_FAME"
stuck.player.x, stuck.player.y = 4, 2
stuck.lastOutdoor = { id = "INDIGO_PLATEAU", x = 9, y = 5 }
stuck.hallOfFame = { { { species = "PIKACHU", level = 81 } } }
check(SaveData.needsPostGameRescue(stuck), "pre-fix softlock save needs rescue")
SaveData.applyPostGameHome(stuck, Data.field.boot)
check(not SaveData.needsPostGameRescue(stuck), "rescue is one-shot")
eq(stuck.player.map, "REDS_HOUSE_2F", "rescue warps home")
eq(stuck.lastOutdoor.id, "PALLET_TOWN", "rescue retargets LAST_MAP")
-- `jp Init`: everything popped, boot sequence pushed (intro -> title)
eq(#stack2.states, 1, "soft reset leaves exactly the boot state")
+129
View File
@@ -0,0 +1,129 @@
-- Parity: Rocket Hideout B4F Lift Key drop (#90 / #105).
--
-- Oracle: scripts/RocketHideoutB4F.asm RocketHideoutB4FRocket3AfterBattleText
-- (PrintText + CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5). The LIFT_KEY object_event starts
-- hidden; talking to Rocket3 after his defeat reveals it.
--
-- Self-contained: `luajit tests/parity_lift_key.lua`; also dofile'd 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 lift key")
local check, eq = S.check, S.eq
local mapScripts = require("data.scripts.init")
local OverworldState = require("src.world.OverworldController")
local liftKeyObj
for _, o in ipairs(Data.maps.ROCKET_HIDEOUT_B4F.objects) do
if o.name == "ROCKETHIDEOUTB4F_LIFT_KEY" then liftKeyObj = o break end
end
check(liftKeyObj and liftKeyObj.hidden == true,
"LIFT_KEY object_event is extracted as hidden")
local script = mapScripts.talkScript("ROCKET_HIDEOUT_B4F",
"TEXT_ROCKETHIDEOUTB4F_ROCKET3")
check(type(script) == "function",
"Rocket3 after-battle drop is a hand-ported talk handler")
-- Capture TextBox payloads without driving the typewriter / font path.
local realTB = package.loaded["src.render.TextBox"]
package.loaded["src.render.TextBox"] = {
new = function(game, text, done) return { text = text, onDone = done } end,
}
-- Drive the defeated-talk path headless: show after text, then ShowObject.
do
local pushed, engaged
local save = {
flags = {},
inventory = {},
defeatedTrainers = { ROCKET_HIDEOUT_B4F_obj_4 = true },
objectToggles = {},
}
local game = {
data = Data,
save = save,
stack = {
push = function(_, box) pushed = box end,
},
}
local ow = {
map = { id = "ROCKET_HIDEOUT_B4F", def = Data.maps.ROCKET_HIDEOUT_B4F },
npcs = {},
entities = {},
trainerDefeated = function(_, npc)
return save.defeatedTrainers[npc.id] == true
end,
engageTrainer = function() engaged = true end,
}
local npc = {
id = "ROCKET_HIDEOUT_B4F_obj_4",
def = { name = "ROCKETHIDEOUTB4F_ROCKET3", index = 4,
text = "TEXT_ROCKETHIDEOUTB4F_ROCKET3",
trainerClass = "OPP_ROCKET", trainerParty = 18 },
}
local doneCalled = false
script(game, ow, npc, function() doneCalled = true end)
check(not engaged, "defeated Rocket3 talk does not re-engage battle")
check(pushed ~= nil, "defeated Rocket3 talk pushes the after-battle text")
eq(pushed and pushed.text, Data.text._RocketHideoutB4FRocket3AfterBattleText,
"after-battle text is the dropped-key line")
check(not save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY,
"drop flag unset until the text box closes")
check(not OverworldState.objectVisible(save, "ROCKET_HIDEOUT_B4F", liftKeyObj),
"LIFT_KEY still hidden before the after-battle text finishes")
-- dismiss the box: CheckAndSetEvent + ShowObject
pushed.onDone()
check(doneCalled, "talk done() runs after ShowObject")
check(save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY == true,
"EVENT_ROCKET_DROPPED_LIFT_KEY set on first after-battle talk")
check(save.objectToggles.ROCKET_HIDEOUT_B4F
and save.objectToggles.ROCKET_HIDEOUT_B4F.ROCKETHIDEOUTB4F_LIFT_KEY == true,
"ShowObject toggles ROCKETHIDEOUTB4F_LIFT_KEY visible")
check(OverworldState.objectVisible(save, "ROCKET_HIDEOUT_B4F", liftKeyObj),
"LIFT_KEY objectVisible after ShowObject")
-- second talk: reprint only, no double-toggle churn
pushed, doneCalled = nil, false
local togglesBefore = save.objectToggles.ROCKET_HIDEOUT_B4F.ROCKETHIDEOUTB4F_LIFT_KEY
script(game, ow, npc, function() doneCalled = true end)
check(pushed ~= nil, "second after-battle talk still shows text")
pushed.onDone()
check(doneCalled, "second talk done() runs")
eq(save.objectToggles.ROCKET_HIDEOUT_B4F.ROCKETHIDEOUTB4F_LIFT_KEY, togglesBefore,
"second talk does not re-ShowObject")
end
-- Undefeated path still engages the trainer (TalkToTrainer).
do
local engaged = false
local save = { flags = {}, inventory = {}, defeatedTrainers = {} }
local game = {
data = Data, save = save,
stack = { push = function() end },
}
local ow = {
map = { id = "ROCKET_HIDEOUT_B4F" },
trainerDefeated = function() return false end,
engageTrainer = function(_, npc, done)
engaged = true
if done then done() end
end,
}
local npc = {
id = "ROCKET_HIDEOUT_B4F_obj_4",
def = { name = "ROCKETHIDEOUTB4F_ROCKET3", index = 4 },
}
script(game, ow, npc, function() end)
check(engaged, "undefeated Rocket3 talk engages the trainer battle")
end
package.loaded["src.render.TextBox"] = realTB
S.finish()
+100
View File
@@ -0,0 +1,100 @@
-- Parity test: Mom heal rest (#113) and Silph Co. 9F nurse fade.
--
-- pokered RedsHouse1FMomText / RedsHouse1FMomHealScript:
-- pre-starter -> WakeUpText; else YouShouldRest -> GBFadeOutToWhite ->
-- HealParty -> MUSIC_PKMN_HEALED (wait) -> GBFadeInFromWhite ->
-- LookingGreatText.
-- SilphCo9FNurseText: heal then white fade / Delay3 / fade in (no jingle).
--
-- Self-contained; run via `luajit tests/parity_mom_heal.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 mom heal")
local check, eq = S.check, S.eq
local reds = dofile("data/scripts/reds_house.lua")
local silph = dofile("data/scripts/flavor/silph_co_9f.lua")
local function cmds(rows)
local out = {}
for _, row in ipairs(rows) do out[#out + 1] = row[1] end
return out
end
local function find(rows, verb, arg)
for i, row in ipairs(rows) do
if row[1] == verb and (arg == nil or row[2] == arg) then
return i, row
end
end
end
-- --- Mom: pre-starter branch + heal rest sequence
do
local rows = reds.talk.TEXT_REDSHOUSE1F_MOM
check(rows ~= nil, "Mom talk script exists")
local iFlag = find(rows, "check_flag", "EVENT_GOT_STARTER")
check(iFlag, "Mom checks EVENT_GOT_STARTER")
local iWake = find(rows, "show_text", "_RedsHouse1FMomWakeUpText")
check(iWake and iWake > iFlag, "WakeUpText follows the starter check")
local iRest = find(rows, "show_text", "_RedsHouse1FMomYouShouldRestText")
check(iRest and iRest > iWake, "heal path is after the wake-up branch")
local iFadeOut = find(rows, "fade", "out")
local iHeal = find(rows, "heal_party")
local iJingle = find(rows, "play_once", "Music_PkmnHealed")
local iFadeIn = find(rows, "fade", "in")
local iGreat = find(rows, "show_text", "_RedsHouse1FMomLookingGreatText")
check(iFadeOut and rows[iFadeOut][3] == "white",
"Mom fades out to white after rest text")
check(iHeal and iHeal > iFadeOut, "heal_party runs under the white fade")
check(iJingle and iJingle > iHeal,
"Music_PkmnHealed plays after heal_party")
check(iFadeIn and rows[iFadeIn][3] == "white" and iFadeIn > iJingle,
"Mom fades in from white after the jingle")
check(iGreat and iGreat > iFadeIn,
"LookingGreatText follows the fade-in")
-- jump_if_true must land on the heal path (rest text), not wake-up
local jumpRow
for _, row in ipairs(rows) do
if row[1] == "jump_if_true" then jumpRow = row break end
end
check(jumpRow, "Mom has jump_if_true after starter check")
eq(rows[jumpRow[2]][2], "_RedsHouse1FMomYouShouldRestText",
"EVENT_GOT_STARTER jumps to the heal rest text")
end
-- --- Silph Co. 9F nurse: white fade, no heal jingle
do
local rows = silph.SILPH_CO_9F.talk.TEXT_SILPHCO9F_NURSE
check(rows ~= nil, "Silph nurse talk script exists")
local iHeal = find(rows, "heal_party")
local iFadeOut = find(rows, "fade", "out")
local iWait = find(rows, "wait")
local iFadeIn = find(rows, "fade", "in")
check(iHeal, "Silph nurse heals the party")
check(iFadeOut and rows[iFadeOut][3] == "white" and iFadeOut > iHeal,
"Silph nurse fades out to white after heal (pokered order)")
check(iWait and rows[iWait][2] == 3 and iWait > iFadeOut,
"Silph nurse Delay3 between fades")
check(iFadeIn and rows[iFadeIn][3] == "white" and iFadeIn > iWait,
"Silph nurse fades in from white")
check(not find(rows, "play_once", "Music_PkmnHealed"),
"Silph nurse does not play Music_PkmnHealed")
local sequence = table.concat(cmds(rows), ",")
check(sequence:find("heal_party,fade,wait,fade,show_text", 1, true),
"Silph heal rest sequence is heal → fade out → wait → fade in → text")
end
-- --- play_once is a blocking command (Mom / captain wait loops)
do
local Commands = require("src.script.Commands")
check(Commands.meta.play_once and Commands.meta.play_once.blocking,
"play_once is marked blocking so heal jingles wait to finish")
end
S.finish()
+117
View File
@@ -0,0 +1,117 @@
-- 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()
+138
View File
@@ -0,0 +1,138 @@
-- Parity test: Pokémon Tower 2F rival leaves after defeat (#111).
--
-- pokered PokemonTower2FDefeatedRivalScript (scripts/PokemonTower2F.asm):
-- after EVENT_BEAT_POKEMON_TOWER_RIVAL, walk RightThenDown or
-- DownThenRight (EVENT_POKEMON_TOWER_RIVAL_ON_LEFT when the player is on
-- (15,5)), then HideObject TOGGLE_POKEMON_TOWER_2F_RIVAL. The port used
-- to end after DefeatedText, so he stayed and HowsYourDex could re-fire.
--
-- Self-contained; run via `luajit tests/parity_tower_rival.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 tower rival")
local check, eq = S.check, S.eq
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, done = done } end,
}
package.loaded["src.core.Music"] = {
play = function() end,
playOnce = function() return true end,
stop = function() end,
}
local story = dofile("data/scripts/story.lua")
local tower = story.POKEMON_TOWER_2F
check(tower ~= nil, "POKEMON_TOWER_2F map script exists")
check(type(tower.rivalScript) == "function", "rivalScript helper exported")
check(type(tower.onStep) == "function", "onStep ambush exists")
check(type(tower.onEnter) == "function", "onEnter hide repair exists")
local RIGHT_THEN_DOWN =
{ "right", "down", "down", "right", "down", "down", "right", "right" }
local DOWN_THEN_RIGHT =
{ "down", "down", "right", "right", "right", "right", "down", "down" }
local function dirsEqual(a, b)
if type(a) ~= "table" or #a ~= #b then return false end
for i = 1, #b do if a[i] ~= b[i] then return false end end
return true
end
local function findWalk(rows)
for _, r in ipairs(rows) do
if r[1] == "walk_npc" then return r end
end
end
-- (1) both approach tiles pick the pokered exit movement
do
local below = tower.rivalScript(14)
local left = tower.rivalScript(15)
local wBelow, wLeft = findWalk(below), findWalk(left)
check(wBelow ~= nil, "player-below script has walk_npc")
check(wLeft ~= nil, "rival-on-left script has walk_npc")
eq(wBelow[2], 1, "walk targets object index 1 (rival)")
check(dirsEqual(wBelow[3], RIGHT_THEN_DOWN),
"player at x=14 uses RightThenDownMovement")
check(dirsEqual(wLeft[3], DOWN_THEN_RIGHT),
"player at x=15 uses DownThenRightMovement")
end
-- (2) win path: flag, defeat text, walk, hide -- in order
do
local rows = tower.rivalScript(14)
local preds = {
{ "set_flag EVENT_BEAT_POKEMON_TOWER_RIVAL",
function(r) return r[1] == "set_flag"
and r[2] == "EVENT_BEAT_POKEMON_TOWER_RIVAL" end },
{ "show_text DefeatedText",
function(r) return r[1] == "show_text"
and r[2] == "_PokemonTower2FRivalDefeatedText" end },
{ "walk_npc exit",
function(r) return r[1] == "walk_npc" end },
{ "hide_object POKEMONTOWER2F_RIVAL",
function(r) return r[1] == "hide_object"
and r[2] == "POKEMON_TOWER_2F"
and r[3] == "POKEMONTOWER2F_RIVAL" end },
}
local pi = 1
for _, r in ipairs(rows) do
if pi <= #preds and preds[pi][2](r) then pi = pi + 1 end
end
for i = 1, #preds do
check(i < pi, "rival script has, in order: " .. preds[i][1])
end
end
-- (3) onStep: only the encounter coords, and not after the beat flag
do
local ran
local game = { save = { flags = {} }, data = {} }
local ow = {
runner = {
isRunning = function() return false end,
run = function(_, rows) ran = rows end,
},
player = { facing = "down" },
npcByIndex = function() return { def = { name = "POKEMONTOWER2F_RIVAL" } } end,
}
check(tower.onStep(game, ow, 15, 5), "onStep fires on (15,5)")
check(ran ~= nil and findWalk(ran) ~= nil, "onStep runs exit-walk script")
check(dirsEqual(findWalk(ran)[3], DOWN_THEN_RIGHT),
"onStep (15,5) picks DownThenRight")
eq(ow.player.facing, "left", "onStep (15,5) faces the rival")
ran = nil
check(tower.onStep(game, ow, 14, 6), "onStep fires on (14,6)")
check(dirsEqual(findWalk(ran)[3], RIGHT_THEN_DOWN),
"onStep (14,6) picks RightThenDown")
check(not tower.onStep(game, ow, 14, 5), "onStep ignores rival's own tile")
game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL = true
check(not tower.onStep(game, ow, 15, 5), "beaten: onStep is inert")
end
-- (4) onEnter hides a stuck rival once the beat flag is set
do
local Commands = require("src.script.Commands")
local hidden = {}
local realHide = Commands.hide_object
Commands.hide_object = function(_, mapId, name)
hidden[#hidden + 1] = { mapId, name }
end
tower.onEnter({ save = { flags = {} } }, {})
eq(#hidden, 0, "onEnter: no hide while unbeaten")
tower.onEnter({
save = { flags = { EVENT_BEAT_POKEMON_TOWER_RIVAL = true } },
}, {})
eq(#hidden, 1, "onEnter: hide once beaten")
eq(hidden[1][1], "POKEMON_TOWER_2F", "onEnter hide map")
eq(hidden[1][2], "POKEMONTOWER2F_RIVAL", "onEnter hide object")
Commands.hide_object = realHide
end
S.finish()
+39
View File
@@ -0,0 +1,39 @@
-- #114: launcher must restore the arrow cursor when leaving for boot.
-- Self-contained: `luajit tests/rom_importer_cursor_test.lua`; also dofile'd
-- 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 S = require("tests.harness").suite("rom importer cursor")
local eq = S.eq
local currentCursor = "arrow"
love.mouse.isCursorSupported = function() return true end
love.mouse.getSystemCursor = function(name) return name end
love.mouse.setCursor = function(c) currentCursor = c or "arrow" end
local RomImporter = require("src.import.RomImporter")
local booted = nil
local ri = setmetatable({
android = false,
workState = nil,
ready = { red = true, blue = false },
onComplete = function(version) booted = version end,
}, RomImporter)
-- Simulate leaving Play while the hand cursor is still active (hover).
currentCursor = "hand"
ri:play("red")
eq(booted, "red", "play boots the chosen version")
eq(currentCursor, "arrow", "play restores the arrow cursor before boot")
-- Android / unsupported cursors must not error.
booted = nil
currentCursor = "hand"
ri.android = true
ri:play("red")
eq(booted, "red", "android play still boots")
eq(currentCursor, "hand", "android play leaves the cursor alone")
S.finish()
+3
View File
@@ -2672,6 +2672,9 @@ end
-- ---------------------------------------------- input hold regressions
runSuites({ "tests/input_hold_test.lua" })
-- ---------------------------------------------- launcher cursor (#114)
runSuites({ "tests/rom_importer_cursor_test.lua" })
-- ---------------------------------------------- parity workstream tests
-- Each tests/parity_*.lua is a self-contained file (own bootstrap + check,
-- error()s if any assertion fails). Globbed, so dropping a new parity