mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18b2bcd0a7 | |||
| f56970350a | |||
| 863f371e68 | |||
| 7def560726 |
@@ -16,9 +16,13 @@ local function push(game, s, done)
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- PrintText on a text_end string returns with the box still drawn and
|
||||
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
|
||||
-- engine/menus/text_box.asm); no A press clears the question first. Ride
|
||||
-- TextBox's opts.choice, the same as Commands.ask (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
M.PEWTER_CITY = {
|
||||
|
||||
@@ -23,9 +23,13 @@ local function push(game, s, done)
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- PrintText on a text_end string returns with the box still drawn and
|
||||
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
|
||||
-- engine/menus/text_box.asm); no A press clears the question first. Ride
|
||||
-- TextBox's opts.choice, the same as Commands.ask (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
M.VIRIDIAN_CITY = {
|
||||
|
||||
+45
-12
@@ -517,6 +517,14 @@ M.GAME_CORNER = {
|
||||
done()
|
||||
return
|
||||
end
|
||||
-- GameCornerRocketText hands the battle its own loss line through
|
||||
-- SaveEndBattleTextPointers (.BattleEndText ->
|
||||
-- _GameCornerRocketBattleEndText, "Dang!"), and PrintEndBattleText
|
||||
-- prints it ON the battle screen between TrainerDefeatedText and
|
||||
-- MoneyForWinningText (engine/battle/core.asm TrainerBattleVictory).
|
||||
-- He is a text_asm trainer with no def_trainers header, so there is no
|
||||
-- header.won for engageTrainer to find and the line has to be handed
|
||||
-- over here or it never shows at all (#862).
|
||||
ow:engageTrainer(npc, function()
|
||||
if not ow:trainerDefeated(npc) then
|
||||
done()
|
||||
@@ -527,19 +535,44 @@ M.GAME_CORNER = {
|
||||
game.data.text._GameCornerRocketAfterBattleText
|
||||
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
|
||||
function()
|
||||
-- #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)
|
||||
-- #198/#862: GameCornerRocketBattleScript (scripts/GameCorner.asm)
|
||||
-- picks the exit walk from where the player is standing, because
|
||||
-- the grunt on (9,5) has to get past him: wYCoord == 6 (talked to
|
||||
-- from the south) or wXCoord == 8 (from the west) leaves the row
|
||||
-- clear and takes GameCornerMovement_Rocket_WalkDirect, five steps
|
||||
-- RIGHT; otherwise the player is east of him on (10,5) and
|
||||
-- GameCornerMovement_Rocket_WalkAroundPlayer steps DOWN, right, UP
|
||||
-- and right again to go AROUND him. pokeyellow's copy of the
|
||||
-- around-path takes one extra RIGHT on the lower row before coming
|
||||
-- back up (it also has to clear Pikachu); both versions end on
|
||||
-- (15,5). He never steps UP: (9,4) is the poster wall, which is
|
||||
-- where the old single UP step sent him.
|
||||
local px = ow.player and ow.player.cellX
|
||||
local py = ow.player and ow.player.cellY
|
||||
local path
|
||||
if py == 6 or px == 8 then
|
||||
path = { { "right", 5 } }
|
||||
elseif require("src.core.GameVersion").isYellow() then
|
||||
path = { { "down", 1 }, { "right", 3 }, { "up", 1 }, { "right", 3 } }
|
||||
else
|
||||
path = { { "down", 1 }, { "right", 2 }, { "up", 1 }, { "right", 4 } }
|
||||
end
|
||||
-- GameCornerRocketExitScript only HideObjects him once
|
||||
-- BIT_SCRIPTED_NPC_MOVEMENT clears, i.e. after the last step.
|
||||
-- scriptMove locks player input (#scriptMoves>0) and ignores
|
||||
-- collision, so the despawn + unfreeze (done) ride the final step.
|
||||
local function step(i)
|
||||
if i > #path then
|
||||
hideRocket()
|
||||
done()
|
||||
return
|
||||
end
|
||||
ow:scriptMove(npc, path[i][1], path[i][2],
|
||||
function() step(i + 1) end)
|
||||
end
|
||||
step(1)
|
||||
end))
|
||||
end)
|
||||
end, game.data.text._GameCornerRocketBattleEndText or "Dang!")
|
||||
end,
|
||||
-- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a
|
||||
-- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the
|
||||
|
||||
+11
-2
@@ -13,9 +13,18 @@ local function push(game, s, done)
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- The question stays on screen under the YES/NO menu. The dojo prize
|
||||
-- balls are the clearest case: FightingDojoHitmonleePokeBallText
|
||||
-- (scripts/FightingDojo.asm) is `call PrintText` on a text_end string --
|
||||
-- no prompt, so no WaitForTextScrollButtonPress -- immediately followed
|
||||
-- by `call YesNoChoice`, and InitYesNoTextBoxParameters
|
||||
-- (engine/menus/text_box.asm) puts the menu above the dialogue box
|
||||
-- rather than replacing it. Ride TextBox's opts.choice, the same as
|
||||
-- Commands.ask, instead of popping the box with an A press and leaving a
|
||||
-- bare ChoiceBox over the overworld (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
-- fill the extracted text placeholders ({NUM:...}, {RAM:...}, {PLAYER})
|
||||
|
||||
@@ -12,9 +12,13 @@ local function push(game, s, done)
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- PrintText on a text_end string returns with the box still drawn and
|
||||
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
|
||||
-- engine/menus/text_box.asm); no A press clears the question first. Ride
|
||||
-- TextBox's opts.choice, the same as Commands.ask (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
@@ -62,10 +62,15 @@ M.MT_MOON_B2F = {
|
||||
{ "walk_npc", 6, { "left", "left", "left", "left", "left" } },
|
||||
{ "face_object", 6, "left" },
|
||||
{ "show_text", "_MtMoonJessieJamesText2" },
|
||||
-- MtMoonB2FScript12 arms _MtMoonJessieJamesText3 with
|
||||
-- SaveEndBattleTextPointers before it sets wCurOpponent, so
|
||||
-- TrainerBattleVictory prints it on the battle screen as "ROCKET: A
|
||||
-- brat beat us?" between TrainerDefeatedText and MoneyForWinningText.
|
||||
-- Its one-word first line only reads right behind that tag (#866).
|
||||
{ "save_end_battle_text", "_MtMoonJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_MtMoonJessieJamesText3" },
|
||||
{ "show_text", "_MtMoonJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
@@ -85,7 +90,8 @@ M.MT_MOON_B2F = {
|
||||
-- motto plays from off-screen FIRST, then the duo pops in at (25,10) /
|
||||
-- (24,10) and whichever of them shares the player's column ($18=24 or
|
||||
-- $19=25, EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT) walks the three
|
||||
-- tiles down to loom over the player while the other steps one. A loss
|
||||
-- tiles down to loom over the player while the other walks four and ends
|
||||
-- up beside him. A loss
|
||||
-- re-hides them (RocketHideoutB4FResetScripts via EVENT_6A0), so the
|
||||
-- trigger re-arms clean.
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -106,7 +112,7 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
if f.EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES then return false end
|
||||
-- ON_LEFT: player under James's column (25); movement data pairs
|
||||
-- RocketHideoutB4FJessieJamesMovementData_45605/45606 swap so the
|
||||
-- column-mate walks 3, the other 1.
|
||||
-- column-mate walks 3, the other 4.
|
||||
local onLeft = (x == 25)
|
||||
ow.runner:run({
|
||||
{ "stop_music" },
|
||||
@@ -116,16 +122,30 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
{ "emote", "player", "shock", 30 },
|
||||
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
|
||||
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
|
||||
-- James (object 2) then Jessie (object 3), Script4..Script9 order
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
|
||||
-- James (object 2) then Jessie (object 3), Script4..Script9 order.
|
||||
-- RocketHideoutB4FJessieJamesMovementData_45605 is a lone $4 that FALLS
|
||||
-- THROUGH into _45606 ($4 $4 $4 $ff), so MoveSprite_ (home/pathfinding.asm)
|
||||
-- reads _45605 as FOUR steps and _45606 as three; $4 is DOWN in Yellow's
|
||||
-- Func_5288 lookup (engine/overworld/movement.asm), which walks with no
|
||||
-- collision test. From (25,10)/(24,10) against a player on y=14 the
|
||||
-- column-mate stops three down, right above him, and the other walks the
|
||||
-- full four to stand alongside -- which is what the facings below assume.
|
||||
-- Reading _45605 as a single step stranded whoever was off-column three
|
||||
-- tiles away, so James never reached the player (#865).
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" }
|
||||
or { "down", "down", "down", "down" } },
|
||||
{ "face_object", 2, onLeft and "down" or "left" },
|
||||
{ "walk_npc", 3, onLeft and { "down" } or { "down", "down", "down" } },
|
||||
{ "walk_npc", 3, onLeft and { "down", "down", "down", "down" }
|
||||
or { "down", "down", "down" } },
|
||||
{ "face_object", 3, onLeft and "right" or "down" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText2" },
|
||||
-- RocketHideoutB4FScript10 saves _RocketHideoutJessieJamesText3 as the
|
||||
-- end-battle text, so it prints as "ROCKET: Such a dreadful twerp!" on
|
||||
-- the battle screen ahead of MoneyForWinningText (#866).
|
||||
{ "save_end_battle_text", "_RocketHideoutJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 43 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "lost" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText3" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
@@ -175,16 +195,27 @@ M.POKEMON_TOWER_7F = {
|
||||
{ "show_text", "_PokemonTowerJessieJamesText1" },
|
||||
{ "face_player_dir", "up" },
|
||||
{ "emote", "player", "shock", 30 },
|
||||
-- Jessie (object 1) then James (object 2), Script1..Script6 order
|
||||
{ "walk_npc", 1, onLeft and { "down" } or { "down", "down", "down" } },
|
||||
-- Jessie (object 1) then James (object 2), Script1..Script6 order.
|
||||
-- Same fall-through blob as the hideout: PokemonTower7FMovementData_60d7a
|
||||
-- is a lone $4 running into _60d7b ($4 $4 $4 $FF), so _60d7a is FOUR
|
||||
-- steps and _60d7b is three. From (10,8)/(11,8) against a player on
|
||||
-- y=12 the column-mate halts one tile above him and the other closes the
|
||||
-- full four to his side; the single-step reading is why James only
|
||||
-- "moved a bit" here (#865).
|
||||
{ "walk_npc", 1, onLeft and { "down", "down", "down", "down" }
|
||||
or { "down", "down", "down" } },
|
||||
{ "face_object", 1, onLeft and "right" or "down" },
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" }
|
||||
or { "down", "down", "down", "down" } },
|
||||
{ "face_object", 2, onLeft and "down" or "left" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText2" },
|
||||
-- PokemonTower7FScript7 saves _PokemonTowerJessieJamesText3 as the
|
||||
-- end-battle text: "ROCKET: You will regret this!" on the battle screen,
|
||||
-- before the prize money (#866).
|
||||
{ "save_end_battle_text", "_PokemonTowerJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 44 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText3" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
@@ -254,10 +285,12 @@ M.SILPH_CO_11F = {
|
||||
{ "walk_npc", 6, jessieDirs },
|
||||
{ "face_object", 6, jessieFace },
|
||||
{ "show_text", "_SilphCoJessieJamesText2" },
|
||||
-- SilphCo11FScript11 saves _SilphCoJessieJamesText3 (SilphCo11FText_624c2)
|
||||
-- as the end-battle text: "ROCKET: Like always..." before the money (#866).
|
||||
{ "save_end_battle_text", "_SilphCoJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 45 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_SilphCoJessieJamesText3" },
|
||||
{ "show_text", "_SilphCoJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
|
||||
@@ -332,6 +332,26 @@ one used sideways. An `options.lua` from before this split keeps its single
|
||||
layout in both orientations until one of them is edited. In-game, Options →
|
||||
**TOUCH PAD** toggles the same on/off flag without leaving a play session.
|
||||
|
||||
## Haptic feedback (mobile)
|
||||
|
||||
Options → **VIBRATION** (also in the launcher's gear menu) buzzes the device
|
||||
the instant an on-screen control takes a button (#806). A glass pad has no
|
||||
edges under a thumb, so the pulse is what tells you the press landed:
|
||||
sliding the d-pad from one direction to the next buzzes again, a second
|
||||
finger landing on a button that is already held does not, and releasing
|
||||
never does.
|
||||
|
||||
Four levels: **OFF**, **LIGHT** (the default), **MEDIUM**, **HEAVY**.
|
||||
"Intensity" is really a pulse length -- the platform call takes a duration
|
||||
and nothing else -- so LIGHT is a 12 ms tick, MEDIUM 25 ms, HEAVY 45 ms.
|
||||
Stepping the row fires one sample pulse at the level you land on, so the
|
||||
three can be compared without leaving the menu. On iOS the system
|
||||
vibration has one fixed length, so all three levels feel the same there and
|
||||
the row is effectively on/off. The setting lives in `options.lua` and the
|
||||
row only appears where the on-screen pad can (Android/iOS, or desktop with
|
||||
`POKEPORT_TOUCH=1`, where it does nothing since desktop LOVE has no
|
||||
vibrator).
|
||||
|
||||
## Screen orientation lock (Android)
|
||||
|
||||
Options → **ORIENTATION** (also in the launcher's gear menu) locks the
|
||||
@@ -585,6 +605,11 @@ settings gear and pulses when an update is waiting, instead of sitting in a
|
||||
banner at the bottom of the page that you had to scroll to notice. Checking
|
||||
for updates from there shows a loader like everything else.
|
||||
|
||||
**A quit button.** An X sits to the right of the settings gear and closes
|
||||
the app cleanly, the same shutdown path as the window's close button. Mostly
|
||||
for platforms where reaching the window chrome is awkward (Android, Steam
|
||||
Deck, fullscreen desktops).
|
||||
|
||||
**The look.** Black background, white outlines, no gradients or glows, and
|
||||
buttons that are solid colour-coded keys: green commits, blue navigates, red
|
||||
destroys, yellow wants attention. The three game tabs keep their red, blue
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.70",
|
||||
"date": "2026-08-05",
|
||||
"size": 9562731,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #590 Launcher/Save Editor/Dex\n- #788 Fly to the Pokemon Centers in the Routes is not available in original\n- #795 Fly logic is drastically different from the originals.\n- #796 Using Rare Candy from the menu closes it out.\n- #797 Gym Leaders giving items when your bag is full / Bypassing bag limit.\n- #805 Escape Rope Moltres Tower\n- #826 \"Super effective\" and \"not very effective\" SFX are reversed\n- #833 Cancelling nickname entry results in \"A\" as the nickname\n- #835 Restarting the launcher forgets the last rom used\n- #837 Wrong sound effect for Pikachu when entering battle\n- #844 Blizzard sound effect.\n- #845 Moderate issue: Fuchsia City binoculars.\n- #846 Surfing speed after using the bicycle.\n- #847 Minor issues related to the endgame.\n\n## Contributors\n\n- @bryanthaboi\n- @dburton95\n- @johnjohto\n- @KikiManjaro"
|
||||
},
|
||||
{
|
||||
"version": "0.1.69",
|
||||
"date": "2026-08-04",
|
||||
|
||||
@@ -3188,6 +3188,16 @@ function BattleState:executeAction(user, target, action)
|
||||
user.boundTurns = target.trappingTurns
|
||||
and math.max(1, target.trappingTurns) or nil
|
||||
|
||||
-- wPlayerSelectedMove / wEnemySelectedMove as the status gauntlet
|
||||
-- reads it: the locked specials keep continuing the move they
|
||||
-- started, so they carry a move id too. Resolved once here and
|
||||
-- handed to every statusInterrupt below, which is where
|
||||
-- .TriedToUseDisabledMoveCheck lives (#860).
|
||||
local selectedId = action.id
|
||||
or (action.special == "trapping" and user.trapMove)
|
||||
or (action.special == "bide" and "BIDE")
|
||||
or nil
|
||||
|
||||
-- trainer class AI actions (engine/battle/trainer_ai.asm)
|
||||
if action.special == "aiItem" then
|
||||
self.aiUses = (self.aiUses or 1) - 1
|
||||
@@ -3245,17 +3255,17 @@ function BattleState:executeAction(user, target, action)
|
||||
return
|
||||
end
|
||||
if action.special == "trapping" then
|
||||
if self:statusInterrupt(user, target) then return end
|
||||
if self:statusInterrupt(user, target, selectedId) then return end
|
||||
self:continueTrapping(user, target)
|
||||
return
|
||||
end
|
||||
if action.special == "bide" then
|
||||
if self:statusInterrupt(user, target) then return end
|
||||
if self:statusInterrupt(user, target, selectedId) then return end
|
||||
self:continueBide(user, target)
|
||||
return
|
||||
end
|
||||
|
||||
if self:statusInterrupt(user, target) then return end
|
||||
if self:statusInterrupt(user, target, selectedId) then return end
|
||||
self:performMove(user, target, action, false)
|
||||
end
|
||||
run()
|
||||
@@ -3349,8 +3359,8 @@ end
|
||||
|
||||
-- Runs Status.beforeMove plus the shared interruption bookkeeping;
|
||||
-- returns true when the user's action is interrupted.
|
||||
function BattleState:statusInterrupt(user, target)
|
||||
local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self)
|
||||
function BattleState:statusInterrupt(user, target, selectedId)
|
||||
local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self, selectedId)
|
||||
for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end
|
||||
if selfHit then
|
||||
-- confusion self-hit (core.asm:3428-3434): clears everything in
|
||||
|
||||
+23
-1
@@ -168,7 +168,7 @@ end
|
||||
-- The active status record's beforeMove runs at its priority slot: above
|
||||
-- VOLATILE_PRIORITY before the held/disable/confusion block (sleep,
|
||||
-- freeze), at or below after it (paralysis) -- the original's order.
|
||||
function Status.beforeMove(battler, rng, battle)
|
||||
function Status.beforeMove(battler, rng, battle, selectedMoveId)
|
||||
local mon = battler.mon
|
||||
-- Haze curing this mon's sleep/freeze forfeits its pending move for
|
||||
-- the turn, silently (haze.asm writes $ff/CANNOT_MOVE to the selected
|
||||
@@ -225,6 +225,28 @@ function Status.beforeMove(battler, rng, battle)
|
||||
end
|
||||
end
|
||||
end
|
||||
-- .TriedToUseDisabledMoveCheck (engine/battle/core.asm, and the enemy
|
||||
-- copy .checkIfTriedToUseDisabledMove): the disabled-move test runs at
|
||||
-- EXECUTION time, comparing wPlayerDisabledMoveNumber against the
|
||||
-- already SELECTED move, so a Disable that lands earlier in the same
|
||||
-- turn still blocks the slower mon's move (#860). It sits after the
|
||||
-- confusion block and before the paralysis roll, so a confusion self-hit
|
||||
-- still pre-empts it and the paralysis roll is never spent on a turn the
|
||||
-- disable eats. PrintMoveIsDisabledText clears CHARGING_UP before
|
||||
-- printing, so a disabled charge move drops its stored turn instead of
|
||||
-- releasing later.
|
||||
if selectedMoveId and battler.disabledSlot then
|
||||
local disabled = (battler.curMoves or {})[battler.disabledSlot]
|
||||
if disabled and disabled.id == selectedMoveId then
|
||||
battler.charging, battler.chargeReady = nil, nil
|
||||
local moves = battle and battle.data and battle.data.moves
|
||||
local shown = moves and moves[selectedMoveId] and moves[selectedMoveId].name
|
||||
or tostring(selectedMoveId)
|
||||
table.insert(msgs, romText(battle and battle.data, "_MoveIsDisabledText",
|
||||
"%s's\n%s is\ndisabled!", name(battler), shown))
|
||||
return false, msgs
|
||||
end
|
||||
end
|
||||
if handler then
|
||||
local canMove, selfHit = runStatus()
|
||||
if not canMove or selfHit then return canMove, msgs, selfHit end
|
||||
|
||||
+53
-1
@@ -30,6 +30,15 @@ local SaveData = {}
|
||||
-- deliberately shared across versions (it holds global preferences and the
|
||||
-- mod enable-state, not per-playthrough data).
|
||||
local OPTIONS_FILENAME = "options.lua"
|
||||
-- #828: options.lua is rewritten whole on every write (see saveOptions), and
|
||||
-- unlike the progress files it had no staged copy, so a write interrupted
|
||||
-- between the truncate and the flush -- the process replaced by
|
||||
-- HostShell.restart on the way back to the launcher, an Android
|
||||
-- external-storage volume that never flushed -- left a truncated or empty
|
||||
-- file that loadOptions could only answer with defaults: every setting
|
||||
-- "reset" at once. Same .bak/.tmp witness names the save files use.
|
||||
local OPTIONS_BACKUP_FILENAME = OPTIONS_FILENAME .. ".bak"
|
||||
local OPTIONS_TMP_FILENAME = OPTIONS_FILENAME .. ".tmp"
|
||||
|
||||
-- Main / backup / staged-witness names for a version (defaults to the active
|
||||
-- one). The backup is a rolling copy and .tmp is the staged-write witness;
|
||||
@@ -305,6 +314,13 @@ function SaveData.defaultOptions()
|
||||
-- layout (#633). Pre-#633 files stored one top-level positions table;
|
||||
-- TouchControls.normalizeConfig folds it into both orientations on load.
|
||||
touchControls = { enabled = true },
|
||||
-- Haptic feedback level for on-screen pad presses (#806):
|
||||
-- off | light | medium | heavy, mapped to a love.system.vibrate
|
||||
-- duration in src/core/TouchControls.lua. LIGHT by default, like the
|
||||
-- overlay itself defaulting on, so an options.lua predating this key
|
||||
-- gets the tick without going looking for the row. Inert wherever the
|
||||
-- overlay never appears (desktop) or LOVE has no vibrator.
|
||||
haptics = "light",
|
||||
}
|
||||
end
|
||||
|
||||
@@ -369,7 +385,21 @@ function SaveData.saveOptions(opts, fs)
|
||||
opts.modOptions = merged
|
||||
end
|
||||
local encoded = SaveSerializer.encode(opts)
|
||||
local ok, err = fs.write(OPTIONS_FILENAME, encoded)
|
||||
-- Stage the new bytes and roll the last good file aside BEFORE the main
|
||||
-- write truncates it, the same tmp/bak dance SaveData.save uses for
|
||||
-- progress: whatever ends the process mid-write, one of the three copies
|
||||
-- is complete and loadOptions promotes it instead of falling back to
|
||||
-- defaults (#828).
|
||||
local ok, err = fs.write(OPTIONS_TMP_FILENAME, encoded)
|
||||
if not ok then
|
||||
Logger.error("options save failed: %s", tostring(err))
|
||||
return nil
|
||||
end
|
||||
local prev = fs.getInfo(OPTIONS_FILENAME) and fs.read(OPTIONS_FILENAME)
|
||||
if type(prev) == "string" and prev ~= "" and prev ~= encoded then
|
||||
fs.write(OPTIONS_BACKUP_FILENAME, prev)
|
||||
end
|
||||
ok, err = fs.write(OPTIONS_FILENAME, encoded)
|
||||
if not ok then
|
||||
Logger.error("options save failed: %s", tostring(err))
|
||||
return nil
|
||||
@@ -387,6 +417,8 @@ function SaveData.saveOptions(opts, fs)
|
||||
#encoded, type(wrote) == "string" and tostring(#wrote) or "nothing")
|
||||
return nil
|
||||
end
|
||||
-- the staged witness has served its purpose; the main file is verified
|
||||
remove(fs, OPTIONS_TMP_FILENAME)
|
||||
return opts
|
||||
end
|
||||
|
||||
@@ -397,6 +429,26 @@ function SaveData.loadOptions(fs)
|
||||
if fs.getInfo(OPTIONS_FILENAME) then
|
||||
Logger.error("options load failed: %s", tostring(err))
|
||||
end
|
||||
-- #828: answering defaults here is what "closing the game reset all my
|
||||
-- settings" looked like -- one interrupted whole-file rewrite and every
|
||||
-- preference, the mod enable-state and the slot registry were gone.
|
||||
-- Promote the staged copy, then the rolled-aside backup, exactly as
|
||||
-- SaveData.load does for progress, and heal the main file from whichever
|
||||
-- one parsed.
|
||||
local recovered = readTable(fs, OPTIONS_TMP_FILENAME)
|
||||
local from = "tmp"
|
||||
if not recovered then
|
||||
recovered = readTable(fs, OPTIONS_BACKUP_FILENAME)
|
||||
from = "bak"
|
||||
end
|
||||
if recovered then
|
||||
Logger.warn("options.lua %s; recovered from %s copy",
|
||||
fs.getInfo(OPTIONS_FILENAME) and "corrupt" or "missing", from)
|
||||
if fs.write then
|
||||
fs.write(OPTIONS_FILENAME, SaveSerializer.encode(recovered))
|
||||
end
|
||||
return SaveData.mergeOptions(recovered)
|
||||
end
|
||||
return SaveData.defaultOptions()
|
||||
end
|
||||
return SaveData.mergeOptions(data)
|
||||
|
||||
@@ -85,6 +85,55 @@ local function clampScale(v)
|
||||
return v
|
||||
end
|
||||
|
||||
-- Haptic feedback (#806): a short vibration the instant a control takes a GB
|
||||
-- button, the way every mobile emulator front-end does it -- the pad has no
|
||||
-- edges under a thumb, so the buzz is the only confirmation a press landed.
|
||||
-- Persisted as options.haptics (src/core/SaveData.lua defaultOptions), NOT
|
||||
-- under options.touchControls: TouchControls:config() is the launcher
|
||||
-- editor's save snapshot and only emits enabled + layouts, so a nested key
|
||||
-- would be dropped on every editor save.
|
||||
-- love.system.vibrate takes a duration and nothing else, so "intensity" is a
|
||||
-- duration preset: Android runs the platform vibrator for exactly that long,
|
||||
-- while iOS ignores the duration and fires the fixed system vibration, so
|
||||
-- there the three levels all read as simply on.
|
||||
TouchControls.HAPTICS = { "off", "light", "medium", "heavy" }
|
||||
TouchControls.HAPTIC_DEFAULT = "light"
|
||||
|
||||
local HAPTIC_SECONDS = { off = 0, light = 0.012, medium = 0.025, heavy = 0.045 }
|
||||
local HAPTIC_LABELS = {
|
||||
off = "OFF", light = "LIGHT", medium = "MEDIUM", heavy = "HEAVY",
|
||||
}
|
||||
|
||||
function TouchControls.normalizeHaptics(level)
|
||||
if HAPTIC_SECONDS[level] then return level end
|
||||
return TouchControls.HAPTIC_DEFAULT
|
||||
end
|
||||
|
||||
function TouchControls.hapticLabel(level)
|
||||
return HAPTIC_LABELS[TouchControls.normalizeHaptics(level)]
|
||||
end
|
||||
|
||||
function TouchControls.cycleHaptics(level, dir)
|
||||
local cur, idx = TouchControls.normalizeHaptics(level), 1
|
||||
for i, m in ipairs(TouchControls.HAPTICS) do
|
||||
if m == cur then idx = i break end
|
||||
end
|
||||
local n = #TouchControls.HAPTICS
|
||||
return TouchControls.HAPTICS[(idx - 1 + (dir or 1)) % n + 1]
|
||||
end
|
||||
|
||||
-- One pulse at the given level. Feature-guarded rather than platform-gated:
|
||||
-- love.system.vibrate is a no-op on desktop and absent from the headless love
|
||||
-- stubs, so the press path below stays identical everywhere and the tests
|
||||
-- never reach a vibrator.
|
||||
function TouchControls.buzz(level)
|
||||
local secs = HAPTIC_SECONDS[TouchControls.normalizeHaptics(level)]
|
||||
if not secs or secs <= 0 then return false end
|
||||
if not (love and love.system and love.system.vibrate) then return false end
|
||||
pcall(love.system.vibrate, secs)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Copy a persisted positions table, dropping unknown / non-numeric entries.
|
||||
-- Always a fresh table: two orientations seeded from the same pre-#633
|
||||
-- layout must not alias, or dragging one would still move the other.
|
||||
@@ -174,6 +223,10 @@ end
|
||||
function TouchControls:init()
|
||||
self.active = wantsOverlay()
|
||||
self.enabled = true
|
||||
-- vibration level for presses (#806); applyOptions overwrites it from
|
||||
-- options.haptics, this is the value a harness that never applies options
|
||||
-- runs with
|
||||
self.haptics = TouchControls.HAPTIC_DEFAULT
|
||||
-- per-orientation buckets (#633); self.positions / self.scale mirror the
|
||||
-- one currently on screen so layout(), the editor and the tests keep a
|
||||
-- single lookup
|
||||
@@ -210,6 +263,9 @@ end
|
||||
function TouchControls:applyOptions(opts)
|
||||
local cfg = TouchControls.normalizeConfig(opts and opts.touchControls)
|
||||
self.enabled = cfg.enabled
|
||||
-- haptics is a plain top-level option, not part of the layout config the
|
||||
-- launcher editor round-trips through config() (#806)
|
||||
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
|
||||
self.layouts = cfg.layouts
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.layoutOx, self.layoutOy = nil, nil
|
||||
@@ -401,7 +457,14 @@ end
|
||||
local function pressBtn(self, btn)
|
||||
local n = (self.held[btn] or 0) + 1
|
||||
self.held[btn] = n
|
||||
if n == 1 then Input:overlayPressed(btn) end
|
||||
-- Buzz only on the 0 -> 1 edge, the same edge that presses the GB button:
|
||||
-- a second finger landing on a button that is already held, and a d-pad
|
||||
-- finger resting inside one direction, must not retrigger it. Sliding the
|
||||
-- d-pad to a new direction does, which is the point (#806).
|
||||
if n == 1 then
|
||||
Input:overlayPressed(btn)
|
||||
TouchControls.buzz(self.haptics)
|
||||
end
|
||||
end
|
||||
|
||||
local function releaseBtn(self, btn)
|
||||
|
||||
@@ -242,6 +242,18 @@ local function coreRows(opts)
|
||||
opts.touchControls = tc
|
||||
return true
|
||||
end)
|
||||
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
|
||||
-- the row buzzes once at the level being selected.
|
||||
local okTC, TC = pcall(require, "src.core.TouchControls")
|
||||
if okTC then
|
||||
add(Strings("VIBRATION"),
|
||||
function() return Strings(TC.hapticLabel(opts.haptics)) end,
|
||||
function(dir)
|
||||
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
|
||||
TC.buzz(opts.haptics)
|
||||
return true
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -311,8 +311,20 @@ local function setPage(imp, key, v)
|
||||
imp._pages[key] = v
|
||||
end
|
||||
|
||||
-- A hand-drawn X, for the same reason drawCheck exists below: the UI font has
|
||||
-- no guaranteed glyph, and the launcher ships no icon asset for it.
|
||||
local function drawCross(x, y, size, color)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setColor(color)
|
||||
love.graphics.setLineWidth(math.max(2, size * 0.16))
|
||||
love.graphics.setLineJoin("bevel")
|
||||
love.graphics.line(x, y, x + size, y + size)
|
||||
love.graphics.line(x + size, y, x, y + size)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- header
|
||||
-- Rail, logo row (settings on the right), tab bar.
|
||||
-- Rail, logo row (settings and quit on the right), tab bar.
|
||||
-- Returns the y at which content may start. Its vertical arithmetic is
|
||||
-- mirrored by headerHeight() at the bottom of this file (the short-window
|
||||
-- scroll decision needs the height before anything draws) -- keep in sync.
|
||||
@@ -340,7 +352,14 @@ local function buildHeader(imp, m)
|
||||
local rx = m.x + m.w - m.pad
|
||||
local by = y + (rowH - gear) / 2
|
||||
|
||||
-- Settings gear, top-right corner.
|
||||
-- The right cluster is laid out right to left -- Quit outermost, the gear
|
||||
-- inboard of it -- but the two are REGISTERED gear first, because the first
|
||||
-- focusable of the first frame adopts the keyboard ring and that must not be
|
||||
-- the button that exits the app.
|
||||
local quitX = rx - gear
|
||||
rx = quitX - math.floor(6 * m.s)
|
||||
|
||||
-- Settings gear.
|
||||
imp._gearIcon = imp._gearIcon
|
||||
or love.graphics.newImage("assets/launcher/gear.png")
|
||||
rx = rx - gear
|
||||
@@ -365,6 +384,23 @@ local function buildHeader(imp, m)
|
||||
end
|
||||
end
|
||||
|
||||
-- Quit, top-right corner.
|
||||
do
|
||||
local x = quitX
|
||||
Kit._audit("control", x, by, gear, gear, "quit")
|
||||
local focused = Kit.focusable("quit", x, by, gear, gear)
|
||||
local hot = focused or Kit.hover(x, by, gear, gear)
|
||||
Theme.fill(x, by, gear, gear, hot and PAL.ink or PAL.bg, 1)
|
||||
Theme.stroke(x, by, gear, gear, PAL.line,
|
||||
hot and Theme.A.focus or Theme.A.hairline, 1)
|
||||
local pad = math.floor(gear * 0.32)
|
||||
drawCross(x + pad, by + pad, gear - 2 * pad,
|
||||
hot and { 0, 0, 0, 1 } or { 1, 1, 1, 0.85 })
|
||||
if Kit.press(x, by, gear, gear) or Kit._activateId == "quit" then
|
||||
queueAction(imp, "quit", function() imp:_quitApp() end)
|
||||
end
|
||||
end
|
||||
|
||||
-- The self-update control lives in the FOOTER next to the BCG mark (small,
|
||||
-- out of the wordmark's way -- it used to overlap the logo on a phone). It
|
||||
-- still GLOWS through Kit.button when there is something to act on.
|
||||
|
||||
@@ -2459,6 +2459,13 @@ function RomImporter:_openSettings()
|
||||
if ok and model then self._settings = model end
|
||||
end
|
||||
|
||||
-- Quit from the launcher's own X. It goes through love.event.quit so main.lua's
|
||||
-- love.quit hook still runs: that is where the worker threads are shut down
|
||||
-- (#339) and where a launcher close is told apart from a running game's (#785).
|
||||
function RomImporter:_quitApp()
|
||||
if love.event and love.event.quit then love.event.quit() end
|
||||
end
|
||||
|
||||
function RomImporter:_closeSettings()
|
||||
if self._settings then self._settings.save() end
|
||||
self._settings = nil
|
||||
|
||||
@@ -260,6 +260,26 @@ function Commands.take_item(ctx, itemId, count)
|
||||
if inv[itemId] == 0 then inv[itemId] = nil end
|
||||
end
|
||||
|
||||
-- save_end_battle_text <TEXT_KEY>: SaveEndBattleTextPointers
|
||||
-- (home/trainers.asm, called from e.g. RocketHideoutB4FScript10 just before
|
||||
-- wCurOpponent is set). The armed line is the trainer's OWN loss line and
|
||||
-- belongs on the battle screen: PrintEndBattleText (home/trainers.asm) runs
|
||||
-- from TrainerBattleVictory (engine/battle/core.asm) after
|
||||
-- TrainerDefeatedText and the pic scroll but BEFORE MoneyForWinningText, and
|
||||
-- TrainerEndBattleText prints _TrainerNameText first so the line opens with
|
||||
-- the "CLASS: " tag. Scripts that printed it with a plain show_text after
|
||||
-- start_battle got it a box too late -- after the payout -- and untagged
|
||||
-- (#866). Arms exactly one battle; start_battle consumes it.
|
||||
function Commands.save_end_battle_text(ctx, textId)
|
||||
local text = ctx.game.data.text[textId]
|
||||
if not text and ctx.overworld then
|
||||
text = ctx.game.data:resolveText(ctx.overworld.map.def.label, textId)
|
||||
end
|
||||
-- BattleState takes finished text, so expand {PLAYER}/{RIVAL} here the
|
||||
-- way OverworldState:engageTrainer does for the sight/talk path
|
||||
ctx.endBattleText = TextBox.substitute(ctx.game, text or textId)
|
||||
end
|
||||
|
||||
-- start_battle "wild" species level | start_battle "trainer" OPP_CLASS partyIndex
|
||||
function Commands.start_battle(ctx, kind, a, b)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
@@ -270,6 +290,9 @@ function Commands.start_battle(ctx, kind, a, b)
|
||||
else
|
||||
battle = BattleState.newTrainer(ctx.game, a, b)
|
||||
end
|
||||
-- one SaveEndBattleTextPointers arms one battle; leaving it set would leak
|
||||
-- the line into the next scripted fight
|
||||
battle.endBattleText, ctx.endBattleText = ctx.endBattleText, nil
|
||||
battle.onFinish = function(result)
|
||||
ctx.lastBattleResult = result
|
||||
ctx.lastCheck = result == "win"
|
||||
|
||||
+26
-3
@@ -437,6 +437,25 @@ local function buildRows(game)
|
||||
require("src.core.TouchControls"):applyOptions(o)
|
||||
return true
|
||||
end },
|
||||
-- Haptic feedback for on-screen pad presses (#806): OFF / LIGHT /
|
||||
-- MEDIUM / HEAVY, where the intensity is a vibration duration --
|
||||
-- love.system.vibrate takes nothing else. Hidden with TOUCH PAD below,
|
||||
-- since the only thing that buzzes is a virtual button press.
|
||||
{ id = "haptics", label = Strings("VIBRATION"),
|
||||
value = function(g)
|
||||
local TC = require("src.core.TouchControls")
|
||||
return Strings(TC.hapticLabel(g.save.options.haptics))
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
local TC = require("src.core.TouchControls")
|
||||
o.haptics = TC.cycleHaptics(o.haptics, dir)
|
||||
TC:applyOptions(o)
|
||||
-- sample the level being selected: stepping the row is the only way
|
||||
-- to compare LIGHT against HEAVY without leaving the menu
|
||||
TC.buzz(o.haptics)
|
||||
return true
|
||||
end },
|
||||
}
|
||||
-- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks
|
||||
if not GBCFX.isSupported() then
|
||||
@@ -454,8 +473,10 @@ local function buildRows(game)
|
||||
end
|
||||
rows = filtered
|
||||
end
|
||||
-- TOUCH PAD only where the overlay can appear (mobile, or desktop with
|
||||
-- POKEPORT_TOUCH=1). POKEPORT_TOUCH=0 forces it off everywhere.
|
||||
-- TOUCH PAD and VIBRATION only where the overlay can appear (mobile, or
|
||||
-- desktop with POKEPORT_TOUCH=1). POKEPORT_TOUCH=0 forces it off
|
||||
-- everywhere. VIBRATION rides the same gate: nothing else in the port
|
||||
-- vibrates, and love.system.vibrate is a no-op on desktop anyway.
|
||||
do
|
||||
local env = os.getenv("POKEPORT_TOUCH")
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
@@ -464,7 +485,9 @@ local function buildRows(game)
|
||||
if not show then
|
||||
local filtered = {}
|
||||
for _, row in ipairs(rows) do
|
||||
if row.id ~= "touchControls" then filtered[#filtered + 1] = row end
|
||||
if row.id ~= "touchControls" and row.id ~= "haptics" then
|
||||
filtered[#filtered + 1] = row
|
||||
end
|
||||
end
|
||||
rows = filtered
|
||||
end
|
||||
|
||||
@@ -1259,8 +1259,14 @@ end
|
||||
function OverworldState:checkBoulderPush(dir)
|
||||
local p = self.player
|
||||
local fx, fy = Collision.target(p.cellX, p.cellY, dir)
|
||||
local npc = self:npcAtCell(fx, fy)
|
||||
if not npc or not Map.isPushable(npc.def) or npc.moving then
|
||||
-- IsSpriteInFrontOfPlayer (home/overworld.asm) hands TryPushingBoulder
|
||||
-- the LOWEST sprite index standing on the faced cell, so in the original a
|
||||
-- second sprite parked on the boulder's cell hides the boulder from the
|
||||
-- push path for the rest of the map visit. Pick the pushable sprite out of
|
||||
-- the cell instead: a scripted walk-up that lands a trainer on the boulder
|
||||
-- must not brick it permanently (#809).
|
||||
local npc = self:pushableAtCell(fx, fy)
|
||||
if not npc or npc.moving then
|
||||
self.boulderTried = nil -- pokered resets when no boulder is in front
|
||||
return false
|
||||
end
|
||||
@@ -1720,6 +1726,22 @@ function OverworldState:npcAtCell(cx, cy)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The Strength boulder on a cell, ignoring anything else standing there.
|
||||
-- npcAtCell returns whichever object the map listed first, which is only
|
||||
-- well defined while at most one sprite occupies a cell; scripted walks
|
||||
-- (TrainerWalkUpToPlayer) can break that, and the push path must still find
|
||||
-- the boulder underneath (#809).
|
||||
function OverworldState:pushableAtCell(cx, cy)
|
||||
for _, npc in ipairs(self.npcs) do
|
||||
if ((npc.cellX == cx and npc.cellY == cy) or
|
||||
(npc.targetX == cx and npc.targetY == cy))
|
||||
and Map.isPushable(npc.def) then
|
||||
return npc
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- what the A press resolved to, for world.interacted's listeners
|
||||
local function interacted(self, fx, fy, kind, target)
|
||||
Runtime.emit("world.interacted", { mapId = self.map.id, x = fx, y = fy,
|
||||
@@ -2962,7 +2984,7 @@ local function meetTrainerTheme(cls)
|
||||
end
|
||||
|
||||
-- Run the pre-battle text -> battle -> won text -> flags sequence.
|
||||
function OverworldState:engageTrainer(npc, onDone)
|
||||
function OverworldState:engageTrainer(npc, onDone, endBattleText)
|
||||
local d = npc.def
|
||||
Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = d.trainerClass,
|
||||
partyIndex = d.trainerParty })
|
||||
@@ -2972,7 +2994,15 @@ function OverworldState:engageTrainer(npc, onDone)
|
||||
battleText = select(1, Game.data:resolveText(self.map.def.label, d.text))
|
||||
or Strings("I like shorts!\nThey're comfy and\neasy to wear!")
|
||||
end
|
||||
local wonText = header and header.won and Game.data.text[header.won]
|
||||
-- `endBattleText` is a caller-supplied stand-in for header.won: the
|
||||
-- text_asm trainers that hand their loss line to the battle through
|
||||
-- SaveEndBattleTextPointers (scripts/GameCorner.asm GameCornerRocketText
|
||||
-- passes _GameCornerRocketBattleEndText, "Dang!") have no def_trainers
|
||||
-- header for the extractor to read, so their script passes the finished
|
||||
-- line here and it still lands where PrintEndBattleText puts it -- between
|
||||
-- TrainerDefeatedText and MoneyForWinningText, on the battle screen (#862).
|
||||
local wonText = endBattleText
|
||||
or (header and header.won and Game.data.text[header.won])
|
||||
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
Game.stack:push(TextBox.new(Game, battleText, function()
|
||||
@@ -3241,8 +3271,26 @@ function OverworldState:startTrainerApproach(npc, dist)
|
||||
self.emote = {
|
||||
npc = npc, frames = 60,
|
||||
onDone = function()
|
||||
if dist > 1 then
|
||||
self:scriptMove(npc, npc.facing, dist - 1, fight)
|
||||
-- TrainerWalkUpToPlayer (engine/overworld/trainer_sight.asm) writes
|
||||
-- dist-1 NPC_MOVEMENT_* bytes and hands them to MoveSprite, and every
|
||||
-- scripted step skips collision entirely (CanWalkOntoTile,
|
||||
-- engine/overworld/movement.asm: "always allow walking if the
|
||||
-- movement is scripted"), so the original marches the trainer straight
|
||||
-- through a Strength boulder sitting on the sight line. Stop one cell
|
||||
-- short of the boulder instead: two sprites on one cell is a state the
|
||||
-- push path cannot represent, and the walk-up is the one scripted move
|
||||
-- the player can steer a boulder into (#809).
|
||||
local steps = dist - 1
|
||||
local cx, cy = npc.cellX, npc.cellY
|
||||
for i = 1, steps do
|
||||
cx, cy = Collision.target(cx, cy, npc.facing)
|
||||
if self:pushableAtCell(cx, cy) then
|
||||
steps = i - 1
|
||||
break
|
||||
end
|
||||
end
|
||||
if steps > 0 then
|
||||
self:scriptMove(npc, npc.facing, steps, fight)
|
||||
else
|
||||
fight()
|
||||
end
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
-- A trainer's walk-up must stop short of a Strength boulder, and the boulder
|
||||
-- must still be pushable afterwards (#809). TrainerWalkUpToPlayer (pokered
|
||||
-- engine/overworld/trainer_sight.asm) writes dist-1 movement bytes that skip
|
||||
-- collision, so the trainer used to park ON the boulder, and after that
|
||||
-- IsSpriteInFrontOfPlayer (home/overworld.asm) handed TryPushingBoulder the
|
||||
-- trainer instead of the rock. POKEPORT_DRIVER=tests/drivers/boulder_trainer_bug809_test.lua POKEPORT_IDENTITY=bug809 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
|
||||
-- No POKEPORT_SPEED: the sighting, the "!" bubble and the walk-up all run at
|
||||
-- the normal 60 Hz logic clock so the stop-short frame is the one a player
|
||||
-- would see. The setup pushes are slow for the same reason; the run takes
|
||||
-- about half a minute of real time before it hands the pad over.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
|
||||
-- pokered data/maps/objects/VictoryRoad3F.asm:
|
||||
-- object_event 13, 3, SPRITE_COOLTRAINER_F, STAY, RIGHT, ..., OPP_COOLTRAINER_F, 3
|
||||
-- object_event 22, 3, SPRITE_BOULDER, STAY, BOULDER_MOVEMENT_BYTE_2, ...
|
||||
-- Her header range is 4 (data/generated/trainer_headers.lua VictoryRoad3F[4]),
|
||||
-- so she spots the player anywhere on row 3 within four cells to her east and
|
||||
-- then walks dist-1 cells toward him. Row 3 is walled at x=19, so BOULDER1
|
||||
-- cannot simply be shoved west into her sight line: it has to go down column
|
||||
-- 22 to row 6, west along row 6, and back up column 17 onto row 3.
|
||||
local MAP = "VICTORY_ROAD_3F"
|
||||
local MAP_LABEL = "VictoryRoad3F"
|
||||
local BOULDER = "VICTORYROAD3F_BOULDER1"
|
||||
local TRAINER = "VICTORYROAD3F_COOLTRAINER_F2"
|
||||
local START = { x = 22, y = 2, facing = "down" }
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local pass = true
|
||||
local function check(label, ok)
|
||||
if not ok then pass = false end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function findNpc(ow, name)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == name then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Hold `btn` until `cond` goes true or the budget runs out, then release and
|
||||
-- let any half-finished step land. Boulder pushes need a held direction:
|
||||
-- handleInput only reaches checkBoulderPush while the player already faces
|
||||
-- that way, and TryPushingBoulder arms on one poll and moves on the next
|
||||
-- (BIT_TRIED_PUSH_BOULDER), so a single tap can never shift a rock.
|
||||
local function holdUntil(btn, cond, budget)
|
||||
local first = true
|
||||
for _ = 1, budget or 600 do
|
||||
if cond() then break end
|
||||
if first then table.insert(game.input.pressQueue, btn); first = false end
|
||||
game.input.state[btn] = true
|
||||
coroutine.yield()
|
||||
end
|
||||
game.input.state[btn] = false
|
||||
for _ = 1, 40 do
|
||||
if not game.overworld.player.moving and #game.overworld.scriptMoves == 0 then
|
||||
break
|
||||
end
|
||||
coroutine.yield()
|
||||
end
|
||||
U.wait(4) -- an input-free poll re-arms turning in place (wCheckFor180DegreeTurn)
|
||||
return cond()
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, START.x, START.y, START.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
local rock = findNpc(ow, BOULDER)
|
||||
local trainer = findNpc(ow, TRAINER)
|
||||
|
||||
check("BOULDER1 loaded on " .. MAP, rock ~= nil)
|
||||
check("COOLTRAINER_F2 loaded on " .. MAP, trainer ~= nil)
|
||||
if not (rock and trainer) then
|
||||
U.log("map objects missing; nothing to drive")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
check("BOULDER1 starts at the asm cell (22,3)",
|
||||
rock.cellX == 22 and rock.cellY == 3)
|
||||
check("COOLTRAINER_F2 starts at the asm cell (13,3) facing right",
|
||||
trainer.cellX == 13 and trainer.cellY == 3 and trainer.facing == "right")
|
||||
check("checkBoulderPush resolves through pushableAtCell",
|
||||
type(ow.pushableAtCell) == "function")
|
||||
|
||||
local header = game.data:trainerHeader(MAP_LABEL, trainer.def.index)
|
||||
local range = header and header.range or 0
|
||||
check("her sight range is 4 cells", range == 4)
|
||||
|
||||
-- The whole route, so a map or tileset edit shows up here instead of as a
|
||||
-- driver that quietly wanders off. If a cell is not walkable the boulder
|
||||
-- cannot be pushed onto it (CheckForCollisionWhenPushingBoulder reuses the
|
||||
-- player's passability check) and the run is not worth continuing.
|
||||
local ROUTE = {
|
||||
{ 22, 4 }, { 22, 5 }, { 22, 6 }, { 23, 5 }, { 23, 6 },
|
||||
{ 21, 6 }, { 20, 6 }, { 19, 6 }, { 18, 6 }, { 17, 6 },
|
||||
{ 18, 7 }, { 17, 7 }, { 17, 5 }, { 17, 4 }, { 17, 3 },
|
||||
{ 18, 4 }, { 18, 3 }, { 16, 3 }, { 16, 4 }, { 16, 2 },
|
||||
}
|
||||
local routeOk = true
|
||||
for _, c in ipairs(ROUTE) do
|
||||
if not ow.map:isWalkableCell(c[1], c[2]) then
|
||||
routeOk = false
|
||||
U.log("route cell not walkable:", c[1], c[2])
|
||||
end
|
||||
end
|
||||
check("the push route is walkable end to end", routeOk)
|
||||
|
||||
-- STRENGTH is live for the map visit. BIT_STRENGTH_ACTIVE is what
|
||||
-- TryPushingBoulder gates on -- it never re-reads badges or party moves --
|
||||
-- so setting the field-move state is the whole grant (see the comment in
|
||||
-- OverworldState:checkBoulderPush).
|
||||
ow.strengthActive = true
|
||||
|
||||
-- Victory Road rolls a wild encounter on every completed step, not just in
|
||||
-- grass (wild_encounters.asm counts caves as indoor), and this run walks
|
||||
-- twenty-odd cells with an empty party. Drop the map's table: a wild
|
||||
-- battle mid-route interrupts the push with a screen transition and has
|
||||
-- nothing to do with what is being checked.
|
||||
game.data.encounters[MAP] = nil
|
||||
|
||||
if not pass then
|
||||
U.log("setup checks already failed; not driving the push")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local function boulderAt(x, y)
|
||||
return function() return rock.cellX == x and rock.cellY == y end
|
||||
end
|
||||
local function playerAt(x, y)
|
||||
local p = ow.player
|
||||
return function() return p.cellX == x and p.cellY == y end
|
||||
end
|
||||
|
||||
-- down column 22 to row 6
|
||||
holdUntil("down", boulderAt(22, 6), 400)
|
||||
check("boulder pushed down column 22 to (22,6)", rock.cellX == 22 and rock.cellY == 6)
|
||||
-- around to its east side
|
||||
holdUntil("right", playerAt(23, 5), 120)
|
||||
holdUntil("down", playerAt(23, 6), 120)
|
||||
-- west along row 6 to the column that reaches row 3
|
||||
holdUntil("left", boulderAt(17, 6), 700)
|
||||
check("boulder pushed west along row 6 to (17,6)", rock.cellX == 17 and rock.cellY == 6)
|
||||
-- around to its south side
|
||||
holdUntil("down", playerAt(18, 7), 120)
|
||||
holdUntil("left", playerAt(17, 7), 120)
|
||||
-- up column 17 onto her row
|
||||
holdUntil("up", boulderAt(17, 3), 400)
|
||||
check("boulder pushed up column 17 onto row 3 at (17,3)",
|
||||
rock.cellX == 17 and rock.cellY == 3)
|
||||
if not pass then
|
||||
U.log("the boulder never reached her row; the race below cannot happen")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- Step onto row 3 one cell out of range (18 - 13 = 5 > 4) so the sighting
|
||||
-- happens on the push itself and not a moment earlier.
|
||||
holdUntil("right", playerAt(18, 4), 120)
|
||||
holdUntil("up", playerAt(18, 3), 120)
|
||||
check("player waiting at (18,3), one cell outside her range",
|
||||
ow.player.cellX == 18 and ow.player.cellY == 3 and not ow.engaging)
|
||||
|
||||
-- The engage lands on a battle we are not going to fight: stand in for it,
|
||||
-- record where the walk-up stopped, and mark her beaten the way winning
|
||||
-- would. Everything the walk-up does has already happened by this point.
|
||||
local stopped
|
||||
local realEngage = ow.engageTrainer
|
||||
ow.engageTrainer = function(self, npc, onDone)
|
||||
stopped = { npc = npc, x = npc.cellX, y = npc.cellY }
|
||||
game.save.defeatedTrainers[npc.id] = true
|
||||
if onDone then onDone() end
|
||||
end
|
||||
|
||||
-- One push west: the boulder lands on (16,3) and the player follows onto
|
||||
-- (17,3), four cells from her, which is the frame she spots him on.
|
||||
holdUntil("left", function() return stopped ~= nil end, 400)
|
||||
|
||||
check("she spotted the player and finished her walk-up", stopped ~= nil)
|
||||
check("the boulder moved one cell west to (16,3)",
|
||||
rock.cellX == 16 and rock.cellY == 3)
|
||||
if stopped then
|
||||
U.log("she stopped at", stopped.x, stopped.y, "boulder at", rock.cellX, rock.cellY)
|
||||
check("she is not standing on the boulder cell",
|
||||
not (stopped.x == rock.cellX and stopped.y == rock.cellY))
|
||||
check("she stopped one cell short of it, at (15,3)",
|
||||
stopped.x == 15 and stopped.y == 3)
|
||||
check("the push path still finds the boulder under that cell",
|
||||
ow:pushableAtCell(rock.cellX, rock.cellY) == rock)
|
||||
check("nothing else shares the boulder's cell",
|
||||
ow:npcAtCell(rock.cellX, rock.cellY) == rock)
|
||||
end
|
||||
U.shot(game, SHOT_DIR .. "/bug809_walkup_stop.png")
|
||||
|
||||
-- ...and the rock still moves. Push it north, the one free direction left:
|
||||
-- west is her, east is the player, south is where he came from.
|
||||
holdUntil("down", playerAt(17, 4), 120)
|
||||
holdUntil("left", playerAt(16, 4), 120)
|
||||
holdUntil("up", boulderAt(16, 2), 400)
|
||||
if not check("the boulder is still pushable after the engage",
|
||||
rock.cellX == 16 and rock.cellY == 2) then
|
||||
U.log("boulder ended at", rock.cellX, rock.cellY, "player at",
|
||||
ow.player.cellX, ow.player.cellY)
|
||||
end
|
||||
holdUntil("down", playerAt(16, 4), 120)
|
||||
U.shot(game, SHOT_DIR .. "/bug809_still_pushable.png")
|
||||
|
||||
ow.engageTrainer = realEngage
|
||||
U.log(pass and "ALL CHECKS PASSED" or "SOME CHECKS FAILED")
|
||||
|
||||
U.log("On screen: the COOLTRAINER stands at (15,3) with a one-cell gap")
|
||||
U.log("between her and the rock, which now sits at (16,2), one row up from")
|
||||
U.log("where she stopped. The near miss to watch for is her sprite ending")
|
||||
U.log("the walk-up on top of the rock, or standing clear of it but leaving")
|
||||
U.log("it inert: walk into the rock from any side and it should still shift")
|
||||
U.log("a cell. Her battle was stubbed out and she is flagged as beaten;")
|
||||
U.log("re-run the driver to watch the race again from the start.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,209 @@
|
||||
-- Driver: Fighting Dojo prize balls, #853 (dex page first) and #854 (the
|
||||
-- question stays on screen under YES/NO). pokered scripts/FightingDojo.asm
|
||||
-- runs `ld a, HITMONLEE / call DisplayPokedex` before .Text, and .Text is a
|
||||
-- text_end string printed with PrintText immediately followed by YesNoChoice.
|
||||
-- No POKEPORT_SPEED here: the dex page and the YES/NO pop are what is judged.
|
||||
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/dojo_balls_bug853_test.lua POKEPORT_IDENTITY=bug853 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 TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
local MapScripts = require("src.script.MapScripts")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local OW = require("src.world.OverworldController")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
-- pokered data/maps/objects/FightingDojo.asm: the two SPRITE_POKE_BALL
|
||||
-- objects sit at (4, 1) HITMONLEE and (5, 1) HITMONCHAN, on the north wall
|
||||
-- under the posters. The only approach is from the mat below them.
|
||||
local MAP = "FIGHTING_DOJO"
|
||||
local LEE = { name = "FIGHTINGDOJO_HITMONLEE_POKE_BALL", x = 4, y = 1 }
|
||||
local CHAN = { name = "FIGHTINGDOJO_HITMONCHAN_POKE_BALL", x = 5, y = 1 }
|
||||
local START = { x = 4, y = 4 } -- walk up from here to (4, 2), facing LEE
|
||||
|
||||
local failures = {}
|
||||
local function check(cond, msg)
|
||||
if cond then U.log("PASS", msg) else
|
||||
failures[#failures + 1] = msg
|
||||
U.log("FAIL", msg)
|
||||
end
|
||||
return cond
|
||||
end
|
||||
|
||||
local function topIs(mt) return getmetatable(game.stack:top()) == mt end
|
||||
local function under()
|
||||
return game.stack.states[#game.stack.states - 1]
|
||||
end
|
||||
|
||||
local function npcByName(ow, name)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == name then return n end
|
||||
end
|
||||
end
|
||||
|
||||
local function pageText()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return "" end
|
||||
local page = top.pages and top.pages[top.pageIndex]
|
||||
return page and table.concat(page, "\n") or ""
|
||||
end
|
||||
|
||||
local function waitFor(cond, cap)
|
||||
for _ = 1, (cap or 200) do
|
||||
if cond() then return true end
|
||||
U.wait(2)
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
local function mashUntil(cond, cap)
|
||||
for _ = 1, (cap or 100) do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
-- fresh dojo with the master already beaten and neither prize taken
|
||||
local function seed(x, y, facing)
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
game.save.flags = {
|
||||
EVENT_BEAT_KARATE_MASTER = true,
|
||||
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 = { FIGHTING_DOJO_obj_1 = true }
|
||||
game.save.objectToggles = {}
|
||||
game.save.player.name = game.save.player.name or "RED"
|
||||
-- one mon so give_pokemon has a party to append to, and room for a prize
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
game.stack:push(OW, MAP, x, y, facing or "up")
|
||||
U.wait(10)
|
||||
return game.stack:top()
|
||||
end
|
||||
|
||||
local ow = seed(START.x, START.y, "up")
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- machine-checkable half: seed, objects, script rows, text, screen id
|
||||
------------------------------------------------------------------
|
||||
check(game.save.flags.EVENT_BEAT_KARATE_MASTER == true,
|
||||
"EVENT_BEAT_KARATE_MASTER is set (the balls answer at all)")
|
||||
check(not game.save.flags.EVENT_GOT_HITMONLEE
|
||||
and not game.save.flags.EVENT_GOT_HITMONCHAN,
|
||||
"neither prize taken yet (no 'greedy' refusal path)")
|
||||
|
||||
local leeBall, chanBall = npcByName(ow, LEE.name), npcByName(ow, CHAN.name)
|
||||
check(leeBall ~= nil, "HITMONLEE ball object loaded")
|
||||
check(chanBall ~= nil, "HITMONCHAN ball object loaded")
|
||||
check(leeBall and leeBall.cellX == LEE.x and leeBall.cellY == LEE.y,
|
||||
("HITMONLEE ball sits at the asm cell (%d, %d)"):format(LEE.x, LEE.y))
|
||||
check(chanBall and chanBall.cellX == CHAN.x and chanBall.cellY == CHAN.y,
|
||||
("HITMONCHAN ball sits at the asm cell (%d, %d)"):format(CHAN.x, CHAN.y))
|
||||
|
||||
check(type(MapScripts.talkScript(MAP, "TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL"))
|
||||
== "function",
|
||||
"TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL has a hand-ported talk script")
|
||||
check(type(MapScripts.talkScript(MAP, "TEXT_FIGHTINGDOJO_HITMONCHAN_POKE_BALL"))
|
||||
== "function",
|
||||
"TEXT_FIGHTINGDOJO_HITMONCHAN_POKE_BALL has a hand-ported talk script")
|
||||
|
||||
-- the ask() string is the extracted descriptor, not the "You want X?" stub
|
||||
local leeText = game.data.text._FightingDojoHitmonleePokeBallText
|
||||
local chanText = game.data.text._FightingDojoHitmonchanPokeBallText
|
||||
check(type(leeText) == "string" and leeText ~= "",
|
||||
"_FightingDojoHitmonleePokeBallText resolves")
|
||||
check(type(chanText) == "string" and chanText ~= "",
|
||||
"_FightingDojoHitmonchanPokeBallText resolves")
|
||||
if type(leeText) == "string" then
|
||||
U.log("lee prompt reads:", (leeText:gsub("\n", " / ")))
|
||||
end
|
||||
local dexOk = pcall(Screens.get, game, "DexEntryMenu")
|
||||
check(dexOk, "DexEntryMenu resolves through the Screens registry")
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- rehearsal on the HITMONCHAN ball, answered NO so nothing is consumed
|
||||
------------------------------------------------------------------
|
||||
if chanBall then
|
||||
ow:talkTo(chanBall)
|
||||
check(waitFor(function() return topIs(DexEntryMenu) end, 60),
|
||||
"#853: the ball opens the HITMONCHAN dex page before any question")
|
||||
U.shot(game, DIR .. "/dojo_balls_1_dex.png")
|
||||
U.tap(game, "b")
|
||||
check(waitFor(function() return topIs(TextBox) end, 60),
|
||||
"#853: closing the dex page leads into the offer text")
|
||||
mashUntil(function() return topIs(ChoiceBox) end, 60)
|
||||
check(topIs(ChoiceBox), "#854: the YES/NO menu opens on the offer")
|
||||
check(getmetatable(under()) == TextBox,
|
||||
"#854: the question box is still on the stack under the YES/NO menu")
|
||||
U.shot(game, DIR .. "/dojo_balls_2_choice.png")
|
||||
U.tap(game, "b") -- B answers NO; the prize stays unclaimed
|
||||
waitFor(function() return game.stack:top() == ow end, 120)
|
||||
check(not game.save.flags.EVENT_GOT_HITMONCHAN,
|
||||
"answering NO leaves the HITMONCHAN prize unclaimed")
|
||||
check(#game.save.party == 1, "answering NO adds nothing to the party")
|
||||
end
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- hand-off: walk to the HITMONLEE ball and open it for real
|
||||
------------------------------------------------------------------
|
||||
ow = seed(START.x, START.y, "up")
|
||||
for _ = 1, 12 do
|
||||
if ow.player.cellY <= LEE.y + 1 then break end
|
||||
U.hold(game, "up", 16)
|
||||
U.wait(4)
|
||||
end
|
||||
|
||||
local function facingTheBall()
|
||||
local cur = game.overworld
|
||||
local ball = cur and npcByName(cur, LEE.name)
|
||||
if not ball then return false end
|
||||
local fx, fy = cur.player:facingCell()
|
||||
return cur:npcAtCell(fx, fy) == ball
|
||||
end
|
||||
|
||||
if not facingTheBall() then
|
||||
-- a map edit or a mod moved the ball: stand on any free walkable
|
||||
-- neighbour instead. {dx, dy, facing} is the offset from the ball to
|
||||
-- the stand cell plus the direction that looks back at it.
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 1, 0, "left" }, { -1, 0, "right" }, { 0, -1, "down" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = LEE.x + s[1], LEE.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log("walk up stopped short; standing on", cx, cy, "facing", s[3])
|
||||
ow = seed(cx, cy, s[3])
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check(facingTheBall(), "player is standing against the HITMONLEE ball")
|
||||
|
||||
U.tap(game, "a")
|
||||
check(waitFor(function() return topIs(DexEntryMenu) end, 60),
|
||||
"#853: pressing A opens the HITMONLEE dex page")
|
||||
U.shot(game, DIR .. "/dojo_balls_3_handoff.png")
|
||||
|
||||
if #failures == 0 then
|
||||
U.log("all checks passed")
|
||||
else
|
||||
U.log(("%d check(s) failed:"):format(#failures), table.concat(failures, "; "))
|
||||
end
|
||||
|
||||
U.log("On screen now: the HITMONLEE dex page the ball opened, name and")
|
||||
U.log("sprite only, since the mon is seen but not owned yet. Press B: the")
|
||||
U.log("offer types out, and the YES/NO menu should appear above it with the")
|
||||
U.log("question still readable -- the old bug swapped the text away for a")
|
||||
U.log("bare YES/NO over the overworld. Answer YES to take HITMONLEE; the")
|
||||
U.log("HITMONCHAN ball beside it stays put and gives the greedy refusal.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,321 @@
|
||||
-- Driver: #862 Celadon Game Corner poster grunt, loss line + exit walk.
|
||||
-- GameCornerRocketText saves _GameCornerRocketBattleEndText ("Dang!") for
|
||||
-- PrintEndBattleText, and GameCornerRocketBattleScript picks the exit walk
|
||||
-- from the player's cell (pokered/scripts/GameCorner.asm:54-102): east of
|
||||
-- him it is WalkAroundPlayer, DOWN/R/R/UP/R/R/R/R, never UP into the poster.
|
||||
-- No POKEPORT_SPEED: the walk and the battle text are what is under test.
|
||||
-- SHOT_DIR=/tmp/shots POKEPORT_IDENTITY=bug862 POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/game_corner_grunt_bug862_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 BattleState = require("src.battle.BattleState")
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok, detail)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label, detail or "")
|
||||
return ok
|
||||
end
|
||||
|
||||
-- pokered/data/maps/objects/GameCorner.asm:36 -- the grunt is
|
||||
-- object_event 9, 5, SPRITE_ROCKET, STAY, UP, facing the poster bg_event
|
||||
-- at (9,4), which is wall. Standing east of him on (10,5) is the branch
|
||||
-- that matters: wYCoord ~= 6 and wXCoord ~= 8, so the script takes
|
||||
-- GameCornerMovement_Rocket_WalkAroundPlayer.
|
||||
local MAP = "GAME_CORNER"
|
||||
local NAME = "GAMECORNER_ROCKET"
|
||||
local GX, GY = 9, 5
|
||||
local STAND = { x = 10, y = 5, facing = "left" }
|
||||
local POSTER = { x = 9, y = 4 }
|
||||
-- DOWN, RIGHT, RIGHT, UP, RIGHT x4 from (9,5), ending on (15,5)
|
||||
local AROUND = {
|
||||
{ 9, 6 }, { 10, 6 }, { 11, 6 }, { 11, 5 },
|
||||
{ 12, 5 }, { 13, 5 }, { 14, 5 }, { 15, 5 },
|
||||
}
|
||||
|
||||
-- clean slate: he must not read as already defeated or already 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"
|
||||
game.save.money = game.save.money or 3000
|
||||
|
||||
-- a tank that one-shots OPP_ROCKET #7, so the mash win below is quick and
|
||||
-- the same every run whatever the type matchups are
|
||||
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 }
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
local ow = game.overworld
|
||||
|
||||
local function findGrunt()
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == NAME then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local grunt = findGrunt()
|
||||
check("GAMECORNER_ROCKET is on the floor", grunt ~= nil)
|
||||
if grunt then
|
||||
check("he stands on (9,5)", grunt.cellX == GX and grunt.cellY == GY,
|
||||
("at (%d,%d)"):format(grunt.cellX, grunt.cellY))
|
||||
end
|
||||
|
||||
-- a map edit or a mod could take (10,5) away; anything east of him keeps
|
||||
-- the WalkAroundPlayer branch, so fall back to a free walkable neighbour
|
||||
-- and say which branch that lands on
|
||||
local function facingGrunt()
|
||||
local g = findGrunt()
|
||||
if not g then return false end
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow:npcAtCell(fx, fy) == g
|
||||
end
|
||||
if grunt and not facingGrunt() then
|
||||
local sides = {
|
||||
{ 1, 0, "left" }, { 0, 1, "up" }, { -1, 0, "right" }, { 0, -1, "down" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = grunt.cellX + s[1], grunt.cellY + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d,%d) is blocked; standing on"):format(STAND.x, STAND.y),
|
||||
cx, cy, "facing", s[3])
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
ow = game.overworld
|
||||
grunt = findGrunt()
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("the player is face to face with him", facingGrunt())
|
||||
local px, py = ow.player.cellX, ow.player.cellY
|
||||
local around = not (py == 6 or px == 8)
|
||||
U.log(("talking from (%d,%d): the script should take %s"):format(
|
||||
px, py, around and "WalkAroundPlayer (down, right, right, up, "
|
||||
.. "right x4)" or "WalkDirect (right x5)"))
|
||||
|
||||
-- the two strings the fix depends on, and the poster cell the pre-fix
|
||||
-- single UP step walked him into
|
||||
local t = game.data.text
|
||||
check("_GameCornerRocketBattleEndText resolves",
|
||||
type(t._GameCornerRocketBattleEndText) == "string"
|
||||
and t._GameCornerRocketBattleEndText ~= "",
|
||||
tostring(t._GameCornerRocketBattleEndText))
|
||||
check("_GameCornerRocketAfterBattleText resolves",
|
||||
type(t._GameCornerRocketAfterBattleText) == "string"
|
||||
and t._GameCornerRocketAfterBattleText ~= "")
|
||||
check("(9,4) is the poster wall, not a cell he can stand on",
|
||||
not ow.map:isWalkableCell(POSTER.x, POSTER.y))
|
||||
|
||||
-- engageTrainer has to accept the script-supplied loss line; a stale
|
||||
-- two-parameter copy would silently drop it and print nothing
|
||||
local info = debug.getinfo(ow.engageTrainer, "S")
|
||||
local sigOk = false
|
||||
if info and info.short_src then
|
||||
local src = io.open((info.short_src:gsub("^@", "")), "r")
|
||||
if src then
|
||||
local n = 0
|
||||
for line in src:lines() do
|
||||
n = n + 1
|
||||
if n == info.linedefined then
|
||||
sigOk = line:find("endBattleText", 1, true) ~= nil
|
||||
break
|
||||
end
|
||||
end
|
||||
src:close()
|
||||
end
|
||||
end
|
||||
check("engageTrainer takes an endBattleText argument", sigOk)
|
||||
|
||||
U.shot(game, DIR .. "/bug862_0_before.png")
|
||||
|
||||
-- Talk and mash to a win, recording every battle message in order and
|
||||
-- pausing on the loss line long enough to photograph it.
|
||||
local said, battle = {}, nil
|
||||
local lastSaid, dangShot = nil, false
|
||||
local function sample()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == BattleState then
|
||||
battle = battle or top
|
||||
local cur = top.current
|
||||
local text = type(cur) == "table" and cur.text
|
||||
if type(text) == "string" and text ~= lastSaid then
|
||||
lastSaid = text
|
||||
said[#said + 1] = text
|
||||
U.log("battle says:", (text:gsub("\n", " ")))
|
||||
end
|
||||
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
|
||||
|
||||
local function idle()
|
||||
return game.stack:top() == ow and not ow.runner:isRunning()
|
||||
and #ow.scriptMoves == 0 and not ow.transitioning
|
||||
end
|
||||
|
||||
U.tap(game, "a")
|
||||
local sawAfter = false
|
||||
for f = 1, 4000 do
|
||||
sample()
|
||||
if pageText():find("hideout", 1, true) then sawAfter = true break end
|
||||
local top = game.stack:top()
|
||||
if lastSaid and lastSaid:find("Dang", 1, true) and not dangShot then
|
||||
-- stop mashing for a moment: the loss line is on the battle screen.
|
||||
-- The row is picked up the frame it starts typing, so let it finish
|
||||
-- before the capture or the shot is one letter wide.
|
||||
dangShot = true
|
||||
U.wait(60)
|
||||
U.shot(game, DIR .. "/bug862_1_dang.png")
|
||||
elseif 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 a stalled battle")
|
||||
top.onFinish("win")
|
||||
if game.stack:top() == top then game.stack:pop() end
|
||||
end
|
||||
else
|
||||
U.tap(game, "a")
|
||||
end
|
||||
U.wait(2)
|
||||
sample()
|
||||
end
|
||||
check("reached the after-battle 'hideout' line", sawAfter)
|
||||
check("the battle carried the script's loss line",
|
||||
battle ~= nil and type(battle.endBattleText) == "string"
|
||||
and battle.endBattleText:find("Dang", 1, true) ~= nil,
|
||||
battle and tostring(battle.endBattleText) or "no battle seen")
|
||||
|
||||
-- PrintEndBattleText sits between TrainerDefeatedText and
|
||||
-- MoneyForWinningText (engine/battle/core.asm TrainerBattleVictory)
|
||||
local iDefeat, iDang, iMoney
|
||||
for i, line in ipairs(said) do
|
||||
if not iDefeat and line:find("defeated", 1, true) then iDefeat = i end
|
||||
if not iDang and line:find("Dang", 1, true) then iDang = i end
|
||||
if not iMoney and line:find("winning", 1, true) then iMoney = i end
|
||||
end
|
||||
check("the loss line printed on the battle screen", iDang ~= nil)
|
||||
check("it printed with the ROCKET: name tag",
|
||||
iDang ~= nil and said[iDang]:find(":", 1, true) ~= nil,
|
||||
iDang and said[iDang] or "")
|
||||
check("order is defeated -> Dang! -> payout",
|
||||
iDefeat ~= nil and iDang ~= nil and iMoney ~= nil
|
||||
and iDefeat < iDang and iDang < iMoney,
|
||||
("defeated=%s dang=%s payout=%s"):format(tostring(iDefeat),
|
||||
tostring(iDang),
|
||||
tostring(iMoney)))
|
||||
U.shot(game, DIR .. "/bug862_2_afterbattle.png")
|
||||
|
||||
-- Dismiss the after-battle box and watch the exit walk cell by cell.
|
||||
U.tap(game, "a")
|
||||
local visited, order, lowShot = {}, {}, false
|
||||
local function mark(cx, cy)
|
||||
local key = cx .. "," .. cy
|
||||
if not visited[key] then
|
||||
visited[key] = true
|
||||
order[#order + 1] = key
|
||||
end
|
||||
end
|
||||
-- the last step's hide_object rides its own onDone, so the grunt leaves
|
||||
-- ow.npcs on the frame he lands: count the cell he is walking INTO as
|
||||
-- visited too, or the destination never shows up in the sample
|
||||
local last = { GX, GY }
|
||||
for _ = 1, 900 do
|
||||
local g = findGrunt()
|
||||
if g then
|
||||
mark(g.cellX, g.cellY)
|
||||
last = { g.cellX, g.cellY }
|
||||
if g.targetX and g.targetY then
|
||||
mark(g.targetX, g.targetY)
|
||||
last = { g.targetX, g.targetY }
|
||||
end
|
||||
if g.cellY > GY and not lowShot then
|
||||
lowShot = true
|
||||
U.shot(game, DIR .. "/bug862_3_walk.png")
|
||||
end
|
||||
elseif idle() then
|
||||
break
|
||||
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 .. "/bug862_4_gone.png")
|
||||
|
||||
U.log("cells he stood on:", table.concat(order, " "))
|
||||
check("he never stood on the poster cell (9,4)",
|
||||
not visited[POSTER.x .. "," .. POSTER.y])
|
||||
check("he never stepped north of his start row", (function()
|
||||
for key in pairs(visited) do
|
||||
local y = tonumber(key:match(",(%d+)$"))
|
||||
if y and y < GY then return false end
|
||||
end
|
||||
return true
|
||||
end)())
|
||||
if around then
|
||||
check("he stepped down to (9,6) to get past the player", visited["9,6"])
|
||||
check("he came back up onto row 5 and finished on (15,5)",
|
||||
last[1] == 15 and last[2] == 5,
|
||||
("last seen on (%d,%d)"):format(last[1], last[2]))
|
||||
else
|
||||
check("he walked straight along row 5 to (15,5)",
|
||||
last[1] == 15 and last[2] == 5 and not visited["9,6"],
|
||||
("last seen on (%d,%d)"):format(last[1], last[2]))
|
||||
end
|
||||
local toggles = game.save.objectToggles.GAME_CORNER
|
||||
check("he despawned only after the last step", findGrunt() == nil)
|
||||
check("his objectToggle is hidden",
|
||||
toggles ~= nil and toggles.GAMECORNER_ROCKET == false)
|
||||
check("he is recorded as defeated",
|
||||
game.save.defeatedTrainers["GAME_CORNER_obj_11"] == true)
|
||||
U.log(("checks: %d passed, %d failed"):format(pass, fail))
|
||||
|
||||
-- Hand the pad over on a clean copy of the same setup so the whole beat
|
||||
-- can be watched at speed.
|
||||
game.save.defeatedTrainers = {}
|
||||
game.save.objectToggles.GAME_CORNER = nil
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.log("You are east of the grunt again, facing him. Press A and win.")
|
||||
U.log("Right looks like: he says his piece on the battle screen after")
|
||||
U.log("\"RED defeated ROCKET!\" -- one box, \"ROCKET: Dang!\" -- and the")
|
||||
U.log("¥ payout comes after it, not before. Then the hideout line, then")
|
||||
U.log("he steps DOWN off row 5, right past you, back up and out east.")
|
||||
U.log("The near miss to watch for: he steps UP into the poster, or the")
|
||||
U.log("Dang! box turns up in the overworld after the battle has torn down.")
|
||||
U.log("Talk to him from (9,6) below instead and he takes the straight")
|
||||
U.log("five-step version east; both are correct, the branch is your cell.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,177 @@
|
||||
-- Manual check of the Rocket Hideout B4F Jessie & James ambush: James walks
|
||||
-- the full four tiles to the player's side (#865) and their loss line prints
|
||||
-- on the battle screen before the prize money (#866).
|
||||
-- pokeyellow scripts/RocketHideoutB4F.asm (MovementData_45605 falls through
|
||||
-- into _45606) and data/maps/objects/RocketHideoutB4F.asm. No fast-forward:
|
||||
-- POKEPORT_DRIVER=tests/drivers/jessie_james_bug866_test.lua POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Commands = require("src.script.Commands")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local mapScripts = require("data.scripts.init")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local MAP = "ROCKET_HIDEOUT_B4F"
|
||||
local BEAT = "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES"
|
||||
local JAMES, JESSIE = "ROCKETHIDEOUTB4F_JAMES", "ROCKETHIDEOUTB4F_JESSIE"
|
||||
-- RocketHideoutB4FScript_455a5 fires on wYCoord $e with wXCoord $18 or $19.
|
||||
-- x=24 leaves EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT clear, which is
|
||||
-- the branch that hands the four-step blob to James (object 2, spawned at
|
||||
-- 25,10) and the three-step one to Jessie (object 3, at 24,10).
|
||||
local TRIGGER = { x = 24, y = 14 }
|
||||
local EXPECT = {
|
||||
[JAMES] = { x = 25, y = 14, facing = "left" },
|
||||
[JESSIE] = { x = 24, y = 13, facing = "down" },
|
||||
}
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
check("running the Yellow cache (the duo exists nowhere else)",
|
||||
GameVersion.isYellow())
|
||||
if not GameVersion.isYellow() then
|
||||
U.log("re-run with POKEPORT_VERSION=yellow; nothing below will be true")
|
||||
end
|
||||
|
||||
local hooks = mapScripts.get(MAP)
|
||||
check("yellow_jessie_james registered an onStep for " .. MAP,
|
||||
type(hooks) == "table" and type(hooks.onStep) == "function")
|
||||
check("their talk entries are registered too",
|
||||
type(hooks) == "table" and type(hooks.talk) == "table"
|
||||
and hooks.talk.TEXT_ROCKETHIDEOUTB4F_JAMES ~= nil
|
||||
and hooks.talk.TEXT_ROCKETHIDEOUTB4F_JESSIE ~= nil)
|
||||
|
||||
-- the #866 fix is a new script verb; if a mod shadowed it or the registry
|
||||
-- never picked it up, the row would silently no-op and the line would come
|
||||
-- back after the money instead of before it
|
||||
local verb = Commands.resolve(game.data, "save_end_battle_text")
|
||||
check("save_end_battle_text resolves as a script verb", type(verb) == "function")
|
||||
|
||||
local texts = {}
|
||||
for i = 1, 4 do
|
||||
local key = "_RocketHideoutJessieJamesText" .. i
|
||||
texts[i] = game.data.text[key]
|
||||
check(key .. " resolves to a string",
|
||||
type(texts[i]) == "string" and texts[i] ~= "")
|
||||
end
|
||||
if type(texts[3]) == "string" then
|
||||
U.log("the armed loss line reads:", (texts[3]:gsub("\n", " / ")))
|
||||
end
|
||||
|
||||
local objs = (game.data.maps[MAP] or {}).objects or {}
|
||||
local defs = {}
|
||||
for _, o in ipairs(objs) do
|
||||
if o.name == JAMES or o.name == JESSIE then defs[o.name] = o end
|
||||
end
|
||||
check("James is object 2 of " .. MAP .. ", hidden at (25,10)",
|
||||
defs[JAMES] ~= nil and defs[JAMES].index == 2
|
||||
and defs[JAMES].x == 25 and defs[JAMES].y == 10
|
||||
and defs[JAMES].hidden == true)
|
||||
check("Jessie is object 3, hidden at (24,10)",
|
||||
defs[JESSIE] ~= nil and defs[JESSIE].index == 3
|
||||
and defs[JESSIE].x == 24 and defs[JESSIE].y == 10
|
||||
and defs[JESSIE].hidden == true)
|
||||
|
||||
local rocket = game.data.trainers.OPP_ROCKET
|
||||
check("OPP_ROCKET party 43 (the duo's shared team) exists",
|
||||
rocket ~= nil and rocket.parties ~= nil and rocket.parties[43] ~= nil)
|
||||
|
||||
-- arm the site: the ambush is gated only on its beat flag, so no story
|
||||
-- progress is needed to make it live
|
||||
game.save.flags[BEAT] = nil
|
||||
game.save.flags.EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT = nil
|
||||
check(BEAT .. " cleared, so the trigger is live",
|
||||
game.save.flags[BEAT] == nil)
|
||||
|
||||
-- a real party, because the human has to win the battle for the loss line
|
||||
-- to print at all
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 60),
|
||||
Pokemon.new(game.data, "NIDOKING", 58),
|
||||
Pokemon.new(game.data, "STARMIE", 58),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
|
||||
-- walk in from the north; the two elevator warps sit on row 15, so the
|
||||
-- approach cannot come from below
|
||||
U.teleport(game, MAP, TRIGGER.x, TRIGGER.y - 1, "down")
|
||||
local ow = game.overworld
|
||||
if not ow.map:isWalkableCell(TRIGGER.x, TRIGGER.y - 1) then
|
||||
-- a map edit moved the free cell: any walkable neighbour of the trigger
|
||||
-- works, the script only reads the tile the player lands on
|
||||
local sides = { { 0, -1, "down" }, { -1, 0, "right" }, { 1, 0, "left" } }
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) then
|
||||
U.log("standing on", cx, cy, "facing", s[3], "instead")
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
ow = game.overworld
|
||||
U.hold(game, s[3] == "down" and "down" or (s[3] == "right" and "right" or "left"), 20)
|
||||
break
|
||||
end
|
||||
end
|
||||
else
|
||||
U.hold(game, "down", 20)
|
||||
end
|
||||
U.wait(10)
|
||||
check("player stepped onto the trigger tile (24,14)",
|
||||
ow.player.cellX == TRIGGER.x and ow.player.cellY == TRIGGER.y)
|
||||
check("the ambush script is running", ow.runner:isRunning())
|
||||
|
||||
U.log("The cutscene is yours now: press A to read, then fight and win.")
|
||||
U.log("Right looks like both Rockets closing in -- Jessie stopping one tile")
|
||||
U.log("above you, James coming all the way down to stand at your right -- and")
|
||||
U.log("after you win, \"ROCKET: Such a dreadful twerp!\" appearing on the")
|
||||
U.log("battle screen just before the money line. The near-miss to watch for")
|
||||
U.log("is James halting three tiles up by the wall, or that line showing up")
|
||||
U.log("in the overworld box after the payout with no ROCKET: tag on it.")
|
||||
U.log("Two more checks print below as you get to them.")
|
||||
|
||||
local function npcNamed(name)
|
||||
for _, n in ipairs(game.overworld and game.overworld.npcs or {}) do
|
||||
if n.def and n.def.name == name then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local approachDone, battleSeen = false, false
|
||||
while true do
|
||||
if not approachDone then
|
||||
local j, s = npcNamed(JAMES), npcNamed(JESSIE)
|
||||
local ow2 = game.overworld
|
||||
if j and s and not j.moving and not s.moving and ow2
|
||||
and #(ow2.scriptMoves or {}) == 0
|
||||
and (j.cellY > 10 or s.cellY > 10) then
|
||||
approachDone = true
|
||||
check("James walked the full four tiles to (25,14) facing left",
|
||||
j.cellX == EXPECT[JAMES].x and j.cellY == EXPECT[JAMES].y
|
||||
and j.facing == EXPECT[JAMES].facing)
|
||||
check("Jessie stopped three down at (24,13) facing the player",
|
||||
s.cellX == EXPECT[JESSIE].x and s.cellY == EXPECT[JESSIE].y
|
||||
and s.facing == EXPECT[JESSIE].facing)
|
||||
U.log("James at", j.cellX, j.cellY, j.facing,
|
||||
"Jessie at", s.cellX, s.cellY, s.facing)
|
||||
U.shot(game, SHOT_DIR .. "/jj866_approach.png")
|
||||
end
|
||||
end
|
||||
if not battleSeen then
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == BattleState then
|
||||
battleSeen = true
|
||||
-- BattleState prints endBattleText between _TrainerDefeatedText and
|
||||
-- _MoneyForWinningText, so an armed field IS the ordering fix
|
||||
check("the battle carries the loss line as its end-battle text",
|
||||
type(top.endBattleText) == "string" and top.endBattleText ~= ""
|
||||
and top.endBattleText == texts[3])
|
||||
if type(top.endBattleText) == "string" then
|
||||
U.log("armed:", (top.endBattleText:gsub("\n", " / ")))
|
||||
end
|
||||
end
|
||||
end
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,211 @@
|
||||
-- Disable blocks the move the slower mon ALREADY selected, on the very
|
||||
-- turn the Disable lands (#860). pokered runs the test at execution
|
||||
-- time, not at selection time: CheckPlayerStatusConditions
|
||||
-- .TriedToUseDisabledMoveCheck (engine/battle/core.asm:3437-3447) compares
|
||||
-- wPlayerDisabledMoveNumber against wPlayerSelectedMove and jumps to
|
||||
-- ExecutePlayerMoveDone when they match -- "prevents a disabled move that
|
||||
-- was selected before being disabled from being used", in the asm's own
|
||||
-- comment. The enemy copy is .checkIfTriedToUseDisabledMove
|
||||
-- (core.asm:5752+). The port only refused a disabled move at menu time,
|
||||
-- so the second mover still fired the move it had latched before the
|
||||
-- Disable resolved.
|
||||
--
|
||||
-- The check sits after the confusion block and before the paralysis roll,
|
||||
-- so this suite also pins the neighbours: the counter tick that clears an
|
||||
-- expired Disable still runs first, and a move that was never disabled is
|
||||
-- untouched.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
local Font = require("src.render.Font")
|
||||
Font.load(Data)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load(Data)
|
||||
|
||||
-- the fixture dataset has no status move; this dataset is this file's own
|
||||
-- copy (fixtures.fresh), so registering one here cannot leak into another
|
||||
-- case. Accuracy 100 keeps DisableEffect's MoveHitTest out of the way.
|
||||
Data.moves.FIX_DISABLE = {
|
||||
id = "FIX_DISABLE", index = 90, name = "FIX DISABLE",
|
||||
type = "NORMAL", power = 0, accuracy = 100, pp = 20,
|
||||
effect = "DISABLE_EFFECT",
|
||||
}
|
||||
|
||||
-- Deterministic rolls: the minimum of every range, except DisableEffect's
|
||||
-- own "1-8 turns disabled" roll (effects.asm:1343-1345), which is pinned
|
||||
-- at 4. A rolled 1 would be spent by the disabled mon's own counter tick
|
||||
-- in the same CheckStatusConditions pass -- vanilla behaviour, but it
|
||||
-- clears the disable before .TriedToUseDisabledMoveCheck can see it, so it
|
||||
-- is not the case this suite is about. rng(0, 255) -> 0 makes every
|
||||
-- accuracy roll hit.
|
||||
local function rolls(disableTurns)
|
||||
return function(a, b)
|
||||
if a == 1 and b == 8 then return disableTurns end
|
||||
if a then return a end
|
||||
return 0
|
||||
end
|
||||
end
|
||||
|
||||
-- playerFirst decides who lands the Disable; the other side is the one
|
||||
-- whose already-selected move has to die. Both mons get FIX_TACKLE in
|
||||
-- slot 1 (the slot DisableEffect picks with the min roll) and FIX_SCRATCH
|
||||
-- in slot 2 as the never-disabled control.
|
||||
local function newBattle(playerFirst, disableTurns)
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
|
||||
local game = { data = Data, save = save,
|
||||
stack = { top = function() return nil end, push = function() end } }
|
||||
local battle = BattleState.newWild(game, "FIXMON_C", 30)
|
||||
battle.rng = rolls(disableTurns or 4)
|
||||
|
||||
local function loadout(battler)
|
||||
battler.mon.moves = {
|
||||
{ id = "FIX_TACKLE", pp = 35 },
|
||||
{ id = "FIX_SCRATCH", pp = 35 },
|
||||
{ id = "FIX_DISABLE", pp = 20 },
|
||||
}
|
||||
battler.curMoves = battler.mon.moves
|
||||
end
|
||||
loadout(battle.player)
|
||||
loadout(battle.enemy)
|
||||
|
||||
-- no speed tie to resolve: the disabler outruns its target outright
|
||||
battle.player.curStats.speed = playerFirst and 200 or 1
|
||||
battle.enemy.curStats.speed = playerFirst and 1 or 200
|
||||
return battle
|
||||
end
|
||||
|
||||
-- the move instances the sides actually own, so PP decrements land on
|
||||
-- the party copy the way DecrementPP mutates wBattleMonPP
|
||||
local function slot(battler, i) return battler.curMoves[i] end
|
||||
|
||||
-- consume the queue the way updateQueue does, minus the presentation
|
||||
local function drain(battle)
|
||||
local rows = {}
|
||||
for _ = 1, 400 do
|
||||
local item = table.remove(battle.queue, 1)
|
||||
if not item then return rows end
|
||||
if item.text then rows[#rows + 1] = { text = item.text } end
|
||||
if item.fn then
|
||||
battle.nextInsert = 0
|
||||
item.fn()
|
||||
end
|
||||
end
|
||||
error("the turn queue never drained")
|
||||
end
|
||||
|
||||
local function saidWith(rows, needle)
|
||||
for i, row in ipairs(rows) do
|
||||
if row.text and row.text:find(needle, 1, true) then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- "X's / MOVE is / disabled!" (PrintMoveIsDisabledText) versus
|
||||
-- DisableEffect's own "MOVE was / disabled!" -- the two lines differ only
|
||||
-- in that verb, so match on it
|
||||
local function blocked(rows) return saidWith(rows, "is\ndisabled!") end
|
||||
local function landed(rows) return saidWith(rows, "was\ndisabled!") end
|
||||
local function usedTackle(rows) return saidWith(rows, "used FIX TACKLE!") end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the player Disables first; the foe's latched FIX TACKLE dies this turn
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle(true)
|
||||
battle.enemyAction = function() return slot(battle.enemy, 1) end
|
||||
local hpBefore = battle.player.mon.hp
|
||||
|
||||
battle:resolveTurn(slot(battle.player, 3))
|
||||
local rows = drain(battle)
|
||||
|
||||
T.check(landed(rows) ~= nil, "the Disable lands")
|
||||
T.eq(battle.enemy.disabledSlot, 1, "and latches onto the foe's slot 1")
|
||||
T.check(blocked(rows) ~= nil,
|
||||
"the foe's already-selected move reports as disabled")
|
||||
T.check(landed(rows) and blocked(rows) and landed(rows) < blocked(rows),
|
||||
"in that order: disabled first, then the blocked attempt")
|
||||
T.check(usedTackle(rows) == nil,
|
||||
"the disabled move is never announced, so it never executed")
|
||||
T.eq(battle.player.mon.hp, hpBefore, "and it deals no damage")
|
||||
T.eq(battle.enemy.disabledTurns, 3,
|
||||
"the counter ticked once for this turn and the disable is still live")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the same, mirrored: the foe Disables first and the player's latched
|
||||
-- move dies (core.asm:5752 .checkIfTriedToUseDisabledMove)
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle(false)
|
||||
battle.enemyAction = function() return slot(battle.enemy, 3) end
|
||||
local hpBefore = battle.enemy.mon.hp
|
||||
local ppBefore = slot(battle.player, 1).pp
|
||||
|
||||
battle:resolveTurn(slot(battle.player, 1))
|
||||
local rows = drain(battle)
|
||||
|
||||
T.check(landed(rows) ~= nil, "the foe's Disable lands")
|
||||
T.eq(battle.player.disabledSlot, 1, "on the player's slot 1")
|
||||
T.check(blocked(rows) ~= nil, "the player's latched move reports as disabled")
|
||||
T.check(usedTackle(rows) == nil, "and is never announced")
|
||||
T.eq(battle.enemy.mon.hp, hpBefore, "the foe takes no damage")
|
||||
T.eq(slot(battle.player, 1).pp, ppBefore,
|
||||
"and the move that never executed spends no PP (DecrementPP is inside "
|
||||
.. "the move, past the status gauntlet)")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- no regression on the turns after: the disable keeps blocking that move
|
||||
-- while its counter runs, and a different move still works
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle(true)
|
||||
battle.enemyAction = function() return slot(battle.enemy, 1) end
|
||||
battle:resolveTurn(slot(battle.player, 3))
|
||||
drain(battle)
|
||||
T.eq(battle.enemy.disabledTurns, 3, "the disable is live going into turn 2")
|
||||
|
||||
-- turn 2: the foe picks the disabled move with no Disable in flight
|
||||
local hpBefore = battle.player.mon.hp
|
||||
battle:resolveTurn(slot(battle.player, 2))
|
||||
local rows = drain(battle)
|
||||
T.check(blocked(rows) ~= nil, "turn 2 still blocks the disabled move")
|
||||
T.check(usedTackle(rows) == nil, "still no execution")
|
||||
T.eq(battle.player.mon.hp, hpBefore, "still no damage")
|
||||
T.eq(battle.enemy.disabledTurns, 2, "and the counter keeps ticking down")
|
||||
|
||||
-- turn 3: the foe picks its OTHER move, which was never disabled
|
||||
battle.enemyAction = function() return slot(battle.enemy, 2) end
|
||||
hpBefore = battle.player.mon.hp
|
||||
rows = (function() battle:resolveTurn(slot(battle.player, 2)); return drain(battle) end)()
|
||||
T.check(blocked(rows) == nil, "an undisabled move is not blocked")
|
||||
T.check(saidWith(rows, "used FIX SCRATCH!") ~= nil, "it is announced")
|
||||
T.check(battle.player.mon.hp < hpBefore, "and it deals damage")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the counter tick still runs ahead of the check: a disable that expires
|
||||
-- on this turn frees the move it was holding (.DisabledCheck precedes
|
||||
-- .TriedToUseDisabledMoveCheck)
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle(true)
|
||||
battle.enemy.disabledSlot, battle.enemy.disabledTurns = 1, 1
|
||||
battle.enemyAction = function() return slot(battle.enemy, 1) end
|
||||
local hpBefore = battle.player.mon.hp
|
||||
|
||||
battle:resolveTurn(slot(battle.player, 2))
|
||||
local rows = drain(battle)
|
||||
|
||||
T.check(saidWith(rows, "disabled no more!") ~= nil, "the disable expires")
|
||||
T.check(blocked(rows) == nil, "so the move is not blocked")
|
||||
T.check(usedTackle(rows) ~= nil, "it executes")
|
||||
T.check(battle.player.mon.hp < hpBefore, "and deals damage")
|
||||
end
|
||||
|
||||
T.finish("disable blocks the already-selected move (#860)")
|
||||
@@ -110,4 +110,92 @@ local last = Logger.history[#Logger.history]
|
||||
check(last and last:find("options save failed", 1, true) ~= nil,
|
||||
"the last failure logged is the false-return one, not the readback one")
|
||||
|
||||
-- ---- an interrupted write no longer resets every setting
|
||||
-- The launcher wrote WIDE and a later write dies partway through (the
|
||||
-- process replaced by HostShell.restart on the way back to the launcher, an
|
||||
-- external-storage flush that never happened), leaving a corrupt
|
||||
-- options.lua. loadOptions must promote the staged/backup copy instead of
|
||||
-- answering defaults, which is what "closing the game reset all my
|
||||
-- settings" looked like.
|
||||
local live = memfs("honest")
|
||||
SaveData.saveOptions({ battleLayout = "wide" }, live)
|
||||
SaveData.saveOptions({ battleLayout = "wide", textSpeed = 1 }, live)
|
||||
check(live.files[OPTIONS .. ".bak"] ~= nil,
|
||||
"the previous good options.lua is rolled aside before the rewrite")
|
||||
check(live.files[OPTIONS .. ".tmp"] == nil,
|
||||
"the staged witness is dropped once the main write is verified")
|
||||
live.files[OPTIONS] = "return { battleLayout = " -- died mid-rewrite
|
||||
local healed = SaveData.loadOptions(live)
|
||||
eq(healed and healed.battleLayout, "wide",
|
||||
"a corrupt options.lua is recovered from the rolled-aside copy")
|
||||
check(live.files[OPTIONS] ~= "return { battleLayout = ",
|
||||
"the main options file is healed from the copy that parsed")
|
||||
|
||||
local gone = memfs("honest")
|
||||
SaveData.saveOptions({ battleLayout = "wide" }, gone)
|
||||
gone.files[OPTIONS] = nil
|
||||
gone.files[OPTIONS .. ".bak"] = nil
|
||||
gone.files[OPTIONS .. ".tmp"] = nil
|
||||
eq(SaveData.loadOptions(gone).battleLayout,
|
||||
SaveData.defaultOptions().battleLayout,
|
||||
"with no copy left the defaults are still the answer")
|
||||
|
||||
-- ---- the reported sequence end to end: launcher setting -> play -> quit
|
||||
-- #828 as the reporter walks it (issue steps 2-7, and the "so its partly
|
||||
-- fixed" comment): change BATTLE LAYOUT from OG to WIDE in the launcher, go
|
||||
-- in game, close, reopen the launcher. Every options write is a whole-file
|
||||
-- rewrite out of the caller's table (saveOptions above), so the only thing
|
||||
-- keeping the launcher's key alive across a game-side write is WHEN the game
|
||||
-- took its copy: SaveData.load re-attaches a fresh loadOptions() to the save
|
||||
-- it just read (src/core/SaveData.lua:1108, and SaveData.newGame does the
|
||||
-- same at :1458), which is after the launcher's last write because
|
||||
-- RomImporter:play hands off only once the settings modal has saved
|
||||
-- (src/import/LauncherSettings.lua open/save, src/import/RomImporter.lua
|
||||
-- play). This pins that ordering: it is the invariant, not the merge, that
|
||||
-- makes the launcher's change survive.
|
||||
local hop = memfs("honest")
|
||||
SaveData.saveOptions({ battleLayout = "og" }, hop)
|
||||
|
||||
-- launcher: the gear menu's edited table, persisted on close
|
||||
local launcherOpts = SaveData.loadOptions(hop)
|
||||
launcherOpts.battleLayout = "wide"
|
||||
launcherOpts.lastVersion = "blue" -- #835 rides the same file
|
||||
SaveData.saveOptions(launcherOpts, hop)
|
||||
|
||||
-- boot: the game's copy is taken here, never earlier
|
||||
local gameOpts = SaveData.loadOptions(hop)
|
||||
eq(gameOpts.battleLayout, "wide",
|
||||
"the game boots on the value the launcher just wrote")
|
||||
|
||||
-- play: an in-game OPTION menu change writes the whole table back
|
||||
gameOpts.textSpeed = 1
|
||||
check(SaveData.saveOptions(gameOpts, hop) ~= nil, "the game-side write lands")
|
||||
|
||||
local reopened = SaveData.loadOptions(hop)
|
||||
eq(reopened.battleLayout, "wide",
|
||||
"the launcher's BATTLE LAYOUT survives a game-side options write (#828)")
|
||||
eq(reopened.textSpeed, 1, "and the in-game change is persisted alongside it")
|
||||
eq(reopened.lastVersion, "blue",
|
||||
"launcher-only keys the game never reads are carried through its write")
|
||||
|
||||
-- The corollary, and the reason the copy has to come from loadOptions: a
|
||||
-- caller that writes a partial literal instead of a loaded table drops every
|
||||
-- key it does not mention, because mergeOptions only fills DEFAULTS in around
|
||||
-- what it is handed (SaveData.mergeOptions). Nothing on the boot path does
|
||||
-- this today; the assertion is the guard rail if someone shortcuts it.
|
||||
SaveData.saveOptions({ battleLayout = "og" }, hop)
|
||||
eq(SaveData.loadOptions(hop).lastVersion, nil,
|
||||
"a partial write drops launcher-only keys, so the game must write the "
|
||||
.. "table loadOptions handed it")
|
||||
|
||||
-- Known gap, deliberately not asserted: a copy taken BEFORE the launcher's
|
||||
-- write and flushed after it still wins, because saveOptions merges only
|
||||
-- modOptions from disk and every other key is last-writer-wins. Measured,
|
||||
-- not guessed (og beats a newer wide). No shipping path holds an options
|
||||
-- table across a launcher write -- HostShell.restart replaces the process on
|
||||
-- the way back to the launcher (#785, #575) and LauncherSettings.open notes
|
||||
-- its own cached table is only true while its modal covers the launcher --
|
||||
-- so closing that gap needs a three-way merge (baseline vs caller vs disk),
|
||||
-- not a straight "disk wins", which would throw away real in-game changes.
|
||||
|
||||
T.finish("options_write_readback_bug828")
|
||||
|
||||
Reference in New Issue
Block a user