mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 08:11:35 +02:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73fbaaa250 | |||
| 9469e39926 | |||
| 179048a58e | |||
| e1f5c2b217 | |||
| 1d8ac1e692 | |||
| 39df5bdfa6 | |||
| 4b7a4daf2c | |||
| e1d233d026 | |||
| c22888a7fd | |||
| 0e4fc3c54a | |||
| 82b91e36ca | |||
| 3588a5f3fe | |||
| 992dc80aa7 | |||
| 6e7073d32b | |||
| 000e691966 | |||
| fdbc51c5d1 | |||
| 3e3566d1b4 | |||
| fcb5d1d348 | |||
| b45d783dee | |||
| d99072b44e | |||
| 0ac55b0f9e | |||
| ab48572852 |
@@ -46,7 +46,11 @@ when the runtime schema is absent. The supported row types are `toggle`,
|
||||
`choice`, `number`, and `text`. Their optional fields retain the meanings
|
||||
established by the existing in-game option UI: choices are `[label, value]`
|
||||
pairs, numeric rows may provide `min`, `max`, and `step`, and text rows may
|
||||
provide `maxLen`.
|
||||
provide `maxLen`. A row may also use
|
||||
`visible_if = {key = "mode", equals = "compact"}` or replace `equals` with
|
||||
`not_equals`. This only hides the in-game menu row; the schema and stored value
|
||||
remain available, and consumers that do not implement conditions may ignore
|
||||
the field.
|
||||
|
||||
Only mods that are enabled and successfully loaded in the current boot are
|
||||
included. A disabled or failed mod must not contribute rows. If an older
|
||||
@@ -69,9 +73,10 @@ it may ignore an unknown row type or optional field.
|
||||
For compatibility with files produced by the original unversioned prototype,
|
||||
a missing `schema_version` means version 1. Consumers must ignore documents
|
||||
with a newer version rather than guessing at their shape. Producers must bump
|
||||
the version whenever they change the document shape or the meaning of an
|
||||
existing field. Version 1 is therefore the legacy unversioned format as well
|
||||
as the explicitly versioned format shown above.
|
||||
the version whenever they change the document envelope or the meaning of an
|
||||
existing field. New optional row fields that older consumers can safely ignore
|
||||
do not require a bump. Version 1 is therefore the legacy unversioned format as
|
||||
well as the explicitly versioned format shown above.
|
||||
|
||||
## Migration note
|
||||
|
||||
|
||||
@@ -226,6 +226,8 @@ exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
|
||||
contextual `squirtbottle` key item. Fishing rows include the owned rods that
|
||||
are valid choices. The list is empty while the world is busy, and omits an
|
||||
action whenever its item, move, badge, terrain, or engine state forbids it.
|
||||
The optional second return is `"world is busy"` during transient input locks
|
||||
or `"no overworld"` before a playable world exists.
|
||||
|
||||
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
|
||||
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
|
||||
@@ -235,6 +237,52 @@ not need generation-specific badge, terrain, bike, fishing, or field-move
|
||||
logic. Action lists are extensible; callers should render the records they
|
||||
understand and ignore unknown ids rather than assuming a fixed list length.
|
||||
|
||||
## Read-only battle snapshots
|
||||
|
||||
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
||||
record while one is active. Gen 1 (Red, Blue, and Yellow) and Gold expose the
|
||||
same core fields:
|
||||
`revision`, `kind`, `catchable`, `prompt`, `message`, `turn`, `player`,
|
||||
`enemy`, `party`, `moves`, and `items`. Pokémon, moves, messages, and items in
|
||||
the result are detached records; changing them cannot change the battle.
|
||||
`revision` stays stable while the visible battle context is unchanged and
|
||||
advances when it changes, so a UI can skip rebuilding an identical view.
|
||||
|
||||
Pokémon records contain `species`, `name`, `level`, `hp`, `maxHp`, `status`,
|
||||
and `active` (plus `slot` in `party`). Move records contain `slot`, `id`,
|
||||
`name`, `pp`, `maxPp`, `type`, `power`, `accuracy`, and `disabled`. Gen 1 also
|
||||
reports the actual ruleset-aware `displayPower`, `hitChance` percentage, and
|
||||
`effectiveness` multiplier (`10` neutral, `20` super-effective, `5`
|
||||
resisted). Item rows contain `id`, `name`, `count`, `ball`, `needsTarget`, and
|
||||
an optional stock `catchChance` percentage.
|
||||
|
||||
`prompt` describes the currently visible choice (`menu`, `moves`, `party`,
|
||||
`advance`, `safari`, or `mimic`) and is `locked` when another screen or battle
|
||||
phase owns input. Generation-specific features remain optional: Gen 1 includes
|
||||
battle medicine, balls, catch previews, Safari balls, and Mimic choices;
|
||||
Gold currently returns an empty `items` list rather than guessing at its
|
||||
pocketed PACK flow. Callers should ignore unknown fields and tolerate absent
|
||||
optional ones.
|
||||
|
||||
## Battle menu intents
|
||||
|
||||
`mod.battle:submit(intent)` applies a validated choice to the snapshot the mod
|
||||
just read. Every intent needs a mod-owned, strictly increasing positive
|
||||
integer `id` and the latest snapshot `revision`. Stale, replayed, covered, or
|
||||
invalid choices return `nil` plus a reason without changing the battle.
|
||||
|
||||
The shared Red, Blue, Yellow, and Gold intents are:
|
||||
|
||||
- `{ kind = "menu", choice = "fight" }` (`party`, `item`, and `run` are the
|
||||
other accepted choices)
|
||||
- `{ kind = "move", slot = 1..4 }`
|
||||
- `{ kind = "back" }` while the move menu is active
|
||||
|
||||
Menu choices and moves use the same engine methods as the native controls;
|
||||
`party` and `item` open the native screens rather than exposing or duplicating
|
||||
their mutable logic. Tutorial, link, Safari, forced, stale, and covered battle
|
||||
states refuse these core intents. Use `mod.input` for ordinary text advance.
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||
|
||||
@@ -154,6 +154,16 @@ love::image::ImageData *Canvas::newImageData(love::image::Image *module, int sli
|
||||
return module->newImageData(r.w, r.h, dataformat);
|
||||
}
|
||||
|
||||
bool Canvas::requestImageData()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
love::image::ImageData *Canvas::pollImageData(love::image::Image *)
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void Canvas::draw(Graphics *gfx, Quad *q, const Matrix4 &t)
|
||||
{
|
||||
if (gfx->isCanvasActive(this))
|
||||
@@ -232,4 +242,3 @@ StringMap<Canvas::SettingType, Canvas::SETTING_MAX_ENUM> Canvas::settingTypes(Ca
|
||||
|
||||
} // graphics
|
||||
} // love
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ public:
|
||||
int getRequestedMSAA() const;
|
||||
|
||||
virtual love::image::ImageData *newImageData(love::image::Image *module, int slice, int mipmap, const Rect &rect);
|
||||
virtual bool requestImageData();
|
||||
virtual love::image::ImageData *pollImageData(love::image::Image *module);
|
||||
virtual void generateMipmaps() = 0;
|
||||
|
||||
virtual int getMSAA() const = 0;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include "Graphics.h"
|
||||
|
||||
#include <algorithm> // For min/max
|
||||
#include <cstring>
|
||||
|
||||
namespace love
|
||||
{
|
||||
@@ -197,6 +198,9 @@ Canvas::Canvas(const Settings &settings)
|
||||
, texture(0)
|
||||
, renderbuffer(0)
|
||||
, actualSamples(0)
|
||||
, readbackBuffer(0)
|
||||
, readbackFence(nullptr)
|
||||
, readbackSize(0)
|
||||
{
|
||||
format = getSizedFormat(format);
|
||||
|
||||
@@ -314,6 +318,14 @@ bool Canvas::loadVolatile()
|
||||
|
||||
void Canvas::unloadVolatile()
|
||||
{
|
||||
if (readbackFence != nullptr)
|
||||
glDeleteSync(readbackFence);
|
||||
if (readbackBuffer != 0)
|
||||
glDeleteBuffers(1, &readbackBuffer);
|
||||
readbackFence = nullptr;
|
||||
readbackBuffer = 0;
|
||||
readbackSize = 0;
|
||||
|
||||
if (fbo != 0 || renderbuffer != 0 || texture != 0)
|
||||
{
|
||||
// This is a bit ugly, but we need some way to destroy the cached FBO
|
||||
@@ -480,6 +492,68 @@ love::image::ImageData *Canvas::newImageData(love::image::Image *module, int sli
|
||||
return data;
|
||||
}
|
||||
|
||||
bool Canvas::requestImageData()
|
||||
{
|
||||
if (readbackFence != nullptr || !isReadable()
|
||||
|| !(GLAD_ES_VERSION_3_0 || GLAD_VERSION_3_2)
|
||||
|| texType != TEXTURE_2D
|
||||
|| (format != PIXELFORMAT_RGBA8 && format != PIXELFORMAT_sRGBA8)
|
||||
|| actualSamples > 0)
|
||||
return false;
|
||||
|
||||
auto gfx = Module::getInstance<Graphics>(Module::M_GRAPHICS);
|
||||
if (gfx != nullptr && gfx->isCanvasActive(this))
|
||||
throw love::Exception("Canvas:requestImageData cannot be called while that Canvas is active.");
|
||||
|
||||
const size_t size = (size_t) pixelWidth * (size_t) pixelHeight * 4;
|
||||
if (readbackBuffer == 0)
|
||||
glGenBuffers(1, &readbackBuffer);
|
||||
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, readbackBuffer);
|
||||
if (readbackSize != size)
|
||||
{
|
||||
glBufferData(GL_PIXEL_PACK_BUFFER, size, nullptr, GL_STREAM_READ);
|
||||
readbackSize = size;
|
||||
}
|
||||
|
||||
GLuint currentfbo = gl.getFramebuffer(OpenGL::FRAMEBUFFER_ALL);
|
||||
gl.bindFramebuffer(OpenGL::FRAMEBUFFER_ALL, getFBO());
|
||||
glReadPixels(0, 0, pixelWidth, pixelHeight, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
|
||||
readbackFence = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
|
||||
gl.bindFramebuffer(OpenGL::FRAMEBUFFER_ALL, currentfbo);
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
|
||||
return readbackFence != nullptr;
|
||||
}
|
||||
|
||||
love::image::ImageData *Canvas::pollImageData(love::image::Image *module)
|
||||
{
|
||||
if (readbackFence == nullptr)
|
||||
return nullptr;
|
||||
|
||||
GLenum status = glClientWaitSync(readbackFence, 0, 0);
|
||||
if (status == GL_TIMEOUT_EXPIRED)
|
||||
return nullptr;
|
||||
|
||||
glDeleteSync(readbackFence);
|
||||
readbackFence = nullptr;
|
||||
if (status == GL_WAIT_FAILED)
|
||||
return nullptr;
|
||||
|
||||
love::image::ImageData *data = module->newImageData(pixelWidth, pixelHeight, PIXELFORMAT_RGBA8);
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, readbackBuffer);
|
||||
void *pixels = glMapBufferRange(GL_PIXEL_PACK_BUFFER, 0, readbackSize, GL_MAP_READ_BIT);
|
||||
if (pixels == nullptr)
|
||||
{
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
|
||||
data->release();
|
||||
throw love::Exception("Could not map asynchronous Canvas readback.");
|
||||
}
|
||||
memcpy(data->getData(), pixels, readbackSize);
|
||||
glUnmapBuffer(GL_PIXEL_PACK_BUFFER);
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
|
||||
return data;
|
||||
}
|
||||
|
||||
void Canvas::generateMipmaps()
|
||||
{
|
||||
if (getMipmapCount() == 1 || getMipmapMode() == MIPMAPS_NONE)
|
||||
|
||||
@@ -54,6 +54,8 @@ public:
|
||||
ptrdiff_t getHandle() const override;
|
||||
|
||||
love::image::ImageData *newImageData(love::image::Image *module, int slice, int mipmap, const Rect &rect) override;
|
||||
bool requestImageData() override;
|
||||
love::image::ImageData *pollImageData(love::image::Image *module) override;
|
||||
void generateMipmaps() override;
|
||||
|
||||
int getMSAA() const override
|
||||
@@ -107,6 +109,9 @@ private:
|
||||
GLenum status;
|
||||
|
||||
int actualSamples;
|
||||
GLuint readbackBuffer;
|
||||
GLsync readbackFence;
|
||||
size_t readbackSize;
|
||||
|
||||
static SupportedFormat supportedFormats[PIXELFORMAT_MAX_ENUM];
|
||||
static SupportedFormat checkedFormats[PIXELFORMAT_MAX_ENUM];
|
||||
|
||||
@@ -116,6 +116,31 @@ int w_Canvas_newImageData(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_Canvas_requestImageData(lua_State *L)
|
||||
{
|
||||
Canvas *canvas = luax_checkcanvas(L, 1);
|
||||
bool requested = false;
|
||||
luax_catchexcept(L, [&](){ requested = canvas->requestImageData(); });
|
||||
luax_pushboolean(L, requested);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_Canvas_pollImageData(lua_State *L)
|
||||
{
|
||||
Canvas *canvas = luax_checkcanvas(L, 1);
|
||||
love::image::Image *image = luax_getmodule<love::image::Image>(L, love::image::Image::type);
|
||||
love::image::ImageData *data = nullptr;
|
||||
luax_catchexcept(L, [&](){ data = canvas->pollImageData(image); });
|
||||
if (data == nullptr)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
luax_pushtype(L, data);
|
||||
data->release();
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_Canvas_generateMipmaps(lua_State *L)
|
||||
{
|
||||
Canvas *c = luax_checkcanvas(L, 1);
|
||||
@@ -139,6 +164,8 @@ static const luaL_Reg w_Canvas_functions[] =
|
||||
{ "getMSAA", w_Canvas_getMSAA },
|
||||
{ "renderTo", w_Canvas_renderTo },
|
||||
{ "newImageData", w_Canvas_newImageData },
|
||||
{ "requestImageData", w_Canvas_requestImageData },
|
||||
{ "pollImageData", w_Canvas_pollImageData },
|
||||
{ "generateMipmaps", w_Canvas_generateMipmaps },
|
||||
{ "getMipmapMode", w_Canvas_getMipmapMode },
|
||||
{ 0, 0 }
|
||||
|
||||
@@ -366,6 +366,7 @@ public class GameActivity extends SDLActivity {
|
||||
Log.d("GameActivity", "Cancelling vibration");
|
||||
vibrator.cancel();
|
||||
}
|
||||
unregisterSecondaryDisplayListener();
|
||||
onHostDestroy();
|
||||
super.onDestroy();
|
||||
}
|
||||
@@ -376,6 +377,7 @@ public class GameActivity extends SDLActivity {
|
||||
Log.d("GameActivity", "Cancelling vibration");
|
||||
vibrator.cancel();
|
||||
}
|
||||
unregisterSecondaryDisplayListener();
|
||||
teardownSecondaryDisplay();
|
||||
onHostPause();
|
||||
super.onPause();
|
||||
@@ -385,6 +387,7 @@ public class GameActivity extends SDLActivity {
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
onHostResume();
|
||||
if (secondaryEnabled) registerSecondaryDisplayListener();
|
||||
setupSecondaryDisplay();
|
||||
}
|
||||
|
||||
@@ -1405,6 +1408,7 @@ public class GameActivity extends SDLActivity {
|
||||
// in src/jni/love/src/common/android.cpp.
|
||||
private static volatile SecondaryPresentation secondaryPresentation;
|
||||
private static volatile boolean secondaryEnabled = false;
|
||||
private SecondaryDisplayMonitor secondaryDisplayMonitor;
|
||||
private static final int MAX_SECONDARY_TOUCHES = 32;
|
||||
private static final java.util.ArrayDeque<String> secondaryTouches =
|
||||
new java.util.ArrayDeque<>();
|
||||
@@ -1416,11 +1420,44 @@ public class GameActivity extends SDLActivity {
|
||||
if (self == null) return;
|
||||
self.runOnUiThread(new Runnable() {
|
||||
@Override public void run() {
|
||||
if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay();
|
||||
if (on) {
|
||||
self.registerSecondaryDisplayListener();
|
||||
setupSecondaryDisplay();
|
||||
} else {
|
||||
self.unregisterSecondaryDisplayListener();
|
||||
teardownSecondaryDisplay();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void registerSecondaryDisplayListener() {
|
||||
if (secondaryDisplayMonitor != null || android.os.Build.VERSION.SDK_INT < 17) return;
|
||||
SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this);
|
||||
if (monitor.register()) secondaryDisplayMonitor = monitor;
|
||||
}
|
||||
|
||||
private void unregisterSecondaryDisplayListener() {
|
||||
SecondaryDisplayMonitor monitor = secondaryDisplayMonitor;
|
||||
secondaryDisplayMonitor = null;
|
||||
if (monitor != null) monitor.unregister();
|
||||
}
|
||||
|
||||
private static void refreshSecondaryDisplay() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || !secondaryEnabled) return;
|
||||
SecondaryPresentation current = secondaryPresentation;
|
||||
Display display = current == null ? null : current.getDisplay();
|
||||
SecondaryDisplayMonitor monitor = self.secondaryDisplayMonitor;
|
||||
if (current == null) {
|
||||
setupSecondaryDisplay();
|
||||
} else if (display == null || monitor == null
|
||||
|| !monitor.hasDisplay(display.getDisplayId())) {
|
||||
teardownSecondaryDisplay();
|
||||
setupSecondaryDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
private static void setupSecondaryDisplay() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
|
||||
@@ -1466,6 +1503,35 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
}
|
||||
|
||||
@android.annotation.TargetApi(17)
|
||||
private static class SecondaryDisplayMonitor
|
||||
implements android.hardware.display.DisplayManager.DisplayListener {
|
||||
private final android.hardware.display.DisplayManager manager;
|
||||
|
||||
SecondaryDisplayMonitor(GameActivity activity) {
|
||||
manager = (android.hardware.display.DisplayManager)
|
||||
activity.getSystemService(Context.DISPLAY_SERVICE);
|
||||
}
|
||||
|
||||
boolean register() {
|
||||
if (manager == null) return false;
|
||||
manager.registerDisplayListener(this, new Handler(Looper.getMainLooper()));
|
||||
return true;
|
||||
}
|
||||
|
||||
void unregister() {
|
||||
manager.unregisterDisplayListener(this);
|
||||
}
|
||||
|
||||
boolean hasDisplay(int displayId) {
|
||||
return manager.getDisplay(displayId) != null;
|
||||
}
|
||||
|
||||
@Override public void onDisplayAdded(int displayId) { refreshSecondaryDisplay(); }
|
||||
@Override public void onDisplayRemoved(int displayId) { refreshSecondaryDisplay(); }
|
||||
@Override public void onDisplayChanged(int displayId) { refreshSecondaryDisplay(); }
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean hasSecondaryDisplay() {
|
||||
return secondaryPresentation != null;
|
||||
|
||||
@@ -12,6 +12,20 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.95",
|
||||
"date": "2026-08-16",
|
||||
"size": 11373796,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.95/gen1recomp++-0.1.95-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.94",
|
||||
"date": "2026-08-15",
|
||||
"size": 11369457,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.94/gen1recomp++-0.1.94-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #694 Missing sound effect when falling from boulder holes\n\n## Contributors\n\n- @anxiousintrovert\n- @bryanthaboi\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.93",
|
||||
"date": "2026-08-15",
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
-- Read-only Gen 1 battle state for companion UIs and accessibility mods.
|
||||
|
||||
local Damage = require("src.battle.Damage")
|
||||
local ItemEffects = require("src.inventory.ItemEffects")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
|
||||
local BattleAPI = {}
|
||||
BattleAPI.__index = BattleAPI
|
||||
|
||||
function BattleAPI.new(game)
|
||||
return setmetatable({ game = game, revision = 0, signature = nil }, BattleAPI)
|
||||
end
|
||||
|
||||
local function activeBattle(game)
|
||||
local states = game and game.stack and game.stack.states or {}
|
||||
for i = #states, 1, -1 do
|
||||
if states[i].isBattleState then return states[i], states[#states] end
|
||||
end
|
||||
end
|
||||
|
||||
local function monCopy(data, mon, active)
|
||||
if not mon then return nil end
|
||||
local def = data.pokemon[mon.species]
|
||||
return {
|
||||
species = mon.species,
|
||||
name = mon.nickname or (def and def.name) or mon.species,
|
||||
level = mon.level, hp = mon.hp,
|
||||
maxHp = mon.stats and mon.stats.hp or mon.hp,
|
||||
status = mon.status, active = active and true or false,
|
||||
}
|
||||
end
|
||||
|
||||
local function visibleMessage(battle, top)
|
||||
local source = top and top.isTextBox and top or top == battle and battle
|
||||
local lines = source and source.visibleText and source:visibleText()
|
||||
if not lines then return nil end
|
||||
local copy = {}
|
||||
for i, line in ipairs(lines) do copy[i] = tostring(line) end
|
||||
return copy
|
||||
end
|
||||
|
||||
local function signature(game, battle, top)
|
||||
if not battle then return "none" end
|
||||
local parts = { tostring(battle), tostring(top), battle.phase or "",
|
||||
tostring(battle.turnCount or 0), tostring(#(battle.queue or {})),
|
||||
tostring(battle.current), tostring(battle.msgWaiting),
|
||||
tostring(battle.msgPrompt), tostring(battle.menuIndex),
|
||||
tostring(battle.mimicIndex),
|
||||
tostring(battle.safari and battle.safari.balls),
|
||||
tostring(battle.ghost), tostring(battle.noCatch),
|
||||
table.concat(visibleMessage(battle, top) or {}, "\n") }
|
||||
for _, battler in ipairs({ battle.player, battle.enemy }) do
|
||||
local mon = battler and battler.mon
|
||||
parts[#parts + 1] = tostring(mon)
|
||||
parts[#parts + 1] = tostring(mon and mon.hp)
|
||||
parts[#parts + 1] = tostring(mon and mon.status)
|
||||
parts[#parts + 1] = table.concat(battler and battler.curTypes or {}, ",")
|
||||
end
|
||||
for _, mon in ipairs(game.save.party or {}) do
|
||||
parts[#parts + 1] = tostring(mon)
|
||||
parts[#parts + 1] = tostring(mon.hp)
|
||||
parts[#parts + 1] = tostring(mon.status)
|
||||
end
|
||||
local inventory = {}
|
||||
for id, count in pairs(game.save.inventory or {}) do
|
||||
if ItemEffects.isBall(id) or ItemEffects.isBattleMedicine(id) then
|
||||
inventory[#inventory + 1] = id .. "=" .. tostring(count)
|
||||
end
|
||||
end
|
||||
table.sort(inventory)
|
||||
for _, item in ipairs(inventory) do parts[#parts + 1] = item end
|
||||
return table.concat(parts, "|")
|
||||
end
|
||||
|
||||
function BattleAPI:_revision(battle, top)
|
||||
local nextSignature = signature(self.game, battle, top)
|
||||
if nextSignature ~= self.signature then
|
||||
self.signature = nextSignature
|
||||
self.revision = self.revision + 1
|
||||
end
|
||||
return self.revision
|
||||
end
|
||||
|
||||
local IMMUNITY_ONLY = { SPECIAL_DAMAGE_EFFECT = true,
|
||||
SUPER_FANG_EFFECT = true, OHKO_EFFECT = true }
|
||||
|
||||
local function movePreview(battle, move, def)
|
||||
local record = battle.effectRecord and battle:effectRecord(def.effect)
|
||||
local power, typeMult = def.power or 0
|
||||
if power > 0 and move.id ~= "COUNTER" then
|
||||
local raw = TypeChart.effectiveness(def.type, battle.enemy.curTypes or {})
|
||||
if IMMUNITY_ONLY[def.effect] then
|
||||
typeMult = raw == 0 and 0 or 10
|
||||
elseif not (record and record.chooseDamage) then
|
||||
typeMult = raw
|
||||
end
|
||||
end
|
||||
local hitChance
|
||||
if record and record.neverMiss then
|
||||
hitChance = 100
|
||||
elseif not (record and record.gate)
|
||||
and (power > 0 or (record and record.accuracyChecked)) then
|
||||
hitChance = battle.enemy.invulnerable and 0
|
||||
or Damage.accuracyChance(battle.ruleset, def,
|
||||
battle.player, battle.enemy)
|
||||
end
|
||||
local displayPower = power > 0 and move.id ~= "COUNTER"
|
||||
and not IMMUNITY_ONLY[def.effect]
|
||||
and not (record and record.chooseDamage) and power or nil
|
||||
return typeMult, hitChance, displayPower
|
||||
end
|
||||
|
||||
local function moveCopies(game, battle)
|
||||
local out = {}
|
||||
for slot, move in ipairs((battle.player and battle.player.curMoves) or {}) do
|
||||
local def = game.data.moves[move.id] or {}
|
||||
local mult, hitChance, displayPower = movePreview(battle, move, def)
|
||||
out[slot] = { slot = slot, id = move.id, name = def.name or move.id,
|
||||
pp = move.pp,
|
||||
maxPp = (def.pp or move.pp or 0)
|
||||
+ (move.ppUps or 0) * math.floor((def.pp or 0) / 5),
|
||||
type = def.type, power = def.power, accuracy = def.accuracy,
|
||||
displayPower = displayPower, hitChance = hitChance,
|
||||
effectiveness = mult,
|
||||
disabled = battle.player.disabledSlot == slot }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function itemCopies(game, battle, catchable)
|
||||
local out = {}
|
||||
for id, count in pairs(game.save.inventory or {}) do
|
||||
if count > 0
|
||||
and (ItemEffects.isBall(id) or ItemEffects.isBattleMedicine(id)) then
|
||||
local def = game.data.items[id] or {}
|
||||
local ball = ItemEffects.isBall(id)
|
||||
out[#out + 1] = { id = id, name = def.name or id, count = count,
|
||||
ball = ball, needsTarget = not ball,
|
||||
catchChance = ball and catchable and battle.catchChance
|
||||
and battle:catchChance(id) or nil }
|
||||
end
|
||||
end
|
||||
table.sort(out, function(a, b) return a.name < b.name end)
|
||||
return out
|
||||
end
|
||||
|
||||
local function mimicCopies(game, battle)
|
||||
local out = {}
|
||||
for i, move in ipairs(battle.mimicMoves or {}) do
|
||||
local def = game.data.moves[move.id] or {}
|
||||
out[i] = { index = i, slot = move.slot, id = move.id,
|
||||
name = def.name or move.id }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function BattleAPI:snapshot()
|
||||
local game = self.game
|
||||
local battle, top = activeBattle(game)
|
||||
if not battle then return nil end
|
||||
local kind = battle:battleKind()
|
||||
local supported = kind ~= "oldman" and kind ~= "link"
|
||||
local catchable = kind == "wild" and not battle.ghost and not battle.noCatch
|
||||
local forcedParty = top and top.isPartyMenu and top.battle == battle
|
||||
and top.forceSwitch
|
||||
local canAdvance = supported and ((top == battle
|
||||
and battle.phase == "messages" and battle.current
|
||||
and (battle.msgWaiting or battle.msgPrompt))
|
||||
or (top and top.isTextBox and not top.choice
|
||||
and (top.waiting or top.done)))
|
||||
local prompt = "locked"
|
||||
if canAdvance then prompt = "advance"
|
||||
elseif supported and forcedParty then prompt = "party"
|
||||
elseif supported and top == battle and kind == "safari"
|
||||
and battle.phase == "menu" then prompt = "safari"
|
||||
elseif supported and top == battle and battle.phase == "mimicSelect" then
|
||||
prompt = "mimic"
|
||||
elseif supported and top == battle and battle.phase == "menu" then
|
||||
prompt = "menu"
|
||||
elseif supported and top == battle and battle.phase == "moveSelect" then
|
||||
prompt = "moves"
|
||||
end
|
||||
local party = {}
|
||||
for i, mon in ipairs(game.save.party or {}) do
|
||||
party[i] = monCopy(game.data, mon,
|
||||
battle.player and battle.player.mon == mon)
|
||||
party[i].slot = i
|
||||
end
|
||||
return { revision = self:_revision(battle, top), kind = kind,
|
||||
catchable = catchable, prompt = prompt,
|
||||
message = visibleMessage(battle, top), turn = battle.turnCount or 0,
|
||||
player = monCopy(game.data, battle.player and battle.player.mon, true),
|
||||
enemy = monCopy(game.data, battle.enemy and battle.enemy.mon, true),
|
||||
party = party, moves = moveCopies(game, battle),
|
||||
items = itemCopies(game, battle, catchable),
|
||||
safariBalls = battle.safari and battle.safari.balls or nil,
|
||||
mimicMoves = mimicCopies(game, battle), mimicIndex = battle.mimicIndex }
|
||||
end
|
||||
|
||||
local MENU_CHOICES = { fight = true, party = true, item = true, run = true }
|
||||
|
||||
local function validSlot(slot)
|
||||
return type(slot) == "number" and slot % 1 == 0 and slot >= 1
|
||||
end
|
||||
|
||||
function BattleAPI:submit(intent)
|
||||
if type(intent) ~= "table" then return nil, "intent must be a table" end
|
||||
if type(intent.id) ~= "number" or intent.id % 1 ~= 0 or intent.id < 1 then
|
||||
return nil, "intent id must be a positive integer"
|
||||
end
|
||||
if self.lastIntentId and intent.id <= self.lastIntentId then
|
||||
return nil, "replayed intent"
|
||||
end
|
||||
|
||||
local battle, top = activeBattle(self.game)
|
||||
if not battle then return nil, "no battle" end
|
||||
if intent.revision ~= self:_revision(battle, top) then
|
||||
return nil, "stale battle context"
|
||||
end
|
||||
local kind = battle:battleKind()
|
||||
if kind == "oldman" or kind == "link" or kind == "safari" then
|
||||
return nil, "battle kind is not controllable"
|
||||
end
|
||||
if top ~= battle then return nil, "battle menu is covered" end
|
||||
|
||||
local ok, err
|
||||
if intent.kind == "menu" then
|
||||
if battle.phase ~= "menu" then return nil, "battle menu is not active" end
|
||||
if not MENU_CHOICES[intent.choice] then
|
||||
return nil, "unknown battle menu choice"
|
||||
end
|
||||
ok, err = battle:chooseMenu(intent.choice)
|
||||
elseif intent.kind == "move" then
|
||||
if battle.phase ~= "moveSelect" then
|
||||
return nil, "move menu is not active"
|
||||
end
|
||||
if battle.moveSwapIndex then return nil, "move reorder is active" end
|
||||
local move = validSlot(intent.slot) and battle.player
|
||||
and battle.player.curMoves[intent.slot]
|
||||
if not move then return nil, "invalid move slot" end
|
||||
if (move.pp or 0) <= 0 then return nil, "move has no PP" end
|
||||
if battle.player.disabledSlot == intent.slot then
|
||||
return nil, "move is disabled"
|
||||
end
|
||||
ok, err = battle:chooseMove(intent.slot)
|
||||
elseif intent.kind == "back" then
|
||||
ok, err = battle:cancelMove()
|
||||
else
|
||||
return nil, "unknown battle intent"
|
||||
end
|
||||
if not ok then return nil, err end
|
||||
self.lastIntentId = intent.id
|
||||
self.signature = nil
|
||||
return true
|
||||
end
|
||||
|
||||
return BattleAPI
|
||||
+95
-52
@@ -38,6 +38,7 @@ local romText = RomText
|
||||
local BattleState = {}
|
||||
BattleState.__index = BattleState
|
||||
BattleState.isOpaque = true
|
||||
BattleState.isBattleState = true
|
||||
|
||||
-- Category identity for per-category GAME SPEED (RFC 0007), the same
|
||||
-- style OverworldController.isOverworld already uses. Every battle --
|
||||
@@ -156,6 +157,13 @@ function BattleState:caughtMarkerVisible()
|
||||
function() return false end, self) == true
|
||||
end
|
||||
|
||||
function BattleState:catchChance(ball, rateOverride)
|
||||
if Runtime.wantsHook("catch.rate") then return nil end
|
||||
return Catching.chance(ball, self.enemy.mon, self.enemy.def, rateOverride,
|
||||
{ ballDef = self:ballDef(ball), statuses = self.data.statuses,
|
||||
battle = self })
|
||||
end
|
||||
|
||||
function BattleState:moveGridNavigation()
|
||||
if self:wideLayout() then return true end
|
||||
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
|
||||
@@ -1166,7 +1174,7 @@ function BattleState:startMessage(item)
|
||||
local npos = text:find("[\n\v]", pos)
|
||||
local chunk = npos and text:sub(pos, npos - 1) or text:sub(pos)
|
||||
local codes = Font.encode(chunk)
|
||||
self.lines[#self.lines + 1] = { codes = codes, cont = cont }
|
||||
self.lines[#self.lines + 1] = { codes = codes, cont = cont, text = chunk }
|
||||
self.total = self.total + #codes
|
||||
if not npos then break end
|
||||
cont = text:sub(npos, npos) == "\v"
|
||||
@@ -1199,6 +1207,18 @@ function BattleState:beginMsgLine()
|
||||
self.shown[#self.shown + 1] = {}
|
||||
end
|
||||
|
||||
function BattleState:visibleText()
|
||||
if self.phase ~= "messages" or not (self.current or self.animPlaying) then
|
||||
return nil
|
||||
end
|
||||
local out, count = {}, #(self.shown or {})
|
||||
for i = math.max(1, self.lineIndex - count + 1), self.lineIndex do
|
||||
local line = self.lines and self.lines[i]
|
||||
if line then out[#out + 1] = line.text or "" end
|
||||
end
|
||||
return #out > 0 and out or nil
|
||||
end
|
||||
|
||||
function BattleState:updateQueue()
|
||||
if self.waitingUI then
|
||||
if self.game.stack:top() ~= self then return true end
|
||||
@@ -1931,6 +1951,77 @@ function BattleState:playerHasPP()
|
||||
return false
|
||||
end
|
||||
|
||||
-- One semantic path for the native command menu and mod.battle intents.
|
||||
function BattleState:chooseMenu(choice)
|
||||
if self.phase ~= "menu" then return nil, "battle menu is not active" end
|
||||
if not self.player or not self.player.mon or self.player.mon.hp <= 0
|
||||
or self:menuLockedAction(self.player) then
|
||||
return nil, "battle menu is not ready"
|
||||
end
|
||||
self:clearTurnFlinches()
|
||||
if choice == "fight" and self.ghost then
|
||||
self:say(Strings("%s is too\nscared to move!", self.player.name))
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
self:act(function()
|
||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||
end)
|
||||
-- A scared turn still ticks the player's residual effects.
|
||||
self:queueResidual(self.player, self.enemy)
|
||||
self:act(function() self:endOfTurn() end)
|
||||
elseif choice == "fight" then
|
||||
-- Trapping, Bide, and similar locks skip the move list.
|
||||
local fightLock = self:fightLockedAction(self.player)
|
||||
if fightLock then
|
||||
self:resolveTurn(fightLock)
|
||||
elseif not self:playerHasPP() then
|
||||
-- No usable PP goes straight to Struggle.
|
||||
self:say(Strings("%s has no\nmoves left!", self.player.name))
|
||||
self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true })
|
||||
else
|
||||
self.phase = "moveSelect"
|
||||
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
|
||||
self.moveSwapIndex = nil
|
||||
end
|
||||
elseif choice == "run" then
|
||||
self:tryRun()
|
||||
elseif choice == "item" then
|
||||
self:openItems()
|
||||
elseif choice == "party" then
|
||||
self:openParty()
|
||||
else
|
||||
return nil, "unknown battle menu choice"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleState:chooseMove(index)
|
||||
if self.phase ~= "moveSelect" then return nil, "move menu is not active" end
|
||||
local move = self.player.curMoves[index]
|
||||
if not move then return nil, "invalid move slot" end
|
||||
self.moveIndex = index
|
||||
if self.player.disabledSlot == index then
|
||||
self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!"))
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
elseif move.pp <= 0 then
|
||||
self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!"))
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
else
|
||||
self.playerMoveListIndex = index
|
||||
self:resolveTurn(move)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleState:cancelMove()
|
||||
if self.phase ~= "moveSelect" then return nil, "move menu is not active" end
|
||||
self.moveSwapIndex = nil
|
||||
self.phase = "menu"
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleState:swapMoves(i, j)
|
||||
if i == j then return end
|
||||
local moves = self.player.curMoves
|
||||
@@ -2100,42 +2191,7 @@ function BattleState:update(dt)
|
||||
self.menuIndex = row * 2 + col + 1
|
||||
if input:wasPressed("a") then
|
||||
require("src.core.Sound").play(self.data, "Press_AB")
|
||||
local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex]
|
||||
if choice == "fight" and self.ghost then
|
||||
self:say(Strings("%s is too\nscared to move!", self.player.name))
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
self:act(function()
|
||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||
end)
|
||||
-- the scared turn still ticks the player's residual (PrintGhostText
|
||||
-- -> ExecutePlayerMoveDone, core.asm:3056, 3275-3279)
|
||||
self:queueResidual(self.player, self.enemy)
|
||||
self:act(function() self:endOfTurn() end)
|
||||
elseif choice == "fight" then
|
||||
-- After the menu: own trapping/Bide or foe Wrap skips the move
|
||||
-- list and forces the locked action (core.asm:320-329)
|
||||
local fightLock = self:fightLockedAction(self.player)
|
||||
if fightLock then
|
||||
self:resolveTurn(fightLock)
|
||||
return
|
||||
end
|
||||
if not self:playerHasPP() then
|
||||
-- _NoMovesLeftText, then Struggle engages
|
||||
self:say(Strings("%s has no\nmoves left!", self.player.name))
|
||||
self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true })
|
||||
return
|
||||
end
|
||||
self.phase = "moveSelect"
|
||||
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
|
||||
self.moveSwapIndex = nil
|
||||
elseif choice == "run" then
|
||||
self:tryRun()
|
||||
elseif choice == "item" then
|
||||
self:openItems()
|
||||
else
|
||||
self:openParty()
|
||||
end
|
||||
self:chooseMenu(({ "fight", "party", "item", "run" })[self.menuIndex])
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -2164,8 +2220,7 @@ function BattleState:update(dt)
|
||||
end
|
||||
elseif input:wasPressed("b") then
|
||||
require("src.core.Sound").play(self.data, "Press_AB")
|
||||
self.moveSwapIndex = nil
|
||||
self.phase = "menu"
|
||||
self:cancelMove()
|
||||
elseif input:wasPressed("a") then
|
||||
require("src.core.Sound").play(self.data, "Press_AB")
|
||||
if self.moveSwapIndex then
|
||||
@@ -2173,19 +2228,7 @@ function BattleState:update(dt)
|
||||
self.moveSwapIndex = nil
|
||||
return
|
||||
end
|
||||
local mv = moves[self.moveIndex]
|
||||
if self.player.disabledSlot == self.moveIndex then
|
||||
self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!"))
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
elseif mv.pp <= 0 then
|
||||
self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!"))
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
else
|
||||
self.playerMoveListIndex = self.moveIndex
|
||||
self:resolveTurn(mv)
|
||||
end
|
||||
self:chooseMove(self.moveIndex)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
+29
-14
@@ -27,6 +27,17 @@ Catching.BALLS = BALLS
|
||||
-- divisor, which is what the old per-field `or` defaults resolved to
|
||||
local DEFAULT_BALL = { randMax = 255, hpFactor = 12, wobbleFactor = 150 }
|
||||
|
||||
local function stockFactors(def, targetMon, targetDef, rateOverride, statuses)
|
||||
local rate = rateOverride or targetDef.catchRate
|
||||
local record = Status.recordFor(statuses, targetMon.status)
|
||||
local statusBonus = record and record.catchBonus or 0
|
||||
local hpQuarter = math.max(1, math.floor(targetMon.hp / 4))
|
||||
local factor = def.hpFactor or DEFAULT_BALL.hpFactor
|
||||
local f = math.min(255, math.floor(math.floor(
|
||||
targetMon.stats.hp * 255 / factor) / hpQuarter))
|
||||
return rate, statusBonus, f, record
|
||||
end
|
||||
|
||||
function Catching.registerInto(registry, _, owner)
|
||||
for id, record in pairs(BALLS) do
|
||||
registry:register(id, record, owner)
|
||||
@@ -41,21 +52,9 @@ end
|
||||
local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, statuses)
|
||||
if def.autoCatch then return true, 3 end
|
||||
local randMax = def.randMax
|
||||
local rate = rateOverride or targetDef.catchRate
|
||||
|
||||
-- the status subtraction and the wobble bonus come off the merged
|
||||
-- status record (SLP/FRZ 25 and +10, the rest 12 and +5)
|
||||
local s = targetMon.status
|
||||
local record = Status.recordFor(statuses, s)
|
||||
local statusBonus = record and record.catchBonus or 0
|
||||
|
||||
-- HP factor (X)
|
||||
local maxhp = targetMon.stats.hp
|
||||
local hpQuarter = math.max(1, math.floor(targetMon.hp / 4))
|
||||
local factor = def.hpFactor or DEFAULT_BALL.hpFactor
|
||||
-- the 255 cap applies only after BOTH divisions (ItemUseBall keeps
|
||||
-- the intermediate in 16 bits); capping early collapses the value
|
||||
local f = math.min(255, math.floor(math.floor(maxhp * 255 / factor) / hpQuarter))
|
||||
local rate, statusBonus, f, record = stockFactors(
|
||||
def, targetMon, targetDef, rateOverride, statuses)
|
||||
|
||||
local function shakes()
|
||||
local ballFactor2 = def.wobbleFactor or DEFAULT_BALL.wobbleFactor
|
||||
@@ -80,6 +79,22 @@ local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, status
|
||||
return false, shakes()
|
||||
end
|
||||
|
||||
-- Exact stock catch probability for read-only previews. A custom attempt
|
||||
-- function may do anything, so nil is safer than presenting a plausible lie.
|
||||
function Catching.chance(ball, targetMon, targetDef, rateOverride, opts)
|
||||
opts = opts or {}
|
||||
local def = opts.ballDef or BALLS[ball] or DEFAULT_BALL
|
||||
if def.attempt then return nil end
|
||||
if def.autoCatch then return 100 end
|
||||
local rate, statusBonus, f = stockFactors(
|
||||
def, targetMon, targetDef, rateOverride, opts.statuses)
|
||||
local outcomes = def.randMax + 1
|
||||
local automatic = math.min(outcomes, math.max(0, statusBonus))
|
||||
local passed = math.min(outcomes, math.max(0, rate + statusBonus + 1))
|
||||
return (automatic + (passed - automatic) * (f + 1) / 256)
|
||||
* 100 / outcomes
|
||||
end
|
||||
|
||||
-- Returns caught, shakes (0-3). rateOverride replaces the species catch
|
||||
-- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate).
|
||||
-- opts (all optional): ballDef = the merged ball record, statuses = the
|
||||
|
||||
+22
-12
@@ -83,25 +83,35 @@ function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit)
|
||||
return rng(0, 255) < b
|
||||
end
|
||||
|
||||
-- Accuracy test: rand(0..255) < floor(accuracy * 255 / 100) adjusted by
|
||||
-- accuracy/evasion stages. With oneIn256Miss a max-accuracy move still
|
||||
-- misses on 255.
|
||||
function Damage.accuracyRoll(ruleset, move, attacker, defender, rng)
|
||||
rng = rng or love.math.random
|
||||
-- Exact number of the 256 RNG outcomes that pass MoveHitTest. Keeping the
|
||||
-- threshold public lets read-only UIs preview the same rules the roll uses.
|
||||
function Damage.accuracyThreshold(ruleset, move, attacker, defender)
|
||||
-- X ACCURACY sets USING_X_ACCURACY: the move simply never misses
|
||||
-- (MoveHitTest returns before any accuracy math, 1/256 included)
|
||||
if attacker.xAccuracy then return true end
|
||||
if attacker.xAccuracy then return 256 end
|
||||
local acc = math.floor(move.accuracy * 255 / 100)
|
||||
local accuracyStage = attacker.stages and attacker.stages.accuracy or 0
|
||||
local evasionStage = defender.stages and defender.stages.evasion or 0
|
||||
-- CalcHitChance scales by the accuracy stage and the evasion stage as
|
||||
-- two separate ratio multiplications, clamping each result
|
||||
acc = math.min(255, Stats.applyStage(acc,
|
||||
attacker.stages and attacker.stages.accuracy or 0))
|
||||
acc = math.min(255, Stats.applyStage(acc,
|
||||
-(defender.stages and defender.stages.evasion or 0)))
|
||||
acc = math.min(255, Stats.applyStage(acc, accuracyStage))
|
||||
acc = math.min(255, Stats.applyStage(acc, -evasionStage))
|
||||
if not ruleset.oneIn256Miss and move.accuracy >= 100
|
||||
and (attacker.stages.accuracy or 0) >= (defender.stages.evasion or 0) then
|
||||
return true
|
||||
and accuracyStage >= evasionStage then
|
||||
return 256
|
||||
end
|
||||
return acc
|
||||
end
|
||||
|
||||
function Damage.accuracyChance(ruleset, move, attacker, defender)
|
||||
return Damage.accuracyThreshold(ruleset, move, attacker, defender) * 100 / 256
|
||||
end
|
||||
|
||||
-- Accuracy test: rand(0..255) < the shared ruleset-aware threshold.
|
||||
function Damage.accuracyRoll(ruleset, move, attacker, defender, rng)
|
||||
rng = rng or love.math.random
|
||||
local acc = Damage.accuracyThreshold(ruleset, move, attacker, defender)
|
||||
if acc == 256 then return true end
|
||||
return rng(0, 255) < acc
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
-- Read-only Gen 2 battle state with the same shape as mod.battle on Gen 1.
|
||||
|
||||
local BattleAPI = {}
|
||||
BattleAPI.__index = BattleAPI
|
||||
|
||||
function BattleAPI.new(game)
|
||||
return setmetatable({ game = game, revision = 0, signature = nil }, BattleAPI)
|
||||
end
|
||||
|
||||
local function activeBattle(game)
|
||||
local states = game and game.stack and game.stack.states or {}
|
||||
local battle
|
||||
for i = #states, 1, -1 do
|
||||
local state = states[i]
|
||||
if state.screenId == "Gen2BattleState" or state.isGen2BattleState then
|
||||
battle = state
|
||||
break
|
||||
end
|
||||
end
|
||||
return battle, states[#states]
|
||||
end
|
||||
|
||||
local function monCopy(data, mon, active)
|
||||
if not mon then return nil end
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
return { species = mon.species,
|
||||
name = mon.nickname or (def and def.name) or mon.species,
|
||||
level = mon.level, hp = mon.hp,
|
||||
maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or mon.hp,
|
||||
status = mon.status, active = active and true or false }
|
||||
end
|
||||
|
||||
local function messageCopy(screen)
|
||||
if not screen.message then return nil end
|
||||
local lines = {}
|
||||
for line in tostring(screen.message):gmatch("[^\n]+") do
|
||||
lines[#lines + 1] = line
|
||||
end
|
||||
return #lines > 0 and lines or nil
|
||||
end
|
||||
|
||||
local function signature(game, screen, top)
|
||||
if not screen then return "none" end
|
||||
local battle = screen.battle or {}
|
||||
local parts = { tostring(screen), tostring(top), tostring(screen.phase),
|
||||
tostring(screen.message), tostring(screen.messageTimer),
|
||||
tostring(screen.menuIndex), tostring(screen.moveIndex),
|
||||
tostring(battle.turn), tostring(battle.over), tostring(battle.outcome) }
|
||||
for _, mon in ipairs({ battle.player, battle.enemy }) do
|
||||
parts[#parts + 1] = tostring(mon)
|
||||
parts[#parts + 1] = tostring(mon and mon.hp)
|
||||
parts[#parts + 1] = tostring(mon and mon.status)
|
||||
end
|
||||
for _, mon in ipairs((game.save and game.save.party) or battle.party or {}) do
|
||||
parts[#parts + 1] = tostring(mon)
|
||||
parts[#parts + 1] = tostring(mon.hp)
|
||||
parts[#parts + 1] = tostring(mon.status)
|
||||
end
|
||||
return table.concat(parts, "|")
|
||||
end
|
||||
|
||||
function BattleAPI:_revision(screen, top)
|
||||
local nextSignature = signature(self.game, screen, top)
|
||||
if nextSignature ~= self.signature then
|
||||
self.signature = nextSignature
|
||||
self.revision = self.revision + 1
|
||||
end
|
||||
return self.revision
|
||||
end
|
||||
|
||||
local function moveCopies(game, battle)
|
||||
local out = {}
|
||||
for slot, move in ipairs((battle.player and battle.player.moves) or {}) do
|
||||
local def = (game.data.moves or {})[move.id] or {}
|
||||
out[slot] = { slot = slot, id = move.id, name = def.name or move.id,
|
||||
pp = move.pp, maxPp = move.maxPp or def.pp or move.pp,
|
||||
type = def.type, power = def.power, accuracy = def.accuracy,
|
||||
disabled = battle:moveDisabled(battle.player, move.id) }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function battleKind(screen)
|
||||
if screen.tutorial then return "oldman" end
|
||||
return screen.battle and screen.battle.wild and "wild" or "trainer"
|
||||
end
|
||||
|
||||
function BattleAPI:snapshot()
|
||||
local game = self.game
|
||||
local screen, top = activeBattle(game)
|
||||
if not screen or not screen.battle then return nil end
|
||||
local battle = screen.battle
|
||||
local prompt = "locked"
|
||||
if top == screen and screen.phase == "menu" then
|
||||
prompt = "menu"
|
||||
elseif top == screen and screen.phase == "moves" then
|
||||
prompt = "moves"
|
||||
elseif top == screen and screen.message then
|
||||
prompt = "advance"
|
||||
elseif top and top.screenId == "Gen2PartyMenu" then
|
||||
prompt = "party"
|
||||
end
|
||||
local party = {}
|
||||
for i, mon in ipairs((game.save and game.save.party) or battle.party or {}) do
|
||||
party[i] = monCopy(game.data, mon, mon == battle.player)
|
||||
party[i].slot = i
|
||||
end
|
||||
return { revision = self:_revision(screen, top), kind = battleKind(screen),
|
||||
catchable = battle.wild and not screen.tutorial, prompt = prompt,
|
||||
message = messageCopy(screen), turn = battle.turn or 0,
|
||||
player = monCopy(game.data, battle.player, true),
|
||||
enemy = monCopy(game.data, battle.enemy, true),
|
||||
party = party, moves = moveCopies(game, battle),
|
||||
-- Gold's PACK is pocketed and target selection is screen-owned. Omit it
|
||||
-- until the engine can expose the same semantic item records as Gen 1.
|
||||
items = {} }
|
||||
end
|
||||
|
||||
local MENU_CHOICES = { fight = true, party = true, item = true, run = true }
|
||||
|
||||
local function validSlot(slot)
|
||||
return type(slot) == "number" and slot % 1 == 0 and slot >= 1
|
||||
end
|
||||
|
||||
function BattleAPI:submit(intent)
|
||||
if type(intent) ~= "table" then return nil, "intent must be a table" end
|
||||
if type(intent.id) ~= "number" or intent.id % 1 ~= 0 or intent.id < 1 then
|
||||
return nil, "intent id must be a positive integer"
|
||||
end
|
||||
if self.lastIntentId and intent.id <= self.lastIntentId then
|
||||
return nil, "replayed intent"
|
||||
end
|
||||
|
||||
local screen, top = activeBattle(self.game)
|
||||
if not screen or not screen.battle then return nil, "no battle" end
|
||||
if intent.revision ~= self:_revision(screen, top) then
|
||||
return nil, "stale battle context"
|
||||
end
|
||||
if screen.tutorial then return nil, "battle kind is not controllable" end
|
||||
if top ~= screen then return nil, "battle menu is covered" end
|
||||
|
||||
local battle = screen.battle
|
||||
local ok, err
|
||||
if intent.kind == "menu" then
|
||||
if screen.phase ~= "menu" then return nil, "battle menu is not active" end
|
||||
if not MENU_CHOICES[intent.choice] then
|
||||
return nil, "unknown battle menu choice"
|
||||
end
|
||||
ok, err = screen:chooseMenu(intent.choice)
|
||||
elseif intent.kind == "move" then
|
||||
if screen.phase ~= "moves" then return nil, "move menu is not active" end
|
||||
if screen.moveSwapIndex then return nil, "move reorder is active" end
|
||||
local move = validSlot(intent.slot) and battle.player
|
||||
and battle.player.moves and battle.player.moves[intent.slot]
|
||||
if not move then return nil, "invalid move slot" end
|
||||
if (move.pp or 0) <= 0 then return nil, "move has no PP" end
|
||||
if battle:moveDisabled(battle.player, move.id) then
|
||||
return nil, "move is disabled"
|
||||
end
|
||||
ok, err = screen:chooseMove(intent.slot)
|
||||
elseif intent.kind == "back" then
|
||||
ok, err = screen:cancelMove()
|
||||
else
|
||||
return nil, "unknown battle intent"
|
||||
end
|
||||
if not ok then return nil, err end
|
||||
self.lastIntentId = intent.id
|
||||
self.signature = nil
|
||||
return true
|
||||
end
|
||||
|
||||
return BattleAPI
|
||||
+42
-10
@@ -428,10 +428,43 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
||||
if type(body) ~= "string" then return nil, "missing body" end
|
||||
userAgent = userAgent or "gen1recomp"
|
||||
if HostShell.haveCurl() then
|
||||
-- --data-binary @- keeps the payload out of argv (command-line length
|
||||
-- io.popen is one-way on Lua/LuaJIT: its mode is "r" or "w", never
|
||||
-- "rw". Stage the request body so the response can stay on a read
|
||||
-- pipe. The staging directory comes from the OS temp contract, never
|
||||
-- tmpnam(): the CRT's tmpnam() can return a name relative to the process
|
||||
-- working directory, and a game installed under Program Files has no
|
||||
-- writable CWD -- io.open would fail before curl ever runs and postLog
|
||||
-- would silently drop the send. TEMP/TMP are per-user writable on
|
||||
-- Windows; TMPDIR (with /tmp fallback) covers POSIX. No love.filesystem:
|
||||
-- the sandbox-era transport stays on plain io/os.
|
||||
local function stagingPath()
|
||||
local dir = os.getenv("TEMP") or os.getenv("TMP")
|
||||
if not dir or dir == "" then dir = os.getenv("TMPDIR") or "/tmp" end
|
||||
local sep = dir:find("\\") and "\\" or "/"
|
||||
return dir .. sep .. ("gen1recomp-post-%d.tmp"):format(
|
||||
(os.time() % 1000000) * 100 + math.random(0, 99))
|
||||
end
|
||||
local bodyPath = stagingPath()
|
||||
local bodyFile, bodyOpenErr = io.open(bodyPath, "wb")
|
||||
if not bodyFile then
|
||||
pcall(os.remove, bodyPath)
|
||||
return nil, "could not create request body: " .. tostring(bodyOpenErr)
|
||||
end
|
||||
local bodyOk, bodyErr = pcall(function()
|
||||
assert(bodyFile:write(body))
|
||||
assert(bodyFile:close())
|
||||
end)
|
||||
if not bodyOk then
|
||||
pcall(function() bodyFile:close() end)
|
||||
pcall(os.remove, bodyPath)
|
||||
return nil, "could not write body: " .. tostring(bodyErr)
|
||||
end
|
||||
|
||||
-- --data-binary @<file> keeps the payload out of argv (command-line length
|
||||
-- limits on Windows) and preserves every byte including trailing
|
||||
-- newlines. No -f, matching httpGet: the response body is discarded
|
||||
-- anyway, and curl's stderr carries the real diagnosis on failure.
|
||||
-- newlines. The body is staged above because io.popen cannot be opened
|
||||
-- for both writing and reading. No -f, matching httpGet: the response
|
||||
-- body is discarded anyway, and curl's stderr carries the diagnosis.
|
||||
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 40)
|
||||
@@ -441,18 +474,17 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
||||
cmd = cmd .. "-H " .. HostShell.quote("Content-Type: " .. contentType) .. " "
|
||||
end
|
||||
cmd = cmd .. "-H " .. HostShell.quote("Content-Length: " .. tostring(#body)) .. " "
|
||||
.. "--data-binary @- "
|
||||
.. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " "
|
||||
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||
.. HostShell.quote(url) .. " 2>&1"
|
||||
local pipe = HostShell.popen(cmd, "rw")
|
||||
if not pipe then return nil, "could not run curl" end
|
||||
local writeOk, werr = pcall(pipe.write, pipe, body)
|
||||
if not writeOk then
|
||||
HostShell.pclose(pipe)
|
||||
return nil, "could not write body: " .. tostring(werr)
|
||||
local pipe = HostShell.popen(cmd)
|
||||
if not pipe then
|
||||
pcall(os.remove, bodyPath)
|
||||
return nil, "could not run curl"
|
||||
end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
HostShell.pclose(pipe)
|
||||
pcall(os.remove, bodyPath)
|
||||
if not readOk then
|
||||
return nil, fetchError(url, nil, tostring(out))
|
||||
end
|
||||
|
||||
@@ -83,6 +83,12 @@ function ItemEffects.healsHP(id)
|
||||
or id == "REVIVE" or id == "MAX_REVIVE"
|
||||
end
|
||||
|
||||
function ItemEffects.isBattleMedicine(id)
|
||||
return HEAL_AMOUNT[id] ~= nil or STATUS_HEAL[id] ~= nil
|
||||
or id == "MAX_POTION" or id == "FULL_RESTORE"
|
||||
or id == "REVIVE" or id == "MAX_REVIVE"
|
||||
end
|
||||
|
||||
-- Does this item need a party-member target?
|
||||
-- 'data' is optional for compat purposes; targeting falls back to itemDef/vanilla detection
|
||||
function ItemEffects.needsTarget(id, itemDef, data)
|
||||
|
||||
+10
-2
@@ -1308,7 +1308,7 @@ function Loader:_api(mod)
|
||||
-- mod.world materializes on first touch, like the image helper above: a
|
||||
-- headless load must not drag the world stack in, and the Game the facade
|
||||
-- acts on is still being wired when the entry chunk runs
|
||||
local world
|
||||
local world, battle
|
||||
setmetatable(api, { __index = function(_, key)
|
||||
-- mod.game is the live service owner, resolved per generation the way
|
||||
-- mod.world is: src/core/Game.lua's singleton under Gen 1, the Game2
|
||||
@@ -1317,9 +1317,17 @@ function Loader:_api(mod)
|
||||
-- entry chunk runs. This is what a mod should hold instead of requiring
|
||||
-- src.core.Game, which under Gold hands back a table nothing instantiated.
|
||||
if key == "game" then return loader:_game() end
|
||||
local game = loader:_game()
|
||||
if key == "battle" then
|
||||
if battle then return battle end
|
||||
local module = game and engineRequire(loader.generation == 2
|
||||
and "src.battle.gen2.BattleAPI" or "src.battle.BattleAPI")
|
||||
if not module then return nil end
|
||||
battle = module.new(game)
|
||||
return battle
|
||||
end
|
||||
if key ~= "world" then return nil end
|
||||
if world then return world end
|
||||
local game = loader:_game()
|
||||
-- one facade name, one arm per generation: Gold's world is not a stack
|
||||
-- state and its flags are a bitfield, so the resolution differs even
|
||||
-- where the method set does not (src/world/gen2/WorldAPI.lua)
|
||||
|
||||
@@ -986,12 +986,45 @@ end
|
||||
function ManagerState:buildOptionRows(m, schema)
|
||||
local rows = {}
|
||||
local modId = m.id
|
||||
local byKey, visibilityKeys = {}, {}
|
||||
for _, row in ipairs(schema) do
|
||||
if type(row) == "table" and type(row.key) == "string" then
|
||||
byKey[row.key] = row
|
||||
local condition = row.visible_if
|
||||
if type(condition) == "table" and type(condition.key) == "string" then
|
||||
visibilityKeys[condition.key] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
local function visible(row)
|
||||
local condition = row.visible_if
|
||||
if condition == nil then return true end
|
||||
if type(condition) ~= "table" or type(condition.key) ~= "string" then
|
||||
return false
|
||||
end
|
||||
local dependency = byKey[condition.key] or { key = condition.key }
|
||||
local value = self:optionValue(modId, dependency)
|
||||
if condition.equals ~= nil then return value == condition.equals end
|
||||
if condition.not_equals ~= nil then return value ~= condition.not_equals end
|
||||
return false
|
||||
end
|
||||
local function refresh(key)
|
||||
if not visibilityKeys[key] then return end
|
||||
local preferred = rows[self.cursor] and rows[self.cursor].id
|
||||
self.optionRows = self:buildOptionRows(m, schema)
|
||||
for index, candidate in ipairs(self.optionRows) do
|
||||
if candidate.id == preferred then self.cursor = index break end
|
||||
end
|
||||
self.cursor = clampIndex(self.cursor, #self.optionRows)
|
||||
end
|
||||
for _, row in ipairs(schema) do
|
||||
if type(row) ~= "table" or type(row.key) ~= "string" or row.key == ""
|
||||
or not OPTION_TYPES[row.type] then
|
||||
-- malformed rows are skipped, reported where the errors screen reads
|
||||
Runtime.reportError(modId, "options row skipped: "
|
||||
.. tostring(type(row) == "table" and (row.key or row.type) or row))
|
||||
elseif not visible(row) then
|
||||
-- Keep the row in the schema and stored options, only hide its menu row.
|
||||
elseif row.type == "toggle" then
|
||||
rows[#rows + 1] = { id = row.key, label = row.label or row.key,
|
||||
value = function()
|
||||
@@ -999,6 +1032,7 @@ function ManagerState:buildOptionRows(m, schema)
|
||||
end,
|
||||
step = function()
|
||||
self:setOption(modId, row.key, not self:optionValue(modId, row))
|
||||
refresh(row.key)
|
||||
return true
|
||||
end }
|
||||
elseif row.type == "choice" then
|
||||
@@ -1021,6 +1055,7 @@ function ManagerState:buildOptionRows(m, schema)
|
||||
end
|
||||
index = clampIndex(index + dir, #choices)
|
||||
self:setOption(modId, row.key, choices[index][2])
|
||||
refresh(row.key)
|
||||
return true
|
||||
end }
|
||||
elseif row.type == "number" then
|
||||
@@ -1036,6 +1071,7 @@ function ManagerState:buildOptionRows(m, schema)
|
||||
step = function(_, dir)
|
||||
local cur = tonumber(self:optionValue(modId, row)) or 0
|
||||
self:setOption(modId, row.key, clamp(cur + dir * (row.step or 1)))
|
||||
refresh(row.key)
|
||||
return true
|
||||
end,
|
||||
activate = function()
|
||||
@@ -1044,7 +1080,10 @@ function ManagerState:buildOptionRows(m, schema)
|
||||
max = row.max or 99,
|
||||
start = math.max(1, tonumber(self:optionValue(modId, row)) or 1),
|
||||
onDone = function(qty)
|
||||
if qty then self:setOption(modId, row.key, clamp(qty)) end
|
||||
if qty then
|
||||
self:setOption(modId, row.key, clamp(qty))
|
||||
refresh(row.key)
|
||||
end
|
||||
end,
|
||||
}))
|
||||
end }
|
||||
@@ -1061,6 +1100,7 @@ function ManagerState:buildOptionRows(m, schema)
|
||||
default = self:optionValue(modId, row),
|
||||
onDone = function(name)
|
||||
self:setOption(modId, row.key, name)
|
||||
refresh(row.key)
|
||||
end,
|
||||
}))
|
||||
end }
|
||||
@@ -1075,6 +1115,8 @@ function ManagerState:buildOptionRows(m, schema)
|
||||
self:setOption(modId, row.key, row.default)
|
||||
end
|
||||
end
|
||||
self.optionRows = self:buildOptionRows(m, schema)
|
||||
self.cursor = clampIndex(self.cursor, #self.optionRows)
|
||||
self:notify("DEFAULTS RESTORED")
|
||||
end }
|
||||
return rows
|
||||
|
||||
+8
-3
@@ -32,9 +32,14 @@ local Net = {}
|
||||
Net.MAX_INFLIGHT = 4
|
||||
-- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely.
|
||||
Net.MAX_SECONDS = 30
|
||||
-- A log body ceiling. Debug logs are kilobytes, and a server operator has no
|
||||
-- reason to accept a mod uploading arbitrary megabytes to its endpoint.
|
||||
Net.MAX_BODY = 65536
|
||||
-- A log body ceiling. A diagnostic ring (boot evidence + recent lines +
|
||||
-- status) routinely exceeds 64 KiB on a long session, so the ceiling is
|
||||
-- 512 KiB: generous for real support logs, still far under the 5 MiB the
|
||||
-- reference loghook endpoint accepts, and small enough that a misbehaving
|
||||
-- mod cannot upload arbitrary megabytes. The body is staged to a file and
|
||||
-- streamed by the transport, so the ceiling is a budget, not a memory
|
||||
-- spike; callers that stay under it never notice it.
|
||||
Net.MAX_BODY = 512 * 1024
|
||||
|
||||
local function fetch()
|
||||
return require("src.net.Fetch")
|
||||
|
||||
@@ -253,6 +253,16 @@ function TextBox:beginLine()
|
||||
table.insert(self.shown, {})
|
||||
end
|
||||
|
||||
function TextBox:visibleText()
|
||||
local page = self.pages[self.pageIndex]
|
||||
if not page then return nil end
|
||||
local out, count = {}, #(self.shown or {})
|
||||
for i = math.max(1, self.lineIndex - count + 1), self.lineIndex do
|
||||
if page[i] ~= nil then out[#out + 1] = page[i] end
|
||||
end
|
||||
return #out > 0 and out or nil
|
||||
end
|
||||
|
||||
function TextBox:update(dt)
|
||||
local input = self.game.input
|
||||
self.blink = (self.blink + 1) % 60
|
||||
|
||||
+63
-43
@@ -121,6 +121,8 @@ local TEXT_ASK_FORGET_MOVE = Strings.source(
|
||||
-- Gen 1 uses. The second label is the two-glyph <PK><MN> ligature (charmap
|
||||
-- $e1/$e2), which is what makes it fit a six-tile column.
|
||||
local MENU = { "FIGHT", "<PK><MN>", "PACK", "RUN" }
|
||||
local MENU_ACTION = { FIGHT = "fight", ["<PK><MN>"] = "party",
|
||||
PACK = "item", RUN = "run" }
|
||||
local MENU_BOX_X = 8
|
||||
local MENU_COL_SPACING = 6
|
||||
|
||||
@@ -1660,6 +1662,64 @@ function BattleState:playerMoves()
|
||||
return (self.battle and self.battle.player and self.battle.player.moves) or {}
|
||||
end
|
||||
|
||||
-- One semantic path for the native command menu and mod.battle intents.
|
||||
function BattleState:chooseMenu(choice)
|
||||
if self.phase ~= "menu" then return nil, "battle menu is not active" end
|
||||
if choice == "fight" then
|
||||
-- CheckPlayerHasUsableMoves skips MoveSelectionScreen and uses Struggle.
|
||||
local fighter = self.battle and self.battle.player
|
||||
if fighter and #self:playerMoves() > 0
|
||||
and not self.battle:hasUsableMoves(fighter) then
|
||||
self:submit({ kind = "move", move = Battle.STRUGGLE })
|
||||
else
|
||||
self.phase = "moves"
|
||||
-- MoveSelectionScreen reopens on the last used move, clamped if the
|
||||
-- moveset shrank since then.
|
||||
local moves = self:playerMoves()
|
||||
self.moveIndex = math.max(1,
|
||||
math.min(self.moveIndex or 1, math.max(1, #moves)))
|
||||
end
|
||||
elseif choice == "run" then
|
||||
self:submit({ kind = "run" })
|
||||
elseif choice == "item" then
|
||||
if self.tutorial then
|
||||
self:openTutorialPack()
|
||||
elseif self.contest then
|
||||
self:throwParkBall()
|
||||
else
|
||||
self:openPack()
|
||||
end
|
||||
elseif choice == "party" then
|
||||
self:openParty()
|
||||
else
|
||||
return nil, "unknown battle menu choice"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleState:chooseMove(index)
|
||||
if self.phase ~= "moves" then return nil, "move menu is not active" end
|
||||
local move = self:playerMoves()[index]
|
||||
if not move then return nil, "invalid move slot" end
|
||||
self.moveIndex = index
|
||||
self.moveSwapIndex = nil
|
||||
if (move.pp or 0) <= 0 then
|
||||
self:refuseMove(TEXT_NO_PP_LEFT)
|
||||
elseif self.battle:moveDisabled(self.battle.player, move.id) then
|
||||
self:refuseMove(TEXT_MOVE_DISABLED)
|
||||
else
|
||||
self:submit({ kind = "move", move = move.id })
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleState:cancelMove()
|
||||
if self.phase ~= "moves" then return nil, "move menu is not active" end
|
||||
self.moveSwapIndex = nil
|
||||
self.phase = "menu"
|
||||
return true
|
||||
end
|
||||
|
||||
-- MoveSelectionScreen's `.pressed_select` (engine/battle/core.asm:5320-5374).
|
||||
-- SELECT marks a slot, SELECT again swaps the marked slot with the one under
|
||||
-- the cursor, and A or B clears the mark without swapping (the A arm opens
|
||||
@@ -1833,37 +1893,7 @@ function BattleState:update(_dt)
|
||||
or self.menuIndex - 2
|
||||
elseif input:wasPressed("a") then
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
local choice = MENU[self.menuIndex]
|
||||
if choice == "FIGHT" then
|
||||
-- `call .CheckPlayerHasUsableMoves / ret z` (engine/battle/core.asm
|
||||
-- :5058-5059): a mon with nothing to spend never sees the list.
|
||||
local fighter = self.battle and self.battle.player
|
||||
if fighter and #self:playerMoves() > 0
|
||||
and not self.battle:hasUsableMoves(fighter) then
|
||||
return self:submit({ kind = "move", move = Battle.STRUGGLE })
|
||||
end
|
||||
self.phase = "moves"
|
||||
-- MoveSelectionScreen seeds wMenuCursorY from wCurMoveNum + 1
|
||||
-- (engine/battle/core.asm:5111) and the A-press writes the picked row
|
||||
-- back, so the list reopens on the move used last turn; only
|
||||
-- SendOutPlayerMon and CleanUpBattleRAM zero it. Clamp rather than
|
||||
-- reset, for a moveset that shrank (Mimic, a forgotten slot).
|
||||
local moves = self:playerMoves()
|
||||
self.moveIndex = math.max(1,
|
||||
math.min(self.moveIndex or 1, math.max(1, #moves)))
|
||||
elseif choice == "RUN" then
|
||||
self:submit({ kind = "run" })
|
||||
elseif choice == "PACK" then
|
||||
if self.tutorial then
|
||||
self:openTutorialPack()
|
||||
elseif self.contest then
|
||||
self:throwParkBall()
|
||||
else
|
||||
self:openPack()
|
||||
end
|
||||
else
|
||||
self:openParty()
|
||||
end
|
||||
self:chooseMenu(MENU_ACTION[MENU[self.menuIndex]])
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -1884,22 +1914,12 @@ function BattleState:update(_dt)
|
||||
elseif input:wasPressed("b") then
|
||||
-- B leaves the list, and a mark never survives it
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
self.moveSwapIndex = nil
|
||||
self.phase = "menu"
|
||||
self:cancelMove()
|
||||
elseif input:wasPressed("a") then
|
||||
-- `xor a / ld [wSwappingMove], a` opens the A arm: choosing a move
|
||||
-- cancels a pending swap rather than performing it
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
self.moveSwapIndex = nil
|
||||
local move = moves[self.moveIndex]
|
||||
if not move then return end
|
||||
-- `.no_pp_left` and `.move_disabled` both end on `jp MoveSelectionScreen`
|
||||
-- (engine/battle/core.asm:5213-5246): neither spends the turn.
|
||||
if (move.pp or 0) <= 0 then return self:refuseMove(TEXT_NO_PP_LEFT) end
|
||||
if self.battle:moveDisabled(self.battle.player, move.id) then
|
||||
return self:refuseMove(TEXT_MOVE_DISABLED)
|
||||
end
|
||||
self:submit({ kind = "move", move = move.id })
|
||||
self:chooseMove(self.moveIndex)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
@@ -95,8 +95,10 @@ end
|
||||
-- are listed; callers receive copied labels and never inspect world internals.
|
||||
function WorldAPI:availableFieldActions()
|
||||
local game, ow, out = self.game, self:overworld(), {}
|
||||
if not (game and game.save and ow and ow.map and ow.player)
|
||||
or not acceptsMenuInput(game, ow) then return out end
|
||||
if not (game and game.save and ow and ow.map and ow.player) then
|
||||
return out, NO_OVERWORLD
|
||||
end
|
||||
if not acceptsMenuInput(game, ow) then return out, "world is busy" end
|
||||
local save, inventory = game.save, game.save.inventory or {}
|
||||
local items = game.data and game.data.items or {}
|
||||
|
||||
|
||||
@@ -78,8 +78,10 @@ end
|
||||
-- collision and fishing rules.
|
||||
function WorldAPI:availableFieldActions()
|
||||
local world, game, out = self:overworld(), self.game, {}
|
||||
if not (world and game and game.save and world.map and world.player)
|
||||
or not world:acceptsMenuInput() then return out end
|
||||
if not (world and game and game.save and world.map and world.player) then
|
||||
return out, NO_OVERWORLD
|
||||
end
|
||||
if not world:acceptsMenuInput() then return out, "world is busy" end
|
||||
local inventory = game.save.inventory or {}
|
||||
|
||||
if (inventory.BICYCLE or 0) > 0 then
|
||||
|
||||
@@ -49,6 +49,16 @@ check(position("onHostPause();") < position("super.onPause();"),
|
||||
check(position("onHostDestroy();") < position("super.onDestroy();"),
|
||||
"destroy hook runs before SDL destruction")
|
||||
|
||||
check(source:find("DisplayManager.DisplayListener", 1, true)
|
||||
and source:find("registerDisplayListener", 1, true)
|
||||
and source:find("unregisterDisplayListener", 1, true),
|
||||
"secondary displays are monitored while the activity is active")
|
||||
check(position("if (secondaryEnabled) registerSecondaryDisplayListener();") <
|
||||
position("setupSecondaryDisplay();"),
|
||||
"secondary display monitoring starts before initial discovery")
|
||||
check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true),
|
||||
"a disconnected active display is rebound without replacing a live one")
|
||||
|
||||
check(not source:lower():find("openxr", 1, true),
|
||||
"generic Android activity must not require OpenXR")
|
||||
check(not source:find("QuestActivity", 1, true) and
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
-- HostShell.httpPost must work with Lua/LuaJIT's one-way io.popen.
|
||||
--
|
||||
-- io.popen accepts "r" or "w", not "rw". POST needs both a request body
|
||||
-- and a response status, so the body is staged in a temporary file and curl
|
||||
-- is opened read-only for its response.
|
||||
-- luajit tests/engine/host_shell_postlog.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
local HostShell = require("src.core.HostShell")
|
||||
|
||||
local MARK = "\n__gen1recomp_http__"
|
||||
local STAGE_DIR = "/tmp/gen1recomp-postlog-stage"
|
||||
local URL = "https://logs.example.com/logs"
|
||||
local BODY = "debug log body\n"
|
||||
|
||||
local realOpen = io.open
|
||||
local realPopen = io.popen
|
||||
local realGetenv = os.getenv
|
||||
local realRemove = os.remove
|
||||
local realHaveCurl = HostShell.haveCurl
|
||||
|
||||
local openedPath, openedMode, writtenBody
|
||||
local popenCommand, popenMode, removedPath
|
||||
|
||||
HostShell.haveCurl = function() return true end
|
||||
os.getenv = function(name)
|
||||
if name == "TEMP" or name == "TMP" or name == "TMPDIR" then
|
||||
return STAGE_DIR
|
||||
end
|
||||
return realGetenv(name)
|
||||
end
|
||||
os.remove = function(path)
|
||||
removedPath = path
|
||||
return true
|
||||
end
|
||||
|
||||
io.open = function(path, mode)
|
||||
openedPath, openedMode = path, mode
|
||||
return {
|
||||
write = function(_, value)
|
||||
writtenBody = value
|
||||
return true
|
||||
end,
|
||||
close = function() return true end,
|
||||
}
|
||||
end
|
||||
|
||||
io.popen = function(command, mode)
|
||||
popenCommand, popenMode = command, mode
|
||||
return {
|
||||
read = function() return MARK .. "200" end,
|
||||
close = function() return true end,
|
||||
}
|
||||
end
|
||||
|
||||
local ok, err = HostShell.httpPost(URL, BODY, "text/plain", "gen1recomp-mod/test", 10)
|
||||
|
||||
io.open = realOpen
|
||||
io.popen = realPopen
|
||||
os.getenv = realGetenv
|
||||
os.remove = realRemove
|
||||
HostShell.haveCurl = realHaveCurl
|
||||
|
||||
eq(ok, true, "a desktop POST succeeds through the read-only response pipe: " .. tostring(err))
|
||||
check(type(openedPath) == "string" and openedPath:find(STAGE_DIR .. "/gen1recomp-post-", 1, true) == 1, "the request body is staged under the OS temp dir")
|
||||
check(openedPath and openedPath:sub(-4) == ".tmp", "the staged body carries a .tmp name")
|
||||
eq(openedMode, "wb", "the temporary request body is opened for binary writing")
|
||||
eq(writtenBody, BODY, "the complete log body is staged")
|
||||
eq(popenMode, "r", "curl is opened in the supported read-only mode")
|
||||
check(popenCommand:find("--data-binary", 1, true) ~= nil,
|
||||
"curl reads the staged body with --data-binary")
|
||||
check(openedPath and popenCommand:find(openedPath, 1, true) ~= nil,
|
||||
"curl receives the temporary body path")
|
||||
check(popenCommand:find(BODY, 1, true) == nil,
|
||||
"the log body is not placed directly in the command line")
|
||||
eq(removedPath, openedPath, "the staged request body is removed")
|
||||
|
||||
T.finish("host shell postlog")
|
||||
@@ -0,0 +1,246 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local S = require("tests.harness").suite("mod battle snapshot")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Gen1BattleState = require("src.battle.BattleState")
|
||||
check(Gen1BattleState.isBattleState == true,
|
||||
"Gen 1 battle states carry the discovery marker")
|
||||
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load({ type_chart = {
|
||||
types = { NORMAL = { name = "NORMAL", category = "physical" } },
|
||||
matchups = {},
|
||||
} })
|
||||
|
||||
local Damage = require("src.battle.Damage")
|
||||
local attacker, defender = { stages = {} }, { stages = {} }
|
||||
eq(Damage.accuracyThreshold({ oneIn256Miss = true }, { accuracy = 100 },
|
||||
attacker, defender), 255, "faithful accuracy keeps the 1-in-256 miss")
|
||||
eq(Damage.accuracyThreshold({ oneIn256Miss = false }, { accuracy = 100 },
|
||||
attacker, defender), 256, "clean accuracy exposes a certain hit")
|
||||
local Catching = require("src.battle.Catching")
|
||||
eq(Catching.chance("MASTER_BALL", { hp = 1, stats = { hp = 1 } },
|
||||
{ catchRate = 1 }), 100, "Master Ball preview is certain")
|
||||
check(Catching.chance("MOD_BALL", { hp = 1, stats = { hp = 1 } },
|
||||
{ catchRate = 1 }, nil, { ballDef = { attempt = function() end } }) == nil,
|
||||
"custom ball logic does not receive a guessed preview")
|
||||
|
||||
local mon = { species = "TESTMON", level = 5, hp = 18,
|
||||
stats = { hp = 20 }, moves = {} }
|
||||
local game = {
|
||||
data = {
|
||||
pokemon = { TESTMON = { name = "TESTMON", catchRate = 255 } },
|
||||
moves = { TACKLE = { name = "TACKLE", type = "NORMAL", power = 35,
|
||||
accuracy = 95, pp = 35 } },
|
||||
items = { POTION = { name = "POTION" },
|
||||
POKE_BALL = { name = "POKE BALL" } },
|
||||
},
|
||||
save = { party = { mon }, inventory = { POTION = 1, POKE_BALL = 1 } },
|
||||
stack = { states = {} },
|
||||
}
|
||||
local battle = {
|
||||
isBattleState = true, phase = "menu", queue = {},
|
||||
ruleset = { oneIn256Miss = true },
|
||||
player = { mon = mon, curTypes = { "NORMAL" }, stages = {},
|
||||
curMoves = { { id = "TACKLE", pp = 35 } } },
|
||||
enemy = { mon = { species = "TESTMON", level = 4, hp = 12,
|
||||
stats = { hp = 12 }, moves = {} }, curTypes = { "NORMAL" }, stages = {} },
|
||||
}
|
||||
function battle:battleKind() return "wild" end
|
||||
function battle:effectRecord() return { accuracyChecked = true } end
|
||||
function battle:visibleText() return { "Wild TESTMON appeared!" } end
|
||||
function battle:menuLockedAction() return nil end
|
||||
function battle:chooseMenu(choice)
|
||||
self.chosenMenu = choice
|
||||
if choice == "fight" then self.phase = "moveSelect" end
|
||||
return true
|
||||
end
|
||||
function battle:chooseMove(slot)
|
||||
self.chosenMove = slot
|
||||
self.phase = "messages"
|
||||
return true
|
||||
end
|
||||
function battle:cancelMove() self.phase = "menu" return true end
|
||||
function battle:catchChance(ball)
|
||||
return require("src.battle.Catching").chance(ball, self.enemy.mon,
|
||||
game.data.pokemon[self.enemy.mon.species])
|
||||
end
|
||||
game.stack.states = { battle }
|
||||
|
||||
local api = require("src.battle.BattleAPI").new(game)
|
||||
local snapshot = api:snapshot()
|
||||
check(snapshot and snapshot.kind == "wild" and snapshot.prompt == "menu",
|
||||
"Gen 1 battle is exposed")
|
||||
eq(snapshot.player.maxHp, 20, "Gen 1 max HP comes from battle stats")
|
||||
eq(snapshot.moves[1].name, "TACKLE", "move records are copied")
|
||||
eq(snapshot.message[1], "Wild TESTMON appeared!", "battle text is copied")
|
||||
eq(#snapshot.items, 2, "medicine and balls are exposed")
|
||||
check(type(snapshot.items[1].catchChance) == "number"
|
||||
or type(snapshot.items[2].catchChance) == "number",
|
||||
"stock catch chance is available")
|
||||
snapshot.player.hp = 0
|
||||
snapshot.moves[1].pp = 0
|
||||
eq(mon.hp, 18, "changing a snapshot cannot change a Pokemon")
|
||||
eq(battle.player.curMoves[1].pp, 35,
|
||||
"changing a snapshot cannot change a move")
|
||||
local same = api:snapshot()
|
||||
eq(same.revision, snapshot.revision, "unchanged battle keeps its revision")
|
||||
battle.enemy.mon.hp = 5
|
||||
check(api:snapshot().revision > same.revision,
|
||||
"observable battle changes advance the revision")
|
||||
game.stack.states = {}
|
||||
check(api:snapshot() == nil, "Gen 1 returns nil outside a battle")
|
||||
game.stack.states = { battle }
|
||||
|
||||
local menu = api:snapshot()
|
||||
local ok, err = api:submit({ id = 1, revision = menu.revision - 1,
|
||||
kind = "menu", choice = "fight" })
|
||||
check(not ok and err == "stale battle context",
|
||||
"Gen 1 rejects a stale intent")
|
||||
ok, err = api:submit({ id = 1, revision = menu.revision,
|
||||
kind = "menu", choice = "missing" })
|
||||
check(not ok and err == "unknown battle menu choice",
|
||||
"Gen 1 rejects an unknown menu choice")
|
||||
check(api:submit({ id = 1, revision = menu.revision,
|
||||
kind = "menu", choice = "fight" }), "Gen 1 accepts a menu intent")
|
||||
eq(battle.chosenMenu, "fight", "Gen 1 uses the semantic menu path")
|
||||
ok, err = api:submit({ id = 1, revision = menu.revision,
|
||||
kind = "menu", choice = "fight" })
|
||||
check(not ok and err == "replayed intent", "Gen 1 rejects a replayed intent")
|
||||
local moveMenu = api:snapshot()
|
||||
ok, err = api:submit({ id = 2, revision = moveMenu.revision,
|
||||
kind = "move", slot = 9 })
|
||||
check(not ok and err == "invalid move slot",
|
||||
"Gen 1 rejects an invalid move slot")
|
||||
check(api:submit({ id = 2, revision = moveMenu.revision,
|
||||
kind = "move", slot = 1 }), "Gen 1 accepts a valid move")
|
||||
eq(battle.chosenMove, 1, "Gen 1 uses the semantic move path")
|
||||
battle.phase = "moveSelect"
|
||||
local back = api:snapshot()
|
||||
check(api:submit({ id = 3, revision = back.revision, kind = "back" }),
|
||||
"Gen 1 accepts move-menu back")
|
||||
eq(battle.phase, "menu", "Gen 1 back restores the command menu")
|
||||
|
||||
local player2 = { species = "CHIKORITA", level = 5, hp = 20,
|
||||
maxHp = 21, moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } }
|
||||
local enemy2 = { species = "RATTATA", level = 3, hp = 12, maxHp = 12,
|
||||
moves = {} }
|
||||
local battle2 = { player = player2, enemy = enemy2, party = { player2 },
|
||||
wild = true, turn = 0 }
|
||||
function battle2:moveDisabled() return false end
|
||||
local screen2 = { screenId = "Gen2BattleState", battle = battle2,
|
||||
phase = "menu", menuIndex = 1, moveIndex = 1 }
|
||||
function screen2:chooseMenu(choice)
|
||||
self.chosenMenu = choice
|
||||
if choice == "fight" then self.phase = "moves" end
|
||||
return true
|
||||
end
|
||||
function screen2:chooseMove(slot)
|
||||
self.chosenMove = slot
|
||||
self.phase = "resolving"
|
||||
return true
|
||||
end
|
||||
function screen2:cancelMove() self.phase = "menu" return true end
|
||||
local game2 = {
|
||||
data = {
|
||||
pokemon = { CHIKORITA = { name = "CHIKORITA" },
|
||||
RATTATA = { name = "RATTATA" } },
|
||||
moves = { TACKLE = { name = "TACKLE", type = "NORMAL",
|
||||
power = 35, accuracy = 95, pp = 35 } },
|
||||
},
|
||||
save = { party = { player2 } }, stack = { states = { screen2 } },
|
||||
}
|
||||
|
||||
local api2 = require("src.battle.gen2.BattleAPI").new(game2)
|
||||
local snapshot2 = api2:snapshot()
|
||||
check(snapshot2 and snapshot2.kind == "wild" and snapshot2.prompt == "menu",
|
||||
"Gold battle is discovered through its screen id")
|
||||
eq(snapshot2.player.maxHp, 21, "Gold max HP uses the mon field")
|
||||
eq(snapshot2.moves[1].name, "TACKLE", "Gold moves are copied")
|
||||
snapshot2.player.hp = 0
|
||||
snapshot2.moves[1].pp = 0
|
||||
eq(player2.hp, 20, "changing a snapshot cannot change a Gold Pokemon")
|
||||
eq(player2.moves[1].pp, 35,
|
||||
"changing a snapshot cannot change a Gold move")
|
||||
screen2.message = "A wild RATTATA appeared!"
|
||||
screen2.phase = "resolving"
|
||||
local message2 = api2:snapshot()
|
||||
eq(message2.prompt, "advance", "Gold message state is exposed")
|
||||
check(message2.revision > snapshot2.revision,
|
||||
"Gold battle changes advance the revision")
|
||||
game2.stack.states = {}
|
||||
check(api2:snapshot() == nil, "Gold returns nil outside a battle")
|
||||
game2.stack.states = { screen2 }
|
||||
|
||||
screen2.message = nil
|
||||
screen2.phase = "menu"
|
||||
local menu2 = api2:snapshot()
|
||||
ok, err = api2:submit({ id = 1, revision = menu2.revision - 1,
|
||||
kind = "menu", choice = "fight" })
|
||||
check(not ok and err == "stale battle context",
|
||||
"Gold rejects a stale intent")
|
||||
ok, err = api2:submit({ id = 1, revision = menu2.revision,
|
||||
kind = "menu", choice = "missing" })
|
||||
check(not ok and err == "unknown battle menu choice",
|
||||
"Gold rejects an unknown menu choice")
|
||||
check(api2:submit({ id = 1, revision = menu2.revision,
|
||||
kind = "menu", choice = "fight" }), "Gold accepts a menu intent")
|
||||
eq(screen2.chosenMenu, "fight", "Gold uses the semantic menu path")
|
||||
local moveMenu2 = api2:snapshot()
|
||||
ok, err = api2:submit({ id = 2, revision = moveMenu2.revision,
|
||||
kind = "move", slot = 9 })
|
||||
check(not ok and err == "invalid move slot",
|
||||
"Gold rejects an invalid move slot")
|
||||
check(api2:submit({ id = 2, revision = moveMenu2.revision,
|
||||
kind = "move", slot = 1 }), "Gold accepts a valid move")
|
||||
eq(screen2.chosenMove, 1, "Gold uses the semantic move path")
|
||||
screen2.phase = "moves"
|
||||
local back2 = api2:snapshot()
|
||||
check(api2:submit({ id = 3, revision = back2.revision, kind = "back" }),
|
||||
"Gold accepts move-menu back")
|
||||
eq(screen2.phase, "menu", "Gold back restores the command menu")
|
||||
|
||||
do
|
||||
local Data = require("tests.modkit").fixtures.fresh()
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||
local pressed = {}
|
||||
local game3 = { data = Data, save = save, input = {
|
||||
wasPressed = function(_, key) return pressed[key] == true end,
|
||||
isDown = function() return false end,
|
||||
}, stack = { states = {} } }
|
||||
function game3.stack:top() return self.states[#self.states] end
|
||||
function game3.stack:push(state) self.states[#self.states + 1] = state end
|
||||
local real = Gen1BattleState.newWild(game3, "FIXMON_B", 12)
|
||||
real.phase, real.queue, real.introSlide = "menu", {}, nil
|
||||
game3.stack.states = { real }
|
||||
pressed.a = true
|
||||
real:update(1 / 60)
|
||||
pressed.a = nil
|
||||
eq(real.phase, "moveSelect", "native Gen 1 FIGHT uses the semantic path")
|
||||
pressed.b = true
|
||||
real:update(1 / 60)
|
||||
pressed.b = nil
|
||||
eq(real.phase, "menu", "native Gen 1 move-menu back still works")
|
||||
end
|
||||
|
||||
local Loader = require("src.mods.Loader")
|
||||
local fs = { read = function() end, getInfo = function() end,
|
||||
getDirectoryItems = function() return {} end }
|
||||
local mod = { path = "mods/snapshot_test", manifest = {
|
||||
id = "snapshot_test", version = "1.0.0", permissionSet = {},
|
||||
} }
|
||||
local loader1 = Loader.new({ fs = fs, generation = 1 })
|
||||
loader1.game = game
|
||||
check(loader1:_api(mod).battle:snapshot().kind == "wild",
|
||||
"mod.battle selects the Gen 1 facade")
|
||||
local loader2 = Loader.new({ fs = fs, generation = 2 })
|
||||
loader2.game = game2
|
||||
check(loader2:_api(mod).battle:snapshot().kind == "wild",
|
||||
"mod.battle selects the Gen 2 facade")
|
||||
|
||||
S.finish()
|
||||
@@ -1106,6 +1106,27 @@ check(loader.modOptions.okmod.hardcore == false
|
||||
and loader.modOptions.okmod.startMoney == 3000
|
||||
and loader.modOptions.okmod.tag == "BLUE",
|
||||
"RESET DEFAULTS restores every schema default")
|
||||
|
||||
-- optional conditions keep mode-specific rows compact and refresh in place
|
||||
local conditionalSchema = {
|
||||
{ key = "mode", label = "MODE", type = "choice", default = "one",
|
||||
choices = { { "ONE", "one" }, { "TWO", "two" } } },
|
||||
{ key = "oneOnly", label = "ONE ONLY", type = "toggle", default = false,
|
||||
visible_if = { key = "mode", equals = "one" } },
|
||||
{ key = "twoOnly", label = "TWO ONLY", type = "toggle", default = false,
|
||||
visible_if = { key = "mode", equals = "two" } },
|
||||
{ key = "notOne", label = "NOT ONE", type = "toggle", default = false,
|
||||
visible_if = { key = "mode", not_equals = "one" } },
|
||||
}
|
||||
loader.modOptions.condmod = {}
|
||||
ms.cursor = 1
|
||||
ms.optionRows = ms:buildOptionRows({ id = "condmod" }, conditionalSchema)
|
||||
check(#ms.optionRows == 3 and ms.optionRows[2].id == "oneOnly",
|
||||
"visible_if uses the controlling row default")
|
||||
ms.optionRows[1].step(mgame, 1)
|
||||
check(#ms.optionRows == 4 and ms.optionRows[2].id == "twoOnly"
|
||||
and ms.optionRows[3].id == "notOne" and ms.cursor == 1,
|
||||
"editing a controller refreshes conditions without moving the cursor")
|
||||
press(ms, "b")
|
||||
check(ms.screen == "list", "B leaves the options screen")
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ function redGame.stack:top() return self.states[#self.states] end
|
||||
|
||||
local RedAPI = require("src.world.WorldAPI")
|
||||
local red = RedAPI.new(redGame, "fixture")
|
||||
local unavailable, reason = RedAPI.new({}, "fixture"):availableFieldActions()
|
||||
T.eq(#unavailable, 0, "Red lists no actions without an overworld")
|
||||
T.eq(reason, "no overworld", "Red reports a missing overworld")
|
||||
local RedWorld = require("src.world.OverworldController")
|
||||
T.check(type(RedWorld.useBicycle) == "function"
|
||||
and type(RedWorld.useFishingRod) == "function"
|
||||
@@ -59,7 +62,9 @@ T.check(not ok and err == "fishing rod unavailable",
|
||||
T.eq(redWorld.rodUsed, used, "a rejected Red rod changes nothing")
|
||||
|
||||
redWorld.player.moving = true
|
||||
T.eq(#red:availableFieldActions(), 0, "Red hides actions while moving")
|
||||
actions, err = red:availableFieldActions()
|
||||
T.eq(#actions, 0, "Red hides actions while moving")
|
||||
T.eq(err, "world is busy", "Red distinguishes a busy world from no actions")
|
||||
ok, err = red:useFieldAction("bicycle")
|
||||
T.check(not ok and err == "world is busy",
|
||||
"Red refuses a stale action while busy")
|
||||
@@ -142,6 +147,9 @@ goldWorld.fieldContext = function(_, mon) return {
|
||||
|
||||
local GoldAPI = require("src.world.gen2.WorldAPI")
|
||||
local gold = GoldAPI.new(goldGame, "fixture")
|
||||
unavailable, reason = GoldAPI.new({}, "fixture"):availableFieldActions()
|
||||
T.eq(#unavailable, 0, "Gold lists no actions without an overworld")
|
||||
T.eq(reason, "no overworld", "Gold reports a missing overworld")
|
||||
actions = gold:availableFieldActions()
|
||||
byId = {}
|
||||
for _, action in ipairs(actions) do byId[action.id] = action end
|
||||
@@ -170,4 +178,9 @@ T.check(gold:useFieldAction("squirtbottle"),
|
||||
T.eq(goldWorld.itemUsed, "SQUIRTBOTTLE",
|
||||
"Gold delegates the SquirtBottle to its field-item path")
|
||||
|
||||
goldWorld.acceptsMenuInput = function() return false end
|
||||
actions, err = gold:availableFieldActions()
|
||||
T.eq(#actions, 0, "Gold hides actions while busy")
|
||||
T.eq(err, "world is busy", "Gold distinguishes a busy world from no actions")
|
||||
|
||||
T.finish()
|
||||
|
||||
Reference in New Issue
Block a user