mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-06 17:10:55 -04:00
Merge branch 'dev' into pr-2409
# Conflicts: # src/ui/game3/save_menu.lua
This commit is contained in:
@@ -403,10 +403,19 @@ jobs:
|
||||
cargo -V >> "$fingerprint"
|
||||
printf '%s\n' "${ImageOS:-}" "${ImageVersion:-}" >> "$fingerprint"
|
||||
if [ "$PLAT" = android ]; then
|
||||
ndk="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-}}"
|
||||
ndk_ver="$(sed -n "s/^[[:space:]]*ndkVersion[[:space:]]*['\"]\([0-9.]*\)['\"].*/\1/p" mobile/android/app/build.gradle)"
|
||||
test -n "$ndk_ver"
|
||||
sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
|
||||
test -n "$sdk"
|
||||
ndk="$sdk/ndk/$ndk_ver"
|
||||
if [ ! -f "$ndk/source.properties" ]; then
|
||||
(yes || true) | "$sdk/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$ndk_ver" >/dev/null
|
||||
fi
|
||||
test -f "$ndk/source.properties"
|
||||
grep -Eq "^Pkg\.Revision[[:space:]]*=[[:space:]]*$ndk_ver\$" "$ndk/source.properties"
|
||||
cat "$ndk/source.properties" >> "$fingerprint"
|
||||
echo "ANDROID_NDK_HOME=$ndk" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_NDK_ROOT=$ndk" >> "$GITHUB_ENV"
|
||||
fi
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
hash="$(sha256sum "$fingerprint")"
|
||||
@@ -488,6 +497,12 @@ jobs:
|
||||
if [ "$PLAT" = android ]; then
|
||||
for abi in arm64-v8a armeabi-v7a; do
|
||||
test -s "dist/native/$PLAT/$abi/$LIB"
|
||||
case "$abi" in
|
||||
arm64-v8a) triple=aarch64-linux-android ;;
|
||||
armeabi-v7a) triple=arm-linux-androideabi ;;
|
||||
esac
|
||||
libcxx="$(ls "$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/sysroot/usr/lib/$triple/libc++_shared.so)"
|
||||
bash scripts/android_bridge_link_check.sh "dist/native/$PLAT/$abi/$LIB" "$libcxx"
|
||||
done
|
||||
else
|
||||
test -s "dist/native/$PLAT/$LIB"
|
||||
|
||||
@@ -248,6 +248,7 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
|
||||
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
|
||||
| `--no-sync` | skip the save sync a linked device otherwise runs before the game boots (`POKEPORT_LAUNCH_SYNC=0`) |
|
||||
| `--update` | check for a release first, and restart once into it if one is ready (`POKEPORT_LAUNCH_UPDATE=1`; `-update` works too) |
|
||||
| `--update-mods` | run the MODS tab's Update all (mods and custom carts) without the confirm, then boot; stays on the launcher if an update fails (`POKEPORT_LAUNCH_UPDATE_MODS=1`; `--updatemods` works too) |
|
||||
|
||||
If this device is linked for save sync, a shortcut now syncs before it boots
|
||||
so CONTINUE never loads a save another device has already moved past. The
|
||||
@@ -271,6 +272,7 @@ The URL parameters correspond to the desktop options:
|
||||
| `gen1recomp++://launch?game=red&launcher=1` | open the launcher on Red instead |
|
||||
| `gen1recomp++://launch?game=red&sync=0` | skip save sync |
|
||||
| `gen1recomp++://launch?game=red&update=1` | check for an update before booting |
|
||||
| `gen1recomp++://launch?game=red&update_mods=1` | update mods and carts before booting |
|
||||
|
||||
The `game` value accepts the same full names and aliases as `--game`. Boolean
|
||||
parameters accept `1`/`0`, `true`/`false`, `yes`/`no`, and `on`/`off`. Percent-encode
|
||||
|
||||
+16
-2
@@ -768,7 +768,12 @@ local function jigglypuffDance(game, npc, ow)
|
||||
return {
|
||||
sound = function()
|
||||
Music.stop() -- SFX_STOP_ALL_MUSIC
|
||||
return { isPlaying = function() return phase ~= "done" end }
|
||||
local budget = (JIGGLYPUFF_SILENCE + Music.ONE_SHOT_CEILING
|
||||
+ JIGGLYPUFF_STEP + JIGGLYPUFF_TAIL) / 60 + 1
|
||||
return {
|
||||
isPlaying = function() return phase ~= "done" end,
|
||||
getDuration = function() return budget end,
|
||||
}
|
||||
end,
|
||||
tick = function()
|
||||
frames = frames + 1
|
||||
@@ -802,8 +807,17 @@ local function jigglypuffDance(game, npc, ow)
|
||||
end
|
||||
if frames >= JIGGLYPUFF_TAIL then
|
||||
phase = "done"
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
local starter
|
||||
for _, mon in ipairs(game.save.party or {}) do
|
||||
if PikachuFollower.isStarterPikachu(game.save, mon) then
|
||||
starter = mon
|
||||
break
|
||||
end
|
||||
end
|
||||
-- scripts/PewterPokecenter_2.asm:65
|
||||
if require("src.core.GameVersion").isYellow()
|
||||
and require("src.world.PikachuFollower").starterInParty(game.save) then
|
||||
and starter and not starter.status then
|
||||
ow.pikachuPewterSleepScene = true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,6 +22,52 @@ local M = {}
|
||||
local baseMtMoonStep = require("data.scripts.story2").MT_MOON_B2F.onStep
|
||||
local baseSilph11Step = require("data.scripts.story").SILPH_CO_11F.onStep
|
||||
|
||||
-- engine/overworld/movement.asm:932
|
||||
local FAST = { stepFrames = 16 }
|
||||
-- engine/overworld/movement.asm:349
|
||||
local HOLD = { hold = 510 }
|
||||
|
||||
local function flat(list)
|
||||
local out = {}
|
||||
for _, r in ipairs(list) do
|
||||
if type(r[1]) == "table" then
|
||||
for _, sub in ipairs(r) do out[#out + 1] = sub end
|
||||
else
|
||||
out[#out + 1] = r
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- scripts/MtMoonB2F.asm:446
|
||||
local function mottoRows(ow, textId, faceDir)
|
||||
local t = 0
|
||||
return {
|
||||
{ "text_opts", { auto = { delay = 91, tick = function()
|
||||
t = t + 1
|
||||
local p = ow.player
|
||||
if t == 11 then
|
||||
-- engine/overworld/emotion_bubbles.asm:60
|
||||
if p then ow.emote = { npc = p, frames = 60, bubble = 1 } end
|
||||
elseif t == 71 then
|
||||
ow.emote = nil
|
||||
if p then p.facing = faceDir end
|
||||
end
|
||||
end } } },
|
||||
{ "show_text", textId },
|
||||
}
|
||||
end
|
||||
|
||||
-- scripts/MtMoonB2F.asm:356
|
||||
local function partingRows(textId, a, b)
|
||||
return {
|
||||
{ "face_object", a, "down", HOLD },
|
||||
{ "face_object", b, "down", HOLD },
|
||||
{ "text_opts", { auto = { delay = 64 } } },
|
||||
{ "show_text", textId },
|
||||
}
|
||||
end
|
||||
|
||||
M.MT_MOON_B2F = {
|
||||
talk = {
|
||||
TEXT_MTMOONB2F_JESSIE = {
|
||||
@@ -42,14 +88,12 @@ M.MT_MOON_B2F = {
|
||||
if not (f.EVENT_GOT_DOME_FOSSIL or f.EVENT_GOT_HELIX_FOSSIL) then
|
||||
return false
|
||||
end
|
||||
ow.runner:run({
|
||||
ow.runner:run(flat({
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
|
||||
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
|
||||
{ "show_text", "_MtMoonJessieJamesText1" },
|
||||
{ "face_player_dir", "up" },
|
||||
{ "emote", "player", "shock", 30 },
|
||||
mottoRows(ow, "_MtMoonJessieJamesText1", "up"),
|
||||
-- MtMoonB2FScript_49e15 simulates PAD_UP for one player step, then
|
||||
-- Script6/Script9 walk Jessie (object 2, MovementData_f9e65: six
|
||||
-- $06) and James (object 6, f9e66: five $06); both objects' movement
|
||||
@@ -57,10 +101,10 @@ M.MT_MOON_B2F = {
|
||||
-- Jessie (9,3)->(3,3) above the player at (3,4), James (9,4)->(4,4)
|
||||
-- beside him. #423
|
||||
{ "walk_npc", "player", { "up" } },
|
||||
{ "walk_npc", 2, { "left", "left", "left", "left", "left", "left" } },
|
||||
{ "face_object", 2, "down" },
|
||||
{ "walk_npc", 6, { "left", "left", "left", "left", "left" } },
|
||||
{ "face_object", 6, "left" },
|
||||
{ "walk_npc", 2, { "left", "left", "left", "left", "left", "left" }, FAST },
|
||||
{ "face_object", 2, "down", HOLD },
|
||||
{ "walk_npc", 6, { "left", "left", "left", "left", "left" }, FAST },
|
||||
{ "face_object", 6, "left", HOLD },
|
||||
{ "show_text", "_MtMoonJessieJamesText2" },
|
||||
-- MtMoonB2FScript12 arms _MtMoonJessieJamesText3 with
|
||||
-- SaveEndBattleTextPointers before it sets wCurOpponent, so
|
||||
@@ -71,7 +115,7 @@ M.MT_MOON_B2F = {
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_MtMoonJessieJamesText4" },
|
||||
partingRows("_MtMoonJessieJamesText4", 2, 6),
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "fade", "out" },
|
||||
@@ -80,7 +124,7 @@ M.MT_MOON_B2F = {
|
||||
{ "fade", "in" },
|
||||
{ "play_default_music" },
|
||||
{ "set_flag", "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" },
|
||||
}, {})
|
||||
}), {})
|
||||
return true
|
||||
end,
|
||||
}
|
||||
@@ -114,12 +158,10 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
-- RocketHideoutB4FJessieJamesMovementData_45605/45606 swap so the
|
||||
-- column-mate walks 3, the other 4.
|
||||
local onLeft = (x == 25)
|
||||
ow.runner:run({
|
||||
ow.runner:run(flat({
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText1" },
|
||||
{ "face_player_dir", "up" },
|
||||
{ "emote", "player", "shock", 30 },
|
||||
mottoRows(ow, "_RocketHideoutJessieJamesText1", "up"),
|
||||
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
|
||||
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
|
||||
-- James (object 2) then Jessie (object 3), Script4..Script9 order.
|
||||
@@ -133,11 +175,11 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
-- 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" },
|
||||
or { "down", "down", "down", "down" }, FAST },
|
||||
{ "face_object", 2, onLeft and "down" or "left", HOLD },
|
||||
{ "walk_npc", 3, onLeft and { "down", "down", "down", "down" }
|
||||
or { "down", "down", "down" } },
|
||||
{ "face_object", 3, onLeft and "right" or "down" },
|
||||
or { "down", "down", "down" }, FAST },
|
||||
{ "face_object", 3, onLeft and "right" or "down", HOLD },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText2" },
|
||||
-- RocketHideoutB4FScript10 saves _RocketHideoutJessieJamesText3 as the
|
||||
-- end-battle text, so it prints as "ROCKET: Such a dreadful twerp!" on
|
||||
@@ -146,7 +188,7 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 43 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "lost" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText4" },
|
||||
partingRows("_RocketHideoutJessieJamesText4", 2, 3),
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "fade", "out" },
|
||||
@@ -159,7 +201,7 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
{ "label", "lost" },
|
||||
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
|
||||
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
|
||||
}, {})
|
||||
}), {})
|
||||
return true
|
||||
end,
|
||||
}
|
||||
@@ -187,14 +229,12 @@ M.POKEMON_TOWER_7F = {
|
||||
if y ~= 12 or (x ~= 10 and x ~= 11) then return false end
|
||||
if f.EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES then return false end
|
||||
local onLeft = (x == 11) -- EVENT_POKEMONTOWER_7_JESSIE_JAMES_ON_LEFT
|
||||
ow.runner:run({
|
||||
ow.runner:run(flat({
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "show_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JESSIE" },
|
||||
{ "show_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JAMES" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText1" },
|
||||
{ "face_player_dir", "up" },
|
||||
{ "emote", "player", "shock", 30 },
|
||||
mottoRows(ow, "_PokemonTowerJessieJamesText1", "up"),
|
||||
-- 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
|
||||
@@ -203,11 +243,11 @@ M.POKEMON_TOWER_7F = {
|
||||
-- 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" },
|
||||
or { "down", "down", "down" }, FAST },
|
||||
{ "face_object", 1, onLeft and "right" or "down", HOLD },
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" }
|
||||
or { "down", "down", "down", "down" } },
|
||||
{ "face_object", 2, onLeft and "down" or "left" },
|
||||
or { "down", "down", "down", "down" }, FAST },
|
||||
{ "face_object", 2, onLeft and "down" or "left", HOLD },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText2" },
|
||||
-- PokemonTower7FScript7 saves _PokemonTowerJessieJamesText3 as the
|
||||
-- end-battle text: "ROCKET: You will regret this!" on the battle screen,
|
||||
@@ -216,7 +256,7 @@ M.POKEMON_TOWER_7F = {
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 44 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText4" },
|
||||
partingRows("_PokemonTowerJessieJamesText4", 1, 2),
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "fade", "out" },
|
||||
@@ -225,7 +265,7 @@ M.POKEMON_TOWER_7F = {
|
||||
{ "fade", "in" },
|
||||
{ "play_default_music" },
|
||||
{ "set_flag", "EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES" },
|
||||
}, {})
|
||||
}), {})
|
||||
return true
|
||||
end,
|
||||
}
|
||||
@@ -273,17 +313,15 @@ M.SILPH_CO_11F = {
|
||||
jessieDirs = { "up", "up", "up", "left", "up", "up" }
|
||||
jessieFace = "left"
|
||||
end
|
||||
ow.runner:run({
|
||||
ow.runner:run(flat({
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "show_text", "_SilphCoJessieJamesText1" },
|
||||
{ "face_player_dir", "down" },
|
||||
{ "emote", "player", "shock", 30 },
|
||||
mottoRows(ow, "_SilphCoJessieJamesText1", "down"),
|
||||
-- James (object 4) then Jessie (object 6), Script5..Script10 order
|
||||
{ "walk_npc", 4, jamesDirs },
|
||||
{ "face_object", 4, jamesFace },
|
||||
{ "walk_npc", 6, jessieDirs },
|
||||
{ "face_object", 6, jessieFace },
|
||||
{ "walk_npc", 4, jamesDirs, FAST },
|
||||
{ "face_object", 4, jamesFace, HOLD },
|
||||
{ "walk_npc", 6, jessieDirs, FAST },
|
||||
{ "face_object", 6, jessieFace, HOLD },
|
||||
{ "show_text", "_SilphCoJessieJamesText2" },
|
||||
-- SilphCo11FScript11 saves _SilphCoJessieJamesText3 (SilphCo11FText_624c2)
|
||||
-- as the end-battle text: "ROCKET: Like always..." before the money (#866).
|
||||
@@ -291,7 +329,7 @@ M.SILPH_CO_11F = {
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 45 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_SilphCoJessieJamesText4" },
|
||||
partingRows("_SilphCoJessieJamesText4", 4, 6),
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "fade", "out" },
|
||||
@@ -300,7 +338,7 @@ M.SILPH_CO_11F = {
|
||||
{ "fade", "in" },
|
||||
{ "play_default_music" },
|
||||
{ "set_flag", "EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES" },
|
||||
}, {})
|
||||
}), {})
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
|
||||
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
|
||||
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, `["silver"]`, or `["all"]`. |
|
||||
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
|
||||
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
|
||||
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). The player can override it from the launcher MODS tab (Sort -> Load order) or the in-game manager; dependencies and a cart's `load_order` still win. |
|
||||
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
|
||||
| `optional_dependencies` | `array` | Soft dependencies. Guarantees that if the target mod is present and active, it loads *before* this mod without blocking load if absent. |
|
||||
| `required_imports` | `array` | User-supplied files required by this mod. The launcher validates and copies each file into this mod's `baseroms/` directory; the mod does not load while one is missing. |
|
||||
|
||||
@@ -27,7 +27,10 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
||||
* **Rebindable GAME SPEED shortcuts**, SPEED - / SPEED + rows in CONTROLS that move the shoulder hotkeys to any pad button or switch them off
|
||||
* **Key bar on the touch pad**, a corner toggle that slides out SAVE, LOAD, SPEED, COLOR, TILT and ZOOM for phones with no keyboard
|
||||
* **Save editor item verbs**, sorting the bag and PC by item number or name, filling one stack or every stack to x99, and a coin editor on every game
|
||||
* **Save editor bag / PC move**, a PC / BAG button on every item row that moves the stack across, clamped to the stack and slot caps
|
||||
* **Shortcuts sync before they boot**, a `--game` launch syncing saves (and, with `--update`, taking a release) first, skippable with any button
|
||||
* **`--update-mods`**, a launch flag that runs Update all before the game boots
|
||||
* **Mod load order**, a Load order sort in the launcher MODS tab and LOAD EARLIER / LATER in the in-game manager, no manifest editing
|
||||
|
||||
## Gen 2 Specifics
|
||||
|
||||
|
||||
@@ -399,6 +399,8 @@ local function makeLauncher(launcherOpts)
|
||||
require("src.import.LauncherWindow").observe(0)
|
||||
require("src.import.LauncherWindow").flush()
|
||||
Importer = nil
|
||||
local onBoot = launcherOpts and launcherOpts.onBoot
|
||||
if onBoot and onBoot(version, cartId, opts) then return end
|
||||
bootGame(version, cartId, opts)
|
||||
end, {
|
||||
launcher = true,
|
||||
@@ -569,8 +571,35 @@ local function showLauncher(version)
|
||||
end
|
||||
end
|
||||
|
||||
local function wantsModUpdate(request)
|
||||
if type(request) ~= "table" then return false end
|
||||
if type(request.tasks) == "table" and request.tasks.mods ~= nil then
|
||||
return request.tasks.mods == true
|
||||
end
|
||||
return request.updateMods == true
|
||||
end
|
||||
|
||||
local function autoUpdateMods(request, tab)
|
||||
if not wantsModUpdate(request) then return end
|
||||
if Importer and Importer.autoUpdateAll then
|
||||
Importer:autoUpdateAll(function() end, { tab = tab })
|
||||
end
|
||||
end
|
||||
|
||||
local deferredLaunchRequest
|
||||
|
||||
local function launcherBusy()
|
||||
return Importer ~= nil and (Importer._updateAll ~= nil
|
||||
or Importer._modInstall ~= nil or Importer._cartInstall ~= nil
|
||||
or Importer._autoUpdateAll ~= nil)
|
||||
end
|
||||
|
||||
local function startLaunchRequest(request)
|
||||
if type(request) ~= "table" then return false end
|
||||
if launcherBusy() then
|
||||
deferredLaunchRequest = request
|
||||
return true
|
||||
end
|
||||
|
||||
local version = request.game
|
||||
if request.launcher or not version then
|
||||
@@ -579,6 +608,7 @@ local function startLaunchRequest(request)
|
||||
else
|
||||
showLauncher(version)
|
||||
end
|
||||
autoUpdateMods(request, not version and "mods" or nil)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -594,6 +624,7 @@ local function startLaunchRequest(request)
|
||||
end)
|
||||
if not ok or type(cart) ~= "table" or cart.base ~= version then
|
||||
showLauncher(version)
|
||||
autoUpdateMods(request)
|
||||
return true
|
||||
end
|
||||
cartId = request.cart
|
||||
@@ -601,6 +632,7 @@ local function startLaunchRequest(request)
|
||||
|
||||
if not RomImporter.isReady(version) then
|
||||
showLauncher(version)
|
||||
autoUpdateMods(request)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -610,6 +642,23 @@ local function startLaunchRequest(request)
|
||||
bootGame(version, cartId)
|
||||
end
|
||||
|
||||
local function bootAfterMods()
|
||||
if not wantsModUpdate(request) then return bootShortcut() end
|
||||
Importer = makeLauncher({ initialTab = "mods",
|
||||
onBoot = function(v, c, opts)
|
||||
if v ~= version or c ~= cartId or opts ~= nil then return false end
|
||||
bootShortcut()
|
||||
return true
|
||||
end })
|
||||
Importer:autoUpdateAll(function(result)
|
||||
if not (result.ok or result.cancelled or result.skipped) then return end
|
||||
require("src.import.LauncherWindow").observe(0)
|
||||
require("src.import.LauncherWindow").flush()
|
||||
Importer = nil
|
||||
bootShortcut()
|
||||
end)
|
||||
end
|
||||
|
||||
Prelaunch = require("src.core.Prelaunch").new({
|
||||
version = version,
|
||||
tasks = request.tasks or {},
|
||||
@@ -620,10 +669,10 @@ local function startLaunchRequest(request)
|
||||
showLauncher(version)
|
||||
return
|
||||
end
|
||||
bootShortcut()
|
||||
bootAfterMods()
|
||||
end,
|
||||
})
|
||||
if not Prelaunch then bootShortcut() end
|
||||
if not Prelaunch then bootAfterMods() end
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -784,6 +833,10 @@ function love.load(args)
|
||||
-- goes to its own service owner, src/core/Game2.lua -- docs/gold-phase1.md).
|
||||
-- Edit on a save row opens the bundled editor on that slot (openEditor).
|
||||
Importer = makeLauncher()
|
||||
if not relaunched then
|
||||
autoUpdateMods(resolvedLaunch,
|
||||
not resolvedLaunch.game and "mods" or nil)
|
||||
end
|
||||
end
|
||||
|
||||
function love.update(dt)
|
||||
@@ -797,6 +850,11 @@ function love.update(dt)
|
||||
if Studio then return Studio.update(dt) end
|
||||
local launchURI = LaunchOptions.pollURI()
|
||||
if launchURI then love.handlers.intent_uri(launchURI) end
|
||||
if deferredLaunchRequest and not launcherBusy() then
|
||||
local request = deferredLaunchRequest
|
||||
deferredLaunchRequest = nil
|
||||
startLaunchRequest(request)
|
||||
end
|
||||
if Prelaunch then return Prelaunch:update(dt) end
|
||||
local client = onlineClientModule()
|
||||
if client then pcall(client.update, dt) end
|
||||
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export LC_ALL=C
|
||||
|
||||
if [ $# -ne 2 ]; then
|
||||
echo "usage: $0 <liblibrashader_bridge.so> <libc++_shared.so>" >&2
|
||||
exit 2
|
||||
fi
|
||||
bridge="$1"
|
||||
libcxx="$2"
|
||||
if [ ! -f "$bridge" ]; then
|
||||
echo "no bridge at $bridge" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
nm="${LLVM_NM:-}"
|
||||
if [ -z "$nm" ]; then
|
||||
for ndk in "${ANDROID_NDK_HOME:-}" "${ANDROID_NDK_ROOT:-}"; do
|
||||
[ -n "$ndk" ] || continue
|
||||
for cand in "$ndk"/toolchains/llvm/prebuilt/*/bin/llvm-nm; do
|
||||
if [ -x "$cand" ]; then
|
||||
nm="$cand"
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [ -z "$nm" ]; then
|
||||
nm="$(command -v llvm-nm || true)"
|
||||
fi
|
||||
if [ -z "$nm" ] || [ ! -x "$nm" ]; then
|
||||
echo "llvm-nm not found (set LLVM_NM or ANDROID_NDK_HOME)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
"$nm" -D --undefined-only "$bridge" > "$tmp/undefined"
|
||||
awk '$(NF-1) == "U" { s = $NF; sub(/@.*/, "", s); if (s ~ /^_Z/) print s }' \
|
||||
"$tmp/undefined" | sort -u > "$tmp/need"
|
||||
|
||||
: > "$tmp/have"
|
||||
if [ -f "$libcxx" ]; then
|
||||
"$nm" -D --defined-only "$libcxx" > "$tmp/defined"
|
||||
awk '{ s = $NF; sub(/@.*/, "", s); print s }' "$tmp/defined" | sort -u > "$tmp/have"
|
||||
fi
|
||||
|
||||
comm -23 "$tmp/need" "$tmp/have" > "$tmp/missing"
|
||||
if [ -s "$tmp/missing" ]; then
|
||||
count="$(wc -l < "$tmp/missing" | tr -d ' ')"
|
||||
echo "$(basename "$bridge") needs $count C++ runtime symbols that $libcxx does not define, so dlopen fails:"
|
||||
head -n 5 "$tmp/missing" | sed 's/^/ /'
|
||||
exit 1
|
||||
fi
|
||||
echo "$(basename "$bridge") links against $(basename "$libcxx")"
|
||||
@@ -566,12 +566,76 @@ shader_bridge_absent() {
|
||||
warn "$SHADER_BRIDGE_LIB: $1; this build can run converted presets but not CONVERT new ones"
|
||||
}
|
||||
|
||||
shader_bridge_verify_apk() {
|
||||
local apk="$1" abi missing="" apk_entries
|
||||
apk_entries="$(unzip -Z1 "$apk")"
|
||||
for abi in $SHADER_BRIDGE_ABIS; do
|
||||
grep -qxF "lib/$abi/$SHADER_BRIDGE_LIB" <<< "$apk_entries" || missing="$missing $abi"
|
||||
shader_bridge_ndk_triple() {
|
||||
case "$1" in
|
||||
arm64-v8a) printf 'aarch64-linux-android' ;;
|
||||
armeabi-v7a) printf 'arm-linux-androideabi' ;;
|
||||
x86_64) printf 'x86_64-linux-android' ;;
|
||||
x86) printf 'i686-linux-android' ;;
|
||||
*) printf '' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
shader_bridge_gradle_ndk() {
|
||||
printf '%s' "${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Library/Android/sdk}}/ndk/$NDK_VERSION"
|
||||
}
|
||||
|
||||
shader_bridge_gradle_libcxx() {
|
||||
local abi="$1" triple cand
|
||||
triple="$(shader_bridge_ndk_triple "$abi")"
|
||||
for cand in "$(shader_bridge_gradle_ndk)"/toolchains/llvm/prebuilt/*/sysroot/usr/lib/"$triple"/libc++_shared.so; do
|
||||
if [ -f "$cand" ]; then
|
||||
printf '%s' "$cand"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
printf ''
|
||||
}
|
||||
|
||||
shader_bridge_check_links() {
|
||||
local bridge="$1" libcxx="$2" label="$3" out rc=0
|
||||
if [ -z "$libcxx" ]; then
|
||||
out="no libc++_shared.so under $(shader_bridge_gradle_ndk)"
|
||||
rc=2
|
||||
else
|
||||
out="$(ANDROID_NDK_HOME="$(shader_bridge_gradle_ndk)" \
|
||||
bash "$ROOT/scripts/android_bridge_link_check.sh" "$bridge" "$libcxx" 2>&1)" || rc=$?
|
||||
fi
|
||||
[ "$rc" -eq 0 ] && return 0
|
||||
if [ "$rc" -eq 1 ]; then
|
||||
if [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then
|
||||
fail "$label: $out
|
||||
Build the bridge with NDK $NDK_VERSION (the one gradle packs libc++_shared.so from)."
|
||||
fi
|
||||
warn "$label: $out"
|
||||
return 0
|
||||
fi
|
||||
if [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then
|
||||
fail "$label: cannot check that $SHADER_BRIDGE_LIB links: $out"
|
||||
fi
|
||||
warn "$label: cannot check that $SHADER_BRIDGE_LIB links: $out"
|
||||
}
|
||||
|
||||
shader_bridge_verify_apk() {
|
||||
local apk="$1" abi missing="" apk_entries tmp
|
||||
apk_entries="$(unzip -Z1 "$apk")"
|
||||
tmp="$(mktemp -d)"
|
||||
trap "rm -rf '$tmp'" EXIT
|
||||
for abi in $SHADER_BRIDGE_ABIS; do
|
||||
if grep -qxF "lib/$abi/$SHADER_BRIDGE_LIB" <<< "$apk_entries"; then
|
||||
mkdir -p "$tmp/$abi"
|
||||
unzip -o -q -j "$apk" "lib/$abi/$SHADER_BRIDGE_LIB" -d "$tmp/$abi"
|
||||
if grep -qxF "lib/$abi/libc++_shared.so" <<< "$apk_entries"; then
|
||||
unzip -o -q -j "$apk" "lib/$abi/libc++_shared.so" -d "$tmp/$abi"
|
||||
fi
|
||||
shader_bridge_check_links "$tmp/$abi/$SHADER_BRIDGE_LIB" "$tmp/$abi/libc++_shared.so" \
|
||||
"$(basename "$apk") lib/$abi"
|
||||
else
|
||||
missing="$missing $abi"
|
||||
fi
|
||||
done
|
||||
rm -rf "$tmp"
|
||||
trap - EXIT
|
||||
[ -z "$missing" ] && return 0
|
||||
if [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then
|
||||
fail "$(basename "$apk") is missing lib/<abi>/$SHADER_BRIDGE_LIB for:$missing"
|
||||
@@ -594,6 +658,8 @@ bundle_shader_bridge_android() {
|
||||
if [ -f "$prebuilt/$abi/$SHADER_BRIDGE_LIB" ]; then
|
||||
mkdir -p "$jni/$abi"
|
||||
cp "$prebuilt/$abi/$SHADER_BRIDGE_LIB" "$jni/$abi/$SHADER_BRIDGE_LIB"
|
||||
shader_bridge_check_links "$jni/$abi/$SHADER_BRIDGE_LIB" "$(shader_bridge_gradle_libcxx "$abi")" \
|
||||
"SHADERFX_BRIDGE_ANDROID_DIR $abi"
|
||||
else
|
||||
warn "SHADERFX_BRIDGE_ANDROID_DIR has no $abi/$SHADER_BRIDGE_LIB"
|
||||
fi
|
||||
|
||||
@@ -417,6 +417,7 @@ if [ "$HAVE_RED_DATA" = "1" ]; then
|
||||
run_tier "T3 save editor: gold / gen2" "$LUA" tests/save_editor_gen2_tests.lua
|
||||
run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua
|
||||
run_tier "T3 save editor: pad / NX input" "$LUA" tests/save_editor_pad_input_test.lua
|
||||
run_tier "T3 save editor: bag / PC move" "$LUA" tests/save_editor_item_move_bug1951_test.lua
|
||||
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
|
||||
# The oversize-save vendor oracle (tests/save_oversize_vendor_test.lua)
|
||||
# cross-checks the launcher's footer-truncation import against the
|
||||
|
||||
@@ -4449,10 +4449,8 @@ end
|
||||
|
||||
function BattleState:continueBide(user, target)
|
||||
user.bideTurns = user.bideTurns - 1
|
||||
if user.bideTurns > 0 then
|
||||
self:sayNext(Strings("%s\nis storing energy!", displayName(user)))
|
||||
return
|
||||
end
|
||||
-- engine/battle/core.asm:3499
|
||||
if user.bideTurns > 0 then return end
|
||||
self:sayNext(self:romText("_UnleashedEnergyText", "%s\nunleashed energy!", displayName(user)))
|
||||
local dmg = (user.bideDamage or 0) * 2
|
||||
user.bideTurns, user.bideDamage = nil, nil
|
||||
@@ -4955,6 +4953,8 @@ function BattleState:learnMove(mon, moveId)
|
||||
end
|
||||
if #mon.moves < 4 then
|
||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||
require("src.world.PikachuFollower")
|
||||
.onMoveLearned(self.game.save, mon, moveId)
|
||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
self:sayNextWaitSfx(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
|
||||
mdef.name), function()
|
||||
@@ -5050,6 +5050,7 @@ function BattleState:playerMonFainted()
|
||||
require("src.core.Sound").play(self.data, "Run")
|
||||
self:say(self:romText("_GotAwayText", "Got away safely!"))
|
||||
self.result = "run"
|
||||
self.playerRan = true
|
||||
self.afterQueue = "finish"
|
||||
else
|
||||
self:say(self:romText("_CantEscapeText", "Can't escape!"))
|
||||
@@ -5122,6 +5123,7 @@ function BattleState:safariAction(choice)
|
||||
require("src.core.Sound").play(self.data, "Run")
|
||||
self:say(self:romText("_GotAwayText", "Got away safely!"))
|
||||
self.result = "run"
|
||||
self.playerRan = true
|
||||
self.afterQueue = "finish"
|
||||
return
|
||||
end
|
||||
@@ -5268,6 +5270,7 @@ function BattleState:tryRun()
|
||||
require("src.core.Sound").play(self.data, "Run")
|
||||
self:say(self:romText("_GotAwayText", "Got away safely!"))
|
||||
self.result = "run"
|
||||
self.playerRan = true
|
||||
self.afterQueue = "finish"
|
||||
else
|
||||
self:say(self:romText("_CantEscapeText", "Can't escape!"))
|
||||
@@ -5470,6 +5473,11 @@ function BattleState:storeCaughtMon()
|
||||
-- engine/menus/pokedex.asm:581-582
|
||||
self:actNext(function() self.fieldCleared = true end)
|
||||
end
|
||||
-- engine/items/item_effects.asm:564
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
game.save.pikachuEmotionModifier = 1
|
||||
game.save.pikachuMood = 0x85
|
||||
end
|
||||
local function askCaughtNickname()
|
||||
self:offerNickname(self.enemy.mon, self.enemy.name)
|
||||
end
|
||||
@@ -5708,6 +5716,14 @@ function BattleState:finish()
|
||||
function() self:finish() end, self.leveledUp)
|
||||
return
|
||||
end
|
||||
-- engine/battle/end_of_battle.asm:49
|
||||
if not self.pikachuMoodChecked then
|
||||
self.pikachuMoodChecked = true
|
||||
if self.kind == "link" or (not self.demo and (self.result == "win"
|
||||
or (self.result == "run" and not self.playerRan))) then
|
||||
require("src.world.PikachuFollower").moodAfterBattle(self.game.save)
|
||||
end
|
||||
end
|
||||
-- Invariant: a battle can never hand the overworld a party with nothing
|
||||
-- healthy in it -- except the Oak's Lab starter rival, where pret skips
|
||||
-- the blackout and OaksLabRivalEndBattleScript HealParty's immediately.
|
||||
|
||||
@@ -721,7 +721,6 @@ MoveEffects.full = {
|
||||
ctx.battle:animBeforeMove(
|
||||
user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM",
|
||||
user.isPlayer)
|
||||
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
||||
end,
|
||||
},
|
||||
SWITCH_AND_TELEPORT_EFFECT = {
|
||||
|
||||
@@ -1235,6 +1235,7 @@ function Battle:hitOnce(attacker, defender, def, opts)
|
||||
Damage.isPhysical(def.type, types)),
|
||||
-- BattleCommand_DamageCalc's `srl c` (effect_commands.asm:2905-2913).
|
||||
defenseHalved = def.effect == "EFFECT_SELFDESTRUCT",
|
||||
reflectOverflowFixed = self:reflectOverflowFixed(),
|
||||
random = self.random,
|
||||
}
|
||||
-- battle.damage, the same hook BattleState:computeDamage calls on Gen 1 and
|
||||
@@ -1447,6 +1448,12 @@ function Battle:markMissed()
|
||||
if self.moveEvent then self.moveEvent.missed = true end
|
||||
end
|
||||
|
||||
-- engine/battle/effect_commands.asm:2646
|
||||
function Battle:reflectOverflowFixed()
|
||||
if self.linkBattle then return false end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- engine/battle/effect_commands.asm:3615
|
||||
Battle.AI_FAIL_STATUSES = {
|
||||
sleep = true, poison = true, toxic = true, paralyze = true,
|
||||
@@ -2510,6 +2517,7 @@ Battle.MOVE_EFFECTS.EFFECT_FUTURE_SIGHT = function(self, attacker, defender, def
|
||||
},
|
||||
types = self.data.type_chart and self.data.type_chart.types,
|
||||
matchups = self:matchupsAgainst(defender),
|
||||
reflectOverflowFixed = self:reflectOverflowFixed(),
|
||||
random = self.random,
|
||||
})
|
||||
state.futureSight = Effects.FUTURE_SIGHT_TURNS
|
||||
@@ -2580,6 +2588,7 @@ Battle.MOVE_EFFECTS.EFFECT_BEAT_UP = function(self, attacker, defender, def)
|
||||
},
|
||||
types = self.data.type_chart and self.data.type_chart.types,
|
||||
matchups = self.data.type_chart and self.data.type_chart.matchups,
|
||||
reflectOverflowFixed = self:reflectOverflowFixed(),
|
||||
random = self.random,
|
||||
})
|
||||
self:emit({ kind = "message",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
local Platform = require("src.core.Platform")
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local HostPicker = require("src.core.HostPicker")
|
||||
|
||||
local FilePicker = {}
|
||||
|
||||
@@ -69,12 +70,11 @@ function FilePicker.open(prompt, kind)
|
||||
:format(title, appleTypes(kind.exts)))
|
||||
elseif platform == "Windows" then
|
||||
local script = table.concat({
|
||||
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
HostPicker.WIN_OPEN_DIALOG,
|
||||
"$d.Title='" .. title .. "';",
|
||||
"$d.Filter='" .. kind.label .. " (" .. windowsPatterns(kind.exts) .. ")|"
|
||||
.. windowsPatterns(kind.exts) .. "|All files (*.*)|*.*';",
|
||||
"if($d.ShowDialog() -eq 'OK'){",
|
||||
HostPicker.WIN_SHOW,
|
||||
"$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';",
|
||||
"$t=Join-Path $env:TEMP $n;",
|
||||
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
|
||||
|
||||
+1
-2
@@ -495,8 +495,7 @@ end
|
||||
-- Asked of the WHOLE stack, not just the top. For BATTLE SIZE "fill" that is
|
||||
-- because the party menu, bag and text boxes a battle opens must not snap the
|
||||
-- surface back to the fixed scale for a frame; the title screen and intro want
|
||||
-- it unconditionally, since neither has a world behind it and neither has any
|
||||
-- reason to sit in a small box in the middle of a large window.
|
||||
-- it unless a skin or FAITHFUL RATIO frames the picture.
|
||||
function Game.fillScaleInStack(stack)
|
||||
for i = #(stack and stack.states or {}), 1, -1 do
|
||||
local state = stack.states[i]
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
local HostShell = require("src.core.HostShell")
|
||||
|
||||
local HostPicker = {}
|
||||
|
||||
HostPicker.RESULT = "host_picker_result"
|
||||
HostPicker.WORKER = "src/core/host_picker_worker.lua"
|
||||
|
||||
HostPicker.WIN_OPEN_DIALOG = table.concat({
|
||||
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||
"$o=New-Object System.Windows.Forms.Form;",
|
||||
"$o.TopMost=$true;",
|
||||
"$o.ShowInTaskbar=$false;",
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
})
|
||||
HostPicker.WIN_SHOW = "if($d.ShowDialog($o) -eq 'OK'){"
|
||||
|
||||
local jobs = {}
|
||||
local nextId = 0
|
||||
local resCh
|
||||
|
||||
function HostPicker.available()
|
||||
return (love and love.thread and love.thread.newThread
|
||||
and love.thread.getChannel) and true or false
|
||||
end
|
||||
|
||||
function HostPicker.start(commands)
|
||||
if type(commands) == "string" then commands = { commands } end
|
||||
if type(commands) ~= "table" or #commands == 0 then return nil end
|
||||
if not HostPicker.available() then return nil end
|
||||
local okCh, ch = pcall(love.thread.getChannel, HostPicker.RESULT)
|
||||
if not (okCh and ch) then return nil end
|
||||
resCh = ch
|
||||
local okTh, th = pcall(love.thread.newThread, HostPicker.WORKER)
|
||||
if not (okTh and th) then return nil end
|
||||
HostShell.releasePointerGrab()
|
||||
nextId = nextId + 1
|
||||
local id = nextId
|
||||
if not pcall(function() th:start(id, commands, HostPicker.RESULT) end) then
|
||||
return nil
|
||||
end
|
||||
jobs[id] = { thread = th }
|
||||
return id
|
||||
end
|
||||
|
||||
local function drain()
|
||||
if not resCh then return end
|
||||
local msg = resCh:pop()
|
||||
while msg ~= nil do
|
||||
if type(msg) == "table" and msg.id and jobs[msg.id] then
|
||||
jobs[msg.id].done = true
|
||||
jobs[msg.id].output = msg.output
|
||||
end
|
||||
msg = resCh:pop()
|
||||
end
|
||||
end
|
||||
|
||||
function HostPicker.poll(id)
|
||||
local job = jobs[id]
|
||||
if not job then return "done", nil end
|
||||
drain()
|
||||
if job.done then
|
||||
jobs[id] = nil
|
||||
return "done", job.output
|
||||
end
|
||||
local th = job.thread
|
||||
local err = th and th.getError and th:getError()
|
||||
if err then
|
||||
jobs[id] = nil
|
||||
return "done", nil, err
|
||||
end
|
||||
return "pending"
|
||||
end
|
||||
|
||||
function HostPicker.pending()
|
||||
for _ in pairs(jobs) do return true end
|
||||
return false
|
||||
end
|
||||
|
||||
function HostPicker._reset()
|
||||
jobs = {}
|
||||
resCh = nil
|
||||
end
|
||||
|
||||
return HostPicker
|
||||
@@ -180,6 +180,7 @@ function LaunchOptions.parseURI(uri)
|
||||
request.launcher = booleanValue(values.launcher)
|
||||
request.sync = booleanValue(values.sync)
|
||||
request.update = booleanValue(values.update)
|
||||
request.updateMods = booleanValue(values.update_mods)
|
||||
return request
|
||||
end
|
||||
|
||||
@@ -261,11 +262,16 @@ function LaunchOptions.forceLauncher(argv)
|
||||
return LaunchOptions.resolveRequest(argv, nil).launcher
|
||||
end
|
||||
|
||||
local function taskFlag(argv, rawArgv, name, env, uriValue)
|
||||
if argFlag(argv, "no-" .. name) or argFlag(rawArgv, "no-" .. name) then
|
||||
return false
|
||||
local function taskFlag(argv, rawArgv, names, env, uriValue)
|
||||
if type(names) ~= "table" then names = { names } end
|
||||
for _, name in ipairs(names) do
|
||||
if argFlag(argv, "no-" .. name) or argFlag(rawArgv, "no-" .. name) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
for _, name in ipairs(names) do
|
||||
if argFlag(argv, name) or argFlag(rawArgv, name) then return true end
|
||||
end
|
||||
if argFlag(argv, name) or argFlag(rawArgv, name) then return true end
|
||||
if uriValue ~= nil then return uriValue end
|
||||
local v = os.getenv(env)
|
||||
if v == "1" then return true end
|
||||
@@ -280,6 +286,8 @@ function LaunchOptions.tasks(argv, rawArgv, uri)
|
||||
uri and uri.sync),
|
||||
update = taskFlag(argv, rawArgv, "update", "POKEPORT_LAUNCH_UPDATE",
|
||||
uri and uri.update) == true,
|
||||
mods = taskFlag(argv, rawArgv, { "update-mods", "updatemods" },
|
||||
"POKEPORT_LAUNCH_UPDATE_MODS", uri and uri.updateMods) == true,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -299,6 +307,7 @@ function LaunchOptions.fromGame(game)
|
||||
if not normalized then return nil end
|
||||
local tasks = LaunchOptions.tasks({}, {})
|
||||
tasks.update = false
|
||||
tasks.mods = false
|
||||
return {
|
||||
source = "intent",
|
||||
game = normalized,
|
||||
@@ -328,6 +337,9 @@ function LaunchOptions.uriFor(version, options)
|
||||
if options.update ~= nil then
|
||||
query[#query + 1] = "update=" .. (options.update and "1" or "0")
|
||||
end
|
||||
if options.updateMods ~= nil then
|
||||
query[#query + 1] = "update_mods=" .. (options.updateMods and "1" or "0")
|
||||
end
|
||||
return "gen1recomp++://launch?" .. table.concat(query, "&")
|
||||
end
|
||||
|
||||
@@ -363,6 +375,7 @@ function LaunchOptions.commandFor(version, slot, tasks)
|
||||
if slot then cmd = cmd .. " --slot " .. tostring(slot) end
|
||||
if type(tasks) == "table" then
|
||||
if tasks.update then cmd = cmd .. " --update" end
|
||||
if tasks.mods then cmd = cmd .. " --update-mods" end
|
||||
if tasks.sync == false then cmd = cmd .. " --no-sync" end
|
||||
end
|
||||
return cmd
|
||||
|
||||
@@ -368,6 +368,7 @@ function SaveData.defaultOptions()
|
||||
-- migrated into PROFILE 1, so deleting every profile does not re-seed one.
|
||||
modProfiles = {},
|
||||
modProfilesSeeded = false,
|
||||
modOrder = {},
|
||||
-- GitHub release checks for mods with a manifest "github" field
|
||||
-- (src/mods/ModUpdate.lua). Keyed by owner/repo; TTL is six hours.
|
||||
modUpdateCache = {},
|
||||
@@ -858,6 +859,25 @@ local function forcedGenerations(entry)
|
||||
return entry == true and 2 or nil
|
||||
end
|
||||
|
||||
function SaveData.modOrder(options)
|
||||
local raw = type(options) == "table" and options.modOrder or nil
|
||||
local out, seen = {}, {}
|
||||
if type(raw) ~= "table" then return out end
|
||||
for _, id in ipairs(raw) do
|
||||
if type(id) == "string" and id ~= "" and not seen[id] then
|
||||
seen[id] = true
|
||||
out[#out + 1] = id
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function SaveData.setModOrder(options, ids)
|
||||
if type(options) ~= "table" then return options end
|
||||
options.modOrder = SaveData.modOrder({ modOrder = ids })
|
||||
return options
|
||||
end
|
||||
|
||||
function SaveData.modForced(options, id, version, generation)
|
||||
local entry = type(options) == "table" and type(options.modsGen2) == "table"
|
||||
and options.modsGen2[id]
|
||||
|
||||
@@ -62,6 +62,7 @@ local FANFARES = {
|
||||
Pokedex_Rating = true,
|
||||
Dex_Page_Added = true,
|
||||
Pokeflute = true,
|
||||
Pokeflute_In_Battle = true,
|
||||
}
|
||||
|
||||
-- which mod put this key in the registry, for attributed failure logs
|
||||
@@ -469,6 +470,50 @@ function Sound.play(data, name)
|
||||
return src
|
||||
end
|
||||
|
||||
-- audio/poke_flute.asm:1
|
||||
local POKEFLUTE_IN_BATTLE = {
|
||||
redblue = { 0x6322, 0x6325, 0x449b },
|
||||
yellow = { 0x59eb, 0x59ee, 0x444b },
|
||||
}
|
||||
|
||||
local function pokefluteInBattleDef(data)
|
||||
local sfx = data and data.audio and data.audio.sfx
|
||||
local base = sfx and sfx.Caught_Mon
|
||||
if type(base) ~= "table" or base.chip ~= nil or base.bank ~= 8
|
||||
or base.address ~= 0x41CE then
|
||||
return nil
|
||||
end
|
||||
local alt = require("src.core.GameVersion").isYellow()
|
||||
and POKEFLUTE_IN_BATTLE.yellow or POKEFLUTE_IN_BATTLE.redblue
|
||||
local def = {}
|
||||
for key, value in pairs(base) do def[key] = value end
|
||||
def.startChannels = {
|
||||
{ number = 5, address = alt[1] },
|
||||
{ number = 6, address = alt[2] },
|
||||
{ number = 7, address = alt[3] },
|
||||
}
|
||||
def.fanfare = true
|
||||
return def
|
||||
end
|
||||
Sound._pokefluteInBattleDef = pokefluteInBattleDef
|
||||
|
||||
-- engine/items/item_effects.asm:1739
|
||||
function Sound.playPokefluteInBattle(data)
|
||||
local sfx = data and data.audio and data.audio.sfx
|
||||
if sfx and sfx.Pokeflute_In_Battle then
|
||||
return Sound.play(data, "Pokeflute_In_Battle")
|
||||
end
|
||||
local field = sfx and sfx.Pokeflute
|
||||
-- audio/headers/sfxheaders1.asm:467
|
||||
if field ~= nil and (type(field) ~= "table" or field.chip ~= nil
|
||||
or field.bank ~= 2 or field.address ~= 0x4228) then
|
||||
return Sound.play(data, "Pokeflute")
|
||||
end
|
||||
local def = pokefluteInBattleDef(data)
|
||||
if not def then return Sound.play(data, "Pokeflute") end
|
||||
return startSfx(data, "Pokeflute_In_Battle", def)
|
||||
end
|
||||
|
||||
-- PlayStereoSFX (audio/engine.asm), the battle animation path: same sound,
|
||||
-- same fanfare duck, but no CheckSFX/wCurSFX gate, and it never writes
|
||||
-- wCurSFX either -- so an animation sound can neither be dropped by, nor
|
||||
|
||||
@@ -92,6 +92,13 @@ function TrainerSight.getTrainerId(eo)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- pokefirered/src/trainer_see.c:97
|
||||
function TrainerSight.isTrainerType(eo)
|
||||
if not eo then return false end
|
||||
local tt = tonumber(eo.trainerType or (eo.def and eo.def.trainerType)) or 0
|
||||
return tt == 1 or tt == 3
|
||||
end
|
||||
|
||||
--- Check if trainer has already been defeated.
|
||||
function TrainerSight.isDefeated(eo, store, ctx)
|
||||
if not eo then return true end
|
||||
@@ -343,7 +350,8 @@ function TrainerSight.check(game, specificTrainer)
|
||||
local eo = specificTrainer
|
||||
if eo.visible and not eo.hidden and not eo.moving and not eo.scriptBusy and not eo.frozen then
|
||||
local sight = tonumber(eo.sight or (eo.def and (eo.def.sight or eo.def.trainerRange))) or 0
|
||||
if sight > 0 and not TrainerSight.isDefeated(eo, store, ctx) then
|
||||
if sight > 0 and TrainerSight.isTrainerType(eo)
|
||||
and not TrainerSight.isDefeated(eo, store, ctx) then
|
||||
local spotted, dist = TrainerSight.checkLineOfSight(eo, P, game)
|
||||
if spotted and not TrainerSight.blockedByDoubles(eo) then
|
||||
TrainerSight.engage(game, eo, dist)
|
||||
@@ -360,7 +368,8 @@ function TrainerSight.check(game, specificTrainer)
|
||||
local eo = Objs.find(lid)
|
||||
if eo and eo ~= P and eo.visible and not eo.hidden and not eo.moving and not eo.scriptBusy and not eo.frozen then
|
||||
local sight = tonumber(eo.sight or (eo.def and (eo.def.sight or eo.def.trainerRange))) or 0
|
||||
if sight > 0 and not TrainerSight.isDefeated(eo, store, ctx) then
|
||||
if sight > 0 and TrainerSight.isTrainerType(eo)
|
||||
and not TrainerSight.isDefeated(eo, store, ctx) then
|
||||
local spotted, dist = TrainerSight.checkLineOfSight(eo, P, game)
|
||||
if spotted and not TrainerSight.blockedByDoubles(eo) then
|
||||
-- Immediately engage and break iterator to suppress any other simultaneous spots
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
require("love.thread")
|
||||
require("love.filesystem")
|
||||
require("love.system")
|
||||
|
||||
local id, commands, channelName = ...
|
||||
local out = love.thread.getChannel(channelName or "host_picker_result")
|
||||
|
||||
local HostShell
|
||||
do
|
||||
local ok, chunk = pcall(love.filesystem.load, "src/core/HostShell.lua")
|
||||
if ok and type(chunk) == "function" then
|
||||
local ok2, mod = pcall(chunk)
|
||||
if ok2 then HostShell = mod end
|
||||
end
|
||||
end
|
||||
|
||||
local output
|
||||
if HostShell and type(commands) == "table" then
|
||||
for _, command in ipairs(commands) do
|
||||
local pipe = HostShell.popen(command)
|
||||
if pipe then
|
||||
local ok, result = pcall(pipe.read, pipe, "*a")
|
||||
HostShell.pclose(pipe)
|
||||
if ok and type(result) == "string" then
|
||||
result = result:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if result ~= "" then
|
||||
output = result
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
out:push({ id = id, output = output })
|
||||
+111
-9
@@ -2338,6 +2338,9 @@ local function sortDefs(scope)
|
||||
end
|
||||
defs[#defs + 1] = { key = "release", label = Strings("Release date") }
|
||||
defs[#defs + 1] = { key = "updated", label = Strings("Last updated") }
|
||||
if scope == "mods" then
|
||||
defs[#defs + 1] = { key = "order", label = Strings("Load order") }
|
||||
end
|
||||
return defs
|
||||
end
|
||||
|
||||
@@ -2401,6 +2404,7 @@ local function currentSort(imp, scope)
|
||||
imp.modSort = sortKey
|
||||
end
|
||||
if sortKey == "trending" and scope ~= "find" then return "popularity" end
|
||||
if sortKey == "order" and scope ~= "mods" then return "popularity" end
|
||||
return sortKey
|
||||
end
|
||||
|
||||
@@ -2579,7 +2583,8 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
-- per frame (with lowercased-string allocations in the comparator) fed the
|
||||
-- GC for nothing. Cache the sorted array, keyed on the list identity, the
|
||||
-- sort mode, and the update-info revision the fetch pump bumps.
|
||||
local statsSort = sortKey ~= "name"
|
||||
local orderSort = sortKey == "order"
|
||||
local statsSort = sortKey ~= "name" and not orderSort
|
||||
local rev = statsSort and (imp._modUpdateRev or 0) or 0
|
||||
local cache = imp._modSortCache
|
||||
if cache and cache.n == #mods
|
||||
@@ -2591,6 +2596,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
local n = decorate(scratch, mods,
|
||||
function(mod, tie)
|
||||
if sortKey == "name" then return tie end
|
||||
if orderSort then return tonumber(mod.loadRank) or math.huge end
|
||||
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
|
||||
if sortKey == "popularity" then
|
||||
return info and info.downloads and info.downloads.total or -1
|
||||
@@ -2600,13 +2606,14 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
return date and date.latest or "0000-00-00"
|
||||
end,
|
||||
function(mod) return (mod.name or ""):lower() end)
|
||||
sortAsc = sortKey == "name"
|
||||
sortAsc = sortKey == "name" or orderSort
|
||||
table.sort(scratch, decCompare)
|
||||
local sorted = undecorate(scratch, n)
|
||||
imp._modSortCache = { src = mods, n = #mods, key = sortKey,
|
||||
rev = rev, at = Kit.time, list = sorted }
|
||||
rev = rev, at = Kit.time, list = sorted, names = {} }
|
||||
mods = sorted
|
||||
end
|
||||
local orderNames = imp._modSortCache and imp._modSortCache.names or {}
|
||||
|
||||
-- A mod row is a fixed height: its details first, then a dedicated second
|
||||
-- line of per-game checkboxes. Fixed row heights are what make the
|
||||
@@ -2678,13 +2685,49 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
else imp._modGames = { id = mod.id, scroll = 0 } end
|
||||
end,
|
||||
})
|
||||
btn(imp, px, gamesY + togH + math.floor(8 * m.s),
|
||||
inner, m.btnH,
|
||||
local detailsY = gamesY + togH + math.floor(8 * m.s)
|
||||
local detailsW = inner
|
||||
if orderSort then
|
||||
local canMove = not safeMode and cartId == nil
|
||||
local mgap = math.floor(6 * m.s)
|
||||
local upLabel, downLabel = Strings("Up"), Strings("Down")
|
||||
local ob = imp._modOrderBtn
|
||||
if not ob or ob.s ~= m.s or ob.up ~= upLabel or ob.down ~= downLabel then
|
||||
ob = { s = m.s, up = upLabel, down = downLabel, keys = {},
|
||||
w = math.max(Kit.textWidth("small", upLabel),
|
||||
Kit.textWidth("small", downLabel)) + math.floor(24 * m.s) }
|
||||
imp._modOrderBtn = ob
|
||||
end
|
||||
local keys = ob.keys[mod.id]
|
||||
if not keys then
|
||||
keys = { "mod-up-" .. mod.id, "mod-down-" .. mod.id }
|
||||
ob.keys[mod.id] = keys
|
||||
end
|
||||
local mw = math.min(ob.w, math.floor((inner - 2 * mgap) / 4))
|
||||
detailsW = inner - 2 * (mw + mgap)
|
||||
local mx = px + detailsW + mgap
|
||||
btn(imp, mx, detailsY, mw, m.btnH, keys[1], upLabel, {
|
||||
font = "small", enabled = canMove and i > 1,
|
||||
action = function() imp:_moveMod(mod.id, -1) end })
|
||||
btn(imp, mx + mw + mgap, detailsY, mw, m.btnH, keys[2],
|
||||
downLabel, { font = "small", enabled = canMove and i < #mods,
|
||||
action = function() imp:_moveMod(mod.id, 1) end })
|
||||
end
|
||||
btn(imp, px, detailsY,
|
||||
detailsW, m.btnH,
|
||||
rowKey, Strings("Details"), { font = "small", icon = "folder",
|
||||
action = function() imp._modActions = mod.id end,
|
||||
})
|
||||
local textW = inner
|
||||
Kit.text("button", Kit.ellipsize("button", mod.name, textW - 16 * m.s), px, ly,
|
||||
local shownName = mod.name
|
||||
if orderSort then
|
||||
shownName = orderNames[i]
|
||||
if not shownName then
|
||||
shownName = "#" .. i .. " " .. tostring(mod.name)
|
||||
orderNames[i] = shownName
|
||||
end
|
||||
end
|
||||
Kit.text("button", Kit.ellipsize("button", shownName, textW - 16 * m.s), px, ly,
|
||||
isFullyDisabled and PAL.muted or PAL.heading)
|
||||
ly = ly + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
local tags = (mod.badge or "MOD") .. (mod.targets and (" / " .. mod.targets) or "")
|
||||
@@ -2726,6 +2769,9 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
segs[#segs + 1] = { (dl and " - " or "") .. dates, PAL.detail }
|
||||
end
|
||||
segLine("small", segs, px, ly, textW)
|
||||
elseif orderSort and mod.orderNote then
|
||||
Kit.text("small", Kit.ellipsize("small", Strings(mod.orderNote), textW),
|
||||
px, ly, PAL.yellow)
|
||||
elseif (mod.description or "") ~= "" then
|
||||
Kit.text("small", Kit.ellipsize("small", mod.description, textW),
|
||||
px, ly, PAL.detail)
|
||||
@@ -4362,8 +4408,10 @@ local function buildSortModal(imp, m)
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(360 * m.s)
|
||||
local gap = math.floor(8 * m.s)
|
||||
local canReset = scope == "mods" and imp._resetModOrder ~= nil
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
+ #defs * (m.btnH + gap) + m.btnH + pad
|
||||
+ (canReset and (m.btnH + gap) or 0)
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local cy = py + pad
|
||||
Kit.text("button", Strings("Sort by"), px + pad, cy, PAL.heading)
|
||||
@@ -4385,6 +4433,17 @@ local function buildSortModal(imp, m)
|
||||
end })
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
if canReset then
|
||||
local cartOn = imp.modCartPlan and imp:modCartPlan() or nil
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "sortpop-reset-order",
|
||||
Strings("Reset load order"), { kind = "warn", font = "small",
|
||||
enabled = not imp.safeMode and cartOn == nil,
|
||||
action = function()
|
||||
imp._sortPopup = nil
|
||||
imp:_resetModOrder()
|
||||
end })
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "sortpop-close",
|
||||
Strings("Close"), { font = "small",
|
||||
action = function() imp._sortPopup = nil end })
|
||||
@@ -4922,8 +4981,10 @@ local function buildModActionsModal(imp, m)
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(440 * m.s)
|
||||
local gap = math.floor(8 * m.s)
|
||||
local cartOn = imp.modCartPlan and imp:modCartPlan() or nil
|
||||
local canOrder = not imp.safeMode and cartOn == nil and #(imp.mods or {}) > 1
|
||||
local nBtns = (hasGit and 2 or 0) + (hasDeps and 1 or 0)
|
||||
+ (hasImports and 1 or 0) + 2
|
||||
+ (hasImports and 1 or 0) + 2 + (canOrder and 2 or 0)
|
||||
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
+ Kit.textHeight("small") + math.floor(12 * m.s)
|
||||
+ nBtns * (m.btnH + gap) - gap + pad
|
||||
@@ -4943,6 +5004,31 @@ local function buildModActionsModal(imp, m)
|
||||
px + pad, cy, statusCol)
|
||||
cy = cy + Kit.textHeight("small") + math.floor(12 * m.s)
|
||||
local id = mod.id
|
||||
if canOrder then
|
||||
local mine = tonumber(mod.loadRank) or math.huge
|
||||
local at, n = 1, #(imp.mods or {})
|
||||
for _, mm in ipairs(imp.mods or {}) do
|
||||
local r = tonumber(mm.loadRank) or math.huge
|
||||
if mm.id ~= id and (r < mine or (r == mine and mm.id < id)) then
|
||||
at = at + 1
|
||||
end
|
||||
end
|
||||
local half = math.floor((pw - 2 * pad - gap) / 2)
|
||||
local moves = {
|
||||
{ "modact-up", Strings("Move up"), -1, at and at > 1 },
|
||||
{ "modact-down", Strings("Move down"), 1, at and at < n },
|
||||
{ "modact-top", Strings("Move to top"), -math.huge, at and at > 1 },
|
||||
{ "modact-bottom", Strings("Move to bottom"), math.huge, at and at < n },
|
||||
}
|
||||
for k, mv in ipairs(moves) do
|
||||
local bx = px + pad + ((k % 2 == 0) and (half + gap) or 0)
|
||||
local delta = mv[3]
|
||||
btn(imp, bx, cy, half, m.btnH, mv[1], mv[2], {
|
||||
font = "small", enabled = mv[4] == true,
|
||||
action = function() imp:_moveMod(id, delta) end })
|
||||
if k % 2 == 0 then cy = cy + m.btnH + gap end
|
||||
end
|
||||
end
|
||||
if hasGit then
|
||||
local updLabel, updKind = Strings("Check for updates"), "ghost"
|
||||
if info and info.status == "available" then
|
||||
@@ -5187,18 +5273,34 @@ local function buildFindEntryModal(imp, m)
|
||||
cy = cy + Kit.textHeight("small")
|
||||
end
|
||||
cy = cy + math.floor(12 * m.s)
|
||||
local cartHave = ModIndex.isCart(entry)
|
||||
and imp:_findInstalledCarts()[entry.id] ~= nil
|
||||
local instW = cartHave and math.floor((pw - 2 * pad - gap) / 2)
|
||||
or (pw - 2 * pad)
|
||||
if action then
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "findpop-inst", action, {
|
||||
btn(imp, px + pad, cy, instW, m.btnH, "findpop-inst", action, {
|
||||
kind = "primary", font = "small",
|
||||
action = function()
|
||||
imp._findEntry = nil
|
||||
imp:_findConfirmInstall(entry)
|
||||
end })
|
||||
else
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "findpop-inst",
|
||||
btn(imp, px + pad, cy, instW, m.btnH, "findpop-inst",
|
||||
Strings("Not installable from this index"),
|
||||
{ font = "small", enabled = false })
|
||||
end
|
||||
if cartHave then
|
||||
local cartId, cartBase = entry.id, entry.base
|
||||
btn(imp, px + pad + instW + gap, cy, instW, m.btnH, "findpop-del",
|
||||
DELETE_LABEL(deleteArmed(imp, "cart", cartId, cartBase)), {
|
||||
kind = "danger", font = "small", icon = "trash", keepArm = true,
|
||||
action = function()
|
||||
imp:pressDelete("cart", cartId, cartBase, function()
|
||||
imp._findEntry = nil
|
||||
imp:deleteCart(cartBase, cartId, { from = "find" })
|
||||
end)
|
||||
end })
|
||||
end
|
||||
cy = cy + m.btnH + gap
|
||||
local half = entry.repo and math.floor((pw - 2 * pad - gap) / 2)
|
||||
or (pw - 2 * pad)
|
||||
|
||||
+259
-55
@@ -3,6 +3,7 @@ local GamepadMap = require("src.core.GamepadMap")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Strings = require("src.core.Strings")
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local HostPicker = require("src.core.HostPicker")
|
||||
local Platform = require("src.core.Platform")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
|
||||
@@ -1138,8 +1139,7 @@ local function chooseRom(promptName)
|
||||
:format(prompt))
|
||||
elseif platform == "Windows" then
|
||||
local script = table.concat({
|
||||
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
HostPicker.WIN_OPEN_DIALOG,
|
||||
"$d.Title='" .. prompt .. "';",
|
||||
"$d.Filter='Game Boy / GBA ROM (*.gb;*.gbc;*.gba)|*.gb;*.gbc;*.gba|All files (*.*)|*.*';",
|
||||
-- copy the pick to a plain-ASCII temp name and answer with that:
|
||||
@@ -1147,7 +1147,7 @@ local function chooseRom(promptName)
|
||||
-- (Pokémon -> Pok\x82mon) and io.open on Windows needs ANSI bytes,
|
||||
-- so returning the original name both crashed the notice draw and
|
||||
-- could never have opened the file (#325, #665)
|
||||
"if($d.ShowDialog() -eq 'OK'){",
|
||||
HostPicker.WIN_SHOW,
|
||||
"$t=Join-Path $env:TEMP 'pokeport_rom_pick.gb';",
|
||||
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
|
||||
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
|
||||
@@ -1183,12 +1183,11 @@ local function chooseImporterFile(promptName, exts)
|
||||
:format(prompt, table.concat(quoted, ", ")))
|
||||
elseif platform == "Windows" then
|
||||
local script = table.concat({
|
||||
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
HostPicker.WIN_OPEN_DIALOG,
|
||||
"$d.Title='" .. prompt .. "';",
|
||||
"$d.Filter='Cartridge dump (" .. table.concat(semis, ";") .. ")|"
|
||||
.. table.concat(semis, ";") .. "|All files (*.*)|*.*';",
|
||||
"if($d.ShowDialog() -eq 'OK'){",
|
||||
HostPicker.WIN_SHOW,
|
||||
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
|
||||
"[Console]::Write($d.FileName)}",
|
||||
})
|
||||
@@ -1206,20 +1205,16 @@ local function chooseImporterFile(promptName, exts)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Open a native picker for a mod .zip (mirrors chooseRom's per-OS dialogs).
|
||||
-- Returns the chosen absolute path or nil. Android uses love.system.pickFile
|
||||
-- ("mod") instead -- see RomImporter:chooseMod.
|
||||
local function chooseZip()
|
||||
local function zipPickCommands()
|
||||
local prompt = shellSafe(Strings("Choose a mod .zip"))
|
||||
local platform = love.system.getOS()
|
||||
if platform == "OS X" then
|
||||
return commandOutput(
|
||||
return {
|
||||
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip"})' 2>/dev/null]])
|
||||
:format(prompt))
|
||||
:format(prompt) }
|
||||
elseif platform == "Windows" then
|
||||
local script = table.concat({
|
||||
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
HostPicker.WIN_OPEN_DIALOG,
|
||||
"$d.Title='" .. prompt .. "';",
|
||||
"$d.Filter='Mod archive (*.zip)|*.zip|All files (*.*)|*.*';",
|
||||
-- copy the pick to a plain-ASCII temp name and answer with that:
|
||||
@@ -1227,25 +1222,33 @@ local function chooseZip()
|
||||
-- (Pokémon -> Pok\x82mon) and io.open on Windows needs ANSI bytes,
|
||||
-- so returning the original name both crashed the notice draw and
|
||||
-- could never have opened the file (#325)
|
||||
"if($d.ShowDialog() -eq 'OK'){",
|
||||
HostPicker.WIN_SHOW,
|
||||
"$t=Join-Path $env:TEMP 'pokeport_mod_pick.zip';",
|
||||
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
|
||||
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
|
||||
"[Console]::Write($t)}",
|
||||
})
|
||||
return commandOutput(
|
||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||
return { 'powershell -NoProfile -STA -Command "' .. script .. '"' }
|
||||
elseif platform == "Linux" then
|
||||
local path = commandOutput(
|
||||
return {
|
||||
([[zenity --file-selection --title="%s" --file-filter="Mod archive | *.zip" 2>/dev/null]])
|
||||
:format(prompt))
|
||||
if path then return path end
|
||||
return commandOutput(
|
||||
[[kdialog --getopenfilename "$HOME" "*.zip|Mod archive" 2>/dev/null]])
|
||||
:format(prompt),
|
||||
[[kdialog --getopenfilename "$HOME" "*.zip|Mod archive" 2>/dev/null]],
|
||||
}
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function chooseZip()
|
||||
for _, command in ipairs(zipPickCommands() or {}) do
|
||||
local path = commandOutput(command)
|
||||
if path then return path end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
RomImporter._zipPickCommands = zipPickCommands
|
||||
|
||||
local function chooseSkinZip()
|
||||
local prompt = shellSafe(Strings("Choose a skin .zip or .deltaskin"))
|
||||
local platform = love.system.getOS()
|
||||
@@ -1255,11 +1258,10 @@ local function chooseSkinZip()
|
||||
:format(prompt))
|
||||
elseif platform == "Windows" then
|
||||
local script = table.concat({
|
||||
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
HostPicker.WIN_OPEN_DIALOG,
|
||||
"$d.Title='" .. prompt .. "';",
|
||||
"$d.Filter='Skin archive (*.zip;*.deltaskin)|*.zip;*.deltaskin|All files (*.*)|*.*';",
|
||||
"if($d.ShowDialog() -eq 'OK'){",
|
||||
HostPicker.WIN_SHOW,
|
||||
"$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';",
|
||||
"$t=Join-Path $env:TEMP $n;",
|
||||
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
|
||||
@@ -1291,14 +1293,13 @@ local function chooseSav()
|
||||
:format(prompt))
|
||||
elseif platform == "Windows" then
|
||||
local script = table.concat({
|
||||
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
HostPicker.WIN_OPEN_DIALOG,
|
||||
"$d.Title='" .. prompt .. "';",
|
||||
"$d.Filter='Game Boy save (*.sav)|*.sav|All files (*.*)|*.*';",
|
||||
-- copy the pick to a plain-ASCII temp name: io.open on Windows
|
||||
-- needs ANSI bytes, so a non-ASCII path (Pokémon -> Pok\x82mon)
|
||||
-- could never have been opened (#325, #665)
|
||||
"if($d.ShowDialog() -eq 'OK'){",
|
||||
HostPicker.WIN_SHOW,
|
||||
"$t=Join-Path $env:TEMP 'pokeport_sav_pick.sav';",
|
||||
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
|
||||
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
|
||||
@@ -1329,15 +1330,14 @@ local function chooseRequiredFile()
|
||||
:format(prompt))
|
||||
elseif platform == "Windows" then
|
||||
local script = table.concat({
|
||||
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
HostPicker.WIN_OPEN_DIALOG,
|
||||
"$d.Title='" .. prompt .. "';",
|
||||
"$d.Filter='All files (*.*)|*.*';",
|
||||
-- Required imports can be multi-gigabyte optical-disc images. Do NOT
|
||||
-- stage them through %TEMP%: that doubles free-space requirements and a
|
||||
-- failed Copy-Item can leave a plausible-looking truncated temp file.
|
||||
-- Stream the selected source directly into the mod-owned destination.
|
||||
"if($d.ShowDialog() -eq 'OK'){",
|
||||
HostPicker.WIN_SHOW,
|
||||
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
|
||||
"[Console]::Write($d.FileName)}",
|
||||
})
|
||||
@@ -1670,6 +1670,7 @@ end
|
||||
-- _pollPickedFiles must stay armed so it consumes the file when it lands
|
||||
-- moments later (it clears pickPending itself once something is found).
|
||||
function RomImporter:focus(f)
|
||||
self._modPickOpenWait = nil
|
||||
if not f then
|
||||
self._activeTouch = nil
|
||||
self._pagePress = nil
|
||||
@@ -2185,12 +2186,12 @@ function RomImporter:_deleteMod(id)
|
||||
end
|
||||
|
||||
-- "Import mod .zip" button: open a native picker and install the pick.
|
||||
-- Android mirrors ROM import: scan for a pending .zip in the save dir (USB
|
||||
-- or a fresh SAF drop), else love.system.pickFile("mod") -> picked_mod.zip
|
||||
-- Android: consume a fresh SAF drop, else love.system.pickFile("mod") -> picked_mod.zip
|
||||
-- which focus/Choose consumes on return.
|
||||
-- NX: no HostShell/desktop picker -- rescan imports/mods/ inbox instead.
|
||||
function RomImporter:chooseMod()
|
||||
if self.workState == "working" then return end
|
||||
if self._hostPick or self._modInboxInstall then return end
|
||||
if self.isNX then
|
||||
self:ensureModsInboxDir()
|
||||
self:rescanModsAction()
|
||||
@@ -2205,19 +2206,30 @@ function RomImporter:chooseMod()
|
||||
return
|
||||
end
|
||||
if self.android then
|
||||
local name = findPendingMod(true, self.pickSkip)
|
||||
local name = findPendingMod(false, self.pickSkip)
|
||||
if name then
|
||||
self:_installMod(name)
|
||||
consumePick(self, name, "picked_mod.zip",
|
||||
self.modNotice and self.modNotice.ok)
|
||||
return
|
||||
end
|
||||
if self._modPickStalled and #self:scanModsInbox() > 0 then
|
||||
self._modPickStalled = nil
|
||||
self._modInboxInstall = "armed"
|
||||
self.tab = "mods"
|
||||
self.modNotice = { ok = true,
|
||||
text = Strings("Installing mods from %s/ ...", MODS_INBOX_DIR) }
|
||||
return
|
||||
end
|
||||
self._modPickOpenWait = nil
|
||||
if not pickFile("mod") then
|
||||
self._modPickStalled = true
|
||||
self.modNotice = { ok = false,
|
||||
text = "Could not open the file picker. Copy a mod .zip via USB." }
|
||||
text = self:_modInboxHint(Strings("Could not open the file picker.")) }
|
||||
else
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
if love.system.getOS() == "Android" then self._modPickOpenWait = 0 end
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -2240,11 +2252,27 @@ function RomImporter:chooseMod()
|
||||
end
|
||||
end
|
||||
|
||||
if love.system.getOS() == "Windows" and Platform.canSpawnProcess() then
|
||||
local id = HostPicker.start(zipPickCommands())
|
||||
if id then
|
||||
local notice = { ok = true, text = Strings(
|
||||
"Choose a .zip in the file dialog. The launcher waits until it closes.") }
|
||||
self._hostPick = { id = id, kind = "mod", notice = notice }
|
||||
self.tab = "mods"
|
||||
self.modNotice = notice
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local path = chooseZip()
|
||||
if path then
|
||||
self:_installMod(path)
|
||||
return
|
||||
end
|
||||
self:_openModBrowser()
|
||||
end
|
||||
|
||||
function RomImporter:_openModBrowser()
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit and Kit.FileBrowser then
|
||||
self._padCursorActive = false
|
||||
@@ -2255,8 +2283,51 @@ function RomImporter:chooseMod()
|
||||
self:_installMod(pickedPath)
|
||||
end,
|
||||
})
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function RomImporter:_modInboxHint(lead)
|
||||
return Strings("%s Copy the .zip into this folder, then tap %s again:\n%s/%s/",
|
||||
lead, self:_modsImportButtonLabel(), love.filesystem.getSaveDirectory(),
|
||||
MODS_INBOX_DIR)
|
||||
end
|
||||
|
||||
function RomImporter:_pumpHostPick()
|
||||
local job = self._hostPick
|
||||
if not job then return end
|
||||
local state, output, err = HostPicker.poll(job.id)
|
||||
if state == "pending" then return end
|
||||
self._hostPick = nil
|
||||
if self.modNotice == job.notice then self.modNotice = nil end
|
||||
output = trim(output)
|
||||
if output ~= "" then
|
||||
self:_installMod(output)
|
||||
elseif err then
|
||||
self:_openModBrowser()
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_pumpModInboxInstall()
|
||||
local step = self._modInboxInstall
|
||||
if not step then return end
|
||||
if step == "armed" then
|
||||
self._modInboxInstall = "shown"
|
||||
return
|
||||
end
|
||||
self._modInboxInstall = nil
|
||||
self:rescanModsAction()
|
||||
end
|
||||
|
||||
function RomImporter:_pumpModPickOpen(dt)
|
||||
if not self._modPickOpenWait then return end
|
||||
self._modPickOpenWait = self._modPickOpenWait + math.min(dt or 0, 0.1)
|
||||
if self._modPickOpenWait < 4 then return end
|
||||
self._modPickOpenWait = nil
|
||||
self._modPickStalled = true
|
||||
self.modNotice = { ok = false,
|
||||
text = self:_modInboxHint(Strings("The file picker did not open.")) }
|
||||
end
|
||||
|
||||
local function requiredManifest(self, modId)
|
||||
@@ -3027,7 +3098,11 @@ function RomImporter:update(dt)
|
||||
self:_pumpCartInstall()
|
||||
self:_pumpCartFill()
|
||||
self:_pumpUpdateAll()
|
||||
if self:_fireAutoUpdateAll() then return end
|
||||
self:_pumpExtract()
|
||||
self:_pumpHostPick()
|
||||
self:_pumpModPickOpen(dt)
|
||||
self:_pumpModInboxInstall()
|
||||
-- Dev harness: POKEPORT_LAUNCHER_SHOT=/path.png resizes the window from
|
||||
-- POKEPORT_WIN=WxH, lets the view settle, then captures one frame and
|
||||
-- quits, so a scripted run can see the real launcher at any window shape
|
||||
@@ -3497,6 +3572,7 @@ end
|
||||
|
||||
function RomImporter:gamepadpressed(_, button)
|
||||
if Transition.active() then return end
|
||||
if self._hostPick then return end
|
||||
local action =(GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit then
|
||||
@@ -3796,6 +3872,7 @@ end
|
||||
-- Player pressed Play on a game whose ROM is imported: hand off to boot.
|
||||
function RomImporter:play(version, fade)
|
||||
if self.workState == "working" then return end
|
||||
if self._hostPick then return end
|
||||
if not self.ready[version] then return end
|
||||
if fade then
|
||||
if not self._launchFade then
|
||||
@@ -3949,6 +4026,7 @@ end
|
||||
-- Delete selected the slot and wiped the arm it had just set, so a secondary
|
||||
-- slot became the loaded one and could never be deleted (#780).
|
||||
function RomImporter:runActions(queue)
|
||||
if self._hostPick then return end
|
||||
for i = 1, #queue do
|
||||
local entry = queue[i]
|
||||
local key = type(entry.key) == "string" and entry.key or ""
|
||||
@@ -4894,6 +4972,7 @@ end
|
||||
|
||||
function RomImporter:keypressed(key)
|
||||
if Transition.active() then return end
|
||||
if self._hostPick then return end
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit then
|
||||
if Kit.FileBrowser and Kit.FileBrowser.active then
|
||||
@@ -5583,15 +5662,46 @@ function RomImporter:_commitCartSave()
|
||||
self._cartNotice = Strings("Saved %s. It is in this list now.", title)
|
||||
end
|
||||
|
||||
function RomImporter:deleteCart(version, id)
|
||||
if self.workState == "working" then return end
|
||||
local ok, err = require("src.carts.CartStore").uninstall(id)
|
||||
if not ok then self._cartNotice = tostring(err); return end
|
||||
function RomImporter:deleteCart(version, id, opts)
|
||||
local fromFind = type(opts) == "table" and opts.from == "find"
|
||||
if self.workState == "working" then
|
||||
local busy = Strings("Wait for the current import to finish, then delete the cart.")
|
||||
if fromFind then
|
||||
self.findNotice = { ok = false, text = busy }
|
||||
else
|
||||
self._cartNotice = busy
|
||||
end
|
||||
return false
|
||||
end
|
||||
local CartStore = require("src.carts.CartStore")
|
||||
local installed = fromFind and CartStore.get(id) or nil
|
||||
if installed and installed.base then version = installed.base end
|
||||
local ok, err = CartStore.uninstall(id)
|
||||
if not ok then
|
||||
if fromFind then
|
||||
self.findNotice = { ok = false, text = tostring(err) }
|
||||
else
|
||||
self._cartNotice = tostring(err)
|
||||
end
|
||||
return false
|
||||
end
|
||||
if self.activeCart[version] == id then self:_selectCart(version, nil) end
|
||||
self:_refreshCarts(version)
|
||||
self._cartPlan = nil
|
||||
local cartKey = "cart:" .. tostring(id)
|
||||
for _, cache in ipairs({ self._cartridgeLabels or {}, self._gbaLabels or {} }) do
|
||||
cache[cartKey] = nil
|
||||
cache["gba:" .. cartKey] = nil
|
||||
end
|
||||
if fromFind then
|
||||
self.findNotice = { ok = true,
|
||||
text = Strings("Cart deleted. Its saves and installed mods were kept.") }
|
||||
return true
|
||||
end
|
||||
self._cartPicker = nil
|
||||
self._cartPopup = version
|
||||
self._cartNotice = Strings("Cart deleted. Its saves and installed mods were kept.")
|
||||
return true
|
||||
end
|
||||
|
||||
function RomImporter:exportCart(id)
|
||||
@@ -5797,6 +5907,7 @@ function RomImporter:_commitRename()
|
||||
end
|
||||
|
||||
function RomImporter:textinput(text)
|
||||
if self._hostPick then return end
|
||||
if self._syncModal and self._syncFocus then
|
||||
self:_syncTypeInto(self._syncFocus, text)
|
||||
return
|
||||
@@ -6308,6 +6419,51 @@ function RomImporter:_setAllMods(want, confirmed)
|
||||
or Strings("Disabled %d mods.", #ids) }
|
||||
end
|
||||
|
||||
function RomImporter:_moveMod(id, delta)
|
||||
if self.safeMode then
|
||||
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." }
|
||||
return false
|
||||
end
|
||||
local cartId, cartReport = self:modCartPlan()
|
||||
if cartId then
|
||||
self.modNotice = { ok = false, text = self:cartPinsNote(cartId, cartReport) }
|
||||
return false
|
||||
end
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local visible, name = {}, id
|
||||
for _, m in ipairs(self.mods or {}) do
|
||||
visible[#visible + 1] = m.id
|
||||
if m.id == id then name = m.name or id end
|
||||
end
|
||||
local ok, at = LauncherMods.moveMod(id, delta, visible)
|
||||
if not ok then
|
||||
self.modNotice = { ok = true, text = (tonumber(delta) or 0) < 0
|
||||
and Strings("%s already loads first.", name)
|
||||
or Strings("%s already loads last.", name) }
|
||||
return false
|
||||
end
|
||||
self._modSortCache = nil
|
||||
self:_refreshMods()
|
||||
self.modNotice = { ok = true,
|
||||
text = Strings("Moved %s to load position %d.", name, at or 0) }
|
||||
return true
|
||||
end
|
||||
|
||||
function RomImporter:_resetModOrder()
|
||||
if self.safeMode then
|
||||
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." }
|
||||
return false
|
||||
end
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local ok = LauncherMods.resetOrder()
|
||||
self._modSortCache = nil
|
||||
self:_refreshMods()
|
||||
self.modNotice = { ok = true, text = ok
|
||||
and Strings("Load order reset to each mod's own priority.")
|
||||
or Strings("Load order already follows each mod's own priority.") }
|
||||
return ok
|
||||
end
|
||||
|
||||
-- GitHub Update / Check for updates / Versions. Soft-fails into modNotice.
|
||||
-- Update button: when a newer release is known, confirm then install; when
|
||||
-- already current, force-refresh the 6h cache and report / offer update.
|
||||
@@ -6965,7 +7121,7 @@ function RomImporter:updateAllAvailable()
|
||||
return true
|
||||
end
|
||||
|
||||
function RomImporter:pressUpdateAllMods()
|
||||
function RomImporter:pressUpdateAllMods(opts)
|
||||
if not self:updateAllAvailable() then return false end
|
||||
if not Platform.canFetchRemote() then
|
||||
self.modNotice = { ok = false,
|
||||
@@ -6973,9 +7129,10 @@ function RomImporter:pressUpdateAllMods()
|
||||
return false
|
||||
end
|
||||
self._updateAll = { stage = "check", index = 0, updated = 0,
|
||||
updatedIds = {}, updatedCarts = {}, failures = {} }
|
||||
updatedIds = {}, updatedCarts = {}, failures = {},
|
||||
auto = (opts and opts.auto) and true or nil }
|
||||
self.modNotice = nil
|
||||
self:_syncModUpdateInfo(true)
|
||||
self:_syncModUpdateInfo(not self._updateAll.auto)
|
||||
self:_setBusy(Strings("Checking for updates"), nil,
|
||||
function() self:_cancelUpdateAll() end)
|
||||
self:_pumpUpdateAll()
|
||||
@@ -7034,20 +7191,24 @@ function RomImporter:_pumpUpdateAll()
|
||||
if job.cancelled or job.total == 0 then
|
||||
return self:_finishUpdateAll(job.cancelled)
|
||||
end
|
||||
job.stage = "confirm"
|
||||
local lines = { Strings("Update %d items?", job.total) }
|
||||
for i = 1, math.min(3, job.total) do
|
||||
local row = job.rows[i]
|
||||
lines[#lines + 1] = tostring(row.name or row.id)
|
||||
if job.auto then
|
||||
job.stage = "next"
|
||||
else
|
||||
job.stage = "confirm"
|
||||
local lines = { Strings("Update %d items?", job.total) }
|
||||
for i = 1, math.min(3, job.total) do
|
||||
local row = job.rows[i]
|
||||
lines[#lines + 1] = tostring(row.name or row.id)
|
||||
end
|
||||
if job.total > 3 then
|
||||
lines[#lines + 1] = Strings("and %d more", job.total - 3)
|
||||
end
|
||||
job.confirm = { kind = "updateAllRun", title = Strings("Update all"),
|
||||
yesLabel = Strings("Update all"), lines = lines }
|
||||
self._modConfirm = job.confirm
|
||||
self:_clearBusy()
|
||||
return
|
||||
end
|
||||
if job.total > 3 then
|
||||
lines[#lines + 1] = Strings("and %d more", job.total - 3)
|
||||
end
|
||||
job.confirm = { kind = "updateAllRun", title = Strings("Update all"),
|
||||
yesLabel = Strings("Update all"), lines = lines }
|
||||
self._modConfirm = job.confirm
|
||||
self:_clearBusy()
|
||||
return
|
||||
end
|
||||
|
||||
if job.stage == "confirm" then
|
||||
@@ -7146,6 +7307,7 @@ function RomImporter:_finishUpdateAll(cancelled)
|
||||
"%s now pins %d mod(s) you do not have. Open it in Custom Carts to install them.",
|
||||
tostring(cart.name), missing)
|
||||
self.modNotice.failures = lines
|
||||
job.pinsMissing = true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7157,6 +7319,48 @@ function RomImporter:_finishUpdateAll(cancelled)
|
||||
break
|
||||
end
|
||||
end
|
||||
if job.auto and self._autoUpdateAll then
|
||||
self._autoUpdateAll.result = {
|
||||
ok = not cancelled and #job.failures == 0 and not job.pinsMissing
|
||||
and self._modDepResolver == nil,
|
||||
cancelled = cancelled and true or false,
|
||||
updated = job.updated,
|
||||
total = job.total or 0,
|
||||
failures = job.failures,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:autoUpdateAll(onDone, opts)
|
||||
if self._autoUpdateAll then return false end
|
||||
self._autoUpdateAll = { onDone = onDone }
|
||||
local tab = opts and opts.tab
|
||||
if tab and self.tab ~= tab then self:_switchTab(tab) end
|
||||
self:_ensureMods()
|
||||
if self:pressUpdateAllMods({ auto = true }) then return true end
|
||||
if self.safeMode and not self.modNotice then
|
||||
self.modNotice = { ok = false,
|
||||
text = Strings("Safe mode is on, so mods were not updated.") }
|
||||
end
|
||||
if self._autoUpdateAll then
|
||||
self._autoUpdateAll.result = { ok = false, skipped = true, updated = 0,
|
||||
total = 0, failures = {} }
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function RomImporter:_fireAutoUpdateAll()
|
||||
local auto = self._autoUpdateAll
|
||||
if not (auto and auto.result) then return false end
|
||||
self._autoUpdateAll = nil
|
||||
local notice = self.modNotice
|
||||
print("update-mods: " .. tostring(notice and notice.text
|
||||
or (auto.result.skipped and "skipped" or "done")))
|
||||
for _, line in ipairs(notice and notice.failures or {}) do
|
||||
print("update-mods: " .. tostring(line))
|
||||
end
|
||||
if type(auto.onDone) == "function" then auto.onDone(auto.result) end
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ local STONES = {
|
||||
-- STAT_LABEL (#811) -- Strings(stat:upper()) alone is a dynamic argument
|
||||
-- the harvester can't discover.
|
||||
local STAT_LABEL = {
|
||||
hp = Strings.source("HP"), attack = Strings.source("ATTACK"),
|
||||
hp = Strings.source("HEALTH"), attack = Strings.source("ATTACK"),
|
||||
defense = Strings.source("DEFENSE"), speed = Strings.source("SPEED"),
|
||||
special = Strings.source("SPECIAL"), accuracy = Strings.source("ACCURACY"),
|
||||
}
|
||||
@@ -507,21 +507,23 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
|
||||
if STONES[itemId] then
|
||||
if not target then return "failed", { noEffect(data) } end
|
||||
-- Yellow's starter Pikachu never evolves: ItemUseEvoStone runs
|
||||
-- IsThisPartyMonStarterPikachu (OT identity match) before
|
||||
-- TryEvolvingMon and bails with the voiced cry + RefusingText.
|
||||
-- The stone is NOT consumed on the refuse path.
|
||||
if target.species == "PIKACHU"
|
||||
and require("src.core.GameVersion").isYellow()
|
||||
and target.ot == save.player.name
|
||||
and target.otId == save.player.id then
|
||||
require("src.core.Sound").playCry(data, "PIKACHU")
|
||||
return "failed", { romText(data, "_RefusingText",
|
||||
"%s\nis refusing!", monName(data, target)) }
|
||||
end
|
||||
local speciesDef = data.pokemon[target.species]
|
||||
for _, evo in ipairs(speciesDef.evolutions) do
|
||||
if evo.method == "ITEM" and evo.item == itemId then
|
||||
-- engine/items/item_effects.asm:810
|
||||
-- Yellow's starter Pikachu never evolves: ItemUseEvoStone runs
|
||||
-- IsThisPartyMonStarterPikachu (OT identity match) before
|
||||
-- TryEvolvingMon and bails with the voiced cry + RefusingText.
|
||||
-- The stone is NOT consumed on the refuse path.
|
||||
if require("src.core.GameVersion").isYellow()
|
||||
and require("src.world.PikachuFollower").isStarterPikachu(save, target) then
|
||||
require("src.core.Sound").playCry(data, "PIKACHU")
|
||||
-- engine/items/item_effects.asm:821
|
||||
save.pikachuEmotionModifier = 4
|
||||
save.pikachuMood = 0x82
|
||||
return "failed", { romText(data, "_RefusingText",
|
||||
"%s\nis refusing!", monName(data, target)) }
|
||||
end
|
||||
-- (engine/items/item_effects.asm:779-781)
|
||||
require("src.core.Sound").play(data, "Heal_Ailment")
|
||||
return "consumed", nil, { evolveTo = evo.species }
|
||||
@@ -553,8 +555,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- Spanish ROM puts the stat before the name), so the extracted line
|
||||
-- cannot be filled positionally; the engine wording stands
|
||||
-- engine/items/item_effects.asm:1313
|
||||
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
|
||||
Strings(STAT_LABEL[vitaminStat])) }, { useJingle = true }
|
||||
require("src.core.Sound").play(data, "Heal_Ailment")
|
||||
-- data/text/text_6.asm:71
|
||||
return "consumed", { Strings("%s's\n%s rose.", monName(data, target),
|
||||
Strings(STAT_LABEL[vitaminStat])) }
|
||||
end
|
||||
|
||||
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
|
||||
@@ -569,6 +573,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
return "consumed", { romText(data, "_PPIncreasedText",
|
||||
"%s's PP\nincreased!", mdef.name) }
|
||||
end
|
||||
-- engine/items/item_effects.asm:2001-2006
|
||||
if mdef then
|
||||
return "ppmaxed", { romText(data, "_PPMaxedOutText",
|
||||
"%s's PP\nis maxed out.", mdef.name) }
|
||||
end
|
||||
return "failed", { noEffect(data) }
|
||||
end
|
||||
|
||||
|
||||
@@ -261,7 +261,7 @@ end
|
||||
-- skipped here so the species id space -- which Fingerprint.records hands to
|
||||
-- Protocol.eligibleParty as "the mons the peer can rebuild" -- never carries an
|
||||
-- id no party slot could hold.
|
||||
local NON_SPECIES = { growthRates = true }
|
||||
local NON_SPECIES = { growthRates = true, tmhmMoves = true, tutorMoves = true }
|
||||
|
||||
local function writeRecords(out, map, label, fields, skip)
|
||||
if map == nil then return end
|
||||
@@ -275,6 +275,19 @@ local function writeRecords(out, map, label, fields, skip)
|
||||
end
|
||||
end
|
||||
|
||||
-- data/items/attributes.asm:149
|
||||
local function writeHeldRecords(out, map)
|
||||
if map == nil then return end
|
||||
out[#out + 1] = "[held_items]"
|
||||
for _, id in ipairs(sortedIds(map)) do
|
||||
local record = map[id]
|
||||
if type(record) == "table" and record.heldEffect ~= "HELD_NONE" then
|
||||
out[#out + 1] = "@" .. id
|
||||
writeFields(out, record, GEN2_HELD_FIELDS)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function writeSection(out, data, kind)
|
||||
writeRecords(out, data[kind], kind, RECORD_FIELDS[kind])
|
||||
end
|
||||
@@ -413,7 +426,7 @@ local function surfaceGen2(data, mods)
|
||||
writeGen2TypeChart(out, data)
|
||||
writeRecords(out, data.gen2Statuses, "statuses", GEN2_STATUS_FIELDS)
|
||||
writeRecords(out, data.gen2MoveEffects, "move_effects", GEN2_EFFECT_FIELDS)
|
||||
writeRecords(out, data.gen2HeldItems, "held_items", GEN2_HELD_FIELDS)
|
||||
writeHeldRecords(out, data.gen2HeldItems)
|
||||
out[#out + 1] = "[mods]" .. modKey(mods)
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ local Wire = {}
|
||||
|
||||
local MAX_INT = 2147483647
|
||||
local MAX_STRING = 64
|
||||
local MAX_DETAIL = 160
|
||||
local MAX_NAME = 40
|
||||
local MAX_LIST = 64
|
||||
local MAX_PARTY = 32
|
||||
@@ -237,7 +238,7 @@ SCHEMAS.peer_gone = function() return {} end
|
||||
SCHEMAS.join_error = function(m)
|
||||
return { reason = Wire.str(m.reason, "", MAX_STRING),
|
||||
field = Wire.str(m.field, nil, MAX_STRING),
|
||||
detail = Wire.str(m.detail, nil, MAX_STRING),
|
||||
detail = Wire.str(m.detail, nil, MAX_DETAIL),
|
||||
code = Wire.str(m.code, nil, MAX_NAME) }
|
||||
end
|
||||
|
||||
|
||||
@@ -1161,8 +1161,8 @@ local function buildOverworld()
|
||||
local p = world.player
|
||||
local d = Map2.DELTA[p.facing]
|
||||
if not d then return false end
|
||||
return Permissions.surfable(
|
||||
world.map:cellCollision(p.cellX + d[1], p.cellY + d[2])) ~= nil
|
||||
return Permissions.surfable(world:cellCollisionAcross(world.map,
|
||||
p.cellX + d[1], p.cellY + d[2])) ~= nil
|
||||
end
|
||||
|
||||
function ow.facingIsLandDismount()
|
||||
@@ -1172,8 +1172,8 @@ local function buildOverworld()
|
||||
if not p.surfing then return false end
|
||||
local d = Map2.DELTA[p.facing]
|
||||
if not d then return false end
|
||||
return Permissions.isLand(
|
||||
world.map:cellCollision(p.cellX + d[1], p.cellY + d[2]))
|
||||
return Permissions.isLand(world:cellCollisionAcross(world.map,
|
||||
p.cellX + d[1], p.cellY + d[2]))
|
||||
end
|
||||
|
||||
function ow.trySurf(_fx, _fy, onClose)
|
||||
|
||||
@@ -39,6 +39,7 @@ local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local RequiredImports = require("src.mods.RequiredImports")
|
||||
local LoadOrder = require("src.mods.LoadOrder")
|
||||
|
||||
local LauncherMods = {}
|
||||
|
||||
@@ -358,9 +359,40 @@ function LauncherMods.deriveList(manifests, options, version)
|
||||
safeMode = safeMode,
|
||||
}
|
||||
end
|
||||
local full = LoadOrder.materialize(SaveData.modOrder(options), ordered)
|
||||
local rank = LoadOrder.rank(full)
|
||||
local generation = version and GameVersion.generation(version) or nil
|
||||
for _, row in ipairs(out) do
|
||||
row.loadRank = rank[row.id] or (#full + 1)
|
||||
local m = byId[row.id]
|
||||
local after
|
||||
local function consider(spec)
|
||||
local dep = spec and spec.id
|
||||
if dep and dep ~= row.id and rank[dep] and rank[dep] > row.loadRank
|
||||
and ModTargets.specApplies(spec, version, generation) then
|
||||
if not after or rank[dep] > rank[after] then after = dep end
|
||||
end
|
||||
end
|
||||
for _, spec in ipairs(m.dependencySpecs or {}) do consider(spec) end
|
||||
for _, spec in ipairs(m.optionalSpecs or {}) do consider(spec) end
|
||||
if after then
|
||||
local dep = byId[after]
|
||||
row.orderAfter = after
|
||||
row.orderNote = "Loads after " .. tostring(dep and dep.name or after)
|
||||
.. " (dependency)"
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function LauncherMods.orderedIds(rows, options)
|
||||
return LoadOrder.materialize(SaveData.modOrder(options), rows)
|
||||
end
|
||||
|
||||
function LauncherMods.moveInOrder(list, id, delta)
|
||||
return (LoadOrder.move(list, id, delta))
|
||||
end
|
||||
|
||||
-- locateRoot(paths) -> the mod-root prefix inside a mounted archive, pure.
|
||||
-- paths is a shallow listing: top-level file names as-is, and for a top-level
|
||||
-- directory a "<dir>/manifest.json" entry when it holds one. Returns "" when
|
||||
@@ -669,6 +701,44 @@ function LauncherMods.setAllEnabled(ids, enabled, version)
|
||||
return true
|
||||
end
|
||||
|
||||
function LauncherMods.moveMod(id, delta, visible)
|
||||
if type(id) ~= "string" or id == "" then return false end
|
||||
local options = SaveData.loadOptions()
|
||||
if SaveData.isSafeMode(options) then return false end
|
||||
local manifests = discover()
|
||||
local known = false
|
||||
for _, m in ipairs(manifests) do
|
||||
if m.id == id then known = true break end
|
||||
end
|
||||
if not known then return false end
|
||||
local full = LoadOrder.materialize(SaveData.modOrder(options), manifests)
|
||||
local list, changed = LoadOrder.moveWithin(full, visible, id, delta)
|
||||
if not changed then return false end
|
||||
SaveData.setModOrder(options, list)
|
||||
SaveData.saveOptions(options)
|
||||
LauncherMods.syncActiveProfile(options)
|
||||
local shown = {}
|
||||
for _, v in ipairs(visible or list) do shown[v] = true end
|
||||
local n = 0
|
||||
for _, v in ipairs(list) do
|
||||
if shown[v] then
|
||||
n = n + 1
|
||||
if v == id then return true, n end
|
||||
end
|
||||
end
|
||||
return true, n
|
||||
end
|
||||
|
||||
function LauncherMods.resetOrder()
|
||||
local options = SaveData.loadOptions()
|
||||
if SaveData.isSafeMode(options) then return false end
|
||||
if #SaveData.modOrder(options) == 0 then return false end
|
||||
SaveData.setModOrder(options, {})
|
||||
SaveData.saveOptions(options)
|
||||
LauncherMods.syncActiveProfile(options)
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------- install (love.filesystem)
|
||||
|
||||
-- Read a .zip source into bytes. Save-dir-relative paths (inbox /
|
||||
@@ -1246,6 +1316,7 @@ function LauncherMods.applyProfile(profileName, options)
|
||||
end
|
||||
if not targetProfile then return false end
|
||||
ModProfile.restoreVersions(targetProfile, options)
|
||||
ModProfile.restoreOrder(targetProfile, options)
|
||||
options.activeProfile = profileName
|
||||
SaveData.saveOptions(options)
|
||||
return true
|
||||
@@ -1255,7 +1326,8 @@ function LauncherMods.saveProfile(profileName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local manifests = discover()
|
||||
options.modProfiles = options.modProfiles or {}
|
||||
local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion)
|
||||
local snap = ModProfile.capture(manifests, options.modOptions,
|
||||
options.modsByVersion, options.modOrder)
|
||||
snap.name = profileName
|
||||
local existingIdx
|
||||
for i, p in ipairs(options.modProfiles) do
|
||||
@@ -1285,7 +1357,8 @@ function LauncherMods.syncActiveProfile(options)
|
||||
local activeName = options.activeProfile or "PROFILE 1"
|
||||
local profiles = options.modProfiles or {}
|
||||
local manifests = discover()
|
||||
local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion)
|
||||
local snap = ModProfile.capture(manifests, options.modOptions,
|
||||
options.modsByVersion, options.modOrder)
|
||||
snap.name = activeName
|
||||
|
||||
local found = false
|
||||
@@ -1330,6 +1403,7 @@ function LauncherMods.duplicateProfile(sourceName, options)
|
||||
options = copyTable(sourceProfile.options),
|
||||
slots = copyTable(sourceProfile.slots),
|
||||
enabledByVersion = copyTable(sourceProfile.enabledByVersion),
|
||||
order = copyTable(sourceProfile.order),
|
||||
}
|
||||
profiles[#profiles + 1] = snap
|
||||
options.modProfiles = profiles
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local LoadOrder = {}
|
||||
|
||||
local function priorityOf(entry)
|
||||
local p = entry and (entry.priority or (entry.manifest and entry.manifest.priority))
|
||||
return tonumber(p) or 0
|
||||
end
|
||||
|
||||
function LoadOrder.byPriority(entries)
|
||||
local list = {}
|
||||
for _, e in ipairs(entries or {}) do
|
||||
if type(e) == "table" and type(e.id) == "string" then list[#list + 1] = e end
|
||||
end
|
||||
table.sort(list, function(a, b)
|
||||
local pa, pb = priorityOf(a), priorityOf(b)
|
||||
if pa == pb then return a.id < b.id end
|
||||
return pa < pb
|
||||
end)
|
||||
local ids = {}
|
||||
for i, e in ipairs(list) do ids[i] = e.id end
|
||||
return ids
|
||||
end
|
||||
|
||||
function LoadOrder.materialize(saved, entries)
|
||||
local listed = SaveData.modOrder({ modOrder = saved })
|
||||
local seen, prio = {}, {}
|
||||
for _, id in ipairs(listed) do seen[id] = true end
|
||||
for _, e in ipairs(entries or {}) do
|
||||
if type(e) == "table" and type(e.id) == "string" then prio[e.id] = priorityOf(e) end
|
||||
end
|
||||
local tail = {}
|
||||
for _, id in ipairs(LoadOrder.byPriority(entries)) do
|
||||
if not seen[id] then
|
||||
seen[id] = true
|
||||
tail[#tail + 1] = id
|
||||
end
|
||||
end
|
||||
local out, t = {}, 1
|
||||
for _, id in ipairs(listed) do
|
||||
local p = prio[id]
|
||||
if p then
|
||||
while tail[t] and prio[tail[t]] < p do
|
||||
out[#out + 1] = tail[t]
|
||||
t = t + 1
|
||||
end
|
||||
end
|
||||
out[#out + 1] = id
|
||||
end
|
||||
for i = t, #tail do out[#out + 1] = tail[i] end
|
||||
return out
|
||||
end
|
||||
|
||||
function LoadOrder.rank(list)
|
||||
local rank = {}
|
||||
local clean = SaveData.modOrder({ modOrder = list })
|
||||
for i, id in ipairs(clean) do rank[id] = i end
|
||||
return rank, #clean + 1
|
||||
end
|
||||
|
||||
function LoadOrder.move(list, id, delta)
|
||||
local out, at = {}, nil
|
||||
for i, v in ipairs(list or {}) do
|
||||
out[i] = v
|
||||
if v == id then at = i end
|
||||
end
|
||||
if not at or #out < 2 then return out, false end
|
||||
local to = at + (tonumber(delta) or 0)
|
||||
if to ~= to then return out, false end
|
||||
to = math.max(1, math.min(#out, to))
|
||||
if to == at then return out, false end
|
||||
table.remove(out, at)
|
||||
table.insert(out, to, id)
|
||||
return out, true
|
||||
end
|
||||
|
||||
function LoadOrder.moveWithin(full, visible, id, delta)
|
||||
local shown = {}
|
||||
for _, v in ipairs(visible or full or {}) do shown[v] = true end
|
||||
local sub = {}
|
||||
for _, v in ipairs(full or {}) do
|
||||
if shown[v] then sub[#sub + 1] = v end
|
||||
end
|
||||
local moved, changed = LoadOrder.move(sub, id, delta)
|
||||
if not changed then
|
||||
local copy = {}
|
||||
for i, v in ipairs(full or {}) do copy[i] = v end
|
||||
return copy, false
|
||||
end
|
||||
local out, k = {}, 0
|
||||
for i, v in ipairs(full) do
|
||||
if shown[v] then
|
||||
k = k + 1
|
||||
out[i] = moved[k]
|
||||
else
|
||||
out[i] = v
|
||||
end
|
||||
end
|
||||
return out, true
|
||||
end
|
||||
|
||||
function LoadOrder.position(list, id)
|
||||
for i, v in ipairs(list or {}) do
|
||||
if v == id then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return LoadOrder
|
||||
+21
-1
@@ -28,6 +28,7 @@ local Runtime = require("src.mods.Runtime")
|
||||
local Steps = require("src.mods.Steps")
|
||||
local Net = require("src.mods.Net")
|
||||
local Job = require("src.mods.Job")
|
||||
local LoadOrder = require("src.mods.LoadOrder")
|
||||
|
||||
local Loader = {}
|
||||
Loader.__index = Loader
|
||||
@@ -359,11 +360,14 @@ function Loader:_loadState()
|
||||
Runtime.safeMode = false
|
||||
self.gen2Forced = {}
|
||||
self.modOptions = {}
|
||||
self.playerSaved, self.playerRank, self.playerFloor = nil, {}, 1
|
||||
return
|
||||
end
|
||||
local options = SaveData.loadOptions(self.fs)
|
||||
self.safeMode = SaveData.isSafeMode(options)
|
||||
Runtime.safeMode = self.safeMode
|
||||
self.playerSaved = SaveData.modOrder(options)
|
||||
self.playerRank, self.playerFloor = LoadOrder.rank(self.playerSaved)
|
||||
local scope = self:_enableScope()
|
||||
local ids = {}
|
||||
for id in pairs(options.mods or {}) do ids[id] = true end
|
||||
@@ -781,6 +785,12 @@ function Loader:_cartRank(id)
|
||||
return report.rank[id] or report.floor
|
||||
end
|
||||
|
||||
function Loader:_playerRank(id)
|
||||
local rank = self.playerRank
|
||||
if not rank then return 1 end
|
||||
return rank[id] or self.playerFloor or 1
|
||||
end
|
||||
|
||||
-- ------- validate and resolve
|
||||
|
||||
-- a failed mod keeps the user's enable flag (the manager still shows it as
|
||||
@@ -1046,6 +1056,14 @@ function Loader:_order()
|
||||
local targetVersion = self:_targetVersion()
|
||||
local generation = self.generation
|
||||
local pending, indegree, dependents = {}, {}, {}
|
||||
if self.playerSaved and #self.playerSaved > 0 then
|
||||
local entries = {}
|
||||
for id, mod in pairs(self.mods) do
|
||||
entries[#entries + 1] = { id = id, priority = mod.manifest and mod.manifest.priority }
|
||||
end
|
||||
self.playerRank, self.playerFloor =
|
||||
LoadOrder.rank(LoadOrder.materialize(self.playerSaved, entries))
|
||||
end
|
||||
for _, id in ipairs(orderedIds(self.mods, isActive)) do
|
||||
pending[id], indegree[id] = true, 0
|
||||
end
|
||||
@@ -1078,9 +1096,11 @@ function Loader:_order()
|
||||
best = id
|
||||
else
|
||||
local ra, rb = self:_cartRank(id), self:_cartRank(best)
|
||||
local ua, ub = self:_playerRank(id), self:_playerRank(best)
|
||||
local pa, pb = self.mods[id].manifest.priority,
|
||||
self.mods[best].manifest.priority
|
||||
if ra < rb or (ra == rb and (pa < pb or (pa == pb and id < best))) then
|
||||
if ra < rb or (ra == rb and (ua < ub or (ua == ub
|
||||
and (pa < pb or (pa == pb and id < best))))) then
|
||||
best = id
|
||||
end
|
||||
end
|
||||
|
||||
+116
-9
@@ -17,6 +17,7 @@ local Theme = require("src.ui.Theme")
|
||||
local OptionRows = require("src.ui.OptionRows")
|
||||
local Strings = require("src.core.Strings")
|
||||
local ModProfile = require("src.mods.ModProfile")
|
||||
local LoadOrder = require("src.mods.LoadOrder")
|
||||
|
||||
local ManagerState = {}
|
||||
ManagerState.__index = ManagerState
|
||||
@@ -43,6 +44,7 @@ local TAB_LINE = { "[MODS] PROF ERRS", "MODS [PROF] ERRS", "MODS PROF [ERRS]" }
|
||||
|
||||
local LIST_TOP = 3 -- first content row (tile y)
|
||||
local LIST_ROWS = 11 -- single-line rows in the scroll region
|
||||
local DETAIL_TOP, DETAIL_ROWS = 11, 4
|
||||
|
||||
-- what the mod declared it does, shown before the player enables it
|
||||
local PERMISSION_ROWS = {
|
||||
@@ -219,6 +221,7 @@ function ManagerState:refresh()
|
||||
-- gen2Pending is not in stagedList: the Gen 2 override is not an enable flag
|
||||
-- and there is nothing in `available` to diff it against
|
||||
self.restartPending = #self:stagedList() > 0 or self.gen2Pending == true
|
||||
or self.orderPending == true
|
||||
-- a live set that drifted off the named profile reverts to ad-hoc
|
||||
local opts = self:optionsTable()
|
||||
if opts.activeProfile then
|
||||
@@ -393,9 +396,7 @@ function ManagerState:detailRows(m)
|
||||
label = m.gen2Forced and Strings("DON'T TRY HERE") or Strings("TRY HERE ANYWAY"),
|
||||
action = function() self:toggleGen2Force(m) end }
|
||||
end
|
||||
-- which games the mod says it is for, in the one place the player is
|
||||
-- already looking when they wonder why it did not run
|
||||
rows[#rows + 1] = { inert = true, label = "FOR " .. ModTargets.chip(m) }
|
||||
for _, row in ipairs(self:orderRows(m)) do rows[#rows + 1] = row end
|
||||
if m.github then
|
||||
rows[#rows + 1] = { inert = true, label = "GH " .. m.github }
|
||||
end
|
||||
@@ -410,6 +411,85 @@ function ManagerState:detailRows(m)
|
||||
return rows
|
||||
end
|
||||
|
||||
function ManagerState:installedIds()
|
||||
local ids = {}
|
||||
for _, m in ipairs(self.status.available or {}) do ids[#ids + 1] = m.id end
|
||||
return ids
|
||||
end
|
||||
|
||||
function ManagerState:loadOrderList()
|
||||
local opts = self:optionsTable()
|
||||
local saved, available = opts.modOrder, self.status.available
|
||||
local c = self._orderCache
|
||||
if c and c.saved == saved and c.available == available then
|
||||
return c.shown, c.full
|
||||
end
|
||||
local full = LoadOrder.materialize(SaveData.modOrder(opts), available or {})
|
||||
local installed = {}
|
||||
for _, id in ipairs(self:installedIds()) do installed[id] = true end
|
||||
local shown = {}
|
||||
for _, id in ipairs(full) do
|
||||
if installed[id] then shown[#shown + 1] = id end
|
||||
end
|
||||
self._orderCache = { saved = saved, available = available, shown = shown, full = full }
|
||||
return shown, full
|
||||
end
|
||||
|
||||
function ManagerState:orderRows(m)
|
||||
local rows = {}
|
||||
if Runtime.safeMode then return rows end
|
||||
local loader = self.game.mods
|
||||
local report = loader and loader.cartReport
|
||||
if report and report.rank and report.rank[m.id] then
|
||||
rows[#rows + 1] = { inert = true, label = Strings("CART SETS ORDER") }
|
||||
return rows
|
||||
end
|
||||
local shown = self:loadOrderList()
|
||||
local at = LoadOrder.position(shown, m.id)
|
||||
if not at then return rows end
|
||||
if #shown < 2 then
|
||||
rows[#rows + 1] = { inert = true, label = "LOAD #" .. at .. " OF " .. #shown }
|
||||
return rows
|
||||
end
|
||||
rows[#rows + 1] = { label = "< LOAD #" .. at .. "/" .. #shown .. " >",
|
||||
adjust = function(dir) self:moveOrder(m, dir) end,
|
||||
action = function() self:notify("LEFT/RIGHT: MOVE") end }
|
||||
return rows
|
||||
end
|
||||
|
||||
local function sameList(a, b)
|
||||
if #a ~= #b then return false end
|
||||
for i, v in ipairs(a) do
|
||||
if b[i] ~= v then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function ManagerState:moveOrder(m, delta)
|
||||
if Runtime.safeMode then
|
||||
self:notify("SAFE MODE ACTIVE")
|
||||
return false
|
||||
end
|
||||
local opts = self:optionsTable()
|
||||
local shown, full = self:loadOrderList()
|
||||
local list, changed = LoadOrder.moveWithin(full, shown, m.id, delta)
|
||||
if not changed then
|
||||
self:notify(delta < 0 and "ALREADY FIRST" or "ALREADY LAST")
|
||||
return false
|
||||
end
|
||||
if not self.bootOrder then
|
||||
self.bootOrder = SaveData.modOrder(opts)
|
||||
self.bootOrderShown = shown
|
||||
end
|
||||
SaveData.setModOrder(opts, list)
|
||||
self:persistOptions()
|
||||
local now = self:loadOrderList()
|
||||
self.orderPending = not sameList(now, self.bootOrderShown)
|
||||
self:refresh()
|
||||
self:notify("LOAD #" .. (LoadOrder.position(now, m.id) or "?") .. " ON RESTART")
|
||||
return true
|
||||
end
|
||||
|
||||
function ManagerState:applyRows()
|
||||
local rows = {}
|
||||
rows[#rows + 1] = { label = Strings("APPLY & RESTART"), action = function()
|
||||
@@ -503,10 +583,11 @@ function ManagerState:moveCursor(dir)
|
||||
end
|
||||
end
|
||||
-- keep the cursor inside the scroll window
|
||||
local window = self.screen == "detail" and DETAIL_ROWS or LIST_ROWS
|
||||
if self.cursor < self.scroll then
|
||||
self.scroll = self.cursor
|
||||
elseif self.cursor > self.scroll + LIST_ROWS - 1 then
|
||||
self.scroll = self.cursor - LIST_ROWS + 1
|
||||
elseif self.cursor > self.scroll + window - 1 then
|
||||
self.scroll = self.cursor - window + 1
|
||||
end
|
||||
end
|
||||
|
||||
@@ -516,6 +597,8 @@ function ManagerState:adjustOrTab(dir)
|
||||
self.cursor, self.scroll = 1, 1
|
||||
self:snapCursor()
|
||||
elseif self.screen == "detail" then
|
||||
local row = self:focusedRow()
|
||||
if row and row.adjust then return row.adjust(dir) end
|
||||
self.descScroll = math.max(1, self.descScroll + dir)
|
||||
else
|
||||
for _ = 1, LIST_ROWS do self:moveCursor(dir) end
|
||||
@@ -786,6 +869,11 @@ function ManagerState:discardChanges()
|
||||
if loader and loader.setEnabled then loader:setEnabled(m.id, en) end
|
||||
SaveData.setModEnabled(opts, m.id, en, scope)
|
||||
end
|
||||
if self.bootOrder then
|
||||
SaveData.setModOrder(opts, self.bootOrder)
|
||||
self.bootOrder, self.bootOrderShown, self.orderPending = nil, nil, false
|
||||
self:persistOptions()
|
||||
end
|
||||
if loader and loader.status then self.game.modStatus = loader:status() end
|
||||
self:refresh()
|
||||
self:notify("CHANGES DISCARDED")
|
||||
@@ -816,6 +904,7 @@ function ManagerState:matchesProfile(p)
|
||||
-- the per-game answers count too, or a profile that only differs on Gold
|
||||
-- would read as still active after the player changed it
|
||||
return ModProfile.matchesVersions(p, self:optionsTable())
|
||||
and ModProfile.matchesOrder(p, self:optionsTable())
|
||||
end
|
||||
|
||||
function ManagerState:persistOptions()
|
||||
@@ -860,6 +949,16 @@ function ManagerState:applyProfile(p)
|
||||
end
|
||||
-- the per-game half of the setup, restored beside the shared enable set
|
||||
ModProfile.restoreVersions(p, self:optionsTable())
|
||||
do
|
||||
local before = self:loadOrderList()
|
||||
if not self.bootOrder then
|
||||
self.bootOrder = SaveData.modOrder(self:optionsTable())
|
||||
self.bootOrderShown = before
|
||||
end
|
||||
ModProfile.restoreOrder(p, self:optionsTable())
|
||||
self.orderPending = not sameList(self:loadOrderList(), self.bootOrderShown)
|
||||
self.restartPending = self.restartPending or self.orderPending
|
||||
end
|
||||
self:optionsTable().activeProfile = p.name
|
||||
self:persistOptions()
|
||||
local missing = ModProfile.missingIds(p, self.byId)
|
||||
@@ -879,12 +978,13 @@ function ManagerState:saveCurrentAs()
|
||||
local opts = self:optionsTable()
|
||||
opts.modProfiles = opts.modProfiles or {}
|
||||
local snap = ModProfile.capture(self.status.available,
|
||||
self:modOptionsTable(), opts.modsByVersion)
|
||||
self:modOptionsTable(), opts.modsByVersion, opts.modOrder)
|
||||
local existing = self:findProfile(name)
|
||||
if existing then
|
||||
existing.enabled, existing.options, existing.slots =
|
||||
snap.enabled, snap.options, snap.slots
|
||||
existing.enabledByVersion = snap.enabledByVersion
|
||||
existing.order = snap.order
|
||||
else
|
||||
snap.name = name
|
||||
opts.modProfiles[#opts.modProfiles + 1] = snap
|
||||
@@ -1261,6 +1361,7 @@ function ManagerState:drawDetail()
|
||||
drawTruncated(statusLine, 16, 3 * 8, 17)
|
||||
drawTruncated((m.category or "OTHER") .. " / " .. (m.profile or "content"),
|
||||
16, 4 * 8, 17)
|
||||
drawTruncated("FOR " .. ModTargets.chip(m), 16, 5 * 8, 17)
|
||||
local lines = wrap(m.error and ("FAILED: " .. m.error)
|
||||
or (m.note and ("SKIPPED: " .. m.note)) or m.description, 16)
|
||||
local visible = 5
|
||||
@@ -1273,12 +1374,18 @@ function ManagerState:drawDetail()
|
||||
Font.drawCode(Theme.moreArrow, 17 * 8, 10 * 8)
|
||||
end
|
||||
local rows = self:rowsForScreen()
|
||||
local y = 11
|
||||
for i, row in ipairs(rows) do
|
||||
drawTruncated(row.label, 32, y * 8, 15)
|
||||
local first = math.max(1, math.min(self.scroll or 1, #rows - DETAIL_ROWS + 1))
|
||||
if self.cursor >= 1 and self.cursor < first then first = self.cursor end
|
||||
local last = math.min(#rows, first + DETAIL_ROWS - 1)
|
||||
local y = DETAIL_TOP
|
||||
for i = first, last do
|
||||
drawTruncated(rows[i].label, 32, y * 8, 15)
|
||||
if i == self.cursor then Font.drawCode(Theme.cursor, 24, y * 8) end
|
||||
y = y + 1
|
||||
end
|
||||
if #rows > last then
|
||||
Font.drawCode(Theme.moreArrow, 19 * 8, (DETAIL_TOP + DETAIL_ROWS - 1) * 8)
|
||||
end
|
||||
self:drawFooter("A:CHOOSE B:BACK")
|
||||
end
|
||||
|
||||
|
||||
+28
-3
@@ -53,7 +53,14 @@ end
|
||||
-- loader's status manifests, m.enabled = the desired set including staged
|
||||
-- flips); `modOptions` is options.modOptions; `byVersion` is
|
||||
-- options.modsByVersion, the per-game answers that differ from it.
|
||||
function ModProfile.capture(available, modOptions, byVersion)
|
||||
local function copyOrder(list)
|
||||
if type(list) ~= "table" then return nil end
|
||||
local clean = SaveData.modOrder({ modOrder = list })
|
||||
if #clean == 0 then return nil end
|
||||
return clean
|
||||
end
|
||||
|
||||
function ModProfile.capture(available, modOptions, byVersion, order)
|
||||
local enabled, options = {}, {}
|
||||
for _, m in ipairs(available or {}) do
|
||||
enabled[m.id] = m.enabled and true or false
|
||||
@@ -75,7 +82,23 @@ function ModProfile.capture(available, modOptions, byVersion)
|
||||
if flags then perVersion[id] = flags end
|
||||
end
|
||||
return { enabled = enabled, options = options, slots = slots,
|
||||
enabledByVersion = perVersion }
|
||||
enabledByVersion = perVersion, order = copyOrder(order) }
|
||||
end
|
||||
|
||||
function ModProfile.restoreOrder(p, options)
|
||||
if type(options) ~= "table" then return end
|
||||
local wanted = copyOrder(type(p) == "table" and p.order or nil)
|
||||
SaveData.setModOrder(options, wanted or {})
|
||||
end
|
||||
|
||||
function ModProfile.matchesOrder(p, options)
|
||||
local wanted = copyOrder(type(p) == "table" and p.order or nil) or {}
|
||||
local live = SaveData.modOrder(options)
|
||||
if #live ~= #wanted then return false end
|
||||
for i, id in ipairs(wanted) do
|
||||
if live[i] ~= id then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Write a profile's per-game answers back into an options table, replacing
|
||||
@@ -147,7 +170,8 @@ function ModProfile.encode(p)
|
||||
formatVersion = ModProfile.FORMAT_VERSION,
|
||||
profile = { name = p.name, enabled = p.enabled,
|
||||
options = p.options, slots = p.slots,
|
||||
enabledByVersion = p.enabledByVersion },
|
||||
enabledByVersion = p.enabledByVersion,
|
||||
order = copyOrder(p.order) },
|
||||
})
|
||||
end
|
||||
|
||||
@@ -191,6 +215,7 @@ function ModProfile.decode(body)
|
||||
if flags then p.enabledByVersion[version] = flags end
|
||||
end
|
||||
end
|
||||
p.order = copyOrder(raw.order)
|
||||
return p
|
||||
end
|
||||
|
||||
|
||||
@@ -142,6 +142,8 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
local name = mon.nickname or def.name
|
||||
if #mon.moves < 4 then
|
||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||
require("src.world.PikachuFollower")
|
||||
.onMoveLearned(game.save, mon, moveId)
|
||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
-- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton
|
||||
-- (learn_move.asm), so the jingle rides the box
|
||||
|
||||
@@ -414,8 +414,10 @@ local function ensureLib()
|
||||
local ok, loaded = pcall(ffi.load, path)
|
||||
disarmProbe()
|
||||
if ok then lib = loaded; return lib end
|
||||
tried[#tried + 1] = path .. " (" .. tostring(loaded) .. ")"
|
||||
else
|
||||
tried[#tried + 1] = path
|
||||
end
|
||||
tried[#tried + 1] = path
|
||||
end
|
||||
local okSym, sym = pcall(function()
|
||||
return ffi.C and ffi.C.librashader_translate_preset
|
||||
|
||||
@@ -127,6 +127,8 @@ O.playTimeFrames = O.mainData + 1870 -- 1B (0-59, 1/60s tic
|
||||
-- offset is identical between pokered and pokeyellow (same sram.asm, same
|
||||
-- wMainData field spacing per both symbol files).
|
||||
O.pikachuHappiness = O.mainData + 377
|
||||
O.pikachuMood = O.mainData + 378 -- engine/menus/save.asm:260
|
||||
O.pikachuEmotionModifier = O.mainData + 421 -- ram/wram.asm:2090
|
||||
O.mainDataSize = 1929 -- wMainDataEnd - wMainDataStart
|
||||
|
||||
O.spriteData = O.mainData + O.mainDataSize
|
||||
@@ -870,6 +872,9 @@ function GenSave.decode(bytes, data, opts)
|
||||
-- O.pikachuHappiness) (#763, #838).
|
||||
if data.gameVersion == "yellow" then
|
||||
save.pikachuHappiness = u8(bytes, O.pikachuHappiness)
|
||||
save.pikachuMood = u8(bytes, O.pikachuMood)
|
||||
local modifier = u8(bytes, O.pikachuEmotionModifier)
|
||||
save.pikachuEmotionModifier = modifier ~= 0 and modifier or nil
|
||||
end
|
||||
|
||||
save.warnings = warnings
|
||||
@@ -1196,6 +1201,11 @@ function GenSave.encode(save, data, template)
|
||||
if data.gameVersion == "yellow" then
|
||||
local h = tonumber(save.pikachuHappiness) or 90
|
||||
setByte(buf, O.pikachuHappiness, math.max(0, math.min(255, math.floor(h))))
|
||||
-- engine/movie/oak_speech/init_player_data.asm:17
|
||||
local mood = tonumber(save.pikachuMood) or 0x80
|
||||
setByte(buf, O.pikachuMood, math.max(0, math.min(255, math.floor(mood))))
|
||||
local modifier = tonumber(save.pikachuEmotionModifier) or 0
|
||||
setByte(buf, O.pikachuEmotionModifier, math.max(0, math.min(255, math.floor(modifier))))
|
||||
end
|
||||
|
||||
local out = table.concat(buf)
|
||||
|
||||
@@ -492,10 +492,12 @@ function Commands.face(ctx, dir)
|
||||
end
|
||||
|
||||
-- face an arbitrary map object (by object_event index)
|
||||
function Commands.face_object(ctx, objIndex, dir)
|
||||
function Commands.face_object(ctx, objIndex, dir, opts)
|
||||
local npc = stampSpriteIndex(ctx,
|
||||
ctx.overworld and ctx.overworld:npcByIndex(objIndex))
|
||||
if npc then npc.facing = dir end
|
||||
-- pokeyellow engine/overworld/movement.asm:349
|
||||
if npc and opts and opts.hold then npc.timer = opts.hold end
|
||||
end
|
||||
|
||||
-- Instantly relocate an NPC (OaksLabCalcRivalMovementScript / SetSpritePosition1).
|
||||
@@ -1201,12 +1203,17 @@ function Commands.walk_npc(ctx, objIndex, dirs, opts)
|
||||
claimMove(ctx, entity)
|
||||
local runner = ctx.runner
|
||||
local wait = not (opts and opts.wait == false)
|
||||
-- pokeyellow engine/overworld/movement.asm:932
|
||||
local fast = opts and opts.stepFrames and entity ~= ow.player
|
||||
local prevStep = entity.stepFrames
|
||||
if fast then entity.stepFrames = opts.stepFrames end
|
||||
local yielded, finished = false, false
|
||||
local i = 0
|
||||
local function step()
|
||||
i = i + 1
|
||||
if not dirs[i] then
|
||||
finished = true
|
||||
if fast then entity.stepFrames = prevStep end
|
||||
if wait and yielded then runner:resume() end
|
||||
return
|
||||
end
|
||||
|
||||
+22
-5
@@ -245,8 +245,9 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
local function teach()
|
||||
-- PIKAHAPPY_USEDTMHM on a successful teach (item_effects.asm:2500)
|
||||
local function taught()
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(game.save, "USEDTMHM", target)
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
PikachuFollower.modifyHappiness(game.save, "USEDTMHM", target)
|
||||
PikachuFollower.onMoveLearned(game.save, target, moveId)
|
||||
end
|
||||
if #target.moves < 4 then
|
||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||
@@ -311,8 +312,9 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
local alarm = battle.lowHealthAlarmActive and battle:lowHealthAlarmActive()
|
||||
local opts = nil
|
||||
if not alarm then
|
||||
opts = TextBox.soundOpts(game, "Pokeflute",
|
||||
{ auto = { wait = false, delay = 0, promptFirst = true } })
|
||||
opts = TextBox.soundOpts(game, function()
|
||||
return require("src.core.Sound").playPokefluteInBattle(game.data)
|
||||
end, { auto = { wait = false, delay = 0, promptFirst = true } })
|
||||
end
|
||||
showMessages(game, head, function()
|
||||
showMessages(game, tail, function() spent({}) end)
|
||||
@@ -436,6 +438,8 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
local mdef = game.data.moves[moveId]
|
||||
if #target.moves < 4 then
|
||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||
require("src.world.PikachuFollower")
|
||||
.onMoveLearned(game.save, target, moveId)
|
||||
local name = target.nickname or def.name
|
||||
showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
|
||||
nextStep, TextBox.soundOpts(game, "Get_Item1"))
|
||||
@@ -475,6 +479,19 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
return
|
||||
end
|
||||
|
||||
-- engine/items/item_effects.asm:2004-2006
|
||||
if result == "ppmaxed" then
|
||||
local menu = game.stack:top()
|
||||
if not (menu and picker and menu.owner == picker and menu.held
|
||||
and menu.ask) then
|
||||
menu = nil
|
||||
end
|
||||
showMessages(game, payload, function()
|
||||
if menu then menu:ask() else closePicker() end
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- .chooseMon loops on a refusal, so the TM/HM picker stays up for another
|
||||
-- pick (engine/items/item_effects.asm:2234, :2237) (#1686)
|
||||
local machineDef = game.data.items[id]
|
||||
@@ -526,7 +543,7 @@ local function pickTargetAndUse(game, battle, id, list)
|
||||
require("src.ui.Screens").push(game, "MoveSelectMenu", mon, prompt,
|
||||
function(moveIndex)
|
||||
useOn(game, battle, id, mon, list, moveIndex, picker)
|
||||
end)
|
||||
end, nil, picker)
|
||||
end,
|
||||
}
|
||||
-- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon
|
||||
|
||||
+163
-57
@@ -7,6 +7,8 @@ local Music = require("src.core.Music")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Timing = require("src.core.Timing")
|
||||
local FaithfulRes = require("src.core.FaithfulRes")
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
|
||||
local IntroMovie = {}
|
||||
IntroMovie.__index = IntroMovie
|
||||
@@ -14,14 +16,22 @@ IntroMovie.isOpaque = true
|
||||
|
||||
-- Same as the title screen: full-bleed art, no world behind it, no player zoom
|
||||
-- to respect, so fill the window rather than sit at the fixed integer scale.
|
||||
function IntroMovie:wantsFillScale() return true end
|
||||
function IntroMovie:wantsFillScale()
|
||||
if FaithfulRes.locked then return false end
|
||||
if TouchSkin.drawable() then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
-- SGB intro palettes: the splash uses PalPacket_GameFreakIntro (logo
|
||||
-- GAMEFREAK, falling star columns RED/VIRIDIAN/BLUEMON), the attract
|
||||
-- fight PalPacket_NidorinoIntro (PURPLEMON letterbox, BLACK bars)
|
||||
function IntroMovie:sgbPalettes(game)
|
||||
local P = require("src.render.PaletteFX")
|
||||
if self.phase == 2 then
|
||||
if self.phase == 1 and (self.studio.card or self.studio.credit) then
|
||||
return nil
|
||||
end
|
||||
-- engine/movie/intro.asm:306
|
||||
if self.phase == 1 or self.phase == 2 then
|
||||
local logo = P.pal(game.data, "GAMEFREAK")
|
||||
if not logo then return nil end
|
||||
return {
|
||||
@@ -39,7 +49,7 @@ function IntroMovie:sgbPalettes(game)
|
||||
P.zone(P.pal(game.data, "BLACK"), 0, 14, 19, 17),
|
||||
}
|
||||
end
|
||||
return nil -- the copyright card stays plain
|
||||
return nil
|
||||
end
|
||||
|
||||
local COPYRIGHT_FRAMES = 180 -- ld c, 180 (intro.asm:311-312)
|
||||
@@ -65,6 +75,29 @@ local POST_FADE_WHITE = 1 + 3 + 3
|
||||
local LOGO_X, LOGO_Y = 72, 56
|
||||
local TEXT_X, TEXT_Y = 40, 80
|
||||
|
||||
-- engine/movie/splash.asm:72
|
||||
local LOGO_OBP0 = { [0] = 0xF9, 0x7E, 0x9F, 0xE7 }
|
||||
-- engine/movie/splash.asm:4
|
||||
local STAR_OBP1 = 0xA4
|
||||
|
||||
local rampCache = setmetatable({}, { __mode = "k" })
|
||||
|
||||
local function obpRamp(obp, ramp)
|
||||
local byRamp = rampCache[ramp]
|
||||
if not byRamp then byRamp = {}; rampCache[ramp] = byRamp end
|
||||
local c = byRamp[obp]
|
||||
if not c then
|
||||
c = { ramp[1] }
|
||||
for i = 1, 3 do c[i + 1] = ramp[math.floor(obp / 4 ^ i) % 4 + 1] end
|
||||
byRamp[obp] = c
|
||||
end
|
||||
return c
|
||||
end
|
||||
|
||||
local function romArt(path)
|
||||
return path and not path:find("^mods/") and path or nil
|
||||
end
|
||||
|
||||
-- CopyrightTextString (engine/movie/title.asm). Red/Blue years are
|
||||
-- (c)'95.'96.'98; Yellow's sheet spells (c)1995-1999 and finishes the
|
||||
-- last digit with NineTile (title screen). Intro originally overflows
|
||||
@@ -123,6 +156,15 @@ local FIGHT_SCRIPT = {
|
||||
{ sfx = "Intro_Lunge" }, { frame = 3 }, { anim = 7 }, -- :135-141
|
||||
{ fade = 24 }, -- GBFadeOutToWhite: 3 pals x 8 frames (home/fade.asm:26-40)
|
||||
}
|
||||
local FIGHT_FADE_INDEX = #FIGHT_SCRIPT
|
||||
|
||||
-- home/fade.asm:71-73
|
||||
local FADE_TO_WHITE = {
|
||||
{ bgp = 0x90, obp = 0x80 },
|
||||
{ bgp = 0x40, obp = 0x40 },
|
||||
{ bgp = 0x00, obp = 0x00 },
|
||||
}
|
||||
local BGP_NORMAL = 0xE4
|
||||
|
||||
local function tryImage(path)
|
||||
if not path then return nil end
|
||||
@@ -173,7 +215,10 @@ function IntroMovie.new(game, onDone)
|
||||
end
|
||||
self.logo = img(intro.gamefreakLogo)
|
||||
self.gfText = img(intro.gamefreakText)
|
||||
self.logoPath = self.logo and romArt(intro.gamefreakLogo.path)
|
||||
self.gfTextPath = self.gfText and romArt(intro.gamefreakText.path)
|
||||
self.bigStar = img(intro.bigStar)
|
||||
self.bigStarPath = self.bigStar and romArt(intro.bigStar.path)
|
||||
self.smallStar = img(intro.fallingStar)
|
||||
self.smallStarBlink = img(intro.fallingStarBlink)
|
||||
self.gengarFrames, self.nidoFrames = {}, {}
|
||||
@@ -194,7 +239,7 @@ function IntroMovie.new(game, onDone)
|
||||
self.nidoX, self.nidoY = -8, 72
|
||||
self.gengarPose, self.nidoFrame = 1, 1
|
||||
self.opIndex, self.opTimer = 1, 0
|
||||
self.fade = 0
|
||||
self.fadeStep = nil
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -213,7 +258,6 @@ function IntroMovie:exitToTitle()
|
||||
if self.finished or self.phase == 4 then return end
|
||||
self.phase = 4
|
||||
self.timer = 0
|
||||
self.fade = 1
|
||||
end
|
||||
|
||||
function IntroMovie:startPhase(phase)
|
||||
@@ -231,6 +275,26 @@ function IntroMovie:startPhase(phase)
|
||||
end
|
||||
|
||||
-- one frame of the fight script (see FIGHT_SCRIPT)
|
||||
function IntroMovie:endScene()
|
||||
self.opIndex = FIGHT_FADE_INDEX
|
||||
self.opTimer = 0
|
||||
end
|
||||
|
||||
-- home/overworld.asm:2395
|
||||
function IntroMovie:pollInterrupt()
|
||||
local input = self.game.input
|
||||
if not input then return false end
|
||||
local function down(b)
|
||||
return (input.isDown and input:isDown(b)) or input:wasPressed(b)
|
||||
end
|
||||
local last = self.lastPollHeld or {}
|
||||
local held = { a = down("a"), start = down("start") }
|
||||
self.lastPollHeld = held
|
||||
if (held.a and not last.a) or (held.start and not last.start) then return true end
|
||||
return down("up") and down("select") and down("b")
|
||||
and not (held.a or held.start or down("down") or down("left") or down("right"))
|
||||
end
|
||||
|
||||
function IntroMovie:fightStep()
|
||||
while true do
|
||||
local op = FIGHT_SCRIPT[self.opIndex]
|
||||
@@ -259,6 +323,13 @@ function IntroMovie:fightStep()
|
||||
end
|
||||
end
|
||||
self.opTimer = self.opTimer + 1
|
||||
if self:pollInterrupt() then
|
||||
-- engine/movie/intro.asm:264
|
||||
if op.move == "scrollIn" then self:endScene() return end
|
||||
self.opIndex = self.opIndex + 1
|
||||
self.opTimer = 0
|
||||
return
|
||||
end
|
||||
if self.opTimer < (op.px or math.abs(op.dx)) then return end
|
||||
timed = true
|
||||
elseif op.anim then
|
||||
@@ -273,11 +344,12 @@ function IntroMovie:fightStep()
|
||||
timed = true
|
||||
elseif op.wait then
|
||||
self.opTimer = self.opTimer + 1
|
||||
if self:pollInterrupt() then self:endScene() return end
|
||||
if self.opTimer < op.wait then return end
|
||||
timed = true
|
||||
elseif op.fade then
|
||||
self.opTimer = self.opTimer + 1
|
||||
self.fade = self.opTimer / op.fade
|
||||
self.fadeStep = math.floor((self.opTimer - 1) / 8) + 1
|
||||
if self.opTimer >= op.fade then self:exitToTitle() end
|
||||
return
|
||||
end
|
||||
@@ -297,10 +369,6 @@ function IntroMovie:update(dt)
|
||||
if self.timer >= POST_FADE_WHITE then self:finish() end
|
||||
return
|
||||
end
|
||||
local input = self.game.input
|
||||
-- CheckForUserInterruption (home/overworld.asm:2395) returns carry only
|
||||
-- on a fresh START or A -- B alone never skips the intro.
|
||||
local skip = input:wasPressed("a") or input:wasPressed("start")
|
||||
self.timer = self.timer + 1
|
||||
if self.phase == 1 then
|
||||
-- the copyright card is a bare DelayFrames, deaf to input (intro.asm:311)
|
||||
@@ -310,18 +378,12 @@ function IntroMovie:update(dt)
|
||||
Sound.play(self.game.data, "Shooting_Star") -- splash.asm:29-30
|
||||
end
|
||||
-- intro.asm:325 `jr c, .next`
|
||||
if skip and self.timer >= STAR_START and self.timer < WAVES_END then
|
||||
if self.timer >= STAR_START and self.timer < WAVES_END and self:pollInterrupt() then
|
||||
self:startPhase(3)
|
||||
return
|
||||
end
|
||||
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
|
||||
else
|
||||
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
|
||||
-- white hold stands in for that beat before the title is built.
|
||||
if skip then
|
||||
self:exitToTitle()
|
||||
return
|
||||
end
|
||||
-- PlayShootingStar ends `jp Delay3` once Music_IntroBattle is playing
|
||||
-- (intro.asm:337), so PlayIntroScene's first op is not on the music's
|
||||
-- own frame
|
||||
@@ -333,28 +395,53 @@ end
|
||||
-- (IntroDrawBlackBars, intro.asm:343-357); drawn AFTER the sprites since
|
||||
-- both Nidorino and the small stars carry OAM_PRIO (intro.asm:195,
|
||||
-- splash.asm:149) so the bars cover them.
|
||||
local function drawBars()
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local function drawBars(bgp)
|
||||
local g = (3 - math.floor((bgp or BGP_NORMAL) / 64) % 4) / 3
|
||||
love.graphics.setColor(g, g, g, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 32)
|
||||
love.graphics.rectangle("fill", 0, 112, 160, 32)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function IntroMovie:logoObp0()
|
||||
local t = self.timer - FLASH_START
|
||||
if t < 0 then return LOGO_OBP0[0] end
|
||||
return LOGO_OBP0[math.min(3, math.floor(t / 10) + 1)]
|
||||
end
|
||||
|
||||
function IntroMovie:drawObp0(img, path, x, y, obp)
|
||||
if not img then return end
|
||||
if not path then
|
||||
love.graphics.draw(img, x, y)
|
||||
return
|
||||
end
|
||||
local P = require("src.render.PaletteFX")
|
||||
local SR = require("src.render.SpriteRenderer")
|
||||
love.graphics.draw(SR.obpImage(path, obpRamp(obp, P.GRAYS), "gfobp" .. obp), x, y)
|
||||
if P.usesSpriteObp() then
|
||||
local blue = GameVersion.isBlue()
|
||||
P.markUiSpriteRedraw(SR.obpImage(path,
|
||||
obpRamp(obp, blue and P.GBC_OBJ_BLUE or P.GBC_OBJ),
|
||||
(blue and "gfobj_blue" or "gfobj") .. obp), nil, x, y)
|
||||
end
|
||||
end
|
||||
|
||||
function IntroMovie:drawSplash()
|
||||
local t = self.timer
|
||||
if t >= STAR_START then
|
||||
-- logo + GAME FREAK letters appear with the star OAM
|
||||
-- (LoadShootingStarGraphics, splash.asm:18-25); the logo palette
|
||||
-- rotates during the 3-flash loop (splash.asm:72-82)
|
||||
local flashing = t >= FLASH_START and t < FLASH_START + FLASH_FRAMES
|
||||
local dim = flashing and math.floor((t - FLASH_START) / 5) % 2 == 0
|
||||
love.graphics.setColor(1, 1, 1, dim and 0.35 or 1)
|
||||
if self.studioLogo then
|
||||
local flashing = t >= FLASH_START and t < FLASH_START + FLASH_FRAMES
|
||||
local dim = flashing and math.floor((t - FLASH_START) / 5) % 2 == 0
|
||||
love.graphics.setColor(1, 1, 1, dim and 0.35 or 1)
|
||||
love.graphics.draw(self.studioLogo, self.studioX, self.studioY,
|
||||
0, self.studioScale, self.studioScale)
|
||||
else
|
||||
if self.logo then love.graphics.draw(self.logo, LOGO_X, LOGO_Y) end
|
||||
if self.gfText then love.graphics.draw(self.gfText, TEXT_X, TEXT_Y) end
|
||||
local obp = self:logoObp0()
|
||||
self:drawObp0(self.logo, self.logoPath, LOGO_X, LOGO_Y, obp)
|
||||
self:drawObp0(self.gfText, self.gfTextPath, TEXT_X, TEXT_Y, obp)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
@@ -362,15 +449,16 @@ function IntroMovie:drawSplash()
|
||||
-- small stars: wave w spawns at y=88 every 24 frames, everything falls
|
||||
-- +1px per 3-frame substep until the wave loop ends; the lower star in
|
||||
-- the tile blinks every substep (splash.asm:97-146, 186-209)
|
||||
local substep = math.floor((math.min(t, WAVES_END) - WAVES_START) / 3)
|
||||
local blink = substep % 2 == 0
|
||||
-- engine/movie/splash.asm:186
|
||||
local moves = math.min(math.floor((t - WAVES_START) / 3) + 1, 6 * 8)
|
||||
local lit = moves % 2 == 1
|
||||
for w, xs in ipairs(STAR_WAVES) do
|
||||
local spawn = (w - 1) * 8 -- in substeps
|
||||
if substep >= spawn then
|
||||
local y = 88 + (substep - spawn)
|
||||
if moves > spawn then
|
||||
local y = 88 + (moves - spawn)
|
||||
if y < 144 then
|
||||
local img = blink and self.smallStar
|
||||
or (self.smallStarBlink or self.smallStar)
|
||||
local img = lit and (self.smallStarBlink or self.smallStar)
|
||||
or self.smallStar
|
||||
for _, x in ipairs(xs) do
|
||||
if img then
|
||||
love.graphics.draw(img, x, y)
|
||||
@@ -389,7 +477,19 @@ function IntroMovie:drawSplash()
|
||||
-- ..(engine/movie/splash.asm ln 32)
|
||||
local n = t - STAR_START + 1
|
||||
local sx, sy = 152 - 4 * n, -16 + 4 * n
|
||||
if self.bigStar then
|
||||
if self.bigStar and self.bigStarPath then
|
||||
local P = require("src.render.PaletteFX")
|
||||
local SR = require("src.render.SpriteRenderer")
|
||||
love.graphics.draw(SR.obpImage(self.bigStarPath,
|
||||
obpRamp(STAR_OBP1, P.GRAYS), "gfobp" .. STAR_OBP1), sx, sy)
|
||||
if P.usesSpriteObp() then
|
||||
-- engine/movie/splash.asm:22
|
||||
local blue = GameVersion.isBlue()
|
||||
P.markUiSpriteRedraw(SR.obpImage(self.bigStarPath,
|
||||
obpRamp(STAR_OBP1, P.ogBg()),
|
||||
(blue and "gfobj1_blue" or "gfobj1") .. STAR_OBP1), nil, sx, sy)
|
||||
end
|
||||
elseif self.bigStar then
|
||||
love.graphics.draw(self.bigStar, sx, sy)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
@@ -403,13 +503,30 @@ function IntroMovie:drawFight()
|
||||
-- Gengar: a 56x56 BG-tile pose recomposed from gengar_N.tilemap, moved
|
||||
-- by scrolling SCX (intro.asm:32-33, 235-269)
|
||||
-- Nidorino: 6x6 OAM sprite, one of the three red_nidorino poses
|
||||
local fade = self.fadeStep and FADE_TO_WHITE[self.fadeStep]
|
||||
local bgp = fade and fade.bgp or BGP_NORMAL
|
||||
local obp = fade and fade.obp or BGP_NORMAL
|
||||
local P = require("src.render.PaletteFX")
|
||||
local SR = require("src.render.SpriteRenderer")
|
||||
local nido = self.nidoFrames[self.nidoFrame]
|
||||
local nidoPath = self.nidoPaths and self.nidoPaths[self.nidoFrame]
|
||||
if nido then
|
||||
love.graphics.draw(nido, self.nidoX, self.nidoY)
|
||||
if fade and nidoPath then
|
||||
love.graphics.draw(SR.obpImage(nidoPath, obpRamp(obp, P.GRAYS),
|
||||
"fadeobp" .. obp), self.nidoX, self.nidoY)
|
||||
else
|
||||
love.graphics.draw(nido, self.nidoX, self.nidoY)
|
||||
end
|
||||
end
|
||||
local gengar = self.gengarFrames[self.gengarPose]
|
||||
local gengarPath = self.gengarPaths and self.gengarPaths[self.gengarPose]
|
||||
if gengar then
|
||||
love.graphics.draw(gengar, self.gengarX, self.gengarY)
|
||||
if fade and gengarPath then
|
||||
love.graphics.draw(SR.obpImage(gengarPath, obpRamp(bgp, P.GRAYS),
|
||||
"fadebgp" .. bgp), self.gengarX, self.gengarY)
|
||||
else
|
||||
love.graphics.draw(gengar, self.gengarX, self.gengarY)
|
||||
end
|
||||
end
|
||||
|
||||
if not gengar and not nido then
|
||||
@@ -417,45 +534,34 @@ function IntroMovie:drawFight()
|
||||
Font.draw(Strings("GENGAR VS NIDORINO"), (160 - 18 * 8) / 2, 64)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
drawBars()
|
||||
if self.fade > 0 then
|
||||
love.graphics.setColor(1, 1, 1, math.min(1, self.fade))
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
self:replayObjLayer(nido, gengar)
|
||||
drawBars(bgp)
|
||||
self:replayObjLayer(nido, gengar, fade)
|
||||
end
|
||||
|
||||
local whitePixel
|
||||
|
||||
local FIGHT_CLIP = { 0, 32, 160, 80 }
|
||||
|
||||
function IntroMovie:replayObjLayer(nido, gengar)
|
||||
function IntroMovie:replayObjLayer(nido, gengar, fade)
|
||||
local P = require("src.render.PaletteFX")
|
||||
local nidoPath = self.nidoPaths and self.nidoPaths[self.nidoFrame]
|
||||
if not (nido and nidoPath and P.usesSpriteObp()) then return end
|
||||
local SR = require("src.render.SpriteRenderer")
|
||||
local blue = GameVersion.isBlue()
|
||||
local opts = { clip = FIGHT_CLIP }
|
||||
local objColors = blue and P.GBC_OBJ_BLUE or P.GBC_OBJ
|
||||
local objGroup = blue and "gbcobj_blue" or "gbcobj"
|
||||
local bgColors, bgGroup = P.ogBg(), blue and "ogbg_blue" or "ogbg"
|
||||
if fade then
|
||||
objColors, objGroup = obpRamp(fade.obp, objColors), objGroup .. fade.obp
|
||||
bgColors, bgGroup = obpRamp(fade.bgp, bgColors), bgGroup .. fade.bgp
|
||||
end
|
||||
-- engine/movie/intro.asm:28
|
||||
P.markUiSpriteRedraw(SR.obpImage(nidoPath,
|
||||
blue and P.GBC_OBJ_BLUE or P.GBC_OBJ, blue and "gbcobj_blue" or "gbcobj"),
|
||||
P.markUiSpriteRedraw(SR.obpImage(nidoPath, objColors, objGroup),
|
||||
nil, self.nidoX, self.nidoY, opts)
|
||||
local gengarPath = self.gengarPaths[self.gengarPose]
|
||||
if gengar and gengarPath then
|
||||
-- engine/movie/intro.asm:195
|
||||
P.markUiSpriteRedraw(SR.obpImage(gengarPath, P.ogBg(),
|
||||
blue and "ogbg_blue" or "ogbg"), nil, self.gengarX, self.gengarY, opts)
|
||||
end
|
||||
if self.fade > 0 then
|
||||
if not whitePixel then
|
||||
local id = love.image.newImageData(1, 1)
|
||||
id:setPixel(0, 0, 1, 1, 1, 1)
|
||||
whitePixel = love.graphics.newImage(id)
|
||||
end
|
||||
P.markUiSpriteRedraw(whitePixel, nil, FIGHT_CLIP[1], FIGHT_CLIP[2],
|
||||
{ clip = FIGHT_CLIP, sx = FIGHT_CLIP[3], sy = FIGHT_CLIP[4],
|
||||
color = { 1, 1, 1, math.min(1, self.fade) } })
|
||||
P.markUiSpriteRedraw(SR.obpImage(gengarPath, bgColors, bgGroup),
|
||||
nil, self.gengarX, self.gengarY, opts)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ function MoveLearnMenu:enter()
|
||||
nil, {
|
||||
choice = function(yes)
|
||||
if yes then
|
||||
self.selecting = true
|
||||
self:askForget()
|
||||
else
|
||||
self:confirmAbandon()
|
||||
end
|
||||
@@ -63,6 +63,26 @@ function MoveLearnMenu:enter()
|
||||
}))
|
||||
end
|
||||
|
||||
-- engine/pokemon/learn_move.asm:119-123
|
||||
function MoveLearnMenu:askForget()
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local game = self.game
|
||||
self.selecting = false
|
||||
local text = romText(game.data, "_WhichMoveToForgetText",
|
||||
"Which move should\nbe forgotten?")
|
||||
self.forgetPrompt = TextBox.strip(text)
|
||||
local box
|
||||
box = TextBox.new(game, text, nil, { stay = {
|
||||
onShown = function()
|
||||
if game.stack:top() == box then game.stack:pop() end
|
||||
-- engine/pokemon/learn_move.asm:142
|
||||
self.index = 1
|
||||
self.selecting = true
|
||||
end,
|
||||
} })
|
||||
game.stack:push(box)
|
||||
end
|
||||
|
||||
function MoveLearnMenu:update(dt)
|
||||
if not self.selecting then return end
|
||||
local input = self.game.input
|
||||
@@ -79,10 +99,13 @@ function MoveLearnMenu:update(dt)
|
||||
local old = self.mon.moves[self.index]
|
||||
if HM_MOVES[old.id] then
|
||||
-- HMCantDeleteText, then back to the forget list
|
||||
-- engine/pokemon/learn_move.asm:177-181
|
||||
local TextBox = require("src.render.TextBox")
|
||||
self.selecting = false
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
romText(self.game.data, "_HMCantDeleteText",
|
||||
"HM techniques\ncan't be deleted!")))
|
||||
"HM techniques\ncan't be deleted!"),
|
||||
function() self:askForget() end))
|
||||
return
|
||||
end
|
||||
local mdef = self.game.data.moves[self.newMoveId]
|
||||
@@ -119,6 +142,8 @@ function MoveLearnMenu:finish(learned)
|
||||
local msg
|
||||
local opts
|
||||
if learned then
|
||||
require("src.world.PikachuFollower")
|
||||
.onMoveLearned(game.save, self.mon, self.newMoveId)
|
||||
-- pokered pages this as four texts in a row; _ForgotAndText carries
|
||||
-- the "And..." tail. Each text_pause holds the box, and the first one
|
||||
-- is followed by SFX_SWAP (engine/pokemon/learn_move.asm:208-222).
|
||||
@@ -146,16 +171,20 @@ function MoveLearnMenu:draw()
|
||||
if not self.selecting then return end
|
||||
-- TextBoxBorder 4,7 b=4 c=14; PlaceString 6,8; cursor 5,8
|
||||
-- engine/pokemon/learn_move.asm:123-140 (#1686)
|
||||
-- engine/pokemon/learn_move.asm:121
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local prompt = self.forgetPrompt or Strings("Which move should\nbe forgotten?")
|
||||
local first, rest = prompt:match("^(.-)\n(.*)$")
|
||||
Font.draw(first or prompt, 8, 14 * 8)
|
||||
if rest then Font.draw(rest, 8, 16 * 8) end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.drawBox(4, 7, 16, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, mv in ipairs(self.mon.moves) do
|
||||
Font.draw(self.game.data.moves[mv.id].name, 48, (7 + i) * 8)
|
||||
end
|
||||
Font.drawCode(CURSOR, 40, (7 + self.index) * 8)
|
||||
-- WhichMoveToForgetText in the bottom dialogue box
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
Font.draw(Strings("Which move should"), 8, 14 * 8)
|
||||
Font.draw(Strings("be forgotten?"), 8, 16 * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
@@ -7,20 +7,53 @@ MoveSelectMenu.__index = MoveSelectMenu
|
||||
|
||||
local CURSOR = 0xED
|
||||
|
||||
function MoveSelectMenu.new(game, mon, prompt, onChoose, onCancel)
|
||||
function MoveSelectMenu.new(game, mon, prompt, onChoose, onCancel, owner)
|
||||
local self = setmetatable({}, MoveSelectMenu)
|
||||
self.game = game
|
||||
self.mon = mon
|
||||
self.rawPrompt = prompt or ""
|
||||
-- pokered engine/items/item_effects.asm:2136
|
||||
self.prompt = require("src.render.TextBox").strip(prompt or "")
|
||||
self.onChoose = onChoose
|
||||
self.onCancel = onCancel
|
||||
self.owner = owner
|
||||
-- pokered engine/battle/core.asm:2542-2543
|
||||
self.index = 1
|
||||
self.ready = false
|
||||
self.held = false
|
||||
self.boxShown = false
|
||||
return self
|
||||
end
|
||||
|
||||
function MoveSelectMenu:enter()
|
||||
self:ask()
|
||||
end
|
||||
|
||||
-- engine/items/item_effects.asm:1979
|
||||
function MoveSelectMenu:ask(text)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local game = self.game
|
||||
self.ready, self.held = false, false
|
||||
local box
|
||||
box = TextBox.new(game, text or self.rawPrompt, nil, { stay = {
|
||||
onShown = function()
|
||||
if game.stack:top() == box then game.stack:pop() end
|
||||
-- engine/battle/core.asm:2542-2543
|
||||
self.index = 1
|
||||
self.boxShown = true
|
||||
self.ready = true
|
||||
end,
|
||||
} })
|
||||
game.stack:push(box)
|
||||
end
|
||||
|
||||
function MoveSelectMenu:release()
|
||||
self.held = false
|
||||
if self.game.stack:top() == self then self.game.stack:pop() end
|
||||
end
|
||||
|
||||
function MoveSelectMenu:update()
|
||||
if not self.ready then return end
|
||||
local input = self.game.input
|
||||
local n = #self.mon.moves
|
||||
-- engine/battle/core.asm:2692
|
||||
@@ -30,16 +63,28 @@ function MoveSelectMenu:update()
|
||||
self.index = self.index < n and self.index + 1 or 1
|
||||
elseif input:wasPressed("b") or (n < 1 and input:wasPressed("a")) then
|
||||
-- engine/items/item_effects.asm:1985
|
||||
self.ready = false
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
elseif input:wasPressed("a") then
|
||||
local picked = self.index
|
||||
self.game.stack:pop()
|
||||
if self.onChoose then self.onChoose(picked) end
|
||||
self.ready = false
|
||||
self.held = true
|
||||
if self.onChoose then self.onChoose(self.index, self) end
|
||||
if self.held and self.game.stack:top() == self then self:release() end
|
||||
end
|
||||
end
|
||||
|
||||
function MoveSelectMenu:draw()
|
||||
if not self.boxShown then return end
|
||||
if self.ready then
|
||||
-- engine/items/item_effects.asm:1979
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local first, rest = self.prompt:match("^(.-)\n(.*)$")
|
||||
Font.draw(first or self.prompt, 8, 14 * 8)
|
||||
if rest then Font.draw(rest, 8, 16 * 8) end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
-- engine/battle/core.asm:2526
|
||||
Font.drawBox(4, 7, 16, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
@@ -50,11 +95,6 @@ function MoveSelectMenu:draw()
|
||||
Font.draw(mv and (mdef and mdef.name or mv.id) or "-", 48, (7 + i) * 8)
|
||||
end
|
||||
Font.drawCode(CURSOR, 40, (7 + self.index) * 8)
|
||||
-- engine/items/item_effects.asm:1979
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
local first, rest = self.prompt:match("^(.-)\n(.*)$")
|
||||
Font.draw(first or self.prompt, 8, 14 * 8)
|
||||
if rest then Font.draw(rest, 8, 16 * 8) end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
+11
-1
@@ -402,6 +402,10 @@ end
|
||||
-- that already popped themselves, and stops a double close eating the bag
|
||||
-- underneath. #252
|
||||
function PartyMenu:close()
|
||||
local top = self.game.stack:top()
|
||||
if top ~= self and top and top.owner == self and top.held then
|
||||
self.game.stack:pop()
|
||||
end
|
||||
if self.game.stack:top() == self then self.game.stack:pop() end
|
||||
end
|
||||
|
||||
@@ -465,6 +469,7 @@ function PartyMenu:update(dt)
|
||||
end
|
||||
-- home/window.asm:119
|
||||
self.cursorsErased = nil
|
||||
self.chosenHollow = nil
|
||||
local input = self.game.input
|
||||
local party = self.party or self.game.save.party
|
||||
|
||||
@@ -502,6 +507,7 @@ function PartyMenu:update(dt)
|
||||
elseif action == "battle_switch" then
|
||||
-- engine/battle/core.asm:2396
|
||||
if self.keepOpen then
|
||||
self.chosenHollow = true
|
||||
self.onSwitch(mon, self)
|
||||
else
|
||||
self.game.stack:pop()
|
||||
@@ -752,6 +758,8 @@ function PartyMenu:update(dt)
|
||||
-- else keeps the old pop-then-call order. Popping first is what made
|
||||
-- a POTION snap the picker shut before the item had even run (#252).
|
||||
if not self.keepOpen then self.game.stack:pop() end
|
||||
-- home/pokemon.asm:246
|
||||
if self.keepOpen then self.chosenHollow = true end
|
||||
self.onSwitch(mon, self)
|
||||
else
|
||||
self.submenu = true
|
||||
@@ -973,7 +981,9 @@ function PartyMenu:draw()
|
||||
-- entryY returns. Drawing it at y put it a tile too high (#278).
|
||||
local cursorY = y + 8
|
||||
if i == self.index and not self.cursorsErased then
|
||||
Font.drawCode(Theme.cursor, 0, cursorY)
|
||||
Font.drawCode((self.chosenHollow or self.submenu) and Theme.cursorHollow
|
||||
or Theme.cursor,
|
||||
0, cursorY)
|
||||
end
|
||||
-- the unfilled swap arrow; the filled cursor replaces it in the tilemap
|
||||
-- when they share a row (PlaceMenuCursor, home/window.asm:184-185) (#814)
|
||||
|
||||
@@ -119,7 +119,12 @@ function ShaderFXScreen.new(game, slot)
|
||||
-- the screen stays open either way, since a convert is not a selection.
|
||||
if item.entry and not item.entry.converted then
|
||||
if not canConvert then
|
||||
self.footer = Strings("Reinstall the app")
|
||||
local bridgeErr = ShaderFX.bridgeError and ShaderFX.bridgeError()
|
||||
if type(bridgeErr) == "string" and bridgeErr:find("cannot locate symbol", 1, true) then
|
||||
self.footer = Strings("Update the app")
|
||||
else
|
||||
self.footer = Strings("Reinstall the app")
|
||||
end
|
||||
return
|
||||
end
|
||||
ShaderFX.clearBridgeQuarantine()
|
||||
|
||||
+80
-53
@@ -8,7 +8,6 @@
|
||||
-- price confirm. Key items and HMs can't be sold (.unsellableItem).
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local Font = require("src.render.Font")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
@@ -30,8 +29,9 @@ local function anythingElse(game)
|
||||
end
|
||||
|
||||
-- .returnToMainPokemartMenu -- pokered engine/events/pokemart.asm:199-206
|
||||
local function refuse(game, menu, list, text)
|
||||
local function refuse(game, menu, list, text, under)
|
||||
game.stack:push(TextBox.new(game, text, function()
|
||||
if under and game.stack:top() == under then game.stack:pop() end
|
||||
if list then list:close() end
|
||||
menu.footer = anythingElse(game)
|
||||
end))
|
||||
@@ -70,45 +70,60 @@ local function buy(game, stock, menu)
|
||||
menu.footer = anythingElse(game)
|
||||
return
|
||||
end
|
||||
-- home/list_menu.asm:89-91
|
||||
list.hollowIndex = list.index
|
||||
local def = game.data.items[item.value]
|
||||
local qtyBox
|
||||
local function dropQty()
|
||||
if game.stack:top() == qtyBox then game.stack:pop() end
|
||||
end
|
||||
-- engine/events/pokemart.asm:152-156
|
||||
game.stack:push(QuantityBox.new(game, {
|
||||
qtyBox = QuantityBox.new(game, {
|
||||
max = 99,
|
||||
unitPrice = def.price,
|
||||
keepOpen = true,
|
||||
onDone = function(qty)
|
||||
if not qty then
|
||||
dropQty()
|
||||
list.footer = greet
|
||||
return
|
||||
end
|
||||
local cost = qty * def.price
|
||||
-- _PokemartTellBuyPriceText + yes/no confirm
|
||||
list.footer = romText(game.data, "_PokemartTellBuyPriceText",
|
||||
"%s?\nThat will be\n¥%d. OK?", def.name, cost)
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then
|
||||
list.footer = greet
|
||||
return
|
||||
end
|
||||
if game.save.money < cost then
|
||||
refuse(game, menu, list, notEnough)
|
||||
return
|
||||
end
|
||||
if not Bag.add(game.save, item.value, qty, game.data) then
|
||||
refuse(game, menu, list, bagFull)
|
||||
return
|
||||
end
|
||||
game.save.money = game.save.money - cost
|
||||
-- SFX_PURCHASE drains before the receipt -- pokemart.asm:193
|
||||
game.stack:push(TextBox.new(game,
|
||||
txt(game, "_PokemartBoughtItemText",
|
||||
Strings("Here you are!\nThank you!")),
|
||||
function() list.footer = greet end,
|
||||
{ preSound = function()
|
||||
return require("src.core.Sound").play(game.data, "Purchase")
|
||||
end }))
|
||||
end))
|
||||
-- engine/events/pokemart.asm:159-169
|
||||
game.stack:push(TextBox.new(game,
|
||||
romText(game.data, "_PokemartTellBuyPriceText",
|
||||
"%s?\nThat will be\n¥%d. OK?", def.name, cost),
|
||||
nil,
|
||||
{ choice = function(yes)
|
||||
if not yes then
|
||||
dropQty()
|
||||
list.footer = greet
|
||||
return
|
||||
end
|
||||
if game.save.money < cost then
|
||||
refuse(game, menu, list, notEnough, qtyBox)
|
||||
return
|
||||
end
|
||||
if not Bag.add(game.save, item.value, qty, game.data) then
|
||||
refuse(game, menu, list, bagFull, qtyBox)
|
||||
return
|
||||
end
|
||||
game.save.money = game.save.money - cost
|
||||
-- SFX_PURCHASE drains before the receipt -- pokemart.asm:193
|
||||
game.stack:push(TextBox.new(game,
|
||||
txt(game, "_PokemartBoughtItemText",
|
||||
Strings("Here you are!\nThank you!")),
|
||||
function()
|
||||
dropQty()
|
||||
list.footer = greet
|
||||
end,
|
||||
{ preSound = function()
|
||||
return require("src.core.Sound").play(game.data, "Purchase")
|
||||
end }))
|
||||
end }))
|
||||
end,
|
||||
}))
|
||||
})
|
||||
game.stack:push(qtyBox)
|
||||
end,
|
||||
})
|
||||
game.stack:push(list)
|
||||
@@ -174,6 +189,8 @@ local function sell(game, menu)
|
||||
menu.footer = anythingElse(game)
|
||||
return
|
||||
end
|
||||
-- home/list_menu.asm:89-91
|
||||
list.hollowIndex = list.index
|
||||
local def = game.data.items[item.value]
|
||||
-- only key items and HMs are unsellable (pokemart.asm IsKeyItem /
|
||||
-- IsItemHM); zero-price items like ETHER sell for ¥0. An unknown id
|
||||
@@ -185,37 +202,47 @@ local function sell(game, menu)
|
||||
return
|
||||
end
|
||||
local unit = math.floor(def.price / 2)
|
||||
game.stack:push(QuantityBox.new(game, {
|
||||
local qtyBox
|
||||
local function dropQty()
|
||||
if game.stack:top() == qtyBox then game.stack:pop() end
|
||||
end
|
||||
qtyBox = QuantityBox.new(game, {
|
||||
max = game.save.inventory[item.value] or 1,
|
||||
unitPrice = unit,
|
||||
keepOpen = true,
|
||||
onDone = function(qty)
|
||||
if not qty then
|
||||
dropQty()
|
||||
list.footer = greet
|
||||
return
|
||||
end
|
||||
-- _PokemartTellSellPriceText + yes/no confirm
|
||||
list.footer = romText(game.data, "_PokemartTellSellPriceText",
|
||||
"I can pay you\n¥%d for that.", unit * qty)
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then
|
||||
list.footer = greet
|
||||
return
|
||||
end
|
||||
game.save.money = game.save.money + unit * qty
|
||||
-- home/inventory.asm:15 AddAmountSoldToMoney sounds SFX_PURCHASE
|
||||
require("src.core.Sound").play(game.data, "Purchase")
|
||||
Bag.remove(game.save, item.value, qty)
|
||||
local left = game.save.inventory[item.value]
|
||||
if left then
|
||||
item.count = left
|
||||
else
|
||||
list:removeCurrent()
|
||||
end
|
||||
-- a sale prints nothing -- engine/events/pokemart.asm:112
|
||||
list.footer = greet
|
||||
end))
|
||||
-- engine/events/pokemart.asm:84-91
|
||||
game.stack:push(TextBox.new(game,
|
||||
romText(game.data, "_PokemartTellSellPriceText",
|
||||
"I can pay you\n¥%d for that.", unit * qty),
|
||||
nil,
|
||||
{ choice = function(yes)
|
||||
dropQty()
|
||||
if not yes then
|
||||
list.footer = greet
|
||||
return
|
||||
end
|
||||
game.save.money = game.save.money + unit * qty
|
||||
-- home/inventory.asm:15
|
||||
require("src.core.Sound").play(game.data, "Purchase")
|
||||
Bag.remove(game.save, item.value, qty)
|
||||
local left = game.save.inventory[item.value]
|
||||
if left then
|
||||
item.count = left
|
||||
else
|
||||
list:removeCurrent()
|
||||
end
|
||||
-- a sale prints nothing -- engine/events/pokemart.asm:112
|
||||
list.footer = greet
|
||||
end }))
|
||||
end,
|
||||
}))
|
||||
})
|
||||
game.stack:push(qtyBox)
|
||||
end,
|
||||
})
|
||||
game.stack:push(list)
|
||||
|
||||
@@ -9,6 +9,8 @@ local GameVersion = require("src.core.GameVersion")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Logger = require("src.core.Logger")
|
||||
local FaithfulRes = require("src.core.FaithfulRes")
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
|
||||
local TitleState = {}
|
||||
TitleState.__index = TitleState
|
||||
@@ -18,7 +20,11 @@ TitleState.isOpaque = true
|
||||
-- at the fixed integer scale. The title screen is a full-bleed picture with no
|
||||
-- world behind it, so a small centred box in a large window is just wasted
|
||||
-- glass -- and unlike the overworld it has no zoom the player chose to respect.
|
||||
function TitleState:wantsFillScale() return true end
|
||||
function TitleState:wantsFillScale()
|
||||
if FaithfulRes.locked then return false end
|
||||
if TouchSkin.drawable() then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
-- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2,
|
||||
-- the version-ribbon band LOGO1, the rest MEWMON.
|
||||
|
||||
@@ -53,13 +53,21 @@ local function se(id)
|
||||
pcall(function() require("src.core.game3.audio").playSe(id) end)
|
||||
end
|
||||
|
||||
local function someone_or_bill_name(session)
|
||||
local flags = session and (session.flags or session.eventFlags) or {}
|
||||
-- FLAG_SYS_NOT_SOMEONES_PC = 0x828 (2088)
|
||||
local isBill = flags[0x828] or flags["FLAG_SYS_NOT_SOMEONES_PC"] or false
|
||||
return isBill and Strings("BILL's PC") or Strings("SOMEONE's PC")
|
||||
local FLAG_SYS_NOT_SOMEONES_PC = 0x834 -- pokefirered/include/constants/flags.h:1386
|
||||
|
||||
local function script_store(session)
|
||||
local Space = package.loaded["src.core.game3.scripting.space"]
|
||||
return (Space and Space.store) or (session and session.store) or session
|
||||
end
|
||||
|
||||
-- pokefirered/src/script_menu.c:1027
|
||||
local function someone_or_bill_name(session)
|
||||
local Flags = require("src.core.game3.scripting.flags")
|
||||
local isBill = Flags.getFlag(script_store(session), nil, FLAG_SYS_NOT_SOMEONES_PC)
|
||||
return isBill and Strings("BILL'S PC") or Strings("SOMEONE'S PC")
|
||||
end
|
||||
PcMenu.storageLabel = someone_or_bill_name
|
||||
|
||||
local function player_pc_name(session)
|
||||
local name = (session and (session.name or session.playerName)) or "RED"
|
||||
return Strings("%s's PC", name)
|
||||
|
||||
+40
-58
@@ -10,6 +10,8 @@ local Chrome = require("src.ui.game3.chrome")
|
||||
local FrlgFont = require("src.ui.game3.frlg_font")
|
||||
local MapSectionsExtract = require("src.import.gba.map_sections_extract")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Flags = require("src.core.game3.scripting.flags")
|
||||
local Dex = require("src.core.game3.dex")
|
||||
|
||||
local SaveMenu = {}
|
||||
|
||||
@@ -25,50 +27,38 @@ local function se(id)
|
||||
pcall(function() require("src.core.game3.audio").playSe(id) end)
|
||||
end
|
||||
|
||||
local function count_badges(session)
|
||||
if not session then return 0 end
|
||||
local count = 0
|
||||
if session.badges then
|
||||
if type(session.badges) == "table" then
|
||||
for i = 1, 8 do
|
||||
if session.badges[i] == true or (tonumber(session.badges[i]) or 0) > 0 then
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
elseif type(session.badges) == "number" then
|
||||
for i = 1, 8 do
|
||||
local mask = bit and bit.lshift(1, i - 1) or math.pow(2, i - 1)
|
||||
if bit and bit.band(session.badges, mask) ~= 0 then
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
for i = 1, 8 do
|
||||
if session["badge" .. i] == true then count = count + 1 end
|
||||
end
|
||||
-- review-v3 V1: game3 sessions carry the badge flags (0x820-0x827), not
|
||||
-- badgeN keys; boot's ContinueInfo reads the same flags via Flags.countBadges.
|
||||
if count == 0 and type(session.flags) == "table" then
|
||||
for id = 0x820, 0x827 do
|
||||
if session.flags[id] or session.flags[tostring(id)] then count = count + 1 end
|
||||
end
|
||||
end
|
||||
function SaveMenu.flagStore(session)
|
||||
local Space = package.loaded["src.core.game3.scripting.space"]
|
||||
local live = Space and Space.getStore and Space.getStore()
|
||||
if type(live) == "table" and type(live.flags) == "table" then return live end
|
||||
if type(session) ~= "table" then return { flags = {} } end
|
||||
if type(session.store) == "table" and type(session.store.flags) == "table" then
|
||||
return session.store
|
||||
end
|
||||
return count
|
||||
return { flags = type(session.flags) == "table" and session.flags or {} }
|
||||
end
|
||||
|
||||
local function count_caught(dex)
|
||||
if not dex then return 0 end
|
||||
local n = 0
|
||||
-- review-v3 V6: saves written before the caught key existed (owned-only
|
||||
-- writers) fall back to owned; fresh writers keep the two in step.
|
||||
local t = dex.caught
|
||||
if t == nil then t = dex.owned or {} end
|
||||
for sp, on in pairs(t) do
|
||||
if on then n = n + 1 end
|
||||
-- pokefirered/src/save_menu_util.c:44
|
||||
function SaveMenu.countBadges(session)
|
||||
return Flags.countBadges(SaveMenu.flagStore(session))
|
||||
end
|
||||
|
||||
-- pokefirered/src/save_menu_util.c:25
|
||||
function SaveMenu.countDex(session)
|
||||
local dex = type(session) == "table" and session.dex or nil
|
||||
if type(dex) ~= "table" then return tonumber(session and session.caughtMonsCount) or 0 end
|
||||
local national = false
|
||||
local okP, PokedexData = pcall(require, "src.core.game3.pokedex_data")
|
||||
if okP and PokedexData and PokedexData.isNationalUnlocked then
|
||||
local ok, on = pcall(PokedexData.isNationalUnlocked, session, dex)
|
||||
national = ok and on == true
|
||||
end
|
||||
return n
|
||||
return Dex.countCaught(dex, national and "national" or "kanto")
|
||||
end
|
||||
|
||||
-- pokefirered/src/start_menu.c:984
|
||||
function SaveMenu.hasDex(session)
|
||||
return Flags.getFlag(SaveMenu.flagStore(session), nil, Flags.IDS.SYS_POKEDEX_GET or 0x829) == true
|
||||
end
|
||||
|
||||
function SaveMenu.show(opts)
|
||||
@@ -190,14 +180,6 @@ end
|
||||
-- GetMapNameGeneric(dest, gMapHeader.regionMapSectionId) -> region_map.c
|
||||
-- GetMapName(dst, mapsec, 0), i.e. the sMapNames place name and never the
|
||||
-- engine's internal map id (which is what session.map holds).
|
||||
--
|
||||
-- CONTRACT (intentional; pinned by tests/engine/save_menu_location_bug2328):
|
||||
-- session.mapName / session.regionMapSectionId / session.mapSec are a
|
||||
-- session/mod injection seam — when a caller supplies them they win over the
|
||||
-- def-derived path. The engine itself never writes these three fields (V8:
|
||||
-- zero assignments in src, absent from every save_schema build), so the branch
|
||||
-- is dead for engine-authored sessions and live for injected ones. Do not
|
||||
-- remove: the bug2328 regression pin exists precisely to keep this override.
|
||||
function SaveMenu.locationName(session)
|
||||
session = session or {}
|
||||
if type(session.mapName) == "string" and session.mapName ~= "" and not session.mapName:find("^FR_") and not session.mapName:find("^SEVII_") then
|
||||
@@ -243,12 +225,8 @@ function SaveMenu.draw()
|
||||
local map = Strings(SaveMenu.locationName(session))
|
||||
local labels = { Strings("PLAYER"), Strings("BADGES"), Strings("POKéDEX"), Strings("TIME") }
|
||||
local valueX = 1 * 8 + SaveMenu.valueX(labels)
|
||||
local badges = count_badges(session)
|
||||
local caught = count_caught(session.dex) or 0 -- review-v3 V5: no phantom caughtMonsCount fallback
|
||||
-- review-v3 V7: the nested playtime is what the schema persists
|
||||
-- (save_schema_firered.lua playTime ← session.playtime); the flat mirrors
|
||||
-- are only written by the runtime ticker, so a freshly loaded save showed
|
||||
-- 0:00 before.
|
||||
local badges = SaveMenu.countBadges(session)
|
||||
local hasDex = SaveMenu.hasDex(session)
|
||||
local pt = session.playtime or session.playTime or {}
|
||||
local hours = tonumber(pt.hours or session.playTimeHours or session.hours) or 0
|
||||
local mins = tonumber(pt.minutes or session.playTimeMinutes or session.minutes) or 0
|
||||
@@ -269,11 +247,15 @@ function SaveMenu.draw()
|
||||
FrlgFont.draw(labels[2], 1 * 8 + 4, 1 * 8 + 32, { colors = FrlgFont.COLOR.NORMAL })
|
||||
FrlgFont.draw(tostring(badges), valueX, 1 * 8 + 32, { colors = FrlgFont.COLOR.NORMAL })
|
||||
-- POKéDEX
|
||||
FrlgFont.draw(labels[3], 1 * 8 + 4, 1 * 8 + 46, { colors = FrlgFont.COLOR.NORMAL })
|
||||
FrlgFont.draw(tostring(caught), valueX, 1 * 8 + 46, { colors = FrlgFont.COLOR.NORMAL })
|
||||
local timeY = 1 * 8 + 46
|
||||
if hasDex then
|
||||
FrlgFont.draw(labels[3], 1 * 8 + 4, 1 * 8 + 46, { colors = FrlgFont.COLOR.NORMAL })
|
||||
FrlgFont.draw(tostring(SaveMenu.countDex(session)), valueX, 1 * 8 + 46, { colors = FrlgFont.COLOR.NORMAL })
|
||||
timeY = 1 * 8 + 60
|
||||
end
|
||||
-- TIME
|
||||
FrlgFont.draw(labels[4], 1 * 8 + 4, 1 * 8 + 60, { colors = FrlgFont.COLOR.NORMAL })
|
||||
FrlgFont.draw(string.format("%d:%02d", hours, mins), valueX, 1 * 8 + 60, { colors = FrlgFont.COLOR.NORMAL })
|
||||
FrlgFont.draw(labels[4], 1 * 8 + 4, timeY, { colors = FrlgFont.COLOR.NORMAL })
|
||||
FrlgFont.draw(string.format("%d:%02d", hours, mins), valueX, timeY, { colors = FrlgFont.COLOR.NORMAL })
|
||||
|
||||
-- 2. Bottom Dialogue Window (pret WindowFunc_DrawDialogueFrame at (2, 15, 26, 4))
|
||||
Chrome.dialogueFrame()
|
||||
|
||||
@@ -218,6 +218,8 @@ end
|
||||
function HeldItemMenu:giveItem(itemId)
|
||||
local mon = self:mon()
|
||||
if not (mon and itemId) then return self:close() end
|
||||
local inv = self.save and self.save.inventory
|
||||
if not (inv and (inv[itemId] or 0) > 0) then return self:close() end
|
||||
if not self:canHold(itemId) then
|
||||
return self:say(self.TEXT.cantHold, function() self:openPack() end)
|
||||
end
|
||||
|
||||
@@ -726,7 +726,9 @@ function PackMenu:giveToSlot(slot, row)
|
||||
save = self.save,
|
||||
slot = slot,
|
||||
items = self.items,
|
||||
-- engine/items/pack.asm:606
|
||||
onClose = function()
|
||||
game.stack:pop()
|
||||
game.stack:pop()
|
||||
self:rebuild()
|
||||
end,
|
||||
|
||||
@@ -93,6 +93,7 @@ local TELEPORT_IN_FRAMES = 43
|
||||
-- engine/overworld/player_animations.asm:22-25
|
||||
local TELEPORT_IN_PAD_HOLDS = { 3, 3, 3, 3, 3, 3, 0 }
|
||||
local TELEPORT_IN_PAD_FRAMES = 18
|
||||
local BOLT_WHITE_VEIL = { 1, 0.8 }
|
||||
local SPIN_DOWN_STEPS = 6
|
||||
-- engine/overworld/player_animations.asm:41-45
|
||||
local HOLE_IN_HOLDS = { 3, 3, 3, 3, 3, 0 }
|
||||
@@ -582,6 +583,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
-- crossConnection re-arms this after setMap; clear so a warp/reload
|
||||
-- cannot leave a stale deferred PlayMapMusic pending
|
||||
self.pendingSeamMusic = nil
|
||||
self.seamPalette = nil
|
||||
self.entities = { self.player }
|
||||
for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end
|
||||
-- Yellow's companion Pikachu trails the player (never in
|
||||
@@ -869,6 +871,14 @@ function OverworldState:paletteNameFor(map)
|
||||
return Runtime.call("map.palette", samePalette, name, map, { tod = tod })
|
||||
end
|
||||
|
||||
-- home/overworld.asm:675
|
||||
function OverworldState:screenPaletteName()
|
||||
if self.seamPalette and not OverworldState.perMapWorldPalettes() then
|
||||
return self.seamPalette
|
||||
end
|
||||
return self:paletteNameFor(self.map)
|
||||
end
|
||||
|
||||
-- UI-pass palette (text boxes and menus tint with the current map). OG RED
|
||||
-- resolves every name to the one global red BG palette inside PaletteFX.pal,
|
||||
-- so this needs no mode-specific branch.
|
||||
@@ -880,7 +890,7 @@ end
|
||||
-- that zone the pic wears the route/town palette and looks washed out.
|
||||
function OverworldState:sgbPalettes()
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local mapName = self:paletteNameFor(self.map)
|
||||
local mapName = self:screenPaletteName()
|
||||
if self.emote and self.emote.pikaPic then
|
||||
local base = PaletteFX.pal(Game.data, mapName)
|
||||
if not base then return nil end
|
||||
@@ -919,7 +929,7 @@ function OverworldState:sgbWorldZones()
|
||||
if PaletteFX.usesGbcPack() and self.map.renderer and self.map.renderer.gbcAtlas then
|
||||
return {}
|
||||
end
|
||||
local base = PaletteFX.pal(Game.data, self:paletteNameFor(self.map))
|
||||
local base = PaletteFX.pal(Game.data, self:screenPaletteName())
|
||||
if not base then return nil end
|
||||
local vw, vh = Game.renderer:worldViewSize()
|
||||
local cam = self.camera
|
||||
@@ -1468,6 +1478,9 @@ function OverworldState:update(dt)
|
||||
|
||||
-- the emotion-bubble pause holds the world for a beat
|
||||
if self.emote then
|
||||
if self.emote.boltAt then
|
||||
require("src.world.PikachuFollower").tickBolt(Game, self, self.emote)
|
||||
end
|
||||
self.emote.frames = self.emote.frames - 1
|
||||
-- PikaPicAnimTimerAndJoypad (engine/pikachu/pikachu_pic_animation.asm)
|
||||
-- cuts a pikapic beat short on A or B; the "!" bubble hold has no such
|
||||
@@ -1547,6 +1560,8 @@ function OverworldState:update(dt)
|
||||
if entry and (self.player.cellX ~= entry.x or self.player.cellY ~= entry.y) then
|
||||
self.warpEntryCell = nil
|
||||
end
|
||||
-- home/overworld.asm:679
|
||||
if stepped then self.seamPalette = nil end
|
||||
-- deferred PlayMapMusic from crossConnection (issue #93)
|
||||
if stepped and self.pendingSeamMusic then
|
||||
local mapId = self.pendingSeamMusic
|
||||
@@ -2043,9 +2058,11 @@ function OverworldState:crossConnection(dir, conn)
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
local pika = PikachuFollower.current(self)
|
||||
local fromX, fromY = p.cellX, p.cellY
|
||||
local seamPal = self:paletteNameFor(self.map)
|
||||
self:setMap(conn.map, x, y, p.facing,
|
||||
{ seamless = true, keepMusic = true, keepPikachu = pika })
|
||||
self.pendingSeamMusic = conn.map
|
||||
self.seamPalette = seamPal
|
||||
-- place the player one cell before the seam (their old world spot,
|
||||
-- which the neighbor strip renders identically) and start the step
|
||||
-- into the new map RIGHT NOW so there is no one-frame stall at the
|
||||
@@ -4975,7 +4992,6 @@ function OverworldState:boulderIntoHole(npc)
|
||||
for _, entry in ipairs(self:seafoamHolesFor(self.map.id)) do
|
||||
local h = entry.hole
|
||||
if npc.cellX == h.x and npc.cellY == h.y then
|
||||
require("src.core.Sound").play(Game.data, "Faint_Thud")
|
||||
Flags.set(Game.save, h.boulderEvent)
|
||||
local toggles = Game.save.objectToggles or {}
|
||||
Game.save.objectToggles = toggles
|
||||
@@ -4999,7 +5015,6 @@ function OverworldState:boulderIntoHole(npc)
|
||||
for i = #self.entities, 1, -1 do
|
||||
if self.entities[i] == npc then table.remove(self.entities, i) end
|
||||
end
|
||||
Game.stack:push(TextBox.new(Game, Strings("The boulder fell\nthrough the hole!")))
|
||||
return true
|
||||
end
|
||||
end
|
||||
@@ -5814,6 +5829,9 @@ function OverworldState:drawWorld()
|
||||
local fadeObp = fade.obp0 and fade:obp0() or fadeBgp
|
||||
PaletteFX.setFadeObp(Transition.shadeMapFor(fadeObp))
|
||||
fade.paletteStepped = true
|
||||
elseif self.emote and self.emote.bgp and not battleOverWorld then
|
||||
-- engine/pikachu/pikachu_pic_animation.asm:847
|
||||
PaletteFX.setShadeMap(Transition.shadeMapFor(self.emote.bgp))
|
||||
else
|
||||
PaletteFX.setShadeMap((self.dark and not battleOverWorld)
|
||||
and PaletteFX.DARK_BGP or self:poisonShadeMap())
|
||||
@@ -6428,6 +6446,13 @@ function OverworldState:drawUI()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
-- engine/pikachu/pikachu_pic_animation.asm:847
|
||||
local bolt = self.emote and self.emote.bgp
|
||||
if bolt and bolt ~= 0xE4 and Game and Game.renderer
|
||||
and (self:bakedWorldColors() or not PaletteFX.shader()) then
|
||||
Game.renderer.screenVeil = BOLT_WHITE_VEIL
|
||||
end
|
||||
end
|
||||
|
||||
function OverworldState:captureSave(save)
|
||||
|
||||
@@ -123,6 +123,34 @@ function PikachuFollower.onStep(save)
|
||||
elseif mood > 128 then
|
||||
save.pikachuMood = mood - 1
|
||||
end
|
||||
-- engine/events/poison.asm:153
|
||||
if (save.pikachuMood or 128) == 128 then
|
||||
save.pikachuEmotionModifier = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- engine/pikachu/pikachu_status.asm:117
|
||||
function PikachuFollower.moodAfterBattle(save)
|
||||
if not GameVersion.isYellow() then return end
|
||||
local starter
|
||||
for _, mon in ipairs(save.party or {}) do
|
||||
if PikachuFollower.isStarterPikachu(save, mon) then starter = mon break end
|
||||
end
|
||||
-- engine/pikachu/pikachu_status.asm:1
|
||||
if not (starter and (starter.hp or 0) > 0) then return end
|
||||
if (save.pikachuMood or 128) < 0x82 then
|
||||
save.pikachuMood = 0x82
|
||||
end
|
||||
end
|
||||
|
||||
-- engine/items/item_effects.asm:2509
|
||||
-- engine/pokemon/evos_moves.asm:375
|
||||
function PikachuFollower.onMoveLearned(save, mon, moveId)
|
||||
if not GameVersion.isYellow() then return end
|
||||
if moveId ~= "THUNDERBOLT" and moveId ~= "THUNDER" then return end
|
||||
if not PikachuFollower.isStarterPikachu(save, mon) then return end
|
||||
save.pikachuEmotionModifier = 5
|
||||
save.pikachuMood = 0x85
|
||||
end
|
||||
|
||||
-- ShouldPikachuSpawn, approximated: Yellow, the lab gift happened, and a
|
||||
@@ -775,6 +803,14 @@ local PIKAPIC_LIFT = 4 -- px the stand-in pic rises on an overlay run
|
||||
-- pikachu_emotions.asm).
|
||||
local PIKAPIC_SCRIPT = { [29] = 10, [30] = 20, [31] = 23, [32] = 23 }
|
||||
|
||||
-- data/pikachu/pikachu_pic_animation.asm:1
|
||||
local THUNDERBOLT_PALS = {}
|
||||
for i = 1, 20 do THUNDERBOLT_PALS[i] = (i % 2 == 1) and 0xC0 or 0xE4 end
|
||||
local THUNDERBOLT_ROW = 4
|
||||
local THUNDERBOLT_SOUND_CAP = 600
|
||||
PikachuFollower.THUNDERBOLT_PALS = THUNDERBOLT_PALS
|
||||
PikachuFollower.THUNDERBOLT_ROW = THUNDERBOLT_ROW
|
||||
|
||||
-- Per script: pikapic_setduration's tick count, and for the scripts whose
|
||||
-- overlay is a whole second pose, that frameset's run lengths in ticks
|
||||
-- (data/pikachu/pikachu_pic_objects.asm PikaPicAnimBGFrames_*, which script
|
||||
@@ -811,7 +847,7 @@ local PIKAPIC = {
|
||||
[22] = { dur = 40, seq = { 8, 100 } },
|
||||
[23] = { dur = 70, seq = { 16, 16, 16, 16 } },
|
||||
[24] = { dur = 60, seq = { 6, 6, 6, 6, 100 } },
|
||||
[25] = { dur = 50, seq = { 6, 106 } },
|
||||
[25] = { dur = 50, seq = { 6, 106 }, bolt = 13 },
|
||||
[26] = { dur = 100, seq = { 20, 8, 20, 116 } },
|
||||
[27] = { dur = 30, seq = { 4, 100 } },
|
||||
[28] = { dur = 64, seq = { 12, 12, 12, 100 } },
|
||||
@@ -830,6 +866,7 @@ local function moodEmotion(save)
|
||||
end
|
||||
return row[column]
|
||||
end
|
||||
PikachuFollower.moodEmotion = moodEmotion
|
||||
|
||||
-- MapSpecificPikachuExpression + TalkToPikachu's selection order
|
||||
local function selectEmotion(game, ow, save)
|
||||
@@ -858,7 +895,6 @@ local function selectEmotion(game, ow, save)
|
||||
if mapId:find("POKEMON_TOWER_", 1, true) == 1 then return 22 end
|
||||
local modifier = save.pikachuEmotionModifier
|
||||
if modifier and MODIFIER_EMOTIONS[modifier] then
|
||||
save.pikachuEmotionModifier = nil
|
||||
return MODIFIER_EMOTIONS[modifier]
|
||||
end
|
||||
return moodEmotion(save)
|
||||
@@ -924,6 +960,11 @@ function playEmotion(game, ow, npc, emotion, opts)
|
||||
pikaSeq = anim.seq, pikaTotal = hold, skippable = opts.skippable,
|
||||
onDone = done,
|
||||
}
|
||||
if anim.bolt then
|
||||
-- engine/pikachu/pikachu_pic_animation.asm:520
|
||||
ow.emote.boltAt = (anim.bolt + 2) * PIKAPIC_TICK
|
||||
ow.emote.boltT = 0
|
||||
end
|
||||
return ow.emote
|
||||
end
|
||||
|
||||
@@ -984,6 +1025,39 @@ function PikachuFollower.picLift(emote)
|
||||
return 0
|
||||
end
|
||||
|
||||
-- engine/pikachu/pikachu_pic_animation.asm:790
|
||||
function PikachuFollower.tickBolt(game, ow, emote)
|
||||
if not (emote and emote.boltAt) or emote.boltDone then return end
|
||||
emote.boltT = (emote.boltT or 0) + 1
|
||||
local s = emote.boltT - emote.boltAt
|
||||
if s < 1 then return end
|
||||
emote.frames = emote.frames + 1
|
||||
if s == 1 then
|
||||
emote.skippable = false
|
||||
emote.boltMute = {
|
||||
isPlaying = function() return ow.emote == emote and not emote.boltDone end,
|
||||
}
|
||||
require("src.core.Music").duckForFanfare(emote.boltMute)
|
||||
return
|
||||
end
|
||||
local k = s - 2
|
||||
local Sound = require("src.core.Sound")
|
||||
if k == 0 then
|
||||
local moves = game.data and game.data.moves
|
||||
local def = moves and moves.THUNDERBOLT
|
||||
if def and def.anim then Sound.playMove(game.data, def.anim) end
|
||||
end
|
||||
local strobe = #THUNDERBOLT_PALS * THUNDERBOLT_ROW
|
||||
if k < strobe then
|
||||
emote.bgp = THUNDERBOLT_PALS[math.floor(k / THUNDERBOLT_ROW) + 1]
|
||||
return
|
||||
end
|
||||
-- engine/pikachu/pikachu_pic_animation.asm:802
|
||||
if Sound.moveSfxBusy() and k < strobe + THUNDERBOLT_SOUND_CAP then return end
|
||||
emote.boltDone = true
|
||||
emote.frames = PIKAPIC_TICK + 1
|
||||
end
|
||||
|
||||
-- Bill's House has three map-scripted Yellow companion beats
|
||||
-- (BillsHouseScript0/2/5): Pikachu walks over to investigate Bill, waits at
|
||||
-- the cell separator, then reacts when Bill reappears. Keep it at the
|
||||
|
||||
@@ -4773,7 +4773,9 @@ function World:rollFishing(rod)
|
||||
-- they stand on.
|
||||
local d = Map.DELTA[player.facing or "down"] or Map.DELTA.down
|
||||
local cx, cy = player.cellX + d[1], player.cellY + d[2]
|
||||
if not Permissions.isWater(map:cellCollision(cx, cy)) then return "nowhere" end
|
||||
if not Permissions.isWater(self:cellCollisionAcross(map, cx, cy)) then
|
||||
return "nowhere"
|
||||
end
|
||||
-- GetFishingGroup (home/map.asm) is MAP_FISHGROUP off the map header, and
|
||||
-- .facingwater's `and a / jr nz` sends FISHGROUP_NONE to .FishNoFish. The
|
||||
-- Encounter helper defaults an unknown map to the pond, so the header is
|
||||
@@ -5465,7 +5467,7 @@ end
|
||||
-- Returns true when the event took the A press.
|
||||
function World:tryHeadbuttOW(cx, cy)
|
||||
if not (self.map and self.player) then return false end
|
||||
if not World.isHeadbuttTree(self.map:cellCollision(cx, cy)) then
|
||||
if not World.isHeadbuttTree(self:cellCollisionAcross(self.map, cx, cy)) then
|
||||
return false
|
||||
end
|
||||
local mon = self:partyMoveUser(MOVE_HEADBUTT)
|
||||
@@ -5771,12 +5773,12 @@ function World:fieldContext(mon)
|
||||
mon = mon,
|
||||
facing = facing,
|
||||
facingX = fx, facingY = fy,
|
||||
facingColl = map:cellCollision(fx, fy),
|
||||
facingColl = self:cellCollisionAcross(map, fx, fy),
|
||||
playerColl = map:cellCollision(p.cellX, p.cellY),
|
||||
-- Crystal's SurfFunction.TrySurf is the only field move that asks
|
||||
-- (../pokecrystal/engine/events/overworld.asm:364).
|
||||
facingObject = self:facingObject(),
|
||||
upColl = map:cellCollision(p.cellX, p.cellY - 1),
|
||||
upColl = self:cellCollisionAcross(map, p.cellX, p.cellY - 1),
|
||||
tileset = map.def and map.def.tileset,
|
||||
facingBlock = blockId,
|
||||
facingBlockIndex = blockIndex,
|
||||
@@ -8557,7 +8559,8 @@ function World:facingObjectCell()
|
||||
if not p then return nil end
|
||||
local d = Map.DELTA[p.facing] or Map.DELTA.down
|
||||
local fx, fy = p.cellX + d[1], p.cellY + d[2]
|
||||
if self.map and Permissions.isCounter(self.map:cellCollision(fx, fy)) then
|
||||
if self.map
|
||||
and Permissions.isCounter(self:cellCollisionAcross(self.map, fx, fy)) then
|
||||
return p.cellX + d[1] * 2, p.cellY + d[2] * 2
|
||||
end
|
||||
return fx, fy
|
||||
@@ -8676,7 +8679,7 @@ function World:interactBody()
|
||||
-- run through the VM like any map script -- PCScript is `opentext /
|
||||
-- special PokemonCenterPC / closetext / end`.
|
||||
local std = TILE_COLLISION_STD_SCRIPTS[
|
||||
self.map and self.map:cellCollision(fx, fy)]
|
||||
self.map and self:cellCollisionAcross(self.map, fx, fy)]
|
||||
if std then
|
||||
local entry = self.stdScripts and self.stdScripts.scripts
|
||||
and self.stdScripts.scripts[std]
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
-- engine/items/item_effects.asm:1731-1745
|
||||
-- audio/poke_flute.asm:1
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR")
|
||||
or "/tmp/shots"
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1; print("PASS " .. label)
|
||||
else fail = fail + 1; print("FAIL " .. label) end
|
||||
return ok
|
||||
end
|
||||
local function finish()
|
||||
U.log(("RESULT pass=%d fail=%d"):format(pass, fail))
|
||||
love.event.quit(fail == 0 and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local lead = Pokemon.new(game.data, "CHARIZARD", 50)
|
||||
game.save.party = { lead }
|
||||
Bag.add(game.save, "POKE_FLUTE", 1)
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(20)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 8)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
for _ = 1, 120 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
if not check("2305 wild battle reached its menu", battle.phase == "menu") then
|
||||
finish()
|
||||
end
|
||||
|
||||
lead.status = "SLP"
|
||||
local turns = 0
|
||||
local realItemUsed = battle.itemUsed
|
||||
battle.itemUsed = function(self, ...)
|
||||
turns = turns + 1
|
||||
return realItemUsed(self, ...)
|
||||
end
|
||||
|
||||
local bag
|
||||
for _ = 1, 20 do
|
||||
local top = game.stack:top()
|
||||
if top and top.screenId == "BagMenu" then bag = top break end
|
||||
U.tap(game, "down"); U.wait(4)
|
||||
U.tap(game, "left"); U.wait(4)
|
||||
U.tap(game, "a"); U.wait(14)
|
||||
end
|
||||
if not check("2305 ITEM opened the bag", bag ~= nil) then finish() end
|
||||
|
||||
local row
|
||||
for i, item in ipairs(bag.items or {}) do
|
||||
if item.value == "POKE_FLUTE" then row = i end
|
||||
end
|
||||
if not check("2305 POKE FLUTE in the battle bag", row ~= nil) then finish() end
|
||||
for _ = 1, 40 do
|
||||
if bag.index == row then break end
|
||||
U.tap(game, bag.index < row and "down" or "up")
|
||||
U.wait(3)
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
|
||||
local box
|
||||
for _ = 1, 240 do
|
||||
local top = game.stack:top()
|
||||
if top and top.isTextBox then box = top break end
|
||||
U.wait(1)
|
||||
end
|
||||
if not check("2305 played-flute box opened", box ~= nil) then finish() end
|
||||
|
||||
for _ = 1, 240 do
|
||||
if box.done then break end
|
||||
U.tap(game, "a")
|
||||
end
|
||||
check("2305 line typed out", box.done == true)
|
||||
check("2305 no tune before the prompt",
|
||||
box.autoStarted ~= true and box.autoSrc == nil)
|
||||
U.shot(game, DIR .. "/2305_01_played_flute_prompt.png")
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
check("2305 prompt answered", box.autoPrompted == true)
|
||||
local src = box.autoSrc
|
||||
if not check("2305 tune started", src ~= nil) then finish() end
|
||||
|
||||
local okd, dur = pcall(src.getDuration, src, "seconds")
|
||||
dur = okd and dur or -1
|
||||
U.log(("tune source runs %.3f s (in-battle 4.267, field 9.600)"):format(dur))
|
||||
check("2305 in-battle phrase length (4.27 s, not 9.6 s)",
|
||||
math.abs(dur - 256 / 60) < 0.1)
|
||||
check("2305 derived def played, not the field Pokeflute",
|
||||
require("src.core.Sound").isPlaying("Pokeflute_In_Battle")
|
||||
and not require("src.core.Sound").isPlaying("Pokeflute"))
|
||||
|
||||
local t0 = love.timer.getTime()
|
||||
local shot, brokeOut = false, false
|
||||
for _ = 1, 900 do
|
||||
local ok, playing = pcall(src.isPlaying, src)
|
||||
if not (ok and playing) then break end
|
||||
if game.stack:top() ~= box then brokeOut = true break end
|
||||
if not shot and love.timer.getTime() - t0 > 1.5 then
|
||||
U.shot(game, DIR .. "/2305_02_short_tune_playing.png")
|
||||
shot = true
|
||||
else
|
||||
U.tap(game, "a")
|
||||
end
|
||||
end
|
||||
local held = love.timer.getTime() - t0
|
||||
U.log(("the box stayed up %.2f s under the tune"):format(held))
|
||||
check("2305 A never cut the tune short", not brokeOut)
|
||||
check("2305 box held about 4.3 s", held > 3.6 and held < 5.2)
|
||||
check("2305 turn did not resolve over the tune", turns == 0)
|
||||
|
||||
U.wait(20)
|
||||
local woke = game.stack:top()
|
||||
check("2305 FluteWokeUpText follows the tune",
|
||||
woke ~= nil and woke ~= box and woke.isTextBox == true)
|
||||
for _ = 1, 60 do
|
||||
if woke.done then break end
|
||||
U.wait(2)
|
||||
end
|
||||
U.shot(game, DIR .. "/2305_03_all_woke_up.png")
|
||||
|
||||
for _ = 1, 200 do
|
||||
if turns > 0 then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
check("2305 dismissing it spends the turn", turns == 1)
|
||||
check("2305 lead is awake", lead.status == nil)
|
||||
finish()
|
||||
end
|
||||
@@ -17,8 +17,10 @@ return function(game)
|
||||
local MAP = "ROUTE_1"
|
||||
local STAND = { x = 5, y = 5, facing = "down" }
|
||||
|
||||
local fails = 0
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
if not ok then fails = fails + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
@@ -229,13 +231,10 @@ return function(game)
|
||||
U.log("pick FIGHT then BIDE and watch turn one: the screen palette flashes")
|
||||
U.log("white and balls spiral inward, silently. Then, still in silence, the")
|
||||
U.log("whole battle screen creeps sideways 1 px at a time out to 3 px and")
|
||||
U.log("back, twice, and only then does it say storing energy. On the foe's")
|
||||
U.log("back, twice, and no text follows before the foe moves. On the foe's")
|
||||
U.log("turn the same creep goes out to 6 px and takes twice as long.")
|
||||
U.log("No flash on the RATTATA and no BIDE thud until the turn it")
|
||||
U.log("unleashes, where the thud and its animation come after the text and")
|
||||
U.log("before the enemy HP bar slides down. The foe's spiral looks the same.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
-- engine/battle/effects.asm:764-789
|
||||
-- engine/battle/core.asm:3481-3506
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local fails = 0
|
||||
local function check(label, ok)
|
||||
if ok then
|
||||
print("PASS " .. label)
|
||||
else
|
||||
print("FAIL " .. label)
|
||||
fails = fails + 1
|
||||
end
|
||||
return ok
|
||||
end
|
||||
|
||||
local function flat(s) return (tostring(s or ""):gsub("[\n\v\f]", " ")) end
|
||||
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.animations = true
|
||||
|
||||
local lead = Pokemon.new(game.data, "CHARMANDER", 20)
|
||||
lead.moves = { { id = "BIDE", pp = 10 } }
|
||||
game.save.party = { lead }
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(15)
|
||||
local ow = game.overworld
|
||||
check("standing on ROUTE_1", ow and ow.map and ow.map.id == "ROUTE_1")
|
||||
|
||||
local function watcher(battle)
|
||||
local w = { shown = {}, anims = {}, queued = {} }
|
||||
local markQ, lastCur = {}, nil
|
||||
function w.poll()
|
||||
for _, row in ipairs(battle.queue) do
|
||||
if not markQ[row] then
|
||||
markQ[row] = true
|
||||
if row.text then w.queued[#w.queued + 1] = flat(row.text) end
|
||||
if row.anim then w.anims[#w.anims + 1] = row.anim end
|
||||
end
|
||||
end
|
||||
local cur = battle.current
|
||||
if cur ~= lastCur then
|
||||
lastCur = cur
|
||||
if cur and cur.text then
|
||||
w.shown[#w.shown + 1] = flat(cur.text)
|
||||
return flat(cur.text)
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
return w
|
||||
end
|
||||
|
||||
local function has(list, needle)
|
||||
for _, s in ipairs(list) do
|
||||
if s:find(needle, 1, true) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function waitPhase(battle, phase, tries, w)
|
||||
for _ = 1, tries do
|
||||
if battle.phase == phase then return true end
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 6 do w.poll() U.wait(1) end
|
||||
end
|
||||
return battle.phase == phase
|
||||
end
|
||||
|
||||
local function pickFirstMove(battle, w)
|
||||
U.tap(game, "a")
|
||||
if not waitPhase(battle, "moveSelect", 10, w) then return false end
|
||||
U.tap(game, "a")
|
||||
return true
|
||||
end
|
||||
|
||||
local function settledShot(watch, path)
|
||||
for _ = 1, 100 do watch.poll() U.wait(1) end
|
||||
return U.shot(game, path)
|
||||
end
|
||||
|
||||
local function leaveBattle()
|
||||
for _ = 1, 10 do
|
||||
if game.stack:top() == ow then break end
|
||||
game.stack:pop()
|
||||
end
|
||||
end
|
||||
|
||||
local battle = BattleState.newWild(game, "RATTATA", 8)
|
||||
battle.onFinish = function() end
|
||||
battle.enemy.mon.moves = { { id = "TACKLE", pp = 35 } }
|
||||
battle.enemy.curMoves = battle.enemy.mon.moves
|
||||
ow:pushBattle(battle)
|
||||
local w = watcher(battle)
|
||||
check("player battle reached the menu", waitPhase(battle, "menu", 80, w))
|
||||
local sinceShown, sinceQueued = #w.shown, #w.queued
|
||||
check("BIDE picked", pickFirstMove(battle, w))
|
||||
|
||||
local tackles, unleashed = 0, false
|
||||
local lockedSeen, sawSpiral = false, false
|
||||
local shots = {}
|
||||
for _ = 1, 3000 do
|
||||
local line = w.poll()
|
||||
if has(w.anims, "XSTATITEM_ANIM") then sawSpiral = true end
|
||||
if line then
|
||||
if line:find("TACKLE", 1, true) then
|
||||
tackles = tackles + 1
|
||||
if tackles == 1 and not shots.store then
|
||||
shots.store = settledShot(w, DIR .. "/2338_01_store_turn_spiral_then_foe_tackle.png")
|
||||
elseif tackles >= 2 and not shots.locked then
|
||||
lockedSeen = true
|
||||
shots.locked = settledShot(w, DIR .. "/2338_02_locked_turn_only_foe_text.png")
|
||||
end
|
||||
elseif line:find("unleashed energy", 1, true) then
|
||||
unleashed = true
|
||||
shots.release = settledShot(w, DIR .. "/2338_03_release_unleashed_energy.png")
|
||||
break
|
||||
end
|
||||
end
|
||||
if game.stack:top() ~= battle then
|
||||
if not game.stack:top() or battle.player.mon.hp <= 0 then break end
|
||||
end
|
||||
if U.frame() % 6 == 0 then U.tap(game, "a") else U.wait(1) end
|
||||
end
|
||||
|
||||
local pShown, pQueued = {}, {}
|
||||
for i = sinceShown + 1, #w.shown do pShown[#pShown + 1] = w.shown[i] end
|
||||
for i = sinceQueued + 1, #w.queued do pQueued[#pQueued + 1] = w.queued[i] end
|
||||
local stray = {}
|
||||
for _, s in ipairs(pShown) do
|
||||
if s:find("CHARMANDER", 1, true) and not s:find("used BIDE", 1, true)
|
||||
and not s:find("unleashed energy", 1, true) then
|
||||
stray[#stray + 1] = s
|
||||
end
|
||||
if s:find("unleashed energy", 1, true) then break end
|
||||
end
|
||||
U.log("player battle shown: " .. table.concat(pShown, " | "))
|
||||
check("storing turn played the X-item spiral", sawSpiral)
|
||||
check("no storing-energy line shown on the player's side",
|
||||
not has(pShown, "storing energy"))
|
||||
check("no storing-energy line queued on the player's side",
|
||||
not has(pQueued, "storing energy"))
|
||||
check("foe acted on at least one locked turn (" .. tackles .. " TACKLE lines)",
|
||||
lockedSeen)
|
||||
check("no CHARMANDER line besides used BIDE before the release: "
|
||||
.. (#stray > 0 and table.concat(stray, " | ") or "(none)"), #stray == 0)
|
||||
check("release printed unleashed energy", unleashed)
|
||||
for _ = 1, 30 do w.poll() U.wait(1) end
|
||||
check("release played BIDE's own animation", has(w.anims, "BIDE"))
|
||||
|
||||
leaveBattle()
|
||||
|
||||
lead.hp = lead.stats.hp
|
||||
lead.status = nil
|
||||
lead.moves = { { id = "SCRATCH", pp = 35 } }
|
||||
local foeBattle = BattleState.newWild(game, "RATTATA", 25)
|
||||
foeBattle.onFinish = function() end
|
||||
foeBattle.enemy.mon.moves = { { id = "BIDE", pp = 10 } }
|
||||
foeBattle.enemy.curMoves = foeBattle.enemy.mon.moves
|
||||
ow:pushBattle(foeBattle)
|
||||
local fw = watcher(foeBattle)
|
||||
check("foe battle reached the menu", waitPhase(foeBattle, "menu", 80, fw))
|
||||
local fSince = #fw.shown
|
||||
local fQSince = #fw.queued
|
||||
check("SCRATCH picked", pickFirstMove(foeBattle, fw))
|
||||
|
||||
local scratches, foeUnleashed, foeSpiral = 0, false, false
|
||||
local foeStoreShot = false
|
||||
for _ = 1, 3000 do
|
||||
local line = fw.poll()
|
||||
if has(fw.anims, "XSTATITEM_DUPLICATE_ANIM") then foeSpiral = true end
|
||||
if line then
|
||||
if line:find("SCRATCH", 1, true) then
|
||||
scratches = scratches + 1
|
||||
if foeSpiral and not foeStoreShot then
|
||||
foeStoreShot = true
|
||||
settledShot(fw, DIR .. "/2338_04_foe_store_turn_then_player_move.png")
|
||||
end
|
||||
elseif line:find("unleashed energy", 1, true) then
|
||||
foeUnleashed = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if game.stack:top() ~= foeBattle then
|
||||
if not game.stack:top() or foeBattle.enemy.mon.hp <= 0 then break end
|
||||
end
|
||||
if U.frame() % 6 == 0 then U.tap(game, "a") else U.wait(1) end
|
||||
end
|
||||
|
||||
local fShown, fQueued = {}, {}
|
||||
for i = fSince + 1, #fw.shown do fShown[#fShown + 1] = fw.shown[i] end
|
||||
for i = fQSince + 1, #fw.queued do fQueued[#fQueued + 1] = fw.queued[i] end
|
||||
local fStray = {}
|
||||
for _, s in ipairs(fShown) do
|
||||
if s:find("RATTATA", 1, true) and not s:find("used BIDE", 1, true)
|
||||
and not s:find("unleashed energy", 1, true) then
|
||||
fStray[#fStray + 1] = s
|
||||
end
|
||||
if s:find("unleashed energy", 1, true) then break end
|
||||
end
|
||||
U.log("foe battle shown: " .. table.concat(fShown, " | "))
|
||||
check("foe's storing turn played XSTATITEM_DUPLICATE_ANIM", foeSpiral)
|
||||
check("no storing-energy line shown on the foe's side",
|
||||
not has(fShown, "storing energy"))
|
||||
check("no storing-energy line queued on the foe's side",
|
||||
not has(fQueued, "storing energy"))
|
||||
check("player moved through the foe's locked turns (" .. scratches
|
||||
.. " SCRATCH lines)", scratches >= 2)
|
||||
check("no RATTATA line besides used BIDE before the release: "
|
||||
.. (#fStray > 0 and table.concat(fStray, " | ") or "(none)"), #fStray == 0)
|
||||
check("foe's release printed unleashed energy", foeUnleashed)
|
||||
|
||||
leaveBattle()
|
||||
print(fails == 0 and "PASS bide_silent_bug2338" or "FAIL bide_silent_bug2338")
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,114 @@
|
||||
-- ../pokecrystal/engine/events/overworld.asm:1444
|
||||
-- ../pokecrystal/data/maps/attributes.asm:139
|
||||
local U = require("tests.drivers.util")
|
||||
local Map = require("src.world.gen2.Map")
|
||||
local Permissions = require("src.world.gen2.Permissions")
|
||||
local WorldAPI = require("src.world.gen2.WorldAPI")
|
||||
|
||||
local SHOTS = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots/2352"
|
||||
local TOWN = "GOLDENROD_CITY"
|
||||
local POND = "ROUTE_34"
|
||||
local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
|
||||
|
||||
return function(game)
|
||||
local fails = 0
|
||||
local function say(line) print("[edge2352] " .. line) end
|
||||
local function ok(cond, label, detail)
|
||||
if not cond then fails = fails + 1 end
|
||||
say((cond and "PASS " or "FAIL ") .. label
|
||||
.. (detail ~= nil and (" (" .. tostring(detail) .. ")") or ""))
|
||||
return cond
|
||||
end
|
||||
|
||||
U.wait(60)
|
||||
local world = game.world
|
||||
if not (world and world.map and game.save) then
|
||||
say("FAIL the crystal world did not boot")
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
world.noWildEncounters = true
|
||||
game.save.inventory = game.save.inventory or {}
|
||||
game.save.inventory.OLD_ROD = 1
|
||||
local api = WorldAPI.new(game, "driver2352")
|
||||
|
||||
local function offersFish()
|
||||
for _, row in ipairs((api:availableFieldActions())) do
|
||||
if row.id == "fish" then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 240 do
|
||||
if not world.mapSetup and not world:busy() then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(10)
|
||||
end
|
||||
|
||||
local function mouth(x, y, facing, label, shot)
|
||||
world:warpToMapId(TOWN, x, y, facing)
|
||||
settle()
|
||||
world.player.facing = facing
|
||||
U.wait(2)
|
||||
ok(world.map and world.map.id == TOWN and world.player.cellX == x
|
||||
and world.player.cellY == y, label .. ": standing at " .. x .. "," .. y)
|
||||
local ctx = world:fieldContext()
|
||||
ok(not Permissions.isWater(ctx.facingColl),
|
||||
label .. ": the faced cell is the neighbour's road, not water",
|
||||
("coll $%02x"):format(ctx.facingColl or 0xff))
|
||||
ok(not offersFish(), label .. ": no FISH shortcut offered")
|
||||
ok(world:useFieldItem("OLD_ROD") == "nowhere",
|
||||
label .. ": OLD ROD answers nowhere (PACK stays open, not the time)")
|
||||
ok(not world.fishing and not world:busy(),
|
||||
label .. ": and no cast started")
|
||||
if shot then U.shot(game, SHOTS .. "/" .. shot) end
|
||||
end
|
||||
|
||||
for x = 18, 21 do
|
||||
mouth(x, 35, "down", "south mouth x=" .. x,
|
||||
x == 19 and "2352_01_south_mouth_no_fish.png" or nil)
|
||||
end
|
||||
for x = 24, 27 do
|
||||
mouth(x, 0, "up", "north mouth x=" .. x,
|
||||
x == 25 and "2352_02_north_mouth_no_fish.png" or nil)
|
||||
end
|
||||
|
||||
local def = world.maps[POND]
|
||||
local tileset = def and world.tilesets[def.tileset]
|
||||
local shore
|
||||
if def and tileset then
|
||||
local probe = Map.new(def, tileset)
|
||||
local taken = {}
|
||||
for _, obj in ipairs(def.objects or {}) do taken[obj.x .. "," .. obj.y] = true end
|
||||
for cy = 0, probe.heightCells - 1 do
|
||||
for cx = 0, probe.widthCells - 1 do
|
||||
for _, facing in ipairs({ "up", "down", "left", "right" }) do
|
||||
local d = DELTA[facing]
|
||||
if not shore and not taken[cx .. "," .. cy]
|
||||
and Permissions.isWalkable(probe:cellCollision(cx, cy))
|
||||
and not Permissions.isWater(probe:cellCollision(cx, cy))
|
||||
and probe:inBounds(cx + d[1], cy + d[2])
|
||||
and Permissions.isWater(probe:cellCollision(cx + d[1], cy + d[2])) then
|
||||
shore = { x = cx, y = cy, facing = facing }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if ok(shore ~= nil, "found a real Route 34 shore cell") then
|
||||
world:warpToMapId(POND, shore.x, shore.y, shore.facing)
|
||||
settle()
|
||||
world.player.facing = shore.facing
|
||||
U.wait(2)
|
||||
ok(offersFish(), "control: the FISH shortcut is offered at a real pond")
|
||||
local outcome = world:useFieldItem("OLD_ROD")
|
||||
ok(outcome ~= "nowhere", "control: OLD ROD casts into real water", outcome)
|
||||
U.wait(40)
|
||||
U.shot(game, SHOTS .. "/2352_03_control_real_pond_cast.png")
|
||||
end
|
||||
|
||||
say(fails == 0 and "ALL PASS" or (fails .. " FAILURES"))
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,128 @@
|
||||
-- pokefirered/src/script_menu.c:1027
|
||||
local U = require("tests.drivers.util")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp"
|
||||
|
||||
local FLAG_SYS_POKEMON_GET = 0x828 -- pokefirered/include/constants/flags.h:1374
|
||||
local FLAG_SYS_NOT_SOMEONES_PC = 0x834 -- pokefirered/include/constants/flags.h:1386
|
||||
local PC_X, PC_Y = 11, 2
|
||||
|
||||
local failures = 0
|
||||
local function result(ok, label)
|
||||
if ok then
|
||||
print("PASS " .. label)
|
||||
else
|
||||
failures = failures + 1
|
||||
print("FAIL " .. label)
|
||||
end
|
||||
return ok
|
||||
end
|
||||
|
||||
local function finish()
|
||||
if failures == 0 then
|
||||
print("PASS game3_bills_pc_label_bug2404")
|
||||
love.event.quit(0)
|
||||
else
|
||||
print("FAIL game3_bills_pc_label_bug2404 failures=" .. failures)
|
||||
love.event.quit(1)
|
||||
end
|
||||
end
|
||||
|
||||
local function waitFor(pred, n)
|
||||
for _ = 1, n do
|
||||
if pred() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return pred()
|
||||
end
|
||||
|
||||
local function PcMenu() return package.loaded["src.ui.game3.pc_menu"] end
|
||||
local function Space() return package.loaded["src.core.game3.scripting.space"] end
|
||||
|
||||
local function drawnStorageRow()
|
||||
local Window = require("src.ui.game3.window")
|
||||
local real = Window.printPx
|
||||
local rows = {}
|
||||
Window.printPx = function(text, ...)
|
||||
rows[#rows + 1] = text
|
||||
return real(text, ...)
|
||||
end
|
||||
local ok = pcall(PcMenu().draw)
|
||||
Window.printPx = real
|
||||
return ok and rows[1] or nil
|
||||
end
|
||||
|
||||
local function openRoot(game)
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 8 do
|
||||
U.wait(20)
|
||||
local M = PcMenu()
|
||||
if M and M.isOpen and M.isOpen() and M.mode == "root" then break end
|
||||
U.tap(game, "a")
|
||||
end
|
||||
U.wait(10)
|
||||
local M = PcMenu()
|
||||
return M and M.isOpen and M.isOpen() and M.mode == "root"
|
||||
end
|
||||
|
||||
local function logOff(game)
|
||||
local M = PcMenu()
|
||||
for _ = 1, 5 do
|
||||
if M.cursor == 5 then break end
|
||||
U.tap(game, "down")
|
||||
U.wait(8)
|
||||
end
|
||||
U.tap(game, "a")
|
||||
local Message = package.loaded["src.ui.game3.message"]
|
||||
return waitFor(function()
|
||||
local S = Space()
|
||||
local running = S and S.vm and S.vm.isRunning and S.vm:isRunning()
|
||||
return not M.isOpen() and not running and not (Message and Message.isOpen and Message.isOpen())
|
||||
end, 180)
|
||||
end
|
||||
|
||||
local function checkLabel(game, want, tag)
|
||||
local M = PcMenu()
|
||||
local row = drawnStorageRow()
|
||||
result(M.storageLabel and M.storageLabel(M._session or game.session) == want and row == want,
|
||||
string.format("%s: root menu first row is %s (got %s)", tag, want, tostring(row)))
|
||||
end
|
||||
|
||||
return function(game)
|
||||
U.wait(30)
|
||||
game:_handleBootAction({ action = "new_game", name = "RED" })
|
||||
U.wait(120)
|
||||
local Map = require("src.core.game3.map")
|
||||
local Flags = require("src.core.game3.scripting.flags")
|
||||
Map.load(nil, game, "FR_VIRIDIAN_CITY_POKEMON_CENTER_1F", { x = PC_X, y = PC_Y, facing = "up" })
|
||||
game.session.x, game.session.y, game.session.facing = PC_X, PC_Y, "up"
|
||||
U.wait(90)
|
||||
|
||||
local store = Space() and Space().store
|
||||
if not result(store ~= nil, "FireRed script flag store is live") then return finish() end
|
||||
Flags.setFlag(store, nil, FLAG_SYS_POKEMON_GET, true)
|
||||
Flags.setFlag(store, nil, FLAG_SYS_NOT_SOMEONES_PC, false)
|
||||
|
||||
if not result(openRoot(game), "before Bill: Center PC opens the root menu") then return finish() end
|
||||
checkLabel(game, "SOMEONE'S PC", "before Bill (starter flag 0x828 set)")
|
||||
U.shot(game, DIR .. "/2404_01_someones_pc_before_bill.png")
|
||||
result(logOff(game), "before Bill: LOG OFF releases the player")
|
||||
|
||||
Flags.setFlag(Space().store, nil, FLAG_SYS_NOT_SOMEONES_PC, true)
|
||||
U.wait(10)
|
||||
if not result(openRoot(game), "after Bill: Center PC opens the root menu") then return finish() end
|
||||
checkLabel(game, "BILL'S PC", "after Bill (0x834 set)")
|
||||
U.shot(game, DIR .. "/2404_02_bills_pc_after_sea_cottage.png")
|
||||
result(logOff(game), "after Bill: LOG OFF releases the player")
|
||||
|
||||
local S = Space()
|
||||
S.persistSession(nil, game)
|
||||
local live = S.store
|
||||
S.store = nil
|
||||
local M = PcMenu()
|
||||
local label = M.storageLabel and M.storageLabel(game.session)
|
||||
S.store = live
|
||||
result(label == "BILL'S PC",
|
||||
string.format("saved session snapshot still reads BILL'S PC (got %s)", tostring(label)))
|
||||
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,96 @@
|
||||
local U = require("tests.drivers.util")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp"
|
||||
|
||||
return function(game)
|
||||
local fails = 0
|
||||
local function result(ok, label)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
if not ok then fails = fails + 1 end
|
||||
end
|
||||
|
||||
for _ = 1, 600 do
|
||||
if game.phase == "boot" and game.boot then break end
|
||||
U.wait(1)
|
||||
end
|
||||
game:_handleBootAction({ action = "new_game", name = "RED" })
|
||||
U.wait(240)
|
||||
|
||||
local Flags = require("src.core.game3.scripting.flags")
|
||||
local Space = require("src.core.game3.scripting.space")
|
||||
local StartMenu = require("src.ui.game3.start_menu")
|
||||
local SaveMenu = require("src.ui.game3.save_menu")
|
||||
local TrainerCard = require("src.ui.game3.trainer_card")
|
||||
local FrlgFont = require("src.ui.game3.frlg_font")
|
||||
|
||||
local store = Space.getStore()
|
||||
result(store ~= nil, "2405 live flag store present")
|
||||
if not store then love.event.quit(1) return end
|
||||
Flags.setBadge(store, 1, true)
|
||||
Flags.setBadge(store, 2, true)
|
||||
|
||||
local drawn = {}
|
||||
local realDraw = FrlgFont.draw
|
||||
FrlgFont.draw = function(text, x, y, opts)
|
||||
drawn[#drawn + 1] = { text = text, x = x, y = y }
|
||||
return realDraw(text, x, y, opts)
|
||||
end
|
||||
local function valueAt(y)
|
||||
for i = #drawn, 1, -1 do
|
||||
local s = drawn[i]
|
||||
if s.y == y and s.x > 1 * 8 + 4 then return s.text end
|
||||
end
|
||||
end
|
||||
local function labelY(label)
|
||||
for i = #drawn, 1, -1 do
|
||||
local s = drawn[i]
|
||||
if s.text == label and s.x == 1 * 8 + 4 then return s.y end
|
||||
end
|
||||
end
|
||||
|
||||
local function openSave()
|
||||
U.tap(game, "start")
|
||||
U.wait(30)
|
||||
for _ = 1, 12 do
|
||||
local e = StartMenu.ENTRIES and StartMenu.ENTRIES[StartMenu.cursor]
|
||||
if e and e.id == "save" then break end
|
||||
U.tap(game, "down")
|
||||
U.wait(8)
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(40)
|
||||
return SaveMenu.isOpen()
|
||||
end
|
||||
|
||||
local function closeSave()
|
||||
for _ = 1, 4 do
|
||||
if not SaveMenu.isOpen() and not StartMenu.isOpen() then break end
|
||||
U.tap(game, "b")
|
||||
U.wait(20)
|
||||
end
|
||||
end
|
||||
|
||||
result(openSave(), "2405 START > SAVE opened the save stats window")
|
||||
drawn = {}
|
||||
U.wait(2)
|
||||
result(valueAt(1 * 8 + 32) == "2", "2405 save stats BADGES reads 2 (" .. tostring(valueAt(1 * 8 + 32)) .. ")")
|
||||
result(labelY("POKéDEX") == nil, "2405 no POKéDEX row before FLAG_SYS_POKEDEX_GET")
|
||||
result(labelY("TIME") == 1 * 8 + 46, "2405 TIME sits on the POKéDEX row (" .. tostring(labelY("TIME")) .. ")")
|
||||
U.shot(game, DIR .. "/2405_01_save_stats_two_badges_no_dex.png")
|
||||
result(TrainerCard.countBadges() == 2 or TrainerCard.countBadges(require("src.core.game3.runtime").getSession()) == 2,
|
||||
"2405 trainer card agrees on 2 badges")
|
||||
closeSave()
|
||||
|
||||
Flags.setFlag(store, nil, Flags.IDS.SYS_POKEDEX_GET or 0x829, true)
|
||||
Flags.setBadge(store, 3, true)
|
||||
result(openSave(), "2405 START > SAVE reopened with the POKéDEX flag")
|
||||
drawn = {}
|
||||
U.wait(2)
|
||||
result(valueAt(1 * 8 + 32) == "3", "2405 save stats BADGES reads 3 (" .. tostring(valueAt(1 * 8 + 32)) .. ")")
|
||||
result(labelY("POKéDEX") == 1 * 8 + 46, "2405 POKéDEX row drawn once the dex is obtained")
|
||||
result(labelY("TIME") == 1 * 8 + 60, "2405 TIME drops below the POKéDEX row (" .. tostring(labelY("TIME")) .. ")")
|
||||
U.shot(game, DIR .. "/2405_02_save_stats_three_badges_with_dex.png")
|
||||
closeSave()
|
||||
|
||||
FrlgFont.draw = realDraw
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,124 @@
|
||||
local U = require("tests.drivers.util")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/bug2403"
|
||||
|
||||
local failures = 0
|
||||
local function result(ok, label)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
if not ok then failures = failures + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
local function finish()
|
||||
if failures == 0 then
|
||||
print("PASS game3_ssanne_woman_bug2403")
|
||||
love.event.quit(0)
|
||||
else
|
||||
print("FAIL game3_ssanne_woman_bug2403 failures=" .. failures)
|
||||
love.event.quit(1)
|
||||
end
|
||||
end
|
||||
|
||||
return function(game)
|
||||
for _ = 1, 600 do
|
||||
if game.phase == "boot" and game.boot then break end
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
game:_handleBootAction({ action = "new_game", name = "RED" })
|
||||
U.wait(180)
|
||||
|
||||
local Runtime = require("src.core.game3.runtime")
|
||||
local Map = require("src.core.game3.map")
|
||||
local Space = require("src.core.game3.scripting.space")
|
||||
local Party = require("src.core.game3.party")
|
||||
local Objects = require("src.core.game3.objects")
|
||||
local Field = require("src.core.game3.field")
|
||||
local TrainerSight = require("src.core.game3.trainer_sight")
|
||||
local Message = package.loaded["src.ui.game3.message"] or require("src.ui.game3.message")
|
||||
|
||||
local session = Runtime.getSession()
|
||||
if not result(session ~= nil, "new game reached the field") then return finish() end
|
||||
session.party = {}
|
||||
Party.giveMon(session, 7, 30)
|
||||
|
||||
local engaged = {}
|
||||
local origEngage = TrainerSight.engage
|
||||
TrainerSight.engage = function(g, eo, dist)
|
||||
engaged[#engaged + 1] = eo and eo.localId
|
||||
return origEngage(g, eo, dist)
|
||||
end
|
||||
|
||||
local okC, MapCatalog = pcall(require, "src.import.gba.map_catalog")
|
||||
local mapId = (okC and MapCatalog.pretToEngine and MapCatalog.pretToEngine("SSAnne_1F_Room2"))
|
||||
or "FR_SSANNE_1F_ROOM2"
|
||||
local function place(x, y, facing)
|
||||
Map.load(nil, game, mapId, { x = x, y = y, facing = facing })
|
||||
game.session.x, game.session.y, game.session.facing = x, y, facing
|
||||
U.wait(30)
|
||||
end
|
||||
|
||||
local function vmBusy()
|
||||
return Space.vm and Space.vm.isRunning and Space.vm:isRunning()
|
||||
end
|
||||
local function msgOpen()
|
||||
return Message.isOpen and Message.isOpen()
|
||||
end
|
||||
|
||||
place(2, 5, "down")
|
||||
local woman = Objects.find(3)
|
||||
if not result(woman ~= nil, "SS Anne 1F Room2 Woman (localId 3) loaded") then return finish() end
|
||||
result(tonumber(woman.trainerType) == 0 and tonumber(woman.sight) == 1,
|
||||
"Woman carries TRAINER_TYPE_NONE with leftover sight 1 (type=" .. tostring(woman.trainerType)
|
||||
.. " sight=" .. tostring(woman.sight) .. ")")
|
||||
|
||||
U.tap(game, "a")
|
||||
local opened = false
|
||||
for _ = 1, 120 do
|
||||
if msgOpen() then opened = true break end
|
||||
U.wait(1)
|
||||
end
|
||||
if not result(opened, "talking to the Woman opens her line") then return finish() end
|
||||
U.wait(20)
|
||||
result(woman.facing == "up", "Woman turned to face the player (facing=" .. tostring(woman.facing) .. ")")
|
||||
result(U.shot(game, DIR .. "/2403_01_woman_cruising_text.png"), "screenshot 2403_01_woman_cruising_text")
|
||||
|
||||
for _ = 1, 300 do
|
||||
if not msgOpen() and not vmBusy() and not Field.locked then break end
|
||||
if msgOpen() then U.tap(game, "a") end
|
||||
U.wait(3)
|
||||
end
|
||||
result(not msgOpen() and not vmBusy(), "Woman's msgbox closed and the VM went idle")
|
||||
|
||||
local looped, reopened, locked = false, false, false
|
||||
for _ = 1, 240 do
|
||||
U.wait(1)
|
||||
if #engaged > 0 then looped = true end
|
||||
if msgOpen() then reopened = true end
|
||||
if Field.locked then locked = true end
|
||||
end
|
||||
result(not looped, "Woman never engages as a trainer after her line (engages=" .. #engaged .. ")")
|
||||
result(not reopened, "her line does not reopen on its own")
|
||||
result(not locked, "field stays unlocked for 240 frames after the text")
|
||||
result(woman.facing == "up" and Objects.find(3) == woman, "Woman still faces the player one tile away")
|
||||
result(U.shot(game, DIR .. "/2403_02_after_text_no_loop.png"), "screenshot 2403_02_after_text_no_loop")
|
||||
|
||||
engaged = {}
|
||||
place(5, 5, "up")
|
||||
local lass = Objects.find(1)
|
||||
if not result(lass ~= nil and TrainerSight.isTrainerType(lass), "Lass Ann (localId 1) is a TRAINER_TYPE_NORMAL") then
|
||||
return finish()
|
||||
end
|
||||
lass.facing = "down"
|
||||
local spotted = false
|
||||
for _ = 1, 300 do
|
||||
if #engaged > 0 then spotted = true break end
|
||||
U.wait(1)
|
||||
end
|
||||
result(spotted and engaged[1] == 1, "control: Lass Ann still spots the player 2 tiles below her")
|
||||
if spotted then
|
||||
U.wait(20)
|
||||
result(U.shot(game, DIR .. "/2403_03_lass_ann_spots_player.png"), "screenshot 2403_03_lass_ann_spots_player")
|
||||
end
|
||||
|
||||
return finish()
|
||||
end
|
||||
@@ -0,0 +1,72 @@
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local ArenaData = require("src.online.ArenaData")
|
||||
local OnlinePanel = require("src.import.OnlinePanel")
|
||||
local Protocol2 = require("src.online.Protocol2")
|
||||
local Trade = require("src.online.Trade")
|
||||
local Wire = require("src.link.Wire")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
local failed = false
|
||||
local function expect(cond, label, why)
|
||||
if cond then
|
||||
print("PASS " .. label)
|
||||
else
|
||||
failed = true
|
||||
print("FAIL " .. label .. (why and (": " .. tostring(why)) or ""))
|
||||
end
|
||||
end
|
||||
|
||||
U.wait(30)
|
||||
|
||||
local running = os.getenv("POKEPORT_VERSION") or "crystal"
|
||||
local order = {}
|
||||
for _, v in ipairs({ "gold", "silver", "crystal" }) do
|
||||
if v ~= running and RomImporter.isReady(v) then order[#order + 1] = v end
|
||||
end
|
||||
order[#order + 1] = running
|
||||
|
||||
Trade.hostIsLive = function() return false end
|
||||
ArenaData.forget()
|
||||
|
||||
local profiles = {}
|
||||
for _, v in ipairs(order) do
|
||||
local profile, reason = ArenaData.profile(v, "vanilla", nil, { partySize = 3 })
|
||||
expect(profile ~= nil, "2382_profile_" .. v, reason)
|
||||
if profile then
|
||||
profiles[v] = profile
|
||||
print(("[driver] %s fingerprint %s engineVersion %s"):format(
|
||||
v, tostring(profile.fingerprint), tostring(profile.engineVersion)))
|
||||
end
|
||||
end
|
||||
require("src.import.CacheFs").mountVersion(running)
|
||||
|
||||
local crystal, gold, silver = profiles.crystal, profiles.gold, profiles.silver
|
||||
expect(crystal and gold, "2382_both_imported",
|
||||
"the identity needs gold and crystal caches")
|
||||
if crystal and gold then
|
||||
expect(crystal.fingerprint == gold.fingerprint, "2382_gold_crystal_same_fingerprint",
|
||||
tostring(gold.fingerprint) .. " vs " .. tostring(crystal.fingerprint))
|
||||
local room = { profile = crystal }
|
||||
expect(OnlinePanel.joinReason(room, gold) == nil,
|
||||
"2382_gold_can_join_crystal_room", OnlinePanel.joinReason(room, gold))
|
||||
expect(OnlinePanel.joinReason({ profile = gold }, crystal) == nil,
|
||||
"2382_crystal_can_join_gold_room",
|
||||
OnlinePanel.joinReason({ profile = gold }, crystal))
|
||||
end
|
||||
if silver and crystal then
|
||||
expect(silver.fingerprint == crystal.fingerprint, "2382_silver_crystal_same_fingerprint",
|
||||
tostring(silver.fingerprint) .. " vs " .. tostring(crystal.fingerprint))
|
||||
end
|
||||
|
||||
local detail = "dataset fingerprint differs: the room has 6f577c0127a1e94d, "
|
||||
.. "you have c444a8971640c763"
|
||||
local msg = Wire.sanitize({ type = "join_error", reason = "profile_mismatch",
|
||||
field = "fingerprint", detail = detail })
|
||||
local text = Protocol2.joinErrorText(msg)
|
||||
print("[driver] join_error text: " .. tostring(text))
|
||||
expect(text:find("you have c444a8971640c763", 1, true) ~= nil,
|
||||
"2382_join_error_detail_whole")
|
||||
|
||||
love.event.quit(failed and 1 or 0)
|
||||
end
|
||||
@@ -0,0 +1,154 @@
|
||||
-- ../pokegold/engine/items/pack.asm:562
|
||||
-- ../pokegold/engine/pokemon/mon_menu.asm:270
|
||||
-- POKEPORT_VERSION=gold POKEPORT_IDENTITY=<identity> POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_SHOT_DIR=<dir> \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_pack_give_dup_bug2378.lua love .
|
||||
local U = require("tests.drivers.util")
|
||||
local Typer = require("src.ui.gen2.Typer")
|
||||
|
||||
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots"
|
||||
|
||||
return function(game)
|
||||
local fails = 0
|
||||
local function say(line) print("[2378] " .. line) end
|
||||
local function ok(cond, line)
|
||||
if not cond then fails = fails + 1 end
|
||||
say((cond and "PASS " or "FAIL ") .. line)
|
||||
return cond
|
||||
end
|
||||
local function finish()
|
||||
say(fails == 0 and "all claims passed" or (fails .. " claims failed"))
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 4)
|
||||
end
|
||||
local function top() return game.stack:top() end
|
||||
local function topId() local s = top() return s and s.screenId or nil end
|
||||
local function waitFor(id, limit)
|
||||
for _ = 1, limit or 120 do
|
||||
if topId() == id then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function waitTyped(state, limit)
|
||||
for _ = 1, limit or 600 do
|
||||
if not Typer.typing(state) then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function drainHeld(held)
|
||||
for _ = 1, 8 do
|
||||
if top() ~= held or not (held.message or held.confirm) then return end
|
||||
waitTyped(held)
|
||||
tap("a", 6)
|
||||
end
|
||||
end
|
||||
|
||||
U.wait(60)
|
||||
local world = game.world
|
||||
if not ok(world and world.map, "the gold world booted") then finish() end
|
||||
world:warpToMapId("NEW_BARK_TOWN", 13, 6, "down")
|
||||
U.wait(60)
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local save = game.save
|
||||
save.party = { Mon.new(game.data, "CYNDAQUIL", 12),
|
||||
Mon.new(game.data, "TOTODILE", 12) }
|
||||
save.party[1].item = nil
|
||||
save.party[2].item = nil
|
||||
save.inventory.BERRY = nil
|
||||
local Bag = require("src.inventory.Bag")
|
||||
ok(Bag.add(save, "BERRY", 1, game.data), "one BERRY is in the bag")
|
||||
if save.options then save.options.textSpeed = "FAST" end
|
||||
|
||||
local function berries()
|
||||
local n = save.inventory.BERRY or 0
|
||||
for _, mon in ipairs(save.party) do
|
||||
if mon.item == "BERRY" then n = n + 1 end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
tap("start", 8)
|
||||
local menu = top()
|
||||
if not ok(menu and menu.screenId == "Gen2StartMenu", "START opened the menu") then
|
||||
finish()
|
||||
end
|
||||
for _ = 1, 10 do
|
||||
if menu.list:current().value == "pack" then break end
|
||||
tap("down")
|
||||
end
|
||||
tap("a")
|
||||
if not ok(waitFor("Gen2PackMenu", 90), "PACK opened") then finish() end
|
||||
local pack = top()
|
||||
for _ = 1, 4 do
|
||||
if pack:pocket().id == "ITEM" then break end
|
||||
tap("right")
|
||||
end
|
||||
ok(pack:pocket().id == "ITEM", "on the ITEM pocket")
|
||||
|
||||
local function giveBerryTo(slot)
|
||||
local rowIndex
|
||||
for i, row in ipairs(pack.rows) do
|
||||
if row.id == "BERRY" then rowIndex = i end
|
||||
end
|
||||
if not ok(rowIndex ~= nil, "the BERRY is listed") then finish() end
|
||||
pack.index = rowIndex
|
||||
pack:openSubmenu()
|
||||
for i, id in ipairs(pack.submenu.rows) do
|
||||
if id == "give" then pack.submenu.index = i end
|
||||
end
|
||||
tap("a")
|
||||
if not ok(waitFor("Gen2PartyMenu", 60), "GIVE asked To which PKMN?") then
|
||||
finish()
|
||||
end
|
||||
local party = top()
|
||||
party.index = slot
|
||||
tap("a", 1)
|
||||
if not ok(waitFor("Gen2HeldItemMenu", 10), "the give text opened") then
|
||||
finish()
|
||||
end
|
||||
drainHeld(top())
|
||||
U.wait(10)
|
||||
end
|
||||
|
||||
giveBerryTo(2)
|
||||
ok(save.party[2].item == "BERRY", "slot 2 holds the BERRY")
|
||||
ok(topId() == "Gen2PackMenu",
|
||||
"empty-hand give lands back on the PACK (top: " .. tostring(topId()) .. ")")
|
||||
local listed = false
|
||||
for _, row in ipairs(pack.rows) do
|
||||
if row.id == "BERRY" then listed = true end
|
||||
end
|
||||
ok(not listed, "and the PACK no longer lists a BERRY")
|
||||
ok(berries() == 1, "one BERRY in total (" .. berries() .. ")")
|
||||
U.shot(game, SHOT_DIR .. "/2378_01_pack_back_after_give.png")
|
||||
|
||||
save.party[1].item = "BERRY"
|
||||
Bag.add(save, "BERRY", 1, game.data)
|
||||
pack:rebuild()
|
||||
local before = berries()
|
||||
giveBerryTo(1)
|
||||
ok(topId() == "Gen2PackMenu",
|
||||
"swap give lands back on the PACK (top: " .. tostring(topId()) .. ")")
|
||||
ok(save.party[1].item == "BERRY", "slot 1 still holds a BERRY")
|
||||
ok((save.inventory.BERRY or 0) == 1, "the bag got the old BERRY back")
|
||||
ok(berries() == before, "no BERRY was minted (" .. berries() .. " of "
|
||||
.. before .. ")")
|
||||
U.shot(game, SHOT_DIR .. "/2378_02_pack_back_after_swap.png")
|
||||
|
||||
for _ = 1, 10 do
|
||||
if top() == game.overworld or topId() == nil then break end
|
||||
tap("b", 8)
|
||||
end
|
||||
finish()
|
||||
end
|
||||
@@ -163,15 +163,41 @@ return function(game)
|
||||
botBar.black .. "/" .. botBar.total)
|
||||
end
|
||||
|
||||
check(tag .. " reached the fade", waitFor(function()
|
||||
return movie.fade >= 0.5 or movie.phase == 4
|
||||
end, 3000), tostring(movie.fade))
|
||||
local shot2 = SHOT_DIR .. "/" .. tag .. "_02_fade_to_white_mid.png"
|
||||
if movie.phase == 3 and check(tag .. " shot 02", U.shot(game, shot2) and load(shot2)) then
|
||||
local botBar = census(shot2, 0, 112, 160, 144)
|
||||
local topBar = census(shot2, 0, 0, 160, 32)
|
||||
check(tag .. " fade wash never paints OBJ pixels into the bars",
|
||||
botBar.obj == 0 and topBar.obj == 0, botBar.obj .. "/" .. topBar.obj)
|
||||
local function near1(path, c, bx1, by1, bx2, by2)
|
||||
local img = load(path)
|
||||
if not img then return 0, 1 end
|
||||
local ox, oy, scale = letterbox(img)
|
||||
local hit, total = 0, 0
|
||||
for y = oy + math.floor(by1 * scale) + 1, oy + math.floor(by2 * scale) - 2 do
|
||||
for x = ox + math.floor(bx1 * scale) + 1, ox + math.floor(bx2 * scale) - 2 do
|
||||
local r, g, b = img:getPixel(x, y)
|
||||
total = total + 1
|
||||
if near(math.floor(r * 255 + 0.5), math.floor(g * 255 + 0.5),
|
||||
math.floor(b * 255 + 0.5), c) then hit = hit + 1 end
|
||||
end
|
||||
end
|
||||
return hit, total
|
||||
end
|
||||
|
||||
for step, want in ipairs({ { name = "fadepal6_bars_bg_shade2", c = bg[3] },
|
||||
{ name = "fadepal7_bars_bg_shade1", c = bg[2] } }) do
|
||||
check(tag .. " reached fade step " .. step, waitFor(function()
|
||||
return movie.fadeStep == step or movie.phase == 4
|
||||
end, 3000), tostring(movie.fadeStep))
|
||||
if movie.phase ~= 3 then break end
|
||||
local upd = movie.update
|
||||
movie.update = function() end
|
||||
waitRenderedFrames(2)
|
||||
local shot = SHOT_DIR .. "/" .. tag .. "_0" .. (step + 1) .. "_" .. want.name .. ".png"
|
||||
if check(tag .. " shot fade step " .. step, U.shot(game, shot) and load(shot)) then
|
||||
local hit, total = near1(shot, want.c, 0, 112, 160, 144)
|
||||
check(tag .. " fade step " .. step .. " bottom bar is BG shade " .. (3 - step),
|
||||
hit == total, hit .. "/" .. total)
|
||||
hit, total = near1(shot, want.c, 0, 0, 160, 32)
|
||||
check(tag .. " fade step " .. step .. " top bar is BG shade " .. (3 - step),
|
||||
hit == total, hit .. "/" .. total)
|
||||
end
|
||||
movie.update = upd
|
||||
end
|
||||
|
||||
finish()
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local P = require("src.render.PaletteFX")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local IntroMovie = require("src.ui.IntroMovie")
|
||||
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local version = GameVersion.get()
|
||||
local tag = "2385_" .. tostring(version)
|
||||
|
||||
local STAR_START, FLASH_START, WAVES_START = 64, 104, 134
|
||||
|
||||
local fails = 0
|
||||
local function check(label, ok, detail)
|
||||
U.log(ok and "PASS" or "FAIL", label, detail or "")
|
||||
if not ok then fails = fails + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
local function finish()
|
||||
P.applyOptions({ colors = "ogred" })
|
||||
if fails > 0 then
|
||||
U.log("FAIL " .. tag .. ": " .. fails .. " check(s), shots in " .. SHOT_DIR)
|
||||
love.event.quit(1)
|
||||
else
|
||||
U.log("PASS " .. tag .. ": all checks, shots in " .. SHOT_DIR)
|
||||
love.event.quit(0)
|
||||
end
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local function waitFor(pred, limit)
|
||||
for _ = 1, limit or 3000 do
|
||||
if pred() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function near(R, G, B, c, tol)
|
||||
tol = tol or 12
|
||||
return math.abs(R - c[1]) <= tol and math.abs(G - c[2]) <= tol
|
||||
and math.abs(B - c[3]) <= tol
|
||||
end
|
||||
|
||||
local function letterbox(img)
|
||||
local W, H = img:getWidth(), img:getHeight()
|
||||
local x1, y1, x2, y2 = W, H, -1, -1
|
||||
for y = 0, H - 1 do
|
||||
for x = 0, W - 1 do
|
||||
local r, g, b = img:getPixel(x, y)
|
||||
if r > 0.02 or g > 0.02 or b > 0.02 then
|
||||
if x < x1 then x1 = x end
|
||||
if x > x2 then x2 = x end
|
||||
if y < y1 then y1 = y end
|
||||
if y > y2 then y2 = y end
|
||||
end
|
||||
end
|
||||
end
|
||||
local bw, bh = x2 - x1 + 1, y2 - y1 + 1
|
||||
if x2 >= x1 and math.abs(bw / 160 - bh / 144) < 0.05 then
|
||||
return x1, y1, bh / 144
|
||||
end
|
||||
local s = math.min(W / 160, H / 144)
|
||||
return math.floor((W - 160 * s) / 2), math.floor((H - 144 * s) / 2), s
|
||||
end
|
||||
|
||||
local function load(path)
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return false end
|
||||
local bytes = f:read("*a")
|
||||
f:close()
|
||||
local ok, img = pcall(function()
|
||||
return love.image.newImageData(love.filesystem.newFileData(bytes, "shot.png"))
|
||||
end)
|
||||
return ok and img or false
|
||||
end
|
||||
|
||||
local function census(img, frame, bx1, by1, bx2, by2, colors)
|
||||
local W, H = img:getWidth(), img:getHeight()
|
||||
local ox, oy, scale = frame[1], frame[2], frame[3]
|
||||
local x1 = ox + math.floor(bx1 * scale + 0.5) + 1
|
||||
local y1 = oy + math.floor(by1 * scale + 0.5) + 1
|
||||
local x2 = ox + math.floor(bx2 * scale + 0.5) - 2
|
||||
local y2 = oy + math.floor(by2 * scale + 0.5) - 2
|
||||
local c = { black = 0 }
|
||||
for k in pairs(colors) do c[k] = 0 end
|
||||
for y = math.max(0, y1), math.min(H - 1, y2) do
|
||||
for x = math.max(0, x1), math.min(W - 1, x2) do
|
||||
local r, g, b = img:getPixel(x, y)
|
||||
local R = math.floor(r * 255 + 0.5)
|
||||
local G = math.floor(g * 255 + 0.5)
|
||||
local B = math.floor(b * 255 + 0.5)
|
||||
if R < 20 and G < 20 and B < 20 then c.black = c.black + 1 end
|
||||
for k, col in pairs(colors) do
|
||||
if near(R, G, B, col) then c[k] = c[k] + 1 end
|
||||
end
|
||||
end
|
||||
end
|
||||
local d = scale * scale
|
||||
for k, v in pairs(c) do c[k] = math.floor(v / d + 0.5) end
|
||||
return c
|
||||
end
|
||||
|
||||
local movie
|
||||
check(tag .. " intro is on the stack", waitFor(function()
|
||||
movie = game.stack:top()
|
||||
return getmetatable(movie) == IntroMovie
|
||||
end, 600))
|
||||
if getmetatable(movie) ~= IntroMovie then finish() end
|
||||
|
||||
game.save.options.colors = "ogred"
|
||||
P.applyOptions(game.save.options)
|
||||
check(tag .. " COLORS is ogred", P.mode == "ogred", P.mode)
|
||||
local blue = GameVersion.isBlue()
|
||||
local obj = blue and P.GBC_OBJ_BLUE or P.GBC_OBJ
|
||||
local bg = P.ogBg()
|
||||
local cols = {
|
||||
obj1 = obj[2], obj2 = obj[3], bg1 = bg[2], bg2 = bg[3],
|
||||
gray1 = { 170, 170, 170 }, gray2 = { 85, 85, 85 },
|
||||
}
|
||||
|
||||
local function freeze() movie.update = function() end end
|
||||
local function thaw() movie.update = nil end
|
||||
|
||||
local function shootAt(phase, timer, name)
|
||||
if not waitFor(function()
|
||||
return movie.phase > phase or (movie.phase == phase and movie.timer >= timer)
|
||||
end, 1200) or movie.phase ~= phase then
|
||||
check(tag .. " reached phase " .. phase .. " t" .. timer, false,
|
||||
movie.phase .. "/" .. movie.timer)
|
||||
return nil
|
||||
end
|
||||
freeze()
|
||||
local path = SHOT_DIR .. "/" .. tag .. "_" .. name .. ".png"
|
||||
local ok = U.shot(game, path)
|
||||
thaw()
|
||||
local img = ok and load(path)
|
||||
if not check(tag .. " shot " .. name, img and true or false) then return nil end
|
||||
return img, { letterbox(img) }
|
||||
end
|
||||
|
||||
local LOGO = { 72, 56, 88, 80 }
|
||||
local TEXT = { 40, 80, 120, 88 }
|
||||
|
||||
local img, fr = shootAt(1, 90, "01_copyright")
|
||||
if img then
|
||||
local c = census(img, fr, 16, 56, 144, 96, cols)
|
||||
U.log(string.format("copyright bg1=%d bg2=%d gray1=%d gray2=%d black=%d",
|
||||
c.bg1, c.bg2, c.gray1, c.gray2, c.black))
|
||||
check(tag .. " copyright shadows wear the boot-ROM BG ramp", c.bg1 + c.bg2 > 20,
|
||||
c.bg1 + c.bg2)
|
||||
check(tag .. " copyright card has no raw DMG grays", c.gray1 + c.gray2 == 0,
|
||||
c.gray1 + c.gray2)
|
||||
end
|
||||
|
||||
img, fr = shootAt(2, STAR_START + 5, "02_logo_black")
|
||||
if img then
|
||||
local l = census(img, fr, LOGO[1], LOGO[2], LOGO[3], LOGO[4], cols)
|
||||
local t = census(img, fr, TEXT[1], TEXT[2], TEXT[3], TEXT[4], cols)
|
||||
U.log(string.format("logo black=%d obj=%d bg=%d | text black=%d obj=%d",
|
||||
l.black, l.obj1 + l.obj2, l.bg1 + l.bg2, t.black, t.obj1 + t.obj2))
|
||||
check(tag .. " logo starts black ($F9)", l.black > 60, l.black)
|
||||
check(tag .. " logo carries no BG ramp", l.bg1 + l.bg2 < 5, l.bg1 + l.bg2)
|
||||
check(tag .. " logo carries no OBJ ramp before the flash", l.obj1 + l.obj2 < 5,
|
||||
l.obj1 + l.obj2)
|
||||
check(tag .. " text starts black", t.black > 60, t.black)
|
||||
end
|
||||
|
||||
img, fr = shootAt(2, FLASH_START + 5, "03_flash1_text_light")
|
||||
if img then
|
||||
local l = census(img, fr, LOGO[1], LOGO[2], LOGO[3], LOGO[4], cols)
|
||||
local t = census(img, fr, TEXT[1], TEXT[2], TEXT[3], TEXT[4], cols)
|
||||
check(tag .. " flash 1 ($7E): text goes OBJ0 light", t.obj1 > 60, t.obj1)
|
||||
check(tag .. " flash 1 ($7E): logo stays black", l.black > 60 and l.obj1 + l.obj2 < 5,
|
||||
l.black .. "/" .. (l.obj1 + l.obj2))
|
||||
end
|
||||
|
||||
img, fr = shootAt(2, FLASH_START + 15, "04_flash2_logo_light")
|
||||
if img then
|
||||
local l = census(img, fr, LOGO[1], LOGO[2], LOGO[3], LOGO[4], cols)
|
||||
local t = census(img, fr, TEXT[1], TEXT[2], TEXT[3], TEXT[4], cols)
|
||||
check(tag .. " flash 2 ($9F): logo goes OBJ0 light", l.obj1 > 60, l.obj1)
|
||||
check(tag .. " flash 2 ($9F): text goes OBJ0 dark", t.obj2 > 60, t.obj2)
|
||||
end
|
||||
|
||||
img, fr = shootAt(2, WAVES_START + 30, "05_settled_logo_dark")
|
||||
if img then
|
||||
local l = census(img, fr, LOGO[1], LOGO[2], LOGO[3], LOGO[4], cols)
|
||||
local t = census(img, fr, TEXT[1], TEXT[2], TEXT[3], TEXT[4], cols)
|
||||
local s = census(img, fr, 0, 90, 160, 112, cols)
|
||||
U.log(string.format("settled logo obj2=%d | text black=%d obj=%d | stars bg=%d obj=%d",
|
||||
l.obj2, t.black, t.obj1 + t.obj2, s.bg1 + s.bg2, s.obj1 + s.obj2))
|
||||
check(tag .. " settles on $E7: logo OBJ0 dark", l.obj2 > 60, l.obj2)
|
||||
check(tag .. " settles on $E7: text black", t.black > 60 and t.obj1 + t.obj2 < 5,
|
||||
t.black .. "/" .. (t.obj1 + t.obj2))
|
||||
check(tag .. " falling stars keep the BG ramp", s.bg1 + s.bg2 > 0 and s.obj1 + s.obj2 == 0,
|
||||
(s.bg1 + s.bg2) .. "/" .. (s.obj1 + s.obj2))
|
||||
end
|
||||
|
||||
if movie.phase == 2 then
|
||||
freeze()
|
||||
game.save.options.colors = "gbc"
|
||||
P.applyOptions(game.save.options)
|
||||
movie.timer = STAR_START + 5
|
||||
local gf = P.pal(game.data, "GAMEFREAK")
|
||||
local path = SHOT_DIR .. "/" .. tag .. "_06_sgb_logo_black.png"
|
||||
local ok = U.shot(game, path)
|
||||
img = ok and load(path)
|
||||
if check(tag .. " shot 06_sgb_logo_black", img and gf and true or false) then
|
||||
fr = { letterbox(img) }
|
||||
local l = census(img, fr, LOGO[1], LOGO[2], LOGO[3], LOGO[4],
|
||||
{ c3 = gf[3], c4 = gf[4] })
|
||||
check(tag .. " SGB logo starts on PAL_GAMEFREAK colour 4", l.c4 > 60 and l.c3 < 5,
|
||||
l.c4 .. "/" .. l.c3)
|
||||
end
|
||||
thaw()
|
||||
else
|
||||
check(tag .. " still on the splash for the SGB shot", false, movie.phase)
|
||||
end
|
||||
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,228 @@
|
||||
-- scripts/MtMoonB2F.asm:225
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR")
|
||||
or "/tmp/shots/2340"
|
||||
pcall(function() io.stdout:setvbuf("no") end)
|
||||
|
||||
local MAP = "MT_MOON_B2F"
|
||||
local TRIGGER = { x = 3, y = 5 }
|
||||
local JESSIE, JAMES = "MTMOONB2F_JESSIE", "MTMOONB2F_JAMES"
|
||||
local BEAT = "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES"
|
||||
local failed = false
|
||||
|
||||
local function check(label, ok)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
if not ok then failed = true end
|
||||
return ok
|
||||
end
|
||||
|
||||
local function finish()
|
||||
print(failed and "FAIL 2340 overall" or "PASS 2340 overall")
|
||||
love.event.quit(failed and 1 or 0)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local function npcNamed(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
|
||||
|
||||
local function autoBox(delay)
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == TextBox and top.auto and top.auto.delay == delay then
|
||||
return top
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
if not check("2340 running Yellow", GameVersion.isYellow()) then finish() end
|
||||
|
||||
local mon = Pokemon.new(game.data, "MEWTWO", 100)
|
||||
mon.moves = { { id = "TACKLE", pp = 35 } }
|
||||
game.save.party = { mon }
|
||||
game.save.flags[BEAT] = nil
|
||||
game.save.flags.EVENT_GOT_HELIX_FOSSIL = true
|
||||
|
||||
local ow
|
||||
local approaches = {
|
||||
{ TRIGGER.x, TRIGGER.y - 1, "down" },
|
||||
{ TRIGGER.x + 1, TRIGGER.y, "left" },
|
||||
{ TRIGGER.x - 1, TRIGGER.y, "right" },
|
||||
{ TRIGGER.x, TRIGGER.y + 1, "up" },
|
||||
}
|
||||
local before
|
||||
for _, a in ipairs(approaches) do
|
||||
U.teleport(game, MAP, a[1], a[2], a[3])
|
||||
ow = game.overworld
|
||||
if ow.map:isWalkableCell(a[1], a[2]) and not ow:npcAtCell(a[1], a[2]) then
|
||||
U.hold(game, a[3], 20)
|
||||
U.wait(2)
|
||||
if ow.runner:isRunning() then before = a[3] break end
|
||||
end
|
||||
end
|
||||
if not check("2340 the ambush fired on (3,5)", ow.runner:isRunning()) then
|
||||
finish()
|
||||
end
|
||||
U.log("pre-trigger facing", before)
|
||||
|
||||
local box
|
||||
for _ = 1, 300 do
|
||||
box = autoBox(91)
|
||||
if box then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if not check("2340 motto box is armed no-wait (auto delay 91)", box ~= nil) then
|
||||
finish()
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if box.done then break end
|
||||
U.wait(1)
|
||||
end
|
||||
local typedAt = U.frame()
|
||||
U.shot(game, DIR .. "/2340_01_motto_typed_no_arrow.png")
|
||||
check("2340 motto box shows no blinking arrow", not box:arrowVisible())
|
||||
|
||||
local sawBubble, bubbleFacingOk, bubbleShot = false, true, false
|
||||
local sawTurnUnderBox, turnShot = false, false
|
||||
local poppedAt
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() ~= box then poppedAt = U.frame() break end
|
||||
if ow.emote and ow.emote.npc == ow.player then
|
||||
sawBubble = true
|
||||
if ow.player.facing ~= before then bubbleFacingOk = false end
|
||||
if not bubbleShot then
|
||||
bubbleShot = true
|
||||
U.shot(game, DIR .. "/2340_02_bubble_over_motto_box.png")
|
||||
end
|
||||
elseif sawBubble and ow.player.facing == "up" then
|
||||
sawTurnUnderBox = true
|
||||
if not turnShot then
|
||||
turnShot = true
|
||||
U.shot(game, DIR .. "/2340_03_bubble_gone_facing_up_box_up.png")
|
||||
end
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
check("2340 motto box closed with no button press", poppedAt ~= nil)
|
||||
local held = poppedAt and (poppedAt - typedAt) or -1
|
||||
U.log("motto box held", held, "frames after typing")
|
||||
check("2340 motto box held ~91 frames after typing (got " .. held .. ")",
|
||||
held >= 85 and held <= 100)
|
||||
check("2340 exclamation bubble shown over the player while the box is up",
|
||||
sawBubble)
|
||||
check("2340 player keeps facing " .. tostring(before) .. " under the bubble",
|
||||
bubbleFacingOk)
|
||||
check("2340 player turns up after the bubble, box still up", sawTurnUnderBox)
|
||||
|
||||
local jessie, james = npcNamed(ow, JESSIE), npcNamed(ow, JAMES)
|
||||
if not check("2340 both of the duo are on the map", jessie and james) then
|
||||
finish()
|
||||
end
|
||||
|
||||
local jStart, jEnd, mStart, mEnd
|
||||
local midShot = false
|
||||
local facedDownOk, jessieArrived = true, false
|
||||
local challenge
|
||||
for _ = 1, 900 do
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == TextBox then
|
||||
challenge = top
|
||||
if mStart and not mEnd and not james.moving
|
||||
and james.cellX == TRIGGER.x + 1 then
|
||||
mEnd = U.frame()
|
||||
end
|
||||
break
|
||||
end
|
||||
if jessie.moving and not jStart then jStart = U.frame() end
|
||||
if jStart and not jEnd and not jessie.moving and jessie.cellX == TRIGGER.x then
|
||||
jEnd = U.frame()
|
||||
jessieArrived = true
|
||||
end
|
||||
if james.moving and not mStart then mStart = U.frame() end
|
||||
if mStart and not mEnd and not james.moving and james.cellX == TRIGGER.x + 1 then
|
||||
mEnd = U.frame()
|
||||
end
|
||||
if jessieArrived and jessie.facing ~= "down" then facedDownOk = false end
|
||||
if jStart and not jEnd and not midShot and jessie.cellX <= 6 then
|
||||
midShot = true
|
||||
U.shot(game, DIR .. "/2340_04_jessie_mid_walk.png")
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
local jFrames = (jStart and jEnd) and (jEnd - jStart) or -1
|
||||
local mFrames = (mStart and mEnd) and (mEnd - mStart) or -1
|
||||
U.log("jessie walk", jFrames, "james walk", mFrames)
|
||||
check("2340 Jessie's six steps take ~96 frames (got " .. jFrames .. ")",
|
||||
jFrames >= 90 and jFrames <= 104)
|
||||
check("2340 James's five steps take ~80 frames (got " .. mFrames .. ")",
|
||||
mFrames >= 74 and mFrames <= 88)
|
||||
check("2340 Jessie faces down from arrival to the challenge line", facedDownOk)
|
||||
check("2340 the challenge box opened", challenge ~= nil)
|
||||
if challenge then
|
||||
check("2340 Jessie still faces down under the challenge box",
|
||||
jessie.facing == "down")
|
||||
for _ = 1, 300 do
|
||||
if challenge.done then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.shot(game, DIR .. "/2340_06_challenge_box.png")
|
||||
end
|
||||
|
||||
local battle
|
||||
for _ = 1, 3000 do
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == BattleState then battle = top end
|
||||
if battle and autoBox(64) then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
local parting = autoBox(64)
|
||||
check("2340 the parting box is armed no-wait (auto delay 64)", parting ~= nil)
|
||||
if parting then
|
||||
check("2340 Jessie faces down for the parting line", jessie.facing == "down")
|
||||
check("2340 James faces down for the parting line", james.facing == "down")
|
||||
local closed, partingShot, partingTyped, sawCont = false, false, nil, false
|
||||
for _ = 1, 1500 do
|
||||
if game.stack:top() ~= parting then closed = true break end
|
||||
if parting.waiting and not parting.done and not sawCont
|
||||
and (parting.preWait or 0) <= 0 and not parting:sfxHeld() then
|
||||
sawCont = true
|
||||
U.shot(game, DIR .. "/2340_07_parting_box_cont_wait.png")
|
||||
U.tap(game, "a")
|
||||
end
|
||||
if parting.done and not partingTyped then partingTyped = U.frame() end
|
||||
if parting.done and not partingShot then
|
||||
partingShot = true
|
||||
U.shot(game, DIR .. "/2340_08_parting_last_page_no_arrow.png")
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
local partHeld = (closed and partingTyped) and (U.frame() - partingTyped) or -1
|
||||
U.log("parting box held", partHeld, "frames after the last page typed")
|
||||
check("2340 the parting box waits for A at its cont", sawCont)
|
||||
check("2340 the parting box closes with no press after the last page", closed)
|
||||
check("2340 parting box held ~64 frames after typing (got " .. partHeld .. ")",
|
||||
partHeld >= 58 and partHeld <= 72)
|
||||
end
|
||||
|
||||
local settled = false
|
||||
for _ = 1, 1200 do
|
||||
if game.stack:top() == ow and not ow.runner:isRunning()
|
||||
and #ow.scriptMoves == 0 and game.save.flags[BEAT] then
|
||||
settled = true
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
check("2340 the scene ran to its end", settled)
|
||||
check("2340 the duo are gone",
|
||||
npcNamed(ow, JESSIE) == nil and npcNamed(ow, JAMES) == nil)
|
||||
finish()
|
||||
end
|
||||
@@ -19,8 +19,10 @@ return function(game)
|
||||
local RING = { "down", "left", "up", "right" }
|
||||
local SILENCE, STEP, TAIL = 32, 24, 48 -- the three DelayFrames counts
|
||||
|
||||
local failures = 0
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
if not ok then failures = failures + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
@@ -190,7 +192,7 @@ return function(game)
|
||||
gap >= SILENCE - 4 and gap <= SILENCE + 8)
|
||||
end
|
||||
|
||||
check("the JIGGLYPUFF turned at least four times", #turns >= 4)
|
||||
check("the JIGGLYPUFF turned at least 14 quarter turns (about four spins)", #turns >= 14)
|
||||
local ringOk, spacingOk = true, true
|
||||
for n, t in ipairs(turns) do
|
||||
if t.to ~= nextInRing(t.from) then
|
||||
@@ -209,14 +211,14 @@ return function(game)
|
||||
end
|
||||
end
|
||||
check("every turn is one clockwise quarter turn (DOWN->LEFT->UP->RIGHT)",
|
||||
ringOk and #turns >= 4)
|
||||
ringOk and #turns >= 14)
|
||||
check(("the turns are %d frames apart"):format(STEP),
|
||||
spacingOk and #turns >= 2)
|
||||
|
||||
-- not just "it eventually closed": an A-dismissable box closes too, only far
|
||||
-- too early, and that is the bug
|
||||
check("mashing A never closed the box early",
|
||||
(closedAt or #log) >= SILENCE + 4 * STEP)
|
||||
(closedAt or #log) >= SILENCE + 14 * STEP)
|
||||
if check("the box closed itself with no button press", closedAt ~= nil) then
|
||||
check("it stayed up for the whole song, A mashing and all",
|
||||
songEnd ~= nil and closedAt > songEnd)
|
||||
@@ -229,8 +231,7 @@ return function(game)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- 2. do it again, screenshot each quarter turn, hand off mid-song ---
|
||||
U.log("--- run 2: screenshots, then the pad is yours -------------------")
|
||||
U.log("--- run 2: screenshots ----------------------------------------")
|
||||
U.wait(30)
|
||||
puff = game.overworld and puffIn(game.overworld)
|
||||
check("still standing against the JIGGLYPUFF", facingThePuff())
|
||||
@@ -253,11 +254,8 @@ return function(game)
|
||||
end
|
||||
check("captured four quarter turns", shots >= 4)
|
||||
|
||||
U.log("The JIGGLYPUFF has been talked to and is mid-song. Mash A: the box has no")
|
||||
U.log("arrow and must not close. The fairy should turn a quarter turn clockwise")
|
||||
U.log("(down, left, up, right) every 24 frames until the song ends, then close")
|
||||
U.log("itself (#249). The Center theme returns just before it does; expected.")
|
||||
|
||||
U.log(failures == 0 and "ALL PASS" or ("DONE " .. failures .. " check(s) failed"))
|
||||
love.event.quit(failures == 0 and 0 or 1)
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local LaunchOptions = require("src.core.LaunchOptions")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local Platform = require("src.core.Platform")
|
||||
|
||||
local dir = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR")
|
||||
or "/tmp/updatemods2037"
|
||||
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
|
||||
love.window.setMode(1024, 768, { resizable = true, highdpi = true })
|
||||
U.wait(2)
|
||||
|
||||
local failed = false
|
||||
local function expect(cond, label, why)
|
||||
if cond then
|
||||
print("PASS " .. label)
|
||||
else
|
||||
failed = true
|
||||
print("FAIL " .. label .. (why and (": " .. tostring(why)) or ""))
|
||||
end
|
||||
end
|
||||
|
||||
local t = LaunchOptions.tasks({ "--game=red", "--update-mods" }, nil, false)
|
||||
expect(t.mods == true and t.update == false, "flag_parsed",
|
||||
tostring(t.mods) .. "/" .. tostring(t.update))
|
||||
expect(LaunchOptions.tasks({ "--updatemods" }, nil, false).mods == true,
|
||||
"flag_alias_parsed")
|
||||
|
||||
local realBeginZip = ModUpdate.beginDownloadZip
|
||||
local realPumpZip = ModUpdate.pumpDownloadZip
|
||||
local realInstall = LauncherMods.installDownloadedZip
|
||||
local realRemote = Platform.canFetchRemote
|
||||
local realDeps = LauncherMods.checkDependencies
|
||||
|
||||
local failId = nil
|
||||
local installs = {}
|
||||
local onDisk = {}
|
||||
ModUpdate.beginDownloadZip = function() return { frames = 0 } end
|
||||
ModUpdate.pumpDownloadZip = function(h)
|
||||
h.frames = h.frames + 1
|
||||
if h.frames < 90 then return false, nil, nil, h.frames / 90 end
|
||||
love.filesystem.write("update_mods_2037.zip", "BYTES")
|
||||
return true, "update_mods_2037.zip"
|
||||
end
|
||||
LauncherMods.installDownloadedZip = function(id, _, version)
|
||||
installs[#installs + 1] = id
|
||||
if id == failId then return nil, "the archive had no manifest" end
|
||||
onDisk[id] = version
|
||||
return true, version
|
||||
end
|
||||
LauncherMods.checkDependencies = function() return { hasIssues = false } end
|
||||
Platform.canFetchRemote = function() return true end
|
||||
|
||||
local RELEASE = { version = "2.0.0",
|
||||
zip = { url = "https://example.invalid/fake.zip" } }
|
||||
local function newLauncher()
|
||||
local imp = RomImporter.new(function() end, { launcher = true })
|
||||
imp.safeMode = false
|
||||
onDisk = {}
|
||||
imp._refreshMods = function(self)
|
||||
for _, m in ipairs(self.mods or {}) do
|
||||
if onDisk[m.id] then m.version = onDisk[m.id] end
|
||||
end
|
||||
self._modUpdateRev = (self._modUpdateRev or 0) + 1
|
||||
end
|
||||
imp._refreshFindSources = function(self) self.findSources = {} end
|
||||
imp._refreshFind = function() end
|
||||
imp._pumpFindFetch = function() end
|
||||
imp._findFetch = nil
|
||||
imp.findLoaded = true
|
||||
imp.findIndex = { mods = {}, carts = {} }
|
||||
imp.mods = {
|
||||
{ id = "fake_mod", name = "Fake Mod", version = "1.0.0",
|
||||
github = "example/fake-mod", enabled = true, status = "ok" },
|
||||
}
|
||||
imp._syncModUpdateInfo = function(self)
|
||||
self.modUpdateInfo = { fake_mod = { status = "available",
|
||||
latest = "2.0.0", best = RELEASE, releases = { RELEASE } } }
|
||||
self._modUpdateRev = (self._modUpdateRev or 0) + 1
|
||||
end
|
||||
return imp
|
||||
end
|
||||
|
||||
local imp
|
||||
local pending = nil
|
||||
love.draw = function()
|
||||
if imp then imp:draw() end
|
||||
if pending then
|
||||
local path = pending
|
||||
pending = nil
|
||||
love.graphics.captureScreenshot(function(imagedata)
|
||||
local f = io.open(path, "wb")
|
||||
if f then f:write(imagedata:encode("png"):getString()) f:close() end
|
||||
end)
|
||||
end
|
||||
end
|
||||
local confirmSeen = false
|
||||
local function step(n)
|
||||
for _ = 1, n do
|
||||
if imp then
|
||||
imp:update(1 / 60)
|
||||
if imp._modConfirm then confirmSeen = true end
|
||||
end
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
local function shot(name)
|
||||
pending = dir .. "/" .. name
|
||||
for _ = 1, 30 do
|
||||
if not pending then break end
|
||||
step(1)
|
||||
end
|
||||
step(2)
|
||||
local f = io.open(dir .. "/" .. name, "rb")
|
||||
U.log(f and "shot" or "FAIL shot", name)
|
||||
if f then f:close() end
|
||||
end
|
||||
|
||||
imp = newLauncher()
|
||||
local results = {}
|
||||
local started = imp:autoUpdateAll(function(r)
|
||||
results[#results + 1] = r
|
||||
end, { tab = "mods" })
|
||||
expect(started, "auto_sweep_started")
|
||||
expect(imp.tab == "mods", "launcher_on_mods_tab", imp.tab)
|
||||
step(30)
|
||||
expect(imp._modInstall ~= nil and imp._busy ~= nil, "overlay_updating_row",
|
||||
imp._busy and imp._busy.title)
|
||||
expect(not confirmSeen, "no_confirm_modal")
|
||||
shot("2037_01_updating_overlay_no_confirm.png")
|
||||
for _ = 1, 200 do
|
||||
if #results > 0 then break end
|
||||
step(1)
|
||||
end
|
||||
expect(#results == 1 and results[1].ok == true, "on_done_ok_boot_handoff",
|
||||
results[1] and tostring(results[1].ok))
|
||||
expect(installs[1] == "fake_mod", "mod_installed", installs[1])
|
||||
expect(not confirmSeen, "no_confirm_whole_run")
|
||||
expect(imp.modNotice and imp.modNotice.text == "Updated 1 items.",
|
||||
"notice_updated", imp.modNotice and imp.modNotice.text)
|
||||
local row = imp.mods[1]
|
||||
local rowInfo = imp:_modUpdateInfo("fake_mod")
|
||||
expect(row.version == "2.0.0" and rowInfo and rowInfo.status == "current",
|
||||
"row_shows_updated_version",
|
||||
tostring(row.version) .. "/" .. tostring(rowInfo and rowInfo.status))
|
||||
step(20)
|
||||
shot("2037_02_update_done_notice.png")
|
||||
|
||||
imp = newLauncher()
|
||||
failId = "fake_mod"
|
||||
installs, results, confirmSeen = {}, {}, false
|
||||
imp:autoUpdateAll(function(r) results[#results + 1] = r end, { tab = "mods" })
|
||||
for _ = 1, 300 do
|
||||
if #results > 0 then break end
|
||||
step(1)
|
||||
end
|
||||
failId = nil
|
||||
expect(#results == 1 and results[1].ok == false, "failure_reported_not_ok")
|
||||
expect(imp.modNotice and imp.modNotice.ok == false
|
||||
and #(imp.modNotice.failures or {}) == 1, "failure_stays_on_launcher",
|
||||
imp.modNotice and imp.modNotice.text)
|
||||
step(20)
|
||||
shot("2037_03_failed_update_stays_on_launcher.png")
|
||||
|
||||
imp = newLauncher()
|
||||
confirmSeen = false
|
||||
imp:_switchTab("mods")
|
||||
expect(imp:pressUpdateAllMods(), "button_press_starts")
|
||||
step(10)
|
||||
expect(confirmSeen and (imp._updateAll or {}).stage == "confirm",
|
||||
"button_still_confirms")
|
||||
step(10)
|
||||
shot("2037_04_button_still_confirms.png")
|
||||
imp._modConfirm = nil
|
||||
step(3)
|
||||
|
||||
ModUpdate.beginDownloadZip = realBeginZip
|
||||
ModUpdate.pumpDownloadZip = realPumpZip
|
||||
LauncherMods.installDownloadedZip = realInstall
|
||||
LauncherMods.checkDependencies = realDeps
|
||||
Platform.canFetchRemote = realRemote
|
||||
imp = nil
|
||||
U.log("done")
|
||||
love.event.quit(failed and 1 or 0)
|
||||
end
|
||||
@@ -0,0 +1,174 @@
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local CartManifest = require("src.carts.CartManifest")
|
||||
local CartStore = require("src.carts.CartStore")
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
local Json = require("src.link.Json")
|
||||
local Kit = require("src.ui.kit.Kit")
|
||||
|
||||
local dir = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR")
|
||||
or "/tmp/cartdel2367"
|
||||
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
|
||||
love.window.setMode(1024, 768, { resizable = true, highdpi = true })
|
||||
U.wait(2)
|
||||
|
||||
local failed = false
|
||||
local function pass(label) print("PASS " .. label) end
|
||||
local function fail(label, why)
|
||||
failed = true
|
||||
print("FAIL " .. label .. (why and (": " .. tostring(why)) or ""))
|
||||
end
|
||||
local function expect(cond, label, why)
|
||||
if cond then pass(label) else fail(label, why) end
|
||||
end
|
||||
|
||||
local SHA = ("a1b2c3d4"):rep(8)
|
||||
local function pin(id, repo)
|
||||
return { id = id, source = "github", repo = repo, version = "1.0.0",
|
||||
sha256 = SHA }
|
||||
end
|
||||
local CART = {
|
||||
id = "wild_green", title = "Wild Green", version = "1.75.0",
|
||||
author = "wild1walker", shell = "#3a8f3a", base = "red", seal = "sealed",
|
||||
mods = { pin("wild_green", "wild1walker/Gen1MakeItGreen"),
|
||||
pin("wg_music", "wild1walker/wg-music"),
|
||||
pin("wg_items", "wild1walker/wg-items"),
|
||||
pin("wg_maps", "wild1walker/wg-maps") },
|
||||
}
|
||||
local function installCart()
|
||||
local cart = CartManifest.parse(CART)
|
||||
return cart and CartStore.install(CartManifest.encode(cart))
|
||||
end
|
||||
|
||||
local FEED = ModIndex.parse(Json.encode({
|
||||
schema_version = 1, categories = { "GAMEPLAY" }, base_games = { "red" },
|
||||
mods = {},
|
||||
carts = { {
|
||||
id = "wild_green", title = "Wild Green", author = "wild1walker",
|
||||
version = "1.75.0", base = "red", seal = "sealed",
|
||||
repo = "https://github.com/wild1walker/Gen1WildGreen",
|
||||
mods = CART.mods, update_check = "ok",
|
||||
latest = { version = "1.75.0", tag = "v1.75.0",
|
||||
zip = { name = "wild_green-1.75.0.g1rcart",
|
||||
url = "https://example.invalid/wild_green.g1rcart" } },
|
||||
} },
|
||||
}))
|
||||
local ENTRY = FEED and FEED.carts and FEED.carts[1]
|
||||
|
||||
pcall(CartStore.uninstall, "wild_green")
|
||||
expect(installCart(), "cart_installed")
|
||||
|
||||
local imp = RomImporter.new(function() end, { launcher = true })
|
||||
imp._refreshFindSources = function() end
|
||||
imp._refreshFind = function() end
|
||||
imp._pumpFindFetch = function() end
|
||||
imp._findFetch = nil
|
||||
imp.findSources = { { feed = "https://example.invalid/data/index.json",
|
||||
base = "https://example.invalid/",
|
||||
label = "example/index" } }
|
||||
imp.findIndex = { schemaVersion = 1, categories = {}, mods = FEED.mods,
|
||||
carts = FEED.carts, baseGames = ModIndex.baseGamesIn(FEED) }
|
||||
imp.findLoaded = true
|
||||
imp:_switchTab("find")
|
||||
imp:_setFindKind("carts")
|
||||
imp.ready.red = nil
|
||||
U.wait(3)
|
||||
|
||||
local buttons = {}
|
||||
local realButton = Kit.button
|
||||
Kit.button = function(x, y, w, h, label, opts)
|
||||
if opts and opts.id then
|
||||
buttons[opts.id] = { label = tostring(label), opts = opts }
|
||||
end
|
||||
return realButton(x, y, w, h, label, opts)
|
||||
end
|
||||
|
||||
local pending = nil
|
||||
love.draw = function()
|
||||
buttons = {}
|
||||
imp:draw()
|
||||
if pending then
|
||||
local path = pending
|
||||
pending = nil
|
||||
love.graphics.captureScreenshot(function(imagedata)
|
||||
local f = io.open(path, "wb")
|
||||
if f then f:write(imagedata:encode("png"):getString()) f:close() end
|
||||
end)
|
||||
end
|
||||
end
|
||||
local function step(n)
|
||||
for _ = 1, n do
|
||||
imp:update(1 / 60)
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
local function shot(name)
|
||||
pending = dir .. "/" .. name
|
||||
for _ = 1, 90 do
|
||||
if not pending then break end
|
||||
step(1)
|
||||
end
|
||||
step(3)
|
||||
local f = io.open(dir .. "/" .. name, "rb")
|
||||
U.log(f and "shot" or "FAIL shot", name)
|
||||
if f then f:close() end
|
||||
end
|
||||
local function press(id)
|
||||
local b = buttons[id]
|
||||
if not b then return false end
|
||||
imp:runActions({ { key = id, fn = b.opts.action, keepArm = b.opts.keepArm } })
|
||||
return true
|
||||
end
|
||||
|
||||
imp._findEntry = ENTRY
|
||||
step(4)
|
||||
local del = buttons["findpop-del"]
|
||||
expect(del ~= nil and del.label == "Delete", "find_popup_has_delete",
|
||||
del and del.label)
|
||||
expect(buttons["findpop-inst"] and buttons["findpop-inst"].label == "Reinstall",
|
||||
"find_popup_keeps_reinstall")
|
||||
shot("2367_01_find_popup_delete.png")
|
||||
|
||||
expect(press("findpop-del"), "find_delete_first_press")
|
||||
step(4)
|
||||
expect(buttons["findpop-del"] and buttons["findpop-del"].label == "Sure?",
|
||||
"find_delete_armed")
|
||||
expect(CartStore.get("wild_green") ~= nil, "find_first_press_keeps_cart")
|
||||
shot("2367_02_find_delete_armed.png")
|
||||
|
||||
expect(press("findpop-del"), "find_delete_second_press")
|
||||
step(4)
|
||||
expect(CartStore.get("wild_green") == nil, "find_delete_removed_cart")
|
||||
expect(imp._findEntry == nil and imp._cartPopup == nil, "find_popup_closed")
|
||||
expect(imp.findNotice and imp.findNotice.ok == true, "find_notice_ok")
|
||||
step(40)
|
||||
shot("2367_03_find_cart_deleted.png")
|
||||
|
||||
imp._findEntry = ENTRY
|
||||
step(4)
|
||||
expect(buttons["findpop-inst"] and buttons["findpop-inst"].label == "Install"
|
||||
and buttons["findpop-del"] == nil, "find_popup_back_to_install")
|
||||
imp._findEntry = nil
|
||||
|
||||
expect(installCart(), "cart_reinstalled")
|
||||
imp:_refreshCarts("red")
|
||||
imp.ready.red = true
|
||||
imp:_switchTab("red")
|
||||
imp._cartPopup = "red"
|
||||
step(4)
|
||||
local cp = buttons["cartpop-id-wild_green-delete"]
|
||||
expect(cp ~= nil, "cart_popup_has_delete")
|
||||
step(40)
|
||||
shot("2367_04_cart_popup_delete.png")
|
||||
press("cartpop-id-wild_green-delete")
|
||||
step(4)
|
||||
press("cartpop-id-wild_green-delete")
|
||||
step(4)
|
||||
expect(CartStore.get("wild_green") == nil, "cart_popup_delete_removed_cart")
|
||||
shot("2367_05_cart_popup_after.png")
|
||||
|
||||
Kit.button = realButton
|
||||
U.log("done")
|
||||
love.event.quit(failed and 1 or 0)
|
||||
end
|
||||
@@ -0,0 +1,203 @@
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Loader = require("src.mods.Loader")
|
||||
local Kit = require("src.ui.kit.Kit")
|
||||
|
||||
local dir = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR")
|
||||
or "/tmp/modorder2177"
|
||||
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
|
||||
local winW = tonumber(os.getenv("DRIVER_W")) or 1024
|
||||
local winH = tonumber(os.getenv("DRIVER_H")) or 768
|
||||
local tag = winW < 600 and "phone" or "desktop"
|
||||
love.window.setMode(winW, winH, { resizable = true, highdpi = true })
|
||||
U.wait(2)
|
||||
|
||||
local failed = false
|
||||
local function pass(label) print("PASS " .. label) end
|
||||
local function fail(label, why)
|
||||
failed = true
|
||||
print("FAIL " .. label .. (why and (": " .. tostring(why)) or ""))
|
||||
end
|
||||
local function expect(cond, label, why)
|
||||
if cond then pass(label) else fail(label, why) end
|
||||
end
|
||||
|
||||
local fs = love.filesystem
|
||||
local IDS = { "driver_order_a", "driver_order_b" }
|
||||
local NAMES = { driver_order_a = "AAAA", driver_order_b = "BBBB" }
|
||||
local function plant()
|
||||
for _, id in ipairs(IDS) do
|
||||
fs.createDirectory("mods/" .. id)
|
||||
fs.write("mods/" .. id .. "/manifest.json", ([[{"id":"%s","name":"Order %s",
|
||||
"version":"1.0.0","entry":"main.lua","priority":-100,
|
||||
"games":["red","blue","yellow"]}]]):format(id, NAMES[id]))
|
||||
fs.write("mods/" .. id .. "/main.lua", ([[
|
||||
return function(mod)
|
||||
mod.content.pokemon:override("PIKACHU", { name = "%s" })
|
||||
end
|
||||
]]):format(NAMES[id]))
|
||||
end
|
||||
end
|
||||
local function unplant()
|
||||
for _, id in ipairs(IDS) do
|
||||
fs.remove("mods/" .. id .. "/manifest.json")
|
||||
fs.remove("mods/" .. id .. "/main.lua")
|
||||
fs.remove("mods/" .. id)
|
||||
end
|
||||
end
|
||||
local function setOrder(list)
|
||||
local opts = SaveData.loadOptions()
|
||||
SaveData.setModOrder(opts, list)
|
||||
opts.modSort = nil
|
||||
SaveData.saveOptions(opts)
|
||||
end
|
||||
|
||||
unplant()
|
||||
plant()
|
||||
setOrder({})
|
||||
|
||||
local imp = RomImporter.new(function() end, { launcher = true })
|
||||
imp:_switchTab("mods")
|
||||
imp.modSort = "order"
|
||||
imp:_refreshMods()
|
||||
U.wait(3)
|
||||
|
||||
local buttons = {}
|
||||
local realButton = Kit.button
|
||||
Kit.button = function(x, y, w, h, label, opts)
|
||||
if opts and opts.id then
|
||||
buttons[opts.id] = { label = tostring(label), opts = opts, y = y }
|
||||
end
|
||||
return realButton(x, y, w, h, label, opts)
|
||||
end
|
||||
local pending = nil
|
||||
love.draw = function()
|
||||
buttons = {}
|
||||
imp:draw()
|
||||
if pending then
|
||||
local path = pending
|
||||
pending = nil
|
||||
love.graphics.captureScreenshot(function(imagedata)
|
||||
local f = io.open(path, "wb")
|
||||
if f then f:write(imagedata:encode("png"):getString()) f:close() end
|
||||
end)
|
||||
end
|
||||
end
|
||||
local function step(n)
|
||||
for _ = 1, n do
|
||||
imp:update(1 / 60)
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
local function shot(name)
|
||||
name = name:gsub("%.png$", "_" .. tag .. ".png")
|
||||
pending = dir .. "/" .. name
|
||||
for _ = 1, 90 do
|
||||
if not pending then break end
|
||||
step(1)
|
||||
end
|
||||
step(3)
|
||||
local f = io.open(dir .. "/" .. name, "rb")
|
||||
U.log(f and "shot" or "FAIL shot", name)
|
||||
if f then f:close() end
|
||||
end
|
||||
local function press(id)
|
||||
local b = buttons[id]
|
||||
if not b or b.opts.enabled == false then return false end
|
||||
imp:runActions({ { key = id, fn = b.opts.action, keepArm = b.opts.keepArm } })
|
||||
return true
|
||||
end
|
||||
local function visiblePos(id)
|
||||
for i, m in ipairs(imp.mods or {}) do
|
||||
local n = 0
|
||||
for _, o in ipairs(imp.mods) do
|
||||
if (o.loadRank or 0) < (m.loadRank or 0) then n = n + 1 end
|
||||
end
|
||||
if m.id == id then return n + 1 end
|
||||
end
|
||||
end
|
||||
local function savedBefore(a, b)
|
||||
local list = SaveData.modOrder(SaveData.loadOptions())
|
||||
local ia, ib
|
||||
for i, v in ipairs(list) do
|
||||
if v == a then ia = i end
|
||||
if v == b then ib = i end
|
||||
end
|
||||
return ia and ib and ia < ib
|
||||
end
|
||||
local function realBoot()
|
||||
local proxy = setmetatable({
|
||||
getDirectoryItems = function(path)
|
||||
if path == "mods" then return { IDS[1], IDS[2] } end
|
||||
return fs.getDirectoryItems(path)
|
||||
end,
|
||||
}, { __index = fs })
|
||||
local base = game and game.data and game.data.pokemon
|
||||
and game.data.pokemon.PIKACHU
|
||||
local copy = {}
|
||||
for k, v in pairs(base or { name = "PIKACHU" }) do copy[k] = v end
|
||||
local data = { pokemon = { PIKACHU = copy } }
|
||||
local loader = Loader.new({ fs = proxy })
|
||||
local ok, err = pcall(loader.load, loader, data)
|
||||
if not ok then return nil, nil, err end
|
||||
return table.concat(loader.order or {}, ","), data.pokemon.PIKACHU.name
|
||||
end
|
||||
|
||||
step(4)
|
||||
expect(visiblePos(IDS[1]) == 1 and visiblePos(IDS[2]) == 2,
|
||||
"initial_order_by_priority_then_id",
|
||||
tostring(visiblePos(IDS[1])) .. "," .. tostring(visiblePos(IDS[2])))
|
||||
expect(buttons["mod-up-driver_order_a"] and buttons["mod-down-driver_order_a"],
|
||||
"row_has_up_down")
|
||||
expect(buttons["mod-up-driver_order_a"]
|
||||
and buttons["mod-up-driver_order_a"].opts.enabled == false,
|
||||
"first_row_up_disabled")
|
||||
local order0, name0 = realBoot()
|
||||
expect(order0 == "driver_order_a,driver_order_b" and name0 == "BBBB",
|
||||
"loader_default_b_last_wins", tostring(order0) .. " " .. tostring(name0))
|
||||
shot("2177_01_mods_sorted_by_load_order.png")
|
||||
|
||||
expect(press("mod-down-driver_order_a"), "press_down_on_a")
|
||||
step(4)
|
||||
expect(savedBefore(IDS[2], IDS[1]), "saved_order_b_before_a")
|
||||
expect(visiblePos(IDS[1]) == 2, "list_shows_a_second")
|
||||
expect(imp.modNotice and imp.modNotice.ok
|
||||
and tostring(imp.modNotice.text):find("position 2", 1, true),
|
||||
"notice_moved_to_position", imp.modNotice and imp.modNotice.text)
|
||||
local order1, name1 = realBoot()
|
||||
expect(order1 == "driver_order_b,driver_order_a" and name1 == "AAAA",
|
||||
"loader_follows_player_order_a_wins", tostring(order1) .. " " .. tostring(name1))
|
||||
shot("2177_02_a_moved_down.png")
|
||||
|
||||
imp._modActions = IDS[1]
|
||||
step(4)
|
||||
expect(buttons["modact-up"] and buttons["modact-down"]
|
||||
and buttons["modact-top"] and buttons["modact-bottom"],
|
||||
"details_has_move_buttons")
|
||||
expect(buttons["modact-top"] and buttons["modact-top"].opts.enabled ~= false,
|
||||
"details_move_to_top_enabled")
|
||||
shot("2177_03_details_move_buttons.png")
|
||||
expect(press("modact-top"), "press_move_to_top")
|
||||
step(4)
|
||||
imp._modActions = nil
|
||||
expect(savedBefore(IDS[1], IDS[2]), "saved_order_a_first_again")
|
||||
local order2, name2 = realBoot()
|
||||
expect(order2 == "driver_order_a,driver_order_b" and name2 == "BBBB",
|
||||
"loader_swapped_back_b_wins", tostring(order2) .. " " .. tostring(name2))
|
||||
|
||||
imp._sortPopup = "mods"
|
||||
step(4)
|
||||
expect(buttons["sortpop-order"] ~= nil and buttons["sortpop-reset-order"] ~= nil,
|
||||
"sort_modal_offers_load_order_and_reset")
|
||||
expect(press("sortpop-reset-order"), "press_reset_order")
|
||||
step(4)
|
||||
expect(#SaveData.modOrder(SaveData.loadOptions()) == 0, "reset_clears_saved_order")
|
||||
|
||||
Kit.button = realButton
|
||||
unplant()
|
||||
setOrder({})
|
||||
U.log("done")
|
||||
love.event.quit(failed and 1 or 0)
|
||||
end
|
||||
@@ -0,0 +1,149 @@
|
||||
-- engine/events/pokemart.asm:159-169
|
||||
-- engine/events/pokemart.asm:84-91
|
||||
-- home/list_menu.asm:89-91
|
||||
-- POKEPORT_DRIVER=tests/drivers/mart_price_text_bug2291.lua POKEPORT_IDENTITY=bsa0922-2291 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Font = require("src.render.Font")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local QuantityBox = require("src.ui.QuantityBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local failures = 0
|
||||
local function check(label, ok)
|
||||
if not ok then failures = failures + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
local function top() return game.stack:top() end
|
||||
local function topIs(cls) return getmetatable(top()) == cls end
|
||||
local function onStack(cls)
|
||||
for _, s in ipairs(game.stack.states or {}) do
|
||||
if getmetatable(s) == cls then return s end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local function waitFor(pred, frames)
|
||||
for _ = 1, frames or 300 do
|
||||
if pred() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return pred()
|
||||
end
|
||||
local function finish()
|
||||
U.log(failures == 0 and "PASS mart_price_text_bug2291"
|
||||
or ("FAIL mart_price_text_bug2291 (" .. failures .. " failures)"))
|
||||
love.event.quit(failures == 0 and 0 or 1)
|
||||
U.wait(600)
|
||||
end
|
||||
|
||||
local ok, err = pcall(function()
|
||||
game.save.money = 3000
|
||||
game.save.inventory = game.save.inventory or {}
|
||||
Bag.add(game.save, "POKE_BALL", 5, game.data)
|
||||
U.teleport(game, "PEWTER_MART", 2, 5, "left")
|
||||
U.wait(10)
|
||||
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 60 do
|
||||
if topIs(Menu) then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
if not check("the mart menu opens", topIs(Menu)) then return end
|
||||
local menu = top()
|
||||
|
||||
U.tap(game, "a")
|
||||
if not check("BUY opens the list", waitFor(function() return topIs(ListMenu) end, 60)) then return end
|
||||
local list = top()
|
||||
local itemId = list.items[list.index].value
|
||||
local def = game.data.items[itemId]
|
||||
local name = def.name
|
||||
U.wait(4)
|
||||
U.tap(game, "a")
|
||||
if not check("the quantity box opens", waitFor(function() return topIs(QuantityBox) end, 30)) then return end
|
||||
check("the chosen row shows the hollow cursor", list.hollowIndex == list.index)
|
||||
U.wait(4)
|
||||
U.tap(game, "a")
|
||||
check("the price line opens as a text box, not YES/NO",
|
||||
waitFor(function() return topIs(TextBox) end, 2) and not topIs(ChoiceBox))
|
||||
local box = top()
|
||||
check("the price text box carries the YES/NO", box.choice ~= nil)
|
||||
local lines = box.pages and box.pages[1] or {}
|
||||
check("line 1 names the item: " .. tostring(lines[1]), lines[1] == name .. "?")
|
||||
U.wait(8)
|
||||
local typed = box.shown and box.shown[1] and #box.shown[1] or 0
|
||||
check(("the item name is typing (%d of %d glyphs)"):format(typed, #Font.encode(name .. "?")),
|
||||
not box.done and typed > 0 and typed < #Font.encode(name .. "?"))
|
||||
U.shot(game, DIR .. "/2291_01_name_typing.png")
|
||||
|
||||
check("the price line reaches the arrow wait",
|
||||
waitFor(function() return box.waiting and (box.preWait or 0) == 0 end, 300))
|
||||
local vis = box:visibleText() or {}
|
||||
check("arrow wait shows the item name", vis[1] == name .. "?")
|
||||
check("arrow wait shows That will be", vis[2] == "That will be")
|
||||
check("no YES/NO at the arrow wait", top() == box and not onStack(ChoiceBox))
|
||||
check("the quantity box stays up under the text", onStack(QuantityBox) ~= nil)
|
||||
check("the list row stays hollow under the text", list.hollowIndex == list.index)
|
||||
U.shot(game, DIR .. "/2291_02_name_and_cont_arrow.png")
|
||||
|
||||
U.tap(game, "a")
|
||||
check("YES/NO opens after the price finishes typing",
|
||||
waitFor(function() return topIs(ChoiceBox) end, 300))
|
||||
check("the text box was done before YES/NO", box.done == true)
|
||||
local last = box:visibleText() or {}
|
||||
check("price text above YES/NO: " .. tostring(last[2]),
|
||||
last[1] == "That will be" and tostring(last[2]):find("OK?", 1, true) ~= nil)
|
||||
check("the quantity box is still drawn under YES/NO", onStack(QuantityBox) ~= nil)
|
||||
U.wait(4)
|
||||
U.shot(game, DIR .. "/2291_03_price_yesno_over_qty.png")
|
||||
|
||||
U.tap(game, "b")
|
||||
check("NO returns to the buy list", waitFor(function() return top() == list end, 30))
|
||||
check("NO closes the quantity box", onStack(QuantityBox) == nil)
|
||||
check("the list cursor is filled again", waitFor(function() return list.hollowIndex == nil end, 5))
|
||||
check("NO buys nothing", game.save.money == 3000)
|
||||
|
||||
U.tap(game, "b")
|
||||
if not check("B returns to BUY/SELL/QUIT", waitFor(function() return top() == menu end, 30)) then return end
|
||||
U.wait(4)
|
||||
U.tap(game, "down")
|
||||
U.wait(4)
|
||||
U.tap(game, "a")
|
||||
if not check("SELL opens the sell list", waitFor(function() return topIs(ListMenu) end, 60)) then return end
|
||||
local sellList = top()
|
||||
for _ = 1, 20 do
|
||||
local row = sellList.items[sellList.index]
|
||||
if row and row.value == "POKE_BALL" then break end
|
||||
U.tap(game, "down")
|
||||
U.wait(4)
|
||||
end
|
||||
U.wait(4)
|
||||
U.tap(game, "a")
|
||||
if not check("the sell quantity box opens", waitFor(function() return topIs(QuantityBox) end, 30)) then return end
|
||||
check("the sold row shows the hollow cursor", sellList.hollowIndex == sellList.index)
|
||||
U.wait(4)
|
||||
U.tap(game, "a")
|
||||
check("the sell price opens as a text box, not YES/NO",
|
||||
waitFor(function() return topIs(TextBox) end, 2) and not topIs(ChoiceBox))
|
||||
local sellBox = top()
|
||||
U.wait(6)
|
||||
check("the sell price is typing", sellBox.done == false)
|
||||
check("sell YES/NO opens after the text", waitFor(function() return topIs(ChoiceBox) end, 300))
|
||||
check("the sell quantity box is still drawn under YES/NO", onStack(QuantityBox) ~= nil)
|
||||
U.wait(4)
|
||||
U.shot(game, DIR .. "/2291_04_sell_yesno_over_qty.png")
|
||||
U.tap(game, "b")
|
||||
check("sell NO returns to the sell list", waitFor(function() return top() == sellList end, 30))
|
||||
check("sell NO keeps the items", (game.save.inventory.POKE_BALL or 0) == 5)
|
||||
end)
|
||||
if not ok then
|
||||
failures = failures + 1
|
||||
U.log("FAIL driver error:", tostring(err))
|
||||
end
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,221 @@
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local HostPicker = require("src.core.HostPicker")
|
||||
local Platform = require("src.core.Platform")
|
||||
|
||||
local dir = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR")
|
||||
or "/tmp/modpicker2292"
|
||||
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
|
||||
love.window.setMode(1024, 768, { resizable = true, highdpi = true })
|
||||
U.wait(2)
|
||||
|
||||
local failed = false
|
||||
local function expect(cond, label, why)
|
||||
if cond then
|
||||
print("PASS " .. label)
|
||||
else
|
||||
failed = true
|
||||
print("FAIL " .. label .. (why and (": " .. tostring(why)) or ""))
|
||||
end
|
||||
end
|
||||
|
||||
local MOD_ID = "picker_probe_2292"
|
||||
local zipPath = dir .. "/" .. MOD_ID .. ".zip"
|
||||
local stage = dir .. "/" .. MOD_ID
|
||||
os.remove(zipPath)
|
||||
os.execute('rm -rf "' .. stage .. '" && mkdir -p "' .. stage .. '"')
|
||||
local mf = io.open(stage .. "/manifest.json", "wb")
|
||||
if mf then
|
||||
mf:write('{"id":"' .. MOD_ID .. '","name":"Picker Probe","version":"1.0.0",'
|
||||
.. '"entry":"main.lua","category":"GAMEPLAY","games":["gen1"],'
|
||||
.. '"description":"Installed through the async mod picker."}')
|
||||
mf:close()
|
||||
end
|
||||
local ml = io.open(stage .. "/main.lua", "wb")
|
||||
if ml then ml:write("return {}\n") ml:close() end
|
||||
os.execute('cd "' .. stage .. '" && zip -qr "' .. zipPath .. '" . 2>/dev/null')
|
||||
os.execute('rm -rf "' .. stage .. '"')
|
||||
local zf = io.open(zipPath, "rb")
|
||||
expect(zf ~= nil, "fixture_zip_built", zipPath)
|
||||
if zf then zf:close() end
|
||||
|
||||
local realGetOS = love.system.getOS
|
||||
local realStart = HostPicker.start
|
||||
local realPickFile = love.system.pickFile
|
||||
local starts, captured = 0, {}
|
||||
local fakeCommand = nil
|
||||
HostPicker.start = function(commands)
|
||||
starts = starts + 1
|
||||
captured = commands
|
||||
return realStart({ fakeCommand })
|
||||
end
|
||||
|
||||
local function newLauncher()
|
||||
local imp = RomImporter.new(function() end, { launcher = true })
|
||||
imp.safeMode = false
|
||||
imp._refreshFindSources = function(self) self.findSources = {} end
|
||||
imp._refreshFind = function() end
|
||||
imp._pumpFindFetch = function() end
|
||||
imp._findFetch = nil
|
||||
imp.findLoaded = true
|
||||
imp.findIndex = { mods = {}, carts = {} }
|
||||
imp:_switchTab("mods")
|
||||
return imp
|
||||
end
|
||||
|
||||
local imp
|
||||
local frames = 0
|
||||
local pending = nil
|
||||
love.draw = function()
|
||||
if imp then imp:draw() end
|
||||
if pending then
|
||||
local path = pending
|
||||
pending = nil
|
||||
love.graphics.captureScreenshot(function(imagedata)
|
||||
local f = io.open(path, "wb")
|
||||
if f then f:write(imagedata:encode("png"):getString()) f:close() end
|
||||
end)
|
||||
end
|
||||
end
|
||||
local function step(n)
|
||||
for _ = 1, n do
|
||||
if imp then imp:update(1 / 60) end
|
||||
frames = frames + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
local function shot(name)
|
||||
pending = dir .. "/" .. name
|
||||
for _ = 1, 30 do
|
||||
if not pending then break end
|
||||
step(1)
|
||||
end
|
||||
step(2)
|
||||
local f = io.open(dir .. "/" .. name, "rb")
|
||||
U.log(f and "shot" or "FAIL shot", name)
|
||||
if f then f:close() end
|
||||
end
|
||||
local function clickImportAsWindows()
|
||||
love.system.getOS = function() return "Windows" end
|
||||
Platform._resetForTests()
|
||||
local t0 = love.timer.getTime()
|
||||
imp:chooseMod()
|
||||
local spent = love.timer.getTime() - t0
|
||||
love.system.getOS = realGetOS
|
||||
Platform._resetForTests()
|
||||
return spent
|
||||
end
|
||||
|
||||
imp = newLauncher()
|
||||
step(20)
|
||||
|
||||
fakeCommand = "sleep 3; printf '%s' '" .. zipPath .. "'"
|
||||
local spent = clickImportAsWindows()
|
||||
expect(spent < 0.5, "click_returns_immediately", ("%.3fs"):format(spent))
|
||||
expect(imp._hostPick ~= nil, "picker_pending_same_frame")
|
||||
expect(starts == 1, "one_worker_started", starts)
|
||||
local cmd = captured and captured[1] or ""
|
||||
expect(cmd:find("$o.TopMost=$true;", 1, true) ~= nil
|
||||
and cmd:find("ShowDialog($o)", 1, true) ~= nil, "windows_dialog_owned_topmost")
|
||||
local before = frames
|
||||
step(30)
|
||||
clickImportAsWindows()
|
||||
expect(starts == 1, "second_click_ignored_while_open", starts)
|
||||
shot("2292_01_waiting_for_picker.png")
|
||||
expect(imp._hostPick ~= nil and frames - before >= 30,
|
||||
"launcher_keeps_running_while_dialog_open", frames - before)
|
||||
expect(imp.modNotice and tostring(imp.modNotice.text):find("file dialog", 1, true) ~= nil
|
||||
and tostring(imp.modNotice.text):find("taskbar", 1, true) == nil,
|
||||
"waiting_notice_shown", imp.modNotice and imp.modNotice.text)
|
||||
local clicked = 0
|
||||
imp:runActions({ { key = "play-red", fn = function() clicked = clicked + 1 end } })
|
||||
expect(clicked == 0, "launcher_clicks_blocked_while_open", clicked)
|
||||
imp:play("red", true)
|
||||
expect(imp._launchFade == nil, "play_blocked_while_open")
|
||||
for _ = 1, 600 do
|
||||
if not imp._hostPick then break end
|
||||
step(1)
|
||||
end
|
||||
expect(imp._hostPick == nil, "pick_answer_consumed")
|
||||
expect(frames - before >= 150, "frames_advanced_through_dialog", frames - before)
|
||||
expect(imp.modNotice and imp.modNotice.ok == true
|
||||
and tostring(imp.modNotice.text):find("Installed", 1, true) ~= nil,
|
||||
"picked_mod_installed", imp.modNotice and imp.modNotice.text)
|
||||
local listed = false
|
||||
for _, row in ipairs(imp.mods or {}) do
|
||||
if row.id == MOD_ID then listed = true end
|
||||
end
|
||||
expect(listed, "mod_in_list")
|
||||
step(20)
|
||||
shot("2292_02_mod_installed_from_picker.png")
|
||||
|
||||
fakeCommand = "sleep 1"
|
||||
clickImportAsWindows()
|
||||
expect(imp._hostPick ~= nil, "cancel_run_pending")
|
||||
for _ = 1, 300 do
|
||||
if not imp._hostPick then break end
|
||||
step(1)
|
||||
end
|
||||
expect(imp._hostPick == nil and imp.modNotice == nil, "cancel_clears_picker")
|
||||
step(10)
|
||||
shot("2292_03_cancel_back_to_mods.png")
|
||||
|
||||
pcall(function() require("src.mods.LauncherMods").uninstall(MOD_ID) end)
|
||||
|
||||
love.filesystem.write("stray_2292.zip", "PK\3\4 not a mod")
|
||||
imp = newLauncher()
|
||||
imp.android = true
|
||||
local pickCalls = 0
|
||||
love.system.pickFile = function() pickCalls = pickCalls + 1 return true end
|
||||
love.system.getOS = function() return "Android" end
|
||||
imp:chooseMod()
|
||||
love.system.getOS = realGetOS
|
||||
expect(pickCalls == 1, "android_stray_zip_not_installed_picker_opened", pickCalls)
|
||||
expect(love.filesystem.getInfo("stray_2292.zip") ~= nil, "android_stray_zip_left_alone")
|
||||
step(200)
|
||||
expect(imp.modNotice == nil, "android_no_notice_before_4s")
|
||||
step(60)
|
||||
expect(imp.modNotice and imp.modNotice.ok == false
|
||||
and tostring(imp.modNotice.text):find("did not open", 1, true) ~= nil
|
||||
and tostring(imp.modNotice.text):find("imports/mods", 1, true) ~= nil,
|
||||
"android_picker_did_not_open_notice", imp.modNotice and imp.modNotice.text)
|
||||
step(10)
|
||||
shot("2292_04_android_picker_did_not_open.png")
|
||||
|
||||
love.filesystem.createDirectory("imports/mods")
|
||||
local inboxZip = "imports/mods/" .. MOD_ID .. ".zip"
|
||||
local zr = io.open(zipPath, "rb")
|
||||
local bytes = zr and zr:read("*a") or ""
|
||||
if zr then zr:close() end
|
||||
love.filesystem.write(inboxZip, bytes)
|
||||
pickCalls = 0
|
||||
love.system.getOS = function() return "Android" end
|
||||
imp:chooseMod()
|
||||
love.system.getOS = realGetOS
|
||||
expect(pickCalls == 0 and imp._modInboxInstall ~= nil, "android_inbox_install_queued", pickCalls)
|
||||
expect(imp.modNotice and tostring(imp.modNotice.text):find("Installing", 1, true) ~= nil,
|
||||
"android_inbox_notice_before_install", imp.modNotice and imp.modNotice.text)
|
||||
pending = dir .. "/2292_05_android_installing_from_inbox.png"
|
||||
step(1)
|
||||
local f5 = io.open(dir .. "/2292_05_android_installing_from_inbox.png", "rb")
|
||||
U.log(f5 and "shot" or "FAIL shot", "2292_05_android_installing_from_inbox.png")
|
||||
if f5 then f5:close() end
|
||||
step(3)
|
||||
local inboxListed = false
|
||||
for _, row in ipairs(imp.mods or {}) do
|
||||
if row.id == MOD_ID then inboxListed = true end
|
||||
end
|
||||
expect(imp._modInboxInstall == nil and inboxListed, "android_inbox_installed_next_frame",
|
||||
imp.modNotice and imp.modNotice.text)
|
||||
love.filesystem.remove(inboxZip)
|
||||
pcall(function() require("src.mods.LauncherMods").uninstall(MOD_ID) end)
|
||||
|
||||
love.filesystem.remove("stray_2292.zip")
|
||||
love.system.pickFile = realPickFile
|
||||
HostPicker.start = realStart
|
||||
imp = nil
|
||||
os.remove(zipPath)
|
||||
U.log("done")
|
||||
love.event.quit(failed and 1 or 0)
|
||||
end
|
||||
@@ -0,0 +1,205 @@
|
||||
-- scripts/PewterPokecenter_2.asm:10-68
|
||||
-- engine/pikachu/pikachu_emotions.asm:318
|
||||
-- engine/pikachu/pikachu_movement.asm:973
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local MAP = "PEWTER_POKECENTER"
|
||||
local PUFF = "PEWTERPOKECENTER_JIGGLYPUFF"
|
||||
local failures = 0
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
if not ok then failures = failures + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
local function finish()
|
||||
U.log(failures == 0 and "ALL PASS" or ("DONE " .. failures .. " check(s) failed"))
|
||||
love.event.quit(failures == 0 and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
if not check("2335_yellow_cache", GameVersion.isYellow()) then finish() end
|
||||
|
||||
local starter = Pokemon.new(game.data, "PIKACHU", 12)
|
||||
require("src.battle.BattleState").stampOT(game.save, starter)
|
||||
game.save.party = { starter }
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
|
||||
game.save.pikachuInBall = false
|
||||
|
||||
U.teleport(game, MAP, 2, 3, "left")
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
|
||||
local function find(pred)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if pred(n) then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local puff = find(function(n) return n.def and n.def.name == PUFF end)
|
||||
local pika = find(function(n) return n.pikachuFollower end)
|
||||
check("2335_puff_loaded", puff ~= nil)
|
||||
if not check("2335_follower_spawned", pika ~= nil) then finish() end
|
||||
U.log("player", ow.player.cellX, ow.player.cellY, "pikachu", pika.cellX, pika.cellY)
|
||||
|
||||
local function facingNpc(n)
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow:npcAtCell(fx, fy) == n
|
||||
end
|
||||
if not check("2335_facing_puff", facingNpc(puff)) then finish() end
|
||||
|
||||
local function boxOnTop()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == TextBox then return top end
|
||||
return nil
|
||||
end
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
local box = boxOnTop()
|
||||
if not check("2335_dance_box_open", box ~= nil and box.auto ~= nil) then finish() end
|
||||
|
||||
local turns, frames, last = 0, 0, puff.facing
|
||||
local shotStart, shotMid = false, false
|
||||
while boxOnTop() == box and frames < 1500 do
|
||||
U.wait(1)
|
||||
frames = frames + 1
|
||||
if puff.facing ~= last then
|
||||
turns = turns + 1
|
||||
last = puff.facing
|
||||
if turns == 1 and not shotStart then
|
||||
shotStart = true
|
||||
U.shot(game, DIR .. "/2335_01_song_first_turn.png")
|
||||
elseif turns == 8 and not shotMid then
|
||||
shotMid = true
|
||||
U.shot(game, DIR .. "/2335_02_mid_dance_still_boxed.png")
|
||||
end
|
||||
end
|
||||
end
|
||||
U.log("dance: turns", turns, "frames after open", frames)
|
||||
check("2335_box_closed_itself", boxOnTop() ~= box)
|
||||
check("2335_four_spins", turns >= 14)
|
||||
check("2335_sleep_flag_set", ow.pikachuPewterSleepScene == true)
|
||||
check("2335_following_disabled", PikachuFollower.isFollowingDisabled(ow))
|
||||
U.wait(4)
|
||||
U.shot(game, DIR .. "/2335_03_box_closed_pikachu_asleep.png")
|
||||
|
||||
local function step(dir)
|
||||
local p = ow.player
|
||||
local x, y = p.cellX, p.cellY
|
||||
for _ = 1, 30 do
|
||||
U.hold(game, dir, 1)
|
||||
if p.moving then break end
|
||||
end
|
||||
for _ = 1, 40 do
|
||||
if not p.moving then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(4)
|
||||
return p.cellX ~= x or p.cellY ~= y
|
||||
end
|
||||
|
||||
local px, py = pika.cellX, pika.cellY
|
||||
local route = { "down", "down", "right", "right", "right" }
|
||||
local walked = 0
|
||||
for _, d in ipairs(route) do
|
||||
if step(d) then walked = walked + 1 end
|
||||
end
|
||||
U.log("walked", walked, "player", ow.player.cellX, ow.player.cellY,
|
||||
"pikachu", pika.cellX, pika.cellY)
|
||||
check("2335_player_walked_away", walked >= 4)
|
||||
check("2335_pikachu_stayed_put", pika.cellX == px and pika.cellY == py)
|
||||
U.shot(game, DIR .. "/2335_04_walked_away_pikachu_stays.png")
|
||||
|
||||
local DIRS = { { "up", 0, -1 }, { "down", 0, 1 }, { "left", -1, 0 }, { "right", 1, 0 } }
|
||||
local function free(x, y)
|
||||
return ow.map:inBounds(x, y) and ow.map:isWalkableCell(x, y) and not ow:npcAtCell(x, y)
|
||||
end
|
||||
local function pathTo(goal)
|
||||
local sx, sy = ow.player.cellX, ow.player.cellY
|
||||
local key = function(x, y) return x .. "," .. y end
|
||||
local prev, queue, head = { [key(sx, sy)] = false }, { { sx, sy } }, 1
|
||||
while queue[head] do
|
||||
local c = queue[head]
|
||||
head = head + 1
|
||||
if goal(c[1], c[2]) then
|
||||
local path, k = {}, key(c[1], c[2])
|
||||
while prev[k] do
|
||||
table.insert(path, 1, prev[k].dir)
|
||||
k = prev[k].from
|
||||
end
|
||||
return path
|
||||
end
|
||||
for _, d in ipairs(DIRS) do
|
||||
local nx, ny = c[1] + d[2], c[2] + d[3]
|
||||
local nk = key(nx, ny)
|
||||
if prev[nk] == nil and free(nx, ny) then
|
||||
prev[nk] = { dir = d[1], from = key(c[1], c[2]) }
|
||||
queue[#queue + 1] = { nx, ny }
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local faceDir
|
||||
local path = pathTo(function(x, y)
|
||||
for _, d in ipairs(DIRS) do
|
||||
if x + d[2] == px and y + d[3] == py then faceDir = d[1] return true end
|
||||
end
|
||||
return false
|
||||
end)
|
||||
if path then
|
||||
for _, d in ipairs(path) do step(d) end
|
||||
if ow.player.facing ~= faceDir then U.tap(game, faceDir) U.wait(8) end
|
||||
end
|
||||
if not check("2335_facing_sleeping_pikachu", facingNpc(pika)) then
|
||||
U.log("player", ow.player.cellX, ow.player.cellY, ow.player.facing,
|
||||
"pikachu", pika.cellX, pika.cellY)
|
||||
finish()
|
||||
end
|
||||
check("2335_still_asleep_before_talk", ow.pikachuPewterSleepScene == true)
|
||||
|
||||
U.tap(game, "a")
|
||||
local sawBubble, sawPic, picPath = false, false, nil
|
||||
for _ = 1, 600 do
|
||||
local e = ow.emote
|
||||
if e and e.bubble and not sawBubble then
|
||||
sawBubble = true
|
||||
U.shot(game, DIR .. "/2335_05_zzz_bubble.png")
|
||||
elseif e and e.pikaPic and not sawPic then
|
||||
sawPic, picPath = true, e.pikaPic
|
||||
U.wait(20)
|
||||
U.shot(game, DIR .. "/2335_06_pikapic_wake_face.png")
|
||||
end
|
||||
if sawPic and not ow.emote then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.log("pikapic:", tostring(picPath))
|
||||
check("2335_zzz_bubble_shown", sawBubble)
|
||||
check("2335_pikapic_26_shown", picPath ~= nil and picPath:find("pikapic_26", 1, true) ~= nil)
|
||||
U.wait(10)
|
||||
check("2335_sleep_flag_cleared", ow.pikachuPewterSleepScene == nil)
|
||||
check("2335_following_reenabled", not PikachuFollower.isFollowingDisabled(ow))
|
||||
|
||||
local qx, qy = pika.cellX, pika.cellY
|
||||
local moved = 0
|
||||
for _, d in ipairs({ "down", "down" }) do
|
||||
if step(d) then moved = moved + 1 end
|
||||
end
|
||||
U.wait(20)
|
||||
U.log("after wake: player", ow.player.cellX, ow.player.cellY, "pikachu", pika.cellX, pika.cellY)
|
||||
check("2335_pikachu_follows_again",
|
||||
moved > 0 and (pika.cellX ~= qx or pika.cellY ~= qy))
|
||||
U.shot(game, DIR .. "/2335_07_pikachu_follows_again.png")
|
||||
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,153 @@
|
||||
-- pokeyellow engine/menus/save.asm:260
|
||||
-- pokeyellow engine/pikachu/pikachu_pic_animation.asm:1
|
||||
-- pokeyellow engine/events/poison.asm:137
|
||||
-- POKEPORT_DRIVER=tests/drivers/pikachu_mood_import_bug2344_test.lua POKEPORT_IDENTITY=bug2344 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local SaveConvert = require("src.save_convert.SaveConvert")
|
||||
local GenSave = require("src.save_convert.GenSave")
|
||||
|
||||
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" }
|
||||
local failures = 0
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
if not ok then failures = failures + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
local function quit()
|
||||
U.log(failures == 0 and "ALL PASS" or ("DONE " .. failures .. " check(s) failed"))
|
||||
love.event.quit(failures == 0 and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
if not check("2344_yellow_cache", GameVersion.isYellow()) then quit() end
|
||||
|
||||
game.save.player.name = "bryan"
|
||||
local pika = Pokemon.new(game.data, "PIKACHU", 12)
|
||||
BattleState.stampOT(game.save, pika)
|
||||
game.save.party = { pika }
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
|
||||
game.save.pikachuInBall = false
|
||||
game.save.pikachuHappiness = 120
|
||||
game.save.pikachuMood = 0x6c
|
||||
game.save.pikachuEmotionModifier = nil
|
||||
|
||||
local bytes, err = SaveConvert.exportSav(game.save, "yellow")
|
||||
if not check("2344_export_ok", bytes ~= nil) then
|
||||
U.log("export error:", tostring(err))
|
||||
quit()
|
||||
end
|
||||
check("2344_export_mood_byte", bytes:byte(GenSave.OFFSETS.pikachuMood + 1) == 0x6c)
|
||||
local imported, ierr = SaveConvert.importSav(bytes, nil, "yellow")
|
||||
if not check("2344_import_ok", imported ~= nil) then
|
||||
U.log("import error:", tostring(ierr))
|
||||
quit()
|
||||
end
|
||||
check("2344_import_mood", imported.pikachuMood == 0x6c)
|
||||
check("2344_import_happiness", imported.pikachuHappiness == 120)
|
||||
U.teleport(game, "ROUTE_1", 5, 20, "up")
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
|
||||
local function follower()
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.pikachuFollower then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local npc = follower()
|
||||
if not check("2344_follower_spawned", npc ~= nil) then quit() end
|
||||
|
||||
local DIRS = { { "up", 0, -1 }, { "left", -1, 0 },
|
||||
{ "right", 1, 0 }, { "down", 0, 1 } }
|
||||
local function faceFollower()
|
||||
for _, d in ipairs(DIRS) do
|
||||
if npc.cellX == ow.player.cellX + d[2]
|
||||
and npc.cellY == ow.player.cellY + d[3] then
|
||||
if ow.player.facing ~= d[1] then
|
||||
U.tap(game, d[1])
|
||||
U.wait(6)
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow:npcAtCell(fx, fy) == npc
|
||||
end
|
||||
|
||||
local stepDir
|
||||
for _, d in ipairs(DIRS) do
|
||||
local cx, cy = ow.player.cellX + d[2], ow.player.cellY + d[3]
|
||||
if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy)
|
||||
and not ow:npcAtCell(cx, cy) then
|
||||
stepDir = d[1]
|
||||
break
|
||||
end
|
||||
end
|
||||
if not check("2344_step_room", stepDir ~= nil) then quit() end
|
||||
local function stepAndBack()
|
||||
U.hold(game, stepDir, 24)
|
||||
U.wait(6)
|
||||
U.tap(game, OPPOSITE[stepDir])
|
||||
U.wait(6)
|
||||
end
|
||||
|
||||
local function talk(wantPic, shotName)
|
||||
if not faceFollower() then
|
||||
check("2344_facing_follower_" .. wantPic, false)
|
||||
return
|
||||
end
|
||||
for _ = 1, 5 do
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 60 do
|
||||
if ow.emote then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if ow.emote then break end
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if ow.emote and ow.emote.pikaPic then break end
|
||||
U.wait(1)
|
||||
end
|
||||
local pic = ow.emote and ow.emote.pikaPic or ""
|
||||
U.log("pikapic:", pic)
|
||||
check("2344_pic_" .. wantPic, pic:find(wantPic, 1, true) ~= nil)
|
||||
U.wait(20)
|
||||
U.shot(game, SHOT_DIR .. "/" .. shotName)
|
||||
for _ = 1, 600 do
|
||||
if not ow.emote then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(10)
|
||||
end
|
||||
|
||||
stepAndBack()
|
||||
game.save.pikachuHappiness = imported.pikachuHappiness
|
||||
game.save.pikachuMood = imported.pikachuMood
|
||||
game.save.pikachuEmotionModifier = imported.pikachuEmotionModifier
|
||||
U.log("imported happiness", tostring(imported.pikachuHappiness),
|
||||
"mood", tostring(imported.pikachuMood))
|
||||
talk("pikapic_3.png", "2344_01_mood_low_frown.png")
|
||||
check("2344_mood_untouched_by_talk", game.save.pikachuMood == 0x6c)
|
||||
|
||||
game.save.pikachuMood = 128
|
||||
talk("pikapic_1.png", "2344_02_mood_neutral_smile.png")
|
||||
|
||||
game.save.pikachuMood = 0x81
|
||||
game.save.pikachuEmotionModifier = 2
|
||||
talk("pikapic_21.png", "2344_03_fishing_modifier.png")
|
||||
check("2344_modifier_survives_talk", game.save.pikachuEmotionModifier == 2)
|
||||
stepAndBack()
|
||||
check("2344_modifier_clears_at_128",
|
||||
game.save.pikachuMood == 128 and game.save.pikachuEmotionModifier == nil)
|
||||
|
||||
quit()
|
||||
end
|
||||
@@ -0,0 +1,299 @@
|
||||
-- pokeyellow engine/pokemon/evos_moves.asm:366
|
||||
-- pokeyellow data/pikachu/pikachu_pic_animation.asm:281
|
||||
-- pokeyellow engine/pikachu/pikachu_pic_animation.asm:790
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Music = require("src.core.Music")
|
||||
|
||||
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" }
|
||||
local failures = 0
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
if not ok then failures = failures + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
local startMode = PaletteFX.mode
|
||||
local function quit()
|
||||
if PaletteFX.mode ~= startMode then PaletteFX.setMode(startMode) end
|
||||
U.log(failures == 0 and "ALL PASS" or ("DONE " .. failures .. " check(s) failed"))
|
||||
love.event.quit(failures == 0 and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
if not check("2347_yellow_cache", GameVersion.isYellow()) then quit() end
|
||||
if PaletteFX.usesGbcPack() then PaletteFX.setMode("gbc") end
|
||||
|
||||
local moveSounds, ducks = {}, 0
|
||||
local realPlayMove, realDuck = Sound.playMove, Music.duckForFanfare
|
||||
Sound.playMove = function(data, anim)
|
||||
moveSounds[#moveSounds + 1] = anim and anim.sound or "?"
|
||||
return realPlayMove(data, anim)
|
||||
end
|
||||
Music.duckForFanfare = function(src)
|
||||
ducks = ducks + 1
|
||||
return realDuck(src)
|
||||
end
|
||||
|
||||
game.save.player.name = "bryan"
|
||||
local pika = Pokemon.new(game.data, "PIKACHU", 30)
|
||||
BattleState.stampOT(game.save, pika)
|
||||
pika.moves = {
|
||||
{ id = "THUNDERSHOCK", pp = 30 }, { id = "GROWL", pp = 40 },
|
||||
{ id = "THUNDER_WAVE", pp = 20 }, { id = "QUICK_ATTACK", pp = 30 },
|
||||
}
|
||||
game.save.party = { pika }
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
|
||||
game.save.pikachuInBall = false
|
||||
game.save.pikachuHappiness = 120
|
||||
game.save.pikachuMood = 128
|
||||
game.save.pikachuEmotionModifier = nil
|
||||
game.save.flashLit = nil
|
||||
game.save.repelSteps = 250
|
||||
|
||||
U.teleport(game, "ROCK_TUNNEL_1F", 19, 6, "right")
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
check("2347_rock_tunnel_dark", ow.dark == true)
|
||||
|
||||
local function follower()
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.pikachuFollower then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local npc = follower()
|
||||
if not check("2347_follower_spawned", npc ~= nil) then quit() end
|
||||
|
||||
local DIRS = { { "up", 0, -1 }, { "left", -1, 0 },
|
||||
{ "right", 1, 0 }, { "down", 0, 1 } }
|
||||
local function freeDir()
|
||||
for _, d in ipairs({ { "left", -1, 0 }, { "right", 1, 0 },
|
||||
{ "up", 0, -1 }, { "down", 0, 1 } }) do
|
||||
local cx, cy = ow.player.cellX + d[2], ow.player.cellY + d[3]
|
||||
if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy)
|
||||
and not ow:npcAtCell(cx, cy) then
|
||||
return d[1]
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local function walk(dir)
|
||||
local sx, sy = ow.player.cellX, ow.player.cellY
|
||||
for _ = 1, 60 do
|
||||
U.hold(game, dir, 1)
|
||||
if ow.player.cellX ~= sx or ow.player.cellY ~= sy then break end
|
||||
end
|
||||
for _ = 1, 40 do
|
||||
if not ow.player.moving then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(4)
|
||||
return ow.player.cellX ~= sx or ow.player.cellY ~= sy
|
||||
end
|
||||
if not check("2347_step_room", freeDir() ~= nil) then quit() end
|
||||
local function stepAndBack()
|
||||
local dir = freeDir()
|
||||
if not dir then return false end
|
||||
local out = walk(dir)
|
||||
local back = walk(OPPOSITE[dir])
|
||||
return out and back
|
||||
end
|
||||
local function faceFollower()
|
||||
for _, d in ipairs(DIRS) do
|
||||
if npc.cellX == ow.player.cellX + d[2]
|
||||
and npc.cellY == ow.player.cellY + d[3] then
|
||||
if ow.player.facing ~= d[1] then
|
||||
U.tap(game, d[1])
|
||||
U.wait(6)
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow:npcAtCell(fx, fy) == npc
|
||||
end
|
||||
|
||||
stepAndBack()
|
||||
U.shot(game, SHOT_DIR .. "/2347_01_dark_before.png")
|
||||
|
||||
pika.moves[4] = { id = "THUNDERBOLT", pp = 15 }
|
||||
PikachuFollower.onMoveLearned(game.save, pika, "THUNDERBOLT")
|
||||
check("2347_learn_arms_modifier", game.save.pikachuEmotionModifier == 5
|
||||
and game.save.pikachuMood == 0x85)
|
||||
|
||||
local function startTalk(tag)
|
||||
if not check(tag .. "_facing_follower", faceFollower()) then return false end
|
||||
for _ = 1, 5 do
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 60 do
|
||||
if ow.emote then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if ow.emote then break end
|
||||
end
|
||||
return ow.emote ~= nil
|
||||
end
|
||||
|
||||
local function isBoltMap(m)
|
||||
return m and m[0] == 0 and m[1] == 0 and m[2] == 0 and m[3] == 3
|
||||
end
|
||||
local function isDarkMap(m)
|
||||
return m and m[0] == 2 and m[1] == 3 and m[2] == 3 and m[3] == 3
|
||||
end
|
||||
|
||||
moveSounds, ducks = {}, 0
|
||||
if not check("2347_talk1_emote", startTalk("2347_talk1")) then quit() end
|
||||
check("2347_talk1_bolt_bubble", ow.emote.bubble and not ow.emote.pikaPic and true or false)
|
||||
for _ = 1, 200 do
|
||||
if ow.emote and ow.emote.pikaPic then break end
|
||||
U.wait(1)
|
||||
end
|
||||
local pic = ow.emote and ow.emote.pikaPic or ""
|
||||
check("2347_talk1_pikapic_25", pic:find("pikapic_25", 1, true) ~= nil)
|
||||
local emote = ow.emote
|
||||
check("2347_talk1_bolt_spec", emote and emote.boltAt == 45)
|
||||
local bgpSeq, shadeSeq, frames = {}, {}, 0
|
||||
local prebolt = 0
|
||||
while ow.emote == emote and frames < 900 do
|
||||
U.wait(1)
|
||||
frames = frames + 1
|
||||
if ow.emote ~= emote then break end
|
||||
bgpSeq[#bgpSeq + 1] = emote.bgp or false
|
||||
shadeSeq[#bgpSeq] = PaletteFX.shadeMap() or false
|
||||
if not emote.bgp then prebolt = prebolt + 1 end
|
||||
end
|
||||
local strobeOk, seen = true, 0
|
||||
local first
|
||||
for i, b in ipairs(bgpSeq) do
|
||||
if b then first = first or i end
|
||||
end
|
||||
if first then
|
||||
for row = 1, 20 do
|
||||
local want = (row % 2 == 1) and 0xC0 or 0xE4
|
||||
for f = 1, 4 do
|
||||
local i = first + (row - 1) * 4 + f - 1
|
||||
if bgpSeq[i] ~= want then strobeOk = false end
|
||||
seen = seen + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
U.log("prebolt frames", prebolt, "strobe start", tostring(first), "total", frames)
|
||||
check("2347_talk1_strobe_alternates_4f", first ~= nil and strobeOk and seen == 80)
|
||||
local whiteDrawn, darkDuringPre = false, false
|
||||
for i = 1, #bgpSeq do
|
||||
if isBoltMap(shadeSeq[i]) then whiteDrawn = true end
|
||||
if first and i < first and isDarkMap(shadeSeq[i]) then darkDuringPre = true end
|
||||
end
|
||||
check("2347_talk1_prebolt_dark", darkDuringPre)
|
||||
check("2347_talk1_strobe_white_drawn", whiteDrawn)
|
||||
local litTail = first and #bgpSeq > first + 82
|
||||
if litTail then
|
||||
for i = first + 82, #bgpSeq do
|
||||
if shadeSeq[i] then litTail = false end
|
||||
end
|
||||
end
|
||||
check("2347_talk1_lit_tail_while_box_up", litTail and true or false)
|
||||
check("2347_talk1_thunderbolt_sound", moveSounds[1] == "Battle_2F")
|
||||
check("2347_talk1_music_muted", ducks == 1)
|
||||
U.wait(6)
|
||||
check("2347_talk1_dark_after_close", isDarkMap(PaletteFX.shadeMap()))
|
||||
check("2347_modifier_survives_talk", game.save.pikachuEmotionModifier == 5)
|
||||
|
||||
if not check("2347_talk2_emote", startTalk("2347_talk2")) then quit() end
|
||||
U.shot(game, SHOT_DIR .. "/2347_02_bolt_bubble.png")
|
||||
for _ = 1, 200 do
|
||||
if ow.emote and ow.emote.pikaPic then break end
|
||||
U.wait(1)
|
||||
end
|
||||
emote = ow.emote
|
||||
check("2347_talk2_replays_bolt", emote and emote.boltAt ~= nil)
|
||||
U.wait(20)
|
||||
U.shot(game, SHOT_DIR .. "/2347_03_pikapic_prebolt.png")
|
||||
for _ = 1, 200 do
|
||||
if emote.bgp == 0xC0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.shot(game, SHOT_DIR .. "/2347_04_strobe_white.png")
|
||||
for _ = 1, 200 do
|
||||
if emote.bgp == 0xE4 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.shot(game, SHOT_DIR .. "/2347_05_strobe_lit.png")
|
||||
for _ = 1, 400 do
|
||||
if emote.boltDone or ow.emote ~= emote then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if ow.emote == emote then U.shot(game, SHOT_DIR .. "/2347_06_lit_tail.png") end
|
||||
for _ = 1, 200 do
|
||||
if ow.emote ~= emote then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(10)
|
||||
U.shot(game, SHOT_DIR .. "/2347_07_after_close_dark.png")
|
||||
|
||||
for _ = 1, 3 do
|
||||
stepAndBack()
|
||||
U.log("step", ow.player.cellX, ow.player.cellY, "mood",
|
||||
tostring(game.save.pikachuMood), "modifier", tostring(game.save.pikachuEmotionModifier))
|
||||
end
|
||||
check("2347_modifier_clears_after_steps", game.save.pikachuEmotionModifier == nil
|
||||
and game.save.pikachuMood == 128)
|
||||
moveSounds = {}
|
||||
if not check("2347_talk3_emote", startTalk("2347_talk3")) then quit() end
|
||||
for _ = 1, 200 do
|
||||
if ow.emote and ow.emote.pikaPic then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(20)
|
||||
U.shot(game, SHOT_DIR .. "/2347_08_after_steps_mood_talk.png")
|
||||
check("2347_talk3_no_bolt", ow.emote and ow.emote.pikaPic and not ow.emote.boltAt
|
||||
and true or false)
|
||||
for _ = 1, 600 do
|
||||
if not ow.emote then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("2347_talk3_no_sound", #moveSounds == 0)
|
||||
|
||||
PaletteFX.setMode("redpp")
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
npc = follower() or npc
|
||||
stepAndBack()
|
||||
if ow:bakedWorldColors() then
|
||||
PikachuFollower.onMoveLearned(game.save, pika, "THUNDERBOLT")
|
||||
if startTalk("2347_baked") then
|
||||
for _ = 1, 200 do
|
||||
if ow.emote and ow.emote.pikaPic then break end
|
||||
U.wait(1)
|
||||
end
|
||||
emote = ow.emote
|
||||
local veiled = false
|
||||
for _ = 1, 400 do
|
||||
if ow.emote ~= emote then break end
|
||||
U.wait(1)
|
||||
local v = game.renderer and game.renderer.screenVeil
|
||||
if emote.bgp == 0xC0 and v and v[1] == 1 then
|
||||
if not veiled then U.shot(game, SHOT_DIR .. "/2347_09_baked_strobe_white.png") end
|
||||
veiled = true
|
||||
end
|
||||
end
|
||||
check("2347_baked_white_veil", veiled)
|
||||
end
|
||||
else
|
||||
U.log("SKIP 2347_baked (no gbc atlas in this cache)")
|
||||
end
|
||||
|
||||
Sound.playMove, Music.duckForFanfare = realPlayMove, realDuck
|
||||
quit()
|
||||
end
|
||||
@@ -112,6 +112,15 @@ return function(game)
|
||||
end
|
||||
end
|
||||
|
||||
local function waitMoveMenu()
|
||||
for _ = 1, 240 do
|
||||
local t = top()
|
||||
if isMoveMenu(t) and t.ready then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function waitForBag()
|
||||
for _ = 1, 60 do
|
||||
local t = top()
|
||||
@@ -176,8 +185,7 @@ return function(game)
|
||||
check("keepOpen is set for the ETHERs", ether.keepOpen == true)
|
||||
cursorTo(ether, 1)
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
check("the move window is the top state", isMoveMenu(top()))
|
||||
check("the move window is the top state", waitMoveMenu())
|
||||
check("...with the party menu still drawn underneath", inStack(isPicker))
|
||||
U.wait(20)
|
||||
U.shot(game, DIR .. "/pp2158_ether_move_window.png")
|
||||
@@ -187,8 +195,7 @@ return function(game)
|
||||
U.wait(8)
|
||||
check("B goes back to the party menu", isPicker(top()))
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
check("and the move window opens again", isMoveMenu(top()))
|
||||
check("and the move window opens again", waitMoveMenu())
|
||||
|
||||
local before = lead.moves[1].pp
|
||||
U.tap(game, "a")
|
||||
@@ -215,8 +222,7 @@ return function(game)
|
||||
if check("party picker opened for MAX ETHER", maxEther ~= nil) then
|
||||
cursorTo(maxEther, 2) -- the untouched SNORLAX
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
check("the move window opened", isMoveMenu(top()))
|
||||
check("the move window opened", waitMoveMenu())
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
for _ = 1, 60 do
|
||||
@@ -247,8 +253,5 @@ return function(game)
|
||||
U.log("spaced with '-' for empty slots, no PP column, cursor at column 5,")
|
||||
U.log("and 'Restore PP of / which technique?' in the bottom box. B returns")
|
||||
U.log("to the party menu; A restores PP with the Heal_Ailment jingle.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
love.event.quit(fail == 0 and 0 or 1)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
-- home/pokemon.asm:246
|
||||
-- engine/items/item_effects.asm:1967-2006
|
||||
-- engine/pokemon/learn_move.asm:119-181
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local MoveSelectMenu = require("src.ui.MoveSelectMenu")
|
||||
local MoveLearnMenu = require("src.ui.MoveLearnMenu")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots2320"
|
||||
local fail = 0
|
||||
|
||||
local function check(label, ok)
|
||||
if not ok then fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
local function top() return game.stack:top() end
|
||||
local function isBox(s) return getmetatable(s) == TextBox end
|
||||
local function isParty(s) return getmetatable(s) == PartyMenu end
|
||||
local function isMoveMenu(s) return getmetatable(s) == MoveSelectMenu end
|
||||
local function isLearn(s) return getmetatable(s) == MoveLearnMenu end
|
||||
local function find(pred)
|
||||
for _, s in ipairs(game.stack.states or {}) do
|
||||
if pred(s) then return s end
|
||||
end
|
||||
end
|
||||
local function boxText(b)
|
||||
local out = {}
|
||||
for _, page in ipairs((b and b.pages) or {}) do
|
||||
for _, l in ipairs(page) do out[#out + 1] = tostring(l) end
|
||||
end
|
||||
return table.concat(out, " ")
|
||||
end
|
||||
local function waitFor(pred, n)
|
||||
for _ = 1, n or 240 do
|
||||
if pred() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return pred()
|
||||
end
|
||||
local function cursorTo(menu, want)
|
||||
for _ = 1, 40 do
|
||||
if menu.index == want then return true end
|
||||
U.tap(game, menu.index < want and "down" or "up")
|
||||
U.wait(3)
|
||||
end
|
||||
return menu.index == want
|
||||
end
|
||||
|
||||
local gengar = Pokemon.new(game.data, "GENGAR", 30)
|
||||
gengar.moves = {
|
||||
{ id = "LICK", pp = 30 }, { id = "CONFUSE_RAY", pp = 10 },
|
||||
{ id = "NIGHT_SHADE", pp = 15 }, { id = "HYPNOSIS", pp = 20 },
|
||||
}
|
||||
local nido = Pokemon.new(game.data, "NIDOKING", 40)
|
||||
nido.moves = {
|
||||
{ id = "SURF", pp = 15 }, { id = "HORN_ATTACK", pp = 25 },
|
||||
{ id = "POISON_STING", pp = 35 }, { id = "LEER", pp = 30 },
|
||||
}
|
||||
game.save.party = { gengar, nido }
|
||||
game.save.player.name = "RED"
|
||||
game.save.inventory = {}
|
||||
Bag.add(game.save, "PP_UP", 5, game.data)
|
||||
Bag.add(game.save, "TM_MEGA_PUNCH", 1, game.data)
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(10)
|
||||
|
||||
local function openPicker(id)
|
||||
while #game.stack.states > 1 do game.stack:pop() end
|
||||
U.wait(2)
|
||||
local bag = Screens.push(game, "BagMenu", {})
|
||||
U.wait(12)
|
||||
local row
|
||||
for i, r in ipairs(bag.items or {}) do
|
||||
if r.value == id then row = i break end
|
||||
end
|
||||
if not row or not cursorTo(bag, row) then return nil end
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
local ut = top()
|
||||
if ut and ut.items and ut.items[1] and ut.items[1].label == "USE" then
|
||||
cursorTo(ut, 1)
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
if not waitFor(function()
|
||||
if isParty(top()) then return true end
|
||||
local t = top()
|
||||
if isBox(t) and (t.done or t.waiting) then U.tap(game, "a") end
|
||||
if getmetatable(t) == ChoiceBox then U.tap(game, "a") end
|
||||
return false
|
||||
end, 600) then return nil end
|
||||
return top()
|
||||
end
|
||||
|
||||
local function pickMon(picker, slot)
|
||||
cursorTo(picker, slot)
|
||||
U.tap(game, "a")
|
||||
end
|
||||
|
||||
U.log("======== pass 1: timing, no shots ========")
|
||||
local picker = openPicker("PP_UP")
|
||||
if check("PP UP party picker opened", picker ~= nil) then
|
||||
pickMon(picker, 1)
|
||||
local promptBox = top()
|
||||
check("A on GENGAR: a text box is on top, not the move box", isBox(promptBox))
|
||||
local mm = find(isMoveMenu)
|
||||
check("the move menu waits underneath, not ready", mm ~= nil and not mm.ready)
|
||||
check("the chosen arrow is hollow", picker.chosenHollow == true)
|
||||
check("the prompt is still typing", isBox(promptBox) and not promptBox.done)
|
||||
local frames = 0
|
||||
while not (isMoveMenu(top()) and top().ready) and frames < 400 do
|
||||
U.wait(1)
|
||||
frames = frames + 1
|
||||
end
|
||||
U.log("prompt frames before the move box:", frames)
|
||||
check("the move box waited for the typed prompt (" .. frames .. " frames)",
|
||||
frames >= 10 and frames < 400)
|
||||
U.tap(game, "b")
|
||||
U.wait(4)
|
||||
check("B returns to the party menu", isParty(top()))
|
||||
U.wait(2)
|
||||
check("the party arrow is filled again", not picker.chosenHollow)
|
||||
end
|
||||
|
||||
U.log("======== pass 2: PP UP with shots ========")
|
||||
picker = openPicker("PP_UP")
|
||||
if check("PP UP party picker opened again", picker ~= nil) then
|
||||
pickMon(picker, 1)
|
||||
U.wait(3)
|
||||
U.shot(game, DIR .. "/2320_01_prompt_typing_hollow_arrow.png")
|
||||
check("move box ready after the prompt",
|
||||
waitFor(function() return isMoveMenu(top()) and top().ready end, 400))
|
||||
local mm = top()
|
||||
U.wait(2)
|
||||
U.shot(game, DIR .. "/2320_02_move_box_over_textbox.png")
|
||||
cursorTo(mm, 1)
|
||||
U.tap(game, "a")
|
||||
check("the result box opened",
|
||||
waitFor(function() return isBox(top()) end, 30))
|
||||
local res = top()
|
||||
check("result reads PP increased", boxText(res):find("increased") ~= nil)
|
||||
check("the move box is held under the result", mm.held == true and find(isMoveMenu) == mm)
|
||||
check("the party arrow is still hollow", picker.chosenHollow == true)
|
||||
waitFor(function() return res.done end, 240)
|
||||
U.shot(game, DIR .. "/2320_03_result_keeps_move_box.png")
|
||||
waitFor(function()
|
||||
if isBox(top()) then U.tap(game, "a") end
|
||||
return not find(isParty)
|
||||
end, 120)
|
||||
check("dismissing closes both the move box and the picker",
|
||||
not find(isParty) and not find(isMoveMenu))
|
||||
check("LICK got one PP Up", (gengar.moves[1].ppUps or 0) == 1)
|
||||
end
|
||||
|
||||
U.log("======== PP UP on a maxed move reprompts ========")
|
||||
gengar.moves[1].ppUps = 3
|
||||
local before = game.save.inventory.PP_UP
|
||||
picker = openPicker("PP_UP")
|
||||
if check("PP UP party picker opened for the maxed move", picker ~= nil) then
|
||||
pickMon(picker, 1)
|
||||
waitFor(function() return isMoveMenu(top()) and top().ready end, 400)
|
||||
local mm = top()
|
||||
cursorTo(mm, 1)
|
||||
U.tap(game, "a")
|
||||
waitFor(function() return isBox(top()) end, 30)
|
||||
local res = top()
|
||||
check("maxed out text prints", boxText(res):find("maxed out") ~= nil)
|
||||
waitFor(function() return res.done end, 240)
|
||||
U.shot(game, DIR .. "/2320_04_pp_maxed_out.png")
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
check("the prompt types again", isBox(top()) and find(isMoveMenu) == mm and not mm.ready)
|
||||
check("the move box comes back",
|
||||
waitFor(function() return top() == mm and mm.ready end, 400))
|
||||
U.wait(2)
|
||||
U.shot(game, DIR .. "/2320_05_maxed_reprompt.png")
|
||||
check("no PP UP was spent", game.save.inventory.PP_UP == before)
|
||||
U.tap(game, "b")
|
||||
U.wait(4)
|
||||
U.tap(game, "b")
|
||||
U.wait(8)
|
||||
end
|
||||
|
||||
U.log("======== TM on a full moveset ========")
|
||||
picker = openPicker("TM_MEGA_PUNCH")
|
||||
if check("TM party picker opened", picker ~= nil) then
|
||||
pickMon(picker, 2)
|
||||
local learn
|
||||
local ok = waitFor(function()
|
||||
learn = find(isLearn)
|
||||
local t = top()
|
||||
if learn and isBox(t) and boxText(t):find("forgotten") then return true end
|
||||
if getmetatable(t) == ChoiceBox then U.tap(game, "a")
|
||||
elseif isBox(t) and (t.done or t.waiting) then U.tap(game, "a") end
|
||||
return false
|
||||
end, 900)
|
||||
check("Which move should be forgotten? types first", ok and learn and not learn.selecting)
|
||||
U.wait(3)
|
||||
U.shot(game, DIR .. "/2320_06_forget_prompt_typing.png")
|
||||
check("the forget box follows the text",
|
||||
waitFor(function() return learn and top() == learn and learn.selecting end, 400))
|
||||
U.wait(2)
|
||||
U.shot(game, DIR .. "/2320_07_forget_box_over_textbox.png")
|
||||
cursorTo(learn, 1)
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
check("SURF is refused and the forget box is gone",
|
||||
isBox(top()) and boxText(top()):find("HM") ~= nil and not learn.selecting)
|
||||
waitFor(function() return top().done end, 240)
|
||||
U.shot(game, DIR .. "/2320_08_hm_refused_no_box.png")
|
||||
U.tap(game, "a")
|
||||
check("the forget prompt retypes, then the box returns",
|
||||
waitFor(function() return top() == learn and learn.selecting end, 400))
|
||||
cursorTo(learn, 2)
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
check("the forget box is gone for 1, 2 and... Poof!", not find(isLearn))
|
||||
waitFor(function()
|
||||
local t = top()
|
||||
if isBox(t) and (t.done or t.waiting) then U.tap(game, "a") end
|
||||
return not find(isParty)
|
||||
end, 900)
|
||||
local knows = false
|
||||
for _, mv in ipairs(nido.moves) do if mv.id == "MEGA_PUNCH" then knows = true end end
|
||||
check("NIDOKING learned MEGA PUNCH", knows)
|
||||
end
|
||||
|
||||
U.log(fail == 0 and "PASS pp_up_move_box_2320" or ("FAIL pp_up_move_box_2320 (" .. fail .. ")"))
|
||||
love.event.quit(fail == 0 and 0 or 1)
|
||||
end
|
||||
@@ -2396,6 +2396,7 @@ local function sellItem(id)
|
||||
qtyTo(top().max or 1) -- the whole stack
|
||||
press("a")
|
||||
U.wait(6)
|
||||
pressUntil(function() return isChoice() or isList() end, "a", 60)
|
||||
end
|
||||
if isChoice() then
|
||||
cursorTo("index", 1) -- YES
|
||||
@@ -2498,6 +2499,7 @@ local function buyItem(id, qty, where)
|
||||
end
|
||||
press("a")
|
||||
U.wait(6)
|
||||
pressUntil(function() return isChoice() or isList() end, "a", 60)
|
||||
if isChoice() then
|
||||
if not cursorTo("index", 1) then return false end -- YES
|
||||
press("a")
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
-- engine/events/pokemart.asm:159-169
|
||||
-- engine/events/pokemart.asm:84-91
|
||||
-- POKEPORT_DRIVER=tests/drivers/route_shop_buy_sell_bug2291.lua POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = require("tests.drivers.util")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Menu = require("src.ui.Menu")
|
||||
|
||||
local failures = 0
|
||||
local function check(label, ok)
|
||||
if not ok then failures = failures + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
local function finish()
|
||||
U.log(failures == 0 and "PASS route_shop_buy_sell_bug2291"
|
||||
or ("FAIL route_shop_buy_sell_bug2291 (" .. failures .. " failures)"))
|
||||
love.event.quit(failures == 0 and 0 or 1)
|
||||
U.wait(600)
|
||||
end
|
||||
|
||||
local function loadRouteShop()
|
||||
local f = assert(io.open("tests/drivers/route.lua", "r"))
|
||||
local src = f:read("*a")
|
||||
f:close()
|
||||
local function slice(from, to)
|
||||
local a = assert(src:find(from, 1, true), from)
|
||||
local b = assert(src:find(to, a, true), to)
|
||||
return src:sub(a, b - 1)
|
||||
end
|
||||
local body = table.concat({
|
||||
"local G, U, press, say, note, WD = ...",
|
||||
slice("local function top() return G.stack:top() end",
|
||||
"-- PokeBotBad's shop lists"),
|
||||
slice("local function qtyTo(want, tries)", "-- Free up bag slots"),
|
||||
slice("local function buyItem(id, qty, where)", "-- Back all the way out"),
|
||||
"return { buyItem = buyItem, sellItem = sellItem, isMenu = isMenu,",
|
||||
" isList = isList, cursorTo = cursorTo, pressUntil = pressUntil }",
|
||||
}, "\n")
|
||||
local chunk = assert(loadstring(body, "=route.lua shops"))
|
||||
local said = {}
|
||||
local function say(...)
|
||||
local parts = { ... }
|
||||
for i = 1, #parts do parts[i] = tostring(parts[i]) end
|
||||
said[#said + 1] = table.concat(parts, " ")
|
||||
U.log("[route]", said[#said])
|
||||
end
|
||||
local function press(btn) U.tap(game, btn) end
|
||||
local function note() end
|
||||
return chunk(game, U, press, say, note, { keepItems = {} }), said
|
||||
end
|
||||
|
||||
local ok, err = pcall(function()
|
||||
local R = loadRouteShop()
|
||||
game.save.money = 3000
|
||||
game.save.inventory = game.save.inventory or {}
|
||||
U.teleport(game, "PEWTER_MART", 2, 5, "left")
|
||||
U.wait(10)
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 60 do
|
||||
if getmetatable(game.stack:top()) == Menu then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
if not check("the mart menu opens", R.isMenu()) then return end
|
||||
|
||||
check("cursor on BUY", R.cursorTo("index", 1))
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
if not check("the buy list opens", R.pressUntil(R.isList, "a", 30)) then return end
|
||||
local money0 = game.save.money
|
||||
local have0 = (game.save.inventory or {}).POKE_BALL or 0
|
||||
local bought = R.buyItem("POKE_BALL", 3, "PEWTER_MART")
|
||||
U.shot(game, DIR .. "/2291_route_after_buy.png")
|
||||
check("route buyItem reports success", bought)
|
||||
check("route buyItem put 3 POKe BALLs in the bag",
|
||||
((game.save.inventory or {}).POKE_BALL or 0) == have0 + 3)
|
||||
check("route buyItem paid 600", game.save.money == money0 - 600)
|
||||
check("route buyItem leaves the buy list on top", R.isList())
|
||||
|
||||
for _ = 1, 10 do
|
||||
if R.isMenu() then break end
|
||||
U.tap(game, "b")
|
||||
U.wait(6)
|
||||
end
|
||||
if not check("back on the BUY/SELL menu", R.isMenu()) then return end
|
||||
local money1 = game.save.money
|
||||
local sold = R.sellItem("POKE_BALL")
|
||||
U.shot(game, DIR .. "/2291_route_after_sell.png")
|
||||
check("route sellItem reports success", sold)
|
||||
check("route sellItem emptied the POKe BALL stack",
|
||||
((game.save.inventory or {}).POKE_BALL or 0) == 0)
|
||||
check("route sellItem was paid 300", game.save.money == money1 + 300)
|
||||
check("route sellItem returns to the BUY/SELL menu", R.isMenu())
|
||||
end)
|
||||
if not ok then check("driver error: " .. tostring(err), false) end
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,155 @@
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
return function(game)
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots_1951"
|
||||
os.execute('mkdir -p "' .. DIR .. '" 2>/dev/null')
|
||||
local fails = 0
|
||||
local function check(label, cond)
|
||||
print((cond and "PASS " or "FAIL ") .. label)
|
||||
if not cond then fails = fails + 1 end
|
||||
return cond
|
||||
end
|
||||
|
||||
local ok, err = xpcall(function()
|
||||
U.wait(30)
|
||||
package.path = package.path .. ";./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua"
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local App = require("tools.save-editor.App")
|
||||
local Kit = require("Kit")
|
||||
|
||||
local W, H = 1280, 720
|
||||
local realDim = love.graphics.getDimensions
|
||||
local realSafe = love.window.getSafeArea
|
||||
local canvas = love.graphics.newCanvas(W, H)
|
||||
|
||||
local rects = {}
|
||||
local realButton = Kit.button
|
||||
Kit.button = function(x, y, w, h, label, opts)
|
||||
if label == "PC" or label == "BAG" then
|
||||
rects[#rects + 1] = { label = label, x = x, y = y, w = w, h = h,
|
||||
enabled = not (opts and opts.enabled == false) }
|
||||
end
|
||||
return realButton(x, y, w, h, label, opts)
|
||||
end
|
||||
|
||||
local function frame(shotName)
|
||||
rects = {}
|
||||
love.graphics.getDimensions = function() return W, H end
|
||||
love.window.getSafeArea = function() return 0, 0, W, H end
|
||||
love.graphics.push("all")
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(0, 0, 0, 1)
|
||||
App.draw()
|
||||
love.graphics.setCanvas()
|
||||
love.graphics.pop()
|
||||
love.graphics.getDimensions = realDim
|
||||
love.window.getSafeArea = realSafe
|
||||
if shotName then
|
||||
local fd = canvas:newImageData():encode("png")
|
||||
local f = io.open(DIR .. "/" .. shotName .. ".png", "wb")
|
||||
if f then f:write(fd:getString()) f:close() end
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
local function find(label)
|
||||
for _, r in ipairs(rects) do
|
||||
if r.label == label then return r end
|
||||
end
|
||||
end
|
||||
|
||||
local function click(r)
|
||||
App.mousepressed(r.x + r.w / 2, r.y + r.h / 2, 1)
|
||||
frame()
|
||||
end
|
||||
|
||||
local seed = SaveData.newGame()
|
||||
seed.inventory, seed.bagOrder = { POTION = 30 }, { "POTION" }
|
||||
local fill = { "ANTIDOTE", "BURN_HEAL", "ICE_HEAL", "AWAKENING", "PARLYZ_HEAL",
|
||||
"FULL_HEAL", "REVIVE", "MAX_REVIVE", "ESCAPE_ROPE", "REPEL", "SUPER_REPEL",
|
||||
"MAX_REPEL", "FIRE_STONE", "THUNDER_STONE", "WATER_STONE", "LEAF_STONE",
|
||||
"MOON_STONE", "RARE_CANDY", "NUGGET" }
|
||||
for _, id in ipairs(fill) do
|
||||
seed.inventory[id] = 1
|
||||
seed.bagOrder[#seed.bagOrder + 1] = id
|
||||
end
|
||||
seed.pcItems = { POTION = 90, ULTRA_BALL = 5 }
|
||||
local path = os.tmpname() .. "-bug1951.lua"
|
||||
local f = assert(io.open(path, "wb")); f:write(SaveData.encode(seed)); f:close()
|
||||
|
||||
App.load(path, { version = "red", embedded = true })
|
||||
local S = App.getState()
|
||||
S.tab = "items"
|
||||
frame(); frame("1951_01_items_tab_before_move")
|
||||
|
||||
local bagCount = 0
|
||||
for _, id in ipairs(S.save.bagOrder or {}) do bagCount = bagCount + 1 end
|
||||
check("1951 bag starts at 20/20 slots", bagCount == 20)
|
||||
local pcBtn = find("PC")
|
||||
local bagBtn = find("BAG")
|
||||
check("1951 bag rows carry a PC button", pcBtn ~= nil and pcBtn.enabled)
|
||||
check("1951 PC rows carry a BAG button", bagBtn ~= nil)
|
||||
|
||||
if pcBtn then
|
||||
click(pcBtn)
|
||||
frame("1951_02_potion_moved_21_left")
|
||||
check("1951 PC POTION tops out at 99", S.save.pcItems.POTION == 99)
|
||||
check("1951 bag POTION keeps the 21 that did not fit", S.save.inventory.POTION == 21)
|
||||
check("1951 status names the remainder: " .. tostring(S.status),
|
||||
tostring(S.status):find("21 left in the bag", 1, true) ~= nil)
|
||||
end
|
||||
|
||||
S.pcSort = "name"
|
||||
frame()
|
||||
local ultra
|
||||
local order = require("Ops").pcOrder(S)
|
||||
local ultraIdx
|
||||
for i, id in ipairs(order) do if id == "ULTRA_BALL" then ultraIdx = i end end
|
||||
local bagRects = {}
|
||||
for _, r in ipairs(rects) do if r.label == "BAG" then bagRects[#bagRects + 1] = r end end
|
||||
ultra = ultraIdx and bagRects[ultraIdx]
|
||||
check("1951 ULTRA_BALL row has a BAG button", ultra ~= nil)
|
||||
if ultra then
|
||||
click(ultra)
|
||||
frame("1951_03_bag_full_refused")
|
||||
check("1951 full bag refuses ULTRA_BALL", S.save.inventory.ULTRA_BALL == nil
|
||||
and S.save.pcItems.ULTRA_BALL == 5)
|
||||
check("1951 status says the bag is full: " .. tostring(S.status),
|
||||
tostring(S.status):find("Bag is full", 1, true) ~= nil)
|
||||
require("Ops").bagDrop(S, "NUGGET")
|
||||
frame()
|
||||
bagRects = {}
|
||||
for _, r in ipairs(rects) do if r.label == "BAG" then bagRects[#bagRects + 1] = r end end
|
||||
order = require("Ops").pcOrder(S)
|
||||
for i, id in ipairs(order) do if id == "ULTRA_BALL" then ultraIdx = i end end
|
||||
ultra = bagRects[ultraIdx]
|
||||
if ultra then click(ultra) end
|
||||
frame("1951_04_ultra_ball_withdrawn")
|
||||
check("1951 ULTRA_BALL withdraws once a slot frees",
|
||||
S.save.inventory.ULTRA_BALL == 5 and S.save.pcItems.ULTRA_BALL == nil)
|
||||
end
|
||||
|
||||
W, H = 500, 800
|
||||
canvas = love.graphics.newCanvas(W, H)
|
||||
frame(); frame()
|
||||
pcBtn = find("PC")
|
||||
if pcBtn and pcBtn.y > H - 60 then
|
||||
S.itemsScroll = pcBtn.y - 160
|
||||
frame(); frame()
|
||||
end
|
||||
frame("1951_05_phone_layout_move_buttons")
|
||||
pcBtn = find("PC")
|
||||
check("1951 phone layout keeps the PC button inside the row", pcBtn ~= nil
|
||||
and pcBtn.x >= 0 and pcBtn.x + pcBtn.w <= W)
|
||||
|
||||
Kit.button = realButton
|
||||
App.unload()
|
||||
os.remove(path)
|
||||
end, debug.traceback)
|
||||
if not ok then
|
||||
print("FAIL 1951 driver error " .. tostring(err))
|
||||
fails = fails + 1
|
||||
end
|
||||
if fails == 0 then print("PASS save_editor_item_move_bug1951") end
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,116 @@
|
||||
-- scripts/SeafoamIslands1F.asm:1
|
||||
-- POKEPORT_DRIVER=tests/drivers/seafoam_boulder_hole_bug2290.lua POKEPORT_IDENTITY=bsa0922-2290 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Sound = require("src.core.Sound")
|
||||
local MAP = "SEAFOAM_ISLANDS_1F"
|
||||
local HX, HY = 17, 6
|
||||
local EVENT = "EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE"
|
||||
local DIRS = { "right", "left", "down", "up" }
|
||||
local STEP = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, right = { 1, 0 } }
|
||||
local BACK = { right = "left", left = "right", up = "down", down = "up" }
|
||||
|
||||
local ok = true
|
||||
local function check(label, pass)
|
||||
U.log(pass and "PASS" or "FAIL", label)
|
||||
if not pass then ok = false end
|
||||
return pass
|
||||
end
|
||||
local function finish()
|
||||
for _, d in ipairs(DIRS) do game.input.state[d] = false end
|
||||
U.log(ok and "PASS seafoam_boulder_hole_2290" or "FAIL seafoam_boulder_hole_2290")
|
||||
love.event.quit(ok and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
if game.save.flags then game.save.flags[EVENT] = nil end
|
||||
U.teleport(game, MAP, 18, 11, "up")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
game.data.encounters[MAP] = nil
|
||||
local rock = ow and ow:npcAtCell(18, 10)
|
||||
if not check("1F boulder 1 present at (18,10)",
|
||||
rock ~= nil and rock.def.sprite == "SPRITE_BOULDER") then
|
||||
finish()
|
||||
end
|
||||
ow.strengthActive = true
|
||||
|
||||
local function free(x, y)
|
||||
if not (ow.map:inBounds(x, y) and ow.map:isWalkableCell(x, y)) then return false end
|
||||
local n = ow:npcAtCell(x, y)
|
||||
return n == nil or n == rock
|
||||
end
|
||||
local pushDir
|
||||
for _, d in ipairs(DIRS) do
|
||||
local s = STEP[d]
|
||||
if not pushDir and free(HX - s[1], HY - s[2]) and free(HX - 2 * s[1], HY - 2 * s[2]) then
|
||||
pushDir = d
|
||||
end
|
||||
end
|
||||
if not check("found an approach lane into the hole at (17,6)", pushDir ~= nil) then
|
||||
finish()
|
||||
end
|
||||
local s = STEP[pushDir]
|
||||
rock.cellX, rock.cellY = HX - s[1], HY - s[2]
|
||||
rock.px, rock.py = rock.cellX * 16, rock.cellY * 16
|
||||
rock.moving, rock.targetX, rock.targetY = false, nil, nil
|
||||
local p = ow.player
|
||||
p.cellX, p.cellY = HX - 2 * s[1], HY - 2 * s[2]
|
||||
p.px, p.py = p.cellX * 16, p.cellY * 16
|
||||
p.moving, p.targetX, p.targetY = false, nil, nil
|
||||
p.facing = pushDir
|
||||
ow.boulderTried = nil
|
||||
U.wait(8)
|
||||
U.log("pushing", pushDir, "from", p.cellX, p.cellY)
|
||||
U.shot(game, DIR .. "/2290_01_boulder_next_to_hole.png")
|
||||
|
||||
local cues = {}
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function(data, key, ...)
|
||||
cues[#cues + 1] = tostring(key)
|
||||
return realPlay(data, key, ...)
|
||||
end
|
||||
|
||||
local function inEntities()
|
||||
for _, e in ipairs(ow.entities) do if e == rock then return true end end
|
||||
return false
|
||||
end
|
||||
local first = true
|
||||
for _ = 1, 300 do
|
||||
if not inEntities() then break end
|
||||
if first then table.insert(game.input.pressQueue, pushDir); first = false end
|
||||
game.input.state[pushDir] = true
|
||||
U.wait(1)
|
||||
end
|
||||
game.input.state[pushDir] = false
|
||||
for _ = 1, 60 do
|
||||
if not ow.dustAnim then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(4)
|
||||
Sound.play = realPlay
|
||||
|
||||
check("boulder removed from 1F after reaching the hole", not inEntities())
|
||||
check(EVENT .. " is set", game.save.flags and game.save.flags[EVENT] == true)
|
||||
check("no text box on top after the fall", game.stack:top() == ow)
|
||||
local thud = false
|
||||
for _, c in ipairs(cues) do if c == "Faint_Thud" then thud = true end end
|
||||
U.log("sounds heard:", #cues > 0 and table.concat(cues, ", ") or "nothing")
|
||||
check("no Faint_Thud when the boulder falls", not thud)
|
||||
U.shot(game, DIR .. "/2290_02_boulder_gone_no_text.png")
|
||||
|
||||
local back = BACK[pushDir]
|
||||
local bs = STEP[back]
|
||||
local sx, sy = p.cellX, p.cellY
|
||||
if free(sx + bs[1], sy + bs[2]) then
|
||||
U.hold(game, back, 20)
|
||||
U.wait(10)
|
||||
check("player walks right away without dismissing anything",
|
||||
p.cellX ~= sx or p.cellY ~= sy)
|
||||
else
|
||||
U.log("skip walk check: cell behind the player is blocked")
|
||||
end
|
||||
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,96 @@
|
||||
-- home/overworld.asm:675
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots2186"
|
||||
|
||||
local failures = 0
|
||||
local function check(label, ok)
|
||||
if not ok then failures = failures + 1 end
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function key(colors)
|
||||
if not colors then return "nil" end
|
||||
local out = {}
|
||||
for i, c in ipairs(colors) do out[i] = table.concat(c, ",") end
|
||||
return table.concat(out, "|")
|
||||
end
|
||||
|
||||
local function baseKey(ow)
|
||||
local z = ow:sgbWorldZones()
|
||||
return key(z and z[1] and z[1].colors), z and #z or 0
|
||||
end
|
||||
|
||||
local function crossNorth(ow)
|
||||
for _ = 1, 40 do
|
||||
table.insert(game.input.pressQueue, "up")
|
||||
game.input.state.up = true
|
||||
coroutine.yield()
|
||||
if ow.map.id == "ROUTE_1" then break end
|
||||
end
|
||||
game.input.state.up = false
|
||||
return ow.map.id == "ROUTE_1" and ow.player.moving
|
||||
end
|
||||
|
||||
U.newGame(game)
|
||||
PaletteFX.setMode("gbc")
|
||||
Zoom.offset = 0
|
||||
local ow = game.overworld
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 1, "up")
|
||||
U.wait(20)
|
||||
ow = game.overworld
|
||||
check("standing at the Pallet Town / Route 1 seam", ow.map.id == "PALLET_TOWN")
|
||||
local pallet = key(PaletteFX.pal(game.data, ow:paletteNameFor(ow.map)))
|
||||
local r1 = { id = "ROUTE_1", def = game.data.maps.ROUTE_1 }
|
||||
local route = key(PaletteFX.pal(game.data, ow:paletteNameFor(r1)))
|
||||
check("Pallet and Route 1 wear different palettes", pallet ~= route)
|
||||
|
||||
check("seam step starts onto Route 1", crossNorth(ow))
|
||||
local held, frames, zoneCount = true, 0, 0
|
||||
while ow.player.moving and frames < 40 do
|
||||
local k, n = baseKey(ow)
|
||||
zoneCount = math.max(zoneCount, n)
|
||||
if k ~= pallet then held = false end
|
||||
frames = frames + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
U.log("seam step frames under Pallet's palette:", frames)
|
||||
check("whole screen keeps Pallet's palette for every seam-step frame", held and frames > 0)
|
||||
check("seam step is one whole-screen zone", zoneCount == 1)
|
||||
local landed, n = baseKey(ow)
|
||||
check("palette flips to Route 1 the frame the step lands", landed == route and n == 1)
|
||||
check("held palette cleared on landing", ow.seamPalette == nil)
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 1, "up")
|
||||
U.wait(20)
|
||||
ow = game.overworld
|
||||
check("second pass: seam step starts", crossNorth(ow))
|
||||
U.shot(game, DIR .. "/2186_01_seam_step_still_pallet.png")
|
||||
check("second pass: shot taken mid-step under Pallet's palette",
|
||||
ow.player.moving and (baseKey(ow)) == pallet)
|
||||
for _ = 1, 40 do
|
||||
if not ow.player.moving then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
U.shot(game, DIR .. "/2186_02_landed_route1_palette.png")
|
||||
check("second pass: landed under Route 1's palette", (baseKey(ow)) == route)
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 1, "up")
|
||||
U.wait(20)
|
||||
ow = game.overworld
|
||||
Zoom.offset = -1
|
||||
U.wait(5)
|
||||
check("survey zoom: seam step starts", crossNorth(ow))
|
||||
local sk, sn = baseKey(ow)
|
||||
check("survey zoom keeps per-map zones mid-step", sk == route and sn > 1)
|
||||
U.shot(game, DIR .. "/2186_03_survey_per_map.png")
|
||||
Zoom.offset = 0
|
||||
|
||||
print(failures == 0 and "PASS seam_palette_timing_2186" or "FAIL seam_palette_timing_2186")
|
||||
love.event.quit(failures == 0 and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
+39
-15
@@ -4,7 +4,7 @@
|
||||
-- open_mart with a yielded runner, the old softlock).
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Flags = require("src.script.Flags")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
@@ -15,6 +15,12 @@ return function(game)
|
||||
Flags.set(game.save, "EVENT_GOT_OAKS_PARCEL")
|
||||
Flags.set(game.save, "EVENT_OAK_GOT_PARCEL")
|
||||
|
||||
local failures = 0
|
||||
local function check(label, ok)
|
||||
if not ok then failures = failures + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
local function topIs(cls)
|
||||
return getmetatable(game.stack:top()) == cls
|
||||
end
|
||||
@@ -31,39 +37,57 @@ return function(game)
|
||||
local ow = game.overworld
|
||||
U.tap(game, "a") -- talk to the clerk
|
||||
U.wait(20)
|
||||
U.log(tag, "menu:", mash("a", function() return topIs(Menu) end))
|
||||
check(tag .. " menu", mash("a", function() return topIs(Menu) end))
|
||||
U.tap(game, "a") -- BUY
|
||||
U.wait(8)
|
||||
U.log(tag, "list:", topIs(ListMenu))
|
||||
check(tag .. " list", topIs(ListMenu))
|
||||
U.shot(game, ("%s/%s_0_list.png"):format(DIR, tag))
|
||||
U.tap(game, "a") -- first item
|
||||
U.wait(8)
|
||||
U.log(tag, "qty:", topIs(QuantityBox))
|
||||
check(tag .. " qty", topIs(QuantityBox))
|
||||
U.tap(game, "down") -- engine/events/pokemart.asm:152-153
|
||||
U.wait(6)
|
||||
U.log(tag, "qty max:", game.stack:top().qty)
|
||||
check(tag .. " qty max 99", game.stack:top().qty == 99)
|
||||
U.shot(game, ("%s/%s_0b_qty99.png"):format(DIR, tag))
|
||||
U.tap(game, "up")
|
||||
U.wait(6)
|
||||
U.tap(game, "a") -- x01
|
||||
U.wait(8)
|
||||
U.log(tag, "confirm:", topIs(ChoiceBox))
|
||||
for _ = 1, 300 do
|
||||
if topIs(ChoiceBox) then break end
|
||||
local t = game.stack:top()
|
||||
if t and t.isTextBox and t.waiting and (t.preWait or 0) == 0 then
|
||||
U.tap(game, "a")
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
check(tag .. " confirm", topIs(ChoiceBox))
|
||||
U.shot(game, ("%s/%s_1_confirm.png"):format(DIR, tag))
|
||||
local money0 = game.save.money
|
||||
U.tap(game, "a") -- YES
|
||||
for _ = 1, 40 do
|
||||
if game.save.money < money0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(8)
|
||||
check(tag .. " bought", game.save.money < money0)
|
||||
U.shot(game, ("%s/%s_2_bought.png"):format(DIR, tag))
|
||||
U.log(tag, "unwound:", mash("b", function() return game.stack:top() == ow end))
|
||||
U.log(tag, "runner idle:",
|
||||
not (ow.runner and ow.runner:isRunning()) and true or false)
|
||||
check(tag .. " unwound", mash("b", function() return game.stack:top() == ow end))
|
||||
check(tag .. " runner idle", not (ow.runner and ow.runner:isRunning()))
|
||||
local x0, y0 = ow.player.cellX, ow.player.cellY
|
||||
U.hold(game, "right", 30)
|
||||
U.wait(20)
|
||||
U.log(tag, "player moved:", ow.player.cellX ~= x0 or ow.player.cellY ~= y0)
|
||||
check(tag .. " player moved", ow.player.cellX ~= x0 or ow.player.cellY ~= y0)
|
||||
U.shot(game, ("%s/%s_3_walk.png"):format(DIR, tag))
|
||||
end
|
||||
|
||||
U.teleport(game, "PEWTER_MART", 2, 5, "left")
|
||||
buyRun("pewter")
|
||||
U.teleport(game, "VIRIDIAN_MART", 2, 5, "left")
|
||||
buyRun("viridian")
|
||||
local ok, err = pcall(function()
|
||||
U.teleport(game, "PEWTER_MART", 2, 5, "left")
|
||||
buyRun("pewter")
|
||||
U.teleport(game, "VIRIDIAN_MART", 2, 5, "left")
|
||||
buyRun("viridian")
|
||||
end)
|
||||
if not ok then check("shop_test error: " .. tostring(err), false) end
|
||||
U.log(failures == 0 and "PASS shop_test" or ("FAIL shop_test (" .. failures .. " failures)"))
|
||||
love.event.quit(failures == 0 and 0 or 1)
|
||||
U.wait(600)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local TouchSkin = require("src.core.TouchSkin")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local TitleState = require("src.ui.TitleState")
|
||||
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local version = tostring(GameVersion.get())
|
||||
local tag = "2368_" .. version
|
||||
|
||||
local fails = 0
|
||||
local function check(label, ok, detail)
|
||||
U.log((ok and "PASS " or "FAIL ") .. tag .. " " .. label, detail or "")
|
||||
if not ok then fails = fails + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
local function finish()
|
||||
TouchSkin.setActive(nil)
|
||||
if fails > 0 then
|
||||
U.log("FAIL " .. tag .. ": " .. fails .. " check(s), shots in " .. SHOT_DIR)
|
||||
love.event.quit(1)
|
||||
else
|
||||
U.log("PASS " .. tag .. ": all checks, shots in " .. SHOT_DIR)
|
||||
love.event.quit(0)
|
||||
end
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local function waitFor(pred, limit)
|
||||
for _ = 1, limit or 3000 do
|
||||
if pred() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function shot(name)
|
||||
U.shot(game, SHOT_DIR .. "/" .. tag .. "_" .. name .. ".png")
|
||||
end
|
||||
|
||||
local function scale()
|
||||
U.wait(4)
|
||||
local r = Renderer:frameRects()
|
||||
return r.Up, Renderer:fitScale(), r
|
||||
end
|
||||
|
||||
local function wholeFit(label)
|
||||
local up, fit = scale()
|
||||
check(label .. " draws at the integer fit", up == fit and up == math.floor(up),
|
||||
("Up=%s fit=%s"):format(tostring(up), tostring(fit)))
|
||||
return up
|
||||
end
|
||||
|
||||
love.window.setMode(1024, 768, { resizable = true })
|
||||
U.wait(2)
|
||||
local skin = assert(TouchSkin.parse([[
|
||||
overlays = 1
|
||||
overlay0_name = "bezel"
|
||||
overlay0_full_screen = true
|
||||
overlay0_normalized = true
|
||||
overlay0_viewport = "0.15,0.08,0.7,0.84"
|
||||
overlay0_descs = 1
|
||||
overlay0_desc0 = "nul,0.5,0.5,rect,0.02,0.02"
|
||||
]]))
|
||||
TouchSkin.setActive(skin)
|
||||
TouchSkin.setOverlayLive(false)
|
||||
U.wait(240)
|
||||
|
||||
local introUp = wholeFit("intro inside the bezel")
|
||||
shot("01_intro_skin")
|
||||
|
||||
local function isTitle()
|
||||
return getmetatable(game.stack:top()) == TitleState
|
||||
end
|
||||
for _ = 1, 40 do
|
||||
if isTitle() then break end
|
||||
U.tap(game, "start")
|
||||
U.wait(10)
|
||||
end
|
||||
if not check("reached the title screen", isTitle()) then finish() end
|
||||
U.wait(30)
|
||||
local titleUp = wholeFit("title inside the bezel")
|
||||
check("title matches the intro scale", titleUp == introUp,
|
||||
("title=%s intro=%s"):format(tostring(titleUp), tostring(introUp)))
|
||||
shot("02_title_skin")
|
||||
|
||||
local title = game.stack:top()
|
||||
waitFor(function()
|
||||
U.tap(game, "a")
|
||||
U.wait(5)
|
||||
return game.stack:top() ~= title
|
||||
end, 60)
|
||||
if not check("opened the CONTINUE/NEW GAME menu", game.stack:top() ~= title) then finish() end
|
||||
U.wait(10)
|
||||
wholeFit("main menu over the title inside the bezel")
|
||||
shot("03_mainmenu_skin")
|
||||
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
local arrived = waitFor(function()
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
return game.stack:top() == OverworldState
|
||||
end, 1500)
|
||||
if not arrived then
|
||||
for i, s in ipairs(game.stack.states) do
|
||||
local keys = {}
|
||||
for k in pairs(s) do keys[#keys + 1] = tostring(k) end
|
||||
U.log("stack", i, tostring(s == OverworldState), tostring(s.screenId),
|
||||
table.concat(keys, ","):sub(1, 200))
|
||||
end
|
||||
end
|
||||
if not check("reached the overworld", arrived) then finish() end
|
||||
U.wait(20)
|
||||
U.tap(game, "start")
|
||||
U.wait(20)
|
||||
check("START menu is up", game.stack:top() ~= OverworldState)
|
||||
local owUp = wholeFit("overworld START menu inside the bezel")
|
||||
check("overworld matches the title scale", owUp == titleUp,
|
||||
("overworld=%s title=%s"):format(tostring(owUp), tostring(titleUp)))
|
||||
shot("04_overworld_start_skin")
|
||||
|
||||
TouchSkin.setActive(nil)
|
||||
game:returnToTitle()
|
||||
U.wait(30)
|
||||
local up, fit = scale()
|
||||
check("title with no skin still fills past the integer fit", up > fit,
|
||||
("Up=%s fit=%s"):format(tostring(up), tostring(fit)))
|
||||
shot("05_title_noskin_fill")
|
||||
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,111 @@
|
||||
-- engine/items/item_effects.asm:1310-1317
|
||||
-- data/text/text_6.asm:71-77
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Sound = require("src.core.Sound")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR")
|
||||
or "/tmp/shots"
|
||||
local failed = false
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
if not ok then failed = true end
|
||||
return ok
|
||||
end
|
||||
|
||||
local function stackHas(pred)
|
||||
for _, s in ipairs(game.stack.states or {}) do
|
||||
if pred(s) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function isBox(s) return getmetatable(s) == TextBox end
|
||||
local function isParty(s) return getmetatable(s) == PartyMenu end
|
||||
|
||||
if (game.save.options and game.save.options.sfxVol or 1) == 0 then
|
||||
U.log("WARNING: sfxVol is 0, so nothing here can be heard.")
|
||||
end
|
||||
|
||||
local mon = Pokemon.new(game.data, "NIDORINO", 20)
|
||||
game.save.party = { mon }
|
||||
game.save.player.name = "bryan"
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(10)
|
||||
|
||||
local heard
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function(data, name, ...)
|
||||
if name == "Heal_Ailment" and not heard then
|
||||
heard = { boxUp = stackHas(isBox), partyUp = stackHas(isParty) }
|
||||
end
|
||||
return realPlay(data, name, ...)
|
||||
end
|
||||
|
||||
local function useVitamin(id, statName, tag, shotOpen, shotDone)
|
||||
heard = nil
|
||||
while #game.stack.states > 1 do
|
||||
game.stack:pop()
|
||||
end
|
||||
game.save.inventory = {}
|
||||
Bag.add(game.save, id, 1, game.data)
|
||||
Screens.push(game, "BagMenu", {})
|
||||
U.wait(12)
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
U.tap(game, "a")
|
||||
local box
|
||||
for _ = 1, 30 do
|
||||
local top = game.stack:top()
|
||||
if isBox(top) then box = top break end
|
||||
U.wait(1)
|
||||
end
|
||||
if not check(tag .. " rose box opened", box ~= nil) then return end
|
||||
local typedAtOpen = box.charIndex or 0
|
||||
check(tag .. " Heal_Ailment fired before the rose box existed",
|
||||
heard ~= nil and heard.boxUp == false)
|
||||
check(tag .. " Heal_Ailment fired over the party menu",
|
||||
heard ~= nil and heard.partyUp == true)
|
||||
check(tag .. " rose box was still typing when first seen (char "
|
||||
.. tostring(typedAtOpen) .. ")", not box.done and typedAtOpen <= 2)
|
||||
U.wait(2)
|
||||
U.shot(game, SHOT_DIR .. "/" .. shotOpen)
|
||||
for _ = 1, 240 do
|
||||
if box.done then break end
|
||||
U.wait(1)
|
||||
end
|
||||
local page = box.pages and box.pages[1] or {}
|
||||
check(tag .. " line 1 is NIDORINO's", page[1] == "NIDORINO's")
|
||||
check(tag .. " line 2 is " .. statName .. " rose.",
|
||||
page[2] == statName .. " rose.")
|
||||
check(tag .. " party menu is still drawn under the message",
|
||||
stackHas(isParty))
|
||||
U.shot(game, SHOT_DIR .. "/" .. shotDone)
|
||||
U.log(tag, "typed:", tostring(page[1]), "/", tostring(page[2]))
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
end
|
||||
|
||||
useVitamin("CALCIUM", "SPECIAL", "CALCIUM",
|
||||
"2332_01_calcium_jingle_box_opening.png",
|
||||
"2332_02_calcium_special_rose_typed.png")
|
||||
check("CALCIUM raised SPECIAL stat exp",
|
||||
(mon.statExp and mon.statExp.special or 0) == 2560)
|
||||
useVitamin("HP_UP", "HEALTH", "HP_UP",
|
||||
"2332_03_hp_up_jingle_box_opening.png",
|
||||
"2332_04_hp_up_health_rose_typed.png")
|
||||
check("HP_UP raised HP stat exp",
|
||||
(mon.statExp and mon.statExp.hp or 0) == 2560)
|
||||
|
||||
Sound.play = realPlay
|
||||
U.log("the cure jingle should start as the box opens and play while")
|
||||
U.log("NIDORINO's / SPECIAL rose. types, not after the last letter.")
|
||||
U.log(failed and "FAIL vitamin_bug2332" or "PASS vitamin_bug2332")
|
||||
love.event.quit(failed and 1 or 0)
|
||||
end
|
||||
@@ -0,0 +1,245 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
|
||||
love.graphics.newShader = love.graphics.newShader or function() return {} end
|
||||
love.graphics.polygon = love.graphics.polygon or function() end
|
||||
|
||||
local clock = 5000
|
||||
love.timer.getTime = function() return clock end
|
||||
|
||||
local Kit = require("src.ui.kit.Kit")
|
||||
local CartManifest = require("src.carts.CartManifest")
|
||||
local CartStore = require("src.carts.CartStore")
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local LauncherView = require("src.import.LauncherView")
|
||||
|
||||
local SHA = ("a1b2c3d4"):rep(8)
|
||||
|
||||
love.graphics.getDimensions = function() return 1280, 720 end
|
||||
love.graphics.getPixelDimensions = function() return 1280, 720 end
|
||||
|
||||
local function pin(id, repo)
|
||||
return { id = id, source = "github", repo = repo, version = "1.0.0",
|
||||
sha256 = SHA }
|
||||
end
|
||||
|
||||
local CART = {
|
||||
id = "wild_green", title = "Wild Green", version = "1.75.0",
|
||||
author = "wild1walker", shell = "#3a8f3a", base = "red", seal = "sealed",
|
||||
mods = { pin("wild_green", "wild1walker/Gen1MakeItGreen"),
|
||||
pin("wg_music", "wild1walker/wg-music"),
|
||||
pin("wg_items", "wild1walker/wg-items"),
|
||||
pin("wg_maps", "wild1walker/wg-maps") },
|
||||
}
|
||||
|
||||
local function installCart()
|
||||
local cart, perr = CartManifest.parse(CART)
|
||||
check(cart ~= nil, "fixture parses: " .. tostring(perr))
|
||||
local ok, err = CartStore.install(CartManifest.encode(cart))
|
||||
check(ok ~= nil, "fixture installs: " .. tostring(err))
|
||||
end
|
||||
|
||||
local Json = require("src.link.Json")
|
||||
local ENTRY_RAW = {
|
||||
id = "wild_green", title = "Wild Green", author = "wild1walker",
|
||||
version = "1.75.0", base = "red", seal = "sealed",
|
||||
repo = "https://github.com/wild1walker/Gen1WildGreen",
|
||||
github = "wild1walker/Gen1WildGreen",
|
||||
mods = CART.mods, update_check = "ok",
|
||||
latest = { version = "1.75.0", tag = "v1.75.0",
|
||||
zip = { name = "wild_green-1.75.0.g1rcart",
|
||||
url = "https://example.test/wild_green-1.75.0.g1rcart" } },
|
||||
}
|
||||
local FEED = ModIndex.parse(Json.encode({
|
||||
schema_version = 1, categories = { "GAMEPLAY" }, base_games = { "red" },
|
||||
mods = {}, carts = { ENTRY_RAW },
|
||||
}))
|
||||
check(FEED ~= nil and FEED.carts and FEED.carts[1] ~= nil, "the feed parses")
|
||||
local ENTRY = FEED.carts[1]
|
||||
check(ModIndex.isCart(ENTRY), "the index entry is a cart")
|
||||
|
||||
local realButton = Kit.button
|
||||
local function drawButtons(imp)
|
||||
local byId = {}
|
||||
Kit.button = function(x, y, w, h, label, opts)
|
||||
if opts and opts.id then
|
||||
byId[opts.id] = { label = tostring(label), opts = opts,
|
||||
x = x, y = y, w = w, h = h }
|
||||
end
|
||||
return realButton(x, y, w, h, label, opts)
|
||||
end
|
||||
local ok, err = pcall(LauncherView.draw, imp)
|
||||
Kit.button = realButton
|
||||
check(ok, "the frame draws: " .. tostring(err))
|
||||
return byId
|
||||
end
|
||||
|
||||
local function press(imp, b)
|
||||
imp:runActions({ { key = b.opts.id, fn = b.opts.action,
|
||||
keepArm = b.opts.keepArm } })
|
||||
end
|
||||
|
||||
local function findLauncher()
|
||||
local imp = RomImporter.new(function() end, { launcher = true })
|
||||
imp.tab = "find"
|
||||
imp.modScope = nil
|
||||
imp.findLoaded = true
|
||||
imp._findFetch = nil
|
||||
imp.findSources = { { feed = "https://example.test/data/index.json",
|
||||
base = "https://example.test/",
|
||||
label = "example/index" } }
|
||||
imp.findIndex = { mods = FEED.mods, carts = FEED.carts, categories = {},
|
||||
baseGames = ModIndex.baseGamesIn(FEED) }
|
||||
imp:_setFindKind("carts")
|
||||
return imp
|
||||
end
|
||||
|
||||
do
|
||||
love.filesystem.createDirectory("mods/wild_green")
|
||||
love.filesystem.write("mods/wild_green/manifest.lua",
|
||||
"return { id = \"wild_green\", name = \"Wild Green\", version = \"1.0.0\" }")
|
||||
installCart()
|
||||
local imp = findLauncher()
|
||||
imp.ready.red = nil
|
||||
check(imp:_findInstalledCarts().wild_green ~= nil,
|
||||
"FIND reads the cart as installed")
|
||||
imp._findEntry = ENTRY
|
||||
|
||||
local b = drawButtons(imp)
|
||||
check(b["findpop-inst"] ~= nil, "the popup keeps its install row")
|
||||
eq(b["findpop-inst"] and b["findpop-inst"].label, "Reinstall",
|
||||
"which reads Reinstall for the installed version")
|
||||
local del = b["findpop-del"]
|
||||
check(del ~= nil, "an installed cart's FIND popup offers Delete")
|
||||
if del then
|
||||
eq(del.label, "Delete", "the chip starts unarmed")
|
||||
eq(del.opts.kind, "danger", "and is drawn as a danger control")
|
||||
check(del.y == b["findpop-inst"].y, "it shares the install row")
|
||||
check(del.x >= b["findpop-inst"].x + b["findpop-inst"].w,
|
||||
"beside the install button, not over it")
|
||||
|
||||
press(imp, del)
|
||||
check(CartStore.get("wild_green") ~= nil, "the first press does not delete")
|
||||
check(imp._findEntry == ENTRY, "and the popup stays open")
|
||||
local armed = drawButtons(imp)["findpop-del"]
|
||||
eq(armed and armed.label, "Sure?", "the chip asks to confirm")
|
||||
|
||||
clock = clock + 0.5
|
||||
press(imp, armed or del)
|
||||
check(CartStore.get("wild_green") == nil, "the second press deletes the cart")
|
||||
eq(love.filesystem.getInfo(CartStore.fileFor("wild_green")), nil,
|
||||
"and its file is gone")
|
||||
eq(imp:_findInstalledCarts().wild_green, nil,
|
||||
"FIND no longer reads it as installed")
|
||||
eq(#imp:_ensureCarts("red"), 0, "the red cart list is empty")
|
||||
eq(imp._findEntry, nil, "the popup closes")
|
||||
eq(imp._cartPopup, nil, "no Custom Carts popup opens for an unready game")
|
||||
check(imp.findNotice ~= nil and imp.findNotice.ok == true,
|
||||
"FIND reports the delete")
|
||||
check(imp.findNotice and tostring(imp.findNotice.text)
|
||||
:find("Cart deleted", 1, true) ~= nil, "with the delete notice")
|
||||
check(love.filesystem.getInfo("mods/wild_green/manifest.lua") ~= nil,
|
||||
"the MODS-side mod sharing the id is untouched")
|
||||
|
||||
imp._findEntry = ENTRY
|
||||
local after = drawButtons(imp)
|
||||
eq(after["findpop-inst"] and after["findpop-inst"].label, "Install",
|
||||
"the listing offers Install again")
|
||||
eq(after["findpop-del"], nil, "and no Delete for a cart that is gone")
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local imp = findLauncher()
|
||||
imp._findEntry = ENTRY
|
||||
local b = drawButtons(imp)
|
||||
eq(b["findpop-del"], nil, "a cart that is not installed has no Delete")
|
||||
end
|
||||
|
||||
do
|
||||
installCart()
|
||||
local imp = findLauncher()
|
||||
local realUninstall = CartStore.uninstall
|
||||
CartStore.uninstall = function() return nil, "could not delete the cart: busy" end
|
||||
imp:deleteCart("red", "wild_green", { from = "find" })
|
||||
CartStore.uninstall = realUninstall
|
||||
check(imp.findNotice ~= nil and imp.findNotice.ok == false,
|
||||
"a failed delete from FIND reports the error there")
|
||||
eq(imp._cartNotice, nil, "not on the Custom Carts popup")
|
||||
check(CartStore.get("wild_green") ~= nil, "and the cart is still installed")
|
||||
imp:deleteCart("red", "wild_green", { from = "find" })
|
||||
check(CartStore.get("wild_green") == nil, "cleanup")
|
||||
end
|
||||
|
||||
do
|
||||
installCart()
|
||||
local imp = findLauncher()
|
||||
imp.tab = "red"
|
||||
imp.ready.red = true
|
||||
imp:_selectCart("red", "wild_green")
|
||||
eq(imp.activeCart.red, "wild_green", "the cart is selected for red")
|
||||
imp._cartPopup = "red"
|
||||
local b = drawButtons(imp)
|
||||
local del = b["cartpop-id-wild_green-delete"]
|
||||
check(del ~= nil, "the Custom Carts popup draws the cart's Delete chip")
|
||||
if del then
|
||||
clock = clock + 10
|
||||
press(imp, del)
|
||||
check(CartStore.get("wild_green") ~= nil, "one press only arms")
|
||||
clock = clock + 0.5
|
||||
press(imp, del)
|
||||
check(CartStore.get("wild_green") == nil, "the second press deletes it")
|
||||
eq(imp.activeCart.red, nil, "and deselects it")
|
||||
eq(imp._cartPopup, "red", "the popup reopens on that game")
|
||||
check(tostring(imp._cartNotice):find("Cart deleted", 1, true) ~= nil,
|
||||
"with the delete notice")
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
installCart()
|
||||
local imp = findLauncher()
|
||||
imp.ready.red = nil
|
||||
imp._findEntry = ENTRY
|
||||
imp.workState = "working"
|
||||
local del = drawButtons(imp)["findpop-del"]
|
||||
check(del ~= nil, "Delete is drawn during an import")
|
||||
if del then
|
||||
clock = clock + 10
|
||||
press(imp, del)
|
||||
clock = clock + 0.5
|
||||
press(imp, del)
|
||||
check(CartStore.get("wild_green") ~= nil,
|
||||
"a delete confirmed mid-import leaves the cart")
|
||||
check(imp.findNotice ~= nil and imp.findNotice.ok == false,
|
||||
"and says why on FIND instead of closing silently")
|
||||
check(imp.findNotice and tostring(imp.findNotice.text)
|
||||
:find("import", 1, true) ~= nil, "naming the running import")
|
||||
end
|
||||
imp.findNotice = nil
|
||||
imp._cartNotice = nil
|
||||
eq(imp:deleteCart("red", "wild_green"), false,
|
||||
"the popup path refuses mid-import too")
|
||||
check(imp._cartNotice ~= nil, "with a Custom Carts notice")
|
||||
imp.workState = nil
|
||||
|
||||
local stale = { image = {}, width = 1, height = 1 }
|
||||
imp._cartridgeLabels = { ["cart:wild_green"] = stale,
|
||||
["gba:cart:wild_green"] = stale, red = stale }
|
||||
imp._gbaLabels = { ["gba:cart:wild_green"] = stale }
|
||||
eq(imp:deleteCart("red", "wild_green", { from = "find" }), true,
|
||||
"the delete runs once the import is done")
|
||||
eq(imp._cartridgeLabels["cart:wild_green"], nil,
|
||||
"the deleted cart's label art is dropped")
|
||||
eq(imp._cartridgeLabels["gba:cart:wild_green"], nil,
|
||||
"in the GBA shell cache key too")
|
||||
eq(imp._gbaLabels["gba:cart:wild_green"], nil, "and the GBA label cache")
|
||||
check(imp._cartridgeLabels.red == stale, "stock labels stay cached")
|
||||
end
|
||||
|
||||
T.finish("cart delete from FIND (#2367)")
|
||||
@@ -30,6 +30,10 @@ local FontStub = {
|
||||
drawCode = function(code, x, y) draws[#draws + 1] = { "code", code, x, y } end,
|
||||
}
|
||||
package.loaded["src.render.Font"] = FontStub
|
||||
local TextBox = require("src.render.TextBox")
|
||||
TextBox.new = function(_, text, onDone, opts)
|
||||
return { fake = true, text = text, onDone = onDone, opts = opts }
|
||||
end
|
||||
local MoveSelectMenu = require("src.ui.MoveSelectMenu")
|
||||
|
||||
local pressed = {}
|
||||
@@ -49,41 +53,53 @@ local chosen, cancelled
|
||||
local menu = MoveSelectMenu.new(game, mon, "Restore PP of\nwhich technique?",
|
||||
function(i) chosen = i end, function() cancelled = true end)
|
||||
stack[1] = menu
|
||||
local function showPrompt(m)
|
||||
m:enter()
|
||||
local box = stack[#stack]
|
||||
check(box and box.fake and box.opts and box.opts.stay, "the prompt types in a stay box")
|
||||
box.opts.stay.onShown()
|
||||
eq(stack[#stack], m, "then hands the screen to the move window")
|
||||
end
|
||||
showPrompt(menu)
|
||||
|
||||
draws = {}
|
||||
menu:draw()
|
||||
-- engine/battle/core.asm:2526
|
||||
eq(draws[1][1], "box", "the move window is a bordered box")
|
||||
eq(draws[1][2], 4, "at tile column 4")
|
||||
eq(draws[1][3], 7, "tile row 7")
|
||||
eq(draws[1][4], 16, "16 tiles wide")
|
||||
eq(draws[1][5], 6, "6 tiles tall")
|
||||
-- engine/battle/core.asm:2530
|
||||
eq(draws[2][2], "SCRATCH", "slot 1 name")
|
||||
eq(draws[2][3], 48, "names start at x 48")
|
||||
eq(draws[2][4], 64, "slot 1 on row 8")
|
||||
eq(draws[3][4], 72, "slot 2 on row 9, single spaced")
|
||||
-- engine/battle/misc.asm:37
|
||||
eq(draws[4][2], "-", "empty slot 3 is a dash")
|
||||
eq(draws[5][2], "-", "empty slot 4 is a dash")
|
||||
check(not tostring(draws[2][2]):find("5"), "no PP column on the move rows")
|
||||
-- engine/battle/core.asm:2535
|
||||
eq(draws[6][1], "code", "cursor glyph")
|
||||
eq(draws[6][3], 40, "cursor at tile column 5")
|
||||
eq(draws[6][4], 64, "cursor starts on the first move")
|
||||
-- engine/items/item_effects.asm:1979
|
||||
eq(draws[7][2], 0, "prompt box at column 0")
|
||||
eq(draws[7][3], 12, "row 12")
|
||||
eq(draws[8][2], "Restore PP of", "prompt line 1")
|
||||
eq(draws[8][4], 112, "on row 14")
|
||||
eq(draws[9][2], "which technique?", "prompt line 2")
|
||||
eq(draws[9][4], 128, "on row 16")
|
||||
eq(draws[1][1], "box", "the prompt box is drawn first")
|
||||
eq(draws[1][2], 0, "prompt box at column 0")
|
||||
eq(draws[1][3], 12, "row 12")
|
||||
eq(draws[2][2], "Restore PP of", "prompt line 1")
|
||||
eq(draws[2][4], 112, "on row 14")
|
||||
eq(draws[3][2], "which technique?", "prompt line 2")
|
||||
eq(draws[3][4], 128, "on row 16")
|
||||
-- engine/battle/core.asm:2526
|
||||
eq(draws[4][1], "box", "the move window is a bordered box")
|
||||
eq(draws[4][2], 4, "at tile column 4")
|
||||
eq(draws[4][3], 7, "tile row 7")
|
||||
eq(draws[4][4], 16, "16 tiles wide")
|
||||
eq(draws[4][5], 6, "6 tiles tall")
|
||||
-- engine/battle/core.asm:2530
|
||||
eq(draws[5][2], "SCRATCH", "slot 1 name")
|
||||
eq(draws[5][3], 48, "names start at x 48")
|
||||
eq(draws[5][4], 64, "slot 1 on row 8")
|
||||
eq(draws[6][4], 72, "slot 2 on row 9, single spaced")
|
||||
-- engine/battle/misc.asm:37
|
||||
eq(draws[7][2], "-", "empty slot 3 is a dash")
|
||||
eq(draws[8][2], "-", "empty slot 4 is a dash")
|
||||
check(not tostring(draws[5][2]):find("5"), "no PP column on the move rows")
|
||||
-- engine/battle/core.asm:2535
|
||||
eq(draws[9][1], "code", "cursor glyph")
|
||||
eq(draws[9][3], 40, "cursor at tile column 5")
|
||||
eq(draws[9][4], 64, "cursor starts on the first move")
|
||||
|
||||
local terminated = MoveSelectMenu.new(game, mon,
|
||||
"Restore PP of\nwhich technique?{DONE}", function() end)
|
||||
stack[#stack + 1] = terminated
|
||||
showPrompt(terminated)
|
||||
table.remove(stack)
|
||||
draws = {}
|
||||
terminated:draw()
|
||||
eq(draws[9][2], "which technique?", "the {DONE} terminator is not drawn")
|
||||
eq(draws[3][2], "which technique?", "the {DONE} terminator is not drawn")
|
||||
|
||||
-- engine/battle/core.asm:2692
|
||||
pressed = { down = true }
|
||||
@@ -102,16 +118,18 @@ eq(#stack, 0, "B pops the move window")
|
||||
eq(cancelled, true, "back to the party menu")
|
||||
|
||||
stack[1] = menu
|
||||
showPrompt(menu)
|
||||
menu.index = 2
|
||||
pressed = { a = true }
|
||||
menu:update()
|
||||
eq(chosen, 2, "A hands back the picked slot")
|
||||
eq(#stack, 0, "and pops itself first")
|
||||
eq(#stack, 0, "and lets go once nothing prints over it")
|
||||
|
||||
local empty, escaped = { moves = {} }, 0
|
||||
local emptyMenu = MoveSelectMenu.new(game, empty, "Restore PP of\nwhich technique?",
|
||||
function() escaped = -1 end, function() escaped = escaped + 1 end)
|
||||
stack[1] = emptyMenu
|
||||
showPrompt(emptyMenu)
|
||||
pressed = { down = true }
|
||||
emptyMenu:update()
|
||||
eq(#stack, 1, "an empty list ignores DOWN")
|
||||
@@ -122,6 +140,7 @@ eq(#stack, 0, "B pops the empty move window")
|
||||
eq(escaped, 1, "and runs onCancel")
|
||||
|
||||
stack[1] = emptyMenu
|
||||
showPrompt(emptyMenu)
|
||||
escaped = 0
|
||||
pressed = { a = true }
|
||||
emptyMenu:update()
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env luajit
|
||||
-- pokefirered/src/script_menu.c:1027, pokefirered/data/maps/Route25_SeaCottage/scripts.inc:105
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local failed = 0
|
||||
local function check(cond, msg)
|
||||
if cond then
|
||||
print("[ok] " .. msg)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("[FAIL] " .. msg)
|
||||
end
|
||||
end
|
||||
|
||||
package.loaded["src.ui.game3.stack"] = {
|
||||
push = function() end,
|
||||
pop = function() end,
|
||||
busy = function() return false end,
|
||||
drawOrder = function() return {} end,
|
||||
}
|
||||
package.loaded["src.core.game3.audio"] = {
|
||||
playSe = function() end,
|
||||
playCry = function() end,
|
||||
}
|
||||
|
||||
local drawn = {}
|
||||
package.loaded["src.ui.game3.window"] = setmetatable({
|
||||
printPx = function(text) drawn[#drawn + 1] = text end,
|
||||
template = function() return {} end,
|
||||
}, { __index = function() return function() end end })
|
||||
|
||||
local Flags = require("src.core.game3.scripting.flags")
|
||||
local Space = require("src.core.game3.scripting.space")
|
||||
local PcMenu = require("src.ui.game3.pc_menu")
|
||||
|
||||
local function label(session)
|
||||
PcMenu.show({ session = session })
|
||||
PcMenu.mode = "root"
|
||||
drawn = {}
|
||||
PcMenu.draw()
|
||||
PcMenu.close()
|
||||
local got = drawn[1]
|
||||
if PcMenu.storageLabel then
|
||||
check(PcMenu.storageLabel(session) == got, "storageLabel matches drawn row")
|
||||
end
|
||||
return got
|
||||
end
|
||||
|
||||
print("[test] 1. fresh store: SOMEONE'S PC")
|
||||
local store = Flags.newStore()
|
||||
Space.store = store
|
||||
local session = { flags = {} }
|
||||
check(label(session) == "SOMEONE'S PC", "no flags -> SOMEONE'S PC (got " .. tostring(label(session)) .. ")")
|
||||
|
||||
print("[test] 2. FLAG_SYS_POKEMON_GET (0x828) alone stays SOMEONE'S PC")
|
||||
Flags.setFlag(store, nil, 0x828, true)
|
||||
session.flags = Flags.serialize(store).flags
|
||||
check(label(session) == "SOMEONE'S PC", "0x828 -> SOMEONE'S PC (got " .. tostring(label(session)) .. ")")
|
||||
|
||||
print("[test] 3. FLAG_SYS_NOT_SOMEONES_PC in the live store: BILL'S PC")
|
||||
Flags.setFlag(store, nil, "FLAG_SYS_NOT_SOMEONES_PC", true)
|
||||
check(Flags.getFlag(store, nil, 0x834), "setFlag by name lands on 0x834")
|
||||
check(label(session) == "BILL'S PC", "live 0x834 -> BILL'S PC (got " .. tostring(label(session)) .. ")")
|
||||
|
||||
print("[test] 4. post-load snapshot (string keys, no live store): BILL'S PC")
|
||||
local snap = Flags.serialize(store).flags
|
||||
Space.store = nil
|
||||
check(label({ flags = snap }) == "BILL'S PC", "serialized 0x834 -> BILL'S PC (got " .. tostring(label({ flags = snap })) .. ")")
|
||||
|
||||
print("[test] 5. snapshot without 0x834: SOMEONE'S PC")
|
||||
check(label({ flags = { ["2088"] = true } }) == "SOMEONE'S PC", "serialized 0x828 only -> SOMEONE'S PC")
|
||||
|
||||
print(("game3_bills_pc_label_bug2404_test: %s (%d failed)"):format(failed == 0 and "PASS" or "FAIL", failed))
|
||||
if failed > 0 then os.exit(1) end
|
||||
@@ -79,6 +79,7 @@ local eo = {
|
||||
py = 160,
|
||||
facing = "down",
|
||||
sight = 4,
|
||||
trainerType = 1,
|
||||
elevation = 0,
|
||||
visible = true,
|
||||
hidden = false,
|
||||
@@ -206,12 +207,12 @@ scriptFired = false
|
||||
FieldEffects.invalidate()
|
||||
Objects.clearMovements()
|
||||
local trainer1 = {
|
||||
localId = 1, cellX = 10, cellY = 8, px = 160, py = 128, facing = "down", sight = 4,
|
||||
localId = 1, cellX = 10, cellY = 8, px = 160, py = 128, facing = "down", sight = 4, trainerType = 1,
|
||||
elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false,
|
||||
scriptKey = "trainer_battle_01",
|
||||
}
|
||||
local trainer2 = {
|
||||
localId = 2, cellX = 12, cellY = 11, px = 192, py = 176, facing = "left", sight = 4,
|
||||
localId = 2, cellX = 12, cellY = 11, px = 192, py = 176, facing = "left", sight = 4, trainerType = 1,
|
||||
elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false,
|
||||
scriptKey = "trainer_battle_02",
|
||||
}
|
||||
@@ -302,6 +303,7 @@ local sightTrainer = {
|
||||
py = 160,
|
||||
facing = "down",
|
||||
sight = 4,
|
||||
trainerType = 1,
|
||||
elevation = 0,
|
||||
visible = true,
|
||||
hidden = false,
|
||||
@@ -320,6 +322,101 @@ TrainerSight.check(dummyGame)
|
||||
check(playedSeId == 21, "SE_PIN (21) played immediately on spot")
|
||||
check(playedSongId == 284 or playedSongId == 285 or playedSongId == 283, "Encounter theme started immediately on spot (song=" .. tostring(playedSongId) .. ")")
|
||||
|
||||
print("[test] 9. TRAINER_TYPE_NONE with leftover sight never engages (SSAnne_1F_Room2 Woman)")
|
||||
scripts["ssanne_woman_msgbox"] = {
|
||||
{ op = "msgbox", text = "cruising" },
|
||||
{ op = "end" },
|
||||
}
|
||||
local function resetField()
|
||||
Field.locked = false
|
||||
FieldEffects.invalidate()
|
||||
Objects.clearMovements()
|
||||
playedSeId = nil
|
||||
playedSongId = nil
|
||||
end
|
||||
local startedKey = nil
|
||||
Space.startScript = function(key) startedKey = key end
|
||||
|
||||
resetField()
|
||||
local woman = {
|
||||
localId = 3, cellX = 2, cellY = 6, px = 32, py = 96, facing = "up",
|
||||
sight = 1, trainerType = 0,
|
||||
elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false,
|
||||
scriptKey = "ssanne_woman_msgbox",
|
||||
}
|
||||
Objects.clear()
|
||||
Objects._byId[3] = woman
|
||||
Objects._order = { 3 }
|
||||
Player.reset(2, 5, "down")
|
||||
check(TrainerSight.checkLineOfSight(woman, Player, dummyGame) == true,
|
||||
"Woman's leftover range-1 cone covers the player")
|
||||
check(TrainerSight.isTrainerType ~= nil and TrainerSight.isTrainerType(woman) == false,
|
||||
"trainerType 0 is not a trainer")
|
||||
check(TrainerSight.check(dummyGame) == false, "Idle sight check does not engage the Woman")
|
||||
check(Field.locked == false, "Field stays unlocked")
|
||||
check(woman.scriptBusy == false, "Woman not claimed")
|
||||
check(playedSeId == nil and playedSongId == nil, "No SE_PIN / encounter music")
|
||||
resetField()
|
||||
woman.scriptBusy = false
|
||||
woman.frozen = false
|
||||
woman.moving = false
|
||||
woman.facing = "up"
|
||||
startedKey = nil
|
||||
Player.reset(2, 5, "down")
|
||||
check(TrainerSight.check(dummyGame, woman) == false, "Spinning-hook path does not engage the Woman")
|
||||
check(Field.locked == false and woman.scriptBusy == false and playedSeId == nil,
|
||||
"Spinning-hook path leaves the field alone")
|
||||
resetField()
|
||||
woman.scriptBusy = false
|
||||
woman.frozen = false
|
||||
woman.moving = false
|
||||
woman.facing = "up"
|
||||
startedKey = nil
|
||||
Player.reset(2, 5, "down")
|
||||
for _ = 1, 3 do TrainerSight.check(dummyGame) end
|
||||
check(Field.locked == false and startedKey == nil, "Repeated idle checks never loop the msgbox")
|
||||
|
||||
resetField()
|
||||
local seeAll = {
|
||||
localId = 4, cellX = 2, cellY = 6, px = 32, py = 96, facing = "up",
|
||||
sight = 1, trainerType = 2,
|
||||
elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false,
|
||||
scriptKey = "trainer_battle_02",
|
||||
}
|
||||
Objects.clear()
|
||||
Objects._byId[4] = seeAll
|
||||
Objects._order = { 4 }
|
||||
Player.reset(2, 5, "down")
|
||||
check(TrainerSight.check(dummyGame) == false, "TRAINER_TYPE_SEE_ALL_DIRECTIONS (2) is not scanned")
|
||||
|
||||
resetField()
|
||||
local buried = {
|
||||
localId = 6, cellX = 2, cellY = 6, px = 32, py = 96, facing = "up",
|
||||
sight = 1, trainerType = 3,
|
||||
elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false,
|
||||
scriptKey = "trainer_battle_02",
|
||||
}
|
||||
Objects.clear()
|
||||
Objects._byId[6] = buried
|
||||
Objects._order = { 6 }
|
||||
Player.reset(2, 5, "down")
|
||||
check(TrainerSight.check(dummyGame) == true, "TRAINER_TYPE_BURIED (3) still engages")
|
||||
check(Field.locked == true and buried.scriptBusy == true, "Buried trainer claims the field")
|
||||
|
||||
resetField()
|
||||
local normal = {
|
||||
localId = 7, cellX = 2, cellY = 6, px = 32, py = 96, facing = "up",
|
||||
sight = 1, def = { trainerType = 1 },
|
||||
elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false,
|
||||
scriptKey = "trainer_battle_01",
|
||||
}
|
||||
Objects.clear()
|
||||
Objects._byId[7] = normal
|
||||
Objects._order = { 7 }
|
||||
Player.reset(2, 5, "down")
|
||||
check(TrainerSight.check(dummyGame) == true, "TRAINER_TYPE_NORMAL (1) from def engages")
|
||||
resetField()
|
||||
|
||||
if failed > 0 then
|
||||
print(string.format("\n%d FAILURE(S)", failed))
|
||||
os.exit(1)
|
||||
@@ -33,6 +33,7 @@ local function fakeMap(waterCell)
|
||||
return (x == waterCell[1] and y == waterCell[2])
|
||||
and COLL_WATER or COLL_FLOOR
|
||||
end,
|
||||
inBounds = function() return true end,
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
-- ../pokecrystal/engine/battle/effect_commands.asm:2644-2654
|
||||
-- ../pokecrystal/data/items/attributes.asm:149-150
|
||||
-- ../pokegold/data/items/attributes.asm:149-150
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Fingerprint = require("src.link.Fingerprint")
|
||||
local Handshake = require("src.link.Handshake")
|
||||
local Wire = require("src.link.Wire")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Battle = require("src.battle.gen2.Battle")
|
||||
local Damage = require("src.battle.gen2.Damage")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local function deepCopy(v)
|
||||
if type(v) ~= "table" then return v end
|
||||
local out = {}
|
||||
for k, x in pairs(v) do out[k] = deepCopy(x) end
|
||||
return out
|
||||
end
|
||||
|
||||
local function tmhmList(n)
|
||||
local out = {}
|
||||
for i = 1, n do out[i] = "MOVE_" .. i end
|
||||
return out
|
||||
end
|
||||
|
||||
local TYPES = {
|
||||
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
|
||||
FIGHTING = { id = "FIGHTING", index = 1, category = "physical" },
|
||||
}
|
||||
|
||||
local function goldData()
|
||||
return {
|
||||
pokemon = {
|
||||
growthRates = {
|
||||
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
|
||||
linear = 0, constant = 0 },
|
||||
},
|
||||
tmhmMoves = tmhmList(57),
|
||||
MACHOP = {
|
||||
id = "MACHOP", index = 66, name = "MACHOP",
|
||||
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
|
||||
specialAttack = 35, specialDefense = 35 },
|
||||
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
|
||||
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
|
||||
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
|
||||
},
|
||||
},
|
||||
moves = {
|
||||
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
|
||||
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
|
||||
},
|
||||
type_chart = { generation = 2, types = deepCopy(TYPES), matchups = {} },
|
||||
gen2HeldItems = {
|
||||
ITEM_46 = { heldEffect = "HELD_NONE", heldParameter = 0 },
|
||||
ITEM_73 = { heldEffect = "HELD_NONE", heldParameter = 0 },
|
||||
LEFTOVERS = { heldEffect = "HELD_LEFTOVERS", heldParameter = 10 },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
local function crystalData()
|
||||
local data = goldData()
|
||||
data.pokemon.tmhmMoves = tmhmList(60)
|
||||
data.pokemon.tutorMoves = { "FLAMETHROWER", "THUNDERBOLT", "ICE_BEAM" }
|
||||
data.gen2HeldItems.ITEM_46 = nil
|
||||
data.gen2HeldItems.ITEM_73 = nil
|
||||
data.gen2HeldItems.CLEAR_BELL = { heldEffect = "HELD_NONE", heldParameter = 0 }
|
||||
data.gen2HeldItems.GS_BALL = { heldEffect = "HELD_NONE", heldParameter = 0 }
|
||||
return data
|
||||
end
|
||||
|
||||
do
|
||||
local gold, crystal = goldData(), crystalData()
|
||||
local g = Fingerprint.compute(gold, {}, 2)
|
||||
local c = Fingerprint.compute(crystal, {}, 2)
|
||||
T.eq(c, g, "Crystal's tutor list and renamed HELD_NONE slots do not split the digest")
|
||||
|
||||
local helloG = Handshake.hello({ data = gold })
|
||||
local helloC = Handshake.hello({ data = crystal })
|
||||
T.eq(helloG.generation, 2, "the gold hello is gen 2")
|
||||
T.eq((Handshake.checkCompat(helloG, helloC)), "full",
|
||||
"a Gold hello meeting a Crystal hello pairs as full")
|
||||
T.eq((Handshake.checkCompat(helloC, helloG)), "full", "and in reverse")
|
||||
end
|
||||
|
||||
do
|
||||
local crystal = crystalData()
|
||||
local base = Fingerprint.compute(crystal, {}, 2)
|
||||
local armed = crystalData()
|
||||
armed.gen2HeldItems.CLEAR_BELL = { heldEffect = "HELD_LEFTOVERS",
|
||||
heldParameter = 10 }
|
||||
T.check(Fingerprint.compute(armed, {}, 2) ~= base,
|
||||
"a HELD_NONE slot given a real effect moves the digest")
|
||||
local tuned = crystalData()
|
||||
tuned.gen2HeldItems.LEFTOVERS.heldParameter = 20
|
||||
T.check(Fingerprint.compute(tuned, {}, 2) ~= base,
|
||||
"a real held item's parameter still moves the digest")
|
||||
local species = crystalData()
|
||||
species.pokemon.MACHOP.baseStats.attack = 81
|
||||
T.check(Fingerprint.compute(species, {}, 2) ~= base,
|
||||
"a species edit still moves the digest")
|
||||
end
|
||||
|
||||
do
|
||||
local gold, crystal = goldData(), crystalData()
|
||||
local cmons = Fingerprint.records(crystal, "pokemon", 2)
|
||||
T.eq(cmons.tutorMoves, nil, "tutorMoves is not a species record")
|
||||
T.eq(cmons.tmhmMoves, nil, "tmhmMoves is not a species record")
|
||||
T.eq(cmons.growthRates, nil, "growthRates is not a species record")
|
||||
T.check(cmons.MACHOP ~= nil, "a real species keeps its record")
|
||||
local gheld = Fingerprint.records(gold, "held_items", 2)
|
||||
T.check(gheld.ITEM_46 ~= nil,
|
||||
"the per-record held map keeps HELD_NONE ids for party eligibility")
|
||||
local cheld = Fingerprint.records(crystal, "held_items", 2)
|
||||
T.eq(cheld.ITEM_46, nil, "Crystal has no ITEM_46 record")
|
||||
end
|
||||
|
||||
do
|
||||
local detail = "dataset fingerprint differs: the room has 6f577c0127a1e94d, "
|
||||
.. "you have c444a8971640c763"
|
||||
local out = Wire.sanitize({ type = "join_error", reason = "profile_mismatch",
|
||||
field = "fingerprint", detail = detail })
|
||||
T.eq(out.detail, detail, "the relay's mismatch detail survives the wire whole")
|
||||
local long = string.rep("x", 120)
|
||||
out = Wire.sanitize({ type = "join_error", reason = "x", detail = long })
|
||||
T.eq(out.detail, long, "a 120-byte detail survives")
|
||||
end
|
||||
|
||||
do
|
||||
local data = goldData()
|
||||
data.items = {}
|
||||
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
|
||||
perfect.hp = Mon.hpDV(perfect)
|
||||
local function highRoll(n) return 99 % math.max(1, n or 1) end
|
||||
|
||||
local seen
|
||||
local realCalc = Damage.calc
|
||||
Damage.calc = function(opts)
|
||||
seen = opts
|
||||
return realCalc(opts)
|
||||
end
|
||||
|
||||
local function reflectHit(version, link)
|
||||
GameVersion.set(version)
|
||||
local player = Mon.new(data, "MACHOP", 15, { dvs = perfect })
|
||||
player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local wild = Mon.new(data, "MACHOP", 15, { dvs = perfect })
|
||||
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local battle = Battle.new({ data = data, party = { player }, wild = wild,
|
||||
random = highRoll })
|
||||
player.stats.attack = 200
|
||||
wild.stats.defense = 512
|
||||
wild.maxHp, wild.hp = 999, 999
|
||||
battle.linkBattle = link or nil
|
||||
battle.screens.enemy.reflect = 5
|
||||
seen = nil
|
||||
battle:useMove(player, wild, "TACKLE")
|
||||
battle:takeEvents()
|
||||
return 999 - wild.hp, seen and seen.reflectOverflowFixed
|
||||
end
|
||||
|
||||
local restore = GameVersion.get()
|
||||
local gold = reflectHit("gold", false)
|
||||
local crystalSolo, soloFlag = reflectHit("crystal", false)
|
||||
local crystalLink, linkFlag = reflectHit("crystal", true)
|
||||
local goldLink = reflectHit("gold", true)
|
||||
GameVersion.set(restore)
|
||||
Damage.calc = realCalc
|
||||
|
||||
T.check(gold > 0, "the Reflect hit lands")
|
||||
T.eq(soloFlag, nil, "a single-player battle leaves the version's fix in charge")
|
||||
T.check(crystalSolo < gold, "single-player Crystal keeps its Reflect fix")
|
||||
T.eq(linkFlag, false, "a link battle forces the single truncation pass")
|
||||
T.eq(crystalLink, gold, "a Crystal link battle computes Gold's Reflect damage")
|
||||
T.eq(goldLink, gold, "and Gold's link battle is unchanged")
|
||||
end
|
||||
|
||||
T.finish("gen2 link cross version bug 2382")
|
||||
@@ -0,0 +1,326 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("mod import picker off the main thread (#2292)")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Platform = require("src.core.Platform")
|
||||
local HostPicker = require("src.core.HostPicker")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
love.system = love.system or {}
|
||||
local saved = {
|
||||
getOS = love.system.getOS,
|
||||
pickFile = love.system.pickFile,
|
||||
pickFileKinds = love.system.pickFileKinds,
|
||||
thread = love.thread,
|
||||
mouse = love.mouse,
|
||||
popen = io.popen,
|
||||
fsLoad = love.filesystem.load,
|
||||
}
|
||||
|
||||
local channels = {}
|
||||
local function channel(name)
|
||||
if channels[name] then return channels[name] end
|
||||
local ch = { q = {} }
|
||||
function ch:push(v) self.q[#self.q + 1] = v end
|
||||
function ch:pop() return table.remove(self.q, 1) end
|
||||
function ch:clear() self.q = {} end
|
||||
function ch:performAtomic(fn, ...) return fn(self, ...) end
|
||||
channels[name] = ch
|
||||
return ch
|
||||
end
|
||||
|
||||
local started = {}
|
||||
local threadStub = {
|
||||
getChannel = channel,
|
||||
newThread = function(path)
|
||||
local th = { path = path }
|
||||
function th:start(...)
|
||||
self.args = { ... }
|
||||
started[#started + 1] = self
|
||||
end
|
||||
function th:getError() return self.err end
|
||||
return th
|
||||
end,
|
||||
}
|
||||
|
||||
local popens = {}
|
||||
local popenAnswer = ""
|
||||
io.popen = function(command)
|
||||
popens[#popens + 1] = command
|
||||
return {
|
||||
read = function() return popenAnswer end,
|
||||
close = function() return true end,
|
||||
}
|
||||
end
|
||||
love.mouse = nil
|
||||
|
||||
local function useOS(name)
|
||||
love.system.getOS = function() return name end
|
||||
Platform._resetForTests()
|
||||
end
|
||||
|
||||
local function desktopImporter()
|
||||
local ri = setmetatable({
|
||||
android = false,
|
||||
workState = nil,
|
||||
tab = "red",
|
||||
ready = { red = true, blue = true },
|
||||
saveNotice = {},
|
||||
installs = {},
|
||||
browsers = 0,
|
||||
}, RomImporter)
|
||||
ri._installMod = function(self, path) self.installs[#self.installs + 1] = path end
|
||||
ri._openModBrowser = function(self) self.browsers = self.browsers + 1 return true end
|
||||
return ri
|
||||
end
|
||||
|
||||
local function run()
|
||||
useOS("Windows")
|
||||
love.thread = threadStub
|
||||
HostPicker._reset()
|
||||
started, popens = {}, {}
|
||||
local ri = desktopImporter()
|
||||
ri:chooseMod()
|
||||
eq(#popens, 0, "Import mod .zip spawns no process on the main thread")
|
||||
eq(#started, 1, "one picker worker starts")
|
||||
check(ri._hostPick ~= nil, "the importer remembers the open picker")
|
||||
eq(#ri.installs, 0, "nothing is installed while the dialog is open")
|
||||
check(ri.modNotice and ri.modNotice.ok == true
|
||||
and tostring(ri.modNotice.text):find("file dialog", 1, true) ~= nil
|
||||
and tostring(ri.modNotice.text):find("taskbar", 1, true) == nil,
|
||||
"the Mods tab says the dialog is open without pointing at a missing taskbar button")
|
||||
eq(ri.tab, "mods", "the Mods tab stays in front")
|
||||
|
||||
local th = started[1]
|
||||
eq(th and th.path, "src/core/host_picker_worker.lua", "the worker is the host picker")
|
||||
local commands = th and th.args[2] or {}
|
||||
eq(type(commands), "table", "the worker receives the command list")
|
||||
local command = commands[1] or ""
|
||||
check(command:find("powershell", 1, true) == 1, "the worker runs the PowerShell dialog")
|
||||
check(command:find("$o.TopMost=$true;", 1, true) ~= nil,
|
||||
"the dialog gets a TopMost owner form")
|
||||
check(command:find("ShowDialog($o)", 1, true) ~= nil,
|
||||
"the dialog is shown owned by that form")
|
||||
check(command:find("ShowDialog()", 1, true) == nil, "no ownerless ShowDialog")
|
||||
local body = command:match('%-Command "(.*)"$') or ""
|
||||
check(body ~= "" and body:find('"', 1, true) == nil,
|
||||
"the -Command body has no double quote to break the cmd wrapper")
|
||||
|
||||
ri:chooseMod()
|
||||
eq(#started, 1, "a second click while the dialog is open starts nothing")
|
||||
local ran = 0
|
||||
ri:runActions({ { key = "rom-red", fn = function() ran = ran + 1 end } })
|
||||
eq(ran, 0, "launcher clicks are dropped while the dialog is open")
|
||||
ri:play("red", true)
|
||||
eq(ri._launchFade, nil, "Play does not start while the dialog is open")
|
||||
eq(#popens, 0, "no second dialog opens on the main thread")
|
||||
|
||||
ri:_pumpHostPick()
|
||||
check(ri._hostPick ~= nil, "no answer yet: still waiting")
|
||||
|
||||
channel(HostPicker.RESULT):push({ id = th.args[1],
|
||||
output = "C:/Temp/pokeport_mod_pick.zip" })
|
||||
ri:_pumpHostPick()
|
||||
eq(ri._hostPick, nil, "the answer clears the pending pick")
|
||||
eq(#ri.installs, 1, "the pick is installed once")
|
||||
eq(ri.installs[1], "C:/Temp/pokeport_mod_pick.zip", "with the path the worker returned")
|
||||
eq(#popens, 0, "and the main thread still spawned nothing")
|
||||
|
||||
started = {}
|
||||
ri = desktopImporter()
|
||||
ri:chooseMod()
|
||||
channel(HostPicker.RESULT):push({ id = started[1].args[1] })
|
||||
ri:_pumpHostPick()
|
||||
eq(ri._hostPick, nil, "a cancelled dialog clears the pending pick")
|
||||
eq(#ri.installs, 0, "and installs nothing")
|
||||
eq(ri.browsers, 0, "and does not open the fallback browser over the cancel")
|
||||
eq(ri.modNotice, nil, "the waiting notice is cleared")
|
||||
|
||||
started = {}
|
||||
ri = desktopImporter()
|
||||
ri:chooseMod()
|
||||
started[1].err = "boom"
|
||||
ri:_pumpHostPick()
|
||||
eq(ri._hostPick, nil, "a dead worker clears the pending pick")
|
||||
eq(ri.browsers, 1, "and falls back to the in-app browser")
|
||||
|
||||
love.thread = nil
|
||||
HostPicker._reset()
|
||||
started, popens = {}, {}
|
||||
popenAnswer = "C:\\Temp\\pokeport_mod_pick.zip\r\n"
|
||||
ri = desktopImporter()
|
||||
ri:chooseMod()
|
||||
eq(#started, 0, "without threads no worker is started")
|
||||
eq(#popens, 1, "the picker runs synchronously instead")
|
||||
eq(ri.installs[1], "C:\\Temp\\pokeport_mod_pick.zip", "and its pick is installed")
|
||||
popenAnswer = ""
|
||||
|
||||
love.thread = threadStub
|
||||
local savedLoaded = {}
|
||||
for _, m in ipairs({ "love.thread", "love.filesystem", "love.system" }) do
|
||||
savedLoaded[m] = package.loaded[m]
|
||||
package.loaded[m] = package.loaded[m] or true
|
||||
end
|
||||
love.filesystem.load = function(path) return loadfile(path) end
|
||||
popens = {}
|
||||
local answers = { "", " /home/p/mod.zip\n" }
|
||||
io.popen = function(cmd)
|
||||
popens[#popens + 1] = cmd
|
||||
local a = answers[#popens] or ""
|
||||
return { read = function() return a end, close = function() return true end }
|
||||
end
|
||||
useOS("Linux")
|
||||
local worker = assert(loadfile("src/core/host_picker_worker.lua"))
|
||||
worker(42, { "zenity x", "kdialog y" }, "picker_test_chan")
|
||||
local msg = channel("picker_test_chan"):pop()
|
||||
check(msg ~= nil, "the worker posts one result")
|
||||
eq(msg and msg.id, 42, "tagged with its job id")
|
||||
eq(msg and msg.output, "/home/p/mod.zip", "the first non-empty answer, trimmed")
|
||||
eq(#popens, 2, "an empty first answer tries the next command")
|
||||
for m, v in pairs(savedLoaded) do package.loaded[m] = v end
|
||||
love.filesystem.load = saved.fsLoad
|
||||
|
||||
for _, path in ipairs({ "src/import/RomImporter.lua", "src/core/FilePicker.lua",
|
||||
"tools/save-editor/SaveIO.lua" }) do
|
||||
local f = io.open(path, "rb")
|
||||
local src = f and f:read("*a") or ""
|
||||
if f then f:close() end
|
||||
check(src ~= "", path .. " is readable")
|
||||
check(src:find("ShowDialog()", 1, true) == nil,
|
||||
path .. " has no ownerless ShowDialog()")
|
||||
check(src:find("ShowDialog($o)", 1, true) ~= nil
|
||||
or src:find("HostPicker.WIN_SHOW", 1, true) ~= nil,
|
||||
path .. " shows its dialog owned by a TopMost form")
|
||||
end
|
||||
check(HostPicker.WIN_OPEN_DIALOG:find('"', 1, true) == nil
|
||||
and HostPicker.WIN_SHOW:find('"', 1, true) == nil,
|
||||
"the shared dialog prefix is double-quote free")
|
||||
end
|
||||
|
||||
local function androidRun()
|
||||
useOS("Android")
|
||||
love.thread = nil
|
||||
local pickCalls = {}
|
||||
love.system.pickFile = function(kind)
|
||||
pickCalls[#pickCalls + 1] = kind or "rom"
|
||||
return true
|
||||
end
|
||||
love.system.pickFileKinds = function() return "rom,mod,sav" end
|
||||
local function androidImporter()
|
||||
local ri = setmetatable({
|
||||
android = true,
|
||||
workState = nil,
|
||||
tab = "mods",
|
||||
ready = { red = true, blue = true },
|
||||
saveNotice = {},
|
||||
installs = {},
|
||||
}, RomImporter)
|
||||
ri._installMod = function(self, source)
|
||||
self.installs[#self.installs + 1] = source
|
||||
self.modNotice = { ok = true, text = "Installed test" }
|
||||
end
|
||||
ri._refreshMods = function() end
|
||||
return ri
|
||||
end
|
||||
|
||||
love.filesystem.write("foo.zip", "PK\0junk")
|
||||
local ri = androidImporter()
|
||||
ri:chooseMod()
|
||||
eq(#ri.installs, 0, "a stray .zip at the save root is not installed on tap")
|
||||
eq(#pickCalls, 1, "the tap opens the picker instead")
|
||||
eq(pickCalls[1], "mod", "asking for a mod archive")
|
||||
check(love.filesystem.getInfo("foo.zip") ~= nil, "the player's file is left alone")
|
||||
love.filesystem.remove("foo.zip")
|
||||
|
||||
pickCalls = {}
|
||||
love.filesystem.write("picked_mod.zip", "PK\0saf")
|
||||
ri = androidImporter()
|
||||
ri:chooseMod()
|
||||
eq(ri.installs[1], "picked_mod.zip", "the SAF drop picked_mod.zip still installs on tap")
|
||||
eq(#pickCalls, 0, "without reopening the picker")
|
||||
check(love.filesystem.getInfo("picked_mod.zip") == nil, "and is retired")
|
||||
|
||||
pickCalls = {}
|
||||
ri = androidImporter()
|
||||
ri:chooseMod()
|
||||
eq(#pickCalls, 1, "the picker is asked to open")
|
||||
for _ = 1, 200 do ri:_pumpModPickOpen(1 / 60) end
|
||||
eq(ri.modNotice, nil, "no notice before four seconds")
|
||||
for _ = 1, 60 do ri:_pumpModPickOpen(1 / 60) end
|
||||
check(ri.modNotice and ri.modNotice.ok == false,
|
||||
"four seconds with no focus loss reports the picker did not open")
|
||||
local text = ri.modNotice and tostring(ri.modNotice.text) or ""
|
||||
check(text:find("did not open", 1, true) ~= nil, "saying so")
|
||||
check(text:find(love.filesystem.getSaveDirectory() .. "/imports/mods/", 1, true) ~= nil,
|
||||
"and pointing at the imports/mods folder")
|
||||
local shown = ri.modNotice
|
||||
for _ = 1, 600 do ri:_pumpModPickOpen(1 / 60) end
|
||||
eq(ri.modNotice, shown, "the notice is shown once")
|
||||
|
||||
love.filesystem.write("imports/mods/fix.zip", "PK\0inbox")
|
||||
pickCalls = {}
|
||||
local before = #ri.installs
|
||||
ri:chooseMod()
|
||||
eq(#pickCalls, 0, "after a stalled picker the next tap reads imports/mods")
|
||||
eq(#ri.installs, before, "the inbox install waits a frame")
|
||||
check(ri.modNotice and tostring(ri.modNotice.text):find("imports/mods", 1, true) ~= nil,
|
||||
"so the installing notice is drawn first")
|
||||
ri:chooseMod()
|
||||
eq(#pickCalls, 0, "a tap while the inbox install is queued opens nothing")
|
||||
ri:_pumpModInboxInstall()
|
||||
eq(#ri.installs, before, "still waiting on the frame the tap landed")
|
||||
ri:_pumpModInboxInstall()
|
||||
eq(ri.installs[#ri.installs], "imports/mods/fix.zip", "and installs the inbox copy")
|
||||
pickCalls = {}
|
||||
ri:chooseMod()
|
||||
eq(#pickCalls, 1, "the tap after that opens the picker again")
|
||||
love.filesystem.remove("imports/mods/fix.zip")
|
||||
|
||||
ri = androidImporter()
|
||||
ri:chooseMod()
|
||||
for _ = 1, 30 do ri:_pumpModPickOpen(1 / 60) end
|
||||
ri:focus(false)
|
||||
for _ = 1, 600 do ri:_pumpModPickOpen(1 / 60) end
|
||||
eq(ri.modNotice, nil, "an opened picker (focus lost) never reports a failure")
|
||||
|
||||
ri = androidImporter()
|
||||
ri:chooseMod()
|
||||
ri:_pumpModPickOpen(30)
|
||||
eq(ri.modNotice, nil, "one huge dt does not fire the notice")
|
||||
|
||||
love.system.pickFile = function() return false end
|
||||
ri = androidImporter()
|
||||
ri:chooseMod()
|
||||
check(ri.modNotice and ri.modNotice.ok == false
|
||||
and tostring(ri.modNotice.text):find("/imports/mods/", 1, true) ~= nil,
|
||||
"a refused picker points at imports/mods")
|
||||
|
||||
useOS("iOS")
|
||||
love.system.pickFile = function() return true end
|
||||
ri = androidImporter()
|
||||
ri:chooseMod()
|
||||
for _ = 1, 600 do ri:_pumpModPickOpen(1 / 60) end
|
||||
eq(ri.modNotice, nil, "iOS never reports a picker that did not open")
|
||||
end
|
||||
|
||||
local ok, err = pcall(run)
|
||||
if not ok then check(false, "desktop suite raised: " .. tostring(err)) end
|
||||
ok, err = pcall(androidRun)
|
||||
if not ok then check(false, "android suite raised: " .. tostring(err)) end
|
||||
|
||||
love.system.getOS = saved.getOS
|
||||
love.system.pickFile = saved.pickFile
|
||||
love.system.pickFileKinds = saved.pickFileKinds
|
||||
love.thread = saved.thread
|
||||
love.mouse = saved.mouse
|
||||
io.popen = saved.popen
|
||||
love.filesystem.load = saved.fsLoad
|
||||
Platform._resetForTests()
|
||||
HostPicker._reset()
|
||||
love.filesystem.remove("foo.zip")
|
||||
love.filesystem.remove("picked_mod.zip")
|
||||
love.filesystem.remove("imports/mods/fix.zip")
|
||||
|
||||
S.finish()
|
||||
@@ -94,15 +94,31 @@ do
|
||||
end
|
||||
|
||||
do
|
||||
local r = drawFight("ogred", "red", function(m) m.fade = 0.5 end)
|
||||
eq(#r, 3, "GBFadeOutToWhite adds a white wash over the replayed layer")
|
||||
local w = r[3] or {}
|
||||
eq(w.color and w.color[4], 0.5, "the wash carries the fade alpha")
|
||||
eq(w.sx, 160, "the wash spans the playfield width")
|
||||
eq(w.sy, 80, "the wash spans the playfield height")
|
||||
eq(w.x, 0, "the wash starts at the playfield left")
|
||||
eq(w.y, 32, "the wash starts under the top bar")
|
||||
check(sameClip(w.clip), "the wash is clipped to the playfield")
|
||||
local W, OBJ, BG = PaletteFX.GBC_OBJ[1], PaletteFX.GBC_OBJ, PaletteFX.GBC_BG
|
||||
local r, b = drawFight("ogred", "red", function(m) m.fadeStep = 1 end)
|
||||
eq(#r, 2, "FadePal6 replays only the two layers, no white wash")
|
||||
local nb, gb
|
||||
for _, x in ipairs(b) do
|
||||
if x.group == "gbcobj128" then nb = x end
|
||||
if x.group == "ogbg144" then gb = x end
|
||||
end
|
||||
check(nb and nb.colors[2] == W and nb.colors[3] == W and nb.colors[4] == OBJ[3],
|
||||
"FadePal6 rOBP0 $80 bakes Nidorino c1/c2 white, c3 OBJ shade 2")
|
||||
check(gb and gb.colors[2] == BG[1] and gb.colors[3] == BG[2] and gb.colors[4] == BG[3],
|
||||
"FadePal6 rBGP $90 bakes Gengar c1 white, c2 shade 1, c3 shade 2")
|
||||
r, b = drawFight("ogred", "red", function(m) m.fadeStep = 2 end)
|
||||
nb = nil
|
||||
for _, x in ipairs(b) do if x.group == "gbcobj64" then nb = x end end
|
||||
check(nb and nb.colors[2] == W and nb.colors[3] == W and nb.colors[4] == OBJ[2],
|
||||
"FadePal7 rOBP0 $40 leaves only c3 at OBJ shade 1")
|
||||
r, b = drawFight("gbc", "red", function(m) m.fadeStep = 1 end)
|
||||
eq(#r, 0, "SGB fade records no replay")
|
||||
local grp = {}
|
||||
for _, x in ipairs(b) do grp[x.group] = x end
|
||||
check(grp.fadeobp128 and grp.fadeobp128.colors[4] == PaletteFX.GRAYS[3],
|
||||
"SGB fade bakes Nidorino through rOBP0 $80 into DMG grays")
|
||||
check(grp.fadebgp144 and grp.fadebgp144.colors[3] == PaletteFX.GRAYS[2],
|
||||
"SGB fade bakes Gengar through rBGP $90 into DMG grays")
|
||||
end
|
||||
|
||||
do
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local Sound = require("src.core.Sound")
|
||||
local Timing = require("src.core.Timing")
|
||||
local IntroMovie = require("src.ui.IntroMovie")
|
||||
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function() end
|
||||
|
||||
local WAVES_START, SPLASH_FRAMES = 134, 318
|
||||
|
||||
local function newGame()
|
||||
local input = { pressed = {}, down = {} }
|
||||
function input:wasPressed(b) return self.pressed[b] or false end
|
||||
function input:isDown(b) return self.down[b] or false end
|
||||
return {
|
||||
input = input,
|
||||
data = { field = { intro = {
|
||||
fallingStar = { path = "tests/fixtures/missing_fallingstar_2385.png" },
|
||||
fallingStarBlink = { path = "tests/fixtures/missing_fallingstarblink_2385.png" },
|
||||
} } },
|
||||
stack = { pop = function() end },
|
||||
}
|
||||
end
|
||||
|
||||
local function starDraws(m, t)
|
||||
m.phase, m.timer = 2, t
|
||||
local calls = {}
|
||||
local realDraw = love.graphics.draw
|
||||
love.graphics.draw = function(img, x, y, ...)
|
||||
if img == m.smallStar or img == m.smallStarBlink then
|
||||
calls[#calls + 1] = { img = img, x = x, y = y }
|
||||
end
|
||||
end
|
||||
local ok, err = pcall(m.drawSplash, m)
|
||||
love.graphics.draw = realDraw
|
||||
check(ok, "drawSplash runs headless" .. (ok and "" or (": " .. tostring(err))))
|
||||
return calls
|
||||
end
|
||||
|
||||
local function at(calls, x)
|
||||
for _, c in ipairs(calls) do if c.x == x then return c end end
|
||||
end
|
||||
|
||||
do
|
||||
local m = IntroMovie.new(newGame())
|
||||
check(m.smallStar and m.smallStarBlink and m.smallStar ~= m.smallStarBlink,
|
||||
"both falling-star looks load")
|
||||
local c = starDraws(m, WAVES_START)
|
||||
local s = at(c, 40)
|
||||
eq(s and s.y, 89, "wave 1 first shows one row below $68 (MoveDownSmallStars runs before the delay)")
|
||||
eq(s and s.img, m.smallStarBlink, "first substep shows rOBP1 $04 (toggled once before the delay)")
|
||||
s = at(starDraws(m, WAVES_START + 3), 40)
|
||||
eq(s and s.y, 90, "second substep moves one more row")
|
||||
eq(s and s.img, m.smallStar, "second substep is back on rOBP1 $A4")
|
||||
s = at(starDraws(m, WAVES_START + 24), 48)
|
||||
eq(s and s.y, 89, "wave 2 enters one row below $68 on its first substep")
|
||||
s = at(starDraws(m, SPLASH_FRAMES - 1), 52)
|
||||
eq(s and s.y, 88 + 48 - 24, "wave 4 rests after 24 moves")
|
||||
eq(s and s.img, m.smallStar, "the 40-frame tail holds rOBP1 $A4 after 48 toggles")
|
||||
end
|
||||
|
||||
local function fightMovie()
|
||||
local game = newGame()
|
||||
local m = IntroMovie.new(game)
|
||||
m:startPhase(3)
|
||||
return m, game.input
|
||||
end
|
||||
|
||||
local function tick(m, input, n, press)
|
||||
for i = 1, n or 1 do
|
||||
input.pressed = (i == 1 and press) or {}
|
||||
m:update(1 / 60)
|
||||
end
|
||||
input.pressed = {}
|
||||
end
|
||||
|
||||
local function runToOp(m, input, idx)
|
||||
for _ = 1, 2000 do
|
||||
if m.opIndex >= idx then return true end
|
||||
tick(m, input, 1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function fadeRun(m, input)
|
||||
local steps = {}
|
||||
for _ = 1, 40 do
|
||||
tick(m, input, 1)
|
||||
if m.phase == 4 then steps[#steps + 1] = 4 break end
|
||||
steps[#steps + 1] = m.fadeStep or 0
|
||||
end
|
||||
return steps
|
||||
end
|
||||
|
||||
local function isStepped(steps)
|
||||
if #steps ~= 24 then return false end
|
||||
for i = 1, 23 do
|
||||
if steps[i] ~= math.floor((i - 1) / 8) + 1 then return false end
|
||||
end
|
||||
return steps[24] == 4
|
||||
end
|
||||
|
||||
do
|
||||
local m, input = fightMovie()
|
||||
tick(m, input, Timing.DELAY3, { a = true })
|
||||
eq(m.opIndex, 1, "A during PlayShootingStar's Delay3 is not polled")
|
||||
eq(m.fadeStep, nil, "the deaf Delay3 starts no fade")
|
||||
tick(m, input, 1)
|
||||
local x = m.gengarX
|
||||
tick(m, input, 1, { a = true })
|
||||
eq(m.gengarX, x, "scroll-in odd frame moves nothing")
|
||||
local steps = fadeRun(m, input)
|
||||
check(isStepped(steps), "an interrupted scroll-in still runs GBFadeOutToWhite: 8 frames FadePal6, 8 FadePal7, then white ("
|
||||
.. table.concat(steps, ",") .. ")")
|
||||
end
|
||||
|
||||
do
|
||||
local m, input = fightMovie()
|
||||
check(runToOp(m, input, 3), "reached the first hip animation")
|
||||
tick(m, input, 1, { a = true })
|
||||
tick(m, input, 3)
|
||||
eq(m.fadeStep, nil, "AnimateIntroNidorino's DelayFrames 5 is deaf")
|
||||
eq(m.phase, 3, "the fight goes on after a press inside an animation")
|
||||
end
|
||||
|
||||
do
|
||||
local m, input = fightMovie()
|
||||
check(runToOp(m, input, 6), "reached the 10-frame wait")
|
||||
tick(m, input, 1, { start = true })
|
||||
check(isStepped(fadeRun(m, input)), "START inside a CheckForUserInterruption wait ends the scene into the stepped fade")
|
||||
end
|
||||
|
||||
do
|
||||
local m, input = fightMovie()
|
||||
check(runToOp(m, input, 12), "reached the raise pose")
|
||||
local x = m.gengarX
|
||||
tick(m, input, 1)
|
||||
eq(m.opIndex, 14, "pose and SFX_INTRO_RAISE chain into the move on one frame")
|
||||
eq(m.gengarX, x - 2, "the raise's first SCX step lands on that frame")
|
||||
tick(m, input, 1, { a = true })
|
||||
eq(m.gengarX, x - 2, "a press on the step's second poll frame stops the move")
|
||||
eq(m.opIndex, 15, "IntroMoveMon returns early and the scene moves on to the next wait")
|
||||
eq(m.fadeStep, nil, "an interrupted Gengar move does not end the scene")
|
||||
end
|
||||
|
||||
do
|
||||
local m, input = fightMovie()
|
||||
check(runToOp(m, input, 6), "reached the 10-frame wait again")
|
||||
input.down = { b = true }
|
||||
tick(m, input, 2)
|
||||
eq(m.fadeStep, nil, "B alone never interrupts")
|
||||
input.down = { up = true, select = true, b = true }
|
||||
tick(m, input, 1)
|
||||
input.down = {}
|
||||
check(isStepped(fadeRun(m, input)), "holding exactly UP+SELECT+B interrupts (home/overworld.asm:2404)")
|
||||
end
|
||||
|
||||
do
|
||||
local m, input = fightMovie()
|
||||
m.lastPollHeld = { a = true }
|
||||
input.down = { up = true, select = true, b = true, a = true }
|
||||
check(runToOp(m, input, 6), "reached the wait with A held too")
|
||||
tick(m, input, 2)
|
||||
eq(m.fadeStep, nil, "UP+SELECT+B with another button held is not the combo")
|
||||
end
|
||||
|
||||
do
|
||||
local m, input = fightMovie()
|
||||
check(runToOp(m, input, 3), "reached the first hip animation for the held press")
|
||||
input.down = { a = true }
|
||||
tick(m, input, 3)
|
||||
eq(m.fadeStep, nil, "A pressed and held inside a deaf animation is not seen there")
|
||||
check(runToOp(m, input, 6), "reached the next CheckForUserInterruption wait")
|
||||
tick(m, input, 1)
|
||||
input.down = {}
|
||||
check(isStepped(fadeRun(m, input)),
|
||||
"A still held at the next poll is new against hJoyLast and interrupts (home/joypad2.asm:16)")
|
||||
end
|
||||
|
||||
do
|
||||
local m, input = fightMovie()
|
||||
m.lastPollHeld = { a = true }
|
||||
input.down = { a = true }
|
||||
check(runToOp(m, input, 7), "A held since the previous poll runs straight through the wait")
|
||||
eq(m.fadeStep, nil, "a button already held at the last poll is not a new press")
|
||||
input.down = {}
|
||||
end
|
||||
|
||||
local function modMovie()
|
||||
local game = newGame()
|
||||
game.data.field.intro.gamefreakLogo = { path = "mods/demo/gamefreak_logo.png" }
|
||||
game.data.field.intro.gamefreakText = { path = "tests/fixtures/missing_gftext_2385.png" }
|
||||
return IntroMovie.new(game)
|
||||
end
|
||||
|
||||
do
|
||||
local m = modMovie()
|
||||
eq(m.logoPath, nil, "mod-supplied logo art keeps the raw draw path")
|
||||
eq(m.gfTextPath, "tests/fixtures/missing_gftext_2385.png", "ROM text art still bakes through rOBP0")
|
||||
end
|
||||
|
||||
Sound.play = realPlay
|
||||
T.finish("intro_splash_fight_timing_bug2385")
|
||||
@@ -0,0 +1,208 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
local IntroMovie = require("src.ui.IntroMovie")
|
||||
|
||||
local prevVersion, prevMode = GameVersion.get(), PaletteFX.mode
|
||||
|
||||
local STAR_START, FLASH_START = 64, 104
|
||||
local WAVES_START, SPLASH_FRAMES = 134, 318
|
||||
|
||||
local LOGO = "tests/fixtures/missing_gflogo_2385.png"
|
||||
local TEXT = "tests/fixtures/missing_gftext_2385.png"
|
||||
local STAR = "tests/fixtures/missing_bigstar_2385.png"
|
||||
|
||||
local function pal(n)
|
||||
return { { 255, 239, 255 }, { n, 200, 100 }, { n, 90, 40 }, { 16, 16, 16 } }
|
||||
end
|
||||
local PALS = { palettes = {
|
||||
GAMEFREAK = pal(1), REDMON = pal(2), VIRIDIAN = pal(3), BLUEMON = pal(4),
|
||||
} }
|
||||
|
||||
local function newGame(studio)
|
||||
return {
|
||||
data = { palettes = PALS, field = { intro = {
|
||||
gamefreakLogo = { path = LOGO },
|
||||
gamefreakText = { path = TEXT },
|
||||
bigStar = { path = STAR },
|
||||
studio = studio,
|
||||
} } },
|
||||
stack = { pop = function() end },
|
||||
}
|
||||
end
|
||||
|
||||
local bakes = {}
|
||||
local realObp = SpriteRenderer.obpImage
|
||||
SpriteRenderer.obpImage = function(path, colors, group)
|
||||
bakes[#bakes + 1] = { path = path, colors = colors, group = group }
|
||||
return realObp(path, colors, group)
|
||||
end
|
||||
|
||||
local function sameRamp(a, b)
|
||||
if not (a and b) then return false end
|
||||
for i = 1, 4 do
|
||||
if a[i] ~= b[i] then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function ramp(base, i1, i2, i3)
|
||||
return { base[1], base[i1 + 1], base[i2 + 1], base[i3 + 1] }
|
||||
end
|
||||
|
||||
local function bakeFor(list, path, group)
|
||||
for _, b in ipairs(list) do
|
||||
if b.path == path and b.group == group then return b end
|
||||
end
|
||||
end
|
||||
|
||||
local function drawSplash(mode, version, timer)
|
||||
GameVersion.set(version)
|
||||
PaletteFX.setMode(mode)
|
||||
local movie = IntroMovie.new(newGame())
|
||||
movie.phase = 2
|
||||
movie.timer = timer
|
||||
local before = #bakes
|
||||
PaletteFX.clearSpriteRedraws()
|
||||
PaletteFX.setPass("ui")
|
||||
local ok, err = pcall(movie.draw, movie)
|
||||
PaletteFX.setPass(nil)
|
||||
check(ok, mode .. "/" .. version .. " splash draw runs headless" .. (ok and "" or (": " .. tostring(err))))
|
||||
local out, newBakes = {}, {}
|
||||
for i, r in ipairs(PaletteFX.uiSpriteRedraws()) do out[i] = r end
|
||||
for i = before + 1, #bakes do newBakes[#newBakes + 1] = bakes[i] end
|
||||
PaletteFX.clearSpriteRedraws()
|
||||
return out, newBakes, movie
|
||||
end
|
||||
|
||||
do
|
||||
local game = newGame()
|
||||
for _, v in ipairs({ "red", "blue" }) do
|
||||
GameVersion.set(v)
|
||||
PaletteFX.setMode("ogred")
|
||||
local movie = IntroMovie.new(game)
|
||||
local z = movie:sgbPalettes(game)
|
||||
check(z and z[1], "ogred/" .. v .. ": the copyright card has palette zones")
|
||||
check(z and z[1] and sameRamp(z[1].colors, PaletteFX.ogBg()),
|
||||
"ogred/" .. v .. ": the copyright card wears the boot-ROM BG ramp")
|
||||
PaletteFX.setMode("gbc")
|
||||
z = movie:sgbPalettes(game)
|
||||
eq(z and #z, 4, "gbc/" .. v .. ": the copyright card gets PalPacket_GameFreakIntro's four zones")
|
||||
movie.phase = 2
|
||||
local z2 = movie:sgbPalettes(game)
|
||||
check(z and z2 and sameRamp(z[1].colors, z2[1].colors),
|
||||
"gbc/" .. v .. ": the card and the splash share PAL_GAMEFREAK")
|
||||
end
|
||||
GameVersion.set("red")
|
||||
PaletteFX.setMode("ogred")
|
||||
local mod = newGame()
|
||||
mod.data.field.intro.studio = { card = "A MOD" }
|
||||
local movie = IntroMovie.new(mod)
|
||||
eq(movie:sgbPalettes(mod), nil, "a studio text card stays untinted")
|
||||
end
|
||||
|
||||
do
|
||||
local movie = IntroMovie.new(newGame())
|
||||
local function at(t)
|
||||
movie.timer = t
|
||||
return movie.logoObp0 and movie:logoObp0()
|
||||
end
|
||||
eq(at(STAR_START), 0xF9, "rOBP0 starts at $F9")
|
||||
eq(at(FLASH_START - 1), 0xF9, "rOBP0 holds $F9 until the flash loop")
|
||||
eq(at(FLASH_START), 0x7E, "first rrc rrc gives $7E")
|
||||
eq(at(FLASH_START + 9), 0x7E, "$7E holds 10 frames")
|
||||
eq(at(FLASH_START + 10), 0x9F, "second rotation gives $9F")
|
||||
eq(at(FLASH_START + 20), 0xE7, "third rotation gives $E7")
|
||||
eq(at(WAVES_START + 10), 0xE7, "$E7 stays through the small-star waves")
|
||||
eq(at(SPLASH_FRAMES - 1), 0xE7, "$E7 stays through the 40-frame tail")
|
||||
end
|
||||
|
||||
do
|
||||
local G = PaletteFX.GRAYS
|
||||
local _, b = drawSplash("gbc", "red", STAR_START + 5)
|
||||
local logo = bakeFor(b, LOGO, "gfobp" .. 0xF9)
|
||||
check(logo and sameRamp(logo.colors, ramp(G, 2, 3, 3)),
|
||||
"$F9 draws the logo's colour 2 as black on the canvas")
|
||||
local text = bakeFor(b, TEXT, "gfobp" .. 0xF9)
|
||||
check(text and sameRamp(text.colors, ramp(G, 2, 3, 3)),
|
||||
"$F9 draws the text's colour 3 as black on the canvas")
|
||||
local star = bakeFor(b, STAR, "gfobp" .. 0xA4)
|
||||
check(star and sameRamp(star.colors, ramp(G, 1, 2, 2)),
|
||||
"rOBP1 $A4 draws the big star's colour 3 as shade 2")
|
||||
_, b = drawSplash("gbc", "red", FLASH_START + 5)
|
||||
logo = bakeFor(b, LOGO, "gfobp" .. 0x7E)
|
||||
check(logo and sameRamp(logo.colors, ramp(G, 3, 3, 1)),
|
||||
"$7E: logo black, text light")
|
||||
_, b = drawSplash("gbc", "red", FLASH_START + 15)
|
||||
logo = bakeFor(b, LOGO, "gfobp" .. 0x9F)
|
||||
check(logo and sameRamp(logo.colors, ramp(G, 3, 1, 2)),
|
||||
"$9F: logo light, text dark")
|
||||
_, b = drawSplash("og", "red", WAVES_START + 10)
|
||||
logo = bakeFor(b, LOGO, "gfobp" .. 0xE7)
|
||||
check(logo and sameRamp(logo.colors, ramp(G, 1, 2, 3)),
|
||||
"$E7 settles the logo on shade 2 and the text on black")
|
||||
end
|
||||
|
||||
do
|
||||
local O = PaletteFX.GBC_OBJ
|
||||
local r, b = drawSplash("ogred", "red", STAR_START + 5)
|
||||
eq(#r, 3, "ogred red: logo, text and big star are replayed over the zone pass")
|
||||
local logo, text, star = r[1] or {}, r[2] or {}, r[3] or {}
|
||||
eq(logo.x, 72, "logo replays at x 72")
|
||||
eq(logo.y, 56, "logo replays at y 56")
|
||||
eq(text.x, 40, "text replays at x 40")
|
||||
eq(text.y, 80, "text replays at y 80")
|
||||
local lb = bakeFor(b, LOGO, "gfobj" .. 0xF9)
|
||||
check(lb and sameRamp(lb.colors, { O[1], O[3], O[4], O[4] }),
|
||||
"ogred red: the logo starts black through OBJ0")
|
||||
eq(logo.image, SpriteRenderer.obpImage(LOGO, lb and lb.colors or O, "gfobj" .. 0xF9),
|
||||
"the replayed logo is the OBJ0 bake")
|
||||
local sb = bakeFor(b, STAR, "gfobj1" .. 0xA4)
|
||||
check(sb and sameRamp(sb.colors, ramp(PaletteFX.GBC_BG, 1, 2, 2)),
|
||||
"ogred red: the big star replays through OBJ1 = the BG ramp")
|
||||
eq(star.image, SpriteRenderer.obpImage(STAR, sb and sb.colors or O, "gfobj1" .. 0xA4),
|
||||
"the big star replays last so it stays over the logo")
|
||||
|
||||
r, b = drawSplash("ogred", "red", WAVES_START + 10)
|
||||
eq(#r, 2, "ogred red: after the star, only logo and text replay")
|
||||
lb = bakeFor(b, LOGO, "gfobj" .. 0xE7)
|
||||
check(lb and sameRamp(lb.colors, { O[1], O[2], O[3], O[4] }),
|
||||
"ogred red: $E7 leaves the logo dark green and the text black")
|
||||
_, b = drawSplash("ogred", "red", FLASH_START + 5)
|
||||
lb = bakeFor(b, TEXT, "gfobj" .. 0x7E)
|
||||
check(lb and sameRamp(lb.colors, { O[1], O[4], O[4], O[2] }),
|
||||
"ogred red: the first flash turns the text light green")
|
||||
end
|
||||
|
||||
do
|
||||
local OB = PaletteFX.GBC_OBJ_BLUE
|
||||
local r, b = drawSplash("ogred", "blue", WAVES_START + 10)
|
||||
eq(#r, 2, "ogred blue: logo and text replay")
|
||||
local lb = bakeFor(b, LOGO, "gfobj_blue" .. 0xE7)
|
||||
check(lb and sameRamp(lb.colors, { OB[1], OB[2], OB[3], OB[4] }),
|
||||
"ogred blue: the logo bakes Blue's pink OBJ0 under its own group")
|
||||
check(not bakeFor(b, LOGO, "gfobj" .. 0xE7), "ogred blue never bakes into the red group")
|
||||
end
|
||||
|
||||
do
|
||||
for _, mode in ipairs({ "gbc", "og", "classic", "redpp" }) do
|
||||
local r = drawSplash(mode, "red", WAVES_START + 10)
|
||||
eq(#r, 0, mode .. " records no splash OBJ replay")
|
||||
end
|
||||
local r = drawSplash("ogred", "yellow", WAVES_START + 10)
|
||||
eq(#r, 0, "OG YELLOW keeps the CGB zone path")
|
||||
r = drawSplash("ogred", "red", 0)
|
||||
eq(#r, 0, "nothing replays before the star")
|
||||
end
|
||||
|
||||
SpriteRenderer.obpImage = realObp
|
||||
GameVersion.set(prevVersion)
|
||||
PaletteFX.setMode(prevMode)
|
||||
|
||||
T.finish()
|
||||
@@ -129,11 +129,169 @@ check(faceJessie ~= nil and rows[faceJessie][3] == "down",
|
||||
check(faceJames ~= nil and rows[faceJames][3] == "left",
|
||||
"Script11 leaves James facing LEFT at the player")
|
||||
|
||||
local facePlayer = indexOf(rows, isRow("face_player_dir"))
|
||||
check(facePlayer ~= nil and rows[facePlayer][2] == "up",
|
||||
"the player turns up to the duo, not right")
|
||||
local emote = indexOf(rows, isRow("emote", "player"))
|
||||
check(emote ~= nil, "the player gets the exclamation bubble TEXT12 shows")
|
||||
-- scripts/MtMoonB2F.asm:446
|
||||
local function rowsWithPlayer(site, x, y, flags, player)
|
||||
local captured = nil
|
||||
local ow = { player = player,
|
||||
runner = { run = function(_, r) captured = r end } }
|
||||
sites[site].onStep({ save = { flags = flags or {} } }, ow, x, y)
|
||||
return captured, ow
|
||||
end
|
||||
|
||||
local SITES = {
|
||||
{ site = "MT_MOON_B2F", x = 3, y = 5, flags = armed,
|
||||
motto = "_MtMoonJessieJamesText1", parting = "_MtMoonJessieJamesText4",
|
||||
challenge = "_MtMoonJessieJamesText2", face = "up", duo = { 2, 6 } },
|
||||
{ site = "ROCKET_HIDEOUT_B4F", x = 24, y = 14, flags = {},
|
||||
motto = "_RocketHideoutJessieJamesText1",
|
||||
parting = "_RocketHideoutJessieJamesText4",
|
||||
challenge = "_RocketHideoutJessieJamesText2", face = "up", duo = { 2, 3 } },
|
||||
{ site = "POKEMON_TOWER_7F", x = 10, y = 12, flags = {},
|
||||
motto = "_PokemonTowerJessieJamesText1",
|
||||
parting = "_PokemonTowerJessieJamesText4",
|
||||
challenge = "_PokemonTowerJessieJamesText2", face = "up", duo = { 1, 2 } },
|
||||
{ site = "SILPH_CO_11F", x = 3, y = 3,
|
||||
flags = { EVENT_BEAT_SILPH_CO_GIOVANNI = true },
|
||||
motto = "_SilphCoJessieJamesText1", parting = "_SilphCoJessieJamesText4",
|
||||
challenge = "_SilphCoJessieJamesText2", face = "down", duo = { 4, 6 } },
|
||||
}
|
||||
|
||||
for _, s in ipairs(SITES) do
|
||||
local player = { facing = "right" }
|
||||
local sr, sow = rowsWithPlayer(s.site, s.x, s.y, s.flags, player)
|
||||
sr = sr or {}
|
||||
local m = indexOf(sr, isRow("show_text", s.motto))
|
||||
check(m ~= nil, s.site .. " plays the motto")
|
||||
m = m or 1
|
||||
local armedRow = sr[m - 1]
|
||||
local auto = armedRow and armedRow[1] == "text_opts"
|
||||
and type(armedRow[2]) == "table" and armedRow[2].auto
|
||||
check(type(auto) == "table", s.site .. " motto box is armed no-wait")
|
||||
auto = type(auto) == "table" and auto or {}
|
||||
eq(auto.delay, 91, s.site .. " motto box closes 10+60+1+20 frames after typing")
|
||||
check(not auto.wait, s.site .. " motto box never waits for A/B")
|
||||
check(type(auto.tick) == "function", s.site .. " motto box drives the bubble")
|
||||
|
||||
local firstWalk = indexOf(sr, isRow("walk_npc")) or #sr
|
||||
check(indexOf(sr, isRow("face_player_dir")) == nil
|
||||
or indexOf(sr, isRow("face_player_dir")) > firstWalk,
|
||||
s.site .. " has no separate turn row after the box closes")
|
||||
check(indexOf(sr, isRow("emote")) == nil,
|
||||
s.site .. " has no separate bubble row after the box closes")
|
||||
|
||||
if type(auto.tick) == "function" then
|
||||
local early, during, late = true, true, true
|
||||
for t = 1, 91 do
|
||||
auto.tick()
|
||||
if t <= 10 and sow.emote ~= nil then early = false end
|
||||
if t >= 11 and t <= 70 then
|
||||
if not (sow.emote and sow.emote.npc == player)
|
||||
or player.facing ~= "right" then during = false end
|
||||
end
|
||||
if t >= 71 and (sow.emote ~= nil or player.facing ~= s.face) then
|
||||
late = false
|
||||
end
|
||||
end
|
||||
check(early, s.site .. " DelayFrames 10 before the bubble")
|
||||
check(during, s.site .. " bubble over the player for 60 frames, old facing kept")
|
||||
check(late, s.site .. " bubble gone, player turned " .. s.face .. " with the box still up")
|
||||
end
|
||||
|
||||
local ch = indexOf(sr, isRow("show_text", s.challenge)) or #sr
|
||||
local fastOk, faceOk, walks, faces = true, true, 0, 0
|
||||
for i = 1, ch do
|
||||
local r = sr[i]
|
||||
if r[1] == "walk_npc" and r[2] ~= "player" then
|
||||
walks = walks + 1
|
||||
if not (type(r[4]) == "table" and r[4].stepFrames == 16) then
|
||||
fastOk = false
|
||||
end
|
||||
elseif r[1] == "walk_npc" and r[2] == "player" then
|
||||
if type(r[4]) == "table" and r[4].stepFrames then fastOk = false end
|
||||
elseif r[1] == "face_object" and i > firstWalk then
|
||||
faces = faces + 1
|
||||
if not (type(r[4]) == "table" and (r[4].hold or 0) >= 256) then
|
||||
faceOk = false
|
||||
end
|
||||
end
|
||||
end
|
||||
eq(walks, 2, s.site .. " walks both of the duo")
|
||||
check(fastOk, s.site .. " duo walks at the player's 16-frame step")
|
||||
eq(faces, 2, s.site .. " faces both of the duo after their walks")
|
||||
check(faceOk, s.site .. " post-walk facings hold past the STAY turn timer")
|
||||
|
||||
local p = indexOf(sr, isRow("show_text", s.parting))
|
||||
check(p ~= nil, s.site .. " plays the parting line")
|
||||
p = p or 1
|
||||
local pArm = sr[p - 1]
|
||||
local pAuto = pArm and pArm[1] == "text_opts" and type(pArm[2]) == "table"
|
||||
and pArm[2].auto
|
||||
check(type(pAuto) == "table" and pAuto.delay == 64 and not pAuto.wait,
|
||||
s.site .. " parting box closes itself after DelayFrames 64")
|
||||
local downs = 0
|
||||
for i = math.max(1, p - 4), p - 1 do
|
||||
local r = sr[i]
|
||||
if r and r[1] == "face_object" and r[3] == "down"
|
||||
and (r[2] == s.duo[1] or r[2] == s.duo[2]) then
|
||||
downs = downs + 1
|
||||
end
|
||||
end
|
||||
eq(downs, 2, s.site .. " both of the duo face down before the parting line")
|
||||
end
|
||||
|
||||
do
|
||||
local Commands = require("src.script.Commands")
|
||||
local NPC = require("src.world.NPC")
|
||||
local npc = setmetatable({ facing = "left", pinnedFacing = "left",
|
||||
wanders = true, timer = 5, moving = false,
|
||||
cellX = 9, cellY = 3, px = 144, py = 48 }, NPC)
|
||||
local player = { stepFrames = 16, cellX = 3, cellY = 5 }
|
||||
local frames, stepDuring, playerDuring = 0, {}, {}
|
||||
local ow = { player = player }
|
||||
function ow.npcByIndex(_, i) if i == 2 then return npc end end
|
||||
function ow.scriptMove(_, e, dir, _, onDone)
|
||||
if e == player then
|
||||
playerDuring[#playerDuring + 1] = e.stepFrames
|
||||
onDone()
|
||||
return
|
||||
end
|
||||
stepDuring[#stepDuring + 1] = e.stepFrames
|
||||
e.facing = dir
|
||||
e.targetX, e.targetY = e.cellX - 1, e.cellY
|
||||
e.moving, e.progress = true, 0
|
||||
while e.moving do
|
||||
e:update(nil, {})
|
||||
frames = frames + 1
|
||||
end
|
||||
onDone()
|
||||
end
|
||||
local ctx = { overworld = ow, runner = {} }
|
||||
Commands.walk_npc(ctx, 2, { "left", "left", "left", "left", "left", "left" },
|
||||
{ stepFrames = 16 })
|
||||
eq(frames, 96, "six fast steps take 96 frames, not 192")
|
||||
eq(stepDuring[1], 16, "walk_npc stepFrames applies during the walk")
|
||||
eq(npc.stepFrames, nil, "walk_npc restores the NPC step length after")
|
||||
Commands.walk_npc(ctx, "player", { "up" }, { stepFrames = 8 })
|
||||
eq(playerDuring[1], 16, "walk_npc stepFrames never touches the player")
|
||||
|
||||
frames = 0
|
||||
Commands.walk_npc(ctx, 2, { "left" })
|
||||
eq(frames, 32, "walk_npc with no opts keeps the 32-frame NPC step")
|
||||
|
||||
npc.timer = 5
|
||||
Commands.face_object(ctx, 2, "down", { hold = 510 })
|
||||
eq(npc.timer, 510, "face_object hold arms the turn timer")
|
||||
local held = true
|
||||
for _ = 1, 200 do
|
||||
npc:update(nil, {})
|
||||
if npc.facing ~= "down" then held = false end
|
||||
end
|
||||
check(held, "a held facing survives 200 frames of the STAY pinned-facing timer")
|
||||
|
||||
npc.timer = 5
|
||||
Commands.face_object(ctx, 2, "down")
|
||||
eq(npc.timer, 5, "face_object without hold leaves the timer alone")
|
||||
end
|
||||
|
||||
if challenge and jamesWalk and faceJames then
|
||||
check(faceJames < challenge and challenge < (battle or math.huge),
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
-- scripts/PewterPokecenter_2.asm:10-68
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local SONG_POLLS = 16
|
||||
|
||||
local music = { polls = 0 }
|
||||
package.loaded["src.core.Music"] = {
|
||||
ONE_SHOT_CEILING = 600,
|
||||
stop = function() end,
|
||||
restoreMap = function() end,
|
||||
play = function() end,
|
||||
playOnce = function() music.polls = 0; return true end,
|
||||
oneShotPlaying = function()
|
||||
music.polls = music.polls + 1
|
||||
return music.polls <= SONG_POLLS
|
||||
end,
|
||||
}
|
||||
|
||||
local Sound = require("src.core.Sound")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local story5 = require("data.scripts.story5")
|
||||
|
||||
Sound.setRate(1)
|
||||
|
||||
local function newGame(party, player)
|
||||
local game = {
|
||||
save = { player = player or {}, flags = {}, options = { textSpeed = "FAST" },
|
||||
party = party },
|
||||
data = { audio = { fanfares = {}, sfx = {}, songs = {} }, text = {} },
|
||||
logicSpeed = function() return 1 end,
|
||||
}
|
||||
game.stack = {
|
||||
states = {},
|
||||
push = function(self, s) table.insert(self.states, s) end,
|
||||
pop = function(self) return table.remove(self.states) end,
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
}
|
||||
game.input = {
|
||||
queue = {},
|
||||
wasPressed = function(self, btn) return self.queue[btn] or false end,
|
||||
isDown = function(self, btn) return self.queue[btn] or false end,
|
||||
}
|
||||
return game
|
||||
end
|
||||
|
||||
local handler = story5.PEWTER_POKECENTER.talk.TEXT_PEWTERPOKECENTER_JIGGLYPUFF
|
||||
|
||||
local function dance(party, player)
|
||||
local game = newGame(party, player)
|
||||
local ow, npc = {}, { facing = "left" }
|
||||
local fired = false
|
||||
handler(game, ow, npc, function() fired = true end)
|
||||
local box = game.stack:top()
|
||||
local res = { turns = 0, held = 0, ow = ow, box = box }
|
||||
local last = npc.facing
|
||||
for _ = 1, 5000 do
|
||||
if game.stack:top() ~= box then break end
|
||||
box:update(1 / 60)
|
||||
if box.autoStarted then res.held = res.held + 1 end
|
||||
if npc.facing ~= last then
|
||||
res.turns = res.turns + 1
|
||||
last = npc.facing
|
||||
end
|
||||
end
|
||||
res.closed = game.stack:top() ~= box
|
||||
res.fired = fired
|
||||
return res
|
||||
end
|
||||
|
||||
GameVersion.set("yellow")
|
||||
|
||||
do
|
||||
local r = dance({ { species = "PIKACHU", hp = 20 } })
|
||||
check(r.closed, "the dance box closes itself")
|
||||
check(r.fired, "the talk callback fires once the box pops")
|
||||
check(r.held >= 32 + (SONG_POLLS + 1) * 24 + 48,
|
||||
("the box stays up for the whole song (held %d frames)"):format(r.held))
|
||||
eq(r.turns, SONG_POLLS, "one quarter turn per 24-frame poll while the song plays")
|
||||
check(r.turns >= 16, "at least four full spins")
|
||||
eq(r.ow.pikachuPewterSleepScene, true,
|
||||
"a healthy starter PIKACHU falls asleep once the dance ends")
|
||||
end
|
||||
|
||||
do
|
||||
local r = dance({ { species = "PIKACHU", hp = 20, status = "PSN" } })
|
||||
check(r.closed, "statused: the dance still ends")
|
||||
eq(r.ow.pikachuPewterSleepScene, nil,
|
||||
"a statused starter PIKACHU stays awake and following")
|
||||
end
|
||||
|
||||
do
|
||||
local r = dance({ { species = "BULBASAUR", hp = 20 } })
|
||||
eq(r.ow.pikachuPewterSleepScene, nil, "no PIKACHU in the party, no sleep scene")
|
||||
end
|
||||
|
||||
do
|
||||
local player = { id = 1234, name = "YELLOW" }
|
||||
local r = dance({ { species = "PIKACHU", hp = 20, otId = 999, ot = "BRUNO" } }, player)
|
||||
eq(r.ow.pikachuPewterSleepScene, nil, "a traded PIKACHU is not the starter, no sleep scene")
|
||||
r = dance({ { species = "PIKACHU", hp = 20, otId = 999, ot = "BRUNO" },
|
||||
{ species = "PIKACHU", hp = 20, otId = 1234, ot = "YELLOW" } }, player)
|
||||
eq(r.ow.pikachuPewterSleepScene, true, "the OT-matched starter behind a traded PIKACHU falls asleep")
|
||||
r = dance({ { species = "PIKACHU", hp = 20, otId = 999, ot = "BRUNO" },
|
||||
{ species = "PIKACHU", hp = 20, otId = 1234, ot = "YELLOW", status = "SLP" } }, player)
|
||||
eq(r.ow.pikachuPewterSleepScene, nil, "the status check reads the starter, not a healthy traded PIKACHU")
|
||||
end
|
||||
|
||||
GameVersion.set("red")
|
||||
|
||||
do
|
||||
local r = dance({ { species = "PIKACHU", hp = 20 } })
|
||||
check(r.turns >= 16, "Red: the same full-length dance")
|
||||
eq(r.ow.pikachuPewterSleepScene, nil, "Red: no sleep scene")
|
||||
end
|
||||
|
||||
do
|
||||
local game = newGame({})
|
||||
local ow, npc = {}, { facing = "left" }
|
||||
handler(game, ow, npc, function() end)
|
||||
local gate = game.stack:top().auto.sound()
|
||||
check(Sound.waitFrames(gate) > 32 + 600 + 24 + 48,
|
||||
"the gate's wait budget covers the longest possible dance")
|
||||
end
|
||||
|
||||
T.finish("jigglypuff_dance_gate_bug2335")
|
||||
@@ -290,9 +290,12 @@ do
|
||||
optionsFile:close()
|
||||
check(optionsSrc:find("tasks.update = false", 1, true) ~= nil,
|
||||
"legacy Android intents never run the update check")
|
||||
local bootAt = src:find("if not Prelaunch then bootShortcut()", 1, true)
|
||||
local bootAt = src:find("if not Prelaunch then bootAfterMods()", 1, true)
|
||||
check(bootAt ~= nil,
|
||||
"and boots on the same frame when there is no stage to run")
|
||||
check(src:find("if not wantsModUpdate(request) then return bootShortcut() end",
|
||||
1, true) ~= nil,
|
||||
"straight into the game unless the mods pass was asked for")
|
||||
end
|
||||
|
||||
T.finish("shortcut pre-boot stage (#1657)")
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
local LaunchOptions = require("src.core.LaunchOptions")
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local Platform = require("src.core.Platform")
|
||||
local CartStore = require("src.carts.CartStore")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
local realGetenv = os.getenv
|
||||
local env = {}
|
||||
os.getenv = function(name)
|
||||
if env[name] ~= nil then return env[name] end
|
||||
return realGetenv(name)
|
||||
end
|
||||
|
||||
local function tasks(argv, rawArgv, uri)
|
||||
return LaunchOptions.tasks(argv, rawArgv, uri or false)
|
||||
end
|
||||
|
||||
do
|
||||
eq(tasks({}, nil).mods, false, "with no flag the mods pass is off")
|
||||
eq(tasks({ "--update-mods" }, nil).mods, true, "--update-mods turns it on")
|
||||
eq(tasks({ "--updatemods" }, nil).mods, true,
|
||||
"--updatemods, the spelling from the request, does too")
|
||||
eq(tasks({ "-update-mods" }, nil).mods, true, "as does -update-mods")
|
||||
eq(tasks({}, { "--update-mods" }).mods, true,
|
||||
"a copy only in the raw argv is still found")
|
||||
eq(tasks({ "--update-mods", "--no-update-mods" }, nil).mods, false,
|
||||
"--no-update-mods beats the positive flag")
|
||||
eq(tasks({ "--updatemods", "--no-update-mods" }, nil).mods, false,
|
||||
"across the two spellings")
|
||||
eq(tasks({ "--update" }, nil).mods, false,
|
||||
"--update stays the engine updater and never touches mods")
|
||||
eq(tasks({ "--update-mods" }, nil).update, false,
|
||||
"and --update-mods never runs the engine updater")
|
||||
env.POKEPORT_LAUNCH_UPDATE_MODS = "1"
|
||||
eq(tasks({}, nil).mods, true, "the env var turns it on")
|
||||
eq(tasks({ "--no-update-mods" }, nil).mods, false, "a flag beats the env")
|
||||
env.POKEPORT_LAUNCH_UPDATE_MODS = "0"
|
||||
eq(tasks({}, nil).mods, false, "and 0 leaves it off")
|
||||
env.POKEPORT_LAUNCH_UPDATE_MODS = "maybe"
|
||||
eq(tasks({}, nil).mods, false, "anything else leaves it off")
|
||||
env.POKEPORT_LAUNCH_UPDATE_MODS = nil
|
||||
end
|
||||
|
||||
do
|
||||
local uri = LaunchOptions.parseURI(
|
||||
"gen1recomp++://launch?game=red&update_mods=1")
|
||||
eq(uri and uri.updateMods, true, "a launch URI can ask for the mods pass")
|
||||
eq(tasks({}, nil, uri).mods, true, "and the task picks it up")
|
||||
eq(tasks({ "--no-update-mods" }, nil, uri).mods, false,
|
||||
"the command line still wins over the URI")
|
||||
local built = LaunchOptions.uriFor("red", { updateMods = true })
|
||||
check(built and built:find("update_mods=1", 1, true) ~= nil,
|
||||
"uriFor writes the parameter")
|
||||
eq(LaunchOptions.parseURI(built).updateMods, true, "which reads back")
|
||||
local intent = LaunchOptions.fromGame("red")
|
||||
eq(intent and intent.tasks.mods, false,
|
||||
"a legacy Android intent never runs the mods pass")
|
||||
check(LaunchOptions.commandFor("red", nil, { mods = true })
|
||||
:find("--update-mods", 1, true) ~= nil,
|
||||
"the shortcut command quotes the flag")
|
||||
end
|
||||
|
||||
local RELEASES = {
|
||||
{ version = "2.0.0", zip = { url = "https://example.invalid/a.zip" } },
|
||||
}
|
||||
|
||||
local oldBeginFetch = ModUpdate.beginFetchReleases
|
||||
local oldBeginZip = ModUpdate.beginDownloadZip
|
||||
local oldPumpZip = ModUpdate.pumpDownloadZip
|
||||
local oldInstall = LauncherMods.installDownloadedZip
|
||||
local oldDeps = LauncherMods.checkDependencies
|
||||
local oldRemote = Platform.canFetchRemote
|
||||
local oldCartList = CartStore.list
|
||||
local oldCartIndex = CartStore.index
|
||||
local oldCartListFor = CartStore.listFor
|
||||
|
||||
local failId = nil
|
||||
local installs = {}
|
||||
local depIssue = false
|
||||
|
||||
ModUpdate.beginFetchReleases = function() return {} end
|
||||
ModUpdate.beginDownloadZip = function() return {} end
|
||||
ModUpdate.pumpDownloadZip = function()
|
||||
love.filesystem.write("install.zip", "BYTES")
|
||||
return true, "install.zip"
|
||||
end
|
||||
LauncherMods.installDownloadedZip = function(id, _, version)
|
||||
installs[#installs + 1] = id
|
||||
if id == failId then return nil, "the archive had no manifest" end
|
||||
return true, version
|
||||
end
|
||||
LauncherMods.checkDependencies = function()
|
||||
return { hasIssues = depIssue }
|
||||
end
|
||||
Platform.canFetchRemote = function() return true end
|
||||
CartStore.list = function() return {} end
|
||||
CartStore.listFor = function() return {} end
|
||||
CartStore.index = function() return {} end
|
||||
|
||||
local function info(status, best)
|
||||
return { status = status, latest = best and best.version or nil,
|
||||
best = best, releases = RELEASES }
|
||||
end
|
||||
|
||||
local function launcher(allCurrent)
|
||||
local ri = setmetatable({
|
||||
mods = {
|
||||
{ id = "one", name = "One", version = "1.0.0", github = "a/one" },
|
||||
{ id = "two", name = "Two", version = "1.0.0" },
|
||||
},
|
||||
modUpdateInfo = {
|
||||
one = allCurrent and info("current") or info("available", RELEASES[1]),
|
||||
},
|
||||
activeCart = {},
|
||||
carts = {},
|
||||
findLoaded = true,
|
||||
findIndex = { mods = {}, carts = {} },
|
||||
}, RomImporter)
|
||||
ri._refreshMods = function() end
|
||||
ri._syncModUpdateInfo = function() end
|
||||
return ri
|
||||
end
|
||||
|
||||
local function run(ri)
|
||||
local confirms, fired = 0, 0
|
||||
for _ = 1, 40 do
|
||||
if ri._modConfirm then confirms = confirms + 1 end
|
||||
if ri:_fireAutoUpdateAll() then fired = fired + 1 end
|
||||
if not ri._updateAll and not ri._autoUpdateAll then break end
|
||||
ri:_pumpUpdateAll()
|
||||
ri:_pumpModInstall()
|
||||
end
|
||||
return confirms, fired
|
||||
end
|
||||
|
||||
do
|
||||
installs = {}
|
||||
local ri = launcher()
|
||||
local results = {}
|
||||
check(ri:autoUpdateAll(function(r) results[#results + 1] = r end),
|
||||
"the launch flag starts the sweep")
|
||||
local confirms, fired = run(ri)
|
||||
eq(confirms, 0, "with no confirm modal for a CLI launch to answer")
|
||||
eq(#installs, 1, "the outdated mod is installed")
|
||||
eq(installs[1], "one", "by id")
|
||||
eq(fired, 1, "the caller hears back once")
|
||||
eq(#results, 1, "exactly once")
|
||||
eq((results[1] or {}).ok, true, "that it worked")
|
||||
eq((results[1] or {}).updated, 1, "with the count")
|
||||
eq(ri.modNotice and ri.modNotice.text, "Updated 1 items.",
|
||||
"and the launcher shows the same notice the button would")
|
||||
eq(ri._autoUpdateAll, nil, "the auto job is cleared")
|
||||
eq(ri._busy, nil, "with the overlay down")
|
||||
end
|
||||
|
||||
do
|
||||
installs = {}
|
||||
local ri = launcher(true)
|
||||
local results = {}
|
||||
ri:autoUpdateAll(function(r) results[#results + 1] = r end)
|
||||
run(ri)
|
||||
eq(#installs, 0, "nothing outdated, nothing installed")
|
||||
eq((results[1] or {}).ok, true, "an up-to-date set still boots")
|
||||
eq(ri.modNotice and ri.modNotice.text, "Everything is up to date.",
|
||||
"and says so")
|
||||
end
|
||||
|
||||
do
|
||||
installs = {}
|
||||
failId = "one"
|
||||
local ri = launcher()
|
||||
local results = {}
|
||||
ri:autoUpdateAll(function(r) results[#results + 1] = r end)
|
||||
run(ri)
|
||||
failId = nil
|
||||
eq((results[1] or {}).ok, false, "a failed install is reported as not ok")
|
||||
eq(#((results[1] or {}).failures or {}), 1, "with the failure line")
|
||||
check(ri.modNotice and not ri.modNotice.ok,
|
||||
"and the failure stays on the launcher")
|
||||
end
|
||||
|
||||
do
|
||||
installs = {}
|
||||
depIssue = true
|
||||
local ri = launcher()
|
||||
local results = {}
|
||||
ri:autoUpdateAll(function(r) results[#results + 1] = r end)
|
||||
run(ri)
|
||||
depIssue = false
|
||||
eq((results[1] or {}).ok, false,
|
||||
"an update that raises the dependency resolver is not ok")
|
||||
check(ri._modDepResolver ~= nil, "so the resolver can be answered")
|
||||
end
|
||||
|
||||
do
|
||||
installs = {}
|
||||
local ri = launcher()
|
||||
ri._syncModUpdateInfo = function(self) self._modInfoFetch = {} end
|
||||
local results = {}
|
||||
ri:autoUpdateAll(function(r) results[#results + 1] = r end)
|
||||
eq((ri._updateAll or {}).stage, "check",
|
||||
"the sweep waits on the release checks")
|
||||
ri:_cancelUpdateAll()
|
||||
run(ri)
|
||||
eq((results[1] or {}).cancelled, true, "cancel is reported as cancelled")
|
||||
eq(#installs, 0, "with nothing installed")
|
||||
end
|
||||
|
||||
do
|
||||
Platform.canFetchRemote = function() return false end
|
||||
local ri = launcher()
|
||||
local results = {}
|
||||
eq(ri:autoUpdateAll(function(r) results[#results + 1] = r end), false,
|
||||
"no remote fetch, no sweep")
|
||||
run(ri)
|
||||
eq((results[1] or {}).skipped, true, "reported as skipped")
|
||||
Platform.canFetchRemote = function() return true end
|
||||
end
|
||||
|
||||
do
|
||||
local ri = launcher()
|
||||
ri.safeMode = true
|
||||
local results = {}
|
||||
eq(ri:autoUpdateAll(function(r) results[#results + 1] = r end), false,
|
||||
"safe mode refuses the sweep")
|
||||
run(ri)
|
||||
eq((results[1] or {}).skipped, true, "and reports it skipped")
|
||||
check(ri.modNotice ~= nil, "with a notice saying why")
|
||||
end
|
||||
|
||||
do
|
||||
local ri = launcher()
|
||||
local switched
|
||||
ri._switchTab = function(self, id) switched = id; self.tab = id end
|
||||
ri:autoUpdateAll(function() end, { tab = "mods" })
|
||||
eq(switched, "mods", "the sweep can put the launcher on the MODS tab")
|
||||
eq(ri:autoUpdateAll(function() end), false,
|
||||
"a second request while one runs is ignored")
|
||||
run(ri)
|
||||
end
|
||||
|
||||
do
|
||||
installs = {}
|
||||
local ri = launcher()
|
||||
check(ri:pressUpdateAllMods(), "the button still starts the sweep")
|
||||
ri:_pumpUpdateAll()
|
||||
eq((ri._updateAll or {}).stage, "confirm", "and still asks first")
|
||||
eq((ri._modConfirm or {}).kind, "updateAllRun", "with the modal up")
|
||||
eq(#installs, 0, "installing nothing before the answer")
|
||||
ri._modConfirm = nil
|
||||
ri:_pumpUpdateAll()
|
||||
end
|
||||
|
||||
do
|
||||
local forced = {}
|
||||
local ri = launcher()
|
||||
ri._syncModUpdateInfo = function(_, force) forced[#forced + 1] = force end
|
||||
ri:autoUpdateAll(function() end)
|
||||
run(ri)
|
||||
eq(forced[1], false, "the launch flag keeps the 6h release cache")
|
||||
local rb = launcher()
|
||||
rb._syncModUpdateInfo = function(_, force) forced[#forced + 1] = force end
|
||||
rb:pressUpdateAllMods()
|
||||
eq(forced[2], true, "while the button still forces a fresh check")
|
||||
rb._modConfirm = nil
|
||||
rb._updateAll = nil
|
||||
rb._busy = nil
|
||||
end
|
||||
|
||||
do
|
||||
local f = assert(io.open("main.lua", "r"))
|
||||
local src = f:read("*a")
|
||||
f:close()
|
||||
check(src:find("Importer:autoUpdateAll(", 1, true) ~= nil,
|
||||
"main.lua runs the sweep on a launcher it opens for the flag")
|
||||
check(src:find("if not Prelaunch then bootAfterMods()", 1, true) ~= nil,
|
||||
"after the pre-boot stage")
|
||||
check(src:find("onBoot = function(v, c, opts)", 1, true) ~= nil
|
||||
and src:find("if onBoot and onBoot(version, cartId, opts) then return end", 1, true) ~= nil,
|
||||
"Play after a failed sweep boots through the shortcut, keeping --slot")
|
||||
check(src:find("if launcherBusy() then\n deferredLaunchRequest = request", 1, true) ~= nil,
|
||||
"a launch request during a download waits instead of dropping the launcher")
|
||||
check(src:find("if deferredLaunchRequest and not launcherBusy() then", 1, true) ~= nil,
|
||||
"and runs once the launcher is idle")
|
||||
end
|
||||
|
||||
ModUpdate.beginFetchReleases = oldBeginFetch
|
||||
ModUpdate.beginDownloadZip = oldBeginZip
|
||||
ModUpdate.pumpDownloadZip = oldPumpZip
|
||||
LauncherMods.installDownloadedZip = oldInstall
|
||||
LauncherMods.checkDependencies = oldDeps
|
||||
Platform.canFetchRemote = oldRemote
|
||||
CartStore.list = oldCartList
|
||||
CartStore.index = oldCartIndex
|
||||
CartStore.listFor = oldCartListFor
|
||||
os.getenv = realGetenv
|
||||
|
||||
T.finish("launch --update-mods (#2037)")
|
||||
@@ -99,12 +99,11 @@ local function buyOne(game, stack)
|
||||
local list = stack[#stack]
|
||||
list.onChoose({ value = "POKE_BALL" })
|
||||
local qty = stack[#stack]
|
||||
game.stack:pop()
|
||||
qty.onDone(1)
|
||||
local confirm = stack[#stack]
|
||||
game.stack:pop()
|
||||
confirm.onChoose(true)
|
||||
return menu, list
|
||||
confirm.choice(true)
|
||||
return menu, list, qty
|
||||
end
|
||||
|
||||
do
|
||||
@@ -119,13 +118,14 @@ for _, case in ipairs({
|
||||
{ name = "not enough money", money = 100, count = nil, text = NO_MONEY },
|
||||
}) do
|
||||
local game, events, stack = newGame(case.money, case.count)
|
||||
local menu, list = buyOne(game, stack)
|
||||
local menu, list, qty = buyOne(game, stack)
|
||||
local box = stack[#stack]
|
||||
if check(getmetatable(box) == TextBox, case.name .. ": a text box is pushed") then
|
||||
check(boxText(box):find(case.text:gsub("\n", " "), 1, true) ~= nil,
|
||||
case.name .. ": the clerk's refusal is the text")
|
||||
end
|
||||
eq(stack[#stack - 1], list, case.name .. ": the list is still under the box")
|
||||
eq(stack[#stack - 1], qty, case.name .. ": the quantity box is still under the box")
|
||||
eq(stack[#stack - 2], list, case.name .. ": the list is under the quantity box")
|
||||
local popsBefore = 0
|
||||
for _, ev in ipairs(events) do
|
||||
if ev[1] == "push" and ev[2] == box then break end
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
-- engine/events/pokemart.asm:159-169
|
||||
-- engine/events/pokemart.asm:84-91
|
||||
-- home/list_menu.asm:89-91
|
||||
-- luajit tests/engine/mart_price_text_bug2291.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local function glyphs(text)
|
||||
local n = 0
|
||||
for _ in tostring(text):gmatch("[%z\1-\127\194-\244][\128-\191]*") do
|
||||
n = n + 1
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
local realFont = package.loaded["src.render.Font"]
|
||||
package.loaded["src.render.Font"] = {
|
||||
BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 },
|
||||
draw = function() end,
|
||||
drawCode = function() end,
|
||||
drawBox = function() end,
|
||||
width = function(text) return glyphs(text) * 8 end,
|
||||
split = function(text)
|
||||
local out = {}
|
||||
for s in tostring(text):gmatch("[%z\1-\127\194-\244][\128-\191]*") do
|
||||
out[#out + 1] = s
|
||||
end
|
||||
return out
|
||||
end,
|
||||
spansFitting = function(spans, pixels)
|
||||
return math.min(#spans, math.floor(pixels / 8))
|
||||
end,
|
||||
encode = function(text)
|
||||
local out = {}
|
||||
for s in tostring(text):gmatch("[%z\1-\127\194-\244][\128-\191]*") do
|
||||
out[#out + 1] = s
|
||||
end
|
||||
return out
|
||||
end,
|
||||
advanceOf = function() return 8 end,
|
||||
}
|
||||
|
||||
local RELOAD = {
|
||||
"src.ui.QuantityBox", "src.ui.ShopMenu", "src.ui.ListMenu",
|
||||
"src.ui.ChoiceBox", "src.ui.Menu", "src.ui.Theme", "src.render.TextBox",
|
||||
}
|
||||
for _, m in ipairs(RELOAD) do package.loaded[m] = nil end
|
||||
|
||||
local ShopMenu = require("src.ui.ShopMenu")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local QuantityBox = require("src.ui.QuantityBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
|
||||
local GREET = "Take your time."
|
||||
local SELL_GREET = "What would you\nlike to sell?"
|
||||
|
||||
local function newGame(money)
|
||||
local stack, pressed = {}, {}
|
||||
local game = {
|
||||
data = {
|
||||
items = { POKE_BALL = { name = "POKe BALL", price = 200 } },
|
||||
text = {
|
||||
_PokemartBuyingGreetingText = GREET,
|
||||
_PokemonSellingGreetingText = SELL_GREET,
|
||||
_PokemartTellBuyPriceText = "{RAM:wStringBuffer}?\nThat will be\011¥{NUM:hMoney, 3 | LEADING_ZEROES | LEFT_ALIGN}. OK?{DONE}",
|
||||
_PokemartTellSellPriceText = "I can pay you\n¥{NUM:hMoney, 3 | LEADING_ZEROES | LEFT_ALIGN} for that.{DONE}",
|
||||
_PokemartBoughtItemText = "Here you are!\nThank you!{PROMPT}",
|
||||
},
|
||||
constants = {},
|
||||
},
|
||||
save = { money = money, inventory = {}, bagOrder = {}, options = { textSpeed = 3 } },
|
||||
input = {
|
||||
wasPressed = function(_, b) return pressed[b] == true end,
|
||||
isDown = function() return false end,
|
||||
},
|
||||
}
|
||||
game.stack = {
|
||||
push = function(_, s) stack[#stack + 1] = s end,
|
||||
pop = function() return table.remove(stack) end,
|
||||
top = function() return stack[#stack] end,
|
||||
}
|
||||
local function frame(btn)
|
||||
for k in pairs(pressed) do pressed[k] = nil end
|
||||
if btn then pressed[btn] = true end
|
||||
local top = stack[#stack]
|
||||
top:update(1 / 60)
|
||||
for k in pairs(pressed) do pressed[k] = nil end
|
||||
end
|
||||
return game, stack, frame
|
||||
end
|
||||
|
||||
local function flat(box)
|
||||
local out = {}
|
||||
for _, page in ipairs(box.pages or {}) do
|
||||
for _, line in ipairs(page) do out[#out + 1] = line end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
do
|
||||
local game, stack, frame = newGame(3000)
|
||||
local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end)
|
||||
menu.items[1].onSelect()
|
||||
local list = stack[#stack]
|
||||
list.index = 1
|
||||
list.onChoose({ value = "POKE_BALL" })
|
||||
eq(list.hollowIndex, list.index, "buy: the chosen row shows the hollow cursor")
|
||||
local qty = stack[#stack]
|
||||
check(getmetatable(qty) == QuantityBox, "buy: the quantity box opens")
|
||||
qty.onDone(1)
|
||||
local box = stack[#stack]
|
||||
check(getmetatable(box) == TextBox, "buy: the price line is a typed text box, not a bare YES/NO")
|
||||
check(box.choice ~= nil, "buy: YES/NO waits on the text box")
|
||||
eq(stack[#stack - 1], qty, "buy: the quantity box stays up under the price text")
|
||||
eq(stack[#stack - 2], list, "buy: the list is under the quantity box")
|
||||
eq(list.footer, GREET, "buy: the price line is not dumped into the footer")
|
||||
local lines = flat(box)
|
||||
eq(lines[1], "POKe BALL?", "buy: the clerk names the item")
|
||||
eq(lines[2], "That will be", "buy: line 2")
|
||||
eq(lines[3], "¥200. OK?", "buy: line 3 carries the price")
|
||||
|
||||
frame()
|
||||
eq(box.done, false, "buy: the price line types, it is not instant")
|
||||
eq(stack[#stack], box, "buy: no YES/NO while the line is typing")
|
||||
|
||||
local sawCont = false
|
||||
for _ = 1, 300 do
|
||||
if box.waiting then sawCont = true break end
|
||||
frame()
|
||||
end
|
||||
check(sawCont, "buy: the cont line waits on the arrow")
|
||||
local vis = box:visibleText() or {}
|
||||
eq(vis[1], "POKe BALL?", "buy: arrow wait shows the item name")
|
||||
eq(vis[2], "That will be", "buy: arrow wait shows That will be")
|
||||
eq(stack[#stack], box, "buy: no YES/NO at the arrow wait")
|
||||
|
||||
for _ = 1, 10 do frame() end
|
||||
frame("a")
|
||||
local pushed = false
|
||||
for _ = 1, 300 do
|
||||
if getmetatable(stack[#stack]) == ChoiceBox then pushed = true break end
|
||||
frame()
|
||||
end
|
||||
check(pushed, "buy: YES/NO opens once the text finishes")
|
||||
check(box.done, "buy: the text box is done when YES/NO opens")
|
||||
eq(stack[#stack - 1], box, "buy: YES/NO sits over the price text")
|
||||
eq(stack[#stack - 2], qty, "buy: the quantity box is still drawn under YES/NO")
|
||||
|
||||
game.stack:pop()
|
||||
game.stack:pop()
|
||||
box.choice(false)
|
||||
eq(stack[#stack], list, "buy NO: the quantity box closes back to the list")
|
||||
eq(list.footer, GREET, "buy NO: the greeting is back")
|
||||
end
|
||||
|
||||
do
|
||||
local game, stack = newGame(3000)
|
||||
local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end)
|
||||
menu.items[1].onSelect()
|
||||
local list = stack[#stack]
|
||||
list.onChoose({ value = "POKE_BALL" })
|
||||
local qty = stack[#stack]
|
||||
qty.onDone(2)
|
||||
local box = stack[#stack]
|
||||
game.stack:pop()
|
||||
box.choice(true)
|
||||
eq(game.save.money, 2600, "buy YES: money is paid")
|
||||
eq(game.save.inventory.POKE_BALL, 2, "buy YES: the items go in the bag")
|
||||
local bought = stack[#stack]
|
||||
check(getmetatable(bought) == TextBox and bought.choice == nil,
|
||||
"buy YES: the receipt text is up")
|
||||
eq(stack[#stack - 1], qty, "buy YES: the quantity box stays under the receipt")
|
||||
game.stack:pop()
|
||||
bought.onDone()
|
||||
eq(stack[#stack], list, "buy YES: the receipt closes back to the list")
|
||||
end
|
||||
|
||||
do
|
||||
local game, stack = newGame(0)
|
||||
game.save.inventory.POKE_BALL = 5
|
||||
game.save.bagOrder = { "POKE_BALL" }
|
||||
local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end)
|
||||
menu.items[2].onSelect()
|
||||
local list = stack[#stack]
|
||||
list.index = 1
|
||||
list.onChoose(list.items[1])
|
||||
eq(list.hollowIndex, list.index, "sell: the chosen row shows the hollow cursor")
|
||||
local qty = stack[#stack]
|
||||
check(getmetatable(qty) == QuantityBox, "sell: the quantity box opens")
|
||||
qty.onDone(2)
|
||||
local box = stack[#stack]
|
||||
check(getmetatable(box) == TextBox and box.choice ~= nil,
|
||||
"sell: the price line is a typed text box with YES/NO")
|
||||
eq(stack[#stack - 1], qty, "sell: the quantity box stays up under the price text")
|
||||
eq(list.footer, SELL_GREET, "sell: the price line is not dumped into the footer")
|
||||
local lines = flat(box)
|
||||
eq(lines[1], "I can pay you", "sell: line 1")
|
||||
eq(lines[2], "¥200 for that.", "sell: line 2 carries the price")
|
||||
eq(box.done, false, "sell: the price line is not instant")
|
||||
game.stack:pop()
|
||||
box.choice(true)
|
||||
eq(stack[#stack], list, "sell YES: back to the sell list")
|
||||
eq(game.save.money, 200, "sell YES: money is paid")
|
||||
eq(game.save.inventory.POKE_BALL, 3, "sell YES: items leave the bag")
|
||||
end
|
||||
|
||||
package.loaded["src.render.Font"] = realFont
|
||||
for _, m in ipairs(RELOAD) do package.loaded[m] = nil end
|
||||
require("src.ui.Screens").invalidate()
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,475 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
love = love or require("tests.love_stub")
|
||||
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
|
||||
love.graphics.newShader = love.graphics.newShader or function() return {} end
|
||||
|
||||
local Loader = require("src.mods.Loader")
|
||||
local CartManifest = require("src.carts.CartManifest")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Manifest = require("src.mods.Manifest")
|
||||
local ModProfile = require("src.mods.ModProfile")
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local LoadOrder = require("src.mods.LoadOrder")
|
||||
local Logger = require("src.core.Logger")
|
||||
Logger.warn = function() end
|
||||
Logger.info = function() end
|
||||
|
||||
local function names(list) return table.concat(list or {}, ",") end
|
||||
|
||||
local function memfs(files)
|
||||
return {
|
||||
files = files,
|
||||
read = function(path) return files[path] end,
|
||||
write = function(path, content) files[path] = content return true end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] then return { type = "file" } end
|
||||
local prefix = path .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
load = function(path)
|
||||
if not files[path] then return nil, "no file: " .. path end
|
||||
return load(files[path], path)
|
||||
end,
|
||||
createDirectory = function() return true end,
|
||||
getDirectoryItems = function(path)
|
||||
local seen, items = {}, {}
|
||||
local prefix = path .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then
|
||||
local child = key:sub(#prefix + 1):match("^[^/]+")
|
||||
if child and not seen[child] then
|
||||
seen[child] = true
|
||||
items[#items + 1] = child
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(items)
|
||||
return items
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local function manifestJson(id, extra)
|
||||
return ([[{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua"%s}]])
|
||||
:format(id, id, extra or "")
|
||||
end
|
||||
|
||||
local function entry(label)
|
||||
return ([[
|
||||
return function(mod)
|
||||
mod.content.pokemon:override("PIKACHU", { name = "%s" })
|
||||
end
|
||||
]]):format(label)
|
||||
end
|
||||
|
||||
local function install(opts, extras)
|
||||
extras = extras or {}
|
||||
local files = { ["options.lua"] = SaveSerializer.encode(opts or {}) }
|
||||
for _, id in ipairs({ "alpha", "beta", "gamma" }) do
|
||||
files["mods/" .. id .. "/manifest.json"] = manifestJson(id, extras[id])
|
||||
files["mods/" .. id .. "/main.lua"] = entry(id:upper())
|
||||
end
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
return files
|
||||
end
|
||||
|
||||
local function boot(files, mode)
|
||||
local data = { pokemon = { PIKACHU = { name = "PIKACHU" } } }
|
||||
local loader = Loader.new({ fs = memfs(files) })
|
||||
local ok = loader:load(data, mode and { mode = mode } or nil)
|
||||
return loader, data, ok
|
||||
end
|
||||
|
||||
|
||||
do
|
||||
T.check(type(SaveData.defaultOptions().modOrder) == "table",
|
||||
"defaultOptions carries a modOrder list")
|
||||
T.eq(names(SaveData.modOrder({ modOrder = { "b", 3, "a", "b", "", "c" } })),
|
||||
"b,a,c", "modOrder keeps strings once, in order")
|
||||
T.eq(#SaveData.modOrder({}), 0, "no modOrder reads as empty")
|
||||
local o = SaveData.setModOrder({}, { "x", "x", "y" })
|
||||
T.eq(names(o.modOrder), "x,y", "setModOrder stores the sanitized list")
|
||||
end
|
||||
|
||||
|
||||
do
|
||||
local entries = {
|
||||
{ id = "gamma", priority = 0 }, { id = "alpha", priority = 5 },
|
||||
{ id = "beta", priority = 0 },
|
||||
}
|
||||
T.eq(names(LoadOrder.materialize({}, entries)), "beta,gamma,alpha",
|
||||
"an empty saved order materializes to (priority, id)")
|
||||
T.eq(names(LoadOrder.materialize({ "alpha", "gone" }, entries)),
|
||||
"beta,gamma,alpha,gone",
|
||||
"unlisted ids merge in by priority, uninstalled saved ids keep their slot")
|
||||
T.eq(names(LoadOrder.materialize({ "gamma" }, entries)), "gamma,beta,alpha",
|
||||
"an unlisted id tied on priority follows the saved one")
|
||||
local grown = {
|
||||
{ id = "a", priority = 0 }, { id = "b", priority = 0 }, { id = "c", priority = 0 },
|
||||
{ id = "fw", priority = -1000 }, { id = "late", priority = 50 },
|
||||
}
|
||||
T.eq(names(LoadOrder.materialize({ "b", "a", "c" }, grown)), "fw,b,a,c,late",
|
||||
"a mod installed after a reorder still lands by its manifest priority")
|
||||
local l = { "a", "b", "c", "d" }
|
||||
T.eq(names(LoadOrder.move(l, "b", -1)), "b,a,c,d", "move up one")
|
||||
T.eq(names(LoadOrder.move(l, "a", -1)), "a,b,c,d", "move up clamps at the top")
|
||||
T.eq(names(LoadOrder.move(l, "d", 1)), "a,b,c,d", "move down clamps at the bottom")
|
||||
T.eq(names(LoadOrder.move(l, "c", -math.huge)), "c,a,b,d", "move to top")
|
||||
T.eq(names(LoadOrder.move(l, "a", math.huge)), "b,c,d,a", "move to bottom")
|
||||
T.eq(names(l), "a,b,c,d", "move never edits its input")
|
||||
local full, changed = LoadOrder.moveWithin({ "a", "x", "b", "c" },
|
||||
{ "a", "b", "c" }, "b", -1)
|
||||
T.check(changed, "moveWithin reports a change")
|
||||
T.eq(names(full), "b,x,a,c",
|
||||
"moveWithin swaps visible neighbours and keeps hidden ids in their slots")
|
||||
end
|
||||
|
||||
|
||||
do
|
||||
local files = install()
|
||||
local loader, data = boot(files)
|
||||
T.eq(names(loader.order), "alpha,beta,gamma",
|
||||
"no modOrder keeps the (priority, id) order")
|
||||
T.eq(data.pokemon.PIKACHU.name, "GAMMA", "and the last id wins")
|
||||
end
|
||||
|
||||
do
|
||||
local files = install({ modOrder = { "gamma", "alpha", "beta" } })
|
||||
local loader, data = boot(files)
|
||||
T.eq(names(loader.order), "gamma,alpha,beta", "modOrder decides the load order")
|
||||
T.eq(data.pokemon.PIKACHU.name, "BETA", "the last mod in the player's order wins")
|
||||
end
|
||||
|
||||
do
|
||||
local files = install({ modOrder = { "beta" } })
|
||||
local loader = boot(files)
|
||||
T.eq(names(loader.order), "beta,alpha,gamma",
|
||||
"a partial list leads, the rest follow by priority and id")
|
||||
end
|
||||
|
||||
do
|
||||
local files = install({ modOrder = { "nope", "gamma" } })
|
||||
local loader, _, ok = boot(files)
|
||||
T.check(ok ~= false, "an unknown id in modOrder is not an error")
|
||||
T.eq(names(loader.order), "gamma,alpha,beta", "unknown ids are skipped")
|
||||
end
|
||||
|
||||
do
|
||||
local files = install({ modOrder = { "alpha", "beta", "gamma" } },
|
||||
{ alpha = [[,"priority":9]], gamma = [[,"priority":-4]] })
|
||||
local loader = boot(files)
|
||||
T.eq(names(loader.order), "alpha,beta,gamma",
|
||||
"the player's order outranks manifest priority")
|
||||
end
|
||||
|
||||
do
|
||||
local files = install({ modOrder = { "beta", "alpha" } },
|
||||
{ gamma = [[,"priority":-1000]] })
|
||||
local loader = boot(files)
|
||||
T.eq(names(loader.order), "gamma,beta,alpha",
|
||||
"a low-priority mod missing from the saved order still loads first")
|
||||
end
|
||||
|
||||
do
|
||||
local files = install({ modOrder = { "alpha", "beta", "gamma" } },
|
||||
{ alpha = [[,"dependencies":["gamma"] ]] })
|
||||
local loader = boot(files)
|
||||
T.eq(names(loader.order), "beta,gamma,alpha",
|
||||
"a dependency still loads before the mod that needs it")
|
||||
end
|
||||
|
||||
do
|
||||
local files = install({ modOrder = { "alpha", "beta", "gamma" } })
|
||||
local cart = assert(CartManifest.parse({ id = "cartx", title = "cartx",
|
||||
version = "1.0.0", author = "t", shell = "#102030", base = "red",
|
||||
seal = "sealed",
|
||||
mods = { { id = "alpha", source = "local", version = "1.0.0" },
|
||||
{ id = "gamma", source = "local", version = "1.0.0" } },
|
||||
load_order = { "gamma", "alpha" } }))
|
||||
files["carts/cartx" .. CartManifest.EXT] = CartManifest.encode(cart)
|
||||
SaveData.setCart("cartx", "hashx")
|
||||
local loader = boot(files)
|
||||
T.eq(names(loader.order), "gamma,alpha", "a cart's load_order beats modOrder")
|
||||
SaveData.setCart(nil)
|
||||
end
|
||||
|
||||
do
|
||||
local files = install({ modOrder = { "gamma", "alpha", "beta" } })
|
||||
local loader = boot(files, "disableAll")
|
||||
T.eq(loader.playerRank and next(loader.playerRank), nil,
|
||||
"arena modes ignore the player's order")
|
||||
end
|
||||
|
||||
|
||||
do
|
||||
local p = ModProfile.capture({ { id = "a", enabled = true } }, {}, {},
|
||||
{ "b", "a", "b" })
|
||||
T.eq(names(p.order), "b,a", "capture copies the sanitized order")
|
||||
local back = ModProfile.decode(ModProfile.encode({ name = "P", enabled = {},
|
||||
options = {}, slots = {}, enabledByVersion = {}, order = { "b", "a" } }))
|
||||
T.eq(names(back and back.order), "b,a", "encode/decode round-trips order")
|
||||
local bad = SaveSerializer.encode({ format = ModProfile.FORMAT,
|
||||
formatVersion = 1, profile = { name = "Q", order = { "x", 4, "x", "y" } } })
|
||||
T.eq(names(ModProfile.decode(bad).order), "x,y",
|
||||
"decode drops non-strings and duplicates")
|
||||
local live = { modOrder = { "z" } }
|
||||
T.check(not ModProfile.matchesOrder({ name = "old" }, live),
|
||||
"a profile without an order does not match a custom order")
|
||||
ModProfile.restoreOrder({ name = "old" }, live)
|
||||
T.eq(names(live.modOrder), "", "a profile without an order resets to the default order")
|
||||
T.check(ModProfile.matchesOrder({ name = "old" }, live),
|
||||
"and matches once the order is back to default")
|
||||
ModProfile.restoreOrder(back, live)
|
||||
T.eq(names(live.modOrder), "b,a", "restoreOrder writes the profile's order")
|
||||
T.check(ModProfile.matchesOrder(back, live), "matchesOrder agrees after restore")
|
||||
T.check(not ModProfile.matchesOrder(back, { modOrder = { "a", "b" } }),
|
||||
"matchesOrder notices a moved mod")
|
||||
end
|
||||
|
||||
|
||||
local function mf(raw) return Manifest.validate(raw) end
|
||||
|
||||
do
|
||||
local manifests = {
|
||||
mf({ id = "aaa", name = "A", version = "1.0.0", entry = "m.lua" }),
|
||||
mf({ id = "bbb", name = "B", version = "1.0.0", entry = "m.lua",
|
||||
dependencies = { "ccc" } }),
|
||||
mf({ id = "ccc", name = "C", version = "1.0.0", entry = "m.lua" }),
|
||||
}
|
||||
local rows = LauncherMods.deriveList(manifests,
|
||||
{ mods = {}, modOrder = { "bbb", "ccc", "aaa" } }, "red")
|
||||
local by = {}
|
||||
for _, r in ipairs(rows) do by[r.id] = r end
|
||||
T.eq(by.bbb.loadRank, 1, "deriveList stamps the saved position")
|
||||
T.eq(by.aaa.loadRank, 3, "for every row")
|
||||
T.eq(by.bbb.orderAfter, "ccc", "a dependency placed later is flagged")
|
||||
T.check(type(by.bbb.orderNote) == "string" and by.bbb.orderNote:find("C", 1, true),
|
||||
"with a note naming it")
|
||||
T.eq(by.ccc.orderNote, nil, "no note when nothing forces a move")
|
||||
T.eq(names(LauncherMods.orderedIds(rows, { modOrder = { "ccc" } })),
|
||||
"ccc,aaa,bbb", "orderedIds materializes over rows")
|
||||
T.eq(names(LauncherMods.moveInOrder({ "a", "b" }, "b", -1)), "b,a",
|
||||
"moveInOrder swaps")
|
||||
end
|
||||
|
||||
do
|
||||
local fs = love.filesystem
|
||||
for _, id in ipairs({ "lo_one", "lo_two", "lo_three" }) do
|
||||
fs.write("mods/" .. id .. "/manifest.json", manifestJson(id))
|
||||
fs.write("mods/" .. id .. "/main.lua", "return function() end")
|
||||
end
|
||||
SaveData.saveOptions(SaveData.defaultOptions())
|
||||
local ok, at = LauncherMods.moveMod("lo_one", 1)
|
||||
T.check(ok, "moveMod moves a mod down")
|
||||
T.eq(at, 2, "and reports its new position")
|
||||
T.eq(names(SaveData.loadOptions().modOrder), "lo_three,lo_one,lo_two",
|
||||
"moveMod persists the whole materialized list")
|
||||
T.check(not LauncherMods.moveMod("lo_two", 1), "the last mod cannot move down")
|
||||
T.check(not LauncherMods.moveMod("missing_mod", -1), "an unknown id is refused")
|
||||
local opts = SaveData.loadOptions()
|
||||
opts.safeMode = true
|
||||
SaveData.saveOptions(opts)
|
||||
T.check(not LauncherMods.moveMod("lo_one", -1), "safe mode refuses a move")
|
||||
opts = SaveData.loadOptions()
|
||||
opts.safeMode = false
|
||||
SaveData.saveOptions(opts)
|
||||
fs.write("mods/lo_zero/manifest.json", manifestJson("lo_zero", [[,"priority":-1000]]))
|
||||
fs.write("mods/lo_zero/main.lua", "return function() end")
|
||||
T.check(LauncherMods.moveMod("lo_two", -1), "a move after a new install")
|
||||
T.eq(names(SaveData.loadOptions().modOrder), "lo_zero,lo_three,lo_two,lo_one",
|
||||
"the new low-priority mod was placed by priority, not forced last")
|
||||
opts = SaveData.loadOptions()
|
||||
opts.modProfiles = { { name = "PLAIN", enabled = {}, options = {}, slots = {},
|
||||
enabledByVersion = {} } }
|
||||
SaveData.saveOptions(opts)
|
||||
T.check(LauncherMods.applyProfile("PLAIN"), "applyProfile takes a profile with no order")
|
||||
T.eq(#SaveData.loadOptions().modOrder, 0, "and resets the load order to default")
|
||||
LauncherMods.moveMod("lo_one", 1)
|
||||
T.check(LauncherMods.resetOrder(), "resetOrder clears a saved order")
|
||||
T.eq(#SaveData.loadOptions().modOrder, 0, "back to author priority")
|
||||
opts = SaveData.loadOptions()
|
||||
opts.modProfiles, opts.activeProfile = nil, nil
|
||||
SaveData.saveOptions(opts)
|
||||
for _, id in ipairs({ "lo_one", "lo_two", "lo_three", "lo_zero" }) do
|
||||
fs.remove("mods/" .. id .. "/manifest.json")
|
||||
fs.remove("mods/" .. id .. "/main.lua")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
do
|
||||
local ManagerState = require("src.mods.ManagerState")
|
||||
local writes = 0
|
||||
local game = {
|
||||
save = { options = { mods = {}, modOrder = {} } },
|
||||
modStatus = { errors = {}, available = {
|
||||
{ id = "ma", name = "MA", enabled = true, state = "loaded", priority = 0 },
|
||||
{ id = "mb", name = "MB", enabled = true, state = "loaded", priority = 0 },
|
||||
{ id = "mc", name = "MC", enabled = true, state = "loaded", priority = 0 },
|
||||
} },
|
||||
writeOptions = function() writes = writes + 1 end,
|
||||
stack = { pop = function() end, push = function() end },
|
||||
}
|
||||
local ms = ManagerState.new(game)
|
||||
ms:refresh()
|
||||
local function labels(rows)
|
||||
local out = {}
|
||||
for _, r in ipairs(rows) do out[#out + 1] = r.label end
|
||||
return table.concat(out, "|")
|
||||
end
|
||||
local function rowNamed(rows, label)
|
||||
for _, r in ipairs(rows) do if r.label == label then return r end end
|
||||
end
|
||||
local rows = ms:detailRows(ms.byId.ma)
|
||||
local orderRow = rowNamed(rows, "< LOAD #1/3 >")
|
||||
T.check(orderRow and orderRow.adjust, "the detail screen has one load position row")
|
||||
T.eq(labels(rows):find("LOAD EARLIER", 1, true), nil, "no separate earlier/later rows")
|
||||
ms.currentMod, ms.screen, ms.scroll = ms.byId.ma, "detail", 1
|
||||
for i, r in ipairs(rows) do if r == orderRow then ms.cursor = i end end
|
||||
ms:adjustOrTab(1)
|
||||
T.eq(names(game.save.options.modOrder), "mb,ma,mc", "RIGHT on the row loads it later")
|
||||
T.check(writes > 0, "and persists the options")
|
||||
T.check(ms.restartPending, "a moved mod stages a restart")
|
||||
rows = ms:detailRows(ms.byId.ma)
|
||||
T.check(rowNamed(rows, "< LOAD #2/3 >") ~= nil, "the row follows the move")
|
||||
ms:adjustOrTab(-1)
|
||||
T.check(not ms.restartPending, "moving back to where it booted clears the restart")
|
||||
T.eq(ms.descScroll, 1, "left/right on the row does not scroll the description")
|
||||
rowNamed(ms:detailRows(ms.byId.mc), "< LOAD #3/3 >").adjust(-1)
|
||||
ms:discardChanges()
|
||||
T.eq(names(game.save.options.modOrder), "", "discard restores the boot order")
|
||||
T.check(not ms.restartPending, "and clears the pending restart")
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local busy = { id = "md", name = "MD", enabled = true, state = "loaded", priority = 0,
|
||||
github = "someone/md", experimental = true, error = "boom", permissions = { "net" } }
|
||||
game.modStatus.available[#game.modStatus.available + 1] = busy
|
||||
ms:refresh()
|
||||
ms.currentMod, ms.screen, ms.cursor, ms.scroll = ms.byId.md, "detail", 1, 1
|
||||
local all = ms:rowsForScreen()
|
||||
T.check(#all > 4, "a busy mod has more detail rows than fit above the footer")
|
||||
local function drawAll()
|
||||
local drawn = {}
|
||||
local realDraw, realCode = Font.draw, Font.drawCode
|
||||
Font.draw = function(text, x, y) drawn[#drawn + 1] = { text = tostring(text), x = x, y = y } end
|
||||
Font.drawCode = function() end
|
||||
ms:drawDetail()
|
||||
Font.draw, Font.drawCode = realDraw, realCode
|
||||
local low, seen = true, {}
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.x == 32 then
|
||||
if d.y >= 15 * 8 then low = false end
|
||||
seen[d.text] = true
|
||||
end
|
||||
end
|
||||
return low, seen
|
||||
end
|
||||
local clear, seen = drawAll()
|
||||
T.check(clear, "no detail row draws on the footer")
|
||||
T.check(seen["FOR " .. require("src.mods.ModTargets").chip(busy)] == nil,
|
||||
"FOR is a header line, not a row")
|
||||
T.check(not seen["BACK"], "BACK starts below the scroll window")
|
||||
for _ = 1, #all - 1 do ms:moveCursor(1) end
|
||||
T.eq(all[ms.cursor].label, "BACK", "the cursor reaches BACK")
|
||||
clear, seen = drawAll()
|
||||
T.check(clear and seen["BACK"], "and BACK scrolls into view above the footer")
|
||||
end
|
||||
|
||||
|
||||
do
|
||||
local Kit = require("src.ui.kit.Kit")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local LauncherView = require("src.import.LauncherView")
|
||||
local function window(w, h)
|
||||
love.graphics.getDimensions = function() return w, h end
|
||||
love.graphics.getPixelDimensions = function() return w, h end
|
||||
end
|
||||
local manifests = {
|
||||
mf({ id = "va", name = "Alpha", version = "1.0.0", entry = "m.lua" }),
|
||||
mf({ id = "vb", name = "Beta", version = "1.0.0", entry = "m.lua" }),
|
||||
mf({ id = "vc", name = "Gamma", version = "1.0.0", entry = "m.lua" }),
|
||||
}
|
||||
local rows = LauncherMods.deriveList(manifests,
|
||||
{ mods = {}, modOrder = { "vc", "va", "vb" } })
|
||||
local seen, labels = {}, {}
|
||||
local realButton = Kit.button
|
||||
Kit.button = function(x, y, w, h, label, opts)
|
||||
if opts and opts.id then
|
||||
seen[opts.id] = { x = x, y = y, w = w, h = h, opts = opts }
|
||||
labels[opts.id] = label
|
||||
end
|
||||
return realButton(x, y, w, h, label, opts)
|
||||
end
|
||||
local texts = {}
|
||||
local realText = Kit.text
|
||||
Kit.text = function(font, s, ...)
|
||||
texts[#texts + 1] = s
|
||||
return realText(font, s, ...)
|
||||
end
|
||||
for _, size in ipairs({ { 1280, 2400 }, { 360, 3200 } }) do
|
||||
window(size[1], size[2])
|
||||
seen, labels, texts = {}, {}, {}
|
||||
local imp = RomImporter.new(function() end, { launcher = true })
|
||||
imp.tab = "mods"
|
||||
imp.ready = { red = true }
|
||||
imp.mods = rows
|
||||
imp.modSort = "order"
|
||||
local moved
|
||||
imp._moveMod = function(_, id, delta) moved = { id, delta } end
|
||||
local ok, err = pcall(LauncherView.draw, imp)
|
||||
T.check(ok, ("%dx%d draws: %s"):format(size[1], size[2], tostring(err)))
|
||||
local tag = size[1] .. "x" .. size[2]
|
||||
T.check(seen["mod-up-va"] ~= nil and seen["mod-down-va"] ~= nil,
|
||||
tag .. ": up/down buttons are drawn for a visible row")
|
||||
T.check(seen["mod-up-vc"] and seen["mod-up-vc"].opts.enabled == false,
|
||||
tag .. ": the first mod cannot move up")
|
||||
T.check(seen["mod-down-vb"] and seen["mod-down-vb"].opts.enabled == false,
|
||||
tag .. ": the last mod cannot move down")
|
||||
local found = false
|
||||
for _, s in ipairs(texts) do
|
||||
if type(s) == "string" and s:find("^#1 Gamma") then found = true end
|
||||
end
|
||||
T.check(found, tag .. ": the list is sorted by load order with #n badges")
|
||||
local det, up = seen["mod-row-va"], seen["mod-up-va"]
|
||||
if det and up then
|
||||
T.check(det.y == up.y and det.x + det.w <= up.x,
|
||||
tag .. ": the move buttons share the Details line, row height unchanged")
|
||||
end
|
||||
seen["mod-down-va"].opts.action()
|
||||
T.eq(moved and moved[1], "va", tag .. ": Down moves that mod")
|
||||
T.eq(moved and moved[2], 1, tag .. ": by one slot")
|
||||
imp._modActions = "va"
|
||||
seen, texts = {}, {}
|
||||
imp._uiActions = {}
|
||||
ok, err = pcall(LauncherView.draw, imp)
|
||||
T.check(ok, tag .. " details modal draws: " .. tostring(err))
|
||||
T.check(seen["modact-up"] and seen["modact-down"] and seen["modact-top"]
|
||||
and seen["modact-bottom"], tag .. ": Details offers the four Move buttons")
|
||||
T.check(seen["modact-top"] and seen["modact-top"].opts.enabled == true,
|
||||
tag .. ": the middle mod can move to the top")
|
||||
seen["modact-bottom"].opts.action()
|
||||
T.eq(moved and moved[2], math.huge, tag .. ": Move to bottom")
|
||||
imp._modActions = nil
|
||||
imp._sortPopup = "mods"
|
||||
seen = {}
|
||||
pcall(LauncherView.draw, imp)
|
||||
T.check(seen["sortpop-order"] ~= nil, tag .. ": Load order is a MODS sort")
|
||||
T.check(seen["sortpop-reset-order"] ~= nil, tag .. ": with a reset button")
|
||||
imp._sortPopup = "find"
|
||||
seen = {}
|
||||
pcall(LauncherView.draw, imp)
|
||||
T.check(seen["sortpop-order"] == nil, tag .. ": but not a FIND sort")
|
||||
end
|
||||
Kit.button, Kit.text = realButton, realText
|
||||
end
|
||||
|
||||
T.finish("mod load order (#2177)")
|
||||
@@ -0,0 +1,278 @@
|
||||
-- home/pokemon.asm:246
|
||||
-- engine/items/item_effects.asm:1979-2006
|
||||
-- engine/pokemon/learn_move.asm:119-181
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("PP UP / TM move box #2320")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local ItemEffects = require("src.inventory.ItemEffects")
|
||||
|
||||
do
|
||||
local Data = {
|
||||
items = { PP_UP = { name = "PP UP" } },
|
||||
moves = { LICK = { name = "LICK", pp = 30 } },
|
||||
pokemon = { GENGAR = { name = "GENGAR" } },
|
||||
text = {},
|
||||
}
|
||||
local t = { species = "GENGAR", hp = 20, stats = { hp = 20 },
|
||||
moves = { { id = "LICK", pp = 48, ppUps = 3 } } }
|
||||
local result, payload = ItemEffects.use(Data, {}, "PP_UP", t, nil, 1)
|
||||
eq(result, "ppmaxed", "PP UP on a move with 3 PP Ups reports maxed out")
|
||||
eq(payload and payload[1], "LICK's PP\nis maxed out.", "and prints PPMaxedOutText")
|
||||
eq(t.moves[1].ppUps, 3, "the move is untouched")
|
||||
end
|
||||
|
||||
local draws
|
||||
local FontStub = {
|
||||
drawBox = function(tx, ty, tw, th) draws[#draws + 1] = { "box", tx, ty, tw, th } end,
|
||||
draw = function(text, x, y) draws[#draws + 1] = { "text", text, x, y } end,
|
||||
drawCode = function(code, x, y) draws[#draws + 1] = { "code", code, x, y } end,
|
||||
}
|
||||
package.loaded["src.render.Font"] = FontStub
|
||||
|
||||
local boxes = {}
|
||||
local TextBoxStub = {}
|
||||
TextBoxStub.strip = function(text)
|
||||
if type(text) ~= "string" then return text end
|
||||
return (text:gsub("{DONE}", ""):gsub("{PROMPT}", ""))
|
||||
end
|
||||
TextBoxStub.new = function(game, text, onDone, opts)
|
||||
local b = { fake = true, text = text, onDone = onDone, opts = opts }
|
||||
boxes[#boxes + 1] = b
|
||||
return b
|
||||
end
|
||||
package.loaded["src.render.TextBox"] = TextBoxStub
|
||||
package.loaded["src.render.HudTiles"] = {
|
||||
tile = function() end, drawHPBar = function() end, statusTile = function() end,
|
||||
}
|
||||
package.loaded["src.render.PaletteFX"] = {
|
||||
shader = function() return nil end, pal = function() return nil end,
|
||||
}
|
||||
|
||||
local MoveSelectMenu = require("src.ui.MoveSelectMenu")
|
||||
local MoveLearnMenu = require("src.ui.MoveLearnMenu")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
PartyMenu.drawIcon = function() end
|
||||
|
||||
local pressed = {}
|
||||
local function newGame()
|
||||
local game = {
|
||||
input = { wasPressed = function(_, b) return pressed[b] == true end,
|
||||
isDown = function() return false end },
|
||||
data = {
|
||||
moves = { LICK = { name = "LICK" }, HYPNOSIS = { name = "HYPNOSIS" },
|
||||
SURF = { name = "SURF" }, MEGA_PUNCH = { name = "MEGA PUNCH", pp = 20 } },
|
||||
pokemon = { GENGAR = { name = "GENGAR" } },
|
||||
statuses = {},
|
||||
text = {},
|
||||
},
|
||||
}
|
||||
game.stack = {
|
||||
states = {},
|
||||
push = function(self, s, ...)
|
||||
self.states[#self.states + 1] = s
|
||||
if type(s.enter) == "function" then s:enter(...) end
|
||||
end,
|
||||
pop = function(self) return table.remove(self.states) end,
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
}
|
||||
return game
|
||||
end
|
||||
|
||||
local function press(state, btn)
|
||||
pressed = { [btn] = true }
|
||||
state:update(1 / 60)
|
||||
pressed = {}
|
||||
end
|
||||
|
||||
local function finishBox(game)
|
||||
local b = game.stack:top()
|
||||
if b and b.fake and b.opts and b.opts.stay and b.opts.stay.onShown then
|
||||
b.opts.stay.onShown()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function cursorGlyphAt(y)
|
||||
for _, d in ipairs(draws) do
|
||||
if d[1] == "code" and d[3] == 0 and d[4] == y then return d[2] end
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local game = newGame()
|
||||
local mon = { species = "GENGAR", level = 30, hp = 60, stats = { hp = 60 },
|
||||
moves = { { id = "LICK", pp = 30 }, { id = "HYPNOSIS", pp = 20 } } }
|
||||
game.save = { party = { mon, { species = "GENGAR", level = 5, hp = 10,
|
||||
stats = { hp = 10 }, moves = {} } } }
|
||||
local menuRef, chosen
|
||||
local picker = PartyMenu.new(game, {
|
||||
pickOnly = true, itemUse = true, keepOpen = true,
|
||||
onSwitch = function(m, p)
|
||||
menuRef = MoveSelectMenu.new(game, m, "Raise PP of which\ntechnique?{DONE}",
|
||||
function(i, menu) chosen = { i, menu } end, nil, p)
|
||||
game.stack:push(menuRef)
|
||||
end,
|
||||
})
|
||||
game.stack:push(picker)
|
||||
|
||||
draws = {}
|
||||
picker:draw()
|
||||
eq(cursorGlyphAt(8), 0xED, "the party cursor is the filled arrow before the pick")
|
||||
|
||||
press(picker, "a")
|
||||
check(picker.chosenHollow == true, "A on a kept-open picker marks the row chosen")
|
||||
draws = {}
|
||||
picker:draw()
|
||||
eq(cursorGlyphAt(8), 0xEC, "the chosen mon's arrow turns hollow")
|
||||
|
||||
check(menuRef ~= nil, "the move menu was pushed")
|
||||
local top = game.stack:top()
|
||||
check(top and top.fake, "a text box types the prompt before the move box")
|
||||
eq(top and top.text, "Raise PP of which\ntechnique?{DONE}", "with the item's prompt")
|
||||
check(top and top.opts and top.opts.stay ~= nil, "a done-text stay box")
|
||||
eq(menuRef.ready, false, "the move menu takes no input while the prompt types")
|
||||
draws = {}
|
||||
menuRef:draw()
|
||||
eq(#draws, 0, "and draws nothing yet")
|
||||
pressed = { a = true }
|
||||
menuRef:update(1 / 60)
|
||||
pressed = {}
|
||||
eq(chosen, nil, "an A press during the prompt picks nothing")
|
||||
|
||||
check(finishBox(game), "the prompt finished typing")
|
||||
eq(game.stack:top(), menuRef, "the prompt box hands the screen to the move menu")
|
||||
eq(menuRef.ready, true, "the move menu is live")
|
||||
draws = {}
|
||||
menuRef:draw()
|
||||
eq(draws[1][1], "box", "prompt text box drawn first")
|
||||
eq(draws[1][2], 0, "at column 0")
|
||||
eq(draws[1][3], 12, "row 12")
|
||||
local last
|
||||
for _, d in ipairs(draws) do if d[1] == "box" then last = d end end
|
||||
eq(last and last[2], 4, "the move box is the last box drawn")
|
||||
eq(last and last[3], 7, "at 4,7, over the text box's top edge")
|
||||
|
||||
press(menuRef, "down")
|
||||
press(menuRef, "a")
|
||||
eq(chosen and chosen[1], 2, "A hands back the picked slot")
|
||||
eq(chosen and chosen[2], menuRef, "and the menu")
|
||||
eq(game.stack:top(), picker, "nothing was pushed over it, so it released itself")
|
||||
eq(#game.stack.states, 1, "back to the party menu")
|
||||
|
||||
menuRef = nil
|
||||
press(picker, "a")
|
||||
finishBox(game)
|
||||
local held = game.stack:top()
|
||||
eq(getmetatable(held), MoveSelectMenu, "the move menu is up again")
|
||||
local result = { fake = true, text = "LICK's PP\nincreased." }
|
||||
held.onChoose = function() game.stack:push(result) end
|
||||
press(held, "a")
|
||||
eq(game.stack:top(), result, "the result text is on top")
|
||||
check(held.held == true, "the move menu is held under it")
|
||||
eq(game.stack.states[#game.stack.states - 1], held, "still on the stack under the result")
|
||||
draws = {}
|
||||
held:draw()
|
||||
local moveBox
|
||||
for _, d in ipairs(draws) do
|
||||
if d[1] == "box" and d[2] == 4 and d[3] == 7 then moveBox = d end
|
||||
if d[1] == "box" and d[2] == 0 and d[3] == 12 then moveBox = "prompt" end
|
||||
end
|
||||
check(type(moveBox) == "table", "the held menu keeps drawing the move box, not its prompt")
|
||||
draws = {}
|
||||
picker:draw()
|
||||
eq(cursorGlyphAt(8), 0xEC, "the party arrow stays hollow under the result")
|
||||
|
||||
game.stack:pop()
|
||||
picker:close()
|
||||
eq(#game.stack.states, 0, "closing the picker drops the held move box too")
|
||||
end
|
||||
|
||||
do
|
||||
local game = newGame()
|
||||
local mon = { species = "GENGAR", moves = { { id = "LICK" } } }
|
||||
local p = { fake = "picker" }
|
||||
local other = MoveSelectMenu.new(game, mon, "x", nil, nil, nil)
|
||||
game.stack.states = { p, other }
|
||||
PartyMenu.close(setmetatable({ game = game }, { __index = p }))
|
||||
eq(#game.stack.states, 2, "close leaves a menu it does not own alone")
|
||||
end
|
||||
|
||||
do
|
||||
local game = newGame()
|
||||
local mon = { species = "GENGAR", moves = { { id = "LICK" }, { id = "HYPNOSIS" } } }
|
||||
local m = MoveSelectMenu.new(game, mon, "Raise PP of which\ntechnique?")
|
||||
game.stack:push(m)
|
||||
finishBox(game)
|
||||
press(m, "down")
|
||||
local maxed = { fake = true }
|
||||
m.onChoose = function() game.stack:push(maxed) end
|
||||
press(m, "a")
|
||||
game.stack:pop()
|
||||
m:ask()
|
||||
local reprompt = game.stack:top()
|
||||
check(reprompt.fake and reprompt.text == "Raise PP of which\ntechnique?",
|
||||
"ask() types the prompt again")
|
||||
eq(m.ready, false, "the menu waits for it")
|
||||
draws = {}
|
||||
m:draw()
|
||||
local promptBox, moveBox = false, false
|
||||
for _, d in ipairs(draws) do
|
||||
if d[1] == "box" and d[3] == 12 then promptBox = true end
|
||||
if d[1] == "box" and d[3] == 7 then moveBox = true end
|
||||
end
|
||||
check(moveBox and not promptBox, "the move box stays up while the prompt retypes")
|
||||
finishBox(game)
|
||||
eq(m.index, 1, "the cursor starts over on the first move")
|
||||
eq(m.ready, true, "and the menu is live again")
|
||||
end
|
||||
|
||||
do
|
||||
local game = newGame()
|
||||
local mon = { species = "GENGAR", level = 30,
|
||||
moves = { { id = "SURF" }, { id = "LICK" }, { id = "HYPNOSIS" }, { id = "LICK" } } }
|
||||
local m = MoveLearnMenu.new(game, mon, "MEGA_PUNCH", function() end)
|
||||
game.stack:push(m)
|
||||
local ask = game.stack:top()
|
||||
check(ask.fake and ask.opts and ask.opts.choice, "TryingToLearn YES/NO first")
|
||||
game.stack:pop()
|
||||
ask.opts.choice(true)
|
||||
local forget = game.stack:top()
|
||||
check(forget.fake and forget.text:find("Which move should", 1, true) ~= nil,
|
||||
"YES types WhichMoveToForgetText")
|
||||
check(forget.opts and forget.opts.stay ~= nil, "as a done-text stay box")
|
||||
eq(m.selecting, false, "the forget list waits for the text")
|
||||
draws = {}
|
||||
m:draw()
|
||||
eq(#draws, 0, "and is not drawn yet")
|
||||
finishBox(game)
|
||||
eq(game.stack:top(), m, "the text hands off to the forget list")
|
||||
eq(m.selecting, true, "which is live")
|
||||
draws = {}
|
||||
m:draw()
|
||||
eq(draws[1][1], "box", "text box first")
|
||||
eq(draws[1][3], 12, "at row 12")
|
||||
local last
|
||||
for _, d in ipairs(draws) do if d[1] == "box" then last = d end end
|
||||
eq(last and last[2], 4, "forget box last")
|
||||
eq(last and last[3], 7, "at 4,7")
|
||||
|
||||
press(m, "a")
|
||||
local hm = game.stack:top()
|
||||
check(hm.fake and hm.text:find("HM techniques", 1, true) ~= nil, "SURF is refused")
|
||||
eq(m.selecting, false, "the forget box is gone under the refusal")
|
||||
game.stack:pop()
|
||||
hm.onDone()
|
||||
local again = game.stack:top()
|
||||
check(again.fake and again.text:find("Which move should", 1, true) ~= nil,
|
||||
"then the prompt types again")
|
||||
finishBox(game)
|
||||
eq(m.selecting, true, "and the forget list comes back")
|
||||
eq(m.index, 1, "on the first move")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user