mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 04:31:09 +02:00
@@ -0,0 +1 @@
|
|||||||
|
* @bryanthaboi
|
||||||
@@ -53,7 +53,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
|
|||||||
|
|
||||||
### Watch the latest update video
|
### Watch the latest update video
|
||||||
|
|
||||||
[](https://www.youtube.com/watch?v=8IOgqbe4YvA)
|
[](https://youtu.be/yi7LkWQPKKM)
|
||||||
|
|
||||||
|
|
||||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
@@ -5,8 +5,27 @@
|
|||||||
-- voucher exchange and the BICYCLE/CANCEL price window need more than
|
-- voucher exchange and the BICYCLE/CANCEL price window need more than
|
||||||
-- command rows (#568).
|
-- command rows (#568).
|
||||||
|
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
|
||||||
|
-- data/events/hidden_events.asm:542
|
||||||
|
local BIKE_DISPLAYS = {
|
||||||
|
{ 1, 0 }, { 2, 1 }, { 1, 2 }, { 3, 2 }, { 0, 4 }, { 1, 5 },
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
BIKE_SHOP = {
|
BIKE_SHOP = {
|
||||||
|
-- engine/events/hidden_events/new_bike.asm:1
|
||||||
|
onInteract = function(game, ow, fx, fy)
|
||||||
|
for _, c in ipairs(BIKE_DISPLAYS) do
|
||||||
|
if c[1] == fx and c[2] == fy then
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
(game.data.text or {})._NewBicycleText or "A shiny new\nBICYCLE!"))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end,
|
||||||
|
|
||||||
talk = {
|
talk = {
|
||||||
-- BikeShopMiddleAgedWomanText (pokered/scripts/BikeShop.asm):
|
-- BikeShopMiddleAgedWomanText (pokered/scripts/BikeShop.asm):
|
||||||
-- always shows the same flavor line, no branching.
|
-- always shows the same flavor line, no branching.
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ return {
|
|||||||
{ "jump_if_true", "come_see" },
|
{ "jump_if_true", "come_see" },
|
||||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||||
{ "give_item", "POKE_BALL", 5, false },
|
{ "give_item", "POKE_BALL", 5, false },
|
||||||
|
{ "text_sound", "Get_Key_Item" }, -- OaksLab.asm:1060
|
||||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
||||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
||||||
{ "jump", "end" },
|
{ "jump", "end" },
|
||||||
|
|||||||
+11
-1
@@ -516,7 +516,17 @@ M.ROUTE_24 = {
|
|||||||
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||||
done)
|
done)
|
||||||
else
|
else
|
||||||
ow:engageTrainer(npc, done)
|
-- scripts/Route24.asm:125
|
||||||
|
ow:engageTrainer(npc, function()
|
||||||
|
if ow:trainerDefeated(npc) then
|
||||||
|
-- scripts/Route24.asm:62
|
||||||
|
push(game,
|
||||||
|
text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||||
|
done)
|
||||||
|
else
|
||||||
|
done()
|
||||||
|
end
|
||||||
|
end, text(game)._Route24CooltrainerM1DefeatedText, true)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if not flags.EVENT_GOT_NUGGET then
|
if not flags.EVENT_GOT_NUGGET then
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ local M = {}
|
|||||||
|
|
||||||
local function text(game) return game.data.text end
|
local function text(game) return game.data.text end
|
||||||
|
|
||||||
local function push(game, s, done)
|
local function push(game, s, done, opts)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game, s, done))
|
game.stack:push(TextBox.new(game, s, done, opts))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- PrintText on a text_end string returns with the box still drawn and
|
-- PrintText on a text_end string returns with the box still drawn and
|
||||||
@@ -236,7 +236,6 @@ M.CINNABAR_GYM = {
|
|||||||
if yes == machine.yes then
|
if yes == machine.yes then
|
||||||
-- CinnabarGymQuizCorrectText: item jingle, then the gate
|
-- CinnabarGymQuizCorrectText: item jingle, then the gate
|
||||||
-- slides open (SFX_GO_INSIDE) if it was still locked
|
-- slides open (SFX_GO_INSIDE) if it was still locked
|
||||||
Sound.play(game.data, "Get_Item1")
|
|
||||||
push(game, t._CinnabarGymQuizCorrectText
|
push(game, t._CinnabarGymQuizCorrectText
|
||||||
or "You're absolutely\ncorrect!\fGo on through!", function()
|
or "You're absolutely\ncorrect!\fGo on through!", function()
|
||||||
if not game.save.flags[gymGateFlag(index)] then
|
if not game.save.flags[gymGateFlag(index)] then
|
||||||
@@ -244,7 +243,9 @@ M.CINNABAR_GYM = {
|
|||||||
Sound.play(game.data, "Go_Inside")
|
Sound.play(game.data, "Go_Inside")
|
||||||
end
|
end
|
||||||
applyGymGates(game, ow)
|
applyGymGates(game, ow)
|
||||||
end)
|
end, { preSound = function()
|
||||||
|
return Sound.play(game.data, "Get_Item1")
|
||||||
|
end })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
Sound.play(game.data, "Denied")
|
Sound.play(game.data, "Denied")
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ local function surfingPikachu(game)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local function push(game, text, done)
|
local function push(game, text, done, opts)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game, text, done))
|
game.stack:push(TextBox.new(game, text, done, opts))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- the two-variant posters: the surf-capable line once a surfing
|
-- the two-variant posters: the surf-capable line once a surfing
|
||||||
@@ -69,11 +69,11 @@ return {
|
|||||||
|
|
||||||
TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done)
|
TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done)
|
||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
|
-- scripts/SummerBeachHouse.asm:68
|
||||||
push(game, t._SummerBeachHousePikachuText or "PIKACHU: Pikaa!",
|
push(game, t._SummerBeachHousePikachuText or "PIKACHU: Pikaa!",
|
||||||
function()
|
done, { auto = { wait = true, delay = 0, sound = function()
|
||||||
require("src.core.Sound").playCry(game.data, "PIKACHU")
|
return require("src.core.Sound").playCry(game.data, "PIKACHU")
|
||||||
done()
|
end } })
|
||||||
end)
|
|
||||||
end,
|
end,
|
||||||
|
|
||||||
TEXT_SUMMERBEACHHOUSE_POSTER1 = poster(1),
|
TEXT_SUMMERBEACHHOUSE_POSTER1 = poster(1),
|
||||||
|
|||||||
@@ -6,6 +6,27 @@
|
|||||||
// Registered from a constructor so no LÖVE/SDL source needs to know about it.
|
// Registered from a constructor so no LÖVE/SDL source needs to know about it.
|
||||||
|
|
||||||
#import <UIKit/UIKit.h>
|
#import <UIKit/UIKit.h>
|
||||||
|
#import <sys/utsname.h>
|
||||||
|
|
||||||
|
@interface GRDeviceBridge : NSObject
|
||||||
|
+ (NSString *)deviceModel;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation GRDeviceBridge
|
||||||
|
+ (NSString *)deviceModel
|
||||||
|
{
|
||||||
|
#if TARGET_OS_SIMULATOR
|
||||||
|
NSString *simulatorModel = NSProcessInfo.processInfo.environment[@"SIMULATOR_MODEL_IDENTIFIER"];
|
||||||
|
if (simulatorModel.length > 0) return simulatorModel;
|
||||||
|
#endif
|
||||||
|
struct utsname systemInfo;
|
||||||
|
if (uname(&systemInfo) == 0) {
|
||||||
|
NSString *model = [NSString stringWithUTF8String:systemInfo.machine];
|
||||||
|
if (model.length > 0) return model;
|
||||||
|
}
|
||||||
|
return @"";
|
||||||
|
}
|
||||||
|
@end
|
||||||
|
|
||||||
__attribute__((constructor))
|
__attribute__((constructor))
|
||||||
static void GRBootstrapInstall(void)
|
static void GRBootstrapInstall(void)
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ int w_syncHealthSteps(lua_State *L)
|
|||||||
""" % MARKER
|
""" % MARKER
|
||||||
|
|
||||||
WRAP_REGISTRATION = """#ifdef LOVE_IOS
|
WRAP_REGISTRATION = """#ifdef LOVE_IOS
|
||||||
|
{ "getDeviceModel", w_getDeviceModel },
|
||||||
{ "pickFile", w_pickFile },
|
{ "pickFile", w_pickFile },
|
||||||
{ "pickFileKinds", w_pickFileKinds },
|
{ "pickFileKinds", w_pickFileKinds },
|
||||||
{ "createFile", w_createFile },
|
{ "createFile", w_createFile },
|
||||||
@@ -202,6 +203,7 @@ int w_syncHealthSteps(lua_State *L)
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
|
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
|
||||||
|
{ "getDeviceModel", w_getDeviceModel },
|
||||||
{ "syncHealthSteps", w_syncHealthSteps },
|
{ "syncHealthSteps", w_syncHealthSteps },
|
||||||
{ "httpDownload", w_httpDownload },
|
{ "httpDownload", w_httpDownload },
|
||||||
{ "httpRequest", w_httpRequest },
|
{ "httpRequest", w_httpRequest },
|
||||||
@@ -210,6 +212,33 @@ WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
|
|||||||
|
|
||||||
BRIDGE_EXTRA_FUNCS = """
|
BRIDGE_EXTRA_FUNCS = """
|
||||||
#ifdef LOVE_IOS
|
#ifdef LOVE_IOS
|
||||||
|
int w_getDeviceModel(lua_State *L)
|
||||||
|
{
|
||||||
|
Class cls = objc_getClass("GRDeviceBridge");
|
||||||
|
if (cls == nullptr)
|
||||||
|
{
|
||||||
|
lua_pushnil(L);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
typedef id (*GRObj)(Class, SEL);
|
||||||
|
id value = ((GRObj)objc_msgSend)(cls, sel_registerName("deviceModel"));
|
||||||
|
if (value == nullptr)
|
||||||
|
{
|
||||||
|
lua_pushnil(L);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
typedef const char *(*GRUTF8)(id, SEL);
|
||||||
|
const char *bytes = ((GRUTF8)objc_msgSend)(value,
|
||||||
|
sel_registerName("UTF8String"));
|
||||||
|
if (bytes == nullptr || bytes[0] == '\\0')
|
||||||
|
{
|
||||||
|
lua_pushnil(L);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
lua_pushstring(L, bytes);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
int w_httpDownload(lua_State *L)
|
int w_httpDownload(lua_State *L)
|
||||||
{
|
{
|
||||||
const char *url = luaL_checkstring(L, 1);
|
const char *url = luaL_checkstring(L, 1);
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 318 B After Width: | Height: | Size: 318 B |
|
Before Width: | Height: | Size: 687 B After Width: | Height: | Size: 687 B |
@@ -0,0 +1,12 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
Version headings match `manifest.json`'s `version`.
|
||||||
|
|
||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats.
|
||||||
|
- Answers written to `mod.save` via `intro.oak_speech.answered`.
|
||||||
|
- Custom `toast_kid.png` sprite shown mid-speech.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Silly Oak Intro Example
|
||||||
|
|
||||||
|
Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival,
|
||||||
|
player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`.
|
||||||
|
|
||||||
|
## Try it (play through yourself)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rm -rf mods/example_silly_oak
|
||||||
|
cp -r mods/examples/example_silly_oak mods/
|
||||||
|
love .
|
||||||
|
```
|
||||||
|
|
||||||
|
Then **NEW GAME** and mash A / pick the menus. Disable or delete
|
||||||
|
`mods/example_silly_oak` when you're done so vanilla boots clean.
|
||||||
|
|
||||||
|
## Headless check
|
||||||
|
|
||||||
|
```sh
|
||||||
|
luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auto driver (screenshots + save asserts)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rm -rf mods/example_silly_oak
|
||||||
|
cp -r mods/examples/example_silly_oak mods/
|
||||||
|
SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \
|
||||||
|
POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love .
|
||||||
|
```
|
||||||
|
|
||||||
|
`POKEPORT_IDENTITY` keeps this run's save out of your normal slot.
|
||||||
|
|
||||||
|
## What it demonstrates
|
||||||
|
|
||||||
|
| Seam | Where |
|
||||||
|
|---|---|
|
||||||
|
| `hooks:wrap("intro.oak_speech.build")` | `main.lua` -- reshape the step list |
|
||||||
|
| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` -- anchored on vanilla step ids |
|
||||||
|
| step kinds `say` / `yesno` / `choice` | `main.lua` |
|
||||||
|
| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` |
|
||||||
|
| `events:on("intro.oak_speech.answered")` | `main.lua` → `mod.save` |
|
||||||
|
| `events:on("intro.oak_speech.finished")` | `main.lua` |
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 245 B |
@@ -0,0 +1,107 @@
|
|||||||
|
-- Gallery entry: reshape Oak's intro speech with extra questions, sprite
|
||||||
|
-- swaps (oak / rival / player / pokemon / a custom image), and answers
|
||||||
|
-- that land in mod.save.
|
||||||
|
--
|
||||||
|
-- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered.
|
||||||
|
|
||||||
|
return function(mod)
|
||||||
|
local toastPic = mod.path .. "/assets/toast_kid.png"
|
||||||
|
|
||||||
|
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
|
||||||
|
steps = next(steps, speech)
|
||||||
|
|
||||||
|
-- after oak says hello, immediately derail
|
||||||
|
mod.ui.insertStepAfter(steps, "oak_welcome", {
|
||||||
|
id = "silly_quiz_intro",
|
||||||
|
kind = "say",
|
||||||
|
pic = "oak",
|
||||||
|
text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.",
|
||||||
|
})
|
||||||
|
|
||||||
|
mod.ui.insertStepAfter(steps, "silly_quiz_intro", {
|
||||||
|
id = "silly_toast",
|
||||||
|
kind = "yesno",
|
||||||
|
pic = "oak",
|
||||||
|
saveKey = "likes_toast",
|
||||||
|
text = "Do you like\ntoast?",
|
||||||
|
})
|
||||||
|
|
||||||
|
-- brand new sprite mid-speech
|
||||||
|
mod.ui.insertStepAfter(steps, "silly_toast", {
|
||||||
|
id = "silly_toast_kid",
|
||||||
|
kind = "say",
|
||||||
|
pic = { type = "image", path = toastPic },
|
||||||
|
reveal = "fade",
|
||||||
|
saveKey = nil,
|
||||||
|
text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.",
|
||||||
|
})
|
||||||
|
|
||||||
|
-- existing mon with a wipe + cry, parked after the real demo mon
|
||||||
|
mod.ui.insertStepAfter(steps, "demo_mon", {
|
||||||
|
id = "silly_mew",
|
||||||
|
kind = "say",
|
||||||
|
pic = { type = "pokemon", id = "MEW" },
|
||||||
|
reveal = "wipe",
|
||||||
|
cry = "MEW",
|
||||||
|
text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.",
|
||||||
|
})
|
||||||
|
|
||||||
|
mod.ui.insertStepAfter(steps, "silly_mew", {
|
||||||
|
id = "silly_snack",
|
||||||
|
kind = "choice",
|
||||||
|
pic = "oak",
|
||||||
|
saveKey = "snack",
|
||||||
|
text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.",
|
||||||
|
choices = { "BERRIES", "LEFTOVERS", "OLD ROD" },
|
||||||
|
})
|
||||||
|
|
||||||
|
-- swap to rival pic for a loaded question before naming him
|
||||||
|
mod.ui.insertStepBefore(steps, "ask_rival_name", {
|
||||||
|
id = "silly_trust",
|
||||||
|
kind = "choice",
|
||||||
|
pic = "rival",
|
||||||
|
reveal = "fade",
|
||||||
|
saveKey = "trusts_rival",
|
||||||
|
text = "Look at this kid.\nTrustworthy?",
|
||||||
|
choices = { "SURE", "NO" },
|
||||||
|
values = { true, false },
|
||||||
|
})
|
||||||
|
|
||||||
|
-- player pic for one last bit after both names are set
|
||||||
|
mod.ui.insertStepAfter(steps, "name_rival", {
|
||||||
|
id = "silly_pineapple",
|
||||||
|
kind = "yesno",
|
||||||
|
pic = "player",
|
||||||
|
saveKey = "pineapple_on_pizza",
|
||||||
|
text = "{PLAYER}. Be honest.\nPineapple on\vpizza?",
|
||||||
|
})
|
||||||
|
|
||||||
|
mod.ui.insertStepAfter(steps, "silly_pineapple", {
|
||||||
|
id = "silly_closing",
|
||||||
|
kind = "say",
|
||||||
|
pic = "oak",
|
||||||
|
text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.",
|
||||||
|
})
|
||||||
|
|
||||||
|
return steps
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- every answered step with a saveKey lands in mod.save (and therefore
|
||||||
|
-- save.modData[mod.id] once the slot is written)
|
||||||
|
mod.events:on("intro.oak_speech.answered", function(ev)
|
||||||
|
if not ev.saveKey then return end
|
||||||
|
mod.save:set(ev.saveKey, ev.value)
|
||||||
|
mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value))
|
||||||
|
end)
|
||||||
|
|
||||||
|
mod.events:on("intro.oak_speech.finished", function(ev)
|
||||||
|
local answers = ev.answers or {}
|
||||||
|
for key, value in pairs(answers) do
|
||||||
|
if mod.save:get(key) == nil then
|
||||||
|
mod.save:set(key, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
mod.save:set("quiz_done", true)
|
||||||
|
mod.log:info("silly oak quiz done")
|
||||||
|
end)
|
||||||
|
end
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "example_silly_oak",
|
||||||
|
"name": "Silly Oak Intro Example",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"api": 2,
|
||||||
|
"entry": "main.lua",
|
||||||
|
"profile": "content",
|
||||||
|
"category": "UI",
|
||||||
|
"game_version": ">=0.0.0-0 <2.0.0",
|
||||||
|
"priority": 100,
|
||||||
|
"dependencies": [],
|
||||||
|
"optional_dependencies": [],
|
||||||
|
"conflicts": [],
|
||||||
|
"description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save."
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Sharing metadata for the manager detail pane.
|
||||||
|
return {
|
||||||
|
summary = "Oak asks dumb questions during the intro and remembers your answers.",
|
||||||
|
author = "Pokemon Gen 1 Recompilation Project",
|
||||||
|
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||||
|
tags = { "intro", "ui", "oak", "hooks" },
|
||||||
|
differences = {
|
||||||
|
changed = {
|
||||||
|
"Oak's NEW GAME speech gains extra questions and sprite beats",
|
||||||
|
},
|
||||||
|
added = {
|
||||||
|
"mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done",
|
||||||
|
"Custom Toast Kid pic mid-intro",
|
||||||
|
},
|
||||||
|
known = { "vanilla naming and the shrink-away still run" },
|
||||||
|
},
|
||||||
|
credits = {
|
||||||
|
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" },
|
||||||
|
},
|
||||||
|
compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 },
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
-- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
|
||||||
|
-- Covers the intro.oak_speech build hook, step helpers, sprite descriptors,
|
||||||
|
-- and answers landing in mod.save.
|
||||||
|
--
|
||||||
|
-- Needs an imported ROM dataset (data/generated/). Headless CI and a
|
||||||
|
-- fresh checkout without a ROM skip cleanly -- the gallery is also
|
||||||
|
-- covered by tests/mod_examples_tests.lua when generated data is present.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local function hasGenerated()
|
||||||
|
local handle = io.open("data/generated/constants.lua", "r")
|
||||||
|
if handle then handle:close() return true end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if not hasGenerated() then
|
||||||
|
print("example_silly_oak_test skipped (needs data/generated/)")
|
||||||
|
os.exit(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local OakSpeech = require("src.ui.OakSpeech")
|
||||||
|
local Data = require("src.core.Data")
|
||||||
|
Data:load()
|
||||||
|
|
||||||
|
local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data })
|
||||||
|
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||||
|
|
||||||
|
local mod = run.mod
|
||||||
|
T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state")
|
||||||
|
local ModUI = require("src.ui.ModUI")
|
||||||
|
local bucket = function()
|
||||||
|
return run.loader.modSave.example_silly_oak or {}
|
||||||
|
end
|
||||||
|
local toastPath = (mod.path or "mods/examples/example_silly_oak")
|
||||||
|
.. "/assets/toast_kid.png"
|
||||||
|
|
||||||
|
-- ------- build hook injects every silly beat around vanilla anchors
|
||||||
|
|
||||||
|
local speech = OakSpeech.new({
|
||||||
|
data = Data,
|
||||||
|
save = { player = { name = "RED", rival = "BLUE" } },
|
||||||
|
stack = { push = function() end, pop = function() end },
|
||||||
|
}, nil)
|
||||||
|
local steps = speech:buildSteps()
|
||||||
|
|
||||||
|
local ids = {}
|
||||||
|
for _, step in ipairs(steps) do ids[#ids + 1] = step.id end
|
||||||
|
local function has(id)
|
||||||
|
for _, x in ipairs(ids) do if x == id then return true end end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
T.check(has("oak_welcome") and has("name_player") and has("shrink"),
|
||||||
|
"vanilla anchors still present")
|
||||||
|
T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"),
|
||||||
|
"toast quiz beats injected")
|
||||||
|
T.check(has("silly_mew") and has("silly_snack"),
|
||||||
|
"MEW reveal and snack choice injected")
|
||||||
|
T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"),
|
||||||
|
"rival trust + pineapple beats injected")
|
||||||
|
|
||||||
|
-- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask
|
||||||
|
local function indexOf(id)
|
||||||
|
for i, x in ipairs(ids) do if x == id then return i end end
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"),
|
||||||
|
"Toast Kid shows before the demo mon")
|
||||||
|
T.check(indexOf("demo_mon") < indexOf("silly_mew"),
|
||||||
|
"MEW shows after the demo mon")
|
||||||
|
T.check(indexOf("silly_trust") < indexOf("ask_rival_name"),
|
||||||
|
"trust question is before rival naming")
|
||||||
|
T.check(indexOf("name_rival") < indexOf("silly_pineapple")
|
||||||
|
and indexOf("silly_pineapple") < indexOf("legend"),
|
||||||
|
"pineapple lands between rival name and the legend beat")
|
||||||
|
|
||||||
|
-- ------- step shapes cover choice / yesno / custom image / pokemon
|
||||||
|
|
||||||
|
local byId = {}
|
||||||
|
for _, step in ipairs(steps) do byId[step.id] = step end
|
||||||
|
|
||||||
|
T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no")
|
||||||
|
T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast")
|
||||||
|
T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice")
|
||||||
|
T.eq(#byId.silly_snack.choices, 3, "snack has three options")
|
||||||
|
T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image",
|
||||||
|
"Toast Kid uses a custom image pic")
|
||||||
|
T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon"
|
||||||
|
and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW",
|
||||||
|
"MEW beat uses pokemon pic + cry")
|
||||||
|
T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic")
|
||||||
|
|
||||||
|
-- ------- resolvePic covers trainer / pokemon / player / image shorthand
|
||||||
|
|
||||||
|
local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech)
|
||||||
|
local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech)
|
||||||
|
local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech)
|
||||||
|
local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data },
|
||||||
|
{ type = "pokemon", id = "MEW", flip = true }, speech)
|
||||||
|
local customImg = OakSpeech.resolvePic({ data = Data },
|
||||||
|
{ type = "image", path = toastPath }, speech)
|
||||||
|
-- headless love stub may return nil images; the call itself must not throw
|
||||||
|
T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata"
|
||||||
|
or type(oakImg) == "table",
|
||||||
|
"oak shorthand resolves without error")
|
||||||
|
T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata"
|
||||||
|
or type(rivalImg) == "table",
|
||||||
|
"rival shorthand resolves without error")
|
||||||
|
T.check(playerImg == speech.playerPic or playerImg == nil
|
||||||
|
or type(playerImg) == "userdata" or type(playerImg) == "table",
|
||||||
|
"player shorthand resolves without error")
|
||||||
|
T.check(mewFlip == true, "pokemon flip flag is honored")
|
||||||
|
T.check(customImg ~= nil or true, "custom image path is accepted")
|
||||||
|
|
||||||
|
-- ------- answered event writes mod.save (loader.modSave bucket)
|
||||||
|
|
||||||
|
Runtime.emit("intro.oak_speech.answered", {
|
||||||
|
saveKey = "likes_toast", value = true, label = "YES", index = 1,
|
||||||
|
step = byId.silly_toast, speech = speech,
|
||||||
|
})
|
||||||
|
Runtime.emit("intro.oak_speech.answered", {
|
||||||
|
saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3,
|
||||||
|
step = byId.silly_snack, speech = speech,
|
||||||
|
})
|
||||||
|
Runtime.emit("intro.oak_speech.answered", {
|
||||||
|
saveKey = "trusts_rival", value = false, label = "NO", index = 2,
|
||||||
|
step = byId.silly_trust, speech = speech,
|
||||||
|
})
|
||||||
|
Runtime.emit("intro.oak_speech.answered", {
|
||||||
|
saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1,
|
||||||
|
step = byId.silly_pineapple, speech = speech,
|
||||||
|
})
|
||||||
|
Runtime.emit("intro.oak_speech.finished", {
|
||||||
|
speech = speech, answers = speech.answers,
|
||||||
|
})
|
||||||
|
|
||||||
|
local saved = bucket()
|
||||||
|
T.eq(saved.likes_toast, true, "likes_toast saved")
|
||||||
|
T.eq(saved.snack, "OLD ROD", "snack saved")
|
||||||
|
T.eq(saved.trusts_rival, false, "trusts_rival saved")
|
||||||
|
T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved")
|
||||||
|
T.eq(saved.quiz_done, true, "quiz_done stamped on finish")
|
||||||
|
|
||||||
|
-- ------- ModUI step helpers (public surface)
|
||||||
|
|
||||||
|
local tiny = {
|
||||||
|
{ id = "a", kind = "say" },
|
||||||
|
{ id = "b", kind = "say" },
|
||||||
|
}
|
||||||
|
ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" })
|
||||||
|
T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor")
|
||||||
|
ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" })
|
||||||
|
T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor")
|
||||||
|
ModUI.removeStep(tiny, "mid")
|
||||||
|
T.check(tiny[2].id ~= "mid", "removeStep drops by id")
|
||||||
|
|
||||||
|
run.release()
|
||||||
|
T.finish("example_silly_oak")
|
||||||
+84
-11
@@ -1008,9 +1008,23 @@ end
|
|||||||
-- flickers the OBJ palette, DoBallTossSpecialEffects)
|
-- flickers the OBJ palette, DoBallTossSpecialEffects)
|
||||||
function BattleState:animNext(name, isPlayer, shakes, ball)
|
function BattleState:animNext(name, isPlayer, shakes, ball)
|
||||||
self.nextInsert = (self.nextInsert or 0) + 1
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
table.insert(self.queue, self.nextInsert,
|
local row = { anim = name, attackerIsPlayer = isPlayer, shakes = shakes,
|
||||||
{ anim = name, attackerIsPlayer = isPlayer, shakes = shakes,
|
ball = ball }
|
||||||
ball = ball })
|
table.insert(self.queue, self.nextInsert, row)
|
||||||
|
return row
|
||||||
|
end
|
||||||
|
|
||||||
|
-- an animation row ahead of the move's own, with PlayBattleAnimation2's
|
||||||
|
-- applying-animation shake (engine/battle/effects.asm:1461-1471)
|
||||||
|
function BattleState:animBeforeMove(name, isPlayer)
|
||||||
|
local at
|
||||||
|
for i, item in ipairs(self.queue) do
|
||||||
|
if item == self.moveAnimRow then at = i break end
|
||||||
|
end
|
||||||
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
|
table.insert(self.queue, at or self.nextInsert,
|
||||||
|
{ anim = name, attackerIsPlayer = isPlayer, animDelayed = true,
|
||||||
|
hit = { animType = isPlayer and 6 or 3 } })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- insert an act right after the current queue item
|
-- insert an act right after the current queue item
|
||||||
@@ -1554,15 +1568,41 @@ end
|
|||||||
function BattleState:sendOutText(name)
|
function BattleState:sendOutText(name)
|
||||||
local e = self.enemy and self.enemy.mon
|
local e = self.enemy and self.enemy.mon
|
||||||
local pct = 100
|
local pct = 100
|
||||||
if e and e.hp > 0 and math.floor(e.stats.hp / 4) > 0 then
|
if e and e.hp > 0 then
|
||||||
|
-- the same routine stamps wLastSwitchInEnemyMonHP
|
||||||
|
-- (engine/battle/common_text.asm:105-110)
|
||||||
|
self.lastSwitchInEnemyHP = e.hp
|
||||||
|
if math.floor(e.stats.hp / 4) > 0 then
|
||||||
pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4))
|
pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4))
|
||||||
end
|
end
|
||||||
|
end
|
||||||
if pct >= 70 then return Strings("Go! %s!", name) end
|
if pct >= 70 then return Strings("Go! %s!", name) end
|
||||||
if pct >= 40 then return Strings("Do it! %s!", name) end
|
if pct >= 40 then return Strings("Do it! %s!", name) end
|
||||||
if pct >= 10 then return Strings("Get'm! %s!", name) end
|
if pct >= 10 then return Strings("Get'm! %s!", name) end
|
||||||
return self:romText("_EnemysWeakText", "The enemy's weak!\nGet'm! %s!", name)
|
return self:romText("_EnemysWeakText", "The enemy's weak!\nGet'm! %s!", name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- RetreatMon / PlayerMon2Text (engine/battle/common_text.asm:167-243): the
|
||||||
|
-- adjective reads the enemy HP lost since this mon switched in
|
||||||
|
function BattleState:withdrawText(name)
|
||||||
|
local e = self.enemy and self.enemy.mon
|
||||||
|
local drop = 0
|
||||||
|
if e and self.lastSwitchInEnemyHP and math.floor(e.stats.hp / 4) > 0 then
|
||||||
|
drop = math.floor((self.lastSwitchInEnemyHP - e.hp) * 25
|
||||||
|
/ math.floor(e.stats.hp / 4))
|
||||||
|
end
|
||||||
|
local word = ""
|
||||||
|
if drop <= 0 then
|
||||||
|
word = self:romText("_EnoughText", "enough!")
|
||||||
|
elseif drop >= 70 then
|
||||||
|
word = self:romText("_GoodText", "good!")
|
||||||
|
elseif drop >= 30 then
|
||||||
|
word = self:romText("_OKExclamationText", "OK!")
|
||||||
|
end
|
||||||
|
return self:romText("_PlayerMon2Text", "%s ", name) .. word
|
||||||
|
.. self:romText("_ComeBackText", "\nCome back!")
|
||||||
|
end
|
||||||
|
|
||||||
-- The cry a mon makes as it takes the field. Yellow does not run its
|
-- The cry a mon makes as it takes the field. Yellow does not run its
|
||||||
-- starter Pikachu through PlayCry at all: SendOutMon branches to
|
-- starter Pikachu through PlayCry at all: SendOutMon branches to
|
||||||
-- .starterPikachu (engine/battle/core.asm:1807-1817) and voices PCM
|
-- .starterPikachu (engine/battle/core.asm:1807-1817) and voices PCM
|
||||||
@@ -1816,7 +1856,8 @@ function BattleState:enter()
|
|||||||
self.enemySendingOut = true
|
self.enemySendingOut = true
|
||||||
self:slidePic("foe")
|
self:slidePic("foe")
|
||||||
end)
|
end)
|
||||||
self:say(Strings("%s sent\nout %s!", foeName, self.enemy.name))
|
-- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923)
|
||||||
|
self:sayAuto(Strings("%s sent\nout %s!", foeName, self.enemy.name))
|
||||||
self:act(function()
|
self:act(function()
|
||||||
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
||||||
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
||||||
@@ -1845,7 +1886,8 @@ function BattleState:enter()
|
|||||||
self.sendingOut = true
|
self.sendingOut = true
|
||||||
self:slidePic("back")
|
self:slidePic("back")
|
||||||
end)
|
end)
|
||||||
self:say(self:sendOutText(self.player.name))
|
-- _GoText.._PlayerMon1Text carry no prompt (data/text/text_2.asm:1274-1294)
|
||||||
|
self:sayAuto(self:sendOutText(self.player.name))
|
||||||
-- then the POOF plays and the mon appears with its cry
|
-- then the POOF plays and the mon appears with its cry
|
||||||
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
|
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
|
||||||
self:queueSendOutAnim(true)
|
self:queueSendOutAnim(true)
|
||||||
@@ -2659,6 +2701,11 @@ function BattleState:resolveSwitch(newMon)
|
|||||||
self.phase = "messages"
|
self.phase = "messages"
|
||||||
self.afterQueue = "menu"
|
self.afterQueue = "menu"
|
||||||
self:act(function()
|
self:act(function()
|
||||||
|
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
|
||||||
|
-- outgoing pic and holds 50 frames before the mon is recalled
|
||||||
|
self:sayNextAuto(self:withdrawText(self.player.name),
|
||||||
|
Timing.SWITCH_PLAYER_MON)
|
||||||
|
self:actNext(function()
|
||||||
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
||||||
local previous = self.player
|
local previous = self.player
|
||||||
self.player = makeBattler(self.data, newMon, true, self.game.save)
|
self.player = makeBattler(self.data, newMon, true, self.game.save)
|
||||||
@@ -2673,9 +2720,10 @@ function BattleState:resolveSwitch(newMon)
|
|||||||
self:markParticipant()
|
self:markParticipant()
|
||||||
sendOutMonCursors(self)
|
sendOutMonCursors(self)
|
||||||
self.sendingOut = true
|
self.sendingOut = true
|
||||||
self:sayNext(self:sendOutText(self.player.name))
|
self:sayNextAuto(self:sendOutText(self.player.name))
|
||||||
self:queueSendOutAnim(false)
|
self:queueSendOutAnim(false)
|
||||||
end)
|
end)
|
||||||
|
end)
|
||||||
self:act(function()
|
self:act(function()
|
||||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||||
end)
|
end)
|
||||||
@@ -2703,7 +2751,12 @@ function BattleState:residualFor(b, opp)
|
|||||||
if b.residualDone then return end
|
if b.residualDone then return end
|
||||||
b.residualDone = true
|
b.residualDone = true
|
||||||
local msgs = Status.residual(b, opp, self)
|
local msgs = Status.residual(b, opp, self)
|
||||||
|
local rec = Status.recordFor(self.data and self.data.statuses, b.mon.status)
|
||||||
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
|
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
|
||||||
|
-- engine/battle/core.asm:490-493
|
||||||
|
if rec and rec.residual then
|
||||||
|
self:animNext("BURN_PSN_ANIM", b.isPlayer)
|
||||||
|
end
|
||||||
if b.leechSeeded and b.mon.hp > 0 then
|
if b.leechSeeded and b.mon.hp > 0 then
|
||||||
-- the drain plays the ABSORB animation from the healing side
|
-- the drain plays the ABSORB animation from the healing side
|
||||||
-- (core.asm:506-517 flips hWhoseTurn before PlayMoveAnimation)
|
-- (core.asm:506-517 flips hWhoseTurn before PlayMoveAnimation)
|
||||||
@@ -3587,7 +3640,17 @@ function BattleState:executeAction(user, target, action)
|
|||||||
markSeen(self.game, self.enemy.mon.species)
|
markSeen(self.game, self.enemy.mon.species)
|
||||||
-- _AIBattleWithdrawText: "X with-/drew Y!"
|
-- _AIBattleWithdrawText: "X with-/drew Y!"
|
||||||
self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName))
|
self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName))
|
||||||
self:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
-- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
|
||||||
|
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
|
||||||
|
self.enemySendingOut = true
|
||||||
|
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||||
|
self:actNext(function()
|
||||||
|
self.enemySendingOut = false
|
||||||
|
self:startGrowIn(self.enemy)
|
||||||
|
self:actNext(function()
|
||||||
|
self:waitSfxNext(self:playEntranceCry(self.enemy))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -4277,6 +4340,9 @@ function BattleState:enemyMonFainted()
|
|||||||
self:act(function()
|
self:act(function()
|
||||||
local previous = self.enemy
|
local previous = self.enemy
|
||||||
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
|
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
|
||||||
|
-- EnemySendOutFirstMon (core.asm:1359-1363): the fresh foe's HP is the
|
||||||
|
-- new wLastSwitchInEnemyMonHP baseline RetreatMon measures from
|
||||||
|
self.lastSwitchInEnemyHP = self.enemy.mon.hp
|
||||||
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
||||||
clearTrapping(self.player)
|
clearTrapping(self.player)
|
||||||
self:syncSides()
|
self:syncSides()
|
||||||
@@ -4296,7 +4362,7 @@ function BattleState:enemyMonFainted()
|
|||||||
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
|
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
|
||||||
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
|
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
|
||||||
self.enemySendingOut = true
|
self.enemySendingOut = true
|
||||||
self:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
self.enemySendingOut = false
|
self.enemySendingOut = false
|
||||||
self:startGrowIn(self.enemy)
|
self:startGrowIn(self.enemy)
|
||||||
@@ -4309,6 +4375,12 @@ function BattleState:enemyMonFainted()
|
|||||||
self:act(function()
|
self:act(function()
|
||||||
local mon = shiftSwitchMon
|
local mon = shiftSwitchMon
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
|
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
|
||||||
|
-- hold, then the recall and the send-out
|
||||||
|
self.nextInsert = 0
|
||||||
|
self:sayNextAuto(self:withdrawText(self.player.name),
|
||||||
|
Timing.SWITCH_PLAYER_MON)
|
||||||
|
self:actNext(function()
|
||||||
local previous = self.player
|
local previous = self.player
|
||||||
self.player = makeBattler(self.data, mon, true, self.game.save)
|
self.player = makeBattler(self.data, mon, true, self.game.save)
|
||||||
clearTrapping(self.enemy)
|
clearTrapping(self.enemy)
|
||||||
@@ -4335,9 +4407,10 @@ function BattleState:enemyMonFainted()
|
|||||||
self.nextInsert = 0
|
self.nextInsert = 0
|
||||||
sendOutMonCursors(self)
|
sendOutMonCursors(self)
|
||||||
self.sendingOut = true
|
self.sendingOut = true
|
||||||
self:sayNext(self:sendOutText(self.player.name))
|
self:sayNextAuto(self:sendOutText(self.player.name))
|
||||||
self:queueSendOutAnim(false)
|
self:queueSendOutAnim(false)
|
||||||
end)
|
end)
|
||||||
|
end)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
|
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
|
||||||
@@ -4526,7 +4599,7 @@ function BattleState:openReplacementMenu()
|
|||||||
self.nextInsert = 0
|
self.nextInsert = 0
|
||||||
sendOutMonCursors(self)
|
sendOutMonCursors(self)
|
||||||
self.sendingOut = true
|
self.sendingOut = true
|
||||||
self:sayNext(self:sendOutText(self.player.name))
|
self:sayNextAuto(self:sendOutText(self.player.name))
|
||||||
self:queueSendOutAnim(false)
|
self:queueSendOutAnim(false)
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -92,6 +92,20 @@ local function hitCount(ctx, record)
|
|||||||
return dist[r + 1]
|
return dist[r + 1]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- engine/battle/effects.asm:119-151 (poison), :194-255 (burn/freeze/paralyze)
|
||||||
|
local FBP_SIDE_STATUS = { BRN = true, FRZ = true, PAR = true }
|
||||||
|
|
||||||
|
local function secondaryStatusFx(battle, user, status)
|
||||||
|
if status == "PSN" then
|
||||||
|
local row = battle:animNext(user.isPlayer and "ENEMY_HUD_SHAKE_ANIM"
|
||||||
|
or "SHAKE_SCREEN_ANIM", user.isPlayer)
|
||||||
|
row.animDelayed = true
|
||||||
|
row.hit = { animType = user.isPlayer and 6 or 3 }
|
||||||
|
elseif FBP_SIDE_STATUS[status] and user.isPlayer then
|
||||||
|
battle:animNext("ENEMY_HUD_SHAKE_ANIM", true).animDelayed = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- The damaging pipeline, extracted from the performMove monolith: every
|
-- The damaging pipeline, extracted from the performMove monolith: every
|
||||||
-- stage keeps the original's exact check order and rng consumption
|
-- stage keeps the original's exact check order and rng consumption
|
||||||
-- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy ->
|
-- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy ->
|
||||||
@@ -318,7 +332,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
-- secondary side effects (blocked by fainting)
|
-- secondary side effects (blocked by fainting)
|
||||||
if record and record.run and record.kind ~= "primary"
|
if record and record.run and record.kind ~= "primary"
|
||||||
and target.mon.hp > 0 and totalDealt > 0 then
|
and target.mon.hp > 0 and totalDealt > 0 then
|
||||||
for _, m in ipairs(record.run(ctx)) do
|
local hadStatus = target.mon.status
|
||||||
|
local msgs = record.run(ctx)
|
||||||
|
if target.mon.status and target.mon.status ~= hadStatus then
|
||||||
|
secondaryStatusFx(battle, user, target.mon.status)
|
||||||
|
end
|
||||||
|
for _, m in ipairs(msgs) do
|
||||||
battle:sayNext(m)
|
battle:sayNext(m)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -581,6 +581,15 @@ MoveEffects.full = {
|
|||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
THRASH_PETAL_DANCE_EFFECT = {
|
THRASH_PETAL_DANCE_EFFECT = {
|
||||||
|
-- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage
|
||||||
|
-- (data/battle/special_effects.asm:22) and animates the setup turn
|
||||||
|
beforeAccuracy = function(ctx)
|
||||||
|
local user = ctx.user
|
||||||
|
if not user.thrashTurns then
|
||||||
|
ctx.battle:animBeforeMove(
|
||||||
|
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
|
||||||
|
end
|
||||||
|
end,
|
||||||
afterDamage = function(ctx)
|
afterDamage = function(ctx)
|
||||||
local user = ctx.user
|
local user = ctx.user
|
||||||
if not user.thrashTurns then
|
if not user.thrashTurns then
|
||||||
|
|||||||
+86
-13
@@ -6,6 +6,49 @@ local IssueReport = {}
|
|||||||
local FORM_URL = "https://github.com/bryanthaboi/gen1recomp/issues/new"
|
local FORM_URL = "https://github.com/bryanthaboi/gen1recomp/issues/new"
|
||||||
local TEMPLATE = "bug_report.yml"
|
local TEMPLATE = "bug_report.yml"
|
||||||
|
|
||||||
|
local APPLE_MODELS = {
|
||||||
|
["iPhone14,2"] = "iPhone 13 Pro",
|
||||||
|
["iPhone14,3"] = "iPhone 13 Pro Max",
|
||||||
|
["iPhone14,4"] = "iPhone 13 mini",
|
||||||
|
["iPhone14,5"] = "iPhone 13",
|
||||||
|
["iPhone14,7"] = "iPhone 14",
|
||||||
|
["iPhone14,8"] = "iPhone 14 Plus",
|
||||||
|
["iPhone15,2"] = "iPhone 14 Pro",
|
||||||
|
["iPhone15,3"] = "iPhone 14 Pro Max",
|
||||||
|
["iPhone15,4"] = "iPhone 15",
|
||||||
|
["iPhone15,5"] = "iPhone 15 Plus",
|
||||||
|
["iPhone16,1"] = "iPhone 15 Pro",
|
||||||
|
["iPhone16,2"] = "iPhone 15 Pro Max",
|
||||||
|
["iPhone17,1"] = "iPhone 16 Pro",
|
||||||
|
["iPhone17,2"] = "iPhone 16 Pro Max",
|
||||||
|
["iPhone17,3"] = "iPhone 16",
|
||||||
|
["iPhone17,4"] = "iPhone 16 Plus",
|
||||||
|
["iPhone17,5"] = "iPhone 16e",
|
||||||
|
["Mac14,2"] = "MacBook Air (13-inch, M2)",
|
||||||
|
["Mac14,3"] = "Mac mini (M2)",
|
||||||
|
["Mac14,5"] = "MacBook Pro (14-inch, M2 Max)",
|
||||||
|
["Mac14,6"] = "MacBook Pro (16-inch, M2 Max)",
|
||||||
|
["Mac14,7"] = "MacBook Pro (13-inch, M2)",
|
||||||
|
["Mac14,9"] = "MacBook Pro (14-inch, M2 Pro)",
|
||||||
|
["Mac14,10"] = "MacBook Pro (16-inch, M2 Pro)",
|
||||||
|
["Mac14,12"] = "Mac mini (M2 Pro)",
|
||||||
|
["Mac14,13"] = "Mac Studio (M2 Max)",
|
||||||
|
["Mac14,14"] = "Mac Studio (M2 Ultra)",
|
||||||
|
["Mac14,15"] = "MacBook Air (15-inch, M2)",
|
||||||
|
["Mac15,3"] = "MacBook Pro (14-inch, M3)",
|
||||||
|
["Mac15,6"] = "MacBook Pro (14-inch, M3 Pro)",
|
||||||
|
["Mac15,7"] = "MacBook Pro (16-inch, M3 Pro)",
|
||||||
|
["Mac15,12"] = "MacBook Air (13-inch, M3)",
|
||||||
|
["Mac15,13"] = "MacBook Air (15-inch, M3)",
|
||||||
|
["Mac16,1"] = "MacBook Pro (14-inch, M4)",
|
||||||
|
["Mac16,5"] = "MacBook Pro (16-inch, M4 Max)",
|
||||||
|
["Mac16,6"] = "MacBook Pro (14-inch, M4 Max)",
|
||||||
|
["Mac16,7"] = "MacBook Pro (16-inch, M4 Pro)",
|
||||||
|
["Mac16,8"] = "MacBook Pro (14-inch, M4 Pro)",
|
||||||
|
["Mac16,10"] = "Mac mini (M4)",
|
||||||
|
["Mac16,11"] = "Mac mini (M4 Pro)",
|
||||||
|
}
|
||||||
|
|
||||||
local function clean(value)
|
local function clean(value)
|
||||||
if value == nil then return nil end
|
if value == nil then return nil end
|
||||||
local text = tostring(value):gsub("^%s+", ""):gsub("%s+$", "")
|
local text = tostring(value):gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
@@ -36,6 +79,16 @@ local function commandValue(command)
|
|||||||
return clean(value)
|
return clean(value)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function commandText(command)
|
||||||
|
if not io or type(io.popen) ~= "function" then return nil end
|
||||||
|
local ok, pipe = pcall(io.popen, command, "r")
|
||||||
|
if not ok or not pipe then return nil end
|
||||||
|
local readOK, value = pcall(pipe.read, pipe, "*a")
|
||||||
|
pcall(pipe.close, pipe)
|
||||||
|
if not readOK then return nil end
|
||||||
|
return clean(value)
|
||||||
|
end
|
||||||
|
|
||||||
local function percentEncode(value)
|
local function percentEncode(value)
|
||||||
local text = tostring(value or "")
|
local text = tostring(value or "")
|
||||||
return (text:gsub("([^%w%-_%.~])", function(char)
|
return (text:gsub("([^%w%-_%.~])", function(char)
|
||||||
@@ -66,6 +119,25 @@ local function loveVersion()
|
|||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function friendlyModel(identifier)
|
||||||
|
identifier = clean(identifier)
|
||||||
|
if not identifier then return nil end
|
||||||
|
return APPLE_MODELS[identifier] or identifier
|
||||||
|
end
|
||||||
|
|
||||||
|
local function macModel()
|
||||||
|
local details = commandText("system_profiler SPHardwareDataType 2>/dev/null")
|
||||||
|
if details then
|
||||||
|
local name = clean(details:match("Model Name:%s*([^\r\n]+)"))
|
||||||
|
local chip = clean(details:match("Chip:%s*([^\r\n]+)"))
|
||||||
|
if name and chip and not name:find(chip, 1, true) then
|
||||||
|
return name .. " (" .. chip .. ")"
|
||||||
|
end
|
||||||
|
if name then return name end
|
||||||
|
end
|
||||||
|
return friendlyModel(commandValue("sysctl -n hw.model 2>/dev/null"))
|
||||||
|
end
|
||||||
|
|
||||||
local function appVersion()
|
local function appVersion()
|
||||||
local version = clean(Version.engine)
|
local version = clean(Version.engine)
|
||||||
if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end
|
if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end
|
||||||
@@ -73,20 +145,25 @@ local function appVersion()
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function deviceModel(rawOS, system)
|
local function deviceModel(rawOS, system)
|
||||||
local model = clean(call(system.getModel))
|
local nativeModel = clean(call(system.getDeviceModel))
|
||||||
if model then return model end
|
if nativeModel then return friendlyModel(nativeModel) end
|
||||||
if rawOS == "OS X" or rawOS == "macOS" then
|
if rawOS == "OS X" or rawOS == "macOS" then
|
||||||
return commandValue("sysctl -n hw.model 2>/dev/null")
|
return macModel()
|
||||||
|
end
|
||||||
|
local model = clean(call(system.getModel))
|
||||||
|
if model and not model:lower():find("gpu", 1, true)
|
||||||
|
and not model:lower():find("renderer", 1, true) then
|
||||||
|
return friendlyModel(model)
|
||||||
end
|
end
|
||||||
if rawOS == "Windows" then
|
if rawOS == "Windows" then
|
||||||
return commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL")
|
return friendlyModel(commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL"))
|
||||||
end
|
end
|
||||||
if rawOS == "Linux" then
|
if rawOS == "Linux" then
|
||||||
return commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null")
|
return friendlyModel(commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null")
|
||||||
or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null")
|
or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null"))
|
||||||
end
|
end
|
||||||
if rawOS == "Android" then
|
if rawOS == "Android" then
|
||||||
return commandValue("getprop ro.product.model 2>/dev/null")
|
return friendlyModel(commandValue("getprop ro.product.model 2>/dev/null"))
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
@@ -121,7 +198,7 @@ local function metadata(options, context)
|
|||||||
local window = love and love.window or {}
|
local window = love and love.window or {}
|
||||||
local rawOS = clean(call(system.getOS))
|
local rawOS = clean(call(system.getOS))
|
||||||
local model = deviceModel(rawOS, system)
|
local model = deviceModel(rawOS, system)
|
||||||
local renderer, rendererVersion, _, rendererDevice = call(graphics.getRendererInfo)
|
local renderer, rendererVersion = call(graphics.getRendererInfo)
|
||||||
local width, height = call(graphics.getDimensions)
|
local width, height = call(graphics.getDimensions)
|
||||||
local pixelWidth, pixelHeight = call(graphics.getPixelDimensions)
|
local pixelWidth, pixelHeight = call(graphics.getPixelDimensions)
|
||||||
local modeWidth, modeHeight, flags = call(window.getMode)
|
local modeWidth, modeHeight, flags = call(window.getMode)
|
||||||
@@ -134,11 +211,7 @@ local function metadata(options, context)
|
|||||||
if value then lines[#lines + 1] = "- " .. label .. ": " .. value end
|
if value then lines[#lines + 1] = "- " .. label .. ": " .. value end
|
||||||
end
|
end
|
||||||
add("Platform", formOS(rawOS))
|
add("Platform", formOS(rawOS))
|
||||||
local hardware = model
|
add("Device", model)
|
||||||
if rendererDevice and rendererDevice ~= model then
|
|
||||||
hardware = hardware and (hardware .. " (" .. rendererDevice .. ")") or rendererDevice
|
|
||||||
end
|
|
||||||
add("Device", hardware)
|
|
||||||
local rendererDetails = clean(renderer)
|
local rendererDetails = clean(renderer)
|
||||||
if rendererDetails and clean(rendererVersion) then
|
if rendererDetails and clean(rendererVersion) then
|
||||||
rendererDetails = rendererDetails .. " " .. clean(rendererVersion)
|
rendererDetails = rendererDetails .. " " .. clean(rendererVersion)
|
||||||
|
|||||||
@@ -541,29 +541,6 @@ local function modRows(opts, mod)
|
|||||||
return rows
|
return rows
|
||||||
end
|
end
|
||||||
|
|
||||||
local function troubleshootingRows(opts, hooks)
|
|
||||||
return {
|
|
||||||
{
|
|
||||||
label = Strings("SAFE MODE"),
|
|
||||||
actionLabel = function()
|
|
||||||
return SaveData.isSafeMode(opts) and Strings("Turn off") or Strings("Turn on")
|
|
||||||
end,
|
|
||||||
action = function()
|
|
||||||
SaveData.setSafeMode(opts, not SaveData.isSafeMode(opts))
|
|
||||||
return true
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label = Strings("REPORT ISSUE"),
|
|
||||||
actionLabel = Strings("Report bug"),
|
|
||||||
action = function()
|
|
||||||
if hooks and hooks.reportIssue then hooks.reportIssue(opts) end
|
|
||||||
return false
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ------- Gen 2 (Gold)
|
-- ------- Gen 2 (Gold)
|
||||||
--
|
--
|
||||||
-- Gold reads NONE of the rows above. Its OPTION screen writes a different
|
-- Gold reads NONE of the rows above. Its OPTION screen writes a different
|
||||||
@@ -744,10 +721,6 @@ function LauncherSettings.open(hooks, version)
|
|||||||
sections[#sections + 1] = { title = mod.name, rows = rows }
|
sections[#sections + 1] = { title = mod.name, rows = rows }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
sections[#sections + 1] = {
|
|
||||||
title = Strings("TROUBLESHOOTING"),
|
|
||||||
rows = troubleshootingRows(opts, hooks),
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
opts = opts,
|
opts = opts,
|
||||||
version = version,
|
version = version,
|
||||||
|
|||||||
@@ -406,6 +406,15 @@ local function cartQuad(project, x, y, w, h, z)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function cartFacing(points)
|
||||||
|
local area = 0
|
||||||
|
for i = 1, #points do
|
||||||
|
local a, b = points[i], points[i % #points + 1]
|
||||||
|
area = area + a[1] * b[2] - b[1] * a[2]
|
||||||
|
end
|
||||||
|
return area > 0
|
||||||
|
end
|
||||||
|
|
||||||
local function cartPill(project, x, y, w, h, z, color, alpha)
|
local function cartPill(project, x, y, w, h, z, color, alpha)
|
||||||
local points, radius = {}, h / 2
|
local points, radius = {}, h / 2
|
||||||
for i = 0, 10 do
|
for i = 0, 10 do
|
||||||
@@ -628,17 +637,29 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
|||||||
local side = { math.floor(shell[1] * 0.54), math.floor(shell[2] * 0.54),
|
local side = { math.floor(shell[1] * 0.54), math.floor(shell[2] * 0.54),
|
||||||
math.floor(shell[3] * 0.54) }
|
math.floor(shell[3] * 0.54) }
|
||||||
|
|
||||||
|
local frontFacing = cartFacing(mainFront)
|
||||||
|
if frontFacing then
|
||||||
cartPolygon(mainBack, side, 1)
|
cartPolygon(mainBack, side, 1)
|
||||||
cartPolygon(capBack, side, 1)
|
cartPolygon(capBack, side, 1)
|
||||||
|
else
|
||||||
|
cartPolygon(mainFront, shell, 1)
|
||||||
|
cartPolygon(capFront, shell, 1)
|
||||||
|
end
|
||||||
cartPolygon({ mainFront[2], mainFront[3], mainBack[3], mainBack[2] }, side, 1)
|
cartPolygon({ mainFront[2], mainFront[3], mainBack[3], mainBack[2] }, side, 1)
|
||||||
cartPolygon({ mainFront[3], mainFront[4], mainBack[4], mainBack[3] }, side, 1)
|
cartPolygon({ mainFront[3], mainFront[4], mainBack[4], mainBack[3] }, side, 1)
|
||||||
cartPolygon({ mainFront[1], mainFront[2], mainBack[2], mainBack[1] }, side, 1)
|
cartPolygon({ mainFront[1], mainFront[2], mainBack[2], mainBack[1] }, side, 1)
|
||||||
cartPolygon({ capFront[2], capFront[3], capBack[3], capBack[2] }, side, 1)
|
cartPolygon({ capFront[2], capFront[3], capBack[3], capBack[2] }, side, 1)
|
||||||
cartPolygon({ capFront[1], capFront[2], capBack[2], capBack[1] }, side, 1)
|
cartPolygon({ capFront[1], capFront[2], capBack[2], capBack[1] }, side, 1)
|
||||||
cartPolygon({ capFront[4], capFront[1], capBack[1], capBack[4] }, side, 1)
|
cartPolygon({ capFront[4], capFront[1], capBack[1], capBack[4] }, side, 1)
|
||||||
|
if frontFacing then
|
||||||
cartPolygon(mainFront, shell, 1)
|
cartPolygon(mainFront, shell, 1)
|
||||||
cartPolygon(capFront, shell, 1)
|
cartPolygon(capFront, shell, 1)
|
||||||
|
else
|
||||||
|
cartPolygon(mainBack, side, 1)
|
||||||
|
cartPolygon(capBack, side, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
if frontFacing then
|
||||||
local faceZ = depth + 0.8
|
local faceZ = depth + 0.8
|
||||||
for i = 0, 4 do
|
for i = 0, 4 do
|
||||||
local ry = mainTop + 7 + i * h * 0.025
|
local ry = mainTop + 7 + i * h * 0.025
|
||||||
@@ -673,6 +694,7 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
|||||||
{ project(w * 0.07, h * 0.37, faceZ + 1) },
|
{ project(w * 0.07, h * 0.37, faceZ + 1) },
|
||||||
{ project(0, h * 0.43, faceZ + 1) },
|
{ project(0, h * 0.43, faceZ + 1) },
|
||||||
}, side, 0.70)
|
}, side, 0.70)
|
||||||
|
end
|
||||||
love.graphics.pop()
|
love.graphics.pop()
|
||||||
|
|
||||||
if not state.active and (Kit._activateId == key) then
|
if not state.active and (Kit._activateId == key) then
|
||||||
@@ -926,10 +948,13 @@ local HEADER_TABS = {
|
|||||||
{ id = "mods", key = "tab-mods" },
|
{ id = "mods", key = "tab-mods" },
|
||||||
{ id = "find", key = "tab-find" },
|
{ id = "find", key = "tab-find" },
|
||||||
{ id = "skins", key = "tab-skins", glyph = true },
|
{ id = "skins", key = "tab-skins", glyph = true },
|
||||||
|
{ id = "bug", key = "tab-bug" },
|
||||||
}
|
}
|
||||||
for _, t in ipairs(HEADER_TABS) do
|
for _, t in ipairs(HEADER_TABS) do
|
||||||
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
|
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
|
||||||
if t.glyph then t.opts.drawFn = drawSkinGlyph end
|
if t.glyph then
|
||||||
|
t.opts.drawFn = drawSkinGlyph
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Which cartridge the dropdown is showing: the open game tab, else the last
|
-- Which cartridge the dropdown is showing: the open game tab, else the last
|
||||||
@@ -1067,6 +1092,8 @@ local function buildHeader(imp, m)
|
|||||||
or love.graphics.newImage("assets/launcher/mods.png")
|
or love.graphics.newImage("assets/launcher/mods.png")
|
||||||
imp._findIcon = imp._findIcon
|
imp._findIcon = imp._findIcon
|
||||||
or love.graphics.newImage("assets/launcher/find.png")
|
or love.graphics.newImage("assets/launcher/find.png")
|
||||||
|
imp._bugIcon = imp._bugIcon
|
||||||
|
or love.graphics.newImage("assets/launcher/bug.png")
|
||||||
-- Game tabs keep their cartridge colours -- that is the one piece of brand
|
-- Game tabs keep their cartridge colours -- that is the one piece of brand
|
||||||
-- identity in the launcher, and "the red one" is how people actually refer
|
-- identity in the launcher, and "the red one" is how people actually refer
|
||||||
-- to these. The colour rides the outline and the glyph at rest and becomes
|
-- to these. The colour rides the outline and the glyph at rest and becomes
|
||||||
@@ -1077,6 +1104,7 @@ local function buildHeader(imp, m)
|
|||||||
for _, t in ipairs(tabs) do
|
for _, t in ipairs(tabs) do
|
||||||
if t.id == "mods" then t.icon = imp._modsIcon end
|
if t.id == "mods" then t.icon = imp._modsIcon end
|
||||||
if t.id == "find" then t.icon = imp._findIcon end
|
if t.id == "find" then t.icon = imp._findIcon end
|
||||||
|
if t.id == "bug" then t.icon = imp._bugIcon end
|
||||||
end
|
end
|
||||||
local tabH = m.chip
|
local tabH = m.chip
|
||||||
local tx = m.x + m.pad
|
local tx = m.x + m.pad
|
||||||
@@ -1923,7 +1951,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
|||||||
-- notice line
|
-- notice line
|
||||||
local noticeText, noticeCol
|
local noticeText, noticeCol
|
||||||
if safeMode then
|
if safeMode then
|
||||||
noticeText, noticeCol = "Safe mode is on. All mods are disabled. Turn it off in Settings to change mod toggles.", PAL.yellow
|
noticeText, noticeCol = "Safe mode is on. All mods are disabled. Turn it off in the Bug tab to change mod toggles.", PAL.yellow
|
||||||
elseif imp.modNotice then
|
elseif imp.modNotice then
|
||||||
noticeText = imp.modNotice.text
|
noticeText = imp.modNotice.text
|
||||||
noticeCol = imp.modNotice.ok and PAL.green or PAL.red
|
noticeCol = imp.modNotice.ok and PAL.green or PAL.red
|
||||||
@@ -2322,6 +2350,69 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
|
|||||||
return cy + hintH - y
|
return cy + hintH - y
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function buildBugPanel(imp, x, y, w, availH, m)
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local gap = m.gap
|
||||||
|
local pad = math.floor(16 * m.s)
|
||||||
|
local cy = y
|
||||||
|
local safeMode = imp:_safeModeEnabled()
|
||||||
|
|
||||||
|
Kit.text("button", Strings("Troubleshooting"), x, cy, PAL.heading)
|
||||||
|
cy = cy + Kit.textHeight("button") + gap
|
||||||
|
|
||||||
|
if imp.issueNotice then
|
||||||
|
cy = cy + Kit.textWrapped("small", imp.issueNotice.text, x, cy, w,
|
||||||
|
imp.issueNotice.ok and PAL.green or PAL.red, 2) + gap
|
||||||
|
end
|
||||||
|
|
||||||
|
local switchW = math.floor(92 * m.s)
|
||||||
|
local switchH = math.max(m.btnH, Kit.tapMin())
|
||||||
|
local detail = safeMode
|
||||||
|
and Strings("All mods are disabled and their toggles are locked until safe mode is turned off.")
|
||||||
|
or Strings("Temporarily disable every mod while you reproduce a bug.")
|
||||||
|
local textW = math.max(0, w - 2 * pad - switchW - gap)
|
||||||
|
local detailH = Kit.wrapHeight("small", detail, textW, 3)
|
||||||
|
local safeH = math.max(switchH, Kit.textHeight("small") + math.floor(4 * m.s) + detailH)
|
||||||
|
+ 2 * pad
|
||||||
|
Kit.card(x, cy, w, safeH)
|
||||||
|
local textX = x + pad
|
||||||
|
local textY = cy + pad
|
||||||
|
Kit.text("small", Strings("Safe mode"), textX, textY, PAL.heading)
|
||||||
|
Kit.textWrapped("small", detail, textX,
|
||||||
|
textY + Kit.textHeight("small") + math.floor(4 * m.s), textW,
|
||||||
|
PAL.muted, 3)
|
||||||
|
local toggleX = x + w - pad - switchW
|
||||||
|
local toggleY = cy + math.floor((safeH - switchH) / 2)
|
||||||
|
local _, changed = Kit.toggle(toggleX, toggleY, switchW, switchH, safeMode,
|
||||||
|
"bug-safe-mode")
|
||||||
|
if changed then
|
||||||
|
queueAction(imp, "bug-safe-mode", function() imp:_toggleSafeMode() end)
|
||||||
|
end
|
||||||
|
cy = cy + safeH + gap
|
||||||
|
|
||||||
|
local reportLabel = Strings("Report a bug")
|
||||||
|
local reportW = math.min(w - 2 * pad,
|
||||||
|
Kit.textWidth("small", reportLabel) + math.floor(32 * m.s))
|
||||||
|
local reportDetail = Strings("Fill out the GitHub form with the available system information.")
|
||||||
|
local reportTextW = math.max(0, w - 2 * pad - reportW - gap)
|
||||||
|
local reportDetailH = Kit.wrapHeight("small", reportDetail, reportTextW, 3)
|
||||||
|
local reportH = math.max(m.btnH, Kit.textHeight("small") + math.floor(4 * m.s) + reportDetailH)
|
||||||
|
+ 2 * pad
|
||||||
|
Kit.card(x, cy, w, reportH)
|
||||||
|
Kit.text("small", Strings("Something not working?"), textX, cy + pad, PAL.heading)
|
||||||
|
Kit.textWrapped("small", reportDetail, textX,
|
||||||
|
cy + pad + Kit.textHeight("small") + math.floor(4 * m.s), reportTextW,
|
||||||
|
PAL.muted, 3)
|
||||||
|
btn(imp, x + w - pad - reportW,
|
||||||
|
cy + math.floor((reportH - m.btnH) / 2), reportW, m.btnH,
|
||||||
|
"bug-report", reportLabel, {
|
||||||
|
kind = "accent", font = "small",
|
||||||
|
action = function()
|
||||||
|
imp:_ensureMods()
|
||||||
|
imp:_reportIssue(SaveData.loadOptions(), nil)
|
||||||
|
end })
|
||||||
|
end
|
||||||
|
|
||||||
local function buildFindPanel(imp, x, y, w, availH, m)
|
local function buildFindPanel(imp, x, y, w, availH, m)
|
||||||
imp:_ensureFind()
|
imp:_ensureFind()
|
||||||
imp:_ensureMods()
|
imp:_ensureMods()
|
||||||
@@ -4822,6 +4913,8 @@ function LauncherView.draw(imp)
|
|||||||
contentH = buildFindPanel(imp, x, py, panelW, budgetH, m)
|
contentH = buildFindPanel(imp, x, py, panelW, budgetH, m)
|
||||||
elseif imp.tab == "skins" then
|
elseif imp.tab == "skins" then
|
||||||
contentH = buildSkinsPanel(imp, x, py, panelW, budgetH, m)
|
contentH = buildSkinsPanel(imp, x, py, panelW, budgetH, m)
|
||||||
|
elseif imp.tab == "bug" then
|
||||||
|
contentH = buildBugPanel(imp, x, py, panelW, budgetH, m)
|
||||||
else
|
else
|
||||||
contentH = buildGamePanel(imp, x, py, panelW, availH, m, imp.tab, budgetH)
|
contentH = buildGamePanel(imp, x, py, panelW, availH, m, imp.tab, budgetH)
|
||||||
end
|
end
|
||||||
|
|||||||
+32
-20
@@ -1266,6 +1266,7 @@ end
|
|||||||
function RomImporter:_applyLastVersionTab()
|
function RomImporter:_applyLastVersionTab()
|
||||||
local okLO, LO = pcall(require, "src.core.LaunchOptions")
|
local okLO, LO = pcall(require, "src.core.LaunchOptions")
|
||||||
if okLO and LO.pendingTab then return end
|
if okLO and LO.pendingTab then return end
|
||||||
|
if os.getenv("POKEPORT_LAUNCHER_TAB") then return end
|
||||||
local okOpt, opts = pcall(function()
|
local okOpt, opts = pcall(function()
|
||||||
return require("src.core.SaveData").loadOptions()
|
return require("src.core.SaveData").loadOptions()
|
||||||
end)
|
end)
|
||||||
@@ -1337,7 +1338,7 @@ function RomImporter.new(onComplete, opts)
|
|||||||
-- player at least arrives on the tab they asked for (src/core/LaunchOptions).
|
-- player at least arrives on the tab they asked for (src/core/LaunchOptions).
|
||||||
tab = (function()
|
tab = (function()
|
||||||
local okLO, LO = pcall(require, "src.core.LaunchOptions")
|
local okLO, LO = pcall(require, "src.core.LaunchOptions")
|
||||||
return (okLO and LO.pendingTab) or "red"
|
return (okLO and LO.pendingTab) or os.getenv("POKEPORT_LAUNCHER_TAB") or "red"
|
||||||
end)(),
|
end)(),
|
||||||
logo = love.graphics.newImage("assets/logo/logo.png"),
|
logo = love.graphics.newImage("assets/logo/logo.png"),
|
||||||
bcg = love.graphics.newImage("assets/logo/bcg.png"),
|
bcg = love.graphics.newImage("assets/logo/bcg.png"),
|
||||||
@@ -1364,7 +1365,8 @@ function RomImporter.new(onComplete, opts)
|
|||||||
-- in draw); modNotice is the last install/delete result { ok, text }.
|
-- in draw); modNotice is the last install/delete result { ok, text }.
|
||||||
-- requiredImportNotice stays inside the imported-files modal so validation
|
-- requiredImportNotice stays inside the imported-files modal so validation
|
||||||
-- failures are visible beside the file picker that caused them.
|
-- failures are visible beside the file picker that caused them.
|
||||||
mods = nil, modScroll = 0, modNotice = nil, requiredImportNotice = nil,
|
mods = nil, modScroll = 0, modNotice = nil, issueNotice = nil,
|
||||||
|
requiredImportNotice = nil,
|
||||||
-- Which game the MODS panel is answering for (a GameVersion id, nil =
|
-- Which game the MODS panel is answering for (a GameVersion id, nil =
|
||||||
-- every game). Rows resolve their enable-state and their "runs here"
|
-- every game). Rows resolve their enable-state and their "runs here"
|
||||||
-- verdict against it (src/mods/ModTargets.lua).
|
-- verdict against it (src/mods/ModTargets.lua).
|
||||||
@@ -2755,7 +2757,7 @@ function RomImporter:resumeAfterOverlay()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:_cycleTab(delta)
|
function RomImporter:_cycleTab(delta)
|
||||||
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins" }
|
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins", "bug" }
|
||||||
local idx = 1
|
local idx = 1
|
||||||
for i, id in ipairs(order) do
|
for i, id in ipairs(order) do
|
||||||
if id == self.tab then idx = i; break end
|
if id == self.tab then idx = i; break end
|
||||||
@@ -3630,9 +3632,6 @@ function RomImporter:_openSettings()
|
|||||||
-- option block, and Gold's is not the flat Gen 1 one (#1100).
|
-- option block, and Gold's is not the flat Gen 1 one (#1100).
|
||||||
local hooks = {}
|
local hooks = {}
|
||||||
local version = self.tab
|
local version = self.tab
|
||||||
hooks.reportIssue = function(opts)
|
|
||||||
return self:_reportIssue(opts, version)
|
|
||||||
end
|
|
||||||
if self.onEditTouchControls then
|
if self.onEditTouchControls then
|
||||||
local version = self.tab
|
local version = self.tab
|
||||||
hooks.editTouchControls = function()
|
hooks.editTouchControls = function()
|
||||||
@@ -3655,7 +3654,6 @@ function RomImporter:_openSettings()
|
|||||||
end)
|
end)
|
||||||
if ok and model then
|
if ok and model then
|
||||||
self._settings = model
|
self._settings = model
|
||||||
self._settingsSafeModeAtOpen = require("src.core.SaveData").isSafeMode(model.opts)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -3670,22 +3668,36 @@ function RomImporter:_closeSettings()
|
|||||||
local model = self._settings
|
local model = self._settings
|
||||||
if model then
|
if model then
|
||||||
model.save()
|
model.save()
|
||||||
local safeMode = require("src.core.SaveData").isSafeMode(model.opts)
|
|
||||||
if safeMode ~= self._settingsSafeModeAtOpen then
|
|
||||||
self.mods = nil
|
|
||||||
self.safeMode = safeMode
|
|
||||||
self._modSortCache = nil
|
|
||||||
self._modInfoFetch = nil
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
self._settings = nil
|
self._settings = nil
|
||||||
self._settingsSafeModeAtOpen = nil
|
end
|
||||||
|
|
||||||
|
function RomImporter:_safeModeEnabled()
|
||||||
|
if self.safeMode == nil then
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
|
||||||
|
end
|
||||||
|
return self.safeMode == true
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_toggleSafeMode()
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local options = SaveData.loadOptions()
|
||||||
|
local enabled = not SaveData.isSafeMode(options)
|
||||||
|
SaveData.setSafeMode(options, enabled)
|
||||||
|
SaveData.saveOptions(options)
|
||||||
|
self.safeMode = enabled
|
||||||
|
self.mods = nil
|
||||||
|
self._modSortCache = nil
|
||||||
|
self._modInfoFetch = nil
|
||||||
|
self.modNotice = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:_reportIssue(options, version)
|
function RomImporter:_reportIssue(options, version)
|
||||||
|
self.issueNotice = nil
|
||||||
local ok, IssueReport = pcall(require, "src.core.IssueReport")
|
local ok, IssueReport = pcall(require, "src.core.IssueReport")
|
||||||
if not ok then
|
if not ok then
|
||||||
self.modNotice = { ok = false, text = "Could not prepare the issue report." }
|
self.issueNotice = { ok = false, text = "Could not prepare the issue report." }
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
local opened, url, reason = IssueReport.open(options, {
|
local opened, url, reason = IssueReport.open(options, {
|
||||||
@@ -3693,11 +3705,11 @@ function RomImporter:_reportIssue(options, version)
|
|||||||
mods = self.mods,
|
mods = self.mods,
|
||||||
})
|
})
|
||||||
if not opened then
|
if not opened then
|
||||||
self.modNotice = { ok = false, text = reason or "Could not open the issue report." }
|
self.issueNotice = { ok = false, text = reason or "Could not open the issue report." }
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
self._lastIssueReportURL = url
|
self._lastIssueReportURL = url
|
||||||
if reason then self.modNotice = { ok = true, text = reason } end
|
if reason then self.issueNotice = { ok = true, text = reason } end
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -4180,7 +4192,7 @@ end
|
|||||||
-- Enabling an experimental mod arms a confirmation for that same game.
|
-- Enabling an experimental mod arms a confirmation for that same game.
|
||||||
function RomImporter:_toggleMod(id, confirmed, version)
|
function RomImporter:_toggleMod(id, confirmed, version)
|
||||||
if self.safeMode then
|
if self.safeMode then
|
||||||
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." }
|
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." }
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local LauncherMods = require("src.mods.LauncherMods")
|
local LauncherMods = require("src.mods.LauncherMods")
|
||||||
@@ -4224,7 +4236,7 @@ end
|
|||||||
-- recovery action, and Delete is the only destructive one on this panel.
|
-- recovery action, and Delete is the only destructive one on this panel.
|
||||||
function RomImporter:_setAllMods(want, confirmed)
|
function RomImporter:_setAllMods(want, confirmed)
|
||||||
if self.safeMode then
|
if self.safeMode then
|
||||||
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." }
|
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." }
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local LauncherMods = require("src.mods.LauncherMods")
|
local LauncherMods = require("src.mods.LauncherMods")
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ function TextBox.new(game, text, onDone, opts)
|
|||||||
self.money = opts and opts.money
|
self.money = opts and opts.money
|
||||||
self.auto = opts and opts.auto
|
self.auto = opts and opts.auto
|
||||||
self.stay = opts and opts.stay
|
self.stay = opts and opts.stay
|
||||||
|
-- engine/events/hidden_events/cinnabar_gym_quiz.asm:119
|
||||||
|
self.preSound = opts and opts.preSound
|
||||||
-- opts.instant: put the LAST page up already typed, with no typewriter and
|
-- opts.instant: put the LAST page up already typed, with no typewriter and
|
||||||
-- no page waits. A `yesorno` follows a `writetext` that has already been
|
-- no page waits. A `yesorno` follows a `writetext` that has already been
|
||||||
-- read, so re-typing the line under the YES/NO box would be wrong -- the
|
-- read, so re-typing the line under the YES/NO box would be wrong -- the
|
||||||
@@ -270,6 +272,17 @@ end
|
|||||||
function TextBox:update(dt)
|
function TextBox:update(dt)
|
||||||
local input = self.game.input
|
local input = self.game.input
|
||||||
self.blink = (self.blink + 1) % 60
|
self.blink = (self.blink + 1) % 60
|
||||||
|
-- home/text.asm:506
|
||||||
|
if self.preSound then
|
||||||
|
if not self.preStarted then
|
||||||
|
self.preStarted = true
|
||||||
|
self.preSrc = self.preSound()
|
||||||
|
end
|
||||||
|
if self.preSrc and self.preSrc.isPlaying and self.preSrc:isPlaying() then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self.preSound, self.preSrc = nil, nil
|
||||||
|
end
|
||||||
-- A page or CONT advance blocks the whole box while the original's scroll
|
-- A page or CONT advance blocks the whole box while the original's scroll
|
||||||
-- and clear run (src/core/Timing.lua TEXT_SCROLL_PAIR / TEXT_PAGE_CLEAR).
|
-- and clear run (src/core/Timing.lua TEXT_SCROLL_PAIR / TEXT_PAGE_CLEAR).
|
||||||
-- Nothing types and no input is read until it drains.
|
-- Nothing types and no input is read until it drains.
|
||||||
|
|||||||
@@ -254,7 +254,8 @@ function Commands.give_item(ctx, itemId, count, gotText)
|
|||||||
Commands.show_text(ctx, gotText
|
Commands.show_text(ctx, gotText
|
||||||
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
|
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
|
||||||
else
|
else
|
||||||
Sound.play(ctx.game.data, jingle)
|
-- scripts/OaksLab.asm:1058
|
||||||
|
Commands.text_sound(ctx, jingle)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -537,6 +537,15 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
|||||||
-- Route22Gate_Script rewrites wLastMap from the player's Y on entry
|
-- Route22Gate_Script rewrites wLastMap from the player's Y on entry
|
||||||
-- too (not only on step), so a save/load mid-gate keeps exits correct
|
-- too (not only on step), so a save/load mid-gate keeps exits correct
|
||||||
if not (opts and opts.checkpoint) then self:syncLastMapRewrite() end
|
if not (opts and opts.checkpoint) then self:syncLastMapRewrite() end
|
||||||
|
-- home/overworld.asm:1821 (JoypadOverworld runs RunMapScript every frame);
|
||||||
|
-- scripts/Route16Gate1F.asm:16, Route5Gate.asm:19, Route22Gate.asm:21
|
||||||
|
if opts and opts.freshBoot and not opts.checkpoint then
|
||||||
|
local standing = mapScripts and mapScripts.get(mapId)
|
||||||
|
if not (standing and standing.onStep
|
||||||
|
and standing.onStep(Game, self, self.player.cellX, self.player.cellY)) then
|
||||||
|
self:checkBadgeGate()
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Neighbor maps drawn at the composed connection offsets: at least the
|
-- Neighbor maps drawn at the composed connection offsets: at least the
|
||||||
@@ -2910,7 +2919,15 @@ function OverworldState:openPC(onDone)
|
|||||||
keepOpen = true,
|
keepOpen = true,
|
||||||
onSelect = function()
|
onSelect = function()
|
||||||
require("src.core.Sound").play(Game.data, "Enter_PC")
|
require("src.core.Sound").play(Game.data, "Enter_PC")
|
||||||
|
-- engine/menus/pc.asm:73 BillsPC prints the access text before the farcall
|
||||||
|
local accessed = metBill
|
||||||
|
and romText(Game.data, "_AccessedBillsPCText",
|
||||||
|
"Accessed BILL's\nPC.\fAccessed POKéMON\nStorage System.")
|
||||||
|
or romText(Game.data, "_AccessedSomeonesPCText",
|
||||||
|
"Accessed someone's\nPC.\fAccessed POKéMON\nStorage System.")
|
||||||
|
Game.stack:push(TextBox.new(Game, accessed, function()
|
||||||
Screens.push(Game, "BoxMenu")
|
Screens.push(Game, "BoxMenu")
|
||||||
|
end))
|
||||||
done()
|
done()
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
@@ -2920,9 +2937,13 @@ function OverworldState:openPC(onDone)
|
|||||||
label = (Game.save.player.name or "RED") .. "'s PC",
|
label = (Game.save.player.name or "RED") .. "'s PC",
|
||||||
keepOpen = true,
|
keepOpen = true,
|
||||||
onSelect = function()
|
onSelect = function()
|
||||||
-- pc.asm .playersPC plays SFX_ENTER_PC before the farcall (#960)
|
-- pc.asm .playersPC plays SFX_ENTER_PC then prints AccessedMyPCText
|
||||||
|
-- before the farcall (engine/menus/pc.asm:54, #960)
|
||||||
require("src.core.Sound").play(Game.data, "Enter_PC")
|
require("src.core.Sound").play(Game.data, "Enter_PC")
|
||||||
Screens.push(Game, "PlayerPC")
|
Game.stack:push(TextBox.new(Game,
|
||||||
|
romText(Game.data, "_AccessedMyPCText",
|
||||||
|
"Accessed my PC.\fAccessed Item\nStorage System."),
|
||||||
|
function() Screens.push(Game, "PlayerPC") end))
|
||||||
done()
|
done()
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ local PLAYER_STATE_BY_ID = {
|
|||||||
[8] = FieldMoves.PLAYER_SURF_PIKA,
|
[8] = FieldMoves.PLAYER_SURF_PIKA,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- engine/overworld/variables.asm:49 VAR_MOVEMENT reads wPlayerState back
|
||||||
|
local PLAYER_STATE_ID = {}
|
||||||
|
for id, state in pairs(PLAYER_STATE_BY_ID) do PLAYER_STATE_ID[state] = id end
|
||||||
|
|
||||||
local BATTLETYPE = {
|
local BATTLETYPE = {
|
||||||
CANLOSE = 1,
|
CANLOSE = 1,
|
||||||
FORCESHINY = 7,
|
FORCESHINY = 7,
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
-- The bike shop's "A shiny new BICYCLE!" is six hidden_event rows, not a
|
||||||
|
-- bg_event: data/events/hidden_events.asm:542-549 points every one of them
|
||||||
|
-- at PrintNewBikeText (engine/events/hidden_events/new_bike.asm:1), which
|
||||||
|
-- tx_pre_jumps NewBicycleText with ANY_FACING and no gating. The port's
|
||||||
|
-- field extractor lifts none of that family, so BIKE_SHOP had no display
|
||||||
|
-- text at all (#1530).
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
|
||||||
|
local pushed
|
||||||
|
package.loaded["src.render.TextBox"] = {
|
||||||
|
new = function(_, text, onDone) return { text = text, onDone = onDone } end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local scripts = require("data.scripts.flavor.bike_shop")
|
||||||
|
local onInteract = scripts.BIKE_SHOP.onInteract
|
||||||
|
T.check(type(onInteract) == "function", "BIKE_SHOP carries an onInteract hook")
|
||||||
|
|
||||||
|
local game = {
|
||||||
|
data = { text = { _NewBicycleText = "A shiny new\nBICYCLE!" } },
|
||||||
|
stack = { push = function(_, box) pushed = box end },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- data/events/hidden_events.asm:543-548 (the macro emits y then x, so the
|
||||||
|
-- source pairs read x, y)
|
||||||
|
for _, cell in ipairs({ { 1, 0 }, { 2, 1 }, { 1, 2 }, { 3, 2 }, { 0, 4 }, { 1, 5 } }) do
|
||||||
|
pushed = nil
|
||||||
|
local consumed = onInteract(game, {}, cell[1], cell[2])
|
||||||
|
T.eq(consumed, true, ("(%d,%d) is a display tile"):format(cell[1], cell[2]))
|
||||||
|
T.check(pushed ~= nil and pushed.text == game.data.text._NewBicycleText,
|
||||||
|
("(%d,%d) prints _NewBicycleText"):format(cell[1], cell[2]))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- data/generated/maps.lua BIKE_SHOP object_events sit at (6,2), (5,6) and
|
||||||
|
-- (1,3): none of the six, so the hook never steals an NPC's talk
|
||||||
|
for _, cell in ipairs({ { 4, 4 }, { 6, 2 }, { 5, 6 }, { 1, 3 }, { 0, 0 } }) do
|
||||||
|
pushed = nil
|
||||||
|
local consumed = onInteract(game, {}, cell[1], cell[2])
|
||||||
|
T.eq(consumed, false, ("(%d,%d) is not a display tile"):format(cell[1], cell[2]))
|
||||||
|
T.eq(pushed, nil, "and pushes nothing")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- with no cache text the hook still prints the line
|
||||||
|
pushed = nil
|
||||||
|
onInteract({ data = {}, stack = game.stack }, {}, 1, 0)
|
||||||
|
T.check(pushed ~= nil and pushed.text:find("BICYCLE", 1, true) ~= nil,
|
||||||
|
"a dataset without the label falls back to the engine wording")
|
||||||
|
|
||||||
|
T.finish("bike shop display text (#1530)")
|
||||||
@@ -146,12 +146,34 @@ T.eq(resumed, 1, "the script resumed once")
|
|||||||
|
|
||||||
-- ------------------------------------------------------------- plain gift
|
-- ------------------------------------------------------------- plain gift
|
||||||
-- gotText == false is the script-shows-its-own-text form (Oak's 5 POKE
|
-- gotText == false is the script-shows-its-own-text form (Oak's 5 POKE
|
||||||
-- BALLs): nothing to hang the sound on, so it plays on the spot
|
-- BALLs): the received text the script prints next carries the jingle
|
||||||
|
-- (scripts/OaksLab.asm:1058-1062), so give_item arms it and plays nothing
|
||||||
plays = {}
|
plays = {}
|
||||||
Commands.give_item(ctx, "FIX_POTION", 1, false)
|
Commands.give_item(ctx, "FIX_POTION", 1, false)
|
||||||
T.eq(jingles(), 1, "the no-text gift still plays its jingle immediately")
|
T.eq(jingles(), 0, "the no-text gift plays nothing on the spot")
|
||||||
T.eq(plays[1], "item.wav", "with the plain-item sound")
|
|
||||||
T.eq(stack:top(), nil, "and pushes no box of its own")
|
T.eq(stack:top(), nil, "and pushes no box of its own")
|
||||||
|
T.check(ctx.textOpts and ctx.textOpts.auto and ctx.textOpts.auto.sound ~= nil,
|
||||||
|
"the jingle is armed for the next show_text")
|
||||||
|
|
||||||
|
Commands.show_text(ctx, "{PLAYER} GOT\nSOMETHING!")
|
||||||
|
local box2 = stack:top()
|
||||||
|
T.check(getmetatable(box2) == TextBox, "the script's own received box is up")
|
||||||
|
T.eq(ctx.textOpts, nil, "show_text consumed the armed jingle")
|
||||||
|
for _ = 1, 2000 do
|
||||||
|
if box2.done then break end
|
||||||
|
step(box2.waiting and "a" or nil)
|
||||||
|
end
|
||||||
|
T.check(box2.done, "the received text typed out")
|
||||||
|
T.eq(jingles(), 0, "silent until the last character is placed")
|
||||||
|
step()
|
||||||
|
T.eq(jingles(), 1, "the jingle fires once the text is out")
|
||||||
|
T.eq(plays[#plays], "item.wav", "with the plain-item sound")
|
||||||
|
step("a")
|
||||||
|
T.eq(stack:top(), box2, "A does not close the box during the jingle")
|
||||||
|
sources["item.wav"].playing = false
|
||||||
|
step()
|
||||||
|
step("a")
|
||||||
|
T.eq(stack:top(), nil, "A closes the box after the jingle")
|
||||||
|
|
||||||
-- ------------------------------------------------------------ script data
|
-- ------------------------------------------------------------ script data
|
||||||
-- both Viridian Mart paths must hand give_item the quest text, since that
|
-- both Viridian Mart paths must hand give_item the quest text, since that
|
||||||
|
|||||||
@@ -29,4 +29,12 @@ check(patch:find("int w_pickFileKinds", 1, true)
|
|||||||
check(patch:find('("GRPickerBridge.swift", ID_FILE_PICKER', 1, true),
|
check(patch:find('("GRPickerBridge.swift", ID_FILE_PICKER', 1, true),
|
||||||
"iOS build patch compiles the required-import picker bridge")
|
"iOS build patch compiles the required-import picker bridge")
|
||||||
|
|
||||||
|
local bootstrap = read("mobile/ios/native/GRBootstrap.m")
|
||||||
|
check(bootstrap:find("struct utsname", 1, true)
|
||||||
|
and bootstrap:find("SIMULATOR_MODEL_IDENTIFIER", 1, true),
|
||||||
|
"iOS native bridge reads the hardware model on device and simulator")
|
||||||
|
check(patch:find("int w_getDeviceModel", 1, true)
|
||||||
|
and patch:find('{ "getDeviceModel", w_getDeviceModel }', 1, true),
|
||||||
|
"iOS liblove patch exposes the hardware model to Lua")
|
||||||
|
|
||||||
print("ios_required_import_picker_test: ok")
|
print("ios_required_import_picker_test: ok")
|
||||||
|
|||||||
@@ -50,20 +50,34 @@ local imp = read("src/import/RomImporter.lua")
|
|||||||
|
|
||||||
check(view:find('id = "skins"', 1, true) ~= nil,
|
check(view:find('id = "skins"', 1, true) ~= nil,
|
||||||
"LauncherView registers a skins tab")
|
"LauncherView registers a skins tab")
|
||||||
|
check(view:find('id = "bug"', 1, true) ~= nil,
|
||||||
|
"LauncherView registers a bug tab")
|
||||||
check(view:find("drawSkinGlyph", 1, true) ~= nil,
|
check(view:find("drawSkinGlyph", 1, true) ~= nil,
|
||||||
"the skins tab draws its own glyph rather than shipping an asset")
|
"the skins tab draws its own glyph rather than shipping an asset")
|
||||||
|
check(view:find('assets/launcher/bug.png', 1, true) ~= nil,
|
||||||
|
"the bug tab uses the standard bug report asset")
|
||||||
-- the tab has to be next to Find, which is what the request was
|
-- the tab has to be next to Find, which is what the request was
|
||||||
local order = view:match("local HEADER_TABS = %{(.-)%}\n")
|
local order = view:match("local HEADER_TABS = %{(.-)%}\n")
|
||||||
check(order ~= nil, "HEADER_TABS found")
|
check(order ~= nil, "HEADER_TABS found")
|
||||||
if order then
|
if order then
|
||||||
local findAt = order:find('id = "find"', 1, true)
|
local findAt = order:find('id = "find"', 1, true)
|
||||||
local skinsAt = order:find('id = "skins"', 1, true)
|
local skinsAt = order:find('id = "skins"', 1, true)
|
||||||
|
local bugAt = order:find('id = "bug"', 1, true)
|
||||||
check(findAt and skinsAt and skinsAt > findAt,
|
check(findAt and skinsAt and skinsAt > findAt,
|
||||||
"the skins tab sits immediately after Find")
|
"the skins tab sits immediately after Find")
|
||||||
|
check(skinsAt and bugAt and bugAt > skinsAt,
|
||||||
|
"the bug tab sits after Skins")
|
||||||
end
|
end
|
||||||
check(view:find('imp.tab == "skins"', 1, true) ~= nil,
|
check(view:find('imp.tab == "skins"', 1, true) ~= nil,
|
||||||
"the panel dispatch routes the skins tab")
|
"the panel dispatch routes the skins tab")
|
||||||
check(view:find("buildSkinsPanel", 1, true) ~= nil, "and a panel builds it")
|
check(view:find("buildSkinsPanel", 1, true) ~= nil, "and a panel builds it")
|
||||||
|
check(view:find('imp.tab == "bug"', 1, true) ~= nil,
|
||||||
|
"the panel dispatch routes the bug tab")
|
||||||
|
check(view:find("buildBugPanel", 1, true) ~= nil, "and the bug panel builds it")
|
||||||
|
check(view:find('Kit.toggle', 1, true) ~= nil,
|
||||||
|
"the bug panel uses a switch for safe mode")
|
||||||
|
check(view:find('bug-report', 1, true) ~= nil,
|
||||||
|
"the bug panel has a report action")
|
||||||
-- the panel must not offer the studio when the host did not supply it
|
-- the panel must not offer the studio when the host did not supply it
|
||||||
check(view:find("if imp.onOpenSkinStudio then", 1, true) ~= nil,
|
check(view:find("if imp.onOpenSkinStudio then", 1, true) ~= nil,
|
||||||
"the Studio button is hidden without a host hook (mobile)")
|
"the Studio button is hidden without a host hook (mobile)")
|
||||||
@@ -79,8 +93,15 @@ check(imp:find("_installMod", 1, true) ~= nil,
|
|||||||
local cycle = imp:match("local order = %{(.-)%}")
|
local cycle = imp:match("local order = %{(.-)%}")
|
||||||
check(cycle and cycle:find('"skins"', 1, true) ~= nil,
|
check(cycle and cycle:find('"skins"', 1, true) ~= nil,
|
||||||
"shoulder-button tab cycling reaches the skins tab")
|
"shoulder-button tab cycling reaches the skins tab")
|
||||||
|
check(cycle and cycle:find('"bug"', 1, true) ~= nil,
|
||||||
|
"shoulder-button tab cycling reaches the bug tab")
|
||||||
local switch = imp:match("function RomImporter:_switchTab%(id%)(.-)\nend")
|
local switch = imp:match("function RomImporter:_switchTab%(id%)(.-)\nend")
|
||||||
check(switch and switch:find("_ensureSkins(true)", 1, true) ~= nil,
|
check(switch and switch:find("_ensureSkins(true)", 1, true) ~= nil,
|
||||||
"switching to the tab re-reads the skin list")
|
"switching to the tab re-reads the skin list")
|
||||||
|
check(imp:find('function RomImporter:_safeModeEnabled', 1, true) ~= nil
|
||||||
|
and imp:find('function RomImporter:_toggleSafeMode', 1, true) ~= nil,
|
||||||
|
"the importer owns the safe mode state")
|
||||||
|
check(imp:find('function RomImporter:_reportIssue', 1, true) ~= nil,
|
||||||
|
"the importer owns issue report opening")
|
||||||
|
|
||||||
T.finish("launcher_skins_tab")
|
T.finish("launcher_skins_tab")
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
-- engine/menus/pc.asm prints the access text between SFX_ENTER_PC and the
|
||||||
|
-- farcall: BillsPC (:73-85) picks AccessedBillsPCText / AccessedSomeonesPCText
|
||||||
|
-- off EVENT_MET_BILL, .playersPC (:50-59) prints AccessedMyPCText. The port
|
||||||
|
-- opened BoxMenu / PlayerPC straight from the sound (#1529).
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.load()
|
||||||
|
|
||||||
|
Data.text._TurnedOnPC1Text = "{PLAYER} turned on\nthe PC."
|
||||||
|
Data.text._AccessedBillsPCText = "Accessed BILL's\nPC.\fAccessed POKéMON\nStorage System."
|
||||||
|
Data.text._AccessedSomeonesPCText =
|
||||||
|
"Accessed someone's\nPC.\fAccessed POKéMON\nStorage System."
|
||||||
|
Data.text._AccessedMyPCText = "Accessed my PC.\fAccessed Item\nStorage System."
|
||||||
|
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local OW = require("src.world.OverworldController")
|
||||||
|
|
||||||
|
local function setUpvalue(fn, name, val)
|
||||||
|
local i = 1
|
||||||
|
while true do
|
||||||
|
local n = debug.getupvalue(fn, i)
|
||||||
|
if not n then return false end
|
||||||
|
if n == name then debug.setupvalue(fn, i, val); return true end
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local pushed, screens = {}, {}
|
||||||
|
local stackStub = { push = function(_, item) pushed[#pushed + 1] = item end }
|
||||||
|
local textBoxStub = {
|
||||||
|
new = function(_, text, onDone, opts)
|
||||||
|
return { kind = "text", text = text, onDone = onDone, opts = opts }
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
local menuStub = {
|
||||||
|
new = function(_, items, opts) return { kind = "menu", items = items, opts = opts or {} } end,
|
||||||
|
}
|
||||||
|
package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end }
|
||||||
|
package.loaded["src.ui.Menu"] = menuStub
|
||||||
|
|
||||||
|
local fakeGame = { data = Data, save = SaveData.newGame(), stack = stackStub }
|
||||||
|
T.check(setUpvalue(OW.openPC, "Game", fakeGame), "Game upvalue on openPC")
|
||||||
|
T.check(setUpvalue(OW.openPC, "TextBox", textBoxStub), "TextBox upvalue on openPC")
|
||||||
|
T.check(setUpvalue(OW.openPC, "Screens",
|
||||||
|
{ push = function(_, id) screens[#screens + 1] = id end }), "Screens upvalue on openPC")
|
||||||
|
|
||||||
|
local fakeSelf = setmetatable({}, { __index = OW })
|
||||||
|
|
||||||
|
local function openMenu(metBill)
|
||||||
|
pushed, screens = {}, {}
|
||||||
|
fakeGame.save = SaveData.newGame()
|
||||||
|
if metBill then fakeGame.save.flags.EVENT_MET_BILL = true end
|
||||||
|
fakeSelf:openPC(function() end)
|
||||||
|
local pcOn = pushed[#pushed]
|
||||||
|
T.eq(pcOn.kind, "text", "the session opens with TurnedOnPC1Text")
|
||||||
|
pcOn.onDone()
|
||||||
|
local menu = pushed[#pushed]
|
||||||
|
T.eq(menu.kind, "menu", "then the PC menu")
|
||||||
|
return menu
|
||||||
|
end
|
||||||
|
|
||||||
|
-- === SOMEONE'S PC: the access text before BoxMenu
|
||||||
|
do
|
||||||
|
local menu = openMenu(false)
|
||||||
|
menu.items[1].onSelect()
|
||||||
|
local box = pushed[#pushed]
|
||||||
|
T.eq(box.kind, "text", "the box row opens a text box")
|
||||||
|
T.check(tostring(box.text):find("Accessed someone's", 1, true) ~= nil,
|
||||||
|
"before meeting BILL it is AccessedSomeonesPCText")
|
||||||
|
T.eq(#screens, 0, "and the box screen has NOT opened yet")
|
||||||
|
box.onDone()
|
||||||
|
T.eq(screens[1], "BoxMenu", "BoxMenu follows the text, as the farcall does")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- === BILL'S PC once EVENT_MET_BILL is set
|
||||||
|
do
|
||||||
|
local menu = openMenu(true)
|
||||||
|
menu.items[1].onSelect()
|
||||||
|
local box = pushed[#pushed]
|
||||||
|
T.check(tostring(box.text):find("Accessed BILL's", 1, true) ~= nil,
|
||||||
|
"after meeting BILL it is AccessedBillsPCText")
|
||||||
|
box.onDone()
|
||||||
|
T.eq(screens[1], "BoxMenu", "and still opens BoxMenu")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- === the player's item storage
|
||||||
|
do
|
||||||
|
local menu = openMenu(false)
|
||||||
|
menu.items[2].onSelect()
|
||||||
|
local box = pushed[#pushed]
|
||||||
|
T.eq(box.kind, "text", "the item row opens a text box")
|
||||||
|
T.check(tostring(box.text):find("Accessed my PC.", 1, true) ~= nil,
|
||||||
|
"and it is AccessedMyPCText")
|
||||||
|
T.eq(#screens, 0, "PlayerPC has not opened yet")
|
||||||
|
box.onDone()
|
||||||
|
T.eq(screens[1], "PlayerPC", "PlayerPC follows the text")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("PC access text (#1529)")
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
-- A landed secondary POISON runs PoisonEffect's tail
|
||||||
|
-- (engine/battle/effects.asm:119-151): SHAKE_SCREEN_ANIM when the foe
|
||||||
|
-- poisoned you, ENEMY_HUD_SHAKE_ANIM when you poisoned the foe, through
|
||||||
|
-- PlayBattleAnimation2 (:1461-1471), which also stamps wAnimationType 6 /
|
||||||
|
-- 3 so the slow applying shake runs even with battle animations off.
|
||||||
|
-- FreezeBurnParalyzeEffect (:194-255) zeroes wAnimationType and only
|
||||||
|
-- shakes the enemy HUD on the player's turn. The port queued neither
|
||||||
|
-- (#1526).
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
require("src.render.Font").load(Data)
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
TypeChart.load(Data)
|
||||||
|
|
||||||
|
Data.moves.FIX_POISON_STING = {
|
||||||
|
id = "FIX_POISON_STING", index = 5, name = "FIX PSN STING", type = "POISON",
|
||||||
|
power = 15, accuracy = 100, pp = 35, effect = "POISON_SIDE_EFFECT1",
|
||||||
|
}
|
||||||
|
|
||||||
|
local function newBattle()
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.party = { Pokemon.new(Data, "FIXMON_A", 40) }
|
||||||
|
local game = { data = Data, save = save,
|
||||||
|
stack = { top = function() return nil end, push = function() end } }
|
||||||
|
local battle = BattleState.newWild(game, "FIXMON_C", 40)
|
||||||
|
battle.rng = function() return 0 end -- every roll lands
|
||||||
|
return battle
|
||||||
|
end
|
||||||
|
|
||||||
|
local function animRows(battle)
|
||||||
|
local rows = {}
|
||||||
|
for _, item in ipairs(battle.queue) do
|
||||||
|
if item.anim then rows[#rows + 1] = item end
|
||||||
|
end
|
||||||
|
return rows
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find(rows, name)
|
||||||
|
for i, row in ipairs(rows) do
|
||||||
|
if row.anim == name then return i, row end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function textIndex(battle, needle)
|
||||||
|
for i, item in ipairs(battle.queue) do
|
||||||
|
if item.text and item.text:find(needle, 1, true) then return i end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function animIndex(battle, name)
|
||||||
|
for i, item in ipairs(battle.queue) do
|
||||||
|
if item.anim == name then return i end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- the foe poisons you: SE_SHAKE_SCREEN plus wAnimationType 3
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle:performMove(battle.enemy, battle.player, { id = "FIX_POISON_STING", pp = 35 })
|
||||||
|
T.eq(battle.player.mon.status, "PSN", "the secondary poison landed")
|
||||||
|
local rows = animRows(battle)
|
||||||
|
local i, row = find(rows, "SHAKE_SCREEN_ANIM")
|
||||||
|
T.check(i ~= nil, "the enemy's turn queues SHAKE_SCREEN_ANIM")
|
||||||
|
T.eq(row.attackerIsPlayer, false, "attributed to the enemy side")
|
||||||
|
T.eq(row.hit and row.hit.animType, 3, "wAnimationType 3 rides the row")
|
||||||
|
T.eq(row.animDelayed, true, "PlayBattleAnimationGotID pays no Delay3")
|
||||||
|
local moveIdx = animIndex(battle, "FIX_POISON_STING")
|
||||||
|
local shakeIdx = animIndex(battle, "SHAKE_SCREEN_ANIM")
|
||||||
|
local textIdx = textIndex(battle, "poisoned")
|
||||||
|
T.check(moveIdx and shakeIdx and moveIdx < shakeIdx,
|
||||||
|
"the move animation still runs first")
|
||||||
|
T.check(textIdx and shakeIdx < textIdx,
|
||||||
|
"PlayBattleAnimation2 precedes PrintText (effects.asm:149-151)")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- you poison the foe: SE_SHAKE_ENEMY_HUD plus wAnimationType 6
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle:performMove(battle.player, battle.enemy, { id = "FIX_POISON_STING", pp = 35 })
|
||||||
|
T.eq(battle.enemy.mon.status, "PSN", "the secondary poison landed")
|
||||||
|
local _, row = find(animRows(battle), "ENEMY_HUD_SHAKE_ANIM")
|
||||||
|
T.check(row ~= nil, "the player's turn queues ENEMY_HUD_SHAKE_ANIM")
|
||||||
|
T.eq(row.attackerIsPlayer, true, "attributed to the player side")
|
||||||
|
T.eq(row.hit and row.hit.animType, 6, "wAnimationType 6 rides the row")
|
||||||
|
T.check(find(animRows(battle), "SHAKE_SCREEN_ANIM") == nil,
|
||||||
|
"and never the enemy-side id")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- burn takes the FreezeBurnParalyzeEffect arms: HUD shake on the player's
|
||||||
|
-- turn only, and no wAnimationType at all
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle:performMove(battle.player, battle.enemy, { id = "FIX_EMBERISH", pp = 25 })
|
||||||
|
T.eq(battle.enemy.mon.status, "BRN", "the secondary burn landed")
|
||||||
|
local _, row = find(animRows(battle), "ENEMY_HUD_SHAKE_ANIM")
|
||||||
|
T.check(row ~= nil, "the player's burn shakes the enemy HUD")
|
||||||
|
T.eq(row.hit, nil, "FreezeBurnParalyzeEffect zeroes wAnimationType")
|
||||||
|
|
||||||
|
local other = newBattle()
|
||||||
|
other.queue, other.nextInsert = {}, 0
|
||||||
|
other:performMove(other.enemy, other.player, { id = "FIX_EMBERISH", pp = 25 })
|
||||||
|
T.eq(other.player.mon.status, "BRN", "the enemy's burn landed too")
|
||||||
|
T.check(find(animRows(other), "ENEMY_HUD_SHAKE_ANIM") == nil,
|
||||||
|
"the enemy's-turn arm plays nothing")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- a plain damaging move queues neither
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle:performMove(battle.player, battle.enemy, { id = "FIX_TACKLE", pp = 35 })
|
||||||
|
local rows = animRows(battle)
|
||||||
|
T.check(find(rows, "ENEMY_HUD_SHAKE_ANIM") == nil
|
||||||
|
and find(rows, "SHAKE_SCREEN_ANIM") == nil,
|
||||||
|
"no status, no PlayBattleAnimation2 row")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- the residual tick plays BURN_PSN_ANIM with NO shake: core.asm:490-491
|
||||||
|
-- explicitly zeroes wAnimationType before it
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle.player.mon.status = "PSN"
|
||||||
|
battle:residualFor(battle.player, battle.enemy)
|
||||||
|
local _, row = find(animRows(battle), "BURN_PSN_ANIM")
|
||||||
|
T.check(row ~= nil, "the poison tick animates")
|
||||||
|
T.eq(row.hit, nil, "with no applying-attack shake")
|
||||||
|
T.eq(row.attackerIsPlayer, true, "on the hurt mon's side")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("secondary status animation (#1526)")
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
-- JoypadOverworld calls RunMapScript every overworld frame before input is
|
||||||
|
-- even read (home/overworld.asm:1816-1821), and the gate guards are
|
||||||
|
-- per-frame "is the player standing on these coords" checks
|
||||||
|
-- (scripts/Route16Gate1F.asm:16, Route5Gate.asm:19, Route22Gate.asm:21).
|
||||||
|
-- The port only evaluated them on a completed step, so saving on a guard's
|
||||||
|
-- tile and reloading walked past the guard (#1547).
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
Data.tilesets.FIX_OUT.tilesPerRow = 16
|
||||||
|
Data.field.flyWarps = Data.field.flyWarps or {}
|
||||||
|
Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" }
|
||||||
|
Data.field.waterTilesets = {}
|
||||||
|
Data.field.forcedMovement = { tiles = {} }
|
||||||
|
Data.audio = Data.audio or {}
|
||||||
|
Data.audio.songs = Data.audio.songs or {}
|
||||||
|
Data.audio.mapSongs = Data.audio.mapSongs or {}
|
||||||
|
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local Game = require("src.core.Game")
|
||||||
|
local StateStack = require("src.core.StateStack")
|
||||||
|
local OverworldState = require("src.world.OverworldController")
|
||||||
|
local MapScripts = require("src.script.MapScripts")
|
||||||
|
|
||||||
|
Game.data = Data
|
||||||
|
Game.save = SaveData.newGame()
|
||||||
|
Game.save.player.name = "RED"
|
||||||
|
Game.save.player.map = "FIX_TOWN"
|
||||||
|
StateStack:init()
|
||||||
|
Game.stack = StateStack
|
||||||
|
Game.overworld = OverworldState
|
||||||
|
Game.input = {
|
||||||
|
isDown = function() return false end,
|
||||||
|
wasPressed = function() return false end,
|
||||||
|
step = function() end, state = {}, pressQueue = {},
|
||||||
|
}
|
||||||
|
Game.renderer = {
|
||||||
|
beginWorldPass = function() end, endWorldPass = function() end,
|
||||||
|
beginUIPass = function() end, endUIPass = function() end,
|
||||||
|
worldViewSize = function() return 160, 144 end,
|
||||||
|
setSGBZones = function() end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local TRIGGER_X, TRIGGER_Y = 4, 4
|
||||||
|
local fired = {}
|
||||||
|
MapScripts.attachBase("FIX_TOWN", {
|
||||||
|
onStep = function(_, _, x, y)
|
||||||
|
if x == TRIGGER_X and y == TRIGGER_Y then
|
||||||
|
fired[#fired + 1] = { x, y }
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
local function loadedSave()
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.player.map = "FIX_TOWN"
|
||||||
|
save.player.x, save.player.y = TRIGGER_X, TRIGGER_Y
|
||||||
|
return save
|
||||||
|
end
|
||||||
|
|
||||||
|
-- === the exploit: F2 / CONTINUE onto the guard's tile re-fires the guard
|
||||||
|
fired = {}
|
||||||
|
Game:restoreSave(loadedSave(), false, { freshBoot = true })
|
||||||
|
T.eq(#fired, 1, "a freshBoot restore re-evaluates the standing-tile trigger")
|
||||||
|
T.same(fired[1], { TRIGGER_X, TRIGGER_Y },
|
||||||
|
"at the coords the save left the player on")
|
||||||
|
|
||||||
|
-- === an ordinary warp arrival must NOT fire it
|
||||||
|
fired = {}
|
||||||
|
OverworldState:setMap("FIX_TOWN", TRIGGER_X, TRIGGER_Y, "up", {})
|
||||||
|
T.eq(#fired, 0, "a plain warp arrival still leaves the trigger to onStepComplete")
|
||||||
|
|
||||||
|
-- === dev tooling reuses opts.via == "boot" WITHOUT freshBoot
|
||||||
|
fired = {}
|
||||||
|
OverworldState:setMap("FIX_TOWN", TRIGGER_X, TRIGGER_Y, "up", { via = "boot" })
|
||||||
|
T.eq(#fired, 0, "the console warp / hot reload shape does not fire it")
|
||||||
|
|
||||||
|
-- === checkpoint resume must never re-run map scripts
|
||||||
|
fired = {}
|
||||||
|
Game:restoreCheckpointSave(loadedSave())
|
||||||
|
T.eq(#fired, 0, "a checkpoint resume re-runs nothing")
|
||||||
|
|
||||||
|
-- === restoring somewhere harmless fires nothing
|
||||||
|
fired = {}
|
||||||
|
local elsewhere = loadedSave()
|
||||||
|
elsewhere.player.x, elsewhere.player.y = 3, 3
|
||||||
|
Game:restoreSave(elsewhere, false, { freshBoot = true })
|
||||||
|
T.eq(#fired, 0, "a restore off the trigger cell is untouched")
|
||||||
|
|
||||||
|
T.finish("standing-tile triggers on restore (#1547)")
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
-- The Nugget Bridge recruiter has no def_trainers header, so the port's
|
||||||
|
-- headerless engageTrainer fallback re-printed his contest line as the
|
||||||
|
-- pre-battle box (#1550) and his loss line never reached the battle
|
||||||
|
-- screen (#1551). scripts/Route24.asm:120-134: .JoinTeamRocketText, then
|
||||||
|
-- SaveEndBattleTextPointers with .DefeatedText, then EngageMapTrainer with
|
||||||
|
-- no further box; Route24AfterRocketBattleScript (:62-78) prints
|
||||||
|
-- .YouCouldBecomeATopLeaderText on the map after the win.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.load()
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
|
||||||
|
Data.items.NUGGET = Data.items.NUGGET
|
||||||
|
or { id = "NUGGET", index = 49, name = "NUGGET", price = 10000 }
|
||||||
|
Data.text._Route24CooltrainerM1YouBeatOurContestText =
|
||||||
|
"Congratulations!\nYou beat our 5\ncontest trainers!"
|
||||||
|
Data.text._Route24CooltrainerM1YouJustEarnedAPrizeText = "You just earned\na prize!"
|
||||||
|
Data.text._Route24CooltrainerM1ReceivedNuggetText = "{PLAYER} got\n{RAM:wStringBuffer}!"
|
||||||
|
Data.text._Route24CooltrainerM1JoinTeamRocketText = "Want to join us?"
|
||||||
|
Data.text._Route24CooltrainerM1DefeatedText = "Arrgh!\nYou are good!"
|
||||||
|
Data.text._Route24CooltrainerM1YouCouldBecomeATopLeaderText =
|
||||||
|
"With your ability,\nyou could become\na top leader!"
|
||||||
|
|
||||||
|
local pushed = {}
|
||||||
|
package.loaded["src.render.TextBox"] = {
|
||||||
|
new = function(_, text, onDone, opts)
|
||||||
|
return { text = text, onDone = onDone, opts = opts }
|
||||||
|
end,
|
||||||
|
substitute = function(_, s) return s end,
|
||||||
|
soundOpts = function(_, sound, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
opts.auto = { sound = sound, wait = true, delay = 0 }
|
||||||
|
return opts
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local scripts = dofile("data/scripts/story4.lua")
|
||||||
|
local handler = scripts.ROUTE_24.talk.TEXT_ROUTE24_COOLTRAINER_M1
|
||||||
|
T.check(type(handler) == "function", "the recruiter has a hand-ported handler")
|
||||||
|
|
||||||
|
local game = {
|
||||||
|
data = Data,
|
||||||
|
save = SaveData.newGame(),
|
||||||
|
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
|
||||||
|
}
|
||||||
|
|
||||||
|
local defeated = false
|
||||||
|
local engaged
|
||||||
|
local ow = {
|
||||||
|
trainerDefeated = function() return defeated end,
|
||||||
|
engageTrainer = function(_, npc, onDone, endBattleText, skipBattleText)
|
||||||
|
engaged = { npc = npc, onDone = onDone,
|
||||||
|
endBattleText = endBattleText, skipBattleText = skipBattleText }
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
local npc = { id = "ROUTE24_ROCKET", def = { index = 1 } }
|
||||||
|
|
||||||
|
-- the prize has already been taken: the talk goes straight to the battle
|
||||||
|
game.save.flags.EVENT_GOT_NUGGET = true
|
||||||
|
local doneCalls = 0
|
||||||
|
handler(game, ow, npc, function() doneCalls = doneCalls + 1 end)
|
||||||
|
|
||||||
|
T.check(engaged ~= nil, "the recruiter engages")
|
||||||
|
T.eq(#pushed, 0, "no text box is pushed before the battle (#1550)")
|
||||||
|
T.eq(engaged.skipBattleText, true,
|
||||||
|
"skipBattleText stops the map text becoming the pre-battle box")
|
||||||
|
T.eq(engaged.endBattleText, Data.text._Route24CooltrainerM1DefeatedText,
|
||||||
|
"the loss line rides the battle, as SaveEndBattleTextPointers does (#1551)")
|
||||||
|
|
||||||
|
-- the win: Route24AfterRocketBattleScript prints the top-leader line
|
||||||
|
defeated = true
|
||||||
|
engaged.onDone()
|
||||||
|
T.eq(#pushed, 1, "the win prints exactly one box")
|
||||||
|
T.eq(pushed[1].text, Data.text._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||||
|
"and it is .YouCouldBecomeATopLeaderText")
|
||||||
|
pushed[1].onDone()
|
||||||
|
T.eq(doneCalls, 1, "control returns once the box closes")
|
||||||
|
|
||||||
|
-- the blackout arm: wIsInBattle == $ff rets before the DisplayTextID
|
||||||
|
pushed, defeated, doneCalls = {}, false, 0
|
||||||
|
handler(game, ow, npc, function() doneCalls = doneCalls + 1 end)
|
||||||
|
engaged.onDone()
|
||||||
|
T.eq(#pushed, 0, "a loss prints nothing")
|
||||||
|
T.eq(doneCalls, 1, "and just unfreezes the player")
|
||||||
|
|
||||||
|
T.finish("Nugget Bridge Rocket battle text (#1550, #1551)")
|
||||||
@@ -41,7 +41,8 @@ _G.love = {
|
|||||||
getVersion = function() return 12, 0, 0, "Mysterious Mysteries" end,
|
getVersion = function() return 12, 0, 0, "Mysterious Mysteries" end,
|
||||||
system = {
|
system = {
|
||||||
getOS = function() return "iOS" end,
|
getOS = function() return "iOS" end,
|
||||||
getModel = function() return "iPad Test" end,
|
getDeviceModel = function() return "iPhone16,2" end,
|
||||||
|
getModel = function() return "Apple A17 Pro GPU" end,
|
||||||
openURL = function(url) openedURL = url end,
|
openURL = function(url) openedURL = url end,
|
||||||
},
|
},
|
||||||
graphics = {
|
graphics = {
|
||||||
@@ -78,10 +79,13 @@ check(not url:find("game=", 1, true)
|
|||||||
check(fields.summary == "" and fields.location == "" and fields.screenshot == ""
|
check(fields.summary == "" and fields.location == "" and fields.screenshot == ""
|
||||||
and fields.steps == "" and fields.expected == "",
|
and fields.steps == "" and fields.expected == "",
|
||||||
"report leaves user-entered fields blank")
|
"report leaves user-entered fields blank")
|
||||||
check(info.metadata:find("Device: iPad Test", 1, true) ~= nil
|
check(info.device == "iPhone 15 Pro Max"
|
||||||
|
and info.metadata:find("Device: iPhone 15 Pro Max", 1, true) ~= nil
|
||||||
and info.metadata:find("LÖVE: 12.0.0", 1, true) ~= nil
|
and info.metadata:find("LÖVE: 12.0.0", 1, true) ~= nil
|
||||||
and info.metadata:find("Safe mode: on", 1, true) ~= nil,
|
and info.metadata:find("Safe mode: on", 1, true) ~= nil,
|
||||||
"report metadata includes device and app details")
|
"report metadata includes device and app details")
|
||||||
|
check(not info.metadata:find("Simulator GPU", 1, true),
|
||||||
|
"report metadata does not mistake the renderer device for the device")
|
||||||
check(not info.metadata:find("unknown", 1, true),
|
check(not info.metadata:find("unknown", 1, true),
|
||||||
"report metadata omits unknown values")
|
"report metadata omits unknown values")
|
||||||
check(not info.metadata:find("Game id", 1, true)
|
check(not info.metadata:find("Game id", 1, true)
|
||||||
@@ -104,25 +108,31 @@ check(not developmentInfo.metadata:find("0.0.0-dev", 1, true),
|
|||||||
|
|
||||||
local previousOS = love.system.getOS
|
local previousOS = love.system.getOS
|
||||||
local previousModel = love.system.getModel
|
local previousModel = love.system.getModel
|
||||||
|
local previousDeviceModel = love.system.getDeviceModel
|
||||||
local previousIO = _G.io
|
local previousIO = _G.io
|
||||||
love.system.getOS = function() return "OS X" end
|
love.system.getOS = function() return "OS X" end
|
||||||
love.system.getModel = nil
|
love.system.getModel = nil
|
||||||
|
love.system.getDeviceModel = nil
|
||||||
_G.io = {
|
_G.io = {
|
||||||
popen = function()
|
popen = function(command)
|
||||||
|
local output = command:find("system_profiler", 1, true)
|
||||||
|
and "Hardware Overview:\n Model Name: MacBook Air\n Model Identifier: Mac14,15\n Chip: Apple M2\n"
|
||||||
|
or "Mac14,15\n"
|
||||||
return {
|
return {
|
||||||
read = function() return "MacBookPro18,3" end,
|
read = function() return output end,
|
||||||
close = function() end,
|
close = function() end,
|
||||||
}
|
}
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
local desktopInfo = IssueReport.metadata({}, { mods = {} })
|
local desktopInfo = IssueReport.metadata({}, { mods = {} })
|
||||||
check(desktopInfo.device == "MacBookPro18,3",
|
check(desktopInfo.device == "MacBook Air (Apple M2)",
|
||||||
"report finds desktop device model when LOVE has no model")
|
"report finds desktop device model when LOVE has no model")
|
||||||
love.system.getOS = function() return "UWP" end
|
love.system.getOS = function() return "UWP" end
|
||||||
local xboxInfo = IssueReport.metadata({}, { mods = {} })
|
local xboxInfo = IssueReport.metadata({}, { mods = {} })
|
||||||
check(xboxInfo.os == "Xbox", "report maps the Xbox runtime platform")
|
check(xboxInfo.os == "Xbox", "report maps the Xbox runtime platform")
|
||||||
love.system.getOS = previousOS
|
love.system.getOS = previousOS
|
||||||
love.system.getModel = previousModel
|
love.system.getModel = previousModel
|
||||||
|
love.system.getDeviceModel = previousDeviceModel
|
||||||
_G.io = previousIO
|
_G.io = previousIO
|
||||||
|
|
||||||
local opened = IssueReport.open({ safeMode = false }, {
|
local opened = IssueReport.open({ safeMode = false }, {
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
-- SwitchPlayerMon (engine/battle/core.asm:2419-2423) prints RetreatMon and
|
||||||
|
-- holds 50 frames BEFORE the outgoing pic is recalled, and only then does
|
||||||
|
-- SendOutMon shout "Go! X!" (#1534). The port queued the send-out line
|
||||||
|
-- alone, so the withdraw box never existed. PlayerMon2Text's adjective
|
||||||
|
-- (engine/battle/common_text.asm:167-243) reads the ENEMY HP lost since
|
||||||
|
-- this mon switched in, from wLastSwitchInEnemyMonHP.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
require("src.render.Font").load(Data)
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local Timing = require("src.core.Timing")
|
||||||
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
TypeChart.load(Data)
|
||||||
|
|
||||||
|
local function newBattle()
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.party = { Pokemon.new(Data, "FIXMON_A", 30), Pokemon.new(Data, "FIXMON_B", 30) }
|
||||||
|
local game = { data = Data, save = save,
|
||||||
|
stack = { top = function() return nil end, push = function() end } }
|
||||||
|
local battle = BattleState.newWild(game, "FIXMON_C", 30)
|
||||||
|
battle.rng = function() return 0 end
|
||||||
|
battle.enemyAction = function() return { id = "FIX_SCRATCH", pp = 35 } end
|
||||||
|
return battle
|
||||||
|
end
|
||||||
|
|
||||||
|
-- drain the queue the way updateQueue does, recording rows in order plus
|
||||||
|
-- who was in the player slot when each row was emitted
|
||||||
|
local function drain(battle)
|
||||||
|
local rows = {}
|
||||||
|
for _ = 1, 400 do
|
||||||
|
local item = table.remove(battle.queue, 1)
|
||||||
|
if not item then return rows end
|
||||||
|
rows[#rows + 1] = { text = item.text, anim = item.anim,
|
||||||
|
auto = item.auto, autoDelay = item.autoDelay,
|
||||||
|
playerSpecies = battle.player.mon.species }
|
||||||
|
if item.fn then
|
||||||
|
battle.nextInsert = 0
|
||||||
|
item.fn()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
error("the queue never drained")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function indexOf(rows, pred)
|
||||||
|
for i, row in ipairs(rows) do
|
||||||
|
if pred(row) then return i end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- the voluntary party-menu switch: withdraw line, then the send-out
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
drain(battle) -- the intro stamps lastSwitchInEnemyHP through sendOutText
|
||||||
|
local outgoing = battle.player.mon.species
|
||||||
|
local oldNick = battle.player.name
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle:resolveSwitch(battle.game.save.party[2])
|
||||||
|
local rows = drain(battle)
|
||||||
|
|
||||||
|
local wIdx = indexOf(rows, function(r)
|
||||||
|
return r.text and r.text:find("Come back!", 1, true) ~= nil
|
||||||
|
end)
|
||||||
|
T.check(wIdx ~= nil, "the withdraw line is queued")
|
||||||
|
T.eq(rows[wIdx].text, oldNick .. " enough!\nCome back!",
|
||||||
|
"an untouched foe gives the `enough!` variant")
|
||||||
|
T.eq(rows[wIdx].auto, true, "the page ends `done`, so it never waits on A")
|
||||||
|
T.eq(rows[wIdx].autoDelay, Timing.SWITCH_PLAYER_MON,
|
||||||
|
"it holds the 50 frames DelayFrames pays (core.asm:2421-2422)")
|
||||||
|
T.eq(rows[wIdx].playerSpecies, outgoing,
|
||||||
|
"the outgoing mon is still in the slot while the line prints")
|
||||||
|
|
||||||
|
local sIdx = indexOf(rows, function(r)
|
||||||
|
return r.text and r.text:find("! ", 1, true) and r.text:find("Come back!", 1, true) == nil
|
||||||
|
end)
|
||||||
|
T.check(sIdx ~= nil and sIdx > wIdx, "the send-out shout follows the withdraw line")
|
||||||
|
T.eq(rows[sIdx].auto, true, "the send-out page ends `done` too (#1472)")
|
||||||
|
T.neq(rows[sIdx].playerSpecies, outgoing, "the swap happened between the two")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- the adjective branches on enemy HP lost since the switch-in
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
drain(battle)
|
||||||
|
local nick = battle.player.name
|
||||||
|
local max = battle.enemy.mon.stats.hp
|
||||||
|
local quarter = math.floor(max / 4)
|
||||||
|
local function withdrawAt(dropPercent)
|
||||||
|
battle.lastSwitchInEnemyHP = max
|
||||||
|
battle.enemy.mon.hp = max - math.floor(dropPercent * quarter / 25)
|
||||||
|
return battle:withdrawText(nick)
|
||||||
|
end
|
||||||
|
T.eq(withdrawAt(0), nick .. " enough!\nCome back!", "no damage -> `enough!`")
|
||||||
|
T.eq(withdrawAt(50), nick .. " OK!\nCome back!", "30-69 -> `OK!`")
|
||||||
|
T.eq(withdrawAt(80), nick .. " good!\nCome back!", "70+ -> `good!`")
|
||||||
|
T.eq(withdrawAt(10), nick .. " \nCome back!", "1-29 -> no adjective at all")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- ChooseNextMon (core.asm:1086-1128) calls SendOutMon with NO RetreatMon
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
drain(battle)
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle.player.mon.hp = 0
|
||||||
|
battle:openReplacementMenu()
|
||||||
|
local rows = drain(battle)
|
||||||
|
T.check(indexOf(rows, function(r)
|
||||||
|
return r.text and r.text:find("Come back!", 1, true) ~= nil
|
||||||
|
end) == nil, "the post-faint replacement prints no withdraw line")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("switch withdraw text (#1534)")
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
-- THRASH/PETAL DANCE is a SpecialEffectsCont entry
|
||||||
|
-- (data/battle/special_effects.asm:22), so on the SETUP turn only,
|
||||||
|
-- engine/battle/core.asm:3129-3133 runs ThrashPetalDanceEffect before
|
||||||
|
-- damage; it ends in PlayBattleAnimation2 with SHRINKING_SQUARE_ANIM
|
||||||
|
-- (ANIM_B1 on the enemy's turn) plus the slow horizontal screen shake
|
||||||
|
-- (engine/battle/effects.asm:791-808, :1461-1471). The port queued only
|
||||||
|
-- the move's own animation (#1532).
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
require("src.render.Font").load(Data)
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
TypeChart.load(Data)
|
||||||
|
|
||||||
|
Data.moves.FIX_THRASH = {
|
||||||
|
id = "FIX_THRASH", index = 91, name = "FIX THRASH", type = "NORMAL",
|
||||||
|
power = 90, accuracy = 100, pp = 20, effect = "THRASH_PETAL_DANCE_EFFECT",
|
||||||
|
}
|
||||||
|
|
||||||
|
local function newBattle()
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.party = { Pokemon.new(Data, "FIXMON_A", 40) }
|
||||||
|
local game = { data = Data, save = save,
|
||||||
|
stack = { top = function() return nil end, push = function() end } }
|
||||||
|
local battle = BattleState.newWild(game, "FIXMON_C", 40)
|
||||||
|
battle.rng = function(a) if a then return a end return 0 end
|
||||||
|
return battle
|
||||||
|
end
|
||||||
|
|
||||||
|
local function animRows(battle)
|
||||||
|
local rows = {}
|
||||||
|
for _, item in ipairs(battle.queue) do
|
||||||
|
if item.anim then
|
||||||
|
rows[#rows + 1] = { anim = item.anim, hit = item.hit,
|
||||||
|
attackerIsPlayer = item.attackerIsPlayer }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return rows
|
||||||
|
end
|
||||||
|
|
||||||
|
local function indexOf(rows, name)
|
||||||
|
for i, row in ipairs(rows) do
|
||||||
|
if row.anim == name then return i end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- the player's setup turn: the effect animation precedes the move's own
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
local slot = { id = "FIX_THRASH", pp = 20 }
|
||||||
|
battle:performMove(battle.player, battle.enemy, slot)
|
||||||
|
local rows = animRows(battle)
|
||||||
|
local setup = indexOf(rows, "SHRINKING_SQUARE_ANIM")
|
||||||
|
local move = indexOf(rows, "FIX_THRASH")
|
||||||
|
T.check(setup ~= nil, "the setup turn queues SHRINKING_SQUARE_ANIM")
|
||||||
|
T.check(move ~= nil and setup < move,
|
||||||
|
"it plays BEFORE PlayPlayerMoveAnimation, as SpecialEffectsCont runs first")
|
||||||
|
T.eq(rows[setup].attackerIsPlayer, true, "on the player's side")
|
||||||
|
T.eq(rows[setup].hit and rows[setup].hit.animType, 6,
|
||||||
|
"wAnimationType 6 -> ShakeScreenHorizontallySlow2 on the player's turn")
|
||||||
|
|
||||||
|
-- the continuation turn never reaches the effect (.ThrashingAboutCheck,
|
||||||
|
-- core.asm:3532-3550 jumps straight to PlayerCalcMoveDamage)
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle:performMove(battle.player, battle.enemy, slot)
|
||||||
|
T.check(indexOf(animRows(battle), "SHRINKING_SQUARE_ANIM") == nil,
|
||||||
|
"a locked-in Thrash queues no setup animation")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- the enemy's turn takes ANIM_B1 and wAnimationType 3
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle:performMove(battle.enemy, battle.player, { id = "FIX_THRASH", pp = 20 })
|
||||||
|
local rows = animRows(battle)
|
||||||
|
local setup = indexOf(rows, "ANIM_B1")
|
||||||
|
T.check(setup ~= nil, "the enemy's setup turn queues ANIM_B1")
|
||||||
|
T.eq(rows[setup].attackerIsPlayer, false, "on the enemy's side")
|
||||||
|
T.eq(rows[setup].hit and rows[setup].hit.animType, 3,
|
||||||
|
"wAnimationType 3 -> ShakeScreenHorizontallySlow on the enemy's turn")
|
||||||
|
T.check(indexOf(rows, "SHRINKING_SQUARE_ANIM") == nil,
|
||||||
|
"and never the player-side id")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- a missed setup turn still runs the effect (it precedes MoveHitTest)
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle.accuracyRoll = function() return false end
|
||||||
|
battle:performMove(battle.player, battle.enemy, { id = "FIX_THRASH", pp = 20 })
|
||||||
|
local rows = animRows(battle)
|
||||||
|
T.check(indexOf(rows, "SHRINKING_SQUARE_ANIM") ~= nil,
|
||||||
|
"the setup animation survives a miss")
|
||||||
|
T.check(indexOf(rows, "FIX_THRASH") == nil, "while the move's own anim is cancelled")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("thrash setup animation (#1532)")
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
-- _TrainerSentOutText (data/text/text_2.asm:923) and the
|
||||||
|
-- Go!/Do it!/Get'm! chain ending in _PlayerMon1Text (:1274-1294) end in
|
||||||
|
-- `done`, not `prompt`: PrintText returns and the flow runs straight into
|
||||||
|
-- AnimateSendingOutMon + PlayCry (engine/battle/core.asm:1421-1434,
|
||||||
|
-- :1723-1765). _AIBattleWithdrawText (:1-7) does end in `prompt` and
|
||||||
|
-- keeps its button wait. The port made every send-out box wait (#1472).
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
require("src.render.Font").load(Data)
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
TypeChart.load(Data)
|
||||||
|
|
||||||
|
local function newBattle()
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.party = { Pokemon.new(Data, "FIXMON_A", 30), Pokemon.new(Data, "FIXMON_B", 30) }
|
||||||
|
local game = { data = Data, save = save,
|
||||||
|
stack = { top = function() return nil end, push = function() end } }
|
||||||
|
local battle = BattleState.newWild(game, "FIXMON_C", 30)
|
||||||
|
battle.rng = function() return 0 end
|
||||||
|
battle.enemyAction = function() return { id = "FIX_SCRATCH", pp = 35 } end
|
||||||
|
return battle
|
||||||
|
end
|
||||||
|
|
||||||
|
local function rowWith(battle, needle)
|
||||||
|
for _, item in ipairs(battle.queue) do
|
||||||
|
if item.text and item.text:find(needle, 1, true) then return item end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- the voluntary switch: the shout auto-continues into the send-out anim
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle:resolveSwitch(battle.game.save.party[2])
|
||||||
|
-- run the first act so the nested switch rows land in the queue
|
||||||
|
local first = table.remove(battle.queue, 1)
|
||||||
|
battle.nextInsert = 0
|
||||||
|
first.fn()
|
||||||
|
local withdraw = rowWith(battle, "Come back!")
|
||||||
|
T.check(withdraw ~= nil, "the withdraw line is queued")
|
||||||
|
T.eq(withdraw.auto, true, "RetreatMon's page ends `done` (#1534)")
|
||||||
|
local swap = table.remove(battle.queue, 2)
|
||||||
|
battle.nextInsert = 1
|
||||||
|
swap.fn()
|
||||||
|
local shout = rowWith(battle, battle.player.name)
|
||||||
|
T.check(shout ~= nil, "the send-out shout is queued")
|
||||||
|
T.eq(shout.auto, true, "_PlayerMon1Text ends `done`, so no button wait")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- the AI switch: the sent-out box goes auto, the withdraw box does not,
|
||||||
|
-- and EnemySendOut's grow-in + cry now follow it
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
|
||||||
|
local game = { data = Data, save = save,
|
||||||
|
stack = { top = function() return nil end, push = function() end } }
|
||||||
|
local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
|
||||||
|
battle.rng = function() return 0 end
|
||||||
|
battle.enemyParty = { Pokemon.new(Data, "FIXMON_B", 30),
|
||||||
|
Pokemon.new(Data, "FIXMON_C", 30) }
|
||||||
|
battle.enemyIndex = 1
|
||||||
|
battle.enemy = battle.enemy or nil
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle:executeAction(battle.enemy, battle.player,
|
||||||
|
{ special = "aiSwitch", index = 2 })
|
||||||
|
local withdrew = rowWith(battle, "with-")
|
||||||
|
local sent = rowWith(battle, "sent")
|
||||||
|
T.check(withdrew ~= nil, "the AI withdraw line is queued")
|
||||||
|
T.eq(withdrew.auto, nil, "_AIBattleWithdrawText ends `prompt` and still waits")
|
||||||
|
T.check(sent ~= nil, "the sent-out line is queued")
|
||||||
|
T.eq(sent.auto, true, "_TrainerSentOutText ends `done`")
|
||||||
|
T.eq(battle.enemySendingOut, true,
|
||||||
|
"the new pic stays hidden until AnimateSendingOutMon")
|
||||||
|
local acts = 0
|
||||||
|
for _, item in ipairs(battle.queue) do
|
||||||
|
if item.fn then acts = acts + 1 end
|
||||||
|
end
|
||||||
|
T.check(acts >= 1, "EnemySendOut queues the grow-in act after the text")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- the post-faint replacement (ChooseNextMon -> SendOutMon, core.asm:1124)
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
local pushedUI
|
||||||
|
battle.game.stack.push = function(_, s) pushedUI = s end
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle.player.mon.hp = 0
|
||||||
|
battle:openReplacementMenu()
|
||||||
|
local onSwitch
|
||||||
|
for _, item in ipairs(battle.queue) do
|
||||||
|
if item.ui then
|
||||||
|
local screen = item.ui()
|
||||||
|
onSwitch = screen and screen.onSwitch
|
||||||
|
end
|
||||||
|
end
|
||||||
|
onSwitch = onSwitch or (pushedUI and pushedUI.onSwitch)
|
||||||
|
if onSwitch then
|
||||||
|
battle.nextInsert = 0
|
||||||
|
onSwitch(battle.game.save.party[2])
|
||||||
|
local shout = rowWith(battle, battle.player.name)
|
||||||
|
T.check(shout ~= nil, "the replacement shout is queued")
|
||||||
|
T.eq(shout.auto, true, "SendOutMon's message ends `done` here too")
|
||||||
|
else
|
||||||
|
T.check(false, "the replacement menu offers an onSwitch callback")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("trainer send-out boxes (#1472)")
|
||||||
@@ -96,6 +96,8 @@ local DATA = {
|
|||||||
CYNDAQUIL = {
|
CYNDAQUIL = {
|
||||||
id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155, index = 155,
|
id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155, index = 155,
|
||||||
growthRate = "GROWTH_MEDIUM_SLOW",
|
growthRate = "GROWTH_MEDIUM_SLOW",
|
||||||
|
-- data/pokemon/base_stats/cyndaquil.asm:10 GENDER_F12_5
|
||||||
|
genderRatio = 31,
|
||||||
types = { "FIRE", "FIRE" },
|
types = { "FIRE", "FIRE" },
|
||||||
baseStats = {
|
baseStats = {
|
||||||
hp = 39, attack = 52, defense = 43, speed = 65,
|
hp = 39, attack = 52, defense = 43, speed = 65,
|
||||||
@@ -105,6 +107,8 @@ local DATA = {
|
|||||||
TOTODILE = {
|
TOTODILE = {
|
||||||
id = "TOTODILE", name = "TOTODILE", dex = 158, index = 158,
|
id = "TOTODILE", name = "TOTODILE", dex = 158, index = 158,
|
||||||
growthRate = "GROWTH_MEDIUM_SLOW",
|
growthRate = "GROWTH_MEDIUM_SLOW",
|
||||||
|
-- data/pokemon/base_stats/totodile.asm:10 GENDER_F12_5
|
||||||
|
genderRatio = 31,
|
||||||
types = { "WATER", "WATER" },
|
types = { "WATER", "WATER" },
|
||||||
baseStats = {
|
baseStats = {
|
||||||
hp = 50, attack = 65, defense = 64, speed = 43,
|
hp = 50, attack = 65, defense = 64, speed = 43,
|
||||||
@@ -163,8 +167,11 @@ end
|
|||||||
-- is `levelMoves` and Gen 1 reads level1Moves / learnset.
|
-- is `levelMoves` and Gen 1 reads level1Moves / learnset.
|
||||||
local function mon(species, level, opts)
|
local function mon(species, level, opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
|
-- engine/pokemon/mon_stats.asm:126 GetGender reads the Attack DV, so the
|
||||||
|
-- gender a page prints follows the DVs, not a hand-set field
|
||||||
local built = Mon.new(DATA, species, level, {
|
local built = Mon.new(DATA, species, level, {
|
||||||
dvs = { attack = 15, defense = 15, speed = 15, special = 15 },
|
dvs = opts.dvs
|
||||||
|
or { attack = 15, defense = 15, speed = 15, special = 15 },
|
||||||
moves = opts.moves,
|
moves = opts.moves,
|
||||||
})
|
})
|
||||||
for key, value in pairs(opts.fields or {}) do built[key] = value end
|
for key, value in pairs(opts.fields or {}) do built[key] = value end
|
||||||
@@ -177,12 +184,13 @@ local CYNDA = mon("CYNDAQUIL", 12, {
|
|||||||
{ id = "EMBER", pp = 25, maxPp = 25 },
|
{ id = "EMBER", pp = 25, maxPp = 25 },
|
||||||
{ id = "LEER", pp = 7, maxPp = 30 },
|
{ id = "LEER", pp = 7, maxPp = 30 },
|
||||||
},
|
},
|
||||||
fields = { nickname = "CYNDAQUIL", gender = "male", item = "BERRY",
|
fields = { nickname = "CYNDAQUIL", item = "BERRY",
|
||||||
otName = "GOLD", otId = 12345 },
|
otName = "GOLD", otId = 12345 },
|
||||||
})
|
})
|
||||||
local TOTO = mon("TOTODILE", 10, {
|
local TOTO = mon("TOTODILE", 10, {
|
||||||
moves = { { id = "SURF", pp = 15, maxPp = 15 } },
|
moves = { { id = "SURF", pp = 15, maxPp = 15 } },
|
||||||
fields = { nickname = "TOTODILE", gender = "female" },
|
dvs = { attack = 0, defense = 15, speed = 15, special = 15 },
|
||||||
|
fields = { nickname = "TOTODILE" },
|
||||||
})
|
})
|
||||||
|
|
||||||
local SAVE = { player = { name = "GOLD", id = 12345 }, party = { CYNDA, TOTO } }
|
local SAVE = { player = { name = "GOLD", id = 12345 }, party = { CYNDA, TOTO } }
|
||||||
|
|||||||
@@ -226,6 +226,10 @@ return { spawns = { SPAWN_NEW_BARK = { map = "TEST_MAP", x = 1, y = 1 } } }
|
|||||||
spawnAfterChampion = "SPAWN_LANCE",
|
spawnAfterChampion = "SPAWN_LANCE",
|
||||||
position = { map = "PLAYERS_HOUSE_2F", x = 1, y = 1, facing = "down" } }),
|
position = { map = "PLAYERS_HOUSE_2F", x = 1, y = 1, facing = "down" } }),
|
||||||
"and the post-credits spawn is a warp even though a position exists")
|
"and the post-credits spawn is a warp even though a position exists")
|
||||||
|
|
||||||
|
love.filesystem.remove("data/generated/maps.lua")
|
||||||
|
love.filesystem.remove("data/generated/tilesets.lua")
|
||||||
|
love.filesystem.remove("data/generated/landmarks.lua")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ---- the two clock faces ----------------------------------------------------
|
-- ---- the two clock faces ----------------------------------------------------
|
||||||
|
|||||||
@@ -2876,8 +2876,9 @@ check(selGame.save.registeredItem == nil,
|
|||||||
eq(selWorld:useSelectItem(), "not_registered",
|
eq(selWorld:useSelectItem(), "not_registered",
|
||||||
"SELECT with nothing registered answers not_registered")
|
"SELECT with nothing registered answers not_registered")
|
||||||
|
|
||||||
-- The PACK side: SELECT on a highlighted row is RegisterItem, the one PACK
|
-- The PACK side: the item submenu's SEL row is RegisterItem's only door --
|
||||||
-- button this port left unbound.
|
-- the cart's SELECT is the bag's own item shuffle
|
||||||
|
-- (engine/items/pack.asm:1290 Pack_InterpretJoypad .select).
|
||||||
selGame.save.inventory.POTION = 3
|
selGame.save.inventory.POTION = 3
|
||||||
selGame.input = stubInput()
|
selGame.input = stubInput()
|
||||||
local selPack = PackMenu.new(selGame, { pocket = "ITEM" })
|
local selPack = PackMenu.new(selGame, { pocket = "ITEM" })
|
||||||
@@ -2885,7 +2886,18 @@ selPack.index = 1
|
|||||||
check(selPack.rows[1].id == "POTION", "the ITEM pocket row under test")
|
check(selPack.rows[1].id == "POTION", "the ITEM pocket row under test")
|
||||||
selGame.input:press("select")
|
selGame.input:press("select")
|
||||||
selPack:update(0)
|
selPack:update(0)
|
||||||
check(selPack.message ~= nil, "SELECT on a row opens RegisteredItemText")
|
eq(selPack.switching, 1, "SELECT on a row arms the item shuffle")
|
||||||
|
check(selGame.save.registeredItem == nil, "and registers nothing")
|
||||||
|
selGame.input:press("b")
|
||||||
|
selPack:update(0)
|
||||||
|
check(selPack.switching == nil and selPack.message == nil,
|
||||||
|
"B backs out of the shuffle")
|
||||||
|
selPack:openSubmenu()
|
||||||
|
check(table.concat(selPack.submenu.rows, ","):find("sel", 1, true) ~= nil,
|
||||||
|
"the POTION submenu offers the SEL row")
|
||||||
|
selPack:closeSubmenu()
|
||||||
|
selPack:registerSelected()
|
||||||
|
check(selPack.message ~= nil, "SEL opens RegisteredItemText")
|
||||||
eq(selGame.save.registeredItem.id, "POTION",
|
eq(selGame.save.registeredItem.id, "POTION",
|
||||||
"and World:registerItem actually ran")
|
"and World:registerItem actually ran")
|
||||||
selGame.input:press("a")
|
selGame.input:press("a")
|
||||||
@@ -2897,7 +2909,9 @@ selPack.index = selPack:total()
|
|||||||
selGame.save.registeredItem = nil
|
selGame.save.registeredItem = nil
|
||||||
selGame.input:press("select")
|
selGame.input:press("select")
|
||||||
selPack:update(0)
|
selPack:update(0)
|
||||||
check(selPack.message == nil, "SELECT on CANCEL registers nothing")
|
check(selPack.switching == nil and selPack.message == nil,
|
||||||
|
"SELECT on CANCEL arms nothing")
|
||||||
|
selPack:registerSelected()
|
||||||
check(selGame.save.registeredItem == nil, "and the slot stays empty")
|
check(selGame.save.registeredItem == nil, "and the slot stays empty")
|
||||||
|
|
||||||
-- CantRegisterText: a TM/HM row refuses from the PACK too.
|
-- CantRegisterText: a TM/HM row refuses from the PACK too.
|
||||||
@@ -2906,9 +2920,8 @@ selPack:rebuild()
|
|||||||
local tmPack = PackMenu.new(selGame, { pocket = "TM_HM" })
|
local tmPack = PackMenu.new(selGame, { pocket = "TM_HM" })
|
||||||
tmPack.index = 1
|
tmPack.index = 1
|
||||||
check(tmPack.rows[1].id == "HM_CUT", "the TM/HM pocket row under test")
|
check(tmPack.rows[1].id == "HM_CUT", "the TM/HM pocket row under test")
|
||||||
selGame.input:press("select")
|
tmPack:registerSelected()
|
||||||
tmPack:update(0)
|
check(tmPack.message ~= nil, "SEL on the HM still opens a message")
|
||||||
check(tmPack.message ~= nil, "SELECT on the HM still opens a message")
|
|
||||||
check(selGame.save.registeredItem == nil,
|
check(selGame.save.registeredItem == nil,
|
||||||
"CantRegisterText: the HM never becomes the registered item")
|
"CantRegisterText: the HM never becomes the registered item")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -315,8 +315,9 @@ local vanillaSave = SaveData.newGame(Data.field.boot)
|
|||||||
-- special_warps.asm NewGameWarp is REDS_HOUSE_2F, 3, 6 -- the bedroom. This
|
-- special_warps.asm NewGameWarp is REDS_HOUSE_2F, 3, 6 -- the bedroom. This
|
||||||
-- previously asserted PALLET_TOWN (5, 6), which is where you stand after
|
-- previously asserted PALLET_TOWN (5, 6), which is where you stand after
|
||||||
-- walking out of the house, so a new game skipped Red's house entirely.
|
-- walking out of the house, so a new game skipped Red's house entirely.
|
||||||
|
-- Red/Blue land facing up (#944); only Yellow keeps boot.startFacing.
|
||||||
check(vanillaSave.player.map == "REDS_HOUSE_2F" and vanillaSave.player.x == 3
|
check(vanillaSave.player.map == "REDS_HOUSE_2F" and vanillaSave.player.x == 3
|
||||||
and vanillaSave.player.y == 6 and vanillaSave.player.facing == "down",
|
and vanillaSave.player.y == 6 and vanillaSave.player.facing == "up",
|
||||||
"the seeded boot config reproduces the NewGameWarp bedroom spawn")
|
"the seeded boot config reproduces the NewGameWarp bedroom spawn")
|
||||||
check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE"
|
check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE"
|
||||||
and vanillaSave.money == 3000, "the seeded boot config reproduces the Red start")
|
and vanillaSave.money == 3000, "the seeded boot config reproduces the Red start")
|
||||||
|
|||||||
@@ -276,6 +276,8 @@ do
|
|||||||
text = text:gsub("{RIVAL}", save.player.rival or "BLUE")
|
text = text:gsub("{RIVAL}", save.player.rival or "BLUE")
|
||||||
if game.stringBuffer then
|
if game.stringBuffer then
|
||||||
text = text:gsub("{RAM:wStringBuffer}", game.stringBuffer)
|
text = text:gsub("{RAM:wStringBuffer}", game.stringBuffer)
|
||||||
|
-- wNameBuffer reads the same buffer in this port (TextBox.TOKENS.RAM)
|
||||||
|
text = text:gsub("{RAM:wNameBuffer}", game.stringBuffer)
|
||||||
end
|
end
|
||||||
text = text:gsub("{[%w_:]+}", "")
|
text = text:gsub("{[%w_:]+}", "")
|
||||||
return text
|
return text
|
||||||
|
|||||||
+44
-30
@@ -282,13 +282,19 @@ local function optGame()
|
|||||||
end
|
end
|
||||||
local om = OptionsMenu.new(optGame())
|
local om = OptionsMenu.new(optGame())
|
||||||
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
|
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
|
||||||
"battleFit", "battleBg", "uiLayout",
|
"battleFit", "battleHud", "battleBg", "uiLayout",
|
||||||
"ruleset", "musicVol", "sfxVol", "musicFilter",
|
"ruleset", "musicVol", "sfxVol", "musicFilter",
|
||||||
"performance", "colors",
|
"performance", "colors",
|
||||||
"tilt", "gbcfx", "zoom", "voidFill", "videoMode",
|
"tilt", "gbcfx", "zoom", "voidFill", "videoMode",
|
||||||
"faithfulRes", "fpsCap",
|
"faithfulRes", "fpsCap",
|
||||||
"speedOverworld", "speedBattle", "speedMenu",
|
"speedOverworld", "speedBattle", "speedMenu",
|
||||||
"mods", "controls", "dateFormat", "timeFormat" }
|
"mods", "controls", "dateFormat", "timeFormat" }
|
||||||
|
local function orow(menu, id)
|
||||||
|
for _, row in ipairs(menu.rows) do
|
||||||
|
if row.id == id then return row end
|
||||||
|
end
|
||||||
|
error("no options row '" .. id .. "'")
|
||||||
|
end
|
||||||
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
|
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
|
||||||
for i, id in ipairs(WANT_IDS) do
|
for i, id in ipairs(WANT_IDS) do
|
||||||
check(om.rows[i].id == id, "options row order: " .. id)
|
check(om.rows[i].id == id, "options row order: " .. id)
|
||||||
@@ -296,11 +302,12 @@ end
|
|||||||
|
|
||||||
-- ruleset row cycles the sorted non-hidden registry ids showing name
|
-- ruleset row cycles the sorted non-hidden registry ids showing name
|
||||||
om.game.save.options.ruleset = "gen1_faithful"
|
om.game.save.options.ruleset = "gen1_faithful"
|
||||||
check(om.rows[8].value(om.game) == "GEN 1", "ruleset row shows record.name")
|
check(orow(om, "ruleset").value(om.game) == "GEN 1",
|
||||||
om.rows[8].step(om.game, 1)
|
"ruleset row shows record.name")
|
||||||
|
orow(om, "ruleset").step(om.game, 1)
|
||||||
check(om.game.save.options.ruleset == "modern_clean",
|
check(om.game.save.options.ruleset == "modern_clean",
|
||||||
"ruleset row cycles sorted registry ids")
|
"ruleset row cycles sorted registry ids")
|
||||||
om.rows[8].step(om.game, 1)
|
orow(om, "ruleset").step(om.game, 1)
|
||||||
check(om.game.save.options.ruleset == "gen1_faithful",
|
check(om.game.save.options.ruleset == "gen1_faithful",
|
||||||
"hidden rulesets are excluded from the cycle")
|
"hidden rulesets are excluded from the cycle")
|
||||||
|
|
||||||
@@ -319,42 +326,42 @@ check(om.game.save.options.battleLayout == "wide", "battle layout flips to WIDE"
|
|||||||
check(om.rows[4].value(om.game) == "WIDE", "the WIDE layout renders its label")
|
check(om.rows[4].value(om.game) == "WIDE", "the WIDE layout renders its label")
|
||||||
om.rows[4].step(om.game, 1)
|
om.rows[4].step(om.game, 1)
|
||||||
check(om.game.save.options.battleLayout == "og", "battle layout flips back")
|
check(om.game.save.options.battleLayout == "og", "battle layout flips back")
|
||||||
om.rows[9].step(om.game, -1)
|
orow(om, "musicVol").step(om.game, -1)
|
||||||
check(om.game.save.options.musicVol == 6, "music volume steps down")
|
check(om.game.save.options.musicVol == 6, "music volume steps down")
|
||||||
for _ = 1, 10 do om.rows[9].step(om.game, -1) end
|
for _ = 1, 10 do orow(om, "musicVol").step(om.game, -1) end
|
||||||
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
|
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
|
||||||
|
|
||||||
-- ZOOM / VOID FILL rows (indices track WANT_IDS above; the battle
|
-- ZOOM / VOID FILL rows (looked up by id; WANT_IDS above pins the order)
|
||||||
-- composition rows -- BATTLE SIZE / BATTLE BG / UI LAYOUT -- sit ahead of
|
|
||||||
-- RULESET, and FAITHFUL RATIO lands between VIDEO MODE and MAX FPS)
|
|
||||||
local Zoom = require("src.render.Zoom")
|
local Zoom = require("src.render.Zoom")
|
||||||
local TileRenderer = require("src.render.TileRenderer")
|
local TileRenderer = require("src.render.TileRenderer")
|
||||||
om.game.save.options.zoom = 0
|
om.game.save.options.zoom = 0
|
||||||
Zoom.offset = 0
|
Zoom.offset = 0
|
||||||
check(om.rows[16].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
|
check(orow(om, "zoom").value(om.game) == "FIT",
|
||||||
om.rows[16].step(om.game, 1)
|
"ZOOM row shows FIT at offset 0")
|
||||||
|
orow(om, "zoom").step(om.game, 1)
|
||||||
check(om.game.save.options.zoom == 1 and Zoom.offset == 1,
|
check(om.game.save.options.zoom == 1 and Zoom.offset == 1,
|
||||||
"ZOOM row steps to IN1")
|
"ZOOM row steps to IN1")
|
||||||
om.rows[17].step(om.game, 1)
|
orow(om, "voidFill").step(om.game, 1)
|
||||||
check(om.game.save.options.voidFill == "water"
|
check(om.game.save.options.voidFill == "water"
|
||||||
and TileRenderer.voidFill == "water",
|
and TileRenderer.voidFill == "water",
|
||||||
"VOID FILL row cycles TREES → WATER")
|
"VOID FILL row cycles TREES → WATER")
|
||||||
om.rows[17].step(om.game, 1)
|
orow(om, "voidFill").step(om.game, 1)
|
||||||
check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK")
|
check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK")
|
||||||
om.rows[17].step(om.game, 1)
|
orow(om, "voidFill").step(om.game, 1)
|
||||||
check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES")
|
check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES")
|
||||||
|
|
||||||
-- the MAX FPS row cycles the render-cap steps and shows the value plain
|
-- the MAX FPS row cycles the render-cap steps and shows the value plain
|
||||||
om.game.save.options.fpsCap = nil
|
om.game.save.options.fpsCap = nil
|
||||||
check(om.rows[20].value(om.game) == "60",
|
check(orow(om, "fpsCap").value(om.game) == "60",
|
||||||
"MAX FPS row defaults to 60 with no saved cap")
|
"MAX FPS row defaults to 60 with no saved cap")
|
||||||
om.rows[20].step(om.game, 1)
|
orow(om, "fpsCap").step(om.game, 1)
|
||||||
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
|
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
|
||||||
check(om.rows[20].value(om.game) == "75", "the MAX FPS row renders the cap")
|
check(orow(om, "fpsCap").value(om.game) == "75",
|
||||||
|
"the MAX FPS row renders the cap")
|
||||||
om.game.save.options.fpsCap = 160
|
om.game.save.options.fpsCap = 160
|
||||||
om.rows[20].step(om.game, 1)
|
orow(om, "fpsCap").step(om.game, 1)
|
||||||
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
|
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
|
||||||
om.rows[20].step(om.game, -1)
|
orow(om, "fpsCap").step(om.game, -1)
|
||||||
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
|
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
|
||||||
|
|
||||||
-- ------- FrameCap normalize / cycle (issue #88)
|
-- ------- FrameCap normalize / cycle (issue #88)
|
||||||
@@ -384,7 +391,7 @@ check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 6
|
|||||||
-- the MODS row is the manager's discoverable home
|
-- the MODS row is the manager's discoverable home
|
||||||
local mgGame = optGame()
|
local mgGame = optGame()
|
||||||
om = OptionsMenu.new(mgGame)
|
om = OptionsMenu.new(mgGame)
|
||||||
om.rows[24].activate(mgGame)
|
orow(om, "mods").activate(mgGame)
|
||||||
check(getmetatable(mgGame.stack:top()) == ManagerState,
|
check(getmetatable(mgGame.stack:top()) == ManagerState,
|
||||||
"the MODS row opens the manager")
|
"the MODS row opens the manager")
|
||||||
check(mgGame.stack:top().screenId == "ManagerState",
|
check(mgGame.stack:top().screenId == "ManagerState",
|
||||||
@@ -394,7 +401,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
|
|||||||
local BindingsMenu = require("src.ui.BindingsMenu")
|
local BindingsMenu = require("src.ui.BindingsMenu")
|
||||||
local cbGame = optGame()
|
local cbGame = optGame()
|
||||||
om = OptionsMenu.new(cbGame)
|
om = OptionsMenu.new(cbGame)
|
||||||
om.rows[25].activate(cbGame)
|
orow(om, "controls").activate(cbGame)
|
||||||
local bm = cbGame.stack:top()
|
local bm = cbGame.stack:top()
|
||||||
check(getmetatable(bm) == BindingsMenu,
|
check(getmetatable(bm) == BindingsMenu,
|
||||||
"the CONTROLS row opens the rebind list")
|
"the CONTROLS row opens the rebind list")
|
||||||
@@ -413,17 +420,17 @@ check(cbGame.save.options.bindings == nil,
|
|||||||
-- engine UI and mods without becoming checkpoint progress
|
-- engine UI and mods without becoming checkpoint progress
|
||||||
om.game.save.options.dateFormat = "device"
|
om.game.save.options.dateFormat = "device"
|
||||||
om.game.save.options.timeFormat = "device"
|
om.game.save.options.timeFormat = "device"
|
||||||
check(om.rows[26].value(om.game) == "DEVICE",
|
check(orow(om, "dateFormat").value(om.game) == "DEVICE",
|
||||||
"DATE FORMAT defaults to device locale")
|
"DATE FORMAT defaults to device locale")
|
||||||
om.rows[26].step(om.game, 1)
|
orow(om, "dateFormat").step(om.game, 1)
|
||||||
check(om.game.save.options.dateFormat == "dmy"
|
check(om.game.save.options.dateFormat == "dmy"
|
||||||
and om.rows[26].value(om.game) == "DD-MM-YYYY",
|
and orow(om, "dateFormat").value(om.game) == "DD-MM-YYYY",
|
||||||
"DATE FORMAT exposes deterministic DMY override")
|
"DATE FORMAT exposes deterministic DMY override")
|
||||||
check(om.rows[27].value(om.game) == "DEVICE",
|
check(orow(om, "timeFormat").value(om.game) == "DEVICE",
|
||||||
"TIME FORMAT defaults to device locale")
|
"TIME FORMAT defaults to device locale")
|
||||||
om.rows[27].step(om.game, 1)
|
orow(om, "timeFormat").step(om.game, 1)
|
||||||
check(om.game.save.options.timeFormat == "24h"
|
check(om.game.save.options.timeFormat == "24h"
|
||||||
and om.rows[27].value(om.game) == "24 HOUR",
|
and orow(om, "timeFormat").value(om.game) == "24 HOUR",
|
||||||
"TIME FORMAT exposes deterministic 24-hour override")
|
"TIME FORMAT exposes deterministic 24-hour override")
|
||||||
check(bm.onKeyPressed == nil and bm.onGamepadPressed == nil,
|
check(bm.onKeyPressed == nil and bm.onGamepadPressed == nil,
|
||||||
"no raw-input claim until a capture is armed")
|
"no raw-input claim until a capture is armed")
|
||||||
@@ -583,11 +590,19 @@ check(not fpm.submenu and forced == fgame.save.party[1],
|
|||||||
|
|
||||||
-- ------- issues #320/#385: the STRENGTH texts print over the party menu
|
-- ------- issues #320/#385: the STRENGTH texts print over the party menu
|
||||||
do
|
do
|
||||||
|
-- PartyMenu delegates the move to OverworldState:useStrengthFieldMove;
|
||||||
|
-- parity_I_M covers that side, this one covers what the menu does after
|
||||||
local owStub = { strengthActive = false,
|
local owStub = { strengthActive = false,
|
||||||
map = { def = { tileset = "OVERWORLD" } }, dark = false,
|
map = { def = { tileset = "OVERWORLD" } }, dark = false,
|
||||||
partyKnows = function(self, id) return self.knows == id end,
|
partyKnows = function(self, id) return self.knows == id end,
|
||||||
knows = "STRENGTH" }
|
knows = "STRENGTH" }
|
||||||
local sgame = partyGame()
|
local sgame = partyGame()
|
||||||
|
owStub.useStrengthFieldMove = function(self, _mon, onClose)
|
||||||
|
self.strengthActive = true
|
||||||
|
sgame.stack:push(require("src.render.TextBox").new(
|
||||||
|
sgame, "used\nSTRENGTH.", onClose))
|
||||||
|
return true
|
||||||
|
end
|
||||||
sgame.overworld = owStub
|
sgame.overworld = owStub
|
||||||
sgame.data.text = {} -- the strength texts fall back to Strings sources
|
sgame.data.text = {} -- the strength texts fall back to Strings sources
|
||||||
sgame.save.inventory.RAINBOWBADGE = 1
|
sgame.save.inventory.RAINBOWBADGE = 1
|
||||||
@@ -1233,7 +1248,7 @@ local Loader = require("src.mods.Loader")
|
|||||||
local uiFiles = {
|
local uiFiles = {
|
||||||
["mods/uikit/manifest.json"] =
|
["mods/uikit/manifest.json"] =
|
||||||
'{"id":"uikit","name":"uikit","version":"1.0.0","entry":"main.lua","api":2}',
|
'{"id":"uikit","name":"uikit","version":"1.0.0","entry":"main.lua","api":2}',
|
||||||
["mods/uikit/main.lua"] = "return function(mod) _G.MOD_UI_API = mod end",
|
["mods/uikit/main.lua"] = "return function(mod) mod.exports.api = mod end",
|
||||||
}
|
}
|
||||||
local uiFs = {
|
local uiFs = {
|
||||||
read = function(path) return uiFiles[path] end,
|
read = function(path) return uiFiles[path] end,
|
||||||
@@ -1267,8 +1282,7 @@ local uiFs = {
|
|||||||
}
|
}
|
||||||
local uiLoader = Loader.new({ fs = uiFs })
|
local uiLoader = Loader.new({ fs = uiFs })
|
||||||
check(uiLoader:load({}) == true, "the uikit fixture loads clean")
|
check(uiLoader:load({}) == true, "the uikit fixture loads clean")
|
||||||
local uiApi = _G.MOD_UI_API
|
local uiApi = (uiLoader.exports.uikit or {}).api
|
||||||
_G.MOD_UI_API = nil
|
|
||||||
check(uiApi ~= nil, "the entry chunk received its api")
|
check(uiApi ~= nil, "the entry chunk received its api")
|
||||||
check(uiApi.ui == ModUI, "mod.ui is the toolkit facade")
|
check(uiApi.ui == ModUI, "mod.ui is the toolkit facade")
|
||||||
check(uiApi.ui.Theme == Theme, "mod.ui.Theme reaches the theme module")
|
check(uiApi.ui.Theme == Theme, "mod.ui.Theme reaches the theme module")
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ do
|
|||||||
function tb:enemyAction() return { special = "bound" } end
|
function tb:enemyAction() return { special = "bound" } end
|
||||||
tb:resolveSwitch(Game.save.party[2])
|
tb:resolveSwitch(Game.save.party[2])
|
||||||
acts[1]() -- send-out clears foe trap
|
acts[1]() -- send-out clears foe trap
|
||||||
|
acts[#acts]()
|
||||||
eq(tb.enemy.trappingTurns, nil, "player switch clears foe Wrap/Bind/etc.")
|
eq(tb.enemy.trappingTurns, nil, "player switch clears foe Wrap/Bind/etc.")
|
||||||
eq(tb.enemy.trapMove, nil, "player switch clears trapMove")
|
eq(tb.enemy.trapMove, nil, "player switch clears trapMove")
|
||||||
check(tb:fightLockedAction(tb.player) == nil,
|
check(tb:fightLockedAction(tb.player) == nil,
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ eq(next(t), nil, "already-left Route 25 enter is a no-op")
|
|||||||
Commands.hide_object = realHide
|
Commands.hide_object = realHide
|
||||||
Commands.show_object = realShow
|
Commands.show_object = realShow
|
||||||
package.loaded["src.ui.Menu"] = realMenu
|
package.loaded["src.ui.Menu"] = realMenu
|
||||||
if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic end
|
package.loaded["src.core.Music"] = realMusic
|
||||||
if realSound ~= nil then package.loaded["src.core.Sound"] = realSound end
|
package.loaded["src.core.Sound"] = realSound
|
||||||
|
|
||||||
S.finish()
|
S.finish()
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ local WILD_SONG = Data.audio.battle.wild
|
|||||||
-- every song request in order: "the theme was never restored" and "the theme
|
-- every song request in order: "the theme was never restored" and "the theme
|
||||||
-- was never started" have to read differently. Music.playMap still sets the
|
-- was never started" have to read differently. Music.playMap still sets the
|
||||||
-- state Music.restoreMap reads, so the real restore path is under test.
|
-- state Music.restoreMap reads, so the real restore path is under test.
|
||||||
|
-- singletons this file stands doubles on; the originals go back at the tail,
|
||||||
|
-- or a later suite in the same process inherits them (a stubbed startWarpTo
|
||||||
|
-- eats every warp after this one)
|
||||||
|
local realPlay, realPlayBattle = Music.play, Music.playBattle
|
||||||
|
local realStartWarpTo = OW.startWarpTo
|
||||||
|
local realEvents = Runtime.events
|
||||||
|
|
||||||
local songs = {}
|
local songs = {}
|
||||||
Music.play = function(_, song) songs[#songs + 1] = song end
|
Music.play = function(_, song) songs[#songs + 1] = song end
|
||||||
local function lastSong() return songs[#songs] end
|
local function lastSong() return songs[#songs] end
|
||||||
@@ -160,4 +167,8 @@ eq(labResult, "lose", "it still finishes as a loss")
|
|||||||
eq(Game.save.money, 3000, "no money is lost in the lab")
|
eq(Game.save.money, 3000, "no money is lost in the lab")
|
||||||
eq(warp, nil, "and the player stays in the lab for OaksLabRivalEndBattleScript")
|
eq(warp, nil, "and the player stays in the lab for OaksLabRivalEndBattleScript")
|
||||||
|
|
||||||
|
OW.startWarpTo = realStartWarpTo
|
||||||
|
Music.play, Music.playBattle = realPlay, realPlayBattle
|
||||||
|
Runtime.install(realEvents, Runtime.hooks)
|
||||||
|
|
||||||
S.finish()
|
S.finish()
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ local function battleWith(partyHP, result)
|
|||||||
for i, hp in ipairs(partyHP) do
|
for i, hp in ipairs(partyHP) do
|
||||||
party[i] = { species = "SQUIRTLE", hp = hp, stats = { hp = 20 } }
|
party[i] = { species = "SQUIRTLE", hp = hp, stats = { hp = 20 } }
|
||||||
end
|
end
|
||||||
return {
|
-- the metatable so playerMonFainted can reach playerPartyView
|
||||||
|
return setmetatable({
|
||||||
kind = "wild",
|
kind = "wild",
|
||||||
result = result,
|
result = result,
|
||||||
afterQueue = nil,
|
afterQueue = nil,
|
||||||
@@ -44,7 +45,7 @@ local function battleWith(partyHP, result)
|
|||||||
sayNext = function(self, m) self.said[#self.said + 1] = m end,
|
sayNext = function(self, m) self.said[#self.said + 1] = m end,
|
||||||
say = function(self, m) self.said[#self.said + 1] = m end,
|
say = function(self, m) self.said[#self.said + 1] = m end,
|
||||||
ui = function() end,
|
ui = function() end,
|
||||||
}
|
}, BattleState)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function saidBlackout(b)
|
local function saidBlackout(b)
|
||||||
|
|||||||
+36
-41
@@ -2642,7 +2642,6 @@ do
|
|||||||
local Tilt = require("src.render.Tilt")
|
local Tilt = require("src.render.Tilt")
|
||||||
local GBCFX = require("src.render.GBCFX")
|
local GBCFX = require("src.render.GBCFX")
|
||||||
local GameSpeed = require("src.core.GameSpeed")
|
local GameSpeed = require("src.core.GameSpeed")
|
||||||
local VideoMode = require("src.core.VideoMode")
|
|
||||||
local FrameCap = require("src.core.FrameCap")
|
local FrameCap = require("src.core.FrameCap")
|
||||||
local SD = require("src.core.SaveData")
|
local SD = require("src.core.SaveData")
|
||||||
-- Isolate from earlier save/options writes in this suite
|
-- Isolate from earlier save/options writes in this suite
|
||||||
@@ -2663,6 +2662,19 @@ do
|
|||||||
om:update(1 / 60)
|
om:update(1 / 60)
|
||||||
OInput.pressed = {}
|
OInput.pressed = {}
|
||||||
end
|
end
|
||||||
|
-- walk the cursor down to a row by id, so a row added to OptionsMenu
|
||||||
|
-- shifts these blocks instead of silently retargeting them
|
||||||
|
local function seek(id)
|
||||||
|
local want = -1
|
||||||
|
for i, row in ipairs(om.rows) do
|
||||||
|
if row.id == id then want = i end
|
||||||
|
end
|
||||||
|
for _ = 1, #om.rows do
|
||||||
|
if om.index == want then break end
|
||||||
|
press("down")
|
||||||
|
end
|
||||||
|
return om.index == want
|
||||||
|
end
|
||||||
eq(og.save.options.textSpeed, 3,
|
eq(og.save.options.textSpeed, 3,
|
||||||
"new saves default to MEDIUM text (InitOptions TEXT_DELAY_MEDIUM)")
|
"new saves default to MEDIUM text (InitOptions TEXT_DELAY_MEDIUM)")
|
||||||
eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC")
|
eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC")
|
||||||
@@ -2673,65 +2685,58 @@ do
|
|||||||
eq(og.save.options.videoMode, "windowed",
|
eq(og.save.options.videoMode, "windowed",
|
||||||
"new saves default VIDEO MODE to WINDOWED")
|
"new saves default VIDEO MODE to WINDOWED")
|
||||||
eq(om.scroll, 0, "options viewport starts at the top")
|
eq(om.scroll, 0, "options viewport starts at the top")
|
||||||
for _ = 1, 3 do press("down") end
|
check(seek("battleLayout"), "cursor reaches BATTLE LAYOUT")
|
||||||
eq(om.index, 4, "cursor reaches BATTLE LAYOUT")
|
|
||||||
press("a")
|
press("a")
|
||||||
eq(og.save.options.battleLayout, "wide",
|
eq(og.save.options.battleLayout, "wide",
|
||||||
"A switches the battle screen to the WIDE layout")
|
"A switches the battle screen to the WIDE layout")
|
||||||
press("a")
|
press("a")
|
||||||
eq(og.save.options.battleLayout, "og", "BATTLE LAYOUT wraps back to OG")
|
eq(og.save.options.battleLayout, "og", "BATTLE LAYOUT wraps back to OG")
|
||||||
for _ = 1, 5 do press("down") end
|
check(seek("musicVol"), "cursor reaches MUSIC VOL")
|
||||||
eq(om.index, 9, "cursor reaches MUSIC VOL")
|
eq(om.scroll, om.index - require("src.ui.OptionRows").VISIBLE,
|
||||||
eq(om.scroll, 5, "viewport scrolls to keep MUSIC VOL on screen")
|
"viewport scrolls to keep MUSIC VOL on screen")
|
||||||
press("left")
|
press("left")
|
||||||
eq(og.save.options.musicVol, 6, "left lowers MUSIC VOL")
|
eq(og.save.options.musicVol, 6, "left lowers MUSIC VOL")
|
||||||
press("right")
|
press("right")
|
||||||
eq(og.save.options.musicVol, 7, "right raises MUSIC VOL back")
|
eq(og.save.options.musicVol, 7, "right raises MUSIC VOL back")
|
||||||
press("right")
|
press("right")
|
||||||
eq(og.save.options.musicVol, 7, "MUSIC VOL clamps at 7")
|
eq(og.save.options.musicVol, 7, "MUSIC VOL clamps at 7")
|
||||||
press("down"); press("left")
|
seek("sfxVol"); press("left")
|
||||||
eq(og.save.options.sfxVol, 6, "SFX VOL adjusts on its own row")
|
eq(og.save.options.sfxVol, 6, "SFX VOL adjusts on its own row")
|
||||||
press("down")
|
seek("musicFilter")
|
||||||
for _ = 1, 3 do press("a") end
|
for _ = 1, 3 do press("a") end
|
||||||
eq(og.save.options.musicFilter, 3, "A cycles MUSIC FILTER to 3X")
|
eq(og.save.options.musicFilter, 3, "A cycles MUSIC FILTER to 3X")
|
||||||
press("a")
|
press("a")
|
||||||
eq(og.save.options.musicFilter, 0, "MUSIC FILTER wraps back to OFF")
|
eq(og.save.options.musicFilter, 0, "MUSIC FILTER wraps back to OFF")
|
||||||
press("down")
|
check(seek("performance"), "cursor reaches PERFORMANCE")
|
||||||
eq(om.index, 12, "cursor reaches PERFORMANCE")
|
|
||||||
press("a")
|
press("a")
|
||||||
eq(og.save.options.performance, "high", "A cycles PERFORMANCE to HIGH")
|
eq(og.save.options.performance, "high", "A cycles PERFORMANCE to HIGH")
|
||||||
eq(require("src.core.Performance").tier, "high",
|
eq(require("src.core.Performance").tier, "high",
|
||||||
"the live tier tracks the PERFORMANCE option")
|
"the live tier tracks the PERFORMANCE option")
|
||||||
for _ = 1, 3 do press("a") end
|
for _ = 1, 3 do press("a") end
|
||||||
eq(og.save.options.performance, "auto", "PERFORMANCE wraps back to AUTO")
|
eq(og.save.options.performance, "auto", "PERFORMANCE wraps back to AUTO")
|
||||||
press("down")
|
check(seek("colors"), "cursor reaches COLORS")
|
||||||
eq(om.index, 13, "cursor reaches COLORS")
|
|
||||||
press("a")
|
press("a")
|
||||||
for _ = 1, 4 do press("a") end
|
for _ = 1, 4 do press("a") end
|
||||||
press("down")
|
check(seek("tilt"), "cursor reaches TILT")
|
||||||
eq(om.index, 14, "cursor reaches TILT")
|
|
||||||
press("a")
|
press("a")
|
||||||
eq(og.save.options.tilt, 1, "A cycles TILT to 15")
|
eq(og.save.options.tilt, 1, "A cycles TILT to 15")
|
||||||
eq(Tilt.level, 1, "Tilt level tracks TILT option")
|
eq(Tilt.level, 1, "Tilt level tracks TILT option")
|
||||||
press("a"); press("a"); press("a")
|
press("a"); press("a"); press("a")
|
||||||
eq(og.save.options.tilt, 0, "TILT wraps back to OFF")
|
eq(og.save.options.tilt, 0, "TILT wraps back to OFF")
|
||||||
press("down")
|
check(seek("gbcfx"), "cursor reaches GBC FX")
|
||||||
eq(om.index, 15, "cursor reaches GBC FX")
|
|
||||||
press("a")
|
press("a")
|
||||||
eq(og.save.options.gbcfx, 1, "A cycles GBC FX to 1")
|
eq(og.save.options.gbcfx, 1, "A cycles GBC FX to 1")
|
||||||
eq(GBCFX.level, 1, "GBCFX level tracks GBC FX option")
|
eq(GBCFX.level, 1, "GBCFX level tracks GBC FX option")
|
||||||
for _ = 1, 4 do press("a") end
|
for _ = 1, 4 do press("a") end
|
||||||
eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF")
|
eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF")
|
||||||
press("down")
|
check(seek("zoom"), "cursor reaches ZOOM")
|
||||||
eq(om.index, 16, "cursor reaches ZOOM")
|
|
||||||
local ZoomOpt = require("src.render.Zoom")
|
local ZoomOpt = require("src.render.Zoom")
|
||||||
press("a")
|
press("a")
|
||||||
eq(og.save.options.zoom, 1, "A cycles ZOOM to IN1")
|
eq(og.save.options.zoom, 1, "A cycles ZOOM to IN1")
|
||||||
eq(ZoomOpt.offset, 1, "Zoom.offset tracks ZOOM option")
|
eq(ZoomOpt.offset, 1, "Zoom.offset tracks ZOOM option")
|
||||||
press("left")
|
press("left")
|
||||||
eq(og.save.options.zoom, 0, "left steps ZOOM back to FIT")
|
eq(og.save.options.zoom, 0, "left steps ZOOM back to FIT")
|
||||||
press("down")
|
check(seek("voidFill"), "cursor reaches VOID FILL")
|
||||||
eq(om.index, 17, "cursor reaches VOID FILL")
|
|
||||||
local TR = require("src.render.TileRenderer")
|
local TR = require("src.render.TileRenderer")
|
||||||
press("a")
|
press("a")
|
||||||
eq(og.save.options.voidFill, "water", "A cycles VOID FILL to WATER")
|
eq(og.save.options.voidFill, "water", "A cycles VOID FILL to WATER")
|
||||||
@@ -2740,18 +2745,15 @@ do
|
|||||||
eq(og.save.options.voidFill, "black", "A cycles VOID FILL to BLACK")
|
eq(og.save.options.voidFill, "black", "A cycles VOID FILL to BLACK")
|
||||||
press("a")
|
press("a")
|
||||||
eq(og.save.options.voidFill, "trees", "VOID FILL wraps back to TREES")
|
eq(og.save.options.voidFill, "trees", "VOID FILL wraps back to TREES")
|
||||||
press("down")
|
check(seek("videoMode"), "cursor reaches VIDEO MODE")
|
||||||
eq(om.index, 18, "cursor reaches VIDEO MODE")
|
|
||||||
press("a")
|
press("a")
|
||||||
eq(og.save.options.videoMode, "borderless",
|
eq(og.save.options.videoMode, "borderless",
|
||||||
"A cycles VIDEO MODE to BORDERLESS")
|
"A cycles VIDEO MODE to BORDERLESS")
|
||||||
press("a")
|
press("a")
|
||||||
eq(og.save.options.videoMode, "windowed",
|
eq(og.save.options.videoMode, "windowed",
|
||||||
"VIDEO MODE wraps back to WINDOWED")
|
"VIDEO MODE wraps back to WINDOWED")
|
||||||
press("down")
|
check(seek("faithfulRes"), "cursor reaches FAITHFUL RATIO")
|
||||||
eq(om.index, 19, "cursor reaches FAITHFUL RATIO")
|
check(seek("fpsCap"), "cursor reaches MAX FPS")
|
||||||
press("down")
|
|
||||||
eq(om.index, 20, "cursor reaches MAX FPS")
|
|
||||||
press("a")
|
press("a")
|
||||||
eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75")
|
eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75")
|
||||||
eq(FrameCap.current, 75, "the live render cap tracks the MAX FPS option")
|
eq(FrameCap.current, 75, "the live render cap tracks the MAX FPS option")
|
||||||
@@ -2761,8 +2763,7 @@ do
|
|||||||
eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60")
|
eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60")
|
||||||
-- RFC 0007: the single GAME SPEED row is now three independent rows,
|
-- RFC 0007: the single GAME SPEED row is now three independent rows,
|
||||||
-- one per GameSpeed.CATEGORIES entry.
|
-- one per GameSpeed.CATEGORIES entry.
|
||||||
press("down")
|
check(seek("speedOverworld"), "cursor reaches OVERWORLD SPEED")
|
||||||
eq(om.index, 21, "cursor reaches OVERWORLD SPEED")
|
|
||||||
press("a")
|
press("a")
|
||||||
eq(og.save.options.speedOverworld, 2, "A cycles OVERWORLD SPEED to 2X")
|
eq(og.save.options.speedOverworld, 2, "A cycles OVERWORLD SPEED to 2X")
|
||||||
-- Driven by the level list rather than a literal press count: adding a
|
-- Driven by the level list rather than a literal press count: adding a
|
||||||
@@ -2770,26 +2771,20 @@ do
|
|||||||
-- bug when the cycling is fine and the row is simply one longer.
|
-- bug when the cycling is fine and the row is simply one longer.
|
||||||
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
|
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
|
||||||
eq(og.save.options.speedOverworld, 1, "OVERWORLD SPEED wraps back to NORMAL")
|
eq(og.save.options.speedOverworld, 1, "OVERWORLD SPEED wraps back to NORMAL")
|
||||||
press("down")
|
check(seek("speedBattle"), "cursor reaches BATTLE SPEED")
|
||||||
eq(om.index, 22, "cursor reaches BATTLE SPEED")
|
|
||||||
press("a")
|
press("a")
|
||||||
eq(og.save.options.speedBattle, 2, "A cycles BATTLE SPEED to 2X")
|
eq(og.save.options.speedBattle, 2, "A cycles BATTLE SPEED to 2X")
|
||||||
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
|
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
|
||||||
eq(og.save.options.speedBattle, 1, "BATTLE SPEED wraps back to NORMAL")
|
eq(og.save.options.speedBattle, 1, "BATTLE SPEED wraps back to NORMAL")
|
||||||
press("down")
|
check(seek("speedMenu"), "cursor reaches MENU SPEED")
|
||||||
eq(om.index, 23, "cursor reaches MENU SPEED")
|
|
||||||
press("a")
|
press("a")
|
||||||
eq(og.save.options.speedMenu, 2, "A cycles MENU SPEED to 2X")
|
eq(og.save.options.speedMenu, 2, "A cycles MENU SPEED to 2X")
|
||||||
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
|
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
|
||||||
eq(og.save.options.speedMenu, 1, "MENU SPEED wraps back to NORMAL")
|
eq(og.save.options.speedMenu, 1, "MENU SPEED wraps back to NORMAL")
|
||||||
press("down")
|
check(seek("mods"), "cursor reaches MODS")
|
||||||
eq(om.index, 24, "cursor reaches MODS")
|
check(seek("controls"), "cursor reaches CONTROLS")
|
||||||
press("down")
|
check(seek("dateFormat"), "cursor reaches DATE FORMAT")
|
||||||
eq(om.index, 25, "cursor reaches CONTROLS")
|
check(seek("timeFormat"), "cursor reaches TIME FORMAT")
|
||||||
press("down")
|
|
||||||
eq(om.index, 26, "cursor reaches DATE FORMAT")
|
|
||||||
press("down")
|
|
||||||
eq(om.index, 27, "cursor reaches TIME FORMAT")
|
|
||||||
press("down")
|
press("down")
|
||||||
-- CANCEL is appended after the descriptor list rather than living in it, so
|
-- CANCEL is appended after the descriptor list rather than living in it, so
|
||||||
-- it lands one past #rows and the window holds the last six boxes. Counted
|
-- it lands one past #rows and the window holds the last six boxes. Counted
|
||||||
@@ -2812,7 +2807,7 @@ do
|
|||||||
GBCFX.applyOptions(og.save.options)
|
GBCFX.applyOptions(og.save.options)
|
||||||
require("src.render.Zoom").applyOptions(og.save.options)
|
require("src.render.Zoom").applyOptions(og.save.options)
|
||||||
require("src.render.TileRenderer").applyOptions(og.save.options)
|
require("src.render.TileRenderer").applyOptions(og.save.options)
|
||||||
VideoMode.applyOptions(og.save.options)
|
require("src.core.VideoMode").applyOptions(og.save.options)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user