mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
big bug squash (#261)
This commit is contained in:
+59
-34
@@ -6,7 +6,8 @@
|
||||
-- takes it ("I'll take this one, then!") and both balls disappear.
|
||||
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
|
||||
-- OaksLabRivalTakePokeBallScript.
|
||||
-- * Rival (object 1): before starter -> "gramps isn't around"; with
|
||||
-- * Rival (object 1): before starter -> "go ahead and choose" once Oak
|
||||
-- has walked you in, else "gramps isn't around" (#218); with
|
||||
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
|
||||
-- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 =
|
||||
-- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm);
|
||||
@@ -162,36 +163,45 @@ return {
|
||||
starterBall("_OaksLabYouWantBulbasaurText", "BULBASAUR", "EVENT_CHOSE_BULBASAUR",
|
||||
"OAKSLAB_BULBASAUR_POKE_BALL", 6, "OAKSLAB_CHARMANDER_POKE_BALL"),
|
||||
|
||||
-- Talking to the rival only ever prints a line: the lab battle is a
|
||||
-- coordinate trigger (OaksLabRivalChallengesPlayerScript, wYCoord == 6;
|
||||
-- see onStep below), never a talk action. The handler used to fall
|
||||
-- through from the "looks stronger" taunt straight into start_battle,
|
||||
-- so talking to Blue at the table launched the rival fight before the
|
||||
-- player ever stepped onto the trigger (#219). scripts/OaksLab.asm
|
||||
-- OaksLabText8 branches text only:
|
||||
-- * got starter, not yet battled -> _OaksLabRivalMyPokemonLooksStronger
|
||||
-- * already battled -> _OaksLabRivalFedUpWithWaitingText (a Route 22
|
||||
-- line; normally unreachable since the rival is hidden after the
|
||||
-- lab battle in OaksLabRivalEndBattleScript)
|
||||
-- * no starter yet -> the FOLLOWED_OAK pre-starter fork (#218)
|
||||
TEXT_OAKSLAB_RIVAL = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
|
||||
{ "jump_if_false", 21 }, -- 3
|
||||
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4
|
||||
{ "jump_if_true", 19 }, -- 5
|
||||
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6
|
||||
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
|
||||
{ "jump_if_false", 11 }, -- 8
|
||||
{ "start_battle", "trainer", "OPP_RIVAL1", 3 }, -- 9 Charmander
|
||||
{ "jump", 16 }, -- 10
|
||||
{ "check_flag", "EVENT_CHOSE_SQUIRTLE" }, -- 11
|
||||
{ "jump_if_false", 15 }, -- 12
|
||||
{ "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur
|
||||
{ "jump", 16 }, -- 14
|
||||
{ "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle
|
||||
-- OaksLabRivalEndBattleScript: HealParty + flag, then exit either way
|
||||
{ "heal_party" }, -- 16
|
||||
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 17
|
||||
{ "jump", 23 }, -- 18
|
||||
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 19
|
||||
{ "jump", "end" }, -- 20
|
||||
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 21
|
||||
{ "jump", "end" }, -- 22
|
||||
-- win: sulk text then exit; loss: Rival1WinText already played in
|
||||
-- battle (HandlePlayerBlackOut), so skip straight to the walk-out
|
||||
{ "jump_if_false", 25 }, -- 23
|
||||
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 24
|
||||
{ "move_npc_to", 1, 4, 11 }, -- 25
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 26
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_GOT_STARTER" },
|
||||
{ "jump_if_false", "pre_starter" },
|
||||
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
|
||||
{ "jump_if_true", "after_battle" },
|
||||
-- has a starter, has not fought yet: just the taunt. The battle is
|
||||
-- the coordinate trigger in onStep, not this talk (#219)
|
||||
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "after_battle" },
|
||||
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
-- pre-starter fork (scripts/OaksLab.asm OaksLabText8 rival handler):
|
||||
-- Oak has already escorted you in (three balls on the table) -> he
|
||||
-- waves you on to choose; not yet escorted (very early game) -> the
|
||||
-- "Gramps isn't around" line. #218
|
||||
{ "label", "pre_starter" },
|
||||
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" },
|
||||
{ "jump_if_false", "gramps_gone" },
|
||||
{ "show_text", "_OaksLabRivalGoAheadAndChooseText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "gramps_gone" },
|
||||
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -213,9 +223,16 @@ return {
|
||||
-- OaksLabScript8 / OaksLabRivalChallenge)
|
||||
onStep = function(game, ow, x, y)
|
||||
local flags = game.save.flags
|
||||
-- Oak blocks the exit mats (4,11)/(5,11) until you take a starter
|
||||
-- Oak stops you leaving without a starter at the bookshelf row (cell
|
||||
-- y == 6): this is the same wYCoord == 6 coordinate script the rival
|
||||
-- challenge just below uses (scripts/OaksLab.asm, both gated on
|
||||
-- EVENT_GOT_STARTER), so Oak halts you level with the shelves, not one
|
||||
-- corridor length later on the exit mat. At y>=6 only the x=4,5
|
||||
-- corridor is walkable and the up-push keeps the player above y=7, so
|
||||
-- this only ever fires at y=6 in the corridor -- matching the rival
|
||||
-- trigger's shape. (#232)
|
||||
if flags.EVENT_FOLLOWED_OAK_INTO_LAB and not flags.EVENT_GOT_STARTER
|
||||
and y == 11 and (x == 4 or x == 5) then
|
||||
and y >= 6 then
|
||||
ow.runner:run({
|
||||
{ "show_text", "_OaksLabOakDontGoAwayYetText" },
|
||||
{ "move_player", "up", 1 },
|
||||
@@ -254,10 +271,18 @@ return {
|
||||
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
|
||||
table.insert(rows, { "heal_party" })
|
||||
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
|
||||
-- win: sulk text then exit; loss jumps to the walk-out (taunt was
|
||||
-- already shown in-battle via Rival1WinText)
|
||||
-- OaksLabRivalEndBattleScript: on WIN, print the "picked the wrong
|
||||
-- POKéMON!" gloat, then BOTH win and loss print the shared exit line
|
||||
-- _OaksLabRivalSmellYouLaterText ("OK! I'll make my POKéMON fight to
|
||||
-- toughen it up!\012<PLAYER>! Gramps! Smell you later!") before Blue
|
||||
-- marches out. A loss skips only the gloat (that taunt was already
|
||||
-- shown in-battle via Rival1WinText), never the exit line (#231). The
|
||||
-- jump_if_false convergence point is the exit line: base+6 indexes the
|
||||
-- SmellYouLater row below, so WIN falls IPicked -> SmellYouLater and
|
||||
-- LOSS jumps straight to SmellYouLater (both then walk-out + hide).
|
||||
table.insert(rows, { "jump_if_false", base + 6 })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||
table.insert(rows, { "move_npc_to", 1, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
ow.runner:run(rows, { npc = rival })
|
||||
|
||||
@@ -395,7 +395,93 @@ M.SS_ANNE_CAPTAINS_ROOM = {
|
||||
-- MrFujisHouse.asm; the teleport back to his house is a warp)
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
-- scripts/PokemonTower7F.asm: after each Rocket loses,
|
||||
-- PokemonTower7FEndBattleScript shows its EndBattle text (EndTrainerBattle),
|
||||
-- prints its AfterBattle text (DisplayTextID), then
|
||||
-- PokemonTower7FRocketLeaveMovementScript walks the grunt off toward the
|
||||
-- (9,16) stairs (MoveSprite) and PokemonTower7FHideNPCScript despawns it
|
||||
-- (HideObject). Without this the beaten grunts stood in the corridor
|
||||
-- forever (#200).
|
||||
local POKEMON_TOWER_7F_ROCKETS = {
|
||||
{ index = 1, name = "POKEMONTOWER7F_ROCKET1",
|
||||
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_0",
|
||||
after = "_PokemonTower7FRocket1AfterBattleText" },
|
||||
{ index = 2, name = "POKEMONTOWER7F_ROCKET2",
|
||||
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_1",
|
||||
after = "_PokemonTower7FRocket2AfterBattleText" },
|
||||
{ index = 3, name = "POKEMONTOWER7F_ROCKET3",
|
||||
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_2",
|
||||
after = "_PokemonTower7FRocket3AfterBattleText" },
|
||||
}
|
||||
|
||||
-- PokemonTower7FNPCCoordMovementTable, keyed by the PLAYER's tile ("x,y")
|
||||
-- at the engagement (both the talk and the sight walk-up leave the player on
|
||||
-- the tile the vanilla table indexes). Each list is that entry's
|
||||
-- NPC_MOVEMENT_* exit, applied from the grunt's current tile. The exact
|
||||
-- tables are one per grunt (dbmapcoord bytes decoded back to x,y).
|
||||
local POKEMON_TOWER_7F_EXITS = {
|
||||
[1] = { -- ROCKET1 @ (9,11), faces RIGHT
|
||||
["9,12"] = { "right", "down", "down", "down", "down", "down", "left" },
|
||||
["10,11"] = { "down", "right", "down", "down", "down", "down" },
|
||||
["11,11"] = { "down", "down", "down", "down", "down" },
|
||||
["12,11"] = { "down", "down", "down", "down", "down" },
|
||||
},
|
||||
[2] = { -- ROCKET2 @ (12,9), faces LEFT
|
||||
["12,10"] = { "left", "down", "down", "down", "down", "down", "down" },
|
||||
["11,9"] = { "down", "down", "down", "left", "down", "down" },
|
||||
["10,9"] = { "down", "down", "down", "down", "down" },
|
||||
["9,9"] = { "down", "down", "down", "down", "down" },
|
||||
},
|
||||
[3] = { -- ROCKET3 @ (9,7), faces RIGHT
|
||||
["9,8"] = { "right", "down", "down", "down", "down", "down", "down" },
|
||||
["10,7"] = { "down", "down", "down", "down", "down" },
|
||||
["11,7"] = { "down", "down", "down", "down", "down" },
|
||||
["12,7"] = { "down", "down", "down", "down", "down" },
|
||||
},
|
||||
}
|
||||
-- The vanilla table only lists the sight/talk tiles the map geometry allows;
|
||||
-- a plain down-walk keeps any other engagement tile (e.g. talking from the
|
||||
-- side) safe -- it still clears the corridor and despawns the grunt.
|
||||
local POKEMON_TOWER_7F_EXIT_FALLBACK =
|
||||
{ "down", "down", "down", "down", "down" }
|
||||
|
||||
M.POKEMON_TOWER_7F = {
|
||||
-- Repair saves that beat a grunt before the exit walk was ported: in
|
||||
-- vanilla HideObject persists (wMissableObjectFlags), so a beaten grunt is
|
||||
-- still gone on re-entry. hide_object below writes objectToggles for new
|
||||
-- wins; this hides any grunt whose BEAT flag is already set.
|
||||
onEnter = function(game, ow)
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do
|
||||
if game.save.flags[r.beat] then
|
||||
Commands.hide_object(ctx, "POKEMON_TOWER_7F", r.name)
|
||||
end
|
||||
end
|
||||
end,
|
||||
-- runVictoryHook fires after every trainer win on the map (both the sight
|
||||
-- and talk paths route through engageTrainer -> checkVictoryRewards), so
|
||||
-- this walks off whichever grunt was just beaten but is still standing.
|
||||
-- The AfterBattle text + walk queue UNDER the EndBattle box engageTrainer
|
||||
-- pushes right after, giving the vanilla order: EndBattle, AfterBattle,
|
||||
-- walk, despawn.
|
||||
onVictory = function(game, ow)
|
||||
if ow.runner:isRunning() then return end
|
||||
for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do
|
||||
local npc = ow:npcByIndex(r.index)
|
||||
if npc and game.save.flags[r.beat] then
|
||||
local key = ow.player.cellX .. "," .. ow.player.cellY
|
||||
local dirs = (POKEMON_TOWER_7F_EXITS[r.index] or {})[key]
|
||||
or POKEMON_TOWER_7F_EXIT_FALLBACK
|
||||
ow.runner:run({
|
||||
{ "show_text", r.after }, -- DisplayTextID
|
||||
{ "walk_npc", r.index, dirs }, -- MoveSprite
|
||||
{ "hide_object", "POKEMON_TOWER_7F", r.name }, -- HideObject
|
||||
}, { npc = npc })
|
||||
return
|
||||
end
|
||||
end
|
||||
end,
|
||||
talk = {
|
||||
TEXT_POKEMONTOWER7F_MR_FUJI = {
|
||||
{ "face_player" }, -- 1
|
||||
|
||||
@@ -528,7 +528,9 @@ M.MT_MOON_B2F = {
|
||||
}
|
||||
|
||||
-- The ticket clerk (scripts/Museum1F.asm Museum1FScientist1Text):
|
||||
-- Y50, once; declining at the rope walks you back out.
|
||||
-- Y50, once. Declining at the rope shoves the player one tile SOUTH back off
|
||||
-- the exhibit rope they crossed heading north (#151); the museum floor has no
|
||||
-- ledges, so a plain scriptMove("down",1) is the correct primitive.
|
||||
local function museumClerk(game, ow, done, onDecline)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
@@ -563,7 +565,7 @@ M.MUSEUM_1F = {
|
||||
if y == 4 and (x == 9 or x == 10)
|
||||
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||
museumClerk(game, ow, nil, function()
|
||||
ow:scriptMove(ow.player, "right", 1)
|
||||
ow:scriptMove(ow.player, "down", 1)
|
||||
end)
|
||||
return true
|
||||
end
|
||||
|
||||
+62
-31
@@ -524,8 +524,17 @@ M.GAME_CORNER = {
|
||||
game.data.text._GameCornerRocketAfterBattleText
|
||||
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
|
||||
function()
|
||||
hideRocket()
|
||||
done()
|
||||
-- #198: GameCornerRocketExitScript (scripts/GameCorner.asm)
|
||||
-- ApplyMovementData walks the grunt one tile UP into the poster
|
||||
-- (the hideout's secret entrance at 9,4) before HideObject, so
|
||||
-- he leaves the floor rather than popping out of existence on
|
||||
-- (9,5). scriptMove locks player input (#scriptMoves>0) and
|
||||
-- ignores collision, so we despawn + unfreeze (done) only once
|
||||
-- the step lands.
|
||||
ow:scriptMove(npc, "up", 1, function()
|
||||
hideRocket()
|
||||
done()
|
||||
end)
|
||||
end))
|
||||
end)
|
||||
end,
|
||||
@@ -606,40 +615,62 @@ local function activePrizes()
|
||||
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
|
||||
end
|
||||
|
||||
-- Prize counters (engine/menus/prize_menu.asm CeladonPrizeMenu; the prize
|
||||
-- list itself is data/events/prizes.asm, prize_mon_levels.asm). Gen1 gates
|
||||
-- the prize window on the COIN CASE: it does IsItemInBag COIN_CASE first, and
|
||||
-- with no case prints RequireCoinCaseText and returns without ever opening a
|
||||
-- window; only with the case does it print ExchangeCoinsForPrizesText and then
|
||||
-- show the prizes. #194: the port used to open the window unconditionally and
|
||||
-- skip both text boxes.
|
||||
local function prizeCounter(game, ow, npc, done)
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Commands = require("src.script.Commands")
|
||||
local items = {}
|
||||
for _, p in ipairs(activePrizes()) do
|
||||
local label
|
||||
if p.kind == "mon" then
|
||||
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
|
||||
else
|
||||
label = game.data.items[p.item].name
|
||||
end
|
||||
table.insert(items, { label = label, right = tostring(p.cost), value = p })
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
-- IsItemInBag COIN_CASE: without the case, deny and open no window
|
||||
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
|
||||
if not game.save.inventory.COIN_CASE then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
|
||||
return
|
||||
end
|
||||
local list
|
||||
list = ListMenu.new(game, "PRIZES (COINS)", items, {
|
||||
footer = ("COINS %d"):format(game.save.coins or 0),
|
||||
onChoose = function(item)
|
||||
local p = item.value
|
||||
if (game.save.coins or 0) < p.cost then
|
||||
list.footer = "Not enough coins!"
|
||||
return
|
||||
-- ExchangeCoinsForPrizesText plays before the prize window opens.
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
|
||||
function()
|
||||
local items = {}
|
||||
for _, p in ipairs(activePrizes()) do
|
||||
local label
|
||||
if p.kind == "mon" then
|
||||
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
|
||||
else
|
||||
label = game.data.items[p.item].name
|
||||
end
|
||||
table.insert(items,
|
||||
{ label = label, right = tostring(p.cost), value = p })
|
||||
end
|
||||
game.save.coins = game.save.coins - p.cost
|
||||
if p.kind == "mon" then
|
||||
Commands.give_pokemon({ save = game.save, game = game },
|
||||
p.species, p.level)
|
||||
else
|
||||
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
|
||||
end
|
||||
list.footer = ("Got it! COINS %d"):format(game.save.coins)
|
||||
end,
|
||||
onCancel = done,
|
||||
})
|
||||
game.stack:push(list)
|
||||
local list
|
||||
list = ListMenu.new(game, "PRIZES (COINS)", items, {
|
||||
footer = ("COINS %d"):format(game.save.coins or 0),
|
||||
onChoose = function(item)
|
||||
local p = item.value
|
||||
if (game.save.coins or 0) < p.cost then
|
||||
list.footer = "Not enough coins!"
|
||||
return
|
||||
end
|
||||
game.save.coins = game.save.coins - p.cost
|
||||
if p.kind == "mon" then
|
||||
Commands.give_pokemon({ save = game.save, game = game },
|
||||
p.species, p.level)
|
||||
else
|
||||
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
|
||||
end
|
||||
list.footer = ("Got it! COINS %d"):format(game.save.coins)
|
||||
end,
|
||||
onCancel = done,
|
||||
})
|
||||
game.stack:push(list)
|
||||
end))
|
||||
end
|
||||
|
||||
M.GAME_CORNER_PRIZE_ROOM = {
|
||||
|
||||
+26
-2
@@ -134,12 +134,19 @@ M.MT_MOON_POKECENTER = {
|
||||
-- Karate Master, take HITMONLEE or HITMONCHAN (the other disappears)
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
-- ownBall/otherBall keep their spots for signature symmetry; only ownBall
|
||||
-- is ever hidden (Gen1 removes just the chosen ball).
|
||||
local function dojoBall(species, ownBall, otherBall, askKey)
|
||||
return function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
local flags = game.save.flags
|
||||
-- Already took one prize: the OTHER ball is still on the mat, so
|
||||
-- talking to it now gives the "Better not get greedy..." refusal
|
||||
-- (FightingDojo.asm .gotThePokemon / _FightingDojoBetterNotGetGreedyText),
|
||||
-- not a silent no-op (#197).
|
||||
if flags.EVENT_GOT_HITMONLEE or flags.EVENT_GOT_HITMONCHAN then
|
||||
done()
|
||||
push(game, t._FightingDojoBetterNotGetGreedyText
|
||||
or "Better not get\ngreedy...", done)
|
||||
return
|
||||
end
|
||||
if not flags.EVENT_BEAT_KARATE_MASTER then
|
||||
@@ -153,8 +160,10 @@ local function dojoBall(species, ownBall, otherBall, askKey)
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.give_pokemon(ctx, species, 30)
|
||||
-- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides
|
||||
-- just the picked object's index) and routes to the greedy line above
|
||||
-- when talked to (#197).
|
||||
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
|
||||
Commands.hide_object(ctx, "FIGHTING_DOJO", otherBall)
|
||||
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
|
||||
end)
|
||||
end
|
||||
@@ -171,6 +180,21 @@ M.FIGHTING_DOJO = {
|
||||
"FIGHTINGDOJO_HITMONLEE_POKE_BALL",
|
||||
"_FightingDojoHitmonchanPokeBallText"),
|
||||
},
|
||||
-- The two dojo posters on the north wall (FightingDojo.asm bg_events at
|
||||
-- the top of the room, cells (4,0)/(5,0) directly above the prize balls)
|
||||
-- print _EnemiesOnEverySideText. The map extractor dropped the dojo
|
||||
-- bg_events (signs = {}), so wire the poster read here, the same
|
||||
-- facing-up + coord shape the mansion switches use. Reachable only once
|
||||
-- a claimed prize frees the ball cell below the poster (#197).
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
if ow.player.facing ~= "up" then return false end
|
||||
if fy == 0 and (fx == 4 or fx == 5) then
|
||||
push(game, text(game)._EnemiesOnEverySideText
|
||||
or "Enemies on every\nside!")
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
+13
-3
@@ -196,14 +196,24 @@ local function inCoords(coords, x, y)
|
||||
return false
|
||||
end
|
||||
|
||||
-- coordinate block: show a line and push the player back one step
|
||||
-- coordinate block: show a line and shove the player back one step. pokered
|
||||
-- shoves with a SIMULATED JOYPAD press (scripts/ViridianCity.asm ViridianCity-
|
||||
-- CheckGymOpenScript), which runs the normal overworld step pipeline including
|
||||
-- HandleLedges (engine/overworld/ledges.asm), so the shove must HOP a ledge
|
||||
-- when one sits in front: the tile below the Viridian Gym door is a down-ledge
|
||||
-- (#151). Gates with no ledge in front (Cinnabar gym lock at (18,4)) get
|
||||
-- checkLedgeHop == false and fall back to the plain forced step, unchanged.
|
||||
local function stepGate(opts)
|
||||
return function(game, ow, x, y)
|
||||
if not inCoords(opts.coords, x, y) then return false end
|
||||
if not opts.blocked(game) then return false end
|
||||
require("src.core.Sound").play(game.data, "Denied")
|
||||
push(game, text(game)[opts.text] or opts.fallback,
|
||||
function() ow:scriptMove(ow.player, opts.push, 1) end)
|
||||
push(game, text(game)[opts.text] or opts.fallback, function()
|
||||
ow.player.facing = opts.push
|
||||
if not ow:checkLedgeHop(opts.push) then
|
||||
ow:scriptMove(ow.player, opts.push, 1)
|
||||
end
|
||||
end)
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -114,9 +114,12 @@ return {
|
||||
-- Fighting Dojo Karate Master (scripts/FightingDojo.asm
|
||||
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
|
||||
-- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is
|
||||
-- only him (data/maps/objects/FightingDojo.asm).
|
||||
-- only him (data/maps/objects/FightingDojo.asm). `dialogue` is the
|
||||
-- concede + prize offer he speaks after the win (his header supplies the
|
||||
-- "Hwa! Arrgh! Beaten!" won line first; #197).
|
||||
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER",
|
||||
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3) },
|
||||
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3),
|
||||
dialogue = { "_FightingDojoKarateMasterIWillGiveYouAPokemonText" } },
|
||||
|
||||
-- Elite Four progress flags (their rooms' door logic isn't ported, but
|
||||
-- the flags make the Hall of Fame checkable)
|
||||
|
||||
+126
-20
@@ -183,7 +183,22 @@ end
|
||||
|
||||
-- the image a battler pic actually draws with this frame
|
||||
function BattleState:picImage(img)
|
||||
if self.grayPics then return grayImage(img) end
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
-- #207: OG / OG INV / CLASSIC are forced-mono display modes. A battle that
|
||||
-- exposes no SGB zones (sgbPalettes() == nil) has a whole-screen GRAYS zone
|
||||
-- invented by PaletteFX.ensureZones, so Renderer:endFrame re-thresholds the
|
||||
-- WHOLE finished frame through the shade shader a second time (keyed on the
|
||||
-- red channel). A pic baked with the species' SGB color is then remapped
|
||||
-- again and loses its warm mid shades -- REDMON's reds 1.0/0.839 both land in
|
||||
-- the c0 bucket, collapsing CHARMANDER's body into the white paper and
|
||||
-- leaving only the outline. Emit the raw DMG-gray build instead (exactly
|
||||
-- what the SE_WAVY_SCREEN grayPics path already does) so the downstream remap
|
||||
-- recolors 255->c0/170->c1/85->c2/0->c3 and all four shades survive; cool
|
||||
-- palettes (CYANMON reds 0.678/0.451) already rendered correctly. This mode
|
||||
-- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync.
|
||||
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
|
||||
or PaletteFX.mode == "classic"
|
||||
if self.grayPics or mono then return grayImage(img) end
|
||||
return fadeImage(img, self:activeBgp())
|
||||
end
|
||||
|
||||
@@ -666,16 +681,53 @@ local function shownHP(b)
|
||||
return math.floor(shown)
|
||||
end
|
||||
|
||||
-- Parse a battle message into its rendered lines. The extractor marks
|
||||
-- \n = next line and \v = CONT (home/text.asm ContText: draw the blinking
|
||||
-- ▼, WaitForTextScrollButtonPress, then ScrollTextUpOneLine); the boosted /
|
||||
-- EXP.ALL exp lines end in the CONT code in the ROM (data/generated/text.lua
|
||||
-- _BoostedText/_WithExpAllText = "...\011"). Each entry is { codes, cont },
|
||||
-- cont true when the line was preceded by \v. Splitting before Font.encode
|
||||
-- keeps the control chars out of the glyph stream. The box then types into
|
||||
-- a rolling 2-line window (self.shown) that scrolls when a 3rd line arrives
|
||||
-- instead of drawing it off-screen at y=144 (#216).
|
||||
function BattleState:startMessage(item)
|
||||
self.current = item
|
||||
self.lines = {}
|
||||
self.total = 0
|
||||
for chunk in (item.text .. "\n"):gmatch("(.-)\n") do
|
||||
local text = item.text or ""
|
||||
local pos, cont = 1, false
|
||||
while true do
|
||||
local npos = text:find("[\n\v]", pos)
|
||||
local chunk = npos and text:sub(pos, npos - 1) or text:sub(pos)
|
||||
local codes = Font.encode(chunk)
|
||||
table.insert(self.lines, codes)
|
||||
self.lines[#self.lines + 1] = { codes = codes, cont = cont }
|
||||
self.total = self.total + #codes
|
||||
if not npos then break end
|
||||
cont = text:sub(npos, npos) == "\v"
|
||||
pos = npos + 1
|
||||
end
|
||||
self.shown = {} -- up to two visible lines of revealed glyph codes
|
||||
self.lineIndex = 0
|
||||
-- self.charIndex counts glyphs typed across the WHOLE message (drivers read
|
||||
-- it against self.total); the current line's revealed count is #shown[last]
|
||||
self.charIndex = 0
|
||||
self.msgWaiting = nil
|
||||
self.scrollPx = nil
|
||||
self:beginMsgLine()
|
||||
end
|
||||
|
||||
-- Start typing the next line into the rolling window. When the box already
|
||||
-- shows two lines, drop the top one and set the pixel scroll-up
|
||||
-- (ScrollTextUpOneLine), mirroring TextBox:beginLine.
|
||||
function BattleState:beginMsgLine()
|
||||
self.lineIndex = self.lineIndex + 1
|
||||
local ln = self.lines[self.lineIndex]
|
||||
self.codes = ln and ln.codes or {}
|
||||
if #self.shown >= 2 then
|
||||
table.remove(self.shown, 1)
|
||||
self.scrollPx = 8
|
||||
end
|
||||
self.shown[#self.shown + 1] = {}
|
||||
end
|
||||
|
||||
function BattleState:updateQueue()
|
||||
@@ -823,8 +875,32 @@ function BattleState:updateQueue()
|
||||
end
|
||||
self:startMessage(item)
|
||||
end
|
||||
if self.charIndex < self.total then
|
||||
self.charIndex = math.min(self.total, self.charIndex + 2)
|
||||
local input = self.game.input
|
||||
-- a \v CONT wait holds the box until A/B, then scrolls the next line in
|
||||
-- (home/text.asm ContText); this keeps a 3rd line on-screen (#216)
|
||||
if self.msgWaiting then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.msgWaiting = nil
|
||||
self:beginMsgLine()
|
||||
end
|
||||
return true
|
||||
end
|
||||
local cur = self.shown[#self.shown]
|
||||
if #cur < #self.codes then
|
||||
-- battle typewriter cadence: two glyphs per fixed step (as before)
|
||||
for _ = 1, 2 do
|
||||
if #cur >= #self.codes then break end
|
||||
cur[#cur + 1] = self.codes[#cur + 1]
|
||||
self.charIndex = self.charIndex + 1
|
||||
end
|
||||
elseif self.lineIndex < #self.lines then
|
||||
-- current line finished, more lines remain: \v waits for A/B + ▼ before
|
||||
-- scrolling, \n advances now (beginMsgLine scrolls if the box is full)
|
||||
if self.lines[self.lineIndex + 1].cont then
|
||||
self.msgWaiting = true
|
||||
else
|
||||
self:beginMsgLine()
|
||||
end
|
||||
else
|
||||
local item = self.current
|
||||
-- TrainerAboutToUseText ends in `done` then DisplayTextBoxID: YES/NO
|
||||
@@ -841,7 +917,6 @@ function BattleState:updateQueue()
|
||||
end))
|
||||
return true
|
||||
end
|
||||
local input = self.game.input
|
||||
if not (item and item.choice)
|
||||
and (input:wasPressed("a") or input:wasPressed("b")) then
|
||||
self.current = nil
|
||||
@@ -2715,12 +2790,15 @@ function BattleState:enemyMonFainted()
|
||||
-- _WithExpAllText / _BoostedText / _ExpPointsText; the EXP.ALL
|
||||
-- pass beats the traded boost (wBoostExpByExpAll checks first),
|
||||
-- and _ExpPointsText prints wExpAmountGained -- the raw share,
|
||||
-- captured before the max-level cap (experience.asm:92-100)
|
||||
-- captured before the max-level cap (experience.asm:92-100).
|
||||
-- _BoostedText / _WithExpAllText end in the CONT code (\v, "...\011"
|
||||
-- in data/generated/text.lua): the box waits for A/B + ▼ then scrolls
|
||||
-- the amount line in, so it stays on-screen instead of at y=144 (#216).
|
||||
local tail = "%d EXP. Points!"
|
||||
if announce == "expAll" then
|
||||
tail = "with EXP.ALL,\n" .. tail
|
||||
tail = "with EXP.ALL,\v" .. tail
|
||||
elseif mon.traded then
|
||||
tail = "a boosted\n" .. tail
|
||||
tail = "a boosted\v" .. tail
|
||||
end
|
||||
self:sayNext(("%s gained\n" .. tail):format(name, gained))
|
||||
end
|
||||
@@ -2733,6 +2811,17 @@ function BattleState:enemyMonFainted()
|
||||
require("src.core.Sound").play(game.data, "Level_Up")
|
||||
return StatBox.new(game, mon)
|
||||
end)
|
||||
-- After PrintStatsBox, experience.asm reloads the active battler's
|
||||
-- wBattleMon and runs DrawHUDsAndHPBars, so its HP bar reflects the
|
||||
-- higher current HP. Experience.lua:84 already raised mon.hp by
|
||||
-- (newMaxHP - oldMaxHP); the party mon and the battler share one table
|
||||
-- (makeBattler), so mon.stats.hp (the bar's denominator) jumps to the
|
||||
-- new max instantly while the battler's shownHP numerator lags at the
|
||||
-- old current HP -- the bar SHRINKS (#224). Animate shownHP up to the
|
||||
-- new current HP (house convention: potions drain the bar too, see
|
||||
-- itemUsed) so the bar grows instead. Only the active player battler
|
||||
-- shares its table with the HUD; other party mons (EXP.ALL) have no bar.
|
||||
if mon == self.player.mon then self:drainNext() end
|
||||
for _, moveId in ipairs(Experience.movesLearnedAt(
|
||||
self.data.pokemon[mon.species], lv)) do
|
||||
self:learnMove(mon, moveId)
|
||||
@@ -4114,7 +4203,12 @@ function BattleState:drawHUDs(slide)
|
||||
-- the HUD clears with the send-out text (ClearScreenArea,
|
||||
-- core.asm:1414-1417) and DrawEnemyHUDAndHPBar (1435) only redraws
|
||||
-- it after the grow-in + cry
|
||||
local barData = self:colorMode() and {} or self.data -- gray fill when zoned
|
||||
-- In colorized modes the zone pass (drawZonePass over BATTLE_ZONES pal 0/1)
|
||||
-- recolors the bar's DMG gray fill by region, so drawHPBar must skip its
|
||||
-- per-pixel tint (grayFill) -- otherwise GREENBAR's red-channel-0 fill
|
||||
-- double-applies and the zone shade shader maps the whole bar to black (#229).
|
||||
local grayFill = self:colorMode()
|
||||
local barData = self.data
|
||||
local fx = self.fx
|
||||
local hudShake = (fx and fx.hudShakeX) or 0
|
||||
-- FaintEnemyPokemon clears the enemy HUD area; it stays blank through
|
||||
@@ -4139,7 +4233,8 @@ function BattleState:drawHUDs(slide)
|
||||
end
|
||||
hudTile(0x73, 8, 16)
|
||||
drawHPBar(barData, 2, 2,
|
||||
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats })
|
||||
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats },
|
||||
nil, grayFill)
|
||||
hudTile(0x74, 8, 24)
|
||||
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
|
||||
hudTile(0x78, 80, 24)
|
||||
@@ -4187,7 +4282,7 @@ function BattleState:drawHUDs(slide)
|
||||
end
|
||||
drawHPBar(barData, 10, 9,
|
||||
{ hp = shownHP(self.player), stats = self.player.mon.stats },
|
||||
1) -- wHPBarType 1: the $6D cap
|
||||
1, grayFill) -- wHPBarType 1: the $6D cap
|
||||
Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80)
|
||||
hudTile(0x73, 144, 80)
|
||||
hudTile(0x77, 144, 88)
|
||||
@@ -4200,16 +4295,27 @@ function BattleState:drawTextArea()
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if self.phase == "messages" and self.current then
|
||||
local shown = 0
|
||||
for li, codes in ipairs(self.lines) do
|
||||
-- battle text uses every other tile row (hlcoord *,14 / *,16)
|
||||
local y = 112 + (li - 1) * 16
|
||||
for i = 1, #codes do
|
||||
if shown >= self.charIndex then break end
|
||||
Font.drawCode(codes[i], 8 + (i - 1) * 8, y)
|
||||
shown = shown + 1
|
||||
-- rolling 2-line window: shown[1] at row y=112, shown[2] at y=128 (battle
|
||||
-- text uses every other tile row, hlcoord *,14 / *,16). scrollPx animates
|
||||
-- the lines up one row (ScrollTextUpOneLine) so a 3rd line scrolls into
|
||||
-- view instead of drawing off-screen at y=144 (#216).
|
||||
if self.scrollPx and self.scrollPx > 0 then
|
||||
self.scrollPx = self.scrollPx - 2
|
||||
if self.scrollPx <= 0 then self.scrollPx = nil end
|
||||
end
|
||||
local off = self.scrollPx or 0
|
||||
local ys = { 112, 128 }
|
||||
for li, line in ipairs(self.shown or {}) do
|
||||
local y = (ys[li] or 128) + off
|
||||
for i = 1, #line do
|
||||
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
|
||||
end
|
||||
end
|
||||
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait holds the
|
||||
-- box, bottom-right of the box like TextBox / home/text.asm
|
||||
if self.msgWaiting and self.frame % 60 < 30 then
|
||||
Font.drawCode(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4)
|
||||
end
|
||||
elseif self.phase == "menu" and self.demo then
|
||||
-- the old-man script (DisplayBattleMenu, core.asm:2038-2049): the
|
||||
-- standard menu, with the '▶' hand drawn by the scripted keystrokes
|
||||
|
||||
@@ -102,10 +102,41 @@ function Data:seedDefaults()
|
||||
-- after-battle rows so Blaine's SetEventRange deactivation and talk
|
||||
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
|
||||
self:seedCinnabarGymTrainerHeaders()
|
||||
-- #197: the Fighting Dojo Karate Master is text_asm, so the extractor
|
||||
-- writes no header for him -- seed one so he engages on sight and has
|
||||
-- his defeat / re-talk lines (same idea as the Cinnabar seed above).
|
||||
self:seedFightingDojoKarateMaster()
|
||||
-- #189: 1F cabin door order vs rooms map (survey zoom)
|
||||
require("src.world.SsAnneLayout").apply(self.maps)
|
||||
end
|
||||
|
||||
-- The Karate Master (FightingDojo.asm) is a text_asm object: his object has
|
||||
-- no def_trainers row (DisplayTextID routes to his ASM script), so the
|
||||
-- extractor emits headers only for the four blackbelts ([2]..[5]). Seed
|
||||
-- object index [1] so he behaves like the real leader:
|
||||
-- * range 4 (matches the strongest blackbelt) -> CheckFightingMapTrainers
|
||||
-- spots the player in his DOWN line and he challenges on sight (#197),
|
||||
-- * battle = his pre-battle challenge, won = "Hwa! Arrgh! Beaten!",
|
||||
-- * after = the "Stay and train at Karate with us!" re-talk line.
|
||||
-- Deliberately NO `event`: EVENT_BEAT_KARATE_MASTER is owned by
|
||||
-- victories.lua (OPP_BLACKBELT#1) exactly like the gym leaders, and
|
||||
-- engageTrainer sets header.event *before* checkVictoryRewards runs -- if
|
||||
-- this header also set it, the reward's flag guard would early-return and
|
||||
-- swallow the prize dialogue. trainerDefeated tracks him via
|
||||
-- defeatedTrainers[npc.id] like the leaders.
|
||||
function Data:seedFightingDojoKarateMaster()
|
||||
local headers = self.trainer_headers
|
||||
if not headers then return end
|
||||
headers.FightingDojo = headers.FightingDojo or {}
|
||||
if headers.FightingDojo[1] then return end
|
||||
headers.FightingDojo[1] = {
|
||||
range = 4,
|
||||
battle = "_FightingDojoKarateMasterText",
|
||||
won = "_FightingDojoKarateMasterDefeatedText",
|
||||
after = "_FightingDojoKarateMasterStayAndTrainWithUsText",
|
||||
}
|
||||
end
|
||||
|
||||
function Data:seedCinnabarGymTrainerHeaders()
|
||||
local headers = self.trainer_headers
|
||||
if not headers or headers.CinnabarGym then return end
|
||||
|
||||
+21
-2
@@ -31,7 +31,12 @@ local function indexOf(list, value)
|
||||
end
|
||||
|
||||
local function levelForWire(v)
|
||||
return v == ANY and nil or v
|
||||
-- ANY ("use each mon's real level") goes on the wire as nil (no forced
|
||||
-- level). An explicit guard, not `v == ANY and nil or v`: that idiom's
|
||||
-- true branch is nil, so it falls through to `or v` and returned the
|
||||
-- literal "ANY" string, which then crashed math.floor in unpackMon (#204).
|
||||
if v == ANY then return nil end
|
||||
return v
|
||||
end
|
||||
|
||||
local function forceLevelLabel(v)
|
||||
@@ -493,6 +498,14 @@ function LinkState:updateTrade(input)
|
||||
if t.stage == "done" then
|
||||
local sent = t.party[t.myPick]
|
||||
local received, evoTo = t:apply(self.game)
|
||||
-- Autosave the instant the swap commits into game.save.party, matching the
|
||||
-- Cable Club: pokered engine/link/cable_club.asm calls SaveSAVtoSRAM
|
||||
-- (engine/menus/save.asm) right after every trade so the trade is on the
|
||||
-- cartridge before the animation runs. Without this the received mon
|
||||
-- lives only in memory until a manual START-menu save, so a force-quit
|
||||
-- would lose it and a reset would clone the sent mon (#222). Guarded so
|
||||
-- headless LinkBattle-style fake games with no writeSave are unaffected.
|
||||
if self.game.writeSave then self.game:writeSave() end
|
||||
local name = received.nickname or self.game.data.pokemon[received.species].name
|
||||
Runtime.emit("link.ended", { reason = "done" })
|
||||
self.net:close()
|
||||
@@ -510,7 +523,13 @@ function LinkState:updateTrade(input)
|
||||
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name),
|
||||
function()
|
||||
if evoTo then
|
||||
require("src.pokemon.Evolution").evolve(game, received, evoTo)
|
||||
-- via="TRADE": a trade evolution cannot be B-cancelled
|
||||
-- (pokered LINK_STATE_TRADING skips the flash B-poll) (#213).
|
||||
-- Re-save once the evolution movie finishes so the evolved
|
||||
-- species (not the pre-evo landed by t:apply) is what persists,
|
||||
-- keeping disk in step with the autosave above (#222).
|
||||
require("src.pokemon.Evolution").evolve(game, received, evoTo,
|
||||
function() if game.writeSave then game:writeSave() end end, "TRADE")
|
||||
end
|
||||
end))
|
||||
end,
|
||||
|
||||
+30
-4
@@ -43,7 +43,12 @@ Protocol.plainCopy = plainCopy
|
||||
|
||||
-- serialize a mon instance for the wire (plain data only). ppUps rides
|
||||
-- along because the real cable transmitted it and its absence silently
|
||||
-- capped a PP-Upped move at base PP on the receiving side.
|
||||
-- capped a PP-Upped move at base PP on the receiving side. ot/otId ride
|
||||
-- along too: pokered's trade sends the whole party block including each
|
||||
-- mon's OT ID (party_struct MON_OTID) and the OT-names block (wPartyMonOT),
|
||||
-- and the receiver keeps them verbatim -- a differing OT/ID is what marks a
|
||||
-- mon as traded (boosted EXP, high-level disobedience). Omitting them made
|
||||
-- a received mon show the receiver as its OT (#215).
|
||||
function Protocol.packMon(mon)
|
||||
local moves = {}
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
@@ -59,6 +64,8 @@ function Protocol.packMon(mon)
|
||||
dvs = mon.dvs,
|
||||
statExp = mon.statExp,
|
||||
moves = moves,
|
||||
ot = mon.ot,
|
||||
otId = mon.otId,
|
||||
extra = plainCopy(mon.extra),
|
||||
}
|
||||
end
|
||||
@@ -71,6 +78,14 @@ function Protocol.unpackMon(data, packed, opts)
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local strict = opts and opts.strict
|
||||
-- forceLevel comes from an "auto-level" ruling. The picker's ANY choice
|
||||
-- ("use each mon's real level", Gen1's only mode) is a string sentinel on
|
||||
-- the LinkState/Tournament side (see levelForWire) that must mean "no
|
||||
-- forced level" here. Coerce once so a non-numeric level string -- the ANY
|
||||
-- sentinel, an old peer, or a mod (#204) -- can never reach math.floor
|
||||
-- below: tonumber("ANY") == nil, i.e. keep the packed real level, while
|
||||
-- tonumber(50)/tonumber("50") both give 50.
|
||||
local forceLevel = opts and tonumber(opts.forceLevel) or nil
|
||||
local def = data.pokemon[packed.species]
|
||||
if not def then
|
||||
if strict then return nil, "unknown POKéMON" end
|
||||
@@ -81,8 +96,8 @@ function Protocol.unpackMon(data, packed, opts)
|
||||
-- ignored and everyone rebuilds at the same fixed level instead, so a
|
||||
-- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
|
||||
-- the identical forceLevel for a given match, so this stays symmetric.
|
||||
if opts and opts.forceLevel then
|
||||
level = math.max(2, math.min(100, math.floor(opts.forceLevel)))
|
||||
if forceLevel then
|
||||
level = math.max(2, math.min(100, math.floor(forceLevel)))
|
||||
end
|
||||
local dvs = {}
|
||||
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
||||
@@ -115,10 +130,19 @@ function Protocol.unpackMon(data, packed, opts)
|
||||
-- current HP/status (a different level's numbers, possibly mid-fight)
|
||||
-- isn't meaningful anymore -- auto-level starts everyone full and fresh,
|
||||
-- same as a standardized tournament format would
|
||||
local forced = opts and opts.forceLevel
|
||||
local forced = forceLevel
|
||||
local hp = forced and stats.hp
|
||||
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
|
||||
local status = forced and nil or packed.status
|
||||
-- preserve the sender's original-trainer identity (party_struct MON_OTID +
|
||||
-- wPartyMonOT on a real cable), clamped/typed like every other field so a
|
||||
-- tampered packet can't inject a bad ID or a huge name. Left nil when the
|
||||
-- packet omits them (a v1/old peer) -- no worse than before for that legacy
|
||||
-- path, and once ot is set the load-time stampOT backfill (mon.ot or ...)
|
||||
-- becomes a no-op so the sender's identity survives save/reload (#215).
|
||||
local otId = packed.otId
|
||||
and math.max(0, math.min(65535, math.floor(packed.otId))) or nil
|
||||
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
|
||||
return {
|
||||
species = packed.species,
|
||||
level = level,
|
||||
@@ -129,6 +153,8 @@ function Protocol.unpackMon(data, packed, opts)
|
||||
hp = hp,
|
||||
status = status,
|
||||
nickname = packed.nickname,
|
||||
ot = ot,
|
||||
otId = otId,
|
||||
moves = moves,
|
||||
-- a namespace whose mod this install lacks survives untouched, so the
|
||||
-- mon keeps it for the trip home
|
||||
|
||||
@@ -57,7 +57,12 @@ local function levelLabel(v)
|
||||
end
|
||||
|
||||
local function levelForWire(v)
|
||||
return v == ANY and nil or v
|
||||
-- ANY ("use each mon's real level") goes on the wire as nil (no forced
|
||||
-- level). An explicit guard, not `v == ANY and nil or v`: that idiom's
|
||||
-- true branch is nil, so it falls through to `or v` and returned the
|
||||
-- literal "ANY" string, which then crashed math.floor in unpackMon (#204).
|
||||
if v == ANY then return nil end
|
||||
return v
|
||||
end
|
||||
|
||||
local function forceLevelLabel(v)
|
||||
|
||||
@@ -106,11 +106,55 @@ function Evolution.apply(game, mon, newSpecies, via)
|
||||
})
|
||||
end
|
||||
|
||||
-- After the "evolved into" text, Gen1 re-runs the level-up learn check on
|
||||
-- the EVOLVED species (engine/pokemon/evos_moves.asm EvolveMon calls the
|
||||
-- LearnMoveFromLevelUp predef, engine/pokemon/learn_move.asm) -- a mon
|
||||
-- evolving at exactly a learnset level gains that move (GYARADOS learns
|
||||
-- BITE at 20, so MAGIKARP->GYARADOS @20 learns BITE, @21 does not) (#12).
|
||||
-- Mirrors the rare-candy learn loop in src/ui/BagMenu.lua so a full move
|
||||
-- list opens the forget prompt. mon.species is already the new species
|
||||
-- (Evolution.apply ran before the congrats text). onDone runs once the
|
||||
-- learn list is exhausted, replacing the caller's direct onDone.
|
||||
function Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
local Experience = require("src.battle.Experience")
|
||||
local def = game.data.pokemon[mon.species]
|
||||
-- movesLearnedAt uses entry.level == level (exact Gen1 rule); do NOT use
|
||||
-- Pokemon.movesAtLevel (<= level), which would over-grant older moves.
|
||||
local moves = Experience.movesLearnedAt(def, mon.level)
|
||||
local i = 0
|
||||
local function nextStep()
|
||||
i = i + 1
|
||||
local moveId = moves[i]
|
||||
if not moveId then
|
||||
if onDone then onDone() end
|
||||
return
|
||||
end
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
if mv.id == moveId then return nextStep() end
|
||||
end
|
||||
local mdef = game.data.moves[moveId]
|
||||
if not mdef then return nextStep() end
|
||||
local name = mon.nickname or def.name
|
||||
if #mon.moves < 4 then
|
||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
game.stack:push(TextBox.new(game,
|
||||
("%s learned\n%s!"):format(name, mdef.name), nextStep))
|
||||
else
|
||||
-- LearnMoveFromLevelUp with a full moveset: the forget UI
|
||||
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
|
||||
end
|
||||
end
|
||||
nextStep()
|
||||
end
|
||||
|
||||
-- Play the evolution movie (flashing forms), then apply + text.
|
||||
-- Headless (no real graphics) falls back to the plain text flow.
|
||||
function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||
if love.image and love.image.newImageData then
|
||||
Screens.push(game, "EvolutionState", mon, newSpecies, onDone)
|
||||
-- forward `via` so EvolutionState can keep trade evolutions
|
||||
-- non-cancelable (LINK_STATE_TRADING) while others accept B (#213)
|
||||
Screens.push(game, "EvolutionState", mon, newSpecies, onDone, via)
|
||||
return
|
||||
end
|
||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||
@@ -120,7 +164,9 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||
:format(oldName, oldName, game.data.pokemon[newSpecies].name)
|
||||
game.stack:push(TextBox.new(game, msg, function()
|
||||
Music.restoreMap(game.data)
|
||||
if onDone then onDone() end
|
||||
-- re-run the evolved species' level-up learn check before onDone
|
||||
-- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12)
|
||||
Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
end))
|
||||
end
|
||||
|
||||
|
||||
+22
-10
@@ -77,7 +77,17 @@ end
|
||||
-- one-pixel sliver. The fill is tinted with the SGB bar palettes at
|
||||
-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else
|
||||
-- red).
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType)
|
||||
--
|
||||
-- grayFill (#229): when the caller will colorize this bar with an SGB
|
||||
-- region palette (BattleState's zone pass, BATTLE_ZONES pal 0/1 =
|
||||
-- GetHealthBarColor), leave the fill as its raw DMG shade-2 gray and skip
|
||||
-- the per-pixel tint -- the DMG hardware bar is ONE gray shade recolored by
|
||||
-- the region palette (engine/gfx/palettes.asm SetPal_Battle,
|
||||
-- data/sgb/sgb_packets.asm BlkPacket_Battle), never a per-pixel repaint.
|
||||
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
|
||||
-- red channel 0, so the tint zeroes the whole bar's red and the zone's
|
||||
-- red-channel-keyed shade shader then maps every pixel to color 3 = black.
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill)
|
||||
local x, y = tx * 8, ty * 8
|
||||
HudTiles.tile(0x71, x, y)
|
||||
HudTiles.tile(0x62, x + 8, y)
|
||||
@@ -86,15 +96,17 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType)
|
||||
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
|
||||
end
|
||||
local tint
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
|
||||
local colors = PaletteFX.pal(data, name)
|
||||
if colors then
|
||||
local c = colors[3] -- GB color 2 is the fill shade
|
||||
-- the fill pixels are the 2/3-gray shade; divide so they land on
|
||||
-- the palette color exactly (the black outline stays black)
|
||||
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
|
||||
math.min(1, c[3] / 170), 1 }
|
||||
if not grayFill then
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
|
||||
local colors = PaletteFX.pal(data, name)
|
||||
if colors then
|
||||
local c = colors[3] -- GB color 2 is the fill shade
|
||||
-- the fill pixels are the 2/3-gray shade; divide so they land on
|
||||
-- the palette color exactly (the black outline stays black)
|
||||
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
|
||||
math.min(1, c[3] / 170), 1 }
|
||||
end
|
||||
end
|
||||
for i = 0, 5 do
|
||||
local seg = math.min(8, math.max(0, px - i * 8))
|
||||
|
||||
+44
-17
@@ -50,14 +50,23 @@ PaletteFX.GBC_OBJ = {
|
||||
}
|
||||
|
||||
-- OG BLUE: Pokemon Blue's Game Boy Color boot-ROM auto-palette. Same
|
||||
-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the
|
||||
-- boot ROM colorizes the background blue instead of red -- so "OG RED" for a
|
||||
-- Blue playthrough is white -> light blue -> dark blue -> black, mirroring
|
||||
-- GBC_BG channel-for-channel so the blue reads at the same brightness. The
|
||||
-- OBJ (sprite) palette stays the same green, matching how Red and Blue share
|
||||
-- the green-character look on a Game Boy Color.
|
||||
-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the boot
|
||||
-- ROM gives Blue its OWN entry rather than a recolored Red: a light-blue/blue
|
||||
-- BACKGROUND and -- unlike Red -- a PINK object palette (OBP0). Values from
|
||||
-- Bulbapedia's "List of color palettes ... Generation I" GBC boot-ROM table
|
||||
-- (BG 0x63A5FF/0x0000FF, OBJ 0xFF8484/0x943A3A), confirmed against a Gambatte
|
||||
-- hardware capture. The earlier code mirrored GBC_BG channel-for-channel
|
||||
-- (0x8484FF/0x3A3A94) and reused Red's green sprites for both versions on the
|
||||
-- premise that Red and Blue "share the green-character look"; both premises
|
||||
-- are wrong -- Blue's background is a genuinely different blue and its
|
||||
-- characters are pink (#155). Lightest shade first, like GBC_BG.
|
||||
PaletteFX.GBC_BG_BLUE = {
|
||||
{ 255, 255, 255 }, { 132, 132, 255 }, { 58, 58, 148 }, { 0, 0, 0 },
|
||||
{ 255, 255, 255 }, { 99, 165, 255 }, { 0, 0, 255 }, { 0, 0, 0 },
|
||||
}
|
||||
-- Blue's OBJ palette (OBP0) is the red/pink ramp -- the very same colors OG
|
||||
-- RED uses for its BACKGROUND (GBC_BG), just applied to objects instead.
|
||||
PaletteFX.GBC_OBJ_BLUE = {
|
||||
{ 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 },
|
||||
}
|
||||
|
||||
-- The active game's OG boot-ROM background palette: blue for a Blue
|
||||
@@ -69,6 +78,16 @@ function PaletteFX.ogBg()
|
||||
return PaletteFX.GBC_BG
|
||||
end
|
||||
|
||||
-- The active game's OG boot-ROM object palette (OBP0): Blue's pink ramp for a
|
||||
-- Blue playthrough, Red's green otherwise. Returns the colors AND a
|
||||
-- version-distinct cache-group string, because SpriteRenderer.getObpImage keys
|
||||
-- its baked-image cache by (image path, group): a shared group would collide a
|
||||
-- Red bake with a Blue one and one version would show the other's colors.
|
||||
function PaletteFX.ogObj()
|
||||
if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end
|
||||
return PaletteFX.GBC_OBJ, "gbcobj"
|
||||
end
|
||||
|
||||
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
|
||||
|
||||
function PaletteFX.shader()
|
||||
@@ -183,17 +202,25 @@ function PaletteFX.usesGbcPack(mode)
|
||||
end
|
||||
|
||||
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
|
||||
-- (the OBP bake + post-zone redraw path). ONLY OG RED does: it wears the
|
||||
-- GBC boot-ROM green object palette (PaletteFX.GBC_OBJ) so the player and
|
||||
-- NPCs stay green over the red background, exactly like Pokemon Red on a
|
||||
-- Game Boy Color. SGB mode deliberately does NOT: an SGB OBJ carries no
|
||||
-- palette of its own, so the characters tint with the whole-map region
|
||||
-- palette along with the terrain (the Super Game Boy never colored Pokemon
|
||||
-- Red's sprites separately -- baking a per-sprite palette there was the
|
||||
-- "reds coloring on the player/NPCs" bug). RED++ colors sprites through
|
||||
-- the usesGbcPack() path in SpriteRenderer instead.
|
||||
-- (the OBP bake + post-zone redraw path). OG RED and SGB both do: characters
|
||||
-- wear the GBC boot-ROM object palette (PaletteFX.ogObj -- green over Red's red
|
||||
-- background, pink over Blue's blue background), so the player and NPCs carry a
|
||||
-- fixed object color instead of tinting with whatever region palette their
|
||||
-- feet stand over. On real hardware a sprite is an OBJ colored by an OBJ
|
||||
-- palette (color/sprites.asm ColorOverworldSprite), distinct from the BG it
|
||||
-- overlaps -- so Red's cap must stay green in tall grass, not turn the ROUTE
|
||||
-- palette's light-blue (issue #150: SGB region-tinting sent the cap to shade-2
|
||||
-- = light-blue and the character clashed with the grass it should blend into).
|
||||
-- Terrain is unaffected -- pal() below still hands SGB its per-map BG palette;
|
||||
-- only OG RED short-circuits BG to the one global red palette. An EARLIER
|
||||
-- attempt at per-sprite SGB color baked GBC_BG (the RED background ramp) onto
|
||||
-- characters -- that was the "reds coloring on the player/NPCs" bug; the object
|
||||
-- palette is GBC_OBJ (green), so baking it here is the fix, not that
|
||||
-- regression. RED++ colors sprites through the usesGbcPack() path in
|
||||
-- SpriteRenderer instead.
|
||||
function PaletteFX.usesSpriteObp(mode)
|
||||
return (mode or PaletteFX.mode) == "ogred"
|
||||
mode = mode or PaletteFX.mode
|
||||
return mode == "ogred" or mode == "gbc"
|
||||
end
|
||||
|
||||
-- ------- post-zone sprite redraw (GBC mode)
|
||||
|
||||
+28
-4
@@ -46,18 +46,30 @@ Renderer.UPRIGHT_MARGIN = 160
|
||||
-- non-square "pixels", and movement judder (issue #87). Always derive the
|
||||
-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi)
|
||||
-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
|
||||
--
|
||||
-- `dpi` here must be the factor LOVE actually applies to every draw call
|
||||
-- (getDPIScale), NOT the drawable/unit size ratio pw/ww. On a normal device
|
||||
-- those are equal (getPixelDimensions == getDimensions * getDPIScale), but on
|
||||
-- the AYN Thor dual-screen surface in forced landscape they diverge: LOVE
|
||||
-- reports pw/ww ≈ 1 while its real transform is 1.5, so scaling by pw/ww lands
|
||||
-- each GB pixel on Sp*(getDPIScale/(pw/ww)) physical pixels -- a fractional,
|
||||
-- stretched/non-square count (issue #208). Prefer getDPIScale so draws land
|
||||
-- on whole physical pixels through LOVE's actual transform; fall back to pw/ww
|
||||
-- (then 1) only when getDPIScale is unavailable. Since getDPIScale == pw/ww
|
||||
-- on every normal device, #87's behavior is byte-identical there.
|
||||
local function displayMetrics()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local pw, ph = ww, wh
|
||||
if love.graphics.getPixelDimensions then
|
||||
pw, ph = love.graphics.getPixelDimensions()
|
||||
end
|
||||
local dpi = 1
|
||||
if ww > 0 and pw > 0 then
|
||||
dpi = pw / ww
|
||||
elseif love.graphics.getDPIScale then
|
||||
local dpi
|
||||
if love.graphics.getDPIScale then
|
||||
dpi = love.graphics.getDPIScale()
|
||||
end
|
||||
if (not dpi or dpi < 1e-6) and ww > 0 and pw > 0 then
|
||||
dpi = pw / ww
|
||||
end
|
||||
if not dpi or dpi < 1e-6 then dpi = 1 end
|
||||
return ww, wh, pw, ph, dpi
|
||||
end
|
||||
@@ -99,6 +111,18 @@ function Renderer:fitScale()
|
||||
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
|
||||
end
|
||||
|
||||
-- The LOVE-unit draw scale endFrame uses for the UI blit: the integer
|
||||
-- framebuffer scale (fitScale) divided by the live coordinate->pixel factor,
|
||||
-- so a GB pixel lands on fitScale() whole PHYSICAL pixels once LOVE applies
|
||||
-- its own transform (fitScale() == drawScale() * dpi). Exposed so #208's
|
||||
-- regression can assert GB pixels stay square on divergent-DPI surfaces
|
||||
-- without reaching into endFrame's locals; endFrame recomputes the same value
|
||||
-- inline (`S = Sp / dpi`).
|
||||
function Renderer:drawScale()
|
||||
local _, _, _, _, dpi = displayMetrics()
|
||||
return self:fitScale() / dpi
|
||||
end
|
||||
|
||||
-- world-pass canvas size in world pixels: enough to fill the window at s'.
|
||||
-- In tilt mode the canvas grows (both dimensions, by Tilt.viewGrowth) so
|
||||
-- the projected ground plane still covers the whole window with no
|
||||
|
||||
@@ -105,7 +105,10 @@ function SpriteRenderer:resolveImage()
|
||||
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
|
||||
if colors then return getObpImage(self.def.image, colors, group) end
|
||||
elseif PaletteFX.usesSpriteObp() then
|
||||
return getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
|
||||
-- OG boot-ROM OBJ palette: green on Red, pink on Blue (PaletteFX.ogObj
|
||||
-- returns colors + a version-distinct cache group so the two never
|
||||
-- collide in obpCache) -- see issue #155
|
||||
return getObpImage(self.def.image, PaletteFX.ogObj())
|
||||
end
|
||||
return self.image
|
||||
end
|
||||
@@ -141,11 +144,13 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
||||
image = getObpImage(self.def.image, colors, group)
|
||||
end
|
||||
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
||||
-- OG RED (GBC boot-ROM look): every OBJ wears the one global green
|
||||
-- object palette. The red BG zone shader still runs over the world
|
||||
-- canvas, so the baked sprite is queued for a post-zone redraw
|
||||
-- (PaletteFX.markSpriteRedraw) that restores its green pixels on top.
|
||||
image = getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
|
||||
-- OG RED (GBC boot-ROM look): every OBJ wears the one global object
|
||||
-- palette -- green over Red's red background, pink over Blue's blue
|
||||
-- background (PaletteFX.ogObj, #155). The BG zone shader still runs over
|
||||
-- the world canvas, so the baked sprite is queued for a post-zone redraw
|
||||
-- (PaletteFX.markSpriteRedraw) that restores its object-colored pixels on
|
||||
-- top.
|
||||
image = getObpImage(self.def.image, PaletteFX.ogObj())
|
||||
redraw = true
|
||||
end
|
||||
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
||||
|
||||
+17
-7
@@ -214,11 +214,10 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
and ow.map.id ~= "AGATHAS_ROOM" then
|
||||
list:close()
|
||||
consume(game, id)
|
||||
require("src.core.Sound").play(game.data, "Teleport_Exit1")
|
||||
ow.player.surfing = false
|
||||
-- EnterMapAnim on arrival (BIT_ESCAPE_WARP / special warp path);
|
||||
-- blackouts omit arrive="teleport" (HandleBlackOut has no LeaveMapAnim)
|
||||
ow:warpToHealPoint(nil, { arrive = "teleport" })
|
||||
-- LeaveMapAnim spin-up + SFX_TELEPORT_EXIT_1, a fade, then land OUTSIDE
|
||||
-- the last Pokémon Center town door like Fly (#196), via the shared
|
||||
-- departure helper -- the same path Dig/Teleport take from the party menu
|
||||
ow:beginTeleportOut()
|
||||
else
|
||||
showMessages(game, { "OAK: " .. game.save.player.name
|
||||
.. "!\nThis isn't the\ntime to use that!" })
|
||||
@@ -303,7 +302,8 @@ local function pickTargetAndUse(game, battle, id, list)
|
||||
-- the ETHERs and PP UP open the move menu after picking a mon
|
||||
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
|
||||
local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP"
|
||||
require("src.ui.Screens").push(game, "PartyMenu", {
|
||||
local def = game.data.items[id]
|
||||
local opts = {
|
||||
pickOnly = true,
|
||||
onSwitch = function(mon)
|
||||
if not wantsMove then
|
||||
@@ -326,7 +326,17 @@ local function pickTargetAndUse(game, battle, id, list)
|
||||
end,
|
||||
}))
|
||||
end,
|
||||
})
|
||||
}
|
||||
-- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon
|
||||
-- shows ABLE / NOT ABLE from its learnset and the prompt reads "Use TM on
|
||||
-- which POKeMON?" (engine/items/item_effects.asm ItemUseTMHM ->
|
||||
-- party_menu.asm TM/HM type). Stones and other pickOnly items keep the
|
||||
-- plain HP layout (Gen 1 shows no ABLE/NOT ABLE for them), so gate
|
||||
-- strictly on def.machine. #210
|
||||
if def and def.machine then
|
||||
opts.tmhm = { move = def.machine.move, kind = def.machine.kind }
|
||||
end
|
||||
require("src.ui.Screens").push(game, "PartyMenu", opts)
|
||||
end
|
||||
|
||||
local function useItem(game, battle, id, list)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
|
||||
-- flashes back and forth with the evolved form, speeding up, then the
|
||||
-- new form appears with its cry and the congratulations text.
|
||||
-- B during the flash cancels ("Huh? ... stopped evolving!"? -- Gen 1
|
||||
-- has no cancel; the flash always completes).
|
||||
-- pokered engine/pokemon/evos_moves.asm (EvolveMon) polls hJoyHeld during
|
||||
-- the flash: holding B aborts the evolution -- the mon keeps its species
|
||||
-- and _StoppedEvolvingText ("Huh? MON stopped evolving!") prints. The
|
||||
-- lone exception is trade evolutions (wLinkState == LINK_STATE_TRADING),
|
||||
-- which skip that poll and cannot be cancelled (#213).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
@@ -14,7 +17,10 @@ EvolutionState.isOpaque = true
|
||||
-- SGB: SetPal_PokemonWholeScreen for the mon on display
|
||||
function EvolutionState:sgbPalettes(game)
|
||||
local P = require("src.render.PaletteFX")
|
||||
local species = self.done and self.newSpecies or self.mon.species
|
||||
-- a cancelled evolution keeps the old species (never applied), so only
|
||||
-- colorize with the new form once it has actually evolved
|
||||
local species = (self.done and not self.canceled) and self.newSpecies
|
||||
or self.mon.species
|
||||
local c = P.monPal(game.data, species)
|
||||
if c then return { P.whole(c) } end
|
||||
return P.wholeNamed(game.data, "MEWMON")
|
||||
@@ -29,17 +35,22 @@ local function frontSprite(game, species)
|
||||
return ok and img or nil
|
||||
end
|
||||
|
||||
function EvolutionState.new(game, mon, newSpecies, onDone)
|
||||
function EvolutionState.new(game, mon, newSpecies, onDone, via)
|
||||
local self = setmetatable({}, EvolutionState)
|
||||
self.game = game
|
||||
self.mon = mon
|
||||
self.newSpecies = newSpecies
|
||||
self.onDone = onDone
|
||||
self.via = via
|
||||
-- evos_moves.asm: only trade evolutions (LINK_STATE_TRADING) skip the
|
||||
-- B-cancel poll; level-up, stone and rare-candy evos are all cancelable.
|
||||
self.cancelable = (via ~= "TRADE")
|
||||
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||
self.oldSprite = frontSprite(game, mon.species)
|
||||
self.newSprite = frontSprite(game, newSpecies)
|
||||
self.t = 0
|
||||
self.done = false
|
||||
self.canceled = false
|
||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||
return self
|
||||
end
|
||||
@@ -47,11 +58,28 @@ end
|
||||
function EvolutionState:update(dt)
|
||||
self.t = self.t + 1
|
||||
if self.done then return end
|
||||
local game = self.game
|
||||
-- evos_moves.asm EvolveMon: each flash iteration polls hJoyHeld and, for
|
||||
-- a cancelable evolution, aborts when B is held -- the mon keeps its
|
||||
-- species (Evolution.apply never runs) and _StoppedEvolvingText prints.
|
||||
if self.cancelable and game.input:isDown("b") then
|
||||
self.done = true
|
||||
self.canceled = true
|
||||
local TextBox = require("src.render.TextBox")
|
||||
-- mirrors data/generated/text.lua _StoppedEvolvingText
|
||||
game.stack:push(TextBox.new(game,
|
||||
("Huh? %s\nstopped evolving!"):format(self.oldName),
|
||||
function()
|
||||
Music.restoreMap(game.data)
|
||||
game.stack:pop() -- the evolution screen itself
|
||||
if self.onDone then self.onDone() end
|
||||
end))
|
||||
return
|
||||
end
|
||||
if self.t >= FLASH_FRAMES then
|
||||
self.done = true
|
||||
local game = self.game
|
||||
local Evolution = require("src.pokemon.Evolution")
|
||||
Evolution.apply(game, self.mon, self.newSpecies)
|
||||
Evolution.apply(game, self.mon, self.newSpecies, self.via)
|
||||
require("src.core.Sound").playCry(game.data, self.newSpecies)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local newName = game.data.pokemon[self.newSpecies].name
|
||||
@@ -61,7 +89,12 @@ function EvolutionState:update(dt)
|
||||
function()
|
||||
Music.restoreMap(game.data)
|
||||
game.stack:pop() -- the evolution screen itself
|
||||
if self.onDone then self.onDone() end
|
||||
-- Gen1 re-runs the level-up learn check on the evolved species
|
||||
-- after the "evolved into" text (evos_moves.asm EvolveMon ->
|
||||
-- learn_move.asm LearnMoveFromLevelUp, #12). Pop the evo screen
|
||||
-- first so the "learned MOVE!" text / forget prompt push onto the
|
||||
-- overworld / battle-return, not this state.
|
||||
Evolution.learnEvolutionMoves(game, self.mon, self.onDone)
|
||||
end))
|
||||
end
|
||||
end
|
||||
@@ -73,7 +106,8 @@ function EvolutionState:draw()
|
||||
-- accelerating flash between the two forms
|
||||
local sprite
|
||||
if self.done then
|
||||
sprite = self.newSprite
|
||||
-- a cancelled evolution settles back on the original form
|
||||
sprite = self.canceled and self.oldSprite or self.newSprite
|
||||
else
|
||||
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
|
||||
local showNew = math.floor(self.t / period) % 2 == 1
|
||||
|
||||
+6
-2
@@ -11,9 +11,13 @@ function FlyMenu.new(game)
|
||||
local visited = game.save.visited or {}
|
||||
local seen = {}
|
||||
for _, mapId in ipairs(game.data.field.flyOrder or {}) do
|
||||
-- towns only (dungeon escape spots share the table), each listed once
|
||||
-- towns only (dungeon escape spots share the table), each listed once.
|
||||
-- Indigo Plateau (tileset PLATEAU) is a valid Fly destination too, so allow
|
||||
-- it past the OVERWORLD-only isOutdoor gate while the CAVERN/FACILITY escape
|
||||
-- spots stay excluded (LoadTownMap_Fly cycles it like any town, #203).
|
||||
local def = game.data.maps[mapId]
|
||||
if visited[mapId] and def and Map.isOutdoor(def) and not seen[mapId] then
|
||||
if visited[mapId] and def and not seen[mapId]
|
||||
and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then
|
||||
seen[mapId] = true
|
||||
table.insert(items, {
|
||||
value = mapId,
|
||||
|
||||
+96
-23
@@ -132,6 +132,10 @@ function PartyMenu.new(game, opts)
|
||||
self.onSwitch = opts.onSwitch
|
||||
self.onCancel = opts.onCancel
|
||||
self.pickOnly = opts.pickOnly
|
||||
-- TM/HM teaching: opts.tmhm = { move, kind } switches the list to Gen 1's
|
||||
-- TM/HM display (ABLE / NOT ABLE per mon instead of the HP bar, and the
|
||||
-- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210
|
||||
self.tmhm = opts.tmhm
|
||||
self.forceSwitch = opts.forceSwitch
|
||||
self.battle = opts.battle
|
||||
self.party = opts.party -- link battles pass their clamped copies
|
||||
@@ -178,8 +182,15 @@ function PartyMenu:update(dt)
|
||||
elseif action == "switch" then
|
||||
self.swapFrom = self.index
|
||||
elseif action == "fly" then
|
||||
-- FLY opens the TOWN MAP with a cursor over the visited fly towns,
|
||||
-- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly).
|
||||
-- flyTo (OverworldController) validates the fly-warp + runs the
|
||||
-- departure/warp, so we just hand it the chosen mapId (#195).
|
||||
local ow = self.game.overworld
|
||||
self.game.stack:pop() -- close the party menu
|
||||
Screens.push(self.game, "FlyMenu")
|
||||
Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId)
|
||||
if ow then ow:flyTo(mapId) end
|
||||
end })
|
||||
return
|
||||
elseif action == "flash" then -- FLASH lights dark tunnels
|
||||
-- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText, then
|
||||
@@ -304,21 +315,15 @@ function PartyMenu:update(dt)
|
||||
-- 1/5 of the user's max HP to a chosen teammate
|
||||
self.softboiledFrom = self.index
|
||||
elseif action == "escape" then
|
||||
-- DIG / TELEPORT both warp to the last Pokémon Center town
|
||||
-- (wLastBlackoutMap, special_warps.asm escape warp); .dig/.teleport
|
||||
-- end with GBPalWhiteOutWithDelay3 + jp .goBackToMap
|
||||
-- DIG / TELEPORT warp to the last Pokémon Center TOWN (wLastBlackoutMap,
|
||||
-- special_warps.asm escape warp). pokered's .dig/.teleport spin the
|
||||
-- player up (LeaveMapAnim), white/fade out, then land it; this port
|
||||
-- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut
|
||||
-- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares
|
||||
-- the exact departure; the fade + warp fire when the spin ends.
|
||||
local ow = self.game.overworld
|
||||
local heal = self.game.save.lastHeal
|
||||
local Transition = require("src.render.Transition")
|
||||
self.game.stack:pop()
|
||||
if ow and heal then
|
||||
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
|
||||
require("src.core.Sound").play(self.game.data, "Teleport_Exit1")
|
||||
-- EnterMapAnim on arrival (HandleFlyWarpOrDungeonWarp sets
|
||||
-- BIT_FLY_WARP); blackouts must not pass arrive="teleport"
|
||||
ow:warpToHealPoint(nil, { arrive = "teleport" })
|
||||
end))
|
||||
end
|
||||
if ow then ow:beginTeleportOut() end
|
||||
return
|
||||
end
|
||||
self.submenu = nil
|
||||
@@ -436,6 +441,30 @@ function PartyMenu:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- The bottom-of-screen context message for the current menu state
|
||||
-- (pokered engine/menus/party_menu.asm PartyMenuMessage / RedrawPartyMenu_):
|
||||
-- the party menu always prints a message in the bottom text box. With the
|
||||
-- normal message id that is PartyMenuBattleText ("Bring out which POKéMON?")
|
||||
-- when IsInBattle else PartyMenuNormalText ("Choose a POKéMON."); the swap /
|
||||
-- item / TM-HM ids print their own strings, which draw() handles inline.
|
||||
-- Pure (no side effects) so drivers can assert it. #147
|
||||
function PartyMenu:bottomMessage()
|
||||
if self.swapFrom then
|
||||
return "Move to where?"
|
||||
elseif self.softboiledFrom or self.pickOnly then
|
||||
return "Use on which one?"
|
||||
elseif self.tmhm then
|
||||
return self.game.data.text._PartyMenuUseTMText
|
||||
or "Use TM on which\nPOKéMON?"
|
||||
elseif self.battle then
|
||||
return self.game.data.text._PartyMenuBattleText
|
||||
or "Bring out which\nPOKéMON?"
|
||||
else
|
||||
return self.game.data.text._PartyMenuNormalText
|
||||
or "Choose a POKéMON."
|
||||
end
|
||||
end
|
||||
|
||||
function PartyMenu:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
@@ -462,16 +491,34 @@ function PartyMenu:draw()
|
||||
-- PrintLevel overwrites the <LV> tile with the third digit
|
||||
Font.draw(tostring(mon.level), 104, y)
|
||||
end
|
||||
if mon.hp <= 0 then
|
||||
Font.draw("FNT", 136, y)
|
||||
elseif mon.status then
|
||||
Font.draw(mon.status, 136, y)
|
||||
if self.tmhm then
|
||||
-- TM/HM teaching menu (engine/menus/party_menu.asm PrintPartyMenu):
|
||||
-- the second row shows the inline "ABLE" / "NOT ABLE" learnability
|
||||
-- strings in place of the HP bar and status, decided by CanLearnTM.
|
||||
-- The learnset scan mirrors ItemEffects.use so the display can never
|
||||
-- disagree with the actual teach. #210
|
||||
local can = false
|
||||
for _, m in ipairs(def.tmhm or {}) do
|
||||
if m == self.tmhm.move then can = true break end
|
||||
end
|
||||
-- right-aligned so the shorter "ABLE" shares "NOT ABLE"'s right edge
|
||||
if can then
|
||||
Font.draw("ABLE", 120, y + 8)
|
||||
else
|
||||
Font.draw("NOT ABLE", 88, y + 8)
|
||||
end
|
||||
else
|
||||
if mon.hp <= 0 then
|
||||
Font.draw("FNT", 136, y)
|
||||
elseif mon.status then
|
||||
Font.draw(mon.status, 136, y)
|
||||
end
|
||||
-- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8)
|
||||
end
|
||||
-- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8)
|
||||
if i == self.index then
|
||||
Font.drawCode(Theme.cursor, 0, y)
|
||||
end
|
||||
@@ -483,8 +530,34 @@ function PartyMenu:draw()
|
||||
Font.draw("Move to where?", 8, 136)
|
||||
elseif self.softboiledFrom then
|
||||
Font.draw("Use on which one?", 8, 136)
|
||||
elseif self.tmhm then
|
||||
-- "Use TM on which\nPOKeMON?" in the standard bottom text box
|
||||
-- (party_menu.asm keeps the message box for the TM/HM menu); box + line
|
||||
-- geometry match TextBox's default (rows 12-17, text on rows 14/16). #210
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local prompt = self.game.data.text._PartyMenuUseTMText
|
||||
or "Use TM on which\nPOKéMON?"
|
||||
local ly = 112
|
||||
for line in (prompt .. "\n"):gmatch("([^\n]*)\n") do
|
||||
Font.draw(line, 8, ly)
|
||||
ly = ly + 16
|
||||
end
|
||||
elseif self.pickOnly then
|
||||
Font.draw("Use on which one?", 8, 136)
|
||||
else
|
||||
-- default field party menu (StartMenu) and the battle voluntary-switch
|
||||
-- (BattleState:openParty): Gen1 prints PartyMenuNormalText / PartyMenuBattleText
|
||||
-- in the standard bottom text box (party_menu.asm PartyMenuMessage), not
|
||||
-- plain bottom-row text. Box + line geometry match the #210 TM/HM case and
|
||||
-- TextBox's default (rows 12-17, text on rows 14/16). #147
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local ly = 112
|
||||
for line in (self:bottomMessage() .. "\n"):gmatch("([^\n]*)\n") do
|
||||
Font.draw(line, 8, ly)
|
||||
ly = ly + 16
|
||||
end
|
||||
end
|
||||
if self.submenu then
|
||||
local n = #self.subItems
|
||||
|
||||
+5
-2
@@ -106,8 +106,11 @@ local function sell(game)
|
||||
onChoose = function(item)
|
||||
local def = game.data.items[item.value]
|
||||
-- only key items and HMs are unsellable (pokemart.asm IsKeyItem /
|
||||
-- IsItemHM); zero-price items like ETHER sell for ¥0
|
||||
if (def and def.keyItem) or item.value:find("^HM_") then
|
||||
-- IsItemHM); zero-price items like ETHER sell for ¥0. An unknown id
|
||||
-- (nil def) has no price, so treat it as unsellable too rather than
|
||||
-- indexing nil below -- guards saves that already picked up a bogus
|
||||
-- ITEM_NONE "0" from Blue's House before that pickup was fixed (#11).
|
||||
if not def or def.keyItem or item.value:find("^HM_") then
|
||||
list.footer = txt(game, "_PokemartUnsellableItemText",
|
||||
"I can't put a\nprice on that.")
|
||||
return
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
-- A on page 2) closes.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
-- status_screen.asm PrintMonType prints the type's DISPLAY name from the
|
||||
-- TypeNames table, not the constant: species types are stored as pokered
|
||||
-- constants (RomExtractor:typesById) and PSYCHIC's is "PSYCHIC_TYPE" (so it
|
||||
-- won't collide with the PSYCHIC move), which would overflow the TYPE field.
|
||||
-- TypeChart.displayName maps it back to "PSYCHIC", like HallOfFame and the
|
||||
-- battle move-type box already do (#214).
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
|
||||
local SummaryMenu = {}
|
||||
SummaryMenu.__index = SummaryMenu
|
||||
@@ -99,10 +106,10 @@ function SummaryMenu:draw()
|
||||
-- TYPE1/TYPE2/IDNo/OT column (10,9) with values indented (11,10)
|
||||
drawLineBox(19, 9, 8, 6)
|
||||
Font.draw("TYPE1/", 80, 72)
|
||||
Font.draw(def.types[1] or "", 88, 80)
|
||||
Font.draw(def.types[1] and TypeChart.displayName(def.types[1]) or "", 88, 80)
|
||||
if def.types[2] then
|
||||
Font.draw("TYPE2/", 80, 88)
|
||||
Font.draw(def.types[2], 88, 96)
|
||||
Font.draw(TypeChart.displayName(def.types[2]), 88, 96)
|
||||
end
|
||||
Font.draw("IDNo/", 80, 104)
|
||||
-- the trainer ID is rolled at new game (SaveData.newGame) and
|
||||
|
||||
+101
-10
@@ -7,6 +7,11 @@
|
||||
-- the selected name in a banner up top, and the player's current
|
||||
-- location blinking. List mode (townMap data missing): up/down through
|
||||
-- an ordered list of fly towns instead. B closes.
|
||||
--
|
||||
-- Fly mode (opts.fly + opts.onFly, LoadTownMap_Fly): the same Kanto map,
|
||||
-- but the cursor cycles ONLY the visited fly destinations (Up/Down, in fly
|
||||
-- order), the banner reads "To <NAME>", and A calls onFly(mapId) to depart.
|
||||
-- This is what the party-menu FLY field move opens (#195).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Sound = require("src.core.Sound")
|
||||
@@ -80,7 +85,10 @@ local function buildLocations(game)
|
||||
local seen = {}
|
||||
for _, mapId in ipairs(field.flyOrder or {}) do
|
||||
local def = game.data.maps and game.data.maps[mapId]
|
||||
if not seen[mapId] and def and Map.isOutdoor(def) then
|
||||
-- accept the PLATEAU tileset too so Indigo Plateau shows on the
|
||||
-- stale-asset list fallback, matching the fly-list filter (#203)
|
||||
if not seen[mapId] and def
|
||||
and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then
|
||||
seen[mapId] = true
|
||||
local loc = { name = mapId:gsub("_", " ") }
|
||||
table.insert(locs, loc)
|
||||
@@ -119,6 +127,42 @@ local function markerXY(loc)
|
||||
return loc.x * 8 + 16, loc.y * 8 + 8
|
||||
end
|
||||
|
||||
-- the row-0 name banner; fly mode prefixes "To " like LoadTownMap_Fly
|
||||
-- (engine/menus/town_map.asm prints the destination as "To <NAME>")
|
||||
function TownMap:bannerText(loc)
|
||||
return (self.fly and "To " or "") .. loc.name
|
||||
end
|
||||
|
||||
-- Fly mode selection set (engine/menus/town_map.asm LoadTownMap_Fly): the
|
||||
-- cursor cycles ONLY the visited fly destinations, in fly order, each landing
|
||||
-- on its town square. Built from field.flyOrder filtered to visited outdoor
|
||||
-- towns that have a fly-warp spot, deduped, reusing the grid loc so the cursor
|
||||
-- lands on the town and its name shows in the banner.
|
||||
local function buildFlyList(game, byMap)
|
||||
local field = game.data.field or {}
|
||||
local visited = game.save.visited or {}
|
||||
local flyWarps = field.flyWarps or {}
|
||||
local Map = require("src.world.Map")
|
||||
local flyLocs, flyMapIds, seen = {}, {}, {}
|
||||
for _, mapId in ipairs(field.flyOrder or {}) do
|
||||
local def = game.data.maps and game.data.maps[mapId]
|
||||
-- INDIGO_PLATEAU is a normal Fly spot (engine/menus/town_map.asm
|
||||
-- LoadTownMap_Fly cycles it like any town), but its map uses tileset
|
||||
-- "PLATEAU" not OVERWORLD, so Map.isOutdoor() alone dropped it from the
|
||||
-- cursor even though it is visited and has a fly warp. Allow PLATEAU here
|
||||
-- while the CAVERN/FACILITY dungeon escape spots that share flyOrder still
|
||||
-- fail the gate and stay out (#203).
|
||||
if not seen[mapId] and visited[mapId] and flyWarps[mapId]
|
||||
and def and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then
|
||||
seen[mapId] = true
|
||||
local loc = byMap[mapId] or { name = mapId:gsub("_", " ") }
|
||||
table.insert(flyLocs, loc)
|
||||
flyMapIds[#flyLocs] = mapId
|
||||
end
|
||||
end
|
||||
return flyLocs, flyMapIds
|
||||
end
|
||||
|
||||
-- opts.nestSpecies: the Pokédex AREA screen (LoadTownMap_Nest) --
|
||||
-- blink a nest icon on every map whose wild slots hold the species
|
||||
function TownMap.new(game, opts)
|
||||
@@ -152,6 +196,26 @@ function TownMap.new(game, opts)
|
||||
or "assets/generated/townmap/nest.png")
|
||||
self.nestIcon = ok and img or nil
|
||||
end
|
||||
if opts.fly then
|
||||
-- FLY picker (LoadTownMap_Fly): restrict the selectable set to the
|
||||
-- visited fly towns so Up/Down cycle only those and A knows the mapId.
|
||||
local flyLocs, flyMapIds = buildFlyList(game, self.byMap)
|
||||
if #flyLocs > 0 then
|
||||
self.fly = true
|
||||
self.onFly = opts.onFly
|
||||
self.locs = flyLocs
|
||||
self.flyMapIds = flyMapIds
|
||||
-- grid rendering needs coords on every entry; without them fall back to
|
||||
-- the name list so the fly screen still works on stale asset builds
|
||||
if self.mode == "grid" then
|
||||
for _, loc in ipairs(flyLocs) do
|
||||
if not (loc.x and loc.y) then self.mode = "list" break end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- with nothing visited yet there is nowhere to fly: leave self.fly unset
|
||||
-- so the screen degrades to a plain viewer (B closes)
|
||||
end
|
||||
-- the player's current location (guard: overworld may not be running)
|
||||
local mapId = game.overworld and game.overworld.map and game.overworld.map.id
|
||||
self.playerLoc = mapId and self.byMap[mapId] or nil
|
||||
@@ -199,7 +263,19 @@ function TownMap:update(dt)
|
||||
self.game.stack:pop()
|
||||
return
|
||||
end
|
||||
if self.nestSpecies then
|
||||
if self.fly then
|
||||
-- LoadTownMap_Fly: Up/Down cycle the visited destinations, A flies there,
|
||||
-- B cancels (handled above). moveList walks self.locs, now the fly list.
|
||||
if input:wasPressed("a") then
|
||||
Sound.play(self.game.data, "Press_AB")
|
||||
local mapId = self.flyMapIds[self.sel]
|
||||
self.game.stack:pop()
|
||||
if mapId and self.onFly then self.onFly(mapId) end
|
||||
return
|
||||
elseif input:wasPressed("up") then self:moveList(-1)
|
||||
elseif input:wasPressed("down") then self:moveList(1)
|
||||
end
|
||||
elseif self.nestSpecies then
|
||||
if input:wasPressed("a") then
|
||||
Sound.play(self.game.data, "Press_AB")
|
||||
self.game.stack:pop()
|
||||
@@ -260,18 +336,28 @@ function TownMap:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
-- the player's current location blinks (slow phase)
|
||||
-- the player's current location blinks (slow phase). Paint it with a
|
||||
-- palette-safe DARK shade (red 0), not red: this screen composites through
|
||||
-- the TOWNMAP SGB shade-remap shader (PaletteFX.shader), which keys ONLY on
|
||||
-- the red channel, and a red-0.75 dot lands in the c1 bucket = TOWNMAP
|
||||
-- {165,214,255}, the exact light-blue used for the water and the town-square
|
||||
-- fill, so the marker was drawn but recolored invisible (#152). Red 0 -> c3
|
||||
-- {25,16,16} = a solid dark "you are here" dot, visible on land and water.
|
||||
if self.playerLoc and self.blink < 20 then
|
||||
local x, y = markerXY(self.playerLoc)
|
||||
love.graphics.setColor(0.75, 0.1, 0.1, 1)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
-- blinking cursor on the selected location
|
||||
-- blinking cursor on the selected location. markerXY is the 8x8 cell's
|
||||
-- top-left; the cursor asset is a 16x16 hollow frame centered on its own
|
||||
-- (8,8), so draw it -4,-4 to enclose the cell (engine/menus/town_map.asm
|
||||
-- draws the box cursor CENTERED on the selected location). Drawing it at
|
||||
-- the cell top-left put the square in the frame's top-left quadrant (#152).
|
||||
if selected and self.blink % 16 < 10 then
|
||||
local x, y = markerXY(selected)
|
||||
if self.bg.cursor then
|
||||
love.graphics.draw(self.bg.cursor, x, y)
|
||||
love.graphics.draw(self.bg.cursor, x - 4, y - 4)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("line", x + 0.5, y + 0.5, 7, 7)
|
||||
@@ -281,7 +367,7 @@ function TownMap:draw()
|
||||
-- the name strip on row 0 (DisplayTownMap: ClearScreenArea + name)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 8)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if selected then Font.draw(selected.name, 8, 0) end
|
||||
if selected then Font.draw(self:bannerText(selected), 8, 0) end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
@@ -294,7 +380,9 @@ function TownMap:draw()
|
||||
drawSquare(loc)
|
||||
end
|
||||
if self.playerLoc and self.blink < 20 then
|
||||
love.graphics.setColor(0.75, 0.1, 0.1, 1)
|
||||
-- palette-safe dark, same red-channel shade-remap reason as the primary
|
||||
-- grid path above (#152); stale-asset builds hit this fallback square
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
|
||||
self.playerLoc.y * 8 + 2, 4, 4)
|
||||
end
|
||||
@@ -317,7 +405,10 @@ function TownMap:draw()
|
||||
end
|
||||
Font.draw(loc.name, 24, y)
|
||||
if loc == self.playerLoc and self.blink < 20 then
|
||||
-- blinking marker on the player's current town
|
||||
-- blinking marker on the player's current town; force the palette-safe
|
||||
-- dark shade explicitly so the red-channel shade-remap keeps it
|
||||
-- visible regardless of Font.draw's leftover color (#152)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", 24 + #loc.name * 8 + 6, y + 2, 4, 4)
|
||||
end
|
||||
end
|
||||
@@ -327,7 +418,7 @@ function TownMap:draw()
|
||||
-- name banner across the top
|
||||
Font.drawBox(0, 0, 20, 3)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if selected then Font.draw(selected.name, 8, 8) end
|
||||
if selected then Font.draw(self:bannerText(selected), 8, 8) end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
@@ -204,6 +204,16 @@ function Map:isWalkableCell(cx, cy)
|
||||
end
|
||||
|
||||
function Map:isGrassCell(cx, cy)
|
||||
-- Off-map cells never count as tall grass (issue #217). cellTile
|
||||
-- border-extends out-of-bounds coordinates with the map's borderBlock,
|
||||
-- and some border blocks (e.g. ROUTE_1's block 11) have the grass tile
|
||||
-- ($52 = 82) in their bottom row -- filler scenery, never standable
|
||||
-- grass. During a map-connection seam step crossConnection parks the
|
||||
-- player one cell before the entry point (cellY = -1 crossing Viridian
|
||||
-- City -> Route 1), so without this guard the feet-overdraw painted an
|
||||
-- animated grass tuft over the player's head for the whole step. pokered
|
||||
-- only ever reads $52 from loaded map tiles, not the border filler.
|
||||
if not self:inBounds(cx, cy) then return false end
|
||||
local grass = self.tileset.grassTile
|
||||
return grass ~= nil and self:cellTile(cx, cy) == grass
|
||||
end
|
||||
|
||||
@@ -222,17 +222,31 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
self.map.renderer:rebuild()
|
||||
self.cutBlocks[mapId] = nil
|
||||
end
|
||||
-- Silph Co card key doors: the .blk layouts ship with the doorways
|
||||
-- open; each floor's map script stamps the closed door block on load
|
||||
-- until its unlock event is set (scripts/SilphCo2F.asm
|
||||
-- SilphCo2FGateCallbackScript et al., closed blocks $54/$5f/$20)
|
||||
-- Silph Co card key doors + Rocket Hideout elevator gates: the .blk
|
||||
-- layouts ship with the doorways open; each floor's map script stamps
|
||||
-- the closed door block on load until its unlock event is set
|
||||
-- (scripts/SilphCo2F.asm SilphCo2FGateCallbackScript et al., closed
|
||||
-- blocks $54/$5f/$20; scripts/RocketHideoutB1F.asm +
|
||||
-- RocketHideoutB4F.asm ...DoorCallbackScript, closed blocks $54/$2d over
|
||||
-- the lift doorway). A door opens on its single `event`, or on `events`
|
||||
-- when every listed flag must be set (Rocket Hideout B4F's lift gate
|
||||
-- needs both guard trainers beaten -- CheckBothEventsSet).
|
||||
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
|
||||
"closedDoors")
|
||||
local floorDoors = closedDoors and closedDoors[mapId]
|
||||
if floorDoors then
|
||||
local stamped = false
|
||||
for _, door in ipairs(floorDoors) do
|
||||
local want = Game.save.flags[door.event] and door.open or door.block
|
||||
local open
|
||||
if door.events then
|
||||
open = true
|
||||
for _, ev in ipairs(door.events) do
|
||||
if not Game.save.flags[ev] then open = false break end
|
||||
end
|
||||
else
|
||||
open = Game.save.flags[door.event]
|
||||
end
|
||||
local want = open and door.open or door.block
|
||||
if self.map:blockAt(door.bx, door.by) ~= want then
|
||||
self.map:setBlock(door.bx, door.by, want)
|
||||
stamped = true
|
||||
@@ -749,6 +763,27 @@ function OverworldState:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- Dig/Teleport/Escape-Rope departure spin (beginTeleportOut). The sprite
|
||||
-- spins UP out of the map before the fade (player_animations.asm
|
||||
-- _LeaveMapAnim -> PlayerSpinWhileMovingUp + SFX_TELEPORT_EXIT_1), the
|
||||
-- mirror of Fly's flyAnim lead-in above. Only when the spin finishes does
|
||||
-- warpToHealPoint push the fade + warp, so the arrival spin-down lands the
|
||||
-- player OUTSIDE the last Pokemon Center door (#196). player.spinFrames
|
||||
-- decrements in lockstep in Player:update, so the rising spin ends here too.
|
||||
if self.teleportOut then
|
||||
self.teleportOut.frames = self.teleportOut.frames - 1
|
||||
if self.teleportOut.frames <= 0 then
|
||||
local onDone = self.teleportOut.onDone
|
||||
self.teleportOut = nil
|
||||
self.player.spinning = false
|
||||
self.player.spinFrames = nil
|
||||
self.player.spinRise = nil
|
||||
self.player.inputLocked = false
|
||||
self:warpToHealPoint(onDone, { arrive = "teleport" })
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- delayed one-shot SFX (the teleport-in spin's second note)
|
||||
if self.delaySfx then
|
||||
self.delaySfx.frames = self.delaySfx.frames - 1
|
||||
@@ -787,7 +822,7 @@ function OverworldState:update(dt)
|
||||
-- escort then walks an extra tile before PlayerEntryMovementRLE, and
|
||||
-- the player lands on desk Oak.
|
||||
local scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
||||
or self.engaging or self.emote
|
||||
or self.engaging or self.emote or self.teleportOut
|
||||
if not scripted and not self.transitioning then
|
||||
self:checkTrainerSight()
|
||||
-- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and
|
||||
@@ -795,7 +830,7 @@ function OverworldState:update(dt)
|
||||
-- direction handling (JoypadOverworld runs the map script first) --
|
||||
-- the player can never start another step after being spotted.
|
||||
scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
||||
or self.engaging or self.emote
|
||||
or self.engaging or self.emote or self.teleportOut
|
||||
end
|
||||
if not scripted and not self.transitioning then
|
||||
self:handleInput()
|
||||
@@ -854,6 +889,21 @@ function OverworldState:dirHeld()
|
||||
or input:isDown("left") or input:isDown("right")
|
||||
end
|
||||
|
||||
-- The warp cell the player WARPED IN on is inert until they physically step
|
||||
-- off it: standing on it, or bonking a wall/edge from it, must not re-fire a
|
||||
-- warp (CheckWarpsNoCollision's arrival-disable; see setMap where
|
||||
-- warpEntryCell/justWarped are set and onStepComplete where they clear).
|
||||
-- Both stand-still warp triggers -- the map-edge exit (checkEdgeExit) and the
|
||||
-- blocked-step collision warp (handleInput) -- must consult this, or a corner
|
||||
-- staircase whose warp tile sits on the map edge (Red's-house (7,1)) bounces
|
||||
-- floors every input frame (issue #230).
|
||||
function OverworldState:onWarpArrivalCell()
|
||||
if self.justWarped then return true end
|
||||
local entry = self.warpEntryCell
|
||||
return entry ~= nil and self.player.cellX == entry.x
|
||||
and self.player.cellY == entry.y
|
||||
end
|
||||
|
||||
function OverworldState:handleInput()
|
||||
local input = Game.input
|
||||
|
||||
@@ -875,10 +925,11 @@ function OverworldState:handleInput()
|
||||
if self:checkBoulderPush(dir) then return end
|
||||
end
|
||||
local result, why = self.player:tryMove(dir, self.map, self.entities)
|
||||
if result == "blocked" then
|
||||
-- a collision while standing on a warp square fires the warp
|
||||
-- when the extra check passes (CheckWarpsCollision: route-gate
|
||||
-- doorways, dock entrances, ...)
|
||||
-- a collision while standing on a warp square fires the warp when the
|
||||
-- extra check passes (CheckWarpsCollision: route-gate doorways, dock
|
||||
-- entrances, ...) -- but never on the inert cell we just warped in on
|
||||
-- (issue #230), which the completed-step path guards the same way.
|
||||
if result == "blocked" and not self:onWarpArrivalCell() then
|
||||
local w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
|
||||
self.player.cellX, self.player.cellY, dir)
|
||||
if w then
|
||||
@@ -1009,6 +1060,11 @@ function OverworldState:checkEdgeExit(dir)
|
||||
|
||||
local w = Warp.onEdge(self.map, p.cellX, p.cellY, dir)
|
||||
if w then
|
||||
-- ...but not while still standing on the warp cell we just arrived on
|
||||
-- (issue #230): fall through so pushing into the edge bonks (SFX +
|
||||
-- walk-in-place) instead of instantly re-warping. A real step onto an
|
||||
-- exit-carpet edge cleared warpEntryCell first, so those still fire.
|
||||
if self:onWarpArrivalCell() then return false end
|
||||
self:takeWarp(w.def)
|
||||
return true
|
||||
end
|
||||
@@ -1256,6 +1312,37 @@ function OverworldState:flyTo(mapId)
|
||||
self.flyDest = { map = mapId, x = spot.x, y = spot.y }
|
||||
end
|
||||
|
||||
-- Dig / Teleport / Escape Rope departure animation, then land OUTSIDE the
|
||||
-- last Pokemon Center door like Fly (#196). pokered's _LeaveMapAnim
|
||||
-- (engine/overworld/player_animations.asm) plays SFX_TELEPORT_EXIT_1 and
|
||||
-- spins the player while it rises up off the map (PlayerSpinWhileMovingUp)
|
||||
-- before the palettes fade; Fly's bird lead-in (flyTo/flyAnim) is the
|
||||
-- analogous departure this mirrors. When the spin finishes (the teleportOut
|
||||
-- countdown in OverworldState:update), warpToHealPoint pushes the fade + warp
|
||||
-- with arrive="teleport" so the sprite spins back DOWN in front of the town
|
||||
-- PC door. Shared by the party-menu DIG/TELEPORT action and BagMenu's
|
||||
-- ESCAPE ROPE so all three animate identically.
|
||||
function OverworldState:beginTeleportOut(onDone)
|
||||
if not Game.save.lastHeal then
|
||||
-- a save that has never visited a Pokemon Center has no heal point to
|
||||
-- warp to; skip the animation entirely (matches the old guard that did
|
||||
-- nothing when lastHeal was absent) instead of spinning into a nil warp
|
||||
if onDone then onDone() end
|
||||
return
|
||||
end
|
||||
require("src.core.Sound").play(Game.data, "Teleport_Exit1")
|
||||
self.player.surfing = false
|
||||
self.player.inputLocked = true
|
||||
-- rising spin: the mirror of the arrival spin-drop set in startWarpTo, so
|
||||
-- spinRise lifts the sprite (Player:pose) while spinFrames counts down
|
||||
self.player.spinning = true
|
||||
self.player.spinTimer = 0
|
||||
self.player.spinFrames = 48
|
||||
self.player.spinTotal = 48
|
||||
self.player.spinRise = true
|
||||
self.teleportOut = { frames = 48, onDone = onDone }
|
||||
end
|
||||
|
||||
function OverworldState:npcAtCell(cx, cy)
|
||||
for _, npc in ipairs(self.npcs) do
|
||||
if (npc.cellX == cx and npc.cellY == cy) or
|
||||
@@ -1474,7 +1561,17 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
-- Pokémon Center PCs and other PC tiles
|
||||
for _, h in ipairs(extras.pcTiles[self.map.id] or {}) do
|
||||
if h.x == fx and h.y == fy and (not h.facing or h.facing == facing) then
|
||||
self:openPC()
|
||||
if self.map.id == "REDS_HOUSE_2F" then
|
||||
-- The player's bedroom PC is the one location in Red/Blue whose PC
|
||||
-- callback is OpenRedsPC (engine/events/hidden_objects/players_pc.asm),
|
||||
-- which runs the PlayerPC predef directly -- item storage, no
|
||||
-- SOMEONE'S/BILL'S PC main menu (DisplayPCMainMenu). Every other
|
||||
-- pcTile is a Pokémon Center-style PC that shows the multi-PC menu. (#228)
|
||||
require("src.core.Sound").play(Game.data, "Turn_On_PC")
|
||||
Screens.push(Game, "PlayerPC")
|
||||
else
|
||||
self:openPC()
|
||||
end
|
||||
return true
|
||||
end
|
||||
end
|
||||
@@ -2007,8 +2104,12 @@ function OverworldState:talkTo(npc)
|
||||
return
|
||||
end
|
||||
|
||||
-- item balls (object_event item argument)
|
||||
if d.item then
|
||||
-- item balls (object_event item argument). A payload id of "0" is
|
||||
-- pokered's ITEM_NONE sentinel: the ROM object sets the 0x80 "has item"
|
||||
-- bit but names item 0, so it is a plain text object, not an item ball
|
||||
-- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats
|
||||
-- the string "0" as truthy, so screen it out and fall through to text.
|
||||
if d.item and d.item ~= "0" and d.item ~= 0 then
|
||||
if not require("src.inventory.Bag").add(Game.save, d.item, 1) then
|
||||
Game.stack:push(TextBox.new(Game, "You can't carry\nany more items!"))
|
||||
return
|
||||
@@ -3257,11 +3358,32 @@ function OverworldState:warpToHealPoint(onDone, opts)
|
||||
-- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear
|
||||
-- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm)
|
||||
Game.save.forcedBike = nil
|
||||
if opts and opts.arrive == "teleport" then
|
||||
local map, x, y = heal.map, heal.x, heal.y
|
||||
local teleport = opts and opts.arrive == "teleport"
|
||||
if teleport then
|
||||
self.arriveWarp = "teleport"
|
||||
-- Dig/Teleport/Escape Rope land OUTSIDE at the last Pokemon Center TOWN
|
||||
-- door, like Fly (#196) -- NOT the interior heal cell a blackout returns
|
||||
-- to. pret routes escape-warp and blackout both through wLastBlackoutMap
|
||||
-- (both appear inside in front of the nurse), but this port has decided
|
||||
-- the escape-warp destination is the town PC door. Prefer the canonical
|
||||
-- Fly landing (field.flyWarps, one tile south of the PC door warp), else
|
||||
-- the remembered outdoor door cell; fall back to the interior heal cell
|
||||
-- only for an old save with no recorded outdoor.
|
||||
local out = heal.outdoor
|
||||
if out then
|
||||
local fw = (Game.data.field.flyWarps or {})[out.id]
|
||||
map = out.id
|
||||
x = fw and fw.x or out.x
|
||||
y = fw and fw.y or out.y
|
||||
end
|
||||
end
|
||||
self:startWarpTo(heal.map, heal.x, heal.y, "down", onDone)
|
||||
if heal.outdoor then
|
||||
self:startWarpTo(map, x, y, "down", onDone)
|
||||
-- Blackouts land at the interior heal cell, so re-point LAST_MAP exits at
|
||||
-- the remembered town door. The teleport branch already lands ON that
|
||||
-- outdoor map, so startWarpTo remembers it on the next exit; re-pointing
|
||||
-- here would wrongly steer exits away from where the player now stands.
|
||||
if heal.outdoor and not teleport then
|
||||
self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y)
|
||||
end
|
||||
end
|
||||
|
||||
+33
-1
@@ -61,16 +61,26 @@ function Player:tryMove(dir, map, entities)
|
||||
if self.facing ~= dir then
|
||||
self.facing = dir
|
||||
self.turnTimer = self.turnFrames or TURN_FRAMES
|
||||
self.bumpFrames = nil -- turning to a new facing ends any wall-bonk cycle
|
||||
return "turned"
|
||||
end
|
||||
if self.turnTimer > 0 then return nil end
|
||||
local ok, why = Collision.canMove(map, entities, self, dir)
|
||||
if not ok then
|
||||
-- Gen1: a blocked step still animates the player walking in place --
|
||||
-- the collision path spends the step's worth of frames running
|
||||
-- UpdateSprites before returning control, so the legs cycle without
|
||||
-- the cell changing (home/overworld.asm collision handling; issue
|
||||
-- #230). Re-armed every frame the direction is held into the wall;
|
||||
-- Player:update ticks the walk clock while it counts down, so releasing
|
||||
-- returns to the standing pose within a step's length.
|
||||
self.bumpFrames = self.stepFrames or STEP_FRAMES
|
||||
return "blocked", why
|
||||
end
|
||||
local tx, ty = Collision.target(self.cellX, self.cellY, dir)
|
||||
self.targetX, self.targetY = tx, ty
|
||||
self.moving = true
|
||||
self.bumpFrames = nil -- a real step supersedes any in-place bonk
|
||||
self.progress = 0
|
||||
-- the bicycle doubles walking speed (8 frames per step)
|
||||
local save = require("src.core.Game").save
|
||||
@@ -97,9 +107,19 @@ function Player:update()
|
||||
if self.spinFrames <= 0 then
|
||||
self.spinFrames = nil
|
||||
self.spinDrop = nil
|
||||
self.spinRise = nil -- teleport-out departure lift (#196)
|
||||
self.spinning = false
|
||||
end
|
||||
end
|
||||
-- wall-bonk walk-in-place (issue #230): while pushing into a wall the
|
||||
-- collision path keeps the walk clock running without moving the cell,
|
||||
-- so the sprite animates against the wall. Guarded on not-moving so a
|
||||
-- real step (which clears bumpFrames and advances animClock itself
|
||||
-- below) can never double-tick the leg cadence.
|
||||
if not self.moving and self.bumpFrames and self.bumpFrames > 0 then
|
||||
self.bumpFrames = self.bumpFrames - 1
|
||||
self.animClock = (self.animClock or 0) + 1
|
||||
end
|
||||
if not self.moving then return false end
|
||||
local stepLen = self.stepFramesCur or self.stepFrames or STEP_FRAMES
|
||||
self.progress = self.progress + 1
|
||||
@@ -132,7 +152,12 @@ function Player:facingCell()
|
||||
end
|
||||
|
||||
function Player:walkPhase()
|
||||
if not self.moving and not self.stepLanded then return 0 end
|
||||
-- moving, the land-frame after a completed step, or an active wall-bonk
|
||||
-- (issue #230) animate; a standing sprite otherwise
|
||||
if not self.moving and not self.stepLanded
|
||||
and not (self.bumpFrames and self.bumpFrames > 0) then
|
||||
return 0
|
||||
end
|
||||
-- walk frame during the middle of each 16-frame animation cycle
|
||||
local p = (self.animClock or self.progress) % 16
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
@@ -183,6 +208,13 @@ function Player:pose()
|
||||
-- (EnterMapAnim PlayerSpinWhileMovingDown)
|
||||
if self.spinFrames and self.spinDrop then
|
||||
py = py - math.floor(self.spinFrames * 24 / (self.spinTotal or 64))
|
||||
elseif self.spinFrames and self.spinRise then
|
||||
-- Dig/Teleport/Escape-Rope departures spin the sprite UP out of the
|
||||
-- map before the fade (LeaveMapAnim PlayerSpinWhileMovingUp) -- the
|
||||
-- mirror of the arrival spin-down: the lift grows from 0 as spinFrames
|
||||
-- counts down to 0 (#196), opposite sign to spinDrop above.
|
||||
local total = self.spinTotal or 64
|
||||
py = py - math.floor((total - self.spinFrames) * 24 / total)
|
||||
end
|
||||
end
|
||||
local sprite = (self.surfing and self.surfSprite)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
-- Driver: reproduce #216 - "Boosted EXP text cut off".
|
||||
--
|
||||
-- A traded mon's EXP gain prints a 3-line message:
|
||||
-- "<mon> gained\na boosted\v<N> EXP. Points!"
|
||||
-- (engine/battle/experience.asm _GainedText/_BoostedText/_ExpPointsText;
|
||||
-- _BoostedText ends in the CONT code \v = char 11, "a boosted\011" in the
|
||||
-- extracted data/generated/text.lua). The in-battle message box only has
|
||||
-- room for two lines (rows y=112 and y=128), so before the fix the third
|
||||
-- line was drawn at y=144 -- one row past the 160x144 screen -- and was
|
||||
-- never seen: a single A press dismissed the whole message and the wild win
|
||||
-- popped straight back to the overworld, so the boosted amount was invisible.
|
||||
--
|
||||
-- Gen1-correct behavior (home/text.asm ContText): the box scrolls a 2-line
|
||||
-- window, waiting for A/B on the \v (drawing the blinking ▼ arrow), then
|
||||
-- scrolls "a boosted" up to the top row and types "<N> EXP. Points!" on the
|
||||
-- bottom row (y=128) -- a VISIBLE row.
|
||||
--
|
||||
-- The driver builds a deterministic traded RATTATA, KOs a weak wild PIDGEY
|
||||
-- with no level-up, then renders the battle into a clean offscreen canvas
|
||||
-- while capturing Font.drawCode, and asserts the encoded "EXP. Points!"
|
||||
-- glyph run lands on a visible row (y <= 130), not off-screen at y=144.
|
||||
-- Fails on the buggy build (amount only ever drawn at y=144); passes once
|
||||
-- the box scrolls.
|
||||
--
|
||||
-- Run:
|
||||
-- SHOT_DIR=/tmp/bug216 POKEPORT_IDENTITY=bug216 POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/battle_boosted_exp_bug216_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local Font = require("src.render.Font")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
-- deterministic traded RATTATA :L8; exp pinned to the L8 floor so a single
|
||||
-- weak wild KO (~22 boosted exp) stays below the L9 threshold (no level-up
|
||||
-- to muddy the message). DVs pinned to max so numbers are stable per run.
|
||||
local rattata = Pokemon.new(game.data, "RATTATA", 8, function(_, b) return b end)
|
||||
local def = game.data.pokemon.RATTATA
|
||||
rattata.exp = Growth.expForLevel(def.growthRate, 8, game.data.growth_rates)
|
||||
rattata.traded = true -- the flag real trades/link set (Commands.lua / Protocol.lua)
|
||||
game.save.party = { rattata }
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
local ow = game.overworld
|
||||
|
||||
-- weak enemy: 1 HP so the first move KOs, speed 1 so RATTATA always moves
|
||||
-- first, rng pinned low so every accuracy roll hits (Damage.accuracyRoll:
|
||||
-- rng(0,255) < acc)
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 2)
|
||||
battle.onFinish = function() end
|
||||
battle.rng = function(a, _) return a end
|
||||
battle.enemy.mon.hp = 1
|
||||
battle.enemy.mon.stats.speed = 1
|
||||
ow:pushBattle(battle)
|
||||
|
||||
-- Render the battle into a clean offscreen 160x144 canvas while recording
|
||||
-- every Font.drawCode(code, x, y). Returns true iff `targetCodes` appear
|
||||
-- as a contiguous left-to-right run on a row at y <= maxY. The off-screen
|
||||
-- amount row (y=144) is excluded, so the buggy build never counts as
|
||||
-- "shown". BattleState:draw runs its own canvas pipeline internally and
|
||||
-- restores the caller's canvas (see battle_hpbar_gbc_bug229_test.lua).
|
||||
local canvas = love.graphics.newCanvas(160, 144)
|
||||
local function seqVisible(targetCodes, maxY)
|
||||
local rec = {}
|
||||
local orig = Font.drawCode
|
||||
Font.drawCode = function(code, x, y)
|
||||
rec[#rec + 1] = { code, x, y }
|
||||
return orig(code, x, y)
|
||||
end
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(0, 0, 0, 1)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local okDraw = pcall(function() battle:draw() end)
|
||||
love.graphics.setCanvas()
|
||||
Font.drawCode = orig
|
||||
if not okDraw then return false end
|
||||
-- group glyphs by row, sort left-to-right, join codes into a delimited
|
||||
-- string, and search each visible row for the target subsequence
|
||||
local byY = {}
|
||||
for _, g in ipairs(rec) do
|
||||
if g[3] <= maxY then
|
||||
byY[g[3]] = byY[g[3]] or {}
|
||||
table.insert(byY[g[3]], g)
|
||||
end
|
||||
end
|
||||
local tparts = {}
|
||||
for _, c in ipairs(targetCodes) do tparts[#tparts + 1] = tostring(c) end
|
||||
local needle = "," .. table.concat(tparts, ",") .. ","
|
||||
for _, list in pairs(byY) do
|
||||
table.sort(list, function(a, b) return a[2] < b[2] end)
|
||||
local cs = {}
|
||||
for _, g in ipairs(list) do cs[#cs + 1] = tostring(g[1]) end
|
||||
if ("," .. table.concat(cs, ",") .. ","):find(needle, 1, true) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local amountCodes = Font.encode("EXP. Points!")
|
||||
|
||||
-- mash through the intro to the FIGHT menu, then FIGHT -> move slot 1
|
||||
-- (TACKLE) -> KO (mirrors battle_levelup_hpbar_bug224_test.lua)
|
||||
for _ = 1, 300 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
if battle.phase ~= "menu" then error("bug216: never reached the FIGHT menu") end
|
||||
U.tap(game, "a") -- FIGHT
|
||||
for _ = 1, 60 do
|
||||
if battle.phase == "moveSelect" then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if battle.phase ~= "moveSelect" then error("bug216: never reached move select") end
|
||||
U.tap(game, "a") -- TACKLE -> KO
|
||||
|
||||
-- Phase A: advance to the boosted-EXP message (contains both "boosted"
|
||||
-- and "EXP. Points!"). Break the frame it becomes current, before any A
|
||||
-- press could scroll past it.
|
||||
local function isBoosted()
|
||||
local c = battle.current
|
||||
return c and c.text and c.text:find("EXP. Points!", 1, true)
|
||||
and c.text:find("boosted", 1, true)
|
||||
end
|
||||
local reached = false
|
||||
for _ = 1, 900 do
|
||||
if isBoosted() then reached = true break end
|
||||
if game.stack:top() ~= battle then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
if not reached then
|
||||
error("bug216: never reached the boosted-EXP message (battle ended early)")
|
||||
end
|
||||
U.log("boosted message: " ..
|
||||
(tostring(battle.current.text):gsub("[\n\v]", "|")))
|
||||
|
||||
-- let the first two lines type out, then capture the cut-off
|
||||
-- "gained / a boosted" box (both builds show this; the buggy build has no
|
||||
-- visible third row)
|
||||
U.wait(40)
|
||||
U.shot(game, DIR .. "/bug216_boosted.png")
|
||||
|
||||
-- Phase B: the amount must become visible on a real row. Each iteration
|
||||
-- renders offscreen and checks; then taps A -- which scrolls the CONT
|
||||
-- window on the fixed build, and (once fully typed) dismisses the message
|
||||
-- on the buggy build.
|
||||
local shown = false
|
||||
for _ = 1, 120 do
|
||||
if seqVisible(amountCodes, 130) then shown = true break end
|
||||
if game.stack:top() ~= battle then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
|
||||
if not shown then
|
||||
error("bug216: 'EXP. Points!' amount never shown on a visible row " ..
|
||||
"(drawn off-screen at y=144); the box did not scroll to the amount")
|
||||
end
|
||||
|
||||
U.shot(game, DIR .. "/bug216_amount.png")
|
||||
|
||||
-- no level-up muddied the message
|
||||
if battle.player.mon.level ~= 8 then
|
||||
error("bug216: expected level 8, got " .. tostring(battle.player.mon.level))
|
||||
end
|
||||
|
||||
U.log("bug216 OK: boosted EXP amount shown on a visible row, level still 8")
|
||||
U.wait(4)
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
-- Regression test for issue #229 ("GSC palette black full health").
|
||||
--
|
||||
-- In RED++ ("Gen 2 / GSC-style") COLORS mode the full-health (green-band)
|
||||
-- in-battle HP bar rendered as a solid black rectangle. Root cause is in
|
||||
-- src/render/HudTiles.lua drawHPBar: it pre-tinted the fill with GREENBAR's
|
||||
-- fill color {0,189,0} (red channel 0), zeroing the red channel of every bar
|
||||
-- pixel; the battle zone shade-remap shader (PaletteFX.shader, keyed ONLY on
|
||||
-- the red channel) then mapped every zeroed-red pixel to color 3 = black.
|
||||
-- Red/orange bands survived because REDBAR {247,0,0} / YELLOWBAR {247,165,0}
|
||||
-- keep a nonzero red channel. SGB ('gbc') mode was unaffected because
|
||||
-- PaletteFX.pack({}) returns nil there, so the fill was already drawn gray.
|
||||
--
|
||||
-- Gen1-correct behavior: the DMG hardware bar is one gray shade recolored by
|
||||
-- the SGB region palette (home/pokemon.asm DrawHPBar + engine/gfx/palettes.asm
|
||||
-- SetPal_Battle + data/sgb/sgb_packets.asm BlkPacket_Battle), never a per-pixel
|
||||
-- repaint. This driver forces RED++, enters a full-HP wild battle, screenshots
|
||||
-- the intro + action menu, then renders the battle into a clean 160x144 canvas
|
||||
-- and asserts the player AND enemy HP-bar fill bands are green (not black).
|
||||
--
|
||||
-- Run:
|
||||
-- SHOT_DIR=/tmp/bug229 POKEPORT_IDENTITY=bug229 POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/battle_hpbar_gbc_bug229_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "."
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
-- Set the SAVED option, not just the live mode: Game:applyOptions re-reads
|
||||
-- save.options.colors, so a bare setMode would get reverted to the default.
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.colors = "redpp"
|
||||
PaletteFX.setMode("redpp")
|
||||
|
||||
-- BULBASAUR :L5 is 19/19 -> full HP -> green band (matches the issue shot).
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
game.save.party = { Pokemon.new(game.data, "BULBASAUR", 5) }
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
local ow = game.overworld
|
||||
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(game, "RATTATA", 3) -- full HP -> green
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
|
||||
U.wait(220)
|
||||
U.shot(game, DIR .. "/bug229_redpp_intro.png")
|
||||
|
||||
-- Advance the intro text to the action menu deterministically (phase flips
|
||||
-- to "menu" at BattleState:1111/1243); stop before a menu tap enters FIGHT.
|
||||
for _ = 1, 40 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
end
|
||||
U.wait(4)
|
||||
U.shot(game, DIR .. "/bug229_redpp_menu.png")
|
||||
|
||||
-- Programmatic assertion: render the battle into a clean offscreen 160x144
|
||||
-- canvas -- BattleState:draw runs its own SGB zone pass internally, so this
|
||||
-- is the real colorized output (verified pixel-identical to the on-screen
|
||||
-- capture). Sample the CENTER fill rows of each bar (an 8px bar tile is
|
||||
-- white frame rows / fill rows / white frame rows, so the middle rows are
|
||||
-- the fill). Before the fix the fill is ~ (0,0,0); after the fix it is
|
||||
-- ~ (0,0.74,0) (GREENBAR fill {0,189,0}/255).
|
||||
if love and love.graphics and love.graphics.newCanvas and battle.phase == "menu" then
|
||||
local canvas = love.graphics.newCanvas(160, 144)
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(0, 0, 0, 1)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
battle:draw()
|
||||
love.graphics.setCanvas()
|
||||
local id = canvas:newImageData()
|
||||
do local dbg = id:encode("png"); local f = io.open(DIR .. "/bug229_offscreen.png", "wb"); if f then f:write(dbg:getString()); f:close() end end
|
||||
|
||||
local function green(r, g, b) return g > 0.4 and g > r and g > b end
|
||||
-- returns worst (lowest-green) fill pixel across the center rows sampled
|
||||
local function worstFill(x, ys)
|
||||
local wr, wg, wb = 1, 1, 1
|
||||
for _, y in ipairs(ys) do
|
||||
local r, g, b = id:getPixel(x, y)
|
||||
if g < wg then wr, wg, wb = r, g, b end
|
||||
end
|
||||
return wr, wg, wb
|
||||
end
|
||||
-- player bar: drawHPBar tile (10,9) -> fill x 96..143; fill rows y 74..77
|
||||
local pr, pg, pb = worstFill(120, { 75, 76 })
|
||||
-- enemy bar: drawHPBar tile (2,2) -> fill x 32..79; fill rows y 18..21
|
||||
local er, eg, eb = worstFill(56, { 19, 20 })
|
||||
U.log(string.format("player fill rgb = %.2f %.2f %.2f", pr, pg, pb))
|
||||
U.log(string.format("enemy fill rgb = %.2f %.2f %.2f", er, eg, eb))
|
||||
|
||||
if not (green(pr, pg, pb) and green(er, eg, eb)) then
|
||||
error(string.format(
|
||||
"issue #229: RED++ HP bar fill not green (player %.2f/%.2f/%.2f enemy %.2f/%.2f/%.2f)",
|
||||
pr, pg, pb, er, eg, eb))
|
||||
end
|
||||
U.log("issue #229 PASS: green HP bar fill in RED++")
|
||||
else
|
||||
error("issue #229 driver: never reached the battle action menu")
|
||||
end
|
||||
|
||||
U.wait(4)
|
||||
end
|
||||
@@ -0,0 +1,125 @@
|
||||
-- Driver: reproduce #224 - "Level up health bar inches down".
|
||||
--
|
||||
-- On a level-up during battle, Gen 1 (engine/battle/experience.asm) raises
|
||||
-- the mon's current HP by (newMaxHP - oldMaxHP) and redraws the active
|
||||
-- battler's HP bar UP to reflect the higher current HP. Our data layer is
|
||||
-- correct (Experience.lua:84 applies the current-HP delta), but the on-screen
|
||||
-- numerator - the battler's shownHP (the value the HUD bar/number use) - was
|
||||
-- never advanced on level-up, while the denominator (mon.stats.hp) jumped
|
||||
-- instantly. So the drawn fill FRACTION fell (e.g. 8/20 -> 8/22) instead of
|
||||
-- rising to 10/22.
|
||||
--
|
||||
-- Setup: a SQUIRTLE at L5 with 8/oldMax HP and exp one point below the level-6
|
||||
-- threshold; a single wild KO crosses it. The driver asserts the player HP
|
||||
-- bar's shownHP rises to the new current HP DURING the level-up messages
|
||||
-- (before the menu-phase safety net at BattleState.lua:1099 would mask it).
|
||||
-- Fails on the buggy build (shownHP stuck at 8); passes once fixed.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
-- deterministic mon: pin DVs to the max so the HP numbers are identical
|
||||
-- across the before/after runs (Stats.randomDVs consumes rng(0,15))
|
||||
local squirtle = Pokemon.new(game.data, "SQUIRTLE", 5, function(_, b) return b end)
|
||||
local def = game.data.pokemon.SQUIRTLE
|
||||
local oldHP = 8
|
||||
squirtle.hp = oldHP -- partly depleted so the bar is well under full
|
||||
-- one point below the level-6 exp threshold: a single kill levels up once
|
||||
squirtle.exp = Growth.expForLevel(def.growthRate, 6, game.data.growth_rates) - 1
|
||||
game.save.party = { squirtle }
|
||||
local oldMax = squirtle.stats.hp
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
local ow = game.overworld
|
||||
|
||||
-- weak enemy: 1 HP so the first TACKLE KOs; speed pinned to 1 so SQUIRTLE
|
||||
-- always moves first (no enemy turn to muddy the HP math); rng pinned to
|
||||
-- the low end so every roll hits (Damage.accuracyRoll: rng(0,255) < acc)
|
||||
local battle = BattleState.newWild(game, "SLOWPOKE", 2)
|
||||
battle.onFinish = function() end
|
||||
battle.rng = function(a, _) return a end
|
||||
battle.enemy.mon.hp = 1
|
||||
battle.enemy.mon.stats.speed = 1
|
||||
ow:pushBattle(battle)
|
||||
|
||||
-- mash through the intro to the FIGHT menu
|
||||
for _ = 1, 240 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
if battle.phase ~= "menu" then error("bug224: never reached the FIGHT menu") end
|
||||
|
||||
-- HP box at L5: the menu safety net snaps shownHP to mon.hp, so 8/oldMax
|
||||
U.shot(game, DIR .. "/bug224_menu.png")
|
||||
|
||||
-- FIGHT -> first move (TACKLE, slot 1) -> KO
|
||||
U.tap(game, "a") -- FIGHT
|
||||
for _ = 1, 60 do
|
||||
if battle.phase == "moveSelect" then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if battle.phase ~= "moveSelect" then error("bug224: never reached move select") end
|
||||
U.tap(game, "a") -- TACKLE
|
||||
|
||||
-- Monitor the level-up messages. The bar must animate from oldHP toward
|
||||
-- the new current HP while phase == 'messages' (before finish/menu-snap).
|
||||
local maxShown = oldHP
|
||||
local caughtUp = false
|
||||
local shotGrew, shotRisen = false, false
|
||||
for _ = 1, 600 do
|
||||
U.wait(1)
|
||||
U.tap(game, "a") -- advance text / dismiss the stat box (never skips the
|
||||
-- time-based drain), so we reach the HP-bar redraw
|
||||
if battle.phase == "messages" and battle.player.mon.level >= 6 then
|
||||
local sh = battle.player.shownHP or battle.player.mon.hp
|
||||
maxShown = math.max(maxShown, sh)
|
||||
if not shotGrew then
|
||||
-- first L6 messages frame: buggy build already shows the shrunk bar
|
||||
-- (8/newMax) here, and it never recovers
|
||||
U.shot(game, DIR .. "/bug224_grew.png")
|
||||
shotGrew = true
|
||||
end
|
||||
if not shotRisen and sh >= oldHP + 1.0 then
|
||||
-- the bar has climbed at least a full HP: capture it mid-rise
|
||||
U.shot(game, DIR .. "/bug224_hpbar.png")
|
||||
shotRisen = true
|
||||
end
|
||||
if sh >= squirtle.hp - 0.5 then
|
||||
caughtUp = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if game.stack:top() ~= battle then break end -- battle finished/popped
|
||||
end
|
||||
|
||||
-- data-layer sanity (Experience.lua): one level gained, max HP grew, and
|
||||
-- current HP rose by the max-HP delta
|
||||
if battle.player.mon.level ~= 6 then
|
||||
error("bug224: expected level 6, got " .. tostring(battle.player.mon.level))
|
||||
end
|
||||
local newMax = squirtle.stats.hp
|
||||
if not (newMax > oldMax) then
|
||||
error("bug224: max HP did not grow (oldMax=" .. tostring(oldMax) ..
|
||||
", newMax=" .. tostring(newMax) .. "); repro is meaningless")
|
||||
end
|
||||
local expectHP = math.min(newMax, oldHP + (newMax - oldMax))
|
||||
if squirtle.hp ~= expectHP then
|
||||
error("bug224: current HP wrong: got " .. tostring(squirtle.hp) ..
|
||||
", expected " .. tostring(expectHP))
|
||||
end
|
||||
|
||||
-- THE BUG: the on-screen bar must have risen to the new current HP during
|
||||
-- the messages phase. On the buggy build shownHP stays stuck at oldHP.
|
||||
if not caughtUp then
|
||||
error("bug224: HP bar did not rise on level-up - shownHP stuck at " ..
|
||||
tostring(maxShown) .. " (oldHP=" .. tostring(oldHP) ..
|
||||
"), mon.hp=" .. tostring(squirtle.hp) .. "/" .. tostring(newMax))
|
||||
end
|
||||
|
||||
U.log("bug224 OK: L6, HP " .. tostring(squirtle.hp) .. "/" .. tostring(newMax) ..
|
||||
", bar rose from " .. tostring(oldHP) .. " to " .. tostring(maxShown))
|
||||
end
|
||||
@@ -0,0 +1,150 @@
|
||||
-- Regression test for issue #207 ("Back of sprite only showing outline").
|
||||
--
|
||||
-- In the forced-mono display modes (OG / OG INV / CLASSIC) a warm-palette
|
||||
-- mon's battle pic (e.g. CHARMANDER, SGB palette REDMON) rendered as a bare
|
||||
-- black outline on white, its two mid shades gone, while cool-palette mons
|
||||
-- (SQUIRTLE, CYANMON) kept full shading. Root cause is a double shade-remap:
|
||||
-- BattleState bakes each pic ONCE with the species' SGB colors, then draws it
|
||||
-- onto the UI canvas; in these modes BattleState exposes no SGB zones, so
|
||||
-- Renderer:endFrame invents a whole-screen GRAYS zone (PaletteFX.ensureZones)
|
||||
-- and runs the ENTIRE colored battle frame through PaletteFX.shader() a SECOND
|
||||
-- time. That shader keys the DMG shade off the red channel
|
||||
-- (r>0.83?c0:r>0.5?c1:r>0.17?c2:c3); REDMON's two mid shades have red 1.0 and
|
||||
-- 0.839 -- BOTH > 0.83 -- so they collapse into shade 0 (the white paper),
|
||||
-- leaving only the near-black outline as shade 3. CYANMON's reds (0.678,
|
||||
-- 0.451) land in the c1/c2 buckets, which is why blue mons were unaffected.
|
||||
--
|
||||
-- Gen1-correct behavior: mon pics are 2bpp 4-shade tiles (gfx/pokemon/back,
|
||||
-- gfx/pokemon/front); all four shades must be visible, and the same grayscale
|
||||
-- palette that renders SQUIRTLE renders CHARMANDER. The fix draws the pics as
|
||||
-- raw DMG grays in these modes so the whole-screen remap recolors 255->c0,
|
||||
-- 170->c1, 85->c2, 0->c3 -- all four shades survive.
|
||||
--
|
||||
-- This driver forces OG, gives the player a CHARMANDER (the failing warm
|
||||
-- palette), enters a wild SQUIRTLE battle, and screenshots the exact action
|
||||
-- menu the reporter shows. The assertion replays the on-screen two-stage
|
||||
-- pipeline into a clean 160x144 canvas (BattleState:draw, then the whole-screen
|
||||
-- GRAYS remap) and asserts the player CHARMANDER back-pic interior contains
|
||||
-- mid-gray shades (not outline-only), with the enemy SQUIRTLE front interior as
|
||||
-- an always-passing control.
|
||||
--
|
||||
-- Run:
|
||||
-- SHOT_DIR=/tmp/bug207 POKEPORT_IDENTITY=bug207 POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/battle_mono_sprite_bug207_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "."
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
-- Set the SAVED option, not just the live mode: Game:applyOptions re-reads
|
||||
-- save.options.colors, so a bare setMode would get reverted to the default.
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.colors = "og"
|
||||
PaletteFX.setMode("og")
|
||||
|
||||
-- CHARMANDER :L5 -- SGB palette REDMON, the warm palette that collapsed.
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
game.save.party = { Pokemon.new(game.data, "CHARMANDER", 5) }
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
local ow = game.overworld
|
||||
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(game, "SQUIRTLE", 5)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
|
||||
U.wait(220)
|
||||
U.shot(game, DIR .. "/bug207_og_intro.png")
|
||||
|
||||
-- Advance the intro text to the action menu deterministically (phase flips
|
||||
-- to "menu" once "Go! CHARMANDER!" has swapped in the species back pic).
|
||||
for _ = 1, 40 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
end
|
||||
U.wait(4)
|
||||
-- The reporter's exact screen: the FIGHT/PKMN/ITEM/RUN action menu with the
|
||||
-- player's CHARMANDER back pic fully visible bottom-left.
|
||||
U.shot(game, DIR .. "/bug207_og_menu.png")
|
||||
|
||||
if not (love and love.graphics and love.graphics.newCanvas
|
||||
and battle.phase == "menu") then
|
||||
error("issue #207 driver: never reached the battle action menu")
|
||||
end
|
||||
|
||||
-- Replay the on-screen forced-mono pipeline into an offscreen 160x144 canvas.
|
||||
-- Stage 1 is BattleState's own colorized frame (its internal SGB zone pass
|
||||
-- plus the mon pics drawn via picImage). Stage 2 is Renderer:endFrame's
|
||||
-- whole-screen remap for a state with no SGB zones: ensureZones -> whole(GRAYS),
|
||||
-- then blit sends GRAYS through the shade shader over the FULL frame. This is
|
||||
-- pixel-faithful to the presented window (the U.shot captures above), but in
|
||||
-- clean 160x144 canvas space so the sample boxes are resolution-independent.
|
||||
local g = love.graphics
|
||||
local shader = PaletteFX.shader()
|
||||
local prev = g.getCanvas()
|
||||
local a = g.newCanvas(160, 144)
|
||||
local b = g.newCanvas(160, 144)
|
||||
g.setCanvas(a)
|
||||
g.clear(1, 1, 1, 1) -- battle letterbox is white (letterboxWhite)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
battle:draw()
|
||||
g.setCanvas(b)
|
||||
g.clear(0, 0, 0, 1)
|
||||
g.setShader(shader)
|
||||
PaletteFX.sendColors(shader, PaletteFX.GRAYS)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
g.draw(a, 0, 0)
|
||||
g.setShader()
|
||||
g.setCanvas(prev)
|
||||
local id = b:newImageData()
|
||||
do
|
||||
local png = id:encode("png")
|
||||
local f = io.open(DIR .. "/bug207_og_offscreen.png", "wb")
|
||||
if f then f:write(png:getString()); f:close() end
|
||||
end
|
||||
|
||||
-- Count mid-gray pixels in a box. The forced-mono frame is neutral gray:
|
||||
-- shade 0 = white (~1.0), shade 3 = black (~0.0), and the two MID shades are
|
||||
-- ltgray (170/255 = 0.667) and dkgray (85/255 = 0.333). A mid shade exists
|
||||
-- only when all four DMG shades survived the remap; an outline-only pic has
|
||||
-- pure white + pure black and zero mid pixels.
|
||||
local function midCount(x0, y0, x1, y1)
|
||||
local n = 0
|
||||
for y = y0, y1 do
|
||||
for x = x0, x1 do
|
||||
local r = id:getPixel(x, y)
|
||||
if r > 0.18 and r < 0.82 then n = n + 1 end
|
||||
end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
-- Player CHARMANDER back pic: hlcoord 1,5 (x=8), feet at y=96 -- interior
|
||||
-- box well inside the body, clear of the white matte and the near-black
|
||||
-- outline, and clear of the bottom-right HUD/HP bar.
|
||||
local backMid = midCount(12, 50, 52, 92)
|
||||
-- Enemy SQUIRTLE front pic: 7x7 slot at hlcoord 12,0 (x~96) -- control that
|
||||
-- always keeps its mid shades (CYANMON reds land in the c1/c2 buckets).
|
||||
local enemyMid = midCount(104, 8, 148, 48)
|
||||
U.log(string.format("player back mid-gray px = %d ; enemy front mid-gray px = %d",
|
||||
backMid, enemyMid))
|
||||
|
||||
if enemyMid <= 20 then
|
||||
error(string.format(
|
||||
"issue #207 driver: control failed -- enemy SQUIRTLE front has no "
|
||||
.. "mid-gray (%d); sample box or pipeline is wrong", enemyMid))
|
||||
end
|
||||
if backMid <= 20 then
|
||||
error(string.format(
|
||||
"issue #207: OG-mode CHARMANDER back pic is outline-only -- interior "
|
||||
.. "has %d mid-gray px (expected the full 4-shade grayscale, like the "
|
||||
.. "enemy front's %d)", backMid, enemyMid))
|
||||
end
|
||||
U.log(string.format(
|
||||
"issue #207 PASS: OG-mode CHARMANDER back keeps its mid shades "
|
||||
.. "(%d mid-gray px, control enemy %d)", backMid, enemyMid))
|
||||
|
||||
U.wait(4)
|
||||
end
|
||||
@@ -0,0 +1,115 @@
|
||||
-- Driver: issue #11 -- Blue's House wall Town Map phantom pickup + the
|
||||
-- sell crash it causes.
|
||||
--
|
||||
-- In pokered the framed Town Map on the wall of Blue's House
|
||||
-- (data/maps/objects/BluesHouse.asm BLUESHOUSE_TOWN_MAP) is a plain
|
||||
-- text object -- talking to it prints _BluesHouseTownMapText
|
||||
-- ("It's a big map! This is useful!") and nothing enters the bag.
|
||||
-- The ROM object carries the 0x80 "has item payload" bit with a payload
|
||||
-- id of 0 (ITEM_NONE), which our extractor copies through as item="0".
|
||||
-- The engine's item-ball branch treated the truthy string "0" as a real
|
||||
-- item, so pressing A picked up a bogus item "0" -- and selling that
|
||||
-- unknown id later hard-crashed ShopMenu.sell (nil item def).
|
||||
--
|
||||
-- This driver reproduces both halves and asserts the correct Gen1
|
||||
-- behavior, so it fails while the bug exists and passes once fixed.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Screens = require("src.ui.Screens")
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(ok, label)
|
||||
if ok then pass = pass + 1; U.log("PASS", label)
|
||||
else fail = fail + 1; U.log("FAIL", label) end
|
||||
end
|
||||
|
||||
-- defensive: keep the pre-fix "<name> found 0!" box from erroring on a
|
||||
-- nil player name (the box never appears after the fix)
|
||||
game.save.player = game.save.player or {}
|
||||
game.save.player.name = game.save.player.name or "RED"
|
||||
|
||||
-- ---- Part 1: talking to the wall Town Map must not pick anything up ----
|
||||
U.teleport(game, "BLUES_HOUSE", 2, 6, "up")
|
||||
U.wait(5)
|
||||
local wallmap
|
||||
for _, n in ipairs(game.overworld.npcs) do
|
||||
if n.def and n.def.name == "BLUESHOUSE_TOWN_MAP" then wallmap = n end
|
||||
end
|
||||
check(wallmap ~= nil, "wall Town Map object present")
|
||||
if wallmap then
|
||||
game.overworld:talkTo(wallmap)
|
||||
U.wait(8)
|
||||
U.shot(game, DIR .. "/bh_1_wallmap.png")
|
||||
-- correct Gen1 behavior: nothing enters the bag
|
||||
check(game.save.inventory["0"] == nil, "no phantom item '0' in bag")
|
||||
check((game.save.inventory["0"] == nil) and (game.save.bagOrder == nil
|
||||
or not (function()
|
||||
for _, id in ipairs(game.save.bagOrder) do
|
||||
if id == "0" then return true end
|
||||
end
|
||||
return false
|
||||
end)()), "bag order has no '0' entry")
|
||||
-- dismiss whatever box is up
|
||||
for _ = 1, 4 do U.tap(game, "b"); U.wait(3) end
|
||||
end
|
||||
|
||||
-- ---- Part 2: selling an unknown id must not crash ----------------------
|
||||
-- Seed a corrupted bag (a save that already picked up "0" before the fix,
|
||||
-- or any legacy/unknown id) and drive the mart SELL path over it.
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
U.teleport(game, "PEWTER_MART", 2, 5, "left")
|
||||
U.wait(5)
|
||||
game.save.money = 3000
|
||||
-- known bag state regardless of whether part 1 leaked a phantom "0"
|
||||
game.save.inventory = {}
|
||||
game.save.bagOrder = nil
|
||||
Bag.add(game.save, "0", 1)
|
||||
check(game.save.inventory["0"] == 1, "seeded unknown item '0' in bag")
|
||||
|
||||
-- open the clerk's BUY/SELL/QUIT menu exactly as the mart interaction does
|
||||
Screens.push(game, "ShopMenu", {})
|
||||
U.wait(4)
|
||||
U.tap(game, "down") -- BUY -> SELL
|
||||
U.wait(4)
|
||||
U.tap(game, "a") -- open the SELL list
|
||||
U.wait(6)
|
||||
local sellList = game.stack:top()
|
||||
check(sellList and sellList.items ~= nil, "SELL list opened")
|
||||
U.shot(game, DIR .. "/bh_2_sell_list.png")
|
||||
|
||||
if sellList and sellList.items then
|
||||
-- find the bogus "0" row
|
||||
local item, idx
|
||||
for i, it in ipairs(sellList.items) do
|
||||
if it.value == "0" then item, idx = it, i end
|
||||
end
|
||||
check(item ~= nil, "bogus '0' row present in sell list")
|
||||
if item then
|
||||
sellList.index = idx
|
||||
local footerBefore = sellList.footer
|
||||
-- Invoke the real onChoose the ListMenu would call on A. Before the
|
||||
-- fix this throws at math.floor(def.price/2) with def=nil; we catch
|
||||
-- it so the run reports the crash instead of hanging on love's error
|
||||
-- screen.
|
||||
local ok, err = pcall(sellList.onChoose, item, sellList)
|
||||
if not ok then
|
||||
fail = fail + 1
|
||||
U.log("FAIL", "selling '0' CRASHED: " .. tostring(err))
|
||||
else
|
||||
check(true, "selling '0' did not crash")
|
||||
-- nothing may be sold: the guard returns before any QuantityBox
|
||||
check(game.save.inventory["0"] == 1, "unknown item still in bag (not sold)")
|
||||
check(sellList.footer ~= footerBefore
|
||||
and tostring(sellList.footer):find("price") ~= nil,
|
||||
"sell footer shows the unsellable message")
|
||||
end
|
||||
U.wait(4)
|
||||
U.shot(game, DIR .. "/bh_3_sell_choose.png")
|
||||
end
|
||||
end
|
||||
|
||||
U.log("RESULT", ("pass=%d fail=%d"):format(pass, fail))
|
||||
if fail == 0 then U.log("RESULT", "ALL PASS") else U.log("RESULT", "HAS FAILURES") end
|
||||
end
|
||||
@@ -0,0 +1,141 @@
|
||||
-- Driver: forced-step shoves use the wrong primitive (issue #151).
|
||||
--
|
||||
-- Two related defects, both a scripted "push the player back one step" that
|
||||
-- reaches for the wrong movement primitive:
|
||||
--
|
||||
-- A MUSEUM_1F ticket rope (data/scripts/story2.lua museumClerk onDecline):
|
||||
-- declining the Y50 ticket must shove the player one cell SOUTH off the
|
||||
-- exhibit rope (scripts/Museum1F.asm) -- the player crossed the rope
|
||||
-- heading NORTH, so the shove is south. The bug shoved "right" onto the
|
||||
-- counter tile (11,4)=tile 23, non-walkable, matching the report's "moved
|
||||
-- onto the table". Correct landing is (10,5)=tile 1 (walkable floor).
|
||||
--
|
||||
-- B VIRIDIAN_CITY gym lock (data/scripts/story5.lua stepGate/viridianGym-
|
||||
-- Lock): the tile below the Gym door (32,8)=tile 44 is a DOWN-ledge
|
||||
-- (44 -> 55, data/tilesets/ledge_tiles.asm); Gen1 shoves the player with a
|
||||
-- SIMULATED JOYPAD down-press that runs the normal step pipeline including
|
||||
-- HandleLedges (engine/overworld/ledges.asm), so the shove HOPS the ledge
|
||||
-- and lands on (32,10)=tile 57. The bug used a raw scriptMove that
|
||||
-- ignores ledges, planting the player standing on the ledge tile (32,9).
|
||||
--
|
||||
-- Both cases assert the CORRECT Gen1 outcome, so this FAILS on the bug and
|
||||
-- PASSES once the shove primitives are fixed.
|
||||
--
|
||||
-- Run:
|
||||
-- POKEPORT_DRIVER=tests/drivers/decline_push_bug151_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug151 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local shotDir = os.getenv("POKEPORT_SHOTDIR") or "."
|
||||
local function shot(name) U.shot(game, shotDir .. "/" .. name) end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
|
||||
-- a party + starter flag so the overworld is fully usable
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
if #game.save.party == 0 then
|
||||
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5))
|
||||
end
|
||||
|
||||
local fails = 0
|
||||
local function expect(cond, ...)
|
||||
if not cond then fails = fails + 1 end
|
||||
U.log(cond and "PASS" or "FAIL", ...)
|
||||
end
|
||||
|
||||
-- Advance dialog until control returns to the overworld: tap A through
|
||||
-- TextBoxes, tap B through a ChoiceBox (B always declines -- ChoiceBox:update
|
||||
-- calls onChoose(false) on B). Bounded so a stuck box can't hang the run.
|
||||
local function driveDialog(maxIters)
|
||||
for _ = 1, (maxIters or 240) do
|
||||
local top = game.stack:top()
|
||||
if top == game.overworld then return true end
|
||||
if getmetatable(top) == ChoiceBox then
|
||||
U.tap(game, "b")
|
||||
elseif getmetatable(top) == TextBox then
|
||||
U.tap(game, "a")
|
||||
else
|
||||
-- unknown state: nudge with A so we never wedge
|
||||
U.tap(game, "a")
|
||||
end
|
||||
U.wait(2)
|
||||
end
|
||||
return game.stack:top() == game.overworld
|
||||
end
|
||||
|
||||
-- Drain queued scriptMoves + any in-flight step, sampling p.hopFrames each
|
||||
-- frame (a hop arc is set only by checkLedgeHop). Returns whether a hop was
|
||||
-- ever seen while draining.
|
||||
local function drainMoves(p, maxFrames)
|
||||
local hopSeen = (p.hopFrames or 0) > 0
|
||||
for _ = 1, (maxFrames or 180) do
|
||||
local ow = game.overworld
|
||||
local pending = ow.scriptMoves and #ow.scriptMoves > 0
|
||||
if not pending and not p.moving and (p.hopFrames or 0) == 0 then break end
|
||||
if (p.hopFrames or 0) > 0 then hopSeen = true end
|
||||
U.wait(1)
|
||||
end
|
||||
return hopSeen
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Case A: MUSEUM_1F ticket-rope decline must shove SOUTH, not RIGHT.
|
||||
-- ------------------------------------------------------------------
|
||||
do
|
||||
game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = false
|
||||
game.save.money = 1000 -- enough to buy, so declining is a real NO choice
|
||||
U.teleport(game, "MUSEUM_1F", 10, 5, "up")
|
||||
U.wait(6)
|
||||
local p = game.overworld.player
|
||||
shot("museum_decline_before.png")
|
||||
-- step north onto the rope cell (10,4); the clerk stops us there
|
||||
U.hold(game, "up", 24)
|
||||
-- clerk dialog: advance the pitch, decline the YES/NO, clear "Come again!"
|
||||
driveDialog(240)
|
||||
drainMoves(p, 120)
|
||||
shot("museum_decline_after.png")
|
||||
expect(p.cellX == 10 and p.cellY == 5,
|
||||
"A: declining shoves the player SOUTH to (10,5), got:", p.cellX, p.cellY)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Case B: VIRIDIAN_CITY gym lock shove must HOP the down-ledge.
|
||||
-- ------------------------------------------------------------------
|
||||
do
|
||||
-- fresh badge state: no non-Earth badges, so the gym stays locked
|
||||
game.save.inventory = game.save.inventory or {}
|
||||
for _, b in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE",
|
||||
"RAINBOWBADGE", "SOULBADGE", "MARSHBADGE",
|
||||
"VOLCANOBADGE" }) do
|
||||
game.save.inventory[b] = nil
|
||||
end
|
||||
U.teleport(game, "VIRIDIAN_CITY", 31, 8, "right")
|
||||
U.wait(6)
|
||||
local p = game.overworld.player
|
||||
shot("viridian_gymlock_before.png")
|
||||
-- step east onto (32,8) directly below the locked Gym door
|
||||
U.hold(game, "right", 24)
|
||||
-- the "GYM's doors are locked..." box appears; clearing it fires the shove
|
||||
local ow = game.overworld
|
||||
local hopSeen = false
|
||||
for _ = 1, 240 do
|
||||
local top = game.stack:top()
|
||||
if top == game.overworld then break end
|
||||
if getmetatable(top) == TextBox then U.tap(game, "a") else U.tap(game, "a") end
|
||||
if (p.hopFrames or 0) > 0 then hopSeen = true end
|
||||
U.wait(2)
|
||||
if (p.hopFrames or 0) > 0 then hopSeen = true end
|
||||
end
|
||||
if drainMoves(p, 180) then hopSeen = true end
|
||||
shot("viridian_gymlock_after.png")
|
||||
expect(hopSeen, "B: the gym-lock shove HOPS the ledge (hop arc seen)")
|
||||
expect(p.cellX == 32 and p.cellY == 10,
|
||||
"B: landed south of the ledge at (32,10), got:", p.cellX, p.cellY)
|
||||
end
|
||||
|
||||
if fails > 0 then error(fails .. " check(s) failed") end
|
||||
U.log("all checks passed -- #151 shoves are Gen1-correct "
|
||||
.. "(museum decline goes south, Viridian gym lock hops the ledge)")
|
||||
end
|
||||
@@ -0,0 +1,193 @@
|
||||
-- Driver: regression coverage for #196 "Dig not working exactly as intended".
|
||||
--
|
||||
-- Report (v0.1.23): using DIG, the character appears SPINNING INSIDE the
|
||||
-- Pokemon Center in front of the nurse, and there is no spin-before-fade on
|
||||
-- departure. Expected (reporter + triage cluster E + the file's own code
|
||||
-- comments): the sprite begins to spin, the screen fades, and the player
|
||||
-- fades in OUTSIDE the nearest town's Pokemon Center door -- exactly like Fly.
|
||||
--
|
||||
-- Gen1/pokered references this exercises:
|
||||
-- engine/overworld/player_animations.asm _LeaveMapAnim (SFX_TELEPORT_EXIT_1
|
||||
-- + PlayerSpinWhileMovingUp) for the departure spin-up;
|
||||
-- EnterMapAnim (PlayerSpinWhileMovingDown) for the arrival spin-down;
|
||||
-- engine/items/item_effects.asm ItemUseEscapeRope (Dig/Teleport share it).
|
||||
--
|
||||
-- Two defects, two assertions:
|
||||
-- FIX A: a DEPARTURE spin must appear on the origin (cave) map BEFORE the
|
||||
-- warp -- ow.teleportOut set / player spinning while still in the cave.
|
||||
-- FIX B: the landing map must be the town (VIRIDIAN_CITY) at the in-front-of
|
||||
-- -door fly spot (23,26), NOT the interior VIRIDIAN_POKECENTER.
|
||||
-- Pre-fix this driver documents the bug (lands in VIRIDIAN_POKECENTER, no
|
||||
-- departure spin); post-fix it passes.
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local failures = {}
|
||||
local function check(cond, msg)
|
||||
if cond then
|
||||
U.log("PASS:", msg)
|
||||
else
|
||||
U.log("FAIL:", msg)
|
||||
table.insert(failures, msg)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- set up a heal record with a remembered Viridian town door ----
|
||||
-- Simulate having healed at the Viridian Pokemon Center: lastHeal points
|
||||
-- at the INTERIOR (the buggy landing), but carries the outdoor town door
|
||||
-- so the fix can relocate the escape-warp outside like Fly does.
|
||||
game.save.player = game.save.player or {}
|
||||
game.save.player.name = game.save.player.name or "bryan"
|
||||
game.save.lastOutdoor = { id = "VIRIDIAN_CITY", x = 23, y = 25 }
|
||||
game.save.lastHeal = {
|
||||
map = "VIRIDIAN_POKECENTER", x = 3, y = 3,
|
||||
outdoor = { id = "VIRIDIAN_CITY", x = 23, y = 25 },
|
||||
}
|
||||
-- field.flyWarps.VIRIDIAN_CITY = {23,26} is the canonical in-front-of-door
|
||||
-- fly landing (one tile south of the PC door warp at 23,25).
|
||||
local fw = game.data.field.flyWarps.VIRIDIAN_CITY
|
||||
U.log("viridian flyWarp:", tostring(fw and fw.x), tostring(fw and fw.y))
|
||||
|
||||
-- ======================= LEG 1: DIG via the party menu ==============
|
||||
-- A DIG-knowing party mon. Pokemon.movesAtLevel never grants DIG, so
|
||||
-- inject the move directly (DIG needs no badge and works in CAVERN maps).
|
||||
local digger = Pokemon.new(game.data, "NIDOKING", 40)
|
||||
digger.moves = { { id = "DIG", pp = 10 } }
|
||||
game.save.party = { digger }
|
||||
|
||||
-- MT_MOON_1F tileset is CAVERN (in DIG_TILESETS); a walkable cave cell.
|
||||
U.teleport(game, "MT_MOON_1F", 14, 34, "down")
|
||||
local ow = game.overworld
|
||||
U.log("start map:", tostring(ow and ow.map and ow.map.id),
|
||||
"tileset:", tostring(ow and ow.map and ow.map.def and ow.map.def.tileset))
|
||||
U.shot(game, DIR .. "/dig_00_cave.png")
|
||||
U.wait(4)
|
||||
|
||||
-- open the party menu and pick DIG on slot 1
|
||||
-- (submenu order for a DIG-only mon: STATS / SWITCH / DIG)
|
||||
Screens.push(game, "PartyMenu")
|
||||
U.wait(5)
|
||||
U.tap(game, "a") -- open the per-mon submenu
|
||||
U.wait(2)
|
||||
U.tap(game, "down") -- STATS -> SWITCH
|
||||
U.wait(2)
|
||||
U.tap(game, "down") -- SWITCH -> DIG
|
||||
U.wait(2)
|
||||
U.tap(game, "a") -- choose DIG
|
||||
U.wait(2)
|
||||
|
||||
-- ---- FIX A: watch for a DEPARTURE spin on the cave map before the warp ----
|
||||
-- A departure spin counts only if it happens while we are still in the cave
|
||||
-- (map.id == MT_MOON_1F). The pre-fix code only spins on arrival, inside the
|
||||
-- Center, so this stays false pre-fix.
|
||||
local sawDepartureSpin = false
|
||||
local spinShotTaken = false
|
||||
local leftCave = false
|
||||
for _ = 1, 240 do
|
||||
local stillCave = ow.map and ow.map.id == "MT_MOON_1F"
|
||||
-- departure-only markers: ow.teleportOut is the new pre-warp spin state,
|
||||
-- and player.spinRise is the rising spin (the arrival uses spinDrop), so
|
||||
-- neither can be confused with the interior arrival spin-down
|
||||
if stillCave and (ow.teleportOut ~= nil or ow.player.spinRise) then
|
||||
sawDepartureSpin = true
|
||||
if not spinShotTaken then
|
||||
U.shot(game, DIR .. "/dig_01_spin.png")
|
||||
spinShotTaken = true
|
||||
end
|
||||
end
|
||||
if ow.map and ow.map.id ~= "MT_MOON_1F" then
|
||||
leftCave = true
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.log("sawDepartureSpin:", tostring(sawDepartureSpin),
|
||||
"leftCave:", tostring(leftCave))
|
||||
|
||||
-- ---- settle the arrival, then FIX B: where did we land? ----
|
||||
local landedMap
|
||||
for _ = 1, 240 do
|
||||
landedMap = ow.map and ow.map.id
|
||||
-- wait until the transition settles onto a stable map that isn't the cave
|
||||
if landedMap and landedMap ~= "MT_MOON_1F" and not ow.transitioning then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(8)
|
||||
U.shot(game, DIR .. "/dig_02_land.png")
|
||||
U.wait(4)
|
||||
landedMap = ow.map and ow.map.id
|
||||
U.log("DIG landed map:", tostring(landedMap),
|
||||
"cell:", tostring(ow.player.cellX), tostring(ow.player.cellY))
|
||||
|
||||
check(sawDepartureSpin,
|
||||
"DIG: departure spin appears in the cave before the fade (FIX A)")
|
||||
check(landedMap == "VIRIDIAN_CITY",
|
||||
"DIG: lands OUTSIDE at VIRIDIAN_CITY, not the interior (FIX B) -- got "
|
||||
.. tostring(landedMap))
|
||||
check(ow.player.cellX == 23 and ow.player.cellY == 26,
|
||||
"DIG: lands at the in-front-of-door fly spot 23,26 -- got "
|
||||
.. tostring(ow.player.cellX) .. "," .. tostring(ow.player.cellY))
|
||||
|
||||
-- ======================= LEG 2: ESCAPE ROPE via the bag =============
|
||||
-- Same shared departure path, but driven through BagMenu's escape_rope
|
||||
-- branch so src/ui/BagMenu.lua is exercised too.
|
||||
game.save.party = { Pokemon.new(game.data, "PIDGEY", 8) }
|
||||
game.save.bagOrder = nil
|
||||
game.save.inventory = { ESCAPE_ROPE = 1 }
|
||||
game.save.money = game.save.money or 3000
|
||||
|
||||
U.teleport(game, "MT_MOON_1F", 14, 34, "down")
|
||||
ow = game.overworld
|
||||
U.shot(game, DIR .. "/dig_03_rope_cave.png")
|
||||
U.wait(4)
|
||||
|
||||
Screens.push(game, "BagMenu")
|
||||
U.wait(5)
|
||||
U.tap(game, "a") -- choose ESCAPE ROPE (only item) -> USE / TOSS menu
|
||||
U.wait(3)
|
||||
U.tap(game, "a") -- USE (first option) -> escape_rope branch
|
||||
U.wait(2)
|
||||
|
||||
local ropeSpin = false
|
||||
for _ = 1, 240 do
|
||||
local stillCave = ow.map and ow.map.id == "MT_MOON_1F"
|
||||
if stillCave and (ow.teleportOut ~= nil or ow.player.spinRise) then
|
||||
ropeSpin = true
|
||||
end
|
||||
if ow.map and ow.map.id ~= "MT_MOON_1F" then break end
|
||||
U.wait(1)
|
||||
end
|
||||
local ropeLanded
|
||||
for _ = 1, 240 do
|
||||
ropeLanded = ow.map and ow.map.id
|
||||
if ropeLanded and ropeLanded ~= "MT_MOON_1F" and not ow.transitioning then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(8)
|
||||
U.shot(game, DIR .. "/dig_04_rope_land.png")
|
||||
U.wait(4)
|
||||
ropeLanded = ow.map and ow.map.id
|
||||
U.log("ESCAPE ROPE landed map:", tostring(ropeLanded),
|
||||
"cell:", tostring(ow.player.cellX), tostring(ow.player.cellY))
|
||||
|
||||
check(ropeSpin,
|
||||
"ESCAPE ROPE: departure spin appears in the cave before the fade (FIX A)")
|
||||
check(ropeLanded == "VIRIDIAN_CITY",
|
||||
"ESCAPE ROPE: lands OUTSIDE at VIRIDIAN_CITY (FIX B) -- got "
|
||||
.. tostring(ropeLanded))
|
||||
|
||||
if #failures == 0 then
|
||||
U.log("RESULT bug196 PASS")
|
||||
else
|
||||
U.log("RESULT bug196 FAIL (" .. #failures .. "):")
|
||||
for _, m in ipairs(failures) do U.log(" -", m) end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,126 @@
|
||||
-- Driver: cancel an evolution with the B button (#213).
|
||||
--
|
||||
-- pokered engine/pokemon/evos_moves.asm polls hJoyHeld during the pic
|
||||
-- flash: holding B aborts the evolution (the mon keeps its species and
|
||||
-- _StoppedEvolvingText prints). Trade evolutions (wLinkState ==
|
||||
-- LINK_STATE_TRADING) skip that poll and cannot be cancelled.
|
||||
--
|
||||
-- Case 1 (level path, cancelable): open EvolutionState directly, wait a
|
||||
-- few frames into the flash (t well under FLASH_FRAMES=220), hold B, and
|
||||
-- assert the mon stays CATERPIE with "stopped evolving" text on screen.
|
||||
-- Case 2 (control): let the flash run to completion with no input and
|
||||
-- assert the mon becomes METAPOD with the "Congratulations!" text.
|
||||
--
|
||||
-- SHOT_DIR=/tmp/evo213 POKEPORT_DRIVER=tests/drivers/evolution_cancel_bug213_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/evo213"
|
||||
os.execute("mkdir -p " .. DIR)
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Evolution = require("src.pokemon.Evolution")
|
||||
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.textSpeed = 1
|
||||
|
||||
local function top() return game.stack:top() end
|
||||
local function evoTop()
|
||||
local t = top()
|
||||
return t and t.screenId == "EvolutionState"
|
||||
end
|
||||
local function waitFor(cond, max)
|
||||
for _ = 1, max or 600 do
|
||||
if cond() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
-- flatten a TextBox's paginated pages (list of line lists) to one string
|
||||
local function pagesText(st)
|
||||
if not st.pages then return nil end
|
||||
local parts = {}
|
||||
for _, page in ipairs(st.pages) do
|
||||
if type(page) == "table" then
|
||||
for _, line in ipairs(page) do
|
||||
if type(line) == "string" then parts[#parts + 1] = line end
|
||||
end
|
||||
elseif type(page) == "string" then
|
||||
parts[#parts + 1] = page
|
||||
end
|
||||
end
|
||||
return table.concat(parts, " ")
|
||||
end
|
||||
local function findText(needle)
|
||||
for _, st in ipairs(game.stack.states or {}) do
|
||||
local blob = pagesText(st)
|
||||
if blob and blob:find(needle, 1, true) then return st end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
-- mash A (one tap every few frames) until cond holds; a single tap only
|
||||
-- fast-forwards a still-typing TextBox, so mashing both finishes the
|
||||
-- typewriter and then presses the close button
|
||||
local function mashUntil(cond, max)
|
||||
for _ = 1, max or 200 do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
|
||||
-- === Case 1: hold B during the flash -> evolution aborts ===
|
||||
local mon = Pokemon.new(game.data, "CATERPIE", 7)
|
||||
table.insert(game.save.party, 1, mon)
|
||||
local done1 = false
|
||||
Evolution.evolve(game, mon, "METAPOD", function() done1 = true end)
|
||||
|
||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
|
||||
U.wait(20) -- into the flash, well under FLASH_FRAMES=220
|
||||
U.log("case1 flash", "t=", top().t, "species=", mon.species)
|
||||
U.shot(game, DIR .. "/evo213_1_evolving.png")
|
||||
|
||||
U.hold(game, "b", 20) -- Gen1 hJoyHeld B-cancel
|
||||
|
||||
-- the flash aborts: EvolutionState is no longer the top (the stopped
|
||||
-- text overlays it and then pops it)
|
||||
if not waitFor(function() return not evoTop() end, 240) then
|
||||
error("evolution did not abort on B: still on EvolutionState, species="
|
||||
.. tostring(mon.species))
|
||||
end
|
||||
U.log("case1 aborted", "species=", mon.species)
|
||||
U.wait(40) -- let "Huh? MON stopped evolving!" finish typing before the shot
|
||||
U.shot(game, DIR .. "/evo213_2_stopped.png")
|
||||
|
||||
assert(mon.species == "CATERPIE",
|
||||
"B-cancel failed: mon evolved to " .. tostring(mon.species)
|
||||
.. " (expected CATERPIE)")
|
||||
assert(findText("stopped evolving"), "StoppedEvolvingText not shown")
|
||||
|
||||
mashUntil(function() return done1 end, 80) -- close the stopped-evolving text
|
||||
assert(done1, "cancel onDone never fired")
|
||||
assert(mon.species == "CATERPIE", "species changed after cancel tail")
|
||||
|
||||
-- === Case 2 (control): no input -> evolution completes ===
|
||||
local mon2 = Pokemon.new(game.data, "CATERPIE", 7)
|
||||
table.insert(game.save.party, 1, mon2)
|
||||
local done2 = false
|
||||
Evolution.evolve(game, mon2, "METAPOD", function() done2 = true end)
|
||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
|
||||
-- let the full flash run (FLASH_FRAMES=220) without pressing B
|
||||
waitFor(function() return not evoTop() end, 400)
|
||||
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
|
||||
error("Congratulations text not shown (case2)")
|
||||
end
|
||||
U.wait(40) -- let the Congratulations text finish typing before the shot
|
||||
U.shot(game, DIR .. "/evo213_3_congrats.png")
|
||||
assert(mon2.species == "METAPOD",
|
||||
"control failed: mon2 stayed " .. tostring(mon2.species)
|
||||
.. " (expected METAPOD)")
|
||||
mashUntil(function() return done2 end, 80)
|
||||
|
||||
U.log("done", "case1=", mon.species, "case2=", mon2.species)
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,143 @@
|
||||
-- Driver: evolution grants the new species' level-up move (#12).
|
||||
--
|
||||
-- pokered engine/pokemon/evos_moves.asm (EvolveMon) re-runs the level-up
|
||||
-- learn check on the *evolved* species after the "evolved into" text, via
|
||||
-- the LearnMoveFromLevelUp predef (engine/pokemon/learn_move.asm). The
|
||||
-- check is EXACT level equality (learnset entry level == mon.level), so a
|
||||
-- mon evolving at exactly a learnset level gains that move.
|
||||
--
|
||||
-- Data pins: GYARADOS.learnset has { level = 20, move = "BITE" }
|
||||
-- (data/generated/pokemon.lua), so MAGIKARP->GYARADOS at level 20 must
|
||||
-- learn BITE, while an evolution at level 21 must NOT (nothing at 21).
|
||||
--
|
||||
-- Case 1 (repro/fix): Lv20 MAGIKARP knowing only SPLASH evolves; after the
|
||||
-- congratulations text the mon must be GYARADOS and know BITE, and the
|
||||
-- "GYARADOS learned BITE!" text must appear. This assertion FAILS on the
|
||||
-- pre-fix build (no learn step) and PASSES once the fix runs the check.
|
||||
-- Case 2 (control): Lv21 MAGIKARP evolves to GYARADOS and must NOT gain
|
||||
-- BITE (guards the exact-level == rule against an over-broad <= fix).
|
||||
--
|
||||
-- SHOT_DIR=/tmp/evo12 POKEPORT_DRIVER=tests/drivers/evolution_move_bug12_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug12 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/evo12"
|
||||
os.execute("mkdir -p " .. DIR)
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Evolution = require("src.pokemon.Evolution")
|
||||
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.textSpeed = 1
|
||||
|
||||
local function top() return game.stack:top() end
|
||||
local function evoTop()
|
||||
local t = top()
|
||||
return t and t.screenId == "EvolutionState"
|
||||
end
|
||||
local function waitFor(cond, max)
|
||||
for _ = 1, max or 600 do
|
||||
if cond() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
-- flatten a TextBox's paginated pages (list of line lists) to one string
|
||||
local function pagesText(st)
|
||||
if not st.pages then return nil end
|
||||
local parts = {}
|
||||
for _, page in ipairs(st.pages) do
|
||||
if type(page) == "table" then
|
||||
for _, line in ipairs(page) do
|
||||
if type(line) == "string" then parts[#parts + 1] = line end
|
||||
end
|
||||
elseif type(page) == "string" then
|
||||
parts[#parts + 1] = page
|
||||
end
|
||||
end
|
||||
return table.concat(parts, " ")
|
||||
end
|
||||
local function findText(needle)
|
||||
for _, st in ipairs(game.stack.states or {}) do
|
||||
local blob = pagesText(st)
|
||||
if blob and blob:find(needle, 1, true) then return st end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local function mashUntil(cond, max)
|
||||
for _ = 1, max or 200 do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
local function hasMove(mon, id)
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
if mv.id == id then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
|
||||
-- === Case 1: MAGIKARP @20 -> GYARADOS must learn BITE ===
|
||||
local mon = Pokemon.new(game.data, "MAGIKARP", 20)
|
||||
mon.moves = { { id = "SPLASH", pp = 40 } } -- deterministic single slot
|
||||
table.insert(game.save.party, 1, mon)
|
||||
local done1 = false
|
||||
Evolution.evolve(game, mon, "GYARADOS", function() done1 = true end)
|
||||
|
||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
|
||||
-- let the full flash run (FLASH_FRAMES=220) with no input, then apply
|
||||
waitFor(function() return not evoTop() end, 400)
|
||||
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
|
||||
error("Congratulations text not shown (case1)")
|
||||
end
|
||||
U.wait(40) -- let the congrats text finish typing before the shot
|
||||
U.shot(game, DIR .. "/evo12_1_congrats.png")
|
||||
|
||||
-- advance past congrats into the level-up learn flow; the fix pushes
|
||||
-- "GYARADOS learned BITE!" here (pre-fix: onDone fires with no learn)
|
||||
local sawLearned = false
|
||||
mashUntil(function()
|
||||
if findText("learned") then sawLearned = true; return true end
|
||||
return done1
|
||||
end, 200)
|
||||
U.wait(30) -- let the "learned BITE!" text type out (fix path)
|
||||
U.shot(game, DIR .. "/evo12_2_learned.png")
|
||||
|
||||
mashUntil(function() return done1 end, 120)
|
||||
U.log("case1", "species=", mon.species, "hasBite=", hasMove(mon, "BITE"),
|
||||
"sawLearned=", sawLearned)
|
||||
|
||||
assert(mon.species == "GYARADOS",
|
||||
"case1: mon stayed " .. tostring(mon.species) .. " (expected GYARADOS)")
|
||||
assert(hasMove(mon, "BITE"),
|
||||
"case1: GYARADOS did not learn BITE on evolution at level 20 (bug #12)")
|
||||
assert(sawLearned, "case1: no 'learned' text shown for the evolution move")
|
||||
|
||||
-- === Case 2 (control): MAGIKARP @21 -> GYARADOS must NOT gain BITE ===
|
||||
local mon2 = Pokemon.new(game.data, "MAGIKARP", 21)
|
||||
mon2.moves = { { id = "SPLASH", pp = 40 } }
|
||||
table.insert(game.save.party, 1, mon2)
|
||||
local done2 = false
|
||||
Evolution.evolve(game, mon2, "GYARADOS", function() done2 = true end)
|
||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
|
||||
waitFor(function() return not evoTop() end, 400)
|
||||
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
|
||||
error("Congratulations text not shown (case2)")
|
||||
end
|
||||
mashUntil(function() return done2 end, 200)
|
||||
U.wait(20)
|
||||
U.shot(game, DIR .. "/evo12_3_lv21_no_bite.png")
|
||||
U.log("case2", "species=", mon2.species, "hasBite=", hasMove(mon2, "BITE"))
|
||||
|
||||
assert(mon2.species == "GYARADOS",
|
||||
"case2: mon2 stayed " .. tostring(mon2.species) .. " (expected GYARADOS)")
|
||||
assert(not hasMove(mon2, "BITE"),
|
||||
"case2: GYARADOS wrongly learned BITE at level 21 (over-grant; exact == broken)")
|
||||
|
||||
U.log("done", "case1=", mon.species, "case2=", mon2.species)
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,221 @@
|
||||
-- Driver: Fighting Dojo Karate Master bundle (#197).
|
||||
-- Six sub-bugs live in FIGHTING_DOJO (scripts/FightingDojo.asm):
|
||||
-- BUG1 no aggro -- the master has no trainer header so range=0
|
||||
-- BUG2 no speech -- no won text + no prize dialogue after the win
|
||||
-- BUG3 wrong re-talk -- shows the pre-battle challenge, not the after line
|
||||
-- BUG4 (verify) -- the ball ask() is the Gen1 descriptor, not a dex entry
|
||||
-- BUG5 both balls -- the chosen ball AND the other one both vanish; the
|
||||
-- other should stay and give the "greedy" refusal
|
||||
-- BUG6 poster -- the north-wall posters ("Enemies on every side!") are
|
||||
-- inert (bg_events dropped by the extractor)
|
||||
--
|
||||
-- Every scenario screenshots the moment and records a pass/fail; the driver
|
||||
-- asserts once at the end, so a single run captures before-evidence for all
|
||||
-- six while still failing red until the fixes land.
|
||||
--
|
||||
-- SHOT_DIR=/tmp/dojo POKEPORT_DRIVER=tests/drivers/fighting_dojo_bug197_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug197 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local OW = require("src.world.OverworldController")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Commands = require("src.script.Commands")
|
||||
|
||||
local failures = {}
|
||||
local function check(cond, msg)
|
||||
if cond then U.log("ok:", msg) else
|
||||
table.insert(failures, msg)
|
||||
U.log("FAIL:", msg)
|
||||
end
|
||||
return cond
|
||||
end
|
||||
|
||||
local function topIsTextBox() return getmetatable(game.stack:top()) == TextBox end
|
||||
local function topIsChoice() return getmetatable(game.stack:top()) == ChoiceBox end
|
||||
|
||||
local function currentPageText()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return "" end
|
||||
local page = top.pages and top.pages[top.pageIndex]
|
||||
if not page then return "" end
|
||||
return table.concat(page, "\n")
|
||||
end
|
||||
|
||||
local function pageReady()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return false end
|
||||
return top.waiting or top.done
|
||||
end
|
||||
|
||||
-- let the current page finish typing WITHOUT advancing past it
|
||||
local function waitReadyPage()
|
||||
for _ = 1, 200 do
|
||||
if pageReady() then break end
|
||||
U.wait(2)
|
||||
end
|
||||
return currentPageText()
|
||||
end
|
||||
|
||||
local function mashUntil(cond, cap)
|
||||
for _ = 1, (cap or 200) do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
-- advance text pages until a ready page contains `want`; stops before
|
||||
-- blowing past a choice box
|
||||
local function sawText(want)
|
||||
return mashUntil(function()
|
||||
if topIsChoice() then return false end
|
||||
if not pageReady() then return false end
|
||||
return currentPageText():find(want, 1, true) ~= nil
|
||||
end, 150)
|
||||
end
|
||||
|
||||
local function npcByName(ow, name)
|
||||
for _, n in ipairs(ow.npcs) do
|
||||
if n.def and n.def.name == name then return n end
|
||||
end
|
||||
end
|
||||
|
||||
local function resetDojo(x, y, facing, flags)
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
-- the four blackbelts are not under test; retire them so only the
|
||||
-- master (or the balls/poster) can react in each scenario
|
||||
game.save.flags = {
|
||||
EVENT_BEAT_FIGHTING_DOJO_TRAINER_0 = true,
|
||||
EVENT_BEAT_FIGHTING_DOJO_TRAINER_1 = true,
|
||||
EVENT_BEAT_FIGHTING_DOJO_TRAINER_2 = true,
|
||||
EVENT_BEAT_FIGHTING_DOJO_TRAINER_3 = true,
|
||||
}
|
||||
game.save.defeatedTrainers = {}
|
||||
game.save.objectToggles = {}
|
||||
game.save.itemsTaken = {}
|
||||
game.save.inventory = game.save.inventory or {}
|
||||
game.save.player.name = game.save.player.name or "RED"
|
||||
-- one healthy mon: enough for a battle to construct, room for a prize
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
for k, v in pairs(flags or {}) do game.save.flags[k] = v end
|
||||
game.stack:push(OW, "FIGHTING_DOJO", x, y, facing or "up")
|
||||
U.wait(5)
|
||||
return game.stack:top()
|
||||
end
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- BUG1/2/3 header seed sanity (deterministic, no battle needed)
|
||||
------------------------------------------------------------------
|
||||
local hdr = game.data:trainerHeader("FightingDojo", 1)
|
||||
check(hdr ~= nil, "BUG1/2/3: Karate Master trainer header (index 1) exists")
|
||||
check(hdr and (hdr.range or 0) > 0, "BUG1: master has a sight range")
|
||||
check(hdr and hdr.won ~= nil, "BUG2: master has a won (defeat) text")
|
||||
check(hdr and hdr.after ~= nil, "BUG3: master has an after (re-talk) text")
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- BUG1: sight aggro. Stand directly below the master (5,3 faces DOWN,
|
||||
-- range 4) with the four blackbelts pre-cleared so only he can engage.
|
||||
------------------------------------------------------------------
|
||||
local ow = resetDojo(5, 4, "up", {})
|
||||
U.shot(game, DIR .. "/dojo_1_before.png")
|
||||
U.wait(20) -- the idle sight scan runs every frame
|
||||
local engaged = ow.engaging or (ow.emote ~= nil)
|
||||
U.log("aggro engaging:", tostring(ow.engaging), "emote:", tostring(ow.emote ~= nil))
|
||||
U.shot(game, DIR .. "/dojo_2_aggro.png")
|
||||
check(engaged, "BUG1: Karate Master aggros on sight")
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- BUG3: talk to the already-beaten master -> "Stay and train..." and
|
||||
-- NOT the "I am the LEADER here!" pre-battle challenge.
|
||||
------------------------------------------------------------------
|
||||
-- talk from (4,3) facing right: beside the master (5,3), off his DOWN
|
||||
-- sight line so he can't (post-fix) aggro before we set him defeated
|
||||
ow = resetDojo(4, 3, "right", { EVENT_BEAT_KARATE_MASTER = true })
|
||||
game.save.defeatedTrainers["FIGHTING_DOJO_obj_1"] = true
|
||||
local master = npcByName(ow, "FIGHTINGDOJO_KARATE_MASTER")
|
||||
if check(master ~= nil, "BUG3: master npc present") then
|
||||
ow:talkTo(master)
|
||||
local first = waitReadyPage()
|
||||
check(not first:find("LEADER", 1, true) and not first:find("Grunt", 1, true),
|
||||
"BUG3: beaten master no longer shows the challenge (page1='" .. first .. "')")
|
||||
check(sawText("Stay and train"),
|
||||
"BUG3: beaten master says 'Stay and train at Karate with us!'")
|
||||
U.shot(game, DIR .. "/dojo_3_retalk.png")
|
||||
mashUntil(function() return game.stack:top() == ow end)
|
||||
end
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- BUG2: the post-battle prize speech (run the same reward path a win
|
||||
-- takes; EVENT_BEAT_KARATE_MASTER must be unset so it prints).
|
||||
------------------------------------------------------------------
|
||||
ow = resetDojo(4, 9, "up", {})
|
||||
ow:checkVictoryRewards("OPP_BLACKBELT", 1)
|
||||
U.wait(5)
|
||||
check(sawText("prized"),
|
||||
"BUG2: win shows the '...prized fighting POKeMON!' prize offer")
|
||||
U.shot(game, DIR .. "/dojo_2_prize.png")
|
||||
mashUntil(function() return game.stack:top() == ow end)
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- BUG4 (verify-only): the Hitmonlee ball prompt is the Gen1 descriptor
|
||||
-- ("You want the hard kicking HITMONLEE?"), not a Pokedex entry screen.
|
||||
------------------------------------------------------------------
|
||||
ow = resetDojo(4, 2, "up", { EVENT_BEAT_KARATE_MASTER = true })
|
||||
local leeBall = npcByName(ow, "FIGHTINGDOJO_HITMONLEE_POKE_BALL")
|
||||
local chanBall = npcByName(ow, "FIGHTINGDOJO_HITMONCHAN_POKE_BALL")
|
||||
check(leeBall ~= nil and chanBall ~= nil, "BUG5: both prize balls on the mat")
|
||||
if leeBall then
|
||||
ow:talkTo(leeBall)
|
||||
check(sawText("hard kicking") or sawText("HITMONLEE"),
|
||||
"BUG4: ball asks the Gen1 descriptor prompt (no dex entry)")
|
||||
U.shot(game, DIR .. "/dojo_4_prompt.png")
|
||||
------------------------------------------------------------------
|
||||
-- BUG5: choose YES -> only the chosen ball vanishes; the other stays
|
||||
-- and, when talked to, gives the "Better not get greedy..." refusal.
|
||||
------------------------------------------------------------------
|
||||
mashUntil(topIsChoice)
|
||||
U.tap(game, "a") -- YES (index 1)
|
||||
mashUntil(function() return game.stack:top() == ow end)
|
||||
check(game.save.flags.EVENT_GOT_HITMONLEE == true, "BUG5: received HITMONLEE")
|
||||
local leeGone = npcByName(ow, "FIGHTINGDOJO_HITMONLEE_POKE_BALL") == nil
|
||||
local chanStays = npcByName(ow, "FIGHTINGDOJO_HITMONCHAN_POKE_BALL") ~= nil
|
||||
check(leeGone, "BUG5: the chosen HITMONLEE ball is removed")
|
||||
check(chanStays, "BUG5: the other (HITMONCHAN) ball stays on the mat")
|
||||
U.shot(game, DIR .. "/dojo_5_onegone.png")
|
||||
if chanStays then
|
||||
ow:talkTo(npcByName(ow, "FIGHTINGDOJO_HITMONCHAN_POKE_BALL"))
|
||||
check(sawText("greedy"), "BUG5: remaining ball gives the greedy refusal")
|
||||
check(not game.save.flags.EVENT_GOT_HITMONCHAN,
|
||||
"BUG5: talking the other ball does NOT hand a second POKeMON")
|
||||
U.shot(game, DIR .. "/dojo_5_greedy.png")
|
||||
mashUntil(function() return game.stack:top() == ow end)
|
||||
end
|
||||
end
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- BUG6: the north-wall poster. A claimed prize frees its ball cell, so
|
||||
-- stand on (4,1) facing up and read the poster above it.
|
||||
------------------------------------------------------------------
|
||||
ow = resetDojo(4, 1, "up",
|
||||
{ EVENT_BEAT_KARATE_MASTER = true, EVENT_GOT_HITMONLEE = true })
|
||||
Commands.hide_object({ game = game, save = game.save, overworld = ow },
|
||||
"FIGHTING_DOJO", "FIGHTINGDOJO_HITMONLEE_POKE_BALL")
|
||||
U.wait(3)
|
||||
U.shot(game, DIR .. "/dojo_6_before.png")
|
||||
ow:interact()
|
||||
check(sawText("Enemies on every"),
|
||||
"BUG6: the poster prints 'Enemies on every side!'")
|
||||
U.shot(game, DIR .. "/dojo_6_poster.png")
|
||||
mashUntil(function() return game.stack:top() == ow end)
|
||||
|
||||
------------------------------------------------------------------
|
||||
U.log("fighting_dojo_bug197_test: failures =", #failures)
|
||||
for _, m in ipairs(failures) do U.log(" -", m) end
|
||||
assert(#failures == 0,
|
||||
"#197 unresolved:\n " .. table.concat(failures, "\n "))
|
||||
U.log("fighting_dojo_bug197_test: ok")
|
||||
end
|
||||
@@ -0,0 +1,125 @@
|
||||
-- Driver: regression coverage for #203 "You can't fly to indigo plateau".
|
||||
--
|
||||
-- pret/pokered engine/menus/town_map.asm LoadTownMap_Fly cycles EVERY visited
|
||||
-- fly destination, and Indigo Plateau is a normal Fly spot once reached (its
|
||||
-- special-warp lands the player on the Plateau exterior, data/maps/
|
||||
-- special_warps.asm). The port's fly-list filter gated each entry on
|
||||
-- Map.isOutdoor(def), which is tileset == "OVERWORLD" -- but Indigo Plateau's
|
||||
-- map uses tileset "PLATEAU", so it was silently dropped from the fly cursor
|
||||
-- even though it is visited, has a fly warp, and sits in flyOrder. This driver
|
||||
-- walks the party-menu FLY flow, asserts INDIGO_PLATEAU is a cyclable fly
|
||||
-- destination, flies there, and lands on the Plateau exterior.
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- Party slot 1 knows FLY. Pokemon.movesAtLevel never grants FLY, so inject
|
||||
-- the move directly after construction (same setup as the #195 driver).
|
||||
local flyer = Pokemon.new(game.data, "PIDGEOT", 40)
|
||||
flyer.moves[1] = { id = "FLY", pp = 15 }
|
||||
game.save.party = { flyer }
|
||||
game.save.player.name = "bryan"
|
||||
game.save.inventory = game.save.inventory or {}
|
||||
game.save.inventory.THUNDERBADGE = true -- FLY submenu entry is badge-gated
|
||||
game.save.visited = {
|
||||
PALLET_TOWN = true, VIRIDIAN_CITY = true, INDIGO_PLATEAU = true,
|
||||
}
|
||||
|
||||
-- Exercise the real visited-marking path (OverworldController marks any
|
||||
-- flyWarps map visited on entry) by standing on the Plateau exterior first,
|
||||
-- then hop back to Pallet for a clean starting point.
|
||||
U.teleport(game, "INDIGO_PLATEAU", 9, 6, "down")
|
||||
U.wait(5)
|
||||
assert(game.save.visited.INDIGO_PLATEAU,
|
||||
"entering the Plateau exterior must mark INDIGO_PLATEAU visited")
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(5)
|
||||
|
||||
-- open the party menu and pick FLY on slot 1 (submenu: STATS / SWITCH / FLY)
|
||||
Screens.push(game, "PartyMenu")
|
||||
U.wait(5)
|
||||
U.tap(game, "a") -- open the per-mon submenu
|
||||
U.wait(2)
|
||||
U.tap(game, "down") -- STATS -> SWITCH
|
||||
U.wait(2)
|
||||
U.tap(game, "down") -- SWITCH -> FLY
|
||||
U.wait(2)
|
||||
U.tap(game, "a") -- choose FLY
|
||||
U.wait(5)
|
||||
|
||||
local top = game.stack:top()
|
||||
U.shot(game, DIR .. "/fly_map_screen.png")
|
||||
U.wait(4) -- let the async png write land before any assert can quit us
|
||||
|
||||
U.log("fly screen top screenId:", tostring(top and top.screenId),
|
||||
"fly=", tostring(top and top.fly), "mode=", tostring(top and top.mode))
|
||||
local isFlyMap = top ~= nil and top.fly == true
|
||||
and top.locs ~= nil and top.mode ~= nil
|
||||
assert(isFlyMap,
|
||||
"FLY must open the TOWN MAP in fly mode (LoadTownMap_Fly), got screen '"
|
||||
.. tostring(top and top.screenId or "nil") .. "' fly="
|
||||
.. tostring(top and top.fly))
|
||||
|
||||
-- KEY ASSERTION (the #203 bug): INDIGO_PLATEAU must be a cyclable fly
|
||||
-- destination. Before the fix the isOutdoor gate drops the PLATEAU-tileset
|
||||
-- Plateau, so this list is missing it and the assert fails.
|
||||
local hasIndigo = false
|
||||
local listStr = {}
|
||||
for i, id in ipairs(top.flyMapIds or {}) do
|
||||
listStr[i] = id
|
||||
if id == "INDIGO_PLATEAU" then hasIndigo = true end
|
||||
end
|
||||
U.log("fly destinations:", table.concat(listStr, ", "))
|
||||
assert(hasIndigo,
|
||||
"INDIGO_PLATEAU must appear in the fly destination list (it is visited, "
|
||||
.. "has a fly warp, and is in flyOrder); got { "
|
||||
.. table.concat(listStr, ", ") .. " }")
|
||||
|
||||
-- walk the cursor to INDIGO PLATEAU (banner reads "To INDIGO PLATEAU")
|
||||
local function selectedMap()
|
||||
return top.flyMapIds and top.flyMapIds[top.sel]
|
||||
end
|
||||
U.log("initial fly selection:", tostring(selectedMap()))
|
||||
local guard = 0
|
||||
while selectedMap() ~= "INDIGO_PLATEAU" and guard < 12 do
|
||||
U.tap(game, "down")
|
||||
U.wait(2)
|
||||
guard = guard + 1
|
||||
end
|
||||
assert(selectedMap() == "INDIGO_PLATEAU",
|
||||
"Up/Down must cycle the fly cursor to INDIGO_PLATEAU, landed on "
|
||||
.. tostring(selectedMap()))
|
||||
U.shot(game, DIR .. "/fly_indigo_selected.png")
|
||||
U.wait(4)
|
||||
|
||||
-- A flies to the highlighted destination: flyTo departs (48-frame bird),
|
||||
-- then warps to the Plateau exterior.
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
assert(game.overworld and game.overworld.flyDest
|
||||
and game.overworld.flyDest.map == "INDIGO_PLATEAU",
|
||||
"pressing A on the fly map must start the departure to INDIGO_PLATEAU, got "
|
||||
.. tostring(game.overworld and game.overworld.flyDest
|
||||
and game.overworld.flyDest.map))
|
||||
|
||||
-- past the bird sweep + warp transition: the player lands on the Plateau
|
||||
local landed = false
|
||||
for _ = 1, 240 do
|
||||
if game.overworld and game.overworld.map
|
||||
and game.overworld.map.id == "INDIGO_PLATEAU" then
|
||||
landed = true
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(6)
|
||||
U.shot(game, DIR .. "/fly_landed_indigo.png")
|
||||
U.wait(4)
|
||||
assert(landed, "Fly must land the player on INDIGO_PLATEAU, ended on "
|
||||
.. tostring(game.overworld and game.overworld.map and game.overworld.map.id))
|
||||
|
||||
U.log("RESULT bug203 PASS")
|
||||
end
|
||||
@@ -0,0 +1,101 @@
|
||||
-- Driver: regression coverage for #195 "Fly doesn't show the map".
|
||||
--
|
||||
-- pret/pokered engine/menus/town_map.asm LoadTownMap_Fly: choosing FLY from
|
||||
-- the party field-move submenu opens the TOWN MAP with a blinking cursor that
|
||||
-- cycles ONLY the visited fly destinations (Up/Down), A flies there, B cancels.
|
||||
-- The port used to push a plain "FLY TO?" ListMenu (src/ui/FlyMenu.lua)
|
||||
-- instead -- a text list, never the map (the how-it-is screenshot). This
|
||||
-- driver walks Fly and asserts the TOWN MAP fly screen appears, that Up/Down
|
||||
-- cycle the visited towns, and that A actually flies.
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- Party slot 1 knows FLY. Pokemon.movesAtLevel never grants FLY, so inject
|
||||
-- the move directly after construction.
|
||||
local flyer = Pokemon.new(game.data, "PIDGEOT", 40)
|
||||
flyer.moves[1] = { id = "FLY", pp = 15 }
|
||||
game.save.party = { flyer }
|
||||
game.save.player.name = "bryan"
|
||||
game.save.inventory = game.save.inventory or {}
|
||||
game.save.inventory.THUNDERBADGE = true -- FLY submenu entry is badge-gated
|
||||
game.save.visited = {
|
||||
PALLET_TOWN = true, VIRIDIAN_CITY = true, PEWTER_CITY = true,
|
||||
CERULEAN_CITY = true, CELADON_CITY = true,
|
||||
}
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(5)
|
||||
|
||||
-- open the party menu and pick FLY on slot 1 (submenu: STATS / SWITCH / FLY)
|
||||
Screens.push(game, "PartyMenu")
|
||||
U.wait(5)
|
||||
U.tap(game, "a") -- open the per-mon submenu
|
||||
U.wait(2)
|
||||
U.tap(game, "down") -- STATS -> SWITCH
|
||||
U.wait(2)
|
||||
U.tap(game, "down") -- SWITCH -> FLY
|
||||
U.wait(2)
|
||||
U.tap(game, "a") -- choose FLY
|
||||
U.wait(5)
|
||||
|
||||
local top = game.stack:top()
|
||||
U.shot(game, DIR .. "/fly_map_screen.png")
|
||||
U.wait(4) -- let the async png write land before any assert can quit us
|
||||
|
||||
U.log("fly screen top screenId:", tostring(top and top.screenId),
|
||||
"fly=", tostring(top and top.fly), "mode=", tostring(top and top.mode))
|
||||
local isFlyMap = top ~= nil and top.fly == true
|
||||
and top.locs ~= nil and top.mode ~= nil
|
||||
assert(isFlyMap,
|
||||
"FLY must open the TOWN MAP in fly mode (LoadTownMap_Fly), got screen '"
|
||||
.. tostring(top and top.screenId or "nil") .. "' fly="
|
||||
.. tostring(top and top.fly))
|
||||
|
||||
-- The cursor cycles ONLY the visited fly towns. Walk it to CELADON CITY.
|
||||
local function selectedMap()
|
||||
return top.flyMapIds and top.flyMapIds[top.sel]
|
||||
end
|
||||
U.log("initial fly selection:", tostring(selectedMap()))
|
||||
local guard = 0
|
||||
while selectedMap() ~= "CELADON_CITY" and guard < 12 do
|
||||
U.tap(game, "down")
|
||||
U.wait(2)
|
||||
guard = guard + 1
|
||||
end
|
||||
assert(selectedMap() == "CELADON_CITY",
|
||||
"Up/Down must cycle the fly cursor to CELADON_CITY, landed on "
|
||||
.. tostring(selectedMap()))
|
||||
U.shot(game, DIR .. "/fly_celadon_selected.png")
|
||||
U.wait(4)
|
||||
|
||||
-- A flies to the highlighted town: flyTo departs (48-frame bird), then warps.
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
assert(game.overworld and game.overworld.flyDest
|
||||
and game.overworld.flyDest.map == "CELADON_CITY",
|
||||
"pressing A on the fly map must start the departure to CELADON_CITY, got "
|
||||
.. tostring(game.overworld and game.overworld.flyDest
|
||||
and game.overworld.flyDest.map))
|
||||
|
||||
-- past the bird sweep + warp transition: the player lands in Celadon
|
||||
local landed = false
|
||||
for _ = 1, 240 do
|
||||
if game.overworld and game.overworld.map
|
||||
and game.overworld.map.id == "CELADON_CITY" then
|
||||
landed = true
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(6)
|
||||
U.shot(game, DIR .. "/fly_landed.png")
|
||||
U.wait(4)
|
||||
assert(landed, "Fly must land the player in CELADON_CITY, ended on "
|
||||
.. tostring(game.overworld and game.overworld.map and game.overworld.map.id))
|
||||
|
||||
U.log("RESULT bug195 PASS")
|
||||
end
|
||||
@@ -0,0 +1,165 @@
|
||||
-- Driver: #198 Celadon Game Corner poster grunt exit.
|
||||
-- The Rocket (GAMECORNER_ROCKET) guards the hideout poster at cell (9,5),
|
||||
-- facing UP toward the poster / secret entrance at (9,4). After you beat
|
||||
-- him he warns "Our hideout might be discovered! I better tell BOSS!" and,
|
||||
-- in Gen1 (scripts/GameCorner.asm GameCornerRocketExitScript), walks UP one
|
||||
-- tile into the poster (the hideout entrance) before HideObject despawns
|
||||
-- him -- freeing (9,5) so the player can reach the poster switch. The bug
|
||||
-- despawned him in place at (9,5) the instant the after-battle box closed.
|
||||
--
|
||||
-- This driver talks to him, mashes through the battle to a win, advances
|
||||
-- the after-battle text, then samples the grunt every frame: it must move
|
||||
-- toward the poster (cellY/targetY north of its start) before it leaves
|
||||
-- ow.npcs. Fails on the pre-fix instant-despawn, passes after the walk.
|
||||
--
|
||||
-- SHOT_DIR=/tmp/gc198 POKEPORT_IDENTITY=bug198 POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gamecorner_rocket_bug198_test.lua love .
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
os.execute("mkdir -p " .. DIR)
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
-- clean slate: the grunt must not already read as defeated/hidden
|
||||
game.save.defeatedTrainers = {}
|
||||
game.save.objectToggles = game.save.objectToggles or {}
|
||||
game.save.objectToggles.GAME_CORNER = nil
|
||||
game.save.player = game.save.player or {}
|
||||
game.save.player.name = game.save.player.name or "RED"
|
||||
|
||||
-- a tank that one-shots the whole party (OPP_ROCKET #7) so the mash win
|
||||
-- is quick and deterministic regardless of type matchups
|
||||
local tank = Pokemon.new(game.data, "MEWTWO", 100)
|
||||
tank.moves = {
|
||||
{ id = "PSYCHIC_M", pp = 99 },
|
||||
{ id = "THUNDERBOLT", pp = 99 },
|
||||
{ id = "ICE_BEAM", pp = 99 },
|
||||
{ id = "EARTHQUAKE", pp = 99 },
|
||||
}
|
||||
game.save.party = { tank }
|
||||
|
||||
-- stand south of the grunt (9,6) facing up; grunt at (9,5) faces the
|
||||
-- poster/secret entrance at (9,4)
|
||||
U.teleport(game, "GAME_CORNER", 9, 6, "up")
|
||||
local ow = game.overworld
|
||||
|
||||
local function findGrunt()
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == "GAMECORNER_ROCKET" then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function pageText()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return "" end
|
||||
local parts = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
if type(page) == "table" then
|
||||
for _, line in ipairs(page) do parts[#parts + 1] = tostring(line) end
|
||||
end
|
||||
end
|
||||
return table.concat(parts, " ")
|
||||
end
|
||||
|
||||
local function idle()
|
||||
return game.stack:top() == ow and not ow.runner:isRunning()
|
||||
and #ow.scriptMoves == 0 and not ow.transitioning
|
||||
end
|
||||
|
||||
local grunt = findGrunt()
|
||||
assert(grunt, "GAMECORNER_ROCKET not present at start")
|
||||
local startX, startY = grunt.cellX, grunt.cellY
|
||||
U.log("grunt start:", startX, startY, grunt.facing)
|
||||
assert(startX == 9 and startY == 5, "grunt not at expected (9,5)")
|
||||
U.shot(game, DIR .. "/gamecorner_rocket_0_before.png")
|
||||
|
||||
-- Talk, then mash A through pre-battle text, the battle (select FIGHT +
|
||||
-- first move), the won text ("Dang!"), until the after-battle "hideout"
|
||||
-- text is on screen. Force-finish a stalled battle via onFinish("win")
|
||||
-- (same safety valve as rival_walkoff_test) so the post-battle script
|
||||
-- (which owns the exit walk) always runs.
|
||||
U.tap(game, "a")
|
||||
local sawAfter = false
|
||||
for f = 1, 4000 do
|
||||
if pageText():find("hideout", 1, true) then sawAfter = true break end
|
||||
local top = game.stack:top()
|
||||
if top and top.phase then
|
||||
if top.phase == "menu" then top.menuIndex = 1
|
||||
elseif top.phase == "moveSelect" then top.moveIndex = 1 end
|
||||
U.tap(game, "a")
|
||||
if f > 2400 and top.onFinish then
|
||||
U.log("force-finishing stalled battle")
|
||||
top.onFinish("win")
|
||||
if game.stack:top() == top then game.stack:pop() end
|
||||
end
|
||||
elseif top ~= ow then
|
||||
U.tap(game, "a")
|
||||
elseif idle() and not findGrunt() then
|
||||
break -- somehow already resolved
|
||||
else
|
||||
U.tap(game, "a")
|
||||
end
|
||||
U.wait(2)
|
||||
end
|
||||
U.log("saw after-battle text:", sawAfter, "defeated:",
|
||||
tostring(game.save.defeatedTrainers["GAME_CORNER_obj_11"]))
|
||||
U.shot(game, DIR .. "/gamecorner_rocket_1_afterbattle.png")
|
||||
assert(sawAfter, "never reached the after-battle 'hideout' text")
|
||||
|
||||
-- Dismiss the after-battle box. From here the fixed script queues a
|
||||
-- one-tile scriptMove UP before hide_object; the buggy script removes
|
||||
-- the grunt in place immediately.
|
||||
U.tap(game, "a")
|
||||
|
||||
-- Sample every logic frame. The scripted walk sets facing=up and
|
||||
-- targetY=(startY-1) for ~16 frames, then lands cellY=startY-1 and the
|
||||
-- onDone despawns him the same frame, so watch for either the in-motion
|
||||
-- targetY or the transient landed cellY north of the start.
|
||||
local walkedUp, walkShot = false, false
|
||||
for _ = 1, 600 do
|
||||
local g = findGrunt()
|
||||
if g then
|
||||
if g.cellY < startY or (g.targetY and g.targetY < startY) then
|
||||
walkedUp = true
|
||||
if not walkShot then
|
||||
walkShot = true
|
||||
U.shot(game, DIR .. "/gamecorner_rocket_2_walk.png")
|
||||
end
|
||||
end
|
||||
else
|
||||
if idle() then break end
|
||||
end
|
||||
if game.stack:top() ~= ow then U.tap(game, "a") end
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
for _ = 1, 400 do
|
||||
if idle() then break end
|
||||
if game.stack:top() ~= ow then U.tap(game, "a") end
|
||||
U.wait(2)
|
||||
end
|
||||
U.wait(5)
|
||||
U.shot(game, DIR .. "/gamecorner_rocket_3_after.png")
|
||||
|
||||
local toggles = game.save.objectToggles.GAME_CORNER
|
||||
U.log("walkedUp:", walkedUp, "grunt gone:", findGrunt() == nil,
|
||||
"toggle:", tostring(toggles and toggles.GAMECORNER_ROCKET))
|
||||
|
||||
-- CORRECT Gen1 behavior: he walks toward the poster before despawning.
|
||||
assert(walkedUp,
|
||||
"grunt never moved toward the poster before despawning (#198)")
|
||||
assert(findGrunt() == nil, "GAMECORNER_ROCKET still present after exit")
|
||||
assert(toggles and toggles.GAMECORNER_ROCKET == false,
|
||||
"grunt objectToggle not hidden")
|
||||
assert(game.save.defeatedTrainers["GAME_CORNER_obj_11"],
|
||||
"grunt not recorded as defeated")
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
assert(not (n.cellX == startX and n.cellY == startY),
|
||||
"an NPC still occupies the grunt's old tile (9,5)")
|
||||
end
|
||||
U.log("gamecorner_rocket_bug198_test: ok")
|
||||
end
|
||||
@@ -0,0 +1,105 @@
|
||||
-- Visual + render-decision regression for #150 ("Grass transparency is off").
|
||||
--
|
||||
-- The reporter's should_be.png shows Red standing in Route 1 tall grass with a
|
||||
-- GREEN cap that blends into the grass. The default SGB color mode instead
|
||||
-- region-tints the player with the per-map SGB BG palette: Red's dark-gray cap
|
||||
-- (DMG shade 2) maps to the ROUTE palette's shade-2 = light-blue (165,214,255)
|
||||
-- (data/generated/palettes.lua ROUTE), so the character clashes with the grass.
|
||||
-- On real GBC/SGB an OBJ carries its own object palette; OG RED already bakes
|
||||
-- PaletteFX.ogObj() (green over Red, pink over Blue -- color/sprites.asm
|
||||
-- ColorOverworldSprite) onto overworld characters. The fix makes SGB mode do
|
||||
-- the same while leaving terrain on its per-map SGB palette.
|
||||
--
|
||||
-- Gate (fails before the fix, passes after):
|
||||
-- * PaletteFX.usesSpriteObp("gbc") == true
|
||||
-- * the player SpriteRenderer bakes a distinct OBJ image in SGB mode
|
||||
-- (resolveImage() ~= the raw grayscale sheet) -- the pixel path that
|
||||
-- recolors the cap green
|
||||
-- * that baked OBJ palette's shade-2 (the cap) is green, not ROUTE light-blue
|
||||
-- Regression guard (must hold before AND after -- terrain is untouched):
|
||||
-- * the ROUTE terrain palette still carries BOTH grass green (173,230,90) and
|
||||
-- light-blue (165,214,255), so the grass field keeps its green+blue dither.
|
||||
--
|
||||
-- Screenshots (SHOT_DIR): grass_bug150_sgb.png (the reported view) and
|
||||
-- grass_bug150_ogred.png (OG RED reference -- green character, red terrain).
|
||||
--
|
||||
-- Run: POKEPORT_DRIVER=tests/drivers/grass_overlay_bug150_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug150 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local DIR = os.getenv("SHOT_DIR") or "."
|
||||
|
||||
local fails = 0
|
||||
local function check(cond, msg)
|
||||
if cond then U.log("ok: " .. msg)
|
||||
else fails = fails + 1; U.log("FAIL: " .. msg) end
|
||||
end
|
||||
local function hasColor(pal, r, g, b)
|
||||
if not pal then return false end
|
||||
for i = 1, #pal do
|
||||
if pal[i][1] == r and pal[i][2] == g and pal[i][3] == b then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- a party + starter flag so the overworld is fully usable
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5))
|
||||
|
||||
-- default SGB color mode. Set the SAVED option too: Game:applyOptions
|
||||
-- re-reads save.options.colors, so a bare setMode() would get reverted.
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.colors = "gbc"
|
||||
PaletteFX.setMode("gbc")
|
||||
|
||||
U.teleport(game, "ROUTE_1", 10, 6, "down")
|
||||
local ow = game.overworld
|
||||
local p = ow.player
|
||||
check(ow.map.id == "ROUTE_1", "on ROUTE_1")
|
||||
check(ow.map:isGrassCell(p.cellX, p.cellY),
|
||||
"player stands on a tall-grass cell (" .. p.cellX .. "," .. p.cellY .. ")")
|
||||
|
||||
U.wait(40) -- let the grass/flower tile animation cycle
|
||||
U.shot(game, DIR .. "/grass_bug150_sgb.png")
|
||||
|
||||
-- === render-decision gate: fails before the fix, passes after =========
|
||||
check(PaletteFX.usesSpriteObp("gbc") == true,
|
||||
"SGB mode bakes an OBJ palette onto overworld characters")
|
||||
|
||||
-- the player's sprite must resolve to a baked OBJ image (not the raw
|
||||
-- grayscale sheet) in SGB mode -- this is the exact path that colors the cap
|
||||
local spr = p.sprite
|
||||
check(spr and spr.image and spr:resolveImage() ~= spr.image,
|
||||
"player sprite bakes a distinct OBJ image in SGB mode")
|
||||
|
||||
-- the baked object palette's shade-2 (the cap) is a green, and specifically
|
||||
-- NOT the ROUTE light-blue the region shader used to hand it
|
||||
local obj = PaletteFX.ogObj() -- {white, brightgreen, darkgreen, black}
|
||||
local cap = obj and obj[3] -- DMG shade 2 -> 3rd palette entry
|
||||
check(cap and cap[2] > cap[1] and cap[2] > cap[3],
|
||||
"OBJ cap color is green-dominant (g>r and g>b)")
|
||||
check(cap and not (cap[1] == 165 and cap[2] == 214 and cap[3] == 255),
|
||||
"OBJ cap color is NOT ROUTE light-blue (165,214,255)")
|
||||
|
||||
-- === regression guard: terrain palette untouched =====================
|
||||
local terrain = PaletteFX.pal(game.data, ow:paletteNameFor(ow.map))
|
||||
check(hasColor(terrain, 173, 230, 90),
|
||||
"ROUTE terrain palette still contains grass green (173,230,90)")
|
||||
check(hasColor(terrain, 165, 214, 255),
|
||||
"ROUTE terrain palette still contains light-blue (grass keeps its blue dither)")
|
||||
|
||||
-- OG RED reference for the human diff (green character over red terrain)
|
||||
game.save.options.colors = "ogred"
|
||||
PaletteFX.setMode("ogred")
|
||||
U.wait(20)
|
||||
U.shot(game, DIR .. "/grass_bug150_ogred.png")
|
||||
-- restore the default so the run doesn't end in a non-default mode
|
||||
game.save.options.colors = "gbc"
|
||||
PaletteFX.setMode("gbc")
|
||||
U.wait(2)
|
||||
|
||||
if fails > 0 then error(fails .. " check(s) failed for #150") end
|
||||
U.log("all #150 checks passed")
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
-- Visual regression for #217: no phantom tall-grass tuft should be drawn
|
||||
-- over the player's head while crossing the Viridian City -> Route 1 seam.
|
||||
--
|
||||
-- The player walks south out of Viridian City's exit path (cellX = 20). On
|
||||
-- the step off the south edge, crossConnection swaps in ROUTE_1 and parks the
|
||||
-- player one cell before the entry point at cellY = -1 (off the top edge) for
|
||||
-- the duration of the seam step. ROUTE_1's border block back-fills that
|
||||
-- off-map row with the grass tile, so before the fix the "feet overdraw"
|
||||
-- painted an animated grass tuft over the player's head for ~5 frames.
|
||||
--
|
||||
-- Screenshots (paths come from SHOT_DIR):
|
||||
-- grass_seam_during.png -- the seam step, map == ROUTE_1 and cellY < 0
|
||||
-- grass_seam_after.png -- one clean frame after the step lands (cellY >= 0)
|
||||
--
|
||||
-- Run: POKEPORT_DRIVER=tests/drivers/grass_seam_bug217_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug217 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
-- a party + starter flag so the overworld is fully usable
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5))
|
||||
|
||||
local shotDir = os.getenv("SHOT_DIR") or "."
|
||||
-- south exit path column; four cells above the bottom edge (cellY 35)
|
||||
U.teleport(game, "VIRIDIAN_CITY", 20, 31, "down")
|
||||
local ow = game.overworld
|
||||
|
||||
local shotDuring, shotAfter = false, false
|
||||
for i = 1, 120 do
|
||||
table.insert(game.input.pressQueue, "down")
|
||||
game.input.state.down = true
|
||||
coroutine.yield()
|
||||
local p = ow.player
|
||||
local inb = ow.map:inBounds(p.cellX, p.cellY)
|
||||
U.log(("f=%d map=%-14s cell=(%d,%d) inb=%s grassCell=%s py=%d moving=%s")
|
||||
:format(i, ow.map.id, p.cellX, p.cellY, tostring(inb),
|
||||
tostring(ow.map:isGrassCell(p.cellX, p.cellY)), p.py, tostring(p.moving)))
|
||||
|
||||
-- BEFORE the fix this is exactly the frame the phantom grass draws:
|
||||
-- map is now ROUTE_1 but the player is still parked off the top edge.
|
||||
if not shotDuring and ow.map.id == "ROUTE_1" and p.cellY < 0 then
|
||||
game.input.state.down = false
|
||||
U.shot(game, shotDir .. "/grass_seam_during.png")
|
||||
shotDuring = true
|
||||
game.input.state.down = true
|
||||
end
|
||||
|
||||
-- one clean frame after the seam step lands on the real map
|
||||
if shotDuring and not shotAfter and ow.map.id == "ROUTE_1"
|
||||
and p.cellY >= 0 and not p.moving then
|
||||
game.input.state.down = false
|
||||
U.shot(game, shotDir .. "/grass_seam_after.png")
|
||||
shotAfter = true
|
||||
break
|
||||
end
|
||||
end
|
||||
game.input.state.down = false
|
||||
U.wait(2)
|
||||
|
||||
if not shotDuring then U.log("WARN: never captured the seam step frame") end
|
||||
if not shotAfter then U.log("WARN: never captured a landed frame") end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Driver: reproduce/verify #204 -- the PvP link/online battle crash when the
|
||||
-- "level ruling" is left on ANY.
|
||||
--
|
||||
-- The link level picker cycles a string sentinel "ANY" meaning "use each
|
||||
-- mon's real level" (Gen1's link cable always fought at the real level, so
|
||||
-- ANY is the project's name for "no forced level"). LinkState.levelForWire is
|
||||
-- supposed to turn that sentinel into nil before it goes on the wire; a broken
|
||||
-- `x and nil or y` idiom instead let the literal "ANY" reach opts.forceLevel,
|
||||
-- and Protocol.unpackMon then crashed on math.floor("ANY") the instant newHost
|
||||
-- unpacked the parties (report traceback: Protocol.lua unpackMon <- LinkBattle
|
||||
-- unpackParty <- newHost <- LinkState.update). This drives that exact host
|
||||
-- battle path in a real window with the bad value forceLevel = "ANY".
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local Net = require("src.link.Net")
|
||||
local LinkBattle = require("src.link.LinkBattle")
|
||||
|
||||
-- a low-level host mon and a high-level foe: the ANY ruling must keep both
|
||||
-- real levels (12 and 100), unlike an AUTO 50 ruling that forces them equal
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) }
|
||||
game.save.player.name = "RED"
|
||||
local foeParty = { Pokemon.new(game.data, "GEODUDE", 100) }
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(5)
|
||||
|
||||
local netA = Net.loopbackPair() -- the guest end is unused: the intro is
|
||||
-- local, and the crash (if present) fires
|
||||
-- during newHost before any turn is taken
|
||||
local opts = {
|
||||
myParty = Protocol.packParty(game.save.party),
|
||||
theirParty = Protocol.packParty(foeParty),
|
||||
theirName = "BLUE",
|
||||
seed = 24680,
|
||||
forceLevel = "ANY", -- the exact value LinkState.levelForWire wrongly emitted
|
||||
}
|
||||
|
||||
local ok, battle = pcall(LinkBattle.newHost, game, netA, opts)
|
||||
if not ok then
|
||||
-- pre-fix: math.floor("ANY") throws inside unpackMon; the battle never
|
||||
-- starts. Capture the still-in-overworld state and the error message
|
||||
-- (the harness would otherwise just print "driver error" and exit, with
|
||||
-- no on-screen error screen to shoot).
|
||||
U.log("LINK_ANY_204: newHost CRASHED (bug present): " .. tostring(battle))
|
||||
U.shot(game, DIR .. "/link_any_204_crash.png")
|
||||
U.log("LINK_ANY_204: done (crash reproduced)")
|
||||
return
|
||||
end
|
||||
|
||||
-- post-fix: the battle constructs; push it and screenshot the intro/scene
|
||||
game.stack:push(battle)
|
||||
U.wait(20)
|
||||
U.shot(game, DIR .. "/link_any_204_battle_intro.png")
|
||||
U.wait(40)
|
||||
U.shot(game, DIR .. "/link_any_204_battle_scene.png")
|
||||
|
||||
local hostLvl = battle.player and battle.player.mon and battle.player.mon.level
|
||||
local foeLvl = battle.enemy and battle.enemy.mon and battle.enemy.mon.level
|
||||
U.log(("LINK_ANY_204: battle started; host lvl=%s foe lvl=%s (expect 12 / 100)")
|
||||
:format(tostring(hostLvl), tostring(foeLvl)))
|
||||
if hostLvl == 12 and foeLvl == 100 then
|
||||
U.log("LINK_ANY_204: PASS -- real levels preserved under the ANY ruling")
|
||||
else
|
||||
U.log("LINK_ANY_204: FAIL -- levels not preserved (host=" ..
|
||||
tostring(hostLvl) .. " foe=" .. tostring(foeLvl) .. ")")
|
||||
end
|
||||
U.log("LINK_ANY_204: done")
|
||||
end
|
||||
@@ -0,0 +1,162 @@
|
||||
-- Driver: regression coverage for #219 "Early Blue Battle".
|
||||
--
|
||||
-- TEXT_OAKSLAB_RIVAL (data/scripts/oaks_lab.lua) is the rival's *talk*
|
||||
-- handler. In pret/pokered scripts/OaksLab.asm OaksLabText8, talking to
|
||||
-- the rival after you have a starter but before the lab battle only prints
|
||||
-- _OaksLabRivalMyPokemonLooksStrongerText: the battle itself is a
|
||||
-- coordinate trigger (OaksLabRivalChallengesPlayerScript, wYCoord == 6),
|
||||
-- never a talk action. The buggy handler fell through from that line
|
||||
-- straight into start_battle OPP_RIVAL1, so talking to Blue at the table
|
||||
-- immediately launched the rival fight.
|
||||
--
|
||||
-- Scenario A (the #219 regression): talk to the rival with a starter but
|
||||
-- no lab battle yet. Correct: the "looks stronger" line shows and NO
|
||||
-- battle starts. Fails before the fix (start_battle fires on talk).
|
||||
-- Scenario B (guard against over-correction): step onto the coordinate
|
||||
-- trigger (y >= 6). Correct: the onStep challenge still starts the
|
||||
-- battle. Passes both before and after the fix.
|
||||
--
|
||||
-- start_battle is stubbed to record the call and return "end" (halts the
|
||||
-- script cleanly, no BattleState push, no yield) so the run never hangs
|
||||
-- and needs no full party. We also read the raw text handed to
|
||||
-- TextBox.new (before {PLAYER}/{RIVAL} substitution) so "stronger" is
|
||||
-- detectable.
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- Capture the raw text the next TextBox is built with.
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local origNew = TextBox.new
|
||||
local lastText
|
||||
TextBox.new = function(g, text, ...)
|
||||
lastText = text
|
||||
return origNew(g, text, ...)
|
||||
end
|
||||
|
||||
-- Stub start_battle: record it and halt the script instead of pushing a
|
||||
-- BattleState (which would need a real party) or yielding (which would
|
||||
-- hang the driver). ScriptRunner resolves the live Commands.start_battle
|
||||
-- when no mod overrides it, so this monkeypatch intercepts both the talk
|
||||
-- handler and the onStep challenge.
|
||||
local Commands = require("src.script.Commands")
|
||||
local origStartBattle = Commands.start_battle
|
||||
local battleStarted = false
|
||||
Commands.start_battle = function(_ctx, _kind, _a, _b)
|
||||
battleStarted = true
|
||||
return "end"
|
||||
end
|
||||
|
||||
local function restore()
|
||||
TextBox.new = origNew
|
||||
Commands.start_battle = origStartBattle
|
||||
end
|
||||
|
||||
local function setFlags()
|
||||
local flags = game.save.flags or {}
|
||||
game.save.flags = flags
|
||||
flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
|
||||
flags.EVENT_GOT_STARTER = true
|
||||
flags.EVENT_CHOSE_SQUIRTLE = true
|
||||
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = nil
|
||||
end
|
||||
|
||||
-- ---- Scenario A: talk to the rival at the table.
|
||||
-- fresh overworld in Oak's lab, player one cell right of the rival
|
||||
-- (object 1 at cell 4,3) and facing him.
|
||||
U.teleport(game, "OAKS_LAB", 5, 3, "left")
|
||||
setFlags()
|
||||
U.wait(6)
|
||||
battleStarted = false
|
||||
lastText = nil
|
||||
|
||||
U.shot(game, DIR .. "/a_before.png")
|
||||
|
||||
-- open the rival's textbox
|
||||
U.tap(game, "left"); U.wait(2)
|
||||
for _ = 1, 8 do
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 30 do
|
||||
if lastText then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if lastText then break end
|
||||
end
|
||||
local strongerSeen = lastText ~= nil and lastText:find("stronger") ~= nil
|
||||
-- let the typewriter reveal the line, then shoot the box: the "looks
|
||||
-- stronger" taunt, still overworld, no battle intro
|
||||
U.wait(30)
|
||||
U.shot(game, DIR .. "/a_after.png")
|
||||
|
||||
-- dismiss the taunt box. Before the fix, closing it drops the script
|
||||
-- into the buggy start_battle rows (battleStarted flips true); after the
|
||||
-- fix it hits jump "end" and the stack settles back to the overworld.
|
||||
-- Stop the moment either happens so we never re-open the box by talking
|
||||
-- again (which would leave a stray TextBox on top and false-fail the
|
||||
-- overworld check).
|
||||
for _ = 1, 20 do
|
||||
if battleStarted then break end
|
||||
if game.stack:top() == game.overworld then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
U.wait(10) -- let any fall-through start_battle fire
|
||||
|
||||
local aBattleStarted = battleStarted
|
||||
local aOverworld = (game.stack:top() == game.overworld)
|
||||
local aText = lastText or "<none>"
|
||||
local aPass = strongerSeen and (aBattleStarted == false) and aOverworld
|
||||
U.log("SCENARIO A text:", aText)
|
||||
U.log("SCENARIO A strongerSeen:", tostring(strongerSeen),
|
||||
"battleStarted:", tostring(aBattleStarted),
|
||||
"overworld:", tostring(aOverworld))
|
||||
U.log("SCENARIO A", aPass and "PASS" or "FAIL")
|
||||
|
||||
-- close any open box before scenario B
|
||||
for _ = 1, 10 do U.tap(game, "a"); U.wait(2) end
|
||||
|
||||
-- ---- Scenario B: step onto the coordinate trigger (y >= 6).
|
||||
-- Guards the real challenge path so the fix doesn't kill the lab battle.
|
||||
U.teleport(game, "OAKS_LAB", 4, 5, "down")
|
||||
setFlags()
|
||||
U.wait(6)
|
||||
battleStarted = false
|
||||
do
|
||||
local p = game.overworld.player
|
||||
U.log("SCENARIO B start cell:", tostring(p.cellX), tostring(p.cellY),
|
||||
"rival:", tostring(game.overworld:npcByIndex(1) ~= nil))
|
||||
end
|
||||
-- walk down until the player crosses y == 6 (OaksLabRivalChallenges),
|
||||
-- pausing to mash A so the "I'll take you on" box + rival walk advance
|
||||
for _ = 1, 6 do
|
||||
U.hold(game, "down", 8)
|
||||
for _ = 1, 12 do
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
if battleStarted then break end
|
||||
end
|
||||
if battleStarted then break end
|
||||
end
|
||||
do
|
||||
local p = game.overworld.player
|
||||
U.log("SCENARIO B end cell:", tostring(p.cellX), tostring(p.cellY),
|
||||
"lastText:", tostring(lastText))
|
||||
end
|
||||
U.shot(game, DIR .. "/b_after.png")
|
||||
local bPass = (battleStarted == true)
|
||||
U.log("SCENARIO B battleStarted:", tostring(battleStarted))
|
||||
U.log("SCENARIO B", bPass and "PASS" or "FAIL")
|
||||
|
||||
-- restore hooks before any assert so a failure can't leave them installed
|
||||
restore()
|
||||
|
||||
U.log("RESULT bug219", (aPass and bPass) and "PASS" or "FAIL")
|
||||
assert(aPass,
|
||||
"Scenario A: talking to the rival must only show the 'looks stronger' "
|
||||
.. "line and start no battle (strongerSeen/battleStarted=false/overworld); "
|
||||
.. "text=" .. aText .. " battleStarted=" .. tostring(aBattleStarted))
|
||||
assert(bPass,
|
||||
"Scenario B: stepping onto the coordinate trigger must still start the "
|
||||
.. "lab battle")
|
||||
end
|
||||
@@ -0,0 +1,122 @@
|
||||
-- Driver: regression coverage for #232 "Hey! Don't go away yet!".
|
||||
--
|
||||
-- The Oak leave-block in data/scripts/oaks_lab.lua onStep must halt a
|
||||
-- pre-starter player at the bookshelf row (cell y == 6), the same
|
||||
-- coordinate the sibling rival challenge below it uses. In
|
||||
-- pret/pokered scripts/OaksLab.asm the "don't go away yet" intercept
|
||||
-- and OaksLabRivalChallengesPlayerScript share the wYCoord == 6
|
||||
-- coordinate script slot (gated on EVENT_GOT_STARTER); the bookshelves
|
||||
-- flank that corridor, so Oak stops you level with the shelves rather
|
||||
-- than one full corridor later on the exit mat.
|
||||
--
|
||||
-- Bug (before the fix): the guard read `y == 11 and (x == 4 or x == 5)`,
|
||||
-- i.e. it only fired on the exit door mat. A pre-starter player walked
|
||||
-- the whole corridor down through the shelves (y=6,7,8,9,10) and was only
|
||||
-- halted at y=11, exactly the port screenshot the issue attached.
|
||||
--
|
||||
-- OAKS_LAB is 10x12 cells (x 0-9, y 0-11). Live walkability:
|
||||
-- y=5 .......... (open)
|
||||
-- y=6 ####..#### (bookshelves solid at x=0-3/6-9, corridor at x=4,5)
|
||||
-- y=7 ####..####
|
||||
-- y=10 .......... door mat warps at (4,11)/(5,11)
|
||||
-- so at y>=6 only the x=4,5 corridor is walkable, and once Oak pushes the
|
||||
-- player back to y=5 they can never reach y>=7 -- the trigger only ever
|
||||
-- fires at y=6 in the corridor, matching the rival trigger's shape.
|
||||
--
|
||||
-- We monkeypatch TextBox.new to detect the raw "Don't go" text and record
|
||||
-- the player's cell Y at that instant (interceptY). No battle is involved
|
||||
-- (EVENT_GOT_STARTER stays clear so the rival-challenge branch is skipped),
|
||||
-- so nothing needs stubbing.
|
||||
|
||||
return function(game)
|
||||
io.stdout:setvbuf("no") -- LOVE block-buffers stdout; flush [driver] logs
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- Capture the raw text handed to TextBox and the Y it fired at.
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local origNew = TextBox.new
|
||||
local dontGoSeen = false
|
||||
local interceptY = nil
|
||||
TextBox.new = function(g, text, ...)
|
||||
if type(text) == "string" and text:find("Don't go", 1, true) then
|
||||
dontGoSeen = true
|
||||
if game.overworld and game.overworld.player then
|
||||
interceptY = interceptY or game.overworld.player.cellY
|
||||
end
|
||||
end
|
||||
return origNew(g, text, ...)
|
||||
end
|
||||
|
||||
local function restore()
|
||||
TextBox.new = origNew
|
||||
end
|
||||
|
||||
-- The pre-starter state right after the walk-in cutscene: FOLLOWED_OAK
|
||||
-- set, no starter yet, no lab battle yet.
|
||||
local function setFlags()
|
||||
local flags = game.save.flags or {}
|
||||
game.save.flags = flags
|
||||
flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
|
||||
flags.EVENT_GOT_STARTER = nil
|
||||
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = nil
|
||||
end
|
||||
|
||||
-- story2.lua labWalkIn ends the follow-Oak cutscene at (5,3) facing down.
|
||||
U.teleport(game, "OAKS_LAB", 5, 3, "down")
|
||||
setFlags()
|
||||
U.wait(6)
|
||||
|
||||
local ow = game.overworld
|
||||
U.log("start cell:", tostring(ow.player.cellX), tostring(ow.player.cellY),
|
||||
"map:", ow.map.id)
|
||||
U.shot(game, DIR .. "/bug232_start.png")
|
||||
|
||||
-- Walk down toward the exit in bursts, mashing A to dismiss the "don't go
|
||||
-- away" box each time it opens. Track how far down the player ever gets.
|
||||
local maxY = ow.player.cellY
|
||||
local shotIntercept = false
|
||||
for _ = 1, 12 do
|
||||
U.hold(game, "down", 8)
|
||||
maxY = math.max(maxY, ow.player.cellY)
|
||||
if dontGoSeen and not shotIntercept then
|
||||
-- let the typewriter reveal the line before shooting the box
|
||||
U.wait(20)
|
||||
U.shot(game, DIR .. "/bug232_intercept.png")
|
||||
shotIntercept = true
|
||||
end
|
||||
for _ = 1, 6 do
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
maxY = math.max(maxY, ow.player.cellY)
|
||||
end
|
||||
maxY = math.max(maxY, ow.player.cellY)
|
||||
U.log("iter cell:", tostring(ow.player.cellX), tostring(ow.player.cellY),
|
||||
"map:", ow.map.id, "maxY:", maxY, "dontGoSeen:", tostring(dontGoSeen))
|
||||
if ow.map.id ~= "OAKS_LAB" then break end
|
||||
end
|
||||
|
||||
U.shot(game, DIR .. "/bug232_end.png")
|
||||
|
||||
local finalMap = ow.map.id
|
||||
U.log("RESULT dontGoSeen:", tostring(dontGoSeen),
|
||||
"interceptY:", tostring(interceptY), "maxY:", maxY,
|
||||
"finalMap:", finalMap, "finalY:", tostring(ow.player.cellY))
|
||||
|
||||
-- restore the hook before any assert so a failure can't leave it installed
|
||||
restore()
|
||||
|
||||
assert(dontGoSeen,
|
||||
"Oak's 'don't go away yet' block must fire when a pre-starter player "
|
||||
.. "walks toward the exit")
|
||||
assert(interceptY == 6,
|
||||
"Oak must intercept at the bookshelf row (cell y == 6), not the exit "
|
||||
.. "mat; interceptY=" .. tostring(interceptY))
|
||||
assert(maxY <= 6,
|
||||
"the player must never pass the bookshelves (y>=7) before Oak stops "
|
||||
.. "them; maxY=" .. tostring(maxY))
|
||||
assert(finalMap == "OAKS_LAB",
|
||||
"the pre-starter player must never warp out of the lab; finalMap="
|
||||
.. tostring(finalMap))
|
||||
U.log("RESULT bug232 PASS")
|
||||
end
|
||||
@@ -0,0 +1,92 @@
|
||||
-- Driver: regression coverage for #218 "Blue mentions Oak being absent".
|
||||
--
|
||||
-- TEXT_OAKSLAB_RIVAL (data/scripts/oaks_lab.lua) picks the pre-starter line.
|
||||
-- pret/pokered scripts/OaksLab.asm gates that line on EVENT_FOLLOWED_OAK_INTO_LAB:
|
||||
-- * flag CLEAR (Oak has not escorted you in yet) -> "Yo {PLAYER}! Gramps
|
||||
-- isn't around!" (_OaksLabRivalGrampsIsntAroundText)
|
||||
-- * flag SET (Oak present, three balls on the table) -> "Heh, I don't need
|
||||
-- to be greedy... Go ahead and choose, {PLAYER}!" (_OaksLabRivalGoAheadAndChooseText)
|
||||
-- The buggy handler jumped straight to GrampsIsntAround for any no-starter
|
||||
-- state, so the rival wrongly claimed Oak was gone while Oak stood in the lab.
|
||||
--
|
||||
-- We read the RAW text handed to TextBox.new (before pagination and {PLAYER}/
|
||||
-- {RIVAL} substitution) so the distinctive words "greedy"/"Gramps" survive.
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- Capture the raw text the next TextBox is built with.
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local origNew = TextBox.new
|
||||
local lastText
|
||||
TextBox.new = function(g, text, ...)
|
||||
lastText = text
|
||||
return origNew(g, text, ...)
|
||||
end
|
||||
|
||||
-- fresh overworld in Oak's lab, player standing one cell right of the
|
||||
-- rival (object 1 at cell 4,3) and facing him.
|
||||
local function setup(followed)
|
||||
U.teleport(game, "OAKS_LAB", 5, 3, "left")
|
||||
local flags = game.save.flags or {}
|
||||
game.save.flags = flags
|
||||
flags.EVENT_FOLLOWED_OAK_INTO_LAB = followed or nil
|
||||
flags.EVENT_GOT_STARTER = nil
|
||||
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = nil
|
||||
U.wait(6)
|
||||
end
|
||||
|
||||
-- face the rival and press A until his text box opens (lastText set).
|
||||
local function talkToRival()
|
||||
lastText = nil
|
||||
U.tap(game, "left"); U.wait(2)
|
||||
for _ = 1, 5 do
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 60 do
|
||||
if lastText then return end
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- close any open text box before the next scenario.
|
||||
local function dismiss()
|
||||
for _ = 1, 20 do U.tap(game, "a"); U.wait(2) end
|
||||
end
|
||||
|
||||
-- ---- Scenario A: Oak has escorted the player in, no starter chosen.
|
||||
-- Correct Gen1 line: the greedy / "go ahead and choose" taunt.
|
||||
setup(true)
|
||||
U.shot(game, DIR .. "/a_before.png")
|
||||
talkToRival()
|
||||
U.wait(20)
|
||||
U.shot(game, DIR .. "/a_after.png")
|
||||
local aText = lastText or "<none>"
|
||||
local aPass = (aText:find("greedy") ~= nil) and (aText:find("Gramps") == nil)
|
||||
U.log("SCENARIO A (FOLLOWED_OAK set) text:", aText)
|
||||
U.log("SCENARIO A", aPass and "PASS" or "FAIL")
|
||||
|
||||
dismiss()
|
||||
|
||||
-- ---- Scenario B: very early game, Oak has not walked you in yet.
|
||||
-- Correct Gen1 line: "Gramps isn't around". Guards the appended
|
||||
-- jump_if_false path so the fix keeps the true-early-game text.
|
||||
setup(false)
|
||||
talkToRival()
|
||||
U.wait(20)
|
||||
U.shot(game, DIR .. "/b_after.png")
|
||||
local bText = lastText or "<none>"
|
||||
local bPass = (bText:find("Gramps") ~= nil)
|
||||
U.log("SCENARIO B (FOLLOWED_OAK clear) text:", bText)
|
||||
U.log("SCENARIO B", bPass and "PASS" or "FAIL")
|
||||
|
||||
-- restore before any assert so a failure can't leave the hook installed
|
||||
TextBox.new = origNew
|
||||
|
||||
U.log("RESULT bug218", (aPass and bPass) and "PASS" or "FAIL")
|
||||
assert(aPass,
|
||||
"Scenario A: rival must give the greedy/choose line while Oak is in the lab, got: " .. aText)
|
||||
assert(bPass,
|
||||
"Scenario B: rival must say Gramps isn't around before Oak escorts you, got: " .. bText)
|
||||
end
|
||||
@@ -0,0 +1,170 @@
|
||||
-- Driver: regression coverage for #231 "Missing Blue Dialogue (first rival
|
||||
-- battle exit line)".
|
||||
--
|
||||
-- The first lab rival battle is a coordinate trigger in
|
||||
-- data/scripts/oaks_lab.lua onStep (OaksLabRivalChallengesPlayerScript,
|
||||
-- wYCoord == 6). In pret/pokered scripts/OaksLab.asm the post-battle
|
||||
-- OaksLabRivalEndBattleScript heals + flags, then on WIN prints the
|
||||
-- "I picked the wrong POKéMON!" gloat, and on BOTH win and loss prints the
|
||||
-- shared exit line _OaksLabRivalSmellYouLaterText ("OK! I'll make my POKéMON
|
||||
-- fight to toughen it up!\012<PLAYER>! Gramps! Smell you later!") before Blue
|
||||
-- walks out. The buggy onStep sequence omitted that exit line entirely, so
|
||||
-- Blue left the lab silently.
|
||||
--
|
||||
-- Scenario WIN: after the battle Blue must gloat ("picked the wrong POKéMON")
|
||||
-- AND say the exit line ("Smell you later").
|
||||
-- Scenario LOSS: Blue skips the gloat (that taunt was shown in-battle via
|
||||
-- Rival1WinText) but must STILL say the exit line ("Smell you later").
|
||||
--
|
||||
-- Both scenarios FAIL before the fix (the exit line never appears) and PASS
|
||||
-- after it.
|
||||
--
|
||||
-- Mechanics: TextBox.new is hooked to APPEND every raw `text` arg (before
|
||||
-- {PLAYER}/{RIVAL} substitution, so the token-free search substrings survive)
|
||||
-- into `seen`; the whole SmellYouLater string, incl. the \012 page break,
|
||||
-- arrives in one TextBox.new call. start_battle is stubbed to record the
|
||||
-- result and RETURN NIL (not "end"): ScriptRunner then continues
|
||||
-- synchronously into heal_party/set_flag/jump_if_false/show_text with NO
|
||||
-- BattleState push and NO yield, so the run needs no party and cannot hang.
|
||||
-- heal_party is a safe no-op on an empty party.
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- accumulate every raw text a TextBox is built with
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local origNew = TextBox.new
|
||||
local seen = {}
|
||||
TextBox.new = function(g, text, ...)
|
||||
if type(text) == "string" then seen[#seen + 1] = text end
|
||||
return origNew(g, text, ...)
|
||||
end
|
||||
|
||||
-- Stub start_battle: set the win/loss result the post-battle rows branch on
|
||||
-- and return nil so ScriptRunner falls through to heal_party/set_flag/
|
||||
-- jump_if_false/show_text with no BattleState and no yield. ScriptRunner
|
||||
-- resolves the live Commands.start_battle when no mod overrides it, so this
|
||||
-- monkeypatch intercepts the onStep challenge (Commands.resolve).
|
||||
local Commands = require("src.script.Commands")
|
||||
local origStartBattle = Commands.start_battle
|
||||
local winResult = true
|
||||
Commands.start_battle = function(ctx, _kind, _a, _b)
|
||||
ctx.lastBattleResult = winResult and "win" or "lose"
|
||||
ctx.lastCheck = winResult -- Rival1: check = (result == "win")
|
||||
return nil
|
||||
end
|
||||
|
||||
local function restore()
|
||||
TextBox.new = origNew
|
||||
Commands.start_battle = origStartBattle
|
||||
end
|
||||
|
||||
-- Set the pre-battle flag state AND clear the rival's object toggle. The
|
||||
-- WIN sequence ends with hide_object OAKSLAB_RIVAL, which persists as
|
||||
-- save.objectToggles.OAKS_LAB.OAKSLAB_RIVAL = false; without clearing it the
|
||||
-- LOSS re-teleport spawns with the rival hidden and onStep (which returns
|
||||
-- false when npcByIndex(1) is nil) never fires the challenge. Must run
|
||||
-- BEFORE U.teleport, since the map spawns its objects on push.
|
||||
local function resetSave()
|
||||
local flags = game.save.flags or {}
|
||||
game.save.flags = flags
|
||||
flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
|
||||
flags.EVENT_GOT_STARTER = true
|
||||
flags.EVENT_CHOSE_SQUIRTLE = true
|
||||
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = nil
|
||||
if game.save.objectToggles and game.save.objectToggles.OAKS_LAB then
|
||||
game.save.objectToggles.OAKS_LAB.OAKSLAB_RIVAL = nil
|
||||
end
|
||||
end
|
||||
|
||||
local function seenHas(sub)
|
||||
for _, t in ipairs(seen) do
|
||||
if t:find(sub, 1, true) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Walk down onto the y>=6 trigger, then page through the post-battle boxes
|
||||
-- and the walk-out. Bounded so the window always quits. Captures the exit
|
||||
-- line box the first time "Smell you later" appears, and a final overworld
|
||||
-- shot once Blue has despawned (hide_object at the end of the sequence).
|
||||
local function drive(smellShot, endShot)
|
||||
local grabbedSmell = false
|
||||
for _ = 1, 500 do
|
||||
if (not grabbedSmell) and seenHas("Smell you later") then
|
||||
U.wait(18) -- let the typewriter reveal the line before the shot
|
||||
U.shot(game, smellShot)
|
||||
grabbedSmell = true
|
||||
end
|
||||
local rival = game.overworld:npcByIndex(1)
|
||||
if rival == nil then break end -- rival walked out + hid: sequence done
|
||||
-- before the trigger fires the player must step down onto y>=6; after,
|
||||
-- A pages every text box (IllTakeYouOn, IPicked, SmellYouLater)
|
||||
local p = game.overworld.player
|
||||
if p and (p.cellY or 0) < 6 then
|
||||
U.hold(game, "down", 8)
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
U.wait(6)
|
||||
U.shot(game, endShot)
|
||||
return grabbedSmell
|
||||
end
|
||||
|
||||
-- ---- Scenario WIN
|
||||
resetSave()
|
||||
U.teleport(game, "OAKS_LAB", 4, 5, "down")
|
||||
U.wait(6)
|
||||
winResult = true
|
||||
seen = {}
|
||||
do
|
||||
local p = game.overworld.player
|
||||
U.log("WIN start cell:", tostring(p.cellX), tostring(p.cellY),
|
||||
"rival:", tostring(game.overworld:npcByIndex(1) ~= nil))
|
||||
end
|
||||
U.shot(game, DIR .. "/win_before.png")
|
||||
drive(DIR .. "/win_smell.png", DIR .. "/win_end.png")
|
||||
local winGloat = seenHas("picked the")
|
||||
local winSmell = seenHas("Smell you later")
|
||||
local winPass = winGloat and winSmell
|
||||
U.log("WIN gloat(picked the):", tostring(winGloat),
|
||||
"exit(Smell you later):", tostring(winSmell))
|
||||
U.log("WIN", winPass and "PASS" or "FAIL")
|
||||
|
||||
-- ---- Scenario LOSS
|
||||
resetSave()
|
||||
U.teleport(game, "OAKS_LAB", 4, 5, "down")
|
||||
U.wait(6)
|
||||
winResult = false
|
||||
seen = {}
|
||||
do
|
||||
local p = game.overworld.player
|
||||
U.log("LOSS start cell:", tostring(p.cellX), tostring(p.cellY),
|
||||
"rival:", tostring(game.overworld:npcByIndex(1) ~= nil))
|
||||
end
|
||||
U.shot(game, DIR .. "/loss_before.png")
|
||||
drive(DIR .. "/loss_smell.png", DIR .. "/loss_end.png")
|
||||
local lossGloat = seenHas("picked the")
|
||||
local lossSmell = seenHas("Smell you later")
|
||||
-- loss must skip the win gloat but still print the shared exit line
|
||||
local lossPass = lossSmell and (not lossGloat)
|
||||
U.log("LOSS gloat(picked the):", tostring(lossGloat),
|
||||
"exit(Smell you later):", tostring(lossSmell))
|
||||
U.log("LOSS", lossPass and "PASS" or "FAIL")
|
||||
|
||||
-- restore hooks before any assert so a failure can't leave them installed
|
||||
restore()
|
||||
|
||||
U.log("RESULT bug231", (winPass and lossPass) and "PASS" or "FAIL")
|
||||
assert(winPass,
|
||||
"WIN: after the first lab rival battle Blue must gloat "
|
||||
.. "('I picked the wrong POKéMON!') AND say the exit line "
|
||||
.. "('Smell you later'); got gloat=" .. tostring(winGloat)
|
||||
.. " exit=" .. tostring(winSmell))
|
||||
assert(lossPass,
|
||||
"LOSS: after losing the first lab rival battle Blue must skip the gloat "
|
||||
.. "but STILL say the exit line ('Smell you later'); got gloat="
|
||||
.. tostring(lossGloat) .. " exit=" .. tostring(lossSmell))
|
||||
end
|
||||
@@ -0,0 +1,107 @@
|
||||
-- Driver: OG BLUE (GBC boot-ROM) palette correctness for Pokemon Blue (#155).
|
||||
--
|
||||
-- Pokemon Blue, like Red, ships no CGB code, so a Game Boy Color colorizes it
|
||||
-- from the boot ROM's per-game auto-palette table. Blue's entry is NOT a
|
||||
-- mirror of Red's and does NOT share Red's green characters: per Bulbapedia's
|
||||
-- Generation-I GBC boot-ROM palette table (and the Gambatte hardware capture
|
||||
-- attached to #155) Blue is a light-blue/blue BACKGROUND
|
||||
-- with a PINK object (OBP0) palette -- the same red/pink ramp Red uses for its
|
||||
-- BACKGROUND. The port had baked a fabricated "channel-swapped Red" BG and
|
||||
-- kept Red's green sprites for both versions, so a Blue playthrough in COLORS =
|
||||
-- OG rendered periwinkle terrain and a green player instead of blue terrain and
|
||||
-- a pink player.
|
||||
--
|
||||
-- These checks are pure-data (version-forced via GameVersion.set) so they run
|
||||
-- even on a Red-only cache; the two screenshots force the Blue OG palette over
|
||||
-- the overworld for visual before/after evidence. Ground-truth RGB below is
|
||||
-- Bulbapedia BG 0xFFFFFF/0x63A5FF/0x0000FF/0x000000, OBP0
|
||||
-- 0xFFFFFF/0xFF8484/0x943A3A/0x000000.
|
||||
--
|
||||
-- Run: SHOT_DIR=/tmp/ogblue POKEPORT_IDENTITY=bug155 POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/ogblue_palette_bug155_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local DIR = os.getenv("SHOT_DIR") or "."
|
||||
|
||||
local fails = 0
|
||||
local function expect(cond, ...)
|
||||
if not cond then fails = fails + 1 end
|
||||
U.log(cond and "PASS" or "FAIL", ...)
|
||||
end
|
||||
|
||||
-- deep-equal for a 4-color {r,g,b} palette table
|
||||
local function palEq(a, b)
|
||||
if type(a) ~= "table" or type(b) ~= "table" or #a ~= #b then return false end
|
||||
for i = 1, #a do
|
||||
local ca, cb = a[i], b[i]
|
||||
if type(ca) ~= "table" or type(cb) ~= "table" then return false end
|
||||
if ca[1] ~= cb[1] or ca[2] ~= cb[2] or ca[3] ~= cb[3] then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local TRUE_BG = { {255,255,255}, {99,165,255}, {0,0,255}, {0,0,0} }
|
||||
local TRUE_OBJ = { {255,255,255}, {255,132,132}, {148,58,58}, {0,0,0} }
|
||||
|
||||
-- (1) the background constant is the real GBC Blue BG, not the periwinkle
|
||||
-- 0x8484FF/0x3A3A94 mirror of Red
|
||||
expect(palEq(PaletteFX.GBC_BG_BLUE, TRUE_BG),
|
||||
"GBC_BG_BLUE == real GBC Blue BG 0x63A5FF/0x0000FF, got",
|
||||
PaletteFX.GBC_BG_BLUE and PaletteFX.GBC_BG_BLUE[2]
|
||||
and table.concat(PaletteFX.GBC_BG_BLUE[2], ","))
|
||||
|
||||
-- (2) a dedicated Blue OBJ (OBP0) constant exists and is the pink ramp
|
||||
expect(PaletteFX.GBC_OBJ_BLUE ~= nil and palEq(PaletteFX.GBC_OBJ_BLUE, TRUE_OBJ),
|
||||
"GBC_OBJ_BLUE == real GBC Blue OBP0 pink 0xFF8484/0x943A3A")
|
||||
-- (3) ... and it is NOT the green Red OBJ palette
|
||||
expect(PaletteFX.GBC_OBJ_BLUE ~= nil
|
||||
and not palEq(PaletteFX.GBC_OBJ_BLUE, PaletteFX.GBC_OBJ),
|
||||
"Blue OBJ is NOT the green Red GBC_OBJ")
|
||||
|
||||
-- (4) version routing: as Blue, the OG helpers resolve Blue's palettes
|
||||
local savedVer = GameVersion.get()
|
||||
GameVersion.set("blue")
|
||||
expect(palEq(PaletteFX.ogBg(), TRUE_BG), "ogBg() -> Blue BG when isBlue()")
|
||||
expect(type(PaletteFX.ogObj) == "function", "PaletteFX.ogObj() helper exists")
|
||||
if type(PaletteFX.ogObj) == "function" then
|
||||
local c = PaletteFX.ogObj()
|
||||
expect(palEq(c, TRUE_OBJ), "ogObj() -> Blue pink OBJ when isBlue()")
|
||||
end
|
||||
|
||||
-- (5) version routing: as Red, the OG helpers still resolve Red's palettes
|
||||
-- (green player over the red field is correct and must not regress)
|
||||
GameVersion.set("red")
|
||||
expect(palEq(PaletteFX.ogBg(), PaletteFX.GBC_BG), "ogBg() -> Red BG when Red")
|
||||
if type(PaletteFX.ogObj) == "function" then
|
||||
expect(palEq(PaletteFX.ogObj(), PaletteFX.GBC_OBJ),
|
||||
"ogObj() -> green Red OBJ when Red")
|
||||
end
|
||||
|
||||
-- Visual proof: force Blue's OG palette over the (Red-cache) overworld. The
|
||||
-- terrain colors come from ogBg() and the player sprite from ogObj(), so the
|
||||
-- shot exercises the exact code the fix touches. Before: periwinkle terrain
|
||||
-- + green player; after: light-blue/blue terrain + pink player, matching
|
||||
-- the Gambatte capture attached to #155.
|
||||
GameVersion.set("blue")
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.colors = "ogred"
|
||||
PaletteFX.setMode("ogred")
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
game.save.party = { Pokemon.new(game.data, "SQUIRTLE", 5) }
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(60)
|
||||
U.shot(game, DIR .. "/ogblue_01_route1.png")
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 5, 6, "down")
|
||||
U.wait(60)
|
||||
U.shot(game, DIR .. "/ogblue_02_pallet.png")
|
||||
|
||||
GameVersion.set(savedVer)
|
||||
|
||||
if fails > 0 then error(fails .. " check(s) failed") end
|
||||
U.log("all checks passed")
|
||||
end
|
||||
@@ -0,0 +1,74 @@
|
||||
-- Driver: party menu bottom context message (#147).
|
||||
-- Gen1 (pokered engine/menus/party_menu.asm PartyMenuMessage) always prints
|
||||
-- a message in the bottom text box: "Choose a POKéMON." (PartyMenuNormalText)
|
||||
-- in the field, "Bring out which POKéMON?" (PartyMenuBattleText) in battle.
|
||||
-- The recomp handled only the swap / item / TM-HM ids and printed NOTHING for
|
||||
-- the default field and battle voluntary-switch cases -- reporter's "NO TEXT
|
||||
-- BOX". This driver opens the party menu in both contexts, screenshots each,
|
||||
-- and asserts PartyMenu:bottomMessage() returns the correct Gen1 string.
|
||||
-- POKEPORT_DRIVER=tests/drivers/party_bug147_message_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug147 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Screens = require("src.ui.Screens")
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1; U.log("PASS", label)
|
||||
else fail = fail + 1; U.log("FAIL", label) end
|
||||
end
|
||||
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARMANDER", 12),
|
||||
Pokemon.new(game.data, "SQUIRTLE", 10),
|
||||
}
|
||||
|
||||
-- FIELD case: party menu opened from the overworld (StartMenu path).
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
local ow = game.overworld
|
||||
Screens.push(game, "PartyMenu")
|
||||
U.wait(8)
|
||||
U.shot(game, DIR .. "/party_field_message.png")
|
||||
local pm = game.stack:top()
|
||||
local fieldMsg = pm and pm.bottomMessage and pm:bottomMessage()
|
||||
U.log("field bottomMessage:", tostring(fieldMsg))
|
||||
check("field message == 'Choose a POKéMON.'",
|
||||
fieldMsg == "Choose a POKéMON.")
|
||||
|
||||
-- back to the overworld before starting the battle
|
||||
while game.stack:top() and game.stack:top() ~= ow do game.stack:pop() end
|
||||
U.wait(2)
|
||||
|
||||
-- BATTLE case: voluntary PKMN switch (BattleState:openParty).
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 8)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
|
||||
local function mashUntil(cond, max)
|
||||
for _ = 1, max or 80 do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
return false
|
||||
end
|
||||
check("reached battle menu", mashUntil(function()
|
||||
return battle.phase == "menu"
|
||||
end))
|
||||
|
||||
-- FIGHT/PKMN/ITEM/RUN: RIGHT to PKMN, then A to open the party
|
||||
U.tap(game, "right"); U.wait(4)
|
||||
U.tap(game, "a"); U.wait(12)
|
||||
U.shot(game, DIR .. "/party_battle_message.png")
|
||||
pm = game.stack:top()
|
||||
local battleMsg = pm and pm.bottomMessage and pm:bottomMessage()
|
||||
U.log("battle party open (onSwitch set):", pm and pm.onSwitch ~= nil)
|
||||
U.log("battle bottomMessage:", tostring(battleMsg))
|
||||
check("battle message == 'Bring out which\\nPOKéMON?'",
|
||||
battleMsg == "Bring out which\nPOKéMON?")
|
||||
|
||||
U.log(("RESULT pass=%d fail=%d"):format(pass, fail))
|
||||
end
|
||||
@@ -0,0 +1,74 @@
|
||||
-- Driver: the bedroom PC (Red's House 2F) must open the player's item PC
|
||||
-- directly (WITHDRAW/DEPOSIT/TOSS/LOG OFF), NOT the Pokemon Center multi-PC
|
||||
-- main menu (SOMEONE'S PC / <name>'s PC / LOG OFF). #228
|
||||
--
|
||||
-- pokered: the bedroom PC's hidden-object callback is OpenRedsPC
|
||||
-- (engine/events/hidden_objects/players_pc.asm) which runs the PlayerPC
|
||||
-- predef, versus the Pokemon Center PC callback which shows DisplayPCMainMenu.
|
||||
--
|
||||
-- SHOT_DIR=/tmp/bug228 POKEPORT_DRIVER=tests/drivers/pc_bug228_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug228 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Menu = require("src.ui.Menu")
|
||||
|
||||
local pass = true
|
||||
local function check(cond, msg)
|
||||
if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end
|
||||
end
|
||||
|
||||
-- Stand at (0,2) facing up so facingCell() = (0,1), the bedroom PC tile
|
||||
-- (field.lua hiddenExtras.pcTiles.REDS_HOUSE_2F = {{facing="up",x=0,y=1}}).
|
||||
U.teleport(game, "REDS_HOUSE_2F", 0, 2, "up")
|
||||
-- teleport bypasses New Game, which seeds pcItems={POTION=1}
|
||||
-- (src/core/SaveData.lua); seed it so the withdraw list has content.
|
||||
game.save.pcItems = game.save.pcItems or { POTION = 1 }
|
||||
|
||||
U.tap(game, "a") -- interact -> tryHiddenObject -> the bedroom PC
|
||||
U.wait(8)
|
||||
|
||||
local menu = game.stack:top()
|
||||
check(getmetatable(menu) == Menu, "a PC menu opened on A-press")
|
||||
U.shot(game, DIR .. "/pc_bug228_bedroom_menu.png")
|
||||
|
||||
-- Inspect labels: the multi-PC main menu carries SOMEONE'S/BILL'S/<name>'s
|
||||
-- PC entries; the player's item PC does not.
|
||||
local labels = {}
|
||||
if menu and menu.items then
|
||||
for _, it in ipairs(menu.items) do
|
||||
labels[#labels + 1] = tostring(it.label)
|
||||
end
|
||||
end
|
||||
U.log("labels: " .. table.concat(labels, " | "))
|
||||
|
||||
local function has(pat)
|
||||
for _, l in ipairs(labels) do
|
||||
if l:lower():find(pat, 1, true) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- BUG present if the multi-PC main menu opened.
|
||||
check(not has("someone"), "no SOMEONE'S PC entry (not the box-PC main menu)")
|
||||
check(not has("bill"), "no BILL'S PC entry")
|
||||
check(not has("prof.oak"),"no PROF.OAK's PC entry")
|
||||
check(not has("'s pc"), "no <name>'s PC entry (bedroom PC skips box storage)")
|
||||
|
||||
-- CORRECT: the player's item PC opened first-row WITHDRAW ITEM.
|
||||
check(has("withdraw item"), "player item PC opened (WITHDRAW ITEM present)")
|
||||
check(menu and menu.items and menu.items[1]
|
||||
and tostring(menu.items[1].label) == "WITHDRAW ITEM",
|
||||
"first row is WITHDRAW ITEM")
|
||||
|
||||
-- Visual proof: open the withdraw list to show the seeded POTION.
|
||||
if menu and menu.items and menu.items[1]
|
||||
and tostring(menu.items[1].label) == "WITHDRAW ITEM" then
|
||||
U.tap(game, "a") -- WITHDRAW ITEM
|
||||
U.wait(8)
|
||||
U.shot(game, DIR .. "/pc_bug228_withdraw.png")
|
||||
end
|
||||
|
||||
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
|
||||
love.event.quit(pass and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,127 @@
|
||||
-- Driver: #194 Celadon prize room must require the COIN CASE.
|
||||
-- engine/menus/prize_menu.asm CeladonPrizeMenu gates the prize window on the
|
||||
-- COIN CASE: IsItemInBag COIN_CASE first, and with no case it prints
|
||||
-- RequireCoinCaseText and returns without ever opening a window; only with the
|
||||
-- case does it print ExchangeCoinsForPrizesText and then show the prize list.
|
||||
-- The port used to open "PRIZES (COINS)" unconditionally with no intro line.
|
||||
--
|
||||
-- The three prize counters are bg-event signs at cells (2,2),(4,2),(6,2) in
|
||||
-- GAME_CORNER_PRIZE_ROOM (data/generated/maps.lua). Stand south of vendor 1
|
||||
-- and press A: no-case -> require box and NO list; has-case -> exchange box,
|
||||
-- then the prize list; cancel returns to the overworld (onCancel == done).
|
||||
--
|
||||
-- SHOT_DIR=/tmp/prize194 POKEPORT_IDENTITY=bug194 POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/prize_room_coincase_bug194_test.lua love .
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
os.execute("mkdir -p " .. DIR)
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
|
||||
-- a party so nothing else blocks overworld interaction
|
||||
game.save.party = { Pokemon.new(game.data, "BULBASAUR", 5) }
|
||||
game.save.inventory = game.save.inventory or {}
|
||||
game.save.coins = game.save.coins or 0
|
||||
|
||||
local function topMeta() return getmetatable(game.stack:top()) end
|
||||
-- let a TextBox finish typing its current page (self.waiting) so shots
|
||||
-- capture the full line instead of a mid-typewriter frame
|
||||
local function settleText(maxFrames)
|
||||
for _ = 1, maxFrames or 40 do
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == TextBox and top.waiting then break end
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
local function pageText()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return "" end
|
||||
local parts = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
if type(page) == "table" then
|
||||
for _, line in ipairs(page) do parts[#parts + 1] = tostring(line) end
|
||||
end
|
||||
end
|
||||
return table.concat(parts, " ")
|
||||
end
|
||||
|
||||
-- ===== CASE 1: NO COIN CASE -> require box, prize list NEVER opens =====
|
||||
game.save.inventory.COIN_CASE = nil
|
||||
-- stand at (2,3) facing up; the faced cell is vendor sign 1 at (2,2)
|
||||
U.teleport(game, "GAME_CORNER_PRIZE_ROOM", 2, 3, "up")
|
||||
local ow = game.overworld
|
||||
U.shot(game, DIR .. "/prize_room_0_before.png")
|
||||
|
||||
U.tap(game, "a")
|
||||
-- scan several frames: a ListMenu must NEVER appear without the case
|
||||
local sawListNoCase, sawRequire = false, false
|
||||
for _ = 1, 60 do
|
||||
if topMeta() == ListMenu then sawListNoCase = true end
|
||||
if topMeta() == TextBox and pageText():find("COIN CASE", 1, true) then
|
||||
sawRequire = true
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.log("no-case: sawRequire", sawRequire, "sawList", sawListNoCase)
|
||||
settleText(40)
|
||||
U.shot(game, DIR .. "/prize_room_1_nocase_requiretext.png")
|
||||
assert(sawRequire,
|
||||
"no-case: 'A COIN CASE is required!' box never shown (#194)")
|
||||
assert(not sawListNoCase,
|
||||
"no-case: prize ListMenu opened without a COIN CASE (#194)")
|
||||
|
||||
-- dismiss the require box; back to the overworld with no list ever opened
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 30 do
|
||||
if game.stack:top() == ow then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(1)
|
||||
end
|
||||
assert(game.stack:top() == ow, "no-case: did not return to overworld")
|
||||
|
||||
-- ===== CASE 2: HAS COIN CASE -> exchange box, then the prize list =====
|
||||
game.save.inventory.COIN_CASE = 1
|
||||
U.tap(game, "a") -- talk to the same vendor sign again
|
||||
local sawExchange = false
|
||||
for _ = 1, 60 do
|
||||
if topMeta() == TextBox and
|
||||
(pageText():find("exchange", 1, true) or
|
||||
pageText():find("coins for prizes", 1, true)) then
|
||||
sawExchange = true
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.log("has-case: sawExchange", sawExchange)
|
||||
settleText(140)
|
||||
U.shot(game, DIR .. "/prize_room_2_exchange.png")
|
||||
assert(sawExchange,
|
||||
"has-case: 'We exchange your coins for prizes.' never shown (#194)")
|
||||
|
||||
-- advance past the exchange line; the prize list must then open
|
||||
local sawList = false
|
||||
for _ = 1, 60 do
|
||||
if topMeta() == ListMenu then sawList = true break end
|
||||
U.tap(game, "a")
|
||||
U.wait(1)
|
||||
end
|
||||
U.log("has-case: sawList", sawList, "title",
|
||||
(sawList and game.stack:top().title) or "-")
|
||||
U.shot(game, DIR .. "/prize_room_3_menu.png")
|
||||
assert(sawList, "has-case: prize ListMenu never opened after exchange text")
|
||||
assert(game.stack:top().title == "PRIZES (COINS)",
|
||||
"has-case: opened list is not the prize window")
|
||||
|
||||
-- cancel returns to the overworld (onCancel == done)
|
||||
U.tap(game, "b")
|
||||
for _ = 1, 30 do
|
||||
if game.stack:top() == ow then break end
|
||||
U.wait(1)
|
||||
end
|
||||
assert(game.stack:top() == ow, "has-case: cancel did not return to overworld")
|
||||
U.log("prize_room_coincase_bug194_test: ok")
|
||||
end
|
||||
@@ -0,0 +1,115 @@
|
||||
-- Driver: Red's-house corner staircase warp (issue #230).
|
||||
--
|
||||
-- REDS_HOUSE_1F/2F share an 8x8 layout with the stairs warp on the top-right
|
||||
-- corner cell (7,1); the cell to its right is the map edge (widthCells-1==7),
|
||||
-- so Warp.extraCheck's facingEdge branch answers "yes" to a right-bonk. Two
|
||||
-- Gen1 invariants this driver pins:
|
||||
--
|
||||
-- 1. The warp cell you ARRIVE on is inert until you physically step off it
|
||||
-- (CheckWarpsNoCollision / the arrival-disable in the completed-step
|
||||
-- path). Holding right into the east wall while standing on (7,1) must
|
||||
-- NOT re-fire the collision warp -- pre-fix it ping-ponged 1F<->2F every
|
||||
-- input frame forever.
|
||||
-- 2. A genuine wall bonk still animates the walk cycle in place (the
|
||||
-- collision path runs UpdateSprites), so player:walkPhase() must reach 1
|
||||
-- during the bonk while the cell stays put.
|
||||
--
|
||||
-- The stairs must still warp normally once the player steps off (7,1) and
|
||||
-- back onto it, so the guard cannot break legitimate staircases.
|
||||
--
|
||||
-- Run:
|
||||
-- POKEPORT_DRIVER=tests/drivers/reds_house_stairs_bug230_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug230 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local shotDir = os.getenv("POKEPORT_SHOTDIR") or "."
|
||||
local function shot(name) U.shot(game, shotDir .. "/" .. name) end
|
||||
|
||||
local ow
|
||||
local fails = 0
|
||||
local function expect(cond, ...)
|
||||
if not cond then fails = fails + 1 end
|
||||
U.log(cond and "PASS" or "FAIL", ...)
|
||||
end
|
||||
local function settle(mapId)
|
||||
for _ = 1, 300 do
|
||||
ow = game.overworld
|
||||
if ow and ow.map.id == mapId and not ow.transitioning
|
||||
and #ow.scriptMoves == 0 and not ow.player.moving then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(4)
|
||||
ow = game.overworld
|
||||
end
|
||||
|
||||
-- 1) Arrive on the 2F stairs via a REAL warp so warpEntryCell/justWarped
|
||||
-- are set. Teleporting straight onto (7,1) bypasses takeWarp and would
|
||||
-- never set the arrival-inert state, so the bug could not reproduce --
|
||||
-- we must walk up onto the 1F stairs and let the warp carry us.
|
||||
U.teleport(game, "REDS_HOUSE_1F", 7, 3, "up")
|
||||
settle("REDS_HOUSE_1F")
|
||||
U.hold(game, "up", 40)
|
||||
settle("REDS_HOUSE_2F")
|
||||
expect(ow.map.id == "REDS_HOUSE_2F", "arrived upstairs, map:", ow.map.id)
|
||||
expect(ow.player.cellX == 7 and ow.player.cellY == 1,
|
||||
"standing on the stairs cell (7,1), got:",
|
||||
ow.player.cellX, ow.player.cellY)
|
||||
shot("reds230_arrive.png")
|
||||
|
||||
-- 2) Ping-pong guard (Fix 1) AND walk-in-place (Fix 2): hold right into the
|
||||
-- east wall for 150 frames. Record every distinct floor id visited and
|
||||
-- whether the sprite ever animates a walk frame.
|
||||
local floors, order, seenPhase1 = {}, {}, false
|
||||
do
|
||||
local last
|
||||
for _ = 1, 150 do
|
||||
table.insert(game.input.pressQueue, "right")
|
||||
game.input.state["right"] = true
|
||||
coroutine.yield() -- Game:update runs here, processing this frame's input
|
||||
local o = game.overworld
|
||||
if o then
|
||||
if o.map.id ~= last then table.insert(order, o.map.id); last = o.map.id end
|
||||
floors[o.map.id] = true
|
||||
if o.player:walkPhase() == 1 then seenPhase1 = true end
|
||||
end
|
||||
end
|
||||
game.input.state["right"] = false
|
||||
end
|
||||
settle("REDS_HOUSE_2F")
|
||||
shot("reds230_after_hold.png")
|
||||
|
||||
local distinct = 0
|
||||
for _ in pairs(floors) do distinct = distinct + 1 end
|
||||
expect(distinct == 1 and floors["REDS_HOUSE_2F"] == true,
|
||||
"no floor ping-pong during the hold; distinct floors:", distinct,
|
||||
"sequence:", table.concat(order, ">"))
|
||||
expect(ow.map.id == "REDS_HOUSE_2F", "still upstairs after the hold, map:",
|
||||
ow.map.id)
|
||||
expect(ow.player.cellX == 7 and ow.player.cellY == 1,
|
||||
"bonked in place, still at (7,1), got:",
|
||||
ow.player.cellX, ow.player.cellY)
|
||||
expect(not ow.player.moving and not ow.transitioning,
|
||||
"settled after the hold, not mid-move/transition")
|
||||
expect(seenPhase1,
|
||||
"walk-in-place: player:walkPhase() reached 1 during the bonk")
|
||||
|
||||
-- 3) Anti-over-fix: the guard clears the instant the player steps off the
|
||||
-- warp cell, so stepping south (off (7,1)) then back north still takes
|
||||
-- the stairs. The exact southern cell does not matter -- only that we
|
||||
-- leave (7,1) and that re-entering it still fires the warp.
|
||||
U.hold(game, "down", 20)
|
||||
settle("REDS_HOUSE_2F")
|
||||
expect(ow.player.cellX == 7 and ow.player.cellY >= 2,
|
||||
"stepped south off the stairs cell, got:",
|
||||
ow.player.cellX, ow.player.cellY)
|
||||
U.hold(game, "up", 40)
|
||||
settle("REDS_HOUSE_1F")
|
||||
expect(ow.map.id == "REDS_HOUSE_1F",
|
||||
"stairs still warp after stepping off and back on, map:", ow.map.id)
|
||||
|
||||
if fails > 0 then error(fails .. " check(s) failed") end
|
||||
U.log("all checks passed")
|
||||
end
|
||||
@@ -0,0 +1,109 @@
|
||||
-- Driver: Rocket Hideout elevator gates (#199).
|
||||
--
|
||||
-- Gen1 stamps a closed barred gate over the elevator doorway on every map
|
||||
-- load, opening it only once the guarding Rockets are beaten:
|
||||
-- scripts/RocketHideoutB1F.asm RocketHideoutB1FDoorCallbackScript
|
||||
-- closed block $54 at (12,8), open block $0e; opens once
|
||||
-- EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4 is set (the guard by the lift;
|
||||
-- EVENT_ENTERED_ROCKET_HIDEOUT is never SetEvent -- the SFX bug noted
|
||||
-- in the source -- so it is not part of the effective open condition).
|
||||
-- scripts/RocketHideoutB4F.asm RocketHideoutB4FDoorCallbackScript
|
||||
-- closed block $2d at (12,5), open block $0e; opens once BOTH
|
||||
-- EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0 and _1 are set.
|
||||
-- B2F/B3F call no door callback (spinner-tile floors), so they get no gate.
|
||||
--
|
||||
-- The recomp shipped no stamping, so the .blk's open floor block ($0e/14)
|
||||
-- showed through -- the reporter's "gate is open" screenshots. Pre-fix the
|
||||
-- closed-gate asserts read 14 and fail; post-fix they read the barred block.
|
||||
--
|
||||
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/rocket_hideout_gate_bug199_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug199 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local OW = require("src.world.OverworldController")
|
||||
|
||||
local failures = {}
|
||||
local function check(cond, msg)
|
||||
if cond then U.log("ok:", msg) else
|
||||
table.insert(failures, msg); U.log("FAIL:", msg)
|
||||
end
|
||||
return cond
|
||||
end
|
||||
|
||||
-- swap map, wiping event flags to a known slate, then settle until the
|
||||
-- map has loaded and the player is idle so the door callback has stamped
|
||||
local function load(mapId, x, y, flags)
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
game.save.flags = {}
|
||||
for k, v in pairs(flags or {}) do game.save.flags[k] = v end
|
||||
game.stack:push(OW, mapId, x, y, "down")
|
||||
for _ = 1, 240 do
|
||||
local ow = game.overworld
|
||||
if ow and ow.map and ow.map.id == mapId and not ow.transitioning
|
||||
and not (ow.player and ow.player.moving) then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(3)
|
||||
return game.overworld
|
||||
end
|
||||
|
||||
local OPEN = 14 -- $0e floor block (doorway when unlocked)
|
||||
local CLOSED_B1F = 84 -- $54 barred gate (bars in the north cell-row)
|
||||
local CLOSED_B4F = 45 -- $2d barred gate (bars in the south cell-row)
|
||||
|
||||
-- B1F: barred until the lift guard (trainer 4) is beaten
|
||||
local ow = load("ROCKET_HIDEOUT_B1F", 24, 14)
|
||||
U.shot(game, DIR .. "/b1f_gate_closed.png")
|
||||
check(ow.map:blockAt(12, 8) == CLOSED_B1F,
|
||||
"B1F doorway (12,8) barred, got " .. tostring(ow.map:blockAt(12, 8)))
|
||||
check(ow.map:blockAt(12, 9) == 44,
|
||||
"B1F warp carpet (12,9) untouched, got " .. tostring(ow.map:blockAt(12, 9)))
|
||||
|
||||
-- B1F: opens once the guard is beaten
|
||||
ow = load("ROCKET_HIDEOUT_B1F", 24, 14,
|
||||
{ EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4 = true })
|
||||
U.shot(game, DIR .. "/b1f_gate_open.png")
|
||||
check(ow.map:blockAt(12, 8) == OPEN,
|
||||
"B1F doorway opens after guard, got " .. tostring(ow.map:blockAt(12, 8)))
|
||||
|
||||
-- B4F: barred until BOTH guards are beaten
|
||||
ow = load("ROCKET_HIDEOUT_B4F", 24, 8)
|
||||
U.shot(game, DIR .. "/b4f_gate_closed.png")
|
||||
check(ow.map:blockAt(12, 5) == CLOSED_B4F,
|
||||
"B4F doorway (12,5) barred, got " .. tostring(ow.map:blockAt(12, 5)))
|
||||
|
||||
-- B4F: one guard is not enough
|
||||
ow = load("ROCKET_HIDEOUT_B4F", 24, 8,
|
||||
{ EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0 = true })
|
||||
check(ow.map:blockAt(12, 5) == CLOSED_B4F,
|
||||
"B4F stays barred with only one guard, got " .. tostring(ow.map:blockAt(12, 5)))
|
||||
|
||||
-- B4F: opens once both guards are beaten
|
||||
ow = load("ROCKET_HIDEOUT_B4F", 24, 8,
|
||||
{ EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0 = true,
|
||||
EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1 = true })
|
||||
U.shot(game, DIR .. "/b4f_gate_open.png")
|
||||
check(ow.map:blockAt(12, 5) == OPEN,
|
||||
"B4F doorway opens after both guards, got " .. tostring(ow.map:blockAt(12, 5)))
|
||||
|
||||
-- B2F has no dynamic gate in pokered (spinner floor); it must still load
|
||||
ow = load("ROCKET_HIDEOUT_B2F", 24, 14)
|
||||
check(ow and ow.map and ow.map.id == "ROCKET_HIDEOUT_B2F",
|
||||
"B2F loads with no gate callback")
|
||||
|
||||
-- regression: the shared Silph Co card-key path must still stamp
|
||||
ow = load("SILPH_CO_2F", 4, 4)
|
||||
check(ow.map:blockAt(2, 2) == 84,
|
||||
"SILPH_CO_2F door1 (2,2) still barred, got " .. tostring(ow.map:blockAt(2, 2)))
|
||||
ow = load("SILPH_CO_2F", 4, 4, { EVENT_SILPH_CO_2_UNLOCKED_DOOR1 = true })
|
||||
check(ow.map:blockAt(2, 2) == 14,
|
||||
"SILPH_CO_2F door1 opens with its event, got " .. tostring(ow.map:blockAt(2, 2)))
|
||||
|
||||
if #failures > 0 then
|
||||
error(#failures .. " check(s) failed: " .. table.concat(failures, " | "))
|
||||
end
|
||||
U.log("all Rocket Hideout gate checks passed")
|
||||
end
|
||||
+1286
-133
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
-- #223 exhaustive DOWN-hop sweep for ROUTE_4 (evidence driver).
|
||||
--
|
||||
-- The reporter's build (v0.1.25) and HEAD share byte-identical ledge code
|
||||
-- (checkLedgeHop) and ledge data (tools/rom_manifest.json ->
|
||||
-- data/generated/field.lua, the 8 rows of data/tilesets/ledge_tiles.asm), so
|
||||
-- this reproduces exactly what the reporter would see.
|
||||
--
|
||||
-- For EVERY ROUTE_4 cell whose tile-at-feet is a south-ledge STANDING tile
|
||||
-- (44/57, i.e. pokered $2C/$39) with a south-ledge tile (54/55, $36/$37)
|
||||
-- directly below, teleport the player onto it facing DOWN, hold DOWN, and
|
||||
-- assert the Gen1 hop fires (p.hopFrames>0) and lands two cells south.
|
||||
--
|
||||
-- Finding: all 139 reachable/functional south ledges hop. The only two that
|
||||
-- do not -- (12,16) and (13,16) -- sit on the SOUTH boundary of the Mt Moon
|
||||
-- Poke Center plaza, where the cell two south is off the map onto the border
|
||||
-- mountain (tile 17); there is no landing, so the hop is correctly refused
|
||||
-- (checkLedgeHop's landing-walkable gate). These are NOT the reporter's spot
|
||||
-- (an open EAST plateau, cells ~62-80) and refusing a hop into the map border
|
||||
-- is correct, so they are treated as EXPECTED refusals here.
|
||||
--
|
||||
-- Run:
|
||||
-- POKEPORT_DRIVER=tests/drivers/route4_downsweep_bug223.lua \
|
||||
-- POKEPORT_IDENTITY=bug223 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
if #game.save.party == 0 then
|
||||
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5))
|
||||
end
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.zoom = -2
|
||||
|
||||
-- same tile read as Map:cellTile: bottom-left 8x8 of the 2x2-cell block
|
||||
local def = game.data.maps.ROUTE_4
|
||||
local ts = game.data.tilesets[def.tileset]
|
||||
local function cellTile(cx, cy)
|
||||
local tx, ty = cx * 2, cy * 2 + 1
|
||||
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
|
||||
local id
|
||||
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then id = def.borderBlock
|
||||
else id = def.blocks[by * def.width + bx + 1] end
|
||||
local block = ts.blocks[(id or 0) + 1]
|
||||
return block and block[(ty % 4) * 4 + (tx % 4) + 1] or nil
|
||||
end
|
||||
local W, H = def.width * 2, def.height * 2
|
||||
|
||||
local function holdDown(x, y, frames)
|
||||
U.teleport(game, "ROUTE_4", x, y, "down")
|
||||
require("src.render.Zoom").applyOptions(game.save.options)
|
||||
U.wait(6)
|
||||
local p = game.overworld.player
|
||||
local hop = false
|
||||
for _ = 1, frames do
|
||||
table.insert(game.input.pressQueue, "down")
|
||||
game.input.state.down = true
|
||||
coroutine.yield()
|
||||
if (p.hopFrames or 0) > 0 then hop = true end
|
||||
end
|
||||
game.input.state.down = false
|
||||
U.wait(4)
|
||||
return p.cellX, p.cellY, hop
|
||||
end
|
||||
|
||||
-- (12,16)/(13,16): south ledges on the plaza's map-border edge; the cell two
|
||||
-- south is off-map (border mountain), so the refusal is correct, not a bug.
|
||||
local expectedRefusal = { ["12,16"] = true, ["13,16"] = true }
|
||||
|
||||
local standers = {}
|
||||
for cy = 0, H - 2 do
|
||||
for cx = 0, W - 1 do
|
||||
local s = cellTile(cx, cy)
|
||||
local f = cellTile(cx, cy + 1)
|
||||
if (s == 44 or s == 57) and (f == 54 or f == 55) then
|
||||
standers[#standers + 1] = { cx, cy }
|
||||
end
|
||||
end
|
||||
end
|
||||
U.log(("#223 sweep: %d south-ledge standing cells in ROUTE_4"):format(#standers))
|
||||
|
||||
local fails, refusals = 0, 0
|
||||
for _, c in ipairs(standers) do
|
||||
local cx, cy = c[1], c[2]
|
||||
-- 2-cell hop is 32 frames (16/cell); budget past it so cellY settles
|
||||
local ex, ey, hop = holdDown(cx, cy, 44)
|
||||
local ok = hop and ex == cx and ey == cy + 2
|
||||
if not ok then
|
||||
if expectedRefusal[cx .. "," .. cy] then
|
||||
refusals = refusals + 1
|
||||
U.log((" refused (expected, map-border edge) (%d,%d) -> (%d,%d) hop=%s")
|
||||
:format(cx, cy, ex, ey, tostring(hop)))
|
||||
else
|
||||
fails = fails + 1
|
||||
U.log((" FAIL (%d,%d) -> (%d,%d) hop=%s"):format(cx, cy, ex, ey, tostring(hop)))
|
||||
end
|
||||
end
|
||||
end
|
||||
U.log(("#223 sweep DONE: %d hopped, %d expected border-refusals, %d unexpected FAILS")
|
||||
:format(#standers - fails - refusals, refusals, fails))
|
||||
if fails > 0 then error(fails .. " unexpected south-ledge DOWN-hop failure(s)") end
|
||||
end
|
||||
@@ -0,0 +1,168 @@
|
||||
-- Driver: Route 4 ledges (issue #223).
|
||||
--
|
||||
-- #223 reports "can't jump down the lower-right cliff outside Mt. Moon". The
|
||||
-- reporter (build v0.1.25, RED++/OG-RED brown palette) is standing on the open
|
||||
-- EAST plateau of ROUTE_4 (the tile-57 ground with the "01/10" texture, cells
|
||||
-- ~72-80, rows 3-6) near its SE corner. The owner clarified the complaint is
|
||||
-- about pressing DOWN (a south-facing ledge), not the vertical side ledges the
|
||||
-- first triage looked at.
|
||||
--
|
||||
-- Finding after an exhaustive DOWN-hop sweep of every ROUTE_4 south-ledge cell
|
||||
-- (see tests/drivers/route4_downsweep_bug223.lua): every functional south
|
||||
-- ledge hops. The "cliff" the reporter pressed DOWN on at the SE corner is a
|
||||
-- solid mountain-wall FACE -- OVERWORLD tile 58, a 5-cell-tall vertical cliff
|
||||
-- (ROUTE_4 cell 80, rows 5-9) -- which is NOT a ledge tile. Gen1 only hops the
|
||||
-- three straight ledge families in data/tilesets/ledge_tiles.asm (54/55 face
|
||||
-- DOWN, 39 faces LEFT, 13/29 face RIGHT); a tall cliff face and the diagonal
|
||||
-- corner tiles are not hoppable in any direction (engine/overworld/ledges.asm
|
||||
-- HandleLedges keys the hop on wPlayerFacingDirection + wTilePlayerStandingOn +
|
||||
-- wTileInFrontOfPlayer + hJoyHeld, all four of which must match a LedgeTiles
|
||||
-- row). The ledge code and data are byte-identical between v0.1.25 and HEAD,
|
||||
-- so this is the same behavior the reporter saw: correct, matching Gen1.
|
||||
--
|
||||
-- Cases (all assert the CORRECT Gen1 behavior, so this passes on a good build
|
||||
-- and would fail on any future ledge regression):
|
||||
-- A a plain south ledge hops with DOWN (system works)
|
||||
-- B the cx45 right-facing side ledge hops with RIGHT (correct input)
|
||||
-- C the cx50 left-facing side ledge hops with LEFT
|
||||
-- D DOWN along the plaza terraces descends by hopping south ledges
|
||||
-- E DOWN into a solid cliff face (tile 58) correctly bonks
|
||||
-- F the reporter's EAST plateau south ledges hop with DOWN (rows 4 and 6)
|
||||
-- G the reporter's SE-corner cliff FACE (tile 58, cell 80) does NOT hop --
|
||||
-- this is the "cliff outside Mt Moon" the report was about, working as
|
||||
-- Gen1 does (a mountain wall is not a ledge)
|
||||
--
|
||||
-- Run:
|
||||
-- POKEPORT_DRIVER=tests/drivers/route4_ledge_bug223_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug223 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local shotDir = os.getenv("POKEPORT_SHOTDIR") or "."
|
||||
local function shot(name) U.shot(game, shotDir .. "/" .. name) end
|
||||
|
||||
-- a party + starter flag so the overworld is fully usable
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
if #game.save.party == 0 then
|
||||
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5))
|
||||
end
|
||||
-- survey zoom-out + the reporter's RED++ palette so the shots frame the whole
|
||||
-- cliff like the report
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.zoom = -2
|
||||
game.save.options.colors = "redpp"
|
||||
require("src.render.PaletteFX").setMode("redpp")
|
||||
|
||||
local fails = 0
|
||||
local function expect(cond, ...)
|
||||
if not cond then fails = fails + 1 end
|
||||
U.log(cond and "PASS" or "FAIL", ...)
|
||||
end
|
||||
|
||||
-- hold a direction for n frames, returning end cell + whether a hop arc was
|
||||
-- ever active (p.hopFrames>0 is set only by checkLedgeHop's jump arc)
|
||||
local function holdDir(x, y, facing, dir, frames)
|
||||
U.teleport(game, "ROUTE_4", x, y, facing)
|
||||
require("src.render.Zoom").applyOptions(game.save.options)
|
||||
U.wait(6)
|
||||
local p = game.overworld.player
|
||||
local hopSeen = false
|
||||
for _ = 1, frames do
|
||||
table.insert(game.input.pressQueue, dir)
|
||||
game.input.state[dir] = true
|
||||
coroutine.yield()
|
||||
if (p.hopFrames or 0) > 0 then hopSeen = true end
|
||||
end
|
||||
game.input.state[dir] = false
|
||||
U.wait(4)
|
||||
return p.cellX, p.cellY, hopSeen
|
||||
end
|
||||
|
||||
-- A) control: a south-facing ledge must hop with DOWN. Standing at (40,8),
|
||||
-- the cell in front (40,9) is a tile-55 south ledge; hop lands on (40,10).
|
||||
do
|
||||
U.teleport(game, "ROUTE_4", 40, 8, "down")
|
||||
require("src.render.Zoom").applyOptions(game.save.options)
|
||||
U.wait(6); shot("route4_south_ledge_before.png")
|
||||
local x, y, hop = holdDir(40, 8, "down", "down", 60)
|
||||
shot("route4_south_ledge_after.png")
|
||||
expect(hop, "A: DOWN hops the south ledge (hop arc seen)")
|
||||
expect(x == 40 and y >= 10, "A: landed south of the ledge, got:", x, y)
|
||||
end
|
||||
|
||||
-- B) a RIGHT-facing side ledge (tile 29) at cx45. DOWN never crosses it;
|
||||
-- RIGHT does. From (44,6) a RIGHT hop clears cx45 and lands on (46,6).
|
||||
do
|
||||
U.teleport(game, "ROUTE_4", 44, 6, "right")
|
||||
require("src.render.Zoom").applyOptions(game.save.options)
|
||||
U.wait(6); shot("route4_side_ledge_right_before.png")
|
||||
local x, y, hop = holdDir(44, 6, "right", "right", 40)
|
||||
shot("route4_side_ledge_right_after.png")
|
||||
expect(hop, "B: RIGHT hops the cx45 side ledge (hop arc seen)")
|
||||
expect(x == 46 and y == 6, "B: landed east of the side ledge, got:", x, y)
|
||||
end
|
||||
|
||||
-- C) the mirror side ledge: cx50 (tile 39) faces LEFT; from (51,5) a LEFT hop
|
||||
-- clears cx50 and lands on (49,5).
|
||||
do
|
||||
local x, y, hop = holdDir(51, 5, "left", "left", 40)
|
||||
expect(hop, "C: LEFT hops the cx50 side ledge (hop arc seen)")
|
||||
expect(x == 49 and y == 5, "C: landed west of the side ledge, got:", x, y)
|
||||
end
|
||||
|
||||
-- D) DOWN down the plaza terraces DOES descend by hopping south ledges: from
|
||||
-- the plaza top (44,5), holding DOWN walks to a south edge and hops the
|
||||
-- terraces; held long enough the player ends well south of the start.
|
||||
do
|
||||
U.teleport(game, "ROUTE_4", 44, 5, "down")
|
||||
require("src.render.Zoom").applyOptions(game.save.options)
|
||||
U.wait(6); shot("route4_terrace_down_before.png")
|
||||
local x, y, hop = holdDir(44, 5, "down", "down", 150)
|
||||
shot("route4_terrace_down_after.png")
|
||||
expect(hop, "D: DOWN down the plaza terraces hops a south ledge")
|
||||
expect(y >= 12, "D: descended the terraces, got:", x, y)
|
||||
end
|
||||
|
||||
-- E) a solid cliff face is NOT a ledge: from (80,4) the cell below (80,5) is a
|
||||
-- tile-58 cliff wall. DOWN bonks in place with no hop -- matching Gen1, DOWN
|
||||
-- only hops south-facing ledge tiles (54/55), not cliff faces.
|
||||
do
|
||||
local x, y, hop = holdDir(80, 4, "down", "down", 30)
|
||||
expect(not hop, "E: DOWN into a solid cliff face does not hop")
|
||||
expect(x == 80 and y == 4, "E: bonked in place at the cliff face, got:", x, y)
|
||||
end
|
||||
|
||||
-- F) the reporter's EAST plateau (the "cliff outside Mt Moon"): the tile-57
|
||||
-- ground at (70,4) and (75,4) sits above the row-5 south ledge (tiles 54/55).
|
||||
-- DOWN hops each two cells south -- this is exactly the "jump down to the
|
||||
-- bottom half of the cliff" the report expected, and it works.
|
||||
do
|
||||
U.teleport(game, "ROUTE_4", 70, 4, "down")
|
||||
require("src.render.Zoom").applyOptions(game.save.options)
|
||||
U.wait(8); shot("route4_east_plateau_before.png")
|
||||
local x1, y1, h1 = holdDir(70, 4, "down", "down", 44)
|
||||
shot("route4_east_plateau_after.png")
|
||||
expect(h1 and x1 == 70 and y1 == 6, "F: (70,4) DOWN hops to (70,6), got:", x1, y1)
|
||||
local x2, y2, h2 = holdDir(75, 4, "down", "down", 44)
|
||||
expect(h2 and x2 == 75 and y2 == 6, "F: (75,4) DOWN hops to (75,6), got:", x2, y2)
|
||||
end
|
||||
|
||||
-- G) the reporter's SE-corner "cliff": (80,4) is tile-57 ground whose south
|
||||
-- neighbour is the tile-58 mountain-wall face (cell 80, rows 5-9). DOWN must
|
||||
-- NOT hop -- a tall cliff face is not a ledge, matching Gen1. This is the
|
||||
-- cliff the report was about; refusing the hop here is correct.
|
||||
do
|
||||
U.teleport(game, "ROUTE_4", 78, 3, "down")
|
||||
require("src.render.Zoom").applyOptions(game.save.options)
|
||||
U.wait(8); shot("route4_se_corner_cliff.png")
|
||||
local x, y, hop = holdDir(80, 4, "down", "down", 30)
|
||||
expect(not hop and x == 80 and y == 4,
|
||||
"G: SE-corner cliff face does not hop (Gen1-correct), got:", x, y, hop)
|
||||
end
|
||||
|
||||
if fails > 0 then error(fails .. " check(s) failed") end
|
||||
U.log("all checks passed -- #223: every functional ROUTE_4 south ledge hops "
|
||||
.. "with DOWN; the reporter's SE 'cliff' is a solid mountain-wall face "
|
||||
.. "(tile 58), correctly not hoppable, matching Gen1")
|
||||
end
|
||||
@@ -0,0 +1,206 @@
|
||||
-- Driver: #221 Saffron gate guards use the WRONG dialogue.
|
||||
--
|
||||
-- The reporter (build 0.1.25) saw the guard ACCEPT a drink he never had --
|
||||
-- "...Huh? I can have this drink? Gee, thanks!" (_SaffronGateGuardImParchedText,
|
||||
-- data/generated/text.lua:2050) -- while walking up to the gate carrying NO
|
||||
-- drink. Gen1 (scripts/Route5Gate.asm Route5GateDefaultScript) instead turns
|
||||
-- a drink-less player back with the thirsty line
|
||||
-- (_SaffronGateGuardGeeImThirstyText, text.lua:2049: "I'm on guard duty. / Gee,
|
||||
-- I'm thirsty, though! ... the road's closed."), and only ACCEPTS a drink (via
|
||||
-- `farcall RemoveGuardDrink`, engine/items/inventory.asm) when one is in the bag.
|
||||
--
|
||||
-- The guard object sits at cell (1,3), walled into an isolated 1x3 booth
|
||||
-- (pokered Route5Gate object_event 1,3 SPRITE_GUARD STAY RIGHT), so he is
|
||||
-- unreachable on foot: the block/dialogue is driven entirely by the onStep
|
||||
-- coordinate trigger on the corridor cells (3,3)/(4,3), not the TALK handler.
|
||||
-- This driver walks the player north through that trigger and asserts:
|
||||
--
|
||||
-- CASE A (no drink): the box is the THIRSTY line, NOT the accept line, and
|
||||
-- the player is shoved back one tile and stays inside ROUTE_5_GATE.
|
||||
-- CASE B (FRESH_WATER in bag): the box is the ACCEPT line, the drink is
|
||||
-- consumed, EVENT_GAVE_GUARDS_DRINK is set, and the player walks on
|
||||
-- through (map leaves ROUTE_5_GATE).
|
||||
--
|
||||
-- On the 0.1.25 bug CASE A would type the accept line and fail; current HEAD
|
||||
-- (fixed by #201) passes. Run with:
|
||||
--
|
||||
-- SHOT_DIR=/tmp/saffron221 POKEPORT_IDENTITY=bug221 POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/saffron_gate_bug221_test.lua love .
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
os.execute("mkdir -p " .. DIR)
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
game.save.player = game.save.player or {}
|
||||
game.save.player.name = game.save.player.name or "RED"
|
||||
game.save.party = { Pokemon.new(game.data, "BULBASAUR", 10) }
|
||||
game.save.flags = game.save.flags or {}
|
||||
|
||||
local function topText()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return "" end
|
||||
local parts = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
if type(page) == "table" then
|
||||
for _, line in ipairs(page) do parts[#parts + 1] = tostring(line) end
|
||||
end
|
||||
end
|
||||
return table.concat(parts, " ")
|
||||
end
|
||||
|
||||
local function isBox()
|
||||
return getmetatable(game.stack:top()) == TextBox
|
||||
end
|
||||
|
||||
-- let the typewriter finish typing up to its first pause (a <CONT> scroll
|
||||
-- or page break sets self.waiting), so a screenshot shows the guard's
|
||||
-- words instead of an empty just-opened box.
|
||||
local function waitTyped(maxFrames)
|
||||
for _ = 1, (maxFrames or 240) do
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == TextBox and (top.waiting or top.done) then return end
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
|
||||
-- walk the player north (holding "up") until a dialogue box pops or we
|
||||
-- give up; returns true if a box appeared. Only presses while the
|
||||
-- overworld is on top and the player is not already mid-step, so a step
|
||||
-- that lands on the trigger cleanly hands control to the pushed TextBox.
|
||||
local function walkNorthUntilBox(ow, maxFrames)
|
||||
for _ = 1, maxFrames do
|
||||
if isBox() then return true end
|
||||
if game.stack:top() == ow and not ow.player.moving
|
||||
and not ow.runner:isRunning() and #ow.scriptMoves == 0
|
||||
and not ow.transitioning then
|
||||
table.insert(game.input.pressQueue, "up")
|
||||
game.input.state.up = true
|
||||
end
|
||||
U.wait(1)
|
||||
game.input.state.up = false
|
||||
end
|
||||
return isBox()
|
||||
end
|
||||
|
||||
local function settle(ow, maxFrames)
|
||||
for _ = 1, (maxFrames or 200) do
|
||||
if game.stack:top() == ow and not ow.player.moving
|
||||
and not ow.runner:isRunning() and #ow.scriptMoves == 0
|
||||
and not ow.transitioning then
|
||||
return
|
||||
end
|
||||
if game.stack:top() ~= ow then U.tap(game, "a") end
|
||||
U.wait(2)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- CASE A: no drink -> thirsty line + shove back, gate stays shut
|
||||
-- ---------------------------------------------------------------
|
||||
game.save.inventory = {}
|
||||
game.save.flags.EVENT_GAVE_GUARDS_DRINK = nil
|
||||
|
||||
U.teleport(game, "ROUTE_5_GATE", 3, 5, "up")
|
||||
local ow = game.overworld
|
||||
assert(ow and ow.map.id == "ROUTE_5_GATE",
|
||||
"CASE A: teleport did not land inside ROUTE_5_GATE (got "
|
||||
.. tostring(ow and ow.map.id) .. ")")
|
||||
assert(ow.player.cellX == 3 and ow.player.cellY == 5,
|
||||
"CASE A: player not at spawn (3,5); got "
|
||||
.. ow.player.cellX .. "," .. ow.player.cellY)
|
||||
U.log("CASE A start:", ow.map.id, ow.player.cellX, ow.player.cellY)
|
||||
|
||||
local gotBoxA = walkNorthUntilBox(ow, 400)
|
||||
waitTyped(240)
|
||||
U.shot(game, DIR .. "/saffron_bug221_nodrink.png")
|
||||
local textA = topText()
|
||||
U.log("CASE A box:", gotBoxA, "text:", textA)
|
||||
assert(gotBoxA, "CASE A: no dialogue box appeared at the gate trigger")
|
||||
|
||||
-- CORRECT Gen1: the drink-less thirsty line, NOT the accept/parched line.
|
||||
assert(textA:find("thirsty", 1, true),
|
||||
"CASE A: box is not the thirsty line (got: " .. textA .. ")")
|
||||
assert(not textA:find("this drink", 1, true)
|
||||
and not textA:find("Gee, thanks", 1, true),
|
||||
"CASE A(#221): drink-less guard spoke the ACCEPT line (got: " .. textA .. ")")
|
||||
|
||||
-- mash A through the (multi-page) thirsty box; the onDone shoves the
|
||||
-- player back one tile. He must NOT pass -- still inside ROUTE_5_GATE.
|
||||
settle(ow, 300)
|
||||
U.log("CASE A after:", ow.map.id, ow.player.cellX, ow.player.cellY)
|
||||
assert(ow.map.id == "ROUTE_5_GATE",
|
||||
"CASE A: drink-less player passed the gate (map=" .. ow.map.id .. ")")
|
||||
assert(ow.player.cellY >= 4,
|
||||
"CASE A: player was not turned back (cellY=" .. ow.player.cellY .. ")")
|
||||
assert(not game.save.flags.EVENT_GAVE_GUARDS_DRINK,
|
||||
"CASE A: gave-drink flag set without a drink")
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- CASE B: FRESH_WATER in bag -> accept line, drink taken, pass through
|
||||
-- ---------------------------------------------------------------
|
||||
game.save.inventory = { FRESH_WATER = 1 }
|
||||
game.save.flags.EVENT_GAVE_GUARDS_DRINK = nil
|
||||
|
||||
U.teleport(game, "ROUTE_5_GATE", 3, 5, "up")
|
||||
ow = game.overworld
|
||||
assert(ow.map.id == "ROUTE_5_GATE", "CASE B: re-teleport failed")
|
||||
U.log("CASE B start:", ow.map.id, ow.player.cellX, ow.player.cellY)
|
||||
|
||||
local gotBoxB = walkNorthUntilBox(ow, 400)
|
||||
waitTyped(240)
|
||||
U.shot(game, DIR .. "/saffron_bug221_drink.png")
|
||||
local textB = topText()
|
||||
U.log("CASE B box:", gotBoxB, "text:", textB)
|
||||
assert(gotBoxB, "CASE B: no dialogue box appeared at the gate trigger")
|
||||
|
||||
-- CORRECT Gen1: carrying a drink triggers the parched/accept line.
|
||||
assert(textB:find("this drink", 1, true) or textB:find("Gee, thanks", 1, true),
|
||||
"CASE B: guard did not speak the accept line (got: " .. textB .. ")")
|
||||
|
||||
-- clear the accept + "you can go on through" boxes, then keep walking
|
||||
-- north out the top of the gate.
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() ~= ow then
|
||||
U.tap(game, "a")
|
||||
else
|
||||
break
|
||||
end
|
||||
U.wait(2)
|
||||
end
|
||||
U.log("CASE B post-accept:", "FRESH_WATER=",
|
||||
tostring(game.save.inventory.FRESH_WATER),
|
||||
"flag=", tostring(game.save.flags.EVENT_GAVE_GUARDS_DRINK))
|
||||
assert(game.save.inventory.FRESH_WATER == nil,
|
||||
"CASE B: the drink was not consumed")
|
||||
assert(game.save.flags.EVENT_GAVE_GUARDS_DRINK == true,
|
||||
"CASE B: EVENT_GAVE_GUARDS_DRINK not set after accepting the drink")
|
||||
|
||||
-- with the flag now set the corridor is free; walk on out the north side
|
||||
-- and STOP the instant the gate map is left (don't wander into the town's
|
||||
-- scripts -- the north warp resolves to the heal point when we teleported
|
||||
-- in with no remembered outdoor side, OverworldController:takeWarp).
|
||||
for _ = 1, 300 do
|
||||
ow = game.overworld
|
||||
if ow.map.id ~= "ROUTE_5_GATE" then break end
|
||||
if game.stack:top() == ow and not ow.player.moving
|
||||
and not ow.runner:isRunning() and #ow.scriptMoves == 0
|
||||
and not ow.transitioning then
|
||||
table.insert(game.input.pressQueue, "up")
|
||||
game.input.state.up = true
|
||||
end
|
||||
U.wait(1)
|
||||
game.input.state.up = false
|
||||
end
|
||||
ow = game.overworld
|
||||
U.shot(game, DIR .. "/saffron_bug221_passed.png")
|
||||
U.log("CASE B end:", ow.map.id, ow.player.cellX, ow.player.cellY)
|
||||
assert(ow.map.id ~= "ROUTE_5_GATE",
|
||||
"CASE B: player with a drink never passed the gate")
|
||||
|
||||
U.log("saffron_gate_bug221_test: ok")
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,124 @@
|
||||
-- Driver: Seafoam Islands B3F strong-current plug rocks (issue #212).
|
||||
--
|
||||
-- The two boulders the player pushes down through the B2F holes land on B3F
|
||||
-- at cells (18,6) and (19,6) and plug the strong current (the reporter's
|
||||
-- expected.png shows two round rocks sitting in the channel). The B2F
|
||||
-- pluggedByHolesOn holes were wired to showObject TOGGLE_..._B3F_BOULDER_3/_4,
|
||||
-- which OverworldState:toggleToObjectName resolves to SEAFOAMISLANDSB3F_
|
||||
-- BOULDER3/4 -- the ALREADY-VISIBLE pushable boulders at (8,14)/(9,14), not the
|
||||
-- hidden landing rocks. The real landing objects at (18,6)/(19,6) are the
|
||||
-- hidden BOULDER5/6, so the plug rocks never appeared (data/generated/field.lua
|
||||
-- + tools/rom_manifest*.json now point showObject at BOULDER_5/_6).
|
||||
--
|
||||
-- Case A asserts the CORRECT Gen1 outcome (rocks appear after plugging), so it
|
||||
-- FAILS on the bug and PASSES once the toggles are repointed.
|
||||
--
|
||||
-- Case B is a regression guard: with only ONE rock the current stays active
|
||||
-- and gates the sole water chokepoint (the 2-wide gap at (18,7)/(19,7) is the
|
||||
-- only water passage between the south and north pools -- verified from the
|
||||
-- B3F water map), so a surfing player stepping up into it is swept south
|
||||
-- (SeafoamIslandsB3F.asm) and cannot cross north. This confirms the reported
|
||||
-- "swim in the strong current" is not reproducible around the trigger cells.
|
||||
--
|
||||
-- Run:
|
||||
-- POKEPORT_DRIVER=tests/drivers/seafoam_current_bug212_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug212 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local shotDir = os.getenv("POKEPORT_SHOTDIR") or "."
|
||||
local function shot(name) U.shot(game, shotDir .. "/" .. name) end
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
game.save.party = { Pokemon.new(game.data, "LAPRAS", 40) }
|
||||
-- SURF (+ STRENGTH for the real puzzle) so the surfing/current paths run
|
||||
game.save.party[1].moves = { { id = "SURF" }, { id = "STRENGTH" } }
|
||||
|
||||
local fails = 0
|
||||
local function expect(cond, ...)
|
||||
if not cond then fails = fails + 1 end
|
||||
U.log(cond and "PASS" or "FAIL", ...)
|
||||
end
|
||||
|
||||
-- a visible SPRITE_BOULDER standing on cell (x,y)? self.npcs only holds
|
||||
-- objects objectVisible() accepted, so a hidden-but-untoggled rock is absent
|
||||
local function boulderAt(x, y)
|
||||
for _, n in ipairs(game.overworld.npcs) do
|
||||
local def = n.def
|
||||
if def and def.sprite == "SPRITE_BOULDER"
|
||||
and n.cellX == x and n.cellY == y then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Case A: plugging both B2F holes reveals the B3F landing rocks.
|
||||
-- ------------------------------------------------------------------
|
||||
do
|
||||
game.save.objectToggles = {}
|
||||
game.save.flags.EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE = nil
|
||||
game.save.flags.EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE = nil
|
||||
|
||||
-- baseline: the empty channel, no plug rocks yet
|
||||
U.teleport(game, "SEAFOAM_ISLANDS_B3F", 18, 8, "up")
|
||||
game.overworld.player.surfing = true
|
||||
U.wait(8)
|
||||
shot("b3f_channel_empty.png")
|
||||
expect(not boulderAt(18, 6) and not boulderAt(19, 6),
|
||||
"A0: before plugging, no rock in the channel at (18,6)/(19,6)")
|
||||
|
||||
-- push both plug boulders through their B2F holes via the real engine
|
||||
-- path (OverworldState:boulderIntoHole sets the event flag AND the
|
||||
-- destMap showObject toggle by name)
|
||||
U.teleport(game, "SEAFOAM_ISLANDS_B2F", 19, 7, "up")
|
||||
U.wait(6)
|
||||
game.overworld:boulderIntoHole({ cellX = 19, cellY = 6 }) -- lands B3F (18,6)
|
||||
game.overworld:boulderIntoHole({ cellX = 22, cellY = 6 }) -- lands B3F (19,6)
|
||||
U.wait(4)
|
||||
|
||||
U.teleport(game, "SEAFOAM_ISLANDS_B3F", 18, 8, "up")
|
||||
game.overworld.player.surfing = true
|
||||
U.wait(8)
|
||||
shot("b3f_channel_plugged.png")
|
||||
expect(boulderAt(18, 6), "A1: plug rock visible at (18,6) after plugging both holes")
|
||||
expect(boulderAt(19, 6), "A2: plug rock visible at (19,6) after plugging both holes")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Case B: one rock -> current still gates the channel, no swim-through.
|
||||
-- ------------------------------------------------------------------
|
||||
do
|
||||
game.save.objectToggles = {}
|
||||
game.save.flags.EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE = true
|
||||
game.save.flags.EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE = nil
|
||||
U.teleport(game, "SEAFOAM_ISLANDS_B3F", 18, 8, "up")
|
||||
local p = game.overworld.player
|
||||
p.surfing = true
|
||||
U.wait(6)
|
||||
local startY = p.cellY
|
||||
local minY = p.cellY
|
||||
-- try to paddle north through the current toward the (18,4..6) pool
|
||||
for _ = 1, 90 do
|
||||
table.insert(game.input.pressQueue, "up")
|
||||
game.input.state["up"] = true
|
||||
coroutine.yield()
|
||||
if p.cellY < minY then minY = p.cellY end
|
||||
end
|
||||
game.input.state["up"] = false
|
||||
U.wait(20)
|
||||
if p.cellY < minY then minY = p.cellY end
|
||||
-- the north pool begins at y<=6; reaching it means the current failed to
|
||||
-- gate the passage. Being swept keeps minY at 7 (the current cell) or south.
|
||||
expect(minY >= 7,
|
||||
"B: current gates the channel; player never crossed north (min cellY):", minY)
|
||||
U.log("B: start cellY", startY, "min cellY", minY, "end cellY", p.cellY,
|
||||
"surfing", tostring(p.surfing))
|
||||
end
|
||||
|
||||
if fails > 0 then error(fails .. " check(s) failed") end
|
||||
U.log("all checks passed -- #212 seafoam B3F plug rocks appear "
|
||||
.. "and the strong current gates the channel")
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Driver (#214): status screen page 1 must print the type's DISPLAY name.
|
||||
-- pokered engine/pokemon/status_screen.asm PrintMonType prints the type's
|
||||
-- entry from the TypeNames table (data/types/names.asm), which for the
|
||||
-- PSYCHIC_TYPE constant is "PSYCHIC". The engine stores each species'
|
||||
-- types as pokered CONSTANT names (RomExtractor:typesById), and PSYCHIC's
|
||||
-- constant is "PSYCHIC_TYPE" so it does not collide with the PSYCHIC move.
|
||||
-- Drawing the raw constant overflowed the TYPE field: "PSYCHIC" fills
|
||||
-- x=88..144, then "_TYPE" runs into the right DrawLineBox bracket (the
|
||||
-- stray "+" tick the reporter circled). SummaryMenu must route the label
|
||||
-- through TypeChart.displayName like HallOfFame / BattleState already do.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SummaryMenu = require("src.ui.SummaryMenu")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Font = require("src.render.Font")
|
||||
|
||||
game.save.player.name = "YOSHIRB"
|
||||
|
||||
-- Capture what SummaryMenu actually draws at the TYPE1 value slot
|
||||
-- (x=88, y=80 in SummaryMenu:draw). Monkeypatch the Font table field so
|
||||
-- the local Font reference inside SummaryMenu resolves to our wrapper at
|
||||
-- call time; this reads the real rendered string, not TypeChart in
|
||||
-- isolation, so it fails while the raw constant is drawn.
|
||||
local realDraw = Font.draw
|
||||
local captured
|
||||
local function recordShot(game, mon, path)
|
||||
local summary = SummaryMenu.new(game, mon)
|
||||
game.stack:push(summary)
|
||||
U.wait(4)
|
||||
captured = nil
|
||||
Font.draw = function(text, x, y)
|
||||
if x == 88 and y == 80 then captured = text end
|
||||
return realDraw(text, x, y)
|
||||
end
|
||||
U.shot(game, path)
|
||||
Font.draw = realDraw
|
||||
game.stack:pop()
|
||||
U.wait(2)
|
||||
return captured
|
||||
end
|
||||
|
||||
-- Psychic mon: the bug case. MEW is pure PSYCHIC.
|
||||
local mew = Pokemon.new(game.data, "MEW", 10)
|
||||
local mewDrawn = recordShot(game, mew, DIR .. "/summary_type_214_mew_p1.png")
|
||||
U.log("MEW raw types[1]=", tostring(game.data.pokemon.MEW.types[1]),
|
||||
"drawn TYPE1=", tostring(mewDrawn))
|
||||
|
||||
-- Control: RATTATA is pure NORMAL, whose constant == display name, so it
|
||||
-- was never affected; it should still read "NORMAL".
|
||||
local ratt = Pokemon.new(game.data, "RATTATA", 5)
|
||||
local rattDrawn = recordShot(game, ratt, DIR .. "/summary_type_214_rattata_p1.png")
|
||||
U.log("RATTATA raw types[1]=", tostring(game.data.pokemon.RATTATA.types[1]),
|
||||
"drawn TYPE1=", tostring(rattDrawn))
|
||||
|
||||
U.log("shots under", DIR)
|
||||
|
||||
-- Raw data must still carry the pokered constant: it is the shared key for
|
||||
-- TypeChart matchups and move.type, so the fix lives at the display layer.
|
||||
assert(game.data.pokemon.MEW.types[1] == "PSYCHIC_TYPE",
|
||||
"expected MEW raw type constant PSYCHIC_TYPE, got "
|
||||
.. tostring(game.data.pokemon.MEW.types[1]))
|
||||
assert(TypeChart.displayName("PSYCHIC_TYPE") == "PSYCHIC",
|
||||
"TypeChart.displayName(PSYCHIC_TYPE) must be PSYCHIC")
|
||||
|
||||
-- The bug: SummaryMenu drew the raw constant "PSYCHIC_TYPE" (overflowing).
|
||||
-- The fix: it must draw the display name "PSYCHIC" (7 chars, fits 88..144).
|
||||
assert(mewDrawn == "PSYCHIC",
|
||||
"#214: status screen TYPE1 for MEW must render PSYCHIC, drew "
|
||||
.. tostring(mewDrawn))
|
||||
assert(not tostring(mewDrawn):find("_"),
|
||||
"#214: TYPE1 label must not contain '_' (no glyph, overflows bracket)")
|
||||
assert(rattDrawn == "NORMAL",
|
||||
"control: RATTATA TYPE1 must render NORMAL, drew " .. tostring(rattDrawn))
|
||||
|
||||
U.log("#214 PASS: status-screen TYPE1 renders display names")
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
-- Driver: teaching a TM must open the party menu in Gen 1's TM/HM mode,
|
||||
-- showing ABLE / NOT ABLE per mon from its learnset (no HP bars) with the
|
||||
-- "Use TM on which POKeMON?" prompt (engine/items/item_effects.asm
|
||||
-- ItemUseTMHM -> engine/menus/party_menu.asm PrintPartyMenu TM/HM type). #210
|
||||
--
|
||||
-- Party CHARIZARD 50 / MAGIKARP 15 / SNORLAX 40 with TM_TOXIC: CHARIZARD
|
||||
-- and SNORLAX learn TOXIC (ABLE); MAGIKARP has an empty tmhm (NOT ABLE).
|
||||
--
|
||||
-- SHOT_DIR=/tmp/bug210 POKEPORT_DRIVER=tests/drivers/tmhm_able_bug210_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug210 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
|
||||
-- fresh party with mixed learnability
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 50),
|
||||
Pokemon.new(game.data, "MAGIKARP", 15),
|
||||
Pokemon.new(game.data, "SNORLAX", 40),
|
||||
}
|
||||
Bag.add(game.save, "TM_TOXIC", 1)
|
||||
U.teleport(game, "PALLET_TOWN", 5, 5, "down")
|
||||
|
||||
-- expected ABLE/NOT ABLE from the same learnset the teach reads
|
||||
-- (ItemEffects.use scans data.pokemon[species].tmhm for machine.move)
|
||||
local move = game.data.items.TM_TOXIC.machine.move
|
||||
for _, mon in ipairs(game.save.party) do
|
||||
local can = false
|
||||
for _, m in ipairs(game.data.pokemon[mon.species].tmhm or {}) do
|
||||
if m == move then can = true break end
|
||||
end
|
||||
U.log("expect", mon.species, can and "ABLE" or "NOT ABLE")
|
||||
end
|
||||
|
||||
-- open the bag; BagMenu.new returns the ITEMS ListMenu
|
||||
local bag = require("src.ui.Screens").push(game, "BagMenu")
|
||||
U.wait(3)
|
||||
for i, it in ipairs(bag.items) do
|
||||
if it.value == "TM_TOXIC" then bag.index = i break end
|
||||
end
|
||||
U.log("bag index on TM_TOXIC:", bag.index, bag.items[bag.index].value)
|
||||
|
||||
-- A -> USE / TOSS menu, then A on USE (index 1)
|
||||
U.tap(game, "a"); U.wait(6)
|
||||
U.tap(game, "a"); U.wait(6)
|
||||
|
||||
local function pageText(box)
|
||||
if not box or getmetatable(box) ~= TextBox then return "" end
|
||||
return table.concat(box.pages[box.pageIndex] or {}, "\n")
|
||||
end
|
||||
|
||||
-- mash through "Booted up a TM!" / "It contained TOXIC!" until the
|
||||
-- TM/HM party menu is on top; stop before tapping A on it (that would
|
||||
-- pick a mon)
|
||||
local function reachPartyMenu(max)
|
||||
for _ = 1, max or 120 do
|
||||
if getmetatable(game.stack:top()) == PartyMenu then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local reached = reachPartyMenu()
|
||||
U.log("reached PartyMenu:", tostring(reached))
|
||||
U.wait(3)
|
||||
U.shot(game, DIR .. "/tmhm_bug210_partymenu.png")
|
||||
|
||||
local top = game.stack:top()
|
||||
U.log("top is PartyMenu:", getmetatable(top) == PartyMenu)
|
||||
U.log("top.tmhm:", top.tmhm and ("move=" .. tostring(top.tmhm.move)
|
||||
.. " kind=" .. tostring(top.tmhm.kind)) or "nil")
|
||||
|
||||
-- assertions: fail while the bug exists (no TM mode), pass once fixed
|
||||
assert(getmetatable(top) == PartyMenu, "did not reach the party menu")
|
||||
assert(top.tmhm, "party menu is not in TM/HM mode (opts.tmhm missing)")
|
||||
assert(top.tmhm.move == move,
|
||||
"TM/HM move mismatch: " .. tostring(top.tmhm.move))
|
||||
|
||||
U.log("DONE")
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,218 @@
|
||||
-- Driver: #200 Pokemon Tower 7F Rocket grunts must walk off + despawn.
|
||||
-- The three grunts (POKEMONTOWER7F_ROCKET1/2/3, obj indices 1/2/3, at
|
||||
-- (9,11)/(12,9)/(9,7)) are sight/talk trainers. In Gen1
|
||||
-- (scripts/PokemonTower7F.asm: PokemonTower7FEndBattleScript ->
|
||||
-- PokemonTower7FRocketLeaveMovementScript -> PokemonTower7FHideNPCScript)
|
||||
-- each grunt, after losing, shows its EndBattle text ("I give up!"), then its
|
||||
-- AfterBattle text ("I'm not going to forget this!"), then walks off toward
|
||||
-- the (9,16) stairs (MoveSprite) and HideObject despawns it. The bug left
|
||||
-- every beaten grunt standing in place forever -- att1 in the report shows
|
||||
-- all three still lined up in the corridor after being defeated.
|
||||
--
|
||||
-- This driver beats each grunt in turn and, per grunt, samples ow.npcs every
|
||||
-- frame: the grunt must MOVE off its start tile before it leaves ow.npcs, and
|
||||
-- must end despawned (gone from ow.npcs, objectToggle == false, beat flag set).
|
||||
-- Before the fix each grunt stays at its start tile and never despawns, so the
|
||||
-- movement + despawn assertions fail. After the fix they pass.
|
||||
--
|
||||
-- SHOT_DIR=/tmp/t7f POKEPORT_IDENTITY=bug200 POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_SPEED=4 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/tower7f_bug200_test.lua love .
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
os.execute("mkdir -p " .. DIR)
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
-- clean slate: no grunt may read as already defeated / hidden
|
||||
game.save.defeatedTrainers = {}
|
||||
game.save.objectToggles = game.save.objectToggles or {}
|
||||
game.save.objectToggles.POKEMON_TOWER_7F = nil
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_BEAT_POKEMONTOWER_7_TRAINER_0 = nil
|
||||
game.save.flags.EVENT_BEAT_POKEMONTOWER_7_TRAINER_1 = nil
|
||||
game.save.flags.EVENT_BEAT_POKEMONTOWER_7_TRAINER_2 = nil
|
||||
game.save.player = game.save.player or {}
|
||||
game.save.player.name = game.save.player.name or "RED"
|
||||
|
||||
-- a tank that one-shots any of the L19-25 Rocket parties regardless of
|
||||
-- type, so the mash win is quick and deterministic (same valve as
|
||||
-- gamecorner_rocket_bug198_test)
|
||||
local function freshParty()
|
||||
local tank = Pokemon.new(game.data, "MEWTWO", 100)
|
||||
tank.moves = {
|
||||
{ id = "PSYCHIC_M", pp = 99 },
|
||||
{ id = "THUNDERBOLT", pp = 99 },
|
||||
{ id = "ICE_BEAM", pp = 99 },
|
||||
{ id = "EARTHQUAKE", pp = 99 },
|
||||
}
|
||||
game.save.party = { tank }
|
||||
end
|
||||
|
||||
-- Each grunt: object index/name/id, home tile, and the tile the player
|
||||
-- stands on (facing up, directly below the grunt) to talk it into a battle.
|
||||
-- The talk tiles are the ones the vanilla movement table keys on, so the
|
||||
-- exit walk is the exact PokemonTower7F path (not the safety fallback).
|
||||
local ROCKETS = {
|
||||
{ index = 1, name = "POKEMONTOWER7F_ROCKET1",
|
||||
id = "POKEMON_TOWER_7F_obj_1",
|
||||
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_0",
|
||||
afterFrag = "forget this",
|
||||
rx = 9, ry = 11, standX = 9, standY = 12 },
|
||||
{ index = 2, name = "POKEMONTOWER7F_ROCKET2",
|
||||
id = "POKEMON_TOWER_7F_obj_2",
|
||||
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_1",
|
||||
afterFrag = "making",
|
||||
rx = 12, ry = 9, standX = 12, standY = 10 },
|
||||
{ index = 3, name = "POKEMONTOWER7F_ROCKET3",
|
||||
id = "POKEMON_TOWER_7F_obj_3",
|
||||
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_2",
|
||||
afterFrag = "getting", rx = 9, ry = 7, standX = 9, standY = 8 },
|
||||
}
|
||||
|
||||
local function pageText()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return "" end
|
||||
local parts = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
if type(page) == "table" then
|
||||
for _, line in ipairs(page) do parts[#parts + 1] = tostring(line) end
|
||||
end
|
||||
end
|
||||
return table.concat(parts, " ")
|
||||
end
|
||||
|
||||
local shotN = 0
|
||||
local function shot(tag)
|
||||
shotN = shotN + 1
|
||||
U.shot(game, DIR .. ("/tower7f_%02d_%s.png"):format(shotN, tag))
|
||||
end
|
||||
|
||||
-- Beat one grunt via a talk engagement, then verify it walks off + despawns.
|
||||
-- shots ~= nil requests the before/walk/after screenshot trio for the report.
|
||||
local function beatGrunt(r, shots)
|
||||
U.teleport(game, "POKEMON_TOWER_7F", r.standX, r.standY, "up")
|
||||
local ow = game.overworld
|
||||
|
||||
local function findGrunt()
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == r.name then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local function idle()
|
||||
return game.stack:top() == ow and not ow.runner:isRunning()
|
||||
and #ow.scriptMoves == 0 and not ow.transitioning
|
||||
end
|
||||
|
||||
local g = findGrunt()
|
||||
assert(g, r.name .. " not present at start")
|
||||
assert(g.cellX == r.rx and g.cellY == r.ry,
|
||||
r.name .. " not at expected start tile")
|
||||
U.log(r.name, "start:", g.cellX, g.cellY, g.facing)
|
||||
if shots then shot("standing") end
|
||||
|
||||
-- Phase A: talk, then mash through pre-battle text + the battle, stopping
|
||||
-- the instant the win is registered (the beat flag is set inside the
|
||||
-- battle's onFinish, before the EndBattle box is pushed) so Phase B can
|
||||
-- watch the exit walk that follows.
|
||||
U.tap(game, "a")
|
||||
local sawAfter = false
|
||||
for f = 1, 4000 do
|
||||
if game.save.flags[r.beat] then break end
|
||||
if pageText():find(r.afterFrag, 1, true) then sawAfter = true end
|
||||
local top = game.stack:top()
|
||||
if top and top.phase then
|
||||
if top.phase == "menu" then top.menuIndex = 1
|
||||
elseif top.phase == "moveSelect" then top.moveIndex = 1 end
|
||||
U.tap(game, "a")
|
||||
if f > 2400 and top.onFinish then
|
||||
U.log("force-finishing stalled battle")
|
||||
top.onFinish("win")
|
||||
if game.stack:top() == top then game.stack:pop() end
|
||||
end
|
||||
else
|
||||
U.tap(game, "a") -- talk / advance pre-battle text
|
||||
end
|
||||
U.wait(2)
|
||||
end
|
||||
assert(game.save.flags[r.beat], r.name .. " never registered a win")
|
||||
|
||||
-- Phase B: sample every logic frame while advancing the EndBattle +
|
||||
-- AfterBattle boxes. The grunt must move off its start tile (mid-walk it
|
||||
-- carries targetX/targetY toward the stairs, then its cell updates) before
|
||||
-- HideObject removes it from ow.npcs.
|
||||
local moved, walkShot = false, false
|
||||
for _ = 1, 2000 do
|
||||
local gg = findGrunt()
|
||||
if gg then
|
||||
if gg.cellX ~= r.rx or gg.cellY ~= r.ry
|
||||
or (gg.targetX and gg.targetX ~= r.rx)
|
||||
or (gg.targetY and gg.targetY ~= r.ry) then
|
||||
moved = true
|
||||
if shots and not walkShot then
|
||||
walkShot = true
|
||||
shot("walkoff")
|
||||
end
|
||||
end
|
||||
elseif idle() then
|
||||
break
|
||||
end
|
||||
if pageText():find(r.afterFrag, 1, true) then sawAfter = true end
|
||||
if game.stack:top() ~= ow then U.tap(game, "a") end
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
for _ = 1, 400 do
|
||||
if idle() then break end
|
||||
if game.stack:top() ~= ow then U.tap(game, "a") end
|
||||
U.wait(2)
|
||||
end
|
||||
U.wait(5)
|
||||
if shots then shot("gone") end
|
||||
|
||||
local toggles = game.save.objectToggles.POKEMON_TOWER_7F
|
||||
U.log(r.name, "sawAfter:", sawAfter, "moved:", moved,
|
||||
"gone:", findGrunt() == nil,
|
||||
"toggle:", tostring(toggles and toggles[r.name]))
|
||||
|
||||
-- CORRECT Gen1 behavior: EndBattle/AfterBattle speech, then the grunt
|
||||
-- walks off before despawning -- it must not vanish in place.
|
||||
assert(sawAfter, r.name .. " never showed its after-battle text (#200)")
|
||||
assert(moved,
|
||||
r.name .. " never walked off its tile before despawning (#200)")
|
||||
assert(findGrunt() == nil, r.name .. " still present after the exit walk")
|
||||
assert(toggles and toggles[r.name] == false,
|
||||
r.name .. " objectToggle not persisted hidden")
|
||||
assert(game.save.defeatedTrainers[r.id], r.name .. " not recorded defeated")
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
assert(not (n.cellX == r.rx and n.cellY == r.ry),
|
||||
"an NPC still occupies " .. r.name .. "'s old tile")
|
||||
end
|
||||
end
|
||||
|
||||
freshParty()
|
||||
beatGrunt(ROCKETS[1], true) -- ROCKET1 gets the report screenshot trio
|
||||
freshParty()
|
||||
beatGrunt(ROCKETS[2], false)
|
||||
freshParty()
|
||||
beatGrunt(ROCKETS[3], false)
|
||||
|
||||
-- all three gone: confirm the corridor up to Mr. Fuji is clear of grunts
|
||||
U.teleport(game, "POKEMON_TOWER_7F", 9, 13, "up")
|
||||
local ow = game.overworld
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
assert(n.def.name == "POKEMONTOWER7F_MR_FUJI",
|
||||
"a Rocket grunt is still on the floor after all three were beaten")
|
||||
end
|
||||
local fuji
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def.name == "POKEMONTOWER7F_MR_FUJI" then fuji = n end
|
||||
end
|
||||
assert(fuji, "MR_FUJI missing from the top floor")
|
||||
shot("fuji_clear")
|
||||
U.log("tower7f_bug200_test: ok")
|
||||
end
|
||||
@@ -0,0 +1,156 @@
|
||||
-- Driver: regression coverage for #152 "'Selector' on the world map is off and
|
||||
-- character location is not shown".
|
||||
--
|
||||
-- pret/pokered engine/menus/town_map.asm DisplayTownMap draws the Kanto map
|
||||
-- with a blinking box cursor CENTERED on the currently selected location plus a
|
||||
-- separate blinking "you are here" marker at the player's current map location.
|
||||
-- Two independent defects lived in src/ui/TownMap.lua's grid+background path
|
||||
-- (the primary path when the extracted Kanto art is present):
|
||||
--
|
||||
-- DEFECT A (selector off): the 16x16 hollow cursor frame was drawn with its
|
||||
-- top-left AT the 8x8 cell's top-left (markerXY), so the frame's center
|
||||
-- landed +4,+4 off the cell -- the town square sat in the frame's top-left
|
||||
-- quadrant instead of being enclosed. Fix centers the frame (-4,-4).
|
||||
--
|
||||
-- DEFECT B (character location not shown): the player marker was painted with
|
||||
-- setColor(0.75,0.1,0.1) red. The TOWN MAP composites through the SGB
|
||||
-- shade-remap shader (PaletteFX.shader), which keys ONLY on the red channel;
|
||||
-- red 0.75 falls in the c1 bucket = TOWNMAP {165,214,255}, the exact
|
||||
-- light-blue used for water and the town-square fill, so the marker was
|
||||
-- painted but recolored invisible. Fix paints it red=0 -> c3 (dark dot).
|
||||
--
|
||||
-- This driver opens the grid TOWN MAP outdoors at PALLET TOWN, moves the cursor
|
||||
-- off the player's town to VIRIDIAN CITY (so the you-are-here marker and the
|
||||
-- selection cursor must BOTH be visible), and asserts -- at native resolution,
|
||||
-- after replicating the Renderer's TOWNMAP shade-remap pass -- that the player
|
||||
-- marker is a visible dark dot (DEFECT B) and the cursor frame is centered on
|
||||
-- the selected cell (DEFECT A). Fails on the current build, passes once fixed.
|
||||
--
|
||||
-- Run:
|
||||
-- SHOT_DIR=/tmp/bug152 POKEPORT_IDENTITY=bug152 POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/townmap_selector_bug152_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- The defect lives in the SGB ('gbc') mode shade-remap; pin it deterministic.
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.colors = "gbc"
|
||||
PaletteFX.setMode("gbc")
|
||||
|
||||
game.save.player = game.save.player or {}
|
||||
game.save.player.name = "bryan"
|
||||
|
||||
-- Outdoor map so the player's map resolves to a town-map location (byMap).
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(5)
|
||||
|
||||
Screens.push(game, "TownMap")
|
||||
U.wait(6)
|
||||
local top = game.stack:top()
|
||||
|
||||
-- Functional preconditions: the grid path with extracted art, and the
|
||||
-- character's location known (playerLoc non-nil) -- otherwise the two draw
|
||||
-- defects would not even be on the code path we mean to test.
|
||||
assert(top and top.mode == "grid",
|
||||
"town map must be in grid mode (extracted art), got " .. tostring(top and top.mode))
|
||||
assert(top.bg ~= nil and top.bg.cursor ~= nil,
|
||||
"extracted Kanto background + cursor asset must be present for this test")
|
||||
assert(top.playerLoc ~= nil and top.playerLoc.name == "PALLET TOWN",
|
||||
"character location must resolve to PALLET TOWN, got "
|
||||
.. tostring(top.playerLoc and top.playerLoc.name))
|
||||
|
||||
-- Human-viewable before/after frame: cursor still on the player's own town.
|
||||
top.blink = 0
|
||||
U.shot(game, DIR .. "/townmap_bug152_pallet.png")
|
||||
U.wait(4)
|
||||
|
||||
-- Move the cursor OFF the player's town up to VIRIDIAN CITY, so the
|
||||
-- you-are-here marker (PALLET) and the selection cursor (VIRIDIAN) occupy
|
||||
-- different cells and BOTH must render (matches the reporter's should2).
|
||||
local guard = 0
|
||||
while top.locs[top.sel].name ~= "VIRIDIAN CITY" and guard < 8 do
|
||||
U.tap(game, "up"); U.wait(3); guard = guard + 1
|
||||
end
|
||||
assert(top.locs[top.sel].name == "VIRIDIAN CITY",
|
||||
"up must snap the cursor to VIRIDIAN CITY, landed on "
|
||||
.. tostring(top.locs[top.sel].name))
|
||||
assert(top.locs[top.sel] ~= top.playerLoc,
|
||||
"cursor must be on a DIFFERENT cell than the you-are-here marker")
|
||||
|
||||
top.blink = 0
|
||||
U.shot(game, DIR .. "/townmap_bug152_viridian.png")
|
||||
U.wait(4)
|
||||
|
||||
-- ---- deterministic native-resolution pixel gate ----
|
||||
-- TownMap:draw() emits raw DMG shades; the Renderer composites the whole
|
||||
-- screen through PaletteFX.shader() with the TOWNMAP SGB palette (keyed on
|
||||
-- the RED channel only). Replicate that here at native 160x144 so the pixel
|
||||
-- checks are scale-independent -- the same offscreen-colorize technique as
|
||||
-- battle_hpbar_gbc_bug229_test.lua.
|
||||
if not (love and love.graphics and love.graphics.newCanvas and PaletteFX.shader()) then
|
||||
error("#152 driver: no shader/canvas support available to verify colorized output")
|
||||
end
|
||||
|
||||
top.blink = 0 -- marker shows while blink<20, cursor while blink%16<10
|
||||
local raw = love.graphics.newCanvas(160, 144)
|
||||
love.graphics.setCanvas(raw)
|
||||
love.graphics.clear(0, 0, 0, 1)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
top:draw()
|
||||
love.graphics.setCanvas()
|
||||
|
||||
local shader = PaletteFX.shader()
|
||||
local colors = PaletteFX.pal(game.data, "TOWNMAP")
|
||||
assert(colors, "TOWNMAP palette must resolve")
|
||||
local shaded = love.graphics.newCanvas(160, 144)
|
||||
love.graphics.setCanvas(shaded)
|
||||
love.graphics.clear(0, 0, 0, 1)
|
||||
love.graphics.setShader(shader)
|
||||
PaletteFX.sendColors(shader, colors)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(raw)
|
||||
love.graphics.setShader()
|
||||
love.graphics.setCanvas()
|
||||
|
||||
local id = shaded:newImageData()
|
||||
do -- save the colorized native frame for human viewing
|
||||
local d = id:encode("png")
|
||||
local f = io.open(DIR .. "/townmap_bug152_viridian_native.png", "wb")
|
||||
if f then f:write(d:getString()); f:close() end
|
||||
end
|
||||
|
||||
local function sumAt(x, y)
|
||||
local r, g, b = id:getPixel(x, y)
|
||||
return r + g + b, r, g, b
|
||||
end
|
||||
|
||||
-- markerXY(loc) = (loc.x*8+16, loc.y*8+8) = the 8x8 cell top-left.
|
||||
-- PALLET (x2,y11) -> (32,96); the you-are-here dot fills +2,+2 4x4, center ~ (35,99).
|
||||
local ms, mr, mg, mb = sumAt(35, 99)
|
||||
-- VIRIDIAN (x2,y8) -> (32,72). Post-fix the centered 16x16 frame's top border
|
||||
-- sits ~4px ABOVE the cell top (row ~68) and its left border ~4px LEFT (col ~28).
|
||||
local ts = select(1, sumAt(32, 68))
|
||||
local ls = select(1, sumAt(28, 78))
|
||||
U.log(string.format("marker(35,99) rgb=%.2f/%.2f/%.2f sum=%.2f", mr, mg, mb, ms))
|
||||
U.log(string.format("cursor top(32,68) sum=%.2f left(28,78) sum=%.2f", ts, ls))
|
||||
|
||||
-- DEFECT B: the "you are here" marker must be a VISIBLE dark dot, not the
|
||||
-- map-fill light-blue (sum 2.49) the red-channel shade-remap folded it into.
|
||||
assert(ms < 0.6, string.format(
|
||||
"#152 DEFECT B: player-location marker invisible -- PALLET cell reads sum=%.2f "
|
||||
.. "(map-fill blue); expected a dark you-are-here dot (sum<0.6)", ms))
|
||||
-- DEFECT A: the 16x16 cursor frame must be CENTERED on the selected cell, so a
|
||||
-- dark border pixel exists ~4px above and ~4px left of the VIRIDIAN cell top-left.
|
||||
assert(ts < 0.6, string.format(
|
||||
"#152 DEFECT A: cursor frame not centered -- no dark top border above VIRIDIAN "
|
||||
.. "cell (sum=%.2f, expected <0.6)", ts))
|
||||
assert(ls < 0.6, string.format(
|
||||
"#152 DEFECT A: cursor frame not centered -- no dark left border of VIRIDIAN "
|
||||
.. "cell (sum=%.2f, expected <0.6)", ls))
|
||||
|
||||
U.log("RESULT bug152 PASS")
|
||||
U.wait(2)
|
||||
end
|
||||
@@ -0,0 +1,150 @@
|
||||
-- Driver (#222): a completed LAN link trade must autosave immediately, so a
|
||||
-- player who quits before touching the START menu keeps the received mon and
|
||||
-- cannot clone the sent one by resetting.
|
||||
--
|
||||
-- pokered engine/link/cable_club.asm: the Cable Club calls SaveSAVtoSRAM
|
||||
-- (engine/menus/save.asm) right after every trade commits, so the swap is on
|
||||
-- the cartridge the instant it happens. Our LinkState:updateTrade "done"
|
||||
-- branch swaps game.save.party in memory (TradeSession:apply) but, before the
|
||||
-- fix, never persisted it -- SaveData.load then returned the pre-trade party
|
||||
-- from disk, losing the received mon and re-materializing the sent one (the
|
||||
-- classic reset-to-clone vector).
|
||||
--
|
||||
-- This drives the real receiver side: lay a baseline disk save with a PIDGEY
|
||||
-- party (the "player saved earlier" state), negotiate a TradeSession to
|
||||
-- "done" against a peer's RATTATA (RATTATA has no trade evolution, so the run
|
||||
-- is deterministic), hand it to a live LinkState so the completion branch
|
||||
-- runs, then RELOAD FROM DISK and assert the on-disk party holds the received
|
||||
-- RATTATA and no stale PIDGEY -- fails while the autosave is missing, passes
|
||||
-- once updateTrade calls game:writeSave().
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local LinkState = require("src.link.LinkState")
|
||||
local Net = require("src.link.Net")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local TradeAnim = require("src.ui.TradeAnim")
|
||||
|
||||
local function topIs(cls)
|
||||
return getmetatable(game.stack:top()) == cls
|
||||
end
|
||||
|
||||
-- (1) a real overworld so writeSave's captureSave has a world to stamp
|
||||
U.teleport(game, "PALLET_TOWN", 5, 6, "down")
|
||||
U.wait(5)
|
||||
|
||||
-- (2) baseline party + baseline disk save: this is the state the player
|
||||
-- would fall back to if the trade never persisted (must NOT contain RATTATA)
|
||||
game.save.party = { Pokemon.new(game.data, "PIDGEY", 10) }
|
||||
local version = game.save.version
|
||||
game:writeSave()
|
||||
U.shot(game, DIR .. "/bug222_00_before.png")
|
||||
|
||||
local baseline = SaveData.load(version)
|
||||
U.log("baseline on disk:", baseline and baseline.party and baseline.party[1]
|
||||
and baseline.party[1].species, "count:",
|
||||
baseline and baseline.party and #baseline.party)
|
||||
assert(baseline and baseline.party and baseline.party[1]
|
||||
and baseline.party[1].species == "PIDGEY",
|
||||
"#222 setup: baseline disk save must hold the PIDGEY party")
|
||||
|
||||
-- (3) drive two TradeSessions to "done" exactly like run_link_tests.lua:
|
||||
-- ours is built on the LIVE game.save.party so apply() mutates it in place.
|
||||
local peerParty = { Pokemon.new(game.data, "RATTATA", 8) }
|
||||
peerParty[1].ot = "CULLEN"; peerParty[1].otId = 16012 -- distinct sender
|
||||
local ourTrade = Protocol.TradeSession.new(game.data, game.save.party)
|
||||
local peerTrade = Protocol.TradeSession.new(game.data, peerParty)
|
||||
ourTrade:handle({ type = "party", mons = Protocol.packParty(peerParty) })
|
||||
peerTrade:handle({ type = "party", mons = Protocol.packParty(game.save.party) })
|
||||
assert(ourTrade.stage == "picking", "trade should reach picking")
|
||||
local pickOurs = ourTrade:pick(1) -- gives PIDGEY
|
||||
local pickPeer = peerTrade:pick(1) -- gives RATTATA
|
||||
ourTrade:handle(pickPeer)
|
||||
peerTrade:handle(pickOurs)
|
||||
assert(ourTrade.stage == "confirming", "both picks -> confirming")
|
||||
local cOurs = ourTrade:confirm(true)
|
||||
local cPeer = peerTrade:confirm(true)
|
||||
ourTrade:handle(cPeer)
|
||||
peerTrade:handle(cOurs)
|
||||
assert(ourTrade.stage == "done", "both confirms -> done")
|
||||
|
||||
-- (4) attach the completed session to a live LinkState in the trade stage,
|
||||
-- fresh Net (no peer) so poll() is empty and the "done" branch runs at once
|
||||
local ls = LinkState.new(game)
|
||||
ls.net = Net.new()
|
||||
ls.peerName = "CULLEN"
|
||||
ls.verdict = "full"
|
||||
ls.confirmed = true
|
||||
ls.stage = "trade"
|
||||
ls.trade = ourTrade
|
||||
game.stack:push(ls)
|
||||
|
||||
-- (5) let the stack update ls once: the "done" branch applies the swap into
|
||||
-- game.save.party, autosaves (after the fix), pops ls, pushes TradeAnim
|
||||
for _ = 1, 120 do
|
||||
if game.stack:top() ~= ls then break end
|
||||
U.wait(1)
|
||||
end
|
||||
assert(game.stack:top() ~= ls, "#222: LinkState must leave the trade 'done' branch")
|
||||
U.log("in-memory party after trade:", game.save.party[1]
|
||||
and game.save.party[1].species)
|
||||
|
||||
-- advance the TradeAnim until the "Trade completed!" TextBox appears for the
|
||||
-- after shot; the disk write already happened in the done branch, this is
|
||||
-- cosmetic. A fresh Font require keeps this independent of TradeAnim state.
|
||||
local Font = require("src.render.Font")
|
||||
local sawText = false
|
||||
for _ = 1, 4000 do
|
||||
local top = game.stack:top()
|
||||
if top == game.overworld then break end
|
||||
if getmetatable(top) == TradeAnim and not top.waitingText then
|
||||
top:update(1 / 60)
|
||||
end
|
||||
if topIs(TextBox) then sawText = true; break end
|
||||
U.tap(game, "a")
|
||||
U.wait(1)
|
||||
end
|
||||
-- fully reveal the current page ("Trade completed!") so the shot shows text
|
||||
-- regardless of the typewriter speed (mirrors trade_anim_test.lua)
|
||||
local tb = game.stack:top()
|
||||
if getmetatable(tb) == TextBox and tb.pages and tb.pageIndex then
|
||||
local page = tb.pages[tb.pageIndex]
|
||||
if page then
|
||||
tb.shown = {}
|
||||
for _, line in ipairs(page) do
|
||||
tb.shown[#tb.shown + 1] = Font.encode(line)
|
||||
end
|
||||
tb.lineIndex = #page
|
||||
tb.charIndex = #(tb.shown[#tb.shown] or {})
|
||||
tb.done = true
|
||||
end
|
||||
end
|
||||
U.wait(2)
|
||||
U.shot(game, DIR .. "/bug222_01_trade_complete.png")
|
||||
U.log("reached trade-complete text:", sawText)
|
||||
|
||||
-- (6) RELOAD FROM DISK and assert the trade was persisted without a manual
|
||||
-- save. These fail before the fix (disk still holds the baseline PIDGEY).
|
||||
local disk = SaveData.load(version)
|
||||
U.log("on disk after trade:", disk and disk.party and disk.party[1]
|
||||
and disk.party[1].species, "count:",
|
||||
disk and disk.party and #disk.party)
|
||||
|
||||
assert(disk and disk.party, "#222: a save file must exist on disk")
|
||||
assert(disk.party[1] and disk.party[1].species == "RATTATA",
|
||||
"#222: trade must autosave -- on-disk lead must be the received RATTATA, was "
|
||||
.. tostring(disk.party[1] and disk.party[1].species))
|
||||
assert(#disk.party == 1,
|
||||
"#222: on-disk party must be exactly the swapped party (no phantom slot), had "
|
||||
.. tostring(#disk.party))
|
||||
for i, mon in ipairs(disk.party) do
|
||||
assert(mon.species ~= "PIDGEY",
|
||||
"#222: the sent PIDGEY must not survive on disk (clone vector), found at slot "
|
||||
.. tostring(i))
|
||||
end
|
||||
|
||||
U.log("#222 PASS: link trade autosaved; disk holds RATTATA, PIDGEY gone")
|
||||
end
|
||||
@@ -0,0 +1,95 @@
|
||||
-- Driver (#215): a link-traded mon must keep its ORIGINAL trainer's OT
|
||||
-- name and ID on the receiving game, not adopt the receiver's identity.
|
||||
--
|
||||
-- pokered engine/link/cable_club.asm + home/serial.asm: a trade transmits
|
||||
-- the whole party data block, which carries each mon's OT ID (party_struct
|
||||
-- MON_OTID offset) and the OT-names block (wPartyMonOT). The receiving game
|
||||
-- copies both verbatim and never overwrites -- a differing OT/ID is exactly
|
||||
-- what marks a mon as traded (boosted EXP, high-level disobedience).
|
||||
--
|
||||
-- The bug: Protocol.packMon/unpackMon dropped mon.ot/mon.otId on the wire,
|
||||
-- so a received mon arrived ot=nil/otId=nil and SummaryMenu (status_screen.asm
|
||||
-- StatusScreen) fell back to the local player's name/ID -- the reporter saw
|
||||
-- the sender's CULLEN/16012 mon show up on the receiver as RED/60368. This
|
||||
-- drives the receiver side headlessly: pack a CULLEN-owned mon and unpack it,
|
||||
-- then read exactly what SummaryMenu draws in the IDNo/OT slots.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local SummaryMenu = require("src.ui.SummaryMenu")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Font = require("src.render.Font")
|
||||
|
||||
-- This install is the RECEIVER: player RED, ID 60368 (from the report's
|
||||
-- Blue window). A correctly preserved OT must NOT match these.
|
||||
game.save.player.name = "RED"
|
||||
game.save.player.id = 60368
|
||||
|
||||
-- Capture exactly the strings SummaryMenu draws in the IDNo value slot
|
||||
-- (x=96, y=112) and the OT value slot (x=96, y=128) on page 1. Monkeypatch
|
||||
-- the Font table field so SummaryMenu's own `local Font` reference resolves
|
||||
-- to our wrapper at call time -- this reads the real rendered strings, not
|
||||
-- Protocol in isolation, so it fails while the received mon carries no OT.
|
||||
local realDraw = Font.draw
|
||||
local function recordShot(mon, path)
|
||||
local summary = SummaryMenu.new(game, mon)
|
||||
game.stack:push(summary)
|
||||
U.wait(4)
|
||||
local capturedId, capturedOt = nil, nil
|
||||
Font.draw = function(text, x, y)
|
||||
if x == 96 and y == 112 then capturedId = text end
|
||||
if x == 96 and y == 128 then capturedOt = text end
|
||||
return realDraw(text, x, y)
|
||||
end
|
||||
U.shot(game, path)
|
||||
Font.draw = realDraw
|
||||
game.stack:pop()
|
||||
U.wait(2)
|
||||
return capturedId, capturedOt
|
||||
end
|
||||
|
||||
-- The bug case: a RATTATA caught by trainer CULLEN (id 16012), sent across
|
||||
-- the wire. pack -> unpack is exactly what the trade session does to the
|
||||
-- mon before it lands in the receiver's party (Protocol.TradeSession).
|
||||
local sender = Pokemon.new(game.data, "RATTATA", 8)
|
||||
sender.ot = "CULLEN"
|
||||
sender.otId = 16012
|
||||
local received = Protocol.unpackMon(game.data, Protocol.packMon(sender))
|
||||
local recvId, recvOt =
|
||||
recordShot(received, DIR .. "/trade_ot_215_received_p1.png")
|
||||
U.log("received mon: IDNo=", tostring(recvId), " OT=", tostring(recvOt))
|
||||
|
||||
-- Control: a mon caught locally on THIS game gets the player stamped as OT
|
||||
-- (BattleState.stampOT, engine/battle/core.asm on catch), so its summary
|
||||
-- correctly reads RED / 60368. This proves the player-fallback path itself
|
||||
-- is fine and it is only the received mon that was wrong.
|
||||
local mine = Pokemon.new(game.data, "PIDGEY", 8)
|
||||
BattleState.stampOT(game.save, mine)
|
||||
local mineId, mineOt =
|
||||
recordShot(mine, DIR .. "/trade_ot_215_control_p1.png")
|
||||
U.log("self-caught mon: IDNo=", tostring(mineId), " OT=", tostring(mineOt))
|
||||
|
||||
U.log("shots under", DIR)
|
||||
|
||||
-- The received mon must show the ORIGINAL trainer, not the receiver.
|
||||
assert(recvOt == "CULLEN",
|
||||
"#215: received mon OT must render CULLEN (the original trainer), drew "
|
||||
.. tostring(recvOt))
|
||||
assert(recvId == "16012",
|
||||
"#215: received mon IDNo must render 16012 (the original trainer ID), drew "
|
||||
.. tostring(recvId))
|
||||
assert(recvOt ~= game.save.player.name,
|
||||
"#215: received mon must not adopt the receiver's OT name")
|
||||
assert(recvId ~= ("%05d"):format(game.save.player.id),
|
||||
"#215: received mon must not adopt the receiver's ID")
|
||||
|
||||
-- Control must still show the local player (self-caught mon is unaffected).
|
||||
assert(mineOt == "RED",
|
||||
"control: self-caught mon OT must render RED, drew " .. tostring(mineOt))
|
||||
assert(mineId == "60368",
|
||||
"control: self-caught mon IDNo must render 60368, drew " .. tostring(mineId))
|
||||
|
||||
U.log("#215 PASS: link-traded mon keeps the original trainer's OT/ID")
|
||||
end
|
||||
@@ -235,6 +235,79 @@ do
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- #206: exported map + position
|
||||
-- Regression guard for issue #206 ("exported .sav loads as a glitch map /
|
||||
-- crashes in an emulator"). The Gen1 continue path rebuilds the entire
|
||||
-- overworld from the saved current-map byte and the player's tile coords
|
||||
-- (engine/menus/main_menu.asm SpecialEnterMap -> ResetPlayerSpriteData ->
|
||||
-- EnterMap -> LoadMapHeader), so the two ways a real cartridge glitches on
|
||||
-- Continue are (a) wCurMap not resolving to a real map header, and (b) the
|
||||
-- player's wYCoord/wXCoord falling outside the map's walk grid. A cell is 2x2
|
||||
-- background tiles (home/overworld.asm), so a WxH-block map is 2W x 2H cells
|
||||
-- and valid coords are 0..2W-1 / 0..2H-1. Drive the launcher's real export
|
||||
-- path (import -> SaveData.load -> exportActiveSlot) and assert the emitted
|
||||
-- bytes land a loadable player across interior/outdoor/cave maps, including
|
||||
-- each map's far corner, so this class of corruption cannot slip back in.
|
||||
do
|
||||
local mapsByIndex = {}
|
||||
for id, def in pairs(data.maps) do
|
||||
if def.index then mapsByIndex[def.index] = id end
|
||||
end
|
||||
|
||||
-- One export cycle through the exact glue the SAVE FILES card uses. Returns
|
||||
-- the raw exported image, or nil if any stage refused (checked by callers).
|
||||
local function exportedThrough(mapId, x, y)
|
||||
local files = fresh()
|
||||
local seed = SaveData.newGame({ playerName = "JOHN", rivalName = "BLUE" })
|
||||
seed.player.map, seed.player.x, seed.player.y = mapId, x, y
|
||||
-- a plausible mid-game party so the slot is not a blank new game
|
||||
seed.party = { {
|
||||
species = "SQUIRTLE", level = 16, exp = 4000,
|
||||
dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 },
|
||||
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = { hp = 44, attack = 28, defense = 31, speed = 26, special = 30 },
|
||||
hp = 44, moves = { { id = "TACKLE", pp = 35, ppUps = 0 } },
|
||||
nickname = "SQ", ot = "JOHN", otId = seed.player.id, catchRate = 45,
|
||||
} }
|
||||
if not SaveFileIO.importToSlot(GenSave.encode(seed, data, nil), "red") then return nil end
|
||||
if not SaveData.load("red") then return nil end
|
||||
if not SaveFileIO.exportActiveSlot("red") then return nil end
|
||||
return files["exports/gen1recomp-red-slot1.sav"]
|
||||
end
|
||||
|
||||
local function assertLoadable(label, mapId, x, y)
|
||||
local def = data.maps[mapId]
|
||||
if not def then return end -- this data set lacks the map; skip silently
|
||||
local out = exportedThrough(mapId, x, y)
|
||||
check(out ~= nil and #out == GenSave.SAVE_SIZE,
|
||||
label .. ": exports a 32768-byte image through the launcher path")
|
||||
if not out then return end
|
||||
-- (a) wCurMap resolves: an unresolved index loads a garbage header, the
|
||||
-- #206 glitch/crash itself
|
||||
eq(mapsByIndex[out:byte(OFF.curMap + 1)], mapId,
|
||||
label .. ": exported wCurMap resolves to the saved map")
|
||||
-- (b) tile coords inside the 2W x 2H cell grid, and unchanged by the round trip
|
||||
local cx, cy = out:byte(OFF.xCoord + 1), out:byte(OFF.yCoord + 1)
|
||||
check(cx <= 2 * def.width - 1 and cy <= 2 * def.height - 1,
|
||||
("%s: player cell (%d,%d) is in-bounds for the %dx%d-block map")
|
||||
:format(label, cx, cy, def.width, def.height))
|
||||
eq(cx, x, label .. ": wXCoord survives load -> export unchanged")
|
||||
eq(cy, y, label .. ": wYCoord survives load -> export unchanged")
|
||||
check(mainChecksumValid(out), label .. ": exported main-data checksum is valid")
|
||||
end
|
||||
|
||||
assertLoadable("bedroom interior", "REDS_HOUSE_2F", 3, 6)
|
||||
assertLoadable("outdoor town", "PALLET_TOWN", 5, 6)
|
||||
assertLoadable("outdoor city", "CERULEAN_CITY", 27, 15)
|
||||
assertLoadable("cave floor", "MT_MOON_1F", 5, 5)
|
||||
-- the map's far corner (2W-1, 2H-1): proves the boundary coord writes and
|
||||
-- survives without truncation or an off-by-one out-of-bounds
|
||||
if data.maps.VIRIDIAN_CITY then
|
||||
local d = data.maps.VIRIDIAN_CITY
|
||||
assertLoadable("far corner", "VIRIDIAN_CITY", 2 * d.width - 1, 2 * d.height - 1)
|
||||
end
|
||||
end
|
||||
|
||||
love.filesystem = realFS
|
||||
|
||||
T.finish("save_file_io")
|
||||
|
||||
+10
-11
@@ -101,15 +101,14 @@ end
|
||||
-- ported, kept here as a regression check of the full chain).
|
||||
--
|
||||
-- Note: the B2F -> B3F leg's *destination* visibility (steps 5-6 below)
|
||||
-- is a known pre-existing gap unrelated to this workstream: B3F's
|
||||
-- toggleable_objects.asm ordinal skips BOULDER1/BOULDER4, so
|
||||
-- TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_3/4 (which should land on
|
||||
-- SEAFOAMISLANDSB3F_BOULDER5/6) resolve through
|
||||
-- OverworldController.lua's toggleToObjectName() to the wrong (already-
|
||||
-- visible) BOULDER3/4 instead. That resolver lives outside this
|
||||
-- workstream's port targets, so only the event flag + source-hide (both
|
||||
-- correct today) are asserted for that leg; the cosmetic destination
|
||||
-- reveal is left as-is.
|
||||
-- was a pre-existing gap (issue #212): the plug rocks that stop the B3F
|
||||
-- strong current land at (18,6)/(19,6) -- the hidden SEAFOAMISLANDSB3F_
|
||||
-- BOULDER5/6 -- but field.pluggedByHolesOn wired those holes' showObject to
|
||||
-- TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_3/4, which toggleToObjectName() resolves
|
||||
-- to the already-visible BOULDER3/4 at (8,14)/(9,14), so the landing rocks
|
||||
-- never appeared. Fixed by repointing the showObject toggles to BOULDER_5/_6
|
||||
-- (tools/rom_manifest*.json + the data/generated/field.lua cache), so the
|
||||
-- destination reveal now works and is asserted here (checkDst = true).
|
||||
local pushes = {
|
||||
{ curMap = "SEAFOAM_ISLANDS_1F", hx = 17, hy = 6,
|
||||
event = "EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE",
|
||||
@@ -135,12 +134,12 @@ local pushes = {
|
||||
event = "EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE",
|
||||
srcMap = "SEAFOAM_ISLANDS_B2F", srcName = "SEAFOAMISLANDSB2F_BOULDER1",
|
||||
dstMap = "SEAFOAM_ISLANDS_B3F", dstName = "SEAFOAMISLANDSB3F_BOULDER5",
|
||||
checkDst = false },
|
||||
checkDst = true },
|
||||
{ curMap = "SEAFOAM_ISLANDS_B2F", hx = 22, hy = 6,
|
||||
event = "EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE",
|
||||
srcMap = "SEAFOAM_ISLANDS_B2F", srcName = "SEAFOAMISLANDSB2F_BOULDER2",
|
||||
dstMap = "SEAFOAM_ISLANDS_B3F", dstName = "SEAFOAMISLANDSB3F_BOULDER6",
|
||||
checkDst = false },
|
||||
checkDst = true },
|
||||
{ curMap = "SEAFOAM_ISLANDS_B3F", hx = 3, hy = 16,
|
||||
event = "EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE",
|
||||
srcMap = "SEAFOAM_ISLANDS_B3F", srcName = "SEAFOAMISLANDSB3F_BOULDER1",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
-- Regression: the tall-grass "feet overdraw" must not fire for an off-map
|
||||
-- cell during a map-connection seam step (issue #217).
|
||||
--
|
||||
-- Walking south out of Viridian City, crossConnection lands the player on
|
||||
-- ROUTE_1 at (10, 0) and parks them one cell BEFORE the seam for the walk
|
||||
-- step -- cellY = 0 - 1 = -1, one row off the new map's top edge. ROUTE_1's
|
||||
-- borderBlock (11) border-extends OVERWORLD block 11, whose bottom tile row
|
||||
-- is the grass tile ($52 = 82), so Map:isGrassCell(10, -1) used to return
|
||||
-- TRUE and the four overworld overdraw sites painted an animated grass tuft
|
||||
-- over the player's head for the ~5 frames the seam step lasted.
|
||||
--
|
||||
-- Tall grass ($52) is only meaningful within the loaded map view; the
|
||||
-- border-block filler that back-fills off-map coordinates is never standable
|
||||
-- grass (pokered engine/overworld/connections.asm loads the neighbour strip
|
||||
-- but the player never collides against border filler as grass). The fix
|
||||
-- guards Map:isGrassCell with an in-bounds check.
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_grass_seam.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.VIRIDIAN_CITY) then Data:load() end
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local OW = require("src.world.OverworldController")
|
||||
local S = require("tests.harness").suite("parity grass seam")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.renderer = Renderer; Renderer:init()
|
||||
Game.stack = StateStack
|
||||
StateStack:init()
|
||||
Game.save = SaveData.newGame()
|
||||
Game.overworld = OW
|
||||
|
||||
-- Park on Viridian City's south edge, on the exit-path column (cellX = 20),
|
||||
-- facing the seam. heightCells = 18 blocks * 2 = 36, so cellY = 35 is the
|
||||
-- bottom edge row.
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, "VIRIDIAN_CITY", 20, 35, "down")
|
||||
local ow = Game.stack:top()
|
||||
|
||||
local south = ow.map:connection("south")
|
||||
check(south and south.map == "ROUTE_1", "Viridian south connects to ROUTE_1")
|
||||
|
||||
check(ow:crossConnection("down", south) == true, "Viridian -> Route 1 crosses")
|
||||
eq(ow.map.id, "ROUTE_1", "landed on ROUTE_1")
|
||||
|
||||
local p = ow.player
|
||||
-- the seam parks the player one cell before the entry point, off the map
|
||||
eq(p.cellY, -1, "seam step parks the player at cellY = -1 (off the top edge)")
|
||||
eq(ow.map:inBounds(p.cellX, p.cellY), false, "the parked cell is off-map")
|
||||
|
||||
-- the border filler genuinely IS the grass tile, so the guard (not a
|
||||
-- different tile) is what suppresses the phantom overdraw
|
||||
eq(ow.map:cellTile(p.cellX, p.cellY), ow.map.tileset.grassTile,
|
||||
"the off-map border tile decodes to the grass tile (82)")
|
||||
|
||||
-- THE FIX: an off-map cell is never tall grass, so no feet-overdraw fires
|
||||
eq(ow.map:isGrassCell(p.cellX, p.cellY), false,
|
||||
"off-map seam cell is not grass -- no phantom overdraw over the player")
|
||||
|
||||
-- and the target the overdraw sites also test must not fire off-map either
|
||||
if p.targetX and not ow.map:inBounds(p.targetX, p.targetY) then
|
||||
eq(ow.map:isGrassCell(p.targetX, p.targetY), false,
|
||||
"off-map seam target is not grass either")
|
||||
end
|
||||
|
||||
-- Positive control: a real in-bounds grass cell still reports grass, so the
|
||||
-- guard did not blind the encounter roll / overdraw to actual tall grass.
|
||||
local gx, gy
|
||||
for cy = 0, ow.map.heightCells - 1 do
|
||||
for cx = 0, ow.map.widthCells - 1 do
|
||||
if ow.map:cellTile(cx, cy) == ow.map.tileset.grassTile then
|
||||
gx, gy = cx, cy
|
||||
break
|
||||
end
|
||||
end
|
||||
if gx then break end
|
||||
end
|
||||
check(gx ~= nil, "ROUTE_1 has at least one in-bounds grass cell to test")
|
||||
if gx then
|
||||
check(ow.map:inBounds(gx, gy), "control grass cell is in bounds")
|
||||
eq(ow.map:isGrassCell(gx, gy), true,
|
||||
"real in-bounds tall grass still detected after the fix")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -52,6 +52,26 @@ local clamped = Protocol.unpackMon(Data, packed)
|
||||
eq(clamped.level, 100, "tampered level clamped")
|
||||
eq(clamped.dvs.attack, 15, "tampered DV clamped")
|
||||
|
||||
-- OT identity survives the wire (#215): pokered's trade sends each mon's OT
|
||||
-- ID (party_struct MON_OTID) and OT name (wPartyMonOT); the receiver keeps
|
||||
-- them verbatim so a traded mon shows its original trainer, not the receiver.
|
||||
local otMon = Pokemon.new(Data, "KADABRA", 30)
|
||||
otMon.ot = "CULLEN"
|
||||
otMon.otId = 16012
|
||||
local otRt = Protocol.unpackMon(Data, Protocol.packMon(otMon))
|
||||
eq(otRt.ot, "CULLEN", "OT name survives the wire")
|
||||
eq(otRt.otId, 16012, "OT ID survives the wire")
|
||||
-- a tampered OT ID is clamped into the 16-bit range like every other field
|
||||
local otPack = Protocol.packMon(otMon)
|
||||
otPack.otId = 999999
|
||||
eq(Protocol.unpackMon(Data, otPack).otId, 65535, "over-range OT ID clamped")
|
||||
otPack.otId = -5
|
||||
eq(Protocol.unpackMon(Data, otPack).otId, 0, "under-range OT ID clamped")
|
||||
-- an old peer that never sends OT leaves it nil (no worse than legacy; the
|
||||
-- load-time stampOT backfill then fills the receiver's own, as before)
|
||||
local legacy = Protocol.packMon(Pokemon.new(Data, "PIDGEY", 10))
|
||||
check(Protocol.unpackMon(Data, legacy).ot == nil, "missing OT stays nil (legacy peer)")
|
||||
|
||||
-- ---------------------------------------------------------------- transport
|
||||
local Net = require("src.link.Net")
|
||||
|
||||
@@ -254,6 +274,10 @@ end
|
||||
-- ---------------------------------------------------------------- trade session
|
||||
local partyA = { Pokemon.new(Data, "KADABRA", 30), Pokemon.new(Data, "PIDGEY", 10) }
|
||||
local partyB = { Pokemon.new(Data, "MACHOKE", 32) }
|
||||
-- distinct original trainers so a preserved OT is unmistakable after the swap
|
||||
-- (#215): A's KADABRA belongs to CULLEN/16012, B's MACHOKE to RED/60368
|
||||
partyA[1].ot = "CULLEN"; partyA[1].otId = 16012
|
||||
partyB[1].ot = "RED"; partyB[1].otId = 60368
|
||||
local tA = Protocol.TradeSession.new(Data, partyA)
|
||||
local tB = Protocol.TradeSession.new(Data, partyB)
|
||||
tA:handle({ type = "party", mons = Protocol.packParty(partyB) })
|
||||
@@ -272,9 +296,14 @@ eq(tA.stage, "done", "trade completes")
|
||||
local gotMon, evoTo = tA:apply(nil)
|
||||
eq(gotMon.species, "MACHOKE", "A received Machoke")
|
||||
eq(evoTo, "MACHAMP", "trade evolution triggers (Machoke -> Machamp)")
|
||||
-- the received mon keeps its SENDER's OT, not the receiver's (#215)
|
||||
eq(gotMon.ot, "RED", "A's received Machoke keeps sender B's OT name")
|
||||
eq(gotMon.otId, 60368, "A's received Machoke keeps sender B's OT ID")
|
||||
local gotMon2, evoTo2 = tB:apply(nil)
|
||||
eq(gotMon2.species, "KADABRA", "B received Kadabra")
|
||||
eq(evoTo2, "ALAKAZAM", "Kadabra -> Alakazam on trade")
|
||||
eq(gotMon2.ot, "CULLEN", "B's received Kadabra keeps sender A's OT name")
|
||||
eq(gotMon2.otId, 16012, "B's received Kadabra keeps sender A's OT ID")
|
||||
|
||||
-- declined trades cancel
|
||||
local tC = Protocol.TradeSession.new(Data, partyA)
|
||||
@@ -398,6 +427,52 @@ eq(battleG.player.mon.stats.hp, expectedStats.hp,
|
||||
eq(gameG.save.party[1].level, 12, "the real save data keeps its actual level")
|
||||
eq(gameH.save.party[1].level, 100, "...on both sides")
|
||||
|
||||
-- ---------------------------------------------------------------- "ANY" level ruling (#204)
|
||||
-- The link "level ruling" picker cycles a string sentinel "ANY" meaning
|
||||
-- "use each mon's real level" (Gen1 link cable always used the real level).
|
||||
-- LinkState/Tournament.levelForWire turns that sentinel into nil on the wire;
|
||||
-- a broken `x and nil or y` idiom used to let the literal "ANY" through into
|
||||
-- opts.forceLevel, and Protocol.unpackMon then called math.floor("ANY") ->
|
||||
-- "bad argument #1 to 'floor' (number expected, got string)", crashing the
|
||||
-- host the instant parties were unpacked in newHost (see the #204 report's
|
||||
-- traceback: unpackMon <- unpackParty <- newHost <- LinkState.update).
|
||||
-- unpackMon now coerces forceLevel with tonumber, so any non-numeric level
|
||||
-- string means "no forced level" and the mon keeps its packed real level.
|
||||
local anyPk = Protocol.packMon(Pokemon.new(Data, "PIKACHU", 12))
|
||||
local okAny, monAny = pcall(Protocol.unpackMon, Data, anyPk, { forceLevel = "ANY" })
|
||||
check(okAny, "unpackMon does not crash on the ANY sentinel string")
|
||||
eq(okAny and monAny and monAny.level, 12, "ANY forceLevel keeps the mon's real level (12)")
|
||||
local okStr, monStr = pcall(Protocol.unpackMon, Data, anyPk, { forceLevel = "50" })
|
||||
eq(okStr and monStr and monStr.level, 50, "a numeric-string forceLevel is still honored (50)")
|
||||
local okNil, monNil = pcall(Protocol.unpackMon, Data, anyPk, { forceLevel = nil })
|
||||
eq(okNil and monNil and monNil.level, 12, "no forceLevel keeps the real level (12)")
|
||||
|
||||
-- end-to-end host path from the crash report: newHost -> unpackParty ->
|
||||
-- unpackMon, with the exact bad value the bug emitted (forceLevel = "ANY").
|
||||
local gameI = makeFakeGame("PIKACHU")
|
||||
gameI.save.party[1] = Pokemon.new(Data, "PIKACHU", 12)
|
||||
local gameJ = makeFakeGame("GEODUDE")
|
||||
gameJ.save.party[1] = Pokemon.new(Data, "GEODUDE", 100)
|
||||
gameJ.save.player.name = "BLUE"
|
||||
local netI, netJ = Net.loopbackPair()
|
||||
local packedI = Protocol.packParty(gameI.save.party)
|
||||
local packedJ = Protocol.packParty(gameJ.save.party)
|
||||
local seedIJ = 24680
|
||||
local okHost, battleI = pcall(LinkBattle.newHost, gameI, netI, {
|
||||
myParty = packedI, theirParty = packedJ, theirName = "BLUE", seed = seedIJ,
|
||||
forceLevel = "ANY",
|
||||
})
|
||||
local okGuest, battleJ = pcall(LinkBattle.newGuest, gameJ, netJ, {
|
||||
myParty = packedJ, theirParty = packedI, theirName = "RED", seed = seedIJ,
|
||||
forceLevel = "ANY",
|
||||
})
|
||||
check(okHost and battleI ~= nil, "newHost does not crash with the ANY ruling")
|
||||
check(okGuest and battleJ ~= nil, "newGuest does not crash with the ANY ruling")
|
||||
eq(okHost and battleI and battleI.player.mon.level, 12,
|
||||
"ANY ruling: the host keeps its mon's real level (12, not forced)")
|
||||
eq(okHost and battleI and battleI.enemy.mon.level, 100,
|
||||
"ANY ruling: the enemy mon keeps its real level (100, not forced)")
|
||||
|
||||
-- ---------------------------------------------------------------- tournament shot clock
|
||||
-- opts.turnLimit only applies to tournament matches; the guest mashes
|
||||
-- through its own menu every frame while the host never presses anything,
|
||||
|
||||
+27
-4
@@ -1244,7 +1244,9 @@ do
|
||||
eb2.participants = { [Game.save.party[1]] = true }
|
||||
eb2:enemyMonFainted()
|
||||
local boosted = Experience.gainFor(Data.pokemon.RATTATA, 10, false, 1, true)
|
||||
check(hasText(eb2, ("BULBASAUR gained\na boosted\n%d EXP. Points!"):format(boosted)),
|
||||
-- _BoostedText ends in the ROM CONT code \v ("a boosted\011"), so the box
|
||||
-- waits + scrolls the amount into view rather than drawing it off-screen (#216)
|
||||
check(hasText(eb2, ("BULBASAUR gained\na boosted\v%d EXP. Points!"):format(boosted)),
|
||||
"_BoostedText tail for traded mons")
|
||||
|
||||
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 30) }
|
||||
@@ -1253,7 +1255,8 @@ do
|
||||
eb3.participants = { [Game.save.party[1]] = true }
|
||||
eb3:enemyMonFainted()
|
||||
local share = Experience.gainFor(Data.pokemon.RATTATA, 10, false, 2, false)
|
||||
check(hasText(eb3, ("BULBASAUR gained\nwith EXP.ALL,\n%d EXP. Points!"):format(share)),
|
||||
-- _WithExpAllText likewise ends in the CONT code \v ("with EXP.ALL,\011") (#216)
|
||||
check(hasText(eb3, ("BULBASAUR gained\nwith EXP.ALL,\v%d EXP. Points!"):format(share)),
|
||||
"_WithExpAllText tail on the EXP.ALL pass")
|
||||
check(not hasText(eb3, "divided"), "no invented EXP.ALL summary line")
|
||||
Game.save.inventory.EXP_ALL = nil
|
||||
@@ -1873,6 +1876,25 @@ do
|
||||
eq(vw, 276, "world fill width covers the unit window at pixel scale 7")
|
||||
eq(vh, 156, "world fill height covers the unit window at pixel scale 7")
|
||||
|
||||
-- #208: on a dual-screen surface (AYN Thor, forced landscape) LOVE can
|
||||
-- report drawable == unit size (pw/ww == 1) while its real coordinate->pixel
|
||||
-- transform is 1.5. fitScale stays keyed off the drawable pixel size, but
|
||||
-- the unit<->pixel conversion must use the REAL getDPIScale, not pw/ww, so
|
||||
-- each GB pixel still lands on a WHOLE number of physical pixels (square) --
|
||||
-- not the stretched 6-vs-9-device-px fractional pixels the reporter saw.
|
||||
-- Before the fix drawScale()==Sp/(pw/ww)==7 and 7*1.5==10.5 px/GB px.
|
||||
Zoom.reset()
|
||||
g.getDimensions = function() return 1920, 1080 end
|
||||
g.getPixelDimensions = function() return 1920, 1080 end
|
||||
g.getDPIScale = function() return 1.5 end
|
||||
eq(Renderer:fitScale(), 7,
|
||||
"#208 divergent DPI: fitScale still 7 from drawable pixels")
|
||||
local physical = Renderer:drawScale() * g.getDPIScale()
|
||||
local rounded = math.floor(physical + 0.5)
|
||||
check(math.abs(physical - rounded) < 1e-9,
|
||||
"#208 GB pixel lands on a whole number of physical pixels (square)")
|
||||
eq(rounded, 7, "#208 each GB pixel covers exactly fitScale (7) physical px")
|
||||
|
||||
-- missing pixel API falls back to getDimensions (headless / old stub)
|
||||
g.getPixelDimensions = nil
|
||||
g.getDPIScale = nil
|
||||
@@ -2765,6 +2787,8 @@ do
|
||||
end
|
||||
|
||||
-- Battle message lines skip a tile row (14 then 16), matching the menu.
|
||||
-- The box renders the rolling 2-line window (self.shown); #216 reworked the
|
||||
-- renderer so a 3rd line scrolls into view instead of drawing at y=144.
|
||||
do
|
||||
local Font = require("src.render.Font")
|
||||
local ys, origCode, origBox = {}, Font.drawCode, Font.drawBox
|
||||
@@ -2773,8 +2797,7 @@ do
|
||||
local battle = setmetatable({
|
||||
phase = "messages",
|
||||
current = true,
|
||||
charIndex = 999,
|
||||
lines = { { 0x80 }, { 0x81 } },
|
||||
shown = { { 0x80 }, { 0x81 } },
|
||||
}, BattleState)
|
||||
battle:drawTextArea()
|
||||
Font.drawCode, Font.drawBox = origCode, origBox
|
||||
|
||||
+27
-8
@@ -313,15 +313,34 @@ def parse_card_key_doors(pokered):
|
||||
util.die(f"card_key.asm: {needle!r} not found (door tiles/blocks changed?)")
|
||||
if n_doors != 22 or len(maps) != 10:
|
||||
util.die(f"card key doors: expected 22 doors / 10 maps, got {n_doors}/{len(maps)}")
|
||||
# closedDoors is hand-ported, not extracted: no retail .blk layout places
|
||||
# a closed-door block at any of the coordinates above (the feature is
|
||||
# unused/cut in the original game), so there is no ROM or disassembly
|
||||
# source to derive this from. It restores that cut content by stamping
|
||||
# facility.bst blocks 0x54/0x5f (2F-10F) or interior.bst block 0x20
|
||||
# (11F) over each door on map load, opened by that door's
|
||||
# EVENT_SILPH_CO_n_UNLOCKED_DOORn flag. Kept in sync by hand with
|
||||
# tools/rom_manifest.json's field.cardKeyDoors.closedDoors.
|
||||
# closedDoors is hand-ported, not extracted: the .blk layouts ship with
|
||||
# these doorways open and each floor's map script stamps the closed block
|
||||
# on load, which the extractor cannot see from static map bytes. Kept in
|
||||
# sync by hand with tools/rom_manifest.json's field.cardKeyDoors.closedDoors.
|
||||
#
|
||||
# ROCKET_HIDEOUT_* are the elevator lift gates, live in retail: the
|
||||
# RocketHideoutB1F/B4F DoorCallbackScripts (scripts/RocketHideoutB1F.asm,
|
||||
# RocketHideoutB4F.asm) ReplaceTileBlock a barred facility block over the
|
||||
# lift doorway with floor block 0x0e until the guards are beaten. B1F
|
||||
# stamps 0x54 at (12,8), opened by EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4
|
||||
# (EVENT_ENTERED_ROCKET_HIDEOUT is never SetEvent -- the noted SFX bug --
|
||||
# so it is not part of the open condition). B4F stamps 0x2d at (12,5),
|
||||
# opened once BOTH EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0 and _1 are set
|
||||
# (CheckBothEventsSet). B2F/B3F call no door callback (spinner floors).
|
||||
#
|
||||
# SILPH_CO_* restore cut content instead: no retail .blk places a closed
|
||||
# block at those coords and no retail script stamps them (the feature is
|
||||
# unused in the original game), stamping facility.bst blocks 0x54/0x5f
|
||||
# (2F-10F) or interior.bst block 0x20 (11F) over each door, opened by that
|
||||
# door's EVENT_SILPH_CO_n_UNLOCKED_DOORn flag.
|
||||
closed_doors = {
|
||||
"ROCKET_HIDEOUT_B1F": [
|
||||
{"block": 0x54, "bx": 12, "by": 8, "event": "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4", "open": 0x0e},
|
||||
],
|
||||
"ROCKET_HIDEOUT_B4F": [
|
||||
{"block": 0x2d, "bx": 12, "by": 5,
|
||||
"events": ["EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0", "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1"], "open": 0x0e},
|
||||
],
|
||||
"SILPH_CO_2F": [
|
||||
{"block": 0x54, "bx": 2, "by": 2, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR1", "open": 0x0e},
|
||||
{"block": 0x54, "bx": 2, "by": 5, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR2", "open": 0x0e},
|
||||
|
||||
+23
-2
@@ -4254,6 +4254,27 @@
|
||||
},
|
||||
"cardKeyDoors": {
|
||||
"closedDoors": {
|
||||
"ROCKET_HIDEOUT_B1F": [
|
||||
{
|
||||
"block": 84,
|
||||
"bx": 12,
|
||||
"by": 8,
|
||||
"event": "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4",
|
||||
"open": 14
|
||||
}
|
||||
],
|
||||
"ROCKET_HIDEOUT_B4F": [
|
||||
{
|
||||
"block": 45,
|
||||
"bx": 12,
|
||||
"by": 5,
|
||||
"events": [
|
||||
"EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0",
|
||||
"EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1"
|
||||
],
|
||||
"open": 14
|
||||
}
|
||||
],
|
||||
"SILPH_CO_2F": [
|
||||
{
|
||||
"block": 84,
|
||||
@@ -6777,7 +6798,7 @@
|
||||
"x": 18,
|
||||
"y": 6
|
||||
},
|
||||
"showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_3",
|
||||
"showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_5",
|
||||
"x": 19,
|
||||
"y": 6
|
||||
},
|
||||
@@ -6788,7 +6809,7 @@
|
||||
"x": 19,
|
||||
"y": 6
|
||||
},
|
||||
"showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_4",
|
||||
"showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_6",
|
||||
"x": 22,
|
||||
"y": 6
|
||||
}
|
||||
|
||||
@@ -4254,6 +4254,27 @@
|
||||
},
|
||||
"cardKeyDoors": {
|
||||
"closedDoors": {
|
||||
"ROCKET_HIDEOUT_B1F": [
|
||||
{
|
||||
"block": 84,
|
||||
"bx": 12,
|
||||
"by": 8,
|
||||
"event": "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4",
|
||||
"open": 14
|
||||
}
|
||||
],
|
||||
"ROCKET_HIDEOUT_B4F": [
|
||||
{
|
||||
"block": 45,
|
||||
"bx": 12,
|
||||
"by": 5,
|
||||
"events": [
|
||||
"EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0",
|
||||
"EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1"
|
||||
],
|
||||
"open": 14
|
||||
}
|
||||
],
|
||||
"SILPH_CO_10F": [
|
||||
{
|
||||
"block": 84,
|
||||
@@ -6754,7 +6775,7 @@
|
||||
"x": 18,
|
||||
"y": 6
|
||||
},
|
||||
"showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_3",
|
||||
"showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_5",
|
||||
"x": 19,
|
||||
"y": 6
|
||||
},
|
||||
@@ -6765,7 +6786,7 @@
|
||||
"x": 19,
|
||||
"y": 6
|
||||
},
|
||||
"showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_4",
|
||||
"showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_6",
|
||||
"x": 22,
|
||||
"y": 6
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user